diff --git a/jvagent/action/facebook_action/endpoints.py b/jvagent/action/facebook_action/endpoints.py
index 2af99e80..a8d7894d 100644
--- a/jvagent/action/facebook_action/endpoints.py
+++ b/jvagent/action/facebook_action/endpoints.py
@@ -20,7 +20,12 @@
from .facebook_api import FacebookAPI
from .messenger_message_coalescer import MessengerMessageCoalescer
from .messenger_webhook_helpers import (
+ FEED_COMMENT_UTTERANCE_MAX,
+ FEED_REACTION_UTTERANCE_MAX,
+ _synthesize_reaction_utterance,
prime_messenger_sender_actions,
+ process_feed_comment_interaction_async,
+ process_feed_reaction_interaction_async,
process_messenger_interaction_async,
resolve_messenger_inbound_event,
verify_meta_messenger_signature,
@@ -664,7 +669,10 @@ async def messenger_interact_webhook_events(request: Request, agent_id: str) ->
raise HTTPException(status_code=400, detail="Invalid JSON body")
events = FacebookAPI.iter_messenger_user_text_events(payload)
- if not events:
+ feed_comment_events = FacebookAPI.iter_feed_comment_events(payload)
+ feed_reaction_events = FacebookAPI.iter_feed_reaction_events(payload)
+
+ if not events and not feed_comment_events and not feed_reaction_events:
return {"status": "ignored", "response": None}
access_control_action = await agent.get_access_control_action()
@@ -748,6 +756,191 @@ async def handle_merged_messenger_event(merged_event: Dict[str, Any]) -> None:
handle_merged_messenger_event,
)
+ # Process feed/comment events (Facebook Page comments)
+ for comment_event in feed_comment_events:
+ sender = str(comment_event.get("sender_id") or "").strip()
+ if not sender:
+ continue
+ page_id = str(comment_event.get("page_id") or "").strip()
+ # Defense-in-depth: skip if sender is the Page itself (should already
+ # be filtered by iter_feed_comment_events, but guard here too).
+ if sender and page_id and sender == page_id:
+ logger.debug(
+ "Skipping feed comment from Page %s (self-reply loop prevention)",
+ sender,
+ )
+ continue
+ comment_text = str(comment_event.get("message") or "").strip()
+ if not comment_text:
+ continue
+ if len(comment_text) > FEED_COMMENT_UTTERANCE_MAX:
+ # Truncate rather than drop, matching the reaction path below.
+ # Dropping left a commenter with no reply and no log line to
+ # explain it, and Facebook comments run far longer than this cap.
+ logger.info(
+ "Feed comment %s truncated for the agent turn (%d > %d chars)",
+ str(comment_event.get("comment_id") or "?"),
+ len(comment_text),
+ FEED_COMMENT_UTTERANCE_MAX,
+ )
+ comment_text = comment_text[: FEED_COMMENT_UTTERANCE_MAX - 3] + "..."
+
+ comment_id = str(comment_event.get("comment_id") or "").strip()
+ post_id = str(comment_event.get("post_id") or "").strip()
+ sender_name = str(comment_event.get("sender_name") or "").strip() or None
+
+ # Meta delivers at-least-once and retries. Without this a redelivery
+ # posts a SECOND public reply under the same comment — the same guard
+ # the Messenger path applies to `mid` above. comment_id is stable per
+ # comment, and only verb == "add" reaches here, so an edit cannot
+ # masquerade as a new comment.
+ if comment_id and not remember_meta_wamid(f"fbcomment:{comment_id}"):
+ logger.debug("Facebook duplicate comment ignored: %s", comment_id)
+ continue
+
+ feed_data_dict: Dict[str, Any] = {
+ "feed_payload": comment_event,
+ "facebook_comment": True,
+ "post_id": post_id,
+ "comment_id": comment_id,
+ "page_id": page_id,
+ }
+
+ has_access = True
+ if access_control_action:
+ has_access = await access_control_action.has_action_access(
+ user_id=sender,
+ action_label="FacebookAction",
+ channel="facebook_comment",
+ )
+ if not has_access:
+ log_access_denied(
+ agent_id=agent_id,
+ user_id=sender,
+ channel="facebook_comment",
+ action_label="FacebookAction",
+ stage="feed_comment",
+ )
+ continue
+
+ # Background the turn like the Messenger path: awaiting a full
+ # orchestrator turn here holds the webhook response open, Meta times
+ # the delivery out and retries, and the retry arrives as another
+ # inbound comment. create_task returns None where the runtime cannot
+ # background (serverless), where awaiting inline is the only option.
+ comment_task = await create_task(
+ process_feed_comment_interaction_async(
+ comment_text,
+ sender,
+ agent_id,
+ agent,
+ feed_data_dict,
+ sender_name=sender_name,
+ ),
+ name=f"facebook_comment_interaction_{sender}",
+ )
+ if comment_task is None:
+ await process_feed_comment_interaction_async(
+ comment_text,
+ sender,
+ agent_id,
+ agent,
+ feed_data_dict,
+ sender_name=sender_name,
+ )
+
+ # Process feed/reaction events (Facebook Page reactions)
+ for reaction_event in feed_reaction_events:
+ sender = str(reaction_event.get("sender_id") or "").strip()
+ if not sender:
+ continue
+ page_id = str(reaction_event.get("page_id") or "").strip()
+ # Defense-in-depth: skip if sender is the Page itself.
+ if sender and page_id and sender == page_id:
+ logger.debug(
+ "Skipping feed reaction from Page %s (self-loop prevention)",
+ sender,
+ )
+ continue
+ reaction_type = str(reaction_event.get("reaction_type") or "").strip()
+ if not reaction_type:
+ continue
+
+ post_id = str(reaction_event.get("post_id") or "").strip()
+ comment_id = str(reaction_event.get("comment_id") or "").strip()
+ sender_name = str(reaction_event.get("sender_name") or "").strip() or None
+
+ utterance = _synthesize_reaction_utterance(
+ reaction_type, sender_name or "", comment_id, post_id
+ )
+ if len(utterance) > FEED_REACTION_UTTERANCE_MAX:
+ utterance = utterance[: FEED_REACTION_UTTERANCE_MAX - 3] + "..."
+
+ # Same at-least-once delivery guard as comments. A reaction carries no
+ # id of its own, so the key is composed — and it INCLUDES the event
+ # timestamp on purpose: a retry repeats the timestamp, while a user who
+ # removes and re-adds a reaction produces a new one and is still heard.
+ reaction_key = ":".join(
+ (
+ "fbreaction",
+ post_id,
+ comment_id,
+ sender,
+ reaction_type,
+ str(reaction_event.get("timestamp") or ""),
+ )
+ )
+ if not remember_meta_wamid(reaction_key):
+ logger.debug("Facebook duplicate reaction ignored: %s", reaction_key)
+ continue
+
+ reaction_data_dict: Dict[str, Any] = {
+ "feed_payload": reaction_event,
+ "facebook_reaction": True,
+ "post_id": post_id,
+ "comment_id": comment_id,
+ "reaction_type": reaction_type,
+ "page_id": page_id,
+ }
+
+ has_access = True
+ if access_control_action:
+ has_access = await access_control_action.has_action_access(
+ user_id=sender,
+ action_label="FacebookAction",
+ channel="facebook_reaction",
+ )
+ if not has_access:
+ log_access_denied(
+ agent_id=agent_id,
+ user_id=sender,
+ channel="facebook_reaction",
+ action_label="FacebookAction",
+ stage="feed_reaction",
+ )
+ continue
+
+ reaction_task = await create_task(
+ process_feed_reaction_interaction_async(
+ utterance,
+ sender,
+ agent_id,
+ agent,
+ reaction_data_dict,
+ sender_name=sender_name,
+ ),
+ name=f"facebook_reaction_interaction_{sender}",
+ )
+ if reaction_task is None:
+ await process_feed_reaction_interaction_async(
+ utterance,
+ sender,
+ agent_id,
+ agent,
+ reaction_data_dict,
+ sender_name=sender_name,
+ )
+
return {"status": "received"}
@@ -805,3 +998,39 @@ async def facebook_register_webhook(
)
_raise_if_graph_error(action_id, result)
return {"success": True, "result": result}
+
+
+@endpoint(
+ "/actions/{action_id}/facebook/page/subscribe-app",
+ methods=["POST"],
+ auth=True,
+ roles=["admin"],
+ tags=["Facebook Action"],
+ summary="Install the app on the Page to receive feed/comment webhook events",
+)
+async def facebook_subscribe_app_to_page(
+ action_id: str,
+) -> Dict[str, Any]:
+ """Subscribe the app to the Facebook Page so it receives Page-level events.
+
+ This is required in addition to the app-level webhook subscription
+ (``/actions/{action_id}/facebook/webhook/register``). Meta requires both:
+
+ 1. App-level subscription (``/{app_id}/subscriptions``) — registers
+ which fields (feed, messages, etc.) the webhook should receive.
+ 2. Per-page installation (``/{page_id}/subscribed_apps``) — tells Meta
+ to deliver Page events for this specific Page.
+
+ Without step 2, ``feed`` events (comments, reactions) are not delivered
+ even if ``feed`` is in the webhook subscription fields.
+
+ Requires ``pages_manage_metadata`` permission on the Page access token.
+ """
+ action = await _require_facebook_action(action_id)
+ result = await action.subscribe_app_to_page()
+ if result.get("status") == "error":
+ raise ValidationError(
+ message=f"Failed to subscribe app to Page: {result.get('error', 'unknown')}",
+ details={"action_id": action_id, "result": result},
+ )
+ return result
diff --git a/jvagent/action/facebook_action/facebook_action.py b/jvagent/action/facebook_action/facebook_action.py
index b2db9d43..92c10198 100644
--- a/jvagent/action/facebook_action/facebook_action.py
+++ b/jvagent/action/facebook_action/facebook_action.py
@@ -580,6 +580,56 @@ def _register() -> Dict[str, Any]:
)
return {"status": "error", "error": str(e)}
+ async def subscribe_app_to_page(self) -> Dict[str, Any]:
+ """Install the app on the Page so it receives Page-level webhook events.
+
+ Meta requires two steps for Page events (feed, comments, reactions):
+ 1. App-level subscription via ``register_messenger_webhook_subscription``
+ (``POST /{app_id}/subscriptions``).
+ 2. Per-page installation via this method (``POST /{page_id}/subscribed_apps``).
+
+ Without this second step, ``feed`` webhook events (comments on Page posts)
+ are not delivered even if ``feed`` is in the subscribed fields.
+
+ Requires ``pages_manage_metadata`` permission on the Page access token.
+ """
+ self._apply_env_defaults()
+ if not self.is_configured():
+ return {
+ "status": "skipped",
+ "reason": "Facebook action is not configured",
+ "issues": self._config_issues(),
+ }
+ page_token = self._page_access_token()
+ if not page_token:
+ await self._maybe_resolve_page_access_token()
+ page_token = self._page_access_token()
+ if not page_token:
+ return {
+ "status": "skipped",
+ "reason": "No page access token available (set FACEBOOK_PAGE_ACCESS_TOKEN or ensure user token has pages_show_list)",
+ }
+ try:
+
+ def _subscribe() -> Dict[str, Any]:
+ return self.api().subscribe_app_to_page()
+
+ result = await asyncio.to_thread(_subscribe)
+ if isinstance(result, dict) and result.get("error"):
+ logger.warning(
+ "Meta app-to-page subscription Graph error: %s",
+ result.get("error"),
+ )
+ else:
+ logger.info(
+ "App subscribed to Page (page_id=%s) — feed/comment events enabled",
+ self.page_id,
+ )
+ return {"status": "ok", "page_id": self.page_id, "result": result}
+ except Exception as e:
+ logger.error("subscribe_app_to_page failed: %s", e, exc_info=True)
+ return {"status": "error", "error": str(e)}
+
async def on_register(self) -> None:
self._apply_env_defaults()
if self._base_graph_config_issues():
@@ -587,6 +637,9 @@ async def on_register(self) -> None:
return
await self._maybe_resolve_page_access_token()
await self._ensure_messenger_webhook_url()
+ sub = await self.subscribe_app_to_page()
+ if sub.get("status") not in ("ok", "skipped"):
+ logger.warning("FacebookAction on_register: subscribe_app_to_page: %s", sub)
logger.debug("Facebook action registered")
async def on_reload(self) -> None:
@@ -618,6 +671,11 @@ async def on_reload(self) -> None:
"FacebookAction on_reload: register_messenger_webhook_subscription: %s",
reg,
)
+ sub = await self.subscribe_app_to_page()
+ if sub.get("status") not in ("ok", "skipped"):
+ logger.warning(
+ "FacebookAction on_reload: subscribe_app_to_page: %s", sub
+ )
async def on_startup(self) -> None:
"""Register Messenger response-bus filter and adapter when configured."""
@@ -659,6 +717,21 @@ async def on_startup(self) -> None:
if not await MessengerAdapter(action=self).initialize(agent=agent):
logger.error("MessengerAdapter initialization failed")
+ from .facebook_comment_adapter import FacebookCommentAdapter
+ from .facebook_comment_filter import FacebookCommentFilter
+ from .facebook_reaction_adapter import FacebookReactionAdapter
+
+ if not await FacebookCommentFilter(
+ channels=["facebook_comment"], priority=100
+ ).initialize(agent=agent):
+ logger.warning("FacebookCommentFilter initialization failed")
+
+ if not await FacebookCommentAdapter(action=self).initialize(agent=agent):
+ logger.error("FacebookCommentAdapter initialization failed")
+
+ if not await FacebookReactionAdapter(action=self).initialize(agent=agent):
+ logger.error("FacebookReactionAdapter initialization failed")
+
skip_reg = (
os.environ.get("FACEBOOK_SKIP_STARTUP_WEBHOOK_REGISTRATION", "").lower()
== "true"
@@ -718,6 +791,17 @@ async def _run_startup_messenger_webhook_register(self) -> None:
"Facebook Messenger webhook registration: %s",
reg,
)
+
+ sub = await self.subscribe_app_to_page()
+ if sub.get("status") == "ok":
+ logger.info("Facebook app subscribed to Page for feed events")
+ elif sub.get("status") == "skipped":
+ logger.info(
+ "Facebook app-to-page subscription skipped: %s",
+ sub.get("reason"),
+ )
+ else:
+ logger.warning("Facebook app-to-page subscription: %s", sub)
except Exception as e:
logger.warning(
"Facebook Messenger startup webhook register failed: %s",
@@ -830,7 +914,7 @@ async def _fallback_deferred() -> None:
_task.add_done_callback(_BACKGROUND_TASKS.discard)
async def ensure_adapter_registered(self) -> bool:
- """Ensure Messenger ChannelAdapter is registered (e.g. Lambda cold start)."""
+ """Ensure Messenger, FacebookComment and FacebookReaction ChannelAdapters are registered."""
if not self.is_configured():
return False
try:
@@ -840,12 +924,42 @@ async def ensure_adapter_registered(self) -> bool:
response_bus = await agent.get_response_bus()
if not response_bus:
return False
- existing = response_bus._channel_adapters.get("messenger")
- if existing and getattr(existing, "_initialized", False):
- return True
- from .messenger_adapter import MessengerAdapter
- return await MessengerAdapter(action=self).initialize(agent=agent)
+ messenger_ok = True
+ existing_messenger = response_bus._channel_adapters.get("messenger")
+ if not (
+ existing_messenger
+ and getattr(existing_messenger, "_initialized", False)
+ ):
+ from .messenger_adapter import MessengerAdapter
+
+ messenger_ok = await MessengerAdapter(action=self).initialize(
+ agent=agent
+ )
+
+ comment_ok = True
+ existing_comment = response_bus._channel_adapters.get("facebook_comment")
+ if not (
+ existing_comment and getattr(existing_comment, "_initialized", False)
+ ):
+ from .facebook_comment_adapter import FacebookCommentAdapter
+
+ comment_ok = await FacebookCommentAdapter(action=self).initialize(
+ agent=agent
+ )
+
+ reaction_ok = True
+ existing_reaction = response_bus._channel_adapters.get("facebook_reaction")
+ if not (
+ existing_reaction and getattr(existing_reaction, "_initialized", False)
+ ):
+ from .facebook_reaction_adapter import FacebookReactionAdapter
+
+ reaction_ok = await FacebookReactionAdapter(action=self).initialize(
+ agent=agent
+ )
+
+ return bool(messenger_ok and comment_ok and reaction_ok)
except Exception as e:
logger.error(
"FacebookAction: ensure_adapter_registered failed: %s",
diff --git a/jvagent/action/facebook_action/facebook_api.py b/jvagent/action/facebook_action/facebook_api.py
index 4824c06e..052285be 100644
--- a/jvagent/action/facebook_action/facebook_api.py
+++ b/jvagent/action/facebook_action/facebook_api.py
@@ -156,6 +156,47 @@ def register_session(self, webhook_url: str) -> Dict:
"POST", endpoint, params=params, data=body, json_body=None
)
+ def subscribe_app_to_page(self) -> Dict:
+ """Install the app on the Page so it receives Page-level webhook events.
+
+ Meta requires **two** steps for Page events (feed, comments, reactions):
+ 1. App-level subscription via ``register_session`` (``/{app_id}/subscriptions``).
+ 2. Per-page installation via this method (``/{page_id}/subscribed_apps``).
+
+ Without this second step, ``feed`` webhook events (comments on Page posts)
+ are not delivered, even if ``feed`` is in the subscribed fields.
+
+ Uses the **page access token** (requires ``pages_manage_metadata`` permission).
+
+ Returns:
+ Graph API response dict. On success Meta returns ``{"success": true}``.
+ """
+ endpoint = f"{self.page_id}/subscribed_apps"
+ fields_val = (
+ self.fields
+ or "feed,messages,messaging_postbacks,message_deliveries,standby,mention"
+ )
+ if isinstance(fields_val, list):
+ fields_val = ",".join(str(x) for x in fields_val)
+ params = {
+ "access_token": self._token_for_page(),
+ "subscribed_fields": str(fields_val),
+ }
+ return self.send_rest_request("POST", endpoint, params=params)
+
+ def list_subscribed_apps(self) -> Union[List, Dict]:
+ """List apps installed on the Page that receive webhook events.
+
+ Uses the page access token (requires ``pages_manage_metadata`` or
+ ``pages_read_engagement`` permission).
+
+ Returns:
+ List of app dicts or error dict.
+ """
+ endpoint = f"{self.page_id}/subscribed_apps"
+ params = {"access_token": self._token_for_page()}
+ return self.send_rest_request("GET", endpoint, params=params)
+
@staticmethod
def _messenger_coordinate_value(coords: Any, *keys: str) -> Optional[float]:
if not isinstance(coords, dict):
@@ -410,6 +451,192 @@ def parse_inbound_message(request: Dict) -> Dict:
)
return {"ok": False, "error": str(e)}
+ @staticmethod
+ def iter_feed_comment_events(
+ request: Dict[str, Any],
+ ) -> List[Dict[str, Any]]:
+ """Parse Meta Page webhook JSON and return inbound **feed comment** events.
+
+ This is the feed/comment counterpart to
+ :meth:`iter_messenger_user_text_events`. It processes entries with
+ ``"changes"`` (feed events) and extracts comment events where
+ ``item == "comment"``.
+
+ Skips: ``messaging`` entries (DMs), reactions, status changes, and
+ non-comment feed items.
+
+ Each returned dict includes:
+ ``sender_id``, ``sender_name``, ``page_id``, ``comment_id``,
+ ``post_id``, ``message`` (comment text), ``message_type`` (always
+ ``"comment"``), ``parent_id`` (parent post or comment ID),
+ ``data`` (raw request payload for downstream channel_gate parsing),
+ ``timestamp``.
+ """
+ out: List[Dict[str, Any]] = []
+ if not isinstance(request, dict):
+ return out
+ if request.get("object") != "page":
+ return out
+ entries = request.get("entry")
+ if not isinstance(entries, list):
+ return out
+
+ for entry in entries:
+ if not isinstance(entry, dict):
+ continue
+ page_id = str(entry.get("id", ""))
+ changes = entry.get("changes")
+ if not isinstance(changes, list):
+ continue
+ for change in changes:
+ if not isinstance(change, dict):
+ continue
+ value = change.get("value")
+ if not isinstance(value, dict):
+ continue
+ item = str(value.get("item", "")).strip().lower()
+ if item != "comment":
+ continue
+ verb = str(value.get("verb", "")).strip().lower()
+ if verb not in ("add",):
+ continue
+ from_obj = value.get("from") or {}
+ sender_id = (
+ str(from_obj.get("id", "")) if isinstance(from_obj, dict) else ""
+ )
+ sender_name = (
+ str(from_obj.get("name", "")) if isinstance(from_obj, dict) else ""
+ )
+ # Skip Page's own comments to prevent self-reply loops.
+ # When the Page replies via reply_to_comment(), Meta sends a
+ # webhook event with from.id == page_id — processing it would
+ # trigger another agent turn and an infinite reply loop.
+ if sender_id and sender_id == page_id:
+ FacebookAPI.logger.debug(
+ "iter_feed_comment_events: skipping Page's own comment "
+ "sender_id=%s == page_id=%s",
+ sender_id,
+ page_id,
+ )
+ continue
+ message = str(value.get("message", "") or "").strip()
+ comment_id = str(
+ value.get("comment_id", "") or value.get("id", "") or ""
+ ).strip()
+ post_id = str(value.get("post_id", "") or "").strip()
+ parent_id = str(value.get("parent_id", "") or post_id or "").strip()
+ ts = value.get("created_time") or value.get("timestamp")
+ try:
+ timestamp = int(ts) if ts is not None else 0
+ except (TypeError, ValueError):
+ timestamp = 0
+ out.append(
+ {
+ "sender_id": sender_id,
+ "sender_name": sender_name,
+ "page_id": page_id,
+ "comment_id": comment_id,
+ "post_id": post_id,
+ "message_type": "comment",
+ "message": message,
+ "parent_id": parent_id,
+ "timestamp": timestamp,
+ "data": request,
+ "verb": verb,
+ }
+ )
+ return out
+
+ @staticmethod
+ def iter_feed_reaction_events(
+ request: Dict[str, Any],
+ ) -> List[Dict[str, Any]]:
+ """Parse Meta Page webhook JSON and return inbound **feed reaction** events.
+
+ Processes entries with ``"changes"`` (feed events) and extracts
+ reaction events where ``item == "reaction"`` and ``verb == "add"``.
+
+ Skips: ``messaging`` entries (DMs), comments, status changes, remove
+ reactions, and the Page's own reactions (self-loop prevention).
+
+ Each returned dict includes:
+ ``sender_id``, ``sender_name``, ``page_id``, ``post_id``,
+ ``comment_id`` (empty string if reaction is on a post, not a comment),
+ ``reaction_type`` (like, love, wow, haha, sorry, anger),
+ ``message_type`` (always ``"reaction"``), ``data`` (raw request payload),
+ ``timestamp``.
+ """
+ out: List[Dict[str, Any]] = []
+ if not isinstance(request, dict):
+ return out
+ if request.get("object") != "page":
+ return out
+ entries = request.get("entry")
+ if not isinstance(entries, list):
+ return out
+
+ for entry in entries:
+ if not isinstance(entry, dict):
+ continue
+ page_id = str(entry.get("id", ""))
+ changes = entry.get("changes")
+ if not isinstance(changes, list):
+ continue
+ for change in changes:
+ if not isinstance(change, dict):
+ continue
+ value = change.get("value")
+ if not isinstance(value, dict):
+ continue
+ item = str(value.get("item", "")).strip().lower()
+ if item != "reaction":
+ continue
+ verb = str(value.get("verb", "")).strip().lower()
+ if verb != "add":
+ continue
+ from_obj = value.get("from") or {}
+ sender_id = (
+ str(from_obj.get("id", "")) if isinstance(from_obj, dict) else ""
+ )
+ sender_name = (
+ str(from_obj.get("name", "")) if isinstance(from_obj, dict) else ""
+ )
+ # Skip Page's own reactions to prevent self-loop.
+ if sender_id and sender_id == page_id:
+ FacebookAPI.logger.debug(
+ "iter_feed_reaction_events: skipping Page's own reaction "
+ "sender_id=%s == page_id=%s",
+ sender_id,
+ page_id,
+ )
+ continue
+ reaction_type = (
+ str(value.get("reaction_type", "") or "").strip().lower()
+ )
+ post_id = str(value.get("post_id", "") or "").strip()
+ comment_id = str(value.get("comment_id", "") or "").strip()
+ ts = value.get("created_time") or value.get("timestamp")
+ try:
+ timestamp = int(ts) if ts is not None else 0
+ except (TypeError, ValueError):
+ timestamp = 0
+ out.append(
+ {
+ "sender_id": sender_id,
+ "sender_name": sender_name,
+ "page_id": page_id,
+ "post_id": post_id,
+ "comment_id": comment_id,
+ "reaction_type": reaction_type,
+ "message_type": "reaction",
+ "message": reaction_type,
+ "timestamp": timestamp,
+ "data": request,
+ "verb": verb,
+ }
+ )
+ return out
+
def send_text_message(self, recipient_id: str, message: str) -> Dict:
"""Send text message to a Facebook user via Messenger."""
endpoint = f"{self.page_id}/messages"
diff --git a/jvagent/action/facebook_action/facebook_comment_adapter.py b/jvagent/action/facebook_action/facebook_comment_adapter.py
new file mode 100644
index 00000000..1a3976ee
--- /dev/null
+++ b/jvagent/action/facebook_action/facebook_comment_adapter.py
@@ -0,0 +1,161 @@
+"""Facebook Page comment channel adapter for the response bus.
+
+Delivers agent replies as comment replies on Facebook Page posts using
+the Graph API ``/{comment_id}/comments`` endpoint. Falls back to posting
+a new comment on the original post (``/{post_id}/comments``) when only
+``post_id`` is available, or to a Messenger DM when neither is present.
+"""
+
+import asyncio
+import logging
+from typing import Any, Dict
+
+from jvagent.action.response.channel_adapter import ChannelAdapter
+from jvagent.action.response.message import ResponseMessage
+
+from .facebook_comment_text import to_facebook_comment_text
+
+logger = logging.getLogger(__name__)
+
+
+class FacebookCommentAdapter(ChannelAdapter):
+ """Deliver ``ResponseMessage`` content as Facebook Page comment replies.
+
+ The adapter resolves the reply target from ``message.metadata``, which
+ carries the original webhook payload fields set in
+ ``messenger_webhook_helpers.create_feed_comment_walker``:
+
+ - ``comment_id`` – reply to the specific comment (preferred)
+ - ``post_id`` – post a new comment on the original post (fallback)
+ - ``user_id`` – fall back to a Messenger DM if neither is available
+ """
+
+ def __init__(self, action: Any) -> None:
+ super().__init__(channel="facebook_comment")
+ self.action = action
+ self._user_locks: Dict[str, asyncio.Lock] = {}
+
+ def _get_user_lock(self, user_id: str) -> asyncio.Lock:
+ if user_id not in self._user_locks:
+ if len(self._user_locks) >= 1000:
+ for key in list(self._user_locks.keys())[:100]:
+ del self._user_locks[key]
+ self._user_locks[user_id] = asyncio.Lock()
+ return self._user_locks[user_id]
+
+ def _graph_failed(self, result: Any) -> bool:
+ if not isinstance(result, dict):
+ return True
+ return bool(result.get("error"))
+
+ @staticmethod
+ def _strip_markdown(text: str) -> str:
+ """Reduce markdown to plain text for a public Facebook comment.
+
+ Delegates to the shared helper so this and ``FacebookCommentFilter``
+ cannot drift apart.
+ """
+ return to_facebook_comment_text(text)
+
+ async def send(self, message: ResponseMessage) -> bool:
+ if not self.action or not self.action.is_configured():
+ logger.debug("FacebookCommentAdapter: FacebookAction not configured")
+ return False
+
+ comment_id = str(message.metadata.get("comment_id", "") or "").strip()
+ post_id = str(message.metadata.get("post_id", "") or "").strip()
+ user_id = str(message.user_id or "").strip()
+
+ text = self._strip_markdown(str(message.content or "").strip())
+ if not text:
+ logger.debug("FacebookCommentAdapter: empty message %s", message.id)
+ return False
+
+ api = self.action.api()
+
+ if comment_id:
+ logger.info("FacebookCommentAdapter: replying to comment %s", comment_id)
+
+ def _reply() -> Any:
+ return api.reply_to_comment(comment_id, text)
+
+ lock = self._get_user_lock(f"comment:{comment_id}")
+ async with lock:
+ try:
+ result = await asyncio.to_thread(_reply)
+ if self._graph_failed(result):
+ logger.error(
+ "FacebookCommentAdapter: reply_to_comment failed: %s",
+ result,
+ )
+ return False
+ return True
+ except Exception as e:
+ logger.error(
+ "FacebookCommentAdapter: reply error for comment %s: %s",
+ comment_id,
+ e,
+ exc_info=True,
+ )
+ return False
+
+ if post_id:
+ logger.info("FacebookCommentAdapter: commenting on post %s", post_id)
+
+ def _comment() -> Any:
+ return api.comment_on_post(post_id, text)
+
+ lock = self._get_user_lock(f"post:{post_id}")
+ async with lock:
+ try:
+ result = await asyncio.to_thread(_comment)
+ if self._graph_failed(result):
+ logger.error(
+ "FacebookCommentAdapter: comment_on_post failed: %s",
+ result,
+ )
+ return False
+ return True
+ except Exception as e:
+ logger.error(
+ "FacebookCommentAdapter: comment error for post %s: %s",
+ post_id,
+ e,
+ exc_info=True,
+ )
+ return False
+
+ if user_id:
+ logger.info(
+ "FacebookCommentAdapter: no comment_id/post_id, sending Messenger DM to %s",
+ user_id,
+ )
+
+ def _dm() -> Any:
+ return api.send_text_message(user_id, text)
+
+ lock = self._get_user_lock(user_id)
+ async with lock:
+ try:
+ result = await asyncio.to_thread(_dm)
+ if self._graph_failed(result):
+ logger.error(
+ "FacebookCommentAdapter: Messenger DM fallback failed: %s",
+ result,
+ )
+ return False
+ return True
+ except Exception as e:
+ logger.error(
+ "FacebookCommentAdapter: DM error for %s: %s",
+ user_id,
+ e,
+ exc_info=True,
+ )
+ return False
+
+ logger.error(
+ "FacebookCommentAdapter: no comment_id, post_id, or user_id on message %s",
+ message.id,
+ )
+ return False
diff --git a/jvagent/action/facebook_action/facebook_comment_filter.py b/jvagent/action/facebook_action/facebook_comment_filter.py
new file mode 100644
index 00000000..c36d3c98
--- /dev/null
+++ b/jvagent/action/facebook_action/facebook_comment_filter.py
@@ -0,0 +1,38 @@
+"""Facebook Page comment channel filter for the response bus.
+
+Strips markdown and HTML formatting that is inappropriate for public
+Facebook comments (plain text only), and truncates to the Facebook
+comment length limit.
+"""
+
+import logging
+from typing import List, Optional
+
+from jvagent.action.response.channel_filter import ChannelFilter
+from jvagent.action.response.message import ResponseMessage
+
+from .facebook_comment_text import to_facebook_comment_text
+
+logger = logging.getLogger(__name__)
+
+
+class FacebookCommentFilter(ChannelFilter):
+ """Transform markdown/HTML to plain text suitable for Facebook Page comments.
+
+ Facebook comments are plain text — no markdown, no HTML. This filter
+ strips common formatting artifacts left by the ReplyAction voice. The
+ transformation lives in ``facebook_comment_text`` so the adapter applies
+ exactly the same one.
+ """
+
+ def __init__(
+ self, channels: Optional[List[str]] = None, priority: int = 100
+ ) -> None:
+ if channels is None:
+ channels = ["facebook_comment"]
+ super().__init__(channels=channels, priority=priority)
+
+ async def filter(self, message: ResponseMessage) -> None:
+ if not message.content:
+ return
+ message.content = to_facebook_comment_text(str(message.content))
diff --git a/jvagent/action/facebook_action/facebook_comment_text.py b/jvagent/action/facebook_action/facebook_comment_text.py
new file mode 100644
index 00000000..feceb057
--- /dev/null
+++ b/jvagent/action/facebook_action/facebook_comment_text.py
@@ -0,0 +1,62 @@
+"""Plain-text shaping for Facebook Page comments.
+
+Facebook comments are plain text — no markdown, no HTML. Both the channel
+filter (bus pipeline) and the adapter (delivery, for content that never went
+through the filter) need the same transformation, and having it written twice
+means the two drift: a fix to one silently leaves the other emitting raw
+markdown into a public comment.
+"""
+
+from __future__ import annotations
+
+import re
+
+# Facebook's documented comment ceiling. Defined once so the filter and the
+# adapter cannot disagree about where to cut.
+FACEBOOK_COMMENT_MAX_LENGTH = 9000
+
+_BOLD = re.compile(r"\*\*(.+?)\*\*")
+_ITALIC = re.compile(r"\*(.+?)\*")
+_HEADING = re.compile(r"^#{1,6}\s+", re.MULTILINE)
+_LINK = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
+
+_HTML_REPLACEMENTS = (
+ ("
", "\n"),
+ ("
", "\n"),
+ ("", ""),
+ ("", ""),
+ ("", ""),
+ ("", ""),
+ ("
", "\n"), + ("
", ""), +) + + +def _link_to_text(match: "re.Match[str]") -> str: + """``[label](url)`` -> ``label (url)``, or just ``label`` when redundant. + + A comment reader has no hover target and no way to recover a dropped URL, + so the destination is kept inline rather than discarded. When the label + already *is* the URL, repeating it would only add noise. + """ + label = match.group(1).strip() + url = match.group(2).strip() + if not url or label == url: + return label + return f"{label} ({url})" + + +def to_facebook_comment_text(text: str) -> str: + """Reduce markdown/HTML to plain text and bound it to the comment limit.""" + if not text: + return "" + out = _BOLD.sub(r"\1", text) + out = _ITALIC.sub(r"\1", out) + out = _HEADING.sub("", out) + out = _LINK.sub(_link_to_text, out) + for needle, replacement in _HTML_REPLACEMENTS: + out = out.replace(needle, replacement) + out = out.strip() + if len(out) > FACEBOOK_COMMENT_MAX_LENGTH: + out = out[: FACEBOOK_COMMENT_MAX_LENGTH - 3].rstrip() + "..." + return out diff --git a/jvagent/action/facebook_action/facebook_reaction_adapter.py b/jvagent/action/facebook_action/facebook_reaction_adapter.py new file mode 100644 index 00000000..99c113fe --- /dev/null +++ b/jvagent/action/facebook_action/facebook_reaction_adapter.py @@ -0,0 +1,38 @@ +"""Facebook Page reaction channel adapter for the response bus. + +Awareness-only adapter: reactions are ingested into the agent pipeline for +context (e.g. moderation triggers), but the agent does **not** auto-reply +or auto-react. ``send()`` logs and returns ``True`` without any Graph API +call. Custom tools/actions can inspect ``message.metadata`` for +``reaction_type``, ``post_id``, ``comment_id`` etc. if they want to act. +""" + +import logging +from typing import Any + +from jvagent.action.response.channel_adapter import ChannelAdapter +from jvagent.action.response.message import ResponseMessage + +logger = logging.getLogger(__name__) + + +class FacebookReactionAdapter(ChannelAdapter): + """No-op adapter for ``channel="facebook_reaction"``. + + The agent receives reaction events for awareness but does not deliver + any outbound response. ``send()`` silently succeeds so the response + bus considers the message delivered. + """ + + def __init__(self, action: Any = None) -> None: + super().__init__(channel="facebook_reaction") + self.action = action + + async def send(self, message: ResponseMessage) -> bool: + logger.debug( + "FacebookReactionAdapter: awareness-only, not replying to reaction " + "(message_id=%s, reaction_type=%s)", + message.id, + message.metadata.get("reaction_type", ""), + ) + return True diff --git a/jvagent/action/facebook_action/messenger_webhook_helpers.py b/jvagent/action/facebook_action/messenger_webhook_helpers.py index 57ca05bd..3b1ac18a 100644 --- a/jvagent/action/facebook_action/messenger_webhook_helpers.py +++ b/jvagent/action/facebook_action/messenger_webhook_helpers.py @@ -549,3 +549,201 @@ async def process_messenger_interaction_async( logger.error( "Error in messenger interaction for %s: %s", sender, e, exc_info=True ) + + +# --------------------------------------------------------------------------- +# Feed / comment webhook helpers (Facebook Page comment events) +# --------------------------------------------------------------------------- + +FEED_COMMENT_UTTERANCE_MAX = 2000 + +logger_feed = logging.getLogger(f"{__name__}.feed_comment") + + +async def create_feed_comment_walker( + agent_id: str, + utterance: str, + sender: str, + data_dict: Dict[str, Any], + sender_name: Optional[str] = None, +) -> Optional[InteractWalker]: + """Create an InteractWalker for a Facebook Page comment event. + + Uses ``channel="facebook_comment"`` so the channel_gate can distinguish + comments from Messenger DMs. + + Args: + agent_id: Agent ID to interact with. + utterance: Comment text (possibly empty if media-only). + sender: Facebook user ID of the commenter (not PSID — may be + a global user ID for Page commenters). + data_dict: Additional data dict (includes ``feed_payload``). + sender_name: Display name of the commenter. + """ + try: + convo_obj = await get_conversation_with_lock(sender) + + if convo_obj and getattr(convo_obj, "session_id", None): + return InteractWalker( + agent_id=agent_id, + utterance=utterance, + channel="facebook_comment", + data=data_dict, + session_id=convo_obj.session_id, + user_name=sender_name, + stream=False, + ) + return InteractWalker( + agent_id=agent_id, + utterance=utterance, + channel="facebook_comment", + data=data_dict, + user_id=sender, + user_name=sender_name, + stream=False, + ) + except Exception as e: + logger_feed.error("Error creating feed-comment walker for %s: %s", sender, e) + return None + + +async def process_feed_comment_interaction_async( + utterance: str, + sender: str, + agent_id: str, + agent: Any, + data_dict: Dict[str, Any], + sender_name: Optional[str] = None, +) -> None: + """Background task: adapter registration, walker spawn, finalize for a feed comment.""" + fb_action: Any = None + try: + fb_action = await agent.get_action_by_type("FacebookAction") + if fb_action: + await fb_action.ensure_adapter_registered() + except Exception as e: + logger_feed.warning( + "Feed-comment adapter ensure failed for agent %s: %s", agent_id, e + ) + + try: + walker = await create_feed_comment_walker( + agent_id, utterance, sender, data_dict, sender_name=sender_name + ) + if not walker: + return + await walker.spawn(agent) + await finalize_interaction_from_webhook(walker, agent_id, sender) + except DatabaseError: + raise + except Exception as e: + logger_feed.error( + "Error in feed-comment interaction for %s: %s", sender, e, exc_info=True + ) + + +# --------------------------------------------------------------------------- +# Feed / reaction webhook helpers (Facebook Page reaction events) +# --------------------------------------------------------------------------- + +REACTION_TYPE_LABELS: Dict[str, str] = { + "like": "👍 like", + "love": "❤️ love", + "wow": "😮 wow", + "haha": "😂 haha", + "sorry": "😢 sorry", + "anger": "😡 anger", +} + +FEED_REACTION_UTTERANCE_MAX = 500 + +logger_reaction = logging.getLogger(f"{__name__}.feed_reaction") + + +def _synthesize_reaction_utterance( + reaction_type: str, + sender_name: str, + comment_id: str, + post_id: str, +) -> str: + """Build a short utterance string that describes the reaction event.""" + label = REACTION_TYPE_LABELS.get(reaction_type, reaction_type) + target = "a comment" if comment_id else "a post" + name = sender_name or "Someone" + return f"[REACTION] {name} reacted with {label} on {target}." + + +async def create_feed_reaction_walker( + agent_id: str, + utterance: str, + sender: str, + data_dict: Dict[str, Any], + sender_name: Optional[str] = None, +) -> Optional[InteractWalker]: + """Create an InteractWalker for a Facebook Page reaction event. + + Uses ``channel="facebook_reaction"`` so the channel_gate and + FacebookReactionAdapter can distinguish reactions from comments and DMs. + """ + try: + convo_obj = await get_conversation_with_lock(sender) + + if convo_obj and getattr(convo_obj, "session_id", None): + return InteractWalker( + agent_id=agent_id, + utterance=utterance, + channel="facebook_reaction", + data=data_dict, + session_id=convo_obj.session_id, + user_name=sender_name, + stream=False, + ) + return InteractWalker( + agent_id=agent_id, + utterance=utterance, + channel="facebook_reaction", + data=data_dict, + user_id=sender, + user_name=sender_name, + stream=False, + ) + except Exception as e: + logger_reaction.error( + "Error creating feed-reaction walker for %s: %s", sender, e + ) + return None + + +async def process_feed_reaction_interaction_async( + utterance: str, + sender: str, + agent_id: str, + agent: Any, + data_dict: Dict[str, Any], + sender_name: Optional[str] = None, +) -> None: + """Background task: adapter registration, walker spawn, finalize for a feed reaction.""" + fb_action: Any = None + try: + fb_action = await agent.get_action_by_type("FacebookAction") + if fb_action: + await fb_action.ensure_adapter_registered() + except Exception as e: + logger_reaction.warning( + "Feed-reaction adapter ensure failed for agent %s: %s", agent_id, e + ) + + try: + walker = await create_feed_reaction_walker( + agent_id, utterance, sender, data_dict, sender_name=sender_name + ) + if not walker: + return + await walker.spawn(agent) + await finalize_interaction_from_webhook(walker, agent_id, sender) + except DatabaseError: + raise + except Exception as e: + logger_reaction.error( + "Error in feed-reaction interaction for %s: %s", sender, e, exc_info=True + ) diff --git a/tests/action/facebook_action/test_feed_comment_text.py b/tests/action/facebook_action/test_feed_comment_text.py new file mode 100644 index 00000000..50fe18af --- /dev/null +++ b/tests/action/facebook_action/test_feed_comment_text.py @@ -0,0 +1,65 @@ +"""Plain-text shaping for Facebook Page comments. + +The filter (bus pipeline) and the adapter (delivery) must apply the *same* +transformation — two copies drift, and the failure is raw markdown in a public +comment under a brand's post. +""" + +from __future__ import annotations + +from jvagent.action.facebook_action.facebook_comment_adapter import ( + FacebookCommentAdapter, +) +from jvagent.action.facebook_action.facebook_comment_text import ( + FACEBOOK_COMMENT_MAX_LENGTH, + to_facebook_comment_text, +) + + +class TestMarkdownStripping: + def test_bold_italic_and_headings(self) -> None: + out = to_facebook_comment_text("## Title\n**bold** and *italic*") + assert out == "Title\nbold and italic" + + def test_html_tags_become_plain_text(self) -> None: + out = to_facebook_comment_text("a