@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.
Files changed (74) hide show
  1. package/PROACTIVE_DELIVERY.en.md +321 -0
  2. package/PROACTIVE_DELIVERY.md +321 -0
  3. package/README.en.md +11 -5
  4. package/README.md +11 -5
  5. package/lib/client.js +1200 -110
  6. package/lib/index.js +229 -229
  7. package/package.json +4 -1
  8. package/plugin-src/client/channel-card-meta.js +44 -0
  9. package/plugin-src/client/channels/dingtalk/index.js +16 -8
  10. package/plugin-src/client/channels/feishu/index.js +17 -9
  11. package/plugin-src/client/channels/qq/index.js +16 -8
  12. package/plugin-src/client/channels/shared/token-channel.js +16 -8
  13. package/plugin-src/client/channels/wecom/index.js +16 -8
  14. package/plugin-src/client/channels/weixin/index.js +16 -8
  15. package/plugin-src/client/channels/whatsapp/index.js +16 -8
  16. package/plugin-src/client/delivery-settings.js +749 -0
  17. package/plugin-src/client/i18n.js +82 -0
  18. package/plugin-src/client/index.js +59 -22
  19. package/plugin-src/client/styles.js +72 -0
  20. package/plugin-src/host/channels/dingtalk/index.mjs +3 -0
  21. package/plugin-src/host/channels/dingtalk/production.mjs +4 -0
  22. package/plugin-src/host/channels/discord/index.mjs +6 -1
  23. package/plugin-src/host/channels/feishu/index.mjs +3 -0
  24. package/plugin-src/host/channels/feishu/production.mjs +4 -0
  25. package/plugin-src/host/channels/qq/index.mjs +6 -1
  26. package/plugin-src/host/channels/qq/production.mjs +2 -0
  27. package/plugin-src/host/channels/shared/production.mjs +7 -0
  28. package/plugin-src/host/channels/slack/index.mjs +6 -1
  29. package/plugin-src/host/channels/slack/production.mjs +4 -0
  30. package/plugin-src/host/channels/telegram/index.mjs +6 -1
  31. package/plugin-src/host/channels/wecom/index.mjs +6 -1
  32. package/plugin-src/host/channels/wecom/production.mjs +4 -0
  33. package/plugin-src/host/channels/weixin/index.mjs +3 -0
  34. package/plugin-src/host/channels/weixin/production.mjs +4 -0
  35. package/plugin-src/host/channels/whatsapp/index.mjs +6 -1
  36. package/plugin-src/host/channels/whatsapp/production.mjs +4 -0
  37. package/plugin-src/host/delivery-adapter.mjs +179 -0
  38. package/plugin-src/host/delivery-http.mjs +132 -0
  39. package/plugin-src/host/delivery-rpc.mjs +158 -0
  40. package/plugin-src/host/delivery-service.mjs +224 -0
  41. package/plugin-src/host/delivery-suggestions.mjs +135 -0
  42. package/plugin-src/host/index.mjs +32 -5
  43. package/scripts/verify-package.mjs +3 -0
  44. package/src/channels/dingtalk/dingtalk-api.mjs +33 -0
  45. package/src/channels/dingtalk/dingtalk-bridge.mjs +2 -2
  46. package/src/channels/dingtalk/dingtalk-controller.mjs +14 -0
  47. package/src/channels/dingtalk/dingtalk-runtime.mjs +39 -0
  48. package/src/channels/discord/discord-runtime.mjs +16 -0
  49. package/src/channels/feishu/bridge.mjs +4 -4
  50. package/src/channels/feishu/feishu-cards.mjs +4 -4
  51. package/src/channels/feishu/feishu-runtime.mjs +35 -0
  52. package/src/channels/feishu/multi-bot-controller.mjs +15 -0
  53. package/src/channels/qq/qq-bridge.mjs +2 -2
  54. package/src/channels/qq/qq-controller.mjs +14 -0
  55. package/src/channels/qq/qq-runtime.mjs +23 -0
  56. package/src/channels/shared/bot-workspace-store.mjs +192 -6
  57. package/src/channels/shared/i18n-en/feishu.mjs +6 -2
  58. package/src/channels/shared/i18n-en/shared-a.mjs +4 -0
  59. package/src/channels/shared/preset-command.mjs +2 -2
  60. package/src/channels/shared/text-harness-bridge.mjs +7 -2
  61. package/src/channels/shared/token-bot-controller.mjs +16 -0
  62. package/src/channels/shared/workspace-command.mjs +1 -1
  63. package/src/channels/slack/slack-controller.mjs +14 -0
  64. package/src/channels/slack/slack-runtime.mjs +24 -0
  65. package/src/channels/telegram/telegram-runtime.mjs +26 -0
  66. package/src/channels/wecom/state-store.mjs +4 -0
  67. package/src/channels/wecom/wecom-bridge.mjs +2 -2
  68. package/src/channels/wecom/wecom-controller.mjs +14 -0
  69. package/src/channels/wecom/wecom-runtime.mjs +21 -0
  70. package/src/channels/weixin/weixin-bridge.mjs +2 -2
  71. package/src/channels/weixin/weixin-controller.mjs +14 -0
  72. package/src/channels/weixin/weixin-runtime.mjs +24 -0
  73. package/src/channels/whatsapp/whatsapp-controller.mjs +14 -0
  74. 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 () => production.close(), 'dsh-im: close WhatsApp Web connections');
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
+ }