初步完成channel->queueu->[tool]->[queue]->[assistant]->channel的流程

【待处理】上下文管理和错误处理。
This commit is contained in:
2026-06-29 09:15:30 +08:00
parent 79ec21bfd4
commit 46544e9c42
46 changed files with 1215 additions and 755 deletions
+16
View File
@@ -0,0 +1,16 @@
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty
+1
View File
@@ -3,3 +3,4 @@
.env
.vscode
.agents
.codegraph
Generated
+123
View File
@@ -39,6 +39,56 @@ dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.102"
@@ -211,16 +261,63 @@ dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "clap"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "common"
version = "0.1.0"
@@ -903,6 +1000,12 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
@@ -1047,6 +1150,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "parking"
version = "2.2.1"
@@ -1481,6 +1590,8 @@ dependencies = [
"ai",
"anyhow",
"axum",
"chrono",
"clap",
"common",
"dotenvy",
"reqwest",
@@ -1779,6 +1890,12 @@ dependencies = [
"unicode-properties",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
@@ -2157,6 +2274,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.23.4"
+14 -10
View File
@@ -3,13 +3,12 @@ use common::ai::{
ChatCompletionRequestBody, ChatCompletionRequestExtra, ChatCompletionRequestMessage,
ChatCompletionRequestThinking, ChatCompletionResponseBody,
};
use common::queue::QueueMessage;
use common::queue::{AssistantMessage, ChannelMessage, QueueMessage};
pub async fn send_message(
sender: tokio::sync::mpsc::Sender<QueueMessage>,
messages: Vec<ChatCompletionRequestMessage>,
channel: ChannelMessage,
) -> Result<(), reqwest::Error> {
let result = request_llm(messages).await;
match result {
@@ -18,25 +17,30 @@ pub async fn send_message(
for choice in result.unwrap().choices {
let messsage_content = choice.message.content;
let messsage_reasoning = choice.message.reasoning_content.unwrap_or_default();
if !messsage_content.trim().is_empty() || !messsage_content.trim().is_empty() {
let tool_calls = choice.message.tool_calls.clone();
sender
.send(QueueMessage::Aissitant(
messsage_reasoning,
messsage_content,
channel.clone(),
AssistantMessage {
reasoning: Some(messsage_reasoning),
content: messsage_content,
tool_calls,
},
))
.await
.unwrap();
}
match choice.message.tool_calls {
Some(tool_calls) => {
// eprintln!("调用了{}个工具", tool_calls.len());
let queue_message = call_tools(tool_calls).await.unwrap();
println!("调用了{}个工具", tool_calls.len());
let queue_message = call_tools(channel.clone(), tool_calls).await.unwrap();
if let Some(queue_message) = queue_message {
sender.send(queue_message).await.unwrap();
}
}
None => {
// eprintln!("没有调用工具");
eprintln!("没有调用工具");
}
}
}
+7 -2
View File
@@ -1,10 +1,14 @@
use chrono::Local;
use serde_json::error;
use common::{ai::ChatCompletionResponseToolCall, queue::QueueMessage};
use common::{
ai::ChatCompletionToolCall,
queue::{ChannelMessage, QueueMessage},
};
pub async fn call_tools(
tools: Vec<ChatCompletionResponseToolCall>,
channel: ChannelMessage,
tools: Vec<ChatCompletionToolCall>,
) -> Result<Option<QueueMessage>, error::Error> {
if let Some(tool) = tools.first() {
if let Some(funcation) = tool.function.as_ref() {
@@ -12,6 +16,7 @@ pub async fn call_tools(
let date_time = Local::now();
let formatted = date_time.format("%Y-%m-%d %H:%M:%S").to_string();
return Ok(Some(QueueMessage::Tool(
channel,
tool.id.clone(),
funcation.name.clone(),
format!("现在时间:{}", formatted),
+28 -15
View File
@@ -23,6 +23,7 @@ pub struct ChatCompletionRequestMessage {
pub role: String,
pub content: String,
pub tool_call_id: Option<String>,
pub tool_calls: Option<Vec<ChatCompletionToolCall>>,
}
impl ChatCompletionRequestMessage {
pub fn new(role: String, content: String) -> Self {
@@ -30,13 +31,28 @@ impl ChatCompletionRequestMessage {
role,
content,
tool_call_id: None,
tool_calls: None,
}
}
pub fn assistant(
role: String,
content: String,
tool_calls: Option<Vec<ChatCompletionToolCall>>,
) -> Self {
Self {
role,
content,
tool_call_id: None,
tool_calls: tool_calls,
}
}
pub fn tool(role: String, content: String, tool_call_id: String) -> Self {
Self {
role,
content,
tool_call_id: Some(tool_call_id),
tool_calls: None,
}
}
}
@@ -51,7 +67,7 @@ pub struct ChatCompletionResponseBody {
model: String,
service_tier: String,
pub choices: Vec<ChatCompletionResponseChoice>,
pub usage: ChatCompletionResponseUsage,
pub usage: ChatCompletionUsage,
}
#[allow(dead_code)]
@@ -68,40 +84,37 @@ pub struct ChatCompletionResponseMessage {
pub role: String,
pub content: String,
pub reasoning_content: Option<String>,
pub tool_calls: Option<Vec<ChatCompletionResponseToolCall>>,
pub tool_calls: Option<Vec<ChatCompletionToolCall>>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct ChatCompletionResponseToolCall {
pub function: Option<ChatCompletionResponseFunction>,
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ChatCompletionToolCall {
pub function: Option<ChatCompletionFunction>,
pub id: String,
#[serde(rename = "type")]
pub call_type: String,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct ChatCompletionResponseFunction {
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ChatCompletionFunction {
pub arguments: String,
pub name: String,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct ChatCompletionResponseUsage {
pub struct ChatCompletionUsage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
pub prompt_tokens_details: ChatCompletionResponseUsagePrompt,
pub completion_tokens_details: ChatCompletionResponseUsageCompletion,
pub prompt_tokens_details: ChatCompletionUsagePrompt,
pub completion_tokens_details: ChatCompletionUsageCompletion,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct ChatCompletionResponseUsagePrompt {
pub struct ChatCompletionUsagePrompt {
pub cached_tokens: u32,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct ChatCompletionResponseUsageCompletion {
pub struct ChatCompletionUsageCompletion {
pub reasoning_tokens: u32,
}
-22
View File
@@ -1,22 +0,0 @@
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct WechatUser {
pub id: isize,
pub user_name: String,
pub token: String,
pub account_id: String,
pub base_url: String,
pub user_id: String,
pub user_status: isize,
pub created_at: isize,
}
#[derive(Debug, Clone, Serialize)]
pub struct WechatUserCreate {
pub user_name: String,
pub token: String,
pub account_id: String,
pub base_url: String,
pub user_id: String,
}
-1
View File
@@ -1,3 +1,2 @@
pub mod ai;
pub mod db;
pub mod queue;
+23 -4
View File
@@ -1,14 +1,33 @@
use serde::{Deserialize, Serialize};
use crate::ai::ChatCompletionToolCall;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum QueueMessage {
Channel(String, String),
Aissitant(String, String),
Tool(String, String, String),
Channel(ChannelMessage),
Aissitant(ChannelMessage, AssistantMessage),
Tool(ChannelMessage, String, String, String),
}
#[derive(Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelMessage {
pub channel: String,
pub account_id: String,
pub from_user_id: String,
pub content: String,
pub context: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssistantMessage {
pub reasoning: Option<String>,
pub content: String,
pub tool_calls: Option<Vec<ChatCompletionToolCall>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolMessage {
pub tool_call_id: String,
pub name: String,
pub result: String,
}
+4
View File
@@ -46,3 +46,7 @@ sqlx = { version = "0.9.0", features = [
# ── 错误处理 ──
anyhow = "1"
thiserror = "2"
# ── cli ──
clap = { version = "4.6.1", features = ["derive"] }
# ── 时间处理 ──
chrono = { version = "0.4", features = ["serde"] } # 日期时间
@@ -1,29 +0,0 @@
-- iAs 初始数据库迁移
-- 认证信息和聊天记录存储
-- 应用状态表(key-value 存储,用于认证信息等)
CREATE TABLE IF NOT EXISTS app_state (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 聊天记录表
CREATE TABLE IF NOT EXISTS chat_records (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
direction TEXT NOT NULL, -- 'inbound' | 'outbound'
user_id TEXT NOT NULL, -- 微信用户 ID
account_id TEXT NOT NULL, -- 机器人账号 ID
text TEXT NOT NULL, -- 消息内容
source TEXT NOT NULL DEFAULT '', -- 消息来源
context_token TEXT NOT NULL DEFAULT '', -- 上下文令牌
message_id TEXT NOT NULL DEFAULT '',
meta JSONB NOT NULL DEFAULT '{}'::JSONB
);
CREATE INDEX IF NOT EXISTS idx_chat_records_user_created
ON chat_records (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_chat_records_account_created
ON chat_records (account_id, created_at DESC);
@@ -1,17 +0,0 @@
-- 待审批的工具调用
CREATE TABLE IF NOT EXISTS pending_approvals (
id UUID PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
user_id TEXT NOT NULL,
skill_name TEXT NOT NULL,
params JSONB NOT NULL,
code_hash TEXT NOT NULL,
attempts_left INTEGER NOT NULL DEFAULT 3,
status TEXT NOT NULL DEFAULT 'pending',
result JSONB,
consumed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_pending_approvals_user_status
ON pending_approvals (user_id, status);
@@ -1,20 +0,0 @@
-- 定时任务表(Phase 6
CREATE TABLE IF NOT EXISTS scheduled_tasks (
id UUID PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
user_id TEXT NOT NULL,
command TEXT NOT NULL,
shell TEXT NOT NULL DEFAULT '/bin/bash',
cwd TEXT NOT NULL DEFAULT '',
interval_seconds INTEGER NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
next_run_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_run_at TIMESTAMPTZ,
last_status TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_due
ON scheduled_tasks (enabled, next_run_at);
@@ -1,15 +0,0 @@
-- LLM Token 用量统计
CREATE TABLE IF NOT EXISTS llm_usage (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
user_id TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
provider TEXT NOT NULL DEFAULT 'deepseek',
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
cache_hit_tokens INTEGER NOT NULL DEFAULT 0,
cache_miss_tokens INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_llm_usage_created ON llm_usage (created_at DESC);
@@ -1,9 +0,0 @@
-- 长期记忆表
CREATE TABLE IF NOT EXISTS user_memories (
id SERIAL PRIMARY KEY,
user_id TEXT NOT NULL DEFAULT 'default',
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_memories_user ON user_memories (user_id);
@@ -1,12 +0,0 @@
-- 会话摘要持久化
CREATE TABLE IF NOT EXISTS session_summaries (
id SERIAL PRIMARY KEY,
user_id TEXT NOT NULL DEFAULT 'default',
summary_text TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT 'overflow',
message_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_session_summaries_created
ON session_summaries (created_at DESC);
@@ -1,11 +0,0 @@
-- 修复 llm_usage 表:从 iPet 旧格式迁移到 iAs 新格式
-- 旧表有 kind/entry 字段,新表需要 prompt_tokens/completion_tokens 等
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS prompt_tokens INTEGER NOT NULL DEFAULT 0;
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS completion_tokens INTEGER NOT NULL DEFAULT 0;
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS total_tokens INTEGER NOT NULL DEFAULT 0;
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS cache_hit_tokens INTEGER NOT NULL DEFAULT 0;
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS cache_miss_tokens INTEGER NOT NULL DEFAULT 0;
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS user_id TEXT NOT NULL DEFAULT '';
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS model TEXT NOT NULL DEFAULT '';
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS provider TEXT NOT NULL DEFAULT 'deepseek';
@@ -1,3 +0,0 @@
-- scheduled_tasks.user_id 默认值修复
ALTER TABLE scheduled_tasks ALTER COLUMN user_id SET DEFAULT '';
ALTER TABLE scheduled_tasks ALTER COLUMN user_id DROP NOT NULL;
@@ -1,3 +0,0 @@
-- 聊天记录去重索引(普通索引,不用 UNIQUE 避免存量重复数据冲突)
CREATE INDEX IF NOT EXISTS idx_chat_records_message_id
ON chat_records (message_id) WHERE message_id != '';
@@ -1,4 +0,0 @@
-- 移除 llm_usage 表中旧 iPet 遗留列的 NOT NULL 约束
-- kind/entry 是旧格式字段,新代码不使用它们
ALTER TABLE llm_usage ALTER COLUMN kind DROP NOT NULL;
ALTER TABLE llm_usage ALTER COLUMN entry DROP NOT NULL;
@@ -1,26 +0,0 @@
-- 修复 chat_records 表中 iPet 遗留的 context_token_present (boolean) → context_token (text)
-- 迁移 0001 创建的是 context_token TEXT,但旧库中列名是 context_token_present BOOLEAN
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'chat_records' AND column_name = 'context_token_present') THEN
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'chat_records' AND column_name = 'context_token') THEN
-- 两列都存在(混合/半迁移库):回填数据后删除旧列
UPDATE chat_records
SET context_token = CASE WHEN context_token_present::boolean THEN '1' ELSE '' END
WHERE context_token = '';
ALTER TABLE chat_records DROP COLUMN context_token_present;
ELSE
-- 只有旧列:类型转换 boolean → text (true → '1', false → ''),再重命名
ALTER TABLE chat_records
ALTER COLUMN context_token_present TYPE TEXT
USING CASE WHEN context_token_present::boolean THEN '1' ELSE '' END;
ALTER TABLE chat_records RENAME COLUMN context_token_present TO context_token;
ALTER TABLE chat_records ALTER COLUMN context_token SET DEFAULT '';
END IF;
END IF;
END;
$$;
@@ -1,12 +0,0 @@
-- 聊天记录去重:清理存量重复 → 建 partial UNIQUE 索引支持 ON CONFLICT
-- 1) 删除重复记录(保留每个 message_id 最早的那条)
DELETE FROM chat_records a
USING chat_records b
WHERE a.message_id = b.message_id
AND a.message_id NOT IN ('', '0')
AND a.id > b.id;
-- 2) 替换旧普通索引为 UNIQUE partial index(排除空和占位值)
DROP INDEX IF EXISTS idx_chat_records_message_id;
CREATE UNIQUE INDEX idx_chat_records_message_id
ON chat_records (message_id) WHERE message_id NOT IN ('', '0');
@@ -1,3 +0,0 @@
-- 确保 llm_usage 表在全新数据库上也有 kind/entry 列(迁移 0010 仅解除了旧库的 NOT NULL
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS kind TEXT;
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS entry JSONB;
@@ -1,6 +0,0 @@
-- 定时任务增加 task_type 字段:system(系统任务)或 model(模型任务)
-- 模型只能管理 model 类型的任务,不能修改 system 任务
ALTER TABLE scheduled_tasks ADD COLUMN IF NOT EXISTS task_type TEXT NOT NULL DEFAULT 'model';
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_type
ON scheduled_tasks (task_type, enabled, next_run_at);
@@ -1,4 +0,0 @@
-- 空迁移:用于对齐 _sqlx_migrations 表中已记录但文件丢失的迁移
-- 该迁移已被之前的某次运行应用过,但对应文件被删除/重命名
-- 此文件仅用于让 sqlx 迁移校验通过,不执行任何 DDL
SELECT 1;
@@ -1,4 +0,0 @@
-- 为"按用户查询最近的会话边界(clear/timeout 摘要)"添加索引。
-- list_recent_chat_records_for_context 会据此过滤掉已过期会话的消息。
CREATE INDEX IF NOT EXISTS idx_session_summaries_user_created
ON session_summaries (user_id, created_at DESC);
@@ -1,10 +1,12 @@
-- Add migration script here
CREATE TABLE ias_wechat_users (
CREATE TABLE ias_wechat_accounts (
id BIGSERIAL PRIMARY KEY,
user_name VARCHAR(100) NOT NULL,
token VARCHAR(255),
account_id VARCHAR(255),
base_url VARCHAR(255),
user_id VARCHAR(255),
updates_buf VARCHAR(255),
user_status INTEGER,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
@@ -1,2 +0,0 @@
-- Add migration script here
ALTER TABLE ias_wechat_users ADD COLUMN user_id VARCHAR(255);
-63
View File
@@ -1,63 +0,0 @@
use std::time::Duration;
use wechat::{client::WeChatClient, types::WeixinMessage};
use crate::AppState;
pub async fn init_wechat(state: Option<AppState>) -> Result<(), Box<dyn std::error::Error>> {
match state {
Some(state)=>{
load_config(state);
}
None=>{}
}
let client = WeChatClient::new(None);
let login = client
.login(
|qrcode_url| {
println!("二维码地址: {qrcode_url}");
},
120,
)
.await?;
// println!("登录成功: account_id={}", login.account_id);
// client.notify_start().await?;
// loop {
// let updates = client.receive_messages().await?;
// for msg in updates.msgs {
// if msg.msg_type != Some(WeixinMessage::TYPE_USER) {
// continue;
// }
// let Some(from_user_id) = msg.from_user_id.as_deref() else {
// continue;
// };
// let Some(text) = msg.text_content() else {
// continue;
// };
// client
// .send_text(
// from_user_id,
// &format!("收到: {text}"),
// msg.context_token.as_deref(),
// )
// .await?;
// }
// tokio::time::sleep(Duration::from_secs(1)).await;
// }
Ok(())
}
fn load_config(state: AppState){
let db = state.db;
}
+67
View File
@@ -0,0 +1,67 @@
use std::sync::Arc;
use crate::{AppState, repository::user_repository::UserRepositor};
use common::queue::{ChannelMessage, QueueMessage};
use sqlx::PgPool;
use tokio::sync::mpsc::Receiver;
use wechat::{manager::WeChatEvent, types::WeixinMessage};
pub async fn start_loop(
state: Arc<AppState>,
mut event_rx: Receiver<WeChatEvent>,
) -> Result<(), String> {
tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
match event {
WeChatEvent::UpdatesBuf {
account_id,
updates_buf,
} => {
persist_updates_buf(&state.db, account_id, updates_buf).await;
}
WeChatEvent::Message {
account_id,
message,
updates_buf,
} => {
persist_updates_buf(&state.db, account_id.clone(), updates_buf).await;
if message.msg_type != Some(WeixinMessage::TYPE_USER) {
continue;
}
let Some(from_user_id) = message.from_user_id.as_deref() else {
continue;
};
let Some(text) = message.text_content() else {
continue;
};
println!("Message account_id:{}=>{}", account_id.clone(), text);
state
.sender
.clone()
.send(QueueMessage::Channel(ChannelMessage {
channel: "wechat".to_string(),
account_id: account_id,
from_user_id: from_user_id.to_string(),
content: text.to_string(),
context: message.context_token,
}))
.await
.unwrap();
}
WeChatEvent::PollError { account_id, error } => {
println!("微信账号轮询失败 account_id={account_id}: {error}");
}
}
}
});
Ok(())
}
async fn persist_updates_buf(pool: &PgPool, account_id: String, buf: String) {
UserRepositor::update_account_buf(pool, account_id, buf)
.await
.unwrap();
}
+123
View File
@@ -0,0 +1,123 @@
use crate::model::user::{WechatAccount, WechatAccountCreate};
use sqlx::PgPool;
use std::io;
use std::sync::Arc;
use wechat::client::WeChatClient;
use wechat::manager::{WeChatAccountConfig, WeChatMultiAccountManager};
use crate::AppState;
use crate::channel::wechat::looper::start_loop;
use crate::repository::user_repository::UserRepositor;
pub async fn init_wechat(state: Option<AppState>) -> Option<WeChatMultiAccountManager> {
if let Some(state) = state {
println!("init_wechat");
if let Some(accounts) = load_accounts(&state.db).await {
println!("accounts:{}", accounts.len());
let (mut manager, event_rx) = WeChatMultiAccountManager::new(1024);
for account in accounts {
println!("account:{}", account.account_id.clone().unwrap_or_default());
manager
.start_account(WeChatAccountConfig {
token: account.token.unwrap_or_default(),
base_url: account.base_url.unwrap_or_default(),
account_id: account.account_id.unwrap_or_default(),
updates_buf: account.updates_buf.unwrap_or_default(),
})
.await
.unwrap();
}
start_loop(Arc::new(state.clone()), event_rx).await.unwrap();
return Some(manager);
}
}
None
}
pub async fn login_user(pool: &PgPool) {
let client = WeChatClient::new(None);
let login = client
.login(
|qrcode_url| {
println!("二维码地址: {qrcode_url}");
},
120,
)
.await;
match login {
Ok(account) => {
let account_id = account.account_id;
if let Ok(exist) = UserRepositor::find_wechat_account(pool, account_id.clone()).await {
eprintln!(
"账号已存在:{}:{}",
exist.account_id.unwrap_or_default(),
exist.user_name.unwrap_or_default()
);
} else {
let mut user_name = String::new();
io::stdin().read_line(&mut user_name).unwrap();
let user_name = if user_name.trim().is_empty() {
let user_start: String = account_id.clone().chars().take(6).collect();
format!("user{}", user_start).to_string()
} else {
user_name.trim().to_string()
};
let user = WechatAccountCreate {
account_id: account_id,
token: account.token,
base_url: account.base_url,
user_id: account.user_id,
user_name: user_name.to_string(),
};
// todo 后续需要处理错误
UserRepositor::add_wechat_account(&pool, user)
.await
.unwrap();
}
}
Err(err) => {
eprintln!("登录失败,请重试:{}", err)
}
}
}
pub async fn list_accounts(pool: &PgPool) {
println!("已登录用户如下:");
if let Some(accounts) = load_accounts(pool).await {
for account in accounts {
let user_name = account.user_name.unwrap_or_default();
let account_id = account.account_id.unwrap_or_default();
println!(" - {}:{}", user_name, account_id);
}
}
}
pub async fn rename_account(pool: &PgPool, account: String, name: String) {
if let Ok(lines) = UserRepositor::update_account_name(pool, account, name).await
&& lines > 0
{
println!("修改成功")
}
}
pub async fn delete_account(pool: &PgPool, account: String) {
if let Ok(lines) = UserRepositor::delete_account(pool, account).await
&& lines > 0
{
println!("删除成功")
}
}
async fn load_accounts(pool: &PgPool) -> Option<Vec<WechatAccount>> {
let accounts = UserRepositor::list_wechat_accounts(pool)
.await
.unwrap_or_default();
if !accounts.is_empty() {
return Some(accounts);
}
None
}
+2
View File
@@ -0,0 +1,2 @@
pub mod manager;
pub mod looper;
+52
View File
@@ -0,0 +1,52 @@
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "ias")]
#[command(version = "1.0")]
#[command(about = "Rust驱动的智能AI助手。")]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Debug, Subcommand)]
pub enum Commands {
// 启动服务
Service,
// 配置渠道
Channel {
#[command(subcommand)]
command: Channels,
},
}
#[derive(Debug, Subcommand)]
pub enum Channels {
// 微信渠道配置
Wechat {
#[command(subcommand)]
command: WechatAction,
},
}
#[derive(Debug, Subcommand)]
pub enum WechatAction {
/// 登录新的微信账号
Login,
/// 查看已经登录的微信账号
List,
/// 删除已登录的微信账号
Delete {
/// 要删除的微信账号
#[arg(short, long)]
account: String,
},
/// 修改账号名称 - 仅做标记,与微信名无关
Rename {
/// 要修改的微信账号
#[arg(short, long)]
account: String,
/// 名称 仅做标记
#[arg(short, long)]
name: String,
},
}
+11 -4
View File
@@ -2,7 +2,7 @@ use std::sync::OnceLock;
use tokio::sync::Mutex;
use common::ai::ChatCompletionRequestMessage;
use common::ai::{ChatCompletionRequestMessage, ChatCompletionToolCall};
static CHAT_CONTEXT: OnceLock<Mutex<Vec<ChatCompletionRequestMessage>>> = OnceLock::new();
@@ -20,8 +20,15 @@ pub async fn snapshot_context() -> Vec<ChatCompletionRequestMessage> {
context.clone()
}
pub async fn add_assistant_message(content: String) {
let message = ChatCompletionRequestMessage::new("assistant".to_string(), content);
pub async fn add_assistant_message(
content: String,
tool_calls: Option<Vec<ChatCompletionToolCall>>,
) {
let message = ChatCompletionRequestMessage::assistant(
"assistant".to_string(),
content,
tool_calls,
);
push_context_message(message).await;
}
@@ -31,6 +38,6 @@ pub async fn add_user_message(content: String) {
}
pub async fn add_tool_message(call_id: String, result: String) {
let message = ChatCompletionRequestMessage::tool("user".to_string(), result, call_id);
let message = ChatCompletionRequestMessage::tool("tool".to_string(), result, call_id);
push_context_message(message).await;
}
+2 -1
View File
@@ -1,2 +1,3 @@
pub mod queue;
pub mod cli;
pub mod context;
pub mod queue;
+62 -40
View File
@@ -1,62 +1,84 @@
use crate::{
AppState,
core::context::{add_assistant_message, add_tool_message, add_user_message, snapshot_context},
use crate::core::context::{
add_assistant_message, add_tool_message, add_user_message, snapshot_context,
};
use ai::core::send_message;
use axum::Json;
use axum::extract::State;
use common::queue::{ChannelMessage, QueueMessage};
use common::queue::QueueMessage;
use serde_json::error;
use std::sync::Arc;
use tokio::sync::{mpsc, mpsc::Sender};
use tokio::sync::{mpsc::Receiver, mpsc::Sender};
use wechat::manager::WeChatMultiAccountManager;
pub async fn create_queue() -> Result<Sender<QueueMessage>, error::Error> {
let (sender, mut receiver) = mpsc::channel::<QueueMessage>(100);
let result_sender = sender.clone();
pub async fn create_queue(
sender: Sender<QueueMessage>,
mut receiver: Receiver<QueueMessage>,
manager: WeChatMultiAccountManager,
) -> Result<(), error::Error> {
tokio::spawn(async move {
while let Some(msg) = receiver.recv().await {
let queue_sender = sender.clone();
match msg {
QueueMessage::Aissitant(reasoning, content) => {
QueueMessage::Aissitant(channel, assistant) => {
let content = assistant.content.clone();
let reasoning = assistant.reasoning.clone().unwrap_or_default();
println!("\n\n收到llm回复:\n{}\n-------\n{}", reasoning, content);
add_assistant_message(content).await;
}
QueueMessage::Channel(name, text) => {
println!("\n\n收到来自{}消息:\n{}", name, text);
add_user_message(text).await;
send_message(queue_sender, snapshot_context().await)
if !content.trim().is_empty() || !reasoning.trim().is_empty() {
let text = if !content.trim().is_empty() {
content.trim()
} else {
reasoning.trim()
};
manager
.send_text(
&channel.account_id,
&channel.from_user_id,
&text,
channel.context.as_deref(),
)
.await
.unwrap();
}
QueueMessage::Tool(tool_call_id, name, result) => {
add_assistant_message(assistant.content, assistant.tool_calls).await;
}
QueueMessage::Channel(bundle) => {
println!(
"\n\n收到来自{}消息:\n{}",
bundle.from_user_id.clone(),
bundle.content.clone()
);
// todo 目前按单用户处理
add_user_message(bundle.content.clone()).await;
send_message(queue_sender, snapshot_context().await, bundle)
.await
.unwrap();
}
QueueMessage::Tool(channel, tool_call_id, name, result) => {
println!("\n\n调用{}工具的结果:\n{}", name, result);
add_tool_message(tool_call_id, result).await;
send_message(queue_sender, snapshot_context().await)
send_message(queue_sender, snapshot_context().await, channel)
.await
.unwrap();
}
}
}
});
Ok(result_sender)
Ok(())
}
pub async fn send_to_queue(
State(state): State<Arc<AppState>>,
Json(body): Json<ChannelMessage>,
) -> Json<serde_json::Value> {
match state
.sender
.send(QueueMessage::Channel(body.channel, body.content))
.await
{
Ok(_) => Json(serde_json::json!({
"success":true,
"message":"queued"
})),
Err(_) => Json(serde_json::json!({
"success":true,
"message":"queue closed"
})),
}
}
// pub async fn send_to_queue(
// State(state): State<Arc<AppState>>,
// Json(body): Json<ChannelMessageBundle>,
// ) -> Json<serde_json::Value> {
// match state
// .sender
// .send(QueueMessage::Channel(body.channel, body.content))
// .await
// {
// Ok(_) => Json(serde_json::json!({
// "success":true,
// "message":"queued"
// })),
// Err(_) => Json(serde_json::json!({
// "success":true,
// "message":"queue closed"
// })),
// }
// }
+2 -1
View File
@@ -5,7 +5,7 @@ use axum::{
};
use serde_json::json;
#[allow(unused)]
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("not found")]
@@ -30,4 +30,5 @@ impl IntoResponse for AppError {
}
}
#[allow(unused)]
pub type AppResult<T> = Result<T, AppError>;
+9 -8
View File
@@ -1,20 +1,21 @@
use std::sync::Arc;
use axum::routing::{get, post};
use anyhow::Ok;
use axum::routing::{get};
use axum::{Json, Router};
use crate::AppState;
use crate::core::queue::send_to_queue;
// use crate::core::queue::send_to_queue;
pub async fn create_http(state: AppState, host: String) {
pub async fn create_http(state: AppState, host: String) -> anyhow::Result<()> {
let app = Router::new()
.route("/message", post(send_to_queue))
// .route("/message", post(send_to_queue))
.route("/health", get(health))
.with_state(Arc::new(state));
let listener = tokio::net::TcpListener::bind(host.clone()).await.unwrap();
axum::serve(listener, app).await.unwrap();
println!("服务启动在:{}", host);
let listener = tokio::net::TcpListener::bind(host.clone()).await?;
println!("服务启动在:http://{}", listener.local_addr()?);
axum::serve(listener, app).await?;
Ok(())
}
async fn health() -> Json<serde_json::Value> {
+42 -23
View File
@@ -2,8 +2,10 @@ mod channel;
mod core;
mod error;
mod http;
mod model;
mod repository;
use clap::Parser;
use common::queue::QueueMessage;
use core::queue::create_queue;
use http::app::create_http;
@@ -12,7 +14,10 @@ use std::env;
use tokio::sync::mpsc;
use crate::channel::wechat::init_wechat;
use crate::channel::wechat::manager::{
delete_account, init_wechat, list_accounts, login_user, rename_account,
};
use crate::core::cli::{Channels, Cli, Commands, WechatAction};
#[derive(Clone)]
pub struct AppState {
@@ -21,35 +26,49 @@ pub struct AppState {
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
if std::env::var("RUST_ANALYZER").is_err() {
dotenv::dotenv().ok();
}
// dotenvy::dotenv().ok();
let name = env::var("NAME").unwrap_or_else(|_| "ias".to_string());
let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0:9003".to_string());
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
let db_url = env::var("DATABASE_URL").expect("必须设置DATABASE_URL");
let pool = PgPoolOptions::new()
.max_connections(10)
.connect(&db_url)
.await?;
let sender = create_queue().await.unwrap();
let state = AppState {
sender: sender,
db: pool,
};
println!("{} start in : {}", name, host);
create_http(state.clone(), host).await;
init_wechat(Some(state)).await.unwrap();
let cli = Cli::parse();
match cli.command {
Commands::Service => {
service(pool).await.unwrap();
}
Commands::Channel { command } => match command {
Channels::Wechat { command } => match command {
WechatAction::Login => login_user(&pool).await,
WechatAction::List => list_accounts(&pool).await,
WechatAction::Delete { account } => delete_account(&pool, account).await,
WechatAction::Rename { account, name } => {
rename_account(&pool, account, name).await
}
},
},
}
Ok(())
}
#[test]
fn test() {
let api_key = std::env::var("ROUTER_API_KEY").unwrap();
println!("{}", api_key);
async fn service(pool: PgPool) -> anyhow::Result<()> {
let name: String = env::var("NAME").unwrap_or_else(|_| "ias".to_string());
let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0:9003".to_string());
println!("{}已启动...", name);
let (sender, receiver) = mpsc::channel::<QueueMessage>(100);
let state = AppState {
sender: sender.clone(),
db: pool,
};
let manger = init_wechat(Some(state.clone())).await.unwrap();
create_queue(sender.clone(), receiver, manger)
.await
.unwrap();
// 启动http服务器,务必放在最下面执行
create_http(state.clone(), host.clone()).await.unwrap();
Ok(())
}
+25
View File
@@ -0,0 +1,25 @@
use chrono::NaiveDateTime;
use serde::Serialize;
use sqlx::prelude::FromRow;
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct WechatAccount {
pub id: i64,
pub user_name: Option<String>,
pub token: Option<String>,
pub account_id: Option<String>,
pub base_url: Option<String>,
pub user_id: Option<String>,
pub updates_buf: Option<String>,
pub user_status: Option<i32>,
pub created_at: Option<NaiveDateTime>,
}
#[derive(Debug, Clone, Serialize)]
pub struct WechatAccountCreate {
pub user_name: String,
pub token: String,
pub account_id: String,
pub base_url: String,
pub user_id: String,
}
+90 -21
View File
@@ -1,39 +1,108 @@
use sqlx::PgPool;
use anyhow::Ok;
use sqlx::{PgPool, Row};
use common::db::user::{WechatUser, WechatUserCreate};
use crate::model::user::{WechatAccount, WechatAccountCreate};
pub struct UserRepositor;
impl UserRepositor {
pub async fn create(pool: &PgPool, user: WechatUserCreate) -> anyhow::Result<isize> {
let record = sqlx::query!(
pub async fn add_wechat_account(
pool: &PgPool,
user: WechatAccountCreate,
) -> anyhow::Result<i64> {
let row = sqlx::query(
r#"
INSERT INTO ias_wechat_users (user_name,token,account_id,base_url,user_id,user_status)
VALUE ($1,$2,$3,$4,$5,1)
INSERT INTO ias_wechat_accounts (user_name,token,account_id,base_url,user_id,user_status)
VALUES ($1,$2,$3,$4,$5,1)
RETURNING id
"#,
user.user_name,
user.token,
user.account_id,
user.base_url,
user.user_id,
"#
)
.bind ( user.user_name)
.bind ( user.token)
.bind ( user.account_id)
.bind ( user.base_url)
.bind ( user.user_id)
.fetch_one(pool)
.await?;
Ok(record.id)
let id = row.try_get::<i64, _>("id");
Ok(id.unwrap_or_default())
}
pub async fn find_by_user_id(pool: &PgPool, user_id: isize) -> anyhow::Result<isize> {
let user = sqlx::query_as!(
WechatUser,
pub async fn find_wechat_account(
pool: &PgPool,
account_id: String,
) -> anyhow::Result<WechatAccount> {
let row = sqlx::query_as::<_, WechatAccount>(
r#"
SELECT *
FROM ias_wechat_users
WHERE user_id = $1
FROM ias_wechat_accounts
WHERE account_id = $1
AND user_status = 1
"#,
user_id,
)
.fetch_optional(pool)
.bind(account_id)
.fetch_one(pool)
.await?;
Ok(user)
Ok(row)
}
pub async fn list_wechat_accounts(pool: &PgPool) -> anyhow::Result<Vec<WechatAccount>> {
let row = sqlx::query_as::<_, WechatAccount>(
r#"
SELECT *
FROM ias_wechat_accounts
WHERE user_status = 1
"#,
)
.fetch_all(pool)
.await?;
Ok(row)
}
pub async fn update_account_name(
pool: &PgPool,
account_id: String,
new_name: String,
) -> anyhow::Result<u64> {
let result = sqlx::query(
r#"
UPDATE ias_wechat_accounts
SET user_name = $1
WHERE account_id = $2
"#,
)
.bind(new_name)
.bind(account_id)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
pub async fn update_account_buf(
pool: &PgPool,
account_id: String,
buf: String,
) -> anyhow::Result<u64> {
let result = sqlx::query(
r#"
UPDATE ias_wechat_accounts
SET updates_buf = $1
WHERE account_id = $2
"#,
)
.bind(buf)
.bind(account_id)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
pub async fn delete_account(pool: &PgPool, account_id: String) -> anyhow::Result<u64> {
let result = sqlx::query(
r#"
DELETE from ias_wechat_accounts
WHERE account_id = $1
"#,
)
.bind(account_id)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
}
+283 -376
View File
@@ -1,8 +1,11 @@
# wechat 模块使用文档
`wechat`一个封装微信 iLink Bot API 的 Rust crate,负责微信 Bot 的扫码登录、监听器生命周期管理、长轮询收消息和文本消息发送。
`wechat` 是微信 iLink Bot API 的 Rust 封装,提供两层能力:
当前模块只提供库能力,不会自动启动后台任务,也不会自动持久化登录态。业务服务需要主动创建 `WeChatClient`、完成登录恢复登录态,然后调用收发消息接口
- `WeChatClient`:单账号客户端,负责扫码登录恢复登录态、注册监听器、长轮询收消息、发送文本消息
- `WeChatMultiAccountManager`:多账号运行时管理器,按账号启动多个独立长轮询任务,并通过事件队列把消息交给业务层。
设计原则:**一个 `WeChatClient` 只代表一个微信 Bot 账号;多账号监听就是每个账号一个独立长轮询任务**。这样可以保证 `token``base_url``get_updates_buf``context_token` 不串账号。
## 模块结构
@@ -10,34 +13,30 @@
wechat/
src/
lib.rs # 模块导出
client.rs # WeChatClient,封装所有 HTTP API 调用
types.rs # iLink Bot API 请求、响应和消息结构
client.rs # 单账号 WeChatClient
manager.rs # 多账号 WeChatMultiAccountManager
types.rs # iLink Bot API 协议类型
```
公开模块:
```rust
pub mod client;
pub mod manager;
pub mod types;
```
常用类型
常用导入
```rust
use wechat::client::WeChatClient;
use wechat::types::{GetUpdatesResp, LoginResult, MessageItem, WeixinMessage};
use wechat::manager::{WeChatAccountConfig, WeChatEvent, WeChatMultiAccountManager};
use wechat::types::{MessageItem, WeixinMessage};
```
## Cargo 引用
本仓库根目录已经把 `wechat` 加入 workspace
```toml
[workspace]
members = ["service", "ai", "common", "wechat"]
```
如果其他 workspace crate 需要使用它,在对应 `Cargo.toml` 里加入:
workspace 内其他 crate 使用
```toml
[dependencies]
@@ -48,29 +47,52 @@ wechat = { path = "../wechat" }
| 变量 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `WEIXIN_BASE_URL` | 否 | `https://ilinkai.weixin.qq.com` | iLink Bot API 地址。测试、代理或私有部署时可覆盖。 |
| `WEIXIN_BASE_URL` | 否 | `https://ilinkai.weixin.qq.com` | iLink Bot API 地址。显式传入 `base_url` 时优先使用显式值。 |
示例:
## 单账号使用
```bash
export WEIXIN_BASE_URL="https://ilinkai.weixin.qq.com"
单账号生命周期:
```text
WeChatClient::new
login 或 set_auth
set_updates_buf
notify_start
receive_messages loop
send_text
notify_stop
```
## 基本生命周期
### 扫码登录
推荐调用顺序:
```rust
use wechat::client::WeChatClient;
1. 创建客户端:`WeChatClient::new(None)`
2. 扫码登录:`login(...)`
3. 持久化登录结果:保存 `token``account_id``base_url`
4. 注册监听器:`notify_start()`
5. 循环拉取消息:`receive_messages()`
6. 处理用户消息并回复:`send_text(...)`
7. 服务退出前注销监听器:`notify_stop()`
#[tokio::main]
async fn main() -> Result<(), String> {
let client = WeChatClient::new(None);
## 快速开始
let login = client
.login(
|qrcode_url| println!("二维码地址: {qrcode_url}"),
120,
)
.await?;
下面示例会扫码登录,注册监听器,然后持续接收用户文本消息并原样回复。
println!("account_id={}", login.account_id);
println!("base_url={}", login.base_url);
// 业务层需要持久化:
// login.token
// login.account_id
// login.base_url
// login.user_id
Ok(())
}
```
### 恢复登录态并收消息
```rust
use std::time::Duration;
@@ -80,349 +102,240 @@ use wechat::types::WeixinMessage;
#[tokio::main]
async fn main() -> Result<(), String> {
let client = WeChatClient::new(None);
let login = client
.login(
|qrcode_url| {
println!("二维码地址: {qrcode_url}");
},
120,
)
.await?;
println!("登录成功: account_id={}", login.account_id);
client.notify_start().await?;
loop {
let updates = client.receive_messages().await?;
for msg in updates.msgs {
if msg.msg_type != Some(WeixinMessage::TYPE_USER) {
continue;
}
let Some(from_user_id) = msg.from_user_id.as_deref() else {
continue;
};
let Some(text) = msg.text_content() else {
continue;
};
let mut client = WeChatClient::new(None);
client
.send_text(from_user_id, &format!("收到: {text}"), msg.context_token.as_deref())
.await?;
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
```
## 登录
完整登录接口:
```rust
pub async fn login(
&self,
on_qrcode: impl Fn(&str),
timeout_secs: u64,
) -> Result<LoginResult, String>
```
参数说明:
| 参数 | 说明 |
| --- | --- |
| `on_qrcode` | 获取二维码内容后的回调。当前实现会传入 `qrcode_img_content`,通常是可打开或可展示为二维码的内容。 |
| `timeout_secs` | 扫码确认总超时时间,单位秒。 |
返回值:
```rust
pub struct LoginResult {
pub token: String,
pub account_id: String,
pub base_url: String,
pub user_id: String,
}
```
登录内部流程:
1. `get_qrcode()` 调用 `/ilink/bot/get_bot_qrcode` 获取二维码。
2. 终端渲染二维码,同时打印二维码链接。
3. `poll_qr_status()` 轮询 `/ilink/bot/get_qrcode_status`
4. 用户扫码并确认后,把 `bot_token``ilink_bot_id` 写入客户端内存。
5. 返回 `LoginResult`,供业务层持久化。
扫码状态值:
| 状态 | 含义 | 当前处理 |
| --- | --- | --- |
| `wait` | 等待扫码 | 继续轮询 |
| `scaned` | 已扫码,等待微信确认 | 提示用户确认 |
| `scaned_but_redirect` | 需要切换 IDC | 使用 `redirect_host` 更新轮询地址 |
| `confirmed` | 登录成功 | 保存 token/account_id 并返回 |
| `expired` | 二维码过期 | 返回错误 |
## 恢复登录态
`wechat` 模块不负责把登录态写入数据库或文件。业务层应在登录成功后保存:
- `LoginResult.token`
- `LoginResult.account_id`
- `LoginResult.base_url`
- `get_updates_buf()` 返回值,建议在每次 `receive_messages()` 成功后保存
下次启动时恢复:
```rust
let mut client = WeChatClient::new(None);
client
.set_auth(
"持久化保存的 token",
"持久化保存的 account_id",
"持久化保存的 base_url",
)
.await;
client
client
.set_updates_buf("持久化保存的 get_updates_buf")
.await;
client.notify_start().await?;
```
client.notify_start().await?;
注意:`set_auth` 需要 `&mut self`,因此恢复登录态时变量要声明为 `mut`
## 注册和注销监听器
注册监听器:
```rust
client.notify_start().await?;
```
注销监听器:
```rust
client.notify_stop().await?;
```
这两个接口分别调用:
- `POST /ilink/bot/msg/notifystart`
- `POST /ilink/bot/msg/notifystop`
建议:
- 登录或恢复登录态后先调用 `notify_start()`,再开始拉消息。
- 进程收到退出信号时调用 `notify_stop()`
- 如果进程异常退出,下一次启动后仍应重新调用 `notify_start()`
## 接收消息
接口:
```rust
pub async fn receive_messages(&self) -> Result<GetUpdatesResp, String>
```
响应结构:
```rust
pub struct GetUpdatesResp {
pub ret: i32,
pub errcode: Option<i32>,
pub errmsg: Option<String>,
pub msgs: Vec<WeixinMessage>,
pub get_updates_buf: String,
pub longpolling_timeout_ms: Option<i64>,
}
```
说明:
- 该接口调用 `/ilink/bot/getupdates`
- 客户端内部维护 `updates_buf`,并在响应里的 `get_updates_buf` 非空时自动更新。
- 如果请求超时,当前实现返回一个空消息列表,不视为致命错误。
- 如果 `ret != 0``errcode != 0`,当前实现会写 warn 日志,但仍把响应返回给调用方。
文本消息提取:
```rust
for msg in updates.msgs {
if msg.msg_type == Some(WeixinMessage::TYPE_USER) {
if let Some(text) = msg.text_content() {
println!("收到文本: {text}");
}
}
}
```
消息关键字段:
| 字段 | 说明 |
| --- | --- |
| `msg_type` | `1` 表示用户消息,`2` 表示 Bot 消息。 |
| `from_user_id` | 发送者 ID。回复私聊消息时通常作为 `send_text``to`。 |
| `to_user_id` | 接收者 ID。 |
| `group_id` | 群消息 ID,群聊场景可能非空。 |
| `item_list` | 消息内容列表,可包含文本、图片、语音、文件、视频。 |
| `context_token` | 微信上下文令牌。回复时透传可保持多轮上下文。 |
## 发送文本消息
接口:
```rust
pub async fn send_text(
&self,
to: &str,
text: &str,
context_token: Option<&str>,
) -> Result<String, String>
```
参数说明:
| 参数 | 说明 |
| --- | --- |
| `to` | 接收者用户 ID。通常来自收到消息的 `from_user_id`。 |
| `text` | 要发送的文本内容。 |
| `context_token` | 可选上下文令牌,建议透传收到消息里的 `context_token`。 |
返回值是本地生成的 `client_id`,可用于日志追踪。
示例:
```rust
let client_id = client
.send_text(
from_user_id,
"你好,我是 Bot",
msg.context_token.as_deref(),
)
.await?;
println!("消息已提交: client_id={client_id}");
```
当前发送实现固定构造:
- `message_type = 2`,表示 Bot 消息
- `message_state = 2`,表示完成态
- `item_list = [MessageItem::text(text)]`
## 非文本消息
协议类型已经定义了以下消息 item:
- `TextItem`
- `ImageItem`
- `VoiceItem`
- `FileItem`
- `VideoItem`
当前便捷发送接口只实现了 `send_text()`。如果需要发送图片、文件或视频,需要新增对应的发送方法,构造 `WeixinMessage.item_list` 中的 `MessageItem`
接收侧可以通过 `MessageItem.item_type` 判断类型:
```rust
for item in msg.item_list.unwrap_or_default() {
match item.item_type {
Some(MessageItem::TYPE_TEXT) => {
let text = item.text_item.and_then(|v| v.text);
println!("文本: {:?}", text);
}
Some(MessageItem::TYPE_IMAGE) => {
println!("图片: {:?}", item.image_item);
}
Some(MessageItem::TYPE_VOICE) => {
println!("语音: {:?}", item.voice_item);
}
Some(MessageItem::TYPE_FILE) => {
println!("文件: {:?}", item.file_item);
}
Some(MessageItem::TYPE_VIDEO) => {
println!("视频: {:?}", item.video_item);
}
_ => {}
}
}
```
## 长轮询循环建议
推荐业务层把接收循环放到独立 tokio task:
```rust
let client_for_loop = client.clone();
tokio::spawn(async move {
loop {
match client_for_loop.receive_messages().await {
Ok(updates) => {
let updates = client.receive_messages().await?;
let latest_buf = client.get_updates_buf().await;
// 建议每次成功 receive 后持久化 latest_buf。
persist_updates_buf(&latest_buf).await;
for msg in updates.msgs {
// 处理消息
}
}
Err(err) => {
tracing::warn!("接收微信消息失败: {err}");
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
}
}
}
});
```
注意:
- `WeChatClient` 实现了 `Clone`,内部 token、account_id 和 updates_buf 使用 `Arc<Mutex<_>>` 共享。
- `receive_messages()` 已经处理普通超时,业务层不需要把空消息视为错误。
- 处理消息时建议过滤 `WeixinMessage::TYPE_BOT`,避免重复处理 Bot 自己发出的消息。
- 每次成功拉取后可以调用 `get_updates_buf()` 并持久化,降低重启后重复收消息的概率。
## 与 AI 回复集成示例
示例逻辑:收到用户文本后调用业务自己的 AI 函数,再把结果发回微信。
```rust
async fn handle_wechat_message(client: &WeChatClient, msg: WeixinMessage) -> Result<(), String> {
if msg.msg_type != Some(WeixinMessage::TYPE_USER) {
return Ok(());
continue;
}
let Some(from_user_id) = msg.from_user_id.as_deref() else {
return Ok(());
continue;
};
let Some(user_text) = msg.text_content() else {
return Ok(());
let Some(text) = msg.text_content() else {
continue;
};
let answer = call_ai(user_text).await?;
client
.send_text(from_user_id, &answer, msg.context_token.as_deref())
.send_text(
from_user_id,
&format!("收到: {text}"),
msg.context_token.as_deref(),
)
.await?;
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
async fn persist_updates_buf(_buf: &str) {}
```
## 多账号监听
多账号监听使用 `WeChatMultiAccountManager`。它会为每个账号创建一个独立 `WeChatClient` 和一个独立 tokio 任务:
```text
account A -> WeChatClient A -> getupdates(token A, buf A)
account B -> WeChatClient B -> getupdates(token B, buf B)
account C -> WeChatClient C -> getupdates(token C, buf C)
```
这不是短轮询刷接口,而是长轮询。单次 `getupdates` 请求通常会被服务端挂起到有消息或超时,因此多个账号对应多个挂起请求是预期模型。
### 账号配置
```rust
use wechat::manager::WeChatAccountConfig;
let account = WeChatAccountConfig::new(
"account_id",
"token",
"base_url",
"get_updates_buf",
);
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| `account_id` | 微信 iLink Bot 账号 ID,来自登录返回的 `LoginResult.account_id`。 |
| `token` | 登录返回的 `LoginResult.token`。 |
| `base_url` | 登录返回的 `LoginResult.base_url`。 |
| `updates_buf` | 上次轮询保存的 `get_updates_buf`,新账号可传空字符串。 |
### 启动多个账号
```rust
use wechat::manager::{WeChatAccountConfig, WeChatEvent, WeChatMultiAccountManager};
use wechat::types::WeixinMessage;
#[tokio::main]
async fn main() -> Result<(), String> {
let (mut manager, mut event_rx) = WeChatMultiAccountManager::new(1024);
let accounts = load_accounts().await;
for account in accounts {
manager.start_account(account).await?;
}
while let Some(event) = event_rx.recv().await {
match event {
WeChatEvent::UpdatesBuf {
account_id,
updates_buf,
} => {
persist_updates_buf(&account_id, &updates_buf).await;
}
WeChatEvent::Message {
account_id,
message,
updates_buf,
} => {
persist_updates_buf(&account_id, &updates_buf).await;
if message.msg_type != Some(WeixinMessage::TYPE_USER) {
continue;
}
let Some(from_user_id) = message.from_user_id.as_deref() else {
continue;
};
let Some(text) = message.text_content() else {
continue;
};
manager
.send_text(
&account_id,
from_user_id,
&format!("账号 {account_id} 收到: {text}"),
message.context_token.as_deref(),
)
.await?;
}
WeChatEvent::PollError { account_id, error } => {
tracing::warn!("微信账号轮询失败 account_id={account_id}: {error}");
}
}
}
Ok(())
}
async fn call_ai(input: &str) -> Result<String, String> {
Ok(format!("AI 回复: {input}"))
async fn load_accounts() -> Vec<WeChatAccountConfig> {
vec![]
}
async fn persist_updates_buf(_account_id: &str, _buf: &str) {}
```
### 停止账号
停止单个账号:
```rust
manager.stop_account("account_id").await?;
```
停止全部账号:
```rust
let results = manager.stop_all().await;
for (account_id, result) in results {
if let Err(err) = result {
tracing::warn!("停止微信账号失败 account_id={account_id}: {err}");
}
}
```
`stop_account` 会先中止本地轮询任务,再尽力调用 `notify_stop()`。如果进程直接退出,`Drop` 会 abort 本地任务,但无法异步调用 `notify_stop()`,所以生产环境建议在收到退出信号时显式调用 `stop_all()`
## 为什么不能一个轮询监听多个账号
iLink Bot 的 `getupdates` 是按 `Authorization: Bearer <token>` 鉴权的。一个 token 只对应一个 Bot 账号,服务端不会在同一个请求里返回其他账号的消息。
必须隔离的账号状态:
| 状态 | 为什么必须隔离 |
| --- | --- |
| `token` | 决定当前请求属于哪个账号。 |
| `base_url` | 登录可能返回账号实际使用的 IDC 地址。 |
| `get_updates_buf` | 轮询游标,混用会导致消息重复、丢失或串账号。 |
| `context_token` | 回复上下文,混用会导致回复到错误会话或上下文断裂。 |
因此多账号正确模型是“多个长轮询任务”,不是“一个共享轮询”。
## 持久化建议
本 crate 不绑定数据库或文件系统,业务层应自行持久化以下数据。
账号表建议字段:
```text
account_id string primary key
token string
base_url string
user_id string optional
updates_buf string
updated_at timestamp
```
收到 `WeChatEvent::Message` 后,使用事件里的 `updates_buf` 更新对应账号的游标。
如果要支持主动发送消息,建议额外维护:
```text
account_id
from_user_id
context_token
updated_at
```
收到用户消息时按 `account_id + from_user_id` 保存 `context_token`。主动发送时优先显式传 `account_id`,再取对应用户最近的 `context_token`
## 发送消息路由
多账号场景下发送消息必须明确使用哪个账号:
```rust
manager
.send_text(
"account_id",
"to_user_id",
"回复内容",
Some("context_token"),
)
.await?;
```
不建议只按 `to_user_id` 自动推断账号。原因是同一个用户可能和多个 Bot 账号都有会话,自动推断会产生歧义。
## API 端点
`WeChatClient` 当前使用以下 iLink Bot API
当前封装使用以下 iLink Bot API
| 方法 | 路径 | 用途 |
| --- | --- | --- |
@@ -433,25 +346,34 @@ async fn call_ai(input: &str) -> Result<String, String> {
| `POST` | `/ilink/bot/getupdates` | 长轮询拉取消息 |
| `POST` | `/ilink/bot/sendmessage` | 发送消息 |
请求头由模块内部生成
登录后请求会带
- `Content-Type: application/json`
- `Content-Length`
- `iLink-App-ClientVersion`
- `X-WECHAT-UIN`
- 登录后请求额外带:
- `Authorization: Bearer <token>`
- `AuthorizationType: ilink_bot_token`
```text
Authorization: Bearer <token>
AuthorizationType: ilink_bot_token
```
## 错误处理
| 场景 | 当前行为 | 建议 |
| --- | --- | --- |
| 二维码过期 | `login()` 返回 `二维码已过期` | 重新登录。 |
| 登录超时 | `login()` 返回 `登录超时` | 增大超时或重新登录。 |
| 长轮询超时 | `receive_messages()` 返回空消息 | 正常情况,继续轮询。 |
| 游标更新 | manager 发送 `WeChatEvent::UpdatesBuf` | 立即持久化对应账号的 `updates_buf`。 |
| 轮询错误 | manager 发送 `WeChatEvent::PollError` 并继续重试 | 记录日志,必要时触发重新登录。 |
| token 失效 | API 返回错误或 HTTP 鉴权失败 | 清理该账号 token,重新扫码登录。 |
| 多账号重复启动 | `start_account()` 返回错误 | 保证同一 `account_id` 只启动一次。 |
## 日志
模块使用 `tracing` 输出日志,主要 target
模块使用 `tracing`,主要 target
```text
ias::auth
```
业务程序可初始化日志
推荐初始化:
```rust
tracing_subscriber::fmt()
@@ -459,34 +381,19 @@ tracing_subscriber::fmt()
.init();
```
## 错误处理
所有公开异步方法当前返回 `Result<_, String>`
常见错误:
| 场景 | 典型错误 | 处理建议 |
| --- | --- | --- |
| 二维码过期 | `二维码已过期` | 重新调用 `login()`。 |
| 登录超时 | `登录超时` | 增大 `timeout_secs` 或重新登录。 |
| token 无效 | HTTP 401/403 或接口返回错误 | 清理持久化登录态,重新扫码登录。 |
| 长轮询超时 | 返回空 `msgs` | 正常情况,继续下一轮。 |
| 网络错误 | `请求失败` | 延迟重试,必要时检查 `WEIXIN_BASE_URL`。 |
## 当前限制
- `ILINK_APP_ID` 当前是空字符串。如接入方要求 app id,需要在 `client.rs` 中配置或改造成环境变量
- 登录态只保存在内存中,重启恢复需要业务层调用 `set_auth()``set_updates_buf()`
- 只提供文本发送便捷方法 `send_text()`
- 群聊回复策略需要业务层结合 `group_id` 和实际 iLink 协议进一步确认
- `send_text()` 当前不解析服务端响应体,只在 HTTP 层确认请求成功后返回本地 `client_id`
- `send_text()` 只封装文本发送
- manager 不内置账号持久化,业务层负责数据库或文件存储
- manager 通过 `JoinHandle::abort()` 停止长轮询任务;如需立即优雅取消 HTTP 请求,可后续在 client 层增加 cancellation 支持
- `ILINK_APP_ID` 当前为空字符串;如果接入方要求 app id,需要在 `client.rs` 中配置或改为环境变量
## 排障清单
1. 确认能访问 `WEIXIN_BASE_URL`
2. 确认扫码后在微信侧完成确认
3. 登录后先调用 `notify_start()`,再调用 `receive_messages()`
4. 如果收不到消息,检查 `get_updates_buf` 是否恢复了过旧状态;必要时清空后重试
5. 如果发送失败,检查 token 是否过期,以及 `to` 是否使用了正确的 `from_user_id`
6. 打开 `ias::auth=debug` 日志观察 API 请求和状态变化
1. 确认 `base_url` 可访问
2. 确认账号已经扫码登录并保存了 token
3. 每个账号只启动一个监听任务
4. 每个账号独立保存 `updates_buf`
5. 登录或恢复后先调用 `notify_start()`,再开始 `getupdates`
6. 发送消息时显式指定 `account_id`,并尽量透传 `context_token`
7. 打开 `ias::auth=debug` 查看请求和轮询状态。
+2
View File
@@ -3,6 +3,7 @@
//! 封装与微信 iLink Bot API 的所有通信:
//!
//! - `client` — HTTP 客户端(登录、长轮询收消息、发消息、生命周期管理)
//! - `manager` — 多账号监听管理器(每账号一个独立长轮询任务)
//! - `types` — API 协议类型定义(消息结构、请求/响应体)
//!
//! ### 核心流程
@@ -13,4 +14,5 @@
//! 5. `notify_stop()` — 注销监听器
pub mod client;
pub mod manager;
pub mod types;
+241
View File
@@ -0,0 +1,241 @@
//! ## 微信多账号监听管理器
//!
//! `WeChatClient` 表示一个账号的一条 iLink Bot 连接;本模块在其上提供
//! 多账号运行时管理:每个账号独立 token、base_url、get_updates_buf 和轮询任务。
use crate::client::WeChatClient;
use crate::types::WeixinMessage;
use std::collections::HashMap;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tracing::{info, warn};
/// 单个微信账号的恢复配置。
#[derive(Debug, Clone)]
pub struct WeChatAccountConfig {
pub account_id: String,
pub token: String,
pub base_url: String,
pub updates_buf: String,
}
impl WeChatAccountConfig {
pub fn new(
account_id: impl Into<String>,
token: impl Into<String>,
base_url: impl Into<String>,
updates_buf: impl Into<String>,
) -> Self {
Self {
account_id: account_id.into(),
token: token.into(),
base_url: base_url.into(),
updates_buf: updates_buf.into(),
}
}
}
/// 多账号监听事件。
#[derive(Debug, Clone)]
pub enum WeChatEvent {
/// 账号游标已更新。即使本轮没有消息,业务层也应持久化该值。
UpdatesBuf {
account_id: String,
updates_buf: String,
},
/// 收到一条微信消息。
Message {
account_id: String,
message: WeixinMessage,
updates_buf: String,
},
/// 某个账号的轮询发生错误。管理器会继续重试,除非任务被停止。
PollError { account_id: String, error: String },
}
struct AccountRuntime {
client: WeChatClient,
task: JoinHandle<()>,
}
/// 多账号监听管理器。
///
/// 设计约束:
/// - 一个 `WeChatClient` 只代表一个账号。
/// - 一个账号对应一个长轮询任务。
/// - `get_updates_buf` 由账号独立维护,不能跨账号共享。
pub struct WeChatMultiAccountManager {
accounts: HashMap<String, AccountRuntime>,
event_tx: mpsc::Sender<WeChatEvent>,
poll_idle_sleep: Duration,
error_retry_sleep: Duration,
}
impl WeChatMultiAccountManager {
/// 创建管理器并返回事件接收端。
pub fn new(event_buffer: usize) -> (Self, mpsc::Receiver<WeChatEvent>) {
let (event_tx, event_rx) = mpsc::channel(event_buffer.max(1));
(
Self {
accounts: HashMap::new(),
event_tx,
poll_idle_sleep: Duration::from_secs(1),
error_retry_sleep: Duration::from_secs(3),
},
event_rx,
)
}
/// 设置空轮询后的休眠时间。
pub fn with_poll_idle_sleep(mut self, duration: Duration) -> Self {
self.poll_idle_sleep = duration;
self
}
/// 设置轮询错误后的重试间隔。
pub fn with_error_retry_sleep(mut self, duration: Duration) -> Self {
self.error_retry_sleep = duration;
self
}
/// 启动一个账号的监听任务。
///
/// 如果同名账号已启动,返回错误,避免两个任务竞争同一个账号游标。
pub async fn start_account(&mut self, config: WeChatAccountConfig) -> Result<(), String> {
if self.accounts.contains_key(&config.account_id) {
return Err(format!("微信账号已启动: {}", config.account_id));
}
let mut client = WeChatClient::new(Some(config.base_url.clone()));
client
.set_auth(&config.token, &config.account_id, &config.base_url)
.await;
client.set_updates_buf(&config.updates_buf).await;
client.notify_start().await?;
let account_id = config.account_id.clone();
let loop_client = client.clone();
let event_tx = self.event_tx.clone();
let poll_idle_sleep = self.poll_idle_sleep;
let error_retry_sleep = self.error_retry_sleep;
let task = tokio::spawn(async move {
info!(target: "ias::auth", "微信账号监听已启动: {}", account_id);
loop {
match loop_client.receive_messages().await {
Ok(resp) => {
let updates_buf = loop_client.get_updates_buf().await;
if !updates_buf.is_empty() {
let event = WeChatEvent::UpdatesBuf {
account_id: account_id.clone(),
updates_buf: updates_buf.clone(),
};
if event_tx.send(event).await.is_err() {
warn!(
target: "ias::auth",
"微信事件接收端已关闭,停止账号监听: {}",
account_id
);
return;
}
}
for message in resp.msgs {
let event = WeChatEvent::Message {
account_id: account_id.clone(),
message,
updates_buf: updates_buf.clone(),
};
if event_tx.send(event).await.is_err() {
warn!(
target: "ias::auth",
"微信事件接收端已关闭,停止账号监听: {}",
account_id
);
return;
}
}
tokio::time::sleep(poll_idle_sleep).await;
}
Err(error) => {
let event = WeChatEvent::PollError {
account_id: account_id.clone(),
error,
};
if event_tx.send(event).await.is_err() {
warn!(
target: "ias::auth",
"微信事件接收端已关闭,停止账号监听: {}",
account_id
);
return;
}
tokio::time::sleep(error_retry_sleep).await;
}
}
}
});
self.accounts
.insert(config.account_id, AccountRuntime { client, task });
Ok(())
}
/// 停止一个账号的监听任务,并尽力调用 notify_stop。
pub async fn stop_account(&mut self, account_id: &str) -> Result<(), String> {
let Some(runtime) = self.accounts.remove(account_id) else {
return Ok(());
};
runtime.task.abort();
runtime.client.notify_stop().await
}
/// 停止所有账号。
pub async fn stop_all(&mut self) -> Vec<(String, Result<(), String>)> {
let account_ids = self.account_ids();
let mut results = Vec::with_capacity(account_ids.len());
for account_id in account_ids {
let result = self.stop_account(&account_id).await;
results.push((account_id, result));
}
results
}
/// 返回当前已启动的账号 ID。
pub fn account_ids(&self) -> Vec<String> {
self.accounts.keys().cloned().collect()
}
/// 判断账号是否正在监听。
pub fn is_running(&self, account_id: &str) -> bool {
self.accounts.contains_key(account_id)
}
/// 使用指定账号发送文本消息。
pub async fn send_text(
&self,
account_id: &str,
to: &str,
text: &str,
context_token: Option<&str>,
) -> Result<String, String> {
let runtime = self
.accounts
.get(account_id)
.ok_or_else(|| format!("微信账号未启动: {}", account_id))?;
runtime.client.send_text(to, text, context_token).await
}
}
impl Drop for WeChatMultiAccountManager {
fn drop(&mut self) {
for runtime in self.accounts.values() {
runtime.task.abort();
}
}
}