Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/gateway/channel-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ export interface ChannelAccessDenied {
walled: true;
reason?: string;
authorizeUrl?: string;
/**
* The bot's reach is `group_only`, so the refused sender has no DM to be sent
* to. An EXTRA FIELD rather than a new `reason` on purpose: reason is the
* contract this runtime branches on and localizes, so a new value would drop
* an un-updated build into its generic fallback — whereas an unknown boolean
* is simply ignored and that build keeps behaving as before.
*/
dmDisabled?: boolean;
}

export function isChannelAccessDenied(
Expand Down
115 changes: 13 additions & 102 deletions src/gateway/channels/lark.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,121 +338,32 @@ describe("handleLarkMessage — payload shape guards", () => {
});
});

describe("handleLarkMessage — PAIR command", () => {
it("matches /PAIR XXXXXX/ and routes to handlePairingCode; replies with success message", async () => {
handlePairingCodeMock.mockResolvedValue({ success: true, agentName: "SRE Bot" });
describe("handleLarkMessage — retired group PAIR", () => {
// Group pairing is gone: a group is served by the dedicated app that was added
// to it, and by nothing else. The command word is swallowed here rather than
// forwarded, so a stale code typed into a group neither binds anything nor
// produces a refusal the room did not ask for.
it("ignores a group PAIR code without calling the frontend or replying", async () => {
const larkClient = makeLarkClient();
const data = makeTextEvent("PAIR ABC123");

await handleLarkMessage(data, larkClient, "lark", makeAgentBoxManager() as any, undefined, {} as any);

expect(handlePairingCodeMock).toHaveBeenCalledWith("ABC123", "lark", "oc_abc123", "group", expect.anything(), undefined);
expect(larkClient.im.message.reply).toHaveBeenCalledWith(expect.objectContaining({
path: { message_id: "mid-1" },
data: expect.objectContaining({
content: expect.stringContaining("SRE Bot"),
}),
}));
expect(handlePairingCodeMock).not.toHaveBeenCalled();
expect(larkClient.im.message.reply).not.toHaveBeenCalled();
// And it is not forwarded to the agent either — the text is a command word,
// not a prompt.
expect(resolveBindingMock).not.toHaveBeenCalled();
});

it("replies with error when pairing fails", async () => {
handlePairingCodeMock.mockResolvedValue({ success: false, error: "Invalid or expired code" });
it("still ignores it case-insensitively, the way the old matcher accepted it", async () => {
const larkClient = makeLarkClient();
const data = makeTextEvent("PAIR DEADBE");

await handleLarkMessage(data, larkClient, "lark", makeAgentBoxManager() as any, undefined, {} as any);

const replyArg = larkClient.im.message.reply.mock.calls[0][0];
expect(replyArg.data.content).toContain("Invalid or expired code");
});

it("upper-cases the pair code before sending — case-insensitive regex", async () => {
handlePairingCodeMock.mockResolvedValue({ success: true, agentName: "n" });
const data = makeTextEvent("pair abc123");
await handleLarkMessage(data, makeLarkClient(), "lark", makeAgentBoxManager() as any, undefined, {} as any);
expect(handlePairingCodeMock.mock.calls[0][0]).toBe("ABC123");
});

it("uses group_channel_id for group PAIR when the same handler also has a personal bot", async () => {
handlePairingCodeMock.mockResolvedValue({ success: true, agentName: "SRE Bot" });
await handleLarkMessage(
makeTextEvent("PAIR ABC123"),
makeLarkClient(),
"lark-runtime",
makeAgentBoxManager() as any,
undefined,
{} as any,
"zh-CN",
{
app_id: "cli_shared",
app_secret: "secret",
group_channel_id: "lark",
personal_bot: {
channel_id: "pb-1",
agent_id: "a1",
access_mode: "open",
owner_user_id: "owner-1",
},
},
);

expect(handlePairingCodeMock).toHaveBeenCalledWith("ABC123", "lark", "oc_abc123", "group", expect.anything(), undefined);
expect(handlePersonalPairingCodeMock).not.toHaveBeenCalled();
});

it("seeds the binding display name from the fetched group title", async () => {
handlePairingCodeMock.mockResolvedValue({ success: true, agentName: "SRE Bot" });
const larkClient = makeLarkClient() as any;
larkClient.request = vi.fn().mockResolvedValue({ data: { name: " 运维告警群 " } });

await handleLarkMessage(makeTextEvent("PAIR ABC123"), larkClient, "lark", makeAgentBoxManager() as any, undefined, {} as any);

expect(larkClient.request).toHaveBeenCalledWith(expect.objectContaining({
url: expect.stringContaining("/open-apis/im/v1/chats/oc_abc123"),
}));
expect(handlePairingCodeMock).toHaveBeenCalledWith("ABC123", "lark", "oc_abc123", "group", expect.anything(), "运维告警群");
});

it("PAIR success reply is Chinese for zh-CN (feishu domain)", async () => {
handlePairingCodeMock.mockResolvedValue({ success: true, agentName: "SRE Bot" });
const lark = makeLarkClient();
await handleLarkMessage(
makeTextEvent("PAIR ABC123"),
lark,
"lark",
makeAgentBoxManager() as any,
undefined,
{} as any,
"zh-CN",
);
const replyArg = lark.im.message.reply.mock.calls[0][0];
expect(replyArg.data.content).toContain("绑定成功");
expect(replyArg.data.content).toContain("SRE Bot");
});

it("PAIR success reply is English for en-US (lark domain)", async () => {
handlePairingCodeMock.mockResolvedValue({ success: true, agentName: "SRE Bot" });
const lark = makeLarkClient();
await handleLarkMessage(
makeTextEvent("PAIR ABC123"),
lark,
"lark",
makeAgentBoxManager() as any,
undefined,
{} as any,
"en-US",
);
const replyArg = lark.im.message.reply.mock.calls[0][0];
expect(replyArg.data.content).toContain("Paired!");
});
await handleLarkMessage(data, larkClient, "lark", makeAgentBoxManager() as any, undefined, {} as any);

it("codes shorter or longer than 6 chars are not matched", async () => {
const data5 = makeTextEvent("PAIR AB12E"); // 5 chars
const data7 = makeTextEvent("PAIR AB12EF3"); // 7 chars
await handleLarkMessage(data5, makeLarkClient(), "lark", makeAgentBoxManager() as any, undefined, {} as any);
await handleLarkMessage(data7, makeLarkClient(), "lark", makeAgentBoxManager() as any, undefined, {} as any);
expect(handlePairingCodeMock).not.toHaveBeenCalled();
expect(larkClient.im.message.reply).not.toHaveBeenCalled();
});
});

Expand Down
59 changes: 45 additions & 14 deletions src/gateway/channels/lark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ const MISSING_OWNER_NOTICE_BY_LOCALE = {
"en-US": "❌ This group binding is missing a session owner. Generate a fresh PAIR code from the Agent page and pair this group again.",
} as const;

// reach=group_only: this bot answers in groups only.
const GROUP_ONLY_DM_NOTICE_BY_LOCALE = {
"zh-CN": "这个助手只在群里使用,请到对应的群里 @ 我。",
"en-US": "This assistant is only available in group chats — @ me in the group instead.",
} as const;

// Gated group, sender's account not linked yet.
const GROUP_ACCESS_UNBOUND_NOTICE_BY_LOCALE = {
"zh-CN": "❌ 你的账号还没完成关联,暂时无法在群里使用这个助手。",
Expand Down Expand Up @@ -317,7 +323,12 @@ export interface LarkChannelConfig {
access_mode: "open" | "public" | "identified" | "granted" | "platform_authorized" | (string & {});
owner_user_id?: string;
authorize_url?: string;
group_auto_bind?: boolean;
// Where this bot may be used, independent of access_mode's WHO.
// `group_only` means DMs are closed — every DM entry point is refused by the
// frontend regardless, so this is a local shortcut that saves a round trip,
// never the authority. Absent / unrecognized means both, matching the
// frontend's NormalizeReach.
reach?: "both" | "group_only" | (string & {});
};
}

Expand Down Expand Up @@ -1199,6 +1210,19 @@ export async function handleLarkMessage(
console.log(`[lark] Ignoring p2p message for non-personal channel=${channelId}`);
return;
}
// reach=group_only closes every DM entry point, commands included. The
// frontend refuses these independently (adapter/rpc.go's personalDM
// registrar is the authority); declining here just saves the round trip.
//
// One short reply rather than silence: unlike a group, a DM is a place the
// sender deliberately opened, and leaving it unanswered reads as the bot
// being broken. It carries no link — the only link this system mints is
// redeemed in a DM, which is exactly what is closed.
if (personalBot.reach === "group_only") {
console.log(`[lark] DM refused channel=${personalChannelId} sender=${senderOpenId} — reach=group_only`);
await replyToLark(larkClient, messageId, GROUP_ONLY_DM_NOTICE_BY_LOCALE[locale]);
return;
}
if (!senderOpenId) {
await replyToLark(larkClient, messageId, "❌ Missing Feishu sender open_id.");
return;
Expand Down Expand Up @@ -1490,16 +1514,14 @@ export async function handleLarkMessage(
return;
}

// Check for PAIR command
const pairMatch = text.match(/^PAIR\s+([A-Z0-9]{6})$/i);
if (pairMatch) {
const code = pairMatch[1].toUpperCase();
// Seed the binding's display name with the group title (best-effort).
const chatName = await fetchLarkChatName(larkClient, chatId);
const result = await handlePairingCode(code, groupChannelId, chatId, "group", frontendClient!, chatName ?? undefined);

const replyText = formatPairReply(result, locale);
await replyToLark(larkClient, messageId, replyText);
// Group PAIR is retired: a group is served by the dedicated app that was added
// to it, and by nothing else. The command word is dropped here rather than
// forwarded, so a stale code typed into a group is simply ignored instead of
// producing a refusal the room did not ask for. (The PERSONAL pairing code, in
// a DM, is untouched — that one links a Feishu identity to a Sicore account.)
// See sicore docs/design/feishu-bot-reach-and-pair-retirement.md.
if (/^PAIR\s+[A-Z0-9]{6}$/i.test(text.trim())) {
console.log(`[lark] group PAIR ignored chat=${chatId} — group pairing is retired`);
return;
}

Expand Down Expand Up @@ -1553,7 +1575,7 @@ export async function handleLarkMessage(
);
if (isChannelAccessDenied(modeBinding)) {
if (modeExistingOnly) return;
await replyToLark(larkClient, messageId, formatGroupAccessDeniedReply(modeBinding, locale, dmCanResolveAccess(personalBot)));
await replyToLark(larkClient, messageId, formatGroupAccessDeniedReply(modeBinding, locale, dmCanResolveAccess(personalBot, modeBinding)));
return;
}
if (!modeBinding) {
Expand Down Expand Up @@ -1665,7 +1687,7 @@ export async function handleLarkMessage(
if (unmentionedGroupMessage) return;
// Gated group: this sender isn't allowed. The message is either an explicit @ or a follow-up
// in a previously established bot topic, so a single short hint is appropriate.
await replyToLark(larkClient, messageId, formatGroupAccessDeniedReply(binding, locale, dmCanResolveAccess(personalBot)));
await replyToLark(larkClient, messageId, formatGroupAccessDeniedReply(binding, locale, dmCanResolveAccess(personalBot, binding)));
return;
}
if (!binding) {
Expand Down Expand Up @@ -2240,7 +2262,16 @@ function minutesUntil(expiresAtMs: number | undefined, now = Date.now()): number
* offers no authorization step, so "DM me" would be a dead end — and the console URL the group
* reply would otherwise carry was the only path they had.
*/
function dmCanResolveAccess(personalBot: LarkChannelConfig["personal_bot"]): boolean {
function dmCanResolveAccess(
personalBot: LarkChannelConfig["personal_bot"],
denied?: ChannelAccessDenied,
): boolean {
// `dmDisabled` wins over everything else here: on a group_only bot the private
// chat refuses this sender too, so "DM me to get authorized" is an instruction
// that cannot be followed. Read from the REFUSAL rather than from the local
// config so the answer comes from the same evaluation that produced it — a
// cached config could still say `both` moments after the owner closed DMs.
if (denied?.dmDisabled) return false;
return Boolean(personalBot) && !isOpenAccessTier(personalBot!.access_mode);
}

Expand Down
Loading