Files
MyAiDesk/nomifun-tauri/crates/backend/nomifun-channel/src/plugins/mattermost/plugin.rs
T
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

919 lines
28 KiB
Rust

//! Mattermost channel plugin — WebSocket receive + REST send/edit.
//!
//! Mirrors the Telegram plugin lifecycle (initialize/start/stop/send/edit)
//! but uses a persistent WebSocket for inbound events and REST v4 for outbound.
//!
//! ## Interactive buttons
//!
//! Mattermost *does* support interactive message buttons (attachments with
//! `integration.url` callbacks), but they require a **publicly reachable
//! integration callback URL** that the Mattermost server POSTs to when a
//! user clicks a button. A desktop-local Tauri app cannot host such an
//! endpoint, so **buttons are intentionally not implemented**. The
//! `message.buttons` field on outgoing messages is silently ignored.
//! Text + reply + streaming edit are fully supported.
use std::sync::Arc;
use std::time::Duration;
use reqwest::Client;
use tokio::sync::{mpsc, watch};
use tokio::task::JoinHandle;
use tracing::{debug, error, info, warn};
use crate::constants::{MATTERMOST_MAX_RECONNECT_ATTEMPTS, MATTERMOST_MAX_RECONNECT_DELAY, MATTERMOST_MESSAGE_LIMIT};
use crate::error::ChannelError;
use crate::plugin::{ChannelPlugin, PluginCallbacks, SharedPluginStatus, mark_error_on_unexpected_exit};
use crate::types::{
BotInfo, MessageContentType, PluginConfig, PluginStatus, PluginType,
UnifiedIncomingMessage, UnifiedMessageContent, UnifiedOutgoingMessage, UnifiedUser,
};
use super::api::MattermostApi;
use super::types::{CreatePostRequest, MmPost, UpdatePostRequest, WsAuthChallenge, WsEvent};
/// Mattermost channel plugin.
///
/// Receives messages via WebSocket (`/api/v4/websocket`), sends/edits via
/// REST (`/api/v4/posts`).
pub struct MattermostPlugin {
status: SharedPluginStatus,
bot_info: Option<BotInfo>,
last_error: Option<String>,
api: Option<Arc<MattermostApi>>,
callbacks: Option<PluginCallbacks>,
ws_handle: Option<JoinHandle<()>>,
shutdown_tx: Option<watch::Sender<bool>>,
/// The bot's own user id, used for the self-loop guard.
bot_user_id: Option<String>,
/// Server URL (stored for WS URL derivation).
server_url: Option<String>,
/// Bot access token (stored for WS auth challenge).
token: Option<String>,
}
impl Default for MattermostPlugin {
fn default() -> Self {
Self {
status: SharedPluginStatus::default(),
bot_info: None,
last_error: None,
api: None,
callbacks: None,
ws_handle: None,
shutdown_tx: None,
bot_user_id: None,
server_url: None,
token: None,
}
}
}
impl MattermostPlugin {
pub fn new() -> Self {
Self::default()
}
}
#[async_trait::async_trait]
impl ChannelPlugin for MattermostPlugin {
async fn initialize(&mut self, config: PluginConfig, callbacks: PluginCallbacks) -> Result<(), ChannelError> {
self.status.set(PluginStatus::Initializing);
let server_url = config
.credentials
.server_url
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| {
self.status.set(PluginStatus::Error);
self.last_error = Some("Missing Mattermost server_url".into());
ChannelError::InvalidConfig("Missing Mattermost server_url".into())
})?
.to_owned();
let token = config
.credentials
.token
.as_deref()
.filter(|t| !t.is_empty())
.ok_or_else(|| {
self.status.set(PluginStatus::Error);
self.last_error = Some("Missing Mattermost bot token".into());
ChannelError::InvalidConfig("Missing Mattermost bot token".into())
})?
.to_owned();
let client = Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| {
self.status.set(PluginStatus::Error);
self.last_error = Some(format!("HTTP client init failed: {e}"));
ChannelError::ConnectionFailed(format!("HTTP client init failed: {e}"))
})?;
let api = Arc::new(MattermostApi::new(client, &server_url, &token));
// Validate credentials by calling GET /api/v4/users/me
let me = api.get_me().await.map_err(|e| {
self.status.set(PluginStatus::Error);
self.last_error = Some(format!("Token validation failed: {e}"));
e
})?;
self.bot_info = Some(BotInfo {
id: me.id.clone(),
username: Some(me.username.clone()),
display_name: me.username.clone(),
});
self.bot_user_id = Some(me.id.clone());
info!(
bot_id = %me.id,
bot_username = %me.username,
"Mattermost bot initialized"
);
self.api = Some(api);
self.callbacks = Some(callbacks);
self.server_url = Some(server_url);
self.token = Some(token);
self.status.set(PluginStatus::Ready);
Ok(())
}
async fn start(&mut self) -> Result<(), ChannelError> {
self.status.set(PluginStatus::Starting);
if self.ws_handle.is_some() {
self.status.set(PluginStatus::Running);
return Ok(());
}
let callbacks = self
.callbacks
.clone()
.ok_or_else(|| ChannelError::PlatformApi("Mattermost callbacks not initialized".into()))?;
let server_url = self
.server_url
.clone()
.ok_or_else(|| ChannelError::PlatformApi("Mattermost server_url not set".into()))?;
let token = self
.token
.clone()
.ok_or_else(|| ChannelError::PlatformApi("Mattermost token not set".into()))?;
let bot_user_id = self
.bot_user_id
.clone()
.ok_or_else(|| ChannelError::PlatformApi("Mattermost bot_user_id not set".into()))?;
let (shutdown_tx, shutdown_rx) = watch::channel(false);
self.shutdown_tx = Some(shutdown_tx);
self.ws_handle = Some(tokio::spawn(ws_loop(
server_url,
token,
bot_user_id,
callbacks.message_tx,
self.status.clone(),
shutdown_rx,
)));
self.status.set(PluginStatus::Running);
info!("Mattermost plugin started");
Ok(())
}
async fn stop(&mut self) -> Result<(), ChannelError> {
self.status.set(PluginStatus::Stopping);
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(true);
}
if let Some(handle) = self.ws_handle.take() {
let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;
}
self.api = None;
self.callbacks = None;
self.status.set(PluginStatus::Stopped);
info!("Mattermost plugin stopped");
Ok(())
}
async fn send_message(&self, chat_id: &str, message: UnifiedOutgoingMessage) -> Result<String, ChannelError> {
let api = self
.api
.as_ref()
.ok_or_else(|| ChannelError::PlatformApi("Plugin not initialized".into()))?;
let text = truncate_message(message.text.as_deref().unwrap_or(""), MATTERMOST_MESSAGE_LIMIT);
// NOTE: message.buttons is intentionally ignored — see module-level doc
// comment. Mattermost interactive buttons require a public callback URL
// that a desktop app cannot host.
let root_id = message
.reply_to_message_id
.as_deref()
.filter(|s| !s.is_empty())
.map(String::from);
let req = CreatePostRequest {
channel_id: chat_id.to_owned(),
message: text,
root_id,
};
let resp = api.create_post(&req).await?;
Ok(resp.id)
}
async fn edit_message(
&self,
_chat_id: &str,
message_id: &str,
message: UnifiedOutgoingMessage,
) -> Result<(), ChannelError> {
let api = self
.api
.as_ref()
.ok_or_else(|| ChannelError::PlatformApi("Plugin not initialized".into()))?;
let text = truncate_message(message.text.as_deref().unwrap_or(""), MATTERMOST_MESSAGE_LIMIT);
let req = UpdatePostRequest {
id: message_id.to_owned(),
message: text,
};
api.update_post(&req).await
}
fn active_user_count(&self) -> usize {
0
}
fn bot_info(&self) -> Option<&BotInfo> {
self.bot_info.as_ref()
}
fn plugin_type(&self) -> PluginType {
PluginType::Mattermost
}
fn status(&self) -> PluginStatus {
self.status.get()
}
fn last_error(&self) -> Option<&str> {
self.last_error.as_deref()
}
}
// ---------------------------------------------------------------------------
// WebSocket loop
// ---------------------------------------------------------------------------
/// Background task: connect to Mattermost WS, authenticate, listen for events.
///
/// Reconnects with exponential backoff up to `MATTERMOST_MAX_RECONNECT_ATTEMPTS`
/// consecutive failures. On exhaustion, calls `mark_error_on_unexpected_exit`.
async fn ws_loop(
server_url: String,
token: String,
bot_user_id: String,
message_tx: mpsc::Sender<UnifiedIncomingMessage>,
status: SharedPluginStatus,
mut shutdown_rx: watch::Receiver<bool>,
) {
let mut consecutive_errors: u32 = 0;
loop {
if *shutdown_rx.borrow() {
debug!("Mattermost WS loop received shutdown signal");
break;
}
let ws_url = derive_ws_url(&server_url);
match connect_and_listen(
&ws_url,
&token,
&bot_user_id,
&message_tx,
&mut shutdown_rx,
)
.await
{
Ok(()) => {
// Clean disconnect (e.g. shutdown requested inside listen)
if *shutdown_rx.borrow() {
break;
}
// Server closed; reconnect
consecutive_errors += 1;
warn!("Mattermost WS closed, reconnecting ({consecutive_errors})");
}
Err(e) => {
consecutive_errors += 1;
warn!(
error = %e,
consecutive_errors,
"Mattermost WS error"
);
}
}
if consecutive_errors >= MATTERMOST_MAX_RECONNECT_ATTEMPTS {
error!("Mattermost max reconnect attempts reached, stopping WS loop");
break;
}
let backoff = backoff_delay(consecutive_errors);
tokio::select! {
_ = tokio::time::sleep(backoff) => {}
_ = shutdown_rx.changed() => {
debug!("Mattermost WS loop shutdown during backoff");
break;
}
}
}
mark_error_on_unexpected_exit(&status, &shutdown_rx, "mattermost");
debug!("Mattermost WS loop exited");
}
/// Connect to the Mattermost WebSocket, send auth challenge, and listen.
async fn connect_and_listen(
ws_url: &str,
token: &str,
bot_user_id: &str,
message_tx: &mpsc::Sender<UnifiedIncomingMessage>,
shutdown_rx: &mut watch::Receiver<bool>,
) -> Result<(), ChannelError> {
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message as WsMessage;
let connector = build_ws_tls_connector()?;
let (ws_stream, _) =
tokio_tungstenite::connect_async_tls_with_config(ws_url, None, false, Some(connector))
.await
.map_err(|e| ChannelError::ConnectionFailed(format!("Mattermost WS connect failed: {e}")))?;
info!("Mattermost WebSocket connected");
let (mut write, mut read) = ws_stream.split();
// Send authentication challenge
let auth = WsAuthChallenge::new(token);
let auth_json = serde_json::to_string(&auth)
.map_err(|e| ChannelError::PlatformApi(format!("Auth challenge serialize failed: {e}")))?;
write
.send(WsMessage::Text(auth_json.into()))
.await
.map_err(|e| ChannelError::ConnectionFailed(format!("Mattermost auth send failed: {e}")))?;
debug!("Mattermost auth challenge sent");
loop {
tokio::select! {
msg = read.next() => {
match msg {
Some(Ok(WsMessage::Text(text))) => {
handle_ws_text(&text, bot_user_id, message_tx).await;
}
Some(Ok(WsMessage::Close(_))) => {
debug!("Mattermost WS received close frame");
return Ok(());
}
Some(Err(e)) => {
return Err(ChannelError::ConnectionFailed(
format!("Mattermost WS read error: {e}")
));
}
None => {
return Err(ChannelError::ConnectionFailed(
"Mattermost WS stream ended unexpectedly".into()
));
}
// Binary, Ping, Pong, Frame — tokio-tungstenite handles
// ping/pong automatically at the protocol level.
_ => {}
}
}
_ = shutdown_rx.changed() => {
debug!("Mattermost WS shutdown during listen");
return Ok(());
}
}
}
}
/// Process a single WS text frame.
async fn handle_ws_text(
text: &str,
bot_user_id: &str,
message_tx: &mpsc::Sender<UnifiedIncomingMessage>,
) {
let event: WsEvent = match serde_json::from_str(text) {
Ok(e) => e,
Err(_) => return, // auth responses, status frames, etc.
};
if event.event.as_deref() != Some("posted") {
return;
}
let data = match event.data {
Some(d) => d,
None => return,
};
let post_str = match &data.post {
Some(s) => s,
None => return,
};
let post: MmPost = match serde_json::from_str(post_str) {
Ok(p) => p,
Err(e) => {
warn!(error = %e, "Failed to parse Mattermost post JSON");
return;
}
};
// Bot-loop guard: skip own messages
if post.user_id == bot_user_id {
return;
}
let channel_type = data.channel_type.as_deref().unwrap_or("");
// Mention gating: for non-DM channels, only process if the bot is mentioned
if channel_type != "D" {
if !is_bot_mentioned(&data.mentions, &post.message, bot_user_id) {
return;
}
}
let sender_name = data
.sender_name
.as_deref()
.unwrap_or("")
.trim_start_matches('@')
.to_owned();
let reply_to = if post.root_id.is_empty() {
None
} else {
Some(post.root_id.clone())
};
let unified = UnifiedIncomingMessage {
id: post.id,
platform: PluginType::Mattermost,
chat_id: post.channel_id,
user: UnifiedUser {
id: post.user_id,
username: Some(sender_name.clone()),
display_name: sender_name,
avatar_url: None,
},
content: UnifiedMessageContent {
content_type: MessageContentType::Text,
text: post.message,
attachments: None,
},
timestamp: chrono_now(),
reply_to_message_id: reply_to,
action: None,
raw: None,
};
let _ = message_tx.send(unified).await;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Derive the WebSocket URL from a server URL.
///
/// `https://` → `wss://`, `http://` → `ws://`; appends `/api/v4/websocket`.
pub(crate) fn derive_ws_url(server_url: &str) -> String {
let base = server_url.trim_end_matches('/');
let ws_base = if base.starts_with("https://") {
base.replacen("https://", "wss://", 1)
} else if base.starts_with("http://") {
base.replacen("http://", "ws://", 1)
} else {
// Fallback: assume wss
format!("wss://{base}")
};
format!("{ws_base}/api/v4/websocket")
}
/// Check if the bot is mentioned in a `"posted"` event.
///
/// Returns `true` if:
/// - The `data.mentions` JSON array contains the bot's user id, OR
/// - The post message text contains `@<bot_username>` (not checked here — the
/// caller should use `bot_info.username`; we rely on the server-side mentions
/// array which is the canonical source).
///
/// For robustness we also accept `@<bot_user_id>` in the text, though
/// Mattermost normally uses username-based mentions.
pub(crate) fn is_bot_mentioned(mentions_json: &Option<String>, message: &str, bot_user_id: &str) -> bool {
// Primary: check the mentions array from the WS event data
if let Some(mentions_str) = mentions_json {
if let Ok(ids) = serde_json::from_str::<Vec<String>>(mentions_str) {
if ids.iter().any(|id| id == bot_user_id) {
return true;
}
}
}
// Fallback: check if message contains @bot_user_id (edge case)
if message.contains(&format!("@{bot_user_id}")) {
return true;
}
false
}
/// Truncate a message to the platform limit, appending "..." if truncated.
pub(crate) fn truncate_message(text: &str, limit: usize) -> String {
if text.len() <= limit {
return text.to_string();
}
let truncated: String = text.chars().take(limit - 3).collect();
format!("{truncated}...")
}
/// Exponential backoff delay, capped.
fn backoff_delay(attempt: u32) -> Duration {
let delay_secs = 2u64.saturating_pow(attempt).min(MATTERMOST_MAX_RECONNECT_DELAY.as_secs());
Duration::from_secs(delay_secs)
}
/// Build a TLS connector for WebSocket connections (mirrors Lark).
///
/// Explicitly sets ALPN to `http/1.1` — WebSocket requires an HTTP/1.1
/// upgrade handshake and is incompatible with h2.
fn build_ws_tls_connector() -> Result<tokio_tungstenite::Connector, ChannelError> {
use std::sync::Arc;
use tokio_tungstenite::Connector;
let certs = rustls_native_certs::load_native_certs();
let mut root_store = rustls::RootCertStore::empty();
root_store.add_parsable_certificates(certs.certs);
let provider = rustls::crypto::CryptoProvider::get_default()
.cloned()
.unwrap_or_else(|| Arc::new(rustls::crypto::ring::default_provider()));
let mut config = rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| ChannelError::ConnectionFailed(format!("TLS config error: {e}")))?
.with_root_certificates(root_store)
.with_no_client_auth();
config.alpn_protocols = vec![b"http/1.1".to_vec()];
Ok(Connector::Rustls(Arc::new(config)))
}
/// Current unix timestamp in seconds.
fn chrono_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// -- derive_ws_url -------------------------------------------------------
#[test]
fn ws_url_from_https() {
assert_eq!(
derive_ws_url("https://mm.example.com"),
"wss://mm.example.com/api/v4/websocket"
);
}
#[test]
fn ws_url_from_http() {
assert_eq!(
derive_ws_url("http://localhost:8065"),
"ws://localhost:8065/api/v4/websocket"
);
}
#[test]
fn ws_url_strips_trailing_slash() {
assert_eq!(
derive_ws_url("https://mm.example.com/"),
"wss://mm.example.com/api/v4/websocket"
);
}
#[test]
fn ws_url_bare_host_gets_wss() {
assert_eq!(
derive_ws_url("mm.example.com"),
"wss://mm.example.com/api/v4/websocket"
);
}
// -- is_bot_mentioned ----------------------------------------------------
#[test]
fn mentioned_in_mentions_array() {
let mentions = Some(r#"["user1","bot123","user2"]"#.to_string());
assert!(is_bot_mentioned(&mentions, "hello", "bot123"));
}
#[test]
fn not_mentioned_in_mentions_array() {
let mentions = Some(r#"["user1","user2"]"#.to_string());
assert!(!is_bot_mentioned(&mentions, "hello", "bot123"));
}
#[test]
fn mentioned_no_mentions_field() {
assert!(!is_bot_mentioned(&None, "hello", "bot123"));
}
#[test]
fn mentioned_via_message_text_fallback() {
assert!(is_bot_mentioned(&None, "hey @bot123 help", "bot123"));
}
#[test]
fn not_mentioned_anywhere() {
let mentions = Some(r#"["user1"]"#.to_string());
assert!(!is_bot_mentioned(&mentions, "hello world", "bot123"));
}
#[test]
fn mentioned_empty_mentions_but_in_text() {
let mentions = Some(r#"[]"#.to_string());
assert!(is_bot_mentioned(&mentions, "cc @bot123", "bot123"));
}
#[test]
fn mentioned_malformed_mentions_json() {
let mentions = Some("not-json".to_string());
// Falls through to text check
assert!(is_bot_mentioned(&mentions, "@bot123", "bot123"));
assert!(!is_bot_mentioned(&mentions, "hello", "bot123"));
}
// -- truncate_message ----------------------------------------------------
#[test]
fn truncate_within_limit() {
assert_eq!(truncate_message("Hello", 100), "Hello");
}
#[test]
fn truncate_at_limit() {
assert_eq!(truncate_message("abc", 3), "abc");
}
#[test]
fn truncate_exceeds_limit() {
let result = truncate_message("Hello, world!", 10);
assert_eq!(result, "Hello, ...");
assert!(result.len() <= 10);
}
#[test]
fn truncate_unicode() {
let text = "abcdefghij"; // 10 chars
let result = truncate_message(text, 8);
assert_eq!(result, "abcde...");
}
// -- backoff_delay -------------------------------------------------------
#[test]
fn backoff_exponential() {
assert_eq!(backoff_delay(1), Duration::from_secs(2));
assert_eq!(backoff_delay(2), Duration::from_secs(4));
assert_eq!(backoff_delay(3), Duration::from_secs(8));
}
#[test]
fn backoff_capped() {
assert_eq!(backoff_delay(5), Duration::from_secs(30));
assert_eq!(backoff_delay(10), Duration::from_secs(30));
}
// -- MattermostPlugin constructor ----------------------------------------
#[test]
fn new_plugin_initial_state() {
let plugin = MattermostPlugin::new();
assert_eq!(plugin.status(), PluginStatus::Created);
assert!(plugin.bot_info().is_none());
assert!(plugin.last_error().is_none());
assert_eq!(plugin.plugin_type(), PluginType::Mattermost);
assert_eq!(plugin.active_user_count(), 0);
}
// -- handle_ws_text (unit tests via faked JSON) --------------------------
#[tokio::test]
async fn handle_posted_event() {
let (tx, mut rx) = mpsc::channel(16);
let post = serde_json::json!({
"id": "post1",
"channel_id": "chan1",
"user_id": "user1",
"message": "Hello bot",
"root_id": "",
"file_ids": []
});
let event = serde_json::json!({
"event": "posted",
"data": {
"post": serde_json::to_string(&post).unwrap(),
"channel_type": "D",
"sender_name": "@alice"
}
});
handle_ws_text(
&serde_json::to_string(&event).unwrap(),
"bot_id",
&tx,
)
.await;
let msg = rx.try_recv().unwrap();
assert_eq!(msg.id, "post1");
assert_eq!(msg.chat_id, "chan1");
assert_eq!(msg.user.id, "user1");
assert_eq!(msg.user.username.as_deref(), Some("alice"));
assert_eq!(msg.content.text, "Hello bot");
assert_eq!(msg.platform, PluginType::Mattermost);
}
#[tokio::test]
async fn handle_posted_skips_bot_own_message() {
let (tx, mut rx) = mpsc::channel(16);
let post = serde_json::json!({
"id": "post2",
"channel_id": "chan1",
"user_id": "bot_id", // same as bot
"message": "I said this"
});
let event = serde_json::json!({
"event": "posted",
"data": {
"post": serde_json::to_string(&post).unwrap(),
"channel_type": "D"
}
});
handle_ws_text(
&serde_json::to_string(&event).unwrap(),
"bot_id",
&tx,
)
.await;
assert!(rx.try_recv().is_err(), "Bot's own message should be skipped");
}
#[tokio::test]
async fn handle_posted_channel_requires_mention() {
let (tx, mut rx) = mpsc::channel(16);
let post = serde_json::json!({
"id": "post3",
"channel_id": "chan1",
"user_id": "user1",
"message": "hello"
});
// Open channel, no mentions
let event = serde_json::json!({
"event": "posted",
"data": {
"post": serde_json::to_string(&post).unwrap(),
"channel_type": "O",
"sender_name": "@alice"
}
});
handle_ws_text(
&serde_json::to_string(&event).unwrap(),
"bot_id",
&tx,
)
.await;
assert!(rx.try_recv().is_err(), "Non-DM without mention should be skipped");
}
#[tokio::test]
async fn handle_posted_channel_with_mention_passes() {
let (tx, mut rx) = mpsc::channel(16);
let post = serde_json::json!({
"id": "post4",
"channel_id": "chan1",
"user_id": "user1",
"message": "@bot_id help me"
});
let event = serde_json::json!({
"event": "posted",
"data": {
"post": serde_json::to_string(&post).unwrap(),
"channel_type": "O",
"sender_name": "@alice",
"mentions": "[\"bot_id\"]"
}
});
handle_ws_text(
&serde_json::to_string(&event).unwrap(),
"bot_id",
&tx,
)
.await;
let msg = rx.try_recv().unwrap();
assert_eq!(msg.id, "post4");
}
#[tokio::test]
async fn handle_non_posted_event_ignored() {
let (tx, mut rx) = mpsc::channel(16);
let event = serde_json::json!({
"event": "typing",
"data": {}
});
handle_ws_text(
&serde_json::to_string(&event).unwrap(),
"bot_id",
&tx,
)
.await;
assert!(rx.try_recv().is_err());
}
#[tokio::test]
async fn handle_auth_response_ignored() {
let (tx, mut rx) = mpsc::channel(16);
let frame = r#"{"status":"OK","seq_reply":1}"#;
handle_ws_text(frame, "bot_id", &tx).await;
assert!(rx.try_recv().is_err());
}
#[tokio::test]
async fn handle_posted_with_thread_reply() {
let (tx, mut rx) = mpsc::channel(16);
let post = serde_json::json!({
"id": "post5",
"channel_id": "chan1",
"user_id": "user1",
"message": "threaded reply",
"root_id": "root_post_1"
});
let event = serde_json::json!({
"event": "posted",
"data": {
"post": serde_json::to_string(&post).unwrap(),
"channel_type": "D"
}
});
handle_ws_text(
&serde_json::to_string(&event).unwrap(),
"bot_id",
&tx,
)
.await;
let msg = rx.try_recv().unwrap();
assert_eq!(msg.reply_to_message_id.as_deref(), Some("root_post_1"));
}
}