Coverage for agentos/channels/adapters/line.py: 0%
131 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 23:17 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 23:17 +0800
1"""
2LINE Channel Adapter — LINE Messaging API.
4LINE Developers Console → Channel Access Token + Channel Secret → webhook → ChannelMessage.
5"""
7from __future__ import annotations
9import base64
10import hashlib
11import hmac
12import json
14from agentos.channels.base import BaseChannelAdapter, ChannelConfig, ReplyResult
15from agentos.channels.message import ChannelMessage, ChannelType, MessageType
18class LINEAdapter(BaseChannelAdapter):
19 """LINE Messaging API adapter.
21 Config fields:
22 channel_access_token: LINE channel access token (long-lived)
23 channel_secret: LINE channel secret (for signature verification)
24 reply_retry_limit: max reply attempts (default 1)
25 """
27 CHANNEL_TYPE = ChannelType.LINE
28 API_BASE = "https://api.line.me/v2"
29 API_DATA = "https://api-data.line.me/v2"
31 def __init__(self, config: ChannelConfig):
32 super().__init__(config)
33 self._access_token = config.extra.get("channel_access_token", "")
34 self._channel_secret = config.extra.get("channel_secret", "")
35 self._retry_limit = config.extra.get("reply_retry_limit", 1)
37 @property
38 def _headers(self) -> dict:
39 return {"Authorization": f"Bearer {self._access_token}"}
41 # ── Signature verification ──
43 def verify_signature(self, body: bytes, signature: str) -> bool:
44 """Verify LINE webhook signature (HMAC-SHA256 base64)."""
45 computed = base64.b64encode(
46 hmac.new(
47 self._channel_secret.encode(),
48 body,
49 hashlib.sha256,
50 ).digest()
51 ).decode()
52 return hmac.compare_digest(computed, signature)
54 # ── Message parsing ──
56 async def parse_incoming(self, payload: dict) -> ChannelMessage | None:
57 """Parse LINE webhook events into ChannelMessage."""
58 events = payload.get("events", [])
59 if not events:
60 return None
62 event = events[0]
63 event_type = event.get("type", "")
65 if event_type == "message":
66 return self._parse_message(event)
67 elif event_type == "postback":
68 return self._parse_postback(event)
69 elif event_type == "follow":
70 return self._parse_follow(event)
71 elif event_type == "unfollow":
72 return ChannelMessage(
73 channel_type=ChannelType.LINE,
74 channel_id=event.get("source", {}).get("userId", ""),
75 user_id=event.get("source", {}).get("userId", ""),
76 content="unfollow",
77 message_type=MessageType.SYSTEM,
78 raw=event,
79 )
81 return None
83 def _parse_message(self, event: dict) -> ChannelMessage | None:
84 """Parse a LINE message event."""
85 source = event.get("source", {})
86 user_id = source.get("userId", "")
87 group_id = source.get("groupId", "")
88 room_id = source.get("roomId", "")
89 channel_id = group_id or room_id or user_id
91 msg = event.get("message", {})
92 msg_type = msg.get("type", "text")
94 content = ""
95 mtype = MessageType.TEXT
96 metadata = {
97 "source_type": source.get("type", "user"),
98 "group_id": group_id,
99 "room_id": room_id,
100 "display_name": "", # Filled via profile API if needed
101 }
103 if msg_type == "text":
104 content = msg.get("text", "")
105 elif msg_type == "image":
106 content = "[Image]"
107 mtype = MessageType.IMAGE
108 metadata["message_id"] = msg.get("id", "")
109 elif msg_type == "video":
110 content = "[Video]"
111 mtype = MessageType.VIDEO
112 elif msg_type == "audio":
113 content = "[Voice message]"
114 mtype = MessageType.VOICE
115 elif msg_type == "file":
116 content = f"[File: {msg.get('fileName', 'unknown')}]"
117 mtype = MessageType.FILE
118 metadata["file_name"] = msg.get("fileName", "")
119 metadata["file_size"] = msg.get("fileSize", 0)
120 elif msg_type == "location":
121 content = f"[Location: {msg.get('title', '')} {msg.get('address', '')}]"
122 mtype = MessageType.LOCATION
123 elif msg_type == "sticker":
124 content = f"[Sticker: {msg.get('packageId')}/{msg.get('stickerId')}]"
125 else:
126 content = f"[{msg_type}]"
128 return ChannelMessage(
129 channel_type=ChannelType.LINE,
130 channel_id=channel_id,
131 user_id=user_id,
132 content=content,
133 message_type=mtype,
134 raw=event,
135 reply_token=event.get("replyToken", ""),
136 metadata=metadata,
137 )
139 def _parse_postback(self, event: dict) -> ChannelMessage | None:
140 """Parse LINE postback event (rich menu, button tap)."""
141 source = event.get("source", {})
142 data = event.get("postback", {}).get("data", "")
143 params = event.get("postback", {}).get("params", {})
145 return ChannelMessage(
146 channel_type=ChannelType.LINE,
147 channel_id=source.get("userId", ""),
148 user_id=source.get("userId", ""),
149 content=data,
150 message_type=MessageType.INTERACTIVE,
151 raw=event,
152 reply_token=event.get("replyToken", ""),
153 metadata={"postback_params": params},
154 )
156 def _parse_follow(self, event: dict) -> ChannelMessage:
157 """Parse LINE follow event."""
158 source = event.get("source", {})
159 return ChannelMessage(
160 channel_type=ChannelType.LINE,
161 channel_id=source.get("userId", ""),
162 user_id=source.get("userId", ""),
163 content="follow",
164 message_type=MessageType.SYSTEM,
165 raw=event,
166 )
168 # ── Reply ──
170 async def reply(self, channel_id: str, content: str, **kwargs) -> ReplyResult:
171 """Send a reply text message."""
172 reply_token = kwargs.get("reply_token", "")
173 if not reply_token:
174 return ReplyResult(success=False, error="reply_token required")
176 return await self._api_reply(
177 reply_token,
178 [
179 {"type": "text", "text": content[:5000]},
180 ],
181 )
183 async def reply_flex(
184 self,
185 channel_id: str,
186 alt_text: str,
187 contents: dict,
188 **kwargs,
189 ) -> ReplyResult:
190 """Send a LINE Flex Message (bubble/carousel)."""
191 reply_token = kwargs.get("reply_token", "")
192 if not reply_token:
193 return ReplyResult(success=False, error="reply_token required")
195 return await self._api_reply(
196 reply_token,
197 [
198 {"type": "flex", "altText": alt_text, "contents": contents},
199 ],
200 )
202 async def reply_quick_reply(
203 self,
204 channel_id: str,
205 text: str,
206 items: list[dict],
207 **kwargs,
208 ) -> ReplyResult:
209 """Send text with quick reply buttons.
211 items = [{"type": "action", "action": {"type": "message", "label": "Yes", "text": "Yes"}}, ...]
212 """
213 reply_token = kwargs.get("reply_token", "")
214 if not reply_token:
215 return ReplyResult(success=False, error="reply_token required")
217 return await self._api_reply(
218 reply_token,
219 [
220 {
221 "type": "text",
222 "text": text[:5000],
223 "quickReply": {"items": items[:13]},
224 },
225 ],
226 )
228 async def push_message(
229 self,
230 user_id: str,
231 messages: list[dict],
232 ) -> ReplyResult:
233 """Push a message to a user (outside reply window)."""
234 return await self._api_push(user_id, messages)
236 async def multicast(
237 self,
238 user_ids: list[str],
239 messages: list[dict],
240 ) -> ReplyResult:
241 """Send the same message to up to 500 users."""
242 return await self._api_call(
243 f"{self.API_BASE}/bot/message/multicast",
244 {"to": user_ids[:500], "messages": messages},
245 method="POST",
246 )
248 # ── Profile ──
250 async def get_profile(self, user_id: str) -> dict | None:
251 """Get LINE user profile."""
252 result = await self._api_call(
253 f"{self.API_BASE}/bot/profile/{user_id}",
254 method="GET",
255 )
256 if result.success:
257 return result.raw
258 return None
260 # ── Rich Menu ──
262 async def set_default_rich_menu(self, rich_menu_id: str) -> bool:
263 """Set the default rich menu for all users."""
264 result = await self._api_call(
265 f"{self.API_BASE}/bot/user/all/richmenu/{rich_menu_id}",
266 method="POST",
267 )
268 return result.success
270 # ── Internal API ──
272 async def _api_reply(self, reply_token: str, messages: list) -> ReplyResult:
273 """Send a reply via reply API."""
274 return await self._api_call(
275 f"{self.API_BASE}/bot/message/reply",
276 {"replyToken": reply_token, "messages": messages},
277 method="POST",
278 )
280 async def _api_push(self, user_id: str, messages: list) -> ReplyResult:
281 """Send a push message."""
282 return await self._api_call(
283 f"{self.API_BASE}/bot/message/push",
284 {"to": user_id, "messages": messages},
285 method="POST",
286 )
288 async def _api_call(
289 self,
290 url: str,
291 body: dict = None,
292 method: str = "POST",
293 ) -> ReplyResult:
294 """Generic LINE API call."""
295 headers = {
296 "Authorization": f"Bearer {self._access_token}",
297 "Content-Type": "application/json",
298 }
300 try:
301 import aiohttp
303 async with aiohttp.ClientSession() as session:
304 if method == "GET":
305 async with session.get(url, headers=headers) as resp:
306 data = await resp.json()
307 return ReplyResult(success=True, raw=data)
308 else:
309 async with session.post(url, headers=headers, json=body) as resp:
310 data = await resp.json()
311 if resp.status == 200:
312 return ReplyResult(success=True, message_id="ok", raw=data)
313 return ReplyResult(
314 success=False,
315 error=data.get("message", "unknown"),
316 )
317 except ImportError:
318 import urllib.request
320 req = urllib.request.Request(
321 url,
322 data=json.dumps(body).encode() if body else None,
323 headers=headers,
324 )
325 with urllib.request.urlopen(req) as resp:
326 data = json.loads(resp.read())
327 return ReplyResult(success=True, message_id="ok", raw=data)