@xmanrui/dsh-im 0.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 (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -0
  3. package/THIRD_PARTY_NOTICES.md +15 -0
  4. package/bin/dsh-im.mjs +99 -0
  5. package/cordis.patch.yml +3 -0
  6. package/lib/client.js +6211 -0
  7. package/lib/index.js +13339 -0
  8. package/package.json +73 -0
  9. package/plugin-src/client/build.mjs +38 -0
  10. package/plugin-src/client/channel-logos.js +107 -0
  11. package/plugin-src/client/channels/dingtalk/api.js +203 -0
  12. package/plugin-src/client/channels/dingtalk/index.js +709 -0
  13. package/plugin-src/client/channels/dingtalk/styles.js +140 -0
  14. package/plugin-src/client/channels/discord/api.js +11 -0
  15. package/plugin-src/client/channels/discord/index.js +25 -0
  16. package/plugin-src/client/channels/discord/styles.js +19 -0
  17. package/plugin-src/client/channels/feishu/api.js +325 -0
  18. package/plugin-src/client/channels/feishu/index.js +986 -0
  19. package/plugin-src/client/channels/feishu/styles.js +505 -0
  20. package/plugin-src/client/channels/qq/api.js +119 -0
  21. package/plugin-src/client/channels/qq/index.js +402 -0
  22. package/plugin-src/client/channels/qq/styles.js +19 -0
  23. package/plugin-src/client/channels/shared/token-api.js +88 -0
  24. package/plugin-src/client/channels/shared/token-channel.js +277 -0
  25. package/plugin-src/client/channels/telegram/api.js +11 -0
  26. package/plugin-src/client/channels/telegram/index.js +25 -0
  27. package/plugin-src/client/channels/telegram/styles.js +19 -0
  28. package/plugin-src/client/channels/wecom/api.js +119 -0
  29. package/plugin-src/client/channels/wecom/index.js +402 -0
  30. package/plugin-src/client/channels/wecom/styles.js +19 -0
  31. package/plugin-src/client/channels/weixin/api.js +156 -0
  32. package/plugin-src/client/channels/weixin/index.js +577 -0
  33. package/plugin-src/client/channels/weixin/styles.js +120 -0
  34. package/plugin-src/client/credential-binding.js +117 -0
  35. package/plugin-src/client/index.js +192 -0
  36. package/plugin-src/client/lifecycle.js +86 -0
  37. package/plugin-src/client/styles.js +192 -0
  38. package/plugin-src/host/build.mjs +24 -0
  39. package/plugin-src/host/channels/dingtalk/connection-supervisor.mjs +130 -0
  40. package/plugin-src/host/channels/dingtalk/index.mjs +39 -0
  41. package/plugin-src/host/channels/dingtalk/production.mjs +122 -0
  42. package/plugin-src/host/channels/dingtalk/rpc.mjs +237 -0
  43. package/plugin-src/host/channels/discord/index.mjs +30 -0
  44. package/plugin-src/host/channels/discord/production.mjs +17 -0
  45. package/plugin-src/host/channels/discord/rpc.mjs +21 -0
  46. package/plugin-src/host/channels/feishu/connection-supervisor.mjs +167 -0
  47. package/plugin-src/host/channels/feishu/controller.mjs +172 -0
  48. package/plugin-src/host/channels/feishu/credential-store.mjs +84 -0
  49. package/plugin-src/host/channels/feishu/index.mjs +73 -0
  50. package/plugin-src/host/channels/feishu/production.mjs +138 -0
  51. package/plugin-src/host/channels/feishu/rpc.mjs +454 -0
  52. package/plugin-src/host/channels/qq/connection-supervisor.mjs +124 -0
  53. package/plugin-src/host/channels/qq/index.mjs +30 -0
  54. package/plugin-src/host/channels/qq/production.mjs +109 -0
  55. package/plugin-src/host/channels/qq/rpc.mjs +146 -0
  56. package/plugin-src/host/channels/shared/connection-supervisor.mjs +133 -0
  57. package/plugin-src/host/channels/shared/production.mjs +104 -0
  58. package/plugin-src/host/channels/shared/rpc.mjs +119 -0
  59. package/plugin-src/host/channels/telegram/index.mjs +30 -0
  60. package/plugin-src/host/channels/telegram/production.mjs +17 -0
  61. package/plugin-src/host/channels/telegram/rpc.mjs +21 -0
  62. package/plugin-src/host/channels/wecom/connection-supervisor.mjs +124 -0
  63. package/plugin-src/host/channels/wecom/index.mjs +36 -0
  64. package/plugin-src/host/channels/wecom/production.mjs +113 -0
  65. package/plugin-src/host/channels/wecom/rpc.mjs +151 -0
  66. package/plugin-src/host/channels/weixin/connection-supervisor.mjs +127 -0
  67. package/plugin-src/host/channels/weixin/index.mjs +39 -0
  68. package/plugin-src/host/channels/weixin/production.mjs +119 -0
  69. package/plugin-src/host/channels/weixin/rpc.mjs +178 -0
  70. package/plugin-src/host/index.mjs +44 -0
  71. package/plugin-src/host/rpc-authority.mjs +11 -0
  72. package/scripts/verify-package.mjs +97 -0
  73. package/src/channels/dingtalk/config-store.mjs +282 -0
  74. package/src/channels/dingtalk/device-auth.mjs +237 -0
  75. package/src/channels/dingtalk/dingtalk-api.mjs +579 -0
  76. package/src/channels/dingtalk/dingtalk-bridge.mjs +281 -0
  77. package/src/channels/dingtalk/dingtalk-card-stream.mjs +233 -0
  78. package/src/channels/dingtalk/dingtalk-controller.mjs +752 -0
  79. package/src/channels/dingtalk/dingtalk-runtime.mjs +358 -0
  80. package/src/channels/dingtalk/harness-client.mjs +299 -0
  81. package/src/channels/dingtalk/state-store.mjs +212 -0
  82. package/src/channels/discord/config-store.mjs +24 -0
  83. package/src/channels/discord/discord-api.mjs +153 -0
  84. package/src/channels/discord/discord-bridge.mjs +15 -0
  85. package/src/channels/discord/discord-controller.mjs +16 -0
  86. package/src/channels/discord/discord-runtime.mjs +460 -0
  87. package/src/channels/discord/harness-client.mjs +3 -0
  88. package/src/channels/discord/state-store.mjs +3 -0
  89. package/src/channels/feishu/bridge.mjs +216 -0
  90. package/src/channels/feishu/config.mjs +71 -0
  91. package/src/channels/feishu/feishu-app.mjs +51 -0
  92. package/src/channels/feishu/feishu-channel.mjs +153 -0
  93. package/src/channels/feishu/feishu-runtime.mjs +222 -0
  94. package/src/channels/feishu/harness-client.mjs +268 -0
  95. package/src/channels/feishu/message-utils.mjs +51 -0
  96. package/src/channels/feishu/multi-bot-controller.mjs +686 -0
  97. package/src/channels/feishu/plugin-config-store.mjs +204 -0
  98. package/src/channels/feishu/plugin-controller.mjs +248 -0
  99. package/src/channels/feishu/registration-manager.mjs +345 -0
  100. package/src/channels/feishu/state-store.mjs +71 -0
  101. package/src/channels/qq/config-store.mjs +171 -0
  102. package/src/channels/qq/harness-client.mjs +3 -0
  103. package/src/channels/qq/qq-bridge.mjs +177 -0
  104. package/src/channels/qq/qq-controller.mjs +452 -0
  105. package/src/channels/qq/qq-runtime.mjs +208 -0
  106. package/src/channels/qq/qr-auth.mjs +24 -0
  107. package/src/channels/qq/state-store.mjs +96 -0
  108. package/src/channels/shared/conversation-state-store.mjs +107 -0
  109. package/src/channels/shared/editable-message-stream.mjs +81 -0
  110. package/src/channels/shared/text-harness-bridge.mjs +180 -0
  111. package/src/channels/shared/token-bot-controller.mjs +298 -0
  112. package/src/channels/shared/token-config-store.mjs +180 -0
  113. package/src/channels/telegram/config-store.mjs +24 -0
  114. package/src/channels/telegram/harness-client.mjs +3 -0
  115. package/src/channels/telegram/state-store.mjs +3 -0
  116. package/src/channels/telegram/telegram-api.mjs +120 -0
  117. package/src/channels/telegram/telegram-bridge.mjs +15 -0
  118. package/src/channels/telegram/telegram-controller.mjs +16 -0
  119. package/src/channels/telegram/telegram-runtime.mjs +278 -0
  120. package/src/channels/wecom/config-store.mjs +170 -0
  121. package/src/channels/wecom/harness-client.mjs +3 -0
  122. package/src/channels/wecom/qr-auth.mjs +95 -0
  123. package/src/channels/wecom/state-store.mjs +90 -0
  124. package/src/channels/wecom/wecom-bridge.mjs +243 -0
  125. package/src/channels/wecom/wecom-controller.mjs +474 -0
  126. package/src/channels/wecom/wecom-runtime.mjs +209 -0
  127. package/src/channels/weixin/config-store.mjs +182 -0
  128. package/src/channels/weixin/harness-client.mjs +259 -0
  129. package/src/channels/weixin/state-store.mjs +112 -0
  130. package/src/channels/weixin/weixin-api.mjs +319 -0
  131. package/src/channels/weixin/weixin-bridge.mjs +173 -0
  132. package/src/channels/weixin/weixin-controller.mjs +545 -0
  133. package/src/channels/weixin/weixin-runtime.mjs +204 -0
@@ -0,0 +1,182 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import { dirname } from 'node:path';
4
+
5
+ import { normalizeWeixinApiBaseUrl } from './weixin-api.mjs';
6
+
7
+ const EMPTY_DOCUMENT = Object.freeze({ version: 1, accounts: Object.freeze([]) });
8
+
9
+ function cleanString(value) {
10
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
11
+ }
12
+
13
+ function safeBotId(value) {
14
+ const id = cleanString(value);
15
+ return id && /^wx_[a-f0-9]{24}$/.test(id) ? id : null;
16
+ }
17
+
18
+ function safeTokenRef(value) {
19
+ const ref = cleanString(value);
20
+ return ref && /^DSH_WEIXIN_BOT_TOKEN_[A-F0-9]{24}$/.test(ref) ? ref : null;
21
+ }
22
+
23
+ export function deriveWeixinBotIdentity(accountId) {
24
+ const raw = cleanString(accountId);
25
+ if (!raw) throw new TypeError('accountId is required');
26
+ const digest = createHash('sha256').update(raw).digest('hex').slice(0, 24);
27
+ return {
28
+ botId: `wx_${digest}`,
29
+ tokenRef: `DSH_WEIXIN_BOT_TOKEN_${digest.toUpperCase()}`,
30
+ };
31
+ }
32
+
33
+ export function maskWeixinAccountId(accountId) {
34
+ const value = cleanString(accountId) ?? '';
35
+ if (value.length <= 10) return value ? `${value.slice(0, 3)}•••` : '微信机器人';
36
+ return `${value.slice(0, 6)}••••${value.slice(-4)}`;
37
+ }
38
+
39
+ function normalizeAccount(value) {
40
+ if (!value || typeof value !== 'object') return null;
41
+ const accountId = cleanString(value.accountId);
42
+ const ownerUserId = cleanString(value.ownerUserId);
43
+ const botId = safeBotId(value.botId);
44
+ const tokenRef = safeTokenRef(value.tokenRef);
45
+ if (!accountId || !ownerUserId || !botId || !tokenRef) return null;
46
+ const derived = deriveWeixinBotIdentity(accountId);
47
+ if (derived.botId !== botId || derived.tokenRef !== tokenRef) return null;
48
+ let baseUrl;
49
+ try {
50
+ baseUrl = normalizeWeixinApiBaseUrl(value.baseUrl);
51
+ } catch {
52
+ return null;
53
+ }
54
+ return Object.freeze({
55
+ botId,
56
+ accountId,
57
+ tokenRef,
58
+ ownerUserId,
59
+ baseUrl,
60
+ createdAt: cleanString(value.createdAt) ?? new Date().toISOString(),
61
+ connectedAt: cleanString(value.connectedAt),
62
+ });
63
+ }
64
+
65
+ function normalizeDocument(value) {
66
+ if (!value || value.version !== 1 || !Array.isArray(value.accounts)) return null;
67
+ const accounts = value.accounts.map(normalizeAccount);
68
+ if (accounts.some((account) => account === null)) return null;
69
+ const ids = new Set();
70
+ const accountIds = new Set();
71
+ const refs = new Set();
72
+ for (const account of accounts) {
73
+ if (ids.has(account.botId) || accountIds.has(account.accountId) || refs.has(account.tokenRef)) {
74
+ return null;
75
+ }
76
+ ids.add(account.botId);
77
+ accountIds.add(account.accountId);
78
+ refs.add(account.tokenRef);
79
+ }
80
+ return Object.freeze({ version: 1, accounts: Object.freeze(accounts) });
81
+ }
82
+
83
+ export class WeixinConfigStore {
84
+ #path;
85
+ #value = EMPTY_DOCUMENT;
86
+ #writeQueue = Promise.resolve();
87
+
88
+ constructor(path) {
89
+ this.#path = path;
90
+ }
91
+
92
+ async load() {
93
+ try {
94
+ const normalized = normalizeDocument(JSON.parse(await readFile(this.#path, 'utf8')));
95
+ if (!normalized) throw new Error('dsh-weixin config contains invalid account data');
96
+ this.#value = normalized;
97
+ } catch (error) {
98
+ if (error?.code !== 'ENOENT') throw error;
99
+ this.#value = EMPTY_DOCUMENT;
100
+ }
101
+ return this;
102
+ }
103
+
104
+ list() {
105
+ return structuredClone(this.#value.accounts);
106
+ }
107
+
108
+ get(botId) {
109
+ const account = this.#value.accounts.find((candidate) => candidate.botId === botId);
110
+ return account ? structuredClone(account) : null;
111
+ }
112
+
113
+ getByAccountId(accountId) {
114
+ const account = this.#value.accounts.find((candidate) => candidate.accountId === accountId);
115
+ return account ? structuredClone(account) : null;
116
+ }
117
+
118
+ async save(value) {
119
+ const normalized = normalizeAccount(value);
120
+ if (!normalized) throw new Error('Refusing to persist incomplete dsh-weixin account data');
121
+ return this.#mutate((accounts) => {
122
+ const accountCollision = accounts.find(
123
+ (account) => account.accountId === normalized.accountId && account.botId !== normalized.botId,
124
+ );
125
+ const refCollision = accounts.find(
126
+ (account) => account.tokenRef === normalized.tokenRef && account.botId !== normalized.botId,
127
+ );
128
+ if (accountCollision || refCollision) throw new Error('Duplicate Weixin account identity');
129
+ const index = accounts.findIndex((account) => account.botId === normalized.botId);
130
+ if (index === -1) accounts.push(normalized);
131
+ else accounts[index] = normalized;
132
+ return structuredClone(normalized);
133
+ });
134
+ }
135
+
136
+ async remove(botId) {
137
+ if (!safeBotId(botId)) throw new TypeError('Invalid Weixin bot id');
138
+ return this.#mutate((accounts) => {
139
+ const index = accounts.findIndex((account) => account.botId === botId);
140
+ if (index === -1) return null;
141
+ const [removed] = accounts.splice(index, 1);
142
+ return structuredClone(removed);
143
+ });
144
+ }
145
+
146
+ async clear() {
147
+ const operation = this.#writeQueue.then(async () => {
148
+ try {
149
+ await unlink(this.#path);
150
+ } catch (error) {
151
+ if (error?.code !== 'ENOENT') throw error;
152
+ }
153
+ this.#value = EMPTY_DOCUMENT;
154
+ });
155
+ this.#writeQueue = operation.then(() => undefined, () => undefined);
156
+ await operation;
157
+ }
158
+
159
+ async #mutate(mutator) {
160
+ let result;
161
+ const operation = this.#writeQueue.then(async () => {
162
+ const accounts = [...this.#value.accounts];
163
+ result = mutator(accounts);
164
+ const document = Object.freeze({ version: 1, accounts: Object.freeze(accounts) });
165
+ await this.#write(document);
166
+ this.#value = document;
167
+ });
168
+ this.#writeQueue = operation.then(() => undefined, () => undefined);
169
+ await operation;
170
+ return result;
171
+ }
172
+
173
+ async #write(document) {
174
+ await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
175
+ const temporary = `${this.#path}.tmp`;
176
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
177
+ encoding: 'utf8',
178
+ mode: 0o600,
179
+ });
180
+ await rename(temporary, this.#path);
181
+ }
182
+ }
@@ -0,0 +1,259 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+
4
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5
+
6
+ function assistantMessageText(event) {
7
+ return (event?.data?.message?.content ?? [])
8
+ .filter((part) => part.type === 'text' && typeof part.text === 'string')
9
+ .map((part) => part.text)
10
+ .join('\n')
11
+ .trim();
12
+ }
13
+
14
+ export class HarnessReplyTracker {
15
+ #promptRpcId;
16
+ #lastSeq;
17
+ #openTurn = null;
18
+ #targetTurn = null;
19
+ #stepText = new Map();
20
+ #latestText = '';
21
+ #finished = false;
22
+ #reason = null;
23
+
24
+ constructor({ promptRpcId, afterSeq = -1 }) {
25
+ this.#promptRpcId = promptRpcId;
26
+ this.#lastSeq = afterSeq;
27
+ }
28
+
29
+ get finished() {
30
+ return this.#finished;
31
+ }
32
+
33
+ get answer() {
34
+ return this.#latestText.trim();
35
+ }
36
+
37
+ get reason() {
38
+ return this.#reason;
39
+ }
40
+
41
+ consume(entries) {
42
+ let update = null;
43
+ const ordered = [...entries]
44
+ .map((entry) => entry?.event ?? entry)
45
+ .filter(Boolean)
46
+ .sort((left, right) => (left.seq ?? -1) - (right.seq ?? -1));
47
+
48
+ for (const event of ordered) {
49
+ const seq = event.seq ?? -1;
50
+ if (seq <= this.#lastSeq) continue;
51
+ this.#lastSeq = seq;
52
+
53
+ if (event.type === 'turn/start') this.#openTurn = event.data?.turn ?? null;
54
+
55
+ if (event.type === 'user/message' && event.data?.source?.rpcId === this.#promptRpcId) {
56
+ this.#targetTurn = this.#openTurn;
57
+ continue;
58
+ }
59
+ if (this.#targetTurn === null) continue;
60
+
61
+ if (event.type === 'turn/end') {
62
+ if (event.data?.turn !== this.#targetTurn) continue;
63
+ this.#finished = true;
64
+ this.#reason = event.data?.reason ?? null;
65
+ this.#openTurn = null;
66
+ continue;
67
+ }
68
+ if (event.data?.turn !== this.#targetTurn) continue;
69
+
70
+ if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'text-delta') {
71
+ const step = event.data?.step ?? 0;
72
+ const index = event.data.chunk.index ?? 0;
73
+ const key = `${step}:${index}`;
74
+ this.#stepText.set(key, (this.#stepText.get(key) ?? '') + event.data.chunk.text);
75
+ const prefix = `${step}:`;
76
+ const text = [...this.#stepText.entries()]
77
+ .filter(([partKey]) => partKey.startsWith(prefix))
78
+ .sort(([left], [right]) => Number(left.split(':')[1]) - Number(right.split(':')[1]))
79
+ .map(([, part]) => part)
80
+ .join('\n')
81
+ .trim();
82
+ if (text && text !== this.#latestText) {
83
+ this.#latestText = text;
84
+ update = { type: 'text', text };
85
+ }
86
+ continue;
87
+ }
88
+
89
+ if (event.type === 'assistant/message') {
90
+ const text = assistantMessageText(event);
91
+ if (text && text !== this.#latestText) {
92
+ this.#latestText = text;
93
+ update = { type: 'text', text };
94
+ }
95
+ continue;
96
+ }
97
+
98
+ if (event.type === 'tool/call') {
99
+ update = { type: 'tool', name: event.data?.name ?? '工具' };
100
+ } else if (event.type === 'tool/result') {
101
+ update = { type: 'status', text: '正在整理结果…' };
102
+ }
103
+ }
104
+ return update;
105
+ }
106
+ }
107
+
108
+ export class HarnessRpcError extends Error {
109
+ constructor(method, error) {
110
+ super(`${method}: ${error?.message ?? 'unknown Harness RPC error'}`);
111
+ this.name = 'HarnessRpcError';
112
+ this.method = method;
113
+ this.code = error?.code ?? 'internal';
114
+ this.details = error?.details ?? {};
115
+ }
116
+ }
117
+
118
+ export class HarnessClient {
119
+ #baseUrl;
120
+ #workspace;
121
+ #agentPreset;
122
+ #autostart;
123
+ #dshBin;
124
+ #managedProcess = null;
125
+
126
+ constructor({ baseUrl, workspace, agentPreset = 'standard', autostart = false, dshBin = 'dsh' }) {
127
+ this.#baseUrl = new URL(baseUrl);
128
+ this.#workspace = workspace;
129
+ this.#agentPreset = agentPreset;
130
+ this.#autostart = autostart;
131
+ this.#dshBin = dshBin;
132
+ }
133
+
134
+ async rpc(method, payload = {}, timeoutMs = 30_000, options = {}) {
135
+ const rpcId = options.rpcId ?? `weixin-${randomUUID()}`;
136
+ const response = await fetch(new URL(`/api/${method}`, this.#baseUrl), {
137
+ method: 'POST',
138
+ headers: { 'content-type': 'application/json' },
139
+ body: JSON.stringify({ type: 'client-request', rpcId, method, payload }),
140
+ signal: AbortSignal.timeout(timeoutMs),
141
+ });
142
+ if (!response.ok) throw new Error(`Harness transport ${method} failed: HTTP ${response.status}`);
143
+ const body = await response.json();
144
+ if (body?.type !== 'server-response' || body?.rpcId !== rpcId) {
145
+ throw new Error(`Harness returned an invalid response for ${method}`);
146
+ }
147
+ if (!body.result?.ok) throw new HarnessRpcError(method, body.result?.error);
148
+ return body.result.value;
149
+ }
150
+
151
+ async health() {
152
+ await this.rpc('host.describe', {}, 5_000);
153
+ return true;
154
+ }
155
+
156
+ async ensureRunning() {
157
+ try {
158
+ return await this.health();
159
+ } catch (firstError) {
160
+ if (!this.#autostart) throw firstError;
161
+ }
162
+
163
+ if (!this.#managedProcess || this.#managedProcess.exitCode !== null) {
164
+ const port = this.#baseUrl.port || (this.#baseUrl.protocol === 'https:' ? '443' : '80');
165
+ this.#managedProcess = spawn(this.#dshBin, [
166
+ 'web', '--host', this.#baseUrl.hostname, '--port', port,
167
+ ], {
168
+ cwd: this.#workspace,
169
+ env: process.env,
170
+ stdio: ['ignore', 'inherit', 'inherit'],
171
+ });
172
+ this.#managedProcess.on('error', (error) => {
173
+ console.error('[dsh-weixin] failed to start Harness:', error.message);
174
+ });
175
+ }
176
+
177
+ const deadline = Date.now() + 60_000;
178
+ let lastError;
179
+ while (Date.now() < deadline) {
180
+ await sleep(1_000);
181
+ try {
182
+ return await this.health();
183
+ } catch (error) {
184
+ lastError = error;
185
+ }
186
+ }
187
+ throw new Error(`Harness did not become ready: ${lastError?.message ?? 'timeout'}`);
188
+ }
189
+
190
+ async workspaceId() {
191
+ const { items } = await this.rpc('workspace.list', {});
192
+ const existing = items.find((item) => item.path === this.#workspace);
193
+ if (existing) return existing.workspaceId;
194
+ const created = await this.rpc('workspace.create', { path: this.#workspace });
195
+ return created.workspace.workspaceId;
196
+ }
197
+
198
+ async createSession() {
199
+ await this.ensureRunning();
200
+ const workspaceId = await this.workspaceId();
201
+ const created = await this.rpc('session.create', {
202
+ workspaceId,
203
+ agentPreset: this.#agentPreset,
204
+ });
205
+ return created.sessionId;
206
+ }
207
+
208
+ async sessionExists(sessionId) {
209
+ try {
210
+ await this.rpc('session.history', { sessionId, maxMessages: 1 });
211
+ return true;
212
+ } catch (error) {
213
+ if (error instanceof HarnessRpcError && error.code === 'session-not-found') return false;
214
+ throw error;
215
+ }
216
+ }
217
+
218
+ async ask(sessionId, text, options = {}) {
219
+ if (typeof options === 'number') options = { timeoutMs: options };
220
+ const timeoutMs = options.timeoutMs ?? 600_000;
221
+ const onUpdate = typeof options.onUpdate === 'function' ? options.onUpdate : null;
222
+ await this.ensureRunning();
223
+ const before = await this.rpc('session.history', { sessionId, maxMessages: 1 });
224
+ const baselineSeq = Math.max(-1, ...(before.events ?? []).map(({ event }) => event.seq ?? -1));
225
+ const promptRpcId = `weixin-${randomUUID()}`;
226
+ const tracker = new HarnessReplyTracker({ promptRpcId, afterSeq: baselineSeq });
227
+
228
+ await this.rpc('session.prompt', {
229
+ sessionId,
230
+ mode: 'queue',
231
+ content: [{ type: 'text', text }],
232
+ clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
233
+ }, 30_000, { rpcId: promptRpcId });
234
+
235
+ const deadline = Date.now() + timeoutMs;
236
+ while (Date.now() < deadline) {
237
+ await sleep(300);
238
+ const history = await this.rpc('session.history', { sessionId, maxMessages: 50 });
239
+ const update = tracker.consume(history.events ?? []);
240
+ if (update && onUpdate) {
241
+ try {
242
+ await onUpdate(update);
243
+ } catch (error) {
244
+ console.warn('[dsh-weixin] ignored a progress update failure:', error.message);
245
+ }
246
+ }
247
+ if (!tracker.finished) continue;
248
+ if (tracker.answer) return tracker.answer;
249
+ throw new Error(
250
+ `Harness turn ended without a text reply${tracker.reason ? ` (${JSON.stringify(tracker.reason)})` : ''}`,
251
+ );
252
+ }
253
+ throw new Error(`Harness reply timed out after ${Math.round(timeoutMs / 1_000)} seconds`);
254
+ }
255
+
256
+ stopManagedProcess() {
257
+ if (this.#managedProcess?.exitCode === null) this.#managedProcess.kill('SIGTERM');
258
+ }
259
+ }
@@ -0,0 +1,112 @@
1
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+
4
+ const EMPTY_STATE = Object.freeze({
5
+ version: 1,
6
+ sessions: {},
7
+ seenMessageIds: [],
8
+ getUpdatesBuf: '',
9
+ });
10
+
11
+ function normalizeState(value) {
12
+ if (!value || typeof value !== 'object') return structuredClone(EMPTY_STATE);
13
+ const sessions = {};
14
+ if (value.sessions && typeof value.sessions === 'object' && !Array.isArray(value.sessions)) {
15
+ for (const [key, sessionId] of Object.entries(value.sessions)) {
16
+ if (typeof key === 'string' && typeof sessionId === 'string' && sessionId) {
17
+ sessions[key] = sessionId;
18
+ }
19
+ }
20
+ }
21
+ return {
22
+ version: 1,
23
+ sessions,
24
+ seenMessageIds: Array.isArray(value.seenMessageIds)
25
+ ? value.seenMessageIds.filter((id) => typeof id === 'string').slice(-1_000)
26
+ : [],
27
+ getUpdatesBuf: typeof value.getUpdatesBuf === 'string' ? value.getUpdatesBuf : '',
28
+ };
29
+ }
30
+
31
+ export class WeixinStateStore {
32
+ #path;
33
+ #state = structuredClone(EMPTY_STATE);
34
+ #writeQueue = Promise.resolve();
35
+
36
+ constructor(path) {
37
+ this.#path = path;
38
+ }
39
+
40
+ async load() {
41
+ try {
42
+ this.#state = normalizeState(JSON.parse(await readFile(this.#path, 'utf8')));
43
+ } catch (error) {
44
+ if (error?.code !== 'ENOENT') throw error;
45
+ this.#state = structuredClone(EMPTY_STATE);
46
+ await this.#persist();
47
+ }
48
+ return this;
49
+ }
50
+
51
+ sessionFor(key) {
52
+ return this.#state.sessions[key] ?? null;
53
+ }
54
+
55
+ async setSession(key, sessionId) {
56
+ this.#state.sessions[key] = sessionId;
57
+ await this.#persist();
58
+ }
59
+
60
+ async clearSession(key) {
61
+ delete this.#state.sessions[key];
62
+ await this.#persist();
63
+ }
64
+
65
+ hasSeen(messageId) {
66
+ return this.#state.seenMessageIds.includes(messageId);
67
+ }
68
+
69
+ async markSeen(messageId) {
70
+ if (this.hasSeen(messageId)) return;
71
+ this.#state.seenMessageIds.push(messageId);
72
+ if (this.#state.seenMessageIds.length > 1_000) {
73
+ this.#state.seenMessageIds.splice(0, this.#state.seenMessageIds.length - 1_000);
74
+ }
75
+ await this.#persist();
76
+ }
77
+
78
+ getUpdatesBuf() {
79
+ return this.#state.getUpdatesBuf;
80
+ }
81
+
82
+ async setGetUpdatesBuf(value) {
83
+ if (typeof value !== 'string' || value === this.#state.getUpdatesBuf) return;
84
+ this.#state.getUpdatesBuf = value;
85
+ await this.#persist();
86
+ }
87
+
88
+ snapshot() {
89
+ return structuredClone(this.#state);
90
+ }
91
+
92
+ async remove() {
93
+ try {
94
+ await unlink(this.#path);
95
+ } catch (error) {
96
+ if (error?.code !== 'ENOENT') throw error;
97
+ }
98
+ this.#state = structuredClone(EMPTY_STATE);
99
+ }
100
+
101
+ async #persist() {
102
+ const snapshot = `${JSON.stringify(this.#state, null, 2)}\n`;
103
+ const operation = this.#writeQueue.then(async () => {
104
+ await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
105
+ const temporary = `${this.#path}.tmp`;
106
+ await writeFile(temporary, snapshot, { encoding: 'utf8', mode: 0o600 });
107
+ await rename(temporary, this.#path);
108
+ });
109
+ this.#writeQueue = operation.then(() => undefined, () => undefined);
110
+ await operation;
111
+ }
112
+ }