@xmanrui/dsh-im 4.29.1 → 4.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +3 -1
- package/README.md +3 -1
- package/lib/client.js +557 -136
- package/lib/index.js +291 -291
- package/package.json +1 -1
- package/plugin-src/client/channels/feishu/api.js +5 -2
- package/plugin-src/client/channels/feishu/group-settings.js +14 -14
- package/plugin-src/client/channels/feishu/index.js +2 -1
- package/plugin-src/client/channels/feishu/slash-panel-settings.js +322 -0
- package/plugin-src/client/delivery-settings.js +16 -0
- package/plugin-src/client/i18n.js +34 -5
- package/plugin-src/client/styles.js +9 -0
- package/plugin-src/host/channels/feishu/production.mjs +2 -1
- package/plugin-src/host/channels/feishu/rpc.mjs +28 -8
- package/src/channels/feishu/bridge.mjs +133 -41
- package/src/channels/feishu/feishu-channel.mjs +9 -4
- package/src/channels/feishu/feishu-runtime.mjs +86 -15
- package/src/channels/feishu/message-utils.mjs +48 -12
- package/src/channels/feishu/multi-bot-controller.mjs +29 -7
- package/src/channels/feishu/plugin-config-store.mjs +3 -1
- package/src/channels/feishu/slash-command-panel.mjs +155 -0
- package/src/channels/feishu/slash-command-registry.mjs +257 -41
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmanrui/dsh-im",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.30.0",
|
|
4
4
|
"icon": "./assets/logo-plugin-message-link-gradient.webp",
|
|
5
5
|
"description": "把十二种 IM 渠道和公网 AI Office 接入本机 DeepSeek Harness。 Connect twelve IM channels and a public AI Office to a local DeepSeek Harness.",
|
|
6
6
|
"keywords": [
|
|
@@ -15,6 +15,7 @@ import { normalizeAccessPolicy } from "../../../../src/channels/shared/access-po
|
|
|
15
15
|
import { normalizeContextEnhancementConfig } from "../../../../src/channels/shared/context-enhancement.mjs";
|
|
16
16
|
import { normalizeFeishuStepPushMode } from "../../../../src/channels/feishu/step-push-mode.mjs";
|
|
17
17
|
import { normalizeFeishuVoiceConfig } from "../../../../src/channels/feishu/voice-config.mjs";
|
|
18
|
+
import { normalizeSlashPanelConfig } from "../../../../src/channels/feishu/slash-command-panel.mjs";
|
|
18
19
|
|
|
19
20
|
export const FEISHU_RPC_CHANNEL = "/feishu";
|
|
20
21
|
|
|
@@ -36,10 +37,11 @@ export const FEISHU_ENDPOINTS = Object.freeze({
|
|
|
36
37
|
setAccessPolicy: "bot.access-policy.set",
|
|
37
38
|
setAlias: 'bot.alias.set',
|
|
38
39
|
setGroupResponseMode: "bot.group-response-mode.set",
|
|
39
|
-
|
|
40
|
+
setMentionTopicReply: "bot.mention-topic-reply.set",
|
|
40
41
|
setStepPush: "bot.step-push.set",
|
|
41
42
|
setStepPushMode: "bot.step-push-mode.set",
|
|
42
43
|
setVoice: "bot.voice.set",
|
|
44
|
+
setSlashPanel: "bot.slash-panel.set",
|
|
43
45
|
// Kept for rolling upgrades. The multi-bot UI never calls these endpoints.
|
|
44
46
|
testConnection: "connection.test",
|
|
45
47
|
disconnect: "connection.disconnect",
|
|
@@ -226,10 +228,11 @@ export function normalizeBotConnection(value, fallbackBotId) {
|
|
|
226
228
|
? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) }
|
|
227
229
|
: {}),
|
|
228
230
|
groupResponseMode: normalizeGroupResponseMode(value.groupResponseMode),
|
|
229
|
-
|
|
231
|
+
mentionTopicReply: value.mentionTopicReply !== false,
|
|
230
232
|
stepPush: value.stepPush === true,
|
|
231
233
|
stepPushMode: normalizeFeishuStepPushMode(value.stepPushMode),
|
|
232
234
|
voice: normalizeFeishuVoiceConfig(value.voice),
|
|
235
|
+
slashPanel: normalizeSlashPanelConfig(value.slashPanel),
|
|
233
236
|
groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
|
|
234
237
|
bot: normalizeBot(value.bot),
|
|
235
238
|
health: normalizeHealth(value.health, connected),
|
|
@@ -27,7 +27,7 @@ function SettingsButton({ children, kind = 'secondary', className = '', ...props
|
|
|
27
27
|
function groupSettingsFrom(value) {
|
|
28
28
|
return {
|
|
29
29
|
groupResponseMode: normalizeGroupResponseMode(value?.groupResponseMode),
|
|
30
|
-
|
|
30
|
+
mentionTopicReply: value?.mentionTopicReply !== false,
|
|
31
31
|
groupMessagePermissionGranted: value?.groupMessagePermissionGranted === true,
|
|
32
32
|
};
|
|
33
33
|
}
|
|
@@ -161,7 +161,7 @@ export function GroupResponseModeEditor({
|
|
|
161
161
|
}, error) : null);
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
export function
|
|
164
|
+
export function MentionTopicReplyEditor({ value = true, disabled = false, onSave }) {
|
|
165
165
|
const current = value === true ? 'on' : 'off';
|
|
166
166
|
const [saving, setSaving] = React.useState(false);
|
|
167
167
|
const [error, setError] = React.useState(null);
|
|
@@ -174,7 +174,7 @@ export function GroupTopicReplyEditor({ value = false, disabled = false, onSave
|
|
|
174
174
|
try {
|
|
175
175
|
await onSave?.(next);
|
|
176
176
|
} catch (cause) {
|
|
177
|
-
setError(cause?.message ?? '
|
|
177
|
+
setError(cause?.message ?? '以话题方式回复设置保存失败,请重试。');
|
|
178
178
|
} finally {
|
|
179
179
|
setSaving(false);
|
|
180
180
|
}
|
|
@@ -182,10 +182,10 @@ export function GroupTopicReplyEditor({ value = false, disabled = false, onSave
|
|
|
182
182
|
|
|
183
183
|
return h('section', {
|
|
184
184
|
className: 'dim-feishuGroupControl',
|
|
185
|
-
'aria-labelledby': 'dim-feishu-
|
|
185
|
+
'aria-labelledby': 'dim-feishu-mention-topic-title',
|
|
186
186
|
},
|
|
187
187
|
h('div', { className: 'dim-feishuGroupControlHeader' },
|
|
188
|
-
h('h3', { id: 'dim-feishu-
|
|
188
|
+
h('h3', { id: 'dim-feishu-mention-topic-title' }, '被 @ 时以话题方式回复'),
|
|
189
189
|
saving
|
|
190
190
|
? h('span', { className: 'dim-feishuGroupControlStatus', role: 'status' }, '保存中…')
|
|
191
191
|
: null),
|
|
@@ -193,13 +193,13 @@ export function GroupTopicReplyEditor({ value = false, disabled = false, onSave
|
|
|
193
193
|
className: 'dim-feishuGroupSelect',
|
|
194
194
|
value: current,
|
|
195
195
|
disabled: disabled || saving,
|
|
196
|
-
'aria-label': '
|
|
196
|
+
'aria-label': '被 @ 时以话题方式回复',
|
|
197
197
|
onChange: (event) => { void change(event); },
|
|
198
198
|
},
|
|
199
|
-
h('option', { value: 'off' }, '
|
|
199
|
+
h('option', { value: 'off' }, '关闭(在会话主窗口回复)'),
|
|
200
200
|
h('option', { value: 'on' }, '开启(自动开启独立飞书话题)')),
|
|
201
201
|
h('p', { className: 'dim-feishuGroupHelp' },
|
|
202
|
-
'
|
|
202
|
+
'开启后,在群聊或私聊里 @机器人 提问,机器人会在那条消息下新建一个飞书话题并在话题内回答;该话题是一条独立会话,话题里的后续消息都属于同一个会话。只发一个 @机器人(打开菜单)不会新建话题,没有 @机器人 的消息也留在原来的会话里。'),
|
|
203
203
|
error ? h('p', {
|
|
204
204
|
className: 'dim-feishuGroupError',
|
|
205
205
|
role: 'alert',
|
|
@@ -315,7 +315,7 @@ export function FeishuGroupSettingsPage({ account, rpcCall }) {
|
|
|
315
315
|
}, [
|
|
316
316
|
account.botId,
|
|
317
317
|
account.groupResponseMode,
|
|
318
|
-
account.
|
|
318
|
+
account.mentionTopicReply,
|
|
319
319
|
account.groupMessagePermissionGranted,
|
|
320
320
|
]);
|
|
321
321
|
|
|
@@ -558,12 +558,12 @@ export function FeishuGroupSettingsPage({ account, rpcCall }) {
|
|
|
558
558
|
},
|
|
559
559
|
onAuthorize: () => startAuthorization(),
|
|
560
560
|
}),
|
|
561
|
-
h(
|
|
562
|
-
value: settings.
|
|
561
|
+
h(MentionTopicReplyEditor, {
|
|
562
|
+
value: settings.mentionTopicReply,
|
|
563
563
|
disabled,
|
|
564
|
-
onSave: (
|
|
565
|
-
FEISHU_ENDPOINTS.
|
|
566
|
-
{
|
|
564
|
+
onSave: (mentionTopicReply) => saveSetting(
|
|
565
|
+
FEISHU_ENDPOINTS.setMentionTopicReply,
|
|
566
|
+
{ mentionTopicReply },
|
|
567
567
|
),
|
|
568
568
|
})),
|
|
569
569
|
h(PermissionFlow, {
|
|
@@ -608,7 +608,8 @@ export function BotCard({
|
|
|
608
608
|
accessPolicy: connection.accessPolicy,
|
|
609
609
|
channelSettings: {
|
|
610
610
|
groupResponseMode: connection.groupResponseMode,
|
|
611
|
-
|
|
611
|
+
mentionTopicReply: connection.mentionTopicReply,
|
|
612
|
+
slashPanel: connection.slashPanel,
|
|
612
613
|
groupMessagePermissionGranted: connection.groupMessagePermissionGranted,
|
|
613
614
|
},
|
|
614
615
|
}),
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
import { h, localizeText } from '../../i18n.js';
|
|
4
|
+
import {
|
|
5
|
+
FEISHU_ENDPOINTS,
|
|
6
|
+
normalizeBotsSnapshot,
|
|
7
|
+
presentError,
|
|
8
|
+
unwrapRpcResult,
|
|
9
|
+
} from './api.js';
|
|
10
|
+
import {
|
|
11
|
+
SLASH_COMMAND_MANIFEST,
|
|
12
|
+
SLASH_PANEL_MODES,
|
|
13
|
+
normalizeSlashPanelConfig,
|
|
14
|
+
} from '../../../../src/channels/feishu/slash-command-panel.mjs';
|
|
15
|
+
|
|
16
|
+
const MANIFEST_BY_NAME = new Map(SLASH_COMMAND_MANIFEST.map((entry) => [entry.command, entry]));
|
|
17
|
+
const ALL_COMMANDS = Object.freeze(SLASH_COMMAND_MANIFEST.map((entry) => entry.command));
|
|
18
|
+
|
|
19
|
+
function PanelButton({ children, disabled = false, onClick, ...props }) {
|
|
20
|
+
return h('button', {
|
|
21
|
+
...props,
|
|
22
|
+
type: 'button',
|
|
23
|
+
className: 'dim-deliveryButton',
|
|
24
|
+
disabled,
|
|
25
|
+
onClick,
|
|
26
|
+
}, children);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function panelSignature(panel) {
|
|
30
|
+
const normalized = normalizeSlashPanelConfig(panel);
|
|
31
|
+
return `${normalized.mode}:${normalized.order.join(',')}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function commandText(name) {
|
|
35
|
+
const entry = MANIFEST_BY_NAME.get(name);
|
|
36
|
+
return `/${name} — ${entry?.default ?? name}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* "指令面板" settings page: which commands the Feishu "/" panel offers, and in
|
|
41
|
+
* which order.
|
|
42
|
+
*
|
|
43
|
+
* The panel lives on Feishu's side per app, so saving only records the choice;
|
|
44
|
+
* the Host re-syncs the app in the background (creating and removing commands,
|
|
45
|
+
* one per second, because Feishu returns the panel in creation order).
|
|
46
|
+
*/
|
|
47
|
+
export function FeishuSlashPanelSettingsPage({ account, rpcCall, onSaved }) {
|
|
48
|
+
const incoming = React.useMemo(() => normalizeSlashPanelConfig(account.slashPanel), [account.botId, panelSignature(account.slashPanel)]);
|
|
49
|
+
const [mode, setMode] = React.useState(incoming.mode);
|
|
50
|
+
const [order, setOrder] = React.useState(() => [...incoming.order]);
|
|
51
|
+
const [pending, setPending] = React.useState('');
|
|
52
|
+
const [dirty, setDirty] = React.useState(false);
|
|
53
|
+
const [saving, setSaving] = React.useState(false);
|
|
54
|
+
const [saved, setSaved] = React.useState(false);
|
|
55
|
+
const [syncing, setSyncing] = React.useState(false);
|
|
56
|
+
/** Bot ids waiting for the reader to confirm a copy, or null when idle. */
|
|
57
|
+
const [syncTargets, setSyncTargets] = React.useState(null);
|
|
58
|
+
const [syncNotice, setSyncNotice] = React.useState(null);
|
|
59
|
+
const [error, setError] = React.useState(null);
|
|
60
|
+
const mounted = React.useRef(true);
|
|
61
|
+
|
|
62
|
+
React.useEffect(() => {
|
|
63
|
+
mounted.current = true;
|
|
64
|
+
return () => { mounted.current = false; };
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
// A status refresh must not discard what the reader is editing; once saved,
|
|
68
|
+
// the next snapshot becomes the new baseline.
|
|
69
|
+
React.useEffect(() => {
|
|
70
|
+
if (dirty) return;
|
|
71
|
+
setMode(incoming.mode);
|
|
72
|
+
setOrder([...incoming.order]);
|
|
73
|
+
}, [incoming.mode, incoming.order, dirty]);
|
|
74
|
+
|
|
75
|
+
const edit = React.useCallback((update) => {
|
|
76
|
+
setSaved(false);
|
|
77
|
+
setDirty(true);
|
|
78
|
+
update();
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
const move = (index, offset) => edit(() => {
|
|
82
|
+
const next = [...order];
|
|
83
|
+
const target = index + offset;
|
|
84
|
+
if (target < 0 || target >= next.length) return;
|
|
85
|
+
[next[index], next[target]] = [next[target], next[index]];
|
|
86
|
+
setOrder(next);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const remove = (index) => edit(() => setOrder(order.filter((_, at) => at !== index)));
|
|
90
|
+
|
|
91
|
+
const add = () => {
|
|
92
|
+
if (!pending || order.includes(pending)) return;
|
|
93
|
+
edit(() => setOrder([...order, pending]));
|
|
94
|
+
setPending('');
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** The panel this page currently shows, in the shape the Host stores. */
|
|
98
|
+
const draftPanel = () => (mode === SLASH_PANEL_MODES.CUSTOM
|
|
99
|
+
? { mode: SLASH_PANEL_MODES.CUSTOM, order: [...order] }
|
|
100
|
+
: { mode: SLASH_PANEL_MODES.DEFAULT, order: [] });
|
|
101
|
+
|
|
102
|
+
/** Adopt a Host snapshot as the new baseline for this page. */
|
|
103
|
+
const applySnapshot = (value) => {
|
|
104
|
+
const snapshot = normalizeBotsSnapshot(value);
|
|
105
|
+
const bot = snapshot.bots.find((entry) => entry.botId === account.botId);
|
|
106
|
+
const stored = normalizeSlashPanelConfig(bot?.slashPanel);
|
|
107
|
+
setMode(stored.mode);
|
|
108
|
+
setOrder([...stored.order]);
|
|
109
|
+
// Publish the stored panel upwards *before* clearing `dirty`: that re-runs
|
|
110
|
+
// the snapshot effect above, and a still-stale account would put the list
|
|
111
|
+
// the reader just replaced back on screen (and into the next copy payload).
|
|
112
|
+
onSaved?.(stored);
|
|
113
|
+
setDirty(false);
|
|
114
|
+
setSaved(true);
|
|
115
|
+
return snapshot;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const save = async () => {
|
|
119
|
+
if (saving) return;
|
|
120
|
+
setSaving(true);
|
|
121
|
+
setError(null);
|
|
122
|
+
try {
|
|
123
|
+
if (typeof rpcCall !== 'function') throw new Error('飞书指令面板设置暂不可用。');
|
|
124
|
+
const value = unwrapRpcResult(await rpcCall(
|
|
125
|
+
FEISHU_ENDPOINTS.setSlashPanel,
|
|
126
|
+
{ botId: account.botId, slashPanel: draftPanel() },
|
|
127
|
+
));
|
|
128
|
+
if (!mounted.current) return;
|
|
129
|
+
applySnapshot(value);
|
|
130
|
+
} catch (cause) {
|
|
131
|
+
if (!mounted.current) return;
|
|
132
|
+
setError(presentError(cause));
|
|
133
|
+
} finally {
|
|
134
|
+
if (mounted.current) setSaving(false);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Copy this panel to every other Feishu bot. Each bot keeps its own stored
|
|
140
|
+
* panel, so the copy is one save per bot (the same endpoint the Save button
|
|
141
|
+
* uses) — offline bots pick the panel up when they start.
|
|
142
|
+
*/
|
|
143
|
+
const startSyncAll = async () => {
|
|
144
|
+
if (saving || syncing || typeof rpcCall !== 'function') return;
|
|
145
|
+
setSyncing(true);
|
|
146
|
+
setError(null);
|
|
147
|
+
setSyncNotice(null);
|
|
148
|
+
try {
|
|
149
|
+
const snapshot = normalizeBotsSnapshot(unwrapRpcResult(
|
|
150
|
+
await rpcCall(FEISHU_ENDPOINTS.status, {}),
|
|
151
|
+
));
|
|
152
|
+
const others = snapshot.bots
|
|
153
|
+
.map((entry) => entry.botId)
|
|
154
|
+
.filter((botId) => botId && botId !== account.botId);
|
|
155
|
+
if (!mounted.current) return;
|
|
156
|
+
if (others.length === 0) {
|
|
157
|
+
setSyncNotice({ scope: 'none' });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// Ask first: the other bots lose whatever panel they had.
|
|
161
|
+
setSyncTargets(others);
|
|
162
|
+
} catch (cause) {
|
|
163
|
+
if (mounted.current) setError(presentError(cause));
|
|
164
|
+
} finally {
|
|
165
|
+
if (mounted.current) setSyncing(false);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const confirmSyncAll = async () => {
|
|
170
|
+
const targets = syncTargets;
|
|
171
|
+
if (!targets || syncing || typeof rpcCall !== 'function') return;
|
|
172
|
+
setSyncing(true);
|
|
173
|
+
setError(null);
|
|
174
|
+
setSyncTargets(null);
|
|
175
|
+
const payload = draftPanel();
|
|
176
|
+
let done = 0;
|
|
177
|
+
let failed = 0;
|
|
178
|
+
// The bot on screen goes first so its own state is authoritative even when
|
|
179
|
+
// a later bot fails.
|
|
180
|
+
for (const botId of [account.botId, ...targets]) {
|
|
181
|
+
try {
|
|
182
|
+
const value = unwrapRpcResult(await rpcCall(
|
|
183
|
+
FEISHU_ENDPOINTS.setSlashPanel,
|
|
184
|
+
{ botId, slashPanel: payload },
|
|
185
|
+
));
|
|
186
|
+
if (botId === account.botId && mounted.current) applySnapshot(value);
|
|
187
|
+
done += 1;
|
|
188
|
+
} catch {
|
|
189
|
+
failed += 1;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (!mounted.current) return;
|
|
193
|
+
setSyncing(false);
|
|
194
|
+
setSyncNotice({ scope: 'done', done, failed });
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const available = ALL_COMMANDS.filter((name) => !order.includes(name));
|
|
198
|
+
|
|
199
|
+
return h('section', {
|
|
200
|
+
className: 'dim-feishuGroupSettings',
|
|
201
|
+
'aria-label': '指令面板设置',
|
|
202
|
+
},
|
|
203
|
+
h('section', {
|
|
204
|
+
className: 'dim-feishuGroupControl',
|
|
205
|
+
'aria-labelledby': 'dim-feishu-slash-panel-title',
|
|
206
|
+
},
|
|
207
|
+
h('div', { className: 'dim-feishuGroupControlHeader' },
|
|
208
|
+
h('h3', { id: 'dim-feishu-slash-panel-title' }, '指令面板'),
|
|
209
|
+
saving
|
|
210
|
+
? h('span', { className: 'dim-feishuGroupControlStatus', role: 'status' }, '保存中…')
|
|
211
|
+
: saved
|
|
212
|
+
? h('span', { className: 'dim-feishuGroupControlStatus', role: 'status' }, '已保存,面板稍后同步')
|
|
213
|
+
: null),
|
|
214
|
+
h('p', { className: 'dim-feishuGroupHelp' },
|
|
215
|
+
'面板只决定聊天输入框里打「/」时列出的指令和顺序;被移除的指令仍可手动输入,功能不受影响。同步由机器人后台完成,指令较多时需要一点时间。'),
|
|
216
|
+
h('select', {
|
|
217
|
+
className: 'dim-feishuGroupSelect',
|
|
218
|
+
value: mode,
|
|
219
|
+
'aria-label': '指令面板模式',
|
|
220
|
+
disabled: saving || syncing,
|
|
221
|
+
onChange: (event) => edit(() => {
|
|
222
|
+
const next = event.target.value;
|
|
223
|
+
setMode(next);
|
|
224
|
+
// Switching to a custom panel starts from everything, in the shipped
|
|
225
|
+
// order, so the reader only has to remove what they do not want.
|
|
226
|
+
if (next === SLASH_PANEL_MODES.CUSTOM && order.length === 0) setOrder([...ALL_COMMANDS]);
|
|
227
|
+
}),
|
|
228
|
+
},
|
|
229
|
+
h('option', { value: SLASH_PANEL_MODES.DEFAULT }, '跟随默认(随插件版本更新)'),
|
|
230
|
+
h('option', { value: SLASH_PANEL_MODES.CUSTOM }, '自定义(自己挑指令和顺序)')),
|
|
231
|
+
mode === SLASH_PANEL_MODES.CUSTOM
|
|
232
|
+
? h(React.Fragment, null,
|
|
233
|
+
order.length === 0
|
|
234
|
+
? h('p', { className: 'dim-feishuGroupHelp' }, '当前没有选择任何指令,保存后「/」面板会变成空的。')
|
|
235
|
+
: h('ul', { className: 'dim-feishuPanelList' },
|
|
236
|
+
order.map((name, index) => h('li', {
|
|
237
|
+
key: name,
|
|
238
|
+
className: 'dim-feishuPanelRow',
|
|
239
|
+
},
|
|
240
|
+
h('span', { className: 'dim-feishuPanelIndex' }, String(index + 1)),
|
|
241
|
+
h('span', { className: 'dim-feishuPanelCommand' }, commandText(name)),
|
|
242
|
+
h('span', { className: 'dim-feishuPanelRowActions' },
|
|
243
|
+
h(PanelButton, {
|
|
244
|
+
'aria-label': [localizeText('上移'), `/${name}`].join(' '),
|
|
245
|
+
disabled: saving || syncing || index === 0,
|
|
246
|
+
onClick: () => move(index, -1),
|
|
247
|
+
}, '↑'),
|
|
248
|
+
h(PanelButton, {
|
|
249
|
+
'aria-label': [localizeText('下移'), `/${name}`].join(' '),
|
|
250
|
+
disabled: saving || syncing || index === order.length - 1,
|
|
251
|
+
onClick: () => move(index, 1),
|
|
252
|
+
}, '↓'),
|
|
253
|
+
h(PanelButton, {
|
|
254
|
+
'aria-label': [localizeText('移除'), `/${name}`].join(' '),
|
|
255
|
+
disabled: saving || syncing,
|
|
256
|
+
onClick: () => remove(index),
|
|
257
|
+
}, '移除'))))),
|
|
258
|
+
h('div', { className: 'dim-feishuPanelAdd' },
|
|
259
|
+
h('select', {
|
|
260
|
+
className: 'dim-feishuGroupSelect',
|
|
261
|
+
value: pending,
|
|
262
|
+
'aria-label': '添加指令',
|
|
263
|
+
disabled: saving || syncing || available.length === 0,
|
|
264
|
+
onChange: (event) => setPending(event.target.value),
|
|
265
|
+
},
|
|
266
|
+
h('option', { value: '' }, available.length === 0 ? '指令都已加入' : '选择要加入的指令'),
|
|
267
|
+
available.map((name) => h('option', { key: name, value: name }, commandText(name)))),
|
|
268
|
+
h(PanelButton, {
|
|
269
|
+
disabled: saving || syncing || !pending,
|
|
270
|
+
onClick: add,
|
|
271
|
+
}, '加入'),
|
|
272
|
+
h(PanelButton, {
|
|
273
|
+
disabled: saving || syncing || available.length === 0,
|
|
274
|
+
onClick: () => edit(() => setOrder([...ALL_COMMANDS])),
|
|
275
|
+
}, '恢复全部'),
|
|
276
|
+
h(PanelButton, {
|
|
277
|
+
disabled: saving || syncing || order.length === 0,
|
|
278
|
+
onClick: () => edit(() => setOrder([])),
|
|
279
|
+
}, '全部移除')))
|
|
280
|
+
: null,
|
|
281
|
+
h('div', { className: 'dim-feishuPanelFooter' },
|
|
282
|
+
h(PanelButton, {
|
|
283
|
+
'data-kind': 'primary',
|
|
284
|
+
disabled: saving || syncing || !dirty,
|
|
285
|
+
onClick: () => { void save(); },
|
|
286
|
+
}, '保存'),
|
|
287
|
+
h(PanelButton, {
|
|
288
|
+
disabled: saving || syncing,
|
|
289
|
+
onClick: () => { void startSyncAll(); },
|
|
290
|
+
}, '保存并同步到其他机器人')),
|
|
291
|
+
syncTargets
|
|
292
|
+
? h('div', { className: 'dim-feishuPanelFooter' },
|
|
293
|
+
h('span', { className: 'dim-feishuGroupHelp' },
|
|
294
|
+
[localizeText('将把这套面板设置写入另外'),
|
|
295
|
+
` ${syncTargets.length} `,
|
|
296
|
+
localizeText('个飞书机器人,它们各自的面板设置会被覆盖。')].join('')),
|
|
297
|
+
h(PanelButton, {
|
|
298
|
+
disabled: syncing,
|
|
299
|
+
onClick: () => { void confirmSyncAll(); },
|
|
300
|
+
}, '确认同步'),
|
|
301
|
+
h(PanelButton, {
|
|
302
|
+
disabled: syncing,
|
|
303
|
+
onClick: () => setSyncTargets(null),
|
|
304
|
+
}, '取消'))
|
|
305
|
+
: null,
|
|
306
|
+
syncing
|
|
307
|
+
? h('p', { className: 'dim-feishuGroupHelp', role: 'status' }, '正在同步…')
|
|
308
|
+
: null,
|
|
309
|
+
syncNotice?.scope === 'none'
|
|
310
|
+
? h('p', { className: 'dim-feishuGroupHelp', role: 'status' }, '这个渠道没有其他飞书机器人,不用同步。')
|
|
311
|
+
: null,
|
|
312
|
+
syncNotice?.scope === 'done'
|
|
313
|
+
? h('p', { className: 'dim-feishuGroupHelp', role: 'status' },
|
|
314
|
+
syncNotice.failed > 0
|
|
315
|
+
? [localizeText('已同步到'), ` ${syncNotice.done} `, localizeText('个机器人,'),
|
|
316
|
+
` ${syncNotice.failed} `, localizeText('个失败。')].join('')
|
|
317
|
+
: [localizeText('已同步到'), ` ${syncNotice.done} `, localizeText('个机器人。')].join(''))
|
|
318
|
+
: null,
|
|
319
|
+
error
|
|
320
|
+
? h('p', { className: 'dim-feishuGroupError', role: 'alert' }, error)
|
|
321
|
+
: null));
|
|
322
|
+
}
|
|
@@ -3,6 +3,7 @@ import * as React from 'react';
|
|
|
3
3
|
import { AccessPolicySettingsPage } from './access-policy-settings.js';
|
|
4
4
|
import { FeishuGroupSettingsPage } from './channels/feishu/group-settings.js';
|
|
5
5
|
import { FeishuVoiceSettingsPage } from './channels/feishu/voice-settings.js';
|
|
6
|
+
import { FeishuSlashPanelSettingsPage } from './channels/feishu/slash-panel-settings.js';
|
|
6
7
|
import { h, isEnglish, localizeText } from './i18n.js';
|
|
7
8
|
|
|
8
9
|
export const DELIVERY_RPC_CHANNEL = '/dsh-im-delivery';
|
|
@@ -30,6 +31,7 @@ export const BOT_SETTINGS_TABS = Object.freeze([
|
|
|
30
31
|
export const FEISHU_BOT_SETTINGS_TABS = Object.freeze([
|
|
31
32
|
...BOT_SETTINGS_TABS,
|
|
32
33
|
Object.freeze({ id: 'group', label: '群聊' }),
|
|
34
|
+
Object.freeze({ id: 'slash', label: '指令面板' }),
|
|
33
35
|
Object.freeze({ id: 'voice', label: '语音交互' }),
|
|
34
36
|
]);
|
|
35
37
|
|
|
@@ -633,12 +635,20 @@ export function DeliveryTargetSettingsPage({
|
|
|
633
635
|
const [saving, setSaving] = React.useState(false);
|
|
634
636
|
const [botCopyState, setBotCopyState] = React.useState(null);
|
|
635
637
|
const [accessPolicy, setAccessPolicy] = React.useState(account.accessPolicy);
|
|
638
|
+
// The slash panel page re-reads its baseline from this snapshot after a save,
|
|
639
|
+
// so the saved panel has to land here — a stale account would put the
|
|
640
|
+
// pre-save list back on screen and into the next "copy to other bots" payload.
|
|
641
|
+
const [slashPanel, setSlashPanel] = React.useState(account.slashPanel);
|
|
636
642
|
const mounted = React.useRef(true);
|
|
637
643
|
|
|
638
644
|
React.useEffect(() => {
|
|
639
645
|
setAccessPolicy(account.accessPolicy);
|
|
640
646
|
}, [account.botId, account.accessPolicy]);
|
|
641
647
|
|
|
648
|
+
React.useEffect(() => {
|
|
649
|
+
setSlashPanel(account.slashPanel);
|
|
650
|
+
}, [account.botId, account.slashPanel]);
|
|
651
|
+
|
|
642
652
|
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
|
|
643
653
|
if (typeof rpcCall !== 'function') throw new Error('投递目标设置暂不可用。');
|
|
644
654
|
return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
|
|
@@ -794,6 +804,12 @@ export function DeliveryTargetSettingsPage({
|
|
|
794
804
|
account,
|
|
795
805
|
rpcCall: accessRpcCall,
|
|
796
806
|
})
|
|
807
|
+
: activeTab.id === 'slash' && channel === 'feishu'
|
|
808
|
+
? h(FeishuSlashPanelSettingsPage, {
|
|
809
|
+
account: { ...account, slashPanel },
|
|
810
|
+
rpcCall: accessRpcCall,
|
|
811
|
+
onSaved: setSlashPanel,
|
|
812
|
+
})
|
|
797
813
|
: activeTab.id === 'voice' && channel === 'feishu'
|
|
798
814
|
? h(FeishuVoiceSettingsPage, {
|
|
799
815
|
key: account.botId,
|
|
@@ -263,12 +263,42 @@ const EN = Object.freeze({
|
|
|
263
263
|
'需在飞书为该机器人开通“获取群组中所有消息”权限(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.',
|
|
264
264
|
'群聊响应方式修改失败,请重试。': 'Could not update the group response mode. Try again.',
|
|
265
265
|
'群消息权限授权失败,请重试。': 'Could not authorize group-message permission. Try again.',
|
|
266
|
-
'
|
|
267
|
-
'
|
|
268
|
-
'
|
|
266
|
+
'被 @ 时以话题方式回复': 'Reply as a Feishu topic when mentioned',
|
|
267
|
+
'以话题方式回复设置保存失败,请重试。': 'Could not save the topic reply setting. Try again.',
|
|
268
|
+
'关闭(在会话主窗口回复)': 'Off (reply in the chat feed)',
|
|
269
269
|
'开启(自动开启独立飞书话题)': 'On (auto-open a dedicated Feishu topic)',
|
|
270
|
-
'
|
|
270
|
+
'开启后,在群聊或私聊里 @机器人 提问,机器人会在那条消息下新建一个飞书话题并在话题内回答;该话题是一条独立会话,话题里的后续消息都属于同一个会话。只发一个 @机器人(打开菜单)不会新建话题,没有 @机器人 的消息也留在原来的会话里。': 'When enabled, mentioning the bot in a group or a private chat opens a dedicated Feishu topic under that message and the answer stays inside it. That topic is an independent conversation in dsh, and later messages in the topic belong to the same conversation. A bare "@bot" (which opens the menu) opens no topic, and messages without a mention stay in the chat\'s own conversation.',
|
|
271
271
|
'分步直推': 'Step push',
|
|
272
|
+
'指令面板': 'Command panel',
|
|
273
|
+
'个失败。': 'failed.',
|
|
274
|
+
'个机器人,': 'bots,',
|
|
275
|
+
'个机器人。': 'bots.',
|
|
276
|
+
'已同步到': 'Copied to',
|
|
277
|
+
'这个渠道没有其他飞书机器人,不用同步。': 'This channel has no other Feishu bots to copy to.',
|
|
278
|
+
'正在同步…': 'Copying…',
|
|
279
|
+
'确认同步': 'Copy to all',
|
|
280
|
+
'个飞书机器人,它们各自的面板设置会被覆盖。': 'other Feishu bots, overwriting their own panel settings.',
|
|
281
|
+
'将把这套面板设置写入另外': 'This copies the panel to',
|
|
282
|
+
'保存并同步到其他机器人': 'Save and copy to the other bots',
|
|
283
|
+
'指令面板设置': 'Command panel settings',
|
|
284
|
+
'指令面板模式': 'Command panel mode',
|
|
285
|
+
'面板只决定聊天输入框里打「/」时列出的指令和顺序;被移除的指令仍可手动输入,功能不受影响。同步由机器人后台完成,指令较多时需要一点时间。': 'The panel only decides which commands the "/" list in the chat input offers, and in which order. A removed command can still be typed by hand and keeps working. The bot syncs the panel in the background; many commands take a moment.',
|
|
286
|
+
'跟随默认(随插件版本更新)': 'Follow the default (updated with the plugin)',
|
|
287
|
+
'自定义(自己挑指令和顺序)': 'Custom (choose the commands and their order)',
|
|
288
|
+
'当前没有选择任何指令,保存后「/」面板会变成空的。': 'No commands are selected; saving leaves the "/" panel empty.',
|
|
289
|
+
'添加指令': 'Add command',
|
|
290
|
+
'选择要加入的指令': 'Choose a command to add',
|
|
291
|
+
'指令都已加入': 'Every command is already listed',
|
|
292
|
+
'加入': 'Add',
|
|
293
|
+
'恢复全部': 'Restore all',
|
|
294
|
+
'全部移除': 'Remove all',
|
|
295
|
+
'移除': 'Remove',
|
|
296
|
+
'上移': 'Move up',
|
|
297
|
+
'下移': 'Move down',
|
|
298
|
+
'保存': 'Save',
|
|
299
|
+
'已保存,面板稍后同步': 'Saved; the panel syncs shortly',
|
|
300
|
+
'请选择面板要显示的指令与顺序。': 'Choose which commands the panel shows, and in what order.',
|
|
301
|
+
'飞书指令面板设置暂不可用。': 'The Feishu command panel settings are unavailable right now.',
|
|
272
302
|
'查看分步直推说明': 'View step push help',
|
|
273
303
|
'开启后逐步推送工具调用与过程说明': 'Push tool calls and process notes step by step',
|
|
274
304
|
'任务过程展示': 'Task progress display',
|
|
@@ -354,7 +384,6 @@ const EN = Object.freeze({
|
|
|
354
384
|
'工作区绝对路径': 'Absolute workspace path',
|
|
355
385
|
'/绝对路径/到/工作区': '/absolute/path/to/workspace',
|
|
356
386
|
'修改': 'Change',
|
|
357
|
-
'保存': 'Save',
|
|
358
387
|
'上下文增强': 'Context enhancement',
|
|
359
388
|
'未开启': 'Not enabled',
|
|
360
389
|
'仅群聊': 'Groups only',
|
|
@@ -494,6 +494,15 @@ const CSS = String.raw`
|
|
|
494
494
|
.dim-feishuGroupPermissionAction .dim-deliveryButton { color: var(--dsw-alias-state-business-primary, #3370ff); border-color: color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 30%, var(--dsw-alias-border-l2, #dfe1e5)); }
|
|
495
495
|
.dim-feishuGroupError, .dim-feishuGroupRefreshError { margin: 0; padding: 9px 11px; border-radius: 8px; font-size: 12px; line-height: 18px; }
|
|
496
496
|
.dim-feishuGroupError, .dim-feishuGroupRefreshError { color: var(--dsw-alias-state-error-primary, #d54941); background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #d54941) 7%, var(--dsw-alias-bg-layer-1, #fff)); }
|
|
497
|
+
.dim-feishuPanelList { display: grid; gap: 7px; margin: 0; padding: 0; list-style: none; }
|
|
498
|
+
.dim-feishuPanelRow { min-width: 0; display: grid; grid-template-columns: max-content minmax(0, 1fr) max-content; align-items: center; gap: 10px; padding: 8px 10px; border: 1px solid var(--dsw-alias-border-l1, #eef0f3); border-radius: 9px; background: var(--dsw-alias-bg-module-platform, #f7f8fa); }
|
|
499
|
+
.dim-feishuPanelIndex { min-width: 18px; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; line-height: 17px; text-align: right; }
|
|
500
|
+
.dim-feishuPanelCommand { min-width: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; line-height: 18px; overflow-wrap: anywhere; }
|
|
501
|
+
.dim-feishuPanelRowActions, .dim-feishuPanelAdd { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
|
502
|
+
.dim-feishuPanelRow .dim-deliveryButton { min-height: 28px; padding: 0 9px; font-size: 12px; }
|
|
503
|
+
.dim-feishuPanelAdd { margin-top: 2px; }
|
|
504
|
+
.dim-feishuPanelAdd .dim-feishuGroupSelect { flex: 1 1 220px; width: auto; }
|
|
505
|
+
.dim-feishuPanelFooter { display: flex; justify-content: flex-start; }
|
|
497
506
|
.dim-feishuGroupAuthorization { min-width: 0; display: grid; grid-template-columns: 184px minmax(0, 1fr); align-items: start; gap: 24px; padding: 18px; border: 1px solid color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 30%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 12px; background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 2.5%, var(--dsw-alias-bg-layer-3, #fff)); }
|
|
498
507
|
.dim-feishuGroupAuthorizationState { min-height: 126px; grid-template-columns: 32px minmax(0, 1fr); align-items: center; }
|
|
499
508
|
.dim-feishuGroupAuthorizationState h3, .dim-feishuGroupAuthorizationError h3, .dim-feishuGroupAuthorizationCopy h3 { margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 16px; line-height: 24px; font-weight: 650; }
|
|
@@ -253,7 +253,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
253
253
|
domain: botConfig.domain,
|
|
254
254
|
botOpenId: botConfig.botOpenId,
|
|
255
255
|
groupResponseMode: botConfig.groupResponseMode,
|
|
256
|
-
|
|
256
|
+
mentionTopicReply: botConfig.mentionTopicReply,
|
|
257
257
|
stepPush: botConfig.stepPush,
|
|
258
258
|
stepPushMode: botConfig.stepPushMode,
|
|
259
259
|
voice: voiceSource,
|
|
@@ -267,6 +267,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
267
267
|
}),
|
|
268
268
|
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
|
|
269
269
|
slashCommands: config.slashCommands !== false,
|
|
270
|
+
slashPanel: botConfig.slashPanel,
|
|
270
271
|
...(wsAgent ? { wsAgent } : {}),
|
|
271
272
|
logger: {
|
|
272
273
|
error: (...args) => logger.error?.(`[${botId ?? botConfig.id}]`, ...args),
|