diff --git a/src/line/bot-handlers.test.ts b/src/line/bot-handlers.test.ts index 00f0082ed..a30f5308f 100644 --- a/src/line/bot-handlers.test.ts +++ b/src/line/bot-handlers.test.ts @@ -170,4 +170,169 @@ describe("handleLineWebhookEvents", () => { expect(processMessage).not.toHaveBeenCalled(); expect(buildLineMessageContextMock).not.toHaveBeenCalled(); }); + + it("allows group messages when per-group policy is open despite channel allowlist", async () => { + const processMessage = vi.fn(); + const event = { + type: "message", + message: { id: "m5", type: "text", text: "hi" }, + replyToken: "reply-token", + timestamp: Date.now(), + source: { type: "group", groupId: "group-open", userId: "user-5" }, + mode: "active", + webhookEventId: "evt-5", + deliveryContext: { isRedelivery: false }, + } as MessageEvent; + + await handleLineWebhookEvents([event], { + cfg: { channels: { line: { groupPolicy: "allowlist" } } }, + account: { + accountId: "default", + enabled: true, + channelAccessToken: "token", + channelSecret: "secret", + tokenSource: "config", + config: { + groupPolicy: "allowlist", + groups: { "group-open": { policy: "open" } }, + }, + }, + runtime: { error: vi.fn() }, + mediaMaxBytes: 1, + processMessage, + }); + + expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1); + expect(processMessage).toHaveBeenCalledTimes(1); + }); + + it("blocks group messages when per-group policy is disabled despite channel open", async () => { + const processMessage = vi.fn(); + const event = { + type: "message", + message: { id: "m6", type: "text", text: "hi" }, + replyToken: "reply-token", + timestamp: Date.now(), + source: { type: "group", groupId: "group-disabled", userId: "user-6" }, + mode: "active", + webhookEventId: "evt-6", + deliveryContext: { isRedelivery: false }, + } as MessageEvent; + + await handleLineWebhookEvents([event], { + cfg: { channels: { line: { groupPolicy: "open" } } }, + account: { + accountId: "default", + enabled: true, + channelAccessToken: "token", + channelSecret: "secret", + tokenSource: "config", + config: { + groupPolicy: "open", + groups: { "group-disabled": { policy: "disabled" } }, + }, + }, + runtime: { error: vi.fn() }, + mediaMaxBytes: 1, + processMessage, + }); + + expect(processMessage).not.toHaveBeenCalled(); + expect(buildLineMessageContextMock).not.toHaveBeenCalled(); + }); + + it("uses per-group allowlist with group-specific allowFrom", async () => { + const processMessage = vi.fn(); + const allowedEvent = { + type: "message", + message: { id: "m7", type: "text", text: "hi" }, + replyToken: "reply-token", + timestamp: Date.now(), + source: { type: "group", groupId: "group-restricted", userId: "allowed-user" }, + mode: "active", + webhookEventId: "evt-7", + deliveryContext: { isRedelivery: false }, + } as MessageEvent; + + const blockedEvent = { + type: "message", + message: { id: "m8", type: "text", text: "hi" }, + replyToken: "reply-token", + timestamp: Date.now(), + source: { type: "group", groupId: "group-restricted", userId: "blocked-user" }, + mode: "active", + webhookEventId: "evt-8", + deliveryContext: { isRedelivery: false }, + } as MessageEvent; + + const context = { + cfg: { channels: { line: { groupPolicy: "open" } } }, + account: { + accountId: "default", + enabled: true, + channelAccessToken: "token", + channelSecret: "secret", + tokenSource: "config", + config: { + groupPolicy: "open", + groups: { + "group-restricted": { + policy: "allowlist", + allowFrom: ["allowed-user"], + }, + }, + }, + }, + runtime: { error: vi.fn() }, + mediaMaxBytes: 1, + processMessage, + }; + + await handleLineWebhookEvents([allowedEvent], context); + expect(processMessage).toHaveBeenCalledTimes(1); + + processMessage.mockClear(); + buildLineMessageContextMock.mockClear(); + + await handleLineWebhookEvents([blockedEvent], context); + expect(processMessage).not.toHaveBeenCalled(); + }); + + it("falls back to channel-wide policy when no per-group policy is set", async () => { + const processMessage = vi.fn(); + const event = { + type: "message", + message: { id: "m9", type: "text", text: "hi" }, + replyToken: "reply-token", + timestamp: Date.now(), + source: { type: "group", groupId: "group-no-override", userId: "user-9" }, + mode: "active", + webhookEventId: "evt-9", + deliveryContext: { isRedelivery: false }, + } as MessageEvent; + + await handleLineWebhookEvents([event], { + cfg: { channels: { line: { groupPolicy: "open" } } }, + account: { + accountId: "default", + enabled: true, + channelAccessToken: "token", + channelSecret: "secret", + tokenSource: "config", + config: { + groupPolicy: "open", + groups: { + "group-other": { policy: "disabled" }, + }, + }, + }, + runtime: { error: vi.fn() }, + mediaMaxBytes: 1, + processMessage, + }); + + // group-no-override has no per-group policy, so channel-wide "open" applies + expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1); + expect(processMessage).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/line/bot-handlers.ts b/src/line/bot-handlers.ts index e3f1eaa48..7721b3f3a 100644 --- a/src/line/bot-handlers.ts +++ b/src/line/bot-handlers.ts @@ -151,27 +151,33 @@ async function shouldProcessLineEvent( }); const dmPolicy = account.config.dmPolicy ?? "pairing"; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const channelGroupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + // Per-group policy override: groups.C1234.policy takes precedence over channel-wide groupPolicy + const groupPolicy = groupConfig?.policy ?? channelGroupPolicy; if (isGroup) { if (groupConfig?.enabled === false) { logVerbose(`Blocked line group ${groupId ?? roomId ?? "unknown"} (group disabled)`); return false; } - if (typeof groupAllowOverride !== "undefined") { - if (!senderId) { - logVerbose("Blocked line group message (group allowFrom override, no sender ID)"); - return false; - } - if (!isSenderAllowed({ allow: effectiveGroupAllow, senderId })) { - logVerbose(`Blocked line group sender ${senderId} (group allowFrom override)`); - return false; - } - } if (groupPolicy === "disabled") { logVerbose("Blocked line group message (groupPolicy: disabled)"); return false; } + if (groupPolicy === "open") { + // Per-group allowFrom still applies even with open policy, when explicitly set + if (typeof groupAllowOverride !== "undefined") { + if (!senderId) { + logVerbose("Blocked line group message (group allowFrom override, no sender ID)"); + return false; + } + if (!isSenderAllowed({ allow: effectiveGroupAllow, senderId })) { + logVerbose(`Blocked line group sender ${senderId} (group allowFrom override)`); + return false; + } + } + return true; + } if (groupPolicy === "allowlist") { if (!senderId) { logVerbose("Blocked line group message (no sender ID, groupPolicy: allowlist)"); diff --git a/src/line/config-schema.ts b/src/line/config-schema.ts index 7e7a2be03..4612800e7 100644 --- a/src/line/config-schema.ts +++ b/src/line/config-schema.ts @@ -6,6 +6,7 @@ const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]); const LineGroupConfigSchema = z .object({ enabled: z.boolean().optional(), + policy: GroupPolicySchema.optional(), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), requireMention: z.boolean().optional(), systemPrompt: z.string().optional(), diff --git a/src/line/types.ts b/src/line/types.ts index 252fcb949..8269e242d 100644 --- a/src/line/types.ts +++ b/src/line/types.ts @@ -45,6 +45,7 @@ export interface LineAccountConfig { export interface LineGroupConfig { enabled?: boolean; + policy?: "open" | "allowlist" | "disabled"; allowFrom?: Array; requireMention?: boolean; systemPrompt?: string;