From a775afc177d7e918150da9d3451d75a661d5d927 Mon Sep 17 00:00:00 2001 From: Astra Date: Tue, 30 Jun 2026 15:53:08 +0100 Subject: [PATCH] Update files --- README.md | 2 +- config.yaml.example | 9 +- instance | Bin 0 -> 16384 bytes supportbot/__main__.py | 20 +--- supportbot/bot.py | 242 ++++++++++++++++++++++++++++++++--------- 5 files changed, 200 insertions(+), 73 deletions(-) create mode 100644 instance diff --git a/README.md b/README.md index 2c468f5..fca8ac2 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ All commands are issued in the support group by replying to a forwarded message. | 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 7d` | Ban the user for a duration (`s`, `m`, `h`, `d`, `w`) | | `/unban` | Unban the user | diff --git a/config.yaml.example b/config.yaml.example index 38e6cd2..dc53567 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -1,8 +1,8 @@ # Telegram bot token bot_token: "BOT_TOKEN_HERE" -# Database path -database: "./instance.db" +# Database path (JSON file) +database: "./instance.json" # ID of support bot group where messages will go target_group: -12345678 @@ -19,3 +19,8 @@ reply_text: | # How often to send reminders for unanswered messages, in minutes (default: 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 diff --git a/instance b/instance new file mode 100644 index 0000000000000000000000000000000000000000..f03fe98e2b3029b3d1103880728d018993291465 GIT binary patch literal 16384 zcmeI%!3l#v5Czc5(VLJ4EWr}&BE4Ba+OZ))-K^q3yyg&j!tTHxL>_$blTlfqS3_^nf1F z1A0IY=m9-&vmSWVTYdij?~jAu%w>D7-t4@paNv;dhdjuGJY)fR$Qt@V9^@el$V1l9 h5Aq-nSwJ4XHJlJ2K!5-N0t5&UAV7cs0RndxSOLec*~S0> literal 0 HcmV?d00001 diff --git a/supportbot/__main__.py b/supportbot/__main__.py index 140c8b1..121fc18 100755 --- a/supportbot/__main__.py +++ b/supportbot/__main__.py @@ -1,12 +1,9 @@ #!/usr/bin/env python3 import logging -import yaml import threading import sys -import os -import shelve import getopt -from pickle import Unpickler +import yaml from . import bot @@ -25,22 +22,13 @@ def usage(): print(" -q Quiet, set log level to WARNING") 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): with open(configpath, "r") as f: config = yaml.safe_load(f) logging.basicConfig(format="%(levelname)-7s [%(asctime)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S", level=loglevel) - shelve.Unpickler = RenamingUnpickler - db = shelve.open(config["database"]) - + db = bot.JsonDb(config["database"]) bot.init(config, db) try: @@ -48,8 +36,6 @@ def main(configpath, loglevel=logging.INFO): start_new_thread(bot.run, join=True) except KeyboardInterrupt: logging.info("Interrupted, exiting") - db.close() - os._exit(1) if __name__ == "__main__": try: @@ -58,7 +44,6 @@ if __name__ == "__main__": print(str(e)) exit(1) - # Process command line args def readopt(name): for e in opts: if e[0] == name: @@ -73,5 +58,4 @@ if __name__ == "__main__": if readopt("-c") is not None: configpath = readopt("-c") - # Run the actual program main(configpath, loglevel) diff --git a/supportbot/bot.py b/supportbot/bot.py index 6766bfe..1e24dc7 100644 --- a/supportbot/bot.py +++ b/supportbot/bot.py @@ -2,6 +2,7 @@ import telebot import logging import time import json +import os import threading from datetime import datetime, timedelta from typing import Optional @@ -17,7 +18,7 @@ ALL_CONTENT_TYPES = ('animation', 'audio', 'contact', 'dice', 'document', TMessage = telebot.types.Message bot: telebot.TeleBot = None -db = None +db: 'JsonDb' = None db_lock = threading.Lock() bot_self_id: int = None @@ -27,9 +28,12 @@ welcome_text: str = None reply_text: str = None integration_fmt: Optional[str] = None 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): - 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"): logging.error("No telegram token specified.") exit(1) @@ -47,6 +51,11 @@ def init(config: dict, _db): integration_fmt = config.get("integration_fmt") if config.get("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) bot_self_id = bot.get_me().id @@ -70,6 +79,22 @@ def run(): logging.warning("%s while polling Telegram, retrying.", type(e).__name__) 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]: while True: try: @@ -94,21 +119,50 @@ def check_telegram_exc(e): time.sleep(d) return False # retry - logging.exception("API exception") + logging.error("Telegram API error: %s", e.result.text) return "exception" ### 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(): - def __init__(self, key, obj): - self.key = key + def __init__(self, obj): self.obj = obj def __enter__(self) -> 'User': return self.obj def __exit__(self, exc_type, *_): if exc_type is None: with db_lock: - db[self.key] = self.obj + db.set_user(self.obj) class User(): id: int @@ -138,37 +192,50 @@ class User(): self.last_messaged = datetime(1970, 1, 1) self.replied_to = True self.last_reminder_sent = None - -# this is kinda shit -db_last_sync = 0 -def db_auto_sync(): - global db_last_sync - now = int(time.time()) - if now > db_last_sync + 15: - db_last_sync = now - with db_lock: - db.sync() -# + def to_dict(self) -> dict: + return { + "id": self.id, + "username": self.username, + "realname": self.realname, + "last_messaged": self.last_messaged.isoformat() if self.last_messaged else None, + "banned_until": self.banned_until.isoformat() if self.banned_until else None, + "replied_to": self.replied_to, + "last_reminder_sent": self.last_reminder_sent.isoformat() if self.last_reminder_sent else None, + "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: 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: - key = "u%d" % id with db_lock: - obj = db.get(key) + obj = db.get_user(id) if obj is None: if allow_new: obj = User() else: - raise KeyError - return ModificationContext(key, obj) + raise KeyError(id) + return ModificationContext(obj) ### Main stuff def handle_msg(ev: TMessage): - db_auto_sync() if ev.chat.type in ("group", "supergroup"): if ev.chat.id == target_group: return handle_group(ev) @@ -184,7 +251,7 @@ def handle_group(ev: TMessage): return 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) if user_id is None: 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)) # deliver message - res = callwrapper(lambda: bot.copy_message(user_id, ev.chat.id, ev.message_id)) - if res == "blocked": - callwrapper(lambda: bot.send_message(target_group, message_thread_id=message_thread_id, text="Bot was blocked by user.")) + def copy_and_log(): + result = bot.copy_message(user_id, ev.chat.id, ev.message_id) + 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): - if c == "info": - msg = format_user_info(db_get_user(user_id)) + if c == "status": + 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")) elif c == "ban": delta = parse_timedelta(arg) @@ -285,7 +362,7 @@ def handle_private(ev: TMessage): 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) 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: u.last_fwd_msg_id = 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: user.last_messaged = now 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): if c == "start": @@ -318,36 +395,79 @@ def reminder_loop(): logging.exception("Exception in reminder loop") 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(): + global _in_quiet_hours if target_group is None: return now = datetime.now() + + if _is_quiet_hour(now): + _in_quiet_hours = True + return + with db_lock: - keys = [k for k in db.keys() if k.startswith("u")] - to_remind = [] - for key in keys: - user = db.get(key) - if not isinstance(user, User): - continue - if getattr(user, 'replied_to', True): - continue - last_reminder = getattr(user, 'last_reminder_sent', None) - if last_reminder is not None and now - last_reminder < reply_reminder_interval: - continue - to_remind.append(user) - for user in to_remind: - try: - _send_reminder(user, now) - except Exception: - logging.exception("Failed to send reminder for user %d", user.id) + users = db.all_users() + all_pending = [] + due_for_reminder = [] + for user in users: + if user.replied_to: + continue + all_pending.append(user) + last_reminder = user.last_reminder_sent + if last_reminder is None or now - last_reminder >= reply_reminder_interval: + due_for_reminder.append(user) + + was_quiet = _in_quiet_hours + _in_quiet_hours = False + + if was_quiet and all_pending: + _send_batch_reminder(all_pending, now) + 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): - fwd_msg_id = getattr(user, 'last_fwd_msg_id', None) - if fwd_msg_id is None: + if user.last_fwd_msg_id is None: return def f(): 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) if status: logging.warning("Reminder failed for user %d: %s", user.id, status) @@ -383,6 +503,24 @@ def escape_html(s): def format_datetime(dt): 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: pending reply") + 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): realname = user.realname if not str_is_printable(realname): @@ -392,6 +530,6 @@ def format_user_info(user: User): if user.username is not None: s += " (@%s)" % escape_html(user.username) if integration_fmt: - s += "\n\u27a4 " + (integration_fmt % user.id) + s += "\n➤ " + (integration_fmt % user.id) s += "\nID: %d" % user.id return s