@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,224 @@
|
|
|
1
|
+
import { normalizeDeliveryTarget } from './delivery-adapter.mjs';
|
|
2
|
+
|
|
3
|
+
const BOT_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
|
4
|
+
const TARGET_ID_PATTERN = /^[A-Za-z0-9._:@-]{1,128}$/;
|
|
5
|
+
const CHANNEL_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
|
|
6
|
+
const DRAFT_TARGET_ID = '__test__';
|
|
7
|
+
|
|
8
|
+
const ADAPTER_METHODS = Object.freeze([
|
|
9
|
+
'ownsBot',
|
|
10
|
+
'listTargets',
|
|
11
|
+
'listSuggestions',
|
|
12
|
+
'createTarget',
|
|
13
|
+
'updateTarget',
|
|
14
|
+
'deleteTarget',
|
|
15
|
+
'sendText',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const DELIVERY_ERROR_CODES = new Set([
|
|
19
|
+
'bad-request',
|
|
20
|
+
'unknown-bot',
|
|
21
|
+
'unknown-target',
|
|
22
|
+
'target-conflict',
|
|
23
|
+
'invalid-target',
|
|
24
|
+
'bot-not-connected',
|
|
25
|
+
'target-rejected',
|
|
26
|
+
'delivery-failed',
|
|
27
|
+
'cancelled',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
function deliveryError(code, message = code, options) {
|
|
31
|
+
const error = new Error(message, options);
|
|
32
|
+
error.code = code;
|
|
33
|
+
return error;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function botIdOf(value) {
|
|
37
|
+
if (typeof value !== 'string' || !BOT_ID_PATTERN.test(value)) {
|
|
38
|
+
throw deliveryError('bad-request', 'Invalid bot id');
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function targetIdOf(value) {
|
|
44
|
+
if (typeof value !== 'string' || !TARGET_ID_PATTERN.test(value)) {
|
|
45
|
+
throw deliveryError('bad-request', 'Invalid target id');
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function targetObject(value, { includesTargetId } = {}) {
|
|
51
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
52
|
+
throw deliveryError('bad-request', 'Invalid target');
|
|
53
|
+
}
|
|
54
|
+
if (includesTargetId) targetIdOf(value.targetId);
|
|
55
|
+
else if (Object.hasOwn(value, 'targetId')) {
|
|
56
|
+
throw deliveryError('bad-request', 'A target id cannot be changed');
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function draftTargetObject(value) {
|
|
62
|
+
targetObject(value);
|
|
63
|
+
const keys = Object.keys(value);
|
|
64
|
+
if (keys.length !== 2 || !keys.includes('kind') || !keys.includes('route')) {
|
|
65
|
+
throw deliveryError('bad-request', 'Invalid draft target');
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function cancellation(signal) {
|
|
71
|
+
if (signal?.aborted) throw deliveryError('cancelled', 'Request cancelled');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function publicOperationError(error, fallback = 'delivery-failed') {
|
|
75
|
+
if (error?.code === 'workspace-bot-not-found') {
|
|
76
|
+
return deliveryError('unknown-bot', 'Unknown bot', { cause: error });
|
|
77
|
+
}
|
|
78
|
+
if (DELIVERY_ERROR_CODES.has(error?.code)) return error;
|
|
79
|
+
return deliveryError(fallback, fallback, { cause: error });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function validateAdapter(adapter) {
|
|
83
|
+
if (!adapter || typeof adapter !== 'object'
|
|
84
|
+
|| typeof adapter.channel !== 'string' || !CHANNEL_PATTERN.test(adapter.channel)) {
|
|
85
|
+
throw new TypeError('A delivery adapter with a valid channel is required');
|
|
86
|
+
}
|
|
87
|
+
for (const method of ADAPTER_METHODS) {
|
|
88
|
+
if (typeof adapter[method] !== 'function') {
|
|
89
|
+
throw new TypeError(`A complete delivery adapter is required (${method})`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return adapter;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export class DeliveryService {
|
|
96
|
+
#adapters = new Map();
|
|
97
|
+
|
|
98
|
+
registerAdapter(value) {
|
|
99
|
+
const adapter = validateAdapter(value);
|
|
100
|
+
const registration = Object.freeze({ adapter });
|
|
101
|
+
this.#adapters.set(adapter.channel, registration);
|
|
102
|
+
return () => {
|
|
103
|
+
if (this.#adapters.get(adapter.channel) !== registration) return false;
|
|
104
|
+
this.#adapters.delete(adapter.channel);
|
|
105
|
+
return true;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async listTargets(botId) {
|
|
110
|
+
const id = botIdOf(botId);
|
|
111
|
+
const adapter = await this.#adapterFor(id);
|
|
112
|
+
try {
|
|
113
|
+
const targets = await adapter.listTargets(id);
|
|
114
|
+
if (!Array.isArray(targets)) throw new TypeError('Adapter returned invalid targets');
|
|
115
|
+
return { botId: id, channel: adapter.channel, targets };
|
|
116
|
+
} catch (error) {
|
|
117
|
+
throw publicOperationError(error);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async listSuggestions(botId) {
|
|
122
|
+
const id = botIdOf(botId);
|
|
123
|
+
const adapter = await this.#adapterFor(id);
|
|
124
|
+
try {
|
|
125
|
+
const suggestions = await adapter.listSuggestions(id);
|
|
126
|
+
if (!Array.isArray(suggestions)) throw new TypeError('Adapter returned invalid suggestions');
|
|
127
|
+
return {
|
|
128
|
+
botId: id,
|
|
129
|
+
channel: adapter.channel,
|
|
130
|
+
suggestions: suggestions.map((suggestion) => normalizeDeliveryTarget(
|
|
131
|
+
adapter.channel,
|
|
132
|
+
suggestion,
|
|
133
|
+
{ targetIdRequired: false },
|
|
134
|
+
)),
|
|
135
|
+
};
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw publicOperationError(error);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async createTarget(botId, target) {
|
|
142
|
+
const id = botIdOf(botId);
|
|
143
|
+
targetObject(target, { includesTargetId: true });
|
|
144
|
+
const adapter = await this.#adapterFor(id);
|
|
145
|
+
try {
|
|
146
|
+
return await adapter.createTarget(id, target);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
throw publicOperationError(error);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async updateTarget(botId, targetId, replacement) {
|
|
153
|
+
const id = botIdOf(botId);
|
|
154
|
+
const targetKey = targetIdOf(targetId);
|
|
155
|
+
targetObject(replacement);
|
|
156
|
+
const adapter = await this.#adapterFor(id);
|
|
157
|
+
try {
|
|
158
|
+
return await adapter.updateTarget(id, targetKey, replacement);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
throw publicOperationError(error);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async deleteTarget(botId, targetId) {
|
|
165
|
+
const id = botIdOf(botId);
|
|
166
|
+
const targetKey = targetIdOf(targetId);
|
|
167
|
+
const adapter = await this.#adapterFor(id);
|
|
168
|
+
try {
|
|
169
|
+
await adapter.deleteTarget(id, targetKey);
|
|
170
|
+
return { deleted: true };
|
|
171
|
+
} catch (error) {
|
|
172
|
+
throw publicOperationError(error);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async send(botId, targetIdOrDraft, text, { signal } = {}) {
|
|
177
|
+
const id = botIdOf(botId);
|
|
178
|
+
const targetKey = typeof targetIdOrDraft === 'string'
|
|
179
|
+
? targetIdOf(targetIdOrDraft)
|
|
180
|
+
: null;
|
|
181
|
+
const draft = targetKey === null ? draftTargetObject(targetIdOrDraft) : null;
|
|
182
|
+
if (typeof text !== 'string' || !text.trim()) {
|
|
183
|
+
throw deliveryError('bad-request', 'Message text is required');
|
|
184
|
+
}
|
|
185
|
+
cancellation(signal);
|
|
186
|
+
const adapter = await this.#adapterFor(id);
|
|
187
|
+
try {
|
|
188
|
+
let target;
|
|
189
|
+
if (draft) {
|
|
190
|
+
target = { targetId: DRAFT_TARGET_ID, ...draft };
|
|
191
|
+
} else {
|
|
192
|
+
const targets = await adapter.listTargets(id);
|
|
193
|
+
if (!Array.isArray(targets)) throw new TypeError('Adapter returned invalid targets');
|
|
194
|
+
target = targets.find((candidate) => candidate?.targetId === targetKey);
|
|
195
|
+
if (!target) throw deliveryError('unknown-target', 'Unknown target');
|
|
196
|
+
}
|
|
197
|
+
cancellation(signal);
|
|
198
|
+
await adapter.sendText(id, target, text, { signal });
|
|
199
|
+
return { sent: true };
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (signal?.aborted || error?.name === 'AbortError' || error?.code === 'ABORT_ERR') {
|
|
202
|
+
throw deliveryError('cancelled', 'Request cancelled', { cause: error });
|
|
203
|
+
}
|
|
204
|
+
throw publicOperationError(error);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async #adapterFor(botId) {
|
|
209
|
+
for (const { adapter } of this.#adapters.values()) {
|
|
210
|
+
let ownsBot;
|
|
211
|
+
try {
|
|
212
|
+
ownsBot = await adapter.ownsBot(botId);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
throw publicOperationError(error);
|
|
215
|
+
}
|
|
216
|
+
if (ownsBot) return adapter;
|
|
217
|
+
}
|
|
218
|
+
throw deliveryError('unknown-bot', 'Unknown bot');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function createDeliveryService() {
|
|
223
|
+
return new DeliveryService();
|
|
224
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
const CHANNELS = new Set([
|
|
2
|
+
'weixin',
|
|
3
|
+
'feishu',
|
|
4
|
+
'dingtalk',
|
|
5
|
+
'wecom',
|
|
6
|
+
'qq',
|
|
7
|
+
'slack',
|
|
8
|
+
'telegram',
|
|
9
|
+
'discord',
|
|
10
|
+
'whatsapp',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
function isRecord(value) {
|
|
14
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function opaqueId(value) {
|
|
18
|
+
return typeof value === 'string'
|
|
19
|
+
&& value.length > 0
|
|
20
|
+
&& value.length <= 512
|
|
21
|
+
&& value.trim() === value
|
|
22
|
+
&& !/[\s:\u0000-\u001f\u007f]/u.test(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function afterPrefix(key, prefix) {
|
|
26
|
+
const marker = `${prefix}:`;
|
|
27
|
+
if (!key.startsWith(marker)) return null;
|
|
28
|
+
const value = key.slice(marker.length);
|
|
29
|
+
return opaqueId(value) ? value : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function simpleSuggestion(key, definitions) {
|
|
33
|
+
for (const [prefix, kind, field] of definitions) {
|
|
34
|
+
const value = afterPrefix(key, prefix);
|
|
35
|
+
if (value) return { kind, route: { [field]: value } };
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function feishuSuggestion(key) {
|
|
41
|
+
const openId = afterPrefix(key, 'p2p');
|
|
42
|
+
if (openId?.startsWith('ou_')) {
|
|
43
|
+
return { kind: 'user', route: { openId } };
|
|
44
|
+
}
|
|
45
|
+
return simpleSuggestion(key, [['group', 'group', 'chatId']]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function slackSuggestion(key) {
|
|
49
|
+
const directChannel = afterPrefix(key, 'direct');
|
|
50
|
+
if (directChannel && /^[A-Za-z0-9_-]{1,128}$/.test(directChannel)) {
|
|
51
|
+
return { kind: 'conversation', route: { channelId: directChannel } };
|
|
52
|
+
}
|
|
53
|
+
const match = /^group:([^:]+):(\d{1,20}(?:\.\d{1,20})?)$/.exec(key);
|
|
54
|
+
if (!match || !/^[A-Za-z0-9_-]{1,128}$/.test(match[1])) return null;
|
|
55
|
+
return { kind: 'thread', route: { channelId: match[1], threadTs: match[2] } };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function telegramSuggestion(key) {
|
|
59
|
+
const match = /^(direct|group):(-?\d+)(?::([1-9]\d*))?$/.exec(key);
|
|
60
|
+
if (!match) return null;
|
|
61
|
+
const chatId = Number(match[2]);
|
|
62
|
+
if (!Number.isSafeInteger(chatId)) return null;
|
|
63
|
+
if (match[1] === 'direct' && match[3] !== undefined) return null;
|
|
64
|
+
if (match[3] === undefined) return { kind: 'chat', route: { chatId: match[2] } };
|
|
65
|
+
const messageThreadId = Number(match[3]);
|
|
66
|
+
if (!Number.isSafeInteger(messageThreadId) || messageThreadId <= 0) return null;
|
|
67
|
+
return { kind: 'topic', route: { chatId: match[2], messageThreadId } };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function discordSuggestion(key) {
|
|
71
|
+
const match = /^(?:direct|group):(\d{1,32})$/.exec(key);
|
|
72
|
+
return match ? { kind: 'channel', route: { channelId: match[1] } } : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function whatsappSuggestion(key) {
|
|
76
|
+
const direct = /^direct:(\d{5,32}@(s\.whatsapp\.net|lid))$/.exec(key);
|
|
77
|
+
if (direct) return { kind: 'user', route: { jid: direct[1] } };
|
|
78
|
+
const group = /^group:(\d{5,32}(?:-\d{1,32})?@g\.us)$/.exec(key);
|
|
79
|
+
return group ? { kind: 'group', route: { jid: group[1] } } : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Convert one persisted conversation key into a stable proactive-delivery route. */
|
|
83
|
+
export function deliverySuggestionFromConversationKey(channel, key) {
|
|
84
|
+
if (!CHANNELS.has(channel) || typeof key !== 'string') return null;
|
|
85
|
+
switch (channel) {
|
|
86
|
+
case 'weixin':
|
|
87
|
+
return simpleSuggestion(key, [['p2p', 'user', 'toUserId']]);
|
|
88
|
+
case 'feishu':
|
|
89
|
+
return feishuSuggestion(key);
|
|
90
|
+
case 'dingtalk':
|
|
91
|
+
return simpleSuggestion(key, [
|
|
92
|
+
['p2p', 'user', 'userId'],
|
|
93
|
+
['group', 'group', 'openConversationId'],
|
|
94
|
+
]);
|
|
95
|
+
case 'wecom':
|
|
96
|
+
return simpleSuggestion(key, [
|
|
97
|
+
['direct', 'user', 'chatId'],
|
|
98
|
+
['group', 'group', 'chatId'],
|
|
99
|
+
]);
|
|
100
|
+
case 'qq':
|
|
101
|
+
return simpleSuggestion(key, [
|
|
102
|
+
['c2c', 'user', 'userOpenId'],
|
|
103
|
+
['group', 'group', 'groupOpenId'],
|
|
104
|
+
]);
|
|
105
|
+
case 'slack':
|
|
106
|
+
return slackSuggestion(key);
|
|
107
|
+
case 'telegram':
|
|
108
|
+
return telegramSuggestion(key);
|
|
109
|
+
case 'discord':
|
|
110
|
+
return discordSuggestion(key);
|
|
111
|
+
case 'whatsapp':
|
|
112
|
+
return whatsappSuggestion(key);
|
|
113
|
+
default:
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Extract and de-duplicate stable delivery routes from a persisted sessions map.
|
|
120
|
+
* Session ids and every other state field are intentionally ignored.
|
|
121
|
+
*/
|
|
122
|
+
export function deliverySuggestionsFromSessions(channel, sessions) {
|
|
123
|
+
if (!CHANNELS.has(channel) || !isRecord(sessions)) return [];
|
|
124
|
+
const suggestions = [];
|
|
125
|
+
const seen = new Set();
|
|
126
|
+
for (const key of Object.keys(sessions)) {
|
|
127
|
+
const suggestion = deliverySuggestionFromConversationKey(channel, key);
|
|
128
|
+
if (!suggestion) continue;
|
|
129
|
+
const identity = JSON.stringify(suggestion);
|
|
130
|
+
if (seen.has(identity)) continue;
|
|
131
|
+
seen.add(identity);
|
|
132
|
+
suggestions.push(suggestion);
|
|
133
|
+
}
|
|
134
|
+
return suggestions;
|
|
135
|
+
}
|
|
@@ -10,6 +10,9 @@ import { apply as applyWeixin } from './channels/weixin/index.mjs';
|
|
|
10
10
|
import { apply as applyWhatsapp } from './channels/whatsapp/index.mjs';
|
|
11
11
|
import { installOutboundArtifactTool } from '../../src/channels/shared/semantic/artifact.mjs';
|
|
12
12
|
import { setImHostLanguage } from '../../src/channels/shared/i18n.mjs';
|
|
13
|
+
import { installDeliveryRpc } from './delivery-rpc.mjs';
|
|
14
|
+
import { installDeliveryHttp } from './delivery-http.mjs';
|
|
15
|
+
import { createDeliveryService } from './delivery-service.mjs';
|
|
13
16
|
import { installUpdateRpc } from './update-rpc.mjs';
|
|
14
17
|
|
|
15
18
|
export const name = 'dsh-im-host';
|
|
@@ -19,15 +22,19 @@ export const inject = [
|
|
|
19
22
|
'typertGateway',
|
|
20
23
|
];
|
|
21
24
|
|
|
22
|
-
function channelConfig(config, name) {
|
|
25
|
+
function channelConfig(config, name, deliveryService) {
|
|
23
26
|
const channel = config[name] ?? {};
|
|
24
|
-
|
|
27
|
+
const withAuthority = config.rpcAuthority === undefined
|
|
25
28
|
? channel
|
|
26
29
|
: { ...channel, rpcAuthority: config.rpcAuthority };
|
|
30
|
+
return name === 'office' ? withAuthority : { ...withAuthority, deliveryService };
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
export function createImHostPlugin(internals = {}) {
|
|
30
34
|
const startUpdate = internals.installUpdateRpc ?? installUpdateRpc;
|
|
35
|
+
const startDelivery = internals.installDeliveryRpc ?? installDeliveryRpc;
|
|
36
|
+
const startDeliveryHttp = internals.installDeliveryHttp ?? installDeliveryHttp;
|
|
37
|
+
const makeDeliveryService = internals.createDeliveryService ?? createDeliveryService;
|
|
31
38
|
const startFeishu = internals.applyFeishu ?? applyFeishu;
|
|
32
39
|
const startWeixin = internals.applyWeixin ?? applyWeixin;
|
|
33
40
|
const startDingtalk = internals.applyDingtalk ?? applyDingtalk;
|
|
@@ -54,8 +61,17 @@ export function createImHostPlugin(internals = {}) {
|
|
|
54
61
|
name,
|
|
55
62
|
inject,
|
|
56
63
|
async apply(ctx, config = {}) {
|
|
64
|
+
const deliveryService = makeDeliveryService();
|
|
65
|
+
if (typeof ctx?.provide === 'function') {
|
|
66
|
+
ctx.provide('dshIm', Object.freeze({
|
|
67
|
+
send: (botId, targetId, text, options) => (
|
|
68
|
+
deliveryService.send(botId, targetId, text, options)
|
|
69
|
+
),
|
|
70
|
+
listTargets: async (botId) => (await deliveryService.listTargets(botId)).targets,
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
57
73
|
const activate = async (readyCtx) => {
|
|
58
|
-
await activateChannels(readyCtx, config);
|
|
74
|
+
await activateChannels(readyCtx, config, deliveryService);
|
|
59
75
|
};
|
|
60
76
|
if (typeof ctx?.inject === 'function') {
|
|
61
77
|
const modern = typeof ctx?.typertGateway?.stream === 'function';
|
|
@@ -63,13 +79,19 @@ export function createImHostPlugin(internals = {}) {
|
|
|
63
79
|
modern ? ['sessionController', 'workspaceController'] : ['apiProxy'],
|
|
64
80
|
activate,
|
|
65
81
|
);
|
|
82
|
+
ctx.inject(['webServer'], (httpCtx) => {
|
|
83
|
+
startDeliveryHttp(httpCtx, deliveryService);
|
|
84
|
+
});
|
|
66
85
|
return;
|
|
67
86
|
}
|
|
68
87
|
await activate(ctx);
|
|
88
|
+
if (ctx?.webServer?.register && typeof ctx?.effect === 'function') {
|
|
89
|
+
startDeliveryHttp(ctx, deliveryService);
|
|
90
|
+
}
|
|
69
91
|
},
|
|
70
92
|
});
|
|
71
93
|
|
|
72
|
-
async function activateChannels(ctx, config) {
|
|
94
|
+
async function activateChannels(ctx, config, deliveryService) {
|
|
73
95
|
setImHostLanguage(config.language ?? process.env.DSH_IM_LANGUAGE);
|
|
74
96
|
if (typeof ctx?.inject === 'function') {
|
|
75
97
|
ctx.inject(['tools', 'systemPrompt'], (artifactCtx) => {
|
|
@@ -87,11 +109,16 @@ export function createImHostPlugin(internals = {}) {
|
|
|
87
109
|
} catch (error) {
|
|
88
110
|
logger.error?.('[dsh-im] failed to activate update management; continuing with channels', error);
|
|
89
111
|
}
|
|
112
|
+
try {
|
|
113
|
+
startDelivery(ctx, deliveryService, { authority: config.rpcAuthority });
|
|
114
|
+
} catch (error) {
|
|
115
|
+
logger.error?.('[dsh-im] failed to activate delivery management; continuing with channels', error);
|
|
116
|
+
}
|
|
90
117
|
}
|
|
91
118
|
const failures = [];
|
|
92
119
|
for (const [channel, start] of channels) {
|
|
93
120
|
try {
|
|
94
|
-
await start(ctx, channelConfig(config, channel));
|
|
121
|
+
await start(ctx, channelConfig(config, channel, deliveryService));
|
|
95
122
|
} catch (error) {
|
|
96
123
|
failures.push(error);
|
|
97
124
|
logger.error?.(`[dsh-im] failed to activate ${channel}; continuing with the remaining channels`, error);
|
|
@@ -24,6 +24,9 @@ const required = [
|
|
|
24
24
|
'bin/dsh-im.mjs',
|
|
25
25
|
'cordis.patch.yml',
|
|
26
26
|
'README.md',
|
|
27
|
+
'README.en.md',
|
|
28
|
+
'PROACTIVE_DELIVERY.md',
|
|
29
|
+
'PROACTIVE_DELIVERY.en.md',
|
|
27
30
|
'THIRD_PARTY_NOTICES.md',
|
|
28
31
|
'plugin-src/client/channels/dingtalk/index.js',
|
|
29
32
|
'plugin-src/client/channels/slack/index.js',
|
|
@@ -1049,6 +1049,39 @@ export function createDingtalkApi({
|
|
|
1049
1049
|
return true;
|
|
1050
1050
|
},
|
|
1051
1051
|
|
|
1052
|
+
async sendRobotText({ clientId, clientSecret, target, text, signal }) {
|
|
1053
|
+
if (typeof text !== 'string' || !text.trim()) throw new TypeError('text is required');
|
|
1054
|
+
const content = text;
|
|
1055
|
+
const normalizedTarget = normalizeFileTarget(target);
|
|
1056
|
+
const token = await accessToken({ clientId, clientSecret, signal });
|
|
1057
|
+
const body = {
|
|
1058
|
+
robotCode: normalizedTarget.robotCode,
|
|
1059
|
+
msgKey: 'sampleText',
|
|
1060
|
+
msgParam: JSON.stringify({ content }),
|
|
1061
|
+
...(normalizedTarget.type === 'group'
|
|
1062
|
+
? { openConversationId: normalizedTarget.openConversationId }
|
|
1063
|
+
: { userIds: [normalizedTarget.userId] }),
|
|
1064
|
+
};
|
|
1065
|
+
const pathname = normalizedTarget.type === 'group'
|
|
1066
|
+
? 'v1.0/robot/groupMessages/send'
|
|
1067
|
+
: 'v1.0/robot/oToMessages/batchSend';
|
|
1068
|
+
const response = await requestJson(fetchImpl, endpoint(apiBase, pathname), {
|
|
1069
|
+
body,
|
|
1070
|
+
headers: { 'x-acs-dingtalk-access-token': token },
|
|
1071
|
+
signal,
|
|
1072
|
+
action: '主动文字消息发送',
|
|
1073
|
+
});
|
|
1074
|
+
const rejection = rejectedProviderResponse(response);
|
|
1075
|
+
if (rejection) {
|
|
1076
|
+
throw new DingtalkApiError(
|
|
1077
|
+
'send-rejected',
|
|
1078
|
+
'钉钉服务拒绝了主动文字消息。',
|
|
1079
|
+
{ providerCode: rejection },
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
return response;
|
|
1083
|
+
},
|
|
1084
|
+
|
|
1052
1085
|
async sendFile(request) {
|
|
1053
1086
|
return sendArtifact(request, {
|
|
1054
1087
|
uploadType: 'file',
|
|
@@ -72,14 +72,14 @@ const HELP_TEXT_LINES = [
|
|
|
72
72
|
'/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)',
|
|
73
73
|
'/workspace 工作区绝对路径 切换工作区',
|
|
74
74
|
'/workspacelist 列出工作区绝对路径',
|
|
75
|
-
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
|
75
|
+
'/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题',
|
|
76
76
|
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
|
|
77
77
|
'/models 按序号列出所有可用模型',
|
|
78
78
|
'/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级',
|
|
79
79
|
'/reasoning [序号、等级ID或 --default] 查看或切换当前推理等级',
|
|
80
80
|
'/model [序号或完整模型ID] [推理等级ID] 查看或切换当前会话模型',
|
|
81
81
|
'示例:先发 /models,再发 /model 2 [推理等级ID]',
|
|
82
|
-
'/presetlist 按序号列出可用 Agent Preset',
|
|
82
|
+
'/presetlist 或 /presets 按序号列出可用 Agent Preset',
|
|
83
83
|
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
|
|
84
84
|
'纯数字 ID:/preset id:<ID>',
|
|
85
85
|
'/preset --default 跟随 Host 默认',
|
|
@@ -406,6 +406,20 @@ export class DingtalkController {
|
|
|
406
406
|
});
|
|
407
407
|
}
|
|
408
408
|
|
|
409
|
+
async sendProactiveText(botId, target, text, options = {}) {
|
|
410
|
+
const config = this.#configStore.get(botId);
|
|
411
|
+
if (!config) throw new Error('Unknown DingTalk bot');
|
|
412
|
+
return this.#withBotTransition(botId, async () => {
|
|
413
|
+
const runtime = this.#runtimes.get(botId);
|
|
414
|
+
if (!runtime?.status?.ready || typeof runtime.sendProactiveText !== 'function') {
|
|
415
|
+
const error = new Error(t('钉钉消息连接当前离线'));
|
|
416
|
+
error.code = 'bot-not-connected';
|
|
417
|
+
throw error;
|
|
418
|
+
}
|
|
419
|
+
return runtime.sendProactiveText(target, text, options);
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
409
423
|
/** Removes one bot, its secret, runtime, and local conversation state. */
|
|
410
424
|
async deleteBot(botId) {
|
|
411
425
|
const config = this.#configStore.get(botId);
|
|
@@ -388,6 +388,45 @@ export class DingtalkRuntime {
|
|
|
388
388
|
});
|
|
389
389
|
}
|
|
390
390
|
|
|
391
|
+
async sendProactiveText(target, text, { signal } = {}) {
|
|
392
|
+
const userId = typeof target?.route?.userId === 'string'
|
|
393
|
+
? target.route.userId.trim() : '';
|
|
394
|
+
const openConversationId = typeof target?.route?.openConversationId === 'string'
|
|
395
|
+
? target.route.openConversationId.trim() : '';
|
|
396
|
+
if ((target?.kind === 'user' && (!userId || openConversationId))
|
|
397
|
+
|| (target?.kind === 'group' && (!openConversationId || userId))
|
|
398
|
+
|| (target?.kind !== 'user' && target?.kind !== 'group')) {
|
|
399
|
+
const error = new TypeError('Invalid DingTalk proactive delivery target');
|
|
400
|
+
error.code = 'invalid-target';
|
|
401
|
+
throw error;
|
|
402
|
+
}
|
|
403
|
+
if (!this.#status.ready || !this.#abortController) {
|
|
404
|
+
const error = new Error('DingTalk runtime is not connected');
|
|
405
|
+
error.code = 'bot-not-connected';
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
signal?.throwIfAborted();
|
|
409
|
+
try {
|
|
410
|
+
await this.#api.sendRobotText({
|
|
411
|
+
clientId: this.#config.clientId,
|
|
412
|
+
clientSecret: this.#clientSecret,
|
|
413
|
+
target: {
|
|
414
|
+
type: target.kind,
|
|
415
|
+
robotCode: this.#config.clientId,
|
|
416
|
+
...(target.kind === 'user' ? { userId } : { openConversationId }),
|
|
417
|
+
},
|
|
418
|
+
text,
|
|
419
|
+
signal: signal ?? this.#abortController.signal,
|
|
420
|
+
});
|
|
421
|
+
} catch (cause) {
|
|
422
|
+
if (cause?.code !== 'send-rejected') throw cause;
|
|
423
|
+
const error = new Error('DingTalk rejected the proactive delivery target', { cause });
|
|
424
|
+
error.code = 'target-rejected';
|
|
425
|
+
throw error;
|
|
426
|
+
}
|
|
427
|
+
return { sent: true };
|
|
428
|
+
}
|
|
429
|
+
|
|
391
430
|
#pendingSenders() {
|
|
392
431
|
return typeof this.#state.pendingSenders === 'function'
|
|
393
432
|
? this.#state.pendingSenders()
|
|
@@ -501,6 +501,22 @@ export class DiscordRuntime {
|
|
|
501
501
|
return this.#bridge.sendConnectionTest(text);
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
+
async sendProactiveText(target, text, options = {}) {
|
|
505
|
+
if (!this.#status.ready || !this.#bridge) {
|
|
506
|
+
const error = new Error('Discord bot is not connected');
|
|
507
|
+
error.code = 'bot-not-connected';
|
|
508
|
+
throw error;
|
|
509
|
+
}
|
|
510
|
+
const channelId = typeof target?.route?.channelId === 'string'
|
|
511
|
+
? target.route.channelId.trim() : '';
|
|
512
|
+
if (target?.kind !== 'channel' || !channelId) {
|
|
513
|
+
const error = new TypeError('Invalid Discord proactive delivery target');
|
|
514
|
+
error.code = 'invalid-target';
|
|
515
|
+
throw error;
|
|
516
|
+
}
|
|
517
|
+
return this.#bridge.sendProactiveText({ channelId }, text, options);
|
|
518
|
+
}
|
|
519
|
+
|
|
504
520
|
async start() {
|
|
505
521
|
if (this.#status.ready && this.#socket) return this.status;
|
|
506
522
|
if (this.#starting) return this.#starting;
|
|
@@ -93,7 +93,7 @@ const REPAIR_COMMAND = /^\/repair(?:\s+(qr|status|cancel|verify))?\s*$/i;
|
|
|
93
93
|
const WATCH_COMMAND = /^\/watch(?:\s+([^\s]+))?$/i;
|
|
94
94
|
const UNWATCH_COMMAND = /^\/unwatch(?:\s+([^\s]+))?$/i;
|
|
95
95
|
const WATCHLIST_COMMAND = /^\/watchlist$/i;
|
|
96
|
-
const SESSION_LIST_PREFIX = /^\/sessionlist(?:\s|$)/i;
|
|
96
|
+
const SESSION_LIST_PREFIX = /^\/(?:sessionlist|sessions)(?:\s|$)/i;
|
|
97
97
|
const WORKSPACE_LIST_COMMAND = /^\/workspacelist$/i;
|
|
98
98
|
const NUMBER_REPLY = /^\d{1,2}$/;
|
|
99
99
|
/** A displayed menu stays number-tappable for this long. */
|
|
@@ -133,13 +133,13 @@ const REPAIR_URL_HOSTS = new Set([
|
|
|
133
133
|
|
|
134
134
|
const ARCHIVED_COMMAND = /^\/archived(?:\s+(on|off))?$/i;
|
|
135
135
|
/** Matches fast card commands that should not be queued behind a running task. */
|
|
136
|
-
const CARD_COMMAND = /^\/(?:m(?:enu)?|new|help|status|compact|sessionlist(?:\s|$)|workspacelist|watchlist|archived(?:\s+(on|off))?)$/i;
|
|
136
|
+
const CARD_COMMAND = /^\/(?:m(?:enu)?|new|help|status|compact|(?:sessionlist|sessions)(?:\s|$)|workspacelist|watchlist|archived(?:\s+(on|off))?)$/i;
|
|
137
137
|
|
|
138
138
|
/** Canonical workspace/session help advertised by every bridge family. */
|
|
139
139
|
const WORKSPACE_HELP_LINES = [
|
|
140
140
|
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
|
|
141
141
|
'/workspacelist 列出工作区绝对路径',
|
|
142
|
-
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
|
142
|
+
'/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题',
|
|
143
143
|
];
|
|
144
144
|
|
|
145
145
|
/** Safe user-facing text for bind/workspace failures (no raw messages). */
|
|
@@ -1005,7 +1005,7 @@ export class FeishuHarnessBridge {
|
|
|
1005
1005
|
return;
|
|
1006
1006
|
}
|
|
1007
1007
|
if (SESSION_LIST_PREFIX.test(commandText)) {
|
|
1008
|
-
const selector = commandText.
|
|
1008
|
+
const selector = commandText.replace(/^\/(?:sessionlist|sessions)/i, '').trim() || null;
|
|
1009
1009
|
await this.#showSessions({ chatId: event.message.chat_id, key }, selector, 0);
|
|
1010
1010
|
return;
|
|
1011
1011
|
}
|
|
@@ -487,7 +487,7 @@ export function menuHelpText() {
|
|
|
487
487
|
'🤖 助手菜单(回复数字即可,无需记命令)',
|
|
488
488
|
'',
|
|
489
489
|
'📋 会话 / 工作区',
|
|
490
|
-
'/sessionlist 列出工作区会话',
|
|
490
|
+
'/sessionlist 或 /sessions 列出工作区会话',
|
|
491
491
|
'/session ID 绑定已有会话',
|
|
492
492
|
'/workspacelist 列出工作区',
|
|
493
493
|
'/workspace 路径 切换工作区',
|
|
@@ -506,7 +506,7 @@ export function menuHelpText() {
|
|
|
506
506
|
'/unwatch ID 取消关注',
|
|
507
507
|
'',
|
|
508
508
|
'🤖 预设 / 模型',
|
|
509
|
-
'/presetlist 列出可用 Agent Preset',
|
|
509
|
+
'/presetlist 或 /presets 列出可用 Agent Preset',
|
|
510
510
|
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
|
|
511
511
|
'纯数字 ID:/preset id:<ID>',
|
|
512
512
|
'/preset --default 跟随 Host 默认',
|
|
@@ -553,7 +553,7 @@ const HELP_TEXT_COMMANDS = [
|
|
|
553
553
|
'`/m` — 打开菜单卡片',
|
|
554
554
|
'`/new` — 开启全新会话',
|
|
555
555
|
'`/session ID` — 绑定已有会话',
|
|
556
|
-
'`/sessionlist [工作区]` — 列出会话',
|
|
556
|
+
'`/sessionlist [工作区]` 或 `/sessions [工作区]` — 列出会话',
|
|
557
557
|
'`/workspace 路径` — 切换工作区',
|
|
558
558
|
'`/workspacelist` — 列出工作区',
|
|
559
559
|
'`/status` — 查看连接状态',
|
|
@@ -564,7 +564,7 @@ const HELP_TEXT_COMMANDS = [
|
|
|
564
564
|
'`/watchlist` — 关注列表',
|
|
565
565
|
'`/unwatch ID` — 取消关注',
|
|
566
566
|
'`/archived on/off` — 归档显隐',
|
|
567
|
-
'`/presetlist` — 列出预设',
|
|
567
|
+
'`/presetlist` 或 `/presets` — 列出预设',
|
|
568
568
|
'`/preset [序号/ID]` — 切换预设',
|
|
569
569
|
'`/preset --default` — 跟随默认',
|
|
570
570
|
'`/models` — 列出模型',
|