@xmanrui/dsh-im 4.0.0 → 4.1.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/PROACTIVE_DELIVERY.en.md +321 -0
- package/PROACTIVE_DELIVERY.md +321 -0
- package/README.en.md +11 -5
- package/README.md +11 -5
- package/lib/client.js +1200 -110
- package/lib/index.js +229 -229
- package/package.json +4 -1
- package/plugin-src/client/channel-card-meta.js +44 -0
- package/plugin-src/client/channels/dingtalk/index.js +16 -8
- package/plugin-src/client/channels/feishu/index.js +17 -9
- package/plugin-src/client/channels/qq/index.js +16 -8
- package/plugin-src/client/channels/shared/token-channel.js +16 -8
- package/plugin-src/client/channels/wecom/index.js +16 -8
- package/plugin-src/client/channels/weixin/index.js +16 -8
- package/plugin-src/client/channels/whatsapp/index.js +16 -8
- package/plugin-src/client/delivery-settings.js +749 -0
- package/plugin-src/client/i18n.js +82 -0
- package/plugin-src/client/index.js +59 -22
- package/plugin-src/client/styles.js +72 -0
- package/plugin-src/host/channels/dingtalk/index.mjs +3 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +4 -0
- package/plugin-src/host/channels/discord/index.mjs +6 -1
- package/plugin-src/host/channels/feishu/index.mjs +3 -0
- package/plugin-src/host/channels/feishu/production.mjs +4 -0
- package/plugin-src/host/channels/qq/index.mjs +6 -1
- package/plugin-src/host/channels/qq/production.mjs +2 -0
- package/plugin-src/host/channels/shared/production.mjs +7 -0
- package/plugin-src/host/channels/slack/index.mjs +6 -1
- package/plugin-src/host/channels/slack/production.mjs +4 -0
- package/plugin-src/host/channels/telegram/index.mjs +6 -1
- package/plugin-src/host/channels/wecom/index.mjs +6 -1
- package/plugin-src/host/channels/wecom/production.mjs +4 -0
- package/plugin-src/host/channels/weixin/index.mjs +3 -0
- package/plugin-src/host/channels/weixin/production.mjs +4 -0
- package/plugin-src/host/channels/whatsapp/index.mjs +6 -1
- package/plugin-src/host/channels/whatsapp/production.mjs +4 -0
- package/plugin-src/host/delivery-adapter.mjs +179 -0
- package/plugin-src/host/delivery-http.mjs +132 -0
- package/plugin-src/host/delivery-rpc.mjs +158 -0
- package/plugin-src/host/delivery-service.mjs +224 -0
- package/plugin-src/host/delivery-suggestions.mjs +135 -0
- package/plugin-src/host/index.mjs +32 -5
- package/scripts/verify-package.mjs +3 -0
- package/src/channels/dingtalk/dingtalk-api.mjs +33 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +2 -2
- package/src/channels/dingtalk/dingtalk-controller.mjs +14 -0
- package/src/channels/dingtalk/dingtalk-runtime.mjs +39 -0
- package/src/channels/discord/discord-runtime.mjs +16 -0
- package/src/channels/feishu/bridge.mjs +4 -4
- package/src/channels/feishu/feishu-cards.mjs +4 -4
- package/src/channels/feishu/feishu-runtime.mjs +35 -0
- package/src/channels/feishu/multi-bot-controller.mjs +15 -0
- package/src/channels/qq/qq-bridge.mjs +2 -2
- package/src/channels/qq/qq-controller.mjs +14 -0
- package/src/channels/qq/qq-runtime.mjs +23 -0
- package/src/channels/shared/bot-workspace-store.mjs +192 -6
- package/src/channels/shared/i18n-en/feishu.mjs +6 -2
- package/src/channels/shared/i18n-en/shared-a.mjs +4 -0
- package/src/channels/shared/preset-command.mjs +2 -2
- package/src/channels/shared/text-harness-bridge.mjs +7 -2
- package/src/channels/shared/token-bot-controller.mjs +16 -0
- package/src/channels/shared/workspace-command.mjs +1 -1
- package/src/channels/slack/slack-controller.mjs +14 -0
- package/src/channels/slack/slack-runtime.mjs +24 -0
- package/src/channels/telegram/telegram-runtime.mjs +26 -0
- package/src/channels/wecom/state-store.mjs +4 -0
- package/src/channels/wecom/wecom-bridge.mjs +2 -2
- package/src/channels/wecom/wecom-controller.mjs +14 -0
- package/src/channels/wecom/wecom-runtime.mjs +21 -0
- package/src/channels/weixin/weixin-bridge.mjs +2 -2
- package/src/channels/weixin/weixin-controller.mjs +14 -0
- package/src/channels/weixin/weixin-runtime.mjs +24 -0
- package/src/channels/whatsapp/whatsapp-controller.mjs +14 -0
- package/src/channels/whatsapp/whatsapp-runtime.mjs +19 -0
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
import { h, isEnglish, localizeText } from './i18n.js';
|
|
4
|
+
|
|
5
|
+
export const DELIVERY_RPC_CHANNEL = '/dsh-im-delivery';
|
|
6
|
+
|
|
7
|
+
const DELIVERY_DOCS_URL = Object.freeze({
|
|
8
|
+
zh: 'https://github.com/xmanrui/dsh-im/blob/main/PROACTIVE_DELIVERY.md',
|
|
9
|
+
en: 'https://github.com/xmanrui/dsh-im/blob/main/PROACTIVE_DELIVERY.en.md',
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const DELIVERY_ENDPOINTS = Object.freeze({
|
|
13
|
+
list: 'target.list',
|
|
14
|
+
listSuggestions: 'target.suggestion.list',
|
|
15
|
+
create: 'target.create',
|
|
16
|
+
update: 'target.update',
|
|
17
|
+
delete: 'target.delete',
|
|
18
|
+
test: 'target.test',
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const CHANNEL_DEFINITIONS = Object.freeze({
|
|
22
|
+
weixin: {
|
|
23
|
+
label: '微信',
|
|
24
|
+
kinds: [{ value: 'user', label: '用户' }],
|
|
25
|
+
fields: { user: [{ key: 'toUserId', label: '微信用户 ID', placeholder: '填写接收消息的用户 ID' }] },
|
|
26
|
+
},
|
|
27
|
+
feishu: {
|
|
28
|
+
label: '飞书',
|
|
29
|
+
kinds: [{ value: 'user', label: '私聊' }, { value: 'group', label: '群聊' }],
|
|
30
|
+
fields: {
|
|
31
|
+
user: [{ key: 'openId', label: 'Open ID', placeholder: 'ou_xxx' }],
|
|
32
|
+
group: [{ key: 'chatId', label: '群 Chat ID', placeholder: 'oc_xxx' }],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
dingtalk: {
|
|
36
|
+
label: '钉钉',
|
|
37
|
+
kinds: [{ value: 'user', label: '用户' }, { value: 'group', label: '群聊' }],
|
|
38
|
+
fields: {
|
|
39
|
+
user: [{ key: 'userId', label: '用户 ID', placeholder: '填写钉钉用户 ID' }],
|
|
40
|
+
group: [{ key: 'openConversationId', label: '群 Open Conversation ID', placeholder: 'cidxxx' }],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
wecom: {
|
|
44
|
+
label: '企业微信',
|
|
45
|
+
kinds: [{ value: 'user', label: '私聊' }, { value: 'group', label: '群聊' }],
|
|
46
|
+
fields: {
|
|
47
|
+
user: [{ key: 'chatId', label: '用户 ID', placeholder: '填写企业微信用户 ID' }],
|
|
48
|
+
group: [{ key: 'chatId', label: '群 Chat ID', placeholder: '填写群 chatid' }],
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
qq: {
|
|
52
|
+
label: 'QQ',
|
|
53
|
+
kinds: [{ value: 'user', label: '单聊' }, { value: 'group', label: '群聊' }],
|
|
54
|
+
fields: {
|
|
55
|
+
user: [{ key: 'userOpenId', label: '用户 Open ID', placeholder: '填写 user_openid' }],
|
|
56
|
+
group: [{ key: 'groupOpenId', label: '群 Open ID', placeholder: '填写 group_openid' }],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
slack: {
|
|
60
|
+
label: 'Slack',
|
|
61
|
+
kinds: [{ value: 'conversation', label: '会话' }, { value: 'thread', label: '线程' }],
|
|
62
|
+
fields: {
|
|
63
|
+
conversation: [{ key: 'channelId', label: 'Channel ID', placeholder: 'C0123456789' }],
|
|
64
|
+
thread: [
|
|
65
|
+
{ key: 'channelId', label: 'Channel ID', placeholder: 'C0123456789' },
|
|
66
|
+
{ key: 'threadTs', label: 'Thread 时间戳', placeholder: '1712345678.123456' },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
telegram: {
|
|
71
|
+
label: 'Telegram',
|
|
72
|
+
kinds: [{ value: 'chat', label: '聊天' }, { value: 'topic', label: '话题' }],
|
|
73
|
+
fields: {
|
|
74
|
+
chat: [{ key: 'chatId', label: 'Chat ID', placeholder: '-1001234567890', inputMode: 'numeric' }],
|
|
75
|
+
topic: [
|
|
76
|
+
{ key: 'chatId', label: 'Chat ID', placeholder: '-1001234567890', inputMode: 'numeric' },
|
|
77
|
+
{ key: 'messageThreadId', label: 'Topic ID', placeholder: '123', inputMode: 'numeric', integer: true },
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
discord: {
|
|
82
|
+
label: 'Discord',
|
|
83
|
+
kinds: [{ value: 'channel', label: '频道或私信' }],
|
|
84
|
+
fields: { channel: [{ key: 'channelId', label: 'Channel ID', placeholder: '填写可发消息的 Channel ID', inputMode: 'numeric' }] },
|
|
85
|
+
},
|
|
86
|
+
whatsapp: {
|
|
87
|
+
label: 'WhatsApp',
|
|
88
|
+
kinds: [{ value: 'user', label: '用户' }, { value: 'group', label: '群聊' }],
|
|
89
|
+
fields: {
|
|
90
|
+
user: [{ key: 'jid', label: '用户 JID', placeholder: '8613800000000@s.whatsapp.net' }],
|
|
91
|
+
group: [{ key: 'jid', label: '群 JID', placeholder: '1234567890@g.us' }],
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export const DELIVERY_CHANNEL_DEFINITIONS = CHANNEL_DEFINITIONS;
|
|
97
|
+
|
|
98
|
+
function presentError(error, fallback) {
|
|
99
|
+
return error?.message || fallback;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function unwrapRpcResult(result) {
|
|
103
|
+
if (result?.ok === true) return result.value;
|
|
104
|
+
if (result?.ok === false) {
|
|
105
|
+
const error = new Error(result.error?.message || '请求失败,请稍后重试。');
|
|
106
|
+
error.code = result.error?.code;
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function targetsFrom(value) {
|
|
113
|
+
if (Array.isArray(value)) return value;
|
|
114
|
+
return Array.isArray(value?.targets) ? value.targets : [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function suggestionsFrom(value) {
|
|
118
|
+
if (Array.isArray(value)) return value;
|
|
119
|
+
return Array.isArray(value?.suggestions) ? value.suggestions : [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function kindLabel(definition, kind) {
|
|
123
|
+
return definition.kinds.find((entry) => entry.value === kind)?.label ?? kind;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function fieldsFor(definition, kind) {
|
|
127
|
+
return definition.fields[kind] ?? [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function cleanDisplayName(value) {
|
|
131
|
+
return typeof value === 'string' && value.trim() ? value.trim().slice(0, 80) : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function suggestionDisplayName(suggestion) {
|
|
135
|
+
return cleanDisplayName(
|
|
136
|
+
suggestion?.name ?? suggestion?.displayName ?? suggestion?.label,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function routeIdentity(definition, candidate) {
|
|
141
|
+
const fields = fieldsFor(definition, candidate?.kind);
|
|
142
|
+
if (fields.length === 0 || !candidate?.route || typeof candidate.route !== 'object') return null;
|
|
143
|
+
const values = [];
|
|
144
|
+
for (const field of fields) {
|
|
145
|
+
const value = candidate.route[field.key];
|
|
146
|
+
if (value === undefined || value === null || String(value).trim() === '') return null;
|
|
147
|
+
values.push(String(value).trim());
|
|
148
|
+
}
|
|
149
|
+
return JSON.stringify([candidate.kind, ...values]);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function validSuggestions(definition, value) {
|
|
153
|
+
const seen = new Set();
|
|
154
|
+
return suggestionsFrom(value).filter((suggestion) => {
|
|
155
|
+
const identity = routeIdentity(definition, suggestion);
|
|
156
|
+
if (!identity || seen.has(identity)) return false;
|
|
157
|
+
seen.add(identity);
|
|
158
|
+
return true;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function maskRouteValue(value) {
|
|
163
|
+
const text = String(value ?? '').trim();
|
|
164
|
+
if (!text) return '';
|
|
165
|
+
const at = text.indexOf('@');
|
|
166
|
+
if (at > 0) {
|
|
167
|
+
const local = text.slice(0, at);
|
|
168
|
+
const domain = text.slice(at);
|
|
169
|
+
const visible = local.length <= 4
|
|
170
|
+
? `${local.slice(0, 1)}••${local.slice(-1)}`
|
|
171
|
+
: `${local.slice(0, 3)}…${local.slice(-2)}`;
|
|
172
|
+
return `${visible}${domain}`;
|
|
173
|
+
}
|
|
174
|
+
if (text.length <= 6) return `${text.slice(0, 2)}…${text.slice(-1)}`;
|
|
175
|
+
if (text.length <= 12) return `${text.slice(0, 3)}…${text.slice(-3)}`;
|
|
176
|
+
return `${text.slice(0, 5)}…${text.slice(-4)}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function suggestionFallbackName(definition, suggestion) {
|
|
180
|
+
const displayName = suggestionDisplayName(suggestion);
|
|
181
|
+
if (displayName) return displayName;
|
|
182
|
+
const firstField = fieldsFor(definition, suggestion.kind)[0];
|
|
183
|
+
const route = firstField ? maskRouteValue(suggestion.route?.[firstField.key]) : '';
|
|
184
|
+
return `${localizeText(kindLabel(definition, suggestion.kind))}${route ? ` · ${route}` : ''}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function randomTargetId(targets) {
|
|
188
|
+
const used = new Set(targets.map((target) => target?.targetId));
|
|
189
|
+
for (let attempt = 0; attempt < 32; attempt += 1) {
|
|
190
|
+
const bytes = new Uint8Array(8);
|
|
191
|
+
const crypto = globalThis.crypto;
|
|
192
|
+
if (typeof crypto?.getRandomValues === 'function') {
|
|
193
|
+
crypto.getRandomValues(bytes);
|
|
194
|
+
} else {
|
|
195
|
+
for (let index = 0; index < bytes.length; index += 1) {
|
|
196
|
+
bytes[index] = Math.floor(Math.random() * 256);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const suffix = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('');
|
|
200
|
+
const targetId = `tgt_${suffix}`;
|
|
201
|
+
if (!used.has(targetId)) return targetId;
|
|
202
|
+
}
|
|
203
|
+
throw new Error('无法生成未占用的 Target ID,请重试。');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function DeliveryButton({ children, kind = 'secondary', className = '', ...props }) {
|
|
207
|
+
return h('button', {
|
|
208
|
+
...props,
|
|
209
|
+
type: props.type ?? 'button',
|
|
210
|
+
className: `dim-deliveryButton ${className}`.trim(),
|
|
211
|
+
'data-kind': kind,
|
|
212
|
+
}, children);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function copyText(value) {
|
|
216
|
+
const clipboard = globalThis.navigator?.clipboard;
|
|
217
|
+
if (typeof clipboard?.writeText !== 'function') {
|
|
218
|
+
throw new Error('当前浏览器不支持自动复制,请手动选择复制。');
|
|
219
|
+
}
|
|
220
|
+
await clipboard.writeText(value);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function TargetForm({
|
|
224
|
+
definition,
|
|
225
|
+
mode,
|
|
226
|
+
initialValue,
|
|
227
|
+
source,
|
|
228
|
+
busy,
|
|
229
|
+
connected,
|
|
230
|
+
onCancel,
|
|
231
|
+
onSave,
|
|
232
|
+
onTest,
|
|
233
|
+
}) {
|
|
234
|
+
const editing = mode === 'edit';
|
|
235
|
+
const initialKind = initialValue?.kind && definition.fields[initialValue.kind]
|
|
236
|
+
? initialValue.kind
|
|
237
|
+
: definition.kinds[0].value;
|
|
238
|
+
const [targetId, setTargetId] = React.useState(initialValue?.targetId ?? '');
|
|
239
|
+
const [name, setName] = React.useState(initialValue?.name ?? '');
|
|
240
|
+
const [kind, setKind] = React.useState(initialKind);
|
|
241
|
+
const [route, setRoute] = React.useState(initialValue?.route ?? {});
|
|
242
|
+
const [error, setError] = React.useState(null);
|
|
243
|
+
const [testing, setTesting] = React.useState(false);
|
|
244
|
+
const [testState, setTestState] = React.useState(null);
|
|
245
|
+
|
|
246
|
+
const currentTarget = () => {
|
|
247
|
+
const normalizedRoute = Object.fromEntries(fieldsFor(definition, kind).map((field) => {
|
|
248
|
+
const raw = String(route[field.key] ?? '').trim();
|
|
249
|
+
return [field.key, field.integer ? Number(raw) : raw];
|
|
250
|
+
}));
|
|
251
|
+
return {
|
|
252
|
+
targetId: targetId.trim(),
|
|
253
|
+
...(name.trim() ? { name: name.trim() } : {}),
|
|
254
|
+
kind,
|
|
255
|
+
route: normalizedRoute,
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const submit = async (event) => {
|
|
260
|
+
event.preventDefault();
|
|
261
|
+
setError(null);
|
|
262
|
+
try {
|
|
263
|
+
await onSave({
|
|
264
|
+
...currentTarget(),
|
|
265
|
+
});
|
|
266
|
+
} catch (caught) {
|
|
267
|
+
setError(presentError(caught, '目标保存失败,请检查后重试。'));
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const testTarget = async () => {
|
|
272
|
+
if (typeof onTest !== 'function') return;
|
|
273
|
+
setTesting(true);
|
|
274
|
+
setTestState(null);
|
|
275
|
+
try {
|
|
276
|
+
const target = currentTarget();
|
|
277
|
+
await onTest({ kind: target.kind, route: target.route });
|
|
278
|
+
setTestState({ tone: 'success', message: '测试消息已发送,请到目标会话确认。' });
|
|
279
|
+
} catch (caught) {
|
|
280
|
+
setTestState({ tone: 'error', message: presentError(caught, '测试发送失败,请稍后重试。') });
|
|
281
|
+
} finally {
|
|
282
|
+
setTesting(false);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
const testReady = fieldsFor(definition, kind).every((field) => (
|
|
286
|
+
String(route[field.key] ?? '').trim()
|
|
287
|
+
));
|
|
288
|
+
|
|
289
|
+
return h('form', { className: 'dim-targetForm', onSubmit: submit },
|
|
290
|
+
h('div', { className: 'dim-targetFormHeading' },
|
|
291
|
+
h('h3', null, editing ? '编辑投递目标' : '新建投递目标'),
|
|
292
|
+
editing
|
|
293
|
+
? null
|
|
294
|
+
: h('p', null, source === 'suggestion'
|
|
295
|
+
? '已从会话自动填入目标信息;确认后再保存。'
|
|
296
|
+
: '请手动填写从对应平台取得的原生标识。')),
|
|
297
|
+
h('div', { className: 'dim-targetFormGrid' },
|
|
298
|
+
h('label', { className: 'dim-targetField' },
|
|
299
|
+
h('span', null, 'Target ID'),
|
|
300
|
+
h('input', {
|
|
301
|
+
name: 'targetId',
|
|
302
|
+
value: targetId,
|
|
303
|
+
readOnly: editing,
|
|
304
|
+
disabled: testing,
|
|
305
|
+
required: true,
|
|
306
|
+
pattern: '[A-Za-z0-9._:@-]{1,128}',
|
|
307
|
+
maxLength: 128,
|
|
308
|
+
autoCapitalize: 'none',
|
|
309
|
+
autoCorrect: 'off',
|
|
310
|
+
spellCheck: false,
|
|
311
|
+
placeholder: '例如 daily-report',
|
|
312
|
+
onChange: (event) => setTargetId(event.target.value),
|
|
313
|
+
})),
|
|
314
|
+
h('label', { className: 'dim-targetField' },
|
|
315
|
+
h('span', null, '显示名称(可选)'),
|
|
316
|
+
h('input', {
|
|
317
|
+
name: 'name',
|
|
318
|
+
value: name,
|
|
319
|
+
disabled: testing,
|
|
320
|
+
maxLength: 80,
|
|
321
|
+
placeholder: '例如 每日汇报群',
|
|
322
|
+
onChange: (event) => setName(event.target.value),
|
|
323
|
+
})),
|
|
324
|
+
h('label', { className: 'dim-targetField' },
|
|
325
|
+
h('span', null, '目标类型'),
|
|
326
|
+
h('select', {
|
|
327
|
+
name: 'kind',
|
|
328
|
+
value: kind,
|
|
329
|
+
disabled: testing,
|
|
330
|
+
onChange: (event) => {
|
|
331
|
+
setKind(event.target.value);
|
|
332
|
+
setRoute({});
|
|
333
|
+
setTestState(null);
|
|
334
|
+
},
|
|
335
|
+
}, definition.kinds.map((entry) => h('option', {
|
|
336
|
+
key: entry.value,
|
|
337
|
+
value: entry.value,
|
|
338
|
+
}, entry.label)))),
|
|
339
|
+
fieldsFor(definition, kind).map((field) => h('label', {
|
|
340
|
+
key: field.key,
|
|
341
|
+
className: 'dim-targetField',
|
|
342
|
+
},
|
|
343
|
+
h('span', null, field.label),
|
|
344
|
+
h('input', {
|
|
345
|
+
name: field.key,
|
|
346
|
+
value: route[field.key] ?? '',
|
|
347
|
+
disabled: testing,
|
|
348
|
+
required: true,
|
|
349
|
+
inputMode: field.inputMode,
|
|
350
|
+
autoCapitalize: 'none',
|
|
351
|
+
autoCorrect: 'off',
|
|
352
|
+
spellCheck: false,
|
|
353
|
+
placeholder: field.placeholder,
|
|
354
|
+
onChange: (event) => {
|
|
355
|
+
setRoute((current) => ({
|
|
356
|
+
...current,
|
|
357
|
+
[field.key]: event.target.value,
|
|
358
|
+
}));
|
|
359
|
+
setTestState(null);
|
|
360
|
+
},
|
|
361
|
+
})))),
|
|
362
|
+
error ? h('p', { className: 'dim-targetFormError', role: 'alert' }, error) : null,
|
|
363
|
+
testState ? h('p', {
|
|
364
|
+
className: 'dim-targetFeedback',
|
|
365
|
+
'data-tone': testState.tone,
|
|
366
|
+
role: 'status',
|
|
367
|
+
'aria-live': 'polite',
|
|
368
|
+
}, testState.message) : null,
|
|
369
|
+
h('div', { className: 'dim-targetFormActions' },
|
|
370
|
+
h(DeliveryButton, { onClick: onCancel, disabled: busy || testing }, '取消'),
|
|
371
|
+
h(DeliveryButton, {
|
|
372
|
+
onClick: () => void testTarget(),
|
|
373
|
+
disabled: busy || testing || !connected || !testReady || typeof onTest !== 'function',
|
|
374
|
+
title: connected ? undefined : '机器人离线时不可发送测试消息',
|
|
375
|
+
'aria-label': '测试投递目标',
|
|
376
|
+
}, testing ? '测试中…' : '测试'),
|
|
377
|
+
h(DeliveryButton, { type: 'submit', kind: 'primary', disabled: busy || testing },
|
|
378
|
+
busy ? '正在保存…' : '保存目标')));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function suggestionOptionLabel(definition, suggestion, added) {
|
|
382
|
+
const name = suggestionDisplayName(suggestion);
|
|
383
|
+
const type = localizeText(kindLabel(definition, suggestion.kind));
|
|
384
|
+
const route = fieldsFor(definition, suggestion.kind)
|
|
385
|
+
.map((field) => maskRouteValue(suggestion.route?.[field.key]))
|
|
386
|
+
.filter(Boolean)
|
|
387
|
+
.join(' · ');
|
|
388
|
+
return [name, type, route, added ? localizeText('已添加') : null]
|
|
389
|
+
.filter(Boolean)
|
|
390
|
+
.join(' · ');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function TargetSuggestionPicker({
|
|
394
|
+
definition,
|
|
395
|
+
phase,
|
|
396
|
+
suggestions,
|
|
397
|
+
error,
|
|
398
|
+
targets,
|
|
399
|
+
onRefresh,
|
|
400
|
+
onSelect,
|
|
401
|
+
onManual,
|
|
402
|
+
onCancel,
|
|
403
|
+
}) {
|
|
404
|
+
const configured = new Set(targets.map((target) => routeIdentity(definition, target)).filter(Boolean));
|
|
405
|
+
|
|
406
|
+
return h('section', { className: 'dim-targetSuggestions', 'aria-label': '从已聊过的会话选择' },
|
|
407
|
+
h('div', { className: 'dim-targetSuggestionHeading' },
|
|
408
|
+
h('div', null,
|
|
409
|
+
h('h3', null, '从已聊过的会话选择'),
|
|
410
|
+
h('p', null, '选择后会自动填写目标信息和调用别名,确认后再保存。')),
|
|
411
|
+
h(DeliveryButton, {
|
|
412
|
+
onClick: () => void onRefresh(),
|
|
413
|
+
disabled: phase === 'loading',
|
|
414
|
+
}, phase === 'loading' ? '正在刷新…' : '刷新')),
|
|
415
|
+
phase === 'loading'
|
|
416
|
+
? h('div', { className: 'dim-targetSuggestionState', 'aria-busy': 'true' }, '正在读取已聊会话…')
|
|
417
|
+
: phase === 'error'
|
|
418
|
+
? h('div', { className: 'dim-targetSuggestionState', role: 'alert' },
|
|
419
|
+
h('p', null, error),
|
|
420
|
+
h(DeliveryButton, { onClick: () => void onRefresh() }, '重新读取'))
|
|
421
|
+
: suggestions.length === 0
|
|
422
|
+
? h('div', { className: 'dim-targetSuggestionState' },
|
|
423
|
+
h('strong', null, '还没有可选择的会话'),
|
|
424
|
+
h('p', null, '先在对应平台与机器人聊一条消息,再刷新。'))
|
|
425
|
+
: h('label', { className: 'dim-targetSuggestionField' },
|
|
426
|
+
h('span', null, '已聊会话'),
|
|
427
|
+
h('select', {
|
|
428
|
+
name: 'suggestion',
|
|
429
|
+
value: '',
|
|
430
|
+
onChange: (event) => {
|
|
431
|
+
if (event.target.value === '') return;
|
|
432
|
+
const suggestion = suggestions[Number(event.target.value)];
|
|
433
|
+
if (suggestion) onSelect(suggestion);
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
h('option', { value: '', disabled: true }, '从会话选择targetID'),
|
|
437
|
+
suggestions.map((suggestion, index) => {
|
|
438
|
+
const identity = routeIdentity(definition, suggestion);
|
|
439
|
+
const added = configured.has(identity);
|
|
440
|
+
return React.createElement('option', {
|
|
441
|
+
key: suggestion.id ?? suggestion.suggestionId ?? `${identity}:${index}`,
|
|
442
|
+
value: String(index),
|
|
443
|
+
disabled: added,
|
|
444
|
+
}, suggestionOptionLabel(definition, suggestion, added));
|
|
445
|
+
}))),
|
|
446
|
+
h('div', { className: 'dim-targetFormActions' },
|
|
447
|
+
h(DeliveryButton, { onClick: onCancel }, '取消'),
|
|
448
|
+
h(DeliveryButton, { onClick: onManual }, '手动填写(高级)')));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function TargetRow({ definition, target, botId, connected, rpcCall, onChanged, onEdit }) {
|
|
452
|
+
const [action, setAction] = React.useState(null);
|
|
453
|
+
const [testState, setTestState] = React.useState(null);
|
|
454
|
+
const [copyState, setCopyState] = React.useState(null);
|
|
455
|
+
const [confirmDelete, setConfirmDelete] = React.useState(false);
|
|
456
|
+
const testTarget = async () => {
|
|
457
|
+
setAction('test');
|
|
458
|
+
setTestState(null);
|
|
459
|
+
try {
|
|
460
|
+
await rpcCall(DELIVERY_ENDPOINTS.test, { botId, targetId: target.targetId });
|
|
461
|
+
setTestState({ tone: 'success', message: '测试消息已发送,请到目标会话确认。' });
|
|
462
|
+
} catch (error) {
|
|
463
|
+
setTestState({ tone: 'error', message: presentError(error, '测试发送失败,请稍后重试。') });
|
|
464
|
+
} finally {
|
|
465
|
+
setAction(null);
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
const copyPair = async () => {
|
|
470
|
+
setCopyState(null);
|
|
471
|
+
try {
|
|
472
|
+
await copyText(JSON.stringify({ botId, targetId: target.targetId }));
|
|
473
|
+
setCopyState('已复制调用参数');
|
|
474
|
+
} catch (error) {
|
|
475
|
+
setCopyState(presentError(error, '复制失败。'));
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const deleteTarget = async () => {
|
|
480
|
+
setAction('delete');
|
|
481
|
+
try {
|
|
482
|
+
await rpcCall(DELIVERY_ENDPOINTS.delete, { botId, targetId: target.targetId });
|
|
483
|
+
await onChanged();
|
|
484
|
+
} catch (error) {
|
|
485
|
+
setTestState({ tone: 'error', message: presentError(error, '删除失败,请稍后重试。') });
|
|
486
|
+
setAction(null);
|
|
487
|
+
setConfirmDelete(false);
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
return h('li', { className: 'dim-targetRow', 'data-target-id': target.targetId },
|
|
492
|
+
h('div', { className: 'dim-targetSummary' },
|
|
493
|
+
h('div', { className: 'dim-targetTitle' },
|
|
494
|
+
React.createElement('strong', null, target.name || target.targetId),
|
|
495
|
+
h('span', null, kindLabel(definition, target.kind))),
|
|
496
|
+
h('code', null, `targetId: ${target.targetId}`)),
|
|
497
|
+
h('div', { className: 'dim-targetActions' },
|
|
498
|
+
h(DeliveryButton, { onClick: () => void copyPair() }, '复制调用参数'),
|
|
499
|
+
h(DeliveryButton, {
|
|
500
|
+
onClick: () => void testTarget(),
|
|
501
|
+
disabled: !connected || action === 'test',
|
|
502
|
+
title: connected ? undefined : '机器人离线时不可发送测试消息',
|
|
503
|
+
'aria-label': '测试投递目标',
|
|
504
|
+
}, action === 'test' ? '测试中…' : '测试'),
|
|
505
|
+
h(DeliveryButton, { onClick: onEdit, disabled: Boolean(action) }, '编辑'),
|
|
506
|
+
h(DeliveryButton, {
|
|
507
|
+
kind: 'danger',
|
|
508
|
+
onClick: () => setConfirmDelete(true),
|
|
509
|
+
disabled: Boolean(action),
|
|
510
|
+
}, '删除')),
|
|
511
|
+
copyState ? h('p', { className: 'dim-targetFeedback', role: 'status' }, copyState) : null,
|
|
512
|
+
testState ? h('p', {
|
|
513
|
+
className: 'dim-targetFeedback',
|
|
514
|
+
'data-tone': testState.tone,
|
|
515
|
+
role: 'status',
|
|
516
|
+
'aria-live': 'polite',
|
|
517
|
+
}, testState.message) : null,
|
|
518
|
+
confirmDelete ? h('div', { className: 'dim-targetDeleteConfirm', role: 'alertdialog' },
|
|
519
|
+
h('p', null,
|
|
520
|
+
'删除 ', h('code', null, target.targetId),
|
|
521
|
+
'?使用这个 targetId 的外部调用将返回 unknown-target。'),
|
|
522
|
+
h('div', { className: 'dim-targetFormActions' },
|
|
523
|
+
h(DeliveryButton, {
|
|
524
|
+
onClick: () => setConfirmDelete(false),
|
|
525
|
+
disabled: action === 'delete',
|
|
526
|
+
}, '取消'),
|
|
527
|
+
h(DeliveryButton, {
|
|
528
|
+
kind: 'danger',
|
|
529
|
+
onClick: () => void deleteTarget(),
|
|
530
|
+
disabled: action === 'delete',
|
|
531
|
+
}, action === 'delete' ? '正在删除…' : '确认删除')))
|
|
532
|
+
: null);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export function DeliveryTargetSettingsPage({ channel, account, rpcCall, onBack }) {
|
|
536
|
+
const definition = CHANNEL_DEFINITIONS[channel];
|
|
537
|
+
const [phase, setPhase] = React.useState('loading');
|
|
538
|
+
const [targets, setTargets] = React.useState([]);
|
|
539
|
+
const [suggestionPhase, setSuggestionPhase] = React.useState('idle');
|
|
540
|
+
const [suggestions, setSuggestions] = React.useState([]);
|
|
541
|
+
const [suggestionError, setSuggestionError] = React.useState(null);
|
|
542
|
+
const [error, setError] = React.useState(null);
|
|
543
|
+
const [editor, setEditor] = React.useState(null);
|
|
544
|
+
const [saving, setSaving] = React.useState(false);
|
|
545
|
+
const [botCopyState, setBotCopyState] = React.useState(null);
|
|
546
|
+
const mounted = React.useRef(true);
|
|
547
|
+
|
|
548
|
+
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
|
|
549
|
+
if (typeof rpcCall !== 'function') throw new Error('投递目标设置暂不可用。');
|
|
550
|
+
return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
|
|
551
|
+
}, [rpcCall]);
|
|
552
|
+
|
|
553
|
+
const loadTargets = React.useCallback(async ({ signal, silent = false } = {}) => {
|
|
554
|
+
if (!silent) setPhase('loading');
|
|
555
|
+
setError(null);
|
|
556
|
+
try {
|
|
557
|
+
const value = await invoke(DELIVERY_ENDPOINTS.list, { botId: account.botId }, signal);
|
|
558
|
+
if (signal?.aborted || !mounted.current) return;
|
|
559
|
+
setTargets(targetsFrom(value));
|
|
560
|
+
setPhase('ready');
|
|
561
|
+
} catch (caught) {
|
|
562
|
+
if (signal?.aborted || caught?.name === 'AbortError' || !mounted.current) return;
|
|
563
|
+
setError(presentError(caught, '无法读取投递目标,请稍后重试。'));
|
|
564
|
+
setPhase('error');
|
|
565
|
+
}
|
|
566
|
+
}, [account.botId, invoke]);
|
|
567
|
+
|
|
568
|
+
const loadSuggestions = React.useCallback(async () => {
|
|
569
|
+
setSuggestionPhase('loading');
|
|
570
|
+
setSuggestionError(null);
|
|
571
|
+
try {
|
|
572
|
+
const value = await invoke(DELIVERY_ENDPOINTS.listSuggestions, { botId: account.botId });
|
|
573
|
+
if (!mounted.current) return;
|
|
574
|
+
setSuggestions(validSuggestions(definition, value));
|
|
575
|
+
setSuggestionPhase('ready');
|
|
576
|
+
} catch (caught) {
|
|
577
|
+
if (caught?.name === 'AbortError' || !mounted.current) return;
|
|
578
|
+
setSuggestionError(presentError(caught, '无法读取已聊会话,请稍后重试。'));
|
|
579
|
+
setSuggestionPhase('error');
|
|
580
|
+
}
|
|
581
|
+
}, [account.botId, definition, invoke]);
|
|
582
|
+
|
|
583
|
+
React.useEffect(() => {
|
|
584
|
+
mounted.current = true;
|
|
585
|
+
const controller = new AbortController();
|
|
586
|
+
void loadTargets({ signal: controller.signal });
|
|
587
|
+
return () => {
|
|
588
|
+
mounted.current = false;
|
|
589
|
+
controller.abort();
|
|
590
|
+
};
|
|
591
|
+
}, [loadTargets]);
|
|
592
|
+
|
|
593
|
+
if (!definition) {
|
|
594
|
+
return h('section', { className: 'dim-deliveryPage' },
|
|
595
|
+
h(DeliveryButton, { className: 'dim-deliveryBack', onClick: onBack }, '← 返回机器人列表'),
|
|
596
|
+
h('p', { role: 'alert' }, '当前渠道暂不支持投递目标。'));
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const saveTarget = async (target) => {
|
|
600
|
+
setSaving(true);
|
|
601
|
+
try {
|
|
602
|
+
if (editor?.mode === 'edit') {
|
|
603
|
+
await invoke(DELIVERY_ENDPOINTS.update, {
|
|
604
|
+
botId: account.botId,
|
|
605
|
+
targetId: editor.target.targetId,
|
|
606
|
+
target: {
|
|
607
|
+
...(target.name ? { name: target.name } : {}),
|
|
608
|
+
kind: target.kind,
|
|
609
|
+
route: target.route,
|
|
610
|
+
},
|
|
611
|
+
});
|
|
612
|
+
} else {
|
|
613
|
+
await invoke(DELIVERY_ENDPOINTS.create, { botId: account.botId, target });
|
|
614
|
+
}
|
|
615
|
+
await loadTargets({ silent: true });
|
|
616
|
+
if (mounted.current) setEditor(null);
|
|
617
|
+
} finally {
|
|
618
|
+
if (mounted.current) setSaving(false);
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
const openSuggestionPicker = () => {
|
|
623
|
+
setEditor({ mode: 'suggestions' });
|
|
624
|
+
void loadSuggestions();
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
const selectSuggestion = (suggestion) => {
|
|
628
|
+
const identity = routeIdentity(definition, suggestion);
|
|
629
|
+
if (!identity) return;
|
|
630
|
+
const route = Object.fromEntries(fieldsFor(definition, suggestion.kind).map((field) => [
|
|
631
|
+
field.key,
|
|
632
|
+
suggestion.route[field.key],
|
|
633
|
+
]));
|
|
634
|
+
setEditor({
|
|
635
|
+
mode: 'create',
|
|
636
|
+
source: 'suggestion',
|
|
637
|
+
draftKey: identity,
|
|
638
|
+
initialValue: {
|
|
639
|
+
targetId: randomTargetId(targets),
|
|
640
|
+
name: suggestionFallbackName(definition, suggestion),
|
|
641
|
+
kind: suggestion.kind,
|
|
642
|
+
route,
|
|
643
|
+
},
|
|
644
|
+
});
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
const copyBotId = async () => {
|
|
648
|
+
setBotCopyState(null);
|
|
649
|
+
try {
|
|
650
|
+
await copyText(account.botId);
|
|
651
|
+
setBotCopyState('已复制 Bot ID');
|
|
652
|
+
} catch (caught) {
|
|
653
|
+
setBotCopyState(presentError(caught, '复制失败。'));
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
return h('section', {
|
|
658
|
+
className: 'dim-deliveryPage',
|
|
659
|
+
'aria-label': `${account.botName || definition.label}机器人设置`,
|
|
660
|
+
},
|
|
661
|
+
h('header', { className: 'dim-deliveryHeader' },
|
|
662
|
+
h(DeliveryButton, { className: 'dim-deliveryBack', onClick: onBack }, '← 返回机器人列表'),
|
|
663
|
+
h('div', { className: 'dim-deliveryHeaderMeta' },
|
|
664
|
+
h('a', {
|
|
665
|
+
className: 'dim-deliveryDocsLink',
|
|
666
|
+
href: isEnglish() ? DELIVERY_DOCS_URL.en : DELIVERY_DOCS_URL.zh,
|
|
667
|
+
target: '_blank',
|
|
668
|
+
rel: 'noopener noreferrer',
|
|
669
|
+
'aria-label': '打开主动投递使用文档',
|
|
670
|
+
},
|
|
671
|
+
h('span', null, '使用文档'),
|
|
672
|
+
h('span', { 'aria-hidden': 'true' }, '↗')))),
|
|
673
|
+
h('section', { className: 'dim-deliveryIdentity', 'aria-labelledby': 'dim-delivery-bot-title' },
|
|
674
|
+
h('div', null,
|
|
675
|
+
h('h2', { id: 'dim-delivery-bot-title', className: 'dim-deliveryBotName' },
|
|
676
|
+
account.botName || '机器人设置')),
|
|
677
|
+
h('div', { className: 'dim-deliveryBotId' },
|
|
678
|
+
h('span', null, 'Bot ID'),
|
|
679
|
+
h('code', { title: account.botId }, account.botId),
|
|
680
|
+
h(DeliveryButton, { onClick: () => void copyBotId() }, '复制')),
|
|
681
|
+
botCopyState ? h('p', { className: 'dim-targetFeedback', role: 'status' }, botCopyState) : null),
|
|
682
|
+
h('section', { className: 'dim-deliveryTargets', 'aria-labelledby': 'dim-delivery-targets-title' },
|
|
683
|
+
h('div', { className: 'dim-deliverySectionHeading' },
|
|
684
|
+
h('div', null,
|
|
685
|
+
h('h3', { id: 'dim-delivery-targets-title' }, '投递目标'),
|
|
686
|
+
account.connected
|
|
687
|
+
? null
|
|
688
|
+
: h('p', null, '机器人当前离线;仍可配置目标,恢复连接后再测试。')),
|
|
689
|
+
h(DeliveryButton, {
|
|
690
|
+
kind: 'primary',
|
|
691
|
+
onClick: openSuggestionPicker,
|
|
692
|
+
disabled: Boolean(editor) || phase !== 'ready',
|
|
693
|
+
title: phase === 'ready' ? undefined : '请先完成投递目标读取',
|
|
694
|
+
}, '新建目标')),
|
|
695
|
+
editor?.mode === 'suggestions'
|
|
696
|
+
? h(TargetSuggestionPicker, {
|
|
697
|
+
definition,
|
|
698
|
+
phase: suggestionPhase,
|
|
699
|
+
suggestions,
|
|
700
|
+
error: suggestionError,
|
|
701
|
+
targets,
|
|
702
|
+
onRefresh: loadSuggestions,
|
|
703
|
+
onSelect: selectSuggestion,
|
|
704
|
+
onManual: () => setEditor({
|
|
705
|
+
mode: 'create',
|
|
706
|
+
source: 'manual',
|
|
707
|
+
draftKey: 'manual',
|
|
708
|
+
initialValue: { targetId: randomTargetId(targets) },
|
|
709
|
+
}),
|
|
710
|
+
onCancel: () => setEditor(null),
|
|
711
|
+
})
|
|
712
|
+
: editor ? h(TargetForm, {
|
|
713
|
+
key: editor.mode === 'edit' ? editor.target.targetId : editor.draftKey,
|
|
714
|
+
definition,
|
|
715
|
+
mode: editor.mode,
|
|
716
|
+
initialValue: editor.mode === 'edit' ? editor.target : editor.initialValue,
|
|
717
|
+
source: editor.source,
|
|
718
|
+
busy: saving,
|
|
719
|
+
connected: account.connected,
|
|
720
|
+
onCancel: () => setEditor(editor.source === 'suggestion'
|
|
721
|
+
? { mode: 'suggestions' }
|
|
722
|
+
: null),
|
|
723
|
+
onSave: saveTarget,
|
|
724
|
+
onTest: (target) => invoke(DELIVERY_ENDPOINTS.test, {
|
|
725
|
+
botId: account.botId,
|
|
726
|
+
target,
|
|
727
|
+
}),
|
|
728
|
+
}) : null,
|
|
729
|
+
phase === 'loading'
|
|
730
|
+
? h('div', { className: 'dim-deliveryState', 'aria-busy': 'true' }, '正在读取投递目标…')
|
|
731
|
+
: phase === 'error'
|
|
732
|
+
? h('div', { className: 'dim-deliveryState', role: 'alert' },
|
|
733
|
+
h('p', null, error),
|
|
734
|
+
h(DeliveryButton, { onClick: () => void loadTargets() }, '重新读取'))
|
|
735
|
+
: targets.length === 0
|
|
736
|
+
? h('div', { className: 'dim-deliveryState dim-deliveryEmpty' },
|
|
737
|
+
h('strong', null, '尚未配置投递目标'),
|
|
738
|
+
h('p', null, '点击“新建目标”可从已聊过的会话选择,也可手动填写。'))
|
|
739
|
+
: h('ul', { className: 'dim-targetList' }, targets.map((target) => h(TargetRow, {
|
|
740
|
+
key: target.targetId,
|
|
741
|
+
definition,
|
|
742
|
+
target,
|
|
743
|
+
botId: account.botId,
|
|
744
|
+
connected: account.connected,
|
|
745
|
+
rpcCall: invoke,
|
|
746
|
+
onChanged: () => loadTargets({ silent: true }),
|
|
747
|
+
onEdit: () => setEditor({ mode: 'edit', target, source: 'edit' }),
|
|
748
|
+
})))));
|
|
749
|
+
}
|