@wanghaopeng1148/deskpet 2.0.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 (76) hide show
  1. package/README.md +394 -0
  2. package/bin/deskpet.mjs +142 -0
  3. package/dist/web/assets/DashboardView-BLXa7VrZ.css +1 -0
  4. package/dist/web/assets/DashboardView-CTyWcLtQ.js +60 -0
  5. package/dist/web/assets/SettingsView-7c3RiRrt.js +1 -0
  6. package/dist/web/assets/SettingsView-BkL0OpZC.css +1 -0
  7. package/dist/web/assets/TasksView-BTK1OTwU.css +1 -0
  8. package/dist/web/assets/TasksView-s1MaR5sZ.js +5 -0
  9. package/dist/web/assets/ToolsView-B8V-A1j_.js +178 -0
  10. package/dist/web/assets/ToolsView-DGLJATQ9.css +1 -0
  11. package/dist/web/assets/WeChatView-ChLBhPso.js +1 -0
  12. package/dist/web/assets/WeChatView-CvdhJ05E.css +1 -0
  13. package/dist/web/assets/browser-CjSdxGTc.js +8 -0
  14. package/dist/web/assets/dashboard-DJ_Miuzx.js +93 -0
  15. package/dist/web/assets/dashboard-Fbcagzwx.css +1 -0
  16. package/dist/web/dashboard/index.html +13 -0
  17. package/package.json +71 -0
  18. package/resources/icons/tray.png +0 -0
  19. package/resources/icons/tray@2x.png +0 -0
  20. package/resources/previews/busy.png +0 -0
  21. package/resources/previews/click.png +0 -0
  22. package/resources/previews/hover.png +0 -0
  23. package/resources/previews/idle.png +0 -0
  24. package/resources/previews/preview.png +0 -0
  25. package/resources/previews/sleep.png +0 -0
  26. package/resources/skins/default_cute/pet.json +7 -0
  27. package/resources/skins/default_cute/spritesheet.webp +0 -0
  28. package/resources/skins/default_cute/submission.json +29 -0
  29. package/server/db/database.ts +118 -0
  30. package/server/db/migrate-legacy.ts +121 -0
  31. package/server/db/task-repository.ts +585 -0
  32. package/server/http/http-server.ts +725 -0
  33. package/server/http/ws-hub.ts +66 -0
  34. package/server/main.ts +322 -0
  35. package/server/plugins/actions/builtin.ts +73 -0
  36. package/server/plugins/actions/clipboard-watch.ts +38 -0
  37. package/server/plugins/actions/http-request.ts +53 -0
  38. package/server/plugins/actions/jenkins-build.ts +209 -0
  39. package/server/plugins/actions/open-app.ts +40 -0
  40. package/server/plugins/actions/python-script.ts +187 -0
  41. package/server/plugins/actions/screenshot.ts +41 -0
  42. package/server/plugins/actions/send-keystroke.ts +103 -0
  43. package/server/plugins/actions/show-reminder.ts +16 -0
  44. package/server/plugins/actions/ssh-command.ts +148 -0
  45. package/server/plugins/actions/task-chain.ts +30 -0
  46. package/server/plugins/actions/volume-control.ts +35 -0
  47. package/server/plugins/index.ts +37 -0
  48. package/server/plugins/registry.ts +72 -0
  49. package/server/services/clipboard-watcher.ts +126 -0
  50. package/server/services/config-store.ts +152 -0
  51. package/server/services/idle-monitor.ts +146 -0
  52. package/server/services/notifier.ts +54 -0
  53. package/server/services/quick-actions-store.ts +54 -0
  54. package/server/services/remote-connector.ts +75 -0
  55. package/server/services/scanner-reader.ts +257 -0
  56. package/server/services/script-runner.ts +263 -0
  57. package/server/services/snapshot-service.ts +196 -0
  58. package/server/services/task-scheduler.ts +900 -0
  59. package/server/services/wechat-bot.ts +744 -0
  60. package/server/services/wechat-command-types.ts +15 -0
  61. package/server/services/wechat-commands.ts +367 -0
  62. package/server/suppress-warnings.ts +10 -0
  63. package/server/utils/asset-url.ts +27 -0
  64. package/server/utils/auto-start.ts +90 -0
  65. package/server/utils/clipboard.ts +50 -0
  66. package/server/utils/dashboard-url.ts +9 -0
  67. package/server/utils/instance-guard.ts +170 -0
  68. package/server/utils/native-notify.ts +68 -0
  69. package/server/utils/open.ts +38 -0
  70. package/server/utils/paths.ts +72 -0
  71. package/server/utils/python-interpreter.ts +154 -0
  72. package/shared/animation-engine.ts +422 -0
  73. package/shared/chain-condition.ts +60 -0
  74. package/shared/cron-weekly.ts +131 -0
  75. package/shared/py-task-params.ts +356 -0
  76. package/shared/types.ts +309 -0
@@ -0,0 +1,744 @@
1
+ /**
2
+ * 微信 Bot — iLink Bot API 移植(对齐旧版 wechat_bot.py 44KB)
3
+ *
4
+ * 能力: 扫码登录(二维码自动刷新) / sendmessage 文本推送(自动拆条) /
5
+ * getupdates 长轮询(收消息+维持 context_token) / 会话过期自动重连 / 看门狗
6
+ *
7
+ * 协议要点(实测经验,勿改):
8
+ * - 文本项不渲染 \n,用垂直制表符 \u000b 作为换行符
9
+ * - ret/errcode == -14 表示会话过期,需重连
10
+ * - context_token 只能靠用户主动发消息刷新,机器人无法主动续期
11
+ */
12
+ import { EventEmitter } from 'node:events'
13
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
14
+ import type { WechatSettings } from '../../shared/types.ts'
15
+
16
+ // ── 协议常量 ────────────────────────────────────────────────
17
+
18
+ const ILINK_BASE_URL = 'https://ilinkai.weixin.qq.com'
19
+ const CHANNEL_VERSION = '2.2.0'
20
+ const ILINK_APP_CLIENT_VERSION = (2 << 16) | (2 << 8) | 0 // 131584
21
+
22
+ const LONG_POLL_TIMEOUT_MS = 40_000
23
+ const API_TIMEOUT_MS = 15_000
24
+ const QR_FETCH_TIMEOUT_MS = 30_000
25
+ const QR_POLL_INTERVAL_MS = 1_000
26
+ const QR_EXPIRE_MAX = 3
27
+ const QR_LOGIN_TIMEOUT_MS = 300_000
28
+ const MSG_MAX_CHARS = 1800
29
+
30
+ const HEARTBEAT_CHECK_INTERVAL_MS = 60_000
31
+ const HEARTBEAT_STALE_THRESHOLD_MS = 120_000
32
+ const MAX_RECONNECT_ATTEMPTS = 3
33
+ const RECONNECT_DELAY_MS = 5_000
34
+
35
+ export type WechatLoginStatus =
36
+ | 'idle'
37
+ | 'fetching'
38
+ | 'waiting'
39
+ | 'scanned'
40
+ | 'refreshed'
41
+ | 'confirmed'
42
+ | 'failed'
43
+ | 'cancelled'
44
+ | 'timeout'
45
+
46
+ export const LOGIN_STATUS_TEXT: Record<WechatLoginStatus, string> = {
47
+ idle: '未连接',
48
+ fetching: '正在获取二维码…',
49
+ waiting: '请用微信扫描二维码',
50
+ scanned: '已扫码,请在手机上确认',
51
+ refreshed: '二维码已过期,已自动刷新',
52
+ confirmed: '已连接微信',
53
+ failed: '连接失败',
54
+ cancelled: '已取消',
55
+ timeout: '扫码超时,请重试'
56
+ }
57
+
58
+ /** iLink Bot 凭据与会话状态(持久化到 userData/config/wechat.json) */
59
+ export interface WechatCredentials {
60
+ botToken: string
61
+ botId: string
62
+ userId: string
63
+ /** 最近发消息的用户 → 推送目标 */
64
+ pushTarget: string
65
+ /** 用户 → 最近 context_token(推送会话凭证) */
66
+ contextTokens: Record<string, string>
67
+ /** 用户 → context_token 捕获时间戳(用于判断 24h 有效期) */
68
+ contextTokenTs: Record<string, number>
69
+ syncBuf: string
70
+ conversationConfirmed: boolean
71
+ linkedAt: string
72
+ baseUrl: string
73
+ }
74
+
75
+ function emptyCredentials(): WechatCredentials {
76
+ return {
77
+ botToken: '',
78
+ botId: '',
79
+ userId: '',
80
+ pushTarget: '',
81
+ contextTokens: {},
82
+ contextTokenTs: {},
83
+ syncBuf: '',
84
+ conversationConfirmed: false,
85
+ linkedAt: '',
86
+ baseUrl: ILINK_BASE_URL
87
+ }
88
+ }
89
+
90
+ export interface WechatStatus {
91
+ status: WechatLoginStatus
92
+ statusText: string
93
+ connected: boolean
94
+ sessionAlive: boolean
95
+ listening: boolean
96
+ botId: string
97
+ pushTarget: string
98
+ linkedAt: string
99
+ qrContent: string | null
100
+ }
101
+
102
+ /** fetch 注入点(测试替身) */
103
+ export type FetchLike = (url: string, init?: { method?: string; headers?: Record<string, string>; body?: string; signal?: AbortSignal }) => Promise<{ ok: boolean; status: number; json: () => Promise<unknown> }>
104
+
105
+ /** 拆条:超长消息按段落/长度切分 */
106
+ export function splitMessage(text: string, maxChars = MSG_MAX_CHARS): string[] {
107
+ if (text.length <= maxChars) return [text]
108
+ const chunks: string[] = []
109
+ let current = ''
110
+ for (const line of text.split('\n')) {
111
+ if ((current + line + '\n').length > maxChars && current) {
112
+ chunks.push(current.trimEnd())
113
+ current = ''
114
+ }
115
+ if (line.length > maxChars) {
116
+ // 单行超长硬切
117
+ for (let i = 0; i < line.length; i += maxChars) {
118
+ chunks.push(line.slice(i, i + maxChars))
119
+ }
120
+ } else {
121
+ current += line + '\n'
122
+ }
123
+ }
124
+ if (current.trim()) chunks.push(current.trimEnd())
125
+ return chunks
126
+ }
127
+
128
+ /** 提取文本消息内容(item_list → text_item.text) */
129
+ export function extractText(msg: Record<string, unknown>): string {
130
+ const items = msg['item_list'] as Array<Record<string, unknown>> | undefined
131
+ if (!Array.isArray(items)) return ''
132
+ const parts: string[] = []
133
+ for (const item of items) {
134
+ const textItem = item['text_item'] as Record<string, unknown> | undefined
135
+ const t = textItem?.['text']
136
+ if (typeof t === 'string' && t.trim()) parts.push(t.trim())
137
+ }
138
+ return parts.join('\n')
139
+ }
140
+
141
+ export class WechatBot extends EventEmitter {
142
+ private creds: WechatCredentials = emptyCredentials()
143
+ private loginStatus: WechatLoginStatus = 'idle'
144
+ private loginAbort: AbortController | null = null
145
+ private listenStop = false
146
+ private listening = false
147
+ private sessionAlive = false
148
+ private lastPollAt = 0
149
+ private reconnectAttempts = 0
150
+ private watchdog: ReturnType<typeof setInterval> | null = null
151
+ private commandHandler: ((text: string) => string | null) | null = null
152
+ /** 当前登录二维码内容(供 UI 渲染) */
153
+ private qrContent: string | null = null
154
+ private readonly fetchImpl: FetchLike
155
+ private credsPath: string
156
+ private getSettings: () => WechatSettings
157
+
158
+ constructor(credsPath: string, getSettings: () => WechatSettings, fetchImpl?: FetchLike) {
159
+ super()
160
+ this.credsPath = credsPath
161
+ this.getSettings = getSettings
162
+ this.fetchImpl = fetchImpl ?? (fetch as unknown as FetchLike)
163
+ this.load()
164
+ }
165
+
166
+ // ── 凭据持久化 ──────────────────────────────────────────
167
+
168
+ private load(): void {
169
+ try {
170
+ if (!existsSync(this.credsPath)) return
171
+ const raw = JSON.parse(readFileSync(this.credsPath, 'utf-8')) as Record<string, unknown>
172
+ this.creds = {
173
+ botToken: String(raw['bot_token'] ?? ''),
174
+ botId: String(raw['bot_id'] ?? ''),
175
+ userId: String(raw['user_id'] ?? ''),
176
+ pushTarget: String(raw['push_target'] ?? ''),
177
+ contextTokens: (raw['context_tokens'] as Record<string, string>) ?? {},
178
+ contextTokenTs: (raw['context_token_ts'] as Record<string, number>) ?? {},
179
+ syncBuf: String(raw['sync_buf'] ?? ''),
180
+ conversationConfirmed: !!raw['conversation_confirmed'],
181
+ linkedAt: String(raw['linked_at'] ?? ''),
182
+ baseUrl: String(raw['base_url'] ?? '') || ILINK_BASE_URL
183
+ }
184
+ } catch (err) {
185
+ console.error('[wechat] 凭据读取失败:', err)
186
+ }
187
+ }
188
+
189
+ private save(): void {
190
+ try {
191
+ const c = this.creds
192
+ writeFileSync(
193
+ this.credsPath,
194
+ JSON.stringify(
195
+ {
196
+ bot_token: c.botToken,
197
+ bot_id: c.botId,
198
+ user_id: c.userId,
199
+ push_target: c.pushTarget,
200
+ context_tokens: c.contextTokens,
201
+ context_token_ts: c.contextTokenTs,
202
+ sync_buf: c.syncBuf,
203
+ conversation_confirmed: c.conversationConfirmed,
204
+ linked_at: c.linkedAt,
205
+ base_url: c.baseUrl
206
+ },
207
+ null,
208
+ 2
209
+ ),
210
+ 'utf-8'
211
+ )
212
+ } catch (err) {
213
+ console.error('[wechat] 凭据写入失败:', err)
214
+ }
215
+ }
216
+
217
+ // ── 状态 ────────────────────────────────────────────────
218
+
219
+ get connected(): boolean {
220
+ return !!this.creds.botToken
221
+ }
222
+
223
+ get sessionAliveFlag(): boolean {
224
+ return this.sessionAlive
225
+ }
226
+
227
+ get isListening(): boolean {
228
+ return this.listening
229
+ }
230
+
231
+ get currentQrContent(): string | null {
232
+ return this.qrContent
233
+ }
234
+
235
+ getStatus(): WechatStatus {
236
+ return {
237
+ status: this.loginStatus,
238
+ statusText: LOGIN_STATUS_TEXT[this.loginStatus],
239
+ connected: this.connected,
240
+ sessionAlive: this.sessionAlive,
241
+ listening: this.listening,
242
+ botId: this.creds.botId,
243
+ pushTarget: this.creds.pushTarget,
244
+ linkedAt: this.creds.linkedAt,
245
+ qrContent: this.qrContent
246
+ }
247
+ }
248
+
249
+ private setStatus(status: WechatLoginStatus, extra = ''): void {
250
+ this.loginStatus = status
251
+ this.emit('status', { status, text: extra || LOGIN_STATUS_TEXT[status] })
252
+ }
253
+
254
+ setCommandHandler(handler: (text: string) => string | null): void {
255
+ this.commandHandler = handler
256
+ }
257
+
258
+ // ── HTTP 基础 ───────────────────────────────────────────
259
+
260
+ private anonHeaders(): Record<string, string> {
261
+ return {
262
+ 'iLink-App-Id': 'bot',
263
+ 'iLink-App-ClientVersion': String(ILINK_APP_CLIENT_VERSION)
264
+ }
265
+ }
266
+
267
+ private authHeaders(token: string): Record<string, string> {
268
+ // X-WECHAT-UIN: 随机 uin 的 base64(对齐旧版实现)
269
+ const uin = Buffer.from(String(Math.floor(Math.random() * 0xffffffff))).toString('base64')
270
+ return {
271
+ 'Content-Type': 'application/json',
272
+ AuthorizationType: 'ilink_bot_token',
273
+ 'X-WECHAT-UIN': uin,
274
+ 'iLink-App-Id': 'bot',
275
+ 'iLink-App-ClientVersion': String(ILINK_APP_CLIENT_VERSION),
276
+ Authorization: `Bearer ${token}`
277
+ }
278
+ }
279
+
280
+ private withBaseInfo<T extends object>(payload: T): T & { base_info: { channel_version: string } } {
281
+ return { ...payload, base_info: { channel_version: CHANNEL_VERSION } } as T & {
282
+ base_info: { channel_version: string }
283
+ }
284
+ }
285
+
286
+ private async getJson(url: string, headers: Record<string, string>, timeoutMs: number): Promise<Record<string, unknown>> {
287
+ const resp = await this.fetchImpl(url, {
288
+ method: 'GET',
289
+ headers,
290
+ signal: AbortSignal.timeout(timeoutMs)
291
+ })
292
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${url}`)
293
+ return (await resp.json()) as Record<string, unknown>
294
+ }
295
+
296
+ private async postJson(
297
+ url: string,
298
+ headers: Record<string, string>,
299
+ body: unknown,
300
+ timeoutMs: number
301
+ ): Promise<Record<string, unknown>> {
302
+ const resp = await this.fetchImpl(url, {
303
+ method: 'POST',
304
+ headers,
305
+ body: JSON.stringify(body),
306
+ signal: AbortSignal.timeout(timeoutMs)
307
+ })
308
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${url}`)
309
+ return (await resp.json()) as Record<string, unknown>
310
+ }
311
+
312
+ // ── 扫码登录 ────────────────────────────────────────────
313
+
314
+ private async fetchQrcode(baseUrl: string): Promise<{ qrId: string; content: string }> {
315
+ const data = await this.getJson(
316
+ `${baseUrl}/ilink/bot/get_bot_qrcode?bot_type=3`,
317
+ this.anonHeaders(),
318
+ QR_FETCH_TIMEOUT_MS
319
+ )
320
+ const qrId = String(data['qrcode'] ?? '').trim()
321
+ const content = String(data['qrcode_img_content'] ?? '').trim() || qrId
322
+ if (!qrId) throw new Error(`获取二维码失败: ${JSON.stringify(data)}`)
323
+ return { qrId, content }
324
+ }
325
+
326
+ private async pollQrStatus(baseUrl: string, qrId: string): Promise<Record<string, unknown>> {
327
+ return this.getJson(
328
+ `${baseUrl}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrId)}`,
329
+ this.anonHeaders(),
330
+ QR_FETCH_TIMEOUT_MS
331
+ )
332
+ }
333
+
334
+ /** 发起扫码登录(后台轮询,状态经 'status' 事件通知) */
335
+ startLogin(): boolean {
336
+ if (this.loginAbort) {
337
+ console.warn('[wechat] 已有登录流程在进行中')
338
+ return false
339
+ }
340
+ this.loginAbort = new AbortController()
341
+ void this.loginWorker(this.loginAbort.signal)
342
+ return true
343
+ }
344
+
345
+ cancelLogin(): void {
346
+ this.loginAbort?.abort()
347
+ this.loginAbort = null
348
+ if (this.loginStatus !== 'confirmed') this.setStatus('cancelled')
349
+ }
350
+
351
+ /**
352
+ * 应用启动时用已持久化的凭据自动恢复会话,无需重新扫码。
353
+ * 仅当存在有效 botToken 且当前未在监听时生效;若 token 已过期,
354
+ * 监听循环会收到 -14 并自动清空凭据、提示重新扫码。
355
+ */
356
+ resume(): boolean {
357
+ if (!this.connected || this.listening) return false
358
+ // iLink 的 context_token 约 24h 有效,且已持久化到磁盘。
359
+ // 只要磁盘上仍保存着该推送目标的令牌,就乐观认为会话活跃——重启后无需用户先发消息即可推送;
360
+ // 若令牌实际已过期,首次推送会返回 -2/-14,由 sendText 捕获并纠正状态。
361
+ if (this.creds.pushTarget && this.creds.contextTokens[this.creds.pushTarget]) {
362
+ this.sessionAlive = true
363
+ }
364
+ this.loginStatus = 'confirmed'
365
+ this.setStatus('confirmed', '已用本地凭据恢复连接')
366
+ this.startListening()
367
+ return true
368
+ }
369
+
370
+
371
+ private async loginWorker(signal: AbortSignal): Promise<void> {
372
+ const startedAt = Date.now()
373
+ let refreshCount = 0
374
+ let lastQrStatus = ''
375
+ try {
376
+ this.setStatus('fetching')
377
+ let { qrId, content } = await this.fetchQrcode(ILINK_BASE_URL)
378
+ this.qrContent = content
379
+ this.setStatus('waiting')
380
+ this.emit('qrcode', content)
381
+
382
+ while (!signal.aborted) {
383
+ if (Date.now() - startedAt > QR_LOGIN_TIMEOUT_MS) {
384
+ this.setStatus('timeout')
385
+ this.finishLogin(null)
386
+ return
387
+ }
388
+ const result = await this.pollQrStatus(ILINK_BASE_URL, qrId)
389
+ const status = String(result['status'] ?? 'wait').trim()
390
+
391
+ if (status === 'scaned' || status === 'scaned_but_redirect') {
392
+ if (lastQrStatus !== status) this.setStatus('scanned')
393
+ } else if (status === 'expired') {
394
+ refreshCount++
395
+ if (refreshCount > QR_EXPIRE_MAX) {
396
+ this.setStatus('timeout')
397
+ this.finishLogin(null)
398
+ return
399
+ }
400
+ const next = await this.fetchQrcode(ILINK_BASE_URL)
401
+ qrId = next.qrId
402
+ content = next.content
403
+ this.qrContent = content
404
+ this.setStatus('refreshed', `二维码已过期,已自动刷新 (${refreshCount}/${QR_EXPIRE_MAX})`)
405
+ this.emit('qrcode', content)
406
+ } else if (status === 'confirmed') {
407
+ const token = String(result['bot_token'] ?? '').trim()
408
+ const botId = String(result['ilink_bot_id'] ?? '').trim()
409
+ const userId = String(result['ilink_user_id'] ?? '').trim()
410
+ const confirmedBase = String(result['baseurl'] ?? '').trim() || ILINK_BASE_URL
411
+ if (!token || !botId) {
412
+ this.setStatus('failed')
413
+ this.finishLogin(new Error('登录已确认但返回凭据不完整'))
414
+ return
415
+ }
416
+ this.creds = {
417
+ ...emptyCredentials(),
418
+ botToken: token,
419
+ botId,
420
+ userId,
421
+ baseUrl: confirmedBase,
422
+ linkedAt: new Date().toLocaleString('zh-CN', { hour12: false })
423
+ }
424
+ this.sessionAlive = false
425
+ this.save()
426
+ this.setStatus('confirmed')
427
+ this.finishLogin(null)
428
+ this.startListening()
429
+ return
430
+ }
431
+ lastQrStatus = status
432
+ await sleep(QR_POLL_INTERVAL_MS, signal)
433
+ }
434
+ // aborted
435
+ } catch (err) {
436
+ if (signal.aborted) return
437
+ console.error('[wechat] 登录异常:', err)
438
+ this.setStatus('failed', `连接失败: ${String(err)}`)
439
+ this.finishLogin(err instanceof Error ? err : new Error(String(err)))
440
+ }
441
+ }
442
+
443
+ private finishLogin(_err: Error | null): void {
444
+ this.loginAbort = null
445
+ }
446
+
447
+ // ── 消息发送 ────────────────────────────────────────────
448
+
449
+ private async sendOnce(toUserId: string, text: string, contextToken: string | undefined): Promise<void> {
450
+ const msg: Record<string, unknown> = {
451
+ from_user_id: '',
452
+ to_user_id: toUserId,
453
+ client_id: `deskpet-${Math.random().toString(16).slice(2, 14)}`,
454
+ message_type: 2,
455
+ message_state: 2,
456
+ // 微信(iLink)文本项不把 \n 渲染为换行,用 \u000b 作为换行符(实测可正确分行)
457
+ item_list: [
458
+ {
459
+ type: 1,
460
+ text_item: { text: text.replace(/\r\n/g, '\u000b').replace(/\n/g, '\u000b') }
461
+ }
462
+ ]
463
+ }
464
+ if (contextToken) msg['context_token'] = contextToken
465
+
466
+ const data = await this.postJson(
467
+ `${this.creds.baseUrl}/ilink/bot/sendmessage`,
468
+ this.authHeaders(this.creds.botToken),
469
+ this.withBaseInfo({ msg }),
470
+ API_TIMEOUT_MS
471
+ )
472
+ const ret = Number(data['ret'] ?? 0)
473
+ const errcode = Number(data['errcode'] ?? 0)
474
+ if (ret !== 0 || errcode !== 0) {
475
+ throw new Error(JSON.stringify(data))
476
+ }
477
+ }
478
+
479
+ /** 发送文本(自动拆条;返回 {ok, message}) */
480
+ async sendText(text: string, toUserId?: string): Promise<{ ok: boolean; message: string }> {
481
+ if (!this.connected) return { ok: false, message: '微信未连接' }
482
+ const target = (toUserId ?? this.creds.pushTarget ?? '').trim()
483
+ if (!target) {
484
+ return {
485
+ ok: false,
486
+ message: '没有可用的推送目标:请先在微信里给机器人发一条消息(如「你好」)建立会话'
487
+ }
488
+ }
489
+ // iLink 协议:必须持有该用户的 context_token 才能推送(用户先发消息才会下发)。
490
+ // 该令牌已持久化到磁盘,24h 内重启后可直接复用,无需用户再次发消息。
491
+ const ctx = this.creds.contextTokens[target]
492
+ if (!ctx) {
493
+ return {
494
+ ok: false,
495
+ message: '尚无该用户的会话令牌:iLink 限制 bot 不能主动发起对话,请先在微信里给机器人发一条消息(如「你好」)'
496
+ }
497
+ }
498
+ const chunks = splitMessage(text)
499
+
500
+ for (let i = 0; i < chunks.length; i++) {
501
+ try {
502
+ await this.sendOnce(target, chunks[i], ctx)
503
+ } catch (err) {
504
+ // context_token 过期 / 登录会话失效 → 标记会话失效,等待用户重新发消息刷新
505
+ this.sessionAlive = false
506
+ const reason = this.classifySendError(err)
507
+ console.warn('[wechat] 推送失败:', reason)
508
+ return { ok: false, message: reason }
509
+ }
510
+ if (i < chunks.length - 1) await sleep(400)
511
+ }
512
+ this.sessionAlive = true
513
+ return { ok: true, message: '发送成功' }
514
+ }
515
+
516
+ /** 把推送异常归类成可读提示(区分登录会话过期与 context_token 过期/超限) */
517
+ private classifySendError(err: unknown): string {
518
+ const raw = err instanceof Error ? err.message : String(err)
519
+ try {
520
+ const data = JSON.parse(raw) as Record<string, unknown>
521
+ const ret = Number(data['ret'] ?? 0)
522
+ const errcode = Number(data['errcode'] ?? 0)
523
+ if (ret === -14 || errcode === -14) {
524
+ return '微信登录会话已过期,请到设置页重新扫码连接'
525
+ }
526
+ if (ret === -2 || errcode === -2) {
527
+ return '微信推送令牌已过期或达每日上限:请在微信里给机器人发一条消息(如「你好」)刷新会话后即可恢复推送'
528
+ }
529
+ } catch {
530
+ /* 非 JSON 错误(网络等),走通用文案 */
531
+ }
532
+ return `微信推送失败:${raw.slice(0, 200)}`
533
+ }
534
+
535
+ async sendTestMessage(): Promise<{ ok: boolean; message: string }> {
536
+ const now = new Date().toLocaleString('zh-CN', { hour12: false })
537
+ return this.sendText(`🐾 DeskPet 测试消息\n时间:${now}\n收到即说明推送链路正常`)
538
+ }
539
+
540
+ /** 任务结果推送(按设置过滤) */
541
+ async notifyTaskResult(taskName: string, success: boolean, detail = ''): Promise<void> {
542
+ const s = this.getSettings()
543
+ if (success && !s.notifyOnTaskComplete) return
544
+ if (!success && !s.notifyOnTaskFailed) return
545
+ const icon = success ? '✅' : '❌'
546
+ const lines = [`${icon} 任务${success ? '完成' : '失败'}:${taskName}`]
547
+ if (detail) lines.push(detail.slice(0, 500))
548
+ await this.sendText(lines.join('\n'))
549
+ }
550
+
551
+ // ── 长轮询接收 ──────────────────────────────────────────
552
+
553
+ startListening(): boolean {
554
+ if (!this.connected || this.listening) return false
555
+ this.listenStop = false
556
+ this.listening = true
557
+ this.lastPollAt = Date.now()
558
+ void this.listenLoop()
559
+
560
+ // 看门狗:长轮询卡死自动重启
561
+ if (!this.watchdog) {
562
+ this.watchdog = setInterval(() => {
563
+ if (!this.listening || this.listenStop) return
564
+ const staleMs = Date.now() - this.lastPollAt
565
+ if (staleMs > HEARTBEAT_STALE_THRESHOLD_MS) {
566
+ console.warn(`[wechat] 长轮询 ${Math.round(staleMs / 1000)}s 无响应,重启监听`)
567
+ this.listening = false
568
+ this.listenStop = true
569
+ setTimeout(() => {
570
+ if (this.connected && !this.listening) this.startListening()
571
+ }, 500)
572
+ }
573
+ }, HEARTBEAT_CHECK_INTERVAL_MS)
574
+ this.watchdog.unref?.()
575
+ }
576
+ return true
577
+ }
578
+
579
+ stopListening(): void {
580
+ this.listenStop = true
581
+ this.listening = false
582
+ }
583
+
584
+ private async listenLoop(): Promise<void> {
585
+ let failStreak = 0
586
+ this.reconnectAttempts = 0
587
+
588
+ while (!this.listenStop && this.connected) {
589
+ try {
590
+ const data = await this.postJson(
591
+ `${this.creds.baseUrl}/ilink/bot/getupdates`,
592
+ this.authHeaders(this.creds.botToken),
593
+ this.withBaseInfo({ get_updates_buf: this.creds.syncBuf }),
594
+ LONG_POLL_TIMEOUT_MS
595
+ )
596
+ failStreak = 0
597
+ this.lastPollAt = Date.now()
598
+
599
+ const ret = Number(data['ret'] ?? 0)
600
+ const errcode = Number(data['errcode'] ?? 0)
601
+
602
+ // 会话过期 → 自动重连
603
+ if (ret === -14 || errcode === -14) {
604
+ const ok = await this.tryReconnect()
605
+ if (ok) continue
606
+ this.creds.botToken = ''
607
+ this.sessionAlive = false
608
+ this.save()
609
+ this.listening = false
610
+ this.emit('disconnected', '微信会话已过期,自动重连失败,请重新扫码连接')
611
+ return
612
+ }
613
+
614
+ if (ret !== 0 || errcode !== 0) {
615
+ await sleep(3000)
616
+ continue
617
+ }
618
+
619
+ let dirty = false
620
+ const newBuf = String(data['get_updates_buf'] ?? '')
621
+ if (newBuf && newBuf !== this.creds.syncBuf) {
622
+ this.creds.syncBuf = newBuf
623
+ dirty = true
624
+ }
625
+ const msgs = (data['msgs'] as Array<Record<string, unknown>>) ?? []
626
+ for (const msg of msgs) {
627
+ if (this.handleIncoming(msg)) dirty = true
628
+ }
629
+ if (dirty) this.save()
630
+ } catch (err) {
631
+ // 长轮询超时属正常,静默重试
632
+ const name = err instanceof Error ? err.name : String(err)
633
+ if (!name.includes('Timeout') && !name.includes('AbortError')) {
634
+ failStreak++
635
+ if (failStreak === 5) {
636
+ console.warn('[wechat] 长轮询连续失败 5 次,可能网络不稳定')
637
+ }
638
+ const backoff = Math.min(3000 * failStreak, 30_000)
639
+ await sleep(backoff)
640
+ }
641
+ }
642
+ }
643
+ this.listening = false
644
+ }
645
+
646
+ private async tryReconnect(): Promise<boolean> {
647
+ if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) return false
648
+ this.reconnectAttempts++
649
+ console.warn(`[wechat] 会话过期,尝试自动重连 (第${this.reconnectAttempts}次)`)
650
+ await sleep(RECONNECT_DELAY_MS)
651
+ // iLink 协议:bot_token 失效后无法本地续期,只能提示重新扫码
652
+ return false
653
+ }
654
+
655
+ /** 处理一条收到的消息;返回是否有状态需要落盘 */
656
+ private handleIncoming(msg: Record<string, unknown>): boolean {
657
+ const fromUid = String(msg['from_user_id'] ?? '').trim()
658
+ if (!fromUid || fromUid === this.creds.botId) return false
659
+
660
+ let dirty = false
661
+ const ctxToken = String(msg['context_token'] ?? '').trim()
662
+ if (ctxToken && this.creds.contextTokens[fromUid] !== ctxToken) {
663
+ this.creds.contextTokens[fromUid] = ctxToken
664
+ this.creds.contextTokenTs[fromUid] = Date.now()
665
+ dirty = true
666
+ }
667
+ // 用户主动发消息 = 推送会话恢复活跃
668
+ this.sessionAlive = true
669
+ if (this.creds.pushTarget !== fromUid) {
670
+ this.creds.pushTarget = fromUid
671
+ dirty = true
672
+ }
673
+ // 首次会话确认
674
+ if (!this.creds.conversationConfirmed) {
675
+ this.creds.conversationConfirmed = true
676
+ dirty = true
677
+ void this.sendText(
678
+ '✅ 微信会话已建立\n之后任务执行结果和测试消息都会推送到这里。\n发「帮助」查看可用指令。',
679
+ fromUid
680
+ )
681
+ }
682
+
683
+ const text = extractText(msg)
684
+ if (!text) return dirty
685
+ this.emit('message', { fromUid, text })
686
+
687
+ // 指令处理(未命中由 router 返回提示;allowRemoteCommand=false 时忽略)
688
+ if (this.commandHandler && this.getSettings().allowRemoteCommand) {
689
+ let reply: string | null
690
+ try {
691
+ reply = this.commandHandler(text)
692
+ } catch (err) {
693
+ reply = `❌ 指令执行出错: ${String(err)}`
694
+ }
695
+ if (reply) void this.sendText(reply, fromUid)
696
+ }
697
+ return dirty
698
+ }
699
+
700
+ // ── 断开 ────────────────────────────────────────────────
701
+
702
+ disconnect(): void {
703
+ this.cancelLogin()
704
+ this.stopListening()
705
+ if (this.watchdog) {
706
+ clearInterval(this.watchdog)
707
+ this.watchdog = null
708
+ }
709
+ this.creds = emptyCredentials()
710
+ this.sessionAlive = false
711
+ this.loginStatus = 'idle'
712
+ this.qrContent = null
713
+ try {
714
+ if (existsSync(this.credsPath)) writeFileSync(this.credsPath, '', 'utf-8')
715
+ } catch {
716
+ /* ignore */
717
+ }
718
+ this.emit('status', { status: 'idle', text: LOGIN_STATUS_TEXT.idle })
719
+ }
720
+
721
+ /** 应用退出时调用 */
722
+ shutdown(): void {
723
+ this.stopListening()
724
+ if (this.watchdog) {
725
+ clearInterval(this.watchdog)
726
+ this.watchdog = null
727
+ }
728
+ this.loginAbort?.abort()
729
+ }
730
+ }
731
+
732
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
733
+ return new Promise((resolve) => {
734
+ const t = setTimeout(resolve, ms)
735
+ signal?.addEventListener(
736
+ 'abort',
737
+ () => {
738
+ clearTimeout(t)
739
+ resolve()
740
+ },
741
+ { once: true }
742
+ )
743
+ })
744
+ }