@unlinearity/cli-wechat-bridge 1.1.5 → 1.1.6

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.
@@ -0,0 +1,65 @@
1
+ import { formatFinalReplyMessage, parseWechatFinalReply, sanitizeWechatFinalReplyText, splitWechatTextIntoChunks, } 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 long replies in bounded chunks: a single oversized sendmessage call
10
+ // can be rejected by the WeChat API, silently losing the whole reply.
11
+ const chunks = splitWechatTextIntoChunks(visibleText);
12
+ for (let index = 0; index < chunks.length; index += 1) {
13
+ const sent = await sender.sendText(chunks[index]);
14
+ if (sent === false) {
15
+ // The send channel is failing (e.g. an expired context token).
16
+ // Report what was dropped instead of ending mid-reply silently;
17
+ // this notice itself is best effort and may also fail.
18
+ const remainingChunks = chunks.length - index - 1;
19
+ if (remainingChunks > 0 || parsed.attachments.length > 0) {
20
+ const parts = [];
21
+ if (remainingChunks > 0) {
22
+ parts.push(`${remainingChunks} reply chunk(s)`);
23
+ }
24
+ if (parsed.attachments.length > 0) {
25
+ parts.push(`${parsed.attachments.length} attachment(s)`);
26
+ }
27
+ await sender.sendText(`[bridge] Reply delivery was interrupted; ${parts.join(" and ")} could not be sent. Send a new message to retry.`);
28
+ }
29
+ return;
30
+ }
31
+ }
32
+ }
33
+ else if (adapter === "opencode" && parsed.visibleText.trim()) {
34
+ onEmptyVisibleReply?.({
35
+ adapter,
36
+ rawVisibleText: parsed.visibleText,
37
+ });
38
+ const sent = await sender.sendText(OPENCODE_EMPTY_VISIBLE_REPLY_MESSAGE);
39
+ if (sent === false) {
40
+ return;
41
+ }
42
+ }
43
+ for (const attachment of parsed.attachments) {
44
+ try {
45
+ switch (attachment.kind) {
46
+ case "image":
47
+ await sender.sendImage(attachment.path);
48
+ break;
49
+ case "file":
50
+ await sender.sendFile(attachment.path);
51
+ break;
52
+ case "voice":
53
+ await sender.sendVoice(attachment.path);
54
+ break;
55
+ case "video":
56
+ await sender.sendVideo(attachment.path);
57
+ break;
58
+ }
59
+ }
60
+ catch (error) {
61
+ const errorText = error instanceof Error ? error.message : String(error ?? "unknown error");
62
+ await sender.sendText(`Failed to send ${attachment.kind} attachment: ${attachment.path}\n${errorText}`);
63
+ }
64
+ }
65
+ }
@@ -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,5 @@
1
+ export * from "./bridge-final-reply.js";
2
+ export * from "./channel-message.js";
3
+ export * from "./wechat-channel-port.js";
4
+ export * from "./wechat-forwarding.js";
5
+ 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,79 @@
1
+ import { truncatePreview } from "../../bridge/bridge-utils.js";
2
+ import { classifyWechatTransportError, describeWechatTransportError, isWechatContextTokenStaleError, } from "../../wechat/wechat-transport.js";
3
+ export const WECHAT_SEND_MAX_ATTEMPTS = 3;
4
+ export const CODEX_LOCAL_THREAD_NOTICE_SUPPRESS_MS = 500;
5
+ const WECHAT_SEND_RETRY_BASE_MS = 750;
6
+ export function formatUserFacingBridgeFatalError(message) {
7
+ return `Bridge error: ${message.replace(/\s+Recent app-server log:.*$/s, "").trim()}`;
8
+ }
9
+ export function shouldForwardBridgeEventToWechat(adapter, eventType, options = {}) {
10
+ if (adapter !== "opencode" && adapter !== "pi") {
11
+ return true;
12
+ }
13
+ switch (eventType) {
14
+ case "stdout":
15
+ case "stderr":
16
+ case "thread_switched":
17
+ return false;
18
+ case "notice":
19
+ return adapter === "pi" || /^OpenCode local draft:\s*/i.test(options.text ?? "");
20
+ case "mirrored_user_input":
21
+ return true;
22
+ default:
23
+ return true;
24
+ }
25
+ }
26
+ /**
27
+ * Local Codex panels can emit a burst of weak thread notifications immediately
28
+ * after a WeChat reply. Suppress only the user-facing notice in that window;
29
+ * the adapter still updates its internal shared-thread state.
30
+ */
31
+ export function shouldSuppressCodexLocalThreadNotice(params) {
32
+ if (params.adapter !== "codex" || params.source !== "local") {
33
+ return false;
34
+ }
35
+ if (typeof params.lastFinalReplyAtMs !== "number") {
36
+ return false;
37
+ }
38
+ const nowMs = params.nowMs ?? Date.now();
39
+ const elapsedMs = nowMs - params.lastFinalReplyAtMs;
40
+ return (elapsedMs >= 0 && elapsedMs < CODEX_LOCAL_THREAD_NOTICE_SUPPRESS_MS);
41
+ }
42
+ export function formatUserFacingInboundError(params) {
43
+ const { adapter, cwd, errorText } = params;
44
+ if (adapter === "opencode" &&
45
+ /opencode companion is not connected/i.test(errorText)) {
46
+ return cwd
47
+ ? `OpenCode is not connected for workspace:\n${cwd}\nRun "wechat-opencode" in that directory to recreate or reconnect the visible terminal.`
48
+ : 'OpenCode is not connected. Run "wechat-opencode" in this directory, then retry.';
49
+ }
50
+ return `Bridge error: ${errorText}`;
51
+ }
52
+ export function formatWechatSendFailureLogEntry(params) {
53
+ return `wechat_send_failed: context=${params.context} recipient=${params.recipientId} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
54
+ }
55
+ export function formatWechatContextTokenStaleLogEntry(params) {
56
+ return `wechat_context_token_stale: context=${params.context} recipient=${params.recipientId} action=wechat_message_required error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
57
+ }
58
+ export function formatWechatSendRetryLogEntry(params) {
59
+ return `wechat_send_retry: context=${params.context} recipient=${params.recipientId} attempt=${params.attempt} delay_ms=${params.delayMs} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
60
+ }
61
+ export function isRetryableWechatSendError(error) {
62
+ if (isWechatContextTokenStaleError(error)) {
63
+ return false;
64
+ }
65
+ const classification = classifyWechatTransportError(error);
66
+ if (classification.retryable) {
67
+ return true;
68
+ }
69
+ const details = describeWechatTransportError(error);
70
+ return /^(?:Error|WechatApiResponseError): sendmessage failed:/i.test(details) &&
71
+ !/errcode=-14\b.*session timeout/i.test(details);
72
+ }
73
+ export function isWechatContextUnavailableError(error) {
74
+ return (isWechatContextTokenStaleError(error) ||
75
+ (error instanceof Error && /No cached context token(?: is available| for )/i.test(error.message)));
76
+ }
77
+ export function computeWechatSendRetryDelayMs(attempt) {
78
+ return WECHAT_SEND_RETRY_BASE_MS * attempt;
79
+ }
@@ -0,0 +1,72 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { getWorkspaceChannelPaths } from "../../wechat/channel-config.js";
5
+ import { writeJsonFileAtomic } from "../../utils/atomic-file.js";
6
+ export function getPendingWechatMessagesFile(cwd) {
7
+ return path.join(getWorkspaceChannelPaths(cwd).workspaceDir, "pending-wechat-messages.json");
8
+ }
9
+ function readPendingMessages(filePath) {
10
+ try {
11
+ if (!fs.existsSync(filePath)) {
12
+ return [];
13
+ }
14
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
15
+ if (!Array.isArray(parsed.messages)) {
16
+ return [];
17
+ }
18
+ return parsed.messages.filter((message) => {
19
+ return (Boolean(message) &&
20
+ typeof message === "object" &&
21
+ typeof message.id === "string" &&
22
+ typeof message.recipientId === "string" &&
23
+ typeof message.text === "string" &&
24
+ typeof message.context === "string" &&
25
+ typeof message.queuedAt === "string");
26
+ });
27
+ }
28
+ catch {
29
+ return [];
30
+ }
31
+ }
32
+ export class PendingWechatMessageStore {
33
+ filePath;
34
+ messages;
35
+ constructor(filePath) {
36
+ this.filePath = filePath;
37
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
38
+ this.messages = readPendingMessages(filePath);
39
+ }
40
+ list() {
41
+ return this.messages.map((message) => ({ ...message }));
42
+ }
43
+ enqueue(recipientId, text, context) {
44
+ const normalizedRecipientId = recipientId.trim();
45
+ const normalizedText = text.trim();
46
+ if (!normalizedRecipientId || !normalizedText) {
47
+ return null;
48
+ }
49
+ const message = {
50
+ id: crypto.randomUUID(),
51
+ recipientId: normalizedRecipientId,
52
+ text: normalizedText,
53
+ context,
54
+ queuedAt: new Date().toISOString(),
55
+ };
56
+ this.messages.push(message);
57
+ this.persist();
58
+ return { ...message };
59
+ }
60
+ remove(id) {
61
+ const next = this.messages.filter((message) => message.id !== id);
62
+ if (next.length === this.messages.length) {
63
+ return false;
64
+ }
65
+ this.messages = next;
66
+ this.persist();
67
+ return true;
68
+ }
69
+ persist() {
70
+ writeJsonFileAtomic(this.filePath, { messages: this.messages });
71
+ }
72
+ }
@@ -0,0 +1,32 @@
1
+ /** Parse the canonical control command grammar shared by all channels. */
2
+ export function parseBridgeControlCommand(text) {
3
+ const [rawCommand, ...rest] = text.trim().split(/\s+/);
4
+ if (!rawCommand?.startsWith("/")) {
5
+ return null;
6
+ }
7
+ const command = rawCommand.toLowerCase();
8
+ const argument = rest.join(" ").trim();
9
+ switch (command) {
10
+ case "/status":
11
+ return { type: "status" };
12
+ case "/resume":
13
+ return argument ? { type: "resume", target: argument } : { type: "resume" };
14
+ case "/new":
15
+ case "/new-session":
16
+ return { type: "new_session" };
17
+ case "/stop":
18
+ return { type: "stop" };
19
+ case "/reset":
20
+ return { type: "reset" };
21
+ case "/confirm":
22
+ case "/yes":
23
+ return { type: "confirm" };
24
+ case "/deny":
25
+ case "/no":
26
+ return { type: "deny" };
27
+ case "/answer":
28
+ return argument ? { type: "answer", raw: argument } : null;
29
+ default:
30
+ return null;
31
+ }
32
+ }
@@ -0,0 +1,50 @@
1
+ /** Dispatch a BridgeEvent without importing any channel or transport code. */
2
+ export async function forwardBridgeEvent(event, handlers) {
3
+ switch (event.type) {
4
+ case "stdout":
5
+ await handlers.stdout?.(event);
6
+ return;
7
+ case "stderr":
8
+ await handlers.stderr?.(event);
9
+ return;
10
+ case "final_reply":
11
+ await handlers.finalReply?.(event);
12
+ return;
13
+ case "status":
14
+ await handlers.status?.(event);
15
+ return;
16
+ case "notice":
17
+ await handlers.notice?.(event);
18
+ return;
19
+ case "thinking":
20
+ await handlers.thinking?.(event);
21
+ return;
22
+ case "approval_required":
23
+ await handlers.approvalRequired?.(event);
24
+ return;
25
+ case "user_input_required":
26
+ await handlers.userInputRequired?.(event);
27
+ return;
28
+ case "mirrored_user_input":
29
+ await handlers.mirroredUserInput?.(event);
30
+ return;
31
+ case "session_switched":
32
+ await handlers.sessionSwitched?.(event);
33
+ return;
34
+ case "thread_switched":
35
+ await handlers.threadSwitched?.(event);
36
+ return;
37
+ case "task_complete":
38
+ await handlers.taskComplete?.(event);
39
+ return;
40
+ case "task_failed":
41
+ await handlers.taskFailed?.(event);
42
+ return;
43
+ case "fatal_error":
44
+ await handlers.fatalError?.(event);
45
+ return;
46
+ case "shutdown_requested":
47
+ await handlers.shutdownRequested?.(event);
48
+ return;
49
+ }
50
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Common, channel-neutral ordering for an inbound bridge message.
3
+ * Channel-specific parsing and daemon slot selection stay outside this
4
+ * function and are supplied through callbacks.
5
+ */
6
+ export async function routeBridgeMessage(context) {
7
+ if (!context.authorized) {
8
+ await context.onUnauthorized();
9
+ return { kind: "handled" };
10
+ }
11
+ if (context.command && await context.handleCommand(context.command)) {
12
+ return { kind: "handled" };
13
+ }
14
+ if (context.hasPendingApproval) {
15
+ await context.remindPendingApproval();
16
+ return { kind: "handled" };
17
+ }
18
+ if (context.hasPendingUserInput || context.adapterState.status === "awaiting_input") {
19
+ await context.remindPendingUserInput();
20
+ return { kind: "handled" };
21
+ }
22
+ if (context.shouldDefer) {
23
+ await context.defer();
24
+ return { kind: "deferred" };
25
+ }
26
+ if (context.adapterState.status === "busy" ||
27
+ context.adapterState.status === "awaiting_approval") {
28
+ await context.remindBusy();
29
+ return { kind: "handled" };
30
+ }
31
+ return { kind: "dispatched", result: await context.dispatch() };
32
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,63 @@
1
+ const ANSI_ESCAPE_RE =
2
+ // eslint-disable-next-line no-control-regex
3
+ /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
4
+ export function nowIso() {
5
+ return new Date().toISOString();
6
+ }
7
+ export function stripAnsi(text) {
8
+ return text.replace(ANSI_ESCAPE_RE, "");
9
+ }
10
+ export function normalizeOutput(text) {
11
+ return stripAnsi(text)
12
+ // eslint-disable-next-line no-control-regex
13
+ .replace(/\u0000/g, "")
14
+ .replace(/\r\n/g, "\n")
15
+ .replace(/\r/g, "\n");
16
+ }
17
+ export function truncatePreview(text, maxLength = 140) {
18
+ const normalized = normalizeOutput(text).trim().replace(/\s+/g, " ");
19
+ if (!normalized) {
20
+ return "(empty)";
21
+ }
22
+ if (normalized.length <= maxLength) {
23
+ return normalized;
24
+ }
25
+ return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
26
+ }
27
+ export function buildOneTimeCode(length = 6) {
28
+ const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
29
+ let code = "";
30
+ while (code.length < length) {
31
+ code += alphabet[Math.floor(Math.random() * alphabet.length)];
32
+ }
33
+ return code;
34
+ }
35
+ export function buildInstanceId() {
36
+ return `bridge-${Date.now().toString(36)}-${buildOneTimeCode(6).toLowerCase()}`;
37
+ }
38
+ export function formatDuration(durationMs) {
39
+ if (!Number.isFinite(durationMs) || durationMs < 0) {
40
+ return "0s";
41
+ }
42
+ const totalSeconds = Math.floor(durationMs / 1000);
43
+ const minutes = Math.floor(totalSeconds / 60);
44
+ const seconds = totalSeconds % 60;
45
+ if (!minutes) {
46
+ return `${seconds}s`;
47
+ }
48
+ return `${minutes}m ${seconds}s`;
49
+ }
50
+ export function summarizeOutput(text, maxLength = 280) {
51
+ const normalized = normalizeOutput(text)
52
+ .split("\n")
53
+ .map((line) => line.trim())
54
+ .filter(Boolean);
55
+ if (!normalized.length) {
56
+ return "(no output)";
57
+ }
58
+ const summary = normalized.slice(-6).join("\n");
59
+ if (summary.length <= maxLength) {
60
+ return summary;
61
+ }
62
+ return summary.slice(summary.length - maxLength);
63
+ }