初步完成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
+2 -1
View File
@@ -2,4 +2,5 @@
*/target */target
.env .env
.vscode .vscode
.agents .agents
.codegraph
Generated
+123
View File
@@ -39,6 +39,56 @@ dependencies = [
"libc", "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]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.102" version = "1.0.102"
@@ -211,16 +261,63 @@ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys", "js-sys",
"num-traits", "num-traits",
"serde",
"wasm-bindgen", "wasm-bindgen",
"windows-link", "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]] [[package]]
name = "cmov" name = "cmov"
version = "0.5.4" version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]] [[package]]
name = "common" name = "common"
version = "0.1.0" version = "0.1.0"
@@ -903,6 +1000,12 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@@ -1047,6 +1150,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "parking" name = "parking"
version = "2.2.1" version = "2.2.1"
@@ -1481,6 +1590,8 @@ dependencies = [
"ai", "ai",
"anyhow", "anyhow",
"axum", "axum",
"chrono",
"clap",
"common", "common",
"dotenvy", "dotenvy",
"reqwest", "reqwest",
@@ -1779,6 +1890,12 @@ dependencies = [
"unicode-properties", "unicode-properties",
] ]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@@ -2157,6 +2274,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.23.4" version = "1.23.4"
+19 -15
View File
@@ -3,13 +3,12 @@ use common::ai::{
ChatCompletionRequestBody, ChatCompletionRequestExtra, ChatCompletionRequestMessage, ChatCompletionRequestBody, ChatCompletionRequestExtra, ChatCompletionRequestMessage,
ChatCompletionRequestThinking, ChatCompletionResponseBody, ChatCompletionRequestThinking, ChatCompletionResponseBody,
}; };
use common::queue::QueueMessage; use common::queue::{AssistantMessage, ChannelMessage, QueueMessage};
pub async fn send_message( pub async fn send_message(
sender: tokio::sync::mpsc::Sender<QueueMessage>, sender: tokio::sync::mpsc::Sender<QueueMessage>,
messages: Vec<ChatCompletionRequestMessage>, messages: Vec<ChatCompletionRequestMessage>,
channel: ChannelMessage,
) -> Result<(), reqwest::Error> { ) -> Result<(), reqwest::Error> {
let result = request_llm(messages).await; let result = request_llm(messages).await;
match result { match result {
@@ -18,25 +17,30 @@ pub async fn send_message(
for choice in result.unwrap().choices { for choice in result.unwrap().choices {
let messsage_content = choice.message.content; let messsage_content = choice.message.content;
let messsage_reasoning = choice.message.reasoning_content.unwrap_or_default(); 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( sender
messsage_reasoning, .send(QueueMessage::Aissitant(
messsage_content, channel.clone(),
)) AssistantMessage {
.await reasoning: Some(messsage_reasoning),
.unwrap(); content: messsage_content,
} tool_calls,
},
))
.await
.unwrap();
match choice.message.tool_calls { match choice.message.tool_calls {
Some(tool_calls) => { Some(tool_calls) => {
// eprintln!("调用了{}个工具", tool_calls.len()); println!("调用了{}个工具", tool_calls.len());
let queue_message = call_tools(tool_calls).await.unwrap(); let queue_message = call_tools(channel.clone(), tool_calls).await.unwrap();
if let Some(queue_message) = queue_message { if let Some(queue_message) = queue_message {
sender.send(queue_message).await.unwrap(); sender.send(queue_message).await.unwrap();
} }
} }
None => { None => {
// eprintln!("没有调用工具"); eprintln!("没有调用工具");
} }
} }
} }
+8 -3
View File
@@ -1,10 +1,14 @@
use chrono::Local; use chrono::Local;
use serde_json::error; use serde_json::error;
use common::{ai::ChatCompletionResponseToolCall, queue::QueueMessage}; use common::{
ai::ChatCompletionToolCall,
queue::{ChannelMessage, QueueMessage},
};
pub async fn call_tools( pub async fn call_tools(
tools: Vec<ChatCompletionResponseToolCall>, channel: ChannelMessage,
tools: Vec<ChatCompletionToolCall>,
) -> Result<Option<QueueMessage>, error::Error> { ) -> Result<Option<QueueMessage>, error::Error> {
if let Some(tool) = tools.first() { if let Some(tool) = tools.first() {
if let Some(funcation) = tool.function.as_ref() { if let Some(funcation) = tool.function.as_ref() {
@@ -12,6 +16,7 @@ pub async fn call_tools(
let date_time = Local::now(); let date_time = Local::now();
let formatted = date_time.format("%Y-%m-%d %H:%M:%S").to_string(); let formatted = date_time.format("%Y-%m-%d %H:%M:%S").to_string();
return Ok(Some(QueueMessage::Tool( return Ok(Some(QueueMessage::Tool(
channel,
tool.id.clone(), tool.id.clone(),
funcation.name.clone(), funcation.name.clone(),
format!("现在时间:{}", formatted), format!("现在时间:{}", formatted),
@@ -59,4 +64,4 @@ pub fn get_tool() -> Vec<serde_json::Value> {
// } // }
// }), // }),
]; ];
} }
+1 -1
View File
@@ -6,4 +6,4 @@ edition = "2024"
[dependencies] [dependencies]
# ── 序列化 ── # ── 序列化 ──
serde = { version = "1.0", features = ["derive"] } # 序列化/反序列化 serde = { version = "1.0", features = ["derive"] } # 序列化/反序列化
serde_json = "1.0" serde_json = "1.0"
+28 -15
View File
@@ -23,6 +23,7 @@ pub struct ChatCompletionRequestMessage {
pub role: String, pub role: String,
pub content: String, pub content: String,
pub tool_call_id: Option<String>, pub tool_call_id: Option<String>,
pub tool_calls: Option<Vec<ChatCompletionToolCall>>,
} }
impl ChatCompletionRequestMessage { impl ChatCompletionRequestMessage {
pub fn new(role: String, content: String) -> Self { pub fn new(role: String, content: String) -> Self {
@@ -30,13 +31,28 @@ impl ChatCompletionRequestMessage {
role, role,
content, content,
tool_call_id: None, 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 { pub fn tool(role: String, content: String, tool_call_id: String) -> Self {
Self { Self {
role, role,
content, content,
tool_call_id: Some(tool_call_id), tool_call_id: Some(tool_call_id),
tool_calls: None,
} }
} }
} }
@@ -51,7 +67,7 @@ pub struct ChatCompletionResponseBody {
model: String, model: String,
service_tier: String, service_tier: String,
pub choices: Vec<ChatCompletionResponseChoice>, pub choices: Vec<ChatCompletionResponseChoice>,
pub usage: ChatCompletionResponseUsage, pub usage: ChatCompletionUsage,
} }
#[allow(dead_code)] #[allow(dead_code)]
@@ -68,40 +84,37 @@ pub struct ChatCompletionResponseMessage {
pub role: String, pub role: String,
pub content: String, pub content: String,
pub reasoning_content: Option<String>, pub reasoning_content: Option<String>,
pub tool_calls: Option<Vec<ChatCompletionResponseToolCall>>, pub tool_calls: Option<Vec<ChatCompletionToolCall>>,
} }
#[allow(dead_code)] #[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Deserialize)] pub struct ChatCompletionToolCall {
pub struct ChatCompletionResponseToolCall { pub function: Option<ChatCompletionFunction>,
pub function: Option<ChatCompletionResponseFunction>,
pub id: String, pub id: String,
#[serde(rename = "type")] #[serde(rename = "type")]
pub call_type: String, pub call_type: String,
} }
#[allow(dead_code)] #[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Deserialize)] pub struct ChatCompletionFunction {
pub struct ChatCompletionResponseFunction {
pub arguments: String, pub arguments: String,
pub name: String, pub name: String,
} }
#[allow(dead_code)]
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct ChatCompletionResponseUsage { pub struct ChatCompletionUsage {
pub prompt_tokens: u32, pub prompt_tokens: u32,
pub completion_tokens: u32, pub completion_tokens: u32,
pub total_tokens: u32, pub total_tokens: u32,
pub prompt_tokens_details: ChatCompletionResponseUsagePrompt, pub prompt_tokens_details: ChatCompletionUsagePrompt,
pub completion_tokens_details: ChatCompletionResponseUsageCompletion, pub completion_tokens_details: ChatCompletionUsageCompletion,
} }
#[allow(dead_code)] #[allow(dead_code)]
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct ChatCompletionResponseUsagePrompt { pub struct ChatCompletionUsagePrompt {
pub cached_tokens: u32, pub cached_tokens: u32,
} }
#[allow(dead_code)] #[allow(dead_code)]
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct ChatCompletionResponseUsageCompletion { pub struct ChatCompletionUsageCompletion {
pub reasoning_tokens: u32, 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 ai;
pub mod db;
pub mod queue; pub mod queue;
+23 -4
View File
@@ -1,14 +1,33 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::ai::ChatCompletionToolCall;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum QueueMessage { pub enum QueueMessage {
Channel(String, String), Channel(ChannelMessage),
Aissitant(String, String), Aissitant(ChannelMessage, AssistantMessage),
Tool(String, String, String), Tool(ChannelMessage, String, String, String),
} }
#[derive(Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelMessage { pub struct ChannelMessage {
pub channel: String, pub channel: String,
pub account_id: String,
pub from_user_id: String,
pub content: 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" anyhow = "1"
thiserror = "2" 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 -- Add migration script here
CREATE TABLE ias_wechat_users ( CREATE TABLE ias_wechat_accounts (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
user_name VARCHAR(100) NOT NULL, user_name VARCHAR(100) NOT NULL,
token VARCHAR(255), token VARCHAR(255),
account_id VARCHAR(255), account_id VARCHAR(255),
base_url VARCHAR(255), base_url VARCHAR(255),
user_id VARCHAR(255),
updates_buf VARCHAR(255),
user_status INTEGER, user_status INTEGER,
created_at TIMESTAMP NOT NULL DEFAULT NOW() 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 tokio::sync::Mutex;
use common::ai::ChatCompletionRequestMessage; use common::ai::{ChatCompletionRequestMessage, ChatCompletionToolCall};
static CHAT_CONTEXT: OnceLock<Mutex<Vec<ChatCompletionRequestMessage>>> = OnceLock::new(); static CHAT_CONTEXT: OnceLock<Mutex<Vec<ChatCompletionRequestMessage>>> = OnceLock::new();
@@ -20,8 +20,15 @@ pub async fn snapshot_context() -> Vec<ChatCompletionRequestMessage> {
context.clone() context.clone()
} }
pub async fn add_assistant_message(content: String) { pub async fn add_assistant_message(
let message = ChatCompletionRequestMessage::new("assistant".to_string(), content); content: String,
tool_calls: Option<Vec<ChatCompletionToolCall>>,
) {
let message = ChatCompletionRequestMessage::assistant(
"assistant".to_string(),
content,
tool_calls,
);
push_context_message(message).await; 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) { 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; push_context_message(message).await;
} }
+3 -2
View File
@@ -1,2 +1,3 @@
pub mod queue; pub mod cli;
pub mod context; pub mod context;
pub mod queue;
+61 -39
View File
@@ -1,62 +1,84 @@
use crate::{ use crate::core::context::{
AppState, add_assistant_message, add_tool_message, add_user_message, snapshot_context,
core::context::{add_assistant_message, add_tool_message, add_user_message, snapshot_context},
}; };
use ai::core::send_message; use ai::core::send_message;
use axum::Json; use common::queue::QueueMessage;
use axum::extract::State;
use common::queue::{ChannelMessage, QueueMessage};
use serde_json::error; use serde_json::error;
use std::sync::Arc; use tokio::sync::{mpsc::Receiver, mpsc::Sender};
use tokio::sync::{mpsc, mpsc::Sender}; use wechat::manager::WeChatMultiAccountManager;
pub async fn create_queue() -> Result<Sender<QueueMessage>, error::Error> { pub async fn create_queue(
let (sender, mut receiver) = mpsc::channel::<QueueMessage>(100); sender: Sender<QueueMessage>,
let result_sender = sender.clone(); mut receiver: Receiver<QueueMessage>,
manager: WeChatMultiAccountManager,
) -> Result<(), error::Error> {
tokio::spawn(async move { tokio::spawn(async move {
while let Some(msg) = receiver.recv().await { while let Some(msg) = receiver.recv().await {
let queue_sender = sender.clone(); let queue_sender = sender.clone();
match msg { 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); println!("\n\n收到llm回复:\n{}\n-------\n{}", reasoning, content);
add_assistant_message(content).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();
}
add_assistant_message(assistant.content, assistant.tool_calls).await;
} }
QueueMessage::Channel(name, text) => { QueueMessage::Channel(bundle) => {
println!("\n\n收到来自{}消息:\n{}", name, text); println!(
add_user_message(text).await; "\n\n收到来自{}消息:\n{}",
send_message(queue_sender, snapshot_context().await) 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 .await
.unwrap(); .unwrap();
} }
QueueMessage::Tool(tool_call_id, name, result) => { QueueMessage::Tool(channel, tool_call_id, name, result) => {
println!("\n\n调用{}工具的结果:\n{}", name, result); println!("\n\n调用{}工具的结果:\n{}", name, result);
add_tool_message(tool_call_id, result).await; add_tool_message(tool_call_id, result).await;
send_message(queue_sender, snapshot_context().await) send_message(queue_sender, snapshot_context().await, channel)
.await .await
.unwrap(); .unwrap();
} }
} }
} }
}); });
Ok(result_sender) Ok(())
} }
pub async fn send_to_queue( // pub async fn send_to_queue(
State(state): State<Arc<AppState>>, // State(state): State<Arc<AppState>>,
Json(body): Json<ChannelMessage>, // Json(body): Json<ChannelMessageBundle>,
) -> Json<serde_json::Value> { // ) -> Json<serde_json::Value> {
match state // match state
.sender // .sender
.send(QueueMessage::Channel(body.channel, body.content)) // .send(QueueMessage::Channel(body.channel, body.content))
.await // .await
{ // {
Ok(_) => Json(serde_json::json!({ // Ok(_) => Json(serde_json::json!({
"success":true, // "success":true,
"message":"queued" // "message":"queued"
})), // })),
Err(_) => Json(serde_json::json!({ // Err(_) => Json(serde_json::json!({
"success":true, // "success":true,
"message":"queue closed" // "message":"queue closed"
})), // })),
} // }
} // }
+2 -1
View File
@@ -5,7 +5,7 @@ use axum::{
}; };
use serde_json::json; use serde_json::json;
#[allow(unused)]
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum AppError { pub enum AppError {
#[error("not found")] #[error("not found")]
@@ -30,4 +30,5 @@ impl IntoResponse for AppError {
} }
} }
#[allow(unused)]
pub type AppResult<T> = Result<T, AppError>; pub type AppResult<T> = Result<T, AppError>;
+9 -8
View File
@@ -1,20 +1,21 @@
use std::sync::Arc; use std::sync::Arc;
use axum::routing::{get, post}; use anyhow::Ok;
use axum::routing::{get};
use axum::{Json, Router}; use axum::{Json, Router};
use crate::AppState; 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() let app = Router::new()
.route("/message", post(send_to_queue)) // .route("/message", post(send_to_queue))
.route("/health", get(health)) .route("/health", get(health))
.with_state(Arc::new(state)); .with_state(Arc::new(state));
let listener = tokio::net::TcpListener::bind(host.clone()).await?;
let listener = tokio::net::TcpListener::bind(host.clone()).await.unwrap(); println!("服务启动在:http://{}", listener.local_addr()?);
axum::serve(listener, app).await.unwrap(); axum::serve(listener, app).await?;
println!("服务启动在:{}", host); Ok(())
} }
async fn health() -> Json<serde_json::Value> { async fn health() -> Json<serde_json::Value> {
+42 -23
View File
@@ -2,8 +2,10 @@ mod channel;
mod core; mod core;
mod error; mod error;
mod http; mod http;
mod model;
mod repository; mod repository;
use clap::Parser;
use common::queue::QueueMessage; use common::queue::QueueMessage;
use core::queue::create_queue; use core::queue::create_queue;
use http::app::create_http; use http::app::create_http;
@@ -12,7 +14,10 @@ use std::env;
use tokio::sync::mpsc; 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)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
@@ -21,35 +26,49 @@ pub struct AppState {
} }
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> anyhow::Result<()> {
if std::env::var("RUST_ANALYZER").is_err() { dotenvy::dotenv().ok();
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());
let db_url = env::var("DATABASE_URL").expect("必须设置DATABASE_URL"); let db_url = env::var("DATABASE_URL").expect("必须设置DATABASE_URL");
let pool = PgPoolOptions::new() let pool = PgPoolOptions::new()
.max_connections(10) .max_connections(10)
.connect(&db_url) .connect(&db_url)
.await?; .await?;
let cli = Cli::parse();
let sender = create_queue().await.unwrap(); match cli.command {
let state = AppState { Commands::Service => {
sender: sender, service(pool).await.unwrap();
db: pool, }
}; Commands::Channel { command } => match command {
Channels::Wechat { command } => match command {
println!("{} start in : {}", name, host); WechatAction::Login => login_user(&pool).await,
create_http(state.clone(), host).await; WechatAction::List => list_accounts(&pool).await,
init_wechat(Some(state)).await.unwrap(); WechatAction::Delete { account } => delete_account(&pool, account).await,
WechatAction::Rename { account, name } => {
rename_account(&pool, account, name).await
}
},
},
}
Ok(()) Ok(())
} }
#[test] async fn service(pool: PgPool) -> anyhow::Result<()> {
fn test() { let name: String = env::var("NAME").unwrap_or_else(|_| "ias".to_string());
let api_key = std::env::var("ROUTER_API_KEY").unwrap(); let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0:9003".to_string());
println!("{}", api_key);
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,
}
+91 -22
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; pub struct UserRepositor;
impl UserRepositor { impl UserRepositor {
pub async fn create(pool: &PgPool, user: WechatUserCreate) -> anyhow::Result<isize> { pub async fn add_wechat_account(
let record = sqlx::query!( pool: &PgPool,
user: WechatAccountCreate,
) -> anyhow::Result<i64> {
let row = sqlx::query(
r#" r#"
INSERT INTO ias_wechat_users (user_name,token,account_id,base_url,user_id,user_status) INSERT INTO ias_wechat_accounts (user_name,token,account_id,base_url,user_id,user_status)
VALUE ($1,$2,$3,$4,$5,1) VALUES ($1,$2,$3,$4,$5,1)
RETURNING id 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) .fetch_one(pool)
.await?; .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!( pub async fn find_wechat_account(
WechatUser, pool: &PgPool,
account_id: String,
) -> anyhow::Result<WechatAccount> {
let row = sqlx::query_as::<_, WechatAccount>(
r#" r#"
SELECT * SELECT *
FROM ias_wechat_users FROM ias_wechat_accounts
WHERE user_id = $1 WHERE account_id = $1
AND user_status = 1
"#, "#,
user_id,
) )
.fetch_optional(pool) .bind(account_id)
.fetch_one(pool)
.await?; .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())
} }
} }
+256 -349
View File
@@ -1,8 +1,11 @@
# wechat 模块使用文档 # 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/ wechat/
src/ src/
lib.rs # 模块导出 lib.rs # 模块导出
client.rs # WeChatClient,封装所有 HTTP API 调用 client.rs # 单账号 WeChatClient
types.rs # iLink Bot API 请求、响应和消息结构 manager.rs # 多账号 WeChatMultiAccountManager
types.rs # iLink Bot API 协议类型
``` ```
公开模块: 公开模块:
```rust ```rust
pub mod client; pub mod client;
pub mod manager;
pub mod types; pub mod types;
``` ```
常用类型 常用导入
```rust ```rust
use wechat::client::WeChatClient; use wechat::client::WeChatClient;
use wechat::types::{GetUpdatesResp, LoginResult, MessageItem, WeixinMessage}; use wechat::manager::{WeChatAccountConfig, WeChatEvent, WeChatMultiAccountManager};
use wechat::types::{MessageItem, WeixinMessage};
``` ```
## Cargo 引用 ## Cargo 引用
本仓库根目录已经把 `wechat` 加入 workspace workspace 内其他 crate 使用
```toml
[workspace]
members = ["service", "ai", "common", "wechat"]
```
如果其他 workspace crate 需要使用它,在对应 `Cargo.toml` 里加入:
```toml ```toml
[dependencies] [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)` #[tokio::main]
2. 扫码登录:`login(...)` async fn main() -> Result<(), String> {
3. 持久化登录结果:保存 `token``account_id``base_url` let client = WeChatClient::new(None);
4. 注册监听器:`notify_start()`
5. 循环拉取消息:`receive_messages()`
6. 处理用户消息并回复:`send_text(...)`
7. 服务退出前注销监听器:`notify_stop()`
## 快速开始 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 ```rust
use std::time::Duration; use std::time::Duration;
@@ -80,23 +102,27 @@ use wechat::types::WeixinMessage;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), String> { async fn main() -> Result<(), String> {
let client = WeChatClient::new(None); let mut client = WeChatClient::new(None);
let login = client client
.login( .set_auth(
|qrcode_url| { "持久化保存的 token",
println!("二维码地址: {qrcode_url}"); "持久化保存的 account_id",
}, "持久化保存的 base_url",
120,
) )
.await?; .await;
client
println!("登录成功: account_id={}", login.account_id); .set_updates_buf("持久化保存的 get_updates_buf")
.await;
client.notify_start().await?; client.notify_start().await?;
loop { loop {
let updates = client.receive_messages().await?; 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 { for msg in updates.msgs {
if msg.msg_type != Some(WeixinMessage::TYPE_USER) { if msg.msg_type != Some(WeixinMessage::TYPE_USER) {
@@ -106,323 +132,210 @@ async fn main() -> Result<(), String> {
let Some(from_user_id) = msg.from_user_id.as_deref() else { let Some(from_user_id) = msg.from_user_id.as_deref() else {
continue; continue;
}; };
let Some(text) = msg.text_content() else { let Some(text) = msg.text_content() else {
continue; continue;
}; };
client client
.send_text(from_user_id, &format!("收到: {text}"), msg.context_token.as_deref()) .send_text(
from_user_id,
&format!("收到: {text}"),
msg.context_token.as_deref(),
)
.await?; .await?;
} }
tokio::time::sleep(Duration::from_secs(1)).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 ```rust
pub async fn login( use wechat::manager::WeChatAccountConfig;
&self,
on_qrcode: impl Fn(&str), let account = WeChatAccountConfig::new(
timeout_secs: u64, "account_id",
) -> Result<LoginResult, String> "token",
"base_url",
"get_updates_buf",
);
``` ```
参数说明: 字段说明:
| 参数 | 说明 |
| --- | --- |
| `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
.set_updates_buf("持久化保存的 get_updates_buf")
.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 消息。 | | `account_id` | 微信 iLink Bot 账号 ID,来自登录返回的 `LoginResult.account_id`。 |
| `from_user_id` | 发送者 ID。回复私聊消息时通常作为 `send_text``to`。 | | `token` | 登录返回的 `LoginResult.token`。 |
| `to_user_id` | 接收者 ID。 | | `base_url` | 登录返回的 `LoginResult.base_url`。 |
| `group_id` | 群消息 ID,群聊场景可能非空。 | | `updates_buf` | 上次轮询保存的 `get_updates_buf`,新账号可传空字符串。 |
| `item_list` | 消息内容列表,可包含文本、图片、语音、文件、视频。 |
| `context_token` | 微信上下文令牌。回复时透传可保持多轮上下文。 |
## 发送文本消息 ### 启动多个账号
接口:
```rust ```rust
pub async fn send_text( use wechat::manager::{WeChatAccountConfig, WeChatEvent, WeChatMultiAccountManager};
&self, use wechat::types::WeixinMessage;
to: &str,
text: &str,
context_token: Option<&str>,
) -> Result<String, String>
```
参数说明: #[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 {
| `to` | 接收者用户 ID。通常来自收到消息的 `from_user_id`。 | manager.start_account(account).await?;
| `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);
}
_ => {}
} }
}
```
## 长轮询循环建议 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;
推荐业务层把接收循环放到独立 tokio task: if message.msg_type != Some(WeixinMessage::TYPE_USER) {
continue;
```rust
let client_for_loop = client.clone();
tokio::spawn(async move {
loop {
match client_for_loop.receive_messages().await {
Ok(updates) => {
for msg in updates.msgs {
// 处理消息
} }
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?;
} }
Err(err) => { WeChatEvent::PollError { account_id, error } => {
tracing::warn!("接收微信消息失败: {err}"); tracing::warn!("微信账号轮询失败 account_id={account_id}: {error}");
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(());
}
let Some(from_user_id) = msg.from_user_id.as_deref() else {
return Ok(());
};
let Some(user_text) = msg.text_content() else {
return Ok(());
};
let answer = call_ai(user_text).await?;
client
.send_text(from_user_id, &answer, msg.context_token.as_deref())
.await?;
Ok(()) Ok(())
} }
async fn call_ai(input: &str) -> Result<String, String> { async fn load_accounts() -> Vec<WeChatAccountConfig> {
Ok(format!("AI 回复: {input}")) 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 端点 ## 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/getupdates` | 长轮询拉取消息 |
| `POST` | `/ilink/bot/sendmessage` | 发送消息 | | `POST` | `/ilink/bot/sendmessage` | 发送消息 |
请求头由模块内部生成 登录后请求会带
- `Content-Type: application/json` ```text
- `Content-Length` Authorization: Bearer <token>
- `iLink-App-ClientVersion` AuthorizationType: ilink_bot_token
- `X-WECHAT-UIN` ```
- 登录后请求额外带:
- `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 ```text
ias::auth ias::auth
``` ```
业务程序可初始化日志 推荐初始化:
```rust ```rust
tracing_subscriber::fmt() tracing_subscriber::fmt()
@@ -459,34 +381,19 @@ tracing_subscriber::fmt()
.init(); .init();
``` ```
## 错误处理
所有公开异步方法当前返回 `Result<_, String>`
常见错误:
| 场景 | 典型错误 | 处理建议 |
| --- | --- | --- |
| 二维码过期 | `二维码已过期` | 重新调用 `login()`。 |
| 登录超时 | `登录超时` | 增大 `timeout_secs` 或重新登录。 |
| token 无效 | HTTP 401/403 或接口返回错误 | 清理持久化登录态,重新扫码登录。 |
| 长轮询超时 | 返回空 `msgs` | 正常情况,继续下一轮。 |
| 网络错误 | `请求失败` | 延迟重试,必要时检查 `WEIXIN_BASE_URL`。 |
## 当前限制 ## 当前限制
- `ILINK_APP_ID` 当前是空字符串。如接入方要求 app id,需要在 `client.rs` 中配置或改造成环境变量 - `send_text()` 只封装文本发送
- 登录态只保存在内存中,重启恢复需要业务层调用 `set_auth()``set_updates_buf()` - manager 不内置账号持久化,业务层负责数据库或文件存储
- 只提供文本发送便捷方法 `send_text()` - manager 通过 `JoinHandle::abort()` 停止长轮询任务;如需立即优雅取消 HTTP 请求,可后续在 client 层增加 cancellation 支持
- 群聊回复策略需要业务层结合 `group_id` 和实际 iLink 协议进一步确认 - `ILINK_APP_ID` 当前为空字符串;如果接入方要求 app id,需要在 `client.rs` 中配置或改为环境变量
- `send_text()` 当前不解析服务端响应体,只在 HTTP 层确认请求成功后返回本地 `client_id`
## 排障清单 ## 排障清单
1. 确认能访问 `WEIXIN_BASE_URL` 1. 确认 `base_url` 可访问
2. 确认扫码后在微信侧完成确认 2. 确认账号已经扫码登录并保存了 token
3. 登录后先调用 `notify_start()`,再调用 `receive_messages()` 3. 每个账号只启动一个监听任务
4. 如果收不到消息,检查 `get_updates_buf` 是否恢复了过旧状态;必要时清空后重试 4. 每个账号独立保存 `updates_buf`
5. 如果发送失败,检查 token 是否过期,以及 `to` 是否使用了正确的 `from_user_id` 5. 登录或恢复后先调用 `notify_start()`,再开始 `getupdates`
6. 打开 `ias::auth=debug` 日志观察 API 请求和状态变化 6. 发送消息时显式指定 `account_id`,并尽量透传 `context_token`
7. 打开 `ias::auth=debug` 查看请求和轮询状态。
+3 -1
View File
@@ -3,6 +3,7 @@
//! 封装与微信 iLink Bot API 的所有通信: //! 封装与微信 iLink Bot API 的所有通信:
//! //!
//! - `client` — HTTP 客户端(登录、长轮询收消息、发消息、生命周期管理) //! - `client` — HTTP 客户端(登录、长轮询收消息、发消息、生命周期管理)
//! - `manager` — 多账号监听管理器(每账号一个独立长轮询任务)
//! - `types` — API 协议类型定义(消息结构、请求/响应体) //! - `types` — API 协议类型定义(消息结构、请求/响应体)
//! //!
//! ### 核心流程 //! ### 核心流程
@@ -13,4 +14,5 @@
//! 5. `notify_stop()` — 注销监听器 //! 5. `notify_stop()` — 注销监听器
pub mod client; pub mod client;
pub mod types; 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();
}
}
}