diff --git a/channels/README.md b/channels/README.md index 3b31ecc..182158a 100644 --- a/channels/README.md +++ b/channels/README.md @@ -255,6 +255,51 @@ Error: `claude CLI exit code 1` --- +## Weixin Channel + +Experimental text-only Weixin channel backed by a Node sidecar bridge. + +### Current MVP Scope + +- Receive text messages and create tasks +- Reply to task result messages to resume a saved session +- Send task completion/failure text notifications back to the same peer +- Start QR-code login automatically when there is no saved session +- Reuse `/dir` and `/agent` command handling + +### 1. Enable the Channel + +```bash +curl -X POST http://127.0.0.1:9712/api/channels/settings \ + -H 'Content-Type: application/json' \ + -d '{ + "weixin_enabled": "true", + "weixin_default_working_dir": "/path/to/your/project", + "weixin_base_url": "https://ilinkai.weixin.qq.com", + "weixin_account_id": "" + }' +``` + +### 2. Start AgentForge + +```bash +uv run taskboard.py +``` + +If enabled, AgentForge will attempt to launch the bridge process. On a first-time login it will emit a QR event and keep polling until you confirm on your phone: + +```text +[Weixin] Bridge started +``` + +### Notes + +- This is a text-only MVP. +- The bridge implements the QR login flow and the documented `getupdates` / `sendmessage` HTTP protocol directly. +- The first version is single-account oriented from the AgentForge side, even though the upstream Weixin plugin supports multiple accounts. + +--- + ## Adding New Channels Create a new file under `channels/` that imports and subclasses `Channel` from diff --git a/channels/weixin_bridge/index.mjs b/channels/weixin_bridge/index.mjs new file mode 100644 index 0000000..edb78be --- /dev/null +++ b/channels/weixin_bridge/index.mjs @@ -0,0 +1,500 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import readline from "node:readline"; + +const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com"; +const BOT_TYPE = process.env.AGENTFORGE_WEIXIN_BOT_TYPE || "3"; +const DATA_DIR = process.env.AGENTFORGE_WEIXIN_DATA_DIR || path.join(process.env.HOME || ".", ".agentforge", "weixin"); +const ACCOUNT_FILE = path.join(DATA_DIR, "account.json"); +const AUTO_LOGIN = (process.env.AGENTFORGE_WEIXIN_AUTO_LOGIN || "true") !== "false"; +const ACCOUNT_ID_OVERRIDE = process.env.AGENTFORGE_WEIXIN_ACCOUNT_ID || ""; +const CHANNEL_VERSION = "agentforge-weixin-bridge/0.2.0"; + +let shuttingDown = false; +let loginInFlight = null; +let pollerStarted = false; +let pollTimer = null; +let state = loadState(); +const pendingSentMessages = new Map(); + +function emit(event) { + process.stdout.write(`${JSON.stringify(event)}\n`); +} + +function log(message) { + process.stderr.write(`[WeixinBridge] ${message}\n`); +} + +function ensureDataDir() { + fs.mkdirSync(DATA_DIR, { recursive: true }); +} + +function loadState() { + try { + if (!fs.existsSync(ACCOUNT_FILE)) { + return { + accountId: ACCOUNT_ID_OVERRIDE, + baseUrl: process.env.AGENTFORGE_WEIXIN_BASE_URL || DEFAULT_BASE_URL, + token: "", + userId: "", + syncCursor: "", + }; + } + const parsed = JSON.parse(fs.readFileSync(ACCOUNT_FILE, "utf8")); + return { + accountId: ACCOUNT_ID_OVERRIDE || parsed.accountId || "", + baseUrl: parsed.baseUrl || process.env.AGENTFORGE_WEIXIN_BASE_URL || DEFAULT_BASE_URL, + token: parsed.token || "", + userId: parsed.userId || "", + syncCursor: parsed.syncCursor || "", + }; + } catch (error) { + log(`failed to load state: ${String(error)}`); + return { + accountId: ACCOUNT_ID_OVERRIDE, + baseUrl: process.env.AGENTFORGE_WEIXIN_BASE_URL || DEFAULT_BASE_URL, + token: "", + userId: "", + syncCursor: "", + }; + } +} + +function saveState() { + ensureDataDir(); + fs.writeFileSync( + ACCOUNT_FILE, + JSON.stringify( + { + accountId: state.accountId, + baseUrl: state.baseUrl, + token: state.token, + userId: state.userId, + syncCursor: state.syncCursor, + }, + null, + 2, + ), + "utf8", + ); +} + +function clearSession() { + state = { + ...state, + token: "", + syncCursor: "", + }; + saveState(); +} + +function ensureTrailingSlash(url) { + return url.endsWith("/") ? url : `${url}/`; +} + +function randomWechatUin() { + const uint32 = crypto.randomBytes(4).readUInt32BE(0); + return Buffer.from(String(uint32), "utf8").toString("base64"); +} + +function buildHeaders(body, token) { + const headers = { + "Content-Type": "application/json", + AuthorizationType: "ilink_bot_token", + "Content-Length": String(Buffer.byteLength(body, "utf8")), + "X-WECHAT-UIN": randomWechatUin(), + }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + return headers; +} + +async function postJson(endpoint, payload, token, timeoutMs = 15000) { + const body = JSON.stringify({ ...payload, base_info: { channel_version: CHANNEL_VERSION } }); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(new URL(endpoint, ensureTrailingSlash(state.baseUrl)), { + method: "POST", + headers: buildHeaders(body, token), + body, + signal: controller.signal, + }); + const raw = await response.text(); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}: ${raw}`); + } + return raw ? JSON.parse(raw) : {}; + } finally { + clearTimeout(timeout); + } +} + +async function fetchQrCode() { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15000); + try { + const url = new URL(`ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(BOT_TYPE)}`, ensureTrailingSlash(state.baseUrl)); + const response = await fetch(url, { signal: controller.signal }); + const raw = await response.text(); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}: ${raw}`); + } + return JSON.parse(raw); + } finally { + clearTimeout(timeout); + } +} + +async function pollQrStatus(qrcode) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 35000); + try { + const url = new URL(`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, ensureTrailingSlash(state.baseUrl)); + const response = await fetch(url, { + headers: { "iLink-App-ClientVersion": "1" }, + signal: controller.signal, + }); + const raw = await response.text(); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}: ${raw}`); + } + return JSON.parse(raw); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + return { status: "wait" }; + } + throw error; + } finally { + clearTimeout(timeout); + } +} + +function extractText(itemList = []) { + const parts = []; + for (const item of itemList) { + if (item?.type === 1 && item.text_item?.text) { + parts.push(String(item.text_item.text)); + } else if (item?.type === 3 && item.voice_item?.text) { + parts.push(String(item.voice_item.text)); + } + } + return parts.join("\n").trim(); +} + +function extractReplyToMessageId(itemList = []) { + for (const item of itemList) { + const refMessageId = item?.ref_msg?.message_item?.msg_id; + if (refMessageId) { + return String(refMessageId); + } + } + return ""; +} + +function extractReplyReference(itemList = []) { + for (const item of itemList) { + const ref = item?.ref_msg; + if (!ref) { + continue; + } + return { + messageId: ref?.message_item?.msg_id ? String(ref.message_item.msg_id) : "", + title: ref?.title ? String(ref.title) : "", + text: ref?.message_item ? extractText([ref.message_item]) : "", + }; + } + return { messageId: "", title: "", text: "" }; +} + +function extractQuotedMessageId(msg) { + for (const item of msg?.item_list || []) { + if (item?.msg_id) { + return String(item.msg_id); + } + } + if (msg?.message_id != null) { + return String(msg.message_id); + } + return ""; +} + +function maybeEmitSentConfirmation(msg) { + const clientId = String(msg?.client_id || ""); + if (!clientId) { + return; + } + const pending = pendingSentMessages.get(clientId); + if (!pending) { + return; + } + const quotedMessageId = extractQuotedMessageId(msg); + if (!quotedMessageId) { + return; + } + pendingSentMessages.delete(clientId); + emit({ + type: "sent", + request_id: pending.requestId, + message_id: clientId, + quoted_message_id: quotedMessageId, + peer_id: pending.peerId, + }); +} + +function normalizeInboundMessage(msg) { + if (msg?.message_type !== 1) { + return null; + } + const peerId = msg.from_user_id || ""; + const text = extractText(msg.item_list || []); + if (!peerId || !text) { + return null; + } + const replyRef = extractReplyReference(msg.item_list || []); + return { + type: "message", + account_id: state.accountId || ACCOUNT_ID_OVERRIDE || "", + peer_id: peerId, + context_token: msg.context_token || "", + message_id: String(msg.message_id || msg.client_id || crypto.randomUUID()), + reply_to_message_id: replyRef.messageId, + reply_to_message_title: replyRef.title, + reply_to_message_text: replyRef.text, + text, + raw_message_type: msg.message_type || 0, + }; +} + +async function sendTextMessage(command) { + if (!state.token) { + throw new Error("weixin account is not logged in"); + } + const messageId = crypto.randomUUID(); + await postJson( + "ilink/bot/sendmessage", + { + msg: { + from_user_id: "", + to_user_id: command.peer_id, + client_id: messageId, + message_type: 2, + message_state: 2, + item_list: [ + { + type: 1, + text_item: { text: command.text || "" }, + }, + ], + context_token: command.context_token || undefined, + }, + }, + state.token, + 15000, + ); + pendingSentMessages.set(messageId, { + requestId: command.request_id || "", + peerId: command.peer_id || "", + }); + emit({ + type: "accepted", + request_id: command.request_id || "", + client_id: messageId, + peer_id: command.peer_id, + }); +} + +async function pollUpdatesOnce() { + if (!state.token || shuttingDown) { + return; + } + + const response = await postJson( + "ilink/bot/getupdates", + { + get_updates_buf: state.syncCursor || "", + }, + state.token, + 40000, + ); + + if (response?.errcode === -14) { + emit({ type: "error", message: "session_expired" }); + clearSession(); + pollerStarted = false; + if (AUTO_LOGIN) { + await ensureLogin(); + } + return; + } + + if (typeof response?.get_updates_buf === "string") { + state.syncCursor = response.get_updates_buf; + saveState(); + } + + for (const msg of response?.msgs || []) { + maybeEmitSentConfirmation(msg); + const normalized = normalizeInboundMessage(msg); + if (normalized) { + emit(normalized); + } + } +} + +async function pollLoop() { + if (pollerStarted) { + return; + } + pollerStarted = true; + emit({ type: "ready", account_id: state.accountId || "" }); + while (!shuttingDown && state.token) { + try { + await pollUpdatesOnce(); + } catch (error) { + emit({ type: "error", message: String(error) }); + await new Promise((resolve) => { + pollTimer = setTimeout(resolve, 2000); + }); + } + } + pollerStarted = false; +} + +async function startPollingIfReady() { + if (state.token && !pollerStarted) { + void pollLoop(); + } +} + +async function loginFlow() { + try { + const qr = await fetchQrCode(); + if (!qr?.qrcode || !qr?.qrcode_img_content) { + throw new Error("QR code response missing qrcode image content"); + } + log( + `qr payload received: len=${String(qr.qrcode_img_content).length} prefix=${String(qr.qrcode_img_content).slice(0, 80)}`, + ); + + emit({ + type: "qr", + qrcode_url: qr.qrcode_img_content, + account_id: state.accountId || ACCOUNT_ID_OVERRIDE || "", + }); + + while (!shuttingDown) { + const status = await pollQrStatus(qr.qrcode); + if (status?.status === "confirmed" && status?.bot_token) { + state = { + ...state, + accountId: ACCOUNT_ID_OVERRIDE || status.ilink_bot_id || state.accountId, + baseUrl: status.baseurl || state.baseUrl, + token: status.bot_token, + userId: status.ilink_user_id || state.userId, + syncCursor: "", + }; + saveState(); + emit({ + type: "login_success", + account_id: state.accountId, + user_id: state.userId, + }); + await startPollingIfReady(); + return; + } + if (status?.status === "expired") { + throw new Error("QR code expired, restart login"); + } + if (status?.status === "scaned") { + emit({ type: "scaned" }); + } + } + } catch (error) { + emit({ type: "error", message: `login_failed: ${String(error)}` }); + throw error; + } finally { + loginInFlight = null; + } +} + +async function ensureLogin() { + if (loginInFlight) { + return loginInFlight; + } + loginInFlight = loginFlow().catch(() => undefined); + return loginInFlight; +} + +async function handleCommand(command) { + if (!command?.type) { + return; + } + + if (command.type === "send_message") { + await sendTextMessage(command); + return; + } + + if (command.type === "login") { + clearSession(); + await ensureLogin(); + return; + } + + if (command.type === "logout") { + clearSession(); + emit({ type: "logged_out" }); + } +} + +ensureDataDir(); + +const rl = readline.createInterface({ + input: process.stdin, + crlfDelay: Infinity, +}); + +rl.on("line", (line) => { + if (!line.trim()) { + return; + } + let command; + try { + command = JSON.parse(line); + } catch { + emit({ type: "error", message: "invalid_json" }); + return; + } + void handleCommand(command).catch((error) => { + emit({ + type: "error", + request_id: command?.request_id || "", + message: String(error), + }); + }); +}); + +process.on("SIGINT", () => { + shuttingDown = true; + if (pollTimer) { + clearTimeout(pollTimer); + } + process.exit(0); +}); + +process.on("SIGTERM", () => { + shuttingDown = true; + if (pollTimer) { + clearTimeout(pollTimer); + } + process.exit(0); +}); + +if (state.token) { + void startPollingIfReady(); +} else if (AUTO_LOGIN) { + void ensureLogin(); +} diff --git a/channels/weixin_bridge/package.json b/channels/weixin_bridge/package.json new file mode 100644 index 0000000..444597f --- /dev/null +++ b/channels/weixin_bridge/package.json @@ -0,0 +1,10 @@ +{ + "name": "agentforge-weixin-bridge", + "private": true, + "type": "module", + "version": "0.1.0", + "description": "Node sidecar bridge for AgentForge Weixin channel", + "engines": { + "node": ">=22" + } +} diff --git a/channels/weixin_channel.py b/channels/weixin_channel.py new file mode 100644 index 0000000..c486d2f --- /dev/null +++ b/channels/weixin_channel.py @@ -0,0 +1,371 @@ +""" +Weixin channel for AgentForge. + +Text-only MVP backed by a Node sidecar bridge that communicates with Python via +newline-delimited JSON over stdio. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import threading +import uuid +from pathlib import Path +from typing import TYPE_CHECKING, Any, Optional + +from taskboard_bus import Channel, MessageBus, OutboundMessage, OutboundMessageType + +if TYPE_CHECKING: + from taskboard import TaskDB, TaskScheduler + + +class WeixinChannel(Channel): + """Weixin integration using a Node bridge process.""" + + def __init__( + self, + bus: MessageBus, + db: "TaskDB", + scheduler: "TaskScheduler", + bridge_cmd: Optional[list[str]] = None, + ): + super().__init__("weixin", bus, db) + self.scheduler = scheduler + self.bridge_cmd = bridge_cmd or self._default_bridge_cmd() + self._bridge_proc: Optional[subprocess.Popen] = None + self._reader_thread: Optional[threading.Thread] = None + + # task_id -> origin metadata used for notifications and resume + self._task_origin: dict[int, dict[str, str]] = {} + self._origin_lock = threading.Lock() + + # notification message_id -> task_id for resume-by-reply + self._notification_map: dict[str, int] = {} + self._notification_lock = threading.Lock() + + # request_id -> task_id for sent acknowledgements from the bridge + self._pending_notifications: dict[str, int] = {} + self._pending_lock = threading.Lock() + + self._status_lock = threading.Lock() + self._status = { + "configured": False, + "login_status": "idle", + "qr_code_url": "", + "last_error": "", + "account_id": "", + "user_id": "", + } + + bus.subscribe_outbound(self._on_outbound) + + def _default_bridge_cmd(self) -> list[str]: + bridge_path = Path(__file__).resolve().parent / "weixin_bridge" / "index.mjs" + return ["node", str(bridge_path)] + + def start(self) -> None: + self._running = True + try: + env = os.environ.copy() + env.setdefault( + "AGENTFORGE_WEIXIN_DATA_DIR", str(Path.home() / ".agentforge" / "weixin") + ) + env.setdefault( + "AGENTFORGE_WEIXIN_BASE_URL", + self.db.get_setting("weixin_base_url", "https://ilinkai.weixin.qq.com"), + ) + env.setdefault( + "AGENTFORGE_WEIXIN_ACCOUNT_ID", + self.db.get_setting("weixin_account_id", ""), + ) + self._bridge_proc = subprocess.Popen( + self.bridge_cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + env=env, + ) + except Exception as exc: + self._running = False + print(f"[Weixin] Failed to start bridge: {exc}") + return + + self._reader_thread = threading.Thread(target=self._read_bridge_events, daemon=True) + self._reader_thread.start() + print("[Weixin] Bridge started") + + def stop(self) -> None: + self._running = False + self.bus.unsubscribe_outbound(self._on_outbound) + if self._bridge_proc and self._bridge_proc.poll() is None: + try: + self._bridge_proc.terminate() + self._bridge_proc.wait(timeout=5) + except Exception: + pass + self._bridge_proc = None + + def send(self, msg: OutboundMessage) -> None: + if not self._running: + return + if msg.type not in (OutboundMessageType.TASK_COMPLETED, OutboundMessageType.TASK_FAILED): + return + + task_id = msg.task_id + with self._origin_lock: + origin = self._task_origin.get(task_id) + if not origin: + print(f"[Weixin] No origin for task #{task_id}, skipping outbound notification") + return + + title = msg.payload.get("title") or f"Task #{task_id}" + if msg.type == OutboundMessageType.TASK_COMPLETED: + body = (msg.payload.get("result") or "").strip() or "Done." + text = f"✅ Task #{task_id} · {title}\n{body}" + else: + body = (msg.payload.get("error") or "Unknown error").strip() + text = f"❌ Task #{task_id} · {title}\n{body}" + + request_id = uuid.uuid4().hex + with self._pending_lock: + self._pending_notifications[request_id] = task_id + self._send_command( + { + "type": "send_message", + "request_id": request_id, + "account_id": origin.get("account_id", ""), + "peer_id": origin["peer_id"], + "context_token": origin.get("context_token", ""), + "reply_to_message_id": origin.get("message_id", ""), + "text": text, + } + ) + + with self._origin_lock: + self._task_origin.pop(task_id, None) + + def _on_outbound(self, msg: OutboundMessage) -> None: + self.send(msg) + + def _read_bridge_events(self) -> None: + if not self._bridge_proc or not self._bridge_proc.stdout: + return + + for line in self._bridge_proc.stdout: + if not self._running: + return + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + print(f"[Weixin] Ignoring non-JSON bridge output: {line}") + continue + self._handle_bridge_event(event) + + def _handle_bridge_event(self, event: dict[str, Any]) -> None: + event_type = event.get("type") + if event_type == "message": + self._handle_message_event(event) + elif event_type == "sent": + self._handle_sent_event(event) + elif event_type == "qr": + qr_value = event.get("qrcode_url", "") or "" + print(f"[Weixin] QR payload len={len(qr_value)} prefix={qr_value[:80]!r}") + self._update_status( + login_status="waiting_for_scan", + qr_code_url=qr_value, + account_id=event.get("account_id", ""), + last_error="", + ) + print("[Weixin] Bridge event: qr") + elif event_type == "scaned": + self._update_status(login_status="scanned", last_error="") + print("[Weixin] Bridge event: scaned") + elif event_type == "login_success": + self._update_status( + configured=True, + login_status="connected", + qr_code_url="", + account_id=event.get("account_id", ""), + user_id=event.get("user_id", ""), + last_error="", + ) + print("[Weixin] Bridge event: login_success") + elif event_type == "ready": + self._update_status( + configured=True, + login_status="connected", + qr_code_url="", + account_id=event.get("account_id", ""), + last_error="", + ) + print("[Weixin] Bridge event: ready") + elif event_type == "error": + self._update_status( + login_status="error", + last_error=event.get("message", "unknown_error"), + ) + print("[Weixin] Bridge event: error") + + def _handle_sent_event(self, event: dict[str, Any]) -> None: + request_id = event.get("request_id") or "" + message_id = event.get("message_id") or "" + quoted_message_id = event.get("quoted_message_id") or "" + if not request_id or (not message_id and not quoted_message_id): + return + with self._pending_lock: + task_id = self._pending_notifications.get(request_id) + if task_id is None: + return + with self._notification_lock: + if message_id: + self._notification_map[message_id] = task_id + if quoted_message_id: + self._notification_map[quoted_message_id] = task_id + if quoted_message_id: + with self._pending_lock: + self._pending_notifications.pop(request_id, None) + + def _handle_message_event(self, event: dict[str, Any]) -> None: + text = (event.get("text") or "").strip() + if not text: + return + + from channels.agent_utils import handle_agent_command, resolve_agent + from channels.dir_utils import handle_dir_command, resolve_working_dir + from taskboard import ScheduleType, Task + + reply_to_message_id = event.get("reply_to_message_id") or "" + reply_to_message_title = event.get("reply_to_message_title") or "" + reply_to_message_text = event.get("reply_to_message_text") or "" + peer_id = event.get("peer_id") or event.get("from_user_id") or "" + account_id = event.get("account_id") or "" + context_token = event.get("context_token") or "" + message_id = event.get("message_id") or "" + + dir_reply = handle_dir_command(text, "weixin", self.db) + if dir_reply is not None: + self._reply_to_event(event, dir_reply) + return + + agent_reply = handle_agent_command(text, "weixin", self.db) + if agent_reply is not None: + self._reply_to_event(event, agent_reply) + return + + task_id = None + if reply_to_message_id: + with self._notification_lock: + task_id = self._notification_map.get(reply_to_message_id) + + if task_id is None: + task_id = self._extract_task_id_from_reply_reference( + reply_to_message_title, + reply_to_message_text, + ) + + if task_id is not None: + task = self.db.get_task(task_id) + if task and task.get("session_id"): + self.db.update_task( + task_id, + status="pending", + prompt=text, + result=None, + error=None, + question=None, + ) + with self._origin_lock: + self._task_origin[task_id] = { + "account_id": account_id, + "peer_id": peer_id, + "context_token": context_token, + "message_id": message_id, + } + self._reply_to_event(event, f"▶️ 收到!正在唤醒 Task #{task_id},请稍候~") + return + self._reply_to_event(event, f"❌ Task #{task_id} has no saved session to resume.") + return + + task = Task( + title=f"[Weixin] {text[:60]}{'…' if len(text) > 60 else ''}", + prompt=text, + working_dir=resolve_working_dir(text, "weixin", self.db), + schedule_type=ScheduleType.IMMEDIATE, + tags="weixin", + agent=resolve_agent("weixin", self.db), + ) + task_id = self.scheduler.submit_task(task) + with self._origin_lock: + self._task_origin[task_id] = { + "account_id": account_id, + "peer_id": peer_id, + "context_token": context_token, + "message_id": message_id, + } + self._reply_to_event(event, f"Task #{task_id} is running…") + + def _reply_to_event(self, event: dict[str, Any], text: str) -> None: + peer_id = event.get("peer_id") or event.get("from_user_id") + if not peer_id: + return + self._send_command( + { + "type": "send_message", + "account_id": event.get("account_id", ""), + "peer_id": peer_id, + "context_token": event.get("context_token", ""), + "reply_to_message_id": event.get("message_id", ""), + "text": text, + } + ) + + def _extract_task_id_from_reply_reference(self, *parts: str) -> Optional[int]: + for part in parts: + if not part: + continue + match = re.search(r"\bTask\s+#(\d+)\b", part) + if match: + return int(match.group(1)) + return None + + def _send_command(self, payload: dict[str, Any]) -> None: + if not self._bridge_proc or not self._bridge_proc.stdin: + return + self._bridge_proc.stdin.write(json.dumps(payload, ensure_ascii=False) + "\n") + self._bridge_proc.stdin.flush() + + def request_login(self) -> None: + self._update_status( + configured=False, + login_status="idle", + qr_code_url="", + last_error="", + user_id="", + ) + self._send_command({"type": "login"}) + + def request_logout(self) -> None: + self._update_status( + configured=False, + login_status="idle", + qr_code_url="", + last_error="", + user_id="", + ) + self._send_command({"type": "logout"}) + + def _update_status(self, **updates: Any) -> None: + with self._status_lock: + self._status.update({k: v for k, v in updates.items() if v is not None}) + + def get_status_snapshot(self) -> dict[str, Any]: + with self._status_lock: + return dict(self._status) diff --git a/docs/superpowers/plans/2026-03-23-weixin-channel-mvp.md b/docs/superpowers/plans/2026-03-23-weixin-channel-mvp.md new file mode 100644 index 0000000..8a356ee --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-weixin-channel-mvp.md @@ -0,0 +1,42 @@ +# Weixin Channel MVP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a text-only Weixin channel MVP backed by a Node sidecar bridge and a Python channel adapter. + +**Architecture:** Python owns AgentForge integration and task/session bookkeeping. A small Node bridge owns Weixin transport concerns and communicates with Python using newline-delimited JSON over stdio. + +**Tech Stack:** Python, pytest, Node.js, newline-delimited JSON, existing AgentForge channel abstractions + +--- + +### Task 1: Add failing Python tests for Weixin channel behavior + +**Files:** +- Create: `tests/test_weixin_channel.py` +- Modify: `channels/weixin_channel.py` + +- [ ] Step 1: Write the failing tests for startup, inbound create/resume, and outbound send. +- [ ] Step 2: Run `pytest tests/test_weixin_channel.py -v` and confirm failure for missing implementation. +- [ ] Step 3: Add the minimal Python implementation to satisfy those tests. +- [ ] Step 4: Run `pytest tests/test_weixin_channel.py -v` and confirm pass. + +### Task 2: Wire channel startup and settings + +**Files:** +- Modify: `taskboard.py` +- Modify: `channels/README.md` + +- [ ] Step 1: Add failing test coverage if practical for startup/config behavior. +- [ ] Step 2: Implement settings/startup wiring for Weixin channel. +- [ ] Step 3: Run focused tests for channel behavior. + +### Task 3: Add Node bridge skeleton + +**Files:** +- Create: `channels/weixin_bridge/package.json` +- Create: `channels/weixin_bridge/index.mjs` + +- [ ] Step 1: Add the minimal bridge command protocol needed by Python tests. +- [ ] Step 2: Keep the transport stubbed if package wiring cannot be safely verified locally. +- [ ] Step 3: Document the expected runtime/config. diff --git a/docs/superpowers/specs/2026-03-23-weixin-channel-design.md b/docs/superpowers/specs/2026-03-23-weixin-channel-design.md new file mode 100644 index 0000000..c276449 --- /dev/null +++ b/docs/superpowers/specs/2026-03-23-weixin-channel-design.md @@ -0,0 +1,52 @@ +# Weixin Channel MVP Design + +**Goal** + +为 AgentForge 增加一个 text-only 的 Weixin channel,支持收消息创建任务、回复结果消息续聊、任务完成后回发文本通知。 + +**Why this design** + +当前仓库的 channel 体系运行在 Python 后端内,而 `@tencent-weixin/openclaw-weixin` 运行在 Node.js 环境中并封装了微信接入细节。最小风险方案是保持 Python 侧只负责接入 AgentForge 的 `MessageBus` / `TaskScheduler`,把微信侧协议处理放在一个独立的 Node sidecar 里。 + +**Architecture** + +- `channels/weixin_channel.py` + Python `Channel` 适配层,负责: + - 启动/停止 Node bridge 子进程 + - 接收入站事件并创建/恢复任务 + - 维护 `task_id -> origin` 与 `notification_id -> task_id` 映射 + - 将任务完成/失败事件转发给 bridge +- `channels/weixin_bridge/` + Node sidecar,负责: + - 封装 `openclaw-weixin` 的生命周期 + - 统一输出 JSON 事件给 Python + - 接收 Python 的发送命令并回发微信消息 + +**MVP scope** + +- 支持文本消息 +- 支持新建任务 +- 支持“回复 bot 的结果消息”触发 resume +- 支持默认工作目录和默认 agent +- 不实现图片/文件/typing +- 不实现多账号 UI,先保留配置字段 + +**Data model** + +- `task_origin[task_id] = { account_id, peer_id, context_token, message_id }` +- `notification_map[message_id] = task_id` + +**Error handling** + +- bridge 启动失败时记录日志并禁用 channel +- bridge 发送失败时不影响任务状态,只记录日志 +- 输入事件缺字段时丢弃并记录日志 + +**Testing** + +- Python 测试覆盖: + - bridge 事件创建任务 + - bridge 事件触发 resume + - outbound 结果转桥接发送命令 + - bridge 进程启动/停止行为 + diff --git a/taskboard-electron/package-lock.json b/taskboard-electron/package-lock.json index 561ea15..62cd426 100644 --- a/taskboard-electron/package-lock.json +++ b/taskboard-electron/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "chokidar": "^5.0.0", "electron-squirrel-startup": "^1.0.1", + "qrcode": "^1.5.4", "react": "^19.2.4", "react-dom": "^19.2.4" }, @@ -61,7 +62,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -772,7 +772,6 @@ "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", @@ -1758,7 +1757,6 @@ "integrity": "sha512-yl43JD/86CIj3Mz5mvvLJqAOfIup7ncxfJ0Btnl0/v5TouVUyeEdcpknfgc+yMevS/48oH9WAkkw93m7otLb/A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/checkbox": "^3.0.1", "@inquirer/confirm": "^4.0.1", @@ -2816,7 +2814,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2883,7 +2880,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2946,7 +2942,6 @@ "version": "5.0.1", "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2956,7 +2951,6 @@ "version": "4.3.0", "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3161,7 +3155,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3347,6 +3340,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001776", "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz", @@ -3585,7 +3587,6 @@ "version": "2.0.1", "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3598,7 +3599,6 @@ "version": "1.1.4", "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/colorette": { @@ -3706,6 +3706,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz", @@ -3814,6 +3823,12 @@ "license": "MIT", "optional": true }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dir-compare": { "version": "4.2.0", "resolved": "https://registry.npmmirror.com/dir-compare/-/dir-compare-4.2.0.tgz", @@ -4478,6 +4493,31 @@ "license": "MIT", "optional": true }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -5144,7 +5184,6 @@ "version": "2.0.5", "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -6958,7 +6997,6 @@ "version": "4.0.0", "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7071,6 +7109,15 @@ "node": ">=10.4.0" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz", @@ -7194,6 +7241,165 @@ "once": "^1.3.1" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qrcode/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -7245,7 +7451,6 @@ "resolved": "https://registry.npmmirror.com/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7433,7 +7638,6 @@ "version": "2.1.1", "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7449,6 +7653,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resedit": { "version": "2.0.3", "resolved": "https://registry.npmmirror.com/resedit/-/resedit-2.0.3.tgz", @@ -7771,6 +7981,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", @@ -8038,7 +8254,6 @@ "version": "6.0.1", "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -8331,7 +8546,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8589,7 +8803,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -8683,7 +8896,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8728,7 +8940,6 @@ "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -8809,6 +9020,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", @@ -8824,7 +9041,6 @@ "version": "6.2.0", "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -8839,14 +9055,12 @@ "version": "8.0.0", "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8856,7 +9070,6 @@ "version": "4.2.3", "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", diff --git a/taskboard-electron/package.json b/taskboard-electron/package.json index 8271d64..25af26c 100644 --- a/taskboard-electron/package.json +++ b/taskboard-electron/package.json @@ -35,6 +35,7 @@ "dependencies": { "chokidar": "^5.0.0", "electron-squirrel-startup": "^1.0.1", + "qrcode": "^1.5.4", "react": "^19.2.4", "react-dom": "^19.2.4" } diff --git a/taskboard-electron/src/renderer/App.jsx b/taskboard-electron/src/renderer/App.jsx index 4216250..60aef23 100644 --- a/taskboard-electron/src/renderer/App.jsx +++ b/taskboard-electron/src/renderer/App.jsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useRef } from "react"; +import QRCode from "qrcode"; import { formatDateTimeLocalInput, formatTaskDateTime, @@ -6,6 +7,12 @@ import { parseTaskDateTime, serializeDateTimeLocalInput, } from "./dateTime.mjs"; +import { + buildChannelsSavePayload, + createInitialChannelsState, + isWeixinQrImageSource, + mergeChannelsStatus, +} from "./channelsSettings.mjs"; const API = "http://127.0.0.1:9712/api"; @@ -506,6 +513,13 @@ async function updateChannelsSettings(data) { }); } +async function runWeixinAction(action) { + await fetch(`${API}/channels/weixin/action`, { + method: "POST", headers: await csrfHeaders(), + body: JSON.stringify({ action }), + }); +} + // ─── Components ─── function Tooltip({ text, children }) { @@ -2047,27 +2061,91 @@ function SettingsModal({ onClose, timeout: initialTimeout, defaultAgent: initial }); const [feishuSaving, setFeishuSaving] = useState(false); const [feishuMsg, setFeishuMsg] = useState(null); // {ok, text} - const [channels, setChannels] = useState({ - telegram: { enabled: false, configured: false, running: false, default_working_dir: "~", default_chat_id: "", bot_token: "", allowed_users: "", ...(initialChannelsStatus?.telegram || {}) }, - slack: { enabled: false, configured: false, running: false, default_working_dir: "~", default_channel: "", default_user: "", bot_token: "", app_token: "", ...(initialChannelsStatus?.slack || {}) }, - }); + const [channels, setChannels] = useState(createInitialChannelsState(initialChannelsStatus)); const [channelsSaving, setChannelsSaving] = useState(false); const [channelsMsg, setChannelsMsg] = useState(null); - const [collapsedChannels, setCollapsedChannels] = useState({ telegram: true, slack: true }); + const [weixinQrSrc, setWeixinQrSrc] = useState(""); + const [weixinActionBusy, setWeixinActionBusy] = useState(false); + const [collapsedChannels, setCollapsedChannels] = useState({ + telegram: true, + slack: true, + weixin: true, + }); // Refresh all channel settings when the modal opens so bot-side /dir changes are visible useEffect(() => { - fetchChannelsStatus().then(s => { - setChannels(c => ({ - telegram: { ...c.telegram, ...(s.telegram || {}) }, - slack: { ...c.slack, ...(s.slack || {}) }, - })); - }); + let cancelled = false; + const refreshChannels = async () => { + const status = await fetchChannelsStatus(); + if (!cancelled) { + setChannels(c => mergeChannelsStatus(c, status)); + } + }; + refreshChannels(); + const intervalId = setInterval(refreshChannels, 2000); fetchFeishuSettings().then(s => { if (s && Object.keys(s).length) setFeishu(f => ({ ...f, ...s })); }); + return () => { + cancelled = true; + clearInterval(intervalId); + }; }, []); + useEffect(() => { + let cancelled = false; + const qrValue = channels.weixin?.qr_code_url || ""; + if (!qrValue) { + setWeixinQrSrc(""); + return () => { + cancelled = true; + }; + } + + if (isWeixinQrImageSource(qrValue)) { + setWeixinQrSrc(qrValue); + return () => { + cancelled = true; + }; + } + + QRCode.toDataURL(qrValue, { + errorCorrectionLevel: "M", + margin: 2, + width: 440, + }) + .then((dataUrl) => { + if (!cancelled) setWeixinQrSrc(dataUrl); + }) + .catch((error) => { + console.error("Failed to generate Weixin QR code", error); + if (!cancelled) setWeixinQrSrc(""); + }); + + return () => { + cancelled = true; + }; + }, [channels.weixin?.qr_code_url]); + + const handleWeixinAction = async (action) => { + setWeixinActionBusy(true); + setChannelsMsg(null); + try { + await runWeixinAction(action); + const updated = await fetchChannelsStatus(); + setChannels(c => mergeChannelsStatus(c, updated)); + if (onChannelsSave) onChannelsSave(updated); + setChannelsMsg({ + ok: true, + text: action === "logout" ? "Wechat logged out." : "Wechat login restarted.", + }); + } catch (e) { + setChannelsMsg({ ok: false, text: String(e) }); + } finally { + setWeixinActionBusy(false); + } + }; + const handleSaveGeneral = async () => { await updateSettings({ timeout: parseInt(timeout) || 600, default_agent: defaultAgent }); onSave(parseInt(timeout) || 600, defaultAgent); @@ -2096,25 +2174,10 @@ function SettingsModal({ onClose, timeout: initialTimeout, defaultAgent: initial setChannelsSaving(true); setChannelsMsg(null); try { - await updateChannelsSettings({ - telegram_enabled: channels.telegram.enabled ? "true" : "false", - telegram_bot_token: channels.telegram.bot_token, - telegram_allowed_users: channels.telegram.allowed_users, - telegram_default_working_dir: channels.telegram.default_working_dir, - telegram_default_chat_id: channels.telegram.default_chat_id, - slack_enabled: channels.slack.enabled ? "true" : "false", - slack_bot_token: channels.slack.bot_token, - slack_app_token: channels.slack.app_token, - slack_default_working_dir: channels.slack.default_working_dir, - slack_default_channel: channels.slack.default_channel, - slack_default_user: channels.slack.default_user, - }); + await updateChannelsSettings(buildChannelsSavePayload(channels)); // Reload channel status after save to reflect new running state const updated = await fetchChannelsStatus(); - setChannels(c => ({ - telegram: { ...c.telegram, ...updated.telegram }, - slack: { ...c.slack, ...updated.slack }, - })); + setChannels(c => mergeChannelsStatus(c, updated)); if (onChannelsSave) onChannelsSave(updated); setChannelsMsg({ ok: true, text: "Saved. Channels restarted." }); } catch (e) { @@ -2232,7 +2295,11 @@ function SettingsModal({ onClose, timeout: initialTimeout, defaultAgent: initial >
{"▼"} - {"✈"} + + + Telegram
@@ -2331,7 +2398,18 @@ function SettingsModal({ onClose, timeout: initialTimeout, defaultAgent: initial >
{"▼"} - {"⚡"} + + + Slack
@@ -2424,6 +2502,189 @@ function SettingsModal({ onClose, timeout: initialTimeout, defaultAgent: initial ); })()} + {/* ── Weixin ── */} + {(() => { + const ch = channels.weixin; + const collapsed = collapsedChannels.weixin; + const statusLabelMap = { + idle: "Idle", + waiting_for_scan: "Waiting for scan", + scanned: "Scanned on phone", + connected: "Connected", + error: "Error", + }; + const statusDot = ch.running + ? { bg: theme.green, label: statusLabelMap[ch.login_status] || "Connected" } + : ch.login_status === "waiting_for_scan" || ch.login_status === "scanned" + ? { bg: theme.orange || "#f59e0b", label: statusLabelMap[ch.login_status] } + : ch.login_status === "error" + ? { bg: theme.red, label: "Error" } + : ch.configured + ? { bg: theme.yellow || "#f59e0b", label: "Configured" } + : { bg: theme.textDim, label: "Login required" }; + return ( +
+
setCollapsedChannels(c => ({ ...c, weixin: !c.weixin }))} + > +
+ {"▼"} + + + + Wechat +
+ + {statusDot.label} +
+
+ +
+ + {!collapsed && ( +
+
+ + setChannels(c => ({ ...c, weixin: { ...c.weixin, default_working_dir: e.target.value } }))} + placeholder="~/my-project" + style={fieldStyle} + /> +
Working directory for tasks created from incoming Weixin messages.
+
+ +
+ + setChannels(c => ({ ...c, weixin: { ...c.weixin, base_url: e.target.value } }))} + placeholder="https://ilinkai.weixin.qq.com" + style={fieldStyle} + /> +
Gateway API base URL used for QR login, long-polling, and sendmessage.
+
+ +
+ + setChannels(c => ({ ...c, weixin: { ...c.weixin, account_id: e.target.value } }))} + placeholder="Optional fixed account id" + style={fieldStyle} + /> +
Optional. Leave empty to let the bridge adopt the account id returned by QR login.
+
+ +
+ + +
+ + {(ch.qr_code_url || ch.login_status === "waiting_for_scan" || ch.login_status === "scanned" || ch.last_error) && ( +
+
+ Weixin Login Status +
+
+ {statusLabelMap[ch.login_status] || "Idle"} + {ch.user_id ? ` · ${ch.user_id}` : ""} +
+ {ch.account_id && ( +
+ Account ID: {ch.account_id} +
+ )} + {weixinQrSrc && ( +
+ Weixin QR code +
+ Open Weixin on your phone and scan this QR code. The status updates automatically. +
+
+ )} + {ch.last_error && ( +
+ {ch.last_error} +
+ )} +
+ )} + +
+
Notes:
+ {[ + "Enabling Weixin starts the local bridge process", + "First launch without a saved session will trigger QR login", + "Reply to a result message to resume the same task session", + ].map(note =>
{note}
)} +
+
+ )} +
+ ); + })()} + {channelsMsg && (
{channelsMsg.text} diff --git a/taskboard-electron/src/renderer/channelsSettings.mjs b/taskboard-electron/src/renderer/channelsSettings.mjs new file mode 100644 index 0000000..a9de1ed --- /dev/null +++ b/taskboard-electron/src/renderer/channelsSettings.mjs @@ -0,0 +1,82 @@ +const DEFAULT_CHANNELS_STATE = { + telegram: { + enabled: false, + configured: false, + running: false, + default_working_dir: "~", + default_chat_id: "", + bot_token: "", + allowed_users: "", + }, + slack: { + enabled: false, + configured: false, + running: false, + default_working_dir: "~", + default_channel: "", + default_user: "", + bot_token: "", + app_token: "", + }, + weixin: { + enabled: false, + configured: false, + running: false, + default_working_dir: "~", + base_url: "https://ilinkai.weixin.qq.com", + account_id: "", + login_status: "idle", + qr_code_url: "", + last_error: "", + user_id: "", + }, +}; + +function cloneState(state) { + return { + telegram: { ...state.telegram }, + slack: { ...state.slack }, + weixin: { ...state.weixin }, + }; +} + +export function createInitialChannelsState(initial = {}) { + const base = cloneState(DEFAULT_CHANNELS_STATE); + return mergeChannelsStatus(base, initial); +} + +export function mergeChannelsStatus(current, status = {}) { + return { + telegram: { ...current.telegram, ...(status.telegram || {}) }, + slack: { ...current.slack, ...(status.slack || {}) }, + weixin: { ...current.weixin, ...(status.weixin || {}) }, + }; +} + +export function buildChannelsSavePayload(channels) { + return { + telegram_enabled: channels.telegram.enabled ? "true" : "false", + telegram_bot_token: channels.telegram.bot_token, + telegram_allowed_users: channels.telegram.allowed_users, + telegram_default_working_dir: channels.telegram.default_working_dir, + telegram_default_chat_id: channels.telegram.default_chat_id, + slack_enabled: channels.slack.enabled ? "true" : "false", + slack_bot_token: channels.slack.bot_token, + slack_app_token: channels.slack.app_token, + slack_default_working_dir: channels.slack.default_working_dir, + slack_default_channel: channels.slack.default_channel, + slack_default_user: channels.slack.default_user, + weixin_enabled: channels.weixin.enabled ? "true" : "false", + weixin_default_working_dir: channels.weixin.default_working_dir, + weixin_base_url: channels.weixin.base_url, + weixin_account_id: channels.weixin.account_id, + }; +} + +export function isWeixinQrImageSource(value) { + const normalized = (value || "").trim(); + if (!normalized) return false; + if (normalized.startsWith("data:image/")) return true; + if (/\.(png|jpg|jpeg|gif|webp|svg)(\?|#|$)/i.test(normalized)) return true; + return false; +} diff --git a/taskboard-electron/src/renderer/channelsSettings.test.mjs b/taskboard-electron/src/renderer/channelsSettings.test.mjs new file mode 100644 index 0000000..c5c7ec8 --- /dev/null +++ b/taskboard-electron/src/renderer/channelsSettings.test.mjs @@ -0,0 +1,87 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + buildChannelsSavePayload, + createInitialChannelsState, + mergeChannelsStatus, + isWeixinQrImageSource, +} from "./channelsSettings.mjs"; + +test("createInitialChannelsState includes weixin defaults", () => { + const state = createInitialChannelsState(); + + assert.deepEqual(state.weixin, { + enabled: false, + configured: false, + running: false, + default_working_dir: "~", + base_url: "https://ilinkai.weixin.qq.com", + account_id: "", + login_status: "idle", + qr_code_url: "", + last_error: "", + user_id: "", + }); +}); + +test("mergeChannelsStatus overlays weixin status onto existing state", () => { + const merged = mergeChannelsStatus(createInitialChannelsState(), { + weixin: { + enabled: true, + configured: true, + running: true, + default_working_dir: "/tmp/repo", + base_url: "https://example.test", + account_id: "wx-demo", + }, + }); + + assert.equal(merged.weixin.enabled, true); + assert.equal(merged.weixin.running, true); + assert.equal(merged.weixin.default_working_dir, "/tmp/repo"); + assert.equal(merged.weixin.base_url, "https://example.test"); + assert.equal(merged.weixin.account_id, "wx-demo"); +}); + +test("buildChannelsSavePayload serializes weixin settings for the API", () => { + const payload = buildChannelsSavePayload({ + ...createInitialChannelsState(), + weixin: { + enabled: true, + configured: true, + running: false, + default_working_dir: "~/workspace/agentforge", + base_url: "https://ilinkai.weixin.qq.com", + account_id: "wx-primary", + }, + }); + + assert.equal(payload.weixin_enabled, "true"); + assert.equal(payload.weixin_default_working_dir, "~/workspace/agentforge"); + assert.equal(payload.weixin_base_url, "https://ilinkai.weixin.qq.com"); + assert.equal(payload.weixin_account_id, "wx-primary"); +}); + +test("isWeixinQrImageSource recognizes real image sources only", () => { + assert.equal( + isWeixinQrImageSource("data:image/png;base64,abc"), + true, + ); + assert.equal( + isWeixinQrImageSource("https://example.test/qr.png"), + true, + ); + assert.equal( + isWeixinQrImageSource("https://liteapp.weixin.qq.com/q/7GiQu1?qrcode=7a9bf9b71b5bc24cac576b5098adb5b4&b"), + false, + ); + assert.equal( + isWeixinQrImageSource("otpauth://totp/example"), + false, + ); + assert.equal( + isWeixinQrImageSource("wxp://some-qr-payload"), + false, + ); +}); diff --git a/taskboard.py b/taskboard.py index c611fce..c7bd627 100644 --- a/taskboard.py +++ b/taskboard.py @@ -62,6 +62,14 @@ SLACK_CHANNEL_AVAILABLE = False SlackChannel = None +try: + from channels.weixin_channel import WeixinChannel + + WEIXIN_CHANNEL_AVAILABLE = True +except ImportError: + WEIXIN_CHANNEL_AVAILABLE = False + WeixinChannel = None + # ──────────────────────────── Helpers ──────────────────────────── @@ -117,6 +125,28 @@ def _normalize_datetime_for_storage(value: Optional[str]) -> Optional[str]: return dt.isoformat() if dt else None +def _build_weixin_channel_status(db, weixin_channel) -> dict: + weixin_status = ( + weixin_channel.get_status_snapshot() + if weixin_channel and hasattr(weixin_channel, "get_status_snapshot") + else {} + ) + runtime_account_id = weixin_status.get("account_id", "") + configured_account_id = db.get_setting("weixin_account_id", "") + return { + "enabled": db.get_setting("weixin_enabled", "false") == "true", + "configured": bool(weixin_status.get("configured", False)), + "running": bool(weixin_channel and getattr(weixin_channel, "_running", False)), + "default_working_dir": db.get_setting("weixin_default_working_dir", "~"), + "base_url": db.get_setting("weixin_base_url", "https://ilinkai.weixin.qq.com"), + "account_id": runtime_account_id or configured_account_id, + "login_status": weixin_status.get("login_status", "idle"), + "qr_code_url": weixin_status.get("qr_code_url", ""), + "last_error": weixin_status.get("last_error", ""), + "user_id": weixin_status.get("user_id", ""), + } + + # ──────────────────────────── Models ──────────────────────────── @@ -2425,6 +2455,7 @@ class TaskAPIHandler(BaseHTTPRequestHandler): feishu_channel = None telegram_channel = None slack_channel = None + weixin_channel = None def do_OPTIONS(self): origin = self.headers.get("Origin", "") @@ -2763,6 +2794,7 @@ def do_GET(self): "default_channel": self.db.get_setting("slack_default_channel", ""), "default_user": self.db.get_setting("slack_default_user", ""), }, + "weixin": _build_weixin_channel_status(self.db, self.weixin_channel), "feishu": { "configured": self.db.get_setting("feishu_enabled", "false") == "true", "running": bool( @@ -2969,6 +3001,10 @@ def do_POST(self): "slack_default_channel", "slack_default_user", "slack_enabled", + "weixin_default_working_dir", + "weixin_base_url", + "weixin_account_id", + "weixin_enabled", } for key, value in body.items(): if key in allowed: @@ -3046,9 +3082,44 @@ def do_POST(self): else: logger.info("Slack channel disabled") + # ── Restart Weixin channel ── + wx_enabled = ( + body.get("weixin_enabled") or self.db.get_setting("weixin_enabled", "false") + ) == "true" + if self.__class__.weixin_channel: + logger.info("Stopping existing Weixin channel...") + self.__class__.weixin_channel.stop() + self.__class__.weixin_channel = None + if wx_enabled and WEIXIN_CHANNEL_AVAILABLE: + logger.info("Starting Weixin channel with new settings...") + ch = WeixinChannel( + bus=self.__class__.bus, + db=self.db, + scheduler=self.__class__.scheduler, + ) + ch.start() + self.__class__.weixin_channel = ch + logger.info("Weixin channel started") + else: + logger.info("Weixin channel disabled") + logger.info("Channel settings updated successfully") self._json_response({"status": "updated"}) + elif path == "/api/channels/weixin/action": + action = (body.get("action") or "").strip().lower() + if not self.__class__.weixin_channel: + self._json_response({"error": "weixin channel not running"}, 400) + return + if action in {"login", "reconnect"}: + self.__class__.weixin_channel.request_login() + self._json_response({"status": "ok", "action": action}) + elif action == "logout": + self.__class__.weixin_channel.request_logout() + self._json_response({"status": "ok", "action": action}) + else: + self._json_response({"error": "unsupported action"}, 400) + elif path == "/api/dag": # Batch-create a full DAG in one call. # Body: {dag_id, tasks: [{ref, title, prompt, working_dir, schedule_type, depends_on_refs, inject_result, ...}]} @@ -3609,6 +3680,21 @@ def run_server(port: int = 9712): logger.info("Slack channel disabled") # ───────────────────────────────────────────────────────────────────── + # ── Weixin channel ─────────────────────────────────────────────────── + weixin_channel = None + wx_enabled = db.get_setting("weixin_enabled", "false") == "true" + if WEIXIN_CHANNEL_AVAILABLE and wx_enabled: + logger.info("Starting Weixin channel...") + weixin_channel = WeixinChannel( + bus=bus, + db=db, + scheduler=scheduler, + ) + weixin_channel.start() + else: + logger.info("Weixin channel disabled") + # ───────────────────────────────────────────────────────────────────── + scheduler.start() TaskAPIHandler.scheduler = scheduler @@ -3618,6 +3704,7 @@ def run_server(port: int = 9712): TaskAPIHandler.ui_channel = ui_channel TaskAPIHandler.telegram_channel = telegram_channel TaskAPIHandler.slack_channel = slack_channel + TaskAPIHandler.weixin_channel = weixin_channel server = QuietHTTPServer(("127.0.0.1", port), TaskAPIHandler) logger.info(f"API server running on http://127.0.0.1:{port}") diff --git a/tests/test_weixin_channel.py b/tests/test_weixin_channel.py new file mode 100644 index 0000000..9bc5909 --- /dev/null +++ b/tests/test_weixin_channel.py @@ -0,0 +1,344 @@ +import io +import json + +from taskboard_bus import MessageBus, OutboundMessage, OutboundMessageType + + +class StubDB: + def __init__(self): + self.settings = {} + self.tasks = {} + self.updated = [] + + def get_setting(self, key, default=None): + return self.settings.get(key, default) + + def set_setting(self, key, value): + self.settings[key] = value + + def get_task(self, task_id): + return self.tasks.get(task_id) + + def update_task(self, task_id, **updates): + self.updated.append((task_id, updates)) + self.tasks.setdefault(task_id, {"id": task_id}).update(updates) + + +class StubScheduler: + def __init__(self): + self.submitted = [] + + def submit_task(self, task): + task_id = len(self.submitted) + 1 + self.submitted.append(task) + return task_id + + +class FakeProcess: + def __init__(self): + self.stdin = io.StringIO() + self.stdout = io.StringIO() + self.terminated = False + + def poll(self): + return None + + def terminate(self): + self.terminated = True + + def wait(self, timeout=None): + return 0 + + +def test_weixin_channel_creates_task_from_bridge_message(): + from channels.weixin_channel import WeixinChannel + + bus = MessageBus() + db = StubDB() + scheduler = StubScheduler() + channel = WeixinChannel(bus=bus, db=db, scheduler=scheduler) + + channel._handle_bridge_event( + { + "type": "message", + "account_id": "wx-1", + "peer_id": "user-1", + "context_token": "ctx-1", + "message_id": "msg-1", + "text": "请帮我修复登录问题", + } + ) + + assert len(scheduler.submitted) == 1 + task = scheduler.submitted[0] + assert task.prompt == "请帮我修复登录问题" + assert task.tags == "weixin" + assert task.title.startswith("[Weixin]") + assert channel._task_origin[1]["peer_id"] == "user-1" + assert channel._task_origin[1]["context_token"] == "ctx-1" + + +def test_weixin_channel_resumes_task_when_reply_matches_notification(): + from channels.weixin_channel import WeixinChannel + + bus = MessageBus() + db = StubDB() + db.tasks[7] = {"id": 7, "session_id": "sess-7", "status": "completed"} + scheduler = StubScheduler() + channel = WeixinChannel(bus=bus, db=db, scheduler=scheduler) + channel._notification_map["notif-7"] = 7 + + channel._handle_bridge_event( + { + "type": "message", + "account_id": "wx-1", + "peer_id": "user-1", + "context_token": "ctx-2", + "message_id": "msg-2", + "reply_to_message_id": "notif-7", + "text": "继续,并补上测试", + } + ) + + assert db.updated == [ + ( + 7, + { + "status": "pending", + "prompt": "继续,并补上测试", + "result": None, + "error": None, + "question": None, + }, + ) + ] + assert channel._task_origin[7]["message_id"] == "msg-2" + assert channel._task_origin[7]["context_token"] == "ctx-2" + commands = ( + [ + json.loads(line) + for line in channel._bridge_proc.stdin.getvalue().splitlines() + if line.strip() + ] + if channel._bridge_proc + else [] + ) + if commands: + assert commands[-1]["text"] == "▶️ 收到!正在唤醒 Task #7,请稍候~" + + +def test_weixin_channel_sends_outbound_notifications_to_bridge(): + from channels.weixin_channel import WeixinChannel + + bus = MessageBus() + db = StubDB() + scheduler = StubScheduler() + channel = WeixinChannel(bus=bus, db=db, scheduler=scheduler) + channel._running = True + channel._bridge_proc = FakeProcess() + channel._task_origin[3] = { + "account_id": "wx-1", + "peer_id": "user-3", + "context_token": "ctx-3", + "message_id": "msg-3", + } + + channel.send( + OutboundMessage( + type=OutboundMessageType.TASK_COMPLETED, + task_id=3, + payload={"title": "Fix login", "result": "修好了"}, + ) + ) + + written = channel._bridge_proc.stdin.getvalue().strip() + command = json.loads(written) + assert command["type"] == "send_message" + assert command["request_id"] + assert command["peer_id"] == "user-3" + assert command["context_token"] == "ctx-3" + assert "Task #3" in command["text"] + assert "修好了" in command["text"] + + channel._handle_bridge_event( + { + "type": "sent", + "request_id": command["request_id"], + "message_id": "wx-out-3", + } + ) + + assert channel._notification_map["wx-out-3"] == 3 + + +def test_weixin_channel_resumes_task_when_reply_matches_real_weixin_msg_id(): + from channels.weixin_channel import WeixinChannel + + bus = MessageBus() + db = StubDB() + db.tasks[9] = {"id": 9, "session_id": "sess-9", "status": "completed"} + scheduler = StubScheduler() + channel = WeixinChannel(bus=bus, db=db, scheduler=scheduler) + channel._running = True + channel._bridge_proc = FakeProcess() + channel._task_origin[9] = { + "account_id": "wx-1", + "peer_id": "user-9", + "context_token": "ctx-9", + "message_id": "incoming-9", + } + + channel.send( + OutboundMessage( + type=OutboundMessageType.TASK_COMPLETED, + task_id=9, + payload={"title": "Fix resume", "result": "done"}, + ) + ) + + command = json.loads(channel._bridge_proc.stdin.getvalue().strip()) + channel._handle_bridge_event( + { + "type": "sent", + "request_id": command["request_id"], + "message_id": command["request_id"], + "quoted_message_id": "wx-real-msg-9", + } + ) + + channel._handle_bridge_event( + { + "type": "message", + "account_id": "wx-1", + "peer_id": "user-9", + "context_token": "ctx-9b", + "message_id": "incoming-9b", + "reply_to_message_id": "wx-real-msg-9", + "text": "继续这个任务", + } + ) + + assert db.updated == [ + ( + 9, + { + "status": "pending", + "prompt": "继续这个任务", + "result": None, + "error": None, + "question": None, + }, + ) + ] + assert channel._task_origin[9]["message_id"] == "incoming-9b" + assert channel._task_origin[9]["context_token"] == "ctx-9b" + commands = [ + json.loads(line) + for line in channel._bridge_proc.stdin.getvalue().splitlines() + if line.strip() + ] + assert commands[-1]["text"] == "▶️ 收到!正在唤醒 Task #9,请稍候~" + + +def test_weixin_channel_resumes_task_when_reply_quotes_task_number_without_msg_id(): + from channels.weixin_channel import WeixinChannel + + bus = MessageBus() + db = StubDB() + db.tasks[11] = {"id": 11, "session_id": "sess-11", "status": "completed"} + scheduler = StubScheduler() + channel = WeixinChannel(bus=bus, db=db, scheduler=scheduler) + + channel._handle_bridge_event( + { + "type": "message", + "account_id": "wx-1", + "peer_id": "user-11", + "context_token": "ctx-11b", + "message_id": "incoming-11b", + "reply_to_message_id": "", + "reply_to_message_title": "✅ Task #11 · [Weixin] 你好", + "reply_to_message_text": "Task #11 已完成", + "text": "继续这个任务", + } + ) + + assert db.updated == [ + ( + 11, + { + "status": "pending", + "prompt": "继续这个任务", + "result": None, + "error": None, + "question": None, + }, + ) + ] + assert channel._task_origin[11]["message_id"] == "incoming-11b" + assert channel._task_origin[11]["context_token"] == "ctx-11b" + + +def test_weixin_channel_tracks_qr_and_login_status_from_bridge_events(): + from channels.weixin_channel import WeixinChannel + + channel = WeixinChannel(bus=MessageBus(), db=StubDB(), scheduler=StubScheduler()) + + channel._handle_bridge_event( + { + "type": "qr", + "qrcode_url": "https://example.test/qr.png", + "account_id": "wx-login", + } + ) + snapshot = channel.get_status_snapshot() + assert snapshot["login_status"] == "waiting_for_scan" + assert snapshot["qr_code_url"] == "https://example.test/qr.png" + assert snapshot["account_id"] == "wx-login" + + channel._handle_bridge_event({"type": "scaned"}) + assert channel.get_status_snapshot()["login_status"] == "scanned" + + channel._handle_bridge_event( + { + "type": "login_success", + "account_id": "wx-login", + "user_id": "user-42", + } + ) + snapshot = channel.get_status_snapshot() + assert snapshot["login_status"] == "connected" + assert snapshot["qr_code_url"] == "" + assert snapshot["configured"] is True + assert snapshot["user_id"] == "user-42" + + +def test_weixin_channel_can_request_relogin_and_logout_via_bridge_commands(): + from channels.weixin_channel import WeixinChannel + + channel = WeixinChannel(bus=MessageBus(), db=StubDB(), scheduler=StubScheduler()) + channel._bridge_proc = FakeProcess() + channel._running = True + channel._handle_bridge_event( + { + "type": "login_success", + "account_id": "wx-login", + "user_id": "user-42", + } + ) + + channel.request_login() + channel.request_logout() + + commands = [ + json.loads(line) + for line in channel._bridge_proc.stdin.getvalue().splitlines() + if line.strip() + ] + assert commands[0]["type"] == "login" + assert commands[1]["type"] == "logout" + + snapshot = channel.get_status_snapshot() + assert snapshot["configured"] is False + assert snapshot["login_status"] == "idle" + assert snapshot["qr_code_url"] == "" diff --git a/tests/test_weixin_status.py b/tests/test_weixin_status.py new file mode 100644 index 0000000..0c1d5a1 --- /dev/null +++ b/tests/test_weixin_status.py @@ -0,0 +1,38 @@ +from taskboard import _build_weixin_channel_status + + +class StubDB: + def __init__(self): + self.settings = { + "weixin_enabled": "true", + "weixin_default_working_dir": "~/repo", + "weixin_base_url": "https://ilinkai.weixin.qq.com", + "weixin_account_id": "", + } + + def get_setting(self, key, default=None): + return self.settings.get(key, default) + + +class StubWeixinChannel: + _running = True + + def get_status_snapshot(self): + return { + "configured": True, + "login_status": "connected", + "qr_code_url": "", + "last_error": "", + "account_id": "wx-live-account", + "user_id": "user-42", + } + + +def test_build_weixin_channel_status_prefers_runtime_account_id(): + status = _build_weixin_channel_status(StubDB(), StubWeixinChannel()) + + assert status["enabled"] is True + assert status["configured"] is True + assert status["running"] is True + assert status["account_id"] == "wx-live-account" + assert status["user_id"] == "user-42"