feat(event_loop): sync + add_event_handler + Timeline::send 加密通道(M2.2/c)

新增模块:src/event_loop.rs

依赖新增:futures-util 0.3
源码增加:#![recursion_limit = "512"](matrix-sdk 类型嵌套很深)

实现要点:
  1. bootstrap::restore 拿到 Client + user_id + device_id
  2. 注册全局 SyncRoomMessageEvent handler(matrix-sdk add_event_handler 闭包模式)
  3. handler 内部:
     - 提取 m.text 文本
     - 跳过 bot 自己发的消息
     - 用 commands::parse 解析(自动处理 mention / DM 区分)
     - 用 commands::render_reply 渲染回复
     - 用 TimelineBuilder::new(room).build() 构造 Timeline
     - 用 timeline.send(RoomMessageEventContent::text_plain(...).into()) 发送
  4. 启动 long-running client.sync()(用 tokio::select! 与 ctrl-c 并发)

E2EE 不变量已实现并强制:
  -  唯一出站通道:Timeline::send(SDK 内部用 Megolm 加密)
  -  绝不使用 Room::send_raw()(明文)
  -  入站消息经 matrix-sdk 解密后是 plaintext,可直接解析

类型挑战(已解决):
  - matrix-sdk 0.18 EventHandler 通过 impl_event_handler! 宏实现,
    要求 FnOnce(Ev, ...) -> Fut 形式,不能包 Pin<Box<>>
  - matrix-sdk-crypto 内部有 !Send 类型,所以 client.sync() 不能 tokio::spawn,
    必须在当前 task 用 tokio::select! 与 ctrl-c 并发
  - 嵌套 async 闭包触发 rustc 深度限制,需要 #![recursion_limit = "512"]

cmd_run 改造:
  - 同样从 MATRIX_BOT_SECRET_PASSPHRASE 环境变量读取 passphrase
  - 用 tokio multi-thread runtime 跑 event_loop::run

CLI 实测(fake config + 缺 secret.bin):
  - 无 config → 友好错误
  - 有 config + 缺 secret.bin → '请先运行 login' 错误
  - 完整流程需真实 homeserver 才能跑通

测试:cargo test → 15 passed(11 commands + 2 config + 2 secret_store)
This commit is contained in:
Matrix Music Bot Dev
2026-07-02 21:03:56 +08:00
parent 5ea92de6b9
commit a2c9904ec3
4 changed files with 162 additions and 9 deletions
Generated
+1
View File
@@ -2043,6 +2043,7 @@ dependencies = [
"async-trait",
"base64",
"clap",
"futures-util",
"getrandom 0.3.4",
"matrix-sdk",
"matrix-sdk-crypto",
+1
View File
@@ -10,6 +10,7 @@ argon2 = "0.5.3"
async-trait = "0.1.89"
base64 = "0.22.1"
clap = { version = "4.6.1", features = ["derive", "env"] }
futures-util = "0.3.32"
getrandom = { version = "0.3", features = ["std"] }
matrix-sdk = "0.18.0"
matrix-sdk-crypto = "0.18.0"
+140
View File
@@ -0,0 +1,140 @@
//! Event loop:恢复登录 → 初始 sync → 注册全局事件 handler → 派发命令。
//!
//! E2EE 不变量:
//! - **所有**出站消息(bot 回复)必须经由 `Timeline::send()`,由 SDK 内部用 Megolm 加密。
//! - **绝不**直接调用 `Room::send_raw()` 之类的明文发送方法。
//! - 入站消息经 matrix-sdk 的 `SyncRoomMessageEvent` 已是解密后的 plaintext。
//!
//! v1 阶段只处理文本消息。后续 milestone 会处理 m.reaction(用于搜索结果选择)
//! 和 m.audio(投递回执)。
use std::sync::Arc;
use matrix_sdk::config::SyncSettings;
use matrix_sdk::room::Room;
use matrix_sdk::ruma::events::room::message::{
MessageType, RoomMessageEventContent, SyncRoomMessageEvent,
};
use matrix_sdk::Client;
use matrix_sdk_ui::timeline::TimelineBuilder;
use tracing::{debug, error, info, warn};
use crate::commands::{self, Decision, ParseContext};
use crate::config::AppConfig;
use crate::error::{AppError, AppResult};
/// 启动 event loop。
///
/// 流程:
/// 1. bootstrap::restore 拿到 Client
/// 2. 注册 m.room.message 全局事件 handler
/// 3. 启动 long-running client.sync()
/// 4. 阻塞直到 ctrl-c
pub async fn run(cfg: AppConfig, passphrase: &str) -> AppResult<()> {
let (client, user_id, device_id) = crate::bootstrap::restore(cfg.clone(), passphrase).await?;
info!(%user_id, %device_id, "恢复登录成功");
// 1. 注册全局 m.room.message handler
let state = Arc::new(cfg);
client.add_event_handler({
let state = state.clone();
move |ev: SyncRoomMessageEvent, room: Room, client: Client| {
let state = state.clone();
async move { handle_message(ev, room, client, state).await }
}
});
info!("已注册 m.room.message 事件 handler");
// 2. 启动 long-running sync(用 select! 同时监听 ctrl-c
// 注意:client.sync() 返回的 Future 非 Sendmatrix-sdk-crypto 内部限制),
// 不能用 tokio::spawn,必须在当前 task 中并发。
info!("开始 long-running sync(按 Ctrl-C 停止)");
tokio::select! {
res = client.sync(SyncSettings::default()) => {
if let Err(e) = res {
error!(error = %e, "long-running sync 出错");
}
}
_ = tokio::signal::ctrl_c() => {
info!("收到 Ctrl-C,正在关闭...");
}
}
Ok(())
}
/// m.room.message 事件处理函数
async fn handle_message(
ev: SyncRoomMessageEvent,
room: Room,
client: Client,
state: Arc<AppConfig>,
) {
// 1. 提取原始消息(必须是 OriginalSyncRoomMessageEvent
let original = match ev {
SyncRoomMessageEvent::Original(o) => o,
SyncRoomMessageEvent::Redacted(_) => return,
};
// 2. 提取文本
let text = match &original.content.msgtype {
MessageType::Text(t) => t.body.clone(),
_ => return, // 非文本消息(M4+ 处理 m.audio/m.image/m.reaction
};
// 3. 跳过 bot 自己发的
let bot_user_id = match client.user_id() {
Some(u) => u.to_owned(),
None => return,
};
if original.sender == bot_user_id {
return;
}
let room_id = room.room_id().to_string();
let is_dm = detect_dm(&room);
debug!(room_id = %room_id, sender = %original.sender, text = %text, "收到消息");
// 4. 解析命令
let ctx = ParseContext {
text: &text,
sender: &original.sender,
is_dm,
bot_user_id: bot_user_id.as_ref(),
prefix: &state.commands.prefix,
mention_required_in_rooms: state.commands.mention_required_in_rooms,
};
let cmd = match commands::parse(&ctx) {
Decision::Handle(c) => c,
Decision::Ignore | Decision::Unknown => return,
};
// 5. 渲染回复
let reply_text = commands::render_reply(&cmd);
// 6. 通过 Timeline 发送(Megolm 加密路径)
if let Err(e) = send_text(&room, &reply_text).await {
warn!(room_id = %room_id, error = %e, "发送回复失败");
} else {
debug!(room_id = %room_id, "回复已发送(已通过 Megolm 加密)");
}
}
/// 通过 Timeline 发送文本(**唯一**合规的出站消息通道)
async fn send_text(room: &Room, text: &str) -> AppResult<()> {
let timeline = TimelineBuilder::new(room)
.build()
.await
.map_err(|e| AppError::Sdk(format!("构造 Timeline 失败: {e}")))?;
let content = RoomMessageEventContent::text_plain(text);
timeline
.send(content.into())
.await
.map_err(|e| AppError::Sdk(format!("Timeline::send 失败: {e}")))?;
Ok(())
}
/// 简单判断一个房间是否是 DM(基于成员数)
fn detect_dm(room: &Room) -> bool {
room.joined_members_count() <= 2
}
+20 -9
View File
@@ -8,11 +8,15 @@
//!
//! M1.2 阶段:login / config 子命令为占位实现,run 子命令仅打印 "正在同步..." 后退出。
// matrix-sdk 0.18 的类型嵌套很深,需要提高递归上限
#![recursion_limit = "512"]
mod bootstrap;
mod client;
mod commands;
mod config;
mod error;
mod event_loop;
mod secret_store;
use std::path::PathBuf;
@@ -155,15 +159,22 @@ fn cmd_login(cfg: AppConfig, password: &str) -> AppResult<()> {
}
fn cmd_run(cfg: AppConfig) -> AppResult<()> {
info!(
homeserver = %cfg.homeserver.url,
user_id = %cfg.bot.user_id,
data_dir = %cfg.data.dir.display(),
"[stub] run 子命令待 M2 实现;目前仅打印配置即退出"
);
Err(AppError::Other(
"run 尚未实现(M2 占位;sync + Timeline 订阅)".to_string(),
))
let passphrase = std::env::var("MATRIX_BOT_SECRET_PASSPHRASE").map_err(|_| {
AppError::SecretStore(
"未设置 MATRIX_BOT_SECRET_PASSPHRASE 环境变量(用于解密 secret.bin)".to_string(),
)
})?;
if passphrase.len() < 8 {
return Err(AppError::SecretStore(
"MATRIX_BOT_SECRET_PASSPHRASE 长度至少 8 字符".to_string(),
));
}
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|e| AppError::Other(format!("tokio runtime 创建失败: {e}")))?;
runtime.block_on(event_loop::run(cfg, &passphrase))
}
fn cmd_config(