fix(discord): post the rendered screen, not the raw stream, as the alert tail

A full-screen UI repaints in place, so the raw output stream read as garbage. Read capture-pane instead and drop rules, blanks and escape codes.
This commit is contained in:
Devvyn committed 2026-10-29 21:31:37 +08:00
1 parent 1c4338e8b5
commit dcf51b1715
3 files changed
+69 -11

No files matched your search

+18
View File
@@ -184,6 +184,24 @@ export function getDiscordStartupHint(): string | undefined {
return activeStartupHint;
}
/**
* The last lines of a rendered screen for a post: colour and charset codes removed, blank lines and
* pure rule lines (box drawing, dashes) dropped, the last 14 lines, at most 900 characters, cut on a line.
*/
export function screenTail(screen: string, maxLines = 14, maxChars = 900): string {
const lines = screen
// eslint-disable-next-line no-control-regex
.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b[()][A-Za-z0-9]|\u001b[@-Z\\-_]/g, '')
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '')
.split(/\r?\n/)
.map((l) => l.trimEnd())
.filter((l) => l.trim() !== '' && !/^[\s\-_=\u2500-\u257f]+$/.test(l));
let out = lines.slice(-maxLines);
while (out.length > 1 && out.join('\n').length > maxChars) out = out.slice(1);
return out.join('\n').slice(-maxChars);
}
/** Final path segment only, `[A-Za-z0-9._-]`, no leading dot, at most 80 characters. */
export function safeAttachmentName(name: string): string {
const base = (name.split(/[\\/]/).pop() ?? '').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[._]+/, '');
+10 -11
View File
@@ -45,8 +45,7 @@ import { looksLikeHostReboot, newestPersistedActivity, planRebootRestore } from
import { rebootRestoreRegistry } from './reboot-restore-registry.js';
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
import { WebhookNotifier, readWebhookConfig } from '../webhook-notify.js';
import { DiscordBridge } from '../discord-bridge.js';
import { stripAnsi } from '../utils/regex-patterns.js';
import { DiscordBridge, screenTail } from '../discord-bridge.js';
import { stopHostSensors } from '../host-sensors.js';
import type { WebhookUrgency } from '../types/push.js';
import { webviewFetch } from './webview-egress.js';
@@ -3010,7 +3009,7 @@ export class WebServer extends EventEmitter {
urgency: template.urgency as WebhookUrgency,
sessionId: sessionId || undefined,
sessionName: sessionName || undefined,
tail: dcSession ? WebServer.outputTail(dcSession) : undefined,
tail: dcSession ? this.discordTail(dcSession) : undefined,
host: this.windowTitle,
})
.catch(() => undefined);
@@ -3096,14 +3095,14 @@ export class WebServer extends EventEmitter {
}
}
/** The last few lines the CLI printed, with colour codes removed, for a Discord alert. */
private static outputTail(session: { textOutput: string; terminalBuffer: string }): string {
const raw = session.textOutput || session.terminalBuffer.slice(-4000);
const lines = stripAnsi(raw)
.split(/\r?\n/)
.map((l) => l.trimEnd())
.filter((l) => l.trim() !== '');
return lines.slice(-12).join('\n').slice(-900);
/**
* What the person would see on the tab right now, for a Discord alert: the rendered tmux screen
* (`capture-pane -p`), never the raw output stream, which a full-screen UI repaints in place and
* reads as garbage. A session with no pane falls back to its plain text output, else nothing.
*/
private discordTail(session: { muxName?: string | null; textOutput: string }): string {
const pane = session.muxName ? (this.mux.capturePaneText?.(session.muxName) ?? null) : null;
return screenTail(pane ?? session.textOutput ?? '');
}
/** The notification body for an event (shared by Web Push and the webhook channel). */