@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
|
@@ -9,13 +9,18 @@ export async function apply(ctx, config = {}) {
|
|
|
9
9
|
return installWhatsappRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority);
|
|
10
10
|
}
|
|
11
11
|
const production = await createProductionController(ctx, config, config.internals ?? {});
|
|
12
|
+
const unregisterDelivery = config.deliveryService && production.deliveryAdapter
|
|
13
|
+
? config.deliveryService.registerAdapter(production.deliveryAdapter) : undefined;
|
|
12
14
|
const disposeRpc = installWhatsappRpc(
|
|
13
15
|
ctx,
|
|
14
16
|
production.controller,
|
|
15
17
|
config.rpcOptions,
|
|
16
18
|
config.rpcAuthority,
|
|
17
19
|
);
|
|
18
|
-
ctx.effect(() => async () =>
|
|
20
|
+
ctx.effect(() => async () => {
|
|
21
|
+
await unregisterDelivery?.();
|
|
22
|
+
await production.close();
|
|
23
|
+
}, 'dsh-im: close WhatsApp Web connections');
|
|
19
24
|
return disposeRpc;
|
|
20
25
|
}
|
|
21
26
|
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
observeBotWorkspaceRemovals,
|
|
16
16
|
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
|
17
17
|
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
|
18
|
+
import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
|
|
18
19
|
import { createTokenConnectionSupervisor } from '../shared/connection-supervisor.mjs';
|
|
19
20
|
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
|
20
21
|
import { harnessConnection } from '../../harness-connection.mjs';
|
|
@@ -151,6 +152,9 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
151
152
|
}).start();
|
|
152
153
|
return {
|
|
153
154
|
controller,
|
|
155
|
+
deliveryAdapter: createDeliveryAdapter({
|
|
156
|
+
channel: 'whatsapp', workspaces, coreController, stateFor,
|
|
157
|
+
}),
|
|
154
158
|
ready: supervisor.ready,
|
|
155
159
|
async close() {
|
|
156
160
|
await supervisor.close();
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { deliverySuggestionsFromSessions } from './delivery-suggestions.mjs';
|
|
2
|
+
|
|
3
|
+
const CHANNELS = new Set([
|
|
4
|
+
'weixin',
|
|
5
|
+
'feishu',
|
|
6
|
+
'dingtalk',
|
|
7
|
+
'wecom',
|
|
8
|
+
'qq',
|
|
9
|
+
'slack',
|
|
10
|
+
'telegram',
|
|
11
|
+
'discord',
|
|
12
|
+
'whatsapp',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
export function supportsDeliveryChannel(channel) {
|
|
16
|
+
return CHANNELS.has(channel);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function invalidTarget(message) {
|
|
20
|
+
const error = new Error(message);
|
|
21
|
+
error.code = 'invalid-target';
|
|
22
|
+
return error;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isRecord(value) {
|
|
26
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function exactKeys(value, allowed, label) {
|
|
30
|
+
if (!isRecord(value)) throw invalidTarget(`${label} must be an object`);
|
|
31
|
+
const extra = Object.keys(value).find((key) => !allowed.includes(key));
|
|
32
|
+
if (extra !== undefined) throw invalidTarget(`${label} contains an unknown field`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function requiredString(value, label) {
|
|
36
|
+
if (typeof value !== 'string' || !value || value.trim() !== value) {
|
|
37
|
+
throw invalidTarget(`${label} must be a non-empty string without surrounding whitespace`);
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function targetName(value) {
|
|
43
|
+
if (typeof value !== 'string' || !value.trim() || value.trim().length > 80) {
|
|
44
|
+
throw invalidTarget('target.name must contain 1 to 80 characters');
|
|
45
|
+
}
|
|
46
|
+
return value.trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function routeWithStrings(route, fields) {
|
|
50
|
+
exactKeys(route, fields, 'route');
|
|
51
|
+
const normalized = {};
|
|
52
|
+
for (const field of fields) normalized[field] = requiredString(route[field], `route.${field}`);
|
|
53
|
+
return normalized;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function oneOf(value, choices) {
|
|
57
|
+
if (!choices.includes(value)) throw invalidTarget('target kind is not supported by this channel');
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeRoute(channel, kind, route) {
|
|
62
|
+
switch (channel) {
|
|
63
|
+
case 'weixin':
|
|
64
|
+
oneOf(kind, ['user']);
|
|
65
|
+
return routeWithStrings(route, ['toUserId']);
|
|
66
|
+
case 'feishu':
|
|
67
|
+
oneOf(kind, ['user', 'group']);
|
|
68
|
+
return routeWithStrings(route, kind === 'user' ? ['openId'] : ['chatId']);
|
|
69
|
+
case 'dingtalk':
|
|
70
|
+
oneOf(kind, ['user', 'group']);
|
|
71
|
+
return routeWithStrings(route, kind === 'user' ? ['userId'] : ['openConversationId']);
|
|
72
|
+
case 'wecom':
|
|
73
|
+
oneOf(kind, ['user', 'group']);
|
|
74
|
+
return routeWithStrings(route, ['chatId']);
|
|
75
|
+
case 'qq':
|
|
76
|
+
oneOf(kind, ['user', 'group']);
|
|
77
|
+
return routeWithStrings(route, kind === 'user' ? ['userOpenId'] : ['groupOpenId']);
|
|
78
|
+
case 'slack': {
|
|
79
|
+
oneOf(kind, ['conversation', 'thread']);
|
|
80
|
+
const normalized = routeWithStrings(
|
|
81
|
+
route,
|
|
82
|
+
kind === 'conversation' ? ['channelId'] : ['channelId', 'threadTs'],
|
|
83
|
+
);
|
|
84
|
+
return normalized;
|
|
85
|
+
}
|
|
86
|
+
case 'telegram': {
|
|
87
|
+
oneOf(kind, ['chat', 'topic']);
|
|
88
|
+
exactKeys(route, kind === 'chat' ? ['chatId'] : ['chatId', 'messageThreadId'], 'route');
|
|
89
|
+
const chatId = requiredString(route.chatId, 'route.chatId');
|
|
90
|
+
if (!/^-?\d+$/.test(chatId)) throw invalidTarget('route.chatId must be a decimal string');
|
|
91
|
+
if (kind === 'chat') return { chatId };
|
|
92
|
+
if (!Number.isSafeInteger(route.messageThreadId) || route.messageThreadId <= 0) {
|
|
93
|
+
throw invalidTarget('route.messageThreadId must be a positive integer');
|
|
94
|
+
}
|
|
95
|
+
return { chatId, messageThreadId: route.messageThreadId };
|
|
96
|
+
}
|
|
97
|
+
case 'discord':
|
|
98
|
+
oneOf(kind, ['channel']);
|
|
99
|
+
return routeWithStrings(route, ['channelId']);
|
|
100
|
+
case 'whatsapp': {
|
|
101
|
+
oneOf(kind, ['user', 'group']);
|
|
102
|
+
const normalized = routeWithStrings(route, ['jid']);
|
|
103
|
+
const valid = kind === 'user'
|
|
104
|
+
? /^\d{5,32}@(s\.whatsapp\.net|lid)$/.test(normalized.jid)
|
|
105
|
+
: /^\d{5,32}(?:-\d{1,32})?@g\.us$/.test(normalized.jid);
|
|
106
|
+
if (!valid) {
|
|
107
|
+
throw invalidTarget(`route.jid must be a ${kind} WhatsApp JID`);
|
|
108
|
+
}
|
|
109
|
+
return normalized;
|
|
110
|
+
}
|
|
111
|
+
default:
|
|
112
|
+
throw new TypeError(`Unsupported delivery channel: ${channel}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Strictly validate and copy one channel delivery target. */
|
|
117
|
+
export function normalizeDeliveryTarget(channel, value, { targetIdRequired = true } = {}) {
|
|
118
|
+
if (!supportsDeliveryChannel(channel)) throw new TypeError(`Unsupported delivery channel: ${channel}`);
|
|
119
|
+
exactKeys(
|
|
120
|
+
value,
|
|
121
|
+
targetIdRequired ? ['targetId', 'name', 'kind', 'route'] : ['name', 'kind', 'route'],
|
|
122
|
+
'target',
|
|
123
|
+
);
|
|
124
|
+
const normalized = {};
|
|
125
|
+
if (targetIdRequired) normalized.targetId = requiredString(value.targetId, 'target.targetId');
|
|
126
|
+
if (value.name !== undefined) normalized.name = targetName(value.name);
|
|
127
|
+
normalized.kind = requiredString(value.kind, 'target.kind');
|
|
128
|
+
normalized.route = normalizeRoute(channel, normalized.kind, value.route);
|
|
129
|
+
return normalized;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Bind one channel's existing workspace store and unwrapped controller to DeliveryService. */
|
|
133
|
+
export function createDeliveryAdapter({ channel, workspaces, coreController, stateFor }) {
|
|
134
|
+
if (!supportsDeliveryChannel(channel)) throw new TypeError(`Unsupported delivery channel: ${channel}`);
|
|
135
|
+
if (!workspaces || typeof workspaces !== 'object') {
|
|
136
|
+
throw new TypeError('delivery adapter requires a workspace store');
|
|
137
|
+
}
|
|
138
|
+
if (!coreController || typeof coreController !== 'object') {
|
|
139
|
+
throw new TypeError('delivery adapter requires a proactive text controller');
|
|
140
|
+
}
|
|
141
|
+
if (typeof stateFor !== 'function') {
|
|
142
|
+
throw new TypeError('delivery adapter requires a bot state getter');
|
|
143
|
+
}
|
|
144
|
+
return Object.freeze({
|
|
145
|
+
channel,
|
|
146
|
+
ownsBot: (botId) => workspaces.has(botId),
|
|
147
|
+
listTargets: (botId) => workspaces.listDeliveryTargets(botId),
|
|
148
|
+
async listSuggestions(botId) {
|
|
149
|
+
const state = await stateFor(botId);
|
|
150
|
+
if (!state || typeof state.snapshot !== 'function') {
|
|
151
|
+
throw new TypeError('delivery suggestion state cannot be inspected');
|
|
152
|
+
}
|
|
153
|
+
const suggestions = deliverySuggestionsFromSessions(channel, state.snapshot()?.sessions);
|
|
154
|
+
return suggestions.map((suggestion) => normalizeDeliveryTarget(
|
|
155
|
+
channel,
|
|
156
|
+
suggestion,
|
|
157
|
+
{ targetIdRequired: false },
|
|
158
|
+
));
|
|
159
|
+
},
|
|
160
|
+
createTarget: (botId, target) => workspaces.createDeliveryTarget(
|
|
161
|
+
botId,
|
|
162
|
+
normalizeDeliveryTarget(channel, target),
|
|
163
|
+
),
|
|
164
|
+
updateTarget: (botId, targetId, replacement) => workspaces.updateDeliveryTarget(
|
|
165
|
+
botId,
|
|
166
|
+
targetId,
|
|
167
|
+
normalizeDeliveryTarget(channel, replacement, { targetIdRequired: false }),
|
|
168
|
+
),
|
|
169
|
+
deleteTarget: (botId, targetId) => workspaces.deleteDeliveryTarget(botId, targetId),
|
|
170
|
+
async sendText(botId, target, text, options = {}) {
|
|
171
|
+
const normalized = normalizeDeliveryTarget(channel, target);
|
|
172
|
+
if (typeof coreController.sendProactiveText !== 'function') {
|
|
173
|
+
throw new TypeError('delivery controller cannot send proactive text');
|
|
174
|
+
}
|
|
175
|
+
await coreController.sendProactiveText(botId, normalized, text, options);
|
|
176
|
+
return { sent: true };
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createDeliveryRpcHandler, DELIVERY_ENDPOINTS } from './delivery-rpc.mjs';
|
|
2
|
+
|
|
3
|
+
export const DELIVERY_HTTP_PATH = '/api/dsh-im/delivery/messages';
|
|
4
|
+
|
|
5
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
const DELIVERY_ERROR_STATUS = Object.freeze({
|
|
8
|
+
'bad-request': 400,
|
|
9
|
+
'unknown-bot': 404,
|
|
10
|
+
'unknown-target': 404,
|
|
11
|
+
'target-conflict': 409,
|
|
12
|
+
'invalid-target': 422,
|
|
13
|
+
'bot-not-connected': 503,
|
|
14
|
+
'target-rejected': 422,
|
|
15
|
+
'delivery-failed': 502,
|
|
16
|
+
cancelled: 408,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
class HttpRequestError extends Error {
|
|
20
|
+
constructor(status, code) {
|
|
21
|
+
super(code);
|
|
22
|
+
this.status = status;
|
|
23
|
+
this.code = code;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function json(response, status, value, headers = {}) {
|
|
28
|
+
if (response.destroyed || response.writableEnded) return;
|
|
29
|
+
const body = JSON.stringify(value);
|
|
30
|
+
response.writeHead(status, {
|
|
31
|
+
'cache-control': 'no-store',
|
|
32
|
+
'content-type': 'application/json; charset=utf-8',
|
|
33
|
+
'content-length': String(Buffer.byteLength(body)),
|
|
34
|
+
...headers,
|
|
35
|
+
});
|
|
36
|
+
response.end(body);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isJsonContentType(value) {
|
|
40
|
+
return typeof value === 'string'
|
|
41
|
+
&& value.split(';', 1)[0].trim().toLowerCase() === 'application/json';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function readJsonBody(request) {
|
|
45
|
+
const declaredLength = Number(request.headers['content-length']);
|
|
46
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) {
|
|
47
|
+
throw new HttpRequestError(413, 'payload-too-large');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const chunks = [];
|
|
51
|
+
let length = 0;
|
|
52
|
+
for await (const chunk of request) {
|
|
53
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
54
|
+
length += buffer.length;
|
|
55
|
+
if (length > MAX_BODY_BYTES) {
|
|
56
|
+
throw new HttpRequestError(413, 'payload-too-large');
|
|
57
|
+
}
|
|
58
|
+
chunks.push(buffer);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(Buffer.concat(chunks, length).toString('utf8'));
|
|
63
|
+
} catch {
|
|
64
|
+
throw new HttpRequestError(400, 'bad-request');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createDeliveryHttpHandler(service) {
|
|
69
|
+
const dispatch = createDeliveryRpcHandler(service);
|
|
70
|
+
return async (request, response) => {
|
|
71
|
+
if (request.method !== 'POST') {
|
|
72
|
+
json(response, 405, {
|
|
73
|
+
error: { code: 'method-not-allowed', message: 'method-not-allowed', details: {} },
|
|
74
|
+
}, { allow: 'POST' });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (!isJsonContentType(request.headers['content-type'])) {
|
|
78
|
+
json(response, 415, {
|
|
79
|
+
error: { code: 'unsupported-media-type', message: 'unsupported-media-type', details: {} },
|
|
80
|
+
});
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const abort = new AbortController();
|
|
85
|
+
const cancel = () => abort.abort();
|
|
86
|
+
const cancelClosedResponse = () => {
|
|
87
|
+
if (!response.writableEnded) cancel();
|
|
88
|
+
};
|
|
89
|
+
request.once('aborted', cancel);
|
|
90
|
+
response.once('close', cancelClosedResponse);
|
|
91
|
+
try {
|
|
92
|
+
const payload = await readJsonBody(request);
|
|
93
|
+
const result = await dispatch(DELIVERY_ENDPOINTS.send, payload, abort.signal);
|
|
94
|
+
if (result.ok) {
|
|
95
|
+
json(response, 200, result.value);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
json(response, DELIVERY_ERROR_STATUS[result.error.code] ?? 500, {
|
|
99
|
+
error: result.error,
|
|
100
|
+
});
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error instanceof HttpRequestError) {
|
|
103
|
+
json(response, error.status, {
|
|
104
|
+
error: { code: error.code, message: error.code, details: {} },
|
|
105
|
+
});
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
json(response, 500, {
|
|
109
|
+
error: { code: 'delivery-failed', message: 'delivery-failed', details: {} },
|
|
110
|
+
});
|
|
111
|
+
} finally {
|
|
112
|
+
request.off('aborted', cancel);
|
|
113
|
+
response.off('close', cancelClosedResponse);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function installDeliveryHttp(ctx, service) {
|
|
119
|
+
if (!ctx?.webServer || typeof ctx.webServer.register !== 'function'
|
|
120
|
+
|| typeof ctx.effect !== 'function') {
|
|
121
|
+
throw new TypeError('DSH Host WebServer is required');
|
|
122
|
+
}
|
|
123
|
+
const route = {
|
|
124
|
+
kind: 'exact',
|
|
125
|
+
path: DELIVERY_HTTP_PATH,
|
|
126
|
+
handler: createDeliveryHttpHandler(service),
|
|
127
|
+
};
|
|
128
|
+
return ctx.effect(
|
|
129
|
+
() => ctx.webServer.register(route),
|
|
130
|
+
`dsh-im: ${DELIVERY_HTTP_PATH}`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { resolveRpcAuthority } from './rpc-authority.mjs';
|
|
2
|
+
|
|
3
|
+
export const DELIVERY_RPC_CHANNEL = '/dsh-im-delivery';
|
|
4
|
+
export const DELIVERY_TEST_MESSAGE = 'DSH-IM 主动投递测试成功。';
|
|
5
|
+
export const DELIVERY_ENDPOINTS = Object.freeze({
|
|
6
|
+
send: 'message.send',
|
|
7
|
+
listTargets: 'target.list',
|
|
8
|
+
listSuggestions: 'target.suggestion.list',
|
|
9
|
+
createTarget: 'target.create',
|
|
10
|
+
updateTarget: 'target.update',
|
|
11
|
+
deleteTarget: 'target.delete',
|
|
12
|
+
testTarget: 'target.test',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const ENDPOINTS = new Set(Object.values(DELIVERY_ENDPOINTS));
|
|
16
|
+
const PUBLIC_ERRORS = new Set([
|
|
17
|
+
'bad-request',
|
|
18
|
+
'unknown-bot',
|
|
19
|
+
'unknown-target',
|
|
20
|
+
'target-conflict',
|
|
21
|
+
'invalid-target',
|
|
22
|
+
'bot-not-connected',
|
|
23
|
+
'target-rejected',
|
|
24
|
+
'delivery-failed',
|
|
25
|
+
'cancelled',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
function isRecord(value) {
|
|
29
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function exactKeys(value, keys) {
|
|
33
|
+
return isRecord(value)
|
|
34
|
+
&& Object.keys(value).length === keys.length
|
|
35
|
+
&& Object.keys(value).every((key) => keys.includes(key));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function validBotId(value) {
|
|
39
|
+
return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validTargetId(value) {
|
|
43
|
+
return typeof value === 'string' && /^[A-Za-z0-9._:@-]{1,128}$/.test(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validTarget(value, { targetId }) {
|
|
47
|
+
const keys = targetId ? ['targetId', 'name', 'kind', 'route'] : ['name', 'kind', 'route'];
|
|
48
|
+
if (!isRecord(value) || Object.keys(value).some((key) => !keys.includes(key))) return false;
|
|
49
|
+
if (targetId && !validTargetId(value.targetId)) return false;
|
|
50
|
+
if (value.name !== undefined
|
|
51
|
+
&& (typeof value.name !== 'string' || !value.name.trim() || value.name.trim().length > 80)) return false;
|
|
52
|
+
return typeof value.kind === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(value.kind)
|
|
53
|
+
&& isRecord(value.route);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function validDraftTarget(value) {
|
|
57
|
+
return exactKeys(value, ['kind', 'route'])
|
|
58
|
+
&& typeof value.kind === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(value.kind)
|
|
59
|
+
&& isRecord(value.route);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function validPayload(endpoint, payload) {
|
|
63
|
+
if (!ENDPOINTS.has(endpoint) || !isRecord(payload)) return false;
|
|
64
|
+
if (endpoint === DELIVERY_ENDPOINTS.send) {
|
|
65
|
+
return exactKeys(payload, ['botId', 'targetId', 'text'])
|
|
66
|
+
&& validBotId(payload.botId) && validTargetId(payload.targetId)
|
|
67
|
+
&& typeof payload.text === 'string' && Boolean(payload.text.trim());
|
|
68
|
+
}
|
|
69
|
+
if (endpoint === DELIVERY_ENDPOINTS.listTargets
|
|
70
|
+
|| endpoint === DELIVERY_ENDPOINTS.listSuggestions) {
|
|
71
|
+
return exactKeys(payload, ['botId']) && validBotId(payload.botId);
|
|
72
|
+
}
|
|
73
|
+
if (endpoint === DELIVERY_ENDPOINTS.createTarget) {
|
|
74
|
+
return exactKeys(payload, ['botId', 'target'])
|
|
75
|
+
&& validBotId(payload.botId) && validTarget(payload.target, { targetId: true });
|
|
76
|
+
}
|
|
77
|
+
if (endpoint === DELIVERY_ENDPOINTS.updateTarget) {
|
|
78
|
+
return exactKeys(payload, ['botId', 'targetId', 'target'])
|
|
79
|
+
&& validBotId(payload.botId) && validTargetId(payload.targetId)
|
|
80
|
+
&& validTarget(payload.target, { targetId: false });
|
|
81
|
+
}
|
|
82
|
+
if (endpoint === DELIVERY_ENDPOINTS.testTarget) {
|
|
83
|
+
return (exactKeys(payload, ['botId', 'targetId'])
|
|
84
|
+
&& validBotId(payload.botId) && validTargetId(payload.targetId))
|
|
85
|
+
|| (exactKeys(payload, ['botId', 'target'])
|
|
86
|
+
&& validBotId(payload.botId) && validDraftTarget(payload.target));
|
|
87
|
+
}
|
|
88
|
+
return exactKeys(payload, ['botId', 'targetId'])
|
|
89
|
+
&& validBotId(payload.botId) && validTargetId(payload.targetId);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function publicError(error) {
|
|
93
|
+
const code = PUBLIC_ERRORS.has(error?.code) ? error.code : 'delivery-failed';
|
|
94
|
+
return { code, message: code, details: {} };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createDeliveryRpcHandler(service) {
|
|
98
|
+
for (const method of [
|
|
99
|
+
'send',
|
|
100
|
+
'listTargets',
|
|
101
|
+
'listSuggestions',
|
|
102
|
+
'createTarget',
|
|
103
|
+
'updateTarget',
|
|
104
|
+
'deleteTarget',
|
|
105
|
+
]) {
|
|
106
|
+
if (typeof service?.[method] !== 'function') {
|
|
107
|
+
throw new TypeError(`A complete delivery service is required (${method})`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return async (endpoint, payload, signal) => {
|
|
111
|
+
if (!validPayload(endpoint, payload)) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
error: { code: 'bad-request', message: 'Invalid delivery request.', details: {} },
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (signal?.aborted) {
|
|
118
|
+
return { ok: false, error: { code: 'cancelled', message: 'cancelled', details: {} } };
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
let value;
|
|
122
|
+
if (endpoint === DELIVERY_ENDPOINTS.send) {
|
|
123
|
+
value = await service.send(payload.botId, payload.targetId, payload.text, { signal });
|
|
124
|
+
} else if (endpoint === DELIVERY_ENDPOINTS.listTargets) {
|
|
125
|
+
value = await service.listTargets(payload.botId);
|
|
126
|
+
} else if (endpoint === DELIVERY_ENDPOINTS.listSuggestions) {
|
|
127
|
+
value = await service.listSuggestions(payload.botId);
|
|
128
|
+
} else if (endpoint === DELIVERY_ENDPOINTS.createTarget) {
|
|
129
|
+
value = await service.createTarget(payload.botId, payload.target);
|
|
130
|
+
} else if (endpoint === DELIVERY_ENDPOINTS.updateTarget) {
|
|
131
|
+
value = await service.updateTarget(payload.botId, payload.targetId, payload.target);
|
|
132
|
+
} else if (endpoint === DELIVERY_ENDPOINTS.deleteTarget) {
|
|
133
|
+
value = await service.deleteTarget(payload.botId, payload.targetId);
|
|
134
|
+
} else {
|
|
135
|
+
value = await service.send(
|
|
136
|
+
payload.botId,
|
|
137
|
+
Object.hasOwn(payload, 'target') ? payload.target : payload.targetId,
|
|
138
|
+
DELIVERY_TEST_MESSAGE,
|
|
139
|
+
{ signal },
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return { ok: true, value };
|
|
143
|
+
} catch (error) {
|
|
144
|
+
return { ok: false, error: publicError(error) };
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function installDeliveryRpc(ctx, service, { authority } = {}) {
|
|
150
|
+
if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
|
|
151
|
+
throw new TypeError('DSH Host Connection RPC is required');
|
|
152
|
+
}
|
|
153
|
+
return ctx.connection.rpc.handle(
|
|
154
|
+
DELIVERY_RPC_CHANNEL,
|
|
155
|
+
createDeliveryRpcHandler(service),
|
|
156
|
+
{ authority: resolveRpcAuthority(authority) },
|
|
157
|
+
);
|
|
158
|
+
}
|