@xmanrui/dsh-im 4.35.1 → 4.36.1

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 (65) hide show
  1. package/PROACTIVE_DELIVERY.en.md +50 -0
  2. package/PROACTIVE_DELIVERY.md +22 -0
  3. package/README.en.md +3 -1
  4. package/README.md +3 -1
  5. package/lib/client.js +30 -8
  6. package/lib/index.js +309 -309
  7. package/package.json +3 -3
  8. package/plugin-src/client/connection-error.js +2 -1
  9. package/plugin-src/host/channels/dingtalk/production.mjs +1 -1
  10. package/plugin-src/host/channels/feishu/production.mjs +4 -2
  11. package/plugin-src/host/channels/matrix/production.mjs +1 -1
  12. package/plugin-src/host/channels/qq/production.mjs +1 -1
  13. package/plugin-src/host/channels/shared/production.mjs +1 -1
  14. package/plugin-src/host/channels/slack/production.mjs +1 -1
  15. package/plugin-src/host/channels/wecom/production.mjs +1 -1
  16. package/plugin-src/host/channels/wecom-app/production.mjs +1 -1
  17. package/plugin-src/host/channels/weixin/production.mjs +1 -1
  18. package/plugin-src/host/channels/whatsapp/production.mjs +1 -1
  19. package/plugin-src/host/delivery-adapter.mjs +60 -12
  20. package/plugin-src/host/delivery-service.mjs +127 -1
  21. package/plugin-src/host/delivery-suggestions.mjs +2 -1
  22. package/plugin-src/host/harness-connection.mjs +3 -7
  23. package/plugin-src/host/index.mjs +5 -2
  24. package/plugin-src/host/modern-harness-api.mjs +139 -4
  25. package/src/channels/dingtalk/dingtalk-bridge.mjs +6 -0
  26. package/src/channels/dingtalk/dingtalk-controller.mjs +14 -0
  27. package/src/channels/dingtalk/dingtalk-runtime.mjs +10 -0
  28. package/src/channels/discord/discord-runtime.mjs +13 -0
  29. package/src/channels/feishu/bridge.mjs +30 -13
  30. package/src/channels/feishu/external-consumer.mjs +118 -0
  31. package/src/channels/feishu/feishu-runtime.mjs +72 -3
  32. package/src/channels/feishu/history-reader.mjs +92 -0
  33. package/src/channels/feishu/multi-bot-controller.mjs +130 -2
  34. package/src/channels/feishu/nearby-history.mjs +72 -0
  35. package/src/channels/feishu/plugin-config-store.mjs +1 -0
  36. package/src/channels/matrix/matrix-controller.mjs +9 -0
  37. package/src/channels/matrix/matrix-runtime.mjs +14 -0
  38. package/src/channels/qq/qq-bridge.mjs +6 -0
  39. package/src/channels/qq/qq-controller.mjs +14 -0
  40. package/src/channels/qq/qq-runtime.mjs +10 -0
  41. package/src/channels/shared/connection-error.mjs +1 -0
  42. package/src/channels/shared/diagnostic-details.mjs +7 -1
  43. package/src/channels/shared/harness-approval.mjs +84 -7
  44. package/src/channels/shared/harness-client.mjs +51 -40
  45. package/src/channels/shared/i18n-en/diagnostics.mjs +3 -0
  46. package/src/channels/shared/i18n-en/shared-c.mjs +3 -0
  47. package/src/channels/shared/message-failure.mjs +1 -1
  48. package/src/channels/shared/text-harness-bridge.mjs +6 -0
  49. package/src/channels/shared/token-bot-controller.mjs +16 -0
  50. package/src/channels/slack/manifest.mjs +1 -0
  51. package/src/channels/slack/slack-api.mjs +7 -0
  52. package/src/channels/slack/slack-controller.mjs +14 -0
  53. package/src/channels/slack/slack-runtime.mjs +12 -0
  54. package/src/channels/telegram/telegram-runtime.mjs +10 -0
  55. package/src/channels/wecom/wecom-bridge.mjs +6 -0
  56. package/src/channels/wecom/wecom-controller.mjs +14 -0
  57. package/src/channels/wecom/wecom-runtime.mjs +10 -0
  58. package/src/channels/wecom-app/wecom-app-bridge.mjs +6 -0
  59. package/src/channels/wecom-app/wecom-app-controller.mjs +14 -0
  60. package/src/channels/wecom-app/wecom-app-runtime.mjs +10 -0
  61. package/src/channels/weixin/weixin-bridge.mjs +6 -0
  62. package/src/channels/weixin/weixin-controller.mjs +14 -0
  63. package/src/channels/weixin/weixin-runtime.mjs +10 -0
  64. package/src/channels/whatsapp/whatsapp-controller.mjs +14 -0
  65. package/src/channels/whatsapp/whatsapp-runtime.mjs +10 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.35.1",
3
+ "version": "4.36.1",
4
4
  "icon": "./assets/logo-plugin-message-link-gradient.webp",
5
5
  "description": "把微信、飞书、企微、钉钉、QQ、Telegram、WhatsApp、Slack、Discord、Matrix接入DSH。 Connect Telegram, WhatsApp, Slack, Discord, Matrix, WeChat, Feishu, WeCom, DingTalk, and QQ to DSH.",
6
6
  "keywords": [
@@ -87,9 +87,9 @@
87
87
  "platform": "web"
88
88
  },
89
89
  "compatibility": {
90
- "dsh": "0.1.7-alpha.1",
90
+ "dsh": "0.2.0-rc.2",
91
91
  "dshReleases": {
92
- "0.1.7-alpha.1": "compatible"
92
+ "0.2.0-rc.2": "compatible"
93
93
  },
94
94
  "profiles": [
95
95
  "web"
@@ -30,7 +30,7 @@ export function formatConnectionDiagnostic(value) {
30
30
  return [
31
31
  localizeText(error.message), details.hint ? localizeText(details.hint) : null,
32
32
  localizeText('错误码') + ': ' + error.code,
33
- ...['channel', 'operation', 'stage', 'reason', 'reasons', 'host', 'durationMs', 'timeoutMs', 'nodeVersion', 'dshVersion', 'platform', 'dependencies', 'proxyConfigured', 'truncated', 'httpStatus', 'providerCode', 'resource', 'file', 'field', 'issue', 'referenceId', 'occurredAt', 'rollback', 'pluginVersion']
33
+ ...['channel', 'operation', 'stage', 'method', 'transport', 'reason', 'reasons', 'host', 'durationMs', 'timeoutMs', 'nodeVersion', 'dshVersion', 'platform', 'dependencies', 'proxyConfigured', 'truncated', 'httpStatus', 'providerCode', 'resource', 'file', 'field', 'issue', 'referenceId', 'occurredAt', 'rollback', 'pluginVersion']
34
34
  .filter(field => details[field] !== undefined)
35
35
  .map(field => `${field}: ${typeof details[field] === 'object' ? JSON.stringify(details[field]) : details[field]}`),
36
36
  ].filter(Boolean).join('\n');
@@ -51,6 +51,7 @@ export function ConnectionError({ error: value, warning = false, showMessage = t
51
51
  };
52
52
  const fields = [
53
53
  ['错误码', error.code], ['失败阶段', localizeText(STAGE_LABELS[details.stage] ?? details.stage ?? '')],
54
+ ['RPC 方法', details.method], ['调用方式', details.transport === 'host-api' ? localizeText('宿主 API(进程内)') : details.transport === 'http' ? 'HTTP' : undefined],
54
55
  ['底层原因', details.reason === 'unknown' ? localizeText('暂未识别') : details.reason], ['多个原因', details.reasons?.join(', ')], ['服务域名', details.host], ['请求耗时(毫秒)', details.durationMs], ['超时阈值(毫秒)', details.timeoutMs], ['Node 版本', details.nodeVersion], ['DSH 版本', details.dshVersion], ['依赖版本', details.dependencies ? Object.entries(details.dependencies).map(([name, version]) => `${name}: ${version}`).join(', ') : undefined], ['代理已配置', details.proxyConfigured], ['原因链已截断', details.truncated], ['运行平台', details.platform], ['HTTP 状态', details.httpStatus], ['服务返回码', details.providerCode],
55
56
  ['配置文件', details.file], ['配置字段', details.field],
56
57
  ['校验原因', details.issue ? localizeText(CONFIG_ISSUE_LABELS[details.issue]) : undefined],
@@ -45,7 +45,7 @@ function pluginPaths(config) {
45
45
 
46
46
  export async function createProductionController(ctx, config = {}, internals = {}) {
47
47
  if (!ctx?.credentials) throw new TypeError('dsh-dingtalk requires ctx.credentials');
48
- const connection = harnessConnection(ctx, config);
48
+ const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
49
49
 
50
50
  const ConfigStore = internals.ConfigStore ?? DingtalkConfigStore;
51
51
  const DeviceAuth = internals.DeviceAuth ?? DingtalkDeviceAuth;
@@ -101,7 +101,7 @@ function pluginPaths(config) {
101
101
  */
102
102
  export async function createProductionController(ctx, config = {}, internals = {}) {
103
103
  if (!ctx?.credentials) throw new TypeError('dsh-feishu requires ctx.credentials');
104
- const connection = harnessConnection(ctx, config);
104
+ const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
105
105
 
106
106
  const lark = internals.lark ?? Lark;
107
107
  const Controller = internals.Controller ?? MultiBotDshFeishuController;
@@ -226,7 +226,7 @@ export async function createProductionController(ctx, config = {}, internals = {
226
226
  verifyApp,
227
227
  credentials: ctx.credentials,
228
228
  configStore: observedConfigStore,
229
- createRuntime: async ({ botId, config: botConfig, appSecret, repair }) => {
229
+ createRuntime: async ({ botId, config: botConfig, appSecret, repair, acceptExternal }) => {
230
230
  const state = await stateFor(botConfig);
231
231
  const id = botId ?? botConfig.id ?? botConfig.appId;
232
232
  await workspaces.ensure(id, {
@@ -248,6 +248,8 @@ export async function createProductionController(ctx, config = {}, internals = {
248
248
  }
249
249
  return new Runtime({
250
250
  lark,
251
+ consumerMode: botConfig.consumerMode,
252
+ acceptExternal,
251
253
  botId: id,
252
254
  repair,
253
255
  appId: botConfig.appId,
@@ -34,7 +34,7 @@ import {
34
34
 
35
35
  export async function createProductionController(ctx, config = {}, internals = {}) {
36
36
  if (!ctx?.credentials) throw new TypeError('dsh-im matrix requires ctx.credentials');
37
- const connection = harnessConnection(ctx, config);
37
+ const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
38
38
 
39
39
  const ResolvedConfigStore = internals.ConfigStore ?? MatrixConfigStore;
40
40
  const ResolvedStateStore = internals.StateStore ?? ConversationStateStore;
@@ -45,7 +45,7 @@ function pluginPaths(config) {
45
45
 
46
46
  export async function createProductionController(ctx, config = {}, internals = {}) {
47
47
  if (!ctx?.credentials) throw new TypeError('dsh-im QQ requires ctx.credentials');
48
- const connection = harnessConnection(ctx, config);
48
+ const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
49
49
 
50
50
  const ConfigStore = internals.ConfigStore ?? QqConfigStore;
51
51
  const StateStore = internals.StateStore ?? QqStateStore;
@@ -44,7 +44,7 @@ export async function createTokenProductionController(ctx, config, internals, de
44
44
  channel, ConfigStore, StateStore, HarnessClient, Controller, Runtime, runtimeOptions,
45
45
  } = definitions;
46
46
  if (!ctx?.credentials) throw new TypeError(`dsh-im ${channel} requires ctx.credentials`);
47
- const connection = harnessConnection(ctx, config);
47
+ const connection = harnessConnection(ctx, config, { competitiveApprovals: ['telegram', 'discord'].includes(channel) });
48
48
 
49
49
  const ResolvedConfigStore = internals.ConfigStore ?? ConfigStore;
50
50
  const ResolvedStateStore = internals.StateStore ?? StateStore;
@@ -33,7 +33,7 @@ import {
33
33
 
34
34
  export async function createProductionController(ctx, config = {}, internals = {}) {
35
35
  if (!ctx?.credentials) throw new TypeError('dsh-im slack requires ctx.credentials');
36
- const connection = harnessConnection(ctx, config);
36
+ const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
37
37
 
38
38
  const ResolvedConfigStore = internals.ConfigStore ?? SlackConfigStore;
39
39
  const ResolvedStateStore = internals.StateStore ?? SlackStateStore;
@@ -44,7 +44,7 @@ function pluginPaths(config) {
44
44
 
45
45
  export async function createProductionController(ctx, config = {}, internals = {}) {
46
46
  if (!ctx?.credentials) throw new TypeError('dsh-im Enterprise WeChat requires ctx.credentials');
47
- const connection = harnessConnection(ctx, config);
47
+ const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
48
48
 
49
49
  const ConfigStore = internals.ConfigStore ?? WecomConfigStore;
50
50
  const StateStore = internals.StateStore ?? WecomStateStore;
@@ -49,7 +49,7 @@ function normalizeOrigin(value) {
49
49
 
50
50
  export async function createProductionController(ctx, config = {}, internals = {}) {
51
51
  if (!ctx?.credentials) throw new TypeError('dsh-im Enterprise WeChat app requires ctx.credentials');
52
- const connection = harnessConnection(ctx, config);
52
+ const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
53
53
 
54
54
  const ConfigStore = internals.ConfigStore ?? WecomAppConfigStore;
55
55
  const StateStore = internals.StateStore ?? WecomAppStateStore;
@@ -77,7 +77,7 @@ function diagnosticWorkspaces(store) {
77
77
 
78
78
  export async function createProductionController(ctx, config = {}, internals = {}) {
79
79
  if (!ctx?.credentials) throw new TypeError('dsh-weixin requires ctx.credentials');
80
- const connection = harnessConnection(ctx, config);
80
+ const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
81
81
 
82
82
  const ConfigStore = internals.ConfigStore ?? WeixinConfigStore;
83
83
  const StateStore = internals.StateStore ?? WeixinStateStore;
@@ -54,7 +54,7 @@ function pluginPaths(config) {
54
54
  }
55
55
 
56
56
  export async function createProductionController(ctx, config = {}, internals = {}) {
57
- const connection = harnessConnection(ctx, config);
57
+ const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
58
58
  const logger = typeof ctx.logger === 'function'
59
59
  ? ctx.logger('dsh-im:whatsapp') : (ctx.logger ?? console);
60
60
  const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
@@ -231,8 +231,35 @@ export function createDeliveryAdapter({ channel, workspaces, coreController, sta
231
231
  if (typeof stateFor !== 'function') {
232
232
  throw new TypeError('delivery adapter requires a bot state getter');
233
233
  }
234
+ const syncTarget = async (botId, targetId, sessionId) => {
235
+ const configured = sessionSyncConfigs(workspaces, botId).find((entry) => entry.targetId === targetId);
236
+ const target = workspaces.deliveryTargetFor(botId, targetId);
237
+ if (!configured || !target || !privateConversationKeyMatchesTarget(channel, configured.conversationKey, target)) {
238
+ throw sessionSyncUnavailable('Session sync target is no longer enabled');
239
+ }
240
+ const sessions = stateSessions(await stateFor(botId));
241
+ if (sessions?.[configured.conversationKey] !== sessionId) {
242
+ throw sessionSyncUnavailable('Session sync target is no longer bound to this Session');
243
+ }
244
+ return { target, conversationKey: configured.conversationKey };
245
+ };
234
246
  return Object.freeze({
235
247
  channel,
248
+ consumeInbound: (botId, options) => {
249
+ if (typeof coreController.consumeInbound !== 'function')
250
+ throw Object.assign(new Error('capability-unavailable'), { code: 'capability-unavailable' });
251
+ return coreController.consumeInbound(botId, options);
252
+ },
253
+ historyChecked: (botId, route, query, options) => {
254
+ if (typeof coreController.historyChecked !== 'function')
255
+ throw Object.assign(new Error('capability-unavailable'), { code: 'capability-unavailable' });
256
+ return coreController.historyChecked(botId, route, query, options);
257
+ },
258
+ replyChecked: (botId, route, text, options) => {
259
+ if (typeof coreController.replyChecked !== 'function')
260
+ throw Object.assign(new Error('capability-unavailable'), { code: 'capability-unavailable' });
261
+ return coreController.replyChecked(botId, route, text, options);
262
+ },
236
263
  describeAccount: (botId) => {
237
264
  if (typeof coreController.describeDeliveryAccount !== 'function') {
238
265
  const error = new Error('Verified account capability unavailable');
@@ -339,20 +366,41 @@ export function createDeliveryAdapter({ channel, workspaces, coreController, sta
339
366
  }
340
367
  return matches;
341
368
  },
369
+ async describeSessionSyncApprovalTarget(botId, targetId, sessionId) {
370
+ const { conversationKey } = await syncTarget(botId, targetId, sessionId);
371
+ return { conversationKey };
372
+ },
373
+ async presentSessionSyncApproval(botId, targetId, sessionId, interaction, options = {}) {
374
+ const original = await syncTarget(botId, targetId, sessionId);
375
+ if (typeof coreController.presentSessionSyncApproval !== 'function') {
376
+ throw sessionSyncUnavailable('This channel cannot present synced approvals');
377
+ }
378
+ const validate = async () => {
379
+ const current = await syncTarget(botId, targetId, sessionId);
380
+ if (current.conversationKey !== original.conversationKey || options.signal?.aborted) {
381
+ throw sessionSyncUnavailable('The approval target changed or the approval ended');
382
+ }
383
+ };
384
+ const guarded = {
385
+ ...interaction,
386
+ respond: async (result, responseOptions) => {
387
+ try { await validate(); }
388
+ catch (error) {
389
+ await interaction.withdraw?.();
390
+ throw Object.assign(new Error('Approval route is no longer available', { cause: error }), {
391
+ code: 'interaction-not-pending',
392
+ });
393
+ }
394
+ return interaction.respond(result, responseOptions);
395
+ },
396
+ };
397
+ return coreController.presentSessionSyncApproval(botId, original.target, guarded, {
398
+ ...options, key: original.conversationKey, validate,
399
+ });
400
+ },
342
401
  async sendSessionSyncText(botId, targetId, sessionId, text, options = {}) {
343
402
  if (typeof text !== 'string' || !text.trim()) throw new TypeError('text is required');
344
- const configured = sessionSyncConfigs(workspaces, botId)
345
- .find((target) => target.targetId === targetId);
346
- const target = workspaces.deliveryTargetFor(botId, targetId);
347
- if (!configured || !target || !privateConversationKeyMatchesTarget(
348
- channel,
349
- configured.conversationKey,
350
- target,
351
- )) throw sessionSyncUnavailable('Session sync target is no longer enabled');
352
- const sessions = stateSessions(await stateFor(botId));
353
- if (sessions?.[configured.conversationKey] !== sessionId) {
354
- throw sessionSyncUnavailable('Session sync target is no longer bound to this Session');
355
- }
403
+ const { target } = await syncTarget(botId, targetId, sessionId);
356
404
  if (typeof coreController.sendProactiveText !== 'function') {
357
405
  throw new TypeError('delivery controller cannot send proactive text');
358
406
  }
@@ -1,5 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { normalizeDeliveryTarget } from './delivery-adapter.mjs';
3
+ import { COMPETITIVE_APPROVAL_CHANNELS } from '../../src/channels/shared/harness-approval.mjs';
4
+ import { t } from '../../src/channels/shared/i18n.mjs';
3
5
 
4
6
  const BOT_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
5
7
  const TARGET_ID_PATTERN = /^[A-Za-z0-9._:@-]{1,128}$/;
@@ -33,6 +35,17 @@ const DELIVERY_ERROR_CODES = new Set([
33
35
  'send-result-unknown',
34
36
  'session-sync-unavailable',
35
37
  'cancelled',
38
+ 'provider-unavailable',
39
+ 'consumer-unavailable',
40
+ 'consumer-conflict',
41
+ 'ingress-not-accepted',
42
+ 'invalid-inbound',
43
+ 'stale-route',
44
+ 'reply-result-unknown',
45
+ 'history-permission-denied',
46
+ 'history-unavailable',
47
+ 'thread-unavailable',
48
+ 'untrusted-source',
36
49
  ]);
37
50
 
38
51
  const SESSION_SYNC_METHODS = Object.freeze([
@@ -132,11 +145,13 @@ export class DeliveryService {
132
145
 
133
146
  registerAdapter(value) {
134
147
  const adapter = validateAdapter(value);
135
- const registration = Object.freeze({ adapter });
148
+ this.#adapters.get(adapter.channel)?.controller.abort(deliveryError('provider-unavailable'));
149
+ const registration = Object.freeze({ adapter, controller: new AbortController() });
136
150
  this.#adapters.set(adapter.channel, registration);
137
151
  return () => {
138
152
  if (this.#adapters.get(adapter.channel) !== registration) return false;
139
153
  this.#adapters.delete(adapter.channel);
154
+ registration.controller.abort(deliveryError('provider-unavailable'));
140
155
  return true;
141
156
  };
142
157
  }
@@ -273,6 +288,59 @@ export class DeliveryService {
273
288
  return targets;
274
289
  }
275
290
 
291
+ /** Offer one existing bound private chat the same approval the Host is awaiting. */
292
+ async presentSessionSyncApproval(sessionId, interaction, { signal, completion } = {}) {
293
+ const targets = (await this.listSessionSyncTargets(sessionId))
294
+ .filter((target) => COMPETITIVE_APPROVAL_CHANNELS.includes(target.channel));
295
+ if (signal?.aborted || targets.length === 0) return false;
296
+ let outcome = null;
297
+ let notified = false;
298
+ const notify = async (finished = false) => {
299
+ if (!finished && (signal?.aborted || outcome !== null)) return;
300
+ const text = finished
301
+ ? t('未找到可处理端,本次操作未获批准。')
302
+ : t('有操作在等待审批:{tool},请到 Web 处理。', { tool: interaction.payload.toolName });
303
+ const results = await Promise.allSettled(targets.map((target) => this.sendSessionSyncText(
304
+ target.botId, target.targetId, sessionId, text, finished ? {} : { signal },
305
+ )));
306
+ notified ||= results.some((result) => result.status === 'fulfilled');
307
+ };
308
+ Promise.resolve(completion).then(async (value) => {
309
+ outcome = value;
310
+ if (value === 'unavailable' && notified) await notify(true);
311
+ }).catch((error) => console.warn('[dsh-im] approval status notification failed:', error?.code ?? error?.name));
312
+ const candidates = new Map();
313
+ let unresolvedTarget = false;
314
+ for (const target of targets) {
315
+ try {
316
+ const adapter = await this.#adapterFor(target.botId);
317
+ if (typeof adapter.describeSessionSyncApprovalTarget !== 'function') {
318
+ unresolvedTarget = true;
319
+ continue;
320
+ }
321
+ const info = await adapter.describeSessionSyncApprovalTarget(target.botId, target.targetId, sessionId);
322
+ candidates.set(JSON.stringify([target.channel, target.botId, info.conversationKey]), { adapter, target });
323
+ } catch (error) {
324
+ unresolvedTarget = true;
325
+ console.warn('[dsh-im] approval target unavailable:', error?.code ?? error?.name);
326
+ }
327
+ }
328
+ if (signal?.aborted) return false;
329
+ if (!unresolvedTarget && candidates.size === 1) {
330
+ const { adapter, target } = candidates.values().next().value;
331
+ try {
332
+ if (await adapter.presentSessionSyncApproval(target.botId, target.targetId, sessionId, interaction, {
333
+ signal, completion,
334
+ })) return true;
335
+ } catch (error) {
336
+ if (!signal?.aborted) console.warn('[dsh-im] synced approval unavailable:', error?.code ?? error?.name);
337
+ }
338
+ }
339
+ if (outcome === 'unavailable') await notify(true);
340
+ else await notify();
341
+ return false;
342
+ }
343
+
276
344
  async sendSessionSyncText(botId, targetId, sessionId, text, { signal } = {}) {
277
345
  const id = botIdOf(botId);
278
346
  const targetKey = targetIdOf(targetId);
@@ -315,6 +383,64 @@ export class DeliveryService {
315
383
  } catch (error) { throw publicOperationError(error); }
316
384
  }
317
385
 
386
+ async consumeInbound(botId, options = {}) {
387
+ const id = botIdOf(botId);
388
+ cancellation(options.signal);
389
+ if (!/^[a-f0-9]{64}$/.test(options.expectedFingerprint ?? '') || typeof options.onEvent !== 'function')
390
+ throw deliveryError('bad-request');
391
+ const registration = await this.#checkedRegistrationFor(id);
392
+ if (typeof registration.adapter.consumeInbound !== 'function' || typeof options.onEvent !== 'function')
393
+ throw deliveryError('capability-unavailable');
394
+ this.#assertRegistered(registration);
395
+ const dispose = await registration.adapter.consumeInbound(id, {
396
+ ...options,
397
+ signal: options.signal ? AbortSignal.any([options.signal, registration.controller.signal]) : registration.controller.signal,
398
+ onEvent: async (evidence, context) => {
399
+ this.#assertRegistered(registration);
400
+ const result = await options.onEvent(evidence, context);
401
+ this.#assertRegistered(registration);
402
+ return result;
403
+ },
404
+ });
405
+ try { this.#assertRegistered(registration); } catch (error) { dispose(); throw error; }
406
+ return dispose;
407
+ }
408
+
409
+ async historyChecked(botId, route, query, options = {}) {
410
+ const id = botIdOf(botId);
411
+ cancellation(options.signal);
412
+ if (!/^[a-f0-9]{64}$/.test(options.expectedFingerprint ?? '')) throw deliveryError('bad-request');
413
+ const registration = await this.#checkedRegistrationFor(id);
414
+ if (typeof registration.adapter.historyChecked !== 'function') throw deliveryError('capability-unavailable');
415
+ this.#assertRegistered(registration);
416
+ const signal = options.signal ? AbortSignal.any([options.signal, registration.controller.signal]) : registration.controller.signal;
417
+ try {
418
+ const result = await registration.adapter.historyChecked(id, structuredClone(route), structuredClone(query), { ...options, signal });
419
+ this.#assertRegistered(registration);
420
+ cancellation(options.signal);
421
+ return result;
422
+ } catch (error) {
423
+ this.#assertRegistered(registration);
424
+ if (signal.aborted || error?.name === 'AbortError') throw deliveryError('cancelled');
425
+ throw publicOperationError(error, 'history-unavailable');
426
+ }
427
+ }
428
+
429
+ async replyChecked(botId, route, text, options = {}) {
430
+ const id = botIdOf(botId);
431
+ cancellation(options.signal);
432
+ if (!/^[a-f0-9]{64}$/.test(options.expectedFingerprint ?? '')
433
+ || typeof text !== 'string' || !text.trim() || text.length > 4000)
434
+ throw deliveryError('bad-request');
435
+ const registration = await this.#checkedRegistrationFor(id);
436
+ if (typeof registration.adapter.replyChecked !== 'function') throw deliveryError('capability-unavailable');
437
+ this.#assertRegistered(registration);
438
+ try { return await registration.adapter.replyChecked(id, structuredClone(route), text, {
439
+ ...options, signal: options.signal ? AbortSignal.any([options.signal, registration.controller.signal]) : registration.controller.signal,
440
+ }); }
441
+ catch (error) { throw publicOperationError(error); }
442
+ }
443
+
318
444
  async sendChecked(botId, targetId, text, { expectedFingerprint, expectedTargetDigest, signal, format = 'plain', receipt = false } = {}) {
319
445
  const id = botIdOf(botId);
320
446
  const key = targetIdOf(targetId);
@@ -124,7 +124,7 @@ export function deliverySuggestionFromConversationKey(channel, key) {
124
124
  case 'whatsapp':
125
125
  return whatsappSuggestion(key);
126
126
  case 'matrix':
127
- return matrixSuggestion(key);
127
+ return matrixSuggestion(key.replace(/^(direct|group):/, ''));
128
128
  default:
129
129
  return null;
130
130
  }
@@ -132,6 +132,7 @@ export function deliverySuggestionFromConversationKey(channel, key) {
132
132
 
133
133
  /** Convert only a persisted private-chat key into its stable delivery route. */
134
134
  export function privateDeliverySuggestionFromConversationKey(channel, key) {
135
+ if (channel === 'matrix' && typeof key === 'string') key = key.replace(/^direct:dm:/, 'dm:');
135
136
  const separator = typeof key === 'string' ? key.indexOf(':') : -1;
136
137
  const prefix = separator > 0 ? key.slice(0, separator) : '';
137
138
  const privatePrefix = {
@@ -1,18 +1,14 @@
1
1
  import { modernHarnessApi } from './modern-harness-api.mjs';
2
2
 
3
- function optionalService(ctx, name) {
4
- if (ctx && Object.hasOwn(ctx, name)) return ctx[name];
5
- return typeof ctx?.get === 'function' ? ctx.get(name) : undefined;
6
- }
7
-
8
3
  /** Connect to this Host directly unless an external Harness URL was configured. */
9
- export function harnessConnection(ctx, config = {}) {
4
+ export function harnessConnection(ctx, config = {}, { competitiveApprovals = false } = {}) {
10
5
  if (config.harnessBaseUrl !== undefined) {
11
6
  return { baseUrl: new URL(config.harnessBaseUrl) };
12
7
  }
13
- const apiProxy = optionalService(ctx, 'apiProxy') ?? modernHarnessApi(ctx);
8
+ const apiProxy = modernHarnessApi(ctx, { deliveryService: config.deliveryService });
14
9
  return {
15
10
  apiProxy,
11
+ competitiveApprovals,
16
12
  // Cordis child contexts share one root; different Hosts must not share
17
13
  // ownership of pending questions and approvals.
18
14
  interactionScope: ctx.root ?? ctx,
@@ -93,6 +93,10 @@ export function createImHostPlugin(internals = {}) {
93
93
  ctx.provide('dshIm', Object.freeze({
94
94
  contractVersion: 1,
95
95
  receiptVersion: 1,
96
+ inboundVersion: 1,
97
+ consumeInbound: (botId, options) => deliveryService.consumeInbound(botId, options),
98
+ historyChecked: (botId, route, query, options) => deliveryService.historyChecked(botId, route, query, options),
99
+ replyChecked: (botId, route, text, options) => deliveryService.replyChecked(botId, route, text, options),
96
100
  describeBot: (botId) => deliveryService.describeBot(botId),
97
101
  sendChecked: (botId, targetId, text, options) => deliveryService.sendChecked(botId, targetId, text, options),
98
102
  send: (botId, targetId, text, options) => (
@@ -106,9 +110,8 @@ export function createImHostPlugin(internals = {}) {
106
110
  await activateChannels(readyCtx, config, deliveryService);
107
111
  };
108
112
  if (typeof ctx?.inject === 'function') {
109
- const modern = typeof ctx?.typertGateway?.stream === 'function';
110
113
  await ctx.inject(
111
- modern ? ['sessionController', 'workspaceController'] : ['apiProxy'],
114
+ ['sessionController', 'workspaceController'],
112
115
  activate,
113
116
  );
114
117
  ctx.inject(['webServer'], (httpCtx) => {