mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-08 21:52:57 +00:00
* fix(feishu): fallback when accepted turns send no visible reply * fix(feishu): cover no-visible-reply fallback gaps * fix(feishu): mark media replies visible * fix(feishu): honor suppressed delivery fallback * test(auto-reply): trim fallback test churn * fix(feishu): gate empty fallback eligibility * test(auto-reply): expect fallback metadata after denied dispatch * fix(feishu): fallback after failed visible final sends * test(feishu): keep reply dispatcher mock shape aligned * fix(auto-reply): respect silent policy for no-visible fallback * fix(feishu): wait for streaming close before fallback * fix(feishu): clear silent skip before later finals * fix(feishu): preserve visible state across keepalives * test(feishu): align lifecycle dispatcher mocks * fix(feishu): require accepted streaming content for fallback --------- Co-authored-by: ArthurNie <264332276+ArthurNie@users.noreply.github.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
3817 lines
115 KiB
TypeScript
3817 lines
115 KiB
TypeScript
import type * as ConversationRuntime from "openclaw/plugin-sdk/conversation-runtime";
|
|
import { createRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
|
|
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
|
|
import { resolveGroupSessionKey } from "openclaw/plugin-sdk/session-store-runtime";
|
|
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js";
|
|
import { parseMergeForwardContent } from "./bot-content.js";
|
|
import type { FeishuMessageEvent } from "./bot.js";
|
|
import { handleFeishuMessage } from "./bot.js";
|
|
import { createFeishuMessageReceiveHandler } from "./monitor.message-handler.js";
|
|
import { setFeishuRuntime } from "./runtime.js";
|
|
|
|
type ConfiguredBindingRoute = ReturnType<typeof ConversationRuntime.resolveConfiguredBindingRoute>;
|
|
type BoundConversation = ReturnType<
|
|
ReturnType<typeof ConversationRuntime.getSessionBindingService>["resolveByConversation"]
|
|
>;
|
|
type BindingReadiness = Awaited<
|
|
ReturnType<typeof ConversationRuntime.ensureConfiguredBindingRouteReady>
|
|
>;
|
|
type ReplyDispatcher = Parameters<
|
|
PluginRuntime["channel"]["reply"]["withReplyDispatcher"]
|
|
>[0]["dispatcher"];
|
|
type DeepPartial<T> = {
|
|
[K in keyof T]?: T[K] extends (...args: never[]) => unknown
|
|
? T[K]
|
|
: T[K] extends ReadonlyArray<unknown>
|
|
? T[K]
|
|
: T[K] extends object
|
|
? DeepPartial<T[K]>
|
|
: T[K];
|
|
};
|
|
|
|
function createReplyDispatcher(): ReplyDispatcher {
|
|
return {
|
|
sendToolResult: vi.fn(),
|
|
sendBlockReply: vi.fn(),
|
|
sendFinalReply: vi.fn(),
|
|
waitForIdle: vi.fn(),
|
|
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
|
getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
|
markComplete: vi.fn(),
|
|
};
|
|
}
|
|
|
|
function createConfiguredFeishuRoute(): NonNullable<ConfiguredBindingRoute> {
|
|
return {
|
|
bindingResolution: {
|
|
conversation: {
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
conversationId: "ou_sender_1",
|
|
},
|
|
compiledBinding: {
|
|
channel: "feishu",
|
|
accountPattern: "default",
|
|
binding: {
|
|
type: "acp",
|
|
agentId: "codex",
|
|
match: {
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
peer: { kind: "direct", id: "ou_sender_1" },
|
|
},
|
|
},
|
|
bindingConversationId: "ou_sender_1",
|
|
target: {
|
|
conversationId: "ou_sender_1",
|
|
},
|
|
agentId: "codex",
|
|
provider: {
|
|
compileConfiguredBinding: () => ({ conversationId: "ou_sender_1" }),
|
|
matchInboundConversation: () => ({ conversationId: "ou_sender_1" }),
|
|
},
|
|
targetFactory: {
|
|
driverId: "acp",
|
|
materialize: () => ({
|
|
record: {
|
|
bindingId: "config:acp:feishu:default:ou_sender_1",
|
|
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
conversationId: "ou_sender_1",
|
|
},
|
|
status: "active",
|
|
boundAt: 0,
|
|
metadata: { source: "config" },
|
|
},
|
|
statefulTarget: {
|
|
kind: "stateful",
|
|
driverId: "acp",
|
|
sessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
|
agentId: "codex",
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
match: {
|
|
conversationId: "ou_sender_1",
|
|
},
|
|
record: {
|
|
bindingId: "config:acp:feishu:default:ou_sender_1",
|
|
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
conversationId: "ou_sender_1",
|
|
},
|
|
status: "active",
|
|
boundAt: 0,
|
|
metadata: { source: "config" },
|
|
},
|
|
statefulTarget: {
|
|
kind: "stateful",
|
|
driverId: "acp",
|
|
sessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
|
agentId: "codex",
|
|
},
|
|
},
|
|
route: {
|
|
agentId: "codex",
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
sessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
|
mainSessionKey: "agent:codex:main",
|
|
lastRoutePolicy: "session",
|
|
matchedBy: "binding.channel",
|
|
} as ResolvedAgentRoute,
|
|
};
|
|
}
|
|
|
|
function createConfiguredBindingReadiness(ok: boolean, error?: string): BindingReadiness {
|
|
return (ok ? { ok: true } : { ok: false, error: error ?? "unknown error" }) as BindingReadiness;
|
|
}
|
|
|
|
function createBoundConversation(): NonNullable<BoundConversation> {
|
|
return {
|
|
bindingId: "default:oc_group_chat:topic:om_topic_root",
|
|
targetSessionKey: "agent:codex:acp:binding:feishu:default:feedface",
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
conversationId: "oc_group_chat:topic:om_topic_root",
|
|
parentConversationId: "oc_group_chat",
|
|
},
|
|
status: "active",
|
|
boundAt: 0,
|
|
};
|
|
}
|
|
|
|
function buildDefaultResolveRoute(): ResolvedAgentRoute {
|
|
return {
|
|
agentId: "main",
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
sessionKey: "agent:main:feishu:dm:ou-attacker",
|
|
mainSessionKey: "agent:main:main",
|
|
lastRoutePolicy: "session",
|
|
matchedBy: "default",
|
|
};
|
|
}
|
|
function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): PluginRuntime {
|
|
return {
|
|
channel: {
|
|
routing: {
|
|
resolveAgentRoute: resolveAgentRouteMock,
|
|
},
|
|
session: {
|
|
readSessionUpdatedAt: readSessionUpdatedAtMock,
|
|
resolveStorePath: resolveStorePathMock,
|
|
recordInboundSession: vi.fn(async () => undefined),
|
|
},
|
|
reply: {
|
|
resolveEnvelopeFormatOptions:
|
|
resolveEnvelopeFormatOptionsMock as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
|
|
formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
|
|
finalizeInboundContext: finalizeInboundContextMock as never,
|
|
dispatchReplyFromConfig: vi.fn().mockResolvedValue({
|
|
queuedFinal: false,
|
|
counts: { final: 1 },
|
|
}),
|
|
withReplyDispatcher: withReplyDispatcherMock as never,
|
|
},
|
|
commands: {
|
|
shouldComputeCommandAuthorized: vi.fn(() => false),
|
|
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
|
|
},
|
|
pairing: {
|
|
readAllowFromStore: vi.fn().mockResolvedValue(["ou_sender_1"]),
|
|
upsertPairingRequest: vi.fn(),
|
|
buildPairingReply: vi.fn(),
|
|
},
|
|
inbound: {
|
|
run: vi.fn(async (params) => {
|
|
const input = await params.adapter.ingest(params.raw);
|
|
const turn = await params.adapter.resolveTurn(input, {
|
|
kind: "message",
|
|
canStartAgentTurn: true,
|
|
});
|
|
await turn.recordInboundSession({
|
|
storePath: turn.storePath,
|
|
sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey,
|
|
ctx: turn.ctxPayload,
|
|
groupResolution: turn.record?.groupResolution,
|
|
createIfMissing: turn.record?.createIfMissing,
|
|
updateLastRoute: turn.record?.updateLastRoute,
|
|
onRecordError: turn.record?.onRecordError ?? (() => undefined),
|
|
});
|
|
return {
|
|
dispatched: true,
|
|
dispatchResult: await turn.runDispatch(),
|
|
};
|
|
}),
|
|
},
|
|
...overrides.channel,
|
|
},
|
|
...(overrides.system ? { system: overrides.system as PluginRuntime["system"] } : {}),
|
|
...(overrides.media ? { media: overrides.media as PluginRuntime["media"] } : {}),
|
|
} as unknown as PluginRuntime;
|
|
}
|
|
|
|
const resolveAgentRouteMock: PluginRuntime["channel"]["routing"]["resolveAgentRoute"] = (params) =>
|
|
mockResolveAgentRoute(params);
|
|
const readSessionUpdatedAtMock: PluginRuntime["channel"]["session"]["readSessionUpdatedAt"] = (
|
|
params,
|
|
) => mockReadSessionUpdatedAt(params);
|
|
const resolveStorePathMock: PluginRuntime["channel"]["session"]["resolveStorePath"] = (params) =>
|
|
mockResolveStorePath(params);
|
|
const resolveEnvelopeFormatOptionsMock = () => ({});
|
|
const finalizeInboundContextMock = (ctx: Record<string, unknown>) => ctx;
|
|
const withReplyDispatcherMock = async ({
|
|
run,
|
|
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => await run();
|
|
|
|
function mockCallArg<T>(
|
|
mock: { mock: { calls: unknown[][] } },
|
|
callIndex: number,
|
|
argIndex: number,
|
|
_type?: (value: unknown) => value is T,
|
|
): T {
|
|
const call = mock.mock.calls[callIndex];
|
|
if (!call) {
|
|
throw new Error(`Expected mock call at index ${callIndex}`);
|
|
}
|
|
return call[argIndex] as T;
|
|
}
|
|
|
|
function lastMockCallArg<T>(
|
|
mock: { mock: { calls: unknown[][] } },
|
|
argIndex = 0,
|
|
_type?: (value: unknown) => value is T,
|
|
): T | undefined {
|
|
return mock.mock.calls.at(-1)?.[argIndex] as T | undefined;
|
|
}
|
|
|
|
type FeishuRoutePeer = { id: string; kind: "direct" | "group" };
|
|
|
|
function expectResolvedRouteCall(
|
|
callIndex: number,
|
|
peer: FeishuRoutePeer,
|
|
parentPeer?: FeishuRoutePeer | null,
|
|
): void {
|
|
const routeRequest = mockCallArg<{
|
|
parentPeer?: FeishuRoutePeer | null;
|
|
peer?: FeishuRoutePeer;
|
|
}>(mockResolveAgentRoute, callIndex, 0);
|
|
expect(routeRequest.peer).toEqual(peer);
|
|
if (arguments.length >= 3) {
|
|
expect(routeRequest.parentPeer).toEqual(parentPeer);
|
|
}
|
|
}
|
|
|
|
const {
|
|
mockCreateFeishuReplyDispatcher,
|
|
mockSendMessageFeishu,
|
|
mockGetMessageFeishu,
|
|
mockListFeishuThreadMessages,
|
|
mockDownloadMessageResourceFeishu,
|
|
mockCreateFeishuClient,
|
|
mockResolveAgentRoute,
|
|
mockReadSessionUpdatedAt,
|
|
mockResolveStorePath,
|
|
mockResolveConfiguredBindingRoute,
|
|
mockEnsureConfiguredBindingRouteReady,
|
|
mockResolveBoundConversation,
|
|
mockTouchBinding,
|
|
mockResolveFeishuReasoningPreviewEnabled,
|
|
mockTranscribeFirstAudio,
|
|
mockMaybeCreateDynamicAgent,
|
|
} = vi.hoisted(() => ({
|
|
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
|
|
dispatcher: createReplyDispatcher(),
|
|
replyOptions: {},
|
|
markDispatchIdle: vi.fn(),
|
|
ensureNoVisibleReplyFallback: vi.fn(),
|
|
})),
|
|
mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }),
|
|
mockGetMessageFeishu: vi.fn().mockResolvedValue(null),
|
|
mockListFeishuThreadMessages: vi.fn().mockResolvedValue([]),
|
|
mockDownloadMessageResourceFeishu: vi.fn().mockResolvedValue({
|
|
buffer: Buffer.from("video"),
|
|
contentType: "video/mp4",
|
|
fileName: "clip.mp4",
|
|
}),
|
|
mockCreateFeishuClient: vi.fn(),
|
|
mockResolveAgentRoute: vi.fn((_params?: unknown) => buildDefaultResolveRoute()),
|
|
mockReadSessionUpdatedAt: vi.fn((_params?: unknown): number | undefined => undefined),
|
|
mockResolveStorePath: vi.fn((_params?: unknown) => "/tmp/feishu-sessions.json"),
|
|
mockResolveConfiguredBindingRoute: vi.fn(
|
|
({
|
|
route,
|
|
}: {
|
|
route: NonNullable<ConfiguredBindingRoute>["route"];
|
|
}): ConfiguredBindingRoute => ({
|
|
bindingResolution: null,
|
|
route,
|
|
}),
|
|
),
|
|
mockEnsureConfiguredBindingRouteReady: vi.fn(
|
|
async (_params?: unknown): Promise<BindingReadiness> => ({ ok: true }),
|
|
),
|
|
mockResolveBoundConversation: vi.fn((_ref?: unknown) => null as BoundConversation),
|
|
mockTouchBinding: vi.fn(),
|
|
mockResolveFeishuReasoningPreviewEnabled: vi.fn(() => false),
|
|
mockTranscribeFirstAudio: vi.fn(),
|
|
mockMaybeCreateDynamicAgent: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("./reply-dispatcher.js", () => ({
|
|
createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher,
|
|
}));
|
|
|
|
vi.mock("./reasoning-preview.js", () => ({
|
|
resolveFeishuReasoningPreviewEnabled: mockResolveFeishuReasoningPreviewEnabled,
|
|
}));
|
|
|
|
vi.mock("./send.js", () => ({
|
|
sendMessageFeishu: mockSendMessageFeishu,
|
|
getMessageFeishu: mockGetMessageFeishu,
|
|
listFeishuThreadMessages: mockListFeishuThreadMessages,
|
|
}));
|
|
|
|
vi.mock("./media.js", () => ({
|
|
downloadMessageResourceFeishu: mockDownloadMessageResourceFeishu,
|
|
saveMessageResourceFeishu: mockDownloadMessageResourceFeishu,
|
|
}));
|
|
|
|
vi.mock("./audio-preflight.runtime.js", () => ({
|
|
transcribeFirstAudio: mockTranscribeFirstAudio,
|
|
}));
|
|
|
|
vi.mock("./client.js", () => ({
|
|
createFeishuClient: mockCreateFeishuClient,
|
|
}));
|
|
|
|
vi.mock("./dynamic-agent.js", () => ({
|
|
maybeCreateDynamicAgent: mockMaybeCreateDynamicAgent,
|
|
}));
|
|
|
|
vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
|
|
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/conversation-runtime")>(
|
|
"openclaw/plugin-sdk/conversation-runtime",
|
|
);
|
|
return {
|
|
...actual,
|
|
resolveConfiguredBindingRoute: (params: unknown) =>
|
|
mockResolveConfiguredBindingRoute(params as { route: ResolvedAgentRoute }),
|
|
resolveRuntimeConversationBindingRoute: (params: {
|
|
route: ResolvedAgentRoute;
|
|
conversation: Parameters<
|
|
ReturnType<typeof actual.getSessionBindingService>["resolveByConversation"]
|
|
>[0];
|
|
}) => {
|
|
const bindingRecord = mockResolveBoundConversation(params.conversation);
|
|
const boundSessionKey = bindingRecord?.targetSessionKey?.trim();
|
|
if (!bindingRecord || !boundSessionKey) {
|
|
return { bindingRecord: null, route: params.route };
|
|
}
|
|
mockTouchBinding(bindingRecord.bindingId);
|
|
return {
|
|
bindingRecord,
|
|
boundSessionKey,
|
|
boundAgentId: params.route.agentId,
|
|
route: {
|
|
...params.route,
|
|
sessionKey: boundSessionKey,
|
|
lastRoutePolicy: boundSessionKey === params.route.mainSessionKey ? "main" : "session",
|
|
matchedBy: "binding.channel",
|
|
},
|
|
};
|
|
},
|
|
ensureConfiguredBindingRouteReady: (params: unknown) =>
|
|
mockEnsureConfiguredBindingRouteReady(params),
|
|
getSessionBindingService: () => ({
|
|
resolveByConversation: mockResolveBoundConversation,
|
|
touch: mockTouchBinding,
|
|
}),
|
|
};
|
|
});
|
|
|
|
afterAll(() => {
|
|
vi.doUnmock("./reply-dispatcher.js");
|
|
vi.doUnmock("./reasoning-preview.js");
|
|
vi.doUnmock("./send.js");
|
|
vi.doUnmock("./media.js");
|
|
vi.doUnmock("./audio-preflight.runtime.js");
|
|
vi.doUnmock("./client.js");
|
|
vi.doUnmock("openclaw/plugin-sdk/conversation-runtime");
|
|
vi.resetModules();
|
|
});
|
|
|
|
async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessageEvent }) {
|
|
const runtime = createRuntimeEnv();
|
|
const feishuConfig = params.cfg.channels?.feishu;
|
|
const cfg =
|
|
feishuConfig?.dmPolicy === "open" && feishuConfig.allowFrom === undefined
|
|
? ({
|
|
...params.cfg,
|
|
channels: {
|
|
...params.cfg.channels,
|
|
feishu: {
|
|
...feishuConfig,
|
|
allowFrom: ["*"],
|
|
},
|
|
},
|
|
} as ClawdbotConfig)
|
|
: params.cfg;
|
|
await handleFeishuMessage({
|
|
cfg,
|
|
event: params.event,
|
|
runtime,
|
|
});
|
|
return runtime;
|
|
}
|
|
|
|
describe("handleFeishuMessage ACP routing", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockResolveConfiguredBindingRoute.mockReset().mockImplementation(
|
|
({
|
|
route,
|
|
}: {
|
|
route: NonNullable<ConfiguredBindingRoute>["route"];
|
|
}): ConfiguredBindingRoute => ({
|
|
bindingResolution: null,
|
|
route,
|
|
}),
|
|
);
|
|
mockEnsureConfiguredBindingRouteReady.mockReset().mockResolvedValue({ ok: true });
|
|
mockResolveBoundConversation.mockReset().mockReturnValue(null);
|
|
mockTouchBinding.mockReset();
|
|
mockResolveFeishuReasoningPreviewEnabled.mockReset().mockReturnValue(false);
|
|
mockTranscribeFirstAudio.mockReset().mockResolvedValue(undefined);
|
|
mockMaybeCreateDynamicAgent.mockReset().mockResolvedValue({ created: false });
|
|
mockResolveAgentRoute.mockReset().mockReturnValue({
|
|
...buildDefaultResolveRoute(),
|
|
sessionKey: "agent:main:feishu:direct:ou_sender_1",
|
|
});
|
|
mockSendMessageFeishu
|
|
.mockReset()
|
|
.mockResolvedValue({ messageId: "reply-msg", chatId: "oc_dm" });
|
|
mockCreateFeishuReplyDispatcher.mockReset().mockReturnValue({
|
|
dispatcher: createReplyDispatcher(),
|
|
replyOptions: {},
|
|
markDispatchIdle: vi.fn(),
|
|
ensureNoVisibleReplyFallback: vi.fn(),
|
|
});
|
|
|
|
setFeishuRuntime(createFeishuBotRuntime());
|
|
});
|
|
|
|
it("ensures configured ACP routes for Feishu DMs", async () => {
|
|
mockResolveConfiguredBindingRoute.mockReturnValue(createConfiguredFeishuRoute());
|
|
|
|
await dispatchMessage({
|
|
cfg: {
|
|
session: { mainKey: "main", scope: "per-sender" },
|
|
channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
|
|
},
|
|
event: {
|
|
sender: { sender_id: { open_id: "ou_sender_1" } },
|
|
message: {
|
|
message_id: "msg-1",
|
|
chat_id: "oc_dm",
|
|
chat_type: "p2p",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello" }),
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(mockResolveConfiguredBindingRoute).toHaveBeenCalledTimes(1);
|
|
expect(mockEnsureConfiguredBindingRouteReady).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("surfaces configured ACP initialization failures to the Feishu conversation", async () => {
|
|
mockResolveConfiguredBindingRoute.mockReturnValue(createConfiguredFeishuRoute());
|
|
mockEnsureConfiguredBindingRouteReady.mockResolvedValue(
|
|
createConfiguredBindingReadiness(false, "runtime unavailable"),
|
|
);
|
|
|
|
await dispatchMessage({
|
|
cfg: {
|
|
session: { mainKey: "main", scope: "per-sender" },
|
|
channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
|
|
},
|
|
event: {
|
|
sender: { sender_id: { open_id: "ou_sender_1" } },
|
|
message: {
|
|
message_id: "msg-2",
|
|
chat_id: "oc_dm",
|
|
chat_type: "p2p",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello" }),
|
|
},
|
|
},
|
|
});
|
|
|
|
const message = mockCallArg<{ text?: string; to?: string }>(mockSendMessageFeishu, 0, 0);
|
|
expect(message.to).toBe("chat:oc_dm");
|
|
expect(message.text).toContain("runtime unavailable");
|
|
});
|
|
|
|
it("routes Feishu topic messages through active bound conversations", async () => {
|
|
mockResolveBoundConversation.mockReturnValue(createBoundConversation());
|
|
|
|
await dispatchMessage({
|
|
cfg: {
|
|
session: { mainKey: "main", scope: "per-sender" },
|
|
channels: {
|
|
feishu: {
|
|
enabled: true,
|
|
allowFrom: ["ou_sender_1"],
|
|
groups: {
|
|
oc_group_chat: {
|
|
allow: true,
|
|
requireMention: false,
|
|
groupSessionScope: "group_topic",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
event: {
|
|
sender: { sender_id: { open_id: "ou_sender_1" } },
|
|
message: {
|
|
message_id: "msg-3",
|
|
chat_id: "oc_group_chat",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
root_id: "om_topic_root",
|
|
content: JSON.stringify({ text: "hello topic" }),
|
|
},
|
|
},
|
|
});
|
|
|
|
const conversationRef = mockCallArg<{ channel?: string; conversationId?: string }>(
|
|
mockResolveBoundConversation,
|
|
0,
|
|
0,
|
|
);
|
|
expect(conversationRef.channel).toBe("feishu");
|
|
expect(conversationRef.conversationId).toBe("oc_group_chat:topic:om_topic_root");
|
|
expect(mockTouchBinding).toHaveBeenCalledWith("default:oc_group_chat:topic:om_topic_root");
|
|
});
|
|
|
|
it("records Feishu DM last-route updates on the resolved session", async () => {
|
|
const runtime = createFeishuBotRuntime();
|
|
const recordInboundSession = vi.fn(async () => undefined);
|
|
runtime.channel.session.recordInboundSession = recordInboundSession;
|
|
mockResolveAgentRoute.mockReturnValue({
|
|
agentId: "main",
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
sessionKey: "agent:main:main",
|
|
mainSessionKey: "agent:main:main",
|
|
lastRoutePolicy: "main",
|
|
matchedBy: "default",
|
|
});
|
|
setFeishuRuntime(runtime);
|
|
|
|
await dispatchMessage({
|
|
cfg: {
|
|
session: { mainKey: "main", scope: "per-sender" },
|
|
channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
|
|
},
|
|
event: {
|
|
sender: { sender_id: { open_id: "ou_sender_1" } },
|
|
message: {
|
|
message_id: "msg-dm-last-route",
|
|
chat_id: "oc_dm",
|
|
chat_type: "p2p",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello" }),
|
|
},
|
|
},
|
|
});
|
|
|
|
const recordParams = lastMockCallArg<{
|
|
sessionKey?: string;
|
|
updateLastRoute?: {
|
|
accountId?: string;
|
|
channel?: string;
|
|
sessionKey?: string;
|
|
to?: string;
|
|
};
|
|
}>(recordInboundSession);
|
|
expect(recordParams?.sessionKey).toBe("agent:main:main");
|
|
expect(recordParams?.updateLastRoute).toMatchObject({
|
|
sessionKey: "agent:main:main",
|
|
channel: "feishu",
|
|
to: "user:ou_sender_1",
|
|
accountId: "default",
|
|
});
|
|
});
|
|
|
|
it("pins shared Feishu DM last-route updates to the configured owner", async () => {
|
|
const runtime = createFeishuBotRuntime();
|
|
const recordInboundSession = vi.fn(async () => undefined);
|
|
runtime.channel.session.recordInboundSession = recordInboundSession;
|
|
runtime.channel.pairing.readAllowFromStore = vi.fn().mockResolvedValue(["ou_sender_2"]);
|
|
mockResolveAgentRoute.mockReturnValue({
|
|
agentId: "main",
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
sessionKey: "agent:main:main",
|
|
mainSessionKey: "agent:main:main",
|
|
lastRoutePolicy: "main",
|
|
matchedBy: "default",
|
|
});
|
|
setFeishuRuntime(runtime);
|
|
|
|
await dispatchMessage({
|
|
cfg: {
|
|
session: { mainKey: "main", scope: "per-sender" },
|
|
channels: { feishu: { enabled: true, allowFrom: ["ou_owner"], dmPolicy: "pairing" } },
|
|
},
|
|
event: {
|
|
sender: { sender_id: { open_id: "ou_sender_2" } },
|
|
message: {
|
|
message_id: "msg-dm-last-route-secondary",
|
|
chat_id: "oc_dm",
|
|
chat_type: "p2p",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello" }),
|
|
},
|
|
},
|
|
});
|
|
|
|
const recordParams = lastMockCallArg<{
|
|
updateLastRoute?: {
|
|
mainDmOwnerPin?: {
|
|
ownerRecipient?: string;
|
|
senderRecipient?: string;
|
|
onSkip?: unknown;
|
|
};
|
|
};
|
|
}>(recordInboundSession);
|
|
expect(recordParams?.updateLastRoute?.mainDmOwnerPin).toMatchObject({
|
|
ownerRecipient: "user:ou_owner",
|
|
senderRecipient: "user:ou_sender_2",
|
|
});
|
|
expect(typeof recordParams?.updateLastRoute?.mainDmOwnerPin?.onSkip).toBe("function");
|
|
});
|
|
|
|
it("matches Feishu DM owner pins against user_id allowlist entries", async () => {
|
|
const runtime = createFeishuBotRuntime();
|
|
const recordInboundSession = vi.fn(async () => undefined);
|
|
runtime.channel.session.recordInboundSession = recordInboundSession;
|
|
mockResolveAgentRoute.mockReturnValue({
|
|
agentId: "main",
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
sessionKey: "agent:main:main",
|
|
mainSessionKey: "agent:main:main",
|
|
lastRoutePolicy: "main",
|
|
matchedBy: "default",
|
|
});
|
|
setFeishuRuntime(runtime);
|
|
|
|
await dispatchMessage({
|
|
cfg: {
|
|
session: { mainKey: "main", scope: "per-sender" },
|
|
channels: { feishu: { enabled: true, allowFrom: ["user_123"], dmPolicy: "allowlist" } },
|
|
},
|
|
event: {
|
|
sender: { sender_id: { open_id: "ou_owner", user_id: "user_123" } },
|
|
message: {
|
|
message_id: "msg-dm-last-route-user-id-owner",
|
|
chat_id: "oc_dm",
|
|
chat_type: "p2p",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello" }),
|
|
},
|
|
},
|
|
});
|
|
|
|
const recordParams = lastMockCallArg<{
|
|
updateLastRoute?: {
|
|
mainDmOwnerPin?: {
|
|
ownerRecipient?: string;
|
|
senderRecipient?: string;
|
|
};
|
|
};
|
|
}>(recordInboundSession);
|
|
expect(recordParams?.updateLastRoute?.mainDmOwnerPin).toMatchObject({
|
|
ownerRecipient: "user:user_123",
|
|
senderRecipient: "user:user_123",
|
|
});
|
|
});
|
|
|
|
it("records Feishu group last-route updates on the resolved session", async () => {
|
|
const runtime = createFeishuBotRuntime();
|
|
const recordInboundSession = vi.fn(async () => undefined);
|
|
runtime.channel.session.recordInboundSession = recordInboundSession;
|
|
mockResolveAgentRoute.mockReturnValue({
|
|
agentId: "agent-B",
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
sessionKey: "agent:agent-B:feishu:group:oc_group_chat",
|
|
mainSessionKey: "agent:agent-B:main",
|
|
lastRoutePolicy: "session",
|
|
matchedBy: "default",
|
|
});
|
|
setFeishuRuntime(runtime);
|
|
|
|
await dispatchMessage({
|
|
cfg: {
|
|
session: { mainKey: "main", scope: "per-sender" },
|
|
channels: {
|
|
feishu: {
|
|
enabled: true,
|
|
allowFrom: ["ou_sender_1"],
|
|
groups: {
|
|
oc_group_chat: {
|
|
allow: true,
|
|
requireMention: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
event: {
|
|
sender: { sender_id: { open_id: "ou_sender_1" } },
|
|
message: {
|
|
message_id: "msg-group-last-route",
|
|
chat_id: "oc_group_chat",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello group" }),
|
|
},
|
|
},
|
|
});
|
|
|
|
const recordParams = lastMockCallArg<{
|
|
sessionKey?: string;
|
|
updateLastRoute?: {
|
|
accountId?: string;
|
|
channel?: string;
|
|
sessionKey?: string;
|
|
to?: string;
|
|
};
|
|
}>(recordInboundSession);
|
|
expect(recordParams?.sessionKey).toBe("agent:agent-B:feishu:group:oc_group_chat");
|
|
expect(recordParams?.updateLastRoute).toMatchObject({
|
|
sessionKey: "agent:agent-B:feishu:group:oc_group_chat",
|
|
channel: "feishu",
|
|
to: "chat:oc_group_chat",
|
|
accountId: "default",
|
|
});
|
|
});
|
|
|
|
it("records configured Feishu thread replies with the dispatcher fallback target", async () => {
|
|
const runtime = createFeishuBotRuntime();
|
|
const recordInboundSession = vi.fn(async () => undefined);
|
|
runtime.channel.session.recordInboundSession = recordInboundSession;
|
|
mockResolveAgentRoute.mockReturnValue({
|
|
agentId: "agent-B",
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
sessionKey: "agent:agent-B:feishu:group:oc_group_chat",
|
|
mainSessionKey: "agent:agent-B:main",
|
|
lastRoutePolicy: "session",
|
|
matchedBy: "default",
|
|
});
|
|
setFeishuRuntime(runtime);
|
|
|
|
await dispatchMessage({
|
|
cfg: {
|
|
session: { mainKey: "main", scope: "per-sender" },
|
|
channels: {
|
|
feishu: {
|
|
enabled: true,
|
|
allowFrom: ["ou_sender_1"],
|
|
groups: {
|
|
oc_group_chat: {
|
|
allow: true,
|
|
requireMention: false,
|
|
replyInThread: "enabled",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
event: {
|
|
sender: { sender_id: { open_id: "ou_sender_1" } },
|
|
message: {
|
|
message_id: "msg-group-thread-fallback",
|
|
chat_id: "oc_group_chat",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "start a thread" }),
|
|
},
|
|
},
|
|
});
|
|
|
|
const recordParams = lastMockCallArg<{
|
|
updateLastRoute?: {
|
|
threadId?: string;
|
|
to?: string;
|
|
};
|
|
}>(recordInboundSession);
|
|
expect(recordParams?.updateLastRoute).toMatchObject({
|
|
to: "chat:oc_group_chat",
|
|
threadId: "msg-group-thread-fallback",
|
|
});
|
|
});
|
|
|
|
it("records auto-threaded Feishu group replies with the dispatcher target", async () => {
|
|
const runtime = createFeishuBotRuntime();
|
|
const recordInboundSession = vi.fn(async () => undefined);
|
|
runtime.channel.session.recordInboundSession = recordInboundSession;
|
|
mockResolveAgentRoute.mockReturnValue({
|
|
agentId: "agent-B",
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
sessionKey: "agent:agent-B:feishu:group:oc_group_chat",
|
|
mainSessionKey: "agent:agent-B:main",
|
|
lastRoutePolicy: "session",
|
|
matchedBy: "default",
|
|
});
|
|
setFeishuRuntime(runtime);
|
|
|
|
await dispatchMessage({
|
|
cfg: {
|
|
session: { mainKey: "main", scope: "per-sender" },
|
|
channels: {
|
|
feishu: {
|
|
enabled: true,
|
|
allowFrom: ["ou_sender_1"],
|
|
groups: {
|
|
oc_group_chat: {
|
|
allow: true,
|
|
requireMention: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
event: {
|
|
sender: { sender_id: { open_id: "ou_sender_1" } },
|
|
message: {
|
|
message_id: "msg-group-auto-thread",
|
|
chat_id: "oc_group_chat",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
root_id: "om_thread_root",
|
|
content: JSON.stringify({ text: "continue the thread" }),
|
|
},
|
|
},
|
|
});
|
|
|
|
const recordParams = lastMockCallArg<{
|
|
updateLastRoute?: {
|
|
threadId?: string;
|
|
to?: string;
|
|
};
|
|
}>(recordInboundSession);
|
|
expect(recordParams?.updateLastRoute).toMatchObject({
|
|
to: "chat:oc_group_chat",
|
|
threadId: "msg-group-auto-thread",
|
|
});
|
|
});
|
|
|
|
it("passes reasoning preview permission from session state into the dispatcher", async () => {
|
|
mockResolveFeishuReasoningPreviewEnabled.mockReturnValue(true);
|
|
|
|
await dispatchMessage({
|
|
cfg: {
|
|
session: { mainKey: "main", scope: "per-sender" },
|
|
channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
|
|
},
|
|
event: {
|
|
sender: { sender_id: { open_id: "ou_sender_1" } },
|
|
message: {
|
|
message_id: "msg-reasoning",
|
|
chat_id: "oc_dm",
|
|
chat_type: "p2p",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello" }),
|
|
},
|
|
},
|
|
});
|
|
|
|
const dispatcherOptions = mockCallArg<{ allowReasoningPreview?: boolean }>(
|
|
mockCreateFeishuReplyDispatcher,
|
|
0,
|
|
0,
|
|
);
|
|
expect(dispatcherOptions.allowReasoningPreview).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("handleFeishuMessage command authorization", () => {
|
|
const mockFinalizeInboundContext = vi.fn((ctx: Record<string, unknown>) => ({
|
|
...ctx,
|
|
CommandAuthorized: typeof ctx.CommandAuthorized === "boolean" ? ctx.CommandAuthorized : false,
|
|
}));
|
|
const mockDispatchReplyFromConfig = vi
|
|
.fn()
|
|
.mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
|
|
const mockWithReplyDispatcher = vi.fn(
|
|
async ({
|
|
dispatcher,
|
|
run,
|
|
onSettled,
|
|
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => {
|
|
try {
|
|
return await run();
|
|
} finally {
|
|
dispatcher.markComplete();
|
|
try {
|
|
await dispatcher.waitForIdle();
|
|
} finally {
|
|
await onSettled?.();
|
|
}
|
|
}
|
|
},
|
|
);
|
|
const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
|
|
const mockShouldComputeCommandAuthorized = vi.fn(() => true);
|
|
const mockReadAllowFromStore = vi.fn().mockResolvedValue([]);
|
|
const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "ABCDEFGH", created: false });
|
|
const mockBuildPairingReply = vi.fn(() => "Pairing response");
|
|
const mockEnqueueSystemEvent = vi.fn();
|
|
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
|
|
id: "inbound-clip.mp4",
|
|
path: "/tmp/inbound-clip.mp4",
|
|
size: Buffer.byteLength("video"),
|
|
contentType: "video/mp4",
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockShouldComputeCommandAuthorized.mockReset().mockReturnValue(true);
|
|
mockGetMessageFeishu.mockReset().mockResolvedValue(null);
|
|
mockListFeishuThreadMessages.mockReset().mockResolvedValue([]);
|
|
mockReadSessionUpdatedAt.mockReturnValue(undefined);
|
|
mockResolveStorePath.mockReturnValue("/tmp/feishu-sessions.json");
|
|
mockResolveConfiguredBindingRoute.mockReset().mockImplementation(
|
|
({
|
|
route,
|
|
}: {
|
|
route: NonNullable<ConfiguredBindingRoute>["route"];
|
|
}): ConfiguredBindingRoute => ({
|
|
bindingResolution: null,
|
|
route,
|
|
}),
|
|
);
|
|
mockEnsureConfiguredBindingRouteReady.mockReset().mockResolvedValue({ ok: true });
|
|
mockResolveBoundConversation.mockReset().mockReturnValue(null);
|
|
mockTouchBinding.mockReset();
|
|
mockTranscribeFirstAudio.mockReset().mockResolvedValue(undefined);
|
|
mockMaybeCreateDynamicAgent.mockReset().mockResolvedValue({ created: false });
|
|
mockResolveAgentRoute.mockReturnValue(buildDefaultResolveRoute());
|
|
mockCreateFeishuClient.mockReturnValue({
|
|
contact: {
|
|
user: {
|
|
get: vi.fn().mockResolvedValue({ data: { user: { name: "Sender" } } }),
|
|
},
|
|
},
|
|
});
|
|
mockEnqueueSystemEvent.mockReset();
|
|
setFeishuRuntime(
|
|
createFeishuBotRuntime({
|
|
system: {
|
|
enqueueSystemEvent: mockEnqueueSystemEvent,
|
|
},
|
|
channel: {
|
|
reply: {
|
|
resolveEnvelopeFormatOptions:
|
|
resolveEnvelopeFormatOptionsMock as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
|
|
formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
|
|
finalizeInboundContext: mockFinalizeInboundContext as never,
|
|
dispatchReplyFromConfig: mockDispatchReplyFromConfig,
|
|
withReplyDispatcher: mockWithReplyDispatcher as never,
|
|
},
|
|
commands: {
|
|
shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
|
|
resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
|
|
},
|
|
pairing: {
|
|
readAllowFromStore: mockReadAllowFromStore,
|
|
upsertPairingRequest: mockUpsertPairingRequest,
|
|
buildPairingReply: mockBuildPairingReply,
|
|
},
|
|
media: {
|
|
saveMediaBuffer: mockSaveMediaBuffer,
|
|
},
|
|
},
|
|
media: {
|
|
detectMime: vi.fn(async () => "application/octet-stream"),
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not enqueue inbound preview text as system events", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
dmPolicy: "open",
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-attacker",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-no-system-preview",
|
|
chat_id: "oc-dm",
|
|
chat_type: "p2p",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hi there" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not send no-visible fallback when send policy denied delivery", async () => {
|
|
mockDispatchReplyFromConfig.mockResolvedValueOnce({
|
|
queuedFinal: false,
|
|
counts: { tool: 0, block: 0, final: 0 },
|
|
sendPolicyDenied: true,
|
|
noVisibleReplyFallbackEligible: true,
|
|
});
|
|
const ensureNoVisibleReplyFallback = vi.fn();
|
|
mockCreateFeishuReplyDispatcher.mockReturnValueOnce({
|
|
dispatcher: createReplyDispatcher(),
|
|
replyOptions: {},
|
|
markDispatchIdle: vi.fn(),
|
|
ensureNoVisibleReplyFallback,
|
|
});
|
|
|
|
await dispatchMessage({
|
|
cfg: {
|
|
channels: {
|
|
feishu: {
|
|
dmPolicy: "open",
|
|
},
|
|
},
|
|
} as ClawdbotConfig,
|
|
event: {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-sender",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-send-policy-deny",
|
|
chat_id: "oc-dm",
|
|
chat_type: "p2p",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello" }),
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(ensureNoVisibleReplyFallback).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("sends no-visible fallback when queued final delivery fails", async () => {
|
|
mockDispatchReplyFromConfig.mockResolvedValueOnce({
|
|
queuedFinal: true,
|
|
counts: { tool: 0, block: 0, final: 1 },
|
|
});
|
|
const ensureNoVisibleReplyFallback = vi.fn();
|
|
const dispatcher = createReplyDispatcher();
|
|
vi.mocked(dispatcher.getFailedCounts).mockReturnValue({ tool: 0, block: 0, final: 1 });
|
|
mockCreateFeishuReplyDispatcher.mockReturnValueOnce({
|
|
dispatcher,
|
|
replyOptions: {},
|
|
markDispatchIdle: vi.fn(),
|
|
ensureNoVisibleReplyFallback,
|
|
});
|
|
|
|
await dispatchMessage({
|
|
cfg: {
|
|
channels: {
|
|
feishu: {
|
|
dmPolicy: "open",
|
|
},
|
|
},
|
|
} as ClawdbotConfig,
|
|
event: {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-sender",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-final-delivery-failed",
|
|
chat_id: "oc-dm",
|
|
chat_type: "p2p",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello" }),
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(ensureNoVisibleReplyFallback).toHaveBeenCalledWith("dispatch-complete-no-visible-reply");
|
|
});
|
|
|
|
it("passes disabled config-write policy to dynamic agent creation", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
dmPolicy: "open",
|
|
allowFrom: ["*"],
|
|
configWrites: false,
|
|
dynamicAgentCreation: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-attacker",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-dynamic-config-writes-disabled",
|
|
chat_id: "oc-dm",
|
|
chat_type: "p2p",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
const dynamicAgentRequest = mockCallArg<{
|
|
configWritesAllowed?: boolean;
|
|
senderOpenId?: string;
|
|
}>(mockMaybeCreateDynamicAgent, 0, 0);
|
|
expect(dynamicAgentRequest.senderOpenId).toBe("ou-attacker");
|
|
expect(dynamicAgentRequest.configWritesAllowed).toBe(false);
|
|
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("blocks open DMs when a restrictive allowlist does not match", async () => {
|
|
const cfg: ClawdbotConfig = {
|
|
commands: { useAccessGroups: true },
|
|
channels: {
|
|
feishu: {
|
|
dmPolicy: "open",
|
|
allowFrom: ["ou-admin"],
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-attacker",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-auth-bypass-regression",
|
|
chat_id: "oc-dm",
|
|
chat_type: "p2p",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "/status" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled();
|
|
expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reads pairing allow store for non-command DMs when dmPolicy is pairing", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
mockReadAllowFromStore.mockResolvedValue(["ou-attacker"]);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
commands: { useAccessGroups: true },
|
|
channels: {
|
|
feishu: {
|
|
dmPolicy: "pairing",
|
|
allowFrom: [],
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-attacker",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-read-store-non-command",
|
|
chat_id: "oc-dm",
|
|
chat_type: "p2p",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello there" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
expect(mockReadAllowFromStore).toHaveBeenCalledWith({
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
});
|
|
expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled();
|
|
expect(mockFinalizeInboundContext).toHaveBeenCalledTimes(1);
|
|
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("skips sender-name lookup when resolveSenderNames is false", async () => {
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
dmPolicy: "open",
|
|
allowFrom: ["*"],
|
|
resolveSenderNames: false,
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-attacker",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-skip-sender-lookup",
|
|
chat_id: "oc-dm",
|
|
chat_type: "p2p",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
expect(mockCreateFeishuClient).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("propagates parent/root message ids into inbound context for reply reconstruction", async () => {
|
|
mockGetMessageFeishu.mockResolvedValueOnce({
|
|
messageId: "om_parent_001",
|
|
chatId: "oc-group",
|
|
content: "quoted content",
|
|
contentType: "text",
|
|
});
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
enabled: true,
|
|
dmPolicy: "open",
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-replier",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "om_reply_001",
|
|
root_id: "om_root_001",
|
|
parent_id: "om_parent_001",
|
|
chat_id: "oc-dm",
|
|
chat_type: "p2p",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "reply text" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
const context = mockCallArg<{
|
|
ReplyToId?: string;
|
|
RootMessageId?: string;
|
|
SupplementalContext?: { quote?: { body?: string } };
|
|
}>(mockFinalizeInboundContext, 0, 0);
|
|
expect(context.ReplyToId).toBe("om_parent_001");
|
|
expect(context.RootMessageId).toBe("om_root_001");
|
|
expect(context.SupplementalContext?.quote?.body).toBe("quoted content");
|
|
});
|
|
|
|
it("uses message create_time as Timestamp instead of Date.now()", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
dmPolicy: "open",
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-attacker",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-create-time",
|
|
chat_id: "oc-dm",
|
|
chat_type: "p2p",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "delete this" }),
|
|
create_time: "1700000000000",
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
const context = mockCallArg<{ Timestamp?: number }>(mockFinalizeInboundContext, 0, 0);
|
|
expect(context.Timestamp).toBe(1700000000000);
|
|
});
|
|
|
|
it("falls back to Date.now() when create_time is absent", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
dmPolicy: "open",
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-attacker",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-no-create-time",
|
|
chat_id: "oc-dm",
|
|
chat_type: "p2p",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello" }),
|
|
},
|
|
};
|
|
|
|
const before = Date.now();
|
|
await dispatchMessage({ cfg, event });
|
|
const after = Date.now();
|
|
|
|
const call = mockFinalizeInboundContext.mock.calls.at(0)?.[0] as { Timestamp: number };
|
|
expect(call.Timestamp).toBeGreaterThanOrEqual(before);
|
|
expect(call.Timestamp).toBeLessThanOrEqual(after);
|
|
});
|
|
|
|
it("falls back to Date.now() when create_time is malformed", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
dmPolicy: "open",
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-attacker",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-malformed-create-time",
|
|
chat_id: "oc-dm",
|
|
chat_type: "p2p",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello" }),
|
|
create_time: "1700000000000ms",
|
|
},
|
|
};
|
|
|
|
const before = Date.now();
|
|
await dispatchMessage({ cfg, event });
|
|
const after = Date.now();
|
|
|
|
const call = mockFinalizeInboundContext.mock.calls.at(0)?.[0] as { Timestamp: number };
|
|
expect(call.Timestamp).toBeGreaterThanOrEqual(before);
|
|
expect(call.Timestamp).toBeLessThanOrEqual(after);
|
|
});
|
|
|
|
it("replies pairing challenge to DM chat_id instead of user:sender id", async () => {
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
dmPolicy: "pairing",
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
user_id: "u_mobile_only",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-pairing-chat-reply",
|
|
chat_id: "oc_dm_chat_1",
|
|
chat_type: "p2p",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello" }),
|
|
},
|
|
};
|
|
|
|
mockReadAllowFromStore.mockResolvedValue([]);
|
|
mockUpsertPairingRequest.mockResolvedValue({ code: "ABCDEFGH", created: true });
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
const message = mockCallArg<{ to?: string }>(mockSendMessageFeishu, 0, 0);
|
|
expect(message.to).toBe("chat:oc_dm_chat_1");
|
|
});
|
|
it("creates pairing request and drops unauthorized DMs in pairing mode", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
mockReadAllowFromStore.mockResolvedValue([]);
|
|
mockUpsertPairingRequest.mockResolvedValue({ code: "ABCDEFGH", created: true });
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
dmPolicy: "pairing",
|
|
allowFrom: [],
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-unapproved",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-pairing-flow",
|
|
chat_id: "oc-dm",
|
|
chat_type: "p2p",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
expect(mockUpsertPairingRequest).toHaveBeenCalledWith({
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
id: "ou-unapproved",
|
|
meta: { name: undefined },
|
|
});
|
|
expect(mockSendMessageFeishu).toHaveBeenCalledTimes(1);
|
|
const pairingMessage = mockCallArg<{ accountId?: string; text?: string; to?: string }>(
|
|
mockSendMessageFeishu,
|
|
0,
|
|
0,
|
|
);
|
|
expect(pairingMessage.to).toBe("chat:oc-dm");
|
|
expect(pairingMessage.text).toContain("Your Feishu user id: ou-unapproved");
|
|
expect(pairingMessage.text).toContain("Pairing code:");
|
|
expect(pairingMessage.text).toContain("ABCDEFGH");
|
|
expect(pairingMessage.accountId).toBe("default");
|
|
expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
|
|
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("computes group command authorization from group allowFrom", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(true);
|
|
mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
commands: { useAccessGroups: true },
|
|
channels: {
|
|
feishu: {
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-attacker",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-group-command-auth",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "/status" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled();
|
|
const context = mockCallArg<{
|
|
ChatType?: string;
|
|
CommandAuthorized?: boolean;
|
|
SenderId?: string;
|
|
}>(mockFinalizeInboundContext, 0, 0);
|
|
expect(context.ChatType).toBe("group");
|
|
expect(context.CommandAuthorized).toBe(false);
|
|
expect(context.SenderId).toBe("ou-attacker");
|
|
});
|
|
|
|
it("normalizes group mention-prefixed slash commands before command-auth probing", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(true);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-attacker",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-group-mention-command-probe",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "@_user_1/model" }),
|
|
mentions: [{ key: "@_user_1", id: { open_id: "ou-bot" }, name: "Bot", tenant_key: "" }],
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
expect(mockShouldComputeCommandAuthorized).toHaveBeenCalledWith("/model", cfg);
|
|
});
|
|
|
|
it("falls back to top-level allowFrom for group command authorization", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(true);
|
|
mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(true);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
commands: { useAccessGroups: true },
|
|
channels: {
|
|
feishu: {
|
|
allowFrom: ["ou-admin"],
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-admin",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-group-command-fallback",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "/status" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled();
|
|
const context = mockCallArg<{
|
|
ChatType?: string;
|
|
CommandAuthorized?: boolean;
|
|
SenderId?: string;
|
|
}>(mockFinalizeInboundContext, 0, 0);
|
|
expect(context.ChatType).toBe("group");
|
|
expect(context.CommandAuthorized).toBe(true);
|
|
expect(context.SenderId).toBe("ou-admin");
|
|
});
|
|
|
|
it("allows group sender when global groupSenderAllowFrom includes sender", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groupPolicy: "open",
|
|
groupSenderAllowFrom: ["ou-allowed"],
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-allowed",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-global-group-sender-allow",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
const context = mockCallArg<{ ChatType?: string; SenderId?: string }>(
|
|
mockFinalizeInboundContext,
|
|
0,
|
|
0,
|
|
);
|
|
expect(context.ChatType).toBe("group");
|
|
expect(context.SenderId).toBe("ou-allowed");
|
|
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps Feishu group policy bound to the chat while preserving speaker identity", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groupPolicy: "open",
|
|
groupSenderAllowFrom: ["ou-allowed"],
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-allowed",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-group-context-79457",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
const finalized = mockCallArg<{
|
|
ChatType?: string;
|
|
From?: string;
|
|
OriginatingChannel?: string;
|
|
OriginatingTo?: string;
|
|
SenderId?: string;
|
|
To?: string;
|
|
}>(mockFinalizeInboundContext, 0, 0);
|
|
expect(finalized.ChatType).toBe("group");
|
|
expect(finalized.From).toBe("feishu:ou-allowed");
|
|
expect(finalized.To).toBe("chat:oc-group");
|
|
expect(finalized.OriginatingChannel).toBe("feishu");
|
|
expect(finalized.OriginatingTo).toBe("chat:oc-group");
|
|
expect(finalized.SenderId).toBe("ou-allowed");
|
|
const groupSessionKey = resolveGroupSessionKey(finalized as never);
|
|
if (!groupSessionKey) {
|
|
throw new Error("Expected group session key");
|
|
}
|
|
expect(groupSessionKey.channel).toBe("feishu");
|
|
expect(groupSessionKey.id).toBe("oc-group");
|
|
expect(groupSessionKey.key).toBe("feishu:group:oc-group");
|
|
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("blocks group sender when global groupSenderAllowFrom excludes sender", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groupPolicy: "open",
|
|
groupSenderAllowFrom: ["ou-allowed"],
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-blocked",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-global-group-sender-block",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
|
|
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("prefers per-group allowFrom over global groupSenderAllowFrom", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groupPolicy: "open",
|
|
groupSenderAllowFrom: ["ou-global"],
|
|
groups: {
|
|
"oc-group": {
|
|
allowFrom: ["ou-group-only"],
|
|
requireMention: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-global",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-per-group-precedence",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
|
|
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("drops quoted group context from senders outside the group sender allowlist in allowlist mode", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
mockGetMessageFeishu.mockResolvedValueOnce({
|
|
messageId: "om_parent_blocked",
|
|
chatId: "oc-group",
|
|
senderId: "ou-blocked",
|
|
senderType: "user",
|
|
content: "blocked quoted content",
|
|
contentType: "text",
|
|
});
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groupPolicy: "open",
|
|
groupSenderAllowFrom: ["ou-allowed"],
|
|
contextVisibility: "allowlist",
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-allowed",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-group-quoted-filter",
|
|
parent_id: "om_parent_blocked",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
const context = mockCallArg<{
|
|
ReplyToId?: string;
|
|
SupplementalContext?: { quote?: { body?: string } };
|
|
}>(mockFinalizeInboundContext, 0, 0);
|
|
expect(context.ReplyToId).toBe("om_parent_blocked");
|
|
expect(context.SupplementalContext?.quote?.body).toBeUndefined();
|
|
});
|
|
|
|
it("keeps quoted group context from non-allowlisted senders in default all mode", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
mockGetMessageFeishu.mockResolvedValueOnce({
|
|
messageId: "om_parent_visible",
|
|
chatId: "oc-group",
|
|
senderId: "ou-blocked",
|
|
senderType: "user",
|
|
content: "visible quoted content",
|
|
contentType: "text",
|
|
});
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groupPolicy: "open",
|
|
groupSenderAllowFrom: ["ou-allowed"],
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-allowed",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-group-quoted-visible",
|
|
parent_id: "om_parent_visible",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
const context = mockCallArg<{
|
|
ReplyToId?: string;
|
|
SupplementalContext?: { quote?: { body?: string } };
|
|
}>(mockFinalizeInboundContext, 0, 0);
|
|
expect(context.ReplyToId).toBe("om_parent_visible");
|
|
expect(context.SupplementalContext?.quote?.body).toBe("visible quoted content");
|
|
});
|
|
|
|
it("dispatches group image message when groupPolicy is open (requireMention defaults to false)", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groupPolicy: "open",
|
|
// requireMention is NOT set — should default to false for open policy
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: { open_id: "ou-sender" },
|
|
},
|
|
message: {
|
|
message_id: "msg-group-image-open",
|
|
chat_id: "oc-group-open",
|
|
chat_type: "group",
|
|
message_type: "image",
|
|
content: JSON.stringify({ image_key: "img_v3_test" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("drops group image message when groupPolicy is open but requireMention is explicitly true", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groupPolicy: "open",
|
|
requireMention: true, // explicit override — user opts into mention-required even for open
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: { open_id: "ou-sender" },
|
|
},
|
|
message: {
|
|
message_id: "msg-group-image-open-explicit-mention",
|
|
chat_id: "oc-group-open",
|
|
chat_type: "group",
|
|
message_type: "image",
|
|
content: JSON.stringify({ image_key: "img_v3_test" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
|
|
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("drops group image message when groupPolicy is allowlist and requireMention is not set (defaults to true)", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groupPolicy: "allowlist",
|
|
// requireMention not set — for non-open policy defaults to true
|
|
groups: {
|
|
"oc-allowlist-group": {
|
|
allow: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: { open_id: "ou-sender" },
|
|
},
|
|
message: {
|
|
message_id: "msg-group-image-allowlist",
|
|
chat_id: "oc-allowlist-group",
|
|
chat_type: "group",
|
|
message_type: "image",
|
|
content: JSON.stringify({ image_key: "img_v3_test" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
|
|
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("admits group when chat_id is explicitly configured under groups, even with empty groupAllowFrom (#67687)", async () => {
|
|
// Regression for #67687: a group that only sets `groups.<chat_id>.requireMention=false`
|
|
// (and leaves `groupAllowFrom` empty) should still be admitted under the schema-default
|
|
// `groupPolicy="allowlist"`. The group's explicit presence in `channels.feishu.groups`
|
|
// is the operator's allowlist signal, and the per-group `requireMention` override should
|
|
// then control mention gating for inbound text events.
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groupPolicy: "allowlist",
|
|
// groupAllowFrom intentionally omitted -> empty []
|
|
groups: {
|
|
"oc-explicit-group": {
|
|
requireMention: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: { open_id: "ou-sender" },
|
|
},
|
|
message: {
|
|
message_id: "msg-explicit-group-67687",
|
|
chat_id: "oc-explicit-group",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello bot" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
// Group must be admitted: the inbound finalize/dispatch path runs.
|
|
expect(mockFinalizeInboundContext).toHaveBeenCalled();
|
|
expect(mockDispatchReplyFromConfig).toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not let explicit group config override disabled group policy", async () => {
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groupPolicy: "disabled",
|
|
groups: {
|
|
"oc-disabled-policy-group": {
|
|
requireMention: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: { open_id: "ou-sender" },
|
|
},
|
|
message: {
|
|
message_id: "msg-disabled-policy-group",
|
|
chat_id: "oc-disabled-policy-group",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello bot" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
|
|
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not treat wildcard group defaults as allowlist admission", async () => {
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groupPolicy: "allowlist",
|
|
groups: {
|
|
"*": {
|
|
requireMention: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: { open_id: "ou-sender" },
|
|
},
|
|
message: {
|
|
message_id: "msg-wildcard-group-default",
|
|
chat_id: "oc-wildcard-only",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello bot" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
|
|
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("drops message when groupConfig.enabled is false", async () => {
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groups: {
|
|
"oc-disabled-group": {
|
|
enabled: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: { open_id: "ou-sender" },
|
|
},
|
|
message: {
|
|
message_id: "msg-disabled-group",
|
|
chat_id: "oc-disabled-group",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
|
|
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("transcribes inbound audio before building the agent turn", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
mockDownloadMessageResourceFeishu.mockResolvedValueOnce({
|
|
buffer: Buffer.from("voice"),
|
|
contentType: "audio/ogg",
|
|
fileName: "voice.ogg",
|
|
});
|
|
mockSaveMediaBuffer.mockResolvedValueOnce({
|
|
id: "inbound-voice.ogg",
|
|
path: "/tmp/inbound-voice.ogg",
|
|
size: Buffer.byteLength("voice"),
|
|
contentType: "audio/ogg",
|
|
});
|
|
mockTranscribeFirstAudio.mockResolvedValueOnce("voice transcript");
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
dmPolicy: "open",
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-voice",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-audio-inbound",
|
|
chat_id: "oc-dm",
|
|
chat_type: "p2p",
|
|
message_type: "audio",
|
|
content: JSON.stringify({
|
|
file_key: "file_audio_payload",
|
|
duration: 1200,
|
|
}),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
const downloadRequest = mockCallArg<{ fileKey?: string; messageId?: string; type?: string }>(
|
|
mockDownloadMessageResourceFeishu,
|
|
0,
|
|
0,
|
|
);
|
|
expect(downloadRequest.messageId).toBe("msg-audio-inbound");
|
|
expect(downloadRequest.fileKey).toBe("file_audio_payload");
|
|
expect(downloadRequest.type).toBe("file");
|
|
const transcribeRequest = mockCallArg<{
|
|
cfg?: { channels?: { feishu?: { dmPolicy?: string } } };
|
|
ctx?: { ChatType?: string; MediaPaths?: string[]; MediaTypes?: string[] };
|
|
}>(mockTranscribeFirstAudio, 0, 0);
|
|
expect(transcribeRequest.ctx?.MediaPaths).toEqual(["/tmp/inbound-voice.ogg"]);
|
|
expect(transcribeRequest.ctx?.MediaTypes).toEqual(["audio/ogg"]);
|
|
expect(transcribeRequest.ctx?.ChatType).toBe("direct");
|
|
expect(transcribeRequest.cfg?.channels?.feishu?.dmPolicy).toBe("open");
|
|
const finalized = mockCallArg<{
|
|
BodyForAgent?: string;
|
|
CommandBody?: string;
|
|
MediaPaths?: string[];
|
|
MediaTranscribedIndexes?: number[];
|
|
MediaTypes?: string[];
|
|
RawBody?: string;
|
|
Transcript?: string;
|
|
}>(mockFinalizeInboundContext, 0, 0);
|
|
expect(finalized.BodyForAgent).toBe(
|
|
"[message_id: msg-audio-inbound]\nou-voice: voice transcript",
|
|
);
|
|
expect(finalized.RawBody).toBe("voice transcript");
|
|
expect(finalized.CommandBody).toBe("voice transcript");
|
|
expect(finalized.Transcript).toBe("voice transcript");
|
|
expect(finalized.MediaPaths).toEqual(["/tmp/inbound-voice.ogg"]);
|
|
expect(finalized.MediaTypes).toEqual(["audio/ogg"]);
|
|
expect(finalized.MediaTranscribedIndexes).toEqual([0]);
|
|
expect(finalized.BodyForAgent).not.toContain("file_audio_payload");
|
|
});
|
|
|
|
it("uses video file_key (not thumbnail image_key) for inbound video download", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
dmPolicy: "open",
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-sender",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-video-inbound",
|
|
chat_id: "oc-dm",
|
|
chat_type: "p2p",
|
|
message_type: "video",
|
|
content: JSON.stringify({
|
|
file_key: "file_video_payload",
|
|
image_key: "img_thumb_payload",
|
|
file_name: "clip.mp4",
|
|
}),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
const videoDownloadRequest = mockCallArg<{
|
|
fileKey?: string;
|
|
messageId?: string;
|
|
type?: string;
|
|
}>(mockDownloadMessageResourceFeishu, 0, 0);
|
|
expect(videoDownloadRequest.messageId).toBe("msg-video-inbound");
|
|
expect(videoDownloadRequest.fileKey).toBe("file_video_payload");
|
|
expect(videoDownloadRequest.type).toBe("file");
|
|
const mediaBuffer = mockCallArg<Buffer>(mockSaveMediaBuffer, 0, 0);
|
|
expect(Buffer.isBuffer(mediaBuffer)).toBe(true);
|
|
expect(mediaBuffer.toString()).toBe("video");
|
|
expect(mockCallArg(mockSaveMediaBuffer, 0, 1)).toBe("video/mp4");
|
|
expect(mockCallArg(mockSaveMediaBuffer, 0, 2)).toBe("inbound");
|
|
expect(typeof mockCallArg(mockSaveMediaBuffer, 0, 3)).toBe("number");
|
|
expect(mockCallArg(mockSaveMediaBuffer, 0, 4)).toBe("clip.mp4");
|
|
});
|
|
|
|
it("uses media message_type file_key (not thumbnail image_key) for inbound mobile video download", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
dmPolicy: "open",
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-sender",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-media-inbound",
|
|
chat_id: "oc-dm",
|
|
chat_type: "p2p",
|
|
message_type: "media",
|
|
content: JSON.stringify({
|
|
file_key: "file_media_payload",
|
|
image_key: "img_media_thumb",
|
|
file_name: "mobile.mp4",
|
|
}),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
const mediaDownloadRequest = mockCallArg<{
|
|
fileKey?: string;
|
|
messageId?: string;
|
|
type?: string;
|
|
}>(mockDownloadMessageResourceFeishu, 0, 0);
|
|
expect(mediaDownloadRequest.messageId).toBe("msg-media-inbound");
|
|
expect(mediaDownloadRequest.fileKey).toBe("file_media_payload");
|
|
expect(mediaDownloadRequest.type).toBe("file");
|
|
const mediaBuffer = mockCallArg<Buffer>(mockSaveMediaBuffer, 0, 0);
|
|
expect(Buffer.isBuffer(mediaBuffer)).toBe(true);
|
|
expect(mediaBuffer.toString()).toBe("video");
|
|
expect(mockCallArg(mockSaveMediaBuffer, 0, 1)).toBe("video/mp4");
|
|
expect(mockCallArg(mockSaveMediaBuffer, 0, 2)).toBe("inbound");
|
|
expect(typeof mockCallArg(mockSaveMediaBuffer, 0, 3)).toBe("number");
|
|
expect(mockCallArg(mockSaveMediaBuffer, 0, 4)).toBe("clip.mp4");
|
|
});
|
|
|
|
it("falls back to the message payload filename when download metadata omits it", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
mockDownloadMessageResourceFeishu.mockResolvedValueOnce({
|
|
buffer: Buffer.from("video"),
|
|
contentType: "video/mp4",
|
|
});
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
dmPolicy: "open",
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-sender",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-media-payload-name",
|
|
chat_id: "oc-dm",
|
|
chat_type: "p2p",
|
|
message_type: "media",
|
|
content: JSON.stringify({
|
|
file_key: "file_media_payload",
|
|
image_key: "img_media_thumb",
|
|
file_name: "payload-name.mp4",
|
|
}),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
const mediaBuffer = mockCallArg<Buffer>(mockSaveMediaBuffer, 0, 0);
|
|
expect(Buffer.isBuffer(mediaBuffer)).toBe(true);
|
|
expect(mediaBuffer.toString()).toBe("video");
|
|
expect(mockCallArg(mockSaveMediaBuffer, 0, 1)).toBe("video/mp4");
|
|
expect(mockCallArg(mockSaveMediaBuffer, 0, 2)).toBe("inbound");
|
|
expect(typeof mockCallArg(mockSaveMediaBuffer, 0, 3)).toBe("number");
|
|
expect(mockCallArg(mockSaveMediaBuffer, 0, 4)).toBe("payload-name.mp4");
|
|
});
|
|
|
|
it("downloads embedded media tags from post messages as files", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
dmPolicy: "open",
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-sender",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-post-media",
|
|
chat_id: "oc-dm",
|
|
chat_type: "p2p",
|
|
message_type: "post",
|
|
content: JSON.stringify({
|
|
title: "Rich text",
|
|
content: [
|
|
[
|
|
{
|
|
tag: "media",
|
|
file_key: "file_post_media_payload",
|
|
file_name: "embedded.mov",
|
|
},
|
|
],
|
|
],
|
|
}),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
const downloadRequest = mockCallArg<{ fileKey?: string; messageId?: string; type?: string }>(
|
|
mockDownloadMessageResourceFeishu,
|
|
0,
|
|
0,
|
|
);
|
|
expect(downloadRequest.messageId).toBe("msg-post-media");
|
|
expect(downloadRequest.fileKey).toBe("file_post_media_payload");
|
|
expect(downloadRequest.type).toBe("file");
|
|
const postMediaBuffer = mockCallArg<Buffer>(mockSaveMediaBuffer, 0, 0);
|
|
expect(Buffer.isBuffer(postMediaBuffer)).toBe(true);
|
|
expect(postMediaBuffer.toString()).toBe("video");
|
|
expect(mockCallArg(mockSaveMediaBuffer, 0, 1)).toBe("video/mp4");
|
|
expect(mockCallArg(mockSaveMediaBuffer, 0, 2)).toBe("inbound");
|
|
expect(typeof mockCallArg(mockSaveMediaBuffer, 0, 3)).toBe("number");
|
|
});
|
|
|
|
it("includes message_id in BodyForAgent on its own line", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
dmPolicy: "open",
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-msgid",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-message-id-line",
|
|
chat_id: "oc-dm",
|
|
chat_type: "p2p",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
const context = mockCallArg<{ BodyForAgent?: string }>(mockFinalizeInboundContext, 0, 0);
|
|
expect(context.BodyForAgent).toBe("[message_id: msg-message-id-line]\nou-msgid: hello");
|
|
});
|
|
|
|
it("expands merge_forward content from API sub-messages", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
const mockGetMerged = vi.fn().mockResolvedValue({
|
|
code: 0,
|
|
data: {
|
|
items: [
|
|
{
|
|
message_id: "container",
|
|
msg_type: "merge_forward",
|
|
body: { content: JSON.stringify({ text: "Merged and Forwarded Message" }) },
|
|
},
|
|
{
|
|
message_id: "sub-2",
|
|
upper_message_id: "container",
|
|
msg_type: "file",
|
|
body: { content: JSON.stringify({ file_name: "report.pdf" }) },
|
|
create_time: "2000",
|
|
},
|
|
{
|
|
message_id: "sub-1",
|
|
upper_message_id: "container",
|
|
msg_type: "text",
|
|
body: { content: JSON.stringify({ text: "alpha" }) },
|
|
create_time: "1000",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
mockCreateFeishuClient.mockReturnValue({
|
|
contact: {
|
|
user: {
|
|
get: vi.fn().mockResolvedValue({ data: { user: { name: "Sender" } } }),
|
|
},
|
|
},
|
|
im: {
|
|
message: {
|
|
get: mockGetMerged,
|
|
},
|
|
},
|
|
} as unknown as PluginRuntime);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
dmPolicy: "open",
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-merge",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-merge-forward",
|
|
chat_id: "oc-dm",
|
|
chat_type: "p2p",
|
|
message_type: "merge_forward",
|
|
content: JSON.stringify({ text: "Merged and Forwarded Message" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
expect(mockGetMerged).toHaveBeenCalledWith({
|
|
path: { message_id: "msg-merge-forward" },
|
|
});
|
|
const context = mockCallArg<{ BodyForAgent?: string }>(mockFinalizeInboundContext, 0, 0);
|
|
expect(context.BodyForAgent).toContain(
|
|
"[Merged and Forwarded Messages]\n- alpha\n- [File: report.pdf]",
|
|
);
|
|
});
|
|
|
|
it("does not partially parse malformed merge_forward create_time values", () => {
|
|
const content = JSON.stringify([
|
|
{
|
|
message_id: "container",
|
|
msg_type: "merge_forward",
|
|
body: { content: JSON.stringify({ text: "Merged and Forwarded Message" }) },
|
|
},
|
|
{
|
|
message_id: "partial",
|
|
upper_message_id: "container",
|
|
msg_type: "text",
|
|
body: { content: JSON.stringify({ text: "partial" }) },
|
|
create_time: "2000ms",
|
|
},
|
|
{
|
|
message_id: "valid",
|
|
upper_message_id: "container",
|
|
msg_type: "text",
|
|
body: { content: JSON.stringify({ text: "valid" }) },
|
|
create_time: "1000",
|
|
},
|
|
]);
|
|
|
|
expect(parseMergeForwardContent({ content })).toBe(
|
|
"[Merged and Forwarded Messages]\n- partial\n- valid",
|
|
);
|
|
});
|
|
|
|
it("falls back when merge_forward API returns no sub-messages", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
mockCreateFeishuClient.mockReturnValue({
|
|
contact: {
|
|
user: {
|
|
get: vi.fn().mockResolvedValue({ data: { user: { name: "Sender" } } }),
|
|
},
|
|
},
|
|
im: {
|
|
message: {
|
|
get: vi.fn().mockResolvedValue({ code: 0, data: { items: [] } }),
|
|
},
|
|
},
|
|
});
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
dmPolicy: "open",
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-merge-empty",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-merge-empty",
|
|
chat_id: "oc-dm",
|
|
chat_type: "p2p",
|
|
message_type: "merge_forward",
|
|
content: JSON.stringify({ text: "Merged and Forwarded Message" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
const context = mockCallArg<{ BodyForAgent?: string }>(mockFinalizeInboundContext, 0, 0);
|
|
expect(context.BodyForAgent).toContain("[Merged and Forwarded Message - could not fetch]");
|
|
});
|
|
|
|
it("dispatches once and appends permission notice to the main agent body", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
mockCreateFeishuClient.mockReturnValue({
|
|
contact: {
|
|
user: {
|
|
get: vi.fn().mockRejectedValue({
|
|
response: {
|
|
data: {
|
|
code: 99991672,
|
|
msg: "permission denied https://open.feishu.cn/app/cli_test",
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
});
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
appId: "cli_test",
|
|
appSecret: "sec_test", // pragma: allowlist secret
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-perm",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-perm-1",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello group" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
const context = mockCallArg<{ BodyForAgent?: string }>(mockFinalizeInboundContext, 0, 0);
|
|
expect(context.BodyForAgent).toContain(
|
|
"Permission grant URL: https://open.feishu.cn/app/cli_test",
|
|
);
|
|
expect(context.BodyForAgent).toContain("ou-perm: hello group");
|
|
});
|
|
|
|
it("ignores stale non-existent contact scope permission errors", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
mockCreateFeishuClient.mockReturnValue({
|
|
contact: {
|
|
user: {
|
|
get: vi.fn().mockRejectedValue({
|
|
response: {
|
|
data: {
|
|
code: 99991672,
|
|
msg: "permission denied: contact:contact.base:readonly https://open.feishu.cn/app/cli_scope_bug",
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
});
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
appId: "cli_scope_bug",
|
|
appSecret: "sec_scope_bug", // pragma: allowlist secret
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-perm-scope",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-perm-scope-1",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello group" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
const context = mockCallArg<{ BodyForAgent?: string }>(mockFinalizeInboundContext, 0, 0);
|
|
expect(context.BodyForAgent).not.toContain("Permission grant URL");
|
|
expect(context.BodyForAgent).toContain("ou-perm-scope: hello group");
|
|
});
|
|
|
|
it("routes group sessions by sender when groupSessionScope=group_sender", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
groupSessionScope: "group_sender",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: { sender_id: { open_id: "ou-scope-user" } },
|
|
message: {
|
|
message_id: "msg-scope-group-sender",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "group sender scope" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
const routeRequest = mockCallArg<{
|
|
parentPeer?: unknown;
|
|
peer?: { id?: string; kind?: string };
|
|
}>(mockResolveAgentRoute, 0, 0);
|
|
expect(routeRequest.peer).toEqual({ kind: "group", id: "oc-group:sender:ou-scope-user" });
|
|
expect(routeRequest.parentPeer).toBeNull();
|
|
});
|
|
|
|
it("routes topic sessions and parentPeer when groupSessionScope=group_topic_sender", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
groupSessionScope: "group_topic_sender",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: { sender_id: { open_id: "ou-topic-user" } },
|
|
message: {
|
|
message_id: "msg-scope-topic-sender",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
root_id: "om_root_topic",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "topic sender scope" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
const routeRequest = mockCallArg<{
|
|
parentPeer?: { id?: string; kind?: string };
|
|
peer?: { id?: string; kind?: string };
|
|
}>(mockResolveAgentRoute, 0, 0);
|
|
expect(routeRequest.peer).toEqual({
|
|
kind: "group",
|
|
id: "oc-group:topic:om_root_topic:sender:ou-topic-user",
|
|
});
|
|
expect(routeRequest.parentPeer).toEqual({ kind: "group", id: "oc-group" });
|
|
});
|
|
|
|
it("keeps root_id as topic key when root_id and thread_id both exist", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
groupSessionScope: "group_topic_sender",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: { sender_id: { open_id: "ou-topic-user" } },
|
|
message: {
|
|
message_id: "msg-scope-topic-thread-id",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
root_id: "om_root_topic",
|
|
thread_id: "omt_topic_1",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "topic sender scope" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
const routeRequest = mockCallArg<{
|
|
parentPeer?: { id?: string; kind?: string };
|
|
peer?: { id?: string; kind?: string };
|
|
}>(mockResolveAgentRoute, 0, 0);
|
|
expect(routeRequest.peer).toEqual({
|
|
kind: "group",
|
|
id: "oc-group:topic:om_root_topic:sender:ou-topic-user",
|
|
});
|
|
expect(routeRequest.parentPeer).toEqual({ kind: "group", id: "oc-group" });
|
|
});
|
|
|
|
it("uses thread_id as the canonical topic key in Feishu topic groups", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
groupSessionScope: "group_topic",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const topicStarter: FeishuMessageEvent = {
|
|
sender: { sender_id: { open_id: "ou-topic-user" } },
|
|
message: {
|
|
message_id: "om_topic_starter_message",
|
|
chat_id: "oc-group",
|
|
chat_type: "topic_group",
|
|
root_id: "omt_topic_1",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "topic starter" }),
|
|
},
|
|
};
|
|
const topicReply: FeishuMessageEvent = {
|
|
sender: { sender_id: { open_id: "ou-topic-user" } },
|
|
message: {
|
|
message_id: "om_topic_reply_message",
|
|
chat_id: "oc-group",
|
|
chat_type: "topic_group",
|
|
root_id: "om_topic_starter_message",
|
|
thread_id: "omt_topic_1",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "topic reply" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event: topicStarter });
|
|
await dispatchMessage({ cfg, event: topicReply });
|
|
|
|
const starterRouteRequest = mockCallArg<{
|
|
parentPeer?: { id?: string; kind?: string };
|
|
peer?: { id?: string; kind?: string };
|
|
}>(mockResolveAgentRoute, 0, 0);
|
|
expect(starterRouteRequest.peer).toEqual({ kind: "group", id: "oc-group:topic:omt_topic_1" });
|
|
expect(starterRouteRequest.parentPeer).toEqual({ kind: "group", id: "oc-group" });
|
|
const replyRouteRequest = mockCallArg<{
|
|
parentPeer?: { id?: string; kind?: string };
|
|
peer?: { id?: string; kind?: string };
|
|
}>(mockResolveAgentRoute, 1, 0);
|
|
expect(replyRouteRequest.peer).toEqual({ kind: "group", id: "oc-group:topic:omt_topic_1" });
|
|
expect(replyRouteRequest.parentPeer).toEqual({ kind: "group", id: "oc-group" });
|
|
});
|
|
|
|
it("uses thread_id as topic key when root_id is missing", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
groupSessionScope: "group_topic_sender",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: { sender_id: { open_id: "ou-topic-user" } },
|
|
message: {
|
|
message_id: "msg-scope-topic-thread-only",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
thread_id: "omt_topic_1",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "topic sender scope" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
expectResolvedRouteCall(
|
|
0,
|
|
{ kind: "group", id: "oc-group:topic:omt_topic_1:sender:ou-topic-user" },
|
|
{ kind: "group", id: "oc-group" },
|
|
);
|
|
});
|
|
|
|
it("maps legacy topicSessionMode=enabled to group_topic routing", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
topicSessionMode: "enabled",
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: { sender_id: { open_id: "ou-legacy" } },
|
|
message: {
|
|
message_id: "msg-legacy-topic-mode",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
root_id: "om_root_legacy",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "legacy topic mode" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
expectResolvedRouteCall(
|
|
0,
|
|
{ kind: "group", id: "oc-group:topic:om_root_legacy" },
|
|
{ kind: "group", id: "oc-group" },
|
|
);
|
|
});
|
|
|
|
it("maps legacy topicSessionMode=enabled to root_id when both root_id and thread_id exist", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
topicSessionMode: "enabled",
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: { sender_id: { open_id: "ou-legacy-thread-id" } },
|
|
message: {
|
|
message_id: "msg-legacy-topic-thread-id",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
root_id: "om_root_legacy",
|
|
thread_id: "omt_topic_legacy",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "legacy topic mode" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
expectResolvedRouteCall(
|
|
0,
|
|
{ kind: "group", id: "oc-group:topic:om_root_legacy" },
|
|
{ kind: "group", id: "oc-group" },
|
|
);
|
|
});
|
|
|
|
it("uses message_id as topic root when group_topic + replyInThread and no root_id", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
groupSessionScope: "group_topic",
|
|
replyInThread: "enabled",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: { sender_id: { open_id: "ou-topic-init" } },
|
|
message: {
|
|
message_id: "msg-new-topic-root",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "create topic" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
expectResolvedRouteCall(
|
|
0,
|
|
{ kind: "group", id: "oc-group:topic:msg-new-topic-root" },
|
|
{ kind: "group", id: "oc-group" },
|
|
);
|
|
});
|
|
|
|
it("keeps topic session key stable after first turn creates a thread", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
groupSessionScope: "group_topic",
|
|
replyInThread: "enabled",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const firstTurn: FeishuMessageEvent = {
|
|
sender: { sender_id: { open_id: "ou-topic-init" } },
|
|
message: {
|
|
message_id: "msg-topic-first",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "create topic" }),
|
|
},
|
|
};
|
|
const secondTurn: FeishuMessageEvent = {
|
|
sender: { sender_id: { open_id: "ou-topic-init" } },
|
|
message: {
|
|
message_id: "msg-topic-second",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
root_id: "msg-topic-first",
|
|
thread_id: "omt_topic_created",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "follow up in same topic" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event: firstTurn });
|
|
await dispatchMessage({ cfg, event: secondTurn });
|
|
|
|
expectResolvedRouteCall(0, { kind: "group", id: "oc-group:topic:msg-topic-first" });
|
|
expectResolvedRouteCall(1, { kind: "group", id: "oc-group:topic:msg-topic-first" });
|
|
});
|
|
|
|
it("hydrates missing native topic thread_id before routing starter events", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
mockGetMessageFeishu.mockResolvedValueOnce({
|
|
messageId: "msg-native-topic-first",
|
|
chatId: "oc-group",
|
|
chatType: "topic_group",
|
|
content: "topic starter",
|
|
contentType: "text",
|
|
threadId: "omt_native_topic",
|
|
});
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
groupSessionScope: "group_topic",
|
|
replyInThread: "enabled",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const firstTurn: FeishuMessageEvent = {
|
|
sender: { sender_id: { open_id: "ou-topic-init" } },
|
|
message: {
|
|
message_id: "msg-native-topic-first",
|
|
chat_id: "oc-group",
|
|
chat_type: "topic_group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "create native topic" }),
|
|
},
|
|
};
|
|
const secondTurn: FeishuMessageEvent = {
|
|
sender: { sender_id: { open_id: "ou-topic-init" } },
|
|
message: {
|
|
message_id: "msg-native-topic-second",
|
|
chat_id: "oc-group",
|
|
chat_type: "topic_group",
|
|
thread_id: "omt_native_topic",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "follow up in same native topic" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event: firstTurn });
|
|
await dispatchMessage({ cfg, event: secondTurn });
|
|
|
|
const getMessageRequest = mockCallArg<{ messageId?: string }>(mockGetMessageFeishu, 0, 0);
|
|
expect(getMessageRequest.messageId).toBe("msg-native-topic-first");
|
|
expectResolvedRouteCall(0, { kind: "group", id: "oc-group:topic:omt_native_topic" });
|
|
expectResolvedRouteCall(1, { kind: "group", id: "oc-group:topic:omt_native_topic" });
|
|
});
|
|
|
|
it("replies to the topic root when handling a message inside an existing topic", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
replyInThread: "enabled",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: { sender_id: { open_id: "ou-topic-user" } },
|
|
message: {
|
|
message_id: "om_child_message",
|
|
root_id: "om_root_topic",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "reply inside topic" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
const dispatcherOptions = mockCallArg<{ replyToMessageId?: string; rootId?: string }>(
|
|
mockCreateFeishuReplyDispatcher,
|
|
0,
|
|
0,
|
|
);
|
|
expect(dispatcherOptions.replyToMessageId).toBe("om_root_topic");
|
|
expect(dispatcherOptions.rootId).toBe("om_root_topic");
|
|
});
|
|
|
|
it("replies to triggering message in normal group even when root_id is present (#32980)", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
groupSessionScope: "group",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: { sender_id: { open_id: "ou-normal-user" } },
|
|
message: {
|
|
message_id: "om_quote_reply",
|
|
root_id: "om_original_msg",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello in normal group" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
const dispatcherOptions = mockCallArg<{ replyToMessageId?: string; rootId?: string }>(
|
|
mockCreateFeishuReplyDispatcher,
|
|
0,
|
|
0,
|
|
);
|
|
expect(dispatcherOptions.replyToMessageId).toBe("om_quote_reply");
|
|
expect(dispatcherOptions.rootId).toBe("om_original_msg");
|
|
});
|
|
|
|
it("replies to topic root in topic-mode group with root_id", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
groupSessionScope: "group_topic",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: { sender_id: { open_id: "ou-topic-user" } },
|
|
message: {
|
|
message_id: "om_topic_reply",
|
|
root_id: "om_topic_root",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello in topic group" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
const dispatcherOptions = mockCallArg<{ replyToMessageId?: string; rootId?: string }>(
|
|
mockCreateFeishuReplyDispatcher,
|
|
0,
|
|
0,
|
|
);
|
|
expect(dispatcherOptions.replyToMessageId).toBe("om_topic_root");
|
|
expect(dispatcherOptions.rootId).toBe("om_topic_root");
|
|
});
|
|
|
|
it("replies to topic root in topic-sender group with root_id", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
groupSessionScope: "group_topic_sender",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: { sender_id: { open_id: "ou-topic-sender-user" } },
|
|
message: {
|
|
message_id: "om_topic_sender_reply",
|
|
root_id: "om_topic_sender_root",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello in topic sender group" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
const dispatcherOptions = mockCallArg<{ replyToMessageId?: string; rootId?: string }>(
|
|
mockCreateFeishuReplyDispatcher,
|
|
0,
|
|
0,
|
|
);
|
|
expect(dispatcherOptions.replyToMessageId).toBe("om_topic_sender_root");
|
|
expect(dispatcherOptions.rootId).toBe("om_topic_sender_root");
|
|
});
|
|
|
|
it("forces thread replies when inbound message contains thread_id", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
groupSessionScope: "group",
|
|
replyInThread: "disabled",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: { sender_id: { open_id: "ou-thread-reply" } },
|
|
message: {
|
|
message_id: "msg-thread-reply",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
thread_id: "omt_topic_thread_reply",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "thread content" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
const dispatcherOptions = mockCallArg<{ replyInThread?: boolean; threadReply?: boolean }>(
|
|
mockCreateFeishuReplyDispatcher,
|
|
0,
|
|
0,
|
|
);
|
|
expect(dispatcherOptions.replyInThread).toBe(true);
|
|
expect(dispatcherOptions.threadReply).toBe(true);
|
|
});
|
|
|
|
it("bootstraps topic thread context only for a new thread session", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
mockGetMessageFeishu.mockResolvedValue({
|
|
messageId: "om_topic_root",
|
|
chatId: "oc-group",
|
|
content: "root starter",
|
|
contentType: "text",
|
|
threadId: "omt_topic_1",
|
|
});
|
|
mockListFeishuThreadMessages.mockResolvedValue([
|
|
{
|
|
messageId: "om_bot_reply",
|
|
senderId: "app_1",
|
|
senderType: "app",
|
|
content: "assistant reply",
|
|
contentType: "text",
|
|
createTime: 1710000000000,
|
|
},
|
|
{
|
|
messageId: "om_follow_up",
|
|
senderId: "ou-topic-user",
|
|
senderType: "user",
|
|
content: "follow-up question",
|
|
contentType: "text",
|
|
createTime: 1710000001000,
|
|
},
|
|
]);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
groupSessionScope: "group_topic",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: { sender_id: { open_id: "ou-topic-user" } },
|
|
message: {
|
|
message_id: "om_topic_followup_existing_session",
|
|
root_id: "om_topic_root",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "current turn" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
expect(mockReadSessionUpdatedAt).toHaveBeenCalledWith({
|
|
storePath: "/tmp/feishu-sessions.json",
|
|
sessionKey: "agent:main:feishu:dm:ou-attacker",
|
|
});
|
|
const listRequest = mockCallArg<{ rootMessageId?: string }>(mockListFeishuThreadMessages, 0, 0);
|
|
expect(listRequest.rootMessageId).toBe("om_topic_root");
|
|
const context = mockCallArg<{
|
|
MessageThreadId?: string;
|
|
SupplementalContext?: {
|
|
thread?: { historyBody?: string; label?: string; starterBody?: string };
|
|
};
|
|
}>(mockFinalizeInboundContext, 0, 0);
|
|
expect(context.SupplementalContext?.thread?.starterBody).toBe("root starter");
|
|
expect(context.SupplementalContext?.thread?.historyBody).toBe(
|
|
"assistant reply\n\nfollow-up question",
|
|
);
|
|
expect(context.SupplementalContext?.thread?.label).toBe("Feishu thread in oc-group");
|
|
expect(context.MessageThreadId).toBe("om_topic_root");
|
|
});
|
|
|
|
it("skips topic thread bootstrap when the thread session already exists", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
mockReadSessionUpdatedAt.mockReturnValue(1710000000000);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
groupSessionScope: "group_topic",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: { sender_id: { open_id: "ou-topic-user" } },
|
|
message: {
|
|
message_id: "om_topic_followup",
|
|
root_id: "om_topic_root",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "current turn" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
expect(mockGetMessageFeishu).not.toHaveBeenCalled();
|
|
expect(mockListFeishuThreadMessages).not.toHaveBeenCalled();
|
|
const context = mockCallArg<{
|
|
MessageThreadId?: string;
|
|
SupplementalContext?: {
|
|
thread?: { historyBody?: string; label?: string; starterBody?: string };
|
|
};
|
|
}>(mockFinalizeInboundContext, 0, 0);
|
|
expect(context.SupplementalContext?.thread?.starterBody).toBeUndefined();
|
|
expect(context.SupplementalContext?.thread?.historyBody).toBeUndefined();
|
|
expect(context.SupplementalContext?.thread?.label).toBe("Feishu thread in oc-group");
|
|
expect(context.MessageThreadId).toBe("om_topic_root");
|
|
});
|
|
|
|
it("keeps sender-scoped thread history when the inbound event and thread history use different sender ids", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
mockGetMessageFeishu.mockResolvedValue({
|
|
messageId: "om_topic_root",
|
|
chatId: "oc-group",
|
|
content: "root starter",
|
|
contentType: "text",
|
|
threadId: "omt_topic_1",
|
|
});
|
|
mockListFeishuThreadMessages.mockResolvedValue([
|
|
{
|
|
messageId: "om_bot_reply",
|
|
senderId: "app_1",
|
|
senderType: "app",
|
|
content: "assistant reply",
|
|
contentType: "text",
|
|
createTime: 1710000000000,
|
|
},
|
|
{
|
|
messageId: "om_follow_up",
|
|
senderId: "user_topic_1",
|
|
senderType: "user",
|
|
content: "follow-up question",
|
|
contentType: "text",
|
|
createTime: 1710000001000,
|
|
},
|
|
]);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
groupSessionScope: "group_topic_sender",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-topic-user",
|
|
user_id: "user_topic_1",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "om_topic_followup_mixed_ids",
|
|
root_id: "om_topic_root",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "current turn" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
const context = mockCallArg<{
|
|
MessageThreadId?: string;
|
|
SupplementalContext?: {
|
|
thread?: { historyBody?: string; label?: string; starterBody?: string };
|
|
};
|
|
}>(mockFinalizeInboundContext, 0, 0);
|
|
expect(context.SupplementalContext?.thread?.starterBody).toBe("root starter");
|
|
expect(context.SupplementalContext?.thread?.historyBody).toBe(
|
|
"assistant reply\n\nfollow-up question",
|
|
);
|
|
expect(context.SupplementalContext?.thread?.label).toBe("Feishu thread in oc-group");
|
|
expect(context.MessageThreadId).toBe("om_topic_root");
|
|
});
|
|
|
|
it("filters topic bootstrap context to allowlisted group senders", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
mockGetMessageFeishu.mockResolvedValue({
|
|
messageId: "om_topic_root",
|
|
chatId: "oc-group",
|
|
senderId: "ou-blocked",
|
|
senderType: "user",
|
|
content: "blocked root starter",
|
|
contentType: "text",
|
|
threadId: "omt_topic_1",
|
|
});
|
|
mockListFeishuThreadMessages.mockResolvedValue([
|
|
{
|
|
messageId: "om_blocked_reply",
|
|
senderId: "ou-blocked",
|
|
senderType: "user",
|
|
content: "blocked follow-up",
|
|
contentType: "text",
|
|
createTime: 1710000000000,
|
|
},
|
|
{
|
|
messageId: "om_bot_reply",
|
|
senderId: "app_1",
|
|
senderType: "app",
|
|
content: "assistant reply",
|
|
contentType: "text",
|
|
createTime: 1710000001000,
|
|
},
|
|
{
|
|
messageId: "om_allowed_reply",
|
|
senderId: "ou-allowed",
|
|
senderType: "user",
|
|
content: "allowed follow-up",
|
|
contentType: "text",
|
|
createTime: 1710000002000,
|
|
},
|
|
]);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
groupPolicy: "open",
|
|
groupSenderAllowFrom: ["ou-allowed"],
|
|
contextVisibility: "allowlist",
|
|
groups: {
|
|
"oc-group": {
|
|
requireMention: false,
|
|
groupSessionScope: "group_topic",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: { sender_id: { open_id: "ou-allowed" } },
|
|
message: {
|
|
message_id: "om_topic_followup_allowlisted",
|
|
root_id: "om_topic_root",
|
|
thread_id: "omt_topic_1",
|
|
chat_id: "oc-group",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "current turn" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
const context = mockCallArg<{
|
|
SupplementalContext?: { thread?: { historyBody?: string; starterBody?: string } };
|
|
}>(mockFinalizeInboundContext, 0, 0);
|
|
expect(context.SupplementalContext?.thread?.starterBody).toBe("assistant reply");
|
|
expect(context.SupplementalContext?.thread?.historyBody).toBe(
|
|
"assistant reply\n\nallowed follow-up",
|
|
);
|
|
});
|
|
|
|
it("does not dispatch twice for the same image message_id (concurrent dedupe)", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
dmPolicy: "open",
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-image-dedup",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-image-dedup",
|
|
chat_id: "oc-dm",
|
|
chat_type: "p2p",
|
|
message_type: "image",
|
|
content: JSON.stringify({
|
|
image_key: "img_dedup_payload",
|
|
}),
|
|
},
|
|
};
|
|
|
|
await Promise.all([dispatchMessage({ cfg, event }), dispatchMessage({ cfg, event })]);
|
|
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("dedupes Feishu media by message_id plus file_key", async () => {
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
dmPolicy: "open",
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
const createAudioEvent = (fileKey: string): FeishuMessageEvent => ({
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-audio-dedup",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-audio-reused-id",
|
|
chat_id: "oc-dm",
|
|
chat_type: "p2p",
|
|
message_type: "audio",
|
|
content: JSON.stringify({
|
|
file_key: fileKey,
|
|
duration: 1200,
|
|
}),
|
|
},
|
|
});
|
|
|
|
await dispatchMessage({ cfg, event: createAudioEvent("file_audio_first") });
|
|
await dispatchMessage({ cfg, event: createAudioEvent("file_audio_second") });
|
|
await dispatchMessage({ cfg, event: createAudioEvent("file_audio_first") });
|
|
|
|
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2);
|
|
expect(mockDownloadMessageResourceFeishu).toHaveBeenCalledTimes(2);
|
|
const firstDownloadRequest = mockCallArg<{
|
|
fileKey?: string;
|
|
messageId?: string;
|
|
type?: string;
|
|
}>(mockDownloadMessageResourceFeishu, 0, 0);
|
|
expect(firstDownloadRequest.messageId).toBe("msg-audio-reused-id");
|
|
expect(firstDownloadRequest.fileKey).toBe("file_audio_first");
|
|
expect(firstDownloadRequest.type).toBe("file");
|
|
const secondDownloadRequest = mockCallArg<{
|
|
fileKey?: string;
|
|
messageId?: string;
|
|
type?: string;
|
|
}>(mockDownloadMessageResourceFeishu, 1, 0);
|
|
expect(secondDownloadRequest.messageId).toBe("msg-audio-reused-id");
|
|
expect(secondDownloadRequest.fileKey).toBe("file_audio_second");
|
|
expect(secondDownloadRequest.type).toBe("file");
|
|
});
|
|
|
|
it("skips empty-text messages with no media to prevent blank user turns in session (#74634)", async () => {
|
|
// Feishu can deliver { "text": "" } events (empty-text or media-stripped
|
|
// messages). Writing blank user content to the session causes downstream
|
|
// LLM providers such as MiniMax to reject requests with "messages must not
|
|
// be empty". The handler should drop such events before queuing a reply.
|
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
channels: {
|
|
feishu: {
|
|
dmPolicy: "open",
|
|
allowFrom: ["*"],
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
|
|
const event: FeishuMessageEvent = {
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-empty-text-sender",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-empty-text-74634",
|
|
chat_id: "oc-dm",
|
|
chat_type: "p2p",
|
|
message_type: "text",
|
|
// Feishu encodes empty text as {"text":""}
|
|
content: JSON.stringify({ text: "" }),
|
|
},
|
|
};
|
|
|
|
await dispatchMessage({ cfg, event });
|
|
|
|
// No reply should be dispatched: empty message is silently skipped
|
|
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("createFeishuMessageReceiveHandler media dedupe", () => {
|
|
it("keeps same-id media variants distinct at receive time", async () => {
|
|
const handleMessage = vi.fn(async () => undefined);
|
|
const core = {
|
|
channel: {
|
|
debounce: {
|
|
resolveInboundDebounceMs: vi.fn(() => 0),
|
|
createInboundDebouncer: vi.fn(
|
|
(options: { onFlush: (entries: FeishuMessageEvent[]) => Promise<void> | void }) => ({
|
|
enqueue: async (event: FeishuMessageEvent) => {
|
|
await options.onFlush([event]);
|
|
},
|
|
}),
|
|
),
|
|
},
|
|
text: {
|
|
hasControlCommand: vi.fn(() => false),
|
|
},
|
|
},
|
|
} as unknown as PluginRuntime;
|
|
const createAudioEvent = (fileKey: string): FeishuMessageEvent => ({
|
|
sender: {
|
|
sender_id: {
|
|
open_id: "ou-audio-receive-dedup",
|
|
},
|
|
},
|
|
message: {
|
|
message_id: "msg-audio-receive-reused-id",
|
|
chat_id: "oc-dm",
|
|
chat_type: "p2p",
|
|
message_type: "audio",
|
|
content: JSON.stringify({
|
|
file_key: fileKey,
|
|
duration: 1200,
|
|
}),
|
|
},
|
|
});
|
|
const handler = createFeishuMessageReceiveHandler({
|
|
cfg: { channels: { feishu: { dmPolicy: "open" } } } as ClawdbotConfig,
|
|
channelRuntime: core.channel,
|
|
accountId: "receive-media-dedupe",
|
|
chatHistories: new Map(),
|
|
handleMessage,
|
|
resolveDebounceText: () => "",
|
|
hasProcessedMessage: vi.fn(async () => false),
|
|
recordProcessedMessage: vi.fn(async () => true),
|
|
});
|
|
|
|
const firstEvent = createAudioEvent("file_audio_receive_first");
|
|
const secondEvent = createAudioEvent("file_audio_receive_second");
|
|
await handler(firstEvent);
|
|
await handler(secondEvent);
|
|
await handler(createAudioEvent("file_audio_receive_first"));
|
|
|
|
expect(handleMessage).toHaveBeenCalledTimes(2);
|
|
const firstCall = mockCallArg<{
|
|
event?: FeishuMessageEvent;
|
|
processingClaimHeld?: boolean;
|
|
}>(handleMessage, 0, 0);
|
|
expect(firstCall.event).toEqual(firstEvent);
|
|
expect(firstCall.processingClaimHeld).toBe(true);
|
|
const secondCall = mockCallArg<{
|
|
event?: FeishuMessageEvent;
|
|
processingClaimHeld?: boolean;
|
|
}>(handleMessage, 1, 0);
|
|
expect(secondCall.event).toEqual(secondEvent);
|
|
expect(secondCall.processingClaimHeld).toBe(true);
|
|
});
|
|
});
|