mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 18:59:33 +01:00
feat(discord): two-way Discord bridge with a thread per tab
Alerts for permission prompts, questions, idle and errors post into a per-tab thread; a reply from an allowlisted user is typed into that tab. Thread creation, thread cleanup on tab delete and pinging are configurable. POST /api/sessions/:id/discord-notify lets a CLI ask for the user. Setup is in docs/discord-bridge.md.
This commit is contained in:
1 parent
f1f7eec4d6
commit
61bf26cfdf
15 files changed
+1862
-1
No files matched your search
@@ -3234,6 +3234,82 @@
|
||||
<div id="webhookResult" class="set-note" style="display:none" data-i18n-skip></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="set-group" id="discordGroup" style="display:none">
|
||||
<div class="set-group-head"><h4>Discord (two-way, one thread per tab)</h4><span class="set-scope">server</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="discord bot thread reply phone notification blocked">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Talk to your sessions from Discord</span>
|
||||
<span class="set-row-desc">Posts alerts into a thread per tab and types your reply in that thread into the tab. Only the user IDs listed below can reply. Setup (bot, least-privilege permissions) is in docs/discord-bridge.md. This group has its own Save button.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="discordEnabled"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Bot token</span>
|
||||
<span class="set-row-desc" id="discordTokenHint">Nothing saved yet.</span>
|
||||
</div>
|
||||
<input type="password" id="discordToken" class="set-input" autocomplete="off" spellcheck="false" placeholder="Bot token from the Developer Portal">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Guild (server) ID</span></div>
|
||||
<input type="text" id="discordGuildId" class="set-input" autocomplete="off" spellcheck="false" inputmode="numeric">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Parent channel ID</span><span class="set-row-desc">Threads are created inside this channel.</span></div>
|
||||
<input type="text" id="discordParentChannelId" class="set-input" autocomplete="off" spellcheck="false" inputmode="numeric">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Allowed user IDs</span><span class="set-row-desc">Comma separated. Only these Discord users can reply into a session.</span></div>
|
||||
<input type="text" id="discordAllowedUserIds" class="set-input" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Create threads</span></div>
|
||||
<select id="discordThreadCreation" class="set-select">
|
||||
<option value="lazy">On the first alert</option>
|
||||
<option value="onSessionStart">For every new tab</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">When a tab is deleted</span><span class="set-row-desc">Deleting a thread needs the Manage Threads permission on the channel.</span></div>
|
||||
<select id="discordOnSessionDelete" class="set-select">
|
||||
<option value="keep">Keep the thread</option>
|
||||
<option value="archive">Archive the thread</option>
|
||||
<option value="delete">Delete the thread</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">@ ping</span></div>
|
||||
<select id="discordPingMode" class="set-select">
|
||||
<option value="off">Never</option>
|
||||
<option value="critical">Critical events only</option>
|
||||
<option value="all">Every post</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Which events</span>
|
||||
<span class="set-row-desc">"Needs attention" skips the routine "response complete" message.</span>
|
||||
</div>
|
||||
<select id="discordScope" class="set-select">
|
||||
<option value="attention">Needs attention</option>
|
||||
<option value="all">Everything</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Save and test</span>
|
||||
<span class="set-row-desc">Send test saves first, then posts to the parent channel.</span>
|
||||
</div>
|
||||
<span>
|
||||
<button class="btn-toolbar btn-sm btn-primary" id="discordSaveBtn" onclick="app.saveDiscord()">Save</button>
|
||||
<button class="btn-toolbar btn-sm" id="discordTestBtn" onclick="app.testDiscord()">Send test</button>
|
||||
<button class="btn-toolbar btn-sm" id="discordClearBtn" onclick="app.clearDiscord()" style="display:none">Remove token</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="discordResult" class="set-note" style="display:none" data-i18n-skip></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ Voice ════════════════════════════════════════════════════ -->
|
||||
|
||||
@@ -570,6 +570,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.loadConfigBackups();
|
||||
this._applyDoctorAdminGate();
|
||||
this.loadWebhook();
|
||||
this.loadDiscord();
|
||||
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
|
||||
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
|
||||
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
|
||||
@@ -1733,6 +1734,111 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Discord bridge (Settings → Notifications). Server-side config behind /api/discord, not a
|
||||
* settings-payload field: the bot token is a secret, so it never round-trips through settings.json
|
||||
* or this page. The token box is write-only. Unlike the webhook group it has its own Save button
|
||||
* only (the modal's main Save does not touch it), so a half-typed token is never sent by accident.
|
||||
*/
|
||||
_discordSay(text, bad = false) {
|
||||
const out = document.getElementById('discordResult');
|
||||
if (!out) return;
|
||||
out.textContent = text;
|
||||
out.style.display = text ? 'block' : 'none';
|
||||
out.style.color = bad ? 'var(--danger, #e5534b)' : '';
|
||||
},
|
||||
|
||||
async loadDiscord() {
|
||||
const group = document.getElementById('discordGroup');
|
||||
if (!group) return;
|
||||
const res = await this._api('/api/discord');
|
||||
let body = null;
|
||||
try { body = res && res.ok ? await res.json() : null; } catch { /* leave hidden */ }
|
||||
if (!body || body.success === false) { group.style.display = 'none'; return; } // not an admin, or an older server
|
||||
const d = body.data;
|
||||
group.style.display = '';
|
||||
document.getElementById('discordEnabled').checked = d.enabled === true;
|
||||
const token = document.getElementById('discordToken');
|
||||
token.value = '';
|
||||
token.placeholder = d.hasToken ? 'Saved. Paste a new token to replace it' : 'Bot token from the Developer Portal';
|
||||
document.getElementById('discordTokenHint').textContent = d.hasToken ? `Saved: ${d.tokenMasked}` : 'Nothing saved yet.';
|
||||
document.getElementById('discordGuildId').value = d.guildId || '';
|
||||
document.getElementById('discordParentChannelId').value = d.parentChannelId || '';
|
||||
document.getElementById('discordAllowedUserIds').value = (d.allowedUserIds || []).join(', ');
|
||||
document.getElementById('discordThreadCreation').value = d.threadCreation;
|
||||
document.getElementById('discordOnSessionDelete').value = d.onSessionDelete;
|
||||
document.getElementById('discordPingMode').value = d.pingMode;
|
||||
document.getElementById('discordScope').value = d.scope;
|
||||
const clearBtn = document.getElementById('discordClearBtn');
|
||||
if (clearBtn) clearBtn.style.display = d.hasToken ? '' : 'none';
|
||||
const gw = { off: 'Not connected (disabled).', connecting: 'Connecting to Discord…', connected: 'Connected: replies are being read.', error: `Not connected: ${d.gatewayError || 'error'}` };
|
||||
let line = gw[d.gateway] || '';
|
||||
if (d.lastResult) {
|
||||
const when = new Date(d.lastResult.at).toLocaleString();
|
||||
line += d.lastResult.ok ? ` Last post succeeded (${when}).` : ` Last post failed (${when}): ${d.lastResult.error}`;
|
||||
}
|
||||
this._discordSay(line.trim(), d.gateway === 'error' || (d.lastResult && !d.lastResult.ok));
|
||||
},
|
||||
|
||||
/** PUT the group's state. Resolves to '' on success, else the error (also shown in the group). */
|
||||
async saveDiscord() {
|
||||
const ids = document.getElementById('discordAllowedUserIds').value.split(/[\s,]+/).filter(Boolean);
|
||||
const payload = {
|
||||
enabled: document.getElementById('discordEnabled').checked,
|
||||
guildId: document.getElementById('discordGuildId').value.trim(),
|
||||
parentChannelId: document.getElementById('discordParentChannelId').value.trim(),
|
||||
allowedUserIds: ids,
|
||||
threadCreation: document.getElementById('discordThreadCreation').value,
|
||||
onSessionDelete: document.getElementById('discordOnSessionDelete').value,
|
||||
pingMode: document.getElementById('discordPingMode').value,
|
||||
scope: document.getElementById('discordScope').value,
|
||||
};
|
||||
const token = document.getElementById('discordToken').value.trim();
|
||||
if (token) payload.token = token; // blank = keep the saved one (Remove token is the way to clear it)
|
||||
const res = await this._api('/api/discord', { method: 'PUT', body: payload });
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
const error = body?.error || 'Could not save the Discord settings.';
|
||||
this._discordSay(error, true);
|
||||
return error;
|
||||
}
|
||||
await this.loadDiscord();
|
||||
return '';
|
||||
},
|
||||
|
||||
async clearDiscord() {
|
||||
if (!confirm('Remove the saved bot token from the server? The Discord bridge stops until you save a new one.')) return;
|
||||
const res = await this._api('/api/discord', { method: 'PUT', body: { token: '', enabled: false } });
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
this._discordSay(body?.error || 'Could not remove the token.', true);
|
||||
return;
|
||||
}
|
||||
await this.loadDiscord();
|
||||
},
|
||||
|
||||
async testDiscord() {
|
||||
const btn = document.getElementById('discordTestBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
if (await this.saveDiscord()) return; // saves first, so it never tests the old values; the error is shown
|
||||
this._discordSay('Sending…');
|
||||
const res = await this._apiPost('/api/discord/test', {});
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
this._discordSay(body?.error || 'Could not send the test.', true);
|
||||
return;
|
||||
}
|
||||
const r = body.data;
|
||||
this._discordSay(r.ok ? 'Test sent. Check the channel.' : `Delivery failed: ${r.error}`, !r.ok);
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Settings → System → Diagnostics: run `codeman doctor` on the server (GET /api/doctor) and list
|
||||
* each tool. Built with DOM nodes and textContent: paths and versions come from the host.
|
||||
|
||||
Reference in new issue
Block a user