Initial commit

This commit is contained in:
bain 2023-10-13 08:51:48 +02:00
commit a2247a4b2a
Signed by: bain
GPG key ID: 31F0F25E3BED0B9B
13 changed files with 534 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
**/__pycache__

13
Dockerfile Normal file
View file

@ -0,0 +1,13 @@
FROM python:3.10-alpine
RUN apk update && apk add cmake olm make alpine-sdk
WORKDIR /app
COPY requirements.txt /app/requirements.txt
RUN pip install -r requirements.txt
RUN mkdir /data
COPY matrix-invitation-dealer /app/matrix-invitation-dealer
COPY docker.env /app/.env
CMD ["python3", "-m", "matrix-invitation-dealer"]

8
docker-compose.yml Normal file
View file

@ -0,0 +1,8 @@
version: '3'
services:
bot:
build: .
network_mode: "host" # FIXME
volumes:
- ./data:/data

3
docker.env Normal file
View file

@ -0,0 +1,3 @@
DATABASE_FILE=/data/database.sqlite
CREDENTIALS_FILE=/data/credentials.json
STORE_PATH=/data/store

View file

View file

@ -0,0 +1,19 @@
import asyncio
import logging
from .client import create_bot
from .main import Bot
logging.basicConfig(level=logging.WARNING)
logging.getLogger(__package__).setLevel(logging.DEBUG)
async def go():
client = await create_bot()
bot = Bot(client)
await bot.run()
asyncio.run(go())

View file

@ -0,0 +1,62 @@
from typing import Optional
from aiohttp import ClientSession
import logging
logger = logging.getLogger(__name__)
class SynapseAdmin:
"""
Synapse administration API wrapper
Resources:
https://matrix-org.github.io/synapse/latest/usage/administration/admin_api
"""
def __init__(self, access_token: str, session: ClientSession):
self.access_token = access_token
self.session = session
@classmethod
async def at(cls, homeserver: str, access_token: str):
"""
Needs to be async, since we're creating an aiohttp session
"""
return cls(access_token, ClientSession(homeserver))
async def user_info(self, user_id: str):
resp = await self.session.get(
"/_synapse/admin/v2/users/" + user_id,
headers={"Authorization": "Bearer " + self.access_token},
)
if resp.status == 200:
return await resp.json()
elif resp.status != 404:
logger.warn(
"HTTP %s while fetching account information (%s)", resp.status, user_id
)
return None
async def create_token(self) -> Optional[str]:
resp = await self.session.post(
"/_synapse/admin/v1/registration_tokens/new",
headers={
"Authorization": "Bearer " + self.access_token,
},
json={
"uses_allowed": 1,
},
)
if not resp.ok:
return None
json = await resp.json()
return json["token"]
async def delete_token(self):
pass
async def get_token(self):
pass

View file

@ -0,0 +1,34 @@
#!/usr/bin/env python3
import json
import os
from .env import CREDENTIALS_FILE, STORE_PATH
from nio import AsyncClient, AsyncClientConfig
async def create_bot() -> AsyncClient:
if not os.path.exists(CREDENTIALS_FILE):
print("Please first run setup to create initial connection parameters and database")
exit(1)
else:
with open(CREDENTIALS_FILE, "r") as f:
config = json.load(f)
cfg = AsyncClientConfig(
encryption_enabled=True,
store_sync_tokens=True,
store_name="test_store",
)
client = AsyncClient(config["homeserver"], config=cfg, store_path=STORE_PATH)
client.access_token = config["access_token"]
client.user_id = config["user_id"]
client.device_id = config["device_id"]
client.download
client.load_store()
if client.should_upload_keys:
await client.keys_upload()
return client

View file

@ -0,0 +1,12 @@
from .env import DATABASE_FILE
import aiosqlite
class CRUD:
def __init__(self, connection: aiosqlite.Connection):
self.connection = connection
@classmethod
async def at(cls, file: str):
conn = await aiosqlite.connect(file)
return cls(conn)

View file

@ -0,0 +1,67 @@
import os
import re
import datetime
from typing import Any, Callable, Optional, TypeVar, Dict
R = TypeVar("R")
_DOTENV: Dict[str, str] = {}
def bool_(v: str) -> bool:
return bool(int(v))
def td_parse(v: str) -> datetime.timedelta:
match = re.match(r"^(?:(\d+)d)?\s*(?:(\d+)h)?\s*(?:(\d+)m)?\s*(?:(\d+)s)?$", v)
if match is None:
raise ValueError(f'Cannot parse "{v}" into timedelta')
days = int(match.group(1) or 0)
hours = int(match.group(2) or 0)
minutes = int(match.group(3) or 0)
seconds = int(match.group(4) or 0)
return datetime.timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
def env(t: Callable[[Any], R], name: str, default: Optional[R] = None) -> R:
x = os.getenv(name)
if x is None:
x = _DOTENV.get(name)
if x is None:
if default is None:
raise ValueError(f"Environment variable {name} not found!")
else:
return default
return t(x)
_ENV_FILE: str = env(str, "ENV_FILE", ".env")
if os.path.isfile(_ENV_FILE):
with open(_ENV_FILE) as f:
for line in f.readlines():
split = line.strip().split("=")
if len(split) < 2 or split[0].startswith("#"):
continue
_DOTENV[split[0]] = "=".join(split[1:])
USER_REQUIRED_AGE: datetime.timedelta = env(
td_parse, "USER_REQUIRED_AGE", datetime.timedelta(days=14)
)
SYNAPSE_ADMIN_ACCESS_TOKEN: str = env(str, "SYNAPSE_ADMIN_ACCESS_TOKEN", "")
SYNAPSE_ADMIN_HOMESERVER: str = env(
str, "SYNAPSE_ADMIN_ACCESS_TOKEN", "http://127.0.0.1:8008"
)
DATABASE_FILE: str = env(str, "DATABASE_FILE", "data.sqlite")
CREDENTIALS_FILE: str = env(str, "CREDENTIALS_FILE", "credentials.json")
STORE_PATH: str = env(str, "STORE_PATH", "store")
USER_ID_SUFFIX: str = env(str, "USER_ID_SUFFIX", "nolog.chat")
INVITE_CODE_QUOTA: str = env(str, "INVITE_CODE_QUOTA", "10/7d")
icq_amount, icq_timespan = INVITE_CODE_QUOTA.split("/")
INVITE_CODE_QUOTA_AMOUNT: int = int(icq_amount)
INVITE_CODE_QUOTA_TIMESPAN: datetime.timedelta = td_parse(icq_timespan)

View file

@ -0,0 +1,232 @@
import datetime
import re
import time
import asyncio
from typing import Deque, Optional
import aiosqlite
from nio import (
AsyncClient,
InviteMemberEvent,
JoinedMembersResponse,
MatrixRoom,
RoomMemberEvent,
RoomMessage,
RoomMessageText,
)
from collections import deque
import logging
from . import env
from .admin import SynapseAdmin
logger = logging.getLogger(__name__)
class Bot:
def __init__(self, client: AsyncClient) -> None:
self.client = client
self.admin_api: SynapseAdmin
self.db: aiosqlite.Connection
self.db_lock = asyncio.Lock()
# FIXME: weird hack because we get the same event twice when we join a room
self.seen_events: Deque[str] = deque(maxlen=20)
async def invite_callback(self, room: MatrixRoom, event: InviteMemberEvent):
if (
event.membership == "invite"
and event.state_key == self.client.user_id # event about me
and event.sender.endswith(':' + env.USER_ID_SUFFIX)
and event.content.get('is_direct', False)
):
# we've got a valid invite!
logger.debug("joining DM of %s", event.sender)
await self.client.join(room.room_id)
elif event.membership == "invite" and event.state_key == self.client.user_id:
print(event.content)
await self.client.room_leave(room.room_id)
async def room_member_update_callback(
self, room: MatrixRoom, event: RoomMemberEvent
):
if event.event_id in self.seen_events:
return
else:
self.seen_events.append(event.event_id)
if event.membership == "join" and event.state_key == self.client.user_id:
user = await self.require_dm_partner(room)
if not user:
return
allowed = await self.user_allowed(user)
if allowed is None:
await self.send_message(
room.room_id,
plain="Hello! I couldn't fetch your account information. Sorry. You can try again later.",
)
await self.leave(room)
return
if not allowed:
await self.send_message(
room.room_id,
formatted="Hello! You can't create invites <i>just yet</i>. Feel free to message me in a few days to check again.",
)
await self.leave(room)
return
await self.send_message(
room.room_id,
formatted="Hello! <b>You are allowed to create invites</b>, hurray! You can generate a new invite by sending the <code>!new</code> command. I will respond with a single-use code that you can share.",
)
if event.membership == "leave" and room.joined_count == 1:
# leave rooms where we're alone
await self.leave(room)
async def room_message_callback(self, room: MatrixRoom, event: RoomMessage):
if type(room) is not MatrixRoom:
return
if type(event) is not RoomMessageText or event.body != "!new":
return
user = event.sender
allowed = await self.user_allowed(user)
if allowed is None:
await self.send_message(
room.room_id,
plain="Hello! I couldn't fetch your account information. Sorry. You can try again later.",
)
return
if not allowed:
await self.send_message(
room.room_id,
plain="Sorry, you're not allowed to create invites.",
)
return
if await self.quota_exceeded(user):
await self.send_message(
room.room_id,
plain="Sorry, you can't create any more invites right now. Come back later.",
)
return
token = await self.admin_api.create_token()
async with self.db_lock:
await self.db.execute(
"INSERT INTO tokens (user, token) VALUES (?, ?);", (user, token)
)
await self.db.commit()
await self.send_message(
room.room_id,
formatted=f"<code>{token}</code>",
)
async def user_allowed(self, user: str) -> Optional[bool]:
"""
Checks both that the user is from the homeserver we are managing, and
that they have the required account age.
"""
user_info = await self.admin_api.user_info(user)
if not user_info:
return None
return (
datetime.timedelta(seconds=time.time() - user_info["creation_ts"])
>= env.USER_REQUIRED_AGE
)
async def quota_exceeded(self, user: str) -> bool:
timespan = env.INVITE_CODE_QUOTA_TIMESPAN.total_seconds()
async with self.db_lock:
async with self.db.execute(
"SELECT count(token) AS amount FROM tokens WHERE unixepoch(CURRENT_TIMESTAMP)-unixepoch(created) < ? AND user = ?;",
(timespan, user),
) as cursor:
res = await cursor.fetchone()
return res is not None and res[0] >= env.INVITE_CODE_QUOTA_AMOUNT
async def require_dm_partner(self, room: MatrixRoom) -> Optional[str]:
"""
Fetches the DM user. Leaves if the room has more users.
"""
users = await self.client.joined_members(room.room_id)
if type(users) is not JoinedMembersResponse:
return None
not_me = list(filter(lambda u: u.user_id != self.client.user_id, users.members))
print(room.users)
if len(not_me) > 1:
# This shouldn't really happen, since we're trying our best to stay out of
# rooms with multiple people.
await self.send_message(
room.room_id,
plain="Sorry, I don't like speaking in public channels. Feel free to DM me.",
)
await self.leave(room)
return None
return not_me[0].user_id
async def send_message(
self, room_id: str, plain: Optional[str] = None, formatted: Optional[str] = None
):
message = {"msgtype": "m.text"}
if formatted:
message["format"] = "org.matrix.custom.html"
message["formatted_body"] = formatted
message["body"] = re.sub(r"<.*?>", "", formatted)
elif plain:
message["body"] = plain
else:
raise RuntimeError("cannot send empty message")
await self.client.room_send(
room_id,
message_type="m.room.message",
content=message,
ignore_unverified_devices=True,
)
async def leave(self, room: MatrixRoom):
await self.client.room_leave(room.room_id)
await self.client.room_forget(room.room_id)
async def main(self):
await self.client.synced.wait()
logger.info("logged in as %s !", self.client.user_id)
self.client.add_event_callback(self.invite_callback, (InviteMemberEvent,)) # type: ignore
self.client.add_event_callback(self.room_member_update_callback, (RoomMemberEvent,)) # type: ignore
self.client.add_event_callback(self.room_message_callback, (RoomMessage,)) # type: ignore
async def run(self):
self.admin_api = await SynapseAdmin.at(
env.SYNAPSE_ADMIN_HOMESERVER, env.SYNAPSE_ADMIN_ACCESS_TOKEN or self.client.access_token
)
self.db = await aiosqlite.connect(env.DATABASE_FILE)
try:
while True:
try:
await asyncio.gather(self.client.sync_forever(30_000), self.main())
except Exception:
# TODO: better restart system
logger.exception("Restarting")
await asyncio.sleep(15)
finally:
await self.client.close()
await self.db.close()
await self.admin_api.session.close()

View file

@ -0,0 +1,80 @@
import asyncio
import json
import getpass
import aiosqlite
import os
from nio import AsyncClient, AsyncClientConfig, LoginResponse
from .env import CREDENTIALS_FILE, DATABASE_FILE, STORE_PATH
def write_details_to_disk(resp: LoginResponse, homeserver) -> None:
"""
Writes the required login details to disk so we can log in later without
using a password.
"""
with open(CREDENTIALS_FILE, "w") as f:
json.dump(
{
"homeserver": homeserver,
"user_id": resp.user_id,
"device_id": resp.device_id,
"access_token": resp.access_token,
},
f,
)
async def main():
print(
"First time use. Did not find credential file. Asking for "
"homeserver, user, and password to create credential file."
)
homeserver = input(f"Enter your homeserver URL: ")
if not (homeserver.startswith("https://") or homeserver.startswith("http://")):
homeserver = "https://" + homeserver
user_id = input(f"Enter your full user ID: ")
device_name = input(f"Choose a name for this device: [matrix-nio] ") or "matrix-nio"
cfg = AsyncClientConfig(
encryption_enabled=True,
store_sync_tokens=True,
store_name="test_store",
)
os.makedirs(STORE_PATH, exist_ok=True)
client = AsyncClient(homeserver, user_id, config=cfg, store_path=STORE_PATH)
pw = getpass.getpass()
resp = await client.login(pw, device_name=device_name)
# check that we logged in successfully
if isinstance(resp, LoginResponse):
write_details_to_disk(resp, homeserver)
print(
"Logged in using a password. Credentials were stored.",
"Try running the script again to login with credentials.",
)
else:
print(f'homeserver = "{homeserver}"; user = "{user_id}"')
print(f"Failed to log in: {resp}")
exit(1)
async with aiosqlite.connect(DATABASE_FILE) as db:
await db.executescript('''
CREATE TABLE IF NOT EXISTS tokens (
user TEXT NOT NULL,
token TEXT NOT NULL,
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
''')
await db.commit()
asyncio.run(main())

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
matrix-nio[e2e]
aiosqlite
aiohttp