feat: AI 工具架构重构与微信增强 (0.2.17 → 0.2.22)

0.2.17 收束模型初始工具列表:
- 仅暴露 query_capabilities/query_capability_detail/call_capability 三个入口
- API 能力统一通过 iAs HTTP API 封装,删除重复 YAML 工具

0.2.18 系统提示词拆分:
- 固定系统提示词硬编码,动态附加部分 AI 可维护
- 新增 update_additional_system_prompt 能力和持久化

0.2.19 上下文增强与打断语义:
- /new 拥有最高打断语义,取消运行中任务并清除当前 turn
- raw_message JSONB 原文入库,群聊按 group_id 隔离作用域
- 工具调用说明替代动态附加系统提示词

0.2.20 微信输入中状态:
- 改用官方 getconfig + sendtyping 流程,显式管理 typing 状态

0.2.21 API 文档功能分组:
- /v1/api-docs 返回 groups,帮助 AI 按功能面发现接口

0.2.22 AI API 能力去重:
- 移除与 HTTP endpoint 重复的专用能力,统一用 query_api_docs + call_http_api
- 删除不再编译的 web/memory 工具文件
This commit is contained in:
2026-07-08 14:58:13 +08:00
parent 279d89b87a
commit 7d5140c8d2
49 changed files with 2330 additions and 1185 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ias-wechat"
version = "0.1.0"
version = "0.1.1"
edition = "2024"
[dependencies]
+6 -2
View File
@@ -2,7 +2,7 @@
`wechat` 是微信 iLink Bot API 的 Rust 封装,提供两层能力:
- `WeChatClient`:单账号客户端,负责扫码登录、恢复登录态、注册监听器、长轮询收消息、发送文本消息。
- `WeChatClient`:单账号客户端,负责扫码登录、恢复登录态、注册监听器、长轮询收消息、发送文本消息和输入中状态
- `WeChatMultiAccountManager`:多账号运行时管理器,按账号启动多个独立长轮询任务,并通过事件队列把消息交给业务层。
设计原则:**一个 `WeChatClient` 只代表一个微信 Bot 账号;多账号监听就是每个账号一个独立长轮询任务**。这样可以保证 `token``base_url``get_updates_buf``context_token` 不串账号。
@@ -48,6 +48,7 @@ wechat = { path = "../wechat" }
| 变量 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `WEIXIN_BASE_URL` | 否 | `https://ilinkai.weixin.qq.com` | iLink Bot API 地址。显式传入 `base_url` 时优先使用显式值。 |
| `WEIXIN_APP_ID` | 否 | `bot` | iLink App ID,请求头会随所有 API 请求发送。 |
## 单账号使用
@@ -59,6 +60,7 @@ login 或 set_auth
set_updates_buf
notify_start
receive_messages loop
send_typing / cancel_typing
send_text
notify_stop
```
@@ -345,6 +347,8 @@ manager
| `POST` | `/ilink/bot/msg/notifystop` | 注销监听器 |
| `POST` | `/ilink/bot/getupdates` | 长轮询拉取消息 |
| `POST` | `/ilink/bot/sendmessage` | 发送消息 |
| `POST` | `/ilink/bot/getconfig` | 获取用户配置,包括输入中状态所需的 `typing_ticket` |
| `POST` | `/ilink/bot/sendtyping` | 发送或取消输入中状态 |
登录后请求会带:
@@ -383,7 +387,7 @@ tracing_subscriber::fmt()
## 当前限制
- `send_text()` 只封装文本发送。
- `send_text()` 只封装文本发送;输入中状态通过独立的 `send_typing()` / `cancel_typing()` 发送
- manager 不内置账号持久化,业务层负责数据库或文件存储。
- manager 通过 `JoinHandle::abort()` 停止长轮询任务;如需立即优雅取消 HTTP 请求,可后续在 client 层增加 cancellation 支持。
- `ILINK_APP_ID` 当前为空字符串;如果接入方要求 app id,需要在 `client.rs` 中配置或改为环境变量。
+124 -20
View File
@@ -5,7 +5,7 @@
//! ### 核心功能
//! - **扫码登录** — `get_qrcode()` → `poll_qr_status()` → `login()`
//! - **消息接收** — `receive_messages()`(长轮询)
//! - **消息发送** — `send_text()`
//! - **消息发送** — `send_text()` / `send_typing()` / `cancel_typing()`
//! - **生命周期** — `notify_start()` / `notify_stop()`
//!
//! ### 线程安全
@@ -13,15 +13,22 @@
//!
//! ### 环境变量
//! - `WEIXIN_BASE_URL` — API 地址(默认 `https://ilinkai.weixin.qq.com`
//! - `WEIXIN_APP_ID` — iLink App ID(默认 `bot`
use crate::types::*;
use base64::Engine;
use reqwest::Client as HttpClient;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use tracing::{debug, info, warn};
struct TypingTicketCacheEntry {
ticket: String,
expires_at: Instant,
}
/// ## 微信 iLink Bot API 客户端
///
/// ### 职责
@@ -39,6 +46,8 @@ use tracing::{debug, info, warn};
/// * 登录状态: `{base_url}/ilink/bot/get_qrcode_status`
/// * 收消息: `{base_url}/ilink/bot/getupdates`
/// * 发消息: `{base_url}/ilink/bot/sendmessage`
/// * 获取用户配置: `{base_url}/ilink/bot/getconfig`
/// * 发送输入中: `{base_url}/ilink/bot/sendtyping`
/// * 注册监听: `{base_url}/ilink/bot/msg/notifystart`
#[derive(Clone)]
pub struct WeChatClient {
@@ -48,11 +57,12 @@ pub struct WeChatClient {
account_id: Arc<Mutex<String>>,
/// get_updates_buf,用于长轮询的上下文缓冲
updates_buf: Arc<Mutex<String>>,
typing_tickets: Arc<Mutex<HashMap<String, TypingTicketCacheEntry>>>,
}
impl WeChatClient {
/// iLink App ID(从 package.json 的 ilink_appid 字段,硬编码或环境变量)
const ILINK_APP_ID: &'static str = "";
/// 默认 iLink App ID;可通过 WEIXIN_APP_ID 覆盖。
const DEFAULT_ILINK_APP_ID: &'static str = "bot";
/// 默认 bot_type
const DEFAULT_BOT_TYPE: &'static str = "3";
/// 默认 API 地址(可通过 WEIXIN_BASE_URL 环境变量覆盖)
@@ -64,6 +74,7 @@ impl WeChatClient {
}
/// 版本号编码:0x00MMNNPP
const CLIENT_VERSION: u32 = 0x0001_0000; // 1.0.0
const TYPING_TICKET_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
pub fn new(base_url: Option<String>) -> Self {
// 优先级: 显式参数 > WEIXIN_BASE_URL 环境变量 > 默认值
@@ -80,6 +91,7 @@ impl WeChatClient {
token: Arc::new(Mutex::new(String::new())),
account_id: Arc::new(Mutex::new(String::new())),
updates_buf: Arc::new(Mutex::new(String::new())),
typing_tickets: Arc::new(Mutex::new(HashMap::new())),
}
}
@@ -374,7 +386,7 @@ impl WeChatClient {
context_token: Option<&str>,
) -> Result<String, String> {
let client_id = uuid::Uuid::new_v4().to_string();
self.send_typing_with_client_id(to, context_token, &client_id)
self.send_typing_status(to, context_token, SendTypingReq::STATUS_TYPING)
.await?;
Ok(client_id.to_string())
}
@@ -386,14 +398,15 @@ impl WeChatClient {
context_token: Option<&str>,
client_id: &str,
) -> Result<String, String> {
self.send_bot_message(
to,
client_id,
WeixinMessage::STATE_TYPING,
None,
context_token,
)
.await
self.send_typing_status(to, context_token, SendTypingReq::STATUS_TYPING)
.await?;
Ok(client_id.to_string())
}
/// 取消“输入中”状态。
pub async fn cancel_typing(&self, to: &str, context_token: Option<&str>) -> Result<(), String> {
self.send_typing_status(to, context_token, SendTypingReq::STATUS_CANCEL)
.await
}
/// 使用已有 client_id 完成文本消息。
@@ -433,7 +446,10 @@ impl WeChatClient {
..Default::default()
};
let body = SendMessageReq { msg };
let body = SendMessageReq {
msg,
base_info: BaseInfo::new(),
};
let url = format!("{}/ilink/bot/sendmessage", self.base_url);
let token = self.token.lock().await.clone();
@@ -448,6 +464,91 @@ impl WeChatClient {
Ok(client_id.to_string())
}
async fn send_typing_status(
&self,
to: &str,
context_token: Option<&str>,
status: i32,
) -> Result<(), String> {
let typing_ticket = self.get_typing_ticket(to, context_token).await?;
let body = SendTypingReq {
ilink_user_id: to.to_string(),
typing_ticket,
status,
base_info: BaseInfo::new(),
};
let url = format!("{}/ilink/bot/sendtyping", self.base_url);
let token = self.token.lock().await.clone();
let _resp_text = self
.post_json(&url, &body, Some(&token))
.await?
.text()
.await
.map_err(|e| format!("发送输入中状态网络错误: {}", e))?;
Ok(())
}
async fn get_typing_ticket(
&self,
to: &str,
context_token: Option<&str>,
) -> Result<String, String> {
let now = Instant::now();
if let Some(ticket) = {
let cache = self.typing_tickets.lock().await;
cache.get(to).and_then(|entry| {
if entry.expires_at > now && !entry.ticket.trim().is_empty() {
Some(entry.ticket.clone())
} else {
None
}
})
} {
return Ok(ticket);
}
let body = GetConfigReq {
ilink_user_id: to.to_string(),
context_token: context_token.map(|value| value.to_string()),
base_info: BaseInfo::new(),
};
let url = format!("{}/ilink/bot/getconfig", self.base_url);
let token = self.token.lock().await.clone();
let resp: GetConfigResp = self
.post_json(&url, &body, Some(&token))
.await?
.json()
.await
.map_err(|e| format!("解析微信配置响应失败: {}", e))?;
if resp.ret != 0 {
return Err(format!(
"获取微信配置失败: ret={} errmsg={:?}",
resp.ret, resp.errmsg
));
}
let ticket = resp.typing_ticket.unwrap_or_default();
if ticket.trim().is_empty() {
return Err(format!("微信输入中 ticket 为空: {}", to));
}
let mut cache = self.typing_tickets.lock().await;
cache.insert(
to.to_string(),
TypingTicketCacheEntry {
ticket: ticket.clone(),
expires_at: Instant::now() + Self::TYPING_TICKET_CACHE_TTL,
},
);
Ok(ticket)
}
/// 设置 auth 状态(从持久化恢复)
pub async fn set_auth(&mut self, token: &str, account_id: &str, base_url: &str) {
*self.token.lock().await = token.to_string();
@@ -483,12 +584,15 @@ impl WeChatClient {
reqwest::header::HeaderValue::from_static("application/json"),
);
if !Self::ILINK_APP_ID.is_empty() {
headers.insert(
"iLink-App-Id",
reqwest::header::HeaderValue::from_static(Self::ILINK_APP_ID),
);
}
let app_id = std::env::var("WEIXIN_APP_ID")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| Self::DEFAULT_ILINK_APP_ID.to_string());
headers.insert(
"iLink-App-Id",
reqwest::header::HeaderValue::from_str(&app_id).unwrap(),
);
headers.insert(
"iLink-App-ClientVersion",
+2 -1
View File
@@ -11,7 +11,8 @@
//! 2. `notify_start()` — 注册监听器
//! 3. `receive_messages()` — 长轮询接收消息
//! 4. `send_text()` — 发送文本回复
//! 5. `notify_stop()` — 注销监听器
//! 5. `send_typing()` / `cancel_typing()` — 发送和取消输入中状态
//! 6. `notify_stop()` — 注销监听器
pub mod client;
pub mod manager;
+15
View File
@@ -265,6 +265,21 @@ impl WeChatMultiAccountManager {
.await
}
/// 使用指定账号取消输入中状态。
pub async fn cancel_typing(
&self,
account_id: &str,
to: &str,
context_token: Option<&str>,
) -> Result<(), String> {
let runtime = self
.accounts
.get(account_id)
.ok_or_else(|| format!("微信账号未启动: {}", account_id))?;
runtime.client.cancel_typing(to, context_token).await
}
/// 使用指定账号完成已有输入中消息。
pub async fn send_text_with_client_id(
&self,
+78
View File
@@ -7,6 +7,7 @@
//! - `MessageItem` — 消息条目(5 种类型)
//! - `GetUpdatesReq/Resp` — 长轮询收消息的请求/响应
//! - `SendMessageReq` — 发送消息请求
//! - `GetConfigReq/Resp`、`SendTypingReq` — 输入中状态相关请求/响应
//! - `QRCodeResponse` / `StatusResponse` — 扫码登录相关
//! - `LoginResult` / `MessageEvent` — 业务层结果类型
@@ -306,6 +307,41 @@ pub struct GetUpdatesResp {
#[derive(Debug, Serialize)]
pub struct SendMessageReq {
pub msg: WeixinMessage,
pub base_info: BaseInfo,
}
/// GetConfig 请求。用于按用户获取 typing_ticket。
#[derive(Debug, Serialize)]
pub struct GetConfigReq {
pub ilink_user_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_token: Option<String>,
pub base_info: BaseInfo,
}
/// GetConfig 响应。
#[derive(Debug, Deserialize)]
pub struct GetConfigResp {
#[serde(default)]
pub ret: i32,
#[serde(skip_serializing_if = "Option::is_none")]
pub errmsg: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub typing_ticket: Option<String>,
}
/// SendTyping 请求。
#[derive(Debug, Serialize)]
pub struct SendTypingReq {
pub ilink_user_id: String,
pub typing_ticket: String,
pub status: i32,
pub base_info: BaseInfo,
}
impl SendTypingReq {
pub const STATUS_TYPING: i32 = 1;
pub const STATUS_CANCEL: i32 = 2;
}
/// NotifyStart/NotifyStop 响应
@@ -336,3 +372,45 @@ pub struct MessageEvent {
pub context_token: Option<String>,
pub message_id: i64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn send_message_req_includes_base_info() {
let req = SendMessageReq {
msg: WeixinMessage {
to_user_id: Some("user-1".to_string()),
msg_type: Some(WeixinMessage::TYPE_BOT),
message_state: Some(WeixinMessage::STATE_FINISH),
item_list: Some(vec![MessageItem::text("hello")]),
..Default::default()
},
base_info: BaseInfo::new(),
};
let value = serde_json::to_value(req).unwrap();
assert_eq!(
value["base_info"]["channel_version"],
env!("CARGO_PKG_VERSION")
);
assert_eq!(value["msg"]["message_state"], WeixinMessage::STATE_FINISH);
}
#[test]
fn send_typing_req_uses_sendtyping_shape() {
let req = SendTypingReq {
ilink_user_id: "user-1".to_string(),
typing_ticket: "ticket".to_string(),
status: SendTypingReq::STATUS_TYPING,
base_info: BaseInfo::new(),
};
let value = serde_json::to_value(req).unwrap();
assert_eq!(value["ilink_user_id"], "user-1");
assert_eq!(value["typing_ticket"], "ticket");
assert_eq!(value["status"], SendTypingReq::STATUS_TYPING);
assert!(value.get("msg").is_none());
}
}