feat(channel): add napcat support for qq protocol
This commit is contained in:
parent
6e444e0311
commit
cc0bc49b2f
@ -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]
|
||||
|
||||
@ -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
523
src/channels/napcat.rs
Normal 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]"));
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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" }
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user