diff --git a/nemubot/message/printer/IRCLib.py b/nemubot/message/printer/IRCLib.py deleted file mode 100644 index abd1f2f..0000000 --- a/nemubot/message/printer/IRCLib.py +++ /dev/null @@ -1,67 +0,0 @@ -# Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2026 Mercier Pierre-Olivier -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -from nemubot.message.visitor import AbstractVisitor - - -class IRCLib(AbstractVisitor): - - """Visitor that sends bot responses via an irc.client.ServerConnection. - - Unlike the socket-based IRC printer (which builds a raw PRIVMSG string), - this calls connection.privmsg() directly so the library handles encoding, - line-length capping, and any internal locking. - """ - - def __init__(self, connection): - self._conn = connection - - def _send(self, target, text): - try: - self._conn.privmsg(target, text) - except Exception: - pass # drop silently during reconnection - - # Visitor methods - - def visit_Text(self, msg): - if isinstance(msg.message, str): - for target in msg.to: - self._send(target, msg.message) - else: - msg.message.accept(self) - - def visit_DirectAsk(self, msg): - text = msg.message if isinstance(msg.message, str) else str(msg.message) - # Mirrors socket.py logic: - # rooms that are NOT the designated nick get a "nick: " prefix - others = [to for to in msg.to if to != msg.designated] - if len(others) == 0 or len(others) != len(msg.to): - for target in msg.to: - self._send(target, text) - if others: - for target in others: - self._send(target, "%s: %s" % (msg.designated, text)) - - def visit_Command(self, msg): - parts = ["!" + msg.cmd] + list(msg.args) - for target in msg.to: - self._send(target, " ".join(parts)) - - def visit_OwnerCommand(self, msg): - parts = ["`" + msg.cmd] + list(msg.args) - for target in msg.to: - self._send(target, " ".join(parts)) diff --git a/nemubot/message/printer/Matrix.py b/nemubot/message/printer/Matrix.py deleted file mode 100644 index ad1b99e..0000000 --- a/nemubot/message/printer/Matrix.py +++ /dev/null @@ -1,69 +0,0 @@ -# Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2026 Mercier Pierre-Olivier -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -from nemubot.message.visitor import AbstractVisitor - - -class Matrix(AbstractVisitor): - - """Visitor that sends bot responses as Matrix room messages. - - Instead of accumulating text like the IRC printer does, each visit_* - method calls send_func(room_id, text) directly for every destination room. - """ - - def __init__(self, send_func): - """ - Argument: - send_func -- callable(room_id: str, text: str) that sends a plain-text - message to the given Matrix room - """ - self._send = send_func - - def visit_Text(self, msg): - if isinstance(msg.message, str): - for room in msg.to: - self._send(room, msg.message) - else: - # Nested message object — let it visit itself - msg.message.accept(self) - - def visit_DirectAsk(self, msg): - text = msg.message if isinstance(msg.message, str) else str(msg.message) - # Rooms that are NOT the designated nick → prefix with "nick: " - others = [to for to in msg.to if to != msg.designated] - if len(others) == 0 or len(others) != len(msg.to): - # At least one room IS the designated target → send plain - for room in msg.to: - self._send(room, text) - if len(others): - # Other rooms → prefix with nick - for room in others: - self._send(room, "%s: %s" % (msg.designated, text)) - - def visit_Command(self, msg): - parts = ["!" + msg.cmd] - if msg.args: - parts.extend(msg.args) - for room in msg.to: - self._send(room, " ".join(parts)) - - def visit_OwnerCommand(self, msg): - parts = ["`" + msg.cmd] - if msg.args: - parts.extend(msg.args) - for room in msg.to: - self._send(room, " ".join(parts)) diff --git a/nemubot/server/IRCLib.py b/nemubot/server/IRCLib.py deleted file mode 100644 index 3051028..0000000 --- a/nemubot/server/IRCLib.py +++ /dev/null @@ -1,375 +0,0 @@ -# Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2026 Mercier Pierre-Olivier -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -from datetime import datetime -import shlex -import threading - -import irc.bot -import irc.client - -import nemubot.message as message -from nemubot.server.threaded import ThreadedServer - - -class _IRCBotAdapter(irc.bot.SingleServerIRCBot): - - """Internal adapter that bridges the irc library event model to nemubot. - - Subclasses SingleServerIRCBot to get automatic reconnection, PING/PONG, - and nick-collision handling for free. - """ - - def __init__(self, server_name, push_fn, channels, on_connect_cmds, - nick, server_list, owner=None, realname="Nemubot", - encoding="utf-8", **connect_params): - super().__init__(server_list, nick, realname, **connect_params) - self._nemubot_name = server_name - self._push = push_fn - self._channels_to_join = channels - self._on_connect_cmds = on_connect_cmds or [] - self.owner = owner - self.encoding = encoding - self._stop_event = threading.Event() - - - # Event loop control - - def start(self): - """Run the reactor loop until stop() is called.""" - self._connect() - while not self._stop_event.is_set(): - self.reactor.process_once(timeout=0.2) - - def stop(self): - """Signal the loop to exit and disconnect cleanly.""" - self._stop_event.set() - try: - self.connection.disconnect("Goodbye") - except Exception: - pass - - def on_disconnect(self, connection, event): - """Reconnect automatically unless we are shutting down.""" - if not self._stop_event.is_set(): - super().on_disconnect(connection, event) - - - # Connection lifecycle - - def on_welcome(self, connection, event): - """001 — run on_connect commands then join channels.""" - for cmd in self._on_connect_cmds: - if callable(cmd): - for c in (cmd() or []): - connection.send_raw(c) - else: - connection.send_raw(cmd) - - for ch in self._channels_to_join: - if isinstance(ch, tuple): - connection.join(ch[0], ch[1] if len(ch) > 1 else "") - elif hasattr(ch, 'name'): - connection.join(ch.name, getattr(ch, 'password', "") or "") - else: - connection.join(str(ch)) - - def on_invite(self, connection, event): - """Auto-join on INVITE.""" - if event.arguments: - connection.join(event.arguments[0]) - - - # CTCP - - def on_ctcp(self, connection, event): - """Handle CTCP requests (irc library >= 19 dispatches all to on_ctcp).""" - nick = irc.client.NickMask(event.source).nick - ctcp_type = event.arguments[0].upper() if event.arguments else "" - ctcp_arg = event.arguments[1] if len(event.arguments) > 1 else "" - self._reply_ctcp(connection, nick, ctcp_type, ctcp_arg) - - # Fallbacks for older irc library versions that dispatch per-type - def on_ctcpversion(self, connection, event): - import nemubot - nick = irc.client.NickMask(event.source).nick - connection.ctcp_reply(nick, "VERSION nemubot v%s" % nemubot.__version__) - - def on_ctcpping(self, connection, event): - nick = irc.client.NickMask(event.source).nick - arg = event.arguments[0] if event.arguments else "" - connection.ctcp_reply(nick, "PING %s" % arg) - - def _reply_ctcp(self, connection, nick, ctcp_type, ctcp_arg): - import nemubot - responses = { - "ACTION": None, # handled as on_action - "CLIENTINFO": "CLIENTINFO ACTION CLIENTINFO FINGER PING SOURCE TIME USERINFO VERSION", - "FINGER": "FINGER nemubot v%s" % nemubot.__version__, - "PING": "PING %s" % ctcp_arg, - "SOURCE": "SOURCE https://github.com/nemunaire/nemubot", - "TIME": "TIME %s" % datetime.now(), - "USERINFO": "USERINFO Nemubot", - "VERSION": "VERSION nemubot v%s" % nemubot.__version__, - } - if ctcp_type in responses and responses[ctcp_type] is not None: - connection.ctcp_reply(nick, responses[ctcp_type]) - - - # Incoming messages - - def _decode(self, text): - if isinstance(text, bytes): - try: - return text.decode("utf-8") - except UnicodeDecodeError: - return text.decode(self.encoding, "replace") - return text - - def _make_message(self, connection, source, target, text): - """Convert raw IRC event data into a nemubot bot message.""" - nick = irc.client.NickMask(source).nick - text = self._decode(text) - bot_nick = connection.get_nickname() - is_channel = irc.client.is_channel(target) - to = [target] if is_channel else [nick] - to_response = [target] if is_channel else [nick] - - common = dict( - server=self._nemubot_name, - to=to, - to_response=to_response, - frm=nick, - frm_owner=(nick == self.owner), - ) - - # "botname: text" or "botname, text" - if (text.startswith(bot_nick + ":") or - text.startswith(bot_nick + ",")): - inner = text[len(bot_nick) + 1:].strip() - return message.DirectAsk(designated=bot_nick, message=inner, - **common) - - # "!command [args]" - if len(text) > 1 and text[0] == '!': - inner = text[1:].strip() - try: - args = shlex.split(inner) - except ValueError: - args = inner.split() - if args: - # Extract @key=value named arguments (same logic as IRC.py) - kwargs = {} - while len(args) > 1: - arg = args[1] - if len(arg) > 2 and arg[0:2] == '\\@': - args[1] = arg[1:] - elif len(arg) > 1 and arg[0] == '@': - arsp = arg[1:].split("=", 1) - kwargs[arsp[0]] = arsp[1] if len(arsp) == 2 else None - args.pop(1) - continue - break - return message.Command(cmd=args[0], args=args[1:], - kwargs=kwargs, **common) - - return message.Text(message=text, **common) - - def on_pubmsg(self, connection, event): - msg = self._make_message( - connection, event.source, event.target, - event.arguments[0] if event.arguments else "", - ) - if msg: - self._push(msg) - - def on_privmsg(self, connection, event): - nick = irc.client.NickMask(event.source).nick - msg = self._make_message( - connection, event.source, nick, - event.arguments[0] if event.arguments else "", - ) - if msg: - self._push(msg) - - def on_action(self, connection, event): - """CTCP ACTION (/me) — delivered as a plain Text message.""" - nick = irc.client.NickMask(event.source).nick - text = "/me %s" % (event.arguments[0] if event.arguments else "") - is_channel = irc.client.is_channel(event.target) - to = [event.target] if is_channel else [nick] - self._push(message.Text( - message=text, - server=self._nemubot_name, - to=to, to_response=to, - frm=nick, frm_owner=(nick == self.owner), - )) - - -class IRCLib(ThreadedServer): - - """IRC server using the irc Python library (jaraco). - - Compared to the hand-rolled IRC.py implementation, this gets: - - Automatic exponential-backoff reconnection - - PING/PONG handled transparently - - Nick-collision suffix logic built-in - """ - - def __init__(self, host="localhost", port=6667, nick="nemubot", - username=None, password=None, realname="Nemubot", - encoding="utf-8", owner=None, channels=None, - on_connect=None, ssl=False, **kwargs): - """Prepare a connection to an IRC server. - - Keyword arguments: - host -- IRC server hostname - port -- IRC server port (default 6667) - nick -- bot's nickname - username -- username for USER command (defaults to nick) - password -- server password (sent as PASS) - realname -- bot's real name - encoding -- fallback encoding for non-UTF-8 servers - owner -- nick of the bot's owner (sets frm_owner on messages) - channels -- list of channel names / (name, key) tuples to join - on_connect -- list of raw IRC commands (or a callable returning one) - to send after receiving 001 - ssl -- wrap the connection in TLS - """ - name = (username or nick) + "@" + host + ":" + str(port) - super().__init__(name=name) - - self._host = host - self._port = int(port) - self._nick = nick - self._username = username or nick - self._password = password - self._realname = realname - self._encoding = encoding - self.owner = owner - self._channels = channels or [] - self._on_connect_cmds = on_connect - self._ssl = ssl - - self._bot = None - self._thread = None - - - # ThreadedServer hooks - - def _start(self): - server_list = [irc.bot.ServerSpec(self._host, self._port, - self._password)] - - connect_params = {"username": self._username} - - if self._ssl: - import ssl as ssl_mod - import irc.connection - ctx = ssl_mod.create_default_context() - host = self._host # capture for closure - connect_params["connect_factory"] = irc.connection.Factory( - wrapper=lambda sock: ctx.wrap_socket(sock, - server_hostname=host) - ) - - self._bot = _IRCBotAdapter( - server_name=self.name, - push_fn=self._push_message, - channels=self._channels, - on_connect_cmds=self._on_connect_cmds, - nick=self._nick, - server_list=server_list, - owner=self.owner, - realname=self._realname, - encoding=self._encoding, - **connect_params, - ) - self._thread = threading.Thread( - target=self._bot.start, - daemon=True, - name="nemubot.IRC/" + self.name, - ) - self._thread.start() - - def _stop(self): - if self._bot: - self._bot.stop() - if self._thread: - self._thread.join(timeout=5) - - - # Outgoing messages - - def send_response(self, response): - if response is None: - return - if isinstance(response, list): - for r in response: - self.send_response(r) - return - if not self._bot: - return - - from nemubot.message.printer.IRCLib import IRCLib as IRCLibPrinter - printer = IRCLibPrinter(self._bot.connection) - response.accept(printer) - - - # subparse: re-parse a plain string in the context of an existing message - # (used by alias, rnd, grep, cat, smmry, sms modules) - - def subparse(self, orig, cnt): - bot_nick = (self._bot.connection.get_nickname() - if self._bot else self._nick) - common = dict( - server=self.name, - to=orig.to, - to_response=orig.to_response, - frm=orig.frm, - frm_owner=orig.frm_owner, - date=orig.date, - ) - text = cnt - - if (text.startswith(bot_nick + ":") or - text.startswith(bot_nick + ",")): - inner = text[len(bot_nick) + 1:].strip() - return message.DirectAsk(designated=bot_nick, message=inner, - **common) - - if len(text) > 1 and text[0] == '!': - inner = text[1:].strip() - try: - args = shlex.split(inner) - except ValueError: - args = inner.split() - if args: - kwargs = {} - while len(args) > 1: - arg = args[1] - if len(arg) > 2 and arg[0:2] == '\\@': - args[1] = arg[1:] - elif len(arg) > 1 and arg[0] == '@': - arsp = arg[1:].split("=", 1) - kwargs[arsp[0]] = arsp[1] if len(arsp) == 2 else None - args.pop(1) - continue - break - return message.Command(cmd=args[0], args=args[1:], - kwargs=kwargs, **common) - - return message.Text(message=text, **common) diff --git a/nemubot/server/Matrix.py b/nemubot/server/Matrix.py deleted file mode 100644 index ed4b746..0000000 --- a/nemubot/server/Matrix.py +++ /dev/null @@ -1,200 +0,0 @@ -# Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2026 Mercier Pierre-Olivier -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -import asyncio -import shlex -import threading - -import nemubot.message as message -from nemubot.server.threaded import ThreadedServer - - -class Matrix(ThreadedServer): - - """Matrix server implementation using matrix-nio's AsyncClient. - - Runs an asyncio event loop in a daemon thread. Incoming room messages are - converted to nemubot bot messages and pushed through the pipe; outgoing - responses are sent via the async client from the same event loop. - """ - - def __init__(self, homeserver, user_id, password=None, access_token=None, - owner=None, nick=None, channels=None, **kwargs): - """Prepare a connection to a Matrix homeserver. - - Keyword arguments: - homeserver -- base URL of the homeserver, e.g. "https://matrix.org" - user_id -- full MXID (@user:server) or bare localpart - password -- login password (required if no access_token) - access_token -- pre-obtained access token (alternative to password) - owner -- MXID of the bot owner (marks frm_owner on messages) - nick -- display name / prefix for DirectAsk detection - channels -- list of room IDs / aliases to join on connect - """ - - # Ensure fully-qualified MXID - if not user_id.startswith("@"): - host = homeserver.split("//")[-1].rstrip("/") - user_id = "@%s:%s" % (user_id, host) - - super().__init__(name=user_id) - - self.homeserver = homeserver - self.user_id = user_id - self.password = password - self.access_token = access_token - self.owner = owner - self.nick = nick or user_id - - self._initial_rooms = channels or [] - self._client = None - self._loop = None - self._thread = None - - - # Open/close - - def _start(self): - self._thread = threading.Thread( - target=self._run_loop, - daemon=True, - name="nemubot.Matrix/" + self._name, - ) - self._thread.start() - - def _stop(self): - if self._client and self._loop and not self._loop.is_closed(): - try: - asyncio.run_coroutine_threadsafe( - self._client.close(), self._loop - ).result(timeout=5) - except Exception: - self._logger.exception("Error while closing Matrix client") - if self._thread: - self._thread.join(timeout=5) - - - # Asyncio thread - - def _run_loop(self): - self._loop = asyncio.new_event_loop() - asyncio.set_event_loop(self._loop) - try: - self._loop.run_until_complete(self._async_main()) - except Exception: - self._logger.exception("Unhandled exception in Matrix event loop") - finally: - self._loop.close() - - async def _async_main(self): - from nio import AsyncClient, LoginError, RoomMessageText - - self._client = AsyncClient(self.homeserver, self.user_id) - - if self.access_token: - self._client.access_token = self.access_token - self._logger.info("Using provided access token for %s", self.user_id) - elif self.password: - resp = await self._client.login(self.password) - if isinstance(resp, LoginError): - self._logger.error("Matrix login failed: %s", resp.message) - return - self._logger.info("Logged in to Matrix as %s", self.user_id) - else: - self._logger.error("Need either password or access_token to connect") - return - - self._client.add_event_callback(self._on_room_message, RoomMessageText) - - for room in self._initial_rooms: - await self._client.join(room) - self._logger.info("Joined room %s", room) - - await self._client.sync_forever(timeout=30000, full_state=True) - - - # Incoming messages - - async def _on_room_message(self, room, event): - """Callback invoked by matrix-nio for each m.room.message event.""" - - if event.sender == self.user_id: - return # ignore own messages - - text = event.body - room_id = room.room_id - frm = event.sender - - common_args = { - "server": self.name, - "to": [room_id], - "to_response": [room_id], - "frm": frm, - "frm_owner": frm == self.owner, - } - - if len(text) > 1 and text[0] == '!': - text = text[1:].strip() - try: - args = shlex.split(text) - except ValueError: - args = text.split(' ') - msg = message.Command(cmd=args[0], args=args[1:], **common_args) - - elif (text.lower().startswith(self.nick.lower() + ":") - or text.lower().startswith(self.nick.lower() + ",")): - text = text[len(self.nick) + 1:].strip() - msg = message.DirectAsk(designated=self.nick, message=text, - **common_args) - - else: - msg = message.Text(message=text, **common_args) - - self._push_message(msg) - - - # Outgoing messages - - def send_response(self, response): - if response is None: - return - if isinstance(response, list): - for r in response: - self.send_response(r) - return - - from nemubot.message.printer.Matrix import Matrix as MatrixPrinter - printer = MatrixPrinter(self._send_text) - response.accept(printer) - - def _send_text(self, room_id, text): - """Thread-safe: schedule a Matrix room_send on the asyncio loop.""" - if not self._client or not self._loop or self._loop.is_closed(): - self._logger.warning("Cannot send: Matrix client not ready") - return - future = asyncio.run_coroutine_threadsafe( - self._client.room_send( - room_id=room_id, - message_type="m.room.message", - content={"msgtype": "m.text", "body": text}, - ignore_unverified_devices=True, - ), - self._loop, - ) - future.add_done_callback( - lambda f: self._logger.warning("Matrix send error: %s", f.exception()) - if not f.cancelled() and f.exception() else None - ) diff --git a/nemubot/server/__init__.py b/nemubot/server/__init__.py index db9ad87..a39c491 100644 --- a/nemubot/server/__init__.py +++ b/nemubot/server/__init__.py @@ -24,13 +24,13 @@ def factory(uri, ssl=False, **init_args): if o.scheme == "irc" or o.scheme == "ircs": # https://www.w3.org/Addressing/draft-mirashi-url-irc-01.txt # https://www-archive.mozilla.org/projects/rt-messaging/chatzilla/irc-urls.html - args = dict(init_args) + args = init_args if o.scheme == "ircs": ssl = True if o.hostname is not None: args["host"] = o.hostname if o.port is not None: args["port"] = o.port if o.username is not None: args["username"] = o.username - if o.password is not None: args["password"] = unquote(o.password) + if o.password is not None: args["password"] = o.password modifiers = o.path.split(",") target = unquote(modifiers.pop(0)[1:]) @@ -41,58 +41,36 @@ def factory(uri, ssl=False, **init_args): if "msg" in params: if "on_connect" not in args: args["on_connect"] = [] - args["on_connect"].append("PRIVMSG %s :%s" % (target, params["msg"][0])) + args["on_connect"].append("PRIVMSG %s :%s" % (target, params["msg"])) if "key" in params: if "channels" not in args: args["channels"] = [] - args["channels"].append((target, params["key"][0])) + args["channels"].append((target, params["key"])) if "pass" in params: - args["password"] = params["pass"][0] + args["password"] = params["pass"] if "charset" in params: - args["encoding"] = params["charset"][0] + args["encoding"] = params["charset"] + # if "channels" not in args and "isnick" not in modifiers: - args["channels"] = [target] + args["channels"] = [ target ] - args["ssl"] = ssl - - from nemubot.server.IRCLib import IRCLib as IRCServer + from nemubot.server.IRC import IRC as IRCServer srv = IRCServer(**args) - elif o.scheme == "matrix": - # matrix://localpart:password@homeserver.tld/!room:homeserver.tld - # matrix://localpart:password@homeserver.tld/%23alias:homeserver.tld - # Use matrixs:// for https (default) vs http - args = dict(init_args) + if ssl: + try: + from ssl import create_default_context + context = create_default_context() + except ImportError: + # Python 3.3 compat + from ssl import SSLContext, PROTOCOL_TLSv1 + context = SSLContext(PROTOCOL_TLSv1) + from ssl import wrap_socket + srv._fd = context.wrap_socket(srv._fd, server_hostname=o.hostname) - homeserver = "https://" + o.hostname - if o.port is not None: - homeserver += ":%d" % o.port - args["homeserver"] = homeserver - - if o.username is not None: - args["user_id"] = o.username - if o.password is not None: - args["password"] = unquote(o.password) - - # Parse rooms from path (comma-separated, URL-encoded) - if o.path and o.path != "/": - rooms = [unquote(r) for r in o.path.lstrip("/").split(",") if r] - if rooms: - args.setdefault("channels", []).extend(rooms) - - params = parse_qs(o.query) - if "token" in params: - args["access_token"] = params["token"][0] - if "nick" in params: - args["nick"] = params["nick"][0] - if "owner" in params: - args["owner"] = params["owner"][0] - - from nemubot.server.Matrix import Matrix as MatrixServer - srv = MatrixServer(**args) return srv diff --git a/nemubot/server/threaded.py b/nemubot/server/threaded.py deleted file mode 100644 index eb1ae19..0000000 --- a/nemubot/server/threaded.py +++ /dev/null @@ -1,132 +0,0 @@ -# Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2026 Mercier Pierre-Olivier -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -import logging -import os -import queue - -from nemubot.bot import sync_act - - -class ThreadedServer: - - """A server backed by a library running in its own thread. - - Uses an os.pipe() as a fake file descriptor to integrate with the bot's - select.poll() main loop without requiring direct socket access. - - When the library thread has a message ready, it calls _push_message(), - which writes a wakeup byte to the pipe's write end. The bot's poll loop - sees the read end become readable, calls async_read(), which drains the - message queue and yields already-parsed bot-level messages. - - This abstraction lets any IM library (IRC via python-irc, Matrix via - matrix-nio, …) plug into nemubot without touching bot.py. - """ - - def __init__(self, name): - self._name = name - self._logger = logging.getLogger("nemubot.server." + name) - self._queue = queue.Queue() - self._pipe_r, self._pipe_w = os.pipe() - - - @property - def name(self): - return self._name - - def fileno(self): - return self._pipe_r - - - # Open/close - - def connect(self): - """Start the library and register the pipe read-end with the poll loop.""" - self._logger.info("Starting connection") - self._start() - sync_act("sckt", "register", self._pipe_r) - - def _start(self): - """Override: start the library's connection (e.g. launch a thread).""" - raise NotImplementedError - - def close(self): - """Unregister from poll, stop the library, and close the pipe.""" - self._logger.info("Closing connection") - sync_act("sckt", "unregister", self._pipe_r) - self._stop() - for fd in (self._pipe_w, self._pipe_r): - try: - os.close(fd) - except OSError: - pass - - def _stop(self): - """Override: stop the library thread gracefully.""" - pass - - - # Writes - - def send_response(self, response): - """Override: send a response via the underlying library.""" - raise NotImplementedError - - def async_write(self): - """No-op: writes go directly through the library, not via poll.""" - pass - - - # Read - - def _push_message(self, msg): - """Called from the library thread to enqueue a bot-level message. - - Writes a wakeup byte to the pipe so the main loop wakes up and - calls async_read(). - """ - self._queue.put(msg) - try: - os.write(self._pipe_w, b'\x00') - except OSError: - pass # pipe closed during shutdown - - def async_read(self): - """Called by the bot when the pipe is readable. - - Drains the wakeup bytes and yields all queued bot messages. - """ - try: - os.read(self._pipe_r, 256) - except OSError: - return - while not self._queue.empty(): - try: - yield self._queue.get_nowait() - except queue.Empty: - break - - def parse(self, msg): - """Messages pushed via _push_message are already bot-level — pass through.""" - yield msg - - - # Exceptions - - def exception(self, flags): - """Called by the bot on POLLERR/POLLHUP/POLLNVAL.""" - self._logger.warning("Exception on server %s: flags=0x%x", self._name, flags) diff --git a/requirements.txt b/requirements.txt index f709273..e69de29 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +0,0 @@ -irc -matrix-nio[e2e]