Coverage for integrations / channels / extensions / messenger_adapter.py: 30.6%
399 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2Facebook Messenger Channel Adapter
4Implements Facebook Messenger messaging via Meta Graph API.
5Based on HevolveBot extension patterns for Messenger.
7Features:
8- Send API for all message types
9- Message templates (generic, button, receipt, etc.)
10- Quick replies
11- Persistent menu
12- Sender actions (typing indicators)
13- Message tags for re-engagement
14- One-time notifications
15- Handover protocol
16- Webhook handling
17- Signature verification
18"""
20from __future__ import annotations
22import asyncio
23import logging
24import os
25import json
26import hashlib
27import hmac
28from typing import Optional, List, Dict, Any, Callable, Union
29from datetime import datetime
30from dataclasses import dataclass, field
31from enum import Enum
32try:
33 import aiohttp
34 HAS_AIOHTTP = True
35except ImportError:
36 HAS_AIOHTTP = False
38from ..base import (
39 ChannelAdapter,
40 ChannelConfig,
41 ChannelStatus,
42 Message,
43 MessageType,
44 MediaAttachment,
45 SendResult,
46 ChannelConnectionError,
47 ChannelSendError,
48 ChannelRateLimitError,
49)
51logger = logging.getLogger(__name__)
54# Meta Graph API endpoints
55GRAPH_API_VERSION = "v18.0"
56GRAPH_API_BASE = f"https://graph.facebook.com/{GRAPH_API_VERSION}"
59class MessagingType(Enum):
60 """Messaging types for send API."""
61 RESPONSE = "RESPONSE"
62 UPDATE = "UPDATE"
63 MESSAGE_TAG = "MESSAGE_TAG"
66class MessageTag(Enum):
67 """Message tags for re-engagement."""
68 CONFIRMED_EVENT_UPDATE = "CONFIRMED_EVENT_UPDATE"
69 POST_PURCHASE_UPDATE = "POST_PURCHASE_UPDATE"
70 ACCOUNT_UPDATE = "ACCOUNT_UPDATE"
71 HUMAN_AGENT = "HUMAN_AGENT"
74class SenderAction(Enum):
75 """Sender actions."""
76 TYPING_ON = "typing_on"
77 TYPING_OFF = "typing_off"
78 MARK_SEEN = "mark_seen"
81@dataclass
82class MessengerConfig(ChannelConfig):
83 """Messenger-specific configuration."""
84 page_access_token: str = ""
85 app_secret: str = ""
86 verify_token: str = ""
87 page_id: Optional[str] = None
88 enable_templates: bool = True
89 enable_quick_replies: bool = True
90 enable_persistent_menu: bool = False
91 api_version: str = GRAPH_API_VERSION
94@dataclass
95class QuickReply:
96 """Quick reply button."""
97 content_type: str = "text" # text, location, user_phone_number, user_email
98 title: Optional[str] = None
99 payload: Optional[str] = None
100 image_url: Optional[str] = None
102 def to_dict(self) -> Dict[str, Any]:
103 """Convert to API format."""
104 result = {"content_type": self.content_type}
105 if self.title:
106 result["title"] = self.title[:20] # Max 20 chars
107 if self.payload:
108 result["payload"] = self.payload[:1000] # Max 1000 chars
109 if self.image_url:
110 result["image_url"] = self.image_url
111 return result
114@dataclass
115class Button:
116 """Button for templates."""
117 type: str # web_url, postback, phone_number, etc.
118 title: str
119 url: Optional[str] = None
120 payload: Optional[str] = None
121 webview_height_ratio: str = "full" # compact, tall, full
122 messenger_extensions: bool = False
124 def to_dict(self) -> Dict[str, Any]:
125 """Convert to API format."""
126 result = {
127 "type": self.type,
128 "title": self.title[:20], # Max 20 chars
129 }
130 if self.type == "web_url":
131 result["url"] = self.url
132 result["webview_height_ratio"] = self.webview_height_ratio
133 result["messenger_extensions"] = self.messenger_extensions
134 elif self.type == "postback":
135 result["payload"] = self.payload or self.title
136 elif self.type == "phone_number":
137 result["payload"] = self.payload
138 return result
141@dataclass
142class GenericElement:
143 """Generic template element."""
144 title: str
145 subtitle: Optional[str] = None
146 image_url: Optional[str] = None
147 default_action: Optional[Dict[str, Any]] = None
148 buttons: List[Button] = field(default_factory=list)
150 def to_dict(self) -> Dict[str, Any]:
151 """Convert to API format."""
152 result = {"title": self.title[:80]} # Max 80 chars
153 if self.subtitle:
154 result["subtitle"] = self.subtitle[:80]
155 if self.image_url:
156 result["image_url"] = self.image_url
157 if self.default_action:
158 result["default_action"] = self.default_action
159 if self.buttons:
160 result["buttons"] = [btn.to_dict() for btn in self.buttons[:3]] # Max 3 buttons
161 return result
164@dataclass
165class GenericTemplate:
166 """Generic template builder."""
167 elements: List[GenericElement] = field(default_factory=list)
168 image_aspect_ratio: str = "horizontal" # horizontal, square
170 def add_element(
171 self,
172 title: str,
173 subtitle: Optional[str] = None,
174 image_url: Optional[str] = None,
175 buttons: Optional[List[Button]] = None,
176 ) -> 'GenericTemplate':
177 """Add an element to the template."""
178 self.elements.append(GenericElement(
179 title=title,
180 subtitle=subtitle,
181 image_url=image_url,
182 buttons=buttons or [],
183 ))
184 return self
186 def to_dict(self) -> Dict[str, Any]:
187 """Convert to API format."""
188 return {
189 "attachment": {
190 "type": "template",
191 "payload": {
192 "template_type": "generic",
193 "image_aspect_ratio": self.image_aspect_ratio,
194 "elements": [elem.to_dict() for elem in self.elements[:10]], # Max 10
195 }
196 }
197 }
200@dataclass
201class ButtonTemplate:
202 """Button template builder."""
203 text: str
204 buttons: List[Button] = field(default_factory=list)
206 def add_url_button(self, title: str, url: str, **kwargs) -> 'ButtonTemplate':
207 """Add a URL button."""
208 self.buttons.append(Button(type="web_url", title=title, url=url, **kwargs))
209 return self
211 def add_postback_button(self, title: str, payload: str) -> 'ButtonTemplate':
212 """Add a postback button."""
213 self.buttons.append(Button(type="postback", title=title, payload=payload))
214 return self
216 def add_call_button(self, title: str, phone: str) -> 'ButtonTemplate':
217 """Add a phone call button."""
218 self.buttons.append(Button(type="phone_number", title=title, payload=phone))
219 return self
221 def to_dict(self) -> Dict[str, Any]:
222 """Convert to API format."""
223 return {
224 "attachment": {
225 "type": "template",
226 "payload": {
227 "template_type": "button",
228 "text": self.text[:640], # Max 640 chars
229 "buttons": [btn.to_dict() for btn in self.buttons[:3]], # Max 3
230 }
231 }
232 }
235class MessengerAdapter(ChannelAdapter):
236 """
237 Facebook Messenger adapter using Meta Graph API.
239 Usage:
240 config = MessengerConfig(
241 page_access_token="your-page-token",
242 app_secret="your-app-secret",
243 verify_token="your-verify-token",
244 )
245 adapter = MessengerAdapter(config)
246 adapter.on_message(my_handler)
247 # Use with webhook endpoint
248 """
250 def __init__(self, config: MessengerConfig):
251 super().__init__(config)
252 self.messenger_config: MessengerConfig = config
253 self._session: Optional[aiohttp.ClientSession] = None
254 self._postback_handlers: Dict[str, Callable] = {}
255 self._referral_handlers: Dict[str, Callable] = {}
256 self._api_base: str = f"https://graph.facebook.com/{config.api_version}"
258 @property
259 def name(self) -> str:
260 return "messenger"
262 async def connect(self) -> bool:
263 """Initialize Messenger API connection."""
264 if not self.messenger_config.page_access_token:
265 logger.error("Messenger page access token required")
266 return False
268 try:
269 # Create HTTP session
270 self._session = aiohttp.ClientSession()
272 # Verify token by getting page info
273 page_info = await self._get_page_info()
274 if not page_info:
275 logger.error("Failed to verify page access token")
276 return False
278 self.messenger_config.page_id = page_info.get("id")
280 self.status = ChannelStatus.CONNECTED
281 page_name = page_info.get("name", "Unknown")
282 logger.info(f"Messenger adapter connected to page: {page_name}")
283 return True
285 except Exception as e:
286 logger.error(f"Failed to connect to Messenger: {e}")
287 self.status = ChannelStatus.ERROR
288 return False
290 async def disconnect(self) -> None:
291 """Disconnect Messenger adapter."""
292 if self._session:
293 await self._session.close()
294 self._session = None
296 self.status = ChannelStatus.DISCONNECTED
298 async def _get_page_info(self) -> Optional[Dict[str, Any]]:
299 """Get page information to verify token."""
300 if not self._session:
301 return None
303 try:
304 url = f"{self._api_base}/me"
305 params = {"access_token": self.messenger_config.page_access_token}
307 async with self._session.get(url, params=params) as response:
308 if response.status == 200:
309 return await response.json()
310 else:
311 data = await response.json()
312 logger.error(f"Failed to get page info: {data}")
313 return None
315 except Exception as e:
316 logger.error(f"Error getting page info: {e}")
317 return None
319 def verify_webhook(self, mode: str, token: str, challenge: str) -> Optional[str]:
320 """
321 Verify webhook subscription request.
322 Should be called from your webhook endpoint for GET requests.
324 Returns challenge string if valid, None if invalid.
325 """
326 if mode == "subscribe" and token == self.messenger_config.verify_token:
327 return challenge
328 return None
330 def verify_signature(self, body: bytes, signature: str) -> bool:
331 """Verify webhook request signature."""
332 if not self.messenger_config.app_secret:
333 return True # Skip verification if no secret configured
335 if not signature.startswith("sha256="):
336 return False
338 expected = "sha256=" + hmac.new(
339 self.messenger_config.app_secret.encode('utf-8'),
340 body,
341 hashlib.sha256
342 ).hexdigest()
344 return hmac.compare_digest(expected, signature)
346 async def handle_webhook(self, body: str, signature: Optional[str] = None) -> None:
347 """
348 Handle incoming webhook POST request from Messenger.
349 Should be called from your webhook endpoint.
350 """
351 try:
352 # Verify signature if provided
353 if signature and not self.verify_signature(body.encode('utf-8'), signature):
354 logger.error("Invalid webhook signature")
355 return
357 data = json.loads(body)
359 # Verify it's a page webhook
360 if data.get("object") != "page":
361 return
363 # Process each entry
364 for entry in data.get("entry", []):
365 for messaging in entry.get("messaging", []):
366 await self._process_messaging_event(messaging)
368 except Exception as e:
369 logger.error(f"Error handling webhook: {e}")
371 async def _process_messaging_event(self, event: Dict[str, Any]) -> None:
372 """Process a single messaging event."""
373 sender_id = event.get("sender", {}).get("id")
375 if "message" in event:
376 await self._handle_message(event)
377 elif "postback" in event:
378 await self._handle_postback(event)
379 elif "referral" in event:
380 await self._handle_referral(event)
381 elif "read" in event:
382 logger.debug(f"Message read by {sender_id}")
383 elif "delivery" in event:
384 logger.debug(f"Message delivered to {sender_id}")
386 async def _handle_message(self, event: Dict[str, Any]) -> None:
387 """Handle incoming message event."""
388 # Ignore echo messages
389 if event.get("message", {}).get("is_echo"):
390 return
392 message = self._convert_message(event)
393 await self._dispatch_message(message)
395 async def _handle_postback(self, event: Dict[str, Any]) -> None:
396 """Handle postback event."""
397 payload = event.get("postback", {}).get("payload")
398 sender_id = event.get("sender", {}).get("id")
400 # Check for registered handler
401 if payload in self._postback_handlers:
402 handler = self._postback_handlers[payload]
403 await handler(event)
404 else:
405 # Convert to message-like event
406 message = Message(
407 id=f"postback_{int(datetime.now().timestamp() * 1000)}",
408 channel=self.name,
409 sender_id=sender_id,
410 chat_id=sender_id,
411 text=f"[postback:{payload}]",
412 timestamp=datetime.fromtimestamp(event.get("timestamp", 0) / 1000),
413 is_group=False,
414 raw={'postback': {'payload': payload}},
415 )
416 await self._dispatch_message(message)
418 async def _handle_referral(self, event: Dict[str, Any]) -> None:
419 """Handle referral event (m.me links, ads, etc.)."""
420 referral = event.get("referral", {})
421 ref = referral.get("ref")
422 source = referral.get("source")
424 logger.info(f"Referral received: ref={ref}, source={source}")
426 if ref in self._referral_handlers:
427 handler = self._referral_handlers[ref]
428 await handler(event)
430 def _convert_message(self, event: Dict[str, Any]) -> Message:
431 """Convert Messenger event to unified Message format."""
432 sender_id = event.get("sender", {}).get("id", "")
433 message_data = event.get("message", {})
434 timestamp = event.get("timestamp", int(datetime.now().timestamp() * 1000))
436 msg_id = message_data.get("mid", "")
437 text = message_data.get("text", "")
439 # Process attachments
440 media = []
441 for attachment in message_data.get("attachments", []):
442 att_type = attachment.get("type")
443 payload = attachment.get("payload", {})
445 if att_type == "image":
446 media.append(MediaAttachment(
447 type=MessageType.IMAGE,
448 url=payload.get("url"),
449 ))
450 elif att_type == "video":
451 media.append(MediaAttachment(
452 type=MessageType.VIDEO,
453 url=payload.get("url"),
454 ))
455 elif att_type == "audio":
456 media.append(MediaAttachment(
457 type=MessageType.AUDIO,
458 url=payload.get("url"),
459 ))
460 elif att_type == "file":
461 media.append(MediaAttachment(
462 type=MessageType.DOCUMENT,
463 url=payload.get("url"),
464 ))
465 elif att_type == "location":
466 coords = payload.get("coordinates", {})
467 text = f"[location:{coords.get('lat', '')},{coords.get('long', '')}]"
468 elif att_type == "fallback":
469 # Shared content
470 text = f"[shared:{payload.get('url', '')}]"
472 # Handle quick reply payload
473 quick_reply = message_data.get("quick_reply", {})
474 if quick_reply.get("payload"):
475 text = text or f"[quick_reply:{quick_reply['payload']}]"
477 return Message(
478 id=msg_id,
479 channel=self.name,
480 sender_id=sender_id,
481 chat_id=sender_id, # Messenger uses sender ID as chat ID for 1:1
482 text=text,
483 media=media,
484 timestamp=datetime.fromtimestamp(timestamp / 1000),
485 is_group=False, # Page messaging is 1:1
486 raw={
487 'nlp': message_data.get('nlp'),
488 'reply_to': message_data.get('reply_to'),
489 },
490 )
492 async def send_message(
493 self,
494 chat_id: str,
495 text: str,
496 reply_to: Optional[str] = None,
497 media: Optional[List[MediaAttachment]] = None,
498 buttons: Optional[List[Dict]] = None,
499 ) -> SendResult:
500 """Send a message to a Messenger user."""
501 if not self._session:
502 return SendResult(success=False, error="Not connected")
504 try:
505 # Build message payload
506 message_data: Dict[str, Any] = {}
508 # Handle media
509 if media and len(media) > 0:
510 return await self._send_media_message(chat_id, media[0], text)
512 # Handle buttons using button template
513 if buttons and self.messenger_config.enable_templates:
514 template = ButtonTemplate(text=text)
515 for btn in buttons:
516 if btn.get("url"):
517 template.add_url_button(btn["text"], btn["url"])
518 else:
519 template.add_postback_button(
520 btn["text"],
521 btn.get("callback_data", btn["text"])
522 )
523 message_data = template.to_dict()
524 else:
525 message_data = {"text": text}
527 # Add reply context
528 if reply_to:
529 message_data["reply_to"] = {"mid": reply_to}
531 return await self._send_api_request(chat_id, message_data)
533 except Exception as e:
534 logger.error(f"Failed to send Messenger message: {e}")
535 return SendResult(success=False, error=str(e))
537 async def _send_media_message(
538 self,
539 chat_id: str,
540 media: MediaAttachment,
541 caption: Optional[str] = None,
542 ) -> SendResult:
543 """Send a media message."""
544 try:
545 # Determine attachment type
546 if media.type == MessageType.IMAGE:
547 att_type = "image"
548 elif media.type == MessageType.VIDEO:
549 att_type = "video"
550 elif media.type == MessageType.AUDIO:
551 att_type = "audio"
552 else:
553 att_type = "file"
555 message_data = {
556 "attachment": {
557 "type": att_type,
558 "payload": {
559 "url": media.url,
560 "is_reusable": True,
561 }
562 }
563 }
565 result = await self._send_api_request(chat_id, message_data)
567 # Send caption as separate message if present
568 if caption and result.success:
569 await self._send_api_request(chat_id, {"text": caption})
571 return result
573 except Exception as e:
574 logger.error(f"Failed to send media message: {e}")
575 return SendResult(success=False, error=str(e))
577 async def _send_api_request(
578 self,
579 recipient_id: str,
580 message: Dict[str, Any],
581 messaging_type: MessagingType = MessagingType.RESPONSE,
582 tag: Optional[MessageTag] = None,
583 ) -> SendResult:
584 """Send a request to the Send API."""
585 if not self._session:
586 return SendResult(success=False, error="Not connected")
588 try:
589 url = f"{self._api_base}/me/messages"
590 params = {"access_token": self.messenger_config.page_access_token}
592 payload = {
593 "recipient": {"id": recipient_id},
594 "messaging_type": messaging_type.value,
595 "message": message,
596 }
598 if tag:
599 payload["tag"] = tag.value
601 async with self._session.post(url, params=params, json=payload) as response:
602 data = await response.json()
604 if response.status == 200:
605 return SendResult(
606 success=True,
607 message_id=data.get("message_id"),
608 )
609 else:
610 error = data.get("error", {})
611 error_code = error.get("code")
612 error_msg = error.get("message", "Unknown error")
614 # Handle rate limiting
615 if error_code == 613:
616 raise ChannelRateLimitError(60)
618 # Handle user blocking
619 if error_code == 551:
620 return SendResult(success=False, error="User has blocked the page")
622 return SendResult(success=False, error=error_msg)
624 except ChannelRateLimitError:
625 raise
626 except Exception as e:
627 logger.error(f"Send API request failed: {e}")
628 return SendResult(success=False, error=str(e))
630 async def edit_message(
631 self,
632 chat_id: str,
633 message_id: str,
634 text: str,
635 buttons: Optional[List[Dict]] = None,
636 ) -> SendResult:
637 """
638 Edit a message.
639 Note: Messenger doesn't support message editing, sends new message.
640 """
641 logger.warning("Messenger doesn't support message editing, sending new message")
642 return await self.send_message(chat_id, text, buttons=buttons)
644 async def delete_message(self, chat_id: str, message_id: str) -> bool:
645 """
646 Delete a message.
647 Note: Messenger doesn't support message deletion by bots.
648 """
649 logger.warning("Messenger doesn't support message deletion")
650 return False
652 async def send_typing(self, chat_id: str) -> None:
653 """Send typing indicator."""
654 await self._send_sender_action(chat_id, SenderAction.TYPING_ON)
656 async def _send_sender_action(self, recipient_id: str, action: SenderAction) -> bool:
657 """Send a sender action."""
658 if not self._session:
659 return False
661 try:
662 url = f"{self._api_base}/me/messages"
663 params = {"access_token": self.messenger_config.page_access_token}
665 payload = {
666 "recipient": {"id": recipient_id},
667 "sender_action": action.value,
668 }
670 async with self._session.post(url, params=params, json=payload) as response:
671 return response.status == 200
673 except Exception:
674 return False
676 async def get_chat_info(self, chat_id: str) -> Optional[Dict[str, Any]]:
677 """Get user profile information."""
678 return await self.get_user_profile(chat_id)
680 # Messenger-specific methods
682 def register_postback_handler(
683 self,
684 payload: str,
685 handler: Callable[[Dict[str, Any]], Any],
686 ) -> None:
687 """Register a handler for postback events."""
688 self._postback_handlers[payload] = handler
690 def register_referral_handler(
691 self,
692 ref: str,
693 handler: Callable[[Dict[str, Any]], Any],
694 ) -> None:
695 """Register a handler for referral events."""
696 self._referral_handlers[ref] = handler
698 async def get_user_profile(
699 self,
700 user_id: str,
701 fields: List[str] = None,
702 ) -> Optional[Dict[str, Any]]:
703 """Get user profile information."""
704 if not self._session:
705 return None
707 try:
708 fields = fields or ["id", "name", "first_name", "last_name", "profile_pic"]
709 url = f"{self._api_base}/{user_id}"
710 params = {
711 "access_token": self.messenger_config.page_access_token,
712 "fields": ",".join(fields),
713 }
715 async with self._session.get(url, params=params) as response:
716 if response.status == 200:
717 return await response.json()
718 return None
720 except Exception as e:
721 logger.error(f"Error getting user profile: {e}")
722 return None
724 async def send_quick_replies(
725 self,
726 chat_id: str,
727 text: str,
728 quick_replies: List[QuickReply],
729 ) -> SendResult:
730 """Send a message with quick reply buttons."""
731 if not self.messenger_config.enable_quick_replies:
732 return await self.send_message(chat_id, text)
734 message_data = {
735 "text": text,
736 "quick_replies": [qr.to_dict() for qr in quick_replies[:13]], # Max 13
737 }
739 return await self._send_api_request(chat_id, message_data)
741 async def send_generic_template(
742 self,
743 chat_id: str,
744 template: GenericTemplate,
745 ) -> SendResult:
746 """Send a generic template (carousel)."""
747 if not self.messenger_config.enable_templates:
748 return SendResult(success=False, error="Templates disabled")
750 return await self._send_api_request(chat_id, template.to_dict())
752 async def send_button_template(
753 self,
754 chat_id: str,
755 template: ButtonTemplate,
756 ) -> SendResult:
757 """Send a button template."""
758 if not self.messenger_config.enable_templates:
759 return SendResult(success=False, error="Templates disabled")
761 return await self._send_api_request(chat_id, template.to_dict())
763 async def send_with_tag(
764 self,
765 chat_id: str,
766 text: str,
767 tag: MessageTag,
768 ) -> SendResult:
769 """Send a message with a message tag (for re-engagement)."""
770 message_data = {"text": text}
771 return await self._send_api_request(
772 chat_id,
773 message_data,
774 MessagingType.MESSAGE_TAG,
775 tag
776 )
778 async def mark_seen(self, chat_id: str) -> bool:
779 """Mark messages as seen."""
780 return await self._send_sender_action(chat_id, SenderAction.MARK_SEEN)
782 async def set_persistent_menu(
783 self,
784 menu_items: List[Dict[str, Any]],
785 locale: str = "default",
786 ) -> bool:
787 """
788 Set the persistent menu for the page.
790 Args:
791 menu_items: List of menu item dicts with keys: type, title, payload/url
792 locale: Locale for the menu (default: all locales)
793 """
794 if not self._session:
795 return False
797 try:
798 url = f"{self._api_base}/me/messenger_profile"
799 params = {"access_token": self.messenger_config.page_access_token}
801 # Convert menu items to proper format
802 call_to_actions = []
803 for item in menu_items[:3]: # Max 3 top-level items
804 if item.get("type") == "web_url":
805 call_to_actions.append({
806 "type": "web_url",
807 "title": item["title"][:30],
808 "url": item["url"],
809 })
810 elif item.get("type") == "postback":
811 call_to_actions.append({
812 "type": "postback",
813 "title": item["title"][:30],
814 "payload": item.get("payload", item["title"]),
815 })
817 payload = {
818 "persistent_menu": [{
819 "locale": locale,
820 "composer_input_disabled": False,
821 "call_to_actions": call_to_actions,
822 }]
823 }
825 async with self._session.post(url, params=params, json=payload) as response:
826 data = await response.json()
827 return data.get("result") == "success"
829 except Exception as e:
830 logger.error(f"Error setting persistent menu: {e}")
831 return False
833 async def delete_persistent_menu(self) -> bool:
834 """Delete the persistent menu."""
835 if not self._session:
836 return False
838 try:
839 url = f"{self._api_base}/me/messenger_profile"
840 params = {"access_token": self.messenger_config.page_access_token}
842 payload = {"fields": ["persistent_menu"]}
844 async with self._session.delete(url, params=params, json=payload) as response:
845 data = await response.json()
846 return data.get("result") == "success"
848 except Exception as e:
849 logger.error(f"Error deleting persistent menu: {e}")
850 return False
852 async def request_one_time_notification(
853 self,
854 chat_id: str,
855 title: str,
856 payload: str,
857 ) -> SendResult:
858 """
859 Request permission to send a one-time notification.
861 The user will see a "Notify Me" button.
862 """
863 message_data = {
864 "attachment": {
865 "type": "template",
866 "payload": {
867 "template_type": "one_time_notif_req",
868 "title": title[:65],
869 "payload": payload,
870 }
871 }
872 }
874 return await self._send_api_request(chat_id, message_data)
876 async def send_one_time_notification(
877 self,
878 notification_token: str,
879 text: str,
880 ) -> SendResult:
881 """Send a one-time notification using a token."""
882 if not self._session:
883 return SendResult(success=False, error="Not connected")
885 try:
886 url = f"{self._api_base}/me/messages"
887 params = {"access_token": self.messenger_config.page_access_token}
889 payload = {
890 "recipient": {"one_time_notif_token": notification_token},
891 "message": {"text": text},
892 }
894 async with self._session.post(url, params=params, json=payload) as response:
895 data = await response.json()
897 if response.status == 200:
898 return SendResult(success=True, message_id=data.get("message_id"))
899 else:
900 error = data.get("error", {}).get("message", "Unknown error")
901 return SendResult(success=False, error=error)
903 except Exception as e:
904 logger.error(f"Failed to send one-time notification: {e}")
905 return SendResult(success=False, error=str(e))
908def create_messenger_adapter(
909 page_access_token: str = None,
910 app_secret: str = None,
911 verify_token: str = None,
912 **kwargs
913) -> MessengerAdapter:
914 """
915 Factory function to create Messenger adapter.
917 Args:
918 page_access_token: Facebook page access token (or set MESSENGER_PAGE_TOKEN env var)
919 app_secret: Facebook app secret (or set MESSENGER_APP_SECRET env var)
920 verify_token: Webhook verification token (or set MESSENGER_VERIFY_TOKEN env var)
921 **kwargs: Additional config options
923 Returns:
924 Configured MessengerAdapter
925 """
926 page_access_token = page_access_token or os.getenv("MESSENGER_PAGE_TOKEN")
927 app_secret = app_secret or os.getenv("MESSENGER_APP_SECRET")
928 verify_token = verify_token or os.getenv("MESSENGER_VERIFY_TOKEN")
930 if not page_access_token:
931 raise ValueError("Messenger page access token required")
933 config = MessengerConfig(
934 page_access_token=page_access_token,
935 app_secret=app_secret or "",
936 verify_token=verify_token or "",
937 **kwargs
938 )
939 return MessengerAdapter(config)