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

1""" 

2Facebook Messenger Channel Adapter 

3 

4Implements Facebook Messenger messaging via Meta Graph API. 

5Based on HevolveBot extension patterns for Messenger. 

6 

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""" 

19 

20from __future__ import annotations 

21 

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 

37 

38from ..base import ( 

39 ChannelAdapter, 

40 ChannelConfig, 

41 ChannelStatus, 

42 Message, 

43 MessageType, 

44 MediaAttachment, 

45 SendResult, 

46 ChannelConnectionError, 

47 ChannelSendError, 

48 ChannelRateLimitError, 

49) 

50 

51logger = logging.getLogger(__name__) 

52 

53 

54# Meta Graph API endpoints 

55GRAPH_API_VERSION = "v18.0" 

56GRAPH_API_BASE = f"https://graph.facebook.com/{GRAPH_API_VERSION}" 

57 

58 

59class MessagingType(Enum): 

60 """Messaging types for send API.""" 

61 RESPONSE = "RESPONSE" 

62 UPDATE = "UPDATE" 

63 MESSAGE_TAG = "MESSAGE_TAG" 

64 

65 

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" 

72 

73 

74class SenderAction(Enum): 

75 """Sender actions.""" 

76 TYPING_ON = "typing_on" 

77 TYPING_OFF = "typing_off" 

78 MARK_SEEN = "mark_seen" 

79 

80 

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 

92 

93 

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 

101 

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 

112 

113 

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 

123 

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 

139 

140 

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) 

149 

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 

162 

163 

164@dataclass 

165class GenericTemplate: 

166 """Generic template builder.""" 

167 elements: List[GenericElement] = field(default_factory=list) 

168 image_aspect_ratio: str = "horizontal" # horizontal, square 

169 

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 

185 

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 } 

198 

199 

200@dataclass 

201class ButtonTemplate: 

202 """Button template builder.""" 

203 text: str 

204 buttons: List[Button] = field(default_factory=list) 

205 

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 

210 

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 

215 

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 

220 

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 } 

233 

234 

235class MessengerAdapter(ChannelAdapter): 

236 """ 

237 Facebook Messenger adapter using Meta Graph API. 

238 

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 """ 

249 

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}" 

257 

258 @property 

259 def name(self) -> str: 

260 return "messenger" 

261 

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 

267 

268 try: 

269 # Create HTTP session 

270 self._session = aiohttp.ClientSession() 

271 

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 

277 

278 self.messenger_config.page_id = page_info.get("id") 

279 

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 

284 

285 except Exception as e: 

286 logger.error(f"Failed to connect to Messenger: {e}") 

287 self.status = ChannelStatus.ERROR 

288 return False 

289 

290 async def disconnect(self) -> None: 

291 """Disconnect Messenger adapter.""" 

292 if self._session: 

293 await self._session.close() 

294 self._session = None 

295 

296 self.status = ChannelStatus.DISCONNECTED 

297 

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 

302 

303 try: 

304 url = f"{self._api_base}/me" 

305 params = {"access_token": self.messenger_config.page_access_token} 

306 

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 

314 

315 except Exception as e: 

316 logger.error(f"Error getting page info: {e}") 

317 return None 

318 

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. 

323 

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 

329 

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 

334 

335 if not signature.startswith("sha256="): 

336 return False 

337 

338 expected = "sha256=" + hmac.new( 

339 self.messenger_config.app_secret.encode('utf-8'), 

340 body, 

341 hashlib.sha256 

342 ).hexdigest() 

343 

344 return hmac.compare_digest(expected, signature) 

345 

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 

356 

357 data = json.loads(body) 

358 

359 # Verify it's a page webhook 

360 if data.get("object") != "page": 

361 return 

362 

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) 

367 

368 except Exception as e: 

369 logger.error(f"Error handling webhook: {e}") 

370 

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") 

374 

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}") 

385 

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 

391 

392 message = self._convert_message(event) 

393 await self._dispatch_message(message) 

394 

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") 

399 

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) 

417 

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") 

423 

424 logger.info(f"Referral received: ref={ref}, source={source}") 

425 

426 if ref in self._referral_handlers: 

427 handler = self._referral_handlers[ref] 

428 await handler(event) 

429 

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)) 

435 

436 msg_id = message_data.get("mid", "") 

437 text = message_data.get("text", "") 

438 

439 # Process attachments 

440 media = [] 

441 for attachment in message_data.get("attachments", []): 

442 att_type = attachment.get("type") 

443 payload = attachment.get("payload", {}) 

444 

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', '')}]" 

471 

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']}]" 

476 

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 ) 

491 

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") 

503 

504 try: 

505 # Build message payload 

506 message_data: Dict[str, Any] = {} 

507 

508 # Handle media 

509 if media and len(media) > 0: 

510 return await self._send_media_message(chat_id, media[0], text) 

511 

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} 

526 

527 # Add reply context 

528 if reply_to: 

529 message_data["reply_to"] = {"mid": reply_to} 

530 

531 return await self._send_api_request(chat_id, message_data) 

532 

533 except Exception as e: 

534 logger.error(f"Failed to send Messenger message: {e}") 

535 return SendResult(success=False, error=str(e)) 

536 

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" 

554 

555 message_data = { 

556 "attachment": { 

557 "type": att_type, 

558 "payload": { 

559 "url": media.url, 

560 "is_reusable": True, 

561 } 

562 } 

563 } 

564 

565 result = await self._send_api_request(chat_id, message_data) 

566 

567 # Send caption as separate message if present 

568 if caption and result.success: 

569 await self._send_api_request(chat_id, {"text": caption}) 

570 

571 return result 

572 

573 except Exception as e: 

574 logger.error(f"Failed to send media message: {e}") 

575 return SendResult(success=False, error=str(e)) 

576 

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") 

587 

588 try: 

589 url = f"{self._api_base}/me/messages" 

590 params = {"access_token": self.messenger_config.page_access_token} 

591 

592 payload = { 

593 "recipient": {"id": recipient_id}, 

594 "messaging_type": messaging_type.value, 

595 "message": message, 

596 } 

597 

598 if tag: 

599 payload["tag"] = tag.value 

600 

601 async with self._session.post(url, params=params, json=payload) as response: 

602 data = await response.json() 

603 

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") 

613 

614 # Handle rate limiting 

615 if error_code == 613: 

616 raise ChannelRateLimitError(60) 

617 

618 # Handle user blocking 

619 if error_code == 551: 

620 return SendResult(success=False, error="User has blocked the page") 

621 

622 return SendResult(success=False, error=error_msg) 

623 

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)) 

629 

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) 

643 

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 

651 

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) 

655 

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 

660 

661 try: 

662 url = f"{self._api_base}/me/messages" 

663 params = {"access_token": self.messenger_config.page_access_token} 

664 

665 payload = { 

666 "recipient": {"id": recipient_id}, 

667 "sender_action": action.value, 

668 } 

669 

670 async with self._session.post(url, params=params, json=payload) as response: 

671 return response.status == 200 

672 

673 except Exception: 

674 return False 

675 

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) 

679 

680 # Messenger-specific methods 

681 

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 

689 

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 

697 

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 

706 

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 } 

714 

715 async with self._session.get(url, params=params) as response: 

716 if response.status == 200: 

717 return await response.json() 

718 return None 

719 

720 except Exception as e: 

721 logger.error(f"Error getting user profile: {e}") 

722 return None 

723 

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) 

733 

734 message_data = { 

735 "text": text, 

736 "quick_replies": [qr.to_dict() for qr in quick_replies[:13]], # Max 13 

737 } 

738 

739 return await self._send_api_request(chat_id, message_data) 

740 

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") 

749 

750 return await self._send_api_request(chat_id, template.to_dict()) 

751 

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") 

760 

761 return await self._send_api_request(chat_id, template.to_dict()) 

762 

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 ) 

777 

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) 

781 

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. 

789 

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 

796 

797 try: 

798 url = f"{self._api_base}/me/messenger_profile" 

799 params = {"access_token": self.messenger_config.page_access_token} 

800 

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 }) 

816 

817 payload = { 

818 "persistent_menu": [{ 

819 "locale": locale, 

820 "composer_input_disabled": False, 

821 "call_to_actions": call_to_actions, 

822 }] 

823 } 

824 

825 async with self._session.post(url, params=params, json=payload) as response: 

826 data = await response.json() 

827 return data.get("result") == "success" 

828 

829 except Exception as e: 

830 logger.error(f"Error setting persistent menu: {e}") 

831 return False 

832 

833 async def delete_persistent_menu(self) -> bool: 

834 """Delete the persistent menu.""" 

835 if not self._session: 

836 return False 

837 

838 try: 

839 url = f"{self._api_base}/me/messenger_profile" 

840 params = {"access_token": self.messenger_config.page_access_token} 

841 

842 payload = {"fields": ["persistent_menu"]} 

843 

844 async with self._session.delete(url, params=params, json=payload) as response: 

845 data = await response.json() 

846 return data.get("result") == "success" 

847 

848 except Exception as e: 

849 logger.error(f"Error deleting persistent menu: {e}") 

850 return False 

851 

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. 

860 

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 } 

873 

874 return await self._send_api_request(chat_id, message_data) 

875 

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") 

884 

885 try: 

886 url = f"{self._api_base}/me/messages" 

887 params = {"access_token": self.messenger_config.page_access_token} 

888 

889 payload = { 

890 "recipient": {"one_time_notif_token": notification_token}, 

891 "message": {"text": text}, 

892 } 

893 

894 async with self._session.post(url, params=params, json=payload) as response: 

895 data = await response.json() 

896 

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) 

902 

903 except Exception as e: 

904 logger.error(f"Failed to send one-time notification: {e}") 

905 return SendResult(success=False, error=str(e)) 

906 

907 

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. 

916 

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 

922 

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") 

929 

930 if not page_access_token: 

931 raise ValueError("Messenger page access token required") 

932 

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)