v2.0.12 — support channel: relay + hyprcrd plugin
Central support desk for the Oumta project. Tickets arrive through a
self-hostable support-relay (public form, SMTP out), hyprcrd pulls
them in as cards, replies go back out through the relay, and KB
fragments are plain hyprcrd cards so the board + search + tags
machinery handles them for free.
support-relay/ (standalone PHP/SQLite service)
- Public form + submit endpoint with IP rate-limit, honeypot, and an
email-matched reply path so users can follow up by typing their
OUMTA-n issue id — mismatched email falls back to a fresh ticket
so a hostile submitter can't hijack someone else's thread.
- One-shot setup wizard configures branding, issue-id prefix, public
URL, mail footer template, SMTP creds (stored AES-256-GCM at rest),
and generates the X-Support-Key API token shown exactly once.
- Hyprcrd-facing API: GET /api/hyprcrd/tickets?since=<iso>, POST
…/reply (sends via SMTP, flips status to answered), PATCH
…/status, DELETE, GET /ping, plus GET/POST /api/hyprcrd/settings
exposing brand/public_url/mail_footer for post-setup edits.
- Minimal dependency-free SMTP client covering STARTTLS/TLS/none,
AUTH LOGIN, RFC 2047 subject encoding, dot-stuffing. Extended to
emit multipart/alternative (plain+HTML) + multipart/related so
outbound mails carry the branded logo via Content-ID embedding;
plain-text fallback stays intact for older clients.
- Mail footer template supports {brand}, {issue_id}, {public_url}
placeholders. Lines with an empty placeholder are dropped, so the
"Reply via …" line vanishes cleanly when no public URL is set.
- Favicon on all public pages. Logo (SVG by default, PNG preferred
when both exist) picked up from logo2026col.* at send time.
- Docker: Dockerfile + docker-compose.yml for a stand-alone test
container, plus a support-relay service in the main compose
(profile "support") so hyprcrd can reach it at http://support-relay
without host-networking shenanigans.
public/plugins/support (hyprcrd client)
- Full-screen Support overlay: ticket list (status-filterable),
active ticket thread, reply composer with KB-fragment search +
click-to-insert, "Save as fragment" button, "Close ticket" shortcut.
- KB fragments are regular cards in a configurable bucket. The
composer just adds a fast search-and-insert path on top.
- Sync-tick hook auto-polls the relay alongside the existing CalDAV
+ relay-mail cycle.
- Settings tab in the main overlay: relay URL, API key, inbox / KB
bucket pickers, connection test, and a "Relay config" subsection
that round-trips to /api/hyprcrd/settings so the mail footer and
branding can be edited without touching the relay's DB.
- Local cache tables support_tickets + support_replies mirror the
relay's authoritative data keyed by relay_id.
Tooling
- dev.sh now opts into both relay + support profiles.
- dev-down.sh wipes support-relay volumes and bind-mount data for a
clean re-setup. Hyprcrd + relay data is preserved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
2d58cd993e
commit
7bce6a3a36
33 files changed
+2915
-10
No files matched your search
@@ -0,0 +1,398 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Support Plugin: backend (hyprcrd side).
|
||||
*
|
||||
* Talks to a standalone support-relay via HTTP. Tickets live on the
|
||||
* relay; this plugin keeps a local cache + links each ticket to a
|
||||
* hyprcrd card so the user can answer from the card detail view.
|
||||
* KB fragments stay purely in hyprcrd (cards in a configured bucket).
|
||||
*
|
||||
* Routes (auth-protected, registered via the plugin dispatch):
|
||||
* GET /api/support/tickets list from local cache
|
||||
* GET /api/support/tickets/{id} single ticket w/ replies
|
||||
* POST /api/support/tickets/{id}/reply forward reply to the relay
|
||||
* PATCH /api/support/tickets/{id}/status forward status change
|
||||
* POST /api/support/poll pull tickets from the relay
|
||||
* GET /api/support/kb?q=... search KB fragments (local cards)
|
||||
* POST /api/support/kb save a new KB fragment
|
||||
* GET /api/support/settings read plugin config (no secret)
|
||||
* POST /api/support/settings write plugin config
|
||||
* POST /api/support/settings/test ping the configured relay
|
||||
*/
|
||||
|
||||
// ── Schema ────────────────────────────────────────────────────────
|
||||
|
||||
(function () {
|
||||
$db = Database::connect();
|
||||
// Local cache of tickets that live on the relay. We map relay_id
|
||||
// (authoritative) to hyprcrd card_id so the card detail view can
|
||||
// surface the ticket + its reply history.
|
||||
$db->exec('
|
||||
CREATE TABLE IF NOT EXISTS support_tickets (
|
||||
relay_id INTEGER PRIMARY KEY,
|
||||
card_id INTEGER,
|
||||
issue_id TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
subject TEXT NOT NULL DEFAULT "",
|
||||
status TEXT NOT NULL DEFAULT "open",
|
||||
message_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_reply_at TEXT,
|
||||
polled_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
');
|
||||
$db->exec('CREATE INDEX IF NOT EXISTS idx_support_tickets_status ON support_tickets(status, updated_at DESC)');
|
||||
$db->exec('CREATE INDEX IF NOT EXISTS idx_support_tickets_card ON support_tickets(card_id)');
|
||||
|
||||
$db->exec('
|
||||
CREATE TABLE IF NOT EXISTS support_replies (
|
||||
relay_id INTEGER PRIMARY KEY,
|
||||
ticket_id INTEGER NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
sent_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (ticket_id) REFERENCES support_tickets(relay_id) ON DELETE CASCADE
|
||||
)
|
||||
');
|
||||
$db->exec('CREATE INDEX IF NOT EXISTS idx_support_replies_ticket ON support_replies(ticket_id, created_at)');
|
||||
})();
|
||||
|
||||
// ── Encryption (API key at rest) ──────────────────────────────────
|
||||
|
||||
function supportEncKey(): string {
|
||||
$db = Database::connect();
|
||||
$vk = $db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn();
|
||||
if ($vk) return substr(hash('sha256', $vk), 0, 32);
|
||||
$hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
|
||||
return substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32);
|
||||
}
|
||||
function supportEncrypt(string $plain): string {
|
||||
$iv = random_bytes(16);
|
||||
$cipher = openssl_encrypt($plain, 'aes-256-cbc', supportEncKey(), OPENSSL_RAW_DATA, $iv);
|
||||
return base64_encode($iv . $cipher);
|
||||
}
|
||||
function supportDecrypt(string $encoded): string {
|
||||
$raw = base64_decode($encoded);
|
||||
if (!$raw || strlen($raw) < 17) return '';
|
||||
return openssl_decrypt(substr($raw, 16), 'aes-256-cbc', supportEncKey(), OPENSSL_RAW_DATA, substr($raw, 0, 16)) ?: '';
|
||||
}
|
||||
|
||||
function supportSetting(string $key, ?string $default = null): ?string {
|
||||
$db = Database::connect();
|
||||
$stmt = $db->prepare('SELECT value FROM settings WHERE key = :k');
|
||||
$stmt->execute(['k' => 'support_' . $key]);
|
||||
$v = $stmt->fetchColumn();
|
||||
return $v === false ? $default : $v;
|
||||
}
|
||||
function supportSetSetting(string $key, ?string $value): void {
|
||||
$db = Database::connect();
|
||||
if ($value === null) {
|
||||
$db->prepare('DELETE FROM settings WHERE key = :k')->execute(['k' => 'support_' . $key]);
|
||||
} else {
|
||||
$db->prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (:k, :v)')
|
||||
->execute(['k' => 'support_' . $key, 'v' => $value]);
|
||||
}
|
||||
}
|
||||
|
||||
function supportRelayUrl(): string {
|
||||
return rtrim((string)supportSetting('relay_url', ''), '/');
|
||||
}
|
||||
function supportRelayKey(): string {
|
||||
$enc = supportSetting('relay_key_enc', '');
|
||||
return $enc ? supportDecrypt($enc) : '';
|
||||
}
|
||||
|
||||
function supportJson($data, int $code = 200): void {
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
/** cURL helper for the support-relay. Short timeouts — this runs inline
|
||||
* from user-triggered flows. Responses are returned as the decoded body
|
||||
* plus the HTTP status so callers can surface errors. */
|
||||
function supportCurl(string $path, string $method = 'GET', ?array $body = null): array {
|
||||
$url = supportRelayUrl() . $path;
|
||||
$key = supportRelayKey();
|
||||
if ($url === '' || $key === '') return ['status' => 0, 'error' => 'support relay not configured', 'body' => null];
|
||||
|
||||
$ch = curl_init($url);
|
||||
$headers = ['X-Support-Key: ' . $key, 'Accept: application/json'];
|
||||
$opts = [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
];
|
||||
if ($body !== null) {
|
||||
$opts[CURLOPT_POSTFIELDS] = json_encode($body);
|
||||
$opts[CURLOPT_HTTPHEADER][] = 'Content-Type: application/json';
|
||||
}
|
||||
curl_setopt_array($ch, $opts);
|
||||
if (function_exists('hyprcrdApplySafeCurlOpts')) hyprcrdApplySafeCurlOpts($ch);
|
||||
$resp = curl_exec($ch);
|
||||
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
if ($resp === false) return ['status' => 0, 'error' => $err ?: 'request failed', 'body' => null];
|
||||
$decoded = json_decode($resp, true);
|
||||
return ['status' => $status, 'body' => $decoded, 'raw' => $resp, 'error' => null];
|
||||
}
|
||||
|
||||
/** Apply a relay-side ticket (+ replies) into the local cache, creating
|
||||
* or updating the linked hyprcrd card. */
|
||||
function supportApplyTicket(array $t): void {
|
||||
$db = Database::connect();
|
||||
$relayId = (int)$t['id'];
|
||||
$issueId = (string)($t['issue_id'] ?? '');
|
||||
$email = strtolower((string)($t['email'] ?? ''));
|
||||
$subject = (string)($t['subject'] ?? '');
|
||||
$status = (string)($t['status'] ?? 'open');
|
||||
$body = (string)($t['body'] ?? '');
|
||||
|
||||
// Look up existing mapping
|
||||
$ex = $db->prepare('SELECT card_id FROM support_tickets WHERE relay_id = :r');
|
||||
$ex->execute(['r' => $relayId]);
|
||||
$cardId = (int)($ex->fetchColumn() ?: 0);
|
||||
|
||||
// Compose the card body from the ticket + reply log (relay is
|
||||
// authoritative on replies too).
|
||||
$replies = $t['replies'] ?? [];
|
||||
$rendered = "**From:** " . $email . "\n"
|
||||
. "**Issue:** " . $issueId . "\n"
|
||||
. "**Status:** " . $status . "\n\n---\n\n"
|
||||
. $body;
|
||||
foreach ($replies as $r) {
|
||||
if (($r['direction'] ?? '') === 'in' && ($r['body'] ?? '') === $body) continue; // skip initial dupe
|
||||
$label = ($r['direction'] ?? '') === 'out' ? 'Reply sent' : 'User reply';
|
||||
$when = (string)($r['created_at'] ?? '');
|
||||
$rendered .= "\n\n---\n\n**{$label} — {$when}:**\n\n" . ($r['body'] ?? '');
|
||||
}
|
||||
|
||||
$title = $subject !== '' ? $subject : '(no subject)';
|
||||
$tagSet = 'support,' . $status;
|
||||
$buckets = ['inbox' => (int)(supportSetting('bucket_inbox', '0') ?: 0)];
|
||||
$bucketId = $buckets['inbox'] ?: null;
|
||||
|
||||
if ($cardId) {
|
||||
$db->prepare('UPDATE cards SET title = :t, body = :b, tags = :tg, updated_at = datetime("now") WHERE id = :id')
|
||||
->execute(['t' => $title, 'b' => $rendered, 'tg' => $tagSet, 'id' => $cardId]);
|
||||
} else {
|
||||
$db->prepare('INSERT INTO cards (title, body, color, bucket_id, position, tags) VALUES (:t, :b, :c, :bi, -1, :tg)')
|
||||
->execute(['t' => $title, 'b' => $rendered, 'c' => '#38bdf8', 'bi' => $bucketId, 'tg' => $tagSet]);
|
||||
$cardId = (int)$db->lastInsertId();
|
||||
}
|
||||
|
||||
$db->prepare('
|
||||
INSERT INTO support_tickets (relay_id, card_id, issue_id, email, subject, status, message_id, created_at, updated_at, last_reply_at, polled_at)
|
||||
VALUES (:r, :c, :ii, :e, :s, :st, :mid, :ca, :ua, :lr, datetime("now"))
|
||||
ON CONFLICT(relay_id) DO UPDATE SET
|
||||
card_id = excluded.card_id,
|
||||
issue_id = excluded.issue_id,
|
||||
email = excluded.email,
|
||||
subject = excluded.subject,
|
||||
status = excluded.status,
|
||||
message_id = excluded.message_id,
|
||||
updated_at = excluded.updated_at,
|
||||
last_reply_at = excluded.last_reply_at,
|
||||
polled_at = datetime("now")
|
||||
')->execute([
|
||||
'r' => $relayId,
|
||||
'c' => $cardId,
|
||||
'ii' => $issueId,
|
||||
'e' => $email,
|
||||
's' => $subject,
|
||||
'st' => $status,
|
||||
'mid' => $t['message_id'] ?? null,
|
||||
'ca' => (string)($t['created_at'] ?? date('Y-m-d H:i:s')),
|
||||
'ua' => (string)($t['updated_at'] ?? date('Y-m-d H:i:s')),
|
||||
'lr' => $t['last_reply_at'] ?? null,
|
||||
]);
|
||||
|
||||
// Replies: straight upsert by relay reply id. Lets the local card
|
||||
// body stay in sync no matter which side added the reply.
|
||||
$db->prepare('DELETE FROM support_replies WHERE ticket_id = :t')->execute(['t' => $relayId]);
|
||||
$ins = $db->prepare('INSERT INTO support_replies (relay_id, ticket_id, direction, body, sent_at, created_at) VALUES (:r, :t, :d, :b, :sa, :ca)');
|
||||
foreach ($replies as $r) {
|
||||
$ins->execute([
|
||||
'r' => (int)($r['id'] ?? 0) ?: null,
|
||||
't' => $relayId,
|
||||
'd' => (string)($r['direction'] ?? 'in'),
|
||||
'b' => (string)($r['body'] ?? ''),
|
||||
'sa' => $r['sent_at'] ?? null,
|
||||
'ca' => (string)($r['created_at'] ?? date('Y-m-d H:i:s')),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Routes ────────────────────────────────────────────────────────
|
||||
|
||||
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||
$db = Database::connect();
|
||||
|
||||
if ($route === '/api/support/tickets' && $method === 'GET') {
|
||||
$status = trim((string)($_GET['status'] ?? ''));
|
||||
$sql = 'SELECT t.*, c.title AS card_title FROM support_tickets t LEFT JOIN cards c ON c.id = t.card_id';
|
||||
$params = [];
|
||||
if ($status !== '') { $sql .= ' WHERE t.status = :s'; $params['s'] = $status; }
|
||||
$sql .= ' ORDER BY COALESCE(t.last_reply_at, t.updated_at) DESC LIMIT 500';
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
supportJson(['tickets' => $stmt->fetchAll()]);
|
||||
}
|
||||
|
||||
if (preg_match('#^/api/support/tickets/(\d+)$#', $route, $m) && $method === 'GET') {
|
||||
$relayId = (int)$m[1];
|
||||
$stmt = $db->prepare('SELECT t.*, c.title AS card_title, c.body AS card_body FROM support_tickets t LEFT JOIN cards c ON c.id = t.card_id WHERE t.relay_id = :r');
|
||||
$stmt->execute(['r' => $relayId]);
|
||||
$t = $stmt->fetch();
|
||||
if (!$t) supportJson(['error' => 'not found'], 404);
|
||||
$reps = $db->prepare('SELECT direction, body, sent_at, created_at FROM support_replies WHERE ticket_id = :t ORDER BY created_at ASC');
|
||||
$reps->execute(['t' => $relayId]);
|
||||
$t['replies'] = $reps->fetchAll();
|
||||
supportJson(['ticket' => $t]);
|
||||
}
|
||||
|
||||
if (preg_match('#^/api/support/tickets/(\d+)/reply$#', $route, $m) && $method === 'POST') {
|
||||
$relayId = (int)$m[1];
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$body = trim((string)($input['body'] ?? ''));
|
||||
if ($body === '') supportJson(['error' => 'body required'], 400);
|
||||
$res = supportCurl('/api/hyprcrd/tickets/' . $relayId . '/reply', 'POST', ['body' => $body]);
|
||||
if ($res['status'] !== 200) {
|
||||
supportJson(['error' => ($res['body']['error'] ?? $res['error'] ?? 'relay error') . ' (HTTP ' . $res['status'] . ')'], 502);
|
||||
}
|
||||
// Re-fetch the updated ticket so the local cache immediately reflects
|
||||
// the new reply + answered status.
|
||||
$fresh = supportCurl('/api/hyprcrd/tickets?since=', 'GET');
|
||||
if ($fresh['status'] === 200 && is_array($fresh['body']['tickets'] ?? null)) {
|
||||
foreach ($fresh['body']['tickets'] as $t) {
|
||||
if ((int)$t['id'] === $relayId) { supportApplyTicket($t); break; }
|
||||
}
|
||||
}
|
||||
supportJson(['ok' => true]);
|
||||
}
|
||||
|
||||
if (preg_match('#^/api/support/tickets/(\d+)/status$#', $route, $m) && $method === 'PATCH') {
|
||||
$relayId = (int)$m[1];
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$status = trim((string)($input['status'] ?? ''));
|
||||
if (!in_array($status, ['open', 'answered', 'closed'], true)) supportJson(['error' => 'invalid status'], 400);
|
||||
$res = supportCurl('/api/hyprcrd/tickets/' . $relayId . '/status', 'PATCH', ['status' => $status]);
|
||||
if ($res['status'] !== 200) supportJson(['error' => 'relay error ' . $res['status']], 502);
|
||||
$db->prepare('UPDATE support_tickets SET status = :s WHERE relay_id = :r')->execute(['s' => $status, 'r' => $relayId]);
|
||||
// Sync the card's support tag set
|
||||
$db->prepare("
|
||||
UPDATE cards SET tags = (
|
||||
SELECT 'support,' || :s FROM support_tickets WHERE card_id = cards.id
|
||||
) WHERE id = (SELECT card_id FROM support_tickets WHERE relay_id = :r)
|
||||
")->execute(['s' => $status, 'r' => $relayId]);
|
||||
supportJson(['ok' => true]);
|
||||
}
|
||||
|
||||
if ($route === '/api/support/poll' && $method === 'POST') {
|
||||
$last = supportSetting('last_polled_at', '') ?: '';
|
||||
$qs = $last !== '' ? '?since=' . urlencode($last) : '';
|
||||
$res = supportCurl('/api/hyprcrd/tickets' . $qs, 'GET');
|
||||
if ($res['status'] !== 200) supportJson(['error' => ($res['body']['error'] ?? $res['error'] ?? 'relay error'), 'status' => $res['status']], 502);
|
||||
$tickets = $res['body']['tickets'] ?? [];
|
||||
$maxUpd = $last;
|
||||
foreach ($tickets as $t) {
|
||||
supportApplyTicket($t);
|
||||
if (!empty($t['updated_at']) && $t['updated_at'] > $maxUpd) $maxUpd = $t['updated_at'];
|
||||
}
|
||||
if ($maxUpd !== $last) supportSetSetting('last_polled_at', $maxUpd);
|
||||
supportJson(['ok' => true, 'pulled' => count($tickets)]);
|
||||
}
|
||||
|
||||
// KB search (local cards in the configured KB bucket)
|
||||
if ($route === '/api/support/kb' && $method === 'GET') {
|
||||
$q = trim((string)($_GET['q'] ?? ''));
|
||||
$bucket = (int)(supportSetting('bucket_kb', '0') ?: 0);
|
||||
if (!$bucket) supportJson(['fragments' => []]);
|
||||
$sql = 'SELECT id, title, body, tags, updated_at FROM cards WHERE bucket_id = :b';
|
||||
$params = ['b' => $bucket];
|
||||
if ($q !== '') {
|
||||
$sql .= ' AND (title LIKE :q OR body LIKE :q2 OR tags LIKE :q3)';
|
||||
$like = '%' . $q . '%';
|
||||
$params['q'] = $like; $params['q2'] = $like; $params['q3'] = $like;
|
||||
}
|
||||
$sql .= ' ORDER BY updated_at DESC LIMIT 50';
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
supportJson(['fragments' => $stmt->fetchAll()]);
|
||||
}
|
||||
|
||||
if ($route === '/api/support/kb' && $method === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$title = trim((string)($input['title'] ?? ''));
|
||||
$body = trim((string)($input['body'] ?? ''));
|
||||
$tags = trim((string)($input['tags'] ?? 'faq'));
|
||||
if ($body === '') supportJson(['error' => 'body required'], 400);
|
||||
if ($title === '') $title = mb_substr(str_replace("\n", ' ', $body), 0, 50);
|
||||
$bucket = (int)(supportSetting('bucket_kb', '0') ?: 0);
|
||||
if (!$bucket) supportJson(['error' => 'KB bucket not configured'], 400);
|
||||
$db->prepare('INSERT INTO cards (title, body, color, bucket_id, position, tags) VALUES (:t, :b, :c, :bi, -1, :tg)')
|
||||
->execute(['t' => $title, 'b' => $body, 'c' => '#4ade80', 'bi' => $bucket, 'tg' => $tags]);
|
||||
supportJson(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
||||
}
|
||||
|
||||
if ($route === '/api/support/settings' && $method === 'GET') {
|
||||
supportJson([
|
||||
'relay_url' => supportRelayUrl(),
|
||||
'has_key' => supportRelayKey() !== '',
|
||||
'bucket_inbox' => (int)(supportSetting('bucket_inbox', '0') ?: 0),
|
||||
'bucket_kb' => (int)(supportSetting('bucket_kb', '0') ?: 0),
|
||||
'last_polled_at' => supportSetting('last_polled_at', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($route === '/api/support/settings' && $method === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
if (array_key_exists('relay_url', $input)) supportSetSetting('relay_url', rtrim((string)$input['relay_url'], '/'));
|
||||
if (array_key_exists('relay_key', $input)) {
|
||||
$k = (string)$input['relay_key'];
|
||||
if ($k === '') supportSetSetting('relay_key_enc', null);
|
||||
else supportSetSetting('relay_key_enc', supportEncrypt($k));
|
||||
}
|
||||
if (array_key_exists('bucket_inbox', $input)) supportSetSetting('bucket_inbox', (int)$input['bucket_inbox'] ?: null);
|
||||
if (array_key_exists('bucket_kb', $input)) supportSetSetting('bucket_kb', (int)$input['bucket_kb'] ?: null);
|
||||
supportJson(['ok' => true]);
|
||||
}
|
||||
|
||||
if ($route === '/api/support/settings/test' && $method === 'POST') {
|
||||
$res = supportCurl('/api/hyprcrd/ping', 'GET');
|
||||
if ($res['status'] !== 200) supportJson(['error' => ($res['body']['error'] ?? $res['error'] ?? 'ping failed'), 'status' => $res['status']], 502);
|
||||
supportJson(['ok' => true, 'brand' => $res['body']['brand'] ?? '']);
|
||||
}
|
||||
|
||||
// Proxy to the relay's editable-settings endpoint. The plugin UI
|
||||
// uses this to let the admin tweak the mail footer / public URL /
|
||||
// brand post-setup without touching the relay's data directly.
|
||||
if ($route === '/api/support/relay-settings' && $method === 'GET') {
|
||||
$res = supportCurl('/api/hyprcrd/settings', 'GET');
|
||||
if ($res['status'] !== 200) supportJson(['error' => ($res['body']['error'] ?? $res['error'] ?? 'relay unreachable'), 'status' => $res['status']], 502);
|
||||
supportJson($res['body']);
|
||||
}
|
||||
if ($route === '/api/support/relay-settings' && $method === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$res = supportCurl('/api/hyprcrd/settings', 'POST', $input);
|
||||
if ($res['status'] !== 200) supportJson(['error' => ($res['body']['error'] ?? $res['error'] ?? 'relay update failed'), 'status' => $res['status']], 502);
|
||||
supportJson(['ok' => true]);
|
||||
}
|
||||
});
|
||||
|
||||
// Hook into the central sync.tick so support polls happen alongside the
|
||||
// CalDAV + relay-mail cycle. Runs server-side triggered from the client
|
||||
// — no separate cron needed. If the relay isn't configured the call
|
||||
// silently no-ops.
|
||||
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||
// Tie the poll to the main relay poll: when hyprcrd fires /api/relay/poll
|
||||
// the client also fires /api/support/poll. No server-side hook runs
|
||||
// here, we just expose the endpoint. See support.js.
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "support",
|
||||
"version": "1.0.0",
|
||||
"description": "Central support channel: public form for incoming tickets (OUMTA-nnn issue IDs, email-matched replies), plugin inbox with a reply composer, KB-fragment library. Outbound replies via configured SMTP.",
|
||||
"frontend": {
|
||||
"js": ["support.js"],
|
||||
"css": ["support.css"],
|
||||
"html": ["support.html"]
|
||||
},
|
||||
"backend": "backend.php"
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/* Support overlay. Full-screen like chat / email / canvas. Dark chrome,
|
||||
* two-column layout on desktop, stacked on narrow. Inbox left, ticket
|
||||
* detail + reply composer + KB picker on the right. */
|
||||
|
||||
.support-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
background: var(--t-bg);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
}
|
||||
.support-overlay.open { display: flex; }
|
||||
|
||||
.support-header {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--t-border);
|
||||
background: var(--t-surface);
|
||||
}
|
||||
.support-header-left, .support-header-right { display: flex; align-items: center; gap: 10px; }
|
||||
.support-header i { font-size: 16px; color: var(--t-accent); }
|
||||
|
||||
.support-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--t-border);
|
||||
color: var(--t-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.support-btn:hover { border-color: var(--t-accent); color: var(--t-accent); }
|
||||
.support-btn-primary {
|
||||
background: var(--t-accent);
|
||||
color: var(--t-bg);
|
||||
border-color: var(--t-accent);
|
||||
}
|
||||
.support-btn-primary:hover { filter: brightness(1.08); color: var(--t-bg); }
|
||||
.support-btn-danger:hover { border-color: var(--t-danger); color: var(--t-danger); }
|
||||
.support-btn-ghost {
|
||||
width: 30px; height: 30px; padding: 0;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.support-filter { display: flex; gap: 4px; }
|
||||
.support-pill {
|
||||
background: transparent;
|
||||
border: 1px solid var(--t-border);
|
||||
color: var(--t-muted);
|
||||
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.support-pill:hover { color: var(--t-bright); }
|
||||
.support-pill.active { background: var(--t-accent); border-color: var(--t-accent); color: var(--t-bg); }
|
||||
|
||||
.support-body {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
min-height: 0;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.support-body { grid-template-columns: 1fr; }
|
||||
.support-list { max-height: 40vh; }
|
||||
.support-list.hidden-mobile { display: none; }
|
||||
}
|
||||
|
||||
.support-list {
|
||||
border-right: 1px solid var(--t-border);
|
||||
overflow-y: auto;
|
||||
background: var(--t-bg);
|
||||
}
|
||||
.support-ticket {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--t-border);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.support-ticket:hover { background: var(--t-surface); }
|
||||
.support-ticket.active { background: color-mix(in srgb, var(--t-accent) 12%, transparent); border-left: 3px solid var(--t-accent); padding-left: 11px; }
|
||||
.support-ticket-row { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
|
||||
.support-ticket-issue {
|
||||
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||
font-size: 9px;
|
||||
color: var(--t-accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.support-ticket-status {
|
||||
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||
font-size: 8px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 2px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.support-ticket-status[data-status="open"] { background: rgba(251, 146, 60, 0.2); color: #fb923c; }
|
||||
.support-ticket-status[data-status="answered"] { background: rgba(74, 222, 128, 0.15); color: #4ade80; }
|
||||
.support-ticket-status[data-status="closed"] { background: rgba(100, 116, 139, 0.2); color: var(--t-dim); }
|
||||
.support-ticket-title { color: var(--t-bright); font-size: 13px; line-height: 1.3; }
|
||||
.support-ticket-meta { color: var(--t-dim); font-size: 11px; display: flex; gap: 8px; }
|
||||
|
||||
.support-detail {
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
.support-empty, .support-empty-hero {
|
||||
color: var(--t-dim);
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.support-empty-hero i { font-size: 48px; display: block; margin: 0 auto 12px; color: var(--t-muted); }
|
||||
|
||||
.support-ticket-header h2 { font-family: var(--t-font-pixel); color: var(--t-bright); font-size: 14px; margin: 0 0 4px; }
|
||||
.support-ticket-header .meta { color: var(--t-dim); font-size: 11px; display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.support-ticket-header .email { color: var(--t-text); }
|
||||
|
||||
.support-thread { display: flex; flex-direction: column; gap: 10px; }
|
||||
.support-msg {
|
||||
background: var(--t-surface);
|
||||
border: 1px solid var(--t-border);
|
||||
border-radius: 4px;
|
||||
padding: 12px 14px;
|
||||
white-space: pre-wrap;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.support-msg[data-dir="out"] {
|
||||
border-left: 3px solid var(--t-accent);
|
||||
padding-left: 11px;
|
||||
}
|
||||
.support-msg[data-dir="in"] {
|
||||
border-left: 3px solid #fb923c;
|
||||
padding-left: 11px;
|
||||
}
|
||||
.support-msg-label {
|
||||
font-family: var(--t-font-pixel);
|
||||
font-size: 9px;
|
||||
color: var(--t-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.support-msg-label .tag { color: var(--t-accent); }
|
||||
.support-msg-label[data-dir="in"] .tag { color: #fb923c; }
|
||||
|
||||
/* Composer */
|
||||
.support-composer {
|
||||
background: var(--t-surface);
|
||||
border: 1px solid var(--t-border);
|
||||
border-radius: 4px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.support-composer-head {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.support-composer-head .font-pixel { font-family: var(--t-font-pixel); font-size: 10px; color: var(--t-accent); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.support-kb-search {
|
||||
flex: 1;
|
||||
background: var(--t-bg);
|
||||
border: 1px solid var(--t-border);
|
||||
color: var(--t-bright);
|
||||
padding: 6px 10px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-family: var(--t-font-content, monospace);
|
||||
outline: none;
|
||||
}
|
||||
.support-kb-search:focus { border-color: var(--t-accent); }
|
||||
|
||||
.support-kb-results {
|
||||
display: none;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background: var(--t-bg);
|
||||
border: 1px solid var(--t-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.support-kb-results.open { display: block; }
|
||||
.support-kb-result {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--t-border);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.support-kb-result:hover { background: var(--t-surface); }
|
||||
.support-kb-result:last-child { border-bottom: none; }
|
||||
.support-kb-result-title { color: var(--t-bright); font-size: 12px; font-weight: 600; }
|
||||
.support-kb-result-preview { color: var(--t-dim); font-size: 11px; margin-top: 2px; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
|
||||
.support-composer textarea {
|
||||
background: var(--t-bg);
|
||||
border: 1px solid var(--t-border);
|
||||
color: var(--t-bright);
|
||||
padding: 10px 12px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--t-font-content, ui-monospace, monospace);
|
||||
font-size: 13px;
|
||||
min-height: 140px;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
}
|
||||
.support-composer textarea:focus { border-color: var(--t-accent); }
|
||||
|
||||
.support-composer-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.support-composer-actions .spacer { flex: 1; }
|
||||
.support-composer-status { font-size: 11px; color: var(--t-dim); align-self: center; margin-right: auto; }
|
||||
.support-composer-status.err { color: var(--t-danger); }
|
||||
.support-composer-status.ok { color: #4ade80; }
|
||||
|
||||
/* Settings tab (injected into the main settings overlay) */
|
||||
.support-settings-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
|
||||
.support-settings-field label {
|
||||
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||
font-size: 9px;
|
||||
color: var(--t-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.support-settings-field input, .support-settings-field select {
|
||||
background: var(--t-bg);
|
||||
border: 1px solid var(--t-border);
|
||||
color: var(--t-bright);
|
||||
padding: 8px 10px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--t-font-content, monospace);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
.support-settings-field input:focus { border-color: var(--t-accent); }
|
||||
.support-settings-help { font-size: 10px; color: var(--t-dim); line-height: 1.5; }
|
||||
.support-settings-row { display: flex; gap: 8px; align-items: center; margin-top: 12px; }
|
||||
@@ -0,0 +1,35 @@
|
||||
<!-- Support plugin overlay: ticket inbox + reply composer + KB panel.
|
||||
Rendered into the document root; shown/hidden via the .open class. -->
|
||||
<div id="support-overlay" class="support-overlay">
|
||||
<header class="support-header">
|
||||
<div class="support-header-left">
|
||||
<i class="ph ph-lifebuoy"></i>
|
||||
<span class="font-pixel text-xs text-t-bright">Support inbox</span>
|
||||
<button id="support-poll" class="support-btn support-btn-ghost" title="Poll the relay for new tickets"><i class="ph ph-arrow-clockwise"></i></button>
|
||||
</div>
|
||||
<div class="support-header-right">
|
||||
<div class="support-filter">
|
||||
<button class="support-pill active" data-filter="">all</button>
|
||||
<button class="support-pill" data-filter="open">open</button>
|
||||
<button class="support-pill" data-filter="answered">answered</button>
|
||||
<button class="support-pill" data-filter="closed">closed</button>
|
||||
</div>
|
||||
<button id="support-close" class="support-btn support-btn-ghost" title="Close (Esc)"><i class="ph ph-x"></i></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="support-body">
|
||||
<!-- Left: ticket list -->
|
||||
<aside id="support-list" class="support-list">
|
||||
<div class="support-empty">Loading…</div>
|
||||
</aside>
|
||||
|
||||
<!-- Right: active ticket detail + reply composer -->
|
||||
<section id="support-detail" class="support-detail">
|
||||
<div class="support-empty-hero">
|
||||
<i class="ph ph-envelope-open"></i>
|
||||
<p>Pick a ticket on the left.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,498 @@
|
||||
// ── Support plugin ──
|
||||
//
|
||||
// Talks to a self-hosted support-relay (see support-relay/ in the repo).
|
||||
// Renders a full-screen overlay with the ticket inbox, the active
|
||||
// ticket's thread, a reply composer with KB-fragment search + insert,
|
||||
// and a "save as fragment" button. KB fragments are plain hyprcrd cards
|
||||
// in the configured KB bucket, searched via the plugin backend.
|
||||
|
||||
const h = window.hyprcrd;
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────
|
||||
|
||||
const state = {
|
||||
tickets: [],
|
||||
filter: '', // '', 'open', 'answered', 'closed'
|
||||
activeId: null, // relay_id of the selected ticket
|
||||
kbResults: [],
|
||||
composerDirty: false,
|
||||
};
|
||||
|
||||
// ── Overlay lifecycle ───────────────────────────────────────────
|
||||
|
||||
const overlay = document.getElementById('support-overlay');
|
||||
function isOpen() { return overlay?.classList.contains('open'); }
|
||||
function show() {
|
||||
if (!overlay) return;
|
||||
overlay.classList.add('open');
|
||||
refreshTickets();
|
||||
// Hopefully poll right away so the user sees fresh data
|
||||
poll();
|
||||
}
|
||||
function hide() {
|
||||
overlay?.classList.remove('open');
|
||||
state.composerDirty = false;
|
||||
}
|
||||
window._supportToggle = () => (isOpen() ? hide() : show());
|
||||
|
||||
$('support-close')?.addEventListener('click', hide);
|
||||
$('support-poll')?.addEventListener('click', () => poll({ manual: true }));
|
||||
|
||||
// Close with Escape (respects any open child dialogs)
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape' && isOpen()) {
|
||||
e.preventDefault();
|
||||
hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Filter pills
|
||||
overlay?.querySelectorAll('.support-pill').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
overlay.querySelectorAll('.support-pill').forEach(b => b.classList.toggle('active', b === btn));
|
||||
state.filter = btn.dataset.filter || '';
|
||||
renderList();
|
||||
});
|
||||
});
|
||||
|
||||
// ── API wrappers ────────────────────────────────────────────────
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(path, {
|
||||
method: opts.method || 'GET',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': h?.util?.csrf?.() || '' },
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||
});
|
||||
const data = await res.json().catch(() => ({ error: 'bad response' }));
|
||||
if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status));
|
||||
return data;
|
||||
}
|
||||
|
||||
async function poll({ manual = false } = {}) {
|
||||
try {
|
||||
const res = await api('api/support/poll', { method: 'POST' });
|
||||
if (manual) toast(`Pulled ${res.pulled || 0} update(s)`);
|
||||
await refreshTickets();
|
||||
if (state.activeId) await refreshActive();
|
||||
} catch (e) {
|
||||
if (manual) toast('Poll failed: ' + e.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTickets() {
|
||||
try {
|
||||
const res = await api('api/support/tickets');
|
||||
state.tickets = res.tickets || [];
|
||||
renderList();
|
||||
} catch (e) {
|
||||
$('support-list').innerHTML = `<div class="support-empty" style="color:var(--t-danger);">Couldn't load: ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshActive() {
|
||||
if (!state.activeId) return;
|
||||
try {
|
||||
const res = await api('api/support/tickets/' + encodeURIComponent(state.activeId));
|
||||
renderDetail(res.ticket);
|
||||
} catch (e) {
|
||||
$('support-detail').innerHTML = `<div class="support-empty" style="color:var(--t-danger);">${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────
|
||||
|
||||
function renderList() {
|
||||
const host = $('support-list');
|
||||
if (!host) return;
|
||||
const shown = state.filter ? state.tickets.filter(t => t.status === state.filter) : state.tickets;
|
||||
if (!shown.length) {
|
||||
host.innerHTML = `<div class="support-empty">No tickets${state.filter ? ' with status ' + state.filter : ''}.</div>`;
|
||||
return;
|
||||
}
|
||||
host.innerHTML = shown.map(t => `
|
||||
<div class="support-ticket ${t.relay_id == state.activeId ? 'active' : ''}" data-id="${t.relay_id}">
|
||||
<div class="support-ticket-row">
|
||||
<span class="support-ticket-issue">${escapeHtml(t.issue_id)}</span>
|
||||
<span class="support-ticket-status" data-status="${escapeHtml(t.status)}">${escapeHtml(t.status)}</span>
|
||||
</div>
|
||||
<div class="support-ticket-title">${escapeHtml(t.subject || '(no subject)')}</div>
|
||||
<div class="support-ticket-meta">
|
||||
<span>${escapeHtml(t.email)}</span>
|
||||
<span>${escapeHtml(formatRel(t.last_reply_at || t.updated_at))}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
host.querySelectorAll('.support-ticket').forEach(el => {
|
||||
el.addEventListener('click', () => openTicket(parseInt(el.dataset.id)));
|
||||
});
|
||||
}
|
||||
|
||||
function renderDetail(t) {
|
||||
const host = $('support-detail');
|
||||
if (!host) return;
|
||||
if (!t) {
|
||||
host.innerHTML = `<div class="support-empty-hero"><i class="ph ph-envelope-open"></i><p>Pick a ticket on the left.</p></div>`;
|
||||
return;
|
||||
}
|
||||
const replies = (t.replies || []).map(r => `
|
||||
<div class="support-msg" data-dir="${escapeHtml(r.direction)}">
|
||||
<div class="support-msg-label" data-dir="${escapeHtml(r.direction)}">
|
||||
<span class="tag">${r.direction === 'out' ? 'sent' : 'received'}</span>
|
||||
<span>${escapeHtml(r.created_at)}</span>
|
||||
</div>${escapeHtml(r.body)}
|
||||
</div>
|
||||
`).join('');
|
||||
host.innerHTML = `
|
||||
<div class="support-ticket-header">
|
||||
<h2>${escapeHtml(t.subject || '(no subject)')}</h2>
|
||||
<div class="meta">
|
||||
<span class="support-ticket-issue">${escapeHtml(t.issue_id)}</span>
|
||||
<span class="support-ticket-status" data-status="${escapeHtml(t.status)}">${escapeHtml(t.status)}</span>
|
||||
<span class="email">${escapeHtml(t.email)}</span>
|
||||
<span>opened ${escapeHtml(formatRel(t.created_at))}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="support-thread">
|
||||
${replies || '<div class="support-empty" style="padding:12px;">No messages yet.</div>'}
|
||||
</div>
|
||||
<div class="support-composer">
|
||||
<div class="support-composer-head">
|
||||
<span class="font-pixel">Reply</span>
|
||||
<input class="support-kb-search" placeholder="Search fragments…" id="support-kb-input">
|
||||
<button class="support-btn support-btn-ghost" id="support-kb-toggle" title="Toggle fragment list"><i class="ph ph-books"></i></button>
|
||||
</div>
|
||||
<div id="support-kb-results" class="support-kb-results"></div>
|
||||
<textarea id="support-reply-body" placeholder="Type your reply. Click a fragment to insert it at the cursor."></textarea>
|
||||
<div class="support-composer-actions">
|
||||
<span id="support-composer-status" class="support-composer-status"></span>
|
||||
<button class="support-btn" id="support-save-fragment"><i class="ph ph-plus-circle"></i> Save as fragment</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="support-btn support-btn-ghost" id="support-status-closed" title="Close without replying">Close ticket</button>
|
||||
<button class="support-btn support-btn-primary" id="support-send-btn"><i class="ph ph-paper-plane-tilt"></i> Send reply</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
bindComposer(t);
|
||||
}
|
||||
|
||||
function bindComposer(t) {
|
||||
const kbInput = $('support-kb-input');
|
||||
const kbResults = $('support-kb-results');
|
||||
const kbToggle = $('support-kb-toggle');
|
||||
const body = $('support-reply-body');
|
||||
const sendBtn = $('support-send-btn');
|
||||
const saveFrag = $('support-save-fragment');
|
||||
const closeBtn = $('support-status-closed');
|
||||
const statusEl = $('support-composer-status');
|
||||
|
||||
// KB search: debounced
|
||||
let kbTimer = null;
|
||||
async function runKb(q) {
|
||||
try {
|
||||
const res = await api('api/support/kb?q=' + encodeURIComponent(q || ''));
|
||||
state.kbResults = res.fragments || [];
|
||||
kbResults.classList.toggle('open', !!state.kbResults.length);
|
||||
kbResults.innerHTML = state.kbResults.map((f, i) => `
|
||||
<div class="support-kb-result" data-idx="${i}">
|
||||
<div class="support-kb-result-title">${escapeHtml(f.title)}</div>
|
||||
<div class="support-kb-result-preview">${escapeHtml(stripMd(f.body).slice(0, 140))}</div>
|
||||
</div>
|
||||
`).join('') || `<div class="support-empty" style="padding:12px;">No fragments yet.</div>`;
|
||||
kbResults.querySelectorAll('.support-kb-result').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const frag = state.kbResults[parseInt(el.dataset.idx)];
|
||||
insertAtCursor(body, frag.body);
|
||||
kbResults.classList.remove('open');
|
||||
kbInput.value = '';
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
kbResults.innerHTML = `<div class="support-empty" style="padding:12px;color:var(--t-danger);">${escapeHtml(e.message)}</div>`;
|
||||
kbResults.classList.add('open');
|
||||
}
|
||||
}
|
||||
kbInput.addEventListener('input', () => {
|
||||
clearTimeout(kbTimer);
|
||||
kbTimer = setTimeout(() => runKb(kbInput.value.trim()), 120);
|
||||
});
|
||||
kbInput.addEventListener('focus', () => runKb(kbInput.value.trim()));
|
||||
kbToggle.addEventListener('click', () => {
|
||||
if (kbResults.classList.contains('open')) kbResults.classList.remove('open');
|
||||
else runKb(kbInput.value.trim());
|
||||
});
|
||||
// Close KB results when clicking outside
|
||||
document.addEventListener('click', e => {
|
||||
if (!overlay.contains(e.target)) return;
|
||||
if (kbResults.contains(e.target) || e.target === kbInput || e.target === kbToggle || kbToggle.contains(e.target)) return;
|
||||
kbResults.classList.remove('open');
|
||||
});
|
||||
|
||||
sendBtn.addEventListener('click', async () => {
|
||||
const text = body.value.trim();
|
||||
if (!text) { setStatus('Nothing to send.', 'err'); return; }
|
||||
sendBtn.disabled = true; setStatus('Sending…');
|
||||
try {
|
||||
await api('api/support/tickets/' + t.relay_id + '/reply', { method: 'POST', body: { body: text } });
|
||||
body.value = '';
|
||||
setStatus('Sent.', 'ok');
|
||||
await refreshTickets();
|
||||
await refreshActive();
|
||||
} catch (e) {
|
||||
setStatus('Failed: ' + e.message, 'err');
|
||||
} finally {
|
||||
sendBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
saveFrag.addEventListener('click', async () => {
|
||||
const text = body.value.trim();
|
||||
if (!text) { setStatus('Type something first.', 'err'); return; }
|
||||
const title = prompt('Fragment title?', text.split('\n')[0].slice(0, 60));
|
||||
if (title === null) return;
|
||||
try {
|
||||
await api('api/support/kb', { method: 'POST', body: { title: title || '', body: text } });
|
||||
setStatus('Saved to fragment library.', 'ok');
|
||||
// Refresh KB list if open
|
||||
if (kbResults.classList.contains('open')) runKb(kbInput.value.trim());
|
||||
} catch (e) {
|
||||
setStatus('Save failed: ' + e.message, 'err');
|
||||
}
|
||||
});
|
||||
|
||||
closeBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await api('api/support/tickets/' + t.relay_id + '/status', { method: 'PATCH', body: { status: 'closed' } });
|
||||
await refreshTickets();
|
||||
await refreshActive();
|
||||
} catch (e) {
|
||||
setStatus('Close failed: ' + e.message, 'err');
|
||||
}
|
||||
});
|
||||
|
||||
function setStatus(msg, kind = '') {
|
||||
statusEl.textContent = msg;
|
||||
statusEl.className = 'support-composer-status' + (kind ? ' ' + kind : '');
|
||||
if (kind === 'ok') setTimeout(() => { statusEl.textContent = ''; statusEl.className = 'support-composer-status'; }, 3500);
|
||||
}
|
||||
}
|
||||
|
||||
async function openTicket(id) {
|
||||
state.activeId = id;
|
||||
renderList();
|
||||
await refreshActive();
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
/** Strip Markdown markers for preview purposes (headings, emphasis). */
|
||||
function stripMd(s) {
|
||||
return String(s || '')
|
||||
.replace(/^#+\s+/gm, '')
|
||||
.replace(/[*_`]/g, '')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Human-friendly timestamp: "just now", "14m ago", "2h ago", "Tue 14:02". */
|
||||
function formatRel(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso.replace(' ', 'T') + (iso.includes('T') || iso.endsWith('Z') ? '' : 'Z'));
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
const now = new Date();
|
||||
const diff = (now - d) / 1000;
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||
if (diff < 86400 * 7) return d.toLocaleDateString(undefined, { weekday: 'short' }) + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
function insertAtCursor(textarea, text) {
|
||||
const start = textarea.selectionStart, end = textarea.selectionEnd;
|
||||
const before = textarea.value.slice(0, start);
|
||||
const after = textarea.value.slice(end);
|
||||
const prefix = (before && !before.endsWith('\n')) ? '\n' : '';
|
||||
textarea.value = before + prefix + text + after;
|
||||
const caret = (before + prefix + text).length;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(caret, caret);
|
||||
}
|
||||
|
||||
function toast(msg, kind = 'info') {
|
||||
// Piggy-back on the simple notification system from relay sync
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid ' + (kind === 'err' ? 'var(--t-danger)' : 'var(--t-accent)') + ';border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:300px;font-family:var(--t-font-pixel);font-size:10px;color:' + (kind === 'err' ? 'var(--t-danger)' : 'var(--t-accent)') + ';';
|
||||
el.textContent = msg;
|
||||
document.body.appendChild(el);
|
||||
setTimeout(() => { el.style.transition = 'opacity 0.3s'; el.style.opacity = '0'; setTimeout(() => el.remove(), 350); }, 3500);
|
||||
}
|
||||
|
||||
// ── Header button ───────────────────────────────────────────────
|
||||
|
||||
function injectHeaderButton() {
|
||||
const tasksBtn = document.getElementById('tasks-btn');
|
||||
if (!tasksBtn || document.getElementById('support-btn')) return;
|
||||
const btn = document.createElement('button');
|
||||
btn.id = 'support-btn';
|
||||
btn.className = 'flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors';
|
||||
btn.innerHTML = '<i class="ph ph-lifebuoy text-sm"></i><span class="font-pixel text-[10px]">Support</span>';
|
||||
btn.addEventListener('click', () => window._supportToggle?.());
|
||||
tasksBtn.parentNode.insertBefore(btn, tasksBtn);
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', injectHeaderButton);
|
||||
} else {
|
||||
injectHeaderButton();
|
||||
}
|
||||
|
||||
// ── Settings tab in the main settings overlay ───────────────────
|
||||
|
||||
function renderSettingsPanel(panelEl) {
|
||||
panelEl.innerHTML = `
|
||||
<p class="support-settings-help" style="margin-bottom:14px;">
|
||||
Connect hyprcrd to your support relay. The relay hosts the public form and
|
||||
sends outgoing replies via SMTP; hyprcrd mirrors tickets and lets you reply
|
||||
from any card.
|
||||
</p>
|
||||
<div class="support-settings-field">
|
||||
<label for="support-set-url">Relay URL</label>
|
||||
<input id="support-set-url" type="url" placeholder="https://support.example.com">
|
||||
</div>
|
||||
<div class="support-settings-field">
|
||||
<label for="support-set-key">API key</label>
|
||||
<input id="support-set-key" type="password" placeholder="paste the key shown by the relay setup" autocomplete="off">
|
||||
<div class="support-settings-help">Leave empty to keep the currently stored key.</div>
|
||||
</div>
|
||||
<div class="support-settings-field">
|
||||
<label for="support-set-inbox">Inbox bucket</label>
|
||||
<select id="support-set-inbox"><option value="0">(none — keep cards unsorted)</option></select>
|
||||
<div class="support-settings-help">Where newly-pulled ticket cards will land.</div>
|
||||
</div>
|
||||
<div class="support-settings-field">
|
||||
<label for="support-set-kb">KB bucket</label>
|
||||
<select id="support-set-kb"><option value="0">(none — disable fragment library)</option></select>
|
||||
<div class="support-settings-help">Cards in this bucket become reusable reply fragments.</div>
|
||||
</div>
|
||||
<div class="support-settings-row">
|
||||
<button id="support-set-save" class="support-btn"><i class="ph ph-floppy-disk"></i> Save</button>
|
||||
<button id="support-set-test" class="support-btn"><i class="ph ph-pulse"></i> Test connection</button>
|
||||
<span id="support-set-status" class="support-settings-help"></span>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-top:20px;font-family:var(--t-font-pixel);font-size:12px;color:var(--t-accent);text-transform:uppercase;letter-spacing:0.5px;">Relay config</h2>
|
||||
<p class="support-settings-help" style="margin-bottom:14px;">
|
||||
Editable post-setup values on the connected relay. Mail footer supports
|
||||
<code>{brand}</code>, <code>{issue_id}</code>, <code>{public_url}</code>
|
||||
placeholders — lines containing an empty placeholder are dropped.
|
||||
</p>
|
||||
<div class="support-settings-field">
|
||||
<label for="support-rs-brand">Brand</label>
|
||||
<input id="support-rs-brand" type="text">
|
||||
</div>
|
||||
<div class="support-settings-field">
|
||||
<label for="support-rs-public">Public form URL</label>
|
||||
<input id="support-rs-public" type="url" placeholder="https://support.oumta.de">
|
||||
</div>
|
||||
<div class="support-settings-field">
|
||||
<label for="support-rs-footer">Mail footer template</label>
|
||||
<textarea id="support-rs-footer" rows="4" style="background:var(--t-bg);border:1px solid var(--t-border);color:var(--t-bright);padding:8px 10px;border-radius:3px;font-family:var(--t-font-content,monospace);font-size:12px;outline:none;resize:vertical;"></textarea>
|
||||
<div class="support-settings-help" id="support-rs-footer-default"></div>
|
||||
</div>
|
||||
<div class="support-settings-row">
|
||||
<button id="support-rs-save" class="support-btn"><i class="ph ph-floppy-disk"></i> Save relay config</button>
|
||||
<span id="support-rs-status" class="support-settings-help"></span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Populate bucket selects from state.buckets
|
||||
const opts = (window.state?.buckets || []).map(b => `<option value="${b.id}">${escapeHtml(b.name)}</option>`).join('');
|
||||
panelEl.querySelector('#support-set-inbox').innerHTML += opts;
|
||||
panelEl.querySelector('#support-set-kb').innerHTML += opts;
|
||||
|
||||
api('api/support/settings').then(cur => {
|
||||
panelEl.querySelector('#support-set-url').value = cur.relay_url || '';
|
||||
panelEl.querySelector('#support-set-key').placeholder = cur.has_key ? '•••••••• (saved — leave empty to keep)' : 'paste the key shown by the relay setup';
|
||||
if (cur.bucket_inbox) panelEl.querySelector('#support-set-inbox').value = cur.bucket_inbox;
|
||||
if (cur.bucket_kb) panelEl.querySelector('#support-set-kb').value = cur.bucket_kb;
|
||||
}).catch(() => {});
|
||||
|
||||
const statusEl = panelEl.querySelector('#support-set-status');
|
||||
function setStatus(msg, kind = '') {
|
||||
statusEl.textContent = msg;
|
||||
statusEl.style.color = kind === 'err' ? 'var(--t-danger)' : kind === 'ok' ? '#4ade80' : 'var(--t-dim)';
|
||||
}
|
||||
|
||||
panelEl.querySelector('#support-set-save').addEventListener('click', async () => {
|
||||
const payload = {
|
||||
relay_url: panelEl.querySelector('#support-set-url').value.trim(),
|
||||
bucket_inbox: parseInt(panelEl.querySelector('#support-set-inbox').value) || 0,
|
||||
bucket_kb: parseInt(panelEl.querySelector('#support-set-kb').value) || 0,
|
||||
};
|
||||
const key = panelEl.querySelector('#support-set-key').value;
|
||||
if (key) payload.relay_key = key;
|
||||
try {
|
||||
await api('api/support/settings', { method: 'POST', body: payload });
|
||||
setStatus('Saved.', 'ok');
|
||||
} catch (e) { setStatus(e.message, 'err'); }
|
||||
});
|
||||
|
||||
panelEl.querySelector('#support-set-test').addEventListener('click', async () => {
|
||||
setStatus('Testing…');
|
||||
try {
|
||||
const r = await api('api/support/settings/test', { method: 'POST' });
|
||||
setStatus('OK — relay says "' + (r.brand || 'support') + '"', 'ok');
|
||||
} catch (e) { setStatus(e.message, 'err'); }
|
||||
});
|
||||
|
||||
// Relay config (brand / public url / footer). Loaded after a tick so
|
||||
// the local-settings render happens first — keeps this lazy, and
|
||||
// silently hides if the relay isn't reachable.
|
||||
const rsStatus = panelEl.querySelector('#support-rs-status');
|
||||
function setRsStatus(msg, kind = '') {
|
||||
rsStatus.textContent = msg;
|
||||
rsStatus.style.color = kind === 'err' ? 'var(--t-danger)' : kind === 'ok' ? '#4ade80' : 'var(--t-dim)';
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const rs = await api('api/support/relay-settings');
|
||||
panelEl.querySelector('#support-rs-brand').value = rs.brand || '';
|
||||
panelEl.querySelector('#support-rs-public').value = rs.public_url || '';
|
||||
panelEl.querySelector('#support-rs-footer').value = rs.mail_footer || '';
|
||||
const def = panelEl.querySelector('#support-rs-footer-default');
|
||||
if (def && rs.default_footer) def.textContent = 'Default if left empty: ' + rs.default_footer.replace(/\n/g, ' ⏎ ');
|
||||
} catch (e) {
|
||||
setRsStatus('(relay unreachable — configure above first)', 'err');
|
||||
}
|
||||
})();
|
||||
panelEl.querySelector('#support-rs-save').addEventListener('click', async () => {
|
||||
setRsStatus('Saving…');
|
||||
try {
|
||||
await api('api/support/relay-settings', { method: 'POST', body: {
|
||||
brand: panelEl.querySelector('#support-rs-brand').value.trim(),
|
||||
public_url: panelEl.querySelector('#support-rs-public').value.trim(),
|
||||
mail_footer: panelEl.querySelector('#support-rs-footer').value,
|
||||
}});
|
||||
setRsStatus('Saved.', 'ok');
|
||||
} catch (e) {
|
||||
setRsStatus(e.message, 'err');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
h?.settings?.registerTab?.({
|
||||
id: 'support',
|
||||
label: 'support',
|
||||
icon: 'ph-lifebuoy',
|
||||
onShow: renderSettingsPanel,
|
||||
});
|
||||
|
||||
// Hook the sync tick so each user-triggered sync also pulls support.
|
||||
h?.hooks?.on?.('sync.tick', () => { poll().catch(() => {}); });
|
||||
|
||||
h?.registerPlugin?.({ name: 'support', version: '1.0.0' });
|
||||
Reference in new issue
Block a user