shennian 0.2.88 → 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.
- package/dist/assets/wechat-channel/macos/manifest.json +13 -0
- package/dist/assets/wechat-channel/macos/shennian-wechat-channel-helper +0 -0
- package/dist/src/agents/adapter.d.ts +6 -0
- package/dist/src/agents/codex-control.d.ts +35 -0
- package/dist/src/agents/codex-control.js +188 -0
- package/dist/src/agents/codex.d.ts +8 -0
- package/dist/src/agents/codex.js +53 -0
- package/dist/src/channels/base.d.ts +4 -1
- package/dist/src/channels/runtime.d.ts +1 -0
- package/dist/src/channels/runtime.js +32 -1
- package/dist/src/channels/secret-registry.d.ts +1 -0
- package/dist/src/channels/wechat-channel/anchor.d.ts +10 -0
- package/dist/src/channels/wechat-channel/anchor.js +65 -0
- package/dist/src/channels/wechat-channel/client.d.ts +74 -0
- package/dist/src/channels/wechat-channel/client.js +96 -0
- package/dist/src/channels/wechat-channel/cooldown.d.ts +15 -0
- package/dist/src/channels/wechat-channel/cooldown.js +38 -0
- package/dist/src/channels/wechat-channel/fingerprint.d.ts +28 -0
- package/dist/src/channels/wechat-channel/fingerprint.js +71 -0
- package/dist/src/channels/wechat-channel/helper-assets.d.ts +28 -0
- package/dist/src/channels/wechat-channel/helper-assets.js +68 -0
- package/dist/src/channels/wechat-channel/helper-client.d.ts +25 -0
- package/dist/src/channels/wechat-channel/helper-client.js +149 -0
- package/dist/src/channels/wechat-channel/helper-protocol.d.ts +84 -0
- package/dist/src/channels/wechat-channel/helper-protocol.js +115 -0
- package/dist/src/channels/wechat-channel/index.d.ts +16 -0
- package/dist/src/channels/wechat-channel/index.js +19 -0
- package/dist/src/channels/wechat-channel/ledger.d.ts +33 -0
- package/dist/src/channels/wechat-channel/ledger.js +54 -0
- package/dist/src/channels/wechat-channel/media-resolver.d.ts +32 -0
- package/dist/src/channels/wechat-channel/media-resolver.js +181 -0
- package/dist/src/channels/wechat-channel/message-key.d.ts +19 -0
- package/dist/src/channels/wechat-channel/message-key.js +105 -0
- package/dist/src/channels/wechat-channel/observer.d.ts +64 -0
- package/dist/src/channels/wechat-channel/observer.js +118 -0
- package/dist/src/channels/wechat-channel/outbound-ledger.d.ts +66 -0
- package/dist/src/channels/wechat-channel/outbound-ledger.js +112 -0
- package/dist/src/channels/wechat-channel/preflight.d.ts +37 -0
- package/dist/src/channels/wechat-channel/preflight.js +48 -0
- package/dist/src/channels/wechat-channel/runner.d.ts +34 -0
- package/dist/src/channels/wechat-channel/runner.js +84 -0
- package/dist/src/channels/wechat-channel/runtime.d.ts +45 -0
- package/dist/src/channels/wechat-channel/runtime.js +66 -0
- package/dist/src/channels/wechat-channel/scheduler.d.ts +30 -0
- package/dist/src/channels/wechat-channel/scheduler.js +152 -0
- package/dist/src/channels/wechat-rpa.d.ts +21 -0
- package/dist/src/channels/wechat-rpa.js +12 -6
- package/dist/src/commands/manager.js +2 -0
- package/dist/src/fs/text-decoder.d.ts +10 -0
- package/dist/src/fs/text-decoder.js +110 -0
- package/dist/src/manager/runtime.js +4 -0
- package/dist/src/native-fusion/service.d.ts +10 -0
- package/dist/src/native-fusion/service.js +27 -0
- package/dist/src/session/handlers/chat.js +18 -0
- package/dist/src/session/handlers/fs.js +39 -3
- package/dist/src/session/handlers/session-refresh.js +12 -0
- package/dist/src/session/handlers/tool-detail.d.ts +3 -0
- package/dist/src/session/handlers/tool-detail.js +218 -0
- package/dist/src/session/manager.d.ts +3 -0
- package/dist/src/session/manager.js +58 -0
- package/dist/src/session/types.d.ts +4 -0
- package/package.json +2 -2
- package/dist/scripts/wechat-rpa-download-candidates.mjs +0 -105
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// @arch docs/features/wechat-rpa-helper-runtime.md
|
|
2
|
+
// @test src/__tests__/wechat-channel-helper-protocol.test.ts
|
|
3
|
+
export const WECHAT_CHANNEL_HELPER_PROTOCOL_VERSION = 1;
|
|
4
|
+
export const WECHAT_CHANNEL_HELPER_COMMAND_TIMEOUT_MS = {
|
|
5
|
+
healthCheck: 2_000,
|
|
6
|
+
ocrRecognize: 10_000,
|
|
7
|
+
windowsCapture: 5_000,
|
|
8
|
+
mouseKeyboard: 3_000,
|
|
9
|
+
clipboard: 5_000,
|
|
10
|
+
menuPickItem: 5_000,
|
|
11
|
+
};
|
|
12
|
+
export const WECHAT_CHANNEL_REQUIRED_HELPER_CAPABILITIES = [
|
|
13
|
+
'screenCapture',
|
|
14
|
+
'visionOcr',
|
|
15
|
+
'windowList',
|
|
16
|
+
'windowFocus',
|
|
17
|
+
'mouseKeyboard',
|
|
18
|
+
'clipboard',
|
|
19
|
+
'contextMenu',
|
|
20
|
+
'imageCropHash',
|
|
21
|
+
];
|
|
22
|
+
export function createWeChatChannelHelperHello(expectedHelperVersion) {
|
|
23
|
+
const version = String(expectedHelperVersion || '').trim();
|
|
24
|
+
if (!version)
|
|
25
|
+
throw new Error('WeChat channel helper expected version is required');
|
|
26
|
+
return {
|
|
27
|
+
type: 'hello',
|
|
28
|
+
protocolVersion: WECHAT_CHANNEL_HELPER_PROTOCOL_VERSION,
|
|
29
|
+
expectedHelperVersion: version,
|
|
30
|
+
capabilities: [...WECHAT_CHANNEL_REQUIRED_HELPER_CAPABILITIES],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function validateWeChatChannelHelperReady(ready, expectedHelperVersion) {
|
|
34
|
+
if (!ready || ready.type !== 'ready') {
|
|
35
|
+
return { ok: false, errorCode: 'helper_invalid_response', errorSummary: 'Helper did not send a ready frame' };
|
|
36
|
+
}
|
|
37
|
+
if (ready.protocolVersion !== WECHAT_CHANNEL_HELPER_PROTOCOL_VERSION) {
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
errorCode: 'helper_protocol_mismatch',
|
|
41
|
+
errorSummary: `Helper protocol ${ready.protocolVersion} does not match ${WECHAT_CHANNEL_HELPER_PROTOCOL_VERSION}`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (ready.helperVersion !== expectedHelperVersion) {
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
errorCode: 'helper_version_mismatch',
|
|
48
|
+
errorSummary: `Helper version ${ready.helperVersion} does not match ${expectedHelperVersion}`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const missing = WECHAT_CHANNEL_REQUIRED_HELPER_CAPABILITIES.filter((capability) => !ready.capabilities.includes(capability));
|
|
52
|
+
if (missing.length) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
errorCode: 'helper_capability_missing',
|
|
56
|
+
errorSummary: `Helper missing capabilities: ${missing.join(', ')}`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return { ok: true };
|
|
60
|
+
}
|
|
61
|
+
export function extractWeChatChannelHelperWarmupSnapshot(value) {
|
|
62
|
+
if (!value || typeof value !== 'object')
|
|
63
|
+
return null;
|
|
64
|
+
const record = value;
|
|
65
|
+
if (!isWeChatChannelHelperWarmState(record.warmState))
|
|
66
|
+
return null;
|
|
67
|
+
const metrics = normalizeWeChatChannelHelperWarmupMetrics(record.warmup);
|
|
68
|
+
return metrics ? { warmState: record.warmState, metrics } : { warmState: record.warmState };
|
|
69
|
+
}
|
|
70
|
+
export function timeoutForWeChatChannelHelperCommand(command) {
|
|
71
|
+
if (command === 'health.check')
|
|
72
|
+
return WECHAT_CHANNEL_HELPER_COMMAND_TIMEOUT_MS.healthCheck;
|
|
73
|
+
if (command === 'ocr.recognize')
|
|
74
|
+
return WECHAT_CHANNEL_HELPER_COMMAND_TIMEOUT_MS.ocrRecognize;
|
|
75
|
+
if (command === 'windows.capture')
|
|
76
|
+
return WECHAT_CHANNEL_HELPER_COMMAND_TIMEOUT_MS.windowsCapture;
|
|
77
|
+
if (command.startsWith('mouse.') || command.startsWith('keyboard.'))
|
|
78
|
+
return WECHAT_CHANNEL_HELPER_COMMAND_TIMEOUT_MS.mouseKeyboard;
|
|
79
|
+
if (command.startsWith('clipboard.'))
|
|
80
|
+
return WECHAT_CHANNEL_HELPER_COMMAND_TIMEOUT_MS.clipboard;
|
|
81
|
+
if (command === 'menu.pickItem')
|
|
82
|
+
return WECHAT_CHANNEL_HELPER_COMMAND_TIMEOUT_MS.menuPickItem;
|
|
83
|
+
return WECHAT_CHANNEL_HELPER_COMMAND_TIMEOUT_MS.healthCheck;
|
|
84
|
+
}
|
|
85
|
+
function isWeChatChannelHelperWarmState(value) {
|
|
86
|
+
return value === 'cold' || value === 'warming' || value === 'warm' || value === 'failed';
|
|
87
|
+
}
|
|
88
|
+
function normalizeWeChatChannelHelperWarmupMetrics(value) {
|
|
89
|
+
if (!value || typeof value !== 'object')
|
|
90
|
+
return undefined;
|
|
91
|
+
const source = value;
|
|
92
|
+
const metrics = {};
|
|
93
|
+
copyStringField(source, metrics, 'startedAt');
|
|
94
|
+
copyStringField(source, metrics, 'readyAt');
|
|
95
|
+
copyStringField(source, metrics, 'warmupStartedAt');
|
|
96
|
+
copyStringField(source, metrics, 'warmupCompletedAt');
|
|
97
|
+
copyNumberField(source, metrics, 'coldStartMs');
|
|
98
|
+
copyNumberField(source, metrics, 'warmupMs');
|
|
99
|
+
copyNumberField(source, metrics, 'firstOcrMs');
|
|
100
|
+
copyNumberField(source, metrics, 'warmOcrMs');
|
|
101
|
+
copyNumberField(source, metrics, 'lastOcrMs');
|
|
102
|
+
copyNumberField(source, metrics, 'ocrSampleCount');
|
|
103
|
+
copyStringField(source, metrics, 'errorCode');
|
|
104
|
+
copyStringField(source, metrics, 'errorSummary');
|
|
105
|
+
return Object.keys(metrics).length > 0 ? metrics : undefined;
|
|
106
|
+
}
|
|
107
|
+
function copyStringField(source, target, key) {
|
|
108
|
+
if (typeof source[key] === 'string')
|
|
109
|
+
target[key] = source[key];
|
|
110
|
+
}
|
|
111
|
+
function copyNumberField(source, target, key) {
|
|
112
|
+
const value = source[key];
|
|
113
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 0)
|
|
114
|
+
target[key] = value;
|
|
115
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export * from './runtime.js';
|
|
2
|
+
export * from './helper-protocol.js';
|
|
3
|
+
export * from './helper-client.js';
|
|
4
|
+
export * from './helper-assets.js';
|
|
5
|
+
export * from './client.js';
|
|
6
|
+
export * from './ledger.js';
|
|
7
|
+
export * from './scheduler.js';
|
|
8
|
+
export * from './fingerprint.js';
|
|
9
|
+
export * from './observer.js';
|
|
10
|
+
export * from './anchor.js';
|
|
11
|
+
export * from './outbound-ledger.js';
|
|
12
|
+
export * from './media-resolver.js';
|
|
13
|
+
export * from './preflight.js';
|
|
14
|
+
export * from './cooldown.js';
|
|
15
|
+
export * from './message-key.js';
|
|
16
|
+
export * from './runner.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// @arch docs/features/wechat-rpa-productization-plan.md
|
|
2
|
+
// @test src/__tests__/wechat-channel-runtime.test.ts
|
|
3
|
+
// @test src/__tests__/wechat-channel-helper-protocol.test.ts
|
|
4
|
+
export * from './runtime.js';
|
|
5
|
+
export * from './helper-protocol.js';
|
|
6
|
+
export * from './helper-client.js';
|
|
7
|
+
export * from './helper-assets.js';
|
|
8
|
+
export * from './client.js';
|
|
9
|
+
export * from './ledger.js';
|
|
10
|
+
export * from './scheduler.js';
|
|
11
|
+
export * from './fingerprint.js';
|
|
12
|
+
export * from './observer.js';
|
|
13
|
+
export * from './anchor.js';
|
|
14
|
+
export * from './outbound-ledger.js';
|
|
15
|
+
export * from './media-resolver.js';
|
|
16
|
+
export * from './preflight.js';
|
|
17
|
+
export * from './cooldown.js';
|
|
18
|
+
export * from './message-key.js';
|
|
19
|
+
export * from './runner.js';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { WeChatChannelObservedMessage } from './client.js';
|
|
2
|
+
import type { WeChatChannelCooldownState } from './cooldown.js';
|
|
3
|
+
export type WeChatChannelLedger = {
|
|
4
|
+
version: 1;
|
|
5
|
+
runtimeId: string;
|
|
6
|
+
bindings: Record<string, WeChatChannelBindingLedger>;
|
|
7
|
+
};
|
|
8
|
+
export type WeChatChannelBindingLedger = {
|
|
9
|
+
bindingId: string;
|
|
10
|
+
baselineEstablished: boolean;
|
|
11
|
+
disabledSince?: string | null;
|
|
12
|
+
revision: number;
|
|
13
|
+
recent: WeChatChannelObservedMessage[];
|
|
14
|
+
pendingSendKeys: string[];
|
|
15
|
+
cooldown?: WeChatChannelCooldownState;
|
|
16
|
+
};
|
|
17
|
+
export declare function loadWeChatChannelLedger(filePath: string, runtimeId: string): WeChatChannelLedger;
|
|
18
|
+
export declare function saveWeChatChannelLedger(filePath: string, ledger: WeChatChannelLedger): void;
|
|
19
|
+
export declare function updateWeChatChannelBindingLedger(input: {
|
|
20
|
+
ledger: WeChatChannelLedger;
|
|
21
|
+
bindingId: string;
|
|
22
|
+
observedMessages: WeChatChannelObservedMessage[];
|
|
23
|
+
baselineOnly?: boolean;
|
|
24
|
+
}): {
|
|
25
|
+
binding: WeChatChannelBindingLedger;
|
|
26
|
+
newMessages: WeChatChannelObservedMessage[];
|
|
27
|
+
};
|
|
28
|
+
export declare function markWeChatChannelBindingDisabled(input: {
|
|
29
|
+
ledger: WeChatChannelLedger;
|
|
30
|
+
bindingId: string;
|
|
31
|
+
disabledAt?: Date;
|
|
32
|
+
}): WeChatChannelBindingLedger;
|
|
33
|
+
export declare function filterDeliverableWeChatMessages(previous: WeChatChannelObservedMessage[], current: WeChatChannelObservedMessage[]): WeChatChannelObservedMessage[];
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// @arch docs/features/wechat-rpa-productization-plan.md
|
|
2
|
+
// @test src/__tests__/wechat-channel-ledger.test.ts
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { WECHAT_CHANNEL_RECENT_MESSAGE_WINDOW } from './runtime.js';
|
|
6
|
+
import { filterNewWeChatMessagesByAnchor } from './anchor.js';
|
|
7
|
+
import { normalizeWeChatObservedWindowForLedger } from './message-key.js';
|
|
8
|
+
export function loadWeChatChannelLedger(filePath, runtimeId) {
|
|
9
|
+
try {
|
|
10
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
11
|
+
if (parsed?.version === 1 && parsed.runtimeId === runtimeId && parsed.bindings && typeof parsed.bindings === 'object')
|
|
12
|
+
return parsed;
|
|
13
|
+
}
|
|
14
|
+
catch { }
|
|
15
|
+
return { version: 1, runtimeId, bindings: {} };
|
|
16
|
+
}
|
|
17
|
+
export function saveWeChatChannelLedger(filePath, ledger) {
|
|
18
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
19
|
+
fs.writeFileSync(filePath, JSON.stringify(ledger, null, 2));
|
|
20
|
+
}
|
|
21
|
+
export function updateWeChatChannelBindingLedger(input) {
|
|
22
|
+
const existing = input.ledger.bindings[input.bindingId];
|
|
23
|
+
const retained = normalizeWeChatObservedWindowForLedger(input.observedMessages).slice(-WECHAT_CHANNEL_RECENT_MESSAGE_WINDOW);
|
|
24
|
+
const baselineOnly = input.baselineOnly || !existing?.baselineEstablished || Boolean(existing?.disabledSince);
|
|
25
|
+
const newMessages = baselineOnly ? [] : filterDeliverableWeChatMessages(existing?.recent ?? [], retained);
|
|
26
|
+
const binding = {
|
|
27
|
+
bindingId: input.bindingId,
|
|
28
|
+
baselineEstablished: true,
|
|
29
|
+
disabledSince: null,
|
|
30
|
+
revision: (existing?.revision ?? 0) + (newMessages.length > 0 ? 1 : 0),
|
|
31
|
+
recent: retained,
|
|
32
|
+
pendingSendKeys: existing?.pendingSendKeys ?? [],
|
|
33
|
+
cooldown: existing?.cooldown,
|
|
34
|
+
};
|
|
35
|
+
input.ledger.bindings[input.bindingId] = binding;
|
|
36
|
+
return { binding, newMessages };
|
|
37
|
+
}
|
|
38
|
+
export function markWeChatChannelBindingDisabled(input) {
|
|
39
|
+
const existing = input.ledger.bindings[input.bindingId];
|
|
40
|
+
const binding = {
|
|
41
|
+
bindingId: input.bindingId,
|
|
42
|
+
baselineEstablished: false,
|
|
43
|
+
disabledSince: (input.disabledAt ?? new Date()).toISOString(),
|
|
44
|
+
revision: existing?.revision ?? 0,
|
|
45
|
+
recent: existing?.recent ?? [],
|
|
46
|
+
pendingSendKeys: existing?.pendingSendKeys ?? [],
|
|
47
|
+
cooldown: existing?.cooldown,
|
|
48
|
+
};
|
|
49
|
+
input.ledger.bindings[input.bindingId] = binding;
|
|
50
|
+
return binding;
|
|
51
|
+
}
|
|
52
|
+
export function filterDeliverableWeChatMessages(previous, current) {
|
|
53
|
+
return filterNewWeChatMessagesByAnchor({ previous, current }).filter((message) => message.senderRole !== 'self');
|
|
54
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ExternalMessageAttachment } from '../base.js';
|
|
2
|
+
import type { WeChatChannelHelperTransport } from './observer.js';
|
|
3
|
+
export type WeChatChannelVisibleMediaCandidate = {
|
|
4
|
+
messageKey: string;
|
|
5
|
+
kind: 'image' | 'video' | 'file' | 'link' | 'card' | string;
|
|
6
|
+
bbox?: {
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
coordinateSpace?: string;
|
|
12
|
+
};
|
|
13
|
+
fileName?: string | null;
|
|
14
|
+
mimeType?: string | null;
|
|
15
|
+
size?: number | null;
|
|
16
|
+
mediaStatus?: 'available' | 'not_downloaded' | 'metadata_only' | 'unsupported' | string | null;
|
|
17
|
+
};
|
|
18
|
+
export type WeChatChannelMediaResolveResult = {
|
|
19
|
+
messageKey: string;
|
|
20
|
+
attachment: ExternalMessageAttachment;
|
|
21
|
+
reasonCode: string;
|
|
22
|
+
};
|
|
23
|
+
export declare function defaultWeChatChannelAttachmentDir(workDir: string, runtimeId: string, bindingId: string): string;
|
|
24
|
+
export declare function resolveVisibleWeChatChannelMedia(input: {
|
|
25
|
+
helper: WeChatChannelHelperTransport;
|
|
26
|
+
candidates: WeChatChannelVisibleMediaCandidate[];
|
|
27
|
+
attachmentsDir: string;
|
|
28
|
+
windowId?: string;
|
|
29
|
+
traceId?: string;
|
|
30
|
+
}): Promise<WeChatChannelMediaResolveResult[]>;
|
|
31
|
+
export declare function materializeLocalAttachment(candidate: WeChatChannelVisibleMediaCandidate, sourcePath: string, attachmentsDir: string): ExternalMessageAttachment;
|
|
32
|
+
export declare function metadataOnlyResult(candidate: WeChatChannelVisibleMediaCandidate, reasonCode: string): WeChatChannelMediaResolveResult;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// @arch docs/features/wechat-rpa-productization-plan.md
|
|
2
|
+
// @arch docs/features/wechat-rpa-helper-runtime.md
|
|
3
|
+
// @test src/__tests__/wechat-channel-media-resolver.test.ts
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
const MAX_INBOUND_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
|
8
|
+
export function defaultWeChatChannelAttachmentDir(workDir, runtimeId, bindingId) {
|
|
9
|
+
const key = crypto.createHash('sha256').update(`${runtimeId}:${bindingId}`).digest('hex').slice(0, 16);
|
|
10
|
+
return path.join(workDir, '.uploads', 'wechat-channel', 'inbound', key);
|
|
11
|
+
}
|
|
12
|
+
export async function resolveVisibleWeChatChannelMedia(input) {
|
|
13
|
+
const results = [];
|
|
14
|
+
for (const candidate of input.candidates) {
|
|
15
|
+
if (!isDownloadableCandidate(candidate)) {
|
|
16
|
+
results.push(metadataOnlyResult(candidate, 'unsupported_media_kind'));
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (!candidate.bbox) {
|
|
20
|
+
results.push(metadataOnlyResult(candidate, 'media_bbox_missing'));
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const snapshot = await input.helper.request('clipboard.snapshot', {}, input.traceId);
|
|
24
|
+
assertHelperOk(snapshot, 'clipboard.snapshot');
|
|
25
|
+
try {
|
|
26
|
+
const center = bboxCenter(candidate.bbox);
|
|
27
|
+
const rightClick = await input.helper.request('mouse.rightClick', {
|
|
28
|
+
x: center.x,
|
|
29
|
+
y: center.y,
|
|
30
|
+
coordinateSpace: center.coordinateSpace,
|
|
31
|
+
windowId: input.windowId,
|
|
32
|
+
}, input.traceId);
|
|
33
|
+
assertHelperOk(rightClick, 'mouse.rightClick');
|
|
34
|
+
const picked = await input.helper.request('menu.pickItem', {
|
|
35
|
+
labels: menuLabelsForCandidate(candidate),
|
|
36
|
+
disallowLabels: ['保存', '另存为', 'Save As', 'Save to Downloads'],
|
|
37
|
+
}, input.traceId);
|
|
38
|
+
assertHelperOk(picked, 'menu.pickItem');
|
|
39
|
+
const fileUrls = await input.helper.request('clipboard.readFileUrls', {}, input.traceId);
|
|
40
|
+
assertHelperOk(fileUrls, 'clipboard.readFileUrls');
|
|
41
|
+
const localPath = firstClipboardPath(fileUrls.result);
|
|
42
|
+
if (!localPath) {
|
|
43
|
+
results.push(metadataOnlyResult(candidate, 'clipboard_file_url_unavailable'));
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
results.push({
|
|
47
|
+
messageKey: candidate.messageKey,
|
|
48
|
+
attachment: materializeLocalAttachment(candidate, localPath, input.attachmentsDir),
|
|
49
|
+
reasonCode: 'edge_local',
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
const restore = await input.helper.request('clipboard.restore', snapshot.result && typeof snapshot.result === 'object' ? snapshot.result : {}, input.traceId);
|
|
54
|
+
if (!restore.ok) {
|
|
55
|
+
results.push(metadataOnlyResult(candidate, restore.errorCode || 'clipboard_restore_failed'));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return results;
|
|
60
|
+
}
|
|
61
|
+
export function materializeLocalAttachment(candidate, sourcePath, attachmentsDir) {
|
|
62
|
+
const stat = fs.statSync(sourcePath);
|
|
63
|
+
if (!stat.isFile())
|
|
64
|
+
throw new Error('wechat_channel_attachment_not_file');
|
|
65
|
+
if (stat.size > MAX_INBOUND_ATTACHMENT_BYTES) {
|
|
66
|
+
return {
|
|
67
|
+
type: normalizeAttachmentType(candidate.kind),
|
|
68
|
+
name: safeFileName(candidate.fileName || path.basename(sourcePath)),
|
|
69
|
+
size: stat.size,
|
|
70
|
+
mimeType: candidate.mimeType || mimeFromPath(sourcePath),
|
|
71
|
+
availability: 'unavailable-large',
|
|
72
|
+
providerError: 'attachment_too_large',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
fs.mkdirSync(attachmentsDir, { recursive: true });
|
|
76
|
+
const buffer = fs.readFileSync(sourcePath);
|
|
77
|
+
const hash = crypto.createHash('sha256').update(buffer).digest('hex');
|
|
78
|
+
const name = safeFileName(candidate.fileName || path.basename(sourcePath));
|
|
79
|
+
const ext = path.extname(name) || path.extname(sourcePath);
|
|
80
|
+
const stem = ext ? name.slice(0, -ext.length) : name;
|
|
81
|
+
const targetPath = path.join(attachmentsDir, `${stem}-${hash.slice(0, 12)}${ext}`);
|
|
82
|
+
if (!fs.existsSync(targetPath))
|
|
83
|
+
fs.writeFileSync(targetPath, buffer);
|
|
84
|
+
return {
|
|
85
|
+
type: normalizeAttachmentType(candidate.kind),
|
|
86
|
+
name,
|
|
87
|
+
mimeType: candidate.mimeType || mimeFromPath(sourcePath),
|
|
88
|
+
size: buffer.byteLength,
|
|
89
|
+
localPath: targetPath,
|
|
90
|
+
hash,
|
|
91
|
+
availability: 'edge-local',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export function metadataOnlyResult(candidate, reasonCode) {
|
|
95
|
+
return {
|
|
96
|
+
messageKey: candidate.messageKey,
|
|
97
|
+
reasonCode,
|
|
98
|
+
attachment: {
|
|
99
|
+
type: normalizeAttachmentType(candidate.kind),
|
|
100
|
+
name: safeFileName(candidate.fileName || `${candidate.kind || 'attachment'}`),
|
|
101
|
+
...(candidate.mimeType ? { mimeType: candidate.mimeType } : {}),
|
|
102
|
+
...(Number.isFinite(candidate.size) ? { size: Number(candidate.size) } : {}),
|
|
103
|
+
availability: candidate.mediaStatus === 'not_downloaded' ? 'pending-download' : 'metadata-only',
|
|
104
|
+
providerError: reasonCode,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function isDownloadableCandidate(candidate) {
|
|
109
|
+
return candidateMediaType(candidate.kind) !== null;
|
|
110
|
+
}
|
|
111
|
+
function candidateMediaType(kind) {
|
|
112
|
+
const normalized = String(kind || '').toLowerCase();
|
|
113
|
+
if (normalized.includes('video'))
|
|
114
|
+
return 'video';
|
|
115
|
+
if (normalized.includes('image') || normalized.includes('photo'))
|
|
116
|
+
return 'image';
|
|
117
|
+
if (normalized.includes('file') || normalized.includes('document'))
|
|
118
|
+
return 'file';
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
function normalizeAttachmentType(kind) {
|
|
122
|
+
const mediaType = candidateMediaType(kind);
|
|
123
|
+
if (mediaType)
|
|
124
|
+
return mediaType;
|
|
125
|
+
return 'file';
|
|
126
|
+
}
|
|
127
|
+
function menuLabelsForCandidate(candidate) {
|
|
128
|
+
const type = normalizeAttachmentType(candidate.kind);
|
|
129
|
+
if (type === 'image')
|
|
130
|
+
return ['复制图片', '复制', 'Copy Image', 'Copy'];
|
|
131
|
+
if (type === 'video')
|
|
132
|
+
return ['复制', 'Copy'];
|
|
133
|
+
return ['复制', 'Copy'];
|
|
134
|
+
}
|
|
135
|
+
function firstClipboardPath(result) {
|
|
136
|
+
const raw = result?.filePaths?.[0] || result?.fileUrls?.[0];
|
|
137
|
+
if (!raw)
|
|
138
|
+
return null;
|
|
139
|
+
if (raw.startsWith('file://'))
|
|
140
|
+
return decodeURIComponent(new URL(raw).pathname);
|
|
141
|
+
return raw;
|
|
142
|
+
}
|
|
143
|
+
function bboxCenter(bbox) {
|
|
144
|
+
return {
|
|
145
|
+
x: bbox.x + bbox.width / 2,
|
|
146
|
+
y: bbox.y + bbox.height / 2,
|
|
147
|
+
coordinateSpace: bbox.coordinateSpace,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function safeFileName(name) {
|
|
151
|
+
return path.basename(name || 'attachment')
|
|
152
|
+
.normalize('NFKC')
|
|
153
|
+
.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
|
|
154
|
+
.replace(/\s+/g, ' ')
|
|
155
|
+
.replace(/^[ ._]+|[ ._]+$/g, '')
|
|
156
|
+
|| 'attachment';
|
|
157
|
+
}
|
|
158
|
+
function mimeFromPath(filePath) {
|
|
159
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
160
|
+
if (ext === '.png')
|
|
161
|
+
return 'image/png';
|
|
162
|
+
if (ext === '.jpg' || ext === '.jpeg')
|
|
163
|
+
return 'image/jpeg';
|
|
164
|
+
if (ext === '.gif')
|
|
165
|
+
return 'image/gif';
|
|
166
|
+
if (ext === '.webp')
|
|
167
|
+
return 'image/webp';
|
|
168
|
+
if (ext === '.mp4')
|
|
169
|
+
return 'video/mp4';
|
|
170
|
+
if (ext === '.mov')
|
|
171
|
+
return 'video/quicktime';
|
|
172
|
+
if (ext === '.pdf')
|
|
173
|
+
return 'application/pdf';
|
|
174
|
+
if (ext === '.txt')
|
|
175
|
+
return 'text/plain';
|
|
176
|
+
return 'application/octet-stream';
|
|
177
|
+
}
|
|
178
|
+
function assertHelperOk(response, command) {
|
|
179
|
+
if (!response.ok)
|
|
180
|
+
throw new Error(`${response.errorCode || 'helper_command_failed'}: ${response.errorSummary || command}`);
|
|
181
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { WeChatChannelObservedMessage } from './client.js';
|
|
2
|
+
export type WeChatChannelMessageKeyInput = Omit<WeChatChannelObservedMessage, 'stableMessageKey'> & {
|
|
3
|
+
stableMessageKey?: string | null;
|
|
4
|
+
};
|
|
5
|
+
export type WeChatChannelNormalizedMessage = WeChatChannelObservedMessage & {
|
|
6
|
+
stableKeyVersion: 1;
|
|
7
|
+
};
|
|
8
|
+
export declare function normalizeWeChatObservedWindowForLedger(messages: WeChatChannelMessageKeyInput[]): WeChatChannelNormalizedMessage[];
|
|
9
|
+
export declare function buildStableWeChatMessageKey(message: WeChatChannelMessageKeyInput, windowIndex?: number, occurrence?: number): string;
|
|
10
|
+
export declare function buildAnchorMetadata(message: WeChatChannelMessageKeyInput, windowIndex: number, occurrence: number): {
|
|
11
|
+
stableKeyVersion: number;
|
|
12
|
+
windowIndex: number;
|
|
13
|
+
occurrence: number;
|
|
14
|
+
senderRole: "unknown" | "self" | "system" | "contact";
|
|
15
|
+
kind: string;
|
|
16
|
+
anchorText: string;
|
|
17
|
+
bboxBand: string | null;
|
|
18
|
+
mediaSignature: string | null;
|
|
19
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// @arch docs/features/wechat-rpa-productization-plan.md
|
|
2
|
+
// @test src/__tests__/wechat-channel-message-key.test.ts
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { normalizeWeChatAnchorText } from './anchor.js';
|
|
5
|
+
export function normalizeWeChatObservedWindowForLedger(messages) {
|
|
6
|
+
const baseCounts = new Map();
|
|
7
|
+
return messages.map((message, windowIndex) => {
|
|
8
|
+
const base = stableMessageBase(message, windowIndex);
|
|
9
|
+
const occurrence = baseCounts.get(base) ?? 0;
|
|
10
|
+
baseCounts.set(base, occurrence + 1);
|
|
11
|
+
const anchorMetadata = buildAnchorMetadata(message, windowIndex, occurrence);
|
|
12
|
+
const stableMessageKey = normalizeExplicitKey(message.stableMessageKey) || hashStableMessageKey({ base, occurrence });
|
|
13
|
+
const anchorText = message.anchorText || message.normalizedText || message.textExcerpt || '';
|
|
14
|
+
return {
|
|
15
|
+
...message,
|
|
16
|
+
stableMessageKey,
|
|
17
|
+
stableKeyVersion: 1,
|
|
18
|
+
anchorText,
|
|
19
|
+
anchorMetadata: {
|
|
20
|
+
...(isRecord(message.anchorMetadata) ? message.anchorMetadata : {}),
|
|
21
|
+
...anchorMetadata,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export function buildStableWeChatMessageKey(message, windowIndex = 0, occurrence = 0) {
|
|
27
|
+
return normalizeExplicitKey(message.stableMessageKey) || hashStableMessageKey({
|
|
28
|
+
base: stableMessageBase(message, windowIndex),
|
|
29
|
+
occurrence,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
export function buildAnchorMetadata(message, windowIndex, occurrence) {
|
|
33
|
+
return {
|
|
34
|
+
stableKeyVersion: 1,
|
|
35
|
+
windowIndex,
|
|
36
|
+
occurrence,
|
|
37
|
+
senderRole: message.senderRole,
|
|
38
|
+
kind: message.kind,
|
|
39
|
+
anchorText: normalizeWeChatAnchorText(message.anchorText || message.normalizedText || message.textExcerpt || ''),
|
|
40
|
+
bboxBand: bboxBand(message.bbox),
|
|
41
|
+
mediaSignature: mediaSignature(message),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function stableMessageBase(message, windowIndex) {
|
|
45
|
+
return JSON.stringify({
|
|
46
|
+
senderRole: message.senderRole || 'unknown',
|
|
47
|
+
senderName: normalizeWeChatAnchorText(message.senderName || ''),
|
|
48
|
+
kind: message.kind || 'text',
|
|
49
|
+
anchorText: normalizeWeChatAnchorText(message.anchorText || message.normalizedText || message.textExcerpt || ''),
|
|
50
|
+
bboxBand: bboxBand(message.bbox),
|
|
51
|
+
mediaSignature: mediaSignature(message),
|
|
52
|
+
neighborSignature: neighborSignature(message.neighborContext),
|
|
53
|
+
orderBand: Math.floor(windowIndex / 4),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function hashStableMessageKey(input) {
|
|
57
|
+
return `wk1_${crypto.createHash('sha256').update(`${input.base}|${input.occurrence}`).digest('hex').slice(0, 24)}`;
|
|
58
|
+
}
|
|
59
|
+
function normalizeExplicitKey(value) {
|
|
60
|
+
if (typeof value !== 'string')
|
|
61
|
+
return null;
|
|
62
|
+
const trimmed = value.trim();
|
|
63
|
+
return trimmed || null;
|
|
64
|
+
}
|
|
65
|
+
function bboxBand(value) {
|
|
66
|
+
if (!isRecord(value))
|
|
67
|
+
return null;
|
|
68
|
+
const x = numberPart(value.x);
|
|
69
|
+
const y = numberPart(value.y);
|
|
70
|
+
const width = numberPart(value.width);
|
|
71
|
+
const height = numberPart(value.height);
|
|
72
|
+
if ([x, y, width, height].some((part) => part === null))
|
|
73
|
+
return null;
|
|
74
|
+
return [x, y, width, height].map((part) => Math.round((part ?? 0) / 20) * 20).join(',');
|
|
75
|
+
}
|
|
76
|
+
function mediaSignature(message) {
|
|
77
|
+
const metadata = isRecord(message.mediaMetadata) ? message.mediaMetadata : {};
|
|
78
|
+
const visualBlocks = Array.isArray(message.visualBlocks) ? message.visualBlocks : [];
|
|
79
|
+
const parts = [
|
|
80
|
+
stringPart(metadata.fileName),
|
|
81
|
+
stringPart(metadata.mimeType),
|
|
82
|
+
stringPart(metadata.size),
|
|
83
|
+
...visualBlocks.map((block) => `${block.blockKind}:${block.blockId}`),
|
|
84
|
+
].filter(Boolean);
|
|
85
|
+
return parts.length ? parts.join('|') : null;
|
|
86
|
+
}
|
|
87
|
+
function neighborSignature(value) {
|
|
88
|
+
if (!isRecord(value))
|
|
89
|
+
return null;
|
|
90
|
+
const before = normalizeWeChatAnchorText(value.beforeText || value.previousText || value.prev);
|
|
91
|
+
const after = normalizeWeChatAnchorText(value.afterText || value.nextText || value.next);
|
|
92
|
+
return before || after ? `${before.slice(0, 24)}|${after.slice(0, 24)}` : null;
|
|
93
|
+
}
|
|
94
|
+
function numberPart(value) {
|
|
95
|
+
const number = Number(value);
|
|
96
|
+
return Number.isFinite(number) ? number : null;
|
|
97
|
+
}
|
|
98
|
+
function stringPart(value) {
|
|
99
|
+
if (value === undefined || value === null)
|
|
100
|
+
return null;
|
|
101
|
+
return String(value).trim() || null;
|
|
102
|
+
}
|
|
103
|
+
function isRecord(value) {
|
|
104
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
105
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { WeChatChannelHelperCommandName, WeChatChannelHelperResponse } from './helper-protocol.js';
|
|
2
|
+
import type { WeChatChannelRuntime, WeChatChannelBindingConfig } from './runtime.js';
|
|
3
|
+
import type { WeChatChannelApiClient, WeChatChannelObservedMessage } from './client.js';
|
|
4
|
+
import { type WeChatConversationListFingerprint } from './fingerprint.js';
|
|
5
|
+
export type WeChatChannelHelperTransport = {
|
|
6
|
+
request<T = unknown>(command: WeChatChannelHelperCommandName, params?: Record<string, unknown>, traceId?: string): Promise<WeChatChannelHelperResponse<T>>;
|
|
7
|
+
};
|
|
8
|
+
export type WeChatChannelWindowInfo = {
|
|
9
|
+
windowId: string;
|
|
10
|
+
appName?: string | null;
|
|
11
|
+
title?: string | null;
|
|
12
|
+
bounds?: {
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
coordinateSpace?: string;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
export type WeChatChannelScreenshot = {
|
|
21
|
+
mimeType: string;
|
|
22
|
+
dataBase64: string;
|
|
23
|
+
width: number;
|
|
24
|
+
height: number;
|
|
25
|
+
windowId?: string | null;
|
|
26
|
+
};
|
|
27
|
+
export type WeChatChannelOcrBlock = {
|
|
28
|
+
text?: string;
|
|
29
|
+
bbox?: {
|
|
30
|
+
x: number;
|
|
31
|
+
y: number;
|
|
32
|
+
width: number;
|
|
33
|
+
height: number;
|
|
34
|
+
coordinateSpace?: string;
|
|
35
|
+
};
|
|
36
|
+
confidence?: number;
|
|
37
|
+
};
|
|
38
|
+
export type WeChatChannelOcrResult = {
|
|
39
|
+
blocks?: WeChatChannelOcrBlock[];
|
|
40
|
+
visibleConversationFingerprints?: WeChatConversationListFingerprint[];
|
|
41
|
+
};
|
|
42
|
+
export type WeChatChannelObserveApi = Pick<WeChatChannelApiClient, 'observe'>;
|
|
43
|
+
export type HelperBackedObserveOptions = {
|
|
44
|
+
runtime: WeChatChannelRuntime;
|
|
45
|
+
binding: WeChatChannelBindingConfig;
|
|
46
|
+
helper: WeChatChannelHelperTransport;
|
|
47
|
+
api: WeChatChannelObserveApi;
|
|
48
|
+
traceId?: string;
|
|
49
|
+
localLedgerTailAnchors?: unknown[];
|
|
50
|
+
};
|
|
51
|
+
export declare function observeWeChatChannelBindingViaHelper(options: HelperBackedObserveOptions): Promise<WeChatChannelObservedMessage[]>;
|
|
52
|
+
export declare function ensureHelperPreflight(helper: WeChatChannelHelperTransport, traceId?: string): Promise<void>;
|
|
53
|
+
export declare function focusWeChatWindow(helper: WeChatChannelHelperTransport, traceId?: string): Promise<WeChatChannelWindowInfo>;
|
|
54
|
+
export declare function openConversationInVisibleList(input: {
|
|
55
|
+
helper: WeChatChannelHelperTransport;
|
|
56
|
+
window: WeChatChannelWindowInfo;
|
|
57
|
+
binding: WeChatChannelBindingConfig;
|
|
58
|
+
traceId?: string;
|
|
59
|
+
}): Promise<{
|
|
60
|
+
opened: boolean;
|
|
61
|
+
reason: string;
|
|
62
|
+
}>;
|
|
63
|
+
export declare function captureWeChatWindow(helper: WeChatChannelHelperTransport, windowId: string, traceId?: string): Promise<WeChatChannelScreenshot>;
|
|
64
|
+
export declare function recognizeWeChatScreenshot(helper: WeChatChannelHelperTransport, screenshot: WeChatChannelScreenshot, traceId?: string): Promise<WeChatChannelOcrResult>;
|