refactor(channels): dedupe message routing and telegram helpers
This commit is contained in:
79
src/channels/dock.test.ts
Normal file
79
src/channels/dock.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
resolveChannelGroupRequireMention,
|
||||
resolveChannelGroupToolsPolicy,
|
||||
@@ -32,6 +31,7 @@ import { normalizeSignalMessagingTarget } from "./plugins/normalize/signal.js";
|
||||
import type {
|
||||
ChannelCapabilities,
|
||||
ChannelCommandAdapter,
|
||||
ChannelConfigAdapter,
|
||||
ChannelElevatedAdapter,
|
||||
ChannelGroupAdapter,
|
||||
ChannelId,
|
||||
@@ -53,21 +53,10 @@ export type ChannelDock = {
|
||||
};
|
||||
streaming?: ChannelDockStreaming;
|
||||
elevated?: ChannelElevatedAdapter;
|
||||
config?: {
|
||||
resolveAllowFrom?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
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;
|
||||
};
|
||||
config?: Pick<
|
||||
ChannelConfigAdapter<unknown>,
|
||||
"resolveAllowFrom" | "formatAllowFrom" | "resolveDefaultTo"
|
||||
>;
|
||||
groups?: ChannelGroupAdapter;
|
||||
mentions?: ChannelMentionAdapter;
|
||||
threading?: ChannelThreadingAdapter;
|
||||
@@ -87,6 +76,12 @@ const formatLower = (allowFrom: Array<string | number>) =>
|
||||
.filter(Boolean)
|
||||
.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>) =>
|
||||
allowFrom
|
||||
.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>(
|
||||
accounts: Record<string, T> | undefined,
|
||||
accountId?: string | null,
|
||||
@@ -182,13 +189,9 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
outbound: { textChunkLimit: 4000 },
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(resolveTelegramAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) =>
|
||||
String(entry),
|
||||
),
|
||||
stringifyAllowFrom(resolveTelegramAccount({ cfg, accountId }).config.allowFrom ?? []),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean)
|
||||
trimAllowFromEntries(allowFrom)
|
||||
.map((entry) => entry.replace(/^(telegram|tg):/i, ""))
|
||||
.map((entry) => entry.toLowerCase()),
|
||||
resolveDefaultTo: ({ cfg, accountId }) => {
|
||||
@@ -202,14 +205,8 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "off",
|
||||
buildToolContext: ({ context, hasRepliedRef }) => {
|
||||
const threadId = context.MessageThreadId ?? context.ReplyToId;
|
||||
return {
|
||||
currentChannelId: context.To?.trim() || undefined,
|
||||
currentThreadTs: threadId != null ? String(threadId) : undefined,
|
||||
hasRepliedRef,
|
||||
};
|
||||
},
|
||||
buildToolContext: ({ context, hasRepliedRef }) =>
|
||||
buildThreadToolContextFromMessageThreadOrReply({ context, hasRepliedRef }),
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
@@ -426,14 +423,8 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg }) => cfg.channels?.googlechat?.replyToMode ?? "off",
|
||||
buildToolContext: ({ context, hasRepliedRef }) => {
|
||||
const threadId = context.MessageThreadId ?? context.ReplyToId;
|
||||
return {
|
||||
currentChannelId: context.To?.trim() || undefined,
|
||||
currentThreadTs: threadId != null ? String(threadId) : undefined,
|
||||
hasRepliedRef,
|
||||
};
|
||||
},
|
||||
buildToolContext: ({ context, hasRepliedRef }) =>
|
||||
buildThreadToolContextFromMessageThreadOrReply({ context, hasRepliedRef }),
|
||||
},
|
||||
},
|
||||
slack: {
|
||||
@@ -487,13 +478,9 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
},
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(resolveSignalAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) =>
|
||||
String(entry),
|
||||
),
|
||||
stringifyAllowFrom(resolveSignalAccount({ cfg, accountId }).config.allowFrom ?? []),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean)
|
||||
trimAllowFromEntries(allowFrom)
|
||||
.map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, ""))))
|
||||
.filter(Boolean),
|
||||
resolveDefaultTo: ({ cfg, accountId }) =>
|
||||
|
||||
122
src/channels/draft-stream-controls.test.ts
Normal file
122
src/channels/draft-stream-controls.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,26 @@ export type FinalizableDraftStreamState = {
|
||||
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: {
|
||||
throttleMs: number;
|
||||
isStopped: () => boolean;
|
||||
@@ -64,27 +84,18 @@ export function createFinalizableDraftStreamControlsForState(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export async function takeMessageIdAfterStop<T>(params: {
|
||||
stopForClear: () => Promise<void>;
|
||||
readMessageId: () => T | undefined;
|
||||
clearMessageId: () => void;
|
||||
}): Promise<T | undefined> {
|
||||
export async function takeMessageIdAfterStop<T>(
|
||||
params: StopAndClearMessageIdParams<T>,
|
||||
): Promise<T | undefined> {
|
||||
await params.stopForClear();
|
||||
const messageId = params.readMessageId();
|
||||
params.clearMessageId();
|
||||
return messageId;
|
||||
}
|
||||
|
||||
export async function clearFinalizableDraftMessage<T>(params: {
|
||||
stopForClear: () => Promise<void>;
|
||||
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;
|
||||
}): Promise<void> {
|
||||
export async function clearFinalizableDraftMessage<T>(
|
||||
params: ClearFinalizableDraftMessageParams<T>,
|
||||
): Promise<void> {
|
||||
const messageId = await takeMessageIdAfterStop({
|
||||
stopForClear: params.stopForClear,
|
||||
readMessageId: params.readMessageId,
|
||||
@@ -101,18 +112,7 @@ export async function clearFinalizableDraftMessage<T>(params: {
|
||||
}
|
||||
}
|
||||
|
||||
export function createFinalizableDraftLifecycle<T>(params: {
|
||||
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;
|
||||
}) {
|
||||
export function createFinalizableDraftLifecycle<T>(params: FinalizableDraftLifecycleParams<T>) {
|
||||
const controls = createFinalizableDraftStreamControlsForState({
|
||||
throttleMs: params.throttleMs,
|
||||
state: params.state,
|
||||
|
||||
@@ -36,6 +36,24 @@ vi.mock("../../../discord/monitor/thread-bindings.js", async (importOriginal) =>
|
||||
|
||||
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", () => {
|
||||
it("normalizes bare numeric IDs to channel: prefix", () => {
|
||||
expect(normalizeDiscordOutboundTarget("1470130713209602050")).toEqual({
|
||||
@@ -110,21 +128,7 @@ describe("discordOutbound", () => {
|
||||
});
|
||||
|
||||
it("uses webhook persona delivery for bound thread text replies", async () => {
|
||||
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(),
|
||||
}),
|
||||
});
|
||||
mockBoundThreadManager();
|
||||
|
||||
const result = await discordOutbound.sendText?.({
|
||||
cfg: {},
|
||||
@@ -160,20 +164,7 @@ describe("discordOutbound", () => {
|
||||
});
|
||||
|
||||
it("falls back to bot send for silent delivery on bound threads", async () => {
|
||||
hoisted.getThreadBindingManagerMock.mockReturnValue({
|
||||
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(),
|
||||
}),
|
||||
});
|
||||
mockBoundThreadManager();
|
||||
|
||||
const result = await discordOutbound.sendText?.({
|
||||
cfg: {},
|
||||
@@ -201,20 +192,7 @@ describe("discordOutbound", () => {
|
||||
});
|
||||
|
||||
it("falls back to bot send when webhook send fails", async () => {
|
||||
hoisted.getThreadBindingManagerMock.mockReturnValue({
|
||||
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(),
|
||||
}),
|
||||
});
|
||||
mockBoundThreadManager();
|
||||
hoisted.sendWebhookMessageDiscordMock.mockRejectedValueOnce(new Error("rate limited"));
|
||||
|
||||
const result = await discordOutbound.sendText?.({
|
||||
|
||||
@@ -57,7 +57,7 @@ export type ChannelConfigAdapter<ResolvedAccount> = {
|
||||
resolveAllowFrom?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}) => string[] | undefined;
|
||||
}) => Array<string | number> | undefined;
|
||||
formatAllowFrom?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
|
||||
@@ -41,6 +41,21 @@ const createEnabledController = (
|
||||
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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -245,19 +260,7 @@ describe("createStatusReactionController", () => {
|
||||
});
|
||||
|
||||
it("should only call setReaction when adapter lacks removeReaction", async () => {
|
||||
const calls: { method: string; emoji: string }[] = [];
|
||||
const adapter: StatusReactionAdapter = {
|
||||
setReaction: vi.fn(async (emoji: string) => {
|
||||
calls.push({ method: "set", emoji });
|
||||
}),
|
||||
// No removeReaction
|
||||
};
|
||||
|
||||
const controller = createStatusReactionController({
|
||||
enabled: true,
|
||||
adapter,
|
||||
initialEmoji: "👀",
|
||||
});
|
||||
const { calls, controller } = createSetOnlyController();
|
||||
|
||||
void controller.setQueued();
|
||||
await vi.runAllTimersAsync();
|
||||
@@ -285,18 +288,7 @@ describe("createStatusReactionController", () => {
|
||||
});
|
||||
|
||||
it("should handle clear gracefully when adapter lacks removeReaction", async () => {
|
||||
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: "👀",
|
||||
});
|
||||
const { calls, controller } = createSetOnlyController();
|
||||
|
||||
await controller.clear();
|
||||
|
||||
|
||||
@@ -18,6 +18,36 @@ vi.mock("../send.js", () => ({
|
||||
|
||||
describe("deliverDiscordReply", () => {
|
||||
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(() => {
|
||||
sendMessageDiscordMock.mockClear().mockResolvedValue({
|
||||
@@ -136,22 +166,7 @@ describe("deliverDiscordReply", () => {
|
||||
});
|
||||
|
||||
it("sends bound-session text replies through webhook delivery", async () => {
|
||||
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",
|
||||
label: "codex-refactor",
|
||||
webhookId: "wh_1",
|
||||
webhookToken: "tok_1",
|
||||
introText: "",
|
||||
});
|
||||
const threadBindings = await createBoundThreadBindings({ label: "codex-refactor" });
|
||||
|
||||
await deliverDiscordReply({
|
||||
replies: [{ text: "Hello from subagent" }],
|
||||
@@ -179,21 +194,7 @@ describe("deliverDiscordReply", () => {
|
||||
});
|
||||
|
||||
it("falls back to bot send when webhook delivery fails", async () => {
|
||||
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: "",
|
||||
});
|
||||
const threadBindings = await createBoundThreadBindings();
|
||||
sendWebhookMessageDiscordMock.mockRejectedValueOnce(new Error("rate limited"));
|
||||
|
||||
await deliverDiscordReply({
|
||||
@@ -217,21 +218,7 @@ describe("deliverDiscordReply", () => {
|
||||
});
|
||||
|
||||
it("does not use thread webhook when outbound target is not a bound thread", async () => {
|
||||
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: "",
|
||||
});
|
||||
const threadBindings = await createBoundThreadBindings();
|
||||
|
||||
await deliverDiscordReply({
|
||||
replies: [{ text: "Parent channel delivery" }],
|
||||
|
||||
@@ -99,6 +99,20 @@ const baseParams = () => ({
|
||||
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", () => {
|
||||
it("infers channel types from ids when missing", () => {
|
||||
expect(normalizeSlackChannelType(undefined, "C123")).toBe("channel");
|
||||
@@ -185,12 +199,7 @@ describe("resolveSlackThreadStarter cache", () => {
|
||||
});
|
||||
|
||||
it("returns cached thread starter without refetching within ttl", async () => {
|
||||
const replies = vi.fn(async () => ({
|
||||
messages: [{ text: "root message", user: "U1", ts: "1000.1" }],
|
||||
}));
|
||||
const client = {
|
||||
conversations: { replies },
|
||||
} as unknown as Parameters<typeof resolveSlackThreadStarter>[0]["client"];
|
||||
const { replies, client } = createThreadStarterRepliesClient();
|
||||
|
||||
const first = await resolveSlackThreadStarter({
|
||||
channelId: "C1",
|
||||
@@ -211,12 +220,7 @@ describe("resolveSlackThreadStarter cache", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
|
||||
const replies = vi.fn(async () => ({
|
||||
messages: [{ text: "root message", user: "U1", ts: "1000.1" }],
|
||||
}));
|
||||
const client = {
|
||||
conversations: { replies },
|
||||
} as unknown as Parameters<typeof resolveSlackThreadStarter>[0]["client"];
|
||||
const { replies, client } = createThreadStarterRepliesClient();
|
||||
|
||||
await resolveSlackThreadStarter({
|
||||
channelId: "C1",
|
||||
@@ -234,13 +238,29 @@ describe("resolveSlackThreadStarter cache", () => {
|
||||
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 () => {
|
||||
const replies = vi.fn(async () => ({
|
||||
messages: [{ text: "root message", user: "U1", ts: "1000.1" }],
|
||||
}));
|
||||
const client = {
|
||||
conversations: { replies },
|
||||
} as unknown as Parameters<typeof resolveSlackThreadStarter>[0]["client"];
|
||||
const { replies, client } = createThreadStarterRepliesClient();
|
||||
|
||||
// Cache cap is 2000; add enough distinct keys to force eviction of earliest keys.
|
||||
for (let i = 0; i <= 2000; i += 1) {
|
||||
|
||||
@@ -9,6 +9,13 @@ vi.mock("../../auto-reply/commands-registry.js", () => {
|
||||
const reportLongCommand = { key: "reportlong", nativeName: "reportlong" };
|
||||
const unsafeConfirmCommand = { key: "unsafeconfirm", nativeName: "unsafeconfirm" };
|
||||
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 raw =
|
||||
typeof values === "object" && values !== null
|
||||
@@ -113,31 +120,18 @@ vi.mock("../../auto-reply/commands-registry.js", () => {
|
||||
}) => {
|
||||
if (params.command?.key === "report") {
|
||||
return resolvePeriodMenu(params, [
|
||||
{ value: "day", label: "day" },
|
||||
{ value: "week", label: "week" },
|
||||
{ value: "month", label: "month" },
|
||||
{ value: "quarter", label: "quarter" },
|
||||
{ value: "year", label: "year" },
|
||||
...fullReportPeriodChoices,
|
||||
{ value: "all", label: "all" },
|
||||
]);
|
||||
}
|
||||
if (params.command?.key === "reportlong") {
|
||||
return resolvePeriodMenu(params, [
|
||||
{ value: "day", label: "day" },
|
||||
{ value: "week", label: "week" },
|
||||
{ value: "month", label: "month" },
|
||||
{ value: "quarter", label: "quarter" },
|
||||
{ value: "year", label: "year" },
|
||||
...fullReportPeriodChoices,
|
||||
{ value: "x".repeat(90), label: "long" },
|
||||
]);
|
||||
}
|
||||
if (params.command?.key === "reportcompact") {
|
||||
return resolvePeriodMenu(params, [
|
||||
{ value: "day", label: "day" },
|
||||
{ value: "week", label: "week" },
|
||||
{ value: "month", label: "month" },
|
||||
{ value: "quarter", label: "quarter" },
|
||||
]);
|
||||
return resolvePeriodMenu(params, baseReportPeriodChoices);
|
||||
}
|
||||
if (params.command?.key === "reportexternal") {
|
||||
return {
|
||||
@@ -320,6 +314,12 @@ function expectArgMenuLayout(respond: ReturnType<typeof vi.fn>): {
|
||||
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(
|
||||
handler: (args: unknown) => Promise<void>,
|
||||
params: {
|
||||
@@ -509,9 +509,7 @@ describe("Slack native command argument menus", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(dispatchMock).toHaveBeenCalledTimes(1);
|
||||
const call = dispatchMock.mock.calls[0]?.[0] as { ctx?: { Body?: string } };
|
||||
expect(call.ctx?.Body).toBe("/report month");
|
||||
expectSingleDispatchedSlashBody("/report month");
|
||||
});
|
||||
|
||||
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);
|
||||
const call = dispatchMock.mock.calls[0]?.[0] as { ctx?: { Body?: string } };
|
||||
expect(call.ctx?.Body).toBe("/reportcompact quarter");
|
||||
expectSingleDispatchedSlashBody("/reportcompact quarter");
|
||||
});
|
||||
|
||||
it("shows an external_select menu when choices exceed static_select options max", async () => {
|
||||
|
||||
@@ -3,6 +3,27 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
let collectTelegramUnmentionedGroupIds: typeof import("./audit.js").collectTelegramUnmentionedGroupIds;
|
||||
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", () => {
|
||||
beforeAll(async () => {
|
||||
({ collectTelegramUnmentionedGroupIds, auditTelegramGroupMembership } =
|
||||
@@ -27,42 +48,16 @@ describe("telegram audit", () => {
|
||||
});
|
||||
|
||||
it("audits membership via getChatMember", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
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,
|
||||
});
|
||||
mockGetChatMemberStatus("member");
|
||||
const res = await auditSingleGroup();
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.groups[0]?.chatId).toBe("-1001");
|
||||
expect(res.groups[0]?.status).toBe("member");
|
||||
});
|
||||
|
||||
it("reports bot not in group when status is left", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
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,
|
||||
});
|
||||
mockGetChatMemberStatus("left");
|
||||
const res = await auditSingleGroup();
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.groups[0]?.ok).toBe(false);
|
||||
expect(res.groups[0]?.status).toBe("left");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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();
|
||||
|
||||
@@ -11,39 +11,22 @@ describe("buildTelegramMessageContext audio transcript body", () => {
|
||||
it("uses preflight transcript as BodyForAgent for mention-gated group voice messages", async () => {
|
||||
transcribeFirstAudioMock.mockResolvedValueOnce("hey bot please help");
|
||||
|
||||
const ctx = await buildTelegramMessageContext({
|
||||
primaryCtx: {
|
||||
message: {
|
||||
message_id: 1,
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
|
||||
date: 1700000000,
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
voice: { file_id: "voice-1" },
|
||||
},
|
||||
me: { id: 7, username: "bot" },
|
||||
} as never,
|
||||
const ctx = await buildTelegramMessageContextForTest({
|
||||
message: {
|
||||
message_id: 1,
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
|
||||
date: 1700000000,
|
||||
text: undefined,
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
voice: { file_id: "voice-1" },
|
||||
},
|
||||
allMedia: [{ path: "/tmp/voice.ogg", contentType: "audio/ogg" }],
|
||||
storeAllowFrom: [],
|
||||
options: { forceWasMentioned: true },
|
||||
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: ["\\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,
|
||||
resolveGroupRequireMention: () => true,
|
||||
resolveTelegramGroupConfig: () => ({
|
||||
|
||||
@@ -1,50 +1,17 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildTelegramMessageContext } from "./bot-message-context.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
|
||||
|
||||
describe("buildTelegramMessageContext sender prefix", () => {
|
||||
async function buildCtx(params: {
|
||||
messageId: number;
|
||||
options?: Record<string, unknown>;
|
||||
}): Promise<Awaited<ReturnType<typeof buildTelegramMessageContext>>> {
|
||||
return await buildTelegramMessageContext({
|
||||
primaryCtx: {
|
||||
message: {
|
||||
message_id: params.messageId,
|
||||
chat: { id: -99, type: "supergroup", title: "Dev Chat" },
|
||||
date: 1700000000,
|
||||
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,
|
||||
}),
|
||||
async function buildCtx(params: { messageId: number; options?: Record<string, unknown> }) {
|
||||
return await buildTelegramMessageContextForTest({
|
||||
message: {
|
||||
message_id: params.messageId,
|
||||
chat: { id: -99, type: "supergroup", title: "Dev Chat" },
|
||||
date: 1700000000,
|
||||
text: "hello",
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
},
|
||||
options: params.options,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,15 @@ export const baseTelegramMessageContextConfig = {
|
||||
|
||||
type BuildTelegramMessageContextForTestParams = {
|
||||
message: Record<string, unknown>;
|
||||
allMedia?: Array<Record<string, unknown>>;
|
||||
options?: Record<string, unknown>;
|
||||
cfg?: Record<string, unknown>;
|
||||
resolveGroupActivation?: () => boolean | undefined;
|
||||
resolveGroupRequireMention?: () => boolean;
|
||||
resolveTelegramGroupConfig?: () => {
|
||||
groupConfig?: { requireMention?: boolean };
|
||||
topicConfig?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export async function buildTelegramMessageContextForTest(
|
||||
@@ -27,7 +34,7 @@ export async function buildTelegramMessageContextForTest(
|
||||
},
|
||||
me: { id: 7, username: "bot" },
|
||||
} as never,
|
||||
allMedia: [],
|
||||
allMedia: params.allMedia ?? [],
|
||||
storeAllowFrom: [],
|
||||
options: params.options ?? {},
|
||||
bot: {
|
||||
@@ -36,7 +43,7 @@ export async function buildTelegramMessageContextForTest(
|
||||
setMessageReaction: vi.fn(),
|
||||
},
|
||||
} as never,
|
||||
cfg: baseTelegramMessageContextConfig,
|
||||
cfg: (params.cfg ?? baseTelegramMessageContextConfig) as never,
|
||||
account: { accountId: "default" } as never,
|
||||
historyLimit: 0,
|
||||
groupHistories: new Map(),
|
||||
@@ -46,10 +53,12 @@ export async function buildTelegramMessageContextForTest(
|
||||
ackReactionScope: "off",
|
||||
logger: { info: vi.fn() },
|
||||
resolveGroupActivation: params.resolveGroupActivation ?? (() => undefined),
|
||||
resolveGroupRequireMention: () => false,
|
||||
resolveTelegramGroupConfig: () => ({
|
||||
groupConfig: { requireMention: false },
|
||||
topicConfig: undefined,
|
||||
}),
|
||||
resolveGroupRequireMention: params.resolveGroupRequireMention ?? (() => false),
|
||||
resolveTelegramGroupConfig:
|
||||
params.resolveTelegramGroupConfig ??
|
||||
(() => ({
|
||||
groupConfig: { requireMention: false },
|
||||
topicConfig: undefined,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
} from "./bot/helpers.js";
|
||||
import type { StickerMetadata, TelegramContext } from "./bot/types.js";
|
||||
import { evaluateTelegramGroupBaseAccess } from "./group-access.js";
|
||||
import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js";
|
||||
import {
|
||||
buildTelegramStatusReactionVariants,
|
||||
resolveTelegramAllowedEmojiReactions,
|
||||
@@ -675,13 +676,10 @@ export const buildTelegramMessageContext = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const skillFilter = firstDefined(topicConfig?.skills, groupConfig?.skills);
|
||||
const systemPromptParts = [
|
||||
groupConfig?.systemPrompt?.trim() || null,
|
||||
topicConfig?.systemPrompt?.trim() || null,
|
||||
].filter((entry): entry is string => Boolean(entry));
|
||||
const groupSystemPrompt =
|
||||
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
|
||||
const { skillFilter, groupSystemPrompt } = resolveTelegramGroupPromptSettings({
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
});
|
||||
const commandBody = normalizeCommandBody(rawBody, { botUsername });
|
||||
const inboundHistory =
|
||||
isGroup && historyKey && historyLimit > 0
|
||||
|
||||
@@ -36,6 +36,20 @@ vi.mock("./bot/delivery.js", () => ({
|
||||
}));
|
||||
|
||||
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(() => {
|
||||
listSkillCommandsForAgents.mockClear();
|
||||
listSkillCommandsForAgents.mockReturnValue([]);
|
||||
@@ -166,14 +180,7 @@ describe("registerTelegramNativeCommands", () => {
|
||||
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(setMyCommands).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const registeredCommands = setMyCommands.mock.calls[0]?.[0] as Array<{
|
||||
command: string;
|
||||
description: string;
|
||||
}>;
|
||||
const registeredCommands = await waitForRegisteredCommands(setMyCommands);
|
||||
expect(registeredCommands.some((entry) => entry.command === "export_session")).toBe(true);
|
||||
expect(registeredCommands.some((entry) => entry.command === "export-session")).toBe(false);
|
||||
|
||||
@@ -207,14 +214,7 @@ describe("registerTelegramNativeCommands", () => {
|
||||
} as TelegramAccountConfig,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(setMyCommands).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const registeredCommands = setMyCommands.mock.calls[0]?.[0] as Array<{
|
||||
command: string;
|
||||
description: string;
|
||||
}>;
|
||||
const registeredCommands = await waitForRegisteredCommands(setMyCommands);
|
||||
|
||||
expect(registeredCommands.length).toBeGreaterThan(0);
|
||||
for (const entry of registeredCommands) {
|
||||
|
||||
@@ -41,7 +41,7 @@ import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||
import { resolveThreadSessionKeys } from "../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { withTelegramApiErrorLogging } from "./api-logging.js";
|
||||
import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js";
|
||||
import { isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js";
|
||||
import {
|
||||
buildCappedTelegramMenuCommands,
|
||||
buildPluginTelegramMenuCommands,
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
evaluateTelegramGroupBaseAccess,
|
||||
evaluateTelegramGroupPolicyAccess,
|
||||
} from "./group-access.js";
|
||||
import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js";
|
||||
import { buildInlineKeyboard } from "./send.js";
|
||||
|
||||
const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again.";
|
||||
@@ -552,13 +553,10 @@ export const registerTelegramNativeCommands = ({
|
||||
})
|
||||
: null;
|
||||
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
|
||||
const skillFilter = firstDefined(topicConfig?.skills, groupConfig?.skills);
|
||||
const systemPromptParts = [
|
||||
groupConfig?.systemPrompt?.trim() || null,
|
||||
topicConfig?.systemPrompt?.trim() || null,
|
||||
].filter((entry): entry is string => Boolean(entry));
|
||||
const groupSystemPrompt =
|
||||
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
|
||||
const { skillFilter, groupSystemPrompt } = resolveTelegramGroupPromptSettings({
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
});
|
||||
const conversationLabel = isGroup
|
||||
? msg.chat.title
|
||||
? `${msg.chat.title} id:${chatId}`
|
||||
|
||||
@@ -17,6 +17,11 @@ async function createMessageHandlerAndReplySpy() {
|
||||
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", () => {
|
||||
const _INBOUND_MEDIA_TEST_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 20_000;
|
||||
it(
|
||||
@@ -40,8 +45,7 @@ describe("telegram inbound media", () => {
|
||||
getFile: async () => ({ file_path: "unused" }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
const payload = expectSingleReplyPayload(replySpy);
|
||||
expect(payload.Body).toContain("Meet here");
|
||||
expect(payload.Body).toContain("48.858844");
|
||||
expect(payload.LocationLat).toBe(48.858844);
|
||||
@@ -72,8 +76,7 @@ describe("telegram inbound media", () => {
|
||||
getFile: async () => ({ file_path: "unused" }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
const payload = expectSingleReplyPayload(replySpy);
|
||||
expect(payload.Body).toContain("Eiffel Tower");
|
||||
expect(payload.LocationName).toBe("Eiffel Tower");
|
||||
expect(payload.LocationAddress).toBe("Champ de Mars, Paris");
|
||||
|
||||
@@ -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", () => {
|
||||
beforeEach(() => {
|
||||
loadWebMedia.mockReset();
|
||||
@@ -178,12 +188,7 @@ describe("deliverReplies", () => {
|
||||
});
|
||||
|
||||
it("includes message_thread_id for DM topics", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 4,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = createBot({ sendMessage });
|
||||
const { runtime, sendMessage, bot } = createSendMessageHarness();
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ text: "Hello" }],
|
||||
@@ -202,12 +207,7 @@ describe("deliverReplies", () => {
|
||||
});
|
||||
|
||||
it("does not include link_preview_options when linkPreview is true", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 4,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = createBot({ sendMessage });
|
||||
const { runtime, sendMessage, bot } = createSendMessageHarness();
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ text: "Check https://example.com" }],
|
||||
|
||||
19
src/telegram/group-config-helpers.ts
Normal file
19
src/telegram/group-config-helpers.ts
Normal 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 };
|
||||
}
|
||||
@@ -2,9 +2,44 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveTelegramReactionLevel } from "./reaction-level.js";
|
||||
|
||||
type ReactionResolution = ReturnType<typeof resolveTelegramReactionLevel>;
|
||||
|
||||
describe("resolveTelegramReactionLevel", () => {
|
||||
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(() => {
|
||||
process.env.TELEGRAM_BOT_TOKEN = "test-token";
|
||||
});
|
||||
@@ -23,10 +58,7 @@ describe("resolveTelegramReactionLevel", () => {
|
||||
};
|
||||
|
||||
const result = resolveTelegramReactionLevel({ cfg });
|
||||
expect(result.level).toBe("minimal");
|
||||
expect(result.ackEnabled).toBe(false);
|
||||
expect(result.agentReactionsEnabled).toBe(true);
|
||||
expect(result.agentReactionGuidance).toBe("minimal");
|
||||
expectMinimalFlags(result);
|
||||
});
|
||||
|
||||
it("returns off level with no reactions enabled", () => {
|
||||
@@ -35,10 +67,11 @@ describe("resolveTelegramReactionLevel", () => {
|
||||
};
|
||||
|
||||
const result = resolveTelegramReactionLevel({ cfg });
|
||||
expect(result.level).toBe("off");
|
||||
expect(result.ackEnabled).toBe(false);
|
||||
expect(result.agentReactionsEnabled).toBe(false);
|
||||
expect(result.agentReactionGuidance).toBeUndefined();
|
||||
expectReactionFlags(result, {
|
||||
level: "off",
|
||||
ackEnabled: false,
|
||||
agentReactionsEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns ack level with only ackEnabled", () => {
|
||||
@@ -47,10 +80,11 @@ describe("resolveTelegramReactionLevel", () => {
|
||||
};
|
||||
|
||||
const result = resolveTelegramReactionLevel({ cfg });
|
||||
expect(result.level).toBe("ack");
|
||||
expect(result.ackEnabled).toBe(true);
|
||||
expect(result.agentReactionsEnabled).toBe(false);
|
||||
expect(result.agentReactionGuidance).toBeUndefined();
|
||||
expectReactionFlags(result, {
|
||||
level: "ack",
|
||||
ackEnabled: true,
|
||||
agentReactionsEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns minimal level with agent reactions enabled and minimal guidance", () => {
|
||||
@@ -59,10 +93,7 @@ describe("resolveTelegramReactionLevel", () => {
|
||||
};
|
||||
|
||||
const result = resolveTelegramReactionLevel({ cfg });
|
||||
expect(result.level).toBe("minimal");
|
||||
expect(result.ackEnabled).toBe(false);
|
||||
expect(result.agentReactionsEnabled).toBe(true);
|
||||
expect(result.agentReactionGuidance).toBe("minimal");
|
||||
expectMinimalFlags(result);
|
||||
});
|
||||
|
||||
it("returns extensive level with agent reactions enabled and extensive guidance", () => {
|
||||
@@ -71,10 +102,7 @@ describe("resolveTelegramReactionLevel", () => {
|
||||
};
|
||||
|
||||
const result = resolveTelegramReactionLevel({ cfg });
|
||||
expect(result.level).toBe("extensive");
|
||||
expect(result.ackEnabled).toBe(false);
|
||||
expect(result.agentReactionsEnabled).toBe(true);
|
||||
expect(result.agentReactionGuidance).toBe("extensive");
|
||||
expectExtensiveFlags(result);
|
||||
});
|
||||
|
||||
it("resolves reaction level from a specific account", () => {
|
||||
@@ -90,10 +118,7 @@ describe("resolveTelegramReactionLevel", () => {
|
||||
};
|
||||
|
||||
const result = resolveTelegramReactionLevel({ cfg, accountId: "work" });
|
||||
expect(result.level).toBe("extensive");
|
||||
expect(result.ackEnabled).toBe(false);
|
||||
expect(result.agentReactionsEnabled).toBe(true);
|
||||
expect(result.agentReactionGuidance).toBe("extensive");
|
||||
expectExtensiveFlags(result);
|
||||
});
|
||||
|
||||
it("falls back to global level when account has no reactionLevel", () => {
|
||||
@@ -109,8 +134,6 @@ describe("resolveTelegramReactionLevel", () => {
|
||||
};
|
||||
|
||||
const result = resolveTelegramReactionLevel({ cfg, accountId: "work" });
|
||||
expect(result.level).toBe("minimal");
|
||||
expect(result.agentReactionsEnabled).toBe(true);
|
||||
expect(result.agentReactionGuidance).toBe("minimal");
|
||||
expectMinimalFlags(result);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user