Add WeChat image messaging and QR login polish

This commit is contained in:
Coffee
2026-03-26 18:59:26 +08:00
parent 5b2dbd6ea8
commit 5705366efb
22 changed files with 1655 additions and 198 deletions
+160
View File
@@ -2,6 +2,17 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -32,18 +43,87 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
@@ -83,6 +163,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -95,12 +184,28 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67"
[[package]]
name = "libc"
version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
]
[[package]]
name = "memchr"
version = "2.8.0"
@@ -113,6 +218,15 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -141,6 +255,36 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "semver"
version = "1.0.27"
@@ -216,6 +360,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -234,6 +384,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasm-encoder"
version = "0.220.1"
@@ -277,7 +433,11 @@ dependencies = [
name = "wechat-channel"
version = "0.1.0"
dependencies = [
"aes",
"base64",
"cipher",
"md-5",
"rand",
"serde",
"serde_json",
"wit-bindgen",
+4
View File
@@ -13,6 +13,10 @@ wit-bindgen = "0.36"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
base64 = "0.22"
aes = "0.8"
cipher = "0.4"
md-5 = "0.10"
rand = "0.8"
[profile.release]
opt-level = "s"
+7 -2
View File
@@ -10,8 +10,13 @@ cargo build --release --target wasm32-wasip2
WASM_PATH="target/wasm32-wasip2/release/wechat_channel.wasm"
if [ -f "$WASM_PATH" ]; then
wasm-tools component new "$WASM_PATH" -o wechat.wasm 2>/dev/null || cp "$WASM_PATH" wechat.wasm
wasm-tools strip wechat.wasm -o wechat.wasm
if command -v wasm-tools >/dev/null 2>&1; then
wasm-tools component new "$WASM_PATH" -o wechat.wasm 2>/dev/null || cp "$WASM_PATH" wechat.wasm
wasm-tools strip wechat.wasm -o wechat.wasm
else
cp "$WASM_PATH" wechat.wasm
echo "wasm-tools not found; copied raw wasm output without component conversion/strip"
fi
echo "Built: wechat.wasm ($(du -h wechat.wasm | cut -f1))"
echo ""
+89 -6
View File
@@ -3,11 +3,12 @@ use base64::Engine as _;
use crate::near::agent::channel_host;
use crate::types::{
BaseInfo, GetConfigRequest, GetConfigResponse, GetUpdatesRequest, GetUpdatesResponse,
MessageItem, OutboundWechatMessage, SendMessageRequest, SendTypingRequest, SendTypingResponse,
TextItem, WechatConfig, MESSAGE_ITEM_TEXT, MESSAGE_STATE_FINISH, MESSAGE_TYPE_BOT,
GetUploadUrlRequest, GetUploadUrlResponse, MessageItem, OutboundWechatMessage,
SendMessageRequest, SendTypingRequest, SendTypingResponse, TextItem, WechatConfig,
MESSAGE_ITEM_TEXT, MESSAGE_STATE_FINISH, MESSAGE_TYPE_BOT,
};
fn base_info() -> BaseInfo {
pub fn base_info() -> BaseInfo {
BaseInfo {
channel_version: env!("CARGO_PKG_VERSION").to_string(),
}
@@ -37,6 +38,16 @@ fn request_headers(body: &[u8]) -> String {
.to_string()
}
fn summarize_body_preview(bytes: &[u8], limit: usize) -> String {
let preview = String::from_utf8_lossy(&bytes[..bytes.len().min(limit)]);
let normalized = preview.replace(['\n', '\r'], " ");
if bytes.len() > limit {
format!("{normalized}...")
} else {
normalized
}
}
pub fn get_updates(
config: &WechatConfig,
get_updates_buf: &str,
@@ -51,6 +62,14 @@ pub fn get_updates(
"{}ilink/bot/getupdates",
ensure_trailing_slash(&config.base_url)
);
channel_host::log(
channel_host::LogLevel::Info,
&format!(
"WeChat getUpdates request: cursor_len={} timeout_ms={}",
get_updates_buf.len(),
config.long_poll_timeout_ms
),
);
let response = channel_host::http_request(
"POST",
&url,
@@ -60,13 +79,42 @@ pub fn get_updates(
)
.map_err(|e| format!("getUpdates request failed: {e}"))?;
channel_host::log(
channel_host::LogLevel::Info,
&format!(
"WeChat getUpdates response: status={} bytes={} has_image_marker={} has_aeskey_marker={} preview={}",
response.status,
response.body.len(),
response
.body
.windows(b"image_item".len())
.any(|window| window == b"image_item"),
response
.body
.windows(b"aeskey".len())
.any(|window| window == b"aeskey"),
summarize_body_preview(&response.body, 160)
),
);
if response.status != 200 {
let body = String::from_utf8_lossy(&response.body);
return Err(format!("getUpdates returned {}: {}", response.status, body));
}
serde_json::from_slice(&response.body)
.map_err(|e| format!("Failed to parse getUpdates response: {e}"))
let parsed: GetUpdatesResponse = serde_json::from_slice(&response.body)
.map_err(|e| format!("Failed to parse getUpdates response: {e}"))?;
channel_host::log(
channel_host::LogLevel::Info,
&format!(
"WeChat getUpdates parsed: ret={:?} errcode={:?} msg_count={} next_cursor_len={}",
parsed.ret,
parsed.errcode,
parsed.msgs.len(),
parsed.get_updates_buf.as_deref().unwrap_or_default().len()
),
);
Ok(parsed)
}
pub fn send_text_message(
@@ -87,13 +135,21 @@ pub fn send_text_message(
text_item: Some(TextItem {
text: text.to_string(),
}),
image_item: None,
}],
context_token: context_token.map(str::to_string),
},
base_info: base_info(),
};
let body = serde_json::to_vec(&message)
send_message_request(config, &message)
}
pub fn send_message_request(
config: &WechatConfig,
message: &SendMessageRequest,
) -> Result<(), String> {
let body = serde_json::to_vec(message)
.map_err(|e| format!("Failed to encode sendMessage request: {e}"))?;
let headers = request_headers(&body);
let url = format!(
@@ -115,6 +171,33 @@ pub fn send_text_message(
Ok(())
}
pub fn get_upload_url(
config: &WechatConfig,
request: &GetUploadUrlRequest,
) -> Result<GetUploadUrlResponse, String> {
let body = serde_json::to_vec(request)
.map_err(|e| format!("Failed to encode getUploadUrl request: {e}"))?;
let headers = request_headers(&body);
let url = format!(
"{}ilink/bot/getuploadurl",
ensure_trailing_slash(&config.base_url)
);
let response = channel_host::http_request("POST", &url, &headers, Some(&body), Some(15_000))
.map_err(|e| format!("getUploadUrl request failed: {e}"))?;
if response.status != 200 {
let body = String::from_utf8_lossy(&response.body);
return Err(format!(
"getUploadUrl returned {}: {}",
response.status, body
));
}
serde_json::from_slice(&response.body)
.map_err(|e| format!("Failed to parse getUploadUrl response: {e}"))
}
pub fn get_config(
config: &WechatConfig,
ilink_user_id: &str,
+1 -1
View File
@@ -3,4 +3,4 @@ pub const CONFIG_PATH: &str = "config.json";
pub const GET_UPDATES_BUF_PATH: &str = "state/get_updates_buf.json";
pub const CONTEXT_TOKENS_PATH: &str = "state/context_tokens.json";
pub const TYPING_TICKETS_PATH: &str = "state/typing_tickets.json";
pub const SESSION_EXPIRED_PATH: &str = "state/session_expired";
pub const PENDING_INBOUND_PATH: &str = "state/pending_inbound.json";
+313 -37
View File
@@ -1,3 +1,5 @@
use std::collections::HashSet;
wit_bindgen::generate!({
world: "sandboxed-channel",
path: "../../wit/channel.wit",
@@ -5,6 +7,7 @@ wit_bindgen::generate!({
mod api;
mod auth;
mod media;
mod state;
mod types;
@@ -16,9 +19,10 @@ use serde_json::json;
use crate::auth::TOKEN_SECRET_NAME;
use crate::state::{
clear_session_expired, load_config, load_context_tokens, load_get_updates_buf,
load_typing_tickets, mark_session_expired, persist_config, persist_context_tokens,
persist_get_updates_buf, persist_typing_tickets, session_expired, TypingTicketEntry,
load_config, load_context_tokens, load_get_updates_buf, load_pending_inbound_bundles,
load_typing_tickets, persist_config, persist_context_tokens, persist_get_updates_buf,
persist_pending_inbound_bundles, persist_typing_tickets, PendingInboundBundle,
StoredInboundAttachment, TypingTicketEntry,
};
use crate::types::{
OutboundMetadata, WechatConfig, WechatMessage, MESSAGE_ITEM_TEXT, MESSAGE_TYPE_USER,
@@ -35,12 +39,22 @@ enum WechatStatusAction {
struct WechatChannel;
fn log_channel(level: channel_host::LogLevel, message: &str) {
#[cfg(not(test))]
channel_host::log(level, message);
#[cfg(test)]
{
let _ = level;
let _ = message;
}
}
impl Guest for WechatChannel {
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
let config = serde_json::from_str::<WechatConfig>(&config_json)
.map_err(|e| format!("Failed to parse WeChat config: {e}"))?;
persist_config(&config)?;
clear_session_expired();
Ok(ChannelConfig {
display_name: "WeChat".to_string(),
@@ -63,14 +77,6 @@ impl Guest for WechatChannel {
}
fn on_poll() {
if session_expired() {
channel_host::log(
channel_host::LogLevel::Warn,
"WeChat session is marked expired; reconnect the channel to resume polling",
);
return;
}
if !channel_host::secret_exists(TOKEN_SECRET_NAME) {
channel_host::log(
channel_host::LogLevel::Warn,
@@ -82,14 +88,25 @@ impl Guest for WechatChannel {
let config = load_config();
let cursor = load_get_updates_buf();
let mut context_tokens = load_context_tokens();
let mut pending_inbound = match load_pending_inbound_bundles() {
Ok(bundles) => bundles,
Err(error) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to load WeChat pending inbound bundles: {error}"),
);
return;
}
};
let carried_pending_keys: HashSet<String> = pending_inbound.keys().cloned().collect();
let mut pending_inbound_changed = false;
match api::get_updates(&config, &cursor) {
Ok(response) => {
if response.errcode == Some(-14) {
mark_session_expired();
channel_host::log(
channel_host::LogLevel::Error,
"WeChat session expired; reconnect the channel",
"WeChat getUpdates returned errcode=-14; reconnect the channel",
);
return;
}
@@ -130,7 +147,39 @@ impl Guest for WechatChannel {
context_tokens_changed |= changed;
}
}
emit_incoming_message(message);
match incoming_bundle_from_message(&config, message) {
Ok(Some(bundle)) => {
let emitted = process_incoming_bundle(
&mut pending_inbound,
bundle,
&mut pending_inbound_changed,
);
for emitted_bundle in emitted {
emit_buffered_bundle(emitted_bundle);
}
}
Ok(None) => {}
Err(error) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to map WeChat inbound message: {error}"),
);
}
}
}
for key in carried_pending_keys {
if let Some(bundle) = pending_inbound.remove(&key) {
pending_inbound_changed = true;
log_channel(
channel_host::LogLevel::Info,
&format!(
"Flushing buffered WeChat image-only message for {} after waiting one poll cycle",
bundle.from_user_id
),
);
emit_buffered_bundle(bundle);
}
}
if context_tokens_changed {
@@ -141,6 +190,15 @@ impl Guest for WechatChannel {
);
}
}
if pending_inbound_changed {
if let Err(error) = persist_pending_inbound_bundles(&pending_inbound) {
channel_host::log(
channel_host::LogLevel::Warn,
&format!("Failed to persist WeChat pending inbound bundles: {error}"),
);
}
}
}
Err(error) => {
channel_host::log(
@@ -173,12 +231,7 @@ impl Guest for WechatChannel {
);
}
api::send_text_message(
&config,
&metadata.from_user_id,
response.content.trim(),
context_token.as_deref(),
)
send_response(&config, &metadata, &response, context_token.as_deref())
}
fn on_status(update: StatusUpdate) {
@@ -225,38 +278,186 @@ impl Guest for WechatChannel {
fn on_shutdown() {}
}
fn emit_incoming_message(message: WechatMessage) {
fn incoming_bundle_from_message(
config: &WechatConfig,
message: WechatMessage,
) -> Result<Option<PendingInboundBundle>, String> {
if message.message_type != Some(MESSAGE_TYPE_USER) {
return;
return Ok(None);
}
let Some(from_user_id) = message.from_user_id.as_deref() else {
return;
let from_user_id = match message.from_user_id.as_deref() {
Some(user_id) => user_id,
None => return Ok(None),
};
let text = extract_text(&message);
if text.trim().is_empty() {
return;
let attachments = media::extract_image_attachments(config, &message)?
.into_iter()
.map(StoredInboundAttachment::from)
.collect::<Vec<_>>();
if text.trim().is_empty() && attachments.is_empty() {
return Ok(None);
}
Ok(Some(PendingInboundBundle {
from_user_id: from_user_id.to_string(),
to_user_id: message.to_user_id,
session_id: message.session_id,
context_token: message.context_token,
message_id: message.message_id,
text,
attachments,
}))
}
fn process_incoming_bundle(
pending_inbound: &mut std::collections::HashMap<String, PendingInboundBundle>,
bundle: PendingInboundBundle,
pending_inbound_changed: &mut bool,
) -> Vec<PendingInboundBundle> {
let key = bundle.from_user_id.clone();
let bundle_has_text = !bundle.text.trim().is_empty();
let bundle_has_attachments = !bundle.attachments.is_empty();
if let Some(mut pending) = pending_inbound.remove(&key) {
*pending_inbound_changed = true;
if bundle_has_text {
let incoming_metadata = bundle.clone();
pending.text = merge_text(&pending.text, &bundle.text);
pending.attachments.extend(bundle.attachments);
merge_bundle_metadata(&mut pending, &incoming_metadata);
log_channel(
channel_host::LogLevel::Info,
&format!(
"Merged buffered WeChat attachment message with follow-up text for {}",
pending.from_user_id
),
);
return vec![pending];
}
let incoming_metadata = bundle.clone();
pending.attachments.extend(bundle.attachments);
merge_bundle_metadata(&mut pending, &incoming_metadata);
pending_inbound.insert(key, pending);
log_channel(
channel_host::LogLevel::Info,
&format!(
"Buffered additional WeChat attachment for {} while waiting for follow-up text",
bundle.from_user_id
),
);
return Vec::new();
}
if bundle_has_attachments && !bundle_has_text {
*pending_inbound_changed = true;
log_channel(
channel_host::LogLevel::Info,
&format!(
"Buffered WeChat image-only message for {} and will wait one poll cycle for follow-up text",
bundle.from_user_id
),
);
pending_inbound.insert(key, bundle);
Vec::new()
} else {
vec![bundle]
}
}
fn emit_buffered_bundle(bundle: PendingInboundBundle) {
let metadata = json!({
"from_user_id": from_user_id,
"to_user_id": message.to_user_id,
"message_id": message.message_id,
"session_id": message.session_id,
"context_token": message.context_token,
"from_user_id": bundle.from_user_id,
"to_user_id": bundle.to_user_id,
"message_id": bundle.message_id,
"session_id": bundle.session_id,
"context_token": bundle.context_token,
});
channel_host::emit_message(&EmittedMessage {
user_id: from_user_id.to_string(),
user_id: bundle.from_user_id.clone(),
user_name: None,
content: text,
thread_id: Some(format!("wechat:{from_user_id}")),
content: bundle.text,
thread_id: Some(format!("wechat:{}", bundle.from_user_id)),
metadata_json: metadata.to_string(),
attachments: Vec::new(),
attachments: bundle.attachments.into_iter().map(Into::into).collect(),
});
}
fn merge_bundle_metadata(target: &mut PendingInboundBundle, incoming: &PendingInboundBundle) {
if incoming.to_user_id.is_some() {
target.to_user_id = incoming.to_user_id.clone();
}
if incoming.session_id.is_some() {
target.session_id = incoming.session_id.clone();
}
if incoming.context_token.is_some() {
target.context_token = incoming.context_token.clone();
}
if incoming.message_id.is_some() {
target.message_id = incoming.message_id;
}
}
fn merge_text(existing: &str, incoming: &str) -> String {
let existing = existing.trim();
let incoming = incoming.trim();
match (existing.is_empty(), incoming.is_empty()) {
(true, true) => String::new(),
(true, false) => incoming.to_string(),
(false, true) => existing.to_string(),
(false, false) => format!("{existing}\n{incoming}"),
}
}
fn send_response(
config: &WechatConfig,
metadata: &OutboundMetadata,
response: &AgentResponse,
context_token: Option<&str>,
) -> Result<(), String> {
let mut remaining_text = response.content.trim().to_string();
let mut sent_attachment = false;
for attachment in &response.attachments {
if !attachment.mime_type.starts_with("image/") {
return Err(format!(
"WeChat currently supports image attachments only, got {} ({})",
attachment.filename, attachment.mime_type
));
}
let caption = if sent_attachment {
""
} else {
remaining_text.as_str()
};
media::send_image_attachment(
config,
&metadata.from_user_id,
attachment,
context_token,
caption,
)?;
sent_attachment = true;
remaining_text.clear();
}
if !remaining_text.is_empty() || !sent_attachment {
api::send_text_message(
config,
&metadata.from_user_id,
&remaining_text,
context_token,
)?;
}
Ok(())
}
fn extract_text(message: &WechatMessage) -> String {
message
.item_list
@@ -413,9 +614,37 @@ export!(WechatChannel);
#[cfg(test)]
mod tests {
use super::{classify_status_update, WechatStatusAction};
use std::collections::HashMap;
use super::{
classify_status_update, merge_text, process_incoming_bundle, PendingInboundBundle,
StoredInboundAttachment, WechatStatusAction,
};
use crate::exports::near::agent::channel::{StatusType, StatusUpdate};
fn make_bundle(user_id: &str, text: &str, image_count: usize) -> PendingInboundBundle {
PendingInboundBundle {
from_user_id: user_id.to_string(),
to_user_id: Some("bot".to_string()),
session_id: Some("session-1".to_string()),
context_token: Some("ctx-1".to_string()),
message_id: Some(1),
text: text.to_string(),
attachments: (0..image_count)
.map(|index| StoredInboundAttachment {
id: format!("att-{index}"),
mime_type: "image/jpeg".to_string(),
filename: Some(format!("photo-{index}.jpg")),
size_bytes: Some(128),
source_url: Some("https://example.com/image.jpg".to_string()),
storage_key: None,
extracted_text: None,
extras_json: "{}".to_string(),
})
.collect(),
}
}
#[test]
fn test_classify_status_update_thinking_starts_typing() {
let update = StatusUpdate {
@@ -493,4 +722,51 @@ mod tests {
assert_eq!(classify_status_update(&update), None);
}
#[test]
fn test_merge_text_joins_non_empty_segments() {
assert_eq!(merge_text("", "hello"), "hello");
assert_eq!(merge_text("look", "what is this"), "look\nwhat is this");
assert_eq!(merge_text("look", ""), "look");
}
#[test]
fn test_process_incoming_bundle_merges_buffered_image_with_follow_up_text() {
let mut pending = HashMap::new();
let mut changed = false;
let emitted = process_incoming_bundle(&mut pending, make_bundle("u1", "", 1), &mut changed);
assert!(emitted.is_empty());
assert!(changed);
assert_eq!(pending.len(), 1);
changed = false;
let emitted = process_incoming_bundle(
&mut pending,
make_bundle("u1", "What is in this image?", 0),
&mut changed,
);
assert!(changed);
assert!(pending.is_empty());
assert_eq!(emitted.len(), 1);
assert_eq!(emitted[0].text, "What is in this image?");
assert_eq!(emitted[0].attachments.len(), 1);
}
#[test]
fn test_process_incoming_bundle_emits_text_and_images_together_without_buffering() {
let mut pending = HashMap::new();
let mut changed = false;
let emitted = process_incoming_bundle(
&mut pending,
make_bundle("u1", "Look at this image", 1),
&mut changed,
);
assert!(!changed);
assert!(pending.is_empty());
assert_eq!(emitted.len(), 1);
assert_eq!(emitted[0].text, "Look at this image");
assert_eq!(emitted[0].attachments.len(), 1);
}
}
+355
View File
@@ -0,0 +1,355 @@
use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit};
use aes::Aes128;
use base64::Engine as _;
use md5::{Digest, Md5};
use rand::RngCore;
use serde_json::json;
use crate::exports::near::agent::channel::Attachment;
use crate::near::agent::channel_host::{self, InboundAttachment};
use crate::types::{
CdnMedia, ImageItem, MessageItem, SendMessageRequest, WechatConfig, MESSAGE_ITEM_IMAGE,
MESSAGE_STATE_FINISH, MESSAGE_TYPE_BOT, UPLOAD_MEDIA_TYPE_IMAGE,
};
const AES_BLOCK_SIZE: usize = 16;
#[derive(Debug, Clone)]
pub struct UploadImage {
pub download_encrypted_query_param: String,
pub aes_key_base64: String,
pub file_size_ciphertext: u64,
}
pub fn extract_image_attachments(
config: &WechatConfig,
message: &crate::types::WechatMessage,
) -> Result<Vec<InboundAttachment>, String> {
message
.item_list
.iter()
.enumerate()
.filter_map(|(index, item)| map_image_attachment(config, message, item, index).transpose())
.collect()
}
pub fn send_image_attachment(
config: &WechatConfig,
to_user_id: &str,
attachment: &Attachment,
context_token: Option<&str>,
text: &str,
) -> Result<(), String> {
if attachment.data.is_empty() {
return Err(format!(
"WeChat image attachment '{}' has no data",
attachment.filename
));
}
let upload = upload_image(config, to_user_id, attachment)?;
if !text.trim().is_empty() {
crate::api::send_text_message(config, to_user_id, text.trim(), context_token)?;
}
let request = SendMessageRequest {
msg: crate::types::OutboundWechatMessage {
from_user_id: String::new(),
to_user_id: to_user_id.to_string(),
client_id: format!("wechat-{}", channel_host::now_millis()),
message_type: MESSAGE_TYPE_BOT,
message_state: MESSAGE_STATE_FINISH,
item_list: vec![MessageItem {
r#type: Some(MESSAGE_ITEM_IMAGE),
text_item: None,
image_item: Some(ImageItem {
media: Some(CdnMedia {
encrypt_query_param: Some(upload.download_encrypted_query_param.clone()),
aes_key: Some(upload.aes_key_base64.clone()),
encrypt_type: Some(1),
}),
aeskey: None,
mid_size: Some(upload.file_size_ciphertext),
}),
}],
context_token: context_token.map(str::to_string),
},
base_info: crate::api::base_info(),
};
crate::api::send_message_request(config, &request)
}
fn map_image_attachment(
config: &WechatConfig,
message: &crate::types::WechatMessage,
item: &MessageItem,
index: usize,
) -> Result<Option<InboundAttachment>, String> {
if item.r#type != Some(MESSAGE_ITEM_IMAGE) {
return Ok(None);
}
let image = item.image_item.as_ref().ok_or_else(|| {
format!(
"WeChat image message {:?} is missing image_item payload",
message.message_id
)
})?;
let media = image.media.as_ref().ok_or_else(|| {
format!(
"WeChat image message {:?} is missing media payload",
message.message_id
)
})?;
let encrypt_query_param = media.encrypt_query_param.as_deref().ok_or_else(|| {
format!(
"WeChat image message {:?} is missing encrypt_query_param",
message.message_id
)
})?;
let message_id = message
.message_id
.ok_or_else(|| "WeChat image message is missing message_id".to_string())?;
let aes_key = preferred_image_aes_key(image, media).map(str::to_string);
Ok(Some(InboundAttachment {
id: format!("wechat-image-{}-{}", message_id, index),
mime_type: "image/jpeg".to_string(),
filename: Some(format!("wechat-image-{}-{}.jpg", message_id, index)),
size_bytes: image.mid_size,
source_url: Some(build_cdn_download_url(
&config.cdn_base_url,
encrypt_query_param,
)),
storage_key: None,
extracted_text: None,
extras_json: json!({ "wechat_aes_key": aes_key }).to_string(),
}))
}
fn preferred_image_aes_key<'a>(image: &'a ImageItem, media: &'a CdnMedia) -> Option<&'a str> {
image
.aeskey
.as_deref()
.filter(|value| !value.trim().is_empty())
.or_else(|| {
media
.aes_key
.as_deref()
.filter(|value| !value.trim().is_empty())
})
}
fn upload_image(
config: &WechatConfig,
to_user_id: &str,
attachment: &Attachment,
) -> Result<UploadImage, String> {
let plaintext = &attachment.data;
let raw_size = plaintext.len() as u64;
let raw_md5 = hex_lower(md5_bytes(plaintext));
let file_size_ciphertext = padded_size(raw_size);
let filekey = hex_lower(random_bytes(16)?);
let aes_key = random_bytes(16)?;
let aes_key_hex = hex_lower(aes_key.clone());
let upload_url = crate::api::get_upload_url(
config,
&crate::types::GetUploadUrlRequest {
filekey: filekey.clone(),
media_type: UPLOAD_MEDIA_TYPE_IMAGE,
to_user_id: to_user_id.to_string(),
rawsize: raw_size,
rawfilemd5: raw_md5,
filesize: file_size_ciphertext,
no_need_thumb: true,
aeskey: aes_key_hex,
base_info: crate::api::base_info(),
},
)?;
let upload_param = upload_url
.upload_param
.as_deref()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "WeChat getUploadUrl returned no upload_param".to_string())?;
if upload_url.thumb_upload_param.is_some() {
channel_host::log(
channel_host::LogLevel::Debug,
"WeChat image upload returned thumb_upload_param; ignoring for single-image flow",
);
}
let ciphertext = encrypt_aes_ecb_pkcs7(plaintext, &aes_key)?;
let upload_response = channel_host::http_request(
"POST",
&build_cdn_upload_url(&config.cdn_base_url, upload_param, &filekey),
r#"{"Content-Type":"application/octet-stream"}"#,
Some(&ciphertext),
Some(15_000),
)
.map_err(|e| format!("WeChat CDN upload failed: {e}"))?;
if upload_response.status != 200 {
let body = String::from_utf8_lossy(&upload_response.body);
return Err(format!(
"WeChat CDN upload returned {}: {}",
upload_response.status, body
));
}
let headers: std::collections::HashMap<String, String> =
serde_json::from_str(&upload_response.headers_json)
.map_err(|e| format!("Failed to parse WeChat CDN upload headers: {e}"))?;
let download_encrypted_query_param = headers
.iter()
.find_map(|(key, value)| {
if key.eq_ignore_ascii_case("x-encrypted-param") {
Some(value.clone())
} else {
None
}
})
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "WeChat CDN upload response missing x-encrypted-param".to_string())?;
Ok(UploadImage {
download_encrypted_query_param,
aes_key_base64: base64::engine::general_purpose::STANDARD.encode(aes_key),
file_size_ciphertext,
})
}
fn build_cdn_download_url(cdn_base_url: &str, encrypted_query_param: &str) -> String {
format!(
"{}/download?encrypted_query_param={}",
cdn_base_url.trim_end_matches('/'),
percent_encode(encrypted_query_param)
)
}
fn build_cdn_upload_url(cdn_base_url: &str, upload_param: &str, filekey: &str) -> String {
format!(
"{}/upload?encrypted_query_param={}&filekey={}",
cdn_base_url.trim_end_matches('/'),
percent_encode(upload_param),
percent_encode(filekey)
)
}
fn percent_encode(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
encoded.push(byte as char);
} else {
encoded.push('%');
encoded.push(nibble_to_hex(byte >> 4));
encoded.push(nibble_to_hex(byte & 0x0F));
}
}
encoded
}
fn nibble_to_hex(nibble: u8) -> char {
match nibble {
0..=9 => (b'0' + nibble) as char,
10..=15 => (b'A' + (nibble - 10)) as char,
_ => '0',
}
}
fn encode_hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(nibble_to_hex(byte >> 4));
out.push(nibble_to_hex(byte & 0x0F));
}
out
}
fn hex_lower(bytes: Vec<u8>) -> String {
encode_hex(&bytes).to_ascii_lowercase()
}
fn encrypt_aes_ecb_pkcs7(plaintext: &[u8], key: &[u8]) -> Result<Vec<u8>, String> {
let cipher = Aes128::new_from_slice(key).map_err(|e| format!("Invalid AES key: {e}"))?;
let mut padded = plaintext.to_vec();
let pad_len = AES_BLOCK_SIZE - (padded.len() % AES_BLOCK_SIZE);
padded.extend(std::iter::repeat_n(pad_len as u8, pad_len));
for chunk in padded.chunks_exact_mut(AES_BLOCK_SIZE) {
cipher.encrypt_block(GenericArray::from_mut_slice(chunk));
}
Ok(padded)
}
fn md5_bytes(bytes: &[u8]) -> Vec<u8> {
Md5::digest(bytes).to_vec()
}
fn random_bytes(len: usize) -> Result<Vec<u8>, String> {
let mut bytes = vec![0u8; len];
rand::rngs::OsRng.fill_bytes(&mut bytes);
if bytes.iter().all(|byte| *byte == 0) {
return Err("OS RNG returned all-zero bytes unexpectedly".to_string());
}
Ok(bytes)
}
fn padded_size(raw_size: u64) -> u64 {
((raw_size / AES_BLOCK_SIZE as u64) + 1) * AES_BLOCK_SIZE as u64
}
#[cfg(test)]
mod tests {
use super::{encode_hex, encrypt_aes_ecb_pkcs7, map_image_attachment, AES_BLOCK_SIZE};
use crate::types::{
CdnMedia, ImageItem, MessageItem, WechatConfig, WechatMessage, MESSAGE_ITEM_IMAGE,
};
#[test]
fn test_encrypt_aes_ecb_pkcs7_is_block_aligned() {
let key = [0x11u8; 16];
let plaintext = b"wechat image payload".to_vec();
let ciphertext = encrypt_aes_ecb_pkcs7(&plaintext, &key).unwrap();
assert_eq!(ciphertext.len() % AES_BLOCK_SIZE, 0);
assert_ne!(ciphertext, plaintext);
assert_eq!(
encode_hex(&ciphertext).to_ascii_lowercase(),
"a7464c94a03fb2c5aa783597a1d2f5a461f1cd5d83a7bd92721e8ac1853f881f"
);
}
#[test]
fn test_map_image_attachment_errors_when_message_id_missing() {
let config = WechatConfig::default();
let message = WechatMessage {
message_id: None,
from_user_id: Some("user-1".to_string()),
to_user_id: Some("bot-1".to_string()),
session_id: None,
message_type: None,
context_token: None,
item_list: vec![MessageItem {
r#type: Some(MESSAGE_ITEM_IMAGE),
text_item: None,
image_item: Some(ImageItem {
media: Some(CdnMedia {
encrypt_query_param: Some("enc".to_string()),
aes_key: Some("aes".to_string()),
encrypt_type: Some(1),
}),
aeskey: None,
mid_size: Some(128),
}),
}],
};
let error = map_image_attachment(&config, &message, &message.item_list[0], 0)
.expect_err("missing message_id should error");
assert!(error.contains("missing message_id"));
}
}
+88 -10
View File
@@ -3,7 +3,7 @@ use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::auth::{
CONFIG_PATH, CONTEXT_TOKENS_PATH, GET_UPDATES_BUF_PATH, SESSION_EXPIRED_PATH,
CONFIG_PATH, CONTEXT_TOKENS_PATH, GET_UPDATES_BUF_PATH, PENDING_INBOUND_PATH,
TYPING_TICKETS_PATH,
};
use crate::near::agent::channel_host;
@@ -15,6 +15,59 @@ pub struct TypingTicketEntry {
pub fetched_at_ms: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct StoredInboundAttachment {
pub id: String,
pub mime_type: String,
pub filename: Option<String>,
pub size_bytes: Option<u64>,
pub source_url: Option<String>,
pub storage_key: Option<String>,
pub extracted_text: Option<String>,
pub extras_json: String,
}
impl From<channel_host::InboundAttachment> for StoredInboundAttachment {
fn from(value: channel_host::InboundAttachment) -> Self {
Self {
id: value.id,
mime_type: value.mime_type,
filename: value.filename,
size_bytes: value.size_bytes,
source_url: value.source_url,
storage_key: value.storage_key,
extracted_text: value.extracted_text,
extras_json: value.extras_json,
}
}
}
impl From<StoredInboundAttachment> for channel_host::InboundAttachment {
fn from(value: StoredInboundAttachment) -> Self {
Self {
id: value.id,
mime_type: value.mime_type,
filename: value.filename,
size_bytes: value.size_bytes,
source_url: value.source_url,
storage_key: value.storage_key,
extracted_text: value.extracted_text,
extras_json: value.extras_json,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct PendingInboundBundle {
pub from_user_id: String,
pub to_user_id: Option<String>,
pub session_id: Option<String>,
pub context_token: Option<String>,
pub message_id: Option<i64>,
pub text: String,
pub attachments: Vec<StoredInboundAttachment>,
}
pub fn load_config() -> WechatConfig {
channel_host::workspace_read(CONFIG_PATH)
.and_then(|raw| serde_json::from_str::<WechatConfig>(&raw).ok())
@@ -63,17 +116,42 @@ pub fn persist_typing_tickets(tickets: &HashMap<String, TypingTicketEntry>) -> R
channel_host::workspace_write(TYPING_TICKETS_PATH, &serialized).map_err(|e| e.to_string())
}
pub fn session_expired() -> bool {
matches!(
channel_host::workspace_read(SESSION_EXPIRED_PATH).as_deref(),
Some("1")
)
pub fn load_pending_inbound_bundles() -> Result<HashMap<String, PendingInboundBundle>, String> {
parse_pending_inbound_bundles(channel_host::workspace_read(PENDING_INBOUND_PATH).as_deref())
}
pub fn clear_session_expired() {
let _ = channel_host::workspace_write(SESSION_EXPIRED_PATH, "0");
pub fn persist_pending_inbound_bundles(
bundles: &HashMap<String, PendingInboundBundle>,
) -> Result<(), String> {
let serialized =
serde_json::to_string(bundles).map_err(|e| format!("Failed to serialize bundles: {e}"))?;
channel_host::workspace_write(PENDING_INBOUND_PATH, &serialized).map_err(|e| e.to_string())
}
pub fn mark_session_expired() {
let _ = channel_host::workspace_write(SESSION_EXPIRED_PATH, "1");
fn parse_pending_inbound_bundles(
raw: Option<&str>,
) -> Result<HashMap<String, PendingInboundBundle>, String> {
match raw {
None => Ok(HashMap::new()),
Some(raw) => serde_json::from_str(raw)
.map_err(|e| format!("Failed to parse pending inbound bundles: {e}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_pending_inbound_bundles_missing_file_returns_empty_map() {
let bundles = parse_pending_inbound_bundles(None).expect("missing state should be empty");
assert!(bundles.is_empty());
}
#[test]
fn test_parse_pending_inbound_bundles_invalid_json_returns_error() {
let error =
parse_pending_inbound_bundles(Some("{not json")).expect_err("invalid json should err");
assert!(error.contains("Failed to parse pending inbound bundles"));
}
}
+52
View File
@@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize};
pub struct WechatConfig {
#[serde(default = "default_base_url")]
pub base_url: String,
#[serde(default = "default_cdn_base_url")]
pub cdn_base_url: String,
#[serde(default = "default_bot_type")]
pub bot_type: String,
#[serde(default = "default_poll_interval_ms")]
@@ -16,6 +18,10 @@ fn default_base_url() -> String {
"https://ilinkai.weixin.qq.com".to_string()
}
fn default_cdn_base_url() -> String {
"https://novac2c.cdn.weixin.qq.com/c2c".to_string()
}
fn default_bot_type() -> String {
"3".to_string()
}
@@ -32,6 +38,7 @@ impl Default for WechatConfig {
fn default() -> Self {
Self {
base_url: default_base_url(),
cdn_base_url: default_cdn_base_url(),
bot_type: default_bot_type(),
poll_interval_ms: default_poll_interval_ms(),
long_poll_timeout_ms: default_long_poll_timeout_ms(),
@@ -44,6 +51,19 @@ pub struct BaseInfo {
pub channel_version: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GetUploadUrlRequest {
pub filekey: String,
pub media_type: i32,
pub to_user_id: String,
pub rawsize: u64,
pub rawfilemd5: String,
pub filesize: u64,
pub no_need_thumb: bool,
pub aeskey: String,
pub base_info: BaseInfo,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GetUpdatesRequest {
pub get_updates_buf: String,
@@ -72,6 +92,14 @@ pub struct GetUpdatesResponse {
pub get_updates_buf: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GetUploadUrlResponse {
#[serde(default)]
pub upload_param: Option<String>,
#[serde(default)]
pub thumb_upload_param: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SendMessageRequest {
pub msg: OutboundWechatMessage,
@@ -140,6 +168,8 @@ pub struct MessageItem {
pub r#type: Option<i32>,
#[serde(default)]
pub text_item: Option<TextItem>,
#[serde(default)]
pub image_item: Option<ImageItem>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -147,6 +177,26 @@ pub struct TextItem {
pub text: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CdnMedia {
#[serde(default)]
pub encrypt_query_param: Option<String>,
#[serde(default)]
pub aes_key: Option<String>,
#[serde(default)]
pub encrypt_type: Option<i32>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ImageItem {
#[serde(default)]
pub media: Option<CdnMedia>,
#[serde(default)]
pub aeskey: Option<String>,
#[serde(default)]
pub mid_size: Option<u64>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct OutboundMetadata {
pub from_user_id: String,
@@ -164,5 +214,7 @@ pub const MESSAGE_TYPE_USER: i32 = 1;
pub const MESSAGE_TYPE_BOT: i32 = 2;
pub const MESSAGE_STATE_FINISH: i32 = 2;
pub const MESSAGE_ITEM_TEXT: i32 = 1;
pub const MESSAGE_ITEM_IMAGE: i32 = 2;
pub const TYPING_STATUS_TYPING: i32 = 1;
pub const TYPING_STATUS_CANCEL: i32 = 2;
pub const UPLOAD_MEDIA_TYPE_IMAGE: i32 = 1;
+3 -1
View File
@@ -17,7 +17,8 @@
"capabilities": {
"http": {
"allowlist": [
{ "host": "ilinkai.weixin.qq.com", "path_prefix": "/" }
{ "host": "ilinkai.weixin.qq.com", "path_prefix": "/" },
{ "host": "novac2c.cdn.weixin.qq.com", "path_prefix": "/c2c/" }
],
"rate_limit": {
"requests_per_minute": 60,
@@ -41,6 +42,7 @@
},
"config": {
"base_url": "https://ilinkai.weixin.qq.com",
"cdn_base_url": "https://novac2c.cdn.weixin.qq.com/c2c",
"bot_type": "3",
"poll_interval_ms": 30000,
"long_poll_timeout_ms": 35000