Update files

This commit is contained in:
Astra 2026-06-25 10:16:10 +01:00
parent 81030d1297
commit 73d8dd543f
5 changed files with 191 additions and 9 deletions

11
Dockerfile Normal file
View file

@ -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"]

86
README.md Normal file
View file

@ -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: |
<b>Welcome</b>. 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.

View file

@ -16,3 +16,6 @@ welcome_text: |
# Message shown when user writes a message to bot (HTML) # Message shown when user writes a message to bot (HTML)
reply_text: | reply_text: |
Thank you for contacting us, we will reply to your message shortly. 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

View file

@ -28,7 +28,7 @@ def usage():
# well this is just dumb # well this is just dumb
class RenamingUnpickler(Unpickler): class RenamingUnpickler(Unpickler):
def find_class(self, module, name): def find_class(self, module, name):
if module == "src.core": if module in ("src.core", "supportbot.__main__", "__main__"):
module = "supportbot.bot" module = "supportbot.bot"
return super().find_class(module, name) return super().find_class(module, name)
@ -44,6 +44,7 @@ def main(configpath, loglevel=logging.INFO):
bot.init(config, db) bot.init(config, db)
try: try:
start_new_thread(bot.reminder_loop)
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")

View file

@ -2,12 +2,14 @@ import telebot
import logging import logging
import time import time
import json import json
import threading
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
LONG_LONG_TIME = datetime(2100, 1, 1) LONG_LONG_TIME = datetime(2100, 1, 1)
ID_REMIND_DURATION = timedelta(days=2) ID_REMIND_DURATION = timedelta(days=2)
BAN_NOTSENT_WARNING = timedelta(minutes=10) BAN_NOTSENT_WARNING = timedelta(minutes=10)
REMINDER_CHECK_INTERVAL = 300 # seconds between checks
ALL_CONTENT_TYPES = ('animation', 'audio', 'contact', 'dice', 'document', ALL_CONTENT_TYPES = ('animation', 'audio', 'contact', 'dice', 'document',
'game', 'location', 'photo', 'sticker', 'story', 'text', 'venue', 'video', 'game', 'location', 'photo', 'sticker', 'story', 'text', 'venue', 'video',
'video_note', 'voice') 'video_note', 'voice')
@ -16,6 +18,7 @@ TMessage = telebot.types.Message
bot: telebot.TeleBot = None bot: telebot.TeleBot = None
db = None db = None
db_lock = threading.Lock()
bot_self_id: int = None bot_self_id: int = None
target_group: Optional[int] = None target_group: Optional[int] = None
@ -23,9 +26,10 @@ message_thread_id: Optional[int] = None
welcome_text: str = None 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)
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 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"): if not config.get("bot_token"):
logging.error("No telegram token specified.") logging.error("No telegram token specified.")
exit(1) exit(1)
@ -41,6 +45,8 @@ def init(config: dict, _db):
welcome_text = config["welcome_text"] welcome_text = config["welcome_text"]
reply_text = config["reply_text"] reply_text = config["reply_text"]
integration_fmt = config.get("integration_fmt") 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) set_handler(handle_msg, content_types=ALL_CONTENT_TYPES)
bot_self_id = bot.get_me().id bot_self_id = bot.get_me().id
@ -101,7 +107,8 @@ class ModificationContext():
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:
db[self.key] = self.obj with db_lock:
db[self.key] = self.obj
class User(): class User():
id: int id: int
@ -109,12 +116,18 @@ class User():
realname: str realname: str
last_messaged: datetime last_messaged: datetime
banned_until: Optional[datetime] banned_until: Optional[datetime]
replied_to: bool
last_reminder_sent: Optional[datetime]
last_fwd_msg_id: Optional[int]
def __init__(self): def __init__(self):
self.id = None self.id = None
self.username = None self.username = None
self.realname = None self.realname = None
self.last_messaged = None self.last_messaged = None
self.banned_until = None self.banned_until = None
self.replied_to = True
self.last_reminder_sent = None
self.last_fwd_msg_id = None
def __eq__(self, other): def __eq__(self, other):
if isinstance(other, User): if isinstance(other, User):
return self.id == other.id return self.id == other.id
@ -123,6 +136,8 @@ class User():
return "<User id=%d>" % self.id return "<User id=%d>" % self.id
def defaults(self): def defaults(self):
self.last_messaged = datetime(1970, 1, 1) self.last_messaged = datetime(1970, 1, 1)
self.replied_to = True
self.last_reminder_sent = None
# this is kinda shit # this is kinda shit
db_last_sync = 0 db_last_sync = 0
@ -131,15 +146,18 @@ def db_auto_sync():
now = int(time.time()) now = int(time.time())
if now > db_last_sync + 15: if now > db_last_sync + 15:
db_last_sync = now db_last_sync = now
db.sync() with db_lock:
db.sync()
# #
def db_get_user(id) -> User: 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: def db_modify_user(id, allow_new=False) -> ModificationContext:
key = "u%d" % id key = "u%d" % id
obj = db.get(key) with db_lock:
obj = db.get(key)
if obj is None: if obj is None:
if allow_new: if allow_new:
obj = User() obj = User()
@ -165,7 +183,8 @@ def handle_group(ev: TMessage):
if ev.reply_to_message.from_user.id != bot_self_id: if ev.reply_to_message.from_user.id != bot_self_id:
return 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) 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")
@ -212,6 +231,12 @@ def handle_group_command(ev: TMessage, user_id: int, c: str, arg: str):
user.banned_until = None user.banned_until = None
msg = "User was unbanned." msg = "User was unbanned."
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))
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): def handle_private(ev: TMessage):
if target_group is None: 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")) 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): 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)
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) logging.debug("delivered msg from %s -> id = %d", user, ev2.message_id)
callwrapper(f) res = callwrapper(f)
if res == "blocked":
return
if reply_text: if reply_text:
callwrapper(lambda: bot.send_message(ev.chat.id, reply_text, parse_mode="HTML")) callwrapper(lambda: bot.send_message(ev.chat.id, reply_text, parse_mode="HTML"))
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.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":
@ -276,6 +308,55 @@ def handle_private_command(ev: TMessage, user: User, c):
elif c == "stop": elif c == "stop":
return True 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 ### Helpers
def str_is_printable(s): def str_is_printable(s):