Compare commits
2 commits
fb163d9f2d
...
6ae1a73f15
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ae1a73f15 | |||
| 469238c2a8 |
6 changed files with 754 additions and 19 deletions
67
nemubot/message/printer/IRCLib.py
Normal file
67
nemubot/message/printer/IRCLib.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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))
|
||||||
69
nemubot/message/printer/Matrix.py
Normal file
69
nemubot/message/printer/Matrix.py
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
# 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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))
|
||||||
375
nemubot/server/IRCLib.py
Normal file
375
nemubot/server/IRCLib.py
Normal file
|
|
@ -0,0 +1,375 @@
|
||||||
|
# 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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)
|
||||||
200
nemubot/server/Matrix.py
Normal file
200
nemubot/server/Matrix.py
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
# 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
@ -24,13 +24,13 @@ def factory(uri, ssl=False, **init_args):
|
||||||
if o.scheme == "irc" or o.scheme == "ircs":
|
if o.scheme == "irc" or o.scheme == "ircs":
|
||||||
# https://www.w3.org/Addressing/draft-mirashi-url-irc-01.txt
|
# https://www.w3.org/Addressing/draft-mirashi-url-irc-01.txt
|
||||||
# https://www-archive.mozilla.org/projects/rt-messaging/chatzilla/irc-urls.html
|
# https://www-archive.mozilla.org/projects/rt-messaging/chatzilla/irc-urls.html
|
||||||
args = init_args
|
args = dict(init_args)
|
||||||
|
|
||||||
if o.scheme == "ircs": ssl = True
|
if o.scheme == "ircs": ssl = True
|
||||||
if o.hostname is not None: args["host"] = o.hostname
|
if o.hostname is not None: args["host"] = o.hostname
|
||||||
if o.port is not None: args["port"] = o.port
|
if o.port is not None: args["port"] = o.port
|
||||||
if o.username is not None: args["username"] = o.username
|
if o.username is not None: args["username"] = o.username
|
||||||
if o.password is not None: args["password"] = o.password
|
if o.password is not None: args["password"] = unquote(o.password)
|
||||||
|
|
||||||
modifiers = o.path.split(",")
|
modifiers = o.path.split(",")
|
||||||
target = unquote(modifiers.pop(0)[1:])
|
target = unquote(modifiers.pop(0)[1:])
|
||||||
|
|
@ -41,36 +41,58 @@ def factory(uri, ssl=False, **init_args):
|
||||||
if "msg" in params:
|
if "msg" in params:
|
||||||
if "on_connect" not in args:
|
if "on_connect" not in args:
|
||||||
args["on_connect"] = []
|
args["on_connect"] = []
|
||||||
args["on_connect"].append("PRIVMSG %s :%s" % (target, params["msg"]))
|
args["on_connect"].append("PRIVMSG %s :%s" % (target, params["msg"][0]))
|
||||||
|
|
||||||
if "key" in params:
|
if "key" in params:
|
||||||
if "channels" not in args:
|
if "channels" not in args:
|
||||||
args["channels"] = []
|
args["channels"] = []
|
||||||
args["channels"].append((target, params["key"]))
|
args["channels"].append((target, params["key"][0]))
|
||||||
|
|
||||||
if "pass" in params:
|
if "pass" in params:
|
||||||
args["password"] = params["pass"]
|
args["password"] = params["pass"][0]
|
||||||
|
|
||||||
if "charset" in params:
|
if "charset" in params:
|
||||||
args["encoding"] = params["charset"]
|
args["encoding"] = params["charset"][0]
|
||||||
|
|
||||||
#
|
|
||||||
if "channels" not in args and "isnick" not in modifiers:
|
if "channels" not in args and "isnick" not in modifiers:
|
||||||
args["channels"] = [ target ]
|
args["channels"] = [target]
|
||||||
|
|
||||||
from nemubot.server.IRC import IRC as IRCServer
|
args["ssl"] = ssl
|
||||||
|
|
||||||
|
from nemubot.server.IRCLib import IRCLib as IRCServer
|
||||||
srv = IRCServer(**args)
|
srv = IRCServer(**args)
|
||||||
|
|
||||||
if ssl:
|
elif o.scheme == "matrix":
|
||||||
try:
|
# matrix://localpart:password@homeserver.tld/!room:homeserver.tld
|
||||||
from ssl import create_default_context
|
# matrix://localpart:password@homeserver.tld/%23alias:homeserver.tld
|
||||||
context = create_default_context()
|
# Use matrixs:// for https (default) vs http
|
||||||
except ImportError:
|
args = dict(init_args)
|
||||||
# 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
|
return srv
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
irc
|
||||||
|
matrix-nio[e2e]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue