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). */
+41
View File
@@ -12,6 +12,7 @@ import {
DISCORD_STARTUP_HINT,
isDiscordCdnUrl,
safeAttachmentName,
screenTail,
DEFAULT_DISCORD_CONFIG,
DiscordApi,
DiscordBridge,
@@ -643,3 +644,43 @@ describe('DiscordBridge files and startup hint', () => {
off.stop();
});
});
describe('screenTail', () => {
it('keeps the last lines of a rendered screen and drops rules, blanks and escape codes', () => {
const screen = [
'old line',
'',
'\u001b[1mWhich option?\u001b[0m',
'─────────────────────────',
' 1. Cap GPUs',
' 2. Cap CPU',
'\u001b(B',
' 4. Type something.',
'━━━━━━━━━━━━━━━━━━━━━━━━━',
'Enter to select · Esc to cancel\u001b(B',
].join('\n');
expect(screenTail(screen)).toBe(
[
'old line',
'Which option?',
' 1. Cap GPUs',
' 2. Cap CPU',
' 4. Type something.',
'Enter to select · Esc to cancel',
].join('\n')
);
});
it('caps lines and characters, cutting on a line', () => {
const many = Array.from({ length: 40 }, (_v, i) => `line ${i}`).join('\n');
expect(screenTail(many).split('\n')).toHaveLength(14);
expect(screenTail(many).endsWith('line 39')).toBe(true);
const wide = Array.from({ length: 5 }, () => 'x'.repeat(400)).join('\n');
expect(screenTail(wide).length).toBeLessThanOrEqual(900);
});
it('is empty for nothing', () => {
expect(screenTail('')).toBe('');
expect(screenTail('\n\n ───── \n')).toBe('');
});
});