surface: envelope inbound messages for agent

This commit is contained in:
Peter Steinberger
2025-12-09 18:43:21 +00:00
parent 55bffeba4a
commit ffc930b871
8 changed files with 149 additions and 28 deletions

View File

@@ -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:<jid>`.
- WebChat always attaches to the `main` session and hydrates the full Tau history from `~/.clawdis/sessions/<SessionId>.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

View File

@@ -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");
});
});

View File

@@ -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}`;
}

View File

@@ -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<void>;
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");
});
});

View File

@@ -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,

View File

@@ -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 () => {

View File

@@ -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<string, PendingBatch>();
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)}`;

View File

@@ -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;