fix(discord): address review on #741

Builds the alert tail only for a post that goes out; separates deduped from disabled and never dedupes a deliberate notify; types the startup hint with fromUser false and skips resumed, remote and Docker tabs; clears per-tab maps and closes the bridge on stop; acts only on a real delete, not a detach; serialises thread-map writes; reloads the bridge after a discord.json restore; probes and self-ignores .codeman-discord; validates the message id; reacts when a reply is rate limited; documents what leaves the machine.
This commit is contained in:
Devvyn committed 2026-10-29 23:03:26 +08:00
1 parent aedefab8b4
commit eb0553a645
12 files changed
+380 -75

No files matched your search

+8 -1
View File
@@ -150,7 +150,12 @@ export async function runConfigBackup(
});
}
export function registerConfigBackupRoutes(app: FastifyInstance): void {
export interface ConfigBackupRouteDeps {
/** Re-read discord.json into the running bridge (it caches its config and would keep the pre-restore token). */
reloadDiscord?: () => Promise<void>;
}
export function registerConfigBackupRoutes(app: FastifyInstance, deps: ConfigBackupRouteDeps = {}): void {
const deny = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => {
if (isMultiUserMode() && !isAdmin(req)) {
reply.code(403);
@@ -236,6 +241,8 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
if (restored.includes('intents.json')) intentStore.reload();
// restoreConfigBackup writes clis.json directly (the one writer besides mutateRegistryFile).
if (restored.includes('clis.json')) reloadCliRegistry();
// The Discord bridge caches discord.json and rewrites nothing from it, so a restore must reach the running bridge.
if (restored.includes('discord.json')) void deps.reloadDiscord?.().catch(() => undefined);
// A restore of users.json replaces accounts, password hashes, roles and bypass grants.
if (isMultiUserMode()) {
void appendAdminAudit({
+2 -1
View File
@@ -171,7 +171,8 @@ export function registerDiscordRoutes(app: FastifyInstance, deps: DiscordRouteDe
host: deps.hostTitle(),
file: upload,
},
urgent === true
// deliberate: an agent's own message is never deduped, so a second, different post is not lost.
{ urgent: urgent === true, deliberate: true }
);
if (result === null) {
reply.code(409);
+38 -25
View File
@@ -512,11 +512,13 @@ export class WebServer extends EventEmitter {
private discordBridge = new DiscordBridge({
configDir: getDataDir(),
fetchImpl: webviewFetch,
sendInput: async (sessionId, text) => {
sendInput: async (sessionId, text, fromUser) => {
const session = this.sessions.get(sessionId);
if (!session) return false;
if (await session.writeViaMux(text, { fromUser: true }).catch(() => false)) return true;
return session.write(text, { fromUser: true });
// fromUser is true only for a Discord reply (a person typed it); the startup hint is Codeman's own
// text and must not auto-name the tab or count as the person's input.
if (await session.writeViaMux(text, { fromUser }).catch(() => false)) return true;
return session.write(text, { fromUser });
},
// Remote (ssh) sessions keep their folder on another host, so received files have nowhere local to go.
sessionDir: (sessionId) => {
@@ -1342,7 +1344,7 @@ export class WebServer extends EventEmitter {
registerWebviewRoutes(this.app, ctx, this.basePath);
registerTabLayoutRoutes(this.app, ctx);
registerMcpSyncRoutes(this.app);
registerConfigBackupRoutes(this.app);
registerConfigBackupRoutes(this.app, { reloadDiscord: () => this.discordBridge.reload() });
registerWebhookRoutes(this.app, {
notifier: this.webhookNotifier,
configDir: getDataDir(),
@@ -1792,7 +1794,11 @@ export class WebServer extends EventEmitter {
// detach: mail posted before that is still there when the session is re-attached
// (while detached, the routes answer 404, so nothing new arrives). Before
// cancelAll, so a reader released by it reads an empty inbox.
if (killMux) agentInbox.drop(sessionId);
if (killMux) {
agentInbox.drop(sessionId);
// A real delete only: a detach (killMux=false) leaves the session running and re-attachable.
void this.discordBridge.sessionDeleted(sessionId).catch(() => undefined);
}
sessionWaits.notifySignal(sessionId, 'exit');
sessionWaits.cancelAll(sessionId);
approvalInbox.resolveForSession(sessionId, 'session_ended');
@@ -2727,41 +2733,42 @@ export class WebServer extends EventEmitter {
this.bridgeSessionEvent(event, data);
}
private isExternalSession(sessionId: string): boolean {
/**
* Whether the startup hint may be typed into this tab: an external CLI (never Claude, which has it as a
* launch flag, and never a shell), running locally. A remote (ssh) or Docker tab has no `codeman` on its
* PATH, so the hint would tell the agent to run a command it does not have.
*/
private isHintableSession(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
return !!session && isExternalCliMode(session.mode);
return !!session && isExternalCliMode(session.mode) && !session.remote && !session.docker;
}
/** Keep the Discord thread in step with the tab: created, renamed, deleted. Never throws. */
private bridgeSessionEvent(event: string, data: unknown): void {
if (
event !== SseEvent.SessionCreated &&
event !== SseEvent.SessionUpdated &&
event !== SseEvent.SessionDeleted &&
event !== SseEvent.SessionIdle
) {
// Deletion is NOT handled here: `session:deleted` also fires for a detach (killMux=false), where the
// session keeps running and can be re-attached. The real delete calls the bridge from the cleanup path.
if (event !== SseEvent.SessionCreated && event !== SseEvent.SessionUpdated && event !== SseEvent.SessionIdle) {
return;
}
const d = (data ?? {}) as {
id?: string;
name?: string;
createdAt?: number;
session?: { id?: string; name?: string; createdAt?: number };
resumeSessionId?: string;
session?: { id?: string; name?: string; createdAt?: number; resumeSessionId?: string };
};
const s = d.session ?? d;
if (!s.id) return;
const id = s.id;
const run =
event === SseEvent.SessionDeleted
? this.discordBridge.sessionDeleted(id)
: event === SseEvent.SessionIdle
? // Claude is told at launch; a shell must never be typed at.
this.discordBridge.sessionIdle(id, this.isExternalSession(id))
: event === SseEvent.SessionCreated
? this.discordBridge
.sessionCreated(id, s.name, s.createdAt)
.then(() => this.discordBridge.sessionRenamed(id, s.name))
: this.discordBridge.sessionRenamed(id, s.name);
event === SseEvent.SessionIdle
? // Claude is told at launch; a shell, a remote or a Docker tab must never be typed at.
this.discordBridge.sessionIdle(id, this.isHintableSession(id))
: event === SseEvent.SessionCreated
? this.discordBridge
.sessionCreated(id, s.name, s.createdAt, !!s.resumeSessionId)
.then(() => this.discordBridge.sessionRenamed(id, s.name))
: this.discordBridge.sessionRenamed(id, s.name);
void run.catch(() => undefined);
}
@@ -3009,7 +3016,8 @@ export class WebServer extends EventEmitter {
urgency: template.urgency as WebhookUrgency,
sessionId: sessionId || undefined,
sessionName: sessionName || undefined,
tail: dcSession ? this.discordTail(dcSession) : undefined,
// A function, so the screen is only read (a synchronous tmux exec) once the bridge has decided to post.
tail: dcSession ? () => this.discordTail(dcSession) : undefined,
host: this.windowTitle,
})
.catch(() => undefined);
@@ -3830,6 +3838,8 @@ export class WebServer extends EventEmitter {
} catch (err) {
console.warn(`[Server] releasing the tab layout slot failed: ${getErrorMessage(err)}`);
}
// A half-built session never had a thread, but its per-tab bridge state goes with it.
void this.discordBridge.sessionDeleted(sessionId).catch(() => undefined);
// Any `session:updated` the half-built session emitted before it failed left a
// tab on every other open board, and the client's handler is an upsert.
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
@@ -4402,6 +4412,9 @@ export class WebServer extends EventEmitter {
// only by the periodic idle sweep, whose interval is disposed just below.
closeAllLlamaSwapLogTails();
// Close the Discord gateway socket and its heartbeat, and clear the bridge's per-tab maps.
this.discordBridge.stop();
// Dispose all managed timers (intervals + resettable timeouts)
this.cleanup.dispose();