fix(routing): harden originating reply routing
This commit is contained in:
@@ -111,6 +111,7 @@
|
|||||||
- Block streaming: preserve leading indentation in block replies (lists, indented fences).
|
- Block streaming: preserve leading indentation in block replies (lists, indented fences).
|
||||||
- Docs: document systemd lingering and logged-in session requirements on macOS/Windows.
|
- Docs: document systemd lingering and logged-in session requirements on macOS/Windows.
|
||||||
- Auto-reply: centralize tool/block/final dispatch across providers for consistent streaming + heartbeat/prefix handling. Thanks @MSch for PR #225.
|
- Auto-reply: centralize tool/block/final dispatch across providers for consistent streaming + heartbeat/prefix handling. Thanks @MSch for PR #225.
|
||||||
|
- Routing: route replies back to the originating provider/chat when multiple providers share the same session. Thanks @jalehman for PR #328.
|
||||||
- Heartbeat: make HEARTBEAT_OK ack padding configurable across heartbeat and cron delivery. (#238) — thanks @jalehman
|
- Heartbeat: make HEARTBEAT_OK ack padding configurable across heartbeat and cron delivery. (#238) — thanks @jalehman
|
||||||
- Skills: emit MEDIA token after Nano Banana Pro image generation. Thanks @Iamadig for PR #271.
|
- Skills: emit MEDIA token after Nano Banana Pro image generation. Thanks @Iamadig for PR #271.
|
||||||
- WhatsApp: set sender E.164 for direct chats so owner commands work in DMs.
|
- WhatsApp: set sender E.164 for direct chats so owner commands work in DMs.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { toToolDefinitions } from "./pi-tool-definition-adapter.js";
|
import { toToolDefinitions } from "./pi-tool-definition-adapter.js";
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] {
|
|||||||
: "";
|
: "";
|
||||||
if (name === "AbortError") throw err;
|
if (name === "AbortError") throw err;
|
||||||
const message =
|
const message =
|
||||||
err instanceof Error ? err.stack ?? err.message : String(err);
|
err instanceof Error ? (err.stack ?? err.message) : String(err);
|
||||||
logError(`[tools] ${tool.name} failed: ${message}`);
|
logError(`[tools] ${tool.name} failed: ${message}`);
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
status: "error",
|
status: "error",
|
||||||
|
|||||||
@@ -717,6 +717,8 @@ export async function getReplyFromConfig(
|
|||||||
// Originating channel for reply routing.
|
// Originating channel for reply routing.
|
||||||
originatingChannel: ctx.OriginatingChannel,
|
originatingChannel: ctx.OriginatingChannel,
|
||||||
originatingTo: ctx.OriginatingTo,
|
originatingTo: ctx.OriginatingTo,
|
||||||
|
originatingAccountId: ctx.AccountId,
|
||||||
|
originatingThreadId: ctx.MessageThreadId,
|
||||||
run: {
|
run: {
|
||||||
agentId,
|
agentId,
|
||||||
agentDir,
|
agentDir,
|
||||||
|
|||||||
91
src/auto-reply/reply/dispatch-from-config.test.ts
Normal file
91
src/auto-reply/reply/dispatch-from-config.test.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import type { MsgContext } from "../templating.js";
|
||||||
|
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||||
|
import type { ReplyDispatcher } from "./reply-dispatcher.js";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
routeReply: vi.fn(async () => ({ ok: true, messageId: "mock" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./route-reply.js", () => ({
|
||||||
|
isRoutableChannel: (channel: string | undefined) =>
|
||||||
|
Boolean(
|
||||||
|
channel &&
|
||||||
|
[
|
||||||
|
"telegram",
|
||||||
|
"slack",
|
||||||
|
"discord",
|
||||||
|
"signal",
|
||||||
|
"imessage",
|
||||||
|
"whatsapp",
|
||||||
|
].includes(channel),
|
||||||
|
),
|
||||||
|
routeReply: mocks.routeReply,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js");
|
||||||
|
|
||||||
|
function createDispatcher(): ReplyDispatcher {
|
||||||
|
return {
|
||||||
|
sendToolResult: vi.fn(() => true),
|
||||||
|
sendBlockReply: vi.fn(() => true),
|
||||||
|
sendFinalReply: vi.fn(() => true),
|
||||||
|
waitForIdle: vi.fn(async () => {}),
|
||||||
|
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("dispatchReplyFromConfig", () => {
|
||||||
|
it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => {
|
||||||
|
mocks.routeReply.mockClear();
|
||||||
|
const cfg = {} as ClawdbotConfig;
|
||||||
|
const dispatcher = createDispatcher();
|
||||||
|
const ctx: MsgContext = {
|
||||||
|
Provider: "slack",
|
||||||
|
OriginatingChannel: "slack",
|
||||||
|
OriginatingTo: "channel:C123",
|
||||||
|
};
|
||||||
|
|
||||||
|
const replyResolver = async (
|
||||||
|
_ctx: MsgContext,
|
||||||
|
_opts: GetReplyOptions | undefined,
|
||||||
|
_cfg: ClawdbotConfig,
|
||||||
|
) => ({ text: "hi" }) satisfies ReplyPayload;
|
||||||
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
||||||
|
|
||||||
|
expect(mocks.routeReply).not.toHaveBeenCalled();
|
||||||
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes when OriginatingChannel differs from Provider", async () => {
|
||||||
|
mocks.routeReply.mockClear();
|
||||||
|
const cfg = {} as ClawdbotConfig;
|
||||||
|
const dispatcher = createDispatcher();
|
||||||
|
const ctx: MsgContext = {
|
||||||
|
Provider: "slack",
|
||||||
|
AccountId: "acc-1",
|
||||||
|
MessageThreadId: 123,
|
||||||
|
OriginatingChannel: "telegram",
|
||||||
|
OriginatingTo: "telegram:999",
|
||||||
|
};
|
||||||
|
|
||||||
|
const replyResolver = async (
|
||||||
|
_ctx: MsgContext,
|
||||||
|
_opts: GetReplyOptions | undefined,
|
||||||
|
_cfg: ClawdbotConfig,
|
||||||
|
) => ({ text: "hi" }) satisfies ReplyPayload;
|
||||||
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
||||||
|
|
||||||
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.routeReply).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
channel: "telegram",
|
||||||
|
to: "telegram:999",
|
||||||
|
accountId: "acc-1",
|
||||||
|
threadId: 123,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,7 +27,7 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
// flow when the provider handles its own messages.
|
// flow when the provider handles its own messages.
|
||||||
const originatingChannel = ctx.OriginatingChannel;
|
const originatingChannel = ctx.OriginatingChannel;
|
||||||
const originatingTo = ctx.OriginatingTo;
|
const originatingTo = ctx.OriginatingTo;
|
||||||
const currentSurface = ctx.Surface?.toLowerCase();
|
const currentSurface = (ctx.Surface ?? ctx.Provider)?.toLowerCase();
|
||||||
const shouldRouteToOriginating =
|
const shouldRouteToOriginating =
|
||||||
isRoutableChannel(originatingChannel) &&
|
isRoutableChannel(originatingChannel) &&
|
||||||
originatingTo &&
|
originatingTo &&
|
||||||
@@ -47,6 +47,8 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
payload,
|
payload,
|
||||||
channel: originatingChannel,
|
channel: originatingChannel,
|
||||||
to: originatingTo,
|
to: originatingTo,
|
||||||
|
accountId: ctx.AccountId,
|
||||||
|
threadId: ctx.MessageThreadId,
|
||||||
cfg,
|
cfg,
|
||||||
});
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
@@ -89,6 +91,7 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
let queuedFinal = false;
|
let queuedFinal = false;
|
||||||
|
let routedFinalCount = 0;
|
||||||
for (const reply of replies) {
|
for (const reply of replies) {
|
||||||
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
|
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
|
||||||
// Route final reply to originating channel.
|
// Route final reply to originating channel.
|
||||||
@@ -96,6 +99,8 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
payload: reply,
|
payload: reply,
|
||||||
channel: originatingChannel,
|
channel: originatingChannel,
|
||||||
to: originatingTo,
|
to: originatingTo,
|
||||||
|
accountId: ctx.AccountId,
|
||||||
|
threadId: ctx.MessageThreadId,
|
||||||
cfg,
|
cfg,
|
||||||
});
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
@@ -103,13 +108,15 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
`dispatch-from-config: route-reply (final) failed: ${result.error ?? "unknown error"}`,
|
`dispatch-from-config: route-reply (final) failed: ${result.error ?? "unknown error"}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Mark as queued since we handled it ourselves.
|
queuedFinal = result.ok || queuedFinal;
|
||||||
queuedFinal = true;
|
if (result.ok) routedFinalCount += 1;
|
||||||
} else {
|
} else {
|
||||||
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
|
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await dispatcher.waitForIdle();
|
await dispatcher.waitForIdle();
|
||||||
|
|
||||||
return { queuedFinal, counts: dispatcher.getQueuedCounts() };
|
const counts = dispatcher.getQueuedCounts();
|
||||||
|
counts.final += routedFinalCount;
|
||||||
|
return { queuedFinal, counts };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ export function createFollowupRunner(params: {
|
|||||||
payload,
|
payload,
|
||||||
channel: originatingChannel,
|
channel: originatingChannel,
|
||||||
to: originatingTo,
|
to: originatingTo,
|
||||||
|
accountId: queued.originatingAccountId,
|
||||||
|
threadId: queued.originatingThreadId,
|
||||||
cfg: queued.run.config,
|
cfg: queued.run.config,
|
||||||
});
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
|
|||||||
111
src/auto-reply/reply/queue.collect-routing.test.ts
Normal file
111
src/auto-reply/reply/queue.collect-routing.test.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import type { FollowupRun, QueueSettings } from "./queue.js";
|
||||||
|
import { enqueueFollowupRun, scheduleFollowupDrain } from "./queue.js";
|
||||||
|
|
||||||
|
function createRun(params: {
|
||||||
|
prompt: string;
|
||||||
|
originatingChannel?: FollowupRun["originatingChannel"];
|
||||||
|
originatingTo?: string;
|
||||||
|
}): FollowupRun {
|
||||||
|
return {
|
||||||
|
prompt: params.prompt,
|
||||||
|
enqueuedAt: Date.now(),
|
||||||
|
originatingChannel: params.originatingChannel,
|
||||||
|
originatingTo: params.originatingTo,
|
||||||
|
run: {
|
||||||
|
agentId: "agent",
|
||||||
|
agentDir: "/tmp",
|
||||||
|
sessionId: "sess",
|
||||||
|
sessionFile: "/tmp/session.json",
|
||||||
|
workspaceDir: "/tmp",
|
||||||
|
config: {} as ClawdbotConfig,
|
||||||
|
provider: "openai",
|
||||||
|
model: "gpt-test",
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
blockReplyBreak: "text_end",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("followup queue collect routing", () => {
|
||||||
|
it("does not collect when destinations differ", async () => {
|
||||||
|
const key = `test-collect-diff-to-${Date.now()}`;
|
||||||
|
const calls: FollowupRun[] = [];
|
||||||
|
const runFollowup = async (run: FollowupRun) => {
|
||||||
|
calls.push(run);
|
||||||
|
};
|
||||||
|
const settings: QueueSettings = {
|
||||||
|
mode: "collect",
|
||||||
|
debounceMs: 0,
|
||||||
|
cap: 50,
|
||||||
|
dropPolicy: "summarize",
|
||||||
|
};
|
||||||
|
|
||||||
|
enqueueFollowupRun(
|
||||||
|
key,
|
||||||
|
createRun({
|
||||||
|
prompt: "one",
|
||||||
|
originatingChannel: "slack",
|
||||||
|
originatingTo: "channel:A",
|
||||||
|
}),
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
enqueueFollowupRun(
|
||||||
|
key,
|
||||||
|
createRun({
|
||||||
|
prompt: "two",
|
||||||
|
originatingChannel: "slack",
|
||||||
|
originatingTo: "channel:B",
|
||||||
|
}),
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
|
||||||
|
scheduleFollowupDrain(key, runFollowup);
|
||||||
|
await expect.poll(() => calls.length).toBe(2);
|
||||||
|
expect(calls[0]?.prompt).toBe("one");
|
||||||
|
expect(calls[1]?.prompt).toBe("two");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collects when channel+destination match", async () => {
|
||||||
|
const key = `test-collect-same-to-${Date.now()}`;
|
||||||
|
const calls: FollowupRun[] = [];
|
||||||
|
const runFollowup = async (run: FollowupRun) => {
|
||||||
|
calls.push(run);
|
||||||
|
};
|
||||||
|
const settings: QueueSettings = {
|
||||||
|
mode: "collect",
|
||||||
|
debounceMs: 0,
|
||||||
|
cap: 50,
|
||||||
|
dropPolicy: "summarize",
|
||||||
|
};
|
||||||
|
|
||||||
|
enqueueFollowupRun(
|
||||||
|
key,
|
||||||
|
createRun({
|
||||||
|
prompt: "one",
|
||||||
|
originatingChannel: "slack",
|
||||||
|
originatingTo: "channel:A",
|
||||||
|
}),
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
enqueueFollowupRun(
|
||||||
|
key,
|
||||||
|
createRun({
|
||||||
|
prompt: "two",
|
||||||
|
originatingChannel: "slack",
|
||||||
|
originatingTo: "channel:A",
|
||||||
|
}),
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
|
||||||
|
scheduleFollowupDrain(key, runFollowup);
|
||||||
|
await expect.poll(() => calls.length).toBe(1);
|
||||||
|
expect(calls[0]?.prompt).toContain(
|
||||||
|
"[Queued messages while agent was busy]",
|
||||||
|
);
|
||||||
|
expect(calls[0]?.originatingChannel).toBe("slack");
|
||||||
|
expect(calls[0]?.originatingTo).toBe("channel:A");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,10 @@ export type FollowupRun = {
|
|||||||
* The chat/channel/user ID where the reply should be sent.
|
* The chat/channel/user ID where the reply should be sent.
|
||||||
*/
|
*/
|
||||||
originatingTo?: string;
|
originatingTo?: string;
|
||||||
|
/** Provider account id (multi-account). */
|
||||||
|
originatingAccountId?: string;
|
||||||
|
/** Telegram forum topic thread id. */
|
||||||
|
originatingThreadId?: number;
|
||||||
run: {
|
run: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
agentDir: string;
|
agentDir: string;
|
||||||
@@ -396,23 +400,34 @@ function buildCollectPrompt(items: FollowupRun[], summary?: string): string {
|
|||||||
* Also returns true for a mix of routable and non-routable channels.
|
* Also returns true for a mix of routable and non-routable channels.
|
||||||
*/
|
*/
|
||||||
function hasCrossProviderItems(items: FollowupRun[]): boolean {
|
function hasCrossProviderItems(items: FollowupRun[]): boolean {
|
||||||
const routableChannels = new Set<string>();
|
const keys = new Set<string>();
|
||||||
let hasNonRoutable = false;
|
let hasUnkeyed = false;
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const channel = item.originatingChannel;
|
const channel = item.originatingChannel;
|
||||||
if (isRoutableChannel(channel)) {
|
const to = item.originatingTo;
|
||||||
routableChannels.add(channel);
|
const accountId = item.originatingAccountId;
|
||||||
} else if (channel) {
|
const threadId = item.originatingThreadId;
|
||||||
// Has a channel but it's not routable (whatsapp, webchat).
|
if (!channel && !to && !accountId && typeof threadId !== "number") {
|
||||||
hasNonRoutable = true;
|
hasUnkeyed = true;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
if (!isRoutableChannel(channel) || !to) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
keys.add(
|
||||||
|
[
|
||||||
|
channel,
|
||||||
|
to,
|
||||||
|
accountId || "",
|
||||||
|
typeof threadId === "number" ? String(threadId) : "",
|
||||||
|
].join("|"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cross-provider if: multiple routable channels, or mix of routable + non-routable.
|
if (keys.size === 0) return false;
|
||||||
return (
|
if (hasUnkeyed) return true;
|
||||||
routableChannels.size > 1 || (routableChannels.size > 0 && hasNonRoutable)
|
return keys.size > 1;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
export function scheduleFollowupDrain(
|
export function scheduleFollowupDrain(
|
||||||
key: string,
|
key: string,
|
||||||
@@ -423,14 +438,23 @@ export function scheduleFollowupDrain(
|
|||||||
queue.draining = true;
|
queue.draining = true;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
|
let forceIndividualCollect = false;
|
||||||
while (queue.items.length > 0 || queue.droppedCount > 0) {
|
while (queue.items.length > 0 || queue.droppedCount > 0) {
|
||||||
await waitForQueueDebounce(queue);
|
await waitForQueueDebounce(queue);
|
||||||
if (queue.mode === "collect") {
|
if (queue.mode === "collect") {
|
||||||
|
if (forceIndividualCollect) {
|
||||||
|
const next = queue.items.shift();
|
||||||
|
if (!next) break;
|
||||||
|
await runFollowup(next);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if messages span multiple providers.
|
// Check if messages span multiple providers.
|
||||||
// If so, process individually to preserve per-message routing.
|
// If so, process individually to preserve per-message routing.
|
||||||
const isCrossProvider = hasCrossProviderItems(queue.items);
|
const isCrossProvider = hasCrossProviderItems(queue.items);
|
||||||
|
|
||||||
if (isCrossProvider) {
|
if (isCrossProvider) {
|
||||||
|
forceIndividualCollect = true;
|
||||||
// Process one at a time to preserve per-message routing info.
|
// Process one at a time to preserve per-message routing info.
|
||||||
const next = queue.items.shift();
|
const next = queue.items.shift();
|
||||||
if (!next) break;
|
if (!next) break;
|
||||||
@@ -451,6 +475,12 @@ export function scheduleFollowupDrain(
|
|||||||
const originatingTo = items.find(
|
const originatingTo = items.find(
|
||||||
(i) => i.originatingTo,
|
(i) => i.originatingTo,
|
||||||
)?.originatingTo;
|
)?.originatingTo;
|
||||||
|
const originatingAccountId = items.find(
|
||||||
|
(i) => i.originatingAccountId,
|
||||||
|
)?.originatingAccountId;
|
||||||
|
const originatingThreadId = items.find(
|
||||||
|
(i) => typeof i.originatingThreadId === "number",
|
||||||
|
)?.originatingThreadId;
|
||||||
|
|
||||||
const prompt = buildCollectPrompt(items, summary);
|
const prompt = buildCollectPrompt(items, summary);
|
||||||
await runFollowup({
|
await runFollowup({
|
||||||
@@ -459,6 +489,8 @@ export function scheduleFollowupDrain(
|
|||||||
enqueuedAt: Date.now(),
|
enqueuedAt: Date.now(),
|
||||||
originatingChannel,
|
originatingChannel,
|
||||||
originatingTo,
|
originatingTo,
|
||||||
|
originatingAccountId,
|
||||||
|
originatingThreadId,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { sendMessageIMessage } from "../../imessage/send.js";
|
|||||||
import { sendMessageSignal } from "../../signal/send.js";
|
import { sendMessageSignal } from "../../signal/send.js";
|
||||||
import { sendMessageSlack } from "../../slack/send.js";
|
import { sendMessageSlack } from "../../slack/send.js";
|
||||||
import { sendMessageTelegram } from "../../telegram/send.js";
|
import { sendMessageTelegram } from "../../telegram/send.js";
|
||||||
|
import { sendMessageWhatsApp } from "../../web/outbound.js";
|
||||||
import type { OriginatingChannelType } from "../templating.js";
|
import type { OriginatingChannelType } from "../templating.js";
|
||||||
import type { ReplyPayload } from "../types.js";
|
import type { ReplyPayload } from "../types.js";
|
||||||
|
|
||||||
@@ -23,6 +24,10 @@ export type RouteReplyParams = {
|
|||||||
channel: OriginatingChannelType;
|
channel: OriginatingChannelType;
|
||||||
/** The destination chat/channel/user ID. */
|
/** The destination chat/channel/user ID. */
|
||||||
to: string;
|
to: string;
|
||||||
|
/** Provider account id (multi-account). */
|
||||||
|
accountId?: string;
|
||||||
|
/** Telegram message thread id (forum topics). */
|
||||||
|
threadId?: number;
|
||||||
/** Config for provider-specific settings. */
|
/** Config for provider-specific settings. */
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
};
|
};
|
||||||
@@ -47,29 +52,48 @@ export type RouteReplyResult = {
|
|||||||
export async function routeReply(
|
export async function routeReply(
|
||||||
params: RouteReplyParams,
|
params: RouteReplyParams,
|
||||||
): Promise<RouteReplyResult> {
|
): Promise<RouteReplyResult> {
|
||||||
const { payload, channel, to } = params;
|
const { payload, channel, to, accountId, threadId } = params;
|
||||||
|
|
||||||
const text = payload.text ?? "";
|
const text = payload.text ?? "";
|
||||||
const mediaUrl = payload.mediaUrl ?? payload.mediaUrls?.[0];
|
const mediaUrls = (payload.mediaUrls?.filter(Boolean) ?? []).length
|
||||||
|
? (payload.mediaUrls?.filter(Boolean) as string[])
|
||||||
|
: payload.mediaUrl
|
||||||
|
? [payload.mediaUrl]
|
||||||
|
: [];
|
||||||
|
const replyToId = payload.replyToId;
|
||||||
|
|
||||||
// Skip empty replies.
|
// Skip empty replies.
|
||||||
if (!text.trim() && !mediaUrl) {
|
if (!text.trim() && mediaUrls.length === 0) {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const sendOne = async (params: {
|
||||||
|
text: string;
|
||||||
|
mediaUrl?: string;
|
||||||
|
}): Promise<RouteReplyResult> => {
|
||||||
|
const { text, mediaUrl } = params;
|
||||||
switch (channel) {
|
switch (channel) {
|
||||||
case "telegram": {
|
case "telegram": {
|
||||||
const result = await sendMessageTelegram(to, text, { mediaUrl });
|
const result = await sendMessageTelegram(to, text, {
|
||||||
|
mediaUrl,
|
||||||
|
messageThreadId: threadId,
|
||||||
|
});
|
||||||
return { ok: true, messageId: result.messageId };
|
return { ok: true, messageId: result.messageId };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "slack": {
|
case "slack": {
|
||||||
const result = await sendMessageSlack(to, text, { mediaUrl });
|
const result = await sendMessageSlack(to, text, {
|
||||||
|
mediaUrl,
|
||||||
|
threadTs: replyToId,
|
||||||
|
});
|
||||||
return { ok: true, messageId: result.messageId };
|
return { ok: true, messageId: result.messageId };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "discord": {
|
case "discord": {
|
||||||
const result = await sendMessageDiscord(to, text, { mediaUrl });
|
const result = await sendMessageDiscord(to, text, {
|
||||||
|
mediaUrl,
|
||||||
|
replyTo: replyToId,
|
||||||
|
});
|
||||||
return { ok: true, messageId: result.messageId };
|
return { ok: true, messageId: result.messageId };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,17 +108,15 @@ export async function routeReply(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "whatsapp": {
|
case "whatsapp": {
|
||||||
// WhatsApp doesn't have a standalone send function in this codebase.
|
const result = await sendMessageWhatsApp(to, text, {
|
||||||
// Falls through to unknown channel handling.
|
verbose: false,
|
||||||
return {
|
mediaUrl,
|
||||||
ok: false,
|
accountId,
|
||||||
error: `WhatsApp routing not yet implemented`,
|
});
|
||||||
};
|
return { ok: true, messageId: result.messageId };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "webchat": {
|
case "webchat": {
|
||||||
// Webchat is typically handled differently (real-time WebSocket).
|
|
||||||
// Falls through to unknown channel handling.
|
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: `Webchat routing not supported for queued replies`,
|
error: `Webchat routing not supported for queued replies`,
|
||||||
@@ -102,14 +124,26 @@ export async function routeReply(
|
|||||||
}
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
// Exhaustive check for unknown channel types.
|
|
||||||
const _exhaustive: never = channel;
|
const _exhaustive: never = channel;
|
||||||
return {
|
return { ok: false, error: `Unknown channel: ${String(_exhaustive)}` };
|
||||||
ok: false,
|
|
||||||
error: `Unknown channel: ${String(_exhaustive)}`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mediaUrls.length === 0) {
|
||||||
|
return await sendOne({ text });
|
||||||
|
}
|
||||||
|
|
||||||
|
let last: RouteReplyResult | undefined;
|
||||||
|
for (let i = 0; i < mediaUrls.length; i++) {
|
||||||
|
const mediaUrl = mediaUrls[i];
|
||||||
|
const caption = i === 0 ? text : "";
|
||||||
|
last = await sendOne({ text: caption, mediaUrl });
|
||||||
|
if (!last.ok) return last;
|
||||||
|
}
|
||||||
|
|
||||||
|
return last ?? { ok: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
return {
|
return {
|
||||||
@@ -122,14 +156,25 @@ export async function routeReply(
|
|||||||
/**
|
/**
|
||||||
* Checks if a channel type is routable via routeReply.
|
* Checks if a channel type is routable via routeReply.
|
||||||
*
|
*
|
||||||
* Some channels (webchat, whatsapp) require special handling and
|
* Some channels (webchat) require special handling and cannot be routed through
|
||||||
* cannot be routed through this generic interface.
|
* this generic interface.
|
||||||
*/
|
*/
|
||||||
export function isRoutableChannel(
|
export function isRoutableChannel(
|
||||||
channel: OriginatingChannelType | undefined,
|
channel: OriginatingChannelType | undefined,
|
||||||
): channel is "telegram" | "slack" | "discord" | "signal" | "imessage" {
|
): channel is
|
||||||
|
| "telegram"
|
||||||
|
| "slack"
|
||||||
|
| "discord"
|
||||||
|
| "signal"
|
||||||
|
| "imessage"
|
||||||
|
| "whatsapp" {
|
||||||
if (!channel) return false;
|
if (!channel) return false;
|
||||||
return ["telegram", "slack", "discord", "signal", "imessage"].includes(
|
return [
|
||||||
channel,
|
"telegram",
|
||||||
);
|
"slack",
|
||||||
|
"discord",
|
||||||
|
"signal",
|
||||||
|
"imessage",
|
||||||
|
"whatsapp",
|
||||||
|
].includes(channel);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type TelegramSendOpts = {
|
|||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
maxBytes?: number;
|
maxBytes?: number;
|
||||||
|
messageThreadId?: number;
|
||||||
api?: Bot["api"];
|
api?: Bot["api"];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,6 +89,10 @@ export async function sendMessageTelegram(
|
|||||||
const bot = opts.api ? null : new Bot(token);
|
const bot = opts.api ? null : new Bot(token);
|
||||||
const api = opts.api ?? bot?.api;
|
const api = opts.api ?? bot?.api;
|
||||||
const mediaUrl = opts.mediaUrl?.trim();
|
const mediaUrl = opts.mediaUrl?.trim();
|
||||||
|
const threadParams =
|
||||||
|
typeof opts.messageThreadId === "number"
|
||||||
|
? { message_thread_id: Math.trunc(opts.messageThreadId) }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const sleep = (ms: number) =>
|
const sleep = (ms: number) =>
|
||||||
new Promise((resolve) => setTimeout(resolve, ms));
|
new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
@@ -150,35 +155,35 @@ export async function sendMessageTelegram(
|
|||||||
| Awaited<ReturnType<typeof api.sendDocument>>;
|
| Awaited<ReturnType<typeof api.sendDocument>>;
|
||||||
if (isGif) {
|
if (isGif) {
|
||||||
result = await sendWithRetry(
|
result = await sendWithRetry(
|
||||||
() => api.sendAnimation(chatId, file, { caption }),
|
() => api.sendAnimation(chatId, file, { caption, ...threadParams }),
|
||||||
"animation",
|
"animation",
|
||||||
).catch((err) => {
|
).catch((err) => {
|
||||||
throw wrapChatNotFound(err);
|
throw wrapChatNotFound(err);
|
||||||
});
|
});
|
||||||
} else if (kind === "image") {
|
} else if (kind === "image") {
|
||||||
result = await sendWithRetry(
|
result = await sendWithRetry(
|
||||||
() => api.sendPhoto(chatId, file, { caption }),
|
() => api.sendPhoto(chatId, file, { caption, ...threadParams }),
|
||||||
"photo",
|
"photo",
|
||||||
).catch((err) => {
|
).catch((err) => {
|
||||||
throw wrapChatNotFound(err);
|
throw wrapChatNotFound(err);
|
||||||
});
|
});
|
||||||
} else if (kind === "video") {
|
} else if (kind === "video") {
|
||||||
result = await sendWithRetry(
|
result = await sendWithRetry(
|
||||||
() => api.sendVideo(chatId, file, { caption }),
|
() => api.sendVideo(chatId, file, { caption, ...threadParams }),
|
||||||
"video",
|
"video",
|
||||||
).catch((err) => {
|
).catch((err) => {
|
||||||
throw wrapChatNotFound(err);
|
throw wrapChatNotFound(err);
|
||||||
});
|
});
|
||||||
} else if (kind === "audio") {
|
} else if (kind === "audio") {
|
||||||
result = await sendWithRetry(
|
result = await sendWithRetry(
|
||||||
() => api.sendAudio(chatId, file, { caption }),
|
() => api.sendAudio(chatId, file, { caption, ...threadParams }),
|
||||||
"audio",
|
"audio",
|
||||||
).catch((err) => {
|
).catch((err) => {
|
||||||
throw wrapChatNotFound(err);
|
throw wrapChatNotFound(err);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
result = await sendWithRetry(
|
result = await sendWithRetry(
|
||||||
() => api.sendDocument(chatId, file, { caption }),
|
() => api.sendDocument(chatId, file, { caption, ...threadParams }),
|
||||||
"document",
|
"document",
|
||||||
).catch((err) => {
|
).catch((err) => {
|
||||||
throw wrapChatNotFound(err);
|
throw wrapChatNotFound(err);
|
||||||
@@ -192,7 +197,11 @@ export async function sendMessageTelegram(
|
|||||||
throw new Error("Message must be non-empty for Telegram sends");
|
throw new Error("Message must be non-empty for Telegram sends");
|
||||||
}
|
}
|
||||||
const res = await sendWithRetry(
|
const res = await sendWithRetry(
|
||||||
() => api.sendMessage(chatId, text, { parse_mode: "Markdown" }),
|
() =>
|
||||||
|
api.sendMessage(chatId, text, {
|
||||||
|
parse_mode: "Markdown",
|
||||||
|
...threadParams,
|
||||||
|
}),
|
||||||
"message",
|
"message",
|
||||||
).catch(async (err) => {
|
).catch(async (err) => {
|
||||||
// Telegram rejects malformed Markdown (e.g., unbalanced '_' or '*').
|
// Telegram rejects malformed Markdown (e.g., unbalanced '_' or '*').
|
||||||
@@ -205,7 +214,10 @@ export async function sendMessageTelegram(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return await sendWithRetry(
|
return await sendWithRetry(
|
||||||
() => api.sendMessage(chatId, text),
|
() =>
|
||||||
|
threadParams
|
||||||
|
? api.sendMessage(chatId, text, threadParams)
|
||||||
|
: api.sendMessage(chatId, text),
|
||||||
"message-plain",
|
"message-plain",
|
||||||
).catch((err2) => {
|
).catch((err2) => {
|
||||||
throw wrapChatNotFound(err2);
|
throw wrapChatNotFound(err2);
|
||||||
|
|||||||
@@ -1252,6 +1252,9 @@ export async function monitorWebProvider(
|
|||||||
WasMentioned: msg.wasMentioned,
|
WasMentioned: msg.wasMentioned,
|
||||||
...(msg.location ? toLocationContext(msg.location) : {}),
|
...(msg.location ? toLocationContext(msg.location) : {}),
|
||||||
Provider: "whatsapp",
|
Provider: "whatsapp",
|
||||||
|
Surface: "whatsapp",
|
||||||
|
OriginatingChannel: "whatsapp",
|
||||||
|
OriginatingTo: msg.to,
|
||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
dispatcher,
|
dispatcher,
|
||||||
|
|||||||
Reference in New Issue
Block a user