From cc0bc49b2f3c2676329ee399e62b0c2a0d211d0a Mon Sep 17 00:00:00 2001 From: argenis de la rosa Date: Sat, 28 Feb 2026 12:55:33 -0500 Subject: [PATCH] feat(channel): add napcat support for qq protocol --- docs/channels-reference.md | 28 +- src/channels/mod.rs | 42 ++- src/channels/napcat.rs | 523 +++++++++++++++++++++++++++++++++++ src/config/schema.rs | 50 ++++ src/cron/scheduler.rs | 13 +- src/gateway/api.rs | 9 + src/integrations/registry.rs | 12 + src/tools/cron_add.rs | 4 +- 8 files changed, 672 insertions(+), 9 deletions(-) create mode 100644 src/channels/napcat.rs diff --git a/docs/channels-reference.md b/docs/channels-reference.md index c3c907618..108d72b11 100644 --- a/docs/channels-reference.md +++ b/docs/channels-reference.md @@ -143,6 +143,7 @@ If `[channels_config.matrix]`, `[channels_config.lark]`, or `[channels_config.fe | Feishu | websocket (default) or webhook | Webhook mode only | | DingTalk | stream mode | No | | QQ | bot gateway | No | +| Napcat | websocket receive + HTTP send (OneBot) | No (typically local/LAN) | | Linq | webhook (`/linq`) | Yes (public HTTPS callback) | | iMessage | local integration | No | | Nostr | relay websocket (NIP-04 / NIP-17) | No | @@ -159,7 +160,7 @@ For channels with inbound sender allowlists: Field names differ by channel: -- `allowed_users` (Telegram/Discord/Slack/Mattermost/Matrix/IRC/Lark/Feishu/DingTalk/QQ/Nextcloud Talk) +- `allowed_users` (Telegram/Discord/Slack/Mattermost/Matrix/IRC/Lark/Feishu/DingTalk/QQ/Napcat/Nextcloud Talk) - `allowed_from` (Signal) - `allowed_numbers` (WhatsApp) - `allowed_senders` (Email/Linq) @@ -472,7 +473,26 @@ Notes: - `X-Bot-Appid` is checked when present and must match `app_id`. - Set `receive_mode = "websocket"` to keep the legacy gateway WS receive path. -### 4.16 Nextcloud Talk +### 4.16 Napcat (QQ via OneBot) + +```toml +[channels_config.napcat] +websocket_url = "ws://127.0.0.1:3001" +api_base_url = "http://127.0.0.1:3001" # optional; auto-derived when omitted +access_token = "" # optional +allowed_users = ["*"] +``` + +Notes: + +- Inbound messages are consumed from Napcat's WebSocket stream. +- Outbound sends use OneBot-compatible HTTP endpoints (`send_private_msg` / `send_group_msg`). +- Recipients: + - `user:` for private messages + - `group:` for group messages +- Outbound reply chaining uses incoming message ids via CQ reply tags. + +### 4.17 Nextcloud Talk ```toml [channels_config.nextcloud_talk] @@ -490,7 +510,7 @@ Notes: - `ZEROCLAW_NEXTCLOUD_TALK_WEBHOOK_SECRET` overrides config secret. - See [nextcloud-talk-setup.md](./nextcloud-talk-setup.md) for a full runbook. -### 4.16 Linq +### 4.18 Linq ```toml [channels_config.linq] @@ -509,7 +529,7 @@ Notes: - `ZEROCLAW_LINQ_SIGNING_SECRET` overrides config secret. - `allowed_senders` uses E.164 phone number format (e.g. `+1234567890`). -### 4.17 iMessage +### 4.19 iMessage ```toml [channels_config.imessage] diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 74602e1ba..ee9e67a00 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -27,6 +27,7 @@ pub mod linq; #[cfg(feature = "channel-matrix")] pub mod matrix; pub mod mattermost; +pub mod napcat; pub mod nextcloud_talk; pub mod nostr; pub mod qq; @@ -55,6 +56,7 @@ pub use linq::LinqChannel; #[cfg(feature = "channel-matrix")] pub use matrix::MatrixChannel; pub use mattermost::MattermostChannel; +pub use napcat::NapcatChannel; pub use nextcloud_talk::NextcloudTalkChannel; pub use nostr::NostrChannel; pub use qq::QQChannel; @@ -335,7 +337,7 @@ fn conversation_memory_key(msg: &traits::ChannelMessage) -> String { fn conversation_history_key(msg: &traits::ChannelMessage) -> String { // QQ uses thread_ts as a passive-reply message id, not a thread identifier. // Using it in history keys would reset context on every incoming message. - if msg.channel == "qq" { + if msg.channel == "qq" || msg.channel == "napcat" { return format!("{}_{}", msg.channel, msg.sender); } @@ -4837,6 +4839,16 @@ fn collect_configured_channels( } } + if let Some(ref napcat_cfg) = config.channels_config.napcat { + match NapcatChannel::from_config(napcat_cfg.clone()) { + Ok(channel) => channels.push(ConfiguredChannel { + display_name: "Napcat", + channel: Arc::new(channel), + }), + Err(err) => tracing::warn!("Napcat channel configuration invalid: {err}"), + } + } + if let Some(ref ct) = config.channels_config.clawdtalk { channels.push(ConfiguredChannel { display_name: "ClawdTalk", @@ -9954,6 +9966,34 @@ BTC is currently around $65,000 based on latest tool output."# ); } + #[test] + fn conversation_history_key_ignores_napcat_message_id_thread() { + let msg1 = traits::ChannelMessage { + id: "msg_1".into(), + sender: "user_1001".into(), + reply_target: "user:1001".into(), + content: "first".into(), + channel: "napcat".into(), + timestamp: 1, + thread_ts: Some("msg-a".into()), + }; + let msg2 = traits::ChannelMessage { + id: "msg_2".into(), + sender: "user_1001".into(), + reply_target: "user:1001".into(), + content: "second".into(), + channel: "napcat".into(), + timestamp: 2, + thread_ts: Some("msg-b".into()), + }; + + assert_eq!(conversation_history_key(&msg1), "napcat_user_1001"); + assert_eq!( + conversation_history_key(&msg1), + conversation_history_key(&msg2) + ); + } + #[tokio::test] async fn autosave_keys_preserve_multiple_conversation_facts() { let tmp = TempDir::new().unwrap(); diff --git a/src/channels/napcat.rs b/src/channels/napcat.rs new file mode 100644 index 000000000..74f579b6b --- /dev/null +++ b/src/channels/napcat.rs @@ -0,0 +1,523 @@ +use super::traits::{Channel, ChannelMessage, SendMessage}; +use crate::config::schema::NapcatConfig; +use anyhow::{anyhow, Context, Result}; +use async_trait::async_trait; +use futures_util::{SinkExt, StreamExt}; +use reqwest::Url; +use serde_json::{json, Value}; +use std::collections::HashSet; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; +use tokio::sync::RwLock; +use tokio::time::{sleep, Duration}; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::Message; +use uuid::Uuid; + +const NAPCAT_SEND_PRIVATE: &str = "/send_private_msg"; +const NAPCAT_SEND_GROUP: &str = "/send_group_msg"; +const NAPCAT_STATUS: &str = "/get_status"; +const NAPCAT_DEDUP_CAPACITY: usize = 10_000; +const NAPCAT_MAX_BACKOFF_SECS: u64 = 60; + +fn current_unix_timestamp_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn normalize_token(raw: &str) -> Option { + let token = raw.trim(); + (!token.is_empty()).then(|| token.to_string()) +} + +fn derive_api_base_from_websocket(websocket_url: &str) -> Option { + let mut url = Url::parse(websocket_url).ok()?; + match url.scheme() { + "ws" => { + url.set_scheme("http").ok()?; + } + "wss" => { + url.set_scheme("https").ok()?; + } + _ => return None, + } + url.set_path(""); + url.set_query(None); + url.set_fragment(None); + Some(url.to_string().trim_end_matches('/').to_string()) +} + +fn compose_onebot_content(content: &str, reply_message_id: Option<&str>) -> String { + let mut parts = Vec::new(); + if let Some(reply_id) = reply_message_id { + let trimmed = reply_id.trim(); + if !trimmed.is_empty() { + parts.push(format!("[CQ:reply,id={trimmed}]")); + } + } + + for line in content.lines() { + let trimmed = line.trim(); + if let Some(marker) = trimmed + .strip_prefix("[IMAGE:") + .and_then(|v| v.strip_suffix(']')) + .map(str::trim) + .filter(|v| !v.is_empty()) + { + parts.push(format!("[CQ:image,file={marker}]")); + continue; + } + parts.push(line.to_string()); + } + + parts.join("\n").trim().to_string() +} + +fn parse_message_segments(message: &Value) -> String { + if let Some(text) = message.as_str() { + return text.trim().to_string(); + } + + let Some(segments) = message.as_array() else { + return String::new(); + }; + + let mut parts = Vec::new(); + for segment in segments { + let seg_type = segment + .get("type") + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + let data = segment.get("data"); + match seg_type { + "text" => { + if let Some(text) = data + .and_then(|d| d.get("text")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|v| !v.is_empty()) + { + parts.push(text.to_string()); + } + } + "image" => { + if let Some(url) = data + .and_then(|d| d.get("url")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|v| !v.is_empty()) + { + parts.push(format!("[IMAGE:{url}]")); + } else if let Some(file) = data + .and_then(|d| d.get("file")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|v| !v.is_empty()) + { + parts.push(format!("[IMAGE:{file}]")); + } + } + _ => {} + } + } + + parts.join("\n").trim().to_string() +} + +fn extract_message_id(event: &Value) -> String { + event + .get("message_id") + .and_then(Value::as_i64) + .map(|v| v.to_string()) + .or_else(|| { + event + .get("message_id") + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| Uuid::new_v4().to_string()) +} + +fn extract_timestamp(event: &Value) -> u64 { + event + .get("time") + .and_then(Value::as_i64) + .and_then(|v| u64::try_from(v).ok()) + .unwrap_or_else(current_unix_timestamp_secs) +} + +pub struct NapcatChannel { + websocket_url: String, + api_base_url: String, + access_token: Option, + allowed_users: Vec, + dedup: Arc>>, +} + +impl NapcatChannel { + pub fn from_config(config: NapcatConfig) -> Result { + let websocket_url = config.websocket_url.trim().to_string(); + if websocket_url.is_empty() { + anyhow::bail!("napcat.websocket_url cannot be empty"); + } + + let api_base_url = if config.api_base_url.trim().is_empty() { + derive_api_base_from_websocket(&websocket_url).ok_or_else(|| { + anyhow!("napcat.api_base_url is required when websocket_url is not ws:// or wss://") + })? + } else { + config.api_base_url.trim().trim_end_matches('/').to_string() + }; + + Ok(Self { + websocket_url, + api_base_url, + access_token: normalize_token(config.access_token.as_deref().unwrap_or_default()), + allowed_users: config.allowed_users, + dedup: Arc::new(RwLock::new(HashSet::new())), + }) + } + + fn is_user_allowed(&self, user_id: &str) -> bool { + self.allowed_users.iter().any(|u| u == "*" || u == user_id) + } + + async fn is_duplicate(&self, message_id: &str) -> bool { + if message_id.is_empty() { + return false; + } + let mut dedup = self.dedup.write().await; + if dedup.contains(message_id) { + return true; + } + if dedup.len() >= NAPCAT_DEDUP_CAPACITY { + let remove_n = dedup.len() / 2; + let to_remove: Vec = dedup.iter().take(remove_n).cloned().collect(); + for key in to_remove { + dedup.remove(&key); + } + } + dedup.insert(message_id.to_string()); + false + } + + fn http_client(&self) -> reqwest::Client { + crate::config::build_runtime_proxy_client("channel.napcat") + } + + async fn post_onebot(&self, endpoint: &str, body: &Value) -> Result<()> { + let url = format!("{}{}", self.api_base_url, endpoint); + let mut request = self.http_client().post(&url).json(body); + if let Some(token) = &self.access_token { + request = request.bearer_auth(token); + } + + let response = request.send().await?; + if !response.status().is_success() { + let status = response.status(); + let err = response.text().await.unwrap_or_default(); + let sanitized = crate::providers::sanitize_api_error(&err); + anyhow::bail!("Napcat HTTP request failed ({status}): {sanitized}"); + } + + let payload: Value = response.json().await.unwrap_or_else(|_| json!({})); + if payload + .get("retcode") + .and_then(Value::as_i64) + .is_some_and(|retcode| retcode != 0) + { + let msg = payload + .get("wording") + .and_then(Value::as_str) + .or_else(|| payload.get("msg").and_then(Value::as_str)) + .unwrap_or("unknown error"); + anyhow::bail!("Napcat returned retcode != 0: {msg}"); + } + + Ok(()) + } + + fn build_ws_request(&self) -> Result> { + let mut ws_url = + Url::parse(&self.websocket_url).with_context(|| "invalid napcat.websocket_url")?; + if let Some(token) = &self.access_token { + let has_access_token = ws_url.query_pairs().any(|(k, _)| k == "access_token"); + if !has_access_token { + ws_url.query_pairs_mut().append_pair("access_token", token); + } + } + + let mut request = ws_url.as_str().into_client_request()?; + if let Some(token) = &self.access_token { + let value = format!("Bearer {token}"); + request.headers_mut().insert( + tokio_tungstenite::tungstenite::http::header::AUTHORIZATION, + value + .parse() + .context("invalid napcat access token header")?, + ); + } + Ok(request) + } + + async fn parse_message_event(&self, event: &Value) -> Option { + if event.get("post_type").and_then(Value::as_str) != Some("message") { + return None; + } + + let message_id = extract_message_id(event); + if self.is_duplicate(&message_id).await { + return None; + } + + let message_type = event + .get("message_type") + .and_then(Value::as_str) + .unwrap_or(""); + let sender_id = event + .get("user_id") + .and_then(Value::as_i64) + .map(|v| v.to_string()) + .or_else(|| { + event + .get("sender") + .and_then(|s| s.get("user_id")) + .and_then(Value::as_i64) + .map(|v| v.to_string()) + }) + .unwrap_or_else(|| "unknown".to_string()); + + if !self.is_user_allowed(&sender_id) { + tracing::warn!("Napcat: ignoring message from unauthorized user: {sender_id}"); + return None; + } + + let content = { + let parsed = parse_message_segments(event.get("message").unwrap_or(&Value::Null)); + if parsed.is_empty() { + event + .get("raw_message") + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or("") + .to_string() + } else { + parsed + } + }; + + if content.trim().is_empty() { + return None; + } + + let reply_target = if message_type == "group" { + let group_id = event + .get("group_id") + .and_then(Value::as_i64) + .map(|v| v.to_string()) + .unwrap_or_default(); + format!("group:{group_id}") + } else { + format!("user:{sender_id}") + }; + + Some(ChannelMessage { + id: message_id.clone(), + sender: sender_id, + reply_target, + content, + channel: "napcat".to_string(), + timestamp: extract_timestamp(event), + // This is a message id for passive reply, not a thread id. + thread_ts: Some(message_id), + }) + } + + async fn listen_once(&self, tx: &tokio::sync::mpsc::Sender) -> Result<()> { + let request = self.build_ws_request()?; + let (mut socket, _) = connect_async(request).await?; + tracing::info!("Napcat: connected to {}", self.websocket_url); + + while let Some(frame) = socket.next().await { + match frame { + Ok(Message::Text(text)) => { + let event: Value = match serde_json::from_str(&text) { + Ok(v) => v, + Err(err) => { + tracing::warn!("Napcat: failed to parse event payload: {err}"); + continue; + } + }; + if let Some(msg) = self.parse_message_event(&event).await { + if tx.send(msg).await.is_err() { + return Ok(()); + } + } + } + Ok(Message::Binary(_)) => {} + Ok(Message::Ping(payload)) => { + socket.send(Message::Pong(payload)).await?; + } + Ok(Message::Pong(_)) => {} + Ok(Message::Close(frame)) => { + return Err(anyhow!("Napcat websocket closed: {:?}", frame)); + } + Ok(Message::Frame(_)) => {} + Err(err) => { + return Err(anyhow!("Napcat websocket error: {err}")); + } + } + } + + Err(anyhow!("Napcat websocket stream ended")) + } +} + +#[async_trait] +impl Channel for NapcatChannel { + fn name(&self) -> &str { + "napcat" + } + + async fn send(&self, message: &SendMessage) -> Result<()> { + let payload = compose_onebot_content(&message.content, message.thread_ts.as_deref()); + if payload.trim().is_empty() { + return Ok(()); + } + + if let Some(group_id) = message.recipient.strip_prefix("group:") { + let body = json!({ + "group_id": group_id, + "message": payload, + }); + self.post_onebot(NAPCAT_SEND_GROUP, &body).await?; + return Ok(()); + } + + let user_id = message + .recipient + .strip_prefix("user:") + .unwrap_or(&message.recipient) + .trim(); + if user_id.is_empty() { + anyhow::bail!("Napcat recipient is empty"); + } + + let body = json!({ + "user_id": user_id, + "message": payload, + }); + self.post_onebot(NAPCAT_SEND_PRIVATE, &body).await + } + + async fn listen(&self, tx: tokio::sync::mpsc::Sender) -> Result<()> { + let mut backoff = Duration::from_secs(1); + loop { + match self.listen_once(&tx).await { + Ok(()) => return Ok(()), + Err(err) => { + tracing::error!( + "Napcat listener error: {err}. Reconnecting in {:?}...", + backoff + ); + sleep(backoff).await; + backoff = + std::cmp::min(backoff * 2, Duration::from_secs(NAPCAT_MAX_BACKOFF_SECS)); + } + } + } + } + + async fn health_check(&self) -> bool { + let url = format!("{}{}", self.api_base_url, NAPCAT_STATUS); + let mut request = self.http_client().get(url); + if let Some(token) = &self.access_token { + request = request.bearer_auth(token); + } + request + .timeout(Duration::from_secs(5)) + .send() + .await + .map(|resp| resp.status().is_success()) + .unwrap_or(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derive_api_base_converts_ws_to_http() { + let base = derive_api_base_from_websocket("ws://127.0.0.1:3001/ws").unwrap(); + assert_eq!(base, "http://127.0.0.1:3001"); + } + + #[test] + fn compose_onebot_content_includes_reply_and_image_markers() { + let content = "hello\n[IMAGE:https://example.com/cat.png]"; + let parsed = compose_onebot_content(content, Some("123")); + assert!(parsed.contains("[CQ:reply,id=123]")); + assert!(parsed.contains("[CQ:image,file=https://example.com/cat.png]")); + assert!(parsed.contains("hello")); + } + + #[tokio::test] + async fn parse_private_event_maps_to_channel_message() { + let cfg = NapcatConfig { + websocket_url: "ws://127.0.0.1:3001".into(), + api_base_url: "".into(), + access_token: None, + allowed_users: vec!["10001".into()], + }; + let channel = NapcatChannel::from_config(cfg).unwrap(); + let event = json!({ + "post_type": "message", + "message_type": "private", + "message_id": 99, + "user_id": 10001, + "time": 1700000000, + "message": [{"type":"text","data":{"text":"hi"}}] + }); + + let msg = channel.parse_message_event(&event).await.unwrap(); + assert_eq!(msg.channel, "napcat"); + assert_eq!(msg.sender, "10001"); + assert_eq!(msg.reply_target, "user:10001"); + assert_eq!(msg.content, "hi"); + assert_eq!(msg.thread_ts.as_deref(), Some("99")); + } + + #[tokio::test] + async fn parse_group_event_with_image_segment() { + let cfg = NapcatConfig { + websocket_url: "ws://127.0.0.1:3001".into(), + api_base_url: "".into(), + access_token: None, + allowed_users: vec!["*".into()], + }; + let channel = NapcatChannel::from_config(cfg).unwrap(); + let event = json!({ + "post_type": "message", + "message_type": "group", + "message_id": "abc-1", + "user_id": 20002, + "group_id": 30003, + "message": [ + {"type":"text","data":{"text":"photo"}}, + {"type":"image","data":{"url":"https://img.example.com/1.jpg"}} + ] + }); + + let msg = channel.parse_message_event(&event).await.unwrap(); + assert_eq!(msg.reply_target, "group:30003"); + assert!(msg.content.contains("photo")); + assert!(msg + .content + .contains("[IMAGE:https://img.example.com/1.jpg]")); + } +} diff --git a/src/config/schema.rs b/src/config/schema.rs index b7e06bbc3..29f0eb288 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -30,6 +30,7 @@ const SUPPORTED_PROXY_SERVICE_KEYS: &[&str] = &[ "channel.matrix", "channel.mattermost", "channel.nextcloud_talk", + "channel.napcat", "channel.qq", "channel.signal", "channel.slack", @@ -409,6 +410,7 @@ impl std::fmt::Debug for Config { self.channels_config.lark.is_some(), self.channels_config.feishu.is_some(), self.channels_config.dingtalk.is_some(), + self.channels_config.napcat.is_some(), self.channels_config.qq.is_some(), self.channels_config.nostr.is_some(), self.channels_config.clawdtalk.is_some(), @@ -3902,6 +3904,8 @@ pub struct ChannelsConfig { pub feishu: Option, /// DingTalk channel configuration. pub dingtalk: Option, + /// Napcat QQ protocol channel configuration. + pub napcat: Option, /// QQ Official Bot channel configuration. pub qq: Option, pub nostr: Option, @@ -3985,6 +3989,10 @@ impl ChannelsConfig { Box::new(ConfigWrapper::new(self.dingtalk.as_ref())), self.dingtalk.is_some(), ), + ( + Box::new(ConfigWrapper::new(self.napcat.as_ref())), + self.napcat.is_some(), + ), ( Box::new(ConfigWrapper::new(self.qq.as_ref())), self.qq @@ -4037,6 +4045,7 @@ impl Default for ChannelsConfig { lark: None, feishu: None, dingtalk: None, + napcat: None, qq: None, nostr: None, clawdtalk: None, @@ -5437,6 +5446,30 @@ impl ChannelConfig for DingTalkConfig { } } +/// Napcat channel configuration (QQ via OneBot-compatible API) +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct NapcatConfig { + /// Napcat WebSocket endpoint (for example `ws://127.0.0.1:3001`) + pub websocket_url: String, + /// Optional Napcat HTTP API base URL. If omitted, derived from websocket_url. + #[serde(default)] + pub api_base_url: String, + /// Optional access token (Authorization Bearer token) + pub access_token: Option, + /// Allowed user IDs. Empty = deny all, "*" = allow all + #[serde(default)] + pub allowed_users: Vec, +} + +impl ChannelConfig for NapcatConfig { + fn name() -> &'static str { + "Napcat" + } + fn desc() -> &'static str { + "QQ via Napcat (OneBot)" + } +} + /// QQ Official Bot configuration (Tencent QQ Bot SDK) #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema)] #[serde(rename_all = "lowercase")] @@ -6053,6 +6086,13 @@ fn decrypt_channel_secrets( "config.channels_config.dingtalk.client_secret", )?; } + if let Some(ref mut napcat) = channels.napcat { + decrypt_optional_secret( + store, + &mut napcat.access_token, + "config.channels_config.napcat.access_token", + )?; + } if let Some(ref mut qq) = channels.qq { decrypt_secret( store, @@ -6215,6 +6255,13 @@ fn encrypt_channel_secrets( "config.channels_config.dingtalk.client_secret", )?; } + if let Some(ref mut napcat) = channels.napcat { + encrypt_optional_secret( + store, + &mut napcat.access_token, + "config.channels_config.napcat.access_token", + )?; + } if let Some(ref mut qq) = channels.qq { encrypt_secret( store, @@ -8628,6 +8675,7 @@ default_temperature = 0.7 lark: None, feishu: None, dingtalk: None, + napcat: None, qq: None, nostr: None, clawdtalk: None, @@ -9556,6 +9604,7 @@ allowed_users = ["@ops:matrix.org"] lark: None, feishu: None, dingtalk: None, + napcat: None, qq: None, nostr: None, clawdtalk: None, @@ -9834,6 +9883,7 @@ channel_id = "C123" lark: None, feishu: None, dingtalk: None, + napcat: None, qq: None, nostr: None, clawdtalk: None, diff --git a/src/cron/scheduler.rs b/src/cron/scheduler.rs index 4dde2736a..99c33073c 100644 --- a/src/cron/scheduler.rs +++ b/src/cron/scheduler.rs @@ -3,8 +3,8 @@ use crate::channels::LarkChannel; #[cfg(feature = "channel-matrix")] use crate::channels::MatrixChannel; use crate::channels::{ - Channel, DiscordChannel, EmailChannel, MattermostChannel, QQChannel, SendMessage, SlackChannel, - TelegramChannel, WhatsAppChannel, + Channel, DiscordChannel, EmailChannel, MattermostChannel, NapcatChannel, QQChannel, + SendMessage, SlackChannel, TelegramChannel, WhatsAppChannel, }; use crate::config::Config; use crate::cron::{ @@ -398,6 +398,15 @@ pub(crate) async fn deliver_announcement( ); channel.send(&SendMessage::new(output, target)).await?; } + "napcat" => { + let napcat_cfg = config + .channels_config + .napcat + .as_ref() + .ok_or_else(|| anyhow::anyhow!("napcat channel not configured"))?; + let channel = NapcatChannel::from_config(napcat_cfg.clone())?; + channel.send(&SendMessage::new(output, target)).await?; + } "whatsapp_web" | "whatsapp" => { let wa = config .channels_config diff --git a/src/gateway/api.rs b/src/gateway/api.rs index 202e777d7..c06c2f1c2 100644 --- a/src/gateway/api.rs +++ b/src/gateway/api.rs @@ -683,6 +683,9 @@ fn mask_sensitive_fields(config: &crate::config::Config) -> crate::config::Confi if let Some(dingtalk) = masked.channels_config.dingtalk.as_mut() { mask_required_secret(&mut dingtalk.client_secret); } + if let Some(napcat) = masked.channels_config.napcat.as_mut() { + mask_optional_secret(&mut napcat.access_token); + } if let Some(qq) = masked.channels_config.qq.as_mut() { mask_required_secret(&mut qq.app_secret); } @@ -874,6 +877,12 @@ fn restore_masked_sensitive_fields( ) { restore_required_secret(&mut incoming_ch.client_secret, ¤t_ch.client_secret); } + if let (Some(incoming_ch), Some(current_ch)) = ( + incoming.channels_config.napcat.as_mut(), + current.channels_config.napcat.as_ref(), + ) { + restore_optional_secret(&mut incoming_ch.access_token, ¤t_ch.access_token); + } if let (Some(incoming_ch), Some(current_ch)) = ( incoming.channels_config.qq.as_mut(), current.channels_config.qq.as_ref(), diff --git a/src/integrations/registry.rs b/src/integrations/registry.rs index 57630fcb8..39416cad4 100644 --- a/src/integrations/registry.rs +++ b/src/integrations/registry.rs @@ -159,6 +159,18 @@ pub fn all_integrations() -> Vec { } }, }, + IntegrationEntry { + name: "Napcat", + description: "QQ via Napcat (OneBot)", + category: IntegrationCategory::Chat, + status_fn: |c| { + if c.channels_config.napcat.is_some() { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, // ── AI Models ─────────────────────────────────────────── IntegrationEntry { name: "OpenRouter", diff --git a/src/tools/cron_add.rs b/src/tools/cron_add.rs index 3469114d5..d2d356030 100644 --- a/src/tools/cron_add.rs +++ b/src/tools/cron_add.rs @@ -56,7 +56,7 @@ impl Tool for CronAddTool { fn description(&self) -> &str { "Create a scheduled cron job (shell or agent) with cron/at/every schedules. \ Use job_type='agent' with a prompt to run the AI agent on schedule. \ - To deliver output to a channel (Discord, Telegram, Slack, Mattermost, QQ, Lark, Feishu, Email), set \ + To deliver output to a channel (Discord, Telegram, Slack, Mattermost, QQ, Napcat, Lark, Feishu, Email), set \ delivery={\"mode\":\"announce\",\"channel\":\"discord\",\"to\":\"\"}. \ This is the preferred tool for sending scheduled/delayed messages to users via channels." } @@ -80,7 +80,7 @@ impl Tool for CronAddTool { "description": "Delivery config to send job output to a channel. Example: {\"mode\":\"announce\",\"channel\":\"discord\",\"to\":\"\"}", "properties": { "mode": { "type": "string", "enum": ["none", "announce"], "description": "Set to 'announce' to deliver output to a channel" }, - "channel": { "type": "string", "enum": ["telegram", "discord", "slack", "mattermost", "qq", "lark", "feishu", "email"], "description": "Channel type to deliver to" }, + "channel": { "type": "string", "enum": ["telegram", "discord", "slack", "mattermost", "qq", "napcat", "lark", "feishu", "email"], "description": "Channel type to deliver to" }, "to": { "type": "string", "description": "Target: Discord channel ID, Telegram chat ID, Slack channel, etc." }, "best_effort": { "type": "boolean", "description": "If true, delivery failure does not fail the job" } }