From 73d8dd543f79e27340e99f4ae5666228ef4a2735 Mon Sep 17 00:00:00 2001 From: Astra Date: Thu, 25 Jun 2026 10:16:10 +0100 Subject: [PATCH 1/2] Update files --- Dockerfile | 11 +++++ README.md | 86 +++++++++++++++++++++++++++++++++++++ config.yaml.example | 3 ++ supportbot/__main__.py | 3 +- supportbot/bot.py | 97 ++++++++++++++++++++++++++++++++++++++---- 5 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 Dockerfile create mode 100644 README.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b332a83 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.12-alpine + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY supportbot/ ./supportbot/ + +ENTRYPOINT ["python", "-m", "supportbot"] +CMD ["-c", "/config.yaml"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2c468f5 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# tgsupportbot + +A Telegram support bot that forwards messages from users to a group chat, allowing a support team to respond privately. + +## How it works + +Users message the bot directly. The bot forwards each message to a configured support group (optionally into a specific topic thread). Support staff reply in the group by replying to the forwarded message; the bot delivers the reply back to the user. + +If a message goes unanswered, the bot sends a reminder to the group every 6 hours until the conversation is marked finished. + +## Setup + +### Docker (recommended) + +```sh +docker build -t tgsupportbot . +docker run -d \ + -v /path/to/config.yaml:/config.yaml \ + -v /path/to/data:/data \ + tgsupportbot +``` + +Set `database: "/data/instance.db"` in your config so the database persists outside the container. + +### Manual + +**Requirements:** Python 3, pip + +```sh +pip install -r requirements.txt +``` + +Copy `config.yaml.example` to `config.yaml` and fill in the values (see [Configuration](#configuration) below). + +Create a bot via [@BotFather](https://t.me/BotFather), add it to your support group, and give it permission to read and send messages. + +```sh +python -m supportbot -c config.yaml +``` + +## Configuration + +```yaml +# Telegram bot token from @BotFather +bot_token: "BOT_TOKEN_HERE" + +# Path to the database file +database: "./instance.db" + +# ID of the support group where messages are forwarded +target_group: -12345678 + +# Optional: topic thread ID to use within the group +message_thread_id: 1 + +# Message shown to users on /start (HTML) +welcome_text: | + Welcome. Please describe your issue. + +# Message shown after a user sends a message (HTML) +reply_text: | + Thanks, we'll get back to you shortly. + +# Optional: link template shown in the group alongside user info. +# %d is replaced with the user's Telegram ID. +integration_fmt: "https://example.com/users/%d" + +# Optional: how often to send reminders for unanswered messages, in minutes (default: 360) +reminder_interval_minutes: 360 +``` + +## Group commands + +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 | +| `/ban` | Ban the user permanently | +| `/ban 7d` | Ban the user for a duration (`s`, `m`, `h`, `d`, `w`) | +| `/unban` | Unban the user | +| `/finished` | Mark the conversation as resolved and stop reminders | + +## Reminders + +When a user sends a message, the bot starts a reminder timer. If `/finished` has not been used before the interval elapses, a reminder is posted to the group. Reminders repeat at the same interval until `/finished` is used. The timer resets if the user sends another message. diff --git a/config.yaml.example b/config.yaml.example index 15172fd..38e6cd2 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -16,3 +16,6 @@ welcome_text: | # Message shown when user writes a message to bot (HTML) reply_text: | Thank you for contacting us, we will reply to your message shortly. + +# How often to send reminders for unanswered messages, in minutes (default: 360) +# reminder_interval_minutes: 360 diff --git a/supportbot/__main__.py b/supportbot/__main__.py index e109535..140c8b1 100755 --- a/supportbot/__main__.py +++ b/supportbot/__main__.py @@ -28,7 +28,7 @@ def usage(): # well this is just dumb class RenamingUnpickler(Unpickler): def find_class(self, module, name): - if module == "src.core": + if module in ("src.core", "supportbot.__main__", "__main__"): module = "supportbot.bot" return super().find_class(module, name) @@ -44,6 +44,7 @@ def main(configpath, loglevel=logging.INFO): bot.init(config, db) try: + start_new_thread(bot.reminder_loop) start_new_thread(bot.run, join=True) except KeyboardInterrupt: logging.info("Interrupted, exiting") diff --git a/supportbot/bot.py b/supportbot/bot.py index cfb8bd5..6766bfe 100644 --- a/supportbot/bot.py +++ b/supportbot/bot.py @@ -2,12 +2,14 @@ import telebot import logging import time import json +import threading from datetime import datetime, timedelta from typing import Optional LONG_LONG_TIME = datetime(2100, 1, 1) ID_REMIND_DURATION = timedelta(days=2) BAN_NOTSENT_WARNING = timedelta(minutes=10) +REMINDER_CHECK_INTERVAL = 300 # seconds between checks ALL_CONTENT_TYPES = ('animation', 'audio', 'contact', 'dice', 'document', 'game', 'location', 'photo', 'sticker', 'story', 'text', 'venue', 'video', 'video_note', 'voice') @@ -16,6 +18,7 @@ TMessage = telebot.types.Message bot: telebot.TeleBot = None db = None +db_lock = threading.Lock() bot_self_id: int = None target_group: Optional[int] = None @@ -23,9 +26,10 @@ message_thread_id: Optional[int] = None welcome_text: str = None reply_text: str = None integration_fmt: Optional[str] = None +reply_reminder_interval: timedelta = timedelta(hours=6) def init(config: dict, _db): - global bot, db, bot_self_id, target_group, message_thread_id, welcome_text, reply_text, integration_fmt + global bot, db, bot_self_id, target_group, message_thread_id, welcome_text, reply_text, integration_fmt, reply_reminder_interval if not config.get("bot_token"): logging.error("No telegram token specified.") exit(1) @@ -41,6 +45,8 @@ def init(config: dict, _db): welcome_text = config["welcome_text"] reply_text = config["reply_text"] integration_fmt = config.get("integration_fmt") + if config.get("reminder_interval_minutes"): + reply_reminder_interval = timedelta(minutes=int(config["reminder_interval_minutes"])) set_handler(handle_msg, content_types=ALL_CONTENT_TYPES) bot_self_id = bot.get_me().id @@ -101,7 +107,8 @@ class ModificationContext(): return self.obj def __exit__(self, exc_type, *_): if exc_type is None: - db[self.key] = self.obj + with db_lock: + db[self.key] = self.obj class User(): id: int @@ -109,12 +116,18 @@ class User(): realname: str last_messaged: datetime banned_until: Optional[datetime] + replied_to: bool + last_reminder_sent: Optional[datetime] + last_fwd_msg_id: Optional[int] def __init__(self): self.id = None self.username = None self.realname = None self.last_messaged = None self.banned_until = None + self.replied_to = True + self.last_reminder_sent = None + self.last_fwd_msg_id = None def __eq__(self, other): if isinstance(other, User): return self.id == other.id @@ -123,6 +136,8 @@ class User(): return "" % self.id def defaults(self): self.last_messaged = datetime(1970, 1, 1) + self.replied_to = True + self.last_reminder_sent = None # this is kinda shit db_last_sync = 0 @@ -131,15 +146,18 @@ def db_auto_sync(): now = int(time.time()) if now > db_last_sync + 15: db_last_sync = now - db.sync() + with db_lock: + db.sync() # def db_get_user(id) -> User: - return db["u%d" % id] + with db_lock: + return db["u%d" % id] def db_modify_user(id, allow_new=False) -> ModificationContext: key = "u%d" % id - obj = db.get(key) + with db_lock: + obj = db.get(key) if obj is None: if allow_new: obj = User() @@ -165,7 +183,8 @@ def handle_group(ev: TMessage): if ev.reply_to_message.from_user.id != bot_self_id: return - user_id = db.get("m%d" % ev.reply_to_message.message_id) + with db_lock: + user_id = db.get("m%d" % 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") @@ -212,6 +231,12 @@ def handle_group_command(ev: TMessage, user_id: int, c: str, arg: str): user.banned_until = None msg = "User was unbanned." return callwrapper(lambda: bot.send_message(target_group, message_thread_id=message_thread_id, text=msg)) + elif c == "finished": + with db_modify_user(user_id) as user: + user.replied_to = True + user.last_reminder_sent = None + msg = "Marked as replied. Reminders stopped." + return callwrapper(lambda: bot.send_message(target_group, message_thread_id=message_thread_id, text=msg)) def handle_private(ev: TMessage): if target_group is None: @@ -259,15 +284,22 @@ def handle_private(ev: TMessage): callwrapper(lambda: bot.send_message(chat_id=target_group, message_thread_id=message_thread_id, text=msg, parse_mode="HTML")) 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) - db["m%d" % ev2.message_id] = user_id + with db_lock: + db["m%d" % 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) - callwrapper(f) + res = callwrapper(f) + if res == "blocked": + return if reply_text: callwrapper(lambda: bot.send_message(ev.chat.id, reply_text, parse_mode="HTML")) 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 def handle_private_command(ev: TMessage, user: User, c): if c == "start": @@ -276,6 +308,55 @@ def handle_private_command(ev: TMessage, user: User, c): elif c == "stop": return True +### Reminders + +def reminder_loop(): + while True: + try: + _send_pending_reminders() + except Exception: + logging.exception("Exception in reminder loop") + time.sleep(REMINDER_CHECK_INTERVAL) + +def _send_pending_reminders(): + if target_group is None: + return + now = datetime.now() + 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) + +def _send_reminder(user: User, now: datetime): + fwd_msg_id = getattr(user, 'last_fwd_msg_id', None) + if 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) + status = callwrapper(f) + if status: + logging.warning("Reminder failed for user %d: %s", user.id, status) + return + with db_modify_user(user.id) as u: + u.replied_to = False + u.last_reminder_sent = now + logging.info("Sent reminder for user %d", user.id) + ### Helpers def str_is_printable(s): From a775afc177d7e918150da9d3451d75a661d5d927 Mon Sep 17 00:00:00 2001 From: Astra Date: Tue, 30 Jun 2026 15:53:08 +0100 Subject: [PATCH 2/2] 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