@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,298 @@
1
+ function cleanString(value) {
2
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
3
+ }
4
+
5
+ function safeError(code, message) {
6
+ return Object.freeze({ code, message });
7
+ }
8
+
9
+ export class TokenBotController {
10
+ #descriptor;
11
+ #credentials;
12
+ #configStore;
13
+ #inspectToken;
14
+ #deriveIdentity;
15
+ #maskPlatformId;
16
+ #createRuntime;
17
+ #deleteState;
18
+ #logger;
19
+ #runtimes = new Map();
20
+ #errors = new Map();
21
+ #transitions = new Map();
22
+ #revision = 0;
23
+ #closed = false;
24
+
25
+ constructor({
26
+ descriptor,
27
+ credentials,
28
+ configStore,
29
+ inspectToken,
30
+ deriveIdentity,
31
+ maskPlatformId,
32
+ createRuntime,
33
+ deleteState = async () => {},
34
+ logger = console,
35
+ }) {
36
+ if (!descriptor?.key || !descriptor?.label || !descriptor?.connectionLabel) {
37
+ throw new TypeError('TokenBotController requires a channel descriptor');
38
+ }
39
+ if (!credentials || typeof credentials.resolve !== 'function'
40
+ || typeof credentials.set !== 'function' || typeof credentials.unset !== 'function') {
41
+ throw new TypeError(`${descriptor.label} requires the DSH credential provider`);
42
+ }
43
+ if (!configStore || typeof configStore.list !== 'function'
44
+ || typeof configStore.save !== 'function' || typeof configStore.remove !== 'function') {
45
+ throw new TypeError(`${descriptor.label} requires a config store`);
46
+ }
47
+ if (typeof inspectToken !== 'function' || typeof deriveIdentity !== 'function'
48
+ || typeof maskPlatformId !== 'function' || typeof createRuntime !== 'function') {
49
+ throw new TypeError(`${descriptor.label} controller dependencies are incomplete`);
50
+ }
51
+ this.#descriptor = descriptor;
52
+ this.#credentials = credentials;
53
+ this.#configStore = configStore;
54
+ this.#inspectToken = inspectToken;
55
+ this.#deriveIdentity = deriveIdentity;
56
+ this.#maskPlatformId = maskPlatformId;
57
+ this.#createRuntime = createRuntime;
58
+ this.#deleteState = deleteState;
59
+ this.#logger = logger;
60
+ }
61
+
62
+ async initialize() {
63
+ if (this.#closed) return this.status();
64
+ for (const config of this.#configStore.list()) {
65
+ await this.#withBotTransition(config.botId, async () => {
66
+ if (this.#closed || this.#runtimes.get(config.botId)?.status?.ready) return;
67
+ const token = await this.#resolveToken(config.tokenRef);
68
+ if (!token) {
69
+ this.#errors.set(config.botId, safeError(
70
+ 'missing-token',
71
+ `${this.#descriptor.label}机器人凭据缺失,请移除后重新接入。`,
72
+ ));
73
+ return;
74
+ }
75
+ try {
76
+ await this.#startRuntime(config, token);
77
+ this.#errors.delete(config.botId);
78
+ } catch (error) {
79
+ this.#errors.set(config.botId, safeError(
80
+ 'connection-failed',
81
+ `${this.#descriptor.label}连接未就绪,插件会自动重试。`,
82
+ ));
83
+ this.#logger.warn?.(
84
+ `[dsh-im:${this.#descriptor.key}] bot ${config.botId} failed to initialize:`,
85
+ error,
86
+ );
87
+ } finally {
88
+ this.#touch();
89
+ }
90
+ });
91
+ }
92
+ return this.status();
93
+ }
94
+
95
+ async bindCredentials({ token } = {}) {
96
+ if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`);
97
+ const normalizedToken = cleanString(token);
98
+ if (!normalizedToken) throw new TypeError(`${this.#descriptor.label} Bot Token is required`);
99
+ const inspected = await this.#inspectToken(normalizedToken);
100
+ const platformId = cleanString(inspected?.platformId);
101
+ const name = cleanString(inspected?.name);
102
+ if (!platformId || !name) throw new Error(`${this.#descriptor.label} returned an invalid bot identity`);
103
+ const identity = this.#deriveIdentity(platformId);
104
+ await this.#withBotTransition(identity.botId, async () => {
105
+ if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`);
106
+ const previousConfig = this.#configStore.getByPlatformId(platformId);
107
+ const previousToken = await this.#credentials.resolve(identity.tokenRef).catch(() => undefined);
108
+ const config = {
109
+ botId: identity.botId,
110
+ platformId,
111
+ tokenRef: identity.tokenRef,
112
+ name,
113
+ username: cleanString(inspected.username),
114
+ createdAt: previousConfig?.createdAt ?? new Date().toISOString(),
115
+ connectedAt: new Date().toISOString(),
116
+ };
117
+ await this.#credentials.set(identity.tokenRef, normalizedToken);
118
+ try {
119
+ await this.#configStore.save(config);
120
+ } catch (error) {
121
+ await this.#restoreCredential(identity.tokenRef, previousToken);
122
+ throw error;
123
+ }
124
+ try {
125
+ await this.#startRuntime(config, normalizedToken);
126
+ this.#errors.delete(identity.botId);
127
+ } catch (error) {
128
+ this.#errors.set(identity.botId, safeError(
129
+ 'connection-failed',
130
+ `${this.#descriptor.label}机器人已接入,消息连接暂未就绪。`,
131
+ ));
132
+ this.#logger.warn?.(
133
+ `[dsh-im:${this.#descriptor.key}] bot ${identity.botId} credential connection failed:`,
134
+ error,
135
+ );
136
+ }
137
+ this.#touch();
138
+ });
139
+ return this.status();
140
+ }
141
+
142
+ async reconnectBot(botId) {
143
+ const config = this.#configStore.get(botId);
144
+ if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`);
145
+ await this.#withBotTransition(botId, async () => {
146
+ const token = await this.#resolveToken(config.tokenRef);
147
+ if (!token) throw new Error(`${this.#descriptor.label} bot token is missing`);
148
+ try {
149
+ await this.#startRuntime(config, token);
150
+ this.#errors.delete(botId);
151
+ } catch (error) {
152
+ this.#errors.set(botId, safeError(
153
+ 'connection-failed',
154
+ `${this.#descriptor.label}连接仍未就绪,请稍后重试。`,
155
+ ));
156
+ throw error;
157
+ } finally {
158
+ this.#touch();
159
+ }
160
+ });
161
+ return this.status();
162
+ }
163
+
164
+ async deleteBot(botId) {
165
+ const config = this.#configStore.get(botId);
166
+ if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`);
167
+ await this.#withBotTransition(botId, async () => {
168
+ const previous = await this.#credentials.resolve(config.tokenRef).catch(() => undefined);
169
+ await this.#stopRuntime(botId);
170
+ try {
171
+ await this.#credentials.unset(config.tokenRef);
172
+ await this.#configStore.remove(botId);
173
+ } catch (error) {
174
+ if (previous?.value) {
175
+ await this.#credentials.set(config.tokenRef, previous.value).catch(() => undefined);
176
+ await this.#startRuntime(config, previous.value).catch(() => undefined);
177
+ }
178
+ throw new Error(`Unable to remove the ${this.#descriptor.label} bot safely.`, { cause: error });
179
+ }
180
+ await this.#deleteState({ botId, config }).catch((error) => {
181
+ this.#logger.warn?.(
182
+ `[dsh-im:${this.#descriptor.key}] bot ${botId} state cleanup failed:`,
183
+ error,
184
+ );
185
+ });
186
+ this.#errors.delete(botId);
187
+ this.#touch();
188
+ });
189
+ return this.status();
190
+ }
191
+
192
+ status() {
193
+ const bots = this.#configStore.list().map((config) => {
194
+ const runtimeStatus = this.#runtimes.get(config.botId)?.status ?? null;
195
+ const connected = runtimeStatus?.ready === true
196
+ && runtimeStatus.connectionState === 'connected'
197
+ && runtimeStatus.harnessReachable === true;
198
+ const state = connected ? 'connected'
199
+ : runtimeStatus?.connectionState === 'connecting' ? 'connecting'
200
+ : this.#errors.has(config.botId) || runtimeStatus?.connectionState === 'failed'
201
+ ? 'error' : 'offline';
202
+ return {
203
+ botId: config.botId,
204
+ state,
205
+ connected,
206
+ configured: true,
207
+ bot: {
208
+ name: config.name,
209
+ username: config.username,
210
+ idMasked: this.#maskPlatformId(config.platformId),
211
+ },
212
+ health: {
213
+ status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline',
214
+ summary: connected ? `${this.#descriptor.label}${this.#descriptor.connectionLabel}运行正常`
215
+ : state === 'error' ? `${this.#descriptor.label}连接未就绪,插件会自动重试`
216
+ : `${this.#descriptor.label}连接当前离线`,
217
+ lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null,
218
+ lastConnectedAt: runtimeStatus?.lastConnectedAt ?? null,
219
+ },
220
+ stats: {
221
+ messagesReceived: runtimeStatus?.messagesReceived ?? 0,
222
+ messagesReplied: runtimeStatus?.messagesReplied ?? 0,
223
+ },
224
+ error: structuredClone(this.#errors.get(config.botId) ?? null),
225
+ };
226
+ });
227
+ const connectedCount = bots.filter((bot) => bot.connected).length;
228
+ return {
229
+ schemaVersion: 1,
230
+ revision: this.#revision,
231
+ state: bots.length === 0 ? 'disconnected'
232
+ : connectedCount === bots.length ? 'connected'
233
+ : connectedCount > 0 ? 'degraded' : 'offline',
234
+ bots,
235
+ totals: { configured: bots.length, connected: connectedCount },
236
+ };
237
+ }
238
+
239
+ async close() {
240
+ if (this.#closed) return;
241
+ this.#closed = true;
242
+ await Promise.allSettled([...this.#transitions.values()]);
243
+ await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
244
+ }
245
+
246
+ async #startRuntime(config, token) {
247
+ if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`);
248
+ await this.#stopRuntime(config.botId);
249
+ if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`);
250
+ const runtime = await this.#createRuntime({ botId: config.botId, config, token });
251
+ if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
252
+ throw new TypeError(`createRuntime returned an invalid ${this.#descriptor.label} runtime`);
253
+ }
254
+ this.#runtimes.set(config.botId, runtime);
255
+ try {
256
+ await runtime.start();
257
+ } catch (error) {
258
+ await runtime.stop().catch(() => undefined);
259
+ this.#runtimes.delete(config.botId);
260
+ throw error;
261
+ }
262
+ }
263
+
264
+ async #stopRuntime(botId) {
265
+ const runtime = this.#runtimes.get(botId);
266
+ this.#runtimes.delete(botId);
267
+ await runtime?.stop().catch((error) => {
268
+ this.#logger.warn?.(
269
+ `[dsh-im:${this.#descriptor.key}] bot ${botId} failed to stop cleanly:`,
270
+ error,
271
+ );
272
+ });
273
+ }
274
+
275
+ async #resolveToken(ref) {
276
+ const result = await this.#credentials.resolve(ref).catch(() => undefined);
277
+ return cleanString(result?.value);
278
+ }
279
+
280
+ async #restoreCredential(ref, previous) {
281
+ if (previous?.value) await this.#credentials.set(ref, previous.value).catch(() => undefined);
282
+ else await this.#credentials.unset(ref).catch(() => undefined);
283
+ }
284
+
285
+ #withBotTransition(botId, operation) {
286
+ const previous = this.#transitions.get(botId) ?? Promise.resolve();
287
+ const current = previous.catch(() => undefined).then(operation);
288
+ const settled = current.finally(() => {
289
+ if (this.#transitions.get(botId) === settled) this.#transitions.delete(botId);
290
+ });
291
+ this.#transitions.set(botId, settled);
292
+ return settled;
293
+ }
294
+
295
+ #touch() {
296
+ this.#revision += 1;
297
+ }
298
+ }
@@ -0,0 +1,180 @@
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
+ const EMPTY_DOCUMENT = Object.freeze({ version: 1, bots: Object.freeze([]) });
6
+
7
+ function cleanString(value) {
8
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
9
+ }
10
+
11
+ function escapePattern(value) {
12
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
13
+ }
14
+
15
+ export function deriveTokenBotIdentity(platformId, { botPrefix, tokenRefPrefix }) {
16
+ const raw = cleanString(platformId);
17
+ if (!raw) throw new TypeError('platformId is required');
18
+ const digest = createHash('sha256').update(raw).digest('hex').slice(0, 24);
19
+ return {
20
+ botId: `${botPrefix}_${digest}`,
21
+ tokenRef: `${tokenRefPrefix}_${digest.toUpperCase()}`,
22
+ };
23
+ }
24
+
25
+ export function maskPlatformId(platformId, fallback) {
26
+ const value = cleanString(platformId) ?? '';
27
+ if (value.length <= 10) return value ? `${value.slice(0, 3)}•••` : fallback;
28
+ return `${value.slice(0, 6)}••••${value.slice(-4)}`;
29
+ }
30
+
31
+ export class TokenBotConfigStore {
32
+ #path;
33
+ #channel;
34
+ #botPrefix;
35
+ #tokenRefPrefix;
36
+ #botIdPattern;
37
+ #tokenRefPattern;
38
+ #value = EMPTY_DOCUMENT;
39
+ #writeQueue = Promise.resolve();
40
+
41
+ constructor(path, { channel, botPrefix, tokenRefPrefix }) {
42
+ this.#path = path;
43
+ this.#channel = channel;
44
+ this.#botPrefix = botPrefix;
45
+ this.#tokenRefPrefix = tokenRefPrefix;
46
+ this.#botIdPattern = new RegExp(`^${escapePattern(botPrefix)}_[a-f0-9]{24}$`);
47
+ this.#tokenRefPattern = new RegExp(`^${escapePattern(tokenRefPrefix)}_[A-F0-9]{24}$`);
48
+ }
49
+
50
+ async load() {
51
+ try {
52
+ const normalized = this.#normalizeDocument(JSON.parse(await readFile(this.#path, 'utf8')));
53
+ if (!normalized) throw new Error(`dsh-im ${this.#channel} config contains invalid bot data`);
54
+ this.#value = normalized;
55
+ } catch (error) {
56
+ if (error?.code !== 'ENOENT') throw error;
57
+ this.#value = EMPTY_DOCUMENT;
58
+ }
59
+ return this;
60
+ }
61
+
62
+ list() {
63
+ return structuredClone(this.#value.bots);
64
+ }
65
+
66
+ get(botId) {
67
+ const bot = this.#value.bots.find((candidate) => candidate.botId === botId);
68
+ return bot ? structuredClone(bot) : null;
69
+ }
70
+
71
+ getByPlatformId(platformId) {
72
+ const bot = this.#value.bots.find((candidate) => candidate.platformId === platformId);
73
+ return bot ? structuredClone(bot) : null;
74
+ }
75
+
76
+ async save(value) {
77
+ const normalized = this.#normalizeBot(value);
78
+ if (!normalized) throw new Error(`Refusing to persist incomplete ${this.#channel} bot data`);
79
+ return this.#mutate((bots) => {
80
+ const identityCollision = bots.find(
81
+ (bot) => bot.platformId === normalized.platformId && bot.botId !== normalized.botId,
82
+ );
83
+ const refCollision = bots.find(
84
+ (bot) => bot.tokenRef === normalized.tokenRef && bot.botId !== normalized.botId,
85
+ );
86
+ if (identityCollision || refCollision) throw new Error(`Duplicate ${this.#channel} bot identity`);
87
+ const index = bots.findIndex((bot) => bot.botId === normalized.botId);
88
+ if (index === -1) bots.push(normalized);
89
+ else bots[index] = normalized;
90
+ return structuredClone(normalized);
91
+ });
92
+ }
93
+
94
+ async remove(botId) {
95
+ if (!this.#botIdPattern.test(botId)) throw new TypeError(`Invalid ${this.#channel} bot id`);
96
+ return this.#mutate((bots) => {
97
+ const index = bots.findIndex((bot) => bot.botId === botId);
98
+ if (index === -1) return null;
99
+ const [removed] = bots.splice(index, 1);
100
+ return structuredClone(removed);
101
+ });
102
+ }
103
+
104
+ async clear() {
105
+ const operation = this.#writeQueue.then(async () => {
106
+ try {
107
+ await unlink(this.#path);
108
+ } catch (error) {
109
+ if (error?.code !== 'ENOENT') throw error;
110
+ }
111
+ this.#value = EMPTY_DOCUMENT;
112
+ });
113
+ this.#writeQueue = operation.then(() => undefined, () => undefined);
114
+ await operation;
115
+ }
116
+
117
+ #normalizeBot(value) {
118
+ if (!value || typeof value !== 'object') return null;
119
+ const platformId = cleanString(value.platformId);
120
+ const botId = cleanString(value.botId);
121
+ const tokenRef = cleanString(value.tokenRef);
122
+ const name = cleanString(value.name);
123
+ if (!platformId || !botId || !tokenRef || !name
124
+ || !this.#botIdPattern.test(botId) || !this.#tokenRefPattern.test(tokenRef)) return null;
125
+ const derived = deriveTokenBotIdentity(platformId, {
126
+ botPrefix: this.#botPrefix,
127
+ tokenRefPrefix: this.#tokenRefPrefix,
128
+ });
129
+ if (derived.botId !== botId || derived.tokenRef !== tokenRef) return null;
130
+ return Object.freeze({
131
+ botId,
132
+ platformId,
133
+ tokenRef,
134
+ name,
135
+ username: cleanString(value.username),
136
+ createdAt: cleanString(value.createdAt) ?? new Date().toISOString(),
137
+ connectedAt: cleanString(value.connectedAt),
138
+ });
139
+ }
140
+
141
+ #normalizeDocument(value) {
142
+ if (!value || value.version !== 1 || !Array.isArray(value.bots)) return null;
143
+ const bots = value.bots.map((bot) => this.#normalizeBot(bot));
144
+ if (bots.some((bot) => bot === null)) return null;
145
+ const ids = new Set();
146
+ const platformIds = new Set();
147
+ const refs = new Set();
148
+ for (const bot of bots) {
149
+ if (ids.has(bot.botId) || platformIds.has(bot.platformId) || refs.has(bot.tokenRef)) return null;
150
+ ids.add(bot.botId);
151
+ platformIds.add(bot.platformId);
152
+ refs.add(bot.tokenRef);
153
+ }
154
+ return Object.freeze({ version: 1, bots: Object.freeze(bots) });
155
+ }
156
+
157
+ async #mutate(mutator) {
158
+ let result;
159
+ const operation = this.#writeQueue.then(async () => {
160
+ const bots = [...this.#value.bots];
161
+ result = mutator(bots);
162
+ const document = Object.freeze({ version: 1, bots: Object.freeze(bots) });
163
+ await this.#write(document);
164
+ this.#value = document;
165
+ });
166
+ this.#writeQueue = operation.then(() => undefined, () => undefined);
167
+ await operation;
168
+ return result;
169
+ }
170
+
171
+ async #write(document) {
172
+ await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
173
+ const temporary = `${this.#path}.tmp`;
174
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
175
+ encoding: 'utf8',
176
+ mode: 0o600,
177
+ });
178
+ await rename(temporary, this.#path);
179
+ }
180
+ }
@@ -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: 'telegram',
9
+ tokenRefPrefix: 'DSH_TELEGRAM_BOT_TOKEN',
10
+ });
11
+
12
+ export function deriveTelegramBotIdentity(platformId) {
13
+ return deriveTokenBotIdentity(platformId, IDENTITY_OPTIONS);
14
+ }
15
+
16
+ export function maskTelegramBotId(platformId) {
17
+ return maskPlatformId(platformId, 'Telegram机器人');
18
+ }
19
+
20
+ export class TelegramConfigStore extends TokenBotConfigStore {
21
+ constructor(path) {
22
+ super(path, { channel: 'Telegram', ...IDENTITY_OPTIONS });
23
+ }
24
+ }
@@ -0,0 +1,3 @@
1
+ import { HarnessClient } from '../weixin/harness-client.mjs';
2
+
3
+ export class TelegramHarnessClient extends HarnessClient {}
@@ -0,0 +1,3 @@
1
+ import { ConversationStateStore } from '../shared/conversation-state-store.mjs';
2
+
3
+ export class TelegramStateStore extends ConversationStateStore {}
@@ -0,0 +1,120 @@
1
+ const DEFAULT_BASE_URL = 'https://api.telegram.org/';
2
+
3
+ function cleanString(value) {
4
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
5
+ }
6
+
7
+ function requestSignal(signal, timeoutMs) {
8
+ const timeout = AbortSignal.timeout(timeoutMs);
9
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
10
+ }
11
+
12
+ export function validTelegramToken(value) {
13
+ return typeof value === 'string' && /^\d{5,20}:[A-Za-z0-9_-]{20,}$/.test(value.trim());
14
+ }
15
+
16
+ export class TelegramApi {
17
+ #token;
18
+ #fetch;
19
+ #baseUrl;
20
+
21
+ constructor({ token, fetchImpl = fetch, baseUrl = DEFAULT_BASE_URL }) {
22
+ if (!validTelegramToken(token)) throw new TypeError('Telegram Bot Token is invalid');
23
+ if (typeof fetchImpl !== 'function') throw new TypeError('TelegramApi requires fetch');
24
+ this.#token = token.trim();
25
+ this.#fetch = fetchImpl;
26
+ this.#baseUrl = new URL(baseUrl);
27
+ }
28
+
29
+ async getMe(options = {}) {
30
+ return this.#call('getMe', {}, options);
31
+ }
32
+
33
+ async getWebhookInfo(options = {}) {
34
+ return this.#call('getWebhookInfo', {}, options);
35
+ }
36
+
37
+ async getUpdates({ offset, timeout = 25, signal } = {}) {
38
+ const payload = {
39
+ timeout,
40
+ limit: 100,
41
+ allowed_updates: ['message'],
42
+ ...(Number.isSafeInteger(offset) ? { offset } : {}),
43
+ };
44
+ return this.#call('getUpdates', payload, {
45
+ signal,
46
+ timeoutMs: Math.max(10_000, (timeout + 10) * 1_000),
47
+ });
48
+ }
49
+
50
+ async sendMessage({ chatId, text, replyToMessageId, messageThreadId, signal }) {
51
+ return this.#call('sendMessage', {
52
+ chat_id: chatId,
53
+ text,
54
+ link_preview_options: { is_disabled: true },
55
+ ...(replyToMessageId ? {
56
+ reply_parameters: { message_id: replyToMessageId, allow_sending_without_reply: true },
57
+ } : {}),
58
+ ...(messageThreadId ? { message_thread_id: messageThreadId } : {}),
59
+ }, { signal });
60
+ }
61
+
62
+ async editMessageText({ chatId, messageId, text, signal }) {
63
+ return this.#call('editMessageText', {
64
+ chat_id: chatId,
65
+ message_id: messageId,
66
+ text,
67
+ link_preview_options: { is_disabled: true },
68
+ }, { signal });
69
+ }
70
+
71
+ async sendChatAction({ chatId, messageThreadId, signal }) {
72
+ return this.#call('sendChatAction', {
73
+ chat_id: chatId,
74
+ action: 'typing',
75
+ ...(messageThreadId ? { message_thread_id: messageThreadId } : {}),
76
+ }, { signal });
77
+ }
78
+
79
+ async #call(method, payload, { signal, timeoutMs = 15_000 } = {}) {
80
+ const url = new URL(this.#baseUrl);
81
+ url.pathname = `${url.pathname.replace(/\/$/, '')}/bot${this.#token}/${method}`;
82
+ let response;
83
+ try {
84
+ response = await this.#fetch(url, {
85
+ method: 'POST',
86
+ headers: { 'content-type': 'application/json' },
87
+ body: JSON.stringify(payload),
88
+ signal: requestSignal(signal, timeoutMs),
89
+ redirect: 'error',
90
+ });
91
+ } catch (error) {
92
+ if (error?.name === 'AbortError' || error?.name === 'TimeoutError') throw error;
93
+ throw new Error(`Telegram ${method} transport failed`);
94
+ }
95
+ let body;
96
+ try {
97
+ body = await response.json();
98
+ } catch {
99
+ throw new Error(`Telegram ${method} returned invalid JSON`);
100
+ }
101
+ if (!response.ok || body?.ok !== true) {
102
+ const description = cleanString(body?.description);
103
+ const error = new Error(description ?? `Telegram ${method} failed`);
104
+ error.code = Number.isInteger(body?.error_code) ? `telegram-${body.error_code}` : 'telegram-api-error';
105
+ throw error;
106
+ }
107
+ return body.result;
108
+ }
109
+ }
110
+
111
+ export async function inspectTelegramToken(token, options = {}) {
112
+ const api = new TelegramApi({ token, ...options });
113
+ const bot = await api.getMe();
114
+ if (!bot?.id || bot?.is_bot !== true) throw new Error('Telegram token does not belong to a bot');
115
+ return {
116
+ platformId: String(bot.id),
117
+ name: cleanString([bot.first_name, bot.last_name].filter(Boolean).join(' ')) ?? 'Telegram机器人',
118
+ username: cleanString(bot.username),
119
+ };
120
+ }
@@ -0,0 +1,15 @@
1
+ import { TextHarnessBridge, createTextBridgeStatus } from '../shared/text-harness-bridge.mjs';
2
+
3
+ export const TELEGRAM_DESCRIPTOR = Object.freeze({
4
+ key: 'telegram',
5
+ label: 'Telegram',
6
+ connectionLabel: ' Bot API 长轮询',
7
+ });
8
+
9
+ export class TelegramHarnessBridge extends TextHarnessBridge {
10
+ constructor(options) {
11
+ super({ descriptor: TELEGRAM_DESCRIPTOR, ...options });
12
+ }
13
+ }
14
+
15
+ export { createTextBridgeStatus as createTelegramBridgeStatus };
@@ -0,0 +1,16 @@
1
+ import { TokenBotController } from '../shared/token-bot-controller.mjs';
2
+ import { deriveTelegramBotIdentity, maskTelegramBotId } from './config-store.mjs';
3
+ import { inspectTelegramToken } from './telegram-api.mjs';
4
+ import { TELEGRAM_DESCRIPTOR } from './telegram-bridge.mjs';
5
+
6
+ export class TelegramController extends TokenBotController {
7
+ constructor(options) {
8
+ super({
9
+ ...options,
10
+ descriptor: TELEGRAM_DESCRIPTOR,
11
+ inspectToken: options.inspectToken ?? inspectTelegramToken,
12
+ deriveIdentity: deriveTelegramBotIdentity,
13
+ maskPlatformId: maskTelegramBotId,
14
+ });
15
+ }
16
+ }