fix: session management and Telegram message formatting

- Fix /new command session creation by checking isNewSession flag
- Add timestamp prefix to Telegram messages matching WhatsApp format
- Add reply logging for Telegram matching WhatsApp behavior
- Move think level token extraction before bodyPrefix to avoid false matches
- Update agent specs (claude, gemini, opencode) to send identity on new sessions
- Pass sessionIntro in command context for proper agent initialization
This commit is contained in:
Arne Moor 2025-12-06 02:27:37 +01:00
parent 08bede3a0b
commit a67243dd49
11 changed files with 104 additions and 45 deletions

View File

@ -39,6 +39,38 @@ describe("agent buildArgs + parseOutput helpers", () => {
expect(builtNoIdentity.at(-1)).not.toContain(CLAUDE_IDENTITY_PREFIX); expect(builtNoIdentity.at(-1)).not.toContain(CLAUDE_IDENTITY_PREFIX);
}); });
it("claudeSpec prepends identity even when body is empty (e.g. /new)", () => {
// This is the /new reset trigger case - body is empty after reset trigger is stripped
const argv = ["claude", ""];
const built = claudeSpec.buildArgs({
argv,
bodyIndex: 1,
isNewSession: true,
sessionId: "sess",
sendSystemOnce: true,
systemSent: false,
identityPrefix: "Custom session intro here",
format: "json",
});
// Should still prepend the identity prefix even though body is empty
const lastArg = built.at(-1);
expect(lastArg).toContain("Custom session intro here");
// Verify it doesn't prepend identity when systemSent is true
const builtAfterSystem = claudeSpec.buildArgs({
argv,
bodyIndex: 1,
isNewSession: false,
sessionId: "sess",
sendSystemOnce: true,
systemSent: true,
identityPrefix: "Custom session intro here",
format: "json",
});
expect(builtAfterSystem.at(-1)).toBe("");
});
it("opencodeSpec adds format flag and identity prefix when needed", () => { it("opencodeSpec adds format flag and identity prefix when needed", () => {
const argv = ["opencode", "body"]; const argv = ["opencode", "body"];
const built = opencodeSpec.buildArgs({ const built = opencodeSpec.buildArgs({

View File

@ -55,13 +55,12 @@ export const claudeSpec: AgentSpec = {
beforeBody.push("-p"); beforeBody.push("-p");
} }
const shouldPrependIdentity = !(ctx.sendSystemOnce && ctx.systemSent); const shouldPrependIdentity = ctx.isNewSession || !(ctx.sendSystemOnce && ctx.systemSent);
const bodyWithIdentity = const bodyWithIdentity = shouldPrependIdentity
shouldPrependIdentity && body ? [ctx.identityPrefix ?? CLAUDE_IDENTITY_PREFIX, body]
? [ctx.identityPrefix ?? CLAUDE_IDENTITY_PREFIX, body] .filter(Boolean)
.filter(Boolean) .join("\n\n")
.join("\n\n") : body;
: body;
return [...beforeBody, bodyWithIdentity, ...afterBody]; return [...beforeBody, bodyWithIdentity, ...afterBody];
}, },

View File

@ -40,7 +40,7 @@ export const geminiSpec: AgentSpec = {
} }
} }
const shouldPrependIdentity = !(ctx.sendSystemOnce && ctx.systemSent); const shouldPrependIdentity = ctx.isNewSession || !(ctx.sendSystemOnce && ctx.systemSent);
const bodyWithIdentity = const bodyWithIdentity =
shouldPrependIdentity && body shouldPrependIdentity && body
? [ctx.identityPrefix ?? GEMINI_IDENTITY_PREFIX, body] ? [ctx.identityPrefix ?? GEMINI_IDENTITY_PREFIX, body]

View File

@ -41,7 +41,7 @@ export const opencodeSpec: AgentSpec = {
// Identity prefix // Identity prefix
// Opencode streams text tokens; we still seed an identity so the agent // Opencode streams text tokens; we still seed an identity so the agent
// keeps context on first turn. // keeps context on first turn.
const shouldPrependIdentity = !(ctx.sendSystemOnce && ctx.systemSent); const shouldPrependIdentity = ctx.isNewSession || !(ctx.sendSystemOnce && ctx.systemSent);
const bodyWithIdentity = const bodyWithIdentity =
shouldPrependIdentity && body shouldPrependIdentity && body
? [ctx.identityPrefix ?? OPENCODE_IDENTITY_PREFIX, body] ? [ctx.identityPrefix ?? OPENCODE_IDENTITY_PREFIX, body]

View File

@ -158,7 +158,8 @@ export const piSpec: AgentSpec = {
} }
// Session defaults // Session defaults
// Identity prefix optional; Pi usually doesn't need it, but allow injection // Identity prefix optional; Pi usually doesn't need it, but allow injection
if (!(ctx.sendSystemOnce && ctx.systemSent) && argv[ctx.bodyIndex]) { const shouldPrependIdentity = ctx.isNewSession || !(ctx.sendSystemOnce && ctx.systemSent);
if (shouldPrependIdentity && argv[ctx.bodyIndex]) {
const existingBody = argv[ctx.bodyIndex]; const existingBody = argv[ctx.bodyIndex];
argv[ctx.bodyIndex] = [ctx.identityPrefix, existingBody] argv[ctx.bodyIndex] = [ctx.identityPrefix, existingBody]
.filter(Boolean) .filter(Boolean)

View File

@ -36,6 +36,7 @@ type CommandReplyParams = {
isNewSession: boolean; isNewSession: boolean;
isFirstTurnInSession: boolean; isFirstTurnInSession: boolean;
systemSent: boolean; systemSent: boolean;
sessionIntro?: string;
timeoutMs: number; timeoutMs: number;
timeoutSeconds: number; timeoutSeconds: number;
commandRunner: typeof runCommandWithTimeout; commandRunner: typeof runCommandWithTimeout;
@ -288,6 +289,7 @@ export async function runCommandReply(
isNewSession, isNewSession,
isFirstTurnInSession, isFirstTurnInSession,
systemSent, systemSent,
sessionIntro,
timeoutMs, timeoutMs,
timeoutSeconds, timeoutSeconds,
commandRunner, commandRunner,
@ -392,7 +394,7 @@ export async function runCommandReply(
sessionId: templatingCtx.SessionId, sessionId: templatingCtx.SessionId,
sendSystemOnce, sendSystemOnce,
systemSent, systemSent,
identityPrefix: agentCfg.identityPrefix, identityPrefix: agentCfg.identityPrefix ?? sessionIntro,
format: agentCfg.format, format: agentCfg.format,
}) })
: argv; : argv;

View File

@ -471,9 +471,10 @@ export async function getReplyFromConfig(
// Optional prefix injected before Body for templating/command prompts. // Optional prefix injected before Body for templating/command prompts.
const sendSystemOnce = sessionCfg?.sendSystemOnce === true; const sendSystemOnce = sessionCfg?.sendSystemOnce === true;
const isFirstTurnInSession = isNewSession || !systemSent; const isFirstTurnInSession = isNewSession || !systemSent;
const sessionIntroConfig = sessionCfg?.sessionIntro;
const sessionIntro = const sessionIntro =
isFirstTurnInSession && sessionCfg?.sessionIntro isFirstTurnInSession && sessionIntroConfig
? applyTemplate(sessionCfg.sessionIntro, sessionCtx) ? applyTemplate(sessionIntroConfig, sessionCtx)
: ""; : "";
const groupIntro = const groupIntro =
isFirstTurnInSession && sessionCtx.ChatType === "group" isFirstTurnInSession && sessionCtx.ChatType === "group"
@ -497,7 +498,19 @@ export async function getReplyFromConfig(
const bodyPrefix = reply?.bodyPrefix const bodyPrefix = reply?.bodyPrefix
? applyTemplate(reply.bodyPrefix, sessionCtx) ? applyTemplate(reply.bodyPrefix, sessionCtx)
: ""; : "";
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; let baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
// Extract stray think level token from user's message BEFORE adding prefixes
// (to avoid removing "Think" from bodyPrefix like "Think deeply whenever asked.")
if (!resolvedThinkLevel && baseBody) {
const parts = baseBody.split(/\s+/);
const maybeLevel = normalizeThinkLevel(parts[0]);
if (maybeLevel) {
resolvedThinkLevel = maybeLevel;
baseBody = parts.slice(1).join(" ").trim();
}
}
const abortedHint = const abortedHint =
reply?.mode === "command" && abortedLastRun reply?.mode === "command" && abortedLastRun
? "Note: The previous agent run was aborted by the user. Resume carefully or ask for clarification." ? "Note: The previous agent run was aborted by the user. Resume carefully or ask for clarification."
@ -562,22 +575,13 @@ export async function getReplyFromConfig(
mediaNote && reply?.mode === "command" mediaNote && reply?.mode === "command"
? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body." ? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body."
: undefined; : undefined;
let commandBody = mediaNote const commandBody = mediaNote
? [mediaNote, mediaReplyHint, prefixedBody ?? ""] ? [mediaNote, mediaReplyHint, prefixedBody ?? ""]
.filter(Boolean) .filter(Boolean)
.join("\n") .join("\n")
.trim() .trim()
: prefixedBody; : prefixedBody;
// Fallback: if a stray leading level token remains, consume it
if (!resolvedThinkLevel && commandBody) {
const parts = commandBody.split(/\s+/);
const maybeLevel = normalizeThinkLevel(parts[0]);
if (maybeLevel) {
resolvedThinkLevel = maybeLevel;
commandBody = parts.slice(1).join(" ").trim();
}
}
const templatingCtx: TemplateContext = { const templatingCtx: TemplateContext = {
...sessionCtx, ...sessionCtx,
Body: commandBody, Body: commandBody,
@ -629,6 +633,7 @@ export async function getReplyFromConfig(
isNewSession, isNewSession,
isFirstTurnInSession, isFirstTurnInSession,
systemSent, systemSent,
sessionIntro,
timeoutMs, timeoutMs,
timeoutSeconds, timeoutSeconds,
commandRunner, commandRunner,

View File

@ -126,7 +126,7 @@ describe("runMultiProviderRelay", () => {
undefined, undefined,
mockRuntime, mockRuntime,
expect.any(AbortSignal), expect.any(AbortSignal),
webTuning, { ...webTuning, suppressStartMessage: true },
); );
}); });

View File

@ -43,10 +43,6 @@ export type WebConfig = {
reconnect?: WebReconnectConfig; reconnect?: WebReconnectConfig;
}; };
export type TelegramConfig = {
allowFrom?: string[]; // @username or user IDs
};
export type GroupChatConfig = { export type GroupChatConfig = {
requireMention?: boolean; requireMention?: boolean;
mentionPatterns?: string[]; mentionPatterns?: string[];
@ -90,7 +86,6 @@ export type WarelayConfig = {
}; };
}; };
web?: WebConfig; web?: WebConfig;
telegram?: TelegramConfig;
}; };
// New branding path (preferred) // New branding path (preferred)

View File

@ -26,10 +26,8 @@ vi.mock("../env.js", () => ({
vi.mock("../config/config.js", () => ({ vi.mock("../config/config.js", () => ({
loadConfig: vi.fn(() => ({ loadConfig: vi.fn(() => ({
telegram: {
allowFrom: ["@testuser"],
},
inbound: { inbound: {
allowFrom: ["@testuser"],
reply: { reply: {
mode: "text", mode: "text",
text: "Auto-reply test", text: "Auto-reply test",
@ -207,10 +205,8 @@ describe("monitorTelegramProvider", () => {
// Set allowFrom filter // Set allowFrom filter
(loadConfig as Mock).mockReturnValueOnce({ (loadConfig as Mock).mockReturnValueOnce({
telegram: {
allowFrom: ["@alloweduser"],
},
inbound: { inbound: {
allowFrom: ["@alloweduser"],
reply: { reply: {
mode: "text", mode: "text",
text: "Auto-reply test", text: "Auto-reply test",

View File

@ -22,12 +22,37 @@ import { normalizeAllowFromEntry } from "../utils.js";
const TELEGRAM_TEXT_LIMIT = 4096; // Telegram's message length limit const TELEGRAM_TEXT_LIMIT = 4096; // Telegram's message length limit
/**
* Format timestamp in the same format as WhatsApp relay.
* Example: [Dec 5 22:41]
*/
function formatTimestamp(ts: number, config?: ReturnType<typeof loadConfig>): string {
const tsCfg = config?.inbound?.timestampPrefix;
const tsEnabled = tsCfg !== false; // default true
if (!tsEnabled) return "";
const tz = typeof tsCfg === "string" ? tsCfg : "UTC";
const date = new Date(ts);
try {
return `[${date.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: tz })} ${date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: tz })}] `;
} catch {
return `[${date.toISOString().slice(5, 16).replace("T", " ")}] `;
}
}
/** /**
* Convert ProviderMessage to MsgContext for auto-reply system. * Convert ProviderMessage to MsgContext for auto-reply system.
*/ */
function providerMessageToContext(message: ProviderMessage): MsgContext { function providerMessageToContext(
message: ProviderMessage,
config?: ReturnType<typeof loadConfig>,
): MsgContext {
const timestampPrefix = formatTimestamp(message.timestamp, config);
const bodyWithTimestamp = timestampPrefix
? `${timestampPrefix}${message.body}`
: message.body;
return { return {
Body: message.body, Body: bodyWithTimestamp,
From: message.from, From: message.from,
To: message.to, To: message.to,
MessageSid: message.id, MessageSid: message.id,
@ -68,10 +93,13 @@ async function sendReply(
media: firstMedia ? [{ type: "image", url: firstMedia }] : undefined, media: firstMedia ? [{ type: "image", url: firstMedia }] : undefined,
}); });
// Log the reply text
runtime.log(`↩️ ${firstChunk}`);
if (isVerbose()) { if (isVerbose()) {
runtime.log( runtime.log(
success( success(
`↩️ Auto-replied to ${replyTo} via Telegram (id ${result.messageId})`, `Auto-replied to ${replyTo} via Telegram (id ${result.messageId})`,
), ),
); );
} }
@ -85,6 +113,7 @@ async function sendReply(
for (let i = 1; i < chunks.length; i++) { for (let i = 1; i < chunks.length; i++) {
try { try {
await provider.send(replyTo, chunks[i]); await provider.send(replyTo, chunks[i]);
runtime.log(`↩️ ${chunks[i]}`);
} catch (err) { } catch (err) {
runtime.error( runtime.error(
danger(`Failed to send Telegram reply chunk ${i}: ${String(err)}`), danger(`Failed to send Telegram reply chunk ${i}: ${String(err)}`),
@ -114,11 +143,11 @@ async function handleInboundMessage(
provider: Provider, provider: Provider,
runtime: RuntimeEnv, runtime: RuntimeEnv,
): Promise<void> { ): Promise<void> {
const ctx = providerMessageToContext(message);
const config = loadConfig(); const config = loadConfig();
const ctx = providerMessageToContext(message, config);
// Check allowFrom filter // Check allowFrom filter
const allowFrom = config.telegram?.allowFrom; const allowFrom = config.inbound?.allowFrom;
if (Array.isArray(allowFrom) && allowFrom.length > 0) { if (Array.isArray(allowFrom) && allowFrom.length > 0) {
if (!allowFrom.includes("*")) { if (!allowFrom.includes("*")) {
const normalizedFrom = normalizeAllowFromEntry(message.from, "telegram"); const normalizedFrom = normalizeAllowFromEntry(message.from, "telegram");
@ -128,7 +157,7 @@ async function handleInboundMessage(
if (!normalizedAllowList.includes(normalizedFrom)) { if (!normalizedAllowList.includes(normalizedFrom)) {
if (isVerbose()) { if (isVerbose()) {
logVerbose( logVerbose(
`Skipping auto-reply: sender ${message.from} not in telegram.allowFrom list`, `Skipping auto-reply: sender ${message.from} not in allowFrom list`,
); );
} }
return; return;
@ -137,9 +166,9 @@ async function handleInboundMessage(
} }
// Log inbound message // Log inbound message
const timestamp = new Date(message.timestamp).toISOString(); const formattedTs = formatTimestamp(message.timestamp, config);
runtime.log( runtime.log(
`\n[${timestamp}] ${message.from} -> ${message.to}: ${message.body}`, `\n${formattedTs}${message.from} -> ${message.to}: ${message.body}`,
); );
// Get reply from config // Get reply from config
@ -209,7 +238,7 @@ export async function monitorTelegramProvider(
apiId: Number.parseInt(env.telegram.apiId, 10), apiId: Number.parseInt(env.telegram.apiId, 10),
apiHash: env.telegram.apiHash, apiHash: env.telegram.apiHash,
sessionDir: undefined, // Uses default ~/.warelay/telegram sessionDir: undefined, // Uses default ~/.warelay/telegram
allowFrom: config.telegram?.allowFrom, allowFrom: config.inbound?.allowFrom,
verbose, verbose,
}; };