@unlinearity/cli-wechat-bridge 1.1.5 → 1.2.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 (89) hide show
  1. package/LICENSE.txt +661 -661
  2. package/README.md +434 -427
  3. package/bin/_run-entry.mjs +47 -9
  4. package/bin/wechat-check-update.mjs +6 -1
  5. package/bin/wechat-claude.mjs +6 -1
  6. package/bin/wechat-codex.mjs +6 -1
  7. package/bin/wechat-daemon.mjs +6 -1
  8. package/bin/wechat-opencode.mjs +6 -1
  9. package/bin/wechat-pi.mjs +6 -1
  10. package/bin/wechat-setup.mjs +6 -1
  11. package/bin/wecom-check-update.mjs +10 -0
  12. package/bin/wecom-claude.mjs +10 -0
  13. package/bin/wecom-codex.mjs +10 -0
  14. package/bin/wecom-daemon.mjs +10 -0
  15. package/bin/wecom-opencode.mjs +10 -0
  16. package/bin/wecom-pi.mjs +10 -0
  17. package/bin/wecom-setup.mjs +10 -0
  18. package/dist/bridge/adapter-control.js +58 -0
  19. package/dist/bridge/bridge-adapters.claude.js +163 -5
  20. package/dist/bridge/bridge-adapters.codex.js +331 -29
  21. package/dist/bridge/bridge-adapters.core.js +27 -2
  22. package/dist/bridge/bridge-adapters.opencode.js +156 -1
  23. package/dist/bridge/bridge-adapters.pi.js +46 -1
  24. package/dist/bridge/bridge-adapters.shared.js +8 -1
  25. package/dist/bridge/bridge-controller.js +6 -0
  26. package/dist/bridge/bridge-final-reply.js +8 -65
  27. package/dist/bridge/bridge-process-reaper.js +41 -2
  28. package/dist/bridge/bridge-state.js +17 -4
  29. package/dist/bridge/bridge-utils.js +10 -127
  30. package/dist/bridge/claude-hook.js +6 -0
  31. package/dist/bridge/claude-native-controls.js +133 -0
  32. package/dist/bridge/native-terminal-control.js +259 -0
  33. package/dist/bridge/wechat-bridge.js +520 -274
  34. package/dist/bridge/wechat-forwarding.js +8 -58
  35. package/dist/bridge/wechat-outbound-queue.js +8 -0
  36. package/dist/channels/wechat/bridge-final-reply.js +56 -0
  37. package/dist/channels/wechat/channel-message.js +24 -0
  38. package/dist/channels/wechat/index.js +11 -0
  39. package/dist/channels/wechat/wechat-channel-port.js +67 -0
  40. package/dist/channels/wechat/wechat-driver.js +191 -0
  41. package/dist/channels/wechat/wechat-forwarding.js +97 -0
  42. package/dist/channels/wechat/wechat-outbound-queue.js +118 -0
  43. package/dist/channels/wecom/bridge-final-reply.js +32 -0
  44. package/dist/channels/wecom/setup-cli.js +12 -0
  45. package/dist/channels/wecom/setup.js +182 -0
  46. package/dist/channels/wecom/wecom-channel-port.js +45 -0
  47. package/dist/channels/wecom/wecom-config.js +72 -0
  48. package/dist/channels/wecom/wecom-driver.js +106 -0
  49. package/dist/channels/wecom/wecom-media.js +147 -0
  50. package/dist/channels/wecom/wecom-message.js +149 -0
  51. package/dist/channels/wecom/wecom-transport.js +314 -0
  52. package/dist/commands/check-update.js +6 -0
  53. package/dist/companion/codex-remote-client.js +30 -3
  54. package/dist/companion/codex-visible-client-link.js +107 -0
  55. package/dist/companion/local-companion-link.js +6 -0
  56. package/dist/companion/local-companion-start.js +62 -11
  57. package/dist/companion/local-companion.js +25 -1
  58. package/dist/companion/opencode-tui-bridge-plugin.js +6 -0
  59. package/dist/companion/pi-tui-bridge-extension.js +62 -3
  60. package/dist/core/bridge-control.js +42 -0
  61. package/dist/core/bridge-defer.js +25 -0
  62. package/dist/core/bridge-event-forwarder.js +50 -0
  63. package/dist/core/bridge-message-router.js +32 -0
  64. package/dist/core/channel-driver.js +1 -0
  65. package/dist/core/channel-types.js +3 -0
  66. package/dist/core/conversation-routing.js +58 -0
  67. package/dist/core/direct-run.js +23 -0
  68. package/dist/core/inbound-message-claims.js +67 -0
  69. package/dist/core/outbound-target.js +32 -0
  70. package/dist/core/text-utils.js +69 -0
  71. package/dist/core/turn-coordinator.js +85 -0
  72. package/dist/daemon/daemon-link.js +9 -0
  73. package/dist/daemon/emoji-bindings.js +6 -0
  74. package/dist/daemon/wechat-daemon.js +827 -209
  75. package/dist/i18n/index.js +6 -0
  76. package/dist/i18n/messages-en.js +11 -3
  77. package/dist/i18n/messages-zh.js +11 -3
  78. package/dist/runtime/create-runtime-host.js +6 -0
  79. package/dist/runtime/legacy-adapter-runtime.js +15 -0
  80. package/dist/runtime/runtime-types.js +3 -0
  81. package/dist/utils/atomic-file.js +6 -0
  82. package/dist/utils/doctor.js +36 -10
  83. package/dist/utils/version-checker.js +17 -11
  84. package/dist/wechat/channel-config.js +6 -0
  85. package/dist/wechat/setup.js +119 -13
  86. package/dist/wechat/wechat-channel.js +6 -0
  87. package/dist/wechat/wechat-transport.js +169 -58
  88. package/package.json +14 -4
  89. package/scripts/ensure-node-pty-permissions.mjs +73 -73
@@ -1,58 +1,8 @@
1
- import { truncatePreview } from "./bridge-utils.js";
2
- import { classifyWechatTransportError, describeWechatTransportError, isWechatContextTokenStaleError, } from "../wechat/wechat-transport.js";
3
- export const WECHAT_SEND_MAX_ATTEMPTS = 3;
4
- const WECHAT_SEND_RETRY_BASE_MS = 750;
5
- export function formatUserFacingBridgeFatalError(message) {
6
- return `Bridge error: ${message.replace(/\s+Recent app-server log:.*$/s, "").trim()}`;
7
- }
8
- export function shouldForwardBridgeEventToWechat(adapter, eventType, options = {}) {
9
- if (adapter !== "opencode" && adapter !== "pi") {
10
- return true;
11
- }
12
- switch (eventType) {
13
- case "stdout":
14
- case "stderr":
15
- case "thread_switched":
16
- return false;
17
- case "notice":
18
- return adapter === "pi" || /^OpenCode local draft:\s*/i.test(options.text ?? "");
19
- case "mirrored_user_input":
20
- return true;
21
- default:
22
- return true;
23
- }
24
- }
25
- export function formatUserFacingInboundError(params) {
26
- const { adapter, cwd, errorText } = params;
27
- if (adapter === "opencode" &&
28
- /opencode companion is not connected/i.test(errorText)) {
29
- return cwd
30
- ? `OpenCode is not connected for workspace:\n${cwd}\nRun "wechat-opencode" in that directory to recreate or reconnect the visible terminal.`
31
- : 'OpenCode is not connected. Run "wechat-opencode" in this directory, then retry.';
32
- }
33
- return `Bridge error: ${errorText}`;
34
- }
35
- export function formatWechatSendFailureLogEntry(params) {
36
- return `wechat_send_failed: context=${params.context} recipient=${params.recipientId} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
37
- }
38
- export function formatWechatContextTokenStaleLogEntry(params) {
39
- return `wechat_context_token_stale: context=${params.context} recipient=${params.recipientId} action=wechat_message_required error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
40
- }
41
- export function formatWechatSendRetryLogEntry(params) {
42
- return `wechat_send_retry: context=${params.context} recipient=${params.recipientId} attempt=${params.attempt} delay_ms=${params.delayMs} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
43
- }
44
- export function isRetryableWechatSendError(error) {
45
- if (isWechatContextTokenStaleError(error)) {
46
- return false;
47
- }
48
- const classification = classifyWechatTransportError(error);
49
- if (classification.retryable) {
50
- return true;
51
- }
52
- const details = describeWechatTransportError(error);
53
- return /^(?:Error|WechatApiResponseError): sendmessage failed:/i.test(details) &&
54
- !/errcode=-14\b.*session timeout/i.test(details);
55
- }
56
- export function computeWechatSendRetryDelayMs(attempt) {
57
- return WECHAT_SEND_RETRY_BASE_MS * attempt;
58
- }
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
3
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
4
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
5
+ // must be released under AGPL-3.0-or-later with full source code; see
6
+ // LICENSE.txt. Network services built on it must offer source to users.
7
+ /** @deprecated Import from ../channels/wechat/wechat-forwarding.ts instead. */
8
+ export * from "../channels/wechat/wechat-forwarding.js";
@@ -0,0 +1,8 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
3
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
4
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
5
+ // must be released under AGPL-3.0-or-later with full source code; see
6
+ // LICENSE.txt. Network services built on it must offer source to users.
7
+ /** @deprecated Import from ../channels/wechat/wechat-outbound-queue.ts instead. */
8
+ export * from "../channels/wechat/wechat-outbound-queue.js";
@@ -0,0 +1,56 @@
1
+ import { formatFinalReplyMessage, parseWechatFinalReply, sanitizeWechatFinalReplyText, } from "../../bridge/bridge-utils.js";
2
+ export const OPENCODE_EMPTY_VISIBLE_REPLY_MESSAGE = "OpenCode 没有产生可发送到微信的可见回复。请查看本地终端输出,或重试这条消息。";
3
+ export async function forwardWechatFinalReply(params) {
4
+ const { adapter, rawText, sender, onEmptyVisibleReply } = params;
5
+ const parsed = parseWechatFinalReply(rawText);
6
+ const sanitizedText = sanitizeWechatFinalReplyText(adapter, parsed.visibleText);
7
+ const visibleText = formatFinalReplyMessage(adapter, sanitizedText).trim();
8
+ if (visibleText) {
9
+ // Send the reply as a single message, matching the official WeChat bot
10
+ // client: it posts the full text in one sendmessage call without
11
+ // chunking. A failed send surfaces through the sender's own retry and
12
+ // pending-queue handling instead of being split up.
13
+ const sent = await sender.sendText(visibleText);
14
+ if (sent === false) {
15
+ // The send channel is failing (e.g. an expired context token).
16
+ // Report what was dropped instead of ending silently; this notice
17
+ // itself is best effort and may also fail.
18
+ if (parsed.attachments.length > 0) {
19
+ await sender.sendText(`[bridge] Reply delivery was interrupted; ${parsed.attachments.length} attachment(s) could not be sent. Send a new message to retry.`);
20
+ }
21
+ return;
22
+ }
23
+ }
24
+ else if (adapter === "opencode" && parsed.visibleText.trim()) {
25
+ onEmptyVisibleReply?.({
26
+ adapter,
27
+ rawVisibleText: parsed.visibleText,
28
+ });
29
+ const sent = await sender.sendText(OPENCODE_EMPTY_VISIBLE_REPLY_MESSAGE);
30
+ if (sent === false) {
31
+ return;
32
+ }
33
+ }
34
+ for (const attachment of parsed.attachments) {
35
+ try {
36
+ switch (attachment.kind) {
37
+ case "image":
38
+ await sender.sendImage(attachment.path);
39
+ break;
40
+ case "file":
41
+ await sender.sendFile(attachment.path);
42
+ break;
43
+ case "voice":
44
+ await sender.sendVoice(attachment.path);
45
+ break;
46
+ case "video":
47
+ await sender.sendVideo(attachment.path);
48
+ break;
49
+ }
50
+ }
51
+ catch (error) {
52
+ const errorText = error instanceof Error ? error.message : String(error ?? "unknown error");
53
+ await sender.sendText(`Failed to send ${attachment.kind} attachment: ${attachment.path}\n${errorText}`);
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,24 @@
1
+ function toChannelAttachment(attachment) {
2
+ return {
3
+ kind: attachment.kind,
4
+ path: attachment.path,
5
+ fileName: attachment.fileName,
6
+ sizeBytes: attachment.sizeBytes,
7
+ };
8
+ }
9
+ /** Convert the WeChat transport shape into the channel-neutral core shape. */
10
+ export function toChannelInboundMessage(message) {
11
+ return {
12
+ id: `${message.senderId}:${message.sessionId}:${message.createdAt}`,
13
+ conversation: {
14
+ channelId: "wechat",
15
+ conversationId: message.sessionId || message.senderId,
16
+ recipientId: message.senderId,
17
+ opaqueRef: message.contextToken,
18
+ },
19
+ senderId: message.senderId,
20
+ text: message.text,
21
+ attachments: message.attachments.map(toChannelAttachment),
22
+ createdAt: message.createdAt,
23
+ };
24
+ }
@@ -0,0 +1,11 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
3
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
4
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
5
+ // must be released under AGPL-3.0-or-later with full source code; see
6
+ // LICENSE.txt. Network services built on it must offer source to users.
7
+ export * from "./bridge-final-reply.js";
8
+ export * from "./channel-message.js";
9
+ export * from "./wechat-channel-port.js";
10
+ export * from "./wechat-forwarding.js";
11
+ export * from "./wechat-outbound-queue.js";
@@ -0,0 +1,67 @@
1
+ import { forwardWechatFinalReply } from "./bridge-final-reply.js";
2
+ function getRecipient(target) {
3
+ if (target.channelId !== "wechat") {
4
+ throw new Error(`WeChat channel cannot send to ${target.channelId}.`);
5
+ }
6
+ return target.recipientId;
7
+ }
8
+ function attachmentSender(deps, recipientId, attachment) {
9
+ switch (attachment.kind) {
10
+ case "image":
11
+ return deps.sendImage(recipientId, attachment.path);
12
+ case "file":
13
+ return deps.sendFile(recipientId, attachment.path);
14
+ case "voice":
15
+ return deps.sendVoice(recipientId, attachment.path);
16
+ case "video":
17
+ return deps.sendVideo(recipientId, attachment.path);
18
+ }
19
+ }
20
+ /**
21
+ * Channel boundary for WeChat output. The bridge core emits ChannelOutput;
22
+ * this port owns WeChat prefixes, final-reply parsing, and media dispatch.
23
+ */
24
+ export class WechatChannelPort {
25
+ channelId = "wechat";
26
+ deps;
27
+ constructor(deps) {
28
+ this.deps = deps;
29
+ }
30
+ async send(output) {
31
+ const recipientId = getRecipient(output.target);
32
+ const prefixText = (text) => this.deps.prefixText?.(output.adapter, text) ?? text;
33
+ const context = typeof output.metadata?.sendContext === "string"
34
+ ? output.metadata.sendContext
35
+ : output.kind;
36
+ if (output.kind === "final_reply") {
37
+ await forwardWechatFinalReply({
38
+ adapter: output.adapter ?? "codex",
39
+ rawText: output.text ?? "",
40
+ onEmptyVisibleReply: ({ rawVisibleText }) => {
41
+ this.deps.onEmptyVisibleReply?.(output.adapter, rawVisibleText);
42
+ },
43
+ sender: {
44
+ sendText: async (text) => {
45
+ const sent = await this.deps.sendText(recipientId, prefixText(text), "final_reply");
46
+ if (sent) {
47
+ this.deps.onTextSent?.(output.adapter, text);
48
+ }
49
+ return sent;
50
+ },
51
+ sendImage: (filePath) => this.deps.sendImage(recipientId, filePath),
52
+ sendFile: (filePath) => this.deps.sendFile(recipientId, filePath),
53
+ sendVoice: (filePath) => this.deps.sendVoice(recipientId, filePath),
54
+ sendVideo: (filePath) => this.deps.sendVideo(recipientId, filePath),
55
+ },
56
+ });
57
+ return true;
58
+ }
59
+ if (output.attachment) {
60
+ await attachmentSender(this.deps, recipientId, output.attachment);
61
+ }
62
+ if (output.text?.trim()) {
63
+ return Boolean(await this.deps.sendText(recipientId, prefixText(output.text), context));
64
+ }
65
+ return true;
66
+ }
67
+ }
@@ -0,0 +1,191 @@
1
+ import { WECHAT_SEND_MAX_ATTEMPTS, computeWechatSendRetryDelayMs, formatWechatContextTokenStaleLogEntry, formatWechatSendFailureLogEntry, formatWechatSendRetryLogEntry, isRetryableWechatSendError, isWechatContextUnavailableError, } from "../../channels/wechat/wechat-forwarding.js";
2
+ import { describeWechatTransportError, isWechatContextTokenStaleError, } from "../../wechat/wechat-transport.js";
3
+ import { formatDuration } from "../../core/text-utils.js";
4
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5
+ /** Per-recipient typing tickets with a random-refresh TTL and retry backoff. */
6
+ class TypingTicketCache {
7
+ fetchTicket;
8
+ log;
9
+ ttlMs;
10
+ initialRetryMs;
11
+ maxRetryMs;
12
+ cache = new Map();
13
+ constructor(fetchTicket, log, ttlMs = 24 * 60 * 60 * 1000, initialRetryMs = 2_000, maxRetryMs = 60 * 60 * 1000) {
14
+ this.fetchTicket = fetchTicket;
15
+ this.log = log;
16
+ this.ttlMs = ttlMs;
17
+ this.initialRetryMs = initialRetryMs;
18
+ this.maxRetryMs = maxRetryMs;
19
+ }
20
+ async getFor(recipientId) {
21
+ const now = Date.now();
22
+ const entry = this.cache.get(recipientId);
23
+ if (entry && now < entry.nextFetchAt) {
24
+ return entry.ticket;
25
+ }
26
+ let ticket;
27
+ try {
28
+ ticket = await this.fetchTicket(recipientId);
29
+ }
30
+ catch {
31
+ ticket = "";
32
+ }
33
+ if (ticket) {
34
+ this.cache.set(recipientId, {
35
+ ticket,
36
+ nextFetchAt: now + Math.random() * this.ttlMs,
37
+ retryDelayMs: this.initialRetryMs,
38
+ });
39
+ this.log(`wechat_typing_ticket_${entry ? "refreshed" : "cached"}: recipient=${recipientId}`);
40
+ }
41
+ else {
42
+ const retryDelayMs = Math.min((entry?.retryDelayMs ?? this.initialRetryMs / 2) * 2, this.maxRetryMs);
43
+ this.cache.set(recipientId, {
44
+ ticket: entry?.ticket ?? "",
45
+ nextFetchAt: now + retryDelayMs,
46
+ retryDelayMs,
47
+ });
48
+ this.log(`wechat_typing_ticket_fetch_failed: recipient=${recipientId} retry_in=${formatDuration(retryDelayMs)}`);
49
+ }
50
+ return this.cache.get(recipientId)?.ticket ?? "";
51
+ }
52
+ /** Cached ticket without triggering a fetch (for cancel sends). */
53
+ peekCached(recipientId) {
54
+ return this.cache.get(recipientId)?.ticket ?? "";
55
+ }
56
+ }
57
+ const WECHAT_CONTEXT_STALE_HINT = "WeChat conversation context is stale or unavailable. Ask the WeChat owner to send any message first, then local terminal replies can sync back to WeChat.";
58
+ /** ChannelDriver over the WeChat iLink long-poll transport. */
59
+ export class WechatChannelDriver {
60
+ id = "wechat";
61
+ displayName = "WeChat";
62
+ operatorDescription = "the configured WeChat owner";
63
+ capabilities = {
64
+ streamingReplies: false,
65
+ outboundAttachments: false,
66
+ multiConversation: false,
67
+ pushInbound: false,
68
+ };
69
+ transport;
70
+ logError;
71
+ buildInboundPromptImpl;
72
+ typingKeepaliveMs;
73
+ typingTickets;
74
+ typingTimers = new Map();
75
+ constructor(options) {
76
+ this.transport = options.transport;
77
+ this.logError = options.logError;
78
+ this.buildInboundPromptImpl = options.buildInboundPrompt;
79
+ this.typingKeepaliveMs = options.typingKeepaliveMs ?? 5_000;
80
+ this.typingTickets = new TypingTicketCache((recipientId) => this.transport.fetchTypingTicket(recipientId), (message) => this.logError(`[typing] ${message}`));
81
+ }
82
+ defaultConversation() {
83
+ return null;
84
+ }
85
+ directConversation(senderId) {
86
+ return {
87
+ channelId: "wechat",
88
+ conversationId: senderId,
89
+ recipientId: senderId,
90
+ };
91
+ }
92
+ async sendText(params) {
93
+ const { target, text, context, log } = params;
94
+ const senderId = target.recipientId;
95
+ const startedAtMs = Date.now();
96
+ log(`wechat_send_started: context=${context} recipient=${senderId} chars=${Array.from(text).length}`);
97
+ for (let attempt = 1; attempt <= WECHAT_SEND_MAX_ATTEMPTS; attempt += 1) {
98
+ try {
99
+ await this.transport.sendText(senderId, text);
100
+ log(`wechat_send_completed: context=${context} recipient=${senderId} attempt=${attempt} elapsed_ms=${Date.now() - startedAtMs}`);
101
+ return { status: "sent" };
102
+ }
103
+ catch (err) {
104
+ if (err instanceof Error && err.name === "AbortError") {
105
+ log(`wechat_send_timeout: context=${context} recipient=${senderId} attempt=${attempt} elapsed_ms=${Date.now() - startedAtMs}`);
106
+ }
107
+ if (isWechatContextUnavailableError(err)) {
108
+ if (isWechatContextTokenStaleError(err)) {
109
+ this.transport.clearCachedContextToken(senderId);
110
+ }
111
+ this.logError(`Failed to send WeChat ${context}: ${WECHAT_CONTEXT_STALE_HINT}`);
112
+ log(isWechatContextTokenStaleError(err)
113
+ ? formatWechatContextTokenStaleLogEntry({
114
+ context,
115
+ recipientId: senderId,
116
+ error: err,
117
+ })
118
+ : formatWechatSendFailureLogEntry({
119
+ context,
120
+ recipientId: senderId,
121
+ error: err,
122
+ }));
123
+ return {
124
+ status: "target_stale",
125
+ error: err,
126
+ target,
127
+ };
128
+ }
129
+ if (attempt < WECHAT_SEND_MAX_ATTEMPTS && isRetryableWechatSendError(err)) {
130
+ const delayMs = computeWechatSendRetryDelayMs(attempt);
131
+ this.logError(`Failed to send WeChat ${context} (attempt ${attempt}). Retrying in ${formatDuration(delayMs)}. ${describeWechatTransportError(err)}`);
132
+ log(formatWechatSendRetryLogEntry({
133
+ context,
134
+ recipientId: senderId,
135
+ attempt,
136
+ delayMs,
137
+ error: err,
138
+ }));
139
+ await delay(delayMs);
140
+ continue;
141
+ }
142
+ this.logError(`Failed to send WeChat ${context}: ${describeWechatTransportError(err)}`);
143
+ log(formatWechatSendFailureLogEntry({
144
+ context,
145
+ recipientId: senderId,
146
+ error: err,
147
+ }));
148
+ return { status: "failed", error: err, target };
149
+ }
150
+ }
151
+ return { status: "failed", error: new Error("unreachable"), target };
152
+ }
153
+ buildInboundPrompt(text, attachments) {
154
+ return this.buildInboundPromptImpl(text, attachments);
155
+ }
156
+ async beginTyping(recipientId) {
157
+ if (this.typingTimers.has(recipientId)) {
158
+ return;
159
+ }
160
+ const ticket = await this.typingTickets.getFor(recipientId);
161
+ if (!ticket) {
162
+ return;
163
+ }
164
+ const sent = await this.transport.sendTyping(recipientId, ticket, 1);
165
+ if (!sent) {
166
+ return;
167
+ }
168
+ const timer = setInterval(() => {
169
+ void this.transport.sendTyping(recipientId, ticket, 1);
170
+ }, this.typingKeepaliveMs);
171
+ timer.unref?.();
172
+ this.typingTimers.set(recipientId, timer);
173
+ }
174
+ async endTyping(recipientId) {
175
+ const timer = this.typingTimers.get(recipientId);
176
+ if (timer) {
177
+ clearInterval(timer);
178
+ this.typingTimers.delete(recipientId);
179
+ }
180
+ const ticket = this.typingTickets.peekCached(recipientId);
181
+ if (!ticket) {
182
+ return;
183
+ }
184
+ await this.transport.sendTyping(recipientId, ticket, 2);
185
+ }
186
+ async endAllTyping() {
187
+ for (const recipientId of [...this.typingTimers.keys()]) {
188
+ await this.endTyping(recipientId);
189
+ }
190
+ }
191
+ }
@@ -0,0 +1,97 @@
1
+ import { truncatePreview } from "../../bridge/bridge-utils.js";
2
+ import { classifyWechatTransportError, describeWechatTransportError, isWechatContextTokenStaleError, } from "../../wechat/wechat-transport.js";
3
+ export const WECHAT_SEND_CONTEXTS = [
4
+ "final_reply",
5
+ "message",
6
+ "notice",
7
+ "approval_required",
8
+ "user_input_required",
9
+ "mirrored_user_input",
10
+ "session_switched",
11
+ "thread_switched",
12
+ "task_failed",
13
+ "fatal_error",
14
+ "inbound_error",
15
+ "thinking",
16
+ ];
17
+ export function isWechatSendContext(value) {
18
+ return typeof value === "string" &&
19
+ WECHAT_SEND_CONTEXTS.includes(value);
20
+ }
21
+ export const WECHAT_SEND_MAX_ATTEMPTS = 3;
22
+ export const CODEX_LOCAL_THREAD_NOTICE_SUPPRESS_MS = 500;
23
+ const WECHAT_SEND_RETRY_BASE_MS = 750;
24
+ export function formatUserFacingBridgeFatalError(message) {
25
+ return `Bridge error: ${message.replace(/\s+Recent app-server log:.*$/s, "").trim()}`;
26
+ }
27
+ export function shouldForwardBridgeEventToWechat(adapter, eventType, options = {}) {
28
+ if (adapter !== "opencode" && adapter !== "pi") {
29
+ return true;
30
+ }
31
+ switch (eventType) {
32
+ case "stdout":
33
+ case "stderr":
34
+ case "thread_switched":
35
+ return false;
36
+ case "notice":
37
+ return adapter === "pi" || /^OpenCode local draft:\s*/i.test(options.text ?? "");
38
+ case "mirrored_user_input":
39
+ return true;
40
+ default:
41
+ return true;
42
+ }
43
+ }
44
+ /**
45
+ * Local Codex panels can emit a burst of weak thread notifications immediately
46
+ * after a WeChat reply. Suppress only the user-facing notice in that window;
47
+ * the adapter still updates its internal shared-thread state.
48
+ */
49
+ export function shouldSuppressCodexLocalThreadNotice(params) {
50
+ if (params.adapter !== "codex" || params.source !== "local") {
51
+ return false;
52
+ }
53
+ if (typeof params.lastFinalReplyAtMs !== "number") {
54
+ return false;
55
+ }
56
+ const nowMs = params.nowMs ?? Date.now();
57
+ const elapsedMs = nowMs - params.lastFinalReplyAtMs;
58
+ return (elapsedMs >= 0 && elapsedMs < CODEX_LOCAL_THREAD_NOTICE_SUPPRESS_MS);
59
+ }
60
+ export function formatUserFacingInboundError(params) {
61
+ const { adapter, cwd, errorText } = params;
62
+ if (adapter === "opencode" &&
63
+ /opencode companion is not connected/i.test(errorText)) {
64
+ return cwd
65
+ ? `OpenCode is not connected for workspace:\n${cwd}\nRun "wechat-opencode" in that directory to recreate or reconnect the visible terminal.`
66
+ : 'OpenCode is not connected. Run "wechat-opencode" in this directory, then retry.';
67
+ }
68
+ return `Bridge error: ${errorText}`;
69
+ }
70
+ export function formatWechatSendFailureLogEntry(params) {
71
+ return `wechat_send_failed: context=${params.context} recipient=${params.recipientId} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
72
+ }
73
+ export function formatWechatContextTokenStaleLogEntry(params) {
74
+ return `wechat_context_token_stale: context=${params.context} recipient=${params.recipientId} action=wechat_message_required error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
75
+ }
76
+ export function formatWechatSendRetryLogEntry(params) {
77
+ return `wechat_send_retry: context=${params.context} recipient=${params.recipientId} attempt=${params.attempt} delay_ms=${params.delayMs} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
78
+ }
79
+ export function isRetryableWechatSendError(error) {
80
+ if (isWechatContextTokenStaleError(error)) {
81
+ return false;
82
+ }
83
+ const classification = classifyWechatTransportError(error);
84
+ if (classification.retryable) {
85
+ return true;
86
+ }
87
+ const details = describeWechatTransportError(error);
88
+ return /^(?:Error|WechatApiResponseError): sendmessage failed:/i.test(details) &&
89
+ !/errcode=-14\b.*session timeout/i.test(details);
90
+ }
91
+ export function isWechatContextUnavailableError(error) {
92
+ return (isWechatContextTokenStaleError(error) ||
93
+ (error instanceof Error && /No cached context token(?: is available| for )/i.test(error.message)));
94
+ }
95
+ export function computeWechatSendRetryDelayMs(attempt) {
96
+ return WECHAT_SEND_RETRY_BASE_MS * attempt;
97
+ }
@@ -0,0 +1,118 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
3
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
4
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
5
+ // must be released under AGPL-3.0-or-later with full source code; see
6
+ // LICENSE.txt. Network services built on it must offer source to users.
7
+ import crypto from "node:crypto";
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { getWorkspaceChannelPaths } from "../../wechat/channel-config.js";
11
+ import { writeJsonFileAtomic } from "../../utils/atomic-file.js";
12
+ /** Contexts worth redelivering after the channel recovers. */
13
+ const PENDING_WORTHY_CONTEXTS = new Set([
14
+ "final_reply",
15
+ "approval_required",
16
+ "user_input_required",
17
+ "task_failed",
18
+ "fatal_error",
19
+ ]);
20
+ /** Drop queued messages older than this — stale replies are noise, not value. */
21
+ const PENDING_MAX_AGE_MS = 30 * 60 * 1000;
22
+ /** Cap the backlog so one recovery never floods the chat. */
23
+ const PENDING_MAX_MESSAGES = 10;
24
+ function isExpired(message, now = Date.now()) {
25
+ const queuedAtMs = Date.parse(message.queuedAt);
26
+ if (!Number.isFinite(queuedAtMs)) {
27
+ return true;
28
+ }
29
+ return now - queuedAtMs > PENDING_MAX_AGE_MS;
30
+ }
31
+ export function getPendingWechatMessagesFile(cwd) {
32
+ return getPendingChannelMessagesFile(cwd, "wechat");
33
+ }
34
+ export function getPendingChannelMessagesFile(cwd, channelId) {
35
+ return path.join(getWorkspaceChannelPaths(cwd).workspaceDir, `pending-${channelId}-messages.json`);
36
+ }
37
+ function readPendingMessages(filePath) {
38
+ try {
39
+ if (!fs.existsSync(filePath)) {
40
+ return [];
41
+ }
42
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
43
+ if (!Array.isArray(parsed.messages)) {
44
+ return [];
45
+ }
46
+ return parsed.messages.filter((message) => {
47
+ return (Boolean(message) &&
48
+ typeof message === "object" &&
49
+ typeof message.id === "string" &&
50
+ typeof message.recipientId === "string" &&
51
+ typeof message.text === "string" &&
52
+ typeof message.context === "string" &&
53
+ typeof message.queuedAt === "string");
54
+ });
55
+ }
56
+ catch {
57
+ return [];
58
+ }
59
+ }
60
+ export class PendingWechatMessageStore {
61
+ filePath;
62
+ messages;
63
+ constructor(filePath) {
64
+ this.filePath = filePath;
65
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
66
+ this.messages = readPendingMessages(filePath);
67
+ }
68
+ list() {
69
+ this.compact();
70
+ return this.messages.map((message) => ({
71
+ ...message,
72
+ target: message.target ? { ...message.target } : undefined,
73
+ }));
74
+ }
75
+ enqueue(recipientId, text, context, target) {
76
+ const normalizedRecipientId = recipientId.trim();
77
+ const normalizedText = text.trim();
78
+ if (!normalizedRecipientId || !normalizedText) {
79
+ return null;
80
+ }
81
+ if (!PENDING_WORTHY_CONTEXTS.has(context)) {
82
+ return null;
83
+ }
84
+ const message = {
85
+ id: crypto.randomUUID(),
86
+ recipientId: normalizedRecipientId,
87
+ text: normalizedText,
88
+ context,
89
+ queuedAt: new Date().toISOString(),
90
+ target: target ? { ...target } : undefined,
91
+ };
92
+ this.messages.push(message);
93
+ this.compact();
94
+ this.persist();
95
+ return { ...message };
96
+ }
97
+ /** Drop expired entries and trim the backlog to the cap (oldest first). */
98
+ compact() {
99
+ const now = Date.now();
100
+ const fresh = this.messages.filter((message) => !isExpired(message, now));
101
+ while (fresh.length > PENDING_MAX_MESSAGES) {
102
+ fresh.shift();
103
+ }
104
+ this.messages = fresh;
105
+ }
106
+ remove(id) {
107
+ const next = this.messages.filter((message) => message.id !== id);
108
+ if (next.length === this.messages.length) {
109
+ return false;
110
+ }
111
+ this.messages = next;
112
+ this.persist();
113
+ return true;
114
+ }
115
+ persist() {
116
+ writeJsonFileAtomic(this.filePath, { messages: this.messages });
117
+ }
118
+ }