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:
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ias-wechat"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user