Initial commit
This commit is contained in:
commit
a2247a4b2a
13 changed files with 534 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
**/__pycache__
|
13
Dockerfile
Normal file
13
Dockerfile
Normal 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
8
docker-compose.yml
Normal file
|
@ -0,0 +1,8 @@
|
|||
version: '3'
|
||||
|
||||
services:
|
||||
bot:
|
||||
build: .
|
||||
network_mode: "host" # FIXME
|
||||
volumes:
|
||||
- ./data:/data
|
3
docker.env
Normal file
3
docker.env
Normal file
|
@ -0,0 +1,3 @@
|
|||
DATABASE_FILE=/data/database.sqlite
|
||||
CREDENTIALS_FILE=/data/credentials.json
|
||||
STORE_PATH=/data/store
|
0
matrix-invitation-dealer/__init__.py
Normal file
0
matrix-invitation-dealer/__init__.py
Normal file
19
matrix-invitation-dealer/__main__.py
Normal file
19
matrix-invitation-dealer/__main__.py
Normal 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())
|
62
matrix-invitation-dealer/admin.py
Normal file
62
matrix-invitation-dealer/admin.py
Normal 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
|
34
matrix-invitation-dealer/client.py
Normal file
34
matrix-invitation-dealer/client.py
Normal 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
|
12
matrix-invitation-dealer/db.py
Normal file
12
matrix-invitation-dealer/db.py
Normal 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)
|
67
matrix-invitation-dealer/env.py
Normal file
67
matrix-invitation-dealer/env.py
Normal 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)
|
||||
|
232
matrix-invitation-dealer/main.py
Normal file
232
matrix-invitation-dealer/main.py
Normal 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()
|
||||
|
80
matrix-invitation-dealer/setup.py
Normal file
80
matrix-invitation-dealer/setup.py
Normal 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
3
requirements.txt
Normal file
|
@ -0,0 +1,3 @@
|
|||
matrix-nio[e2e]
|
||||
aiosqlite
|
||||
aiohttp
|
Loading…
Reference in a new issue