Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/line.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ In the LINE Developers Console β†’ **Messaging API** tab β†’ scan the QR code wi

- **1:1 chat** β€” send a message to the bot, get an AI agent response
- **Group chat** β€” add the bot to a group, it responds to all messages
- **Images** β€” send image messages to the bot (automatically compressed and resized)
- **Audio** β€” send audio messages (e.g. voice notes). They are automatically transcribed (if STT is enabled in Core) and passed to the agent as text.
- **Webhook signature validation** β€” HMAC-SHA256 via `LINE_CHANNEL_SECRET`

### Not Supported (LINE API limitations)
Expand Down
6 changes: 6 additions & 0 deletions docs/telegram.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,12 @@ explain VPC peering ← ignored in groups

DMs and replies within forum topics always trigger the agent (no @mention needed).

### File Attachments

- **Images** β€” send photos (compressed/resized automatically).
- **Documents** β€” send text-based files (e.g. `.txt`, `.csv`, `.rs`, `.py`) up to 512KB. They are passed directly to the agent as text.
- **Audio/Voice** β€” send voice notes or audio files. They are automatically transcribed (if STT is enabled in Core) and passed to the agent as text.

### Emoji reactions

The bot shows status reactions on your message as the agent works:
Expand Down
2 changes: 1 addition & 1 deletion gateway/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

370 changes: 265 additions & 105 deletions gateway/src/adapters/feishu.rs

Large diffs are not rendered by default.

112 changes: 69 additions & 43 deletions gateway/src/adapters/googlechat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,13 +231,17 @@ impl GoogleChatAdapter {
};

let formatted = markdown_to_gchat(text);
let url = format!(
"{}/{}?updateMask=text",
self.api_base, message_name
);
let url = format!("{}/{}?updateMask=text", self.api_base, message_name);
let body = serde_json::json!({ "text": formatted });

match self.client.patch(&url).bearer_auth(&token).json(&body).send().await {
match self
.client
.patch(&url)
.bearer_auth(&token)
.json(&body)
.send()
.await
{
Ok(r) if r.status().is_success() => {
tracing::trace!(message_name = %message_name, "googlechat message edited");
}
Expand All @@ -261,7 +265,8 @@ impl GoogleChatAdapter {
match reply.command.as_deref() {
Some("add_reaction") | Some("remove_reaction") | Some("create_topic") => return,
Some("edit_message") => {
self.edit_message(&reply.reply_to, &reply.content.text).await;
self.edit_message(&reply.reply_to, &reply.content.text)
.await;
return;
}
_ => {}
Expand Down Expand Up @@ -397,10 +402,7 @@ pub async fn webhook(

if let Some(ref adapter) = state.google_chat {
if let Some(ref verifier) = adapter.jwt_verifier {
let auth_header = match headers
.get("authorization")
.and_then(|v| v.to_str().ok())
{
let auth_header = match headers.get("authorization").and_then(|v| v.to_str().ok()) {
Some(h) => h,
None => {
warn!("googlechat webhook: missing authorization header");
Expand Down Expand Up @@ -466,12 +468,7 @@ pub async fn webhook(

let thread_id = msg.thread.as_ref().map(|t| t.name.clone());

let message_id = msg
.name
.rsplit('/')
.next()
.unwrap_or(&msg.name)
.to_string();
let message_id = msg.name.rsplit('/').next().unwrap_or(&msg.name).to_string();

let gw_event = GatewayEvent::new(
"googlechat",
Expand Down Expand Up @@ -559,7 +556,9 @@ impl GoogleChatTokenCache {
}

async fn refresh(&self, client: &reqwest::Client) -> Result<(String, u64), String> {
let jwt = self.build_jwt().map_err(|e| format!("JWT build error: {e}"))?;
let jwt = self
.build_jwt()
.map_err(|e| format!("JWT build error: {e}"))?;
let resp = client
.post("https://oauth2.googleapis.com/token")
.form(&[
Expand Down Expand Up @@ -612,8 +611,7 @@ impl GoogleChatTokenCache {
let key = jsonwebtoken::EncodingKey::from_rsa_pem(self.private_key.as_bytes())
.map_err(|e| format!("RSA key parse error: {e}"))?;
let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256);
jsonwebtoken::encode(&header, &claims, &key)
.map_err(|e| format!("JWT encode error: {e}"))
jsonwebtoken::encode(&header, &claims, &key).map_err(|e| format!("JWT encode error: {e}"))
}
}

Expand Down Expand Up @@ -981,7 +979,10 @@ mod tests {
let msg = payload.message.as_ref().unwrap();
assert_eq!(msg.argument_text.as_deref(), Some("hi"));
assert_eq!(msg.thread.as_ref().unwrap().name, "spaces/SP/threads/t1");
assert_eq!(payload.space.as_ref().unwrap().space_type.as_deref(), Some("ROOM"));
assert_eq!(
payload.space.as_ref().unwrap().space_type.as_deref(),
Some("ROOM")
);
}

#[test]
Expand Down Expand Up @@ -1344,15 +1345,16 @@ mod tests {

#[tokio::test]
async fn handle_reply_sends_gateway_response_success() {
use wiremock::{Mock, MockServer, ResponseTemplate};
use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};

let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex("/spaces/.*/messages"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({"name": "spaces/TEST/messages/msg_abc"}),
))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"name": "spaces/TEST/messages/msg_abc"})),
)
.mount(&mock_server)
.await;

Expand All @@ -1371,6 +1373,7 @@ mod tests {
content: Content {
content_type: "text".into(),
text: "hello".into(),
attachments: Vec::new(),
},
command: None,
request_id: Some("req_123".into()),
Expand All @@ -1388,8 +1391,8 @@ mod tests {

#[tokio::test]
async fn handle_reply_sends_failure_response_on_api_error() {
use wiremock::{Mock, MockServer, ResponseTemplate};
use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};

let mock_server = MockServer::start().await;
Mock::given(method("POST"))
Expand All @@ -1413,6 +1416,7 @@ mod tests {
content: Content {
content_type: "text".into(),
text: "hello".into(),
attachments: Vec::new(),
},
command: None,
request_id: Some("req_fail".into()),
Expand All @@ -1427,13 +1431,17 @@ mod tests {
assert!(!resp.success);
assert!(resp.message_id.is_none());
let err = resp.error.expect("error should be set on send failure");
assert!(err.contains("500"), "error should include status code, got: {}", err);
assert!(
err.contains("500"),
"error should include status code, got: {}",
err
);
}

#[tokio::test]
async fn handle_reply_empty_message_short_circuits() {
use wiremock::{Mock, MockServer, ResponseTemplate};
use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};

let mock_server = MockServer::start().await;
// Mount a mock that would fail the test if called
Expand All @@ -1459,6 +1467,7 @@ mod tests {
content: Content {
content_type: "text".into(),
text: "".into(),
attachments: Vec::new(),
},
command: None,
request_id: Some("req_empty".into()),
Expand All @@ -1467,7 +1476,10 @@ mod tests {
adapter.handle_reply(&reply, &event_tx).await;

let received = event_rx.try_recv();
assert!(received.is_ok(), "expected failure GatewayResponse for empty message");
assert!(
received.is_ok(),
"expected failure GatewayResponse for empty message"
);
let resp: GatewayResponse = serde_json::from_str(&received.unwrap()).unwrap();
assert_eq!(resp.request_id, "req_empty");
assert!(!resp.success);
Expand All @@ -1476,8 +1488,8 @@ mod tests {

#[tokio::test]
async fn handle_reply_multi_chunk_failure_includes_error() {
use wiremock::{Mock, MockServer, ResponseTemplate};
use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};

let mock_server = MockServer::start().await;
Mock::given(method("POST"))
Expand All @@ -1502,6 +1514,7 @@ mod tests {
content: Content {
content_type: "text".into(),
text: long_text,
attachments: Vec::new(),
},
command: None,
request_id: Some("req_multi_fail".into()),
Expand Down Expand Up @@ -1535,6 +1548,7 @@ mod tests {
content: Content {
content_type: "text".into(),
text: "hello".into(),
attachments: Vec::new(),
},
command: None,
request_id: Some("req_notoken".into()),
Expand All @@ -1552,15 +1566,16 @@ mod tests {

#[tokio::test]
async fn handle_reply_edit_message_does_not_send_response() {
use wiremock::{Mock, MockServer, ResponseTemplate};
use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};

let mock_server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path_regex("/spaces/.*/messages/.*"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({"name": "spaces/SP/messages/msg1"}),
))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"name": "spaces/SP/messages/msg1"})),
)
.mount(&mock_server)
.await;

Expand All @@ -1579,6 +1594,7 @@ mod tests {
content: Content {
content_type: "text".into(),
text: "updated text".into(),
attachments: Vec::new(),
},
command: Some("edit_message".into()),
request_id: None,
Expand All @@ -1592,15 +1608,16 @@ mod tests {

#[tokio::test]
async fn handle_reply_multi_chunk_sends_gateway_response() {
use wiremock::{Mock, MockServer, ResponseTemplate};
use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};

let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex("/spaces/.*/messages"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({"name": "spaces/TEST/messages/first_chunk"}),
))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"name": "spaces/TEST/messages/first_chunk"})),
)
.mount(&mock_server)
.await;

Expand All @@ -1620,6 +1637,7 @@ mod tests {
content: Content {
content_type: "text".into(),
text: long_text,
attachments: Vec::new(),
},
command: None,
request_id: Some("req_multi".into()),
Expand All @@ -1632,24 +1650,28 @@ mod tests {
let resp: GatewayResponse = serde_json::from_str(&received.unwrap()).unwrap();
assert_eq!(resp.request_id, "req_multi");
assert!(resp.success);
assert_eq!(resp.message_id, Some("spaces/TEST/messages/first_chunk".into()));
assert_eq!(
resp.message_id,
Some("spaces/TEST/messages/first_chunk".into())
);
}

#[tokio::test]
async fn handle_reply_multi_chunk_partial_failure_reports_failure() {
// Mixed success/failure: chunk 1 succeeds, subsequent chunks fail.
// Expect success=false (any chunk failure marks overall as failed),
// but message_id is still set so core has a reference.
use wiremock::{Mock, MockServer, ResponseTemplate};
use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};

let mock_server = MockServer::start().await;
// First request: 200 OK with message name
Mock::given(method("POST"))
.and(path_regex("/spaces/.*/messages"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({"name": "spaces/TEST/messages/first_chunk"}),
))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"name": "spaces/TEST/messages/first_chunk"})),
)
.up_to_n_times(1)
.mount(&mock_server)
.await;
Expand All @@ -1676,6 +1698,7 @@ mod tests {
content: Content {
content_type: "text".into(),
text: long_text,
attachments: Vec::new(),
},
command: None,
request_id: Some("req_partial".into()),
Expand All @@ -1688,7 +1711,10 @@ mod tests {
let resp: GatewayResponse = serde_json::from_str(&received.unwrap()).unwrap();
assert_eq!(resp.request_id, "req_partial");
assert!(!resp.success, "partial failure must report success=false");
assert_eq!(resp.message_id, Some("spaces/TEST/messages/first_chunk".into()));
assert_eq!(
resp.message_id,
Some("spaces/TEST/messages/first_chunk".into())
);
let err = resp.error.expect("partial failure should set error");
assert!(err.contains("500"));
}
Expand Down
Loading
Loading