feat(channel): add napcat support for qq protocol

This commit is contained in:
argenis de la rosa 2026-02-28 12:55:33 -05:00 committed by Argenis
parent 6e444e0311
commit cc0bc49b2f
8 changed files with 672 additions and 9 deletions

View File

@ -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:<qq_user_id>` for private messages
- `group:<qq_group_id>` 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]

View File

@ -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();

523
src/channels/napcat.rs Normal file
View File

@ -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<String> {
let token = raw.trim();
(!token.is_empty()).then(|| token.to_string())
}
fn derive_api_base_from_websocket(websocket_url: &str) -> Option<String> {
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<String>,
allowed_users: Vec<String>,
dedup: Arc<RwLock<HashSet<String>>>,
}
impl NapcatChannel {
pub fn from_config(config: NapcatConfig) -> Result<Self> {
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<String> = 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<tokio_tungstenite::tungstenite::http::Request<()>> {
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<ChannelMessage> {
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<ChannelMessage>) -> 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<ChannelMessage>) -> 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]"));
}
}

View File

@ -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<FeishuConfig>,
/// DingTalk channel configuration.
pub dingtalk: Option<DingTalkConfig>,
/// Napcat QQ protocol channel configuration.
pub napcat: Option<NapcatConfig>,
/// QQ Official Bot channel configuration.
pub qq: Option<QQConfig>,
pub nostr: Option<NostrConfig>,
@ -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<String>,
/// Allowed user IDs. Empty = deny all, "*" = allow all
#[serde(default)]
pub allowed_users: Vec<String>,
}
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,

View File

@ -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

View File

@ -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, &current_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, &current_ch.access_token);
}
if let (Some(incoming_ch), Some(current_ch)) = (
incoming.channels_config.qq.as_mut(),
current.channels_config.qq.as_ref(),

View File

@ -159,6 +159,18 @@ pub fn all_integrations() -> Vec<IntegrationEntry> {
}
},
},
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",

View File

@ -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\":\"<channel_id_or_chat_id>\"}. \
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\":\"<channel_id>\"}",
"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" }
}