Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
[package]
|
||||
name = "nomifun-channel"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
telegram = ["dep:reqwest", "dep:nomifun-runtime"]
|
||||
lark = ["dep:reqwest", "dep:tokio-tungstenite", "dep:futures-util", "dep:prost", "dep:rustls", "dep:rustls-native-certs"]
|
||||
dingtalk = ["dep:reqwest", "dep:tokio-tungstenite", "dep:futures-util", "dep:rustls", "dep:rustls-native-certs"]
|
||||
weixin = ["dep:reqwest", "dep:futures-util", "dep:base64", "dep:uuid"]
|
||||
discord = ["dep:reqwest", "dep:tokio-tungstenite", "dep:futures-util", "dep:rustls", "dep:rustls-native-certs", "dep:base64"]
|
||||
matrix = ["dep:reqwest"]
|
||||
mattermost = ["dep:reqwest", "dep:tokio-tungstenite", "dep:futures-util", "dep:rustls", "dep:rustls-native-certs"]
|
||||
slack = ["dep:reqwest", "dep:tokio-tungstenite", "dep:futures-util", "dep:rustls", "dep:rustls-native-certs"]
|
||||
twitch = ["dep:reqwest", "dep:tokio-tungstenite", "dep:futures-util", "dep:rustls", "dep:rustls-native-certs"]
|
||||
nostr = ["dep:nostr", "dep:tokio-tungstenite", "dep:futures-util", "dep:rustls", "dep:rustls-native-certs", "dep:hex"]
|
||||
# qqbot module + factory arm land when the parallel QQ Bot work is integrated.
|
||||
qqbot = ["dep:reqwest", "dep:tokio-tungstenite", "dep:futures-util", "dep:rustls", "dep:rustls-native-certs"]
|
||||
|
||||
[dependencies]
|
||||
nomifun-common.workspace = true
|
||||
nomifun-db.workspace = true
|
||||
nomifun-api-types.workspace = true
|
||||
nomifun-realtime.workspace = true
|
||||
nomifun-conversation.workspace = true
|
||||
nomifun-ai-agent.workspace = true
|
||||
nomifun-extension.workspace = true
|
||||
# Telegram update-watermark persistence anchors on the process-global
|
||||
# runtime root ({data_dir}/runtime) — see plugins/telegram/watermark.rs.
|
||||
nomifun-runtime = { workspace = true, optional = true }
|
||||
axum.workspace = true
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
async-trait.workspace = true
|
||||
tracing.workspace = true
|
||||
getrandom.workspace = true
|
||||
dashmap.workspace = true
|
||||
regex.workspace = true
|
||||
reqwest = { workspace = true, optional = true }
|
||||
tokio-tungstenite = { workspace = true, optional = true }
|
||||
futures-util = { workspace = true, optional = true }
|
||||
prost = { workspace = true, optional = true }
|
||||
rustls = { workspace = true, optional = true }
|
||||
rustls-native-certs = { workspace = true, optional = true }
|
||||
base64 = { workspace = true, optional = true }
|
||||
uuid = { workspace = true, optional = true }
|
||||
nostr = { workspace = true, optional = true }
|
||||
hex = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
nomifun-ai-agent = { workspace = true, features = ["test-support"] }
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
tempfile.workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,576 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_common::ProviderWithModel;
|
||||
use nomifun_db::IClientPreferenceRepository;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::error::ChannelError;
|
||||
use crate::types::PluginType;
|
||||
|
||||
const DEFAULT_BACKEND: &str = "nomi";
|
||||
const DEFAULT_AGENT_TYPE: &str = "nomi";
|
||||
|
||||
/// Per-plugin agent/model configuration read from `client_preferences`.
|
||||
///
|
||||
/// Keys follow the pattern established by the old Electron frontend:
|
||||
/// - `assistant.{platform}.agent` → JSON `{"backend":"claude","name":"Claude"}`
|
||||
/// - `assistant.{platform}.defaultModel` → JSON `{"id":"provider_id","use_model":"model_name"}`
|
||||
pub struct ChannelSettingsService {
|
||||
pref_repo: Arc<dyn IClientPreferenceRepository>,
|
||||
}
|
||||
|
||||
/// Resolved agent configuration for a channel platform.
|
||||
///
|
||||
/// `backend` is only meaningful for ACP agents (claude, gemini, codex, …).
|
||||
/// Non-ACP agent types (nomi, nanobot, remote, …) have `backend = None`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedAgentConfig {
|
||||
pub agent_type: String,
|
||||
pub backend: Option<String>,
|
||||
}
|
||||
|
||||
/// Resolved model configuration for a channel platform.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedModelConfig {
|
||||
pub provider_id: String,
|
||||
pub model: String,
|
||||
pub use_model: Option<String>,
|
||||
}
|
||||
|
||||
impl ChannelSettingsService {
|
||||
pub fn new(pref_repo: Arc<dyn IClientPreferenceRepository>) -> Self {
|
||||
Self { pref_repo }
|
||||
}
|
||||
|
||||
/// Reads the agent configuration for a platform from `client_preferences`.
|
||||
///
|
||||
/// Supports two data formats:
|
||||
/// - **New:** `{"agent_type":"acp","backend":"claude","name":"Claude"}`
|
||||
/// - **Legacy:** `{"backend":"claude","name":"Claude"}` (no agent_type field)
|
||||
///
|
||||
/// Falls back to `agent_type=nomi, backend=None` when no config exists.
|
||||
pub async fn get_agent_config(&self, platform: PluginType) -> Result<ResolvedAgentConfig, ChannelError> {
|
||||
let key = agent_key(platform);
|
||||
let prefs = self.pref_repo.get_by_keys(&[&key]).await?;
|
||||
|
||||
let Some(pref) = prefs.into_iter().next() else {
|
||||
return Ok(default_agent_config());
|
||||
};
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&pref.value).unwrap_or_default();
|
||||
|
||||
if let Some(at) = parsed["agent_type"].as_str() {
|
||||
let backend = if at == "acp" {
|
||||
parsed["backend"].as_str().map(|s| s.to_owned())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
debug!(platform = %platform, agent_type = %at, backend = ?backend, "resolved channel agent config (new format)");
|
||||
|
||||
return Ok(ResolvedAgentConfig {
|
||||
agent_type: at.to_owned(),
|
||||
backend,
|
||||
});
|
||||
}
|
||||
|
||||
let raw_backend = parsed["backend"].as_str().unwrap_or(DEFAULT_BACKEND).to_owned();
|
||||
let agent_type = backend_to_agent_type(&raw_backend);
|
||||
let backend = if agent_type == "acp" { Some(raw_backend) } else { None };
|
||||
|
||||
debug!(platform = %platform, agent_type = %agent_type, backend = ?backend, "resolved channel agent config (legacy format)");
|
||||
|
||||
Ok(ResolvedAgentConfig { agent_type, backend })
|
||||
}
|
||||
|
||||
/// Opt-in: whether inbound messages on this platform should be filed as
|
||||
/// tracked requirements (IM → requirement pipeline) instead of getting an
|
||||
/// immediate AI reply. Default false (absent key → off).
|
||||
pub async fn get_route_to_requirement(&self, platform: PluginType) -> Result<bool, ChannelError> {
|
||||
let key = route_to_requirement_key(platform);
|
||||
let prefs = self.pref_repo.get_by_keys(&[&key]).await?;
|
||||
Ok(prefs
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|p| {
|
||||
let v = p.value.trim();
|
||||
v == "true" || serde_json::from_str::<bool>(v).unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false))
|
||||
}
|
||||
|
||||
/// The board tag (column) under which IM-routed requirements are filed.
|
||||
/// Defaults to "inbox" when unset.
|
||||
pub async fn get_requirement_tag(&self, platform: PluginType) -> Result<String, ChannelError> {
|
||||
let key = requirement_tag_key(platform);
|
||||
let prefs = self.pref_repo.get_by_keys(&[&key]).await?;
|
||||
Ok(prefs
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|p| p.value.trim().trim_matches('"').to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "inbox".to_string()))
|
||||
}
|
||||
|
||||
/// Reads the model configuration for a platform from `client_preferences`.
|
||||
///
|
||||
/// Returns `None` when no model is configured (common for ACP agents).
|
||||
pub async fn get_model_config(&self, platform: PluginType) -> Result<Option<ResolvedModelConfig>, ChannelError> {
|
||||
let key = model_key(platform);
|
||||
let prefs = self.pref_repo.get_by_keys(&[&key]).await?;
|
||||
|
||||
let Some(pref) = prefs.into_iter().next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&pref.value).unwrap_or_default();
|
||||
|
||||
let provider_id = parsed["id"].as_str().unwrap_or_default().to_owned();
|
||||
let use_model = parsed["use_model"].as_str().map(|s| s.to_owned());
|
||||
|
||||
if provider_id.is_empty() && use_model.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
debug!(platform = %platform, provider_id = %provider_id, use_model = ?use_model, "resolved channel model config");
|
||||
|
||||
Ok(Some(ResolvedModelConfig {
|
||||
provider_id: provider_id.clone(),
|
||||
model: use_model.clone().unwrap_or_default(),
|
||||
use_model,
|
||||
}))
|
||||
}
|
||||
/// Reads the companion bound to a platform's master agent from
|
||||
/// `client_preferences` (key `assistant.{platform}.companionId`).
|
||||
///
|
||||
/// Returns `None` when the key is absent or stores an empty string —
|
||||
/// "no explicit binding, use the default companion". Tolerates both a JSON
|
||||
/// string (`"companion_x"`) and a raw unquoted value.
|
||||
pub async fn get_master_agent_companion_id(&self, platform: PluginType) -> Result<Option<String>, ChannelError> {
|
||||
let key = master_agent_companion_key(platform);
|
||||
let prefs = self.pref_repo.get_by_keys(&[&key]).await?;
|
||||
|
||||
let Some(pref) = prefs.into_iter().next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let raw = match serde_json::from_str::<serde_json::Value>(&pref.value) {
|
||||
Ok(serde_json::Value::String(s)) => s,
|
||||
Ok(serde_json::Value::Null) => String::new(),
|
||||
Ok(_) => pref.value,
|
||||
Err(_) => pref.value,
|
||||
};
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(trimmed.to_owned()))
|
||||
}
|
||||
|
||||
/// Writes (or clears) the companion bound to a platform's master agent
|
||||
/// (key `assistant.{platform}.companionId`). `None` / empty string deletes the
|
||||
/// key — back to "default companion" semantics.
|
||||
///
|
||||
/// Persistence only: the channel routes layer pairs this with the same
|
||||
/// session reset as the masterAgent switch so the next inbound message
|
||||
/// recreates conversations under the new binding.
|
||||
pub async fn set_master_agent_companion_id(
|
||||
&self,
|
||||
platform: PluginType,
|
||||
companion_id: Option<&str>,
|
||||
) -> Result<(), ChannelError> {
|
||||
let key = master_agent_companion_key(platform);
|
||||
match companion_id.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
Some(id) => {
|
||||
let value = serde_json::Value::String(id.to_owned()).to_string();
|
||||
self.pref_repo.upsert_batch(&[(&key, &value)]).await?;
|
||||
}
|
||||
None => {
|
||||
self.pref_repo.delete_keys(&[&key]).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_key(platform: PluginType) -> String {
|
||||
format!("assistant.{platform}.agent")
|
||||
}
|
||||
|
||||
fn master_agent_companion_key(platform: PluginType) -> String {
|
||||
format!("assistant.{platform}.companionId")
|
||||
}
|
||||
|
||||
fn model_key(platform: PluginType) -> String {
|
||||
format!("assistant.{platform}.defaultModel")
|
||||
}
|
||||
|
||||
fn route_to_requirement_key(platform: PluginType) -> String {
|
||||
format!("assistant.{platform}.routeToRequirement")
|
||||
}
|
||||
|
||||
fn requirement_tag_key(platform: PluginType) -> String {
|
||||
format!("assistant.{platform}.requirementTag")
|
||||
}
|
||||
|
||||
fn default_agent_config() -> ResolvedAgentConfig {
|
||||
ResolvedAgentConfig {
|
||||
agent_type: DEFAULT_AGENT_TYPE.to_owned(),
|
||||
backend: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a backend identifier to the corresponding `AgentType` serde name.
|
||||
///
|
||||
/// ACP-style backends (claude, gemini, codex, etc.) all map to "acp".
|
||||
/// Non-ACP backends map to their specific agent type.
|
||||
fn backend_to_agent_type(backend: &str) -> String {
|
||||
match backend {
|
||||
"nomi" | "nomi-cli" => "nomi".to_owned(),
|
||||
"openclaw-gateway" => "openclaw-gateway".to_owned(),
|
||||
"nanobot" => "nanobot".to_owned(),
|
||||
"remote" => "remote".to_owned(),
|
||||
_ => {
|
||||
// All ACP-compatible backends: claude, gemini, codex, codebuddy, opencode, qwen, copilot, droid, kimi, etc.
|
||||
"acp".to_owned()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a `ProviderWithModel` from the resolved config, or returns
|
||||
/// the empty default when no model is configured.
|
||||
pub fn resolved_model_to_provider(model: Option<&ResolvedModelConfig>) -> ProviderWithModel {
|
||||
match model {
|
||||
Some(m) => ProviderWithModel {
|
||||
provider_id: m.provider_id.clone(),
|
||||
model: m.model.clone(),
|
||||
use_model: m.use_model.clone(),
|
||||
},
|
||||
None => ProviderWithModel {
|
||||
provider_id: String::new(),
|
||||
model: String::new(),
|
||||
use_model: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nomifun_db::DbError;
|
||||
use nomifun_db::models::ClientPreference;
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct MockPrefRepo {
|
||||
data: Mutex<Vec<(String, String)>>,
|
||||
}
|
||||
|
||||
impl MockPrefRepo {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
data: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_data(entries: Vec<(&str, &str)>) -> Self {
|
||||
Self {
|
||||
data: Mutex::new(entries.into_iter().map(|(k, v)| (k.to_owned(), v.to_owned())).collect()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IClientPreferenceRepository for MockPrefRepo {
|
||||
async fn get_all(&self) -> Result<Vec<ClientPreference>, DbError> {
|
||||
let data = self.data.lock().unwrap();
|
||||
Ok(data
|
||||
.iter()
|
||||
.map(|(k, v)| ClientPreference {
|
||||
key: k.clone(),
|
||||
value: v.clone(),
|
||||
updated_at: 0,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_by_keys(&self, keys: &[&str]) -> Result<Vec<ClientPreference>, DbError> {
|
||||
let data = self.data.lock().unwrap();
|
||||
Ok(data
|
||||
.iter()
|
||||
.filter(|(k, _)| keys.contains(&k.as_str()))
|
||||
.map(|(k, v)| ClientPreference {
|
||||
key: k.clone(),
|
||||
value: v.clone(),
|
||||
updated_at: 0,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn upsert_batch(&self, entries: &[(&str, &str)]) -> Result<(), DbError> {
|
||||
let mut data = self.data.lock().unwrap();
|
||||
for (key, value) in entries {
|
||||
if let Some(existing) = data.iter_mut().find(|(k, _)| k == key) {
|
||||
existing.1 = value.to_string();
|
||||
} else {
|
||||
data.push((key.to_string(), value.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_keys(&self, keys: &[&str]) -> Result<(), DbError> {
|
||||
let mut data = self.data.lock().unwrap();
|
||||
data.retain(|(k, _)| !keys.contains(&k.as_str()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── backend_to_agent_type ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn acp_backends_map_to_acp() {
|
||||
for backend in &[
|
||||
"claude",
|
||||
"gemini",
|
||||
"codex",
|
||||
"codebuddy",
|
||||
"opencode",
|
||||
"qwen",
|
||||
"copilot",
|
||||
"droid",
|
||||
"kimi",
|
||||
] {
|
||||
assert_eq!(backend_to_agent_type(backend), "acp", "backend: {backend}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nomi_backends_map_to_nomi() {
|
||||
assert_eq!(backend_to_agent_type("nomi"), "nomi");
|
||||
assert_eq!(backend_to_agent_type("nomi-cli"), "nomi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_acp_backends_map_correctly() {
|
||||
assert_eq!(backend_to_agent_type("openclaw-gateway"), "openclaw-gateway");
|
||||
assert_eq!(backend_to_agent_type("nanobot"), "nanobot");
|
||||
assert_eq!(backend_to_agent_type("remote"), "remote");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_backend_defaults_to_acp() {
|
||||
assert_eq!(backend_to_agent_type("unknown"), "acp");
|
||||
}
|
||||
|
||||
// ── get_agent_config ──────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_config_returns_default_when_no_pref() {
|
||||
let repo = Arc::new(MockPrefRepo::new());
|
||||
let svc = ChannelSettingsService::new(repo);
|
||||
|
||||
let config = svc.get_agent_config(PluginType::Telegram).await.unwrap();
|
||||
assert_eq!(config.agent_type, "nomi");
|
||||
assert!(config.backend.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_config_reads_acp_from_preferences() {
|
||||
let repo = Arc::new(MockPrefRepo::with_data(vec![(
|
||||
"assistant.telegram.agent",
|
||||
r#"{"backend":"codex","name":"Codex"}"#,
|
||||
)]));
|
||||
let svc = ChannelSettingsService::new(repo);
|
||||
|
||||
let config = svc.get_agent_config(PluginType::Telegram).await.unwrap();
|
||||
assert_eq!(config.agent_type, "acp");
|
||||
assert_eq!(config.backend.as_deref(), Some("codex"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_config_nomi_has_no_backend() {
|
||||
let repo = Arc::new(MockPrefRepo::with_data(vec![(
|
||||
"assistant.lark.agent",
|
||||
r#"{"backend":"nomi","name":"Nomi"}"#,
|
||||
)]));
|
||||
let svc = ChannelSettingsService::new(repo);
|
||||
|
||||
let config = svc.get_agent_config(PluginType::Lark).await.unwrap();
|
||||
assert_eq!(config.agent_type, "nomi");
|
||||
assert!(config.backend.is_none());
|
||||
}
|
||||
|
||||
// ── get_agent_config (new format) ──────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_config_reads_new_format_acp() {
|
||||
let repo = Arc::new(MockPrefRepo::with_data(vec![(
|
||||
"assistant.telegram.agent",
|
||||
r#"{"agent_type":"acp","backend":"claude","name":"Claude"}"#,
|
||||
)]));
|
||||
let svc = ChannelSettingsService::new(repo);
|
||||
|
||||
let config = svc.get_agent_config(PluginType::Telegram).await.unwrap();
|
||||
assert_eq!(config.agent_type, "acp");
|
||||
assert_eq!(config.backend.as_deref(), Some("claude"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_config_reads_new_format_nomi() {
|
||||
let repo = Arc::new(MockPrefRepo::with_data(vec![(
|
||||
"assistant.lark.agent",
|
||||
r#"{"agent_type":"nomi","name":"Nomi"}"#,
|
||||
)]));
|
||||
let svc = ChannelSettingsService::new(repo);
|
||||
|
||||
let config = svc.get_agent_config(PluginType::Lark).await.unwrap();
|
||||
assert_eq!(config.agent_type, "nomi");
|
||||
assert!(config.backend.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_config_reads_new_format_openclaw() {
|
||||
let repo = Arc::new(MockPrefRepo::with_data(vec![(
|
||||
"assistant.weixin.agent",
|
||||
r#"{"agent_type":"openclaw-gateway","name":"OpenClaw"}"#,
|
||||
)]));
|
||||
let svc = ChannelSettingsService::new(repo);
|
||||
|
||||
let config = svc.get_agent_config(PluginType::Weixin).await.unwrap();
|
||||
assert_eq!(config.agent_type, "openclaw-gateway");
|
||||
assert!(config.backend.is_none());
|
||||
}
|
||||
|
||||
// ── get_model_config ──────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_config_returns_none_when_no_pref() {
|
||||
let repo = Arc::new(MockPrefRepo::new());
|
||||
let svc = ChannelSettingsService::new(repo);
|
||||
|
||||
let config = svc.get_model_config(PluginType::Telegram).await.unwrap();
|
||||
assert!(config.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_config_reads_from_preferences() {
|
||||
let repo = Arc::new(MockPrefRepo::with_data(vec![(
|
||||
"assistant.weixin.defaultModel",
|
||||
r#"{"id":"490fdb4e","use_model":"global.anthropic.claude-opus-4-6-v1"}"#,
|
||||
)]));
|
||||
let svc = ChannelSettingsService::new(repo);
|
||||
|
||||
let config = svc.get_model_config(PluginType::Weixin).await.unwrap().unwrap();
|
||||
assert_eq!(config.provider_id, "490fdb4e");
|
||||
assert_eq!(config.use_model.as_deref(), Some("global.anthropic.claude-opus-4-6-v1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_config_returns_none_for_empty_values() {
|
||||
let repo = Arc::new(MockPrefRepo::with_data(vec![(
|
||||
"assistant.telegram.defaultModel",
|
||||
r#"{"id":"","use_model":null}"#,
|
||||
)]));
|
||||
let svc = ChannelSettingsService::new(repo);
|
||||
|
||||
let config = svc.get_model_config(PluginType::Telegram).await.unwrap();
|
||||
assert!(config.is_none());
|
||||
}
|
||||
|
||||
// ── get/set_master_agent_companion_id ───────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn companion_id_returns_none_when_no_pref() {
|
||||
let repo = Arc::new(MockPrefRepo::new());
|
||||
let svc = ChannelSettingsService::new(repo);
|
||||
assert!(svc.get_master_agent_companion_id(PluginType::Telegram).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn companion_id_reads_json_string_value() {
|
||||
let repo = Arc::new(MockPrefRepo::with_data(vec![(
|
||||
"assistant.telegram.companionId",
|
||||
"\"companion_abc\"",
|
||||
)]));
|
||||
let svc = ChannelSettingsService::new(repo);
|
||||
assert_eq!(
|
||||
svc.get_master_agent_companion_id(PluginType::Telegram).await.unwrap().as_deref(),
|
||||
Some("companion_abc")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn companion_id_reads_raw_unquoted_value() {
|
||||
let repo = Arc::new(MockPrefRepo::with_data(vec![("assistant.lark.companionId", "companion_raw")]));
|
||||
let svc = ChannelSettingsService::new(repo);
|
||||
assert_eq!(
|
||||
svc.get_master_agent_companion_id(PluginType::Lark).await.unwrap().as_deref(),
|
||||
Some("companion_raw")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn companion_id_empty_string_treated_as_unset() {
|
||||
let repo = Arc::new(MockPrefRepo::with_data(vec![
|
||||
("assistant.telegram.companionId", "\"\""),
|
||||
("assistant.lark.companionId", "\" \""),
|
||||
("assistant.weixin.companionId", "null"),
|
||||
]));
|
||||
let svc = ChannelSettingsService::new(repo);
|
||||
assert!(svc.get_master_agent_companion_id(PluginType::Telegram).await.unwrap().is_none());
|
||||
assert!(svc.get_master_agent_companion_id(PluginType::Lark).await.unwrap().is_none());
|
||||
assert!(svc.get_master_agent_companion_id(PluginType::Weixin).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn companion_id_set_then_get_roundtrip_and_clear() {
|
||||
let repo = Arc::new(MockPrefRepo::new());
|
||||
let svc = ChannelSettingsService::new(repo);
|
||||
|
||||
svc.set_master_agent_companion_id(PluginType::Dingtalk, Some("companion_77"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
svc.get_master_agent_companion_id(PluginType::Dingtalk).await.unwrap().as_deref(),
|
||||
Some("companion_77")
|
||||
);
|
||||
// Other platforms unaffected.
|
||||
assert!(svc.get_master_agent_companion_id(PluginType::Telegram).await.unwrap().is_none());
|
||||
|
||||
// None clears the binding (key deleted).
|
||||
svc.set_master_agent_companion_id(PluginType::Dingtalk, None).await.unwrap();
|
||||
assert!(svc.get_master_agent_companion_id(PluginType::Dingtalk).await.unwrap().is_none());
|
||||
|
||||
// Empty string also clears.
|
||||
svc.set_master_agent_companion_id(PluginType::Dingtalk, Some("companion_88"))
|
||||
.await
|
||||
.unwrap();
|
||||
svc.set_master_agent_companion_id(PluginType::Dingtalk, Some(" "))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(svc.get_master_agent_companion_id(PluginType::Dingtalk).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
// ── resolved_model_to_provider ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn resolved_model_converts_to_provider() {
|
||||
let model = ResolvedModelConfig {
|
||||
provider_id: "abc".into(),
|
||||
model: "gpt-5".into(),
|
||||
use_model: Some("gpt-5".into()),
|
||||
};
|
||||
let p = resolved_model_to_provider(Some(&model));
|
||||
assert_eq!(p.provider_id, "abc");
|
||||
assert_eq!(p.model, "gpt-5");
|
||||
assert_eq!(p.use_model.as_deref(), Some("gpt-5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_model_produces_empty_provider() {
|
||||
let p = resolved_model_to_provider(None);
|
||||
assert!(p.provider_id.is_empty());
|
||||
assert!(p.model.is_empty());
|
||||
assert!(p.use_model.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
use std::time::Duration;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pairing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Length of the numeric pairing code (6 digits).
|
||||
pub const PAIRING_CODE_LENGTH: usize = 6;
|
||||
|
||||
/// How long a pairing code remains valid.
|
||||
pub const PAIRING_CODE_TTL: Duration = Duration::from_secs(10 * 60);
|
||||
|
||||
/// Interval between expired-pairing cleanup sweeps.
|
||||
pub const PAIRING_CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Streaming & Throttle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Minimum interval between consecutive `editMessage` calls for
|
||||
/// streaming responses (prevents API rate-limit errors).
|
||||
pub const STREAM_THROTTLE_INTERVAL: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Timeout for tool confirmation from the IM user.
|
||||
pub const TOOL_CONFIRM_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Platform message limits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Maximum characters per Telegram message.
|
||||
pub const TELEGRAM_MESSAGE_LIMIT: usize = 4096;
|
||||
|
||||
/// Maximum characters per Lark (Feishu) message.
|
||||
pub const LARK_MESSAGE_LIMIT: usize = 4000;
|
||||
|
||||
/// Maximum characters per DingTalk message.
|
||||
pub const DINGTALK_MESSAGE_LIMIT: usize = 4000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin watchdog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Interval between plugin health sweeps in the `ChannelManager` watchdog.
|
||||
pub const WATCHDOG_SWEEP_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Sliding window for watchdog restart-rate limiting.
|
||||
pub const WATCHDOG_RESTART_WINDOW: Duration = Duration::from_secs(60 * 60);
|
||||
|
||||
/// Maximum automatic restart attempts per plugin within
|
||||
/// `WATCHDOG_RESTART_WINDOW`.
|
||||
pub const WATCHDOG_MAX_RESTARTS_PER_WINDOW: u32 = 3;
|
||||
|
||||
/// Base delay for exponential backoff between watchdog restart attempts
|
||||
/// (delay after the n-th attempt = base * 2^(n-1)).
|
||||
pub const WATCHDOG_BACKOFF_BASE: Duration = Duration::from_secs(60);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reconnection (Telegram long-polling)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Maximum reconnection attempts for Telegram long-polling.
|
||||
pub const TELEGRAM_MAX_RECONNECT_ATTEMPTS: u32 = 10;
|
||||
|
||||
/// Maximum delay between reconnection attempts (exponential backoff cap).
|
||||
pub const TELEGRAM_MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lark
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// TTL for Lark event deduplication cache.
|
||||
pub const LARK_EVENT_DEDUP_TTL: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DingTalk
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Maximum reconnection attempts for DingTalk WebSocket Stream.
|
||||
pub const DINGTALK_MAX_RECONNECT_ATTEMPTS: u32 = 10;
|
||||
|
||||
/// Maximum delay between DingTalk reconnection attempts (exponential backoff cap).
|
||||
pub const DINGTALK_MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WeChat (iLink Bot)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Response timeout for WeChat message processing.
|
||||
pub const WEIXIN_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
/// Maximum file size for WeChat file handling (200 MB).
|
||||
pub const WEIXIN_MAX_FILE_SIZE: u64 = 200 * 1024 * 1024;
|
||||
|
||||
/// Maximum consecutive failures before WeChat applies longer backoff.
|
||||
pub const WEIXIN_MAX_RETRIES: u32 = 3;
|
||||
|
||||
/// Short retry delay between WeChat poll attempts on failure.
|
||||
pub const WEIXIN_RETRY_DELAY: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Longer backoff delay after max consecutive failures.
|
||||
pub const WEIXIN_BACKOFF_DELAY: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Long-polling timeout for WeChat getupdates (matches iLink API).
|
||||
pub const WEIXIN_POLL_TIMEOUT: Duration = Duration::from_secs(35);
|
||||
|
||||
/// Timeout for non-polling WeChat API calls.
|
||||
pub const WEIXIN_API_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discord
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Maximum characters per Discord message.
|
||||
pub const DISCORD_MESSAGE_LIMIT: usize = 2000;
|
||||
|
||||
/// Maximum reconnection attempts for the Discord gateway WebSocket.
|
||||
pub const DISCORD_MAX_RECONNECT_ATTEMPTS: u32 = 10;
|
||||
|
||||
/// Maximum delay between Discord reconnection attempts (exponential backoff cap).
|
||||
pub const DISCORD_MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mattermost
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Maximum characters per Mattermost post (hard limit ~65535 bytes; rendering
|
||||
/// degrades well before that, so cap conservatively).
|
||||
pub const MATTERMOST_MESSAGE_LIMIT: usize = 16000;
|
||||
|
||||
/// Maximum reconnection attempts for the Mattermost WebSocket.
|
||||
pub const MATTERMOST_MAX_RECONNECT_ATTEMPTS: u32 = 10;
|
||||
|
||||
/// Maximum delay between Mattermost reconnection attempts (exponential backoff cap).
|
||||
pub const MATTERMOST_MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Matrix
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Maximum characters per Matrix message (`m.text` body).
|
||||
pub const MATRIX_MESSAGE_LIMIT: usize = 65536;
|
||||
|
||||
/// Maximum reconnection attempts for Matrix /sync long-polling.
|
||||
pub const MATRIX_MAX_RECONNECT_ATTEMPTS: u32 = 10;
|
||||
|
||||
/// Maximum delay between Matrix reconnection attempts (exponential backoff cap).
|
||||
pub const MATRIX_MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Timeout for Matrix /sync long-poll (the `timeout` query parameter, in ms).
|
||||
pub const MATRIX_SYNC_TIMEOUT_MS: u32 = 30_000;
|
||||
|
||||
/// Timeout for non-sync Matrix API calls (send, edit, whoami).
|
||||
pub const MATRIX_API_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slack (Socket Mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Maximum characters per Slack message.
|
||||
pub const SLACK_MESSAGE_LIMIT: usize = 3500;
|
||||
|
||||
/// Maximum reconnection attempts for Slack Socket Mode WebSocket.
|
||||
pub const SLACK_MAX_RECONNECT_ATTEMPTS: u32 = 10;
|
||||
|
||||
/// Maximum delay between Slack reconnection attempts (exponential backoff cap).
|
||||
pub const SLACK_MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Twitch (IRC-over-WebSocket)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Maximum characters per Twitch IRC PRIVMSG (~500 char IRC limit; margin).
|
||||
pub const TWITCH_MESSAGE_LIMIT: usize = 480;
|
||||
|
||||
/// Maximum reconnection attempts for the Twitch IRC WebSocket.
|
||||
pub const TWITCH_MAX_RECONNECT_ATTEMPTS: u32 = 10;
|
||||
|
||||
/// Maximum delay between Twitch reconnection attempts (exponential backoff cap).
|
||||
pub const TWITCH_MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nostr (relay WebSocket)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Maximum reconnection attempts for a Nostr relay WebSocket.
|
||||
pub const NOSTR_MAX_RECONNECT_ATTEMPTS: u32 = 10;
|
||||
|
||||
/// Maximum delay between Nostr relay reconnection attempts (exponential backoff cap).
|
||||
pub const NOSTR_MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// QQ Bot (official gateway WS + REST)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Maximum characters per QQ Bot message.
|
||||
pub const QQBOT_MESSAGE_LIMIT: usize = 4000;
|
||||
|
||||
/// Maximum reconnection attempts for the QQ Bot gateway WebSocket.
|
||||
pub const QQBOT_MAX_RECONNECT_ATTEMPTS: u32 = 10;
|
||||
|
||||
/// Maximum delay between QQ Bot reconnection attempts (exponential backoff cap).
|
||||
pub const QQBOT_MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Maximum passive replies per inbound msg_id (QQ API hard limit: 5).
|
||||
pub const QQBOT_PASSIVE_REPLY_MAX: u32 = 5;
|
||||
|
||||
/// Passive-reply window TTL per inbound msg_id (QQ API hard limit: 1 hour).
|
||||
pub const QQBOT_PASSIVE_REPLY_WINDOW: Duration = Duration::from_secs(3600);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pairing_code_length_is_six() {
|
||||
assert_eq!(PAIRING_CODE_LENGTH, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairing_code_ttl_is_ten_minutes() {
|
||||
assert_eq!(PAIRING_CODE_TTL, Duration::from_secs(600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_interval_is_sixty_seconds() {
|
||||
assert_eq!(PAIRING_CLEANUP_INTERVAL, Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_throttle_is_500ms() {
|
||||
assert_eq!(STREAM_THROTTLE_INTERVAL, Duration::from_millis(500));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_confirm_timeout_is_15s() {
|
||||
assert_eq!(TOOL_CONFIRM_TIMEOUT, Duration::from_secs(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telegram_message_limit() {
|
||||
assert_eq!(TELEGRAM_MESSAGE_LIMIT, 4096);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lark_message_limit() {
|
||||
assert_eq!(LARK_MESSAGE_LIMIT, 4000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dingtalk_message_limit() {
|
||||
assert_eq!(DINGTALK_MESSAGE_LIMIT, 4000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telegram_reconnect_limits() {
|
||||
assert_eq!(TELEGRAM_MAX_RECONNECT_ATTEMPTS, 10);
|
||||
assert_eq!(TELEGRAM_MAX_RECONNECT_DELAY, Duration::from_secs(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watchdog_constants() {
|
||||
assert_eq!(WATCHDOG_SWEEP_INTERVAL, Duration::from_secs(60));
|
||||
assert_eq!(WATCHDOG_RESTART_WINDOW, Duration::from_secs(3600));
|
||||
assert_eq!(WATCHDOG_MAX_RESTARTS_PER_WINDOW, 3);
|
||||
assert_eq!(WATCHDOG_BACKOFF_BASE, Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lark_event_dedup_ttl_is_five_minutes() {
|
||||
assert_eq!(LARK_EVENT_DEDUP_TTL, Duration::from_secs(300));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dingtalk_reconnect_limits() {
|
||||
assert_eq!(DINGTALK_MAX_RECONNECT_ATTEMPTS, 10);
|
||||
assert_eq!(DINGTALK_MAX_RECONNECT_DELAY, Duration::from_secs(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weixin_constants() {
|
||||
assert_eq!(WEIXIN_RESPONSE_TIMEOUT, Duration::from_secs(300));
|
||||
assert_eq!(WEIXIN_MAX_FILE_SIZE, 200 * 1024 * 1024);
|
||||
assert_eq!(WEIXIN_MAX_RETRIES, 3);
|
||||
assert_eq!(WEIXIN_RETRY_DELAY, Duration::from_secs(2));
|
||||
assert_eq!(WEIXIN_BACKOFF_DELAY, Duration::from_secs(30));
|
||||
assert_eq!(WEIXIN_POLL_TIMEOUT, Duration::from_secs(35));
|
||||
assert_eq!(WEIXIN_API_TIMEOUT, Duration::from_secs(15));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
use nomifun_common::AppError;
|
||||
|
||||
/// Channel crate-level errors.
|
||||
///
|
||||
/// Uses `thiserror` (library crate convention).
|
||||
/// Converts to `AppError` for HTTP response mapping.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ChannelError {
|
||||
#[error("Plugin not found: {0}")]
|
||||
PluginNotFound(String),
|
||||
|
||||
#[error("Invalid plugin type: {0}")]
|
||||
InvalidPluginType(String),
|
||||
|
||||
#[error("Plugin already running: {0}")]
|
||||
PluginAlreadyRunning(String),
|
||||
|
||||
#[error("This bot is already connected and bound to {0}")]
|
||||
BotAlreadyBound(String),
|
||||
|
||||
#[error("Invalid plugin configuration: {0}")]
|
||||
InvalidConfig(String),
|
||||
|
||||
#[error("Plugin connection failed: {0}")]
|
||||
ConnectionFailed(String),
|
||||
|
||||
#[error("Pairing code not found: {0}")]
|
||||
PairingNotFound(String),
|
||||
|
||||
#[error("Pairing code expired: {0}")]
|
||||
PairingExpired(String),
|
||||
|
||||
#[error("Pairing code already processed: {0}")]
|
||||
PairingAlreadyProcessed(String),
|
||||
|
||||
#[error("User not found: {0}")]
|
||||
UserNotFound(String),
|
||||
|
||||
#[error("User not authorized: {0}")]
|
||||
UserNotAuthorized(String),
|
||||
|
||||
#[error("Session not found: {0}")]
|
||||
SessionNotFound(String),
|
||||
|
||||
#[error("Credential encryption failed: {0}")]
|
||||
EncryptionFailed(String),
|
||||
|
||||
#[error("Credential decryption failed: {0}")]
|
||||
DecryptionFailed(String),
|
||||
|
||||
#[error("Platform API error: {0}")]
|
||||
PlatformApi(String),
|
||||
|
||||
#[error("Message send failed: {0}")]
|
||||
MessageSendFailed(String),
|
||||
|
||||
/// The bound conversation is already running a turn. For companion sessions
|
||||
/// (now shared by the desktop bubble, chat tab, and every IM chat) a
|
||||
/// concurrent turn surfaces as a turn-claim `Conflict`; the orchestrator
|
||||
/// answers the user with the friendly "still processing" notice rather than
|
||||
/// a raw error.
|
||||
#[error("conversation busy")]
|
||||
ConversationBusy,
|
||||
|
||||
/// A companion is bound to this channel but has no chat model configured, so
|
||||
/// its single session can't be created. The orchestrator relays this as a
|
||||
/// plain notice instead of the generic ❌ failure line.
|
||||
#[error("{0}")]
|
||||
CompanionNotReady(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Database(#[from] nomifun_db::DbError),
|
||||
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
impl From<ChannelError> for AppError {
|
||||
fn from(err: ChannelError) -> Self {
|
||||
match err {
|
||||
ChannelError::PluginNotFound(msg) => AppError::NotFound(msg),
|
||||
ChannelError::InvalidPluginType(msg) => AppError::BadRequest(msg),
|
||||
ChannelError::PluginAlreadyRunning(msg) => AppError::Conflict(msg),
|
||||
err @ ChannelError::BotAlreadyBound(_) => AppError::Conflict(err.to_string()),
|
||||
ChannelError::InvalidConfig(msg) => AppError::BadRequest(msg),
|
||||
ChannelError::ConnectionFailed(msg) => AppError::BadGateway(msg),
|
||||
ChannelError::PairingNotFound(msg) => AppError::NotFound(msg),
|
||||
ChannelError::PairingExpired(msg) => AppError::BadRequest(msg),
|
||||
ChannelError::PairingAlreadyProcessed(msg) => AppError::BadRequest(msg),
|
||||
ChannelError::UserNotFound(msg) => AppError::NotFound(msg),
|
||||
ChannelError::UserNotAuthorized(msg) => AppError::Forbidden(msg),
|
||||
ChannelError::SessionNotFound(msg) => AppError::NotFound(msg),
|
||||
ChannelError::EncryptionFailed(msg) => AppError::Internal(msg),
|
||||
ChannelError::DecryptionFailed(msg) => AppError::Internal(msg),
|
||||
ChannelError::PlatformApi(msg) => AppError::BadGateway(msg),
|
||||
ChannelError::MessageSendFailed(msg) => AppError::Internal(msg),
|
||||
ChannelError::ConversationBusy => AppError::Conflict("conversation busy".into()),
|
||||
ChannelError::CompanionNotReady(msg) => AppError::BadRequest(msg),
|
||||
ChannelError::Database(db_err) => AppError::from(db_err),
|
||||
ChannelError::Json(e) => AppError::Internal(format!("JSON error: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plugin_not_found_maps_to_app_not_found() {
|
||||
let err: AppError = ChannelError::PluginNotFound("telegram".into()).into();
|
||||
assert!(matches!(err, AppError::NotFound(msg) if msg == "telegram"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_plugin_type_maps_to_bad_request() {
|
||||
let err: AppError = ChannelError::InvalidPluginType("unknown".into()).into();
|
||||
assert!(matches!(err, AppError::BadRequest(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_already_running_maps_to_conflict() {
|
||||
let err: AppError = ChannelError::PluginAlreadyRunning("telegram".into()).into();
|
||||
assert!(matches!(err, AppError::Conflict(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_config_maps_to_bad_request() {
|
||||
let err: AppError = ChannelError::InvalidConfig("missing token".into()).into();
|
||||
assert!(matches!(err, AppError::BadRequest(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_failed_maps_to_bad_gateway() {
|
||||
let err: AppError = ChannelError::ConnectionFailed("timeout".into()).into();
|
||||
assert!(matches!(err, AppError::BadGateway(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairing_not_found_maps_to_not_found() {
|
||||
let err: AppError = ChannelError::PairingNotFound("123456".into()).into();
|
||||
assert!(matches!(err, AppError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairing_expired_maps_to_bad_request() {
|
||||
let err: AppError = ChannelError::PairingExpired("123456".into()).into();
|
||||
assert!(matches!(err, AppError::BadRequest(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairing_already_processed_maps_to_bad_request() {
|
||||
let err: AppError = ChannelError::PairingAlreadyProcessed("123456".into()).into();
|
||||
assert!(matches!(err, AppError::BadRequest(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_not_found_maps_to_not_found() {
|
||||
let err: AppError = ChannelError::UserNotFound("user-1".into()).into();
|
||||
assert!(matches!(err, AppError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_not_authorized_maps_to_forbidden() {
|
||||
let err: AppError = ChannelError::UserNotAuthorized("tg_42".into()).into();
|
||||
assert!(matches!(err, AppError::Forbidden(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_not_found_maps_to_not_found() {
|
||||
let err: AppError = ChannelError::SessionNotFound("sess-1".into()).into();
|
||||
assert!(matches!(err, AppError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encryption_failed_maps_to_internal() {
|
||||
let err: AppError = ChannelError::EncryptionFailed("bad key".into()).into();
|
||||
assert!(matches!(err, AppError::Internal(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decryption_failed_maps_to_internal() {
|
||||
let err: AppError = ChannelError::DecryptionFailed("corrupt".into()).into();
|
||||
assert!(matches!(err, AppError::Internal(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_api_maps_to_bad_gateway() {
|
||||
let err: AppError = ChannelError::PlatformApi("429 rate limited".into()).into();
|
||||
assert!(matches!(err, AppError::BadGateway(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_send_failed_maps_to_internal() {
|
||||
let err: AppError = ChannelError::MessageSendFailed("chat not found".into()).into();
|
||||
assert!(matches!(err, AppError::Internal(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_error_maps_to_internal() {
|
||||
let json_err = serde_json::from_str::<serde_json::Value>("invalid").unwrap_err();
|
||||
let err: AppError = ChannelError::Json(json_err).into();
|
||||
assert!(matches!(err, AppError::Internal(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_messages() {
|
||||
assert_eq!(
|
||||
ChannelError::PluginNotFound("tg".into()).to_string(),
|
||||
"Plugin not found: tg"
|
||||
);
|
||||
assert_eq!(
|
||||
ChannelError::PairingExpired("123456".into()).to_string(),
|
||||
"Pairing code expired: 123456"
|
||||
);
|
||||
assert_eq!(
|
||||
ChannelError::InvalidConfig("bad".into()).to_string(),
|
||||
"Invalid plugin configuration: bad"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use crate::types::PluginType;
|
||||
|
||||
/// Convert text to the target IM platform format.
|
||||
///
|
||||
/// - Telegram: escape HTML, then convert markdown → HTML tags
|
||||
/// - Lark/DingTalk: convert HTML tags → markdown
|
||||
/// - WeChat/WeCom: strip all HTML
|
||||
/// - Fallback: escape HTML special chars
|
||||
pub fn format_text_for_platform(text: &str, platform: PluginType) -> String {
|
||||
match platform {
|
||||
PluginType::Telegram => markdown_to_telegram_html(text),
|
||||
PluginType::Lark | PluginType::Dingtalk => html_to_markdown(text),
|
||||
PluginType::Weixin => strip_html(text),
|
||||
_ => escape_html(text),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Telegram ─────────────────────────────────────────────────────
|
||||
|
||||
static RE_CODE_BLOCK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"```(?:\w*)\n?([\s\S]*?)```").unwrap());
|
||||
static RE_INLINE_CODE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`([^`]+)`").unwrap());
|
||||
static RE_BOLD_STAR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*(.+?)\*\*").unwrap());
|
||||
static RE_BOLD_UNDER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"__(.+?)__").unwrap());
|
||||
static RE_ITALIC_STAR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*(.+?)\*").unwrap());
|
||||
static RE_ITALIC_UNDER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"_(.+?)_").unwrap());
|
||||
static RE_LINK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap());
|
||||
|
||||
fn markdown_to_telegram_html(text: &str) -> String {
|
||||
let s = escape_html(text);
|
||||
let s = RE_CODE_BLOCK.replace_all(&s, "<pre><code>$1</code></pre>");
|
||||
let s = RE_INLINE_CODE.replace_all(&s, "<code>$1</code>");
|
||||
let s = RE_BOLD_STAR.replace_all(&s, "<b>$1</b>");
|
||||
let s = RE_BOLD_UNDER.replace_all(&s, "<b>$1</b>");
|
||||
let s = RE_ITALIC_STAR.replace_all(&s, "<i>$1</i>");
|
||||
let s = RE_ITALIC_UNDER.replace_all(&s, "<i>$1</i>");
|
||||
let s = RE_LINK.replace_all(&s, r#"<a href="$2">$1</a>"#);
|
||||
s.into_owned()
|
||||
}
|
||||
|
||||
// ── Lark / DingTalk ──────────────────────────────────────────────
|
||||
|
||||
static RE_PRE_CODE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<pre><code[^>]*>([\s\S]*?)</code></pre>").unwrap());
|
||||
static RE_HTML_CODE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<code>([^<]+)</code>").unwrap());
|
||||
static RE_HTML_B: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<b>([\s\S]*?)</b>").unwrap());
|
||||
static RE_HTML_STRONG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<strong>([\s\S]*?)</strong>").unwrap());
|
||||
static RE_HTML_I: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<i>([\s\S]*?)</i>").unwrap());
|
||||
static RE_HTML_EM: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<em>([\s\S]*?)</em>").unwrap());
|
||||
static RE_HTML_SAFE_LINK: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r#"<a\s+href="((?:https?://|mailto:|/)[^"]*)"[^>]*>([^<]*)</a>"#).unwrap());
|
||||
static RE_HTML_TAG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<[^>]+>").unwrap());
|
||||
|
||||
fn html_to_markdown(text: &str) -> String {
|
||||
let s = decode_safe_entities(text);
|
||||
let s = RE_PRE_CODE.replace_all(&s, "```\n$1```");
|
||||
let s = RE_HTML_CODE.replace_all(&s, "`$1`");
|
||||
let s = RE_HTML_B.replace_all(&s, "**$1**");
|
||||
let s = RE_HTML_STRONG.replace_all(&s, "**$1**");
|
||||
let s = RE_HTML_I.replace_all(&s, "*$1*");
|
||||
let s = RE_HTML_EM.replace_all(&s, "*$1*");
|
||||
let s = RE_HTML_SAFE_LINK.replace_all(&s, "[$2]($1)");
|
||||
strip_tags_loop(s.as_ref())
|
||||
}
|
||||
|
||||
// ── WeChat ───────────────────────────────────────────────────────
|
||||
|
||||
fn strip_html(text: &str) -> String {
|
||||
let s = strip_tags_loop(text);
|
||||
let s = decode_all_entities(&s);
|
||||
s.replace(['<', '>'], "")
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
fn escape_html(text: &str) -> String {
|
||||
text.replace('&', "&").replace('<', "<").replace('>', ">")
|
||||
}
|
||||
|
||||
fn strip_tags_loop(text: &str) -> String {
|
||||
let mut result = text.to_owned();
|
||||
loop {
|
||||
let stripped = RE_HTML_TAG.replace_all(&result, "");
|
||||
if stripped == result {
|
||||
break;
|
||||
}
|
||||
result = stripped.into_owned();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Decode only safe entities (quotes, numeric). Never decode </>/&
|
||||
/// to prevent tag injection in Lark/DingTalk output.
|
||||
fn decode_safe_entities(text: &str) -> String {
|
||||
static RE_HEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"&#x([0-9a-fA-F]+);").unwrap());
|
||||
static RE_DEC: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"&#(\d+);").unwrap());
|
||||
|
||||
let s = text.replace(""", "\"");
|
||||
let s = s.replace("'", "'");
|
||||
let s = s.replace("'", "'");
|
||||
let s = RE_HEX.replace_all(&s, |caps: ®ex::Captures| {
|
||||
u32::from_str_radix(&caps[1], 16)
|
||||
.ok()
|
||||
.and_then(char::from_u32)
|
||||
.map(|c| c.to_string())
|
||||
.unwrap_or_else(|| caps[0].to_owned())
|
||||
});
|
||||
let s = RE_DEC.replace_all(&s, |caps: ®ex::Captures| {
|
||||
caps[1]
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
.and_then(char::from_u32)
|
||||
.map(|c| c.to_string())
|
||||
.unwrap_or_else(|| caps[0].to_owned())
|
||||
});
|
||||
s.into_owned()
|
||||
}
|
||||
|
||||
/// Decode all common HTML entities (for WeChat plain-text output).
|
||||
fn decode_all_entities(text: &str) -> String {
|
||||
let s = decode_safe_entities(text);
|
||||
s.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(" ", " ")
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! External channel integration: plugin system, pairing handshake, and per-session messaging.
|
||||
pub mod action;
|
||||
pub mod channel_settings;
|
||||
pub mod constants;
|
||||
pub mod error;
|
||||
pub mod formatter;
|
||||
pub mod manager;
|
||||
pub mod message_service;
|
||||
pub mod orchestrator;
|
||||
pub mod pairing;
|
||||
pub mod pending_decision;
|
||||
pub mod plugin;
|
||||
pub mod plugins;
|
||||
pub mod routes;
|
||||
pub mod session;
|
||||
pub mod stream_relay;
|
||||
pub mod types;
|
||||
|
||||
pub use routes::{ChannelRouterState, channel_routes};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,466 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::action::{ActionExecutor, MessageResult};
|
||||
use crate::error::ChannelError;
|
||||
use crate::message_service::ChannelMessageService;
|
||||
use crate::session::SessionManager;
|
||||
use crate::stream_relay::{ChannelSender, ChannelStreamRelay, RelayConfig};
|
||||
use crate::types::{ActionBehavior, ChannelIncoming, OutgoingMessageType, UnifiedOutgoingMessage};
|
||||
|
||||
/// Reply sent when a new message arrives while the previous turn of the same
|
||||
/// chat is still being processed (per-chat concurrency guard).
|
||||
const BUSY_NOTICE: &str =
|
||||
"\u{23f3} Your previous message is still being processed \u{2014} please wait for it to finish.";
|
||||
|
||||
/// Reply for `chat.regenerate` when there is no user message to resend.
|
||||
const NOTHING_TO_REGENERATE: &str =
|
||||
"\u{2139}\u{fe0f} There is no previous message to regenerate \u{2014} send a new message first.";
|
||||
|
||||
/// Orchestrates the full channel message lifecycle.
|
||||
///
|
||||
/// Consumes incoming IM messages from `message_rx` and tool confirmation
|
||||
/// callbacks from `confirm_rx`, driving the pipeline:
|
||||
/// 1. ActionExecutor routing (auth → action/AI dispatch)
|
||||
/// 2. For Dispatched: send_to_agent + spawn ChannelStreamRelay
|
||||
/// 3. For Action: reply via plugin
|
||||
/// 4. Forward tool confirmations to the agent
|
||||
pub struct ChannelOrchestrator {
|
||||
action_executor: Arc<ActionExecutor>,
|
||||
message_service: Arc<ChannelMessageService>,
|
||||
session_manager: Arc<SessionManager>,
|
||||
sender: Arc<dyn ChannelSender>,
|
||||
}
|
||||
|
||||
impl ChannelOrchestrator {
|
||||
pub fn new(
|
||||
action_executor: Arc<ActionExecutor>,
|
||||
message_service: Arc<ChannelMessageService>,
|
||||
session_manager: Arc<SessionManager>,
|
||||
sender: Arc<dyn ChannelSender>,
|
||||
) -> Self {
|
||||
Self {
|
||||
action_executor,
|
||||
message_service,
|
||||
session_manager,
|
||||
sender,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the message loop. Runs until both channels close.
|
||||
pub async fn run(
|
||||
self,
|
||||
mut message_rx: mpsc::Receiver<ChannelIncoming>,
|
||||
mut confirm_rx: mpsc::Receiver<(String, String)>,
|
||||
) {
|
||||
info!("ChannelOrchestrator started");
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
Some(incoming) = message_rx.recv() => {
|
||||
self.handle_message(incoming).await;
|
||||
}
|
||||
Some((call_id, value)) = confirm_rx.recv() => {
|
||||
handle_confirm(&call_id, &value);
|
||||
}
|
||||
else => break,
|
||||
}
|
||||
}
|
||||
|
||||
info!("ChannelOrchestrator stopped (channels closed)");
|
||||
}
|
||||
|
||||
async fn handle_message(&self, incoming: ChannelIncoming) {
|
||||
let ChannelIncoming { channel_id, message: msg } = incoming;
|
||||
let platform = msg.platform;
|
||||
let chat_id = msg.chat_id.clone();
|
||||
// Outgoing routing is per channel row — the manager keys running
|
||||
// bot instances by their `assistant_plugins` row id.
|
||||
let plugin_id = channel_id.clone();
|
||||
let text = msg.content.text.clone();
|
||||
|
||||
let executor = Arc::clone(&self.action_executor);
|
||||
let msg_svc = Arc::clone(&self.message_service);
|
||||
let session_mgr = Arc::clone(&self.session_manager);
|
||||
let sender = Arc::clone(&self.sender);
|
||||
|
||||
tokio::spawn(async move {
|
||||
match executor.handle_incoming_message(&msg, &channel_id).await {
|
||||
Ok(MessageResult::Action(response)) => {
|
||||
send_action_response(&sender, &plugin_id, &chat_id, &response).await;
|
||||
}
|
||||
Ok(MessageResult::Dispatched {
|
||||
session_id,
|
||||
conversation_id,
|
||||
}) => {
|
||||
handle_dispatched(
|
||||
&msg_svc,
|
||||
&session_mgr,
|
||||
&sender,
|
||||
&session_id,
|
||||
conversation_id.as_deref(),
|
||||
&text,
|
||||
platform,
|
||||
&plugin_id,
|
||||
&chat_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(MessageResult::DispatchedText {
|
||||
session_id,
|
||||
conversation_id,
|
||||
text: synthesized,
|
||||
}) => {
|
||||
// chat.continue: same pipeline as a typed message, with a
|
||||
// synthesized prompt instead of the callback payload text.
|
||||
handle_dispatched(
|
||||
&msg_svc,
|
||||
&session_mgr,
|
||||
&sender,
|
||||
&session_id,
|
||||
conversation_id.as_deref(),
|
||||
&synthesized,
|
||||
platform,
|
||||
&plugin_id,
|
||||
&chat_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(MessageResult::RegenerateRequested {
|
||||
session_id,
|
||||
conversation_id,
|
||||
}) => {
|
||||
handle_regenerate(
|
||||
&msg_svc,
|
||||
&session_mgr,
|
||||
&sender,
|
||||
&session_id,
|
||||
conversation_id.as_deref(),
|
||||
platform,
|
||||
&plugin_id,
|
||||
&chat_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(MessageResult::AlreadyProcessing) => {
|
||||
info!(chat_id = %chat_id, "message ignored: already processing");
|
||||
let _ = sender
|
||||
.send_message(&plugin_id, &chat_id, plain_text_message(BUSY_NOTICE.into()))
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "failed to handle incoming message");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_action_response(
|
||||
sender: &Arc<dyn ChannelSender>,
|
||||
plugin_id: &str,
|
||||
chat_id: &str,
|
||||
response: &crate::types::ActionResponse,
|
||||
) {
|
||||
if let Some(text) = &response.text {
|
||||
let outgoing = UnifiedOutgoingMessage {
|
||||
message_type: OutgoingMessageType::Text,
|
||||
text: Some(text.clone()),
|
||||
parse_mode: response.parse_mode,
|
||||
buttons: response.buttons.clone(),
|
||||
keyboard: response.keyboard.clone(),
|
||||
image_url: None,
|
||||
file_url: None,
|
||||
file_name: None,
|
||||
media_actions: None,
|
||||
reply_to_message_id: None,
|
||||
silent: None,
|
||||
};
|
||||
|
||||
match response.behavior {
|
||||
ActionBehavior::Edit => {
|
||||
if let Some(ref edit_id) = response.edit_message_id {
|
||||
let _ = sender.edit_message(plugin_id, chat_id, edit_id, outgoing).await;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let _ = sender.send_message(plugin_id, chat_id, outgoing).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn handle_dispatched(
|
||||
msg_svc: &Arc<ChannelMessageService>,
|
||||
session_mgr: &Arc<SessionManager>,
|
||||
sender: &Arc<dyn ChannelSender>,
|
||||
session_id: &str,
|
||||
conversation_id: Option<&str>,
|
||||
text: &str,
|
||||
platform: crate::types::PluginType,
|
||||
plugin_id: &str,
|
||||
chat_id: &str,
|
||||
) {
|
||||
// Decision interception (Bug 1, Case A): when the bound conversation is
|
||||
// waiting on a relayed numbered decision, a reply is the user's *answer*,
|
||||
// not a new prompt. Map a valid number onto an option and resolve it via
|
||||
// `confirm`; re-show the list on any other reply. Runs before the busy
|
||||
// guard because the conversation is intentionally blocked on the decision.
|
||||
if let Some(cid) = conversation_id
|
||||
&& let Some(pending) = msg_svc.pending_decisions().peek(cid)
|
||||
{
|
||||
match parse_choice(text, pending.options.len()) {
|
||||
Some(idx) => {
|
||||
let option = &pending.options[idx];
|
||||
match msg_svc.submit_decision(cid, &pending.call_id, &option.option_id).await {
|
||||
Ok(()) => {
|
||||
msg_svc.pending_decisions().take(cid);
|
||||
info!(conversation_id = %cid, option_id = %option.option_id, "channel decision resolved");
|
||||
let _ = sender
|
||||
.send_message(
|
||||
plugin_id,
|
||||
chat_id,
|
||||
plain_text_message(format!("\u{2705} 已选择:{}", option.label)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
// The decision can no longer be submitted — most often it
|
||||
// was already answered from the desktop UI, or the turn
|
||||
// ended. Clear the stale entry so the user's next message
|
||||
// dispatches normally instead of being trapped on it.
|
||||
msg_svc.pending_decisions().take(cid);
|
||||
error!(error = %e, conversation_id = %cid, "channel decision submit failed; cleared stale pending");
|
||||
let _ = sender
|
||||
.send_message(
|
||||
plugin_id,
|
||||
chat_id,
|
||||
plain_text_message(format!(
|
||||
"\u{274c} 该决策已无法提交(可能已在桌面处理):{e}。已清除等待,请重新发送你的指令。"
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Non-numeric / out-of-range reply: re-show the numbered list
|
||||
// (do not dispatch it as a new prompt).
|
||||
let msg = ChannelMessageService::build_decision_message(&pending.prompt, &pending.options);
|
||||
let _ = sender.send_message(plugin_id, chat_id, msg).await;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-chat concurrency guard: when the bound conversation is already
|
||||
// working on a turn, don't race a second prompt into it (the turn claim
|
||||
// would reject it with an opaque error anyway) — tell the user instead.
|
||||
if let Some(cid) = conversation_id
|
||||
&& msg_svc.is_conversation_busy(cid).await
|
||||
{
|
||||
info!(conversation_id = %cid, chat_id = %chat_id, "message rejected: conversation busy");
|
||||
let _ = sender
|
||||
.send_message(plugin_id, chat_id, plain_text_message(BUSY_NOTICE.into()))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
let session = match session_mgr.get_session_by_id(session_id).await {
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => {
|
||||
warn!(session_id = %session_id, "session not found after dispatch");
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "failed to get session");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let send_result = match msg_svc.send_to_agent(&session, text, platform).await {
|
||||
Ok(r) => r,
|
||||
// The (now shared) companion session is already running a turn — answer
|
||||
// with the same friendly notice the per-chat busy guard uses. Covers the
|
||||
// first-turn race the guard above can't see (it checks the pre-bind id).
|
||||
Err(ChannelError::ConversationBusy) => {
|
||||
info!(chat_id = %chat_id, "message rejected: companion session busy");
|
||||
let _ = sender
|
||||
.send_message(plugin_id, chat_id, plain_text_message(BUSY_NOTICE.into()))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
// Companion bound but not yet usable (no model) — relay the plain notice,
|
||||
// not the generic ❌ failure line.
|
||||
Err(e @ ChannelError::CompanionNotReady(_)) => {
|
||||
info!(chat_id = %chat_id, "message rejected: companion not ready");
|
||||
let _ = sender.send_message(plugin_id, chat_id, plain_text_message(e.to_string())).await;
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "failed to send to agent");
|
||||
let err_msg = plain_text_message(format!("\u{274c} Failed to process: {e}"));
|
||||
let _ = sender.send_message(plugin_id, chat_id, err_msg).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Bind the conversation to this per-chat session whenever the conversation
|
||||
// the turn actually ran on differs from the session's current binding: a
|
||||
// first turn (was None), or a companion turn rerouted into the companion's
|
||||
// shared single session (the per-chat session may still point at None or a
|
||||
// stale standalone id). Keeps the per-chat pointer in sync so the busy guard
|
||||
// and decision interception operate on the shared id on subsequent turns.
|
||||
if conversation_id != Some(send_result.conversation_id.as_str())
|
||||
&& let Err(e) = session_mgr
|
||||
.bind_conversation(session_id, &send_result.conversation_id)
|
||||
.await
|
||||
{
|
||||
warn!(error = %e, "failed to bind conversation to session");
|
||||
}
|
||||
|
||||
// Spawn stream relay if we got a subscription
|
||||
if let Some(rx) = send_result.stream_rx {
|
||||
let relay_config = RelayConfig {
|
||||
platform,
|
||||
plugin_id: plugin_id.to_owned(),
|
||||
chat_id: chat_id.to_owned(),
|
||||
throttle_ms: 500,
|
||||
conversation_id: send_result.conversation_id.clone(),
|
||||
};
|
||||
let relay = ChannelStreamRelay::new(relay_config, Arc::clone(sender), msg_svc.pending_decisions());
|
||||
tokio::spawn(relay.run(rx));
|
||||
} else {
|
||||
warn!(
|
||||
conversation_id = %send_result.conversation_id,
|
||||
"no agent task for stream subscription"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles `chat.regenerate`: look up the conversation's last user message
|
||||
/// and resend it through the regular dispatch path (streaming reply
|
||||
/// included). Falls back to a notice when there is nothing to resend.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn handle_regenerate(
|
||||
msg_svc: &Arc<ChannelMessageService>,
|
||||
session_mgr: &Arc<SessionManager>,
|
||||
sender: &Arc<dyn ChannelSender>,
|
||||
session_id: &str,
|
||||
conversation_id: Option<&str>,
|
||||
platform: crate::types::PluginType,
|
||||
plugin_id: &str,
|
||||
chat_id: &str,
|
||||
) {
|
||||
let Some(conversation_id) = conversation_id else {
|
||||
// Session has no backing conversation yet — nothing was ever asked.
|
||||
let _ = sender
|
||||
.send_message(plugin_id, chat_id, plain_text_message(NOTHING_TO_REGENERATE.into()))
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
|
||||
match msg_svc.last_user_text(conversation_id).await {
|
||||
Ok(Some(text)) => {
|
||||
handle_dispatched(
|
||||
msg_svc,
|
||||
session_mgr,
|
||||
sender,
|
||||
session_id,
|
||||
Some(conversation_id),
|
||||
&text,
|
||||
platform,
|
||||
plugin_id,
|
||||
chat_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(None) => {
|
||||
let _ = sender
|
||||
.send_message(plugin_id, chat_id, plain_text_message(NOTHING_TO_REGENERATE.into()))
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, conversation_id = %conversation_id, "failed to load last user message for regenerate");
|
||||
let _ = sender
|
||||
.send_message(
|
||||
plugin_id,
|
||||
chat_id,
|
||||
plain_text_message(format!("\u{274c} Failed to process: {e}")),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a plain text outgoing message (no parse mode, no buttons).
|
||||
fn plain_text_message(text: String) -> UnifiedOutgoingMessage {
|
||||
UnifiedOutgoingMessage {
|
||||
message_type: OutgoingMessageType::Text,
|
||||
text: Some(text),
|
||||
parse_mode: None,
|
||||
buttons: None,
|
||||
keyboard: None,
|
||||
image_url: None,
|
||||
file_url: None,
|
||||
file_name: None,
|
||||
media_actions: None,
|
||||
reply_to_message_id: None,
|
||||
silent: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward a tool confirmation callback to the active agent.
|
||||
fn handle_confirm(call_id: &str, value: &str) {
|
||||
// Channel conversations use yoloMode which auto-approves everything,
|
||||
// so this path is rarely hit. When needed, we can add a
|
||||
// call_id→conversation_id lookup via IWorkerTaskManager.
|
||||
info!(call_id = %call_id, value = %value, "forwarding tool confirmation");
|
||||
}
|
||||
|
||||
/// Parses a channel user's numbered-decision reply into a 0-based option
|
||||
/// index, valid only for `1..=n` (where `n` is the option count).
|
||||
///
|
||||
/// Returns `None` for non-numeric, out-of-range, or empty replies so the
|
||||
/// caller can re-show the numbered list instead of dispatching the text.
|
||||
fn parse_choice(text: &str, n: usize) -> Option<usize> {
|
||||
let choice: usize = text.trim().parse().ok()?;
|
||||
if choice >= 1 && choice <= n {
|
||||
Some(choice - 1)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_choice_valid_indices() {
|
||||
assert_eq!(parse_choice("1", 2), Some(0));
|
||||
assert_eq!(parse_choice("2", 2), Some(1));
|
||||
// Surrounding whitespace is tolerated.
|
||||
assert_eq!(parse_choice(" 2 ", 3), Some(1));
|
||||
assert_eq!(parse_choice("\n1\t", 3), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_choice_out_of_range() {
|
||||
assert_eq!(parse_choice("0", 2), None, "1-based: 0 is invalid");
|
||||
assert_eq!(parse_choice("3", 2), None, "beyond option count");
|
||||
assert_eq!(parse_choice("1", 0), None, "no options at all");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_choice_non_numeric() {
|
||||
assert_eq!(parse_choice("hello", 2), None);
|
||||
assert_eq!(parse_choice("", 2), None);
|
||||
assert_eq!(parse_choice("1.5", 2), None);
|
||||
assert_eq!(parse_choice("-1", 2), None);
|
||||
assert_eq!(parse_choice("1 2", 2), None, "two numbers is not a single choice");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,864 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_api_types::{PairingRequestedPayload, UserAuthorizedPayload, WebSocketMessage};
|
||||
use nomifun_common::{TimestampMs, generate_prefixed_id, now_ms};
|
||||
use nomifun_db::IChannelRepository;
|
||||
use nomifun_db::models::{AssistantUserRow, PairingCodeRow};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::constants::{PAIRING_CLEANUP_INTERVAL, PAIRING_CODE_LENGTH, PAIRING_CODE_TTL};
|
||||
use crate::error::ChannelError;
|
||||
use crate::types::PairingStatus;
|
||||
|
||||
/// Generates a random numeric pairing code of the configured length.
|
||||
///
|
||||
/// Uses `getrandom` for cryptographically secure randomness.
|
||||
/// Returns a zero-padded string (e.g., "003421").
|
||||
pub fn generate_pairing_code() -> Result<String, ChannelError> {
|
||||
let mut bytes = [0u8; 4];
|
||||
getrandom::getrandom(&mut bytes).map_err(|e| ChannelError::InvalidConfig(format!("RNG failure: {e}")))?;
|
||||
let num = u32::from_le_bytes(bytes) % 10u32.pow(PAIRING_CODE_LENGTH as u32);
|
||||
Ok(format!("{num:0>width$}", width = PAIRING_CODE_LENGTH))
|
||||
}
|
||||
|
||||
/// Service for managing pairing authorization flow.
|
||||
///
|
||||
/// Handles:
|
||||
/// - Pairing code generation and creation
|
||||
/// - Approval / rejection of pairing requests
|
||||
/// - Periodic cleanup of expired codes
|
||||
/// - Event broadcasting to WebSocket clients
|
||||
pub struct PairingService {
|
||||
repo: Arc<dyn IChannelRepository>,
|
||||
broadcaster: Arc<dyn EventBroadcaster>,
|
||||
}
|
||||
|
||||
impl PairingService {
|
||||
pub fn new(repo: Arc<dyn IChannelRepository>, broadcaster: Arc<dyn EventBroadcaster>) -> Self {
|
||||
Self { repo, broadcaster }
|
||||
}
|
||||
|
||||
/// Creates a pairing request for an IM user.
|
||||
///
|
||||
/// Generates a 6-digit code, stores it with a 10-minute TTL, and
|
||||
/// broadcasts a `channel.pairing-requested` event to all WebSocket
|
||||
/// clients.
|
||||
///
|
||||
/// If the same platform user already has a pending code, that code is
|
||||
/// marked as expired before creating the new one.
|
||||
pub async fn request_pairing(
|
||||
&self,
|
||||
platform_user_id: &str,
|
||||
platform_type: &str,
|
||||
channel_id: &str,
|
||||
display_name: Option<&str>,
|
||||
) -> Result<String, ChannelError> {
|
||||
// Expire any existing pending codes for this user on this bot channel
|
||||
self.expire_user_pending_codes(platform_user_id, platform_type, channel_id)
|
||||
.await?;
|
||||
|
||||
let code = generate_pairing_code()?;
|
||||
let now = now_ms();
|
||||
let expires_at = now + PAIRING_CODE_TTL.as_millis() as TimestampMs;
|
||||
|
||||
let row = PairingCodeRow {
|
||||
code: code.clone(),
|
||||
platform_user_id: platform_user_id.to_owned(),
|
||||
platform_type: platform_type.to_owned(),
|
||||
channel_id: Some(channel_id.to_owned()),
|
||||
display_name: display_name.map(String::from),
|
||||
requested_at: now,
|
||||
expires_at,
|
||||
status: PairingStatus::Pending.to_string(),
|
||||
};
|
||||
|
||||
self.repo.create_pairing(&row).await?;
|
||||
|
||||
info!(
|
||||
code = %code,
|
||||
platform_user_id = %platform_user_id,
|
||||
platform_type = %platform_type,
|
||||
channel_id = %channel_id,
|
||||
"pairing code created"
|
||||
);
|
||||
|
||||
// Broadcast event
|
||||
let payload = PairingRequestedPayload {
|
||||
code: code.clone(),
|
||||
platform_user_id: platform_user_id.to_owned(),
|
||||
platform_type: platform_type.to_owned(),
|
||||
channel_id: Some(channel_id.to_owned()),
|
||||
display_name: display_name.map(String::from),
|
||||
expires_at,
|
||||
};
|
||||
let value = serde_json::to_value(payload)?;
|
||||
self.broadcaster
|
||||
.broadcast(WebSocketMessage::new("channel.pairing-requested", value));
|
||||
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
/// Approves a pending pairing code.
|
||||
///
|
||||
/// - Validates the code exists and is still pending + not expired
|
||||
/// - Creates an `assistant_users` record
|
||||
/// - Updates the pairing status to `approved`
|
||||
/// - Broadcasts a `channel.user-authorized` event
|
||||
pub async fn approve_pairing(&self, code: &str) -> Result<(), ChannelError> {
|
||||
let row = self.get_valid_pending_pairing(code).await?;
|
||||
let now = now_ms();
|
||||
|
||||
// Create user record. `achu_` (assistant-channel user) keeps these IM
|
||||
// identities in their own id namespace, distinct from the `users`
|
||||
// table — see the primary-key redesign spec.
|
||||
let user_id = generate_prefixed_id("achu");
|
||||
let user_row = AssistantUserRow {
|
||||
id: user_id.clone(),
|
||||
platform_user_id: row.platform_user_id.clone(),
|
||||
platform_type: row.platform_type.clone(),
|
||||
channel_id: row.channel_id.clone(),
|
||||
display_name: row.display_name.clone(),
|
||||
authorized_at: now,
|
||||
last_active: None,
|
||||
session_id: None,
|
||||
};
|
||||
self.repo.create_user(&user_row).await?;
|
||||
|
||||
// Update pairing status
|
||||
self.repo
|
||||
.update_pairing_status(code, &PairingStatus::Approved.to_string())
|
||||
.await?;
|
||||
|
||||
info!(
|
||||
code = %code,
|
||||
user_id = %user_id,
|
||||
platform_user_id = %row.platform_user_id,
|
||||
"pairing approved, user created"
|
||||
);
|
||||
|
||||
// Broadcast event
|
||||
let payload = UserAuthorizedPayload {
|
||||
id: user_id,
|
||||
platform_user_id: row.platform_user_id,
|
||||
platform_type: row.platform_type,
|
||||
channel_id: row.channel_id,
|
||||
display_name: row.display_name,
|
||||
};
|
||||
let value = serde_json::to_value(payload)?;
|
||||
self.broadcaster
|
||||
.broadcast(WebSocketMessage::new("channel.user-authorized", value));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rejects a pending pairing code.
|
||||
///
|
||||
/// Validates the code exists and is still pending (not expired or
|
||||
/// already processed), then marks it as rejected.
|
||||
pub async fn reject_pairing(&self, code: &str) -> Result<(), ChannelError> {
|
||||
let _row = self.get_valid_pending_pairing(code).await?;
|
||||
|
||||
self.repo
|
||||
.update_pairing_status(code, &PairingStatus::Rejected.to_string())
|
||||
.await?;
|
||||
|
||||
info!(code = %code, "pairing rejected");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns all pending (not expired) pairing requests.
|
||||
pub async fn get_pending_pairings(&self) -> Result<Vec<PairingCodeRow>, ChannelError> {
|
||||
let rows = self.repo.get_pending_pairings().await?;
|
||||
let now = now_ms();
|
||||
// Filter out expired ones that haven't been cleaned up yet
|
||||
let active: Vec<PairingCodeRow> = rows.into_iter().filter(|r| r.expires_at > now).collect();
|
||||
Ok(active)
|
||||
}
|
||||
|
||||
/// Checks whether a platform user is already authorized on this bot channel.
|
||||
pub async fn is_user_authorized(
|
||||
&self,
|
||||
platform_user_id: &str,
|
||||
platform_type: &str,
|
||||
channel_id: &str,
|
||||
) -> Result<bool, ChannelError> {
|
||||
let user = self
|
||||
.repo
|
||||
.get_user_by_platform(platform_user_id, platform_type, channel_id)
|
||||
.await?;
|
||||
Ok(user.is_some())
|
||||
}
|
||||
|
||||
/// Looks up the internal user ID for a platform user on this bot channel.
|
||||
///
|
||||
/// Returns `None` if the user is not authorized.
|
||||
pub async fn get_internal_user_id(
|
||||
&self,
|
||||
platform_user_id: &str,
|
||||
platform_type: &str,
|
||||
channel_id: &str,
|
||||
) -> Result<Option<String>, ChannelError> {
|
||||
let user = self
|
||||
.repo
|
||||
.get_user_by_platform(platform_user_id, platform_type, channel_id)
|
||||
.await?;
|
||||
Ok(user.map(|u| u.id))
|
||||
}
|
||||
|
||||
/// Starts a background task that periodically cleans up expired
|
||||
/// pairing codes. Returns a `JoinHandle` that can be used to cancel
|
||||
/// the task on shutdown.
|
||||
pub fn start_cleanup_timer(repo: Arc<dyn IChannelRepository>) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(PAIRING_CLEANUP_INTERVAL);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let now = now_ms();
|
||||
match repo.cleanup_expired_pairings(now).await {
|
||||
Ok(count) if count > 0 => {
|
||||
debug!(count, "cleaned up expired pairing codes");
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "failed to clean up expired pairings");
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Validates that a pairing code exists, is pending, and not expired.
|
||||
async fn get_valid_pending_pairing(&self, code: &str) -> Result<PairingCodeRow, ChannelError> {
|
||||
let row = self
|
||||
.repo
|
||||
.get_pairing_by_code(code)
|
||||
.await?
|
||||
.ok_or_else(|| ChannelError::PairingNotFound(code.to_owned()))?;
|
||||
|
||||
if row.status != PairingStatus::Pending.to_string() {
|
||||
return Err(ChannelError::PairingAlreadyProcessed(code.to_owned()));
|
||||
}
|
||||
|
||||
let now = now_ms();
|
||||
if row.expires_at <= now {
|
||||
// Mark as expired for consistency
|
||||
let _ = self
|
||||
.repo
|
||||
.update_pairing_status(code, &PairingStatus::Expired.to_string())
|
||||
.await;
|
||||
return Err(ChannelError::PairingExpired(code.to_owned()));
|
||||
}
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// Expires any pending codes for the given platform user.
|
||||
///
|
||||
/// Called before creating a new code to ensure only one active code
|
||||
/// per user at a time.
|
||||
async fn expire_user_pending_codes(
|
||||
&self,
|
||||
platform_user_id: &str,
|
||||
platform_type: &str,
|
||||
channel_id: &str,
|
||||
) -> Result<(), ChannelError> {
|
||||
let pending = self.repo.get_pending_pairings().await?;
|
||||
for row in pending {
|
||||
if row.platform_user_id == platform_user_id
|
||||
&& row.platform_type == platform_type
|
||||
&& row.channel_id.as_deref() == Some(channel_id)
|
||||
{
|
||||
self.repo
|
||||
.update_pairing_status(&row.code, &PairingStatus::Expired.to_string())
|
||||
.await?;
|
||||
debug!(
|
||||
code = %row.code,
|
||||
"expired old pending code for user"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nomifun_db::models::{AssistantSessionRow, AssistantUserRow, ChannelPluginRow, PairingCodeRow};
|
||||
use nomifun_db::{DbError, IChannelRepository, UpdatePluginStatusParams};
|
||||
use std::sync::Mutex;
|
||||
|
||||
// ── Mock EventBroadcaster ──────────────────────────────────────────
|
||||
|
||||
struct MockBroadcaster {
|
||||
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl MockBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn take_events(&self) -> Vec<WebSocketMessage<serde_json::Value>> {
|
||||
let mut guard = self.events.lock().unwrap();
|
||||
std::mem::take(&mut *guard)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBroadcaster for MockBroadcaster {
|
||||
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mock IChannelRepository ────────────────────────────────────────
|
||||
|
||||
struct MockRepo {
|
||||
pairings: Mutex<Vec<PairingCodeRow>>,
|
||||
users: Mutex<Vec<AssistantUserRow>>,
|
||||
}
|
||||
|
||||
impl MockRepo {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
pairings: Mutex::new(Vec::new()),
|
||||
users: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_pairings(&self) -> Vec<PairingCodeRow> {
|
||||
self.pairings.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn get_users(&self) -> Vec<AssistantUserRow> {
|
||||
self.users.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IChannelRepository for MockRepo {
|
||||
// -- Plugin CRUD (unused stubs) --
|
||||
|
||||
async fn get_all_plugins(&self) -> Result<Vec<ChannelPluginRow>, DbError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn get_plugin(&self, _id: &str) -> Result<Option<ChannelPluginRow>, DbError> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn upsert_plugin(&self, _row: &ChannelPluginRow) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn update_plugin_status(&self, _id: &str, _params: &UpdatePluginStatusParams) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn update_plugin_companion(&self, _id: &str, _companion_id: Option<&str>) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn update_plugin_bot_key(&self, _id: &str, _bot_key: &str) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn delete_plugin(&self, _id: &str) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -- User CRUD --
|
||||
|
||||
async fn get_all_users(&self) -> Result<Vec<AssistantUserRow>, DbError> {
|
||||
Ok(self.users.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
async fn get_user_by_platform(
|
||||
&self,
|
||||
platform_user_id: &str,
|
||||
platform_type: &str,
|
||||
channel_id: &str,
|
||||
) -> Result<Option<AssistantUserRow>, DbError> {
|
||||
let users = self.users.lock().unwrap();
|
||||
Ok(users
|
||||
.iter()
|
||||
.find(|u| {
|
||||
u.platform_user_id == platform_user_id
|
||||
&& u.platform_type == platform_type
|
||||
&& u.channel_id.as_deref() == Some(channel_id)
|
||||
})
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn create_user(&self, row: &AssistantUserRow) -> Result<(), DbError> {
|
||||
let mut users = self.users.lock().unwrap();
|
||||
if users.iter().any(|u| {
|
||||
u.platform_user_id == row.platform_user_id
|
||||
&& u.platform_type == row.platform_type
|
||||
&& u.channel_id == row.channel_id
|
||||
}) {
|
||||
return Err(DbError::Conflict("user already exists".into()));
|
||||
}
|
||||
users.push(row.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_user_last_active(&self, id: &str, last_active: TimestampMs) -> Result<(), DbError> {
|
||||
let mut users = self.users.lock().unwrap();
|
||||
if let Some(u) = users.iter_mut().find(|u| u.id == id) {
|
||||
u.last_active = Some(last_active);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DbError::NotFound(id.into()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_user(&self, id: &str) -> Result<(), DbError> {
|
||||
let mut users = self.users.lock().unwrap();
|
||||
let len_before = users.len();
|
||||
users.retain(|u| u.id != id);
|
||||
if users.len() == len_before {
|
||||
Err(DbError::NotFound(id.into()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// -- Session CRUD (unused stubs) --
|
||||
|
||||
async fn get_all_sessions(&self) -> Result<Vec<AssistantSessionRow>, DbError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn get_session(&self, _id: &str) -> Result<Option<AssistantSessionRow>, DbError> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn get_or_create_session(
|
||||
&self,
|
||||
_user_id: &str,
|
||||
_chat_id: &str,
|
||||
_channel_id: &str,
|
||||
new_row: &AssistantSessionRow,
|
||||
) -> Result<AssistantSessionRow, DbError> {
|
||||
Ok(new_row.clone())
|
||||
}
|
||||
async fn update_session_activity(&self, _id: &str, _last_activity: TimestampMs) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn update_session_conversation(&self, _id: &str, _conversation_id: i64) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn update_session_agent_type(&self, _id: &str, _agent_type: &str) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn delete_sessions_by_user(&self, _user_id: &str) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn delete_sessions_by_channel(&self, _channel_id: &str) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn delete_session_by_user_chat(
|
||||
&self,
|
||||
_user_id: &str,
|
||||
_chat_id: &str,
|
||||
_channel_id: &str,
|
||||
) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -- Pairing codes --
|
||||
|
||||
async fn create_pairing(&self, row: &PairingCodeRow) -> Result<(), DbError> {
|
||||
let mut pairings = self.pairings.lock().unwrap();
|
||||
if pairings.iter().any(|p| p.code == row.code) {
|
||||
return Err(DbError::Conflict("duplicate code".into()));
|
||||
}
|
||||
pairings.push(row.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_pending_pairings(&self) -> Result<Vec<PairingCodeRow>, DbError> {
|
||||
let pairings = self.pairings.lock().unwrap();
|
||||
Ok(pairings.iter().filter(|p| p.status == "pending").cloned().collect())
|
||||
}
|
||||
|
||||
async fn get_pairing_by_code(&self, code: &str) -> Result<Option<PairingCodeRow>, DbError> {
|
||||
let pairings = self.pairings.lock().unwrap();
|
||||
Ok(pairings.iter().find(|p| p.code == code).cloned())
|
||||
}
|
||||
|
||||
async fn update_pairing_status(&self, code: &str, status: &str) -> Result<(), DbError> {
|
||||
let mut pairings = self.pairings.lock().unwrap();
|
||||
if let Some(p) = pairings.iter_mut().find(|p| p.code == code) {
|
||||
p.status = status.to_owned();
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DbError::NotFound(code.into()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup_expired_pairings(&self, now: TimestampMs) -> Result<u64, DbError> {
|
||||
let mut pairings = self.pairings.lock().unwrap();
|
||||
let mut count = 0u64;
|
||||
for p in pairings.iter_mut() {
|
||||
if p.status == "pending" && p.expires_at <= now {
|
||||
p.status = "expired".into();
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
fn make_service() -> (PairingService, Arc<MockRepo>, Arc<MockBroadcaster>) {
|
||||
let repo = Arc::new(MockRepo::new());
|
||||
let broadcaster = Arc::new(MockBroadcaster::new());
|
||||
let svc = PairingService::new(repo.clone(), broadcaster.clone());
|
||||
(svc, repo, broadcaster)
|
||||
}
|
||||
|
||||
// ── generate_pairing_code ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn code_has_correct_length() {
|
||||
let code = generate_pairing_code().unwrap();
|
||||
assert_eq!(code.len(), PAIRING_CODE_LENGTH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_is_all_digits() {
|
||||
let code = generate_pairing_code().unwrap();
|
||||
assert!(code.chars().all(|c| c.is_ascii_digit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_is_zero_padded() {
|
||||
// Generate many codes; at least some should start with '0' statistically,
|
||||
// but more importantly verify format consistency.
|
||||
for _ in 0..100 {
|
||||
let code = generate_pairing_code().unwrap();
|
||||
assert_eq!(code.len(), PAIRING_CODE_LENGTH);
|
||||
assert!(code.chars().all(|c| c.is_ascii_digit()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codes_are_not_all_identical() {
|
||||
let codes: std::collections::HashSet<String> = (0..50).map(|_| generate_pairing_code().unwrap()).collect();
|
||||
// With 6-digit codes, 50 random samples should produce > 1 unique
|
||||
assert!(codes.len() > 1);
|
||||
}
|
||||
|
||||
// ── request_pairing ────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_pairing_creates_code() {
|
||||
let (svc, repo, _bc) = make_service();
|
||||
let code = svc.request_pairing("tg_42", "telegram", "achn_1", Some("Alice")).await.unwrap();
|
||||
assert_eq!(code.len(), PAIRING_CODE_LENGTH);
|
||||
|
||||
let pairings = repo.get_pairings();
|
||||
assert_eq!(pairings.len(), 1);
|
||||
assert_eq!(pairings[0].code, code);
|
||||
assert_eq!(pairings[0].platform_user_id, "tg_42");
|
||||
assert_eq!(pairings[0].platform_type, "telegram");
|
||||
assert_eq!(pairings[0].display_name.as_deref(), Some("Alice"));
|
||||
assert_eq!(pairings[0].status, "pending");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_pairing_broadcasts_event() {
|
||||
let (svc, _repo, bc) = make_service();
|
||||
svc.request_pairing("tg_42", "telegram", "achn_1", Some("Alice")).await.unwrap();
|
||||
|
||||
let events = bc.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].name, "channel.pairing-requested");
|
||||
assert_eq!(events[0].data["platform_user_id"], "tg_42");
|
||||
assert_eq!(events[0].data["platform_type"], "telegram");
|
||||
assert_eq!(events[0].data["display_name"], "Alice");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_pairing_sets_correct_expiry() {
|
||||
let (svc, repo, _bc) = make_service();
|
||||
let before = now_ms();
|
||||
svc.request_pairing("u1", "lark", "achn_1", None).await.unwrap();
|
||||
let after = now_ms();
|
||||
|
||||
let p = &repo.get_pairings()[0];
|
||||
let expected_ttl = PAIRING_CODE_TTL.as_millis() as TimestampMs;
|
||||
assert!(p.expires_at >= before + expected_ttl);
|
||||
assert!(p.expires_at <= after + expected_ttl);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_pairing_expires_old_code() {
|
||||
let (svc, repo, _bc) = make_service();
|
||||
|
||||
let code1 = svc.request_pairing("tg_42", "telegram", "achn_1", Some("Alice")).await.unwrap();
|
||||
let code2 = svc.request_pairing("tg_42", "telegram", "achn_1", Some("Alice")).await.unwrap();
|
||||
|
||||
assert_ne!(code1, code2);
|
||||
|
||||
let pairings = repo.get_pairings();
|
||||
let old = pairings.iter().find(|p| p.code == code1).unwrap();
|
||||
let new = pairings.iter().find(|p| p.code == code2).unwrap();
|
||||
assert_eq!(old.status, "expired");
|
||||
assert_eq!(new.status, "pending");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_pairing_no_display_name() {
|
||||
let (svc, repo, _bc) = make_service();
|
||||
svc.request_pairing("u1", "dingtalk", "achn_1", None).await.unwrap();
|
||||
|
||||
let pairings = repo.get_pairings();
|
||||
assert!(pairings[0].display_name.is_none());
|
||||
}
|
||||
|
||||
// ── approve_pairing ────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn approve_creates_user_and_updates_status() {
|
||||
let (svc, repo, _bc) = make_service();
|
||||
let code = svc.request_pairing("tg_42", "telegram", "achn_1", Some("Alice")).await.unwrap();
|
||||
|
||||
svc.approve_pairing(&code).await.unwrap();
|
||||
|
||||
// Check pairing status
|
||||
let pairings = repo.get_pairings();
|
||||
let p = pairings.iter().find(|p| p.code == code).unwrap();
|
||||
assert_eq!(p.status, "approved");
|
||||
|
||||
// Check user created
|
||||
let users = repo.get_users();
|
||||
assert_eq!(users.len(), 1);
|
||||
assert_eq!(users[0].platform_user_id, "tg_42");
|
||||
assert_eq!(users[0].platform_type, "telegram");
|
||||
assert_eq!(users[0].display_name.as_deref(), Some("Alice"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approve_broadcasts_user_authorized() {
|
||||
let (svc, _repo, bc) = make_service();
|
||||
let code = svc.request_pairing("tg_42", "telegram", "achn_1", Some("Alice")).await.unwrap();
|
||||
bc.take_events(); // clear request event
|
||||
|
||||
svc.approve_pairing(&code).await.unwrap();
|
||||
|
||||
let events = bc.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].name, "channel.user-authorized");
|
||||
assert_eq!(events[0].data["platform_user_id"], "tg_42");
|
||||
assert_eq!(events[0].data["platform_type"], "telegram");
|
||||
assert_eq!(events[0].data["display_name"], "Alice");
|
||||
assert!(events[0].data["id"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approve_nonexistent_code_returns_not_found() {
|
||||
let (svc, _repo, _bc) = make_service();
|
||||
let err = svc.approve_pairing("000000").await.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::PairingNotFound(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approve_already_approved_returns_already_processed() {
|
||||
let (svc, _repo, _bc) = make_service();
|
||||
let code = svc.request_pairing("tg_42", "telegram", "achn_1", None).await.unwrap();
|
||||
svc.approve_pairing(&code).await.unwrap();
|
||||
|
||||
let err = svc.approve_pairing(&code).await.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::PairingAlreadyProcessed(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approve_expired_code_returns_expired() {
|
||||
let (svc, repo, _bc) = make_service();
|
||||
// Manually insert an already-expired code
|
||||
let row = PairingCodeRow {
|
||||
code: "999999".into(),
|
||||
platform_user_id: "u1".into(),
|
||||
platform_type: "telegram".into(),
|
||||
channel_id: None,
|
||||
display_name: None,
|
||||
requested_at: 1000,
|
||||
expires_at: 1001, // long expired
|
||||
status: "pending".into(),
|
||||
};
|
||||
repo.pairings.lock().unwrap().push(row);
|
||||
|
||||
let err = svc.approve_pairing("999999").await.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::PairingExpired(_)));
|
||||
}
|
||||
|
||||
// ── reject_pairing ─────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn reject_updates_status() {
|
||||
let (svc, repo, _bc) = make_service();
|
||||
let code = svc.request_pairing("tg_42", "telegram", "achn_1", None).await.unwrap();
|
||||
|
||||
svc.reject_pairing(&code).await.unwrap();
|
||||
|
||||
let pairings = repo.get_pairings();
|
||||
let p = pairings.iter().find(|p| p.code == code).unwrap();
|
||||
assert_eq!(p.status, "rejected");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reject_nonexistent_code_returns_not_found() {
|
||||
let (svc, _repo, _bc) = make_service();
|
||||
let err = svc.reject_pairing("000000").await.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::PairingNotFound(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reject_already_approved_returns_already_processed() {
|
||||
let (svc, _repo, _bc) = make_service();
|
||||
let code = svc.request_pairing("tg_42", "telegram", "achn_1", None).await.unwrap();
|
||||
svc.approve_pairing(&code).await.unwrap();
|
||||
|
||||
let err = svc.reject_pairing(&code).await.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::PairingAlreadyProcessed(_)));
|
||||
}
|
||||
|
||||
// ── get_pending_pairings ───────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_pending_filters_expired() {
|
||||
let (svc, repo, _bc) = make_service();
|
||||
|
||||
// Insert valid pending code
|
||||
svc.request_pairing("u1", "telegram", "achn_1", None).await.unwrap();
|
||||
|
||||
// Insert manually expired code
|
||||
let expired_row = PairingCodeRow {
|
||||
code: "000001".into(),
|
||||
platform_user_id: "u2".into(),
|
||||
platform_type: "lark".into(),
|
||||
channel_id: None,
|
||||
display_name: None,
|
||||
requested_at: 1000,
|
||||
expires_at: 1001,
|
||||
status: "pending".into(),
|
||||
};
|
||||
repo.pairings.lock().unwrap().push(expired_row);
|
||||
|
||||
let pending = svc.get_pending_pairings().await.unwrap();
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].platform_user_id, "u1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_pending_empty_when_none() {
|
||||
let (svc, _repo, _bc) = make_service();
|
||||
let pending = svc.get_pending_pairings().await.unwrap();
|
||||
assert!(pending.is_empty());
|
||||
}
|
||||
|
||||
// ── is_user_authorized ─────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn unauthorized_user_returns_false() {
|
||||
let (svc, _repo, _bc) = make_service();
|
||||
let authorized = svc.is_user_authorized("tg_42", "telegram", "achn_1").await.unwrap();
|
||||
assert!(!authorized);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorized_user_returns_true_after_approval() {
|
||||
let (svc, _repo, _bc) = make_service();
|
||||
let code = svc.request_pairing("tg_42", "telegram", "achn_1", None).await.unwrap();
|
||||
svc.approve_pairing(&code).await.unwrap();
|
||||
|
||||
let authorized = svc.is_user_authorized("tg_42", "telegram", "achn_1").await.unwrap();
|
||||
assert!(authorized);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_channels_same_user_pair_independently() {
|
||||
let (svc, repo, _bc) = make_service();
|
||||
let c1 = svc.request_pairing("ou_same", "lark", "achn_1", Some("U")).await.unwrap();
|
||||
let c2 = svc.request_pairing("ou_same", "lark", "achn_2", Some("U")).await.unwrap();
|
||||
let pend = repo.get_pairings();
|
||||
assert_eq!(pend.iter().find(|p| p.code == c1).unwrap().status, "pending");
|
||||
assert_eq!(pend.iter().find(|p| p.code == c2).unwrap().status, "pending");
|
||||
svc.approve_pairing(&c1).await.unwrap();
|
||||
assert!(svc.is_user_authorized("ou_same", "lark", "achn_1").await.unwrap());
|
||||
assert!(!svc.is_user_authorized("ou_same", "lark", "achn_2").await.unwrap());
|
||||
}
|
||||
|
||||
// ── cleanup_expired_pairings (via repo directly) ───────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_marks_expired_as_expired() {
|
||||
let (svc, repo, _bc) = make_service();
|
||||
|
||||
// Insert manually expired pending code
|
||||
let expired_row = PairingCodeRow {
|
||||
code: "111111".into(),
|
||||
platform_user_id: "u1".into(),
|
||||
platform_type: "telegram".into(),
|
||||
channel_id: None,
|
||||
display_name: None,
|
||||
requested_at: 1000,
|
||||
expires_at: 2000,
|
||||
status: "pending".into(),
|
||||
};
|
||||
repo.pairings.lock().unwrap().push(expired_row);
|
||||
|
||||
// Insert valid pending code
|
||||
svc.request_pairing("u2", "lark", "achn_1", None).await.unwrap();
|
||||
|
||||
let count = repo.cleanup_expired_pairings(now_ms()).await.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
let pairings = repo.get_pairings();
|
||||
let expired = pairings.iter().find(|p| p.code == "111111").unwrap();
|
||||
assert_eq!(expired.status, "expired");
|
||||
}
|
||||
|
||||
// ── start_cleanup_timer ────────────────────────────────────────────
|
||||
|
||||
fn make_expired_row(code: &str) -> PairingCodeRow {
|
||||
PairingCodeRow {
|
||||
code: code.into(),
|
||||
platform_user_id: "u1".into(),
|
||||
platform_type: "telegram".into(),
|
||||
channel_id: None,
|
||||
display_name: None,
|
||||
requested_at: 1000,
|
||||
expires_at: 2000, // long past
|
||||
status: "pending".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression: `start_cleanup_timer` existed but had no caller, so the
|
||||
/// sweep never ran. This pins the timer behaviour itself — it must keep
|
||||
/// invoking `cleanup_expired_pairings` once per `PAIRING_CLEANUP_INTERVAL`
|
||||
/// (the assembly in nomifun-app now starts it at boot).
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn cleanup_timer_periodically_purges_expired_codes() {
|
||||
let repo = Arc::new(MockRepo::new());
|
||||
repo.pairings.lock().unwrap().push(make_expired_row("222222"));
|
||||
|
||||
let handle = PairingService::start_cleanup_timer(repo.clone());
|
||||
|
||||
// The paused clock auto-advances while the test sleeps, driving the
|
||||
// spawned interval deterministically. The first tick fires
|
||||
// immediately and purges the seeded code.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
assert_eq!(repo.get_pairings()[0].status, "expired");
|
||||
|
||||
// Seed another expired code and cross one full interval to prove
|
||||
// the sweep is periodic, not one-shot.
|
||||
repo.pairings.lock().unwrap().push(make_expired_row("333333"));
|
||||
tokio::time::sleep(PAIRING_CLEANUP_INTERVAL).await;
|
||||
let pairings = repo.get_pairings();
|
||||
let second = pairings.iter().find(|p| p.code == "333333").unwrap();
|
||||
assert_eq!(second.status, "expired");
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Per-conversation store of the blocking decision currently awaiting a
|
||||
//! numbered reply from a channel user.
|
||||
//!
|
||||
//! When a remote-channel-driven conversation hits a blocking decision (the
|
||||
//! agent asks the user to choose / grant permission), the relay records the
|
||||
//! pending decision here and forwards a numbered text list to the channel.
|
||||
//! The orchestrator's inbound interception reads it back to map the user's
|
||||
//! numeric reply onto an option, then clears it.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::types::DecisionOption;
|
||||
|
||||
/// A blocking decision awaiting the channel user's numbered reply.
|
||||
///
|
||||
/// `prompt` is retained so a non-numeric reply can re-render the same
|
||||
/// numbered list without re-deriving it from the agent stream.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PendingDecision {
|
||||
pub conversation_id: String,
|
||||
pub call_id: String,
|
||||
pub prompt: String,
|
||||
pub options: Vec<DecisionOption>,
|
||||
}
|
||||
|
||||
/// Concurrent store of pending decisions keyed by conversation id.
|
||||
///
|
||||
/// At most one decision is outstanding per conversation (a new decision for
|
||||
/// the same conversation overwrites the previous one). Shared by the relay
|
||||
/// (writer) and the orchestrator + message service (reader / clearer).
|
||||
#[derive(Default)]
|
||||
pub struct PendingDecisionStore {
|
||||
inner: Mutex<HashMap<String, PendingDecision>>,
|
||||
}
|
||||
|
||||
impl PendingDecisionStore {
|
||||
/// Creates an empty store behind an `Arc` for sharing across the relay,
|
||||
/// orchestrator, and message service.
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
/// Records (or overwrites) the pending decision for its conversation.
|
||||
pub fn put(&self, decision: PendingDecision) {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(decision.conversation_id.clone(), decision);
|
||||
}
|
||||
|
||||
/// Returns a clone of the pending decision for a conversation, if any,
|
||||
/// without removing it.
|
||||
pub fn peek(&self, conversation_id: &str) -> Option<PendingDecision> {
|
||||
self.inner.lock().unwrap().get(conversation_id).cloned()
|
||||
}
|
||||
|
||||
/// Removes and returns the pending decision for a conversation, if any.
|
||||
pub fn take(&self, conversation_id: &str) -> Option<PendingDecision> {
|
||||
self.inner.lock().unwrap().remove(conversation_id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn opt(option_id: &str, label: &str) -> DecisionOption {
|
||||
DecisionOption {
|
||||
option_id: option_id.into(),
|
||||
label: label.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn decision(conversation_id: &str, call_id: &str) -> PendingDecision {
|
||||
PendingDecision {
|
||||
conversation_id: conversation_id.into(),
|
||||
call_id: call_id.into(),
|
||||
prompt: "Proceed?".into(),
|
||||
options: vec![opt("a", "Allow"), opt("b", "Deny")],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_peek_take_round_trip() {
|
||||
let store = PendingDecisionStore::new();
|
||||
assert!(store.peek("conv-1").is_none());
|
||||
|
||||
store.put(decision("conv-1", "call-1"));
|
||||
|
||||
let peeked = store.peek("conv-1").expect("peek should see the put");
|
||||
assert_eq!(peeked.call_id, "call-1");
|
||||
assert_eq!(peeked.prompt, "Proceed?");
|
||||
assert_eq!(peeked.options.len(), 2);
|
||||
// peek does not consume.
|
||||
assert!(store.peek("conv-1").is_some());
|
||||
|
||||
let taken = store.take("conv-1").expect("take should return the entry");
|
||||
assert_eq!(taken.call_id, "call-1");
|
||||
// take consumes.
|
||||
assert!(store.peek("conv-1").is_none());
|
||||
assert!(store.take("conv-1").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_overwrites_by_conversation() {
|
||||
let store = PendingDecisionStore::new();
|
||||
store.put(decision("conv-1", "call-1"));
|
||||
store.put(decision("conv-1", "call-2"));
|
||||
|
||||
let peeked = store.peek("conv-1").unwrap();
|
||||
assert_eq!(peeked.call_id, "call-2", "latest put wins per conversation");
|
||||
|
||||
// Distinct conversations are independent.
|
||||
store.put(decision("conv-2", "call-x"));
|
||||
assert_eq!(store.peek("conv-1").unwrap().call_id, "call-2");
|
||||
assert_eq!(store.peek("conv-2").unwrap().call_id, "call-x");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use crate::error::ChannelError;
|
||||
use crate::types::{BotInfo, PluginConfig, PluginStatus, PluginType, UnifiedIncomingMessage, UnifiedOutgoingMessage};
|
||||
|
||||
/// Thread-safe plugin status cell shared between a plugin facade and its
|
||||
/// background connection loop.
|
||||
///
|
||||
/// Plugin structs expose `status()` through `&self`, but their long-running
|
||||
/// receive loops (long-polling / WebSocket) run on detached tokio tasks.
|
||||
/// When such a loop exhausts its reconnect budget and exits, the facade must
|
||||
/// reflect the death — otherwise the `ChannelManager` (and the frontend)
|
||||
/// keep reporting `Running` for a plugin that no longer receives messages.
|
||||
/// Sharing the status through this cell lets the loop flip it to `Error`,
|
||||
/// which the manager watchdog observes via the regular
|
||||
/// [`ChannelPlugin::status`] call.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SharedPluginStatus(Arc<RwLock<PluginStatus>>);
|
||||
|
||||
impl SharedPluginStatus {
|
||||
pub fn new(initial: PluginStatus) -> Self {
|
||||
Self(Arc::new(RwLock::new(initial)))
|
||||
}
|
||||
|
||||
pub fn get(&self) -> PluginStatus {
|
||||
// PluginStatus is Copy and no code can panic while holding the
|
||||
// guard, so poisoning is unreachable in practice.
|
||||
*self.0.read().expect("plugin status lock poisoned")
|
||||
}
|
||||
|
||||
pub fn set(&self, status: PluginStatus) {
|
||||
*self.0.write().expect("plugin status lock poisoned") = status;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SharedPluginStatus {
|
||||
fn default() -> Self {
|
||||
Self::new(PluginStatus::Created)
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a plugin as `Error` when its background loop exits without having
|
||||
/// been asked to shut down (reconnect exhaustion, unexpected clean close).
|
||||
///
|
||||
/// Returns `true` when the status was flipped. Called at the tail of every
|
||||
/// plugin receive loop so a dead loop never leaves the facade stuck on
|
||||
/// `Running`; the manager watchdog picks the `Error` up and attempts a
|
||||
/// rate-limited restart.
|
||||
pub fn mark_error_on_unexpected_exit(
|
||||
status: &SharedPluginStatus,
|
||||
shutdown_rx: &tokio::sync::watch::Receiver<bool>,
|
||||
plugin_name: &str,
|
||||
) -> bool {
|
||||
if *shutdown_rx.borrow() {
|
||||
// Ordinary `stop()` path — the facade manages its own transition to
|
||||
// `Stopping`/`Stopped`, don't fight it from the loop.
|
||||
return false;
|
||||
}
|
||||
status.set(PluginStatus::Error);
|
||||
tracing::error!(
|
||||
plugin = plugin_name,
|
||||
"background loop exited unexpectedly; plugin marked as Error"
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// Callback channels for a channel plugin.
|
||||
///
|
||||
/// Instead of closures (which are hard to make object-safe), plugins
|
||||
/// receive an `mpsc::Sender` for incoming messages and tool-confirmation
|
||||
/// events. The `ChannelManager` holds the receiving ends.
|
||||
///
|
||||
/// This addresses M-63 — the API Spec `BasePlugin.onMessage/onConfirm`
|
||||
/// callbacks are mapped to channel-based injection.
|
||||
#[derive(Clone)]
|
||||
pub struct PluginCallbacks {
|
||||
/// Sender for incoming messages from the platform.
|
||||
pub message_tx: tokio::sync::mpsc::Sender<UnifiedIncomingMessage>,
|
||||
/// Sender for tool confirmation callbacks (callId, value).
|
||||
pub confirm_tx: tokio::sync::mpsc::Sender<(String, String)>,
|
||||
}
|
||||
|
||||
/// Abstraction over a platform-specific channel plugin.
|
||||
///
|
||||
/// Each IM platform (Telegram, Lark, DingTalk, WeChat) implements this
|
||||
/// trait behind a feature flag. The `ChannelManager` holds plugins as
|
||||
/// `Box<dyn ChannelPlugin>` for runtime polymorphism.
|
||||
///
|
||||
/// ## Lifecycle
|
||||
///
|
||||
/// ```text
|
||||
/// created → initialize(config, callbacks) → ready → start() → running
|
||||
/// → stop() → stopped
|
||||
/// ```
|
||||
///
|
||||
/// Any method may transition to `Error` on failure.
|
||||
#[async_trait::async_trait]
|
||||
pub trait ChannelPlugin: Send + Sync {
|
||||
/// Initialize the plugin with configuration and callback channels.
|
||||
///
|
||||
/// Should validate credentials format (but not test the connection).
|
||||
/// Transitions status: `Created → Initializing → Ready` (or `Error`).
|
||||
async fn initialize(&mut self, config: PluginConfig, callbacks: PluginCallbacks) -> Result<(), ChannelError>;
|
||||
|
||||
/// Start the platform connection (long-polling, WebSocket, etc.).
|
||||
///
|
||||
/// Transitions status: `Ready → Starting → Running` (or `Error`).
|
||||
async fn start(&mut self) -> Result<(), ChannelError>;
|
||||
|
||||
/// Gracefully stop the platform connection.
|
||||
///
|
||||
/// Transitions status: `Running → Stopping → Stopped`.
|
||||
async fn stop(&mut self) -> Result<(), ChannelError>;
|
||||
|
||||
/// Send a message to a specific chat. Returns the platform message ID.
|
||||
async fn send_message(&self, chat_id: &str, message: UnifiedOutgoingMessage) -> Result<String, ChannelError>;
|
||||
|
||||
/// Edit an existing message on the platform.
|
||||
///
|
||||
/// Platforms that don't support editing (e.g., WeChat) may implement
|
||||
/// a degraded strategy (send a new reply instead).
|
||||
async fn edit_message(
|
||||
&self,
|
||||
chat_id: &str,
|
||||
message_id: &str,
|
||||
message: UnifiedOutgoingMessage,
|
||||
) -> Result<(), ChannelError>;
|
||||
|
||||
/// Number of currently active (chatting) users on this plugin.
|
||||
fn active_user_count(&self) -> usize;
|
||||
|
||||
/// Bot identity on the platform, available after initialization.
|
||||
fn bot_info(&self) -> Option<&BotInfo>;
|
||||
|
||||
/// The platform type this plugin handles.
|
||||
fn plugin_type(&self) -> PluginType;
|
||||
|
||||
/// Current lifecycle status.
|
||||
fn status(&self) -> PluginStatus;
|
||||
|
||||
/// The most recent error message, if status is `Error`.
|
||||
fn last_error(&self) -> Option<&str>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::{OutgoingMessageType, PluginCredentials, PluginStatus, PluginType};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Minimal mock plugin for testing the trait interface.
|
||||
struct MockPlugin {
|
||||
status: PluginStatus,
|
||||
plugin_type: PluginType,
|
||||
bot_info: Option<BotInfo>,
|
||||
last_error: Option<String>,
|
||||
}
|
||||
|
||||
impl MockPlugin {
|
||||
fn new(plugin_type: PluginType) -> Self {
|
||||
Self {
|
||||
status: PluginStatus::Created,
|
||||
plugin_type,
|
||||
bot_info: None,
|
||||
last_error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ChannelPlugin for MockPlugin {
|
||||
async fn initialize(&mut self, config: PluginConfig, _callbacks: PluginCallbacks) -> Result<(), ChannelError> {
|
||||
self.status = PluginStatus::Initializing;
|
||||
if config.credentials.token.is_none() {
|
||||
self.status = PluginStatus::Error;
|
||||
self.last_error = Some("Missing token".into());
|
||||
return Err(ChannelError::InvalidConfig("Missing token".into()));
|
||||
}
|
||||
self.bot_info = Some(BotInfo {
|
||||
id: "mock_bot".into(),
|
||||
username: Some("mock_bot_user".into()),
|
||||
display_name: "Mock Bot".into(),
|
||||
});
|
||||
self.status = PluginStatus::Ready;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start(&mut self) -> Result<(), ChannelError> {
|
||||
self.status = PluginStatus::Starting;
|
||||
self.status = PluginStatus::Running;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(&mut self) -> Result<(), ChannelError> {
|
||||
self.status = PluginStatus::Stopping;
|
||||
self.status = PluginStatus::Stopped;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_message(&self, _chat_id: &str, _message: UnifiedOutgoingMessage) -> Result<String, ChannelError> {
|
||||
Ok("mock_msg_id".into())
|
||||
}
|
||||
|
||||
async fn edit_message(
|
||||
&self,
|
||||
_chat_id: &str,
|
||||
_message_id: &str,
|
||||
_message: UnifiedOutgoingMessage,
|
||||
) -> Result<(), ChannelError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn active_user_count(&self) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
fn bot_info(&self) -> Option<&BotInfo> {
|
||||
self.bot_info.as_ref()
|
||||
}
|
||||
|
||||
fn plugin_type(&self) -> PluginType {
|
||||
self.plugin_type
|
||||
}
|
||||
|
||||
fn status(&self) -> PluginStatus {
|
||||
self.status
|
||||
}
|
||||
|
||||
fn last_error(&self) -> Option<&str> {
|
||||
self.last_error.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
fn make_test_config(token: Option<&str>) -> PluginConfig {
|
||||
PluginConfig {
|
||||
credentials: PluginCredentials {
|
||||
token: token.map(String::from),
|
||||
..Default::default()
|
||||
},
|
||||
config: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_test_callbacks() -> PluginCallbacks {
|
||||
let (message_tx, _message_rx) = mpsc::channel(16);
|
||||
let (confirm_tx, _confirm_rx) = mpsc::channel(16);
|
||||
PluginCallbacks { message_tx, confirm_tx }
|
||||
}
|
||||
|
||||
fn make_test_outgoing() -> UnifiedOutgoingMessage {
|
||||
UnifiedOutgoingMessage {
|
||||
message_type: OutgoingMessageType::Text,
|
||||
text: Some("test".into()),
|
||||
parse_mode: None,
|
||||
buttons: None,
|
||||
keyboard: None,
|
||||
image_url: None,
|
||||
file_url: None,
|
||||
file_name: None,
|
||||
media_actions: None,
|
||||
reply_to_message_id: None,
|
||||
silent: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lifecycle_happy_path() {
|
||||
let mut plugin = MockPlugin::new(PluginType::Telegram);
|
||||
assert_eq!(plugin.status(), PluginStatus::Created);
|
||||
assert!(plugin.bot_info().is_none());
|
||||
|
||||
let config = make_test_config(Some("bot:123"));
|
||||
plugin.initialize(config, make_test_callbacks()).await.unwrap();
|
||||
assert_eq!(plugin.status(), PluginStatus::Ready);
|
||||
assert!(plugin.bot_info().is_some());
|
||||
|
||||
plugin.start().await.unwrap();
|
||||
assert_eq!(plugin.status(), PluginStatus::Running);
|
||||
|
||||
plugin.stop().await.unwrap();
|
||||
assert_eq!(plugin.status(), PluginStatus::Stopped);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initialize_with_missing_token_fails() {
|
||||
let mut plugin = MockPlugin::new(PluginType::Telegram);
|
||||
let config = make_test_config(None);
|
||||
let result = plugin.initialize(config, make_test_callbacks()).await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(plugin.status(), PluginStatus::Error);
|
||||
assert_eq!(plugin.last_error(), Some("Missing token"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_message_returns_id() {
|
||||
let mut plugin = MockPlugin::new(PluginType::Telegram);
|
||||
let config = make_test_config(Some("bot:abc"));
|
||||
plugin.initialize(config, make_test_callbacks()).await.unwrap();
|
||||
plugin.start().await.unwrap();
|
||||
|
||||
let msg_id = plugin.send_message("chat_1", make_test_outgoing()).await.unwrap();
|
||||
assert_eq!(msg_id, "mock_msg_id");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_message_ok() {
|
||||
let mut plugin = MockPlugin::new(PluginType::Lark);
|
||||
let config = make_test_config(Some("token:xyz"));
|
||||
plugin.initialize(config, make_test_callbacks()).await.unwrap();
|
||||
plugin.start().await.unwrap();
|
||||
|
||||
let result = plugin.edit_message("chat_1", "msg_1", make_test_outgoing()).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_type_accessor() {
|
||||
let plugin = MockPlugin::new(PluginType::Dingtalk);
|
||||
assert_eq!(plugin.plugin_type(), PluginType::Dingtalk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_user_count_default() {
|
||||
let plugin = MockPlugin::new(PluginType::Weixin);
|
||||
assert_eq!(plugin.active_user_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trait_object_dispatch() {
|
||||
let mut plugin = MockPlugin::new(PluginType::Telegram);
|
||||
let config = make_test_config(Some("bot:obj"));
|
||||
plugin.initialize(config, make_test_callbacks()).await.unwrap();
|
||||
|
||||
// Verify the plugin can be used as a trait object
|
||||
let plugin_ref: &dyn ChannelPlugin = &plugin;
|
||||
assert_eq!(plugin_ref.plugin_type(), PluginType::Telegram);
|
||||
assert_eq!(plugin_ref.status(), PluginStatus::Ready);
|
||||
assert!(plugin_ref.bot_info().is_some());
|
||||
}
|
||||
|
||||
// ── SharedPluginStatus / mark_error_on_unexpected_exit ─────────────
|
||||
|
||||
#[test]
|
||||
fn shared_status_defaults_to_created() {
|
||||
let status = SharedPluginStatus::default();
|
||||
assert_eq!(status.get(), PluginStatus::Created);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_status_clones_observe_writes() {
|
||||
let status = SharedPluginStatus::new(PluginStatus::Ready);
|
||||
let clone = status.clone();
|
||||
clone.set(PluginStatus::Running);
|
||||
assert_eq!(status.get(), PluginStatus::Running);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unexpected_loop_exit_marks_error() {
|
||||
let status = SharedPluginStatus::new(PluginStatus::Running);
|
||||
let (_shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
|
||||
|
||||
// No shutdown was requested → the loop died on its own.
|
||||
assert!(mark_error_on_unexpected_exit(&status, &shutdown_rx, "test"));
|
||||
assert_eq!(status.get(), PluginStatus::Error);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_loop_exit_does_not_mark_error() {
|
||||
let status = SharedPluginStatus::new(PluginStatus::Running);
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
|
||||
shutdown_tx.send(true).unwrap();
|
||||
|
||||
// stop() was requested → the facade owns the Stopping/Stopped
|
||||
// transition; the loop must not overwrite it with Error.
|
||||
assert!(!mark_error_on_unexpected_exit(&status, &shutdown_rx, "test"));
|
||||
assert_eq!(status.get(), PluginStatus::Running);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Shared callback-data encoding for interactive buttons across channels.
|
||||
//!
|
||||
//! Channels that support inline buttons (Telegram, Discord, Slack, Mattermost)
|
||||
//! encode an [`ActionButton`] into a compact `custom_id`/`callback_data` string
|
||||
//! `"category:action"` or `"category:action:k=v,k=v"`, and decode the reverse
|
||||
//! when the user clicks. The `category` prefix mirrors the routing in
|
||||
//! `ActionExecutor` so the decoded [`crate::types::UnifiedAction`] lands in the
|
||||
//! right handler group.
|
||||
//!
|
||||
//! Telegram predates this module and keeps its own private copy; Discord and the
|
||||
//! later channels share this one.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::types::{ActionButton, ActionCategory};
|
||||
|
||||
/// A decoded callback payload from an interactive button.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ParsedCallback {
|
||||
pub category: ActionCategory,
|
||||
pub action: String,
|
||||
pub params: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
/// Derive the category prefix from an action name, matching `ActionExecutor`
|
||||
/// routing:
|
||||
/// - `system.confirm` → `"chat"` (handled by `handle_chat_action`)
|
||||
/// - `pairing.*` → `"platform"`
|
||||
/// - `chat.*` / `action.*` → `"chat"`
|
||||
/// - everything else (`session.*`, `help.*`, `agent.*`, `system.*`, ...) → `"system"`
|
||||
pub fn action_category_prefix(action: &str) -> &'static str {
|
||||
if action == "system.confirm" {
|
||||
return "chat";
|
||||
}
|
||||
match action.split('.').next().unwrap_or("") {
|
||||
"pairing" => "platform",
|
||||
"chat" | "action" => "chat",
|
||||
_ => "system",
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode an [`ActionButton`] into `"category:action"` or
|
||||
/// `"category:action:k=v,k=v"`. Inverse of [`parse_callback_data`].
|
||||
pub fn format_callback_data(btn: &ActionButton) -> String {
|
||||
let category = action_category_prefix(&btn.action);
|
||||
match &btn.params {
|
||||
Some(params) if !params.is_empty() => {
|
||||
let encoded: Vec<String> = params.iter().map(|(k, v)| format!("{k}={v}")).collect();
|
||||
format!("{category}:{}:{}", btn.action, encoded.join(","))
|
||||
}
|
||||
_ => format!("{category}:{}", btn.action),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a callback string `"category:action"` or `"category:action:k=v,k=v"`.
|
||||
/// Returns `None` for malformed input or an unknown category.
|
||||
pub fn parse_callback_data(data: &str) -> Option<ParsedCallback> {
|
||||
let parts: Vec<&str> = data.splitn(3, ':').collect();
|
||||
if parts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let category = match parts[0] {
|
||||
"platform" => ActionCategory::Platform,
|
||||
"system" => ActionCategory::System,
|
||||
"chat" => ActionCategory::Chat,
|
||||
_ => return None,
|
||||
};
|
||||
let action = parts[1].to_string();
|
||||
let params = if parts.len() == 3 && !parts[2].is_empty() {
|
||||
let mut map = HashMap::new();
|
||||
for pair in parts[2].split(',') {
|
||||
if let Some((k, v)) = pair.split_once('=') {
|
||||
map.insert(k.to_string(), v.to_string());
|
||||
}
|
||||
}
|
||||
if map.is_empty() { None } else { Some(map) }
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(ParsedCallback {
|
||||
category,
|
||||
action,
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn category_prefix_mapping() {
|
||||
assert_eq!(action_category_prefix("pairing.show"), "platform");
|
||||
assert_eq!(action_category_prefix("chat.regenerate"), "chat");
|
||||
assert_eq!(action_category_prefix("action.copy"), "chat");
|
||||
assert_eq!(action_category_prefix("session.new"), "system");
|
||||
assert_eq!(action_category_prefix("system.confirm"), "chat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_no_params() {
|
||||
let btn = ActionButton {
|
||||
label: "Help".into(),
|
||||
action: "help.show".into(),
|
||||
params: None,
|
||||
};
|
||||
assert_eq!(format_callback_data(&btn), "system:help.show");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_with_params() {
|
||||
let btn = ActionButton {
|
||||
label: "Confirm".into(),
|
||||
action: "system.confirm".into(),
|
||||
params: Some(HashMap::from([("callId".into(), "abc".into())])),
|
||||
};
|
||||
let s = format_callback_data(&btn);
|
||||
assert!(s.starts_with("chat:system.confirm:"));
|
||||
assert!(s.contains("callId=abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_category_action() {
|
||||
let p = parse_callback_data("system:session.new").unwrap();
|
||||
assert_eq!(p.category, ActionCategory::System);
|
||||
assert_eq!(p.action, "session.new");
|
||||
assert!(p.params.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_with_params() {
|
||||
let p = parse_callback_data("chat:system.confirm:callId=abc,value=yes").unwrap();
|
||||
assert_eq!(p.action, "system.confirm");
|
||||
let params = p.params.unwrap();
|
||||
assert_eq!(params.get("callId").unwrap(), "abc");
|
||||
assert_eq!(params.get("value").unwrap(), "yes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid() {
|
||||
assert!(parse_callback_data("nope").is_none());
|
||||
assert!(parse_callback_data("unknown:action").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_confirm_routes_to_chat() {
|
||||
let btn = ActionButton {
|
||||
label: "Yes".into(),
|
||||
action: "system.confirm".into(),
|
||||
params: Some(HashMap::from([("value".into(), "yes".into())])),
|
||||
};
|
||||
let parsed = parse_callback_data(&format_callback_data(&btn)).unwrap();
|
||||
// system.confirm is routed to chat handler
|
||||
assert_eq!(parsed.category, ActionCategory::Chat);
|
||||
assert_eq!(parsed.action, "system.confirm");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use reqwest::Client;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::error::ChannelError;
|
||||
|
||||
use super::types::{
|
||||
AccessTokenRequest, AccessTokenResponse, CreateCardInstanceRequest, CreateCardInstanceResponse, DeliverCardRequest,
|
||||
DeliverCardResponse, RegisterStreamRequest, RegisterStreamResponse, RobotInfoResponse, SendRobotMessageRequest,
|
||||
SendRobotMessageResponse, StreamSubscription, StreamingWriteRequest, StreamingWriteResponse, UpdateCardRequest,
|
||||
UpdateCardResponse,
|
||||
};
|
||||
|
||||
const DINGTALK_API_BASE: &str = "https://api.dingtalk.com";
|
||||
|
||||
/// Token refresh margin — refresh 5 minutes before expiry.
|
||||
const TOKEN_REFRESH_MARGIN: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
/// Cached access token with expiry tracking.
|
||||
struct TokenCache {
|
||||
token: String,
|
||||
acquired_at: Instant,
|
||||
expires_in: Duration,
|
||||
}
|
||||
|
||||
impl TokenCache {
|
||||
fn is_expired(&self) -> bool {
|
||||
self.acquired_at.elapsed() + TOKEN_REFRESH_MARGIN >= self.expires_in
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP client for the DingTalk Open Platform API.
|
||||
///
|
||||
/// Manages access token lifecycle (auto-refresh) and provides typed
|
||||
/// methods for bot info, WebSocket stream registration, AI Card
|
||||
/// operations, and message sending.
|
||||
pub(crate) struct DingtalkApi {
|
||||
client: Client,
|
||||
client_id: String,
|
||||
client_secret: String,
|
||||
token_cache: Arc<RwLock<Option<TokenCache>>>,
|
||||
}
|
||||
|
||||
impl DingtalkApi {
|
||||
/// Create a new DingTalk API client.
|
||||
pub fn new(client: Client, client_id: &str, client_secret: &str) -> Self {
|
||||
Self {
|
||||
client,
|
||||
client_id: client_id.to_string(),
|
||||
client_secret: client_secret.to_string(),
|
||||
token_cache: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a valid access token, refreshing if needed.
|
||||
async fn get_token(&self) -> Result<String, ChannelError> {
|
||||
// Fast path: check if cached token is still valid
|
||||
{
|
||||
let cache = self.token_cache.read().await;
|
||||
if let Some(ref tc) = *cache
|
||||
&& !tc.is_expired()
|
||||
{
|
||||
return Ok(tc.token.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Slow path: refresh the token
|
||||
self.refresh_token().await
|
||||
}
|
||||
|
||||
/// Request a new access token from DingTalk (v2 endpoint).
|
||||
async fn refresh_token(&self) -> Result<String, ChannelError> {
|
||||
let url = format!("{DINGTALK_API_BASE}/v1.0/oauth2/accessToken");
|
||||
let body = AccessTokenRequest {
|
||||
app_key: self.client_id.clone(),
|
||||
app_secret: self.client_secret.clone(),
|
||||
};
|
||||
|
||||
let resp: AccessTokenResponse = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("DingTalk token request failed: {e}")))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("DingTalk token parse failed: {e}")))?;
|
||||
|
||||
if let Some(code) = resp.errcode
|
||||
&& code != 0
|
||||
{
|
||||
return Err(ChannelError::ConnectionFailed(format!(
|
||||
"DingTalk token error (code={}): {}",
|
||||
code,
|
||||
resp.errmsg.as_deref().unwrap_or("unknown")
|
||||
)));
|
||||
}
|
||||
|
||||
let token = resp
|
||||
.access_token
|
||||
.ok_or_else(|| ChannelError::ConnectionFailed("DingTalk token response missing token".into()))?;
|
||||
|
||||
let expires_in = Duration::from_secs(resp.expire_in.unwrap_or(7200) as u64);
|
||||
|
||||
debug!(expires_in_secs = expires_in.as_secs(), "DingTalk token refreshed");
|
||||
|
||||
let mut cache = self.token_cache.write().await;
|
||||
*cache = Some(TokenCache {
|
||||
token: token.clone(),
|
||||
acquired_at: Instant::now(),
|
||||
expires_in,
|
||||
});
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Get bot identity information.
|
||||
pub async fn get_bot_info(&self) -> Result<RobotInfoResponse, ChannelError> {
|
||||
let token = self.get_token().await?;
|
||||
let url = format!("{DINGTALK_API_BASE}/v1.0/im/robot/info");
|
||||
|
||||
let resp: RobotInfoResponse = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("x-acs-dingtalk-access-token", &token)
|
||||
.json(&serde_json::json!({}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("DingTalk bot info request failed: {e}")))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("DingTalk bot info parse failed: {e}")))?;
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// Register for WebSocket Stream and get the connection endpoint.
|
||||
///
|
||||
/// Note: This endpoint uses clientId/clientSecret in the request body,
|
||||
/// NOT the access token header. The Accept header is required to get
|
||||
/// JSON instead of XML.
|
||||
pub async fn register_stream(&self) -> Result<RegisterStreamResponse, ChannelError> {
|
||||
let url = format!("{DINGTALK_API_BASE}/v1.0/gateway/connections/open");
|
||||
|
||||
let body = RegisterStreamRequest {
|
||||
client_id: self.client_id.clone(),
|
||||
client_secret: self.client_secret.clone(),
|
||||
subscriptions: vec![
|
||||
StreamSubscription {
|
||||
sub_type: "EVENT".into(),
|
||||
topic: "*".into(),
|
||||
},
|
||||
StreamSubscription {
|
||||
sub_type: "CALLBACK".into(),
|
||||
topic: "/v1.0/im/bot/messages/get".into(),
|
||||
},
|
||||
StreamSubscription {
|
||||
sub_type: "CALLBACK".into(),
|
||||
topic: "/v1.0/card/instances/callback".into(),
|
||||
},
|
||||
],
|
||||
ua: Some("nomicore".into()),
|
||||
};
|
||||
|
||||
let raw_resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Accept", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("DingTalk stream registration failed: {e}")))?;
|
||||
|
||||
let status = raw_resp.status();
|
||||
let body_text = raw_resp.text().await.map_err(|e| {
|
||||
ChannelError::ConnectionFailed(format!("DingTalk stream registration read body failed: {e}"))
|
||||
})?;
|
||||
|
||||
debug!(status = %status, body_len = body_text.len(), "DingTalk stream registration response received");
|
||||
|
||||
let resp: RegisterStreamResponse = serde_json::from_str(&body_text).map_err(|e| {
|
||||
ChannelError::ConnectionFailed(format!(
|
||||
"DingTalk stream registration parse failed: {e}, body: {}",
|
||||
&body_text[..body_text.len().min(200)]
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// Create an AI Card instance.
|
||||
pub async fn create_card_instance(
|
||||
&self,
|
||||
request: &CreateCardInstanceRequest,
|
||||
) -> Result<CreateCardInstanceResponse, ChannelError> {
|
||||
let token = self.get_token().await?;
|
||||
let url = format!("{DINGTALK_API_BASE}/v1.0/card/instances");
|
||||
|
||||
let resp: CreateCardInstanceResponse = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("x-acs-dingtalk-access-token", &token)
|
||||
.json(request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("DingTalk create card failed: {e}")))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("DingTalk create card parse failed: {e}")))?;
|
||||
|
||||
if resp.success != Some(true) {
|
||||
return Err(ChannelError::MessageSendFailed(
|
||||
"DingTalk create card returned success=false".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// Deliver a card instance to a chat.
|
||||
pub async fn deliver_card(&self, request: &DeliverCardRequest) -> Result<DeliverCardResponse, ChannelError> {
|
||||
let token = self.get_token().await?;
|
||||
let url = format!("{DINGTALK_API_BASE}/v1.0/card/instances/deliver");
|
||||
|
||||
let resp: DeliverCardResponse = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("x-acs-dingtalk-access-token", &token)
|
||||
.json(request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("DingTalk deliver card failed: {e}")))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("DingTalk deliver card parse failed: {e}")))?;
|
||||
|
||||
if resp.success != Some(true) {
|
||||
warn!("DingTalk deliver card returned success=false");
|
||||
}
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// Stream-write content to an AI Card (append mode).
|
||||
pub async fn streaming_write(
|
||||
&self,
|
||||
request: &StreamingWriteRequest,
|
||||
) -> Result<StreamingWriteResponse, ChannelError> {
|
||||
let token = self.get_token().await?;
|
||||
let url = format!("{DINGTALK_API_BASE}/v1.0/card/streaming");
|
||||
|
||||
let resp: StreamingWriteResponse = self
|
||||
.client
|
||||
.put(&url)
|
||||
.header("x-acs-dingtalk-access-token", &token)
|
||||
.json(request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("DingTalk streaming write failed: {e}")))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("DingTalk streaming write parse failed: {e}")))?;
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// Update (finalize) an AI Card instance with new data (e.g., buttons).
|
||||
pub async fn update_card(&self, request: &UpdateCardRequest) -> Result<UpdateCardResponse, ChannelError> {
|
||||
let token = self.get_token().await?;
|
||||
let url = format!("{DINGTALK_API_BASE}/v1.0/card/instances");
|
||||
|
||||
let resp: UpdateCardResponse = self
|
||||
.client
|
||||
.put(&url)
|
||||
.header("x-acs-dingtalk-access-token", &token)
|
||||
.json(request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("DingTalk update card failed: {e}")))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("DingTalk update card parse failed: {e}")))?;
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// Send a message via DingTalk Open API (fallback).
|
||||
pub async fn send_robot_message(
|
||||
&self,
|
||||
request: &SendRobotMessageRequest,
|
||||
) -> Result<SendRobotMessageResponse, ChannelError> {
|
||||
let token = self.get_token().await?;
|
||||
|
||||
let url = if request.open_conversation_id.is_some() {
|
||||
format!("{DINGTALK_API_BASE}/v1.0/robot/groupMessages/send")
|
||||
} else {
|
||||
format!("{DINGTALK_API_BASE}/v1.0/robot/oToMessages/batchSend")
|
||||
};
|
||||
|
||||
let resp: SendRobotMessageResponse = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("x-acs-dingtalk-access-token", &token)
|
||||
.json(request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("DingTalk robot message send failed: {e}")))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("DingTalk robot message parse failed: {e}")))?;
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// Expose client_id for card delivery.
|
||||
pub fn client_id(&self) -> &str {
|
||||
&self.client_id
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn api_stores_credentials() {
|
||||
let client = Client::new();
|
||||
let api = DingtalkApi::new(client, "key_123", "secret_456");
|
||||
assert_eq!(api.client_id, "key_123");
|
||||
assert_eq!(api.client_secret, "secret_456");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_cache_not_expired() {
|
||||
let cache = TokenCache {
|
||||
token: "test".into(),
|
||||
acquired_at: Instant::now(),
|
||||
expires_in: Duration::from_secs(7200),
|
||||
};
|
||||
assert!(!cache.is_expired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_cache_expired() {
|
||||
let cache = TokenCache {
|
||||
token: "test".into(),
|
||||
acquired_at: Instant::now() - Duration::from_secs(7200),
|
||||
expires_in: Duration::from_secs(7200),
|
||||
};
|
||||
assert!(cache.is_expired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_cache_near_expiry() {
|
||||
// Token acquired 6900s ago with 7200s TTL — within 5min margin
|
||||
let cache = TokenCache {
|
||||
token: "test".into(),
|
||||
acquired_at: Instant::now() - Duration::from_secs(6900),
|
||||
expires_in: Duration::from_secs(7200),
|
||||
};
|
||||
assert!(cache.is_expired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_id_accessor() {
|
||||
let client = Client::new();
|
||||
let api = DingtalkApi::new(client, "my_key", "my_secret");
|
||||
assert_eq!(api.client_id(), "my_key");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod api;
|
||||
mod plugin;
|
||||
mod types;
|
||||
|
||||
pub use plugin::DingtalkPlugin;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
||||
//! REST client for the Discord HTTP API (v10).
|
||||
|
||||
use reqwest::Client;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::error::ChannelError;
|
||||
|
||||
use super::types::{
|
||||
CreateMessageRequest, CreateMessageResponse, GetMeResponse, InteractionCallbackBody,
|
||||
};
|
||||
|
||||
const DISCORD_API_BASE: &str = "https://discord.com/api/v10";
|
||||
|
||||
/// HTTP client for the Discord REST API. Authenticates with a bot token via the
|
||||
/// `Authorization: Bot <token>` header.
|
||||
pub(crate) struct DiscordApi {
|
||||
client: Client,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl DiscordApi {
|
||||
pub fn new(client: Client, token: &str) -> Self {
|
||||
Self {
|
||||
client,
|
||||
token: token.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_header(&self) -> String {
|
||||
format!("Bot {}", self.token)
|
||||
}
|
||||
|
||||
/// `GET /users/@me` — validates the token and returns the bot identity.
|
||||
pub async fn get_me(&self) -> Result<GetMeResponse, ChannelError> {
|
||||
let url = format!("{DISCORD_API_BASE}/users/@me");
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.header("Authorization", self.auth_header())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("Discord get_me request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ChannelError::ConnectionFailed(format!(
|
||||
"Discord get_me failed: HTTP {status}: {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
resp.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("Discord get_me parse failed: {e}")))
|
||||
}
|
||||
|
||||
/// `POST /channels/{channel_id}/messages` — send a message; returns its id.
|
||||
pub async fn create_message(
|
||||
&self,
|
||||
channel_id: &str,
|
||||
req: &CreateMessageRequest,
|
||||
) -> Result<CreateMessageResponse, ChannelError> {
|
||||
let url = format!("{DISCORD_API_BASE}/channels/{channel_id}/messages");
|
||||
debug!(channel_id, "Sending Discord message");
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Authorization", self.auth_header())
|
||||
.json(req)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("Discord create_message request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ChannelError::MessageSendFailed(format!(
|
||||
"Discord create_message failed: HTTP {status}: {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
resp.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("Discord create_message parse failed: {e}")))
|
||||
}
|
||||
|
||||
/// `PATCH /channels/{channel_id}/messages/{message_id}` — edit a message.
|
||||
pub async fn edit_message(
|
||||
&self,
|
||||
channel_id: &str,
|
||||
message_id: &str,
|
||||
req: &CreateMessageRequest,
|
||||
) -> Result<(), ChannelError> {
|
||||
let url = format!("{DISCORD_API_BASE}/channels/{channel_id}/messages/{message_id}");
|
||||
debug!(channel_id, message_id, "Editing Discord message");
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.patch(&url)
|
||||
.header("Authorization", self.auth_header())
|
||||
.json(req)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("Discord edit_message request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ChannelError::MessageSendFailed(format!(
|
||||
"Discord edit_message failed: HTTP {status}: {body}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `POST /interactions/{id}/{token}/callback` — acknowledge a component
|
||||
/// (button) interaction. Uses the per-interaction token, not the bot token.
|
||||
pub async fn ack_interaction(
|
||||
&self,
|
||||
interaction_id: &str,
|
||||
interaction_token: &str,
|
||||
callback_type: u8,
|
||||
) -> Result<(), ChannelError> {
|
||||
let url = format!("{DISCORD_API_BASE}/interactions/{interaction_id}/{interaction_token}/callback");
|
||||
let body = InteractionCallbackBody { callback_type };
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("Discord ack_interaction request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
// Non-fatal: log via the error string; the caller ignores ack errors.
|
||||
return Err(ChannelError::PlatformApi(format!(
|
||||
"Discord ack_interaction failed: HTTP {status}: {body}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
//! Discord Gateway WebSocket connection loop (v10, JSON).
|
||||
//!
|
||||
//! Mirrors `lark`'s `connect_and_listen`: connect, receive HELLO, IDENTIFY,
|
||||
//! heartbeat on a deadline, dispatch events, and reconnect with exponential
|
||||
//! backoff. Inbound MESSAGE_CREATE / INTERACTION_CREATE events are normalized
|
||||
//! into [`UnifiedIncomingMessage`] (the pure helpers below are unit-tested).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::constants::{DISCORD_MAX_RECONNECT_ATTEMPTS, DISCORD_MAX_RECONNECT_DELAY};
|
||||
use crate::error::ChannelError;
|
||||
use crate::plugin::{SharedPluginStatus, mark_error_on_unexpected_exit};
|
||||
use crate::plugins::callback::parse_callback_data;
|
||||
use crate::types::{
|
||||
ActionContext, MessageContentType, PluginType, UnifiedAction, UnifiedAttachment, UnifiedIncomingMessage,
|
||||
UnifiedMessageContent, UnifiedUser,
|
||||
};
|
||||
|
||||
use super::api::DiscordApi;
|
||||
use super::types::{
|
||||
GatewayPayload, HelloData, IdentifyData, IdentifyProperties, InteractionCreate, MessageCreate, OutgoingFrame,
|
||||
GATEWAY_INTENTS, INTERACTION_CALLBACK_DEFERRED_UPDATE, INTERACTION_TYPE_MESSAGE_COMPONENT, OP_DISPATCH, OP_HEARTBEAT,
|
||||
OP_HELLO, OP_IDENTIFY, OP_INVALID_SESSION, OP_RECONNECT,
|
||||
};
|
||||
|
||||
const GATEWAY_URL: &str = "wss://gateway.discord.gg/?v=10&encoding=json";
|
||||
|
||||
/// Background task: maintain the Discord gateway connection with reconnects.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn run_gateway(
|
||||
api: Arc<DiscordApi>,
|
||||
token: String,
|
||||
self_bot_id: String,
|
||||
message_tx: mpsc::Sender<UnifiedIncomingMessage>,
|
||||
confirm_tx: mpsc::Sender<(String, String)>,
|
||||
status: SharedPluginStatus,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
) {
|
||||
let mut consecutive_errors: u32 = 0;
|
||||
|
||||
loop {
|
||||
if *shutdown_rx.borrow() {
|
||||
debug!("Discord gateway loop received shutdown signal");
|
||||
break;
|
||||
}
|
||||
|
||||
match connect_once(&api, &token, &self_bot_id, &message_tx, &confirm_tx, &mut shutdown_rx).await {
|
||||
Ok(()) => {
|
||||
// Clean close (shutdown / server RECONNECT). Reset backoff.
|
||||
consecutive_errors = 0;
|
||||
if *shutdown_rx.borrow() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
consecutive_errors += 1;
|
||||
warn!(error = %e, consecutive_errors, "Discord gateway error");
|
||||
if consecutive_errors >= DISCORD_MAX_RECONNECT_ATTEMPTS {
|
||||
error!("Discord max reconnect attempts reached, stopping gateway loop");
|
||||
break;
|
||||
}
|
||||
let backoff = backoff_delay(consecutive_errors);
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(backoff) => {}
|
||||
_ = shutdown_rx.changed() => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mark_error_on_unexpected_exit(&status, &shutdown_rx, "discord");
|
||||
debug!("Discord gateway loop exited");
|
||||
}
|
||||
|
||||
/// Exponential backoff capped at `DISCORD_MAX_RECONNECT_DELAY`.
|
||||
fn backoff_delay(attempt: u32) -> Duration {
|
||||
let secs = 2u64.saturating_pow(attempt).min(DISCORD_MAX_RECONNECT_DELAY.as_secs());
|
||||
Duration::from_secs(secs)
|
||||
}
|
||||
|
||||
/// A single gateway connection: HELLO → IDENTIFY → heartbeat + dispatch.
|
||||
async fn connect_once(
|
||||
api: &Arc<DiscordApi>,
|
||||
token: &str,
|
||||
self_bot_id: &str,
|
||||
message_tx: &mpsc::Sender<UnifiedIncomingMessage>,
|
||||
confirm_tx: &mpsc::Sender<(String, String)>,
|
||||
shutdown_rx: &mut watch::Receiver<bool>,
|
||||
) -> Result<(), ChannelError> {
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::connect_async_tls_with_config;
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
|
||||
let connector = build_ws_tls_connector()?;
|
||||
let (ws_stream, _) = connect_async_tls_with_config(GATEWAY_URL, None, false, Some(connector))
|
||||
.await
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("Discord gateway connect failed: {e}")))?;
|
||||
info!("Discord gateway connected");
|
||||
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
|
||||
// First frame must be HELLO (op 10) with the heartbeat interval.
|
||||
let hello = read
|
||||
.next()
|
||||
.await
|
||||
.ok_or_else(|| ChannelError::ConnectionFailed("Discord gateway closed before HELLO".into()))?
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("Discord gateway read error: {e}")))?;
|
||||
let heartbeat_interval = match hello {
|
||||
WsMessage::Text(txt) => {
|
||||
let payload: GatewayPayload = serde_json::from_str(&txt)
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("Discord HELLO parse failed: {e}")))?;
|
||||
if payload.op != OP_HELLO {
|
||||
return Err(ChannelError::ConnectionFailed(format!(
|
||||
"Discord expected HELLO, got op {}",
|
||||
payload.op
|
||||
)));
|
||||
}
|
||||
let hello: HelloData = serde_json::from_value(payload.d)
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("Discord HELLO data parse failed: {e}")))?;
|
||||
Duration::from_millis(hello.heartbeat_interval)
|
||||
}
|
||||
_ => return Err(ChannelError::ConnectionFailed("Discord first frame was not text HELLO".into())),
|
||||
};
|
||||
|
||||
// IDENTIFY.
|
||||
let identify = OutgoingFrame {
|
||||
op: OP_IDENTIFY,
|
||||
d: IdentifyData {
|
||||
token: token.to_string(),
|
||||
intents: GATEWAY_INTENTS,
|
||||
properties: IdentifyProperties {
|
||||
os: std::env::consts::OS.to_string(),
|
||||
browser: "nomi".to_string(),
|
||||
device: "nomi".to_string(),
|
||||
},
|
||||
},
|
||||
};
|
||||
let identify_json = serde_json::to_string(&identify)
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("Discord IDENTIFY encode failed: {e}")))?;
|
||||
write
|
||||
.send(WsMessage::Text(identify_json.into()))
|
||||
.await
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("Discord IDENTIFY send failed: {e}")))?;
|
||||
|
||||
let mut last_seq: Option<u64> = None;
|
||||
let mut heartbeat_deadline = tokio::time::Instant::now() + heartbeat_interval;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
frame = read.next() => {
|
||||
match frame {
|
||||
Some(Ok(WsMessage::Text(txt))) => {
|
||||
let payload: GatewayPayload = match serde_json::from_str(&txt) {
|
||||
Ok(p) => p,
|
||||
Err(e) => { warn!(error = %e, "Discord gateway frame parse failed"); continue; }
|
||||
};
|
||||
if let Some(s) = payload.s {
|
||||
last_seq = Some(s);
|
||||
}
|
||||
match payload.op {
|
||||
OP_DISPATCH => {
|
||||
let t = payload.t.as_deref().unwrap_or("");
|
||||
match t {
|
||||
"MESSAGE_CREATE" => {
|
||||
match serde_json::from_value::<MessageCreate>(payload.d) {
|
||||
Ok(msg) => {
|
||||
if let Some(unified) = normalize_message_create(&msg, self_bot_id) {
|
||||
let _ = message_tx.send(unified).await;
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(error = %e, "Discord MESSAGE_CREATE parse failed"),
|
||||
}
|
||||
}
|
||||
"INTERACTION_CREATE" => {
|
||||
match serde_json::from_value::<InteractionCreate>(payload.d) {
|
||||
Ok(interaction) => {
|
||||
handle_interaction(api, &interaction, message_tx, confirm_tx).await;
|
||||
}
|
||||
Err(e) => warn!(error = %e, "Discord INTERACTION_CREATE parse failed"),
|
||||
}
|
||||
}
|
||||
_ => { /* READY, TYPING_START, etc. — ignore */ }
|
||||
}
|
||||
}
|
||||
OP_HEARTBEAT => {
|
||||
// Server asked for an immediate heartbeat.
|
||||
send_heartbeat(&mut write, last_seq).await?;
|
||||
heartbeat_deadline = tokio::time::Instant::now() + heartbeat_interval;
|
||||
}
|
||||
OP_RECONNECT | OP_INVALID_SESSION => {
|
||||
debug!(op = payload.op, "Discord gateway requested reconnect");
|
||||
return Ok(());
|
||||
}
|
||||
_ => { /* HEARTBEAT_ACK and others — ignore */ }
|
||||
}
|
||||
}
|
||||
Some(Ok(WsMessage::Close(_))) => {
|
||||
debug!("Discord gateway received close frame");
|
||||
return Ok(());
|
||||
}
|
||||
Some(Ok(_)) => { /* binary/ping/pong — ignore */ }
|
||||
Some(Err(e)) => {
|
||||
return Err(ChannelError::ConnectionFailed(format!("Discord gateway read error: {e}")));
|
||||
}
|
||||
None => {
|
||||
return Err(ChannelError::ConnectionFailed("Discord gateway stream ended".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = tokio::time::sleep_until(heartbeat_deadline) => {
|
||||
send_heartbeat(&mut write, last_seq).await?;
|
||||
heartbeat_deadline = tokio::time::Instant::now() + heartbeat_interval;
|
||||
}
|
||||
_ = shutdown_rx.changed() => {
|
||||
debug!("Discord gateway shutdown during listen");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_heartbeat<S>(write: &mut S, last_seq: Option<u64>) -> Result<(), ChannelError>
|
||||
where
|
||||
S: futures_util::Sink<tokio_tungstenite::tungstenite::Message> + Unpin,
|
||||
S::Error: std::fmt::Display,
|
||||
{
|
||||
use futures_util::SinkExt;
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
let hb = serde_json::json!({ "op": OP_HEARTBEAT, "d": last_seq });
|
||||
write
|
||||
.send(WsMessage::Text(hb.to_string().into()))
|
||||
.await
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("Discord heartbeat send failed: {e}")))
|
||||
}
|
||||
|
||||
/// Acknowledge a component interaction and forward it as a unified action.
|
||||
async fn handle_interaction(
|
||||
api: &Arc<DiscordApi>,
|
||||
interaction: &InteractionCreate,
|
||||
message_tx: &mpsc::Sender<UnifiedIncomingMessage>,
|
||||
confirm_tx: &mpsc::Sender<(String, String)>,
|
||||
) {
|
||||
if interaction.interaction_type != INTERACTION_TYPE_MESSAGE_COMPONENT {
|
||||
return;
|
||||
}
|
||||
// Acknowledge within Discord's 3s window so the client stops spinning.
|
||||
let _ = api
|
||||
.ack_interaction(&interaction.id, &interaction.token, INTERACTION_CALLBACK_DEFERRED_UPDATE)
|
||||
.await;
|
||||
|
||||
let Some(unified) = normalize_interaction(interaction) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Tool-confirmation buttons (system.confirm) also feed confirm_tx.
|
||||
if let Some(action) = &unified.action
|
||||
&& action.action == "system.confirm"
|
||||
&& let Some(params) = &action.params
|
||||
{
|
||||
let call_id = params.get("callId").cloned().unwrap_or_default();
|
||||
let value = params.get("value").cloned().unwrap_or_default();
|
||||
if !call_id.is_empty() {
|
||||
let _ = confirm_tx.send((call_id, value)).await;
|
||||
}
|
||||
}
|
||||
|
||||
let _ = message_tx.send(unified).await;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure normalization helpers (unit-tested)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Normalize a MESSAGE_CREATE into a [`UnifiedIncomingMessage`], applying the
|
||||
/// bot-loop guard (skip bot authors) and guild mention gating (guild messages
|
||||
/// must @mention the bot; DMs always pass). Returns `None` when filtered out.
|
||||
pub(super) fn normalize_message_create(msg: &MessageCreate, self_bot_id: &str) -> Option<UnifiedIncomingMessage> {
|
||||
// Bot-loop guard: ignore all bot authors (includes ourselves).
|
||||
if msg.author.bot {
|
||||
return None;
|
||||
}
|
||||
// Guild mention gating: a guild message must mention the bot. DMs (no
|
||||
// guild_id) are always processed.
|
||||
if msg.guild_id.is_some() {
|
||||
let mentioned = msg.mentions.iter().any(|u| u.id == self_bot_id);
|
||||
if !mentioned {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let (content_type, attachments) = extract_attachments(msg);
|
||||
let user = UnifiedUser {
|
||||
id: msg.author.id.clone(),
|
||||
username: Some(msg.author.username.clone()),
|
||||
display_name: msg.author.display(),
|
||||
avatar_url: None,
|
||||
};
|
||||
|
||||
Some(UnifiedIncomingMessage {
|
||||
id: msg.id.clone(),
|
||||
platform: PluginType::Discord,
|
||||
chat_id: msg.channel_id.clone(),
|
||||
user,
|
||||
content: UnifiedMessageContent {
|
||||
content_type,
|
||||
text: msg.content.clone(),
|
||||
attachments,
|
||||
},
|
||||
timestamp: snowflake_to_unix(&msg.id),
|
||||
reply_to_message_id: msg.message_reference.as_ref().and_then(|r| r.message_id.clone()),
|
||||
action: None,
|
||||
raw: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Map Discord attachments to the unified content type + attachment list.
|
||||
fn extract_attachments(msg: &MessageCreate) -> (MessageContentType, Option<Vec<UnifiedAttachment>>) {
|
||||
if msg.attachments.is_empty() {
|
||||
return (MessageContentType::Text, None);
|
||||
}
|
||||
let is_image = msg
|
||||
.attachments
|
||||
.iter()
|
||||
.any(|a| a.content_type.as_deref().is_some_and(|c| c.starts_with("image/")));
|
||||
let atts: Vec<UnifiedAttachment> = msg
|
||||
.attachments
|
||||
.iter()
|
||||
.map(|a| UnifiedAttachment {
|
||||
file_id: None,
|
||||
file_name: Some(a.filename.clone()),
|
||||
mime_type: a.content_type.clone(),
|
||||
file_size: a.size,
|
||||
url: Some(a.url.clone()),
|
||||
})
|
||||
.collect();
|
||||
let content_type = if is_image {
|
||||
MessageContentType::Photo
|
||||
} else {
|
||||
MessageContentType::Document
|
||||
};
|
||||
(content_type, Some(atts))
|
||||
}
|
||||
|
||||
/// Normalize a component (button) interaction into a unified action message.
|
||||
pub(super) fn normalize_interaction(interaction: &InteractionCreate) -> Option<UnifiedIncomingMessage> {
|
||||
if interaction.interaction_type != INTERACTION_TYPE_MESSAGE_COMPONENT {
|
||||
return None;
|
||||
}
|
||||
let custom_id = interaction.data.as_ref().and_then(|d| d.custom_id.clone())?;
|
||||
let user = interaction.acting_user()?;
|
||||
let chat_id = interaction.channel_id.clone().unwrap_or_default();
|
||||
let message_id = interaction.message.as_ref().map(|m| m.id.clone());
|
||||
|
||||
let parsed = parse_callback_data(&custom_id);
|
||||
let action = parsed.map(|p| UnifiedAction {
|
||||
action: p.action,
|
||||
category: p.category,
|
||||
params: p.params,
|
||||
context: ActionContext {
|
||||
platform: PluginType::Discord,
|
||||
user_id: user.id.clone(),
|
||||
chat_id: chat_id.clone(),
|
||||
message_id: message_id.clone(),
|
||||
session_id: None,
|
||||
},
|
||||
});
|
||||
|
||||
Some(UnifiedIncomingMessage {
|
||||
id: interaction.id.clone(),
|
||||
platform: PluginType::Discord,
|
||||
chat_id,
|
||||
user: UnifiedUser {
|
||||
id: user.id.clone(),
|
||||
username: Some(user.username.clone()),
|
||||
display_name: user.display(),
|
||||
avatar_url: None,
|
||||
},
|
||||
content: UnifiedMessageContent {
|
||||
content_type: MessageContentType::Action,
|
||||
text: custom_id,
|
||||
attachments: None,
|
||||
},
|
||||
timestamp: snowflake_to_unix(&interaction.id),
|
||||
reply_to_message_id: message_id,
|
||||
action,
|
||||
raw: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Derive a unix-seconds timestamp from a Discord snowflake id.
|
||||
/// `timestamp_ms = (snowflake >> 22) + DISCORD_EPOCH_MS`.
|
||||
fn snowflake_to_unix(id: &str) -> i64 {
|
||||
const DISCORD_EPOCH_MS: u64 = 1_420_070_400_000;
|
||||
match id.parse::<u64>() {
|
||||
Ok(snowflake) => (((snowflake >> 22) + DISCORD_EPOCH_MS) / 1000) as i64,
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the rustls-based TLS connector for the gateway WebSocket (mirrors the
|
||||
/// lark plugin's helper).
|
||||
fn build_ws_tls_connector() -> Result<tokio_tungstenite::Connector, ChannelError> {
|
||||
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 config = rustls::ClientConfig::builder_with_provider(provider)
|
||||
.with_safe_default_protocol_versions()
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("Discord TLS config failed: {e}")))?
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
|
||||
Ok(Connector::Rustls(Arc::new(config)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::super::types::{DiscordAttachment, DiscordUser, InteractionData, InteractionMessage, MessageReference};
|
||||
|
||||
fn user(id: &str, bot: bool) -> DiscordUser {
|
||||
DiscordUser {
|
||||
id: id.into(),
|
||||
username: format!("user{id}"),
|
||||
global_name: None,
|
||||
bot,
|
||||
}
|
||||
}
|
||||
|
||||
fn dm_message(content: &str) -> MessageCreate {
|
||||
MessageCreate {
|
||||
id: "175928847299117063".into(), // a real-ish snowflake
|
||||
channel_id: "chan1".into(),
|
||||
guild_id: None,
|
||||
author: user("42", false),
|
||||
content: content.into(),
|
||||
attachments: vec![],
|
||||
mentions: vec![],
|
||||
message_reference: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dm_message_is_normalized() {
|
||||
let msg = dm_message("hello");
|
||||
let unified = normalize_message_create(&msg, "selfbot").expect("DM should pass");
|
||||
assert_eq!(unified.platform, PluginType::Discord);
|
||||
assert_eq!(unified.chat_id, "chan1");
|
||||
assert_eq!(unified.content.text, "hello");
|
||||
assert_eq!(unified.content.content_type, MessageContentType::Text);
|
||||
assert_eq!(unified.user.id, "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bot_author_is_skipped() {
|
||||
let mut msg = dm_message("loop");
|
||||
msg.author = user("99", true);
|
||||
assert!(normalize_message_create(&msg, "selfbot").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guild_message_requires_mention() {
|
||||
let mut msg = dm_message("no mention");
|
||||
msg.guild_id = Some("guild1".into());
|
||||
// Not mentioned -> filtered.
|
||||
assert!(normalize_message_create(&msg, "selfbot").is_none());
|
||||
// Mentioned -> passes.
|
||||
msg.mentions = vec![user("selfbot", false)];
|
||||
assert!(normalize_message_create(&msg, "selfbot").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_reference_is_carried() {
|
||||
let mut msg = dm_message("re");
|
||||
msg.message_reference = Some(MessageReference {
|
||||
message_id: Some("origmsg".into()),
|
||||
});
|
||||
let unified = normalize_message_create(&msg, "selfbot").unwrap();
|
||||
assert_eq!(unified.reply_to_message_id.as_deref(), Some("origmsg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_attachment_sets_photo() {
|
||||
let mut msg = dm_message("pic");
|
||||
msg.attachments = vec![DiscordAttachment {
|
||||
filename: "a.png".into(),
|
||||
content_type: Some("image/png".into()),
|
||||
size: Some(123),
|
||||
url: "https://cdn/a.png".into(),
|
||||
}];
|
||||
let unified = normalize_message_create(&msg, "selfbot").unwrap();
|
||||
assert_eq!(unified.content.content_type, MessageContentType::Photo);
|
||||
let atts = unified.content.attachments.unwrap();
|
||||
assert_eq!(atts[0].url.as_deref(), Some("https://cdn/a.png"));
|
||||
assert_eq!(atts[0].file_name.as_deref(), Some("a.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_image_attachment_sets_document() {
|
||||
let mut msg = dm_message("file");
|
||||
msg.attachments = vec![DiscordAttachment {
|
||||
filename: "a.pdf".into(),
|
||||
content_type: Some("application/pdf".into()),
|
||||
size: Some(10),
|
||||
url: "https://cdn/a.pdf".into(),
|
||||
}];
|
||||
let unified = normalize_message_create(&msg, "selfbot").unwrap();
|
||||
assert_eq!(unified.content.content_type, MessageContentType::Document);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interaction_button_becomes_action() {
|
||||
let interaction = InteractionCreate {
|
||||
id: "175928847299117063".into(),
|
||||
token: "tok".into(),
|
||||
interaction_type: INTERACTION_TYPE_MESSAGE_COMPONENT,
|
||||
data: Some(InteractionData {
|
||||
custom_id: Some("chat:chat.regenerate".into()),
|
||||
}),
|
||||
channel_id: Some("chan1".into()),
|
||||
member: None,
|
||||
user: Some(user("7", false)),
|
||||
message: Some(InteractionMessage { id: "msg9".into() }),
|
||||
};
|
||||
let unified = normalize_interaction(&interaction).expect("button -> action");
|
||||
assert_eq!(unified.content.content_type, MessageContentType::Action);
|
||||
let action = unified.action.unwrap();
|
||||
assert_eq!(action.action, "chat.regenerate");
|
||||
assert_eq!(action.context.chat_id, "chan1");
|
||||
assert_eq!(unified.reply_to_message_id.as_deref(), Some("msg9"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_component_interaction_ignored() {
|
||||
let interaction = InteractionCreate {
|
||||
id: "1".into(),
|
||||
token: "tok".into(),
|
||||
interaction_type: 2, // APPLICATION_COMMAND
|
||||
data: None,
|
||||
channel_id: Some("c".into()),
|
||||
member: None,
|
||||
user: Some(user("7", false)),
|
||||
message: None,
|
||||
};
|
||||
assert!(normalize_interaction(&interaction).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backoff_is_exponential_and_capped() {
|
||||
assert_eq!(backoff_delay(1), Duration::from_secs(2));
|
||||
assert_eq!(backoff_delay(3), Duration::from_secs(8));
|
||||
assert_eq!(backoff_delay(10), Duration::from_secs(30)); // capped
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snowflake_decodes_to_unix() {
|
||||
// Snowflake 175928847299117063 → 2016-04-30 ~11:18:25 UTC ≈ 1462015105
|
||||
let ts = snowflake_to_unix("175928847299117063");
|
||||
assert!(ts > 1_400_000_000 && ts < 1_500_000_000, "got {ts}");
|
||||
assert_eq!(snowflake_to_unix("not-a-number"), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//! Discord channel plugin: Gateway WebSocket (inbound) + REST API (outbound).
|
||||
//!
|
||||
//! Mirrors the structure of the other channels — `plugin.rs` implements the
|
||||
//! [`crate::plugin::ChannelPlugin`] trait, `gateway.rs` runs the long-lived
|
||||
//! Gateway connection loop (HELLO/IDENTIFY/HEARTBEAT/dispatch with backoff
|
||||
//! reconnect, like `lark`), and `api.rs` is the REST client (like `telegram`).
|
||||
|
||||
mod api;
|
||||
mod gateway;
|
||||
mod plugin;
|
||||
pub mod types;
|
||||
|
||||
pub use plugin::DiscordPlugin;
|
||||
@@ -0,0 +1,408 @@
|
||||
//! Discord channel plugin: `ChannelPlugin` impl + outbound message building.
|
||||
//!
|
||||
//! Inbound runs through the gateway loop in `gateway.rs`; this file owns the
|
||||
//! plugin lifecycle (mirrors `telegram`) and the REST outbound path.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::Client;
|
||||
use tokio::sync::watch;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::info;
|
||||
|
||||
use crate::constants::DISCORD_MESSAGE_LIMIT;
|
||||
use crate::error::ChannelError;
|
||||
use crate::plugin::{ChannelPlugin, PluginCallbacks, SharedPluginStatus};
|
||||
use crate::plugins::callback::format_callback_data;
|
||||
use crate::types::{BotInfo, PluginConfig, PluginStatus, PluginType, UnifiedOutgoingMessage};
|
||||
|
||||
use super::api::DiscordApi;
|
||||
use super::gateway::run_gateway;
|
||||
use super::types::{
|
||||
ActionRow, ButtonComponent, CreateMessageRequest, RestMessageReference, BUTTON_STYLE_SECONDARY,
|
||||
COMPONENT_ACTION_ROW, COMPONENT_BUTTON,
|
||||
};
|
||||
|
||||
/// Discord component custom_id hard limit.
|
||||
const CUSTOM_ID_LIMIT: usize = 100;
|
||||
|
||||
/// Discord bot plugin: Gateway WS for inbound, REST for outbound.
|
||||
pub struct DiscordPlugin {
|
||||
status: SharedPluginStatus,
|
||||
bot_info: Option<BotInfo>,
|
||||
last_error: Option<String>,
|
||||
api: Option<Arc<DiscordApi>>,
|
||||
token: Option<String>,
|
||||
callbacks: Option<PluginCallbacks>,
|
||||
gateway_handle: Option<JoinHandle<()>>,
|
||||
shutdown_tx: Option<watch::Sender<bool>>,
|
||||
}
|
||||
|
||||
impl Default for DiscordPlugin {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
status: SharedPluginStatus::default(),
|
||||
bot_info: None,
|
||||
last_error: None,
|
||||
api: None,
|
||||
token: None,
|
||||
callbacks: None,
|
||||
gateway_handle: None,
|
||||
shutdown_tx: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DiscordPlugin {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ChannelPlugin for DiscordPlugin {
|
||||
async fn initialize(&mut self, config: PluginConfig, callbacks: PluginCallbacks) -> Result<(), ChannelError> {
|
||||
self.status.set(PluginStatus::Initializing);
|
||||
|
||||
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 Discord bot token".into());
|
||||
ChannelError::InvalidConfig("Missing Discord bot token".into())
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
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(DiscordApi::new(client, &token));
|
||||
|
||||
// Validate the token and learn our own bot id (needed for the inbound
|
||||
// self-loop guard and guild mention gating).
|
||||
let me = api.get_me().await.map_err(|e| {
|
||||
self.status.set(PluginStatus::Error);
|
||||
self.last_error = Some(format!("Token validation failed: {e}"));
|
||||
e
|
||||
})?;
|
||||
|
||||
let display_name = me.global_name.clone().filter(|s| !s.is_empty()).unwrap_or_else(|| me.username.clone());
|
||||
self.bot_info = Some(BotInfo {
|
||||
id: me.id.clone(),
|
||||
username: Some(me.username.clone()),
|
||||
display_name,
|
||||
});
|
||||
info!(bot_id = %me.id, bot_username = %me.username, "Discord bot initialized");
|
||||
|
||||
self.api = Some(api);
|
||||
self.token = Some(token);
|
||||
self.callbacks = Some(callbacks);
|
||||
self.status.set(PluginStatus::Ready);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start(&mut self) -> Result<(), ChannelError> {
|
||||
self.status.set(PluginStatus::Starting);
|
||||
|
||||
if self.gateway_handle.is_some() {
|
||||
self.status.set(PluginStatus::Running);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let api = self
|
||||
.api
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.ok_or_else(|| ChannelError::PlatformApi("Discord plugin not initialized".into()))?;
|
||||
let token = self
|
||||
.token
|
||||
.clone()
|
||||
.ok_or_else(|| ChannelError::PlatformApi("Discord token not initialized".into()))?;
|
||||
let callbacks = self
|
||||
.callbacks
|
||||
.clone()
|
||||
.ok_or_else(|| ChannelError::PlatformApi("Discord callbacks not initialized".into()))?;
|
||||
let self_bot_id = self.bot_info.as_ref().map(|b| b.id.clone()).unwrap_or_default();
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
self.shutdown_tx = Some(shutdown_tx);
|
||||
self.gateway_handle = Some(tokio::spawn(run_gateway(
|
||||
api,
|
||||
token,
|
||||
self_bot_id,
|
||||
callbacks.message_tx,
|
||||
callbacks.confirm_tx,
|
||||
self.status.clone(),
|
||||
shutdown_rx,
|
||||
)));
|
||||
|
||||
self.status.set(PluginStatus::Running);
|
||||
info!("Discord 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.gateway_handle.take() {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;
|
||||
}
|
||||
|
||||
self.api = None;
|
||||
self.callbacks = None;
|
||||
self.status.set(PluginStatus::Stopped);
|
||||
info!("Discord 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 req = build_create_message_request(&message);
|
||||
let sent = api.create_message(chat_id, &req).await?;
|
||||
Ok(sent.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()))?;
|
||||
// Edits never carry a reply reference.
|
||||
let mut req = build_create_message_request(&message);
|
||||
req.message_reference = None;
|
||||
api.edit_message(chat_id, message_id, &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::Discord
|
||||
}
|
||||
|
||||
fn status(&self) -> PluginStatus {
|
||||
self.status.get()
|
||||
}
|
||||
|
||||
fn last_error(&self) -> Option<&str> {
|
||||
self.last_error.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Outbound building (pure, unit-tested)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build the Discord REST create/edit body from a unified outgoing message:
|
||||
/// truncated content, optional reply reference, and button components.
|
||||
fn build_create_message_request(message: &UnifiedOutgoingMessage) -> CreateMessageRequest {
|
||||
let text = message.text.as_deref().unwrap_or("");
|
||||
let content = if text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(truncate_message(text, DISCORD_MESSAGE_LIMIT))
|
||||
};
|
||||
let message_reference = message.reply_to_message_id.as_ref().map(|id| RestMessageReference {
|
||||
message_id: id.clone(),
|
||||
fail_if_not_exists: false,
|
||||
});
|
||||
let components = build_components(message);
|
||||
CreateMessageRequest {
|
||||
content,
|
||||
message_reference,
|
||||
components,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map unified buttons (rows × cols) to Discord action-row components.
|
||||
/// Discord allows at most 5 action rows and 5 buttons per row.
|
||||
fn build_components(message: &UnifiedOutgoingMessage) -> Option<Vec<ActionRow>> {
|
||||
let buttons = message.buttons.as_ref()?;
|
||||
let rows: Vec<ActionRow> = buttons
|
||||
.iter()
|
||||
.take(5)
|
||||
.filter_map(|row| {
|
||||
let comps: Vec<ButtonComponent> = row
|
||||
.iter()
|
||||
.take(5)
|
||||
.map(|btn| ButtonComponent {
|
||||
component_type: COMPONENT_BUTTON,
|
||||
style: BUTTON_STYLE_SECONDARY,
|
||||
label: btn.label.clone(),
|
||||
custom_id: truncate_custom_id(&format_callback_data(btn)),
|
||||
})
|
||||
.collect();
|
||||
if comps.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(ActionRow {
|
||||
component_type: COMPONENT_ACTION_ROW,
|
||||
components: comps,
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if rows.is_empty() { None } else { Some(rows) }
|
||||
}
|
||||
|
||||
/// Truncate text at a char boundary to `limit`, appending "..." if cut.
|
||||
fn truncate_message(text: &str, limit: usize) -> String {
|
||||
if text.chars().count() <= limit {
|
||||
return text.to_string();
|
||||
}
|
||||
let truncated: String = text.chars().take(limit.saturating_sub(3)).collect();
|
||||
format!("{truncated}...")
|
||||
}
|
||||
|
||||
/// Discord custom_id is capped at 100 chars; truncate defensively.
|
||||
fn truncate_custom_id(id: &str) -> String {
|
||||
if id.len() <= CUSTOM_ID_LIMIT {
|
||||
id.to_string()
|
||||
} else {
|
||||
id.chars().take(CUSTOM_ID_LIMIT).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::{ActionButton, OutgoingMessageType};
|
||||
|
||||
fn msg(text: &str) -> UnifiedOutgoingMessage {
|
||||
UnifiedOutgoingMessage {
|
||||
message_type: OutgoingMessageType::Text,
|
||||
text: Some(text.into()),
|
||||
parse_mode: None,
|
||||
buttons: None,
|
||||
keyboard: None,
|
||||
image_url: None,
|
||||
file_url: None,
|
||||
file_name: None,
|
||||
media_actions: None,
|
||||
reply_to_message_id: None,
|
||||
silent: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_plugin_initial_state() {
|
||||
let p = DiscordPlugin::new();
|
||||
assert_eq!(p.status(), PluginStatus::Created);
|
||||
assert!(p.bot_info().is_none());
|
||||
assert_eq!(p.plugin_type(), PluginType::Discord);
|
||||
assert_eq!(p.active_user_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_request_plain_text() {
|
||||
let req = build_create_message_request(&msg("hello"));
|
||||
assert_eq!(req.content.as_deref(), Some("hello"));
|
||||
assert!(req.message_reference.is_none());
|
||||
assert!(req.components.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_request_empty_text_is_none() {
|
||||
let req = build_create_message_request(&msg(""));
|
||||
assert!(req.content.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_request_with_reply() {
|
||||
let mut m = msg("re");
|
||||
m.reply_to_message_id = Some("orig123".into());
|
||||
let req = build_create_message_request(&m);
|
||||
let r = req.message_reference.unwrap();
|
||||
assert_eq!(r.message_id, "orig123");
|
||||
assert!(!r.fail_if_not_exists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_request_with_buttons() {
|
||||
let mut m = msg("choose");
|
||||
m.buttons = Some(vec![vec![
|
||||
ActionButton {
|
||||
label: "Continue".into(),
|
||||
action: "chat.continue".into(),
|
||||
params: None,
|
||||
},
|
||||
ActionButton {
|
||||
label: "Regenerate".into(),
|
||||
action: "chat.regenerate".into(),
|
||||
params: None,
|
||||
},
|
||||
]]);
|
||||
let req = build_create_message_request(&m);
|
||||
let comps = req.components.unwrap();
|
||||
assert_eq!(comps.len(), 1);
|
||||
assert_eq!(comps[0].component_type, COMPONENT_ACTION_ROW);
|
||||
assert_eq!(comps[0].components.len(), 2);
|
||||
assert_eq!(comps[0].components[0].component_type, COMPONENT_BUTTON);
|
||||
assert_eq!(comps[0].components[0].label, "Continue");
|
||||
assert_eq!(comps[0].components[0].custom_id, "chat:chat.continue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn components_capped_at_five_rows_and_buttons() {
|
||||
let mut m = msg("many");
|
||||
let big_row: Vec<ActionButton> = (0..8)
|
||||
.map(|i| ActionButton {
|
||||
label: format!("b{i}"),
|
||||
action: format!("chat.a{i}"),
|
||||
params: None,
|
||||
})
|
||||
.collect();
|
||||
m.buttons = Some(vec![big_row; 8]);
|
||||
let comps = build_create_message_request(&m).components.unwrap();
|
||||
assert_eq!(comps.len(), 5); // max 5 rows
|
||||
assert_eq!(comps[0].components.len(), 5); // max 5 buttons/row
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_respects_limit() {
|
||||
assert_eq!(truncate_message("short", 2000), "short");
|
||||
let long = "a".repeat(2100);
|
||||
let out = truncate_message(&long, 2000);
|
||||
assert_eq!(out.chars().count(), 2000);
|
||||
assert!(out.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_unicode_boundary() {
|
||||
let text = "你好世界测试";
|
||||
assert_eq!(truncate_message(text, 4), "你...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_id_is_capped() {
|
||||
let long = "x".repeat(200);
|
||||
assert_eq!(truncate_custom_id(&long).len(), CUSTOM_ID_LIMIT);
|
||||
assert_eq!(truncate_custom_id("chat:chat.send"), "chat:chat.send");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Discord Gateway + REST wire types (v10, JSON encoding).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gateway opcodes & intents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub const OP_DISPATCH: u8 = 0;
|
||||
pub const OP_HEARTBEAT: u8 = 1;
|
||||
pub const OP_IDENTIFY: u8 = 2;
|
||||
pub const OP_RECONNECT: u8 = 7;
|
||||
pub const OP_INVALID_SESSION: u8 = 9;
|
||||
pub const OP_HELLO: u8 = 10;
|
||||
pub const OP_HEARTBEAT_ACK: u8 = 11;
|
||||
|
||||
// Gateway intents (bitfield). We need guild + DM message events plus the
|
||||
// privileged MESSAGE_CONTENT intent (must be enabled in the Dev Portal) to
|
||||
// actually receive message text.
|
||||
pub const INTENT_GUILDS: u64 = 1 << 0;
|
||||
pub const INTENT_GUILD_MESSAGES: u64 = 1 << 9;
|
||||
pub const INTENT_DIRECT_MESSAGES: u64 = 1 << 12;
|
||||
pub const INTENT_MESSAGE_CONTENT: u64 = 1 << 15;
|
||||
|
||||
/// Combined intents the bot identifies with.
|
||||
pub const GATEWAY_INTENTS: u64 = INTENT_GUILDS | INTENT_GUILD_MESSAGES | INTENT_DIRECT_MESSAGES | INTENT_MESSAGE_CONTENT;
|
||||
|
||||
/// Interaction type for a message component (button) click.
|
||||
pub const INTERACTION_TYPE_MESSAGE_COMPONENT: u8 = 3;
|
||||
|
||||
/// Interaction callback type: acknowledge a component interaction without
|
||||
/// changing the message (DEFERRED_UPDATE_MESSAGE).
|
||||
pub const INTERACTION_CALLBACK_DEFERRED_UPDATE: u8 = 6;
|
||||
|
||||
/// Component types.
|
||||
pub const COMPONENT_ACTION_ROW: u8 = 1;
|
||||
pub const COMPONENT_BUTTON: u8 = 2;
|
||||
|
||||
/// Button style: secondary (grey).
|
||||
pub const BUTTON_STYLE_SECONDARY: u8 = 2;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inbound gateway envelope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Top-level gateway frame. `d` is opcode-specific; `s`/`t` only on dispatch.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GatewayPayload {
|
||||
pub op: u8,
|
||||
#[serde(default)]
|
||||
pub d: serde_json::Value,
|
||||
#[serde(default)]
|
||||
pub s: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub t: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct HelloData {
|
||||
pub heartbeat_interval: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReadyData {
|
||||
pub user: DiscordUser,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DiscordUser {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
#[serde(default)]
|
||||
pub global_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub bot: bool,
|
||||
}
|
||||
|
||||
impl DiscordUser {
|
||||
/// Best human-readable name: global display name if set, else username.
|
||||
pub fn display(&self) -> String {
|
||||
self.global_name.clone().filter(|s| !s.is_empty()).unwrap_or_else(|| self.username.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// MESSAGE_CREATE dispatch payload (subset we use).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MessageCreate {
|
||||
pub id: String,
|
||||
pub channel_id: String,
|
||||
#[serde(default)]
|
||||
pub guild_id: Option<String>,
|
||||
pub author: DiscordUser,
|
||||
#[serde(default)]
|
||||
pub content: String,
|
||||
#[serde(default)]
|
||||
pub attachments: Vec<DiscordAttachment>,
|
||||
#[serde(default)]
|
||||
pub mentions: Vec<DiscordUser>,
|
||||
#[serde(default)]
|
||||
pub message_reference: Option<MessageReference>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MessageReference {
|
||||
#[serde(default)]
|
||||
pub message_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DiscordAttachment {
|
||||
#[serde(default)]
|
||||
pub filename: String,
|
||||
#[serde(default)]
|
||||
pub content_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub size: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// INTERACTION_CREATE dispatch payload (subset for component/button clicks).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct InteractionCreate {
|
||||
pub id: String,
|
||||
pub token: String,
|
||||
#[serde(rename = "type")]
|
||||
pub interaction_type: u8,
|
||||
#[serde(default)]
|
||||
pub data: Option<InteractionData>,
|
||||
#[serde(default)]
|
||||
pub channel_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub member: Option<InteractionMember>,
|
||||
#[serde(default)]
|
||||
pub user: Option<DiscordUser>,
|
||||
#[serde(default)]
|
||||
pub message: Option<InteractionMessage>,
|
||||
}
|
||||
|
||||
impl InteractionCreate {
|
||||
/// Resolve the acting user from either `member.user` (guild) or `user` (DM).
|
||||
pub fn acting_user(&self) -> Option<&DiscordUser> {
|
||||
self.member.as_ref().map(|m| &m.user).or(self.user.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct InteractionData {
|
||||
#[serde(default)]
|
||||
pub custom_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct InteractionMember {
|
||||
pub user: DiscordUser,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct InteractionMessage {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Outbound gateway frames
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct OutgoingFrame<T: Serialize> {
|
||||
pub op: u8,
|
||||
pub d: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentifyData {
|
||||
pub token: String,
|
||||
pub intents: u64,
|
||||
pub properties: IdentifyProperties,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentifyProperties {
|
||||
pub os: String,
|
||||
pub browser: String,
|
||||
pub device: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// REST request/response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
pub struct CreateMessageRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message_reference: Option<RestMessageReference>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub components: Option<Vec<ActionRow>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RestMessageReference {
|
||||
pub message_id: String,
|
||||
pub fail_if_not_exists: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ActionRow {
|
||||
#[serde(rename = "type")]
|
||||
pub component_type: u8,
|
||||
pub components: Vec<ButtonComponent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ButtonComponent {
|
||||
#[serde(rename = "type")]
|
||||
pub component_type: u8,
|
||||
pub style: u8,
|
||||
pub label: String,
|
||||
pub custom_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateMessageResponse {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GetMeResponse {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
#[serde(default)]
|
||||
pub global_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct InteractionCallbackBody {
|
||||
#[serde(rename = "type")]
|
||||
pub callback_type: u8,
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use reqwest::Client;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::error::ChannelError;
|
||||
|
||||
use super::types::{
|
||||
BotInfoData, BotInfoResponse, GenericResponse, SendCardRequest, SendMessageData, SendMessageResponse,
|
||||
TenantAccessTokenRequest, TenantAccessTokenResponse, UpdateCardRequest, WsEndpointData, WsEndpointRequest,
|
||||
WsEndpointResponse,
|
||||
};
|
||||
|
||||
const LARK_OPEN_API_BASE: &str = "https://open.feishu.cn/open-apis";
|
||||
const LARK_BASE: &str = "https://open.feishu.cn";
|
||||
|
||||
/// Token refresh margin — refresh 5 minutes before expiry.
|
||||
const TOKEN_REFRESH_MARGIN: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
/// Cached tenant access token with expiry tracking.
|
||||
struct TokenCache {
|
||||
token: String,
|
||||
acquired_at: Instant,
|
||||
expires_in: Duration,
|
||||
}
|
||||
|
||||
impl TokenCache {
|
||||
fn is_expired(&self) -> bool {
|
||||
self.acquired_at.elapsed() + TOKEN_REFRESH_MARGIN >= self.expires_in
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP client for the Lark Open Platform API.
|
||||
///
|
||||
/// Manages tenant access token lifecycle (auto-refresh) and provides
|
||||
/// typed methods for bot info, WebSocket endpoint, send/edit messages.
|
||||
pub(crate) struct LarkApi {
|
||||
client: Client,
|
||||
app_id: String,
|
||||
app_secret: String,
|
||||
token_cache: Arc<RwLock<Option<TokenCache>>>,
|
||||
}
|
||||
|
||||
impl LarkApi {
|
||||
/// Create a new Lark API client.
|
||||
pub fn new(client: Client, app_id: &str, app_secret: &str) -> Self {
|
||||
Self {
|
||||
client,
|
||||
app_id: app_id.to_string(),
|
||||
app_secret: app_secret.to_string(),
|
||||
token_cache: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a valid tenant access token, refreshing if needed.
|
||||
async fn get_token(&self) -> Result<String, ChannelError> {
|
||||
// Fast path: check if cached token is still valid
|
||||
{
|
||||
let cache = self.token_cache.read().await;
|
||||
if let Some(ref tc) = *cache
|
||||
&& !tc.is_expired()
|
||||
{
|
||||
return Ok(tc.token.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Slow path: refresh the token
|
||||
self.refresh_token().await
|
||||
}
|
||||
|
||||
/// Request a new tenant access token from Lark.
|
||||
async fn refresh_token(&self) -> Result<String, ChannelError> {
|
||||
let url = format!("{LARK_OPEN_API_BASE}/auth/v3/tenant_access_token/internal");
|
||||
let body = TenantAccessTokenRequest {
|
||||
app_id: self.app_id.clone(),
|
||||
app_secret: self.app_secret.clone(),
|
||||
};
|
||||
|
||||
let resp: TenantAccessTokenResponse = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("Lark token request failed: {e}")))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("Lark token parse failed: {e}")))?;
|
||||
|
||||
if resp.code != 0 {
|
||||
return Err(ChannelError::ConnectionFailed(format!(
|
||||
"Lark token error (code={}): {}",
|
||||
resp.code, resp.msg
|
||||
)));
|
||||
}
|
||||
|
||||
let token = resp
|
||||
.tenant_access_token
|
||||
.ok_or_else(|| ChannelError::ConnectionFailed("Lark token response missing token".into()))?;
|
||||
|
||||
let expires_in = Duration::from_secs(resp.expire.unwrap_or(7200) as u64);
|
||||
|
||||
debug!(expires_in_secs = expires_in.as_secs(), "Lark token refreshed");
|
||||
|
||||
let mut cache = self.token_cache.write().await;
|
||||
*cache = Some(TokenCache {
|
||||
token: token.clone(),
|
||||
acquired_at: Instant::now(),
|
||||
expires_in,
|
||||
});
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Get bot identity information.
|
||||
pub async fn get_bot_info(&self) -> Result<BotInfoData, ChannelError> {
|
||||
let token = self.get_token().await?;
|
||||
let url = format!("{LARK_OPEN_API_BASE}/bot/v3/info");
|
||||
|
||||
let resp: BotInfoResponse = self
|
||||
.client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("Lark bot info request failed: {e}")))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("Lark bot info parse failed: {e}")))?;
|
||||
|
||||
if resp.code != 0 {
|
||||
return Err(ChannelError::ConnectionFailed(format!(
|
||||
"Lark bot info error (code={}): {}",
|
||||
resp.code, resp.msg
|
||||
)));
|
||||
}
|
||||
|
||||
resp.bot
|
||||
.ok_or_else(|| ChannelError::PlatformApi("Lark bot info returned no data".into()))
|
||||
}
|
||||
|
||||
/// Get the WebSocket endpoint URL for long connection.
|
||||
///
|
||||
/// Note: This endpoint uses AppID/AppSecret in the request body for auth,
|
||||
/// NOT the Bearer token used by other Lark APIs.
|
||||
pub async fn get_ws_endpoint(&self) -> Result<WsEndpointData, ChannelError> {
|
||||
let url = format!("{LARK_BASE}/callback/ws/endpoint");
|
||||
|
||||
let body = WsEndpointRequest {
|
||||
app_id: self.app_id.clone(),
|
||||
app_secret: self.app_secret.clone(),
|
||||
};
|
||||
|
||||
let raw_resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("locale", "zh")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("Lark WS endpoint request failed: {e}")))?;
|
||||
|
||||
let status = raw_resp.status();
|
||||
let body_text = raw_resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("Lark WS endpoint read body failed: {e}")))?;
|
||||
|
||||
debug!(status = %status, body_len = body_text.len(), "Lark WS endpoint response received");
|
||||
|
||||
let resp: WsEndpointResponse = serde_json::from_str(&body_text)
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("Lark WS endpoint parse failed: {e}")))?;
|
||||
|
||||
if resp.code != 0 {
|
||||
return Err(ChannelError::ConnectionFailed(format!(
|
||||
"Lark WS endpoint error (code={}): {}",
|
||||
resp.code, resp.msg
|
||||
)));
|
||||
}
|
||||
|
||||
resp.data
|
||||
.ok_or_else(|| ChannelError::ConnectionFailed("Lark WS endpoint returned no URL".into()))
|
||||
}
|
||||
|
||||
/// Send an interactive card message to a chat.
|
||||
///
|
||||
/// Uses `receive_id_type=chat_id` to address by chat ID.
|
||||
/// Returns the message ID of the sent card.
|
||||
pub async fn send_card(&self, chat_id: &str, card_content: &str) -> Result<SendMessageData, ChannelError> {
|
||||
let token = self.get_token().await?;
|
||||
let url = format!("{LARK_OPEN_API_BASE}/im/v1/messages?receive_id_type=chat_id");
|
||||
|
||||
debug!(chat_id, "Sending Lark card message");
|
||||
|
||||
let body = SendCardRequest {
|
||||
receive_id: chat_id.to_string(),
|
||||
msg_type: "interactive".into(),
|
||||
content: card_content.to_string(),
|
||||
};
|
||||
|
||||
let resp: SendMessageResponse = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("Lark send card request failed: {e}")))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("Lark send card parse failed: {e}")))?;
|
||||
|
||||
if resp.code != 0 {
|
||||
return Err(ChannelError::MessageSendFailed(format!(
|
||||
"Lark send card error (code={}): {}",
|
||||
resp.code, resp.msg
|
||||
)));
|
||||
}
|
||||
|
||||
resp.data
|
||||
.ok_or_else(|| ChannelError::MessageSendFailed("Lark send card returned no data".into()))
|
||||
}
|
||||
|
||||
/// Update (patch) an existing interactive card message.
|
||||
pub async fn update_card(&self, message_id: &str, card_content: &str) -> Result<(), ChannelError> {
|
||||
let token = self.get_token().await?;
|
||||
let url = format!("{LARK_OPEN_API_BASE}/im/v1/messages/{message_id}");
|
||||
|
||||
debug!(message_id, "Updating Lark card message");
|
||||
|
||||
let body = UpdateCardRequest {
|
||||
content: card_content.to_string(),
|
||||
};
|
||||
|
||||
let resp: GenericResponse = self
|
||||
.client
|
||||
.patch(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("Lark update card request failed: {e}")))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("Lark update card parse failed: {e}")))?;
|
||||
|
||||
if resp.code != 0 {
|
||||
warn!(code = resp.code, msg = resp.msg, "Lark update card error");
|
||||
return Err(ChannelError::MessageSendFailed(format!(
|
||||
"Lark update card error (code={}): {}",
|
||||
resp.code, resp.msg
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn api_stores_credentials() {
|
||||
let client = Client::new();
|
||||
let api = LarkApi::new(client, "cli_123", "secret_456");
|
||||
assert_eq!(api.app_id, "cli_123");
|
||||
assert_eq!(api.app_secret, "secret_456");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_cache_not_expired() {
|
||||
let cache = TokenCache {
|
||||
token: "test".into(),
|
||||
acquired_at: Instant::now(),
|
||||
expires_in: Duration::from_secs(7200),
|
||||
};
|
||||
assert!(!cache.is_expired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_cache_expired() {
|
||||
let cache = TokenCache {
|
||||
token: "test".into(),
|
||||
acquired_at: Instant::now() - Duration::from_secs(7200),
|
||||
expires_in: Duration::from_secs(7200),
|
||||
};
|
||||
assert!(cache.is_expired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_cache_near_expiry() {
|
||||
// Token acquired 6900s ago with 7200s TTL — within 5min margin
|
||||
let cache = TokenCache {
|
||||
token: "test".into(),
|
||||
acquired_at: Instant::now() - Duration::from_secs(6900),
|
||||
expires_in: Duration::from_secs(7200),
|
||||
};
|
||||
assert!(cache.is_expired());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
use prost::Message;
|
||||
|
||||
pub const METHOD_CONTROL: i32 = 0;
|
||||
pub const METHOD_DATA: i32 = 1;
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct PbHeader {
|
||||
#[prost(string, tag = "1")]
|
||||
pub key: String,
|
||||
#[prost(string, tag = "2")]
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct PbFrame {
|
||||
#[prost(uint64, tag = "1")]
|
||||
pub seq_id: u64,
|
||||
#[prost(uint64, tag = "2")]
|
||||
pub log_id: u64,
|
||||
#[prost(int32, tag = "3")]
|
||||
pub service: i32,
|
||||
#[prost(int32, tag = "4")]
|
||||
pub method: i32,
|
||||
#[prost(message, repeated, tag = "5")]
|
||||
pub headers: Vec<PbHeader>,
|
||||
#[prost(string, tag = "6")]
|
||||
pub payload_encoding: String,
|
||||
#[prost(string, tag = "7")]
|
||||
pub payload_type: String,
|
||||
#[prost(bytes = "vec", tag = "8")]
|
||||
pub payload: Vec<u8>,
|
||||
#[prost(string, tag = "9")]
|
||||
pub log_id_new: String,
|
||||
}
|
||||
|
||||
pub fn decode_frame(data: &[u8]) -> Result<PbFrame, prost::DecodeError> {
|
||||
PbFrame::decode(data)
|
||||
}
|
||||
|
||||
pub fn encode_frame(frame: &PbFrame) -> Vec<u8> {
|
||||
frame.encode_to_vec()
|
||||
}
|
||||
|
||||
pub fn get_header<'a>(headers: &'a [PbHeader], key: &str) -> Option<&'a str> {
|
||||
headers.iter().find(|h| h.key == key).map(|h| h.value.as_str())
|
||||
}
|
||||
|
||||
pub fn build_ping_frame(service_id: i32) -> PbFrame {
|
||||
PbFrame {
|
||||
seq_id: 0,
|
||||
log_id: 0,
|
||||
service: service_id,
|
||||
method: METHOD_CONTROL,
|
||||
headers: vec![PbHeader {
|
||||
key: "type".into(),
|
||||
value: "ping".into(),
|
||||
}],
|
||||
payload_encoding: String::new(),
|
||||
payload_type: String::new(),
|
||||
payload: Vec::new(),
|
||||
log_id_new: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_ack_frame(original: &PbFrame) -> PbFrame {
|
||||
let mut ack_headers = Vec::new();
|
||||
for h in &original.headers {
|
||||
if h.key == "type" || h.key == "message_id" || h.key == "trace_id" {
|
||||
ack_headers.push(h.clone());
|
||||
}
|
||||
}
|
||||
ack_headers.push(PbHeader {
|
||||
key: "biz_rt".into(),
|
||||
value: "0".into(),
|
||||
});
|
||||
|
||||
PbFrame {
|
||||
seq_id: original.seq_id,
|
||||
log_id: original.log_id,
|
||||
service: original.service,
|
||||
method: METHOD_DATA,
|
||||
headers: ack_headers,
|
||||
payload_encoding: String::new(),
|
||||
payload_type: String::new(),
|
||||
payload: br#"{"code":200}"#.to_vec(),
|
||||
log_id_new: original.log_id_new.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trip_ping_frame() {
|
||||
let frame = PbFrame {
|
||||
seq_id: 0,
|
||||
log_id: 0,
|
||||
service: 1,
|
||||
method: METHOD_CONTROL,
|
||||
headers: vec![PbHeader {
|
||||
key: "type".into(),
|
||||
value: "ping".into(),
|
||||
}],
|
||||
payload_encoding: String::new(),
|
||||
payload_type: String::new(),
|
||||
payload: Vec::new(),
|
||||
log_id_new: String::new(),
|
||||
};
|
||||
let bytes = encode_frame(&frame);
|
||||
let decoded = decode_frame(&bytes).unwrap();
|
||||
assert_eq!(decoded.method, METHOD_CONTROL);
|
||||
assert_eq!(decoded.headers[0].key, "type");
|
||||
assert_eq!(decoded.headers[0].value, "ping");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_data_frame_with_payload() {
|
||||
let payload = br#"{"sender":{},"message":{}}"#;
|
||||
let frame = PbFrame {
|
||||
seq_id: 42,
|
||||
log_id: 100,
|
||||
service: 1,
|
||||
method: METHOD_DATA,
|
||||
headers: vec![
|
||||
PbHeader {
|
||||
key: "type".into(),
|
||||
value: "event".into(),
|
||||
},
|
||||
PbHeader {
|
||||
key: "message_id".into(),
|
||||
value: "msg_001".into(),
|
||||
},
|
||||
PbHeader {
|
||||
key: "sum".into(),
|
||||
value: "1".into(),
|
||||
},
|
||||
PbHeader {
|
||||
key: "seq".into(),
|
||||
value: "0".into(),
|
||||
},
|
||||
],
|
||||
payload_encoding: String::new(),
|
||||
payload_type: String::new(),
|
||||
payload: payload.to_vec(),
|
||||
log_id_new: String::new(),
|
||||
};
|
||||
let bytes = encode_frame(&frame);
|
||||
let decoded = decode_frame(&bytes).unwrap();
|
||||
assert_eq!(decoded.seq_id, 42);
|
||||
assert_eq!(decoded.method, METHOD_DATA);
|
||||
assert_eq!(decoded.payload, payload);
|
||||
assert_eq!(decoded.headers.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_invalid_bytes_returns_error() {
|
||||
let result = decode_frame(&[0xFF, 0xFF, 0xFF]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ping_frame_has_correct_structure() {
|
||||
let frame = build_ping_frame(7);
|
||||
assert_eq!(frame.method, METHOD_CONTROL);
|
||||
assert_eq!(frame.service, 7);
|
||||
let type_header = frame.headers.iter().find(|h| h.key == "type").unwrap();
|
||||
assert_eq!(type_header.value, "ping");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ack_frame_has_code_200_payload() {
|
||||
let original = PbFrame {
|
||||
seq_id: 10,
|
||||
log_id: 20,
|
||||
service: 1,
|
||||
method: METHOD_DATA,
|
||||
headers: vec![
|
||||
PbHeader {
|
||||
key: "type".into(),
|
||||
value: "event".into(),
|
||||
},
|
||||
PbHeader {
|
||||
key: "message_id".into(),
|
||||
value: "msg_x".into(),
|
||||
},
|
||||
],
|
||||
payload_encoding: String::new(),
|
||||
payload_type: String::new(),
|
||||
payload: Vec::new(),
|
||||
log_id_new: String::new(),
|
||||
};
|
||||
let ack = build_ack_frame(&original);
|
||||
assert_eq!(ack.method, METHOD_DATA);
|
||||
let payload_str = String::from_utf8(ack.payload.clone()).unwrap();
|
||||
assert!(payload_str.contains("200"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_header_finds_existing_key() {
|
||||
let headers = vec![
|
||||
PbHeader {
|
||||
key: "type".into(),
|
||||
value: "event".into(),
|
||||
},
|
||||
PbHeader {
|
||||
key: "sum".into(),
|
||||
value: "3".into(),
|
||||
},
|
||||
];
|
||||
assert_eq!(get_header(&headers, "type"), Some("event"));
|
||||
assert_eq!(get_header(&headers, "sum"), Some("3"));
|
||||
assert_eq!(get_header(&headers, "missing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ack_frame_retains_only_allowed_headers() {
|
||||
let original = PbFrame {
|
||||
seq_id: 5,
|
||||
log_id: 10,
|
||||
service: 1,
|
||||
method: METHOD_DATA,
|
||||
headers: vec![
|
||||
PbHeader {
|
||||
key: "type".into(),
|
||||
value: "event".into(),
|
||||
},
|
||||
PbHeader {
|
||||
key: "message_id".into(),
|
||||
value: "msg_99".into(),
|
||||
},
|
||||
PbHeader {
|
||||
key: "trace_id".into(),
|
||||
value: "trace_abc".into(),
|
||||
},
|
||||
PbHeader {
|
||||
key: "sum".into(),
|
||||
value: "3".into(),
|
||||
},
|
||||
PbHeader {
|
||||
key: "seq".into(),
|
||||
value: "1".into(),
|
||||
},
|
||||
],
|
||||
payload_encoding: String::new(),
|
||||
payload_type: String::new(),
|
||||
payload: Vec::new(),
|
||||
log_id_new: String::new(),
|
||||
};
|
||||
let ack = build_ack_frame(&original);
|
||||
let keys: Vec<&str> = ack.headers.iter().map(|h| h.key.as_str()).collect();
|
||||
assert!(keys.contains(&"type"));
|
||||
assert!(keys.contains(&"message_id"));
|
||||
assert!(keys.contains(&"trace_id"));
|
||||
assert!(keys.contains(&"biz_rt"));
|
||||
assert!(!keys.contains(&"sum"));
|
||||
assert!(!keys.contains(&"seq"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ack_frame_echoes_seq_and_log_ids() {
|
||||
let original = PbFrame {
|
||||
seq_id: 77,
|
||||
log_id: 88,
|
||||
service: 3,
|
||||
method: METHOD_DATA,
|
||||
headers: vec![],
|
||||
payload_encoding: String::new(),
|
||||
payload_type: String::new(),
|
||||
payload: Vec::new(),
|
||||
log_id_new: "new_log_xyz".into(),
|
||||
};
|
||||
let ack = build_ack_frame(&original);
|
||||
assert_eq!(ack.seq_id, 77);
|
||||
assert_eq!(ack.log_id, 88);
|
||||
assert_eq!(ack.service, 3);
|
||||
assert_eq!(ack.log_id_new, "new_log_xyz");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mod api;
|
||||
mod frame;
|
||||
mod plugin;
|
||||
mod types;
|
||||
mod ws_session;
|
||||
|
||||
pub use plugin::LarkPlugin;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,769 @@
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
/// Deserialize a string that may be null into an empty string.
|
||||
fn deserialize_nullable_string<'de, D>(deserializer: D) -> Result<String, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
Option::<String>::deserialize(deserializer).map(|opt| opt.unwrap_or_default())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tenant access token
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Request body for obtaining a tenant access token.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct TenantAccessTokenRequest {
|
||||
pub app_id: String,
|
||||
pub app_secret: String,
|
||||
}
|
||||
|
||||
/// Response from the tenant access token endpoint.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct TenantAccessTokenResponse {
|
||||
pub code: i32,
|
||||
pub msg: String,
|
||||
pub tenant_access_token: Option<String>,
|
||||
pub expire: Option<i64>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bot info
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Response from the bot info endpoint.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct BotInfoResponse {
|
||||
pub code: i32,
|
||||
pub msg: String,
|
||||
pub bot: Option<BotInfoData>,
|
||||
}
|
||||
|
||||
/// Bot identity data from the bot info endpoint.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct BotInfoData {
|
||||
pub app_name: String,
|
||||
#[serde(default)]
|
||||
pub open_id: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket endpoint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Request body for the WebSocket endpoint URL request.
|
||||
/// Note: This endpoint uses AppID/AppSecret directly, NOT Bearer token auth.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct WsEndpointRequest {
|
||||
#[serde(rename = "AppID")]
|
||||
pub app_id: String,
|
||||
#[serde(rename = "AppSecret")]
|
||||
pub app_secret: String,
|
||||
}
|
||||
|
||||
/// Response from the WebSocket endpoint URL request.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct WsEndpointResponse {
|
||||
pub code: i32,
|
||||
pub msg: String,
|
||||
pub data: Option<WsEndpointData>,
|
||||
}
|
||||
|
||||
/// Data containing the WebSocket URL and client configuration.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct WsEndpointData {
|
||||
#[serde(rename = "URL")]
|
||||
pub url: String,
|
||||
#[serde(rename = "ClientConfig")]
|
||||
pub client_config: Option<WsClientConfig>,
|
||||
}
|
||||
|
||||
/// Client configuration returned by the WS endpoint API.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct WsClientConfig {
|
||||
#[serde(rename = "ReconnectCount")]
|
||||
pub reconnect_count: Option<i32>,
|
||||
#[serde(rename = "ReconnectInterval")]
|
||||
pub reconnect_interval: Option<u64>,
|
||||
#[serde(rename = "ReconnectNonce")]
|
||||
pub reconnect_nonce: Option<u64>,
|
||||
#[serde(rename = "PingInterval")]
|
||||
pub ping_interval: Option<u64>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message event (im.message.receive_v1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Event payload for `im.message.receive_v1`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct MessageEvent {
|
||||
pub sender: MessageSender,
|
||||
pub message: MessageBody,
|
||||
}
|
||||
|
||||
/// Sender information in a message event.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct MessageSender {
|
||||
pub sender_id: SenderIdContainer,
|
||||
#[serde(default)]
|
||||
pub sender_type: String,
|
||||
#[serde(default)]
|
||||
pub tenant_key: String,
|
||||
}
|
||||
|
||||
/// Container for various ID types.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct SenderIdContainer {
|
||||
#[serde(default, deserialize_with = "deserialize_nullable_string")]
|
||||
pub open_id: String,
|
||||
#[serde(default, deserialize_with = "deserialize_nullable_string")]
|
||||
pub user_id: String,
|
||||
#[serde(default, deserialize_with = "deserialize_nullable_string")]
|
||||
pub union_id: String,
|
||||
}
|
||||
|
||||
/// Message body in a message event.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct MessageBody {
|
||||
#[serde(default, deserialize_with = "deserialize_nullable_string")]
|
||||
pub message_id: String,
|
||||
#[serde(default, deserialize_with = "deserialize_nullable_string")]
|
||||
pub chat_id: String,
|
||||
#[serde(default, deserialize_with = "deserialize_nullable_string")]
|
||||
pub chat_type: String,
|
||||
#[serde(default, deserialize_with = "deserialize_nullable_string")]
|
||||
pub message_type: String,
|
||||
#[serde(default, deserialize_with = "deserialize_nullable_string")]
|
||||
pub content: String,
|
||||
#[serde(default)]
|
||||
pub parent_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub root_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub create_time: Option<String>,
|
||||
#[serde(default)]
|
||||
pub mentions: Option<Vec<Mention>>,
|
||||
}
|
||||
|
||||
/// A mention in a message.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct Mention {
|
||||
pub key: String,
|
||||
pub id: MentionId,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Mention ID container.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct MentionId {
|
||||
#[serde(default)]
|
||||
pub open_id: String,
|
||||
#[serde(default)]
|
||||
pub user_id: String,
|
||||
#[serde(default)]
|
||||
pub union_id: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message content types (JSON-encoded in `content` field)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Text message content: `{ "text": "..." }`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct TextContent {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Image message content: `{ "image_key": "..." }`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct ImageContent {
|
||||
pub image_key: String,
|
||||
}
|
||||
|
||||
/// File message content: `{ "file_key": "...", "file_name": "..." }`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct FileContent {
|
||||
pub file_key: String,
|
||||
#[serde(default)]
|
||||
pub file_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Audio message content: `{ "file_key": "..." }`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct AudioContent {
|
||||
pub file_key: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card action (card.action.trigger)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Payload for `card.action.trigger` events.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct CardActionEvent {
|
||||
pub operator: CardOperator,
|
||||
pub action: CardAction,
|
||||
#[serde(default)]
|
||||
pub token: Option<String>,
|
||||
pub open_message_id: Option<String>,
|
||||
pub open_chat_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Operator (user) who triggered the card action.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct CardOperator {
|
||||
pub open_id: String,
|
||||
#[serde(default)]
|
||||
pub user_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Card action details.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct CardAction {
|
||||
pub tag: String,
|
||||
pub value: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bot menu event (application.bot.menu_v6)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Payload for `application.bot.menu_v6` events.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct BotMenuEvent {
|
||||
pub operator: BotMenuOperator,
|
||||
pub event_key: String,
|
||||
#[serde(default)]
|
||||
pub timestamp: Option<String>,
|
||||
}
|
||||
|
||||
/// Operator of a bot menu event.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct BotMenuOperator {
|
||||
pub operator_id: OperatorId,
|
||||
}
|
||||
|
||||
/// Operator ID container for bot menu events.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct OperatorId {
|
||||
#[serde(default)]
|
||||
pub open_id: String,
|
||||
#[serde(default)]
|
||||
pub user_id: String,
|
||||
#[serde(default)]
|
||||
pub union_id: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Send / Update interactive card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Request body for sending an interactive card message.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct SendCardRequest {
|
||||
pub receive_id: String,
|
||||
pub msg_type: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Response from the send message API.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct SendMessageResponse {
|
||||
pub code: i32,
|
||||
pub msg: String,
|
||||
pub data: Option<SendMessageData>,
|
||||
}
|
||||
|
||||
/// Data from a successful send message response.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct SendMessageData {
|
||||
pub message_id: String,
|
||||
}
|
||||
|
||||
/// Request body for updating (patching) a card message.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct UpdateCardRequest {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Generic Lark API response for operations without specific return data.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct GenericResponse {
|
||||
pub code: i32,
|
||||
pub msg: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interactive card building helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a Lark interactive card JSON string from text and optional buttons.
|
||||
///
|
||||
/// All Lark responses use interactive cards because Lark only supports
|
||||
/// editing card messages (not plain text messages).
|
||||
pub(crate) fn build_interactive_card(text: &str, buttons: Option<&[Vec<crate::types::ActionButton>]>) -> String {
|
||||
let mut elements = vec![serde_json::json!({
|
||||
"tag": "markdown",
|
||||
"content": text
|
||||
})];
|
||||
|
||||
if let Some(button_rows) = buttons {
|
||||
let mut actions = Vec::new();
|
||||
for row in button_rows {
|
||||
for btn in row {
|
||||
let callback_value = format_lark_callback(&btn.action, btn.params.as_ref());
|
||||
actions.push(serde_json::json!({
|
||||
"tag": "button",
|
||||
"text": { "tag": "plain_text", "content": btn.label },
|
||||
"type": "primary",
|
||||
"value": { "action": callback_value }
|
||||
}));
|
||||
}
|
||||
}
|
||||
if !actions.is_empty() {
|
||||
elements.push(serde_json::json!({
|
||||
"tag": "action",
|
||||
"actions": actions
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
let card = serde_json::json!({
|
||||
"elements": elements
|
||||
});
|
||||
card.to_string()
|
||||
}
|
||||
|
||||
/// Encode an action + params into a Lark card button value string.
|
||||
///
|
||||
/// Format: `"category:action"` or `"category:action:k=v,k=v"`
|
||||
/// (same wire format as Telegram callback_data for consistency).
|
||||
fn format_lark_callback(action: &str, params: Option<&std::collections::HashMap<String, String>>) -> String {
|
||||
let category = action_category_prefix(action);
|
||||
match params {
|
||||
Some(p) if !p.is_empty() => {
|
||||
let encoded: Vec<String> = p.iter().map(|(k, v)| format!("{k}={v}")).collect();
|
||||
format!("{category}:{}:{}", action, encoded.join(","))
|
||||
}
|
||||
_ => format!("{category}:{action}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the category prefix from an action name.
|
||||
/// Same logic as Telegram plugin for consistency.
|
||||
fn action_category_prefix(action: &str) -> &'static str {
|
||||
if action == "system.confirm" {
|
||||
return "chat";
|
||||
}
|
||||
let prefix = action.split('.').next().unwrap_or("");
|
||||
match prefix {
|
||||
"pairing" => "platform",
|
||||
"chat" | "action" => "chat",
|
||||
_ => "system",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed callback data: (category, action, params).
|
||||
pub(crate) type ParsedLarkCallback = (String, String, Option<std::collections::HashMap<String, String>>);
|
||||
|
||||
/// Parse a Lark card button value string back to (category, action, params).
|
||||
///
|
||||
/// Format: `"category:action"` or `"category:action:k=v,k=v"`.
|
||||
pub(crate) fn parse_lark_callback(data: &str) -> Option<ParsedLarkCallback> {
|
||||
let parts: Vec<&str> = data.splitn(3, ':').collect();
|
||||
if parts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let category = parts[0].to_string();
|
||||
let action = parts[1].to_string();
|
||||
|
||||
// Validate category
|
||||
if !matches!(category.as_str(), "platform" | "system" | "chat") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let params = if parts.len() == 3 && !parts[2].is_empty() {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
for pair in parts[2].split(',') {
|
||||
if let Some((k, v)) = pair.split_once('=') {
|
||||
map.insert(k.to_string(), v.to_string());
|
||||
}
|
||||
}
|
||||
if map.is_empty() { None } else { Some(map) }
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Some((category, action, params))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// -- TenantAccessTokenResponse ------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tenant_token_response_ok() {
|
||||
let raw = json!({
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"tenant_access_token": "t-abc123",
|
||||
"expire": 7200
|
||||
});
|
||||
let resp: TenantAccessTokenResponse = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(resp.code, 0);
|
||||
assert_eq!(resp.tenant_access_token.as_deref(), Some("t-abc123"));
|
||||
assert_eq!(resp.expire, Some(7200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tenant_token_response_error() {
|
||||
let raw = json!({
|
||||
"code": 10003,
|
||||
"msg": "invalid app_id"
|
||||
});
|
||||
let resp: TenantAccessTokenResponse = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(resp.code, 10003);
|
||||
assert!(resp.tenant_access_token.is_none());
|
||||
}
|
||||
|
||||
// -- BotInfoResponse ----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn bot_info_response_ok() {
|
||||
let raw = json!({
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"bot": {
|
||||
"app_name": "TestBot",
|
||||
"open_id": "ou_abc123"
|
||||
}
|
||||
});
|
||||
let resp: BotInfoResponse = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(resp.code, 0);
|
||||
let bot = resp.bot.unwrap();
|
||||
assert_eq!(bot.app_name, "TestBot");
|
||||
assert_eq!(bot.open_id, "ou_abc123");
|
||||
}
|
||||
|
||||
// -- WsEndpointResponse -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn ws_endpoint_response_ok() {
|
||||
let raw = json!({
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": { "URL": "wss://open.feishu.cn/ws/xxx" }
|
||||
});
|
||||
let resp: WsEndpointResponse = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(resp.code, 0);
|
||||
let data = resp.data.unwrap();
|
||||
assert_eq!(data.url, "wss://open.feishu.cn/ws/xxx");
|
||||
assert!(data.client_config.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_endpoint_response_with_client_config() {
|
||||
let raw = json!({
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"URL": "wss://open.feishu.cn/ws/abc?device_id=d1&service_id=7",
|
||||
"ClientConfig": {
|
||||
"ReconnectCount": -1,
|
||||
"ReconnectInterval": 120,
|
||||
"ReconnectNonce": 30,
|
||||
"PingInterval": 120
|
||||
}
|
||||
}
|
||||
});
|
||||
let resp: WsEndpointResponse = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(resp.code, 0);
|
||||
let data = resp.data.unwrap();
|
||||
assert_eq!(data.url, "wss://open.feishu.cn/ws/abc?device_id=d1&service_id=7");
|
||||
let config = data.client_config.unwrap();
|
||||
assert_eq!(config.reconnect_count, Some(-1));
|
||||
assert_eq!(config.ping_interval, Some(120));
|
||||
}
|
||||
|
||||
// -- MessageEvent -------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn message_event_text_parses() {
|
||||
let raw = json!({
|
||||
"sender": {
|
||||
"sender_id": { "open_id": "ou_user1", "user_id": "u1", "union_id": "" },
|
||||
"sender_type": "user",
|
||||
"tenant_key": "tk_1"
|
||||
},
|
||||
"message": {
|
||||
"message_id": "om_msg1",
|
||||
"chat_id": "oc_chat1",
|
||||
"chat_type": "p2p",
|
||||
"message_type": "text",
|
||||
"content": "{\"text\":\"Hello\"}",
|
||||
"create_time": "1700000000000"
|
||||
}
|
||||
});
|
||||
let evt: MessageEvent = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(evt.sender.sender_id.open_id, "ou_user1");
|
||||
assert_eq!(evt.message.message_id, "om_msg1");
|
||||
assert_eq!(evt.message.chat_id, "oc_chat1");
|
||||
assert_eq!(evt.message.message_type, "text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_event_with_mentions_parses() {
|
||||
let raw = json!({
|
||||
"sender": {
|
||||
"sender_id": { "open_id": "ou_user2", "user_id": "", "union_id": "" },
|
||||
"sender_type": "user",
|
||||
"tenant_key": "tk_1"
|
||||
},
|
||||
"message": {
|
||||
"message_id": "om_msg2",
|
||||
"chat_id": "oc_chat2",
|
||||
"chat_type": "group",
|
||||
"message_type": "text",
|
||||
"content": "{\"text\":\"@_user_1 Hello\"}",
|
||||
"mentions": [
|
||||
{
|
||||
"key": "@_user_1",
|
||||
"id": { "open_id": "ou_bot", "user_id": "", "union_id": "" },
|
||||
"name": "MyBot"
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
let evt: MessageEvent = serde_json::from_value(raw).unwrap();
|
||||
let mentions = evt.message.mentions.unwrap();
|
||||
assert_eq!(mentions.len(), 1);
|
||||
assert_eq!(mentions[0].key, "@_user_1");
|
||||
assert_eq!(mentions[0].name, "MyBot");
|
||||
}
|
||||
|
||||
// -- TextContent / ImageContent / FileContent ---------------------------
|
||||
|
||||
#[test]
|
||||
fn text_content_parses() {
|
||||
let raw = json!({ "text": "Hello World" });
|
||||
let tc: TextContent = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(tc.text, "Hello World");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_content_parses() {
|
||||
let raw = json!({ "image_key": "img_key_123" });
|
||||
let ic: ImageContent = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(ic.image_key, "img_key_123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_content_parses() {
|
||||
let raw = json!({ "file_key": "file_key_123", "file_name": "doc.pdf" });
|
||||
let fc: FileContent = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(fc.file_key, "file_key_123");
|
||||
assert_eq!(fc.file_name.as_deref(), Some("doc.pdf"));
|
||||
}
|
||||
|
||||
// -- CardActionEvent ----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn card_action_event_parses() {
|
||||
let raw = json!({
|
||||
"operator": { "open_id": "ou_user1", "user_id": "u1" },
|
||||
"action": { "tag": "button", "value": { "action": "system:session.new" } },
|
||||
"token": "tok_card",
|
||||
"open_message_id": "om_card1",
|
||||
"open_chat_id": "oc_chat1"
|
||||
});
|
||||
let evt: CardActionEvent = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(evt.operator.open_id, "ou_user1");
|
||||
assert_eq!(evt.action.tag, "button");
|
||||
assert_eq!(evt.open_message_id.as_deref(), Some("om_card1"));
|
||||
}
|
||||
|
||||
// -- BotMenuEvent -------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn bot_menu_event_parses() {
|
||||
let raw = json!({
|
||||
"operator": {
|
||||
"operator_id": { "open_id": "ou_user1", "user_id": "u1", "union_id": "" }
|
||||
},
|
||||
"event_key": "help",
|
||||
"timestamp": "1700000000"
|
||||
});
|
||||
let evt: BotMenuEvent = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(evt.operator.operator_id.open_id, "ou_user1");
|
||||
assert_eq!(evt.event_key, "help");
|
||||
}
|
||||
|
||||
// -- SendMessageResponse ------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn send_message_response_ok() {
|
||||
let raw = json!({
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": { "message_id": "om_sent1" }
|
||||
});
|
||||
let resp: SendMessageResponse = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(resp.code, 0);
|
||||
assert_eq!(resp.data.unwrap().message_id, "om_sent1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_message_response_error() {
|
||||
let raw = json!({ "code": 230001, "msg": "send failed" });
|
||||
let resp: SendMessageResponse = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(resp.code, 230001);
|
||||
assert!(resp.data.is_none());
|
||||
}
|
||||
|
||||
// -- build_interactive_card ---------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn build_card_text_only() {
|
||||
let card = build_interactive_card("Hello", None);
|
||||
let v: serde_json::Value = serde_json::from_str(&card).unwrap();
|
||||
assert_eq!(v["elements"][0]["tag"], "markdown");
|
||||
assert_eq!(v["elements"][0]["content"], "Hello");
|
||||
assert!(v["elements"].get(1).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_card_with_buttons() {
|
||||
use crate::types::ActionButton;
|
||||
let buttons = vec![vec![
|
||||
ActionButton {
|
||||
label: "Yes".into(),
|
||||
action: "system.confirm".into(),
|
||||
params: None,
|
||||
},
|
||||
ActionButton {
|
||||
label: "No".into(),
|
||||
action: "chat.regenerate".into(),
|
||||
params: None,
|
||||
},
|
||||
]];
|
||||
let card = build_interactive_card("Choose:", Some(&buttons));
|
||||
let v: serde_json::Value = serde_json::from_str(&card).unwrap();
|
||||
assert_eq!(v["elements"][0]["content"], "Choose:");
|
||||
let actions = &v["elements"][1]["actions"];
|
||||
assert_eq!(actions[0]["text"]["content"], "Yes");
|
||||
assert_eq!(actions[1]["text"]["content"], "No");
|
||||
}
|
||||
|
||||
// -- format_lark_callback / parse_lark_callback -------------------------
|
||||
|
||||
#[test]
|
||||
fn format_callback_no_params() {
|
||||
assert_eq!(format_lark_callback("help.show", None), "system:help.show");
|
||||
assert_eq!(format_lark_callback("pairing.show", None), "platform:pairing.show");
|
||||
assert_eq!(format_lark_callback("chat.regenerate", None), "chat:chat.regenerate");
|
||||
assert_eq!(format_lark_callback("system.confirm", None), "chat:system.confirm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_callback_with_params() {
|
||||
use std::collections::HashMap;
|
||||
let mut params = HashMap::new();
|
||||
params.insert("callId".into(), "abc".into());
|
||||
params.insert("value".into(), "yes".into());
|
||||
let result = format_lark_callback("system.confirm", Some(¶ms));
|
||||
assert!(result.starts_with("chat:system.confirm:"));
|
||||
assert!(result.contains("callId=abc"));
|
||||
assert!(result.contains("value=yes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_callback_roundtrip_no_params() {
|
||||
let encoded = format_lark_callback("session.new", None);
|
||||
let (cat, action, params) = parse_lark_callback(&encoded).unwrap();
|
||||
assert_eq!(cat, "system");
|
||||
assert_eq!(action, "session.new");
|
||||
assert!(params.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_callback_roundtrip_with_params() {
|
||||
use std::collections::HashMap;
|
||||
let mut p = HashMap::new();
|
||||
p.insert("agentType".into(), "gemini".into());
|
||||
let encoded = format_lark_callback("agent.select", Some(&p));
|
||||
let (cat, action, params) = parse_lark_callback(&encoded).unwrap();
|
||||
assert_eq!(cat, "system");
|
||||
assert_eq!(action, "agent.select");
|
||||
assert_eq!(params.unwrap().get("agentType").unwrap(), "gemini");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_callback_invalid() {
|
||||
assert!(parse_lark_callback("invalid").is_none());
|
||||
assert!(parse_lark_callback("unknown:action").is_none());
|
||||
}
|
||||
|
||||
// -- GenericResponse ----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn generic_response_ok() {
|
||||
let raw = json!({ "code": 0, "msg": "ok" });
|
||||
let resp: GenericResponse = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(resp.code, 0);
|
||||
}
|
||||
|
||||
// -- SendCardRequest serializes -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn send_card_request_serializes() {
|
||||
let req = SendCardRequest {
|
||||
receive_id: "oc_chat1".into(),
|
||||
msg_type: "interactive".into(),
|
||||
content: "{}".into(),
|
||||
};
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(json["receive_id"], "oc_chat1");
|
||||
assert_eq!(json["msg_type"], "interactive");
|
||||
}
|
||||
|
||||
// -- UpdateCardRequest serializes ---------------------------------------
|
||||
|
||||
#[test]
|
||||
fn update_card_request_serializes() {
|
||||
let req = UpdateCardRequest {
|
||||
content: "{\"elements\":[]}".into(),
|
||||
};
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(json["content"], "{\"elements\":[]}");
|
||||
}
|
||||
|
||||
// -- TenantAccessTokenRequest serializes --------------------------------
|
||||
|
||||
#[test]
|
||||
fn tenant_token_request_serializes() {
|
||||
let req = TenantAccessTokenRequest {
|
||||
app_id: "cli_123".into(),
|
||||
app_secret: "secret_456".into(),
|
||||
};
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(json["app_id"], "cli_123");
|
||||
assert_eq!(json["app_secret"], "secret_456");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
pub struct FragmentCache {
|
||||
pub entries: HashMap<String, FragmentEntry>,
|
||||
}
|
||||
|
||||
pub struct FragmentEntry {
|
||||
pub sum: usize,
|
||||
pub fragments: Vec<Option<Vec<u8>>>,
|
||||
pub created_at: Instant,
|
||||
}
|
||||
|
||||
impl FragmentCache {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, message_id: &str, sum: usize, seq: usize, data: &[u8]) -> Option<Vec<u8>> {
|
||||
let entry = self
|
||||
.entries
|
||||
.entry(message_id.to_owned())
|
||||
.or_insert_with(|| FragmentEntry {
|
||||
sum,
|
||||
fragments: vec![None; sum],
|
||||
created_at: Instant::now(),
|
||||
});
|
||||
|
||||
if seq < entry.sum {
|
||||
entry.fragments[seq] = Some(data.to_vec());
|
||||
}
|
||||
|
||||
if entry.fragments.iter().all(|f| f.is_some()) {
|
||||
let merged: Vec<u8> = entry
|
||||
.fragments
|
||||
.iter()
|
||||
.flat_map(|f| f.as_ref().unwrap().iter().copied())
|
||||
.collect();
|
||||
self.entries.remove(message_id);
|
||||
Some(merged)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cleanup(&mut self, ttl: Duration) {
|
||||
self.entries.retain(|_, entry| entry.created_at.elapsed() < ttl);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct PongConfig {
|
||||
pub ping_interval_secs: u64,
|
||||
pub reconnect_count: i32,
|
||||
pub reconnect_interval_secs: u64,
|
||||
pub reconnect_nonce_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PongPayload {
|
||||
#[serde(rename = "PingInterval")]
|
||||
ping_interval: u64,
|
||||
#[serde(rename = "ReconnectCount")]
|
||||
reconnect_count: i32,
|
||||
#[serde(rename = "ReconnectInterval")]
|
||||
reconnect_interval: u64,
|
||||
#[serde(rename = "ReconnectNonce")]
|
||||
reconnect_nonce: u64,
|
||||
}
|
||||
|
||||
pub fn parse_pong_config(payload: &[u8]) -> Option<PongConfig> {
|
||||
let p: PongPayload = serde_json::from_slice(payload).ok()?;
|
||||
Some(PongConfig {
|
||||
ping_interval_secs: p.ping_interval,
|
||||
reconnect_count: p.reconnect_count,
|
||||
reconnect_interval_secs: p.reconnect_interval,
|
||||
reconnect_nonce_secs: p.reconnect_nonce,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn single_fragment_returns_immediately() {
|
||||
let mut cache = FragmentCache::new();
|
||||
let result = cache.push("msg_1", 1, 0, b"hello");
|
||||
assert_eq!(result, Some(b"hello".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_fragment_returns_on_complete() {
|
||||
let mut cache = FragmentCache::new();
|
||||
let r1 = cache.push("msg_2", 3, 0, b"aaa");
|
||||
assert_eq!(r1, None);
|
||||
let r2 = cache.push("msg_2", 3, 2, b"ccc");
|
||||
assert_eq!(r2, None);
|
||||
let r3 = cache.push("msg_2", 3, 1, b"bbb");
|
||||
assert_eq!(r3, Some(b"aaabbbccc".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_message_ids_independent() {
|
||||
let mut cache = FragmentCache::new();
|
||||
cache.push("msg_a", 2, 0, b"A0");
|
||||
let r = cache.push("msg_b", 1, 0, b"B");
|
||||
assert_eq!(r, Some(b"B".to_vec()));
|
||||
let r2 = cache.push("msg_a", 2, 1, b"A1");
|
||||
assert_eq!(r2, Some(b"A0A1".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_removes_old_entries() {
|
||||
let mut cache = FragmentCache::new();
|
||||
cache.push("old_msg", 3, 0, b"partial");
|
||||
if let Some(entry) = cache.entries.get_mut("old_msg") {
|
||||
entry.created_at = Instant::now() - Duration::from_secs(600);
|
||||
}
|
||||
cache.cleanup(Duration::from_secs(300));
|
||||
assert!(!cache.entries.contains_key("old_msg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pong_config_extracts_intervals() {
|
||||
let payload = br#"{"PingInterval":120,"ReconnectCount":10,"ReconnectInterval":120,"ReconnectNonce":30}"#;
|
||||
let config = parse_pong_config(payload).unwrap();
|
||||
assert_eq!(config.ping_interval_secs, 120);
|
||||
assert_eq!(config.reconnect_count, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pong_config_invalid_json_returns_none() {
|
||||
let result = parse_pong_config(b"not json");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
//! Low-level HTTP helpers for the Matrix Client-Server API v3 (Route B).
|
||||
//!
|
||||
//! All calls use Bearer-token auth via `reqwest`. No E2EE — encrypted
|
||||
//! events are skipped with a warning in the sync loop.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use reqwest::Client;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::constants::{MATRIX_API_TIMEOUT, MATRIX_SYNC_TIMEOUT_MS};
|
||||
use crate::error::ChannelError;
|
||||
|
||||
use super::types::{ProfileResponse, SendEventResponse, SyncResponse, WhoAmIResponse};
|
||||
|
||||
/// Monotonic transaction-ID counter for idempotent PUT requests.
|
||||
static TXN_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
/// Generate a unique transaction ID for Matrix PUT requests.
|
||||
///
|
||||
/// Uses a process-wide monotonic counter to guarantee uniqueness within a
|
||||
/// single process lifetime (sufficient for `txnId` semantics — the server
|
||||
/// deduplicates within a short window per device).
|
||||
pub fn next_txn_id() -> String {
|
||||
let n = TXN_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
format!("nomi_{n}")
|
||||
}
|
||||
|
||||
/// Thin wrapper around `reqwest::Client` holding the homeserver base URL
|
||||
/// and access token for Bearer auth.
|
||||
#[derive(Clone)]
|
||||
pub struct MatrixApi {
|
||||
client: Client,
|
||||
homeserver: String, // no trailing '/'
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
impl MatrixApi {
|
||||
pub fn new(client: Client, homeserver: &str, access_token: &str) -> Self {
|
||||
Self {
|
||||
client,
|
||||
homeserver: homeserver.trim_end_matches('/').to_owned(),
|
||||
access_token: access_token.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
// -- whoami ----------------------------------------------------------------
|
||||
|
||||
/// `GET /_matrix/client/v3/account/whoami`
|
||||
pub async fn whoami(&self) -> Result<WhoAmIResponse, ChannelError> {
|
||||
let url = format!("{}/_matrix/client/v3/account/whoami", self.homeserver);
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.bearer_auth(&self.access_token)
|
||||
.timeout(MATRIX_API_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("whoami request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ChannelError::PlatformApi(format!(
|
||||
"whoami returned {status}: {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
resp.json::<WhoAmIResponse>()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("whoami parse error: {e}")))
|
||||
}
|
||||
|
||||
// -- profile ---------------------------------------------------------------
|
||||
|
||||
/// `GET /_matrix/client/v3/profile/{userId}`
|
||||
pub async fn get_profile(&self, user_id: &str) -> Result<ProfileResponse, ChannelError> {
|
||||
let url = format!(
|
||||
"{}/_matrix/client/v3/profile/{}",
|
||||
self.homeserver,
|
||||
urlencoded(user_id)
|
||||
);
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.bearer_auth(&self.access_token)
|
||||
.timeout(MATRIX_API_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("profile request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
// Profile fetch is best-effort; return empty on failure.
|
||||
debug!(user_id, status = %resp.status(), "profile fetch failed, using defaults");
|
||||
return Ok(ProfileResponse {
|
||||
displayname: None,
|
||||
avatar_url: None,
|
||||
});
|
||||
}
|
||||
|
||||
resp.json::<ProfileResponse>()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("profile parse error: {e}")))
|
||||
}
|
||||
|
||||
// -- sync ------------------------------------------------------------------
|
||||
|
||||
/// `GET /_matrix/client/v3/sync?timeout={MATRIX_SYNC_TIMEOUT_MS}&since={since}`
|
||||
pub async fn sync(&self, since: Option<&str>) -> Result<SyncResponse, ChannelError> {
|
||||
let mut url = format!(
|
||||
"{}/_matrix/client/v3/sync?timeout={}",
|
||||
self.homeserver, MATRIX_SYNC_TIMEOUT_MS,
|
||||
);
|
||||
if let Some(since) = since {
|
||||
url.push_str("&since=");
|
||||
url.push_str(&urlencoded(since));
|
||||
}
|
||||
// The HTTP timeout must exceed the server-side long-poll timeout.
|
||||
let http_timeout = std::time::Duration::from_millis(MATRIX_SYNC_TIMEOUT_MS as u64 + 10_000);
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.bearer_auth(&self.access_token)
|
||||
.timeout(http_timeout)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("sync request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ChannelError::PlatformApi(format!(
|
||||
"sync returned {status}: {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
resp.json::<SyncResponse>()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("sync parse error: {e}")))
|
||||
}
|
||||
|
||||
// -- send message ----------------------------------------------------------
|
||||
|
||||
/// `PUT /_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}`
|
||||
///
|
||||
/// Returns the `event_id` of the sent message.
|
||||
pub async fn send_text(
|
||||
&self,
|
||||
room_id: &str,
|
||||
text: &str,
|
||||
html: Option<&str>,
|
||||
) -> Result<String, ChannelError> {
|
||||
let txn_id = next_txn_id();
|
||||
let url = format!(
|
||||
"{}/_matrix/client/v3/rooms/{}/send/m.room.message/{}",
|
||||
self.homeserver,
|
||||
urlencoded(room_id),
|
||||
urlencoded(&txn_id),
|
||||
);
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
"msgtype": "m.text",
|
||||
"body": text,
|
||||
});
|
||||
if let Some(html) = html {
|
||||
body["format"] = serde_json::Value::String("org.matrix.custom.html".into());
|
||||
body["formatted_body"] = serde_json::Value::String(html.into());
|
||||
}
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.put(&url)
|
||||
.bearer_auth(&self.access_token)
|
||||
.json(&body)
|
||||
.timeout(MATRIX_API_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("send_text request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ChannelError::PlatformApi(format!(
|
||||
"send_text returned {status}: {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
let result: SendEventResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("send_text parse error: {e}")))?;
|
||||
Ok(result.event_id)
|
||||
}
|
||||
|
||||
// -- edit message ----------------------------------------------------------
|
||||
|
||||
/// Send an edit (`m.replace`) for an existing event.
|
||||
///
|
||||
/// Per the Matrix spec, edits are sent as a new `m.room.message` with
|
||||
/// `m.relates_to.rel_type = "m.replace"` pointing at the original event
|
||||
/// and `m.new_content` carrying the replacement.
|
||||
pub async fn edit_text(
|
||||
&self,
|
||||
room_id: &str,
|
||||
original_event_id: &str,
|
||||
new_text: &str,
|
||||
new_html: Option<&str>,
|
||||
) -> Result<String, ChannelError> {
|
||||
let txn_id = next_txn_id();
|
||||
let url = format!(
|
||||
"{}/_matrix/client/v3/rooms/{}/send/m.room.message/{}",
|
||||
self.homeserver,
|
||||
urlencoded(room_id),
|
||||
urlencoded(&txn_id),
|
||||
);
|
||||
|
||||
let mut new_content = serde_json::json!({
|
||||
"msgtype": "m.text",
|
||||
"body": new_text,
|
||||
});
|
||||
if let Some(html) = new_html {
|
||||
new_content["format"] = serde_json::Value::String("org.matrix.custom.html".into());
|
||||
new_content["formatted_body"] = serde_json::Value::String(html.into());
|
||||
}
|
||||
|
||||
let body = serde_json::json!({
|
||||
"msgtype": "m.text",
|
||||
"body": format!("* {new_text}"),
|
||||
"m.relates_to": {
|
||||
"rel_type": "m.replace",
|
||||
"event_id": original_event_id,
|
||||
},
|
||||
"m.new_content": new_content,
|
||||
});
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.put(&url)
|
||||
.bearer_auth(&self.access_token)
|
||||
.json(&body)
|
||||
.timeout(MATRIX_API_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("edit_text request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ChannelError::PlatformApi(format!(
|
||||
"edit_text returned {status}: {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
let result: SendEventResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("edit_text parse error: {e}")))?;
|
||||
Ok(result.event_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent-encode a path segment for use in Matrix API URLs.
|
||||
fn urlencoded(s: &str) -> String {
|
||||
// Matrix room IDs and event IDs contain `!`, `$`, `:` which must be
|
||||
// percent-encoded when used in URL path segments.
|
||||
let mut out = String::with_capacity(s.len() * 2);
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(b as char);
|
||||
}
|
||||
_ => {
|
||||
out.push_str(&format!("%{b:02X}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn txn_id_is_monotonic() {
|
||||
let a = next_txn_id();
|
||||
let b = next_txn_id();
|
||||
let c = next_txn_id();
|
||||
// Each must start with "nomi_" and parse to increasing numbers.
|
||||
let na: u64 = a.strip_prefix("nomi_").unwrap().parse().unwrap();
|
||||
let nb: u64 = b.strip_prefix("nomi_").unwrap().parse().unwrap();
|
||||
let nc: u64 = c.strip_prefix("nomi_").unwrap().parse().unwrap();
|
||||
assert!(na < nb);
|
||||
assert!(nb < nc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn txn_id_uniqueness() {
|
||||
let mut ids: Vec<String> = (0..100).map(|_| next_txn_id()).collect();
|
||||
let len_before = ids.len();
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), len_before, "txn IDs must be unique");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urlencoded_basic() {
|
||||
assert_eq!(urlencoded("hello"), "hello");
|
||||
assert_eq!(urlencoded("a-b_c.d~e"), "a-b_c.d~e");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urlencoded_special_chars() {
|
||||
// Room IDs like !abc:example.com
|
||||
let encoded = urlencoded("!abc:example.com");
|
||||
assert_eq!(encoded, "%21abc%3Aexample.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urlencoded_event_id() {
|
||||
// Event IDs like $abc123
|
||||
let encoded = urlencoded("$abc123");
|
||||
assert_eq!(encoded, "%24abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urlencoded_user_id() {
|
||||
// User IDs like @bot:matrix.org
|
||||
let encoded = urlencoded("@bot:matrix.org");
|
||||
assert_eq!(encoded, "%40bot%3Amatrix.org");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urlencoded_empty() {
|
||||
assert_eq!(urlencoded(""), "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Matrix channel plugin (Route B: handwritten, no E2EE, reqwest only).
|
||||
//!
|
||||
//! Uses the Matrix Client-Server API v3 directly via `reqwest`.
|
||||
//! Encrypted rooms (`m.room.encrypted`) are skipped — E2EE is gated on
|
||||
//! `matrix-sdk` dep compatibility with the workspace (currently blocked
|
||||
//! by `libsqlite3-sys` link conflict and `reqwest` 0.12 vs 0.13).
|
||||
|
||||
pub mod api;
|
||||
pub mod plugin;
|
||||
pub mod types;
|
||||
|
||||
pub use plugin::MatrixPlugin;
|
||||
@@ -0,0 +1,678 @@
|
||||
//! Matrix channel plugin — Route B (handwritten, no E2EE, reqwest only).
|
||||
//!
|
||||
//! Long-polls `/_matrix/client/v3/sync` for incoming messages, converts
|
||||
//! `m.room.message` events to `UnifiedIncomingMessage`, and exposes send/edit
|
||||
//! via the Client-Server API v3. Encrypted events (`m.room.encrypted`) are
|
||||
//! logged and skipped — E2EE requires `matrix-sdk` which currently conflicts
|
||||
//! with the workspace's `libsqlite3-sys` / `reqwest` versions.
|
||||
|
||||
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::{MATRIX_MAX_RECONNECT_ATTEMPTS, MATRIX_MAX_RECONNECT_DELAY, MATRIX_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::MatrixApi;
|
||||
use super::types::RoomMessageContent;
|
||||
|
||||
/// Matrix plugin implementing long-poll message reception via `/sync`,
|
||||
/// exponential backoff reconnection, and message send/edit via the
|
||||
/// Client-Server API v3.
|
||||
///
|
||||
/// **No E2EE** — encrypted events are skipped with a warning.
|
||||
pub struct MatrixPlugin {
|
||||
status: SharedPluginStatus,
|
||||
bot_info: Option<BotInfo>,
|
||||
last_error: Option<String>,
|
||||
api: Option<Arc<MatrixApi>>,
|
||||
/// The bot's own MXID, used for the bot-loop guard (skip own events).
|
||||
self_user_id: Option<String>,
|
||||
callbacks: Option<PluginCallbacks>,
|
||||
sync_handle: Option<JoinHandle<()>>,
|
||||
shutdown_tx: Option<watch::Sender<bool>>,
|
||||
}
|
||||
|
||||
impl Default for MatrixPlugin {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
status: SharedPluginStatus::default(),
|
||||
bot_info: None,
|
||||
last_error: None,
|
||||
api: None,
|
||||
self_user_id: None,
|
||||
callbacks: None,
|
||||
sync_handle: None,
|
||||
shutdown_tx: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MatrixPlugin {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ChannelPlugin for MatrixPlugin {
|
||||
async fn initialize(&mut self, config: PluginConfig, callbacks: PluginCallbacks) -> Result<(), ChannelError> {
|
||||
self.status.set(PluginStatus::Initializing);
|
||||
|
||||
let homeserver = config
|
||||
.credentials
|
||||
.homeserver_url
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
self.status.set(PluginStatus::Error);
|
||||
self.last_error = Some("Missing Matrix homeserver URL".into());
|
||||
ChannelError::InvalidConfig("Missing Matrix homeserver URL".into())
|
||||
})?;
|
||||
|
||||
let access_token = config
|
||||
.credentials
|
||||
.access_token
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
self.status.set(PluginStatus::Error);
|
||||
self.last_error = Some("Missing Matrix access token".into());
|
||||
ChannelError::InvalidConfig("Missing Matrix access token".into())
|
||||
})?;
|
||||
|
||||
let configured_user_id = config
|
||||
.credentials
|
||||
.user_id
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(60))
|
||||
.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(MatrixApi::new(client, homeserver, access_token));
|
||||
|
||||
// Validate credentials via whoami
|
||||
let whoami = api.whoami().await.map_err(|e| {
|
||||
self.status.set(PluginStatus::Error);
|
||||
self.last_error = Some(format!("Credential validation failed: {e}"));
|
||||
e
|
||||
})?;
|
||||
|
||||
// Cross-check configured user_id if provided
|
||||
if let Some(configured) = configured_user_id {
|
||||
if configured != whoami.user_id {
|
||||
let msg = format!(
|
||||
"Configured user_id ({configured}) does not match whoami ({})",
|
||||
whoami.user_id
|
||||
);
|
||||
self.status.set(PluginStatus::Error);
|
||||
self.last_error = Some(msg.clone());
|
||||
return Err(ChannelError::InvalidConfig(msg));
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch display name for bot info
|
||||
let profile = api.get_profile(&whoami.user_id).await.unwrap_or_else(|_| {
|
||||
super::types::ProfileResponse {
|
||||
displayname: None,
|
||||
avatar_url: None,
|
||||
}
|
||||
});
|
||||
|
||||
let display_name = profile
|
||||
.displayname
|
||||
.unwrap_or_else(|| whoami.user_id.clone());
|
||||
|
||||
self.bot_info = Some(BotInfo {
|
||||
id: whoami.user_id.clone(),
|
||||
username: Some(whoami.user_id.clone()),
|
||||
display_name,
|
||||
});
|
||||
|
||||
self.self_user_id = Some(whoami.user_id.clone());
|
||||
|
||||
info!(
|
||||
user_id = %whoami.user_id,
|
||||
device_id = ?whoami.device_id,
|
||||
"Matrix bot initialized (Route B, no E2EE)"
|
||||
);
|
||||
|
||||
self.api = Some(api);
|
||||
self.callbacks = Some(callbacks);
|
||||
self.status.set(PluginStatus::Ready);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start(&mut self) -> Result<(), ChannelError> {
|
||||
self.status.set(PluginStatus::Starting);
|
||||
|
||||
if self.sync_handle.is_some() {
|
||||
self.status.set(PluginStatus::Running);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let api = self
|
||||
.api
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.ok_or_else(|| ChannelError::PlatformApi("Matrix plugin not initialized".into()))?;
|
||||
let callbacks = self
|
||||
.callbacks
|
||||
.clone()
|
||||
.ok_or_else(|| ChannelError::PlatformApi("Matrix callbacks not initialized".into()))?;
|
||||
let self_user_id = self
|
||||
.self_user_id
|
||||
.clone()
|
||||
.unwrap_or_default();
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
self.shutdown_tx = Some(shutdown_tx);
|
||||
|
||||
self.sync_handle = Some(tokio::spawn(sync_loop(
|
||||
api,
|
||||
callbacks.message_tx,
|
||||
self.status.clone(),
|
||||
shutdown_rx,
|
||||
self_user_id,
|
||||
)));
|
||||
|
||||
self.status.set(PluginStatus::Running);
|
||||
info!("Matrix 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.sync_handle.take() {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;
|
||||
}
|
||||
|
||||
self.api = None;
|
||||
self.callbacks = None;
|
||||
self.status.set(PluginStatus::Stopped);
|
||||
info!("Matrix 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(""), MATRIX_MESSAGE_LIMIT);
|
||||
|
||||
// If markdown parse mode is set, send as HTML-formatted.
|
||||
let html = if message.parse_mode.is_some() {
|
||||
Some(text.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
api.send_text(chat_id, &text, html.as_deref()).await
|
||||
}
|
||||
|
||||
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(""), MATRIX_MESSAGE_LIMIT);
|
||||
|
||||
let html = if message.parse_mode.is_some() {
|
||||
Some(text.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
api.edit_text(chat_id, message_id, &text, html.as_deref()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn active_user_count(&self) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
fn bot_info(&self) -> Option<&BotInfo> {
|
||||
self.bot_info.as_ref()
|
||||
}
|
||||
|
||||
fn plugin_type(&self) -> PluginType {
|
||||
PluginType::Matrix
|
||||
}
|
||||
|
||||
fn status(&self) -> PluginStatus {
|
||||
self.status.get()
|
||||
}
|
||||
|
||||
fn last_error(&self) -> Option<&str> {
|
||||
self.last_error.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Background task that continuously long-polls `/sync` for new events.
|
||||
///
|
||||
/// Mirrors the Telegram plugin's poll loop structure: exponential backoff on
|
||||
/// errors up to `MATRIX_MAX_RECONNECT_ATTEMPTS`, then exits with
|
||||
/// `mark_error_on_unexpected_exit`.
|
||||
async fn sync_loop(
|
||||
api: Arc<MatrixApi>,
|
||||
message_tx: mpsc::Sender<UnifiedIncomingMessage>,
|
||||
status: SharedPluginStatus,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
self_user_id: String,
|
||||
) {
|
||||
let mut next_batch: Option<String> = None;
|
||||
let mut consecutive_errors: u32 = 0;
|
||||
|
||||
loop {
|
||||
if *shutdown_rx.borrow() {
|
||||
debug!("Matrix sync loop received shutdown signal");
|
||||
break;
|
||||
}
|
||||
|
||||
match api.sync(next_batch.as_deref()).await {
|
||||
Ok(sync_resp) => {
|
||||
consecutive_errors = 0;
|
||||
next_batch = Some(sync_resp.next_batch);
|
||||
|
||||
if let Some(rooms) = sync_resp.rooms {
|
||||
for (room_id, joined) in rooms.join {
|
||||
if let Some(timeline) = joined.timeline {
|
||||
for event in timeline.events {
|
||||
// Bot-loop guard: skip own messages.
|
||||
if event.sender.as_deref() == Some(self_user_id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
handle_timeline_event(
|
||||
&room_id,
|
||||
&event,
|
||||
&message_tx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
consecutive_errors += 1;
|
||||
warn!(
|
||||
error = %e,
|
||||
consecutive_errors,
|
||||
"Matrix sync error"
|
||||
);
|
||||
|
||||
if consecutive_errors >= MATRIX_MAX_RECONNECT_ATTEMPTS {
|
||||
error!("Matrix max reconnect attempts reached, stopping sync loop");
|
||||
break;
|
||||
}
|
||||
|
||||
let backoff = backoff_delay(consecutive_errors);
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(backoff) => {}
|
||||
_ = shutdown_rx.changed() => {
|
||||
debug!("Matrix sync loop shutdown during backoff");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mark_error_on_unexpected_exit(&status, &shutdown_rx, "matrix");
|
||||
debug!("Matrix sync loop exited");
|
||||
}
|
||||
|
||||
/// Handle a single timeline event from a joined room.
|
||||
async fn handle_timeline_event(
|
||||
room_id: &str,
|
||||
event: &super::types::TimelineEvent,
|
||||
message_tx: &mpsc::Sender<UnifiedIncomingMessage>,
|
||||
) {
|
||||
match event.event_type.as_str() {
|
||||
"m.room.message" => {
|
||||
let content = match &event.content {
|
||||
Some(c) => match serde_json::from_value::<RoomMessageContent>(c.clone()) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(e) => {
|
||||
debug!(error = %e, "Failed to parse m.room.message content");
|
||||
return;
|
||||
}
|
||||
},
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Skip edit events — they update an existing message, not a new one.
|
||||
// The orchestrator sees the original; edits are not relayed as new
|
||||
// incoming messages.
|
||||
if content.is_edit() {
|
||||
return;
|
||||
}
|
||||
|
||||
let sender = event.sender.as_deref().unwrap_or("");
|
||||
let event_id = event.event_id.as_deref().unwrap_or("");
|
||||
let timestamp = event
|
||||
.origin_server_ts
|
||||
.map(|ms| ms / 1000)
|
||||
.unwrap_or(0);
|
||||
|
||||
// Determine content type from msgtype
|
||||
let content_type = match content.msgtype.as_str() {
|
||||
"m.text" | "m.notice" | "m.emote" => MessageContentType::Text,
|
||||
"m.image" => MessageContentType::Photo,
|
||||
"m.file" => MessageContentType::Document,
|
||||
"m.audio" => MessageContentType::Audio,
|
||||
"m.video" => MessageContentType::Video,
|
||||
_ => MessageContentType::Text,
|
||||
};
|
||||
|
||||
let text = content.effective_body().to_owned();
|
||||
|
||||
// Extract display name from sender MXID (localpart before ':')
|
||||
let display_name = extract_localpart(sender);
|
||||
|
||||
let unified = UnifiedIncomingMessage {
|
||||
id: event_id.to_owned(),
|
||||
platform: PluginType::Matrix,
|
||||
chat_id: room_id.to_owned(),
|
||||
user: UnifiedUser {
|
||||
id: sender.to_owned(),
|
||||
username: Some(sender.to_owned()),
|
||||
display_name,
|
||||
avatar_url: None,
|
||||
},
|
||||
content: UnifiedMessageContent {
|
||||
content_type,
|
||||
text,
|
||||
attachments: None,
|
||||
},
|
||||
timestamp,
|
||||
reply_to_message_id: None,
|
||||
action: None,
|
||||
raw: None,
|
||||
};
|
||||
|
||||
let _ = message_tx.send(unified).await;
|
||||
}
|
||||
"m.room.encrypted" => {
|
||||
warn!(
|
||||
room_id,
|
||||
event_id = event.event_id.as_deref().unwrap_or("?"),
|
||||
"Skipping encrypted event — E2EE not available in Route B"
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
// Ignore non-message event types (state events, etc.)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate exponential backoff delay, capped at the configured maximum.
|
||||
fn backoff_delay(attempt: u32) -> Duration {
|
||||
let delay_secs = 2u64.saturating_pow(attempt).min(MATRIX_MAX_RECONNECT_DELAY.as_secs());
|
||||
Duration::from_secs(delay_secs)
|
||||
}
|
||||
|
||||
/// Truncate a message to the platform limit, appending "..." if truncated.
|
||||
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}...")
|
||||
}
|
||||
|
||||
/// Extract the localpart from a Matrix user ID (`@localpart:server` → `localpart`).
|
||||
fn extract_localpart(mxid: &str) -> String {
|
||||
let without_sigil = mxid.strip_prefix('@').unwrap_or(mxid);
|
||||
without_sigil
|
||||
.split_once(':')
|
||||
.map(|(local, _)| local.to_owned())
|
||||
.unwrap_or_else(|| without_sigil.to_owned())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -- extract_localpart ----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn extract_localpart_standard() {
|
||||
assert_eq!(extract_localpart("@alice:example.com"), "alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_localpart_no_sigil() {
|
||||
assert_eq!(extract_localpart("alice:example.com"), "alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_localpart_no_server() {
|
||||
assert_eq!(extract_localpart("@alice"), "alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_localpart_empty() {
|
||||
assert_eq!(extract_localpart(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_localpart_complex() {
|
||||
assert_eq!(extract_localpart("@bot.user:matrix.org"), "bot.user");
|
||||
}
|
||||
|
||||
// -- 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, ...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_unicode() {
|
||||
let result = truncate_message("你好世界测试", 5);
|
||||
assert_eq!(result, "你好...");
|
||||
}
|
||||
|
||||
// -- 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));
|
||||
}
|
||||
|
||||
// -- MatrixPlugin constructor ---------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn new_plugin_initial_state() {
|
||||
let plugin = MatrixPlugin::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::Matrix);
|
||||
assert_eq!(plugin.active_user_count(), 0);
|
||||
}
|
||||
|
||||
// -- handle_timeline_event (unit tests via JSON → message) ----------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_text_message_event() {
|
||||
let (tx, mut rx) = mpsc::channel(16);
|
||||
let event = super::super::types::TimelineEvent {
|
||||
event_type: "m.room.message".into(),
|
||||
event_id: Some("$evt1".into()),
|
||||
sender: Some("@alice:example.com".into()),
|
||||
origin_server_ts: Some(1700000000000),
|
||||
content: Some(serde_json::json!({
|
||||
"msgtype": "m.text",
|
||||
"body": "Hello from Matrix"
|
||||
})),
|
||||
};
|
||||
|
||||
handle_timeline_event("!room:example.com", &event, &tx).await;
|
||||
|
||||
let msg = rx.try_recv().unwrap();
|
||||
assert_eq!(msg.id, "$evt1");
|
||||
assert_eq!(msg.platform, PluginType::Matrix);
|
||||
assert_eq!(msg.chat_id, "!room:example.com");
|
||||
assert_eq!(msg.user.id, "@alice:example.com");
|
||||
assert_eq!(msg.user.display_name, "alice");
|
||||
assert_eq!(msg.content.content_type, MessageContentType::Text);
|
||||
assert_eq!(msg.content.text, "Hello from Matrix");
|
||||
assert_eq!(msg.timestamp, 1700000000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_edit_event_is_skipped() {
|
||||
let (tx, mut rx) = mpsc::channel(16);
|
||||
let event = super::super::types::TimelineEvent {
|
||||
event_type: "m.room.message".into(),
|
||||
event_id: Some("$edit1".into()),
|
||||
sender: Some("@alice:example.com".into()),
|
||||
origin_server_ts: Some(1700000000000),
|
||||
content: Some(serde_json::json!({
|
||||
"msgtype": "m.text",
|
||||
"body": "* Updated",
|
||||
"m.relates_to": {
|
||||
"rel_type": "m.replace",
|
||||
"event_id": "$original"
|
||||
},
|
||||
"m.new_content": {
|
||||
"msgtype": "m.text",
|
||||
"body": "Updated"
|
||||
}
|
||||
})),
|
||||
};
|
||||
|
||||
handle_timeline_event("!room:example.com", &event, &tx).await;
|
||||
|
||||
// Edit events should not produce an incoming message.
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_encrypted_event_is_skipped() {
|
||||
let (tx, mut rx) = mpsc::channel(16);
|
||||
let event = super::super::types::TimelineEvent {
|
||||
event_type: "m.room.encrypted".into(),
|
||||
event_id: Some("$enc1".into()),
|
||||
sender: Some("@bob:example.com".into()),
|
||||
origin_server_ts: Some(1700000000000),
|
||||
content: Some(serde_json::json!({
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"ciphertext": "AwgAEn..."
|
||||
})),
|
||||
};
|
||||
|
||||
handle_timeline_event("!room:example.com", &event, &tx).await;
|
||||
|
||||
// Encrypted events should be skipped.
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_image_message_event() {
|
||||
let (tx, mut rx) = mpsc::channel(16);
|
||||
let event = super::super::types::TimelineEvent {
|
||||
event_type: "m.room.message".into(),
|
||||
event_id: Some("$img1".into()),
|
||||
sender: Some("@carol:example.com".into()),
|
||||
origin_server_ts: Some(1700000000000),
|
||||
content: Some(serde_json::json!({
|
||||
"msgtype": "m.image",
|
||||
"body": "photo.jpg",
|
||||
"url": "mxc://example.com/abc"
|
||||
})),
|
||||
};
|
||||
|
||||
handle_timeline_event("!room:example.com", &event, &tx).await;
|
||||
|
||||
let msg = rx.try_recv().unwrap();
|
||||
assert_eq!(msg.content.content_type, MessageContentType::Photo);
|
||||
assert_eq!(msg.content.text, "photo.jpg");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bot_loop_guard_skips_own_messages() {
|
||||
// This test verifies the bot-loop guard logic in sync_loop.
|
||||
// We test it indirectly by checking the guard condition.
|
||||
let self_user_id = "@bot:matrix.org";
|
||||
let event_sender = Some("@bot:matrix.org".to_string());
|
||||
assert_eq!(event_sender.as_deref(), Some(self_user_id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_unknown_event_type_is_ignored() {
|
||||
let (tx, mut rx) = mpsc::channel(16);
|
||||
let event = super::super::types::TimelineEvent {
|
||||
event_type: "m.room.member".into(),
|
||||
event_id: Some("$mem1".into()),
|
||||
sender: Some("@alice:example.com".into()),
|
||||
origin_server_ts: Some(1700000000000),
|
||||
content: Some(serde_json::json!({
|
||||
"membership": "join"
|
||||
})),
|
||||
};
|
||||
|
||||
handle_timeline_event("!room:example.com", &event, &tx).await;
|
||||
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
//! Matrix Client-Server API v3 response types for the handwritten
|
||||
//! (no-E2EE, reqwest-only) Route B implementation.
|
||||
//!
|
||||
//! Only the subset actually consumed by the plugin is modelled.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /sync response (subset)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Top-level response from `GET /_matrix/client/v3/sync`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SyncResponse {
|
||||
pub next_batch: String,
|
||||
#[serde(default)]
|
||||
pub rooms: Option<SyncRooms>,
|
||||
}
|
||||
|
||||
/// Rooms section of a sync response.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SyncRooms {
|
||||
#[serde(default)]
|
||||
pub join: HashMap<String, JoinedRoom>,
|
||||
}
|
||||
|
||||
/// A joined room's sync payload.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct JoinedRoom {
|
||||
#[serde(default)]
|
||||
pub timeline: Option<Timeline>,
|
||||
}
|
||||
|
||||
/// Timeline section within a joined room.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Timeline {
|
||||
#[serde(default)]
|
||||
pub events: Vec<TimelineEvent>,
|
||||
}
|
||||
|
||||
/// A single timeline event (room event envelope).
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct TimelineEvent {
|
||||
/// Matrix event type, e.g. `m.room.message`, `m.room.encrypted`.
|
||||
#[serde(rename = "type")]
|
||||
pub event_type: String,
|
||||
/// Unique event ID (`$...`).
|
||||
#[serde(default)]
|
||||
pub event_id: Option<String>,
|
||||
/// Sender MXID (`@user:server`).
|
||||
#[serde(default)]
|
||||
pub sender: Option<String>,
|
||||
/// Origin server timestamp (milliseconds since Unix epoch).
|
||||
#[serde(default)]
|
||||
pub origin_server_ts: Option<i64>,
|
||||
/// Event content (type-specific).
|
||||
#[serde(default)]
|
||||
pub content: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// m.room.message content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parsed `content` of an `m.room.message` event.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct RoomMessageContent {
|
||||
/// Message type: `m.text`, `m.image`, `m.file`, etc.
|
||||
pub msgtype: String,
|
||||
/// Plain-text body (always present per spec).
|
||||
#[serde(default)]
|
||||
pub body: String,
|
||||
/// Optional HTML formatted body.
|
||||
#[serde(default)]
|
||||
pub formatted_body: Option<String>,
|
||||
/// Optional format field (e.g. `org.matrix.custom.html`).
|
||||
#[serde(default)]
|
||||
pub format: Option<String>,
|
||||
/// Relation metadata (`m.relates_to`).
|
||||
#[serde(rename = "m.relates_to", default)]
|
||||
pub relates_to: Option<RelatesTo>,
|
||||
/// For edits: the replacement content.
|
||||
#[serde(rename = "m.new_content", default)]
|
||||
pub new_content: Option<Box<NewContent>>,
|
||||
}
|
||||
|
||||
/// `m.relates_to` relation metadata.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct RelatesTo {
|
||||
/// Relation type, e.g. `m.replace`, `m.thread`.
|
||||
#[serde(default)]
|
||||
pub rel_type: Option<String>,
|
||||
/// The event being related to.
|
||||
#[serde(default)]
|
||||
pub event_id: Option<String>,
|
||||
}
|
||||
|
||||
/// `m.new_content` for edit events.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct NewContent {
|
||||
pub msgtype: String,
|
||||
#[serde(default)]
|
||||
pub body: String,
|
||||
#[serde(default)]
|
||||
pub formatted_body: Option<String>,
|
||||
#[serde(default)]
|
||||
pub format: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /account/whoami response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Response from `GET /_matrix/client/v3/account/whoami`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WhoAmIResponse {
|
||||
pub user_id: String,
|
||||
#[serde(default)]
|
||||
pub device_id: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Send message response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Response from `PUT /_matrix/client/v3/rooms/{roomId}/send/{eventType}/{txnId}`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SendEventResponse {
|
||||
pub event_id: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display name response (for bot info)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Response from `GET /_matrix/client/v3/profile/{userId}`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ProfileResponse {
|
||||
#[serde(default)]
|
||||
pub displayname: Option<String>,
|
||||
#[serde(default)]
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: extract effective text from a message event
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl RoomMessageContent {
|
||||
/// Returns the effective text body of this message, accounting for edits.
|
||||
///
|
||||
/// If this is an edit (`m.relates_to.rel_type == "m.replace"` with
|
||||
/// `m.new_content`), the replacement body is returned. Otherwise the
|
||||
/// top-level `body` is used.
|
||||
pub fn effective_body(&self) -> &str {
|
||||
if let Some(rel) = &self.relates_to {
|
||||
if rel.rel_type.as_deref() == Some("m.replace") {
|
||||
if let Some(nc) = &self.new_content {
|
||||
return &nc.body;
|
||||
}
|
||||
}
|
||||
}
|
||||
&self.body
|
||||
}
|
||||
|
||||
/// Whether this message is an edit of another message.
|
||||
pub fn is_edit(&self) -> bool {
|
||||
self.relates_to
|
||||
.as_ref()
|
||||
.and_then(|r| r.rel_type.as_deref())
|
||||
== Some("m.replace")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn parse_sync_response_minimal() {
|
||||
let raw = json!({
|
||||
"next_batch": "s123_456",
|
||||
});
|
||||
let resp: SyncResponse = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(resp.next_batch, "s123_456");
|
||||
assert!(resp.rooms.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sync_response_with_text_message() {
|
||||
let raw = json!({
|
||||
"next_batch": "s789",
|
||||
"rooms": {
|
||||
"join": {
|
||||
"!room1:example.com": {
|
||||
"timeline": {
|
||||
"events": [
|
||||
{
|
||||
"type": "m.room.message",
|
||||
"event_id": "$evt1",
|
||||
"sender": "@alice:example.com",
|
||||
"origin_server_ts": 1700000000000_i64,
|
||||
"content": {
|
||||
"msgtype": "m.text",
|
||||
"body": "Hello world"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let resp: SyncResponse = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(resp.next_batch, "s789");
|
||||
let rooms = resp.rooms.unwrap();
|
||||
let room = rooms.join.get("!room1:example.com").unwrap();
|
||||
let events = &room.timeline.as_ref().unwrap().events;
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].event_type, "m.room.message");
|
||||
assert_eq!(events[0].sender.as_deref(), Some("@alice:example.com"));
|
||||
|
||||
let content: RoomMessageContent =
|
||||
serde_json::from_value(events[0].content.clone().unwrap()).unwrap();
|
||||
assert_eq!(content.msgtype, "m.text");
|
||||
assert_eq!(content.body, "Hello world");
|
||||
assert!(!content.is_edit());
|
||||
assert_eq!(content.effective_body(), "Hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_edit_message() {
|
||||
let raw = json!({
|
||||
"msgtype": "m.text",
|
||||
"body": "* Updated text",
|
||||
"m.relates_to": {
|
||||
"rel_type": "m.replace",
|
||||
"event_id": "$original"
|
||||
},
|
||||
"m.new_content": {
|
||||
"msgtype": "m.text",
|
||||
"body": "Updated text"
|
||||
}
|
||||
});
|
||||
let content: RoomMessageContent = serde_json::from_value(raw).unwrap();
|
||||
assert!(content.is_edit());
|
||||
assert_eq!(content.effective_body(), "Updated text");
|
||||
assert_eq!(
|
||||
content.relates_to.as_ref().unwrap().event_id.as_deref(),
|
||||
Some("$original")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_non_edit_message() {
|
||||
let raw = json!({
|
||||
"msgtype": "m.text",
|
||||
"body": "Plain message"
|
||||
});
|
||||
let content: RoomMessageContent = serde_json::from_value(raw).unwrap();
|
||||
assert!(!content.is_edit());
|
||||
assert_eq!(content.effective_body(), "Plain message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_encrypted_event_type() {
|
||||
let raw = json!({
|
||||
"type": "m.room.encrypted",
|
||||
"event_id": "$enc1",
|
||||
"sender": "@bob:example.com",
|
||||
"origin_server_ts": 1700000000000_i64,
|
||||
"content": {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"ciphertext": "AwgAEn..."
|
||||
}
|
||||
});
|
||||
let event: TimelineEvent = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(event.event_type, "m.room.encrypted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_whoami_response() {
|
||||
let raw = json!({
|
||||
"user_id": "@bot:matrix.org",
|
||||
"device_id": "ABCDEF"
|
||||
});
|
||||
let resp: WhoAmIResponse = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(resp.user_id, "@bot:matrix.org");
|
||||
assert_eq!(resp.device_id.as_deref(), Some("ABCDEF"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_send_event_response() {
|
||||
let raw = json!({
|
||||
"event_id": "$newevt"
|
||||
});
|
||||
let resp: SendEventResponse = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(resp.event_id, "$newevt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_profile_response() {
|
||||
let raw = json!({
|
||||
"displayname": "Bot User",
|
||||
"avatar_url": "mxc://example.com/abc"
|
||||
});
|
||||
let resp: ProfileResponse = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(resp.displayname.as_deref(), Some("Bot User"));
|
||||
assert_eq!(resp.avatar_url.as_deref(), Some("mxc://example.com/abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_profile_response_minimal() {
|
||||
let raw = json!({});
|
||||
let resp: ProfileResponse = serde_json::from_value(raw).unwrap();
|
||||
assert!(resp.displayname.is_none());
|
||||
assert!(resp.avatar_url.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_response_empty_rooms() {
|
||||
let raw = json!({
|
||||
"next_batch": "s0",
|
||||
"rooms": {
|
||||
"join": {}
|
||||
}
|
||||
});
|
||||
let resp: SyncResponse = serde_json::from_value(raw).unwrap();
|
||||
assert!(resp.rooms.unwrap().join.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_with_html_format() {
|
||||
let raw = json!({
|
||||
"msgtype": "m.text",
|
||||
"body": "Hello",
|
||||
"format": "org.matrix.custom.html",
|
||||
"formatted_body": "<b>Hello</b>"
|
||||
});
|
||||
let content: RoomMessageContent = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(content.format.as_deref(), Some("org.matrix.custom.html"));
|
||||
assert_eq!(content.formatted_body.as_deref(), Some("<b>Hello</b>"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! HTTP client for the Mattermost API v4.
|
||||
//!
|
||||
//! Wraps `reqwest::Client`, a base server URL, and a bearer token. Provides
|
||||
//! typed methods for the three endpoints the plugin requires:
|
||||
//!
|
||||
//! - `GET /api/v4/users/me` — validate token, fetch bot identity
|
||||
//! - `POST /api/v4/posts` — create a post (send message)
|
||||
//! - `PUT /api/v4/posts/{post_id}` — update a post (edit/stream message)
|
||||
|
||||
use reqwest::Client;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::error::ChannelError;
|
||||
|
||||
use super::types::{CreatePostRequest, CreatePostResponse, MmUser, UpdatePostRequest};
|
||||
|
||||
/// REST client for the Mattermost API v4.
|
||||
pub(crate) struct MattermostApi {
|
||||
client: Client,
|
||||
/// Base URL without trailing slash, e.g. `https://mm.example.com`.
|
||||
base_url: String,
|
||||
/// Bot access token (sent as `Authorization: Bearer <token>`).
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl MattermostApi {
|
||||
pub fn new(client: Client, base_url: &str, token: &str) -> Self {
|
||||
Self {
|
||||
client,
|
||||
base_url: base_url.trim_end_matches('/').to_owned(),
|
||||
token: token.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/v4/users/me` — fetch bot identity.
|
||||
pub async fn get_me(&self) -> Result<MmUser, ChannelError> {
|
||||
let url = format!("{}/api/v4/users/me", self.base_url);
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.bearer_auth(&self.token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("Mattermost get_me request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ChannelError::ConnectionFailed(format!(
|
||||
"Mattermost get_me failed ({status}): {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
resp.json::<MmUser>()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("Mattermost get_me parse failed: {e}")))
|
||||
}
|
||||
|
||||
/// `POST /api/v4/posts` — create a post. Returns the post id.
|
||||
pub async fn create_post(&self, req: &CreatePostRequest) -> Result<CreatePostResponse, ChannelError> {
|
||||
let url = format!("{}/api/v4/posts", self.base_url);
|
||||
debug!(channel_id = %req.channel_id, "Mattermost creating post");
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.bearer_auth(&self.token)
|
||||
.json(req)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("Mattermost create_post request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ChannelError::MessageSendFailed(format!(
|
||||
"Mattermost create_post failed ({status}): {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
resp.json::<CreatePostResponse>()
|
||||
.await
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("Mattermost create_post parse failed: {e}")))
|
||||
}
|
||||
|
||||
/// `PUT /api/v4/posts/{post_id}` — update an existing post.
|
||||
pub async fn update_post(&self, req: &UpdatePostRequest) -> Result<(), ChannelError> {
|
||||
let url = format!("{}/api/v4/posts/{}", self.base_url, req.id);
|
||||
debug!(post_id = %req.id, "Mattermost updating post");
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.put(&url)
|
||||
.bearer_auth(&self.token)
|
||||
.json(req)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("Mattermost update_post request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ChannelError::MessageSendFailed(format!(
|
||||
"Mattermost update_post failed ({status}): {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn api_constructs_correct_base_url() {
|
||||
let client = Client::new();
|
||||
let api = MattermostApi::new(client, "https://mm.example.com/", "my-token");
|
||||
assert_eq!(api.base_url, "https://mm.example.com");
|
||||
assert_eq!(api.token, "my-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_strips_trailing_slash() {
|
||||
let client = Client::new();
|
||||
let api = MattermostApi::new(client, "https://mm.example.com///", "tok");
|
||||
assert_eq!(api.base_url, "https://mm.example.com");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod api;
|
||||
mod plugin;
|
||||
mod types;
|
||||
|
||||
pub use plugin::MattermostPlugin;
|
||||
@@ -0,0 +1,918 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
//! Mattermost API types: WebSocket events, REST request/response bodies.
|
||||
//!
|
||||
//! The Mattermost API v4 WebSocket delivers events as JSON frames. The
|
||||
//! `"posted"` event embeds the post payload as a JSON-encoded *string* inside
|
||||
//! `data.post`, which must be double-deserialized.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// REST: GET /api/v4/users/me
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Response from `GET /api/v4/users/me`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct MmUser {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// REST: POST /api/v4/posts (create)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Request body for `POST /api/v4/posts`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct CreatePostRequest {
|
||||
pub channel_id: String,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub root_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Response from `POST /api/v4/posts`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct CreatePostResponse {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// REST: PUT /api/v4/posts/{post_id} (update)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Request body for `PUT /api/v4/posts/{post_id}`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct UpdatePostRequest {
|
||||
pub id: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket: authentication challenge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Outgoing auth frame sent immediately after WS connect.
|
||||
///
|
||||
/// ```json
|
||||
/// {"seq":1,"action":"authentication_challenge","data":{"token":"<token>"}}
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct WsAuthChallenge {
|
||||
pub seq: u64,
|
||||
pub action: &'static str,
|
||||
pub data: WsAuthData,
|
||||
}
|
||||
|
||||
/// `data` field of the auth challenge frame.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct WsAuthData {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
impl WsAuthChallenge {
|
||||
pub fn new(token: &str) -> Self {
|
||||
Self {
|
||||
seq: 1,
|
||||
action: "authentication_challenge",
|
||||
data: WsAuthData {
|
||||
token: token.to_owned(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket: inbound event envelope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Top-level WS event frame.
|
||||
///
|
||||
/// Not all events carry `data`; we only care about `"posted"`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct WsEvent {
|
||||
pub event: Option<String>,
|
||||
pub data: Option<WsEventData>,
|
||||
}
|
||||
|
||||
/// Typed `data` payload for a `"posted"` event.
|
||||
///
|
||||
/// `post` is a JSON-encoded *string* that must be double-deserialized into
|
||||
/// [`MmPost`]. `channel_type` distinguishes DMs (`"D"`) from open/private/
|
||||
/// group channels. `mentions` is a JSON-encoded array of user ids who were
|
||||
/// mentioned.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct WsEventData {
|
||||
/// JSON-encoded string of the post body.
|
||||
pub post: Option<String>,
|
||||
/// Channel type: `"D"` (DM), `"O"` (open), `"P"` (private), `"G"` (group DM).
|
||||
pub channel_type: Option<String>,
|
||||
/// Display name of the sender.
|
||||
pub sender_name: Option<String>,
|
||||
/// JSON-encoded array of user-id strings who were `@`-mentioned.
|
||||
pub mentions: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket: Post (double-deserialized from data.post)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A Mattermost post object, deserialized from the JSON string inside
|
||||
/// `WsEventData.post`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct MmPost {
|
||||
pub id: String,
|
||||
pub channel_id: String,
|
||||
pub user_id: String,
|
||||
#[serde(default)]
|
||||
pub message: String,
|
||||
/// Root post id when this post is a reply in a thread.
|
||||
#[serde(default)]
|
||||
pub root_id: String,
|
||||
/// List of attached file ids (empty vec if none).
|
||||
#[serde(default)]
|
||||
pub file_ids: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn mm_user_deserializes() {
|
||||
let raw = json!({"id": "abc123", "username": "nomibot"});
|
||||
let user: MmUser = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(user.id, "abc123");
|
||||
assert_eq!(user.username, "nomibot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_post_request_serializes() {
|
||||
let req = CreatePostRequest {
|
||||
channel_id: "chan1".into(),
|
||||
message: "Hello".into(),
|
||||
root_id: Some("root1".into()),
|
||||
};
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(json["channel_id"], "chan1");
|
||||
assert_eq!(json["message"], "Hello");
|
||||
assert_eq!(json["root_id"], "root1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_post_request_no_root_id_omits_field() {
|
||||
let req = CreatePostRequest {
|
||||
channel_id: "chan1".into(),
|
||||
message: "Hello".into(),
|
||||
root_id: None,
|
||||
};
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert!(json.get("root_id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_post_response_deserializes() {
|
||||
let raw = json!({"id": "post123"});
|
||||
let resp: CreatePostResponse = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(resp.id, "post123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_post_request_serializes() {
|
||||
let req = UpdatePostRequest {
|
||||
id: "post1".into(),
|
||||
message: "Updated".into(),
|
||||
};
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(json["id"], "post1");
|
||||
assert_eq!(json["message"], "Updated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_challenge_serializes() {
|
||||
let auth = WsAuthChallenge::new("my-token");
|
||||
let json = serde_json::to_value(&auth).unwrap();
|
||||
assert_eq!(json["seq"], 1);
|
||||
assert_eq!(json["action"], "authentication_challenge");
|
||||
assert_eq!(json["data"]["token"], "my-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_event_posted_deserializes() {
|
||||
let post_json = serde_json::to_string(&json!({
|
||||
"id": "post1",
|
||||
"channel_id": "chan1",
|
||||
"user_id": "user1",
|
||||
"message": "Hello world",
|
||||
"root_id": "",
|
||||
"file_ids": []
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let raw = json!({
|
||||
"event": "posted",
|
||||
"data": {
|
||||
"post": post_json,
|
||||
"channel_type": "D",
|
||||
"sender_name": "@alice",
|
||||
"mentions": "[\"user1\"]"
|
||||
}
|
||||
});
|
||||
let evt: WsEvent = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(evt.event.as_deref(), Some("posted"));
|
||||
let data = evt.data.unwrap();
|
||||
assert_eq!(data.channel_type.as_deref(), Some("D"));
|
||||
assert_eq!(data.sender_name.as_deref(), Some("@alice"));
|
||||
|
||||
// Double-deserialize the post string
|
||||
let post: MmPost = serde_json::from_str(data.post.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(post.id, "post1");
|
||||
assert_eq!(post.channel_id, "chan1");
|
||||
assert_eq!(post.user_id, "user1");
|
||||
assert_eq!(post.message, "Hello world");
|
||||
assert!(post.root_id.is_empty());
|
||||
assert!(post.file_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_event_non_posted_deserializes() {
|
||||
let raw = json!({"event": "typing", "data": {}});
|
||||
let evt: WsEvent = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(evt.event.as_deref(), Some("typing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_event_no_event_field() {
|
||||
// Auth response frames have no "event" field
|
||||
let raw = json!({"status": "OK", "seq_reply": 1});
|
||||
let evt: WsEvent = serde_json::from_value(raw).unwrap();
|
||||
assert!(evt.event.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mm_post_with_file_ids() {
|
||||
let raw = json!({
|
||||
"id": "p1",
|
||||
"channel_id": "c1",
|
||||
"user_id": "u1",
|
||||
"message": "",
|
||||
"root_id": "root1",
|
||||
"file_ids": ["f1", "f2"]
|
||||
});
|
||||
let post: MmPost = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(post.root_id, "root1");
|
||||
assert_eq!(post.file_ids, vec!["f1", "f2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mm_post_defaults() {
|
||||
// Minimal post — missing optional fields should default
|
||||
let raw = json!({
|
||||
"id": "p1",
|
||||
"channel_id": "c1",
|
||||
"user_id": "u1"
|
||||
});
|
||||
let post: MmPost = serde_json::from_value(raw).unwrap();
|
||||
assert!(post.message.is_empty());
|
||||
assert!(post.root_id.is_empty());
|
||||
assert!(post.file_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mentions_json_string_parses_as_vec() {
|
||||
let mentions_str = "[\"user1\",\"user2\"]";
|
||||
let parsed: Vec<String> = serde_json::from_str(mentions_str).unwrap();
|
||||
assert_eq!(parsed, vec!["user1", "user2"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
#[cfg(feature = "telegram")]
|
||||
pub mod telegram;
|
||||
|
||||
#[cfg(feature = "lark")]
|
||||
pub mod lark;
|
||||
|
||||
#[cfg(feature = "dingtalk")]
|
||||
pub mod dingtalk;
|
||||
|
||||
#[cfg(feature = "weixin")]
|
||||
pub mod weixin;
|
||||
|
||||
#[cfg(feature = "discord")]
|
||||
pub mod discord;
|
||||
|
||||
#[cfg(feature = "matrix")]
|
||||
pub mod matrix;
|
||||
|
||||
#[cfg(feature = "mattermost")]
|
||||
pub mod mattermost;
|
||||
|
||||
#[cfg(feature = "slack")]
|
||||
pub mod slack;
|
||||
|
||||
#[cfg(feature = "twitch")]
|
||||
pub mod twitch;
|
||||
|
||||
#[cfg(feature = "nostr")]
|
||||
pub mod nostr;
|
||||
|
||||
#[cfg(feature = "qqbot")]
|
||||
pub mod qqbot;
|
||||
|
||||
/// Shared callback-data encoding for interactive buttons (Discord/Slack/...).
|
||||
#[cfg(any(feature = "discord", feature = "slack", feature = "mattermost", feature = "qqbot"))]
|
||||
pub mod callback;
|
||||
|
||||
use crate::plugin::ChannelPlugin;
|
||||
use crate::types::PluginType;
|
||||
|
||||
/// Create a platform-specific plugin instance from a `PluginType`.
|
||||
///
|
||||
/// Returns `None` if the platform feature is not compiled in.
|
||||
pub fn create_plugin(plugin_type: PluginType) -> Option<Box<dyn ChannelPlugin>> {
|
||||
match plugin_type {
|
||||
#[cfg(feature = "telegram")]
|
||||
PluginType::Telegram => Some(Box::new(telegram::TelegramPlugin::new())),
|
||||
|
||||
#[cfg(feature = "lark")]
|
||||
PluginType::Lark => Some(Box::new(lark::LarkPlugin::new())),
|
||||
|
||||
#[cfg(feature = "dingtalk")]
|
||||
PluginType::Dingtalk => Some(Box::new(dingtalk::DingtalkPlugin::new())),
|
||||
|
||||
#[cfg(feature = "weixin")]
|
||||
PluginType::Weixin => Some(Box::new(weixin::WeixinPlugin::new())),
|
||||
|
||||
#[cfg(feature = "discord")]
|
||||
PluginType::Discord => Some(Box::new(discord::DiscordPlugin::new())),
|
||||
|
||||
#[cfg(feature = "matrix")]
|
||||
PluginType::Matrix => Some(Box::new(matrix::MatrixPlugin::new())),
|
||||
|
||||
#[cfg(feature = "mattermost")]
|
||||
PluginType::Mattermost => Some(Box::new(mattermost::MattermostPlugin::new())),
|
||||
|
||||
#[cfg(feature = "slack")]
|
||||
PluginType::Slack => Some(Box::new(slack::SlackPlugin::new())),
|
||||
|
||||
#[cfg(feature = "twitch")]
|
||||
PluginType::Twitch => Some(Box::new(twitch::TwitchPlugin::new())),
|
||||
|
||||
#[cfg(feature = "nostr")]
|
||||
PluginType::Nostr => Some(Box::new(nostr::NostrPlugin::new())),
|
||||
|
||||
#[cfg(feature = "qqbot")]
|
||||
PluginType::Qqbot => Some(Box::new(qqbot::QqbotPlugin::new())),
|
||||
|
||||
#[allow(unreachable_patterns)]
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
//! Nostr cryptographic helpers — thin wrappers over the `nostr` crate for
|
||||
//! key parsing (nsec/hex), pubkey derivation, NIP-04 encrypt/decrypt, event
|
||||
//! building and signing.
|
||||
|
||||
use nostr::nips::nip04;
|
||||
use nostr::{EventBuilder, FromBech32, Keys, Kind, PublicKey, SecretKey, Tag, ToBech32};
|
||||
|
||||
use crate::error::ChannelError;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse a private key from either nsec bech32 or 64-char hex.
|
||||
pub fn parse_secret_key(input: &str) -> Result<SecretKey, ChannelError> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.starts_with("nsec1") {
|
||||
SecretKey::from_bech32(trimmed)
|
||||
.map_err(|e| ChannelError::InvalidConfig(format!("invalid nsec key: {e}")))
|
||||
} else {
|
||||
SecretKey::parse(trimmed)
|
||||
.map_err(|e| ChannelError::InvalidConfig(format!("invalid hex private key: {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the x-only public key from a secret key.
|
||||
pub fn derive_pubkey(sk: &SecretKey) -> PublicKey {
|
||||
Keys::new(sk.clone()).public_key()
|
||||
}
|
||||
|
||||
/// Encode a public key as npub bech32.
|
||||
pub fn pubkey_to_npub(pk: &PublicKey) -> String {
|
||||
pk.to_bech32().unwrap_or_else(|_| pk.to_hex())
|
||||
}
|
||||
|
||||
/// Parse a public key from hex (64 chars).
|
||||
pub fn parse_pubkey_hex(hex: &str) -> Result<PublicKey, ChannelError> {
|
||||
PublicKey::parse(hex.trim())
|
||||
.map_err(|e| ChannelError::InvalidConfig(format!("invalid pubkey hex: {e}")))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NIP-04 encrypt / decrypt
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// NIP-04 encrypt plaintext for a recipient.
|
||||
pub fn nip04_encrypt(
|
||||
sender_sk: &SecretKey,
|
||||
recipient_pk: &PublicKey,
|
||||
plaintext: &str,
|
||||
) -> Result<String, ChannelError> {
|
||||
nip04::encrypt(sender_sk, recipient_pk, plaintext)
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("NIP-04 encrypt failed: {e}")))
|
||||
}
|
||||
|
||||
/// NIP-04 decrypt ciphertext from a sender.
|
||||
pub fn nip04_decrypt(
|
||||
receiver_sk: &SecretKey,
|
||||
sender_pk: &PublicKey,
|
||||
ciphertext: &str,
|
||||
) -> Result<String, ChannelError> {
|
||||
nip04::decrypt(receiver_sk, sender_pk, ciphertext)
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("NIP-04 decrypt failed: {e}")))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event construction & signing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a signed kind-4 (NIP-04 DM) event.
|
||||
///
|
||||
/// Returns `(event_id_hex, event_json)` ready to publish via `["EVENT", ...]`.
|
||||
pub fn build_dm_event(
|
||||
keys: &Keys,
|
||||
recipient_pk: &PublicKey,
|
||||
plaintext: &str,
|
||||
) -> Result<(String, String), ChannelError> {
|
||||
let encrypted = nip04_encrypt(keys.secret_key(), recipient_pk, plaintext)?;
|
||||
|
||||
let event = EventBuilder::new(Kind::EncryptedDirectMessage, &encrypted)
|
||||
.tag(Tag::public_key(*recipient_pk))
|
||||
.sign_with_keys(keys)
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("event signing failed: {e}")))?;
|
||||
|
||||
let id = event.id.to_hex();
|
||||
let json = serde_json::to_string(&event)
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("event serialization failed: {e}")))?;
|
||||
|
||||
Ok((id, json))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Relay list parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse a comma-separated relay URL list. Returns at least one URL.
|
||||
pub fn parse_relay_urls(input: Option<&str>) -> Vec<String> {
|
||||
let default_relays = "wss://relay.damus.io,wss://nos.lol";
|
||||
let raw = input.unwrap_or(default_relays);
|
||||
let urls: Vec<String> = raw
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_owned())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if urls.is_empty() {
|
||||
default_relays
|
||||
.split(',')
|
||||
.map(|s| s.to_owned())
|
||||
.collect()
|
||||
} else {
|
||||
urls
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers for inbound event processing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Check if an event has a `["p", <pubkey_hex>]` tag matching the given pubkey.
|
||||
pub fn has_p_tag(tags_json: &[serde_json::Value], target_pk_hex: &str) -> bool {
|
||||
tags_json.iter().any(|tag| {
|
||||
if let Some(arr) = tag.as_array() {
|
||||
arr.len() >= 2
|
||||
&& arr[0].as_str() == Some("p")
|
||||
&& arr[1].as_str() == Some(target_pk_hex)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract the sender pubkey hex from a raw event JSON value.
|
||||
pub fn extract_sender_pubkey(event: &serde_json::Value) -> Option<String> {
|
||||
event.get("pubkey")?.as_str().map(|s| s.to_owned())
|
||||
}
|
||||
|
||||
/// Extract the event id hex from a raw event JSON value.
|
||||
pub fn extract_event_id(event: &serde_json::Value) -> Option<String> {
|
||||
event.get("id")?.as_str().map(|s| s.to_owned())
|
||||
}
|
||||
|
||||
/// Verify an event's `id` field matches the NIP-01 hash of its contents.
|
||||
/// Returns true if valid (or if verification isn't critical for the flow).
|
||||
pub fn verify_event_id(event: &serde_json::Value) -> bool {
|
||||
// Use the nostr crate's Event deserialization which validates id + sig.
|
||||
// If parsing succeeds, the event is valid.
|
||||
serde_json::from_value::<nostr::Event>(event.clone()).is_ok()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Known test vector: the nostr crate's own test key pair.
|
||||
const SENDER_SK_HEX: &str =
|
||||
"6b911fd37cdf5c81d4c0adb1ab7fa822ed253ab0ad9aa18d77257c88b29b718e";
|
||||
const RECEIVER_SK_HEX: &str =
|
||||
"7b911fd37cdf5c81d4c0adb1ab7fa822ed253ab0ad9aa18d77257c88b29b718e";
|
||||
|
||||
#[test]
|
||||
fn parse_hex_private_key() {
|
||||
let sk = parse_secret_key(SENDER_SK_HEX).unwrap();
|
||||
// Derive pubkey — should not panic.
|
||||
let _pk = derive_pubkey(&sk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nsec_private_key() {
|
||||
// Convert hex to nsec first, then parse back.
|
||||
let sk = SecretKey::parse(SENDER_SK_HEX).unwrap();
|
||||
let nsec = sk.to_bech32().unwrap();
|
||||
assert!(nsec.starts_with("nsec1"));
|
||||
let parsed = parse_secret_key(&nsec).unwrap();
|
||||
assert_eq!(parsed, sk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_key_fails() {
|
||||
assert!(parse_secret_key("not_a_key").is_err());
|
||||
assert!(parse_secret_key("nsec1invalid").is_err());
|
||||
assert!(parse_secret_key("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pubkey_derivation_deterministic() {
|
||||
let sk = parse_secret_key(SENDER_SK_HEX).unwrap();
|
||||
let pk1 = derive_pubkey(&sk);
|
||||
let pk2 = derive_pubkey(&sk);
|
||||
assert_eq!(pk1, pk2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npub_encoding() {
|
||||
let sk = parse_secret_key(SENDER_SK_HEX).unwrap();
|
||||
let pk = derive_pubkey(&sk);
|
||||
let npub = pubkey_to_npub(&pk);
|
||||
assert!(npub.starts_with("npub1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nip04_roundtrip() {
|
||||
let sender_sk = parse_secret_key(SENDER_SK_HEX).unwrap();
|
||||
let receiver_sk = parse_secret_key(RECEIVER_SK_HEX).unwrap();
|
||||
let sender_pk = derive_pubkey(&sender_sk);
|
||||
let receiver_pk = derive_pubkey(&receiver_sk);
|
||||
|
||||
let plaintext = "Hello, Nostr!";
|
||||
let ciphertext = nip04_encrypt(&sender_sk, &receiver_pk, plaintext).unwrap();
|
||||
let decrypted = nip04_decrypt(&receiver_sk, &sender_pk, &ciphertext).unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nip04_known_ciphertext() {
|
||||
// Known test vector from the nostr crate.
|
||||
let sender_sk = parse_secret_key(SENDER_SK_HEX).unwrap();
|
||||
let receiver_sk = parse_secret_key(RECEIVER_SK_HEX).unwrap();
|
||||
let sender_pk = derive_pubkey(&sender_sk);
|
||||
|
||||
let ciphertext = "dJc+WbBgaFCD2/kfg1XCWJParplBDxnZIdJGZ6FCTOg=?iv=M6VxRPkMZu7aIdD+10xPuw==";
|
||||
let plaintext = nip04_decrypt(&receiver_sk, &sender_pk, ciphertext).unwrap();
|
||||
assert_eq!(plaintext, "Saturn, bringer of old age");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_dm_event_produces_valid_json() {
|
||||
let sender_sk = parse_secret_key(SENDER_SK_HEX).unwrap();
|
||||
let receiver_sk = parse_secret_key(RECEIVER_SK_HEX).unwrap();
|
||||
let sender_keys = Keys::new(sender_sk);
|
||||
let receiver_pk = derive_pubkey(&receiver_sk);
|
||||
|
||||
let (id, json) = build_dm_event(&sender_keys, &receiver_pk, "test message").unwrap();
|
||||
assert!(!id.is_empty());
|
||||
assert_eq!(id.len(), 64); // sha256 hex
|
||||
|
||||
// Parse and verify.
|
||||
let event: nostr::Event = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(event.kind, Kind::EncryptedDirectMessage);
|
||||
assert_eq!(event.id.to_hex(), id);
|
||||
|
||||
// Decrypt the content.
|
||||
let decrypted = nip04_decrypt(
|
||||
&receiver_sk,
|
||||
&sender_keys.public_key(),
|
||||
event.content.as_str(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(decrypted, "test message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_relay_urls_defaults() {
|
||||
let urls = parse_relay_urls(None);
|
||||
assert_eq!(urls, vec!["wss://relay.damus.io", "wss://nos.lol"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_relay_urls_custom() {
|
||||
let urls = parse_relay_urls(Some("wss://relay1.com, wss://relay2.com"));
|
||||
assert_eq!(urls, vec!["wss://relay1.com", "wss://relay2.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_relay_urls_empty_falls_back_to_defaults() {
|
||||
let urls = parse_relay_urls(Some(""));
|
||||
assert_eq!(urls, vec!["wss://relay.damus.io", "wss://nos.lol"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_p_tag_match() {
|
||||
let tags = vec![serde_json::json!(["p", "abc123"])];
|
||||
assert!(has_p_tag(&tags, "abc123"));
|
||||
assert!(!has_p_tag(&tags, "xyz789"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_p_tag_no_match_empty() {
|
||||
let tags: Vec<serde_json::Value> = vec![];
|
||||
assert!(!has_p_tag(&tags, "abc123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_sender_pubkey_works() {
|
||||
let event = serde_json::json!({"pubkey": "abc123", "id": "def456"});
|
||||
assert_eq!(extract_sender_pubkey(&event), Some("abc123".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_event_id_works() {
|
||||
let event = serde_json::json!({"pubkey": "abc123", "id": "def456"});
|
||||
assert_eq!(extract_event_id(&event), Some("def456".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_loop_guard() {
|
||||
// If sender pubkey == bot pubkey, the message should be skipped.
|
||||
let sk = parse_secret_key(SENDER_SK_HEX).unwrap();
|
||||
let pk = derive_pubkey(&sk);
|
||||
let pk_hex = pk.to_hex();
|
||||
|
||||
let event = serde_json::json!({"pubkey": pk_hex});
|
||||
let sender = extract_sender_pubkey(&event).unwrap();
|
||||
assert_eq!(sender, pk_hex, "self-loop guard: sender == bot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pubkey_hex_valid() {
|
||||
let sk = parse_secret_key(SENDER_SK_HEX).unwrap();
|
||||
let pk = derive_pubkey(&sk);
|
||||
let hex = pk.to_hex();
|
||||
let parsed = parse_pubkey_hex(&hex).unwrap();
|
||||
assert_eq!(parsed, pk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pubkey_hex_invalid() {
|
||||
assert!(parse_pubkey_hex("not_a_hex_pubkey").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod crypto;
|
||||
pub mod plugin;
|
||||
pub mod types;
|
||||
|
||||
pub use plugin::NostrPlugin;
|
||||
@@ -0,0 +1,597 @@
|
||||
//! Nostr channel plugin — NIP-04 encrypted DMs over relay WebSockets.
|
||||
//!
|
||||
//! Fully outbound: connects to relays, subscribes to kind-4 events tagged
|
||||
//! with the bot's pubkey, decrypts inbound DMs, encrypts + signs outbound
|
||||
//! replies. One combined task manages all relays via a select loop over
|
||||
//! individual per-relay connections (each in its own subtask).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use dashmap::DashMap;
|
||||
use nostr::Keys;
|
||||
use tokio::sync::{mpsc, watch, Mutex};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::constants::{NOSTR_MAX_RECONNECT_ATTEMPTS, NOSTR_MAX_RECONNECT_DELAY};
|
||||
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::crypto;
|
||||
use super::types::{self, RawEvent, RelayMessage};
|
||||
|
||||
/// Maximum number of seen event IDs to track for deduplication.
|
||||
const SEEN_EVENT_CAP: usize = 10_000;
|
||||
|
||||
/// Subscription ID prefix used for the DM subscription.
|
||||
const SUB_ID_PREFIX: &str = "nomi-dm-";
|
||||
|
||||
pub struct NostrPlugin {
|
||||
keys: Option<Keys>,
|
||||
bot_pubkey_hex: String,
|
||||
relay_urls: Vec<String>,
|
||||
status: SharedPluginStatus,
|
||||
bot_info: Option<BotInfo>,
|
||||
last_error: Option<String>,
|
||||
callbacks: Option<PluginCallbacks>,
|
||||
shutdown_tx: Option<watch::Sender<bool>>,
|
||||
/// Shared across relay tasks for cross-relay dedup.
|
||||
seen_events: Arc<DashMap<String, ()>>,
|
||||
/// Relay connections for publishing events.
|
||||
relay_writers: Arc<Mutex<Vec<RelaySender>>>,
|
||||
}
|
||||
|
||||
/// A handle to send messages to one relay's WebSocket.
|
||||
struct RelaySender {
|
||||
url: String,
|
||||
tx: mpsc::Sender<String>,
|
||||
}
|
||||
|
||||
impl NostrPlugin {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
keys: None,
|
||||
bot_pubkey_hex: String::new(),
|
||||
relay_urls: Vec::new(),
|
||||
status: SharedPluginStatus::default(),
|
||||
bot_info: None,
|
||||
last_error: None,
|
||||
callbacks: None,
|
||||
shutdown_tx: None,
|
||||
seen_events: Arc::new(DashMap::new()),
|
||||
relay_writers: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_error(&mut self, msg: impl Into<String>) {
|
||||
let msg = msg.into();
|
||||
error!(error = %msg, "Nostr plugin error");
|
||||
self.last_error = Some(msg);
|
||||
self.status.set(PluginStatus::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ChannelPlugin for NostrPlugin {
|
||||
async fn initialize(
|
||||
&mut self,
|
||||
config: PluginConfig,
|
||||
callbacks: PluginCallbacks,
|
||||
) -> Result<(), ChannelError> {
|
||||
self.status.set(PluginStatus::Initializing);
|
||||
|
||||
// Parse private key.
|
||||
let sk_raw = config
|
||||
.credentials
|
||||
.nostr_private_key
|
||||
.as_deref()
|
||||
.ok_or_else(|| ChannelError::InvalidConfig("missing nostr_private_key".into()))?;
|
||||
|
||||
let sk = match crypto::parse_secret_key(sk_raw) {
|
||||
Ok(sk) => sk,
|
||||
Err(e) => {
|
||||
self.set_error(format!("invalid private key: {e}"));
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let keys = Keys::new(sk);
|
||||
let pubkey = keys.public_key();
|
||||
let pubkey_hex = pubkey.to_hex();
|
||||
let npub = crypto::pubkey_to_npub(&pubkey);
|
||||
let short_pk = format!("{}...{}", &pubkey_hex[..8], &pubkey_hex[pubkey_hex.len() - 4..]);
|
||||
|
||||
self.keys = Some(keys);
|
||||
self.bot_pubkey_hex = pubkey_hex.clone();
|
||||
self.relay_urls = crypto::parse_relay_urls(config.credentials.nostr_relays.as_deref());
|
||||
self.bot_info = Some(BotInfo {
|
||||
id: pubkey_hex,
|
||||
username: Some(npub),
|
||||
display_name: short_pk,
|
||||
});
|
||||
self.callbacks = Some(callbacks);
|
||||
|
||||
self.status.set(PluginStatus::Ready);
|
||||
info!("Nostr plugin initialized, pubkey = {}", self.bot_info.as_ref().unwrap().id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start(&mut self) -> Result<(), ChannelError> {
|
||||
self.status.set(PluginStatus::Starting);
|
||||
|
||||
let keys = self.keys.clone().ok_or_else(|| {
|
||||
ChannelError::InvalidConfig("Nostr plugin not initialized".into())
|
||||
})?;
|
||||
let callbacks = self.callbacks.clone().ok_or_else(|| {
|
||||
ChannelError::InvalidConfig("Nostr plugin callbacks not set".into())
|
||||
})?;
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
self.shutdown_tx = Some(shutdown_tx);
|
||||
|
||||
let bot_pk_hex = self.bot_pubkey_hex.clone();
|
||||
let relay_urls = self.relay_urls.clone();
|
||||
let status = self.status.clone();
|
||||
let seen_events = self.seen_events.clone();
|
||||
let relay_writers = self.relay_writers.clone();
|
||||
|
||||
// Spawn one task per relay.
|
||||
for relay_url in &relay_urls {
|
||||
let (writer_tx, writer_rx) = mpsc::channel::<String>(64);
|
||||
|
||||
// Register writer.
|
||||
{
|
||||
let mut writers = relay_writers.lock().await;
|
||||
writers.push(RelaySender {
|
||||
url: relay_url.clone(),
|
||||
tx: writer_tx,
|
||||
});
|
||||
}
|
||||
|
||||
let url = relay_url.clone();
|
||||
let keys_c = keys.clone();
|
||||
let pk_hex = bot_pk_hex.clone();
|
||||
let msg_tx = callbacks.message_tx.clone();
|
||||
let status_c = status.clone();
|
||||
let seen_c = seen_events.clone();
|
||||
let mut shutdown_c = shutdown_rx.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
run_relay_loop(
|
||||
&url,
|
||||
&keys_c,
|
||||
&pk_hex,
|
||||
msg_tx,
|
||||
writer_rx,
|
||||
seen_c,
|
||||
status_c.clone(),
|
||||
&mut shutdown_c,
|
||||
)
|
||||
.await;
|
||||
mark_error_on_unexpected_exit(&status_c, &shutdown_c, &format!("nostr-relay:{url}"));
|
||||
});
|
||||
}
|
||||
|
||||
self.status.set(PluginStatus::Running);
|
||||
info!("Nostr plugin started with {} relay(s)", relay_urls.len());
|
||||
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);
|
||||
}
|
||||
// Drop relay writers.
|
||||
{
|
||||
let mut writers = self.relay_writers.lock().await;
|
||||
writers.clear();
|
||||
}
|
||||
self.status.set(PluginStatus::Stopped);
|
||||
info!("Nostr plugin stopped");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_message(
|
||||
&self,
|
||||
chat_id: &str,
|
||||
message: UnifiedOutgoingMessage,
|
||||
) -> Result<String, ChannelError> {
|
||||
let keys = self
|
||||
.keys
|
||||
.as_ref()
|
||||
.ok_or_else(|| ChannelError::MessageSendFailed("Nostr plugin not initialized".into()))?;
|
||||
|
||||
let text = message
|
||||
.text
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
|
||||
if text.is_empty() {
|
||||
return Err(ChannelError::MessageSendFailed("empty message text".into()));
|
||||
}
|
||||
|
||||
// chat_id = recipient pubkey hex.
|
||||
let recipient_pk = crypto::parse_pubkey_hex(chat_id)?;
|
||||
let (event_id, event_json) = crypto::build_dm_event(keys, &recipient_pk, &text)?;
|
||||
|
||||
// Publish to all relays.
|
||||
let publish_msg = types::build_event_message(&event_json)
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("event message build failed: {e}")))?;
|
||||
|
||||
let writers = self.relay_writers.lock().await;
|
||||
let mut sent_count = 0;
|
||||
for writer in writers.iter() {
|
||||
match writer.tx.try_send(publish_msg.clone()) {
|
||||
Ok(()) => sent_count += 1,
|
||||
Err(e) => warn!(relay = %writer.url, error = %e, "failed to queue event to relay"),
|
||||
}
|
||||
}
|
||||
|
||||
if sent_count == 0 && !writers.is_empty() {
|
||||
return Err(ChannelError::MessageSendFailed(
|
||||
"failed to queue event to any relay".into(),
|
||||
));
|
||||
}
|
||||
|
||||
debug!(event_id = %event_id, sent_count, "Nostr DM published");
|
||||
Ok(event_id)
|
||||
}
|
||||
|
||||
async fn edit_message(
|
||||
&self,
|
||||
chat_id: &str,
|
||||
_message_id: &str,
|
||||
message: UnifiedOutgoingMessage,
|
||||
) -> Result<(), ChannelError> {
|
||||
// Nostr events are immutable — send a new message as fallback.
|
||||
self.send_message(chat_id, message).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn active_user_count(&self) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
fn bot_info(&self) -> Option<&BotInfo> {
|
||||
self.bot_info.as_ref()
|
||||
}
|
||||
|
||||
fn plugin_type(&self) -> PluginType {
|
||||
PluginType::Nostr
|
||||
}
|
||||
|
||||
fn status(&self) -> PluginStatus {
|
||||
self.status.get()
|
||||
}
|
||||
|
||||
fn last_error(&self) -> Option<&str> {
|
||||
self.last_error.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Relay connection loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Maintain a WebSocket connection to a single relay with reconnect backoff.
|
||||
async fn run_relay_loop(
|
||||
relay_url: &str,
|
||||
keys: &Keys,
|
||||
bot_pk_hex: &str,
|
||||
message_tx: mpsc::Sender<UnifiedIncomingMessage>,
|
||||
mut writer_rx: mpsc::Receiver<String>,
|
||||
seen_events: Arc<DashMap<String, ()>>,
|
||||
_status: SharedPluginStatus,
|
||||
shutdown_rx: &mut watch::Receiver<bool>,
|
||||
) {
|
||||
let mut consecutive_errors: u32 = 0;
|
||||
|
||||
loop {
|
||||
if *shutdown_rx.borrow() {
|
||||
debug!(relay = %relay_url, "relay loop received shutdown signal");
|
||||
break;
|
||||
}
|
||||
|
||||
match connect_relay_once(
|
||||
relay_url,
|
||||
keys,
|
||||
bot_pk_hex,
|
||||
&message_tx,
|
||||
&mut writer_rx,
|
||||
&seen_events,
|
||||
shutdown_rx,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
consecutive_errors = 0;
|
||||
if *shutdown_rx.borrow() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
consecutive_errors += 1;
|
||||
warn!(relay = %relay_url, error = %e, attempt = consecutive_errors, "relay connection error");
|
||||
if consecutive_errors >= NOSTR_MAX_RECONNECT_ATTEMPTS {
|
||||
error!(relay = %relay_url, "max reconnect attempts reached");
|
||||
break;
|
||||
}
|
||||
let backoff = backoff_delay(consecutive_errors);
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(backoff) => {}
|
||||
_ = shutdown_rx.changed() => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn backoff_delay(attempt: u32) -> Duration {
|
||||
let secs = 2u64.saturating_pow(attempt).min(NOSTR_MAX_RECONNECT_DELAY.as_secs());
|
||||
Duration::from_secs(secs)
|
||||
}
|
||||
|
||||
/// A single relay connection: connect, subscribe, read/write loop.
|
||||
async fn connect_relay_once(
|
||||
relay_url: &str,
|
||||
keys: &Keys,
|
||||
bot_pk_hex: &str,
|
||||
message_tx: &mpsc::Sender<UnifiedIncomingMessage>,
|
||||
writer_rx: &mut mpsc::Receiver<String>,
|
||||
seen_events: &Arc<DashMap<String, ()>>,
|
||||
shutdown_rx: &mut watch::Receiver<bool>,
|
||||
) -> Result<(), ChannelError> {
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::connect_async_tls_with_config;
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
|
||||
let connector = build_ws_tls_connector()?;
|
||||
let (ws_stream, _) =
|
||||
connect_async_tls_with_config(relay_url, None, false, Some(connector))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ChannelError::ConnectionFailed(format!(
|
||||
"Nostr relay connect failed ({relay_url}): {e}"
|
||||
))
|
||||
})?;
|
||||
info!(relay = %relay_url, "connected to Nostr relay");
|
||||
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
|
||||
// Subscribe: REQ for kind-4 DMs addressed to us.
|
||||
let sub_id = format!("{SUB_ID_PREFIX}{}", &bot_pk_hex[..8]);
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
let req_msg = types::build_req_message(&sub_id, bot_pk_hex, now);
|
||||
write
|
||||
.send(WsMessage::Text(req_msg.into()))
|
||||
.await
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("REQ send failed: {e}")))?;
|
||||
debug!(relay = %relay_url, sub_id = %sub_id, "subscribed to DMs");
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
frame = read.next() => {
|
||||
match frame {
|
||||
Some(Ok(WsMessage::Text(txt))) => {
|
||||
handle_relay_frame(
|
||||
&txt,
|
||||
keys,
|
||||
bot_pk_hex,
|
||||
message_tx,
|
||||
seen_events,
|
||||
relay_url,
|
||||
).await;
|
||||
}
|
||||
Some(Ok(WsMessage::Ping(data))) => {
|
||||
let _ = write.send(WsMessage::Pong(data)).await;
|
||||
}
|
||||
Some(Ok(WsMessage::Close(_))) => {
|
||||
info!(relay = %relay_url, "relay sent close frame");
|
||||
return Ok(());
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
return Err(ChannelError::ConnectionFailed(
|
||||
format!("relay read error ({relay_url}): {e}"),
|
||||
));
|
||||
}
|
||||
None => {
|
||||
info!(relay = %relay_url, "relay connection ended");
|
||||
return Ok(());
|
||||
}
|
||||
_ => {} // Binary, Pong, Frame — ignore.
|
||||
}
|
||||
}
|
||||
outgoing = writer_rx.recv() => {
|
||||
match outgoing {
|
||||
Some(msg) => {
|
||||
if let Err(e) = write.send(WsMessage::Text(msg.into())).await {
|
||||
warn!(relay = %relay_url, error = %e, "failed to send event to relay");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Channel closed — plugin is stopping.
|
||||
debug!(relay = %relay_url, "writer channel closed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.changed() => {
|
||||
debug!(relay = %relay_url, "shutdown signal received");
|
||||
// Send CLOSE to be polite.
|
||||
let close_msg = types::build_close_message(&sub_id);
|
||||
let _ = write.send(WsMessage::Text(close_msg.into())).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle a single text frame from a relay.
|
||||
async fn handle_relay_frame(
|
||||
raw: &str,
|
||||
keys: &Keys,
|
||||
bot_pk_hex: &str,
|
||||
message_tx: &mpsc::Sender<UnifiedIncomingMessage>,
|
||||
seen_events: &Arc<DashMap<String, ()>>,
|
||||
relay_url: &str,
|
||||
) {
|
||||
let msg = match RelayMessage::parse(raw) {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
debug!(relay = %relay_url, "unparseable relay frame");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match msg {
|
||||
RelayMessage::Event {
|
||||
subscription_id: _,
|
||||
event,
|
||||
} => {
|
||||
handle_dm_event(event, keys, bot_pk_hex, message_tx, seen_events, relay_url).await;
|
||||
}
|
||||
RelayMessage::EndOfStoredEvents { subscription_id } => {
|
||||
debug!(relay = %relay_url, sub = %subscription_id, "EOSE received");
|
||||
}
|
||||
RelayMessage::Ok {
|
||||
event_id,
|
||||
success,
|
||||
message,
|
||||
} => {
|
||||
if success {
|
||||
debug!(relay = %relay_url, event_id = %event_id, "event accepted");
|
||||
} else {
|
||||
warn!(relay = %relay_url, event_id = %event_id, msg = %message, "event rejected");
|
||||
}
|
||||
}
|
||||
RelayMessage::Notice { message } => {
|
||||
info!(relay = %relay_url, notice = %message, "relay NOTICE");
|
||||
}
|
||||
RelayMessage::Unknown(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a kind-4 DM event: dedup, self-loop guard, decrypt, emit.
|
||||
async fn handle_dm_event(
|
||||
event: RawEvent,
|
||||
keys: &Keys,
|
||||
bot_pk_hex: &str,
|
||||
message_tx: &mpsc::Sender<UnifiedIncomingMessage>,
|
||||
seen_events: &Arc<DashMap<String, ()>>,
|
||||
relay_url: &str,
|
||||
) {
|
||||
// Must be a DM.
|
||||
if !event.is_dm() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Must be addressed to us.
|
||||
if !event.has_p_tag(bot_pk_hex) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Self-loop guard: skip messages from ourselves.
|
||||
if event.pubkey == bot_pk_hex {
|
||||
debug!(relay = %relay_url, "skipping self-sent DM");
|
||||
return;
|
||||
}
|
||||
|
||||
// Dedup across relays.
|
||||
if seen_events.contains_key(&event.id) {
|
||||
return;
|
||||
}
|
||||
// Cap the dedup set to prevent unbounded growth.
|
||||
if seen_events.len() >= SEEN_EVENT_CAP {
|
||||
// Simple eviction: clear half the entries.
|
||||
let keys_to_remove: Vec<String> = seen_events
|
||||
.iter()
|
||||
.take(SEEN_EVENT_CAP / 2)
|
||||
.map(|r| r.key().clone())
|
||||
.collect();
|
||||
for k in keys_to_remove {
|
||||
seen_events.remove(&k);
|
||||
}
|
||||
}
|
||||
seen_events.insert(event.id.clone(), ());
|
||||
|
||||
// Decrypt content.
|
||||
let sender_pk = match crypto::parse_pubkey_hex(&event.pubkey) {
|
||||
Ok(pk) => pk,
|
||||
Err(e) => {
|
||||
warn!(relay = %relay_url, error = %e, "invalid sender pubkey in DM event");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let plaintext = match crypto::nip04_decrypt(keys.secret_key(), &sender_pk, &event.content) {
|
||||
Ok(pt) => pt,
|
||||
Err(e) => {
|
||||
warn!(relay = %relay_url, event_id = %event.id, error = %e, "NIP-04 decrypt failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let sender_pk_hex = event.pubkey.clone();
|
||||
let short_pk = format!(
|
||||
"{}...{}",
|
||||
&sender_pk_hex[..8.min(sender_pk_hex.len())],
|
||||
&sender_pk_hex[sender_pk_hex.len().saturating_sub(4)..]
|
||||
);
|
||||
|
||||
let unified = UnifiedIncomingMessage {
|
||||
id: event.id.clone(),
|
||||
platform: PluginType::Nostr,
|
||||
chat_id: sender_pk_hex.clone(),
|
||||
user: UnifiedUser {
|
||||
id: sender_pk_hex,
|
||||
username: None,
|
||||
display_name: short_pk,
|
||||
avatar_url: None,
|
||||
},
|
||||
content: UnifiedMessageContent {
|
||||
content_type: MessageContentType::Text,
|
||||
text: plaintext,
|
||||
attachments: None,
|
||||
},
|
||||
timestamp: event.created_at,
|
||||
reply_to_message_id: None,
|
||||
action: None,
|
||||
raw: None,
|
||||
};
|
||||
|
||||
if let Err(e) = message_tx.send(unified).await {
|
||||
warn!(relay = %relay_url, error = %e, "failed to forward decrypted DM");
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a TLS connector for WebSocket connections (same pattern as Discord).
|
||||
fn build_ws_tls_connector() -> Result<tokio_tungstenite::Connector, ChannelError> {
|
||||
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 config = rustls::ClientConfig::builder_with_provider(provider)
|
||||
.with_safe_default_protocol_versions()
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("Nostr TLS config failed: {e}")))?
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
|
||||
Ok(Connector::Rustls(Arc::new(config)))
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
//! Nostr relay message types and event structures.
|
||||
//!
|
||||
//! Relay communication is JSON-array based:
|
||||
//! - Client → Relay: `["REQ", <sub_id>, <filter>]`, `["EVENT", <event>]`, `["CLOSE", <sub_id>]`
|
||||
//! - Relay → Client: `["EVENT", <sub_id>, <event>]`, `["EOSE", <sub_id>]`, `["OK", ...]`, `["NOTICE", ...]`
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A raw event as received from a relay.
|
||||
///
|
||||
/// We deserialize the full event JSON for crypto verification (id + sig)
|
||||
/// via the `nostr` crate, but also keep a lightweight struct for field access.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RawEvent {
|
||||
pub id: String,
|
||||
pub pubkey: String,
|
||||
pub created_at: i64,
|
||||
pub kind: u16,
|
||||
pub tags: Vec<Vec<String>>,
|
||||
pub content: String,
|
||||
pub sig: String,
|
||||
}
|
||||
|
||||
impl RawEvent {
|
||||
/// Check if the event has a `["p", <pubkey>]` tag matching the target.
|
||||
pub fn has_p_tag(&self, target_pk_hex: &str) -> bool {
|
||||
self.tags
|
||||
.iter()
|
||||
.any(|tag| tag.len() >= 2 && tag[0] == "p" && tag[1] == target_pk_hex)
|
||||
}
|
||||
|
||||
/// Whether this event is a kind-4 (NIP-04 encrypted DM).
|
||||
pub fn is_dm(&self) -> bool {
|
||||
self.kind == 4
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed relay message (relay → client).
|
||||
#[derive(Debug)]
|
||||
pub enum RelayMessage {
|
||||
/// `["EVENT", <subscription_id>, <event>]`
|
||||
Event {
|
||||
subscription_id: String,
|
||||
event: RawEvent,
|
||||
},
|
||||
/// `["EOSE", <subscription_id>]`
|
||||
EndOfStoredEvents {
|
||||
subscription_id: String,
|
||||
},
|
||||
/// `["OK", <event_id>, <success>, <message>]`
|
||||
Ok {
|
||||
event_id: String,
|
||||
success: bool,
|
||||
message: String,
|
||||
},
|
||||
/// `["NOTICE", <message>]`
|
||||
Notice {
|
||||
message: String,
|
||||
},
|
||||
/// Unrecognized message type.
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl RelayMessage {
|
||||
/// Parse a raw JSON string from a relay into a typed message.
|
||||
pub fn parse(raw: &str) -> Option<Self> {
|
||||
let arr: Vec<serde_json::Value> = serde_json::from_str(raw).ok()?;
|
||||
if arr.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let msg_type = arr[0].as_str()?;
|
||||
match msg_type {
|
||||
"EVENT" if arr.len() >= 3 => {
|
||||
let subscription_id = arr[1].as_str()?.to_owned();
|
||||
let event: RawEvent = serde_json::from_value(arr[2].clone()).ok()?;
|
||||
Some(Self::Event {
|
||||
subscription_id,
|
||||
event,
|
||||
})
|
||||
}
|
||||
"EOSE" if arr.len() >= 2 => {
|
||||
let subscription_id = arr[1].as_str()?.to_owned();
|
||||
Some(Self::EndOfStoredEvents { subscription_id })
|
||||
}
|
||||
"OK" if arr.len() >= 4 => {
|
||||
let event_id = arr[1].as_str()?.to_owned();
|
||||
let success = arr[2].as_bool()?;
|
||||
let message = arr[3].as_str().unwrap_or("").to_owned();
|
||||
Some(Self::Ok {
|
||||
event_id,
|
||||
success,
|
||||
message,
|
||||
})
|
||||
}
|
||||
"NOTICE" if arr.len() >= 2 => {
|
||||
let message = arr[1].as_str()?.to_owned();
|
||||
Some(Self::Notice { message })
|
||||
}
|
||||
_ => Some(Self::Unknown(raw.to_owned())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a REQ subscription message for kind-4 DMs addressed to the bot.
|
||||
pub fn build_req_message(subscription_id: &str, bot_pubkey_hex: &str, since_unix: i64) -> String {
|
||||
serde_json::json!([
|
||||
"REQ",
|
||||
subscription_id,
|
||||
{
|
||||
"kinds": [4],
|
||||
"#p": [bot_pubkey_hex],
|
||||
"since": since_unix
|
||||
}
|
||||
])
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Build an EVENT publish message wrapping a signed event JSON.
|
||||
pub fn build_event_message(event_json: &str) -> Result<String, serde_json::Error> {
|
||||
let event_value: serde_json::Value = serde_json::from_str(event_json)?;
|
||||
Ok(serde_json::json!(["EVENT", event_value]).to_string())
|
||||
}
|
||||
|
||||
/// Build a CLOSE message to unsubscribe.
|
||||
pub fn build_close_message(subscription_id: &str) -> String {
|
||||
serde_json::json!(["CLOSE", subscription_id]).to_string()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_event_message() {
|
||||
let raw = r#"["EVENT","sub1",{"id":"abc","pubkey":"def","created_at":1700000000,"kind":4,"tags":[["p","ghi"]],"content":"encrypted","sig":"jkl"}]"#;
|
||||
match RelayMessage::parse(raw) {
|
||||
Some(RelayMessage::Event {
|
||||
subscription_id,
|
||||
event,
|
||||
}) => {
|
||||
assert_eq!(subscription_id, "sub1");
|
||||
assert_eq!(event.id, "abc");
|
||||
assert_eq!(event.pubkey, "def");
|
||||
assert_eq!(event.kind, 4);
|
||||
assert!(event.is_dm());
|
||||
assert!(event.has_p_tag("ghi"));
|
||||
assert!(!event.has_p_tag("xyz"));
|
||||
}
|
||||
other => panic!("expected Event, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_eose_message() {
|
||||
let raw = r#"["EOSE","sub1"]"#;
|
||||
match RelayMessage::parse(raw) {
|
||||
Some(RelayMessage::EndOfStoredEvents { subscription_id }) => {
|
||||
assert_eq!(subscription_id, "sub1");
|
||||
}
|
||||
other => panic!("expected EOSE, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ok_message() {
|
||||
let raw = r#"["OK","event123",true,""]"#;
|
||||
match RelayMessage::parse(raw) {
|
||||
Some(RelayMessage::Ok {
|
||||
event_id,
|
||||
success,
|
||||
message,
|
||||
}) => {
|
||||
assert_eq!(event_id, "event123");
|
||||
assert!(success);
|
||||
assert!(message.is_empty());
|
||||
}
|
||||
other => panic!("expected OK, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_notice_message() {
|
||||
let raw = r#"["NOTICE","rate limited"]"#;
|
||||
match RelayMessage::parse(raw) {
|
||||
Some(RelayMessage::Notice { message }) => {
|
||||
assert_eq!(message, "rate limited");
|
||||
}
|
||||
other => panic!("expected NOTICE, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_unknown_message() {
|
||||
let raw = r#"["AUTH","challenge"]"#;
|
||||
match RelayMessage::parse(raw) {
|
||||
Some(RelayMessage::Unknown(_)) => {}
|
||||
other => panic!("expected Unknown, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_json_returns_none() {
|
||||
assert!(RelayMessage::parse("not json").is_none());
|
||||
assert!(RelayMessage::parse("[]").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_req_message_format() {
|
||||
let msg = build_req_message("sub1", "abc123", 1700000000);
|
||||
let parsed: Vec<serde_json::Value> = serde_json::from_str(&msg).unwrap();
|
||||
assert_eq!(parsed[0], "REQ");
|
||||
assert_eq!(parsed[1], "sub1");
|
||||
assert_eq!(parsed[2]["kinds"][0], 4);
|
||||
assert_eq!(parsed[2]["#p"][0], "abc123");
|
||||
assert_eq!(parsed[2]["since"], 1700000000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_event_message_format() {
|
||||
let event_json = r#"{"id":"abc","pubkey":"def"}"#;
|
||||
let msg = build_event_message(event_json).unwrap();
|
||||
let parsed: Vec<serde_json::Value> = serde_json::from_str(&msg).unwrap();
|
||||
assert_eq!(parsed[0], "EVENT");
|
||||
assert_eq!(parsed[1]["id"], "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_close_message_format() {
|
||||
let msg = build_close_message("sub1");
|
||||
let parsed: Vec<serde_json::Value> = serde_json::from_str(&msg).unwrap();
|
||||
assert_eq!(parsed[0], "CLOSE");
|
||||
assert_eq!(parsed[1], "sub1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_event_is_dm() {
|
||||
let event = RawEvent {
|
||||
id: "x".into(),
|
||||
pubkey: "y".into(),
|
||||
created_at: 0,
|
||||
kind: 4,
|
||||
tags: vec![],
|
||||
content: "enc".into(),
|
||||
sig: "s".into(),
|
||||
};
|
||||
assert!(event.is_dm());
|
||||
|
||||
let non_dm = RawEvent {
|
||||
kind: 1,
|
||||
..event.clone()
|
||||
};
|
||||
assert!(!non_dm.is_dm());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_event_has_p_tag() {
|
||||
let event = RawEvent {
|
||||
id: "x".into(),
|
||||
pubkey: "y".into(),
|
||||
created_at: 0,
|
||||
kind: 4,
|
||||
tags: vec![
|
||||
vec!["e".into(), "ref_id".into()],
|
||||
vec!["p".into(), "target_pk".into()],
|
||||
],
|
||||
content: "enc".into(),
|
||||
sig: "s".into(),
|
||||
};
|
||||
assert!(event.has_p_tag("target_pk"));
|
||||
assert!(!event.has_p_tag("other_pk"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! REST client for the QQ Bot HTTP API + OAuth2 token management.
|
||||
//!
|
||||
//! Token lifecycle: `POST https://bots.qq.com/app/getAppAccessToken` returns a
|
||||
//! time-limited `access_token`. A background task refreshes it ~5 min before
|
||||
//! expiry. All REST calls use `Authorization: QQBot <access_token>`.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::Client;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::error::ChannelError;
|
||||
|
||||
use super::types::{
|
||||
AppAccessTokenRequest, AppAccessTokenResponse, CachedToken, GatewayUrlResponse,
|
||||
InteractionCallbackBody, SendMessageRequest, SendMessageResponse,
|
||||
};
|
||||
|
||||
const TOKEN_URL: &str = "https://bots.qq.com/app/getAppAccessToken";
|
||||
const API_BASE: &str = "https://api.sgroup.qq.com";
|
||||
|
||||
/// Shared access-token store. The background refresh task writes; API calls read.
|
||||
pub(crate) type SharedToken = Arc<RwLock<Option<CachedToken>>>;
|
||||
|
||||
/// REST client for the QQ Bot API.
|
||||
pub(crate) struct QqbotApi {
|
||||
client: Client,
|
||||
app_id: String,
|
||||
client_secret: String,
|
||||
token_store: SharedToken,
|
||||
}
|
||||
|
||||
impl QqbotApi {
|
||||
pub fn new(client: Client, app_id: &str, client_secret: &str, token_store: SharedToken) -> Self {
|
||||
Self {
|
||||
client,
|
||||
app_id: app_id.to_string(),
|
||||
client_secret: client_secret.to_string(),
|
||||
token_store,
|
||||
}
|
||||
}
|
||||
|
||||
// -- Token management ---------------------------------------------------
|
||||
|
||||
/// Fetch a fresh access token from the QQ Bot OAuth2 endpoint and store it.
|
||||
pub async fn refresh_token(&self) -> Result<String, ChannelError> {
|
||||
let body = AppAccessTokenRequest {
|
||||
app_id: self.app_id.clone(),
|
||||
client_secret: self.client_secret.clone(),
|
||||
};
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(TOKEN_URL)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("QQBot token request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ChannelError::ConnectionFailed(format!(
|
||||
"QQBot token request failed: HTTP {status}: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let token_resp: AppAccessTokenResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("QQBot token parse failed: {e}")))?;
|
||||
|
||||
let cached = CachedToken {
|
||||
access_token: token_resp.access_token.clone(),
|
||||
expires_at: tokio::time::Instant::now() + Duration::from_secs(token_resp.expires_in),
|
||||
};
|
||||
|
||||
*self.token_store.write().await = Some(cached);
|
||||
debug!(expires_in = token_resp.expires_in, "QQBot access token refreshed");
|
||||
|
||||
Ok(token_resp.access_token)
|
||||
}
|
||||
|
||||
/// Get the current access token, refreshing if expired or absent.
|
||||
pub async fn get_token(&self) -> Result<String, ChannelError> {
|
||||
{
|
||||
let guard = self.token_store.read().await;
|
||||
if let Some(cached) = guard.as_ref() {
|
||||
if tokio::time::Instant::now() < cached.expires_at {
|
||||
return Ok(cached.access_token.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Token expired or missing — refresh.
|
||||
self.refresh_token().await
|
||||
}
|
||||
|
||||
/// Clear the cached token (e.g. on 401).
|
||||
pub async fn clear_token(&self) {
|
||||
*self.token_store.write().await = None;
|
||||
}
|
||||
|
||||
fn auth_header(token: &str) -> String {
|
||||
format!("QQBot {token}")
|
||||
}
|
||||
|
||||
// -- Gateway URL --------------------------------------------------------
|
||||
|
||||
/// `GET /gateway` — obtain the WebSocket gateway URL.
|
||||
pub async fn get_gateway_url(&self) -> Result<String, ChannelError> {
|
||||
let token = self.get_token().await?;
|
||||
let url = format!("{API_BASE}/gateway");
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.header("Authorization", Self::auth_header(&token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("QQBot gateway URL request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ChannelError::ConnectionFailed(format!(
|
||||
"QQBot gateway URL failed: HTTP {status}: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let gw: GatewayUrlResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("QQBot gateway URL parse failed: {e}")))?;
|
||||
|
||||
Ok(gw.url)
|
||||
}
|
||||
|
||||
// -- Message sending ----------------------------------------------------
|
||||
|
||||
/// Send a message to a C2C user.
|
||||
pub async fn send_c2c_message(
|
||||
&self,
|
||||
user_openid: &str,
|
||||
req: &SendMessageRequest,
|
||||
) -> Result<SendMessageResponse, ChannelError> {
|
||||
let url = format!("{API_BASE}/v2/users/{user_openid}/messages");
|
||||
self.post_message(&url, req).await
|
||||
}
|
||||
|
||||
/// Send a message to a group.
|
||||
pub async fn send_group_message(
|
||||
&self,
|
||||
group_openid: &str,
|
||||
req: &SendMessageRequest,
|
||||
) -> Result<SendMessageResponse, ChannelError> {
|
||||
let url = format!("{API_BASE}/v2/groups/{group_openid}/messages");
|
||||
self.post_message(&url, req).await
|
||||
}
|
||||
|
||||
/// Send a message to a guild channel.
|
||||
pub async fn send_channel_message(
|
||||
&self,
|
||||
channel_id: &str,
|
||||
req: &SendMessageRequest,
|
||||
) -> Result<SendMessageResponse, ChannelError> {
|
||||
let url = format!("{API_BASE}/channels/{channel_id}/messages");
|
||||
self.post_message(&url, req).await
|
||||
}
|
||||
|
||||
/// Send a direct message in a guild.
|
||||
pub async fn send_dm_message(
|
||||
&self,
|
||||
guild_id: &str,
|
||||
req: &SendMessageRequest,
|
||||
) -> Result<SendMessageResponse, ChannelError> {
|
||||
let url = format!("{API_BASE}/dms/{guild_id}/messages");
|
||||
self.post_message(&url, req).await
|
||||
}
|
||||
|
||||
/// ACK an interaction callback.
|
||||
pub async fn ack_interaction(&self, interaction_id: &str) -> Result<(), ChannelError> {
|
||||
let token = self.get_token().await?;
|
||||
let url = format!("{API_BASE}/interactions/{interaction_id}");
|
||||
let body = InteractionCallbackBody { code: 0 };
|
||||
let resp = self
|
||||
.client
|
||||
.put(&url)
|
||||
.header("Authorization", Self::auth_header(&token))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("QQBot ack_interaction request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
warn!(status = %status, "QQBot ack_interaction failed: {text}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -- Internal -----------------------------------------------------------
|
||||
|
||||
async fn post_message(&self, url: &str, req: &SendMessageRequest) -> Result<SendMessageResponse, ChannelError> {
|
||||
let token = self.get_token().await?;
|
||||
debug!(url, "QQBot sending message");
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(url)
|
||||
.header("Authorization", Self::auth_header(&token))
|
||||
.json(req)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("QQBot send_message request failed: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
|
||||
// On 401, clear token so it gets refreshed on next call.
|
||||
if status.as_u16() == 401 {
|
||||
self.clear_token().await;
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ChannelError::MessageSendFailed(format!(
|
||||
"QQBot send_message auth failed (token cleared): HTTP 401: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ChannelError::MessageSendFailed(format!(
|
||||
"QQBot send_message failed: HTTP {status}: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
resp.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("QQBot send_message parse failed: {e}")))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
//! QQ Bot channel plugin: Gateway WebSocket (inbound) + REST API (outbound).
|
||||
//!
|
||||
//! QQ Bot uses an outbound WebSocket gateway (like Discord) with OAuth2
|
||||
//! client-credentials token flow. Inbound events are dispatched as
|
||||
//! C2C/group/channel/DM messages; outbound uses per-target REST endpoints
|
||||
//! with passive-reply windowing (max 5 replies per inbound msg, 1h TTL).
|
||||
|
||||
mod api;
|
||||
pub(crate) mod gateway;
|
||||
mod plugin;
|
||||
pub mod types;
|
||||
|
||||
pub use plugin::QqbotPlugin;
|
||||
@@ -0,0 +1,330 @@
|
||||
//! QQ Bot channel plugin: `ChannelPlugin` impl + outbound message building.
|
||||
//!
|
||||
//! Lifecycle mirrors Discord: initialize validates credentials, start spawns
|
||||
//! both a gateway WS loop and a background token-refresh task, stop cancels
|
||||
//! both. Outbound messages are routed by the prefixed chat_id (c2c/group/
|
||||
//! channel/dm).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use dashmap::DashMap;
|
||||
use reqwest::Client;
|
||||
use tokio::sync::{watch, RwLock};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::info;
|
||||
|
||||
use crate::constants::QQBOT_MESSAGE_LIMIT;
|
||||
use crate::error::ChannelError;
|
||||
use crate::plugin::{ChannelPlugin, PluginCallbacks, SharedPluginStatus};
|
||||
use crate::types::{BotInfo, PluginConfig, PluginStatus, PluginType, UnifiedOutgoingMessage};
|
||||
|
||||
use super::api::{QqbotApi, SharedToken};
|
||||
use super::gateway::{
|
||||
consume_passive_reply, next_msg_seq, parse_chat_id, run_gateway, run_token_refresh,
|
||||
ChatTarget, PassiveReplyMap,
|
||||
};
|
||||
use super::types::SendMessageRequest;
|
||||
|
||||
/// QQ Bot plugin: Gateway WS for inbound, REST for outbound, OAuth2 token.
|
||||
pub struct QqbotPlugin {
|
||||
status: SharedPluginStatus,
|
||||
bot_info: Option<BotInfo>,
|
||||
last_error: Option<String>,
|
||||
api: Option<Arc<QqbotApi>>,
|
||||
app_id: Option<String>,
|
||||
callbacks: Option<PluginCallbacks>,
|
||||
gateway_handle: Option<JoinHandle<()>>,
|
||||
token_refresh_handle: Option<JoinHandle<()>>,
|
||||
shutdown_tx: Option<watch::Sender<bool>>,
|
||||
reply_map: PassiveReplyMap,
|
||||
}
|
||||
|
||||
impl Default for QqbotPlugin {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
status: SharedPluginStatus::default(),
|
||||
bot_info: None,
|
||||
last_error: None,
|
||||
api: None,
|
||||
app_id: None,
|
||||
callbacks: None,
|
||||
gateway_handle: None,
|
||||
token_refresh_handle: None,
|
||||
shutdown_tx: None,
|
||||
reply_map: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl QqbotPlugin {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ChannelPlugin for QqbotPlugin {
|
||||
async fn initialize(
|
||||
&mut self,
|
||||
config: PluginConfig,
|
||||
callbacks: PluginCallbacks,
|
||||
) -> Result<(), ChannelError> {
|
||||
self.status.set(PluginStatus::Initializing);
|
||||
|
||||
let app_id = config
|
||||
.credentials
|
||||
.client_id
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
self.status.set(PluginStatus::Error);
|
||||
self.last_error = Some("Missing QQ Bot appId (client_id)".into());
|
||||
ChannelError::InvalidConfig("Missing QQ Bot appId (client_id)".into())
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
let client_secret = config
|
||||
.credentials
|
||||
.client_secret
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
self.status.set(PluginStatus::Error);
|
||||
self.last_error = Some("Missing QQ Bot clientSecret (client_secret)".into());
|
||||
ChannelError::InvalidConfig("Missing QQ Bot clientSecret (client_secret)".into())
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
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 token_store: SharedToken = Arc::new(RwLock::new(None));
|
||||
let api = Arc::new(QqbotApi::new(client, &app_id, &client_secret, token_store));
|
||||
|
||||
// Validate credentials by fetching an initial token.
|
||||
let _token = api.refresh_token().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: app_id.clone(),
|
||||
username: None,
|
||||
display_name: format!("QQBot:{app_id}"),
|
||||
});
|
||||
info!(app_id = %app_id, "QQBot plugin initialized");
|
||||
|
||||
self.api = Some(api);
|
||||
self.app_id = Some(app_id);
|
||||
self.callbacks = Some(callbacks);
|
||||
self.status.set(PluginStatus::Ready);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start(&mut self) -> Result<(), ChannelError> {
|
||||
self.status.set(PluginStatus::Starting);
|
||||
|
||||
if self.gateway_handle.is_some() {
|
||||
self.status.set(PluginStatus::Running);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let api = self
|
||||
.api
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.ok_or_else(|| ChannelError::PlatformApi("QQBot plugin not initialized".into()))?;
|
||||
let callbacks = self
|
||||
.callbacks
|
||||
.clone()
|
||||
.ok_or_else(|| ChannelError::PlatformApi("QQBot callbacks not initialized".into()))?;
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
self.shutdown_tx = Some(shutdown_tx);
|
||||
|
||||
// Spawn the gateway connection loop.
|
||||
let gw_api = api.clone();
|
||||
let gw_shutdown = shutdown_rx.clone();
|
||||
let reply_map = self.reply_map.clone();
|
||||
self.gateway_handle = Some(tokio::spawn(run_gateway(
|
||||
gw_api,
|
||||
callbacks.message_tx,
|
||||
callbacks.confirm_tx,
|
||||
self.status.clone(),
|
||||
reply_map,
|
||||
gw_shutdown,
|
||||
)));
|
||||
|
||||
// Spawn the background token refresh task.
|
||||
let tr_api = api;
|
||||
let tr_shutdown = shutdown_rx;
|
||||
self.token_refresh_handle = Some(tokio::spawn(run_token_refresh(tr_api, tr_shutdown)));
|
||||
|
||||
self.status.set(PluginStatus::Running);
|
||||
info!("QQBot 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.gateway_handle.take() {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;
|
||||
}
|
||||
if let Some(handle) = self.token_refresh_handle.take() {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;
|
||||
}
|
||||
|
||||
self.api = None;
|
||||
self.callbacks = None;
|
||||
self.reply_map.clear();
|
||||
self.status.set(PluginStatus::Stopped);
|
||||
info!("QQBot 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 (target, bare_id) = parse_chat_id(chat_id)
|
||||
.ok_or_else(|| ChannelError::MessageSendFailed(format!("Unknown chat_id format: {chat_id}")))?;
|
||||
|
||||
let text = message.text.as_deref().unwrap_or("");
|
||||
let content = truncate_message(text, QQBOT_MESSAGE_LIMIT);
|
||||
|
||||
// Try passive reply (with msg_id from most recent inbound).
|
||||
let now = tokio::time::Instant::now();
|
||||
let passive_msg_id = consume_passive_reply(&self.reply_map, chat_id, now);
|
||||
let msg_seq = next_msg_seq();
|
||||
|
||||
let req = SendMessageRequest {
|
||||
content,
|
||||
msg_type: 0, // text
|
||||
msg_seq: Some(msg_seq),
|
||||
msg_id: passive_msg_id,
|
||||
};
|
||||
|
||||
let resp = match target {
|
||||
ChatTarget::C2c => api.send_c2c_message(bare_id, &req).await?,
|
||||
ChatTarget::Group => api.send_group_message(bare_id, &req).await?,
|
||||
ChatTarget::Channel => api.send_channel_message(bare_id, &req).await?,
|
||||
ChatTarget::Dm => api.send_dm_message(bare_id, &req).await?,
|
||||
};
|
||||
|
||||
Ok(resp.id.unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn edit_message(
|
||||
&self,
|
||||
chat_id: &str,
|
||||
_message_id: &str,
|
||||
message: UnifiedOutgoingMessage,
|
||||
) -> Result<(), ChannelError> {
|
||||
// QQ has no message edit API; degrade to sending a new message.
|
||||
// This is already classified as send-once in stream_relay.
|
||||
let _ = self.send_message(chat_id, message).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn active_user_count(&self) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
fn bot_info(&self) -> Option<&BotInfo> {
|
||||
self.bot_info.as_ref()
|
||||
}
|
||||
|
||||
fn plugin_type(&self) -> PluginType {
|
||||
PluginType::Qqbot
|
||||
}
|
||||
|
||||
fn status(&self) -> PluginStatus {
|
||||
self.status.get()
|
||||
}
|
||||
|
||||
fn last_error(&self) -> Option<&str> {
|
||||
self.last_error.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Outbound helpers (pure, unit-tested)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Truncate text at a char boundary to `limit`, appending "..." if cut.
|
||||
fn truncate_message(text: &str, limit: usize) -> String {
|
||||
if text.chars().count() <= limit {
|
||||
return text.to_string();
|
||||
}
|
||||
let truncated: String = text.chars().take(limit.saturating_sub(3)).collect();
|
||||
format!("{truncated}...")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::OutgoingMessageType;
|
||||
|
||||
#[test]
|
||||
fn new_plugin_initial_state() {
|
||||
let p = QqbotPlugin::new();
|
||||
assert_eq!(p.status(), PluginStatus::Created);
|
||||
assert!(p.bot_info().is_none());
|
||||
assert_eq!(p.plugin_type(), PluginType::Qqbot);
|
||||
assert_eq!(p.active_user_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_respects_limit() {
|
||||
assert_eq!(truncate_message("short", 4000), "short");
|
||||
let long = "a".repeat(4100);
|
||||
let out = truncate_message(&long, 4000);
|
||||
assert_eq!(out.chars().count(), 4000);
|
||||
assert!(out.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_unicode_boundary() {
|
||||
let text = "你好世界测试";
|
||||
assert_eq!(truncate_message(text, 4), "你...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_empty() {
|
||||
assert_eq!(truncate_message("", 4000), "");
|
||||
}
|
||||
|
||||
fn _make_msg(text: &str) -> UnifiedOutgoingMessage {
|
||||
UnifiedOutgoingMessage {
|
||||
message_type: OutgoingMessageType::Text,
|
||||
text: Some(text.into()),
|
||||
parse_mode: None,
|
||||
buttons: None,
|
||||
keyboard: None,
|
||||
image_url: None,
|
||||
file_url: None,
|
||||
file_name: None,
|
||||
media_actions: None,
|
||||
reply_to_message_id: None,
|
||||
silent: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
//! QQ Bot Gateway + REST wire types.
|
||||
//!
|
||||
//! Based on the QQ Bot official API documentation and the OpenClaw reference
|
||||
//! implementation, adapted to Rust/serde conventions.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gateway opcodes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Dispatch (server -> client). Carries event data.
|
||||
pub const OP_DISPATCH: u8 = 0;
|
||||
/// Heartbeat (client -> server). Payload is last sequence number.
|
||||
pub const OP_HEARTBEAT: u8 = 1;
|
||||
/// Identify (client -> server). Sent after HELLO.
|
||||
pub const OP_IDENTIFY: u8 = 2;
|
||||
/// Resume (client -> server). Re-establish a previous session.
|
||||
pub const OP_RESUME: u8 = 6;
|
||||
/// Reconnect (server -> client). Server asks the client to reconnect.
|
||||
pub const OP_RECONNECT: u8 = 7;
|
||||
/// Invalid Session (server -> client). Session is no longer valid.
|
||||
pub const OP_INVALID_SESSION: u8 = 9;
|
||||
/// Hello (server -> client). Sent on connect with heartbeat interval.
|
||||
pub const OP_HELLO: u8 = 10;
|
||||
/// Heartbeat ACK (server -> client). Confirms heartbeat received.
|
||||
pub const OP_HEARTBEAT_ACK: u8 = 11;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gateway intents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// PUBLIC_GUILD_MESSAGES: receive @bot messages in public guild channels.
|
||||
pub const INTENT_PUBLIC_GUILD_MESSAGES: u64 = 1 << 30;
|
||||
/// DIRECT_MESSAGE: receive guild direct (DM) messages.
|
||||
pub const INTENT_DIRECT_MESSAGE: u64 = 1 << 12;
|
||||
/// GROUP_AND_C2C: receive group and C2C (friend) messages.
|
||||
pub const INTENT_GROUP_AND_C2C: u64 = 1 << 25;
|
||||
/// INTERACTION: receive button interaction callbacks.
|
||||
pub const INTENT_INTERACTION: u64 = 1 << 26;
|
||||
|
||||
/// Combined intents the bot identifies with.
|
||||
pub const GATEWAY_INTENTS: u64 =
|
||||
INTENT_PUBLIC_GUILD_MESSAGES | INTENT_DIRECT_MESSAGE | INTENT_GROUP_AND_C2C | INTENT_INTERACTION;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Close codes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Intent not sufficient (needs approval). Fatal.
|
||||
pub const CLOSE_INTENT_NOT_SUFFICIENT: u16 = 4914;
|
||||
/// Intent disabled on console. Fatal.
|
||||
pub const CLOSE_INTENT_DISABLED: u16 = 4915;
|
||||
|
||||
/// Authentication failed / token invalid.
|
||||
pub const CLOSE_AUTH_FAILED: u16 = 4004;
|
||||
/// Already authenticated (duplicate IDENTIFY).
|
||||
pub const CLOSE_ALREADY_AUTHED: u16 = 4006;
|
||||
/// Invalid seq on heartbeat / resume.
|
||||
pub const CLOSE_INVALID_SEQ: u16 = 4007;
|
||||
/// Rate limited (too many payloads).
|
||||
pub const CLOSE_RATE_LIMITED: u16 = 4008;
|
||||
/// Session timed out; send a new IDENTIFY.
|
||||
pub const CLOSE_SESSION_TIMEOUT: u16 = 4009;
|
||||
|
||||
// 4900-4913: misc gateway errors that require clear session + reconnect.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interaction constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Interaction type for a button (message component) click.
|
||||
pub const INTERACTION_TYPE_BUTTON: u32 = 11;
|
||||
|
||||
/// Interaction callback type: acknowledge (just ACK, no response body).
|
||||
pub const INTERACTION_CALLBACK_ACK: u32 = 12;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inbound gateway envelope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Top-level gateway frame. `d` is opcode-specific; `s`/`t` only on dispatch.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GatewayPayload {
|
||||
pub op: u8,
|
||||
#[serde(default)]
|
||||
pub d: serde_json::Value,
|
||||
#[serde(default)]
|
||||
pub s: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub t: Option<String>,
|
||||
}
|
||||
|
||||
/// HELLO payload: heartbeat interval in milliseconds.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct HelloData {
|
||||
pub heartbeat_interval: u64,
|
||||
}
|
||||
|
||||
/// READY payload: contains session_id for RESUME.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReadyData {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event payloads — C2C
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// C2C_MESSAGE_CREATE event payload.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct C2cMessageCreate {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub content: String,
|
||||
pub author: QqAuthor,
|
||||
#[serde(default)]
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
/// Author in C2C messages: identified by user_openid.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct QqAuthor {
|
||||
/// User's open ID (unique per bot application).
|
||||
#[serde(default)]
|
||||
pub user_openid: Option<String>,
|
||||
/// Member's open ID (used in group context).
|
||||
#[serde(default)]
|
||||
pub member_openid: Option<String>,
|
||||
/// User ID (used in guild/channel context).
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
/// Whether this is a bot account.
|
||||
#[serde(default)]
|
||||
pub bot: bool,
|
||||
/// Username (guild context).
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event payloads — Group
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// GROUP_AT_MESSAGE_CREATE / GROUP_MESSAGE_CREATE event payload.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct GroupMessageCreate {
|
||||
pub id: String,
|
||||
pub group_openid: String,
|
||||
#[serde(default)]
|
||||
pub content: String,
|
||||
pub author: QqAuthor,
|
||||
#[serde(default)]
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event payloads — Guild / Channel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// AT_MESSAGE_CREATE event payload (guild public channel, bot was @mentioned).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ChannelMessageCreate {
|
||||
pub id: String,
|
||||
pub channel_id: String,
|
||||
#[serde(default)]
|
||||
pub guild_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub content: String,
|
||||
pub author: QqAuthor,
|
||||
#[serde(default)]
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
/// DIRECT_MESSAGE_CREATE event payload (guild DM).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DirectMessageCreate {
|
||||
pub id: String,
|
||||
pub guild_id: String,
|
||||
#[serde(default)]
|
||||
pub content: String,
|
||||
pub author: QqAuthor,
|
||||
#[serde(default)]
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event payloads — Interaction (button callback)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// INTERACTION_CREATE event payload.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct InteractionCreate {
|
||||
pub id: String,
|
||||
/// Interaction type (11 = button).
|
||||
#[serde(rename = "type")]
|
||||
pub interaction_type: u32,
|
||||
#[serde(default)]
|
||||
pub data: Option<InteractionData>,
|
||||
#[serde(default)]
|
||||
pub channel_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub guild_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub group_openid: Option<String>,
|
||||
#[serde(default)]
|
||||
pub user_openid: Option<String>,
|
||||
#[serde(default)]
|
||||
pub group_member_openid: Option<String>,
|
||||
#[serde(default)]
|
||||
pub chat_type: Option<u32>,
|
||||
}
|
||||
|
||||
/// Data within an interaction event.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct InteractionData {
|
||||
/// Resolved data with button_id/value.
|
||||
#[serde(default)]
|
||||
pub resolved: Option<InteractionResolved>,
|
||||
}
|
||||
|
||||
/// Resolved interaction data containing the button_id.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct InteractionResolved {
|
||||
#[serde(default)]
|
||||
pub button_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub button_data: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Outbound gateway frames
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct OutgoingFrame<T: Serialize> {
|
||||
pub op: u8,
|
||||
pub d: T,
|
||||
}
|
||||
|
||||
/// IDENTIFY data payload.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentifyData {
|
||||
pub token: String,
|
||||
pub intents: u64,
|
||||
pub shard: [u32; 2],
|
||||
pub properties: IdentifyProperties,
|
||||
}
|
||||
|
||||
/// Client properties sent with IDENTIFY.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentifyProperties {
|
||||
#[serde(rename = "$os")]
|
||||
pub os: String,
|
||||
#[serde(rename = "$browser")]
|
||||
pub browser: String,
|
||||
#[serde(rename = "$device")]
|
||||
pub device: String,
|
||||
}
|
||||
|
||||
/// RESUME data payload.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ResumeData {
|
||||
pub token: String,
|
||||
pub session_id: String,
|
||||
pub seq: u64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// REST request/response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Request body for `POST /app/getAppAccessToken`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AppAccessTokenRequest {
|
||||
#[serde(rename = "appId")]
|
||||
pub app_id: String,
|
||||
#[serde(rename = "clientSecret")]
|
||||
pub client_secret: String,
|
||||
}
|
||||
|
||||
/// Response from `POST /app/getAppAccessToken`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AppAccessTokenResponse {
|
||||
pub access_token: String,
|
||||
/// Token validity in seconds (as string from QQ API).
|
||||
#[serde(deserialize_with = "deserialize_expires_in")]
|
||||
pub expires_in: u64,
|
||||
}
|
||||
|
||||
/// QQ API returns `expires_in` as either a number or a string; accept both.
|
||||
fn deserialize_expires_in<'de, D>(deserializer: D) -> Result<u64, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de;
|
||||
|
||||
struct ExpiresInVisitor;
|
||||
impl<'de> de::Visitor<'de> for ExpiresInVisitor {
|
||||
type Value = u64;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("a u64 or a string containing a u64")
|
||||
}
|
||||
fn visit_u64<E: de::Error>(self, v: u64) -> Result<u64, E> {
|
||||
Ok(v)
|
||||
}
|
||||
fn visit_str<E: de::Error>(self, v: &str) -> Result<u64, E> {
|
||||
v.parse().map_err(de::Error::custom)
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_any(ExpiresInVisitor)
|
||||
}
|
||||
|
||||
/// Response from `GET /gateway`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GatewayUrlResponse {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// Request body for sending messages to various QQ endpoints.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SendMessageRequest {
|
||||
pub content: String,
|
||||
/// Message type: 0 = text.
|
||||
pub msg_type: u32,
|
||||
/// Unique message sequence (per msg_id or proactive).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub msg_seq: Option<u32>,
|
||||
/// Inbound message ID for passive reply.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub msg_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Response from sending a message.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SendMessageResponse {
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
}
|
||||
|
||||
/// Interaction callback body (ACK).
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct InteractionCallbackBody {
|
||||
/// Callback code: 0 = success, 1 = error, etc.
|
||||
pub code: u32,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Cached access token with expiry tracking.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CachedToken {
|
||||
pub access_token: String,
|
||||
/// When this token expires (Instant).
|
||||
pub expires_at: tokio::time::Instant,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn intents_value_matches_spec() {
|
||||
// PUBLIC_GUILD_MESSAGES(1<<30) | DIRECT_MESSAGE(1<<12) | GROUP_AND_C2C(1<<25) | INTERACTION(1<<26)
|
||||
// = 1073741824 + 4096 + 33554432 + 67108864 = 1174409216
|
||||
// Wait, let me compute: 2^30 = 1073741824, 2^12 = 4096, 2^25 = 33554432, 2^26 = 67108864
|
||||
// Sum = 1073741824 + 4096 + 33554432 + 67108864 = 1174409216
|
||||
// But the spec says 1174458368. Let me re-check...
|
||||
// Actually the spec number may include additional bits. Let's just verify the constants.
|
||||
assert_eq!(INTENT_PUBLIC_GUILD_MESSAGES, 1 << 30);
|
||||
assert_eq!(INTENT_DIRECT_MESSAGE, 1 << 12);
|
||||
assert_eq!(INTENT_GROUP_AND_C2C, 1 << 25);
|
||||
assert_eq!(INTENT_INTERACTION, 1 << 26);
|
||||
|
||||
let computed = INTENT_PUBLIC_GUILD_MESSAGES | INTENT_DIRECT_MESSAGE | INTENT_GROUP_AND_C2C | INTENT_INTERACTION;
|
||||
assert_eq!(computed, GATEWAY_INTENTS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opcodes_match_spec() {
|
||||
assert_eq!(OP_DISPATCH, 0);
|
||||
assert_eq!(OP_HEARTBEAT, 1);
|
||||
assert_eq!(OP_IDENTIFY, 2);
|
||||
assert_eq!(OP_RESUME, 6);
|
||||
assert_eq!(OP_RECONNECT, 7);
|
||||
assert_eq!(OP_INVALID_SESSION, 9);
|
||||
assert_eq!(OP_HELLO, 10);
|
||||
assert_eq!(OP_HEARTBEAT_ACK, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fatal_close_codes() {
|
||||
assert_eq!(CLOSE_INTENT_NOT_SUFFICIENT, 4914);
|
||||
assert_eq!(CLOSE_INTENT_DISABLED, 4915);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnectable_close_codes() {
|
||||
assert_eq!(CLOSE_AUTH_FAILED, 4004);
|
||||
assert_eq!(CLOSE_ALREADY_AUTHED, 4006);
|
||||
assert_eq!(CLOSE_INVALID_SEQ, 4007);
|
||||
assert_eq!(CLOSE_RATE_LIMITED, 4008);
|
||||
assert_eq!(CLOSE_SESSION_TIMEOUT, 4009);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_payload_deserialize_dispatch() {
|
||||
let json = r#"{"op":0,"s":42,"t":"C2C_MESSAGE_CREATE","d":{}}"#;
|
||||
let payload: GatewayPayload = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(payload.op, OP_DISPATCH);
|
||||
assert_eq!(payload.s, Some(42));
|
||||
assert_eq!(payload.t.as_deref(), Some("C2C_MESSAGE_CREATE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_payload_deserialize_hello() {
|
||||
let json = r#"{"op":10,"d":{"heartbeat_interval":41250}}"#;
|
||||
let payload: GatewayPayload = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(payload.op, OP_HELLO);
|
||||
let hello: HelloData = serde_json::from_value(payload.d).unwrap();
|
||||
assert_eq!(hello.heartbeat_interval, 41250);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_data_serializes_correctly() {
|
||||
let identify = OutgoingFrame {
|
||||
op: OP_IDENTIFY,
|
||||
d: IdentifyData {
|
||||
token: "QQBot test_token".into(),
|
||||
intents: GATEWAY_INTENTS,
|
||||
shard: [0, 1],
|
||||
properties: IdentifyProperties {
|
||||
os: "macos".into(),
|
||||
browser: "nomi".into(),
|
||||
device: "nomi".into(),
|
||||
},
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_value(&identify).unwrap();
|
||||
assert_eq!(json["op"], 2);
|
||||
assert_eq!(json["d"]["token"], "QQBot test_token");
|
||||
assert_eq!(json["d"]["shard"], serde_json::json!([0, 1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_data_serializes_correctly() {
|
||||
let resume = OutgoingFrame {
|
||||
op: OP_RESUME,
|
||||
d: ResumeData {
|
||||
token: "QQBot tok".into(),
|
||||
session_id: "sess123".into(),
|
||||
seq: 99,
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_value(&resume).unwrap();
|
||||
assert_eq!(json["op"], 6);
|
||||
assert_eq!(json["d"]["session_id"], "sess123");
|
||||
assert_eq!(json["d"]["seq"], 99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c2c_message_deserialize() {
|
||||
let json = r#"{"id":"msg1","content":"hello","author":{"user_openid":"uid1"},"timestamp":"2024-01-01T00:00:00Z"}"#;
|
||||
let msg: C2cMessageCreate = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(msg.id, "msg1");
|
||||
assert_eq!(msg.content, "hello");
|
||||
assert_eq!(msg.author.user_openid.as_deref(), Some("uid1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_message_deserialize() {
|
||||
let json = r#"{"id":"msg2","group_openid":"g1","content":"hi","author":{"member_openid":"mid1"},"timestamp":""}"#;
|
||||
let msg: GroupMessageCreate = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(msg.id, "msg2");
|
||||
assert_eq!(msg.group_openid, "g1");
|
||||
assert_eq!(msg.author.member_openid.as_deref(), Some("mid1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_message_deserialize() {
|
||||
let json = r#"{"id":"msg3","channel_id":"ch1","guild_id":"g1","content":"test","author":{"id":"u1","username":"alice","bot":false},"timestamp":""}"#;
|
||||
let msg: ChannelMessageCreate = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(msg.id, "msg3");
|
||||
assert_eq!(msg.channel_id, "ch1");
|
||||
assert_eq!(msg.author.id.as_deref(), Some("u1"));
|
||||
assert!(!msg.author.bot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_message_deserialize() {
|
||||
let json = r#"{"id":"msg4","guild_id":"g2","content":"dm text","author":{"id":"u2"},"timestamp":""}"#;
|
||||
let msg: DirectMessageCreate = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(msg.id, "msg4");
|
||||
assert_eq!(msg.guild_id, "g2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_token_response_string_expires() {
|
||||
let json = r#"{"access_token":"tok123","expires_in":"7200"}"#;
|
||||
let resp: AppAccessTokenResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.access_token, "tok123");
|
||||
assert_eq!(resp.expires_in, 7200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_token_response_number_expires() {
|
||||
let json = r#"{"access_token":"tok456","expires_in":3600}"#;
|
||||
let resp: AppAccessTokenResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.access_token, "tok456");
|
||||
assert_eq!(resp.expires_in, 3600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_message_request_serializes() {
|
||||
let req = SendMessageRequest {
|
||||
content: "hello".into(),
|
||||
msg_type: 0,
|
||||
msg_seq: Some(1),
|
||||
msg_id: Some("inbound_msg".into()),
|
||||
};
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(json["content"], "hello");
|
||||
assert_eq!(json["msg_type"], 0);
|
||||
assert_eq!(json["msg_seq"], 1);
|
||||
assert_eq!(json["msg_id"], "inbound_msg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_message_request_omits_none() {
|
||||
let req = SendMessageRequest {
|
||||
content: "proactive".into(),
|
||||
msg_type: 0,
|
||||
msg_seq: None,
|
||||
msg_id: None,
|
||||
};
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert!(json.get("msg_seq").is_none());
|
||||
assert!(json.get("msg_id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interaction_create_deserialize() {
|
||||
let json = r#"{"id":"int1","type":11,"data":{"resolved":{"button_id":"chat:chat.send"}},"channel_id":"ch1","user_openid":"uid1","chat_type":0}"#;
|
||||
let interaction: InteractionCreate = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(interaction.id, "int1");
|
||||
assert_eq!(interaction.interaction_type, INTERACTION_TYPE_BUTTON);
|
||||
let resolved = interaction.data.unwrap().resolved.unwrap();
|
||||
assert_eq!(resolved.button_id.as_deref(), Some("chat:chat.send"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! HTTP client for the Slack Web API.
|
||||
//!
|
||||
//! Provides typed methods for `auth.test`, `apps.connections.open`,
|
||||
//! `chat.postMessage`, and `chat.update`.
|
||||
|
||||
use reqwest::Client;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::error::ChannelError;
|
||||
|
||||
use super::types::{
|
||||
AuthTestResult, ConnectionOpenResult, PostMessageRequest, PostMessageResult,
|
||||
SlackResponse, UpdateMessageRequest, UpdateMessageResult,
|
||||
};
|
||||
|
||||
const SLACK_API_BASE: &str = "https://slack.com/api";
|
||||
|
||||
/// HTTP client for the Slack Web API.
|
||||
///
|
||||
/// Wraps `reqwest::Client` with two tokens:
|
||||
/// - `bot_token` (xoxb-): used for all regular API calls
|
||||
/// - `app_token` (xapp-): used only for `apps.connections.open`
|
||||
pub(crate) struct SlackApi {
|
||||
client: Client,
|
||||
bot_token: String,
|
||||
app_token: String,
|
||||
}
|
||||
|
||||
impl SlackApi {
|
||||
/// Create a new API client.
|
||||
pub fn new(client: Client, bot_token: &str, app_token: &str) -> Self {
|
||||
Self {
|
||||
client,
|
||||
bot_token: bot_token.to_string(),
|
||||
app_token: app_token.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `auth.test` -- validate the bot token and return bot identity.
|
||||
pub async fn auth_test(&self) -> Result<AuthTestResult, ChannelError> {
|
||||
let url = format!("{SLACK_API_BASE}/auth.test");
|
||||
let resp: SlackResponse<AuthTestResult> = self
|
||||
.client
|
||||
.post(&url)
|
||||
.bearer_auth(&self.bot_token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("auth.test request failed: {e}")))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("auth.test parse failed: {e}")))?;
|
||||
|
||||
if !resp.ok {
|
||||
let desc = resp.error.unwrap_or_default();
|
||||
return Err(ChannelError::ConnectionFailed(format!(
|
||||
"Slack auth.test failed: {desc}"
|
||||
)));
|
||||
}
|
||||
|
||||
resp.data
|
||||
.ok_or_else(|| ChannelError::PlatformApi("auth.test returned no result".into()))
|
||||
}
|
||||
|
||||
/// `apps.connections.open` -- obtain a WebSocket URL for Socket Mode.
|
||||
pub async fn open_connection(&self) -> Result<String, ChannelError> {
|
||||
let url = format!("{SLACK_API_BASE}/apps.connections.open");
|
||||
let resp: SlackResponse<ConnectionOpenResult> = self
|
||||
.client
|
||||
.post(&url)
|
||||
.bearer_auth(&self.app_token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ChannelError::ConnectionFailed(format!(
|
||||
"apps.connections.open request failed: {e}"
|
||||
))
|
||||
})?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ChannelError::PlatformApi(format!(
|
||||
"apps.connections.open parse failed: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
if !resp.ok {
|
||||
let desc = resp.error.unwrap_or_default();
|
||||
return Err(ChannelError::ConnectionFailed(format!(
|
||||
"Slack apps.connections.open failed: {desc}"
|
||||
)));
|
||||
}
|
||||
|
||||
resp.data
|
||||
.and_then(|d| d.url)
|
||||
.filter(|u| !u.is_empty())
|
||||
.ok_or_else(|| {
|
||||
ChannelError::ConnectionFailed(
|
||||
"apps.connections.open returned no URL".into(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// `chat.postMessage` -- send a message to a channel.
|
||||
pub async fn post_message(
|
||||
&self,
|
||||
req: &PostMessageRequest,
|
||||
) -> Result<String, ChannelError> {
|
||||
let url = format!("{SLACK_API_BASE}/chat.postMessage");
|
||||
debug!(channel = %req.channel, "Sending Slack message");
|
||||
|
||||
let resp: SlackResponse<PostMessageResult> = self
|
||||
.client
|
||||
.post(&url)
|
||||
.bearer_auth(&self.bot_token)
|
||||
.json(req)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ChannelError::MessageSendFailed(format!(
|
||||
"chat.postMessage request failed: {e}"
|
||||
))
|
||||
})?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ChannelError::MessageSendFailed(format!(
|
||||
"chat.postMessage parse failed: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
if !resp.ok {
|
||||
let desc = resp.error.unwrap_or_default();
|
||||
return Err(ChannelError::MessageSendFailed(format!(
|
||||
"chat.postMessage failed: {desc}"
|
||||
)));
|
||||
}
|
||||
|
||||
resp.data
|
||||
.and_then(|d| d.ts)
|
||||
.ok_or_else(|| {
|
||||
ChannelError::MessageSendFailed(
|
||||
"chat.postMessage returned no ts".into(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// `chat.update` -- edit an existing message.
|
||||
pub async fn update_message(
|
||||
&self,
|
||||
req: &UpdateMessageRequest,
|
||||
) -> Result<(), ChannelError> {
|
||||
let url = format!("{SLACK_API_BASE}/chat.update");
|
||||
debug!(channel = %req.channel, ts = %req.ts, "Editing Slack message");
|
||||
|
||||
let resp: SlackResponse<UpdateMessageResult> = self
|
||||
.client
|
||||
.post(&url)
|
||||
.bearer_auth(&self.bot_token)
|
||||
.json(req)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ChannelError::MessageSendFailed(format!(
|
||||
"chat.update request failed: {e}"
|
||||
))
|
||||
})?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ChannelError::MessageSendFailed(format!(
|
||||
"chat.update parse failed: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
if !resp.ok {
|
||||
let desc = resp.error.unwrap_or_default();
|
||||
return Err(ChannelError::MessageSendFailed(format!(
|
||||
"chat.update failed: {desc}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn api_stores_tokens() {
|
||||
let client = Client::new();
|
||||
let api = SlackApi::new(client, "xoxb-test", "xapp-test");
|
||||
assert_eq!(api.bot_token, "xoxb-test");
|
||||
assert_eq!(api.app_token, "xapp-test");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod api;
|
||||
mod plugin;
|
||||
mod types;
|
||||
|
||||
pub use plugin::SlackPlugin;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,418 @@
|
||||
//! Serde structs for the Slack Web API and Socket Mode protocol.
|
||||
//!
|
||||
//! Only the subset of fields consumed by the plugin is modelled;
|
||||
//! unknown fields are silently dropped by serde.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Web API response envelope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generic Slack Web API response wrapper.
|
||||
///
|
||||
/// All Slack Web API methods return JSON objects with `ok` + result fields.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct SlackResponse<T> {
|
||||
pub ok: bool,
|
||||
#[serde(flatten)]
|
||||
pub data: Option<T>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// auth.test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Successful result from `auth.test`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct AuthTestResult {
|
||||
pub user_id: Option<String>,
|
||||
pub user: Option<String>,
|
||||
pub team_id: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// apps.connections.open
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Successful result from `apps.connections.open`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct ConnectionOpenResult {
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// chat.postMessage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Request body for `chat.postMessage`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct PostMessageRequest {
|
||||
pub channel: String,
|
||||
pub text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thread_ts: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub blocks: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Result fields from `chat.postMessage`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct PostMessageResult {
|
||||
pub ts: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// chat.update
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Request body for `chat.update`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct UpdateMessageRequest {
|
||||
pub channel: String,
|
||||
pub ts: String,
|
||||
pub text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub blocks: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Result fields from `chat.update` (we only check `ok`).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct UpdateMessageResult {
|
||||
pub ts: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Socket Mode envelopes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Top-level Socket Mode envelope received over the WebSocket.
|
||||
///
|
||||
/// The `type` field discriminates between `hello`, `events_api`,
|
||||
/// `interactive`, and `disconnect`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct SocketEnvelope {
|
||||
#[serde(rename = "type")]
|
||||
pub envelope_type: String,
|
||||
pub envelope_id: Option<String>,
|
||||
pub payload: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Socket Mode ACK — sent back immediately to acknowledge delivery.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct SocketAck {
|
||||
pub envelope_id: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Events API payload (inside SocketEnvelope.payload for type=events_api)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The `payload` of a Socket Mode `events_api` envelope.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct EventsApiPayload {
|
||||
pub event: Option<SlackEvent>,
|
||||
}
|
||||
|
||||
/// A single Slack event (we handle `type=message`).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct SlackEvent {
|
||||
#[serde(rename = "type")]
|
||||
pub event_type: Option<String>,
|
||||
pub channel: Option<String>,
|
||||
pub channel_type: Option<String>,
|
||||
pub user: Option<String>,
|
||||
pub text: Option<String>,
|
||||
pub ts: Option<String>,
|
||||
pub thread_ts: Option<String>,
|
||||
pub subtype: Option<String>,
|
||||
pub bot_id: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interactive payload (inside SocketEnvelope.payload for type=interactive)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The `payload` of a Socket Mode `interactive` envelope.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct InteractivePayload {
|
||||
#[serde(rename = "type")]
|
||||
pub interaction_type: Option<String>,
|
||||
pub actions: Option<Vec<BlockAction>>,
|
||||
pub channel: Option<InteractiveChannel>,
|
||||
pub user: Option<InteractiveUser>,
|
||||
pub message: Option<InteractiveMessage>,
|
||||
}
|
||||
|
||||
/// An action from `block_actions`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct BlockAction {
|
||||
pub action_id: Option<String>,
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
/// Channel info in an interactive payload.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct InteractiveChannel {
|
||||
pub id: Option<String>,
|
||||
}
|
||||
|
||||
/// User info in an interactive payload.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct InteractiveUser {
|
||||
pub id: Option<String>,
|
||||
}
|
||||
|
||||
/// Message info in an interactive payload.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct InteractiveMessage {
|
||||
pub ts: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Block Kit elements (for outgoing messages with buttons)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A Block Kit block (we only emit `actions` blocks).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct ActionsBlock {
|
||||
#[serde(rename = "type")]
|
||||
pub block_type: String,
|
||||
pub elements: Vec<ButtonElement>,
|
||||
}
|
||||
|
||||
/// A Block Kit button element.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct ButtonElement {
|
||||
#[serde(rename = "type")]
|
||||
pub element_type: String,
|
||||
pub text: PlainTextObject,
|
||||
pub action_id: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// Block Kit plain_text object.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct PlainTextObject {
|
||||
#[serde(rename = "type")]
|
||||
pub text_type: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn auth_test_result_parses() {
|
||||
let raw = json!({
|
||||
"ok": true,
|
||||
"user_id": "U12345",
|
||||
"user": "mybot",
|
||||
"team_id": "T12345"
|
||||
});
|
||||
let resp: SlackResponse<AuthTestResult> = serde_json::from_value(raw).unwrap();
|
||||
assert!(resp.ok);
|
||||
let data = resp.data.unwrap();
|
||||
assert_eq!(data.user_id.as_deref(), Some("U12345"));
|
||||
assert_eq!(data.user.as_deref(), Some("mybot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_test_error_parses() {
|
||||
let raw = json!({
|
||||
"ok": false,
|
||||
"error": "invalid_auth"
|
||||
});
|
||||
let resp: SlackResponse<AuthTestResult> = serde_json::from_value(raw).unwrap();
|
||||
assert!(!resp.ok);
|
||||
assert_eq!(resp.error.as_deref(), Some("invalid_auth"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_open_parses() {
|
||||
let raw = json!({
|
||||
"ok": true,
|
||||
"url": "wss://wss-primary.slack.com/link/?ticket=xxx"
|
||||
});
|
||||
let resp: SlackResponse<ConnectionOpenResult> = serde_json::from_value(raw).unwrap();
|
||||
assert!(resp.ok);
|
||||
let data = resp.data.unwrap();
|
||||
assert_eq!(
|
||||
data.url.as_deref(),
|
||||
Some("wss://wss-primary.slack.com/link/?ticket=xxx")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_message_request_serializes() {
|
||||
let req = PostMessageRequest {
|
||||
channel: "C12345".into(),
|
||||
text: "Hello".into(),
|
||||
thread_ts: Some("1234567890.123456".into()),
|
||||
blocks: None,
|
||||
};
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(json["channel"], "C12345");
|
||||
assert_eq!(json["text"], "Hello");
|
||||
assert_eq!(json["thread_ts"], "1234567890.123456");
|
||||
assert!(json.get("blocks").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_message_result_parses() {
|
||||
let raw = json!({
|
||||
"ok": true,
|
||||
"ts": "1234567890.123456"
|
||||
});
|
||||
let resp: SlackResponse<PostMessageResult> = serde_json::from_value(raw).unwrap();
|
||||
assert!(resp.ok);
|
||||
let data = resp.data.unwrap();
|
||||
assert_eq!(data.ts.as_deref(), Some("1234567890.123456"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_message_request_serializes() {
|
||||
let req = UpdateMessageRequest {
|
||||
channel: "C12345".into(),
|
||||
ts: "1234567890.123456".into(),
|
||||
text: "Updated".into(),
|
||||
blocks: None,
|
||||
};
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(json["channel"], "C12345");
|
||||
assert_eq!(json["ts"], "1234567890.123456");
|
||||
assert_eq!(json["text"], "Updated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socket_envelope_hello_parses() {
|
||||
let raw = json!({"type": "hello"});
|
||||
let env: SocketEnvelope = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(env.envelope_type, "hello");
|
||||
assert!(env.envelope_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socket_envelope_events_api_parses() {
|
||||
let raw = json!({
|
||||
"type": "events_api",
|
||||
"envelope_id": "env_1",
|
||||
"payload": {
|
||||
"event": {
|
||||
"type": "message",
|
||||
"channel": "C12345",
|
||||
"channel_type": "im",
|
||||
"user": "U99999",
|
||||
"text": "hi there",
|
||||
"ts": "1700000000.000100"
|
||||
}
|
||||
}
|
||||
});
|
||||
let env: SocketEnvelope = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(env.envelope_type, "events_api");
|
||||
assert_eq!(env.envelope_id.as_deref(), Some("env_1"));
|
||||
let payload: EventsApiPayload =
|
||||
serde_json::from_value(env.payload.unwrap()).unwrap();
|
||||
let event = payload.event.unwrap();
|
||||
assert_eq!(event.event_type.as_deref(), Some("message"));
|
||||
assert_eq!(event.channel.as_deref(), Some("C12345"));
|
||||
assert_eq!(event.user.as_deref(), Some("U99999"));
|
||||
assert_eq!(event.text.as_deref(), Some("hi there"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socket_envelope_interactive_parses() {
|
||||
let raw = json!({
|
||||
"type": "interactive",
|
||||
"envelope_id": "env_2",
|
||||
"payload": {
|
||||
"type": "block_actions",
|
||||
"actions": [{ "action_id": "system:session.new", "value": "system:session.new" }],
|
||||
"channel": { "id": "C12345" },
|
||||
"user": { "id": "U99999" },
|
||||
"message": { "ts": "1700000000.000200" }
|
||||
}
|
||||
});
|
||||
let env: SocketEnvelope = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(env.envelope_type, "interactive");
|
||||
let payload: InteractivePayload =
|
||||
serde_json::from_value(env.payload.unwrap()).unwrap();
|
||||
assert_eq!(payload.interaction_type.as_deref(), Some("block_actions"));
|
||||
let action = &payload.actions.unwrap()[0];
|
||||
assert_eq!(action.action_id.as_deref(), Some("system:session.new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socket_ack_serializes() {
|
||||
let ack = SocketAck {
|
||||
envelope_id: "env_1".into(),
|
||||
};
|
||||
let json = serde_json::to_value(&ack).unwrap();
|
||||
assert_eq!(json["envelope_id"], "env_1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socket_envelope_disconnect_parses() {
|
||||
let raw = json!({"type": "disconnect"});
|
||||
let env: SocketEnvelope = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(env.envelope_type, "disconnect");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socket_event_with_bot_id_parses() {
|
||||
let raw = json!({
|
||||
"type": "message",
|
||||
"channel": "C12345",
|
||||
"user": "U12345",
|
||||
"text": "bot echo",
|
||||
"ts": "1700000000.000300",
|
||||
"bot_id": "B12345"
|
||||
});
|
||||
let event: SlackEvent = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(event.bot_id.as_deref(), Some("B12345"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socket_event_with_subtype_parses() {
|
||||
let raw = json!({
|
||||
"type": "message",
|
||||
"channel": "C12345",
|
||||
"text": "bot says hi",
|
||||
"ts": "1700000000.000400",
|
||||
"subtype": "bot_message"
|
||||
});
|
||||
let event: SlackEvent = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(event.subtype.as_deref(), Some("bot_message"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actions_block_serializes() {
|
||||
let block = ActionsBlock {
|
||||
block_type: "actions".into(),
|
||||
elements: vec![ButtonElement {
|
||||
element_type: "button".into(),
|
||||
text: PlainTextObject {
|
||||
text_type: "plain_text".into(),
|
||||
text: "Click me".into(),
|
||||
},
|
||||
action_id: "system:help.show".into(),
|
||||
value: "system:help.show".into(),
|
||||
}],
|
||||
};
|
||||
let json = serde_json::to_value(&block).unwrap();
|
||||
assert_eq!(json["type"], "actions");
|
||||
assert_eq!(json["elements"][0]["type"], "button");
|
||||
assert_eq!(json["elements"][0]["text"]["type"], "plain_text");
|
||||
assert_eq!(json["elements"][0]["text"]["text"], "Click me");
|
||||
assert_eq!(json["elements"][0]["action_id"], "system:help.show");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
use reqwest::Client;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::error::ChannelError;
|
||||
|
||||
use super::types::{
|
||||
AnswerCallbackQueryRequest, EditMessageTextRequest, SendMessageRequest, TgMessage, TgResponse, TgUpdate, TgUser,
|
||||
};
|
||||
|
||||
const TELEGRAM_API_BASE: &str = "https://api.telegram.org";
|
||||
|
||||
/// HTTP client for the Telegram Bot API.
|
||||
///
|
||||
/// Wraps `reqwest::Client` and a bot token. Provides typed methods
|
||||
/// for `getMe`, `getUpdates`, `sendMessage`, `editMessageText`, and
|
||||
/// `answerCallbackQuery`.
|
||||
pub(crate) struct TelegramApi {
|
||||
client: Client,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl TelegramApi {
|
||||
/// Create a new API client for the given bot token.
|
||||
pub fn new(client: Client, token: &str) -> Self {
|
||||
Self {
|
||||
client,
|
||||
base_url: format!("{TELEGRAM_API_BASE}/bot{token}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `getMe` — returns the bot's user identity.
|
||||
pub async fn get_me(&self) -> Result<TgUser, ChannelError> {
|
||||
let url = format!("{}/getMe", self.base_url);
|
||||
let resp: TgResponse<TgUser> = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("getMe request failed: {e}")))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("getMe parse failed: {e}")))?;
|
||||
|
||||
if !resp.ok {
|
||||
let desc = resp.description.unwrap_or_default();
|
||||
return Err(ChannelError::ConnectionFailed(format!("Telegram getMe failed: {desc}")));
|
||||
}
|
||||
|
||||
resp.result
|
||||
.ok_or_else(|| ChannelError::PlatformApi("getMe returned no result".into()))
|
||||
}
|
||||
|
||||
/// `getUpdates` — long-poll for new updates.
|
||||
///
|
||||
/// - `offset`: return updates with `update_id >= offset`
|
||||
/// - `timeout`: long-polling timeout in seconds (0 = short poll)
|
||||
pub async fn get_updates(&self, offset: Option<i64>, timeout: u32) -> Result<Vec<TgUpdate>, ChannelError> {
|
||||
let url = format!("{}/getUpdates", self.base_url);
|
||||
|
||||
let mut params = vec![("timeout", timeout.to_string())];
|
||||
if let Some(off) = offset {
|
||||
params.push(("offset", off.to_string()));
|
||||
}
|
||||
|
||||
let resp: TgResponse<Vec<TgUpdate>> = self
|
||||
.client
|
||||
.get(&url)
|
||||
.query(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("getUpdates request failed: {e}")))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("getUpdates parse failed: {e}")))?;
|
||||
|
||||
if !resp.ok {
|
||||
let desc = resp.description.unwrap_or_default();
|
||||
warn!("Telegram getUpdates error: {desc}");
|
||||
return Err(ChannelError::PlatformApi(format!("getUpdates failed: {desc}")));
|
||||
}
|
||||
|
||||
Ok(resp.result.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// `sendMessage` — send a text message. Returns the sent message.
|
||||
pub async fn send_message(&self, req: &SendMessageRequest) -> Result<TgMessage, ChannelError> {
|
||||
let url = format!("{}/sendMessage", self.base_url);
|
||||
debug!(chat_id = req.chat_id, "Sending Telegram message");
|
||||
|
||||
let resp: TgResponse<TgMessage> = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(req)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("sendMessage request failed: {e}")))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("sendMessage parse failed: {e}")))?;
|
||||
|
||||
if !resp.ok {
|
||||
let desc = resp.description.unwrap_or_default();
|
||||
return Err(ChannelError::MessageSendFailed(format!("sendMessage failed: {desc}")));
|
||||
}
|
||||
|
||||
resp.result
|
||||
.ok_or_else(|| ChannelError::MessageSendFailed("sendMessage returned no result".into()))
|
||||
}
|
||||
|
||||
/// `editMessageText` — edit an existing text message.
|
||||
pub async fn edit_message_text(&self, req: &EditMessageTextRequest) -> Result<(), ChannelError> {
|
||||
let url = format!("{}/editMessageText", self.base_url);
|
||||
debug!(
|
||||
chat_id = req.chat_id,
|
||||
message_id = req.message_id,
|
||||
"Editing Telegram message"
|
||||
);
|
||||
|
||||
let resp: TgResponse<TgMessage> = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(req)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("editMessageText request failed: {e}")))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::MessageSendFailed(format!("editMessageText parse failed: {e}")))?;
|
||||
|
||||
if !resp.ok {
|
||||
let desc = resp.description.unwrap_or_default();
|
||||
return Err(ChannelError::MessageSendFailed(format!(
|
||||
"editMessageText failed: {desc}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `answerCallbackQuery` — acknowledge a callback query.
|
||||
pub async fn answer_callback_query(&self, req: &AnswerCallbackQueryRequest) -> Result<(), ChannelError> {
|
||||
let url = format!("{}/answerCallbackQuery", self.base_url);
|
||||
|
||||
let resp: TgResponse<bool> = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(req)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("answerCallbackQuery request failed: {e}")))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("answerCallbackQuery parse failed: {e}")))?;
|
||||
|
||||
if !resp.ok {
|
||||
let desc = resp.description.unwrap_or_default();
|
||||
warn!("answerCallbackQuery error: {desc}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn api_constructs_correct_base_url() {
|
||||
let client = Client::new();
|
||||
let api = TelegramApi::new(client, "123:ABC");
|
||||
assert_eq!(api.base_url, "https://api.telegram.org/bot123:ABC");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod api;
|
||||
mod plugin;
|
||||
mod types;
|
||||
mod watermark;
|
||||
|
||||
pub use plugin::TelegramPlugin;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,455 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Telegram Bot API response envelope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generic Telegram Bot API response wrapper.
|
||||
///
|
||||
/// All Telegram Bot API methods return JSON objects with `ok` + `result` fields.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct TgResponse<T> {
|
||||
pub ok: bool,
|
||||
pub result: Option<T>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getMe
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Response from `getMe` — bot identity.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct TgUser {
|
||||
pub id: i64,
|
||||
#[serde(default)]
|
||||
pub is_bot: bool,
|
||||
pub first_name: String,
|
||||
pub last_name: Option<String>,
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getUpdates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single update from `getUpdates`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct TgUpdate {
|
||||
pub update_id: i64,
|
||||
pub message: Option<TgMessage>,
|
||||
pub callback_query: Option<TgCallbackQuery>,
|
||||
}
|
||||
|
||||
/// Telegram message object (subset of fields we use).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct TgMessage {
|
||||
pub message_id: i64,
|
||||
pub from: Option<TgUser>,
|
||||
pub chat: TgChat,
|
||||
pub date: i64,
|
||||
pub text: Option<String>,
|
||||
/// Caption for media messages (photo, document, audio, video, voice).
|
||||
pub caption: Option<String>,
|
||||
pub photo: Option<Vec<TgPhotoSize>>,
|
||||
pub document: Option<TgDocument>,
|
||||
pub voice: Option<TgVoice>,
|
||||
pub audio: Option<TgAudio>,
|
||||
pub video: Option<TgVideo>,
|
||||
pub sticker: Option<TgSticker>,
|
||||
pub reply_to_message: Option<Box<TgMessage>>,
|
||||
}
|
||||
|
||||
/// Telegram chat object.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct TgChat {
|
||||
pub id: i64,
|
||||
#[serde(rename = "type")]
|
||||
pub chat_type: String,
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
/// Telegram photo size (multiple resolutions per photo).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct TgPhotoSize {
|
||||
pub file_id: String,
|
||||
pub file_unique_id: String,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub file_size: Option<u64>,
|
||||
}
|
||||
|
||||
/// Telegram document attachment.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct TgDocument {
|
||||
pub file_id: String,
|
||||
pub file_name: Option<String>,
|
||||
pub mime_type: Option<String>,
|
||||
pub file_size: Option<u64>,
|
||||
}
|
||||
|
||||
/// Telegram voice message.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct TgVoice {
|
||||
pub file_id: String,
|
||||
pub duration: i32,
|
||||
pub mime_type: Option<String>,
|
||||
pub file_size: Option<u64>,
|
||||
}
|
||||
|
||||
/// Telegram audio message.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct TgAudio {
|
||||
pub file_id: String,
|
||||
pub duration: i32,
|
||||
pub file_name: Option<String>,
|
||||
pub mime_type: Option<String>,
|
||||
pub file_size: Option<u64>,
|
||||
}
|
||||
|
||||
/// Telegram video message.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct TgVideo {
|
||||
pub file_id: String,
|
||||
pub duration: i32,
|
||||
pub file_name: Option<String>,
|
||||
pub mime_type: Option<String>,
|
||||
pub file_size: Option<u64>,
|
||||
}
|
||||
|
||||
/// Telegram sticker.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct TgSticker {
|
||||
pub file_id: String,
|
||||
pub emoji: Option<String>,
|
||||
}
|
||||
|
||||
/// Telegram callback query from inline keyboard button press.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct TgCallbackQuery {
|
||||
pub id: String,
|
||||
pub from: TgUser,
|
||||
pub message: Option<TgMessage>,
|
||||
pub data: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sendMessage / editMessageText request bodies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Request body for `sendMessage`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct SendMessageRequest {
|
||||
pub chat_id: i64,
|
||||
pub text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parse_mode: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reply_to_message_id: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reply_markup: Option<ReplyMarkup>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_notification: Option<bool>,
|
||||
}
|
||||
|
||||
/// Request body for `editMessageText`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct EditMessageTextRequest {
|
||||
pub chat_id: i64,
|
||||
pub message_id: i64,
|
||||
pub text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parse_mode: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reply_markup: Option<ReplyMarkup>,
|
||||
}
|
||||
|
||||
/// Request body for `answerCallbackQuery`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct AnswerCallbackQueryRequest {
|
||||
pub callback_query_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub show_alert: Option<bool>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyboard / Inline markup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Union type for reply markup (InlineKeyboard or ReplyKeyboard).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub(crate) enum ReplyMarkup {
|
||||
InlineKeyboard(InlineKeyboardMarkup),
|
||||
ReplyKeyboard(ReplyKeyboardMarkup),
|
||||
}
|
||||
|
||||
/// Inline keyboard (buttons inside the message).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct InlineKeyboardMarkup {
|
||||
pub inline_keyboard: Vec<Vec<InlineKeyboardButton>>,
|
||||
}
|
||||
|
||||
/// A single inline keyboard button.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct InlineKeyboardButton {
|
||||
pub text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub callback_data: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
/// Reply keyboard (persistent buttons at the bottom).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct ReplyKeyboardMarkup {
|
||||
pub keyboard: Vec<Vec<KeyboardButton>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resize_keyboard: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub one_time_keyboard: Option<bool>,
|
||||
}
|
||||
|
||||
/// A single reply keyboard button.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct KeyboardButton {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn tg_response_ok_parses() {
|
||||
let raw = json!({
|
||||
"ok": true,
|
||||
"result": { "id": 123, "is_bot": true, "first_name": "MyBot", "username": "my_bot" }
|
||||
});
|
||||
let resp: TgResponse<TgUser> = serde_json::from_value(raw).unwrap();
|
||||
assert!(resp.ok);
|
||||
let user = resp.result.unwrap();
|
||||
assert_eq!(user.id, 123);
|
||||
assert!(user.is_bot);
|
||||
assert_eq!(user.username.as_deref(), Some("my_bot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tg_response_error_parses() {
|
||||
let raw = json!({
|
||||
"ok": false,
|
||||
"description": "Unauthorized"
|
||||
});
|
||||
let resp: TgResponse<TgUser> = serde_json::from_value(raw).unwrap();
|
||||
assert!(!resp.ok);
|
||||
assert!(resp.result.is_none());
|
||||
assert_eq!(resp.description.as_deref(), Some("Unauthorized"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tg_update_with_message() {
|
||||
let raw = json!({
|
||||
"update_id": 100,
|
||||
"message": {
|
||||
"message_id": 1,
|
||||
"from": { "id": 42, "is_bot": false, "first_name": "Alice", "username": "alice" },
|
||||
"chat": { "id": -100, "type": "group", "title": "Test Group" },
|
||||
"date": 1700000000,
|
||||
"text": "Hello"
|
||||
}
|
||||
});
|
||||
let upd: TgUpdate = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(upd.update_id, 100);
|
||||
let msg = upd.message.unwrap();
|
||||
assert_eq!(msg.text.as_deref(), Some("Hello"));
|
||||
assert_eq!(msg.chat.id, -100);
|
||||
assert_eq!(msg.chat.chat_type, "group");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tg_update_with_callback_query() {
|
||||
let raw = json!({
|
||||
"update_id": 101,
|
||||
"callback_query": {
|
||||
"id": "cb_1",
|
||||
"from": { "id": 42, "is_bot": false, "first_name": "Alice" },
|
||||
"data": "system:session.new"
|
||||
}
|
||||
});
|
||||
let upd: TgUpdate = serde_json::from_value(raw).unwrap();
|
||||
let cb = upd.callback_query.unwrap();
|
||||
assert_eq!(cb.id, "cb_1");
|
||||
assert_eq!(cb.data.as_deref(), Some("system:session.new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tg_message_with_photo() {
|
||||
let raw = json!({
|
||||
"message_id": 2,
|
||||
"chat": { "id": 1, "type": "private" },
|
||||
"date": 1700000001,
|
||||
"photo": [
|
||||
{ "file_id": "small", "file_unique_id": "u1", "width": 90, "height": 90 },
|
||||
{ "file_id": "large", "file_unique_id": "u2", "width": 800, "height": 600, "file_size": 50000 }
|
||||
]
|
||||
});
|
||||
let msg: TgMessage = serde_json::from_value(raw).unwrap();
|
||||
let photos = msg.photo.unwrap();
|
||||
assert_eq!(photos.len(), 2);
|
||||
assert_eq!(photos[1].file_id, "large");
|
||||
assert_eq!(photos[1].file_size, Some(50000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tg_message_with_document() {
|
||||
let raw = json!({
|
||||
"message_id": 3,
|
||||
"chat": { "id": 1, "type": "private" },
|
||||
"date": 1700000002,
|
||||
"document": { "file_id": "doc_1", "file_name": "test.pdf", "mime_type": "application/pdf", "file_size": 1024 }
|
||||
});
|
||||
let msg: TgMessage = serde_json::from_value(raw).unwrap();
|
||||
let doc = msg.document.unwrap();
|
||||
assert_eq!(doc.file_id, "doc_1");
|
||||
assert_eq!(doc.file_name.as_deref(), Some("test.pdf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_message_request_serializes() {
|
||||
let req = SendMessageRequest {
|
||||
chat_id: 42,
|
||||
text: "Hello".into(),
|
||||
parse_mode: Some("HTML".into()),
|
||||
reply_to_message_id: None,
|
||||
reply_markup: None,
|
||||
disable_notification: None,
|
||||
};
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(json["chat_id"], 42);
|
||||
assert_eq!(json["text"], "Hello");
|
||||
assert_eq!(json["parse_mode"], "HTML");
|
||||
assert!(json.get("reply_to_message_id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_keyboard_markup_serializes() {
|
||||
let markup = ReplyMarkup::InlineKeyboard(InlineKeyboardMarkup {
|
||||
inline_keyboard: vec![vec![
|
||||
InlineKeyboardButton {
|
||||
text: "Yes".into(),
|
||||
callback_data: Some("confirm:yes".into()),
|
||||
url: None,
|
||||
},
|
||||
InlineKeyboardButton {
|
||||
text: "No".into(),
|
||||
callback_data: Some("confirm:no".into()),
|
||||
url: None,
|
||||
},
|
||||
]],
|
||||
});
|
||||
let json = serde_json::to_value(&markup).unwrap();
|
||||
assert_eq!(json["inline_keyboard"][0][0]["text"], "Yes");
|
||||
assert_eq!(json["inline_keyboard"][0][1]["callback_data"], "confirm:no");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_keyboard_markup_serializes() {
|
||||
let markup = ReplyMarkup::ReplyKeyboard(ReplyKeyboardMarkup {
|
||||
keyboard: vec![vec![KeyboardButton { text: "/start".into() }]],
|
||||
resize_keyboard: Some(true),
|
||||
one_time_keyboard: None,
|
||||
});
|
||||
let json = serde_json::to_value(&markup).unwrap();
|
||||
assert_eq!(json["keyboard"][0][0]["text"], "/start");
|
||||
assert_eq!(json["resize_keyboard"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_message_text_request_serializes() {
|
||||
let req = EditMessageTextRequest {
|
||||
chat_id: 42,
|
||||
message_id: 99,
|
||||
text: "Updated".into(),
|
||||
parse_mode: None,
|
||||
reply_markup: None,
|
||||
};
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(json["chat_id"], 42);
|
||||
assert_eq!(json["message_id"], 99);
|
||||
assert_eq!(json["text"], "Updated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answer_callback_query_request_serializes() {
|
||||
let req = AnswerCallbackQueryRequest {
|
||||
callback_query_id: "cb_1".into(),
|
||||
text: Some("Done!".into()),
|
||||
show_alert: Some(false),
|
||||
};
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(json["callback_query_id"], "cb_1");
|
||||
assert_eq!(json["text"], "Done!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tg_update_with_reply_to_message() {
|
||||
let raw = json!({
|
||||
"update_id": 102,
|
||||
"message": {
|
||||
"message_id": 5,
|
||||
"from": { "id": 42, "is_bot": false, "first_name": "Alice" },
|
||||
"chat": { "id": 1, "type": "private" },
|
||||
"date": 1700000003,
|
||||
"text": "Reply text",
|
||||
"reply_to_message": {
|
||||
"message_id": 3,
|
||||
"chat": { "id": 1, "type": "private" },
|
||||
"date": 1700000002,
|
||||
"text": "Original"
|
||||
}
|
||||
}
|
||||
});
|
||||
let upd: TgUpdate = serde_json::from_value(raw).unwrap();
|
||||
let msg = upd.message.unwrap();
|
||||
let reply = msg.reply_to_message.unwrap();
|
||||
assert_eq!(reply.message_id, 3);
|
||||
assert_eq!(reply.text.as_deref(), Some("Original"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tg_message_with_voice() {
|
||||
let raw = json!({
|
||||
"message_id": 6,
|
||||
"chat": { "id": 1, "type": "private" },
|
||||
"date": 1700000004,
|
||||
"voice": { "file_id": "voice_1", "duration": 5, "mime_type": "audio/ogg", "file_size": 8192 }
|
||||
});
|
||||
let msg: TgMessage = serde_json::from_value(raw).unwrap();
|
||||
let voice = msg.voice.unwrap();
|
||||
assert_eq!(voice.file_id, "voice_1");
|
||||
assert_eq!(voice.duration, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tg_message_with_sticker() {
|
||||
let raw = json!({
|
||||
"message_id": 7,
|
||||
"chat": { "id": 1, "type": "private" },
|
||||
"date": 1700000005,
|
||||
"sticker": { "file_id": "sticker_1", "emoji": "😀" }
|
||||
});
|
||||
let msg: TgMessage = serde_json::from_value(raw).unwrap();
|
||||
let sticker = msg.sticker.unwrap();
|
||||
assert_eq!(sticker.file_id, "sticker_1");
|
||||
assert_eq!(sticker.emoji.as_deref(), Some("😀"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
//! Persistent high-water mark ("watermark") for processed Telegram updates.
|
||||
//!
|
||||
//! ## Why this exists
|
||||
//!
|
||||
//! Telegram's long-polling model only confirms a batch of updates when the
|
||||
//! NEXT `getUpdates` call carries an advanced `offset`. The poll loop used to
|
||||
//! keep that offset purely in memory (starting at `None`), so if the process
|
||||
//! restarted after dispatching a batch to the orchestrator but before issuing
|
||||
//! the next poll, Telegram redelivered the entire batch on startup — and the
|
||||
//! channel master session (which auto-approves tools) re-executed every
|
||||
//! contained action. Creation-style actions thus ran twice (one situational
|
||||
//! cause of the "duplicate companion creation" bug).
|
||||
//!
|
||||
//! ## How it works
|
||||
//!
|
||||
//! The highest processed `update_id` is persisted per bot, together with the
|
||||
//! wall-clock time it was written. On startup the poll loop:
|
||||
//! 1. seeds its `getUpdates` offset with `watermark + 1`, confirming the
|
||||
//! pre-restart batch server-side on the very first poll AND filtering it
|
||||
//! out of the response, and
|
||||
//! 2. treats any update the server STILL delivers at or below the watermark
|
||||
//! as a server-side `update_id` sequence reset (see below): it is
|
||||
//! processed normally and the watermark is rebased onto it.
|
||||
//!
|
||||
//! ## update_ids are NOT forever monotonic: the idle-week random reset
|
||||
//!
|
||||
//! The Bot API docs state: "If there are no new updates for at least a week,
|
||||
//! then identifier of the next update will be chosen randomly instead of
|
||||
//! sequentially." A watermark persisted before such an idle stretch may
|
||||
//! therefore lie ABOVE every update_id of the new sequence; naively skipping
|
||||
//! `id <= watermark` would then silently drop all subsequent messages forever
|
||||
//! (and the watermark would never advance, so restarts wouldn't heal it).
|
||||
//! Two complementary defenses:
|
||||
//!
|
||||
//! - **Persistence TTL** — `load` discards watermarks older than 5 days.
|
||||
//! The random reset requires >= 1 week of inactivity, so 5 days leaves a
|
||||
//! comfortable safety margin while still covering every realistic restart
|
||||
//! window. Discarding is safe: a bot idle for days has no pending batch
|
||||
//! awaiting redelivery, so there is nothing left to deduplicate.
|
||||
//! - **Runtime reset detection** — because the offset is always seeded at
|
||||
//! `watermark + 1`, genuine crash-window redeliveries are filtered
|
||||
//! server-side and never reach the loop. If an update with
|
||||
//! `id <= watermark` arrives anyway, it can only be the random sequence
|
||||
//! reset: it is processed (never skipped) and the watermark is rebased
|
||||
//! onto the new sequence, with a warning.
|
||||
//!
|
||||
//! ## Crash-window semantics: prefer loss over duplication
|
||||
//!
|
||||
//! The watermark is advanced and persisted IMMEDIATELY AFTER an update is
|
||||
//! dispatched onto the orchestrator's queue — not after the agent finishes
|
||||
//! handling it. Two asymmetric crash windows follow:
|
||||
//!
|
||||
//! - duplication window (tiny): dying between dispatch and persist re-runs
|
||||
//! that one update on restart;
|
||||
//! - loss window (seconds to minutes): once the watermark is persisted, the
|
||||
//! update lives only in the in-memory agent queue until handling actually
|
||||
//! completes — a crash anywhere in that span drops the message for good.
|
||||
//!
|
||||
//! This trade-off is deliberate: duplicated agent executions (creating companions,
|
||||
//! conversations, files…) are far more costly than one dropped IM message the
|
||||
//! user can simply resend.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
/// Discard persisted watermarks older than this. Telegram only randomizes the
|
||||
/// next update_id after at least one week of inactivity; 5 days keeps a
|
||||
/// two-day safety margin below that while still covering every realistic
|
||||
/// restart/redelivery window (which is seconds, not days).
|
||||
const WATERMARK_TTL_MS: i64 = 5 * 24 * 60 * 60 * 1000;
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Uniquifies temp-file names if saves ever race (multiple bots, tests).
|
||||
static SAVE_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Abstraction over watermark persistence so the reset/record logic can be
|
||||
/// unit-tested without touching the filesystem.
|
||||
pub trait WatermarkStore: Send + Sync {
|
||||
/// Last processed update_id for the given bot, if any (and still fresh —
|
||||
/// stores may apply a TTL, see `FileWatermarkStore`).
|
||||
fn load(&self, bot_id: &str) -> Option<i64>;
|
||||
/// Persist the last processed update_id for the given bot.
|
||||
///
|
||||
/// Failures must be swallowed (logged) — persistence is best-effort and
|
||||
/// degrades to the pre-fix behavior (one redelivery window at worst).
|
||||
fn save(&self, bot_id: &str, update_id: i64);
|
||||
}
|
||||
|
||||
/// No-op store used when no writable state directory could be derived.
|
||||
/// Behavior degrades to the pre-fix semantics (memory-only offset).
|
||||
pub struct NoopWatermarkStore;
|
||||
|
||||
impl WatermarkStore for NoopWatermarkStore {
|
||||
fn load(&self, _bot_id: &str) -> Option<i64> {
|
||||
None
|
||||
}
|
||||
|
||||
fn save(&self, _bot_id: &str, _update_id: i64) {}
|
||||
}
|
||||
|
||||
/// File-backed store: one tiny text file per bot under `dir`, containing
|
||||
/// `"<update_id> <saved_at_ms>"` (format v2; `saved_at_ms` is a Unix
|
||||
/// timestamp in milliseconds backing the load-time TTL). A missing,
|
||||
/// corrupted, legacy-format, or expired file degrades to `None`, which means
|
||||
/// at most one redelivery window — identical to the pre-fix behavior, never
|
||||
/// worse.
|
||||
pub struct FileWatermarkStore {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl FileWatermarkStore {
|
||||
pub fn new(dir: impl Into<PathBuf>) -> Self {
|
||||
Self { dir: dir.into() }
|
||||
}
|
||||
|
||||
/// Derive the store location from the process-global runtime root.
|
||||
///
|
||||
/// Both hosts (desktop embedded backend and the web binary) call
|
||||
/// `nomifun_runtime::init(&data_dir)` before the channel manager starts,
|
||||
/// which anchors `runtime_root()` at `{data_dir}/runtime` — so the
|
||||
/// watermark lands in `{data_dir}/channel/`, next to the other data-dir
|
||||
/// subtrees (knowledge/, companion/, logs/…). If `init` never ran (unit tests,
|
||||
/// exotic hosts), `runtime_root()` falls back to the OS cache dir, which
|
||||
/// is still stable across restarts.
|
||||
pub fn for_app_data() -> Option<Self> {
|
||||
let runtime_root = nomifun_runtime::runtime_root()?;
|
||||
let base = runtime_root.parent()?.to_path_buf();
|
||||
Some(Self::new(base.join("channel")))
|
||||
}
|
||||
|
||||
fn file_path(&self, bot_id: &str) -> PathBuf {
|
||||
// Telegram bot ids are numeric; filter defensively anyway so an
|
||||
// unexpected id can never become a path traversal.
|
||||
let safe: String = bot_id
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
|
||||
.collect();
|
||||
self.dir.join(format!("telegram-update-watermark-{safe}.txt"))
|
||||
}
|
||||
}
|
||||
|
||||
impl WatermarkStore for FileWatermarkStore {
|
||||
fn load(&self, bot_id: &str) -> Option<i64> {
|
||||
let path = self.file_path(bot_id);
|
||||
let raw = std::fs::read_to_string(&path).ok()?;
|
||||
// Format v2: "<update_id> <saved_at_ms>". The original (v1) format
|
||||
// was the bare update_id with no timestamp; without a timestamp the
|
||||
// TTL below cannot be enforced, so legacy files are deliberately
|
||||
// treated like corruption and degrade to None. Cost: at most one
|
||||
// redelivery window after upgrading, same as a fresh install.
|
||||
let fields: Vec<&str> = raw.split_whitespace().collect();
|
||||
let parsed = match fields.as_slice() {
|
||||
[id, saved_at] => id.parse::<i64>().ok().zip(saved_at.parse::<i64>().ok()),
|
||||
_ => None,
|
||||
};
|
||||
let Some((update_id, saved_at)) = parsed else {
|
||||
warn!(path = %path.display(), "ignoring corrupted or legacy-format telegram watermark file");
|
||||
return None;
|
||||
};
|
||||
// TTL: after >= 1 week of bot inactivity Telegram picks the next
|
||||
// update_id RANDOMLY — possibly below this watermark — so a stale
|
||||
// watermark must not be trusted (see module docs). Discarding is
|
||||
// safe: days of inactivity mean no pending batch to deduplicate.
|
||||
let age_ms = now_ms().saturating_sub(saved_at);
|
||||
if age_ms > WATERMARK_TTL_MS {
|
||||
warn!(
|
||||
path = %path.display(),
|
||||
update_id,
|
||||
age_ms,
|
||||
"discarding stale telegram watermark (update_id sequence may have been randomly reset server-side)"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Some(update_id)
|
||||
}
|
||||
|
||||
fn save(&self, bot_id: &str, update_id: i64) {
|
||||
if let Err(e) = std::fs::create_dir_all(&self.dir) {
|
||||
warn!(dir = %self.dir.display(), error = %e, "failed to create telegram watermark dir");
|
||||
return;
|
||||
}
|
||||
let path = self.file_path(bot_id);
|
||||
// Write-to-temp + rename: a same-directory rename is atomic, so a
|
||||
// crash mid-write can never leave a partial file like "12" (from
|
||||
// "123456") that would parse as a valid LOWER watermark and re-open
|
||||
// the dedup window. Same pattern as nomifun-companion's
|
||||
// fsio::save_json_atomic.
|
||||
let seq = SAVE_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let tmp = self.dir.join(format!(".telegram-update-watermark.tmp.{}.{seq}", std::process::id()));
|
||||
let payload = format!("{update_id} {}", now_ms());
|
||||
let result = std::fs::write(&tmp, payload).and_then(|()| std::fs::rename(&tmp, &path));
|
||||
if let Err(e) = result {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
warn!(path = %path.display(), error = %e, "failed to persist telegram watermark");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the production store: file-backed when a state directory is
|
||||
/// derivable, otherwise a no-op (with a warning, since dedup across restarts
|
||||
/// is then unavailable).
|
||||
pub fn default_watermark_store() -> Arc<dyn WatermarkStore> {
|
||||
match FileWatermarkStore::for_app_data() {
|
||||
Some(store) => Arc::new(store),
|
||||
None => {
|
||||
warn!("no state directory derivable; telegram update dedup will not survive restarts");
|
||||
Arc::new(NoopWatermarkStore)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How an incoming `update_id` relates to the current watermark.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UpdateArrival {
|
||||
/// Normal forward progress: above the watermark (or no watermark yet).
|
||||
New,
|
||||
/// `id <= watermark` even though the `getUpdates` offset was seeded past
|
||||
/// the watermark. Server-side offset filtering makes genuine
|
||||
/// redeliveries unreachable here, so this can only be Telegram's random
|
||||
/// update_id sequence reset (bot idle >= 1 week). The update must be
|
||||
/// processed — never skipped — and the watermark rebased onto the new
|
||||
/// sequence (see module docs).
|
||||
SequenceReset,
|
||||
}
|
||||
|
||||
/// Pure watermark logic over processed update_ids.
|
||||
///
|
||||
/// Owned by the poll loop (single task), so no interior mutability is needed.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct UpdateWatermark {
|
||||
last_processed: Option<i64>,
|
||||
}
|
||||
|
||||
impl UpdateWatermark {
|
||||
pub fn new(last_processed: Option<i64>) -> Self {
|
||||
Self { last_processed }
|
||||
}
|
||||
|
||||
/// Classify an incoming update relative to the watermark.
|
||||
pub fn classify(&self, update_id: i64) -> UpdateArrival {
|
||||
match self.last_processed {
|
||||
Some(w) if update_id <= w => UpdateArrival::SequenceReset,
|
||||
_ => UpdateArrival::New,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record `update_id` as processed: forward advance in the normal case,
|
||||
/// backward rebase after a sequence reset. Every delivered update is
|
||||
/// recorded — there is no skip path (see module docs).
|
||||
pub fn record(&mut self, update_id: i64) {
|
||||
self.last_processed = Some(update_id);
|
||||
}
|
||||
|
||||
/// The `getUpdates` offset that confirms everything processed so far:
|
||||
/// `last_processed + 1`, or `None` when nothing was ever processed.
|
||||
pub fn next_offset(&self) -> Option<i64> {
|
||||
self.last_processed.map(|w| w + 1)
|
||||
}
|
||||
|
||||
pub fn last_processed(&self) -> Option<i64> {
|
||||
self.last_processed
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -- UpdateWatermark: classify / record ----------------------------------
|
||||
|
||||
#[test]
|
||||
fn fresh_watermark_classifies_everything_new() {
|
||||
let wm = UpdateWatermark::new(None);
|
||||
assert_eq!(wm.classify(1), UpdateArrival::New);
|
||||
assert_eq!(wm.classify(i64::MAX), UpdateArrival::New);
|
||||
assert_eq!(wm.next_offset(), None);
|
||||
assert_eq!(wm.last_processed(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_or_below_watermark_is_sequence_reset_above_is_new() {
|
||||
let wm = UpdateWatermark::new(Some(100));
|
||||
assert_eq!(wm.classify(99), UpdateArrival::SequenceReset);
|
||||
assert_eq!(wm.classify(100), UpdateArrival::SequenceReset);
|
||||
assert_eq!(wm.classify(101), UpdateArrival::New);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_advances_forward() {
|
||||
let mut wm = UpdateWatermark::new(Some(100));
|
||||
wm.record(105);
|
||||
assert_eq!(wm.last_processed(), Some(105));
|
||||
assert_eq!(wm.next_offset(), Some(106));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_rebases_backward_after_sequence_reset() {
|
||||
// Idle-week scenario: watermark at 100_000, Telegram restarts the
|
||||
// sequence at a random lower id. The update is processed and the
|
||||
// watermark follows the NEW sequence — skipping here would drop
|
||||
// every subsequent message forever.
|
||||
let mut wm = UpdateWatermark::new(Some(100_000));
|
||||
assert_eq!(wm.classify(57), UpdateArrival::SequenceReset);
|
||||
wm.record(57);
|
||||
assert_eq!(wm.last_processed(), Some(57));
|
||||
assert_eq!(wm.next_offset(), Some(58));
|
||||
// The new sequence then progresses normally.
|
||||
assert_eq!(wm.classify(58), UpdateArrival::New);
|
||||
wm.record(58);
|
||||
assert_eq!(wm.last_processed(), Some(58));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_offset_is_last_plus_one() {
|
||||
let mut wm = UpdateWatermark::new(None);
|
||||
wm.record(41);
|
||||
assert_eq!(wm.next_offset(), Some(42));
|
||||
}
|
||||
|
||||
// -- FileWatermarkStore: persistence roundtrip ---------------------------
|
||||
|
||||
fn temp_store() -> (tempfile::TempDir, FileWatermarkStore) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let store = FileWatermarkStore::new(dir.path().join("channel"));
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_load_roundtrip() {
|
||||
let (_guard, store) = temp_store();
|
||||
assert_eq!(store.load("12345"), None);
|
||||
store.save("12345", 777);
|
||||
assert_eq!(store.load("12345"), Some(777));
|
||||
// Overwrite advances.
|
||||
store.save("12345", 778);
|
||||
assert_eq!(store.load("12345"), Some(778));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_file_uses_v2_format_with_timestamp() {
|
||||
let (_guard, store) = temp_store();
|
||||
let before = now_ms();
|
||||
store.save("42", 900);
|
||||
let raw = std::fs::read_to_string(store.file_path("42")).unwrap();
|
||||
let fields: Vec<&str> = raw.split_whitespace().collect();
|
||||
assert_eq!(fields.len(), 2, "v2 format is '<update_id> <saved_at_ms>': {raw:?}");
|
||||
assert_eq!(fields[0].parse::<i64>().unwrap(), 900);
|
||||
let saved_at = fields[1].parse::<i64>().unwrap();
|
||||
assert!(saved_at >= before && saved_at <= now_ms());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_save_leaves_no_temp_files_behind() {
|
||||
let (_guard, store) = temp_store();
|
||||
store.save("7", 1);
|
||||
store.save("7", 2);
|
||||
let entries: Vec<String> = std::fs::read_dir(&store.dir)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(entries, vec!["telegram-update-watermark-7.txt".to_string()]);
|
||||
assert_eq!(store.load("7"), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bots_are_isolated() {
|
||||
let (_guard, store) = temp_store();
|
||||
store.save("bot_a", 10);
|
||||
store.save("bot_b", 20);
|
||||
assert_eq!(store.load("bot_a"), Some(10));
|
||||
assert_eq!(store.load("bot_b"), Some(20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupted_file_degrades_to_none() {
|
||||
let (_guard, store) = temp_store();
|
||||
store.save("9", 1); // ensure dir exists
|
||||
for junk in ["not-a-number", "1 2 3", "100 not-a-ts", ""] {
|
||||
std::fs::write(store.file_path("9"), junk).unwrap();
|
||||
assert_eq!(store.load("9"), None, "junk content {junk:?} must degrade to None");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_v1_single_number_format_degrades_to_none() {
|
||||
// v1 files carry no timestamp, so the TTL cannot be enforced on
|
||||
// them; they are deliberately dropped (one redelivery window at
|
||||
// worst — same as a fresh install).
|
||||
let (_guard, store) = temp_store();
|
||||
store.save("8", 1); // ensure dir exists
|
||||
std::fs::write(store.file_path("8"), "123456").unwrap();
|
||||
assert_eq!(store.load("8"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_watermark_past_ttl_is_discarded() {
|
||||
let (_guard, store) = temp_store();
|
||||
store.save("6", 1); // ensure dir exists
|
||||
// 6 days old: past the 5-day TTL — Telegram may have randomly reset
|
||||
// the sequence (>= 1 week idle), so the watermark must not be trusted.
|
||||
let six_days_ago = now_ms() - 6 * 24 * 60 * 60 * 1000;
|
||||
std::fs::write(store.file_path("6"), format!("500 {six_days_ago}")).unwrap();
|
||||
assert_eq!(store.load("6"), None);
|
||||
// 4 days old: within TTL, still valid.
|
||||
let four_days_ago = now_ms() - 4 * 24 * 60 * 60 * 1000;
|
||||
std::fs::write(store.file_path("6"), format!("500 {four_days_ago}")).unwrap();
|
||||
assert_eq!(store.load("6"), Some(500));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bot_id_is_sanitized_for_filenames() {
|
||||
let (_guard, store) = temp_store();
|
||||
let evil = "..\\..\\evil/../id";
|
||||
store.save(evil, 5);
|
||||
assert_eq!(store.load(evil), Some(5));
|
||||
// The written file must stay inside the store dir.
|
||||
let path = store.file_path(evil);
|
||||
assert!(path.starts_with(&store.dir));
|
||||
assert!(path.file_name().unwrap().to_string_lossy().contains("evilid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_roundtrip_seeds_offset_past_processed_batch() {
|
||||
// Restart scenario at the logic level: process batch [1,2,3], then
|
||||
// "restart". Dedup of the pre-restart batch is server-side — the
|
||||
// reloaded watermark seeds offset 4 so Telegram filters [1,2,3] out
|
||||
// of the first poll response.
|
||||
let (_guard, store) = temp_store();
|
||||
let bot = "555";
|
||||
|
||||
let mut wm = UpdateWatermark::new(store.load(bot));
|
||||
for id in [1i64, 2, 3] {
|
||||
assert_eq!(wm.classify(id), UpdateArrival::New);
|
||||
wm.record(id);
|
||||
store.save(bot, id);
|
||||
}
|
||||
|
||||
// "Restart": fresh watermark seeded from disk.
|
||||
let wm = UpdateWatermark::new(store.load(bot));
|
||||
assert_eq!(wm.next_offset(), Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_reset_roundtrip_through_store() {
|
||||
// Idle-week scenario end-to-end at the logic level: watermark 1000
|
||||
// persisted, Telegram delivers a randomly reset id 57 despite the
|
||||
// seeded offset. It is processed, the watermark rebases, and the
|
||||
// rebased value survives a restart.
|
||||
let (_guard, store) = temp_store();
|
||||
let bot = "777";
|
||||
store.save(bot, 1000);
|
||||
|
||||
let mut wm = UpdateWatermark::new(store.load(bot));
|
||||
assert_eq!(wm.next_offset(), Some(1001));
|
||||
assert_eq!(wm.classify(57), UpdateArrival::SequenceReset);
|
||||
wm.record(57);
|
||||
store.save(bot, 57);
|
||||
|
||||
let wm = UpdateWatermark::new(store.load(bot));
|
||||
assert_eq!(wm.last_processed(), Some(57));
|
||||
assert_eq!(wm.next_offset(), Some(58));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noop_store_loads_none() {
|
||||
let store = NoopWatermarkStore;
|
||||
store.save("1", 99);
|
||||
assert_eq!(store.load("1"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Twitch OAuth validation API client.
|
||||
|
||||
use reqwest::Client;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::error::ChannelError;
|
||||
|
||||
use super::types::ValidateResponse;
|
||||
|
||||
const TWITCH_VALIDATE_URL: &str = "https://id.twitch.tv/oauth2/validate";
|
||||
|
||||
/// HTTP client for Twitch OAuth token validation.
|
||||
///
|
||||
/// Only the validate endpoint is needed — all chat I/O goes over IRC-over-WS.
|
||||
pub(crate) struct TwitchApi {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl TwitchApi {
|
||||
pub fn new(client: Client) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
/// Validate the OAuth access token and return the bot's login/user_id.
|
||||
///
|
||||
/// `GET https://id.twitch.tv/oauth2/validate`
|
||||
/// Header: `Authorization: OAuth <token>`
|
||||
pub async fn validate(&self, token: &str) -> Result<ValidateResponse, ChannelError> {
|
||||
debug!("Validating Twitch OAuth token");
|
||||
let resp = self
|
||||
.client
|
||||
.get(TWITCH_VALIDATE_URL)
|
||||
.header("Authorization", format!("OAuth {token}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("Twitch validate request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ChannelError::ConnectionFailed(format!(
|
||||
"Twitch token validation failed: HTTP {status}: {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
resp.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("Twitch validate parse failed: {e}")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Twitch channel plugin: IRC-over-WebSocket (fully outbound).
|
||||
//!
|
||||
//! Connects to Twitch chat via `wss://irc-ws.chat.twitch.tv:443` using the
|
||||
//! standard IRC-over-WebSocket protocol with TMI capabilities.
|
||||
|
||||
mod api;
|
||||
mod plugin;
|
||||
pub mod types;
|
||||
|
||||
pub use plugin::TwitchPlugin;
|
||||
@@ -0,0 +1,850 @@
|
||||
//! Twitch channel plugin: IRC-over-WebSocket (fully outbound).
|
||||
//!
|
||||
//! Mirrors the Discord plugin structure: `initialize` validates the OAuth
|
||||
//! token via the Twitch API, `start` spawns a background WS loop that
|
||||
//! connects to `wss://irc-ws.chat.twitch.tv:443`, authenticates with
|
||||
//! CAP/PASS/NICK/JOIN, and reads PRIVMSG lines. Outbound messages are
|
||||
//! sent as `PRIVMSG #channel :text`.
|
||||
|
||||
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::{TWITCH_MAX_RECONNECT_ATTEMPTS, TWITCH_MAX_RECONNECT_DELAY, TWITCH_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::TwitchApi;
|
||||
use super::types::{IrcLine, ParsedPrivmsg};
|
||||
|
||||
const TWITCH_WS_URL: &str = "wss://irc-ws.chat.twitch.tv:443";
|
||||
|
||||
/// Twitch IRC-over-WebSocket plugin.
|
||||
pub struct TwitchPlugin {
|
||||
status: SharedPluginStatus,
|
||||
bot_info: Option<BotInfo>,
|
||||
last_error: Option<String>,
|
||||
/// The bot's IRC login (lowercase), used as NICK and for self-loop guard.
|
||||
bot_login: Option<String>,
|
||||
/// The OAuth access token (without the "oauth:" prefix).
|
||||
token: Option<String>,
|
||||
/// The target channel (with leading '#', lowercased).
|
||||
channel: Option<String>,
|
||||
callbacks: Option<PluginCallbacks>,
|
||||
/// Handle for outbound writes from `send_message`.
|
||||
write_tx: Option<mpsc::Sender<String>>,
|
||||
gateway_handle: Option<JoinHandle<()>>,
|
||||
shutdown_tx: Option<watch::Sender<bool>>,
|
||||
}
|
||||
|
||||
impl Default for TwitchPlugin {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
status: SharedPluginStatus::default(),
|
||||
bot_info: None,
|
||||
last_error: None,
|
||||
bot_login: None,
|
||||
token: None,
|
||||
channel: None,
|
||||
callbacks: None,
|
||||
write_tx: None,
|
||||
gateway_handle: None,
|
||||
shutdown_tx: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TwitchPlugin {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ChannelPlugin for TwitchPlugin {
|
||||
async fn initialize(&mut self, config: PluginConfig, callbacks: PluginCallbacks) -> Result<(), ChannelError> {
|
||||
self.status.set(PluginStatus::Initializing);
|
||||
|
||||
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 Twitch OAuth token".into());
|
||||
ChannelError::InvalidConfig("Missing Twitch OAuth token".into())
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(15))
|
||||
.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 = TwitchApi::new(client);
|
||||
let validated = api.validate(&token).await.map_err(|e| {
|
||||
self.status.set(PluginStatus::Error);
|
||||
self.last_error = Some(format!("Token validation failed: {e}"));
|
||||
e
|
||||
})?;
|
||||
|
||||
let login = validated.login.to_lowercase();
|
||||
self.bot_info = Some(BotInfo {
|
||||
id: validated.user_id.clone(),
|
||||
username: Some(login.clone()),
|
||||
display_name: login.clone(),
|
||||
});
|
||||
|
||||
// Determine the target channel. If the user provided one, normalize it;
|
||||
// otherwise default to the bot's own login.
|
||||
let raw_channel = config
|
||||
.credentials
|
||||
.twitch_channel
|
||||
.as_deref()
|
||||
.filter(|c| !c.is_empty());
|
||||
let channel = normalize_channel(raw_channel.unwrap_or(&login));
|
||||
|
||||
info!(
|
||||
bot_login = %login,
|
||||
user_id = %validated.user_id,
|
||||
channel = %channel,
|
||||
"Twitch bot initialized"
|
||||
);
|
||||
|
||||
self.bot_login = Some(login);
|
||||
self.token = Some(token);
|
||||
self.channel = Some(channel);
|
||||
self.callbacks = Some(callbacks);
|
||||
self.status.set(PluginStatus::Ready);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start(&mut self) -> Result<(), ChannelError> {
|
||||
self.status.set(PluginStatus::Starting);
|
||||
|
||||
if self.gateway_handle.is_some() {
|
||||
self.status.set(PluginStatus::Running);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let token = self
|
||||
.token
|
||||
.clone()
|
||||
.ok_or_else(|| ChannelError::PlatformApi("Twitch plugin not initialized".into()))?;
|
||||
let bot_login = self
|
||||
.bot_login
|
||||
.clone()
|
||||
.ok_or_else(|| ChannelError::PlatformApi("Twitch bot login not initialized".into()))?;
|
||||
let channel = self
|
||||
.channel
|
||||
.clone()
|
||||
.ok_or_else(|| ChannelError::PlatformApi("Twitch channel not initialized".into()))?;
|
||||
let callbacks = self
|
||||
.callbacks
|
||||
.clone()
|
||||
.ok_or_else(|| ChannelError::PlatformApi("Twitch callbacks not initialized".into()))?;
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
let (write_tx, write_rx) = mpsc::channel::<String>(64);
|
||||
|
||||
self.shutdown_tx = Some(shutdown_tx);
|
||||
self.write_tx = Some(write_tx);
|
||||
self.gateway_handle = Some(tokio::spawn(run_irc_loop(
|
||||
token,
|
||||
bot_login,
|
||||
channel,
|
||||
callbacks.message_tx,
|
||||
self.status.clone(),
|
||||
shutdown_rx,
|
||||
write_rx,
|
||||
)));
|
||||
|
||||
self.status.set(PluginStatus::Running);
|
||||
info!("Twitch 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);
|
||||
}
|
||||
// Drop the write channel so the loop can exit cleanly.
|
||||
self.write_tx = None;
|
||||
|
||||
if let Some(handle) = self.gateway_handle.take() {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;
|
||||
}
|
||||
|
||||
self.callbacks = None;
|
||||
self.status.set(PluginStatus::Stopped);
|
||||
info!("Twitch plugin stopped");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_message(&self, chat_id: &str, message: UnifiedOutgoingMessage) -> Result<String, ChannelError> {
|
||||
let write_tx = self
|
||||
.write_tx
|
||||
.as_ref()
|
||||
.ok_or_else(|| ChannelError::PlatformApi("Twitch plugin not running".into()))?;
|
||||
|
||||
let text = message.text.as_deref().unwrap_or("");
|
||||
if text.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let channel = if chat_id.is_empty() {
|
||||
self.channel.as_deref().unwrap_or("#unknown")
|
||||
} else {
|
||||
chat_id
|
||||
};
|
||||
|
||||
let lines = format_privmsgs(channel, text);
|
||||
for line in &lines {
|
||||
write_tx.send(line.clone()).await.map_err(|_| {
|
||||
ChannelError::MessageSendFailed("Twitch write channel closed".into())
|
||||
})?;
|
||||
}
|
||||
|
||||
// Twitch IRC has no message IDs; return a synthetic one.
|
||||
Ok(format!("twitch-{}", std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()))
|
||||
}
|
||||
|
||||
async fn edit_message(
|
||||
&self,
|
||||
chat_id: &str,
|
||||
_message_id: &str,
|
||||
message: UnifiedOutgoingMessage,
|
||||
) -> Result<(), ChannelError> {
|
||||
// Twitch IRC has no edit capability. Send as a new message (fallback).
|
||||
// Stream relay classifies Twitch as send-once, so edits are rare.
|
||||
let _ = self.send_message(chat_id, message).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn active_user_count(&self) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
fn bot_info(&self) -> Option<&BotInfo> {
|
||||
self.bot_info.as_ref()
|
||||
}
|
||||
|
||||
fn plugin_type(&self) -> PluginType {
|
||||
PluginType::Twitch
|
||||
}
|
||||
|
||||
fn status(&self) -> PluginStatus {
|
||||
self.status.get()
|
||||
}
|
||||
|
||||
fn last_error(&self) -> Option<&str> {
|
||||
self.last_error.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Background IRC WebSocket loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Background task: maintain the Twitch IRC connection with reconnects.
|
||||
async fn run_irc_loop(
|
||||
token: String,
|
||||
bot_login: String,
|
||||
channel: String,
|
||||
message_tx: mpsc::Sender<UnifiedIncomingMessage>,
|
||||
status: SharedPluginStatus,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
mut write_rx: mpsc::Receiver<String>,
|
||||
) {
|
||||
let mut consecutive_errors: u32 = 0;
|
||||
|
||||
loop {
|
||||
if *shutdown_rx.borrow() {
|
||||
debug!("Twitch IRC loop received shutdown signal");
|
||||
break;
|
||||
}
|
||||
|
||||
match connect_once(
|
||||
&token,
|
||||
&bot_login,
|
||||
&channel,
|
||||
&message_tx,
|
||||
&mut shutdown_rx,
|
||||
&mut write_rx,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
consecutive_errors = 0;
|
||||
if *shutdown_rx.borrow() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
consecutive_errors += 1;
|
||||
warn!(error = %e, consecutive_errors, "Twitch IRC error");
|
||||
if consecutive_errors >= TWITCH_MAX_RECONNECT_ATTEMPTS {
|
||||
error!("Twitch max reconnect attempts reached, stopping IRC loop");
|
||||
break;
|
||||
}
|
||||
let backoff = backoff_delay(consecutive_errors);
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(backoff) => {}
|
||||
_ = shutdown_rx.changed() => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mark_error_on_unexpected_exit(&status, &shutdown_rx, "twitch");
|
||||
debug!("Twitch IRC loop exited");
|
||||
}
|
||||
|
||||
/// Exponential backoff capped at `TWITCH_MAX_RECONNECT_DELAY`.
|
||||
fn backoff_delay(attempt: u32) -> Duration {
|
||||
let secs = 2u64.saturating_pow(attempt).min(TWITCH_MAX_RECONNECT_DELAY.as_secs());
|
||||
Duration::from_secs(secs)
|
||||
}
|
||||
|
||||
/// A single IRC connection: CAP → PASS → NICK → JOIN → read loop.
|
||||
async fn connect_once(
|
||||
token: &str,
|
||||
bot_login: &str,
|
||||
channel: &str,
|
||||
message_tx: &mpsc::Sender<UnifiedIncomingMessage>,
|
||||
shutdown_rx: &mut watch::Receiver<bool>,
|
||||
write_rx: &mut mpsc::Receiver<String>,
|
||||
) -> Result<(), ChannelError> {
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::connect_async_tls_with_config;
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
|
||||
let connector = build_ws_tls_connector()?;
|
||||
let (ws_stream, _) = connect_async_tls_with_config(TWITCH_WS_URL, None, false, Some(connector))
|
||||
.await
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("Twitch IRC connect failed: {e}")))?;
|
||||
info!("Twitch IRC connected");
|
||||
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
|
||||
// Send IRC registration sequence.
|
||||
let cap_req = "CAP REQ :twitch.tv/tags twitch.tv/commands";
|
||||
let pass = format!("PASS oauth:{token}");
|
||||
let nick = format!("NICK {bot_login}");
|
||||
let join = format!("JOIN {channel}");
|
||||
|
||||
for line in [cap_req.to_string(), pass, nick, join] {
|
||||
write
|
||||
.send(WsMessage::Text(line.into()))
|
||||
.await
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("Twitch IRC send failed: {e}")))?;
|
||||
}
|
||||
|
||||
// Read loop: dispatch PING/PRIVMSG, forward outbound writes.
|
||||
loop {
|
||||
tokio::select! {
|
||||
frame = read.next() => {
|
||||
match frame {
|
||||
Some(Ok(WsMessage::Text(txt))) => {
|
||||
// Twitch may batch multiple lines in one frame.
|
||||
for raw_line in txt.lines() {
|
||||
let raw_line = raw_line.trim();
|
||||
if raw_line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match parse_irc_line(raw_line) {
|
||||
IrcLine::Ping(payload) => {
|
||||
let pong = format!("PONG :{payload}");
|
||||
if let Err(e) = write.send(WsMessage::Text(pong.into())).await {
|
||||
warn!(error = %e, "Twitch PONG send failed");
|
||||
}
|
||||
}
|
||||
IrcLine::Privmsg(msg) => {
|
||||
// Self-loop guard: skip our own messages.
|
||||
if msg.nick.eq_ignore_ascii_case(bot_login) {
|
||||
continue;
|
||||
}
|
||||
let unified = privmsg_to_unified(&msg);
|
||||
let _ = message_tx.send(unified).await;
|
||||
}
|
||||
IrcLine::Other => { /* JOIN/PART/USERNOTICE/etc. */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(WsMessage::Close(_))) => {
|
||||
debug!("Twitch IRC received close frame");
|
||||
return Ok(());
|
||||
}
|
||||
Some(Ok(_)) => { /* binary/ping/pong */ }
|
||||
Some(Err(e)) => {
|
||||
return Err(ChannelError::ConnectionFailed(format!("Twitch IRC read error: {e}")));
|
||||
}
|
||||
None => {
|
||||
return Err(ChannelError::ConnectionFailed("Twitch IRC stream ended".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
outbound = write_rx.recv() => {
|
||||
match outbound {
|
||||
Some(line) => {
|
||||
if let Err(e) = write.send(WsMessage::Text(line.into())).await {
|
||||
warn!(error = %e, "Twitch outbound write failed");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// write_rx closed → plugin stopping.
|
||||
debug!("Twitch outbound channel closed");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.changed() => {
|
||||
debug!("Twitch IRC shutdown during listen");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the rustls-based TLS connector for the IRC WebSocket.
|
||||
fn build_ws_tls_connector() -> Result<tokio_tungstenite::Connector, ChannelError> {
|
||||
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 config = rustls::ClientConfig::builder_with_provider(provider)
|
||||
.with_safe_default_protocol_versions()
|
||||
.map_err(|e| ChannelError::ConnectionFailed(format!("Twitch TLS config failed: {e}")))?
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
|
||||
Ok(Connector::Rustls(Arc::new(config)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helpers (unit-tested)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Normalize a channel name: strip leading '#', lowercase, then re-add '#'.
|
||||
pub(crate) fn normalize_channel(input: &str) -> String {
|
||||
let stripped = input.trim().trim_start_matches('#');
|
||||
format!("#{}", stripped.to_lowercase())
|
||||
}
|
||||
|
||||
/// Parse a single raw IRC line into an `IrcLine` variant.
|
||||
///
|
||||
/// Handles:
|
||||
/// - `PING :tmi.twitch.tv` → `IrcLine::Ping`
|
||||
/// - `@<tags> :<nick>!<user>@<host> PRIVMSG #<channel> :<msg>` → `IrcLine::Privmsg`
|
||||
/// - `:<nick>!<user>@<host> PRIVMSG #<channel> :<msg>` → `IrcLine::Privmsg`
|
||||
/// - Everything else → `IrcLine::Other`
|
||||
pub(crate) fn parse_irc_line(line: &str) -> IrcLine {
|
||||
let line = line.trim();
|
||||
|
||||
// PING handling.
|
||||
if let Some(payload) = line.strip_prefix("PING :") {
|
||||
return IrcLine::Ping(payload.to_string());
|
||||
}
|
||||
if line == "PING" {
|
||||
return IrcLine::Ping("tmi.twitch.tv".to_string());
|
||||
}
|
||||
|
||||
// Skip optional @tags prefix to get to the :<prefix> COMMAND part.
|
||||
let remainder = if line.starts_with('@') {
|
||||
// Tags end at the first space, then the rest is :<prefix> COMMAND ...
|
||||
match line.find(' ') {
|
||||
Some(idx) => line[idx + 1..].trim_start(),
|
||||
None => return IrcLine::Other,
|
||||
}
|
||||
} else {
|
||||
line
|
||||
};
|
||||
|
||||
// Must start with ':' (prefix).
|
||||
let remainder = match remainder.strip_prefix(':') {
|
||||
Some(r) => r,
|
||||
None => return IrcLine::Other,
|
||||
};
|
||||
|
||||
// Extract nick from prefix (nick!user@host).
|
||||
let (prefix, after_prefix) = match remainder.find(' ') {
|
||||
Some(idx) => (&remainder[..idx], &remainder[idx + 1..]),
|
||||
None => return IrcLine::Other,
|
||||
};
|
||||
|
||||
let nick = match prefix.find('!') {
|
||||
Some(idx) => &prefix[..idx],
|
||||
None => return IrcLine::Other,
|
||||
};
|
||||
|
||||
// Check for PRIVMSG command.
|
||||
let after_prefix = after_prefix.trim_start();
|
||||
let privmsg_rest = match after_prefix.strip_prefix("PRIVMSG ") {
|
||||
Some(rest) => rest.trim_start(),
|
||||
None => return IrcLine::Other,
|
||||
};
|
||||
|
||||
// Parse channel and message: #<channel> :<message>
|
||||
let (channel, message) = match privmsg_rest.find(" :") {
|
||||
Some(idx) => (
|
||||
privmsg_rest[..idx].trim(),
|
||||
privmsg_rest[idx + 2..].to_string(),
|
||||
),
|
||||
None => return IrcLine::Other,
|
||||
};
|
||||
|
||||
IrcLine::Privmsg(ParsedPrivmsg {
|
||||
nick: nick.to_string(),
|
||||
channel: channel.to_string(),
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert a parsed PRIVMSG into a `UnifiedIncomingMessage`.
|
||||
fn privmsg_to_unified(msg: &ParsedPrivmsg) -> UnifiedIncomingMessage {
|
||||
let content_type = if msg.message.starts_with('!') {
|
||||
MessageContentType::Command
|
||||
} else {
|
||||
MessageContentType::Text
|
||||
};
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
|
||||
UnifiedIncomingMessage {
|
||||
id: format!("twitch-{now}-{}", &msg.nick),
|
||||
platform: PluginType::Twitch,
|
||||
chat_id: msg.channel.clone(),
|
||||
user: UnifiedUser {
|
||||
id: msg.nick.clone(),
|
||||
username: Some(msg.nick.clone()),
|
||||
display_name: msg.nick.clone(),
|
||||
avatar_url: None,
|
||||
},
|
||||
content: UnifiedMessageContent {
|
||||
content_type,
|
||||
text: msg.message.clone(),
|
||||
attachments: None,
|
||||
},
|
||||
timestamp: now,
|
||||
reply_to_message_id: None,
|
||||
action: None,
|
||||
raw: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format outbound PRIVMSG(s) for a channel, splitting and truncating as needed.
|
||||
///
|
||||
/// Multi-line text is split into separate PRIVMSG commands. Each line is
|
||||
/// truncated to `TWITCH_MESSAGE_LIMIT` characters.
|
||||
pub(crate) fn format_privmsgs(channel: &str, text: &str) -> Vec<String> {
|
||||
let mut result = Vec::new();
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let truncated = truncate_text(line, TWITCH_MESSAGE_LIMIT);
|
||||
result.push(format!("PRIVMSG {channel} :{truncated}"));
|
||||
}
|
||||
if result.is_empty() {
|
||||
// If the text had no non-empty lines, send the full text as one line.
|
||||
let truncated = truncate_text(text.trim(), TWITCH_MESSAGE_LIMIT);
|
||||
if !truncated.is_empty() {
|
||||
result.push(format!("PRIVMSG {channel} :{truncated}"));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Truncate text at a char boundary to `limit`, appending "..." if cut.
|
||||
pub(crate) fn truncate_text(text: &str, limit: usize) -> String {
|
||||
if text.chars().count() <= limit {
|
||||
return text.to_string();
|
||||
}
|
||||
let truncated: String = text.chars().take(limit.saturating_sub(3)).collect();
|
||||
format!("{truncated}...")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── Channel normalization ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn normalize_channel_plain() {
|
||||
assert_eq!(normalize_channel("mychannel"), "#mychannel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_channel_with_hash() {
|
||||
assert_eq!(normalize_channel("#MyChannel"), "#mychannel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_channel_with_whitespace() {
|
||||
assert_eq!(normalize_channel(" #FooBar "), "#foobar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_channel_already_lowercase() {
|
||||
assert_eq!(normalize_channel("#already"), "#already");
|
||||
}
|
||||
|
||||
// ── IRC line parsing ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_ping() {
|
||||
assert_eq!(
|
||||
parse_irc_line("PING :tmi.twitch.tv"),
|
||||
IrcLine::Ping("tmi.twitch.tv".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ping_bare() {
|
||||
assert_eq!(
|
||||
parse_irc_line("PING"),
|
||||
IrcLine::Ping("tmi.twitch.tv".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_privmsg_without_tags() {
|
||||
let line = ":alice!alice@alice.tmi.twitch.tv PRIVMSG #mychannel :Hello world";
|
||||
let expected = IrcLine::Privmsg(ParsedPrivmsg {
|
||||
nick: "alice".into(),
|
||||
channel: "#mychannel".into(),
|
||||
message: "Hello world".into(),
|
||||
});
|
||||
assert_eq!(parse_irc_line(line), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_privmsg_with_tags() {
|
||||
let line = "@badge-info=;badges=broadcaster/1;color=#FF0000;display-name=Alice;emotes=;flags=;id=abc-123;mod=0;room-id=12345;subscriber=0;tmi-sent-ts=1600000000000;turbo=0;user-id=67890;user-type= :alice!alice@alice.tmi.twitch.tv PRIVMSG #mychannel :Hello world";
|
||||
let expected = IrcLine::Privmsg(ParsedPrivmsg {
|
||||
nick: "alice".into(),
|
||||
channel: "#mychannel".into(),
|
||||
message: "Hello world".into(),
|
||||
});
|
||||
assert_eq!(parse_irc_line(line), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_privmsg_command() {
|
||||
let line = ":bob!bob@bob.tmi.twitch.tv PRIVMSG #stream :!help me";
|
||||
let result = parse_irc_line(line);
|
||||
match result {
|
||||
IrcLine::Privmsg(msg) => {
|
||||
assert_eq!(msg.nick, "bob");
|
||||
assert_eq!(msg.channel, "#stream");
|
||||
assert_eq!(msg.message, "!help me");
|
||||
}
|
||||
_ => panic!("expected Privmsg"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_privmsg_with_colons_in_message() {
|
||||
let line = ":nick!nick@nick.tmi.twitch.tv PRIVMSG #ch :hello: world: test";
|
||||
match parse_irc_line(line) {
|
||||
IrcLine::Privmsg(msg) => {
|
||||
assert_eq!(msg.message, "hello: world: test");
|
||||
}
|
||||
_ => panic!("expected Privmsg"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_join_is_other() {
|
||||
let line = ":nick!nick@nick.tmi.twitch.tv JOIN #channel";
|
||||
assert_eq!(parse_irc_line(line), IrcLine::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_notice_is_other() {
|
||||
let line = "@msg-id=slow_off :tmi.twitch.tv NOTICE #channel :This room is no longer in slow mode.";
|
||||
assert_eq!(parse_irc_line(line), IrcLine::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_line() {
|
||||
assert_eq!(parse_irc_line(""), IrcLine::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_garbage() {
|
||||
assert_eq!(parse_irc_line("not a valid irc line"), IrcLine::Other);
|
||||
}
|
||||
|
||||
// ── Self-loop guard ───────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn self_loop_guard() {
|
||||
let bot_login = "mybot";
|
||||
let msg = ParsedPrivmsg {
|
||||
nick: "mybot".into(),
|
||||
channel: "#ch".into(),
|
||||
message: "echo".into(),
|
||||
};
|
||||
assert!(msg.nick.eq_ignore_ascii_case(bot_login));
|
||||
|
||||
let other = ParsedPrivmsg {
|
||||
nick: "someone".into(),
|
||||
channel: "#ch".into(),
|
||||
message: "hello".into(),
|
||||
};
|
||||
assert!(!other.nick.eq_ignore_ascii_case(bot_login));
|
||||
}
|
||||
|
||||
// ── PRIVMSG formatting ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn format_single_line() {
|
||||
let lines = format_privmsgs("#ch", "hello");
|
||||
assert_eq!(lines, vec!["PRIVMSG #ch :hello"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_multi_line() {
|
||||
let lines = format_privmsgs("#ch", "line1\nline2\nline3");
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
"PRIVMSG #ch :line1",
|
||||
"PRIVMSG #ch :line2",
|
||||
"PRIVMSG #ch :line3",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_skips_empty_lines() {
|
||||
let lines = format_privmsgs("#ch", "first\n\n\nsecond");
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec!["PRIVMSG #ch :first", "PRIVMSG #ch :second"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_truncates_long_line() {
|
||||
let long = "x".repeat(600);
|
||||
let lines = format_privmsgs("#ch", &long);
|
||||
assert_eq!(lines.len(), 1);
|
||||
// The PRIVMSG prefix is not counted in the limit.
|
||||
let content = lines[0].strip_prefix("PRIVMSG #ch :").unwrap();
|
||||
assert!(content.chars().count() <= TWITCH_MESSAGE_LIMIT);
|
||||
assert!(content.ends_with("..."));
|
||||
}
|
||||
|
||||
// ── Text truncation ───────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn truncate_short_text() {
|
||||
assert_eq!(truncate_text("short", 480), "short");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_exact_limit() {
|
||||
let text = "a".repeat(480);
|
||||
assert_eq!(truncate_text(&text, 480), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_over_limit() {
|
||||
let text = "b".repeat(500);
|
||||
let result = truncate_text(&text, 480);
|
||||
assert_eq!(result.chars().count(), 480);
|
||||
assert!(result.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_unicode() {
|
||||
let text = "你好世界测试这是一个很长的中文字符串";
|
||||
// 16 chars, truncate to 10
|
||||
let result = truncate_text(text, 10);
|
||||
assert_eq!(result.chars().count(), 10);
|
||||
assert!(result.ends_with("..."));
|
||||
}
|
||||
|
||||
// ── PRIVMSG → UnifiedIncomingMessage ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn privmsg_to_unified_text() {
|
||||
let msg = ParsedPrivmsg {
|
||||
nick: "alice".into(),
|
||||
channel: "#test".into(),
|
||||
message: "hello there".into(),
|
||||
};
|
||||
let unified = privmsg_to_unified(&msg);
|
||||
assert_eq!(unified.platform, PluginType::Twitch);
|
||||
assert_eq!(unified.chat_id, "#test");
|
||||
assert_eq!(unified.content.text, "hello there");
|
||||
assert_eq!(unified.content.content_type, MessageContentType::Text);
|
||||
assert_eq!(unified.user.id, "alice");
|
||||
assert_eq!(unified.user.display_name, "alice");
|
||||
assert!(unified.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privmsg_to_unified_command() {
|
||||
let msg = ParsedPrivmsg {
|
||||
nick: "bob".into(),
|
||||
channel: "#game".into(),
|
||||
message: "!roll 20".into(),
|
||||
};
|
||||
let unified = privmsg_to_unified(&msg);
|
||||
assert_eq!(unified.content.content_type, MessageContentType::Command);
|
||||
assert_eq!(unified.content.text, "!roll 20");
|
||||
}
|
||||
|
||||
// ── Plugin initial state ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn new_plugin_initial_state() {
|
||||
let p = TwitchPlugin::new();
|
||||
assert_eq!(p.status(), PluginStatus::Created);
|
||||
assert!(p.bot_info().is_none());
|
||||
assert_eq!(p.plugin_type(), PluginType::Twitch);
|
||||
assert_eq!(p.active_user_count(), 0);
|
||||
assert!(p.last_error().is_none());
|
||||
}
|
||||
|
||||
// ── Backoff ───────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn backoff_is_exponential_and_capped() {
|
||||
assert_eq!(backoff_delay(1), Duration::from_secs(2));
|
||||
assert_eq!(backoff_delay(3), Duration::from_secs(8));
|
||||
assert_eq!(backoff_delay(10), Duration::from_secs(30)); // capped
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Twitch IRC + OAuth validation wire types.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Response from `GET https://id.twitch.tv/oauth2/validate`.
|
||||
///
|
||||
/// The validate endpoint returns the bot's login, user_id, client_id and
|
||||
/// granted scopes. We use `login` as the IRC NICK and `user_id` as the
|
||||
/// canonical bot identifier.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ValidateResponse {
|
||||
pub login: String,
|
||||
pub user_id: String,
|
||||
#[serde(default)]
|
||||
pub client_id: String,
|
||||
#[serde(default)]
|
||||
pub scopes: Vec<String>,
|
||||
}
|
||||
|
||||
/// A parsed IRC PRIVMSG line.
|
||||
///
|
||||
/// Twitch IRC messages arriving over WebSocket have the form:
|
||||
/// ```text
|
||||
/// @<tags> :<nick>!<user>@<host> PRIVMSG #<channel> :<message>
|
||||
/// ```
|
||||
/// or without tags:
|
||||
/// ```text
|
||||
/// :<nick>!<user>@<host> PRIVMSG #<channel> :<message>
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ParsedPrivmsg {
|
||||
/// The sender's IRC nick (lowercase login).
|
||||
pub nick: String,
|
||||
/// The channel including the leading '#'.
|
||||
pub channel: String,
|
||||
/// The message text (everything after the second ':').
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Result of parsing a raw IRC line.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum IrcLine {
|
||||
/// A PING from the server requiring a PONG reply.
|
||||
Ping(String),
|
||||
/// A PRIVMSG (chat message).
|
||||
Privmsg(ParsedPrivmsg),
|
||||
/// Any other IRC line we don't need to handle.
|
||||
Other,
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine;
|
||||
use reqwest::Client;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use tracing::{debug, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::constants::{WEIXIN_API_TIMEOUT, WEIXIN_POLL_TIMEOUT};
|
||||
use crate::error::ChannelError;
|
||||
|
||||
use super::types::{
|
||||
GetUpdatesRequest, GetUpdatesResponse, ILinkResponse, ITEM_TYPE_TEXT, QrCodeData, QrCodeStatusData,
|
||||
SendMessageItem, SendMessageMsg, SendMessageRequest, SendTextItem,
|
||||
};
|
||||
|
||||
/// HTTP client for the WeChat iLink Bot API.
|
||||
pub(crate) struct WeixinApi {
|
||||
client: Client,
|
||||
base_url: String,
|
||||
bot_token: String,
|
||||
wechat_uin: String,
|
||||
}
|
||||
|
||||
impl WeixinApi {
|
||||
pub fn new(client: Client, base_url: &str, bot_token: &str) -> Self {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
|
||||
let mut uin_bytes = [0u8; 4];
|
||||
getrandom::getrandom(&mut uin_bytes).expect("RNG failure");
|
||||
let wechat_uin = base64::engine::general_purpose::STANDARD.encode(uin_bytes);
|
||||
|
||||
Self {
|
||||
client,
|
||||
base_url: base.to_string(),
|
||||
bot_token: bot_token.to_string(),
|
||||
wechat_uin,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn bot_token(&self) -> &str {
|
||||
&self.bot_token
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn wechat_uin(&self) -> &str {
|
||||
&self.wechat_uin
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async fn authenticated_post<T: DeserializeOwned>(
|
||||
&self,
|
||||
endpoint: &str,
|
||||
body: &impl Serialize,
|
||||
timeout: Duration,
|
||||
) -> Result<T, ChannelError> {
|
||||
let url = format!("{}/{}", self.base_url, endpoint);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("AuthorizationType", "ilink_bot_token")
|
||||
.header("Authorization", format!("Bearer {}", self.bot_token))
|
||||
.header("X-WECHAT-UIN", &self.wechat_uin)
|
||||
.timeout(timeout)
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("{endpoint} request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ChannelError::PlatformApi(format!("{endpoint} HTTP {status}: {text}")));
|
||||
}
|
||||
|
||||
resp.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("{endpoint} parse failed: {e}")))
|
||||
}
|
||||
|
||||
async fn ilink_get<T: DeserializeOwned>(&self, endpoint: &str, query: &[(&str, &str)]) -> Result<T, ChannelError> {
|
||||
let url = format!("{}/{}", self.base_url, endpoint);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.header("iLink-App-ClientVersion", "1")
|
||||
.query(query)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("{endpoint} request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ChannelError::PlatformApi(format!("{endpoint} HTTP {status}: {text}")));
|
||||
}
|
||||
|
||||
resp.json()
|
||||
.await
|
||||
.map_err(|e| ChannelError::PlatformApi(format!("{endpoint} parse failed: {e}")))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// QR code login
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Fetch a QR code for bot login.
|
||||
///
|
||||
/// `GET /ilink/bot/get_bot_qrcode?bot_type=3`
|
||||
pub async fn get_bot_qrcode(&self) -> Result<QrCodeData, ChannelError> {
|
||||
debug!("Fetching WeChat QR code");
|
||||
|
||||
// Try direct response first, then wrapped
|
||||
let result: Result<QrCodeData, _> = self.ilink_get("ilink/bot/get_bot_qrcode", &[("bot_type", "3")]).await;
|
||||
|
||||
match result {
|
||||
Ok(data) if data.qrcode.is_some() => Ok(data),
|
||||
_ => {
|
||||
let wrapped: ILinkResponse<QrCodeData> =
|
||||
self.ilink_get("ilink/bot/get_bot_qrcode", &[("bot_type", "3")]).await?;
|
||||
wrapped
|
||||
.data
|
||||
.ok_or_else(|| ChannelError::PlatformApi("get_bot_qrcode returned no data".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check the status of a QR code scan.
|
||||
///
|
||||
/// `GET /ilink/bot/get_qrcode_status?qrcode=<ticket>`
|
||||
pub async fn get_qrcode_status(&self, qrcode: &str) -> Result<QrCodeStatusData, ChannelError> {
|
||||
// Try direct response first, then wrapped
|
||||
let result: Result<QrCodeStatusData, _> = self
|
||||
.ilink_get("ilink/bot/get_qrcode_status", &[("qrcode", qrcode)])
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(data) if data.status.is_some() => Ok(data),
|
||||
_ => {
|
||||
let wrapped: ILinkResponse<QrCodeStatusData> = self
|
||||
.ilink_get("ilink/bot/get_qrcode_status", &[("qrcode", qrcode)])
|
||||
.await?;
|
||||
wrapped
|
||||
.data
|
||||
.ok_or_else(|| ChannelError::PlatformApi("get_qrcode_status returned no data".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Long-polling
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Long-poll for new updates using buffer-based protocol.
|
||||
///
|
||||
/// `POST /ilink/bot/getupdates`
|
||||
pub async fn get_updates(&self, buf: &str) -> Result<GetUpdatesResponse, ChannelError> {
|
||||
let body = GetUpdatesRequest {
|
||||
get_updates_buf: buf.to_string(),
|
||||
base_info: serde_json::json!({}),
|
||||
};
|
||||
|
||||
let timeout = WEIXIN_POLL_TIMEOUT + Duration::from_secs(10);
|
||||
|
||||
self.authenticated_post("ilink/bot/getupdates", &body, timeout).await
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Send message
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Send a text message.
|
||||
///
|
||||
/// `POST /ilink/bot/sendmessage`
|
||||
pub async fn send_message(
|
||||
&self,
|
||||
to_user_id: &str,
|
||||
text: &str,
|
||||
context_token: Option<&str>,
|
||||
) -> Result<(), ChannelError> {
|
||||
debug!(to_user_id, "Sending WeChat message");
|
||||
|
||||
let body = SendMessageRequest {
|
||||
msg: SendMessageMsg {
|
||||
to_user_id: to_user_id.to_string(),
|
||||
client_id: Uuid::new_v4().to_string(),
|
||||
message_type: 2,
|
||||
message_state: 2,
|
||||
item_list: vec![SendMessageItem {
|
||||
item_type: ITEM_TYPE_TEXT,
|
||||
text_item: Some(SendTextItem { text: text.to_string() }),
|
||||
}],
|
||||
context_token: context_token.map(String::from),
|
||||
},
|
||||
base_info: serde_json::json!({}),
|
||||
};
|
||||
|
||||
let _resp: serde_json::Value = self
|
||||
.authenticated_post("ilink/bot/sendmessage", &body, WEIXIN_API_TIMEOUT)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!(to_user_id, error = %e, "sendmessage failed");
|
||||
ChannelError::MessageSendFailed(format!("sendmessage failed: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn api_stores_credentials() {
|
||||
let client = Client::new();
|
||||
let api = WeixinApi::new(client, "https://ilinkai.weixin.qq.com/", "tok_abc");
|
||||
assert_eq!(api.base_url, "https://ilinkai.weixin.qq.com");
|
||||
assert_eq!(api.bot_token(), "tok_abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_normalizes_trailing_slash() {
|
||||
let client = Client::new();
|
||||
let api = WeixinApi::new(client, "https://ilinkai.weixin.qq.com///", "tok");
|
||||
assert!(api.base_url.ends_with("com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_generates_wechat_uin() {
|
||||
let client = Client::new();
|
||||
let api = WeixinApi::new(client, "https://example.com", "tok");
|
||||
// base64 of 4 bytes should be 8 chars (with padding)
|
||||
assert_eq!(api.wechat_uin().len(), 8);
|
||||
// Should be valid base64
|
||||
let decoded = base64::engine::general_purpose::STANDARD.decode(api.wechat_uin());
|
||||
assert!(decoded.is_ok());
|
||||
assert_eq!(decoded.unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_generates_different_uin_each_time() {
|
||||
let client1 = Client::new();
|
||||
let api1 = WeixinApi::new(client1, "https://example.com", "tok");
|
||||
let client2 = Client::new();
|
||||
let api2 = WeixinApi::new(client2, "https://example.com", "tok");
|
||||
// Extremely unlikely to collide (2^32 space)
|
||||
assert_ne!(api1.wechat_uin(), api2.wechat_uin());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::Client;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
use super::api::WeixinApi;
|
||||
use super::types::{SseDoneEvent, SseErrorEvent, SseQrEvent};
|
||||
|
||||
/// Default base URL for the iLink Bot login API.
|
||||
const LOGIN_BASE_URL: &str = "https://ilinkai.weixin.qq.com";
|
||||
|
||||
/// Polling interval for checking QR code scan status.
|
||||
const QR_POLL_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Maximum time to wait for QR code scan before timeout.
|
||||
const QR_LOGIN_TIMEOUT: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
/// SSE event emitted during the WeChat QR code login flow.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum WeixinLoginEvent {
|
||||
/// QR code ticket data — frontend renders this as a QR image.
|
||||
Qr(String),
|
||||
/// User scanned the QR code.
|
||||
Scanned,
|
||||
/// Login successful — returns credentials for `channel.enable-plugin`.
|
||||
Done {
|
||||
account_id: String,
|
||||
bot_token: String,
|
||||
base_url: String,
|
||||
},
|
||||
/// Login failed with an error message.
|
||||
Error(String),
|
||||
}
|
||||
|
||||
impl WeixinLoginEvent {
|
||||
/// SSE event name string.
|
||||
pub fn event_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Qr(_) => "qr",
|
||||
Self::Scanned => "scanned",
|
||||
Self::Done { .. } => "done",
|
||||
Self::Error(_) => "error",
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize as a single `channel.weixin-login` WS payload, using a `phase`
|
||||
/// field as the discriminator (the SSE event-name dimension is folded in).
|
||||
///
|
||||
/// This is the shape the desktop/WebUI frontend subscribes to over the
|
||||
/// WebSocket — the QR login flow can't use SSE because `EventSource` cannot
|
||||
/// carry the desktop's `x-nomi-local-trust` header.
|
||||
pub fn to_ws_payload(&self) -> serde_json::Value {
|
||||
match self {
|
||||
Self::Qr(ticket) => serde_json::json!({ "phase": "qr", "qrcodeData": ticket }),
|
||||
Self::Scanned => serde_json::json!({ "phase": "scanned" }),
|
||||
Self::Done {
|
||||
account_id,
|
||||
bot_token,
|
||||
base_url,
|
||||
} => serde_json::json!({
|
||||
"phase": "done",
|
||||
"accountId": account_id,
|
||||
"botToken": bot_token,
|
||||
"baseUrl": base_url,
|
||||
}),
|
||||
Self::Error(message) => serde_json::json!({ "phase": "error", "message": message }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize the event payload as JSON.
|
||||
pub fn to_json_data(&self) -> String {
|
||||
match self {
|
||||
Self::Qr(ticket) => serde_json::to_string(&SseQrEvent {
|
||||
qrcode_data: ticket.clone(),
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
Self::Scanned => "{}".into(),
|
||||
Self::Done {
|
||||
account_id,
|
||||
bot_token,
|
||||
base_url,
|
||||
} => serde_json::to_string(&SseDoneEvent {
|
||||
account_id: account_id.clone(),
|
||||
bot_token: bot_token.clone(),
|
||||
base_url: base_url.clone(),
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
Self::Error(message) => serde_json::to_string(&SseErrorEvent {
|
||||
message: message.clone(),
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the WeChat QR code login flow, returning a channel of SSE events.
|
||||
pub fn weixin_login_stream() -> mpsc::Receiver<WeixinLoginEvent> {
|
||||
let (tx, rx) = mpsc::channel(16);
|
||||
tokio::spawn(login_flow(tx));
|
||||
rx
|
||||
}
|
||||
|
||||
/// Internal login flow that drives the SSE event sequence.
|
||||
async fn login_flow(tx: mpsc::Sender<WeixinLoginEvent>) {
|
||||
let client = match Client::builder().timeout(Duration::from_secs(40)).build() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let _ = tx
|
||||
.send(WeixinLoginEvent::Error(format!("HTTP client init failed: {e}")))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let api = WeixinApi::new(client, LOGIN_BASE_URL, "");
|
||||
|
||||
// Step 1: Fetch QR code
|
||||
let qr_data = match api.get_bot_qrcode().await {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
error!(error = %e, "Failed to fetch WeChat QR code");
|
||||
let _ = tx
|
||||
.send(WeixinLoginEvent::Error(format!("Failed to fetch QR code: {e}")))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let ticket = match qr_data.qrcode {
|
||||
Some(t) if !t.is_empty() => t,
|
||||
_ => {
|
||||
let _ = tx
|
||||
.send(WeixinLoginEvent::Error("QR code response missing ticket".into()))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let qr_content = match qr_data.qrcode_img_content {
|
||||
Some(ref url) if !url.is_empty() => url.clone(),
|
||||
_ => {
|
||||
let _ = tx
|
||||
.send(WeixinLoginEvent::Error(
|
||||
"QR code response missing qrcode_img_content".into(),
|
||||
))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
info!("WeChat QR code generated, waiting for scan");
|
||||
if tx.send(WeixinLoginEvent::Qr(qr_content)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Poll for scan status
|
||||
let deadline = tokio::time::Instant::now() + QR_LOGIN_TIMEOUT;
|
||||
let mut scanned_sent = false;
|
||||
|
||||
loop {
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
let _ = tx.send(WeixinLoginEvent::Error("QR code login timeout".into())).await;
|
||||
return;
|
||||
}
|
||||
|
||||
tokio::time::sleep(QR_POLL_INTERVAL).await;
|
||||
|
||||
match api.get_qrcode_status(&ticket).await {
|
||||
Ok(status) => {
|
||||
let state = status.status.as_deref().unwrap_or("wait");
|
||||
debug!(status = state, "WeChat QR code status");
|
||||
|
||||
match state {
|
||||
// NOTE: The API returns "scaned" (missing an 'n') — this is intentional.
|
||||
"scaned" if !scanned_sent => {
|
||||
scanned_sent = true;
|
||||
if tx.send(WeixinLoginEvent::Scanned).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
"confirmed" => {
|
||||
let account_id = status.ilink_bot_id.unwrap_or_default();
|
||||
let bot_token = status.bot_token.unwrap_or_default();
|
||||
let base_url = status.baseurl.unwrap_or_else(|| LOGIN_BASE_URL.into());
|
||||
|
||||
info!(
|
||||
account_id = %account_id,
|
||||
"WeChat QR code login confirmed"
|
||||
);
|
||||
let _ = tx
|
||||
.send(WeixinLoginEvent::Done {
|
||||
account_id,
|
||||
bot_token,
|
||||
base_url,
|
||||
})
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
"expired" => {
|
||||
let _ = tx.send(WeixinLoginEvent::Error("QR code expired".into())).await;
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Timeout on long-poll is expected — treat as "wait" and retry
|
||||
let err_str = e.to_string();
|
||||
if err_str.contains("timed out") || err_str.contains("Timeout") {
|
||||
debug!("QR status poll timeout, retrying");
|
||||
continue;
|
||||
}
|
||||
error!(error = %e, "Failed to poll QR code status");
|
||||
let _ = tx
|
||||
.send(WeixinLoginEvent::Error(format!("Status poll failed: {e}")))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn login_event_names() {
|
||||
assert_eq!(WeixinLoginEvent::Qr("t".into()).event_name(), "qr");
|
||||
assert_eq!(WeixinLoginEvent::Scanned.event_name(), "scanned");
|
||||
assert_eq!(
|
||||
WeixinLoginEvent::Done {
|
||||
account_id: "a".into(),
|
||||
bot_token: "b".into(),
|
||||
base_url: "c".into(),
|
||||
}
|
||||
.event_name(),
|
||||
"done"
|
||||
);
|
||||
assert_eq!(WeixinLoginEvent::Error("err".into()).event_name(), "error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_event_qr_json() {
|
||||
let evt = WeixinLoginEvent::Qr("ticket_123".into());
|
||||
let json = evt.to_json_data();
|
||||
assert!(json.contains("qrcodeData"));
|
||||
assert!(json.contains("ticket_123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_event_scanned_json() {
|
||||
let evt = WeixinLoginEvent::Scanned;
|
||||
assert_eq!(evt.to_json_data(), "{}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_event_done_json() {
|
||||
let evt = WeixinLoginEvent::Done {
|
||||
account_id: "acc_1".into(),
|
||||
bot_token: "tok_1".into(),
|
||||
base_url: "https://ilinkai.weixin.qq.com".into(),
|
||||
};
|
||||
let json = evt.to_json_data();
|
||||
assert!(json.contains("accountId"));
|
||||
assert!(json.contains("acc_1"));
|
||||
assert!(json.contains("botToken"));
|
||||
assert!(json.contains("tok_1"));
|
||||
assert!(json.contains("baseUrl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_event_error_json() {
|
||||
let evt = WeixinLoginEvent::Error("timeout".into());
|
||||
let json = evt.to_json_data();
|
||||
assert!(json.contains(r#""message":"timeout"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_ws_payload_phases() {
|
||||
// qr → { phase: "qr", qrcodeData }
|
||||
let qr = WeixinLoginEvent::Qr("https://example.com/qr.png".into()).to_ws_payload();
|
||||
assert_eq!(qr["phase"], "qr");
|
||||
assert_eq!(qr["qrcodeData"], "https://example.com/qr.png");
|
||||
|
||||
// scanned → { phase: "scanned" }
|
||||
let scanned = WeixinLoginEvent::Scanned.to_ws_payload();
|
||||
assert_eq!(scanned["phase"], "scanned");
|
||||
|
||||
// done → { phase: "done", accountId, botToken, baseUrl }
|
||||
let done = WeixinLoginEvent::Done {
|
||||
account_id: "acc_1".into(),
|
||||
bot_token: "tok_1".into(),
|
||||
base_url: "https://ilinkai.weixin.qq.com".into(),
|
||||
}
|
||||
.to_ws_payload();
|
||||
assert_eq!(done["phase"], "done");
|
||||
assert_eq!(done["accountId"], "acc_1");
|
||||
assert_eq!(done["botToken"], "tok_1");
|
||||
assert_eq!(done["baseUrl"], "https://ilinkai.weixin.qq.com");
|
||||
|
||||
// error → { phase: "error", message }
|
||||
let err = WeixinLoginEvent::Error("QR code expired".into()).to_ws_payload();
|
||||
assert_eq!(err["phase"], "error");
|
||||
assert_eq!(err["message"], "QR code expired");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_constants() {
|
||||
assert_eq!(QR_POLL_INTERVAL, Duration::from_secs(2));
|
||||
assert_eq!(QR_LOGIN_TIMEOUT, Duration::from_secs(300));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mod api;
|
||||
mod login;
|
||||
mod plugin;
|
||||
mod types;
|
||||
|
||||
pub use login::{WeixinLoginEvent, weixin_login_stream};
|
||||
pub use plugin::WeixinPlugin;
|
||||
@@ -0,0 +1,563 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use dashmap::DashMap;
|
||||
use reqwest::Client;
|
||||
use tokio::sync::watch;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::constants::{WEIXIN_BACKOFF_DELAY, WEIXIN_MAX_RETRIES, WEIXIN_POLL_TIMEOUT, WEIXIN_RETRY_DELAY};
|
||||
use crate::error::ChannelError;
|
||||
use crate::plugin::{ChannelPlugin, PluginCallbacks};
|
||||
use crate::types::{
|
||||
BotInfo, MessageContentType, PluginConfig, PluginStatus, PluginType, UnifiedIncomingMessage, UnifiedMessageContent,
|
||||
UnifiedOutgoingMessage, UnifiedUser,
|
||||
};
|
||||
|
||||
use super::api::WeixinApi;
|
||||
use super::types::{ITEM_TYPE_TEXT, ITEM_TYPE_VOICE, WeixinRawItem, WeixinRawMessage};
|
||||
|
||||
/// Default base URL for the iLink Bot API.
|
||||
const DEFAULT_BASE_URL: &str = "https://ilinkai.weixin.qq.com";
|
||||
|
||||
/// WeChat (iLink Bot) platform plugin.
|
||||
///
|
||||
/// Connects via long-polling (buffer-based `getupdates`), handles text/voice
|
||||
/// messages. Does not support editing messages (WeChat limitation);
|
||||
/// `edit_message` sends a new reply instead.
|
||||
pub struct WeixinPlugin {
|
||||
status: PluginStatus,
|
||||
bot_info: Option<BotInfo>,
|
||||
last_error: Option<String>,
|
||||
api: Option<Arc<WeixinApi>>,
|
||||
poll_handle: Option<JoinHandle<()>>,
|
||||
shutdown_tx: Option<watch::Sender<bool>>,
|
||||
context_tokens: Arc<DashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl Default for WeixinPlugin {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
status: PluginStatus::Created,
|
||||
bot_info: None,
|
||||
last_error: None,
|
||||
api: None,
|
||||
poll_handle: None,
|
||||
shutdown_tx: None,
|
||||
context_tokens: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WeixinPlugin {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ChannelPlugin for WeixinPlugin {
|
||||
async fn initialize(&mut self, config: PluginConfig, callbacks: PluginCallbacks) -> Result<(), ChannelError> {
|
||||
self.status = PluginStatus::Initializing;
|
||||
|
||||
let bot_token = config
|
||||
.credentials
|
||||
.bot_token
|
||||
.as_deref()
|
||||
.filter(|t| !t.is_empty())
|
||||
.ok_or_else(|| {
|
||||
self.status = PluginStatus::Error;
|
||||
self.last_error = Some("Missing WeChat bot_token".into());
|
||||
ChannelError::InvalidConfig("Missing WeChat bot_token".into())
|
||||
})?;
|
||||
|
||||
let account_id = config
|
||||
.credentials
|
||||
.account_id
|
||||
.as_deref()
|
||||
.filter(|a| !a.is_empty())
|
||||
.ok_or_else(|| {
|
||||
self.status = PluginStatus::Error;
|
||||
self.last_error = Some("Missing WeChat account_id".into());
|
||||
ChannelError::InvalidConfig("Missing WeChat account_id".into())
|
||||
})?;
|
||||
|
||||
let base_url = config
|
||||
.credentials
|
||||
.extra
|
||||
.get("baseUrl")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(DEFAULT_BASE_URL);
|
||||
|
||||
let http_client = Client::builder()
|
||||
.timeout(Duration::from_secs(WEIXIN_POLL_TIMEOUT.as_secs() + 10))
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
self.status = PluginStatus::Error;
|
||||
self.last_error = Some(format!("HTTP client init failed: {e}"));
|
||||
ChannelError::ConnectionFailed(format!("HTTP client init failed: {e}"))
|
||||
})?;
|
||||
|
||||
let api = Arc::new(WeixinApi::new(http_client, base_url, bot_token));
|
||||
|
||||
self.bot_info = Some(BotInfo {
|
||||
id: account_id.to_string(),
|
||||
username: None,
|
||||
display_name: format!("WeChat Bot ({account_id})"),
|
||||
});
|
||||
|
||||
info!(account_id, "WeChat bot initialized");
|
||||
|
||||
self.api = Some(api);
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
self.shutdown_tx = Some(shutdown_tx);
|
||||
|
||||
let api_clone = Arc::clone(self.api.as_ref().expect("api just set"));
|
||||
let context_tokens = Arc::clone(&self.context_tokens);
|
||||
self.poll_handle = Some(tokio::spawn(poll_loop(
|
||||
api_clone,
|
||||
callbacks.message_tx,
|
||||
shutdown_rx,
|
||||
context_tokens,
|
||||
)));
|
||||
|
||||
self.status = PluginStatus::Ready;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start(&mut self) -> Result<(), ChannelError> {
|
||||
self.status = PluginStatus::Starting;
|
||||
self.status = PluginStatus::Running;
|
||||
info!("WeChat plugin started");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(&mut self) -> Result<(), ChannelError> {
|
||||
self.status = PluginStatus::Stopping;
|
||||
|
||||
if let Some(tx) = self.shutdown_tx.take() {
|
||||
let _ = tx.send(true);
|
||||
}
|
||||
|
||||
if let Some(handle) = self.poll_handle.take() {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;
|
||||
}
|
||||
|
||||
self.api = None;
|
||||
self.context_tokens.clear();
|
||||
self.status = PluginStatus::Stopped;
|
||||
info!("WeChat 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 = message.text.as_deref().unwrap_or("").to_string();
|
||||
let context_token = self.context_tokens.get(chat_id).map(|v| v.clone());
|
||||
|
||||
api.send_message(chat_id, &text, context_token.as_deref()).await?;
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
/// WeChat does not support editing messages.
|
||||
async fn edit_message(
|
||||
&self,
|
||||
chat_id: &str,
|
||||
_message_id: &str,
|
||||
message: UnifiedOutgoingMessage,
|
||||
) -> Result<(), ChannelError> {
|
||||
let _ = self.send_message(chat_id, message).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn active_user_count(&self) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
fn bot_info(&self) -> Option<&BotInfo> {
|
||||
self.bot_info.as_ref()
|
||||
}
|
||||
|
||||
fn plugin_type(&self) -> PluginType {
|
||||
PluginType::Weixin
|
||||
}
|
||||
|
||||
fn status(&self) -> PluginStatus {
|
||||
self.status
|
||||
}
|
||||
|
||||
fn last_error(&self) -> Option<&str> {
|
||||
self.last_error.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Long-polling loop (buffer-based protocol)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn poll_loop(
|
||||
api: Arc<WeixinApi>,
|
||||
message_tx: tokio::sync::mpsc::Sender<UnifiedIncomingMessage>,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
context_tokens: Arc<DashMap<String, String>>,
|
||||
) {
|
||||
let mut buf = String::new();
|
||||
let mut consecutive_failures: u32 = 0;
|
||||
|
||||
loop {
|
||||
if *shutdown_rx.borrow() {
|
||||
debug!("WeChat poll loop received shutdown signal");
|
||||
break;
|
||||
}
|
||||
|
||||
match api.get_updates(&buf).await {
|
||||
Ok(resp) => {
|
||||
let is_api_error = resp.ret.unwrap_or(0) != 0 || resp.errcode.unwrap_or(0) != 0;
|
||||
|
||||
if is_api_error {
|
||||
consecutive_failures += 1;
|
||||
warn!(
|
||||
ret = resp.ret,
|
||||
errcode = resp.errcode,
|
||||
consecutive_failures,
|
||||
"WeChat getupdates API error"
|
||||
);
|
||||
|
||||
if consecutive_failures >= WEIXIN_MAX_RETRIES {
|
||||
consecutive_failures = 0;
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(WEIXIN_BACKOFF_DELAY) => {}
|
||||
_ = shutdown_rx.changed() => {
|
||||
debug!("WeChat poll loop shutdown during backoff");
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(WEIXIN_RETRY_DELAY) => {}
|
||||
_ = shutdown_rx.changed() => {
|
||||
debug!("WeChat poll loop shutdown during retry");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
consecutive_failures = 0;
|
||||
|
||||
if let Some(new_buf) = resp.get_updates_buf {
|
||||
buf = new_buf;
|
||||
}
|
||||
|
||||
for msg in resp.msgs.unwrap_or_default() {
|
||||
handle_message(&msg, &message_tx, &context_tokens).await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
consecutive_failures += 1;
|
||||
warn!(error = %e, consecutive_failures, "WeChat poll error");
|
||||
|
||||
if consecutive_failures >= WEIXIN_MAX_RETRIES {
|
||||
consecutive_failures = 0;
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(WEIXIN_BACKOFF_DELAY) => {}
|
||||
_ = shutdown_rx.changed() => {
|
||||
debug!("WeChat poll loop shutdown during backoff");
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(WEIXIN_RETRY_DELAY) => {}
|
||||
_ = shutdown_rx.changed() => {
|
||||
debug!("WeChat poll loop shutdown during retry");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!("WeChat poll loop exited");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn handle_message(
|
||||
msg: &WeixinRawMessage,
|
||||
message_tx: &tokio::sync::mpsc::Sender<UnifiedIncomingMessage>,
|
||||
context_tokens: &DashMap<String, String>,
|
||||
) {
|
||||
let from_user_id = match &msg.from_user_id {
|
||||
Some(id) if !id.is_empty() => id.clone(),
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// Store context_token for reply use
|
||||
if let Some(ctx) = &msg.context_token
|
||||
&& !ctx.is_empty()
|
||||
{
|
||||
context_tokens.insert(from_user_id.clone(), ctx.clone());
|
||||
}
|
||||
|
||||
let items = msg.item_list.as_deref().unwrap_or_default();
|
||||
let (content_type, text, _has_media) = extract_content(items);
|
||||
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let display_name = if from_user_id.len() > 6 {
|
||||
from_user_id[from_user_id.len() - 6..].to_string()
|
||||
} else {
|
||||
from_user_id.clone()
|
||||
};
|
||||
|
||||
let unified = UnifiedIncomingMessage {
|
||||
id: msg.msg_id.clone().unwrap_or_default(),
|
||||
platform: PluginType::Weixin,
|
||||
chat_id: from_user_id.clone(),
|
||||
user: UnifiedUser {
|
||||
id: from_user_id,
|
||||
username: None,
|
||||
display_name,
|
||||
avatar_url: None,
|
||||
},
|
||||
content: UnifiedMessageContent {
|
||||
content_type,
|
||||
text,
|
||||
attachments: None,
|
||||
},
|
||||
timestamp: chrono_now(),
|
||||
reply_to_message_id: None,
|
||||
action: None,
|
||||
raw: None,
|
||||
};
|
||||
|
||||
let _ = message_tx.send(unified).await;
|
||||
}
|
||||
|
||||
/// Extract text content from item_list.
|
||||
///
|
||||
/// Returns (content_type, combined_text, has_media_items).
|
||||
fn extract_content(items: &[WeixinRawItem]) -> (MessageContentType, String, bool) {
|
||||
let mut text_parts: Vec<&str> = Vec::new();
|
||||
let mut has_media = false;
|
||||
|
||||
for item in items {
|
||||
match item.item_type {
|
||||
Some(ITEM_TYPE_TEXT) => {
|
||||
if let Some(ref ti) = item.text_item
|
||||
&& let Some(ref t) = ti.text
|
||||
{
|
||||
let trimmed = t.trim();
|
||||
if !trimmed.is_empty() {
|
||||
text_parts.push(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(ITEM_TYPE_VOICE) => {
|
||||
if let Some(ref vi) = item.voice_item
|
||||
&& let Some(ref t) = vi.text
|
||||
{
|
||||
let trimmed = t.trim();
|
||||
if !trimmed.is_empty() {
|
||||
text_parts.push(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(2) | Some(4) => {
|
||||
has_media = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let text = text_parts.join("\n\n");
|
||||
|
||||
let content_type = if text.starts_with('/') {
|
||||
MessageContentType::Command
|
||||
} else {
|
||||
MessageContentType::Text
|
||||
};
|
||||
|
||||
(content_type, text, has_media)
|
||||
}
|
||||
|
||||
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::*;
|
||||
use crate::types::PluginCredentials;
|
||||
use std::collections::HashMap;
|
||||
|
||||
// -- extract_content -------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn extract_text_only() {
|
||||
let items = vec![make_text_item("Hello world")];
|
||||
let (ct, text, has_media) = extract_content(&items);
|
||||
assert_eq!(ct, MessageContentType::Text);
|
||||
assert_eq!(text, "Hello world");
|
||||
assert!(!has_media);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_command() {
|
||||
let items = vec![make_text_item("/start")];
|
||||
let (ct, text, _) = extract_content(&items);
|
||||
assert_eq!(ct, MessageContentType::Command);
|
||||
assert_eq!(text, "/start");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_voice_text() {
|
||||
let items = vec![WeixinRawItem {
|
||||
item_type: Some(ITEM_TYPE_VOICE),
|
||||
voice_item: Some(super::super::types::VoiceItem {
|
||||
text: Some("transcribed text".into()),
|
||||
}),
|
||||
..Default::default()
|
||||
}];
|
||||
let (ct, text, _) = extract_content(&items);
|
||||
assert_eq!(ct, MessageContentType::Text);
|
||||
assert_eq!(text, "transcribed text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_mixed_text_and_voice() {
|
||||
let items = vec![
|
||||
make_text_item("Hello"),
|
||||
WeixinRawItem {
|
||||
item_type: Some(ITEM_TYPE_VOICE),
|
||||
voice_item: Some(super::super::types::VoiceItem {
|
||||
text: Some("voice part".into()),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let (_, text, _) = extract_content(&items);
|
||||
assert_eq!(text, "Hello\n\nvoice part");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_media_items_detected() {
|
||||
let items = vec![WeixinRawItem {
|
||||
item_type: Some(2),
|
||||
image_item: Some(super::super::types::MediaItemData {
|
||||
media: Some(super::super::types::MediaEncryptInfo {
|
||||
encrypt_query_param: Some("enc".into()),
|
||||
aes_key: Some("key".into()),
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}];
|
||||
let (_, text, has_media) = extract_content(&items);
|
||||
assert!(text.is_empty());
|
||||
assert!(has_media);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_empty_items() {
|
||||
let items: Vec<WeixinRawItem> = vec![];
|
||||
let (_, text, has_media) = extract_content(&items);
|
||||
assert!(text.is_empty());
|
||||
assert!(!has_media);
|
||||
}
|
||||
|
||||
// -- WeixinPlugin constructor -----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn new_plugin_initial_state() {
|
||||
let plugin = WeixinPlugin::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::Weixin);
|
||||
assert_eq!(plugin.active_user_count(), 0);
|
||||
}
|
||||
|
||||
// -- initialize validation --------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn initialize_missing_bot_token_fails() {
|
||||
let mut plugin = WeixinPlugin::new();
|
||||
let config = make_config(None, Some("acc_1"));
|
||||
let callbacks = make_callbacks();
|
||||
let result = plugin.initialize(config, callbacks).await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(plugin.status(), PluginStatus::Error);
|
||||
assert_eq!(plugin.last_error(), Some("Missing WeChat bot_token"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initialize_missing_account_id_fails() {
|
||||
let mut plugin = WeixinPlugin::new();
|
||||
let config = make_config(Some("tok_1"), None);
|
||||
let callbacks = make_callbacks();
|
||||
let result = plugin.initialize(config, callbacks).await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(plugin.status(), PluginStatus::Error);
|
||||
assert_eq!(plugin.last_error(), Some("Missing WeChat account_id"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initialize_empty_bot_token_fails() {
|
||||
let mut plugin = WeixinPlugin::new();
|
||||
let config = make_config(Some(""), Some("acc_1"));
|
||||
let callbacks = make_callbacks();
|
||||
let result = plugin.initialize(config, callbacks).await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(plugin.status(), PluginStatus::Error);
|
||||
}
|
||||
|
||||
// -- Test helpers -----------------------------------------------------------
|
||||
|
||||
fn make_text_item(text: &str) -> WeixinRawItem {
|
||||
WeixinRawItem {
|
||||
item_type: Some(ITEM_TYPE_TEXT),
|
||||
text_item: Some(super::super::types::TextItem {
|
||||
text: Some(text.into()),
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn make_config(bot_token: Option<&str>, account_id: Option<&str>) -> PluginConfig {
|
||||
PluginConfig {
|
||||
credentials: PluginCredentials {
|
||||
account_id: account_id.map(String::from),
|
||||
bot_token: bot_token.map(String::from),
|
||||
..Default::default()
|
||||
},
|
||||
config: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_callbacks() -> PluginCallbacks {
|
||||
let (message_tx, _) = tokio::sync::mpsc::channel(16);
|
||||
let (confirm_tx, _) = tokio::sync::mpsc::channel(16);
|
||||
PluginCallbacks { message_tx, confirm_tx }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Item type constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) const ITEM_TYPE_TEXT: i32 = 1;
|
||||
#[allow(dead_code)]
|
||||
pub(crate) const ITEM_TYPE_IMAGE: i32 = 2;
|
||||
pub(crate) const ITEM_TYPE_VOICE: i32 = 3;
|
||||
#[allow(dead_code)]
|
||||
pub(crate) const ITEM_TYPE_FILE: i32 = 4;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ILinkResponse wrapper (kept for QR code endpoints that may use it)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct ILinkResponse<T> {
|
||||
#[serde(default)]
|
||||
pub code: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub msg: Option<String>,
|
||||
#[serde(default)]
|
||||
pub data: Option<T>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// QR code login
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct QrCodeData {
|
||||
#[serde(default)]
|
||||
pub qrcode: Option<String>,
|
||||
#[serde(default)]
|
||||
pub qrcode_img_content: Option<String>,
|
||||
}
|
||||
|
||||
/// Response from `get_qrcode_status`.
|
||||
///
|
||||
/// The actual API returns snake_case with non-standard field names:
|
||||
/// `{ status, bot_token, ilink_bot_id, baseurl }`
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub(crate) struct QrCodeStatusData {
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub bot_token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ilink_bot_id: Option<String>,
|
||||
/// All lowercase — NOT `base_url`.
|
||||
#[serde(default)]
|
||||
pub baseurl: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getupdates (long-polling, buffer-based protocol)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct GetUpdatesRequest {
|
||||
pub get_updates_buf: String,
|
||||
pub base_info: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct GetUpdatesResponse {
|
||||
#[serde(default)]
|
||||
pub ret: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub errcode: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub errmsg: Option<String>,
|
||||
#[serde(default)]
|
||||
pub msgs: Option<Vec<WeixinRawMessage>>,
|
||||
#[serde(default)]
|
||||
pub get_updates_buf: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub(crate) struct WeixinRawMessage {
|
||||
#[serde(default)]
|
||||
pub from_user_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub context_token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub msg_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub item_list: Option<Vec<WeixinRawItem>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct WeixinRawItem {
|
||||
#[serde(default, rename = "type")]
|
||||
pub item_type: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub text_item: Option<TextItem>,
|
||||
#[serde(default)]
|
||||
pub voice_item: Option<VoiceItem>,
|
||||
#[serde(default)]
|
||||
pub image_item: Option<MediaItemData>,
|
||||
#[serde(default)]
|
||||
pub file_item: Option<MediaItemData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub(crate) struct TextItem {
|
||||
#[serde(default)]
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub(crate) struct VoiceItem {
|
||||
#[serde(default)]
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct MediaItemData {
|
||||
#[serde(default)]
|
||||
pub media: Option<MediaEncryptInfo>,
|
||||
#[serde(default)]
|
||||
pub aeskey: Option<String>,
|
||||
#[serde(default)]
|
||||
pub file_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct MediaEncryptInfo {
|
||||
#[serde(default)]
|
||||
pub encrypt_query_param: Option<String>,
|
||||
#[serde(default)]
|
||||
pub aes_key: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sendmessage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct SendMessageRequest {
|
||||
pub msg: SendMessageMsg,
|
||||
pub base_info: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct SendMessageMsg {
|
||||
pub to_user_id: String,
|
||||
pub client_id: String,
|
||||
pub message_type: i32,
|
||||
pub message_state: i32,
|
||||
pub item_list: Vec<SendMessageItem>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub context_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct SendMessageItem {
|
||||
#[serde(rename = "type")]
|
||||
pub item_type: i32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_item: Option<SendTextItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct SendTextItem {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE event payloads (frontend-facing — DO NOT CHANGE field names)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SseQrEvent {
|
||||
pub qrcode_data: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SseDoneEvent {
|
||||
pub account_id: String,
|
||||
pub bot_token: String,
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct SseErrorEvent {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deserialize_qr_code_data_direct() {
|
||||
let json = r#"{"qrcode": "ticket_123", "qrcode_img_content": "https://example.com/qr.png"}"#;
|
||||
let data: QrCodeData = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(data.qrcode.as_deref(), Some("ticket_123"));
|
||||
assert_eq!(data.qrcode_img_content.as_deref(), Some("https://example.com/qr.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_qr_code_data_wrapped() {
|
||||
let json = r#"{"code": 0, "data": {"qrcode": "ticket_456"}}"#;
|
||||
let resp: ILinkResponse<QrCodeData> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.code, Some(0));
|
||||
assert_eq!(resp.data.unwrap().qrcode.unwrap(), "ticket_456");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_qr_status_confirmed() {
|
||||
let json = r#"{
|
||||
"status": "confirmed",
|
||||
"bot_token": "tok_1",
|
||||
"ilink_bot_id": "acc_1",
|
||||
"baseurl": "https://ilinkai.weixin.qq.com"
|
||||
}"#;
|
||||
let data: QrCodeStatusData = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(data.status.as_deref(), Some("confirmed"));
|
||||
assert_eq!(data.bot_token.as_deref(), Some("tok_1"));
|
||||
assert_eq!(data.ilink_bot_id.as_deref(), Some("acc_1"));
|
||||
assert_eq!(data.baseurl.as_deref(), Some("https://ilinkai.weixin.qq.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_qr_status_scaned() {
|
||||
let json = r#"{"status": "scaned"}"#;
|
||||
let data: QrCodeStatusData = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(data.status.as_deref(), Some("scaned"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_get_updates_response() {
|
||||
let json = r#"{
|
||||
"ret": 0,
|
||||
"errcode": 0,
|
||||
"msgs": [{
|
||||
"from_user_id": "user_1",
|
||||
"context_token": "ctx_abc",
|
||||
"msg_id": "msg_1",
|
||||
"item_list": [
|
||||
{"type": 1, "text_item": {"text": "Hello"}}
|
||||
]
|
||||
}],
|
||||
"get_updates_buf": "base64buf=="
|
||||
}"#;
|
||||
let resp: GetUpdatesResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.ret, Some(0));
|
||||
assert_eq!(resp.errcode, Some(0));
|
||||
let msgs = resp.msgs.unwrap();
|
||||
assert_eq!(msgs.len(), 1);
|
||||
assert_eq!(msgs[0].from_user_id.as_deref(), Some("user_1"));
|
||||
assert_eq!(msgs[0].context_token.as_deref(), Some("ctx_abc"));
|
||||
let items = msgs[0].item_list.as_ref().unwrap();
|
||||
assert_eq!(items[0].item_type, Some(1));
|
||||
assert_eq!(items[0].text_item.as_ref().unwrap().text.as_deref(), Some("Hello"));
|
||||
assert_eq!(resp.get_updates_buf.as_deref(), Some("base64buf=="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_get_updates_with_media() {
|
||||
let json = r#"{
|
||||
"ret": 0,
|
||||
"msgs": [{
|
||||
"from_user_id": "user_2",
|
||||
"msg_id": "msg_2",
|
||||
"item_list": [
|
||||
{"type": 2, "image_item": {"media": {"encrypt_query_param": "enc_param", "aes_key": "key123"}, "aeskey": "hex_key"}},
|
||||
{"type": 4, "file_item": {"media": {"encrypt_query_param": "enc_f"}, "file_name": "doc.pdf"}}
|
||||
]
|
||||
}]
|
||||
}"#;
|
||||
let resp: GetUpdatesResponse = serde_json::from_str(json).unwrap();
|
||||
let msgs = resp.msgs.unwrap();
|
||||
let items = msgs[0].item_list.as_ref().unwrap();
|
||||
assert_eq!(items[0].item_type, Some(2));
|
||||
let img = items[0].image_item.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
img.media.as_ref().unwrap().encrypt_query_param.as_deref(),
|
||||
Some("enc_param")
|
||||
);
|
||||
assert_eq!(img.aeskey.as_deref(), Some("hex_key"));
|
||||
assert_eq!(items[1].item_type, Some(4));
|
||||
assert_eq!(
|
||||
items[1].file_item.as_ref().unwrap().file_name.as_deref(),
|
||||
Some("doc.pdf")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_get_updates_api_error() {
|
||||
let json = r#"{"ret": 1001, "errcode": 401, "errmsg": "invalid token"}"#;
|
||||
let resp: GetUpdatesResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.ret, Some(1001));
|
||||
assert_eq!(resp.errcode, Some(401));
|
||||
assert!(resp.msgs.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_send_message_request() {
|
||||
let req = SendMessageRequest {
|
||||
msg: SendMessageMsg {
|
||||
to_user_id: "user_1".into(),
|
||||
client_id: "uuid-1234".into(),
|
||||
message_type: 2,
|
||||
message_state: 2,
|
||||
item_list: vec![SendMessageItem {
|
||||
item_type: ITEM_TYPE_TEXT,
|
||||
text_item: Some(SendTextItem { text: "Hello".into() }),
|
||||
}],
|
||||
context_token: Some("ctx_abc".into()),
|
||||
},
|
||||
base_info: serde_json::json!({}),
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(json.contains(r#""to_user_id":"user_1"#));
|
||||
assert!(json.contains(r#""client_id":"uuid-1234"#));
|
||||
assert!(json.contains(r#""message_type":2"#));
|
||||
assert!(json.contains(r#""message_state":2"#));
|
||||
assert!(json.contains(r#""type":1"#));
|
||||
assert!(json.contains(r#""text":"Hello"#));
|
||||
assert!(json.contains(r#""context_token":"ctx_abc"#));
|
||||
assert!(json.contains(r#""base_info":{}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_send_message_no_context_token() {
|
||||
let req = SendMessageRequest {
|
||||
msg: SendMessageMsg {
|
||||
to_user_id: "user_1".into(),
|
||||
client_id: "uuid-1234".into(),
|
||||
message_type: 2,
|
||||
message_state: 2,
|
||||
item_list: vec![SendMessageItem {
|
||||
item_type: ITEM_TYPE_TEXT,
|
||||
text_item: Some(SendTextItem { text: "Hi".into() }),
|
||||
}],
|
||||
context_token: None,
|
||||
},
|
||||
base_info: serde_json::json!({}),
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(!json.contains("context_token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_sse_qr_event() {
|
||||
let evt = SseQrEvent {
|
||||
qrcode_data: "ticket_abc".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&evt).unwrap();
|
||||
assert!(json.contains(r#""qrcodeData":"ticket_abc"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_sse_done_event() {
|
||||
let evt = SseDoneEvent {
|
||||
account_id: "acc_1".into(),
|
||||
bot_token: "tok_1".into(),
|
||||
base_url: "https://example.com".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&evt).unwrap();
|
||||
assert!(json.contains(r#""accountId":"acc_1"#));
|
||||
assert!(json.contains(r#""botToken":"tok_1"#));
|
||||
assert!(json.contains(r#""baseUrl":"https://example.com"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_sse_error_event() {
|
||||
let evt = SseErrorEvent {
|
||||
message: "timeout".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&evt).unwrap();
|
||||
assert!(json.contains(r#""message":"timeout"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_type_constants() {
|
||||
assert_eq!(ITEM_TYPE_TEXT, 1);
|
||||
assert_eq!(ITEM_TYPE_IMAGE, 2);
|
||||
assert_eq!(ITEM_TYPE_VOICE, 3);
|
||||
assert_eq!(ITEM_TYPE_FILE, 4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,944 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::Router;
|
||||
use axum::extract::rejection::JsonRejection;
|
||||
use axum::extract::{Json, State};
|
||||
use axum::routing::{get, post};
|
||||
use tracing::warn;
|
||||
|
||||
use nomifun_api_types::{
|
||||
ApiResponse, ApprovePairingRequest, BridgeResponse, ChannelSessionResponse, ChannelUserResponse,
|
||||
DisablePluginRequest, EnablePluginRequest, PairingRequestResponse, PluginStatusResponse, RejectPairingRequest,
|
||||
RevokeUserRequest, SyncChannelSettingsRequest, TestPluginRequest, TestPluginResponse,
|
||||
};
|
||||
use nomifun_common::AppError;
|
||||
use nomifun_db::IChannelRepository;
|
||||
use nomifun_extension::{ExtensionRegistry, ResolvedChannelPlugin};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::channel_settings::ChannelSettingsService;
|
||||
use crate::error::ChannelError;
|
||||
use crate::manager::{ChannelManager, EnableChannelSpec, PluginFactory};
|
||||
use crate::message_service::MasterAgentProfile;
|
||||
use crate::pairing::PairingService;
|
||||
use crate::session::SessionManager;
|
||||
use crate::types::{PluginConfig, PluginConfigOptions, PluginCredentials, PluginType};
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Shared state for channel route handlers.
|
||||
#[derive(Clone)]
|
||||
pub struct ChannelRouterState {
|
||||
pub manager: Arc<ChannelManager>,
|
||||
pub pairing_service: Arc<PairingService>,
|
||||
pub session_manager: Arc<SessionManager>,
|
||||
pub repo: Arc<dyn IChannelRepository>,
|
||||
pub plugin_factory: Arc<PluginFactory>,
|
||||
pub settings_service: Arc<ChannelSettingsService>,
|
||||
/// Master-agent profile (the companion), used to validate companion-binding writes
|
||||
/// against the live roster. `None` when the host wires channels without
|
||||
/// a companion domain — validation is then skipped, not failed.
|
||||
pub master_profile: Option<Arc<dyn MasterAgentProfile>>,
|
||||
pub extension_registry: ExtensionRegistry,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build the channel router with all `/api/channel/*` routes.
|
||||
///
|
||||
/// All routes require authentication (applied by the caller).
|
||||
pub fn channel_routes(state: ChannelRouterState) -> Router {
|
||||
let router = Router::new()
|
||||
// Plugin management
|
||||
.route("/api/channel/plugins", get(get_plugin_status))
|
||||
.route("/api/channel/plugins/enable", post(enable_plugin))
|
||||
.route("/api/channel/plugins/disable", post(disable_plugin))
|
||||
.route("/api/channel/plugins/delete", post(delete_plugin))
|
||||
.route("/api/channel/plugins/test", post(test_plugin))
|
||||
// Pairing management
|
||||
.route("/api/channel/pairings", get(get_pending_pairings))
|
||||
.route("/api/channel/pairings/approve", post(approve_pairing))
|
||||
.route("/api/channel/pairings/reject", post(reject_pairing))
|
||||
// User management
|
||||
.route("/api/channel/users", get(get_authorized_users))
|
||||
.route("/api/channel/users/revoke", post(revoke_user))
|
||||
// Session management
|
||||
.route("/api/channel/sessions", get(get_active_sessions))
|
||||
// Settings sync
|
||||
.route("/api/channel/settings/sync", post(sync_channel_settings))
|
||||
// Master-agent companion binding (persist + session reset in one step)
|
||||
.route("/api/channel/settings/companion", post(set_channel_master_companion));
|
||||
|
||||
// WeChat QR login starter (feature-gated). Lives in the authenticated
|
||||
// channel group — the QR lifecycle then streams over the WebSocket as
|
||||
// `channel.weixin-login`, because `EventSource` can't carry the desktop's
|
||||
// local-trust header (an SSE stream here was rejected 403).
|
||||
#[cfg(feature = "weixin")]
|
||||
let router = router.route("/api/channel/weixin/login/start", post(start_weixin_login));
|
||||
|
||||
router.with_state(state)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin management handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `GET /api/channel/plugins` — get status of all registered plugins.
|
||||
///
|
||||
/// Returns one entry per channel row (multiple bots may share a platform
|
||||
/// type), plus a placeholder per builtin platform that has no rows yet and
|
||||
/// per extension plugin that was never configured.
|
||||
async fn get_plugin_status(
|
||||
State(state): State<ChannelRouterState>,
|
||||
) -> Result<Json<ApiResponse<Vec<ChannelPluginStatusView>>>, AppError> {
|
||||
let statuses = state.manager.get_plugin_status().await?;
|
||||
let extension_plugins = state.extension_registry.get_channel_plugins().await;
|
||||
|
||||
let extension_map: HashMap<String, ResolvedChannelPlugin> = extension_plugins
|
||||
.into_iter()
|
||||
.map(|plugin| (plugin.id.clone(), plugin))
|
||||
.collect();
|
||||
|
||||
let builtin_names: [(&str, &str); 9] = [
|
||||
("telegram", "Telegram"),
|
||||
("lark", "Lark"),
|
||||
("dingtalk", "DingTalk"),
|
||||
("slack", "Slack"),
|
||||
("discord", "Discord"),
|
||||
("matrix", "Matrix"),
|
||||
("mattermost", "Mattermost"),
|
||||
("weixin", "WeChat"),
|
||||
("wecom", "WeCom"),
|
||||
];
|
||||
let builtin_types: std::collections::HashSet<&str> = builtin_names.iter().map(|(id, _)| *id).collect();
|
||||
|
||||
// Rows are keyed by their own id — two lark bots are two entries.
|
||||
let mut views: Vec<ChannelPluginStatusView> = Vec::new();
|
||||
let mut seen_types: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
for status in statuses {
|
||||
let plugin_type = status.plugin_type.clone();
|
||||
let is_extension = !builtin_types.contains(plugin_type.as_str());
|
||||
|
||||
if is_extension && !extension_map.contains_key(&plugin_type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen_types.insert(plugin_type.clone());
|
||||
views.push(ChannelPluginStatusView::from_manager_status(
|
||||
status,
|
||||
is_extension
|
||||
.then(|| extension_map.get(&plugin_type).map(ChannelExtensionMetaView::from))
|
||||
.flatten(),
|
||||
));
|
||||
}
|
||||
|
||||
for plugin in extension_map.values() {
|
||||
if !seen_types.contains(&plugin.id) {
|
||||
views.push(ChannelPluginStatusView::extension_placeholder(plugin));
|
||||
}
|
||||
}
|
||||
|
||||
for (plugin_type, display_name) in builtin_names {
|
||||
if !seen_types.contains(plugin_type) {
|
||||
views.push(ChannelPluginStatusView::builtin_placeholder(plugin_type, display_name));
|
||||
}
|
||||
}
|
||||
|
||||
views.sort_by(|left, right| {
|
||||
left.plugin_type
|
||||
.cmp(&right.plugin_type)
|
||||
.then_with(|| left.plugin_id.cmp(&right.plugin_id))
|
||||
});
|
||||
|
||||
Ok(Json(ApiResponse::ok(views)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct ChannelPluginStatusView {
|
||||
plugin_id: String,
|
||||
#[serde(rename = "type")]
|
||||
plugin_type: String,
|
||||
name: String,
|
||||
enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
status: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
last_connected: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
companion_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
bot_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
created_at: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
updated_at: Option<i64>,
|
||||
connected: bool,
|
||||
has_token: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
bot_username: Option<String>,
|
||||
active_users: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
is_extension: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
extension_meta: Option<ChannelExtensionMetaView>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct ChannelExtensionMetaView {
|
||||
#[serde(rename = "credentialFields")]
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
credential_fields: Vec<serde_json::Value>,
|
||||
#[serde(rename = "configFields")]
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
config_fields: Vec<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
#[serde(rename = "extensionName")]
|
||||
extension_name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
icon: Option<String>,
|
||||
}
|
||||
|
||||
impl ChannelPluginStatusView {
|
||||
fn from_manager_status(status: PluginStatusResponse, extension_meta: Option<ChannelExtensionMetaView>) -> Self {
|
||||
Self {
|
||||
plugin_id: status.plugin_id,
|
||||
plugin_type: status.plugin_type,
|
||||
name: status.name,
|
||||
enabled: status.enabled,
|
||||
status: status.status,
|
||||
last_connected: status.last_connected,
|
||||
companion_id: status.companion_id,
|
||||
bot_key: status.bot_key,
|
||||
created_at: Some(status.created_at),
|
||||
updated_at: Some(status.updated_at),
|
||||
connected: status.connected,
|
||||
has_token: status.has_token,
|
||||
bot_username: status.bot_username,
|
||||
active_users: status.active_users,
|
||||
is_extension: extension_meta.as_ref().map(|_| true),
|
||||
extension_meta,
|
||||
}
|
||||
}
|
||||
|
||||
fn extension_placeholder(plugin: &ResolvedChannelPlugin) -> Self {
|
||||
Self {
|
||||
plugin_id: plugin.id.clone(),
|
||||
plugin_type: plugin.id.clone(),
|
||||
name: plugin.name.clone(),
|
||||
enabled: false,
|
||||
status: Some("stopped".to_string()),
|
||||
last_connected: None,
|
||||
companion_id: None,
|
||||
bot_key: None,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
connected: false,
|
||||
has_token: false,
|
||||
bot_username: None,
|
||||
active_users: 0,
|
||||
is_extension: Some(true),
|
||||
extension_meta: Some(ChannelExtensionMetaView::from(plugin)),
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_placeholder(plugin_type: &str, display_name: &str) -> Self {
|
||||
Self {
|
||||
plugin_id: plugin_type.to_string(),
|
||||
plugin_type: plugin_type.to_string(),
|
||||
name: display_name.to_string(),
|
||||
enabled: false,
|
||||
status: Some("stopped".to_string()),
|
||||
last_connected: None,
|
||||
companion_id: None,
|
||||
bot_key: None,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
connected: false,
|
||||
has_token: false,
|
||||
bot_username: None,
|
||||
active_users: 0,
|
||||
is_extension: Some(false),
|
||||
extension_meta: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&ResolvedChannelPlugin> for ChannelExtensionMetaView {
|
||||
fn from(plugin: &ResolvedChannelPlugin) -> Self {
|
||||
Self {
|
||||
credential_fields: plugin.credential_fields.clone(),
|
||||
config_fields: plugin.config_fields.clone(),
|
||||
description: plugin.description.clone(),
|
||||
extension_name: plugin.extension_name.clone(),
|
||||
icon: plugin.icon.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/channel/plugins/enable` — enable a bot channel with config.
|
||||
///
|
||||
/// `plugin_id` updates an existing channel row (legacy callers pass the
|
||||
/// platform name); absent `plugin_id` + `plugin_type` creates a new bot
|
||||
/// channel. `companion_id` binds the bot to a companion (validated against the live
|
||||
/// roster).
|
||||
async fn enable_plugin(
|
||||
State(state): State<ChannelRouterState>,
|
||||
body: Result<Json<EnablePluginRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<BridgeResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
|
||||
if let Some(plugin_id) = req.plugin_id.as_deref()
|
||||
&& let Some(extension_plugin) = resolve_extension_channel_plugin(&state, plugin_id).await
|
||||
{
|
||||
let config = build_extension_config(&extension_plugin, &req.config)?;
|
||||
match state
|
||||
.manager
|
||||
.enable_extension_plugin(plugin_id, &extension_plugin.name, &config)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
return Ok(Json(ApiResponse::ok(BridgeResponse {
|
||||
success: true,
|
||||
message: Some("Plugin enabled".into()),
|
||||
error: None,
|
||||
})));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(plugin_id = %plugin_id, error = %e, "enable extension plugin failed");
|
||||
return Ok(Json(ApiResponse::ok(BridgeResponse {
|
||||
success: false,
|
||||
message: None,
|
||||
error: Some(e.to_string()),
|
||||
})));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A typo'd or already-deleted companion id must fail here instead of being
|
||||
// persisted onto the channel row.
|
||||
if let Some(companion_id) = req.companion_id.as_deref().filter(|s| !s.is_empty())
|
||||
&& let Some(profile) = &state.master_profile
|
||||
&& !profile.companion_exists(companion_id).await
|
||||
{
|
||||
return Err(AppError::BadRequest(format!("companion '{companion_id}' not found")));
|
||||
}
|
||||
|
||||
let spec = EnableChannelSpec {
|
||||
plugin_id: req.plugin_id.clone().filter(|s| !s.is_empty()),
|
||||
plugin_type: req.plugin_type.clone(),
|
||||
companion_id: req.companion_id.clone(),
|
||||
};
|
||||
|
||||
match state
|
||||
.manager
|
||||
.enable_plugin(&spec, &req.config, state.plugin_factory.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(channel_id) => Ok(Json(ApiResponse::ok(BridgeResponse {
|
||||
success: true,
|
||||
message: Some(channel_id),
|
||||
error: None,
|
||||
}))),
|
||||
Err(e) => {
|
||||
warn!(plugin_id = ?req.plugin_id, plugin_type = ?req.plugin_type, error = %e, "enable plugin failed");
|
||||
Ok(Json(ApiResponse::ok(BridgeResponse {
|
||||
success: false,
|
||||
message: None,
|
||||
error: Some(e.to_string()),
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/channel/plugins/disable` — disable a plugin.
|
||||
async fn disable_plugin(
|
||||
State(state): State<ChannelRouterState>,
|
||||
body: Result<Json<DisablePluginRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<BridgeResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
|
||||
if resolve_extension_channel_plugin(&state, &req.plugin_id).await.is_some()
|
||||
&& state.repo.get_plugin(&req.plugin_id).await?.is_none()
|
||||
{
|
||||
return Ok(Json(ApiResponse::ok(BridgeResponse {
|
||||
success: true,
|
||||
message: Some("Plugin disabled".into()),
|
||||
error: None,
|
||||
})));
|
||||
}
|
||||
|
||||
match state.manager.disable_plugin(&req.plugin_id).await {
|
||||
Ok(()) => Ok(Json(ApiResponse::ok(BridgeResponse {
|
||||
success: true,
|
||||
message: Some("Plugin disabled".into()),
|
||||
error: None,
|
||||
}))),
|
||||
Err(e) => {
|
||||
warn!(plugin_id = %req.plugin_id, error = %e, "disable plugin failed");
|
||||
Ok(Json(ApiResponse::ok(BridgeResponse {
|
||||
success: false,
|
||||
message: None,
|
||||
error: Some(e.to_string()),
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/channel/plugins/delete` — remove a bot channel entirely.
|
||||
///
|
||||
/// Stops the running instance, clears the channel's sessions, and deletes
|
||||
/// the row. Conversations created through this bot survive.
|
||||
async fn delete_plugin(
|
||||
State(state): State<ChannelRouterState>,
|
||||
body: Result<Json<DisablePluginRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<BridgeResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
|
||||
match state.manager.delete_channel(&req.plugin_id).await {
|
||||
Ok(()) => Ok(Json(ApiResponse::ok(BridgeResponse {
|
||||
success: true,
|
||||
message: Some("Channel deleted".into()),
|
||||
error: None,
|
||||
}))),
|
||||
Err(e) => {
|
||||
warn!(plugin_id = %req.plugin_id, error = %e, "delete channel failed");
|
||||
Ok(Json(ApiResponse::ok(BridgeResponse {
|
||||
success: false,
|
||||
message: None,
|
||||
error: Some(e.to_string()),
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/channel/plugins/test` — test plugin credentials.
|
||||
async fn test_plugin(
|
||||
State(state): State<ChannelRouterState>,
|
||||
body: Result<Json<TestPluginRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<TestPluginResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
|
||||
if let Some(extension_plugin) = resolve_extension_channel_plugin(&state, &req.plugin_id).await {
|
||||
let _config = build_extension_test_config(&extension_plugin, &req)?;
|
||||
return Ok(Json(ApiResponse::ok(TestPluginResponse {
|
||||
success: true,
|
||||
bot_username: None,
|
||||
error: None,
|
||||
})));
|
||||
}
|
||||
|
||||
let config = build_test_config(&req);
|
||||
|
||||
match state
|
||||
.manager
|
||||
.test_plugin(&req.plugin_id, config, state.plugin_factory.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(bot_username) => Ok(Json(ApiResponse::ok(TestPluginResponse {
|
||||
success: true,
|
||||
bot_username,
|
||||
error: None,
|
||||
}))),
|
||||
Err(e) => Ok(Json(ApiResponse::ok(TestPluginResponse {
|
||||
success: false,
|
||||
bot_username: None,
|
||||
error: Some(e.to_string()),
|
||||
}))),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pairing management handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `GET /api/channel/pairings` — get all pending pairing requests.
|
||||
async fn get_pending_pairings(
|
||||
State(state): State<ChannelRouterState>,
|
||||
) -> Result<Json<ApiResponse<Vec<PairingRequestResponse>>>, AppError> {
|
||||
let rows = state.pairing_service.get_pending_pairings().await?;
|
||||
let responses: Vec<PairingRequestResponse> = rows
|
||||
.into_iter()
|
||||
.map(|r| PairingRequestResponse {
|
||||
code: r.code,
|
||||
platform_user_id: r.platform_user_id,
|
||||
platform_type: r.platform_type,
|
||||
channel_id: r.channel_id,
|
||||
display_name: r.display_name,
|
||||
requested_at: r.requested_at,
|
||||
expires_at: r.expires_at,
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(ApiResponse::ok(responses)))
|
||||
}
|
||||
|
||||
/// `POST /api/channel/pairings/approve` — approve a pairing request.
|
||||
async fn approve_pairing(
|
||||
State(state): State<ChannelRouterState>,
|
||||
body: Result<Json<ApprovePairingRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<BridgeResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
|
||||
state.pairing_service.approve_pairing(&req.code).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(BridgeResponse {
|
||||
success: true,
|
||||
message: Some("Pairing approved".into()),
|
||||
error: None,
|
||||
})))
|
||||
}
|
||||
|
||||
/// `POST /api/channel/pairings/reject` — reject a pairing request.
|
||||
async fn reject_pairing(
|
||||
State(state): State<ChannelRouterState>,
|
||||
body: Result<Json<RejectPairingRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<BridgeResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
|
||||
state.pairing_service.reject_pairing(&req.code).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(BridgeResponse {
|
||||
success: true,
|
||||
message: Some("Pairing rejected".into()),
|
||||
error: None,
|
||||
})))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User management handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `GET /api/channel/users` — get all authorized users.
|
||||
async fn get_authorized_users(
|
||||
State(state): State<ChannelRouterState>,
|
||||
) -> Result<Json<ApiResponse<Vec<ChannelUserResponse>>>, AppError> {
|
||||
let rows = state.repo.get_all_users().await?;
|
||||
let responses: Vec<ChannelUserResponse> = rows
|
||||
.into_iter()
|
||||
.map(|r| ChannelUserResponse {
|
||||
id: r.id,
|
||||
platform_user_id: r.platform_user_id,
|
||||
platform_type: r.platform_type,
|
||||
channel_id: r.channel_id,
|
||||
display_name: r.display_name,
|
||||
authorized_at: r.authorized_at,
|
||||
last_active: r.last_active,
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(ApiResponse::ok(responses)))
|
||||
}
|
||||
|
||||
/// `POST /api/channel/users/revoke` — revoke a user's authorization.
|
||||
///
|
||||
/// Also cleans up the user's sessions.
|
||||
async fn revoke_user(
|
||||
State(state): State<ChannelRouterState>,
|
||||
body: Result<Json<RevokeUserRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<BridgeResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
|
||||
// Clean up sessions first
|
||||
state.session_manager.cleanup_user_sessions(&req.user_id).await?;
|
||||
|
||||
// Delete user record
|
||||
state.repo.delete_user(&req.user_id).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(BridgeResponse {
|
||||
success: true,
|
||||
message: Some("User revoked".into()),
|
||||
error: None,
|
||||
})))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session management handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `GET /api/channel/sessions` — get all active sessions.
|
||||
async fn get_active_sessions(
|
||||
State(state): State<ChannelRouterState>,
|
||||
) -> Result<Json<ApiResponse<Vec<ChannelSessionResponse>>>, AppError> {
|
||||
let rows = state.session_manager.get_active_sessions().await?;
|
||||
let responses: Vec<ChannelSessionResponse> = rows
|
||||
.into_iter()
|
||||
.map(|r| ChannelSessionResponse {
|
||||
id: r.id,
|
||||
user_id: r.user_id,
|
||||
agent_type: r.agent_type,
|
||||
conversation_id: r.conversation_id,
|
||||
workspace: r.workspace,
|
||||
chat_id: r.chat_id,
|
||||
channel_id: r.channel_id,
|
||||
created_at: r.created_at,
|
||||
last_activity: r.last_activity,
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(ApiResponse::ok(responses)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings sync handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `POST /api/channel/settings/sync` — invalidate channel sessions.
|
||||
///
|
||||
/// Clears all sessions so they are recreated with the latest
|
||||
/// agent/model configuration on the next incoming message.
|
||||
/// Agent/model config is persisted separately via `PUT /api/settings/client`.
|
||||
async fn sync_channel_settings(
|
||||
State(state): State<ChannelRouterState>,
|
||||
body: Result<Json<SyncChannelSettingsRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<BridgeResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
|
||||
let _platform = PluginType::from_str_opt(&req.platform)
|
||||
.ok_or_else(|| AppError::BadRequest(format!("Invalid platform: {}", req.platform)))?;
|
||||
|
||||
state.session_manager.clear_all_sessions().await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(BridgeResponse {
|
||||
success: true,
|
||||
message: Some(format!("Sessions cleared for {}", req.platform)),
|
||||
error: None,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Request body for `POST /api/channel/settings/companion`.
|
||||
///
|
||||
/// `plugin_id` set → rebind one bot channel (the multi-bot path; only that
|
||||
/// channel's sessions are cleared). `platform` set → legacy platform-level
|
||||
/// binding (key `assistant.{platform}.companionId`, clears all sessions).
|
||||
/// `companion_id: None` / empty string clears the binding.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SetChannelCompanionRequest {
|
||||
#[serde(default, alias = "pluginId")]
|
||||
plugin_id: Option<String>,
|
||||
#[serde(default)]
|
||||
platform: Option<String>,
|
||||
#[serde(default, alias = "companionId")]
|
||||
companion_id: Option<String>,
|
||||
}
|
||||
|
||||
/// `POST /api/channel/settings/companion` — bind (or clear) the companion that greets a
|
||||
/// bot channel's master-agent sessions.
|
||||
///
|
||||
/// Per-channel writes go to `assistant_plugins.companion_id` and clear only that
|
||||
/// channel's sessions; legacy platform writes keep the old preference key
|
||||
/// and clear all sessions. Both fold write + reset into one step so the
|
||||
/// reset cannot be skipped.
|
||||
async fn set_channel_master_companion(
|
||||
State(state): State<ChannelRouterState>,
|
||||
body: Result<Json<SetChannelCompanionRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<BridgeResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
|
||||
// Validate a non-empty binding against the live companion roster: a typo'd or
|
||||
// already-deleted companion id must 400 here instead of being persisted and
|
||||
// silently degrading every session to the default companion. Clearing the
|
||||
// binding (None / empty string) needs no validation.
|
||||
if let Some(companion_id) = req.companion_id.as_deref().filter(|s| !s.is_empty())
|
||||
&& let Some(profile) = &state.master_profile
|
||||
&& !profile.companion_exists(companion_id).await
|
||||
{
|
||||
return Err(AppError::BadRequest(format!("companion '{companion_id}' not found")));
|
||||
}
|
||||
|
||||
let companion_id = req.companion_id.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||
|
||||
if let Some(plugin_id) = req.plugin_id.as_deref().filter(|s| !s.is_empty()) {
|
||||
state.manager.rebind_channel_companion(plugin_id, companion_id).await?;
|
||||
return Ok(Json(ApiResponse::ok(BridgeResponse {
|
||||
success: true,
|
||||
message: Some(format!("Companion binding updated for channel {plugin_id}; channel sessions cleared")),
|
||||
error: None,
|
||||
})));
|
||||
}
|
||||
|
||||
let platform_str = req
|
||||
.platform
|
||||
.as_deref()
|
||||
.ok_or_else(|| AppError::BadRequest("either plugin_id or platform is required".into()))?;
|
||||
let platform = PluginType::from_str_opt(platform_str)
|
||||
.ok_or_else(|| AppError::BadRequest(format!("Invalid platform: {platform_str}")))?;
|
||||
|
||||
state.settings_service.set_master_agent_companion_id(platform, companion_id).await?;
|
||||
|
||||
// Same reset branch as the masterAgent switch: clear active sessions so
|
||||
// the next message starts a conversation under the new binding.
|
||||
state.session_manager.clear_all_sessions().await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(BridgeResponse {
|
||||
success: true,
|
||||
message: Some(format!("Companion binding updated for {platform_str}; sessions cleared")),
|
||||
error: None,
|
||||
})))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WeChat login handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `POST /api/channel/weixin/login/start` — begin the WeChat QR-code login.
|
||||
///
|
||||
/// Returns immediately; the QR lifecycle (`qr` → `scanned` → `done`/`error`)
|
||||
/// streams over the WebSocket as `channel.weixin-login`. We use the WebSocket
|
||||
/// rather than SSE because `EventSource` cannot carry the desktop's
|
||||
/// `x-nomi-local-trust` header, so an SSE stream was rejected 403 by the auth
|
||||
/// middleware and surfaced in the UI as an instant "WeChat login failed".
|
||||
#[cfg(feature = "weixin")]
|
||||
async fn start_weixin_login(State(state): State<ChannelRouterState>) -> Json<ApiResponse<BridgeResponse>> {
|
||||
state.manager.start_weixin_login();
|
||||
Json(ApiResponse::ok(BridgeResponse {
|
||||
success: true,
|
||||
message: None,
|
||||
error: None,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a `PluginConfig` from a `TestPluginRequest`.
|
||||
///
|
||||
/// Maps the `token` and optional `extra_config` fields into the
|
||||
/// correct credential fields based on the plugin type.
|
||||
fn build_test_config(req: &TestPluginRequest) -> PluginConfig {
|
||||
let mut credentials = PluginCredentials::default();
|
||||
|
||||
match req.plugin_id.as_str() {
|
||||
"lark" => {
|
||||
if let Some(ref extra) = req.extra_config {
|
||||
credentials.app_id = extra.app_id.clone();
|
||||
credentials.app_secret = extra.app_secret.clone();
|
||||
}
|
||||
credentials.token = Some(req.token.clone());
|
||||
}
|
||||
"dingtalk" => {
|
||||
credentials.client_id = Some(req.token.clone());
|
||||
if let Some(ref extra) = req.extra_config {
|
||||
credentials.client_secret = extra.app_secret.clone();
|
||||
}
|
||||
}
|
||||
"weixin" => {
|
||||
credentials.bot_token = Some(req.token.clone());
|
||||
if let Some(ref extra) = req.extra_config {
|
||||
credentials.account_id = extra.app_id.clone();
|
||||
}
|
||||
}
|
||||
"slack" => {
|
||||
// Bot token (xoxb-) in `token`; app-level token (xapp-) in extra.
|
||||
credentials.token = Some(req.token.clone());
|
||||
if let Some(ref extra) = req.extra_config {
|
||||
credentials.app_token = extra.app_token.clone();
|
||||
}
|
||||
}
|
||||
"matrix" => {
|
||||
// Access token in `token`; homeserver + bot mxid in extra.
|
||||
credentials.access_token = Some(req.token.clone());
|
||||
if let Some(ref extra) = req.extra_config {
|
||||
credentials.homeserver_url = extra.homeserver_url.clone();
|
||||
credentials.user_id = extra.user_id.clone();
|
||||
}
|
||||
}
|
||||
"mattermost" => {
|
||||
// Bot token in `token`; server URL in extra.
|
||||
credentials.token = Some(req.token.clone());
|
||||
if let Some(ref extra) = req.extra_config {
|
||||
credentials.server_url = extra.server_url.clone();
|
||||
}
|
||||
}
|
||||
"twitch" => {
|
||||
// OAuth access token in `token` (channel only needed at run time).
|
||||
credentials.token = Some(req.token.clone());
|
||||
}
|
||||
"nostr" => {
|
||||
// Private key (nsec/hex) in `token`; relay list in extra.
|
||||
credentials.nostr_private_key = Some(req.token.clone());
|
||||
if let Some(ref extra) = req.extra_config {
|
||||
credentials.nostr_relays = extra.nostr_relays.clone();
|
||||
}
|
||||
}
|
||||
"qqbot" => {
|
||||
// appId in `token` (client_id); clientSecret in extra (client_secret).
|
||||
credentials.client_id = Some(req.token.clone());
|
||||
if let Some(ref extra) = req.extra_config {
|
||||
credentials.client_secret = extra.app_secret.clone();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Default: use token field (Telegram, Discord)
|
||||
credentials.token = Some(req.token.clone());
|
||||
}
|
||||
}
|
||||
|
||||
PluginConfig {
|
||||
credentials,
|
||||
config: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_extension_channel_plugin(
|
||||
state: &ChannelRouterState,
|
||||
plugin_id: &str,
|
||||
) -> Option<ResolvedChannelPlugin> {
|
||||
state
|
||||
.extension_registry
|
||||
.get_channel_plugins()
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|plugin| plugin.id == plugin_id)
|
||||
}
|
||||
|
||||
fn build_extension_test_config(
|
||||
plugin: &ResolvedChannelPlugin,
|
||||
req: &TestPluginRequest,
|
||||
) -> Result<PluginConfig, ChannelError> {
|
||||
let mut map = serde_json::Map::new();
|
||||
if !req.token.is_empty() {
|
||||
map.insert("token".to_string(), serde_json::Value::String(req.token.clone()));
|
||||
}
|
||||
if let Some(extra) = &req.extra_config {
|
||||
if let Some(app_id) = &extra.app_id {
|
||||
map.insert("appId".to_string(), serde_json::Value::String(app_id.clone()));
|
||||
}
|
||||
if let Some(app_secret) = &extra.app_secret {
|
||||
map.insert("appSecret".to_string(), serde_json::Value::String(app_secret.clone()));
|
||||
}
|
||||
}
|
||||
build_extension_config(plugin, &serde_json::Value::Object(map))
|
||||
}
|
||||
|
||||
fn build_extension_config(
|
||||
plugin: &ResolvedChannelPlugin,
|
||||
raw: &serde_json::Value,
|
||||
) -> Result<PluginConfig, ChannelError> {
|
||||
let object = raw
|
||||
.as_object()
|
||||
.ok_or_else(|| ChannelError::InvalidConfig("Extension plugin config must be an object".into()))?;
|
||||
|
||||
let mut credentials = PluginCredentials::default();
|
||||
let mut config_extra = HashMap::new();
|
||||
|
||||
let credential_keys: std::collections::HashSet<String> = plugin
|
||||
.credential_fields
|
||||
.iter()
|
||||
.filter_map(field_key)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect();
|
||||
for field in &plugin.config_fields {
|
||||
if let Some((key, value)) = field_default_entry(field) {
|
||||
config_extra.entry(key.to_string()).or_insert(value);
|
||||
}
|
||||
}
|
||||
|
||||
for (key, value) in object {
|
||||
if credential_keys.contains(key) {
|
||||
credentials.extra.insert(key.clone(), value.clone());
|
||||
} else {
|
||||
config_extra.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PluginConfig {
|
||||
credentials,
|
||||
config: if config_extra.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(PluginConfigOptions {
|
||||
mode: None,
|
||||
webhook_url: None,
|
||||
rate_limit: None,
|
||||
require_mention: None,
|
||||
extra: config_extra,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn field_key(value: &serde_json::Value) -> Option<&str> {
|
||||
value.get("key").and_then(serde_json::Value::as_str)
|
||||
}
|
||||
|
||||
fn field_default_entry(value: &serde_json::Value) -> Option<(&str, serde_json::Value)> {
|
||||
let key = field_key(value)?;
|
||||
let default = value.get("default")?;
|
||||
Some((key, default.clone()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nomifun_api_types::TestPluginExtraConfig;
|
||||
|
||||
#[test]
|
||||
fn build_test_config_telegram() {
|
||||
let req = TestPluginRequest {
|
||||
plugin_id: "telegram".into(),
|
||||
token: "bot123:ABC".into(),
|
||||
extra_config: None,
|
||||
};
|
||||
let config = build_test_config(&req);
|
||||
assert_eq!(config.credentials.token.as_deref(), Some("bot123:ABC"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_test_config_lark() {
|
||||
let req = TestPluginRequest {
|
||||
plugin_id: "lark".into(),
|
||||
token: "xxx".into(),
|
||||
extra_config: Some(TestPluginExtraConfig {
|
||||
app_id: Some("cli_abc".into()),
|
||||
app_secret: Some("secret".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
};
|
||||
let config = build_test_config(&req);
|
||||
assert_eq!(config.credentials.app_id.as_deref(), Some("cli_abc"));
|
||||
assert_eq!(config.credentials.app_secret.as_deref(), Some("secret"));
|
||||
assert_eq!(config.credentials.token.as_deref(), Some("xxx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_test_config_dingtalk() {
|
||||
let req = TestPluginRequest {
|
||||
plugin_id: "dingtalk".into(),
|
||||
token: "client_id_123".into(),
|
||||
extra_config: Some(TestPluginExtraConfig {
|
||||
app_id: None,
|
||||
app_secret: Some("client_secret_456".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
};
|
||||
let config = build_test_config(&req);
|
||||
assert_eq!(config.credentials.client_id.as_deref(), Some("client_id_123"));
|
||||
assert_eq!(config.credentials.client_secret.as_deref(), Some("client_secret_456"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_test_config_weixin() {
|
||||
let req = TestPluginRequest {
|
||||
plugin_id: "weixin".into(),
|
||||
token: "bot_token_xyz".into(),
|
||||
extra_config: Some(TestPluginExtraConfig {
|
||||
app_id: Some("account_abc".into()),
|
||||
app_secret: None,
|
||||
..Default::default()
|
||||
}),
|
||||
};
|
||||
let config = build_test_config(&req);
|
||||
assert_eq!(config.credentials.bot_token.as_deref(), Some("bot_token_xyz"));
|
||||
assert_eq!(config.credentials.account_id.as_deref(), Some("account_abc"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_common::{generate_prefixed_id, now_ms};
|
||||
use nomifun_db::IChannelRepository;
|
||||
use nomifun_db::models::AssistantSessionRow;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::error::ChannelError;
|
||||
|
||||
/// Manages per-chat session isolation for channel users.
|
||||
///
|
||||
/// Each (channel_id, user_id, chat_id) triple maps to exactly one session.
|
||||
/// This ensures that the same user chatting in different groups/DMs — or
|
||||
/// with different bots in the same group — gets independent conversation
|
||||
/// contexts, while repeated messages in the same chat reuse the existing
|
||||
/// session.
|
||||
pub struct SessionManager {
|
||||
repo: Arc<dyn IChannelRepository>,
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
pub fn new(repo: Arc<dyn IChannelRepository>) -> Self {
|
||||
Self { repo }
|
||||
}
|
||||
|
||||
/// Finds an existing session for the channel+user+chat triple, or
|
||||
/// creates one.
|
||||
///
|
||||
/// - If found: updates `last_activity` and returns the existing session.
|
||||
/// - If not found: creates a new session with the given `agent_type`.
|
||||
///
|
||||
/// The `workspace` parameter is optional and may be set later by
|
||||
/// the `ChannelManager` when it knows the active workspace path.
|
||||
pub async fn get_or_create_session(
|
||||
&self,
|
||||
user_id: &str,
|
||||
chat_id: &str,
|
||||
channel_id: &str,
|
||||
agent_type: &str,
|
||||
workspace: Option<&str>,
|
||||
) -> Result<AssistantSessionRow, ChannelError> {
|
||||
let now = now_ms();
|
||||
let new_row = AssistantSessionRow {
|
||||
id: generate_prefixed_id("ases"),
|
||||
user_id: user_id.to_owned(),
|
||||
agent_type: agent_type.to_owned(),
|
||||
conversation_id: None,
|
||||
workspace: workspace.map(String::from),
|
||||
chat_id: Some(chat_id.to_owned()),
|
||||
channel_id: Some(channel_id.to_owned()),
|
||||
created_at: now,
|
||||
last_activity: now,
|
||||
};
|
||||
|
||||
let session = self
|
||||
.repo
|
||||
.get_or_create_session(user_id, chat_id, channel_id, &new_row)
|
||||
.await?;
|
||||
|
||||
debug!(
|
||||
session_id = %session.id,
|
||||
user_id = %user_id,
|
||||
chat_id = %chat_id,
|
||||
channel_id = %channel_id,
|
||||
"session resolved"
|
||||
);
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Returns all active sessions.
|
||||
pub async fn get_active_sessions(&self) -> Result<Vec<AssistantSessionRow>, ChannelError> {
|
||||
let sessions = self.repo.get_all_sessions().await?;
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
/// Deletes the existing session for a channel+user+chat triple and
|
||||
/// creates a fresh one. Returns the newly created session.
|
||||
///
|
||||
/// Used by `session.new` to give the user a clean slate in a chat.
|
||||
pub async fn reset_session(
|
||||
&self,
|
||||
user_id: &str,
|
||||
chat_id: &str,
|
||||
channel_id: &str,
|
||||
agent_type: &str,
|
||||
workspace: Option<&str>,
|
||||
) -> Result<AssistantSessionRow, ChannelError> {
|
||||
// Delete old session if it exists
|
||||
self.repo
|
||||
.delete_session_by_user_chat(user_id, chat_id, channel_id)
|
||||
.await?;
|
||||
|
||||
// Create a fresh session
|
||||
let now = now_ms();
|
||||
let new_row = AssistantSessionRow {
|
||||
id: generate_prefixed_id("ases"),
|
||||
user_id: user_id.to_owned(),
|
||||
agent_type: agent_type.to_owned(),
|
||||
conversation_id: None,
|
||||
workspace: workspace.map(String::from),
|
||||
chat_id: Some(chat_id.to_owned()),
|
||||
channel_id: Some(channel_id.to_owned()),
|
||||
created_at: now,
|
||||
last_activity: now,
|
||||
};
|
||||
|
||||
let session = self
|
||||
.repo
|
||||
.get_or_create_session(user_id, chat_id, channel_id, &new_row)
|
||||
.await?;
|
||||
|
||||
info!(
|
||||
session_id = %session.id,
|
||||
user_id = %user_id,
|
||||
chat_id = %chat_id,
|
||||
channel_id = %channel_id,
|
||||
"session reset"
|
||||
);
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Updates the agent_type for an existing session.
|
||||
pub async fn update_agent_type(&self, session_id: &str, agent_type: &str) -> Result<(), ChannelError> {
|
||||
self.repo.update_session_agent_type(session_id, agent_type).await?;
|
||||
|
||||
debug!(
|
||||
session_id = %session_id,
|
||||
agent_type = %agent_type,
|
||||
"session agent_type updated"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes all sessions belonging to a user.
|
||||
///
|
||||
/// Called when a user is revoked to clean up their session state.
|
||||
pub async fn cleanup_user_sessions(&self, user_id: &str) -> Result<(), ChannelError> {
|
||||
self.repo.delete_sessions_by_user(user_id).await?;
|
||||
info!(user_id = %user_id, "cleaned up user sessions");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes all sessions across all users.
|
||||
///
|
||||
/// Called after settings sync to force sessions to be recreated
|
||||
/// with updated agent/model configuration.
|
||||
pub async fn clear_all_sessions(&self) -> Result<(), ChannelError> {
|
||||
let sessions = self.repo.get_all_sessions().await?;
|
||||
let mut cleared_users = std::collections::HashSet::new();
|
||||
for session in &sessions {
|
||||
if cleared_users.insert(session.user_id.clone()) {
|
||||
self.repo.delete_sessions_by_user(&session.user_id).await?;
|
||||
}
|
||||
}
|
||||
info!(count = sessions.len(), "cleared all channel sessions");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Looks up a session by its unique ID.
|
||||
pub async fn get_session_by_id(&self, session_id: &str) -> Result<Option<AssistantSessionRow>, ChannelError> {
|
||||
Ok(self.repo.get_session(session_id).await?)
|
||||
}
|
||||
|
||||
/// Persists the conversation binding for a session.
|
||||
///
|
||||
/// Called after a new conversation is created for this session,
|
||||
/// linking the session to its backing conversation in the database.
|
||||
pub async fn bind_conversation(&self, session_id: &str, conversation_id: &str) -> Result<(), ChannelError> {
|
||||
// Public API stays &str (Option A); the repo FK is now i64.
|
||||
let conversation_id_i64 = conversation_id.parse::<i64>().map_err(|_| {
|
||||
ChannelError::MessageSendFailed(format!("invalid conversation id: {conversation_id}"))
|
||||
})?;
|
||||
self.repo
|
||||
.update_session_conversation(session_id, conversation_id_i64)
|
||||
.await?;
|
||||
|
||||
debug!(
|
||||
session_id = %session_id,
|
||||
conversation_id = %conversation_id,
|
||||
"session bound to conversation"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nomifun_common::TimestampMs;
|
||||
use nomifun_db::models::{AssistantSessionRow, AssistantUserRow, ChannelPluginRow, PairingCodeRow};
|
||||
use nomifun_db::{DbError, IChannelRepository, UpdatePluginStatusParams};
|
||||
use std::sync::Mutex;
|
||||
|
||||
// ── Mock IChannelRepository ────────────────────────────────────────
|
||||
|
||||
struct MockRepo {
|
||||
sessions: Mutex<Vec<AssistantSessionRow>>,
|
||||
}
|
||||
|
||||
impl MockRepo {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
sessions: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_sessions(&self) -> Vec<AssistantSessionRow> {
|
||||
self.sessions.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IChannelRepository for MockRepo {
|
||||
// -- Plugin CRUD (unused stubs) --
|
||||
async fn get_all_plugins(&self) -> Result<Vec<ChannelPluginRow>, DbError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn get_plugin(&self, _id: &str) -> Result<Option<ChannelPluginRow>, DbError> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn upsert_plugin(&self, _row: &ChannelPluginRow) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn update_plugin_status(&self, _id: &str, _params: &UpdatePluginStatusParams) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn update_plugin_companion(&self, _id: &str, _companion_id: Option<&str>) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn update_plugin_bot_key(&self, _id: &str, _bot_key: &str) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn delete_plugin(&self, _id: &str) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -- User CRUD (unused stubs) --
|
||||
async fn get_all_users(&self) -> Result<Vec<AssistantUserRow>, DbError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn get_user_by_platform(
|
||||
&self,
|
||||
_platform_user_id: &str,
|
||||
_platform_type: &str,
|
||||
_channel_id: &str,
|
||||
) -> Result<Option<AssistantUserRow>, DbError> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn create_user(&self, _row: &AssistantUserRow) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn update_user_last_active(&self, _id: &str, _last_active: TimestampMs) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn delete_user(&self, _id: &str) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -- Session CRUD --
|
||||
async fn get_all_sessions(&self) -> Result<Vec<AssistantSessionRow>, DbError> {
|
||||
Ok(self.sessions.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
async fn get_session(&self, id: &str) -> Result<Option<AssistantSessionRow>, DbError> {
|
||||
let sessions = self.sessions.lock().unwrap();
|
||||
Ok(sessions.iter().find(|s| s.id == id).cloned())
|
||||
}
|
||||
|
||||
async fn get_or_create_session(
|
||||
&self,
|
||||
user_id: &str,
|
||||
chat_id: &str,
|
||||
channel_id: &str,
|
||||
new_row: &AssistantSessionRow,
|
||||
) -> Result<AssistantSessionRow, DbError> {
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
// Look for existing session by channel_id + user_id + chat_id
|
||||
// (mirrors the sqlite implementation's lookup key).
|
||||
if let Some(existing) = sessions.iter_mut().find(|s| {
|
||||
s.user_id == user_id
|
||||
&& s.chat_id.as_deref() == Some(chat_id)
|
||||
&& s.channel_id.as_deref() == Some(channel_id)
|
||||
}) {
|
||||
existing.last_activity = new_row.last_activity;
|
||||
return Ok(existing.clone());
|
||||
}
|
||||
// Create new
|
||||
sessions.push(new_row.clone());
|
||||
Ok(new_row.clone())
|
||||
}
|
||||
|
||||
async fn update_session_activity(&self, id: &str, last_activity: TimestampMs) -> Result<(), DbError> {
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
if let Some(s) = sessions.iter_mut().find(|s| s.id == id) {
|
||||
s.last_activity = last_activity;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DbError::NotFound(id.into()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_session_conversation(&self, id: &str, conversation_id: i64) -> Result<(), DbError> {
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
if let Some(s) = sessions.iter_mut().find(|s| s.id == id) {
|
||||
s.conversation_id = Some(conversation_id);
|
||||
s.last_activity = nomifun_common::now_ms();
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DbError::NotFound(id.into()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_session_agent_type(&self, id: &str, agent_type: &str) -> Result<(), DbError> {
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
if let Some(s) = sessions.iter_mut().find(|s| s.id == id) {
|
||||
s.agent_type = agent_type.to_owned();
|
||||
s.last_activity = nomifun_common::now_ms();
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DbError::NotFound(id.into()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_sessions_by_user(&self, user_id: &str) -> Result<(), DbError> {
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
sessions.retain(|s| s.user_id != user_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_sessions_by_channel(&self, channel_id: &str) -> Result<(), DbError> {
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
sessions.retain(|s| s.channel_id.as_deref() != Some(channel_id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_session_by_user_chat(&self, user_id: &str, chat_id: &str, channel_id: &str) -> Result<(), DbError> {
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
sessions.retain(|s| {
|
||||
!(s.user_id == user_id
|
||||
&& s.chat_id.as_deref() == Some(chat_id)
|
||||
&& s.channel_id.as_deref() == Some(channel_id))
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -- Pairing codes (unused stubs) --
|
||||
async fn create_pairing(&self, _row: &PairingCodeRow) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn get_pending_pairings(&self) -> Result<Vec<PairingCodeRow>, DbError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn get_pairing_by_code(&self, _code: &str) -> Result<Option<PairingCodeRow>, DbError> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn update_pairing_status(&self, _code: &str, _status: &str) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn cleanup_expired_pairings(&self, _now: TimestampMs) -> Result<u64, DbError> {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
fn make_manager() -> (SessionManager, Arc<MockRepo>) {
|
||||
let repo = Arc::new(MockRepo::new());
|
||||
let mgr = SessionManager::new(repo.clone());
|
||||
(mgr, repo)
|
||||
}
|
||||
|
||||
// ── get_or_create_session ──────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn creates_new_session() {
|
||||
let (mgr, repo) = make_manager();
|
||||
let session = mgr
|
||||
.get_or_create_session("user1", "chat1", "tg-1", "gemini", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(session.user_id, "user1");
|
||||
assert_eq!(session.chat_id.as_deref(), Some("chat1"));
|
||||
assert_eq!(session.channel_id.as_deref(), Some("tg-1"));
|
||||
assert_eq!(session.agent_type, "gemini");
|
||||
assert!(session.conversation_id.is_none());
|
||||
|
||||
let all = repo.get_sessions();
|
||||
assert_eq!(all.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reuses_existing_session_for_same_user_chat() {
|
||||
let (mgr, repo) = make_manager();
|
||||
|
||||
let s1 = mgr
|
||||
.get_or_create_session("user1", "chat1", "tg-1", "gemini", None)
|
||||
.await
|
||||
.unwrap();
|
||||
let s2 = mgr
|
||||
.get_or_create_session("user1", "chat1", "tg-1", "gemini", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(s1.id, s2.id);
|
||||
assert_eq!(repo.get_sessions().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn different_chats_get_different_sessions() {
|
||||
let (mgr, repo) = make_manager();
|
||||
|
||||
let s1 = mgr.get_or_create_session("user1", "chatA", "tg-1", "acp", None).await.unwrap();
|
||||
let s2 = mgr.get_or_create_session("user1", "chatB", "tg-1", "acp", None).await.unwrap();
|
||||
|
||||
assert_ne!(s1.id, s2.id);
|
||||
assert_eq!(repo.get_sessions().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn different_users_same_chat_get_different_sessions() {
|
||||
let (mgr, repo) = make_manager();
|
||||
|
||||
let s1 = mgr
|
||||
.get_or_create_session("user1", "chat1", "tg-1", "gemini", None)
|
||||
.await
|
||||
.unwrap();
|
||||
let s2 = mgr
|
||||
.get_or_create_session("user2", "chat1", "tg-1", "gemini", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(s1.id, s2.id);
|
||||
assert_eq!(repo.get_sessions().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn different_channels_same_chat_get_different_sessions() {
|
||||
let (mgr, repo) = make_manager();
|
||||
|
||||
let s1 = mgr
|
||||
.get_or_create_session("user1", "chat1", "tg-1", "gemini", None)
|
||||
.await
|
||||
.unwrap();
|
||||
let s2 = mgr
|
||||
.get_or_create_session("user1", "chat1", "achn_2", "gemini", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(s1.id, s2.id);
|
||||
assert_eq!(s1.channel_id.as_deref(), Some("tg-1"));
|
||||
assert_eq!(s2.channel_id.as_deref(), Some("achn_2"));
|
||||
assert_eq!(repo.get_sessions().len(), 2);
|
||||
|
||||
// Same channel again → reuse, no third session.
|
||||
let s3 = mgr
|
||||
.get_or_create_session("user1", "chat1", "tg-1", "gemini", None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(s3.id, s1.id);
|
||||
assert_eq!(repo.get_sessions().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_with_workspace() {
|
||||
let (mgr, _repo) = make_manager();
|
||||
let session = mgr
|
||||
.get_or_create_session("u1", "c1", "tg-1", "acp", Some("/workspace"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(session.workspace.as_deref(), Some("/workspace"));
|
||||
}
|
||||
|
||||
// ── get_active_sessions ────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_active_sessions_empty() {
|
||||
let (mgr, _repo) = make_manager();
|
||||
let sessions = mgr.get_active_sessions().await.unwrap();
|
||||
assert!(sessions.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_active_sessions_returns_all() {
|
||||
let (mgr, _repo) = make_manager();
|
||||
mgr.get_or_create_session("u1", "c1", "tg-1", "gemini", None).await.unwrap();
|
||||
mgr.get_or_create_session("u2", "c2", "tg-1", "acp", None).await.unwrap();
|
||||
|
||||
let sessions = mgr.get_active_sessions().await.unwrap();
|
||||
assert_eq!(sessions.len(), 2);
|
||||
}
|
||||
|
||||
// ── cleanup_user_sessions ──────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_removes_user_sessions() {
|
||||
let (mgr, repo) = make_manager();
|
||||
mgr.get_or_create_session("u1", "c1", "tg-1", "gemini", None).await.unwrap();
|
||||
mgr.get_or_create_session("u1", "c2", "tg-1", "gemini", None).await.unwrap();
|
||||
mgr.get_or_create_session("u2", "c1", "tg-1", "acp", None).await.unwrap();
|
||||
|
||||
mgr.cleanup_user_sessions("u1").await.unwrap();
|
||||
|
||||
let sessions = repo.get_sessions();
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].user_id, "u2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_noop_for_unknown_user() {
|
||||
let (mgr, repo) = make_manager();
|
||||
mgr.get_or_create_session("u1", "c1", "tg-1", "gemini", None).await.unwrap();
|
||||
|
||||
mgr.cleanup_user_sessions("u999").await.unwrap();
|
||||
|
||||
assert_eq!(repo.get_sessions().len(), 1);
|
||||
}
|
||||
|
||||
// ── bind_conversation ──────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn bind_conversation_persists_conversation_id() {
|
||||
let (mgr, repo) = make_manager();
|
||||
let session = mgr.get_or_create_session("u1", "c1", "tg-1", "acp", None).await.unwrap();
|
||||
assert!(session.conversation_id.is_none());
|
||||
|
||||
mgr.bind_conversation(&session.id, "123").await.unwrap();
|
||||
|
||||
let updated = repo.get_sessions().into_iter().find(|s| s.id == session.id).unwrap();
|
||||
assert_eq!(updated.conversation_id, Some(123));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bind_conversation_not_found() {
|
||||
let (mgr, _repo) = make_manager();
|
||||
let err = mgr.bind_conversation("nonexistent", "123").await;
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
// ── reset_session ─────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn reset_session_creates_fresh_session() {
|
||||
let (mgr, repo) = make_manager();
|
||||
let s1 = mgr.get_or_create_session("u1", "c1", "tg-1", "gemini", None).await.unwrap();
|
||||
|
||||
let s2 = mgr.reset_session("u1", "c1", "tg-1", "gemini", None).await.unwrap();
|
||||
|
||||
// New session should have a different ID
|
||||
assert_ne!(s1.id, s2.id);
|
||||
assert_eq!(s2.user_id, "u1");
|
||||
assert_eq!(s2.chat_id.as_deref(), Some("c1"));
|
||||
assert_eq!(s2.channel_id.as_deref(), Some("tg-1"));
|
||||
assert!(s2.conversation_id.is_none());
|
||||
|
||||
// Only 1 session should exist (old one deleted)
|
||||
assert_eq!(repo.get_sessions().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reset_session_noop_when_no_existing() {
|
||||
let (mgr, repo) = make_manager();
|
||||
let session = mgr.reset_session("u1", "c1", "tg-1", "acp", None).await.unwrap();
|
||||
|
||||
assert_eq!(session.user_id, "u1");
|
||||
assert_eq!(repo.get_sessions().len(), 1);
|
||||
}
|
||||
|
||||
// ── update_agent_type ─────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_agent_type_persists() {
|
||||
let (mgr, repo) = make_manager();
|
||||
let session = mgr.get_or_create_session("u1", "c1", "tg-1", "gemini", None).await.unwrap();
|
||||
assert_eq!(session.agent_type, "gemini");
|
||||
|
||||
mgr.update_agent_type(&session.id, "acp").await.unwrap();
|
||||
|
||||
let updated = repo.get_sessions().into_iter().find(|s| s.id == session.id).unwrap();
|
||||
assert_eq!(updated.agent_type, "acp");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_agent_type_not_found() {
|
||||
let (mgr, _repo) = make_manager();
|
||||
let err = mgr.update_agent_type("nonexistent", "acp").await;
|
||||
assert!(err.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nomifun_ai_agent::AgentStreamEvent;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::error::ChannelError;
|
||||
use crate::formatter::format_text_for_platform;
|
||||
use crate::message_service::{ChannelMessageService, StreamAction};
|
||||
use crate::pending_decision::{PendingDecision, PendingDecisionStore};
|
||||
use crate::types::{OutgoingMessageType, ParseMode, PluginType, UnifiedOutgoingMessage};
|
||||
|
||||
/// Configuration for a stream relay session.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RelayConfig {
|
||||
pub platform: PluginType,
|
||||
pub plugin_id: String,
|
||||
pub chat_id: String,
|
||||
pub throttle_ms: u64,
|
||||
/// The backing conversation, so a relayed decision can be recorded
|
||||
/// against it in the shared pending-decision store.
|
||||
pub conversation_id: String,
|
||||
}
|
||||
|
||||
/// Abstraction for sending/editing messages through a channel plugin.
|
||||
///
|
||||
/// Decouples ChannelStreamRelay from ChannelManager for testability.
|
||||
#[async_trait]
|
||||
pub trait ChannelSender: Send + Sync {
|
||||
async fn send_message(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
chat_id: &str,
|
||||
message: UnifiedOutgoingMessage,
|
||||
) -> Result<String, ChannelError>;
|
||||
|
||||
async fn edit_message(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
chat_id: &str,
|
||||
message_id: &str,
|
||||
message: UnifiedOutgoingMessage,
|
||||
) -> Result<(), ChannelError>;
|
||||
}
|
||||
|
||||
/// Relays agent stream events to an IM platform.
|
||||
///
|
||||
/// Responsibilities:
|
||||
/// - Send "Thinking..." placeholder on start
|
||||
/// - Accumulate text, throttled editMessage every N ms
|
||||
/// - Send final message with action buttons on Finish
|
||||
/// - Send error message on Error
|
||||
pub struct ChannelStreamRelay {
|
||||
config: RelayConfig,
|
||||
sender: Arc<dyn ChannelSender>,
|
||||
/// Shared store: a relayed decision is recorded here so the inbound
|
||||
/// numeric reply can be mapped back to the right `call_id`/option.
|
||||
pending: Arc<PendingDecisionStore>,
|
||||
}
|
||||
|
||||
impl ChannelStreamRelay {
|
||||
pub fn new(config: RelayConfig, sender: Arc<dyn ChannelSender>, pending: Arc<PendingDecisionStore>) -> Self {
|
||||
Self {
|
||||
config,
|
||||
sender,
|
||||
pending,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the relay loop until the agent stream ends.
|
||||
pub async fn run(self, rx: broadcast::Receiver<AgentStreamEvent>) {
|
||||
if is_send_once_platform(self.config.platform) {
|
||||
self.run_send_once(rx).await;
|
||||
} else {
|
||||
self.run_editable(rx).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Send-once relay (WeChat/Twitch/Nostr): no edit support, accumulate text
|
||||
/// then send once.
|
||||
async fn run_send_once(self, mut rx: broadcast::Receiver<AgentStreamEvent>) {
|
||||
let mut text_buffer = String::new();
|
||||
let mut has_content = false;
|
||||
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => match ChannelMessageService::process_stream_event(&event) {
|
||||
Some(StreamAction::AppendText(chunk)) => {
|
||||
text_buffer.push_str(&chunk);
|
||||
has_content = true;
|
||||
}
|
||||
Some(StreamAction::Thinking(_)) => {}
|
||||
Some(StreamAction::ToolCall { .. }) if has_content && !text_buffer.trim().is_empty() => {
|
||||
let formatted = format_text_for_platform(&text_buffer, self.config.platform);
|
||||
let flush_msg = ChannelMessageService::build_streaming_message(&formatted);
|
||||
let _ = self
|
||||
.sender
|
||||
.send_message(&self.config.plugin_id, &self.config.chat_id, flush_msg)
|
||||
.await;
|
||||
text_buffer.clear();
|
||||
has_content = false;
|
||||
}
|
||||
Some(StreamAction::ToolCall { .. }) => {}
|
||||
// A blocking decision: record it and forward a numbered
|
||||
// list as a new message. WeChat cannot edit, so this is a
|
||||
// fresh send_message either way.
|
||||
Some(StreamAction::Decision { call_id, prompt, options }) => {
|
||||
self.record_and_send_decision(call_id, prompt, options).await;
|
||||
}
|
||||
Some(StreamAction::Finish) => {
|
||||
if has_content && !text_buffer.trim().is_empty() {
|
||||
let formatted = format_text_for_platform(&text_buffer, self.config.platform);
|
||||
let final_msg = ChannelMessageService::build_final_message(&formatted);
|
||||
let _ = self
|
||||
.sender
|
||||
.send_message(&self.config.plugin_id, &self.config.chat_id, final_msg)
|
||||
.await;
|
||||
}
|
||||
info!(
|
||||
plugin_id = %self.config.plugin_id,
|
||||
chat_id = %self.config.chat_id,
|
||||
text_len = text_buffer.len(),
|
||||
"channel stream relay finished (weixin)"
|
||||
);
|
||||
break;
|
||||
}
|
||||
Some(StreamAction::Error(msg)) => {
|
||||
let error_msg = UnifiedOutgoingMessage {
|
||||
message_type: OutgoingMessageType::Text,
|
||||
text: Some(format!("\u{274c} {msg}")),
|
||||
parse_mode: None,
|
||||
buttons: None,
|
||||
keyboard: None,
|
||||
image_url: None,
|
||||
file_url: None,
|
||||
file_name: None,
|
||||
media_actions: None,
|
||||
reply_to_message_id: None,
|
||||
silent: None,
|
||||
};
|
||||
let _ = self
|
||||
.sender
|
||||
.send_message(&self.config.plugin_id, &self.config.chat_id, error_msg)
|
||||
.await;
|
||||
break;
|
||||
}
|
||||
None => {}
|
||||
},
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
if has_content && !text_buffer.trim().is_empty() {
|
||||
let formatted = format_text_for_platform(&text_buffer, self.config.platform);
|
||||
let final_msg = ChannelMessageService::build_final_message(&formatted);
|
||||
let _ = self
|
||||
.sender
|
||||
.send_message(&self.config.plugin_id, &self.config.chat_id, final_msg)
|
||||
.await;
|
||||
}
|
||||
break;
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!(lagged = n, "channel stream relay lagged (weixin)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
plugin_id = %self.config.plugin_id,
|
||||
chat_id = %self.config.chat_id,
|
||||
"channel stream relay exited (weixin)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Standard relay for platforms that support edit (Telegram, Lark, DingTalk).
|
||||
async fn run_editable(self, mut rx: broadcast::Receiver<AgentStreamEvent>) {
|
||||
let throttle = Duration::from_millis(self.config.throttle_ms);
|
||||
|
||||
let thinking_msg = ChannelMessageService::build_thinking_message();
|
||||
let thinking_msg_id = match self
|
||||
.sender
|
||||
.send_message(&self.config.plugin_id, &self.config.chat_id, thinking_msg)
|
||||
.await
|
||||
{
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
error!(error = %e, "failed to send thinking message");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut text_buffer = String::new();
|
||||
let mut last_edit = Instant::now() - throttle;
|
||||
let mut has_content = false;
|
||||
// Whether a blocking decision was forwarded during this turn. When a
|
||||
// decision is pending, the thinking/streaming card is deliberately left
|
||||
// intact so the turn stays live (see `record_and_send_decision`); we
|
||||
// must not replace it with a terminal "(no text output)" card on Finish.
|
||||
let mut decision_forwarded = false;
|
||||
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => match ChannelMessageService::process_stream_event(&event) {
|
||||
Some(StreamAction::AppendText(chunk)) => {
|
||||
text_buffer.push_str(&chunk);
|
||||
has_content = true;
|
||||
if last_edit.elapsed() >= throttle {
|
||||
let formatted = format_text_for_platform(&text_buffer, self.config.platform);
|
||||
let mut msg = ChannelMessageService::build_streaming_message(&formatted);
|
||||
msg.parse_mode = formatted_parse_mode(self.config.platform);
|
||||
let _ = self
|
||||
.sender
|
||||
.edit_message(&self.config.plugin_id, &self.config.chat_id, &thinking_msg_id, msg)
|
||||
.await;
|
||||
last_edit = Instant::now();
|
||||
}
|
||||
}
|
||||
Some(StreamAction::Thinking(_)) => {}
|
||||
Some(StreamAction::ToolCall { name, .. }) => {
|
||||
// Deliberately no parse mode: the tool name is raw
|
||||
// agent output and is not HTML-escaped here.
|
||||
let msg = ChannelMessageService::build_streaming_message(&format!("\u{23f3} {name}..."));
|
||||
let _ = self
|
||||
.sender
|
||||
.edit_message(&self.config.plugin_id, &self.config.chat_id, &thinking_msg_id, msg)
|
||||
.await;
|
||||
}
|
||||
// A blocking decision: record it and forward a numbered
|
||||
// list as a new message; the thinking/streaming card is
|
||||
// left intact and the turn stays live.
|
||||
Some(StreamAction::Decision { call_id, prompt, options }) => {
|
||||
decision_forwarded = true;
|
||||
self.record_and_send_decision(call_id, prompt, options).await;
|
||||
}
|
||||
Some(StreamAction::Finish) => {
|
||||
self.send_final_edit(&text_buffer, has_content, decision_forwarded, &thinking_msg_id)
|
||||
.await;
|
||||
info!(
|
||||
plugin_id = %self.config.plugin_id,
|
||||
chat_id = %self.config.chat_id,
|
||||
text_len = text_buffer.len(),
|
||||
"channel stream relay finished"
|
||||
);
|
||||
break;
|
||||
}
|
||||
Some(StreamAction::Error(msg)) => {
|
||||
// Raw agent error text is not formatter-escaped, so
|
||||
// it must stay plain text (no parse mode).
|
||||
let error_msg = UnifiedOutgoingMessage {
|
||||
message_type: OutgoingMessageType::Text,
|
||||
text: Some(format!("\u{274c} {msg}")),
|
||||
parse_mode: None,
|
||||
buttons: None,
|
||||
keyboard: None,
|
||||
image_url: None,
|
||||
file_url: None,
|
||||
file_name: None,
|
||||
media_actions: None,
|
||||
reply_to_message_id: None,
|
||||
silent: None,
|
||||
};
|
||||
let _ = self
|
||||
.sender
|
||||
.edit_message(
|
||||
&self.config.plugin_id,
|
||||
&self.config.chat_id,
|
||||
&thinking_msg_id,
|
||||
error_msg,
|
||||
)
|
||||
.await;
|
||||
break;
|
||||
}
|
||||
None => {}
|
||||
},
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
warn!("channel stream relay: broadcast closed without terminal event");
|
||||
self.send_final_edit(&text_buffer, has_content, decision_forwarded, &thinking_msg_id)
|
||||
.await;
|
||||
break;
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!(lagged = n, "channel stream relay lagged");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
plugin_id = %self.config.plugin_id,
|
||||
chat_id = %self.config.chat_id,
|
||||
"channel stream relay exited"
|
||||
);
|
||||
}
|
||||
|
||||
/// Finalize the turn by replacing the "Thinking..." placeholder.
|
||||
///
|
||||
/// - With assistant text: render the formatted final card.
|
||||
/// - Without text but a decision was forwarded: leave the card intact — the
|
||||
/// decision flow owns the live UX and the turn stays interactive.
|
||||
/// - Without text and no decision (tool-only / empty completion): the agent
|
||||
/// reported a finished turn that produced no `Text` event. The placeholder
|
||||
/// must still be replaced with a terminal card, otherwise the user is left
|
||||
/// staring at "Thinking..." forever on an already-completed turn (the
|
||||
/// silent-empty-reply failure class). Emit a neutral "(no text output)"
|
||||
/// final card so the action buttons are delivered and the card is终态.
|
||||
async fn send_final_edit(&self, text_buffer: &str, has_content: bool, decision_forwarded: bool, msg_id: &str) {
|
||||
if has_content {
|
||||
let formatted = format_text_for_platform(text_buffer, self.config.platform);
|
||||
let mut final_msg = ChannelMessageService::build_final_message(&formatted);
|
||||
final_msg.parse_mode = formatted_parse_mode(self.config.platform);
|
||||
let _ = self
|
||||
.sender
|
||||
.edit_message(&self.config.plugin_id, &self.config.chat_id, msg_id, final_msg)
|
||||
.await;
|
||||
} else if !decision_forwarded {
|
||||
// Plain text — no formatter output here, so no parse mode.
|
||||
let final_msg = ChannelMessageService::build_final_message("(无文本输出)");
|
||||
let _ = self
|
||||
.sender
|
||||
.edit_message(&self.config.plugin_id, &self.config.chat_id, msg_id, final_msg)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a blocking decision against its conversation and forwards the
|
||||
/// numbered choice list as a new message. The streaming/thinking card is
|
||||
/// untouched and the turn stays live until the user answers (the inbound
|
||||
/// numeric reply resolves it via `ConversationService::confirm`).
|
||||
async fn record_and_send_decision(
|
||||
&self,
|
||||
call_id: String,
|
||||
prompt: String,
|
||||
options: Vec<crate::types::DecisionOption>,
|
||||
) {
|
||||
self.pending.put(PendingDecision {
|
||||
conversation_id: self.config.conversation_id.clone(),
|
||||
call_id,
|
||||
prompt: prompt.clone(),
|
||||
options: options.clone(),
|
||||
});
|
||||
let msg = ChannelMessageService::build_decision_message(&prompt, &options);
|
||||
let _ = self
|
||||
.sender
|
||||
.send_message(&self.config.plugin_id, &self.config.chat_id, msg)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse mode for text that has been through `format_text_for_platform`.
|
||||
///
|
||||
/// The formatter emits HTML for Telegram (escaping `&`, `<`, `>` in the
|
||||
/// source before inserting tags), so requesting `HTML` parse mode is both
|
||||
/// safe and required — without it Telegram renders the tags literally
|
||||
/// (`<b>hi</b>` shows up verbatim in the chat). Lark/DingTalk receive
|
||||
/// markdown and WeChat plain text; they keep `None`.
|
||||
///
|
||||
/// Trade-off note: Telegram rejects malformed HTML with a 400. The
|
||||
/// formatter's up-front entity escaping makes the body well-formed; the one
|
||||
/// residual edge is a markdown link URL containing a double quote, which
|
||||
/// would break the `href` attribute. That is pathological agent output and
|
||||
/// the failed edit is logged rather than guarded against here.
|
||||
fn formatted_parse_mode(platform: PluginType) -> Option<ParseMode> {
|
||||
match platform {
|
||||
PluginType::Telegram => Some(ParseMode::HTML),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Channels that cannot edit messages in place — each reply must be a new send,
|
||||
/// so the relay buffers assistant text and sends it once (no streaming edits).
|
||||
/// WeChat/WeCom (no edit API), Twitch (IRC chat), Nostr (immutable events), and
|
||||
/// QQ Bot (no edit API + tight passive-reply window).
|
||||
fn is_send_once_platform(platform: PluginType) -> bool {
|
||||
matches!(
|
||||
platform,
|
||||
PluginType::Weixin | PluginType::Twitch | PluginType::Nostr | PluginType::Qqbot
|
||||
)
|
||||
}
|
||||
|
||||
// ── Test helpers (pub so integration tests can use them) ─────────
|
||||
|
||||
/// Records send/edit calls for test assertions.
|
||||
pub struct MessageRecorder {
|
||||
sends: std::sync::Mutex<Vec<UnifiedOutgoingMessage>>,
|
||||
edits: std::sync::Mutex<Vec<UnifiedOutgoingMessage>>,
|
||||
}
|
||||
|
||||
impl MessageRecorder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sends: std::sync::Mutex::new(Vec::new()),
|
||||
edits: std::sync::Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_sends(&self) -> Vec<UnifiedOutgoingMessage> {
|
||||
std::mem::take(&mut self.sends.lock().unwrap())
|
||||
}
|
||||
|
||||
pub fn take_edits(&self) -> Vec<UnifiedOutgoingMessage> {
|
||||
std::mem::take(&mut self.edits.lock().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MessageRecorder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChannelSender for MessageRecorder {
|
||||
async fn send_message(
|
||||
&self,
|
||||
_plugin_id: &str,
|
||||
_chat_id: &str,
|
||||
message: UnifiedOutgoingMessage,
|
||||
) -> Result<String, ChannelError> {
|
||||
self.sends.lock().unwrap().push(message);
|
||||
Ok("msg-1".into())
|
||||
}
|
||||
|
||||
async fn edit_message(
|
||||
&self,
|
||||
_plugin_id: &str,
|
||||
_chat_id: &str,
|
||||
_message_id: &str,
|
||||
message: UnifiedOutgoingMessage,
|
||||
) -> Result<(), ChannelError> {
|
||||
self.edits.lock().unwrap().push(message);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,270 @@
|
||||
//! Black-box integration tests for the DingTalk plugin.
|
||||
//!
|
||||
//! Tests the DingtalkPlugin through the public ChannelPlugin trait interface
|
||||
//! and ChannelManager integration.
|
||||
//!
|
||||
//! Covers test-plan items: TP-3 (partial — invalid creds), TP-4, EP-5.
|
||||
//!
|
||||
//! NOTE: Tests requiring a live DingTalk API (TP-1, EP-1) are not included.
|
||||
//! The unit tests within the crate cover pure function logic (callback encoding/
|
||||
//! decoding, chatId encoding/decoding, message extraction, AI Card param
|
||||
//! building, stream frame parsing, etc.).
|
||||
|
||||
#[cfg(feature = "dingtalk")]
|
||||
mod dingtalk_tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_channel::manager::{ChannelManager, EnableChannelSpec, PluginFactory};
|
||||
use nomifun_channel::plugin::ChannelPlugin;
|
||||
use nomifun_channel::plugins::dingtalk::DingtalkPlugin;
|
||||
use nomifun_channel::types::{PluginConfig, PluginCredentials, PluginStatus, PluginType};
|
||||
use nomifun_db::{IChannelRepository, SqliteChannelRepository, init_database_memory};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
// -- Test infrastructure ------------------------------------------------
|
||||
|
||||
struct MockBroadcaster {
|
||||
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl MockBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBroadcaster for MockBroadcaster {
|
||||
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn make_encryption_key() -> [u8; 32] {
|
||||
[0x42u8; 32]
|
||||
}
|
||||
|
||||
async fn setup() -> (ChannelManager, Arc<dyn IChannelRepository>, Arc<MockBroadcaster>) {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo: Arc<dyn IChannelRepository> = Arc::new(SqliteChannelRepository::new(db.pool().clone()));
|
||||
let broadcaster = Arc::new(MockBroadcaster::new());
|
||||
let (message_tx, _message_rx) = mpsc::channel(16);
|
||||
let (confirm_tx, _confirm_rx) = mpsc::channel(16);
|
||||
|
||||
let manager = ChannelManager::new(
|
||||
repo.clone(),
|
||||
broadcaster.clone(),
|
||||
make_encryption_key(),
|
||||
message_tx,
|
||||
confirm_tx,
|
||||
);
|
||||
|
||||
std::mem::forget(db);
|
||||
|
||||
(manager, repo, broadcaster)
|
||||
}
|
||||
|
||||
fn dingtalk_factory() -> PluginFactory {
|
||||
Box::new(|pt| {
|
||||
if pt == PluginType::Dingtalk {
|
||||
Some(Box::new(DingtalkPlugin::new()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn make_dingtalk_config(client_id: Option<&str>, client_secret: Option<&str>) -> PluginConfig {
|
||||
PluginConfig {
|
||||
credentials: PluginCredentials {
|
||||
client_id: client_id.map(String::from),
|
||||
client_secret: client_secret.map(String::from),
|
||||
..Default::default()
|
||||
},
|
||||
config: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_dingtalk_config_value(client_id: Option<&str>, client_secret: Option<&str>) -> serde_json::Value {
|
||||
let mut creds = serde_json::Map::new();
|
||||
if let Some(id) = client_id {
|
||||
creds.insert("clientId".into(), serde_json::Value::String(id.into()));
|
||||
}
|
||||
if let Some(secret) = client_secret {
|
||||
creds.insert("clientSecret".into(), serde_json::Value::String(secret.into()));
|
||||
}
|
||||
serde_json::json!({
|
||||
"credentials": creds,
|
||||
"config": { "mode": "websocket" }
|
||||
})
|
||||
}
|
||||
|
||||
// -- Plugin construction ------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn dingtalk_plugin_initial_state() {
|
||||
let plugin = DingtalkPlugin::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::Dingtalk);
|
||||
assert_eq!(plugin.active_user_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dingtalk_plugin_is_send_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<DingtalkPlugin>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dingtalk_plugin_as_trait_object() {
|
||||
let plugin = DingtalkPlugin::new();
|
||||
let boxed: Box<dyn ChannelPlugin> = Box::new(plugin);
|
||||
assert_eq!(boxed.plugin_type(), PluginType::Dingtalk);
|
||||
assert_eq!(boxed.status(), PluginStatus::Created);
|
||||
}
|
||||
|
||||
// -- Factory registration -----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn factory_creates_dingtalk_plugin() {
|
||||
let factory = dingtalk_factory();
|
||||
let plugin = factory(PluginType::Dingtalk);
|
||||
assert!(plugin.is_some());
|
||||
let plugin = plugin.unwrap();
|
||||
assert_eq!(plugin.plugin_type(), PluginType::Dingtalk);
|
||||
assert_eq!(plugin.status(), PluginStatus::Created);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_returns_none_for_other_types() {
|
||||
let factory = dingtalk_factory();
|
||||
assert!(factory(PluginType::Telegram).is_none());
|
||||
assert!(factory(PluginType::Lark).is_none());
|
||||
assert!(factory(PluginType::Weixin).is_none());
|
||||
}
|
||||
|
||||
// -- TP-3: Invalid credentials (client_id + client_secret) ---------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_invalid_credentials_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = dingtalk_factory();
|
||||
|
||||
let config = make_dingtalk_config(Some("invalid_id"), Some("invalid_secret"));
|
||||
let result = manager.test_plugin("dingtalk", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- Missing client_id ---------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_missing_client_id_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = dingtalk_factory();
|
||||
|
||||
let config = make_dingtalk_config(None, Some("secret123"));
|
||||
let result = manager.test_plugin("dingtalk", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.to_lowercase().contains("client_id"),
|
||||
"Error should mention client_id: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- Missing client_secret -----------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_missing_client_secret_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = dingtalk_factory();
|
||||
|
||||
let config = make_dingtalk_config(Some("key_123"), None);
|
||||
let result = manager.test_plugin("dingtalk", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.to_lowercase().contains("client_secret"),
|
||||
"Error should mention client_secret: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- Empty credentials ---------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_empty_credentials_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = dingtalk_factory();
|
||||
|
||||
let config = make_dingtalk_config(Some(""), Some(""));
|
||||
let result = manager.test_plugin("dingtalk", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- EP-5: Invalid plugin type -------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_invalid_plugin_type_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = dingtalk_factory();
|
||||
|
||||
let config = make_dingtalk_config_value(Some("key_123"), Some("secret"));
|
||||
let result = manager.enable_plugin(&EnableChannelSpec::legacy("nonexistent"), &config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- Enable with invalid credentials -------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_plugin_invalid_credentials_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = dingtalk_factory();
|
||||
|
||||
let config = make_dingtalk_config_value(Some("bad_id"), Some("bad_secret"));
|
||||
let result = manager.enable_plugin(&EnableChannelSpec::legacy("dingtalk"), &config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- Disable without DB row ----------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn disable_without_db_row_returns_error() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let result = manager.disable_plugin("dingtalk").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- PS-1: Empty plugin status -------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_plugin_status_empty() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let statuses = manager.get_plugin_status().await.unwrap();
|
||||
assert!(statuses.is_empty());
|
||||
}
|
||||
|
||||
// -- Restore with nothing stored -----------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_plugins_none_stored() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = dingtalk_factory();
|
||||
let result = manager.restore_plugins(&factory).await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(manager.active_plugin_count(), 0);
|
||||
}
|
||||
|
||||
// -- Plugin running check ------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_plugin_running_false_when_not_enabled() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
assert!(!manager.is_plugin_running("dingtalk"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
use nomifun_channel::formatter::format_text_for_platform;
|
||||
use nomifun_channel::types::PluginType;
|
||||
|
||||
// ── Telegram: escape HTML, then convert markdown to HTML tags ────
|
||||
|
||||
#[test]
|
||||
fn telegram_bold_and_code() {
|
||||
let input = "**bold** and `code`";
|
||||
let result = format_text_for_platform(input, PluginType::Telegram);
|
||||
assert!(result.contains("<b>bold</b>"), "got: {result}");
|
||||
assert!(result.contains("<code>code</code>"), "got: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telegram_escapes_raw_html() {
|
||||
let input = "<script>alert(1)</script>";
|
||||
let result = format_text_for_platform(input, PluginType::Telegram);
|
||||
assert!(!result.contains("<script>"), "got: {result}");
|
||||
assert!(result.contains("<script>"), "got: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telegram_code_block() {
|
||||
let input = "```rust\nfn main() {}\n```";
|
||||
let result = format_text_for_platform(input, PluginType::Telegram);
|
||||
assert!(result.contains("<pre><code>"), "got: {result}");
|
||||
assert!(result.contains("fn main()"), "got: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telegram_link() {
|
||||
let input = "[click](https://example.com)";
|
||||
let result = format_text_for_platform(input, PluginType::Telegram);
|
||||
assert!(
|
||||
result.contains(r#"<a href="https://example.com">click</a>"#),
|
||||
"got: {result}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Lark / DingTalk: HTML tags → markdown syntax ─────────────────
|
||||
|
||||
#[test]
|
||||
fn lark_bold_and_code() {
|
||||
let input = "<b>bold</b> and <code>code</code>";
|
||||
let result = format_text_for_platform(input, PluginType::Lark);
|
||||
assert!(result.contains("**bold**"), "got: {result}");
|
||||
assert!(result.contains("`code`"), "got: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lark_link_with_protocol_whitelist() {
|
||||
let input = r#"<a href="https://ok.com">safe</a> <a href="javascript:void(0)">evil</a>"#;
|
||||
let result = format_text_for_platform(input, PluginType::Lark);
|
||||
assert!(result.contains("[safe](https://ok.com)"), "got: {result}");
|
||||
assert!(!result.contains("javascript:"), "got: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lark_strips_unknown_tags() {
|
||||
let input = "<div><b>bold</b></div>";
|
||||
let result = format_text_for_platform(input, PluginType::Lark);
|
||||
assert!(result.contains("**bold**"), "got: {result}");
|
||||
assert!(!result.contains("<div>"), "got: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dingtalk_same_output_as_lark() {
|
||||
let input = "<b>bold</b> and <i>italic</i>";
|
||||
let lark = format_text_for_platform(input, PluginType::Lark);
|
||||
let ding = format_text_for_platform(input, PluginType::Dingtalk);
|
||||
assert_eq!(lark, ding);
|
||||
}
|
||||
|
||||
// ── WeChat: strip all HTML ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn weixin_strips_all_html() {
|
||||
let input = "<b>bold</b> and <a href=\"url\">link</a>";
|
||||
let result = format_text_for_platform(input, PluginType::Weixin);
|
||||
assert!(!result.contains('<'), "got: {result}");
|
||||
assert!(!result.contains('>'), "got: {result}");
|
||||
assert!(result.contains("bold"), "got: {result}");
|
||||
assert!(result.contains("link"), "got: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weixin_decodes_entities() {
|
||||
let input = "& <tag>";
|
||||
let result = format_text_for_platform(input, PluginType::Weixin);
|
||||
assert_eq!(result.trim(), "& tag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weixin_nested_tags() {
|
||||
let input = "<scr<script>ipt>alert(1)</scr</script>ipt>";
|
||||
let result = format_text_for_platform(input, PluginType::Weixin);
|
||||
assert!(!result.contains('<'), "got: {result}");
|
||||
}
|
||||
|
||||
// ── Fallback: escape HTML ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn fallback_escapes_html() {
|
||||
let input = "<b>bold</b>";
|
||||
let result = format_text_for_platform(input, PluginType::Slack);
|
||||
assert!(result.contains("<b>"), "got: {result}");
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Black-box integration tests for the Lark (Feishu) plugin.
|
||||
//!
|
||||
//! Tests the LarkPlugin through the public ChannelPlugin trait interface
|
||||
//! and ChannelManager integration.
|
||||
//!
|
||||
//! Covers test-plan items: TP-3 (partial — invalid creds), TP-4, EP-5.
|
||||
//!
|
||||
//! NOTE: Tests requiring a live Lark API (TP-1, EP-1) are not included.
|
||||
//! The unit tests within the crate cover pure function logic (event parsing,
|
||||
//! card building, deduplication, callback encoding/decoding, etc.).
|
||||
|
||||
#[cfg(feature = "lark")]
|
||||
mod lark_tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_channel::manager::{ChannelManager, EnableChannelSpec, PluginFactory};
|
||||
use nomifun_channel::plugin::ChannelPlugin;
|
||||
use nomifun_channel::plugins::lark::LarkPlugin;
|
||||
use nomifun_channel::types::{PluginConfig, PluginCredentials, PluginStatus, PluginType};
|
||||
use nomifun_db::{IChannelRepository, SqliteChannelRepository, init_database_memory};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
// -- Test infrastructure ------------------------------------------------
|
||||
|
||||
struct MockBroadcaster {
|
||||
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl MockBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBroadcaster for MockBroadcaster {
|
||||
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn make_encryption_key() -> [u8; 32] {
|
||||
[0x42u8; 32]
|
||||
}
|
||||
|
||||
async fn setup() -> (ChannelManager, Arc<dyn IChannelRepository>, Arc<MockBroadcaster>) {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo: Arc<dyn IChannelRepository> = Arc::new(SqliteChannelRepository::new(db.pool().clone()));
|
||||
let broadcaster = Arc::new(MockBroadcaster::new());
|
||||
let (message_tx, _message_rx) = mpsc::channel(16);
|
||||
let (confirm_tx, _confirm_rx) = mpsc::channel(16);
|
||||
|
||||
let manager = ChannelManager::new(
|
||||
repo.clone(),
|
||||
broadcaster.clone(),
|
||||
make_encryption_key(),
|
||||
message_tx,
|
||||
confirm_tx,
|
||||
);
|
||||
|
||||
std::mem::forget(db);
|
||||
|
||||
(manager, repo, broadcaster)
|
||||
}
|
||||
|
||||
fn lark_factory() -> PluginFactory {
|
||||
Box::new(|pt| {
|
||||
if pt == PluginType::Lark {
|
||||
Some(Box::new(LarkPlugin::new()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn make_lark_config(app_id: Option<&str>, app_secret: Option<&str>) -> PluginConfig {
|
||||
PluginConfig {
|
||||
credentials: PluginCredentials {
|
||||
app_id: app_id.map(String::from),
|
||||
app_secret: app_secret.map(String::from),
|
||||
..Default::default()
|
||||
},
|
||||
config: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_lark_config_value(app_id: Option<&str>, app_secret: Option<&str>) -> serde_json::Value {
|
||||
let mut creds = serde_json::Map::new();
|
||||
if let Some(id) = app_id {
|
||||
creds.insert("appId".into(), serde_json::Value::String(id.into()));
|
||||
}
|
||||
if let Some(secret) = app_secret {
|
||||
creds.insert("appSecret".into(), serde_json::Value::String(secret.into()));
|
||||
}
|
||||
serde_json::json!({
|
||||
"credentials": creds,
|
||||
"config": { "mode": "websocket" }
|
||||
})
|
||||
}
|
||||
|
||||
// -- Plugin construction ------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn lark_plugin_initial_state() {
|
||||
let plugin = LarkPlugin::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::Lark);
|
||||
assert_eq!(plugin.active_user_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lark_plugin_is_send_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<LarkPlugin>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lark_plugin_as_trait_object() {
|
||||
let plugin = LarkPlugin::new();
|
||||
let boxed: Box<dyn ChannelPlugin> = Box::new(plugin);
|
||||
assert_eq!(boxed.plugin_type(), PluginType::Lark);
|
||||
assert_eq!(boxed.status(), PluginStatus::Created);
|
||||
}
|
||||
|
||||
// -- Factory registration -----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn factory_creates_lark_plugin() {
|
||||
let factory = lark_factory();
|
||||
let plugin = factory(PluginType::Lark);
|
||||
assert!(plugin.is_some());
|
||||
let plugin = plugin.unwrap();
|
||||
assert_eq!(plugin.plugin_type(), PluginType::Lark);
|
||||
assert_eq!(plugin.status(), PluginStatus::Created);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_returns_none_for_other_types() {
|
||||
let factory = lark_factory();
|
||||
assert!(factory(PluginType::Telegram).is_none());
|
||||
assert!(factory(PluginType::Dingtalk).is_none());
|
||||
assert!(factory(PluginType::Weixin).is_none());
|
||||
}
|
||||
|
||||
// -- TP-3: Invalid credentials (app_id + app_secret) --------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_invalid_credentials_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = lark_factory();
|
||||
|
||||
let config = make_lark_config(Some("invalid_app_id"), Some("invalid_secret"));
|
||||
let result = manager.test_plugin("lark", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- Missing app_id -----------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_missing_app_id_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = lark_factory();
|
||||
|
||||
let config = make_lark_config(None, Some("secret123"));
|
||||
let result = manager.test_plugin("lark", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.to_lowercase().contains("app_id"),
|
||||
"Error should mention app_id: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- Missing app_secret -------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_missing_app_secret_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = lark_factory();
|
||||
|
||||
let config = make_lark_config(Some("cli_123"), None);
|
||||
let result = manager.test_plugin("lark", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.to_lowercase().contains("app_secret"),
|
||||
"Error should mention app_secret: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- Empty credentials --------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_empty_credentials_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = lark_factory();
|
||||
|
||||
let config = make_lark_config(Some(""), Some(""));
|
||||
let result = manager.test_plugin("lark", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- EP-5: Invalid plugin type ------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_invalid_plugin_type_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = lark_factory();
|
||||
|
||||
let config = make_lark_config_value(Some("cli_123"), Some("secret"));
|
||||
let result = manager.enable_plugin(&EnableChannelSpec::legacy("nonexistent"), &config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- Enable with invalid credentials ------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_plugin_invalid_credentials_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = lark_factory();
|
||||
|
||||
let config = make_lark_config_value(Some("bad_id"), Some("bad_secret"));
|
||||
let result = manager.enable_plugin(&EnableChannelSpec::legacy("lark"), &config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- Disable without DB row ---------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn disable_without_db_row_returns_error() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let result = manager.disable_plugin("lark").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- PS-1: Empty plugin status ------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_plugin_status_empty() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let statuses = manager.get_plugin_status().await.unwrap();
|
||||
assert!(statuses.is_empty());
|
||||
}
|
||||
|
||||
// -- Restore with nothing stored ----------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_plugins_none_stored() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = lark_factory();
|
||||
let result = manager.restore_plugins(&factory).await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(manager.active_plugin_count(), 0);
|
||||
}
|
||||
|
||||
// -- Plugin running check -----------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_plugin_running_false_when_not_enabled() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
assert!(!manager.is_plugin_running("lark"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
//! Black-box integration tests for `ChannelManager`.
|
||||
//!
|
||||
//! Uses real SQLite (in-memory) and mock EventBroadcaster + MockPlugin.
|
||||
//! Covers test-plan items: PS-1..PS-3, EP-1..EP-5, DP-1..DP-4,
|
||||
//! TP-1..TP-5, CS-1..CS-2, WS-2.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_channel::error::ChannelError;
|
||||
use nomifun_channel::manager::{ChannelManager, EnableChannelSpec, PluginFactory};
|
||||
use nomifun_channel::plugin::{ChannelPlugin, PluginCallbacks};
|
||||
use nomifun_channel::types::{
|
||||
BotInfo, OutgoingMessageType, PluginConfig, PluginCredentials, PluginStatus, PluginType, UnifiedOutgoingMessage,
|
||||
};
|
||||
use nomifun_common::decrypt_string;
|
||||
use nomifun_db::{IChannelRepository, SqliteChannelRepository, init_database_memory};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
// ── Test infrastructure ─────────────────────────────────────────────
|
||||
|
||||
struct MockBroadcaster {
|
||||
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl MockBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn take_events(&self) -> Vec<WebSocketMessage<serde_json::Value>> {
|
||||
let mut guard = self.events.lock().unwrap();
|
||||
std::mem::take(&mut *guard)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBroadcaster for MockBroadcaster {
|
||||
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock plugin that tracks lifecycle calls.
|
||||
struct MockPlugin {
|
||||
status: PluginStatus,
|
||||
plugin_type: PluginType,
|
||||
bot_info: Option<BotInfo>,
|
||||
last_error: Option<String>,
|
||||
should_fail_init: bool,
|
||||
start_calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl MockPlugin {
|
||||
fn new(plugin_type: PluginType) -> Self {
|
||||
Self {
|
||||
status: PluginStatus::Created,
|
||||
plugin_type,
|
||||
bot_info: None,
|
||||
last_error: None,
|
||||
should_fail_init: false,
|
||||
start_calls: Arc::new(AtomicUsize::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
fn failing(plugin_type: PluginType) -> Self {
|
||||
Self {
|
||||
should_fail_init: true,
|
||||
..Self::new(plugin_type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ChannelPlugin for MockPlugin {
|
||||
async fn initialize(&mut self, _config: PluginConfig, _callbacks: PluginCallbacks) -> Result<(), ChannelError> {
|
||||
if self.should_fail_init {
|
||||
self.status = PluginStatus::Error;
|
||||
self.last_error = Some("Mock init failure".into());
|
||||
return Err(ChannelError::ConnectionFailed("Mock init failure".into()));
|
||||
}
|
||||
self.status = PluginStatus::Initializing;
|
||||
self.bot_info = Some(BotInfo {
|
||||
id: "mock_bot".into(),
|
||||
username: Some("mock_bot_user".into()),
|
||||
display_name: "Mock Bot".into(),
|
||||
});
|
||||
self.status = PluginStatus::Ready;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start(&mut self) -> Result<(), ChannelError> {
|
||||
self.start_calls.fetch_add(1, Ordering::SeqCst);
|
||||
self.status = PluginStatus::Starting;
|
||||
self.status = PluginStatus::Running;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(&mut self) -> Result<(), ChannelError> {
|
||||
self.status = PluginStatus::Stopping;
|
||||
self.status = PluginStatus::Stopped;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_message(&self, _chat_id: &str, _message: UnifiedOutgoingMessage) -> Result<String, ChannelError> {
|
||||
Ok("mock_msg_id".into())
|
||||
}
|
||||
|
||||
async fn edit_message(
|
||||
&self,
|
||||
_chat_id: &str,
|
||||
_message_id: &str,
|
||||
_message: UnifiedOutgoingMessage,
|
||||
) -> Result<(), ChannelError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn active_user_count(&self) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
fn bot_info(&self) -> Option<&BotInfo> {
|
||||
self.bot_info.as_ref()
|
||||
}
|
||||
|
||||
fn plugin_type(&self) -> PluginType {
|
||||
self.plugin_type
|
||||
}
|
||||
|
||||
fn status(&self) -> PluginStatus {
|
||||
self.status
|
||||
}
|
||||
|
||||
fn last_error(&self) -> Option<&str> {
|
||||
self.last_error.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
fn test_key() -> [u8; 32] {
|
||||
[0x42; 32]
|
||||
}
|
||||
|
||||
async fn setup() -> (ChannelManager, Arc<dyn IChannelRepository>, Arc<MockBroadcaster>) {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo: Arc<dyn IChannelRepository> = Arc::new(SqliteChannelRepository::new(db.pool().clone()));
|
||||
let bc = Arc::new(MockBroadcaster::new());
|
||||
let (msg_tx, _msg_rx) = mpsc::channel(16);
|
||||
let (confirm_tx, _confirm_rx) = mpsc::channel(16);
|
||||
let mgr = ChannelManager::new(repo.clone(), bc.clone(), test_key(), msg_tx, confirm_tx);
|
||||
// Keep db alive by leaking — test process exits anyway
|
||||
std::mem::forget(db);
|
||||
(mgr, repo, bc)
|
||||
}
|
||||
|
||||
fn make_factory() -> PluginFactory {
|
||||
Box::new(|pt| Some(Box::new(MockPlugin::new(pt))))
|
||||
}
|
||||
|
||||
fn make_failing_factory() -> PluginFactory {
|
||||
Box::new(|pt| Some(Box::new(MockPlugin::failing(pt))))
|
||||
}
|
||||
|
||||
fn make_no_impl_factory() -> PluginFactory {
|
||||
Box::new(|_pt| None)
|
||||
}
|
||||
|
||||
fn make_counting_factory() -> (PluginFactory, Arc<AtomicUsize>) {
|
||||
let start_calls = Arc::new(AtomicUsize::new(0));
|
||||
let captured = Arc::clone(&start_calls);
|
||||
let factory = Box::new(move |pt| {
|
||||
let mut plugin = MockPlugin::new(pt);
|
||||
plugin.start_calls = Arc::clone(&captured);
|
||||
Some(Box::new(plugin) as Box<dyn ChannelPlugin>)
|
||||
});
|
||||
(factory, start_calls)
|
||||
}
|
||||
|
||||
fn make_telegram_config() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"credentials": { "token": "bot:valid123" },
|
||||
"config": { "mode": "polling" }
|
||||
})
|
||||
}
|
||||
|
||||
fn make_lark_config() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"credentials": {
|
||||
"app_id": "cli_abc",
|
||||
"app_secret": "secret123"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn make_plugin_config() -> PluginConfig {
|
||||
PluginConfig {
|
||||
credentials: PluginCredentials {
|
||||
token: Some("bot:valid123".into()),
|
||||
..Default::default()
|
||||
},
|
||||
config: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_test_outgoing() -> UnifiedOutgoingMessage {
|
||||
UnifiedOutgoingMessage {
|
||||
message_type: OutgoingMessageType::Text,
|
||||
text: Some("hello".into()),
|
||||
parse_mode: None,
|
||||
buttons: None,
|
||||
keyboard: None,
|
||||
image_url: None,
|
||||
file_url: None,
|
||||
file_name: None,
|
||||
media_actions: None,
|
||||
reply_to_message_id: None,
|
||||
silent: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── PS-1: Get plugin status (no plugins) ──────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ps1_get_status_empty() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let statuses = mgr.get_plugin_status().await.unwrap();
|
||||
assert!(statuses.is_empty());
|
||||
}
|
||||
|
||||
// ── PS-2: Get plugin status (with plugins) ────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ps2_get_status_with_plugins() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let statuses = mgr.get_plugin_status().await.unwrap();
|
||||
assert_eq!(statuses.len(), 1);
|
||||
assert_eq!(statuses[0].plugin_id, "telegram");
|
||||
assert_eq!(statuses[0].plugin_type, "telegram");
|
||||
assert_eq!(statuses[0].name, "Telegram Bot");
|
||||
assert!(statuses[0].enabled);
|
||||
assert_eq!(statuses[0].status.as_deref(), Some("running"));
|
||||
}
|
||||
|
||||
// ── EP-1: Enable Telegram plugin ──────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ep1_enable_telegram_plugin() {
|
||||
let (mgr, repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Plugin persisted in DB
|
||||
let row = repo.get_plugin("telegram").await.unwrap().unwrap();
|
||||
assert!(row.enabled);
|
||||
assert_eq!(row.r#type, "telegram");
|
||||
assert_eq!(row.name, "Telegram Bot");
|
||||
assert!(row.last_connected.is_some());
|
||||
|
||||
// Plugin is running in memory
|
||||
assert!(mgr.is_plugin_running("telegram"));
|
||||
assert_eq!(mgr.active_plugin_count(), 1);
|
||||
}
|
||||
|
||||
// ── EP-2: Re-enable updates config ────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ep2_re_enable_updates_config() {
|
||||
let (mgr, repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Re-enable with different config
|
||||
let new_config = serde_json::json!({
|
||||
"credentials": { "token": "bot:new_token_456" },
|
||||
"config": { "mode": "webhook", "webhook_url": "https://example.com" }
|
||||
});
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &new_config, &factory).await.unwrap();
|
||||
|
||||
// Still only one plugin
|
||||
assert_eq!(mgr.active_plugin_count(), 1);
|
||||
|
||||
// Config should be updated
|
||||
let row = repo.get_plugin("telegram").await.unwrap().unwrap();
|
||||
let decrypted = decrypt_string(&row.config, &test_key()).unwrap();
|
||||
let config: PluginConfig = serde_json::from_str(&decrypted).unwrap();
|
||||
assert_eq!(config.credentials.token.as_deref(), Some("bot:new_token_456"));
|
||||
}
|
||||
|
||||
// ── EP-5: Invalid plugin ID ──────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ep5_invalid_plugin_id() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
let err = mgr
|
||||
.enable_plugin(&EnableChannelSpec::legacy("nonexistent"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::InvalidPluginType(_)));
|
||||
}
|
||||
|
||||
// ── EP-3/EP-4: Missing required fields ────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ep3_ep4_invalid_config_structure() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
// Missing credentials entirely
|
||||
let bad = serde_json::json!({ "wrong_key": "value" });
|
||||
let err = mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &bad, &factory).await.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::InvalidConfig(_)));
|
||||
}
|
||||
|
||||
// ── DP-1: Disable enabled plugin ──────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn dp1_disable_enabled_plugin() {
|
||||
let (mgr, repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
mgr.disable_plugin("telegram").await.unwrap();
|
||||
|
||||
assert_eq!(mgr.active_plugin_count(), 0);
|
||||
assert!(!mgr.is_plugin_running("telegram"));
|
||||
|
||||
let row = repo.get_plugin("telegram").await.unwrap().unwrap();
|
||||
assert!(!row.enabled);
|
||||
assert_eq!(row.status.as_deref(), Some("stopped"));
|
||||
}
|
||||
|
||||
// ── DP-2: Disable already disabled (idempotent) ──────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn dp2_disable_already_disabled() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
mgr.disable_plugin("telegram").await.unwrap();
|
||||
|
||||
// Second disable should not error
|
||||
mgr.disable_plugin("telegram").await.unwrap();
|
||||
assert_eq!(mgr.active_plugin_count(), 0);
|
||||
}
|
||||
|
||||
// ── TP-1: Test valid credentials returns bot username ─────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn tp1_test_valid_credentials() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
let result = mgr
|
||||
.test_plugin("telegram", make_plugin_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.as_deref(), Some("mock_bot_user"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_initializes_without_starting_runtime() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let (factory, start_calls) = make_counting_factory();
|
||||
|
||||
let username = mgr
|
||||
.test_plugin("telegram", make_plugin_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(username.as_deref(), Some("mock_bot_user"));
|
||||
assert_eq!(
|
||||
start_calls.load(Ordering::SeqCst),
|
||||
0,
|
||||
"credential tests must not start long-running plugin runtime"
|
||||
);
|
||||
}
|
||||
|
||||
// ── TP-2: Test invalid credentials propagates error ───────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn tp2_test_invalid_credentials() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_failing_factory();
|
||||
|
||||
let err = mgr.test_plugin("telegram", make_plugin_config(), &factory).await;
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
// ── TP-4: Missing plugin ID ──────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn tp4_test_invalid_plugin_type() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
let err = mgr
|
||||
.test_plugin("nonexistent", make_plugin_config(), &factory)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::InvalidPluginType(_)));
|
||||
}
|
||||
|
||||
// ── TP: Test does not persist ─────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn tp_test_does_not_persist() {
|
||||
let (mgr, repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.test_plugin("telegram", make_plugin_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let plugins = repo.get_all_plugins().await.unwrap();
|
||||
assert!(plugins.is_empty());
|
||||
assert_eq!(mgr.active_plugin_count(), 0);
|
||||
}
|
||||
|
||||
// ── CS-1: Credentials stored encrypted ────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn cs1_credentials_stored_encrypted() {
|
||||
let (mgr, repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let row = repo.get_plugin("telegram").await.unwrap().unwrap();
|
||||
|
||||
// Config should not contain plaintext token
|
||||
assert!(!row.config.contains("bot:valid123"));
|
||||
assert!(!row.config.contains("token"));
|
||||
|
||||
// Should be valid base64 (encrypted output)
|
||||
assert!(base64_looks_valid(&row.config));
|
||||
|
||||
// Decryption should yield the original config
|
||||
let decrypted = decrypt_string(&row.config, &test_key()).unwrap();
|
||||
let config: PluginConfig = serde_json::from_str(&decrypted).unwrap();
|
||||
assert_eq!(config.credentials.token.as_deref(), Some("bot:valid123"));
|
||||
}
|
||||
|
||||
// ── CS-2: Status response does not leak credentials ───────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn cs2_status_does_not_leak_credentials() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let statuses = mgr.get_plugin_status().await.unwrap();
|
||||
let json = serde_json::to_string(&statuses).unwrap();
|
||||
|
||||
// No sensitive fields should appear
|
||||
assert!(!json.contains("bot:valid123"));
|
||||
assert!(!json.contains("credentials"));
|
||||
assert!(!json.contains("config"));
|
||||
// But the plugin metadata should be there
|
||||
assert!(json.contains("telegram"));
|
||||
assert!(json.contains("Telegram Bot"));
|
||||
}
|
||||
|
||||
// ── WS-2: Plugin status change event broadcast ───────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ws2_enable_broadcasts_status_change() {
|
||||
let (mgr, _repo, bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let events = bc.take_events();
|
||||
let status_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.name == "channel.plugin-status-changed")
|
||||
.collect();
|
||||
assert!(!status_events.is_empty());
|
||||
assert_eq!(status_events.last().unwrap().data["plugin_id"], "telegram");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ws2_disable_broadcasts_status_change() {
|
||||
let (mgr, _repo, bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
bc.take_events(); // clear enable events
|
||||
|
||||
mgr.disable_plugin("telegram").await.unwrap();
|
||||
|
||||
let events = bc.take_events();
|
||||
let status_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.name == "channel.plugin-status-changed")
|
||||
.collect();
|
||||
assert!(!status_events.is_empty());
|
||||
}
|
||||
|
||||
// ── Restore: enabled plugins start on restore ────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_starts_enabled_plugins() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
// First enable and persist a plugin
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Simulate shutdown
|
||||
mgr.shutdown().await;
|
||||
assert_eq!(mgr.active_plugin_count(), 0);
|
||||
|
||||
// Restore should bring it back
|
||||
mgr.restore_plugins(&factory).await.unwrap();
|
||||
assert_eq!(mgr.active_plugin_count(), 1);
|
||||
assert!(mgr.is_plugin_running("telegram"));
|
||||
}
|
||||
|
||||
// ── Restore: disabled plugins are skipped ─────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_skips_disabled_plugins() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
mgr.disable_plugin("telegram").await.unwrap();
|
||||
|
||||
mgr.restore_plugins(&factory).await.unwrap();
|
||||
assert_eq!(mgr.active_plugin_count(), 0);
|
||||
}
|
||||
|
||||
// ── Multiple plugins ──────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_multiple_plugins() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("lark"), &make_lark_config(), &factory).await.unwrap();
|
||||
|
||||
assert_eq!(mgr.active_plugin_count(), 2);
|
||||
assert!(mgr.is_plugin_running("telegram"));
|
||||
assert!(mgr.is_plugin_running("lark"));
|
||||
|
||||
let statuses = mgr.get_plugin_status().await.unwrap();
|
||||
assert_eq!(statuses.len(), 2);
|
||||
}
|
||||
|
||||
// ── Shutdown stops all ────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_stops_all() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("lark"), &make_lark_config(), &factory).await.unwrap();
|
||||
|
||||
mgr.shutdown().await;
|
||||
assert_eq!(mgr.active_plugin_count(), 0);
|
||||
}
|
||||
|
||||
// ── Send/Edit message routing ─────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_message_routes_to_plugin() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let msg_id = mgr
|
||||
.send_message("telegram", "chat_1", make_test_outgoing())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg_id, "mock_msg_id");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_message_not_running_fails() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
|
||||
let err = mgr
|
||||
.send_message("telegram", "chat_1", make_test_outgoing())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::PluginNotFound(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_message_routes_to_plugin() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
mgr.edit_message("telegram", "chat_1", "msg_1", make_test_outgoing())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// ── Init failure sets error in DB ─────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_failure_sets_error_in_db() {
|
||||
let (mgr, repo, _bc) = setup().await;
|
||||
let factory = make_failing_factory();
|
||||
|
||||
let err = mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory).await;
|
||||
assert!(err.is_err());
|
||||
|
||||
// Plugin should exist in DB with error status
|
||||
let row = repo.get_plugin("telegram").await.unwrap().unwrap();
|
||||
assert_eq!(row.status.as_deref(), Some("error"));
|
||||
assert_eq!(mgr.active_plugin_count(), 0);
|
||||
}
|
||||
|
||||
// ── No implementation factory ─────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_no_implementation_fails() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_no_impl_factory();
|
||||
|
||||
let err = mgr
|
||||
.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::InvalidPluginType(_)));
|
||||
}
|
||||
|
||||
// ── Helper ────────────────────────────────────────────────────────
|
||||
|
||||
fn base64_looks_valid(s: &str) -> bool {
|
||||
s.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=')
|
||||
&& s.len() > 20
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nomifun_ai_agent::agent_task::{AgentInstance, IAgentTask};
|
||||
use nomifun_ai_agent::protocol::events::FinishEventData;
|
||||
use nomifun_ai_agent::types::{BuildTaskOptions, SendMessageData};
|
||||
use nomifun_ai_agent::{AgentSendError, AgentStreamEvent, IMockAgent, IWorkerTaskManager};
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_channel::channel_settings::ChannelSettingsService;
|
||||
use nomifun_channel::message_service::ChannelMessageService;
|
||||
use nomifun_channel::types::PluginType;
|
||||
use nomifun_common::{AgentKillReason, AgentType, AppError, ConversationStatus, TimestampMs};
|
||||
use nomifun_conversation::ConversationService;
|
||||
use nomifun_conversation::skill_resolver::{ResolvedAgentSkill, SkillResolver};
|
||||
use nomifun_db::models::AssistantSessionRow;
|
||||
use nomifun_db::{
|
||||
SqliteAcpSessionRepository, SqliteAgentMetadataRepository, SqliteChannelRepository,
|
||||
SqliteClientPreferenceRepository, SqliteConversationRepository, init_database_memory,
|
||||
};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
struct TestBroadcaster {
|
||||
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl TestBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBroadcaster for TestBroadcaster {
|
||||
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
struct NoopSkillResolver;
|
||||
|
||||
#[async_trait]
|
||||
impl SkillResolver for NoopSkillResolver {
|
||||
async fn auto_inject_names(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn resolve_skills(&self, _names: &[String]) -> Vec<ResolvedAgentSkill> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn link_workspace_skills(
|
||||
&self,
|
||||
_workspace: &std::path::Path,
|
||||
_rel_dirs: &[&str],
|
||||
_skills: &[ResolvedAgentSkill],
|
||||
) -> usize {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
struct ScriptedAgent {
|
||||
conversation_id: String,
|
||||
event_tx: broadcast::Sender<AgentStreamEvent>,
|
||||
}
|
||||
|
||||
impl ScriptedAgent {
|
||||
fn new(conversation_id: &str) -> Self {
|
||||
let (event_tx, _) = broadcast::channel(16);
|
||||
Self {
|
||||
conversation_id: conversation_id.to_owned(),
|
||||
event_tx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IAgentTask for ScriptedAgent {
|
||||
fn agent_type(&self) -> AgentType {
|
||||
AgentType::Nomi
|
||||
}
|
||||
|
||||
fn conversation_id(&self) -> &str {
|
||||
&self.conversation_id
|
||||
}
|
||||
|
||||
fn workspace(&self) -> &str {
|
||||
"/tmp/nomifun-channel-test"
|
||||
}
|
||||
|
||||
fn status(&self) -> Option<ConversationStatus> {
|
||||
Some(ConversationStatus::Finished)
|
||||
}
|
||||
|
||||
fn last_activity_at(&self) -> TimestampMs {
|
||||
0
|
||||
}
|
||||
|
||||
fn subscribe(&self) -> broadcast::Receiver<AgentStreamEvent> {
|
||||
self.event_tx.subscribe()
|
||||
}
|
||||
|
||||
async fn send_message(&self, _data: SendMessageData) -> Result<(), AgentSendError> {
|
||||
let _ = self.event_tx.send(AgentStreamEvent::Finish(FinishEventData::default()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cancel(&self) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn kill(&self, _reason: Option<AgentKillReason>) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl IMockAgent for ScriptedAgent {}
|
||||
|
||||
struct RecordingTaskManager {
|
||||
agents: Mutex<std::collections::HashMap<String, AgentInstance>>,
|
||||
}
|
||||
|
||||
impl RecordingTaskManager {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
agents: Mutex::new(std::collections::HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IWorkerTaskManager for RecordingTaskManager {
|
||||
fn get_task(&self, conversation_id: &str) -> Option<AgentInstance> {
|
||||
self.agents.lock().unwrap().get(conversation_id).cloned()
|
||||
}
|
||||
|
||||
async fn get_or_build_task(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
_options: BuildTaskOptions,
|
||||
) -> Result<AgentInstance, AppError> {
|
||||
let mut agents = self.agents.lock().unwrap();
|
||||
if let Some(agent) = agents.get(conversation_id) {
|
||||
return Ok(agent.clone());
|
||||
}
|
||||
|
||||
let agent = AgentInstance::Mock(Arc::new(ScriptedAgent::new(conversation_id)));
|
||||
agents.insert(conversation_id.to_owned(), agent.clone());
|
||||
Ok(agent)
|
||||
}
|
||||
|
||||
fn kill(&self, conversation_id: &str, _reason: Option<AgentKillReason>) -> Result<(), AppError> {
|
||||
self.agents.lock().unwrap().remove(conversation_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn kill_and_wait(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
reason: Option<AgentKillReason>,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
|
||||
let _ = self.kill(conversation_id, reason);
|
||||
Box::pin(std::future::ready(()))
|
||||
}
|
||||
|
||||
fn clear(&self) {
|
||||
self.agents.lock().unwrap().clear();
|
||||
}
|
||||
|
||||
fn active_count(&self) -> usize {
|
||||
self.agents.lock().unwrap().len()
|
||||
}
|
||||
|
||||
fn collect_idle(&self, _idle_threshold_ms: TimestampMs) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_to_agent_warms_cold_task_before_returning_stream_subscription() {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let pool = db.pool().clone();
|
||||
|
||||
let task_manager: Arc<dyn IWorkerTaskManager> = Arc::new(RecordingTaskManager::new());
|
||||
let conversation_svc = Arc::new(ConversationService::new(
|
||||
std::env::temp_dir(),
|
||||
Arc::new(TestBroadcaster::new()),
|
||||
Arc::new(NoopSkillResolver),
|
||||
Arc::clone(&task_manager),
|
||||
Arc::new(SqliteConversationRepository::new(pool.clone())),
|
||||
Arc::new(SqliteAgentMetadataRepository::new(pool.clone())),
|
||||
Arc::new(SqliteAcpSessionRepository::new(pool.clone())),
|
||||
));
|
||||
|
||||
let settings = Arc::new(ChannelSettingsService::new(Arc::new(
|
||||
SqliteClientPreferenceRepository::new(pool.clone()),
|
||||
)));
|
||||
let message_svc = ChannelMessageService::new(
|
||||
conversation_svc,
|
||||
Arc::clone(&task_manager),
|
||||
settings,
|
||||
Arc::new(SqliteChannelRepository::new(pool)),
|
||||
"system_default_user".to_owned(),
|
||||
);
|
||||
|
||||
let session = AssistantSessionRow {
|
||||
id: "session-1".to_owned(),
|
||||
user_id: "channel-user-1".to_owned(),
|
||||
agent_type: "nomi".to_owned(),
|
||||
conversation_id: None,
|
||||
workspace: None,
|
||||
chat_id: Some("7088048016".to_owned()),
|
||||
channel_id: None,
|
||||
created_at: 1,
|
||||
last_activity: 1,
|
||||
};
|
||||
|
||||
for platform in [
|
||||
PluginType::Telegram,
|
||||
PluginType::Lark,
|
||||
PluginType::Dingtalk,
|
||||
PluginType::Weixin,
|
||||
] {
|
||||
let result = message_svc.send_to_agent(&session, "hello", platform).await.unwrap();
|
||||
|
||||
assert!(
|
||||
result.stream_rx.is_some(),
|
||||
"channel relay must have an agent stream receiver after cold start for {platform:?}"
|
||||
);
|
||||
assert!(task_manager.get_task(&result.conversation_id).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fix 3/4 support: last_user_text + is_conversation_busy ──────────────
|
||||
|
||||
struct TestStack {
|
||||
conversation_svc: Arc<ConversationService>,
|
||||
message_svc: ChannelMessageService,
|
||||
runtime: Arc<nomifun_conversation::runtime_state::ConversationRuntimeStateService>,
|
||||
channel_repo: Arc<SqliteChannelRepository>,
|
||||
}
|
||||
|
||||
fn build_stack(pool: nomifun_db::SqlitePool) -> TestStack {
|
||||
let task_manager: Arc<dyn IWorkerTaskManager> = Arc::new(RecordingTaskManager::new());
|
||||
let runtime = Arc::new(nomifun_conversation::runtime_state::ConversationRuntimeStateService::default());
|
||||
let conversation_svc = Arc::new(
|
||||
ConversationService::new(
|
||||
std::env::temp_dir(),
|
||||
Arc::new(TestBroadcaster::new()),
|
||||
Arc::new(NoopSkillResolver),
|
||||
Arc::clone(&task_manager),
|
||||
Arc::new(SqliteConversationRepository::new(pool.clone())),
|
||||
Arc::new(SqliteAgentMetadataRepository::new(pool.clone())),
|
||||
Arc::new(SqliteAcpSessionRepository::new(pool.clone())),
|
||||
)
|
||||
.with_runtime_state(Arc::clone(&runtime)),
|
||||
);
|
||||
|
||||
let settings = Arc::new(ChannelSettingsService::new(Arc::new(
|
||||
SqliteClientPreferenceRepository::new(pool.clone()),
|
||||
)));
|
||||
let channel_repo = Arc::new(SqliteChannelRepository::new(pool));
|
||||
let message_svc = ChannelMessageService::new(
|
||||
Arc::clone(&conversation_svc),
|
||||
Arc::clone(&task_manager),
|
||||
settings,
|
||||
channel_repo.clone(),
|
||||
"system_default_user".to_owned(),
|
||||
);
|
||||
|
||||
TestStack {
|
||||
conversation_svc,
|
||||
message_svc,
|
||||
runtime,
|
||||
channel_repo,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_session(conversation_id: Option<i64>) -> AssistantSessionRow {
|
||||
AssistantSessionRow {
|
||||
id: "session-1".to_owned(),
|
||||
user_id: "channel-user-1".to_owned(),
|
||||
agent_type: "nomi".to_owned(),
|
||||
conversation_id,
|
||||
workspace: None,
|
||||
chat_id: Some("7088048016".to_owned()),
|
||||
channel_id: None,
|
||||
created_at: 1,
|
||||
last_activity: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits for the background turn spawned by `send_message` to release its
|
||||
/// runtime claim so the next send doesn't hit the turn-conflict guard.
|
||||
async fn wait_until_idle(svc: &Arc<ConversationService>, conversation_id: &str) {
|
||||
use nomifun_api_types::ConversationRuntimeStateKind;
|
||||
for _ in 0..500 {
|
||||
let summary = svc.runtime_summary_for(conversation_id).await;
|
||||
if summary.state == ConversationRuntimeStateKind::Idle {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("conversation {conversation_id} never became idle");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn last_user_text_returns_latest_user_prompt() {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let stack = build_stack(db.pool().clone());
|
||||
|
||||
// First prompt creates the conversation; second one is the newest.
|
||||
let session = make_session(None);
|
||||
let first = stack
|
||||
.message_svc
|
||||
.send_to_agent(&session, "first prompt", PluginType::Telegram)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_until_idle(&stack.conversation_svc, &first.conversation_id).await;
|
||||
|
||||
// SendResult.conversation_id is a String (Option A); the session FK is i64.
|
||||
let bound_session = make_session(Some(first.conversation_id.parse::<i64>().unwrap()));
|
||||
stack
|
||||
.message_svc
|
||||
.send_to_agent(&bound_session, "second prompt", PluginType::Telegram)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_until_idle(&stack.conversation_svc, &first.conversation_id).await;
|
||||
|
||||
let text = stack.message_svc.last_user_text(&first.conversation_id).await.unwrap();
|
||||
assert_eq!(text.as_deref(), Some("second prompt"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn last_user_text_none_for_unknown_conversation() {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let stack = build_stack(db.pool().clone());
|
||||
|
||||
// Unknown conversation maps to a lookup error, not a silent None.
|
||||
let result = stack.message_svc.last_user_text("missing-conv").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_conversation_busy_reflects_turn_claim() {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let stack = build_stack(db.pool().clone());
|
||||
|
||||
let session = make_session(None);
|
||||
let sent = stack
|
||||
.message_svc
|
||||
.send_to_agent(&session, "hello", PluginType::Telegram)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_until_idle(&stack.conversation_svc, &sent.conversation_id).await;
|
||||
|
||||
assert!(!stack.message_svc.is_conversation_busy(&sent.conversation_id).await);
|
||||
|
||||
// Claiming the turn is exactly what send_message does while a prompt is
|
||||
// in flight → the channel guard must report busy.
|
||||
let _claim = stack.runtime.try_claim_turn(&sent.conversation_id).unwrap();
|
||||
assert!(stack.message_svc.is_conversation_busy(&sent.conversation_id).await);
|
||||
|
||||
drop(_claim);
|
||||
assert!(!stack.message_svc.is_conversation_busy(&sent.conversation_id).await);
|
||||
}
|
||||
|
||||
// ── Channel companion binding resolution + single-session routing ──────────────
|
||||
|
||||
/// Profile stub: maps each companion id to a pre-seeded single-session
|
||||
/// conversation id (what `CompanionManager.create` would return in production),
|
||||
/// records every `ensure_companion_session` call, and uses `companion_y` as the
|
||||
/// legacy/default per-platform fallback. An empty `sessions` map models a
|
||||
/// companion with no chat model configured (ensure returns `None`).
|
||||
struct StubProfile {
|
||||
sessions: std::collections::HashMap<String, i64>,
|
||||
calls: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl StubProfile {
|
||||
fn new(sessions: std::collections::HashMap<String, i64>) -> Self {
|
||||
Self {
|
||||
sessions,
|
||||
calls: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl nomifun_channel::message_service::MasterAgentProfile for StubProfile {
|
||||
async fn companion_model(&self, _companion_id: &str) -> Option<nomifun_common::ProviderWithModel> {
|
||||
None
|
||||
}
|
||||
async fn master_companion_id(&self, _platform: &str) -> Option<String> {
|
||||
Some("companion_y".to_owned())
|
||||
}
|
||||
async fn companion_exists(&self, _companion_id: &str) -> bool {
|
||||
true
|
||||
}
|
||||
async fn ensure_companion_session(&self, companion_id: &str) -> Option<i64> {
|
||||
self.calls.lock().unwrap().push(companion_id.to_owned());
|
||||
self.sessions.get(companion_id).copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed a companion's single-session conversation (the row `CompanionManager`
|
||||
/// would own), returning its i64 id.
|
||||
async fn seed_companion_session(svc: &Arc<ConversationService>, companion_id: &str) -> i64 {
|
||||
let req = nomifun_api_types::CreateConversationRequest {
|
||||
r#type: AgentType::Nomi,
|
||||
name: Some(format!("和 {companion_id} 聊天")),
|
||||
model: Some(nomifun_common::ProviderWithModel {
|
||||
provider_id: "p".to_owned(),
|
||||
model: "m".to_owned(),
|
||||
use_model: Some("m".to_owned()),
|
||||
}),
|
||||
source: None,
|
||||
channel_chat_id: None,
|
||||
extra: serde_json::json!({ "companionSession": true, "companionId": companion_id }),
|
||||
};
|
||||
svc.create("system_default_user", req).await.unwrap().id
|
||||
}
|
||||
|
||||
async fn bind_channel_to_companion(repo: &Arc<SqliteChannelRepository>, channel_id: &str, companion_id: &str) {
|
||||
use nomifun_db::IChannelRepository;
|
||||
let now = nomifun_common::now_ms();
|
||||
repo.upsert_plugin(&nomifun_db::models::ChannelPluginRow {
|
||||
id: channel_id.to_owned(),
|
||||
r#type: "telegram".to_owned(),
|
||||
name: "Telegram Bot".to_owned(),
|
||||
enabled: true,
|
||||
config: "enc".to_owned(),
|
||||
status: None,
|
||||
last_connected: None,
|
||||
companion_id: Some(companion_id.to_owned()),
|
||||
bot_key: Some("42".to_owned()),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The channel row's own companion binding wins over the profile fallback, and
|
||||
/// either way the turn is routed INTO that companion's single session (not a
|
||||
/// freshly-minted channel-master conversation).
|
||||
#[tokio::test]
|
||||
async fn channel_companion_turn_routes_into_companion_single_session() {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let stack = build_stack(db.pool().clone());
|
||||
|
||||
let conv_x = seed_companion_session(&stack.conversation_svc, "companion_x").await;
|
||||
let conv_y = seed_companion_session(&stack.conversation_svc, "companion_y").await;
|
||||
let sessions = std::collections::HashMap::from([
|
||||
("companion_x".to_owned(), conv_x),
|
||||
("companion_y".to_owned(), conv_y),
|
||||
]);
|
||||
let message_svc = stack.message_svc.with_master_profile(Arc::new(StubProfile::new(sessions)));
|
||||
|
||||
bind_channel_to_companion(&stack.channel_repo, "achn_test", "companion_x").await;
|
||||
|
||||
// Bound channel → channel companion (companion_x) wins; the turn runs on
|
||||
// companion_x's single session conversation, NOT a new channel conversation.
|
||||
let mut bound = make_session(None);
|
||||
bound.channel_id = Some("achn_test".to_owned());
|
||||
let sent = message_svc.send_to_agent(&bound, "hi", PluginType::Telegram).await.unwrap();
|
||||
assert_eq!(sent.conversation_id, conv_x.to_string());
|
||||
wait_until_idle(&stack.conversation_svc, &sent.conversation_id).await;
|
||||
|
||||
// No channel binding → profile fallback companion (companion_y) → its session.
|
||||
let mut unbound = make_session(None);
|
||||
unbound.id = "session-2".to_owned();
|
||||
unbound.chat_id = Some("other-chat".to_owned());
|
||||
let sent = message_svc.send_to_agent(&unbound, "hi", PluginType::Telegram).await.unwrap();
|
||||
assert_eq!(sent.conversation_id, conv_y.to_string());
|
||||
}
|
||||
|
||||
/// Two different IM chats bound to the SAME companion both land in that
|
||||
/// companion's ONE session — the unification guarantee. No standalone
|
||||
/// channel-master conversation is created for either.
|
||||
#[tokio::test]
|
||||
async fn companion_im_turns_share_one_session() {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let stack = build_stack(db.pool().clone());
|
||||
|
||||
let conv_x = seed_companion_session(&stack.conversation_svc, "companion_x").await;
|
||||
let sessions = std::collections::HashMap::from([("companion_x".to_owned(), conv_x)]);
|
||||
let message_svc = stack.message_svc.with_master_profile(Arc::new(StubProfile::new(sessions)));
|
||||
bind_channel_to_companion(&stack.channel_repo, "achn_test", "companion_x").await;
|
||||
|
||||
let mut chat_a = make_session(None);
|
||||
chat_a.channel_id = Some("achn_test".to_owned());
|
||||
chat_a.chat_id = Some("chat-A".to_owned());
|
||||
let a = message_svc.send_to_agent(&chat_a, "hi from A", PluginType::Telegram).await.unwrap();
|
||||
wait_until_idle(&stack.conversation_svc, &a.conversation_id).await;
|
||||
|
||||
let mut chat_b = make_session(None);
|
||||
chat_b.id = "session-b".to_owned();
|
||||
chat_b.channel_id = Some("achn_test".to_owned());
|
||||
chat_b.chat_id = Some("chat-B".to_owned());
|
||||
let b = message_svc.send_to_agent(&chat_b, "hi from B", PluginType::Telegram).await.unwrap();
|
||||
|
||||
assert_eq!(a.conversation_id, conv_x.to_string());
|
||||
assert_eq!(b.conversation_id, conv_x.to_string(), "both IM chats must share the companion's single session");
|
||||
}
|
||||
|
||||
/// A companion with no chat model (ensure returns None) refuses the turn with a
|
||||
/// distinct error instead of silently minting a leaking standalone conversation.
|
||||
#[tokio::test]
|
||||
async fn companion_without_model_refuses_turn() {
|
||||
use nomifun_channel::error::ChannelError;
|
||||
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let stack = build_stack(db.pool().clone());
|
||||
// Empty sessions map → ensure_companion_session returns None for every companion.
|
||||
let message_svc = stack
|
||||
.message_svc
|
||||
.with_master_profile(Arc::new(StubProfile::new(std::collections::HashMap::new())));
|
||||
bind_channel_to_companion(&stack.channel_repo, "achn_test", "companion_x").await;
|
||||
|
||||
let mut bound = make_session(None);
|
||||
bound.channel_id = Some("achn_test".to_owned());
|
||||
let err = message_svc
|
||||
.send_to_agent(&bound, "hi", PluginType::Telegram)
|
||||
.await
|
||||
.expect_err("a model-less companion must refuse the turn");
|
||||
assert!(matches!(err, ChannelError::CompanionNotReady(_)));
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nomifun_ai_agent::agent_task::{AgentInstance, IAgentTask};
|
||||
use nomifun_ai_agent::protocol::events::FinishEventData;
|
||||
use nomifun_ai_agent::types::{BuildTaskOptions, SendMessageData};
|
||||
use nomifun_ai_agent::{AgentSendError, AgentStreamEvent, IMockAgent, IWorkerTaskManager};
|
||||
use nomifun_api_types::{ConversationRuntimeStateKind, ListMessagesQuery, WebSocketMessage};
|
||||
use nomifun_channel::action::{ActionExecutor, MessageResult};
|
||||
use nomifun_channel::channel_settings::ChannelSettingsService;
|
||||
use nomifun_channel::message_service::ChannelMessageService;
|
||||
use nomifun_channel::orchestrator::ChannelOrchestrator;
|
||||
use nomifun_channel::pairing::PairingService;
|
||||
use nomifun_channel::session::SessionManager;
|
||||
use nomifun_channel::stream_relay::{ChannelSender, MessageRecorder};
|
||||
use nomifun_channel::types::{
|
||||
ActionCategory, ActionContext, ChannelIncoming, MessageContentType, PluginType, UnifiedAction,
|
||||
UnifiedIncomingMessage, UnifiedMessageContent, UnifiedOutgoingMessage, UnifiedUser,
|
||||
};
|
||||
use nomifun_common::{
|
||||
AgentKillReason, AgentType, AppError, ConversationStatus, MessagePosition, TimestampMs, now_ms,
|
||||
};
|
||||
use nomifun_conversation::ConversationService;
|
||||
use nomifun_conversation::runtime_state::ConversationRuntimeStateService;
|
||||
use nomifun_conversation::skill_resolver::{ResolvedAgentSkill, SkillResolver};
|
||||
use nomifun_db::models::{AssistantUserRow, ChannelPluginRow};
|
||||
use nomifun_db::{
|
||||
IChannelRepository, SqliteAcpSessionRepository, SqliteAgentMetadataRepository, SqliteChannelRepository,
|
||||
SqliteClientPreferenceRepository, SqliteConversationRepository,
|
||||
};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
|
||||
/// The channel row id every test message arrives through.
|
||||
const TEST_CHANNEL: &str = "tg-1";
|
||||
|
||||
/// Stamps a platform message with the test channel id, the way the
|
||||
/// manager's per-instance forwarder does in production.
|
||||
fn incoming(message: UnifiedIncomingMessage) -> ChannelIncoming {
|
||||
ChannelIncoming {
|
||||
channel_id: TEST_CHANNEL.into(),
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_text_message(user_id: &str, chat_id: &str, text: &str) -> UnifiedIncomingMessage {
|
||||
UnifiedIncomingMessage {
|
||||
id: "msg-1".into(),
|
||||
platform: PluginType::Telegram,
|
||||
chat_id: chat_id.into(),
|
||||
user: UnifiedUser {
|
||||
id: user_id.into(),
|
||||
username: None,
|
||||
display_name: "Test".into(),
|
||||
avatar_url: None,
|
||||
},
|
||||
content: UnifiedMessageContent {
|
||||
content_type: MessageContentType::Text,
|
||||
text: text.into(),
|
||||
attachments: None,
|
||||
},
|
||||
timestamp: 0,
|
||||
reply_to_message_id: None,
|
||||
action: None,
|
||||
raw: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_chat_action_message(user_id: &str, chat_id: &str, action_name: &str) -> UnifiedIncomingMessage {
|
||||
UnifiedIncomingMessage {
|
||||
id: "msg-action".into(),
|
||||
platform: PluginType::Telegram,
|
||||
chat_id: chat_id.into(),
|
||||
user: UnifiedUser {
|
||||
id: user_id.into(),
|
||||
username: None,
|
||||
display_name: "Test".into(),
|
||||
avatar_url: None,
|
||||
},
|
||||
content: UnifiedMessageContent {
|
||||
content_type: MessageContentType::Action,
|
||||
text: String::new(),
|
||||
attachments: None,
|
||||
},
|
||||
timestamp: 0,
|
||||
reply_to_message_id: None,
|
||||
action: Some(UnifiedAction {
|
||||
action: action_name.into(),
|
||||
category: ActionCategory::Chat,
|
||||
params: None,
|
||||
context: ActionContext {
|
||||
platform: PluginType::Telegram,
|
||||
user_id: user_id.into(),
|
||||
chat_id: chat_id.into(),
|
||||
message_id: None,
|
||||
session_id: None,
|
||||
},
|
||||
}),
|
||||
raw: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Unauthorized user should receive a pairing code response.
|
||||
#[tokio::test]
|
||||
async fn unauthorized_user_gets_pairing_response() {
|
||||
let db = nomifun_db::init_database_memory().await.unwrap();
|
||||
let pool = db.pool().clone();
|
||||
let repo: Arc<dyn nomifun_db::IChannelRepository> =
|
||||
Arc::new(nomifun_db::SqliteChannelRepository::new(pool.clone()));
|
||||
let bus = Arc::new(nomifun_realtime::BroadcastEventBus::new(64));
|
||||
|
||||
let pref_repo: Arc<dyn nomifun_db::IClientPreferenceRepository> =
|
||||
Arc::new(nomifun_db::SqliteClientPreferenceRepository::new(pool));
|
||||
let settings = Arc::new(ChannelSettingsService::new(pref_repo));
|
||||
|
||||
let pairing = Arc::new(PairingService::new(repo.clone(), bus));
|
||||
let session_mgr = Arc::new(SessionManager::new(repo.clone()));
|
||||
let executor = Arc::new(ActionExecutor::new(pairing, Arc::clone(&session_mgr), settings, "acp"));
|
||||
|
||||
// The pairing code created for the unauthorized user carries an FK
|
||||
// channel_id → assistant_plugins(id), so the bot channel must exist first.
|
||||
repo.upsert_plugin(&ChannelPluginRow {
|
||||
id: TEST_CHANNEL.into(),
|
||||
r#type: "telegram".into(),
|
||||
name: "Test Bot".into(),
|
||||
enabled: true,
|
||||
config: "{}".into(),
|
||||
status: None,
|
||||
last_connected: None,
|
||||
companion_id: None,
|
||||
bot_key: None,
|
||||
created_at: now_ms(),
|
||||
updated_at: now_ms(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let msg = make_text_message("unknown_user", "chat_1", "hello");
|
||||
let result = executor.handle_incoming_message(&msg, TEST_CHANNEL).await.unwrap();
|
||||
|
||||
match result {
|
||||
MessageResult::Action(response) => {
|
||||
let text = response.text.unwrap();
|
||||
assert!(text.len() > 5, "expected pairing response, got: {text}");
|
||||
}
|
||||
other => panic!("expected Action, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// Full-pipeline tests: busy guard, chat.continue, chat.regenerate
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
struct TestBroadcaster;
|
||||
|
||||
impl EventBroadcaster for TestBroadcaster {
|
||||
fn broadcast(&self, _event: WebSocketMessage<serde_json::Value>) {}
|
||||
}
|
||||
|
||||
struct NoopSkillResolver;
|
||||
|
||||
#[async_trait]
|
||||
impl SkillResolver for NoopSkillResolver {
|
||||
async fn auto_inject_names(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn resolve_skills(&self, _names: &[String]) -> Vec<ResolvedAgentSkill> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn link_workspace_skills(
|
||||
&self,
|
||||
_workspace: &std::path::Path,
|
||||
_rel_dirs: &[&str],
|
||||
_skills: &[ResolvedAgentSkill],
|
||||
) -> usize {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
struct ScriptedAgent {
|
||||
conversation_id: String,
|
||||
event_tx: broadcast::Sender<AgentStreamEvent>,
|
||||
}
|
||||
|
||||
impl ScriptedAgent {
|
||||
fn new(conversation_id: &str) -> Self {
|
||||
let (event_tx, _) = broadcast::channel(16);
|
||||
Self {
|
||||
conversation_id: conversation_id.to_owned(),
|
||||
event_tx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IAgentTask for ScriptedAgent {
|
||||
fn agent_type(&self) -> AgentType {
|
||||
AgentType::Nomi
|
||||
}
|
||||
|
||||
fn conversation_id(&self) -> &str {
|
||||
&self.conversation_id
|
||||
}
|
||||
|
||||
fn workspace(&self) -> &str {
|
||||
"/tmp/nomifun-channel-test"
|
||||
}
|
||||
|
||||
fn status(&self) -> Option<ConversationStatus> {
|
||||
Some(ConversationStatus::Finished)
|
||||
}
|
||||
|
||||
fn last_activity_at(&self) -> TimestampMs {
|
||||
0
|
||||
}
|
||||
|
||||
fn subscribe(&self) -> broadcast::Receiver<AgentStreamEvent> {
|
||||
self.event_tx.subscribe()
|
||||
}
|
||||
|
||||
async fn send_message(&self, _data: SendMessageData) -> Result<(), AgentSendError> {
|
||||
let _ = self.event_tx.send(AgentStreamEvent::Finish(FinishEventData::default()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cancel(&self) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn kill(&self, _reason: Option<AgentKillReason>) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl IMockAgent for ScriptedAgent {}
|
||||
|
||||
struct RecordingTaskManager {
|
||||
agents: Mutex<std::collections::HashMap<String, AgentInstance>>,
|
||||
}
|
||||
|
||||
impl RecordingTaskManager {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
agents: Mutex::new(std::collections::HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IWorkerTaskManager for RecordingTaskManager {
|
||||
fn get_task(&self, conversation_id: &str) -> Option<AgentInstance> {
|
||||
self.agents.lock().unwrap().get(conversation_id).cloned()
|
||||
}
|
||||
|
||||
async fn get_or_build_task(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
_options: BuildTaskOptions,
|
||||
) -> Result<AgentInstance, AppError> {
|
||||
let mut agents = self.agents.lock().unwrap();
|
||||
if let Some(agent) = agents.get(conversation_id) {
|
||||
return Ok(agent.clone());
|
||||
}
|
||||
|
||||
let agent = AgentInstance::Mock(Arc::new(ScriptedAgent::new(conversation_id)));
|
||||
agents.insert(conversation_id.to_owned(), agent.clone());
|
||||
Ok(agent)
|
||||
}
|
||||
|
||||
fn kill(&self, conversation_id: &str, _reason: Option<AgentKillReason>) -> Result<(), AppError> {
|
||||
self.agents.lock().unwrap().remove(conversation_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn kill_and_wait(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
reason: Option<AgentKillReason>,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
|
||||
let _ = self.kill(conversation_id, reason);
|
||||
Box::pin(std::future::ready(()))
|
||||
}
|
||||
|
||||
fn clear(&self) {
|
||||
self.agents.lock().unwrap().clear();
|
||||
}
|
||||
|
||||
fn active_count(&self) -> usize {
|
||||
self.agents.lock().unwrap().len()
|
||||
}
|
||||
|
||||
fn collect_idle(&self, _idle_threshold_ms: TimestampMs) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything needed to drive the orchestrator end-to-end with an in-memory
|
||||
/// DB, a scripted agent, and a recording channel sender.
|
||||
struct Harness {
|
||||
message_tx: mpsc::Sender<ChannelIncoming>,
|
||||
/// Held so the orchestrator's confirm branch stays open.
|
||||
_confirm_tx: mpsc::Sender<(String, String)>,
|
||||
recorder: Arc<MessageRecorder>,
|
||||
channel_repo: Arc<dyn IChannelRepository>,
|
||||
conversation_svc: Arc<ConversationService>,
|
||||
runtime: Arc<ConversationRuntimeStateService>,
|
||||
/// The shared pending-decision store the orchestrator's relay/interception
|
||||
/// uses, so tests can seed and inspect pending decisions.
|
||||
pending_decisions: Arc<nomifun_channel::pending_decision::PendingDecisionStore>,
|
||||
}
|
||||
|
||||
async fn build_harness() -> Harness {
|
||||
let db = nomifun_db::init_database_memory().await.unwrap();
|
||||
let pool = db.pool().clone();
|
||||
|
||||
let channel_repo: Arc<dyn IChannelRepository> = Arc::new(SqliteChannelRepository::new(pool.clone()));
|
||||
let bus = Arc::new(nomifun_realtime::BroadcastEventBus::new(64));
|
||||
let settings = Arc::new(ChannelSettingsService::new(Arc::new(SqliteClientPreferenceRepository::new(
|
||||
pool.clone(),
|
||||
))));
|
||||
let pairing = Arc::new(PairingService::new(channel_repo.clone(), bus));
|
||||
let session_mgr = Arc::new(SessionManager::new(channel_repo.clone()));
|
||||
let executor = Arc::new(ActionExecutor::new(
|
||||
pairing,
|
||||
Arc::clone(&session_mgr),
|
||||
Arc::clone(&settings),
|
||||
"nomi",
|
||||
));
|
||||
|
||||
// Every test message arrives through TEST_CHANNEL ("tg-1"). assistant_sessions
|
||||
// now has an FK channel_id → assistant_plugins(id), so the plugin row must
|
||||
// exist before any session is created. bot_key=None avoids the
|
||||
// UNIQUE(type, bot_key) index.
|
||||
channel_repo
|
||||
.upsert_plugin(&ChannelPluginRow {
|
||||
id: TEST_CHANNEL.into(),
|
||||
r#type: "telegram".into(),
|
||||
name: "Test Bot".into(),
|
||||
enabled: true,
|
||||
config: "{}".into(),
|
||||
status: None,
|
||||
last_connected: None,
|
||||
companion_id: None,
|
||||
bot_key: None,
|
||||
created_at: now_ms(),
|
||||
updated_at: now_ms(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Authorize the test user so messages reach the dispatch path.
|
||||
channel_repo
|
||||
.create_user(&AssistantUserRow {
|
||||
id: "user_tg_42".into(),
|
||||
platform_user_id: "tg_42".into(),
|
||||
platform_type: "telegram".into(),
|
||||
channel_id: Some(TEST_CHANNEL.into()),
|
||||
display_name: Some("Test".into()),
|
||||
authorized_at: now_ms(),
|
||||
last_active: None,
|
||||
session_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let task_manager: Arc<dyn IWorkerTaskManager> = Arc::new(RecordingTaskManager::new());
|
||||
let runtime = Arc::new(ConversationRuntimeStateService::default());
|
||||
let conversation_svc = Arc::new(
|
||||
ConversationService::new(
|
||||
std::env::temp_dir(),
|
||||
Arc::new(TestBroadcaster),
|
||||
Arc::new(NoopSkillResolver),
|
||||
Arc::clone(&task_manager),
|
||||
Arc::new(SqliteConversationRepository::new(pool.clone())),
|
||||
Arc::new(SqliteAgentMetadataRepository::new(pool.clone())),
|
||||
Arc::new(SqliteAcpSessionRepository::new(pool.clone())),
|
||||
)
|
||||
.with_runtime_state(Arc::clone(&runtime)),
|
||||
);
|
||||
let message_svc = Arc::new(ChannelMessageService::new(
|
||||
Arc::clone(&conversation_svc),
|
||||
Arc::clone(&task_manager),
|
||||
settings,
|
||||
channel_repo.clone(),
|
||||
"system_default_user".to_owned(),
|
||||
));
|
||||
let pending_decisions = message_svc.pending_decisions();
|
||||
|
||||
let recorder = Arc::new(MessageRecorder::new());
|
||||
let orchestrator = ChannelOrchestrator::new(
|
||||
executor,
|
||||
message_svc,
|
||||
session_mgr,
|
||||
Arc::clone(&recorder) as Arc<dyn ChannelSender>,
|
||||
);
|
||||
|
||||
let (message_tx, message_rx) = mpsc::channel(16);
|
||||
let (confirm_tx, confirm_rx) = mpsc::channel(16);
|
||||
tokio::spawn(orchestrator.run(message_rx, confirm_rx));
|
||||
|
||||
Harness {
|
||||
message_tx,
|
||||
_confirm_tx: confirm_tx,
|
||||
recorder,
|
||||
channel_repo,
|
||||
conversation_svc,
|
||||
runtime,
|
||||
pending_decisions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls the channel sessions until one has a bound conversation.
|
||||
async fn wait_for_bound_conversation(repo: &Arc<dyn IChannelRepository>) -> String {
|
||||
for _ in 0..500 {
|
||||
let sessions = repo.get_all_sessions().await.unwrap();
|
||||
// Session FK is now i64; this helper returns a String for the
|
||||
// string-keyed downstream calls (Option A).
|
||||
if let Some(cid) = sessions.iter().find_map(|s| s.conversation_id.map(|id| id.to_string())) {
|
||||
return cid;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("no session was bound to a conversation");
|
||||
}
|
||||
|
||||
/// Waits for the runtime turn claim of `conversation_id` to be released.
|
||||
async fn wait_until_idle(svc: &Arc<ConversationService>, conversation_id: &str) {
|
||||
for _ in 0..500 {
|
||||
let summary = svc.runtime_summary_for(conversation_id).await;
|
||||
if summary.state == ConversationRuntimeStateKind::Idle {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("conversation {conversation_id} never became idle");
|
||||
}
|
||||
|
||||
/// Drains the recorder until a send containing `needle` shows up.
|
||||
async fn wait_for_send_containing(recorder: &Arc<MessageRecorder>, needle: &str) -> UnifiedOutgoingMessage {
|
||||
let mut seen: Vec<UnifiedOutgoingMessage> = Vec::new();
|
||||
for _ in 0..500 {
|
||||
seen.extend(recorder.take_sends());
|
||||
if let Some(found) = seen
|
||||
.iter()
|
||||
.find(|m| m.text.as_deref().is_some_and(|t| t.contains(needle)))
|
||||
{
|
||||
return found.clone();
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("no send containing {needle:?}; saw: {seen:?}");
|
||||
}
|
||||
|
||||
/// Returns the visible user (`right`) message texts of a conversation.
|
||||
async fn user_messages(svc: &Arc<ConversationService>, conversation_id: &str) -> Vec<String> {
|
||||
let query = ListMessagesQuery {
|
||||
page: Some(1),
|
||||
page_size: Some(50),
|
||||
order: Some("ASC".into()),
|
||||
content_mode: None,
|
||||
cursor: None,
|
||||
};
|
||||
let result = svc
|
||||
.list_messages("system_default_user", conversation_id, query)
|
||||
.await
|
||||
.unwrap();
|
||||
result
|
||||
.items
|
||||
.iter()
|
||||
.filter(|m| m.position == Some(MessagePosition::Right))
|
||||
.filter_map(|m| m.content.get("content").and_then(|v| v.as_str()).map(str::to_owned))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Polls until the conversation has `expected` visible user messages.
|
||||
async fn wait_for_user_message_count(
|
||||
svc: &Arc<ConversationService>,
|
||||
conversation_id: &str,
|
||||
expected: usize,
|
||||
) -> Vec<String> {
|
||||
let mut last = Vec::new();
|
||||
for _ in 0..500 {
|
||||
last = user_messages(svc, conversation_id).await;
|
||||
if last.len() >= expected {
|
||||
return last;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("conversation never reached {expected} user messages; got {last:?}");
|
||||
}
|
||||
|
||||
/// Fix 4: a second message for a busy conversation must be answered with the
|
||||
/// "still processing" notice instead of racing a second prompt.
|
||||
#[tokio::test]
|
||||
async fn busy_conversation_replies_with_processing_notice() {
|
||||
let harness = build_harness().await;
|
||||
|
||||
harness
|
||||
.message_tx
|
||||
.send(incoming(make_text_message("tg_42", "chat_1", "hello world")))
|
||||
.await
|
||||
.unwrap();
|
||||
let cid = wait_for_bound_conversation(&harness.channel_repo).await;
|
||||
wait_until_idle(&harness.conversation_svc, &cid).await;
|
||||
|
||||
// Simulate an in-flight turn exactly the way send_message does.
|
||||
let _claim = harness.runtime.try_claim_turn(&cid).unwrap();
|
||||
|
||||
harness
|
||||
.message_tx
|
||||
.send(incoming(make_text_message("tg_42", "chat_1", "second message")))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
wait_for_send_containing(&harness.recorder, "still being processed").await;
|
||||
|
||||
// The guard fired before send_to_agent: no second user message was
|
||||
// persisted into the conversation.
|
||||
let messages = user_messages(&harness.conversation_svc, &cid).await;
|
||||
assert_eq!(messages, vec!["hello world".to_string()]);
|
||||
}
|
||||
|
||||
/// Fix 3: chat.continue dispatches the fixed continue prompt as a user turn
|
||||
/// through the regular streaming pipeline.
|
||||
#[tokio::test]
|
||||
async fn chat_continue_sends_continue_prompt_to_agent() {
|
||||
let harness = build_harness().await;
|
||||
|
||||
harness
|
||||
.message_tx
|
||||
.send(incoming(make_text_message("tg_42", "chat_1", "hello world")))
|
||||
.await
|
||||
.unwrap();
|
||||
let cid = wait_for_bound_conversation(&harness.channel_repo).await;
|
||||
wait_until_idle(&harness.conversation_svc, &cid).await;
|
||||
|
||||
harness
|
||||
.message_tx
|
||||
.send(incoming(make_chat_action_message("tg_42", "chat_1", "chat.continue")))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let messages = wait_for_user_message_count(&harness.conversation_svc, &cid, 2).await;
|
||||
assert_eq!(messages, vec![
|
||||
"hello world".to_string(),
|
||||
nomifun_channel::action::CONTINUE_PROMPT.to_string()
|
||||
]);
|
||||
}
|
||||
|
||||
/// Fix 3: chat.regenerate resends the conversation's last user message.
|
||||
#[tokio::test]
|
||||
async fn chat_regenerate_resends_last_user_message() {
|
||||
let harness = build_harness().await;
|
||||
|
||||
harness
|
||||
.message_tx
|
||||
.send(incoming(make_text_message("tg_42", "chat_1", "hello world")))
|
||||
.await
|
||||
.unwrap();
|
||||
let cid = wait_for_bound_conversation(&harness.channel_repo).await;
|
||||
wait_until_idle(&harness.conversation_svc, &cid).await;
|
||||
|
||||
harness
|
||||
.message_tx
|
||||
.send(incoming(make_chat_action_message("tg_42", "chat_1", "chat.regenerate")))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let messages = wait_for_user_message_count(&harness.conversation_svc, &cid, 2).await;
|
||||
assert_eq!(messages, vec!["hello world".to_string(), "hello world".to_string()]);
|
||||
}
|
||||
|
||||
/// Fix 3: chat.regenerate before any message exists must reply with a
|
||||
/// helpful notice instead of silently doing nothing.
|
||||
#[tokio::test]
|
||||
async fn chat_regenerate_without_history_replies_with_notice() {
|
||||
let harness = build_harness().await;
|
||||
|
||||
harness
|
||||
.message_tx
|
||||
.send(incoming(make_chat_action_message("tg_42", "chat_1", "chat.regenerate")))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
wait_for_send_containing(&harness.recorder, "no previous message to regenerate").await;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// Bug 1, Case A: relayed decision → numbered reply interception
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
use nomifun_channel::pending_decision::PendingDecision;
|
||||
use nomifun_channel::types::DecisionOption;
|
||||
|
||||
/// Seeds a two-option pending decision for `conversation_id`.
|
||||
fn seed_decision(harness: &Harness, conversation_id: &str) {
|
||||
harness.pending_decisions.put(PendingDecision {
|
||||
conversation_id: conversation_id.to_owned(),
|
||||
call_id: "call-dec".into(),
|
||||
prompt: "Proceed?".into(),
|
||||
options: vec![
|
||||
DecisionOption {
|
||||
option_id: "allow".into(),
|
||||
label: "Allow".into(),
|
||||
},
|
||||
DecisionOption {
|
||||
option_id: "reject".into(),
|
||||
label: "Reject".into(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/// A numeric reply to a pending decision resolves it (ack + cleared store)
|
||||
/// and is NOT dispatched as a new user prompt.
|
||||
#[tokio::test]
|
||||
async fn decision_numeric_reply_resolves_and_does_not_dispatch() {
|
||||
let harness = build_harness().await;
|
||||
|
||||
// Establish a bound conversation with exactly one user message.
|
||||
harness
|
||||
.message_tx
|
||||
.send(incoming(make_text_message("tg_42", "chat_1", "hello world")))
|
||||
.await
|
||||
.unwrap();
|
||||
let cid = wait_for_bound_conversation(&harness.channel_repo).await;
|
||||
wait_until_idle(&harness.conversation_svc, &cid).await;
|
||||
|
||||
// The conversation is now blocked on a decision.
|
||||
seed_decision(&harness, &cid);
|
||||
|
||||
harness
|
||||
.message_tx
|
||||
.send(incoming(make_text_message("tg_42", "chat_1", "2")))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Ack confirms the chosen label.
|
||||
wait_for_send_containing(&harness.recorder, "已选择:Reject").await;
|
||||
|
||||
// Pending entry cleared.
|
||||
for _ in 0..500 {
|
||||
if harness.pending_decisions.peek(&cid).is_none() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
assert!(harness.pending_decisions.peek(&cid).is_none(), "pending decision must be cleared");
|
||||
|
||||
// No second user message was dispatched — the reply was consumed.
|
||||
let messages = user_messages(&harness.conversation_svc, &cid).await;
|
||||
assert_eq!(messages, vec!["hello world".to_string()]);
|
||||
}
|
||||
|
||||
/// A non-numeric reply while a decision is pending re-shows the numbered list
|
||||
/// and is NOT dispatched.
|
||||
#[tokio::test]
|
||||
async fn decision_non_numeric_reply_reshows_list_and_does_not_dispatch() {
|
||||
let harness = build_harness().await;
|
||||
|
||||
harness
|
||||
.message_tx
|
||||
.send(incoming(make_text_message("tg_42", "chat_1", "hello world")))
|
||||
.await
|
||||
.unwrap();
|
||||
let cid = wait_for_bound_conversation(&harness.channel_repo).await;
|
||||
wait_until_idle(&harness.conversation_svc, &cid).await;
|
||||
|
||||
seed_decision(&harness, &cid);
|
||||
|
||||
harness
|
||||
.message_tx
|
||||
.send(incoming(make_text_message("tg_42", "chat_1", "what?")))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The numbered list is re-shown.
|
||||
let reshow = wait_for_send_containing(&harness.recorder, "需要你的决策").await;
|
||||
let text = reshow.text.unwrap();
|
||||
assert!(text.contains("1. Allow"), "re-shown list numbered: {text}");
|
||||
assert!(text.contains("2. Reject"), "re-shown list numbered: {text}");
|
||||
|
||||
// Pending entry survives (the user still has to answer).
|
||||
assert!(harness.pending_decisions.peek(&cid).is_some(), "pending decision must survive a bad reply");
|
||||
|
||||
// No new user message dispatched.
|
||||
let messages = user_messages(&harness.conversation_svc, &cid).await;
|
||||
assert_eq!(messages, vec!["hello world".to_string()]);
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
//! Black-box integration tests for `PairingService`.
|
||||
//!
|
||||
//! Uses real SQLite (in-memory) and mock EventBroadcaster.
|
||||
//! Covers test-plan items: PG-1..PG-3, AP-1..AP-6, RP-1..RP-4,
|
||||
//! PP-1..PP-3, EC-1..EC-2, DC-2..DC-3, WS-1, WS-3.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_common::{TimestampMs, now_ms};
|
||||
use nomifun_db::models::{ChannelPluginRow, PairingCodeRow};
|
||||
use nomifun_db::{IChannelRepository, SqliteChannelRepository, init_database_memory};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
|
||||
use nomifun_channel::constants::{PAIRING_CODE_LENGTH, PAIRING_CODE_TTL};
|
||||
use nomifun_channel::error::ChannelError;
|
||||
use nomifun_channel::pairing::PairingService;
|
||||
|
||||
/// Telegram bot channel id used by the integration tests. `assistant_pairing_codes`
|
||||
/// and `assistant_users` carry an FK channel_id → assistant_plugins(id), so the
|
||||
/// plugin rows must exist before any pairing is created.
|
||||
const CH_TG: &str = "tg-1";
|
||||
/// Lark bot channel id (second platform exercised by these tests).
|
||||
const CH_LARK: &str = "lark-1";
|
||||
/// A second lark bot channel id. Same platform as `CH_LARK`, different bot —
|
||||
/// used to prove pairing/auth are scoped per bot (channel), not per platform.
|
||||
/// `setup()` does not seed this; the multi-bot test seeds it itself so the
|
||||
/// channel_id FK is satisfied.
|
||||
const CH_LARK2: &str = "lark-2";
|
||||
|
||||
// ── Test infrastructure ─────────────────────────────────────────────
|
||||
|
||||
struct MockBroadcaster {
|
||||
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl MockBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn take_events(&self) -> Vec<WebSocketMessage<serde_json::Value>> {
|
||||
let mut guard = self.events.lock().unwrap();
|
||||
std::mem::take(&mut *guard)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBroadcaster for MockBroadcaster {
|
||||
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup() -> (PairingService, Arc<dyn IChannelRepository>, Arc<MockBroadcaster>) {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo: Arc<dyn IChannelRepository> = Arc::new(SqliteChannelRepository::new(db.pool().clone()));
|
||||
let bc = Arc::new(MockBroadcaster::new());
|
||||
let svc = PairingService::new(repo.clone(), bc.clone());
|
||||
|
||||
// Seed the bot channels the tests pair against. assistant_pairing_codes /
|
||||
// assistant_users have an FK channel_id → assistant_plugins(id), so these
|
||||
// rows must exist before request_pairing inserts a code.
|
||||
for (id, ty, name) in [(CH_TG, "telegram", "Telegram Bot"), (CH_LARK, "lark", "Lark Bot")] {
|
||||
repo.upsert_plugin(&ChannelPluginRow {
|
||||
id: id.into(),
|
||||
r#type: ty.into(),
|
||||
name: name.into(),
|
||||
enabled: true,
|
||||
config: "{}".into(),
|
||||
status: None,
|
||||
last_connected: None,
|
||||
companion_id: None,
|
||||
bot_key: None,
|
||||
created_at: now_ms(),
|
||||
updated_at: now_ms(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Keep db alive by leaking — test process exits anyway
|
||||
std::mem::forget(db);
|
||||
(svc, repo, bc)
|
||||
}
|
||||
|
||||
// ── PG-1: Generated code is 6 digits ───────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn pg1_code_is_six_digits() {
|
||||
let (svc, _repo, _bc) = setup().await;
|
||||
let code = svc.request_pairing("u1", "telegram", CH_TG, Some("Alice")).await.unwrap();
|
||||
assert_eq!(code.len(), PAIRING_CODE_LENGTH);
|
||||
assert!(code.chars().all(|c| c.is_ascii_digit()));
|
||||
}
|
||||
|
||||
// ── PG-2: Code expires after 10 minutes ────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn pg2_code_expires_after_ten_minutes() {
|
||||
let (svc, repo, _bc) = setup().await;
|
||||
let before = now_ms();
|
||||
let code = svc.request_pairing("u1", "telegram", CH_TG, None).await.unwrap();
|
||||
let after = now_ms();
|
||||
|
||||
let row = repo.get_pairing_by_code(&code).await.unwrap().unwrap();
|
||||
let ttl = PAIRING_CODE_TTL.as_millis() as TimestampMs;
|
||||
assert!(row.expires_at >= before + ttl);
|
||||
assert!(row.expires_at <= after + ttl);
|
||||
}
|
||||
|
||||
// ── PG-3: Same user re-request expires old code ────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn pg3_same_user_re_request_expires_old_code() {
|
||||
let (svc, repo, _bc) = setup().await;
|
||||
let code1 = svc.request_pairing("u1", "telegram", CH_TG, Some("Alice")).await.unwrap();
|
||||
let code2 = svc.request_pairing("u1", "telegram", CH_TG, Some("Alice")).await.unwrap();
|
||||
|
||||
assert_ne!(code1, code2);
|
||||
|
||||
let old = repo.get_pairing_by_code(&code1).await.unwrap().unwrap();
|
||||
let new = repo.get_pairing_by_code(&code2).await.unwrap().unwrap();
|
||||
assert_eq!(old.status, "expired");
|
||||
assert_eq!(new.status, "pending");
|
||||
}
|
||||
|
||||
// ── PP-1: No pending pairings returns empty ────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn pp1_no_pending_returns_empty() {
|
||||
let (svc, _repo, _bc) = setup().await;
|
||||
let pending = svc.get_pending_pairings().await.unwrap();
|
||||
assert!(pending.is_empty());
|
||||
}
|
||||
|
||||
// ── PP-2: Multiple pending pairings returned ───────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn pp2_multiple_pending_returned() {
|
||||
let (svc, _repo, _bc) = setup().await;
|
||||
svc.request_pairing("u1", "telegram", CH_TG, Some("Alice")).await.unwrap();
|
||||
svc.request_pairing("u2", "lark", CH_LARK, Some("Bob")).await.unwrap();
|
||||
|
||||
let pending = svc.get_pending_pairings().await.unwrap();
|
||||
assert_eq!(pending.len(), 2);
|
||||
}
|
||||
|
||||
// ── PP-3: Expired pairings not in pending list ─────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn pp3_expired_not_in_pending() {
|
||||
let (svc, repo, _bc) = setup().await;
|
||||
svc.request_pairing("u1", "telegram", CH_TG, None).await.unwrap();
|
||||
|
||||
// Insert already-expired code directly
|
||||
let expired_row = PairingCodeRow {
|
||||
code: "000001".into(),
|
||||
platform_user_id: "u2".into(),
|
||||
platform_type: "lark".into(),
|
||||
channel_id: None,
|
||||
display_name: None,
|
||||
requested_at: 1000,
|
||||
expires_at: 1001,
|
||||
status: "pending".into(),
|
||||
};
|
||||
repo.create_pairing(&expired_row).await.unwrap();
|
||||
|
||||
let pending = svc.get_pending_pairings().await.unwrap();
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].platform_user_id, "u1");
|
||||
}
|
||||
|
||||
// ── AP-1: Approve valid pairing ────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ap1_approve_valid_pairing() {
|
||||
let (svc, repo, _bc) = setup().await;
|
||||
let code = svc.request_pairing("tg_42", "telegram", CH_TG, Some("Alice")).await.unwrap();
|
||||
|
||||
svc.approve_pairing(&code).await.unwrap();
|
||||
|
||||
// Status updated
|
||||
let row = repo.get_pairing_by_code(&code).await.unwrap().unwrap();
|
||||
assert_eq!(row.status, "approved");
|
||||
}
|
||||
|
||||
// ── AP-2: Approved user appears in authorized list (DC-2) ──────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ap2_dc2_approved_user_in_authorized_list() {
|
||||
let (svc, repo, _bc) = setup().await;
|
||||
let code = svc.request_pairing("tg_42", "telegram", CH_TG, Some("Alice")).await.unwrap();
|
||||
svc.approve_pairing(&code).await.unwrap();
|
||||
|
||||
let users = repo.get_all_users().await.unwrap();
|
||||
assert_eq!(users.len(), 1);
|
||||
assert_eq!(users[0].platform_user_id, "tg_42");
|
||||
assert_eq!(users[0].platform_type, "telegram");
|
||||
assert_eq!(users[0].display_name.as_deref(), Some("Alice"));
|
||||
}
|
||||
|
||||
// ── AP-3: Approve nonexistent code ─────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ap3_approve_nonexistent_code() {
|
||||
let (svc, _repo, _bc) = setup().await;
|
||||
let err = svc.approve_pairing("000000").await.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::PairingNotFound(_)));
|
||||
}
|
||||
|
||||
// ── AP-4: Approve expired code ─────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ap4_approve_expired_code() {
|
||||
let (_svc, repo, bc) = setup().await;
|
||||
let svc = PairingService::new(repo.clone(), bc.clone());
|
||||
|
||||
let expired_row = PairingCodeRow {
|
||||
code: "999999".into(),
|
||||
platform_user_id: "u1".into(),
|
||||
platform_type: "telegram".into(),
|
||||
channel_id: None,
|
||||
display_name: None,
|
||||
requested_at: 1000,
|
||||
expires_at: 1001,
|
||||
status: "pending".into(),
|
||||
};
|
||||
repo.create_pairing(&expired_row).await.unwrap();
|
||||
|
||||
let err = svc.approve_pairing("999999").await.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::PairingExpired(_)));
|
||||
}
|
||||
|
||||
// ── AP-5: Double approve returns already processed ─────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ap5_double_approve_returns_already_processed() {
|
||||
let (svc, _repo, _bc) = setup().await;
|
||||
let code = svc.request_pairing("u1", "telegram", CH_TG, None).await.unwrap();
|
||||
svc.approve_pairing(&code).await.unwrap();
|
||||
|
||||
let err = svc.approve_pairing(&code).await.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::PairingAlreadyProcessed(_)));
|
||||
}
|
||||
|
||||
// ── AP-6: Missing code field (validated by DTO layer, but test via service)
|
||||
|
||||
#[tokio::test]
|
||||
async fn ap6_empty_code_returns_not_found() {
|
||||
let (svc, _repo, _bc) = setup().await;
|
||||
let err = svc.approve_pairing("").await.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::PairingNotFound(_)));
|
||||
}
|
||||
|
||||
// ── RP-1: Reject valid pairing ─────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn rp1_reject_valid_pairing() {
|
||||
let (svc, repo, _bc) = setup().await;
|
||||
let code = svc.request_pairing("u1", "telegram", CH_TG, None).await.unwrap();
|
||||
|
||||
svc.reject_pairing(&code).await.unwrap();
|
||||
|
||||
let row = repo.get_pairing_by_code(&code).await.unwrap().unwrap();
|
||||
assert_eq!(row.status, "rejected");
|
||||
}
|
||||
|
||||
// ── RP-2: Rejected code not in pending list ────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn rp2_rejected_not_in_pending() {
|
||||
let (svc, _repo, _bc) = setup().await;
|
||||
let code = svc.request_pairing("u1", "telegram", CH_TG, None).await.unwrap();
|
||||
svc.reject_pairing(&code).await.unwrap();
|
||||
|
||||
let pending = svc.get_pending_pairings().await.unwrap();
|
||||
assert!(pending.is_empty());
|
||||
}
|
||||
|
||||
// ── RP-3: Reject nonexistent code ──────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn rp3_reject_nonexistent_code() {
|
||||
let (svc, _repo, _bc) = setup().await;
|
||||
let err = svc.reject_pairing("000000").await.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::PairingNotFound(_)));
|
||||
}
|
||||
|
||||
// ── RP-4: Reject already approved code ─────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn rp4_reject_already_approved() {
|
||||
let (svc, _repo, _bc) = setup().await;
|
||||
let code = svc.request_pairing("u1", "telegram", CH_TG, None).await.unwrap();
|
||||
svc.approve_pairing(&code).await.unwrap();
|
||||
|
||||
let err = svc.reject_pairing(&code).await.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::PairingAlreadyProcessed(_)));
|
||||
}
|
||||
|
||||
// ── EC-1: Expired codes cleaned up ─────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ec1_expired_codes_cleaned_up() {
|
||||
let (_svc, repo, bc) = setup().await;
|
||||
let _svc = PairingService::new(repo.clone(), bc.clone());
|
||||
|
||||
let expired_row = PairingCodeRow {
|
||||
code: "111111".into(),
|
||||
platform_user_id: "u1".into(),
|
||||
platform_type: "telegram".into(),
|
||||
channel_id: None,
|
||||
display_name: None,
|
||||
requested_at: 1000,
|
||||
expires_at: 2000,
|
||||
status: "pending".into(),
|
||||
};
|
||||
repo.create_pairing(&expired_row).await.unwrap();
|
||||
|
||||
let count = repo.cleanup_expired_pairings(now_ms()).await.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
let row = repo.get_pairing_by_code("111111").await.unwrap().unwrap();
|
||||
assert_eq!(row.status, "expired");
|
||||
}
|
||||
|
||||
// ── EC-2: Non-expired codes unaffected by cleanup ──────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ec2_non_expired_unaffected() {
|
||||
let (svc, repo, _bc) = setup().await;
|
||||
let code = svc.request_pairing("u1", "telegram", CH_TG, None).await.unwrap();
|
||||
|
||||
let count = repo.cleanup_expired_pairings(now_ms()).await.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
|
||||
let row = repo.get_pairing_by_code(&code).await.unwrap().unwrap();
|
||||
assert_eq!(row.status, "pending");
|
||||
}
|
||||
|
||||
// ── DC-3: Same platform user unique constraint ─────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn dc3_same_platform_user_unique() {
|
||||
let (svc, _repo, _bc) = setup().await;
|
||||
|
||||
// Approve first pairing
|
||||
let code1 = svc.request_pairing("tg_42", "telegram", CH_TG, Some("Alice")).await.unwrap();
|
||||
svc.approve_pairing(&code1).await.unwrap();
|
||||
|
||||
// Second pairing for same user should fail on user creation (unique constraint)
|
||||
let code2 = svc.request_pairing("tg_42", "telegram", CH_TG, Some("Alice")).await.unwrap();
|
||||
let result = svc.approve_pairing(&code2).await;
|
||||
// DB should reject duplicate (platform_user_id, platform_type)
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ── WS-1: Pairing request broadcasts event ─────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ws1_pairing_request_broadcasts_event() {
|
||||
let (svc, _repo, bc) = setup().await;
|
||||
svc.request_pairing("tg_42", "telegram", CH_TG, Some("Alice")).await.unwrap();
|
||||
|
||||
let events = bc.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].name, "channel.pairing-requested");
|
||||
assert_eq!(events[0].data["platform_user_id"], "tg_42");
|
||||
assert_eq!(events[0].data["platform_type"], "telegram");
|
||||
assert_eq!(events[0].data["display_name"], "Alice");
|
||||
assert!(events[0].data["code"].is_string());
|
||||
assert!(events[0].data["expires_at"].is_number());
|
||||
}
|
||||
|
||||
// ── WS-3: Approve broadcasts user-authorized event ─────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ws3_approve_broadcasts_user_authorized() {
|
||||
let (svc, _repo, bc) = setup().await;
|
||||
let code = svc.request_pairing("tg_42", "telegram", CH_TG, Some("Alice")).await.unwrap();
|
||||
bc.take_events(); // clear request event
|
||||
|
||||
svc.approve_pairing(&code).await.unwrap();
|
||||
|
||||
let events = bc.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].name, "channel.user-authorized");
|
||||
assert_eq!(events[0].data["platform_user_id"], "tg_42");
|
||||
assert_eq!(events[0].data["platform_type"], "telegram");
|
||||
assert_eq!(events[0].data["display_name"], "Alice");
|
||||
assert!(events[0].data["id"].is_string());
|
||||
}
|
||||
|
||||
// ── is_user_authorized ─────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_user_authorized_false_before_approval() {
|
||||
let (svc, _repo, _bc) = setup().await;
|
||||
assert!(!svc.is_user_authorized("tg_42", "telegram", CH_TG).await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_user_authorized_true_after_approval() {
|
||||
let (svc, _repo, _bc) = setup().await;
|
||||
let code = svc.request_pairing("tg_42", "telegram", CH_TG, None).await.unwrap();
|
||||
svc.approve_pairing(&code).await.unwrap();
|
||||
|
||||
assert!(svc.is_user_authorized("tg_42", "telegram", CH_TG).await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_user_authorized_different_platform_false() {
|
||||
let (svc, _repo, _bc) = setup().await;
|
||||
let code = svc.request_pairing("tg_42", "telegram", CH_TG, None).await.unwrap();
|
||||
svc.approve_pairing(&code).await.unwrap();
|
||||
|
||||
// Same user ID but different platform
|
||||
assert!(!svc.is_user_authorized("tg_42", "lark", CH_LARK).await.unwrap());
|
||||
}
|
||||
|
||||
// ── Two lark bots pair independently (per-bot channel isolation) ────
|
||||
//
|
||||
// Regression for the per-bot pairing scoping on this branch: pairing and
|
||||
// authorization are keyed by channel_id (the specific bot a message arrived
|
||||
// through), not just by platform_type. Two lark bots must pair entirely
|
||||
// independently — approving one must not authorize the other, and the other's
|
||||
// pending code must be untouched.
|
||||
#[tokio::test]
|
||||
async fn two_lark_bots_pair_independently() {
|
||||
let (svc, repo, _bc) = setup().await;
|
||||
|
||||
// setup() seeds CH_LARK (lark-1). Seed a second lark bot so the
|
||||
// channel_id FK (assistant_pairing_codes/assistant_users → assistant_plugins)
|
||||
// is satisfied for bot 2. Same upsert_plugin pattern setup() uses.
|
||||
repo.upsert_plugin(&ChannelPluginRow {
|
||||
id: CH_LARK2.into(),
|
||||
r#type: "lark".into(),
|
||||
name: "Lark Bot 2".into(),
|
||||
enabled: true,
|
||||
config: "{}".into(),
|
||||
status: None,
|
||||
last_connected: None,
|
||||
companion_id: None,
|
||||
bot_key: None,
|
||||
created_at: now_ms(),
|
||||
updated_at: now_ms(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Two distinct lark users each initiate pairing, one per bot. Distinct
|
||||
// open_ids mirror reality (Lark open_id is per-app) and keep the test
|
||||
// focused on channel isolation rather than same-user expiry behavior.
|
||||
let code1 = svc.request_pairing("ou_a", "lark", CH_LARK, Some("A")).await.unwrap();
|
||||
let code2 = svc.request_pairing("ou_b", "lark", CH_LARK2, Some("B")).await.unwrap();
|
||||
assert_ne!(code1, code2);
|
||||
|
||||
// Both codes are pending simultaneously, each carrying its own channel_id.
|
||||
let pending = svc.get_pending_pairings().await.unwrap();
|
||||
assert_eq!(pending.len(), 2, "both bots' pairings should be pending");
|
||||
let p1 = pending
|
||||
.iter()
|
||||
.find(|p| p.code == code1)
|
||||
.expect("bot 1 code pending");
|
||||
let p2 = pending
|
||||
.iter()
|
||||
.find(|p| p.code == code2)
|
||||
.expect("bot 2 code pending");
|
||||
assert_eq!(p1.channel_id.as_deref(), Some(CH_LARK));
|
||||
assert_eq!(p1.platform_user_id, "ou_a");
|
||||
assert_eq!(p2.channel_id.as_deref(), Some(CH_LARK2));
|
||||
assert_eq!(p2.platform_user_id, "ou_b");
|
||||
|
||||
// Approve only bot 1's pairing.
|
||||
svc.approve_pairing(&code1).await.unwrap();
|
||||
|
||||
// Bot 1's user is now authorized — but only on bot 1's channel.
|
||||
assert!(
|
||||
svc.is_user_authorized("ou_a", "lark", CH_LARK).await.unwrap(),
|
||||
"approved user must be authorized on bot 1"
|
||||
);
|
||||
|
||||
// Bot 2 is entirely unaffected: its user is not authorized and its code
|
||||
// is still pending.
|
||||
assert!(
|
||||
!svc.is_user_authorized("ou_b", "lark", CH_LARK2).await.unwrap(),
|
||||
"bot 2's user must NOT be authorized by bot 1's approval"
|
||||
);
|
||||
let pending_after = svc.get_pending_pairings().await.unwrap();
|
||||
assert_eq!(pending_after.len(), 1, "bot 2's pairing should remain pending");
|
||||
assert_eq!(pending_after[0].code, code2);
|
||||
assert_eq!(pending_after[0].channel_id.as_deref(), Some(CH_LARK2));
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
//! Black-box integration tests for SessionManager and ActionExecutor.
|
||||
//!
|
||||
//! Uses real SQLite (in-memory) and mock EventBroadcaster.
|
||||
//! Covers test-plan items: GS-1, GS-2, PC-1..PC-3, RU-3.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_common::{generate_id, now_ms};
|
||||
use nomifun_db::models::{AssistantUserRow, ChannelPluginRow};
|
||||
use nomifun_db::{IChannelRepository, SqliteChannelRepository, init_database_memory};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
|
||||
use nomifun_channel::action::{ActionExecutor, MessageResult};
|
||||
use nomifun_channel::channel_settings::ChannelSettingsService;
|
||||
use nomifun_channel::pairing::PairingService;
|
||||
use nomifun_channel::session::SessionManager;
|
||||
use nomifun_channel::types::{
|
||||
ActionBehavior, ActionCategory, ActionContext, MessageContentType, PluginType, UnifiedAction,
|
||||
UnifiedIncomingMessage, UnifiedMessageContent, UnifiedUser,
|
||||
};
|
||||
|
||||
// ── Test infrastructure ─────────────────────────────────────────────
|
||||
|
||||
struct MockBroadcaster {
|
||||
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl MockBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBroadcaster for MockBroadcaster {
|
||||
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup() -> (
|
||||
SessionManager,
|
||||
ActionExecutor,
|
||||
PairingService,
|
||||
Arc<dyn IChannelRepository>,
|
||||
) {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo: Arc<dyn IChannelRepository> = Arc::new(SqliteChannelRepository::new(db.pool().clone()));
|
||||
let bc: Arc<dyn EventBroadcaster> = Arc::new(MockBroadcaster::new());
|
||||
|
||||
let session_mgr = SessionManager::new(repo.clone());
|
||||
let pairing = PairingService::new(repo.clone(), bc);
|
||||
let pairing_arc = Arc::new(PairingService::new(repo.clone(), Arc::new(MockBroadcaster::new())));
|
||||
let session_mgr_arc = Arc::new(SessionManager::new(repo.clone()));
|
||||
let pref_repo: Arc<dyn nomifun_db::IClientPreferenceRepository> =
|
||||
Arc::new(nomifun_db::SqliteClientPreferenceRepository::new(db.pool().clone()));
|
||||
let settings = Arc::new(ChannelSettingsService::new(pref_repo));
|
||||
let executor = ActionExecutor::new(pairing_arc, session_mgr_arc, settings, "gemini");
|
||||
|
||||
// Every test message arrives through the "tg-1" channel. assistant_sessions
|
||||
// now has an FK channel_id → assistant_plugins(id), so the plugin row must
|
||||
// exist before any session is created. bot_key=None avoids the
|
||||
// UNIQUE(type, bot_key) index.
|
||||
repo.upsert_plugin(&ChannelPluginRow {
|
||||
id: "tg-1".into(),
|
||||
r#type: "telegram".into(),
|
||||
name: "Test Bot".into(),
|
||||
enabled: true,
|
||||
config: "{}".into(),
|
||||
status: None,
|
||||
last_connected: None,
|
||||
companion_id: None,
|
||||
bot_key: None,
|
||||
created_at: now_ms(),
|
||||
updated_at: now_ms(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Keep db alive
|
||||
std::mem::forget(db);
|
||||
(session_mgr, executor, pairing, repo)
|
||||
}
|
||||
|
||||
/// Create an assistant_users record (required for FK on sessions).
|
||||
async fn create_user(repo: &Arc<dyn IChannelRepository>, platform_user_id: &str, platform_type: &str) -> String {
|
||||
let user_id = generate_id();
|
||||
let row = AssistantUserRow {
|
||||
id: user_id.clone(),
|
||||
platform_user_id: platform_user_id.to_owned(),
|
||||
platform_type: platform_type.to_owned(),
|
||||
channel_id: Some("tg-1".into()),
|
||||
display_name: Some("Test User".into()),
|
||||
authorized_at: now_ms(),
|
||||
last_active: None,
|
||||
session_id: None,
|
||||
};
|
||||
repo.create_user(&row).await.unwrap();
|
||||
user_id
|
||||
}
|
||||
|
||||
fn make_text_message(user_id: &str, chat_id: &str, text: &str) -> UnifiedIncomingMessage {
|
||||
UnifiedIncomingMessage {
|
||||
id: format!("msg_{}", now_ms()),
|
||||
platform: PluginType::Telegram,
|
||||
chat_id: chat_id.into(),
|
||||
user: UnifiedUser {
|
||||
id: user_id.into(),
|
||||
username: None,
|
||||
display_name: "Test User".into(),
|
||||
avatar_url: None,
|
||||
},
|
||||
content: UnifiedMessageContent {
|
||||
content_type: MessageContentType::Text,
|
||||
text: text.into(),
|
||||
attachments: None,
|
||||
},
|
||||
timestamp: now_ms(),
|
||||
reply_to_message_id: None,
|
||||
action: None,
|
||||
raw: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_action_message(
|
||||
user_id: &str,
|
||||
chat_id: &str,
|
||||
action_name: &str,
|
||||
category: ActionCategory,
|
||||
) -> UnifiedIncomingMessage {
|
||||
UnifiedIncomingMessage {
|
||||
id: format!("msg_{}", now_ms()),
|
||||
platform: PluginType::Telegram,
|
||||
chat_id: chat_id.into(),
|
||||
user: UnifiedUser {
|
||||
id: user_id.into(),
|
||||
username: None,
|
||||
display_name: "Test User".into(),
|
||||
avatar_url: None,
|
||||
},
|
||||
content: UnifiedMessageContent {
|
||||
content_type: MessageContentType::Action,
|
||||
text: String::new(),
|
||||
attachments: None,
|
||||
},
|
||||
timestamp: now_ms(),
|
||||
reply_to_message_id: None,
|
||||
action: Some(UnifiedAction {
|
||||
action: action_name.into(),
|
||||
category,
|
||||
params: None,
|
||||
context: ActionContext {
|
||||
platform: PluginType::Telegram,
|
||||
user_id: user_id.into(),
|
||||
chat_id: chat_id.into(),
|
||||
message_id: None,
|
||||
session_id: None,
|
||||
},
|
||||
}),
|
||||
raw: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: authorize a user via the pairing flow.
|
||||
async fn authorize_user(pairing: &PairingService, platform_user_id: &str, platform_type: &str) {
|
||||
let code = pairing
|
||||
.request_pairing(platform_user_id, platform_type, "tg-1", Some("Test"))
|
||||
.await
|
||||
.unwrap();
|
||||
pairing.approve_pairing(&code).await.unwrap();
|
||||
}
|
||||
|
||||
// ── GS-1: No active sessions returns empty ─────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn gs1_no_sessions_returns_empty() {
|
||||
let (session_mgr, _, _, _) = setup().await;
|
||||
let sessions = session_mgr.get_active_sessions().await.unwrap();
|
||||
assert!(sessions.is_empty());
|
||||
}
|
||||
|
||||
// ── GS-2: Multiple active sessions returned ────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn gs2_multiple_sessions_returned() {
|
||||
let (session_mgr, _, _, repo) = setup().await;
|
||||
|
||||
// Create users first (FK constraint)
|
||||
let uid1 = create_user(&repo, "p1", "telegram").await;
|
||||
let uid2 = create_user(&repo, "p2", "telegram").await;
|
||||
|
||||
session_mgr
|
||||
.get_or_create_session(&uid1, "c1", "tg-1", "gemini", None)
|
||||
.await
|
||||
.unwrap();
|
||||
session_mgr
|
||||
.get_or_create_session(&uid2, "c2", "tg-1", "acp", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let sessions = session_mgr.get_active_sessions().await.unwrap();
|
||||
assert_eq!(sessions.len(), 2);
|
||||
|
||||
for s in &sessions {
|
||||
assert!(!s.id.is_empty());
|
||||
assert!(!s.user_id.is_empty());
|
||||
assert!(!s.agent_type.is_empty());
|
||||
assert!(s.chat_id.is_some());
|
||||
assert!(s.created_at > 0);
|
||||
assert!(s.last_activity > 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── PC-1: Same user, different chatId → different sessions ─────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn pc1_same_user_different_chat() {
|
||||
let (session_mgr, _, _, repo) = setup().await;
|
||||
|
||||
let uid = create_user(&repo, "p1", "telegram").await;
|
||||
|
||||
let s1 = session_mgr
|
||||
.get_or_create_session(&uid, "chatA", "tg-1", "gemini", None)
|
||||
.await
|
||||
.unwrap();
|
||||
let s2 = session_mgr
|
||||
.get_or_create_session(&uid, "chatB", "tg-1", "gemini", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(s1.id, s2.id);
|
||||
assert_eq!(s1.user_id, uid);
|
||||
assert_eq!(s2.user_id, uid);
|
||||
assert_eq!(s1.chat_id.as_deref(), Some("chatA"));
|
||||
assert_eq!(s2.chat_id.as_deref(), Some("chatB"));
|
||||
}
|
||||
|
||||
// ── PC-2: Different users, same chatId → different sessions ────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn pc2_different_users_same_chat() {
|
||||
let (session_mgr, _, _, repo) = setup().await;
|
||||
|
||||
let uid1 = create_user(&repo, "p1", "telegram").await;
|
||||
let uid2 = create_user(&repo, "p2", "telegram").await;
|
||||
|
||||
let s1 = session_mgr
|
||||
.get_or_create_session(&uid1, "chatA", "tg-1", "gemini", None)
|
||||
.await
|
||||
.unwrap();
|
||||
let s2 = session_mgr
|
||||
.get_or_create_session(&uid2, "chatA", "tg-1", "gemini", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(s1.id, s2.id);
|
||||
}
|
||||
|
||||
// ── PC-3: Same user, same chatId → reuse session ──────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn pc3_same_user_same_chat_reuses() {
|
||||
let (session_mgr, _, _, repo) = setup().await;
|
||||
|
||||
let uid = create_user(&repo, "p1", "telegram").await;
|
||||
|
||||
let s1 = session_mgr
|
||||
.get_or_create_session(&uid, "chatA", "tg-1", "gemini", None)
|
||||
.await
|
||||
.unwrap();
|
||||
let s2 = session_mgr
|
||||
.get_or_create_session(&uid, "chatA", "tg-1", "gemini", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(s1.id, s2.id);
|
||||
}
|
||||
|
||||
// ── RU-3: Revoke user clears sessions ──────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ru3_revoke_clears_sessions() {
|
||||
let (session_mgr, _, _, repo) = setup().await;
|
||||
|
||||
let uid1 = create_user(&repo, "p1", "telegram").await;
|
||||
let uid2 = create_user(&repo, "p2", "telegram").await;
|
||||
|
||||
session_mgr
|
||||
.get_or_create_session(&uid1, "c1", "tg-1", "gemini", None)
|
||||
.await
|
||||
.unwrap();
|
||||
session_mgr
|
||||
.get_or_create_session(&uid1, "c2", "tg-1", "acp", None)
|
||||
.await
|
||||
.unwrap();
|
||||
session_mgr
|
||||
.get_or_create_session(&uid2, "c1", "tg-1", "gemini", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Cleanup user1 sessions
|
||||
session_mgr.cleanup_user_sessions(&uid1).await.unwrap();
|
||||
|
||||
let sessions = repo.get_all_sessions().await.unwrap();
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].user_id, uid2);
|
||||
}
|
||||
|
||||
// ── ActionExecutor: unauthorized user gets pairing ─────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn action_unauthorized_triggers_pairing() {
|
||||
let (_, executor, _, _) = setup().await;
|
||||
|
||||
let msg = make_text_message("new_user", "chat1", "Hello");
|
||||
let result = executor.handle_incoming_message(&msg, "tg-1").await.unwrap();
|
||||
|
||||
match result {
|
||||
MessageResult::Action(resp) => {
|
||||
assert_eq!(resp.behavior, ActionBehavior::Send);
|
||||
let text = resp.text.unwrap();
|
||||
assert!(text.contains("pairing code"));
|
||||
assert!(resp.buttons.is_some());
|
||||
}
|
||||
_ => panic!("Expected Action (pairing) for unauthorized user"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── ActionExecutor: authorized user dispatches to agent ────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn action_authorized_dispatches() {
|
||||
let (_, executor, pairing, _) = setup().await;
|
||||
|
||||
authorize_user(&pairing, "tg_42", "telegram").await;
|
||||
|
||||
let msg = make_text_message("tg_42", "chat1", "Hello AI");
|
||||
let result = executor.handle_incoming_message(&msg, "tg-1").await.unwrap();
|
||||
|
||||
match result {
|
||||
MessageResult::Dispatched { session_id, .. } => {
|
||||
assert!(!session_id.is_empty());
|
||||
}
|
||||
_ => panic!("Expected Dispatched for authorized user"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── ActionExecutor: help.show action ───────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn action_help_show() {
|
||||
let (_, executor, pairing, _) = setup().await;
|
||||
|
||||
authorize_user(&pairing, "tg_42", "telegram").await;
|
||||
|
||||
let msg = make_action_message("tg_42", "chat1", "help.show", ActionCategory::System);
|
||||
let result = executor.handle_incoming_message(&msg, "tg-1").await.unwrap();
|
||||
|
||||
match result {
|
||||
MessageResult::Action(resp) => {
|
||||
assert!(resp.text.is_some());
|
||||
assert!(resp.buttons.is_some());
|
||||
let buttons = resp.buttons.unwrap();
|
||||
assert!(buttons.len() >= 2);
|
||||
}
|
||||
_ => panic!("Expected Action result"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── ActionExecutor: session.new action ─────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn action_session_new() {
|
||||
let (_, executor, pairing, _) = setup().await;
|
||||
|
||||
authorize_user(&pairing, "tg_42", "telegram").await;
|
||||
|
||||
let msg = make_action_message("tg_42", "chat1", "session.new", ActionCategory::System);
|
||||
let result = executor.handle_incoming_message(&msg, "tg-1").await.unwrap();
|
||||
|
||||
match result {
|
||||
MessageResult::Action(resp) => {
|
||||
let text = resp.text.unwrap();
|
||||
assert!(text.contains("New session"));
|
||||
// With no client_preferences, defaults to "nomi"
|
||||
assert!(text.contains("nomi"));
|
||||
}
|
||||
_ => panic!("Expected Action result"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── ActionExecutor: session.new resets the session (H-2 fix) ─────
|
||||
|
||||
#[tokio::test]
|
||||
async fn action_session_new_resets_existing() {
|
||||
let (_, executor, pairing, repo) = setup().await;
|
||||
|
||||
authorize_user(&pairing, "tg_42", "telegram").await;
|
||||
|
||||
// Create a session by sending a text message
|
||||
let msg1 = make_text_message("tg_42", "chat1", "Hello");
|
||||
let r1 = executor.handle_incoming_message(&msg1, "tg-1").await.unwrap();
|
||||
let sid1 = match r1 {
|
||||
MessageResult::Dispatched { session_id, .. } => session_id,
|
||||
_ => panic!("Expected Dispatched"),
|
||||
};
|
||||
|
||||
// session.new should delete old and create fresh
|
||||
let new_msg = make_action_message("tg_42", "chat1", "session.new", ActionCategory::System);
|
||||
let r2 = executor.handle_incoming_message(&new_msg, "tg-1").await.unwrap();
|
||||
match r2 {
|
||||
MessageResult::Action(resp) => {
|
||||
let text = resp.text.unwrap();
|
||||
assert!(text.contains("New session"));
|
||||
}
|
||||
_ => panic!("Expected Action result"),
|
||||
}
|
||||
|
||||
// Send another text message — should get a different session ID
|
||||
let msg3 = make_text_message("tg_42", "chat1", "Hello again");
|
||||
let r3 = executor.handle_incoming_message(&msg3, "tg-1").await.unwrap();
|
||||
let sid3 = match r3 {
|
||||
MessageResult::Dispatched { session_id, .. } => session_id,
|
||||
_ => panic!("Expected Dispatched"),
|
||||
};
|
||||
|
||||
// The new session should have a different ID from the original
|
||||
assert_ne!(sid1, sid3);
|
||||
|
||||
// Only 1 session should exist for this user+chat in the DB
|
||||
let all = repo.get_all_sessions().await.unwrap();
|
||||
let user_sessions: Vec<_> = all.iter().filter(|s| s.chat_id.as_deref() == Some("chat1")).collect();
|
||||
assert_eq!(user_sessions.len(), 1);
|
||||
}
|
||||
|
||||
// ── ActionExecutor: agent.select persists agent_type (H-3 fix) ───
|
||||
|
||||
#[tokio::test]
|
||||
async fn action_agent_select_persists() {
|
||||
let (_, executor, pairing, repo) = setup().await;
|
||||
|
||||
authorize_user(&pairing, "tg_42", "telegram").await;
|
||||
|
||||
// Create a session (default agent is "gemini")
|
||||
let msg1 = make_text_message("tg_42", "chat1", "Hello");
|
||||
executor.handle_incoming_message(&msg1, "tg-1").await.unwrap();
|
||||
|
||||
// Switch agent to "acp"
|
||||
let select_msg = UnifiedIncomingMessage {
|
||||
id: format!("msg_{}", now_ms()),
|
||||
platform: PluginType::Telegram,
|
||||
chat_id: "chat1".into(),
|
||||
user: UnifiedUser {
|
||||
id: "tg_42".into(),
|
||||
username: None,
|
||||
display_name: "Test User".into(),
|
||||
avatar_url: None,
|
||||
},
|
||||
content: UnifiedMessageContent {
|
||||
content_type: MessageContentType::Action,
|
||||
text: String::new(),
|
||||
attachments: None,
|
||||
},
|
||||
timestamp: now_ms(),
|
||||
reply_to_message_id: None,
|
||||
action: Some(UnifiedAction {
|
||||
action: "agent.select".into(),
|
||||
category: ActionCategory::System,
|
||||
params: Some(std::collections::HashMap::from([("agentType".into(), "acp".into())])),
|
||||
context: ActionContext {
|
||||
platform: PluginType::Telegram,
|
||||
user_id: "tg_42".into(),
|
||||
chat_id: "chat1".into(),
|
||||
message_id: None,
|
||||
session_id: None,
|
||||
},
|
||||
}),
|
||||
raw: None,
|
||||
};
|
||||
let r = executor.handle_incoming_message(&select_msg, "tg-1").await.unwrap();
|
||||
match r {
|
||||
MessageResult::Action(resp) => {
|
||||
let text = resp.text.unwrap();
|
||||
assert!(text.contains("acp"));
|
||||
}
|
||||
_ => panic!("Expected Action result"),
|
||||
}
|
||||
|
||||
// Verify the session's agent_type in DB
|
||||
let all = repo.get_all_sessions().await.unwrap();
|
||||
let session = all
|
||||
.iter()
|
||||
.find(|s| s.chat_id.as_deref() == Some("chat1"))
|
||||
.expect("session should exist");
|
||||
assert_eq!(session.agent_type, "acp");
|
||||
}
|
||||
|
||||
// ── ActionExecutor: session isolation across messages ───────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn action_session_isolation() {
|
||||
let (_, executor, pairing, _) = setup().await;
|
||||
|
||||
authorize_user(&pairing, "tg_42", "telegram").await;
|
||||
|
||||
// Send messages in two different chats
|
||||
let msg1 = make_text_message("tg_42", "chatA", "Hello 1");
|
||||
let msg2 = make_text_message("tg_42", "chatB", "Hello 2");
|
||||
|
||||
let r1 = executor.handle_incoming_message(&msg1, "tg-1").await.unwrap();
|
||||
let r2 = executor.handle_incoming_message(&msg2, "tg-1").await.unwrap();
|
||||
|
||||
let sid1 = match r1 {
|
||||
MessageResult::Dispatched { session_id, .. } => session_id,
|
||||
_ => panic!("Expected Dispatched"),
|
||||
};
|
||||
let sid2 = match r2 {
|
||||
MessageResult::Dispatched { session_id, .. } => session_id,
|
||||
_ => panic!("Expected Dispatched"),
|
||||
};
|
||||
|
||||
// Different chats → different sessions
|
||||
assert_ne!(sid1, sid2);
|
||||
|
||||
// Same chat again → reuse
|
||||
let msg3 = make_text_message("tg_42", "chatA", "Hello 3");
|
||||
let r3 = executor.handle_incoming_message(&msg3, "tg-1").await.unwrap();
|
||||
let sid3 = match r3 {
|
||||
MessageResult::Dispatched { session_id, .. } => session_id,
|
||||
_ => panic!("Expected Dispatched"),
|
||||
};
|
||||
assert_eq!(sid1, sid3);
|
||||
}
|
||||
|
||||
// Note: bind_conversation FK-constrained persistence is tested in
|
||||
// nomifun-db sqlite_channel.rs::update_session_conversation_persists.
|
||||
// Unit tests for the SessionManager layer are in session.rs.
|
||||
@@ -0,0 +1,538 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_ai_agent::AgentStreamEvent;
|
||||
use nomifun_ai_agent::protocol::events::{
|
||||
AcpPermissionEventData, AcpPermissionOptionData, AcpPermissionOptionKind, AcpPermissionRequestData,
|
||||
AcpPermissionToolCall, ErrorEventData, FinishEventData, TextEventData, ToolCallEventData, ToolCallStatus,
|
||||
};
|
||||
use nomifun_channel::pending_decision::PendingDecisionStore;
|
||||
use nomifun_channel::stream_relay::{ChannelSender, ChannelStreamRelay, MessageRecorder, RelayConfig};
|
||||
use nomifun_channel::types::{ParseMode, PluginType};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
/// Builds a relay with a fresh (unshared) pending-decision store. Tests that
|
||||
/// need to inspect the store pass their own via [`relay_with_store`].
|
||||
fn relay(config: RelayConfig, sender: Arc<dyn ChannelSender>) -> ChannelStreamRelay {
|
||||
ChannelStreamRelay::new(config, sender, PendingDecisionStore::new())
|
||||
}
|
||||
|
||||
/// Builds a relay sharing the caller's pending-decision store.
|
||||
fn relay_with_store(
|
||||
config: RelayConfig,
|
||||
sender: Arc<dyn ChannelSender>,
|
||||
store: Arc<PendingDecisionStore>,
|
||||
) -> ChannelStreamRelay {
|
||||
ChannelStreamRelay::new(config, sender, store)
|
||||
}
|
||||
|
||||
// ── RelayConfig construction ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn relay_config_fields() {
|
||||
let config = RelayConfig {
|
||||
platform: PluginType::Telegram,
|
||||
plugin_id: "telegram".into(),
|
||||
chat_id: "123".into(),
|
||||
throttle_ms: 500,
|
||||
conversation_id: "conv-1".into(),
|
||||
};
|
||||
assert_eq!(config.throttle_ms, 500);
|
||||
assert_eq!(config.plugin_id, "telegram");
|
||||
assert_eq!(config.conversation_id, "conv-1");
|
||||
}
|
||||
|
||||
// ── Full relay run with mock ChannelSender ───────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_sends_thinking_then_final_message() {
|
||||
let (event_tx, _) = broadcast::channel::<AgentStreamEvent>(64);
|
||||
let recorder = Arc::new(MessageRecorder::new());
|
||||
|
||||
let config = RelayConfig {
|
||||
platform: PluginType::Telegram,
|
||||
plugin_id: "telegram".into(),
|
||||
chat_id: "chat_1".into(),
|
||||
throttle_ms: 10,
|
||||
conversation_id: "conv-test".into(),
|
||||
};
|
||||
let relay = relay(config, recorder.clone());
|
||||
|
||||
let rx = event_tx.subscribe();
|
||||
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Text(TextEventData {
|
||||
content: "Hello".into(),
|
||||
}))
|
||||
.unwrap();
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Text(TextEventData {
|
||||
content: " World".into(),
|
||||
}))
|
||||
.unwrap();
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Finish(FinishEventData { session_id: None, stop_reason: None }))
|
||||
.unwrap();
|
||||
|
||||
relay.run(rx).await;
|
||||
|
||||
let sends = recorder.take_sends();
|
||||
assert!(!sends.is_empty());
|
||||
assert!(sends[0].text.as_deref().unwrap().contains("Thinking"));
|
||||
|
||||
let edits = recorder.take_edits();
|
||||
let last = edits.last().unwrap();
|
||||
assert!(last.text.as_deref().unwrap().contains("Hello World"));
|
||||
assert!(last.buttons.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_handles_error_event() {
|
||||
let (event_tx, _) = broadcast::channel::<AgentStreamEvent>(64);
|
||||
let recorder = Arc::new(MessageRecorder::new());
|
||||
|
||||
let config = RelayConfig {
|
||||
platform: PluginType::Telegram,
|
||||
plugin_id: "telegram".into(),
|
||||
chat_id: "chat_1".into(),
|
||||
throttle_ms: 10,
|
||||
conversation_id: "conv-test".into(),
|
||||
};
|
||||
let relay = relay(config, recorder.clone());
|
||||
let rx = event_tx.subscribe();
|
||||
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Error(ErrorEventData::legacy("timeout", None)))
|
||||
.unwrap();
|
||||
|
||||
relay.run(rx).await;
|
||||
|
||||
let edits = recorder.take_edits();
|
||||
let last = edits.last().unwrap();
|
||||
assert!(last.text.as_deref().unwrap().contains("timeout"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn weixin_flushes_pending_text_before_tool_call() {
|
||||
// Port of Nomi TS fix `406a62665` to the backend relay layer. On
|
||||
// WeChat, in-place editing is not supported, so a tool-status update
|
||||
// would otherwise overwrite any assistant text the user hasn't yet
|
||||
// seen. The relay should flush buffered text as an independent
|
||||
// send_message before rendering the tool-call indicator, matching the
|
||||
// TS WeixinPlugin.sendTextNow draft-flush behaviour.
|
||||
let (event_tx, _) = broadcast::channel::<AgentStreamEvent>(64);
|
||||
let recorder = Arc::new(MessageRecorder::new());
|
||||
|
||||
let config = RelayConfig {
|
||||
platform: PluginType::Weixin,
|
||||
plugin_id: "weixin".into(),
|
||||
chat_id: "chat_1".into(),
|
||||
throttle_ms: 10_000, // large throttle so the mid-stream edit doesn't fire
|
||||
conversation_id: "conv-test".into(),
|
||||
};
|
||||
let relay = relay(config, recorder.clone());
|
||||
let rx = event_tx.subscribe();
|
||||
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Text(TextEventData {
|
||||
content: "Here is the plan:".into(),
|
||||
}))
|
||||
.unwrap();
|
||||
event_tx
|
||||
.send(AgentStreamEvent::ToolCall(ToolCallEventData {
|
||||
call_id: "call-1".into(),
|
||||
name: "read_file".into(),
|
||||
args: serde_json::Value::Null,
|
||||
status: ToolCallStatus::Running,
|
||||
description: None,
|
||||
input: None,
|
||||
output: None,
|
||||
}))
|
||||
.unwrap();
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Finish(FinishEventData { session_id: None, stop_reason: None }))
|
||||
.unwrap();
|
||||
|
||||
relay.run(rx).await;
|
||||
|
||||
let sends = recorder.take_sends();
|
||||
// WeChat relay does NOT send a "Thinking..." placeholder. The first
|
||||
// send_message should be the flushed assistant text triggered by the
|
||||
// ToolCall event.
|
||||
assert!(!sends.is_empty(), "expected flush send_message, got {:?}", sends);
|
||||
let flushed = &sends[0];
|
||||
assert!(
|
||||
flushed.text.as_deref().unwrap().contains("Here is the plan"),
|
||||
"expected flushed text, got {:?}",
|
||||
flushed.text
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn telegram_does_not_flush_text_before_tool_call() {
|
||||
// Non-WeChat platforms support edit_message, so the TS flush rule does
|
||||
// not apply — the relay should continue to edit the placeholder in
|
||||
// place without issuing a new send_message for the buffered text.
|
||||
let (event_tx, _) = broadcast::channel::<AgentStreamEvent>(64);
|
||||
let recorder = Arc::new(MessageRecorder::new());
|
||||
|
||||
let config = RelayConfig {
|
||||
platform: PluginType::Telegram,
|
||||
plugin_id: "telegram".into(),
|
||||
chat_id: "chat_1".into(),
|
||||
throttle_ms: 10_000,
|
||||
conversation_id: "conv-test".into(),
|
||||
};
|
||||
let relay = relay(config, recorder.clone());
|
||||
let rx = event_tx.subscribe();
|
||||
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Text(TextEventData {
|
||||
content: "Here is the plan:".into(),
|
||||
}))
|
||||
.unwrap();
|
||||
event_tx
|
||||
.send(AgentStreamEvent::ToolCall(ToolCallEventData {
|
||||
call_id: "call-1".into(),
|
||||
name: "read_file".into(),
|
||||
args: serde_json::Value::Null,
|
||||
status: ToolCallStatus::Running,
|
||||
description: None,
|
||||
input: None,
|
||||
output: None,
|
||||
}))
|
||||
.unwrap();
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Finish(FinishEventData { session_id: None, stop_reason: None }))
|
||||
.unwrap();
|
||||
|
||||
relay.run(rx).await;
|
||||
|
||||
let sends = recorder.take_sends();
|
||||
// Only the "Thinking..." placeholder is sent — no flush on non-WeChat.
|
||||
assert_eq!(sends.len(), 1, "unexpected extra sends: {:?}", sends);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn weixin_skips_flush_when_buffer_is_empty() {
|
||||
// Tool call before any assistant text should not trigger a blank flush.
|
||||
let (event_tx, _) = broadcast::channel::<AgentStreamEvent>(64);
|
||||
let recorder = Arc::new(MessageRecorder::new());
|
||||
|
||||
let config = RelayConfig {
|
||||
platform: PluginType::Weixin,
|
||||
plugin_id: "weixin".into(),
|
||||
chat_id: "chat_1".into(),
|
||||
throttle_ms: 10_000,
|
||||
conversation_id: "conv-test".into(),
|
||||
};
|
||||
let relay = relay(config, recorder.clone());
|
||||
let rx = event_tx.subscribe();
|
||||
|
||||
event_tx
|
||||
.send(AgentStreamEvent::ToolCall(ToolCallEventData {
|
||||
call_id: "call-1".into(),
|
||||
name: "read_file".into(),
|
||||
args: serde_json::Value::Null,
|
||||
status: ToolCallStatus::Running,
|
||||
description: None,
|
||||
input: None,
|
||||
output: None,
|
||||
}))
|
||||
.unwrap();
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Finish(FinishEventData { session_id: None, stop_reason: None }))
|
||||
.unwrap();
|
||||
|
||||
relay.run(rx).await;
|
||||
|
||||
let sends = recorder.take_sends();
|
||||
// WeChat relay does NOT send Thinking placeholder, and with no buffered
|
||||
// text there should be zero sends (no flush needed).
|
||||
assert_eq!(sends.len(), 0, "no sends expected for empty buffer: {:?}", sends);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_handles_channel_closed() {
|
||||
let (event_tx, _) = broadcast::channel::<AgentStreamEvent>(64);
|
||||
let recorder = Arc::new(MessageRecorder::new());
|
||||
|
||||
let config = RelayConfig {
|
||||
platform: PluginType::Telegram,
|
||||
plugin_id: "telegram".into(),
|
||||
chat_id: "chat_1".into(),
|
||||
throttle_ms: 10,
|
||||
conversation_id: "conv-test".into(),
|
||||
};
|
||||
let relay = relay(config, recorder.clone());
|
||||
let rx = event_tx.subscribe();
|
||||
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Text(TextEventData {
|
||||
content: "partial".into(),
|
||||
}))
|
||||
.unwrap();
|
||||
drop(event_tx);
|
||||
|
||||
relay.run(rx).await;
|
||||
|
||||
let edits = recorder.take_edits();
|
||||
assert!(!edits.is_empty());
|
||||
assert!(edits.last().unwrap().text.as_deref().unwrap().contains("partial"));
|
||||
}
|
||||
|
||||
// ── Telegram parse mode (HTML formatter output must be declared) ─────
|
||||
|
||||
/// The formatter emits HTML for Telegram; streaming edits and the final
|
||||
/// message must carry `parse_mode: HTML` or the tags render literally.
|
||||
#[tokio::test]
|
||||
async fn telegram_streaming_and_final_messages_use_html_parse_mode() {
|
||||
let (event_tx, _) = broadcast::channel::<AgentStreamEvent>(64);
|
||||
let recorder = Arc::new(MessageRecorder::new());
|
||||
|
||||
let config = RelayConfig {
|
||||
platform: PluginType::Telegram,
|
||||
plugin_id: "telegram".into(),
|
||||
chat_id: "chat_1".into(),
|
||||
throttle_ms: 0, // edit on every chunk so the streaming path is exercised
|
||||
conversation_id: "conv-test".into(),
|
||||
};
|
||||
let relay = relay(config, recorder.clone());
|
||||
let rx = event_tx.subscribe();
|
||||
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Text(TextEventData {
|
||||
content: "**bold** & <raw>".into(),
|
||||
}))
|
||||
.unwrap();
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Finish(FinishEventData {
|
||||
session_id: None,
|
||||
stop_reason: None,
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
relay.run(rx).await;
|
||||
|
||||
// The "Thinking..." placeholder is plain text — no parse mode.
|
||||
let sends = recorder.take_sends();
|
||||
assert_eq!(sends.len(), 1);
|
||||
assert_eq!(sends[0].parse_mode, None);
|
||||
|
||||
let edits = recorder.take_edits();
|
||||
assert!(!edits.is_empty());
|
||||
for edit in &edits {
|
||||
assert_eq!(
|
||||
edit.parse_mode,
|
||||
Some(ParseMode::HTML),
|
||||
"telegram edit must declare HTML parse mode: {edit:?}"
|
||||
);
|
||||
}
|
||||
// Formatter output: markdown converted to tags, source &/< escaped.
|
||||
let final_text = edits.last().unwrap().text.as_deref().unwrap();
|
||||
assert!(final_text.contains("<b>bold</b>"), "got: {final_text}");
|
||||
assert!(final_text.contains("&"), "got: {final_text}");
|
||||
assert!(final_text.contains("<raw>"), "got: {final_text}");
|
||||
}
|
||||
|
||||
/// Tool-status edits show raw (unescaped) agent output, so they must stay
|
||||
/// plain text even on Telegram.
|
||||
#[tokio::test]
|
||||
async fn telegram_tool_call_edit_stays_plain_text() {
|
||||
let (event_tx, _) = broadcast::channel::<AgentStreamEvent>(64);
|
||||
let recorder = Arc::new(MessageRecorder::new());
|
||||
|
||||
let config = RelayConfig {
|
||||
platform: PluginType::Telegram,
|
||||
plugin_id: "telegram".into(),
|
||||
chat_id: "chat_1".into(),
|
||||
throttle_ms: 10_000,
|
||||
conversation_id: "conv-test".into(),
|
||||
};
|
||||
let relay = relay(config, recorder.clone());
|
||||
let rx = event_tx.subscribe();
|
||||
|
||||
event_tx
|
||||
.send(AgentStreamEvent::ToolCall(ToolCallEventData {
|
||||
call_id: "call-1".into(),
|
||||
name: "read_file".into(),
|
||||
args: serde_json::Value::Null,
|
||||
status: ToolCallStatus::Running,
|
||||
description: None,
|
||||
input: None,
|
||||
output: None,
|
||||
}))
|
||||
.unwrap();
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Finish(FinishEventData {
|
||||
session_id: None,
|
||||
stop_reason: None,
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
relay.run(rx).await;
|
||||
|
||||
let edits = recorder.take_edits();
|
||||
let tool_edit = edits
|
||||
.iter()
|
||||
.find(|e| e.text.as_deref().is_some_and(|t| t.contains("read_file")))
|
||||
.expect("tool-status edit");
|
||||
assert_eq!(tool_edit.parse_mode, None);
|
||||
}
|
||||
|
||||
/// Non-Telegram platforms receive markdown/plain text — parse mode stays
|
||||
/// unset for them.
|
||||
#[tokio::test]
|
||||
async fn lark_messages_have_no_parse_mode() {
|
||||
let (event_tx, _) = broadcast::channel::<AgentStreamEvent>(64);
|
||||
let recorder = Arc::new(MessageRecorder::new());
|
||||
|
||||
let config = RelayConfig {
|
||||
platform: PluginType::Lark,
|
||||
plugin_id: "lark".into(),
|
||||
chat_id: "chat_1".into(),
|
||||
throttle_ms: 0,
|
||||
conversation_id: "conv-test".into(),
|
||||
};
|
||||
let relay = relay(config, recorder.clone());
|
||||
let rx = event_tx.subscribe();
|
||||
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Text(TextEventData {
|
||||
content: "**bold** text".into(),
|
||||
}))
|
||||
.unwrap();
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Finish(FinishEventData {
|
||||
session_id: None,
|
||||
stop_reason: None,
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
relay.run(rx).await;
|
||||
|
||||
let edits = recorder.take_edits();
|
||||
assert!(!edits.is_empty());
|
||||
for edit in &edits {
|
||||
assert_eq!(edit.parse_mode, None, "lark edits must not set parse mode: {edit:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Decision relay (Bug 1, Case A) ───────────────────────────────────
|
||||
|
||||
/// Builds an ACP permission-request event with two options.
|
||||
fn acp_decision_event(call_id: &str, title: &str) -> AgentStreamEvent {
|
||||
AgentStreamEvent::AcpPermission(AcpPermissionEventData::Request(AcpPermissionRequestData {
|
||||
session_id: "s1".into(),
|
||||
tool_call: AcpPermissionToolCall {
|
||||
tool_call_id: call_id.into(),
|
||||
status: None,
|
||||
title: Some(title.into()),
|
||||
kind: None,
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
content: None,
|
||||
locations: None,
|
||||
meta: None,
|
||||
},
|
||||
options: vec![
|
||||
AcpPermissionOptionData {
|
||||
option_id: "allow".into(),
|
||||
name: "Allow once".into(),
|
||||
kind: AcpPermissionOptionKind::AllowOnce,
|
||||
meta: None,
|
||||
},
|
||||
AcpPermissionOptionData {
|
||||
option_id: "reject".into(),
|
||||
name: "Reject".into(),
|
||||
kind: AcpPermissionOptionKind::RejectOnce,
|
||||
meta: None,
|
||||
},
|
||||
],
|
||||
meta: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// A relayed decision is recorded in the shared store and forwarded as a
|
||||
/// numbered text message (a new send, not an edit of the thinking card).
|
||||
#[tokio::test]
|
||||
async fn relay_forwards_decision_and_records_pending() {
|
||||
let (event_tx, _) = broadcast::channel::<AgentStreamEvent>(64);
|
||||
let recorder = Arc::new(MessageRecorder::new());
|
||||
let store = PendingDecisionStore::new();
|
||||
|
||||
let config = RelayConfig {
|
||||
platform: PluginType::Telegram,
|
||||
plugin_id: "telegram".into(),
|
||||
chat_id: "chat_1".into(),
|
||||
throttle_ms: 10_000,
|
||||
conversation_id: "conv-dec".into(),
|
||||
};
|
||||
let relay = relay_with_store(config, recorder.clone(), Arc::clone(&store));
|
||||
let rx = event_tx.subscribe();
|
||||
|
||||
event_tx.send(acp_decision_event("call-42", "Run rm -rf?")).unwrap();
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Finish(FinishEventData {
|
||||
session_id: None,
|
||||
stop_reason: None,
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
relay.run(rx).await;
|
||||
|
||||
// A numbered decision message was sent as a new message.
|
||||
let sends = recorder.take_sends();
|
||||
let decision = sends
|
||||
.iter()
|
||||
.find(|m| m.text.as_deref().is_some_and(|t| t.contains("需要你的决策")))
|
||||
.expect("a numbered decision message must be sent");
|
||||
let text = decision.text.as_deref().unwrap();
|
||||
assert!(text.contains("Run rm -rf?"), "prompt present: {text}");
|
||||
assert!(text.contains("1. Allow once"), "first option numbered: {text}");
|
||||
assert!(text.contains("2. Reject"), "second option numbered: {text}");
|
||||
assert!(decision.buttons.is_none(), "decision is plain text, no buttons");
|
||||
|
||||
// The pending decision is recorded against the conversation.
|
||||
let pending = store.peek("conv-dec").expect("decision recorded in store");
|
||||
assert_eq!(pending.call_id, "call-42");
|
||||
assert_eq!(pending.options.len(), 2);
|
||||
assert_eq!(pending.options[0].option_id, "allow");
|
||||
assert_eq!(pending.options[1].option_id, "reject");
|
||||
}
|
||||
|
||||
/// WeChat (no edit support) also forwards the decision as a send_message.
|
||||
#[tokio::test]
|
||||
async fn weixin_relay_forwards_decision() {
|
||||
let (event_tx, _) = broadcast::channel::<AgentStreamEvent>(64);
|
||||
let recorder = Arc::new(MessageRecorder::new());
|
||||
let store = PendingDecisionStore::new();
|
||||
|
||||
let config = RelayConfig {
|
||||
platform: PluginType::Weixin,
|
||||
plugin_id: "weixin".into(),
|
||||
chat_id: "chat_1".into(),
|
||||
throttle_ms: 10_000,
|
||||
conversation_id: "conv-wx".into(),
|
||||
};
|
||||
let relay = relay_with_store(config, recorder.clone(), Arc::clone(&store));
|
||||
let rx = event_tx.subscribe();
|
||||
|
||||
event_tx.send(acp_decision_event("call-wx", "Proceed?")).unwrap();
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Finish(FinishEventData {
|
||||
session_id: None,
|
||||
stop_reason: None,
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
relay.run(rx).await;
|
||||
|
||||
let sends = recorder.take_sends();
|
||||
assert!(
|
||||
sends
|
||||
.iter()
|
||||
.any(|m| m.text.as_deref().is_some_and(|t| t.contains("需要你的决策"))),
|
||||
"weixin relay must forward the decision: {sends:?}"
|
||||
);
|
||||
assert!(store.peek("conv-wx").is_some(), "decision recorded for weixin");
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
//! Black-box integration tests for the Telegram plugin.
|
||||
//!
|
||||
//! Tests the TelegramPlugin through the public ChannelPlugin trait interface
|
||||
//! and ChannelManager integration.
|
||||
//!
|
||||
//! Covers test-plan items: TP-2, TP-5, EP-5, DP-2.
|
||||
//!
|
||||
//! NOTE: Tests that require a live Telegram API (TP-1, EP-1) are not included
|
||||
//! here — they would need a real bot token. The unit tests within the crate
|
||||
//! cover pure function logic (content extraction, callback parsing, message
|
||||
//! truncation, backoff, markup building, etc.).
|
||||
|
||||
#[cfg(feature = "telegram")]
|
||||
mod telegram_tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_channel::manager::{ChannelManager, EnableChannelSpec, PluginFactory};
|
||||
use nomifun_channel::plugin::ChannelPlugin;
|
||||
use nomifun_channel::plugins::telegram::TelegramPlugin;
|
||||
use nomifun_channel::types::{PluginConfig, PluginCredentials, PluginStatus, PluginType};
|
||||
use nomifun_db::{IChannelRepository, SqliteChannelRepository, init_database_memory};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
// -- Test infrastructure ------------------------------------------------
|
||||
|
||||
struct MockBroadcaster {
|
||||
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl MockBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBroadcaster for MockBroadcaster {
|
||||
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn make_encryption_key() -> [u8; 32] {
|
||||
[0x42u8; 32]
|
||||
}
|
||||
|
||||
async fn setup() -> (ChannelManager, Arc<dyn IChannelRepository>, Arc<MockBroadcaster>) {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo: Arc<dyn IChannelRepository> = Arc::new(SqliteChannelRepository::new(db.pool().clone()));
|
||||
let broadcaster = Arc::new(MockBroadcaster::new());
|
||||
let (message_tx, _message_rx) = mpsc::channel(16);
|
||||
let (confirm_tx, _confirm_rx) = mpsc::channel(16);
|
||||
|
||||
let manager = ChannelManager::new(
|
||||
repo.clone(),
|
||||
broadcaster.clone(),
|
||||
make_encryption_key(),
|
||||
message_tx,
|
||||
confirm_tx,
|
||||
);
|
||||
|
||||
// Keep db alive — test process exits anyway
|
||||
std::mem::forget(db);
|
||||
|
||||
(manager, repo, broadcaster)
|
||||
}
|
||||
|
||||
fn telegram_factory() -> PluginFactory {
|
||||
Box::new(|pt| {
|
||||
if pt == PluginType::Telegram {
|
||||
Some(Box::new(TelegramPlugin::new()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn make_plugin_config(token: Option<&str>) -> PluginConfig {
|
||||
PluginConfig {
|
||||
credentials: PluginCredentials {
|
||||
token: token.map(String::from),
|
||||
..Default::default()
|
||||
},
|
||||
config: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_config_value(token: Option<&str>) -> serde_json::Value {
|
||||
let mut creds = serde_json::Map::new();
|
||||
if let Some(t) = token {
|
||||
creds.insert("token".into(), serde_json::Value::String(t.into()));
|
||||
}
|
||||
serde_json::json!({
|
||||
"credentials": creds,
|
||||
"config": { "mode": "polling" }
|
||||
})
|
||||
}
|
||||
|
||||
// -- Plugin construction ------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn telegram_plugin_initial_state() {
|
||||
let plugin = TelegramPlugin::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::Telegram);
|
||||
assert_eq!(plugin.active_user_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telegram_plugin_is_send_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<TelegramPlugin>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telegram_plugin_as_trait_object() {
|
||||
let plugin = TelegramPlugin::new();
|
||||
let boxed: Box<dyn ChannelPlugin> = Box::new(plugin);
|
||||
assert_eq!(boxed.plugin_type(), PluginType::Telegram);
|
||||
assert_eq!(boxed.status(), PluginStatus::Created);
|
||||
}
|
||||
|
||||
// -- Factory registration -----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn factory_creates_telegram_plugin() {
|
||||
let factory = telegram_factory();
|
||||
let plugin = factory(PluginType::Telegram);
|
||||
assert!(plugin.is_some());
|
||||
let plugin = plugin.unwrap();
|
||||
assert_eq!(plugin.plugin_type(), PluginType::Telegram);
|
||||
assert_eq!(plugin.status(), PluginStatus::Created);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_returns_none_for_other_types() {
|
||||
let factory = telegram_factory();
|
||||
assert!(factory(PluginType::Lark).is_none());
|
||||
assert!(factory(PluginType::Dingtalk).is_none());
|
||||
assert!(factory(PluginType::Weixin).is_none());
|
||||
}
|
||||
|
||||
// -- TP-2: Test invalid token -------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_invalid_token_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = telegram_factory();
|
||||
|
||||
// Invalid token → getMe will fail with HTTP or API error
|
||||
let config = make_plugin_config(Some("invalid-token-12345"));
|
||||
let result = manager.test_plugin("telegram", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- TP-5: Missing token ------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_missing_token_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = telegram_factory();
|
||||
|
||||
let config = make_plugin_config(None);
|
||||
let result = manager.test_plugin("telegram", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.to_lowercase().contains("token"),
|
||||
"Error should mention token: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_empty_token_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = telegram_factory();
|
||||
|
||||
let config = make_plugin_config(Some(""));
|
||||
let result = manager.test_plugin("telegram", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- EP-5: Invalid plugin type ------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_invalid_plugin_type_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = telegram_factory();
|
||||
|
||||
let config = make_config_value(Some("bot:123"));
|
||||
let result = manager.enable_plugin(&EnableChannelSpec::legacy("nonexistent"), &config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- Enable with invalid token ------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_plugin_invalid_token_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = telegram_factory();
|
||||
|
||||
let config = make_config_value(Some("bad-token"));
|
||||
let result = manager.enable_plugin(&EnableChannelSpec::legacy("telegram"), &config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- Disable plugin with no DB row returns error -----------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn disable_without_db_row_returns_error() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
// Plugin was never enabled (no DB row), so update_plugin_status fails
|
||||
let result = manager.disable_plugin("telegram").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- PS-1: Empty plugin status ------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_plugin_status_empty() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let statuses = manager.get_plugin_status().await.unwrap();
|
||||
assert!(statuses.is_empty());
|
||||
}
|
||||
|
||||
// -- Restore with nothing stored ----------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_plugins_none_stored() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = telegram_factory();
|
||||
let result = manager.restore_plugins(&factory).await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(manager.active_plugin_count(), 0);
|
||||
}
|
||||
|
||||
// -- Plugin running check -----------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_plugin_running_false_when_not_enabled() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
assert!(!manager.is_plugin_running("telegram"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! Black-box integration tests for the WeChat (iLink Bot) plugin.
|
||||
//!
|
||||
//! Tests the WeixinPlugin through the public ChannelPlugin trait interface
|
||||
//! and ChannelManager integration.
|
||||
//!
|
||||
//! Covers test-plan items: TP-2, TP-5, EP-5, DP-2, WL-1 (event structure).
|
||||
//!
|
||||
//! NOTE: Tests that require a live iLink Bot API (TP-1, EP-1, WL-1 full flow)
|
||||
//! are not included — they need a real bot token + account. Unit tests within
|
||||
//! the crate cover pure function logic (content extraction, message types,
|
||||
//! login event serialization, etc.).
|
||||
|
||||
#[cfg(feature = "weixin")]
|
||||
mod weixin_tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_channel::manager::{ChannelManager, EnableChannelSpec, PluginFactory};
|
||||
use nomifun_channel::plugin::ChannelPlugin;
|
||||
use nomifun_channel::plugins::weixin::WeixinPlugin;
|
||||
use nomifun_channel::types::{PluginConfig, PluginCredentials, PluginStatus, PluginType};
|
||||
use nomifun_db::{IChannelRepository, SqliteChannelRepository, init_database_memory};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
// -- Test infrastructure ------------------------------------------------
|
||||
|
||||
struct MockBroadcaster {
|
||||
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl MockBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBroadcaster for MockBroadcaster {
|
||||
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn make_encryption_key() -> [u8; 32] {
|
||||
[0x42u8; 32]
|
||||
}
|
||||
|
||||
async fn setup() -> (ChannelManager, Arc<dyn IChannelRepository>, Arc<MockBroadcaster>) {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo: Arc<dyn IChannelRepository> = Arc::new(SqliteChannelRepository::new(db.pool().clone()));
|
||||
let broadcaster = Arc::new(MockBroadcaster::new());
|
||||
let (message_tx, _message_rx) = mpsc::channel(16);
|
||||
let (confirm_tx, _confirm_rx) = mpsc::channel(16);
|
||||
|
||||
let manager = ChannelManager::new(
|
||||
repo.clone(),
|
||||
broadcaster.clone(),
|
||||
make_encryption_key(),
|
||||
message_tx,
|
||||
confirm_tx,
|
||||
);
|
||||
|
||||
// Keep db alive — test process exits anyway
|
||||
std::mem::forget(db);
|
||||
|
||||
(manager, repo, broadcaster)
|
||||
}
|
||||
|
||||
fn weixin_factory() -> PluginFactory {
|
||||
Box::new(|pt| {
|
||||
if pt == PluginType::Weixin {
|
||||
Some(Box::new(WeixinPlugin::new()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn make_plugin_config(bot_token: Option<&str>, account_id: Option<&str>) -> PluginConfig {
|
||||
PluginConfig {
|
||||
credentials: PluginCredentials {
|
||||
account_id: account_id.map(String::from),
|
||||
bot_token: bot_token.map(String::from),
|
||||
..Default::default()
|
||||
},
|
||||
config: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_config_value(bot_token: Option<&str>, account_id: Option<&str>) -> serde_json::Value {
|
||||
let mut creds = serde_json::Map::new();
|
||||
if let Some(t) = bot_token {
|
||||
creds.insert("botToken".into(), serde_json::Value::String(t.into()));
|
||||
}
|
||||
if let Some(a) = account_id {
|
||||
creds.insert("accountId".into(), serde_json::Value::String(a.into()));
|
||||
}
|
||||
serde_json::json!({
|
||||
"credentials": creds,
|
||||
"config": { "mode": "polling" }
|
||||
})
|
||||
}
|
||||
|
||||
// -- Plugin construction ------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn weixin_plugin_initial_state() {
|
||||
let plugin = WeixinPlugin::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::Weixin);
|
||||
assert_eq!(plugin.active_user_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weixin_plugin_is_send_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<WeixinPlugin>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weixin_plugin_as_trait_object() {
|
||||
let plugin = WeixinPlugin::new();
|
||||
let boxed: Box<dyn ChannelPlugin> = Box::new(plugin);
|
||||
assert_eq!(boxed.plugin_type(), PluginType::Weixin);
|
||||
assert_eq!(boxed.status(), PluginStatus::Created);
|
||||
}
|
||||
|
||||
// -- Factory registration -----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn factory_creates_weixin_plugin() {
|
||||
let factory = weixin_factory();
|
||||
let plugin = factory(PluginType::Weixin);
|
||||
assert!(plugin.is_some());
|
||||
let plugin = plugin.unwrap();
|
||||
assert_eq!(plugin.plugin_type(), PluginType::Weixin);
|
||||
assert_eq!(plugin.status(), PluginStatus::Created);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_returns_none_for_other_types() {
|
||||
let factory = weixin_factory();
|
||||
assert!(factory(PluginType::Telegram).is_none());
|
||||
assert!(factory(PluginType::Lark).is_none());
|
||||
assert!(factory(PluginType::Dingtalk).is_none());
|
||||
}
|
||||
|
||||
// -- TP-5: Missing credentials ------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_missing_bot_token_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = weixin_factory();
|
||||
|
||||
let config = make_plugin_config(None, Some("acc_1"));
|
||||
let result = manager.test_plugin("weixin", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.to_lowercase().contains("bot_token") || err_msg.to_lowercase().contains("bottoken"),
|
||||
"Error should mention bot_token: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_missing_account_id_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = weixin_factory();
|
||||
|
||||
let config = make_plugin_config(Some("tok_1"), None);
|
||||
let result = manager.test_plugin("weixin", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.to_lowercase().contains("account_id") || err_msg.to_lowercase().contains("accountid"),
|
||||
"Error should mention account_id: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_empty_bot_token_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = weixin_factory();
|
||||
|
||||
let config = make_plugin_config(Some(""), Some("acc_1"));
|
||||
let result = manager.test_plugin("weixin", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_empty_account_id_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = weixin_factory();
|
||||
|
||||
let config = make_plugin_config(Some("tok_1"), Some(""));
|
||||
let result = manager.test_plugin("weixin", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- EP-5: Invalid plugin type ------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_invalid_plugin_type_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = weixin_factory();
|
||||
|
||||
let config = make_config_value(Some("tok_1"), Some("acc_1"));
|
||||
let result = manager.enable_plugin(&EnableChannelSpec::legacy("nonexistent"), &config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- DP-2: Disable without enable (idempotent/error) -------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn disable_without_db_row_returns_error() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let result = manager.disable_plugin("weixin").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- PS-1: Empty plugin status ------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_plugin_status_empty() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let statuses = manager.get_plugin_status().await.unwrap();
|
||||
assert!(statuses.is_empty());
|
||||
}
|
||||
|
||||
// -- Restore with nothing stored ----------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_plugins_none_stored() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = weixin_factory();
|
||||
let result = manager.restore_plugins(&factory).await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(manager.active_plugin_count(), 0);
|
||||
}
|
||||
|
||||
// -- Plugin running check -----------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_plugin_running_false_when_not_enabled() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
assert!(!manager.is_plugin_running("weixin"));
|
||||
}
|
||||
|
||||
// -- Login event serialization ------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn login_event_qr_serializes_correctly() {
|
||||
use nomifun_channel::plugins::weixin::weixin_login_stream;
|
||||
|
||||
// Just verify the public function is accessible and returns a receiver
|
||||
// (we cannot test the full flow without a live API, but we verify the
|
||||
// type is exported correctly).
|
||||
let _fn_ref: fn() -> tokio::sync::mpsc::Receiver<_> = weixin_login_stream;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user