story-kit: merge 320_story_whatsapp_business_api_integration_for_bot_commands
This commit is contained in:
@@ -17,3 +17,13 @@ enabled = false
|
|||||||
# Rooms where the bot responds to all messages (not just addressed ones).
|
# Rooms where the bot responds to all messages (not just addressed ones).
|
||||||
# This list is updated automatically when users toggle ambient mode at runtime.
|
# This list is updated automatically when users toggle ambient mode at runtime.
|
||||||
# ambient_rooms = ["!roomid:example.com"]
|
# ambient_rooms = ["!roomid:example.com"]
|
||||||
|
|
||||||
|
# ── WhatsApp Business API ──────────────────────────────────────────────
|
||||||
|
# Set transport = "whatsapp" to use WhatsApp instead of Matrix.
|
||||||
|
# The webhook endpoint will be available at /webhook/whatsapp.
|
||||||
|
# You must configure this URL in the Meta Developer Dashboard.
|
||||||
|
#
|
||||||
|
# transport = "whatsapp"
|
||||||
|
# whatsapp_phone_number_id = "123456789012345"
|
||||||
|
# whatsapp_access_token = "EAAx..."
|
||||||
|
# whatsapp_verify_token = "my-secret-verify-token"
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ use settings::SettingsApi;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::whatsapp::WhatsAppWebhookContext;
|
||||||
|
|
||||||
const DEFAULT_PORT: u16 = 3001;
|
const DEFAULT_PORT: u16 = 3001;
|
||||||
|
|
||||||
pub fn parse_port(value: Option<String>) -> u16 {
|
pub fn parse_port(value: Option<String>) -> u16 {
|
||||||
@@ -51,12 +53,15 @@ pub fn remove_port_file(path: &Path) {
|
|||||||
let _ = std::fs::remove_file(path);
|
let _ = std::fs::remove_file(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint {
|
pub fn build_routes(
|
||||||
|
ctx: AppContext,
|
||||||
|
whatsapp_ctx: Option<Arc<WhatsAppWebhookContext>>,
|
||||||
|
) -> impl poem::Endpoint {
|
||||||
let ctx_arc = std::sync::Arc::new(ctx);
|
let ctx_arc = std::sync::Arc::new(ctx);
|
||||||
|
|
||||||
let (api_service, docs_service) = build_openapi_service(ctx_arc.clone());
|
let (api_service, docs_service) = build_openapi_service(ctx_arc.clone());
|
||||||
|
|
||||||
Route::new()
|
let mut route = Route::new()
|
||||||
.nest("/api", api_service)
|
.nest("/api", api_service)
|
||||||
.nest("/docs", docs_service.swagger_ui())
|
.nest("/docs", docs_service.swagger_ui())
|
||||||
.at("/ws", get(ws::ws_handler))
|
.at("/ws", get(ws::ws_handler))
|
||||||
@@ -71,8 +76,18 @@ pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint {
|
|||||||
.at("/health", get(health::health))
|
.at("/health", get(health::health))
|
||||||
.at("/assets/*path", get(assets::embedded_asset))
|
.at("/assets/*path", get(assets::embedded_asset))
|
||||||
.at("/", get(assets::embedded_index))
|
.at("/", get(assets::embedded_index))
|
||||||
.at("/*path", get(assets::embedded_file))
|
.at("/*path", get(assets::embedded_file));
|
||||||
.data(ctx_arc)
|
|
||||||
|
if let Some(wa_ctx) = whatsapp_ctx {
|
||||||
|
route = route.at(
|
||||||
|
"/webhook/whatsapp",
|
||||||
|
get(crate::whatsapp::webhook_verify)
|
||||||
|
.post(crate::whatsapp::webhook_receive)
|
||||||
|
.data(wa_ctx),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
route.data(ctx_arc)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiTuple = (
|
type ApiTuple = (
|
||||||
@@ -181,6 +196,6 @@ mod tests {
|
|||||||
fn build_routes_constructs_without_panic() {
|
fn build_routes_constructs_without_panic() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let ctx = context::AppContext::new_test(tmp.path().to_path_buf());
|
let ctx = context::AppContext::new_test(tmp.path().to_path_buf());
|
||||||
let _endpoint = build_routes(ctx);
|
let _endpoint = build_routes(ctx, None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,7 +193,32 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
qa_app_process: Arc::new(std::sync::Mutex::new(None)),
|
qa_app_process: Arc::new(std::sync::Mutex::new(None)),
|
||||||
};
|
};
|
||||||
|
|
||||||
let app = build_routes(ctx);
|
// Build WhatsApp webhook context if bot.toml configures transport = "whatsapp".
|
||||||
|
let whatsapp_ctx: Option<Arc<whatsapp::WhatsAppWebhookContext>> = startup_root
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|root| matrix::BotConfig::load(root))
|
||||||
|
.filter(|cfg| cfg.transport == "whatsapp")
|
||||||
|
.map(|cfg| {
|
||||||
|
let transport = Arc::new(whatsapp::WhatsAppTransport::new(
|
||||||
|
cfg.whatsapp_phone_number_id.clone().unwrap_or_default(),
|
||||||
|
cfg.whatsapp_access_token.clone().unwrap_or_default(),
|
||||||
|
));
|
||||||
|
let bot_name = cfg
|
||||||
|
.display_name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "Assistant".to_string());
|
||||||
|
Arc::new(whatsapp::WhatsAppWebhookContext {
|
||||||
|
verify_token: cfg.whatsapp_verify_token.clone().unwrap_or_default(),
|
||||||
|
transport,
|
||||||
|
project_root: startup_root.clone().unwrap(),
|
||||||
|
agents: Arc::clone(&startup_agents),
|
||||||
|
bot_name,
|
||||||
|
bot_user_id: "whatsapp-bot".to_string(),
|
||||||
|
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let app = build_routes(ctx, whatsapp_ctx);
|
||||||
|
|
||||||
|
|
||||||
// Optional Matrix bot: connect to the homeserver and start listening for
|
// Optional Matrix bot: connect to the homeserver and start listening for
|
||||||
|
|||||||
@@ -368,8 +368,11 @@ pub async fn run_bot(
|
|||||||
// Create the transport abstraction based on the configured transport type.
|
// Create the transport abstraction based on the configured transport type.
|
||||||
let transport: Arc<dyn ChatTransport> = match config.transport.as_str() {
|
let transport: Arc<dyn ChatTransport> = match config.transport.as_str() {
|
||||||
"whatsapp" => {
|
"whatsapp" => {
|
||||||
slog!("[matrix-bot] Using WhatsApp transport (stub)");
|
slog!("[matrix-bot] Using WhatsApp transport");
|
||||||
Arc::new(crate::whatsapp::WhatsAppTransport::new())
|
Arc::new(crate::whatsapp::WhatsAppTransport::new(
|
||||||
|
config.whatsapp_phone_number_id.clone().unwrap_or_default(),
|
||||||
|
config.whatsapp_access_token.clone().unwrap_or_default(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
slog!("[matrix-bot] Using Matrix transport");
|
slog!("[matrix-bot] Using Matrix transport");
|
||||||
@@ -1393,7 +1396,7 @@ mod tests {
|
|||||||
ambient_rooms: Arc::new(std::sync::Mutex::new(HashSet::new())),
|
ambient_rooms: Arc::new(std::sync::Mutex::new(HashSet::new())),
|
||||||
agents: Arc::new(AgentPool::new_test(3000)),
|
agents: Arc::new(AgentPool::new_test(3000)),
|
||||||
htop_sessions: Arc::new(TokioMutex::new(HashMap::new())),
|
htop_sessions: Arc::new(TokioMutex::new(HashMap::new())),
|
||||||
transport: Arc::new(crate::whatsapp::WhatsAppTransport::new()),
|
transport: Arc::new(crate::whatsapp::WhatsAppTransport::new("test-phone".to_string(), "test-token".to_string())),
|
||||||
};
|
};
|
||||||
// Clone must work (required by Matrix SDK event handler injection).
|
// Clone must work (required by Matrix SDK event handler injection).
|
||||||
let _cloned = ctx.clone();
|
let _cloned = ctx.clone();
|
||||||
|
|||||||
@@ -66,6 +66,20 @@ pub struct BotConfig {
|
|||||||
/// round-tripping.
|
/// round-tripping.
|
||||||
#[serde(default = "default_transport")]
|
#[serde(default = "default_transport")]
|
||||||
pub transport: String,
|
pub transport: String,
|
||||||
|
|
||||||
|
// ── WhatsApp Business API fields ─────────────────────────────────
|
||||||
|
// These are only required when `transport = "whatsapp"`.
|
||||||
|
|
||||||
|
/// WhatsApp Business phone number ID from the Meta dashboard.
|
||||||
|
#[serde(default)]
|
||||||
|
pub whatsapp_phone_number_id: Option<String>,
|
||||||
|
/// Long-lived access token for the WhatsApp Business API.
|
||||||
|
#[serde(default)]
|
||||||
|
pub whatsapp_access_token: Option<String>,
|
||||||
|
/// Verify token used in the webhook handshake (you choose this value
|
||||||
|
/// and configure it in the Meta webhook settings).
|
||||||
|
#[serde(default)]
|
||||||
|
pub whatsapp_verify_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_transport() -> String {
|
fn default_transport() -> String {
|
||||||
@@ -97,7 +111,31 @@ impl BotConfig {
|
|||||||
{
|
{
|
||||||
config.room_ids.push(single);
|
config.room_ids.push(single);
|
||||||
}
|
}
|
||||||
if config.room_ids.is_empty() {
|
|
||||||
|
if config.transport == "whatsapp" {
|
||||||
|
// Validate WhatsApp-specific fields.
|
||||||
|
if config.whatsapp_phone_number_id.as_ref().is_none_or(|s| s.is_empty()) {
|
||||||
|
eprintln!(
|
||||||
|
"[bot] bot.toml: transport=\"whatsapp\" requires \
|
||||||
|
whatsapp_phone_number_id"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if config.whatsapp_access_token.as_ref().is_none_or(|s| s.is_empty()) {
|
||||||
|
eprintln!(
|
||||||
|
"[bot] bot.toml: transport=\"whatsapp\" requires \
|
||||||
|
whatsapp_access_token"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if config.whatsapp_verify_token.as_ref().is_none_or(|s| s.is_empty()) {
|
||||||
|
eprintln!(
|
||||||
|
"[bot] bot.toml: transport=\"whatsapp\" requires \
|
||||||
|
whatsapp_verify_token"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
} else if config.room_ids.is_empty() {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[matrix-bot] bot.toml has no room_ids configured — \
|
"[matrix-bot] bot.toml has no room_ids configured — \
|
||||||
add `room_ids = [\"!roomid:example.com\"]` to bot.toml"
|
add `room_ids = [\"!roomid:example.com\"]` to bot.toml"
|
||||||
@@ -556,10 +594,88 @@ password = "secret"
|
|||||||
room_ids = ["!abc:example.com"]
|
room_ids = ["!abc:example.com"]
|
||||||
enabled = true
|
enabled = true
|
||||||
transport = "whatsapp"
|
transport = "whatsapp"
|
||||||
|
whatsapp_phone_number_id = "123456"
|
||||||
|
whatsapp_access_token = "EAAtoken"
|
||||||
|
whatsapp_verify_token = "my-verify"
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let config = BotConfig::load(tmp.path()).unwrap();
|
let config = BotConfig::load(tmp.path()).unwrap();
|
||||||
assert_eq!(config.transport, "whatsapp");
|
assert_eq!(config.transport, "whatsapp");
|
||||||
|
assert_eq!(
|
||||||
|
config.whatsapp_phone_number_id.as_deref(),
|
||||||
|
Some("123456")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config.whatsapp_access_token.as_deref(),
|
||||||
|
Some("EAAtoken")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config.whatsapp_verify_token.as_deref(),
|
||||||
|
Some("my-verify")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_whatsapp_returns_none_when_missing_phone_number_id() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".story_kit");
|
||||||
|
fs::create_dir_all(&sk).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk.join("bot.toml"),
|
||||||
|
r#"
|
||||||
|
homeserver = "https://matrix.example.com"
|
||||||
|
username = "@bot:example.com"
|
||||||
|
password = "secret"
|
||||||
|
enabled = true
|
||||||
|
transport = "whatsapp"
|
||||||
|
whatsapp_access_token = "EAAtoken"
|
||||||
|
whatsapp_verify_token = "my-verify"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(BotConfig::load(tmp.path()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_whatsapp_returns_none_when_missing_access_token() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".story_kit");
|
||||||
|
fs::create_dir_all(&sk).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk.join("bot.toml"),
|
||||||
|
r#"
|
||||||
|
homeserver = "https://matrix.example.com"
|
||||||
|
username = "@bot:example.com"
|
||||||
|
password = "secret"
|
||||||
|
enabled = true
|
||||||
|
transport = "whatsapp"
|
||||||
|
whatsapp_phone_number_id = "123456"
|
||||||
|
whatsapp_verify_token = "my-verify"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(BotConfig::load(tmp.path()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_whatsapp_returns_none_when_missing_verify_token() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".story_kit");
|
||||||
|
fs::create_dir_all(&sk).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk.join("bot.toml"),
|
||||||
|
r#"
|
||||||
|
homeserver = "https://matrix.example.com"
|
||||||
|
username = "@bot:example.com"
|
||||||
|
password = "secret"
|
||||||
|
enabled = true
|
||||||
|
transport = "whatsapp"
|
||||||
|
whatsapp_phone_number_id = "123456"
|
||||||
|
whatsapp_access_token = "EAAtoken"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(BotConfig::load(tmp.path()).is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ pub fn spawn_bot(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// WhatsApp transport is handled via HTTP webhooks, not the Matrix sync loop.
|
||||||
|
if config.transport == "whatsapp" {
|
||||||
|
crate::slog!("[bot] transport=whatsapp — skipping Matrix bot; webhooks handle WhatsApp");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
crate::slog!(
|
crate::slog!(
|
||||||
"[matrix-bot] Starting Matrix bot → homeserver={} rooms={:?}",
|
"[matrix-bot] Starting Matrix bot → homeserver={} rooms={:?}",
|
||||||
config.homeserver,
|
config.homeserver,
|
||||||
|
|||||||
@@ -59,26 +59,20 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// Verify that the WhatsApp stub satisfies the ChatTransport trait and
|
/// Verify that WhatsAppTransport satisfies the ChatTransport trait and
|
||||||
/// can be used as `Arc<dyn ChatTransport>`.
|
/// can be used as `Arc<dyn ChatTransport>` (compile-time check).
|
||||||
#[tokio::test]
|
/// Functional tests are in `whatsapp::tests` using mockito.
|
||||||
async fn whatsapp_transport_satisfies_trait() {
|
#[test]
|
||||||
let transport: Arc<dyn ChatTransport> =
|
fn whatsapp_transport_satisfies_trait() {
|
||||||
Arc::new(crate::whatsapp::WhatsAppTransport::new());
|
fn assert_transport<T: ChatTransport>() {}
|
||||||
|
assert_transport::<crate::whatsapp::WhatsAppTransport>();
|
||||||
|
|
||||||
let msg_id = transport
|
// Verify it can be wrapped in Arc<dyn ChatTransport>.
|
||||||
.send_message("room-1", "hello", "<p>hello</p>")
|
let _: Arc<dyn ChatTransport> =
|
||||||
.await
|
Arc::new(crate::whatsapp::WhatsAppTransport::new(
|
||||||
.unwrap();
|
"test-phone".to_string(),
|
||||||
assert!(!msg_id.is_empty());
|
"test-token".to_string(),
|
||||||
|
));
|
||||||
transport
|
|
||||||
.edit_message("room-1", &msg_id, "edited", "<p>edited</p>")
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
transport.send_typing("room-1", true).await.unwrap();
|
|
||||||
transport.send_typing("room-1", false).await.unwrap();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MatrixTransport cannot be tested without a live homeserver, but we
|
/// MatrixTransport cannot be tested without a live homeserver, but we
|
||||||
|
|||||||
@@ -1,43 +1,103 @@
|
|||||||
//! WhatsApp stub implementation of [`ChatTransport`].
|
//! WhatsApp Business API integration.
|
||||||
//!
|
//!
|
||||||
//! This is a placeholder transport that logs operations and returns stub
|
//! Provides:
|
||||||
//! values. It exists to prove the transport abstraction works with a
|
//! - [`WhatsAppTransport`] — a [`ChatTransport`] that sends messages via the
|
||||||
//! second platform and will be replaced with a real WhatsApp Business API
|
//! Meta Graph API (`graph.facebook.com/v21.0/{phone_number_id}/messages`).
|
||||||
//! integration in the future.
|
//! - [`webhook_verify`] / [`webhook_receive`] — Poem handlers for the WhatsApp
|
||||||
|
//! webhook (GET verification handshake + POST incoming messages).
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::agents::AgentPool;
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
use crate::transport::{ChatTransport, MessageId};
|
use crate::transport::{ChatTransport, MessageId};
|
||||||
|
|
||||||
/// Stub WhatsApp transport.
|
// ── Graph API base URL (overridable for tests) ──────────────────────────
|
||||||
|
|
||||||
|
const GRAPH_API_BASE: &str = "https://graph.facebook.com/v21.0";
|
||||||
|
|
||||||
|
// ── WhatsApp Transport ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Real WhatsApp Business API transport.
|
||||||
///
|
///
|
||||||
/// All methods log the operation and return success with placeholder values.
|
/// Sends text messages via `POST {GRAPH_API_BASE}/{phone_number_id}/messages`.
|
||||||
/// Message editing is not supported by WhatsApp — `edit_message` sends a
|
|
||||||
/// new message instead (TODO: implement via WhatsApp Business API).
|
|
||||||
pub struct WhatsAppTransport {
|
pub struct WhatsAppTransport {
|
||||||
/// Counter for generating unique stub message IDs.
|
phone_number_id: String,
|
||||||
next_id: std::sync::atomic::AtomicU64,
|
access_token: String,
|
||||||
|
client: reqwest::Client,
|
||||||
|
/// Optional base URL override for tests.
|
||||||
|
api_base: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WhatsAppTransport {
|
impl WhatsAppTransport {
|
||||||
pub fn new() -> Self {
|
pub fn new(phone_number_id: String, access_token: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
next_id: std::sync::atomic::AtomicU64::new(1),
|
phone_number_id,
|
||||||
|
access_token,
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
api_base: GRAPH_API_BASE.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn next_message_id(&self) -> String {
|
#[cfg(test)]
|
||||||
let id = self
|
fn with_api_base(phone_number_id: String, access_token: String, api_base: String) -> Self {
|
||||||
.next_id
|
Self {
|
||||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
phone_number_id,
|
||||||
format!("whatsapp-stub-{id}")
|
access_token,
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
api_base,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for WhatsAppTransport {
|
/// Send a text message to a WhatsApp user via the Graph API.
|
||||||
fn default() -> Self {
|
async fn send_text(&self, to: &str, body: &str) -> Result<String, String> {
|
||||||
Self::new()
|
let url = format!(
|
||||||
|
"{}/{}/messages",
|
||||||
|
self.api_base, self.phone_number_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let payload = GraphSendMessage {
|
||||||
|
messaging_product: "whatsapp",
|
||||||
|
to,
|
||||||
|
r#type: "text",
|
||||||
|
text: GraphTextBody { body },
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(&self.access_token)
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("WhatsApp API request failed: {e}"))?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
let resp_text = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| "<no body>".to_string());
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!(
|
||||||
|
"WhatsApp API returned {status}: {resp_text}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the message ID from the response.
|
||||||
|
let parsed: GraphSendResponse = serde_json::from_str(&resp_text).map_err(|e| {
|
||||||
|
format!("Failed to parse WhatsApp API response: {e} — body: {resp_text}")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let msg_id = parsed
|
||||||
|
.messages
|
||||||
|
.first()
|
||||||
|
.map(|m| m.id.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(msg_id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,73 +105,475 @@ impl Default for WhatsAppTransport {
|
|||||||
impl ChatTransport for WhatsAppTransport {
|
impl ChatTransport for WhatsAppTransport {
|
||||||
async fn send_message(
|
async fn send_message(
|
||||||
&self,
|
&self,
|
||||||
room_id: &str,
|
recipient: &str,
|
||||||
plain: &str,
|
plain: &str,
|
||||||
_html: &str,
|
_html: &str,
|
||||||
) -> Result<MessageId, String> {
|
) -> Result<MessageId, String> {
|
||||||
// TODO: Send via WhatsApp Business API
|
slog!("[whatsapp] send_message to {recipient}: {plain:.80}");
|
||||||
let msg_id = self.next_message_id();
|
self.send_text(recipient, plain).await
|
||||||
slog!(
|
|
||||||
"[whatsapp-stub] send_message to {room_id}: {plain:.80} (id={msg_id})"
|
|
||||||
);
|
|
||||||
Ok(msg_id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn edit_message(
|
async fn edit_message(
|
||||||
&self,
|
&self,
|
||||||
room_id: &str,
|
recipient: &str,
|
||||||
original_message_id: &str,
|
_original_message_id: &str,
|
||||||
plain: &str,
|
plain: &str,
|
||||||
html: &str,
|
html: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// WhatsApp does not support message editing.
|
// WhatsApp does not support message editing — send a new message.
|
||||||
// Send a new message instead.
|
slog!("[whatsapp] edit_message — WhatsApp does not support edits, sending new message");
|
||||||
slog!(
|
self.send_message(recipient, plain, html).await.map(|_| ())
|
||||||
"[whatsapp-stub] edit_message (original={original_message_id}) — \
|
|
||||||
WhatsApp does not support edits, sending new message"
|
|
||||||
);
|
|
||||||
self.send_message(room_id, plain, html).await.map(|_| ())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_typing(&self, room_id: &str, typing: bool) -> Result<(), String> {
|
async fn send_typing(&self, _recipient: &str, _typing: bool) -> Result<(), String> {
|
||||||
// TODO: Send typing indicator via WhatsApp Business API
|
// WhatsApp Business API does not expose typing indicators.
|
||||||
slog!("[whatsapp-stub] send_typing to {room_id}: typing={typing}");
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Graph API request/response types ────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct GraphSendMessage<'a> {
|
||||||
|
messaging_product: &'a str,
|
||||||
|
to: &'a str,
|
||||||
|
r#type: &'a str,
|
||||||
|
text: GraphTextBody<'a>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct GraphTextBody<'a> {
|
||||||
|
body: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct GraphSendResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
messages: Vec<GraphMessageId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct GraphMessageId {
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Webhook types (Meta → us) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Top-level webhook payload from Meta.
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct WebhookPayload {
|
||||||
|
#[serde(default)]
|
||||||
|
pub entry: Vec<WebhookEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct WebhookEntry {
|
||||||
|
#[serde(default)]
|
||||||
|
pub changes: Vec<WebhookChange>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct WebhookChange {
|
||||||
|
pub value: Option<WebhookValue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct WebhookValue {
|
||||||
|
#[serde(default)]
|
||||||
|
pub messages: Vec<WebhookMessage>,
|
||||||
|
pub metadata: Option<WebhookMetadata>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct WebhookMetadata {
|
||||||
|
pub phone_number_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct WebhookMessage {
|
||||||
|
pub from: Option<String>,
|
||||||
|
pub r#type: Option<String>,
|
||||||
|
pub text: Option<WebhookText>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct WebhookText {
|
||||||
|
pub body: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract text messages from a webhook payload.
|
||||||
|
///
|
||||||
|
/// Returns `(sender_phone, message_body)` pairs.
|
||||||
|
pub fn extract_text_messages(payload: &WebhookPayload) -> Vec<(String, String)> {
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
for entry in &payload.entry {
|
||||||
|
for change in &entry.changes {
|
||||||
|
if let Some(value) = &change.value {
|
||||||
|
for msg in &value.messages {
|
||||||
|
if msg.r#type.as_deref() == Some("text")
|
||||||
|
&& let (Some(from), Some(text)) = (&msg.from, &msg.text)
|
||||||
|
&& let Some(body) = &text.body
|
||||||
|
{
|
||||||
|
messages.push((from.clone(), body.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Webhook handlers (Poem) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
use poem::{Request, Response, handler, http::StatusCode, web::Query};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
/// Query parameters for the webhook verification GET request.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct VerifyQuery {
|
||||||
|
#[serde(rename = "hub.mode")]
|
||||||
|
pub hub_mode: Option<String>,
|
||||||
|
#[serde(rename = "hub.verify_token")]
|
||||||
|
pub hub_verify_token: Option<String>,
|
||||||
|
#[serde(rename = "hub.challenge")]
|
||||||
|
pub hub_challenge: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared context for webhook handlers, injected via Poem's `Data` extractor.
|
||||||
|
pub struct WhatsAppWebhookContext {
|
||||||
|
pub verify_token: String,
|
||||||
|
pub transport: Arc<WhatsAppTransport>,
|
||||||
|
pub project_root: PathBuf,
|
||||||
|
pub agents: Arc<AgentPool>,
|
||||||
|
pub bot_name: String,
|
||||||
|
/// The bot's "user ID" for command dispatch (e.g. "whatsapp-bot").
|
||||||
|
pub bot_user_id: String,
|
||||||
|
pub ambient_rooms: Arc<Mutex<HashSet<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /webhook/whatsapp — Meta verification handshake.
|
||||||
|
///
|
||||||
|
/// Meta sends `hub.mode=subscribe&hub.verify_token=<token>&hub.challenge=<challenge>`.
|
||||||
|
/// We return the challenge if the token matches.
|
||||||
|
#[handler]
|
||||||
|
pub async fn webhook_verify(
|
||||||
|
Query(q): Query<VerifyQuery>,
|
||||||
|
ctx: poem::web::Data<&Arc<WhatsAppWebhookContext>>,
|
||||||
|
) -> Response {
|
||||||
|
if q.hub_mode.as_deref() == Some("subscribe")
|
||||||
|
&& q.hub_verify_token.as_deref() == Some(&ctx.verify_token)
|
||||||
|
&& let Some(challenge) = q.hub_challenge
|
||||||
|
{
|
||||||
|
slog!("[whatsapp] Webhook verification succeeded");
|
||||||
|
return Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.body(challenge);
|
||||||
|
}
|
||||||
|
slog!("[whatsapp] Webhook verification failed");
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::FORBIDDEN)
|
||||||
|
.body("Verification failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /webhook/whatsapp — receive incoming messages from Meta.
|
||||||
|
#[handler]
|
||||||
|
pub async fn webhook_receive(
|
||||||
|
req: &Request,
|
||||||
|
body: poem::Body,
|
||||||
|
ctx: poem::web::Data<&Arc<WhatsAppWebhookContext>>,
|
||||||
|
) -> Response {
|
||||||
|
let _ = req;
|
||||||
|
let bytes = match body.into_bytes().await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
slog!("[whatsapp] Failed to read webhook body: {e}");
|
||||||
|
return Response::builder()
|
||||||
|
.status(StatusCode::BAD_REQUEST)
|
||||||
|
.body("Bad request");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload: WebhookPayload = match serde_json::from_slice(&bytes) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
slog!("[whatsapp] Failed to parse webhook payload: {e}");
|
||||||
|
// Meta expects 200 even on parse errors to avoid retries.
|
||||||
|
return Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.body("ok");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let messages = extract_text_messages(&payload);
|
||||||
|
if messages.is_empty() {
|
||||||
|
// Status updates, read receipts, etc. — acknowledge silently.
|
||||||
|
return Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.body("ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
let ctx = Arc::clone(*ctx);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
for (sender, text) in messages {
|
||||||
|
slog!("[whatsapp] Message from {sender}: {text}");
|
||||||
|
handle_incoming_message(&ctx, &sender, &text).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.body("ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatch an incoming WhatsApp message to bot commands.
|
||||||
|
async fn handle_incoming_message(
|
||||||
|
ctx: &WhatsAppWebhookContext,
|
||||||
|
sender: &str,
|
||||||
|
message: &str,
|
||||||
|
) {
|
||||||
|
use crate::matrix::commands::{CommandDispatch, try_handle_command};
|
||||||
|
|
||||||
|
let dispatch = CommandDispatch {
|
||||||
|
bot_name: &ctx.bot_name,
|
||||||
|
bot_user_id: &ctx.bot_user_id,
|
||||||
|
project_root: &ctx.project_root,
|
||||||
|
agents: &ctx.agents,
|
||||||
|
ambient_rooms: &ctx.ambient_rooms,
|
||||||
|
room_id: sender,
|
||||||
|
// WhatsApp messages are always "addressed" to the bot (1:1 or bot-specific).
|
||||||
|
is_addressed: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(response) = try_handle_command(&dispatch, message) {
|
||||||
|
slog!("[whatsapp] Sending command response to {sender}");
|
||||||
|
if let Err(e) = ctx.transport.send_message(sender, &response, "").await {
|
||||||
|
slog!("[whatsapp] Failed to send reply to {sender}: {e}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for async commands (htop, delete).
|
||||||
|
if let Some(htop_cmd) = crate::matrix::htop::extract_htop_command(
|
||||||
|
message,
|
||||||
|
&ctx.bot_name,
|
||||||
|
&ctx.bot_user_id,
|
||||||
|
) {
|
||||||
|
use crate::matrix::htop::HtopCommand;
|
||||||
|
slog!("[whatsapp] Handling htop command from {sender}");
|
||||||
|
match htop_cmd {
|
||||||
|
HtopCommand::Stop => {
|
||||||
|
// htop stop — no-op on WhatsApp since there's no persistent
|
||||||
|
// editable message; just acknowledge.
|
||||||
|
let _ = ctx
|
||||||
|
.transport
|
||||||
|
.send_message(sender, "htop stopped.", "")
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
HtopCommand::Start { duration_secs } => {
|
||||||
|
// On WhatsApp, send a single snapshot instead of a live-updating
|
||||||
|
// dashboard since we can't edit messages.
|
||||||
|
let snapshot =
|
||||||
|
crate::matrix::htop::build_htop_message(&ctx.agents, 0, duration_secs);
|
||||||
|
let _ = ctx
|
||||||
|
.transport
|
||||||
|
.send_message(sender, &snapshot, "")
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(del_cmd) = crate::matrix::delete::extract_delete_command(
|
||||||
|
message,
|
||||||
|
&ctx.bot_name,
|
||||||
|
&ctx.bot_user_id,
|
||||||
|
) {
|
||||||
|
let response = match del_cmd {
|
||||||
|
crate::matrix::delete::DeleteCommand::Delete { story_number } => {
|
||||||
|
slog!("[whatsapp] Handling delete command from {sender}: story {story_number}");
|
||||||
|
crate::matrix::delete::handle_delete(
|
||||||
|
&ctx.bot_name,
|
||||||
|
&story_number,
|
||||||
|
&ctx.project_root,
|
||||||
|
&ctx.agents,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
crate::matrix::delete::DeleteCommand::BadArgs => {
|
||||||
|
format!("Usage: `{} delete <number>`", ctx.bot_name)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = ctx.transport.send_message(sender, &response, "").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No command matched — inform the user that only commands are supported.
|
||||||
|
// (LLM passthrough is a separate story.)
|
||||||
|
let _ = ctx
|
||||||
|
.transport
|
||||||
|
.send_message(
|
||||||
|
sender,
|
||||||
|
"I only respond to commands right now. Try `help` to see what's available.",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn send_message_returns_unique_ids() {
|
fn extract_text_messages_parses_valid_payload() {
|
||||||
let transport = WhatsAppTransport::new();
|
let json = r#"{
|
||||||
let id1 = transport
|
"entry": [{
|
||||||
.send_message("room1", "hello", "<p>hello</p>")
|
"changes": [{
|
||||||
.await
|
"value": {
|
||||||
.unwrap();
|
"messages": [{
|
||||||
let id2 = transport
|
"from": "15551234567",
|
||||||
.send_message("room1", "world", "<p>world</p>")
|
"type": "text",
|
||||||
.await
|
"text": { "body": "help" }
|
||||||
.unwrap();
|
}],
|
||||||
assert_ne!(id1, id2, "each message should get a unique ID");
|
"metadata": { "phone_number_id": "123456" }
|
||||||
assert!(id1.starts_with("whatsapp-stub-"));
|
}
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}"#;
|
||||||
|
let payload: WebhookPayload = serde_json::from_str(json).unwrap();
|
||||||
|
let msgs = extract_text_messages(&payload);
|
||||||
|
assert_eq!(msgs.len(), 1);
|
||||||
|
assert_eq!(msgs[0].0, "15551234567");
|
||||||
|
assert_eq!(msgs[0].1, "help");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_text_messages_ignores_non_text() {
|
||||||
|
let json = r#"{
|
||||||
|
"entry": [{
|
||||||
|
"changes": [{
|
||||||
|
"value": {
|
||||||
|
"messages": [{
|
||||||
|
"from": "15551234567",
|
||||||
|
"type": "image",
|
||||||
|
"image": { "id": "img123" }
|
||||||
|
}],
|
||||||
|
"metadata": { "phone_number_id": "123456" }
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}"#;
|
||||||
|
let payload: WebhookPayload = serde_json::from_str(json).unwrap();
|
||||||
|
let msgs = extract_text_messages(&payload);
|
||||||
|
assert!(msgs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_text_messages_handles_empty_payload() {
|
||||||
|
let json = r#"{ "entry": [] }"#;
|
||||||
|
let payload: WebhookPayload = serde_json::from_str(json).unwrap();
|
||||||
|
let msgs = extract_text_messages(&payload);
|
||||||
|
assert!(msgs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_text_messages_handles_multiple_messages() {
|
||||||
|
let json = r#"{
|
||||||
|
"entry": [{
|
||||||
|
"changes": [{
|
||||||
|
"value": {
|
||||||
|
"messages": [
|
||||||
|
{ "from": "111", "type": "text", "text": { "body": "status" } },
|
||||||
|
{ "from": "222", "type": "text", "text": { "body": "help" } }
|
||||||
|
],
|
||||||
|
"metadata": { "phone_number_id": "123456" }
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}"#;
|
||||||
|
let payload: WebhookPayload = serde_json::from_str(json).unwrap();
|
||||||
|
let msgs = extract_text_messages(&payload);
|
||||||
|
assert_eq!(msgs.len(), 2);
|
||||||
|
assert_eq!(msgs[0].1, "status");
|
||||||
|
assert_eq!(msgs[1].1, "help");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn edit_message_succeeds() {
|
async fn transport_send_message_calls_graph_api() {
|
||||||
let transport = WhatsAppTransport::new();
|
let mut server = mockito::Server::new_async().await;
|
||||||
let result = transport
|
let mock = server
|
||||||
.edit_message("room1", "msg-1", "updated", "<p>updated</p>")
|
.mock("POST", "/123456/messages")
|
||||||
|
.match_header("authorization", "Bearer test-token")
|
||||||
|
.with_body(r#"{"messages": [{"id": "wamid.abc123"}]}"#)
|
||||||
|
.create_async()
|
||||||
.await;
|
.await;
|
||||||
assert!(result.is_ok(), "edit should succeed (sends new message)");
|
|
||||||
|
let transport = WhatsAppTransport::with_api_base(
|
||||||
|
"123456".to_string(),
|
||||||
|
"test-token".to_string(),
|
||||||
|
server.url(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = transport
|
||||||
|
.send_message("15551234567", "hello", "<p>hello</p>")
|
||||||
|
.await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap(), "wamid.abc123");
|
||||||
|
mock.assert_async().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn send_typing_succeeds() {
|
async fn transport_edit_sends_new_message() {
|
||||||
let transport = WhatsAppTransport::new();
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let mock = server
|
||||||
|
.mock("POST", "/123456/messages")
|
||||||
|
.with_body(r#"{"messages": [{"id": "wamid.xyz"}]}"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let transport = WhatsAppTransport::with_api_base(
|
||||||
|
"123456".to_string(),
|
||||||
|
"test-token".to_string(),
|
||||||
|
server.url(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = transport
|
||||||
|
.edit_message("15551234567", "old-msg-id", "updated", "<p>updated</p>")
|
||||||
|
.await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
mock.assert_async().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn transport_send_typing_succeeds() {
|
||||||
|
let transport = WhatsAppTransport::new("123".to_string(), "tok".to_string());
|
||||||
assert!(transport.send_typing("room1", true).await.is_ok());
|
assert!(transport.send_typing("room1", true).await.is_ok());
|
||||||
assert!(transport.send_typing("room1", false).await.is_ok());
|
assert!(transport.send_typing("room1", false).await.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn transport_handles_api_error() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
server
|
||||||
|
.mock("POST", "/123456/messages")
|
||||||
|
.with_status(401)
|
||||||
|
.with_body(r#"{"error": {"message": "Invalid token"}}"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let transport = WhatsAppTransport::with_api_base(
|
||||||
|
"123456".to_string(),
|
||||||
|
"bad-token".to_string(),
|
||||||
|
server.url(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = transport
|
||||||
|
.send_message("15551234567", "hello", "")
|
||||||
|
.await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("401"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user