Compare commits

...

4 Commits

Author SHA256 Message Date
dff
1468fad783 Delete arch/sh-bot.rar 2026-05-13 21:42:10 +00:00
dff
c89c839686 Delete arch/bot_old.py 2026-05-13 21:42:06 +00:00
c544fa5683 t push -u origin masterMerge branch 'master' of https://git.fingeri.ng/whiskers/Telegram_ShBot
Initial commit
2026-05-13 23:40:34 +02:00
8b053a7adb Initial commit 2026-05-13 23:38:18 +02:00
19 changed files with 6510 additions and 0 deletions

19
bot_state.py Normal file
View File

@@ -0,0 +1,19 @@
"""Shared in-memory state. Import from here; never create a second copy."""
import asyncio
submissions: dict = {}
counter: int = 0
daily_submissions: dict = {}
welcome_messages: dict = {}
welcome_lock: asyncio.Lock = asyncio.Lock()
chat_sessions: dict = {}
chat_message_map: dict = {}
banned_chat_users: set = set()
upload_prompt_tasks: dict = {}
upload_prompt_msg_ids: dict = {} # user_id -> last prompt message_id
submitting_users: set = set()
chatroom_semipublic_group_messages: dict = {}
known_usernames: dict[int, str | None] = {}
backup_hashes: dict[str, str] = {} # file_unique_id -> sha256
confirmed_users: set[int] = set()
blacklisted_words: list[str] = []

20
config.py Normal file
View File

@@ -0,0 +1,20 @@
TOKEN = "xxx"
CHATROOM_SEMIPUBLIC_GROUP_ID = -1003857747500 # SUPERGROUP CHATROOM SEMI-PUBLIC
MAIN_PUBLIC_CHANNEL_ID = -1003903492201 # BROADCAST CHANNEL, ONLY FOR MEDIA WITHOUT DISCUSSIONS, PUBLIC
CHAT_CONTACT_ADMIN_GROUP_ID = -1003926588340 # ADMIN DISCUSSION/CONTACT GROUP, PRIVATE
REVIEW_ADMIN_CHATROOM_SEMIPUBLIC_GROUP_ID = -1003917414873 # REVIEW GROUP, PRIVATE, ONLY FOR APPROVING/REJECTING SUBMISSIONS, NOT FOR DISCUSSIONS, ADMIN ONLY
CHATROOM_PRIVATE_BACKUP_GROUP_ID = -1003747166652 # PRIVATE BACKUP GROUP, NOT PUBLIC, ONLY FOR BACKUP PURPOSES, NOT FOR DISCUSSIONS, ADMIN ONLY
BLACKLISTED_WORDS_FILE = "./rsrcs/blacklist.txt"
BACKUP_IDS_FILE = "./rsrcs/backup_ids.json"
CONFIRMED_USERS_FILE = "./rsrcs/confirmed_users.json"
DAILY_SUBMISSION_LIMIT = 5
PURGE_INTERVAL_HOURS = 14 # 12 for complex high-risk groups, 18-24 for general groups, 36 for less known, secretive groups ect.
BOT_USERNAME = "harmfulmeowbot" # WITHOUT @, for captions and other purposes
INVITELINK_ARCH = "t.me/+8lo1VJuwVRwxOTk0" # INVITE LINK FOR ARCHIVE CHANNEL, NOT FOR DISCUSSIONS, PUBLIC
INVITELINK_CHAT = "https://t.me/+UMULzzHutGhhMmQ8" # INVITE LINK FOR CHATROOM, DISCUSSION SUPER GROUP, PUBLIC
BLACKLIST_MODE = 0 # 0 = delete message, 1 = delete original message and bot sends censored version

5
docs/requirements.md Normal file
View File

@@ -0,0 +1,5 @@
- python 3.12+
- pip with venv/pipx
- shell
- screen
- telegram - 4 groups + 1 channel or 5 groups recommended

90
docs/roadmap.md Normal file
View File

@@ -0,0 +1,90 @@
(- for each new submission, if the content is bigger than 35 media OR there has been over 20 messages in the CHATROOM_SEMIPUBLIC_GROUP_ID inbetween last submission and the new one, add one of the following messages randomly after submitting media and pin that msg:
- […], then delete the old warning and resend the following warning again immediately and pin it:
""
- add https://pypi.org/project/lexicont/ to monitor all new messages within 15 minute cycles, ML analyze and detect purge score of that convo blob ect.)
01/05/2026:
- first time warning does not work
- reindex command shall pull all OLD submission from the backup CHATROOM_PRIVATE_BACKUP_GROUP_ID as well and also, save the hashes in a json, also for new submissions
- formatting is not proper the way i expressed/want it, i.e. welcome, submitted ect.
- fix: blacklist displays censorship too complex ☑️
- links get censored instead of just deleted, even for admins ☑️
- add softban, permanent ban, softmute, mute, kick commands:
/sban @ or id, duration & time unit, reason (optional) -> bans user for certain time (save in db)
/smute @ or id, duration & time unit, reason (optional) -> mutes user for certain time
/mute @ or id, reason (optional) -> mutes user forever
/pban @ or id, reason (optional) -> bans user forever
/rmute @ or id -> revokes mute
/rban @ or id -> revokes ban
/kick @ or id, reason (optional) -> kicks a user once
- add better log to save every little action & command executed in functional db
- add sqlite3 integration
- make user bans when /ban is executed by admin be saved in the json, so even after restart they cannot contact admins again
- rework the button 'New Chat' to say/into 'Contact Administrators' ☑️
- fix/add auto delete leave messages basically delete the system messages like '... left the group' that telegram shows ☑️
- make when new 'Media submitted msg' is sent, delete the prior one (if user sends large portions of data) ☑️
- if a message contains a link and blacklisted word, do not send censored message with link, just delete old message as now someone could bypass the link blocker by just adding a slur word into their msg
- add a command to reload the config file that can only be executed by admins in any of the groups (except backup CHATROOM_PRIVATE_BACKUP_GROUP_ID), also make the config a dynamic json and add much more configuration to it, such as instead of harcoding what messages should look like etc. or invite links, make it fully configurable
- add multi group/channel/branch management via admin commands that let you change & view & reload the config within telegram
30/04/2026:
- purge chat CHATROOM_SEMIPUBLIC_GROUP_ID every 36hours, except for all messages from the bots
- refine messages, especially welcome & submission accepted into the following - new welcome message:
"@username (links to profile, or simply id if user has no username set) welcome to the official s3lfharm archive.
You can [view media](UNDERLINE) by either checking [pinned messages](BOLD) or the [media section](BOLD) if you click on the channel.
[Feel free](UNDERLINE) to share your own [s3lfharm imagery](SPOILER + ITALIC) using this bot ('this bot' shall hyperclickable link to https://t.me/selfharmmeowbot?start=submit).
[💋 Join the public archive
https://t.me/+8lo1VJuwVRwxOTk0 (S3LF HARM)](QUOTE)" - new submission accepted message/caption:
"This is an [anonymous](UNDERLINE) submission reviewed by admins.
You can apply having [self-harm imagery](SPOILER + ITALIC) posted using this bot. ('this bot' hyperlinks to https://t.me/selfharmmeowbot?start=submit)"
- monitor ALL changes of usernames of ALL users and send them in chat CHATROOM_SEMIPUBLIC_GROUP_ID, CHAT_CONTACT_ADMIN_GROUP_ID like:
"@old_username (clickable hyperlink to profile via id tho) changed username to @new_username (clickable hyperlink to profile via id again) at EXACT_TIME."
- turn bot actions in private chats into clickable style buttons directly under the message, not "official" telegram buttons in the message prompt
- use any kind of library etc. if possible to avoid duplicate sending of data, check entirety of CHATROOM_PRIVATE_BACKUP_GROUP_ID if that same video/picture submitted already exists, if it does and the submission is approved/rejected say additionally i.e.:
"Submitted content from @.../USERID contains x duplicates. Skipping those."
- on first time using bot user has to confirm:
"Hey!
This bot is affiliated with services offering extreme contents and services involving topics/ touching on topics such as [political controversies, gore, self-injury](ITALIC + SPOILER] ect.
[If](BOLD) you [acknowledge that](BOLD) and want to [proceed](BOLD), please tap '[Yes](BOLD + UNDERLINE)'.
This is solely a trigger warning that will only show up [once](UNDERLINE)."
- add a blacklist system working the following:
if a message contains any word from BLACKLISTED_WORDS_LIST, first apply REPLACEMENT_CHARSET on ALL blacklisted words in that messages, for those blacklisted words for the rest of the characters that have not been replaced by REPLACEMENT_CHARSET please make it apply either CHARTSET_1, CHARTSET_2, CHARTSET_3 or CHARTSET_4, delete the original message immediately and respond like this:
"Censored text from @.../USERID -> QUOTE 'i love 🅁 @ 🄿 🄴'"
or if user has no username set
"Censored text from USERID (which links to their profile) -> QUOTE 'i love 🅁 @ 🄿 🄴'"
and inside the quote make each blacklisted censored word a spoiler + between each letter of each blacklisted word must be two spaces
- also, delete ANY kind of LINK that is sent immediately, even the sneaky ones like google[.]com or t .me/ ect., except for admins, those are still allowed to send

3
docs/usage.md Normal file
View File

@@ -0,0 +1,3 @@
Usage
Execute `/bot/bin/python bot.py` (if /bot/ is your venv, otherwise just python/python3)

94
filters.py Normal file
View File

@@ -0,0 +1,94 @@
"""Charset helpers, blacklist censoring, link detection."""
import html
import random
import re
import bot_state as state
_CHARSET_1 = "🄰🄱🄲🄳🄴🄵🄶🄷🄸🄹🄺🄻🄼🄽🄾🄿🅀🅁🅂🅃🅄🅅🅆🅇🅈🅉"
_CHARSET_2 = "🅐🅑🅒🅓🅔🅕🅖🅗🅘🅙🅚🅛🅜🅝🅞🅟🅠🅡🅢🅣🅤🅥🅦🅧🅨🅩"
_CHARSET_3 = "ⒶⒷⒸⒹⒺⒻⒼⒽⒾⒿⓀⓁⓂⓃⓄⓅⓆⓇⓈⓉⓊⓋⓌⓍⓎⓏ"
_CHARSET_4 = "🇦🇧🇨🇩🇪🇫🇬🇭🇮🇯🇰🇱🇲🇳🇴🇵🇶🇷🇸🇹🇺🇻🇼🇽🇾🇿"
_CHARSETS = [_CHARSET_1, _CHARSET_2, _CHARSET_3, _CHARSET_4]
_REPLACEMENT: dict[str, str] = {
"a": "@", "e": "3",
"i": "1", "o": "0",
}
LINK_PATTERNS: list[re.Pattern] = [
re.compile(r"https?://", re.IGNORECASE),
re.compile(r"www\s*\.", re.IGNORECASE),
re.compile(r"t\s*[\[\(\.]\s*me\s*/", re.IGNORECASE),
re.compile(r"t\s+\.\s*me", re.IGNORECASE),
re.compile(r"\w+\s*\[\s*\.\s*\]\s*\w+", re.IGNORECASE),
re.compile(r"\w+\s*\(\s*\.\s*\)\s*\w+", re.IGNORECASE),
re.compile(r"\w+\s*\(\s*dot\s*\)\s*\w+", re.IGNORECASE),
re.compile(
r"\.(?:com|net|org|info|biz|name|pro|xyz|online|site|website|space|store|shop|blog|tech|dev|app|cloud|"
r"digital|solutions|systems|services|agency|group|company|center|world|global|today|live|life|news|media|"
r"network|social|community|zone|one|link|io|ai|co|ly|me|gg|tv|to|sh|fm|ws|cc|so|vc|it|page|software|tools|"
r"design|studio|lab|labs|build|engineering|data|systems|academy|care|finance|capital|fund|money|loan|loans|"
r"credit|insurance|investments|tax|accountants|law|legal|attorney|consulting|partners|ventures|holdings|"
r"management|marketing|media|press|events|productions|photos|photography|pictures|video|film|music|audio|"
r"games|game|play|fun|chat|dating|love|fans|family|kids|school|education|college|university|training|"
r"courses|institute|health|clinic|hospital|doctor|dentist|fitness|gym|yoga|diet|food|restaurant|cafe|"
r"coffee|bar|beer|wine|recipes|kitchen|cooking|fashion|style|clothing|shoes|jewelry|beauty|hair|makeup|"
r"salon|travel|trips|tours|vacations|holiday|flights|tickets|hotel|hostel|rentals|cars|car|auto|"
r"motorcycles|bike|bikes|taxi|delivery|express|logistics|shipping|realty|realestate|homes|house|rent|"
r"apartments|property|construction|builders|contractors|repair|cleaning|security|energy|solar|green|eco|"
r"farm|garden|flowers|pets|pet|dog|cat|animals|science|research|space|earth|energy|finance|bank|exchange|"
r"trade|trading|market|markets|crypto|bitcoin|eth|nft|art|gallery|design|graphics|print|books|library|"
r"wiki|guide|help|support|tools|download|software|app|cloud|host|hosting|server|email|mail|tech|network|"
r"systems|solutions|world|global|international|express|plus|pro|max|now|top|best|cool|fun|zone|land|city|"
r"place|town|country|uk|us|ca|au|de|fr|ru|cn|jp|kr|in|br|za|es|it|nl|se|no|fi|dk|pl|ch|be|at|ie|nz|mx|"
r"ar|cl|co|pe|pt|gr|tr|ae|sa|il|sg|hk|id|my|th|vn|ph|pk|bd|ng|ke|gh)\b",
re.IGNORECASE,
),
]
def censor_word(word: str) -> str:
charset = random.choice(_CHARSETS)
chars = []
for char in word:
lower = char.lower()
if lower in _REPLACEMENT:
chars.append(_REPLACEMENT[lower])
elif lower.isalpha():
idx = ord(lower) - ord("a")
chars.append(charset[idx] if 0 <= idx < len(charset) else char)
else:
chars.append(char)
chunks = ["".join(chars[i:i + 3]) for i in range(0, len(chars), 3)]
return " ".join(chunks)
def process_blacklisted_message(text: str) -> tuple[str, bool]:
if not state.blacklisted_words or not text:
return html.escape(text or ""), False
matches: list[tuple[int, int, str]] = []
for word in state.blacklisted_words:
for m in re.finditer(re.escape(word), text, re.IGNORECASE):
matches.append((m.start(), m.end(), m.group()))
if not matches:
return html.escape(text), False
matches.sort(key=lambda x: (x[0], -(x[1] - x[0])))
filtered, last_end = [], 0
for start, end, w in matches:
if start >= last_end:
filtered.append((start, end, w))
last_end = end
parts, pos = [], 0
for start, end, w in filtered:
parts.append(html.escape(text[pos:start]))
censored_words = [censor_word(tok) for tok in w.split()]
parts.append(f"<tg-spoiler>{' '.join(censored_words)}</tg-spoiler>")
pos = end
parts.append(html.escape(text[pos:]))
return "".join(parts), True
def contains_link(text: str) -> bool:
if not text:
return False
return any(p.search(text) for p in LINK_PATTERNS)

71
hashing.py Normal file
View File

@@ -0,0 +1,71 @@
"""
Media-hash helpers.
Strategy
--------
We never download files just to hash them. Instead we use Telegram's
file_unique_id as a stable, server-side content identifier two files
that are byte-for-byte identical always share the same file_unique_id,
regardless of who uploaded them or when.
The cache (backup_ids.json) stores:
{ "<file_unique_id>": "<sha256_hex>" }
"""
import hashlib
import logging
from aiogram import Bot
import bot_state as state
from persistence import save_backup_ids
logger = logging.getLogger(__name__)
def _hash(file_unique_id: str) -> str:
return hashlib.sha256(file_unique_id.encode()).hexdigest()
def register_file(file_unique_id: str) -> bool:
"""Add a file to the in-memory cache and persist it.
Returns True if the file was new, False if it was already known.
"""
if file_unique_id in state.backup_hashes:
return False
state.backup_hashes[file_unique_id] = _hash(file_unique_id)
save_backup_ids()
return True
def is_duplicate(file_unique_id: str) -> bool:
return file_unique_id in state.backup_hashes
def check_media_list(media: list[dict]) -> tuple[list[dict], list[dict]]:
"""Split a media list into (unique, duplicates)."""
unique, dupes = [], []
for item in media:
if is_duplicate(item.get("file_unique_id", "")):
dupes.append(item)
else:
unique.append(item)
return unique, dupes
async def preload_backup_hashes(bot: Bot, chat_id: int) -> None:
"""
Since the standard Bot API does not expose a bulk message history
endpoint for groups, we:
1. Load whatever is already in backup_ids.json (done by load_backup_ids).
2. On each new message in the backup group, register via register_file.
To do a full historical reindex, admins forward all older media back
into the backup group — the bot registers each file automatically.
"""
logger.info(
"Backup hash cache loaded from disk: %d known files. "
"New files arriving in the backup group will be indexed automatically.",
len(state.backup_hashes),
)

47
keyboards.py Normal file
View File

@@ -0,0 +1,47 @@
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
confirm_tos_kb = InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text="Yes", callback_data="tos_confirm")]]
)
anonymous_choice_kb = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="Yes", callback_data="menu_anon_yes")],
[InlineKeyboardButton(text="No", callback_data="menu_anon_no")],
]
)
confirm_kb = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="Submit", callback_data="submit")],
[InlineKeyboardButton(text="Cancel", callback_data="cancel")],
]
)
def menu_kb() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="📤 Upload media", callback_data="menu_upload")],
[InlineKeyboardButton(text="📩 Contact Administrators", callback_data="menu_chat")],
]
)
def admin_kb(sub_id: int) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="Approve", callback_data=f"a|{sub_id}")],
[InlineKeyboardButton(text="Reject", callback_data=f"r|{sub_id}")],
]
)
def publish_kb(sub_id: int) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="Broadcast", callback_data=f"p|b|{sub_id}")],
[InlineKeyboardButton(text="Send in discussion", callback_data=f"p|d|{sub_id}")],
[InlineKeyboardButton(text="Send in both", callback_data=f"p|both|{sub_id}")],
]
)

51
main.py Normal file
View File

@@ -0,0 +1,51 @@
"""Entry point — wires everything together."""
import asyncio
import logging
from aiogram import Bot, Dispatcher
from aiogram.fsm.storage.memory import MemoryStorage
from config import TOKEN
from hashing import preload_backup_hashes
from middlewares import TosMiddleware, UsernameTrackerMiddleware
from persistence import load_backup_ids, load_blacklist, load_confirmed_users
from routers import admin, group, private
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
async def main() -> None:
# 1. Load persisted data
load_backup_ids()
load_confirmed_users()
load_blacklist()
bot = Bot(token=TOKEN)
dp = Dispatcher(storage=MemoryStorage())
# 2. Log cached hashes
import bot_state as state
logger.info("Loaded %d file hashes from cache.", len(state.backup_hashes))
# 3. Middlewares
dp.update.outer_middleware(UsernameTrackerMiddleware())
dp.update.outer_middleware(TosMiddleware())
# 4. Routers
dp.include_router(private.router)
dp.include_router(admin.router)
dp.include_router(group.router)
# 5. Background tasks
asyncio.create_task(group.purge_chatroom_semipublic_group(bot))
logger.info("Bot starting…")
await dp.start_polling(bot)
if __name__ == "__main__":
asyncio.run(main())

75
middlewares.py Normal file
View File

@@ -0,0 +1,75 @@
"""Outer middlewares: username tracker + TOS gate."""
import logging
from datetime import datetime, timezone
from aiogram import BaseMiddleware, Bot, types
import bot_state as state
from config import CHAT_CONTACT_ADMIN_GROUP_ID, CHATROOM_SEMIPUBLIC_GROUP_ID
from keyboards import confirm_tos_kb
from persistence import save_confirmed_users
logger = logging.getLogger(__name__)
TOS_TEXT = (
"Hey! \n\n"
"This bot is affiliated with services offering extreme contents and services involving "
"topics / touching on topics such as "
"political controversies, gore, self-injury etc. \n\n"
"If you acknowledge that and want to proceed, "
"please tap 'Yes'.\n\n"
"This is solely a trigger warning that will only show up once."
)
class UsernameTrackerMiddleware(BaseMiddleware):
async def __call__(self, handler, event, data):
user: types.User | None = data.get("event_from_user")
if user:
old = state.known_usernames.get(user.id, "UNSET")
if old != "UNSET" and old != user.username:
old_m = f'@{old}' if old else str(user.id)
new_m = f'@{user.username}' if user.username else str(user.id)
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
text = f"{old_m} changed username to {new_m} at {now}."
bot: Bot = data["bot"]
for chat_id in (CHATROOM_SEMIPUBLIC_GROUP_ID, CHAT_CONTACT_ADMIN_GROUP_ID):
try:
await bot.send_message(chat_id, text, parse_mode="HTML")
except Exception:
logger.warning("Failed to broadcast username change to %s", chat_id)
state.known_usernames[user.id] = user.username
return await handler(event, data)
class TosMiddleware(BaseMiddleware):
"""Block all private interactions until the user confirms the TOS."""
async def __call__(self, handler, event, data):
user: types.User | None = data.get("event_from_user")
if not user:
return await handler(event, data)
is_private = (
(isinstance(event, types.Message) and event.chat.type == "private")
or (isinstance(event, types.CallbackQuery) and event.message and event.message.chat.type == "private")
)
if not is_private:
return await handler(event, data)
# Always let the TOS confirmation callback through FIRST
if isinstance(event, types.CallbackQuery) and event.data == "tos_confirm":
return await handler(event, data)
if user.id not in state.confirmed_users:
bot: Bot = data["bot"]
try:
await bot.send_message(user.id, TOS_TEXT, parse_mode="HTML", reply_markup=confirm_tos_kb)
except Exception:
logger.warning("Failed to send TOS to user %s", user.id)
if isinstance(event, types.CallbackQuery):
try:
await event.answer()
except Exception:
pass
return # gate: do NOT call handler
return await handler(event, data)

51
persistence.py Normal file
View File

@@ -0,0 +1,51 @@
import json
import os
import bot_state as state
from config import BACKUP_IDS_FILE, BLACKLISTED_WORDS_FILE, CONFIRMED_USERS_FILE
def load_backup_ids() -> None:
if not os.path.exists(BACKUP_IDS_FILE):
return
try:
with open(BACKUP_IDS_FILE) as f:
data = json.load(f)
state.backup_hashes = data if isinstance(data, dict) else {}
except Exception:
state.backup_hashes = {}
def save_backup_ids() -> None:
try:
with open(BACKUP_IDS_FILE, "w") as f:
json.dump(state.backup_hashes, f)
except Exception:
pass
def load_confirmed_users() -> None:
if not os.path.exists(CONFIRMED_USERS_FILE):
return
try:
with open(CONFIRMED_USERS_FILE) as f:
state.confirmed_users = set(json.load(f))
except Exception:
state.confirmed_users = set()
def save_confirmed_users() -> None:
try:
with open(CONFIRMED_USERS_FILE, "w") as f:
json.dump(list(state.confirmed_users), f)
except Exception:
pass
def load_blacklist() -> None:
if not os.path.exists(BLACKLISTED_WORDS_FILE):
return
try:
with open(BLACKLISTED_WORDS_FILE, encoding="utf-8") as f:
state.blacklisted_words = [ln.strip().lower() for ln in f if ln.strip()]
except Exception:
state.blacklisted_words = []

1
routers/__init__.py Normal file
View File

@@ -0,0 +1 @@
from routers import admin, group, private

190
routers/admin.py Normal file
View File

@@ -0,0 +1,190 @@
"""Handlers for admin review/contact groups."""
import logging
from aiogram import F, Router, types
from aiogram.filters import Command
import bot_state as state_store
from config import (
BOT_USERNAME,
CHAT_CONTACT_ADMIN_GROUP_ID,
CHATROOM_PRIVATE_BACKUP_GROUP_ID,
CHATROOM_SEMIPUBLIC_GROUP_ID,
MAIN_PUBLIC_CHANNEL_ID,
REVIEW_ADMIN_CHATROOM_SEMIPUBLIC_GROUP_ID,
)
from hashing import register_file
from keyboards import publish_kb
from utils import build_quote, get_admin_display_name, send_media_items
logger = logging.getLogger(__name__)
router = Router(name="admin")
# ── Submission approve / reject ───────────────────────────────────────────────
@router.callback_query(F.data.startswith("a|"))
async def approve(cb: types.CallbackQuery):
sub_id = int(cb.data.split("|")[1])
if sub_id not in state_store.submissions:
await cb.answer("Submission not found.", show_alert=True)
return
await cb.message.edit_reply_markup(reply_markup=publish_kb(sub_id))
await cb.answer()
@router.callback_query(F.data.startswith("p|"))
async def publish(cb: types.CallbackQuery):
parts = cb.data.split("|")
target = parts[1]
sub_id = int(parts[2])
submission = state_store.submissions.get(sub_id)
if not submission:
await cb.answer("Submission not found.", show_alert=True)
return
user_id = submission["user_id"]
media = submission["media"]
duplicate_media = [m for m in media if m.get("file_unique_id", "") in state_store.backup_hashes]
unique_media = [m for m in media if m.get("file_unique_id", "") not in state_store.backup_hashes]
if duplicate_media:
try:
user_info = await cb.bot.get_chat(user_id)
user_ref = (
f'@{user_info.username}'
if getattr(user_info, "username", None)
else str(user_id)
)
except Exception:
user_ref = str(user_id)
try:
await cb.bot.send_message(
REVIEW_ADMIN_CHATROOM_SEMIPUBLIC_GROUP_ID,
f"⚠️ Content from {user_ref} contains {len(duplicate_media)} duplicate(s). "
"Skipping those.",
parse_mode="HTML",
)
except Exception:
logger.warning("Could not send duplicate notice")
caption = (
"This is an anonymous submission reviewed by admins.\n\n"
"You can apply having self-harm imagery posted using "
f'<a href="https://t.me/{BOT_USERNAME}">this bot</a>.'
)
if unique_media:
await send_media_items(cb.bot, CHATROOM_PRIVATE_BACKUP_GROUP_ID, unique_media)
for item in unique_media:
register_file(item["file_unique_id"])
if target in {"d", "both"}:
await send_media_items(cb.bot, CHATROOM_SEMIPUBLIC_GROUP_ID, unique_media, caption=caption)
if target in {"b", "both"}:
await send_media_items(cb.bot, MAIN_PUBLIC_CHANNEL_ID, unique_media, caption=caption)
try:
await cb.bot.send_message(user_id, "Your submission was approved ✅")
except Exception:
logger.warning("Could not notify user %s of approval", user_id)
state_store.submissions.pop(sub_id, None)
try:
await cb.message.edit_reply_markup()
except Exception:
pass
await cb.answer()
@router.callback_query(F.data.startswith("r|"))
async def reject(cb: types.CallbackQuery):
sub_id = int(cb.data.split("|")[1])
submission = state_store.submissions.get(sub_id)
if not submission:
await cb.answer("Submission not found.", show_alert=True)
return
try:
await cb.bot.send_message(submission["user_id"], "Your submission was rejected ❌")
except Exception:
logger.warning("Could not notify user %s of rejection", submission["user_id"])
state_store.submissions.pop(sub_id, None)
try:
await cb.message.edit_reply_markup()
except Exception:
pass
await cb.answer()
# ── Admin contact-group reply commands ────────────────────────────────────────
@router.message(F.chat.id == CHAT_CONTACT_ADMIN_GROUP_ID, F.reply_to_message, Command("stop"))
async def admin_stop_chat(message: types.Message):
route = state_store.chat_message_map.get(message.reply_to_message.message_id)
if not route:
return
user_id = route["user_id"]
if state_store.chat_sessions.pop(user_id, None):
try:
await message.bot.send_message(user_id, "An admin has closed this chat.")
except Exception:
pass
@router.message(F.chat.id == CHAT_CONTACT_ADMIN_GROUP_ID, F.reply_to_message, Command("ban"))
async def admin_ban_user(message: types.Message):
route = state_store.chat_message_map.get(message.reply_to_message.message_id)
if not route:
return
user_id = route["user_id"]
state_store.banned_chat_users.add(user_id)
if state_store.chat_sessions.pop(user_id, None):
try:
await message.bot.send_message(user_id, "An admin has closed this chat.")
except Exception:
pass
@router.message(F.chat.id == CHAT_CONTACT_ADMIN_GROUP_ID, F.reply_to_message, Command("private"))
async def admin_private_chat(message: types.Message):
route = state_store.chat_message_map.get(message.reply_to_message.message_id)
if not route:
return
user_id = route["user_id"]
username = (
f"@{message.from_user.username}"
if message.from_user.username
else message.from_user.full_name
)
try:
await message.bot.send_message(
user_id,
f"An admin wants to continue this chat privately. Feel free to text them at {username}, "
"or open a new chat here. This chat is now closed.",
)
except Exception:
pass
state_store.chat_sessions.pop(user_id, None)
@router.message(F.chat.id == CHAT_CONTACT_ADMIN_GROUP_ID, F.reply_to_message)
async def admin_reply(message: types.Message):
if message.text in {"/stop", "/ban", "/private"}:
return
route = state_store.chat_message_map.get(message.reply_to_message.message_id)
if not route:
return
user_id = route["user_id"]
if user_id not in state_store.chat_sessions:
return
quote = build_quote(route["quote"])
admin_name = get_admin_display_name(message.from_user)
try:
if message.text:
await message.bot.send_message(user_id, f"{admin_name}\n{quote}\n\n{message.text}")
else:
await message.bot.send_message(user_id, f"{admin_name}\n{quote}")
await message.bot.copy_message(
user_id, CHAT_CONTACT_ADMIN_GROUP_ID, message.message_id
)
except Exception:
logger.exception("Failed to relay admin reply to user %s", user_id)

186
routers/group.py Normal file
View File

@@ -0,0 +1,186 @@
"""Handlers for the semipublic chatroom and backup group."""
import asyncio
import logging
from aiogram import F, Router, types
from aiogram.filters import Command
import bot_state as state_store
from config import (
BLACKLIST_MODE,
CHATROOM_PRIVATE_BACKUP_GROUP_ID,
CHATROOM_SEMIPUBLIC_GROUP_ID,
PURGE_INTERVAL_HOURS,
INVITELINK_ARCH,
INVITELINK_CHAT,
BOT_USERNAME,
)
from filters import contains_link, process_blacklisted_message
from hashing import register_file
from utils import is_group_admin
logger = logging.getLogger(__name__)
router = Router(name="group")
# ── Background purge task ─────────────────────────────────────────────────────
async def purge_chatroom_semipublic_group(bot) -> None:
while True:
await asyncio.sleep(PURGE_INTERVAL_HOURS * 3600)
for message_id, is_bot_msg in list(state_store.chatroom_semipublic_group_messages.items()):
if is_bot_msg:
continue
try:
await bot.delete_message(CHATROOM_SEMIPUBLIC_GROUP_ID, message_id)
except Exception:
pass
state_store.chatroom_semipublic_group_messages.pop(message_id, None)
# ── Backup-group tracker ──────────────────────────────────────────────────────
@router.message(F.chat.id == CHATROOM_PRIVATE_BACKUP_GROUP_ID)
async def track_backup_group(message: types.Message):
if message.photo:
register_file(message.photo[-1].file_unique_id)
elif message.video:
register_file(message.video.file_unique_id)
elif message.document:
register_file(message.document.file_unique_id)
@router.message(
F.chat.id == CHATROOM_PRIVATE_BACKUP_GROUP_ID,
Command("reindex"),
)
async def reindex_backup(message: types.Message):
if not await is_group_admin(message.bot, CHATROOM_PRIVATE_BACKUP_GROUP_ID, message.from_user.id):
return
count = len(state_store.backup_hashes)
await message.reply(
f" Currently <b>{count}</b> files indexed in the hash cache.\n\n"
"The Bot API does not expose a bulk message history endpoint. "
"To do a full historical reindex, forward all older media back into "
"this group — the bot will register each file automatically as it arrives.",
parse_mode="HTML",
)
# ── Welcome / join request ────────────────────────────────────────────────────
@router.message(F.new_chat_members)
async def welcome(message: types.Message):
async with state_store.welcome_lock:
try:
await message.delete()
except Exception:
pass
for mid in state_store.welcome_messages.get(message.chat.id, []):
try:
await message.bot.delete_message(message.chat.id, mid)
except Exception:
pass
sent = []
for user in message.new_chat_members:
mention = (
f'@{user.username}' if user.username else str(user.id)
)
text = (
f"{mention} welcome to the official s3lfharm archive.\n\n"
"You can view media by checking pinned messages or the media section.\n\n"
"Feel free to share your own s3lfharm imagery using "
f'<a href="https://t.me/{BOT_USERNAME}">this bot</a>.\n\n'
f'\U0001f48b <a href="{INVITELINK_ARCH}">Join the public archive</a>\n\n'
f'<blockquote>S3LF HARM</blockquote>'
)
try:
msg = await message.bot.send_message(
message.chat.id,
text,
parse_mode="HTML",
disable_web_page_preview=True,
)
sent.append(msg.message_id)
except Exception:
logger.exception("Failed to send welcome in %s", message.chat.id)
state_store.welcome_messages[message.chat.id] = sent
@router.chat_join_request()
async def auto_approve_join_request(request: types.ChatJoinRequest):
try:
await request.bot.approve_chat_join_request(request.chat.id, request.from_user.id)
logger.info("Approved user %s to join %s", request.from_user.id, request.chat.id)
except Exception:
logger.exception("Failed to approve join request from %s", request.from_user.id)
# ── Auto-delete Telegram service/system messages in semipublic group ──────────
@router.message(
F.chat.id == CHATROOM_SEMIPUBLIC_GROUP_ID,
F.content_type.in_({
types.ContentType.NEW_CHAT_MEMBERS,
types.ContentType.LEFT_CHAT_MEMBER,
types.ContentType.NEW_CHAT_TITLE,
types.ContentType.NEW_CHAT_PHOTO,
types.ContentType.DELETE_CHAT_PHOTO,
types.ContentType.GROUP_CHAT_CREATED,
types.ContentType.SUPERGROUP_CHAT_CREATED,
types.ContentType.MESSAGE_AUTO_DELETE_TIMER_CHANGED,
types.ContentType.PINNED_MESSAGE,
}),
)
async def delete_service_messages(message: types.Message):
try:
await message.delete()
except Exception:
pass
# ── Semipublic group moderation ───────────────────────────────────────────────
@router.message(F.chat.id == CHATROOM_SEMIPUBLIC_GROUP_ID)
async def handle_semipublic_message(message: types.Message):
state_store.chatroom_semipublic_group_messages[message.message_id] = bool(
message.from_user and message.from_user.is_bot
)
if not message.from_user or message.from_user.is_bot:
return
# ── Define text first ────────────────────────────────────────────────────
text = message.text or message.caption or ""
# ── Blacklist check (all users including admins) ──────────────────────────
censored_text, was_censored = process_blacklisted_message(text)
if was_censored:
try:
await message.delete()
except Exception:
pass
sender = (
f'@{message.from_user.username}'
if message.from_user.username
else str(message.from_user.id)
)
if BLACKLIST_MODE == 1:
try:
await message.bot.send_message(
CHATROOM_SEMIPUBLIC_GROUP_ID,
f"Censored text from {sender}\n<blockquote>{censored_text}</blockquote>",
parse_mode="HTML",
)
except Exception:
logger.exception("Failed to send censor notice")
return
# ── Link check — admins are fully exempt ─────────────────────────────────
if await is_group_admin(message.bot, CHATROOM_SEMIPUBLIC_GROUP_ID, message.from_user.id):
return # admin: keep message as-is, no further checks
has_link_entity = any(
e.type in ("url", "text_link")
for e in (message.entities or []) + (message.caption_entities or [])
)
if has_link_entity or contains_link(text):
try:
await message.delete()
except Exception:
pass

240
routers/private.py Normal file
View File

@@ -0,0 +1,240 @@
"""Handlers for private (DM) interactions: TOS, menu, upload FSM, chat."""
import logging
import time
from aiogram import F, Router, types
from aiogram.filters import Command, CommandStart
from aiogram.fsm.context import FSMContext
import bot_state as state_store
from config import (
CHAT_CONTACT_ADMIN_GROUP_ID,
DAILY_SUBMISSION_LIMIT,
REVIEW_ADMIN_CHATROOM_SEMIPUBLIC_GROUP_ID,
INVITELINK_ARCH,
INVITELINK_CHAT,
BOT_USERNAME,
)
from keyboards import admin_kb, anonymous_choice_kb, confirm_kb, menu_kb
from persistence import save_confirmed_users
from states import ChatSetup, Upload
from utils import (
build_message_preview,
build_quote,
cancel_upload_prompt,
get_admin_display_name,
schedule_upload_prompt,
send_media_items,
start_chat_session,
)
logger = logging.getLogger(__name__)
router = Router(name="private")
# ── TOS confirm ───────────────────────────────────────────────────────────────
@router.callback_query(F.data == "tos_confirm")
async def tos_confirm_cb(cb: types.CallbackQuery):
state_store.confirmed_users.add(cb.from_user.id)
save_confirmed_users()
try:
await cb.message.edit_reply_markup(reply_markup=None)
except Exception:
pass
await cb.message.answer("Choose:", reply_markup=menu_kb())
await cb.answer()
# ── /start ────────────────────────────────────────────────────────────────────
@router.message(CommandStart())
async def cmd_start(message: types.Message, state: FSMContext):
await message.answer(
"The other videos/images can be found in the channel.\n"
"Channel invite links:\n"
f"[Archive]({INVITELINK_ARCH}) | [Chat]({INVITELINK_CHAT})",
parse_mode="Markdown",
)
await message.answer("Choose:", reply_markup=menu_kb())
# ── Menu buttons ──────────────────────────────────────────────────────────────
@router.callback_query(F.data == "menu_upload")
async def menu_upload_cb(cb: types.CallbackQuery, state: FSMContext):
if cb.from_user.id in state_store.chat_sessions:
await cb.answer(
"Your chat is active. Send /stop to return to the submission menu.",
show_alert=True,
)
return
cancel_upload_prompt(cb.from_user.id)
await state.set_state(Upload.waiting_media)
await state.update_data(media=[])
await cb.message.answer("Send photos or videos. You can send multiple, then press Submit.")
await cb.answer()
@router.callback_query(F.data == "menu_chat")
async def menu_chat_cb(cb: types.CallbackQuery, state: FSMContext):
if cb.from_user.id in state_store.banned_chat_users:
await cb.answer("You are not allowed to contact the administrators.", show_alert=True)
return
if cb.from_user.id in state_store.chat_sessions:
await cb.answer(
"Your chat is already active. Send /stop to close it.", show_alert=True
)
return
cancel_upload_prompt(cb.from_user.id)
await state.clear()
await state.set_state(ChatSetup.waiting_anonymous_choice)
await cb.message.answer("Do you want to remain anonymous?", reply_markup=anonymous_choice_kb)
await cb.answer()
# ── Anonymous choice ──────────────────────────────────────────────────────────
@router.callback_query(F.data == "menu_anon_yes")
async def menu_anon_yes_cb(cb: types.CallbackQuery, state: FSMContext):
await state.set_state(ChatSetup.waiting_anonymous_name)
await cb.message.answer("What name do you want to use?")
await cb.answer()
@router.callback_query(F.data == "menu_anon_no")
async def menu_anon_no_cb(cb: types.CallbackQuery, state: FSMContext):
await start_chat_session(cb.bot, cb.from_user, cb.message.answer, state)
await cb.answer()
@router.message(ChatSetup.waiting_anonymous_name, F.text)
async def anonymous_name(message: types.Message, state: FSMContext):
name = message.text.strip()
if not name:
await message.answer("Please send a valid name.")
return
await start_chat_session(message.bot, message.from_user, message.answer, state, anonymous_name=name)
# ── /stop ─────────────────────────────────────────────────────────────────────
@router.message(Command("stop"), F.chat.type == "private")
async def cmd_stop(message: types.Message, state: FSMContext):
cancel_upload_prompt(message.from_user.id)
state_store.upload_prompt_msg_ids.pop(message.from_user.id, None)
await state.clear()
if state_store.chat_sessions.pop(message.from_user.id, None):
await message.answer("Chat stopped.", reply_markup=menu_kb())
else:
await message.answer("Choose:", reply_markup=menu_kb())
# ── Media upload FSM ──────────────────────────────────────────────────────────
@router.message(Upload.waiting_media, F.photo | F.video)
async def handle_media(message: types.Message, state: FSMContext):
if message.photo:
file_id, file_unique_id, file_type = (
message.photo[-1].file_id,
message.photo[-1].file_unique_id,
"photo",
)
else:
file_id, file_unique_id, file_type = (
message.video.file_id,
message.video.file_unique_id,
"video",
)
data = await state.get_data()
media = data.get("media", [])
media.append({"file_id": file_id, "file_unique_id": file_unique_id, "type": file_type})
await state.update_data(media=media)
await schedule_upload_prompt(message.bot, message.chat.id, message.from_user.id)
@router.callback_query(F.data == "submit")
async def cb_submit(cb: types.CallbackQuery, state: FSMContext):
if cb.from_user.id in state_store.submitting_users:
await cb.answer()
return
state_store.submitting_users.add(cb.from_user.id)
try:
cancel_upload_prompt(cb.from_user.id)
state_store.upload_prompt_msg_ids.pop(cb.from_user.id, None)
data = await state.get_data()
media = list(data.get("media", []))
if not media:
await cb.answer("Send at least one photo or video first.", show_alert=True)
return
user = cb.from_user
current_day = int(time.time() // 86400)
user_limit = state_store.daily_submissions.get(user.id)
if not user_limit or user_limit["day"] != current_day:
user_limit = {"day": current_day, "count": 0}
state_store.daily_submissions[user.id] = user_limit
if user_limit["count"] >= DAILY_SUBMISSION_LIMIT:
await cb.message.answer(
f"You reached your daily limit of {DAILY_SUBMISSION_LIMIT} submissions. "
"Please try again tomorrow."
)
await cb.answer()
return
state_store.counter += 1
sub_id = state_store.counter
state_store.submissions[sub_id] = {"user_id": user.id, "media": media}
caption = "New submission from @" + (user.username or user.full_name)
try:
await send_media_items(cb.bot, REVIEW_ADMIN_CHATROOM_SEMIPUBLIC_GROUP_ID, media)
await cb.bot.send_message(
REVIEW_ADMIN_CHATROOM_SEMIPUBLIC_GROUP_ID,
caption,
reply_markup=admin_kb(sub_id),
)
except Exception:
logger.exception("Failed to forward submission %s to review group", sub_id)
user_limit["count"] += 1
await state.clear()
await cb.message.answer("Submitted ✅", reply_markup=menu_kb())
await cb.answer()
finally:
state_store.submitting_users.discard(cb.from_user.id)
@router.callback_query(F.data == "cancel")
async def cb_cancel(cb: types.CallbackQuery, state: FSMContext):
cancel_upload_prompt(cb.from_user.id)
state_store.upload_prompt_msg_ids.pop(cb.from_user.id, None)
await state.clear()
await cb.message.answer("Cancelled.", reply_markup=menu_kb())
await cb.answer()
# ── Active chat (DM → admin group) ───────────────────────────────────────────
@router.message(F.chat.type == "private")
async def active_chat_router(message: types.Message, state: FSMContext):
session = state_store.chat_sessions.get(message.from_user.id)
if not session:
return
preview = build_message_preview(message)
header = f"From: {session['display_name']}\n\n{preview}"
try:
if message.text:
sent = await message.bot.send_message(CHAT_CONTACT_ADMIN_GROUP_ID, header)
state_store.chat_message_map[sent.message_id] = {
"user_id": message.from_user.id,
"quote": preview,
}
else:
info_msg = await message.bot.send_message(CHAT_CONTACT_ADMIN_GROUP_ID, header)
copied_msg = await message.bot.copy_message(
CHAT_CONTACT_ADMIN_GROUP_ID, message.chat.id, message.message_id
)
for mid in (info_msg.message_id, copied_msg.message_id):
state_store.chat_message_map[mid] = {
"user_id": message.from_user.id,
"quote": preview,
}
except Exception:
logger.exception(
"Failed to forward chat message from user %s to admin group",
message.from_user.id,
)

5220
rsrcs/blacklist.txt Normal file

File diff suppressed because it is too large Load Diff

1
rsrcs/requirements.txt Normal file
View File

@@ -0,0 +1 @@
aiogram==3.27.0

11
states.py Normal file
View File

@@ -0,0 +1,11 @@
from aiogram.fsm.state import State, StatesGroup
class Upload(StatesGroup):
waiting_media = State()
confirm = State()
class ChatSetup(StatesGroup):
waiting_anonymous_choice = State()
waiting_anonymous_name = State()

135
utils.py Normal file
View File

@@ -0,0 +1,135 @@
"""Reusable async helpers shared across routers."""
import asyncio
import logging
from aiogram import Bot, types
from aiogram.types import InputMediaPhoto, InputMediaVideo
import bot_state as state
logger = logging.getLogger(__name__)
def get_chat_display_name(user: types.User, anonymous_name: str | None = None) -> str:
if anonymous_name:
return anonymous_name
if user.username:
return f"{user.full_name} (@{user.username}, {user.id})"
return f"{user.full_name} ({user.id})"
def get_admin_display_name(user: types.User) -> str:
if user.username:
return f"Admin: {user.full_name} (@{user.username})"
return f"Admin: {user.full_name}"
def build_message_preview(message: types.Message) -> str:
if message.text: return message.text
if message.photo: return f"[Photo]\n{message.caption}" if message.caption else "[Photo]"
if message.video: return f"[Video]\n{message.caption}" if message.caption else "[Video]"
if message.document: return f"[Document]\n{message.caption}" if message.caption else "[Document]"
if message.voice: return "[Voice message]"
if message.audio: return f"[Audio]\n{message.caption}" if message.caption else "[Audio]"
if message.sticker: return f"[Sticker] {message.sticker.emoji or ''}".strip()
if message.animation: return f"[Animation]\n{message.caption}" if message.caption else "[Animation]"
return "[Unsupported message]"
def build_quote(text: str | None) -> str:
return "\n".join("> " + line for line in (text or "[No text]").splitlines())
async def is_group_admin(bot: Bot, chat_id: int, user_id: int) -> bool:
try:
member = await bot.get_chat_member(chat_id, user_id)
return member.status in ("administrator", "creator")
except Exception:
logger.warning("Failed to check admin status for user %s in chat %s", user_id, chat_id)
return False
async def send_media_items(
bot: Bot, chat_id: int, media: list[dict], caption: str | None = None
) -> None:
if len(media) == 1:
item = media[0]
pm = "HTML" if caption else None
try:
if item["type"] == "photo":
await bot.send_photo(chat_id, item["file_id"], caption=caption, parse_mode=pm)
else:
await bot.send_video(chat_id, item["file_id"], caption=caption, parse_mode=pm)
except Exception:
logger.exception("Failed to send single media to %s", chat_id)
return
for i in range(0, len(media), 10):
chunk = media[i:i + 10]
group = []
for j, item in enumerate(chunk):
ic = caption if i == 0 and j == 0 else None
if item["type"] == "photo":
group.append(InputMediaPhoto(media=item["file_id"], caption=ic, parse_mode="HTML" if ic else None))
else:
group.append(InputMediaVideo(media=item["file_id"], caption=ic, parse_mode="HTML" if ic else None))
try:
await bot.send_media_group(chat_id, media=group)
except Exception:
logger.exception("Failed to send media group to %s", chat_id)
def cancel_upload_prompt(user_id: int) -> None:
task = state.upload_prompt_tasks.pop(user_id, None)
if task:
task.cancel()
async def schedule_upload_prompt(bot: Bot, chat_id: int, user_id: int) -> None:
cancel_upload_prompt(user_id)
async def _delayed() -> None:
try:
await asyncio.sleep(0.8)
from keyboards import confirm_kb
prev_msg_id = state.upload_prompt_msg_ids.pop(user_id, None)
if prev_msg_id:
try:
await bot.delete_message(chat_id, prev_msg_id)
except Exception:
pass
sent = await bot.send_message(
chat_id,
"Media added. Send more or confirm submission.",
reply_markup=confirm_kb,
)
state.upload_prompt_msg_ids[user_id] = sent.message_id
except asyncio.CancelledError:
pass
except Exception:
logger.exception("Failed to send upload prompt to %s", chat_id)
finally:
if state.upload_prompt_tasks.get(user_id) is task:
state.upload_prompt_tasks.pop(user_id, None)
task = asyncio.create_task(_delayed())
state.upload_prompt_tasks[user_id] = task
async def start_chat_session(
bot: Bot,
user: types.User,
send_func,
fsm_state,
anonymous_name: str | None = None,
) -> None:
await fsm_state.clear()
cancel_upload_prompt(user.id)
state.chat_sessions[user.id] = {
"display_name": get_chat_display_name(user, anonymous_name),
"anonymous": bool(anonymous_name),
}
try:
await send_func(
"Your chat has started. Every message you send will be forwarded to the admins. "
"Send /stop to return to the menu."
)
except Exception:
logger.exception("Failed to send chat-start message to %s", user.id)