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);
});
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", () => {
const argv = ["opencode", "body"];
const built = opencodeSpec.buildArgs({

View File

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

View File

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

View File

@ -158,7 +158,8 @@ export const piSpec: AgentSpec = {
}
// Session defaults
// 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];
argv[ctx.bodyIndex] = [ctx.identityPrefix, existingBody]
.filter(Boolean)

View File

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

View File

@ -471,9 +471,10 @@ export async function getReplyFromConfig(
// Optional prefix injected before Body for templating/command prompts.
const sendSystemOnce = sessionCfg?.sendSystemOnce === true;
const isFirstTurnInSession = isNewSession || !systemSent;
const sessionIntroConfig = sessionCfg?.sessionIntro;
const sessionIntro =
isFirstTurnInSession && sessionCfg?.sessionIntro
? applyTemplate(sessionCfg.sessionIntro, sessionCtx)
isFirstTurnInSession && sessionIntroConfig
? applyTemplate(sessionIntroConfig, sessionCtx)
: "";
const groupIntro =
isFirstTurnInSession && sessionCtx.ChatType === "group"
@ -497,7 +498,19 @@ export async function getReplyFromConfig(
const bodyPrefix = reply?.bodyPrefix
? 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 =
reply?.mode === "command" && abortedLastRun
? "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"
? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body."
: undefined;
let commandBody = mediaNote
const commandBody = mediaNote
? [mediaNote, mediaReplyHint, prefixedBody ?? ""]
.filter(Boolean)
.join("\n")
.trim()
: 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 = {
...sessionCtx,
Body: commandBody,
@ -629,6 +633,7 @@ export async function getReplyFromConfig(
isNewSession,
isFirstTurnInSession,
systemSent,
sessionIntro,
timeoutMs,
timeoutSeconds,
commandRunner,

View File

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

View File

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

View File

@ -26,10 +26,8 @@ vi.mock("../env.js", () => ({
vi.mock("../config/config.js", () => ({
loadConfig: vi.fn(() => ({
telegram: {
allowFrom: ["@testuser"],
},
inbound: {
allowFrom: ["@testuser"],
reply: {
mode: "text",
text: "Auto-reply test",
@ -207,10 +205,8 @@ describe("monitorTelegramProvider", () => {
// Set allowFrom filter
(loadConfig as Mock).mockReturnValueOnce({
telegram: {
allowFrom: ["@alloweduser"],
},
inbound: {
allowFrom: ["@alloweduser"],
reply: {
mode: "text",
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
/**
* 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.
*/
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 {
Body: message.body,
Body: bodyWithTimestamp,
From: message.from,
To: message.to,
MessageSid: message.id,
@ -68,10 +93,13 @@ async function sendReply(
media: firstMedia ? [{ type: "image", url: firstMedia }] : undefined,
});
// Log the reply text
runtime.log(`↩️ ${firstChunk}`);
if (isVerbose()) {
runtime.log(
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++) {
try {
await provider.send(replyTo, chunks[i]);
runtime.log(`↩️ ${chunks[i]}`);
} catch (err) {
runtime.error(
danger(`Failed to send Telegram reply chunk ${i}: ${String(err)}`),
@ -114,11 +143,11 @@ async function handleInboundMessage(
provider: Provider,
runtime: RuntimeEnv,
): Promise<void> {
const ctx = providerMessageToContext(message);
const config = loadConfig();
const ctx = providerMessageToContext(message, config);
// Check allowFrom filter
const allowFrom = config.telegram?.allowFrom;
const allowFrom = config.inbound?.allowFrom;
if (Array.isArray(allowFrom) && allowFrom.length > 0) {
if (!allowFrom.includes("*")) {
const normalizedFrom = normalizeAllowFromEntry(message.from, "telegram");
@ -128,7 +157,7 @@ async function handleInboundMessage(
if (!normalizedAllowList.includes(normalizedFrom)) {
if (isVerbose()) {
logVerbose(
`Skipping auto-reply: sender ${message.from} not in telegram.allowFrom list`,
`Skipping auto-reply: sender ${message.from} not in allowFrom list`,
);
}
return;
@ -137,9 +166,9 @@ async function handleInboundMessage(
}
// Log inbound message
const timestamp = new Date(message.timestamp).toISOString();
const formattedTs = formatTimestamp(message.timestamp, config);
runtime.log(
`\n[${timestamp}] ${message.from} -> ${message.to}: ${message.body}`,
`\n${formattedTs}${message.from} -> ${message.to}: ${message.body}`,
);
// Get reply from config
@ -209,7 +238,7 @@ export async function monitorTelegramProvider(
apiId: Number.parseInt(env.telegram.apiId, 10),
apiHash: env.telegram.apiHash,
sessionDir: undefined, // Uses default ~/.warelay/telegram
allowFrom: config.telegram?.allowFrom,
allowFrom: config.inbound?.allowFrom,
verbose,
};