Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 218 additions & 0 deletions docs/plans/2026-07-30-001-feat-substitute-chat-blocklist-plan.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions src/bot-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,11 @@ export interface SubstituteModeConfig {
disclosure?: 'prefix' | 'none';
/** Optional allow-list of chat IDs. When provided, substitute trigger only fires in these chats. */
chats?: string[];
/** Optional block-list of chat IDs (黑名单). When a chat is listed here the substitute
* trigger never fires there — deny-wins over {@link chats} (a chat in both is blocked)
* and hard (cannot be re-enabled by the per-chat `/substitute on` runtime toggle).
* Applies to regular and topic groups alike. Direct @bot mentions are unaffected. */
excludedChats?: string[];
/** When true, do not automatically DM the owner a control card for substitute-mode sessions. */
disableControlCard?: boolean;
/** How the bot replies to a substitute-mode trigger:
Expand Down
4 changes: 4 additions & 0 deletions src/core/dashboard-ipc-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2733,13 +2733,17 @@ ipcRoute('PUT', '/api/bot-substitute-mode', async (req, res) => {
const chats = Array.isArray(rec.chats)
? [...new Set(rec.chats.map(String).map(s => s.trim()).filter(Boolean))]
: [];
const excludedChats = Array.isArray(rec.excludedChats)
? [...new Set(rec.excludedChats.map(String).map(s => s.trim()).filter(Boolean))]
: [];
const r = await substituteModeStore.updateBotSubstituteMode(cachedLarkAppId, {
enabled: rec.enabled === true,
targets,
disclosure: rec.disclosure === 'none' ? 'none' : 'prefix',
replyMode: rec.replyMode === 'quote' ? 'quote' : 'thread',
disableControlCard: rec.disableControlCard === true,
...(chats.length ? { chats } : {}),
...(excludedChats.length ? { excludedChats } : {}),
// 话题群开关:显式 false 才关(旧客户端不带字段 → normalize 缺省开)。
topicGroups: rec.topicGroups,
topicActiveSessionTrigger: rec.topicActiveSessionTrigger,
Expand Down
20 changes: 18 additions & 2 deletions src/dashboard/web/bot-defaults-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2651,6 +2651,7 @@ function SubstituteModeSection(props: { bot: BotDefaultsRow; patchBot: PatchBot
const [replyMode, setReplyMode] = useState<'thread' | 'quote'>(initial?.replyMode === 'quote' ? 'quote' : 'thread');
const [controlCard, setControlCard] = useState(initial?.disableControlCard !== true);
const [chatsText, setChatsText] = useState(() => formatSubstituteChats(initial?.chats));
const [excludedChatsText, setExcludedChatsText] = useState(() => formatSubstituteChats(initial?.excludedChats));
// 话题群相关开关缺省开:只有显式 false 才是关(与 normalize 语义一致)。
const [topicGroups, setTopicGroups] = useState(initial?.topicGroups !== false);
const [topicActiveSessionTrigger, setTopicActiveSessionTrigger] = useState(initial?.topicActiveSessionTrigger !== false);
Expand Down Expand Up @@ -2744,13 +2745,14 @@ function SubstituteModeSection(props: { bot: BotDefaultsRow; patchBot: PatchBot
setReplyMode(next?.replyMode === 'quote' ? 'quote' : 'thread');
setControlCard(next?.disableControlCard !== true);
setChatsText(formatSubstituteChats(next?.chats));
setExcludedChatsText(formatSubstituteChats(next?.excludedChats));
setTopicGroups(next?.topicGroups !== false);
setTopicActiveSessionTrigger(next?.topicActiveSessionTrigger !== false);
const targets = next?.targets ?? [];
setTargetRows(targets.length ? targets.map(target => makeTargetDraft(target)) : [makeTargetDraft()]);
}, [props.bot.larkAppId, props.bot.substituteMode]);

async function save(body: { enabled: boolean; targets: BotSubstituteTarget[]; disclosure?: 'prefix' | 'none'; chats?: string[]; replyMode?: 'thread' | 'quote'; disableControlCard?: boolean; topicGroups?: boolean; topicActiveSessionTrigger?: boolean }): Promise<void> {
async function save(body: { enabled: boolean; targets: BotSubstituteTarget[]; disclosure?: 'prefix' | 'none'; chats?: string[]; excludedChats?: string[]; replyMode?: 'thread' | 'quote'; disableControlCard?: boolean; topicGroups?: boolean; topicActiveSessionTrigger?: boolean }): Promise<void> {
setBusy(true);
setStatus(null);
try {
Expand All @@ -2771,6 +2773,7 @@ function SubstituteModeSection(props: { bot: BotDefaultsRow; patchBot: PatchBot
setReplyMode(next?.replyMode === 'quote' ? 'quote' : 'thread');
setControlCard(next?.disableControlCard !== true);
setChatsText(formatSubstituteChats(next?.chats));
setExcludedChatsText(formatSubstituteChats(next?.excludedChats));
setTopicGroups(next?.topicGroups !== false);
setTopicActiveSessionTrigger(next?.topicActiveSessionTrigger !== false);
if (resolution.length) {
Expand Down Expand Up @@ -2843,7 +2846,7 @@ function SubstituteModeSection(props: { bot: BotDefaultsRow; patchBot: PatchBot
setStatus({ text: `✗ ${tr('botDefaults.substituteTargetsInvalid')}` });
return;
}
void save({ enabled, targets, disclosure, chats: parseSubstituteChats(chatsText), replyMode, disableControlCard: !controlCard, topicGroups, topicActiveSessionTrigger });
void save({ enabled, targets, disclosure, chats: parseSubstituteChats(chatsText), excludedChats: parseSubstituteChats(excludedChatsText), replyMode, disableControlCard: !controlCard, topicGroups, topicActiveSessionTrigger });
}

const disclosureOptions: DropdownFieldOption<'prefix' | 'none'>[] = [
Expand Down Expand Up @@ -2929,6 +2932,19 @@ function SubstituteModeSection(props: { bot: BotDefaultsRow; patchBot: PatchBot
/>
</label>
</div>
<div className="bd-row">
<label>
<FieldTitle help={tr('botDefaults.substituteExcludedChatsHelp')}>{tr('botDefaults.substituteExcludedChats')}</FieldTitle>
<textarea
data-input="substituteExcludedChats"
rows={3}
placeholder={tr('botDefaults.substituteExcludedChatsPlaceholder')}
value={excludedChatsText}
disabled={busy}
onChange={event => setExcludedChatsText(event.currentTarget.value)}
/>
</label>
</div>
<div className="bd-row bd-substitute-targets">
<FieldTitle help={tr('botDefaults.substituteTargetsHelp')}>{tr('botDefaults.substituteTargets')}</FieldTitle>
<div className="bd-substitute-target-list" data-input="substituteTargets">
Expand Down
1 change: 1 addition & 0 deletions src/dashboard/web/bot-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type BotSubstituteMode = {
targets: BotSubstituteTarget[];
disclosure: 'prefix' | 'none';
chats?: string[];
excludedChats?: string[];
replyMode?: 'thread' | 'quote';
disableControlCard?: boolean;
/** 话题群支持(缺省 true;显式 false 关)。 */
Expand Down
6 changes: 6 additions & 0 deletions src/dashboard/web/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1861,6 +1861,9 @@ const zh: DashboardMessages = {
'botDefaults.substituteChats': '生效群聊',
'botDefaults.substituteChatsHelp': '留空表示所有群聊(普通群与话题群)都生效;填写后仅在这些群聊 ID 中触发替身。每行一个,也可用逗号/分号分隔。',
'botDefaults.substituteChatsPlaceholder': 'oc_xxx\noc_yyy',
'botDefaults.substituteExcludedChats': '黑名单群聊',
'botDefaults.substituteExcludedChatsHelp': '列出的群聊里替身永不触发(代答被硬关闭,群内 /substitute on 也无法开启),普通群与话题群一视同仁;直接 @机器人 仍照常回答。优先级高于「生效群聊」白名单——同时出现在两处的群以黑名单为准(不代答)。每行一个,也可用逗号/分号分隔。',
'botDefaults.substituteExcludedChatsPlaceholder': 'oc_xxx\noc_yyy',
'botDefaults.substituteTargetsPlaceholder': 'zhangsan@bytedance.com\nou_xxxx(也可直接填 open_id / union_id)',
'botDefaults.substituteTargets': '替身对象',
'botDefaults.substituteTargetsHelp': '每项对应一个替身对象。可直接填邮箱(保存时自动解析为 Open ID),也可选择 Open ID、User ID 或 Union ID;解析失败的邮箱会标红。',
Expand Down Expand Up @@ -3880,6 +3883,9 @@ const en: DashboardMessages = {
'botDefaults.substituteChats': 'Allowed chats',
'botDefaults.substituteChatsHelp': 'Leave blank to allow all chats (regular and topic groups); otherwise substitute only fires in these chat IDs. One per line, or comma/semicolon separated.',
'botDefaults.substituteChatsPlaceholder': 'oc_xxx\noc_yyy',
'botDefaults.substituteExcludedChats': 'Blocked chats',
'botDefaults.substituteExcludedChatsHelp': 'Substitute never fires in these chats (answering is hard-disabled — /substitute on cannot re-enable it), for regular and topic groups alike; direct @bot mentions still answer normally. Takes precedence over the allowed-chats list: a chat in both is blocked. One per line, or comma/semicolon separated.',
'botDefaults.substituteExcludedChatsPlaceholder': 'oc_xxx\noc_yyy',
'botDefaults.substituteTargetsPlaceholder': 'alice@example.com\nou_xxxx (open_id / union_id also accepted)',
'botDefaults.substituteTargets': 'Substitute targets',
'botDefaults.substituteTargetsHelp': 'Each row is one substitute target. Enter an email to resolve it to an Open ID on save, or select Open ID, User ID, or Union ID. Emails that cannot be resolved are flagged.',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ export const messages: Record<string, string> = {
'cmd.substitute.updated_off': '✅ Substitute mode disabled for this group.',
'cmd.substitute.unsupported': '⚠️ /substitute only works in group chats (regular or topic groups).',
'cmd.substitute.topic_disabled': '⚠️ Substitute mode for topic groups is disabled in this bot configuration; the per-group switch cannot enable it.',
'cmd.substitute.blocked': '⚠️ This group is on the substitute block-list in the bot configuration; substitute never fires here and /substitute on cannot enable it.',
'cmd.substitute.owner_only': '⚠️ Only owner/allowedUsers can change substitute mode.',
'cmd.substitute.usage': 'Usage: @me /substitute status | on | off',
'cmd.restart.in_progress': '🔄 Restarting {cliName}…',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ export const messages: Record<string, string> = {
'cmd.substitute.updated_off': '✅ 已关闭当前群替身模式。',
'cmd.substitute.unsupported': '⚠️ /substitute 仅支持群聊(普通群/话题群)。',
'cmd.substitute.topic_disabled': '⚠️ 当前 bot 配置已关闭话题群替身支持,群内开关无法单独开启。',
'cmd.substitute.blocked': '⚠️ 当前群已被 bot 配置加入替身黑名单,替身不会在此触发,/substitute on 也无法开启。',
'cmd.substitute.owner_only': '⚠️ 只有 owner/allowedUsers 可以修改替身模式开关。',
'cmd.substitute.usage': '用法:@我 /substitute status|on|off',
'cmd.restart.in_progress': '🔄 正在重启 {cliName}...',
Expand Down
27 changes: 26 additions & 1 deletion src/im/lark/event-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1127,7 +1127,15 @@ export function resolveSubstituteTrigger(
return undefined;
}

function isSubstituteAllowedChat(cfg: { chats?: string[] } | undefined, chatId: string): boolean {
function isSubstituteExcludedChat(cfg: { excludedChats?: string[] } | undefined, chatId: string): boolean {
return cfg?.excludedChats?.includes(chatId) ?? false;
}

function isSubstituteAllowedChat(cfg: { chats?: string[]; excludedChats?: string[] } | undefined, chatId: string): boolean {
// 黑名单先判且 deny-wins:命中即整段替身触发块短路(连带跳过其后的运行态
// 开关 isSubstituteEnabledForChat),因此配置黑名单 = 硬关闭,/substitute on
// 也翻不回来。对普通群与话题群一视同仁,与白名单同层。
if (isSubstituteExcludedChat(cfg, chatId)) return false;
if (!cfg?.chats?.length) return true;
return cfg.chats.includes(chatId);
}
Expand Down Expand Up @@ -2584,6 +2592,23 @@ export function startLarkEventDispatcher(larkAppId: string, larkAppSecret: strin
substituteChatMode = chatMode as 'group' | 'topic';
}
}
// 黑名单硬静默:命中黑名单的群里,一条「本该触发替身」的消息(@ 到了配置的
// 替身对象、但没有直接 @ 本 bot)必须当作没读到——直接 return,不只是清 trigger。
// 只清 trigger 不够:消息会继续 fall-through 到通用群消息门,若 bot 在该群有活跃
// 会话 / 是 solo 群 / mentionMode 放开,仍会被喂进去并弹卡片(用户实测现象)。
// 直接 @ 本 bot(explicitlyMentionedThisBot)不受影响:黑名单只静音替身代答,
// 不静音「直接找 bot 问问题」。/substitute 命令已在上方 command 处理器拦截。
if (substituteCfg?.enabled === true
&& chatType === 'group'
&& !explicitlyMentionedThisBot
&& isSubstituteExcludedChat(substituteCfg, chatId)
&& resolveSubstituteTrigger(larkAppId, message)) {
logger.info(
`[substitute:${larkAppId}] excluded chat — dropping @target message ` +
`msg=${messageId.substring(0, 12)} chat=${chatId.substring(0, 12)}`,
);
return;
}
let substituteTrigger = substituteChatMode
? resolveSubstituteTrigger(larkAppId, message)
: undefined;
Expand Down
8 changes: 8 additions & 0 deletions src/im/lark/substitute-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ export async function tryHandleSubstituteCommand(
await reply(t('cmd.substitute.topic_disabled', undefined, loc));
return true;
}
if (getBot(larkAppId).config.substituteMode?.excludedChats?.includes(chatId)) {
// 配置黑名单是硬关闭:dispatcher 里 isSubstituteAllowedChat 命中即短路,
// per-chat /substitute on 翻不回来。若仍回 status_on / updated_on 就是假成功
// (用户看到“已开启”却静默不代答),所以对 status/on/off 统一回报被屏蔽,
// 且不写运行态开关。先于 owner 权限检查:屏蔽状态非敏感,人人可见。
await reply(t('cmd.substitute.blocked', undefined, loc));
return true;
}

const arg = match[1]?.trim().toLowerCase() ?? 'status';
if (!arg || arg === 'status') {
Expand Down
7 changes: 7 additions & 0 deletions src/services/substitute-mode-normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import type { SubstituteModeConfig, SubstituteTarget } from '../bot-registry.js'
* - A DISABLED config still persists its target list (as long as it has ≥1
* target), so the dashboard toggle can flip on/off without re-entering
* everyone. Only an empty disabled config collapses to undefined (delete).
* - `chats` (allow-list) and `excludedChats` (block-list) are each trimmed,
* de-duplicated, and dropped when empty. They persist on a disabled config
* the same way targets do.
*/
export function normalizeSubstituteMode(raw: unknown): SubstituteModeConfig | undefined {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
Expand All @@ -44,6 +47,9 @@ export function normalizeSubstituteMode(raw: unknown): SubstituteModeConfig | un
const chats = Array.isArray(rec.chats)
? [...new Set(rec.chats.map(String).map(s => s.trim()).filter(Boolean))]
: [];
const excludedChats = Array.isArray(rec.excludedChats)
? [...new Set(rec.excludedChats.map(String).map(s => s.trim()).filter(Boolean))]
: [];
const out: SubstituteModeConfig = {
enabled,
targets,
Expand All @@ -53,6 +59,7 @@ export function normalizeSubstituteMode(raw: unknown): SubstituteModeConfig | un
topicActiveSessionTrigger: rec.topicActiveSessionTrigger !== false,
};
if (chats.length) out.chats = chats;
if (excludedChats.length) out.excludedChats = excludedChats;
const replyMode = rec.replyMode === 'quote' ? 'quote' : 'thread';
if (replyMode === 'quote') out.replyMode = 'quote';
if (rec.disableControlCard === true) out.disableControlCard = true;
Expand Down
9 changes: 8 additions & 1 deletion src/services/substitute-mode-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,14 @@ export async function updateBotSubstituteMode(
const chats = Array.isArray(rec.chats)
? [...new Set(rec.chats.map(String).map(s => s.trim()).filter(Boolean))]
: [];
const normalized = normalizeSubstituteMode({ ...rec, chats: chats.length ? chats : undefined });
const excludedChats = Array.isArray(rec.excludedChats)
? [...new Set(rec.excludedChats.map(String).map(s => s.trim()).filter(Boolean))]
: [];
const normalized = normalizeSubstituteMode({
...rec,
chats: chats.length ? chats : undefined,
excludedChats: excludedChats.length ? excludedChats : undefined,
});
if (rec.enabled === true && (!Array.isArray(rec.targets) || rec.targets.length === 0 || !normalized)) {
return { ok: false, reason: 'targets_required' };
}
Expand Down
4 changes: 3 additions & 1 deletion test/dashboard-ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1722,16 +1722,18 @@ describe('PUT /api/bot-substitute-mode', () => {
targets: [{ userId: 'u_alice', name: 'Alice' }],
disclosure: 'prefix',
replyMode: 'quote',
excludedChats: ['oc_x', ' oc_y ', '', 'oc_x'],
}),
});

expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({
ok: true,
substituteMode: { replyMode: 'quote' },
substituteMode: { replyMode: 'quote', excludedChats: ['oc_x', 'oc_y'] },
});
expect(JSON.parse(readFileSync(configPath, 'utf-8'))[0].substituteMode).toMatchObject({
replyMode: 'quote',
excludedChats: ['oc_x', 'oc_y'],
});
} finally {
if (prevBotsConfig === undefined) delete process.env.BOTS_CONFIG;
Expand Down
Loading