Coverage for integrations / channels / extensions / line_adapter.py: 25.8%
357 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"""
2LINE Channel Adapter
4Implements LINE Messaging API integration.
5Based on HevolveBot extension patterns for LINE.
7Features:
8- Messaging API (send/receive messages)
9- Rich menus
10- Flex Messages (rich UI components)
11- LIFF (LINE Front-end Framework) integration
12- Quick replies
13- Image maps
14- Stickers
15- Location messages
16- Push and reply messaging
17- Webhook signature validation
18"""
20from __future__ import annotations
22import asyncio
23import logging
24import os
25import json
26import hmac
27import hashlib
28import base64
29from typing import Optional, List, Dict, Any, Callable, Union
30from datetime import datetime
31from dataclasses import dataclass, field
32try:
33 import aiohttp
34 HAS_AIOHTTP = True
35except ImportError:
36 HAS_AIOHTTP = False
38try:
39 from linebot import LineBotApi, WebhookHandler, WebhookParser
40 from linebot.models import (
41 TextSendMessage,
42 ImageSendMessage,
43 VideoSendMessage,
44 AudioSendMessage,
45 LocationSendMessage,
46 StickerSendMessage,
47 FlexSendMessage,
48 TemplateSendMessage,
49 QuickReply,
50 QuickReplyButton,
51 MessageAction,
52 URIAction,
53 PostbackAction,
54 RichMenu,
55 RichMenuArea,
56 RichMenuBounds,
57 RichMenuSize,
58 BubbleContainer,
59 BoxComponent,
60 TextComponent,
61 ButtonComponent,
62 ImageComponent,
63 FlexMessage,
64 )
65 from linebot.exceptions import LineBotApiError, InvalidSignatureError
66 HAS_LINE = True
67except ImportError:
68 HAS_LINE = False
70from ..base import (
71 ChannelAdapter,
72 ChannelConfig,
73 ChannelStatus,
74 Message,
75 MessageType,
76 MediaAttachment,
77 SendResult,
78 ChannelConnectionError,
79 ChannelSendError,
80 ChannelRateLimitError,
81)
83logger = logging.getLogger(__name__)
86@dataclass
87class LINEConfig(ChannelConfig):
88 """LINE-specific configuration."""
89 channel_access_token: str = ""
90 channel_secret: str = ""
91 liff_id: Optional[str] = None
92 default_rich_menu_id: Optional[str] = None
93 enable_push_messages: bool = True
94 enable_flex_messages: bool = True
97@dataclass
98class FlexBubble:
99 """Flex Message Bubble builder helper."""
100 header: Optional[Dict[str, Any]] = None
101 hero: Optional[Dict[str, Any]] = None
102 body: Optional[Dict[str, Any]] = None
103 footer: Optional[Dict[str, Any]] = None
104 size: str = "mega"
106 def set_header(self, text: str, **kwargs) -> 'FlexBubble':
107 """Set header with text."""
108 self.header = {
109 "type": "box",
110 "layout": "vertical",
111 "contents": [{
112 "type": "text",
113 "text": text,
114 "weight": kwargs.get("weight", "bold"),
115 "size": kwargs.get("size", "xl"),
116 }]
117 }
118 return self
120 def set_hero_image(self, url: str, **kwargs) -> 'FlexBubble':
121 """Set hero image."""
122 self.hero = {
123 "type": "image",
124 "url": url,
125 "size": kwargs.get("size", "full"),
126 "aspectRatio": kwargs.get("aspect_ratio", "20:13"),
127 "aspectMode": kwargs.get("aspect_mode", "cover"),
128 }
129 return self
131 def set_body(self, contents: List[Dict[str, Any]]) -> 'FlexBubble':
132 """Set body contents."""
133 self.body = {
134 "type": "box",
135 "layout": "vertical",
136 "contents": contents,
137 }
138 return self
140 def add_body_text(self, text: str, **kwargs) -> 'FlexBubble':
141 """Add text to body."""
142 if not self.body:
143 self.body = {"type": "box", "layout": "vertical", "contents": []}
144 self.body["contents"].append({
145 "type": "text",
146 "text": text,
147 "wrap": kwargs.get("wrap", True),
148 "size": kwargs.get("size", "md"),
149 })
150 return self
152 def set_footer(self, buttons: List[Dict[str, Any]]) -> 'FlexBubble':
153 """Set footer with buttons."""
154 self.footer = {
155 "type": "box",
156 "layout": "vertical",
157 "spacing": "sm",
158 "contents": buttons,
159 }
160 return self
162 def add_button(
163 self,
164 label: str,
165 action_type: str = "message",
166 data: str = None,
167 **kwargs
168 ) -> 'FlexBubble':
169 """Add a button to footer."""
170 if not self.footer:
171 self.footer = {"type": "box", "layout": "vertical", "spacing": "sm", "contents": []}
173 if action_type == "uri":
174 action = {"type": "uri", "label": label, "uri": data}
175 elif action_type == "postback":
176 action = {"type": "postback", "label": label, "data": data}
177 else:
178 action = {"type": "message", "label": label, "text": data or label}
180 self.footer["contents"].append({
181 "type": "button",
182 "style": kwargs.get("style", "primary"),
183 "action": action,
184 })
185 return self
187 def to_dict(self) -> Dict[str, Any]:
188 """Convert to Flex Message JSON."""
189 bubble = {
190 "type": "bubble",
191 "size": self.size,
192 }
193 if self.header:
194 bubble["header"] = self.header
195 if self.hero:
196 bubble["hero"] = self.hero
197 if self.body:
198 bubble["body"] = self.body
199 if self.footer:
200 bubble["footer"] = self.footer
201 return bubble
204@dataclass
205class QuickReplyItem:
206 """Quick reply button item."""
207 label: str
208 action_type: str = "message" # message, postback, uri, datetime, camera, cameraRoll, location
209 text: Optional[str] = None
210 data: Optional[str] = None
211 image_url: Optional[str] = None
214class LINEAdapter(ChannelAdapter):
215 """
216 LINE Messaging API adapter.
218 Usage:
219 config = LINEConfig(
220 channel_access_token="your-token",
221 channel_secret="your-secret",
222 )
223 adapter = LINEAdapter(config)
224 adapter.on_message(my_handler)
225 # Use with webhook endpoint
226 """
228 def __init__(self, config: LINEConfig):
229 if not HAS_LINE:
230 raise ImportError(
231 "line-bot-sdk not installed. "
232 "Install with: pip install line-bot-sdk"
233 )
235 super().__init__(config)
236 self.line_config: LINEConfig = config
237 self._api: Optional[LineBotApi] = None
238 self._parser: Optional[WebhookParser] = None
239 self._postback_handlers: Dict[str, Callable] = {}
240 self._rich_menus: Dict[str, str] = {} # alias -> rich_menu_id
242 @property
243 def name(self) -> str:
244 return "line"
246 async def connect(self) -> bool:
247 """Initialize LINE Bot API client."""
248 if not self.line_config.channel_access_token:
249 logger.error("LINE channel access token required")
250 return False
252 if not self.line_config.channel_secret:
253 logger.error("LINE channel secret required")
254 return False
256 try:
257 # Initialize API client
258 self._api = LineBotApi(self.line_config.channel_access_token)
260 # Initialize webhook parser
261 self._parser = WebhookParser(self.line_config.channel_secret)
263 # Verify token by getting bot info
264 bot_info = self._api.get_bot_info()
265 logger.info(f"LINE connected as: {bot_info.display_name}")
267 self.status = ChannelStatus.CONNECTED
268 return True
270 except LineBotApiError as e:
271 logger.error(f"Failed to connect to LINE: {e.message}")
272 self.status = ChannelStatus.ERROR
273 return False
274 except Exception as e:
275 logger.error(f"Failed to connect to LINE: {e}")
276 self.status = ChannelStatus.ERROR
277 return False
279 async def disconnect(self) -> None:
280 """Disconnect LINE adapter."""
281 self._api = None
282 self._parser = None
283 self.status = ChannelStatus.DISCONNECTED
285 def validate_signature(self, body: str, signature: str) -> bool:
286 """Validate webhook signature."""
287 if not self.line_config.channel_secret:
288 return False
290 hash_value = hmac.new(
291 self.line_config.channel_secret.encode('utf-8'),
292 body.encode('utf-8'),
293 hashlib.sha256
294 ).digest()
296 expected_signature = base64.b64encode(hash_value).decode('utf-8')
297 return hmac.compare_digest(signature, expected_signature)
299 async def handle_webhook(self, body: str, signature: str) -> None:
300 """
301 Handle incoming webhook request from LINE.
302 Should be called from your webhook endpoint.
303 """
304 if not self._parser:
305 raise ChannelConnectionError("Adapter not initialized")
307 # Validate signature
308 if not self.validate_signature(body, signature):
309 raise InvalidSignatureError("Invalid signature")
311 # Parse events
312 events = self._parser.parse(body, signature)
314 for event in events:
315 await self._handle_event(event)
317 async def _handle_event(self, event: Any) -> None:
318 """Handle LINE webhook event."""
319 event_type = event.type
321 if event_type == "message":
322 await self._handle_message_event(event)
323 elif event_type == "postback":
324 await self._handle_postback_event(event)
325 elif event_type == "follow":
326 await self._handle_follow_event(event)
327 elif event_type == "unfollow":
328 await self._handle_unfollow_event(event)
329 elif event_type == "join":
330 await self._handle_join_event(event)
331 elif event_type == "leave":
332 await self._handle_leave_event(event)
334 async def _handle_message_event(self, event: Any) -> None:
335 """Handle message event."""
336 message = self._convert_message(event)
337 await self._dispatch_message(message)
339 async def _handle_postback_event(self, event: Any) -> None:
340 """Handle postback event."""
341 data = event.postback.data
343 # Check for registered handler
344 if data in self._postback_handlers:
345 handler = self._postback_handlers[data]
346 await handler(event)
347 else:
348 # Convert to message-like event
349 message = Message(
350 id=event.timestamp,
351 channel=self.name,
352 sender_id=event.source.user_id,
353 chat_id=self._get_chat_id(event.source),
354 text=f"[postback:{data}]",
355 timestamp=datetime.fromtimestamp(event.timestamp / 1000),
356 is_group=event.source.type != "user",
357 raw={'postback': {'data': data}},
358 )
359 await self._dispatch_message(message)
361 async def _handle_follow_event(self, event: Any) -> None:
362 """Handle follow event (user added bot)."""
363 logger.info(f"User followed: {event.source.user_id}")
365 async def _handle_unfollow_event(self, event: Any) -> None:
366 """Handle unfollow event (user blocked bot)."""
367 logger.info(f"User unfollowed: {event.source.user_id}")
369 async def _handle_join_event(self, event: Any) -> None:
370 """Handle join event (bot added to group/room)."""
371 chat_id = self._get_chat_id(event.source)
372 logger.info(f"Bot joined: {chat_id}")
374 async def _handle_leave_event(self, event: Any) -> None:
375 """Handle leave event (bot removed from group/room)."""
376 chat_id = self._get_chat_id(event.source)
377 logger.info(f"Bot left: {chat_id}")
379 def _get_chat_id(self, source: Any) -> str:
380 """Get chat ID from event source."""
381 if source.type == "user":
382 return source.user_id
383 elif source.type == "group":
384 return source.group_id
385 elif source.type == "room":
386 return source.room_id
387 return ""
389 def _convert_message(self, event: Any) -> Message:
390 """Convert LINE event to unified Message format."""
391 source = event.source
392 msg = event.message
394 # Get text content
395 text = ""
396 media = []
398 if msg.type == "text":
399 text = msg.text
400 elif msg.type == "image":
401 media.append(MediaAttachment(
402 type=MessageType.IMAGE,
403 file_id=msg.id,
404 ))
405 elif msg.type == "video":
406 media.append(MediaAttachment(
407 type=MessageType.VIDEO,
408 file_id=msg.id,
409 ))
410 elif msg.type == "audio":
411 media.append(MediaAttachment(
412 type=MessageType.AUDIO,
413 file_id=msg.id,
414 ))
415 elif msg.type == "location":
416 text = f"[location:{msg.latitude},{msg.longitude}]"
417 elif msg.type == "sticker":
418 text = f"[sticker:{msg.package_id}/{msg.sticker_id}]"
420 # Determine chat type
421 is_group = source.type != "user"
422 chat_id = self._get_chat_id(source)
424 return Message(
425 id=msg.id,
426 channel=self.name,
427 sender_id=source.user_id if hasattr(source, 'user_id') else "",
428 chat_id=chat_id,
429 text=text,
430 media=media,
431 timestamp=datetime.fromtimestamp(event.timestamp / 1000),
432 is_group=is_group,
433 raw={
434 'reply_token': event.reply_token,
435 'source_type': source.type,
436 'message_type': msg.type,
437 },
438 )
440 async def send_message(
441 self,
442 chat_id: str,
443 text: str,
444 reply_to: Optional[str] = None,
445 media: Optional[List[MediaAttachment]] = None,
446 buttons: Optional[List[Dict]] = None,
447 ) -> SendResult:
448 """Send a message to a LINE chat."""
449 if not self._api:
450 return SendResult(success=False, error="Not connected")
452 try:
453 messages = []
455 # Build message based on content
456 if buttons and self.line_config.enable_flex_messages:
457 # Use flex message for buttons
458 flex = self._build_flex_message(text, buttons)
459 messages.append(FlexSendMessage(alt_text=text, contents=flex))
460 elif media and len(media) > 0:
461 # Send media
462 for m in media:
463 if m.type == MessageType.IMAGE:
464 messages.append(ImageSendMessage(
465 original_content_url=m.url,
466 preview_image_url=m.url,
467 ))
468 elif m.type == MessageType.VIDEO:
469 messages.append(VideoSendMessage(
470 original_content_url=m.url,
471 preview_image_url=m.url,
472 ))
473 elif m.type == MessageType.AUDIO:
474 messages.append(AudioSendMessage(
475 original_content_url=m.url,
476 duration=60000, # Default duration
477 ))
478 if text:
479 messages.append(TextSendMessage(text=text))
480 else:
481 messages.append(TextSendMessage(text=text))
483 # Use push message if no reply token
484 if reply_to:
485 self._api.reply_message(reply_to, messages)
486 else:
487 self._api.push_message(chat_id, messages)
489 return SendResult(success=True)
491 except LineBotApiError as e:
492 if e.status_code == 429:
493 raise ChannelRateLimitError()
494 logger.error(f"Failed to send LINE message: {e.message}")
495 return SendResult(success=False, error=e.message)
496 except Exception as e:
497 logger.error(f"Failed to send LINE message: {e}")
498 return SendResult(success=False, error=str(e))
500 def _build_flex_message(
501 self,
502 text: str,
503 buttons: List[Dict],
504 ) -> Dict[str, Any]:
505 """Build a flex message with buttons."""
506 bubble = FlexBubble()
507 bubble.add_body_text(text)
509 for btn in buttons:
510 if btn.get('url'):
511 bubble.add_button(
512 btn['text'],
513 action_type="uri",
514 data=btn['url'],
515 )
516 else:
517 bubble.add_button(
518 btn['text'],
519 action_type="postback",
520 data=btn.get('callback_data', btn['text']),
521 )
523 return bubble.to_dict()
525 async def edit_message(
526 self,
527 chat_id: str,
528 message_id: str,
529 text: str,
530 buttons: Optional[List[Dict]] = None,
531 ) -> SendResult:
532 """
533 Edit an existing LINE message.
534 Note: LINE doesn't support editing messages, so this sends a new message.
535 """
536 logger.warning("LINE doesn't support message editing, sending new message")
537 return await self.send_message(chat_id, text, buttons=buttons)
539 async def delete_message(self, chat_id: str, message_id: str) -> bool:
540 """
541 Delete a LINE message.
542 Note: LINE doesn't support message deletion by bots.
543 """
544 logger.warning("LINE doesn't support message deletion")
545 return False
547 async def send_typing(self, chat_id: str) -> None:
548 """
549 Send typing indicator.
550 Note: LINE doesn't have a typing indicator API.
551 """
552 pass
554 async def get_chat_info(self, chat_id: str) -> Optional[Dict[str, Any]]:
555 """Get information about a LINE chat."""
556 if not self._api:
557 return None
559 try:
560 # Try to get group info
561 try:
562 group = self._api.get_group_summary(chat_id)
563 return {
564 'id': chat_id,
565 'type': 'group',
566 'name': group.group_name,
567 'picture_url': group.picture_url,
568 }
569 except Exception:
570 pass
572 # Try to get room info (chat rooms don't have names)
573 try:
574 # Rooms don't have summary API
575 return {
576 'id': chat_id,
577 'type': 'room',
578 }
579 except Exception:
580 pass
582 # Assume it's a user
583 try:
584 profile = self._api.get_profile(chat_id)
585 return {
586 'id': chat_id,
587 'type': 'user',
588 'name': profile.display_name,
589 'picture_url': profile.picture_url,
590 'status_message': profile.status_message,
591 }
592 except Exception:
593 pass
595 return None
597 except Exception as e:
598 logger.error(f"Failed to get chat info: {e}")
599 return None
601 # LINE-specific methods
603 def register_postback_handler(
604 self,
605 data: str,
606 handler: Callable[[Any], Any],
607 ) -> None:
608 """Register a handler for postback actions."""
609 self._postback_handlers[data] = handler
611 async def send_flex_message(
612 self,
613 chat_id: str,
614 bubble: FlexBubble,
615 alt_text: str = "Flex Message",
616 reply_token: Optional[str] = None,
617 ) -> SendResult:
618 """Send a flex message."""
619 if not self._api:
620 return SendResult(success=False, error="Not connected")
622 try:
623 message = FlexSendMessage(
624 alt_text=alt_text,
625 contents=bubble.to_dict(),
626 )
628 if reply_token:
629 self._api.reply_message(reply_token, message)
630 else:
631 self._api.push_message(chat_id, message)
633 return SendResult(success=True)
635 except LineBotApiError as e:
636 logger.error(f"Failed to send flex message: {e.message}")
637 return SendResult(success=False, error=e.message)
639 async def send_quick_reply(
640 self,
641 chat_id: str,
642 text: str,
643 items: List[QuickReplyItem],
644 reply_token: Optional[str] = None,
645 ) -> SendResult:
646 """Send a message with quick reply buttons."""
647 if not self._api:
648 return SendResult(success=False, error="Not connected")
650 try:
651 quick_reply_items = []
653 for item in items:
654 if item.action_type == "message":
655 action = MessageAction(label=item.label, text=item.text or item.label)
656 elif item.action_type == "postback":
657 action = PostbackAction(label=item.label, data=item.data or item.label)
658 elif item.action_type == "uri":
659 action = URIAction(label=item.label, uri=item.data)
660 else:
661 action = MessageAction(label=item.label, text=item.text or item.label)
663 quick_reply_items.append(QuickReplyButton(
664 action=action,
665 image_url=item.image_url,
666 ))
668 message = TextSendMessage(
669 text=text,
670 quick_reply=QuickReply(items=quick_reply_items),
671 )
673 if reply_token:
674 self._api.reply_message(reply_token, message)
675 else:
676 self._api.push_message(chat_id, message)
678 return SendResult(success=True)
680 except LineBotApiError as e:
681 logger.error(f"Failed to send quick reply: {e.message}")
682 return SendResult(success=False, error=e.message)
684 async def send_sticker(
685 self,
686 chat_id: str,
687 package_id: str,
688 sticker_id: str,
689 reply_token: Optional[str] = None,
690 ) -> SendResult:
691 """Send a sticker."""
692 if not self._api:
693 return SendResult(success=False, error="Not connected")
695 try:
696 message = StickerSendMessage(
697 package_id=package_id,
698 sticker_id=sticker_id,
699 )
701 if reply_token:
702 self._api.reply_message(reply_token, message)
703 else:
704 self._api.push_message(chat_id, message)
706 return SendResult(success=True)
708 except LineBotApiError as e:
709 logger.error(f"Failed to send sticker: {e.message}")
710 return SendResult(success=False, error=e.message)
712 async def send_location(
713 self,
714 chat_id: str,
715 title: str,
716 address: str,
717 latitude: float,
718 longitude: float,
719 reply_token: Optional[str] = None,
720 ) -> SendResult:
721 """Send a location message."""
722 if not self._api:
723 return SendResult(success=False, error="Not connected")
725 try:
726 message = LocationSendMessage(
727 title=title,
728 address=address,
729 latitude=latitude,
730 longitude=longitude,
731 )
733 if reply_token:
734 self._api.reply_message(reply_token, message)
735 else:
736 self._api.push_message(chat_id, message)
738 return SendResult(success=True)
740 except LineBotApiError as e:
741 logger.error(f"Failed to send location: {e.message}")
742 return SendResult(success=False, error=e.message)
744 async def create_rich_menu(
745 self,
746 name: str,
747 chat_bar_text: str,
748 areas: List[Dict[str, Any]],
749 size: tuple = (2500, 1686),
750 ) -> Optional[str]:
751 """Create a rich menu."""
752 if not self._api:
753 return None
755 try:
756 rich_menu = RichMenu(
757 size=RichMenuSize(width=size[0], height=size[1]),
758 selected=True,
759 name=name,
760 chat_bar_text=chat_bar_text,
761 areas=[
762 RichMenuArea(
763 bounds=RichMenuBounds(
764 x=area['x'],
765 y=area['y'],
766 width=area['width'],
767 height=area['height'],
768 ),
769 action=self._build_action(area['action']),
770 )
771 for area in areas
772 ],
773 )
775 rich_menu_id = self._api.create_rich_menu(rich_menu)
776 self._rich_menus[name] = rich_menu_id
777 return rich_menu_id
779 except LineBotApiError as e:
780 logger.error(f"Failed to create rich menu: {e.message}")
781 return None
783 def _build_action(self, action: Dict[str, Any]) -> Any:
784 """Build action object from dict."""
785 action_type = action.get('type', 'message')
787 if action_type == 'uri':
788 return URIAction(label=action.get('label', ''), uri=action.get('uri', ''))
789 elif action_type == 'postback':
790 return PostbackAction(label=action.get('label', ''), data=action.get('data', ''))
791 else:
792 return MessageAction(label=action.get('label', ''), text=action.get('text', ''))
794 async def set_rich_menu(self, user_id: str, rich_menu_id: str) -> bool:
795 """Link a rich menu to a user."""
796 if not self._api:
797 return False
799 try:
800 self._api.link_rich_menu_to_user(user_id, rich_menu_id)
801 return True
802 except LineBotApiError as e:
803 logger.error(f"Failed to set rich menu: {e.message}")
804 return False
806 async def get_message_content(self, message_id: str) -> Optional[bytes]:
807 """Get content of a media message."""
808 if not self._api:
809 return None
811 try:
812 content = self._api.get_message_content(message_id)
813 return content.content
814 except LineBotApiError as e:
815 logger.error(f"Failed to get message content: {e.message}")
816 return None
818 def get_liff_url(self, path: str = "") -> Optional[str]:
819 """Get LIFF URL for the configured LIFF app."""
820 if not self.line_config.liff_id:
821 return None
822 return f"https://liff.line.me/{self.line_config.liff_id}{path}"
825def create_line_adapter(
826 channel_access_token: str = None,
827 channel_secret: str = None,
828 **kwargs
829) -> LINEAdapter:
830 """
831 Factory function to create LINE adapter.
833 Args:
834 channel_access_token: LINE channel access token (or set LINE_CHANNEL_ACCESS_TOKEN env var)
835 channel_secret: LINE channel secret (or set LINE_CHANNEL_SECRET env var)
836 **kwargs: Additional config options
838 Returns:
839 Configured LINEAdapter
840 """
841 channel_access_token = channel_access_token or os.getenv("LINE_CHANNEL_ACCESS_TOKEN")
842 channel_secret = channel_secret or os.getenv("LINE_CHANNEL_SECRET")
844 if not channel_access_token:
845 raise ValueError("LINE channel access token required")
846 if not channel_secret:
847 raise ValueError("LINE channel secret required")
849 config = LINEConfig(
850 channel_access_token=channel_access_token,
851 channel_secret=channel_secret,
852 **kwargs
853 )
854 return LINEAdapter(config)