mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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:
parent
151d269413
commit
1f92787aa0
18 changed files with 228 additions and 3 deletions
|
|
@ -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 } },
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
52
packages/channels/base/src/DmGate.test.ts
Normal file
52
packages/channels/base/src/DmGate.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
35
packages/channels/base/src/DmGate.ts
Normal file
35
packages/channels/base/src/DmGate.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ function createChannel(): DingtalkChannelInstance {
|
|||
sessionScope: 'user',
|
||||
cwd: '/tmp',
|
||||
groupPolicy: 'open',
|
||||
dmPolicy: 'open',
|
||||
groups: {},
|
||||
},
|
||||
{} as never,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ function createConfig(overrides?: Partial<ChannelConfig>): ChannelConfig {
|
|||
sessionScope: 'user',
|
||||
cwd: '/tmp',
|
||||
groupPolicy: 'open',
|
||||
dmPolicy: 'open',
|
||||
groups: { '*': { requireMention: true } },
|
||||
...overrides,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ const config: ChannelConfig = {
|
|||
sessionScope: 'user',
|
||||
cwd: process.cwd(),
|
||||
groupPolicy: 'disabled',
|
||||
dmPolicy: 'open',
|
||||
groups: {},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -253,6 +253,7 @@ function makeConfig(
|
|||
sessionScope: 'user',
|
||||
cwd: process.cwd(),
|
||||
groupPolicy: 'disabled',
|
||||
dmPolicy: 'open',
|
||||
groups: {},
|
||||
botId: 'bot-id',
|
||||
secret: 'bot-secret',
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ const config: ChannelConfig = {
|
|||
sessionScope: 'user',
|
||||
cwd: process.cwd(),
|
||||
groupPolicy: 'disabled',
|
||||
dmPolicy: 'open',
|
||||
groups: {},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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'] } });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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']) || {},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue