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):