diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index b332a83..0000000 --- a/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index fca8ac2..0000000 --- a/README.md +++ /dev/null @@ -1,86 +0,0 @@ -# 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 | -| ------------ | ---------------------------------------------------- | -| `/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 | -| `/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 dc53567..15172fd 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -1,8 +1,8 @@ # Telegram bot token bot_token: "BOT_TOKEN_HERE" -# Database path (JSON file) -database: "./instance.json" +# Database path +database: "./instance.db" # ID of support bot group where messages will go target_group: -12345678 @@ -16,11 +16,3 @@ 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 - -# 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 deleted file mode 100644 index f03fe98..0000000 Binary files a/instance and /dev/null differ diff --git a/supportbot/__main__.py b/supportbot/__main__.py index 121fc18..e109535 100755 --- a/supportbot/__main__.py +++ b/supportbot/__main__.py @@ -1,9 +1,12 @@ #!/usr/bin/env python3 import logging +import yaml import threading import sys +import os +import shelve import getopt -import yaml +from pickle import Unpickler from . import bot @@ -22,20 +25,30 @@ 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 == "src.core": + 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) - db = bot.JsonDb(config["database"]) + shelve.Unpickler = RenamingUnpickler + db = shelve.open(config["database"]) + bot.init(config, db) try: - start_new_thread(bot.reminder_loop) start_new_thread(bot.run, join=True) except KeyboardInterrupt: logging.info("Interrupted, exiting") + db.close() + os._exit(1) if __name__ == "__main__": try: @@ -44,6 +57,7 @@ if __name__ == "__main__": print(str(e)) exit(1) + # Process command line args def readopt(name): for e in opts: if e[0] == name: @@ -58,4 +72,5 @@ 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 1e24dc7..cfb8bd5 100644 --- a/supportbot/bot.py +++ b/supportbot/bot.py @@ -2,15 +2,12 @@ import telebot import logging import time import json -import os -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') @@ -18,8 +15,7 @@ ALL_CONTENT_TYPES = ('animation', 'audio', 'contact', 'dice', 'document', TMessage = telebot.types.Message bot: telebot.TeleBot = None -db: 'JsonDb' = None -db_lock = threading.Lock() +db = None bot_self_id: int = None target_group: Optional[int] = None @@ -27,13 +23,9 @@ 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) -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, quiet_hours_start, quiet_hours_end, _in_quiet_hours + global bot, db, bot_self_id, target_group, message_thread_id, welcome_text, reply_text, integration_fmt if not config.get("bot_token"): logging.error("No telegram token specified.") exit(1) @@ -49,13 +41,6 @@ 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"])) - 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 @@ -79,22 +64,6 @@ 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: @@ -119,50 +88,20 @@ def check_telegram_exc(e): time.sleep(d) return False # retry - logging.error("Telegram API error: %s", e.result.text) + logging.exception("API exception") 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, obj): + def __init__(self, key, obj): + self.key = key self.obj = obj def __enter__(self) -> 'User': return self.obj def __exit__(self, exc_type, *_): if exc_type is None: - with db_lock: - db.set_user(self.obj) + db[self.key] = self.obj class User(): id: int @@ -170,18 +109,12 @@ 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 @@ -190,52 +123,34 @@ class User(): return "" % self.id def defaults(self): self.last_messaged = datetime(1970, 1, 1) - self.replied_to = True - self.last_reminder_sent = None - 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 + +# 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 + db.sync() +# def db_get_user(id) -> User: - with db_lock: - user = db.get_user(id) - if user is None: - raise KeyError(id) - return user + return db["u%d" % id] def db_modify_user(id, allow_new=False) -> ModificationContext: - with db_lock: - obj = db.get_user(id) + key = "u%d" % id + obj = db.get(key) if obj is None: if allow_new: obj = User() else: - raise KeyError(id) - return ModificationContext(obj) + raise KeyError + return ModificationContext(key, 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) @@ -250,8 +165,7 @@ def handle_group(ev: TMessage): if ev.reply_to_message.from_user.id != bot_self_id: return - with db_lock: - user_id = db.get_msg_user(ev.reply_to_message.message_id) + 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") @@ -270,23 +184,13 @@ def handle_group(ev: TMessage): return callwrapper(lambda: bot.send_message(target_group, message_thread_id=message_thread_id, text=msg)) # deliver message - 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 + 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 handle_group_command(ev: TMessage, user_id: int, c: str, arg: str): - if c == "status": - user = db_get_user(user_id) - msg = format_user_info(user) + "\n" + format_ticket_info(user) + if c == "info": + msg = format_user_info(db_get_user(user_id)) 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) @@ -308,12 +212,6 @@ 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: @@ -361,22 +259,15 @@ 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) - with db_lock: - 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 + db["m%d" % ev2.message_id] = user_id logging.debug("delivered msg from %s -> id = %d", user, ev2.message_id) - res = callwrapper(f) - if res == "blocked": - return + callwrapper(f) 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": @@ -385,98 +276,6 @@ 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 _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: - 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): - 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=user.last_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): @@ -503,24 +302,6 @@ 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): @@ -530,6 +311,6 @@ def format_user_info(user: User): if user.username is not None: s += " (@%s)" % escape_html(user.username) if integration_fmt: - s += "\n➤ " + (integration_fmt % user.id) + s += "\n\u27a4 " + (integration_fmt % user.id) s += "\nID: %d" % user.id return s