From 73d8dd543f79e27340e99f4ae5666228ef4a2735 Mon Sep 17 00:00:00 2001 From: Astra Date: Thu, 25 Jun 2026 10:16:10 +0100 Subject: [PATCH] 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):