diff --git a/README.md b/README.md index e40292ae0..cc7380273 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,11 @@ In chat, send `/status` to see if the agent is reachable, how much context the s - Direct chats now share a canonical session key `main` by default (configurable via `inbound.reply.session.mainKey`). Groups stay isolated as `group:`. - WebChat always attaches to the `main` session and hydrates the full Tau history from `~/.clawdis/sessions/.jsonl`, so desktop view mirrors WhatsApp/Telegram turns. - Inbound contexts carry a `Surface` hint (e.g., `whatsapp`, `webchat`, `telegram`) for logging; replies still go back to the originating surface deterministically. +- Every inbound message is wrapped for the agent as `[Surface FROM HOST/IP TIMESTAMP] body`: + - WhatsApp: `[WhatsApp +15551234567 2025-12-09 12:34] …` + - Telegram: `[Telegram telegram:123456789 2025-12-09 12:34] …` + - WebChat: `[WebChat my-mac.local 10.0.0.5 2025-12-09 12:34] …` + This keeps the model aware of the transport, sender, host, and time without relying on implicit context. ## Credits diff --git a/src/auto-reply/envelope.test.ts b/src/auto-reply/envelope.test.ts new file mode 100644 index 000000000..12ef60086 --- /dev/null +++ b/src/auto-reply/envelope.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { formatAgentEnvelope } from "./envelope.js"; + +describe("formatAgentEnvelope", () => { + it("includes surface, from, ip, host, and timestamp", () => { + const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z + const body = formatAgentEnvelope({ + surface: "WebChat", + from: "user1", + host: "mac-mini", + ip: "10.0.0.5", + timestamp: ts, + body: "hello", + }); + expect(body).toBe("[WebChat user1 mac-mini 10.0.0.5 2025-01-02 03:04] hello"); + }); + + it("handles missing optional fields", () => { + const body = formatAgentEnvelope({ surface: "Telegram", body: "hi" }); + expect(body).toBe("[Telegram] hi"); + }); +}); diff --git a/src/auto-reply/envelope.ts b/src/auto-reply/envelope.ts new file mode 100644 index 000000000..d34022b53 --- /dev/null +++ b/src/auto-reply/envelope.ts @@ -0,0 +1,28 @@ +export type AgentEnvelopeParams = { + surface: string; + from?: string; + timestamp?: number | Date; + host?: string; + ip?: string; + body: string; +}; + +function formatTimestamp(ts?: number | Date): string | undefined { + if (!ts) return undefined; + const date = ts instanceof Date ? ts : new Date(ts); + if (Number.isNaN(date.getTime())) return undefined; + // Compact ISO-like format with minutes precision. + return date.toISOString().slice(0, 16).replace("T", " "); +} + +export function formatAgentEnvelope(params: AgentEnvelopeParams): string { + const surface = params.surface?.trim() || "Surface"; + const parts: string[] = [surface]; + if (params.from?.trim()) parts.push(params.from.trim()); + if (params.host?.trim()) parts.push(params.host.trim()); + if (params.ip?.trim()) parts.push(params.ip.trim()); + const ts = formatTimestamp(params.timestamp); + if (ts) parts.push(ts); + const header = `[${parts.join(" ")}]`; + return `${header} ${params.body}`; +} diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index a6976b4b0..e4282b1e6 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -23,7 +23,13 @@ vi.mock("@grammyjs/transformer-throttler", () => ({ apiThrottler: () => throttlerSpy(), })); +vi.mock("../auto-reply/reply.js", () => { + const replySpy = vi.fn(); + return { getReplyFromConfig: replySpy, __replySpy: replySpy }; +}); + import { createTelegramBot } from "./bot.js"; +import * as replyModule from "../auto-reply/reply.js"; describe("createTelegramBot", () => { it("installs grammY throttler", () => { @@ -31,4 +37,32 @@ describe("createTelegramBot", () => { expect(throttlerSpy).toHaveBeenCalledTimes(1); expect(useSpy).toHaveBeenCalledWith("throttler"); }); + + it("wraps inbound message with Telegram envelope", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + createTelegramBot({ token: "tok" }); + expect(onSpy).toHaveBeenCalledWith("message", expect.any(Function)); + const handler = onSpy.mock.calls[0][1] as (ctx: any) => Promise; + + const message = { + chat: { id: 1234, type: "private" }, + text: "hello world", + date: 1736380800, // 2025-01-09T00:00:00Z + }; + await handler({ + message, + me: { username: "clawdis_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toMatch(/^\[Telegram telegram:1234 2025-01-09 00:00]/); + expect(payload.Body).toContain("hello world"); + }); }); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index b342511ad..b9f86aaa6 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -7,6 +7,7 @@ import { Bot, InputFile, webhookCallback } from "grammy"; import { chunkText } from "../auto-reply/chunk.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; +import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { loadConfig } from "../config/config.js"; import { danger, logVerbose } from "../globals.js"; import { getChildLogger } from "../logging.js"; @@ -98,8 +99,15 @@ export function createTelegramBot(opts: TelegramBotOptions) { } const media = await resolveMedia(ctx, mediaMaxBytes); - const body = (msg.text ?? msg.caption ?? media?.placeholder ?? "").trim(); - if (!body) return; + const rawBody = (msg.text ?? msg.caption ?? media?.placeholder ?? "").trim(); + if (!rawBody) return; + + const body = formatAgentEnvelope({ + surface: "Telegram", + from: isGroup ? `group:${chatId}` : `telegram:${chatId}`, + timestamp: msg.date ? msg.date * 1000 : undefined, + body: rawBody, + }); const ctxPayload = { Body: body, diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 2071f6302..39d43c06c 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -680,8 +680,8 @@ describe("web auto-reply", () => { expect(resolver).toHaveBeenCalledTimes(1); const args = resolver.mock.calls[0][0]; - expect(args.Body).toContain("[Jan 1 00:00] [clawdis] first"); - expect(args.Body).toContain("[Jan 1 01:00] [clawdis] second"); + expect(args.Body).toContain("[WhatsApp +1 2025-01-01 00:00] [clawdis] first"); + expect(args.Body).toContain("[WhatsApp +1 2025-01-01 01:00] [clawdis] second"); // Max listeners bumped to avoid warnings in multi-instance test runs expect(process.getMaxListeners?.()).toBeGreaterThanOrEqual(50); @@ -1292,7 +1292,8 @@ describe("web auto-reply", () => { // The resolver should receive a prefixed body with the configured marker const callArg = resolver.mock.calls[0]?.[0] as { Body?: string }; expect(callArg?.Body).toBeDefined(); - expect(callArg?.Body).toBe("[same-phone] hello"); + expect(callArg?.Body).toContain("[WhatsApp +1555"); + expect(callArg?.Body).toContain("[same-phone] hello"); resetLoadConfigMock(); }); @@ -1324,9 +1325,10 @@ describe("web auto-reply", () => { sendMedia: vi.fn(), }); - // Body should NOT be prefixed + // Body should include envelope but not the same-phone prefix const callArg = resolver.mock.calls[0]?.[0] as { Body?: string }; - expect(callArg?.Body).toBe("hello"); + expect(callArg?.Body).toContain("[WhatsApp +1555"); + expect(callArg?.Body).toContain("hello"); }); it("applies responsePrefix to regular replies", async () => { diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 8faa4e3d9..f6cd5ef20 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -31,6 +31,8 @@ import { sleepWithAbort, } from "./reconnect.js"; import { formatError, getWebAuthAgeMs, readWebSelfId } from "./session.js"; +import { formatAgentEnvelope } from "../auto-reply/envelope.js"; +import { formatAgentEnvelope } from "../auto-reply/envelope.js"; const WEB_TEXT_LIMIT = 4000; const DEFAULT_GROUP_HISTORY_LIMIT = 50; @@ -759,19 +761,6 @@ export async function monitorWebProvider( type PendingBatch = { messages: WebInboundMsg[]; timer?: NodeJS.Timeout }; const pendingBatches = new Map(); - const formatTimestamp = (ts?: number) => { - const tsCfg = cfg.inbound?.timestampPrefix; - const tsEnabled = tsCfg !== false; // default true - if (!tsEnabled) return ""; - const tz = typeof tsCfg === "string" ? tsCfg : "UTC"; - const date = ts ? new Date(ts) : new Date(); - 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", " ")}] `; - } - }; - const buildLine = (msg: WebInboundMsg) => { // Build message prefix: explicit config > default based on allowFrom let messagePrefix = cfg.inbound?.messagePrefix; @@ -784,7 +773,18 @@ export async function monitorWebProvider( msg.chatType === "group" ? `${msg.senderName ?? msg.senderE164 ?? "Someone"}: ` : ""; - return `${formatTimestamp(msg.timestamp)}${prefixStr}${senderLabel}${msg.body}`; + const baseLine = `${prefixStr}${senderLabel}${msg.body}`; + + // Wrap with standardized envelope for the agent. + return formatAgentEnvelope({ + surface: "WhatsApp", + from: + msg.chatType === "group" + ? msg.from + : msg.from?.replace(/^whatsapp:/, ""), + timestamp: msg.timestamp, + body: baseLine, + }); }; const processBatch = async (conversationId: string) => { @@ -806,9 +806,13 @@ export async function monitorWebProvider( history.length > 0 ? history.slice(0, -1) : []; if (historyWithoutCurrent.length > 0) { const historyText = historyWithoutCurrent - .map( - (m) => - `${m.sender}: ${m.body}${m.timestamp ? ` [${new Date(m.timestamp).toISOString()}]` : ""}`, + .map((m) => + formatAgentEnvelope({ + surface: "WhatsApp", + from: conversationId, + timestamp: m.timestamp, + body: `${m.sender}: ${m.body}`, + }), ) .join("\\n"); combinedBody = `[Chat messages since your last reply - for context]\\n${historyText}\\n\\n[Current message - respond to this]\\n${buildLine(latest)}`; diff --git a/src/webchat/server.ts b/src/webchat/server.ts index 06ab88033..9750d3b32 100644 --- a/src/webchat/server.ts +++ b/src/webchat/server.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { type WebSocket, WebSocketServer } from "ws"; import { loadConfig } from "../config/config.js"; +import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { loadSessionStore, resolveStorePath, @@ -139,6 +140,7 @@ function broadcastAll(payload: unknown) { async function handleRpc( body: unknown, + meta?: { remoteAddress?: string | null; senderHost?: string }, ): Promise<{ ok: boolean; payloads?: RpcPayload[]; error?: string }> { const payload = body as { text?: unknown; @@ -148,8 +150,8 @@ async function handleRpc( timeout?: unknown; }; - const text: string = (payload.text ?? "").toString(); - if (!text.trim()) return { ok: false, error: "empty text" }; + const textRaw: string = (payload.text ?? "").toString(); + if (!textRaw.trim()) return { ok: false, error: "empty text" }; if (!gateway || !gatewayReady) { return { ok: false, error: "gateway unavailable" }; } @@ -163,11 +165,20 @@ async function handleRpc( const idempotencyKey = randomUUID(); try { + // Wrap user text with surface + host/IP envelope + const message = formatAgentEnvelope({ + surface: "WebChat", + from: meta?.senderHost ?? os.hostname(), + ip: meta?.remoteAddress ?? undefined, + timestamp: Date.now(), + body: textRaw.trim(), + }); + // Send agent request; wait for final res (status ok/error) const res = (await gateway.request( "agent", { - message: text, + message, thinking, deliver, to, @@ -254,7 +265,13 @@ export async function startWebChatServer( } catch { // ignore } - const result = await handleRpc(body); + const forwarded = + (req.headers["x-forwarded-for"] as string | undefined)?.split(",")[0]?.trim() ?? + req.socket.remoteAddress; + const result = await handleRpc(body, { + remoteAddress: forwarded, + senderHost: os.hostname(), + }); res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(result)); return;