diff --git a/integrations/bridge-core/package.json b/integrations/bridge-core/package.json new file mode 100644 index 000000000..c1ede5a51 --- /dev/null +++ b/integrations/bridge-core/package.json @@ -0,0 +1,15 @@ +{ + "name": "@codewhale/bridge-core", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Shared pure helpers for CodeWhale chat bridges.", + "main": "src/lib.mjs", + "scripts": { + "check": "node --check src/lib.mjs", + "test": "node --test test/*.test.mjs" + }, + "engines": { + "node": ">=18" + } +} diff --git a/integrations/bridge-core/src/lib.mjs b/integrations/bridge-core/src/lib.mjs new file mode 100644 index 000000000..d510b5e0b --- /dev/null +++ b/integrations/bridge-core/src/lib.mjs @@ -0,0 +1,330 @@ +import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const DEFAULT_ACTION_TTL_MS = 24 * 60 * 60 * 1000; + +async function chmodBestEffort(filePath, mode) { + try { + await chmod(filePath, mode); + } catch (error) { + if (process.platform !== "win32") throw error; + } +} + +export class ThreadStore { + static async open(filePath, options = {}) { + const store = new ThreadStore(filePath, options); + await store.load(); + return store; + } + + constructor(filePath, options = {}) { + this.filePath = filePath; + this.options = { + messageLimit: options.messageLimit || 0, + actions: options.actions === true, + actionLimit: options.actionLimit || 200, + actionTtlMs: options.actionTtlMs || DEFAULT_ACTION_TTL_MS, + privateMode: options.privateMode === true + }; + this.data = { chats: {} }; + this.ensureShape(); + } + + ensureShape() { + if (!this.data || typeof this.data !== "object") this.data = {}; + if (!this.data.chats || typeof this.data.chats !== "object") this.data.chats = {}; + if (this.options.messageLimit > 0 && !Array.isArray(this.data.messages)) { + this.data.messages = []; + } + if (this.options.actions && (!this.data.actions || typeof this.data.actions !== "object")) { + this.data.actions = {}; + } + } + + async load() { + try { + const raw = await readFile(this.filePath, "utf8"); + this.data = JSON.parse(raw); + this.ensureShape(); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + } + + async recordMessage(messageKey) { + if (!messageKey || this.options.messageLimit <= 0) return false; + this.ensureShape(); + if (this.data.messages.includes(messageKey)) return true; + this.data.messages.push(messageKey); + this.data.messages = this.data.messages.slice(-this.options.messageLimit); + await this.save(); + return false; + } + + async getChat(chatId) { + return this.data.chats[chatId] || null; + } + + listChats() { + return Object.entries(this.data.chats || {}); + } + + async setChat(chatId, state) { + this.data.chats[chatId] = state; + await this.save(); + return state; + } + + async patchChat(chatId, patch) { + const current = this.data.chats[chatId] || {}; + this.data.chats[chatId] = { ...current, ...patch }; + await this.save(); + return this.data.chats[chatId]; + } + + async putAction(action) { + if (!this.options.actions) return ""; + this.ensureShape(); + const token = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; + this.data.actions[token] = { + ...action, + createdAt: new Date().toISOString() + }; + this.pruneActions(); + await this.save(); + return token; + } + + async getAction(token) { + if (!token || !this.options.actions) return null; + this.ensureShape(); + return this.data.actions[token] || null; + } + + async takeAction(token) { + const action = await this.getAction(token); + if (action) { + delete this.data.actions[token]; + await this.save(); + } + return action; + } + + pruneActions() { + if (!this.options.actions) return; + const cutoff = Date.now() - this.options.actionTtlMs; + const fresh = Object.entries(this.data.actions || {}).filter(([, action]) => { + const time = Date.parse(action.createdAt || ""); + return Number.isFinite(time) && time >= cutoff; + }); + this.data.actions = Object.fromEntries(fresh.slice(-this.options.actionLimit)); + } + + async save() { + const dir = path.dirname(this.filePath); + await mkdir(dir, { recursive: true, mode: 0o700 }); + if (this.options.privateMode) await chmodBestEffort(dir, 0o700); + const tmp = `${this.filePath}.tmp`; + await writeFile(tmp, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600 }); + if (this.options.privateMode) await chmodBestEffort(tmp, 0o600); + await rename(tmp, this.filePath); + if (this.options.privateMode) await chmodBestEffort(this.filePath, 0o600); + } +} + +export function envFirst(env, ...names) { + for (const name of names) { + const value = env?.[name]; + if (value != null && String(value).trim()) return String(value).trim(); + } + return ""; +} + +export function parseList(raw) { + return String(raw || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +export function parseBool(raw, fallback = false) { + if (raw == null || raw === "") return fallback; + return ["1", "true", "yes", "on"].includes(String(raw).trim().toLowerCase()); +} + +export function parseEnvText(raw) { + const env = {}; + for (const line of String(raw || "").split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const normalized = trimmed.startsWith("export ") ? trimmed.slice(7).trim() : trimmed; + const index = normalized.indexOf("="); + if (index <= 0) continue; + const key = normalized.slice(0, index).trim(); + let value = normalized.slice(index + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + env[key] = value; + } + return env; +} + +export function cleanEnvValue(value) { + return String(value ?? "").trim(); +} + +export function isPlaceholderValue(value) { + const normalized = cleanEnvValue(value).toLowerCase(); + return ( + !normalized || + normalized.includes("replace-with") || + normalized.includes("xxxxxxxx") || + normalized === "changeme" + ); +} + +export function parseTextContent(content, keys = ["text", "content"]) { + if (typeof content !== "string") return ""; + try { + const parsed = JSON.parse(content); + for (const key of keys) { + if (typeof parsed?.[key] === "string") return parsed[key]; + } + } catch { + return content; + } + return content; +} + +export function stripGroupPrefix(text, { chatType, requirePrefix, prefix, directChatTypes = [] }) { + const trimmed = String(text || "").trim(); + if (!trimmed) return { accepted: false, text: "" }; + if (!requirePrefix || directChatTypes.includes(chatType)) { + return { accepted: true, text: trimmed }; + } + const marker = prefix || "/ds"; + if (trimmed === marker) return { accepted: true, text: "/help" }; + if (trimmed.startsWith(`${marker} `)) { + return { accepted: true, text: trimmed.slice(marker.length).trim() }; + } + return { accepted: false, text: "" }; +} + +export function parseCommand(text, options = {}) { + const trimmed = String(text || "").trim(); + if (!trimmed.startsWith("/")) return { name: "prompt", args: trimmed }; + const [head, ...rest] = trimmed.split(/\s+/); + const rawName = head.slice(1); + const name = (options.stripBotMention ? rawName.split("@")[0] : rawName).toLowerCase(); + return { + name, + args: rest.join(" ").trim() + }; +} + +export function parseApprovalDecisionArgs(args) { + const parts = String(args || "") + .split(/\s+/) + .filter(Boolean); + return { + approvalId: parts[0] || "", + remember: parts.slice(1).includes("remember") + }; +} + +export function commandAction(command, options = {}) { + const allowMenu = options.allowMenu === true; + const allowStart = options.allowStart === true; + switch (command.name) { + case "start": + if (allowStart) return { kind: "help" }; + break; + case "help": + return { kind: "help" }; + case "menu": + if (allowMenu) return { kind: "menu" }; + break; + case "status": + return { kind: "status" }; + case "threads": + return { kind: "threads" }; + case "new": + return { kind: "new_thread" }; + case "resume": + return { kind: "resume", threadId: command.args }; + case "interrupt": + return { kind: "interrupt" }; + case "compact": + return { kind: "compact" }; + case "model": + return { kind: "set_model", modelName: command.args }; + case "allow": + return { kind: "approval", decision: "allow", ...parseApprovalDecisionArgs(command.args) }; + case "deny": + return { kind: "approval", decision: "deny", ...parseApprovalDecisionArgs(command.args) }; + case "prompt": + return { kind: "prompt", prompt: command.args }; + default: + break; + } + return { + kind: "prompt", + prompt: `/${command.name}${command.args ? ` ${command.args}` : ""}` + }; +} + +export function preservedChatStateFields(state = {}, fields = ["model"]) { + const preserved = {}; + for (const field of fields) { + if (Object.prototype.hasOwnProperty.call(state || {}, field)) { + preserved[field] = state[field] || null; + } + } + return preserved; +} + +export function splitMessage(text, maxChars = 3500) { + const value = String(text || ""); + const chars = Array.from(value); + if (chars.length <= maxChars) return value ? [value] : []; + const chunks = []; + let cursor = 0; + while (cursor < chars.length) { + chunks.push(chars.slice(cursor, cursor + maxChars).join("")); + cursor += maxChars; + } + return chunks; +} + +export function compactRuntimeError(status, body) { + const message = + body?.error?.message || + body?.message || + (typeof body === "string" ? body : JSON.stringify(body)); + return `Runtime API request failed (${status}): ${message}`; +} + +export function latestRunningTurn(detail) { + const turns = Array.isArray(detail?.turns) ? detail.turns : []; + for (let index = turns.length - 1; index >= 0; index -= 1) { + const turn = turns[index]; + if (["queued", "in_progress"].includes(turn?.status)) return turn; + } + return null; +} + +export function activeTurnBlock(detail, state = {}) { + const runningTurn = latestRunningTurn(detail); + if (!runningTurn) return null; + return { + turnId: runningTurn.id || state.activeTurnId || "", + message: `Thread already has active turn ${ + runningTurn.id || state.activeTurnId || "(unknown)" + }. Wait for it to finish or send /interrupt.` + }; +} diff --git a/integrations/bridge-core/test/lib.test.mjs b/integrations/bridge-core/test/lib.test.mjs new file mode 100644 index 000000000..9077af06e --- /dev/null +++ b/integrations/bridge-core/test/lib.test.mjs @@ -0,0 +1,117 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + activeTurnBlock, + commandAction, + envFirst, + parseBool, + parseCommand, + parseEnvText, + parseList, + parseTextContent, + preservedChatStateFields, + splitMessage, + stripGroupPrefix, + ThreadStore +} from "../src/lib.mjs"; + +test("env and primitive parsers handle bridge env conventions", () => { + assert.equal(envFirst({ A: "", B: " value " }, "A", "B"), "value"); + assert.deepEqual(parseList(" a, b ,, "), ["a", "b"]); + assert.equal(parseBool("yes"), true); + assert.equal(parseBool("0", true), false); + assert.deepEqual(parseEnvText("export A='one'\nB=\"two\"\n# nope"), { A: "one", B: "two" }); +}); + +test("parseTextContent supports plain text and JSON text/content wrappers", () => { + assert.equal(parseTextContent("hello"), "hello"); + assert.equal(parseTextContent(JSON.stringify({ text: "hello" })), "hello"); + assert.equal(parseTextContent(JSON.stringify({ content: "hello" })), "hello"); +}); + +test("stripGroupPrefix supports direct chat types and prefixed group text", () => { + assert.deepEqual( + stripGroupPrefix("inspect", { + chatType: "private", + requirePrefix: true, + prefix: "/cw", + directChatTypes: ["private"] + }), + { accepted: true, text: "inspect" } + ); + assert.deepEqual( + stripGroupPrefix("/cw inspect", { + chatType: "group", + requirePrefix: true, + prefix: "/cw", + directChatTypes: ["private"] + }), + { accepted: true, text: "inspect" } + ); +}); + +test("commands map common actions while menu/start stay opt in", () => { + assert.deepEqual(parseCommand("/allow@CodeWhaleBot ap_1 remember", { stripBotMention: true }), { + name: "allow", + args: "ap_1 remember" + }); + assert.deepEqual(parseCommand("/allow@CodeWhaleBot ap_1 remember"), { + name: "allow@codewhalebot", + args: "ap_1 remember" + }); + assert.deepEqual(commandAction(parseCommand("/status")), { kind: "status" }); + assert.deepEqual(commandAction(parseCommand("/menu")), { kind: "prompt", prompt: "/menu" }); + assert.deepEqual(commandAction(parseCommand("/menu"), { allowMenu: true }), { kind: "menu" }); + assert.deepEqual(commandAction(parseCommand("/start"), { allowStart: true }), { kind: "help" }); +}); + +test("state/message/runtime helpers preserve bridge behavior", () => { + assert.deepEqual( + preservedChatStateFields({ model: "m", replyToMessageId: "r", ignored: true }, [ + "model", + "replyToMessageId" + ]), + { model: "m", replyToMessageId: "r" } + ); + assert.deepEqual(splitMessage("a🧪b", 2), ["a🧪", "b"]); + assert.deepEqual(activeTurnBlock({ turns: [{ id: "t1", status: "queued" }] }), { + turnId: "t1", + message: "Thread already has active turn t1. Wait for it to finish or send /interrupt." + }); +}); + +test("ThreadStore supports chat state, message dedupe, and action tokens", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "codewhale-bridge-core-")); + try { + const statePath = path.join(dir, "thread-map.json"); + const store = await ThreadStore.open(statePath, { + messageLimit: 2, + actions: true, + actionLimit: 2 + }); + + await store.setChat("chat-a", { threadId: "thread-a" }); + assert.equal((await store.getChat("chat-a")).threadId, "thread-a"); + + assert.equal(await store.recordMessage("m1"), false); + assert.equal(await store.recordMessage("m1"), true); + assert.equal(await store.recordMessage("m2"), false); + assert.equal(await store.recordMessage("m3"), false); + assert.deepEqual(store.data.messages, ["m2", "m3"]); + + const token = await store.putAction({ kind: "resume", threadId: "thread-a" }); + assert.equal((await store.getAction(token)).kind, "resume"); + assert.equal((await store.takeAction(token)).threadId, "thread-a"); + assert.equal(await store.getAction(token), null); + + const saved = await ThreadStore.open(statePath, { messageLimit: 2, actions: true }); + assert.equal((await saved.getChat("chat-a")).threadId, "thread-a"); + assert.deepEqual(saved.data.messages, ["m2", "m3"]); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/integrations/feishu-bridge/src/index.mjs b/integrations/feishu-bridge/src/index.mjs index d0895f2e5..1bbc55e69 100644 --- a/integrations/feishu-bridge/src/index.mjs +++ b/integrations/feishu-bridge/src/index.mjs @@ -1,5 +1,3 @@ -import fs from "node:fs/promises"; -import path from "node:path"; import * as Lark from "@larksuiteoapi/node-sdk"; import { @@ -20,67 +18,11 @@ import { splitMessage, stripGroupPrefix } from "./lib.mjs"; +import { ThreadStore as CoreThreadStore } from "../../bridge-core/src/lib.mjs"; -class ThreadStore { - static async open(filePath) { - const store = new ThreadStore(filePath); - await store.load(); - return store; - } - +class ThreadStore extends CoreThreadStore { constructor(filePath) { - this.filePath = filePath; - this.data = { chats: {} }; - } - - async load() { - try { - const raw = await fs.readFile(this.filePath, "utf8"); - this.data = JSON.parse(raw); - if (!this.data.chats) this.data.chats = {}; - if (!this.data.messages) this.data.messages = []; - } catch (error) { - if (error.code !== "ENOENT") throw error; - } - } - - async recordMessage(messageId) { - if (!messageId) return false; - if (!Array.isArray(this.data.messages)) this.data.messages = []; - if (this.data.messages.includes(messageId)) return true; - this.data.messages.push(messageId); - this.data.messages = this.data.messages.slice(-200); - await this.save(); - return false; - } - - async getChat(chatId) { - return this.data.chats[chatId] || null; - } - - listChats() { - return Object.entries(this.data.chats || {}); - } - - async setChat(chatId, state) { - this.data.chats[chatId] = state; - await this.save(); - return state; - } - - async patchChat(chatId, patch) { - const current = this.data.chats[chatId] || {}; - this.data.chats[chatId] = { ...current, ...patch }; - await this.save(); - return this.data.chats[chatId]; - } - - async save() { - const dir = path.dirname(this.filePath); - await fs.mkdir(dir, { recursive: true, mode: 0o700 }); - const tmp = `${this.filePath}.tmp`; - await fs.writeFile(tmp, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600 }); - await fs.rename(tmp, this.filePath); + super(filePath, { messageLimit: 200 }); } } diff --git a/integrations/feishu-bridge/src/lib.mjs b/integrations/feishu-bridge/src/lib.mjs index 217b6beb2..2c24c7f33 100644 --- a/integrations/feishu-bridge/src/lib.mjs +++ b/integrations/feishu-bridge/src/lib.mjs @@ -1,60 +1,37 @@ -export function parseList(raw) { - return String(raw || "") - .split(",") - .map((item) => item.trim()) - .filter(Boolean); -} - -export function parseBool(raw, fallback = false) { - if (raw == null || raw === "") return fallback; - return ["1", "true", "yes", "on"].includes(String(raw).trim().toLowerCase()); -} - -export function parseEnvText(raw) { - const env = {}; - for (const line of String(raw || "").split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const normalized = trimmed.startsWith("export ") ? trimmed.slice(7).trim() : trimmed; - const index = normalized.indexOf("="); - if (index <= 0) continue; - const key = normalized.slice(0, index).trim(); - let value = normalized.slice(index + 1).trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - env[key] = value; - } - return env; -} - -export function cleanEnvValue(value) { - return String(value ?? "").trim(); -} - -export function isPlaceholderValue(value) { - const normalized = cleanEnvValue(value).toLowerCase(); - return ( - !normalized || - normalized.includes("replace-with") || - normalized.includes("xxxxxxxx") || - normalized === "changeme" - ); -} +import { + activeTurnBlock, + cleanEnvValue, + commandAction as coreCommandAction, + compactRuntimeError, + isPlaceholderValue, + latestRunningTurn, + parseApprovalDecisionArgs, + parseBool, + parseCommand, + parseEnvText, + parseList, + parseTextContent as coreParseTextContent, + preservedChatStateFields as corePreservedChatStateFields, + splitMessage, + stripGroupPrefix as coreStripGroupPrefix +} from "../../bridge-core/src/lib.mjs"; + +export { + activeTurnBlock, + cleanEnvValue, + compactRuntimeError, + isPlaceholderValue, + latestRunningTurn, + parseApprovalDecisionArgs, + parseBool, + parseCommand, + parseEnvText, + parseList, + splitMessage +}; export function parseTextContent(content) { - if (typeof content !== "string") return ""; - try { - const parsed = JSON.parse(content); - if (typeof parsed.text === "string") return parsed.text; - if (typeof parsed.content === "string") return parsed.content; - } catch { - return content; - } - return content; + return coreParseTextContent(content, ["text", "content"]); } export function incomingIdentity(event) { @@ -98,124 +75,20 @@ export function pairingRefusalText(identity) { } export function stripGroupPrefix(text, { chatType, requirePrefix, prefix }) { - const trimmed = String(text || "").trim(); - if (!trimmed) return { accepted: false, text: "" }; - if (!requirePrefix || chatType === "p2p") { - return { accepted: true, text: trimmed }; - } - const marker = prefix || "/ds"; - if (trimmed === marker) return { accepted: true, text: "/help" }; - if (trimmed.startsWith(`${marker} `)) { - return { accepted: true, text: trimmed.slice(marker.length).trim() }; - } - return { accepted: false, text: "" }; -} - -export function parseCommand(text) { - const trimmed = String(text || "").trim(); - if (!trimmed.startsWith("/")) return { name: "prompt", args: trimmed }; - const [head, ...rest] = trimmed.split(/\s+/); - return { - name: head.slice(1).toLowerCase(), - args: rest.join(" ").trim() - }; -} - -export function parseApprovalDecisionArgs(args) { - const parts = String(args || "") - .split(/\s+/) - .filter(Boolean); - return { - approvalId: parts[0] || "", - remember: parts.slice(1).includes("remember") - }; + return coreStripGroupPrefix(text, { + chatType, + requirePrefix, + prefix: prefix || "/ds", + directChatTypes: ["p2p"] + }); } export function commandAction(command) { - switch (command.name) { - case "help": - return { kind: "help" }; - case "status": - return { kind: "status" }; - case "threads": - return { kind: "threads" }; - case "new": - return { kind: "new_thread" }; - case "resume": - return { kind: "resume", threadId: command.args }; - case "interrupt": - return { kind: "interrupt" }; - case "compact": - return { kind: "compact" }; - case "model": - // /model — switch per-chat default model. - // Stored in thread store and used for future threads/turns. - // Pass "default" to reset to the bridge-level default. - return { kind: "set_model", modelName: command.args }; - case "allow": - return { kind: "approval", decision: "allow", ...parseApprovalDecisionArgs(command.args) }; - case "deny": - return { kind: "approval", decision: "deny", ...parseApprovalDecisionArgs(command.args) }; - case "prompt": - return { kind: "prompt", prompt: command.args }; - default: - return { - kind: "prompt", - prompt: `/${command.name}${command.args ? ` ${command.args}` : ""}` - }; - } + return coreCommandAction(command); } export function preservedChatStateFields(state = {}) { - const preserved = {}; - if (Object.prototype.hasOwnProperty.call(state || {}, "model")) { - preserved.model = state.model || null; - } - if (state?.replyToMessageId) { - preserved.replyToMessageId = state.replyToMessageId; - } - return preserved; -} - -export function splitMessage(text, maxChars = 3500) { - const value = String(text || ""); - const chars = Array.from(value); - if (chars.length <= maxChars) return value ? [value] : []; - const chunks = []; - let cursor = 0; - while (cursor < chars.length) { - chunks.push(chars.slice(cursor, cursor + maxChars).join("")); - cursor += maxChars; - } - return chunks; -} - -export function compactRuntimeError(status, body) { - const message = - body?.error?.message || - body?.message || - (typeof body === "string" ? body : JSON.stringify(body)); - return `Runtime API request failed (${status}): ${message}`; -} - -export function latestRunningTurn(detail) { - const turns = Array.isArray(detail?.turns) ? detail.turns : []; - for (let index = turns.length - 1; index >= 0; index -= 1) { - const turn = turns[index]; - if (["queued", "in_progress"].includes(turn?.status)) return turn; - } - return null; -} - -export function activeTurnBlock(detail, state = {}) { - const runningTurn = latestRunningTurn(detail); - if (!runningTurn) return null; - return { - turnId: runningTurn.id || state.activeTurnId || "", - message: `Thread already has active turn ${ - runningTurn.id || state.activeTurnId || "(unknown)" - }. Wait for it to finish or send /interrupt.` - }; + return corePreservedChatStateFields(state, ["model", "replyToMessageId"]); } export function validateBridgeConfig(env, options = {}) { diff --git a/integrations/telegram-bridge/src/index.mjs b/integrations/telegram-bridge/src/index.mjs index 918c524cb..4f0a4018a 100644 --- a/integrations/telegram-bridge/src/index.mjs +++ b/integrations/telegram-bridge/src/index.mjs @@ -1,6 +1,3 @@ -import fs from "node:fs/promises"; -import path from "node:path"; - import { activeTurnBlock, activeTurnKeyboard, @@ -26,104 +23,11 @@ import { telegramIdentity, telegramRetryDelayMs } from "./lib.mjs"; +import { ThreadStore as CoreThreadStore } from "../../bridge-core/src/lib.mjs"; -class ThreadStore { - static async open(filePath) { - const store = new ThreadStore(filePath); - await store.load(); - return store; - } - +class ThreadStore extends CoreThreadStore { constructor(filePath) { - this.filePath = filePath; - this.data = { chats: {}, messages: [], actions: {} }; - } - - async load() { - try { - const raw = await fs.readFile(this.filePath, "utf8"); - this.data = JSON.parse(raw); - if (!this.data.chats) this.data.chats = {}; - if (!Array.isArray(this.data.messages)) this.data.messages = []; - if (!this.data.actions || typeof this.data.actions !== "object") this.data.actions = {}; - } catch (error) { - if (error.code !== "ENOENT") throw error; - } - } - - async recordMessage(messageKey) { - if (!messageKey) return false; - if (!Array.isArray(this.data.messages)) this.data.messages = []; - if (this.data.messages.includes(messageKey)) return true; - this.data.messages.push(messageKey); - this.data.messages = this.data.messages.slice(-500); - await this.save(); - return false; - } - - async getChat(chatId) { - return this.data.chats[chatId] || null; - } - - listChats() { - return Object.entries(this.data.chats || {}); - } - - async setChat(chatId, state) { - this.data.chats[chatId] = state; - await this.save(); - return state; - } - - async patchChat(chatId, patch) { - const current = this.data.chats[chatId] || {}; - this.data.chats[chatId] = { ...current, ...patch }; - await this.save(); - return this.data.chats[chatId]; - } - - async putAction(action) { - if (!this.data.actions || typeof this.data.actions !== "object") this.data.actions = {}; - const token = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; - this.data.actions[token] = { - ...action, - createdAt: new Date().toISOString() - }; - this.pruneActions(); - await this.save(); - return token; - } - - async getAction(token) { - if (!token || !this.data.actions) return null; - return this.data.actions[token] || null; - } - - async takeAction(token) { - const action = await this.getAction(token); - if (action) { - delete this.data.actions[token]; - await this.save(); - } - return action; - } - - pruneActions() { - const entries = Object.entries(this.data.actions || {}); - const cutoff = Date.now() - 24 * 60 * 60 * 1000; - const fresh = entries.filter(([, action]) => { - const time = Date.parse(action.createdAt || ""); - return Number.isFinite(time) && time >= cutoff; - }); - this.data.actions = Object.fromEntries(fresh.slice(-200)); - } - - async save() { - const dir = path.dirname(this.filePath); - await fs.mkdir(dir, { recursive: true, mode: 0o700 }); - const tmp = `${this.filePath}.tmp`; - await fs.writeFile(tmp, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600 }); - await fs.rename(tmp, this.filePath); + super(filePath, { messageLimit: 500, actions: true }); } } diff --git a/integrations/telegram-bridge/src/lib.mjs b/integrations/telegram-bridge/src/lib.mjs index e2f755f39..afaeb70b9 100644 --- a/integrations/telegram-bridge/src/lib.mjs +++ b/integrations/telegram-bridge/src/lib.mjs @@ -1,57 +1,35 @@ -export function envFirst(env, ...names) { - for (const name of names) { - const value = env?.[name]; - if (value != null && String(value).trim()) return String(value).trim(); - } - return ""; -} - -export function parseList(raw) { - return String(raw || "") - .split(",") - .map((item) => item.trim()) - .filter(Boolean); -} - -export function parseBool(raw, fallback = false) { - if (raw == null || raw === "") return fallback; - return ["1", "true", "yes", "on"].includes(String(raw).trim().toLowerCase()); -} - -export function parseEnvText(raw) { - const env = {}; - for (const line of String(raw || "").split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const normalized = trimmed.startsWith("export ") ? trimmed.slice(7).trim() : trimmed; - const index = normalized.indexOf("="); - if (index <= 0) continue; - const key = normalized.slice(0, index).trim(); - let value = normalized.slice(index + 1).trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - env[key] = value; - } - return env; -} - -export function cleanEnvValue(value) { - return String(value ?? "").trim(); -} - -export function isPlaceholderValue(value) { - const normalized = cleanEnvValue(value).toLowerCase(); - return ( - !normalized || - normalized.includes("replace-with") || - normalized.includes("xxxxxxxx") || - normalized === "changeme" - ); -} +import { + activeTurnBlock, + cleanEnvValue, + commandAction as coreCommandAction, + compactRuntimeError, + envFirst, + isPlaceholderValue, + latestRunningTurn, + parseApprovalDecisionArgs, + parseBool, + parseCommand as coreParseCommand, + parseEnvText, + parseList, + preservedChatStateFields, + splitMessage, + stripGroupPrefix as coreStripGroupPrefix +} from "../../bridge-core/src/lib.mjs"; + +export { + activeTurnBlock, + cleanEnvValue, + compactRuntimeError, + envFirst, + isPlaceholderValue, + latestRunningTurn, + parseApprovalDecisionArgs, + parseBool, + parseEnvText, + parseList, + preservedChatStateFields, + splitMessage +}; export function telegramIdentity(update) { const message = update?.message || update?.edited_message || {}; @@ -97,76 +75,20 @@ export function pairingRefusalText(identity) { } export function stripGroupPrefix(text, { chatType, requirePrefix, prefix }) { - const trimmed = String(text || "").trim(); - if (!trimmed) return { accepted: false, text: "" }; - if (!requirePrefix || !isGroupChat(chatType)) { - return { accepted: true, text: trimmed }; - } - const marker = prefix || "/cw"; - if (trimmed === marker) return { accepted: true, text: "/help" }; - if (trimmed.startsWith(`${marker} `)) { - return { accepted: true, text: trimmed.slice(marker.length).trim() }; - } - return { accepted: false, text: "" }; + return coreStripGroupPrefix(text, { + chatType, + requirePrefix, + prefix: prefix || "/cw", + directChatTypes: ["private"] + }); } export function parseCommand(text) { - const trimmed = String(text || "").trim(); - if (!trimmed.startsWith("/")) return { name: "prompt", args: trimmed }; - const [head, ...rest] = trimmed.split(/\s+/); - const name = head - .slice(1) - .split("@")[0] - .toLowerCase(); - return { - name, - args: rest.join(" ").trim() - }; -} - -export function parseApprovalDecisionArgs(args) { - const parts = String(args || "") - .split(/\s+/) - .filter(Boolean); - return { - approvalId: parts[0] || "", - remember: parts.slice(1).includes("remember") - }; + return coreParseCommand(text, { stripBotMention: true }); } export function commandAction(command) { - switch (command.name) { - case "start": - case "help": - return { kind: "help" }; - case "menu": - return { kind: "menu" }; - case "status": - return { kind: "status" }; - case "threads": - return { kind: "threads" }; - case "new": - return { kind: "new_thread" }; - case "resume": - return { kind: "resume", threadId: command.args }; - case "interrupt": - return { kind: "interrupt" }; - case "compact": - return { kind: "compact" }; - case "model": - return { kind: "set_model", modelName: command.args }; - case "allow": - return { kind: "approval", decision: "allow", ...parseApprovalDecisionArgs(command.args) }; - case "deny": - return { kind: "approval", decision: "deny", ...parseApprovalDecisionArgs(command.args) }; - case "prompt": - return { kind: "prompt", prompt: command.args }; - default: - return { - kind: "prompt", - prompt: `/${command.name}${command.args ? ` ${command.args}` : ""}` - }; - } + return coreCommandAction(command, { allowMenu: true, allowStart: true }); } export function controlKeyboard() { @@ -249,55 +171,6 @@ export function callbackAction(data) { return null; } -export function preservedChatStateFields(state = {}) { - const preserved = {}; - if (Object.prototype.hasOwnProperty.call(state || {}, "model")) { - preserved.model = state.model || null; - } - return preserved; -} - -export function splitMessage(text, maxChars = 3500) { - const value = String(text || ""); - const chars = Array.from(value); - if (chars.length <= maxChars) return value ? [value] : []; - const chunks = []; - let cursor = 0; - while (cursor < chars.length) { - chunks.push(chars.slice(cursor, cursor + maxChars).join("")); - cursor += maxChars; - } - return chunks; -} - -export function compactRuntimeError(status, body) { - const message = - body?.error?.message || - body?.message || - (typeof body === "string" ? body : JSON.stringify(body)); - return `Runtime API request failed (${status}): ${message}`; -} - -export function latestRunningTurn(detail) { - const turns = Array.isArray(detail?.turns) ? detail.turns : []; - for (let index = turns.length - 1; index >= 0; index -= 1) { - const turn = turns[index]; - if (["queued", "in_progress"].includes(turn?.status)) return turn; - } - return null; -} - -export function activeTurnBlock(detail, state = {}) { - const runningTurn = latestRunningTurn(detail); - if (!runningTurn) return null; - return { - turnId: runningTurn.id || state.activeTurnId || "", - message: `Thread already has active turn ${ - runningTurn.id || state.activeTurnId || "(unknown)" - }. Wait for it to finish or send /interrupt.` - }; -} - export function telegramRetryDelayMs(error, fallbackMs = 3000) { const retryAfter = Number(error?.parameters?.retry_after || 0); if (Number.isFinite(retryAfter) && retryAfter > 0) { diff --git a/integrations/wecom-bridge/src/lib.mjs b/integrations/wecom-bridge/src/lib.mjs index a020efa6b..f331f09a8 100644 --- a/integrations/wecom-bridge/src/lib.mjs +++ b/integrations/wecom-bridge/src/lib.mjs @@ -1,31 +1,34 @@ -import { readFile, writeFile, mkdir, rename, chmod } from "node:fs/promises"; -import path from "node:path"; - -export function parseList(raw) { - return String(raw || "") - .split(",") - .map((item) => item.trim()) - .filter(Boolean); -} - -export function parseBool(raw, fallback = false) { - if (raw == null || raw === "") return fallback; - return ["1", "true", "yes", "on"].includes(String(raw).trim().toLowerCase()); -} - -export function cleanEnvValue(value) { - return String(value ?? "").trim(); -} - -export function isPlaceholderValue(value) { - const normalized = cleanEnvValue(value).toLowerCase(); - return ( - !normalized || - normalized.includes("replace-with") || - normalized.includes("xxxxxxxx") || - normalized === "changeme" - ); -} +import { + activeTurnBlock, + cleanEnvValue, + commandAction as coreCommandAction, + compactRuntimeError, + isPlaceholderValue, + latestRunningTurn, + parseApprovalDecisionArgs, + parseBool, + parseCommand, + parseList, + parseTextContent as coreParseTextContent, + preservedChatStateFields, + splitMessage, + stripGroupPrefix as coreStripGroupPrefix, + ThreadStore as CoreThreadStore +} from "../../bridge-core/src/lib.mjs"; + +export { + activeTurnBlock, + cleanEnvValue, + compactRuntimeError, + isPlaceholderValue, + latestRunningTurn, + parseApprovalDecisionArgs, + parseBool, + parseCommand, + parseList, + preservedChatStateFields, + splitMessage +}; export function requiredEnv(name) { const value = process.env[name]; @@ -36,14 +39,7 @@ export function requiredEnv(name) { } export function parseTextContent(content) { - if (typeof content !== "string") return ""; - try { - const parsed = JSON.parse(content); - if (typeof parsed.text === "string") return parsed.text; - } catch { - return content; - } - return content; + return coreParseTextContent(content, ["text"]); } export function incomingIdentity(body) { @@ -75,116 +71,16 @@ export function pairingRefusalText(identity) { } export function stripGroupPrefix(text, { chatType, requirePrefix, prefix }) { - const trimmed = String(text || "").trim(); - if (!trimmed) return { accepted: false, text: "" }; - if (!requirePrefix || chatType === "single") { - return { accepted: true, text: trimmed }; - } - const marker = prefix || "/ds"; - if (trimmed === marker) return { accepted: true, text: "/help" }; - if (trimmed.startsWith(`${marker} `)) { - return { accepted: true, text: trimmed.slice(marker.length).trim() }; - } - return { accepted: false, text: "" }; -} - -export function parseCommand(text) { - const trimmed = String(text || "").trim(); - if (!trimmed.startsWith("/")) return { name: "prompt", args: trimmed }; - const [head, ...rest] = trimmed.split(/\s+/); - return { - name: head.slice(1).toLowerCase(), - args: rest.join(" ").trim() - }; -} - -export function parseApprovalDecisionArgs(args) { - const parts = String(args || "") - .split(/\s+/) - .filter(Boolean); - return { - approvalId: parts[0] || "", - remember: parts.slice(1).includes("remember") - }; + return coreStripGroupPrefix(text, { + chatType, + requirePrefix, + prefix: prefix || "/ds", + directChatTypes: ["single"] + }); } export function commandAction(command) { - switch (command.name) { - case "help": - return { kind: "help" }; - case "status": - return { kind: "status" }; - case "threads": - return { kind: "threads" }; - case "new": - return { kind: "new_thread" }; - case "resume": - return { kind: "resume", threadId: command.args }; - case "interrupt": - return { kind: "interrupt" }; - case "compact": - return { kind: "compact" }; - case "model": - return { kind: "set_model", modelName: command.args }; - case "allow": - return { kind: "approval", decision: "allow", ...parseApprovalDecisionArgs(command.args) }; - case "deny": - return { kind: "approval", decision: "deny", ...parseApprovalDecisionArgs(command.args) }; - default: - return { - kind: "prompt", - prompt: `/${command.name}${command.args ? ` ${command.args}` : ""}` - }; - } -} - -export function preservedChatStateFields(state = {}) { - const preserved = {}; - if (Object.prototype.hasOwnProperty.call(state || {}, "model")) { - preserved.model = state.model || null; - } - return preserved; -} - -export function splitMessage(text, maxChars = 3500) { - const value = String(text || ""); - const chars = Array.from(value); - if (chars.length <= maxChars) return value ? [value] : []; - const chunks = []; - let cursor = 0; - while (cursor < chars.length) { - chunks.push(chars.slice(cursor, cursor + maxChars).join("")); - cursor += maxChars; - } - return chunks; -} - -export function compactRuntimeError(status, body) { - const message = - body?.error?.message || - body?.message || - (typeof body === "string" ? body : JSON.stringify(body)); - return `Runtime API request failed (${status}): ${message}`; -} - -export function latestRunningTurn(detail) { - const turns = Array.isArray(detail?.turns) ? detail.turns : []; - for (let index = turns.length - 1; index >= 0; index -= 1) { - const turn = turns[index]; - if (["queued", "in_progress"].includes(turn?.status)) return turn; - } - return null; -} - -export function activeTurnBlock(detail, state = {}) { - const runningTurn = latestRunningTurn(detail); - if (!runningTurn) return null; - return { - turnId: runningTurn.id || state.activeTurnId || "", - message: `Thread already has active turn ${ - runningTurn.id || state.activeTurnId || "(unknown)" - }. Wait for it to finish or send /interrupt.` - }; + return coreCommandAction(command); } export function helpText() { @@ -205,66 +101,9 @@ export function helpText() { ].join("\n"); } -export class ThreadStore { - static async open(filePath) { - const store = new ThreadStore(filePath); - await store.load(); - return store; - } - +export class ThreadStore extends CoreThreadStore { constructor(filePath) { - this.filePath = filePath; - this.data = { chats: {} }; - } - - async load() { - try { - const raw = await readFile(this.filePath, "utf8"); - this.data = JSON.parse(raw); - if (!this.data.chats) this.data.chats = {}; - } catch (error) { - if (error.code !== "ENOENT") throw error; - } - } - - async getChat(chatId) { - return this.data.chats[chatId] || null; - } - - listChats() { - return Object.entries(this.data.chats || {}); - } - - async setChat(chatId, state) { - this.data.chats[chatId] = state; - await this.save(); - return state; - } - - async patchChat(chatId, patch) { - const current = this.data.chats[chatId] || {}; - this.data.chats[chatId] = { ...current, ...patch }; - await this.save(); - return this.data.chats[chatId]; - } - - async save() { - const dir = path.dirname(this.filePath); - await mkdir(dir, { recursive: true, mode: 0o700 }); - await chmodBestEffort(dir, 0o700); - const tmp = `${this.filePath}.tmp`; - await writeFile(tmp, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600 }); - await chmodBestEffort(tmp, 0o600); - await rename(tmp, this.filePath); - await chmodBestEffort(this.filePath, 0o600); - } -} - -async function chmodBestEffort(filePath, mode) { - try { - await chmod(filePath, mode); - } catch (error) { - if (process.platform !== "win32") throw error; + super(filePath, { privateMode: true }); } } diff --git a/integrations/weixin-bridge/src/index.mjs b/integrations/weixin-bridge/src/index.mjs index 0759d2be0..1656c7957 100644 --- a/integrations/weixin-bridge/src/index.mjs +++ b/integrations/weixin-bridge/src/index.mjs @@ -24,69 +24,15 @@ import { activeTurnBlock, helpText, } from "./lib.mjs"; +import { ThreadStore as CoreThreadStore } from "../../bridge-core/src/lib.mjs"; // ============================================================================ // ThreadStore — JSON 文件持久化(与 feishu/telegram/wechat bridge 一致) // ============================================================================ -class ThreadStore { - static async open(filePath) { - const store = new ThreadStore(filePath); - await store.load(); - return store; - } - +class ThreadStore extends CoreThreadStore { constructor(filePath) { - this.filePath = filePath; - this.data = { chats: {}, messages: [] }; - } - - async load() { - try { - const raw = await fs.readFile(this.filePath, "utf8"); - this.data = JSON.parse(raw); - if (!this.data.chats) this.data.chats = {}; - if (!Array.isArray(this.data.messages)) this.data.messages = []; - } catch (error) { - if (error.code !== "ENOENT") throw error; - } - } - - async recordMessage(messageKey) { - if (!messageKey) return false; - if (!Array.isArray(this.data.messages)) this.data.messages = []; - if (this.data.messages.includes(messageKey)) return true; - this.data.messages.push(messageKey); - this.data.messages = this.data.messages.slice(-500); - await this.save(); - return false; - } - - async getChat(chatId) { - return this.data.chats[chatId] || null; - } - - async setChat(chatId, state) { - this.data.chats[chatId] = state; - await this.save(); - return state; - } - - async patchChat(chatId, patch) { - const current = this.data.chats[chatId] || {}; - this.data.chats[chatId] = { ...current, ...patch }; - await this.save(); - return this.data.chats[chatId]; - } - - async save() { - const dir = path.dirname(this.filePath); - await fs.mkdir(dir, { recursive: true, mode: 0o700 }); - const tmp = `${this.filePath}.tmp`; - await fs.writeFile(tmp, `${JSON.stringify(this.data, null, 2)}\n`, { - mode: 0o600, - }); - await fs.rename(tmp, this.filePath); + super(filePath, { messageLimit: 500 }); } } diff --git a/integrations/weixin-bridge/src/lib.mjs b/integrations/weixin-bridge/src/lib.mjs index c9fb7bbb2..47aba4403 100644 --- a/integrations/weixin-bridge/src/lib.mjs +++ b/integrations/weixin-bridge/src/lib.mjs @@ -1,5 +1,19 @@ import crypto from "node:crypto"; +import { + activeTurnBlock, + commandAction, + compactRuntimeError, + envFirst, + latestRunningTurn, + parseApprovalDecisionArgs, + parseBool, + parseCommand, + parseList, + preservedChatStateFields, + splitMessage, +} from "../../bridge-core/src/lib.mjs"; + // ============================================================================ // iLink Bot API 协议层 — 参考 @tencent-weixin/openclaw-weixin // ============================================================================ @@ -7,29 +21,19 @@ import crypto from "node:crypto"; const DEFAULT_API_TIMEOUT_MS = 30_000; const LONGPOLL_DEFAULT_TIMEOUT_MS = 35_000; -// --------------------------------------------------------------------------- -// 通用工具 -// --------------------------------------------------------------------------- - -export function parseList(raw) { - return String(raw || "") - .split(",") - .map((item) => item.trim()) - .filter(Boolean); -} - -export function parseBool(raw, fallback = false) { - if (raw == null || raw === "") return fallback; - return ["1", "true", "yes", "on"].includes(String(raw).trim().toLowerCase()); -} - -export function envFirst(env, ...names) { - for (const name of names) { - const value = env?.[name]; - if (value != null && String(value).trim()) return String(value).trim(); - } - return ""; -} +export { + activeTurnBlock, + commandAction, + compactRuntimeError, + envFirst, + latestRunningTurn, + parseApprovalDecisionArgs, + parseBool, + parseCommand, + parseList, + preservedChatStateFields, + splitMessage, +}; export function randomUin() { const uint32 = crypto.randomBytes(4).readUInt32BE(0); @@ -74,125 +78,6 @@ export function extractText(itemList) { return ""; } -// --------------------------------------------------------------------------- -// 命令解析(与 feishu/telegram/wechat bridge 一致) -// --------------------------------------------------------------------------- - -export function parseCommand(text) { - const trimmed = String(text || "").trim(); - if (!trimmed.startsWith("/")) return { name: "prompt", args: trimmed }; - const [head, ...rest] = trimmed.split(/\s+/); - return { - name: head.slice(1).toLowerCase(), - args: rest.join(" ").trim(), - }; -} - -export function parseApprovalDecisionArgs(args) { - const parts = String(args || "").split(/\s+/).filter(Boolean); - return { - approvalId: parts[0] || "", - remember: parts.slice(1).includes("remember"), - }; -} - -export function commandAction(command) { - switch (command.name) { - case "help": - return { kind: "help" }; - case "status": - return { kind: "status" }; - case "threads": - return { kind: "threads" }; - case "new": - return { kind: "new_thread" }; - case "resume": - return { kind: "resume", threadId: command.args }; - case "model": - return { kind: "set_model", modelName: command.args }; - case "interrupt": - return { kind: "interrupt" }; - case "compact": - return { kind: "compact" }; - case "allow": - return { - kind: "approval", - decision: "allow", - ...parseApprovalDecisionArgs(command.args), - }; - case "deny": - return { - kind: "approval", - decision: "deny", - ...parseApprovalDecisionArgs(command.args), - }; - case "prompt": - return { kind: "prompt", prompt: command.args }; - default: - return { - kind: "prompt", - prompt: `/${command.name}${command.args ? ` ${command.args}` : ""}`, - }; - } -} - -export function preservedChatStateFields(state = {}) { - const preserved = {}; - if (Object.prototype.hasOwnProperty.call(state || {}, "model")) { - preserved.model = state.model || null; - } - return preserved; -} - -// --------------------------------------------------------------------------- -// 消息拆分(微信 iLink 单条消息无明确上限,保守 3500 字符) -// --------------------------------------------------------------------------- - -export function splitMessage(text, maxChars = 3500) { - const value = String(text || ""); - const chars = Array.from(value); - if (chars.length <= maxChars) return value ? [value] : []; - const chunks = []; - let cursor = 0; - while (cursor < chars.length) { - chunks.push(chars.slice(cursor, cursor + maxChars).join("")); - cursor += maxChars; - } - return chunks; -} - -// --------------------------------------------------------------------------- -// Runtime 工具 -// --------------------------------------------------------------------------- - -export function compactRuntimeError(status, body) { - const message = - body?.error?.message || - body?.message || - (typeof body === "string" ? body : JSON.stringify(body)); - return `Runtime API request failed (${status}): ${message}`; -} - -export function latestRunningTurn(detail) { - const turns = Array.isArray(detail?.turns) ? detail.turns : []; - for (let index = turns.length - 1; index >= 0; index -= 1) { - const turn = turns[index]; - if (["queued", "in_progress"].includes(turn?.status)) return turn; - } - return null; -} - -export function activeTurnBlock(detail, state = {}) { - const runningTurn = latestRunningTurn(detail); - if (!runningTurn) return null; - return { - turnId: runningTurn.id || state.activeTurnId || "", - message: `Thread already has active turn ${ - runningTurn.id || state.activeTurnId || "(unknown)" - }. Wait for it to finish or send /interrupt.`, - }; -} - // --------------------------------------------------------------------------- // 帮助文本 // --------------------------------------------------------------------------- diff --git a/integrations/weixin-bridge/test/lib.test.mjs b/integrations/weixin-bridge/test/lib.test.mjs new file mode 100644 index 000000000..0bc30449b --- /dev/null +++ b/integrations/weixin-bridge/test/lib.test.mjs @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + activeTurnBlock, + commandAction, + extractText, + MessageItemType, + parseBool, + parseCommand, + parseList, + preservedChatStateFields, + splitMessage +} from "../src/lib.mjs"; + +test("extractText reads text and voice transcript items", () => { + assert.equal( + extractText([{ type: MessageItemType.TEXT, text_item: { text: "hello" } }]), + "hello" + ); + assert.equal( + extractText([{ type: MessageItemType.VOICE, voice_item: { text: "voice text" } }]), + "voice text" + ); +}); + +test("shared command helpers preserve Weixin bridge command behavior", () => { + assert.deepEqual(parseList("u1, u2 ,, "), ["u1", "u2"]); + assert.equal(parseBool("yes"), true); + assert.deepEqual(parseCommand("/allow ap_1 remember"), { + name: "allow", + args: "ap_1 remember" + }); + assert.deepEqual(commandAction(parseCommand("/model auto")), { + kind: "set_model", + modelName: "auto" + }); + assert.deepEqual(commandAction(parseCommand("/unknown value")), { + kind: "prompt", + prompt: "/unknown value" + }); +}); + +test("shared state and runtime helpers preserve Weixin bridge behavior", () => { + assert.deepEqual(preservedChatStateFields({ model: "m", activeTurnId: "turn-1" }), { + model: "m" + }); + assert.deepEqual(splitMessage("a🧪b", 2), ["a🧪", "b"]); + assert.deepEqual(activeTurnBlock({ turns: [{ id: "turn-1", status: "in_progress" }] }), { + turnId: "turn-1", + message: "Thread already has active turn turn-1. Wait for it to finish or send /interrupt." + }); +});