mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 17:14:33 +00:00
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 {
|
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 }) =>
|
||||||
|
|||||||
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;
|
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,
|
||||||
|
|||||||
@@ -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?.({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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" }],
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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: () => ({
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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" }],
|
||||||
|
|||||||
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 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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user