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 >