feat(feishu): add configurable group response mode

This commit is contained in:
xmanrui 2026-08-22 01:26:29 +08:00
parent 1a86fa5013
commit 9950261a49
19 changed files with 615 additions and 150 deletions

View file

@ -40,6 +40,10 @@ import {
workspaceListCard,
} from './feishu-cards.mjs';
import { MAX_WATCHES_PER_KEY } from './state-store.mjs';
import {
FEISHU_GROUP_RESPONSE_MODES,
normalizeFeishuGroupResponseMode,
} from './group-response-mode.mjs';
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
@ -249,6 +253,8 @@ export class FeishuHarnessBridge {
#signal;
#botId;
#appId;
#botOpenId;
#groupResponseMode;
#repair;
#repairOwnerOpenIds;
#repairAttempt = null;
@ -275,6 +281,8 @@ export class FeishuHarnessBridge {
allowedSenderOpenIds = new Set(),
botId,
appId,
botOpenId,
groupResponseMode = FEISHU_GROUP_RESPONSE_MODES.ALL,
repair,
repairOwnerOpenIds,
repairPollIntervalMs = REPAIR_POLL_INTERVAL_MS,
@ -308,6 +316,8 @@ export class FeishuHarnessBridge {
this.#allowedSenderOpenIds = allowedSenderOpenIds;
this.#botId = nonEmptyString(botId);
this.#appId = nonEmptyString(appId);
this.#botOpenId = nonEmptyString(botOpenId);
this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
this.#repair = repair ?? null;
const repairOwners = repairOwnerOpenIds ?? allowedSenderOpenIds;
this.#repairOwnerOpenIds = new Set(
@ -327,6 +337,18 @@ export class FeishuHarnessBridge {
}
}
setGroupResponseMode(value) {
this.#groupResponseMode = normalizeFeishuGroupResponseMode(value);
}
#isAddressed(event) {
if (event?.message?.chat_type === 'p2p') return true;
const mentions = Array.isArray(event?.message?.mentions) ? event.message.mentions : [];
if (!this.#botOpenId) return mentions.length > 0;
return mentions.some((mention) => mention?.id?.open_id === this.#botOpenId
|| mention?.open_id === this.#botOpenId);
}
accept(event) {
if (this.#signal?.aborted) return Promise.resolve();
const messageId = nonEmptyString(event?.message?.message_id);
@ -337,6 +359,12 @@ export class FeishuHarnessBridge {
this.#logger.warn?.('[dsh-feishu] ignored a message from a sender outside the allowlist');
return Promise.resolve();
}
const addressed = this.#isAddressed(event);
if (event?.message?.chat_type !== 'p2p'
&& this.#groupResponseMode === FEISHU_GROUP_RESPONSE_MODES.MENTION
&& !addressed) {
return Promise.resolve();
}
if (this.#state.hasSeen(messageId) || this.#acceptedMessageIds.has(messageId)) {
return Promise.resolve();
}
@ -362,8 +390,6 @@ export class FeishuHarnessBridge {
const commandRunner = isControlCommand(commandText)
? runControlCommand
: (isModelCommand(commandText) ? runModelCommand : null);
const addressed = event?.message?.chat_type === 'p2p'
|| (Array.isArray(event?.message?.mentions) && event.message.mentions.length > 0);
if (commandRunner && addressed) {
const processing = this.#processFastCommand(
event,
@ -407,8 +433,7 @@ export class FeishuHarnessBridge {
actor: senderOpenId(event),
messageId,
text: extractText(event) ?? '',
addressed: event?.message?.chat_type === 'p2p'
|| (Array.isArray(event?.message?.mentions) && event.message.mentions.length > 0),
addressed,
hasPendingQuestion: Boolean(pending),
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
? pending.queue

View file

@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
import { FeishuHarnessBridge } from './bridge.mjs';
import { cardActionProbeCard } from './feishu-cards.mjs';
import { VerifiedFeishuChannel } from './feishu-channel.mjs';
import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs';
import {
connectionTestTargetUnavailable,
sendRememberedConnectionTest,
@ -87,6 +88,8 @@ export class FeishuRuntime {
#appId;
#appSecret;
#domain;
#botOpenId;
#groupResponseMode;
#ownerOpenIds;
#harness;
#state;
@ -109,6 +112,8 @@ export class FeishuRuntime {
appId,
appSecret,
domain = 'feishu',
botOpenId,
groupResponseMode,
ownerOpenId,
ownerOpenIds,
harness,
@ -138,6 +143,8 @@ export class FeishuRuntime {
this.#appId = appId;
this.#appSecret = appSecret;
this.#domain = domain;
this.#botOpenId = nonEmptyString(botOpenId);
this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
this.#ownerOpenIds = normalizedOwners;
this.#harness = harness;
this.#state = state;
@ -153,6 +160,11 @@ export class FeishuRuntime {
return structuredClone(this.#status);
}
setGroupResponseMode(value) {
this.#groupResponseMode = normalizeFeishuGroupResponseMode(value);
this.#bridge?.setGroupResponseMode(this.#groupResponseMode);
}
async start() {
if (this.#wsClient && this.#status.ready) return this.status;
if (this.#starting) return this.#starting;
@ -202,6 +214,8 @@ export class FeishuRuntime {
allowedSenderOpenIds: new Set(this.#ownerOpenIds),
botId: this.#botId,
appId: this.#appId,
botOpenId: this.#botOpenId,
groupResponseMode: this.#groupResponseMode,
repair: this.#repair,
repairOwnerOpenIds: new Set(this.#ownerOpenIds.filter((value) => value !== '*')),
replyTimeoutMs: this.#replyTimeoutMs,

View file

@ -0,0 +1,15 @@
export const FEISHU_GROUP_RESPONSE_MODES = Object.freeze({
MENTION: 'mention',
ALL: 'all',
});
export function normalizeFeishuGroupResponseMode(value) {
return value === FEISHU_GROUP_RESPONSE_MODES.ALL
? FEISHU_GROUP_RESPONSE_MODES.ALL
: FEISHU_GROUP_RESPONSE_MODES.MENTION;
}
export function isFeishuGroupResponseMode(value) {
return value === FEISHU_GROUP_RESPONSE_MODES.MENTION
|| value === FEISHU_GROUP_RESPONSE_MODES.ALL;
}

View file

@ -6,6 +6,10 @@ import {
CallbackRepairManager,
} from './repair-manager.mjs';
import { REQUIRED_TENANT_SCOPES } from './plugin-controller.mjs';
import {
isFeishuGroupResponseMode,
normalizeFeishuGroupResponseMode,
} from './group-response-mode.mjs';
const ACTIVE_REGISTRATION_STATES = new Set([
'starting', 'qr_ready', 'polling', 'slow_down', 'domain_switched',
@ -76,6 +80,7 @@ function configuredBotFingerprint(config) {
botName: config.botName,
botOpenId: config.botOpenId,
activated: config.activated,
groupResponseMode: normalizeFeishuGroupResponseMode(config.groupResponseMode),
deletionPending: config.deletionPending === true,
connectedAt: config.connectedAt ?? null,
createdAt: config.createdAt ?? null,
@ -473,6 +478,20 @@ export class MultiBotDshFeishuController {
});
}
async updateGroupResponseMode(botId, groupResponseMode) {
this.#assertOpen();
if (!isFeishuGroupResponseMode(groupResponseMode)) {
throw new TypeError('Invalid Feishu group response mode');
}
return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
const config = this.#requireBot(botId);
const saved = await this.#configStore.saveBot({ ...config, groupResponseMode });
this.#runtimes.get(botId)?.setGroupResponseMode?.(saved.groupResponseMode);
this.#touch();
return this.status(botId);
}));
}
async deleteBot(botId) {
this.#assertOpen();
return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
@ -534,6 +553,7 @@ export class MultiBotDshFeishuController {
phase: botPhase({ connected, error, connection }),
connected,
configured: true,
groupResponseMode: normalizeFeishuGroupResponseMode(config.groupResponseMode),
bot: publicBot(config),
connection,
error,

View file

@ -1,6 +1,7 @@
import { createHash } from 'node:crypto';
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs';
export const LEGACY_FEISHU_SECRET_REF = 'DSH_FEISHU_APP_SECRET';
@ -43,6 +44,7 @@ function normalizeBot(value, { legacy = false } = {}) {
botName: cleanString(value.botName),
botOpenId: cleanString(value.botOpenId),
activated: value.activated ?? null,
groupResponseMode: normalizeFeishuGroupResponseMode(value.groupResponseMode),
deletionPending: value.deletionPending === true,
connectedAt: cleanString(value.connectedAt),
createdAt: cleanString(value.createdAt) ?? cleanString(value.connectedAt),