@xmanrui/dsh-im 4.17.0 → 4.18.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 (50) hide show
  1. package/README.en.md +20 -14
  2. package/README.md +19 -16
  3. package/lib/client.js +447 -257
  4. package/lib/index.js +285 -263
  5. package/package.json +10 -4
  6. package/plugin-src/client/access-policy-settings.js +5 -0
  7. package/plugin-src/client/channel-logos.js +10 -0
  8. package/plugin-src/client/channels/imessage/api.js +12 -0
  9. package/plugin-src/client/channels/imessage/index.js +45 -0
  10. package/plugin-src/client/channels/imessage/styles.js +19 -0
  11. package/plugin-src/client/channels/shared/token-api.js +1 -0
  12. package/plugin-src/client/channels/shared/token-channel.js +5 -1
  13. package/plugin-src/client/delivery-settings.js +5 -0
  14. package/plugin-src/client/i18n.js +23 -1
  15. package/plugin-src/client/index.js +35 -14
  16. package/plugin-src/client/session-channel-logos.js +2 -0
  17. package/plugin-src/client/styles.js +1 -0
  18. package/plugin-src/host/channels/dingtalk/rpc.mjs +2 -4
  19. package/plugin-src/host/channels/feishu/rpc.mjs +2 -4
  20. package/plugin-src/host/channels/imessage/index.mjs +21 -0
  21. package/plugin-src/host/channels/imessage/production.mjs +13 -0
  22. package/plugin-src/host/channels/imessage/rpc.mjs +70 -0
  23. package/plugin-src/host/channels/office/rpc.mjs +2 -1
  24. package/plugin-src/host/channels/qq/rpc.mjs +2 -4
  25. package/plugin-src/host/channels/shared/access-policy-production.mjs +1 -1
  26. package/plugin-src/host/channels/shared/rpc.mjs +2 -4
  27. package/plugin-src/host/channels/shared/startup.mjs +2 -4
  28. package/plugin-src/host/channels/slack/rpc.mjs +2 -4
  29. package/plugin-src/host/channels/telegram/rpc.mjs +2 -4
  30. package/plugin-src/host/channels/wecom/rpc.mjs +2 -4
  31. package/plugin-src/host/channels/wecom-app/rpc.mjs +2 -4
  32. package/plugin-src/host/channels/weixin/rpc.mjs +2 -4
  33. package/plugin-src/host/channels/whatsapp/rpc.mjs +2 -4
  34. package/plugin-src/host/delivery-adapter.mjs +4 -0
  35. package/plugin-src/host/delivery-rpc.mjs +2 -4
  36. package/plugin-src/host/inbound-ttl-rpc.mjs +2 -4
  37. package/plugin-src/host/index.mjs +4 -1
  38. package/plugin-src/host/modern-harness-api.mjs +1 -1
  39. package/plugin-src/host/update-rpc.mjs +2 -1
  40. package/plugin-src/management-rpc.mjs +77 -0
  41. package/src/channels/imessage/config-store.mjs +24 -0
  42. package/src/channels/imessage/controller.mjs +37 -0
  43. package/src/channels/imessage/harness-client.mjs +7 -0
  44. package/src/channels/imessage/imessage-api.mjs +194 -0
  45. package/src/channels/imessage/imessage-bridge.mjs +16 -0
  46. package/src/channels/imessage/runtime.mjs +98 -0
  47. package/src/channels/imessage/state-store.mjs +3 -0
  48. package/src/channels/shared/i18n-en/shared-a.mjs +2 -2
  49. package/src/channels/shared/message-failure.mjs +5 -5
  50. package/src/channels/shared/session-channel-labels.mjs +1 -0
@@ -1,3 +1,4 @@
1
+ import { registerManagementRpc } from '../../../management-rpc.mjs';
1
2
  import { resolveRpcAuthority } from '../../rpc-authority.mjs';
2
3
  import { publicChannelInitializing, publicChannelStartupError } from './startup-error.mjs';
3
4
 
@@ -5,12 +6,9 @@ import { publicChannelInitializing, publicChannelStartupError } from './startup-
5
6
  export async function installProductionChannel(ctx, config, {
6
7
  channel, rpcChannel, createProduction, createHandler,
7
8
  }) {
8
- if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
9
- throw new TypeError('DSH Host Connection RPC is required');
10
- }
11
9
  let startupError = publicChannelInitializing(channel);
12
10
  let handler = async () => ({ ok: false, error: startupError });
13
- const disposeRpc = ctx.connection.rpc.handle(rpcChannel, (endpoint, payload, signal) => {
11
+ const disposeRpc = registerManagementRpc(ctx, rpcChannel, (endpoint, payload, signal) => {
14
12
  if (signal?.aborted) {
15
13
  return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.', details: {} } };
16
14
  }
@@ -1,3 +1,4 @@
1
+ import { registerManagementRpc } from '../../../management-rpc.mjs';
1
2
  import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from '../shared/context-enhancement-rpc.mjs';
2
3
  import { SET_ACCESS_POLICY_ENDPOINT, validAccessPolicyPayload } from '../shared/access-policy-rpc.mjs';
3
4
  import { resolveRpcAuthority } from '../../rpc-authority.mjs';
@@ -200,10 +201,7 @@ export function createSlackRpcHandler(controller) {
200
201
  }
201
202
 
202
203
  export function installSlackRpc(ctx, controller, authority) {
203
- if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
204
- throw new TypeError('DSH Host Connection RPC is required');
205
- }
206
- return ctx.connection.rpc.handle(
204
+ return registerManagementRpc(ctx,
207
205
  SLACK_RPC_CHANNEL,
208
206
  createSlackRpcHandler(controller),
209
207
  { authority: resolveRpcAuthority(authority) },
@@ -1,3 +1,4 @@
1
+ import { registerManagementRpc } from '../../../management-rpc.mjs';
1
2
  import {
2
3
  TOKEN_BOT_ENDPOINTS,
3
4
  createTokenBotRpcHandler,
@@ -13,10 +14,7 @@ export function createTelegramRpcHandler(controller) {
13
14
  }
14
15
 
15
16
  export function installTelegramRpc(ctx, controller, authority) {
16
- if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
17
- throw new TypeError('DSH Host Connection RPC is required');
18
- }
19
- return ctx.connection.rpc.handle(
17
+ return registerManagementRpc(ctx,
20
18
  TELEGRAM_RPC_CHANNEL,
21
19
  createTelegramRpcHandler(controller),
22
20
  { authority: resolveRpcAuthority(authority) },
@@ -1,3 +1,4 @@
1
+ import { registerManagementRpc } from '../../../management-rpc.mjs';
1
2
  import QRCode from 'qrcode';
2
3
  import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from '../shared/context-enhancement-rpc.mjs';
3
4
  import { SET_ACCESS_POLICY_ENDPOINT, validAccessPolicyPayload } from '../shared/access-policy-rpc.mjs';
@@ -233,10 +234,7 @@ export function installWecomRpc(ctx, controller, options, authority) {
233
234
 
234
235
  /** Mount the existing Connection transport before the production controller is ready. */
235
236
  export function installWecomRpcHandler(ctx, handler, authority) {
236
- if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
237
- throw new TypeError('DSH Host Connection RPC is required');
238
- }
239
- return ctx.connection.rpc.handle(
237
+ return registerManagementRpc(ctx,
240
238
  WECOM_RPC_CHANNEL,
241
239
  handler,
242
240
  { authority: resolveRpcAuthority(authority) },
@@ -1,3 +1,4 @@
1
+ import { registerManagementRpc } from '../../../management-rpc.mjs';
1
2
  import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from '../shared/context-enhancement-rpc.mjs';
2
3
  import { SET_ACCESS_POLICY_ENDPOINT, validAccessPolicyPayload } from '../shared/access-policy-rpc.mjs';
3
4
  import { resolveRpcAuthority } from '../../rpc-authority.mjs';
@@ -214,10 +215,7 @@ export function createWecomAppRpcHandler(controller) {
214
215
  }
215
216
 
216
217
  export function installWecomAppRpc(ctx, controller, options, authority) {
217
- if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
218
- throw new TypeError('DSH Host Connection RPC is required');
219
- }
220
- return ctx.connection.rpc.handle(
218
+ return registerManagementRpc(ctx,
221
219
  WECOM_APP_RPC_CHANNEL,
222
220
  createWecomAppRpcHandler(controller, options),
223
221
  { authority: resolveRpcAuthority(authority) },
@@ -1,3 +1,4 @@
1
+ import { registerManagementRpc } from '../../../management-rpc.mjs';
1
2
  import QRCode from 'qrcode';
2
3
  import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from '../shared/context-enhancement-rpc.mjs';
3
4
  import { SET_ACCESS_POLICY_ENDPOINT, validAccessPolicyPayload } from '../shared/access-policy-rpc.mjs';
@@ -260,10 +261,7 @@ export function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl } = {}
260
261
  }
261
262
 
262
263
  export function installWeixinRpc(ctx, controller, options, authority) {
263
- if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
264
- throw new TypeError('DSH Host Connection RPC is required');
265
- }
266
- return ctx.connection.rpc.handle(
264
+ return registerManagementRpc(ctx,
267
265
  WEIXIN_RPC_CHANNEL,
268
266
  createWeixinRpcHandler(controller, options),
269
267
  { authority: resolveRpcAuthority(authority) },
@@ -1,3 +1,4 @@
1
+ import { registerManagementRpc } from '../../../management-rpc.mjs';
1
2
  import QRCode from 'qrcode';
2
3
 
3
4
  import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
@@ -218,10 +219,7 @@ export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } =
218
219
  }
219
220
 
220
221
  export function installWhatsappRpc(ctx, controller, options, authority) {
221
- if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
222
- throw new TypeError('DSH Host Connection RPC is required');
223
- }
224
- return ctx.connection.rpc.handle(
222
+ return registerManagementRpc(ctx,
225
223
  WHATSAPP_RPC_CHANNEL,
226
224
  createWhatsappRpcHandler(controller, options),
227
225
  { authority: resolveRpcAuthority(authority) },
@@ -15,6 +15,7 @@ const CHANNELS = new Set([
15
15
  'telegram',
16
16
  'discord',
17
17
  'whatsapp',
18
+ 'imessage',
18
19
  ]);
19
20
 
20
21
  export function supportsDeliveryChannel(channel) {
@@ -122,6 +123,9 @@ function normalizeRoute(channel, kind, route) {
122
123
  }
123
124
  return normalized;
124
125
  }
126
+ case 'imessage':
127
+ oneOf(kind, ['user']);
128
+ return routeWithStrings(route, ['chatGuid']);
125
129
  default:
126
130
  throw new TypeError(`Unsupported delivery channel: ${channel}`);
127
131
  }
@@ -1,3 +1,4 @@
1
+ import { registerManagementRpc } from '../management-rpc.mjs';
1
2
  import { resolveRpcAuthority } from './rpc-authority.mjs';
2
3
 
3
4
  export const DELIVERY_RPC_CHANNEL = '/dsh-im-delivery';
@@ -159,10 +160,7 @@ export function createDeliveryRpcHandler(service) {
159
160
  }
160
161
 
161
162
  export function installDeliveryRpc(ctx, service, { authority } = {}) {
162
- if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
163
- throw new TypeError('DSH Host Connection RPC is required');
164
- }
165
- return ctx.connection.rpc.handle(
163
+ return registerManagementRpc(ctx,
166
164
  DELIVERY_RPC_CHANNEL,
167
165
  createDeliveryRpcHandler(service),
168
166
  { authority: resolveRpcAuthority(authority) },
@@ -1,3 +1,4 @@
1
+ import { registerManagementRpc } from '../management-rpc.mjs';
1
2
  import { normalizeInboundTtlHours } from '../../src/channels/shared/inbound-ttl.mjs';
2
3
  import { getInboundTtlRuntime } from './inbound-ttl-runtime.mjs';
3
4
 
@@ -69,13 +70,10 @@ export function createInboundTtlRpcHandler({ store, service, logger = null } = {
69
70
  }
70
71
 
71
72
  export function installInboundTtlRpc(ctx, options = {}) {
72
- if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
73
- throw new TypeError('DSH Host Connection RPC is required');
74
- }
75
73
  const runtime = options.runtime ?? getInboundTtlRuntime(ctx, options.config);
76
74
  const logger = typeof ctx?.logger === 'function'
77
75
  ? ctx.logger('dsh-im:inbound-ttl') : (ctx?.logger ?? null);
78
- return ctx.connection.rpc.handle(
76
+ return registerManagementRpc(ctx,
79
77
  INBOUND_TTL_RPC_CHANNEL,
80
78
  createInboundTtlRpcHandler({ ...runtime, logger }),
81
79
  { authority: 'loopback' },
@@ -9,6 +9,7 @@ import { apply as applyWecom } from './channels/wecom/index.mjs';
9
9
  import { apply as applyWecomApp } from './channels/wecom-app/index.mjs';
10
10
  import { apply as applyWeixin } from './channels/weixin/index.mjs';
11
11
  import { apply as applyWhatsapp } from './channels/whatsapp/index.mjs';
12
+ import { apply as applyIMessage } from './channels/imessage/index.mjs';
12
13
  import { installOutboundArtifactTool } from '../../src/channels/shared/semantic/artifact.mjs';
13
14
  import { setImHostLanguage } from '../../src/channels/shared/i18n.mjs';
14
15
  import { installDeliveryRpc } from './delivery-rpc.mjs';
@@ -53,6 +54,7 @@ export function createImHostPlugin(internals = {}) {
53
54
  const startDiscord = internals.applyDiscord ?? applyDiscord;
54
55
  const startOffice = internals.applyOffice ?? applyOffice;
55
56
  const startWhatsapp = internals.applyWhatsapp ?? applyWhatsapp;
57
+ const startIMessage = internals.applyIMessage ?? applyIMessage;
56
58
  const channels = [
57
59
  ['feishu', startFeishu],
58
60
  ['weixin', startWeixin],
@@ -64,6 +66,7 @@ export function createImHostPlugin(internals = {}) {
64
66
  ['telegram', startTelegram],
65
67
  ['discord', startDiscord],
66
68
  ['whatsapp', startWhatsapp],
69
+ ['imessage', startIMessage],
67
70
  ['office', startOffice],
68
71
  ];
69
72
  return Object.freeze({
@@ -130,7 +133,7 @@ export function createImHostPlugin(internals = {}) {
130
133
  const logger = typeof ctx?.logger === 'function'
131
134
  ? ctx.logger(name)
132
135
  : (ctx?.logger ?? console);
133
- if (ctx?.connection?.rpc) {
136
+ if (ctx?.connection?.fetch) {
134
137
  try {
135
138
  startUpdate(ctx);
136
139
  } catch (error) {
@@ -5,7 +5,7 @@ import { hasActiveHarnessInteractionOwner } from '../../src/channels/shared/harn
5
5
  const modernApis = new WeakMap();
6
6
 
7
7
  function failureOf(error) {
8
- const failure = error?.failure;
8
+ const failure = error?.failure ?? (error?.isDSHRemoteError === true ? error : undefined);
9
9
  if (failure && typeof failure === 'object'
10
10
  && typeof failure.code === 'string'
11
11
  && typeof failure.message === 'string') {
@@ -1,3 +1,4 @@
1
+ import { registerManagementRpc } from '../management-rpc.mjs';
1
2
  import { createUpdateRuntime } from './update-runtime.mjs';
2
3
  import { createUpdateService } from './update-service.mjs';
3
4
 
@@ -42,7 +43,7 @@ export function createUpdateRpcHandler(service) {
42
43
  export function installUpdateRpc(ctx, options = {}) {
43
44
  const runtime = options.runtime ?? createUpdateRuntime({ ctx, moduleUrl: import.meta.url });
44
45
  const service = options.service ?? createUpdateService({ runtime });
45
- const dispose = ctx.connection.rpc.handle(UPDATE_RPC_CHANNEL, createUpdateRpcHandler(service), {
46
+ const dispose = registerManagementRpc(ctx, UPDATE_RPC_CHANNEL, createUpdateRpcHandler(service), {
46
47
  authority: 'loopback',
47
48
  });
48
49
  ctx.effect(() => () => service.close(), 'dsh-im: close update installer');
@@ -0,0 +1,77 @@
1
+ import { resolveRpcAuthority } from './host/rpc-authority.mjs';
2
+
3
+ function rpcEndpoint(channel) {
4
+ if (!/^\/[A-Za-z0-9._~-]+$/.test(channel)) throw new TypeError('Invalid IM RPC channel');
5
+ return `dsh-im${channel}`;
6
+ }
7
+
8
+ function isRecord(value) {
9
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
10
+ }
11
+
12
+ function isLoopbackAuthority(authority) {
13
+ if (!authority) return false;
14
+ try {
15
+ const url = new URL(`http://${authority}`);
16
+ if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) return false;
17
+ const hostname = url.hostname.replace(/\.$/, '');
18
+ return hostname === 'localhost' || hostname === '[::1]'
19
+ || /^127\.\d+\.\d+\.\d+$/.test(hostname);
20
+ } catch { return false; }
21
+ }
22
+
23
+ function isLoopbackRequest(request) {
24
+ if (!isLoopbackAuthority(request.headers.get('host'))) return false;
25
+ const origin = request.headers.get('origin');
26
+ if (origin === null) return true;
27
+ try {
28
+ const url = new URL(origin);
29
+ return ['http:', 'https:'].includes(url.protocol) && isLoopbackAuthority(url.host);
30
+ } catch { return false; }
31
+ }
32
+
33
+ function reply(rpcId, result) {
34
+ // Older endpoint handlers omit details; Connection's client requires the field.
35
+ const value = result.ok === false
36
+ ? { ...result, error: { ...result.error, details: result.error.details ?? {} } } : result;
37
+ return Response.json({ type: 'server-response', rpcId, result: value });
38
+ }
39
+
40
+ /** Register an IM channel through the public /api carrier shared by supported DSH releases. */
41
+ export function registerManagementRpc(ctx, channel, handler, { authority } = {}) {
42
+ const policy = resolveRpcAuthority(authority);
43
+ const endpoint = rpcEndpoint(channel);
44
+ if (typeof ctx?.connection?.fetch?.register !== 'function') {
45
+ throw new TypeError('DSH Host Connection Fetch registry is required');
46
+ }
47
+ return ctx.connection.fetch.register({
48
+ path: `/api/${endpoint}`,
49
+ methods: ['POST'],
50
+ requestBody: 'buffered',
51
+ async fetch(request) {
52
+ // DSH applies browser authentication and Host/Origin trust before this handler.
53
+ if (policy === 'loopback' && !isLoopbackRequest(request)) return new Response('forbidden', { status: 403 });
54
+ if (request.method !== 'POST') return new Response('method not allowed', { status: 405 });
55
+ if (request.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase() !== 'application/json') {
56
+ return new Response('content type must be application/json', { status: 415 });
57
+ }
58
+ let message;
59
+ try { message = await request.json(); }
60
+ catch { return new Response('body is not JSON', { status: 400 }); }
61
+ const rpcId = typeof message?.rpcId === 'string' ? message.rpcId : 'invalid-request';
62
+ const call = message?.payload;
63
+ if (!isRecord(message) || message.type !== 'client-request' || typeof message.rpcId !== 'string'
64
+ || message.method !== endpoint || !isRecord(call) || typeof call.method !== 'string'
65
+ || !Object.hasOwn(call, 'payload')) {
66
+ return reply(rpcId, { ok: false, error: { code: 'gateway/bad-request', message: 'Invalid IM management request.' } });
67
+ }
68
+ try { return reply(rpcId, await handler(call.method, call.payload, request.signal)); }
69
+ catch { return new Response('IM management handler failed', { status: 500 }); }
70
+ },
71
+ });
72
+ }
73
+
74
+ /** Keep channel UI callers unchanged while using DSH's native correlation and response validation. */
75
+ export function callManagementRpc(connection, channel, method, payload, signal) {
76
+ return connection.rpc.call('/api', rpcEndpoint(channel), { method, payload }, signal);
77
+ }
@@ -0,0 +1,24 @@
1
+ import {
2
+ deriveTokenBotIdentity,
3
+ maskPlatformId,
4
+ TokenBotConfigStore,
5
+ } from '../shared/token-config-store.mjs';
6
+
7
+ const IDENTITY_OPTIONS = Object.freeze({
8
+ botPrefix: 'imessage',
9
+ tokenRefPrefix: 'DSH_IMESSAGE_PASSWORD',
10
+ });
11
+
12
+ export function deriveIMessageBotIdentity(platformId) {
13
+ return deriveTokenBotIdentity(platformId, IDENTITY_OPTIONS);
14
+ }
15
+
16
+ export function maskIMessageBotId(platformId) {
17
+ return maskPlatformId(platformId, 'iMessage 网关');
18
+ }
19
+
20
+ export class IMessageConfigStore extends TokenBotConfigStore {
21
+ constructor(path) {
22
+ super(path, { channel: 'iMessage', ...IDENTITY_OPTIONS });
23
+ }
24
+ }
@@ -0,0 +1,37 @@
1
+ import { TokenBotController } from '../shared/token-bot-controller.mjs';
2
+ import { MacOSMessagesApi } from './imessage-api.mjs';
3
+ import {
4
+ deriveIMessageBotIdentity,
5
+ maskIMessageBotId,
6
+ } from './config-store.mjs';
7
+ import { IMESSAGE_DESCRIPTOR } from './imessage-bridge.mjs';
8
+
9
+ const NATIVE_CREDENTIAL = 'macos-messages-native';
10
+
11
+ export async function inspectIMessageCredential(value, { api = new MacOSMessagesApi() } = {}) {
12
+ if (value !== NATIVE_CREDENTIAL) throw new TypeError('Invalid native iMessage credential');
13
+ const permissions = await api.getPermissions();
14
+ return { platformId: 'macos-messages', name: 'Mac Messages', permissions };
15
+ }
16
+
17
+ export class IMessageController extends TokenBotController {
18
+ constructor(options) {
19
+ super({
20
+ ...options,
21
+ descriptor: IMESSAGE_DESCRIPTOR,
22
+ inspectToken: options.inspectToken ?? inspectIMessageCredential,
23
+ deriveIdentity: deriveIMessageBotIdentity,
24
+ maskPlatformId: maskIMessageBotId,
25
+ });
26
+ }
27
+
28
+ async bindNative() {
29
+ return super.bindCredentials({ token: NATIVE_CREDENTIAL });
30
+ }
31
+
32
+ async permissions() {
33
+ return new MacOSMessagesApi().getPermissions();
34
+ }
35
+ }
36
+
37
+ export { NATIVE_CREDENTIAL };
@@ -0,0 +1,7 @@
1
+ import { HarnessClient } from '../shared/harness-client.mjs';
2
+
3
+ export class IMessageHarnessClient extends HarnessClient {
4
+ constructor(options) {
5
+ super({ ...options, rpcIdPrefix: 'imessage', logPrefix: 'dsh-imessage' });
6
+ }
7
+ }
@@ -0,0 +1,194 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ const execFileAsync = promisify(execFile);
5
+ const DEFAULT_DB_PATH = `${process.env.HOME ?? ''}/Library/Messages/chat.db`;
6
+ const DEFAULT_TIMEOUT_MS = 15_000;
7
+ // Self-chat has no separate bot sender. Keep the reply marker in Messages itself
8
+ // so echoes are still recognizable after a Host restart or iCloud resync.
9
+ export const IMESSAGE_BOT_REPLY_PREFIX = '🤖 DSH\n';
10
+
11
+ function cleanString(value) {
12
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
13
+ }
14
+
15
+ function normalizeChatGuid(value) {
16
+ const chatGuid = cleanString(value?.chatGuid ?? value);
17
+ if (!chatGuid || chatGuid.length > 512 || /[\r\n]/.test(chatGuid)) {
18
+ throw new TypeError('iMessage chatGuid is required');
19
+ }
20
+ return chatGuid;
21
+ }
22
+
23
+ function normalizeAddress(value) {
24
+ const address = cleanString(value);
25
+ if (!address || address.length > 512 || /[\r\n]/.test(address)) {
26
+ throw new TypeError('iMessage address is required');
27
+ }
28
+ return address;
29
+ }
30
+
31
+ function permissionError(kind, message, cause) {
32
+ const error = new Error(message, { cause });
33
+ error.code = kind;
34
+ return error;
35
+ }
36
+
37
+ function sqlString(value) {
38
+ return `'${String(value).replaceAll("'", "''")}'`;
39
+ }
40
+
41
+ function decodeRows(stdout) {
42
+ const text = String(stdout ?? '').trim();
43
+ if (!text) return [];
44
+ try {
45
+ const rows = JSON.parse(text);
46
+ return Array.isArray(rows) ? rows : [];
47
+ } catch (error) {
48
+ throw new Error('macOS Messages returned invalid database output', { cause: error });
49
+ }
50
+ }
51
+
52
+ function appleScriptString(value) {
53
+ return JSON.stringify(String(value));
54
+ }
55
+
56
+ export function normalizeIMessageTarget(value) {
57
+ return normalizeChatGuid(value);
58
+ }
59
+
60
+ export function normalizeIMessage(value, { botId } = {}) {
61
+ if (!value || typeof value !== 'object') return null;
62
+ if (value.serviceName !== undefined && value.serviceName !== 'iMessage') return null;
63
+ const guid = cleanString(value.guid ?? value.id);
64
+ const chatGuid = cleanString(value.chatGuid ?? value.chat_guid);
65
+ const text = cleanString(value.text);
66
+ const sender = cleanString(value.sender ?? value.handle_id);
67
+ if (!guid || !chatGuid || !text || !sender) return null;
68
+ if (text.startsWith(IMESSAGE_BOT_REPLY_PREFIX)) return null;
69
+ if (value.isFromMe === 1 || value.isFromMe === true) return null;
70
+ if (botId && sender === botId) return null;
71
+ return Object.freeze({
72
+ messageId: guid,
73
+ providerMessageId: guid,
74
+ conversationId: chatGuid,
75
+ kind: 'direct',
76
+ senderId: sender,
77
+ senderName: sender,
78
+ content: text,
79
+ addressed: true,
80
+ replyTarget: { chatGuid, address: sender, serviceName: 'iMessage' },
81
+ connectionTestTarget: { chatGuid, address: sender, serviceName: 'iMessage' },
82
+ ...(value.receivedAt ? { receivedAt: value.receivedAt } : {}),
83
+ });
84
+ }
85
+
86
+ export class MacOSMessagesApi {
87
+ #dbPath;
88
+ #execFile;
89
+ #execFileOptions;
90
+ #osascript;
91
+
92
+ constructor({ dbPath = DEFAULT_DB_PATH, execFileImpl = execFileAsync, osascriptImpl } = {}) {
93
+ this.#dbPath = dbPath;
94
+ this.#execFile = execFileImpl;
95
+ this.#execFileOptions = { timeout: DEFAULT_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 };
96
+ this.#osascript = osascriptImpl ?? ((script) => this.#execFile('/usr/bin/osascript', ['-e', script], this.#execFileOptions));
97
+ if (typeof this.#execFile !== 'function' || typeof this.#osascript !== 'function') {
98
+ throw new TypeError('MacOSMessagesApi requires command runners');
99
+ }
100
+ }
101
+
102
+ async getPermissions() {
103
+ const result = { platform: process.platform, database: 'unknown', automation: 'unknown' };
104
+ if (process.platform !== 'darwin') {
105
+ return { ...result, database: 'unsupported', automation: 'unsupported' };
106
+ }
107
+ try {
108
+ await this.#execFile('/usr/bin/sqlite3', ['-json', this.#dbPath, 'SELECT 1 AS ok LIMIT 1;'], this.#execFileOptions);
109
+ result.database = 'granted';
110
+ } catch (error) {
111
+ result.database = /authorization denied|not authorized|unable to open database/i.test(String(error?.stderr ?? error))
112
+ ? 'required' : 'error';
113
+ }
114
+ try {
115
+ await this.#osascript('tell application "Messages" to get name');
116
+ result.automation = 'granted';
117
+ } catch (error) {
118
+ result.automation = /not authorized|(-1743)|assistive/i.test(String(error?.stderr ?? error))
119
+ ? 'required' : 'error';
120
+ }
121
+ return result;
122
+ }
123
+
124
+ async listMessages({ after = 0, limit = 50, chatGuid } = {}) {
125
+ const cursor = Number.isSafeInteger(after) && after >= 0 ? after : 0;
126
+ const boundedLimit = Math.max(1, Math.min(100, Number(limit) || 50));
127
+ const chatFilter = chatGuid ? ` AND c.guid = ${sqlString(normalizeChatGuid(chatGuid))}` : '';
128
+ // Messages delivers self-chat back as an incoming copy. Read that copy once,
129
+ // keeping all outgoing rows excluded, and filter bot replies by their marker.
130
+ const query = `SELECT m.ROWID AS rowid, m.guid AS guid, m.text AS text,
131
+ h.id AS sender, c.guid AS chatGuid, c.service_name AS serviceName,
132
+ m.is_from_me AS isFromMe,
133
+ datetime((m.date / 1000000000) + 978307200, 'unixepoch') AS receivedAt
134
+ FROM message m
135
+ JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
136
+ JOIN chat c ON c.ROWID = cmj.chat_id
137
+ LEFT JOIN handle h ON h.ROWID = m.handle_id
138
+ WHERE m.ROWID > ${cursor} AND m.is_from_me = 0
139
+ AND m.text IS NOT NULL AND m.text != ''
140
+ AND c.service_name = 'iMessage'${chatFilter}
141
+ ORDER BY m.ROWID ASC LIMIT ${boundedLimit};`;
142
+ try {
143
+ const { stdout } = await this.#execFile('/usr/bin/sqlite3', ['-json', this.#dbPath, query], this.#execFileOptions);
144
+ return decodeRows(stdout);
145
+ } catch (error) {
146
+ if (/authorization denied|not authorized|unable to open database/i.test(String(error?.stderr ?? error))) {
147
+ throw permissionError('messages-database-permission-required', '请在系统设置中授予 DeepSeek Harness 完全磁盘访问权限。', error);
148
+ }
149
+ throw error;
150
+ }
151
+ }
152
+
153
+ async getLatestMessageRowId() {
154
+ if (process.platform !== 'darwin') return 0;
155
+ const query = `SELECT COALESCE(MAX(m.ROWID), 0) AS rowid
156
+ FROM message m
157
+ JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
158
+ JOIN chat c ON c.ROWID = cmj.chat_id
159
+ WHERE c.service_name = 'iMessage';`;
160
+ const { stdout } = await this.#execFile(
161
+ '/usr/bin/sqlite3', ['-json', this.#dbPath, query], this.#execFileOptions,
162
+ );
163
+ const rows = decodeRows(stdout);
164
+ const rowid = Number(rows[0]?.rowid ?? 0);
165
+ return Number.isSafeInteger(rowid) && rowid >= 0 ? rowid : 0;
166
+ }
167
+
168
+ async sendText({ chatGuid, address, text } = {}) {
169
+ const target = normalizeChatGuid(chatGuid);
170
+ const recipient = address ? normalizeAddress(address) : target.split(';').at(-1) || target;
171
+ const content = cleanString(text);
172
+ if (!content) throw new TypeError('iMessage text is required');
173
+ const reply = content.startsWith(IMESSAGE_BOT_REPLY_PREFIX)
174
+ ? content : `${IMESSAGE_BOT_REPLY_PREFIX}${content}`;
175
+ const script = `tell application "Messages"
176
+ set serviceList to every service whose service type = iMessage
177
+ if (count of serviceList) is 0 then error "No iMessage service is available"
178
+ set targetService to item 1 of serviceList
179
+ set targetBuddy to buddy ${appleScriptString(recipient)} of targetService
180
+ send ${appleScriptString(reply)} to targetBuddy
181
+ end tell`;
182
+ try {
183
+ await this.#osascript(script);
184
+ return { sent: true };
185
+ } catch (error) {
186
+ if (/not authorized|(-1743)|assistive/i.test(String(error?.stderr ?? error))) {
187
+ throw permissionError('messages-automation-permission-required', '请在系统设置中允许 DeepSeek Harness 自动化控制 Messages。', error);
188
+ }
189
+ throw error;
190
+ }
191
+ }
192
+ }
193
+
194
+ export { DEFAULT_DB_PATH };
@@ -0,0 +1,16 @@
1
+ import { TextHarnessBridge, createTextBridgeStatus } from '../shared/text-harness-bridge.mjs';
2
+
3
+ export const IMESSAGE_DESCRIPTOR = Object.freeze({
4
+ key: 'imessage',
5
+ label: 'iMessage',
6
+ connectionLabel: ' macOS Messages 连接',
7
+ reactions: Object.freeze({ processing: '👀', success: '✅', error: '❌' }),
8
+ });
9
+
10
+ export class IMessageHarnessBridge extends TextHarnessBridge {
11
+ constructor(options) {
12
+ super({ ...options, descriptor: IMESSAGE_DESCRIPTOR });
13
+ }
14
+ }
15
+
16
+ export { createTextBridgeStatus as createIMessageBridgeStatus };