mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-18 18:29:35 +08:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12ab5a40b5 | |||
| fa1fe39bb3 | |||
| 614278187d | |||
| 832a9aa049 | |||
| 2906a0ec10 | |||
| 540db101bc | |||
| 44da74b6c5 | |||
| c0acbfebbf | |||
| b2bfe92598 | |||
| 3014194ed4 | |||
| e0401a519f | |||
| 20ba7a0b91 | |||
| a64551a8f3 | |||
| f59c863c95 | |||
| 6833d76c74 | |||
| de69b8c6b2 | |||
| 07c03cc148 | |||
| 5f9774df55 | |||
| 9558bdfcdd |
@@ -556,6 +556,106 @@ async function main(): Promise<void> {
|
||||
prompt += '\n' + pending.join('\n');
|
||||
}
|
||||
|
||||
// --- Slash command handling ---
|
||||
// Only known session slash commands are handled here. This prevents
|
||||
// accidental interception of user prompts that happen to start with '/'.
|
||||
const KNOWN_SESSION_COMMANDS = new Set(['/compact']);
|
||||
const trimmedPrompt = prompt.trim();
|
||||
const isSessionSlashCommand = KNOWN_SESSION_COMMANDS.has(trimmedPrompt);
|
||||
|
||||
if (isSessionSlashCommand) {
|
||||
log(`Handling session command: ${trimmedPrompt}`);
|
||||
let slashSessionId: string | undefined;
|
||||
let compactBoundarySeen = false;
|
||||
let hadError = false;
|
||||
let resultEmitted = false;
|
||||
|
||||
try {
|
||||
for await (const message of query({
|
||||
prompt: trimmedPrompt,
|
||||
options: {
|
||||
cwd: '/workspace/group',
|
||||
resume: sessionId,
|
||||
systemPrompt: undefined,
|
||||
allowedTools: [],
|
||||
env: sdkEnv,
|
||||
permissionMode: 'bypassPermissions' as const,
|
||||
allowDangerouslySkipPermissions: true,
|
||||
settingSources: ['project', 'user'] as const,
|
||||
hooks: {
|
||||
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
|
||||
},
|
||||
},
|
||||
})) {
|
||||
const msgType = message.type === 'system'
|
||||
? `system/${(message as { subtype?: string }).subtype}`
|
||||
: message.type;
|
||||
log(`[slash-cmd] type=${msgType}`);
|
||||
|
||||
if (message.type === 'system' && message.subtype === 'init') {
|
||||
slashSessionId = message.session_id;
|
||||
log(`Session after slash command: ${slashSessionId}`);
|
||||
}
|
||||
|
||||
// Observe compact_boundary to confirm compaction completed
|
||||
if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
|
||||
compactBoundarySeen = true;
|
||||
log('Compact boundary observed — compaction completed');
|
||||
}
|
||||
|
||||
if (message.type === 'result') {
|
||||
const resultSubtype = (message as { subtype?: string }).subtype;
|
||||
const textResult = 'result' in message ? (message as { result?: string }).result : null;
|
||||
|
||||
if (resultSubtype?.startsWith('error')) {
|
||||
hadError = true;
|
||||
writeOutput({
|
||||
status: 'error',
|
||||
result: null,
|
||||
error: textResult || 'Session command failed.',
|
||||
newSessionId: slashSessionId,
|
||||
});
|
||||
} else {
|
||||
writeOutput({
|
||||
status: 'success',
|
||||
result: textResult || 'Conversation compacted.',
|
||||
newSessionId: slashSessionId,
|
||||
});
|
||||
}
|
||||
resultEmitted = true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
hadError = true;
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
log(`Slash command error: ${errorMsg}`);
|
||||
writeOutput({ status: 'error', result: null, error: errorMsg });
|
||||
}
|
||||
|
||||
log(`Slash command done. compactBoundarySeen=${compactBoundarySeen}, hadError=${hadError}`);
|
||||
|
||||
// Warn if compact_boundary was never observed — compaction may not have occurred
|
||||
if (!hadError && !compactBoundarySeen) {
|
||||
log('WARNING: compact_boundary was not observed. Compaction may not have completed.');
|
||||
}
|
||||
|
||||
// Only emit final session marker if no result was emitted yet and no error occurred
|
||||
if (!resultEmitted && !hadError) {
|
||||
writeOutput({
|
||||
status: 'success',
|
||||
result: compactBoundarySeen
|
||||
? 'Conversation compacted.'
|
||||
: 'Compaction requested but compact_boundary was not observed.',
|
||||
newSessionId: slashSessionId,
|
||||
});
|
||||
} else if (!hadError) {
|
||||
// Emit session-only marker so host updates session tracking
|
||||
writeOutput({ status: 'success', result: null, newSessionId: slashSessionId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// --- End slash command handling ---
|
||||
|
||||
// Script phase: run script before waking agent
|
||||
if (containerInput.script && containerInput.isScheduledTask) {
|
||||
log('Running task script...');
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
formatOutbound,
|
||||
stripInternalTags,
|
||||
} from './router.js';
|
||||
import { parseTextStyles, parseSignalStyles } from './text-styles.js';
|
||||
import { NewMessage } from './types.js';
|
||||
|
||||
function makeMsg(overrides: Partial<NewMessage> = {}): NewMessage {
|
||||
@@ -293,259 +292,3 @@ describe('trigger gating (requiresTrigger interaction)', () => {
|
||||
expect(shouldProcess(false, false, undefined, msgs)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// --- parseTextStyles ---
|
||||
|
||||
describe('parseTextStyles — passthrough channels', () => {
|
||||
it('passes text through unchanged on discord', () => {
|
||||
const md = '**bold** and *italic* and [link](https://example.com)';
|
||||
expect(parseTextStyles(md, 'discord')).toBe(md);
|
||||
});
|
||||
|
||||
it('passes text through unchanged on signal (signal uses parseSignalStyles)', () => {
|
||||
const md = '**bold** and *italic* and [link](https://example.com)';
|
||||
expect(parseTextStyles(md, 'signal')).toBe(md);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTextStyles — bold', () => {
|
||||
it('converts **bold** to *bold* on whatsapp', () => {
|
||||
expect(parseTextStyles('**hello**', 'whatsapp')).toBe('*hello*');
|
||||
});
|
||||
|
||||
it('converts **bold** to *bold* on telegram', () => {
|
||||
expect(parseTextStyles('say **this** now', 'telegram')).toBe(
|
||||
'say *this* now',
|
||||
);
|
||||
});
|
||||
|
||||
it('converts **bold** to *bold* on slack', () => {
|
||||
expect(parseTextStyles('**hello**', 'slack')).toBe('*hello*');
|
||||
});
|
||||
|
||||
it('does not convert a lone * as bold', () => {
|
||||
expect(parseTextStyles('a * b * c', 'whatsapp')).toBe('a * b * c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTextStyles — italic', () => {
|
||||
it('converts *italic* to _italic_ on whatsapp', () => {
|
||||
expect(parseTextStyles('say *this* now', 'whatsapp')).toBe(
|
||||
'say _this_ now',
|
||||
);
|
||||
});
|
||||
|
||||
it('converts *italic* to _italic_ on telegram', () => {
|
||||
expect(parseTextStyles('*italic*', 'telegram')).toBe('_italic_');
|
||||
});
|
||||
|
||||
it('bold-before-italic: **bold** *italic* → *bold* _italic_', () => {
|
||||
expect(parseTextStyles('**bold** *italic*', 'whatsapp')).toBe(
|
||||
'*bold* _italic_',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTextStyles — headings', () => {
|
||||
it('converts # heading on whatsapp', () => {
|
||||
expect(parseTextStyles('# Top', 'whatsapp')).toBe('*Top*');
|
||||
});
|
||||
|
||||
it('converts ## heading on telegram', () => {
|
||||
expect(parseTextStyles('## Hello World', 'telegram')).toBe('*Hello World*');
|
||||
});
|
||||
|
||||
it('converts ### heading on telegram', () => {
|
||||
expect(parseTextStyles('### Section', 'telegram')).toBe('*Section*');
|
||||
});
|
||||
|
||||
it('only converts headings at line start', () => {
|
||||
const input = 'not a ## heading in middle';
|
||||
expect(parseTextStyles(input, 'whatsapp')).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTextStyles — links', () => {
|
||||
it('converts [text](url) to text (url) on whatsapp', () => {
|
||||
expect(parseTextStyles('[Link](https://example.com)', 'whatsapp')).toBe(
|
||||
'Link (https://example.com)',
|
||||
);
|
||||
});
|
||||
|
||||
it('converts [text](url) to text (url) on telegram', () => {
|
||||
expect(parseTextStyles('[Link](https://example.com)', 'telegram')).toBe(
|
||||
'Link (https://example.com)',
|
||||
);
|
||||
});
|
||||
|
||||
it('converts [text](url) to <url|text> on slack', () => {
|
||||
expect(parseTextStyles('[Click here](https://example.com)', 'slack')).toBe(
|
||||
'<https://example.com|Click here>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTextStyles — horizontal rules', () => {
|
||||
it('strips --- on telegram', () => {
|
||||
expect(parseTextStyles('above\n---\nbelow', 'telegram')).toBe(
|
||||
'above\n\nbelow',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips *** on whatsapp', () => {
|
||||
expect(parseTextStyles('above\n***\nbelow', 'whatsapp')).toBe(
|
||||
'above\n\nbelow',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTextStyles — code block protection', () => {
|
||||
it('does not transform **bold** inside fenced code block', () => {
|
||||
const input = '```\n**not bold**\n```';
|
||||
expect(parseTextStyles(input, 'whatsapp')).toBe(input);
|
||||
});
|
||||
|
||||
it('does not transform *italic* inside inline code', () => {
|
||||
const input = 'use `*star*` literally';
|
||||
expect(parseTextStyles(input, 'telegram')).toBe(input);
|
||||
});
|
||||
|
||||
it('transforms text outside code blocks but not inside', () => {
|
||||
const input = '**bold** and `*code*` and *italic*';
|
||||
expect(parseTextStyles(input, 'whatsapp')).toBe(
|
||||
'*bold* and `*code*` and _italic_',
|
||||
);
|
||||
});
|
||||
|
||||
it('transforms text outside fenced block but not inside', () => {
|
||||
const input = '**bold**\n```\n**raw**\n```\n*italic*';
|
||||
expect(parseTextStyles(input, 'telegram')).toBe(
|
||||
'*bold*\n```\n**raw**\n```\n_italic_',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- parseSignalStyles ---
|
||||
|
||||
describe('parseSignalStyles — basic styles', () => {
|
||||
it('extracts BOLD from **text**', () => {
|
||||
const { text, textStyle } = parseSignalStyles('**hello**');
|
||||
expect(text).toBe('hello');
|
||||
expect(textStyle).toEqual([{ style: 'BOLD', start: 0, length: 5 }]);
|
||||
});
|
||||
|
||||
it('extracts ITALIC from *text*', () => {
|
||||
const { text, textStyle } = parseSignalStyles('*hello*');
|
||||
expect(text).toBe('hello');
|
||||
expect(textStyle).toEqual([{ style: 'ITALIC', start: 0, length: 5 }]);
|
||||
});
|
||||
|
||||
it('extracts ITALIC from _text_', () => {
|
||||
const { text, textStyle } = parseSignalStyles('_hello_');
|
||||
expect(text).toBe('hello');
|
||||
expect(textStyle).toEqual([{ style: 'ITALIC', start: 0, length: 5 }]);
|
||||
});
|
||||
|
||||
it('extracts STRIKETHROUGH from ~~text~~', () => {
|
||||
const { text, textStyle } = parseSignalStyles('~~hello~~');
|
||||
expect(text).toBe('hello');
|
||||
expect(textStyle).toEqual([
|
||||
{ style: 'STRIKETHROUGH', start: 0, length: 5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('extracts MONOSPACE from `inline code`', () => {
|
||||
const { text, textStyle } = parseSignalStyles('`code`');
|
||||
expect(text).toBe('code');
|
||||
expect(textStyle).toEqual([{ style: 'MONOSPACE', start: 0, length: 4 }]);
|
||||
});
|
||||
|
||||
it('extracts BOLD from ## heading and strips marker', () => {
|
||||
const { text, textStyle } = parseSignalStyles('## Hello World');
|
||||
expect(text).toBe('Hello World');
|
||||
expect(textStyle).toEqual([{ style: 'BOLD', start: 0, length: 11 }]);
|
||||
});
|
||||
|
||||
it('no styles for plain text', () => {
|
||||
const { text, textStyle } = parseSignalStyles('just plain text');
|
||||
expect(text).toBe('just plain text');
|
||||
expect(textStyle).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSignalStyles — mixed content', () => {
|
||||
it('correctly offsets styles in mixed text', () => {
|
||||
const { text, textStyle } = parseSignalStyles('say **hi** now');
|
||||
expect(text).toBe('say hi now');
|
||||
expect(textStyle).toEqual([{ style: 'BOLD', start: 4, length: 2 }]);
|
||||
});
|
||||
|
||||
it('handles multiple styles with correct offsets', () => {
|
||||
const { text, textStyle } = parseSignalStyles('**bold** and *italic*');
|
||||
expect(text).toBe('bold and italic');
|
||||
expect(textStyle[0]).toEqual({ style: 'BOLD', start: 0, length: 4 });
|
||||
expect(textStyle[1]).toEqual({ style: 'ITALIC', start: 9, length: 6 });
|
||||
});
|
||||
|
||||
it('strips link markers, no style applied', () => {
|
||||
const { text, textStyle } = parseSignalStyles(
|
||||
'[Click here](https://example.com)',
|
||||
);
|
||||
expect(text).toBe('Click here (https://example.com)');
|
||||
expect(textStyle).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('strips horizontal rules', () => {
|
||||
const { text, textStyle } = parseSignalStyles('above\n---\nbelow');
|
||||
expect(text).toBe('above\nbelow');
|
||||
expect(textStyle).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSignalStyles — code block protection', () => {
|
||||
it('protects fenced code block content with MONOSPACE', () => {
|
||||
const input = '```\n**not bold**\n```';
|
||||
const { text, textStyle } = parseSignalStyles(input);
|
||||
expect(text).toBe('**not bold**');
|
||||
expect(textStyle).toEqual([{ style: 'MONOSPACE', start: 0, length: 12 }]);
|
||||
});
|
||||
|
||||
it('styles outside block are still processed', () => {
|
||||
const input = '**bold**\n```\nraw code\n```';
|
||||
const { text, textStyle } = parseSignalStyles(input);
|
||||
expect(text).toContain('bold');
|
||||
expect(text).toContain('raw code');
|
||||
const boldStyle = textStyle.find((s) => s.style === 'BOLD');
|
||||
const codeStyle = textStyle.find((s) => s.style === 'MONOSPACE');
|
||||
expect(boldStyle).toBeDefined();
|
||||
expect(codeStyle).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSignalStyles — snake_case guard', () => {
|
||||
it('does not italicise underscores in snake_case', () => {
|
||||
const { text, textStyle } = parseSignalStyles('use snake_case_here');
|
||||
expect(text).toBe('use snake_case_here');
|
||||
expect(textStyle).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatOutbound — channel-aware', () => {
|
||||
it('applies parseTextStyles when channel is provided', () => {
|
||||
expect(formatOutbound('**bold**', 'whatsapp')).toBe('*bold*');
|
||||
});
|
||||
|
||||
it('returns plain stripped text when no channel provided', () => {
|
||||
expect(formatOutbound('**bold**')).toBe('**bold**');
|
||||
});
|
||||
|
||||
it('strips internal tags then applies channel formatting', () => {
|
||||
expect(
|
||||
formatOutbound('<internal>thinking</internal>**done**', 'telegram'),
|
||||
).toBe('*done*');
|
||||
});
|
||||
|
||||
it('signal channel is passthrough — raw markdown preserved for parseSignalStyles', () => {
|
||||
expect(formatOutbound('**bold**', 'signal')).toBe('**bold**');
|
||||
});
|
||||
});
|
||||
|
||||
+55
-6
@@ -49,7 +49,6 @@ import { GroupQueue } from './group-queue.js';
|
||||
import { resolveGroupFolderPath } from './group-folder.js';
|
||||
import { startIpcWatcher } from './ipc.js';
|
||||
import { findChannel, formatMessages, formatOutbound } from './router.js';
|
||||
import { ChannelType } from './text-styles.js';
|
||||
import {
|
||||
restoreRemoteControl,
|
||||
startRemoteControl,
|
||||
@@ -61,6 +60,7 @@ import {
|
||||
loadSenderAllowlist,
|
||||
shouldDropMessage,
|
||||
} from './sender-allowlist.js';
|
||||
import { extractSessionCommand, handleSessionCommand, isSessionCommandAllowed } from './session-commands.js';
|
||||
import { startSchedulerLoop } from './task-scheduler.js';
|
||||
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
||||
import { logger } from './logger.js';
|
||||
@@ -238,6 +238,33 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
|
||||
|
||||
if (missedMessages.length === 0) return true;
|
||||
|
||||
// --- Session command interception (before trigger check) ---
|
||||
const cmdResult = await handleSessionCommand({
|
||||
missedMessages,
|
||||
isMainGroup,
|
||||
groupName: group.name,
|
||||
triggerPattern: getTriggerPattern(group.trigger),
|
||||
timezone: TIMEZONE,
|
||||
deps: {
|
||||
sendMessage: (text) => channel.sendMessage(chatJid, text),
|
||||
setTyping: (typing) => channel.setTyping?.(chatJid, typing) ?? Promise.resolve(),
|
||||
runAgent: (prompt, onOutput) => runAgent(group, prompt, chatJid, onOutput),
|
||||
closeStdin: () => queue.closeStdin(chatJid),
|
||||
advanceCursor: (ts) => { lastAgentTimestamp[chatJid] = ts; saveState(); },
|
||||
formatMessages,
|
||||
canSenderInteract: (msg) => {
|
||||
const hasTrigger = getTriggerPattern(group.trigger).test(msg.content.trim());
|
||||
const reqTrigger = !isMainGroup && group.requiresTrigger !== false;
|
||||
return isMainGroup || !reqTrigger || (hasTrigger && (
|
||||
msg.is_from_me ||
|
||||
isTriggerAllowed(chatJid, msg.sender, loadSenderAllowlist())
|
||||
));
|
||||
},
|
||||
},
|
||||
});
|
||||
if (cmdResult.handled) return cmdResult.success;
|
||||
// --- End session command interception ---
|
||||
|
||||
// For non-main groups, check if trigger is required and present
|
||||
if (!isMainGroup && group.requiresTrigger !== false) {
|
||||
const triggerPattern = getTriggerPattern(group.trigger);
|
||||
@@ -247,7 +274,9 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
|
||||
triggerPattern.test(m.content.trim()) &&
|
||||
(m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
|
||||
);
|
||||
if (!hasTrigger) return true;
|
||||
if (!hasTrigger) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const prompt = formatMessages(missedMessages, TIMEZONE);
|
||||
@@ -464,6 +493,28 @@ async function startMessageLoop(): Promise<void> {
|
||||
}
|
||||
|
||||
const isMainGroup = group.isMain === true;
|
||||
|
||||
// --- Session command interception (message loop) ---
|
||||
// Scan ALL messages in the batch for a session command.
|
||||
const loopCmdMsg = groupMessages.find(
|
||||
(m) => extractSessionCommand(m.content, getTriggerPattern(group.trigger)) !== null,
|
||||
);
|
||||
|
||||
if (loopCmdMsg) {
|
||||
// Only close active container if the sender is authorized — otherwise an
|
||||
// untrusted user could kill in-flight work by sending /compact (DoS).
|
||||
// closeStdin no-ops internally when no container is active.
|
||||
if (isSessionCommandAllowed(isMainGroup, loopCmdMsg.is_from_me === true)) {
|
||||
queue.closeStdin(chatJid);
|
||||
}
|
||||
// Enqueue so processGroupMessages handles auth + cursor advancement.
|
||||
// Don't pipe via IPC — slash commands need a fresh container with
|
||||
// string prompt (not MessageStream) for SDK recognition.
|
||||
queue.enqueueMessageCheck(chatJid);
|
||||
continue;
|
||||
}
|
||||
// --- End session command interception ---
|
||||
|
||||
const needsTrigger = !isMainGroup && group.requiresTrigger !== false;
|
||||
|
||||
// For non-main groups, only act on trigger messages.
|
||||
@@ -687,16 +738,14 @@ async function main(): Promise<void> {
|
||||
logger.warn({ jid }, 'No channel owns JID, cannot send message');
|
||||
return;
|
||||
}
|
||||
const text = formatOutbound(rawText, channel.name as ChannelType);
|
||||
const text = formatOutbound(rawText);
|
||||
if (text) await channel.sendMessage(jid, text);
|
||||
},
|
||||
});
|
||||
startIpcWatcher({
|
||||
sendMessage: (jid, rawText) => {
|
||||
sendMessage: (jid, text) => {
|
||||
const channel = findChannel(channels, jid);
|
||||
if (!channel) throw new Error(`No channel for JID: ${jid}`);
|
||||
const text = formatOutbound(rawText, channel.name as ChannelType);
|
||||
if (!text) return Promise.resolve();
|
||||
return channel.sendMessage(jid, text);
|
||||
},
|
||||
registeredGroups: () => registeredGroups,
|
||||
|
||||
+2
-3
@@ -1,6 +1,5 @@
|
||||
import { Channel, NewMessage } from './types.js';
|
||||
import { formatLocalTime } from './timezone.js';
|
||||
import { parseTextStyles, ChannelType } from './text-styles.js';
|
||||
|
||||
export function escapeXml(s: string): string {
|
||||
if (!s) return '';
|
||||
@@ -29,10 +28,10 @@ export function stripInternalTags(text: string): string {
|
||||
return text.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
|
||||
}
|
||||
|
||||
export function formatOutbound(rawText: string, channel?: ChannelType): string {
|
||||
export function formatOutbound(rawText: string): string {
|
||||
const text = stripInternalTags(rawText);
|
||||
if (!text) return '';
|
||||
return channel ? parseTextStyles(text, channel) : text;
|
||||
return text;
|
||||
}
|
||||
|
||||
export function routeOutbound(
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { extractSessionCommand, handleSessionCommand, isSessionCommandAllowed } from './session-commands.js';
|
||||
import type { NewMessage } from './types.js';
|
||||
import type { SessionCommandDeps } from './session-commands.js';
|
||||
|
||||
describe('extractSessionCommand', () => {
|
||||
const trigger = /^@Andy\b/i;
|
||||
|
||||
it('detects bare /compact', () => {
|
||||
expect(extractSessionCommand('/compact', trigger)).toBe('/compact');
|
||||
});
|
||||
|
||||
it('detects /compact with trigger prefix', () => {
|
||||
expect(extractSessionCommand('@Andy /compact', trigger)).toBe('/compact');
|
||||
});
|
||||
|
||||
it('rejects /compact with extra text', () => {
|
||||
expect(extractSessionCommand('/compact now please', trigger)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects partial matches', () => {
|
||||
expect(extractSessionCommand('/compaction', trigger)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects regular messages', () => {
|
||||
expect(extractSessionCommand('please compact the conversation', trigger)).toBeNull();
|
||||
});
|
||||
|
||||
it('handles whitespace', () => {
|
||||
expect(extractSessionCommand(' /compact ', trigger)).toBe('/compact');
|
||||
});
|
||||
|
||||
it('is case-sensitive for the command', () => {
|
||||
expect(extractSessionCommand('/Compact', trigger)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSessionCommandAllowed', () => {
|
||||
it('allows main group regardless of sender', () => {
|
||||
expect(isSessionCommandAllowed(true, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('allows trusted/admin sender (is_from_me) in non-main group', () => {
|
||||
expect(isSessionCommandAllowed(false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('denies untrusted sender in non-main group', () => {
|
||||
expect(isSessionCommandAllowed(false, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('allows trusted sender in main group', () => {
|
||||
expect(isSessionCommandAllowed(true, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function makeMsg(content: string, overrides: Partial<NewMessage> = {}): NewMessage {
|
||||
return {
|
||||
id: 'msg-1',
|
||||
chat_jid: 'group@test',
|
||||
sender: 'user@test',
|
||||
sender_name: 'User',
|
||||
content,
|
||||
timestamp: '100',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(overrides: Partial<SessionCommandDeps> = {}): SessionCommandDeps {
|
||||
return {
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
setTyping: vi.fn().mockResolvedValue(undefined),
|
||||
runAgent: vi.fn().mockResolvedValue('success'),
|
||||
closeStdin: vi.fn(),
|
||||
advanceCursor: vi.fn(),
|
||||
formatMessages: vi.fn().mockReturnValue('<formatted>'),
|
||||
canSenderInteract: vi.fn().mockReturnValue(true),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const trigger = /^@Andy\b/i;
|
||||
|
||||
describe('handleSessionCommand', () => {
|
||||
it('returns handled:false when no session command found', async () => {
|
||||
const deps = makeDeps();
|
||||
const result = await handleSessionCommand({
|
||||
missedMessages: [makeMsg('hello')],
|
||||
isMainGroup: true,
|
||||
groupName: 'test',
|
||||
triggerPattern: trigger,
|
||||
timezone: 'UTC',
|
||||
deps,
|
||||
});
|
||||
expect(result.handled).toBe(false);
|
||||
});
|
||||
|
||||
it('handles authorized /compact in main group', async () => {
|
||||
const deps = makeDeps();
|
||||
const result = await handleSessionCommand({
|
||||
missedMessages: [makeMsg('/compact')],
|
||||
isMainGroup: true,
|
||||
groupName: 'test',
|
||||
triggerPattern: trigger,
|
||||
timezone: 'UTC',
|
||||
deps,
|
||||
});
|
||||
expect(result).toEqual({ handled: true, success: true });
|
||||
expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function));
|
||||
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
|
||||
});
|
||||
|
||||
it('sends denial to interactable sender in non-main group', async () => {
|
||||
const deps = makeDeps();
|
||||
const result = await handleSessionCommand({
|
||||
missedMessages: [makeMsg('/compact', { is_from_me: false })],
|
||||
isMainGroup: false,
|
||||
groupName: 'test',
|
||||
triggerPattern: trigger,
|
||||
timezone: 'UTC',
|
||||
deps,
|
||||
});
|
||||
expect(result).toEqual({ handled: true, success: true });
|
||||
expect(deps.sendMessage).toHaveBeenCalledWith('Session commands require admin access.');
|
||||
expect(deps.runAgent).not.toHaveBeenCalled();
|
||||
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
|
||||
});
|
||||
|
||||
it('silently consumes denied command when sender cannot interact', async () => {
|
||||
const deps = makeDeps({ canSenderInteract: vi.fn().mockReturnValue(false) });
|
||||
const result = await handleSessionCommand({
|
||||
missedMessages: [makeMsg('/compact', { is_from_me: false })],
|
||||
isMainGroup: false,
|
||||
groupName: 'test',
|
||||
triggerPattern: trigger,
|
||||
timezone: 'UTC',
|
||||
deps,
|
||||
});
|
||||
expect(result).toEqual({ handled: true, success: true });
|
||||
expect(deps.sendMessage).not.toHaveBeenCalled();
|
||||
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
|
||||
});
|
||||
|
||||
it('processes pre-compact messages before /compact', async () => {
|
||||
const deps = makeDeps();
|
||||
const msgs = [
|
||||
makeMsg('summarize this', { timestamp: '99' }),
|
||||
makeMsg('/compact', { timestamp: '100' }),
|
||||
];
|
||||
const result = await handleSessionCommand({
|
||||
missedMessages: msgs,
|
||||
isMainGroup: true,
|
||||
groupName: 'test',
|
||||
triggerPattern: trigger,
|
||||
timezone: 'UTC',
|
||||
deps,
|
||||
});
|
||||
expect(result).toEqual({ handled: true, success: true });
|
||||
expect(deps.formatMessages).toHaveBeenCalledWith([msgs[0]], 'UTC');
|
||||
// Two runAgent calls: pre-compact + /compact
|
||||
expect(deps.runAgent).toHaveBeenCalledTimes(2);
|
||||
expect(deps.runAgent).toHaveBeenCalledWith('<formatted>', expect.any(Function));
|
||||
expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function));
|
||||
});
|
||||
|
||||
it('allows is_from_me sender in non-main group', async () => {
|
||||
const deps = makeDeps();
|
||||
const result = await handleSessionCommand({
|
||||
missedMessages: [makeMsg('/compact', { is_from_me: true })],
|
||||
isMainGroup: false,
|
||||
groupName: 'test',
|
||||
triggerPattern: trigger,
|
||||
timezone: 'UTC',
|
||||
deps,
|
||||
});
|
||||
expect(result).toEqual({ handled: true, success: true });
|
||||
expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function));
|
||||
});
|
||||
|
||||
it('reports failure when command-stage runAgent returns error without streamed status', async () => {
|
||||
// runAgent resolves 'error' but callback never gets status: 'error'
|
||||
const deps = makeDeps({ runAgent: vi.fn().mockImplementation(async (prompt, onOutput) => {
|
||||
await onOutput({ status: 'success', result: null });
|
||||
return 'error';
|
||||
})});
|
||||
const result = await handleSessionCommand({
|
||||
missedMessages: [makeMsg('/compact')],
|
||||
isMainGroup: true,
|
||||
groupName: 'test',
|
||||
triggerPattern: trigger,
|
||||
timezone: 'UTC',
|
||||
deps,
|
||||
});
|
||||
expect(result).toEqual({ handled: true, success: true });
|
||||
expect(deps.sendMessage).toHaveBeenCalledWith(expect.stringContaining('failed'));
|
||||
});
|
||||
|
||||
it('returns success:false on pre-compact failure with no output', async () => {
|
||||
const deps = makeDeps({ runAgent: vi.fn().mockResolvedValue('error') });
|
||||
const msgs = [
|
||||
makeMsg('summarize this', { timestamp: '99' }),
|
||||
makeMsg('/compact', { timestamp: '100' }),
|
||||
];
|
||||
const result = await handleSessionCommand({
|
||||
missedMessages: msgs,
|
||||
isMainGroup: true,
|
||||
groupName: 'test',
|
||||
triggerPattern: trigger,
|
||||
timezone: 'UTC',
|
||||
deps,
|
||||
});
|
||||
expect(result).toEqual({ handled: true, success: false });
|
||||
expect(deps.sendMessage).toHaveBeenCalledWith(expect.stringContaining('Failed to process'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { NewMessage } from './types.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* Extract a session slash command from a message, stripping the trigger prefix if present.
|
||||
* Returns the slash command (e.g., '/compact') or null if not a session command.
|
||||
*/
|
||||
export function extractSessionCommand(content: string, triggerPattern: RegExp): string | null {
|
||||
let text = content.trim();
|
||||
text = text.replace(triggerPattern, '').trim();
|
||||
if (text === '/compact') return '/compact';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session command sender is authorized.
|
||||
* Allowed: main group (any sender), or trusted/admin sender (is_from_me) in any group.
|
||||
*/
|
||||
export function isSessionCommandAllowed(isMainGroup: boolean, isFromMe: boolean): boolean {
|
||||
return isMainGroup || isFromMe;
|
||||
}
|
||||
|
||||
/** Minimal agent result interface — matches the subset of ContainerOutput used here. */
|
||||
export interface AgentResult {
|
||||
status: 'success' | 'error';
|
||||
result?: string | object | null;
|
||||
}
|
||||
|
||||
/** Dependencies injected by the orchestrator. */
|
||||
export interface SessionCommandDeps {
|
||||
sendMessage: (text: string) => Promise<void>;
|
||||
setTyping: (typing: boolean) => Promise<void>;
|
||||
runAgent: (
|
||||
prompt: string,
|
||||
onOutput: (result: AgentResult) => Promise<void>,
|
||||
) => Promise<'success' | 'error'>;
|
||||
closeStdin: () => void;
|
||||
advanceCursor: (timestamp: string) => void;
|
||||
formatMessages: (msgs: NewMessage[], timezone: string) => string;
|
||||
/** Whether the denied sender would normally be allowed to interact (for denial messages). */
|
||||
canSenderInteract: (msg: NewMessage) => boolean;
|
||||
}
|
||||
|
||||
function resultToText(result: string | object | null | undefined): string {
|
||||
if (!result) return '';
|
||||
const raw = typeof result === 'string' ? result : JSON.stringify(result);
|
||||
return raw.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle session command interception in processGroupMessages.
|
||||
* Scans messages for a session command, handles auth + execution.
|
||||
* Returns { handled: true, success } if a command was found; { handled: false } otherwise.
|
||||
* success=false means the caller should retry (cursor was not advanced).
|
||||
*/
|
||||
export async function handleSessionCommand(opts: {
|
||||
missedMessages: NewMessage[];
|
||||
isMainGroup: boolean;
|
||||
groupName: string;
|
||||
triggerPattern: RegExp;
|
||||
timezone: string;
|
||||
deps: SessionCommandDeps;
|
||||
}): Promise<{ handled: false } | { handled: true; success: boolean }> {
|
||||
const { missedMessages, isMainGroup, groupName, triggerPattern, timezone, deps } = opts;
|
||||
|
||||
const cmdMsg = missedMessages.find(
|
||||
(m) => extractSessionCommand(m.content, triggerPattern) !== null,
|
||||
);
|
||||
const command = cmdMsg ? extractSessionCommand(cmdMsg.content, triggerPattern) : null;
|
||||
|
||||
if (!command || !cmdMsg) return { handled: false };
|
||||
|
||||
if (!isSessionCommandAllowed(isMainGroup, cmdMsg.is_from_me === true)) {
|
||||
// DENIED: send denial if the sender would normally be allowed to interact,
|
||||
// then silently consume the command by advancing the cursor past it.
|
||||
// Trade-off: other messages in the same batch are also consumed (cursor is
|
||||
// a high-water mark). Acceptable for this narrow edge case.
|
||||
if (deps.canSenderInteract(cmdMsg)) {
|
||||
await deps.sendMessage('Session commands require admin access.');
|
||||
}
|
||||
deps.advanceCursor(cmdMsg.timestamp);
|
||||
return { handled: true, success: true };
|
||||
}
|
||||
|
||||
// AUTHORIZED: process pre-compact messages first, then run the command
|
||||
logger.info({ group: groupName, command }, 'Session command');
|
||||
|
||||
const cmdIndex = missedMessages.indexOf(cmdMsg);
|
||||
const preCompactMsgs = missedMessages.slice(0, cmdIndex);
|
||||
|
||||
// Send pre-compact messages to the agent so they're in the session context.
|
||||
if (preCompactMsgs.length > 0) {
|
||||
const prePrompt = deps.formatMessages(preCompactMsgs, timezone);
|
||||
let hadPreError = false;
|
||||
let preOutputSent = false;
|
||||
|
||||
const preResult = await deps.runAgent(prePrompt, async (result) => {
|
||||
if (result.status === 'error') hadPreError = true;
|
||||
const text = resultToText(result.result);
|
||||
if (text) {
|
||||
await deps.sendMessage(text);
|
||||
preOutputSent = true;
|
||||
}
|
||||
// Close stdin on session-update marker — emitted after query completes,
|
||||
// so all results (including multi-result runs) are already written.
|
||||
if (result.status === 'success' && result.result === null) {
|
||||
deps.closeStdin();
|
||||
}
|
||||
});
|
||||
|
||||
if (preResult === 'error' || hadPreError) {
|
||||
logger.warn({ group: groupName }, 'Pre-compact processing failed, aborting session command');
|
||||
await deps.sendMessage(`Failed to process messages before ${command}. Try again.`);
|
||||
if (preOutputSent) {
|
||||
// Output was already sent — don't retry or it will duplicate.
|
||||
// Advance cursor past pre-compact messages, leave command pending.
|
||||
deps.advanceCursor(preCompactMsgs[preCompactMsgs.length - 1].timestamp);
|
||||
return { handled: true, success: true };
|
||||
}
|
||||
return { handled: true, success: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Forward the literal slash command as the prompt (no XML formatting)
|
||||
await deps.setTyping(true);
|
||||
|
||||
let hadCmdError = false;
|
||||
const cmdOutput = await deps.runAgent(command, async (result) => {
|
||||
if (result.status === 'error') hadCmdError = true;
|
||||
const text = resultToText(result.result);
|
||||
if (text) await deps.sendMessage(text);
|
||||
});
|
||||
|
||||
// Advance cursor to the command — messages AFTER it remain pending for next poll.
|
||||
deps.advanceCursor(cmdMsg.timestamp);
|
||||
await deps.setTyping(false);
|
||||
|
||||
if (cmdOutput === 'error' || hadCmdError) {
|
||||
await deps.sendMessage(`${command} failed. The session is unchanged.`);
|
||||
}
|
||||
|
||||
return { handled: true, success: true };
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
/**
|
||||
* parseTextStyles — convert Claude's Markdown output to channel-native formatting.
|
||||
*
|
||||
* Claude outputs standard Markdown. Each channel has its own text style syntax:
|
||||
* - Signal: passthrough (SignalChannel handles rich text styles natively
|
||||
* via the signal-cli JSON-RPC textStyle param — see parseSignalStyles)
|
||||
* - WhatsApp / Telegram: *bold*, _italic_, no headings, plain links
|
||||
* - Slack: *bold*, _italic_, <url|text> links
|
||||
* - Discord: passthrough (already Markdown)
|
||||
*
|
||||
* Code blocks (fenced and inline) are NEVER transformed by marker substitution.
|
||||
*/
|
||||
|
||||
export type ChannelType =
|
||||
| 'signal'
|
||||
| 'whatsapp'
|
||||
| 'telegram'
|
||||
| 'slack'
|
||||
| 'discord';
|
||||
|
||||
/** Transform Markdown text for the target channel's native format. */
|
||||
export function parseTextStyles(text: string, channel: ChannelType): string {
|
||||
if (!text) return text;
|
||||
|
||||
// Discord and Signal are passthrough — no marker substitution.
|
||||
// Discord is already Markdown; Signal uses parseSignalStyles() for rich text.
|
||||
if (channel === 'discord' || channel === 'signal') return text;
|
||||
|
||||
// Split into protected (code) and unprotected regions, transform only the latter.
|
||||
const segments = splitProtectedRegions(text);
|
||||
return segments
|
||||
.map(({ content, protected: isProtected }) =>
|
||||
isProtected ? content : transformSegment(content, channel),
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signal rich-text formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SignalTextStyle {
|
||||
/** One of Signal's supported text styles. */
|
||||
style: 'BOLD' | 'ITALIC' | 'STRIKETHROUGH' | 'MONOSPACE' | 'SPOILER';
|
||||
/** Start position in the final message string, in UTF-16 code units. */
|
||||
start: number;
|
||||
/** Length of the styled range, in UTF-16 code units. */
|
||||
length: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Claude's Markdown into a plain string + Signal textStyle ranges.
|
||||
*
|
||||
* The returned `text` has all markdown markers stripped. The `textStyle`
|
||||
* array uses UTF-16 code-unit offsets (JavaScript's native string indexing),
|
||||
* matching what signal-cli's JSON-RPC `send.textStyle` param expects.
|
||||
*
|
||||
* Supported patterns:
|
||||
* **bold** → BOLD
|
||||
* *italic* → ITALIC
|
||||
* _italic_ → ITALIC
|
||||
* ~~strike~~ → STRIKETHROUGH
|
||||
* `inline code` → MONOSPACE
|
||||
* ```code block``` → MONOSPACE
|
||||
* ## Heading → BOLD (markers stripped)
|
||||
* [text](url) → "text (url)" (no style)
|
||||
* --- → removed
|
||||
*/
|
||||
export function parseSignalStyles(rawText: string): {
|
||||
text: string;
|
||||
textStyle: SignalTextStyle[];
|
||||
} {
|
||||
const textStyle: SignalTextStyle[] = [];
|
||||
let out = '';
|
||||
let i = 0;
|
||||
const s = rawText;
|
||||
const n = s.length;
|
||||
|
||||
function addStyle(
|
||||
style: SignalTextStyle['style'],
|
||||
startOut: number,
|
||||
endOut: number,
|
||||
): void {
|
||||
const length = endOut - startOut;
|
||||
if (length > 0) textStyle.push({ style, start: startOut, length });
|
||||
}
|
||||
|
||||
while (i < n) {
|
||||
// ── Fenced code block ```[lang]\n...\n``` ──────────────────────────
|
||||
if (s[i] === '`' && s[i + 1] === '`' && s[i + 2] === '`') {
|
||||
const langNl = s.indexOf('\n', i + 3);
|
||||
if (langNl !== -1) {
|
||||
// Find closing ``` on its own line
|
||||
const closeAt = s.indexOf('\n```', langNl);
|
||||
if (closeAt !== -1) {
|
||||
const content = s.slice(langNl + 1, closeAt);
|
||||
const startOut = out.length;
|
||||
out += content;
|
||||
addStyle('MONOSPACE', startOut, out.length);
|
||||
// Advance past \n``` + optional trailing newline
|
||||
const afterClose = s.indexOf('\n', closeAt + 4);
|
||||
i = afterClose !== -1 ? afterClose + 1 : n;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Malformed fence — copy literally
|
||||
out += s[i];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Inline code `text` ────────────────────────────────────────────
|
||||
if (s[i] === '`') {
|
||||
const end = s.indexOf('`', i + 1);
|
||||
const nl = s.indexOf('\n', i + 1);
|
||||
if (end !== -1 && (nl === -1 || end < nl)) {
|
||||
const content = s.slice(i + 1, end);
|
||||
const startOut = out.length;
|
||||
out += content;
|
||||
addStyle('MONOSPACE', startOut, out.length);
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bold **text** ─────────────────────────────────────────────────
|
||||
if (s[i] === '*' && s[i + 1] === '*' && s[i + 2] && s[i + 2] !== ' ') {
|
||||
const end = s.indexOf('**', i + 2);
|
||||
if (end !== -1 && s[end - 1] !== ' ') {
|
||||
const content = s.slice(i + 2, end);
|
||||
const startOut = out.length;
|
||||
out += content;
|
||||
addStyle('BOLD', startOut, out.length);
|
||||
i = end + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Strikethrough ~~text~~ ────────────────────────────────────────
|
||||
if (s[i] === '~' && s[i + 1] === '~' && s[i + 2] && s[i + 2] !== ' ') {
|
||||
const end = s.indexOf('~~', i + 2);
|
||||
if (end !== -1) {
|
||||
const content = s.slice(i + 2, end);
|
||||
const startOut = out.length;
|
||||
out += content;
|
||||
addStyle('STRIKETHROUGH', startOut, out.length);
|
||||
i = end + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Italic *text* (single star, not part of **) ─────────────────
|
||||
if (
|
||||
s[i] === '*' &&
|
||||
s[i + 1] !== '*' &&
|
||||
s[i + 1] !== ' ' &&
|
||||
s[i + 1] !== undefined
|
||||
) {
|
||||
const end = findClosingStar(s, i + 1);
|
||||
if (end !== -1) {
|
||||
const content = s.slice(i + 1, end);
|
||||
const startOut = out.length;
|
||||
out += content;
|
||||
addStyle('ITALIC', startOut, out.length);
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Italic _text_ (only at word boundaries) ──────────────────────
|
||||
if (s[i] === '_' && s[i + 1] !== '_' && s[i + 1] !== ' ' && s[i + 1]) {
|
||||
// Guard against snake_case: only treat as italic when preceded by a
|
||||
// non-word character (or start of string).
|
||||
const prevChar = i > 0 ? s[i - 1] : '';
|
||||
if (!/\w/.test(prevChar)) {
|
||||
const end = findClosingUnderscore(s, i + 1);
|
||||
if (end !== -1) {
|
||||
const content = s.slice(i + 1, end);
|
||||
const startOut = out.length;
|
||||
out += content;
|
||||
addStyle('ITALIC', startOut, out.length);
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ATX Heading ## text → text (as BOLD) ─────────────────────────
|
||||
if ((i === 0 || s[i - 1] === '\n') && s[i] === '#') {
|
||||
let j = i;
|
||||
while (j < n && s[j] === '#') j++;
|
||||
if (j < n && s[j] === ' ') {
|
||||
const lineEnd = s.indexOf('\n', j + 1);
|
||||
const headingText =
|
||||
lineEnd !== -1 ? s.slice(j + 1, lineEnd) : s.slice(j + 1);
|
||||
const startOut = out.length;
|
||||
out += headingText;
|
||||
addStyle('BOLD', startOut, out.length);
|
||||
if (lineEnd !== -1) {
|
||||
out += '\n';
|
||||
i = lineEnd + 1;
|
||||
} else i = n;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Links [text](url) → text (url) ───────────────────────────────
|
||||
if (s[i] === '[') {
|
||||
const closeBracket = s.indexOf(']', i + 1);
|
||||
if (closeBracket !== -1 && s[closeBracket + 1] === '(') {
|
||||
const closeParen = s.indexOf(')', closeBracket + 2);
|
||||
if (closeParen !== -1) {
|
||||
const linkText = s.slice(i + 1, closeBracket);
|
||||
const url = s.slice(closeBracket + 2, closeParen);
|
||||
out += `${linkText} (${url})`;
|
||||
i = closeParen + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Horizontal rule --- / *** / ___ ──────────────────────────────
|
||||
if (i === 0 || s[i - 1] === '\n') {
|
||||
const hrMatch = /^(-{3,}|\*{3,}|_{3,}) *(\n|$)/.exec(s.slice(i));
|
||||
if (hrMatch) {
|
||||
i += hrMatch[0].length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Default: copy character, preserving surrogate pairs ───────────
|
||||
const code = s.charCodeAt(i);
|
||||
if (code >= 0xd800 && code <= 0xdbff && i + 1 < n) {
|
||||
out += s[i] + s[i + 1];
|
||||
i += 2;
|
||||
} else {
|
||||
out += s[i];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return { text: out, textStyle };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers for parseSignalStyles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Find the position of a closing single `*` that isn't part of `**`. */
|
||||
function findClosingStar(s: string, from: number): number {
|
||||
for (let i = from; i < s.length; i++) {
|
||||
if (s[i] === '\n') return -1; // italics don't span lines
|
||||
if (s[i] === '*' && s[i + 1] !== '*' && s[i - 1] !== ' ') return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Find the closing `_` that isn't part of `__` and is at a word boundary. */
|
||||
function findClosingUnderscore(s: string, from: number): number {
|
||||
for (let i = from; i < s.length; i++) {
|
||||
if (s[i] === '\n') return -1;
|
||||
if (s[i] === '_' && s[i + 1] !== '_' && !/\w/.test(s[i + 1] ?? '')) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Marker-substitution helpers (WhatsApp / Telegram / Slack)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Segment {
|
||||
content: string;
|
||||
protected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split text into alternating unprotected/protected segments.
|
||||
* Protected = fenced code blocks (```...```) and inline code (`...`).
|
||||
*/
|
||||
function splitProtectedRegions(text: string): Segment[] {
|
||||
const segments: Segment[] = [];
|
||||
const CODE_PATTERN = /```[\s\S]*?```|`[^`\n]+`/g;
|
||||
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = CODE_PATTERN.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({
|
||||
content: text.slice(lastIndex, match.index),
|
||||
protected: false,
|
||||
});
|
||||
}
|
||||
segments.push({ content: match[0], protected: true });
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
segments.push({ content: text.slice(lastIndex), protected: false });
|
||||
}
|
||||
|
||||
return segments.length > 0 ? segments : [{ content: text, protected: false }];
|
||||
}
|
||||
|
||||
/** Apply marker-substitution transformations to a non-code segment. */
|
||||
function transformSegment(text: string, channel: ChannelType): string {
|
||||
let t = text;
|
||||
|
||||
// Order matters: italic before bold.
|
||||
// The italic regex won't match **bold** (it requires the char after the opening *
|
||||
// to be a non-* non-space), so running italic first is safe. If we ran bold
|
||||
// first (**bold** → *bold*), the italic step would immediately re-convert *bold*
|
||||
// to _bold_, producing wrong output.
|
||||
|
||||
// 1. Italic: *text* → _text_ (whatsapp/telegram/slack use _)
|
||||
t = t.replace(/(?<!\*)\*(?=[^\s*])([^*\n]+?)(?<=[^\s*])\*(?!\*)/g, '_$1_');
|
||||
|
||||
// 2. Bold: **text** → *text* (whatsapp/telegram/slack use single *)
|
||||
t = t.replace(/\*\*(?=[^\s*])([^*]+?)(?<=[^\s*])\*\*/g, '*$1*');
|
||||
|
||||
// 3. Headings: ## Title → *Title* (any level, line-start only)
|
||||
t = t.replace(/^#{1,6}\s+(.+)$/gm, '*$1*');
|
||||
|
||||
// 4. Links
|
||||
if (channel === 'slack') {
|
||||
t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<$2|$1>');
|
||||
} else {
|
||||
t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)');
|
||||
}
|
||||
|
||||
// 5. Horizontal rules: strip them
|
||||
t = t.replace(/^(-{3,}|\*{3,}|_{3,})$/gm, '');
|
||||
|
||||
return t;
|
||||
}
|
||||
Reference in New Issue
Block a user