// WhatsApp Cloud API adapter (`graph.facebook.com/v…/{phone_number_id}`).
//
// Mirrors Hermes `plugins/platforms/whatsapp_cloud/adapter.py`: text sends use
// `POST /messages` with `messaging_product: whatsapp`, and inbound updates
// arrive through the webhook `entry[].changes[].value` envelope. Within the
// 24-hour customer-service window any content works; outside it only approved
// templates are accepted.
///|
/// Graph API version, overridable with `[whatsapp.extra] api_version`.
pub fn whatsapp_api_version(cfg : PlatformConfig) -> String {
cfg.extra_str("api_version", "v21.0")
}
///|
/// Phone-number id (`WHATSAPP_CLOUD_PHONE_NUMBER_ID`).
pub fn whatsapp_phone_number_id(cfg : PlatformConfig) -> String? {
cfg.extra_first(["phone_number_id", "phone_id"])
}
///|
/// API root, overridable with `[whatsapp.extra] api_base`.
pub fn whatsapp_api_base(cfg : PlatformConfig) -> String {
let base = cfg.extra_str("api_base", "https://graph.facebook.com")
let trimmed = if base.has_suffix("/") {
base[:base.length() - 1].to_owned()
} else {
base
}
let phone = whatsapp_phone_number_id(cfg).unwrap_or("")
"\{trimmed}/\{whatsapp_api_version(cfg)}/\{phone}"
}
///|
/// `POST /messages` text payload.
pub fn whatsapp_send_payload(
to : String,
body : String,
reply_to : String?,
) -> Json {
let payload = json_obj([
("messaging_product", Json::string("whatsapp")),
("recipient_type", Json::string("individual")),
("to", Json::string(to)),
("type", Json::string("text")),
(
"text",
json_obj([
("body", Json::string(body)),
("preview_url", Json::boolean(false)),
]),
),
])
match reply_to {
Some(mid) =>
if payload is Object(m) {
m.set("context", json_obj([("message_id", Json::string(mid))]))
}
None => ()
}
payload
}
///|
/// `POST /messages` media payload for a public URL.
pub fn whatsapp_media_payload(
to : String,
kind : String,
url : String,
caption : String?,
) -> Json {
let typ = match kind {
"photo" | "image" => "image"
"video" => "video"
"audio" | "voice" => "audio"
_ => "document"
}
let media = json_obj([("link", Json::string(url))])
let media = if typ == "document" {
obj_with_opt(media, "filename", caption)
} else {
obj_with_opt(media, "caption", caption)
}
json_obj([
("messaging_product", Json::string("whatsapp")),
("recipient_type", Json::string("individual")),
("to", Json::string(to)),
("type", Json::string(typ)),
(typ, media),
])
}
///|
/// First `messages[0].id` from a Graph send response.
fn whatsapp_message_id(j : Json) -> String? {
match json_field_array(j, "messages") {
Some(msgs) =>
if msgs.length() > 0 {
json_field_string(msgs[0], "id")
} else {
None
}
None => None
}
}
///|
/// Post one payload to `/messages`, mapping Graph errors to `SendResult`.
async fn whatsapp_call(
cfg : PlatformConfig,
token : String,
payload : Json,
) -> SendResult {
let url = "\{whatsapp_api_base(cfg)}/messages"
match post_json(url, payload, bearer_headers(token)) {
Err(e) => {
let retry_after = whatsapp_retry_after(e.body)
SendResult::failed(
"whatsapp: \{api_error_message(e)}",
retryable=e.is_transient() || retry_after is Some(_),
retry_after?,
kind=classify_send_error(e.body),
)
}
Ok(j) =>
match graph_error_message(j) {
Some(msg) => send_body_failure("whatsapp", msg)
None => send_ok(whatsapp_message_id(j), j)
}
}
}
///|
/// Graph 429 bodies carry `error.message` containing "(#131056) … retry after n".
pub fn whatsapp_retry_after(body : String) -> Double? {
let needle = "retry after "
match body.to_lower().find(needle) {
Some(i) => {
let rest = body[i + needle.length():].to_owned()
let digits = StringBuilder()
for c in rest {
if (c >= '0' && c <= '9') ||
(c == '.' && digits.to_string().length() > 0) {
digits.write_char(c)
} else {
break
}
}
let s = digits.to_string()
if s.is_empty() {
None
} else {
Some(@string.parse_double(s)) catch {
_ => None
}
}
}
None => None
}
}
///|
/// Send ``content``, chunked at WhatsApp's 4096-char limit.
pub async fn whatsapp_send(
cfg : PlatformConfig,
chat_id : String,
content : String,
reply_to? : String,
) -> SendResult {
match cfg.token_of() {
None => missing_config(Whatsapp, "WHATSAPP_CLOUD_ACCESS_TOKEN")
Some(token) =>
if whatsapp_phone_number_id(cfg) is None {
missing_config(Whatsapp, "WHATSAPP_CLOUD_PHONE_NUMBER_ID")
} else if content.is_empty() {
SendResult::ok()
} else {
let mut first = true
let mut last = SendResult::ok()
let mut first_id : String? = None
for chunk in chunk_text(content, Platform::max_message_length(Whatsapp)) {
let reply = if first { reply_to } else { None }
let r = send_with_retry(() => {
whatsapp_call(
cfg,
token,
whatsapp_send_payload(chat_id, chunk, reply),
)
})
first = false
if !r.success {
return r
}
if first_id is None {
first_id = r.message_id
}
last = r
}
{ ..last, message_id: first_id, }
}
}
}
///|
/// Send a media URL by public link.
pub async fn whatsapp_send_media(
cfg : PlatformConfig,
chat_id : String,
kind : String,
url : String,
caption? : String,
) -> SendResult {
match cfg.token_of() {
None => missing_config(Whatsapp, "WHATSAPP_CLOUD_ACCESS_TOKEN")
Some(token) =>
whatsapp_call(
cfg,
token,
whatsapp_media_payload(chat_id, kind, url, caption),
)
}
}
///|
/// Mark an inbound message as read (blue ticks).
pub async fn whatsapp_mark_read(
cfg : PlatformConfig,
message_id : String,
) -> Unit {
match cfg.token_of() {
None => ()
Some(token) => {
let url = "\{whatsapp_api_base(cfg)}/messages"
let payload = json_obj([
("messaging_product", Json::string("whatsapp")),
("status", Json::string("read")),
("message_id", Json::string(message_id)),
])
ignore(post_json(url, payload, bearer_headers(token)))
}
}
}
///|
/// Webhook verification handshake (`hub.mode=subscribe`).
pub fn whatsapp_verify(
mode : String,
token : String,
challenge : String,
expected : String,
) -> String? {
if mode == "subscribe" && (expected.is_empty() || token == expected) {
Some(challenge)
} else {
None
}
}
///|
/// Meta's `X-Hub-Signature-256` value for ``body`` (`sha256=`).
pub fn whatsapp_signature(secret : String, body : String) -> String {
"sha256=\{hmac_sha256_hex(secret, body)}"
}
///|
/// Verify a WhatsApp `X-Hub-Signature-256` header: `sha256=` HMAC of the
/// raw body keyed by `app_secret`, case-insensitive hex, constant-time
/// (Hermes `whatsapp_cloud._verify_signature`).
pub fn whatsapp_verify_signature(
secret : String,
body : String,
header : String,
) -> Bool {
if secret.is_empty() || !header.has_prefix("sha256=") {
return false
}
let provided = header[7:].to_owned().trim().to_lower()
constant_time_eq(whatsapp_signature(secret, body), "sha256=\{provided}")
}
///|
/// Parse a WhatsApp webhook POST body into message events.
pub fn whatsapp_parse_webhook(body : String) -> Array[MessageEvent] {
let out : Array[MessageEvent] = []
let j = @json.parse(body) catch { _ => return out }
let entries = match json_field_array(j, "entry") {
Some(e) => e
None => return out
}
for entry in entries {
let changes = match json_field_array(entry, "changes") {
Some(c) => c
None => continue
}
for change in changes {
let value = match json_field_obj(change, "value") {
Some(v) => v
None => continue
}
let contacts = json_field_array(value, "contacts")
for msg in json_field_array(value, "messages").unwrap_or([]) {
match whatsapp_parse_message(msg, contacts) {
Some(ev) => out.push(ev)
None => ()
}
}
}
}
out
}
///|
/// Convert one webhook `messages[]` entry.
fn whatsapp_parse_message(msg : Json, contacts : Array[Json]?) -> MessageEvent? {
let chat_id = json_id(msg, "from")
match chat_id {
None => None
Some(chat_id) => {
let (mtype, text, media) = whatsapp_content(msg)
let user_name = match contacts {
Some(cs) =>
if cs.length() > 0 {
match json_field_obj(cs[0], "profile") {
Some(p) => json_field_string(p, "name")
None => None
}
} else {
None
}
None => None
}
let reply = match json_field_obj(msg, "context") {
Some(c) => json_id(c, "id")
None => None
}
Some(
MessageEvent::new(
text,
mtype,
Whatsapp,
chat_id,
user_name?,
message_id?=json_id(msg, "id"),
reply_to_message_id?=reply,
media~,
timestamp=parse_ms(json_field_string(msg, "timestamp")),
),
)
}
}
}
///|
/// Classify a WhatsApp message payload (`text`, `image`, `location`, …).
fn whatsapp_content(
msg : Json,
) -> (MessageType, String, Array[MediaAttachment]) {
let media : Array[MediaAttachment] = []
let typ = json_field_string(msg, "type").unwrap_or("text")
match typ {
"text" =>
match json_field_obj(msg, "text") {
Some(t) => (Text, json_field_string(t, "body").unwrap_or(""), media)
None => (Text, "", media)
}
"image" | "video" | "audio" | "document" | "sticker" => {
let obj = json_field_obj(msg, typ).unwrap_or(Json::null())
let id = json_id(obj, "id").unwrap_or("")
let caption = json_field_string(obj, "caption").unwrap_or("")
media.push({ url: id, kind: typ, })
let mt = match typ {
"image" => Photo
"video" => Video
"audio" => Audio
_ => Document
}
(mt, caption, media)
}
"location" =>
match json_field_obj(msg, "location") {
Some(l) => {
let name = json_field_string(l, "name").unwrap_or("")
let address = json_field_string(l, "address").unwrap_or("")
(Location, "\{name} \{address}", media)
}
None => (Location, "", media)
}
"button" =>
match json_field_obj(msg, "button") {
Some(b) => (Text, json_field_string(b, "text").unwrap_or(""), media)
None => (Text, "", media)
}
"interactive" =>
match json_field_obj(msg, "interactive") {
Some(i) => {
let sub = match json_field_obj(i, "button_reply") {
Some(b) => b
None =>
match json_field_obj(i, "list_reply") {
Some(l) => l
None => Json::null()
}
}
(Text, json_field_string(sub, "title").unwrap_or(""), media)
}
None => (Text, "", media)
}
_ => (Text, "", media)
}
}
///|
/// Parse a platform timestamp (seconds or milliseconds) into epoch ms.
pub fn parse_ms(s : String?) -> Int64 {
match s {
Some(v) =>
try {
let n = @string.parse_int64(v)
if n < 100000000000L {
n * 1000L
} else {
n
}
} catch {
_ => now_ms()
}
None => now_ms()
}
}