From 21f16ecd6811b818ff4c0c4e7cb2076a3bc0330c Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Sun, 2 Feb 2025 12:18:18 +0800 Subject: [PATCH 1/2] feat: discord adapter --- pkg/core/bootutils/deps.py | 3 +- pkg/platform/manager.py | 2 +- pkg/platform/sources/discord.py | 258 ++++++++++++++++++++++++++++++++ pkg/platform/types/events.py | 5 + requirements.txt | 1 + 5 files changed, 267 insertions(+), 2 deletions(-) create mode 100644 pkg/platform/sources/discord.py diff --git a/pkg/core/bootutils/deps.py b/pkg/core/bootutils/deps.py index a31441bc..755c1211 100644 --- a/pkg/core/bootutils/deps.py +++ b/pkg/core/bootutils/deps.py @@ -26,7 +26,8 @@ "argon2": "argon2-cffi", "jwt": "pyjwt", "Crypto": "pycryptodome", - "lark_oapi": "lark-oapi" + "lark_oapi": "lark-oapi", + "discord": "discord.py" } diff --git a/pkg/platform/manager.py b/pkg/platform/manager.py index b68f1f43..22dfe17d 100644 --- a/pkg/platform/manager.py +++ b/pkg/platform/manager.py @@ -37,7 +37,7 @@ def __init__(self, ap: app.Application = None): async def initialize(self): - from .sources import nakuru, aiocqhttp, qqbotpy, wecom, lark + from .sources import nakuru, aiocqhttp, qqbotpy, wecom, lark, discord async def on_friend_message(event: platform_events.FriendMessage, adapter: msadapter.MessageSourceAdapter): diff --git a/pkg/platform/sources/discord.py b/pkg/platform/sources/discord.py new file mode 100644 index 00000000..4ae086eb --- /dev/null +++ b/pkg/platform/sources/discord.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import discord + +import typing +import asyncio +import traceback +import time +import re +import base64 +import uuid +import json +import datetime + +import aiohttp + +from .. import adapter +from ...pipeline.longtext.strategies import forward +from ...core import app +from ..types import message as platform_message +from ..types import events as platform_events +from ..types import entities as platform_entities +from ...utils import image + + +class DiscordMessageConverter(adapter.MessageConverter): + + @staticmethod + async def yiri2target( + message_chain: platform_message.MessageChain + ) -> typing.Tuple[str, typing.List[discord.File]]: + for ele in message_chain: + if isinstance(ele, platform_message.At): + message_chain.remove(ele) + break + + text_string = "" + image_files = [] + + for ele in message_chain: + if isinstance(ele, platform_message.Image): + image_bytes = None + + if ele.base64: + image_bytes = base64.b64decode(ele.base64) + elif ele.url: + async with aiohttp.ClientSession() as session: + async with session.get(ele.url) as response: + image_bytes = await response.read() + elif ele.path: + with open(ele.path, "rb") as f: + image_bytes = f.read() + + image_files.append(discord.File(fp=image_bytes, filename=f"{uuid.uuid4()}.png")) + elif isinstance(ele, platform_message.Plain): + text_string += ele.text + + return text_string, image_files + + @staticmethod + async def target2yiri( + message: discord.Message + ) -> platform_message.MessageChain: + lb_msg_list = [] + + msg_create_time = datetime.datetime.fromtimestamp( + int(message.created_at.timestamp()) + ) + + lb_msg_list.append( + platform_message.Source(id=message.id, time=msg_create_time) + ) + + element_list = [] + + def text_element_recur(text_ele: str) -> list[platform_message.MessageComponent]: + if text_ele == "": + return [] + + # <@1234567890> + # @everyone + # @here + at_pattern = re.compile(r"(@everyone|@here|<@[\d]+>)") + at_matches = at_pattern.findall(text_ele) + + if len(at_matches) > 0: + mid_at = at_matches[0] + + text_split = text_ele.split(mid_at) + + mid_at_component = [] + + if mid_at == "@everyone" or mid_at == "@here": + mid_at_component.append(platform_message.AtAll()) + else: + mid_at_component.append(platform_message.At(target=mid_at[2:-1])) + + return text_element_recur(text_split[0]) + \ + mid_at_component + \ + text_element_recur(text_split[1]) + else: + return [platform_message.Plain(text=text_ele)] + + + element_list.extend(text_element_recur(message.content)) + + # attachments + for attachment in message.attachments: + async with aiohttp.ClientSession(trust_env=True) as session: + async with session.get(attachment.url) as response: + image_data = await response.read() + image_base64 = base64.b64encode(image_data).decode("utf-8") + image_format = response.headers["Content-Type"] + element_list.append(platform_message.Image(base64=f"data:{image_format};base64,{image_base64}")) + + return platform_message.MessageChain(element_list) + + +class DiscordEventConverter(adapter.EventConverter): + + @staticmethod + async def yiri2target( + event: platform_events.Event + ) -> discord.Message: + pass + + @staticmethod + async def target2yiri( + event: discord.Message + ) -> platform_events.Event: + message_chain = await DiscordMessageConverter.target2yiri(event) + + if type(event.channel) == discord.DMChannel: + return platform_events.FriendMessage( + sender=platform_entities.Friend( + id=event.author.id, + nickname=event.author.name, + remark=event.channel.id, + ), + message_chain=message_chain, + time=event.created_at.timestamp(), + source_platform_object=event, + ) + elif type(event.channel) == discord.TextChannel: + return platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=event.author.id, + member_name=event.author.name, + permission=platform_entities.Permission.Member, + group=platform_entities.Group( + id=event.channel.id, + name=event.channel.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.created_at.timestamp(), + source_platform_object=event, + ) + +@adapter.adapter_class("discord") +class DiscordMessageSourceAdapter(adapter.MessageSourceAdapter): + + bot: discord.Client + + bot_account_id: str # 用于在流水线中识别at是否是本bot,直接以bot_name作为标识 + + config: dict + + ap: app.Application + + message_converter: DiscordMessageConverter = DiscordMessageConverter() + event_converter: DiscordEventConverter = DiscordEventConverter() + + listeners: typing.Dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, adapter.MessageSourceAdapter], None], + ] = {} + + def __init__(self, config: dict, ap: app.Application): + self.config = config + self.ap = ap + + self.bot_account_id = self.config["client_id"] + + adapter_self = self + + class MyClient(discord.Client): + + async def on_message(self: discord.Client, message: discord.Message): + if message.author.id == self.user.id or message.author.bot: + return + + lb_event = await adapter_self.event_converter.target2yiri(message) + await adapter_self.listeners[type(lb_event)](lb_event, adapter_self) + + intents = discord.Intents.default() + intents.message_content = True + + self.bot = MyClient(intents=intents, proxy=self.config["proxy"]) + + 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, + ): + msg_to_send, image_files = await self.message_converter.yiri2target(message) + assert isinstance(message_source.source_platform_object, discord.Message) + + args = { + "content": msg_to_send, + } + + if len(image_files) > 0: + args["files"] = image_files + + if quote_origin: + args["reference"] = message_source.source_platform_object + + if message.has(platform_message.At): + args["mention_author"] = True + + await message_source.source_platform_object.channel.send(**args) + + 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, adapter.MessageSourceAdapter], None], + ): + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[[platform_events.Event, adapter.MessageSourceAdapter], None], + ): + self.listeners.pop(event_type) + + async def run_async(self): + async with self.bot: + await self.bot.start(self.config["token"], reconnect=True) + + async def kill(self) -> bool: + await self.bot.close() + return True diff --git a/pkg/platform/types/events.py b/pkg/platform/types/events.py index 25bbcf86..ae0b6b70 100644 --- a/pkg/platform/types/events.py +++ b/pkg/platform/types/events.py @@ -72,6 +72,11 @@ class MessageEvent(Event): message_chain: platform_message.MessageChain """消息内容。""" + source_platform_object: typing.Optional[typing.Any] = None + """原消息平台对象。 + 供消息平台适配器开发者使用,如果回复用户时需要使用原消息事件对象的信息, + 那么可以将其存到这个字段以供之后取出使用。""" + class FriendMessage(MessageEvent): """好友消息。 diff --git a/requirements.txt b/requirements.txt index fe75198d..b002deb1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,6 +26,7 @@ argon2-cffi pyjwt pycryptodome lark-oapi +discord.py # indirect taskgroup==0.0.0a4 \ No newline at end of file From 5381e09a6c022bfc6366f9a4eb5f10d8b7c8203a Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Sun, 2 Feb 2025 16:28:21 +0800 Subject: [PATCH 2/2] chore: config for discord --- pkg/core/migrations/m024_discord_config.py | 28 +++++++++++++++++++ pkg/core/stages/migrate.py | 2 +- pkg/platform/sources/discord.py | 8 +++++- templates/platform.json | 6 +++++ templates/schema/platform.json | 31 ++++++++++++++++++++++ 5 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 pkg/core/migrations/m024_discord_config.py diff --git a/pkg/core/migrations/m024_discord_config.py b/pkg/core/migrations/m024_discord_config.py new file mode 100644 index 00000000..7318d11f --- /dev/null +++ b/pkg/core/migrations/m024_discord_config.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from .. import migration + + +@migration.migration_class("discord-config", 24) +class DiscordConfigMigration(migration.Migration): + """迁移""" + + async def need_migrate(self) -> bool: + """判断当前环境是否需要运行此迁移""" + + for adapter in self.ap.platform_cfg.data['platform-adapters']: + if adapter['adapter'] == 'discord': + return False + + return True + + async def run(self): + """执行迁移""" + self.ap.platform_cfg.data['platform-adapters'].append({ + "adapter": "discord", + "enable": False, + "client_id": "1234567890", + "token": "XXXXXXXXXX" + }) + + await self.ap.platform_cfg.dump_config() diff --git a/pkg/core/stages/migrate.py b/pkg/core/stages/migrate.py index 69c13beb..1639a736 100644 --- a/pkg/core/stages/migrate.py +++ b/pkg/core/stages/migrate.py @@ -8,7 +8,7 @@ from ..migrations import m005_deepseek_cfg_completion, m006_vision_config, m007_qcg_center_url, m008_ad_fixwin_config_migrate, m009_msg_truncator_cfg from ..migrations import m010_ollama_requester_config, m011_command_prefix_config, m012_runner_config, m013_http_api_config, m014_force_delay_config from ..migrations import m015_gitee_ai_config, m016_dify_service_api, m017_dify_api_timeout_params, m018_xai_config, m019_zhipuai_config -from ..migrations import m020_wecom_config, m021_lark_config, m022_lmstudio_config, m023_siliconflow_config +from ..migrations import m020_wecom_config, m021_lark_config, m022_lmstudio_config, m023_siliconflow_config, m024_discord_config @stage.stage_class("MigrationStage") diff --git a/pkg/platform/sources/discord.py b/pkg/platform/sources/discord.py index 4ae086eb..f8f0179d 100644 --- a/pkg/platform/sources/discord.py +++ b/pkg/platform/sources/discord.py @@ -10,6 +10,7 @@ import base64 import uuid import json +import os import datetime import aiohttp @@ -201,7 +202,12 @@ async def on_message(self: discord.Client, message: discord.Message): intents = discord.Intents.default() intents.message_content = True - self.bot = MyClient(intents=intents, proxy=self.config["proxy"]) + args = {} + + if os.getenv("http_proxy"): + args["proxy"] = os.getenv("http_proxy") + + self.bot = MyClient(intents=intents, **args) async def send_message( self, target_type: str, target_id: str, message: platform_message.MessageChain diff --git a/templates/platform.json b/templates/platform.json index b440264b..0eb13feb 100644 --- a/templates/platform.json +++ b/templates/platform.json @@ -42,6 +42,12 @@ "app_id": "cli_abcdefgh", "app_secret": "XXXXXXXXXX", "bot_name": "LangBot" + }, + { + "adapter": "discord", + "enable": true, + "client_id": "1234567890", + "token": "XXXXXXXXXX" } ], "track-function-calls": true, diff --git a/templates/schema/platform.json b/templates/schema/platform.json index 900623b8..2c05eab6 100644 --- a/templates/schema/platform.json +++ b/templates/schema/platform.json @@ -213,6 +213,37 @@ "description": "飞书的bot_name" } } + }, + { + "title": "Discord 适配器", + "description": "用于接入 Discord", + "properties": { + "adapter": { + "type": "string", + "const": "discord" + }, + "enable": { + "type": "boolean", + "default": false, + "description": "是否启用此适配器", + "layout": { + "comp": "switch", + "props": { + "color": "primary" + } + } + }, + "client_id": { + "type": "string", + "default": "", + "description": "Discord 的 client_id" + }, + "token": { + "type": "string", + "default": "", + "description": "Discord 的 token" + } + } } ] }