feat(channels): add dmPolicy config to disable private/DM messages (#6521)

* feat(channels): add dmPolicy config to disable private/DM messages

Add DmGate class mirroring GroupGate to gate DM/private messages in
channel adapters. Operators can now set dmPolicy: 'disabled' in their
channel config to silently drop all DM messages while keeping group
messages active.

Closes #6392

* fix(channels): address review feedback for dmPolicy

- Add dmPolicy: 'open' to all test config factories (8 files) to
  maintain type correctness with required ChannelConfig field
- Add integration tests in ChannelBase.test.ts:
  - preflightInbound: DM dropped + group passes when dmPolicy=disabled
  - isStoredLoopTargetAuthorized: DM loop job disabled + group passes
- Add dmPolicy assertions in config-utils.test.ts (default + explicit)
- Keep dmPolicy as required field (not optional) for strict parity
  with groupPolicy
This commit is contained in:
ChengHui Chen 2026-07-08 20:01:10 +08:00 committed by GitHub
parent 151d269413
commit 1f92787aa0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 228 additions and 3 deletions

View file

@ -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 } },
},
},

View file

@ -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

View file

@ -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.

View file

@ -200,6 +200,7 @@ function defaultConfig(overrides: Partial<ChannelConfig> = {}): 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;

View file

@ -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) {

View file

@ -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> = {}): 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);
});
});
});

View file

@ -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 };
}
}

View file

@ -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,

View file

@ -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<string, GroupConfig>; // "*" for defaults, group IDs for overrides

View file

@ -89,6 +89,7 @@ function createChannel(): DingtalkChannelInstance {
sessionScope: 'user',
cwd: '/tmp',
groupPolicy: 'open',
dmPolicy: 'open',
groups: {},
},
{} as never,

View file

@ -30,6 +30,7 @@ function createConfig(overrides?: Partial<ChannelConfig>): ChannelConfig {
sessionScope: 'user',
cwd: '/tmp',
groupPolicy: 'open',
dmPolicy: 'open',
groups: { '*': { requireMention: true } },
...overrides,
};

View file

@ -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',

View file

@ -102,6 +102,7 @@ function makeChannel(overrides: Record<string, unknown> = {}): QQChannelClass {
sessionScope: 'user' as const,
cwd: '/tmp',
groupPolicy: 'disabled' as const,
dmPolicy: 'open',
groups: {},
appID: 'test-app-id',
appSecret: 'test-secret',

View file

@ -65,6 +65,7 @@ const config: ChannelConfig = {
sessionScope: 'user',
cwd: process.cwd(),
groupPolicy: 'disabled',
dmPolicy: 'open',
groups: {},
};

View file

@ -253,6 +253,7 @@ function makeConfig(
sessionScope: 'user',
cwd: process.cwd(),
groupPolicy: 'disabled',
dmPolicy: 'open',
groups: {},
botId: 'bot-id',
secret: 'bot-secret',

View file

@ -40,6 +40,7 @@ const config: ChannelConfig = {
sessionScope: 'user',
cwd: process.cwd(),
groupPolicy: 'disabled',
dmPolicy: 'open',
groups: {},
};

View file

@ -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'] } });
});

View file

@ -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']) || {},
};
}