shennian 0.2.87 → 0.2.89

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 (73) hide show
  1. package/dist/assets/wechat-channel/macos/manifest.json +13 -0
  2. package/dist/assets/wechat-channel/macos/shennian-wechat-channel-helper +0 -0
  3. package/dist/src/agents/adapter.d.ts +6 -0
  4. package/dist/src/agents/codex-control.d.ts +35 -0
  5. package/dist/src/agents/codex-control.js +188 -0
  6. package/dist/src/agents/codex-utils.d.ts +5 -0
  7. package/dist/src/agents/codex-utils.js +5 -0
  8. package/dist/src/agents/codex.d.ts +8 -0
  9. package/dist/src/agents/codex.js +55 -2
  10. package/dist/src/agents/model-registry/discovery.js +2 -1
  11. package/dist/src/channels/base.d.ts +4 -13
  12. package/dist/src/channels/runtime.d.ts +1 -3
  13. package/dist/src/channels/runtime.js +32 -5
  14. package/dist/src/channels/secret-registry.d.ts +1 -4
  15. package/dist/src/channels/wechat-channel/anchor.d.ts +10 -0
  16. package/dist/src/channels/wechat-channel/anchor.js +65 -0
  17. package/dist/src/channels/wechat-channel/client.d.ts +74 -0
  18. package/dist/src/channels/wechat-channel/client.js +96 -0
  19. package/dist/src/channels/wechat-channel/cooldown.d.ts +15 -0
  20. package/dist/src/channels/wechat-channel/cooldown.js +38 -0
  21. package/dist/src/channels/wechat-channel/fingerprint.d.ts +28 -0
  22. package/dist/src/channels/wechat-channel/fingerprint.js +71 -0
  23. package/dist/src/channels/wechat-channel/helper-assets.d.ts +28 -0
  24. package/dist/src/channels/wechat-channel/helper-assets.js +68 -0
  25. package/dist/src/channels/wechat-channel/helper-client.d.ts +25 -0
  26. package/dist/src/channels/wechat-channel/helper-client.js +149 -0
  27. package/dist/src/channels/wechat-channel/helper-protocol.d.ts +84 -0
  28. package/dist/src/channels/wechat-channel/helper-protocol.js +115 -0
  29. package/dist/src/channels/wechat-channel/index.d.ts +16 -0
  30. package/dist/src/channels/wechat-channel/index.js +19 -0
  31. package/dist/src/channels/wechat-channel/ledger.d.ts +33 -0
  32. package/dist/src/channels/wechat-channel/ledger.js +54 -0
  33. package/dist/src/channels/wechat-channel/media-resolver.d.ts +32 -0
  34. package/dist/src/channels/wechat-channel/media-resolver.js +181 -0
  35. package/dist/src/channels/wechat-channel/message-key.d.ts +19 -0
  36. package/dist/src/channels/wechat-channel/message-key.js +105 -0
  37. package/dist/src/channels/wechat-channel/observer.d.ts +64 -0
  38. package/dist/src/channels/wechat-channel/observer.js +118 -0
  39. package/dist/src/channels/wechat-channel/outbound-ledger.d.ts +66 -0
  40. package/dist/src/channels/wechat-channel/outbound-ledger.js +112 -0
  41. package/dist/src/channels/wechat-channel/preflight.d.ts +37 -0
  42. package/dist/src/channels/wechat-channel/preflight.js +48 -0
  43. package/dist/src/channels/wechat-channel/runner.d.ts +34 -0
  44. package/dist/src/channels/wechat-channel/runner.js +84 -0
  45. package/dist/src/channels/wechat-channel/runtime.d.ts +45 -0
  46. package/dist/src/channels/wechat-channel/runtime.js +66 -0
  47. package/dist/src/channels/wechat-channel/scheduler.d.ts +30 -0
  48. package/dist/src/channels/wechat-channel/scheduler.js +152 -0
  49. package/dist/src/channels/wechat-rpa/macos-flow.d.ts +0 -28
  50. package/dist/src/channels/wechat-rpa/macos-flow.js +1 -134
  51. package/dist/src/channels/wechat-rpa.d.ts +21 -0
  52. package/dist/src/channels/wechat-rpa.js +39 -61
  53. package/dist/src/commands/manager.d.ts +1 -1
  54. package/dist/src/commands/manager.js +5 -10
  55. package/dist/src/fs/text-decoder.d.ts +10 -0
  56. package/dist/src/fs/text-decoder.js +110 -0
  57. package/dist/src/manager/runtime.js +4 -6
  58. package/dist/src/native-fusion/service.d.ts +10 -0
  59. package/dist/src/native-fusion/service.js +27 -0
  60. package/dist/src/session/handlers/chat.js +18 -2
  61. package/dist/src/session/handlers/fs.js +39 -3
  62. package/dist/src/session/handlers/session-refresh.js +12 -0
  63. package/dist/src/session/handlers/tool-detail.d.ts +3 -0
  64. package/dist/src/session/handlers/tool-detail.js +218 -0
  65. package/dist/src/session/manager.d.ts +3 -0
  66. package/dist/src/session/manager.js +58 -0
  67. package/dist/src/session/types.d.ts +4 -0
  68. package/package.json +2 -2
  69. package/dist/scripts/wechat-rpa-download-candidates.mjs +0 -105
  70. package/dist/scripts/wechat-rpa-win-visual.mjs +0 -1735
  71. package/dist/scripts/wechat-rpa-win.mjs +0 -352
  72. package/dist/src/channels/wechat-rpa/windows-visual-flow.d.ts +0 -40
  73. package/dist/src/channels/wechat-rpa/windows-visual-flow.js +0 -189
@@ -1,1735 +0,0 @@
1
- #!/usr/bin/env node
2
- // @arch docs/features/wechat-rpa-channel.md
3
- // @test tests/wechat-rpa-win-visual.test.mjs
4
-
5
- import { spawn } from 'node:child_process'
6
- import crypto from 'node:crypto'
7
- import fs from 'node:fs'
8
- import path from 'node:path'
9
- import { fileURLToPath } from 'node:url'
10
- import { recognizeWindowsScreenshot } from './wechat-rpa-windows-ocr.mjs'
11
- import { parseWeChatLayout } from './wechat-rpa-lab/layout/parser.mjs'
12
-
13
- const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
14
- const helperBridge = path.join(repoRoot, 'scripts/wechat-rpa-win.mjs')
15
-
16
- const SECTION_LABELS = new Set(['群聊', '联系人', '聊天记录', '公众号', '小程序', '朋友圈', '企业', '企业微信'])
17
-
18
- function printHelp() {
19
- console.log(`Usage:
20
- node scripts/wechat-rpa-win-visual.mjs --group ABC --recent-limit 5
21
- node scripts/wechat-rpa-win-visual.mjs --group ABC --reply-text "我是 AI" --ocr-fixture C:\\tmp\\search-ocr.json
22
- node scripts/wechat-rpa-win-visual.mjs --group ABC --file C:\\tmp\\demo.png --file C:\\tmp\\demo.mp4 --ocr-fixture C:\\tmp\\search-ocr.json
23
-
24
- Options:
25
- --group <name> Required target group/conversation name.
26
- --reply-text <text> Text to send after opening the group.
27
- --file <path> File/image/video to send. Repeatable.
28
- --recent-limit <n> Number of OCR message observations to include in summary. Default: 5.
29
- --capture-dir <dir> Directory for screenshots and debug JSON. Default: temp shennian-wechat-rpa-win-visual-*.
30
- --download-attachments-dir <dir>
31
- Copy clicked inbound attachments into this directory.
32
- --download-expected-token <token>
33
- Extra filename/token for cache-only download lookup. Repeatable.
34
- --download-limit <n> Maximum inbound attachments to try in one run. Defaults to 1.
35
- --allow-right-click-download
36
- Allow risky right-click Copy fallback when cache-only lookup misses. Disabled by default.
37
- --allow-search-open Allow opening a group through the WeChat search panel. Disabled by default on Windows live runs.
38
- --no-download-attachments
39
- Keep inbound attachments as metadata-only/pending-download.
40
- --ocr-url <url> Deprecated; accepted for compatibility but ignored.
41
- --token <token> Deprecated; accepted for compatibility but ignored.
42
- --ocr-fixture <path> Local OCR fixture JSON for deterministic selection tests/debugging.
43
- --helper <path> Override native helper exe passed through to scripts/wechat-rpa-win.mjs.
44
- --open-timeout-ms <n> Timeout waiting for title confirmation. Default: 12000.
45
- --dry-run Open/read only; do not send reply-text/files.
46
-
47
- This is the Windows commercial baseline visual RPA orchestrator: WeChat foreground + screenshot + local OCR boxes + native click/paste/press.`)
48
- }
49
-
50
- function takeOption(argv, name) {
51
- const index = argv.indexOf(name)
52
- if (index < 0) return null
53
- const value = argv[index + 1]
54
- if (!value || value.startsWith('--')) throw new Error(`Missing value for ${name}`)
55
- argv.splice(index, 2)
56
- return value
57
- }
58
-
59
- function takeMany(argv, name) {
60
- const values = []
61
- for (;;) {
62
- const value = takeOption(argv, name)
63
- if (value === null) break
64
- values.push(value)
65
- }
66
- return values
67
- }
68
-
69
- function takeFlag(argv, name) {
70
- const index = argv.indexOf(name)
71
- if (index < 0) return false
72
- argv.splice(index, 1)
73
- return true
74
- }
75
-
76
- function sleep(ms) {
77
- return new Promise(resolve => setTimeout(resolve, ms))
78
- }
79
-
80
- function psSingleQuoted(value) {
81
- return String(value).replace(/'/g, "''")
82
- }
83
-
84
- export function normalizeConversationName(value) {
85
- const compact = String(value || '')
86
- .replace(/[\s\u200b\u200c\u200d]+/g, '')
87
- .replace(/夕卜/g, '外')
88
- .replace(/氵则/g, '测')
89
- .replace(/氵則/g, '测')
90
- .replace(/讠羊/g, '群')
91
- .replace(/訁羊/g, '群')
92
- return compact
93
- .replace(/[((]\d+[))]\d*$/g, '')
94
- .replace(/([\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}])[00]{1,3}$/u, '$1')
95
- .trim()
96
- .toLowerCase()
97
- }
98
-
99
- function normalizeText(value) {
100
- return String(value || '').replace(/\s+/g, ' ').trim()
101
- }
102
-
103
- function centerOfBox(box) {
104
- return {
105
- x: Number(box.x || 0) + Number(box.width || 0) / 2,
106
- y: Number(box.y || 0) + Number(box.height || 0) / 2,
107
- }
108
- }
109
-
110
- function observationText(row) {
111
- return normalizeText(row?.text)
112
- }
113
-
114
- function observationConfidence(row) {
115
- const value = Number(row?.confidence)
116
- return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0.75
117
- }
118
-
119
- function isSection(row) {
120
- const text = observationText(row)
121
- return SECTION_LABELS.has(text) || /^(群聊|联系人|聊天记录|公众号|小程序|企业微信)$/.test(text)
122
- }
123
-
124
- export function findConversationInSearchResults(observations, targetName) {
125
- const target = normalizeConversationName(targetName)
126
- if (!target) throw new Error('targetName is required')
127
- const rows = (Array.isArray(observations) ? observations : [])
128
- .filter(row => row?.box && observationText(row))
129
- .map((row, index) => ({ row, index, text: observationText(row), center: centerOfBox(row.box) }))
130
- .sort((a, b) => a.center.y - b.center.y || a.center.x - b.center.x)
131
-
132
- const sections = rows.filter(item => isSection(item.row))
133
- const groupSection = sections.find(item => item.text === '群聊')
134
- const nextSection = groupSection
135
- ? sections.find(item => item.center.y > groupSection.center.y + 0.002)
136
- : null
137
-
138
- const exactCandidates = rows.filter(item => normalizeConversationName(item.text) === target)
139
- const sectionCandidates = groupSection
140
- ? exactCandidates.filter(item => {
141
- if (item.center.y <= groupSection.center.y) return false
142
- if (nextSection && item.center.y >= nextSection.center.y) return false
143
- return true
144
- })
145
- : []
146
-
147
- const fallbackCandidates = exactCandidates.filter(item => isLikelySearchResultCandidate(item.row))
148
- const candidates = sectionCandidates.length > 0 ? sectionCandidates : fallbackCandidates.length > 0 ? fallbackCandidates : exactCandidates
149
- if (candidates.length === 0) return null
150
-
151
- return candidates
152
- .map(item => {
153
- const sectionDistance = groupSection ? Math.max(0, item.center.y - groupSection.center.y) : item.center.y
154
- const horizontalPenalty = item.center.x <= 1
155
- ? Math.abs(item.center.x - 0.2) * 0.5
156
- : Math.abs(item.center.x - 200) * 0.005
157
- const score =
158
- observationConfidence(item.row) * 100
159
- + (groupSection && item.center.y > groupSection.center.y ? 50 : 0)
160
- - sectionDistance * 5
161
- - horizontalPenalty
162
- return { ...item, score }
163
- })
164
- .sort((a, b) => b.score - a.score)[0].row
165
- }
166
-
167
- function isLikelySearchResultCandidate(row) {
168
- const box = row?.box || row
169
- if (!box) return false
170
- const x = Number(box.x || 0)
171
- const y = Number(box.y || 0)
172
- const width = Number(box.width || 0)
173
- const height = Number(box.height || 0)
174
- const normalized = Math.max(Math.abs(x), Math.abs(y), Math.abs(width), Math.abs(height)) <= 1
175
- if (normalized) return x >= 0.08 && x <= 0.55 && y >= 0.12 && y <= 0.72
176
- return x >= 120 && x <= 620 && y >= 120 && y <= 720
177
- }
178
-
179
- export function findConversationInLeftList(observations, targetName) {
180
- const target = normalizeConversationName(targetName)
181
- if (!target) throw new Error('targetName is required')
182
- const candidates = (Array.isArray(observations) ? observations : [])
183
- .filter(row => row?.box && normalizeConversationName(observationText(row)) === target && isLikelyLeftListConversation(row))
184
- .map(row => ({ row, center: centerOfBox(row.box), confidence: observationConfidence(row) }))
185
- if (!candidates.length) return null
186
- return candidates
187
- .map(item => ({
188
- ...item,
189
- score: item.confidence * 100 - Math.abs(item.center.x - 220) * 0.01 - item.center.y * 0.001,
190
- }))
191
- .sort((a, b) => b.score - a.score)[0].row
192
- }
193
-
194
- function isLikelyLeftListConversation(row) {
195
- const box = row?.box || row
196
- if (!box) return false
197
- const x = Number(box.x || 0)
198
- const y = Number(box.y || 0)
199
- const width = Number(box.width || 0)
200
- const height = Number(box.height || 0)
201
- const normalized = Math.max(Math.abs(x), Math.abs(y), Math.abs(width), Math.abs(height)) <= 1
202
- if (normalized) return x >= 0.10 && x <= 0.34 && y >= 0.12 && y <= 0.92
203
- return x >= 120 && x <= 460 && y >= 120 && y <= 900
204
- }
205
-
206
- export function findTitleConfirmation(observations, targetName) {
207
- const target = normalizeConversationName(targetName)
208
- const rows = Array.isArray(observations) ? observations : []
209
- return rows.find(row => normalizeConversationName(observationText(row)) === target && isLikelyMainTitle(row)) || null
210
- }
211
-
212
- export function findSendButtonObservation(observations) {
213
- return (Array.isArray(observations) ? observations : [])
214
- .filter(row => /^(发送|send)$/i.test(normalizedMessageText(observationText(row))) && isLikelyComposerSendButton(row))
215
- .sort((left, right) => observationConfidence(right) - observationConfidence(left))[0] || null
216
- }
217
-
218
- export function findComposerPendingAttachmentObservation(observations) {
219
- return (Array.isArray(observations) ? observations : [])
220
- .filter(row => looksLikeComposerPendingAttachmentText(observationText(row)) && isLikelyComposerPendingAttachment(row))
221
- .sort((left, right) => observationConfidence(right) - observationConfidence(left))[0] || null
222
- }
223
-
224
- export function findComposerDirtyObservation(observations) {
225
- return findSendButtonObservation(observations) || findComposerPendingAttachmentObservation(observations)
226
- }
227
-
228
- export function isRetryableOcrError(status, body) {
229
- if ([408, 429, 500, 502, 503, 504].includes(Number(status))) return true
230
- return /invalid model response|timeout|temporar/i.test(String(body || ''))
231
- }
232
-
233
- export function pointFromObservation(capturePayload, observation, imageSize) {
234
- if (!capturePayload?.bounds) throw new Error('capture payload missing bounds')
235
- if (!observation?.box) throw new Error('observation missing box')
236
- const bounds = capturePayload.bounds
237
- const width = Number(imageSize?.width || bounds.width)
238
- const height = Number(imageSize?.height || bounds.height)
239
- const scaleX = Number(bounds.width) / width
240
- const scaleY = Number(bounds.height) / height
241
- const rawX = Number(observation.box.x)
242
- const rawY = Number(observation.box.y)
243
- const rawWidth = Number(observation.box.width)
244
- const rawHeight = Number(observation.box.height)
245
- const normalizedBox = Math.max(Math.abs(rawX), Math.abs(rawY), Math.abs(rawWidth), Math.abs(rawHeight)) <= 1
246
- const cx = normalizedBox ? (rawX + rawWidth / 2) * width : rawX + rawWidth / 2
247
- const cy = normalizedBox ? (rawY + rawHeight / 2) * height : rawY + rawHeight / 2
248
- return {
249
- x: Math.round(Number(bounds.x) + cx * scaleX),
250
- y: Math.round(Number(bounds.y) + cy * scaleY),
251
- }
252
- }
253
-
254
- export function geometryPoint(capturePayload, kind) {
255
- const bounds = capturePayload?.mainWindow?.bounds || capturePayload?.bounds
256
- if (!bounds) throw new Error('capture payload missing main window bounds')
257
- const presets = {
258
- search: [0.186, 0.087],
259
- // Right-side composer: avoid the left search overlay and click inside the text area.
260
- input: [0.58, 0.92],
261
- fileInput: [0.58, 0.86],
262
- send: [0.932, 0.945],
263
- }
264
- const pair = presets[kind]
265
- if (!pair) throw new Error(`Unknown geometry point: ${kind}`)
266
- return {
267
- x: Math.round(Number(bounds.x) + Number(bounds.width) * pair[0]),
268
- y: Math.round(Number(bounds.y) + Number(bounds.height) * pair[1]),
269
- }
270
- }
271
-
272
- export function assertUsableMainWindow(capturePayload, phase = 'capture') {
273
- const bounds = capturePayload?.mainWindow?.bounds || capturePayload?.bounds
274
- if (!bounds) throw new Error(`${phase}: capture payload missing main window bounds`)
275
- const width = Number(bounds.width)
276
- const height = Number(bounds.height)
277
- if (width < 760 || height < 600) {
278
- throw new Error(
279
- `${phase}: WeChat chat main window is not available; got ${Math.round(width)}x${Math.round(height)}. ` +
280
- 'The visible WeChat window looks like a login/prompt dialog, so the RPA flow was stopped.',
281
- )
282
- }
283
- return capturePayload
284
- }
285
-
286
- export function readPngSize(filePath) {
287
- const fd = fs.openSync(filePath, 'r')
288
- try {
289
- const buffer = Buffer.alloc(24)
290
- fs.readSync(fd, buffer, 0, buffer.length, 0)
291
- if (buffer.toString('ascii', 1, 4) !== 'PNG') throw new Error(`Not a PNG file: ${filePath}`)
292
- return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }
293
- } finally {
294
- fs.closeSync(fd)
295
- }
296
- }
297
-
298
- async function runBridge(args, options) {
299
- const finalArgs = [helperBridge, ...args]
300
- if (options.helper) finalArgs.push('--helper', options.helper)
301
- const child = spawn(process.execPath, finalArgs, {
302
- cwd: repoRoot,
303
- windowsHide: true,
304
- stdio: ['ignore', 'pipe', 'pipe'],
305
- })
306
- let stdout = ''
307
- let stderr = ''
308
- child.stdout.setEncoding('utf8')
309
- child.stderr.setEncoding('utf8')
310
- child.stdout.on('data', chunk => { stdout += chunk })
311
- child.stderr.on('data', chunk => { stderr += chunk })
312
- const exitCode = await new Promise((resolve, reject) => {
313
- child.on('error', reject)
314
- child.on('close', resolve)
315
- })
316
- if (stderr.trim()) process.stderr.write(stderr)
317
- if (exitCode !== 0) {
318
- throw new Error(`wechat-rpa-win command failed (${exitCode}): ${args.join(' ')}\n${stdout}`)
319
- }
320
- const trimmed = stdout.trim()
321
- if (!trimmed) return null
322
- try {
323
- return JSON.parse(trimmed)
324
- } catch (error) {
325
- throw new Error(`Invalid helper JSON for ${args.join(' ')}: ${error.message}\n${stdout}`)
326
- }
327
- }
328
-
329
- async function capture(region, options, label) {
330
- const file = path.join(options.captureDir, `${String(options.step++).padStart(2, '0')}-${label || region}.png`)
331
- const result = await runBridge(['capture', '--region', region, '--output', file], options)
332
- if (region === 'window') assertUsableMainWindow(result?.payload, label || region)
333
- return { result, payload: result?.payload, file, imageSize: readPngSize(file) }
334
- }
335
-
336
- async function captureWithRetry(region, options, label, retry = {}) {
337
- const attempts = Number(retry.attempts || 5)
338
- const delayMs = Number(retry.delayMs || 700)
339
- let lastError = null
340
- for (let attempt = 1; attempt <= attempts; attempt += 1) {
341
- try {
342
- return await capture(region, options, label)
343
- } catch (error) {
344
- lastError = error
345
- if (!isRetryableWindowCaptureError(error) || attempt === attempts) break
346
- await sleep(delayMs)
347
- }
348
- }
349
- throw lastError
350
- }
351
-
352
- async function waitForStableWindow(options, label, retry = {}) {
353
- const attempts = Number(retry.attempts || 6)
354
- const delayMs = Number(retry.delayMs || 650)
355
- let previous = null
356
- let latest = null
357
- for (let attempt = 1; attempt <= attempts; attempt += 1) {
358
- latest = await captureWithRetry('window', options, `${label}-stability-${attempt}`, { attempts: 2, delayMs })
359
- if (previous && areSameWindowBounds(previous.payload, latest.payload)) return latest
360
- previous = latest
361
- await sleep(delayMs)
362
- }
363
- return latest
364
- }
365
-
366
- function areSameWindowBounds(left, right) {
367
- const a = left?.mainWindow?.bounds || left?.bounds
368
- const b = right?.mainWindow?.bounds || right?.bounds
369
- if (!a || !b) return false
370
- return ['x', 'y', 'width', 'height'].every(key => Math.abs(Number(a[key]) - Number(b[key])) <= 2)
371
- }
372
-
373
- export function isRetryableWindowCaptureError(error) {
374
- return /Cannot locate WeChat main window via (Win32|UI Automation)/i.test(String(error?.message || error || ''))
375
- }
376
-
377
- export function assertSearchOpenAllowed(options, targetGroup) {
378
- if (options?.allowSearchOpen) return true
379
- throw new Error(
380
- `Target group '${targetGroup || options?.group || ''}' is not the current title or visible in the left list; ` +
381
- 'search-panel opening is disabled for Windows safety because it can make WeChat non-enumerable.',
382
- )
383
- }
384
-
385
- async function click(point, options, extra = []) {
386
- return runBridge(['click', '--x', String(point.x), '--y', String(point.y), ...extra], options)
387
- }
388
-
389
- async function rightClick(point, options, extra = []) {
390
- return click(point, options, ['--right', ...extra])
391
- }
392
-
393
- async function readClipboard(options) {
394
- const result = await runBridge(['read-clipboard'], options)
395
- return result?.payload || result
396
- }
397
-
398
- function writeDebugArtifact(options, name, value) {
399
- if (!options?.captureDir) return
400
- fs.writeFileSync(path.join(options.captureDir, name), `${JSON.stringify(value, null, 2)}\n`)
401
- }
402
-
403
- export function prepareCaptureDir(captureDir) {
404
- fs.mkdirSync(captureDir, { recursive: true })
405
- for (const entry of fs.readdirSync(captureDir, { withFileTypes: true })) {
406
- if (!entry.isFile()) continue
407
- if (!/\.(png|json)$/i.test(entry.name)) continue
408
- fs.rmSync(path.join(captureDir, entry.name), { force: true })
409
- }
410
- }
411
-
412
- async function pasteText(text, options) {
413
- return runBridge(['paste-text', '--text', text], options)
414
- }
415
-
416
- async function pasteFiles(files, options) {
417
- return runBridge(['paste-files', ...files.flatMap(file => ['--file', file])], options)
418
- }
419
-
420
- async function press(keys, options) {
421
- return runBridge(['press', '--keys', keys], options)
422
- }
423
-
424
- async function dismissMenus(options, count = 2) {
425
- try {
426
- return await runBridge(['dismiss-menus', '--count', String(count)], options)
427
- } catch (error) {
428
- if (process.platform !== 'win32') throw error
429
- return dismissMenusWithPowerShell(count)
430
- }
431
- }
432
-
433
- async function dismissMenusWithPowerShell(count = 2) {
434
- const finalCount = Math.max(1, Math.min(5, Number(count) || 2))
435
- const script = `
436
- Add-Type @"
437
- using System;
438
- using System.Runtime.InteropServices;
439
- public static class KeyboardDismiss {
440
- [DllImport("user32.dll")]
441
- public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo);
442
- }
443
-
444
- "@
445
- for ($i = 0; $i -lt ${finalCount}; $i += 1) {
446
- [KeyboardDismiss]::keybd_event(0x1B, 0, 0, [UIntPtr]::Zero)
447
- Start-Sleep -Milliseconds 20
448
- [KeyboardDismiss]::keybd_event(0x1B, 0, 0x0002, [UIntPtr]::Zero)
449
- Start-Sleep -Milliseconds 90
450
- }
451
- @{ ok = $true; command = "dismiss-menus-powershell"; count = ${finalCount} } | ConvertTo-Json -Compress
452
- `
453
- const encoded = Buffer.from(script, 'utf16le').toString('base64')
454
- const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded], {
455
- windowsHide: true,
456
- stdio: ['ignore', 'pipe', 'pipe'],
457
- })
458
- let stdout = ''
459
- let stderr = ''
460
- child.stdout.setEncoding('utf8')
461
- child.stderr.setEncoding('utf8')
462
- child.stdout.on('data', chunk => { stdout += chunk })
463
- child.stderr.on('data', chunk => { stderr += chunk })
464
- const exitCode = await new Promise((resolve, reject) => {
465
- child.on('error', reject)
466
- child.on('close', resolve)
467
- })
468
- if (exitCode !== 0) {
469
- throw new Error(stderr.trim() || stdout.trim() || `dismiss-menus PowerShell exited ${exitCode}`)
470
- }
471
- try {
472
- return JSON.parse(stdout.trim())
473
- } catch {
474
- return { ok: true, command: 'dismiss-menus-powershell', count: finalCount, stdout: stdout.trim() }
475
- }
476
- }
477
-
478
- async function verifyPostRunSafety(options, artifacts) {
479
- try {
480
- const safetyCapture = await captureWithRetry('window', options, 'post-run-safety', { attempts: 4, delayMs: 800 })
481
- artifacts.push(safetyCapture.file)
482
- return {
483
- ok: true,
484
- artifact: safetyCapture.file,
485
- bounds: safetyCapture.payload?.mainWindow?.bounds || safetyCapture.payload?.bounds,
486
- }
487
- } catch (error) {
488
- const message = error instanceof Error ? error.message : String(error)
489
- writeDebugArtifact(options, 'post-run-safety-error.json', {
490
- ok: false,
491
- message,
492
- })
493
- throw new Error(
494
- `P0 Windows WeChat safety check failed after the visual flow: ${message}. ` +
495
- 'This run is not eligible for pass-rate or TODO acceptance even if the business action appeared to succeed.',
496
- )
497
- }
498
- }
499
-
500
- async function recognizeScreenshot(capture, options, purpose) {
501
- const ocr = await recognizeWindowsScreenshot(capture, { ...options, isRetryableOcrError }, purpose)
502
- return {
503
- ...ocr,
504
- layout: buildWindowsCoreLayout(capture, ocr.observations, {
505
- targetGroup: options.group,
506
- purpose,
507
- }),
508
- }
509
- }
510
-
511
- export function buildWindowsCoreLayout(capture, observations, options = {}) {
512
- const bounds = capture?.payload?.mainWindow?.bounds || capture?.payload?.bounds || capture?.bounds || {}
513
- const imageSize = capture?.imageSize || options.imageSize || {}
514
- const width = Number(imageSize.width || bounds.width || 0)
515
- const height = Number(imageSize.height || bounds.height || 0)
516
- const windowWidth = Number(bounds.width || width || 1)
517
- const windowHeight = Number(bounds.height || height || 1)
518
- const screenshot = {
519
- path: capture?.file || options.imagePath || '',
520
- width,
521
- height,
522
- scale: width && windowWidth ? width / windowWidth : 1,
523
- scaleX: width && windowWidth ? width / windowWidth : 1,
524
- scaleY: height && windowHeight ? height / windowHeight : width && windowWidth ? width / windowWidth : 1,
525
- }
526
- const window = {
527
- x: Number(bounds.x || 0),
528
- y: Number(bounds.y || 0),
529
- width: windowWidth,
530
- height: windowHeight,
531
- title: '微信',
532
- ownerName: 'WeChat',
533
- }
534
- const ocr = (Array.isArray(observations) ? observations : []).map((item) => {
535
- if (!item?.box || item.x != null) return item
536
- return {
537
- ...item,
538
- x: item.box.x,
539
- y: item.box.y,
540
- width: item.box.width,
541
- height: item.box.height,
542
- }
543
- })
544
- return parseWeChatLayout({
545
- ocr,
546
- screenshot,
547
- window,
548
- targetGroup: options.targetGroup || options.group || '',
549
- })
550
- }
551
-
552
- export function summarizeCoreLayout(layout) {
553
- const currentTitle = layout?.currentTitle || ''
554
- return {
555
- currentTitle,
556
- targetGroupVisible: Boolean(layout?.targetGroupVisible || currentTitle),
557
- leftConversationCount: Array.isArray(layout?.leftConversationList?.items) ? layout.leftConversationList.items.length : 0,
558
- searchResultCount: Array.isArray(layout?.searchResults?.sections)
559
- ? layout.searchResults.sections.reduce((total, section) => total + (Array.isArray(section.items) ? section.items.length : 0), 0)
560
- : 0,
561
- messageCount: Array.isArray(layout?.messageArea?.messages) ? layout.messageArea.messages.length : 0,
562
- attachmentCount: Array.isArray(layout?.attachmentBubbles) ? layout.attachmentBubbles.length : 0,
563
- inputBox: layout?.inputBox?.rect || null,
564
- messageArea: layout?.messageArea?.rect || null,
565
- }
566
- }
567
-
568
- export function summarizeOcrEvidence(ocr = {}) {
569
- const observations = Array.isArray(ocr?.observations) ? ocr.observations : []
570
- return {
571
- provider: ocr?.provider || '',
572
- language: ocr?.language || '',
573
- durationMs: Number(ocr?.durationMs || 0),
574
- observationCount: observations.length,
575
- lineCount: observations.filter(item => item?.providerKind === 'line').length,
576
- wordCount: observations.filter(item => item?.providerKind === 'word').length,
577
- }
578
- }
579
-
580
- export async function detectNewOutboundBubble(beforeFile, afterFile) {
581
- return detectNewOutboundVisualChange(beforeFile, afterFile, {
582
- kind: 'text-bubble',
583
- minimumPixels: 250,
584
- predicate: 'green',
585
- topRatio: 0.45,
586
- bottomRatio: 0.88,
587
- })
588
- }
589
-
590
- export async function detectNewOutboundAttachmentBubble(beforeFile, afterFile) {
591
- return detectNewOutboundVisualChange(beforeFile, afterFile, {
592
- kind: 'attachment-bubble',
593
- minimumPixels: 900,
594
- predicate: 'non-background',
595
- topRatio: 0.18,
596
- bottomRatio: 0.84,
597
- })
598
- }
599
-
600
- export function classifyComposerPendingAttachmentVisualMetrics(metrics = {}) {
601
- const width = Number(metrics.width || 0)
602
- const height = Number(metrics.height || 0)
603
- const grayPixels = Number(metrics.grayPixels || 0)
604
- const sampledPixels = Number(metrics.sampledPixels || 0)
605
- const grayRatio = sampledPixels > 0 ? grayPixels / sampledPixels : Number(metrics.grayRatio || 0)
606
- const box = metrics.box || {}
607
- const boxWidth = Number(box.width || 0)
608
- const boxHeight = Number(box.height || 0)
609
- const boxY = Number(box.y || 0)
610
- return Boolean(
611
- width > 0
612
- && height > 0
613
- && grayPixels >= 1000
614
- && grayRatio >= 0.08
615
- && boxWidth >= width * 0.12
616
- && boxHeight >= 18
617
- && boxY >= height * 0.88
618
- )
619
- }
620
-
621
- export async function detectComposerPendingAttachmentVisual(filePath) {
622
- if (!filePath) return { ok: false, reason: 'missing image path' }
623
- const script = `
624
- Add-Type -AssemblyName System.Drawing
625
- $image = [System.Drawing.Bitmap]::FromFile('${psSingleQuoted(filePath)}')
626
- try {
627
- $left = [int]($image.Width * 0.34)
628
- $right = [int]($image.Width * 0.92)
629
- $top = [int]($image.Height * 0.90)
630
- $bottom = [int]($image.Height * 0.995)
631
- $grayPixels = 0
632
- $sampledPixels = 0
633
- $minX = $image.Width
634
- $minY = $image.Height
635
- $maxX = 0
636
- $maxY = 0
637
- for ($y = $top; $y -lt $bottom; $y += 2) {
638
- for ($x = $left; $x -lt $right; $x += 2) {
639
- $pixel = $image.GetPixel($x, $y)
640
- $sampledPixels += 1
641
- $spread = [Math]::Max([Math]::Abs($pixel.R - $pixel.G), [Math]::Max([Math]::Abs($pixel.G - $pixel.B), [Math]::Abs($pixel.R - $pixel.B)))
642
- $isCardGray = $pixel.R -ge 228 -and $pixel.R -le 246 -and $pixel.G -ge 228 -and $pixel.G -le 246 -and $pixel.B -ge 228 -and $pixel.B -le 246 -and $spread -le 8
643
- if ($isCardGray) {
644
- $grayPixels += 1
645
- if ($x -lt $minX) { $minX = $x }
646
- if ($y -lt $minY) { $minY = $y }
647
- if ($x -gt $maxX) { $maxX = $x }
648
- if ($y -gt $maxY) { $maxY = $y }
649
- }
650
- }
651
- }
652
- $grayRatio = 0
653
- if ($sampledPixels -gt 0) { $grayRatio = $grayPixels / $sampledPixels }
654
- @{
655
- kind = "composer-pending-attachment-visual"
656
- width = $image.Width
657
- height = $image.Height
658
- sampledPixels = $sampledPixels
659
- grayPixels = $grayPixels
660
- grayRatio = $grayRatio
661
- scan = @{ left = $left; right = $right; top = $top; bottom = $bottom; step = 2 }
662
- box = @{ x = $minX; y = $minY; width = [Math]::Max(0, $maxX - $minX); height = [Math]::Max(0, $maxY - $minY) }
663
- } | ConvertTo-Json -Depth 4 -Compress
664
- } finally {
665
- $image.Dispose()
666
- }
667
- `
668
- const encoded = Buffer.from(script, 'utf16le').toString('base64')
669
- const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded], {
670
- windowsHide: true,
671
- stdio: ['ignore', 'pipe', 'pipe'],
672
- })
673
- let stdout = ''
674
- let stderr = ''
675
- child.stdout.setEncoding('utf8')
676
- child.stderr.setEncoding('utf8')
677
- child.stdout.on('data', chunk => { stdout += chunk })
678
- child.stderr.on('data', chunk => { stderr += chunk })
679
- const exitCode = await new Promise((resolve, reject) => {
680
- child.on('error', reject)
681
- child.on('close', resolve)
682
- })
683
- if (exitCode !== 0) return { ok: false, reason: stderr.trim() || `powershell exited ${exitCode}` }
684
- try {
685
- const metrics = JSON.parse(stdout.trim())
686
- return {
687
- ...metrics,
688
- ok: classifyComposerPendingAttachmentVisualMetrics(metrics),
689
- }
690
- } catch {
691
- return { ok: false, reason: stdout.trim() || 'invalid composer visual detector JSON' }
692
- }
693
- }
694
-
695
- export function attachmentVisualBeforeFile(opened = {}, pendingCapture = {}) {
696
- return pendingCapture?.file || opened?.file || null
697
- }
698
-
699
- async function detectNewOutboundVisualChange(beforeFile, afterFile, detector) {
700
- if (!beforeFile || !afterFile) return { ok: false, reason: 'missing image path', kind: detector.kind }
701
- const script = `
702
- Add-Type -AssemblyName System.Drawing
703
- $before = [System.Drawing.Bitmap]::FromFile('${psSingleQuoted(beforeFile)}')
704
- $after = [System.Drawing.Bitmap]::FromFile('${psSingleQuoted(afterFile)}')
705
- try {
706
- if ($before.Width -ne $after.Width -or $before.Height -ne $after.Height) {
707
- @{ ok = $false; reason = "image size mismatch"; changedGreenPixels = 0 } | ConvertTo-Json -Compress
708
- exit 0
709
- }
710
- $left = [int]($after.Width * 0.52)
711
- $right = [int]($after.Width * 0.96)
712
- $top = [int]($after.Height * ${Number(detector.topRatio)})
713
- $bottom = [int]($after.Height * ${Number(detector.bottomRatio)})
714
- $changedPixels = 0
715
- $minX = $after.Width
716
- $minY = $after.Height
717
- $maxX = 0
718
- $maxY = 0
719
- for ($y = $top; $y -lt $bottom; $y += 2) {
720
- for ($x = $left; $x -lt $right; $x += 2) {
721
- $a = $after.GetPixel($x, $y)
722
- $b = $before.GetPixel($x, $y)
723
- $isGreen = $a.G -ge 175 -and $a.R -ge 80 -and $a.R -le 190 -and $a.B -ge 50 -and $a.B -le 180 -and ($a.G - $a.R) -ge 25 -and ($a.G - $a.B) -ge 45
724
- $isNonBackground = -not ($a.R -ge 245 -and $a.G -ge 245 -and $a.B -ge 245) -and -not ($a.R -ge 235 -and $a.G -ge 235 -and $a.B -ge 235 -and [Math]::Abs($a.R - $a.G) -le 5 -and [Math]::Abs($a.G - $a.B) -le 5)
725
- $changed = ([Math]::Abs($a.R - $b.R) + [Math]::Abs($a.G - $b.G) + [Math]::Abs($a.B - $b.B)) -ge 45
726
- $wanted = ${detector.predicate === 'green' ? '$isGreen' : '$isNonBackground'}
727
- if ($wanted -and $changed) {
728
- $changedPixels += 1
729
- if ($x -lt $minX) { $minX = $x }
730
- if ($y -lt $minY) { $minY = $y }
731
- if ($x -gt $maxX) { $maxX = $x }
732
- if ($y -gt $maxY) { $maxY = $y }
733
- }
734
- }
735
- }
736
- @{
737
- ok = $changedPixels -ge ${Number(detector.minimumPixels)}
738
- kind = "${psSingleQuoted(detector.kind)}"
739
- changedPixels = $changedPixels
740
- sampledStep = 2
741
- box = @{ x = $minX; y = $minY; width = [Math]::Max(0, $maxX - $minX); height = [Math]::Max(0, $maxY - $minY) }
742
- } | ConvertTo-Json -Depth 4 -Compress
743
- } finally {
744
- $before.Dispose()
745
- $after.Dispose()
746
- }
747
- `
748
- const encoded = Buffer.from(script, 'utf16le').toString('base64')
749
- const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded], {
750
- windowsHide: true,
751
- stdio: ['ignore', 'pipe', 'pipe'],
752
- })
753
- let stdout = ''
754
- let stderr = ''
755
- child.stdout.setEncoding('utf8')
756
- child.stderr.setEncoding('utf8')
757
- child.stdout.on('data', chunk => { stdout += chunk })
758
- child.stderr.on('data', chunk => { stderr += chunk })
759
- const exitCode = await new Promise((resolve, reject) => {
760
- child.on('error', reject)
761
- child.on('close', resolve)
762
- })
763
- if (exitCode !== 0) return { ok: false, reason: stderr.trim() || `powershell exited ${exitCode}` }
764
- try {
765
- return JSON.parse(stdout.trim())
766
- } catch {
767
- return { ok: false, reason: stdout.trim() || 'invalid bubble detector JSON' }
768
- }
769
- }
770
-
771
- export function summarizeObservations(observations, limit) {
772
- return (Array.isArray(observations) ? observations : [])
773
- .filter(row => observationText(row))
774
- .filter(isUsefulSummaryObservation)
775
- .sort((a, b) => observationBottom(a) - observationBottom(b))
776
- .slice(-limit)
777
- .map(row => ({
778
- text: observationText(row),
779
- role: row.role || 'unknown',
780
- confidence: observationConfidence(row),
781
- ...(row.attachment ? { attachment: row.attachment } : {}),
782
- box: row.box,
783
- }))
784
- }
785
-
786
- function observationBottom(row) {
787
- const box = row?.box || row
788
- return Number(box?.y || 0) + Number(box?.height || 0)
789
- }
790
-
791
- function isUsefulSummaryObservation(row) {
792
- const text = observationText(row)
793
- const compact = normalizedMessageText(text)
794
- if (!compact) return false
795
- if (/^[0-9::.\-]+$/.test(compact)) return false
796
- if (/^[0-9]{1,2}[a-z\u0400-\u04ff]{1,3}[0-9]{1,2}$/i.test(compact)) return false
797
- if (/^[0-9].*[0-9]$/.test(compact) && compact.length <= 8) return false
798
- if (compact.replace(/^[00]+/, '') === normalizedMessageText('微信电脑版')) return false
799
- if (compact === '0') return false
800
- return compact.length >= 2 || Boolean(row?.attachment)
801
- }
802
-
803
- function normalizedMessageText(value) {
804
- return String(value || '').replace(/\s+/g, '').toLowerCase()
805
- }
806
-
807
- export function observationsContainText(observations, text) {
808
- const target = normalizedMessageText(text)
809
- if (!target) return true
810
- return (Array.isArray(observations) ? observations : []).some(row => {
811
- const content = normalizedMessageText(observationText(row))
812
- return isStrongTextMatch(content, target)
813
- })
814
- }
815
-
816
- export function findSentTextConfirmation(observations, text) {
817
- const target = normalizedMessageText(text)
818
- if (!target) return null
819
- return (Array.isArray(observations) ? observations : []).find(row => {
820
- const content = normalizedMessageText(observationText(row))
821
- return isStrongTextMatch(content, target) && isLikelyOutboundMessageText(row)
822
- }) || null
823
- }
824
-
825
- async function confirmSentText(confirmOcr, text, beforeCapture, afterCapture) {
826
- const ocrHit = findSentTextConfirmation(confirmOcr.observations, text)
827
- if (ocrHit) return { method: 'ocr-outbound-text', observation: ocrHit }
828
-
829
- const visual = await detectNewOutboundBubble(beforeCapture?.file, afterCapture?.file)
830
- if (visual?.ok) {
831
- return {
832
- method: 'visual-new-outbound-bubble',
833
- observation: {
834
- text,
835
- role: 'outbound-visual',
836
- confidence: 0,
837
- box: visual.box,
838
- visual,
839
- },
840
- }
841
- }
842
-
843
- return { method: 'none', visual }
844
- }
845
-
846
- async function assertComposerEmptyBeforeSend(options, artifacts) {
847
- const composerCapture = await capture('window', options, 'composer-before-send')
848
- artifacts.push(composerCapture.file)
849
- const composerOcr = await recognizeScreenshot(composerCapture, options, 'composer-before-send')
850
- const dirtyObservation = findComposerDirtyObservation(composerOcr.observations)
851
- const dirtyVisual = dirtyObservation ? null : await detectComposerPendingAttachmentVisual(composerCapture.file)
852
- if (dirtyObservation || dirtyVisual?.ok) {
853
- writeJsonArtifact(options, 'composer-before-send-ocr.json', {
854
- ocrEvidence: summarizeOcrEvidence(composerOcr),
855
- dirtyObservation,
856
- dirtyVisual,
857
- sample: summarizeObservations(composerOcr.observations, 20),
858
- })
859
- throw new Error(
860
- 'WeChat composer is not empty before send; pending text or attachments are already staged. ' +
861
- `Refusing to paste/send new content. See ${composerCapture.file}`,
862
- )
863
- }
864
- return { capture: composerCapture, ocr: composerOcr }
865
- }
866
-
867
- function isLikelyOutboundMessageText(row) {
868
- const box = row?.box || row
869
- if (!box) return false
870
- const x = Number(box.x || 0)
871
- const y = Number(box.y || 0)
872
- const width = Number(box.width || 0)
873
- const height = Number(box.height || 0)
874
- const normalized = Math.max(Math.abs(x), Math.abs(y), Math.abs(width), Math.abs(height)) <= 1
875
- const centerX = x + width / 2
876
- const centerY = y + height / 2
877
- if (normalized) return centerX >= 0.52 && centerY >= 0.22 && centerY <= 0.88
878
- return centerX >= 700 && centerY >= 180 && centerY <= 900
879
- }
880
-
881
- function isLikelyComposerSendButton(row) {
882
- const box = row?.box || row
883
- if (!box) return false
884
- const x = Number(box.x || 0)
885
- const y = Number(box.y || 0)
886
- const width = Number(box.width || 0)
887
- const height = Number(box.height || 0)
888
- const normalized = Math.max(Math.abs(x), Math.abs(y), Math.abs(width), Math.abs(height)) <= 1
889
- const centerX = x + width / 2
890
- const centerY = y + height / 2
891
- if (normalized) return centerX >= 0.78 && centerY >= 0.82
892
- return centerX >= 900 && centerY >= 720
893
- }
894
-
895
- function normalizedObservationCenter(row) {
896
- const box = row?.box || row
897
- if (!box) return null
898
- const x = Number(box.x || 0)
899
- const y = Number(box.y || 0)
900
- const width = Number(box.width || 0)
901
- const height = Number(box.height || 0)
902
- const centerX = x + width / 2
903
- const centerY = y + height / 2
904
- const normalized = Math.max(Math.abs(x), Math.abs(y), Math.abs(width), Math.abs(height)) <= 1
905
- if (normalized) return { x: centerX, y: centerY }
906
- const imageWidth = Number(row?.imageWidth || box.imageWidth || 0)
907
- const imageHeight = Number(row?.imageHeight || box.imageHeight || 0)
908
- if (imageWidth > 0 && imageHeight > 0) return { x: centerX / imageWidth, y: centerY / imageHeight }
909
- return { x: centerX / 1800, y: centerY / 1100 }
910
- }
911
-
912
- function looksLikeComposerPendingAttachmentText(value) {
913
- const text = normalizedMessageText(value)
914
- .replace(/[.。]/g, '.')
915
- .replace(/[||]/g, 'i')
916
- return /\.(txt|csv|pdf|docx?|xlsx?|pptx?|zip|rar|7z|png|jpe?g|gif|webp|bmp|mp4|mov|avi|mkv|webm)$/i.test(text) ||
917
- /\.(txt|csv|pdf|docx?|xlsx?|pptx?|zip|rar|7z|png|jpe?g|gif|webp|bmp|mp4|mov|avi|mkv|webm)\b/i.test(text)
918
- }
919
-
920
- function isLikelyComposerPendingAttachment(row) {
921
- const center = normalizedObservationCenter(row)
922
- if (!center) return false
923
- return center.x >= 0.26 && center.x <= 0.86 && center.y >= 0.78
924
- }
925
-
926
- function isLikelyMainTitle(row) {
927
- const box = row?.box || row
928
- if (!box) return false
929
- const x = Number(box.x || 0)
930
- const y = Number(box.y || 0)
931
- const width = Number(box.width || 0)
932
- const height = Number(box.height || 0)
933
- const normalized = Math.max(Math.abs(x), Math.abs(y), Math.abs(width), Math.abs(height)) <= 1
934
- if (normalized) return x >= 0.32 && y <= 0.18
935
- return x >= 360 && y <= 160
936
- }
937
-
938
- function isStrongTextMatch(content, target) {
939
- if (!content || !target) return false
940
- if (content.includes(target)) return true
941
- const minimumReverseLength = Math.min(8, Math.max(4, Math.ceil(target.length * 0.5)))
942
- return content.length >= minimumReverseLength && target.includes(content)
943
- }
944
-
945
- function basenameForAnyPlatform(file) {
946
- const value = String(file || '')
947
- return value.includes('\\') ? path.win32.basename(value) : path.basename(value)
948
- }
949
-
950
- function safeFileName(name) {
951
- return path.basename(name || 'attachment')
952
- .normalize('NFKC')
953
- .replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
954
- .replace(/\s+/g, ' ')
955
- .replace(/^[ ._]+|[ ._]+$/g, '')
956
- || 'attachment'
957
- }
958
-
959
- function uniqueInboundPath(dir, name, hash) {
960
- const safe = safeFileName(name || 'attachment')
961
- const ext = path.extname(safe)
962
- const stem = ext ? safe.slice(0, -ext.length) : safe
963
- const candidate = path.join(dir, safe)
964
- if (!fs.existsSync(candidate)) return candidate
965
- return path.join(dir, `${stem}-${hash.slice(0, 12)}${ext}`)
966
- }
967
-
968
- function mimeTypeFromExt(ext) {
969
- const value = String(ext || '').toLowerCase()
970
- if (['.jpg', '.jpeg'].includes(value)) return 'image/jpeg'
971
- if (value === '.png') return 'image/png'
972
- if (value === '.gif') return 'image/gif'
973
- if (value === '.webp') return 'image/webp'
974
- if (value === '.mp4') return 'video/mp4'
975
- if (value === '.mov') return 'video/quicktime'
976
- if (value === '.pdf') return 'application/pdf'
977
- if (value === '.txt') return 'text/plain'
978
- if (value === '.docx') return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
979
- if (value === '.xlsx') return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
980
- if (value === '.pptx') return 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
981
- if (value === '.zip') return 'application/zip'
982
- return 'application/octet-stream'
983
- }
984
-
985
- function attachmentFromText(text) {
986
- const normalized = normalizeFileCardText(text)
987
- const match = normalized.match(/[a-z0-9][a-z0-9._-]{2,}\.(txt|pdf|docx?|xlsx?|pptx?|zip|rar|7z|png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm)\b/i)
988
- if (!match) return null
989
- const name = safeFileName(match[0])
990
- const ext = path.extname(name).toLowerCase()
991
- return {
992
- type: attachmentTypeFromExt(ext),
993
- name,
994
- mimeType: mimeTypeFromExt(ext),
995
- availability: 'remote',
996
- }
997
- }
998
-
999
- function normalizeFileCardText(text) {
1000
- return String(text || '')
1001
- .normalize('NFKC')
1002
- .replace(/w[l1i]n/gi, 'win')
1003
- .replace(/[一-—–]/g, '-')
1004
- .replace(/[.。·]/g, '.')
1005
- .replace(/\s+/g, '')
1006
- }
1007
-
1008
- function attachmentTypeFromExt(ext) {
1009
- if (IMAGE_EXTENSIONS.has(String(ext || '').toLowerCase())) return 'image'
1010
- if (VIDEO_EXTENSIONS.has(String(ext || '').toLowerCase())) return 'video'
1011
- return 'file'
1012
- }
1013
-
1014
- function postPasteDelayMs(file) {
1015
- const type = classifyOutboundFile(file).type
1016
- if (type === 'image') return 8_000
1017
- if (type === 'video') return 15_000
1018
- return 2_000
1019
- }
1020
-
1021
- const IMAGE_EXTENSIONS = new Set(['.apng', '.avif', '.bmp', '.gif', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.tif', '.tiff', '.webp'])
1022
- const VIDEO_EXTENSIONS = new Set(['.3g2', '.3gp', '.avi', '.m4v', '.mkv', '.mov', '.mp4', '.mpeg', '.mpg', '.webm', '.wmv'])
1023
-
1024
- export function classifyOutboundFile(file) {
1025
- const name = basenameForAnyPlatform(file)
1026
- const ext = path.extname(name).toLowerCase()
1027
- const type = IMAGE_EXTENSIONS.has(ext) ? 'image' : VIDEO_EXTENSIONS.has(ext) ? 'video' : 'file'
1028
- return {
1029
- type,
1030
- name,
1031
- localPath: file,
1032
- availability: 'edge-local',
1033
- }
1034
- }
1035
-
1036
- export function copyInboundAttachment(source, targetDir, attachment) {
1037
- const buffer = fs.readFileSync(source.path)
1038
- const hash = crypto.createHash('sha256').update(buffer).digest('hex')
1039
- const sourceName = path.basename(source.path)
1040
- const preferredName = attachment?.name && /\.[\p{L}\p{N}]+$/u.test(attachment.name) ? attachment.name : sourceName
1041
- const filePath = uniqueInboundPath(targetDir, preferredName, hash)
1042
- fs.mkdirSync(targetDir, { recursive: true })
1043
- if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, buffer)
1044
- const ext = path.extname(filePath).toLowerCase()
1045
- return {
1046
- type: attachment?.type || attachmentTypeFromExt(ext),
1047
- name: path.basename(filePath),
1048
- mimeType: attachment?.mimeType || mimeTypeFromExt(ext),
1049
- size: buffer.byteLength,
1050
- localPath: filePath,
1051
- hash,
1052
- availability: 'edge-local',
1053
- }
1054
- }
1055
-
1056
- export function summarizeAttachmentObservations(observations, limit) {
1057
- return (Array.isArray(observations) ? observations : [])
1058
- .filter(row => row?.box && observationText(row))
1059
- .map(row => {
1060
- const attachment = row.attachment || attachmentFromText(observationText(row)) || attachmentFromSizeRow(row)
1061
- if (!attachment) return null
1062
- return {
1063
- text: observationText(row) || attachment.name,
1064
- role: row.role || 'unknown',
1065
- confidence: observationConfidence(row),
1066
- box: row.box,
1067
- attachment,
1068
- }
1069
- })
1070
- .filter(Boolean)
1071
- .sort((a, b) => observationBottom(a) - observationBottom(b))
1072
- .slice(-limit)
1073
- }
1074
-
1075
- function attachmentFromSizeRow(row) {
1076
- const text = normalizedMessageText(observationText(row)).replace(/,/g, '')
1077
- const box = row?.box || row
1078
- const x = Number(box?.x || 0)
1079
- if (!/^\d+(?:\.\d+)?(?:b|kb|mb|gb)$/.test(text)) return null
1080
- if (x < 260) return null
1081
- return {
1082
- type: 'file',
1083
- name: 'wechat-attachment',
1084
- mimeType: 'application/octet-stream',
1085
- availability: 'remote',
1086
- }
1087
- }
1088
-
1089
- function mergeRecentMessagesWithAttachments(recentMessages, attachmentMessages, limit) {
1090
- const merged = [...recentMessages]
1091
- for (const message of attachmentMessages) {
1092
- const existingIndex = merged.findIndex(item => item.attachment?.name === message.attachment?.name || (
1093
- item.box && message.box && Math.abs(observationBottom(item) - observationBottom(message)) < 8
1094
- ))
1095
- if (existingIndex >= 0) {
1096
- if (!merged[existingIndex].attachment && message.attachment) merged[existingIndex] = { ...merged[existingIndex], attachment: message.attachment }
1097
- } else {
1098
- merged.push(message)
1099
- }
1100
- }
1101
- return merged
1102
- .sort((a, b) => observationBottom(a) - observationBottom(b))
1103
- .slice(-limit)
1104
- }
1105
-
1106
- function clipboardSourceFile(payload) {
1107
- const files = Array.isArray(payload?.files) ? payload.files : Array.isArray(payload?.Files) ? payload.Files : []
1108
- for (const file of files) {
1109
- const candidate = String(file || '').trim()
1110
- if (candidate && fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
1111
- return { path: candidate, size: fs.statSync(candidate).size, mtimeMs: fs.statSync(candidate).mtimeMs }
1112
- }
1113
- }
1114
-
1115
- const text = String(payload?.text || payload?.Text || '')
1116
- for (const rawLine of text.split(/\r?\n/)) {
1117
- const line = rawLine.trim().replace(/^["']|["']$/g, '')
1118
- const candidate = line.startsWith('file:///') ? decodeURIComponent(line.replace(/^file:\/\/\//i, '')) : line
1119
- if (candidate && fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
1120
- return { path: candidate, size: fs.statSync(candidate).size, mtimeMs: fs.statSync(candidate).mtimeMs }
1121
- }
1122
- }
1123
-
1124
- return null
1125
- }
1126
-
1127
- function defaultWeChatCacheRoots() {
1128
- const roots = []
1129
- const home = process.env.USERPROFILE || process.env.HOME
1130
- if (home) {
1131
- roots.push(path.join(home, 'Documents', 'xwechat_files'))
1132
- roots.push(path.join(home, 'Documents', 'WeChat Files'))
1133
- }
1134
- return roots
1135
- }
1136
-
1137
- function extensionForAttachment(attachment = {}, options = {}) {
1138
- const name = String(attachment.name || '')
1139
- const ext = path.extname(name).toLowerCase()
1140
- if (ext) return ext
1141
- for (const token of Array.isArray(options.downloadExpectedTokens) ? options.downloadExpectedTokens : []) {
1142
- const tokenExt = path.extname(String(token || '')).toLowerCase()
1143
- if (tokenExt) return tokenExt
1144
- }
1145
- const mime = String(attachment.mimeType || '').toLowerCase()
1146
- if (mime === 'image/png') return '.png'
1147
- if (mime === 'image/jpeg') return '.jpg'
1148
- if (mime === 'image/gif') return '.gif'
1149
- if (mime === 'application/pdf') return '.pdf'
1150
- if (mime.includes('mp4')) return '.mp4'
1151
- return ''
1152
- }
1153
-
1154
- function cacheLookupTokens(message = {}, attachment = {}, options = {}) {
1155
- const values = [
1156
- message.text,
1157
- attachment.name,
1158
- ...(Array.isArray(options.downloadExpectedTokens) ? options.downloadExpectedTokens : []),
1159
- ].map(value => normalizedFileNameToken(value)).filter(Boolean)
1160
- const tokens = new Set()
1161
- for (const value of values) {
1162
- for (const token of value.split(/[^a-z0-9]+/i)) {
1163
- if (token.length >= 4 && !/^(file|image|video|pdf|png|jpg|jpeg|mp4)$/i.test(token)) {
1164
- tokens.add(token.toLowerCase())
1165
- }
1166
- }
1167
- if (value.length >= 8) tokens.add(value.toLowerCase())
1168
- }
1169
- return [...tokens]
1170
- }
1171
-
1172
- export function expectedDownloadTokenMessages(options = {}) {
1173
- return (Array.isArray(options.downloadExpectedTokens) ? options.downloadExpectedTokens : [])
1174
- .map((token) => {
1175
- const name = safeFileName(basenameForAnyPlatform(token))
1176
- const ext = path.extname(name).toLowerCase()
1177
- if (!ext) return null
1178
- return {
1179
- text: name,
1180
- role: 'expected-download-token',
1181
- confidence: 0,
1182
- attachment: {
1183
- type: attachmentTypeFromExt(ext),
1184
- name,
1185
- mimeType: mimeTypeFromExt(ext),
1186
- availability: 'remote',
1187
- },
1188
- }
1189
- })
1190
- .filter(Boolean)
1191
- }
1192
-
1193
- function candidateCacheFiles(roots, options = {}) {
1194
- const maxFiles = Number(options.maxCacheFiles || 5000)
1195
- const minMtimeMs = Date.now() - Number(options.maxCacheAgeMs || 14 * 24 * 60 * 60 * 1000)
1196
- const files = []
1197
- const stack = roots.filter(Boolean).map(root => path.resolve(root))
1198
- const seen = new Set()
1199
- while (stack.length && files.length < maxFiles) {
1200
- const current = stack.pop()
1201
- if (!current || seen.has(current)) continue
1202
- seen.add(current)
1203
- let entries = []
1204
- try {
1205
- entries = fs.readdirSync(current, { withFileTypes: true })
1206
- } catch {
1207
- continue
1208
- }
1209
- for (const entry of entries) {
1210
- const fullPath = path.join(current, entry.name)
1211
- if (entry.isDirectory()) {
1212
- if (/^(cache|temp|rwtemp|filestorage|msgattach|image|video|file|[0-9-]+)$/i.test(entry.name) || current.toLowerCase().includes('xwechat_files')) {
1213
- stack.push(fullPath)
1214
- }
1215
- continue
1216
- }
1217
- if (!entry.isFile()) continue
1218
- let stat
1219
- try {
1220
- stat = fs.statSync(fullPath)
1221
- } catch {
1222
- continue
1223
- }
1224
- if (stat.mtimeMs < minMtimeMs || stat.size <= 0) continue
1225
- files.push({ path: fullPath, size: stat.size, mtimeMs: stat.mtimeMs, name: entry.name })
1226
- if (files.length >= maxFiles) break
1227
- }
1228
- }
1229
- return files.sort((a, b) => b.mtimeMs - a.mtimeMs)
1230
- }
1231
-
1232
- export function findCachedInboundAttachment(message = {}, attachment = {}, options = {}) {
1233
- const ext = extensionForAttachment(attachment, options)
1234
- const tokens = cacheLookupTokens(message, attachment, options)
1235
- const roots = options.wechatCacheRoots || defaultWeChatCacheRoots()
1236
- if (!ext || tokens.length === 0 || roots.length === 0) return null
1237
- const candidates = candidateCacheFiles(roots, options)
1238
- .filter(file => path.extname(file.name).toLowerCase() === ext)
1239
- .map(file => {
1240
- const normalizedName = normalizedFileNameToken(file.name)
1241
- const score = tokens.reduce((sum, token) => sum + (normalizedName.includes(token) ? Math.min(20, token.length) : 0), 0)
1242
- return { ...file, score }
1243
- })
1244
- .filter(file => file.score > 0)
1245
- .sort((a, b) => b.score - a.score || b.mtimeMs - a.mtimeMs)
1246
- const best = candidates[0]
1247
- return best ? { path: best.path, size: best.size, mtimeMs: best.mtimeMs } : null
1248
- }
1249
-
1250
- export function findCopyMenuItem(observations) {
1251
- const rows = (Array.isArray(observations) ? observations : [])
1252
- .filter(row => row?.box && observationText(row))
1253
- .map(row => ({ row, text: normalizeText(observationText(row)).toLowerCase() }))
1254
- return rows.find(({ text }) => /^(复制|拷贝|copy)$/i.test(text))?.row
1255
- || rows.find(({ text }) => /复制|拷贝|\bcopy\b/i.test(text))?.row
1256
- || null
1257
- }
1258
-
1259
- function attachmentRightClickPoint(capturePayload, message, imageSize) {
1260
- const point = pointFromObservation(capturePayload, message, imageSize)
1261
- const box = message?.box || {}
1262
- const text = normalizedMessageText(observationText(message)).replace(/,/g, '')
1263
- const isSizeOnly = /^\d+(?:\.\d+)?(?:b|kb|mb|gb)$/.test(text)
1264
- if (!isSizeOnly) return point
1265
-
1266
- const scale = imageSize?.width && Number(box.x || 0) <= 1 && Number(box.width || 0) <= 1 ? imageSize.width : 1
1267
- const width = Number(box.width || 0) * scale
1268
- return {
1269
- x: Math.round(point.x + Math.max(48, Math.min(140, width + 72))),
1270
- y: Math.round(point.y - 18),
1271
- }
1272
- }
1273
-
1274
- async function localizeInboundAttachments(recentMessages, messageCapture, options) {
1275
- if (options.downloadAttachments === false || !options.downloadAttachmentsDir) {
1276
- return { recentMessages, downloads: [] }
1277
- }
1278
- const targetDir = path.resolve(options.downloadAttachmentsDir)
1279
- fs.mkdirSync(targetDir, { recursive: true })
1280
- const maxDownloads = downloadAttachmentLimit(options)
1281
- const downloads = []
1282
- const updated = []
1283
- const messages = [...recentMessages]
1284
- for (const expected of expectedDownloadTokenMessages(options)) {
1285
- const exists = messages.some(message => message?.attachment?.name === expected.attachment.name)
1286
- if (!exists) messages.push(expected)
1287
- }
1288
- for (const message of messages) {
1289
- if (downloads.length >= maxDownloads) {
1290
- updated.push(message)
1291
- continue
1292
- }
1293
- const attachment = message.attachment
1294
- if (!attachment || attachment.localPath || attachment.url) {
1295
- updated.push(message)
1296
- continue
1297
- }
1298
- const cached = findCachedInboundAttachment(message, attachment, options)
1299
- if (cached) {
1300
- const localized = copyInboundAttachment(cached, targetDir, attachment)
1301
- updated.push({ ...message, text: message.text || localized.name, attachment: localized })
1302
- downloads.push({ text: message.text, ok: true, strategy: 'wechat-cache-scan', sourcePath: cached.path, localPath: localized.localPath, size: localized.size })
1303
- continue
1304
- }
1305
- if (!options.allowRightClickDownload) {
1306
- const pending = {
1307
- ...message,
1308
- attachment: {
1309
- ...attachment,
1310
- availability: 'pending-download',
1311
- providerError: 'No matching local WeChat cache file found; right-click download fallback is disabled on Windows.',
1312
- },
1313
- }
1314
- updated.push(pending)
1315
- downloads.push({ text: message.text, ok: false, strategy: 'wechat-cache-scan', providerError: pending.attachment.providerError })
1316
- continue
1317
- }
1318
- if (!message.box) {
1319
- const pending = {
1320
- ...message,
1321
- attachment: {
1322
- ...attachment,
1323
- availability: 'pending-download',
1324
- providerError: 'Right-click download fallback requires an OCR box, but cache-only lookup did not find a local file.',
1325
- },
1326
- }
1327
- updated.push(pending)
1328
- downloads.push({ text: message.text, ok: false, strategy: 'right-click-copy', providerError: pending.attachment.providerError })
1329
- continue
1330
- }
1331
- const targetPoint = attachmentRightClickPoint(messageCapture.payload, message, messageCapture.imageSize)
1332
- writeDebugArtifact(options, `download-attachment-${downloads.length + 1}-target.json`, {
1333
- text: message.text,
1334
- box: message.box,
1335
- point: targetPoint,
1336
- strategy: 'right-click-copy',
1337
- })
1338
- await rightClick(targetPoint, options, ['--no-raise'])
1339
- await sleep(350)
1340
-
1341
- let copied = null
1342
- let providerError = ''
1343
- try {
1344
- const menuCapture = await capture('window', options, `context-menu-${downloads.length + 1}`)
1345
- const menuOcr = await recognizeScreenshot(menuCapture, options, 'context-menu')
1346
- writeDebugArtifact(options, `download-attachment-${downloads.length + 1}-menu.json`, menuOcr.observations)
1347
- const copyItem = findCopyMenuItem(menuOcr.observations)
1348
- if (!copyItem) {
1349
- providerError = 'Right-click attachment menu did not expose a Copy item'
1350
- } else {
1351
- await click(pointFromObservation(menuCapture.payload, copyItem, menuCapture.imageSize), options, ['--no-raise'])
1352
- await sleep(350)
1353
- const clipboard = await readClipboard(options)
1354
- writeDebugArtifact(options, `download-attachment-${downloads.length + 1}-clipboard.json`, clipboard)
1355
- copied = clipboardSourceFile(clipboard)
1356
- if (!copied) providerError = 'Right-click Copy did not put a local file path on the clipboard'
1357
- }
1358
- } finally {
1359
- await dismissMenus(options, 2).catch(() => {})
1360
- await press('{ESC}', options).catch(() => {})
1361
- }
1362
-
1363
- if (!copied) {
1364
- const pending = {
1365
- ...message,
1366
- attachment: {
1367
- ...attachment,
1368
- availability: 'pending-download',
1369
- providerError,
1370
- },
1371
- }
1372
- updated.push(pending)
1373
- downloads.push({ text: message.text, ok: false, providerError: pending.attachment.providerError })
1374
- continue
1375
- }
1376
- const localized = copyInboundAttachment(copied, targetDir, attachment)
1377
- updated.push({ ...message, text: message.text || localized.name, attachment: localized })
1378
- downloads.push({ text: message.text, ok: true, sourcePath: copied.path, localPath: localized.localPath, size: localized.size })
1379
- }
1380
- return { recentMessages: updated, downloads }
1381
- }
1382
-
1383
- export function downloadAttachmentLimit(options = {}) {
1384
- const value = Number(options.downloadLimit ?? 1)
1385
- if (!Number.isFinite(value) || value < 1) return 1
1386
- // Windows WeChat can lose its enumerable main window after repeated
1387
- // right-click attachment menus. Keep live runs to one attachment until this
1388
- // path has a separate stability gate.
1389
- return Math.min(1, Math.floor(value))
1390
- }
1391
-
1392
- export function missingConfirmedFiles(observations, files) {
1393
- const rows = Array.isArray(observations) ? observations : []
1394
- const combinedText = normalizedMessageText(rows.map(row => observationText(row)).join(''))
1395
- const combinedFileToken = normalizedFileNameToken(rows.map(row => observationText(row)).join(''))
1396
- return (files || []).filter(file => {
1397
- const basename = basenameForAnyPlatform(file)
1398
- const normalizedBasename = normalizedMessageText(basename)
1399
- const normalizedFileToken = normalizedFileNameToken(basename)
1400
- if (combinedText.includes(normalizedBasename)) return false
1401
- if (normalizedFileToken && combinedFileToken.includes(normalizedFileToken)) return false
1402
- return !rows.some(row => {
1403
- const text = normalizedMessageText(observationText(row))
1404
- const attachmentName = normalizedMessageText(row?.attachment?.filename || row?.attachment?.name)
1405
- const fileText = normalizedFileNameToken(observationText(row))
1406
- const fileAttachmentName = normalizedFileNameToken(row?.attachment?.filename || row?.attachment?.name)
1407
- return text.includes(normalizedBasename)
1408
- || attachmentName.includes(normalizedBasename)
1409
- || (normalizedFileToken && fileText.includes(normalizedFileToken))
1410
- || (normalizedFileToken && fileAttachmentName.includes(normalizedFileToken))
1411
- })
1412
- })
1413
- }
1414
-
1415
- function normalizedFileNameToken(value) {
1416
- return String(value || '')
1417
- .normalize('NFKC')
1418
- .toLowerCase()
1419
- .replace(/w[l1i]n/g, 'win')
1420
- .replace(/[\s._\-—–一.。·,,::;;/\\|[\]()(){}<>《》"'“”‘’]+/g, '')
1421
- .replace(/[^a-z0-9\u4e00-\u9fff]+/g, '')
1422
- }
1423
-
1424
- async function openConversationBySearch(options, artifacts) {
1425
- const initial = await capture('window', options, 'window-before-search')
1426
- artifacts.push(initial.file)
1427
- try {
1428
- const initialTitleOcr = await recognizeScreenshot(initial, options, 'title-confirmation')
1429
- if (findTitleConfirmation(initialTitleOcr.observations, options.group)) {
1430
- initial.openEvidence = {
1431
- strategy: 'current-title',
1432
- searchUsed: false,
1433
- leftListUsed: false,
1434
- titleConfirmed: true,
1435
- }
1436
- return initial
1437
- }
1438
- } catch (error) {
1439
- process.stderr.write(`Initial title OCR was skipped: ${error instanceof Error ? error.message : String(error)}\n`)
1440
- }
1441
- const initialOcr = await recognizeScreenshot(initial, options, 'left-list')
1442
- const leftListTarget = findConversationInLeftList(initialOcr.observations, options.group)
1443
- if (leftListTarget) {
1444
- const targetPoint = pointFromObservation(initial.payload, leftListTarget, initial.imageSize)
1445
- writeDebugArtifact(options, 'open-target-left-list.json', {
1446
- group: options.group,
1447
- text: observationText(leftListTarget),
1448
- box: leftListTarget.box,
1449
- point: targetPoint,
1450
- })
1451
- await click(targetPoint, options, ['--no-raise'])
1452
- await sleep(650)
1453
- const opened = await waitForStableWindow(options, 'opened-from-left-list', { attempts: 6, delayMs: 800 })
1454
- artifacts.push(opened.file)
1455
- const titleOcr = await recognizeScreenshot(opened, options, 'title-confirmation')
1456
- const titleConfirmed = Boolean(findTitleConfirmation(titleOcr.observations, options.group))
1457
- if (!titleConfirmed) {
1458
- if (!options.allowWeakTitle) throw new Error(`Opened left-list conversation title was not confirmed as '${options.group}'. See ${opened.file}`)
1459
- }
1460
- opened.openEvidence = {
1461
- strategy: 'left-list',
1462
- searchUsed: false,
1463
- leftListUsed: true,
1464
- titleConfirmed,
1465
- targetText: observationText(leftListTarget),
1466
- }
1467
- return opened
1468
- }
1469
-
1470
- assertSearchOpenAllowed(options, options.group)
1471
-
1472
- await click(geometryPoint(initial.payload, 'search'), options)
1473
- await sleep(180)
1474
- await press('^a', options)
1475
- await pasteText(options.group, options)
1476
- await sleep(650)
1477
-
1478
- const searchCapture = await capture('window', options, 'search-results')
1479
- artifacts.push(searchCapture.file)
1480
- const searchOcr = await recognizeScreenshot(searchCapture, options, 'search-results')
1481
- const target = findConversationInSearchResults(searchOcr.observations, options.group)
1482
- if (!target) {
1483
- throw new Error(`Could not find target group '${options.group}' under search result section '群聊'. See ${searchCapture.file}`)
1484
- }
1485
- const targetPoint = pointFromObservation(searchCapture.payload, target, searchCapture.imageSize)
1486
- writeDebugArtifact(options, 'open-target-search.json', {
1487
- group: options.group,
1488
- text: observationText(target),
1489
- box: target.box,
1490
- point: targetPoint,
1491
- })
1492
- await click(targetPoint, options, ['--no-raise'])
1493
- await sleep(350)
1494
- // WeChat 4.x can keep the transient search result panel open after clicking a result.
1495
- // Close it explicitly so later input/send geometry always targets the conversation composer.
1496
- await press('{ESC}', options)
1497
- await sleep(500)
1498
-
1499
- const opened = await waitForStableWindow(options, 'opened-conversation', { attempts: 6, delayMs: 800 })
1500
- artifacts.push(opened.file)
1501
- const titleOcr = await recognizeScreenshot(opened, options, 'title-confirmation')
1502
- const titleConfirmed = Boolean(findTitleConfirmation(titleOcr.observations, options.group))
1503
- if (!titleConfirmed) {
1504
- // Some OCR models miss the top title but the click may still be correct. Keep the debug capture and continue
1505
- // only if the caller explicitly opted out of strict confirmation.
1506
- if (!options.allowWeakTitle) throw new Error(`Opened conversation title was not confirmed as '${options.group}'. See ${opened.file}`)
1507
- }
1508
- opened.openEvidence = {
1509
- strategy: 'search',
1510
- searchUsed: true,
1511
- leftListUsed: false,
1512
- titleConfirmed,
1513
- targetText: observationText(target),
1514
- }
1515
- return opened
1516
- }
1517
-
1518
- export function buildActionEvidence({ options = {}, opened = {}, sent = [], downloads = [], postRunSafety = {}, artifacts = [], layout = {} } = {}) {
1519
- const confirmationMethods = []
1520
- for (const item of Array.isArray(sent) ? sent : []) {
1521
- if (item?.confirmationMethod) confirmationMethods.push(item.confirmationMethod)
1522
- if (Array.isArray(item?.confirmationMethods)) confirmationMethods.push(...item.confirmationMethods)
1523
- }
1524
- const downloadStrategies = Array.from(new Set((Array.isArray(downloads) ? downloads : [])
1525
- .map(item => item?.strategy)
1526
- .filter(Boolean)))
1527
- const openEvidence = opened?.openEvidence || {}
1528
- return {
1529
- targetGroup: options.group || '',
1530
- openStrategy: openEvidence.strategy || 'unknown',
1531
- searchUsed: Boolean(openEvidence.searchUsed),
1532
- leftListUsed: Boolean(openEvidence.leftListUsed),
1533
- titleConfirmed: Boolean(openEvidence.titleConfirmed),
1534
- searchOpenAllowed: Boolean(options.allowSearchOpen),
1535
- rightClickDownloadAllowed: Boolean(options.allowRightClickDownload),
1536
- rightClickUsed: downloadStrategies.includes('right-click-copy'),
1537
- downloadStrategies,
1538
- sentTypes: (Array.isArray(sent) ? sent : []).map(item => item?.type).filter(Boolean),
1539
- confirmationMethods: Array.from(new Set(confirmationMethods)),
1540
- postRunSafetyOk: postRunSafety?.ok === true,
1541
- postRunSafetyArtifact: postRunSafety?.artifact || '',
1542
- artifactCount: Array.isArray(artifacts) ? artifacts.length : 0,
1543
- layout: {
1544
- currentTitle: layout?.currentTitle || '',
1545
- messageCount: Number(layout?.messageCount || 0),
1546
- attachmentCount: Number(layout?.attachmentCount || 0),
1547
- hasInputBox: Boolean(layout?.inputBox),
1548
- hasMessageArea: Boolean(layout?.messageArea),
1549
- },
1550
- }
1551
- }
1552
-
1553
- export async function runVisualFlow(input) {
1554
- const captureDir = path.resolve(input.captureDir || fs.mkdtempSync(path.join(fs.realpathSync(process.env.TEMP || process.env.TMP || '/tmp'), 'shennian-wechat-rpa-win-visual-')))
1555
- prepareCaptureDir(captureDir)
1556
- const options = {
1557
- ...input,
1558
- captureDir,
1559
- step: 1,
1560
- recentLimit: Math.max(Number(input.recentLimit || 5), input.downloadAttachmentsDir && input.downloadAttachments !== false ? 20 : 0),
1561
- ocrTimeoutMs: Number(input.ocrTimeoutMs || 45_000),
1562
- openTimeoutMs: Number(input.openTimeoutMs || 12_000),
1563
- }
1564
- if (!options.group) throw new Error('--group is required')
1565
- const artifacts = []
1566
-
1567
- const opened = await openConversationBySearch(options, artifacts)
1568
- const messageCapture = await capture('messages', options, 'messages-before-send')
1569
- artifacts.push(messageCapture.file)
1570
- const messageOcr = await recognizeScreenshot(messageCapture, options, 'message-read')
1571
- let recentMessages = summarizeObservations(messageOcr.observations, options.recentLimit)
1572
- recentMessages = mergeRecentMessagesWithAttachments(
1573
- recentMessages,
1574
- summarizeAttachmentObservations(messageOcr.observations, options.recentLimit),
1575
- options.recentLimit,
1576
- )
1577
- const localization = await localizeInboundAttachments(recentMessages, messageCapture, options)
1578
- recentMessages = localization.recentMessages
1579
-
1580
- const sent = []
1581
- if (!options.dryRun && (options.replyText || options.files?.length)) {
1582
- await assertComposerEmptyBeforeSend(options, artifacts)
1583
- }
1584
- if (!options.dryRun && options.replyText) {
1585
- await click(geometryPoint(opened.payload, 'input'), options)
1586
- await pasteText(options.replyText, options)
1587
- await sleep(250)
1588
- await press('{ENTER}', options)
1589
- await sleep(900)
1590
- const confirmCapture = await capture('window', options, 'after-text-send')
1591
- artifacts.push(confirmCapture.file)
1592
- const confirmOcr = await recognizeScreenshot(confirmCapture, options, 'send-confirmation')
1593
- const confirmation = await confirmSentText(confirmOcr, options.replyText, opened, confirmCapture)
1594
- if (!confirmation.observation) {
1595
- throw new Error(`Sent text was not confirmed by OCR or outbound bubble diff: '${options.replyText}'. See ${confirmCapture.file}`)
1596
- }
1597
- sent.push({
1598
- type: 'text',
1599
- text: options.replyText,
1600
- confirmationMethod: confirmation.method,
1601
- observations: summarizeObservations([confirmation.observation], options.recentLimit),
1602
- })
1603
- }
1604
-
1605
- if (!options.dryRun && options.files?.length) {
1606
- const confirmedFiles = []
1607
- const sentAttachments = []
1608
- const confirmationObservations = []
1609
- const confirmationMethods = []
1610
- for (const [index, file] of options.files.entries()) {
1611
- let pendingCapture = null
1612
- let pendingOcr = null
1613
- let sendButton = null
1614
- for (let attempt = 1; attempt <= 2; attempt += 1) {
1615
- await click(geometryPoint(opened.payload, 'fileInput'), options)
1616
- await sleep(120)
1617
- await pasteFiles([file], options)
1618
- await sleep(950)
1619
- pendingCapture = await captureWithRetry('window', options, `pending-file-${index + 1}${attempt > 1 ? `-retry-${attempt}` : ''}`, { attempts: 8, delayMs: 900 })
1620
- artifacts.push(pendingCapture.file)
1621
- pendingOcr = await recognizeScreenshot(pendingCapture, options, 'send-button')
1622
- sendButton = findSendButtonObservation(pendingOcr.observations)
1623
- if (sendButton || missingConfirmedFiles(pendingOcr.observations, [file]).length === 0) break
1624
- }
1625
- if (!pendingCapture || !pendingOcr) throw new Error(`File was not captured after paste: ${basenameForAnyPlatform(file)}`)
1626
- if (!sendButton && missingConfirmedFiles(pendingOcr.observations, [file]).length > 0) {
1627
- throw new Error(`File did not appear in the WeChat composer after paste: ${basenameForAnyPlatform(file)}. See ${pendingCapture.file}`)
1628
- }
1629
- if (sendButton) {
1630
- await click(pointFromObservation(pendingCapture.payload, sendButton, pendingCapture.imageSize), options)
1631
- } else {
1632
- await press('{ENTER}', options)
1633
- }
1634
- await sleep(postPasteDelayMs(file))
1635
- const fileConfirmCapture = await capture('window', options, `after-file-${index + 1}-send`)
1636
- artifacts.push(fileConfirmCapture.file)
1637
- const fileConfirmOcr = await recognizeScreenshot(fileConfirmCapture, options, 'send-confirmation')
1638
- const missing = missingConfirmedFiles(fileConfirmOcr.observations, [file])
1639
- if (missing.length > 0) {
1640
- const visual = await detectNewOutboundAttachmentBubble(attachmentVisualBeforeFile(opened, pendingCapture), fileConfirmCapture.file)
1641
- if (!visual?.ok) {
1642
- throw new Error(`Sent file was not confirmed by OCR or outbound attachment diff: ${basenameForAnyPlatform(file)}. See ${fileConfirmCapture.file}`)
1643
- }
1644
- confirmationMethods.push('visual-new-outbound-attachment')
1645
- confirmationObservations.push({
1646
- text: basenameForAnyPlatform(file),
1647
- role: 'outbound-visual',
1648
- confidence: 0,
1649
- box: visual.box,
1650
- visual,
1651
- })
1652
- } else {
1653
- confirmationMethods.push('ocr-file-name')
1654
- confirmationObservations.push(...summarizeObservations(fileConfirmOcr.observations, options.recentLimit))
1655
- }
1656
- confirmedFiles.push(file)
1657
- sentAttachments.push(classifyOutboundFile(file))
1658
- }
1659
- sent.push({
1660
- type: 'files',
1661
- files: confirmedFiles,
1662
- confirmationMethods,
1663
- attachments: sentAttachments,
1664
- observations: confirmationObservations.slice(-options.recentLimit),
1665
- })
1666
- }
1667
-
1668
- const postRunSafety = await verifyPostRunSafety(options, artifacts)
1669
-
1670
- const layoutSummary = summarizeCoreLayout(messageOcr.layout)
1671
- const summary = {
1672
- ok: true,
1673
- group: options.group,
1674
- captureDir,
1675
- ocrEvidence: summarizeOcrEvidence(messageOcr),
1676
- layout: layoutSummary,
1677
- recentMessages,
1678
- downloads: localization.downloads,
1679
- sent,
1680
- postRunSafety,
1681
- artifacts,
1682
- }
1683
- summary.actionEvidence = buildActionEvidence({
1684
- options,
1685
- opened,
1686
- sent,
1687
- downloads: localization.downloads,
1688
- postRunSafety,
1689
- artifacts,
1690
- layout: layoutSummary,
1691
- })
1692
- fs.writeFileSync(path.join(captureDir, 'summary.json'), `${JSON.stringify(summary, null, 2)}\n`)
1693
- return summary
1694
- }
1695
-
1696
- async function main() {
1697
- const argv = process.argv.slice(2)
1698
- if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
1699
- printHelp()
1700
- return
1701
- }
1702
- const files = takeMany(argv, '--file')
1703
- const options = {
1704
- group: takeOption(argv, '--group'),
1705
- replyText: takeOption(argv, '--reply-text'),
1706
- recentLimit: takeOption(argv, '--recent-limit') || '5',
1707
- captureDir: takeOption(argv, '--capture-dir'),
1708
- downloadAttachmentsDir: takeOption(argv, '--download-attachments-dir'),
1709
- downloadExpectedTokens: takeMany(argv, '--download-expected-token'),
1710
- downloadLimit: takeOption(argv, '--download-limit'),
1711
- allowRightClickDownload: takeFlag(argv, '--allow-right-click-download'),
1712
- allowSearchOpen: takeFlag(argv, '--allow-search-open'),
1713
- ocrUrl: takeOption(argv, '--ocr-url'),
1714
- token: takeOption(argv, '--token'),
1715
- ocrFixture: takeOption(argv, '--ocr-fixture'),
1716
- helper: takeOption(argv, '--helper'),
1717
- channelId: takeOption(argv, '--channel-id'),
1718
- ocrTimeoutMs: Number(takeOption(argv, '--ocr-timeout-ms') || 45_000),
1719
- openTimeoutMs: Number(takeOption(argv, '--open-timeout-ms') || 12_000),
1720
- allowWeakTitle: takeFlag(argv, '--allow-weak-title'),
1721
- downloadAttachments: !takeFlag(argv, '--no-download-attachments'),
1722
- dryRun: takeFlag(argv, '--dry-run'),
1723
- files,
1724
- }
1725
- if (argv.length) throw new Error(`Unknown arguments: ${argv.join(' ')}`)
1726
- const summary = await runVisualFlow(options)
1727
- process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`)
1728
- }
1729
-
1730
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
1731
- main().catch(error => {
1732
- console.error(error instanceof Error ? error.message : String(error))
1733
- process.exit(1)
1734
- })
1735
- }