mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
Rename Weixin channel integration to WeChat
This commit is contained in:
+1
-1
@@ -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 |
|
||||
|
||||
@@ -274,7 +274,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weixin-channel"
|
||||
name = "wechat-channel"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
@@ -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]
|
||||
Executable
+24
@@ -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
|
||||
@@ -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<GetUpdatesResponse, String> {
|
||||
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 {
|
||||
@@ -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";
|
||||
@@ -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<ChannelConfig, String> {
|
||||
let config = serde_json::from_str::<WeixinConfig>(&config_json)
|
||||
.map_err(|e| format!("Failed to parse Weixin config: {e}"))?;
|
||||
let config = serde_json::from_str::<WechatConfig>(&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::<OutboundMetadata>(&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);
|
||||
@@ -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::<WeixinConfig>(&raw).ok())
|
||||
.and_then(|raw| serde_json::from_str::<WechatConfig>(&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())
|
||||
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
pub msgs: Vec<WeixinMessage>,
|
||||
pub msgs: Vec<WechatMessage>,
|
||||
#[serde(default)]
|
||||
pub get_updates_buf: Option<String>,
|
||||
}
|
||||
|
||||
#[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<i64>,
|
||||
#[serde(default)]
|
||||
+6
-6
@@ -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,
|
||||
@@ -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
|
||||
+29
-35
@@ -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 = <weixin sender id>` or owner scope if it is the bound owner
|
||||
- `thread_id = Some("weixin:<sender_id>")`
|
||||
- `conversation_scope_id = Some("weixin:<sender_id>")`
|
||||
- `channel = "wechat"`
|
||||
- `user_id = <wechat sender id>` or owner scope if it is the bound owner
|
||||
- `thread_id = Some("wechat:<sender_id>")`
|
||||
- `conversation_scope_id = Some("wechat:<sender_id>")`
|
||||
|
||||
`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?
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
@@ -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<dyn crate::db::SettingsStore> = 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,
|
||||
|
||||
+46
-46
@@ -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"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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, '');
|
||||
});
|
||||
|
||||
+92
-92
@@ -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<dyn Fn(&str, Option<i64>) -> Result<TelegramBindingResult, ExtensionError> + 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<WeixinLoginPollOutcome, ExtensionError> + Send + Sync,
|
||||
type TestWechatLoginPoller = Arc<
|
||||
dyn Fn(&mut PendingWechatLogin) -> Result<WechatLoginPollOutcome, ExtensionError> + 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<Option<String>>,
|
||||
pending_telegram_verification: RwLock<HashMap<String, PendingTelegramVerificationChallenge>>,
|
||||
pending_weixin_logins: RwLock<HashMap<String, PendingWeixinLogin>>,
|
||||
pending_wechat_logins: RwLock<HashMap<String, PendingWechatLogin>>,
|
||||
#[cfg(test)]
|
||||
test_wasm_channel_loader: RwLock<Option<TestWasmChannelLoader>>,
|
||||
#[cfg(test)]
|
||||
test_telegram_binding_resolver: RwLock<Option<TestTelegramBindingResolver>>,
|
||||
#[cfg(test)]
|
||||
test_weixin_login_starter: RwLock<Option<TestWeixinLoginStarter>>,
|
||||
test_wechat_login_starter: RwLock<Option<TestWechatLoginStarter>>,
|
||||
#[cfg(test)]
|
||||
test_weixin_login_poller: RwLock<Option<TestWeixinLoginPoller>>,
|
||||
test_wechat_login_poller: RwLock<Option<TestWechatLoginPoller>>,
|
||||
}
|
||||
|
||||
/// 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<InteractiveLoginInfo> {
|
||||
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",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
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<String, PendingWeixinLogin>,
|
||||
sessions: &mut std::collections::HashMap<String, PendingWechatLogin>,
|
||||
) {
|
||||
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<WeixinLoginPollOutcome, ExtensionError> {
|
||||
session: &mut PendingWechatLogin,
|
||||
) -> Result<WechatLoginPollOutcome, ExtensionError> {
|
||||
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<QrCodeResponse>,
|
||||
) -> Result<WeixinLoginPollOutcome, ExtensionError> {
|
||||
) -> Result<WechatLoginPollOutcome, ExtensionError> {
|
||||
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<QrCodeResponse,
|
||||
let client = Client::builder()
|
||||
.timeout(QR_FETCH_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|e| ExtensionError::Other(format!("Failed to create Weixin login client: {e}")))?;
|
||||
.map_err(|e| ExtensionError::Other(format!("Failed to create WeChat login client: {e}")))?;
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ExtensionError::Other(format!("Failed to fetch Weixin QR code: {e}")))?;
|
||||
.map_err(|e| ExtensionError::Other(format!("Failed to fetch WeChat QR code: {e}")))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!(status = %status, "Weixin QR code request failed");
|
||||
tracing::warn!(status = %status, "WeChat QR code request failed");
|
||||
return Err(ExtensionError::Other(format!(
|
||||
"Weixin QR code request failed with {status}: {body}"
|
||||
"WeChat QR code request failed with {status}: {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<QrCodeResponse>()
|
||||
.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<QrStatusResponse, ExtensionError> {
|
||||
@@ -289,7 +289,7 @@ async fn poll_qr_status(base_url: &str, qrcode: &str) -> Result<QrStatusResponse
|
||||
let client = Client::builder()
|
||||
.timeout(QR_LONG_POLL_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|e| ExtensionError::Other(format!("Failed to create Weixin poll client: {e}")))?;
|
||||
.map_err(|e| ExtensionError::Other(format!("Failed to create WeChat poll client: {e}")))?;
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
@@ -309,7 +309,7 @@ async fn poll_qr_status(base_url: &str, qrcode: &str) -> Result<QrStatusResponse
|
||||
}
|
||||
Err(error) => {
|
||||
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<QrStatusResponse
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!(status = %status, "Weixin QR status poll failed");
|
||||
tracing::warn!(status = %status, "WeChat QR status poll failed");
|
||||
return Err(ExtensionError::Other(format!(
|
||||
"Weixin QR status poll failed with {status}: {body}"
|
||||
"WeChat QR status poll failed with {status}: {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<QrStatusResponse>()
|
||||
.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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user