refactor(channels): dedupe message routing and telegram helpers

This commit is contained in:
Peter Steinberger
2026-02-22 07:37:54 +00:00
parent b109fa53ea
commit 75c1bfbae8
21 changed files with 566 additions and 410 deletions

79
src/channels/dock.test.ts Normal file
View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { getChannelDock } from "./dock.js";
function emptyConfig(): OpenClawConfig {
return {} as OpenClawConfig;
}
describe("channels dock", () => {
it("telegram and googlechat threading contexts map thread ids consistently", () => {
const hasRepliedRef = { value: false };
const telegramDock = getChannelDock("telegram");
const googleChatDock = getChannelDock("googlechat");
const telegramContext = telegramDock?.threading?.buildToolContext?.({
cfg: emptyConfig(),
context: { To: " room-1 ", MessageThreadId: 42, ReplyToId: "fallback" },
hasRepliedRef,
});
const googleChatContext = googleChatDock?.threading?.buildToolContext?.({
cfg: emptyConfig(),
context: { To: " space-1 ", ReplyToId: "thread-abc" },
hasRepliedRef,
});
expect(telegramContext).toEqual({
currentChannelId: "room-1",
currentThreadTs: "42",
hasRepliedRef,
});
expect(googleChatContext).toEqual({
currentChannelId: "space-1",
currentThreadTs: "thread-abc",
hasRepliedRef,
});
});
it("irc resolveDefaultTo matches account id case-insensitively", () => {
const ircDock = getChannelDock("irc");
const cfg = {
channels: {
irc: {
defaultTo: "#root",
accounts: {
Work: { defaultTo: "#work" },
},
},
},
} as OpenClawConfig;
const accountDefault = ircDock?.config?.resolveDefaultTo?.({ cfg, accountId: "work" });
const rootDefault = ircDock?.config?.resolveDefaultTo?.({ cfg, accountId: "missing" });
expect(accountDefault).toBe("#work");
expect(rootDefault).toBe("#root");
});
it("signal allowFrom formatter normalizes values and preserves wildcard", () => {
const signalDock = getChannelDock("signal");
const formatted = signalDock?.config?.formatAllowFrom?.({
cfg: emptyConfig(),
allowFrom: [" signal:+14155550100 ", " * "],
});
expect(formatted).toEqual(["+14155550100", "*"]);
});
it("telegram allowFrom formatter trims, strips prefix, and lowercases", () => {
const telegramDock = getChannelDock("telegram");
const formatted = telegramDock?.config?.formatAllowFrom?.({
cfg: emptyConfig(),
allowFrom: [" TG:User ", "telegram:Foo", " Plain "],
});
expect(formatted).toEqual(["user", "foo", "plain"]);
});
});

View File

@@ -1,4 +1,3 @@
import type { OpenClawConfig } from "../config/config.js";
import { import {
resolveChannelGroupRequireMention, resolveChannelGroupRequireMention,
resolveChannelGroupToolsPolicy, resolveChannelGroupToolsPolicy,
@@ -32,6 +31,7 @@ import { normalizeSignalMessagingTarget } from "./plugins/normalize/signal.js";
import type { import type {
ChannelCapabilities, ChannelCapabilities,
ChannelCommandAdapter, ChannelCommandAdapter,
ChannelConfigAdapter,
ChannelElevatedAdapter, ChannelElevatedAdapter,
ChannelGroupAdapter, ChannelGroupAdapter,
ChannelId, ChannelId,
@@ -53,21 +53,10 @@ export type ChannelDock = {
}; };
streaming?: ChannelDockStreaming; streaming?: ChannelDockStreaming;
elevated?: ChannelElevatedAdapter; elevated?: ChannelElevatedAdapter;
config?: { config?: Pick<
resolveAllowFrom?: (params: { ChannelConfigAdapter<unknown>,
cfg: OpenClawConfig; "resolveAllowFrom" | "formatAllowFrom" | "resolveDefaultTo"
accountId?: string | null; >;
}) => Array<string | number> | undefined;
formatAllowFrom?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
allowFrom: Array<string | number>;
}) => string[];
resolveDefaultTo?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
}) => string | undefined;
};
groups?: ChannelGroupAdapter; groups?: ChannelGroupAdapter;
mentions?: ChannelMentionAdapter; mentions?: ChannelMentionAdapter;
threading?: ChannelThreadingAdapter; threading?: ChannelThreadingAdapter;
@@ -87,6 +76,12 @@ const formatLower = (allowFrom: Array<string | number>) =>
.filter(Boolean) .filter(Boolean)
.map((entry) => entry.toLowerCase()); .map((entry) => entry.toLowerCase());
const stringifyAllowFrom = (allowFrom: Array<string | number>) =>
allowFrom.map((entry) => String(entry));
const trimAllowFromEntries = (allowFrom: Array<string | number>) =>
allowFrom.map((entry) => String(entry).trim()).filter(Boolean);
const formatDiscordAllowFrom = (allowFrom: Array<string | number>) => const formatDiscordAllowFrom = (allowFrom: Array<string | number>) =>
allowFrom allowFrom
.map((entry) => .map((entry) =>
@@ -133,6 +128,18 @@ function buildIMessageThreadToolContext(params: {
}; };
} }
function buildThreadToolContextFromMessageThreadOrReply(params: {
context: ChannelThreadingContext;
hasRepliedRef: ChannelThreadingToolContext["hasRepliedRef"];
}): ChannelThreadingToolContext {
const threadId = params.context.MessageThreadId ?? params.context.ReplyToId;
return {
currentChannelId: params.context.To?.trim() || undefined,
currentThreadTs: threadId != null ? String(threadId) : undefined,
hasRepliedRef: params.hasRepliedRef,
};
}
function resolveCaseInsensitiveAccount<T>( function resolveCaseInsensitiveAccount<T>(
accounts: Record<string, T> | undefined, accounts: Record<string, T> | undefined,
accountId?: string | null, accountId?: string | null,
@@ -182,13 +189,9 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
outbound: { textChunkLimit: 4000 }, outbound: { textChunkLimit: 4000 },
config: { config: {
resolveAllowFrom: ({ cfg, accountId }) => resolveAllowFrom: ({ cfg, accountId }) =>
(resolveTelegramAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => stringifyAllowFrom(resolveTelegramAccount({ cfg, accountId }).config.allowFrom ?? []),
String(entry),
),
formatAllowFrom: ({ allowFrom }) => formatAllowFrom: ({ allowFrom }) =>
allowFrom trimAllowFromEntries(allowFrom)
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.replace(/^(telegram|tg):/i, "")) .map((entry) => entry.replace(/^(telegram|tg):/i, ""))
.map((entry) => entry.toLowerCase()), .map((entry) => entry.toLowerCase()),
resolveDefaultTo: ({ cfg, accountId }) => { resolveDefaultTo: ({ cfg, accountId }) => {
@@ -202,14 +205,8 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
}, },
threading: { threading: {
resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "off", resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "off",
buildToolContext: ({ context, hasRepliedRef }) => { buildToolContext: ({ context, hasRepliedRef }) =>
const threadId = context.MessageThreadId ?? context.ReplyToId; buildThreadToolContextFromMessageThreadOrReply({ context, hasRepliedRef }),
return {
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: threadId != null ? String(threadId) : undefined,
hasRepliedRef,
};
},
}, },
}, },
whatsapp: { whatsapp: {
@@ -426,14 +423,8 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
}, },
threading: { threading: {
resolveReplyToMode: ({ cfg }) => cfg.channels?.googlechat?.replyToMode ?? "off", resolveReplyToMode: ({ cfg }) => cfg.channels?.googlechat?.replyToMode ?? "off",
buildToolContext: ({ context, hasRepliedRef }) => { buildToolContext: ({ context, hasRepliedRef }) =>
const threadId = context.MessageThreadId ?? context.ReplyToId; buildThreadToolContextFromMessageThreadOrReply({ context, hasRepliedRef }),
return {
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: threadId != null ? String(threadId) : undefined,
hasRepliedRef,
};
},
}, },
}, },
slack: { slack: {
@@ -487,13 +478,9 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
}, },
config: { config: {
resolveAllowFrom: ({ cfg, accountId }) => resolveAllowFrom: ({ cfg, accountId }) =>
(resolveSignalAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => stringifyAllowFrom(resolveSignalAccount({ cfg, accountId }).config.allowFrom ?? []),
String(entry),
),
formatAllowFrom: ({ allowFrom }) => formatAllowFrom: ({ allowFrom }) =>
allowFrom trimAllowFromEntries(allowFrom)
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, "")))) .map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, ""))))
.filter(Boolean), .filter(Boolean),
resolveDefaultTo: ({ cfg, accountId }) => resolveDefaultTo: ({ cfg, accountId }) =>

View File

@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from "vitest";
import {
clearFinalizableDraftMessage,
createFinalizableDraftLifecycle,
createFinalizableDraftStreamControlsForState,
takeMessageIdAfterStop,
} from "./draft-stream-controls.js";
describe("draft-stream-controls", () => {
it("takeMessageIdAfterStop stops, reads, and clears message id", async () => {
const events: string[] = [];
let messageId: string | undefined = "m-1";
const result = await takeMessageIdAfterStop({
stopForClear: async () => {
events.push("stop");
},
readMessageId: () => {
events.push("read");
return messageId;
},
clearMessageId: () => {
events.push("clear");
messageId = undefined;
},
});
expect(result).toBe("m-1");
expect(messageId).toBeUndefined();
expect(events).toEqual(["stop", "read", "clear"]);
});
it("clearFinalizableDraftMessage deletes valid message ids", async () => {
const deleteMessage = vi.fn(async () => {});
const onDeleteSuccess = vi.fn();
await clearFinalizableDraftMessage({
stopForClear: async () => {},
readMessageId: () => "m-2",
clearMessageId: () => {},
isValidMessageId: (value): value is string => typeof value === "string",
deleteMessage,
onDeleteSuccess,
warnPrefix: "cleanup failed",
});
expect(deleteMessage).toHaveBeenCalledWith("m-2");
expect(onDeleteSuccess).toHaveBeenCalledWith("m-2");
});
it("clearFinalizableDraftMessage skips invalid message ids", async () => {
const deleteMessage = vi.fn(async () => {});
await clearFinalizableDraftMessage({
stopForClear: async () => {},
readMessageId: () => 123,
clearMessageId: () => {},
isValidMessageId: (value): value is string => typeof value === "string",
deleteMessage,
warnPrefix: "cleanup failed",
});
expect(deleteMessage).not.toHaveBeenCalled();
});
it("clearFinalizableDraftMessage warns when delete fails", async () => {
const warn = vi.fn();
await clearFinalizableDraftMessage({
stopForClear: async () => {},
readMessageId: () => "m-3",
clearMessageId: () => {},
isValidMessageId: (value): value is string => typeof value === "string",
deleteMessage: async () => {
throw new Error("boom");
},
warn,
warnPrefix: "cleanup failed",
});
expect(warn).toHaveBeenCalledWith("cleanup failed: boom");
});
it("controls ignore updates after final", async () => {
const sendOrEditStreamMessage = vi.fn(async () => true);
const controls = createFinalizableDraftStreamControlsForState({
throttleMs: 250,
state: { stopped: false, final: true },
sendOrEditStreamMessage,
});
controls.update("ignored");
await controls.loop.flush();
expect(sendOrEditStreamMessage).not.toHaveBeenCalled();
});
it("lifecycle clear marks stopped, clears id, and deletes preview message", async () => {
const state = { stopped: false, final: false };
let messageId: string | undefined = "m-4";
const deleteMessage = vi.fn(async () => {});
const lifecycle = createFinalizableDraftLifecycle({
throttleMs: 250,
state,
sendOrEditStreamMessage: async () => true,
readMessageId: () => messageId,
clearMessageId: () => {
messageId = undefined;
},
isValidMessageId: (value): value is string => typeof value === "string",
deleteMessage,
warnPrefix: "cleanup failed",
});
await lifecycle.clear();
expect(state.stopped).toBe(true);
expect(messageId).toBeUndefined();
expect(deleteMessage).toHaveBeenCalledWith("m-4");
});
});

View File

@@ -5,6 +5,26 @@ export type FinalizableDraftStreamState = {
final: boolean; final: boolean;
}; };
type StopAndClearMessageIdParams<T> = {
stopForClear: () => Promise<void>;
readMessageId: () => T | undefined;
clearMessageId: () => void;
};
type ClearFinalizableDraftMessageParams<T> = StopAndClearMessageIdParams<T> & {
isValidMessageId: (value: unknown) => value is T;
deleteMessage: (messageId: T) => Promise<void>;
onDeleteSuccess?: (messageId: T) => void;
warn?: (message: string) => void;
warnPrefix: string;
};
type FinalizableDraftLifecycleParams<T> = ClearFinalizableDraftMessageParams<T> & {
throttleMs: number;
state: FinalizableDraftStreamState;
sendOrEditStreamMessage: (text: string) => Promise<boolean>;
};
export function createFinalizableDraftStreamControls(params: { export function createFinalizableDraftStreamControls(params: {
throttleMs: number; throttleMs: number;
isStopped: () => boolean; isStopped: () => boolean;
@@ -64,27 +84,18 @@ export function createFinalizableDraftStreamControlsForState(params: {
}); });
} }
export async function takeMessageIdAfterStop<T>(params: { export async function takeMessageIdAfterStop<T>(
stopForClear: () => Promise<void>; params: StopAndClearMessageIdParams<T>,
readMessageId: () => T | undefined; ): Promise<T | undefined> {
clearMessageId: () => void;
}): Promise<T | undefined> {
await params.stopForClear(); await params.stopForClear();
const messageId = params.readMessageId(); const messageId = params.readMessageId();
params.clearMessageId(); params.clearMessageId();
return messageId; return messageId;
} }
export async function clearFinalizableDraftMessage<T>(params: { export async function clearFinalizableDraftMessage<T>(
stopForClear: () => Promise<void>; params: ClearFinalizableDraftMessageParams<T>,
readMessageId: () => T | undefined; ): Promise<void> {
clearMessageId: () => void;
isValidMessageId: (value: unknown) => value is T;
deleteMessage: (messageId: T) => Promise<void>;
onDeleteSuccess?: (messageId: T) => void;
warn?: (message: string) => void;
warnPrefix: string;
}): Promise<void> {
const messageId = await takeMessageIdAfterStop({ const messageId = await takeMessageIdAfterStop({
stopForClear: params.stopForClear, stopForClear: params.stopForClear,
readMessageId: params.readMessageId, readMessageId: params.readMessageId,
@@ -101,18 +112,7 @@ export async function clearFinalizableDraftMessage<T>(params: {
} }
} }
export function createFinalizableDraftLifecycle<T>(params: { export function createFinalizableDraftLifecycle<T>(params: FinalizableDraftLifecycleParams<T>) {
throttleMs: number;
state: FinalizableDraftStreamState;
sendOrEditStreamMessage: (text: string) => Promise<boolean>;
readMessageId: () => T | undefined;
clearMessageId: () => void;
isValidMessageId: (value: unknown) => value is T;
deleteMessage: (messageId: T) => Promise<void>;
onDeleteSuccess?: (messageId: T) => void;
warn?: (message: string) => void;
warnPrefix: string;
}) {
const controls = createFinalizableDraftStreamControlsForState({ const controls = createFinalizableDraftStreamControlsForState({
throttleMs: params.throttleMs, throttleMs: params.throttleMs,
state: params.state, state: params.state,

View File

@@ -36,6 +36,24 @@ vi.mock("../../../discord/monitor/thread-bindings.js", async (importOriginal) =>
const { discordOutbound } = await import("./discord.js"); const { discordOutbound } = await import("./discord.js");
function mockBoundThreadManager() {
hoisted.getThreadBindingManagerMock.mockReturnValue({
getByThreadId: () => ({
accountId: "default",
channelId: "parent-1",
threadId: "thread-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
label: "codex-thread",
webhookId: "wh-1",
webhookToken: "tok-1",
boundBy: "system",
boundAt: Date.now(),
}),
});
}
describe("normalizeDiscordOutboundTarget", () => { describe("normalizeDiscordOutboundTarget", () => {
it("normalizes bare numeric IDs to channel: prefix", () => { it("normalizes bare numeric IDs to channel: prefix", () => {
expect(normalizeDiscordOutboundTarget("1470130713209602050")).toEqual({ expect(normalizeDiscordOutboundTarget("1470130713209602050")).toEqual({
@@ -110,21 +128,7 @@ describe("discordOutbound", () => {
}); });
it("uses webhook persona delivery for bound thread text replies", async () => { it("uses webhook persona delivery for bound thread text replies", async () => {
hoisted.getThreadBindingManagerMock.mockReturnValue({ mockBoundThreadManager();
getByThreadId: () => ({
accountId: "default",
channelId: "parent-1",
threadId: "thread-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
label: "codex-thread",
webhookId: "wh-1",
webhookToken: "tok-1",
boundBy: "system",
boundAt: Date.now(),
}),
});
const result = await discordOutbound.sendText?.({ const result = await discordOutbound.sendText?.({
cfg: {}, cfg: {},
@@ -160,20 +164,7 @@ describe("discordOutbound", () => {
}); });
it("falls back to bot send for silent delivery on bound threads", async () => { it("falls back to bot send for silent delivery on bound threads", async () => {
hoisted.getThreadBindingManagerMock.mockReturnValue({ mockBoundThreadManager();
getByThreadId: () => ({
accountId: "default",
channelId: "parent-1",
threadId: "thread-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
webhookId: "wh-1",
webhookToken: "tok-1",
boundBy: "system",
boundAt: Date.now(),
}),
});
const result = await discordOutbound.sendText?.({ const result = await discordOutbound.sendText?.({
cfg: {}, cfg: {},
@@ -201,20 +192,7 @@ describe("discordOutbound", () => {
}); });
it("falls back to bot send when webhook send fails", async () => { it("falls back to bot send when webhook send fails", async () => {
hoisted.getThreadBindingManagerMock.mockReturnValue({ mockBoundThreadManager();
getByThreadId: () => ({
accountId: "default",
channelId: "parent-1",
threadId: "thread-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
webhookId: "wh-1",
webhookToken: "tok-1",
boundBy: "system",
boundAt: Date.now(),
}),
});
hoisted.sendWebhookMessageDiscordMock.mockRejectedValueOnce(new Error("rate limited")); hoisted.sendWebhookMessageDiscordMock.mockRejectedValueOnce(new Error("rate limited"));
const result = await discordOutbound.sendText?.({ const result = await discordOutbound.sendText?.({

View File

@@ -57,7 +57,7 @@ export type ChannelConfigAdapter<ResolvedAccount> = {
resolveAllowFrom?: (params: { resolveAllowFrom?: (params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
accountId?: string | null; accountId?: string | null;
}) => string[] | undefined; }) => Array<string | number> | undefined;
formatAllowFrom?: (params: { formatAllowFrom?: (params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
accountId?: string | null; accountId?: string | null;

View File

@@ -41,6 +41,21 @@ const createEnabledController = (
return { adapter, calls, controller }; return { adapter, calls, controller };
}; };
const createSetOnlyController = () => {
const calls: { method: string; emoji: string }[] = [];
const adapter: StatusReactionAdapter = {
setReaction: vi.fn(async (emoji: string) => {
calls.push({ method: "set", emoji });
}),
};
const controller = createStatusReactionController({
enabled: true,
adapter,
initialEmoji: "👀",
});
return { calls, controller };
};
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Tests // Tests
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -245,19 +260,7 @@ describe("createStatusReactionController", () => {
}); });
it("should only call setReaction when adapter lacks removeReaction", async () => { it("should only call setReaction when adapter lacks removeReaction", async () => {
const calls: { method: string; emoji: string }[] = []; const { calls, controller } = createSetOnlyController();
const adapter: StatusReactionAdapter = {
setReaction: vi.fn(async (emoji: string) => {
calls.push({ method: "set", emoji });
}),
// No removeReaction
};
const controller = createStatusReactionController({
enabled: true,
adapter,
initialEmoji: "👀",
});
void controller.setQueued(); void controller.setQueued();
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
@@ -285,18 +288,7 @@ describe("createStatusReactionController", () => {
}); });
it("should handle clear gracefully when adapter lacks removeReaction", async () => { it("should handle clear gracefully when adapter lacks removeReaction", async () => {
const calls: { method: string; emoji: string }[] = []; const { calls, controller } = createSetOnlyController();
const adapter: StatusReactionAdapter = {
setReaction: vi.fn(async (emoji: string) => {
calls.push({ method: "set", emoji });
}),
};
const controller = createStatusReactionController({
enabled: true,
adapter,
initialEmoji: "👀",
});
await controller.clear(); await controller.clear();

View File

@@ -18,6 +18,36 @@ vi.mock("../send.js", () => ({
describe("deliverDiscordReply", () => { describe("deliverDiscordReply", () => {
const runtime = {} as RuntimeEnv; const runtime = {} as RuntimeEnv;
const createBoundThreadBindings = async (
overrides: Partial<{
threadId: string;
channelId: string;
targetSessionKey: string;
agentId: string;
label: string;
webhookId: string;
webhookToken: string;
introText: string;
}> = {},
) => {
const threadBindings = createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
});
await threadBindings.bindTarget({
threadId: "thread-1",
channelId: "parent-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
webhookId: "wh_1",
webhookToken: "tok_1",
introText: "",
...overrides,
});
return threadBindings;
};
beforeEach(() => { beforeEach(() => {
sendMessageDiscordMock.mockClear().mockResolvedValue({ sendMessageDiscordMock.mockClear().mockResolvedValue({
@@ -136,22 +166,7 @@ describe("deliverDiscordReply", () => {
}); });
it("sends bound-session text replies through webhook delivery", async () => { it("sends bound-session text replies through webhook delivery", async () => {
const threadBindings = createThreadBindingManager({ const threadBindings = await createBoundThreadBindings({ label: "codex-refactor" });
accountId: "default",
persist: false,
enableSweeper: false,
});
await threadBindings.bindTarget({
threadId: "thread-1",
channelId: "parent-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
label: "codex-refactor",
webhookId: "wh_1",
webhookToken: "tok_1",
introText: "",
});
await deliverDiscordReply({ await deliverDiscordReply({
replies: [{ text: "Hello from subagent" }], replies: [{ text: "Hello from subagent" }],
@@ -179,21 +194,7 @@ describe("deliverDiscordReply", () => {
}); });
it("falls back to bot send when webhook delivery fails", async () => { it("falls back to bot send when webhook delivery fails", async () => {
const threadBindings = createThreadBindingManager({ const threadBindings = await createBoundThreadBindings();
accountId: "default",
persist: false,
enableSweeper: false,
});
await threadBindings.bindTarget({
threadId: "thread-1",
channelId: "parent-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
webhookId: "wh_1",
webhookToken: "tok_1",
introText: "",
});
sendWebhookMessageDiscordMock.mockRejectedValueOnce(new Error("rate limited")); sendWebhookMessageDiscordMock.mockRejectedValueOnce(new Error("rate limited"));
await deliverDiscordReply({ await deliverDiscordReply({
@@ -217,21 +218,7 @@ describe("deliverDiscordReply", () => {
}); });
it("does not use thread webhook when outbound target is not a bound thread", async () => { it("does not use thread webhook when outbound target is not a bound thread", async () => {
const threadBindings = createThreadBindingManager({ const threadBindings = await createBoundThreadBindings();
accountId: "default",
persist: false,
enableSweeper: false,
});
await threadBindings.bindTarget({
threadId: "thread-1",
channelId: "parent-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
webhookId: "wh_1",
webhookToken: "tok_1",
introText: "",
});
await deliverDiscordReply({ await deliverDiscordReply({
replies: [{ text: "Parent channel delivery" }], replies: [{ text: "Parent channel delivery" }],

View File

@@ -99,6 +99,20 @@ const baseParams = () => ({
removeAckAfterReply: false, removeAckAfterReply: false,
}); });
type ThreadStarterClient = Parameters<typeof resolveSlackThreadStarter>[0]["client"];
function createThreadStarterRepliesClient(
response: { messages?: Array<{ text?: string; user?: string; ts?: string }> } = {
messages: [{ text: "root message", user: "U1", ts: "1000.1" }],
},
): { replies: ReturnType<typeof vi.fn>; client: ThreadStarterClient } {
const replies = vi.fn(async () => response);
const client = {
conversations: { replies },
} as unknown as ThreadStarterClient;
return { replies, client };
}
describe("normalizeSlackChannelType", () => { describe("normalizeSlackChannelType", () => {
it("infers channel types from ids when missing", () => { it("infers channel types from ids when missing", () => {
expect(normalizeSlackChannelType(undefined, "C123")).toBe("channel"); expect(normalizeSlackChannelType(undefined, "C123")).toBe("channel");
@@ -185,12 +199,7 @@ describe("resolveSlackThreadStarter cache", () => {
}); });
it("returns cached thread starter without refetching within ttl", async () => { it("returns cached thread starter without refetching within ttl", async () => {
const replies = vi.fn(async () => ({ const { replies, client } = createThreadStarterRepliesClient();
messages: [{ text: "root message", user: "U1", ts: "1000.1" }],
}));
const client = {
conversations: { replies },
} as unknown as Parameters<typeof resolveSlackThreadStarter>[0]["client"];
const first = await resolveSlackThreadStarter({ const first = await resolveSlackThreadStarter({
channelId: "C1", channelId: "C1",
@@ -211,12 +220,7 @@ describe("resolveSlackThreadStarter cache", () => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
const replies = vi.fn(async () => ({ const { replies, client } = createThreadStarterRepliesClient();
messages: [{ text: "root message", user: "U1", ts: "1000.1" }],
}));
const client = {
conversations: { replies },
} as unknown as Parameters<typeof resolveSlackThreadStarter>[0]["client"];
await resolveSlackThreadStarter({ await resolveSlackThreadStarter({
channelId: "C1", channelId: "C1",
@@ -234,13 +238,29 @@ describe("resolveSlackThreadStarter cache", () => {
expect(replies).toHaveBeenCalledTimes(2); expect(replies).toHaveBeenCalledTimes(2);
}); });
it("does not cache empty starter text", async () => {
const { replies, client } = createThreadStarterRepliesClient({
messages: [{ text: " ", user: "U1", ts: "1000.1" }],
});
const first = await resolveSlackThreadStarter({
channelId: "C1",
threadTs: "1000.1",
client,
});
const second = await resolveSlackThreadStarter({
channelId: "C1",
threadTs: "1000.1",
client,
});
expect(first).toBeNull();
expect(second).toBeNull();
expect(replies).toHaveBeenCalledTimes(2);
});
it("evicts oldest entries once cache exceeds bounded size", async () => { it("evicts oldest entries once cache exceeds bounded size", async () => {
const replies = vi.fn(async () => ({ const { replies, client } = createThreadStarterRepliesClient();
messages: [{ text: "root message", user: "U1", ts: "1000.1" }],
}));
const client = {
conversations: { replies },
} as unknown as Parameters<typeof resolveSlackThreadStarter>[0]["client"];
// Cache cap is 2000; add enough distinct keys to force eviction of earliest keys. // Cache cap is 2000; add enough distinct keys to force eviction of earliest keys.
for (let i = 0; i <= 2000; i += 1) { for (let i = 0; i <= 2000; i += 1) {

View File

@@ -9,6 +9,13 @@ vi.mock("../../auto-reply/commands-registry.js", () => {
const reportLongCommand = { key: "reportlong", nativeName: "reportlong" }; const reportLongCommand = { key: "reportlong", nativeName: "reportlong" };
const unsafeConfirmCommand = { key: "unsafeconfirm", nativeName: "unsafeconfirm" }; const unsafeConfirmCommand = { key: "unsafeconfirm", nativeName: "unsafeconfirm" };
const periodArg = { name: "period", description: "period" }; const periodArg = { name: "period", description: "period" };
const baseReportPeriodChoices = [
{ value: "day", label: "day" },
{ value: "week", label: "week" },
{ value: "month", label: "month" },
{ value: "quarter", label: "quarter" },
];
const fullReportPeriodChoices = [...baseReportPeriodChoices, { value: "year", label: "year" }];
const hasNonEmptyArgValue = (values: unknown, key: string) => { const hasNonEmptyArgValue = (values: unknown, key: string) => {
const raw = const raw =
typeof values === "object" && values !== null typeof values === "object" && values !== null
@@ -113,31 +120,18 @@ vi.mock("../../auto-reply/commands-registry.js", () => {
}) => { }) => {
if (params.command?.key === "report") { if (params.command?.key === "report") {
return resolvePeriodMenu(params, [ return resolvePeriodMenu(params, [
{ value: "day", label: "day" }, ...fullReportPeriodChoices,
{ value: "week", label: "week" },
{ value: "month", label: "month" },
{ value: "quarter", label: "quarter" },
{ value: "year", label: "year" },
{ value: "all", label: "all" }, { value: "all", label: "all" },
]); ]);
} }
if (params.command?.key === "reportlong") { if (params.command?.key === "reportlong") {
return resolvePeriodMenu(params, [ return resolvePeriodMenu(params, [
{ value: "day", label: "day" }, ...fullReportPeriodChoices,
{ value: "week", label: "week" },
{ value: "month", label: "month" },
{ value: "quarter", label: "quarter" },
{ value: "year", label: "year" },
{ value: "x".repeat(90), label: "long" }, { value: "x".repeat(90), label: "long" },
]); ]);
} }
if (params.command?.key === "reportcompact") { if (params.command?.key === "reportcompact") {
return resolvePeriodMenu(params, [ return resolvePeriodMenu(params, baseReportPeriodChoices);
{ value: "day", label: "day" },
{ value: "week", label: "week" },
{ value: "month", label: "month" },
{ value: "quarter", label: "quarter" },
]);
} }
if (params.command?.key === "reportexternal") { if (params.command?.key === "reportexternal") {
return { return {
@@ -320,6 +314,12 @@ function expectArgMenuLayout(respond: ReturnType<typeof vi.fn>): {
return findFirstActionsBlock(payload) ?? { type: "actions", elements: [] }; return findFirstActionsBlock(payload) ?? { type: "actions", elements: [] };
} }
function expectSingleDispatchedSlashBody(expectedBody: string) {
expect(dispatchMock).toHaveBeenCalledTimes(1);
const call = dispatchMock.mock.calls[0]?.[0] as { ctx?: { Body?: string } };
expect(call.ctx?.Body).toBe(expectedBody);
}
async function runArgMenuAction( async function runArgMenuAction(
handler: (args: unknown) => Promise<void>, handler: (args: unknown) => Promise<void>,
params: { params: {
@@ -509,9 +509,7 @@ describe("Slack native command argument menus", () => {
}, },
}); });
expect(dispatchMock).toHaveBeenCalledTimes(1); expectSingleDispatchedSlashBody("/report month");
const call = dispatchMock.mock.calls[0]?.[0] as { ctx?: { Body?: string } };
expect(call.ctx?.Body).toBe("/report month");
}); });
it("dispatches the command when an overflow option is chosen", async () => { it("dispatches the command when an overflow option is chosen", async () => {
@@ -528,9 +526,7 @@ describe("Slack native command argument menus", () => {
}, },
}); });
expect(dispatchMock).toHaveBeenCalledTimes(1); expectSingleDispatchedSlashBody("/reportcompact quarter");
const call = dispatchMock.mock.calls[0]?.[0] as { ctx?: { Body?: string } };
expect(call.ctx?.Body).toBe("/reportcompact quarter");
}); });
it("shows an external_select menu when choices exceed static_select options max", async () => { it("shows an external_select menu when choices exceed static_select options max", async () => {

View File

@@ -3,6 +3,27 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
let collectTelegramUnmentionedGroupIds: typeof import("./audit.js").collectTelegramUnmentionedGroupIds; let collectTelegramUnmentionedGroupIds: typeof import("./audit.js").collectTelegramUnmentionedGroupIds;
let auditTelegramGroupMembership: typeof import("./audit.js").auditTelegramGroupMembership; let auditTelegramGroupMembership: typeof import("./audit.js").auditTelegramGroupMembership;
function mockGetChatMemberStatus(status: string) {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ ok: true, result: { status } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);
}
async function auditSingleGroup() {
return auditTelegramGroupMembership({
token: "t",
botId: 123,
groupIds: ["-1001"],
timeoutMs: 5000,
});
}
describe("telegram audit", () => { describe("telegram audit", () => {
beforeAll(async () => { beforeAll(async () => {
({ collectTelegramUnmentionedGroupIds, auditTelegramGroupMembership } = ({ collectTelegramUnmentionedGroupIds, auditTelegramGroupMembership } =
@@ -27,42 +48,16 @@ describe("telegram audit", () => {
}); });
it("audits membership via getChatMember", async () => { it("audits membership via getChatMember", async () => {
vi.stubGlobal( mockGetChatMemberStatus("member");
"fetch", const res = await auditSingleGroup();
vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ ok: true, result: { status: "member" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);
const res = await auditTelegramGroupMembership({
token: "t",
botId: 123,
groupIds: ["-1001"],
timeoutMs: 5000,
});
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(res.groups[0]?.chatId).toBe("-1001"); expect(res.groups[0]?.chatId).toBe("-1001");
expect(res.groups[0]?.status).toBe("member"); expect(res.groups[0]?.status).toBe("member");
}); });
it("reports bot not in group when status is left", async () => { it("reports bot not in group when status is left", async () => {
vi.stubGlobal( mockGetChatMemberStatus("left");
"fetch", const res = await auditSingleGroup();
vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ ok: true, result: { status: "left" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);
const res = await auditTelegramGroupMembership({
token: "t",
botId: 123,
groupIds: ["-1001"],
timeoutMs: 5000,
});
expect(res.ok).toBe(false); expect(res.ok).toBe(false);
expect(res.groups[0]?.ok).toBe(false); expect(res.groups[0]?.ok).toBe(false);
expect(res.groups[0]?.status).toBe("left"); expect(res.groups[0]?.status).toBe("left");

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { buildTelegramMessageContext } from "./bot-message-context.js"; import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
const transcribeFirstAudioMock = vi.fn(); const transcribeFirstAudioMock = vi.fn();
@@ -11,39 +11,22 @@ describe("buildTelegramMessageContext audio transcript body", () => {
it("uses preflight transcript as BodyForAgent for mention-gated group voice messages", async () => { it("uses preflight transcript as BodyForAgent for mention-gated group voice messages", async () => {
transcribeFirstAudioMock.mockResolvedValueOnce("hey bot please help"); transcribeFirstAudioMock.mockResolvedValueOnce("hey bot please help");
const ctx = await buildTelegramMessageContext({ const ctx = await buildTelegramMessageContextForTest({
primaryCtx: { message: {
message: { message_id: 1,
message_id: 1, chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" }, date: 1700000000,
date: 1700000000, text: undefined,
from: { id: 42, first_name: "Alice" }, from: { id: 42, first_name: "Alice" },
voice: { file_id: "voice-1" }, voice: { file_id: "voice-1" },
}, },
me: { id: 7, username: "bot" },
} as never,
allMedia: [{ path: "/tmp/voice.ogg", contentType: "audio/ogg" }], allMedia: [{ path: "/tmp/voice.ogg", contentType: "audio/ogg" }],
storeAllowFrom: [],
options: { forceWasMentioned: true }, options: { forceWasMentioned: true },
bot: {
api: {
sendChatAction: vi.fn(),
setMessageReaction: vi.fn(),
},
} as never,
cfg: { cfg: {
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } }, agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } },
channels: { telegram: {} }, channels: { telegram: {} },
messages: { groupChat: { mentionPatterns: ["\\bbot\\b"] } }, messages: { groupChat: { mentionPatterns: ["\\bbot\\b"] } },
} as never, },
account: { accountId: "default" } as never,
historyLimit: 0,
groupHistories: new Map(),
dmPolicy: "open",
allowFrom: [],
groupAllowFrom: [],
ackReactionScope: "off",
logger: { info: vi.fn() },
resolveGroupActivation: () => true, resolveGroupActivation: () => true,
resolveGroupRequireMention: () => true, resolveGroupRequireMention: () => true,
resolveTelegramGroupConfig: () => ({ resolveTelegramGroupConfig: () => ({

View File

@@ -1,50 +1,17 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it } from "vitest";
import { buildTelegramMessageContext } from "./bot-message-context.js"; import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
describe("buildTelegramMessageContext sender prefix", () => { describe("buildTelegramMessageContext sender prefix", () => {
async function buildCtx(params: { async function buildCtx(params: { messageId: number; options?: Record<string, unknown> }) {
messageId: number; return await buildTelegramMessageContextForTest({
options?: Record<string, unknown>; message: {
}): Promise<Awaited<ReturnType<typeof buildTelegramMessageContext>>> { message_id: params.messageId,
return await buildTelegramMessageContext({ chat: { id: -99, type: "supergroup", title: "Dev Chat" },
primaryCtx: { date: 1700000000,
message: { text: "hello",
message_id: params.messageId, from: { id: 42, first_name: "Alice" },
chat: { id: -99, type: "supergroup", title: "Dev Chat" }, },
date: 1700000000, options: params.options,
text: "hello",
from: { id: 42, first_name: "Alice" },
},
me: { id: 7, username: "bot" },
} as never,
allMedia: [],
storeAllowFrom: [],
options: params.options ?? {},
bot: {
api: {
sendChatAction: vi.fn(),
setMessageReaction: vi.fn(),
},
} as never,
cfg: {
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } },
channels: { telegram: {} },
messages: { groupChat: { mentionPatterns: [] } },
} as never,
account: { accountId: "default" } as never,
historyLimit: 0,
groupHistories: new Map(),
dmPolicy: "open",
allowFrom: [],
groupAllowFrom: [],
ackReactionScope: "off",
logger: { info: vi.fn() },
resolveGroupActivation: () => undefined,
resolveGroupRequireMention: () => false,
resolveTelegramGroupConfig: () => ({
groupConfig: { requireMention: false },
topicConfig: undefined,
}),
}); });
} }

View File

@@ -9,8 +9,15 @@ export const baseTelegramMessageContextConfig = {
type BuildTelegramMessageContextForTestParams = { type BuildTelegramMessageContextForTestParams = {
message: Record<string, unknown>; message: Record<string, unknown>;
allMedia?: Array<Record<string, unknown>>;
options?: Record<string, unknown>; options?: Record<string, unknown>;
cfg?: Record<string, unknown>;
resolveGroupActivation?: () => boolean | undefined; resolveGroupActivation?: () => boolean | undefined;
resolveGroupRequireMention?: () => boolean;
resolveTelegramGroupConfig?: () => {
groupConfig?: { requireMention?: boolean };
topicConfig?: unknown;
};
}; };
export async function buildTelegramMessageContextForTest( export async function buildTelegramMessageContextForTest(
@@ -27,7 +34,7 @@ export async function buildTelegramMessageContextForTest(
}, },
me: { id: 7, username: "bot" }, me: { id: 7, username: "bot" },
} as never, } as never,
allMedia: [], allMedia: params.allMedia ?? [],
storeAllowFrom: [], storeAllowFrom: [],
options: params.options ?? {}, options: params.options ?? {},
bot: { bot: {
@@ -36,7 +43,7 @@ export async function buildTelegramMessageContextForTest(
setMessageReaction: vi.fn(), setMessageReaction: vi.fn(),
}, },
} as never, } as never,
cfg: baseTelegramMessageContextConfig, cfg: (params.cfg ?? baseTelegramMessageContextConfig) as never,
account: { accountId: "default" } as never, account: { accountId: "default" } as never,
historyLimit: 0, historyLimit: 0,
groupHistories: new Map(), groupHistories: new Map(),
@@ -46,10 +53,12 @@ export async function buildTelegramMessageContextForTest(
ackReactionScope: "off", ackReactionScope: "off",
logger: { info: vi.fn() }, logger: { info: vi.fn() },
resolveGroupActivation: params.resolveGroupActivation ?? (() => undefined), resolveGroupActivation: params.resolveGroupActivation ?? (() => undefined),
resolveGroupRequireMention: () => false, resolveGroupRequireMention: params.resolveGroupRequireMention ?? (() => false),
resolveTelegramGroupConfig: () => ({ resolveTelegramGroupConfig:
groupConfig: { requireMention: false }, params.resolveTelegramGroupConfig ??
topicConfig: undefined, (() => ({
}), groupConfig: { requireMention: false },
topicConfig: undefined,
})),
}); });
} }

View File

@@ -62,6 +62,7 @@ import {
} from "./bot/helpers.js"; } from "./bot/helpers.js";
import type { StickerMetadata, TelegramContext } from "./bot/types.js"; import type { StickerMetadata, TelegramContext } from "./bot/types.js";
import { evaluateTelegramGroupBaseAccess } from "./group-access.js"; import { evaluateTelegramGroupBaseAccess } from "./group-access.js";
import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js";
import { import {
buildTelegramStatusReactionVariants, buildTelegramStatusReactionVariants,
resolveTelegramAllowedEmojiReactions, resolveTelegramAllowedEmojiReactions,
@@ -675,13 +676,10 @@ export const buildTelegramMessageContext = async ({
}); });
} }
const skillFilter = firstDefined(topicConfig?.skills, groupConfig?.skills); const { skillFilter, groupSystemPrompt } = resolveTelegramGroupPromptSettings({
const systemPromptParts = [ groupConfig,
groupConfig?.systemPrompt?.trim() || null, topicConfig,
topicConfig?.systemPrompt?.trim() || null, });
].filter((entry): entry is string => Boolean(entry));
const groupSystemPrompt =
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
const commandBody = normalizeCommandBody(rawBody, { botUsername }); const commandBody = normalizeCommandBody(rawBody, { botUsername });
const inboundHistory = const inboundHistory =
isGroup && historyKey && historyLimit > 0 isGroup && historyKey && historyLimit > 0

View File

@@ -36,6 +36,20 @@ vi.mock("./bot/delivery.js", () => ({
})); }));
describe("registerTelegramNativeCommands", () => { describe("registerTelegramNativeCommands", () => {
type RegisteredCommand = {
command: string;
description: string;
};
async function waitForRegisteredCommands(
setMyCommands: ReturnType<typeof vi.fn>,
): Promise<RegisteredCommand[]> {
await vi.waitFor(() => {
expect(setMyCommands).toHaveBeenCalled();
});
return setMyCommands.mock.calls[0]?.[0] as RegisteredCommand[];
}
beforeEach(() => { beforeEach(() => {
listSkillCommandsForAgents.mockClear(); listSkillCommandsForAgents.mockClear();
listSkillCommandsForAgents.mockReturnValue([]); listSkillCommandsForAgents.mockReturnValue([]);
@@ -166,14 +180,7 @@ describe("registerTelegramNativeCommands", () => {
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"], } as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
}); });
await vi.waitFor(() => { const registeredCommands = await waitForRegisteredCommands(setMyCommands);
expect(setMyCommands).toHaveBeenCalled();
});
const registeredCommands = setMyCommands.mock.calls[0]?.[0] as Array<{
command: string;
description: string;
}>;
expect(registeredCommands.some((entry) => entry.command === "export_session")).toBe(true); expect(registeredCommands.some((entry) => entry.command === "export_session")).toBe(true);
expect(registeredCommands.some((entry) => entry.command === "export-session")).toBe(false); expect(registeredCommands.some((entry) => entry.command === "export-session")).toBe(false);
@@ -207,14 +214,7 @@ describe("registerTelegramNativeCommands", () => {
} as TelegramAccountConfig, } as TelegramAccountConfig,
}); });
await vi.waitFor(() => { const registeredCommands = await waitForRegisteredCommands(setMyCommands);
expect(setMyCommands).toHaveBeenCalled();
});
const registeredCommands = setMyCommands.mock.calls[0]?.[0] as Array<{
command: string;
description: string;
}>;
expect(registeredCommands.length).toBeGreaterThan(0); expect(registeredCommands.length).toBeGreaterThan(0);
for (const entry of registeredCommands) { for (const entry of registeredCommands) {

View File

@@ -41,7 +41,7 @@ import { resolveAgentRoute } from "../routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../routing/session-key.js"; import { resolveThreadSessionKeys } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { withTelegramApiErrorLogging } from "./api-logging.js"; import { withTelegramApiErrorLogging } from "./api-logging.js";
import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js"; import { isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js";
import { import {
buildCappedTelegramMenuCommands, buildCappedTelegramMenuCommands,
buildPluginTelegramMenuCommands, buildPluginTelegramMenuCommands,
@@ -64,6 +64,7 @@ import {
evaluateTelegramGroupBaseAccess, evaluateTelegramGroupBaseAccess,
evaluateTelegramGroupPolicyAccess, evaluateTelegramGroupPolicyAccess,
} from "./group-access.js"; } from "./group-access.js";
import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js";
import { buildInlineKeyboard } from "./send.js"; import { buildInlineKeyboard } from "./send.js";
const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again."; const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again.";
@@ -552,13 +553,10 @@ export const registerTelegramNativeCommands = ({
}) })
: null; : null;
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey; const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
const skillFilter = firstDefined(topicConfig?.skills, groupConfig?.skills); const { skillFilter, groupSystemPrompt } = resolveTelegramGroupPromptSettings({
const systemPromptParts = [ groupConfig,
groupConfig?.systemPrompt?.trim() || null, topicConfig,
topicConfig?.systemPrompt?.trim() || null, });
].filter((entry): entry is string => Boolean(entry));
const groupSystemPrompt =
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
const conversationLabel = isGroup const conversationLabel = isGroup
? msg.chat.title ? msg.chat.title
? `${msg.chat.title} id:${chatId}` ? `${msg.chat.title} id:${chatId}`

View File

@@ -17,6 +17,11 @@ async function createMessageHandlerAndReplySpy() {
return { handler, replySpy }; return { handler, replySpy };
} }
function expectSingleReplyPayload(replySpy: ReturnType<typeof vi.fn>) {
expect(replySpy).toHaveBeenCalledTimes(1);
return replySpy.mock.calls[0][0] as Record<string, unknown>;
}
describe("telegram inbound media", () => { describe("telegram inbound media", () => {
const _INBOUND_MEDIA_TEST_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 20_000; const _INBOUND_MEDIA_TEST_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 20_000;
it( it(
@@ -40,8 +45,7 @@ describe("telegram inbound media", () => {
getFile: async () => ({ file_path: "unused" }), getFile: async () => ({ file_path: "unused" }),
}); });
expect(replySpy).toHaveBeenCalledTimes(1); const payload = expectSingleReplyPayload(replySpy);
const payload = replySpy.mock.calls[0][0];
expect(payload.Body).toContain("Meet here"); expect(payload.Body).toContain("Meet here");
expect(payload.Body).toContain("48.858844"); expect(payload.Body).toContain("48.858844");
expect(payload.LocationLat).toBe(48.858844); expect(payload.LocationLat).toBe(48.858844);
@@ -72,8 +76,7 @@ describe("telegram inbound media", () => {
getFile: async () => ({ file_path: "unused" }), getFile: async () => ({ file_path: "unused" }),
}); });
expect(replySpy).toHaveBeenCalledTimes(1); const payload = expectSingleReplyPayload(replySpy);
const payload = replySpy.mock.calls[0][0];
expect(payload.Body).toContain("Eiffel Tower"); expect(payload.Body).toContain("Eiffel Tower");
expect(payload.LocationName).toBe("Eiffel Tower"); expect(payload.LocationName).toBe("Eiffel Tower");
expect(payload.LocationAddress).toBe("Champ de Mars, Paris"); expect(payload.LocationAddress).toBe("Champ de Mars, Paris");

View File

@@ -61,6 +61,16 @@ function mockMediaLoad(fileName: string, contentType: string, data: string) {
}); });
} }
function createSendMessageHarness(messageId = 4) {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({
message_id: messageId,
chat: { id: "123" },
});
const bot = createBot({ sendMessage });
return { runtime, sendMessage, bot };
}
describe("deliverReplies", () => { describe("deliverReplies", () => {
beforeEach(() => { beforeEach(() => {
loadWebMedia.mockReset(); loadWebMedia.mockReset();
@@ -178,12 +188,7 @@ describe("deliverReplies", () => {
}); });
it("includes message_thread_id for DM topics", async () => { it("includes message_thread_id for DM topics", async () => {
const runtime = createRuntime(); const { runtime, sendMessage, bot } = createSendMessageHarness();
const sendMessage = vi.fn().mockResolvedValue({
message_id: 4,
chat: { id: "123" },
});
const bot = createBot({ sendMessage });
await deliverWith({ await deliverWith({
replies: [{ text: "Hello" }], replies: [{ text: "Hello" }],
@@ -202,12 +207,7 @@ describe("deliverReplies", () => {
}); });
it("does not include link_preview_options when linkPreview is true", async () => { it("does not include link_preview_options when linkPreview is true", async () => {
const runtime = createRuntime(); const { runtime, sendMessage, bot } = createSendMessageHarness();
const sendMessage = vi.fn().mockResolvedValue({
message_id: 4,
chat: { id: "123" },
});
const bot = createBot({ sendMessage });
await deliverWith({ await deliverWith({
replies: [{ text: "Check https://example.com" }], replies: [{ text: "Check https://example.com" }],

View File

@@ -0,0 +1,19 @@
import type { TelegramGroupConfig, TelegramTopicConfig } from "../config/types.js";
import { firstDefined } from "./bot-access.js";
export function resolveTelegramGroupPromptSettings(params: {
groupConfig?: TelegramGroupConfig;
topicConfig?: TelegramTopicConfig;
}): {
skillFilter: string[] | undefined;
groupSystemPrompt: string | undefined;
} {
const skillFilter = firstDefined(params.topicConfig?.skills, params.groupConfig?.skills);
const systemPromptParts = [
params.groupConfig?.systemPrompt?.trim() || null,
params.topicConfig?.systemPrompt?.trim() || null,
].filter((entry): entry is string => Boolean(entry));
const groupSystemPrompt =
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
return { skillFilter, groupSystemPrompt };
}

View File

@@ -2,9 +2,44 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import { resolveTelegramReactionLevel } from "./reaction-level.js"; import { resolveTelegramReactionLevel } from "./reaction-level.js";
type ReactionResolution = ReturnType<typeof resolveTelegramReactionLevel>;
describe("resolveTelegramReactionLevel", () => { describe("resolveTelegramReactionLevel", () => {
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN; const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
const expectReactionFlags = (
result: ReactionResolution,
expected: {
level: "off" | "ack" | "minimal" | "extensive";
ackEnabled: boolean;
agentReactionsEnabled: boolean;
agentReactionGuidance?: "minimal" | "extensive";
},
) => {
expect(result.level).toBe(expected.level);
expect(result.ackEnabled).toBe(expected.ackEnabled);
expect(result.agentReactionsEnabled).toBe(expected.agentReactionsEnabled);
expect(result.agentReactionGuidance).toBe(expected.agentReactionGuidance);
};
const expectMinimalFlags = (result: ReactionResolution) => {
expectReactionFlags(result, {
level: "minimal",
ackEnabled: false,
agentReactionsEnabled: true,
agentReactionGuidance: "minimal",
});
};
const expectExtensiveFlags = (result: ReactionResolution) => {
expectReactionFlags(result, {
level: "extensive",
ackEnabled: false,
agentReactionsEnabled: true,
agentReactionGuidance: "extensive",
});
};
beforeAll(() => { beforeAll(() => {
process.env.TELEGRAM_BOT_TOKEN = "test-token"; process.env.TELEGRAM_BOT_TOKEN = "test-token";
}); });
@@ -23,10 +58,7 @@ describe("resolveTelegramReactionLevel", () => {
}; };
const result = resolveTelegramReactionLevel({ cfg }); const result = resolveTelegramReactionLevel({ cfg });
expect(result.level).toBe("minimal"); expectMinimalFlags(result);
expect(result.ackEnabled).toBe(false);
expect(result.agentReactionsEnabled).toBe(true);
expect(result.agentReactionGuidance).toBe("minimal");
}); });
it("returns off level with no reactions enabled", () => { it("returns off level with no reactions enabled", () => {
@@ -35,10 +67,11 @@ describe("resolveTelegramReactionLevel", () => {
}; };
const result = resolveTelegramReactionLevel({ cfg }); const result = resolveTelegramReactionLevel({ cfg });
expect(result.level).toBe("off"); expectReactionFlags(result, {
expect(result.ackEnabled).toBe(false); level: "off",
expect(result.agentReactionsEnabled).toBe(false); ackEnabled: false,
expect(result.agentReactionGuidance).toBeUndefined(); agentReactionsEnabled: false,
});
}); });
it("returns ack level with only ackEnabled", () => { it("returns ack level with only ackEnabled", () => {
@@ -47,10 +80,11 @@ describe("resolveTelegramReactionLevel", () => {
}; };
const result = resolveTelegramReactionLevel({ cfg }); const result = resolveTelegramReactionLevel({ cfg });
expect(result.level).toBe("ack"); expectReactionFlags(result, {
expect(result.ackEnabled).toBe(true); level: "ack",
expect(result.agentReactionsEnabled).toBe(false); ackEnabled: true,
expect(result.agentReactionGuidance).toBeUndefined(); agentReactionsEnabled: false,
});
}); });
it("returns minimal level with agent reactions enabled and minimal guidance", () => { it("returns minimal level with agent reactions enabled and minimal guidance", () => {
@@ -59,10 +93,7 @@ describe("resolveTelegramReactionLevel", () => {
}; };
const result = resolveTelegramReactionLevel({ cfg }); const result = resolveTelegramReactionLevel({ cfg });
expect(result.level).toBe("minimal"); expectMinimalFlags(result);
expect(result.ackEnabled).toBe(false);
expect(result.agentReactionsEnabled).toBe(true);
expect(result.agentReactionGuidance).toBe("minimal");
}); });
it("returns extensive level with agent reactions enabled and extensive guidance", () => { it("returns extensive level with agent reactions enabled and extensive guidance", () => {
@@ -71,10 +102,7 @@ describe("resolveTelegramReactionLevel", () => {
}; };
const result = resolveTelegramReactionLevel({ cfg }); const result = resolveTelegramReactionLevel({ cfg });
expect(result.level).toBe("extensive"); expectExtensiveFlags(result);
expect(result.ackEnabled).toBe(false);
expect(result.agentReactionsEnabled).toBe(true);
expect(result.agentReactionGuidance).toBe("extensive");
}); });
it("resolves reaction level from a specific account", () => { it("resolves reaction level from a specific account", () => {
@@ -90,10 +118,7 @@ describe("resolveTelegramReactionLevel", () => {
}; };
const result = resolveTelegramReactionLevel({ cfg, accountId: "work" }); const result = resolveTelegramReactionLevel({ cfg, accountId: "work" });
expect(result.level).toBe("extensive"); expectExtensiveFlags(result);
expect(result.ackEnabled).toBe(false);
expect(result.agentReactionsEnabled).toBe(true);
expect(result.agentReactionGuidance).toBe("extensive");
}); });
it("falls back to global level when account has no reactionLevel", () => { it("falls back to global level when account has no reactionLevel", () => {
@@ -109,8 +134,6 @@ describe("resolveTelegramReactionLevel", () => {
}; };
const result = resolveTelegramReactionLevel({ cfg, accountId: "work" }); const result = resolveTelegramReactionLevel({ cfg, accountId: "work" });
expect(result.level).toBe("minimal"); expectMinimalFlags(result);
expect(result.agentReactionsEnabled).toBe(true);
expect(result.agentReactionGuidance).toBe("minimal");
}); });
}); });