features: supports WeChat channel
This commit is contained in:
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@@ -866,6 +866,7 @@ dependencies = [
|
|||||||
"notify",
|
"notify",
|
||||||
"portable-pty",
|
"portable-pty",
|
||||||
"prost",
|
"prost",
|
||||||
|
"rand 0.8.5",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"sacp",
|
"sacp",
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ tower-http = { version = "0.6", features = ["fs", "cors"] }
|
|||||||
tokio-tungstenite = { version = "0.26", features = ["native-tls"] }
|
tokio-tungstenite = { version = "0.26", features = ["native-tls"] }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
prost = "0.13"
|
prost = "0.13"
|
||||||
|
rand = "0.8"
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-window-state = { version = "2", optional = true }
|
tauri-plugin-window-state = { version = "2", optional = true }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod lark;
|
pub mod lark;
|
||||||
pub mod telegram;
|
pub mod telegram;
|
||||||
|
pub mod weixin;
|
||||||
|
|
||||||
use super::error::ChatChannelError;
|
use super::error::ChatChannelError;
|
||||||
use super::traits::ChatChannelBackend;
|
use super::traits::ChatChannelBackend;
|
||||||
@@ -29,6 +30,21 @@ pub fn create_backend(
|
|||||||
cfg.chat_id,
|
cfg.chat_id,
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
ChannelType::Weixin => {
|
||||||
|
let cfg: WeixinConfig = serde_json::from_value(config.clone()).map_err(|e| {
|
||||||
|
ChatChannelError::ConfigurationInvalid(format!("Invalid Weixin config: {e}"))
|
||||||
|
})?;
|
||||||
|
if cfg.base_url.is_empty() {
|
||||||
|
return Err(ChatChannelError::ConfigurationInvalid(
|
||||||
|
"base_url is required".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Box::new(weixin::WeixinBackend::new(
|
||||||
|
channel_id,
|
||||||
|
token,
|
||||||
|
cfg.base_url,
|
||||||
|
)))
|
||||||
|
}
|
||||||
ChannelType::Lark => {
|
ChannelType::Lark => {
|
||||||
let cfg: LarkConfig = serde_json::from_value(config.clone()).map_err(|e| {
|
let cfg: LarkConfig = serde_json::from_value(config.clone()).map_err(|e| {
|
||||||
ChatChannelError::ConfigurationInvalid(format!("Invalid Lark config: {e}"))
|
ChatChannelError::ConfigurationInvalid(format!("Invalid Lark config: {e}"))
|
||||||
|
|||||||
653
src-tauri/src/chat_channel/backends/weixin.rs
Normal file
653
src-tauri/src/chat_channel/backends/weixin.rs
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use base64::{engine::general_purpose::STANDARD as B64, Engine};
|
||||||
|
use rand::Rng;
|
||||||
|
use reqwest::header::{HeaderMap, HeaderValue};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::{mpsc, Mutex};
|
||||||
|
|
||||||
|
use crate::chat_channel::error::ChatChannelError;
|
||||||
|
use crate::chat_channel::traits::ChatChannelBackend;
|
||||||
|
use crate::chat_channel::types::*;
|
||||||
|
|
||||||
|
const ILINK_BASE_URL: &str = "https://ilinkai.weixin.qq.com";
|
||||||
|
|
||||||
|
// ── QR code auth types (public, used by commands) ──
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WeixinQrcodeInfo {
|
||||||
|
pub qrcode_id: String,
|
||||||
|
pub qrcode_img_content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WeixinQrcodeStatus {
|
||||||
|
pub status: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub bot_token: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── QR code auth functions (called before backend exists) ──
|
||||||
|
|
||||||
|
pub async fn weixin_get_qrcode() -> Result<WeixinQrcodeInfo, ChatChannelError> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.get(format!("{ILINK_BASE_URL}/ilink/bot/get_bot_qrcode"))
|
||||||
|
.query(&[("bot_type", "3")])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ChatChannelError::ConnectionFailed(format!("QR code request failed: {e}")))?;
|
||||||
|
|
||||||
|
let body: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ChatChannelError::ConnectionFailed(format!("QR code parse failed: {e}")))?;
|
||||||
|
|
||||||
|
let qrcode_id = body
|
||||||
|
.get("qrcode")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
let raw_img = body
|
||||||
|
.get("qrcode_img_content")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if qrcode_id.is_empty() {
|
||||||
|
return Err(ChatChannelError::ConnectionFailed(
|
||||||
|
"Empty qrcode in response".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the image content is a URL, fetch the actual image bytes and
|
||||||
|
// convert to a data-URI so the frontend can display it without CORS issues.
|
||||||
|
let qrcode_img_content = if raw_img.starts_with("http://") || raw_img.starts_with("https://") {
|
||||||
|
match fetch_image_as_data_uri(&client, &raw_img).await {
|
||||||
|
Ok(data_uri) => data_uri,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[Weixin] failed to proxy QR image: {e}, falling back to URL");
|
||||||
|
raw_img
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
raw_img
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(WeixinQrcodeInfo {
|
||||||
|
qrcode_id,
|
||||||
|
qrcode_img_content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch an image from a URL and return it as a `data:<mime>;base64,...` string.
|
||||||
|
async fn fetch_image_as_data_uri(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
url: &str,
|
||||||
|
) -> Result<String, ChatChannelError> {
|
||||||
|
let resp = client
|
||||||
|
.get(url)
|
||||||
|
.header(
|
||||||
|
reqwest::header::USER_AGENT,
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||||
|
)
|
||||||
|
.header(reqwest::header::REFERER, ILINK_BASE_URL)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ChatChannelError::ConnectionFailed(format!("Image fetch failed: {e}")))?;
|
||||||
|
|
||||||
|
let content_type = resp
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("image/png")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// If the server returned HTML instead of an image, bail out
|
||||||
|
if content_type.contains("text/html") || content_type.contains("text/plain") {
|
||||||
|
return Err(ChatChannelError::ConnectionFailed(format!(
|
||||||
|
"Expected image but got {content_type}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = resp
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ChatChannelError::ConnectionFailed(format!("Image read failed: {e}")))?;
|
||||||
|
|
||||||
|
let b64 = B64.encode(&bytes);
|
||||||
|
// Normalize content-type: strip parameters like charset
|
||||||
|
let mime = content_type.split(';').next().unwrap_or("image/png").trim();
|
||||||
|
Ok(format!("data:{mime};base64,{b64}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn weixin_check_qrcode(
|
||||||
|
qrcode: &str,
|
||||||
|
) -> Result<WeixinQrcodeStatus, ChatChannelError> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.get(format!("{ILINK_BASE_URL}/ilink/bot/get_qrcode_status"))
|
||||||
|
.query(&[("qrcode", qrcode)])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ChatChannelError::ConnectionFailed(format!("QR status request failed: {e}")))?;
|
||||||
|
|
||||||
|
let body: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ChatChannelError::ConnectionFailed(format!("QR status parse failed: {e}")))?;
|
||||||
|
|
||||||
|
let status = body
|
||||||
|
.get("status")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("waiting")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let bot_token = body
|
||||||
|
.get("bot_token")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
let base_url = body
|
||||||
|
.get("baseurl")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
Ok(WeixinQrcodeStatus {
|
||||||
|
status,
|
||||||
|
bot_token,
|
||||||
|
base_url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Backend implementation ──
|
||||||
|
|
||||||
|
struct WeixinReplyContext {
|
||||||
|
to_user_id: String,
|
||||||
|
context_token: String,
|
||||||
|
expired: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WeixinBackend {
|
||||||
|
bot_token: String,
|
||||||
|
base_url: String,
|
||||||
|
client: reqwest::Client,
|
||||||
|
status: Arc<Mutex<ChannelConnectionStatus>>,
|
||||||
|
channel_id: i32,
|
||||||
|
shutdown_tx: Arc<Mutex<Option<tokio::sync::watch::Sender<bool>>>>,
|
||||||
|
reply_context: Arc<Mutex<Option<WeixinReplyContext>>>,
|
||||||
|
/// Messages that failed due to expired context_token, resend on next refresh.
|
||||||
|
pending_messages: Arc<Mutex<Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WeixinBackend {
|
||||||
|
pub fn new(channel_id: i32, bot_token: String, base_url: String) -> Self {
|
||||||
|
Self {
|
||||||
|
bot_token,
|
||||||
|
base_url,
|
||||||
|
client: reqwest::Client::builder()
|
||||||
|
.connect_timeout(Duration::from_secs(10))
|
||||||
|
.timeout(Duration::from_secs(45))
|
||||||
|
.build()
|
||||||
|
.unwrap_or_default(),
|
||||||
|
status: Arc::new(Mutex::new(ChannelConnectionStatus::Disconnected)),
|
||||||
|
channel_id,
|
||||||
|
shutdown_tx: Arc::new(Mutex::new(None)),
|
||||||
|
reply_context: Arc::new(Mutex::new(None)),
|
||||||
|
pending_messages: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_headers(bot_token: &str) -> HeaderMap {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("Content-Type", HeaderValue::from_static("application/json"));
|
||||||
|
headers.insert(
|
||||||
|
"AuthorizationType",
|
||||||
|
HeaderValue::from_static("ilink_bot_token"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let uin_raw = rand::thread_rng().gen::<u32>().to_string();
|
||||||
|
let uin_b64 = B64.encode(uin_raw.as_bytes());
|
||||||
|
if let Ok(val) = HeaderValue::from_str(&uin_b64) {
|
||||||
|
headers.insert("X-WECHAT-UIN", val);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bearer = format!("Bearer {bot_token}");
|
||||||
|
if let Ok(val) = HeaderValue::from_str(&bearer) {
|
||||||
|
headers.insert("Authorization", val);
|
||||||
|
}
|
||||||
|
|
||||||
|
headers
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_text(
|
||||||
|
&self,
|
||||||
|
text: &str,
|
||||||
|
) -> Result<SentMessageId, ChatChannelError> {
|
||||||
|
// Extract context data under lock, then release
|
||||||
|
let (to_user_id, context_token, expired) = {
|
||||||
|
let guard = self.reply_context.lock().await;
|
||||||
|
let ctx = guard.as_ref().ok_or_else(|| {
|
||||||
|
ChatChannelError::SendFailed(
|
||||||
|
"No active WeChat conversation context. A user must message the bot first."
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
(
|
||||||
|
ctx.to_user_id.clone(),
|
||||||
|
ctx.context_token.clone(),
|
||||||
|
ctx.expired,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// If context is expired, buffer the message for resend on next refresh
|
||||||
|
if expired {
|
||||||
|
eprintln!(
|
||||||
|
"[Weixin] context expired, buffering message (len={})",
|
||||||
|
text.len()
|
||||||
|
);
|
||||||
|
self.pending_messages.lock().await.push(text.to_string());
|
||||||
|
return Ok(SentMessageId(String::new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let client_id = format!("codeg-{}", uuid::Uuid::new_v4());
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"msg": {
|
||||||
|
"from_user_id": "",
|
||||||
|
"to_user_id": to_user_id,
|
||||||
|
"client_id": client_id,
|
||||||
|
"message_type": 2,
|
||||||
|
"message_state": 2,
|
||||||
|
"context_token": context_token,
|
||||||
|
"item_list": [{
|
||||||
|
"type": 1,
|
||||||
|
"text_item": { "text": text }
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"base_info": { "channel_version": "1.0.2" }
|
||||||
|
});
|
||||||
|
|
||||||
|
let url = format!("{}/ilink/bot/sendmessage", self.base_url);
|
||||||
|
eprintln!(
|
||||||
|
"[Weixin] sendmessage to={to_user_id}, context_token_len={}, text_len={}",
|
||||||
|
context_token.len(),
|
||||||
|
text.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.headers(Self::build_headers(&self.bot_token))
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ChatChannelError::SendFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
let status_code = resp.status();
|
||||||
|
let resp_text = resp.text().await.unwrap_or_default();
|
||||||
|
eprintln!("[Weixin] sendmessage response: status={status_code}, body={resp_text}");
|
||||||
|
|
||||||
|
if !status_code.is_success() {
|
||||||
|
return Err(ChatChannelError::SendFailed(format!(
|
||||||
|
"HTTP {status_code}: {resp_text}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for ret errors in response (e.g. -2 = context expired)
|
||||||
|
if let Ok(resp_json) = serde_json::from_str::<serde_json::Value>(&resp_text) {
|
||||||
|
if let Some(ret) = resp_json.get("ret").and_then(|v| v.as_i64()) {
|
||||||
|
if ret != 0 {
|
||||||
|
let errmsg = resp_json
|
||||||
|
.get("errmsg")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
eprintln!("[Weixin] sendmessage ret={ret}, errmsg={errmsg}");
|
||||||
|
|
||||||
|
if ret == -2 {
|
||||||
|
// Context token expired — mark stale and buffer
|
||||||
|
if let Some(ref mut c) = *self.reply_context.lock().await {
|
||||||
|
c.expired = true;
|
||||||
|
}
|
||||||
|
self.pending_messages.lock().await.push(text.to_string());
|
||||||
|
eprintln!("[Weixin] context_token expired (ret=-2), buffered message");
|
||||||
|
return Ok(SentMessageId(String::new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Err(ChatChannelError::SendFailed(format!(
|
||||||
|
"ret={ret}: {errmsg}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(SentMessageId(String::new()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ChatChannelBackend for WeixinBackend {
|
||||||
|
fn channel_type(&self) -> ChannelType {
|
||||||
|
ChannelType::Weixin
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start(
|
||||||
|
&self,
|
||||||
|
command_tx: mpsc::Sender<IncomingCommand>,
|
||||||
|
) -> Result<(), ChatChannelError> {
|
||||||
|
*self.status.lock().await = ChannelConnectionStatus::Connecting;
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"[Weixin] start: base_url={}, token_len={}",
|
||||||
|
self.base_url,
|
||||||
|
self.bot_token.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify auth by doing a quick getupdates with empty cursor
|
||||||
|
let verify_body = serde_json::json!({
|
||||||
|
"get_updates_buf": "",
|
||||||
|
"base_info": { "channel_version": "1.0.2" }
|
||||||
|
});
|
||||||
|
let url = format!("{}/ilink/bot/getupdates", self.base_url);
|
||||||
|
eprintln!("[Weixin] verify POST {url}");
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.headers(Self::build_headers(&self.bot_token))
|
||||||
|
.json(&verify_body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
let status_code = resp.status();
|
||||||
|
let resp_text = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
eprintln!("[Weixin] verify response status={status_code}, body={resp_text}");
|
||||||
|
|
||||||
|
let verify_result: serde_json::Value =
|
||||||
|
serde_json::from_str(&resp_text).map_err(|e| {
|
||||||
|
ChatChannelError::ConnectionFailed(format!("JSON parse failed: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let ret = verify_result
|
||||||
|
.get("ret")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.unwrap_or(-1);
|
||||||
|
|
||||||
|
// The iLink API may omit the `ret` field or return non-zero on the first
|
||||||
|
// call. Always extract the cursor if present — it's needed for polling.
|
||||||
|
let initial_cursor = verify_result
|
||||||
|
.get("get_updates_buf")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if ret != 0 {
|
||||||
|
eprintln!(
|
||||||
|
"[Weixin] verify returned ret={ret}, but got cursor len={}",
|
||||||
|
initial_cursor.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
*self.status.lock().await = ChannelConnectionStatus::Connected;
|
||||||
|
|
||||||
|
// Start long-polling loop
|
||||||
|
let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false);
|
||||||
|
*self.shutdown_tx.lock().await = Some(shutdown_tx);
|
||||||
|
|
||||||
|
let client = self.client.clone();
|
||||||
|
let bot_token = self.bot_token.clone();
|
||||||
|
let base_url = self.base_url.clone();
|
||||||
|
let channel_id = self.channel_id;
|
||||||
|
let status = self.status.clone();
|
||||||
|
let reply_context = self.reply_context.clone();
|
||||||
|
let pending_messages = self.pending_messages.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut cursor = initial_cursor;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if *shutdown_rx.borrow() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"get_updates_buf": cursor,
|
||||||
|
"base_info": { "channel_version": "1.0.2" }
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = tokio::select! {
|
||||||
|
r = client
|
||||||
|
.post(format!("{base_url}/ilink/bot/getupdates"))
|
||||||
|
.headers(WeixinBackend::build_headers(&bot_token))
|
||||||
|
.json(&body)
|
||||||
|
.send() => r,
|
||||||
|
_ = shutdown_rx.changed() => break,
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(resp) => {
|
||||||
|
// Recover from error state after successful poll
|
||||||
|
{
|
||||||
|
let mut s = status.lock().await;
|
||||||
|
if *s == ChannelConnectionStatus::Error {
|
||||||
|
*s = ChannelConnectionStatus::Connected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(body) = resp.json::<serde_json::Value>().await {
|
||||||
|
let ret = body.get("ret").and_then(|v| v.as_i64());
|
||||||
|
|
||||||
|
// Always update cursor if present
|
||||||
|
if let Some(new_cursor) =
|
||||||
|
body.get("get_updates_buf").and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
if !new_cursor.is_empty() {
|
||||||
|
cursor = new_cursor.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If ret is explicitly non-zero (not just missing), log it
|
||||||
|
if let Some(r) = ret {
|
||||||
|
if r != 0 {
|
||||||
|
eprintln!("[Weixin] getupdates ret={r}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process messages
|
||||||
|
if let Some(msgs) = body.get("msgs").and_then(|v| v.as_array()) {
|
||||||
|
if !msgs.is_empty() {
|
||||||
|
eprintln!("[Weixin] got {} message(s)", msgs.len());
|
||||||
|
}
|
||||||
|
for msg in msgs {
|
||||||
|
// Only handle text messages (type 1 in item_list)
|
||||||
|
let text = msg
|
||||||
|
.get("item_list")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.and_then(|items| {
|
||||||
|
items.iter().find_map(|item| {
|
||||||
|
let t =
|
||||||
|
item.get("type").and_then(|v| v.as_i64())?;
|
||||||
|
if t == 1 {
|
||||||
|
item.pointer("/text_item/text")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let text = match text {
|
||||||
|
Some(t) if !t.is_empty() => t,
|
||||||
|
_ => {
|
||||||
|
eprintln!("[Weixin] skipped non-text message");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let from_user_id = msg
|
||||||
|
.get("from_user_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let context_token = msg
|
||||||
|
.get("context_token")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Store reply context for outbound messages
|
||||||
|
if !from_user_id.is_empty() && !context_token.is_empty() {
|
||||||
|
let was_expired = reply_context
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.as_ref()
|
||||||
|
.map(|c| c.expired)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
*reply_context.lock().await = Some(WeixinReplyContext {
|
||||||
|
to_user_id: from_user_id.to_string(),
|
||||||
|
context_token: context_token.to_string(),
|
||||||
|
expired: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resend buffered messages with fresh context
|
||||||
|
if was_expired {
|
||||||
|
let buffered: Vec<String> =
|
||||||
|
pending_messages.lock().await.drain(..).collect();
|
||||||
|
if !buffered.is_empty() {
|
||||||
|
eprintln!(
|
||||||
|
"[Weixin] context refreshed, resending {} buffered message(s)",
|
||||||
|
buffered.len()
|
||||||
|
);
|
||||||
|
for pending_text in &buffered {
|
||||||
|
let cid =
|
||||||
|
format!("codeg-{}", uuid::Uuid::new_v4());
|
||||||
|
let send_body = serde_json::json!({
|
||||||
|
"msg": {
|
||||||
|
"from_user_id": "",
|
||||||
|
"to_user_id": from_user_id,
|
||||||
|
"client_id": cid,
|
||||||
|
"message_type": 2,
|
||||||
|
"message_state": 2,
|
||||||
|
"context_token": context_token,
|
||||||
|
"item_list": [{
|
||||||
|
"type": 1,
|
||||||
|
"text_item": { "text": pending_text }
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"base_info": { "channel_version": "1.0.2" }
|
||||||
|
});
|
||||||
|
let _ = client
|
||||||
|
.post(format!(
|
||||||
|
"{base_url}/ilink/bot/sendmessage"
|
||||||
|
))
|
||||||
|
.headers(WeixinBackend::build_headers(
|
||||||
|
&bot_token,
|
||||||
|
))
|
||||||
|
.json(&send_body)
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("[Weixin] dispatching: {text}");
|
||||||
|
let send_result = command_tx
|
||||||
|
.send(IncomingCommand {
|
||||||
|
channel_id,
|
||||||
|
sender_id: from_user_id.to_string(),
|
||||||
|
command_text: text.to_string(),
|
||||||
|
metadata: msg.clone(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
if let Err(e) = send_result {
|
||||||
|
eprintln!("[Weixin] command_tx.send failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
eprintln!("[Weixin] failed to parse response body");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[Weixin] polling error: {e}");
|
||||||
|
*status.lock().await = ChannelConnectionStatus::Error;
|
||||||
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*status.lock().await = ChannelConnectionStatus::Disconnected;
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop(&self) -> Result<(), ChatChannelError> {
|
||||||
|
if let Some(tx) = self.shutdown_tx.lock().await.take() {
|
||||||
|
let _ = tx.send(true);
|
||||||
|
}
|
||||||
|
*self.status.lock().await = ChannelConnectionStatus::Disconnected;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn status(&self) -> ChannelConnectionStatus {
|
||||||
|
*self.status.lock().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_message(&self, text: &str) -> Result<SentMessageId, ChatChannelError> {
|
||||||
|
self.send_text(text).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_rich_message(
|
||||||
|
&self,
|
||||||
|
message: &RichMessage,
|
||||||
|
) -> Result<SentMessageId, ChatChannelError> {
|
||||||
|
let plain_text = message.to_plain_text();
|
||||||
|
self.send_text(&plain_text).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn test_connection(&self) -> Result<(), ChatChannelError> {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"get_updates_buf": "",
|
||||||
|
"base_info": { "channel_version": "1.0.2" }
|
||||||
|
});
|
||||||
|
|
||||||
|
let url = format!("{}/ilink/bot/getupdates", self.base_url);
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.headers(Self::build_headers(&self.bot_token))
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
let status_code = resp.status();
|
||||||
|
let resp_text = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
eprintln!("[Weixin] test_connection: status={status_code}, body={resp_text}");
|
||||||
|
|
||||||
|
// As long as we got a valid JSON response from the server, treat it as reachable.
|
||||||
|
// The iLink API may return ret != 0 on first empty-cursor call.
|
||||||
|
let _: serde_json::Value = serde_json::from_str(&resp_text).map_err(|e| {
|
||||||
|
ChatChannelError::ConnectionFailed(format!("Not valid JSON: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !status_code.is_success() {
|
||||||
|
return Err(ChatChannelError::AuthenticationFailed(format!(
|
||||||
|
"HTTP {status_code}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
pub enum ChannelType {
|
pub enum ChannelType {
|
||||||
Lark,
|
Lark,
|
||||||
Telegram,
|
Telegram,
|
||||||
|
Weixin,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Per-channel strong typed configs ──
|
// ── Per-channel strong typed configs ──
|
||||||
@@ -20,11 +21,17 @@ pub struct LarkConfig {
|
|||||||
pub chat_id: String,
|
pub chat_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct WeixinConfig {
|
||||||
|
pub base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for ChannelType {
|
impl std::fmt::Display for ChannelType {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
ChannelType::Lark => write!(f, "lark"),
|
ChannelType::Lark => write!(f, "lark"),
|
||||||
ChannelType::Telegram => write!(f, "telegram"),
|
ChannelType::Telegram => write!(f, "telegram"),
|
||||||
|
ChannelType::Weixin => write!(f, "weixin"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::app_error::AppCommandError;
|
use crate::app_error::AppCommandError;
|
||||||
|
use crate::chat_channel::backends::weixin::{WeixinQrcodeInfo, WeixinQrcodeStatus};
|
||||||
use crate::chat_channel::manager::ChatChannelManager;
|
use crate::chat_channel::manager::ChatChannelManager;
|
||||||
use crate::chat_channel::types::ChannelType;
|
use crate::chat_channel::types::ChannelType;
|
||||||
use crate::db::service::{chat_channel_message_log_service, chat_channel_service};
|
use crate::db::service::{chat_channel_message_log_service, chat_channel_service};
|
||||||
@@ -112,8 +113,15 @@ pub async fn connect_chat_channel_core(
|
|||||||
.with_detail(e.to_string())
|
.with_detail(e.to_string())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let token = crate::keyring_store::get_channel_token(id)
|
let token = crate::keyring_store::get_channel_token(id).ok_or_else(|| {
|
||||||
.ok_or_else(|| AppCommandError::configuration_missing("Token not set"))?;
|
eprintln!("[connect_chat_channel] channel {id}: Token not set in keyring");
|
||||||
|
AppCommandError::configuration_missing("Token not set")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"[connect_chat_channel] channel {id}: creating {channel_type} backend, config={}",
|
||||||
|
model.config_json
|
||||||
|
);
|
||||||
|
|
||||||
let backend = crate::chat_channel::backends::create_backend(id, channel_type, &config, token)
|
let backend = crate::chat_channel::backends::create_backend(id, channel_type, &config, token)
|
||||||
.map_err(AppCommandError::from)?;
|
.map_err(AppCommandError::from)?;
|
||||||
@@ -121,8 +129,12 @@ pub async fn connect_chat_channel_core(
|
|||||||
manager
|
manager
|
||||||
.add_channel(id, model.name, channel_type, backend)
|
.add_channel(id, model.name, channel_type, backend)
|
||||||
.await
|
.await
|
||||||
.map_err(AppCommandError::from)?;
|
.map_err(|e| {
|
||||||
|
eprintln!("[connect_chat_channel] channel {id}: add_channel failed: {e}");
|
||||||
|
AppCommandError::from(e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
eprintln!("[connect_chat_channel] channel {id}: connected successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,6 +337,58 @@ pub async fn set_chat_event_filter_core(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WeChat QR code auth
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub async fn weixin_get_qrcode_core() -> Result<WeixinQrcodeInfo, AppCommandError> {
|
||||||
|
crate::chat_channel::backends::weixin::weixin_get_qrcode()
|
||||||
|
.await
|
||||||
|
.map_err(AppCommandError::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn weixin_check_qrcode_core(
|
||||||
|
db: &AppDatabase,
|
||||||
|
channel_id: i32,
|
||||||
|
qrcode: &str,
|
||||||
|
) -> Result<WeixinQrcodeStatus, AppCommandError> {
|
||||||
|
let result = crate::chat_channel::backends::weixin::weixin_check_qrcode(qrcode)
|
||||||
|
.await
|
||||||
|
.map_err(AppCommandError::from)?;
|
||||||
|
|
||||||
|
// On confirmed: save token + update config with base_url
|
||||||
|
if result.status == "confirmed" {
|
||||||
|
eprintln!(
|
||||||
|
"[Weixin] QR confirmed for channel {channel_id}, bot_token={}, base_url={}",
|
||||||
|
result.bot_token.as_deref().map(|t| if t.len() > 8 { &t[..8] } else { t }).unwrap_or("None"),
|
||||||
|
result.base_url.as_deref().unwrap_or("None"),
|
||||||
|
);
|
||||||
|
if let Some(ref token) = result.bot_token {
|
||||||
|
save_chat_channel_token_core(channel_id, token)?;
|
||||||
|
eprintln!("[Weixin] Token saved for channel {channel_id}");
|
||||||
|
} else {
|
||||||
|
eprintln!("[Weixin] WARNING: No bot_token in confirmed response for channel {channel_id}");
|
||||||
|
}
|
||||||
|
if let Some(ref base_url) = result.base_url {
|
||||||
|
let config_json = serde_json::json!({ "base_url": base_url }).to_string();
|
||||||
|
update_chat_channel_core(
|
||||||
|
db,
|
||||||
|
channel_id,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(config_json),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
eprintln!("[Weixin] Config updated with base_url for channel {channel_id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tauri commands (use tauri::State for injection)
|
// Tauri commands (use tauri::State for injection)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -492,3 +556,19 @@ pub async fn set_chat_message_language(
|
|||||||
) -> Result<(), AppCommandError> {
|
) -> Result<(), AppCommandError> {
|
||||||
set_chat_message_language_core(&db, language).await
|
set_chat_message_language_core(&db, language).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "tauri-runtime")]
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn weixin_get_qrcode() -> Result<WeixinQrcodeInfo, AppCommandError> {
|
||||||
|
weixin_get_qrcode_core().await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "tauri-runtime")]
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn weixin_check_qrcode(
|
||||||
|
db: tauri::State<'_, AppDatabase>,
|
||||||
|
channel_id: i32,
|
||||||
|
qrcode: String,
|
||||||
|
) -> Result<WeixinQrcodeStatus, AppCommandError> {
|
||||||
|
weixin_check_qrcode_core(&db, channel_id, &qrcode).await
|
||||||
|
}
|
||||||
|
|||||||
@@ -389,6 +389,8 @@ mod tauri_app {
|
|||||||
chat_channel_commands::set_chat_event_filter,
|
chat_channel_commands::set_chat_event_filter,
|
||||||
chat_channel_commands::get_chat_message_language,
|
chat_channel_commands::get_chat_message_language,
|
||||||
chat_channel_commands::set_chat_message_language,
|
chat_channel_commands::set_chat_message_language,
|
||||||
|
chat_channel_commands::weixin_get_qrcode,
|
||||||
|
chat_channel_commands::weixin_check_qrcode,
|
||||||
web::start_web_server,
|
web::start_web_server,
|
||||||
web::stop_web_server,
|
web::stop_web_server,
|
||||||
web::get_web_server_status,
|
web::get_web_server_status,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use serde::Deserialize;
|
|||||||
use crate::app_error::AppCommandError;
|
use crate::app_error::AppCommandError;
|
||||||
use crate::app_state::AppState;
|
use crate::app_state::AppState;
|
||||||
use crate::commands::chat_channel as cc_commands;
|
use crate::commands::chat_channel as cc_commands;
|
||||||
|
use crate::chat_channel::backends::weixin::{WeixinQrcodeInfo, WeixinQrcodeStatus};
|
||||||
use crate::models::chat_channel::{ChannelStatusInfo, ChatChannelInfo, ChatChannelMessageLogInfo};
|
use crate::models::chat_channel::{ChannelStatusInfo, ChatChannelInfo, ChatChannelMessageLogInfo};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -247,3 +248,29 @@ pub async fn set_chat_message_language(
|
|||||||
cc_commands::set_chat_message_language_core(&state.db, params.language).await?;
|
cc_commands::set_chat_message_language_core(&state.db, params.language).await?;
|
||||||
Ok(Json(()))
|
Ok(Json(()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WeChat QR code auth
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub async fn weixin_get_qrcode() -> Result<Json<WeixinQrcodeInfo>, AppCommandError> {
|
||||||
|
let result = cc_commands::weixin_get_qrcode_core().await?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct WeixinCheckQrcodeParams {
|
||||||
|
pub channel_id: i32,
|
||||||
|
pub qrcode: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn weixin_check_qrcode(
|
||||||
|
Extension(state): Extension<Arc<AppState>>,
|
||||||
|
Json(params): Json<WeixinCheckQrcodeParams>,
|
||||||
|
) -> Result<Json<WeixinQrcodeStatus>, AppCommandError> {
|
||||||
|
let result =
|
||||||
|
cc_commands::weixin_check_qrcode_core(&state.db, params.channel_id, ¶ms.qrcode)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|||||||
@@ -200,6 +200,8 @@ pub fn build_router(state: Arc<AppState>, token: String, static_dir: std::path::
|
|||||||
.route("/set_chat_event_filter", post(handlers::chat_channel::set_chat_event_filter))
|
.route("/set_chat_event_filter", post(handlers::chat_channel::set_chat_event_filter))
|
||||||
.route("/get_chat_message_language", post(handlers::chat_channel::get_chat_message_language))
|
.route("/get_chat_message_language", post(handlers::chat_channel::get_chat_message_language))
|
||||||
.route("/set_chat_message_language", post(handlers::chat_channel::set_chat_message_language))
|
.route("/set_chat_message_language", post(handlers::chat_channel::set_chat_message_language))
|
||||||
|
.route("/weixin_get_qrcode", post(handlers::chat_channel::weixin_get_qrcode))
|
||||||
|
.route("/weixin_check_qrcode", post(handlers::chat_channel::weixin_check_qrcode))
|
||||||
// ─── Terminal ───
|
// ─── Terminal ───
|
||||||
.route("/terminal_spawn", post(handlers::terminal::terminal_spawn))
|
.route("/terminal_spawn", post(handlers::terminal::terminal_spawn))
|
||||||
.route("/terminal_write", post(handlers::terminal::terminal_write))
|
.route("/terminal_write", post(handlers::terminal::terminal_write))
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export function AddChatChannelDialog({
|
|||||||
const [token, setToken] = useState("")
|
const [token, setToken] = useState("")
|
||||||
const [chatId, setChatId] = useState("")
|
const [chatId, setChatId] = useState("")
|
||||||
const [appId, setAppId] = useState("")
|
const [appId, setAppId] = useState("")
|
||||||
|
const [baseUrl, setBaseUrl] = useState("https://ilinkai.weixin.qq.com")
|
||||||
const [dailyReportEnabled, setDailyReportEnabled] = useState(false)
|
const [dailyReportEnabled, setDailyReportEnabled] = useState(false)
|
||||||
const [dailyReportTime, setDailyReportTime] = useState("18:00")
|
const [dailyReportTime, setDailyReportTime] = useState("18:00")
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ export function AddChatChannelDialog({
|
|||||||
setToken("")
|
setToken("")
|
||||||
setChatId("")
|
setChatId("")
|
||||||
setAppId("")
|
setAppId("")
|
||||||
|
setBaseUrl("https://ilinkai.weixin.qq.com")
|
||||||
setDailyReportEnabled(false)
|
setDailyReportEnabled(false)
|
||||||
setDailyReportTime("18:00")
|
setDailyReportTime("18:00")
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -71,11 +73,11 @@ export function AddChatChannelDialog({
|
|||||||
setError(t("nameRequired"))
|
setError(t("nameRequired"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!token.trim()) {
|
if (channelType !== "weixin" && !token.trim()) {
|
||||||
setError(t("tokenRequired"))
|
setError(t("tokenRequired"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!chatId.trim()) {
|
if (channelType !== "weixin" && !chatId.trim()) {
|
||||||
setError(t("chatIdRequired"))
|
setError(t("chatIdRequired"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -84,9 +86,11 @@ export function AddChatChannelDialog({
|
|||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const configJson =
|
const configJson =
|
||||||
channelType === "lark"
|
channelType === "weixin"
|
||||||
? JSON.stringify({ app_id: appId, chat_id: chatId })
|
? JSON.stringify({ base_url: baseUrl })
|
||||||
: JSON.stringify({ chat_id: chatId })
|
: channelType === "lark"
|
||||||
|
? JSON.stringify({ app_id: appId, chat_id: chatId })
|
||||||
|
: JSON.stringify({ chat_id: chatId })
|
||||||
|
|
||||||
const channel = await createChatChannel({
|
const channel = await createChatChannel({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
@@ -97,7 +101,9 @@ export function AddChatChannelDialog({
|
|||||||
dailyReportTime: dailyReportEnabled ? dailyReportTime : null,
|
dailyReportTime: dailyReportEnabled ? dailyReportTime : null,
|
||||||
})
|
})
|
||||||
|
|
||||||
await saveChatChannelToken(channel.id, token.trim())
|
if (channelType !== "weixin" && token.trim()) {
|
||||||
|
await saveChatChannelToken(channel.id, token.trim())
|
||||||
|
}
|
||||||
|
|
||||||
handleOpenChange(false)
|
handleOpenChange(false)
|
||||||
onChannelAdded()
|
onChannelAdded()
|
||||||
@@ -113,6 +119,7 @@ export function AddChatChannelDialog({
|
|||||||
chatId,
|
chatId,
|
||||||
channelType,
|
channelType,
|
||||||
appId,
|
appId,
|
||||||
|
baseUrl,
|
||||||
dailyReportEnabled,
|
dailyReportEnabled,
|
||||||
dailyReportTime,
|
dailyReportTime,
|
||||||
handleOpenChange,
|
handleOpenChange,
|
||||||
@@ -149,6 +156,7 @@ export function AddChatChannelDialog({
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="telegram">Telegram</SelectItem>
|
<SelectItem value="telegram">Telegram</SelectItem>
|
||||||
<SelectItem value="lark">{t("lark")}</SelectItem>
|
<SelectItem value="lark">{t("lark")}</SelectItem>
|
||||||
|
<SelectItem value="weixin">{t("weixin")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,30 +172,40 @@ export function AddChatChannelDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
{channelType !== "weixin" && (
|
||||||
<label className="text-xs font-medium">
|
<div className="space-y-1.5">
|
||||||
{channelType === "telegram" ? "Bot Token" : "App Secret"}
|
<label className="text-xs font-medium">
|
||||||
</label>
|
{channelType === "telegram" ? "Bot Token" : "App Secret"}
|
||||||
<Input
|
</label>
|
||||||
type="password"
|
<Input
|
||||||
value={token}
|
type="password"
|
||||||
onChange={(e) => setToken(e.target.value)}
|
value={token}
|
||||||
placeholder={
|
onChange={(e) => setToken(e.target.value)}
|
||||||
channelType === "telegram" ? "123456:ABC-DEF..." : "xxxxx"
|
placeholder={
|
||||||
}
|
channelType === "telegram" ? "123456:ABC-DEF..." : "xxxxx"
|
||||||
/>
|
}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
{channelType !== "weixin" && (
|
||||||
<label className="text-xs font-medium">Chat ID</label>
|
<div className="space-y-1.5">
|
||||||
<Input
|
<label className="text-xs font-medium">Chat ID</label>
|
||||||
value={chatId}
|
<Input
|
||||||
onChange={(e) => setChatId(e.target.value)}
|
value={chatId}
|
||||||
placeholder={
|
onChange={(e) => setChatId(e.target.value)}
|
||||||
channelType === "telegram" ? "-100123456789" : "oc_xxxxx"
|
placeholder={
|
||||||
}
|
channelType === "telegram" ? "-100123456789" : "oc_xxxxx"
|
||||||
/>
|
}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{channelType === "weixin" && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("weixinScanDescription")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-xs font-medium">{t("dailyReport")}</label>
|
<label className="text-xs font-medium">{t("dailyReport")}</label>
|
||||||
|
|||||||
@@ -37,9 +37,14 @@ import {
|
|||||||
getChatChannelStatus,
|
getChatChannelStatus,
|
||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
import { subscribe } from "@/lib/platform"
|
import { subscribe } from "@/lib/platform"
|
||||||
import type { ChatChannelInfo, ChannelStatusInfo } from "@/lib/types"
|
import type {
|
||||||
|
ChatChannelInfo,
|
||||||
|
ChannelStatusInfo,
|
||||||
|
ChannelType,
|
||||||
|
} from "@/lib/types"
|
||||||
import { AddChatChannelDialog } from "./add-chat-channel-dialog"
|
import { AddChatChannelDialog } from "./add-chat-channel-dialog"
|
||||||
import { EditChatChannelDialog } from "./edit-chat-channel-dialog"
|
import { EditChatChannelDialog } from "./edit-chat-channel-dialog"
|
||||||
|
import { WeixinQrcodeDialog } from "./weixin-qrcode-dialog"
|
||||||
|
|
||||||
export function ChannelListTab() {
|
export function ChannelListTab() {
|
||||||
const t = useTranslations("ChatChannelSettings")
|
const t = useTranslations("ChatChannelSettings")
|
||||||
@@ -50,6 +55,7 @@ export function ChannelListTab() {
|
|||||||
const [editTarget, setEditTarget] = useState<ChatChannelInfo | null>(null)
|
const [editTarget, setEditTarget] = useState<ChatChannelInfo | null>(null)
|
||||||
const [deleteTarget, setDeleteTarget] = useState<ChatChannelInfo | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<ChatChannelInfo | null>(null)
|
||||||
const [actionLoading, setActionLoading] = useState<number | null>(null)
|
const [actionLoading, setActionLoading] = useState<number | null>(null)
|
||||||
|
const [qrcodeChannelId, setQrcodeChannelId] = useState<number | null>(null)
|
||||||
|
|
||||||
const loadChannels = useCallback(async () => {
|
const loadChannels = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -114,12 +120,35 @@ export function ChannelListTab() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const handleConnect = useCallback(
|
const handleConnect = useCallback(
|
||||||
async (id: number) => {
|
async (id: number, channelType?: ChannelType) => {
|
||||||
setActionLoading(id)
|
setActionLoading(id)
|
||||||
try {
|
try {
|
||||||
await connectChatChannel(id)
|
await connectChatChannel(id)
|
||||||
toast.success(t("connectSuccess"))
|
toast.success(t("connectSuccess"))
|
||||||
await loadChannels()
|
await loadChannels()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (channelType === "weixin") {
|
||||||
|
// No token or token expired — show QR code dialog
|
||||||
|
setQrcodeChannelId(id)
|
||||||
|
} else {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
|
toast.error(t("connectFailed") + ": " + msg)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadChannels, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleWeixinAuthSuccess = useCallback(
|
||||||
|
async (channelId: number) => {
|
||||||
|
setQrcodeChannelId(null)
|
||||||
|
setActionLoading(channelId)
|
||||||
|
try {
|
||||||
|
await connectChatChannel(channelId)
|
||||||
|
toast.success(t("connectSuccess"))
|
||||||
|
await loadChannels()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
toast.error(t("connectFailed") + ": " + msg)
|
toast.error(t("connectFailed") + ": " + msg)
|
||||||
@@ -270,7 +299,7 @@ export function ChannelListTab() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
title={t("connect")}
|
title={t("connect")}
|
||||||
disabled={isLoading || !ch.enabled}
|
disabled={isLoading || !ch.enabled}
|
||||||
onClick={() => handleConnect(ch.id)}
|
onClick={() => handleConnect(ch.id, ch.channel_type)}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
@@ -327,6 +356,15 @@ export function ChannelListTab() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{qrcodeChannelId !== null && (
|
||||||
|
<WeixinQrcodeDialog
|
||||||
|
open
|
||||||
|
channelId={qrcodeChannelId}
|
||||||
|
onOpenChange={(open) => !open && setQrcodeChannelId(null)}
|
||||||
|
onAuthSuccess={handleWeixinAuthSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={!!deleteTarget}
|
open={!!deleteTarget}
|
||||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export function EditChatChannelDialog({
|
|||||||
const [token, setToken] = useState("")
|
const [token, setToken] = useState("")
|
||||||
const [chatId, setChatId] = useState(config.chat_id ?? "")
|
const [chatId, setChatId] = useState(config.chat_id ?? "")
|
||||||
const [appId, setAppId] = useState(config.app_id ?? "")
|
const [appId, setAppId] = useState(config.app_id ?? "")
|
||||||
|
const [baseUrl] = useState(config.base_url ?? "")
|
||||||
const [dailyReportEnabled, setDailyReportEnabled] = useState(
|
const [dailyReportEnabled, setDailyReportEnabled] = useState(
|
||||||
channel.daily_report_enabled
|
channel.daily_report_enabled
|
||||||
)
|
)
|
||||||
@@ -65,7 +66,7 @@ export function EditChatChannelDialog({
|
|||||||
setError(t("nameRequired"))
|
setError(t("nameRequired"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!chatId.trim()) {
|
if (channel.channel_type !== "weixin" && !chatId.trim()) {
|
||||||
setError(t("chatIdRequired"))
|
setError(t("chatIdRequired"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -74,9 +75,11 @@ export function EditChatChannelDialog({
|
|||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const configJson =
|
const configJson =
|
||||||
channel.channel_type === "lark"
|
channel.channel_type === "weixin"
|
||||||
? JSON.stringify({ app_id: appId, chat_id: chatId })
|
? JSON.stringify({ base_url: baseUrl })
|
||||||
: JSON.stringify({ chat_id: chatId })
|
: channel.channel_type === "lark"
|
||||||
|
? JSON.stringify({ app_id: appId, chat_id: chatId })
|
||||||
|
: JSON.stringify({ chat_id: chatId })
|
||||||
|
|
||||||
await updateChatChannel({
|
await updateChatChannel({
|
||||||
id: channel.id,
|
id: channel.id,
|
||||||
@@ -105,6 +108,7 @@ export function EditChatChannelDialog({
|
|||||||
chatId,
|
chatId,
|
||||||
channel,
|
channel,
|
||||||
appId,
|
appId,
|
||||||
|
baseUrl,
|
||||||
dailyReportEnabled,
|
dailyReportEnabled,
|
||||||
dailyReportTime,
|
dailyReportTime,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -140,32 +144,45 @@ export function EditChatChannelDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
{channel.channel_type !== "weixin" && (
|
||||||
<label className="text-xs font-medium">
|
<div className="space-y-1.5">
|
||||||
{channel.channel_type === "telegram" ? "Bot Token" : "App Secret"}
|
<label className="text-xs font-medium">
|
||||||
</label>
|
{channel.channel_type === "telegram"
|
||||||
<Input
|
? "Bot Token"
|
||||||
type="password"
|
: "App Secret"}
|
||||||
value={token}
|
</label>
|
||||||
onChange={(e) => setToken(e.target.value)}
|
<Input
|
||||||
placeholder={
|
type="password"
|
||||||
hasToken ? t("tokenPlaceholderKeep") : t("tokenRequired")
|
value={token}
|
||||||
}
|
onChange={(e) => setToken(e.target.value)}
|
||||||
/>
|
placeholder={
|
||||||
</div>
|
hasToken ? t("tokenPlaceholderKeep") : t("tokenRequired")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
{channel.channel_type !== "weixin" && (
|
||||||
<label className="text-xs font-medium">Chat ID</label>
|
<div className="space-y-1.5">
|
||||||
<Input
|
<label className="text-xs font-medium">Chat ID</label>
|
||||||
value={chatId}
|
<Input
|
||||||
onChange={(e) => setChatId(e.target.value)}
|
value={chatId}
|
||||||
placeholder={
|
onChange={(e) => setChatId(e.target.value)}
|
||||||
channel.channel_type === "telegram"
|
placeholder={
|
||||||
? "-100123456789"
|
channel.channel_type === "telegram"
|
||||||
: "oc_xxxxx"
|
? "-100123456789"
|
||||||
}
|
: "oc_xxxxx"
|
||||||
/>
|
}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{channel.channel_type === "weixin" && baseUrl && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium">Base URL</label>
|
||||||
|
<Input value={baseUrl} disabled />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-xs font-medium">{t("dailyReport")}</label>
|
<label className="text-xs font-medium">{t("dailyReport")}</label>
|
||||||
|
|||||||
206
src/components/settings/weixin-qrcode-dialog.tsx
Normal file
206
src/components/settings/weixin-qrcode-dialog.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import { ExternalLink, Loader2, RefreshCw } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { weixinGetQrcode, weixinCheckQrcode } from "@/lib/api"
|
||||||
|
|
||||||
|
interface WeixinQrcodeDialogProps {
|
||||||
|
open: boolean
|
||||||
|
channelId: number
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onAuthSuccess: (channelId: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function WeixinQrcodeContent({
|
||||||
|
channelId,
|
||||||
|
onAuthSuccess,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
channelId: number
|
||||||
|
onAuthSuccess: (channelId: number) => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("ChatChannelSettings")
|
||||||
|
const [qrcodeImg, setQrcodeImg] = useState<string | null>(null)
|
||||||
|
const [qrcodeUrl, setQrcodeUrl] = useState<string | null>(null)
|
||||||
|
const [imgFailed, setImgFailed] = useState(false)
|
||||||
|
const [qrcodeId, setQrcodeId] = useState<string | null>(null)
|
||||||
|
const [status, setStatus] = useState<"loading" | "waiting" | "expired">(
|
||||||
|
"loading"
|
||||||
|
)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
const stopPolling = useCallback(() => {
|
||||||
|
if (pollingRef.current) {
|
||||||
|
clearInterval(pollingRef.current)
|
||||||
|
pollingRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchQrcode = useCallback(async () => {
|
||||||
|
setStatus("loading")
|
||||||
|
setError(null)
|
||||||
|
setQrcodeImg(null)
|
||||||
|
setQrcodeUrl(null)
|
||||||
|
setImgFailed(false)
|
||||||
|
setQrcodeId(null)
|
||||||
|
stopPolling()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await weixinGetQrcode()
|
||||||
|
setQrcodeId(result.qrcode_id)
|
||||||
|
|
||||||
|
if (result.qrcode_img_content) {
|
||||||
|
const raw = result.qrcode_img_content
|
||||||
|
// Keep the original URL for fallback link
|
||||||
|
if (raw.startsWith("http")) {
|
||||||
|
setQrcodeUrl(raw)
|
||||||
|
}
|
||||||
|
const imgSrc = raw.startsWith("data:")
|
||||||
|
? raw
|
||||||
|
: raw.startsWith("http")
|
||||||
|
? raw
|
||||||
|
: `data:image/png;base64,${raw}`
|
||||||
|
setQrcodeImg(imgSrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("waiting")
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
|
setError(msg)
|
||||||
|
setStatus("expired")
|
||||||
|
}
|
||||||
|
}, [stopPolling])
|
||||||
|
|
||||||
|
// Fetch QR code on mount + cleanup polling on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial data fetch on mount
|
||||||
|
fetchQrcode()
|
||||||
|
return () => stopPolling()
|
||||||
|
}, [fetchQrcode, stopPolling])
|
||||||
|
|
||||||
|
// Start polling when we have a qrcodeId
|
||||||
|
useEffect(() => {
|
||||||
|
if (!qrcodeId || status !== "waiting") return
|
||||||
|
|
||||||
|
pollingRef.current = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const result = await weixinCheckQrcode(channelId, qrcodeId)
|
||||||
|
if (result.status === "confirmed") {
|
||||||
|
stopPolling()
|
||||||
|
onAuthSuccess(channelId)
|
||||||
|
onClose()
|
||||||
|
} else if (result.status === "expired") {
|
||||||
|
stopPolling()
|
||||||
|
setStatus("expired")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Polling error — keep trying
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
|
||||||
|
return () => stopPolling()
|
||||||
|
}, [qrcodeId, status, channelId, stopPolling, onAuthSuccess, onClose])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4 py-4">
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
{t("weixinScanDescription")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{status === "loading" && (
|
||||||
|
<div className="flex h-48 w-48 items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "waiting" && qrcodeImg && (
|
||||||
|
<>
|
||||||
|
{!imgFailed ? (
|
||||||
|
<>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={qrcodeImg}
|
||||||
|
alt="WeChat QR Code"
|
||||||
|
className="h-48 w-48 rounded-md"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
onError={() => setImgFailed(true)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : qrcodeUrl ? (
|
||||||
|
<a
|
||||||
|
href={qrcodeUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex h-48 w-48 flex-col items-center justify-center gap-2 rounded-md border border-dashed text-sm text-muted-foreground hover:bg-muted"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-6 w-6" />
|
||||||
|
{t("weixinOpenQrcode")}
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
<p className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
{t("weixinWaitingScan")}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "expired" && (
|
||||||
|
<>
|
||||||
|
<div className="flex h-48 w-48 items-center justify-center rounded-md bg-muted">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("weixinQrcodeExpired")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchQrcode}>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5 mr-1" />
|
||||||
|
{t("weixinRefreshQrcode")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WeixinQrcodeDialog({
|
||||||
|
open,
|
||||||
|
channelId,
|
||||||
|
onOpenChange,
|
||||||
|
onAuthSuccess,
|
||||||
|
}: WeixinQrcodeDialogProps) {
|
||||||
|
const t = useTranslations("ChatChannelSettings")
|
||||||
|
const handleClose = useCallback(() => onOpenChange(false), [onOpenChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("weixinScanTitle")}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{open && (
|
||||||
|
<WeixinQrcodeContent
|
||||||
|
channelId={channelId}
|
||||||
|
onAuthSuccess={onAuthSuccess}
|
||||||
|
onClose={handleClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1687,6 +1687,7 @@
|
|||||||
"channelNamePlaceholder": "بوت Telegram الخاص بي",
|
"channelNamePlaceholder": "بوت Telegram الخاص بي",
|
||||||
"channelType": "نوع القناة",
|
"channelType": "نوع القناة",
|
||||||
"lark": "Lark (Feishu)",
|
"lark": "Lark (Feishu)",
|
||||||
|
"weixin": "WeChat",
|
||||||
"dailyReport": "التقرير اليومي",
|
"dailyReport": "التقرير اليومي",
|
||||||
"dailyReportTime": "وقت التقرير",
|
"dailyReportTime": "وقت التقرير",
|
||||||
"nameRequired": "اسم القناة مطلوب.",
|
"nameRequired": "اسم القناة مطلوب.",
|
||||||
@@ -1713,6 +1714,12 @@
|
|||||||
"editChannel": "تعديل القناة",
|
"editChannel": "تعديل القناة",
|
||||||
"editSuccess": "تم تحديث القناة.",
|
"editSuccess": "تم تحديث القناة.",
|
||||||
"tokenPlaceholderKeep": "اتركه فارغاً للاحتفاظ بالقيمة الحالية",
|
"tokenPlaceholderKeep": "اتركه فارغاً للاحتفاظ بالقيمة الحالية",
|
||||||
|
"weixinScanTitle": "مسح رمز QR",
|
||||||
|
"weixinScanDescription": "افتح WeChat وامسح رمز QR للاتصال.",
|
||||||
|
"weixinQrcodeExpired": "انتهت صلاحية رمز QR.",
|
||||||
|
"weixinRefreshQrcode": "تحديث",
|
||||||
|
"weixinWaitingScan": "في انتظار المسح...",
|
||||||
|
"weixinOpenQrcode": "فتح رمز QR في المتصفح",
|
||||||
"connect": "اتصال",
|
"connect": "اتصال",
|
||||||
"disconnect": "قطع الاتصال",
|
"disconnect": "قطع الاتصال",
|
||||||
"test": "اختبار الاتصال",
|
"test": "اختبار الاتصال",
|
||||||
|
|||||||
@@ -1687,6 +1687,7 @@
|
|||||||
"channelNamePlaceholder": "Mein Telegram Bot",
|
"channelNamePlaceholder": "Mein Telegram Bot",
|
||||||
"channelType": "Kanaltyp",
|
"channelType": "Kanaltyp",
|
||||||
"lark": "Lark (Feishu)",
|
"lark": "Lark (Feishu)",
|
||||||
|
"weixin": "WeChat",
|
||||||
"dailyReport": "Tagesbericht",
|
"dailyReport": "Tagesbericht",
|
||||||
"dailyReportTime": "Berichtszeit",
|
"dailyReportTime": "Berichtszeit",
|
||||||
"nameRequired": "Kanalname ist erforderlich.",
|
"nameRequired": "Kanalname ist erforderlich.",
|
||||||
@@ -1713,6 +1714,12 @@
|
|||||||
"editChannel": "Kanal bearbeiten",
|
"editChannel": "Kanal bearbeiten",
|
||||||
"editSuccess": "Kanal aktualisiert.",
|
"editSuccess": "Kanal aktualisiert.",
|
||||||
"tokenPlaceholderKeep": "Leer lassen, um aktuellen Wert beizubehalten",
|
"tokenPlaceholderKeep": "Leer lassen, um aktuellen Wert beizubehalten",
|
||||||
|
"weixinScanTitle": "QR-Code scannen",
|
||||||
|
"weixinScanDescription": "Öffnen Sie WeChat und scannen Sie den QR-Code, um eine Verbindung herzustellen.",
|
||||||
|
"weixinQrcodeExpired": "QR-Code abgelaufen.",
|
||||||
|
"weixinRefreshQrcode": "Aktualisieren",
|
||||||
|
"weixinWaitingScan": "Warten auf Scan...",
|
||||||
|
"weixinOpenQrcode": "QR-Code im Browser öffnen",
|
||||||
"connect": "Verbinden",
|
"connect": "Verbinden",
|
||||||
"disconnect": "Trennen",
|
"disconnect": "Trennen",
|
||||||
"test": "Verbindung testen",
|
"test": "Verbindung testen",
|
||||||
|
|||||||
@@ -1687,6 +1687,7 @@
|
|||||||
"channelNamePlaceholder": "My Telegram Bot",
|
"channelNamePlaceholder": "My Telegram Bot",
|
||||||
"channelType": "Channel Type",
|
"channelType": "Channel Type",
|
||||||
"lark": "Lark (Feishu)",
|
"lark": "Lark (Feishu)",
|
||||||
|
"weixin": "WeChat",
|
||||||
"dailyReport": "Daily Report",
|
"dailyReport": "Daily Report",
|
||||||
"dailyReportTime": "Report Time",
|
"dailyReportTime": "Report Time",
|
||||||
"nameRequired": "Channel name is required.",
|
"nameRequired": "Channel name is required.",
|
||||||
@@ -1713,6 +1714,12 @@
|
|||||||
"editChannel": "Edit Channel",
|
"editChannel": "Edit Channel",
|
||||||
"editSuccess": "Channel updated.",
|
"editSuccess": "Channel updated.",
|
||||||
"tokenPlaceholderKeep": "Leave blank to keep current",
|
"tokenPlaceholderKeep": "Leave blank to keep current",
|
||||||
|
"weixinScanTitle": "Scan QR Code",
|
||||||
|
"weixinScanDescription": "Open WeChat and scan the QR code to connect.",
|
||||||
|
"weixinQrcodeExpired": "QR code expired.",
|
||||||
|
"weixinRefreshQrcode": "Refresh",
|
||||||
|
"weixinWaitingScan": "Waiting for scan...",
|
||||||
|
"weixinOpenQrcode": "Open QR code in browser",
|
||||||
"connect": "Connect",
|
"connect": "Connect",
|
||||||
"disconnect": "Disconnect",
|
"disconnect": "Disconnect",
|
||||||
"test": "Test Connection",
|
"test": "Test Connection",
|
||||||
|
|||||||
@@ -1687,6 +1687,7 @@
|
|||||||
"channelNamePlaceholder": "Mi bot de Telegram",
|
"channelNamePlaceholder": "Mi bot de Telegram",
|
||||||
"channelType": "Tipo de canal",
|
"channelType": "Tipo de canal",
|
||||||
"lark": "Lark (Feishu)",
|
"lark": "Lark (Feishu)",
|
||||||
|
"weixin": "WeChat",
|
||||||
"dailyReport": "Informe diario",
|
"dailyReport": "Informe diario",
|
||||||
"dailyReportTime": "Hora del informe",
|
"dailyReportTime": "Hora del informe",
|
||||||
"nameRequired": "El nombre del canal es obligatorio.",
|
"nameRequired": "El nombre del canal es obligatorio.",
|
||||||
@@ -1713,6 +1714,12 @@
|
|||||||
"editChannel": "Editar canal",
|
"editChannel": "Editar canal",
|
||||||
"editSuccess": "Canal actualizado.",
|
"editSuccess": "Canal actualizado.",
|
||||||
"tokenPlaceholderKeep": "Dejar vacío para mantener actual",
|
"tokenPlaceholderKeep": "Dejar vacío para mantener actual",
|
||||||
|
"weixinScanTitle": "Escanear código QR",
|
||||||
|
"weixinScanDescription": "Abra WeChat y escanee el código QR para conectarse.",
|
||||||
|
"weixinQrcodeExpired": "El código QR ha expirado.",
|
||||||
|
"weixinRefreshQrcode": "Actualizar",
|
||||||
|
"weixinWaitingScan": "Esperando escaneo...",
|
||||||
|
"weixinOpenQrcode": "Abrir código QR en el navegador",
|
||||||
"connect": "Conectar",
|
"connect": "Conectar",
|
||||||
"disconnect": "Desconectar",
|
"disconnect": "Desconectar",
|
||||||
"test": "Probar conexión",
|
"test": "Probar conexión",
|
||||||
|
|||||||
@@ -1687,6 +1687,7 @@
|
|||||||
"channelNamePlaceholder": "Mon bot Telegram",
|
"channelNamePlaceholder": "Mon bot Telegram",
|
||||||
"channelType": "Type de canal",
|
"channelType": "Type de canal",
|
||||||
"lark": "Lark (Feishu)",
|
"lark": "Lark (Feishu)",
|
||||||
|
"weixin": "WeChat",
|
||||||
"dailyReport": "Rapport quotidien",
|
"dailyReport": "Rapport quotidien",
|
||||||
"dailyReportTime": "Heure du rapport",
|
"dailyReportTime": "Heure du rapport",
|
||||||
"nameRequired": "Le nom du canal est requis.",
|
"nameRequired": "Le nom du canal est requis.",
|
||||||
@@ -1713,6 +1714,12 @@
|
|||||||
"editChannel": "Modifier le canal",
|
"editChannel": "Modifier le canal",
|
||||||
"editSuccess": "Canal mis à jour.",
|
"editSuccess": "Canal mis à jour.",
|
||||||
"tokenPlaceholderKeep": "Laisser vide pour conserver l'actuel",
|
"tokenPlaceholderKeep": "Laisser vide pour conserver l'actuel",
|
||||||
|
"weixinScanTitle": "Scanner le QR code",
|
||||||
|
"weixinScanDescription": "Ouvrez WeChat et scannez le QR code pour vous connecter.",
|
||||||
|
"weixinQrcodeExpired": "QR code expiré.",
|
||||||
|
"weixinRefreshQrcode": "Actualiser",
|
||||||
|
"weixinWaitingScan": "En attente du scan...",
|
||||||
|
"weixinOpenQrcode": "Ouvrir le QR code dans le navigateur",
|
||||||
"connect": "Connecter",
|
"connect": "Connecter",
|
||||||
"disconnect": "Déconnecter",
|
"disconnect": "Déconnecter",
|
||||||
"test": "Tester la connexion",
|
"test": "Tester la connexion",
|
||||||
|
|||||||
@@ -1687,6 +1687,7 @@
|
|||||||
"channelNamePlaceholder": "My Telegram Bot",
|
"channelNamePlaceholder": "My Telegram Bot",
|
||||||
"channelType": "チャンネルタイプ",
|
"channelType": "チャンネルタイプ",
|
||||||
"lark": "Lark(飛書)",
|
"lark": "Lark(飛書)",
|
||||||
|
"weixin": "WeChat",
|
||||||
"dailyReport": "デイリーレポート",
|
"dailyReport": "デイリーレポート",
|
||||||
"dailyReportTime": "送信時刻",
|
"dailyReportTime": "送信時刻",
|
||||||
"nameRequired": "チャンネル名を入力してください。",
|
"nameRequired": "チャンネル名を入力してください。",
|
||||||
@@ -1713,6 +1714,12 @@
|
|||||||
"editChannel": "チャンネルを編集",
|
"editChannel": "チャンネルを編集",
|
||||||
"editSuccess": "チャンネルを更新しました。",
|
"editSuccess": "チャンネルを更新しました。",
|
||||||
"tokenPlaceholderKeep": "空欄で現在の値を維持",
|
"tokenPlaceholderKeep": "空欄で現在の値を維持",
|
||||||
|
"weixinScanTitle": "QRコードをスキャン",
|
||||||
|
"weixinScanDescription": "WeChatを開いてQRコードをスキャンして接続してください。",
|
||||||
|
"weixinQrcodeExpired": "QRコードの有効期限が切れました。",
|
||||||
|
"weixinRefreshQrcode": "更新",
|
||||||
|
"weixinWaitingScan": "スキャン待ち...",
|
||||||
|
"weixinOpenQrcode": "ブラウザでQRコードを開く",
|
||||||
"connect": "接続",
|
"connect": "接続",
|
||||||
"disconnect": "切断",
|
"disconnect": "切断",
|
||||||
"test": "接続テスト",
|
"test": "接続テスト",
|
||||||
|
|||||||
@@ -1687,6 +1687,7 @@
|
|||||||
"channelNamePlaceholder": "내 Telegram 봇",
|
"channelNamePlaceholder": "내 Telegram 봇",
|
||||||
"channelType": "채널 유형",
|
"channelType": "채널 유형",
|
||||||
"lark": "Lark (飛書)",
|
"lark": "Lark (飛書)",
|
||||||
|
"weixin": "WeChat",
|
||||||
"dailyReport": "일일 리포트",
|
"dailyReport": "일일 리포트",
|
||||||
"dailyReportTime": "발송 시간",
|
"dailyReportTime": "발송 시간",
|
||||||
"nameRequired": "채널 이름을 입력하세요.",
|
"nameRequired": "채널 이름을 입력하세요.",
|
||||||
@@ -1713,6 +1714,12 @@
|
|||||||
"editChannel": "채널 편집",
|
"editChannel": "채널 편집",
|
||||||
"editSuccess": "채널이 업데이트되었습니다.",
|
"editSuccess": "채널이 업데이트되었습니다.",
|
||||||
"tokenPlaceholderKeep": "비워두면 현재 값 유지",
|
"tokenPlaceholderKeep": "비워두면 현재 값 유지",
|
||||||
|
"weixinScanTitle": "QR 코드 스캔",
|
||||||
|
"weixinScanDescription": "WeChat을 열고 QR 코드를 스캔하여 연결하세요.",
|
||||||
|
"weixinQrcodeExpired": "QR 코드가 만료되었습니다.",
|
||||||
|
"weixinRefreshQrcode": "새로고침",
|
||||||
|
"weixinWaitingScan": "스캔 대기 중...",
|
||||||
|
"weixinOpenQrcode": "브라우저에서 QR 코드 열기",
|
||||||
"connect": "연결",
|
"connect": "연결",
|
||||||
"disconnect": "연결 해제",
|
"disconnect": "연결 해제",
|
||||||
"test": "연결 테스트",
|
"test": "연결 테스트",
|
||||||
|
|||||||
@@ -1687,6 +1687,7 @@
|
|||||||
"channelNamePlaceholder": "Meu bot do Telegram",
|
"channelNamePlaceholder": "Meu bot do Telegram",
|
||||||
"channelType": "Tipo de canal",
|
"channelType": "Tipo de canal",
|
||||||
"lark": "Lark (Feishu)",
|
"lark": "Lark (Feishu)",
|
||||||
|
"weixin": "WeChat",
|
||||||
"dailyReport": "Relatório diário",
|
"dailyReport": "Relatório diário",
|
||||||
"dailyReportTime": "Horário do relatório",
|
"dailyReportTime": "Horário do relatório",
|
||||||
"nameRequired": "O nome do canal é obrigatório.",
|
"nameRequired": "O nome do canal é obrigatório.",
|
||||||
@@ -1713,6 +1714,12 @@
|
|||||||
"editChannel": "Editar canal",
|
"editChannel": "Editar canal",
|
||||||
"editSuccess": "Canal atualizado.",
|
"editSuccess": "Canal atualizado.",
|
||||||
"tokenPlaceholderKeep": "Deixar em branco para manter atual",
|
"tokenPlaceholderKeep": "Deixar em branco para manter atual",
|
||||||
|
"weixinScanTitle": "Escanear código QR",
|
||||||
|
"weixinScanDescription": "Abra o WeChat e escaneie o código QR para conectar.",
|
||||||
|
"weixinQrcodeExpired": "Código QR expirado.",
|
||||||
|
"weixinRefreshQrcode": "Atualizar",
|
||||||
|
"weixinWaitingScan": "Aguardando escaneamento...",
|
||||||
|
"weixinOpenQrcode": "Abrir código QR no navegador",
|
||||||
"connect": "Conectar",
|
"connect": "Conectar",
|
||||||
"disconnect": "Desconectar",
|
"disconnect": "Desconectar",
|
||||||
"test": "Testar conexão",
|
"test": "Testar conexão",
|
||||||
|
|||||||
@@ -1687,6 +1687,7 @@
|
|||||||
"channelNamePlaceholder": "我的 Telegram 机器人",
|
"channelNamePlaceholder": "我的 Telegram 机器人",
|
||||||
"channelType": "渠道类型",
|
"channelType": "渠道类型",
|
||||||
"lark": "飞书",
|
"lark": "飞书",
|
||||||
|
"weixin": "微信",
|
||||||
"dailyReport": "每日报告",
|
"dailyReport": "每日报告",
|
||||||
"dailyReportTime": "推送时间",
|
"dailyReportTime": "推送时间",
|
||||||
"nameRequired": "请输入渠道名称。",
|
"nameRequired": "请输入渠道名称。",
|
||||||
@@ -1713,6 +1714,12 @@
|
|||||||
"editChannel": "编辑渠道",
|
"editChannel": "编辑渠道",
|
||||||
"editSuccess": "渠道已更新。",
|
"editSuccess": "渠道已更新。",
|
||||||
"tokenPlaceholderKeep": "留空保持不变",
|
"tokenPlaceholderKeep": "留空保持不变",
|
||||||
|
"weixinScanTitle": "扫码登录",
|
||||||
|
"weixinScanDescription": "打开微信扫描二维码以连接。",
|
||||||
|
"weixinQrcodeExpired": "二维码已过期。",
|
||||||
|
"weixinRefreshQrcode": "刷新二维码",
|
||||||
|
"weixinWaitingScan": "等待扫码...",
|
||||||
|
"weixinOpenQrcode": "在浏览器中打开二维码",
|
||||||
"connect": "连接",
|
"connect": "连接",
|
||||||
"disconnect": "断开",
|
"disconnect": "断开",
|
||||||
"test": "测试连接",
|
"test": "测试连接",
|
||||||
|
|||||||
@@ -1687,6 +1687,7 @@
|
|||||||
"channelNamePlaceholder": "我的 Telegram 機器人",
|
"channelNamePlaceholder": "我的 Telegram 機器人",
|
||||||
"channelType": "頻道類型",
|
"channelType": "頻道類型",
|
||||||
"lark": "飛書",
|
"lark": "飛書",
|
||||||
|
"weixin": "微信",
|
||||||
"dailyReport": "每日報告",
|
"dailyReport": "每日報告",
|
||||||
"dailyReportTime": "推送時間",
|
"dailyReportTime": "推送時間",
|
||||||
"nameRequired": "請輸入頻道名稱。",
|
"nameRequired": "請輸入頻道名稱。",
|
||||||
@@ -1713,6 +1714,12 @@
|
|||||||
"editChannel": "編輯頻道",
|
"editChannel": "編輯頻道",
|
||||||
"editSuccess": "頻道已更新。",
|
"editSuccess": "頻道已更新。",
|
||||||
"tokenPlaceholderKeep": "留空保持不變",
|
"tokenPlaceholderKeep": "留空保持不變",
|
||||||
|
"weixinScanTitle": "掃碼登入",
|
||||||
|
"weixinScanDescription": "打開微信掃描二維碼以連接。",
|
||||||
|
"weixinQrcodeExpired": "二維碼已過期。",
|
||||||
|
"weixinRefreshQrcode": "重新整理",
|
||||||
|
"weixinWaitingScan": "等待掃碼...",
|
||||||
|
"weixinOpenQrcode": "在瀏覽器中打開二維碼",
|
||||||
"connect": "連線",
|
"connect": "連線",
|
||||||
"disconnect": "斷開",
|
"disconnect": "斷開",
|
||||||
"test": "測試連線",
|
"test": "測試連線",
|
||||||
|
|||||||
@@ -1440,3 +1440,23 @@ export async function getChatMessageLanguage(): Promise<string> {
|
|||||||
export async function setChatMessageLanguage(language: string): Promise<void> {
|
export async function setChatMessageLanguage(language: string): Promise<void> {
|
||||||
return getTransport().call("set_chat_message_language", { language })
|
return getTransport().call("set_chat_message_language", { language })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── WeChat QR Code Auth ───
|
||||||
|
|
||||||
|
export async function weixinGetQrcode(): Promise<{
|
||||||
|
qrcode_id: string
|
||||||
|
qrcode_img_content: string
|
||||||
|
}> {
|
||||||
|
return getTransport().call("weixin_get_qrcode")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function weixinCheckQrcode(
|
||||||
|
channelId: number,
|
||||||
|
qrcode: string
|
||||||
|
): Promise<{
|
||||||
|
status: string
|
||||||
|
bot_token?: string
|
||||||
|
base_url?: string
|
||||||
|
}> {
|
||||||
|
return getTransport().call("weixin_check_qrcode", { channelId, qrcode })
|
||||||
|
}
|
||||||
|
|||||||
@@ -850,7 +850,7 @@ export interface PreflightResult {
|
|||||||
|
|
||||||
// ─── Chat Channels ───
|
// ─── Chat Channels ───
|
||||||
|
|
||||||
export type ChannelType = "lark" | "telegram"
|
export type ChannelType = "lark" | "telegram" | "weixin"
|
||||||
|
|
||||||
export type ChannelConnectionStatus =
|
export type ChannelConnectionStatus =
|
||||||
| "connected"
|
| "connected"
|
||||||
|
|||||||
Reference in New Issue
Block a user