diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a295864 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +*.pyc +__pycache__ diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml new file mode 100644 index 0000000..afd6dd7 --- /dev/null +++ b/.github/workflows/image.yml @@ -0,0 +1,50 @@ +name: Docker Image + +on: + push: + paths: + - ".github/workflows/image.yml" + - "docker/Dockerfile" + - "chatbridge/**" + - "lang/**" + - "__main__.py" + - "LICENSE" + - "mcdreforged.plugin.json" + - "**/requirements.txt" + +jobs: + image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: fallenbreath + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: | + fallenbreath/chatbridge + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + platforms: 'linux/amd64' + file: ./docker/Dockerfile + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 0000000..b8328ea --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,43 @@ +name: CI for MCDR Plugin + +on: + push: + pull_request: + +jobs: + package: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Setup python + uses: actions/setup-python@v5 + with: + python-version: 3.9 + + - uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Pack Plugin + run: | + python -m mcdreforged pack -o ./package + + - uses: actions/upload-artifact@v4 + with: + name: Chatbridge distribution for ${{ github.sha }} + path: package/ + + - name: Publish distribution to release + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + files: package/*.pyz diff --git a/.gitignore b/.gitignore index a4e5bf9..6ffbf03 100644 --- a/.gitignore +++ b/.gitignore @@ -4,11 +4,13 @@ test.py *.log *.pyc -/__pycache__ +__pycache__ StatsHelper.py /stats_helper /more_apis *.zip *.mcdr *.pyz +*.bat +*.sh ChatBridge_*.json diff --git a/README.md b/README.md index 04b70fe..42cad42 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,22 @@ See [here](https://github.com/TISUnion/ChatBridge/tree/v1) for chatbridge v1. **NOT compatible with Chatbridge v1** -![topomap](https://raw.githubusercontent.com/TISUnion/ChatBridge/master/topomap.png) +```mermaid +flowchart LR + subgraph Minecraft Host + mcdr1("ChatBridge Client (MCDR plugin)")<--MCDR-->smp[Minecraft Surival Server] + mcdr2("ChatBridge Client (MCDR plugin)")<--MCDR-->cmp[Minecraft Creative Server] + mcdr3("ChatBridge Client (MCDR plugin)")<--MCDR-->smpc[Minecraft Mirror Server] + online("ChatBridge Online Command Client")<--RCON-->bc[Bungeecord Server] + end + + server(["ChatBridge Server"]) + server<-->mcdr1 & mcdr2 & mcdr3 & online + server<-->cli_client("CLI Client")<-.->user[/User/] + server<-->cq_client("ChatBridge CQHttp Client")<-->cqhttp[CQ Http bot]-.-QQ + server<-->khl_client("ChatBridge Khl Client")<-->khl["Kaiheila (Kook)"] + server<-->discord_client("ChatBridge Discord Client")<-->Discord +``` ## Disclaimer @@ -14,11 +29,12 @@ ChatBridge is mainly for custom use of TIS server, especially the bot/command co - Discord client - Kaiheila client - Online command client +- ... -Therefore for these bot and related clients: +Therefore, for these bot and related clients: - Expect hardcoded constants in codes and lack of document/usage/support -- PRs for features will not be accepted, related issues will probably be ignored +- PRs for features will not be accepted, issues complaining something don't work will probably be ignored. No after-sales support - If you want more features, fork this repository and implement them yourself But the basic chatbridge components are within the support range, including: @@ -27,12 +43,51 @@ But the basic chatbridge components are within the support range, including: - CLI server - MCDR plugin +## 免责声明 + +ChatBridge 是一个为 TIS 服务器定制使用的工具,尤其是 bot/指令相关的组件: + +- CQHttp 客户端 +- Discord 客户端 +- Kaiheila 客户端 +- Online 指令客户端 +- ... + +因此,对于这些 bot 及相关的客户端: + +- 代码中将会包含若干硬编码常量,缺乏相关的文档/用法等支持 +- 功能方面的 PR 不会被接受,相关的 issue 大概率会被忽略,没有售后 +- 如果你想要更多的功能,建议你去 fork 这个仓库,然后自己实现 + +但基本的 ChatBridge 组件都是在支持范围内,这包括: + +- CLI 客户端 +- CLI 服务端 +- MCDR 插件 + ## Usage Enter `python ChatBridge.pyz` in command line to see possible helps At launch, if the configure file is missing, chatbridge will automatically generate a default one and exit +## Docker Image + +[![Docker](https://img.shields.io/docker/v/fallenbreath/chatbridge/latest)](https://hub.docker.com/r/fallenbreath/chatbridge) + +Docker Hub image: [`fallenbreath/chatbridge`](https://hub.docker.com/r/fallenbreath/chatbridge) + +Image name examples: + +- `fallenbreath/chatbridge:latest` +- `fallenbreath/chatbridge:v2.5.3` + +Working directory: `/app` + +Example usages: `docker run --rm fallenbreath/chatbridge:latest server` + +See the [./docker](docker) directory in the repository for more details and docker compose example + ## Requirement Python 3.6+ required @@ -94,6 +149,7 @@ Just put the `.pyz` file into the plugin folder Extra configure fields (compared to [CLI client](#cli-client)) ```json5 + "enable": true, // for switching the functionality of the chatbridge plugin "debug": false, // for switching debug logging on ``` @@ -104,7 +160,7 @@ Extra configure fields (compared to [CLI client](#cli-client)) Extra requirements (also listed in `/chatbridge/impl/discord/requirements.txt`): ``` -discord.py +discord.py>=2.0.0 ``` Extra configure fields (compared to [CLI client](#cli-client)) @@ -136,7 +192,6 @@ python ChatBridge.pyz cqhttp_bot Extra requirements (also listed in `/chatbridge/impl/cqhttp/requirements.txt`): ``` -websocket>=0.2.1 websocket-client>=1.2.1 ``` @@ -163,21 +218,47 @@ Extra configure fields (compared to [CLI client](#cli-client)) "server_display_name": "TIS" // The name of the server, used for display in some places ``` +## Client as a Satori client + +``` +python ChatBridge.pyz cqhttp_bot +``` + +Extra requirements (also listed in `/chatbridge/impl/satori/requirements.txt`): + +``` +satori-python>=0.11 +``` + +Just like the CoolqHttp client, but it uses satori protocol + +Extra configure fields (compared to [CLI client](#cli-client)) + +```json5 + "ws_address": "127.0.0.1", // satori server address + "ws_port": 5500, // satori server port + "ws_path": "", // satori server optional path prefix + "satori_token": "xxxxx", // satori access token + "react_channel_id": 12345, // the target channel id (for QQ, it's the group id) + "chatbridge_message_prefix": "!!qq", + "client_to_query_stats": "MyClient1", // it should be a client as an MCDR plugin, with stats_helper plugin installed in the MCDR + "client_to_query_online": "MyClient2", // a client described in the following section "Client to respond online command" + "server_display_name": "TIS" // The name of the server, used for display in some places +``` + ## Kaiheila bot client -`python ChatBridge.pyz kaiheila_bot` +`python ChatBridge.pyz satori_bot` Extra requirements (also listed in `/chatbridge/impl/kaiheila/requirements.txt`): ``` -khl.py==0.0.10 +khl.py~=0.3.17 ``` Extra configure fields (compared to [CLI client](#cli-client)) ```json5 - "client_id": "", // kaiheila client id - "client_secret": "", // kaiheila client secret "token": "", // kaiheila token "channels_for_command": [ // a list of channels, public commands can be used here. use string "123400000000000000", diff --git a/chatbridge/cli_entry.py b/chatbridge/cli_entry.py index c0f572a..5ad540f 100644 --- a/chatbridge/cli_entry.py +++ b/chatbridge/cli_entry.py @@ -25,6 +25,11 @@ def cqhttp_bot(): entry.main() +def satori_bot(): + from chatbridge.impl.satori import entry + entry.main() + + def online_command(): from chatbridge.impl.online import entry entry.main() @@ -49,6 +54,7 @@ def main(): print('{} server: Start the ChatBridge server'.format(prefix)) print('{} discord_bot: Start a Discord bot as client'.format(prefix)) print('{} cqhttp_bot: Start a CQ-Http bot as client'.format(prefix)) + print('{} satori_bot: Start a Satori bot as client'.format(prefix)) print('{} kaiheila_bot: Start a Kaiheila bot as client'.format(prefix)) - print('{} online_command: Start a CQ-Http bot as client'.format(prefix)) + print('{} online_command: Start a OnlineCommand bot as client'.format(prefix)) diff --git a/chatbridge/common/serializer.py b/chatbridge/common/serializer.py index 8a86e6c..33f83ef 100644 --- a/chatbridge/common/serializer.py +++ b/chatbridge/common/serializer.py @@ -1,10 +1,15 @@ +from typing import TypeVar, Type + from mcdreforged.api.utils.serializer import Serializable +Self = TypeVar('Self', bound='NoMissingFieldSerializable') + class NoMissingFieldSerializable(Serializable): @classmethod - def deserialize(cls, data: dict, **kwargs): + def deserialize(cls: Type[Self], data: dict, **kwargs) -> Self: kwargs.setdefault('error_at_missing', True) + # noinspection PyTypeChecker return super().deserialize(data, **kwargs) @classmethod diff --git a/chatbridge/core/client.py b/chatbridge/core/client.py index f585606..527df68 100644 --- a/chatbridge/core/client.py +++ b/chatbridge/core/client.py @@ -15,7 +15,7 @@ from chatbridge.core.network import net_util from chatbridge.core.network.basic import ChatBridgeBase, Address from chatbridge.core.network.protocol import ChatBridgePacket, PacketType, AbstractPacket, ChatPayload, \ - KeepAlivePayload, AbstractPayload, CommandPayload + KeepAlivePayload, AbstractPayload, CommandPayload, CustomPayload from chatbridge.core.network.protocol import LoginPacket, LoginResultPacket @@ -234,9 +234,9 @@ def _on_stopped(self): self.__thread_keep_alive.join() self.logger.debug('Joined keep alive thread') - # ---------------- - # Packet Logic - # ---------------- + # --------------------- + # Packet core logic + # --------------------- def _send_packet(self, packet: AbstractPacket): if self._is_connected(): @@ -279,13 +279,22 @@ def send_to(self, type_: str, clients: Union[str, Iterable[str]], payload: Abstr def send_to_all(self, type_: str, payload: AbstractPayload): self.__build_and_send_packet(type_, [], payload, is_broadcast=True) + # ------------------------- + # Packet handlers + # ------------------------- + def _on_packet(self, packet: ChatBridgePacket): + """ + A dispatcher that dispatch the packet based on packet type + """ if packet.type == PacketType.keep_alive: self._on_keep_alive(packet.sender, KeepAlivePayload.deserialize(packet.payload)) - if packet.type == PacketType.chat: + elif packet.type == PacketType.chat: self.on_chat(packet.sender, ChatPayload.deserialize(packet.payload)) - if packet.type == PacketType.command: + elif packet.type == PacketType.command: self.on_command(packet.sender, CommandPayload.deserialize(packet.payload)) + elif packet.type == PacketType.custom: + self.on_custom(packet.sender, CustomPayload.deserialize(packet.payload)) def _on_keep_alive(self, sender: str, payload: KeepAlivePayload): if payload.is_ping(): @@ -301,10 +310,20 @@ def on_chat(self, sender: str, payload: ChatPayload): def on_command(self, sender: str, payload: CommandPayload): pass + def on_custom(self, sender: str, payload: CustomPayload): + pass + + # ------------------------- + # Send packet shortcuts + # ------------------------- + def _send_keep_alive_ping(self): self.send_to(PacketType.keep_alive, self._keep_alive_target(), KeepAlivePayload.ping()) - def send_chat(self, message: str, author: str = ''): + def send_chat(self, target: str, message: str, author: str = ''): + self.send_to(PacketType.chat, target, ChatPayload(author=author, message=message)) + + def broadcast_chat(self, message: str, author: str = ''): self.send_to_all(PacketType.chat, ChatPayload(author=author, message=message)) def send_command(self, target: str, command: str, params: Optional[Union[Serializable, dict]] = None): @@ -313,9 +332,15 @@ def send_command(self, target: str, command: str, params: Optional[Union[Seriali def reply_command(self, target: str, asker_payload: 'CommandPayload', result: Union[Serializable, dict]): self.send_to(PacketType.command, target, CommandPayload.answer(asker_payload, result)) - # -------------- - # Keep Alive - # -------------- + def send_custom(self, target: str, data: dict): + self.send_to(PacketType.chat, target, CustomPayload(data=data)) + + def broadcast_custom(self, data: dict): + self.send_to_all(PacketType.chat, CustomPayload(data=data)) + + # ------------------- + # Keep Alive Impl + # ------------------- def _get_keep_alive_thread_name(self): return 'KeepAlive' diff --git a/chatbridge/core/network/protocol.py b/chatbridge/core/network/protocol.py index 7c0d2e4..cbf5f99 100644 --- a/chatbridge/core/network/protocol.py +++ b/chatbridge/core/network/protocol.py @@ -29,6 +29,7 @@ class PacketType: keep_alive = 'chatbridge.keep_alive' chat = 'chatbridge.chat' command = 'chatbridge.command' + custom = 'chatbridge.custom' class ChatBridgePacket(AbstractPacket): @@ -111,3 +112,7 @@ def answer(cls, asker_payload: 'CommandPayload', result: Union[Serializable, dic params=asker_payload.params, result=result, ) + + +class CustomPayload(AbstractPayload): + data: dict diff --git a/chatbridge/core/server.py b/chatbridge/core/server.py index 7685d58..49f9efc 100644 --- a/chatbridge/core/server.py +++ b/chatbridge/core/server.py @@ -111,6 +111,7 @@ def is_running(self) -> bool: def _main_loop(self): self.__sock = socket.socket() + self.__sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: self.__sock.bind(self.server_address) except: diff --git a/chatbridge/impl/cli/cli_client.py b/chatbridge/impl/cli/cli_client.py index b61687c..a336e3e 100644 --- a/chatbridge/impl/cli/cli_client.py +++ b/chatbridge/impl/cli/cli_client.py @@ -17,6 +17,9 @@ def on_chat(self, sender: str, payload: ChatPayload): def console_loop(self): while True: text = input() + if len(text) == 0: + continue + self.logger.info('Processing user input "{}"'.format(text)) if text == 'start': self.start() @@ -33,7 +36,7 @@ def console_loop(self): self.logger.info('restart: restart the client') self.logger.info('ping: display ping') else: - self.send_chat(text) + self.broadcast_chat(text) def main(): diff --git a/chatbridge/impl/cli/cli_server.py b/chatbridge/impl/cli/cli_server.py index 893a0f1..dfdb87c 100644 --- a/chatbridge/impl/cli/cli_server.py +++ b/chatbridge/impl/cli/cli_server.py @@ -1,3 +1,4 @@ +import sys import threading import time import traceback @@ -46,6 +47,9 @@ def on_chat(self, sender: str, content: ChatPayload): def console_loop(self): while self.is_running(): text = input() + if len(text) == 0: + continue + self.logger.info('Processing user input "{}"'.format(text)) if text == 'stop': self.stop() @@ -87,7 +91,12 @@ def main(): print('- Client #{}: name = {}, password = {}'.format(i + 1, client_info.name, client_info.password)) server.add_client(client_info) server.start() - server.console_loop() + + if sys.stdin.isatty(): + server.console_loop() + else: + utils.wait_until_terminate() + server.stop() if __name__ == '__main__': diff --git a/chatbridge/impl/cqhttp/copywritings.py b/chatbridge/impl/cqhttp/copywritings.py new file mode 100644 index 0000000..ffaf847 --- /dev/null +++ b/chatbridge/impl/cqhttp/copywritings.py @@ -0,0 +1,15 @@ +CQHelpMessage = ''' +!!help: 显示本条帮助信息 +!!ping: pong!! +!!mc <消息>: 向 MC 中发送聊天信息 <消息> +!!online: 显示正版通道在线列表 +!!stats <类别> <内容> [<-bot>]: 查询统计信息 <类别>.<内容> 的排名 +'''.strip() + +StatsHelpMessage = ''' +!!stats <类别> <内容> [<-bot>] +添加 `-bot` 来列出 bot +例子: +!!stats used diamond_pickaxe +!!stats custom time_since_rest -bot +'''.strip() diff --git a/chatbridge/impl/cqhttp/entry.py b/chatbridge/impl/cqhttp/entry.py index c46db46..303bc91 100644 --- a/chatbridge/impl/cqhttp/entry.py +++ b/chatbridge/impl/cqhttp/entry.py @@ -6,30 +6,16 @@ from chatbridge.common.logger import ChatBridgeLogger from chatbridge.core.client import ChatBridgeClient -from chatbridge.core.network.protocol import ChatPayload, CommandPayload +from chatbridge.core.network.protocol import ChatPayload, CommandPayload, CustomPayload from chatbridge.impl import utils from chatbridge.impl.cqhttp.config import CqHttpConfig +from chatbridge.impl.cqhttp.copywritings import CQHelpMessage, StatsHelpMessage from chatbridge.impl.tis.protocol import StatsQueryResult, OnlineQueryResult ConfigFile = 'ChatBridge_CQHttp.json' cq_bot: Optional['CQBot'] = None chatClient: Optional['CqHttpChatBridgeClient'] = None -CQHelpMessage = ''' -!!help: 显示本条帮助信息 -!!ping: pong!! -!!mc <消息>: 向 MC 中发送聊天信息 <消息> -!!online: 显示正版通道在线列表 -!!stats <类别> <内容> [<-bot>]: 查询统计信息 <类别>.<内容> 的排名 -'''.strip() -StatsHelpMessage = ''' -!!stats <类别> <内容> [<-bot>] -添加 `-bot` 来列出 bot -例子: -!!stats used diamond_pickaxe -!!stats custom time_since_rest -bot -'''.strip() - class CQBot(websocket.WebSocketApp): def __init__(self, config: CqHttpConfig): @@ -51,10 +37,9 @@ def on_message(self, _, message: str): if chatClient is None: return data = json.loads(message) - if 'status' in data: - self.logger.info('CoolQ return status {}'.format(data['status'])) - elif data['post_type'] == 'message' and data['message_type'] == 'group': - if data['anonymous'] is None and data['group_id'] == self.config.react_group_id: + if data.get('post_type') == 'message' and data.get('message_type') == 'group': + if data.get('anonymous') is None and data['group_id'] == self.config.react_group_id: + self.logger.info('QQ chat message: {}'.format(data)) args = data['raw_message'].split(' ') if len(args) == 1 and args[0] == '!!help': @@ -71,10 +56,15 @@ def on_message(self, _, message: str): if len(sender) == 0: sender = data['sender']['nickname'] text = html.unescape(data['raw_message'].split(' ', 1)[1]) - chatClient.send_chat(text, sender) + chatClient.broadcast_chat(text, sender) if len(args) == 1 and args[0] == '!!online': self.logger.info('!!online command triggered') + if not self.config.client_to_query_online: + self.logger.info('!!online command is not enabled') + self.send_text('!!online 指令未启用') + return + if chatClient.is_online(): command = args[0] client = self.config.client_to_query_online @@ -85,6 +75,11 @@ def on_message(self, _, message: str): if len(args) >= 1 and args[0] == '!!stats': self.logger.info('!!stats command triggered') + if not self.config.client_to_query_stats: + self.logger.info('!!stats command is not enabled') + self.send_text('!!stats 指令未启用') + return + command = '!!stats rank ' + ' '.join(args[1:]) if len(args) == 0 or len(args) - int(command.find('-bot') != -1) != 3: self.send_text(StatsHelpMessage) @@ -163,12 +158,29 @@ def on_command(self, sender: str, payload: CommandPayload): result = OnlineQueryResult.deserialize(payload.result) cq_bot.send_text('====== 玩家列表 ======\n{}'.format('\n'.join(result.data))) + def on_custom(self, sender: str, payload: CustomPayload): + global cq_bot + if cq_bot is None: + return + try: + __example_data = { + 'cqhttp_client.action': 'send_text', + 'text': 'the message you want to send' + } + if payload.data.get('cqhttp_client.action') == 'send_text': + text = payload.data.get('text') + self.logger.info('Triggered custom text, sending message {} to qq'.format(text)) + cq_bot.send_text(text) + except: + self.logger.exception('Error in on_custom()') + def main(): global chatClient, cq_bot config = utils.load_config(ConfigFile, CqHttpConfig) chatClient = CqHttpChatBridgeClient.create(config) utils.start_guardian(chatClient) + utils.register_exit_on_termination() print('Starting CQ Bot') cq_bot = CQBot(config) cq_bot.start() diff --git a/chatbridge/impl/cqhttp/requirements.txt b/chatbridge/impl/cqhttp/requirements.txt index 6a28e81..3190b31 100644 --- a/chatbridge/impl/cqhttp/requirements.txt +++ b/chatbridge/impl/cqhttp/requirements.txt @@ -1,2 +1 @@ -websocket>=0.2.1 websocket-client>=1.2.1 diff --git a/chatbridge/impl/discord/bot.py b/chatbridge/impl/discord/bot.py index 9f2102b..e99fdf1 100644 --- a/chatbridge/impl/discord/bot.py +++ b/chatbridge/impl/discord/bot.py @@ -104,7 +104,7 @@ async def on_message(self, message: Message): # Chat if message.channel.id == self.config.channel_for_chat: self.logger.info('Chat: {}'.format(msg_debug)) - stored.client.send_chat(message.content, author=message.author.name) + stored.client.broadcast_chat(message.content, author=message.author.name) def add_message(self, data, channel_id, t): self.messages.put(MessageData(data=data, channel=channel_id, type=t)) @@ -151,7 +151,10 @@ def format_message_text(msg): def create_bot() -> DiscordBot: config = stored.config - bot = DiscordBot(config.command_prefix) + + intents = discord.Intents.default() + intents.message_content = True + bot = DiscordBot(config.command_prefix, intents=intents) # noinspection PyShadowingBuiltins @bot.command() diff --git a/chatbridge/impl/discord/entry.py b/chatbridge/impl/discord/entry.py index adce48a..e139c57 100644 --- a/chatbridge/impl/discord/entry.py +++ b/chatbridge/impl/discord/entry.py @@ -13,12 +13,13 @@ def main(): stored.client = DiscordChatClient.create(stored.config) stored.bot = bot.create_bot() utils.start_guardian(stored.client) + utils.register_exit_on_termination() try: stored.bot.start_running() except (KeyboardInterrupt, SystemExit): stored.client.stop() - except: + except Exception: print(traceback.format_exc()) print('Bye~') diff --git a/chatbridge/impl/kaiheila/entry.py b/chatbridge/impl/kaiheila/entry.py index fd72703..b3ffc85 100644 --- a/chatbridge/impl/kaiheila/entry.py +++ b/chatbridge/impl/kaiheila/entry.py @@ -1,12 +1,12 @@ import asyncio import collections -import json import logging import queue from typing import Optional, List -from khl import Bot, Cert, Msg +from khl import Bot, Message, MessageTypes, PublicMessage +from chatbridge.common.logger import ChatBridgeLogger from chatbridge.core.client import ChatBridgeClient from chatbridge.core.config import ClientConfig from chatbridge.core.network.protocol import ChatPayload, CommandPayload @@ -22,8 +22,6 @@ class KaiHeiLaConfig(ClientConfig): - client_id: str = '' - client_secret: str = '' token: str = '' channels_for_command: List[str] = [ '123321', @@ -50,24 +48,65 @@ class MessageDataType: chatClient: Optional['KhlChatBridgeClient'] = None -class KaiHeiLaBot(Bot): +class KaiHeiLaBot: def __init__(self, config: KaiHeiLaConfig): self.config = config - cert = Cert( - client_id=self.config.client_id, - client_secret=self.config.client_secret, - token=self.config.token - ) - super().__init__(cert=cert, cmd_prefix=[self.config.command_prefix]) + self.loop = asyncio.get_event_loop() + self.bot = Bot(token=self.config.token) + self.__register_bot_callbacks() + self.logger = ChatBridgeLogger('khl', file_handler=chatClient.logger.file_handler) self.messages = queue.Queue() - self.event_loop = asyncio.get_event_loop() - self._setup_event_loop(self.event_loop) def startRunning(self): self.logger.info('Starting the bot') - self.on_text_msg(self.on_message) - asyncio.ensure_future(self.on_ready(), loop=self.event_loop) - self.run() + self.bot.run() + + def __register_bot_callbacks(self): + self.bot.on_message()(self.__on_message) + self.bot.on_startup(self.__on_ready) + + def bot_command(): + return self.bot.command(prefixes=[self.config.command_prefix]) + + @bot_command() + async def help(msg: Message): + if msg.ctx.channel.id in self.config.channels_for_command: + if msg.ctx.channel.id == self.config.channel_for_chat: + text = CommandHelpMessageAll + else: + text = CommandHelpMessage + await msg.reply(text) + + @bot_command() + async def ping(msg: Message): + if msg.ctx.channel.id in self.config.channels_for_command: + self.logger.info('!!ping command triggered') + await msg.reply('pong!!') + + async def send_chatbridge_command(target_client: str, command: str, msg: Message): + if chatClient.is_online(): + self.logger.info('Sending command "{}" to client {}'.format(command, target_client)) + chatClient.send_command(target_client, command, params={'from_channel': msg.ctx.channel.id}) + else: + await msg.reply('ChatBridge client is offline') + + @bot_command() + async def online(msg: Message): + if msg.ctx.channel.id == config.channel_for_chat: # chat channel only + self.logger.info('!!online command triggered') + await send_chatbridge_command(config.client_to_query_online, '!!online', msg) + + @bot_command() + async def stats(msg: Message, *args): + self.logger.info('!!stats command triggered, args: {}'.format(args)) + args = list(args) + if len(args) >= 1 and args[0] == 'rank': + args.pop(0) + command = '!!stats rank ' + ' '.join(args) + if len(args) == 0 or len(args) - int(command.find('-bot') != -1) - int(command.find('-all') != -1) != 2: + await msg.reply(StatsCommandHelpMessage) + else: + await send_chatbridge_command(config.client_to_query_stats, command, msg) async def listeningMessage(self): self.logger.info('Message listening looping...') @@ -83,41 +122,45 @@ async def listeningMessage(self): assert isinstance(data, tuple) sender: str = data[0] payload: ChatPayload = data[1] - await self.send(self.config.channel_for_chat, self.formatMessageToKaiHeiLa('[{}] {}'.format(sender, payload.formatted_str()))) + ch = await self.bot.client.fetch_public_channel(self.config.channel_for_chat) + await self.bot.client.send(ch, self.formatMessageToKaiHeiLa('[{}] {}'.format(sender, payload.formatted_str()))) elif message_data.type == MessageDataType.CARD: # embed assert isinstance(data, list) - await self.send(message_data.channel, json.dumps([ + ch = await self.bot.client.fetch_public_channel(message_data.channel) + await self.bot.client.send(ch, [ { "type": "card", "theme": "secondary", "size": "lg", "modules": data } - ]), type=Msg.Types.CARD) + ], type=MessageTypes.CARD) elif message_data.type == MessageDataType.TEXT: - await self.send(message_data.channel, self.formatMessageToKaiHeiLa(str(data))) + await self.bot.client.send(message_data.channel, self.formatMessageToKaiHeiLa(str(data))) else: self.logger.debug('Unknown messageData type {}'.format(message_data.data)) - except: + except Exception: self.logger.exception('Error looping khl bot') - async def on_ready(self): - id_ = await self.id() - self.logger.info(f'Logged in with id {id_}') - await self.listeningMessage() + async def __on_ready(self, b: Bot): + me = await b.client.fetch_me() + self.logger.info(f'Logged in as {me.username} with id {me.id}') + _ = asyncio.create_task(self.listeningMessage()) - async def on_message(self, message: Msg): - if message.author_id == await self.id(): + async def __on_message(self, message: Message): + if message.author_id == (await self.bot.client.fetch_me()).id: + return + if not isinstance(message, PublicMessage): return - channel_id = message.ctx.channel.id - author = message.ctx.author.username + channel_id = message.channel.id + author = message.author.username self.logger.debug('channel id = {}'.format(channel_id)) if channel_id in self.config.channels_for_command or channel_id == self.config.channel_for_chat: self.logger.info(f"{channel_id}: {author}: {message.content}") if channel_id == self.config.channel_for_chat: global chatClient - if not message.content.startswith(self.config.command_prefix): - chatClient.send_chat(message.content, author=author) + if message.content.startswith('!!qq ') or not message.content.startswith(self.config.command_prefix): + chatClient.broadcast_chat(message.content, author=author) def add_message(self, data, channel_id, t): self.messages.put(MessageData(data=data, channel=channel_id, type=t)) @@ -156,49 +199,6 @@ def formatMessageToKaiHeiLa(self, message: str) -> str: return message -def createKaiHeiLaBot() -> KaiHeiLaBot: - bot = KaiHeiLaBot(config) - - @bot.command() - async def help(msg: Msg): - if msg.ctx.channel.id in bot.config.channels_for_command: - if msg.ctx.channel.id == bot.config.channel_for_chat: - text = CommandHelpMessageAll - else: - text = CommandHelpMessage - await msg.reply(text) - - @bot.command() - async def ping(msg: Msg): - if msg.ctx.channel.id in bot.config.channels_for_command: - await bot.send(msg.ctx.channel.id, 'pong!!') - - async def send_chatbridge_command(target_client: str, command: str, msg: Msg): - if chatClient.is_online(): - bot.logger.info('Sending command "{}" to client {}'.format(command, target_client)) - chatClient.send_command(target_client, command, params={'from_channel': msg.ctx.channel.id}) - else: - await msg.reply('ChatBridge client is offline') - - @bot.command() - async def online(msg: Msg): - if msg.ctx.channel.id == config.channel_for_chat: # chat channel only - await send_chatbridge_command(config.client_to_query_online, '!!online', msg) - - @bot.command() - async def stats(msg: Msg, *args): - args = list(args) - if len(args) >= 1 and args[0] == 'rank': - args.pop(0) - command = '!!stats rank ' + ' '.join(args) - if len(args) == 0 or len(args) - int(command.find('-bot') != -1) - int(command.find('-all') != -1) != 2: - await msg.reply(StatsCommandHelpMessage) - else: - await send_chatbridge_command(config.client_to_query_stats, command, msg) - - return bot - - class KhlChatBridgeClient(ChatBridgeClient): def on_chat(self, sender: str, payload: ChatPayload): khlBot.add_message((sender, payload), None, MessageDataType.CHAT) @@ -222,7 +222,7 @@ def on_command(self, sender: str, payload: CommandPayload): message = '错误代码:{}'.format(result.error_code) khlBot.add_message(message, channel_id, MessageDataType.TEXT) elif payload.command == '!!online': - result = OnlineQueryResult.deserialize(payload.result) + result: OnlineQueryResult = OnlineQueryResult.deserialize(payload.result) khlBot.add_embed('{} online players'.format(config.server_display_name), '\n'.join(result.data), channel_id) @@ -231,8 +231,9 @@ def main(): config = utils.load_config(ConfigFile, KaiHeiLaConfig) chatClient = KhlChatBridgeClient.create(config) utils.start_guardian(chatClient) + utils.register_exit_on_termination() print('Starting KHL Bot') - khlBot = createKaiHeiLaBot() + khlBot = KaiHeiLaBot(config) khlBot.startRunning() print('Bye~') diff --git a/chatbridge/impl/kaiheila/requirements.txt b/chatbridge/impl/kaiheila/requirements.txt index 73ac0c3..7edbe2e 100644 --- a/chatbridge/impl/kaiheila/requirements.txt +++ b/chatbridge/impl/kaiheila/requirements.txt @@ -1 +1 @@ -khl.py==0.0.10 +khl.py~=0.3.17 diff --git a/chatbridge/impl/mcdr/client.py b/chatbridge/impl/mcdr/client.py index a8819f5..ba96ecd 100644 --- a/chatbridge/impl/mcdr/client.py +++ b/chatbridge/impl/mcdr/client.py @@ -5,7 +5,7 @@ from chatbridge.core.client import ChatBridgeClient from chatbridge.core.network.protocol import ChatPayload, CommandPayload from chatbridge.impl.mcdr.config import MCDRClientConfig -from chatbridge.impl.tis.protocol import StatsQueryResult +from chatbridge.impl.tis.protocol import StatsQueryResult, OnlineQueryResult class ChatBridgeMCDRClient(ChatBridgeClient): @@ -38,9 +38,10 @@ def on_chat(self, sender: str, payload: ChatPayload): self.server.say(RText('[{}] {}'.format(sender, payload.formatted_str()), RColor.gray)) def on_command(self, sender: str, payload: CommandPayload): + is_ask = not payload.responded command = payload.command result: Optional[Serializable] = None - if command.startswith('!!stats '): + if command.startswith('!!stats '): # !!stats request try: import stats_helper except (ImportError, ModuleNotFoundError): @@ -69,6 +70,17 @@ def on_command(self, sender: str, payload: CommandPayload): result = StatsQueryResult.create(stats_name, lines[1:-1], total) else: result = StatsQueryResult.unknown_stat() + elif command == '!!online': # !!online response + player = payload.params.get('player') + if player is None: + self.logger.warning('No player in params, params {}'.format(payload.params)) + else: + result: OnlineQueryResult = OnlineQueryResult.deserialize(payload.result) + for line in result.data: + self.server.tell(player, line) - if result is not None: + if is_ask and result is not None: self.reply_command(sender, payload, result) + + def query_online(self, client_to_query_online: str, player: str): + self.send_command(client_to_query_online, '!!online', params={'player': player}) diff --git a/chatbridge/impl/mcdr/config.py b/chatbridge/impl/mcdr/config.py index 605bbb4..271346f 100644 --- a/chatbridge/impl/mcdr/config.py +++ b/chatbridge/impl/mcdr/config.py @@ -1,5 +1,9 @@ +from typing import Optional + from chatbridge.core.config import ClientConfig class MCDRClientConfig(ClientConfig): + enable: bool = True debug: bool = False + client_to_query_online: Optional[str] = None diff --git a/chatbridge/impl/mcdr/mcdr_entry.py b/chatbridge/impl/mcdr/mcdr_entry.py index fcd0b28..1f5f695 100644 --- a/chatbridge/impl/mcdr/mcdr_entry.py +++ b/chatbridge/impl/mcdr/mcdr_entry.py @@ -33,6 +33,17 @@ def display_status(source: CommandSource): source.reply(tr('status.info', client.is_online(), client.get_ping_text())) +def query_online(source: CommandSource): + if config.client_to_query_online is None: + source.reply('client_to_query_online unset') + return + + if client is not None: + client.query_online(config.client_to_query_online, source.player) + else: + source.reply(tr('status.not_init')) + + @new_thread('ChatBridge-restart') def restart_client(source: CommandSource): with cb_lock: @@ -58,7 +69,7 @@ def send_chat(message: str, *, author: str = ''): if not client.is_running(): client.start() if client.is_online(): - client.send_chat(message, author) + client.broadcast_chat(message, author) def on_load(server: PluginServerInterface, old_module): @@ -81,6 +92,12 @@ def on_load(server: PluginServerInterface, old_module): except: server.logger.exception('Failed to read the config file! ChatBridge might not work properly') server.logger.error('Fix the configure file and then reload the plugin') + config.enable = False + + if not config.enable: + server.logger.info('ChatBridge is disabled') + return + client = ChatBridgeMCDRClient(config, server) if config.debug: client.logger.set_debug_all(True) @@ -92,6 +109,7 @@ def on_load(server: PluginServerInterface, old_module): then(Literal('status').runs(display_status)). then(Literal('restart').runs(restart_client)) ) + server.register_command(Literal('!!online').runs(query_online)) @new_thread('ChatBridge-start') def start(): diff --git a/chatbridge/impl/online/entry.py b/chatbridge/impl/online/entry.py index 2b3fe7d..b8e9790 100644 --- a/chatbridge/impl/online/entry.py +++ b/chatbridge/impl/online/entry.py @@ -3,6 +3,8 @@ """ import collections import functools +import re +import sys import traceback from concurrent.futures.thread import ThreadPoolExecutor from threading import Lock @@ -62,11 +64,10 @@ def handle_minecraft(updater: Callable[[str, Collection[str]], Any], server: Rco @staticmethod def handle_bungee(updater: Callable[[str, Collection[str]], Any], respond: str): for line in respond.splitlines(): - if not line.startswith('Total players online:'): - server_name = line.split('] (', 1)[0][1:] - player_list = set(line.split('): ')[-1].split(', ')) - if '' in player_list: - player_list.remove('') + matched = re.fullmatch(r'\[([^]]+)] \(\d+\): (.*)', line) + if matched: + server_name = matched.group(1) + player_list = set(filter(None, matched.group(2).split(', '))) updater(server_name, player_list) @staticmethod @@ -98,7 +99,7 @@ def updater(name: str, players: Collection[str]): for server in config.server_list: pool.submit(self.query_server, server, 'list', lambda data, svr=server: self.handle_minecraft(updater, svr, data)) for server in config.bungeecord_list: - pool.submit(self.query_server, server, 'glist', lambda data: self.handle_bungee(updater, data)) + pool.submit(self.query_server, server, 'glist all', lambda data: self.handle_bungee(updater, data)) counter_sorted = sorted([(key, value) for key, value in counter.items()], key=functools.cmp_to_key(self.server_comparator)) player_set_all = set() @@ -115,6 +116,9 @@ def console_input_loop(): while True: try: text = input() + if len(text) == 0: + continue + if text in ['!!online', 'online']: print('\n'.join(chatClient.query())) elif text == 'stop': @@ -135,7 +139,11 @@ def main(): config = utils.load_config(ClientConfigFile, OnlineConfig) chatClient = OnlineChatClient.create(config) utils.start_guardian(chatClient) - console_input_loop() + if sys.stdin.isatty(): + console_input_loop() + else: + utils.wait_until_terminate() + chatClient.stop() if __name__ == '__main__': diff --git a/chatbridge/impl/satori/__init__.py b/chatbridge/impl/satori/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatbridge/impl/satori/config.py b/chatbridge/impl/satori/config.py new file mode 100644 index 0000000..bd54117 --- /dev/null +++ b/chatbridge/impl/satori/config.py @@ -0,0 +1,12 @@ +from chatbridge.core.config import ClientConfig + + +class SatoriConfig(ClientConfig): + ws_address: str = '127.0.0.1' + ws_port: int = 6700 + ws_path: str = '' + satori_token: str = '' + react_channel_id: int = 12345 + chatbridge_message_prefix: str = '!!qq' + client_to_query_stats: str = 'MyClient1' + client_to_query_online: str = 'MyClient2' diff --git a/chatbridge/impl/satori/entry.py b/chatbridge/impl/satori/entry.py new file mode 100644 index 0000000..6b5695f --- /dev/null +++ b/chatbridge/impl/satori/entry.py @@ -0,0 +1,231 @@ +import asyncio +import queue +from typing import Optional, List + +from satori import WebsocketsInfo, Event, EventType +from satori.client import App, Account, ApiInfo + +from chatbridge.common.logger import ChatBridgeLogger +from chatbridge.core.client import ChatBridgeClient +from chatbridge.core.network.protocol import ChatPayload, CommandPayload, CustomPayload +from chatbridge.impl import utils +from chatbridge.impl.cqhttp.copywritings import CQHelpMessage, StatsHelpMessage +from chatbridge.impl.satori.config import SatoriConfig +from chatbridge.impl.tis.protocol import StatsQueryResult, OnlineQueryResult + +ConfigFile = 'ChatBridge_satori.json' + +config: SatoriConfig +cb_client: Optional['SatoriChatBridgeClient'] = None +satori_client: Optional['SatoriClient'] = None + + +class SatoriClient: + def __init__(self): + self.app = App(WebsocketsInfo( + host=config.ws_address, + port=config.ws_port, + path=config.ws_path, + token=config.satori_token, + )) + self.logger = ChatBridgeLogger('Satori', file_handler=cb_client.logger.file_handler) + self.register_satori_hooks() + + self.__message_queue: queue.Queue[Optional[str]] = queue.Queue() + self.__loop: Optional[asyncio.AbstractEventLoop] = None + + def register_satori_hooks(self): + self.logger.info('Registering satori hooks') + + @self.app.register_on(EventType.MESSAGE_CREATED) + async def listen(account: Account, event: Event): + self.logger.debug('Satori MESSAGE_CREATED account={} event={}'.format(account, event)) + if event.channel is None or event.message is None or event.user is None or event.message is None: + return + if event.channel.id != str(config.react_channel_id): + return + self.logger.info('Satori chat message event: {}'.format(event)) + + msg_str = event.message.content + msg_comp = event.message.message + args = msg_str.split(' ') + + async def send_text(s: str): + await account.send_message(event.channel.id, s) + + if len(args) == 1 and args[0] == '!!help': + self.logger.info('!!help command triggered') + await send_text(CQHelpMessage) + + if len(args) == 1 and args[0] == '!!ping': + self.logger.info('!!ping command triggered') + await send_text('pong!!') + + if len(args) >= 2 and args[0] == '!!mc': + self.logger.info('!!mc command triggered') + sender = event.user.nick or event.user.name or event.member.nick or event.member.name + assert sender is not None + + from satori import Text + new_elements: List[str] = [] + for el in msg_comp: + if isinstance(el, Text): + new_elements.append(el.text) + else: + new_elements.append(f'<{el.tag}>') + processed_msg = ''.join(new_elements) + if processed_msg.startswith('!!mc '): + processed_msg = processed_msg.split(' ', 1)[-1] + cb_client.broadcast_chat(processed_msg, sender) + + if len(args) == 1 and args[0] == '!!online': + self.logger.info('!!online command triggered') + if not config.client_to_query_online: + self.logger.info('!!online command is not enabled') + await send_text('!!online 指令未启用') + return + + if cb_client.is_online(): + command = args[0] + client = config.client_to_query_online + self.logger.info('Sending command "{}" to client {}'.format(command, client)) + cb_client.send_command(client, command) + else: + await send_text('ChatBridge 客户端离线') + + if len(args) >= 1 and args[0] == '!!stats': + self.logger.info('!!stats command triggered') + if not config.client_to_query_stats: + self.logger.info('!!stats command is not enabled') + await send_text('!!stats 指令未启用') + return + + command = '!!stats rank ' + ' '.join(args[1:]) + if len(args) == 0 or len(args) - int(command.find('-bot') != -1) != 3: + await send_text(StatsHelpMessage) + return + if cb_client.is_online: + client = config.client_to_query_stats + self.logger.info('Sending command "{}" to client {}'.format(command, client)) + cb_client.send_command(client, command) + else: + await send_text('ChatBridge 客户端离线') + + async def __send_text_one(self, text: str): + account = Account('', 'chatbridge', ApiInfo( + host=config.ws_address, + port=config.ws_port, + path=config.ws_path, + token=config.satori_token, + )) + # https://satori.js.org/zh-CN/protocol/message.html + text = text.replace('&', '&') + text = text.replace('"', '"') + text = text.replace('<', '<') + text = text.replace('>', '>') + await account.send_message(str(config.react_channel_id), text) + + async def __send_text_long(self, text: str): + msg = '' + length = 0 + lines = text.rstrip().splitlines(keepends=True) + for i in range(len(lines)): + msg += lines[i] + length += len(lines[i]) + if i == len(lines) - 1 or length + len(lines[i + 1]) > 500: + await self.__send_text_one(msg) + msg = '' + length = 0 + + async def __messanger_loop(self): + while True: + try: + msg = self.__message_queue.get(block=False) + except queue.Empty: + await asyncio.sleep(0.05) + continue + if msg is None: + break + try: + await self.__send_text_long(msg) + except Exception: + self.logger.exception('messanger loop error') + + async def main(self): + self.__loop = asyncio.get_event_loop() + _ = asyncio.create_task(self.__messanger_loop()) + await self.app.run_async(stop_signal=[]) + + def submit_text(self, s: str): + self.__message_queue.put(s) + + def shutdown(self): + self.__message_queue.put(None) + + +class SatoriChatBridgeClient(ChatBridgeClient): + def on_chat(self, sender: str, payload: ChatPayload): + if satori_client is None: + return + + parts = payload.message.split(' ', 1) + if len(parts) != 2 or (len(config.chatbridge_message_prefix) > 0 and parts[0] != config.chatbridge_message_prefix): + return + + payload.message = parts[1] + msg_to_send = '[{}] {}'.format(sender, payload.formatted_str()) + self.logger.info('Triggered command, sending message {!r} to satori'.format(msg_to_send)) + satori_client.submit_text(msg_to_send) + + def on_command(self, sender: str, payload: CommandPayload): + if satori_client is None: + return + if not payload.responded: + return + if payload.command.startswith('!!stats '): + result = StatsQueryResult.deserialize(payload.result) + if result.success: + messages = ['====== {} ======'.format(result.stats_name)] + messages.extend(result.data) + messages.append('总数:{}'.format(result.total)) + satori_client.submit_text('\n'.join(messages)) + elif result.error_code == 1: + satori_client.submit_text('统计信息未找到') + elif result.error_code == 2: + satori_client.submit_text('StatsHelper 插件未加载') + elif payload.command == '!!online': + result = OnlineQueryResult.deserialize(payload.result) + satori_client.submit_text('====== 玩家列表 ======\n{}'.format('\n'.join(result.data))) + + def on_custom(self, sender: str, payload: CustomPayload): + if satori_client is None: + return + if payload.data.get('cqhttp_client.action') == 'send_text': + text = payload.data.get('text') + self.logger.info('Triggered custom text, sending message {} to satori'.format(text)) + satori_client.submit_text(text) + + +def main(): + global config, cb_client, satori_client + config = utils.load_config(ConfigFile, SatoriConfig) + + cb_client = SatoriChatBridgeClient.create(config) + satori_client = SatoriClient() + + def exit_callback(): + satori_client.shutdown() + cb_client.stop() + + utils.start_guardian(cb_client) + utils.register_exit_on_termination(exit_callback) + + print('Starting Satori Bot') + # cannot use asyncio.run() to create a new one + # or some "got Future attached to a different loop" error will raise + asyncio.get_event_loop().run_until_complete(satori_client.main()) + print('Bye~') + + +if __name__ == '__main__': + main() diff --git a/chatbridge/impl/satori/requirements.txt b/chatbridge/impl/satori/requirements.txt new file mode 100644 index 0000000..bf6bd8e --- /dev/null +++ b/chatbridge/impl/satori/requirements.txt @@ -0,0 +1 @@ +satori-python==0.11.5 diff --git a/chatbridge/impl/utils.py b/chatbridge/impl/utils.py index a77c58c..94fd172 100644 --- a/chatbridge/impl/utils.py +++ b/chatbridge/impl/utils.py @@ -1,8 +1,11 @@ import json import os +import queue +import signal +import sys import time from threading import Thread -from typing import Type, TypeVar, Callable +from typing import Type, TypeVar, Callable, Optional, Any from chatbridge.core.client import ChatBridgeClient from chatbridge.core.config import BasicConfig @@ -20,7 +23,7 @@ def load_config(config_path: str, config_class: Type[T]) -> T: raise FileNotFoundError(config_path) else: with open(config_path, encoding='utf8') as file: - config.update_from(json.load(file)) + vars(config).update(vars(config_class.deserialize(json.load(file)))) with open(config_path, 'w', encoding='utf8') as file: json.dump(config.serialize(), file, ensure_ascii=False, indent=4) return config @@ -40,3 +43,22 @@ def loop(): thread = Thread(name='ChatBridge Guardian', target=loop, daemon=True) thread.start() return thread + + +def wait_until_terminate(): + q = queue.Queue() + signal.signal(signal.SIGINT, lambda s, _: q.put(s)) + signal.signal(signal.SIGTERM, lambda s, _: q.put(s)) + sig = q.get() + print('Interrupted with {} ({})'.format(signal.Signals(sig).name, sig)) + + +def register_exit_on_termination(exit_callback: Optional[Callable[[], Any]] = None): + def callback(sig, _): + print('Interrupted with {} ({}), exiting'.format(signal.Signals(sig).name, sig)) + if exit_callback: + exit_callback() + sys.exit(0) + + signal.signal(signal.SIGINT, callback) + signal.signal(signal.SIGTERM, callback) diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..a1c0f46 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,21 @@ +ARG PYTHON_VERSION=3.9 + +FROM python:${PYTHON_VERSION} as builder + +RUN pip3 install mcdreforged + +COPY chatbridge /build/chatbridge +COPY lang /build/lang +COPY __main__.py LICENSE mcdreforged.plugin.json requirements.txt /build/ +RUN cd /build \ + && mcdreforged pack \ + && find . -name "requirements.txt" -exec cat '{}' \; > requirements.all.txt + +FROM python:${PYTHON_VERSION}-slim + +COPY --from=builder /build/requirements.all.txt /app/ +RUN pip3 install -r /app/requirements.all.txt && pip3 cache purge + +COPY --from=builder /build/ChatBridge.pyz /app/ +WORKDIR /app +ENTRYPOINT ["python3", "ChatBridge.pyz"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..c1cccf5 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,29 @@ +version: '3' +services: + server: + container_name: chatbridge_server + restart: unless-stopped + image: fallenbreath/chatbridge:latest + command: server + stdin_open: true + tty: true + volumes: + - ./ChatBridge_server.json:/app/ChatBridge_server.json + ports: + - '30001:30001' + + khl_bot: + container_name: chatbridge_khl_bot + restart: unless-stopped + image: fallenbreath/chatbridge:latest + command: kaiheila_bot + volumes: + - ./ChatBridge_kaiheila.json:/app/ChatBridge_kaiheila.json + + satori_bot: + container_name: chatbridge_satori_bot + restart: unless-stopped + image: fallenbreath/chatbridge:latest + command: satori_bot + volumes: + - ./ChatBridge_satori.json:/app/ChatBridge_satori.json diff --git a/mcdreforged.plugin.json b/mcdreforged.plugin.json index c80d15d..89f4cf1 100644 --- a/mcdreforged.plugin.json +++ b/mcdreforged.plugin.json @@ -1,6 +1,6 @@ { "id": "chatbridge", - "version": "2.2.0", + "version": "2.6.3", "name": "ChatBridge v2 for MCDR", "description": { "en_us": "Broadcast chats between Minecraft servers and more", diff --git a/topomap.png b/topomap.png deleted file mode 100644 index 95f85c4..0000000 Binary files a/topomap.png and /dev/null differ