diff --git a/src/agents/agents.test.ts b/src/agents/agents.test.ts index 1d55da210..7708aa004 100644 --- a/src/agents/agents.test.ts +++ b/src/agents/agents.test.ts @@ -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({ diff --git a/src/agents/claude.ts b/src/agents/claude.ts index 22f8e7ed6..3db224221 100644 --- a/src/agents/claude.ts +++ b/src/agents/claude.ts @@ -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]; }, diff --git a/src/agents/gemini.ts b/src/agents/gemini.ts index ce0d68165..10ab48289 100644 --- a/src/agents/gemini.ts +++ b/src/agents/gemini.ts @@ -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] diff --git a/src/agents/opencode.ts b/src/agents/opencode.ts index 836b7c4e0..e33708301 100644 --- a/src/agents/opencode.ts +++ b/src/agents/opencode.ts @@ -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] diff --git a/src/agents/pi.ts b/src/agents/pi.ts index 195315ffc..629e1ee18 100644 --- a/src/agents/pi.ts +++ b/src/agents/pi.ts @@ -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) diff --git a/src/auto-reply/command-reply.ts b/src/auto-reply/command-reply.ts index 6eecb945a..5dd77ff93 100644 --- a/src/auto-reply/command-reply.ts +++ b/src/auto-reply/command-reply.ts @@ -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; diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index a425cd321..056a062ad 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -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, diff --git a/src/cli/multi-relay.test.ts b/src/cli/multi-relay.test.ts index d4a9831b0..c9d655101 100644 --- a/src/cli/multi-relay.test.ts +++ b/src/cli/multi-relay.test.ts @@ -126,7 +126,7 @@ describe("runMultiProviderRelay", () => { undefined, mockRuntime, expect.any(AbortSignal), - webTuning, + { ...webTuning, suppressStartMessage: true }, ); }); diff --git a/src/config/config.ts b/src/config/config.ts index a34a928cc..d35a0bea7 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -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) diff --git a/src/telegram/monitor.test.ts b/src/telegram/monitor.test.ts index 52f78d0f5..9fbb9e588 100644 --- a/src/telegram/monitor.test.ts +++ b/src/telegram/monitor.test.ts @@ -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", diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index 75113b284..4ff7a6d96 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -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): 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, +): 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 { - 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, };