mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* docs(feishu): clarify webhook-only event subscription support * Update channels-src/feishu/feishu.capabilities.json Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
898 lines
29 KiB
Rust
898 lines
29 KiB
Rust
// Feishu API types have fields reserved for future use.
|
|
#![allow(dead_code)]
|
|
|
|
//! Feishu/Lark Bot channel for IronClaw.
|
|
//!
|
|
//! This WASM component implements the channel interface for handling Feishu
|
|
//! webhooks (Event Subscription v2.0) and sending messages back via the
|
|
//! Feishu/Lark Bot API. IronClaw currently does not connect to Feishu's
|
|
//! long-connection websocket subscription mode; use Event Subscription
|
|
//! webhooks for this channel.
|
|
//!
|
|
//! # Features
|
|
//!
|
|
//! - Webhook-based message receiving (Event Subscription v2.0)
|
|
//! - URL verification challenge handling
|
|
//! - Private chat (DM) support
|
|
//! - Group chat support with @mention triggering
|
|
//! - Tenant access token management (app_id + app_secret exchange)
|
|
//! - Supports both Feishu (open.feishu.cn) and Lark (open.larksuite.com)
|
|
//!
|
|
//! # Security
|
|
//!
|
|
//! - App credentials (app_id, app_secret) are injected by the host into
|
|
//! the config JSON during startup for token exchange
|
|
//! - Bearer token for API calls is obtained via token exchange and cached
|
|
//! - Verification token validated by host for webhook requests
|
|
|
|
// Generate bindings from the WIT file
|
|
wit_bindgen::generate!({
|
|
world: "sandboxed-channel",
|
|
path: "../../wit/channel.wit",
|
|
});
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
// Re-export generated types
|
|
use exports::near::agent::channel::{
|
|
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
|
|
OutgoingHttpResponse, StatusUpdate,
|
|
};
|
|
use near::agent::channel_host::{self, EmittedMessage};
|
|
|
|
// ============================================================================
|
|
// Workspace paths for cross-callback state
|
|
// ============================================================================
|
|
|
|
const OWNER_ID_PATH: &str = "owner_id";
|
|
const DM_POLICY_PATH: &str = "dm_policy";
|
|
const ALLOW_FROM_PATH: &str = "allow_from";
|
|
const API_BASE_PATH: &str = "api_base";
|
|
const APP_ID_PATH: &str = "app_id";
|
|
const APP_SECRET_PATH: &str = "app_secret";
|
|
const TOKEN_PATH: &str = "tenant_access_token";
|
|
const TOKEN_EXPIRY_PATH: &str = "token_expiry";
|
|
|
|
// ============================================================================
|
|
// Feishu API Types
|
|
// ============================================================================
|
|
|
|
/// Feishu Event Subscription v2.0 envelope.
|
|
/// https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case
|
|
#[derive(Debug, Deserialize)]
|
|
struct FeishuEvent {
|
|
/// Schema version (always "2.0" for v2 events).
|
|
#[serde(default)]
|
|
schema: Option<String>,
|
|
|
|
/// Event header with metadata.
|
|
header: Option<FeishuEventHeader>,
|
|
|
|
/// Event payload (varies by event type).
|
|
event: Option<serde_json::Value>,
|
|
|
|
/// URL verification challenge (only for initial setup).
|
|
challenge: Option<String>,
|
|
|
|
/// Token for URL verification (only for initial setup).
|
|
token: Option<String>,
|
|
|
|
/// Type field for URL verification ("url_verification").
|
|
#[serde(rename = "type")]
|
|
event_type: Option<String>,
|
|
}
|
|
|
|
/// Event header containing metadata.
|
|
#[derive(Debug, Deserialize)]
|
|
struct FeishuEventHeader {
|
|
/// Unique event ID.
|
|
event_id: String,
|
|
|
|
/// Event type (e.g., "im.message.receive_v1").
|
|
event_type: String,
|
|
|
|
/// Timestamp.
|
|
#[serde(default)]
|
|
create_time: Option<String>,
|
|
|
|
/// App ID.
|
|
#[serde(default)]
|
|
app_id: Option<String>,
|
|
|
|
/// Tenant key.
|
|
#[serde(default)]
|
|
tenant_key: Option<String>,
|
|
}
|
|
|
|
/// Message receive event payload (im.message.receive_v1).
|
|
#[derive(Debug, Deserialize)]
|
|
struct MessageReceiveEvent {
|
|
sender: FeishuSender,
|
|
message: FeishuMessage,
|
|
}
|
|
|
|
/// Sender information.
|
|
#[derive(Debug, Deserialize)]
|
|
struct FeishuSender {
|
|
sender_id: FeishuSenderId,
|
|
#[serde(default)]
|
|
sender_type: Option<String>,
|
|
#[serde(default)]
|
|
tenant_key: Option<String>,
|
|
}
|
|
|
|
/// Sender ID with multiple ID types.
|
|
#[derive(Debug, Deserialize)]
|
|
struct FeishuSenderId {
|
|
#[serde(default)]
|
|
open_id: Option<String>,
|
|
#[serde(default)]
|
|
user_id: Option<String>,
|
|
#[serde(default)]
|
|
union_id: Option<String>,
|
|
}
|
|
|
|
/// Message content.
|
|
#[derive(Debug, Deserialize)]
|
|
struct FeishuMessage {
|
|
/// Unique message ID.
|
|
message_id: String,
|
|
|
|
/// Parent message ID (for thread replies).
|
|
#[serde(default)]
|
|
parent_id: Option<String>,
|
|
|
|
/// Root message ID (for thread root).
|
|
#[serde(default)]
|
|
root_id: Option<String>,
|
|
|
|
/// Chat ID the message belongs to.
|
|
chat_id: String,
|
|
|
|
/// Chat type: "p2p" (DM) or "group".
|
|
#[serde(default)]
|
|
chat_type: Option<String>,
|
|
|
|
/// Message type: "text", "image", "post", etc.
|
|
message_type: String,
|
|
|
|
/// JSON-encoded content.
|
|
content: String,
|
|
|
|
/// Mentions in the message.
|
|
#[serde(default)]
|
|
mentions: Option<Vec<FeishuMention>>,
|
|
}
|
|
|
|
/// Mention in a message.
|
|
#[derive(Debug, Deserialize)]
|
|
struct FeishuMention {
|
|
key: String,
|
|
id: FeishuMentionId,
|
|
name: String,
|
|
#[serde(default)]
|
|
tenant_key: Option<String>,
|
|
}
|
|
|
|
/// Mention ID.
|
|
#[derive(Debug, Deserialize)]
|
|
struct FeishuMentionId {
|
|
#[serde(default)]
|
|
open_id: Option<String>,
|
|
#[serde(default)]
|
|
user_id: Option<String>,
|
|
#[serde(default)]
|
|
union_id: Option<String>,
|
|
}
|
|
|
|
/// Text message content (when message_type == "text").
|
|
#[derive(Debug, Deserialize)]
|
|
struct TextContent {
|
|
text: String,
|
|
}
|
|
|
|
/// Metadata stored for responding to messages.
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct FeishuMessageMetadata {
|
|
chat_id: String,
|
|
message_id: String,
|
|
chat_type: String,
|
|
}
|
|
|
|
/// Feishu API response wrapper.
|
|
#[derive(Debug, Deserialize)]
|
|
struct FeishuApiResponse<T> {
|
|
code: i32,
|
|
msg: String,
|
|
#[serde(default)]
|
|
data: Option<T>,
|
|
}
|
|
|
|
/// Tenant access token response (flat format).
|
|
///
|
|
/// Unlike most Feishu APIs that nest results under `data`, the
|
|
/// `/auth/v3/tenant_access_token/internal` endpoint returns `code`, `msg`,
|
|
/// `tenant_access_token`, and `expire` at the top level.
|
|
#[derive(Debug, Deserialize)]
|
|
struct TenantAccessTokenResponse {
|
|
#[serde(default)]
|
|
code: i32,
|
|
#[serde(default)]
|
|
msg: String,
|
|
tenant_access_token: String,
|
|
expire: i64,
|
|
}
|
|
|
|
/// Send message request body.
|
|
#[derive(Debug, Serialize)]
|
|
struct SendMessageBody {
|
|
receive_id: String,
|
|
msg_type: String,
|
|
content: String,
|
|
}
|
|
|
|
/// Reply message request body.
|
|
#[derive(Debug, Serialize)]
|
|
struct ReplyMessageBody {
|
|
msg_type: String,
|
|
content: String,
|
|
}
|
|
|
|
// ============================================================================
|
|
// Configuration
|
|
// ============================================================================
|
|
|
|
/// Channel configuration parsed from capabilities.json `config` section.
|
|
#[derive(Debug, Deserialize)]
|
|
struct FeishuConfig {
|
|
/// Feishu App ID (for token exchange).
|
|
app_id: Option<String>,
|
|
|
|
/// Feishu App Secret (for token exchange).
|
|
app_secret: Option<String>,
|
|
|
|
/// API base URL. Defaults to "https://open.feishu.cn" (use
|
|
/// "https://open.larksuite.com" for Lark international).
|
|
#[serde(default = "default_api_base")]
|
|
api_base: String,
|
|
|
|
/// Restrict to a single owner (open_id). If set, messages from other
|
|
/// users are silently ignored.
|
|
owner_id: Option<String>,
|
|
|
|
/// DM pairing policy: "open" or "pairing" (default).
|
|
dm_policy: Option<String>,
|
|
|
|
/// Allowed user IDs (open_id) for DM pairing.
|
|
#[serde(default)]
|
|
allow_from: Option<Vec<String>>,
|
|
}
|
|
|
|
fn default_api_base() -> String {
|
|
"https://open.feishu.cn".to_string()
|
|
}
|
|
|
|
// ============================================================================
|
|
// Channel Implementation
|
|
// ============================================================================
|
|
|
|
struct FeishuChannel;
|
|
|
|
export!(FeishuChannel);
|
|
|
|
impl Guest for FeishuChannel {
|
|
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
|
|
let config: FeishuConfig = serde_json::from_str(&config_json)
|
|
.map_err(|e| format!("Failed to parse config: {}", e))?;
|
|
|
|
channel_host::log(channel_host::LogLevel::Info, "Feishu channel starting");
|
|
|
|
// Persist config for cross-callback access.
|
|
let api_base = config.api_base.trim_end_matches('/').to_string();
|
|
let _ = channel_host::workspace_write(API_BASE_PATH, &api_base);
|
|
|
|
// Persist app credentials for token exchange in later callbacks.
|
|
// These are injected by the host from the secrets store into the
|
|
// config JSON (see setup.rs inject_channel_secrets_into_config).
|
|
if let Some(ref app_id) = config.app_id {
|
|
let _ = channel_host::workspace_write(APP_ID_PATH, app_id);
|
|
}
|
|
if let Some(ref app_secret) = config.app_secret {
|
|
let _ = channel_host::workspace_write(APP_SECRET_PATH, app_secret);
|
|
}
|
|
|
|
if let Some(owner_id) = &config.owner_id {
|
|
let _ = channel_host::workspace_write(OWNER_ID_PATH, owner_id);
|
|
channel_host::log(
|
|
channel_host::LogLevel::Info,
|
|
&format!("Owner restriction enabled: user {}", owner_id),
|
|
);
|
|
} else {
|
|
let _ = channel_host::workspace_write(OWNER_ID_PATH, "");
|
|
}
|
|
|
|
let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing").to_string();
|
|
let _ = channel_host::workspace_write(DM_POLICY_PATH, &dm_policy);
|
|
|
|
let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default())
|
|
.unwrap_or_else(|_| "[]".to_string());
|
|
let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json);
|
|
|
|
// Obtain initial tenant access token if credentials are available.
|
|
let has_credentials = config.app_id.is_some() && config.app_secret.is_some();
|
|
if has_credentials {
|
|
match obtain_tenant_token(&api_base) {
|
|
Ok(_) => {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Info,
|
|
"Tenant access token obtained successfully",
|
|
);
|
|
}
|
|
Err(e) => {
|
|
// Non-fatal: token will be obtained on first message send.
|
|
channel_host::log(
|
|
channel_host::LogLevel::Warn,
|
|
&format!("Failed to obtain initial token (will retry): {}", e),
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Warn,
|
|
"No app credentials in config; outbound messaging will fail \
|
|
unless feishu_app_id and feishu_app_secret are injected by the host",
|
|
);
|
|
}
|
|
|
|
Ok(ChannelConfig {
|
|
display_name: "Feishu".to_string(),
|
|
http_endpoints: vec![HttpEndpointConfig {
|
|
path: "/webhook/feishu".to_string(),
|
|
methods: vec!["POST".to_string()],
|
|
require_secret: false,
|
|
}],
|
|
poll: None,
|
|
})
|
|
}
|
|
|
|
fn on_http_request(req: IncomingHttpRequest) -> OutgoingHttpResponse {
|
|
// Parse the request body as UTF-8.
|
|
let body_str = match std::str::from_utf8(&req.body) {
|
|
Ok(s) => s,
|
|
Err(_) => {
|
|
return json_response(400, serde_json::json!({"error": "Invalid UTF-8 body"}));
|
|
}
|
|
};
|
|
|
|
// Parse as Feishu event envelope.
|
|
let event: FeishuEvent = match serde_json::from_str(body_str) {
|
|
Ok(e) => e,
|
|
Err(e) => {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Error,
|
|
&format!("Failed to parse Feishu event: {}", e),
|
|
);
|
|
return json_response(200, serde_json::json!({}));
|
|
}
|
|
};
|
|
|
|
// Handle URL verification challenge (initial webhook setup).
|
|
if event.event_type.as_deref() == Some("url_verification") {
|
|
if let Some(challenge) = &event.challenge {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Info,
|
|
"Handling URL verification challenge",
|
|
);
|
|
return json_response(200, serde_json::json!({ "challenge": challenge }));
|
|
}
|
|
}
|
|
|
|
// Handle v2.0 events.
|
|
if let Some(header) = &event.header {
|
|
match header.event_type.as_str() {
|
|
"im.message.receive_v1" => {
|
|
if let Some(event_data) = &event.event {
|
|
handle_message_event(event_data);
|
|
}
|
|
}
|
|
other => {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Debug,
|
|
&format!("Ignoring event type: {}", other),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Always respond 200 quickly (Feishu expects fast responses).
|
|
json_response(200, serde_json::json!({}))
|
|
}
|
|
|
|
fn on_poll() {
|
|
// Feishu uses webhooks, not polling.
|
|
}
|
|
|
|
fn on_respond(response: AgentResponse) -> Result<(), String> {
|
|
let metadata: FeishuMessageMetadata = serde_json::from_str(&response.metadata_json)
|
|
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
|
|
|
|
send_reply(&metadata.message_id, &response.content)
|
|
}
|
|
|
|
fn on_broadcast(user_id: String, response: AgentResponse) -> Result<(), String> {
|
|
send_message(&user_id, "open_id", &response.content)
|
|
}
|
|
|
|
fn on_status(_update: StatusUpdate) {
|
|
// Status updates (thinking, tool execution, etc.) are not forwarded
|
|
// to Feishu in this initial implementation.
|
|
}
|
|
|
|
fn on_shutdown() {
|
|
channel_host::log(channel_host::LogLevel::Info, "Feishu channel shutting down");
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Message Handling
|
|
// ============================================================================
|
|
|
|
/// Handle an im.message.receive_v1 event.
|
|
fn handle_message_event(event_data: &serde_json::Value) {
|
|
let msg_event: MessageReceiveEvent = match serde_json::from_value(event_data.clone()) {
|
|
Ok(e) => e,
|
|
Err(e) => {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Error,
|
|
&format!("Failed to parse message event: {}", e),
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let sender_id = msg_event
|
|
.sender
|
|
.sender_id
|
|
.open_id
|
|
.as_deref()
|
|
.unwrap_or("unknown");
|
|
|
|
// Owner restriction check.
|
|
if let Some(owner_id) = channel_host::workspace_read(OWNER_ID_PATH) {
|
|
if !owner_id.is_empty() && sender_id != owner_id {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Debug,
|
|
&format!("Ignoring message from non-owner: {}", sender_id),
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// allow_from restriction: if configured, only listed user IDs may interact.
|
|
if let Some(allow_from_json) = channel_host::workspace_read(ALLOW_FROM_PATH) {
|
|
if let Ok(allow_list) = serde_json::from_str::<Vec<String>>(&allow_from_json) {
|
|
if !allow_list.is_empty() && !allow_list.iter().any(|id| id == sender_id) {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Debug,
|
|
&format!(
|
|
"Ignoring message from user not in allow_from: {}",
|
|
sender_id
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// DM pairing check for p2p chats.
|
|
let chat_type = msg_event.message.chat_type.as_deref().unwrap_or("unknown");
|
|
|
|
if chat_type == "p2p" {
|
|
let dm_policy =
|
|
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string());
|
|
|
|
if dm_policy == "pairing" {
|
|
let sender_name = sender_id.to_string();
|
|
match channel_host::pairing_is_allowed("feishu", sender_id, Some(&sender_name)) {
|
|
Ok(true) => {}
|
|
Ok(false) => {
|
|
// Upsert a pairing request.
|
|
let meta = serde_json::json!({
|
|
"sender_id": sender_id,
|
|
"chat_id": msg_event.message.chat_id,
|
|
"chat_type": chat_type,
|
|
});
|
|
let _ = channel_host::pairing_upsert_request(
|
|
"feishu",
|
|
sender_id,
|
|
&meta.to_string(),
|
|
);
|
|
channel_host::log(
|
|
channel_host::LogLevel::Info,
|
|
&format!("Pairing request created for {}", sender_id),
|
|
);
|
|
return;
|
|
}
|
|
Err(e) => {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Error,
|
|
&format!("Pairing check failed: {}", e),
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract text content.
|
|
let text = extract_text_content(&msg_event.message);
|
|
if text.is_empty() {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Debug,
|
|
&format!(
|
|
"Ignoring non-text message type: {}",
|
|
msg_event.message.message_type
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Build metadata for responding.
|
|
let metadata = FeishuMessageMetadata {
|
|
chat_id: msg_event.message.chat_id.clone(),
|
|
message_id: msg_event.message.message_id.clone(),
|
|
chat_type: chat_type.to_string(),
|
|
};
|
|
|
|
let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string());
|
|
|
|
// Determine thread ID from reply chain.
|
|
let thread_id = msg_event
|
|
.message
|
|
.root_id
|
|
.as_deref()
|
|
.or(msg_event.message.parent_id.as_deref())
|
|
.map(|s| s.to_string());
|
|
|
|
// Emit message to the agent.
|
|
channel_host::emit_message(&EmittedMessage {
|
|
user_id: sender_id.to_string(),
|
|
user_name: None,
|
|
content: text,
|
|
thread_id,
|
|
metadata_json,
|
|
attachments: vec![],
|
|
});
|
|
}
|
|
|
|
/// Extract text content from a Feishu message.
|
|
///
|
|
/// Currently handles "text" message type. Other types (image, post, file,
|
|
/// etc.) are logged and skipped.
|
|
fn extract_text_content(message: &FeishuMessage) -> String {
|
|
match message.message_type.as_str() {
|
|
"text" => {
|
|
// Content is JSON: {"text": "hello"}
|
|
match serde_json::from_str::<TextContent>(&message.content) {
|
|
Ok(tc) => {
|
|
let mut text = tc.text;
|
|
// Strip @mention placeholders like @_user_1.
|
|
if let Some(mentions) = &message.mentions {
|
|
for mention in mentions {
|
|
text = text.replace(&mention.key, &mention.name);
|
|
}
|
|
}
|
|
text.trim().to_string()
|
|
}
|
|
Err(_) => String::new(),
|
|
}
|
|
}
|
|
_ => String::new(),
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Outbound Messaging
|
|
// ============================================================================
|
|
|
|
/// Reply to a specific message.
|
|
fn send_reply(message_id: &str, content: &str) -> Result<(), String> {
|
|
let api_base = channel_host::workspace_read(API_BASE_PATH)
|
|
.unwrap_or_else(|| "https://open.feishu.cn".to_string());
|
|
|
|
let token = get_valid_token(&api_base)?;
|
|
|
|
let url = format!("{}/open-apis/im/v1/messages/{}/reply", api_base, message_id);
|
|
|
|
let body = ReplyMessageBody {
|
|
msg_type: "text".to_string(),
|
|
content: serde_json::json!({"text": content}).to_string(),
|
|
};
|
|
|
|
let body_json =
|
|
serde_json::to_string(&body).map_err(|e| format!("Failed to serialize body: {}", e))?;
|
|
|
|
let headers = serde_json::json!({
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
"Authorization": format!("Bearer {}", token),
|
|
});
|
|
|
|
let result = channel_host::http_request(
|
|
"POST",
|
|
&url,
|
|
&headers.to_string(),
|
|
Some(body_json.as_bytes()),
|
|
Some(10_000),
|
|
);
|
|
|
|
match result {
|
|
Ok(response) => {
|
|
if response.status != 200 {
|
|
let body_str = String::from_utf8_lossy(&response.body);
|
|
return Err(format!(
|
|
"Feishu API returned {}: {}",
|
|
response.status, body_str
|
|
));
|
|
}
|
|
// Check API-level error code.
|
|
if let Ok(api_resp) =
|
|
serde_json::from_slice::<FeishuApiResponse<serde_json::Value>>(&response.body)
|
|
{
|
|
if api_resp.code != 0 {
|
|
return Err(format!(
|
|
"Feishu API error {}: {}",
|
|
api_resp.code, api_resp.msg
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
Err(e) => Err(format!("HTTP request failed: {}", e)),
|
|
}
|
|
}
|
|
|
|
/// Send a new message to a user/chat (for broadcast).
|
|
fn send_message(receive_id: &str, receive_id_type: &str, content: &str) -> Result<(), String> {
|
|
let api_base = channel_host::workspace_read(API_BASE_PATH)
|
|
.unwrap_or_else(|| "https://open.feishu.cn".to_string());
|
|
|
|
let token = get_valid_token(&api_base)?;
|
|
|
|
let url = format!(
|
|
"{}/open-apis/im/v1/messages?receive_id_type={}",
|
|
api_base, receive_id_type
|
|
);
|
|
|
|
let body = SendMessageBody {
|
|
receive_id: receive_id.to_string(),
|
|
msg_type: "text".to_string(),
|
|
content: serde_json::json!({"text": content}).to_string(),
|
|
};
|
|
|
|
let body_json =
|
|
serde_json::to_string(&body).map_err(|e| format!("Failed to serialize body: {}", e))?;
|
|
|
|
let headers = serde_json::json!({
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
"Authorization": format!("Bearer {}", token),
|
|
});
|
|
|
|
let result = channel_host::http_request(
|
|
"POST",
|
|
&url,
|
|
&headers.to_string(),
|
|
Some(body_json.as_bytes()),
|
|
Some(10_000),
|
|
);
|
|
|
|
match result {
|
|
Ok(response) => {
|
|
if response.status != 200 {
|
|
let body_str = String::from_utf8_lossy(&response.body);
|
|
return Err(format!(
|
|
"Feishu API returned {}: {}",
|
|
response.status, body_str
|
|
));
|
|
}
|
|
if let Ok(api_resp) =
|
|
serde_json::from_slice::<FeishuApiResponse<serde_json::Value>>(&response.body)
|
|
{
|
|
if api_resp.code != 0 {
|
|
return Err(format!(
|
|
"Feishu API error {}: {}",
|
|
api_resp.code, api_resp.msg
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
Err(e) => Err(format!("HTTP request failed: {}", e)),
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Token Management
|
|
// ============================================================================
|
|
|
|
/// Get a valid tenant access token, refreshing if needed.
|
|
fn get_valid_token(api_base: &str) -> Result<String, String> {
|
|
// Check cached token.
|
|
if let Some(token) = channel_host::workspace_read(TOKEN_PATH) {
|
|
if !token.is_empty() {
|
|
if let Some(expiry_str) = channel_host::workspace_read(TOKEN_EXPIRY_PATH) {
|
|
if let Ok(expiry) = expiry_str.parse::<u64>() {
|
|
let now = channel_host::now_millis();
|
|
// Refresh 5 minutes before expiry.
|
|
if now < expiry.saturating_sub(300_000) {
|
|
return Ok(token);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Token expired or missing — obtain new one.
|
|
obtain_tenant_token(api_base)
|
|
}
|
|
|
|
/// Exchange app_id + app_secret for a tenant access token.
|
|
///
|
|
/// Reads credentials from workspace storage (persisted during `on_start`
|
|
/// from config JSON injected by the host).
|
|
fn obtain_tenant_token(api_base: &str) -> Result<String, String> {
|
|
let app_id = channel_host::workspace_read(APP_ID_PATH)
|
|
.filter(|s| !s.is_empty())
|
|
.ok_or_else(|| "app_id not configured (missing from workspace)".to_string())?;
|
|
let app_secret = channel_host::workspace_read(APP_SECRET_PATH)
|
|
.filter(|s| !s.is_empty())
|
|
.ok_or_else(|| "app_secret not configured (missing from workspace)".to_string())?;
|
|
|
|
let url = format!(
|
|
"{}/open-apis/auth/v3/tenant_access_token/internal",
|
|
api_base
|
|
);
|
|
|
|
let body = serde_json::json!({
|
|
"app_id": &app_id,
|
|
"app_secret": &app_secret,
|
|
});
|
|
|
|
let headers = serde_json::json!({
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
});
|
|
|
|
let body_bytes = body.to_string();
|
|
let result = channel_host::http_request(
|
|
"POST",
|
|
&url,
|
|
&headers.to_string(),
|
|
Some(body_bytes.as_bytes()),
|
|
Some(10_000),
|
|
);
|
|
|
|
match result {
|
|
Ok(response) => {
|
|
if response.status != 200 {
|
|
let body_str = String::from_utf8_lossy(&response.body);
|
|
return Err(format!(
|
|
"Token exchange returned {}: {}",
|
|
response.status, body_str
|
|
));
|
|
}
|
|
|
|
let token_resp: TenantAccessTokenResponse = serde_json::from_slice(&response.body)
|
|
.map_err(|e| format!("Failed to parse token response: {}", e))?;
|
|
|
|
if token_resp.code != 0 {
|
|
return Err(format!(
|
|
"Token exchange error {}: {}",
|
|
token_resp.code, token_resp.msg
|
|
));
|
|
}
|
|
|
|
if token_resp.tenant_access_token.is_empty() {
|
|
return Err("Token response missing tenant_access_token".to_string());
|
|
}
|
|
|
|
if token_resp.expire <= 0 {
|
|
return Err(format!(
|
|
"Token response has invalid expire value: {}",
|
|
token_resp.expire
|
|
));
|
|
}
|
|
|
|
// Cache the token with expiry.
|
|
let now = channel_host::now_millis();
|
|
let expiry = now.saturating_add((token_resp.expire as u64).saturating_mul(1000));
|
|
|
|
let _ = channel_host::workspace_write(TOKEN_PATH, &token_resp.tenant_access_token);
|
|
let _ = channel_host::workspace_write(TOKEN_EXPIRY_PATH, &expiry.to_string());
|
|
|
|
channel_host::log(
|
|
channel_host::LogLevel::Debug,
|
|
&format!(
|
|
"Tenant access token refreshed, expires in {}s",
|
|
token_resp.expire
|
|
),
|
|
);
|
|
|
|
Ok(token_resp.tenant_access_token)
|
|
}
|
|
Err(e) => Err(format!("Token exchange request failed: {}", e)),
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helpers
|
|
// ============================================================================
|
|
|
|
/// Build a JSON HTTP response.
|
|
fn json_response(status: u16, body: serde_json::Value) -> OutgoingHttpResponse {
|
|
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
|
OutgoingHttpResponse {
|
|
status,
|
|
headers_json: serde_json::json!({
|
|
"Content-Type": "application/json",
|
|
})
|
|
.to_string(),
|
|
body: body_bytes,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parse_flat_token_response() {
|
|
let json = r#"{
|
|
"code": 0,
|
|
"msg": "ok",
|
|
"tenant_access_token": "t-abc123",
|
|
"expire": 7200
|
|
}"#;
|
|
let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap();
|
|
assert_eq!(resp.code, 0);
|
|
assert_eq!(resp.msg, "ok");
|
|
assert_eq!(resp.tenant_access_token, "t-abc123");
|
|
assert_eq!(resp.expire, 7200);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_token_response_rejects_missing_token() {
|
|
let json = r#"{"code": 0, "msg": "ok", "expire": 7200}"#;
|
|
let result: Result<TenantAccessTokenResponse, _> = serde_json::from_str(json);
|
|
assert!(result.is_err(), "should fail when tenant_access_token is missing");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_token_response_rejects_missing_expire() {
|
|
let json = r#"{"code": 0, "msg": "ok", "tenant_access_token": "t-abc"}"#;
|
|
let result: Result<TenantAccessTokenResponse, _> = serde_json::from_str(json);
|
|
assert!(result.is_err(), "should fail when expire is missing");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_token_response_defaults_code_and_msg() {
|
|
let json = r#"{"tenant_access_token": "t-abc", "expire": 3600}"#;
|
|
let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap();
|
|
assert_eq!(resp.code, 0);
|
|
assert_eq!(resp.msg, "");
|
|
assert_eq!(resp.tenant_access_token, "t-abc");
|
|
assert_eq!(resp.expire, 3600);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_token_error_response() {
|
|
let json = r#"{
|
|
"code": 10003,
|
|
"msg": "invalid app_id",
|
|
"tenant_access_token": "",
|
|
"expire": 0
|
|
}"#;
|
|
let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap();
|
|
assert_eq!(resp.code, 10003);
|
|
assert!(resp.tenant_access_token.is_empty());
|
|
}
|
|
}
|