初步完成channel->queueu->[tool]->[queue]->[assistant]->channel的流程
【待处理】上下文管理和错误处理。
This commit is contained in:
@@ -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
@@ -2,4 +2,5 @@
|
||||
*/target
|
||||
.env
|
||||
.vscode
|
||||
.agents
|
||||
.agents
|
||||
.codegraph
|
||||
|
||||
Generated
+123
@@ -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"
|
||||
|
||||
+19
-15
@@ -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() {
|
||||
sender
|
||||
.send(QueueMessage::Aissitant(
|
||||
messsage_reasoning,
|
||||
messsage_content,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let tool_calls = choice.message.tool_calls.clone();
|
||||
|
||||
sender
|
||||
.send(QueueMessage::Aissitant(
|
||||
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!("没有调用工具");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-3
@@ -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),
|
||||
@@ -59,4 +64,4 @@ pub fn get_tool() -> Vec<serde_json::Value> {
|
||||
// }
|
||||
// }),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,4 +6,4 @@ edition = "2024"
|
||||
[dependencies]
|
||||
# ── 序列化 ──
|
||||
serde = { version = "1.0", features = ["derive"] } # 序列化/反序列化
|
||||
serde_json = "1.0"
|
||||
serde_json = "1.0"
|
||||
+28
-15
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,3 +1,2 @@
|
||||
pub mod ai;
|
||||
pub mod db;
|
||||
pub mod queue;
|
||||
|
||||
+23
-4
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
+3
-1
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod manager;
|
||||
pub mod looper;
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod queue;
|
||||
pub mod context;
|
||||
pub mod cli;
|
||||
pub mod context;
|
||||
pub mod queue;
|
||||
+61
-39
@@ -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;
|
||||
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) => {
|
||||
println!("\n\n收到来自{}消息:\n{}", name, text);
|
||||
add_user_message(text).await;
|
||||
send_message(queue_sender, snapshot_context().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(tool_call_id, name, result) => {
|
||||
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"
|
||||
// })),
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
SELECT *
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
+256
-349
@@ -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,23 +102,27 @@ use wechat::types::WeixinMessage;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), String> {
|
||||
let client = WeChatClient::new(None);
|
||||
let mut client = WeChatClient::new(None);
|
||||
|
||||
let login = client
|
||||
.login(
|
||||
|qrcode_url| {
|
||||
println!("二维码地址: {qrcode_url}");
|
||||
},
|
||||
120,
|
||||
client
|
||||
.set_auth(
|
||||
"持久化保存的 token",
|
||||
"持久化保存的 account_id",
|
||||
"持久化保存的 base_url",
|
||||
)
|
||||
.await?;
|
||||
|
||||
println!("登录成功: account_id={}", login.account_id);
|
||||
.await;
|
||||
client
|
||||
.set_updates_buf("持久化保存的 get_updates_buf")
|
||||
.await;
|
||||
|
||||
client.notify_start().await?;
|
||||
|
||||
loop {
|
||||
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 {
|
||||
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 {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(text) = msg.text_content() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
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?;
|
||||
}
|
||||
|
||||
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
|
||||
pub async fn login(
|
||||
&self,
|
||||
on_qrcode: impl Fn(&str),
|
||||
timeout_secs: u64,
|
||||
) -> Result<LoginResult, String>
|
||||
use wechat::manager::WeChatAccountConfig;
|
||||
|
||||
let account = WeChatAccountConfig::new(
|
||||
"account_id",
|
||||
"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 消息。 |
|
||||
| `from_user_id` | 发送者 ID。回复私聊消息时通常作为 `send_text` 的 `to`。 |
|
||||
| `to_user_id` | 接收者 ID。 |
|
||||
| `group_id` | 群消息 ID,群聊场景可能非空。 |
|
||||
| `item_list` | 消息内容列表,可包含文本、图片、语音、文件、视频。 |
|
||||
| `context_token` | 微信上下文令牌。回复时透传可保持多轮上下文。 |
|
||||
| `account_id` | 微信 iLink Bot 账号 ID,来自登录返回的 `LoginResult.account_id`。 |
|
||||
| `token` | 登录返回的 `LoginResult.token`。 |
|
||||
| `base_url` | 登录返回的 `LoginResult.base_url`。 |
|
||||
| `updates_buf` | 上次轮询保存的 `get_updates_buf`,新账号可传空字符串。 |
|
||||
|
||||
## 发送文本消息
|
||||
|
||||
接口:
|
||||
### 启动多个账号
|
||||
|
||||
```rust
|
||||
pub async fn send_text(
|
||||
&self,
|
||||
to: &str,
|
||||
text: &str,
|
||||
context_token: Option<&str>,
|
||||
) -> Result<String, String>
|
||||
```
|
||||
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);
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `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);
|
||||
}
|
||||
_ => {}
|
||||
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;
|
||||
|
||||
推荐业务层把接收循环放到独立 tokio task:
|
||||
|
||||
```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 {
|
||||
// 处理消息
|
||||
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?;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("接收微信消息失败: {err}");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
WeChatEvent::PollError { account_id, error } => {
|
||||
tracing::warn!("微信账号轮询失败 account_id={account_id}: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
- `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(())
|
||||
}
|
||||
|
||||
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` 查看请求和轮询状态。
|
||||
|
||||
+3
-1
@@ -3,6 +3,7 @@
|
||||
//! 封装与微信 iLink Bot API 的所有通信:
|
||||
//!
|
||||
//! - `client` — HTTP 客户端(登录、长轮询收消息、发消息、生命周期管理)
|
||||
//! - `manager` — 多账号监听管理器(每账号一个独立长轮询任务)
|
||||
//! - `types` — API 协议类型定义(消息结构、请求/响应体)
|
||||
//!
|
||||
//! ### 核心流程
|
||||
@@ -13,4 +14,5 @@
|
||||
//! 5. `notify_stop()` — 注销监听器
|
||||
|
||||
pub mod client;
|
||||
pub mod types;
|
||||
pub mod manager;
|
||||
pub mod types;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user