@xmanrui/dsh-im 4.17.1 → 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 (34) hide show
  1. package/README.en.md +17 -13
  2. package/README.md +16 -15
  3. package/lib/client.js +424 -246
  4. package/lib/index.js +280 -258
  5. package/package.json +7 -2
  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 +20 -0
  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/imessage/index.mjs +21 -0
  19. package/plugin-src/host/channels/imessage/production.mjs +13 -0
  20. package/plugin-src/host/channels/imessage/rpc.mjs +70 -0
  21. package/plugin-src/host/channels/shared/access-policy-production.mjs +1 -1
  22. package/plugin-src/host/delivery-adapter.mjs +4 -0
  23. package/plugin-src/host/index.mjs +3 -0
  24. package/plugin-src/host/modern-harness-api.mjs +1 -1
  25. package/src/channels/imessage/config-store.mjs +24 -0
  26. package/src/channels/imessage/controller.mjs +37 -0
  27. package/src/channels/imessage/harness-client.mjs +7 -0
  28. package/src/channels/imessage/imessage-api.mjs +194 -0
  29. package/src/channels/imessage/imessage-bridge.mjs +16 -0
  30. package/src/channels/imessage/runtime.mjs +98 -0
  31. package/src/channels/imessage/state-store.mjs +3 -0
  32. package/src/channels/shared/i18n-en/shared-a.mjs +2 -2
  33. package/src/channels/shared/message-failure.mjs +5 -5
  34. package/src/channels/shared/session-channel-labels.mjs +1 -0
@@ -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 };
@@ -0,0 +1,98 @@
1
+ import { sendRememberedConnectionTest } from '../shared/connection-test.mjs';
2
+ import { MacOSMessagesApi, normalizeIMessage } from './imessage-api.mjs';
3
+ import { createIMessageBridgeStatus, IMessageHarnessBridge } from './imessage-bridge.mjs';
4
+
5
+ const DEFAULT_POLL_INTERVAL_MS = 2_000;
6
+
7
+ class IMessageBotClient {
8
+ #api;
9
+ #signal;
10
+ constructor(api, signal) { this.#api = api; this.#signal = signal; }
11
+ sendText(target, text) {
12
+ return this.#api.sendText({
13
+ chatGuid: target.chatGuid,
14
+ address: target.address,
15
+ text,
16
+ signal: this.#signal,
17
+ });
18
+ }
19
+ sendTyping() { return Promise.resolve(); }
20
+ }
21
+
22
+ export function createIMessageRuntimeStatus() {
23
+ return { startedAt: null, ready: false, connectionState: 'idle', harnessReachable: false,
24
+ lastCheckedAt: null, lastConnectedAt: null, lastError: null, ...createIMessageBridgeStatus() };
25
+ }
26
+
27
+ export class IMessageRuntime {
28
+ #config; #token; #harness; #state; #contextEnhancement; #accessPolicy; #logger;
29
+ #replyTimeoutMs; #pollIntervalMs; #createApi; #status = createIMessageRuntimeStatus();
30
+ #api; #bridge; #abortController; #timer; #polling; #stopped = true;
31
+ constructor({ config, token, harness, state, contextEnhancement, accessPolicy, logger = console,
32
+ replyTimeoutMs = 600_000, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
33
+ createApi = (options) => new MacOSMessagesApi(options) }) {
34
+ if (!config || !token || !harness || !state) throw new TypeError('IMessageRuntime requires config, token, Harness, and state');
35
+ this.#config = config; this.#token = token; this.#harness = harness; this.#state = state;
36
+ this.#contextEnhancement = contextEnhancement; this.#accessPolicy = accessPolicy; this.#logger = logger;
37
+ this.#replyTimeoutMs = replyTimeoutMs; this.#pollIntervalMs = pollIntervalMs; this.#createApi = createApi;
38
+ }
39
+ get status() { return structuredClone(this.#status); }
40
+ async sendConnectionTest(text) {
41
+ if (!this.#status.ready || !this.#api) { const error = new Error('iMessage gateway is not connected'); error.code = 'test-target-unavailable'; throw error; }
42
+ await sendRememberedConnectionTest({
43
+ state: this.#state,
44
+ text,
45
+ channelLabel: 'iMessage',
46
+ send: (target, value) => this.#api.sendText({
47
+ chatGuid: target.chatGuid, address: target.address, text: value,
48
+ }),
49
+ });
50
+ }
51
+ async sendProactiveText(target, text, options = {}) {
52
+ if (!this.#status.ready || !this.#api) { const error = new Error('iMessage gateway is not connected'); error.code = 'bot-not-connected'; throw error; }
53
+ const chatGuid = target?.route?.chatGuid;
54
+ if (!chatGuid) { const error = new TypeError('Invalid iMessage proactive delivery target'); error.code = 'invalid-target'; throw error; }
55
+ await this.#api.sendText({ chatGuid, text, signal: options.signal });
56
+ return { sent: true };
57
+ }
58
+ async start() {
59
+ if (this.#status.ready) return this.status;
60
+ await this.stop(); this.#stopped = false; this.#status.startedAt = new Date().toISOString(); this.#status.connectionState = 'connecting';
61
+ this.#abortController = new AbortController();
62
+ try {
63
+ await this.#harness.ensureRunning(); this.#status.harnessReachable = true;
64
+ this.#api = this.#createApi({ signal: this.#abortController.signal });
65
+ const permissions = await this.#api.getPermissions();
66
+ if (permissions.database !== 'granted' || permissions.automation !== 'granted') {
67
+ const error = new Error('macOS Messages permissions are required');
68
+ error.code = 'messages-permission-required';
69
+ error.permissions = permissions;
70
+ throw error;
71
+ }
72
+ if (this.#state.cursor() === null && typeof this.#api.getLatestMessageRowId === 'function') {
73
+ await this.#state.setCursor(await this.#api.getLatestMessageRowId());
74
+ }
75
+ const client = new IMessageBotClient(this.#api, this.#abortController.signal);
76
+ this.#bridge = new IMessageHarnessBridge({ bot: client, harness: this.#harness, state: this.#state,
77
+ contextEnhancement: this.#contextEnhancement, accessPolicy: this.#accessPolicy, status: this.#status,
78
+ logger: this.#logger, replyTimeoutMs: this.#replyTimeoutMs, signal: this.#abortController.signal });
79
+ this.#status.ready = true; this.#status.connectionState = 'connected'; this.#status.lastConnectedAt = Date.now();
80
+ this.#schedulePoll(0); return this.status;
81
+ } catch (error) { this.#status.ready = false; this.#status.connectionState = 'failed'; this.#status.lastError = error.message; await this.stop(); throw error; }
82
+ }
83
+ async stop() { this.#stopped = true; clearTimeout(this.#timer); this.#timer = null; this.#abortController?.abort(); await this.#polling?.catch(() => {}); this.#polling = null; this.#bridge = null; this.#api = null; this.#status.ready = false; }
84
+ #schedulePoll(delay) { if (this.#stopped) return; this.#timer = setTimeout(() => { this.#polling = this.#poll().finally(() => this.#schedulePoll(this.#pollIntervalMs)); }, delay); this.#timer.unref?.(); }
85
+ async #poll() {
86
+ if (!this.#api || !this.#bridge || this.#stopped) return;
87
+ try {
88
+ const rows = await this.#api.listMessages({ after: this.#state.cursor() ?? 0, limit: 100 });
89
+ const messages = Array.isArray(rows) ? rows : rows?.messages ?? rows?.data ?? [];
90
+ for (const raw of messages) {
91
+ const message = normalizeIMessage(raw, { botId: this.#config.platformId });
92
+ if (message) await this.#bridge.accept(message);
93
+ if (Number.isSafeInteger(raw.rowid)) await this.#state.setCursor(raw.rowid);
94
+ }
95
+ this.#status.lastCheckedAt = Date.now(); this.#status.lastError = null;
96
+ } catch (error) { if (!this.#stopped) { this.#status.lastError = error.message; this.#logger.warn?.('[dsh-im:imessage] polling failed', error); } }
97
+ }
98
+ }
@@ -0,0 +1,3 @@
1
+ import { ConversationStateStore } from '../shared/conversation-state-store.mjs';
2
+
3
+ export class IMessageStateStore extends ConversationStateStore {}
@@ -89,8 +89,8 @@ export default {
89
89
  'The Workspace or Session state just changed. Please send this message again.',
90
90
  '当前工作区不存在或暂不可用。请重新选择工作区后重试。':
91
91
  'The current Workspace does not exist or is unavailable. Select another Workspace and try again.',
92
- '当前 Agent Preset 不存在或暂不可用。请发送 /presetlist 后重新选择。':
93
- 'The current Agent Preset does not exist or is unavailable. Send /presetlist and select another one.',
92
+ '当前 Agent Preset 无法使用。请发送 /presetlist 查看可用项,使用 /preset <序号或 ID> 重新选择,再发送 /new 创建新会话后重试。如需继续原会话,请联系管理员恢复原 Preset。':
93
+ 'The current Agent Preset is unavailable. Send /presetlist to see available options, select one with /preset <index or ID>, then send /new to create a new Session and try again. To continue the original Session, ask an administrator to restore the original Preset.',
94
94
  '回复已经生成,但机器人没有发送权限。请联系管理员检查渠道权限或重新绑定机器人。':
95
95
  'The reply was generated, but the bot cannot send it. Ask an administrator to check channel permissions or reconnect the bot.',
96
96
  '回复已经生成,但当前渠道正在限流,暂时无法发送。请稍后重试。':
@@ -77,7 +77,7 @@ const FAILURE_MESSAGES = Object.freeze({
77
77
  WORKSPACE_UNAVAILABLE:
78
78
  '当前工作区不存在或暂不可用。请重新选择工作区后重试。',
79
79
  PRESET_UNAVAILABLE:
80
- '当前 Agent Preset 不存在或暂不可用。请发送 /presetlist 后重新选择。',
80
+ '当前 Agent Preset 无法使用。请发送 /presetlist 查看可用项,使用 /preset <序号或 ID> 重新选择,再发送 /new 创建新会话后重试。如需继续原会话,请联系管理员恢复原 Preset。',
81
81
  CHANNEL_PERMISSION:
82
82
  '回复已经生成,但机器人没有发送权限。请联系管理员检查渠道权限或重新绑定机器人。',
83
83
  CHANNEL_RATE_LIMIT:
@@ -133,7 +133,7 @@ function failureCode(error) {
133
133
  if (code === 'agent-busy') return 'SESSION_BUSY';
134
134
  if (code === 'workspace-session-stale') return 'SESSION_STALE';
135
135
  if (code.startsWith('workspace-')) return 'WORKSPACE_UNAVAILABLE';
136
- if (code.startsWith('agent-preset-')) return 'PRESET_UNAVAILABLE';
136
+ if (/^agent-preset[-/]/u.test(code)) return 'PRESET_UNAVAILABLE';
137
137
  if (code.startsWith('image-') || code.startsWith('inbound-file-')
138
138
  || code === 'attachment-error') return 'INPUT_INVALID';
139
139
 
@@ -170,8 +170,8 @@ function safeReferenceId(value) {
170
170
  }
171
171
 
172
172
  function safeFailureReason(value) {
173
- if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/u.test(value)) return null;
174
- return value.toUpperCase().replaceAll('-', '_');
173
+ if (typeof value !== 'string' || !/^[A-Za-z0-9_/-]{1,64}$/u.test(value)) return null;
174
+ return value.toUpperCase().replace(/[-/]/gu, '_');
175
175
  }
176
176
 
177
177
  export function classifyMessageFailure(error, {
@@ -190,7 +190,7 @@ export function classifyMessageFailure(error, {
190
190
  : classifiedCode;
191
191
  return Object.freeze({
192
192
  code,
193
- reason: safeReason ?? code,
193
+ reason: safeReason ?? safeFailureReason(error?.code) ?? code,
194
194
  message: typeof userMessage === 'string' && userMessage.trim()
195
195
  ? userMessage.trim()
196
196
  : t(FAILURE_MESSAGES[code]),
@@ -8,6 +8,7 @@ export const SESSION_CHANNEL_LABELS = Object.freeze(Object.fromEntries(Object.en
8
8
  telegram: ['Telegram', 'Telegram'],
9
9
  discord: ['Discord', 'Discord'],
10
10
  whatsapp: ['WhatsApp', 'WhatsApp'],
11
+ imessage: ['iMessage', 'iMessage'],
11
12
  office: ['AI Office', 'AI Office'],
12
13
  }).map(([channel, labels]) => [channel, Object.freeze(labels)])));
13
14