dump current state (wip-ish)
This commit is contained in:
parent
0124c35472
commit
51fb1c9f26
46 changed files with 3749 additions and 0 deletions
0
hotdog/__init__.py
Normal file
0
hotdog/__init__.py
Normal file
31
hotdog/__main__.py
Normal file
31
hotdog/__main__.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--config", required=True, help="Path to config.yaml")
|
||||
args = parser.parse_args()
|
||||
|
||||
config = Config(args.config)
|
||||
logging.basicConfig(
|
||||
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
||||
level=config.loglevel,
|
||||
)
|
||||
logging.getLogger("peewee").setLevel("INFO") # XXX too spamy
|
||||
|
||||
bot = Bot(config)
|
||||
|
||||
await bot.run()
|
||||
|
||||
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
log.info("Shutdown by user.")
|
||||
299
hotdog/bot.py
Normal file
299
hotdog/bot.py
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from time import time as now
|
||||
from types import ModuleType as Plugin
|
||||
from typing import *
|
||||
|
||||
from aiohttp import ClientConnectionError, ServerDisconnectedError
|
||||
from nio import AsyncClient, AsyncClientConfig, Event, InviteMemberEvent, JoinError
|
||||
from nio import LoginError as LoginErrorResponse
|
||||
from nio import MatrixRoom, RoomMessageText, UnknownEvent
|
||||
|
||||
from .models import Job, JobCallback, Message, Reaction
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoginError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
MessageHandler = Callable[[Message], Awaitable]
|
||||
|
||||
# XXX add the facility to store/buffer responses, e.g. if a timer triggers
|
||||
# a new message that should be sent to a room but the bot is disconnected
|
||||
# at the moment, the bot should just buffer it and send it when it's back
|
||||
# online.
|
||||
|
||||
# XXX have a mechanism that first collects all responders and then calls them.
|
||||
# this would allow to let responders know about each other and interact if
|
||||
# necessary, e.g. urlinfo sees that youtube-info is already handling it.
|
||||
|
||||
|
||||
class Bot:
|
||||
def __init__(self, config):
|
||||
self.client: AsyncClient = None
|
||||
self.config = config
|
||||
self.plugins = {}
|
||||
self.message_handlers: List[MessageHandler] = []
|
||||
self.timers: List[Job] = []
|
||||
self.shared: Dict[str, Any] = {} # Shared memory for the plug-ins.
|
||||
|
||||
def on_message(self, callback: MessageHandler):
|
||||
self.message_handlers.append(callback)
|
||||
|
||||
def on_command(self, command: Union[str, Container[str]], callback: MessageHandler):
|
||||
commands = command = {command} if type(command) is str else command
|
||||
|
||||
async def guard(message):
|
||||
if message.command not in commands:
|
||||
return
|
||||
await callback(message)
|
||||
|
||||
guard.__qualname__ = f"{callback.__module__}.{callback.__qualname__}"
|
||||
|
||||
self.message_handlers.append(guard)
|
||||
|
||||
def add_timer(
|
||||
self,
|
||||
*,
|
||||
title: str,
|
||||
callback: JobCallback,
|
||||
every: Optional[float] = None,
|
||||
next_at: Optional[datetime] = None,
|
||||
next_in: Optional[float] = None,
|
||||
jitter: float = 0,
|
||||
):
|
||||
# We require "every", or "next_at" (x)or "next_in".
|
||||
assert every or next_at or next_in
|
||||
assert not (next_at and next_in)
|
||||
job = Job(
|
||||
app=self,
|
||||
title=title,
|
||||
every=every,
|
||||
func=callback,
|
||||
jitter=jitter,
|
||||
)
|
||||
job.next = (
|
||||
now() + next_in
|
||||
if next_in
|
||||
else next_at.timestamp()
|
||||
if next_at
|
||||
else job._next()
|
||||
)
|
||||
self.timers.append(job)
|
||||
|
||||
def _init(self):
|
||||
command_plugins = load_plugins("command")
|
||||
|
||||
self.client = AsyncClient(
|
||||
self.config.homeserver_url,
|
||||
self.config.user_id,
|
||||
device_id=self.config.device_id,
|
||||
store_path=self.config.store_path.as_posix(),
|
||||
config=AsyncClientConfig(
|
||||
max_limit_exceeded=0,
|
||||
max_timeouts=0,
|
||||
store_sync_tokens=True,
|
||||
encryption_enabled=True,
|
||||
),
|
||||
)
|
||||
add_event_callback = self.client.add_event_callback
|
||||
add_event_callback(self._on_any_event, Event)
|
||||
add_event_callback(self._on_message, RoomMessageText)
|
||||
# add_event_callback(self._on_invite, InviteMemberEvent) # XXX make join-on-invite configurable
|
||||
add_event_callback(self._on_unknown, UnknownEvent)
|
||||
|
||||
for name, mod in command_plugins.items():
|
||||
log.debug(f"Initializing plugin: {name}")
|
||||
try:
|
||||
mod.init(self)
|
||||
except Exception as err:
|
||||
log.exception("Initialization failed: %s", name, exc_info=err)
|
||||
else:
|
||||
self.plugins[name] = mod
|
||||
|
||||
async def _on_unknown(self, room: MatrixRoom, event: UnknownEvent):
|
||||
# See if we can transform an Unknown event into something we DO know.
|
||||
if event.type == "m.reaction":
|
||||
await self._on_reaction(room, Reaction.from_dict(event.source))
|
||||
|
||||
async def _on_reaction(self, room: MatrixRoom, event: Reaction):
|
||||
# XXX allow clients to register handlers for these events
|
||||
pass
|
||||
|
||||
async def _on_any_event(self, *args):
|
||||
log.debug("New event: %s", repr(args))
|
||||
|
||||
async def _on_message(self, room: MatrixRoom, event: RoomMessageText):
|
||||
if (self.config.is_dev and room.room_id != self.config.dev_room) or (
|
||||
not self.config.is_dev and room.room_id == self.config.dev_room
|
||||
):
|
||||
return
|
||||
|
||||
is_own_message = event.sender == self.client.user
|
||||
if is_own_message:
|
||||
return
|
||||
|
||||
log.info(f"#{room.display_name} <{room.user_name(event.sender)}> {event.body}")
|
||||
|
||||
msg = Message(self, event.body, room, event)
|
||||
|
||||
tasks = {}
|
||||
for h in self.message_handlers:
|
||||
try:
|
||||
coro = h(msg)
|
||||
except Exception as err:
|
||||
log.exception("Error calling message handler: %s", h, exc_info=err)
|
||||
else:
|
||||
tasks[h] = asyncio.create_task(coro)
|
||||
|
||||
timeout = 10
|
||||
fut = asyncio.gather(*tasks.values(), return_exceptions=True)
|
||||
try:
|
||||
await asyncio.wait_for(fut, timeout)
|
||||
except asyncio.TimeoutError as err:
|
||||
await swallow(fut)
|
||||
|
||||
for h, t in tasks.items():
|
||||
assert t.done()
|
||||
try:
|
||||
err = t.exception()
|
||||
except asyncio.CancelledError:
|
||||
log.error("Message handler took too long to finished: %s", h)
|
||||
if err is not None:
|
||||
log.exception("Error in message handler: %s", h, exc_info=err)
|
||||
|
||||
async def _on_invite(self, room: MatrixRoom, event: InviteMemberEvent):
|
||||
if self.config.is_dev:
|
||||
return
|
||||
|
||||
log.debug(f"Received invite from user: {event.sender}: {room.room_id}")
|
||||
|
||||
res = await self.client.join(room.room_id)
|
||||
if type(res) == JoinError:
|
||||
log.error(f"Could not join room: {room.room_id}: {res.message}")
|
||||
else:
|
||||
log.info(f"Joined room: {room.room_id}")
|
||||
|
||||
async def _login(self):
|
||||
# if self.config.access_token:
|
||||
# self.client.restore_login(
|
||||
# self.config.user_id, self.config.device_id, self.config.access_token
|
||||
# )
|
||||
# if self.config.password:
|
||||
# else:
|
||||
# resp = await self.client.login(token=self.config.access_token)
|
||||
resp = await self.client.login(
|
||||
password=self.config.password, device_name=self.config.device_name
|
||||
)
|
||||
|
||||
if isinstance(resp, LoginErrorResponse):
|
||||
raise LoginError(f"Could not log in: {resp.message}")
|
||||
|
||||
if not self.config.device_id:
|
||||
self.config.device_id_path.write_text(self.client.device_id)
|
||||
|
||||
await self.client.set_displayname(self.config.display_name)
|
||||
await self.client.update_device(
|
||||
self.client.device_id, {"display_name": self.config.device_name}
|
||||
)
|
||||
|
||||
async def _run_timers(self) -> NoReturn:
|
||||
client = self.client
|
||||
await asyncio.sleep(2)
|
||||
while True:
|
||||
await asyncio.sleep(0.2)
|
||||
# if not client.logged_in:
|
||||
# continue
|
||||
for job in self.timers:
|
||||
if job.next is not None and job.next <= now():
|
||||
job.next = None
|
||||
try:
|
||||
coro = job.func(job)
|
||||
except Exception as err:
|
||||
log.exception(
|
||||
"Disabled job with error: %s", job.title, exc_info=err
|
||||
)
|
||||
job.task = None
|
||||
else:
|
||||
job.task = asyncio.create_task(coro)
|
||||
if job.task is not None:
|
||||
task = job.task
|
||||
if task.done():
|
||||
job.task = None
|
||||
try:
|
||||
await task
|
||||
except Exception as err:
|
||||
log.exception(
|
||||
"Disabled job with error: %s", job.title, exc_info=err
|
||||
)
|
||||
else:
|
||||
job.next = job._next()
|
||||
log.debug(f"Task scheduled: {job.title}: {job.next}")
|
||||
|
||||
async def _run_client(self) -> NoReturn:
|
||||
client = self.client
|
||||
while True:
|
||||
try:
|
||||
if not client.logged_in:
|
||||
await self._login()
|
||||
|
||||
log.info(f"Logged in as {client.user}")
|
||||
await client.sync_forever(timeout=30_000, full_state=True)
|
||||
|
||||
log.warning("Shutting down.")
|
||||
return
|
||||
|
||||
except LoginError as err:
|
||||
log.error(f"Could not log in: {err}")
|
||||
return
|
||||
except asyncio.exceptions.TimeoutError:
|
||||
log.warning("Connection timed out.")
|
||||
except ClientConnectionError as err:
|
||||
log.warning(f"Could not connect to server: {err}")
|
||||
except ServerDisconnectedError as err:
|
||||
log.exception("Disconnected from server.", exc_info=err)
|
||||
finally:
|
||||
await client.close()
|
||||
client.access_token = ""
|
||||
|
||||
waitfor = 15
|
||||
log.info(f"Retrying in {waitfor}s...")
|
||||
await asyncio.sleep(waitfor)
|
||||
|
||||
async def run(self) -> NoReturn:
|
||||
self._init()
|
||||
await asyncio.gather(
|
||||
self._run_timers(),
|
||||
self._run_client(),
|
||||
)
|
||||
|
||||
|
||||
def load_plugins(pkg: str) -> Mapping[str, Plugin]:
|
||||
modules = {}
|
||||
for modpath in (Path(__file__).parent / pkg).glob(f"*.py"):
|
||||
modname = modpath.with_suffix("").name
|
||||
try:
|
||||
mod = import_module(f".{pkg}.{modname}", __package__)
|
||||
except Exception as err:
|
||||
log.exception("Error loading plugin: %s", modpath, exc_info=err)
|
||||
else:
|
||||
if not hasattr(mod, "init"):
|
||||
log.error(f"Invalid plugin: {modpath}")
|
||||
else:
|
||||
log.debug(f"Loaded plugin: {modpath}")
|
||||
modules[modname] = mod
|
||||
return modules
|
||||
|
||||
|
||||
async def swallow(future: asyncio.Future):
|
||||
"""Get rid of a Future."""
|
||||
future.cancel()
|
||||
try:
|
||||
await future # Should always immediately raise.
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
20
hotdog/command/README.md
Normal file
20
hotdog/command/README.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
A plugin is any module that defines an `init` function that takes a `Bot`
|
||||
instance as first argument.
|
||||
The function can then register any message handlers or timers on the bot,
|
||||
or add any dependencies it requires.
|
||||
|
||||
Example:
|
||||
|
||||
```py
|
||||
from ..functions import reply
|
||||
|
||||
HELP = """Responds to your hello.
|
||||
!hello
|
||||
"""
|
||||
|
||||
def init(bot):
|
||||
bot.on_command("hello", reply_world)
|
||||
|
||||
async def reply_world(message):
|
||||
await reply(message, "world")
|
||||
```
|
||||
31
hotdog/command/aoderb.py
Normal file
31
hotdog/command/aoderb.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import random
|
||||
import re
|
||||
|
||||
from ..functions import reply
|
||||
from ..models import Message
|
||||
|
||||
HELP = """Entscheidet zwischen A und B.
|
||||
@me: <A> oder <B>?
|
||||
"""
|
||||
|
||||
|
||||
def init(bot):
|
||||
bot.on_message(handle)
|
||||
|
||||
|
||||
async def handle(message: Message):
|
||||
if not (
|
||||
message.text.endswith("?") and "oder" in message.tokens and message.is_for_me
|
||||
):
|
||||
return
|
||||
|
||||
_, text = message.text.split(None, 1)
|
||||
args = text[:-1].split(" oder ")
|
||||
if ":" in args[0]:
|
||||
args[0] = args[0].split(":", 1)[1]
|
||||
elif "-" in args[0]:
|
||||
args[0] = args[0].split("-", 1)[1]
|
||||
choice = random.choice(args).strip(" ,.:")
|
||||
if not choice:
|
||||
return
|
||||
await reply(message, plain=choice, with_name=True)
|
||||
370
hotdog/command/covid.py
Normal file
370
hotdog/command/covid.py
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from html import escape
|
||||
from typing import *
|
||||
|
||||
import requests
|
||||
|
||||
from ..functions import localizedtz, react, reply
|
||||
from ..models import Job, Message
|
||||
from ..tz import cest
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def init(bot):
|
||||
if "covid.store" not in bot.shared:
|
||||
bot.shared["covid.store"] = Store(bot.config.get("covid.storage"))
|
||||
bot.shared["covid.store"].connect()
|
||||
|
||||
bot.on_message(handle)
|
||||
|
||||
one_minute = 60
|
||||
one_hour = 3600
|
||||
bot.add_timer(
|
||||
title="update covid store",
|
||||
every=6 * one_hour,
|
||||
callback=update_store,
|
||||
jitter=30 * one_minute,
|
||||
)
|
||||
|
||||
|
||||
# https://npgeo-corona-npgeo-de.hub.arcgis.com/datasets/917fc37a709542548cc3be077a786c17_0
|
||||
parse_last_update = re.compile(
|
||||
r"(?P<day>\d{1,2})\.(?P<month>\d{1,2})\.(?P<year>\d{4}), (?P<hour>\d{2}):(?P<minute>\d{2}) Uhr"
|
||||
).fullmatch
|
||||
|
||||
api_url = "https://services7.arcgis.com/mOBPykOjAyBO2ZKk/arcgis/rest/services/RKI_Landkreisdaten/FeatureServer/0/query"
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, dbpath: Optional[str] = None):
|
||||
self.dbpath = dbpath
|
||||
self.connection: Optional[sqlite3.Connection] = None
|
||||
|
||||
def connect(self, path: Optional[str] = None) -> None:
|
||||
if path:
|
||||
self.dbpath = path
|
||||
if self.connection is not None:
|
||||
return self.connection
|
||||
log.debug("Connecting to %s", self.dbpath)
|
||||
conn = self.connection = sqlite3.connect(
|
||||
self.dbpath, isolation_level=None
|
||||
) # auto commit
|
||||
conn.row_factory = sqlite3.Row # Enable access row data by column name.
|
||||
self.init()
|
||||
|
||||
def init(self) -> None:
|
||||
conn = self.connection
|
||||
sql = """
|
||||
create table if not exists county ( -- Landkreis
|
||||
id integer primary key not null,
|
||||
state_id integer not null references state(id),
|
||||
name text unique not null
|
||||
);;
|
||||
|
||||
create table if not exists state ( -- Bundesland
|
||||
id integer primary key not null,
|
||||
name text unique not null
|
||||
);;
|
||||
|
||||
create table if not exists county_probe (
|
||||
county_id integer not null references county(id),
|
||||
ts integer not null,
|
||||
cases integer not null,
|
||||
cases7_per_100k real not null,
|
||||
deaths integer not null,
|
||||
population integer not null
|
||||
);;
|
||||
|
||||
create unique index if not exists
|
||||
county_probe_index
|
||||
on county_probe(county_id, ts);;
|
||||
|
||||
create table if not exists state_probe (
|
||||
state_id integer not null references state(id),
|
||||
ts integer not null,
|
||||
cases7_per_100k real not null,
|
||||
population integer not null
|
||||
);;
|
||||
|
||||
create unique index if not exists
|
||||
state_probe_index
|
||||
on state_probe(state_id, ts);;
|
||||
|
||||
drop view if exists current;;
|
||||
|
||||
create view if not exists
|
||||
current
|
||||
as select
|
||||
county.name as county_name,
|
||||
county_probe.cases as county_cases,
|
||||
county_probe.cases7_per_100k as county_cases7_per_100k,
|
||||
county_probe.deaths as county_deaths,
|
||||
county_probe.population as county_population,
|
||||
county_probe.ts as ts,
|
||||
state.name as state_name,
|
||||
state_probe.cases7_per_100k as state_cases7_per_100k,
|
||||
state_probe.population as state_population
|
||||
from county_probe
|
||||
inner join county on county.id=county_probe.county_id
|
||||
inner join state on state.id=county.state_id
|
||||
inner join state_probe on state_probe.state_id
|
||||
where
|
||||
county_probe.ts=state_probe.ts
|
||||
and state_probe.state_id=county.state_id
|
||||
;;
|
||||
|
||||
create trigger if not exists
|
||||
current_insert
|
||||
instead of insert
|
||||
on current
|
||||
begin
|
||||
insert into
|
||||
state (name)
|
||||
values (new.state_name)
|
||||
on conflict do nothing;
|
||||
insert into
|
||||
county (name, state_id)
|
||||
values (
|
||||
new.county_name,
|
||||
(select id from state where name=new.state_name)
|
||||
)
|
||||
on conflict do nothing;
|
||||
insert into
|
||||
state_probe (state_id, ts, cases7_per_100k, population)
|
||||
values (
|
||||
(select id from state where name=new.state_name),
|
||||
new.ts,
|
||||
new.state_cases7_per_100k,
|
||||
new.state_population
|
||||
)
|
||||
on conflict do nothing;
|
||||
insert into
|
||||
county_probe (county_id, ts, cases, cases7_per_100k, deaths, population)
|
||||
values (
|
||||
(select id from county where name=new.county_name),
|
||||
new.ts,
|
||||
new.county_cases,
|
||||
new.county_cases7_per_100k,
|
||||
new.county_deaths,
|
||||
new.county_population
|
||||
)
|
||||
on conflict do nothing;
|
||||
end;;
|
||||
"""
|
||||
for s in sql.split(";;"):
|
||||
s = s.strip()
|
||||
if s:
|
||||
conn.execute(s)
|
||||
|
||||
def add(self, probes: Iterable["Probe"]):
|
||||
rows = iter(p.as_row() for p in probes)
|
||||
for first_row in rows:
|
||||
break
|
||||
else:
|
||||
return
|
||||
|
||||
sql = f"""
|
||||
insert into current({",".join(first_row.keys())})
|
||||
values({",".join(["?"] * len(first_row))})
|
||||
"""
|
||||
|
||||
self.connection.execute(sql, tuple(first_row.values()))
|
||||
self.connection.executemany(sql, (tuple(r.values()) for r in rows))
|
||||
|
||||
def _select(self, condition="", params=[]) -> Iterable["Probe"]:
|
||||
sql = f"select * from current {condition}"
|
||||
for row in self.connection.execute(sql, params):
|
||||
yield Probe.from_row(row)
|
||||
|
||||
def find_one(self, term) -> Optional["Probe"]:
|
||||
cond = """
|
||||
where county_name like ?
|
||||
or state_name like ?
|
||||
order by ts desc
|
||||
limit 1
|
||||
"""
|
||||
for probe in self._select(cond, (term, term)):
|
||||
return probe
|
||||
|
||||
|
||||
@dataclass
|
||||
class Probe:
|
||||
# County fields
|
||||
cases: int
|
||||
deaths: int
|
||||
county_name: str
|
||||
ts: datetime
|
||||
cases7_per_100k: float
|
||||
# recovered: str
|
||||
population: str # Einwohnerzahl Landkreis
|
||||
|
||||
# State fields:
|
||||
state_name: str
|
||||
state_population: int # Einwohnerzahl Bundesland
|
||||
state_cases7_per_100k: float
|
||||
|
||||
@property
|
||||
def death_rate(self) -> float:
|
||||
return self.deaths / self.cases * 100
|
||||
|
||||
@property
|
||||
def cases_per_100k(self) -> float:
|
||||
return self.cases / self.population * 100_000
|
||||
|
||||
@property
|
||||
def cases_per_population(self) -> float:
|
||||
return self.cases / self.population
|
||||
|
||||
_json_fields = {
|
||||
# County fields
|
||||
"cases": "cases",
|
||||
"deaths": "deaths",
|
||||
"county": "county_name",
|
||||
"last_update": "ts", # Needs to be converted to a timestamp
|
||||
"cases7_per_100k": "cases7_per_100k",
|
||||
# "recovered": "recovered",
|
||||
"EWZ": "population",
|
||||
# State fields:
|
||||
"BL": "state_name",
|
||||
"EWZ_BL": "state_population",
|
||||
"cases7_bl_per_100k": "state_cases7_per_100k",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_api_json(cls, data: Mapping[str, Any]):
|
||||
match = parse_last_update(data["last_update"]) # "25.10.2020, 00:00 Uhr"
|
||||
if not match:
|
||||
raise ValueError(f"Could not parse last_updated: {data['last_update']}")
|
||||
|
||||
dt = datetime(
|
||||
int(match["year"]),
|
||||
int(match["month"]),
|
||||
int(match["day"]),
|
||||
int(match["hour"]),
|
||||
int(match["minute"]),
|
||||
)
|
||||
dt = dt.replace(tzinfo=cest(dt)) # The timezone is from the entry date.
|
||||
|
||||
d = {ck: data[jk] for jk, ck in cls._json_fields.items()}
|
||||
d["ts"] = dt
|
||||
return cls(**d)
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, data):
|
||||
return cls(
|
||||
county_name=data["county_name"],
|
||||
cases=data["county_cases"],
|
||||
cases7_per_100k=data["county_cases7_per_100k"],
|
||||
deaths=data["county_deaths"],
|
||||
population=data["county_population"],
|
||||
ts=datetime.fromtimestamp(data["ts"], tz=timezone.utc),
|
||||
state_name=data["state_name"],
|
||||
state_cases7_per_100k=data["state_cases7_per_100k"],
|
||||
state_population=data["state_population"],
|
||||
)
|
||||
|
||||
def as_row(self):
|
||||
return {
|
||||
"county_name": self.county_name,
|
||||
"county_cases": self.cases,
|
||||
"county_cases7_per_100k": self.cases7_per_100k,
|
||||
"county_deaths": self.deaths,
|
||||
"county_population": self.population,
|
||||
"ts": int(self.ts.timestamp()),
|
||||
"state_name": self.state_name,
|
||||
"state_cases7_per_100k": self.state_cases7_per_100k,
|
||||
"state_population": self.state_population,
|
||||
}
|
||||
|
||||
|
||||
def api_params(fields="*"):
|
||||
return {
|
||||
"where": "9999=9999",
|
||||
"outFields": ",".join(fields),
|
||||
"f": "json",
|
||||
"returnGeometry": "false",
|
||||
}
|
||||
|
||||
|
||||
def load_data() -> Iterable[Probe]:
|
||||
# import json
|
||||
|
||||
# with open("/data/covid.resp") as fp:
|
||||
# data = json.load(fp)
|
||||
|
||||
log.debug("Loading data from API.")
|
||||
r = requests.get(
|
||||
api_url,
|
||||
params=api_params(Probe._json_fields),
|
||||
timeout=(10, 10),
|
||||
headers={"user-agent": "hotdog/v1 covid"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
data = r.json()
|
||||
log.debug(f'Found {len(data["features"])} entries.')
|
||||
for row in data["features"]:
|
||||
yield Probe.from_api_json(row["attributes"])
|
||||
|
||||
|
||||
async def update_store(job: Job):
|
||||
store: Store = job.app.shared["covid.store"]
|
||||
store.add(load_data())
|
||||
|
||||
|
||||
def pfloat(n):
|
||||
return f"{n:_.02f}".replace(".", ",")
|
||||
|
||||
|
||||
def as_html(probe, tzname: str, lc: str) -> str:
|
||||
# now = datetime.now(tz=timezone.utc)
|
||||
# since = now - probe.ts
|
||||
# fmt = "%A" if since.days < 7 else "%x"
|
||||
date = localizedtz(probe.ts, "%A, %x", tzname=tzname, lc=lc)
|
||||
return (
|
||||
f"<i>Zahlen von {date}</i>: "
|
||||
+ ", ".join(
|
||||
[
|
||||
f"🌍 {escape(probe.county_name)}",
|
||||
f"😷 {probe.population:_}",
|
||||
f"🦠 {probe.cases:_}",
|
||||
f"☠️ {probe.deaths:_}",
|
||||
f"📈 {pfloat(probe.cases7_per_100k)} (<i>7-Tage-Inzidenz</i>)",
|
||||
]
|
||||
)
|
||||
+ " — "
|
||||
+ ", ".join(
|
||||
[
|
||||
f"🌍 {escape(probe.state_name)}",
|
||||
f"😷 {probe.state_population:_}",
|
||||
f"📈 {pfloat(probe.state_cases7_per_100k)}",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def handle(message: Message):
|
||||
if message.command not in ("cov", "covid") or not message.args:
|
||||
return
|
||||
|
||||
# if message.args.get(0) == "!force-reload":
|
||||
# await react(message, "⚡️")
|
||||
# await update_store(Job(message.app, "", update_store))
|
||||
# await react(message, "👍")
|
||||
# return
|
||||
|
||||
store: Store = message.app.shared["covid.store"]
|
||||
term = "%".join(message.args)
|
||||
if (probe := store.find_one(f"%{term}%")) :
|
||||
roomconf = message.app.config.l6n[message.room.room_id]
|
||||
await reply(
|
||||
message,
|
||||
html=as_html(probe, tzname=roomconf["timezone"], lc=roomconf["locale"]),
|
||||
)
|
||||
else:
|
||||
await reply(message, "No such county or state.", in_thread=True)
|
||||
102
hotdog/command/ddf.py
Normal file
102
hotdog/command/ddf.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import re
|
||||
from dataclasses import dataclass
|
||||
from html import escape
|
||||
from random import choice
|
||||
from typing import *
|
||||
|
||||
from ..functions import reply
|
||||
from ..models import Message
|
||||
|
||||
HELP = """Die drei ??? Folgenindex
|
||||
!ddf [episode #|title]
|
||||
"""
|
||||
|
||||
|
||||
def init(bot):
|
||||
if "ddf.eps" not in bot.shared:
|
||||
with open(bot.config.get("ddf.storage")) as fp:
|
||||
bot.shared["ddf.db"] = db = load_db(fp)
|
||||
bot.shared["ddf.eps"] = tuple(set(db.values()))
|
||||
|
||||
bot.on_command({"ddf", "???"}, handle)
|
||||
|
||||
|
||||
def load_db(fp):
|
||||
db = {}
|
||||
for ep in Episode.from_csv(fp):
|
||||
if ep.nr_europa:
|
||||
db[ep.nr_europa.lower()] = ep
|
||||
elif ep.nr_kosmos:
|
||||
db[ep.nr_kosmos.lower()] = ep
|
||||
if ep.title_de:
|
||||
db[ep.title_de.lower()] = ep
|
||||
if ep.title_us:
|
||||
db[ep.title_us.lower()] = ep
|
||||
return db
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Episode:
|
||||
nr_kosmos: str
|
||||
nr_europa: str
|
||||
nr_randho: str
|
||||
title_us: str
|
||||
title_de: str
|
||||
autor: str
|
||||
year_randho: str
|
||||
year_kosmos: str
|
||||
year_europa: str
|
||||
|
||||
@classmethod
|
||||
def from_csv(cls, fp) -> Iterable["Episode"]:
|
||||
fp = iter(fp)
|
||||
next(fp) # skip the first line, it contains the header
|
||||
for line in fp:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
yield cls(*line.split(";"))
|
||||
|
||||
|
||||
async def handle(message: Message):
|
||||
bot = message.app
|
||||
args = message.args
|
||||
|
||||
ep: Episode = None
|
||||
if not args:
|
||||
eps = bot.shared["ddf.eps"]
|
||||
ep = choice(eps)
|
||||
else:
|
||||
arg = args.str(0).lower()
|
||||
db = bot.shared["ddf.db"]
|
||||
if arg in db:
|
||||
ep = db[arg]
|
||||
else:
|
||||
for key in db:
|
||||
if arg in key:
|
||||
ep = db[key]
|
||||
break
|
||||
|
||||
if not ep:
|
||||
return
|
||||
|
||||
nr = year = src = None
|
||||
if ep.nr_europa:
|
||||
src = ""
|
||||
nr = ep.nr_europa
|
||||
year = ep.year_europa
|
||||
elif ep.nr_kosmos:
|
||||
src = "Buch"
|
||||
nr = ep.nr_kosmos
|
||||
year = ep.year_kosmos
|
||||
|
||||
if not nr or not year:
|
||||
return
|
||||
|
||||
html = f"(<i>{src}<i>:) " if src else ""
|
||||
html += f"<b>#{escape(nr)}</b> <i>Die drei ???</i>: <b>{escape(ep.title_de)}</b>"
|
||||
if ep.title_us:
|
||||
html += f" (US: <i>{escape(ep.title_us)}</i>)"
|
||||
html += f" ({escape(year)})"
|
||||
|
||||
await reply(message, html=html)
|
||||
27
hotdog/command/dm.py
Normal file
27
hotdog/command/dm.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from random import choice
|
||||
|
||||
from ..functions import react, reply
|
||||
from ..models import Message
|
||||
|
||||
HELP = """
|
||||
!dm
|
||||
"""
|
||||
|
||||
|
||||
def init(bot):
|
||||
bot.on_command("dm", handle)
|
||||
|
||||
|
||||
answers = (
|
||||
"deine mutter",
|
||||
"deine mama",
|
||||
"deine mudda",
|
||||
"deine muddi",
|
||||
"dei muddi",
|
||||
"du miefst",
|
||||
"drogeriemarkt",
|
||||
)
|
||||
|
||||
|
||||
async def handle(message: Message):
|
||||
await reply(message, choice(answers))
|
||||
131
hotdog/command/feed.py
Normal file
131
hotdog/command/feed.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from html import escape
|
||||
|
||||
import feeder
|
||||
import postillon
|
||||
|
||||
from ..functions import clamp, localizedtz, reply, send_message, strip_tags
|
||||
from ..models import Job, Message
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def init(bot):
|
||||
bot.on_command("feed", handle)
|
||||
|
||||
if "feeder" not in bot.shared:
|
||||
feeds = (
|
||||
feeder.Feed(fid, f["url"], title=f["display"])
|
||||
for fid, f in bot.config.get("feeder.feeds").items()
|
||||
)
|
||||
feedstore = feeder.Store(bot.config.get("feeder.storage"))
|
||||
feedstore.connect()
|
||||
bot.shared["feeder"] = feeder.Feeder(feedstore, feeds)
|
||||
|
||||
if "poststore" not in bot.shared:
|
||||
bot.shared["poststore"] = postillon.Store(bot.config.get("postillon.storage"))
|
||||
bot.shared["poststore"].connect()
|
||||
|
||||
one_minute = 60
|
||||
one_hour = 3600
|
||||
bot.add_timer(
|
||||
title="update feeds",
|
||||
every=one_hour,
|
||||
callback=update_feeds,
|
||||
jitter=10 * one_minute,
|
||||
)
|
||||
|
||||
|
||||
async def handle(message: Message):
|
||||
feed_id = message.args.str(0)
|
||||
count = clamp(1, message.args.int(1) or 3, 10)
|
||||
|
||||
bot = message.app
|
||||
feeder = bot.shared["feeder"]
|
||||
if feed_id not in feeder.feeds:
|
||||
return
|
||||
posts = feeder.posts(feed_id, [p.id for p in feeder.feeds[feed_id].posts[:count]])
|
||||
|
||||
feedconf = bot.config.get("feeder.feeds")[feed_id]
|
||||
roomconf = bot.config.l6n[message.room.room_id]
|
||||
for post in posts:
|
||||
text = post_as_html(
|
||||
post,
|
||||
tzname=roomconf["timezone"],
|
||||
lc=roomconf["locale"],
|
||||
max_content_len=feedconf.get("max_content_len", 300),
|
||||
)
|
||||
await reply(message, html=text)
|
||||
|
||||
|
||||
def handle_postillon(bot, posts):
|
||||
"""Special handling for Postillon posts to store them in the Postillon DB as well."""
|
||||
poststore = bot.shared["poststore"]
|
||||
for post in posts:
|
||||
poststore.add(postillon.split_post(post))
|
||||
|
||||
|
||||
async def update_feeds(job: Job):
|
||||
max_posts = 2
|
||||
bot = job.app
|
||||
feeder = bot.shared["feeder"]
|
||||
feeds = bot.config.get("feeder.feeds")
|
||||
rooms = {fid: f.get("rooms", []) for fid, f in feeds.items()}
|
||||
news = await feeder.update_all()
|
||||
sends = []
|
||||
mores = []
|
||||
for feed_id, post_ids in news.items():
|
||||
posts = feeder.posts(feed_id, post_ids)
|
||||
log.debug(f"new posts: {feed_id}: {len(posts)}")
|
||||
if feed_id == "post":
|
||||
handle_postillon(bot, posts)
|
||||
prefix = f"[feed:{feed_id}]"
|
||||
for room_id in rooms[feed_id]:
|
||||
roomconf = bot.config.l6n[room_id]
|
||||
selected = posts[:max_posts] if len(posts) > max_posts + 1 else posts
|
||||
for post in selected:
|
||||
text = post_as_html(
|
||||
post,
|
||||
tzname=roomconf["timezone"],
|
||||
lc=roomconf["locale"],
|
||||
max_content_len=feeds[feed_id].get("max_content_len", 300),
|
||||
)
|
||||
text = f"{prefix} {text}"
|
||||
sends.append(send_message(bot.client, room_id, html=text))
|
||||
if len(posts) > len(selected):
|
||||
more = len(posts) - len(selected)
|
||||
mores.append([bot.client, room_id, f"{prefix} {more} more"])
|
||||
await asyncio.gather(*sends)
|
||||
await asyncio.gather(*(send_message(*more) for more in mores))
|
||||
|
||||
|
||||
def post_as_html(post, tzname: str, lc: str, *, max_content_len: int = 300):
|
||||
parts = []
|
||||
if post.date:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
since = now - post.date
|
||||
fmt = ""
|
||||
if since.days < 1:
|
||||
fmt = "%X"
|
||||
else:
|
||||
if since.days < 7:
|
||||
fmt += "%A, "
|
||||
fmt += "%x %X"
|
||||
parts.append("(" + localizedtz(post.date, fmt, tzname=tzname, lc=lc) + ")")
|
||||
if post.title:
|
||||
parts.append(f'<a href="{escape(post.link)}">{escape(post.title)}</a>')
|
||||
elif post.link:
|
||||
parts.append(f'<a href="{escape(post.link)}">{escape(post.link)}</a>')
|
||||
if post.content and max_content_len > 0:
|
||||
if parts:
|
||||
parts.append("—")
|
||||
content = ""
|
||||
for word in strip_tags(post.content).split(" "):
|
||||
if len(content + f" {word}") > max_content_len - 3:
|
||||
content += " […]"
|
||||
break
|
||||
content += f" {word}"
|
||||
parts.append(escape(content))
|
||||
return " ".join(parts)
|
||||
38
hotdog/command/help.py
Normal file
38
hotdog/command/help.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import re
|
||||
from html import escape
|
||||
|
||||
from ..functions import reply
|
||||
from ..models import Message
|
||||
|
||||
|
||||
def init(bot):
|
||||
bot.on_command({"help", "usage"}, handle)
|
||||
|
||||
|
||||
async def handle(message: Message):
|
||||
bot = message.app
|
||||
plugins = {k: p for k, p in bot.plugins.items() if hasattr(p, "HELP")}
|
||||
|
||||
pname = message.args.str(0)
|
||||
if pname not in plugins:
|
||||
modnames = ", ".join(f"<code>{m}</code>" for m in sorted(plugins.keys()))
|
||||
await reply(message, html=f"Help is available for: {modnames}")
|
||||
return
|
||||
|
||||
me = f"@{message.my_name}: "
|
||||
pfix = bot.config.command_prefix
|
||||
|
||||
plugin = plugins[pname]
|
||||
usage: str = plugin.HELP
|
||||
usage = re.sub(
|
||||
"^(!|@me: )", lambda m: pfix if m[1] == "!" else me, usage, flags=re.M
|
||||
)
|
||||
|
||||
lines = usage.splitlines(False)
|
||||
usage = (
|
||||
f"<i>{escape(lines[0])}</i><br>\n<code>"
|
||||
+ "<br>\n".join(escape(l) for l in lines[1:])
|
||||
+ "</code>"
|
||||
)
|
||||
|
||||
await reply(message, html=usage, in_thread=True)
|
||||
322
hotdog/command/orakel.py
Normal file
322
hotdog/command/orakel.py
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
from random import choice, choices, randint, random
|
||||
|
||||
from ..functions import reply
|
||||
from ..models import Message
|
||||
|
||||
HELP = """Gibt Antworten auf Fragen.
|
||||
@me: <eine Frage>?
|
||||
"""
|
||||
|
||||
|
||||
ka = (
|
||||
"Keine Ahnung.",
|
||||
"Das weiß ich leider nicht.",
|
||||
"Ich weiß es nicht.",
|
||||
"Ich weiß es auch nicht.",
|
||||
"Frag besser jemanden, der sich damit auskennt.",
|
||||
"Da wendest du dich besser eine Fachkraft.",
|
||||
"Woher soll ich das wissen?",
|
||||
"Das weiß niemand so genau ...",
|
||||
"Das kann dir keiner beantworten ...",
|
||||
"Eine Frage, so alt wie die Menschheit!",
|
||||
"Das ist eine gute Frage.",
|
||||
"Wer weiß ...",
|
||||
"Boa, keine Ahnung, ey!",
|
||||
"Seh ich so aus, als ob ich wüsste ich das?",
|
||||
"Wie kommst du darauf, dass ich das wüsste?",
|
||||
"Bin ich die Auskunft, oder was?",
|
||||
"Ich bin doch nicht die Auskunft!",
|
||||
"Das ist mir doch egal ...",
|
||||
"Das solltest du besser mit dir selbst ausmachen.",
|
||||
"Das beantwortest du dir am besten mal selbst.",
|
||||
"Ich glaube, das weißt du selbst ganz genau.",
|
||||
"Das weißt du doch selbst ganz genau.",
|
||||
"Schau tief in dich, dort findest du die Antwort.",
|
||||
"Du kämpfst wie eine Kuh!",
|
||||
"Die Antwort ist ... 42.",
|
||||
"Sechs mal Neun.",
|
||||
"Das fragst du besser deine Mama.",
|
||||
)
|
||||
|
||||
ja = (
|
||||
"Ja",
|
||||
"Ja!",
|
||||
"Ja.",
|
||||
"Jap.",
|
||||
"Jo.",
|
||||
"Jau!",
|
||||
"Joa.",
|
||||
"Joooaaaa.",
|
||||
"Jooo",
|
||||
"Klar!",
|
||||
"Klaaaar.",
|
||||
"Ja, klar.",
|
||||
"Klar doch.",
|
||||
"Aber klar doch.",
|
||||
"Na sicher.",
|
||||
"Sicher doch.",
|
||||
"Joa, warum nicht.",
|
||||
"Jo, warum denn nicht.",
|
||||
"Auf jeden Fall!",
|
||||
"Besser wär's.",
|
||||
)
|
||||
|
||||
nein = (
|
||||
"Nein",
|
||||
"Nee",
|
||||
"Besser nicht.",
|
||||
"Nein!",
|
||||
"Niemals!",
|
||||
"Auf keinen Fall!",
|
||||
)
|
||||
|
||||
vielleicht = (
|
||||
"Frag mich später noch mal.",
|
||||
"Das weiß ich nicht genau.",
|
||||
"Ja, vielleicht.",
|
||||
"Nein, vielleicht besser nicht.",
|
||||
)
|
||||
|
||||
|
||||
def random_room_participant(message: Message) -> str:
|
||||
users = [
|
||||
u
|
||||
for uid, u in message.room.users.items()
|
||||
if uid not in (message.event.sender, message.app.client.user)
|
||||
]
|
||||
if not users:
|
||||
return ""
|
||||
user = choice(users)
|
||||
return user.display_name or user.user_id
|
||||
|
||||
|
||||
numnames = [
|
||||
"null",
|
||||
"ein",
|
||||
"zwei",
|
||||
"drei",
|
||||
"vier",
|
||||
"fünf",
|
||||
"sechs",
|
||||
"sieben",
|
||||
"acht",
|
||||
"neun",
|
||||
"zehn",
|
||||
"elf",
|
||||
"zwölf",
|
||||
]
|
||||
|
||||
praepos = (
|
||||
"vor",
|
||||
"hinter",
|
||||
"über",
|
||||
"unter",
|
||||
"auf",
|
||||
"neben", # careful, this cannot be contracted with dativ
|
||||
)
|
||||
|
||||
wwords = {
|
||||
"wann": (
|
||||
# Time
|
||||
lambda m: f"{choice(['Um ', ''])}{randint(0, 23)} Uhr",
|
||||
lambda m: f"{choice(['Um ', ''])}{randint(0, 23)}:{randint(1, 59):02} Uhr",
|
||||
lambda m: f"{choice(['Um ', ''])}{randint(0, 23)} Uhr und {randint(1, 59)} Minuten",
|
||||
lambda m: f"{choice(['Um ', ''])}{randint(1, 59)} Minuten {choice(['vor','nach'])} {randint(0, 12)}",
|
||||
lambda m: f"{choice(['Um ', ''])}Viertel {choice(['vor','nach'])} {randint(0, 12)}",
|
||||
lambda m: f"Um halb {randint(1, 12)}",
|
||||
"Um Mitternacht",
|
||||
lambda m: f"Am {choice(['Nach', 'Vor'])}mittag",
|
||||
# Date
|
||||
"Morgen",
|
||||
"Übermorgen",
|
||||
"Gestern",
|
||||
"Vorgestern",
|
||||
lambda m: f"{choice(['Nächst', 'Vorig'])}e Woche",
|
||||
lambda m: f"{choice(['Vor', 'In'])} {randint(2, 12)} {choice(['Wochen', 'Monaten'])}",
|
||||
lambda m: f"{choice(['Vor', 'In'])} {randint(2, 9999)} Jahren",
|
||||
lambda m: f"{choice(['Vor', 'In'])} {randint(2, 999)} Millionen Jahren",
|
||||
lambda m: f"Am {randint(1, 28)}.{randint(1, 12)}.{randint(9, 9999)}",
|
||||
# Date-time
|
||||
lambda m: f"{choice(['Vor', 'In'])} {randint(2, 17)} Jahren, {randint(2,360)} Tagen, {randint(2,23)} Stunden, {randint(2,59)} Minuten und {randint(2,59)} Sekunden",
|
||||
lambda m: f"Am {randint(1, 28)}.{randint(1, 12)}.{randint(9, 9999)} um {randint(0, 23)}:{randint(1, 59):02} Uhr",
|
||||
),
|
||||
"warum": (),
|
||||
"was": (),
|
||||
"wer": (
|
||||
"Deine Mama!",
|
||||
"Die Polizei.",
|
||||
"Die drei Fragezeichen!",
|
||||
"TKKG.",
|
||||
"Tim und Struppi.",
|
||||
"Die fünf Freunde.",
|
||||
"Der Osterhase.",
|
||||
"Die Ferienbande! Yaaaaay.",
|
||||
"Der Weihnachtsmann.",
|
||||
"Frau Holle.",
|
||||
"Gott!",
|
||||
"Irgendein Gott.",
|
||||
"Du!",
|
||||
"Du selbst.",
|
||||
"Ein Höhlentroll mit einem dicken Prügel.",
|
||||
"Eine höhere Macht.",
|
||||
"Ein unbekanntes Wesen.",
|
||||
"Ein unbekanntes Wesen aus einer unbekannten Galaxie.",
|
||||
"Aliens!",
|
||||
"Graf Zahl.",
|
||||
"Die Russen!",
|
||||
"Die USA!",
|
||||
"Die Reichsbürger!",
|
||||
"Die Regierung.",
|
||||
"Die Presse.",
|
||||
"Die Illuminaten!",
|
||||
"Die internationale Verschwörung e.V.!",
|
||||
"Dein Nachbar.",
|
||||
"Die Kanzlerin.",
|
||||
"Der Präsident.",
|
||||
"Der Vorsitzende vom Schützenverein.",
|
||||
"Der Postbote.",
|
||||
"Die Behörden.",
|
||||
"Die Wissenschaft.",
|
||||
"Die sechs von der Müllabfuhr.",
|
||||
"Der, der grade hinter dir steht und dir zuguckt.",
|
||||
"Die, die sich hinter deinem Schrank versteckt hat.",
|
||||
"Der Typ der in der Ecke steht.",
|
||||
"Die Anderen; immer die Anderen.",
|
||||
lambda m: random_room_participant(m) or "Irgendwer.",
|
||||
),
|
||||
"wie": (
|
||||
"Das weiß ich leider auch nicht, vielleicht mit einer Telefonlawine?",
|
||||
"Am besten gar nicht.",
|
||||
"Das ist schwierig zu sagen.",
|
||||
*ka,
|
||||
),
|
||||
"wieso": (),
|
||||
"wieviel": (
|
||||
"Nichts",
|
||||
lambda m: choice(["Ungefähr ", "Genau ", ""]) + choice(numnames[2:]),
|
||||
lambda m: choice(["Ungefähr ", "Genau ", ""]) + f"{23 * random():n}",
|
||||
lambda m: choice(["Ungefähr ", "Genau ", ""]) + str(randint(1, 10)),
|
||||
lambda m: choice(["Ungefähr ", "Genau ", ""]) + str(randint(7, 23)),
|
||||
lambda m: choice(["Ungefähr ", "Genau ", ""]) + str(randint(17, 42)),
|
||||
lambda m: choice(["Ungefähr ", "Genau ", ""]) + str(randint(23, 99)),
|
||||
lambda m: choice(["Ungefähr ", "Genau ", ""]) + str(randint(100, 1_000_000)),
|
||||
"Unendlich!",
|
||||
),
|
||||
"wieviele": (
|
||||
lambda m: choice(["Ungefähr ", "Genau ", ""]) + choice(numnames[2:]),
|
||||
lambda m: choice(["Ungefähr ", "Genau ", ""]) + f"{23 * random():n}",
|
||||
lambda m: choice(["Ungefähr ", "Genau ", ""]) + str(randint(1, 10)),
|
||||
lambda m: choice(["Ungefähr ", "Genau ", ""]) + str(randint(7, 23)),
|
||||
lambda m: choice(["Ungefähr ", "Genau ", ""]) + str(randint(17, 42)),
|
||||
lambda m: choice(["Ungefähr ", "Genau ", ""]) + str(randint(23, 99)),
|
||||
lambda m: choice(["Ungefähr ", "Genau ", ""]) + str(randint(100, 1_000_000)),
|
||||
"Ein paar.",
|
||||
"Nur ein paar.",
|
||||
"Nur ein paar wenige.",
|
||||
"Wirklich viele.",
|
||||
"Einige.",
|
||||
"Ne ganze Menge.",
|
||||
"Zu viele!",
|
||||
"Ziemlich viele!",
|
||||
"Unendlich viele!",
|
||||
),
|
||||
"wo": (
|
||||
lambda m: f"Genau {choice(praepos)} dir!",
|
||||
lambda m: f"{choice(praepos).title()} dir!",
|
||||
lambda m: f"{choice(praepos).title()} dem Bett.",
|
||||
lambda m: f"{choice(praepos).title()} dem Kopfkissen.",
|
||||
lambda m: f"{choice(praepos).title()} dem Couche-Kissen.",
|
||||
lambda m: f"{choice(praepos).title()} dem Tisch.",
|
||||
lambda m: f"{choice(praepos).title()} der Kommode.",
|
||||
lambda m: f"{choice(praepos).title()} dem Stuhl.",
|
||||
"Droben auf dem Berge.",
|
||||
"In der Garage.",
|
||||
"Im Klo.",
|
||||
"Auf der Straße.",
|
||||
"In der Gasse.",
|
||||
"In den Wolken.",
|
||||
"Vorm Fernseher.",
|
||||
"Im Cyberspace.",
|
||||
"Genau vor deiner Nase.",
|
||||
"Auf deinem Kopf.",
|
||||
"Unter deinem Hintern.",
|
||||
"Du sitzt drauf!",
|
||||
"Im Gefängnis.",
|
||||
"Im Bundestag.",
|
||||
"Auf der dunklen Seite des Mondes.",
|
||||
"In einer weit entfernten Galaxie.",
|
||||
"Auf dem Mond.",
|
||||
"Auf der ISS.",
|
||||
"Im Inneren der Erde.",
|
||||
),
|
||||
"woher": (),
|
||||
"wohin": (),
|
||||
"weshalb": (),
|
||||
"welche": (),
|
||||
"welcher": (),
|
||||
"welches": (),
|
||||
"um wieviel uhr": (
|
||||
lambda m: f"Um {randint(0, 23)} Uhr",
|
||||
lambda m: f"Um {randint(0, 23)}:{randint(1, 59):02} Uhr",
|
||||
lambda m: f"Um {randint(0, 23)} Uhr und {randint(1, 59)} Minuten",
|
||||
lambda m: f"Um {randint(1, 59)} Minuten {choice(['vor','nach'])} {randint(0, 12)}",
|
||||
lambda m: f"Um Viertel {choice(['vor','nach'])} {randint(0, 12)}",
|
||||
lambda m: f"Um halb {randint(1, 12)}",
|
||||
),
|
||||
"seit wann": (
|
||||
lambda m: f"Seit {randint(0, 23)} Uhr",
|
||||
lambda m: f"Seit {randint(0, 23)}:{randint(1, 59):02} Uhr",
|
||||
lambda m: f"Seit {randint(0, 23)} Uhr und {randint(1, 59)} Minuten",
|
||||
lambda m: f"Seit {randint(1, 59)} Minuten {choice(['vor','nach'])} {randint(0, 12)}",
|
||||
lambda m: f"Seit Viertel {choice(['vor','nach'])} {randint(0, 12)}",
|
||||
lambda m: f"Seit halb {randint(1, 12)}",
|
||||
# Date
|
||||
"Seit Gestern",
|
||||
"Seit Vorgestern",
|
||||
lambda m: f"Seit vorige Woche",
|
||||
lambda m: f"Seit {randint(2, 12)} {choice(['Wochen', 'Monaten'])}",
|
||||
lambda m: f"Seit {randint(2, 9999)} Jahren",
|
||||
lambda m: f"Seit {randint(2, 999)} Millionen Jahren",
|
||||
lambda m: f"Seit dem {randint(1, 28)}.{randint(1, 12)}.{randint(9, 9999)}",
|
||||
# Date-time
|
||||
lambda m: f"Seit {randint(2, 17)} Jahren, {randint(2,360)} Tagen, {randint(2,23)} Stunden, {randint(2,59)} Minuten und {randint(2,59)} Sekunden",
|
||||
lambda m: f"Seit dem {randint(1, 28)}.{randint(1, 12)}.{randint(9, 9999)} um {randint(0, 23)}:{randint(1, 59):02} Uhr",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# wer
|
||||
# wem
|
||||
# seit wann
|
||||
# um wieviel uhr
|
||||
|
||||
|
||||
def init(bot):
|
||||
bot.on_message(handle)
|
||||
|
||||
|
||||
async def handle(message: Message):
|
||||
if "oder" in message.tokens or not ( # avoid conflicts with aoderb.py
|
||||
message.text.endswith("?") and message.is_for_me
|
||||
):
|
||||
return
|
||||
|
||||
if message.words.startswith("um wieviel uhr"):
|
||||
wword = "um wieviel uhr"
|
||||
elif message.words.startswith("seit wann"):
|
||||
wword = "seit wann"
|
||||
else:
|
||||
wword = message.words.str(0).rstrip(",.?!").lower()
|
||||
if wword in ("und", "aber"):
|
||||
wword = message.words.str(1).rstrip(",.?!").lower()
|
||||
if wword in wwords:
|
||||
reps = (
|
||||
choices([wwords[wword], ka], weights=[100, 10])[0] if wwords[wword] else ka
|
||||
)
|
||||
template = choice(reps)
|
||||
else:
|
||||
template = choice(choices([ja, nein, vielleicht], weights=[100, 100, 15])[0])
|
||||
|
||||
text = template(message) if callable(template) else template
|
||||
|
||||
await reply(message, text, with_name=True)
|
||||
127
hotdog/command/post.py
Normal file
127
hotdog/command/post.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
from datetime import datetime, timezone
|
||||
|
||||
import postillon
|
||||
|
||||
from ..functions import clamp, localizedtz, reply
|
||||
from ..models import Message
|
||||
|
||||
HELP = """Postillon Newsticker.
|
||||
!post [how many|search terms ...]
|
||||
!post find [search terms ...]
|
||||
!post random [how many]
|
||||
!post more
|
||||
"""
|
||||
|
||||
|
||||
def init(bot):
|
||||
bot.on_command("post", handle)
|
||||
|
||||
bot.shared.setdefault("post.finds", {})
|
||||
if "post.store" not in bot.shared:
|
||||
bot.shared["post.store"] = postillon.Store(bot.config.get("postillon.storage"))
|
||||
bot.shared["post.store"].connect()
|
||||
|
||||
|
||||
def post_as_plain(post, tzname: str, lc: str):
|
||||
parts = []
|
||||
if post.content:
|
||||
parts.append(f"+++ {post.content} +++")
|
||||
if post.date:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
since = now - post.date
|
||||
fmt = ""
|
||||
if since.days < 1:
|
||||
fmt = "%X"
|
||||
else:
|
||||
if since.days < 7:
|
||||
fmt += "%A, "
|
||||
fmt += "%x %X"
|
||||
parts.append("(" + localizedtz(post.date, fmt, tzname=tzname, lc=lc) + ")")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def post_as_html(post, tzname: str, lc: str):
|
||||
parts = []
|
||||
if post.content:
|
||||
content = post.content.strip()
|
||||
sep = ": " if ": " in content else "? " if "? " in content else None
|
||||
if not sep:
|
||||
parts.append(f"+++ {post.content} +++")
|
||||
else:
|
||||
q, a = content.split(sep, 2)
|
||||
parts.append(f"+++ <b>{q}{sep}</b>{a} +++")
|
||||
if post.date:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
since = now - post.date
|
||||
fmt = ""
|
||||
if since.days < 1:
|
||||
fmt = "%X"
|
||||
else:
|
||||
if since.days < 7:
|
||||
fmt += "%A, "
|
||||
fmt += "%x %X"
|
||||
parts.append("(" + localizedtz(post.date, fmt, tzname=tzname, lc=lc) + ")")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
async def handle(message: Message):
|
||||
bot = message.app
|
||||
poststore = bot.shared["post.store"]
|
||||
finds = bot.shared["post.finds"] # used to page through find results
|
||||
max_results = 5
|
||||
|
||||
mode = message.args.str(0)
|
||||
args = message.args
|
||||
if mode in ("find", "random", "more"):
|
||||
args = args[1:]
|
||||
else:
|
||||
# Time for magic!
|
||||
if not args:
|
||||
if finds.get(message.room.room_id, (None, 0))[0] is not None:
|
||||
mode = "more"
|
||||
else:
|
||||
mode = "random"
|
||||
else:
|
||||
if len(args) == 1 and args.str(0).isdecimal():
|
||||
mode = "random"
|
||||
else:
|
||||
mode = "find"
|
||||
|
||||
if mode == "find":
|
||||
term = "%".join(args)
|
||||
posts = poststore.search(f"%{term}%")
|
||||
finds[message.room.room_id] = (term, 0)
|
||||
elif mode == "random":
|
||||
finds[message.room.room_id] = (None, 0)
|
||||
count = clamp(1, args.int(0), max_results)
|
||||
posts = [poststore.random_post() for _ in range(count)]
|
||||
elif mode == "more":
|
||||
term, page = finds.get(message.room.room_id, (None, 0))
|
||||
if term is None:
|
||||
return
|
||||
page += 1
|
||||
posts = poststore.search(f"%{term}%", skip=page * max_results)
|
||||
finds[message.room.room_id] = (term, page)
|
||||
else:
|
||||
return
|
||||
|
||||
roomconf = bot.config.l6n[message.room.room_id]
|
||||
i = None
|
||||
for i, post in enumerate(posts):
|
||||
if i >= max_results:
|
||||
await reply(
|
||||
message,
|
||||
html=f"<i>Für weitere Ergebnisse</i>: <code>{bot.config.command_prefix}post more</code>",
|
||||
in_thread=True,
|
||||
)
|
||||
break
|
||||
text = post_as_html(
|
||||
post,
|
||||
tzname=roomconf["timezone"],
|
||||
lc=roomconf["locale"],
|
||||
)
|
||||
await reply(message, html=text)
|
||||
else:
|
||||
finds[message.room.room_id] = (None, 0)
|
||||
if i is None:
|
||||
await reply(message, html="<i>Keine weiteren Ergebnisse.</i>", in_thread=True)
|
||||
39
hotdog/command/prost.py
Normal file
39
hotdog/command/prost.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from random import choice
|
||||
|
||||
from ..functions import react
|
||||
from ..models import Message
|
||||
|
||||
|
||||
def init(bot):
|
||||
bot.on_message(handle)
|
||||
|
||||
|
||||
emojis = {
|
||||
"☕️",
|
||||
"🍵",
|
||||
"🍶",
|
||||
"🍷",
|
||||
"🍸",
|
||||
"🍹",
|
||||
"🍺",
|
||||
"🍻",
|
||||
"🍼",
|
||||
"🍾",
|
||||
"🥂",
|
||||
"🥃",
|
||||
"🥛",
|
||||
"🥤",
|
||||
"🧃",
|
||||
"🧉",
|
||||
}
|
||||
|
||||
|
||||
async def handle(message: Message):
|
||||
vs16 = "\ufe0f" # see https://en.wikipedia.org/wiki/Variation_Selectors_%28Unicode_block%29
|
||||
is_drinkmoji = message.text.rstrip(vs16) in emojis
|
||||
if not (message.tokens.str(0).startswith("PR!") or is_drinkmoji):
|
||||
return
|
||||
|
||||
remoji = message.text if is_drinkmoji else choice(tuple(emojis))
|
||||
|
||||
await react(message, remoji)
|
||||
20
hotdog/command/reminder.py
Normal file
20
hotdog/command/reminder.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from ..functions import reply
|
||||
from ..models import Message
|
||||
|
||||
# @hotdog: remind <who> <time> <what>
|
||||
# .remind me at 14:30 take out the milk
|
||||
# .remind me to take out the milk tomorrow
|
||||
|
||||
# today -> next 6 hour step
|
||||
# tomorrow -> at 10
|
||||
# in X {days|hours|minutes|seconds}
|
||||
# {on|at} d.m.y [hh:mm]
|
||||
|
||||
|
||||
def init(bot):
|
||||
bot.on_message(handle)
|
||||
|
||||
|
||||
async def handle(message: Message):
|
||||
if message.command != "remind" or not message.args.startswith("me"):
|
||||
return
|
||||
26
hotdog/command/retour.py
Normal file
26
hotdog/command/retour.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from random import choice
|
||||
|
||||
from ..functions import reply
|
||||
from ..models import Message
|
||||
|
||||
|
||||
def init(bot):
|
||||
bot.on_message(handle)
|
||||
|
||||
|
||||
replies = (
|
||||
"Du auch.",
|
||||
"Selber.",
|
||||
"Danke, gleichfalls.",
|
||||
)
|
||||
|
||||
|
||||
async def handle(message: Message):
|
||||
if "oder" in message.tokens or not (
|
||||
message.words.startswith("du bist")
|
||||
and message.is_for_me
|
||||
and not message.text.endswith("?") # avoid conflicts with orakel.py
|
||||
):
|
||||
return
|
||||
|
||||
await reply(message, choice(replies), with_name=True)
|
||||
59
hotdog/command/roll.py
Normal file
59
hotdog/command/roll.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import re
|
||||
from collections import defaultdict
|
||||
from random import randint
|
||||
|
||||
from ..functions import reply
|
||||
from ..models import Message
|
||||
|
||||
HELP = """Würfelt eine Summe aus.
|
||||
!roll [how many]d<sides> [ ... ]
|
||||
"""
|
||||
|
||||
parse_die = re.compile("(?P<num>\d*)[dwDW](?P<sides>\d+)").fullmatch
|
||||
|
||||
|
||||
def init(bot):
|
||||
bot.on_command("roll", handle)
|
||||
|
||||
|
||||
async def handle(message: Message):
|
||||
if len(message.args) == 1 and message.args[0].isdecimal():
|
||||
dice = [(1, int(message.args[0]))]
|
||||
else:
|
||||
dice = []
|
||||
num = None
|
||||
for arg in message.args:
|
||||
if (match := parse_die(arg)) :
|
||||
if num and match["num"]:
|
||||
return
|
||||
num, sides = int(match["num"] or num or 1), int(match["sides"])
|
||||
if not 1 <= num < 1000 or not 2 <= sides <= 100:
|
||||
return
|
||||
dice.append((num, sides))
|
||||
num = None
|
||||
elif arg.isdecimal():
|
||||
if num is not None:
|
||||
return
|
||||
num = int(arg)
|
||||
else:
|
||||
return
|
||||
|
||||
if not 0 < len(dice) < 20:
|
||||
return
|
||||
|
||||
rolls = defaultdict(list)
|
||||
for num, sides in dice:
|
||||
for _ in range(num):
|
||||
rolls[sides].append(randint(1, sides))
|
||||
|
||||
total = sum(sum(vs) for sides, vs in rolls.items())
|
||||
if len(rolls) == 1:
|
||||
sides = list(rolls.keys())[0]
|
||||
text = f"{total} ({len(rolls[sides])}d{sides})"
|
||||
else:
|
||||
dicestr = " + ".join(
|
||||
f"{len(vs)}d{sides} ({sum(vs)})"
|
||||
for sides, vs in sorted(rolls.items(), key=lambda i: i[0], reverse=1)
|
||||
)
|
||||
text = f"{total} = {dicestr}"
|
||||
await reply(message, plain=text, in_thread=True)
|
||||
199
hotdog/command/urlinfo.py
Normal file
199
hotdog/command/urlinfo.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import codecs
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from html import escape
|
||||
from html.parser import HTMLParser
|
||||
from random import randint
|
||||
from time import time as now
|
||||
from typing import *
|
||||
|
||||
import requests
|
||||
|
||||
from ..functions import reply
|
||||
from ..models import Message
|
||||
|
||||
HELP = """Return information about an online HTTP resource.
|
||||
!u[rl] <url>
|
||||
"""
|
||||
|
||||
|
||||
def init(bot):
|
||||
bot.on_command({"u", "url"}, handle)
|
||||
|
||||
|
||||
match_url = re.compile(
|
||||
# r"https?://(?:[a-zA-Z]|[0-9]|[$-_~@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+"
|
||||
r"https?://\S+"
|
||||
).fullmatch
|
||||
|
||||
|
||||
class TitleParser(HTMLParser):
|
||||
"""Parse the first <title> from HTML"""
|
||||
|
||||
# XXX check if it's the <head>'s title we're in, but beware that head can be implicit
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.__is_title = False
|
||||
self.__found = False
|
||||
self.__title = ""
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == "title":
|
||||
self.__is_title = True
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag == "title":
|
||||
self.__found = True
|
||||
self.__is_title = False
|
||||
|
||||
def handle_data(self, data):
|
||||
if self.__is_title and not self.__found:
|
||||
self.__title += data
|
||||
|
||||
@property
|
||||
def title(self) -> Optional[str]:
|
||||
return self.__title if self.__found else None
|
||||
|
||||
|
||||
def get_encodings_from_content(content: str) -> List[str]:
|
||||
"""Returns encodings from given content string."""
|
||||
|
||||
charset_re = re.compile(r'<meta.*?charset=["\']*(.+?)["\'>]', flags=re.I)
|
||||
pragma_re = re.compile(r'<meta.*?content=["\']*;?charset=(.+?)["\'>]', flags=re.I)
|
||||
xml_re = re.compile(r'^<\?xml.*?encoding=["\']*(.+?)["\'>]')
|
||||
|
||||
return (
|
||||
charset_re.findall(content)
|
||||
+ pragma_re.findall(content)
|
||||
+ xml_re.findall(content)
|
||||
)
|
||||
|
||||
|
||||
def stream_decode_response_unicode(content: Iterable[bytes]) -> Iterable[str]:
|
||||
"""Stream decodes a iterator."""
|
||||
|
||||
decoder = None
|
||||
for chunk in content:
|
||||
if decoder is None:
|
||||
encodings = get_encodings_from_content(
|
||||
chunk.decode("utf-8", errors="replace")
|
||||
) + ["utf-8"]
|
||||
decoder = codecs.getincrementaldecoder(encodings[0])(errors="replace")
|
||||
|
||||
rv = decoder.decode(chunk)
|
||||
if rv:
|
||||
yield rv
|
||||
rv = decoder.decode(b"", final=True)
|
||||
if rv:
|
||||
yield rv
|
||||
|
||||
|
||||
def title(content: Iterable[str]) -> Optional[str]:
|
||||
t = TitleParser()
|
||||
for chunk in content:
|
||||
t.feed(chunk)
|
||||
if t.title is not None:
|
||||
break
|
||||
return t.title
|
||||
|
||||
|
||||
def capped(content: Iterable[str], read_max: int) -> Iterable[str]:
|
||||
read = 0
|
||||
for chunk in content:
|
||||
read += len(chunk)
|
||||
yield chunk
|
||||
if read >= read_max:
|
||||
break
|
||||
|
||||
|
||||
@lru_cache(maxsize=5)
|
||||
def load_info(url: str, cachetoken) -> Optional[Mapping[str, Any]]:
|
||||
"""The cachetoken is just there to bust the LRU cache after a while."""
|
||||
try:
|
||||
r = requests.get(
|
||||
url,
|
||||
stream=True,
|
||||
timeout=(3, 3),
|
||||
headers={"user-agent": "hotdog/v1 urlinfo"},
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
content_type = r.headers.get("Content-Type", "")
|
||||
is_html = content_type.startswith("text/html") or url.lower().endswith(
|
||||
(".html", ".htm")
|
||||
)
|
||||
if is_html:
|
||||
one_kb = 2 ** 10
|
||||
# chunks = r.iter_content(chunk_size=30 * one_kb, decode_unicode=True)
|
||||
chunks = stream_decode_response_unicode(r.iter_content(chunk_size=30 * one_kb))
|
||||
html_title = title(capped(chunks, read_max=200 * one_kb))
|
||||
else:
|
||||
html_title = None
|
||||
|
||||
filename = None
|
||||
dispo = r.headers.get("Content-Disposition", "").split(";")
|
||||
if len(dispo) == 2 and dispo[0] == "attachment":
|
||||
dispo = dispo[1].strip().split("=", 2)
|
||||
if len(dispo) == 2 and dispo[0] == "filename":
|
||||
filename = dispo[1].strip()
|
||||
|
||||
return {
|
||||
"code": r.status_code,
|
||||
"url": r.url,
|
||||
"elapsed_ms": int(r.elapsed.total_seconds() * 1_000),
|
||||
"reason": r.reason,
|
||||
"type": r.headers.get("Content-Type"),
|
||||
"size": (
|
||||
int(r.headers["Content-Length"]) if "Content-Length" in r.headers else None
|
||||
),
|
||||
"title": html_title,
|
||||
"filename": filename,
|
||||
}
|
||||
|
||||
|
||||
def cachetoken(quant_m=15):
|
||||
"""Return a cache token with the given time frame"""
|
||||
return int(now() / 60 / quant_m)
|
||||
|
||||
|
||||
def pretty_size(size: int) -> str:
|
||||
qs = "", "K", "M", "G", "T", "P"
|
||||
for q in qs:
|
||||
if size < 1024 or q == qs[-1]:
|
||||
break
|
||||
size /= 1000
|
||||
if not q:
|
||||
return f"{size} B"
|
||||
return f"{size:_.02f} {q}B"
|
||||
|
||||
|
||||
async def handle(message: Message):
|
||||
url = message.args.str(0)
|
||||
if not match_url(url):
|
||||
return
|
||||
|
||||
info = load_info(url, cachetoken())
|
||||
if not info:
|
||||
return
|
||||
|
||||
details = []
|
||||
if info["type"]:
|
||||
details.append(f"<i>Media type</i>: {escape(info['type'])}")
|
||||
if info["size"]:
|
||||
details.append(f"<i>Size</i>: {pretty_size(info['size'])}")
|
||||
details.append(f"<i>Status</i>: {info['code']}")
|
||||
if info["reason"]:
|
||||
details[-1] += f" ({escape(info['reason'])})"
|
||||
if info["url"] != url:
|
||||
details.append(
|
||||
f"""<i>Redirected to</i>: <a href="{escape(info['url'])}">{escape(info['url'])}</a>"""
|
||||
)
|
||||
if info["filename"] and info["filename"] != url.rsplit("/", 2)[-1]:
|
||||
details.append(f"<i>Filename</i>: {escape(info['filename'])}")
|
||||
details.append(f"<i>TTFB</i>: {info['elapsed_ms']:_} ms")
|
||||
|
||||
text = f"<b>{escape(info['title'])}</b> — " if info["title"] else ""
|
||||
text += "; ".join(details)
|
||||
|
||||
await reply(message, html=text, in_thread=True)
|
||||
458
hotdog/command/wikipedia.py
Normal file
458
hotdog/command/wikipedia.py
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from functools import lru_cache, partial
|
||||
from html import escape
|
||||
from time import time as now
|
||||
from typing import *
|
||||
|
||||
import requests
|
||||
|
||||
from ..functions import localizedtz, react, reply, strip_tags
|
||||
from ..models import Message
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
HELP = """Look up articles on Wikipedia.
|
||||
!w[p|ikipedia] [lang (ISO 639)] <search terms ...>
|
||||
"""
|
||||
|
||||
|
||||
def init(bot):
|
||||
bot.shared[__name__] = {"last": 0}
|
||||
|
||||
bot.on_command({"w", "wp", "wikipedia"}, handle)
|
||||
|
||||
|
||||
api_url = "https://{lang}.wikipedia.org/w/api.php"
|
||||
|
||||
# see https://de.wikipedia.org/wiki/Liste_der_Wikipedias_nach_Sprachen
|
||||
langs = {
|
||||
"aa",
|
||||
"ab",
|
||||
"ace",
|
||||
"af",
|
||||
"ak",
|
||||
"als",
|
||||
"am",
|
||||
"an",
|
||||
"ang",
|
||||
"ar",
|
||||
"arc",
|
||||
"as",
|
||||
"ast",
|
||||
"av",
|
||||
"ay",
|
||||
"az",
|
||||
"ba",
|
||||
"bar",
|
||||
"bat-smg",
|
||||
"bcl",
|
||||
"be",
|
||||
"bg",
|
||||
"bh",
|
||||
"bi",
|
||||
"bjn",
|
||||
"bm",
|
||||
"bn",
|
||||
"bo",
|
||||
"bpy",
|
||||
"br",
|
||||
"bs",
|
||||
"bug",
|
||||
"bxr",
|
||||
"ca",
|
||||
"cbk-zam",
|
||||
"cdo",
|
||||
"ce",
|
||||
"ceb",
|
||||
"ch",
|
||||
"cho",
|
||||
"chr",
|
||||
"chy",
|
||||
"ckb",
|
||||
"co",
|
||||
"cr",
|
||||
"crh",
|
||||
"cs",
|
||||
"csb",
|
||||
"cu",
|
||||
"cv",
|
||||
"cy",
|
||||
"da",
|
||||
"de",
|
||||
"diq",
|
||||
"dsb",
|
||||
"dv",
|
||||
"dz",
|
||||
"ee",
|
||||
"el",
|
||||
"eml",
|
||||
"en",
|
||||
"eo",
|
||||
"es",
|
||||
"et",
|
||||
"eu",
|
||||
"ext",
|
||||
"fa",
|
||||
"ff",
|
||||
"fi",
|
||||
"fiu-vro",
|
||||
"fj",
|
||||
"fo",
|
||||
"fr",
|
||||
"frp",
|
||||
"frr",
|
||||
"fur",
|
||||
"fy",
|
||||
"ga",
|
||||
"gag",
|
||||
"gan",
|
||||
"gd",
|
||||
"gl",
|
||||
"glk",
|
||||
"gn",
|
||||
"got",
|
||||
"gu",
|
||||
"gv",
|
||||
"ha",
|
||||
"hak",
|
||||
"haw",
|
||||
"he",
|
||||
"hi",
|
||||
"hif",
|
||||
"ho",
|
||||
"hr",
|
||||
"hsb",
|
||||
"ht",
|
||||
"hu",
|
||||
"hy",
|
||||
"hz",
|
||||
"ia",
|
||||
"id",
|
||||
"ie",
|
||||
"ig",
|
||||
"ii",
|
||||
"ik",
|
||||
"ilo",
|
||||
"io",
|
||||
"is",
|
||||
"it",
|
||||
"iu",
|
||||
"ja",
|
||||
"jbo",
|
||||
"jv",
|
||||
"ka",
|
||||
"kaa",
|
||||
"kab",
|
||||
"kbd",
|
||||
"kg",
|
||||
"kj",
|
||||
"kk",
|
||||
"kl",
|
||||
"km",
|
||||
"kn",
|
||||
"ko",
|
||||
"koi",
|
||||
"kr[",
|
||||
"krc",
|
||||
"ks",
|
||||
"ksh",
|
||||
"ku",
|
||||
"kv",
|
||||
"kw",
|
||||
"ky",
|
||||
"la",
|
||||
"lad",
|
||||
"lb",
|
||||
"lbe",
|
||||
"lez",
|
||||
"lg",
|
||||
"li",
|
||||
"lij",
|
||||
"lmo",
|
||||
"ln",
|
||||
"lo",
|
||||
"lt",
|
||||
"ltg",
|
||||
"lv",
|
||||
"map-bms",
|
||||
"mdf",
|
||||
"mg",
|
||||
"mh",
|
||||
"mhr",
|
||||
"mi",
|
||||
"mk",
|
||||
"ml",
|
||||
"mn",
|
||||
"mr",
|
||||
"mrj",
|
||||
"ms",
|
||||
"mt",
|
||||
"mus",
|
||||
"mwl",
|
||||
"my",
|
||||
"myv",
|
||||
"mzn",
|
||||
"na",
|
||||
"nah",
|
||||
"nap",
|
||||
"nds",
|
||||
"nds-nl",
|
||||
"ne",
|
||||
"new",
|
||||
"ng",
|
||||
"nl",
|
||||
"nn",
|
||||
"no",
|
||||
"nov",
|
||||
"nrm",
|
||||
"nso",
|
||||
"nv",
|
||||
"oc",
|
||||
"om",
|
||||
"or",
|
||||
"os",
|
||||
"pa",
|
||||
"pag",
|
||||
"pam",
|
||||
"pap",
|
||||
"pcd",
|
||||
"pdc",
|
||||
"pfl",
|
||||
"pi",
|
||||
"pih",
|
||||
"pl",
|
||||
"pms",
|
||||
"pnb",
|
||||
"pnt",
|
||||
"ps",
|
||||
"pt",
|
||||
"qu",
|
||||
"rm",
|
||||
"rmy",
|
||||
"rn",
|
||||
"ro",
|
||||
"roa-rup",
|
||||
"roa-tara",
|
||||
"ru",
|
||||
"rue",
|
||||
"rw",
|
||||
"sa",
|
||||
"sc",
|
||||
"scn",
|
||||
"sco",
|
||||
"sd",
|
||||
"se",
|
||||
"sg",
|
||||
"sh",
|
||||
"si",
|
||||
"simple",
|
||||
"sk",
|
||||
"sl",
|
||||
"sm",
|
||||
"sn",
|
||||
"so",
|
||||
"sq",
|
||||
"sr",
|
||||
"srn",
|
||||
"ss",
|
||||
"st",
|
||||
"stq",
|
||||
"su",
|
||||
"sv",
|
||||
"sw",
|
||||
"szl",
|
||||
"ta",
|
||||
"te",
|
||||
"tet",
|
||||
"tg",
|
||||
"th",
|
||||
"ti",
|
||||
"tk",
|
||||
"tl",
|
||||
"tn",
|
||||
"to",
|
||||
"tpi",
|
||||
"tr",
|
||||
"ts",
|
||||
"tt",
|
||||
"tum",
|
||||
"tw",
|
||||
"ty",
|
||||
"udm",
|
||||
"ug",
|
||||
"uk",
|
||||
"ur",
|
||||
"uz",
|
||||
"ve",
|
||||
"vec",
|
||||
"vep",
|
||||
"vi",
|
||||
"vls",
|
||||
"vo",
|
||||
"wa",
|
||||
"war",
|
||||
"wo",
|
||||
"wuu",
|
||||
"xal",
|
||||
"xh",
|
||||
"xmf",
|
||||
"yi",
|
||||
"yo",
|
||||
"za",
|
||||
"zea",
|
||||
"zh",
|
||||
"zh-classical",
|
||||
"zh-min-nan",
|
||||
"zh-yue",
|
||||
"zu",
|
||||
"zu",
|
||||
}
|
||||
|
||||
|
||||
def searchparams(terms: Iterable[str]):
|
||||
# see https://www.mediawiki.org/wiki/API:Search
|
||||
return {
|
||||
"action": "query",
|
||||
"list": "search",
|
||||
"srsearch": " ".join(terms),
|
||||
"format": "json",
|
||||
"srlimit": 3,
|
||||
}
|
||||
|
||||
|
||||
def resolveparams(ids: Iterable[int]):
|
||||
return {
|
||||
"action": "query",
|
||||
"prop": "info",
|
||||
"pageids": "|".join(map(str, ids)),
|
||||
"inprop": "url",
|
||||
"format": "json",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Hit:
|
||||
# ns: int
|
||||
pageid: int
|
||||
size: int
|
||||
snippet: str
|
||||
timestamp: datetime
|
||||
title: str
|
||||
wordcount: int
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data):
|
||||
return cls(
|
||||
data["pageid"],
|
||||
data["size"],
|
||||
data["snippet"],
|
||||
fromjsonformat(data["timestamp"]),
|
||||
data["title"],
|
||||
data["wordcount"],
|
||||
)
|
||||
|
||||
|
||||
def fromjsonformat(s: str) -> datetime:
|
||||
if s.endswith("Z"):
|
||||
s = s[:-1] + "+00:00"
|
||||
return datetime.fromisoformat(s)
|
||||
|
||||
|
||||
def load_api_json(session, lang, params):
|
||||
r = session.get(
|
||||
api_url.format(lang=lang),
|
||||
params=params,
|
||||
timeout=(3, 3),
|
||||
headers={"User-Agent": "hotdog/v1 wikipedia"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def search(session, lang, terms):
|
||||
data = load_api_json(session, lang, searchparams(terms))
|
||||
return {
|
||||
"total": data["query"]["searchinfo"]["totalhits"],
|
||||
"hits": [Hit.from_json(d) for d in data["query"]["search"]],
|
||||
}
|
||||
|
||||
|
||||
def resolve_urls(session, lang, ids: Collection[int]):
|
||||
if not ids:
|
||||
return {}
|
||||
data = load_api_json(session, lang, resolveparams(ids))
|
||||
return {
|
||||
int(pid): p.get("canonicalurl") for pid, p in data["query"]["pages"].items()
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=10)
|
||||
def search_and_resolve(lang, terms):
|
||||
session = requests.Session()
|
||||
result = search(session, lang, terms)
|
||||
result["urls"] = resolve_urls(session, lang, [hit.pageid for hit in result["hits"]])
|
||||
return result
|
||||
|
||||
|
||||
def snippet(s: str, max_content_len: int = 300):
|
||||
content = ""
|
||||
for word in s.split():
|
||||
if not word:
|
||||
continue
|
||||
if len(content + f" {word}") > max_content_len - 3:
|
||||
content += " […]"
|
||||
break
|
||||
content += f" {word}"
|
||||
return content
|
||||
|
||||
|
||||
async def handle(message: Message):
|
||||
bot = message.app
|
||||
roomconf = bot.config.l6n[message.room.room_id]
|
||||
if message.args.get(0) in langs:
|
||||
lang = message.args[0]
|
||||
args = message.args[1:]
|
||||
else:
|
||||
lang, *_ = roomconf["locale"].split("_", 1)
|
||||
args = message.args
|
||||
|
||||
if not args:
|
||||
return
|
||||
|
||||
await react(message, "⚡️")
|
||||
|
||||
# XXX no need to wait if the result is already cached - can we check that?
|
||||
|
||||
# Guard against API flooding...
|
||||
timeout = 10
|
||||
while 0 < (waitfor := timeout - (now() - bot.shared[__name__]["last"])):
|
||||
log.debug(f"Waiting for {waitfor}s before next API call.")
|
||||
await asyncio.sleep(waitfor)
|
||||
bot.shared[__name__]["last"] = now()
|
||||
|
||||
r = search_and_resolve(lang, args)
|
||||
|
||||
if not r["hits"]:
|
||||
await react(message, "❌")
|
||||
return
|
||||
|
||||
lines = []
|
||||
if r["total"] > 3:
|
||||
lines.append(f"Found <b>{r['total']}</b> matching articles.")
|
||||
for hit in r["hits"][:3]:
|
||||
last_updated = localizedtz(
|
||||
hit.timestamp, "%x %X", tzname=roomconf["timezone"], lc=roomconf["locale"]
|
||||
)
|
||||
teaser = snippet(escape(strip_tags(f"… {hit.snippet} …")))
|
||||
lines.append(
|
||||
f'<b><a href="{escape(r["urls"][hit.pageid])}">{escape(hit.title)}</a></b>: <i>{teaser}</i> (<i>{last_updated}</i>)'
|
||||
)
|
||||
|
||||
await asyncio.gather(
|
||||
reply(message, html="<br/>\n".join(lines), in_thread=True),
|
||||
react(message, "✅"),
|
||||
)
|
||||
131
hotdog/command/youtube.py
Normal file
131
hotdog/command/youtube.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import re
|
||||
from dataclasses import dataclass, fields
|
||||
from functools import lru_cache
|
||||
from html import escape
|
||||
from time import time as now
|
||||
from typing import *
|
||||
|
||||
import youtube_dl
|
||||
|
||||
from ..functions import reply
|
||||
from ..models import Message
|
||||
|
||||
HELP = """Gibt Informationen zu Youtube-Videos aus.
|
||||
Some text containing a <Youtube URL>.
|
||||
"""
|
||||
|
||||
youtube_re = re.compile(
|
||||
r"\byoutu(\.be/|be\.com/(embed/|v/|watch\?\w*v=))(?P<id>[0-9A-Za-z_-]{10,11})\b"
|
||||
)
|
||||
|
||||
|
||||
def init(bot):
|
||||
bot.on_message(handle)
|
||||
|
||||
|
||||
@lru_cache(maxsize=5)
|
||||
def load_info(url, cachetoken):
|
||||
"""The cachetoken is just there to bust the LRU cache after a while."""
|
||||
return Info.from_url(url)
|
||||
|
||||
|
||||
def cachetoken(quant_m=15):
|
||||
"""Return a cache token with the given time frame"""
|
||||
return int(now() / 60 / quant_m)
|
||||
|
||||
|
||||
async def handle(message: Message):
|
||||
if message.command in {"u", "url"}:
|
||||
return
|
||||
|
||||
match = youtube_re.search(message.text)
|
||||
if not match:
|
||||
return
|
||||
|
||||
youtube_id = match["id"]
|
||||
|
||||
info = load_info(youtube_id, cachetoken())
|
||||
info.escape_all()
|
||||
details = [
|
||||
f"🖋 {info.author}",
|
||||
f"⏱ {pretty_duration(info.duration_seconds)}",
|
||||
f"📺 {info.width}×{info.height}",
|
||||
f"👀 {info.view_count:_}",
|
||||
]
|
||||
tag = (info.categories[:1] or info.tags[:1] or [""])[0]
|
||||
if tag:
|
||||
details.append(f"🏷 {tag}")
|
||||
text = f"<b>{info.title}</b> — {', '.join(details)}"
|
||||
await reply(message, html=text)
|
||||
|
||||
|
||||
def pretty_duration(seconds: int) -> str:
|
||||
hours = seconds // 3600
|
||||
minutes = (seconds - hours * 3600) // 60
|
||||
seconds = seconds % 60
|
||||
return (
|
||||
f"{hours}h{minutes:02}m{seconds:02}s" if hours else f"{minutes}m{seconds:02}s"
|
||||
)
|
||||
|
||||
|
||||
class Nolog:
|
||||
def debug(self, msg):
|
||||
pass
|
||||
|
||||
def warning(self, msg):
|
||||
pass
|
||||
|
||||
def error(self, msg):
|
||||
pass
|
||||
|
||||
|
||||
ytdl = youtube_dl.YoutubeDL({"skip_download": True, "logger": Nolog()})
|
||||
|
||||
|
||||
@dataclass
|
||||
class Info:
|
||||
author: str
|
||||
title: str
|
||||
description: str
|
||||
duration_seconds: int
|
||||
view_count: int
|
||||
thumbnail: str
|
||||
width: int
|
||||
height: int
|
||||
categories: List[str]
|
||||
tags: List[str]
|
||||
|
||||
def escape_all(self):
|
||||
for f in fields(self):
|
||||
if f.type is str:
|
||||
setattr(self, f.name, escape(getattr(self, f.name)))
|
||||
elif get_origin(f.type) is list:
|
||||
setattr(self, f.name, [escape(x) for x in getattr(self, f.name)])
|
||||
|
||||
@classmethod
|
||||
def from_url(cls, url):
|
||||
info = ytdl.extract_info(url, download=False)
|
||||
new = cls(
|
||||
author=info["uploader"] or info["creator"] or info["uploader_id"] or "",
|
||||
title=info["title"] or info["alt_title"] or "",
|
||||
description=info["description"] or "",
|
||||
duration_seconds=info["duration"] or 0,
|
||||
view_count=info["view_count"] or 0,
|
||||
thumbnail=(
|
||||
info["thumbnail"]
|
||||
or (info["thumbnails"][-1] if info["thumbnails"] else "")
|
||||
),
|
||||
categories=info["categories"] or [],
|
||||
tags=info["tags"] or [],
|
||||
width=info["width"] or 0,
|
||||
height=info["height"] or 0,
|
||||
)
|
||||
if info["formats"]:
|
||||
new.width, new.height = max(
|
||||
(
|
||||
(f["width"] or new.width),
|
||||
(f["height"] or new.height),
|
||||
)
|
||||
for f in info["formats"]
|
||||
)
|
||||
return new
|
||||
92
hotdog/config.py
Normal file
92
hotdog/config.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import platform
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import *
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class ConfigError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class Fallbackdict(dict):
|
||||
"""Like defaultdict, but not implicitly creating entries."""
|
||||
|
||||
def __init__(self, default):
|
||||
super().__init__()
|
||||
self._default = default
|
||||
|
||||
def get(self, key, default=None):
|
||||
return super().get(key, self._default if default is None else default)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return super().get(key, self._default)
|
||||
|
||||
|
||||
class Config:
|
||||
def __init__(self, path):
|
||||
self.load(path)
|
||||
|
||||
def load(self, path):
|
||||
with open(path) as fp:
|
||||
self.config = yaml.safe_load(fp)
|
||||
|
||||
self.command_prefix = self.get("command_prefix")
|
||||
|
||||
# Logging
|
||||
self.loglevel = self.get("loglevel", default="INFO")
|
||||
|
||||
# Storage
|
||||
self.store_path = Path(self.get("storage.store_path"))
|
||||
|
||||
# Matrix
|
||||
self.user_id = self.get("matrix.user_id")
|
||||
if not re.fullmatch("@.*:.*", self.user_id):
|
||||
raise ConfigError("matrix.user_id must be in the form @name:domain")
|
||||
|
||||
self.device_id_path = self.store_path / f"{self.user_id}.device_id"
|
||||
try:
|
||||
self.device_id = self.device_id_path.read_text()
|
||||
except FileNotFoundError:
|
||||
self.device_id = ""
|
||||
|
||||
self.device_name = self.get(
|
||||
"matrix.session_name", default=f"hotdog/{platform.node()}"
|
||||
)
|
||||
self.display_name = self.get("matrix.nick_name")
|
||||
self.homeserver_url = self.get("matrix.homeserver_url")
|
||||
self.password = self.get("matrix.password")
|
||||
|
||||
# Development
|
||||
self.is_dev = self.get("dev.active", False)
|
||||
self.dev_room = self.get("dev.room", "")
|
||||
|
||||
# Location / l6n
|
||||
l6n_default = self.get("l6n.default")
|
||||
l6n = Fallbackdict(l6n_default)
|
||||
for room in self.get("l6n.rooms"):
|
||||
l6n[room["id"]] = {**l6n_default, **room} # XXX in py3.9 use |-operator
|
||||
self.l6n = l6n
|
||||
|
||||
def get(self, path: Union[str, List[str]], default: Any = None) -> Any:
|
||||
if isinstance(path, str):
|
||||
path = path.split(".")
|
||||
config = self.config
|
||||
for part in path:
|
||||
config = config.get(part)
|
||||
if config is None:
|
||||
if default is not None:
|
||||
return default
|
||||
raise ConfigError(f"Config value is missing: {'.'.join(path)}")
|
||||
return config
|
||||
|
||||
def set(self, path: Union[str, List[str]], value: Any):
|
||||
if isinstance(path, str):
|
||||
path = path.split(".")
|
||||
config = self.config
|
||||
for part in path[:-1]:
|
||||
if part in config and not isinstance(config[part], dict):
|
||||
raise ConfigError(f"Conflicting value exists: {'.'.join(path)}")
|
||||
config = config.get(part, {})
|
||||
config[path[-1]] = value
|
||||
181
hotdog/functions.py
Normal file
181
hotdog/functions.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import locale
|
||||
import logging
|
||||
import unicodedata
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from html import escape
|
||||
from html.parser import HTMLParser
|
||||
from io import StringIO
|
||||
from typing import *
|
||||
|
||||
import nio
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
tzdb = {
|
||||
"Europe/Berlin": timezone(timedelta(hours=2), "Europe/Berlin"),
|
||||
}
|
||||
|
||||
|
||||
def html_nametag(uid, name):
|
||||
return f'<a href="https://matrix.to/#/{escape(uid)}">{escape(name)}</a>'
|
||||
|
||||
|
||||
async def reply(
|
||||
message,
|
||||
plain: Optional[str] = None,
|
||||
*,
|
||||
html: Optional[str] = None,
|
||||
with_name: bool = False,
|
||||
in_thread: bool = False,
|
||||
) -> nio.RoomSendResponse:
|
||||
"""Reply to the given message in plain text or HTML.
|
||||
|
||||
If with_name is set the reply will be prefixed with the original
|
||||
message's sender's name.
|
||||
If in_thread is set the reply will reference the original message,
|
||||
allowing a client to connect the messages unambiguously.
|
||||
"""
|
||||
assert plain or html
|
||||
if with_name:
|
||||
if plain:
|
||||
plain = f"{message.sender_name}: {plain}"
|
||||
if html:
|
||||
sender = html_nametag(message.event.sender, message.sender_name)
|
||||
html = f"{sender}: {html}"
|
||||
args = {}
|
||||
if in_thread:
|
||||
args["reply_to_event_id"] = message.event.event_id
|
||||
if html:
|
||||
args["html"] = html
|
||||
if plain:
|
||||
args["plain"] = plain
|
||||
return await send_message(message.app.client, message.room.room_id, **args)
|
||||
|
||||
|
||||
async def react(message, emoji: str) -> nio.RoomSendResponse:
|
||||
"""Annotate a message with an Emoji.
|
||||
|
||||
Only a single Emoji character should be used for the reaction; compound
|
||||
characters are ok.
|
||||
"""
|
||||
# For details on Matrix reactions see MSC2677:
|
||||
# - https://github.com/uhoreg/matrix-doc/blob/aggregations-reactions/proposals/2677-reactions.md
|
||||
# - and https://github.com/matrix-org/matrix-appservice-slack/pull/522/commits/88e7076a595509b196f53369102a469cace6cc19
|
||||
vs16 = "\ufe0f" # see https://en.wikipedia.org/wiki/Variation_Selectors_%28Unicode_block%29
|
||||
if not emoji.endswith(vs16):
|
||||
if len(emoji) != 1:
|
||||
log.warning("Reactions should only use a single emoji, got: %s", emoji)
|
||||
else:
|
||||
emoji = unicodedata.normalize("NFC", emoji + vs16)
|
||||
return await message.app.client.room_send(
|
||||
room_id=message.room.room_id,
|
||||
message_type="m.reaction",
|
||||
content={
|
||||
"m.relates_to": {
|
||||
"rel_type": "m.annotation",
|
||||
"event_id": message.event.event_id,
|
||||
"key": emoji,
|
||||
}
|
||||
},
|
||||
ignore_unverified_devices=True,
|
||||
)
|
||||
|
||||
|
||||
async def redact(message, reason: Optional[str] = None) -> nio.RoomRedactResponse:
|
||||
"""Redact a message.
|
||||
|
||||
This allows not only to redact text messages, but also reactions, i.e.
|
||||
take back a reaction.
|
||||
"""
|
||||
resp = await message.app.client.room_redact(
|
||||
room_id=message.room.room_id, event_id=message.event.event_id, reason=reason
|
||||
)
|
||||
if isinstance(resp, nio.RoomRedactError):
|
||||
raise RuntimeError(
|
||||
f"Could not redact: {message.event.event_id}: {resp.message}"
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
async def send_message(
|
||||
client: nio.AsyncClient,
|
||||
room_id: str,
|
||||
plain: str = "",
|
||||
*,
|
||||
html: Optional[str] = None,
|
||||
as_notice: bool = True,
|
||||
reply_to_event_id: Optional[str] = None,
|
||||
) -> nio.RoomSendResponse:
|
||||
"""Send text to a matrix room.
|
||||
|
||||
The message can be given as either plain text or HTML.
|
||||
If the plain text variant is not explicitly given, it will be
|
||||
generated from the rich text variant.
|
||||
You should always send the message as notice. Per convention
|
||||
bots don't react to notices, so sending only notices will avoid
|
||||
infinite loops between multiple bots in a channel.
|
||||
"""
|
||||
assert plain or html
|
||||
|
||||
content = {
|
||||
"msgtype": "m.notice" if as_notice else "m.text",
|
||||
"body": plain,
|
||||
}
|
||||
|
||||
if html:
|
||||
content["format"] = "org.matrix.custom.html"
|
||||
content["formatted_body"] = html
|
||||
if not plain:
|
||||
content["body"] = strip_tags(html)
|
||||
|
||||
if reply_to_event_id:
|
||||
content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_event_id}}
|
||||
|
||||
try:
|
||||
return await client.room_send(
|
||||
room_id,
|
||||
"m.room.message",
|
||||
content,
|
||||
ignore_unverified_devices=True,
|
||||
)
|
||||
except nio.SendRetryError:
|
||||
log.exception(f"Unable to send message to room: {room_id}")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def localized(lc: str, category=locale.LC_ALL):
|
||||
locale.setlocale(category, lc)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
locale.resetlocale(category)
|
||||
|
||||
|
||||
def localizedtz(dt: datetime, fmt: str, lc: str, tzname: str):
|
||||
tz = tzdb[tzname]
|
||||
with localized(lc, locale.LC_TIME):
|
||||
return dt.astimezone(tz).strftime(fmt)
|
||||
|
||||
|
||||
class TextonlyParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.__text = StringIO()
|
||||
|
||||
def handle_data(self, d):
|
||||
self.__text.write(d)
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
return self.__text.getvalue()
|
||||
|
||||
|
||||
def strip_tags(html):
|
||||
s = TextonlyParser()
|
||||
s.feed(html)
|
||||
return s.text
|
||||
|
||||
|
||||
def clamp(lower, x, upper):
|
||||
return max(lower, min(x, upper))
|
||||
131
hotdog/models.py
Normal file
131
hotdog/models.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from random import random
|
||||
from time import time as now
|
||||
from typing import *
|
||||
|
||||
import nio
|
||||
|
||||
JobCallback = Callable[["Job"], None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Job:
|
||||
app: "Bot"
|
||||
title: str
|
||||
func: JobCallback
|
||||
every: Optional[float] = None
|
||||
jitter: float = 0.0
|
||||
next: Optional[float] = None
|
||||
task: Optional[asyncio.Task] = None
|
||||
|
||||
def _next(self) -> Optional[float]:
|
||||
if self.every is None:
|
||||
return None
|
||||
return now() + self.every + self._jitter(self.jitter)
|
||||
|
||||
@staticmethod
|
||||
def _jitter(x: float) -> float:
|
||||
return 2 * x * random() - x
|
||||
|
||||
|
||||
class Tokens(tuple):
|
||||
def get(self, pos, default=None):
|
||||
try:
|
||||
return self[pos]
|
||||
except:
|
||||
return default
|
||||
|
||||
def int(self, pos, default=0):
|
||||
try:
|
||||
return abs(int(self[pos]))
|
||||
except:
|
||||
return default
|
||||
|
||||
def str(self, pos, default=""):
|
||||
try:
|
||||
return str(self[pos])
|
||||
except:
|
||||
return default
|
||||
|
||||
def startswith(self, args: Union[str, List[str]]):
|
||||
if isinstance(args, str):
|
||||
args = Tokens.from_str(args)
|
||||
return self[: len(args)] == args
|
||||
|
||||
@classmethod
|
||||
def from_str(cls, s):
|
||||
return cls(t for t in s.split() if t)
|
||||
|
||||
def __getitem__(self, index):
|
||||
result = super().__getitem__(index)
|
||||
return Tokens(result) if isinstance(index, slice) else result
|
||||
|
||||
|
||||
@dataclass
|
||||
class Message:
|
||||
app: "Bot"
|
||||
text: str
|
||||
room: nio.rooms.MatrixRoom
|
||||
event: nio.events.room_events.RoomMessageText
|
||||
tokens: Tokens = None
|
||||
words: Tokens = None
|
||||
is_for_me: bool = False
|
||||
command: Optional[str] = None
|
||||
args: Optional[Tokens] = None
|
||||
|
||||
@property
|
||||
def sender_name(self) -> str:
|
||||
return self.room.user_name(self.event.sender)
|
||||
|
||||
@property
|
||||
def my_name(self) -> str:
|
||||
return self.room.user_name(self.app.client.user)
|
||||
|
||||
@property
|
||||
def is_direct_chat(self):
|
||||
# "A group is an unnamed room with no canonical alias."
|
||||
return self.room.is_group and self.room.member_count == 2
|
||||
|
||||
def __post_init__(self):
|
||||
self.tokens = Tokens.from_str(self.text)
|
||||
self.words = self.tokens
|
||||
|
||||
"""
|
||||
.roll d20
|
||||
hotdog: roll d20
|
||||
hotdog, roll d20
|
||||
@hotdog roll d20
|
||||
@hotdog : roll d20
|
||||
"""
|
||||
|
||||
first_arg = self.tokens.str(0)
|
||||
if self.text.startswith(self.app.config.command_prefix):
|
||||
self.command = first_arg[len(self.app.config.command_prefix) :]
|
||||
self.args = self.tokens[1:]
|
||||
self.words = self.args
|
||||
return
|
||||
|
||||
me = self.my_name
|
||||
if me and me.lower() == first_arg.lstrip("@").rstrip(":,!").lower():
|
||||
self.is_for_me = True
|
||||
if self.tokens.str(1) in ":,!":
|
||||
args = self.tokens[2:]
|
||||
else:
|
||||
args = self.tokens[1:]
|
||||
self.words = args
|
||||
self.command, self.args = args.get(0), args[1:]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Reaction(nio.Event):
|
||||
related_event_id: str = field()
|
||||
key: str = field()
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, event_dict):
|
||||
return cls(
|
||||
event_dict,
|
||||
event_dict["content"]["m.relates_to"]["event_id"],
|
||||
event_dict["content"]["m.relates_to"]["key"],
|
||||
)
|
||||
51
hotdog/tz.py
Normal file
51
hotdog/tz.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import calendar
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _last_of_month(calendar_day: int, year: int, month: int) -> int:
|
||||
last = None
|
||||
for date, day in calendar.Calendar().itermonthdays2(year, month):
|
||||
if day == calendar_day and date != 0:
|
||||
last = date
|
||||
if last is None:
|
||||
raise RuntimeError(
|
||||
"No such calendar day found: {calendar_day}".format(
|
||||
calendar_day=calendar_day
|
||||
)
|
||||
)
|
||||
return last
|
||||
|
||||
|
||||
_cest = timezone(timedelta(hours=2), "CEST")
|
||||
_cet = timezone(timedelta(hours=1), "CET")
|
||||
|
||||
|
||||
def cest(date: Optional[datetime] = None) -> timezone:
|
||||
"""
|
||||
Return the timezone for Central European (Summer) Time.
|
||||
|
||||
Automatically selects CET/CEST based on the given date.
|
||||
|
||||
Since 1996, European Summer Time has been observed from the last Sunday
|
||||
in March to the last Sunday in October.
|
||||
"""
|
||||
if date is None:
|
||||
date = datetime.now()
|
||||
if date.year < 1996:
|
||||
raise NotImplementedError()
|
||||
march = 3
|
||||
october = 10
|
||||
if march < date.month < october:
|
||||
return _cest
|
||||
if march > date.month or date.month > october:
|
||||
return _cet
|
||||
past_break = (
|
||||
date.day >= _last_of_month(calendar.SUNDAY, date.year, date.month)
|
||||
and date.hour > 2
|
||||
)
|
||||
if date.month == march:
|
||||
return _cest if past_break else _cet
|
||||
if date.month == october:
|
||||
return _cet if past_break else _cest
|
||||
raise RuntimeError("Month is out of bounds: {date.month}".format(date=date))
|
||||
Loading…
Add table
Add a link
Reference in a new issue