Update files

This commit is contained in:
Astra 2026-06-30 15:53:08 +01:00
parent 73d8dd543f
commit a775afc177
5 changed files with 200 additions and 73 deletions

View file

@ -75,7 +75,7 @@ All commands are issued in the support group by replying to a forwarded message.
| Command | Description | | Command | Description |
| ------------ | ---------------------------------------------------- | | ------------ | ---------------------------------------------------- |
| `/info` | Show the user's name, username, and ID | | `/status` | Show the user's info, reply status, and ban state |
| `/ban` | Ban the user permanently | | `/ban` | Ban the user permanently |
| `/ban 7d` | Ban the user for a duration (`s`, `m`, `h`, `d`, `w`) | | `/ban 7d` | Ban the user for a duration (`s`, `m`, `h`, `d`, `w`) |
| `/unban` | Unban the user | | `/unban` | Unban the user |

View file

@ -1,8 +1,8 @@
# Telegram bot token # Telegram bot token
bot_token: "BOT_TOKEN_HERE" bot_token: "BOT_TOKEN_HERE"
# Database path # Database path (JSON file)
database: "./instance.db" database: "./instance.json"
# ID of support bot group where messages will go # ID of support bot group where messages will go
target_group: -12345678 target_group: -12345678
@ -19,3 +19,8 @@ reply_text: |
# How often to send reminders for unanswered messages, in minutes (default: 360) # How often to send reminders for unanswered messages, in minutes (default: 360)
# reminder_interval_minutes: 360 # reminder_interval_minutes: 360
# Quiet hours: suppress reminders between these hours (24h clock).
# At the end of quiet hours, one batch message is sent with links to all pending messages.
# quiet_hours_start: 0
# quiet_hours_end: 6

BIN
instance Normal file

Binary file not shown.

View file

@ -1,12 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import logging import logging
import yaml
import threading import threading
import sys import sys
import os
import shelve
import getopt import getopt
from pickle import Unpickler import yaml
from . import bot from . import bot
@ -25,22 +22,13 @@ def usage():
print(" -q Quiet, set log level to WARNING") print(" -q Quiet, set log level to WARNING")
print(" -c Location of config file (default: ./config.yaml)") print(" -c Location of config file (default: ./config.yaml)")
# well this is just dumb
class RenamingUnpickler(Unpickler):
def find_class(self, module, name):
if module in ("src.core", "supportbot.__main__", "__main__"):
module = "supportbot.bot"
return super().find_class(module, name)
def main(configpath, loglevel=logging.INFO): def main(configpath, loglevel=logging.INFO):
with open(configpath, "r") as f: with open(configpath, "r") as f:
config = yaml.safe_load(f) config = yaml.safe_load(f)
logging.basicConfig(format="%(levelname)-7s [%(asctime)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S", level=loglevel) logging.basicConfig(format="%(levelname)-7s [%(asctime)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S", level=loglevel)
shelve.Unpickler = RenamingUnpickler db = bot.JsonDb(config["database"])
db = shelve.open(config["database"])
bot.init(config, db) bot.init(config, db)
try: try:
@ -48,8 +36,6 @@ def main(configpath, loglevel=logging.INFO):
start_new_thread(bot.run, join=True) start_new_thread(bot.run, join=True)
except KeyboardInterrupt: except KeyboardInterrupt:
logging.info("Interrupted, exiting") logging.info("Interrupted, exiting")
db.close()
os._exit(1)
if __name__ == "__main__": if __name__ == "__main__":
try: try:
@ -58,7 +44,6 @@ if __name__ == "__main__":
print(str(e)) print(str(e))
exit(1) exit(1)
# Process command line args
def readopt(name): def readopt(name):
for e in opts: for e in opts:
if e[0] == name: if e[0] == name:
@ -73,5 +58,4 @@ if __name__ == "__main__":
if readopt("-c") is not None: if readopt("-c") is not None:
configpath = readopt("-c") configpath = readopt("-c")
# Run the actual program
main(configpath, loglevel) main(configpath, loglevel)

View file

@ -2,6 +2,7 @@ import telebot
import logging import logging
import time import time
import json import json
import os
import threading import threading
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
@ -17,7 +18,7 @@ ALL_CONTENT_TYPES = ('animation', 'audio', 'contact', 'dice', 'document',
TMessage = telebot.types.Message TMessage = telebot.types.Message
bot: telebot.TeleBot = None bot: telebot.TeleBot = None
db = None db: 'JsonDb' = None
db_lock = threading.Lock() db_lock = threading.Lock()
bot_self_id: int = None bot_self_id: int = None
@ -27,9 +28,12 @@ welcome_text: str = None
reply_text: str = None reply_text: str = None
integration_fmt: Optional[str] = None integration_fmt: Optional[str] = None
reply_reminder_interval: timedelta = timedelta(hours=6) reply_reminder_interval: timedelta = timedelta(hours=6)
quiet_hours_start: Optional[int] = None
quiet_hours_end: Optional[int] = None
_in_quiet_hours: bool = False
def init(config: dict, _db): def init(config: dict, _db):
global bot, db, bot_self_id, target_group, message_thread_id, welcome_text, reply_text, integration_fmt, reply_reminder_interval global bot, db, bot_self_id, target_group, message_thread_id, welcome_text, reply_text, integration_fmt, reply_reminder_interval, quiet_hours_start, quiet_hours_end, _in_quiet_hours
if not config.get("bot_token"): if not config.get("bot_token"):
logging.error("No telegram token specified.") logging.error("No telegram token specified.")
exit(1) exit(1)
@ -47,6 +51,11 @@ def init(config: dict, _db):
integration_fmt = config.get("integration_fmt") integration_fmt = config.get("integration_fmt")
if config.get("reminder_interval_minutes"): if config.get("reminder_interval_minutes"):
reply_reminder_interval = timedelta(minutes=int(config["reminder_interval_minutes"])) reply_reminder_interval = timedelta(minutes=int(config["reminder_interval_minutes"]))
if config.get("quiet_hours_start") is not None:
quiet_hours_start = int(config["quiet_hours_start"])
if config.get("quiet_hours_end") is not None:
quiet_hours_end = int(config["quiet_hours_end"])
_in_quiet_hours = _is_quiet_hour(datetime.now())
set_handler(handle_msg, content_types=ALL_CONTENT_TYPES) set_handler(handle_msg, content_types=ALL_CONTENT_TYPES)
bot_self_id = bot.get_me().id bot_self_id = bot.get_me().id
@ -70,6 +79,22 @@ def run():
logging.warning("%s while polling Telegram, retrying.", type(e).__name__) logging.warning("%s while polling Telegram, retrying.", type(e).__name__)
time.sleep(1) time.sleep(1)
def callwrapper_ex(f):
"""Like callwrapper but also returns the Telegram error text on failure."""
while True:
try:
f()
except telebot.apihelper.ApiException as e:
status = check_telegram_exc(e)
if not status:
continue
try:
err = json.loads(e.result.text).get("description", e.result.text)
except Exception:
err = e.result.text
return status, err
return None, None
def callwrapper(f) -> Optional[str]: def callwrapper(f) -> Optional[str]:
while True: while True:
try: try:
@ -94,21 +119,50 @@ def check_telegram_exc(e):
time.sleep(d) time.sleep(d)
return False # retry return False # retry
logging.exception("API exception") logging.error("Telegram API error: %s", e.result.text)
return "exception" return "exception"
### db ### db
class JsonDb:
def __init__(self, path: str):
self.path = path
self._data = {"users": {}, "msg_map": {}}
if os.path.exists(path):
with open(path) as f:
self._data = json.load(f)
def _save(self):
with open(self.path, "w") as f:
json.dump(self._data, f, indent=2)
def get_user(self, user_id: int) -> Optional['User']:
d = self._data["users"].get(str(user_id))
return User.from_dict(d) if d is not None else None
def set_user(self, user: 'User'):
self._data["users"][str(user.id)] = user.to_dict()
self._save()
def get_msg_user(self, msg_id: int) -> Optional[int]:
return self._data["msg_map"].get(str(msg_id))
def set_msg_user(self, msg_id: int, user_id: int):
self._data["msg_map"][str(msg_id)] = user_id
self._save()
def all_users(self) -> list:
return [User.from_dict(d) for d in self._data["users"].values()]
class ModificationContext(): class ModificationContext():
def __init__(self, key, obj): def __init__(self, obj):
self.key = key
self.obj = obj self.obj = obj
def __enter__(self) -> 'User': def __enter__(self) -> 'User':
return self.obj return self.obj
def __exit__(self, exc_type, *_): def __exit__(self, exc_type, *_):
if exc_type is None: if exc_type is None:
with db_lock: with db_lock:
db[self.key] = self.obj db.set_user(self.obj)
class User(): class User():
id: int id: int
@ -138,37 +192,50 @@ class User():
self.last_messaged = datetime(1970, 1, 1) self.last_messaged = datetime(1970, 1, 1)
self.replied_to = True self.replied_to = True
self.last_reminder_sent = None self.last_reminder_sent = None
def to_dict(self) -> dict:
# this is kinda shit return {
db_last_sync = 0 "id": self.id,
def db_auto_sync(): "username": self.username,
global db_last_sync "realname": self.realname,
now = int(time.time()) "last_messaged": self.last_messaged.isoformat() if self.last_messaged else None,
if now > db_last_sync + 15: "banned_until": self.banned_until.isoformat() if self.banned_until else None,
db_last_sync = now "replied_to": self.replied_to,
with db_lock: "last_reminder_sent": self.last_reminder_sent.isoformat() if self.last_reminder_sent else None,
db.sync() "last_fwd_msg_id": self.last_fwd_msg_id,
# }
@classmethod
def from_dict(cls, d: dict) -> 'User':
u = cls()
u.id = d["id"]
u.username = d.get("username")
u.realname = d.get("realname")
u.last_messaged = datetime.fromisoformat(d["last_messaged"]) if d.get("last_messaged") else datetime(1970, 1, 1)
u.banned_until = datetime.fromisoformat(d["banned_until"]) if d.get("banned_until") else None
u.replied_to = d.get("replied_to", True)
u.last_reminder_sent = datetime.fromisoformat(d["last_reminder_sent"]) if d.get("last_reminder_sent") else None
u.last_fwd_msg_id = d.get("last_fwd_msg_id")
return u
def db_get_user(id) -> User: def db_get_user(id) -> User:
with db_lock: with db_lock:
return db["u%d" % id] user = db.get_user(id)
if user is None:
raise KeyError(id)
return user
def db_modify_user(id, allow_new=False) -> ModificationContext: def db_modify_user(id, allow_new=False) -> ModificationContext:
key = "u%d" % id
with db_lock: with db_lock:
obj = db.get(key) obj = db.get_user(id)
if obj is None: if obj is None:
if allow_new: if allow_new:
obj = User() obj = User()
else: else:
raise KeyError raise KeyError(id)
return ModificationContext(key, obj) return ModificationContext(obj)
### Main stuff ### Main stuff
def handle_msg(ev: TMessage): def handle_msg(ev: TMessage):
db_auto_sync()
if ev.chat.type in ("group", "supergroup"): if ev.chat.type in ("group", "supergroup"):
if ev.chat.id == target_group: if ev.chat.id == target_group:
return handle_group(ev) return handle_group(ev)
@ -184,7 +251,7 @@ def handle_group(ev: TMessage):
return return
with db_lock: with db_lock:
user_id = db.get("m%d" % ev.reply_to_message.message_id) user_id = db.get_msg_user(ev.reply_to_message.message_id)
logging.debug("found id = %d mapped to user %s", ev.reply_to_message.message_id, user_id) logging.debug("found id = %d mapped to user %s", ev.reply_to_message.message_id, user_id)
if user_id is None: if user_id is None:
logging.warning("Couldn't find replied to message in target group") logging.warning("Couldn't find replied to message in target group")
@ -203,13 +270,23 @@ def handle_group(ev: TMessage):
return callwrapper(lambda: bot.send_message(target_group, message_thread_id=message_thread_id, text=msg)) return callwrapper(lambda: bot.send_message(target_group, message_thread_id=message_thread_id, text=msg))
# deliver message # deliver message
res = callwrapper(lambda: bot.copy_message(user_id, ev.chat.id, ev.message_id)) def copy_and_log():
if res == "blocked": result = bot.copy_message(user_id, ev.chat.id, ev.message_id)
callwrapper(lambda: bot.send_message(target_group, message_thread_id=message_thread_id, text="Bot was blocked by user.")) logging.info("copy_message response: %s", result)
res, err_text = callwrapper_ex(copy_and_log)
if res:
reply_id = ev.message_id
msg = "Failed to deliver: %s" % err_text
callwrapper(lambda: bot.send_message(target_group, message_thread_id=message_thread_id,
text=msg, reply_to_message_id=reply_id))
else:
with db_modify_user(user_id) as u:
u.replied_to = True
def handle_group_command(ev: TMessage, user_id: int, c: str, arg: str): def handle_group_command(ev: TMessage, user_id: int, c: str, arg: str):
if c == "info": if c == "status":
msg = format_user_info(db_get_user(user_id)) user = db_get_user(user_id)
msg = format_user_info(user) + "\n" + format_ticket_info(user)
return callwrapper(lambda: bot.send_message(target_group, message_thread_id=message_thread_id, text=msg, parse_mode="HTML")) return callwrapper(lambda: bot.send_message(target_group, message_thread_id=message_thread_id, text=msg, parse_mode="HTML"))
elif c == "ban": elif c == "ban":
delta = parse_timedelta(arg) delta = parse_timedelta(arg)
@ -285,7 +362,7 @@ def handle_private(ev: TMessage):
def f(user_id=user.id): def f(user_id=user.id):
ev2 = bot.forward_message(chat_id=target_group, from_chat_id=ev.chat.id, message_id=ev.message_id, message_thread_id=message_thread_id) ev2 = bot.forward_message(chat_id=target_group, from_chat_id=ev.chat.id, message_id=ev.message_id, message_thread_id=message_thread_id)
with db_lock: with db_lock:
db["m%d" % ev2.message_id] = user_id db.set_msg_user(ev2.message_id, user_id)
with db_modify_user(user_id) as u: with db_modify_user(user_id) as u:
u.last_fwd_msg_id = ev2.message_id u.last_fwd_msg_id = ev2.message_id
logging.debug("delivered msg from %s -> id = %d", user, ev2.message_id) logging.debug("delivered msg from %s -> id = %d", user, ev2.message_id)
@ -299,7 +376,7 @@ def handle_private(ev: TMessage):
with db_modify_user(user.id) as user: with db_modify_user(user.id) as user:
user.last_messaged = now user.last_messaged = now
user.replied_to = False user.replied_to = False
user.last_reminder_sent = now # first reminder fires REPLY_REMINDER_INTERVAL from now user.last_reminder_sent = now # first reminder fires reply_reminder_interval from now
def handle_private_command(ev: TMessage, user: User, c): def handle_private_command(ev: TMessage, user: User, c):
if c == "start": if c == "start":
@ -318,36 +395,79 @@ def reminder_loop():
logging.exception("Exception in reminder loop") logging.exception("Exception in reminder loop")
time.sleep(REMINDER_CHECK_INTERVAL) time.sleep(REMINDER_CHECK_INTERVAL)
def _is_quiet_hour(now: datetime) -> bool:
if quiet_hours_start is None or quiet_hours_end is None:
return False
h = now.hour
if quiet_hours_start <= quiet_hours_end:
return quiet_hours_start <= h < quiet_hours_end
return h >= quiet_hours_start or h < quiet_hours_end # wraps midnight
def _send_pending_reminders(): def _send_pending_reminders():
global _in_quiet_hours
if target_group is None: if target_group is None:
return return
now = datetime.now() now = datetime.now()
if _is_quiet_hour(now):
_in_quiet_hours = True
return
with db_lock: with db_lock:
keys = [k for k in db.keys() if k.startswith("u")] users = db.all_users()
to_remind = [] all_pending = []
for key in keys: due_for_reminder = []
user = db.get(key) for user in users:
if not isinstance(user, User): if user.replied_to:
continue continue
if getattr(user, 'replied_to', True): all_pending.append(user)
continue last_reminder = user.last_reminder_sent
last_reminder = getattr(user, 'last_reminder_sent', None) if last_reminder is None or now - last_reminder >= reply_reminder_interval:
if last_reminder is not None and now - last_reminder < reply_reminder_interval: due_for_reminder.append(user)
continue
to_remind.append(user) was_quiet = _in_quiet_hours
for user in to_remind: _in_quiet_hours = False
try:
_send_reminder(user, now) if was_quiet and all_pending:
except Exception: _send_batch_reminder(all_pending, now)
logging.exception("Failed to send reminder for user %d", user.id) else:
for user in due_for_reminder:
try:
_send_reminder(user, now)
except Exception:
logging.exception("Failed to send reminder for user %d", user.id)
def _send_batch_reminder(users: list, now: datetime):
group_str = str(target_group)
channel_id = group_str[4:] if group_str.startswith('-100') else group_str.lstrip('-')
lines = ["\U0001f514 Pending replies:"]
for user in users:
if user.last_fwd_msg_id is None:
continue
lines.append("https://t.me/c/%s/%d" % (channel_id, user.last_fwd_msg_id))
if len(lines) == 1:
return
msg_text = "\n".join(lines)
callwrapper(lambda: bot.send_message(chat_id=target_group, message_thread_id=message_thread_id,
text=msg_text))
for user in users:
if user.last_fwd_msg_id is None:
continue
with db_modify_user(user.id) as u:
u.replied_to = False
u.last_reminder_sent = now
logging.info("Sent batch reminder for %d users", len(users))
def _send_reminder(user: User, now: datetime): def _send_reminder(user: User, now: datetime):
fwd_msg_id = getattr(user, 'last_fwd_msg_id', None) if user.last_fwd_msg_id is None:
if fwd_msg_id is None:
return return
def f(): def f():
bot.send_message(chat_id=target_group, message_thread_id=message_thread_id, bot.send_message(chat_id=target_group, message_thread_id=message_thread_id,
text="\U0001f514 Pending reply", reply_to_message_id=fwd_msg_id) text="\U0001f514 Pending reply", reply_to_message_id=user.last_fwd_msg_id)
status = callwrapper(f) status = callwrapper(f)
if status: if status:
logging.warning("Reminder failed for user %d: %s", user.id, status) logging.warning("Reminder failed for user %d: %s", user.id, status)
@ -383,6 +503,24 @@ def escape_html(s):
def format_datetime(dt): def format_datetime(dt):
return dt.strftime("%Y-%m-%d %H:%M:%S") return dt.strftime("%Y-%m-%d %H:%M:%S")
def format_ticket_info(user: User):
now = datetime.now()
lines = []
if user.replied_to:
lines.append("Status: replied")
else:
lines.append("Status: <b>pending reply</b>")
if user.last_reminder_sent:
lines.append("Last reminder: %s" % format_datetime(user.last_reminder_sent))
if user.banned_until is not None:
if user.banned_until >= LONG_LONG_TIME:
lines.append("Ban: permanent")
elif user.banned_until > now:
lines.append("Ban: until %s" % format_datetime(user.banned_until))
else:
lines.append("Ban: expired")
return "\n".join(lines)
def format_user_info(user: User): def format_user_info(user: User):
realname = user.realname realname = user.realname
if not str_is_printable(realname): if not str_is_printable(realname):
@ -392,6 +530,6 @@ def format_user_info(user: User):
if user.username is not None: if user.username is not None:
s += " (@%s)" % escape_html(user.username) s += " (@%s)" % escape_html(user.username)
if integration_fmt: if integration_fmt:
s += "\n\u27a4 " + (integration_fmt % user.id) s += "\n " + (integration_fmt % user.id)
s += "\nID: <code>%d</code>" % user.id s += "\nID: <code>%d</code>" % user.id
return s return s