@unlinearity/cli-wechat-bridge 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE.txt +21 -0
  2. package/README.md +531 -0
  3. package/bin/_run-entry.mjs +41 -0
  4. package/bin/wechat-bridge-claude.mjs +5 -0
  5. package/bin/wechat-bridge-codex.mjs +5 -0
  6. package/bin/wechat-bridge-opencode.mjs +5 -0
  7. package/bin/wechat-bridge-shell.mjs +5 -0
  8. package/bin/wechat-bridge.mjs +5 -0
  9. package/bin/wechat-check-update.mjs +5 -0
  10. package/bin/wechat-claude-start.mjs +5 -0
  11. package/bin/wechat-claude.mjs +5 -0
  12. package/bin/wechat-codex-start.mjs +5 -0
  13. package/bin/wechat-codex.mjs +5 -0
  14. package/bin/wechat-opencode-start.mjs +5 -0
  15. package/bin/wechat-opencode.mjs +5 -0
  16. package/package.json +86 -0
  17. package/src/bridge/bridge-adapters.claude.ts +1047 -0
  18. package/src/bridge/bridge-adapters.codex.ts +2733 -0
  19. package/src/bridge/bridge-adapters.core.ts +877 -0
  20. package/src/bridge/bridge-adapters.opencode.ts +2390 -0
  21. package/src/bridge/bridge-adapters.shared.ts +1647 -0
  22. package/src/bridge/bridge-adapters.shell.ts +456 -0
  23. package/src/bridge/bridge-adapters.ts +30 -0
  24. package/src/bridge/bridge-controller.ts +58 -0
  25. package/src/bridge/bridge-final-reply.ts +56 -0
  26. package/src/bridge/bridge-process-reaper.ts +417 -0
  27. package/src/bridge/bridge-state.ts +520 -0
  28. package/src/bridge/bridge-types.ts +218 -0
  29. package/src/bridge/bridge-utils.ts +1524 -0
  30. package/src/bridge/claude-hook.ts +92 -0
  31. package/src/bridge/claude-hooks.ts +376 -0
  32. package/src/bridge/wechat-bridge.ts +1305 -0
  33. package/src/commands/check-update.ts +46 -0
  34. package/src/companion/codex-panel-link.ts +151 -0
  35. package/src/companion/codex-panel.ts +225 -0
  36. package/src/companion/codex-remote-client.ts +178 -0
  37. package/src/companion/local-companion-link.ts +338 -0
  38. package/src/companion/local-companion-start.ts +520 -0
  39. package/src/companion/local-companion.ts +532 -0
  40. package/src/media/media-types.ts +11 -0
  41. package/src/runtime/create-runtime-host.ts +16 -0
  42. package/src/runtime/legacy-adapter-runtime.ts +55 -0
  43. package/src/runtime/runtime-types.ts +51 -0
  44. package/src/utils/version-checker.ts +197 -0
  45. package/src/wechat/channel-config.ts +141 -0
  46. package/src/wechat/setup.ts +232 -0
  47. package/src/wechat/standalone-bot.ts +483 -0
  48. package/src/wechat/wechat-channel.ts +608 -0
  49. package/src/wechat/wechat-transport.ts +1290 -0
@@ -0,0 +1,456 @@
1
+ import type { ApprovalRequest } from "./bridge-types.ts";
2
+ import {
3
+ getInteractiveShellCommandRejectionMessage,
4
+ isHighRiskShellCommand,
5
+ normalizeOutput,
6
+ nowIso,
7
+ truncatePreview,
8
+ } from "./bridge-utils.ts";
9
+ import { AbstractPtyAdapter } from "./bridge-adapters.core.ts";
10
+ import * as shared from "./bridge-adapters.shared.ts";
11
+
12
+ type ShellRuntime = shared.ShellRuntime;
13
+
14
+ const {
15
+ buildShellInputPayload,
16
+ buildShellProfileCommand,
17
+ resolveShellRuntime,
18
+ } = shared;
19
+
20
+ export class ShellCommandRejectedError extends Error {
21
+ constructor(message: string) {
22
+ super(message);
23
+ this.name = "ShellCommandRejectedError";
24
+ }
25
+ }
26
+
27
+ export class ShellAdapter extends AbstractPtyAdapter {
28
+ private static readonly COMPLETION_MARKER_PREFIX = "__WECHAT_BRIDGE_DONE__";
29
+ private static readonly POWERSHELL_LEADING_NOISE_PATTERNS = [
30
+ /^PS>>\s*/u,
31
+ /^PS [^>\n]*>\s*/u,
32
+ /^PS>\s*/u,
33
+ /^>>\s*/u,
34
+ /^>\s*/u,
35
+ /^function global:prompt \{ "" \}/u,
36
+ /^\$__wechatBridgePreviousErrorActionPreference = \$ErrorActionPreference/u,
37
+ /^\$ErrorActionPreference = 'Continue'/u,
38
+ /^\$global:LASTEXITCODE = 0/u,
39
+ /^try \{/u,
40
+ /^\$decoded = \[System\.Text\.Encoding\]::UTF8\.GetString\(\[System\.Convert\]::FromBase64String\(".*"\)\)/u,
41
+ /^\$scriptBlock = \[scriptblock\]::Create\(\$decoded\)/u,
42
+ /^& \$scriptBlock/u,
43
+ /^\} catch \{/u,
44
+ /^Write-Error \$_/u,
45
+ /^\$global:LASTEXITCODE = 1/u,
46
+ /^\} finally \{/u,
47
+ /^if \(-not \(\$global:LASTEXITCODE -is \[int\]\)\) \{ \$global:LASTEXITCODE = 0 \}/u,
48
+ /^Write-Output "__WECHAT_BRIDGE_DONE__:[^"]*"/u,
49
+ /^\$ErrorActionPreference = \$__wechatBridgePreviousErrorActionPreference/u,
50
+ /^\}/u,
51
+ ] as const;
52
+
53
+ private pendingShellCommand: string | null = null;
54
+ private interruptTimer: ReturnType<typeof setTimeout> | null = null;
55
+ private outputBuffer = "";
56
+ private currentCompletionMarker: string | null = null;
57
+ private expectedEchoLines: string[] = [];
58
+ private commandSequence = 0;
59
+
60
+ protected buildSpawnArgs(): string[] {
61
+ return this.getShellRuntime().launchArgs;
62
+ }
63
+
64
+ protected override buildEnv(): Record<string, string> {
65
+ const env = super.buildEnv();
66
+ if (this.getShellRuntime().family === "posix") {
67
+ env.PS1 = "";
68
+ env.PROMPT = "";
69
+ env.RPROMPT = "";
70
+ }
71
+ return env;
72
+ }
73
+
74
+ protected afterStart(): void {
75
+ if (this.options.profile) {
76
+ this.writeToPty(
77
+ `${buildShellProfileCommand(this.options.profile, this.getShellRuntime().family)}\r`,
78
+ );
79
+ }
80
+
81
+ if (this.getShellRuntime().family === "powershell") {
82
+ this.writeToPty('function global:prompt { "" }\r');
83
+ }
84
+ }
85
+
86
+ override async sendInput(text: string): Promise<void> {
87
+ if (!this.pty) {
88
+ throw new Error("shell adapter is not running.");
89
+ }
90
+ if (this.state.status === "busy") {
91
+ throw new Error("shell is still working. Wait for the current reply or use /stop.");
92
+ }
93
+ if (this.pendingApproval || this.state.status === "awaiting_approval") {
94
+ throw new Error("A shell approval request is pending. Reply with /confirm <code> or /deny.");
95
+ }
96
+
97
+ const rejectionMessage = getInteractiveShellCommandRejectionMessage(text);
98
+ if (rejectionMessage) {
99
+ throw new ShellCommandRejectedError(rejectionMessage);
100
+ }
101
+
102
+ if (isHighRiskShellCommand(text)) {
103
+ this.pendingShellCommand = text;
104
+ const request: ApprovalRequest = {
105
+ source: "shell",
106
+ summary: "High-risk shell command detected. Confirmation is required.",
107
+ commandPreview: truncatePreview(text, 180),
108
+ };
109
+ this.pendingApproval = request;
110
+ this.state.pendingApproval = request;
111
+ this.state.pendingApprovalOrigin = "wechat";
112
+ this.setStatus("awaiting_approval", "Waiting for shell command approval.");
113
+ this.emit({
114
+ type: "approval_required",
115
+ request,
116
+ timestamp: nowIso(),
117
+ });
118
+ return;
119
+ }
120
+
121
+ this.startShellCommand(text);
122
+ }
123
+
124
+ override async interrupt(): Promise<boolean> {
125
+ if (!this.pty) {
126
+ return false;
127
+ }
128
+
129
+ this.writeToPty("\u0003");
130
+ this.clearInterruptTimer();
131
+ this.interruptTimer = setTimeout(() => {
132
+ this.interruptTimer = null;
133
+ if (this.state.status === "busy" || this.state.status === "awaiting_approval") {
134
+ this.finishShellCommand({
135
+ summary: "Interrupted",
136
+ statusMessage: "Shell command interrupted.",
137
+ });
138
+ }
139
+ }, 1_500);
140
+ return true;
141
+ }
142
+
143
+ protected override async applyApproval(
144
+ action: "confirm" | "deny",
145
+ _pendingApproval: ApprovalRequest,
146
+ ): Promise<boolean> {
147
+ if (!this.pendingApproval) {
148
+ return false;
149
+ }
150
+
151
+ if (action === "deny") {
152
+ this.pendingShellCommand = null;
153
+ this.finishShellCommand({
154
+ summary: "Denied",
155
+ statusMessage: "Shell command denied.",
156
+ });
157
+ return true;
158
+ }
159
+
160
+ const command = this.pendingShellCommand;
161
+ if (!command) {
162
+ return false;
163
+ }
164
+
165
+ this.pendingShellCommand = null;
166
+ this.startShellCommand(command);
167
+ return true;
168
+ }
169
+
170
+ protected override handleData(rawText: string): void {
171
+ const text = normalizeOutput(rawText);
172
+ if (!text) {
173
+ return;
174
+ }
175
+
176
+ this.state.lastOutputAt = nowIso();
177
+ if (!this.hasAcceptedInput) {
178
+ return;
179
+ }
180
+
181
+ this.outputBuffer = `${this.outputBuffer}${text}`;
182
+ this.flushShellOutputBuffer();
183
+ }
184
+
185
+ protected override handleExit(exitCode: number | undefined): void {
186
+ if (this.hasAcceptedInput) {
187
+ this.flushShellOutputBuffer(true);
188
+ }
189
+ this.resetShellCommandState();
190
+ super.handleExit(exitCode);
191
+ }
192
+
193
+ private getShellRuntime(): ShellRuntime {
194
+ return resolveShellRuntime(this.options.command);
195
+ }
196
+
197
+ private startShellCommand(text: string): void {
198
+ if (!this.pty) {
199
+ throw new Error(`${this.options.kind} adapter is not running.`);
200
+ }
201
+
202
+ this.clearInterruptTimer();
203
+ this.resetShellCommandState({
204
+ preserveStatus: true,
205
+ preservePreview: true,
206
+ });
207
+
208
+ const completionMarker = this.createCompletionMarker();
209
+ const payload = buildShellInputPayload(
210
+ text,
211
+ this.getShellRuntime().family,
212
+ completionMarker,
213
+ );
214
+
215
+ this.hasAcceptedInput = true;
216
+ this.currentPreview = truncatePreview(text);
217
+ this.state.lastInputAt = nowIso();
218
+ this.state.activeTurnOrigin = "wechat";
219
+ this.pendingApproval = null;
220
+ this.state.pendingApproval = null;
221
+ this.state.pendingApprovalOrigin = undefined;
222
+ this.currentCompletionMarker = completionMarker;
223
+ this.expectedEchoLines = normalizeOutput(payload)
224
+ .split("\n")
225
+ .map((line) => line.trim())
226
+ .filter(Boolean);
227
+ this.writeToPty(payload);
228
+ this.setStatus("busy");
229
+ }
230
+
231
+ private flushShellOutputBuffer(force = false): void {
232
+ const lastNewlineIndex = this.outputBuffer.lastIndexOf("\n");
233
+ if (!force && lastNewlineIndex < 0) {
234
+ return;
235
+ }
236
+
237
+ const sliceEnd = force
238
+ ? this.outputBuffer.length
239
+ : Math.max(0, lastNewlineIndex + 1);
240
+ if (sliceEnd === 0) {
241
+ return;
242
+ }
243
+
244
+ const chunk = this.outputBuffer.slice(0, sliceEnd);
245
+ this.outputBuffer = this.outputBuffer.slice(sliceEnd);
246
+
247
+ const visibleLines: string[] = [];
248
+ let completedExitCode: number | null = null;
249
+
250
+ for (const rawLine of chunk.split("\n")) {
251
+ if (!rawLine && !force) {
252
+ continue;
253
+ }
254
+
255
+ const completionExitCode = this.parseCompletionExitCode(rawLine);
256
+ if (completionExitCode !== null) {
257
+ completedExitCode = completionExitCode;
258
+ continue;
259
+ }
260
+
261
+ const filtered = this.filterShellOutputLine(rawLine);
262
+ if (filtered) {
263
+ visibleLines.push(filtered);
264
+ }
265
+ }
266
+
267
+ const visibleText = visibleLines.join("\n").trim();
268
+ if (visibleText) {
269
+ this.emit({
270
+ type: "stdout",
271
+ text: visibleText,
272
+ timestamp: nowIso(),
273
+ });
274
+ }
275
+
276
+ if (completedExitCode !== null) {
277
+ this.finishShellCommand({ exitCode: completedExitCode });
278
+ }
279
+ }
280
+
281
+ private filterShellOutputLine(line: string): string | null {
282
+ const cleanedLine =
283
+ this.getShellRuntime().family === "powershell"
284
+ ? this.stripLeadingPowerShellNoise(line)
285
+ : line;
286
+ const trimmed = cleanedLine.trim();
287
+ if (!trimmed) {
288
+ return null;
289
+ }
290
+
291
+ if (this.consumeExpectedEchoLine(trimmed)) {
292
+ return null;
293
+ }
294
+
295
+ if (this.isShellPromptLine(trimmed)) {
296
+ return null;
297
+ }
298
+
299
+ if (this.getShellRuntime().family === "posix") {
300
+ if (
301
+ trimmed === "__wechat_bridge_status=$?" ||
302
+ trimmed.startsWith("printf '%s:%s\\n'") ||
303
+ trimmed.startsWith("printf '__WECHAT_BRIDGE_DONE__:%s")
304
+ ) {
305
+ return null;
306
+ }
307
+ }
308
+
309
+ return cleanedLine;
310
+ }
311
+
312
+ private consumeExpectedEchoLine(line: string): boolean {
313
+ if (!this.expectedEchoLines.length) {
314
+ return false;
315
+ }
316
+
317
+ const normalizedLine = this.normalizeEchoLine(line);
318
+ if (normalizedLine !== this.expectedEchoLines[0]) {
319
+ return false;
320
+ }
321
+
322
+ this.expectedEchoLines.shift();
323
+ return true;
324
+ }
325
+
326
+ private normalizeEchoLine(line: string): string {
327
+ const trimmed = line.trim();
328
+ if (this.getShellRuntime().family === "powershell") {
329
+ return trimmed.replace(/^(?:PS [^>]*>\s*|PS>\s*|>>\s*|>\s*)+/u, "").trim();
330
+ }
331
+ return trimmed.replace(/^(?:[$#>]\s*)+/u, "").trim();
332
+ }
333
+
334
+ private isShellPromptLine(line: string): boolean {
335
+ if (this.getShellRuntime().family === "powershell") {
336
+ return /^(?:PS [^>]*>\s*|PS>\s*|>>\s*|>\s*)+$/u.test(line);
337
+ }
338
+ return /^(?:[$#>]\s*)+$/u.test(line);
339
+ }
340
+
341
+ private parseCompletionExitCode(line: string): number | null {
342
+ const marker = this.currentCompletionMarker;
343
+ if (!marker) {
344
+ return null;
345
+ }
346
+
347
+ const trimmed = this.stripLeadingShellPromptTokens(line).trim();
348
+ const prefix = `${marker}:`;
349
+ if (!trimmed.startsWith(prefix)) {
350
+ return null;
351
+ }
352
+
353
+ const exitCodeText = trimmed.slice(prefix.length).trim();
354
+ if (!/^-?\d+$/u.test(exitCodeText)) {
355
+ return null;
356
+ }
357
+
358
+ return Number(exitCodeText);
359
+ }
360
+
361
+ private finishShellCommand(options: {
362
+ exitCode?: number;
363
+ summary?: string;
364
+ statusMessage?: string;
365
+ }): void {
366
+ const summary = options.summary ?? this.currentPreview;
367
+ this.resetShellCommandState();
368
+ this.setStatus("idle", options.statusMessage);
369
+ this.emit({
370
+ type: "task_complete",
371
+ exitCode: options.exitCode,
372
+ summary,
373
+ timestamp: nowIso(),
374
+ });
375
+ }
376
+
377
+ private resetShellCommandState(
378
+ options: {
379
+ preserveStatus?: boolean;
380
+ preservePreview?: boolean;
381
+ } = {},
382
+ ): void {
383
+ this.clearInterruptTimer();
384
+ this.clearCompletionTimer();
385
+ this.pendingApproval = null;
386
+ this.pendingShellCommand = null;
387
+ this.state.pendingApproval = null;
388
+ this.state.pendingApprovalOrigin = undefined;
389
+ this.state.activeTurnOrigin = undefined;
390
+ this.hasAcceptedInput = false;
391
+ this.outputBuffer = "";
392
+ this.currentCompletionMarker = null;
393
+ this.expectedEchoLines = [];
394
+ if (!options.preservePreview) {
395
+ this.currentPreview = "(idle)";
396
+ }
397
+ if (!options.preserveStatus && this.state.status !== "stopped") {
398
+ this.state.status = "idle";
399
+ }
400
+ }
401
+
402
+ private clearInterruptTimer(): void {
403
+ if (!this.interruptTimer) {
404
+ return;
405
+ }
406
+ clearTimeout(this.interruptTimer);
407
+ this.interruptTimer = null;
408
+ }
409
+
410
+ private createCompletionMarker(): string {
411
+ this.commandSequence += 1;
412
+ return `${ShellAdapter.COMPLETION_MARKER_PREFIX}:${this.commandSequence.toString(36)}`;
413
+ }
414
+
415
+ private stripLeadingPowerShellNoise(line: string): string {
416
+ let text = line.trimStart();
417
+
418
+ while (text) {
419
+ let stripped = false;
420
+ for (const pattern of ShellAdapter.POWERSHELL_LEADING_NOISE_PATTERNS) {
421
+ const next = text.replace(pattern, "");
422
+ if (next !== text) {
423
+ text = next.trimStart();
424
+ stripped = true;
425
+ break;
426
+ }
427
+ }
428
+ if (!stripped) {
429
+ break;
430
+ }
431
+ }
432
+
433
+ return text;
434
+ }
435
+
436
+ private stripLeadingShellPromptTokens(line: string): string {
437
+ let text = line.trimStart();
438
+ if (this.getShellRuntime().family === "powershell") {
439
+ while (true) {
440
+ const next = text
441
+ .replace(/^PS>>\s*/u, "")
442
+ .replace(/^PS [^>\n]*>\s*/u, "")
443
+ .replace(/^PS>\s*/u, "")
444
+ .replace(/^>>\s*/u, "")
445
+ .replace(/^>\s*/u, "");
446
+ if (next === text) {
447
+ break;
448
+ }
449
+ text = next.trimStart();
450
+ }
451
+ return text;
452
+ }
453
+
454
+ return text.replace(/^(?:[$#>]\s*)+/u, "").trimStart();
455
+ }
456
+ }
@@ -0,0 +1,30 @@
1
+ import type { BridgeAdapter } from "./bridge-types.ts";
2
+ import { ClaudeCompanionAdapter } from "./bridge-adapters.claude.ts";
3
+ import { LocalCompanionProxyAdapter } from "./bridge-adapters.core.ts";
4
+ import { CodexPtyAdapter } from "./bridge-adapters.codex.ts";
5
+ import { OpenCodeServerAdapter } from "./bridge-adapters.opencode.ts";
6
+ import { ShellAdapter } from "./bridge-adapters.shell.ts";
7
+ import type { AdapterOptions } from "./bridge-adapters.shared.ts";
8
+
9
+ export * from "./bridge-adapters.shared.ts";
10
+
11
+ export function createBridgeAdapter(options: AdapterOptions): BridgeAdapter {
12
+ switch (options.kind) {
13
+ case "codex":
14
+ return options.renderMode === "panel"
15
+ ? new CodexPtyAdapter(options)
16
+ : new LocalCompanionProxyAdapter(options);
17
+ case "claude":
18
+ return options.renderMode === "companion"
19
+ ? new ClaudeCompanionAdapter(options)
20
+ : new LocalCompanionProxyAdapter(options);
21
+ case "opencode":
22
+ return options.renderMode === "companion"
23
+ ? new OpenCodeServerAdapter(options)
24
+ : new LocalCompanionProxyAdapter(options);
25
+ case "shell":
26
+ return new ShellAdapter(options);
27
+ default:
28
+ throw new Error(`Unsupported adapter: ${options.kind}`);
29
+ }
30
+ }
@@ -0,0 +1,58 @@
1
+ import {
2
+ clearLocalCompanionEndpoint,
3
+ readLocalCompanionEndpoint,
4
+ writeLocalCompanionEndpoint,
5
+ } from "../companion/local-companion-link.ts";
6
+ import type { BridgeAdapter } from "./bridge-types.ts";
7
+ import { hasLocalClientEndpointProvider } from "../runtime/runtime-types.ts";
8
+
9
+ export class BridgeController {
10
+ private endpointInstanceId: string | null = null;
11
+ private readonly adapter: BridgeAdapter;
12
+ private readonly cwd: string;
13
+
14
+ constructor(adapter: BridgeAdapter, cwd: string) {
15
+ this.adapter = adapter;
16
+ this.cwd = cwd;
17
+ }
18
+
19
+ syncLocalClientEndpoint(): void {
20
+ if (!hasLocalClientEndpointProvider(this.adapter)) {
21
+ return;
22
+ }
23
+
24
+ const endpoint = this.adapter.getLocalClientEndpoint();
25
+ if (!endpoint) {
26
+ this.clearLocalClientEndpoint();
27
+ return;
28
+ }
29
+
30
+ const existing = readLocalCompanionEndpoint(this.cwd);
31
+ const adapterState = this.adapter.getState();
32
+ const nextEndpoint =
33
+ existing?.instanceId === endpoint.instanceId
34
+ ? {
35
+ ...endpoint,
36
+ companionPid: endpoint.companionPid ?? existing.companionPid,
37
+ companionConnectedAt:
38
+ endpoint.companionConnectedAt ?? existing.companionConnectedAt,
39
+ companionStatus: adapterState.status,
40
+ companionLastStateAt: new Date().toISOString(),
41
+ companionWorkerPid: adapterState.pid,
42
+ }
43
+ : {
44
+ ...endpoint,
45
+ companionStatus: adapterState.status,
46
+ companionLastStateAt: new Date().toISOString(),
47
+ companionWorkerPid: adapterState.pid,
48
+ };
49
+
50
+ this.endpointInstanceId = endpoint.instanceId;
51
+ writeLocalCompanionEndpoint(nextEndpoint);
52
+ }
53
+
54
+ clearLocalClientEndpoint(): void {
55
+ clearLocalCompanionEndpoint(this.cwd, this.endpointInstanceId ?? undefined);
56
+ this.endpointInstanceId = null;
57
+ }
58
+ }
@@ -0,0 +1,56 @@
1
+ import type { BridgeAdapterKind } from "./bridge-types.ts";
2
+ import {
3
+ formatFinalReplyMessage,
4
+ parseWechatFinalReply,
5
+ sanitizeWechatFinalReplyText,
6
+ } from "./bridge-utils.ts";
7
+
8
+ export type WechatFinalReplySender = {
9
+ sendText: (text: string) => Promise<void>;
10
+ sendImage: (imagePath: string) => Promise<unknown>;
11
+ sendFile: (filePath: string) => Promise<unknown>;
12
+ sendVoice: (voicePath: string) => Promise<unknown>;
13
+ sendVideo: (videoPath: string) => Promise<unknown>;
14
+ };
15
+
16
+ export async function forwardWechatFinalReply(params: {
17
+ adapter: BridgeAdapterKind;
18
+ rawText: string;
19
+ sender: WechatFinalReplySender;
20
+ }): Promise<void> {
21
+ const { adapter, rawText, sender } = params;
22
+ const parsed = parseWechatFinalReply(rawText);
23
+ const visibleText = formatFinalReplyMessage(
24
+ adapter,
25
+ sanitizeWechatFinalReplyText(adapter, parsed.visibleText),
26
+ ).trim();
27
+
28
+ if (visibleText) {
29
+ await sender.sendText(visibleText);
30
+ }
31
+
32
+ for (const attachment of parsed.attachments) {
33
+ try {
34
+ switch (attachment.kind) {
35
+ case "image":
36
+ await sender.sendImage(attachment.path);
37
+ break;
38
+ case "file":
39
+ await sender.sendFile(attachment.path);
40
+ break;
41
+ case "voice":
42
+ await sender.sendVoice(attachment.path);
43
+ break;
44
+ case "video":
45
+ await sender.sendVideo(attachment.path);
46
+ break;
47
+ }
48
+ } catch (error) {
49
+ const errorText =
50
+ error instanceof Error ? error.message : String(error ?? "unknown error");
51
+ await sender.sendText(
52
+ `Failed to send ${attachment.kind} attachment: ${attachment.path}\n${errorText}`,
53
+ );
54
+ }
55
+ }
56
+ }