mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 03:03:24 +08:00
feat(feishu): add configurable group response mode
This commit is contained in:
parent
1a86fa5013
commit
9950261a49
19 changed files with 615 additions and 150 deletions
100
lib/client.js
100
lib/client.js
|
|
@ -258,6 +258,12 @@ var EN = Object.freeze({
|
|||
"WhatsApp\u8D26\u53F7": "WhatsApp account",
|
||||
"\u5FAE\u4FE1\u8BBE\u7F6E": "WeChat settings",
|
||||
"\u98DE\u4E66\u673A\u5668\u4EBA\u8BBE\u7F6E": "Feishu bot settings",
|
||||
"\u7FA4\u804A\u54CD\u5E94\u65B9\u5F0F": "Group response mode",
|
||||
"\u4EC5\u5728 @\u673A\u5668\u4EBA\u65F6\u54CD\u5E94\uFF08\u63A8\u8350\uFF09": "Only respond when @mentioned (recommended)",
|
||||
"\u54CD\u5E94\u6240\u6709\u7FA4\u6D88\u606F\uFF08\u9700\u98DE\u4E66\u654F\u611F\u6743\u9650\uFF09": "Respond to all group messages (requires a sensitive Feishu scope)",
|
||||
"\u79C1\u804A\u59CB\u7EC8\u54CD\u5E94\uFF1B\u7FA4\u804A\u4EC5\u5904\u7406\u660E\u786E @\u5F53\u524D\u673A\u5668\u4EBA\u7684\u6D88\u606F\u3002": "Direct messages always work; group chats require an explicit @mention of this bot.",
|
||||
"\u9700\u5728\u98DE\u4E66\u4E3A\u8BE5\u673A\u5668\u4EBA\u5F00\u901A\u201C\u83B7\u53D6\u7FA4\u7EC4\u4E2D\u6240\u6709\u6D88\u606F\u201D\u6743\u9650\uFF08im:message.group_msg\uFF09\uFF1B\u5F00\u901A\u540E\uFF0C\u673A\u5668\u4EBA\u4F1A\u5904\u7406\u7FA4\u804A\u4E2D\u7684\u6240\u6709\u53EF\u89C1\u6D88\u606F\u3002": "Grant this bot the \u201CRead all messages in associated group chat\u201D Feishu scope (im:message.group_msg); once granted, it will process every visible group message.",
|
||||
"\u7FA4\u804A\u54CD\u5E94\u65B9\u5F0F\u4FEE\u6539\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\u3002": "Could not update the group response mode. Try again.",
|
||||
"\u9489\u9489\u8BBE\u7F6E": "DingTalk settings",
|
||||
"\u4F01\u4E1A\u5FAE\u4FE1\u8BBE\u7F6E": "WeCom settings",
|
||||
"\u626B\u7801\u63A5\u5165\u673A\u5668\u4EBA": "Scan QR code",
|
||||
|
|
@ -3286,6 +3292,7 @@ var FEISHU_ENDPOINTS = Object.freeze({
|
|||
deleteBot: "bot.delete",
|
||||
setWorkspace: "bot.workspace.set",
|
||||
setAgentPreset: "bot.preset.set",
|
||||
setGroupResponseMode: "bot.group-response-mode.set",
|
||||
// Kept for rolling upgrades. The multi-bot UI never calls these endpoints.
|
||||
testConnection: "connection.test",
|
||||
disconnect: "connection.disconnect"
|
||||
|
|
@ -3325,6 +3332,9 @@ function optionalTimestamp(value) {
|
|||
}
|
||||
return void 0;
|
||||
}
|
||||
function normalizeGroupResponseMode(value) {
|
||||
return value === "all" ? "all" : "mention";
|
||||
}
|
||||
function clamp2(value, min, max, fallback) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
|
||||
}
|
||||
|
|
@ -3420,6 +3430,7 @@ function normalizeBotConnection(value, fallbackBotId) {
|
|||
configured: value.configured !== false,
|
||||
workspace: optionalString2(value.workspace)?.slice(0, 4096) ?? "",
|
||||
agentPreset: normalizeAgentPresetId(value.agentPreset),
|
||||
groupResponseMode: normalizeGroupResponseMode(value.groupResponseMode),
|
||||
bot: normalizeBot2(value.bot),
|
||||
health: normalizeHealth(value.health, connected),
|
||||
error: normalizeError2(value.error)
|
||||
|
|
@ -4000,6 +4011,17 @@ var CSS3 = String.raw`
|
|||
.bxf-metric dt { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; line-height: 17px; }
|
||||
.bxf-metric dd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; line-height: 18px; font-weight: 550; margin: 3px 0 0; }
|
||||
|
||||
.bxf-responseMode { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) max-content; align-items: center; column-gap: 10px; row-gap: 5px; margin-top: 6px; padding: 8px 10px; border: 1px solid var(--dsw-alias-border-l1, #eef0f3); border-radius: 9px; background: var(--dsw-alias-bg-module-platform, #f7f8fa); }
|
||||
.bxf-responseModeHeader { display: contents; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: normal; }
|
||||
.bxf-responseModeHeader > span:first-child { grid-column: 1; grid-row: 1; white-space: nowrap; }
|
||||
.bxf-responseModeStatus { grid-column: 2; grid-row: 1; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; white-space: nowrap; }
|
||||
.bxf-responseModeSelect { min-width: 0; width: 100%; grid-column: 1 / -1; grid-row: 2; height: 32px; padding: 0 30px 0 9px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; color: var(--dsw-alias-label-primary, #1f2329); background-color: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; cursor: pointer; transition: border-color .16s ease, box-shadow .16s ease, background-color .16s ease; }
|
||||
.bxf-responseModeSelect:hover:not(:disabled) { border-color: color-mix(in srgb, var(--bxf-accent) 45%, var(--dsw-alias-border-l2, #dfe1e5)); }
|
||||
.bxf-responseModeSelect:focus-visible { outline: none; border-color: var(--bxf-accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--bxf-accent) 16%, transparent); }
|
||||
.bxf-responseModeSelect:disabled { cursor: not-allowed; opacity: .55; }
|
||||
.bxf-responseModeHelp { grid-column: 1 / -1; grid-row: 3; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; line-height: 1.45; }
|
||||
.bxf-responseModeError { grid-column: 1 / -1; grid-row: 4; color: var(--bxf-error); font-size: 12px; line-height: 1.4; margin: 0; }
|
||||
|
||||
.bxf-connectedFooter { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--dsw-alias-border-l1, #eef0f3); }
|
||||
.bxf-healthSummary { min-width: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 18px; }
|
||||
.bxf-healthSummary[data-error="true"] { color: var(--bxf-error); }
|
||||
|
|
@ -4482,6 +4504,57 @@ function RemoveConfirmation2({ bot, busy, onConfirm, onCancel }) {
|
|||
)
|
||||
);
|
||||
}
|
||||
function GroupResponseModeEditor({ value, disabled = false, onSave }) {
|
||||
const current = normalizeGroupResponseMode(value);
|
||||
const [saving, setSaving] = React11.useState(false);
|
||||
const [error, setError] = React11.useState(null);
|
||||
const change = async (event) => {
|
||||
const next = normalizeGroupResponseMode(event.target.value);
|
||||
if (next === current || saving || disabled) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave?.(next);
|
||||
} catch (cause) {
|
||||
setError(cause?.message ?? "\u7FA4\u804A\u54CD\u5E94\u65B9\u5F0F\u4FEE\u6539\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\u3002");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
return h2(
|
||||
"div",
|
||||
{ className: "bxf-responseMode dim-responseMode" },
|
||||
h2(
|
||||
"div",
|
||||
{ className: "bxf-responseModeHeader dim-responseModeHeader" },
|
||||
h2("span", null, "\u7FA4\u804A\u54CD\u5E94\u65B9\u5F0F"),
|
||||
saving ? h2("span", { className: "bxf-responseModeStatus dim-responseModeStatus" }, "\u4FDD\u5B58\u4E2D\u2026") : null
|
||||
),
|
||||
h2(
|
||||
"select",
|
||||
{
|
||||
className: "bxf-responseModeSelect dim-responseModeSelect",
|
||||
value: current,
|
||||
disabled: disabled || saving,
|
||||
"aria-label": "\u7FA4\u804A\u54CD\u5E94\u65B9\u5F0F",
|
||||
onChange: (event) => {
|
||||
void change(event);
|
||||
}
|
||||
},
|
||||
h2("option", { value: "mention" }, "\u4EC5\u5728 @\u673A\u5668\u4EBA\u65F6\u54CD\u5E94\uFF08\u63A8\u8350\uFF09"),
|
||||
h2("option", { value: "all" }, "\u54CD\u5E94\u6240\u6709\u7FA4\u6D88\u606F\uFF08\u9700\u98DE\u4E66\u654F\u611F\u6743\u9650\uFF09")
|
||||
),
|
||||
h2(
|
||||
"small",
|
||||
{ className: "bxf-responseModeHelp dim-responseModeHelp" },
|
||||
current === "mention" ? "\u79C1\u804A\u59CB\u7EC8\u54CD\u5E94\uFF1B\u7FA4\u804A\u4EC5\u5904\u7406\u660E\u786E @\u5F53\u524D\u673A\u5668\u4EBA\u7684\u6D88\u606F\u3002" : "\u9700\u5728\u98DE\u4E66\u4E3A\u8BE5\u673A\u5668\u4EBA\u5F00\u901A\u201C\u83B7\u53D6\u7FA4\u7EC4\u4E2D\u6240\u6709\u6D88\u606F\u201D\u6743\u9650\uFF08im:message.group_msg\uFF09\uFF1B\u5F00\u901A\u540E\uFF0C\u673A\u5668\u4EBA\u4F1A\u5904\u7406\u7FA4\u804A\u4E2D\u7684\u6240\u6709\u53EF\u89C1\u6D88\u606F\u3002"
|
||||
),
|
||||
error ? h2("p", {
|
||||
className: "bxf-responseModeError dim-responseModeError",
|
||||
role: "alert"
|
||||
}, error) : null
|
||||
);
|
||||
}
|
||||
function BotCard({
|
||||
connection,
|
||||
busy,
|
||||
|
|
@ -4493,6 +4566,7 @@ function BotCard({
|
|||
onRepairCallback,
|
||||
onWorkspaceSave,
|
||||
onAgentPresetSave,
|
||||
onGroupResponseModeSave,
|
||||
onRequestRemove,
|
||||
onConfirmRemove,
|
||||
onCancelRemove,
|
||||
|
|
@ -4567,6 +4641,11 @@ function BotCard({
|
|||
disabled: Boolean(busy),
|
||||
onSave: onAgentPresetSave
|
||||
}),
|
||||
h2(GroupResponseModeEditor, {
|
||||
value: connection.groupResponseMode,
|
||||
disabled: Boolean(busy),
|
||||
onSave: onGroupResponseModeSave
|
||||
}),
|
||||
h2(
|
||||
"div",
|
||||
{ className: "bxf-connectedFooter dim-cardFooter" },
|
||||
|
|
@ -4641,6 +4720,7 @@ function BotList(props) {
|
|||
onRepairCallback: () => props.onRepairCallback(bot),
|
||||
onWorkspaceSave: (workspace) => props.onWorkspaceSave(bot, workspace),
|
||||
onAgentPresetSave: (agentPreset) => props.onAgentPresetSave(bot, agentPreset),
|
||||
onGroupResponseModeSave: (groupResponseMode) => props.onGroupResponseModeSave(bot, groupResponseMode),
|
||||
onRequestRemove: () => props.onRequestRemove(bot),
|
||||
onConfirmRemove: () => props.onConfirmRemove(bot),
|
||||
onCancelRemove: props.onCancelRemove,
|
||||
|
|
@ -5151,6 +5231,25 @@ function FeishuSettingsTab({ rpcCall }) {
|
|||
if (mountedRef.current) setBotBusy(botId, null);
|
||||
}
|
||||
}, [invoke, loadStatus, mergeSnapshot, setBotBusy, setBotError, workspaceFence]);
|
||||
const saveGroupResponseMode = React11.useCallback(async (connection, groupResponseMode) => {
|
||||
const { botId } = connection;
|
||||
const snapshotVersion = workspaceFence.beginMutation();
|
||||
setBotBusy(botId, "group-response-mode");
|
||||
setBotError(botId, null);
|
||||
try {
|
||||
const snapshot = normalizeBotsSnapshot(await invoke(
|
||||
FEISHU_ENDPOINTS.setGroupResponseMode,
|
||||
{ botId, groupResponseMode }
|
||||
));
|
||||
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
|
||||
mergeSnapshot(snapshot);
|
||||
}
|
||||
} finally {
|
||||
const shouldRefresh = workspaceFence.endMutation();
|
||||
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
|
||||
if (mountedRef.current) setBotBusy(botId, null);
|
||||
}
|
||||
}, [invoke, loadStatus, mergeSnapshot, setBotBusy, setBotError, workspaceFence]);
|
||||
const requestRemove = React11.useCallback((connection) => {
|
||||
setRemoveTargetId(connection.botId);
|
||||
}, []);
|
||||
|
|
@ -5303,6 +5402,7 @@ function FeishuSettingsTab({ rpcCall }) {
|
|||
onRepairCallback: repairCallback,
|
||||
onWorkspaceSave: saveWorkspace,
|
||||
onAgentPresetSave: saveAgentPreset,
|
||||
onGroupResponseModeSave: saveGroupResponseMode,
|
||||
onRequestRemove: requestRemove,
|
||||
onConfirmRemove: (bot) => void confirmRemove(bot),
|
||||
onCancelRemove: cancelRemove,
|
||||
|
|
|
|||
292
lib/index.js
292
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -22,6 +22,7 @@ export const FEISHU_ENDPOINTS = Object.freeze({
|
|||
deleteBot: "bot.delete",
|
||||
setWorkspace: "bot.workspace.set",
|
||||
setAgentPreset: "bot.preset.set",
|
||||
setGroupResponseMode: "bot.group-response-mode.set",
|
||||
// Kept for rolling upgrades. The multi-bot UI never calls these endpoints.
|
||||
testConnection: "connection.test",
|
||||
disconnect: "connection.disconnect",
|
||||
|
|
@ -70,6 +71,10 @@ function optionalTimestamp(value) {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
export function normalizeGroupResponseMode(value) {
|
||||
return value === "all" ? "all" : "mention";
|
||||
}
|
||||
|
||||
function clamp(value, min, max, fallback) {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? Math.min(max, Math.max(min, value))
|
||||
|
|
@ -186,6 +191,7 @@ export function normalizeBotConnection(value, fallbackBotId) {
|
|||
configured: value.configured !== false,
|
||||
workspace: optionalString(value.workspace)?.slice(0, 4_096) ?? "",
|
||||
agentPreset: normalizeAgentPresetId(value.agentPreset),
|
||||
groupResponseMode: normalizeGroupResponseMode(value.groupResponseMode),
|
||||
bot: normalizeBot(value.bot),
|
||||
health: normalizeHealth(value.health, connected),
|
||||
error: normalizeError(value.error),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
FEISHU_RPC_CHANNEL,
|
||||
formatRemaining,
|
||||
normalizeBotsSnapshot,
|
||||
normalizeGroupResponseMode,
|
||||
normalizePollResult,
|
||||
normalizeProvisioning,
|
||||
presentError,
|
||||
|
|
@ -413,6 +414,50 @@ function RemoveConfirmation({ bot, busy, onConfirm, onCancel }) {
|
|||
);
|
||||
}
|
||||
|
||||
function GroupResponseModeEditor({ value, disabled = false, onSave }) {
|
||||
const current = normalizeGroupResponseMode(value);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState(null);
|
||||
|
||||
const change = async (event) => {
|
||||
const next = normalizeGroupResponseMode(event.target.value);
|
||||
if (next === current || saving || disabled) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave?.(next);
|
||||
} catch (cause) {
|
||||
setError(cause?.message ?? "群聊响应方式修改失败,请重试。");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return h("div", { className: "bxf-responseMode dim-responseMode" },
|
||||
h("div", { className: "bxf-responseModeHeader dim-responseModeHeader" },
|
||||
h("span", null, "群聊响应方式"),
|
||||
saving ? h("span", { className: "bxf-responseModeStatus dim-responseModeStatus" }, "保存中…") : null),
|
||||
h("select", {
|
||||
className: "bxf-responseModeSelect dim-responseModeSelect",
|
||||
value: current,
|
||||
disabled: disabled || saving,
|
||||
"aria-label": "群聊响应方式",
|
||||
onChange: (event) => { void change(event); },
|
||||
},
|
||||
h("option", { value: "mention" }, "仅在 @机器人时响应(推荐)"),
|
||||
h("option", { value: "all" }, "响应所有群消息(需飞书敏感权限)"),
|
||||
),
|
||||
h("small", { className: "bxf-responseModeHelp dim-responseModeHelp" },
|
||||
current === "mention"
|
||||
? "私聊始终响应;群聊仅处理明确 @当前机器人的消息。"
|
||||
: "需在飞书为该机器人开通“获取群组中所有消息”权限(im:message.group_msg);开通后,机器人会处理群聊中的所有可见消息。"),
|
||||
error ? h("p", {
|
||||
className: "bxf-responseModeError dim-responseModeError",
|
||||
role: "alert",
|
||||
}, error) : null,
|
||||
);
|
||||
}
|
||||
|
||||
export function BotCard({
|
||||
connection,
|
||||
busy,
|
||||
|
|
@ -424,6 +469,7 @@ export function BotCard({
|
|||
onRepairCallback,
|
||||
onWorkspaceSave,
|
||||
onAgentPresetSave,
|
||||
onGroupResponseModeSave,
|
||||
onRequestRemove,
|
||||
onConfirmRemove,
|
||||
onCancelRemove,
|
||||
|
|
@ -479,6 +525,11 @@ export function BotCard({
|
|||
disabled: Boolean(busy),
|
||||
onSave: onAgentPresetSave,
|
||||
}),
|
||||
h(GroupResponseModeEditor, {
|
||||
value: connection.groupResponseMode,
|
||||
disabled: Boolean(busy),
|
||||
onSave: onGroupResponseModeSave,
|
||||
}),
|
||||
h("div", { className: "bxf-connectedFooter dim-cardFooter" },
|
||||
summary ? h("div", { className: "bxf-healthSummary dim-cardSummary", "data-error": actionError || connection.error ? "true" : undefined },
|
||||
summary) : null,
|
||||
|
|
@ -536,6 +587,7 @@ function BotList(props) {
|
|||
onRepairCallback: () => props.onRepairCallback(bot),
|
||||
onWorkspaceSave: (workspace) => props.onWorkspaceSave(bot, workspace),
|
||||
onAgentPresetSave: (agentPreset) => props.onAgentPresetSave(bot, agentPreset),
|
||||
onGroupResponseModeSave: (groupResponseMode) => props.onGroupResponseModeSave(bot, groupResponseMode),
|
||||
onRequestRemove: () => props.onRequestRemove(bot),
|
||||
onConfirmRemove: () => props.onConfirmRemove(bot),
|
||||
onCancelRemove: props.onCancelRemove,
|
||||
|
|
@ -1101,6 +1153,26 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
}
|
||||
}, [invoke, loadStatus, mergeSnapshot, setBotBusy, setBotError, workspaceFence]);
|
||||
|
||||
const saveGroupResponseMode = React.useCallback(async (connection, groupResponseMode) => {
|
||||
const { botId } = connection;
|
||||
const snapshotVersion = workspaceFence.beginMutation();
|
||||
setBotBusy(botId, "group-response-mode");
|
||||
setBotError(botId, null);
|
||||
try {
|
||||
const snapshot = normalizeBotsSnapshot(await invoke(
|
||||
FEISHU_ENDPOINTS.setGroupResponseMode,
|
||||
{ botId, groupResponseMode },
|
||||
));
|
||||
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
|
||||
mergeSnapshot(snapshot);
|
||||
}
|
||||
} finally {
|
||||
const shouldRefresh = workspaceFence.endMutation();
|
||||
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
|
||||
if (mountedRef.current) setBotBusy(botId, null);
|
||||
}
|
||||
}, [invoke, loadStatus, mergeSnapshot, setBotBusy, setBotError, workspaceFence]);
|
||||
|
||||
const requestRemove = React.useCallback((connection) => {
|
||||
setRemoveTargetId(connection.botId);
|
||||
}, []);
|
||||
|
|
@ -1254,6 +1326,7 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
onRepairCallback: repairCallback,
|
||||
onWorkspaceSave: saveWorkspace,
|
||||
onAgentPresetSave: saveAgentPreset,
|
||||
onGroupResponseModeSave: saveGroupResponseMode,
|
||||
onRequestRemove: requestRemove,
|
||||
onConfirmRemove: (bot) => void confirmRemove(bot),
|
||||
onCancelRemove: cancelRemove,
|
||||
|
|
|
|||
|
|
@ -407,6 +407,17 @@ const CSS = String.raw`
|
|||
.bxf-metric dt { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; line-height: 17px; }
|
||||
.bxf-metric dd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; line-height: 18px; font-weight: 550; margin: 3px 0 0; }
|
||||
|
||||
.bxf-responseMode { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) max-content; align-items: center; column-gap: 10px; row-gap: 5px; margin-top: 6px; padding: 8px 10px; border: 1px solid var(--dsw-alias-border-l1, #eef0f3); border-radius: 9px; background: var(--dsw-alias-bg-module-platform, #f7f8fa); }
|
||||
.bxf-responseModeHeader { display: contents; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: normal; }
|
||||
.bxf-responseModeHeader > span:first-child { grid-column: 1; grid-row: 1; white-space: nowrap; }
|
||||
.bxf-responseModeStatus { grid-column: 2; grid-row: 1; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; white-space: nowrap; }
|
||||
.bxf-responseModeSelect { min-width: 0; width: 100%; grid-column: 1 / -1; grid-row: 2; height: 32px; padding: 0 30px 0 9px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; color: var(--dsw-alias-label-primary, #1f2329); background-color: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; cursor: pointer; transition: border-color .16s ease, box-shadow .16s ease, background-color .16s ease; }
|
||||
.bxf-responseModeSelect:hover:not(:disabled) { border-color: color-mix(in srgb, var(--bxf-accent) 45%, var(--dsw-alias-border-l2, #dfe1e5)); }
|
||||
.bxf-responseModeSelect:focus-visible { outline: none; border-color: var(--bxf-accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--bxf-accent) 16%, transparent); }
|
||||
.bxf-responseModeSelect:disabled { cursor: not-allowed; opacity: .55; }
|
||||
.bxf-responseModeHelp { grid-column: 1 / -1; grid-row: 3; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; line-height: 1.45; }
|
||||
.bxf-responseModeError { grid-column: 1 / -1; grid-row: 4; color: var(--bxf-error); font-size: 12px; line-height: 1.4; margin: 0; }
|
||||
|
||||
.bxf-connectedFooter { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--dsw-alias-border-l1, #eef0f3); }
|
||||
.bxf-healthSummary { min-width: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 18px; }
|
||||
.bxf-healthSummary[data-error="true"] { color: var(--bxf-error); }
|
||||
|
|
|
|||
|
|
@ -68,6 +68,12 @@ const EN = Object.freeze({
|
|||
'WhatsApp账号': 'WhatsApp account',
|
||||
'微信设置': 'WeChat settings',
|
||||
'飞书机器人设置': 'Feishu bot settings',
|
||||
'群聊响应方式': 'Group response mode',
|
||||
'仅在 @机器人时响应(推荐)': 'Only respond when @mentioned (recommended)',
|
||||
'响应所有群消息(需飞书敏感权限)': 'Respond to all group messages (requires a sensitive Feishu scope)',
|
||||
'私聊始终响应;群聊仅处理明确 @当前机器人的消息。': 'Direct messages always work; group chats require an explicit @mention of this bot.',
|
||||
'需在飞书为该机器人开通“获取群组中所有消息”权限(im:message.group_msg);开通后,机器人会处理群聊中的所有可见消息。': 'Grant this bot the “Read all messages in associated group chat” Feishu scope (im:message.group_msg); once granted, it will process every visible group message.',
|
||||
'群聊响应方式修改失败,请重试。': 'Could not update the group response mode. Try again.',
|
||||
'钉钉设置': 'DingTalk settings',
|
||||
'企业微信设置': 'WeCom settings',
|
||||
'扫码接入机器人': 'Scan QR code',
|
||||
|
|
|
|||
|
|
@ -144,6 +144,8 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
appId: botConfig.appId,
|
||||
appSecret,
|
||||
domain: botConfig.domain,
|
||||
botOpenId: botConfig.botOpenId,
|
||||
groupResponseMode: botConfig.groupResponseMode,
|
||||
ownerOpenIds: botConfig.ownerOpenIds ?? [botConfig.ownerOpenId],
|
||||
harness: workspaceScope.harness,
|
||||
state: workspaceScope.state,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ import { publicConnectionTestResult } from '../../../../src/channels/shared/conn
|
|||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import { publicWorkspaceError, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
|
||||
import { validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
|
||||
import {
|
||||
isFeishuGroupResponseMode,
|
||||
normalizeFeishuGroupResponseMode,
|
||||
} from '../../../../src/channels/feishu/group-response-mode.mjs';
|
||||
import {
|
||||
FEISHU_ENDPOINTS,
|
||||
FEISHU_RPC_CHANNEL,
|
||||
|
|
@ -259,6 +263,7 @@ function publicBotEntry(entry) {
|
|||
connected,
|
||||
configured: source.configured === true,
|
||||
agentPreset: normalizeAgentPresetId(source.agentPreset),
|
||||
groupResponseMode: normalizeFeishuGroupResponseMode(source.groupResponseMode),
|
||||
bot: publicBot(source.bot),
|
||||
health: publicHealth(source, connected),
|
||||
};
|
||||
|
|
@ -389,6 +394,13 @@ function validPayload(endpoint, payload) {
|
|||
return validAgentPresetPayload(payload)
|
||||
? null : '请选择 Agent Preset。';
|
||||
}
|
||||
if (endpoint === FEISHU_ENDPOINTS.setGroupResponseMode) {
|
||||
return hasOnlyKeys(payload, new Set(['botId', 'groupResponseMode']))
|
||||
&& safeOpaqueId(payload.botId)
|
||||
&& isFeishuGroupResponseMode(payload.groupResponseMode)
|
||||
? null
|
||||
: '请选择群聊响应方式。';
|
||||
}
|
||||
return 'Unknown Feishu endpoint.';
|
||||
}
|
||||
|
||||
|
|
@ -623,6 +635,14 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
|
|||
await controller.updateAgentPreset(payload.botId, payload.agentPreset),
|
||||
{ encodeQr: cachedEncodeQr },
|
||||
);
|
||||
} else if (endpoint === FEISHU_ENDPOINTS.setGroupResponseMode) {
|
||||
if (typeof controller.updateGroupResponseMode !== 'function') {
|
||||
throw new Error('Group response mode update is unavailable');
|
||||
}
|
||||
value = await toPublicFeishuStatus(
|
||||
await controller.updateGroupResponseMode(payload.botId, payload.groupResponseMode),
|
||||
{ encodeQr: cachedEncodeQr },
|
||||
);
|
||||
} else {
|
||||
if (typeof controller.deleteBot !== 'function') throw new Error('Multi-bot delete is unavailable');
|
||||
value = await toPublicFeishuStatus(await controller.deleteBot(payload.botId), { encodeQr: cachedEncodeQr });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
15
src/channels/feishu/group-response-mode.mjs
Normal file
15
src/channels/feishu/group-response-mode.mjs
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -306,6 +306,51 @@ test('bridge maps a Feishu conversation to a persistent Harness session and repl
|
|||
assert.equal(status.messagesRejected, 1);
|
||||
});
|
||||
|
||||
test('mention response mode ignores unaddressed groups and only accepts this bot mention', async () => {
|
||||
const fixture = stateFixture([['group:oc_group_mentions', 'session-group-mentions']]);
|
||||
const asked = [];
|
||||
const status = bridgeStatus();
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async () => undefined),
|
||||
channel: {},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, text) => {
|
||||
asked.push(text);
|
||||
return '收到';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status,
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
botOpenId: 'ou_bot',
|
||||
groupResponseMode: 'mention',
|
||||
});
|
||||
|
||||
await bridge.accept(event('group-unaddressed', '普通群消息', {
|
||||
chat_type: 'group', chat_id: 'oc_group_mentions',
|
||||
}));
|
||||
await bridge.accept(event('group-mentions-someone-else', '@_other 你好', {
|
||||
chat_type: 'group', chat_id: 'oc_group_mentions',
|
||||
mentions: [{ key: '@_other', id: { open_id: 'ou_other' } }],
|
||||
}));
|
||||
assert.deepEqual(asked, []);
|
||||
assert.equal(status.messagesReceived, 0);
|
||||
|
||||
await bridge.accept(event('group-mentions-bot', '@_bot 你好', {
|
||||
chat_type: 'group', chat_id: 'oc_group_mentions',
|
||||
mentions: [{ key: '@_bot', id: { open_id: 'ou_bot' } }],
|
||||
}));
|
||||
assert.deepEqual(asked, ['你好']);
|
||||
assert.equal(status.messagesReceived, 1);
|
||||
|
||||
bridge.setGroupResponseMode('all');
|
||||
await bridge.accept(event('group-all-mode', '无需提及', {
|
||||
chat_type: 'group', chat_id: 'oc_group_mentions',
|
||||
}));
|
||||
assert.deepEqual(asked, ['你好', '无需提及']);
|
||||
});
|
||||
|
||||
test('bridge downloads an inbound Feishu image once and submits structured Harness content', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-image']]);
|
||||
const downloaded = [];
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ test('multi-bot endpoints are bot-scoped and keep legacy operations separate', (
|
|||
assert.equal(FEISHU_ENDPOINTS.reconnectBot, 'bot.reconnect');
|
||||
assert.equal(FEISHU_ENDPOINTS.disconnectBot, 'bot.disconnect');
|
||||
assert.equal(FEISHU_ENDPOINTS.deleteBot, 'bot.delete');
|
||||
assert.equal(FEISHU_ENDPOINTS.setGroupResponseMode, 'bot.group-response-mode.set');
|
||||
assert.equal(FEISHU_ENDPOINTS.testConnection, 'connection.test');
|
||||
});
|
||||
|
||||
|
|
@ -33,6 +34,7 @@ test('client normalizes multiple independent bots and derives authoritative tota
|
|||
state: 'connected',
|
||||
connected: true,
|
||||
configured: true,
|
||||
groupResponseMode: 'all',
|
||||
bot: {
|
||||
name: '销售助手',
|
||||
appIdMasked: 'cli_aaaa••••1111',
|
||||
|
|
@ -58,6 +60,8 @@ test('client normalizes multiple independent bots and derives authoritative tota
|
|||
assert.equal(snapshot.revision, 9);
|
||||
assert.deepEqual(snapshot.totals, { configured: 2, connected: 1 });
|
||||
assert.equal(snapshot.bots[0].state, 'connected');
|
||||
assert.equal(snapshot.bots[0].groupResponseMode, 'all');
|
||||
assert.equal(snapshot.bots[1].groupResponseMode, 'mention');
|
||||
assert.equal(snapshot.bots[1].state, 'connecting');
|
||||
assert.equal(snapshot.bots[1].bot.domain, 'lark');
|
||||
assert.equal(snapshot.bots[1].error.message, '连接失败');
|
||||
|
|
|
|||
|
|
@ -51,6 +51,44 @@ test('Feishu connection check requests and displays test-message feedback', asyn
|
|||
assert.match(markup, /role="status"[^>]*>测试消息已发送/);
|
||||
assert.match(markup, /修复卡片按钮/);
|
||||
assert.match(markup, /aria-label="修复飞书测试机器人的卡片按钮"/);
|
||||
assert.match(markup, /<select[^>]*aria-label="群聊响应方式"/);
|
||||
assert.match(markup, /仅在 @机器人时响应(推荐)/);
|
||||
assert.match(markup, /响应所有群消息(需飞书敏感权限)/);
|
||||
});
|
||||
|
||||
test('Feishu bot card saves group response mode from a dropdown', async () => {
|
||||
const saved = [];
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(BotCard, {
|
||||
connection: {
|
||||
botId: 'bot-mode-test',
|
||||
state: 'connected',
|
||||
connected: true,
|
||||
groupResponseMode: 'mention',
|
||||
bot: { name: '响应模式机器人', appIdMasked: 'cli_mode••••test' },
|
||||
health: { summary: '长连接运行正常', lastCheckedAt: Date.now() },
|
||||
},
|
||||
onGroupResponseModeSave: async (value) => saved.push(value),
|
||||
onReconnect() {},
|
||||
onRequestRemove() {},
|
||||
onConfirmRemove() {},
|
||||
onCancelRemove() {},
|
||||
}));
|
||||
});
|
||||
const select = renderer.root.findByProps({ 'aria-label': '群聊响应方式' });
|
||||
assert.equal(select.type, 'select');
|
||||
assert.equal(select.props.value, 'mention');
|
||||
assert.deepEqual(select.findAllByType('option').map((option) => option.props.value), [
|
||||
'mention', 'all',
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
select.props.onChange({ target: { value: 'all' } });
|
||||
await flushMicrotasks();
|
||||
});
|
||||
assert.deepEqual(saved, ['all']);
|
||||
await act(async () => renderer.unmount());
|
||||
});
|
||||
|
||||
test('Feishu callback repair keeps a Host-submitted attempt when a stale QR cancel races saving', async (t) => {
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ function fixture({
|
|||
stops: 0,
|
||||
sentTests: [],
|
||||
probes: [],
|
||||
responseModes: [],
|
||||
repair,
|
||||
get status() { return structuredClone(status); },
|
||||
async start() {
|
||||
|
|
@ -126,6 +127,10 @@ function fixture({
|
|||
runtime.sentTests.push(text);
|
||||
return { sent: true };
|
||||
},
|
||||
setGroupResponseMode(mode) {
|
||||
runtime.responseModes.push(mode);
|
||||
runtime.config.groupResponseMode = mode;
|
||||
},
|
||||
async beginCardActionProbe(options) {
|
||||
runtime.probes.push(structuredClone(options));
|
||||
if (callbackProbe) return callbackProbe({ botId, runtime, options });
|
||||
|
|
@ -184,6 +189,29 @@ test('QR registration separates events from card callbacks', async () => {
|
|||
await fx.controller.close();
|
||||
});
|
||||
|
||||
test('group response mode defaults to mention and updates the live runtime without reconnecting', async () => {
|
||||
const existing = bot('bot_response_mode', 'response_mode');
|
||||
const fx = fixture({
|
||||
bots: [existing],
|
||||
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||
});
|
||||
await fx.controller.initialize();
|
||||
|
||||
assert.equal(fx.controller.status().bots[0].groupResponseMode, 'mention');
|
||||
const runtime = fx.runtimes.get(existing.id)[0];
|
||||
const updated = await fx.controller.updateGroupResponseMode(existing.id, 'all');
|
||||
|
||||
assert.equal(updated.bots[0].groupResponseMode, 'all');
|
||||
assert.equal(fx.configStore.getBot(existing.id).groupResponseMode, 'all');
|
||||
assert.deepEqual(runtime.responseModes, ['all']);
|
||||
assert.equal(fx.runtimes.get(existing.id).length, 1);
|
||||
await assert.rejects(
|
||||
fx.controller.updateGroupResponseMode(existing.id, 'sometimes'),
|
||||
/Invalid Feishu group response mode/,
|
||||
);
|
||||
await fx.controller.close();
|
||||
});
|
||||
|
||||
test('callback repair is deduplicated per bot, updates only its secret, and proves the callback', async () => {
|
||||
const existing = bot('bot_existing', 'existing');
|
||||
const fx = fixture({
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ test('PluginConfigStore persists non-secret onboarding facts', async () => {
|
|||
assert.doesNotMatch(raw, /must-not-be-written/);
|
||||
assert.equal((await stat(path)).mode & 0o777, 0o600);
|
||||
assert.equal((await new PluginConfigStore(path).load()).get().appId, 'cli_test');
|
||||
assert.equal(store.get().groupResponseMode, 'mention');
|
||||
|
||||
await store.save({ ...store.get(), groupResponseMode: 'all' });
|
||||
assert.equal((await new PluginConfigStore(path).load()).get().groupResponseMode, 'all');
|
||||
|
||||
await store.clear();
|
||||
assert.equal(store.get(), null);
|
||||
|
|
@ -66,6 +70,7 @@ test('PluginConfigStore migrates a v1 bot atomically without moving its credenti
|
|||
assert.deepEqual(migrated.bots[0].ownerOpenIds, ['ou_legacy']);
|
||||
assert.equal(store.get().ownerOpenId, 'ou_legacy');
|
||||
assert.equal(store.list()[0].appId, 'cli_legacy');
|
||||
assert.equal(store.list()[0].groupResponseMode, 'mention');
|
||||
});
|
||||
|
||||
test('PluginConfigStore rejects an invalid or duplicate v2 document without dropping entries', async () => {
|
||||
|
|
|
|||
|
|
@ -198,6 +198,57 @@ test('Host exposes only browser-safe Agent Preset fields in status and update re
|
|||
await fx.dispose();
|
||||
});
|
||||
|
||||
test('Host validates and updates the Feishu group response mode', async () => {
|
||||
let mode = 'mention';
|
||||
const current = () => status({
|
||||
schemaVersion: 2,
|
||||
revision: 4,
|
||||
configured: true,
|
||||
bots: [{
|
||||
botId: 'bot_mode',
|
||||
phase: 'connected',
|
||||
connected: true,
|
||||
configured: true,
|
||||
groupResponseMode: mode,
|
||||
bot: { name: '模式机器人', domain: 'feishu' },
|
||||
connection: {
|
||||
ready: true,
|
||||
feishuLongConnectionState: 'connected',
|
||||
harnessReachable: true,
|
||||
},
|
||||
}],
|
||||
});
|
||||
const controller = {
|
||||
status: async () => current(),
|
||||
startRegistration: async () => current(),
|
||||
cancelRegistration: async () => current(),
|
||||
disconnect: async () => status(),
|
||||
updateGroupResponseMode: async (botId, value) => {
|
||||
assert.equal(botId, 'bot_mode');
|
||||
mode = value;
|
||||
return current();
|
||||
},
|
||||
};
|
||||
const fx = await rpcFixture(controller);
|
||||
|
||||
const updated = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.setGroupResponseMode,
|
||||
{ botId: 'bot_mode', groupResponseMode: 'all' },
|
||||
signal(),
|
||||
);
|
||||
assert.equal(updated.ok, true);
|
||||
assert.equal(updated.value.bots[0].groupResponseMode, 'all');
|
||||
|
||||
const invalid = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.setGroupResponseMode,
|
||||
{ botId: 'bot_mode', groupResponseMode: 'sometimes' },
|
||||
signal(),
|
||||
);
|
||||
assert.equal(invalid.ok, false);
|
||||
assert.equal(invalid.error.code, 'bad-request');
|
||||
await fx.dispose();
|
||||
});
|
||||
|
||||
test('RPC dispatch matches every endpoint in client/api.js', async () => {
|
||||
const calls = [];
|
||||
let current = status({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue