Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8da4a1f
feat(wasm): add WhatsApp-style webhook improvements
jrevillard Mar 13, 2026
5547353
feat(whatsapp): implement on_message_persisted for mark_as_read
jrevillard Mar 13, 2026
0dfe7a9
feat(wasm): add WhatsApp-style HMAC verification and integration tests
jrevillard Mar 13, 2026
f215d0d
fix(channels): add on_message_persisted stub to all channels
jrevillard Mar 13, 2026
f7a0e92
fix(wasm): use verification_mode for GET request webhook verification
jrevillard Mar 14, 2026
628d052
feat(wasm): add webhook ACK mechanism for reliable message processing
jrevillard Mar 14, 2026
14fe3e6
fix(wasm): integrate ACK mechanism with agent loop for reliable webho…
jrevillard Mar 15, 2026
7629ef1
style: fix formatting issues
jrevillard Mar 15, 2026
88ec125
chore: bump channel registry versions
jrevillard Mar 15, 2026
01ec5df
fix(tests): add channel-host@0.4.0 stub in wit_compat test
jrevillard Mar 15, 2026
5171ddc
fix(wasm): address PR review feedback - remove dead code, improve ACK…
jrevillard Mar 16, 2026
a919e40
chore: bump tool registry versions after source changes
jrevillard Mar 16, 2026
c64cd71
refactor(wasm): decouple WASM router from AgentDeps via OnMessagePers…
jrevillard Mar 17, 2026
a86e1b0
style: format code after rebase conflict resolution
jrevillard Mar 17, 2026
4cd8098
feat(wasm): add channel-persistence export to all channels
jrevillard Mar 17, 2026
6d49d60
fix(ci): remove invalid package.metadata.component from whatsapp Carg…
jrevillard Mar 17, 2026
0208678
fix(ci): bump feishu and telegram registry versions [skip-regression-…
jrevillard Mar 17, 2026
250c570
chore: remove unnecessary workspace metadata.component
jrevillard Mar 17, 2026
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: 1 addition & 1 deletion channels-src/discord/discord.capabilities.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": "0.2.0",
"wit_version": "0.3.0",
"wit_version": "0.4.0",
"type": "channel",
"name": "discord",
"description": "Discord webhook channel for slash commands, components, and optional mention polling",
Expand Down
7 changes: 7 additions & 0 deletions channels-src/discord/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ use exports::near::agent::channel::{
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
OutgoingHttpResponse, PollConfig, StatusUpdate,
};
use exports::near::agent::channel_persistence;
use near::agent::channel_host::{self, EmittedMessage};

/// Discord interaction wrapper.
Expand Down Expand Up @@ -1205,6 +1206,12 @@ fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse
}
}

impl channel_persistence::Guest for DiscordChannel {
fn on_message_persisted(_metadata_json: String) -> Result<(), String> {
Ok(()) // No-op: Discord does not support read receipts
}
}

export!(DiscordChannel);

fn truncate_message(content: &str) -> String {
Expand Down
7 changes: 7 additions & 0 deletions channels-src/feishu/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ use exports::near::agent::channel::{
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
OutgoingHttpResponse, StatusUpdate,
};
use exports::near::agent::channel_persistence;
use near::agent::channel_host::{self, EmittedMessage};

// ============================================================================
Expand Down Expand Up @@ -268,6 +269,12 @@ fn default_api_base() -> String {

struct FeishuChannel;

impl channel_persistence::Guest for FeishuChannel {
fn on_message_persisted(_metadata_json: String) -> Result<(), String> {
Ok(()) // No-op: Feishu does not require post-persistence actions
}
}

export!(FeishuChannel);

impl Guest for FeishuChannel {
Expand Down
2 changes: 1 addition & 1 deletion channels-src/slack/slack.capabilities.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": "0.2.0",
"wit_version": "0.3.0",
"wit_version": "0.4.0",
"type": "channel",
"name": "slack",
"description": "Slack Events API channel for receiving and responding to Slack messages",
Expand Down
7 changes: 7 additions & 0 deletions channels-src/slack/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ use exports::near::agent::channel::{
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
OutgoingHttpResponse, StatusUpdate,
};
use exports::near::agent::channel_persistence;
use near::agent::channel_host::{self, EmittedMessage, InboundAttachment};

/// Slack event wrapper.
Expand Down Expand Up @@ -712,6 +713,12 @@ fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse
}

// Export the component
impl channel_persistence::Guest for SlackChannel {
fn on_message_persisted(_metadata_json: String) -> Result<(), String> {
Ok(()) // No-op: Slack does not require post-persistence actions
}
}

export!(SlackChannel);

#[cfg(test)]
Expand Down
7 changes: 7 additions & 0 deletions channels-src/telegram/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ use exports::near::agent::channel::{
AgentResponse, Attachment, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
OutgoingHttpResponse, PollConfig, StatusType, StatusUpdate,
};
use exports::near::agent::channel_persistence;
use near::agent::channel_host::{self, EmittedMessage, InboundAttachment};

// ============================================================================
Expand Down Expand Up @@ -2033,6 +2034,12 @@ fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse
}

// Export the component
impl channel_persistence::Guest for TelegramChannel {
fn on_message_persisted(_metadata_json: String) -> Result<(), String> {
Ok(()) // No-op: Telegram does not require post-persistence actions
}
}

export!(TelegramChannel);

// ============================================================================
Expand Down
2 changes: 1 addition & 1 deletion channels-src/telegram/telegram.capabilities.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": "0.2.2",
"wit_version": "0.3.0",
"wit_version": "0.4.0",
"type": "channel",
"name": "telegram",
"description": "Telegram Bot API channel for receiving and responding to Telegram messages",
Expand Down
2 changes: 1 addition & 1 deletion channels-src/whatsapp/Cargo.lock

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

128 changes: 128 additions & 0 deletions channels-src/whatsapp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ use exports::near::agent::channel::{
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
OutgoingHttpResponse, StatusUpdate,
};
use exports::near::agent::channel_persistence;
use near::agent::channel_host::{self, EmittedMessage, InboundAttachment};

// ============================================================================
Expand Down Expand Up @@ -290,6 +291,14 @@ struct WhatsAppConfig {

#[serde(default)]
allow_from: Option<Vec<String>>,

/// Whether to mark incoming messages as read (default: true)
#[serde(default = "default_mark_as_read")]
mark_as_read: bool,
}

fn default_mark_as_read() -> bool {
true
}

fn default_api_version() -> String {
Expand Down Expand Up @@ -321,6 +330,7 @@ impl Guest for WhatsAppChannel {
owner_id: None,
dm_policy: None,
allow_from: None,
mark_as_read: default_mark_as_read(),
}
}
};
Expand Down Expand Up @@ -354,6 +364,10 @@ impl Guest for WhatsAppChannel {
.unwrap_or_else(|_| "[]".to_string());
let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json);

// Persist mark_as_read setting for on_message_persisted
let mark_as_read_str = if config.mark_as_read { "true" } else { "false" };
let _ = channel_host::workspace_write("channels/whatsapp/mark_as_read", mark_as_read_str);

// WhatsApp Cloud API is webhook-only, no polling available
Ok(ChannelConfig {
display_name: "WhatsApp".to_string(),
Expand Down Expand Up @@ -524,6 +538,73 @@ impl Guest for WhatsAppChannel {
}
}

// ============================================================================
// Persistence Callback Implementation
// ============================================================================

/// Metadata extracted from persisted message for mark_as_read callback.
#[derive(Debug, Deserialize)]
struct PersistedMessageMetadata {
phone_number_id: String,
message_id: String,
}

impl channel_persistence::Guest for WhatsAppChannel {
/// Called after a message has been persisted to the database.
///
/// This callback is used to mark the message as read in WhatsApp,
/// removing the "typing..." indicator from the sender's view.
fn on_message_persisted(metadata_json: String) -> Result<(), String> {
// Check if mark_as_read is enabled
let mark_as_read_enabled = match channel_host::workspace_read("channels/whatsapp/mark_as_read") {
Some(s) => s == "true",
None => return Ok(()), // Default to disabled if not set
};

if !mark_as_read_enabled {
channel_host::log(
channel_host::LogLevel::Debug,
"mark_as_read disabled, skipping callback",
);
return Ok(());
}

// Parse metadata to extract phone_number_id and message_id
let metadata: PersistedMessageMetadata = match serde_json::from_str(&metadata_json) {
Ok(m) => m,
Err(e) => {
// Metadata parsing failed - log but don't fail the callback
// This can happen for messages without proper routing metadata
channel_host::log(
channel_host::LogLevel::Debug,
&format!("Failed to parse metadata for mark_as_read: {}", e),
);
return Ok(()); // Return Ok to not block ACK
}
};

// Mark the message as read via WhatsApp Cloud API
match mark_message_as_read(&metadata.phone_number_id, &metadata.message_id) {
Ok(()) => {
channel_host::log(
channel_host::LogLevel::Debug,
&format!("Marked message {} as read", metadata.message_id),
);
Ok(())
}
Err(e) => {
// Log error but return Ok to not block message ACK
// The message was already persisted, so we don't want to fail the callback
channel_host::log(
channel_host::LogLevel::Warn,
&format!("Failed to mark message as read: {}", e),
);
Ok(()) // Always return Ok - mark_as_read failure is non-blocking
}
}
}
}

// ============================================================================
// Webhook Verification
// ============================================================================
Expand Down Expand Up @@ -945,6 +1026,53 @@ fn send_pairing_reply(
}
}

/// Mark a WhatsApp message as read via the Cloud API.
///
/// https://developers.facebook.com/docs/whatsapp/cloud-api/guides/mark-message-as-read
fn mark_message_as_read(phone_number_id: &str, message_id: &str) -> Result<(), String> {
// Read api_version from workspace (set during on_start), fallback to default
let api_version = channel_host::workspace_read("channels/whatsapp/api_version")
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "v18.0".to_string());

let url = format!(
"https://graph.facebook.com/{}/{}/messages",
api_version, phone_number_id
);

// Mark as read payload
let payload = serde_json::json!({
"messaging_product": "whatsapp",
"status": "read",
"message_id": message_id
});

let payload_bytes =
serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?;

let headers = serde_json::json!({
"Content-Type": "application/json",
"Authorization": "Bearer {WHATSAPP_ACCESS_TOKEN}"
});

let result = channel_host::http_request(
"POST",
&url,
&headers.to_string(),
Some(&payload_bytes),
None,
);

match result {
Ok(response) if response.status >= 200 && response.status < 300 => Ok(()),
Ok(response) => {
let body_str = String::from_utf8_lossy(&response.body);
Err(format!("WhatsApp API error: {} - {}", response.status, body_str))
}
Err(e) => Err(format!("HTTP request failed: {}", e)),
}
}

/// Create a JSON HTTP response.
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
let body = serde_json::to_vec(&value).unwrap_or_default();
Expand Down
14 changes: 11 additions & 3 deletions channels-src/whatsapp/whatsapp.capabilities.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": "0.2.0",
"wit_version": "0.3.0",
"wit_version": "0.4.0",
"type": "channel",
"name": "whatsapp",
"description": "WhatsApp Cloud API channel for receiving and responding to WhatsApp messages",
Expand All @@ -11,6 +11,11 @@
"prompt": "Enter your WhatsApp Cloud API permanent access token (from the Meta Developer Portal under your app's WhatsApp > API Setup).",
"validation": "^[A-Za-z0-9_-]+$"
},
{
"name": "whatsapp_app_secret",
"prompt": "Enter your WhatsApp App Secret (from the Meta Developer Portal under your app's WhatsApp > Configuration). Used for webhook signature verification.",
"validation": "^[a-fA-F0-9]{32}$"
},
{
"name": "whatsapp_verify_token",
"prompt": "Webhook verify token (leave empty to auto-generate)",
Expand Down Expand Up @@ -45,7 +50,9 @@
"webhook": {
"secret_header": "X-Hub-Signature-256",
"secret_name": "whatsapp_verify_token",
"verify_token_param": "hub.verify_token"
"verify_token_param": "hub.verify_token",
"verification_mode": "query_param",
"hmac_secret_name": "whatsapp_app_secret"
}
}
},
Expand All @@ -54,6 +61,7 @@
"reply_to_message": true,
"owner_id": null,
"dm_policy": "pairing",
"allow_from": []
"allow_from": [],
"mark_as_read": true
}
}
4 changes: 2 additions & 2 deletions registry/channels/discord.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"name": "discord",
"display_name": "Discord Channel",
"kind": "channel",
"version": "0.2.1",
"wit_version": "0.3.0",
"version": "0.2.3",
"wit_version": "0.4.0",
"description": "Talk to your agent in Discord",
"keywords": [
"messaging",
Expand Down
4 changes: 2 additions & 2 deletions registry/channels/feishu.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"name": "feishu",
"display_name": "Feishu / Lark Channel",
"kind": "channel",
"version": "0.1.1",
"wit_version": "0.3.0",
"version": "0.1.2",
"wit_version": "0.4.0",
"description": "Talk to your agent through a Feishu or Lark bot",
"keywords": [
"messaging",
Expand Down
4 changes: 2 additions & 2 deletions registry/channels/slack.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"name": "slack",
"display_name": "Slack Channel",
"kind": "channel",
"version": "0.2.1",
"wit_version": "0.3.0",
"version": "0.2.3",
"wit_version": "0.4.0",
"description": "Talk to your agent in Slack",
"keywords": [
"messaging",
Expand Down
4 changes: 2 additions & 2 deletions registry/channels/telegram.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"name": "telegram",
"display_name": "Telegram Channel",
"kind": "channel",
"version": "0.2.4",
"wit_version": "0.3.0",
"version": "0.2.5",
"wit_version": "0.4.0",
"description": "Talk to your agent through a Telegram bot",
"keywords": [
"messaging",
Expand Down
4 changes: 2 additions & 2 deletions registry/channels/whatsapp.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"name": "whatsapp",
"display_name": "WhatsApp Channel",
"kind": "channel",
"version": "0.2.0",
"wit_version": "0.3.0",
"version": "0.2.2",
"wit_version": "0.4.0",
"description": "Talk to your agent through WhatsApp",
"keywords": [
"messaging",
Expand Down
4 changes: 2 additions & 2 deletions registry/tools/github.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"name": "github",
"display_name": "GitHub",
"kind": "tool",
"version": "0.2.1",
"wit_version": "0.3.0",
"version": "0.2.2",
"wit_version": "0.4.0",
"description": "GitHub integration for issues, PRs, repos, and code search",
"keywords": [
"git",
Expand Down
Loading
Loading