diff --git a/docs/design/channels/channels-design.md b/docs/design/channels/channels-design.md index 918670dd09..120afde387 100644 --- a/docs/design/channels/channels-design.md +++ b/docs/design/channels/channels-design.md @@ -130,6 +130,7 @@ Plugins run in-process (no sandbox), same trust model as npm dependencies. "model": "qwen3.5-plus", "instructions": "Keep responses short.", "groupPolicy": "disabled", // disabled | allowlist | open + "dmPolicy": "open", // open | disabled "groups": { "*": { "requireMention": true } }, }, }, diff --git a/docs/users/features/channels/overview.md b/docs/users/features/channels/overview.md index f2a546575f..33ad5dc26f 100644 --- a/docs/users/features/channels/overview.md +++ b/docs/users/features/channels/overview.md @@ -37,6 +37,7 @@ Channels are configured under the `channels` key in `settings.json`. Each channe "cwd": "/path/to/working/directory", "instructions": "Optional system instructions for the agent.", "groupPolicy": "disabled", + "dmPolicy": "open", "groups": { "*": { "requireMention": true } } @@ -62,6 +63,7 @@ Channels are configured under the `channels` key in `settings.json`. Each channe | `cwd` | No | Working directory for the agent. Defaults to the current directory | | `instructions` | No | Custom instructions prepended to the first message of each session | | `groupPolicy` | No | Group chat access: `disabled` (default), `allowlist`, or `open`. See [Group Chats](#group-chats) | +| `dmPolicy` | No | Private/DM access: `open` (default) or `disabled` (silently drop all DMs). Useful for group-only bots | | `groupHistoryLimit` | No | Opt-in group history backfill. `0` or omitted disables it. A positive number persists that many authorized, unmentioned group messages for the next bot mention/reply. | | `groups` | No | Per-group settings. Keys are group chat IDs or `"*"` for defaults. See [Group Chats](#group-chats) | | `dispatchMode` | No | What happens when you send a message while the bot is busy: `steer` (default), `collect`, or `followup`. See [Dispatch Modes](#dispatch-modes) | @@ -213,9 +215,10 @@ By default, Qwen ignores unmentioned group messages and does not store them as s ``` 1. groupPolicy — is this group allowed? (no → ignore) -2. requireMention — was the bot mentioned/replied to? (no → ignore) -3. senderPolicy — is this sender approved? (no → pairing flow) -4. Route to session +2. dmPolicy — is this DM allowed? (disabled → ignore) +3. requireMention — was the bot mentioned/replied to? (no → ignore) +4. senderPolicy — is this sender approved? (no → pairing flow) +5. Route to session ``` ### Telegram Setup for Groups diff --git a/docs/users/features/channels/plugins.md b/docs/users/features/channels/plugins.md index 41f8ec4258..7f96db9a61 100644 --- a/docs/users/features/channels/plugins.md +++ b/docs/users/features/channels/plugins.md @@ -55,6 +55,7 @@ All standard channel options work with custom channels: | `instructions` | Prepended to the first message of each session | | `model` | Model override for the channel | | `groupPolicy` | `disabled`, `allowlist`, or `open` | +| `dmPolicy` | `open` or `disabled` | | `groups` | Per-group settings | See [Overview](./overview) for details on each option. diff --git a/packages/channels/base/src/ChannelBase.test.ts b/packages/channels/base/src/ChannelBase.test.ts index bfb6709024..9f73001215 100644 --- a/packages/channels/base/src/ChannelBase.test.ts +++ b/packages/channels/base/src/ChannelBase.test.ts @@ -200,6 +200,7 @@ function defaultConfig(overrides: Partial = {}): ChannelConfig { sessionScope: 'user', cwd: '/tmp', groupPolicy: 'disabled', + dmPolicy: 'open', groups: {}, ...overrides, }; @@ -259,6 +260,22 @@ describe('ChannelBase', () => { expect(bridge.prompt).toHaveBeenCalled(); }); + it('silently drops DM messages when dmPolicy=disabled', async () => { + const ch = createChannel({ dmPolicy: 'disabled' }); + await ch.handleInbound(envelope()); + expect(ch.sent).toEqual([]); + expect(bridge.prompt).not.toHaveBeenCalled(); + }); + + it('allows group messages through when dmPolicy=disabled', async () => { + const ch = createChannel({ + dmPolicy: 'disabled', + groupPolicy: 'open', + }); + await ch.handleInbound(envelope({ isGroup: true, isMentioned: true })); + expect(bridge.prompt).toHaveBeenCalled(); + }); + it('rejects direct processing without preflight', async () => { const ch = new UnsafeProcessChannel('test-chan', defaultConfig(), bridge); @@ -11134,6 +11151,88 @@ describe('ChannelBase', () => { expect(bridge.prompt).not.toHaveBeenCalled(); }); + it('disables a stored DM job when dmPolicy=disabled', async () => { + const disable = vi.fn().mockResolvedValue(true); + const ch = createChannel( + { dmPolicy: 'disabled' }, + { + loopController: { + create: vi.fn(), + listForTarget: vi.fn(), + disable, + validateCron: vi.fn(), + }, + }, + ); + ch.proactiveSupported = true; + + await expect( + ch.runLoopPrompt({ + id: 'job-1', + channelName: 'test-chan', + target: { + channelName: 'test-chan', + senderId: 'user1', + chatId: 'chat1', + isGroup: false, + }, + cwd: '/tmp', + cron: '0 9 * * *', + prompt: 'post summary', + label: 'daily summary', + recurring: true, + enabled: true, + createdBy: 'User 1', + createdAt: '2026-06-30T01:00:00.000Z', + consecutiveFailures: 0, + runCount: 0, + }), + ).rejects.toThrow('no longer authorized'); + + expect(disable).toHaveBeenCalledWith('job-1'); + expect(bridge.prompt).not.toHaveBeenCalled(); + }); + + it('allows stored group job when dmPolicy=disabled', async () => { + const disable = vi.fn().mockResolvedValue(true); + const ch = createChannel( + { dmPolicy: 'disabled', groupPolicy: 'open' }, + { + loopController: { + create: vi.fn(), + listForTarget: vi.fn(), + disable, + validateCron: vi.fn(), + }, + }, + ); + ch.proactiveSupported = true; + + await ch.runLoopPrompt({ + id: 'job-1', + channelName: 'test-chan', + target: { + channelName: 'test-chan', + senderId: 'user1', + chatId: 'group-1', + isGroup: true, + }, + cwd: '/tmp', + cron: '0 9 * * *', + prompt: 'post summary', + label: 'daily summary', + recurring: true, + enabled: true, + createdBy: 'User 1', + createdAt: '2026-06-30T01:00:00.000Z', + consecutiveFailures: 0, + runCount: 0, + }); + + expect(disable).not.toHaveBeenCalled(); + expect(bridge.prompt).toHaveBeenCalled(); + }); + it('rejects stored threaded jobs unless the adapter supports the target', async () => { const ch = createChannel(); ch.proactiveSupported = true; diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index 390ac2c106..2d44db80fb 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -16,6 +16,7 @@ import type { } from './types.js'; import { BlockStreamer } from './BlockStreamer.js'; import { GroupGate } from './GroupGate.js'; +import { DmGate } from './DmGate.js'; import { GroupHistoryStore } from './group-history-store.js'; import type { GroupHistoryEntry } from './group-history-store.js'; import { SenderGate } from './SenderGate.js'; @@ -192,6 +193,7 @@ export abstract class ChannelBase { protected config: ChannelConfig; protected bridge: ChannelAgentBridge; protected groupGate: GroupGate; + protected dmGate: DmGate; protected gate: SenderGate; protected router: SessionRouter; protected name: string; @@ -392,6 +394,7 @@ export abstract class ChannelBase { this.loopController = options?.loopController; this.groupGate = new GroupGate(config.groupPolicy, config.groups); + this.dmGate = new DmGate(config.dmPolicy); const pairingStore = config.senderPolicy === 'pairing' ? new PairingStore(name) : undefined; @@ -2291,6 +2294,7 @@ export abstract class ChannelBase { }; return ( this.groupGate.check(envelope).allowed && + this.dmGate.check(envelope).allowed && this.gate.isAllowed(normalizedTarget.senderId) && this.isAuthorizedForSharedSession(envelope) ); @@ -2952,6 +2956,11 @@ export abstract class ChannelBase { return false; } + const dmResult = this.dmGate.check(envelope); + if (!dmResult.allowed) { + return false; + } + const result = this.gate.check(envelope.senderId, envelope.senderName); if (!result.allowed) { if (result.pairingCode !== undefined) { diff --git a/packages/channels/base/src/DmGate.test.ts b/packages/channels/base/src/DmGate.test.ts new file mode 100644 index 0000000000..bfc9001d2c --- /dev/null +++ b/packages/channels/base/src/DmGate.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { DmGate } from './DmGate.js'; +import type { Envelope } from './types.js'; + +function envelope(overrides: Partial = {}): Envelope { + return { + channelName: 'test', + senderId: 'user1', + senderName: 'User', + chatId: 'chat1', + text: 'hello', + isGroup: false, + isMentioned: false, + isReplyToBot: false, + ...overrides, + }; +} + +describe('DmGate', () => { + describe('group messages', () => { + it('always allows group messages regardless of policy', () => { + for (const policy of ['disabled', 'open'] as const) { + const gate = new DmGate(policy); + expect(gate.check(envelope({ isGroup: true })).allowed).toBe(true); + } + }); + }); + + describe('disabled policy', () => { + it('rejects all DM messages', () => { + const gate = new DmGate('disabled'); + const result = gate.check(envelope()); + expect(result).toEqual({ allowed: false, reason: 'disabled' }); + }); + }); + + describe('open policy', () => { + it('allows all DM messages', () => { + const gate = new DmGate('open'); + const result = gate.check(envelope()); + expect(result.allowed).toBe(true); + }); + }); + + describe('defaults', () => { + it('defaults to open policy', () => { + const gate = new DmGate(); + const result = gate.check(envelope()); + expect(result.allowed).toBe(true); + }); + }); +}); diff --git a/packages/channels/base/src/DmGate.ts b/packages/channels/base/src/DmGate.ts new file mode 100644 index 0000000000..2c50398ba9 --- /dev/null +++ b/packages/channels/base/src/DmGate.ts @@ -0,0 +1,35 @@ +import type { DmPolicy, Envelope } from './types.js'; + +export interface DmCheckResult { + allowed: boolean; + reason?: 'disabled'; +} + +export class DmGate { + private policy: DmPolicy; + + constructor(policy: DmPolicy = 'open') { + this.policy = policy; + } + + /** + * DM check: policy gating for private/non-group messages. + * Evaluation order: + * 1. Group messages bypass this gate (handled by GroupGate) + * 2. dmPolicy (disabled → drop) + * + * Symmetric with GroupGate — GroupGate owns group messages, + * DmGate owns DM messages. + */ + check(envelope: Envelope): DmCheckResult { + if (envelope.isGroup) { + return { allowed: true }; + } + + if (this.policy === 'disabled') { + return { allowed: false, reason: 'disabled' }; + } + + return { allowed: true }; + } +} diff --git a/packages/channels/base/src/index.ts b/packages/channels/base/src/index.ts index 5ac1e037d0..0112134036 100644 --- a/packages/channels/base/src/index.ts +++ b/packages/channels/base/src/index.ts @@ -48,6 +48,8 @@ export { PairingStore } from './PairingStore.js'; export type { PairingRequest } from './PairingStore.js'; export { GroupGate } from './GroupGate.js'; export type { GroupCheckResult } from './GroupGate.js'; +export { DmGate } from './DmGate.js'; +export type { DmCheckResult } from './DmGate.js'; export { SenderGate } from './SenderGate.js'; export type { SenderCheckResult } from './SenderGate.js'; export { SessionRouter } from './SessionRouter.js'; @@ -75,6 +77,7 @@ export type { ChannelTaskLifecycleEvent, ChannelType, DispatchMode, + DmPolicy, Envelope, GroupConfig, GroupPolicy, diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts index 70a6133314..3d0a9c7ce0 100644 --- a/packages/channels/base/src/types.ts +++ b/packages/channels/base/src/types.ts @@ -5,6 +5,7 @@ export type SenderPolicy = 'allowlist' | 'pairing' | 'open'; export type SessionScope = 'user' | 'thread' | 'single'; export type ChannelType = string; export type GroupPolicy = 'disabled' | 'allowlist' | 'open'; +export type DmPolicy = 'disabled' | 'open'; export type DispatchMode = 'collect' | 'steer' | 'followup'; export interface ChannelIdentityConfig { @@ -64,6 +65,7 @@ export interface ChannelConfig { memoryScope?: ChannelMemoryScopeConfig; model?: string; groupPolicy: GroupPolicy; // default: "disabled" + dmPolicy: DmPolicy; // default: "open" groupHistoryLimit?: number; groups: Record; // "*" for defaults, group IDs for overrides diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.test.ts b/packages/channels/dingtalk/src/DingtalkAdapter.test.ts index caa07523ac..60894dc056 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.test.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.test.ts @@ -89,6 +89,7 @@ function createChannel(): DingtalkChannelInstance { sessionScope: 'user', cwd: '/tmp', groupPolicy: 'open', + dmPolicy: 'open', groups: {}, }, {} as never, diff --git a/packages/channels/feishu/src/adapter.test.ts b/packages/channels/feishu/src/adapter.test.ts index c7cb8ef047..bf470160f3 100644 --- a/packages/channels/feishu/src/adapter.test.ts +++ b/packages/channels/feishu/src/adapter.test.ts @@ -30,6 +30,7 @@ function createConfig(overrides?: Partial): ChannelConfig { sessionScope: 'user', cwd: '/tmp', groupPolicy: 'open', + dmPolicy: 'open', groups: { '*': { requireMention: true } }, ...overrides, }; diff --git a/packages/channels/qqbot/src/send.test.ts b/packages/channels/qqbot/src/send.test.ts index 7820aa2e58..b4299104ff 100644 --- a/packages/channels/qqbot/src/send.test.ts +++ b/packages/channels/qqbot/src/send.test.ts @@ -200,6 +200,7 @@ describe('session persistence paths', () => { sessionScope: 'user' as const, cwd: '/tmp', groupPolicy: 'disabled' as const, + dmPolicy: 'open', groups: {}, appID: 'test-app-id', appSecret: 'test-secret', @@ -284,6 +285,7 @@ describe('group sender-name sanitization', () => { sessionScope: 'user' as const, cwd: '/tmp', groupPolicy: 'open' as const, + dmPolicy: 'open', groups: {}, appID: 'test-app-id', appSecret: 'test-secret', @@ -437,6 +439,7 @@ describe('sendMessage', () => { sessionScope: 'user' as const, cwd: '/tmp', groupPolicy: 'disabled' as const, + dmPolicy: 'open', groups: {}, appID: 'test-app-id', appSecret: 'test-secret', @@ -1063,6 +1066,7 @@ describe('setReplyMsgId', () => { sessionScope: 'user' as const, cwd: '/tmp', groupPolicy: 'disabled' as const, + dmPolicy: 'open', groups: {}, appID: 'test-app-id', appSecret: 'test-secret', @@ -1162,6 +1166,7 @@ describe('lifecycle status hooks', () => { sessionScope: 'user' as const, cwd: '/tmp', groupPolicy: 'disabled' as const, + dmPolicy: 'open', groups: {}, appID: 'test-app-id', appSecret: 'test-secret', @@ -1231,6 +1236,7 @@ describe('gateway reconnect timer', () => { sessionScope: 'user' as const, cwd: '/tmp', groupPolicy: 'disabled' as const, + dmPolicy: 'open', groups: {}, appID: 'test-app-id', appSecret: 'test-secret', @@ -1288,6 +1294,7 @@ describe('connect() sanitized-error on final retry', () => { sessionScope: 'user' as const, cwd: '/tmp', groupPolicy: 'disabled' as const, + dmPolicy: 'open', groups: {}, appID: 'test-app-id', appSecret: 'test-secret', @@ -1364,6 +1371,7 @@ describe('restoreQQState validation filters', () => { sessionScope: 'user' as const, cwd: '/tmp', groupPolicy: 'disabled' as const, + dmPolicy: 'open', groups: {}, appID: 'test-app-id', appSecret: 'test-secret', @@ -1595,6 +1603,7 @@ describe('atomic state persistence', () => { sessionScope: 'user' as const, cwd: '/tmp', groupPolicy: 'disabled' as const, + dmPolicy: 'open', groups: {}, appID: 'test-app-id', appSecret: 'test-secret', @@ -1705,6 +1714,7 @@ describe('replyMsgId cleanup timer', () => { sessionScope: 'user' as const, cwd: '/tmp', groupPolicy: 'disabled' as const, + dmPolicy: 'open', groups: {}, appID: 'test-app-id', appSecret: 'test-secret', diff --git a/packages/channels/qqbot/src/stream.test.ts b/packages/channels/qqbot/src/stream.test.ts index 45069e1b6d..360efa690d 100644 --- a/packages/channels/qqbot/src/stream.test.ts +++ b/packages/channels/qqbot/src/stream.test.ts @@ -102,6 +102,7 @@ function makeChannel(overrides: Record = {}): QQChannelClass { sessionScope: 'user' as const, cwd: '/tmp', groupPolicy: 'disabled' as const, + dmPolicy: 'open', groups: {}, appID: 'test-app-id', appSecret: 'test-secret', diff --git a/packages/channels/telegram/src/TelegramAdapter.test.ts b/packages/channels/telegram/src/TelegramAdapter.test.ts index 18d4f9529e..284e7aa42f 100644 --- a/packages/channels/telegram/src/TelegramAdapter.test.ts +++ b/packages/channels/telegram/src/TelegramAdapter.test.ts @@ -65,6 +65,7 @@ const config: ChannelConfig = { sessionScope: 'user', cwd: process.cwd(), groupPolicy: 'disabled', + dmPolicy: 'open', groups: {}, }; diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 70227b3050..d39be3ce02 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -253,6 +253,7 @@ function makeConfig( sessionScope: 'user', cwd: process.cwd(), groupPolicy: 'disabled', + dmPolicy: 'open', groups: {}, botId: 'bot-id', secret: 'bot-secret', diff --git a/packages/channels/weixin/src/WeixinAdapter.test.ts b/packages/channels/weixin/src/WeixinAdapter.test.ts index 18cc23c4ce..3a0f43665c 100644 --- a/packages/channels/weixin/src/WeixinAdapter.test.ts +++ b/packages/channels/weixin/src/WeixinAdapter.test.ts @@ -40,6 +40,7 @@ const config: ChannelConfig = { sessionScope: 'user', cwd: process.cwd(), groupPolicy: 'disabled', + dmPolicy: 'open', groups: {}, }; diff --git a/packages/cli/src/commands/channel/config-utils.test.ts b/packages/cli/src/commands/channel/config-utils.test.ts index 48f564557a..4bad32ed1a 100644 --- a/packages/cli/src/commands/channel/config-utils.test.ts +++ b/packages/cli/src/commands/channel/config-utils.test.ts @@ -170,6 +170,7 @@ describe('parseChannelConfig', () => { expect(result.sessionScope).toBe('user'); expect(result.cwd).toBe(process.cwd()); expect(result.groupPolicy).toBe('disabled'); + expect(result.dmPolicy).toBe('open'); expect(result.groups).toEqual({}); expect(result.identity).toBeUndefined(); expect(result.memoryScope).toBeUndefined(); @@ -325,6 +326,7 @@ describe('parseChannelConfig', () => { memoryScope: { namespace: 'qwen-tag:ops', mode: 'metadata-only' }, model: 'qwen-coder', groupPolicy: 'open', + dmPolicy: 'disabled', groups: { g1: { mentionKeywords: ['@bot'] } }, }); @@ -345,6 +347,7 @@ describe('parseChannelConfig', () => { }); expect(result.model).toBe('qwen-coder'); expect(result.groupPolicy).toBe('open'); + expect(result.dmPolicy).toBe('disabled'); expect(result.groups).toEqual({ g1: { mentionKeywords: ['@bot'] } }); }); diff --git a/packages/cli/src/commands/channel/config-utils.ts b/packages/cli/src/commands/channel/config-utils.ts index c272fd714f..b4b27c8bfa 100644 --- a/packages/cli/src/commands/channel/config-utils.ts +++ b/packages/cli/src/commands/channel/config-utils.ts @@ -208,6 +208,7 @@ export async function parseChannelConfig( model: rawConfig['model'] as string | undefined, groupPolicy: (rawConfig['groupPolicy'] as ChannelConfig['groupPolicy']) || 'disabled', + dmPolicy: (rawConfig['dmPolicy'] as ChannelConfig['dmPolicy']) || 'open', groups: (rawConfig['groups'] as ChannelConfig['groups']) || {}, }; }