Compare commits
2 Commits
b3b0e124af
...
c544fa5683
| Author | SHA256 | Date | |
|---|---|---|---|
| c544fa5683 | |||
| 8b053a7adb |
132
arch/bot_old.py
Normal file
132
arch/bot_old.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import asyncio
|
||||||
|
from aiogram import Bot, Dispatcher, types, F
|
||||||
|
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
from aiogram.filters import CommandStart
|
||||||
|
from aiogram.fsm.storage.memory import MemoryStorage
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.fsm.state import StatesGroup, State
|
||||||
|
|
||||||
|
TOKEN = "8517883174:AAFA-dHF5Xm6q1mPQJA_eSBcyqDsaO0nPS4"
|
||||||
|
CHANNEL_ID = -1003749575740
|
||||||
|
REVIEW_ID = -1003911723791
|
||||||
|
|
||||||
|
bot = Bot(token=TOKEN)
|
||||||
|
dp = Dispatcher(storage=MemoryStorage())
|
||||||
|
|
||||||
|
submissions = {}
|
||||||
|
counter = 0
|
||||||
|
|
||||||
|
class Upload(StatesGroup):
|
||||||
|
waiting_media = State()
|
||||||
|
confirm = State()
|
||||||
|
|
||||||
|
start_kb = ReplyKeyboardMarkup(
|
||||||
|
keyboard=[
|
||||||
|
[KeyboardButton(text="Upload Photo")],
|
||||||
|
[KeyboardButton(text="Upload Video")]
|
||||||
|
],
|
||||||
|
resize_keyboard=True
|
||||||
|
)
|
||||||
|
|
||||||
|
confirm_kb = InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[InlineKeyboardButton(text="Submit", callback_data="submit")],
|
||||||
|
[InlineKeyboardButton(text="Cancel", callback_data="cancel")]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def admin_kb(sub_id):
|
||||||
|
return InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[InlineKeyboardButton(text="Approve", callback_data="a|" + str(sub_id))],
|
||||||
|
[InlineKeyboardButton(text="Reject", callback_data="r|" + str(sub_id))]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@dp.message(CommandStart())
|
||||||
|
async def start(message: types.Message, state: FSMContext):
|
||||||
|
await message.answer("The other videos/images can be found in the channel. Channel invite link: https://t.me/+engKAbYjPgNmYTQ1 here is where your stuff will be posted too")
|
||||||
|
args = message.text.split()
|
||||||
|
if len(args) > 1 and args[1] == "submit":
|
||||||
|
await message.answer("Send your photo or video", reply_markup=start_kb)
|
||||||
|
else:
|
||||||
|
await message.answer("Choose:", reply_markup=start_kb)
|
||||||
|
|
||||||
|
@dp.message(F.text == "Upload Photo")
|
||||||
|
async def photo_btn(message: types.Message, state: FSMContext):
|
||||||
|
await state.set_state(Upload.waiting_media)
|
||||||
|
await state.update_data(type="photo")
|
||||||
|
await message.answer("Send photo")
|
||||||
|
|
||||||
|
@dp.message(F.text == "Upload Video")
|
||||||
|
async def video_btn(message: types.Message, state: FSMContext):
|
||||||
|
await state.set_state(Upload.waiting_media)
|
||||||
|
await state.update_data(type="video")
|
||||||
|
await message.answer("Send video")
|
||||||
|
|
||||||
|
@dp.message(Upload.waiting_media, F.photo | F.video)
|
||||||
|
async def handle_media(message: types.Message, state: FSMContext):
|
||||||
|
if message.photo:
|
||||||
|
file_id = message.photo[-1].file_id
|
||||||
|
file_type = "photo"
|
||||||
|
else:
|
||||||
|
file_id = message.video.file_id
|
||||||
|
file_type = "video"
|
||||||
|
await state.update_data(file_id=file_id, file_type=file_type)
|
||||||
|
await state.set_state(Upload.confirm)
|
||||||
|
await message.answer("Confirm submission", reply_markup=confirm_kb)
|
||||||
|
|
||||||
|
@dp.callback_query(F.data == "submit")
|
||||||
|
async def submit(cb: types.CallbackQuery, state: FSMContext):
|
||||||
|
global counter
|
||||||
|
data = await state.get_data()
|
||||||
|
file_id = data["file_id"]
|
||||||
|
file_type = data["file_type"]
|
||||||
|
user = cb.from_user
|
||||||
|
counter += 1
|
||||||
|
submissions[counter] = (user.id, file_id, file_type)
|
||||||
|
caption = "New submission from @" + (user.username if user.username else user.full_name)
|
||||||
|
if file_type == "photo":
|
||||||
|
await bot.send_photo(REVIEW_ID, file_id, caption=caption, reply_markup=admin_kb(counter))
|
||||||
|
else:
|
||||||
|
await bot.send_video(REVIEW_ID, file_id, caption=caption, reply_markup=admin_kb(counter))
|
||||||
|
await cb.message.answer("Submitted")
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
@dp.callback_query(F.data == "cancel")
|
||||||
|
async def cancel(cb: types.CallbackQuery, state: FSMContext):
|
||||||
|
await state.clear()
|
||||||
|
await cb.message.answer("Cancelled")
|
||||||
|
|
||||||
|
@dp.callback_query(F.data.startswith("a|"))
|
||||||
|
async def approve(cb: types.CallbackQuery):
|
||||||
|
sub_id = int(cb.data.split("|")[1])
|
||||||
|
user_id, file_id, file_type = submissions[sub_id]
|
||||||
|
if file_type == "photo":
|
||||||
|
await bot.send_photo(CHANNEL_ID, file_id, caption="Approved submission, submit your own self-harm media using https://t.me/Selfharmmeowbot?start=submit")
|
||||||
|
else:
|
||||||
|
await bot.send_video(CHANNEL_ID, file_id, caption="Approved submission, submit your own self-harm media using https://t.me/Selfharmmeowbot?start=submit")
|
||||||
|
await bot.send_message(user_id, "Your submission was approved")
|
||||||
|
await cb.message.edit_reply_markup()
|
||||||
|
|
||||||
|
@dp.callback_query(F.data.startswith("r|"))
|
||||||
|
async def reject(cb: types.CallbackQuery):
|
||||||
|
sub_id = int(cb.data.split("|")[1])
|
||||||
|
user_id, _, _ = submissions[sub_id]
|
||||||
|
await bot.send_message(user_id, "Your submission was rejected")
|
||||||
|
await cb.message.edit_reply_markup()
|
||||||
|
|
||||||
|
@dp.message(F.new_chat_members)
|
||||||
|
async def welcome(message: types.Message):
|
||||||
|
for user in message.new_chat_members:
|
||||||
|
text = f"<a href='tg://user?id={user.id}'>{user.first_name}</a> welcome to the channel. if you want to submit media of SH please use this link to our bot: https://t.me/selfharmmeowbot?start=submit If you want to view existing self-harm media in the channel, please click on it and go to the media section."
|
||||||
|
msg = await message.answer(text, parse_mode="HTML")
|
||||||
|
await asyncio.sleep(1800)
|
||||||
|
await msg.delete()
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
await dp.start_polling(bot)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
BIN
arch/sh-bot.rar
Normal file
BIN
arch/sh-bot.rar
Normal file
Binary file not shown.
19
bot_state.py
Normal file
19
bot_state.py
Normal 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
20
config.py
Normal 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
5
docs/requirements.md
Normal 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
90
docs/roadmap.md
Normal 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
3
docs/usage.md
Normal 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
94
filters.py
Normal 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
71
hashing.py
Normal 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
47
keyboards.py
Normal 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
51
main.py
Normal 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
75
middlewares.py
Normal 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
51
persistence.py
Normal 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
1
routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from routers import admin, group, private
|
||||||
190
routers/admin.py
Normal file
190
routers/admin.py
Normal 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
186
routers/group.py
Normal 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
240
routers/private.py
Normal 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
5220
rsrcs/blacklist.txt
Normal file
File diff suppressed because it is too large
Load Diff
1
rsrcs/requirements.txt
Normal file
1
rsrcs/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
aiogram==3.27.0
|
||||||
11
states.py
Normal file
11
states.py
Normal 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
135
utils.py
Normal 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)
|
||||||
Reference in New Issue
Block a user