diff --git a/pkg/platform/sources/line.png b/pkg/platform/sources/line.png new file mode 100644 index 00000000..c11c0223 Binary files /dev/null and b/pkg/platform/sources/line.png differ diff --git a/pkg/platform/sources/line.py b/pkg/platform/sources/line.py new file mode 100644 index 00000000..1cbf9850 --- /dev/null +++ b/pkg/platform/sources/line.py @@ -0,0 +1,286 @@ +import typing +import quart + + +import traceback +import typing +import asyncio +import re +import base64 +import uuid +import json +import datetime +import hashlib +from Crypto.Cipher import AES + + +from ...core import app +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +from ..logger import EventLogger + + + +from linebot.v3 import ( + WebhookHandler +) +from linebot.v3.exceptions import ( + InvalidSignatureError +) +from linebot.v3.messaging import ( + Configuration, + ApiClient, + MessagingApi, + ReplyMessageRequest, + TextMessage, + ImageMessage +) +from linebot.v3.webhooks import ( + MessageEvent, + TextMessageContent, + ImageMessageContent, + VideoMessageContent, + AudioMessageContent, + FileMessageContent, + LocationMessageContent, + StickerMessageContent +) + +# from linebot import WebhookParser +from linebot.v3.webhook import WebhookParser +from linebot.v3.messaging import MessagingApiBlob + + + +class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + @staticmethod + async def yiri2target( + message_chain: platform_message.MessageChain, api_client: ApiClient + ) -> typing.Tuple[list]: + content_list = [] + for component in message_chain: + if isinstance(component, platform_message.At): + content_list.append({'type': 'at', 'target': component.target}) + elif isinstance(component, platform_message.Plain): + content_list.append({'type': 'text', 'content': component.text}) + elif isinstance(component, platform_message.Image): + if not component.url: + pass + content_list.append({'type': 'image', 'image': component.url}) + + elif isinstance(component, platform_message.Voice): + content_list.append({'type': 'voice', 'url': component.url, 'length': component.length}) + + + return content_list + + @staticmethod + async def target2yiri( + message, + bot_client + ) -> platform_message.MessageChain: + lb_msg_list = [] + msg_create_time = datetime.datetime.fromtimestamp(int(message.timestamp) / 1000) + + lb_msg_list.append(platform_message.Source(id=message.webhook_event_id, time=msg_create_time)) + + if isinstance(message.message, TextMessageContent): + lb_msg_list.append(platform_message.Plain(text=message.message.text)) + elif isinstance(message.message, AudioMessageContent): + pass + elif isinstance(message.message, VideoMessageContent): + pass + elif isinstance(message.message, ImageMessageContent): + message_content = MessagingApiBlob(bot_client).get_message_content(message.message.id) + + base64_string = base64.b64encode(message_content).decode('utf-8') + + # 如果需要Data URI格式(用于直接嵌入HTML等) + # 首先需要知道图片类型,LINE图片通常是JPEG + data_uri = f"data:image/jpeg;base64,{base64_string}" + lb_msg_list.append(platform_message.Image(base64 = data_uri)) + return platform_message.MessageChain(lb_msg_list) + + +class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter): + @staticmethod + async def yiri2target( + event: platform_events.MessageEvent, + ) -> MessageEvent: + pass + + @staticmethod + async def target2yiri( + event, + bot_client + ) -> platform_events.Event: + message_chain = await LINEMessageConverter.target2yiri(event, bot_client) + + if event.source.type== 'user': + return platform_events.FriendMessage( + sender=platform_entities.Friend( + id=event.message.id, + nickname=event.source.user_id, + remark='', + ), + message_chain=message_chain, + time=event.timestamp, + source_platform_object=event, + ) + else: + return platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=event.event.sender.sender_id.open_id, + member_name=event.event.sender.sender_id.union_id, + permission=platform_entities.Permission.Member, + group=platform_entities.Group( + id=event.message.id, + name='', + permission=platform_entities.Permission.Member, + ), + special_title='', + join_timestamp=0, + last_speak_timestamp=0, + mute_time_remaining=0, + ), + message_chain=message_chain, + time=event.timestamp, + source_platform_object=event, + ) + +class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): + bot: MessagingApi + api_client: ApiClient + + bot_account_id: str # 用于在流水线中识别at是否是本bot,直接以bot_name作为标识 + message_converter: LINEMessageConverter + event_converter: LINEEventConverter + + listeners: typing.Dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ] + + config: dict + quart_app: quart.Quart + + + card_id_dict: dict[str, str] # 消息id到卡片id的映射,便于创建卡片后的发送消息到指定卡片 + + seq: int # 用于在发送卡片消息中识别消息顺序,直接以seq作为标识 + + def __init__(self, config: dict, logger: EventLogger): + configuration = Configuration(access_token=config['channel_access_token']) + line_webhook = WebhookHandler(config['channel_secret']) + parser = WebhookParser(config['channel_secret']) + api_client = ApiClient(configuration) + + bot_account_id = config.get('bot_account_id', 'langbot') + + + super().__init__( + config = config, + logger = logger, + quart_app = quart.Quart(__name__), + listeners = {}, + card_id_dict = {}, + seq = 1, + event_converter = LINEEventConverter(), + message_converter = LINEMessageConverter(), + line_webhook = line_webhook, + parser = parser, + configuration=configuration, + api_client = api_client, + bot = MessagingApi(api_client), + bot_account_id = bot_account_id, + ) + + @self.quart_app.route('/line/callback', methods=['POST']) + async def line_callback(): + try: + signature = quart.request.headers.get('X-Line-Signature') + body = await quart.request.get_data(as_text=True) + events = parser.parse(body, signature) # 解密解析消息 + + try: + + # print(events) + lb_event = await self.event_converter.target2yiri(events[0], self.api_client) + if lb_event.__class__ in self.listeners: + await self.listeners[lb_event.__class__](lb_event, self) + except InvalidSignatureError: + self.logger.info(f"Invalid signature. Please check your channel access token/channel secret.{traceback.format_exc()}") + return quart.Response('Invalid signature', status=400) + + + return {'code': 200, 'message': 'ok'} + except Exception: + await self.logger.error(f'Error in LINE callback: {traceback.format_exc()}') + return {'code': 500, 'message': 'error'} + + + + + + + async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): + + pass + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ): + content_list = await self.message_converter.yiri2target(message, self.api_client) + + for content in content_list: + if content['type'] == 'text': + self.bot.reply_message_with_http_info( + ReplyMessageRequest( + reply_token=message_source.source_platform_object.reply_token, + messages=[TextMessage(text=content['content'])] + ) + ) + elif content['type'] == 'image': + self.bot.reply_message_with_http_info( + ReplyMessageRequest( + reply_token=message_source.source_platform_object.reply_token, + messages=[ImageMessage(text=content['content'])] + ) + ) + + async def is_muted(self, group_id: int) -> bool: + return False + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ): + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ): + self.listeners.pop(event_type) + + async def run_async(self): + port = self.config['port'] + + async def shutdown_trigger_placeholder(): + while True: + await asyncio.sleep(1) + await self.quart_app.run_task( + host='0.0.0.0', + port=port, + shutdown_trigger=shutdown_trigger_placeholder, + ) + + async def kill(self) -> bool: + pass diff --git a/pkg/platform/sources/line.yaml b/pkg/platform/sources/line.yaml new file mode 100644 index 00000000..c0237dd9 --- /dev/null +++ b/pkg/platform/sources/line.yaml @@ -0,0 +1,54 @@ +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: LINE + label: + en_US: LINE + zh_Hans: LINE + description: + en_US: LINE Adapter + zh_Hans: LINE适配器,请查看文档了解使用方式 + ja_JP: LINEアダプター、ドキュメントを参照してください + zh_Hant: LINE適配器,請查看文檔了解使用方式 + icon: line.png +spec: + config: + - name: channel_access_token + label: + en_US: Channel access token + zh_Hans: 频道访问令牌 + ja_JP: チャンネルアクセストークン + zh_Hant: 頻道訪問令牌 + type: string + required: true + default: "" + - name: port + label: + en_US: Webhook Port + zh_Hans: Webhook端口 + description: + en_US: Only valid when webhook mode is enabled, please fill in the webhook port + zh_Hans: 请填写 Webhook 端口 + ja_JP: Webhookポートを入力してください + zh_Hant: 請填寫 Webhook 端口 + type: integer + required: true + default: 2287 + - name: channel_secret + label: + en_US: Channel secret + zh_Hans: 消息密钥 + ja_JP: チャンネルシークレット + zh_Hant: 消息密钥 + description: + en_US: Only valid when webhook mode is enabled, please fill in the encrypt key + zh_Hans: 请填写加密密钥 + ja_JP: Webhookモードが有効な場合にのみ、暗号化キーを入力してください + zh_Hant: 請填寫加密密钥 + type: string + required: true + default: "" +execution: + python: + path: ./line.py + attr: LINEAdapter \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 58858687..5d55e4c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,7 @@ dependencies = [ "qdrant-client (>=1.15.1,<2.0.0)", "langbot-plugin==0.1.1b8", "asyncpg>=0.30.0", + "line-bot-sdk>=3.19.0" ] keywords = [ "bot",