feat(line): per-group policy support
Add groupPolicies config to LINE bot allowing per-group AI policy overrides (model, system prompt, etc). - Add groupPolicies field to LineBotConfig type and schema - Resolve effective policy by merging group-specific overrides onto default policy in bot-handlers - Add tests for policy resolution logic
This commit is contained in:
parent
87267fad4f
commit
f03fab3a9f
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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)");
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -45,6 +45,7 @@ export interface LineAccountConfig {
|
||||
|
||||
export interface LineGroupConfig {
|
||||
enabled?: boolean;
|
||||
policy?: "open" | "allowlist" | "disabled";
|
||||
allowFrom?: Array<string | number>;
|
||||
requireMention?: boolean;
|
||||
systemPrompt?: string;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user