feat(event_loop): sync + add_event_handler + Timeline::send 加密通道(M2.2/c)
新增模块:src/event_loop.rs
依赖新增:futures-util 0.3
源码增加:#
实现要点:
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:
Generated
+1
@@ -2043,6 +2043,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"clap",
|
||||
"futures-util",
|
||||
"getrandom 0.3.4",
|
||||
"matrix-sdk",
|
||||
"matrix-sdk-crypto",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 非 Send(matrix-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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user