diff --git a/docs/channels/line.md b/docs/channels/line.md index 64f775394d3..3af01855f7a 100644 --- a/docs/channels/line.md +++ b/docs/channels/line.md @@ -68,6 +68,22 @@ Minimal config: } ``` +Public DM config: + +```json5 +{ + channels: { + line: { + enabled: true, + channelAccessToken: "LINE_CHANNEL_ACCESS_TOKEN", + channelSecret: "LINE_CHANNEL_SECRET", + dmPolicy: "open", + allowFrom: ["*"], + }, + }, +} +``` + Env vars (default account only): - `LINE_CHANNEL_ACCESS_TOKEN` @@ -119,7 +135,7 @@ openclaw pairing approve line Allowlists and policies: - `channels.line.dmPolicy`: `pairing | allowlist | open | disabled` -- `channels.line.allowFrom`: allowlisted LINE user IDs for DMs +- `channels.line.allowFrom`: allowlisted LINE user IDs for DMs; `dmPolicy: "open"` requires `["*"]` - `channels.line.groupPolicy`: `allowlist | open | disabled` - `channels.line.groupAllowFrom`: allowlisted LINE user IDs for groups - Per-group overrides: `channels.line.groups..allowFrom` diff --git a/extensions/line/src/config-schema.test.ts b/extensions/line/src/config-schema.test.ts new file mode 100644 index 00000000000..945cc7f29ae --- /dev/null +++ b/extensions/line/src/config-schema.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { LineConfigSchema } from "./config-schema.js"; + +describe("LineConfigSchema", () => { + it('rejects dmPolicy="open" without wildcard allowFrom', () => { + const result = LineConfigSchema.safeParse({ + channelAccessToken: "token", + channelSecret: "secret", + dmPolicy: "open", + }); + + expect(result.success).toBe(false); + expect(result.error.issues).toEqual([ + expect.objectContaining({ + path: ["allowFrom"], + message: 'channels.line.dmPolicy="open" requires channels.line.allowFrom to include "*"', + }), + ]); + }); + + it('accepts dmPolicy="open" with wildcard allowFrom', () => { + const result = LineConfigSchema.safeParse({ + channelAccessToken: "token", + channelSecret: "secret", + dmPolicy: "open", + allowFrom: ["*"], + }); + + expect(result.success).toBe(true); + }); + + it('rejects account dmPolicy="open" without wildcard allowFrom', () => { + const result = LineConfigSchema.safeParse({ + accounts: { + work: { + channelAccessToken: "token", + channelSecret: "secret", + dmPolicy: "open", + }, + }, + }); + + expect(result.success).toBe(false); + expect(result.error.issues).toEqual([ + expect.objectContaining({ + path: ["accounts", "work", "allowFrom"], + message: 'channels.line.dmPolicy="open" requires channels.line.allowFrom to include "*"', + }), + ]); + }); +}); diff --git a/extensions/line/src/config-schema.ts b/extensions/line/src/config-schema.ts index 372fd8cd966..4618d62b790 100644 --- a/extensions/line/src/config-schema.ts +++ b/extensions/line/src/config-schema.ts @@ -1,4 +1,8 @@ -import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema"; +import { + buildChannelConfigSchema, + requireOpenAllowFrom, +} from "openclaw/plugin-sdk/channel-config-schema"; +import { requireChannelOpenAllowFrom } from "openclaw/plugin-sdk/extension-shared"; import { z } from "openclaw/plugin-sdk/zod"; const DmPolicySchema = z.enum(["open", "allowlist", "pairing", "disabled"]); @@ -15,7 +19,7 @@ const ThreadBindingsSchema = z }) .strict(); -const LineCommonConfigSchema = z.object({ +const LineCommonConfigSchemaBase = z.object({ enabled: z.boolean().optional(), channelAccessToken: z.string().optional(), channelSecret: z.string().optional(), @@ -42,15 +46,35 @@ const LineGroupConfigSchema = z }) .strict(); -const LineAccountConfigSchema = LineCommonConfigSchema.extend({ +const LineAccountConfigSchema = LineCommonConfigSchemaBase.extend({ groups: z.record(z.string(), LineGroupConfigSchema.optional()).optional(), -}).strict(); +}) + .strict() + .superRefine((value, ctx) => { + requireChannelOpenAllowFrom({ + channel: "line", + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + requireOpenAllowFrom, + }); + }); -export const LineConfigSchema = LineCommonConfigSchema.extend({ +export const LineConfigSchema = LineCommonConfigSchemaBase.extend({ accounts: z.record(z.string(), LineAccountConfigSchema.optional()).optional(), defaultAccount: z.string().optional(), groups: z.record(z.string(), LineGroupConfigSchema.optional()).optional(), -}).strict(); +}) + .strict() + .superRefine((value, ctx) => { + requireChannelOpenAllowFrom({ + channel: "line", + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + requireOpenAllowFrom, + }); + }); export const LineChannelConfigSchema = buildChannelConfigSchema(LineConfigSchema); diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index b0692c01bc0..260fd00ae62 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -2907,41 +2907,6 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(statusReactionController.setTool).toHaveBeenCalledWith("exec"); }); - it("keeps non-command Telegram progress draft lines across post-tool assistant boundaries", async () => { - const draftStream = createSequencedDraftStream(2001); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onReplyStart?.(); - await replyOptions?.onAssistantMessageStart?.(); - await replyOptions?.onItemEvent?.({ kind: "search", progressText: "docs lookup" }); - await replyOptions?.onItemEvent?.({ progressText: "tests passed" }); - await replyOptions?.onAssistantMessageStart?.(); - await dispatcherOptions.deliver({ text: "Final after tool" }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - - await dispatchWithContext({ - context: createContext(), - streamMode: "progress", - telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } }, - }); - - expect(draftStream.update).toHaveBeenCalledWith( - expect.stringMatching(/^Shelling\n`šŸ”Ž Web Search: docs lookup`\n• `tests passed`$/), - ); - expect(draftStream.forceNewMessage).not.toHaveBeenCalled(); - expect(draftStream.materialize).not.toHaveBeenCalled(); - expect(draftStream.clear).toHaveBeenCalledTimes(1); - expect(deliverReplies).toHaveBeenCalledWith( - expect.objectContaining({ - replies: [expect.objectContaining({ text: "Final after tool" })], - }), - ); - expect(editMessageTelegram).not.toHaveBeenCalled(); - }); - it("keeps DM reasoning block updates in preview flow without sending duplicates", async () => { const answerDraftStream = createDraftStream(999); let previewRevision = 0;