mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
* feat(secrets): expand secret target coverage and gateway tooling * docs(secrets): align gateway and CLI secret docs * chore(protocol): regenerate swift gateway models for secrets methods * fix(config): restore talk apiKey fallback and stabilize runner test * ci(windows): reduce test worker count for shard stability * ci(windows): raise node heap for test shard stability * test(feishu): make proxy env precedence assertion windows-safe * fix(gateway): resolve auth password SecretInput refs for clients * fix(gateway): resolve remote SecretInput credentials for clients * fix(secrets): skip inactive refs in command snapshot assignments * fix(secrets): scope gateway.remote refs to effective auth surfaces * fix(secrets): ignore memory defaults when enabled agents disable search * fix(secrets): honor Google Chat serviceAccountRef inheritance * fix(secrets): address tsgo errors in command and gateway collectors * fix(secrets): avoid auth-store load in providers-only configure * fix(gateway): defer local password ref resolution by precedence * fix(secrets): gate telegram webhook secret refs by webhook mode * fix(secrets): gate slack signing secret refs to http mode * fix(secrets): skip telegram botToken refs when tokenFile is set * fix(secrets): gate discord pluralkit refs by enabled flag * fix(secrets): gate discord voice tts refs by voice enabled * test(secrets): make runtime fixture modes explicit * fix(cli): resolve local qr password secret refs * fix(cli): fail when gateway leaves command refs unresolved * fix(gateway): fail when local password SecretRef is unresolved * fix(gateway): fail when required remote SecretRefs are unresolved * fix(gateway): resolve local password refs only when password can win * fix(cli): skip local password SecretRef resolution on qr token override * test(gateway): cast SecretRef fixtures to OpenClawConfig * test(secrets): activate mode-gated targets in runtime coverage fixture * fix(cron): support SecretInput webhook tokens safely * fix(bluebubbles): support SecretInput passwords across config paths * fix(msteams): make appPassword SecretInput-safe in onboarding/token paths * fix(bluebubbles): align SecretInput schema helper typing * fix(cli): clarify secrets.resolve version-skew errors * refactor(secrets): return structured inactive paths from secrets.resolve * refactor(gateway): type onboarding secret writes as SecretInput * chore(protocol): regenerate swift models for secrets.resolve * feat(secrets): expand extension credential secretref support * fix(secrets): gate web-search refs by active provider * fix(onboarding): detect SecretRef credentials in extension status * fix(onboarding): allow keeping existing ref in secret prompt * fix(onboarding): resolve gateway password SecretRefs for probe and tui * fix(onboarding): honor secret-input-mode for local gateway auth * fix(acp): resolve gateway SecretInput credentials * fix(secrets): gate gateway.remote refs to remote surfaces * test(secrets): cover pattern matching and inactive array refs * docs(secrets): clarify secrets.resolve and remote active surfaces * fix(bluebubbles): keep existing SecretRef during onboarding * fix(tests): resolve CI type errors in new SecretRef coverage * fix(extensions): replace raw fetch with SSRF-guarded fetch * test(secrets): mark gateway remote targets active in runtime coverage * test(infra): normalize home-prefix expectation across platforms * fix(cli): only resolve local qr password refs in password mode * test(cli): cover local qr token mode with unresolved password ref * docs(cli): clarify local qr password ref resolution behavior * refactor(extensions): reuse sdk SecretInput helpers * fix(wizard): resolve onboarding env-template secrets before plaintext * fix(cli): surface secrets.resolve diagnostics in memory and qr * test(secrets): repair post-rebase runtime and fixtures * fix(gateway): skip remote password ref resolution when token wins * fix(secrets): treat tailscale remote gateway refs as active * fix(gateway): allow remote password fallback when token ref is unresolved * fix(gateway): ignore stale local password refs for none and trusted-proxy * fix(gateway): skip remote secret ref resolution on local call paths * test(cli): cover qr remote tailscale secret ref resolution * fix(secrets): align gateway password active-surface with auth inference * fix(cli): resolve inferred local gateway password refs in qr * fix(gateway): prefer resolvable remote password over token ref pre-resolution * test(gateway): cover none and trusted-proxy stale password refs * docs(secrets): sync qr and gateway active-surface behavior * fix: restore stability blockers from pre-release audit * Secrets: fix collector/runtime precedence contradictions * docs: align secrets and web credential docs * fix(rebase): resolve integration regressions after main rebase * fix(node-host): resolve gateway secret refs for auth * fix(secrets): harden secretinput runtime readers * gateway: skip inactive auth secretref resolution * cli: avoid gateway preflight for inactive secret refs * extensions: allow unresolved refs in onboarding status * tests: fix qr-cli module mock hoist ordering * Security: align audit checks with SecretInput resolution * Gateway: resolve local-mode remote fallback secret refs * Node host: avoid resolving inactive password secret refs * Secrets runtime: mark Slack appToken inactive for HTTP mode * secrets: keep inactive gateway remote refs non-blocking * cli: include agent memory secret targets in runtime resolution * docs(secrets): sync docs with active-surface and web search behavior * fix(secrets): keep telegram top-level token refs active for blank account tokens * fix(daemon): resolve gateway password secret refs for probe auth * fix(secrets): skip IRC NickServ ref resolution when NickServ is disabled * fix(secrets): align token inheritance and exec timeout defaults * docs(secrets): clarify active-surface notes in cli docs * cli: require secrets.resolve gateway capability * gateway: log auth secret surface diagnostics * secrets: remove dead provider resolver module * fix(secrets): restore gateway auth precedence and fallback resolution * fix(tests): align plugin runtime mock typings --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
1601 lines
44 KiB
TypeScript
1601 lines
44 KiB
TypeScript
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
|
|
import type { FeishuMessageEvent } from "./bot.js";
|
|
import { buildFeishuAgentBody, handleFeishuMessage, toMessageResourceType } from "./bot.js";
|
|
import { setFeishuRuntime } from "./runtime.js";
|
|
|
|
const {
|
|
mockCreateFeishuReplyDispatcher,
|
|
mockSendMessageFeishu,
|
|
mockGetMessageFeishu,
|
|
mockDownloadMessageResourceFeishu,
|
|
mockCreateFeishuClient,
|
|
mockResolveAgentRoute,
|
|
} = vi.hoisted(() => ({
|
|
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
|
|
dispatcher: vi.fn(),
|
|
replyOptions: {},
|
|
markDispatchIdle: vi.fn(),
|
|
})),
|
|
mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }),
|
|
mockGetMessageFeishu: vi.fn().mockResolvedValue(null),
|
|
mockDownloadMessageResourceFeishu: vi.fn().mockResolvedValue({
|
|
buffer: Buffer.from("video"),
|
|
contentType: "video/mp4",
|
|
fileName: "clip.mp4",
|
|
}),
|
|
mockCreateFeishuClient: vi.fn(),
|
|
mockResolveAgentRoute: vi.fn(() => ({
|
|
agentId: "main",
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
sessionKey: "agent:main:feishu:dm:ou-attacker",
|
|
mainSessionKey: "agent:main:main",
|
|
matchedBy: "default",
|
|
})),
|
|
}));
|
|
|
|
vi.mock("./reply-dispatcher.js", () => ({
|
|
createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher,
|
|
}));
|
|
|
|
vi.mock("./send.js", () => ({
|
|
sendMessageFeishu: mockSendMessageFeishu,
|
|
getMessageFeishu: mockGetMessageFeishu,
|
|
}));
|
|
|
|
vi.mock("./media.js", () => ({
|
|
downloadMessageResourceFeishu: mockDownloadMessageResourceFeishu,
|
|
}));
|
|
|
|
vi.mock("./client.js", () => ({
|
|
createFeishuClient: mockCreateFeishuClient,
|
|
}));
|
|
|
|
function createRuntimeEnv(): RuntimeEnv {
|
|
return {
|
|
log: vi.fn(),
|
|
error: vi.fn(),
|
|
exit: vi.fn((code: number): never => {
|
|
throw new Error(`exit ${code}`);
|
|
}),
|
|
} as RuntimeEnv;
|
|
}
|
|
|
|
async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessageEvent }) {
|
|
await handleFeishuMessage({
|
|
cfg: params.cfg,
|
|
event: params.event,
|
|
runtime: createRuntimeEnv(),
|
|
});
|
|
}
|
|
|
|
describe("buildFeishuAgentBody", () => {
|
|
it("builds message id, speaker, quoted content, mentions, and permission notice in order", () => {
|
|
const body = buildFeishuAgentBody({
|
|
ctx: {
|
|
content: "hello world",
|
|
senderName: "Sender Name",
|
|
senderOpenId: "ou-sender",
|
|
messageId: "msg-42",
|
|
mentionTargets: [{ openId: "ou-target", name: "Target User", key: "@_user_1" }],
|
|
},
|
|
quotedContent: "previous message",
|
|
permissionErrorForAgent: {
|
|
code: 99991672,
|
|
message: "permission denied",
|
|
grantUrl: "https://open.feishu.cn/app/cli_test",
|
|
},
|
|
});
|
|
|
|
expect(body).toBe(
|
|
'[message_id: msg-42]\nSender Name: [Replying to: "previous message"]\n\nhello world\n\n[System: Your reply will automatically @mention: Target User. Do not write @xxx yourself.]\n\n[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: https://open.feishu.cn/app/cli_test]',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("handleFeishuMessage command authorization", () => {
|
|
const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx);
|
|
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);
|
|
mockResolveAgentRoute.mockReturnValue({
|
|
agentId: "main",
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
sessionKey: "agent:main:feishu:dm:ou-attacker",
|
|
mainSessionKey: "agent:main:main",
|
|
matchedBy: "default",
|
|
});
|
|
mockCreateFeishuClient.mockReturnValue({
|
|
contact: {
|
|
user: {
|
|
get: vi.fn().mockResolvedValue({ data: { user: { name: "Sender" } } }),
|
|
},
|
|
},
|
|
});
|
|
mockEnqueueSystemEvent.mockReset();
|
|
setFeishuRuntime(
|
|
createPluginRuntimeMock({
|
|
system: {
|
|
enqueueSystemEvent: mockEnqueueSystemEvent,
|
|
},
|
|
channel: {
|
|
routing: {
|
|
resolveAgentRoute:
|
|
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
|
|
},
|
|
reply: {
|
|
resolveEnvelopeFormatOptions: vi.fn(
|
|
() => ({}),
|
|
) as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
|
|
formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
|
|
finalizeInboundContext:
|
|
mockFinalizeInboundContext as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
|
dispatchReplyFromConfig: mockDispatchReplyFromConfig,
|
|
withReplyDispatcher:
|
|
mockWithReplyDispatcher as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
|
|
},
|
|
commands: {
|
|
shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
|
|
resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
|
|
},
|
|
media: {
|
|
saveMediaBuffer:
|
|
mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
|
|
},
|
|
pairing: {
|
|
readAllowFromStore: mockReadAllowFromStore,
|
|
upsertPairingRequest: mockUpsertPairingRequest,
|
|
buildPairingReply: mockBuildPairingReply,
|
|
},
|
|
},
|
|
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("uses authorizer resolution instead of hardcoded CommandAuthorized=true", 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).toHaveBeenCalledWith({
|
|
useAccessGroups: true,
|
|
authorizers: [{ configured: true, allowed: false }],
|
|
});
|
|
expect(mockFinalizeInboundContext).toHaveBeenCalledTimes(1);
|
|
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
CommandAuthorized: false,
|
|
SenderId: "ou-attacker",
|
|
Surface: "feishu",
|
|
}),
|
|
);
|
|
});
|
|
|
|
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 });
|
|
|
|
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
ReplyToId: "om_parent_001",
|
|
RootMessageId: "om_root_001",
|
|
ReplyToBody: "quoted content",
|
|
}),
|
|
);
|
|
});
|
|
|
|
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 });
|
|
|
|
expect(mockSendMessageFeishu).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
to: "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(mockBuildPairingReply).toHaveBeenCalledWith({
|
|
channel: "feishu",
|
|
idLine: "Your Feishu user id: ou-unapproved",
|
|
code: "ABCDEFGH",
|
|
});
|
|
expect(mockSendMessageFeishu).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
to: "chat:oc-dm",
|
|
accountId: "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).toHaveBeenCalledWith({
|
|
useAccessGroups: true,
|
|
authorizers: [{ configured: false, allowed: false }],
|
|
});
|
|
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
ChatType: "group",
|
|
CommandAuthorized: false,
|
|
SenderId: "ou-attacker",
|
|
}),
|
|
);
|
|
});
|
|
|
|
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).toHaveBeenCalledWith({
|
|
useAccessGroups: true,
|
|
authorizers: [{ configured: true, allowed: true }],
|
|
});
|
|
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
ChatType: "group",
|
|
CommandAuthorized: true,
|
|
SenderId: "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 });
|
|
|
|
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
ChatType: "group",
|
|
SenderId: "ou-allowed",
|
|
}),
|
|
);
|
|
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 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("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 });
|
|
|
|
expect(mockDownloadMessageResourceFeishu).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
messageId: "msg-video-inbound",
|
|
fileKey: "file_video_payload",
|
|
type: "file",
|
|
}),
|
|
);
|
|
expect(mockSaveMediaBuffer).toHaveBeenCalledWith(
|
|
expect.any(Buffer),
|
|
"video/mp4",
|
|
"inbound",
|
|
expect.any(Number),
|
|
"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 });
|
|
|
|
expect(mockDownloadMessageResourceFeishu).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
messageId: "msg-media-inbound",
|
|
fileKey: "file_media_payload",
|
|
type: "file",
|
|
}),
|
|
);
|
|
expect(mockSaveMediaBuffer).toHaveBeenCalledWith(
|
|
expect.any(Buffer),
|
|
"video/mp4",
|
|
"inbound",
|
|
expect.any(Number),
|
|
"clip.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 });
|
|
|
|
expect(mockDownloadMessageResourceFeishu).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
messageId: "msg-post-media",
|
|
fileKey: "file_post_media_payload",
|
|
type: "file",
|
|
}),
|
|
);
|
|
expect(mockSaveMediaBuffer).toHaveBeenCalledWith(
|
|
expect.any(Buffer),
|
|
"video/mp4",
|
|
"inbound",
|
|
expect.any(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 });
|
|
|
|
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
BodyForAgent: "[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,
|
|
},
|
|
},
|
|
});
|
|
|
|
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" },
|
|
});
|
|
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
BodyForAgent: expect.stringContaining(
|
|
"[Merged and Forwarded Messages]\n- alpha\n- [File: report.pdf]",
|
|
),
|
|
}),
|
|
);
|
|
});
|
|
|
|
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 });
|
|
|
|
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
BodyForAgent: expect.stringContaining("[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",
|
|
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);
|
|
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
BodyForAgent: expect.stringContaining(
|
|
"Permission grant URL: https://open.feishu.cn/app/cli_test",
|
|
),
|
|
}),
|
|
);
|
|
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
BodyForAgent: expect.stringContaining("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",
|
|
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);
|
|
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
BodyForAgent: expect.not.stringContaining("Permission grant URL"),
|
|
}),
|
|
);
|
|
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
BodyForAgent: expect.stringContaining("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 });
|
|
|
|
expect(mockResolveAgentRoute).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
peer: { kind: "group", id: "oc-group:sender:ou-scope-user" },
|
|
parentPeer: null,
|
|
}),
|
|
);
|
|
});
|
|
|
|
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 });
|
|
|
|
expect(mockResolveAgentRoute).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
peer: { kind: "group", id: "oc-group:topic:om_root_topic:sender:ou-topic-user" },
|
|
parentPeer: { 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 });
|
|
|
|
expect(mockResolveAgentRoute).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
peer: { kind: "group", id: "oc-group:topic:om_root_topic:sender:ou-topic-user" },
|
|
parentPeer: { 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 });
|
|
|
|
expect(mockResolveAgentRoute).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
peer: { kind: "group", id: "oc-group:topic:omt_topic_1:sender:ou-topic-user" },
|
|
parentPeer: { 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 });
|
|
|
|
expect(mockResolveAgentRoute).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
peer: { kind: "group", id: "oc-group:topic:om_root_legacy" },
|
|
parentPeer: { 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 });
|
|
|
|
expect(mockResolveAgentRoute).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
peer: { kind: "group", id: "oc-group:topic:om_root_legacy" },
|
|
parentPeer: { 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 });
|
|
|
|
expect(mockResolveAgentRoute).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
peer: { kind: "group", id: "oc-group:topic:msg-new-topic-root" },
|
|
parentPeer: { 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 });
|
|
|
|
expect(mockResolveAgentRoute).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({
|
|
peer: { kind: "group", id: "oc-group:topic:msg-topic-first" },
|
|
}),
|
|
);
|
|
expect(mockResolveAgentRoute).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.objectContaining({
|
|
peer: { kind: "group", id: "oc-group:topic:msg-topic-first" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
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 });
|
|
|
|
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
replyToMessageId: "om_root_topic",
|
|
rootId: "om_root_topic",
|
|
}),
|
|
);
|
|
});
|
|
|
|
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 });
|
|
|
|
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
replyInThread: true,
|
|
threadReply: true,
|
|
}),
|
|
);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe("toMessageResourceType", () => {
|
|
it("maps image to image", () => {
|
|
expect(toMessageResourceType("image")).toBe("image");
|
|
});
|
|
|
|
it("maps audio to file", () => {
|
|
expect(toMessageResourceType("audio")).toBe("file");
|
|
});
|
|
|
|
it("maps video/file/sticker to file", () => {
|
|
expect(toMessageResourceType("video")).toBe("file");
|
|
expect(toMessageResourceType("file")).toBe("file");
|
|
expect(toMessageResourceType("sticker")).toBe("file");
|
|
});
|
|
});
|