diff options
Diffstat (limited to 'modules/core/module-debug-supervisor.js')
| -rw-r--r-- | modules/core/module-debug-supervisor.js | 159 |
1 files changed, 158 insertions, 1 deletions
diff --git a/modules/core/module-debug-supervisor.js b/modules/core/module-debug-supervisor.js index 25e0566..ed97aff 100644 --- a/modules/core/module-debug-supervisor.js +++ b/modules/core/module-debug-supervisor.js @@ -63,6 +63,9 @@ class DebugSupervisorModule { this.commands['feedback-inbox'] = () => this._cmdFeedbackInbox(); this.commands['hermes-poll'] = () => this._cmdFeedbackInbox(); this.commands['hermes-reset'] = () => this._cmdChatReset(); + // Admin management of stored /stear reports (2026-10-26). `feedback <text>` still + // sends a chat message: a subcommand needs plausible arguments (see _cmdFeedback). + this.commands['feedback-admin'] = (args) => this._fbDispatch(args); } seed() { @@ -960,9 +963,25 @@ class DebugSupervisorModule { } _cmdFeedback(args) { - const msg = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim(); + const raw = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim(); + const argv = raw.split(/\s+/); + const sub = (argv[0] || '').toLowerCase(); + // A subcommand only when its ARGUMENTS are plausible. Measured failure without this + // check: "feedback list is broken on mobile" became a filtered list of reports. + const STATUS = ['', 'new', 'read', 'archived']; + const isNum = (v) => /^\d+$/.test(v || ''); + const forced = raw.startsWith('--'); + const isSub = !forced && ( + (sub === 'list' && STATUS.indexOf((argv[1] || '').toLowerCase()) >= 0) || + ((sub === 'stats' || sub === 'dashboard' || sub === 'help') && argv.length === 1) || + (['read', 'archive', 'reopen'].indexOf(sub) >= 0 && isNum(argv[1])) || + (sub === 'reply' && isNum(argv[1]) && argv.length >= 3) + ); + if (isSub) { this._fbDispatch(args); return; } + const msg = forced ? raw.replace(/^--\s*/, '') : raw; if (!msg) { this.terminal.print('usage: hermes <message> (alias: feedback <message>)', 'error'); + this.terminal.print(' feedback list|read|reply|archive|reopen|stats|dashboard|help', ''); return; } this.terminal.print('π¨ Sendingβ¦', ''); @@ -998,6 +1017,144 @@ class DebugSupervisorModule { }); } + // ββ feedback management (admin) ββββββββββββββββββββββββββββββββββββββββββ + _fbHelp() { + const rows = [ + 'feedback <message> send a message to the operator agent (alias: hermes)', + 'feedback list [status] list stored /stear reports (new|read|archived, default all)', + 'feedback read <id> show one report in full', + 'feedback reply <id> <t> publish a reply (status becomes read, user sees it)', + 'feedback archive <id> archive a report', + 'feedback reopen <id> move a report back to new', + 'feedback stats counts per status', + 'feedback dashboard open /admin/feedback.html in a new tab', + 'feedback-inbox read agent replies from the chat bridge', + ]; + this.terminal.print('feedback β commands', ''); + rows.forEach(r => this.terminal.print(' ' + r, '')); + this.terminal.print(' admin subcommands need a valid admin session (log in at /admin-login.html)', ''); + } + + async _fbToken() { + if (!this._fbKey) { + const r = await fetch('/admin/api/admin-token.php', { credentials: 'same-origin' }); + if (!r.ok) throw new Error(r.status === 302 || r.redirected ? 'admin session required' : 'token HTTP ' + r.status); + const d = await r.json(); + if (!d || !d.token) throw new Error('no admin token in response'); + this._fbKey = d.token; + } + const w = Math.floor(Date.now() / 1000 / 300) * 300; + const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(this._fbKey + ':' + w)); + return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''); + } + + async _fbCall(path, opts) { + const tok = await this._fbToken(); + const o = Object.assign({ credentials: 'same-origin' }, opts || {}); + o.headers = Object.assign({ 'X-Admin-Token': tok, 'Content-Type': 'application/json' }, + (opts && opts.headers) || {}); + const r = await fetch('/api/feedback' + path, o); + if (r.status === 403) { this._fbKey = null; throw new Error('admin token rejected (rejected window, retry)'); } + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); + } + + _fbPad(s, n) { s = String(s == null ? '' : s); return s.length >= n ? s.slice(0, n - 1) + 'β¦' : s + ' '.repeat(n - s.length); } + + _fbFail(e) { this.terminal.print('β feedback: ' + (e && e.message ? e.message : e), 'error'); } + + _fbList(status) { + const q = status ? '?status=' + encodeURIComponent(status) + '&limit=50' : '?limit=50'; + this.terminal.print('π loadingβ¦', ''); + this._fbCall('/list' + q).then(d => { + const items = d.items || []; + if (!items.length) { this.terminal.print('no reports' + (status ? ' with status ' + status : ''), ''); return; } + this.terminal.print(this._fbPad('#', 6) + this._fbPad('status', 10) + this._fbPad('cat', 10) + + this._fbPad('user', 12) + this._fbPad('date', 12) + 'message', ''); + items.forEach(it => this.terminal.print( + this._fbPad(it.id, 6) + this._fbPad(it.status, 10) + this._fbPad(it.category, 10) + + this._fbPad(it.username || 'anon', 12) + this._fbPad(String(it.created_at || '').slice(0, 10), 12) + + String(it.message || '').replace(/\s+/g, ' ').slice(0, 60), '')); + const total = (typeof d.total === 'number' ? d.total : items.length); + const more = total > items.length ? ' (showing ' + items.length + ' of ' + total + + ' β feedback list ' + (status || '') + ' with a higher limit via the dashboard)' : ''; + this.terminal.print(total + ' item(s)' + more + ' β feedback read <id>, reply <id> <text>', ''); + }).catch(e => this._fbFail(e)); + } + + _fbRead(id) { + if (!id) { this.terminal.print('usage: feedback read <id>', 'error'); return; } + const want = String(id); + // The server understands ?id= since v4.6.93's API change; an older process ignores it and + // returns the newest row instead, so verify the id before trusting the answer. + this._fbCall('/list?limit=1&id=' + encodeURIComponent(want)).then(d => { + const hit = (d.items || []).find(x => String(x.id) === want); + if (hit) return hit; + return this._fbCall('/list?limit=200').then(d2 => + (d2.items || []).find(x => String(x.id) === want) || null); + }).then(it => { + if (!it) { this.terminal.print('no report #' + want, 'error'); return; } + this.terminal.print('#' + it.id + ' ' + it.status + ' ' + it.category + ' ' + (it.username || 'anonymous'), ''); + this.terminal.print('page: ' + (it.page || 'β') + ' at: ' + (it.created_at || ''), ''); + this.terminal.print('', ''); + this.terminal.print(String(it.message || ''), ''); + if (it.admin_reply) { this.terminal.print('', ''); this.terminal.print('reply (' + (it.replied_at || '') + '): ' + it.admin_reply, ''); } + this.terminal.print('', ''); + this.terminal.print('feedback reply ' + it.id + ' <text> | feedback archive ' + it.id, ''); + }).catch(e => this._fbFail(e)); + } + + _fbReply(id, text) { + if (!id || !text) { this.terminal.print('usage: feedback reply <id> <text>', 'error'); return; } + this._fbCall('/' + encodeURIComponent(id), { method: 'PATCH', + body: JSON.stringify({ status: 'read', admin_reply: text }) }) + .then(() => this.terminal.print('β
reply published on #' + id + ' (the user sees it in /stear β My reports)', '')) + .catch(e => this._fbFail(e)); + } + + _fbStatus(id, status) { + if (!id) { this.terminal.print('usage: feedback ' + (status === 'new' ? 'reopen' : 'archive') + ' <id>', 'error'); return; } + this._fbCall('/' + encodeURIComponent(id), { method: 'PATCH', body: JSON.stringify({ status: status }) }) + .then(() => this.terminal.print('β
#' + id + ' β ' + status, '')) + .catch(e => this._fbFail(e)); + } + + _fbStats() { + const one = (st) => this._fbCall('/list?limit=1' + (st ? '&status=' + st : '')) + .then(d => { + if (typeof d.total === 'number') return d.total; // COUNT(*) from the server + return this._fbCall('/list?limit=200' + (st ? '&status=' + st : '')) + .then(d2 => (d2.items || []).filter(x => !st || x.status === st).length); + }); + Promise.all([one(''), one('new'), one('read'), one('archived')]).then(([all, nw, rd, ar]) => { + this.terminal.print('feedback: ' + all + ' total β new ' + nw + ', read ' + rd + + ', archived ' + ar, ''); + this.terminal.print('dashboard: /admin/feedback.html', ''); + }).catch(e => this._fbFail(e)); + } + + _fbDashboard() { + this.terminal.print('πͺ opening /admin/feedback.html β¦', ''); + window.open('/admin/feedback.html', '_blank'); + } + + _fbDispatch(args) { + const argv = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim().split(/\s+/); + const sub = (argv[0] || '').toLowerCase(); + const arg = argv[1]; + const rest = argv.slice(2).join(' '); + switch (sub) { + case 'list': return this._fbList(arg || ''); + case 'read': return this._fbRead(arg); + case 'reply': return this._fbReply(arg, rest); + case 'archive': return this._fbStatus(arg, 'archived'); + case 'reopen': return this._fbStatus(arg, 'new'); + case 'stats': return this._fbStats(); + case 'dashboard': return this._fbDashboard(); + case 'help': default: return this._fbHelp(); + } + } + _cmdSync(args) { const url = (args && args[0]) ? args[0] : null; if (url) { |