diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index 74395f27..9c47f68b 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -77,7 +77,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | Linq | ✅ | ❌ | P3 | Real iMessage via API, no Mac required | | Feishu/Lark | ✅ | 🚧 | P3 | WASM channel with Event Subscription v2.0; Bitable/Docx tools planned | | LINE | ✅ | ❌ | P3 | | -| WeChat (iLink bot) | ✅ | ❌ | P2 | Extension-first channel (`channels-src/weixin`), Phase 1 targets single-account parity on the upstream DM flow before multi-account follow-up | +| WeChat (iLink bot) | ✅ | ❌ | P2 | Extension-first channel (`channels-src/wechat`), Phase 1 targets single-account parity on the upstream DM flow before multi-account follow-up | | WebChat | ✅ | ✅ | - | Web gateway chat | | Matrix | ✅ | ❌ | P3 | E2EE support | | Mattermost | ✅ | ❌ | P3 | Emoji reactions, interactive buttons, model picker | diff --git a/channels-src/weixin/.gitignore b/channels-src/wechat/.gitignore similarity index 100% rename from channels-src/weixin/.gitignore rename to channels-src/wechat/.gitignore diff --git a/channels-src/weixin/Cargo.lock b/channels-src/wechat/Cargo.lock similarity index 99% rename from channels-src/weixin/Cargo.lock rename to channels-src/wechat/Cargo.lock index 903d0423..a8084278 100644 --- a/channels-src/weixin/Cargo.lock +++ b/channels-src/wechat/Cargo.lock @@ -274,7 +274,7 @@ dependencies = [ ] [[package]] -name = "weixin-channel" +name = "wechat-channel" version = "0.1.0" dependencies = [ "base64", diff --git a/channels-src/weixin/Cargo.toml b/channels-src/wechat/Cargo.toml similarity index 80% rename from channels-src/weixin/Cargo.toml rename to channels-src/wechat/Cargo.toml index ed143861..cf40f496 100644 --- a/channels-src/weixin/Cargo.toml +++ b/channels-src/wechat/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "weixin-channel" +name = "wechat-channel" version = "0.1.0" edition = "2021" -description = "Weixin iLink Bot channel for IronClaw" +description = "WeChat iLink Bot channel for IronClaw" license = "MIT OR Apache-2.0" [lib] diff --git a/channels-src/wechat/build.sh b/channels-src/wechat/build.sh new file mode 100755 index 00000000..4013354d --- /dev/null +++ b/channels-src/wechat/build.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" + +echo "Building WeChat channel WASM component..." + +cargo build --release --target wasm32-wasip2 + +WASM_PATH="target/wasm32-wasip2/release/wechat_channel.wasm" + +if [ -f "$WASM_PATH" ]; then + wasm-tools component new "$WASM_PATH" -o wechat.wasm 2>/dev/null || cp "$WASM_PATH" wechat.wasm + wasm-tools strip wechat.wasm -o wechat.wasm + + echo "Built: wechat.wasm ($(du -h wechat.wasm | cut -f1))" + echo "" + echo "To install:" + echo " mkdir -p ~/.ironclaw/channels" + echo " cp wechat.wasm wechat.capabilities.json ~/.ironclaw/channels/" +else + echo "Error: WASM output not found at $WASM_PATH" + exit 1 +fi diff --git a/channels-src/weixin/src/api.rs b/channels-src/wechat/src/api.rs similarity index 91% rename from channels-src/weixin/src/api.rs rename to channels-src/wechat/src/api.rs index 1f694fe5..14ed9784 100644 --- a/channels-src/weixin/src/api.rs +++ b/channels-src/wechat/src/api.rs @@ -2,8 +2,8 @@ use base64::Engine as _; use crate::near::agent::channel_host; use crate::types::{ - BaseInfo, GetUpdatesRequest, GetUpdatesResponse, MessageItem, OutboundWeixinMessage, - SendMessageRequest, TextItem, WeixinConfig, MESSAGE_ITEM_TEXT, MESSAGE_STATE_FINISH, + BaseInfo, GetUpdatesRequest, GetUpdatesResponse, MessageItem, OutboundWechatMessage, + SendMessageRequest, TextItem, WechatConfig, MESSAGE_ITEM_TEXT, MESSAGE_STATE_FINISH, MESSAGE_TYPE_BOT, }; @@ -30,7 +30,7 @@ fn request_headers(body: &[u8]) -> String { serde_json::json!({ "Content-Type": "application/json", "AuthorizationType": "ilink_bot_token", - "Authorization": "Bearer {WEIXIN_BOT_TOKEN}", + "Authorization": "Bearer {WECHAT_BOT_TOKEN}", "Content-Length": body.len().to_string(), "X-WECHAT-UIN": random_wechat_uin(), }) @@ -38,7 +38,7 @@ fn request_headers(body: &[u8]) -> String { } pub fn get_updates( - config: &WeixinConfig, + config: &WechatConfig, get_updates_buf: &str, ) -> Result { let body = serde_json::to_vec(&GetUpdatesRequest { @@ -70,16 +70,16 @@ pub fn get_updates( } pub fn send_text_message( - config: &WeixinConfig, + config: &WechatConfig, to_user_id: &str, text: &str, context_token: Option<&str>, ) -> Result<(), String> { let message = SendMessageRequest { - msg: OutboundWeixinMessage { + msg: OutboundWechatMessage { from_user_id: String::new(), to_user_id: to_user_id.to_string(), - client_id: format!("weixin-{}", channel_host::now_millis()), + client_id: format!("wechat-{}", channel_host::now_millis()), message_type: MESSAGE_TYPE_BOT, message_state: MESSAGE_STATE_FINISH, item_list: vec![MessageItem { diff --git a/channels-src/weixin/src/auth.rs b/channels-src/wechat/src/auth.rs similarity index 81% rename from channels-src/weixin/src/auth.rs rename to channels-src/wechat/src/auth.rs index 66e593de..cfd516da 100644 --- a/channels-src/weixin/src/auth.rs +++ b/channels-src/wechat/src/auth.rs @@ -1,4 +1,4 @@ -pub const TOKEN_SECRET_NAME: &str = "weixin_bot_token"; +pub const TOKEN_SECRET_NAME: &str = "wechat_bot_token"; pub const CONFIG_PATH: &str = "config.json"; pub const GET_UPDATES_BUF_PATH: &str = "state/get_updates_buf.json"; pub const CONTEXT_TOKENS_PATH: &str = "state/context_tokens.json"; diff --git a/channels-src/weixin/src/lib.rs b/channels-src/wechat/src/lib.rs similarity index 84% rename from channels-src/weixin/src/lib.rs rename to channels-src/wechat/src/lib.rs index 26c7ea16..2840ff32 100644 --- a/channels-src/weixin/src/lib.rs +++ b/channels-src/wechat/src/lib.rs @@ -21,20 +21,20 @@ use crate::state::{ session_expired, }; use crate::types::{ - OutboundMetadata, WeixinConfig, WeixinMessage, MESSAGE_ITEM_TEXT, MESSAGE_TYPE_USER, + OutboundMetadata, WechatConfig, WechatMessage, MESSAGE_ITEM_TEXT, MESSAGE_TYPE_USER, }; -struct WeixinChannel; +struct WechatChannel; -impl Guest for WeixinChannel { +impl Guest for WechatChannel { fn on_start(config_json: String) -> Result { - let config = serde_json::from_str::(&config_json) - .map_err(|e| format!("Failed to parse Weixin config: {e}"))?; + let config = serde_json::from_str::(&config_json) + .map_err(|e| format!("Failed to parse WeChat config: {e}"))?; persist_config(&config)?; clear_session_expired(); Ok(ChannelConfig { - display_name: "Weixin".to_string(), + display_name: "WeChat".to_string(), http_endpoints: Vec::new(), poll: Some(PollConfig { interval_ms: config.poll_interval_ms.max(30_000), @@ -49,7 +49,7 @@ impl Guest for WeixinChannel { exports::near::agent::channel::OutgoingHttpResponse { status: 404, headers_json: "{}".to_string(), - body: b"{\"error\":\"weixin channel does not expose webhooks\"}".to_vec(), + body: b"{\"error\":\"wechat channel does not expose webhooks\"}".to_vec(), } } @@ -57,7 +57,7 @@ impl Guest for WeixinChannel { if session_expired() { channel_host::log( channel_host::LogLevel::Warn, - "Weixin session is marked expired; reconnect the channel to resume polling", + "WeChat session is marked expired; reconnect the channel to resume polling", ); return; } @@ -65,7 +65,7 @@ impl Guest for WeixinChannel { if !channel_host::secret_exists(TOKEN_SECRET_NAME) { channel_host::log( channel_host::LogLevel::Warn, - "Weixin bot token is missing; skipping poll", + "WeChat bot token is missing; skipping poll", ); return; } @@ -80,7 +80,7 @@ impl Guest for WeixinChannel { mark_session_expired(); channel_host::log( channel_host::LogLevel::Error, - "Weixin session expired; reconnect the channel", + "WeChat session expired; reconnect the channel", ); return; } @@ -89,11 +89,11 @@ impl Guest for WeixinChannel { let errmsg = response .errmsg .as_deref() - .unwrap_or("unknown Weixin polling error"); + .unwrap_or("unknown WeChat polling error"); channel_host::log( channel_host::LogLevel::Warn, &format!( - "Weixin getUpdates returned ret={} errmsg={errmsg}", + "WeChat getUpdates returned ret={} errmsg={errmsg}", response.ret.unwrap_or(-1) ), ); @@ -104,7 +104,7 @@ impl Guest for WeixinChannel { if let Err(error) = persist_get_updates_buf(next_cursor) { channel_host::log( channel_host::LogLevel::Warn, - &format!("Failed to persist Weixin polling cursor: {error}"), + &format!("Failed to persist WeChat polling cursor: {error}"), ); } } @@ -128,7 +128,7 @@ impl Guest for WeixinChannel { if let Err(error) = persist_context_tokens(&context_tokens) { channel_host::log( channel_host::LogLevel::Warn, - &format!("Failed to persist Weixin context tokens: {error}"), + &format!("Failed to persist WeChat context tokens: {error}"), ); } } @@ -136,7 +136,7 @@ impl Guest for WeixinChannel { Err(error) => { channel_host::log( channel_host::LogLevel::Error, - &format!("Weixin polling failed: {error}"), + &format!("WeChat polling failed: {error}"), ); } } @@ -144,7 +144,7 @@ impl Guest for WeixinChannel { fn on_respond(response: AgentResponse) -> Result<(), String> { let metadata = serde_json::from_str::(&response.metadata_json) - .map_err(|e| format!("Invalid Weixin response metadata: {e}"))?; + .map_err(|e| format!("Invalid WeChat response metadata: {e}"))?; let config = load_config(); let context_tokens = load_context_tokens(); let context_token = metadata @@ -169,7 +169,7 @@ impl Guest for WeixinChannel { fn on_shutdown() {} } -fn emit_incoming_message(message: WeixinMessage) { +fn emit_incoming_message(message: WechatMessage) { if message.message_type != Some(MESSAGE_TYPE_USER) { return; } @@ -195,13 +195,13 @@ fn emit_incoming_message(message: WeixinMessage) { user_id: from_user_id.to_string(), user_name: None, content: text, - thread_id: Some(format!("weixin:{from_user_id}")), + thread_id: Some(format!("wechat:{from_user_id}")), metadata_json: metadata.to_string(), attachments: Vec::new(), }); } -fn extract_text(message: &WeixinMessage) -> String { +fn extract_text(message: &WechatMessage) -> String { message .item_list .iter() @@ -215,4 +215,4 @@ fn extract_text(message: &WeixinMessage) -> String { .unwrap_or_default() } -export!(WeixinChannel); +export!(WechatChannel); diff --git a/channels-src/weixin/src/state.rs b/channels-src/wechat/src/state.rs similarity index 90% rename from channels-src/weixin/src/state.rs rename to channels-src/wechat/src/state.rs index fb63c81a..424cb317 100644 --- a/channels-src/weixin/src/state.rs +++ b/channels-src/wechat/src/state.rs @@ -2,15 +2,15 @@ use std::collections::HashMap; use crate::auth::{CONFIG_PATH, CONTEXT_TOKENS_PATH, GET_UPDATES_BUF_PATH, SESSION_EXPIRED_PATH}; use crate::near::agent::channel_host; -use crate::types::WeixinConfig; +use crate::types::WechatConfig; -pub fn load_config() -> WeixinConfig { +pub fn load_config() -> WechatConfig { channel_host::workspace_read(CONFIG_PATH) - .and_then(|raw| serde_json::from_str::(&raw).ok()) + .and_then(|raw| serde_json::from_str::(&raw).ok()) .unwrap_or_default() } -pub fn persist_config(config: &WeixinConfig) -> Result<(), String> { +pub fn persist_config(config: &WechatConfig) -> Result<(), String> { let serialized = serde_json::to_string(config).map_err(|e| format!("Failed to serialize config: {e}"))?; channel_host::workspace_write(CONFIG_PATH, &serialized).map_err(|e| e.to_string()) diff --git a/channels-src/weixin/src/types.rs b/channels-src/wechat/src/types.rs similarity index 94% rename from channels-src/weixin/src/types.rs rename to channels-src/wechat/src/types.rs index 27ec7e8e..48735af7 100644 --- a/channels-src/weixin/src/types.rs +++ b/channels-src/wechat/src/types.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize, Serialize)] -pub struct WeixinConfig { +pub struct WechatConfig { #[serde(default = "default_base_url")] pub base_url: String, #[serde(default = "default_bot_type")] @@ -28,7 +28,7 @@ fn default_long_poll_timeout_ms() -> u32 { 35_000 } -impl Default for WeixinConfig { +impl Default for WechatConfig { fn default() -> Self { Self { base_url: default_base_url(), @@ -59,19 +59,19 @@ pub struct GetUpdatesResponse { #[serde(default)] pub errmsg: Option, #[serde(default)] - pub msgs: Vec, + pub msgs: Vec, #[serde(default)] pub get_updates_buf: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct SendMessageRequest { - pub msg: OutboundWeixinMessage, + pub msg: OutboundWechatMessage, pub base_info: BaseInfo, } #[derive(Debug, Clone, Deserialize, Serialize)] -pub struct OutboundWeixinMessage { +pub struct OutboundWechatMessage { pub from_user_id: String, pub to_user_id: String, pub client_id: String, @@ -83,7 +83,7 @@ pub struct OutboundWeixinMessage { } #[derive(Debug, Clone, Deserialize)] -pub struct WeixinMessage { +pub struct WechatMessage { #[serde(default)] pub message_id: Option, #[serde(default)] diff --git a/channels-src/weixin/weixin.capabilities.json b/channels-src/wechat/wechat.capabilities.json similarity index 78% rename from channels-src/weixin/weixin.capabilities.json rename to channels-src/wechat/wechat.capabilities.json index a54018ae..cc262616 100644 --- a/channels-src/weixin/weixin.capabilities.json +++ b/channels-src/wechat/wechat.capabilities.json @@ -2,13 +2,13 @@ "version": "0.1.0", "wit_version": "0.3.0", "type": "channel", - "name": "weixin", - "description": "Weixin iLink Bot channel for direct-message chat via long polling", + "name": "wechat", + "description": "WeChat iLink Bot channel for direct-message chat via long polling", "setup": { "required_secrets": [ { - "name": "weixin_bot_token", - "prompt": "Connect this channel from the Weixin setup flow. IronClaw stores the bot token after QR login succeeds.", + "name": "wechat_bot_token", + "prompt": "Connect this channel from the WeChat setup flow. IronClaw stores the bot token after QR login succeeds.", "optional": false } ], @@ -25,13 +25,13 @@ } }, "secrets": { - "allowed_names": ["weixin_*"] + "allowed_names": ["wechat_*"] }, "channel": { "allowed_paths": [], "allow_polling": true, "min_poll_interval_ms": 30000, - "workspace_prefix": "channels/weixin/", + "workspace_prefix": "channels/wechat/", "callback_timeout_secs": 45, "emit_rate_limit": { "messages_per_minute": 100, diff --git a/channels-src/weixin/build.sh b/channels-src/weixin/build.sh deleted file mode 100755 index f4a72185..00000000 --- a/channels-src/weixin/build.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -cd "$(dirname "$0")" - -echo "Building Weixin channel WASM component..." - -cargo build --release --target wasm32-wasip2 - -WASM_PATH="target/wasm32-wasip2/release/weixin_channel.wasm" - -if [ -f "$WASM_PATH" ]; then - wasm-tools component new "$WASM_PATH" -o weixin.wasm 2>/dev/null || cp "$WASM_PATH" weixin.wasm - wasm-tools strip weixin.wasm -o weixin.wasm - - echo "Built: weixin.wasm ($(du -h weixin.wasm | cut -f1))" - echo "" - echo "To install:" - echo " mkdir -p ~/.ironclaw/channels" - echo " cp weixin.wasm weixin.capabilities.json ~/.ironclaw/channels/" -else - echo "Error: WASM output not found at $WASM_PATH" - exit 1 -fi diff --git a/docs/plans/2026-03-25-weixin-integration-design.md b/docs/plans/2026-03-25-wechat-integration-design.md similarity index 81% rename from docs/plans/2026-03-25-weixin-integration-design.md rename to docs/plans/2026-03-25-wechat-integration-design.md index ad3b09b0..a6573cfd 100644 --- a/docs/plans/2026-03-25-weixin-integration-design.md +++ b/docs/plans/2026-03-25-wechat-integration-design.md @@ -1,8 +1,8 @@ -# Weixin Integration Design +# WeChat Integration Design **Date:** 2026-03-25 **Status:** Ready for implementation -**Goal:** Add Weixin support to IronClaw using the same upstream iLink Bot protocol as `@tencent-weixin/openclaw-weixin`, while keeping the implementation aligned with IronClaw's extension-first channel architecture. +**Goal:** Add WeChat support to IronClaw using the same upstream iLink Bot protocol as `@tencent-weixin/openclaw-weixin`, while keeping the implementation aligned with IronClaw's extension-first channel architecture. --- @@ -10,7 +10,7 @@ The current upstream npm package is `@tencent-weixin/openclaw-weixin` version `2.0.1`. -From the package README and source, the upstream Weixin channel does all of the following: +From the package README and source, the upstream WeChat channel does all of the following: - logs in by QR code against `https://ilinkai.weixin.qq.com` - receives inbound messages by long-polling `ilink/bot/getupdates` @@ -18,9 +18,9 @@ From the package README and source, the upstream Weixin channel does all of the - uses `ilink/bot/getconfig` and `ilink/bot/sendtyping` for typing indicators - uses `ilink/bot/getuploadurl` for media uploads - persists `get_updates_buf` for long-poll resume -- persists `context_token` so replies stay attached to the right Weixin session -- supports multiple logged-in Weixin bot accounts -- treats Weixin as a direct-message-only channel +- persists `context_token` so replies stay attached to the right WeChat session +- supports multiple logged-in WeChat bot accounts +- treats WeChat as a direct-message-only channel - block-sends replies instead of token streaming This design treats that upstream behavior as the capability boundary. We should not add scope based on features the upstream plugin does not have. @@ -31,7 +31,7 @@ This design treats that upstream behavior as the capability boundary. We should IronClaw should **not** try to load the upstream OpenClaw plugin directly. -Instead, IronClaw should implement a **native channel extension** under `channels-src/weixin/` and only extend the host/runtime where that support is generic and reusable. +Instead, IronClaw should implement a **native channel extension** under `channels-src/wechat/` and only extend the host/runtime where that support is generic and reusable. ### Why not host the npm plugin directly @@ -39,7 +39,7 @@ Instead, IronClaw should implement a **native channel extension** under `channel - It assumes OpenClaw-specific lifecycle concepts such as `gateway.startAccount`. - Recreating an OpenClaw-compatible Node plugin host inside IronClaw would be more work and more fragile than implementing the protocol directly. -### Why `channels-src/weixin/` +### Why `channels-src/wechat/` - It matches the existing layering used by other platform channels. - It keeps platform protocol logic out of host-owned core modules. @@ -49,10 +49,10 @@ Recommended layout: ```text channels-src/ - weixin/ + wechat/ Cargo.toml build.sh - weixin.capabilities.json + wechat.capabilities.json src/ lib.rs api.rs @@ -65,21 +65,21 @@ channels-src/ ## Phase 1 Scope -Phase 1 is a **single-account** implementation of the upstream Weixin channel. +Phase 1 is a **single-account** implementation of the upstream WeChat channel. The point of this phase is to keep the channel aligned with upstream behavior while removing the one biggest source of host/runtime complexity: multi-account lifecycle. ### Must-have in Phase 1 - QR code login -- one connected Weixin bot account +- one connected WeChat bot account - direct-message text receive/send - `getupdates` long-poll loop - `sendmessage` outbound replies - `context_token` persistence - `get_updates_buf` persistence - login persistence across restart -- extension-first packaging under `channels-src/weixin/` +- extension-first packaging under `channels-src/wechat/` ### Explicit simplification from upstream @@ -100,7 +100,7 @@ We should not spend time listing non-goals that come from outside the upstream c ```mermaid flowchart LR - A["Core WASM channel host"] --> B["Weixin channel extension"] + A["Core WASM channel host"] --> B["WeChat channel extension"] C["Generic login UI/API"] --> D["QR login session"] D --> E["Secret storage"] E --> B @@ -112,14 +112,14 @@ flowchart LR ### Extension responsibilities -`channels-src/weixin/` should own: +`channels-src/wechat/` should own: - iLink API request/response types - QR login protocol calls - long-polling `getupdates` - `context_token` storage and lookup - outbound `sendmessage` -- Weixin-specific status/error mapping +- WeChat-specific status/error mapping ### Host responsibilities @@ -139,7 +139,7 @@ Phase 1 is single-account, so state should stay simple. ### Secrets -- `weixin_bot_token` +- `wechat_bot_token` This is written after QR login succeeds and reused on restart. @@ -150,16 +150,16 @@ Under the channel workspace prefix, persist: - `state/get_updates_buf.json` - `state/context_tokens.json` -`context_tokens.json` maps the Weixin peer to its latest `context_token`. +`context_tokens.json` maps the WeChat peer to its latest `context_token`. ### Inbound message mapping -For each inbound Weixin DM: +For each inbound WeChat DM: -- `channel = "weixin"` -- `user_id = ` or owner scope if it is the bound owner -- `thread_id = Some("weixin:")` -- `conversation_scope_id = Some("weixin:")` +- `channel = "wechat"` +- `user_id = ` or owner scope if it is the bound owner +- `thread_id = Some("wechat:")` +- `conversation_scope_id = Some("wechat:")` `metadata_json` should include: @@ -184,22 +184,22 @@ Minimum host support needed: 4. On success, write the returned token to channel secrets. 5. Reload or reactivate the channel so polling starts automatically. -This should be added as a generic channel-auth capability, not as Weixin-specific core logic. +This should be added as a generic channel-auth capability, not as WeChat-specific core logic. --- ## User Flow -Phase 1 should be **web-first**, because the target user is a normal Weixin user rather than a CLI-only operator. +Phase 1 should be **web-first**, because the target user is a normal WeChat user rather than a CLI-only operator. -1. Install or enable the `weixin` channel extension. -2. Click "Connect Weixin". +1. Install or enable the `wechat` channel extension. +2. Click "Connect WeChat". 3. Web UI requests a login session from the host. 4. Web UI displays the QR code. 5. User scans and confirms on their phone. -6. Host stores `weixin_bot_token`. +6. Host stores `wechat_bot_token`. 7. Channel reloads and starts polling. -8. User sends a DM in Weixin and receives IronClaw replies there. +8. User sends a DM in WeChat and receives IronClaw replies there. CLI support can still exist for development, but it should not be the primary Phase 1 UX. @@ -259,9 +259,3 @@ After Phase 1 is stable, add the upstream features we intentionally deferred: - multi-account support - typing indicators - media upload/send - ---- - -## Open Question - -- Should the public channel name remain `weixin`, or should we preserve an upstream-flavored alias for discoverability? diff --git a/registry/_bundles.json b/registry/_bundles.json index 0d7577e6..04cd0bb6 100644 --- a/registry/_bundles.json +++ b/registry/_bundles.json @@ -15,14 +15,14 @@ }, "messaging": { "display_name": "Messaging Channels", - "description": "Discord, Telegram, Slack, and WhatsApp channels", + "description": "Discord, Telegram, Slack, WhatsApp, and WeChat channels", "extensions": [ "channels/discord", "channels/telegram", "channels/slack", "channels/whatsapp", "channels/feishu", - "channels/weixin" + "channels/wechat" ], "shared_auth": null }, diff --git a/registry/channels/weixin.json b/registry/channels/wechat.json similarity index 54% rename from registry/channels/weixin.json rename to registry/channels/wechat.json index 0bc0be08..28a1c7a9 100644 --- a/registry/channels/weixin.json +++ b/registry/channels/wechat.json @@ -1,27 +1,27 @@ { - "name": "weixin", - "display_name": "Weixin Channel", + "name": "wechat", + "display_name": "WeChat Channel", "kind": "channel", "version": "0.1.0", "wit_version": "0.3.0", - "description": "Talk to your agent through a Weixin iLink bot account", + "description": "Talk to your agent through a WeChat iLink bot account", "keywords": [ "messaging", "chat", - "weixin", + "wechat", "wechat", "qr" ], "source": { - "dir": "channels-src/weixin", - "capabilities": "weixin.capabilities.json", - "crate_name": "weixin-channel" + "dir": "channels-src/wechat", + "capabilities": "wechat.capabilities.json", + "crate_name": "wechat-channel" }, "auth_summary": { "method": "interactive", - "provider": "Weixin", + "provider": "WeChat", "secrets": [ - "weixin_bot_token" + "wechat_bot_token" ], "shared_auth": null, "setup_url": "https://ilinkai.weixin.qq.com" diff --git a/src/channels/wasm/setup.rs b/src/channels/wasm/setup.rs index 95346d04..a13a8669 100644 --- a/src/channels/wasm/setup.rs +++ b/src/channels/wasm/setup.rs @@ -470,7 +470,7 @@ async fn inject_channel_settings_into_config( }; let setting_mappings: &[(&str, &str)] = match channel_name { - "weixin" => &[("base_url", "extensions.weixin.base_url")], + "wechat" => &[("base_url", "extensions.wechat.base_url")], _ => return, }; @@ -505,7 +505,7 @@ mod tests { #[tokio::test] async fn test_inject_channel_settings_uses_owner_scope() -> Result<(), String> { let dir = tempfile::tempdir().map_err(|e| format!("tempdir failed: {e}"))?; - let db_path = dir.path().join("weixin-settings.db"); + let db_path = dir.path().join("wechat-settings.db"); let db = Arc::new( crate::db::libsql::LibSqlBackend::new_local(&db_path) .await @@ -517,14 +517,14 @@ mod tests { db.set_setting( "default", - "extensions.weixin.base_url", + "extensions.wechat.base_url", &serde_json::json!("https://default.example"), ) .await .map_err(|e| format!("persist default setting failed: {e}"))?; db.set_setting( "owner-123", - "extensions.weixin.base_url", + "extensions.wechat.base_url", &serde_json::json!("https://owner.example"), ) .await @@ -533,7 +533,7 @@ mod tests { let settings_store: Arc = db; let mut config_updates = std::collections::HashMap::new(); super::inject_channel_settings_into_config( - "weixin", + "wechat", "owner-123", Some(&settings_store), &mut config_updates, diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 2e283c01..fb8ab15d 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -3096,29 +3096,29 @@ mod tests { } #[tokio::test] - async fn test_extensions_setup_returns_interactive_login_for_weixin() { + async fn test_extensions_setup_returns_interactive_login_for_wechat() { use axum::body::Body; use tower::ServiceExt; let secrets = test_secrets_store(); let (ext_mgr, _wasm_tools_dir, wasm_channels_dir) = test_ext_mgr(secrets); - std::fs::write(wasm_channels_dir.path().join("weixin.wasm"), b"\0asm fake") - .expect("write fake weixin wasm"); + std::fs::write(wasm_channels_dir.path().join("wechat.wasm"), b"\0asm fake") + .expect("write fake wechat wasm"); let caps = serde_json::json!({ "type": "channel", - "name": "weixin", + "name": "wechat", "setup": { "required_secrets": [ - {"name": "weixin_bot_token", "prompt": "Connect Weixin"} + {"name": "wechat_bot_token", "prompt": "Connect WeChat"} ] } }); std::fs::write( - wasm_channels_dir.path().join("weixin.capabilities.json"), - serde_json::to_string(&caps).expect("serialize weixin caps"), + wasm_channels_dir.path().join("wechat.capabilities.json"), + serde_json::to_string(&caps).expect("serialize wechat caps"), ) - .expect("write weixin capabilities"); + .expect("write wechat capabilities"); let state = test_gateway_state(Some(ext_mgr)); let app = Router::new() @@ -3130,7 +3130,7 @@ mod tests { let mut req = axum::http::Request::builder() .method("GET") - .uri("/api/extensions/weixin/setup") + .uri("/api/extensions/wechat/setup") .body(Body::empty()) .expect("request"); req.extensions_mut().insert(UserIdentity { @@ -3148,11 +3148,11 @@ mod tests { .expect("body"); let parsed: serde_json::Value = serde_json::from_slice(&body).expect("json response"); - assert_eq!(parsed["name"], "weixin"); + assert_eq!(parsed["name"], "wechat"); assert_eq!(parsed["interactive_login"]["method"], "qr_code"); assert_eq!( parsed["interactive_login"]["button_label"], - "Connect Weixin" + "Connect WeChat" ); assert_eq!(parsed["secrets"], serde_json::json!([])); assert_eq!(parsed["fields"], serde_json::json!([])); @@ -3160,7 +3160,7 @@ mod tests { #[cfg(feature = "libsql")] #[tokio::test] - async fn test_extensions_weixin_login_poll_broadcasts_auth_completed_and_activates() { + async fn test_extensions_wechat_login_poll_broadcasts_auth_completed_and_activates() { use axum::body::Body; use tokio::time::{Duration, timeout}; use tower::ServiceExt; @@ -3169,19 +3169,19 @@ mod tests { let (ext_mgr, _wasm_tools_dir, wasm_channels_dir, db, _db_tmp) = test_ext_mgr_with_db(secrets.clone()).await; - std::fs::write(wasm_channels_dir.path().join("weixin.wasm"), b"\0asm fake") - .expect("write fake weixin wasm"); + std::fs::write(wasm_channels_dir.path().join("wechat.wasm"), b"\0asm fake") + .expect("write fake wechat wasm"); let caps = serde_json::json!({ "type": "channel", - "name": "weixin", + "name": "wechat", "setup": { "required_secrets": [ - {"name": "weixin_bot_token", "prompt": "Connect Weixin"} + {"name": "wechat_bot_token", "prompt": "Connect WeChat"} ] }, "capabilities": { "channel": { - "allowed_paths": ["/webhook/weixin"] + "allowed_paths": ["/webhook/wechat"] } }, "config": { @@ -3190,10 +3190,10 @@ mod tests { } }); std::fs::write( - wasm_channels_dir.path().join("weixin.capabilities.json"), - serde_json::to_string(&caps).expect("serialize weixin caps"), + wasm_channels_dir.path().join("wechat.capabilities.json"), + serde_json::to_string(&caps).expect("serialize wechat caps"), ) - .expect("write weixin capabilities"); + .expect("write wechat capabilities"); let channel_manager = Arc::new(crate::channels::ChannelManager::new()); let runtime = Arc::new( @@ -3227,11 +3227,11 @@ mod tests { })) .await; ext_mgr - .set_test_weixin_login_starter(Arc::new(|user_id, base_url, bot_type| { + .set_test_wechat_login_starter(Arc::new(|user_id, base_url, bot_type| { Ok(( - crate::extensions::weixin_login::PendingWeixinLogin { + crate::extensions::wechat_login::PendingWechatLogin { user_id: user_id.to_string(), - session_id: "weixin-session-42".to_string(), + session_id: "wechat-session-42".to_string(), qrcode: "qr-42".to_string(), qr_code_url: "https://qr.example/42".to_string(), started_at: std::time::Instant::now(), @@ -3240,9 +3240,9 @@ mod tests { refresh_count: 0, }, crate::extensions::InteractiveLoginStartResult { - session_id: "weixin-session-42".to_string(), + session_id: "wechat-session-42".to_string(), status: "pending".to_string(), - message: "Scan the QR code in Weixin to finish connecting.".to_string(), + message: "Scan the QR code in WeChat to finish connecting.".to_string(), qr_code_url: Some("https://qr.example/42".to_string()), instructions: Some( "Keep this window open while you scan and confirm on your phone." @@ -3253,8 +3253,8 @@ mod tests { })) .await; ext_mgr - .set_test_weixin_login_poller(Arc::new(|session| { - if session.session_id != "weixin-session-42" { + .set_test_wechat_login_poller(Arc::new(|session| { + if session.session_id != "wechat-session-42" { return Err(crate::extensions::ExtensionError::Other(format!( "unexpected session id: {}", session.session_id @@ -3262,10 +3262,10 @@ mod tests { } Ok( - crate::extensions::weixin_login::WeixinLoginPollOutcome::Confirmed( - crate::extensions::weixin_login::ConfirmedWeixinLogin { - bot_token: "weixin-token-42".to_string(), - base_url: Some("https://weixin.example".to_string()), + crate::extensions::wechat_login::WechatLoginPollOutcome::Confirmed( + crate::extensions::wechat_login::ConfirmedWechatLogin { + bot_token: "wechat-token-42".to_string(), + base_url: Some("https://wechat.example".to_string()), ilink_bot_id: "wx-bot-42".to_string(), }, ), @@ -3288,7 +3288,7 @@ mod tests { let mut start_req = axum::http::Request::builder() .method("POST") - .uri("/api/extensions/weixin/login/start") + .uri("/api/extensions/wechat/login/start") .header("content-type", "application/json") .body(Body::from(r#"{"force":true}"#)) .expect("start request"); @@ -3308,14 +3308,14 @@ mod tests { serde_json::from_slice(&start_body).expect("start json response"); assert_eq!(start_json["success"], serde_json::Value::Bool(true)); assert_eq!(start_json["status"], "pending"); - assert_eq!(start_json["session_id"], "weixin-session-42"); + assert_eq!(start_json["session_id"], "wechat-session-42"); assert_eq!(start_json["qr_code_url"], "https://qr.example/42"); let mut poll_req = axum::http::Request::builder() .method("POST") - .uri("/api/extensions/weixin/login/poll") + .uri("/api/extensions/wechat/login/poll") .header("content-type", "application/json") - .body(Body::from(r#"{"session_id":"weixin-session-42"}"#)) + .body(Body::from(r#"{"session_id":"wechat-session-42"}"#)) .expect("poll request"); poll_req.extensions_mut().insert(UserIdentity { user_id: "test".to_string(), @@ -3338,7 +3338,7 @@ mod tests { poll_json["message"] .as_str() .unwrap_or_default() - .contains("Weixin connected as wx-bot-42"), + .contains("WeChat connected as wx-bot-42"), "unexpected poll message: {poll_json:?}" ); @@ -3359,26 +3359,26 @@ mod tests { }) .await .expect("timed out waiting for auth_completed"); - assert_eq!(auth_completed.0, "weixin"); + assert_eq!(auth_completed.0, "wechat"); assert!(auth_completed.1); - assert!(auth_completed.2.contains("Weixin connected as wx-bot-42")); + assert!(auth_completed.2.contains("WeChat connected as wx-bot-42")); assert!( secrets - .exists("test", "weixin_bot_token") + .exists("test", "wechat_bot_token") .await - .expect("check weixin secret"), - "weixin token should be stored after successful poll" + .expect("check wechat secret"), + "wechat token should be stored after successful poll" ); assert!( - channel_manager.get_channel("weixin").await.is_some(), - "weixin should be hot-added after successful poll" + channel_manager.get_channel("wechat").await.is_some(), + "wechat should be hot-added after successful poll" ); assert_eq!( - db.get_setting("test", "extensions.weixin.base_url") + db.get_setting("test", "extensions.wechat.base_url") .await - .expect("get weixin base_url setting"), - Some(serde_json::json!("https://weixin.example")) + .expect("get wechat base_url setting"), + Some(serde_json::json!("https://wechat.example")) ); } diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index f9cc418a..c362b293 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -3251,7 +3251,7 @@ function renderInteractiveLoginPanel() { const title = document.createElement('div'); title.className = 'configure-verification-title'; - title.textContent = 'Weixin QR Login'; + title.textContent = 'WeChat QR Login'; panel.appendChild(title); const status = document.createElement('div'); @@ -3262,7 +3262,7 @@ function renderInteractiveLoginPanel() { const img = document.createElement('img'); img.className = 'configure-qr-image'; - img.alt = 'Weixin QR code'; + img.alt = 'WeChat QR code'; img.style.display = 'none'; img.dataset.qrImage = 'true'; panel.appendChild(img); @@ -3322,7 +3322,7 @@ function setInteractiveLoginBusy(overlay, busy, label) { function startInteractiveLogin(name, overlay) { if (!overlay || !document.body.contains(overlay)) return; clearConfigureInlineError(overlay); - setConfigureInlineStatus(overlay, 'Generating Weixin QR code...'); + setConfigureInlineStatus(overlay, 'Generating WeChat QR code...'); setInteractiveLoginBusy(overlay, true, 'Waiting for scan...'); apiFetch('/api/extensions/' + encodeURIComponent(name) + '/login/start', { @@ -3332,7 +3332,7 @@ function startInteractiveLogin(name, overlay) { .then((res) => { if (!overlay || !document.body.contains(overlay)) return; if (!res.success || !res.session_id) { - setInteractiveLoginBusy(overlay, false, 'Connect Weixin'); + setInteractiveLoginBusy(overlay, false, 'Connect WeChat'); setConfigureInlineError(overlay, res.message || 'Failed to start interactive login'); setConfigureInlineStatus(overlay, ''); return; @@ -3345,7 +3345,7 @@ function startInteractiveLogin(name, overlay) { }) .catch((err) => { if (!overlay || !document.body.contains(overlay)) return; - setInteractiveLoginBusy(overlay, false, 'Connect Weixin'); + setInteractiveLoginBusy(overlay, false, 'Connect WeChat'); setConfigureInlineError(overlay, err.message || 'Failed to start interactive login'); setConfigureInlineStatus(overlay, ''); }); @@ -3387,14 +3387,14 @@ function pollInteractiveLogin(name, overlay, sessionId) { return; } - setInteractiveLoginBusy(overlay, false, 'Connect Weixin'); + setInteractiveLoginBusy(overlay, false, 'Connect WeChat'); setConfigureInlineError(overlay, res.message || 'Interactive login failed'); setConfigureInlineStatus(overlay, ''); }) .catch((err) => { if (!overlay || !document.body.contains(overlay)) return; if (overlay.dataset.interactiveLoginSessionId !== sessionId) return; - setInteractiveLoginBusy(overlay, false, 'Connect Weixin'); + setInteractiveLoginBusy(overlay, false, 'Connect WeChat'); setConfigureInlineError(overlay, err.message || 'Interactive login failed'); setConfigureInlineStatus(overlay, ''); }); diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index 878f22ae..85d79c5b 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -17,11 +17,11 @@ use crate::channels::wasm::{ use crate::channels::{ChannelManager, OutgoingResponse}; use crate::extensions::discovery::OnlineDiscovery; use crate::extensions::registry::ExtensionRegistry; -use crate::extensions::weixin_login::{ - PendingWeixinLogin, WEIXIN_BASE_URL_SETTING_PATH, WEIXIN_CHANNEL_NAME, WEIXIN_DEFAULT_BASE_URL, - WEIXIN_DEFAULT_BOT_TYPE, WeixinLoginPollOutcome, - interactive_login_info as weixin_interactive_login_info, poll_login as poll_weixin_login, - purge_expired_logins as purge_expired_weixin_logins, start_login as start_weixin_login, +use crate::extensions::wechat_login::{ + PendingWechatLogin, WECHAT_BASE_URL_SETTING_PATH, WECHAT_CHANNEL_NAME, WECHAT_DEFAULT_BASE_URL, + WECHAT_DEFAULT_BOT_TYPE, WechatLoginPollOutcome, + interactive_login_info as wechat_interactive_login_info, poll_login as poll_wechat_login, + purge_expired_logins as purge_expired_wechat_logins, start_login as start_wechat_login, }; use crate::extensions::{ ActivateResult, AuthResult, ConfigureResult, ExtensionError, ExtensionKind, ExtensionSource, @@ -121,18 +121,18 @@ type TestWasmChannelLoader = type TestTelegramBindingResolver = Arc) -> Result + Send + Sync>; #[cfg(test)] -type TestWeixinLoginStarter = Arc< +type TestWechatLoginStarter = Arc< dyn Fn( &str, &str, &str, - ) -> Result<(PendingWeixinLogin, InteractiveLoginStartResult), ExtensionError> + ) -> Result<(PendingWechatLogin, InteractiveLoginStartResult), ExtensionError> + Send + Sync, >; #[cfg(test)] -type TestWeixinLoginPoller = Arc< - dyn Fn(&mut PendingWeixinLogin) -> Result + Send + Sync, +type TestWechatLoginPoller = Arc< + dyn Fn(&mut PendingWechatLogin) -> Result + Send + Sync, >; const TELEGRAM_OWNER_BIND_TIMEOUT_SECS: u64 = 120; @@ -451,15 +451,15 @@ pub struct ExtensionManager { /// Set by the web gateway at startup via `enable_gateway_mode()`. gateway_base_url: RwLock>, pending_telegram_verification: RwLock>, - pending_weixin_logins: RwLock>, + pending_wechat_logins: RwLock>, #[cfg(test)] test_wasm_channel_loader: RwLock>, #[cfg(test)] test_telegram_binding_resolver: RwLock>, #[cfg(test)] - test_weixin_login_starter: RwLock>, + test_wechat_login_starter: RwLock>, #[cfg(test)] - test_weixin_login_poller: RwLock>, + test_wechat_login_poller: RwLock>, } /// Sanitize a URL for logging by removing query parameters and credentials. @@ -569,15 +569,15 @@ impl ExtensionManager { gateway_mode: std::sync::atomic::AtomicBool::new(false), gateway_base_url: RwLock::new(None), pending_telegram_verification: RwLock::new(HashMap::new()), - pending_weixin_logins: RwLock::new(HashMap::new()), + pending_wechat_logins: RwLock::new(HashMap::new()), #[cfg(test)] test_wasm_channel_loader: RwLock::new(None), #[cfg(test)] test_telegram_binding_resolver: RwLock::new(None), #[cfg(test)] - test_weixin_login_starter: RwLock::new(None), + test_wechat_login_starter: RwLock::new(None), #[cfg(test)] - test_weixin_login_poller: RwLock::new(None), + test_wechat_login_poller: RwLock::new(None), } } @@ -592,13 +592,13 @@ impl ExtensionManager { } #[cfg(test)] - pub(crate) async fn set_test_weixin_login_starter(&self, starter: TestWeixinLoginStarter) { - *self.test_weixin_login_starter.write().await = Some(starter); + pub(crate) async fn set_test_wechat_login_starter(&self, starter: TestWechatLoginStarter) { + *self.test_wechat_login_starter.write().await = Some(starter); } #[cfg(test)] - pub(crate) async fn set_test_weixin_login_poller(&self, poller: TestWeixinLoginPoller) { - *self.test_weixin_login_poller.write().await = Some(poller); + pub(crate) async fn set_test_wechat_login_poller(&self, poller: TestWechatLoginPoller) { + *self.test_wechat_login_poller.write().await = Some(poller); } #[cfg(test)] @@ -822,10 +822,10 @@ impl ExtensionManager { overrides.insert("bot_username".to_string(), serde_json::json!(username)); } - if name == WEIXIN_CHANNEL_NAME + if name == WECHAT_CHANNEL_NAME && let Some(store) = self.store.as_ref() && let Ok(Some(serde_json::Value::String(base_url))) = store - .get_setting(&self.user_id, WEIXIN_BASE_URL_SETTING_PATH) + .get_setting(&self.user_id, WECHAT_BASE_URL_SETTING_PATH) .await && !base_url.trim().is_empty() { @@ -3581,11 +3581,11 @@ impl ExtensionManager { return Ok(AuthResult::authenticated(name, ExtensionKind::WasmChannel)); } - if name == WEIXIN_CHANNEL_NAME { + if name == WECHAT_CHANNEL_NAME { return Ok(AuthResult::awaiting_token( name, ExtensionKind::WasmChannel, - "Open the Weixin channel setup to scan a QR code and connect it.".to_string(), + "Open the WeChat channel setup to scan a QR code and connect it.".to_string(), cap_file.setup.setup_url.clone(), )); } @@ -4561,8 +4561,8 @@ impl ExtensionManager { !expired }); - let mut weixin_logins = self.pending_weixin_logins.write().await; - purge_expired_weixin_logins(&mut weixin_logins); + let mut wechat_logins = self.pending_wechat_logins.write().await; + purge_expired_wechat_logins(&mut wechat_logins); } fn interactive_login_info_for_extension( @@ -4570,8 +4570,8 @@ impl ExtensionManager { kind: ExtensionKind, ) -> Option { match (kind, name) { - (ExtensionKind::WasmChannel, WEIXIN_CHANNEL_NAME) => { - Some(weixin_interactive_login_info()) + (ExtensionKind::WasmChannel, WECHAT_CHANNEL_NAME) => { + Some(wechat_interactive_login_info()) } _ => None, } @@ -4607,11 +4607,11 @@ impl ExtensionManager { crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes) .map_err(|e| ExtensionError::Other(e.to_string()))?; - if name == WEIXIN_CHANNEL_NAME { + if name == WECHAT_CHANNEL_NAME { return Ok(ExtensionSetupSchema { secrets: Vec::new(), fields: Vec::new(), - interactive_login: Some(weixin_interactive_login_info()), + interactive_login: Some(wechat_interactive_login_info()), }); } @@ -4697,10 +4697,10 @@ impl ExtensionManager { } } - async fn resolve_weixin_base_url(&self, user_id: &str) -> String { + async fn resolve_wechat_base_url(&self, user_id: &str) -> String { if let Some(store) = &self.store && let Ok(Some(serde_json::Value::String(value))) = store - .get_setting(user_id, WEIXIN_BASE_URL_SETTING_PATH) + .get_setting(user_id, WECHAT_BASE_URL_SETTING_PATH) .await { let trimmed = value.trim(); @@ -4711,7 +4711,7 @@ impl ExtensionManager { let cap_path = self .wasm_channels_dir - .join(format!("{}.capabilities.json", WEIXIN_CHANNEL_NAME)); + .join(format!("{}.capabilities.json", WECHAT_CHANNEL_NAME)); if let Ok(cap_bytes) = tokio::fs::read(&cap_path).await && let Ok(cap_file) = crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes) @@ -4726,13 +4726,13 @@ impl ExtensionManager { } } - WEIXIN_DEFAULT_BASE_URL.to_string() + WECHAT_DEFAULT_BASE_URL.to_string() } - async fn resolve_weixin_bot_type(&self) -> String { + async fn resolve_wechat_bot_type(&self) -> String { let cap_path = self .wasm_channels_dir - .join(format!("{}.capabilities.json", WEIXIN_CHANNEL_NAME)); + .join(format!("{}.capabilities.json", WECHAT_CHANNEL_NAME)); if let Ok(cap_bytes) = tokio::fs::read(&cap_path).await && let Ok(cap_file) = crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes) @@ -4747,7 +4747,7 @@ impl ExtensionManager { } } - WEIXIN_DEFAULT_BOT_TYPE.to_string() + WECHAT_DEFAULT_BOT_TYPE.to_string() } pub async fn start_interactive_login( @@ -4764,7 +4764,7 @@ impl ExtensionManager { ))); } - if name != WEIXIN_CHANNEL_NAME { + if name != WECHAT_CHANNEL_NAME { return Err(ExtensionError::AuthNotSupported(format!( "Interactive login is not implemented for '{}'", name @@ -4773,21 +4773,21 @@ impl ExtensionManager { self.cleanup_expired_auths().await; - let base_url = self.resolve_weixin_base_url(user_id).await; - let bot_type = self.resolve_weixin_bot_type().await; + let base_url = self.resolve_wechat_base_url(user_id).await; + let bot_type = self.resolve_wechat_bot_type().await; #[cfg(test)] let login_result = - if let Some(starter) = self.test_weixin_login_starter.read().await.as_ref() { + if let Some(starter) = self.test_wechat_login_starter.read().await.as_ref() { starter(user_id, &base_url, &bot_type) } else { - start_weixin_login(user_id, &base_url, &bot_type).await + start_wechat_login(user_id, &base_url, &bot_type).await }; #[cfg(not(test))] - let login_result = start_weixin_login(user_id, &base_url, &bot_type).await; + let login_result = start_wechat_login(user_id, &base_url, &bot_type).await; let (session, result) = login_result?; - self.pending_weixin_logins + self.pending_wechat_logins .write() .await .insert(session.session_id.clone(), session); @@ -4810,7 +4810,7 @@ impl ExtensionManager { ))); } - if name != WEIXIN_CHANNEL_NAME { + if name != WECHAT_CHANNEL_NAME { return Err(ExtensionError::AuthNotSupported(format!( "Interactive login is not implemented for '{}'", name @@ -4819,35 +4819,35 @@ impl ExtensionManager { self.cleanup_expired_auths().await; - let mut sessions = self.pending_weixin_logins.write().await; + let mut sessions = self.pending_wechat_logins.write().await; let Some(session) = sessions.get_mut(session_id) else { return Err(ExtensionError::Other( - "This Weixin login session no longer exists. Start again.".to_string(), + "This WeChat login session no longer exists. Start again.".to_string(), )); }; if session.user_id != user_id { return Err(ExtensionError::AuthFailed( - "This Weixin login session belongs to another user".to_string(), + "This WeChat login session belongs to another user".to_string(), )); } #[cfg(test)] - let outcome = if let Some(poller) = self.test_weixin_login_poller.read().await.as_ref() { + let outcome = if let Some(poller) = self.test_wechat_login_poller.read().await.as_ref() { poller(session) } else { - poll_weixin_login(session).await + poll_wechat_login(session).await }?; #[cfg(not(test))] - let outcome = poll_weixin_login(session).await?; + let outcome = poll_wechat_login(session).await?; match outcome { - WeixinLoginPollOutcome::Pending(result) => { + WechatLoginPollOutcome::Pending(result) => { if matches!(result.status.as_str(), "failed") { sessions.remove(session_id); } Ok(result) } - WeixinLoginPollOutcome::Confirmed(confirmed) => { + WechatLoginPollOutcome::Confirmed(confirmed) => { sessions.remove(session_id); drop(sessions); @@ -4857,14 +4857,14 @@ impl ExtensionManager { let _ = store .set_setting( user_id, - WEIXIN_BASE_URL_SETTING_PATH, + WECHAT_BASE_URL_SETTING_PATH, &serde_json::Value::String(base_url.to_string()), ) .await; } let mut secrets = std::collections::HashMap::new(); - secrets.insert("weixin_bot_token".to_string(), confirmed.bot_token); + secrets.insert("wechat_bot_token".to_string(), confirmed.bot_token); let configure = self .configure(name, &secrets, &std::collections::HashMap::new(), user_id) .await?; @@ -4878,12 +4878,12 @@ impl ExtensionManager { }, message: if configure.activated { format!( - "Weixin connected as {}. {}", + "WeChat connected as {}. {}", confirmed.ilink_bot_id, configure.message ) } else { format!( - "Weixin login succeeded for {} but activation failed: {}", + "WeChat login succeeded for {} but activation failed: {}", confirmed.ilink_bot_id, configure.message ) }, @@ -5994,9 +5994,9 @@ mod tests { normalize_hosted_callback_url, send_telegram_text_message, telegram_message_matches_verification_code, }; - use crate::extensions::weixin_login::{ - ConfirmedWeixinLogin, PendingWeixinLogin, WEIXIN_BASE_URL_SETTING_PATH, - WeixinLoginPollOutcome, + use crate::extensions::wechat_login::{ + ConfirmedWechatLogin, PendingWechatLogin, WECHAT_BASE_URL_SETTING_PATH, + WechatLoginPollOutcome, }; use crate::extensions::{ ExtensionError, ExtensionKind, ExtensionSource, InstallResult, InteractiveLoginStartResult, @@ -6950,30 +6950,30 @@ mod tests { #[cfg(feature = "libsql")] #[tokio::test] - async fn test_weixin_interactive_login_poll_persists_state_and_activates() -> Result<(), String> + async fn test_wechat_interactive_login_poll_persists_state_and_activates() -> Result<(), String> { let dir = tempfile::tempdir().map_err(|err| format!("temp dir: {err}"))?; let channels_dir = dir.path().join("channels"); std::fs::create_dir_all(&channels_dir).map_err(|err| format!("channels dir: {err}"))?; - std::fs::write(channels_dir.join("weixin.wasm"), b"mock") + std::fs::write(channels_dir.join("wechat.wasm"), b"mock") .map_err(|err| format!("write wasm: {err}"))?; std::fs::write( - channels_dir.join("weixin.capabilities.json"), + channels_dir.join("wechat.capabilities.json"), serde_json::to_vec(&serde_json::json!({ "type": "channel", - "name": "weixin", + "name": "wechat", "setup": { "required_secrets": [ { - "name": "weixin_bot_token", - "prompt": "Connect Weixin", + "name": "wechat_bot_token", + "prompt": "Connect WeChat", "optional": false } ] }, "capabilities": { "channel": { - "allowed_paths": ["/webhook/weixin"] + "allowed_paths": ["/webhook/wechat"] } }, "config": { @@ -7047,11 +7047,11 @@ mod tests { })) .await; manager - .set_test_weixin_login_starter(Arc::new(|user_id, base_url, bot_type| { + .set_test_wechat_login_starter(Arc::new(|user_id, base_url, bot_type| { Ok(( - PendingWeixinLogin { + PendingWechatLogin { user_id: user_id.to_string(), - session_id: "weixin-session-1".to_string(), + session_id: "wechat-session-1".to_string(), qrcode: "qr-123".to_string(), qr_code_url: "https://qr.example/one".to_string(), started_at: std::time::Instant::now(), @@ -7060,9 +7060,9 @@ mod tests { refresh_count: 0, }, InteractiveLoginStartResult { - session_id: "weixin-session-1".to_string(), + session_id: "wechat-session-1".to_string(), status: "pending".to_string(), - message: "Scan the QR code in Weixin to finish connecting.".to_string(), + message: "Scan the QR code in WeChat to finish connecting.".to_string(), qr_code_url: Some("https://qr.example/one".to_string()), instructions: Some( "Keep this window open while you scan and confirm on your phone." @@ -7073,75 +7073,75 @@ mod tests { })) .await; manager - .set_test_weixin_login_poller(Arc::new(|session| { - if session.session_id != "weixin-session-1" { + .set_test_wechat_login_poller(Arc::new(|session| { + if session.session_id != "wechat-session-1" { return Err(ExtensionError::Other(format!( "unexpected session id: {}", session.session_id ))); } - Ok(WeixinLoginPollOutcome::Confirmed(ConfirmedWeixinLogin { - bot_token: "weixin-token-123".to_string(), - base_url: Some("https://weixin.example".to_string()), + Ok(WechatLoginPollOutcome::Confirmed(ConfirmedWechatLogin { + bot_token: "wechat-token-123".to_string(), + base_url: Some("https://wechat.example".to_string()), ilink_bot_id: "wx-bot-1".to_string(), })) })) .await; let start = manager - .start_interactive_login("weixin", "test") + .start_interactive_login("wechat", "test") .await .map_err(|err| format!("start interactive login: {err}"))?; require_eq( start.session_id.clone(), - "weixin-session-1".to_string(), + "wechat-session-1".to_string(), "start session id", )?; require_eq(start.status, "pending".to_string(), "start status")?; let poll = manager - .poll_interactive_login("weixin", &start.session_id, "test") + .poll_interactive_login("wechat", &start.session_id, "test") .await .map_err(|err| format!("poll interactive login: {err}"))?; require_eq(poll.status, "succeeded".to_string(), "poll status")?; require_eq(poll.activated, Some(true), "poll activated")?; require( - poll.message.contains("Weixin connected as wx-bot-1"), + poll.message.contains("WeChat connected as wx-bot-1"), format!("unexpected poll message: {}", poll.message), )?; require( - manager.active_channel_names.read().await.contains("weixin"), - "weixin should be marked active after successful login", + manager.active_channel_names.read().await.contains("wechat"), + "wechat should be marked active after successful login", )?; require( - channel_manager.get_channel("weixin").await.is_some(), - "weixin should be hot-added to the running channel manager", + channel_manager.get_channel("wechat").await.is_some(), + "wechat should be hot-added to the running channel manager", )?; require_eq( manager.load_persisted_active_channels("test").await, - vec!["weixin".to_string()], + vec!["wechat".to_string()], "persisted active channels", )?; require( manager .secrets - .exists("test", "weixin_bot_token") + .exists("test", "wechat_bot_token") .await - .map_err(|err| format!("check stored weixin token: {err}"))?, - "weixin bot token should be stored after successful login", + .map_err(|err| format!("check stored wechat token: {err}"))?, + "wechat bot token should be stored after successful login", )?; let persisted_base_url = manager .store .as_ref() .ok_or_else(|| "db-backed manager missing".to_string())? - .get_setting("test", WEIXIN_BASE_URL_SETTING_PATH) + .get_setting("test", WECHAT_BASE_URL_SETTING_PATH) .await - .map_err(|err| format!("weixin base_url setting query: {err}"))?; + .map_err(|err| format!("wechat base_url setting query: {err}"))?; require_eq( persisted_base_url, - Some(serde_json::json!("https://weixin.example")), - "weixin base_url setting", + Some(serde_json::json!("https://wechat.example")), + "wechat base_url setting", ) } diff --git a/src/extensions/mod.rs b/src/extensions/mod.rs index 5e852254..96bf242b 100644 --- a/src/extensions/mod.rs +++ b/src/extensions/mod.rs @@ -19,7 +19,7 @@ pub mod discovery; pub mod manager; pub mod registry; -pub(crate) mod weixin_login; +pub(crate) mod wechat_login; pub use discovery::OnlineDiscovery; pub use manager::ExtensionManager; diff --git a/src/extensions/weixin_login.rs b/src/extensions/wechat_login.rs similarity index 82% rename from src/extensions/weixin_login.rs rename to src/extensions/wechat_login.rs index f42098df..393f0288 100644 --- a/src/extensions/weixin_login.rs +++ b/src/extensions/wechat_login.rs @@ -8,10 +8,10 @@ use crate::extensions::{ ExtensionError, InteractiveLoginInfo, InteractiveLoginPollResult, InteractiveLoginStartResult, }; -pub(crate) const WEIXIN_CHANNEL_NAME: &str = "weixin"; -pub(crate) const WEIXIN_BASE_URL_SETTING_PATH: &str = "extensions.weixin.base_url"; -pub(crate) const WEIXIN_DEFAULT_BASE_URL: &str = "https://ilinkai.weixin.qq.com"; -pub(crate) const WEIXIN_DEFAULT_BOT_TYPE: &str = "3"; +pub(crate) const WECHAT_CHANNEL_NAME: &str = "wechat"; +pub(crate) const WECHAT_BASE_URL_SETTING_PATH: &str = "extensions.wechat.base_url"; +pub(crate) const WECHAT_DEFAULT_BASE_URL: &str = "https://ilinkai.weixin.qq.com"; +pub(crate) const WECHAT_DEFAULT_BOT_TYPE: &str = "3"; const LOGIN_SESSION_TTL: Duration = Duration::from_secs(5 * 60); const QR_LONG_POLL_TIMEOUT: Duration = Duration::from_secs(35); @@ -19,7 +19,7 @@ const QR_FETCH_TIMEOUT: Duration = Duration::from_secs(15); const MAX_QR_REFRESH_COUNT: u8 = 3; #[derive(Debug, Clone)] -pub(crate) struct PendingWeixinLogin { +pub(crate) struct PendingWechatLogin { pub user_id: String, pub session_id: String, pub qrcode: String, @@ -30,22 +30,22 @@ pub(crate) struct PendingWeixinLogin { pub refresh_count: u8, } -impl PendingWeixinLogin { +impl PendingWechatLogin { pub fn is_fresh(&self) -> bool { self.started_at.elapsed() < LOGIN_SESSION_TTL } } #[derive(Debug, Clone)] -pub(crate) struct ConfirmedWeixinLogin { +pub(crate) struct ConfirmedWechatLogin { pub bot_token: String, pub base_url: Option, pub ilink_bot_id: String, } -pub(crate) enum WeixinLoginPollOutcome { +pub(crate) enum WechatLoginPollOutcome { Pending(InteractiveLoginPollResult), - Confirmed(ConfirmedWeixinLogin), + Confirmed(ConfirmedWechatLogin), } #[derive(Debug, Clone, Deserialize)] @@ -68,13 +68,13 @@ struct QrStatusResponse { pub(crate) fn interactive_login_info() -> InteractiveLoginInfo { InteractiveLoginInfo { method: "qr_code".to_string(), - button_label: "Connect Weixin".to_string(), - instructions: Some("Scan the QR code with Weixin to connect this channel.".to_string()), + button_label: "Connect WeChat".to_string(), + instructions: Some("Scan the QR code with WeChat to connect this channel.".to_string()), } } pub(crate) fn purge_expired_logins( - sessions: &mut std::collections::HashMap, + sessions: &mut std::collections::HashMap, ) { sessions.retain(|_, session| session.is_fresh()); } @@ -83,20 +83,20 @@ pub(crate) async fn start_login( user_id: &str, base_url: &str, bot_type: &str, -) -> Result<(PendingWeixinLogin, InteractiveLoginStartResult), ExtensionError> { +) -> Result<(PendingWechatLogin, InteractiveLoginStartResult), ExtensionError> { let qr = fetch_qr_code(base_url, bot_type).await?; Ok(build_pending_login(user_id, base_url, bot_type, qr)) } pub(crate) async fn poll_login( - session: &mut PendingWeixinLogin, -) -> Result { + session: &mut PendingWechatLogin, +) -> Result { if !session.is_fresh() { - return Ok(WeixinLoginPollOutcome::Pending( + return Ok(WechatLoginPollOutcome::Pending( InteractiveLoginPollResult { session_id: session.session_id.clone(), status: "failed".to_string(), - message: "The QR code expired. Start a new Weixin connection.".to_string(), + message: "The QR code expired. Start a new WeChat connection.".to_string(), qr_code_url: None, activated: Some(false), }, @@ -119,9 +119,9 @@ fn build_pending_login( base_url: &str, bot_type: &str, qr: QrCodeResponse, -) -> (PendingWeixinLogin, InteractiveLoginStartResult) { +) -> (PendingWechatLogin, InteractiveLoginStartResult) { let session_id = Uuid::new_v4().to_string(); - let session = PendingWeixinLogin { + let session = PendingWechatLogin { user_id: user_id.to_string(), session_id: session_id.clone(), qrcode: qr.qrcode, @@ -135,7 +135,7 @@ fn build_pending_login( let result = InteractiveLoginStartResult { session_id, status: "pending".to_string(), - message: "Scan the QR code in Weixin to finish connecting.".to_string(), + message: "Scan the QR code in WeChat to finish connecting.".to_string(), qr_code_url: Some(qr.qrcode_img_content), instructions: Some( "Keep this window open while you scan and confirm on your phone.".to_string(), @@ -146,12 +146,12 @@ fn build_pending_login( } fn handle_poll_status( - session: &mut PendingWeixinLogin, + session: &mut PendingWechatLogin, status: QrStatusResponse, refreshed_qr: Option, -) -> Result { +) -> Result { match status.status.as_str() { - "wait" => Ok(WeixinLoginPollOutcome::Pending( + "wait" => Ok(WechatLoginPollOutcome::Pending( InteractiveLoginPollResult { session_id: session.session_id.clone(), status: "pending".to_string(), @@ -160,11 +160,11 @@ fn handle_poll_status( activated: None, }, )), - "scaned" => Ok(WeixinLoginPollOutcome::Pending( + "scaned" => Ok(WechatLoginPollOutcome::Pending( InteractiveLoginPollResult { session_id: session.session_id.clone(), status: "scanned".to_string(), - message: "QR code scanned. Confirm the login in Weixin.".to_string(), + message: "QR code scanned. Confirm the login in WeChat.".to_string(), qr_code_url: None, activated: None, }, @@ -172,7 +172,7 @@ fn handle_poll_status( "expired" => { session.refresh_count = session.refresh_count.saturating_add(1); if session.refresh_count > MAX_QR_REFRESH_COUNT { - return Ok(WeixinLoginPollOutcome::Pending( + return Ok(WechatLoginPollOutcome::Pending( InteractiveLoginPollResult { session_id: session.session_id.clone(), status: "failed".to_string(), @@ -185,14 +185,14 @@ fn handle_poll_status( let refreshed = refreshed_qr.ok_or_else(|| { ExtensionError::Other( - "Weixin QR status expired without a refreshed QR code".to_string(), + "WeChat QR status expired without a refreshed QR code".to_string(), ) })?; session.qrcode = refreshed.qrcode; session.qr_code_url = refreshed.qrcode_img_content.clone(); session.started_at = Instant::now(); - Ok(WeixinLoginPollOutcome::Pending( + Ok(WechatLoginPollOutcome::Pending( InteractiveLoginPollResult { session_id: session.session_id.clone(), status: "refreshed".to_string(), @@ -209,29 +209,29 @@ fn handle_poll_status( .filter(|value| !value.trim().is_empty()) .ok_or_else(|| { ExtensionError::Other( - "Weixin login succeeded but no bot account id was returned".to_string(), + "WeChat login succeeded but no bot account id was returned".to_string(), ) })?; let bot_token = bot_token.ok_or_else(|| { ExtensionError::Other( - "Weixin login succeeded but no bot token was returned".to_string(), + "WeChat login succeeded but no bot token was returned".to_string(), ) })?; - Ok(WeixinLoginPollOutcome::Confirmed(ConfirmedWeixinLogin { + Ok(WechatLoginPollOutcome::Confirmed(ConfirmedWechatLogin { bot_token, base_url: status.baseurl.filter(|value| !value.trim().is_empty()), ilink_bot_id, })) } other => { - tracing::warn!(status = other, "Unexpected Weixin QR status"); - Ok(WeixinLoginPollOutcome::Pending( + tracing::warn!(status = other, "Unexpected WeChat QR status"); + Ok(WechatLoginPollOutcome::Pending( InteractiveLoginPollResult { session_id: session.session_id.clone(), status: "failed".to_string(), - message: format!("Unexpected Weixin login status: {other}"), + message: format!("Unexpected WeChat login status: {other}"), qr_code_url: None, activated: Some(false), }, @@ -257,27 +257,27 @@ async fn fetch_qr_code(base_url: &str, bot_type: &str) -> Result() .await - .map_err(|e| ExtensionError::Other(format!("Failed to parse Weixin QR code response: {e}"))) + .map_err(|e| ExtensionError::Other(format!("Failed to parse WeChat QR code response: {e}"))) } async fn poll_qr_status(base_url: &str, qrcode: &str) -> Result { @@ -289,7 +289,7 @@ async fn poll_qr_status(base_url: &str, qrcode: &str) -> Result Result { return Err(ExtensionError::Other(format!( - "Failed to poll Weixin QR status: {error}" + "Failed to poll WeChat QR status: {error}" ))); } }; @@ -317,22 +317,22 @@ async fn poll_qr_status(base_url: &str, qrcode: &str) -> Result() .await - .map_err(|e| ExtensionError::Other(format!("Failed to parse Weixin QR status: {e}"))) + .map_err(|e| ExtensionError::Other(format!("Failed to parse WeChat QR status: {e}"))) } #[cfg(test)] mod tests { use super::{ - QrCodeResponse, QrStatusResponse, WeixinLoginPollOutcome, build_pending_login, + QrCodeResponse, QrStatusResponse, WechatLoginPollOutcome, build_pending_login, handle_poll_status, }; @@ -385,7 +385,7 @@ mod tests { .map_err(|e| e.to_string())?; match outcome { - WeixinLoginPollOutcome::Confirmed(confirmed) => { + WechatLoginPollOutcome::Confirmed(confirmed) => { assert_eq!(confirmed.bot_token, "bot-token-123"); assert_eq!(confirmed.ilink_bot_id, "wx-bot-1"); assert_eq!( @@ -394,7 +394,7 @@ mod tests { ); Ok(()) } - WeixinLoginPollOutcome::Pending(result) => Err(format!( + WechatLoginPollOutcome::Pending(result) => Err(format!( "expected confirmed login, got pending status {}", result.status )), @@ -428,7 +428,7 @@ mod tests { .map_err(|e| e.to_string())?; match outcome { - WeixinLoginPollOutcome::Pending(result) => { + WechatLoginPollOutcome::Pending(result) => { assert_eq!(result.status, "refreshed"); assert_eq!( result.qr_code_url.as_deref(), @@ -438,7 +438,7 @@ mod tests { assert_eq!(session.refresh_count, 1); Ok(()) } - WeixinLoginPollOutcome::Confirmed(_) => { + WechatLoginPollOutcome::Confirmed(_) => { Err("expected QR refresh before confirmation".to_string()) } }