lanonna
This commit is contained in:
parent
dc85f8b7c0
commit
6387ed26d8
10 changed files with 1329 additions and 0 deletions
17
lanonna/commands.py
Normal file
17
lanonna/commands.py
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
from string import ascii_lowercase as ascii_lowercase_
|
||||||
|
|
||||||
|
commands = {
|
||||||
|
'asino',
|
||||||
|
}
|
||||||
|
|
||||||
|
ascii_lowercase = set(ascii_lowercase_)
|
||||||
|
|
||||||
|
|
||||||
|
def parse(src: str) -> None | tuple[str, str]:
|
||||||
|
'''The command format in PEG format is: `![a-z] ?whitespace ?(.*)'''
|
||||||
|
if src.startswith('!') and src[1:2] in ascii_lowercase:
|
||||||
|
cmd = src[1:].split(' ', 1)[0]
|
||||||
|
idx = len(cmd) + 2
|
||||||
|
return (cmd, src[idx:])
|
||||||
|
else:
|
||||||
|
return None
|
43
lanonna/config.py
Normal file
43
lanonna/config.py
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
from typing import NamedTuple, Union
|
||||||
|
import os
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
CONF = '/etc/lanonna.toml'
|
||||||
|
|
||||||
|
|
||||||
|
class Configuration(NamedTuple):
|
||||||
|
matrix_url: str
|
||||||
|
matrix_username: str
|
||||||
|
matrix_password: str
|
||||||
|
mq_url: str
|
||||||
|
|
||||||
|
|
||||||
|
def init() -> Union[str | Configuration]:
|
||||||
|
|
||||||
|
def missing_key(k):
|
||||||
|
return f'Missing configuration value in configuration file: {k}.'
|
||||||
|
|
||||||
|
|
||||||
|
if not os.path.exists(CONF):
|
||||||
|
return f"Can't read configuration file. Please create {CONF}."
|
||||||
|
|
||||||
|
with open('/etc/lanonna.toml', 'rb') as f:
|
||||||
|
conf = tomllib.load(f)
|
||||||
|
|
||||||
|
if 'lanonna' not in conf: # check that there is a toml section for this
|
||||||
|
return missing_key('lanonna')
|
||||||
|
|
||||||
|
conf = conf['lanonna']
|
||||||
|
keys = ('matrix_url', 'matrix_username', 'matrix_password', 'mq_url')
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
if key not in conf:
|
||||||
|
return missing_key(key)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
rest = set(conf.keys()) - set(keys)
|
||||||
|
if len(rest) > 0:
|
||||||
|
return f"There are unknown configuration values in the config file: '{rest}'. Please remove those."
|
||||||
|
else:
|
||||||
|
return Configuration(**conf)
|
64
lanonna/lanonna.py
Normal file
64
lanonna/lanonna.py
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
import matrix
|
||||||
|
import mq
|
||||||
|
import protocol
|
||||||
|
import config
|
||||||
|
|
||||||
|
logger = logging.getLogger('lanonna')
|
||||||
|
logger.setLevel(logging.WARNING)
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
async def matrixmain(matrix_client: matrix.MatrixClient, mq_client: mq.MQClient):
|
||||||
|
from nio import RoomMessageText
|
||||||
|
|
||||||
|
client = matrix_client.client
|
||||||
|
|
||||||
|
callback = lambda r, e: matrix.message_received_cb(matrix_client, mq_client, logger, r, e)
|
||||||
|
client.add_event_callback(callback, RoomMessageText)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await client.sync_forever(timeout=30000, full_state=True) # milliseconds
|
||||||
|
logging.info('Exiting from the matrix loop')
|
||||||
|
client.logout()
|
||||||
|
except Exception as e:
|
||||||
|
logging.exception(f'Exception in matrix loop: {e}')
|
||||||
|
|
||||||
|
|
||||||
|
async def mqmain(rabbit_client: mq.MQClient, matrix_client: matrix.MatrixClient):
|
||||||
|
async def loop():
|
||||||
|
async with rabbit_client.queue.iterator() as queue_iter:
|
||||||
|
async for message in queue_iter:
|
||||||
|
async with message.process():
|
||||||
|
body = message.body.decode()
|
||||||
|
br = protocol.json_to_bot_response(body)
|
||||||
|
# logging.info(f'New message from MQ: {str(br)[:24]}...')
|
||||||
|
await matrix.send_text(matrix_client, br)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with rabbit_client.connection:
|
||||||
|
await loop()
|
||||||
|
except Exception as e:
|
||||||
|
logging.exception(f'Exception in mq loop: {e}')
|
||||||
|
|
||||||
|
|
||||||
|
async def main(conf: config.Configuration):
|
||||||
|
mq_client = await mq.initialize(conf)
|
||||||
|
matrix_client = await matrix.initialize(conf)
|
||||||
|
|
||||||
|
await asyncio.gather(
|
||||||
|
mqmain(mq_client, matrix_client),
|
||||||
|
matrixmain(matrix_client, mq_client)
|
||||||
|
)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
configuration = config.init()
|
||||||
|
if isinstance(configuration, str): # it's an error!
|
||||||
|
print(configuration, file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
else:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
loop.run_until_complete(main(configuration))
|
62
lanonna/matrix.py
Normal file
62
lanonna/matrix.py
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import logging
|
||||||
|
from collections import namedtuple
|
||||||
|
from typing import Any
|
||||||
|
from nio import AsyncClient, InviteEvent, RoomMessageText, MatrixRoom
|
||||||
|
from markdown import markdown
|
||||||
|
|
||||||
|
import protocol
|
||||||
|
import mq
|
||||||
|
import config
|
||||||
|
import commands
|
||||||
|
|
||||||
|
MatrixClient = namedtuple('MatrixClient', ('client'))
|
||||||
|
|
||||||
|
async def initialize(c: config.Configuration):
|
||||||
|
client = AsyncClient(c.matrix_url, c.matrix_username)
|
||||||
|
|
||||||
|
logging.info(await client.login(c.matrix_password))
|
||||||
|
# always accept every room invite
|
||||||
|
client.add_event_callback(lambda room, _: client.join(room.room_id), InviteEvent)
|
||||||
|
|
||||||
|
return MatrixClient(client)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_text(matrix_client: MatrixClient, response: protocol.BotResponse):
|
||||||
|
client = matrix_client.client
|
||||||
|
content: dict[str, Any] = {"msgtype": "m.text", "body": response.content}
|
||||||
|
|
||||||
|
if response.as_markdown:
|
||||||
|
content["formatted_body"] = markdown(response.content)
|
||||||
|
content["format"] = "org.matrix.custom.html"
|
||||||
|
|
||||||
|
if response.as_reply and response.source_message_id:
|
||||||
|
content["m.relates_to"] = {"m.in_reply_to": {"event_id": response.source_message_id}}
|
||||||
|
|
||||||
|
try:
|
||||||
|
await client.room_send(response.room_id, 'm.room.message', content)
|
||||||
|
logging.info(f'Replied in matrix: {response}')
|
||||||
|
except Exception as e:
|
||||||
|
logging.exception(f"Unable to send message response {response}|{e}")
|
||||||
|
|
||||||
|
async def message_received_cb(matrix_client: MatrixClient,
|
||||||
|
rabbit_client: mq.MQClient,
|
||||||
|
logger: logging.Logger,
|
||||||
|
room: MatrixRoom,
|
||||||
|
event: RoomMessageText):
|
||||||
|
import mq
|
||||||
|
cmd = commands.parse(event.body)
|
||||||
|
if cmd:
|
||||||
|
logging.info(f'got new matrix command: {cmd}')
|
||||||
|
swm = protocol.SwitchboardMessage(command=cmd[0],
|
||||||
|
content=cmd[1],
|
||||||
|
source_message_id=event.event_id,
|
||||||
|
sender_nick=room.user_name(event.sender),
|
||||||
|
room_id=room.room_id)
|
||||||
|
try:
|
||||||
|
if swm.command in commands.commands:
|
||||||
|
await mq.route_to_exchange(rabbit_client, swm)
|
||||||
|
else:
|
||||||
|
help_ = protocol.unknown_cmd_help_reply(swm)
|
||||||
|
await send_text(matrix_client, help_)
|
||||||
|
except Exception as e:
|
||||||
|
logging.exception(f"Can't route switchboard message: {swm}|{str(e)}")
|
30
lanonna/mq.py
Normal file
30
lanonna/mq.py
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import aio_pika
|
||||||
|
from collections import namedtuple
|
||||||
|
|
||||||
|
import protocol
|
||||||
|
import config
|
||||||
|
|
||||||
|
MQClient = namedtuple('MQClient', ('queue', 'exchange',
|
||||||
|
'connection', 'channel'))
|
||||||
|
|
||||||
|
|
||||||
|
async def initialize(c: config.Configuration):
|
||||||
|
connection = await aio_pika.connect_robust(c.mq_url)
|
||||||
|
channel = await connection.channel()
|
||||||
|
|
||||||
|
queue = await channel.declare_queue('lanonna')
|
||||||
|
exchange = await channel.declare_exchange('lanonna', durable=True)
|
||||||
|
await queue.bind(exchange)
|
||||||
|
|
||||||
|
return MQClient(queue, exchange, connection, channel)
|
||||||
|
|
||||||
|
|
||||||
|
async def route_to_exchange(rabbit_client: MQClient, msg: protocol.SwitchboardMessage):
|
||||||
|
import json
|
||||||
|
|
||||||
|
routing_key = msg.command
|
||||||
|
|
||||||
|
btes = json.dumps(msg._asdict()).encode()
|
||||||
|
mqmsg = aio_pika.Message(body=btes)
|
||||||
|
|
||||||
|
await rabbit_client.channel.default_exchange.publish(mqmsg, routing_key)
|
1014
lanonna/poetry.lock
generated
Normal file
1014
lanonna/poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
49
lanonna/protocol.py
Normal file
49
lanonna/protocol.py
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
from typing import NamedTuple, Optional
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
class SwitchboardMessage(NamedTuple):
|
||||||
|
command: str
|
||||||
|
content: str
|
||||||
|
source_message_id: str
|
||||||
|
sender_nick: str
|
||||||
|
room_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class BotResponse(NamedTuple):
|
||||||
|
content: str
|
||||||
|
source_message_id: str | None
|
||||||
|
room_id: str
|
||||||
|
as_reply: bool # requires source_message_id
|
||||||
|
as_markdown: bool
|
||||||
|
|
||||||
|
|
||||||
|
def unknown_cmd_help_reply(swm: SwitchboardMessage):
|
||||||
|
'''Returns the standard help text
|
||||||
|
because the user requested an unknown command'''
|
||||||
|
import commands
|
||||||
|
WRONG_CMD_TEXT = f'Hai sbagliato commando, lezzo! Prova uno di questi: {commands.commands}'
|
||||||
|
return BotResponse(content=WRONG_CMD_TEXT,
|
||||||
|
source_message_id=swm.source_message_id,
|
||||||
|
room_id=swm.room_id,
|
||||||
|
as_reply=True,
|
||||||
|
as_markdown=False)
|
||||||
|
|
||||||
|
|
||||||
|
def json_to_bot_response(json_str: str) -> Optional[BotResponse]:
|
||||||
|
import logging
|
||||||
|
try:
|
||||||
|
data = json.loads(json_str)
|
||||||
|
|
||||||
|
content = data['content']
|
||||||
|
room_id = data['room_id']
|
||||||
|
|
||||||
|
as_reply = data.get('as_reply', False)
|
||||||
|
as_markdown = data.get('as_markdown', False)
|
||||||
|
source_message_id = data.get('source_message_id', None)
|
||||||
|
|
||||||
|
return BotResponse(content, source_message_id, room_id, as_reply, as_markdown)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.exception(f"Error parsing JSON or missing required fields: {str(e)}")
|
||||||
|
return None
|
23
lanonna/pyproject.toml
Normal file
23
lanonna/pyproject.toml
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
[tool.poetry]
|
||||||
|
name = "lanonna"
|
||||||
|
version = "1.0"
|
||||||
|
description = "La Nonna è un bot per lezzo e matrix che sa tutto ma anche niente."
|
||||||
|
authors = ["bparodi"]
|
||||||
|
license = "GPLv3"
|
||||||
|
readme = "README.md"
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
python = "^3.10"
|
||||||
|
matrix-nio = "^0.23.0"
|
||||||
|
pika = "^1.3.2"
|
||||||
|
aio-pika = "^9.3.1"
|
||||||
|
markdown = "^3.5.1"
|
||||||
|
|
||||||
|
|
||||||
|
[tool.poetry.group.dev.dependencies]
|
||||||
|
mypy = "^1.8.0"
|
||||||
|
pytest = "^7.4.3"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
0
lanonna/tests/__init__.py
Normal file
0
lanonna/tests/__init__.py
Normal file
27
lanonna/tests/test_cmd_parser.py
Normal file
27
lanonna/tests/test_cmd_parser.py
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import commands
|
||||||
|
|
||||||
|
def test_parser():
|
||||||
|
yes = [
|
||||||
|
('!alert', ''),
|
||||||
|
('!alert 23', '23'),
|
||||||
|
('!alert @9090132-', '@9090132-'),
|
||||||
|
('!alert ciao', 'ciao'),
|
||||||
|
('!alert ciao ciaone', 'ciao ciaone'),
|
||||||
|
('!alert ciao ciaone ancora', 'ciao ciaone ancora'),
|
||||||
|
]
|
||||||
|
|
||||||
|
nope = [
|
||||||
|
'!',
|
||||||
|
'!123',
|
||||||
|
'! ',
|
||||||
|
'alert',
|
||||||
|
' '
|
||||||
|
]
|
||||||
|
|
||||||
|
for c, exp in yes:
|
||||||
|
res = commands.parse(c)
|
||||||
|
assert res[0] == 'alert'
|
||||||
|
assert res[1] == exp
|
||||||
|
|
||||||
|
for c in nope:
|
||||||
|
assert commands.parse(c) is None
|
Loading…
Reference in a new issue