Skip to content

Commit 1984c32

Browse files
committed
feat: add /check command handler for admin profile verification
- Implement new check.py handler with /check command to verify user profiles - Fetch user profile photos and validate username/photo presence - Support inline forwarded message verification with verify/unverify buttons - Refactor common profile checking logic into telegram_utils and constants - Extract get_user_mention_by_id, fetch_group_admin_ids utilities - Consolidate warning message templates with format_threshold_display helper - Update main.py to register check handler with priority routing - Add comprehensive test coverage (18 test classes, 95% coverage) - Update README with test statistics: 328 tests, 99% coverage, 100% pass rate BREAKING: Removed duplicate /verify command inline forwarding from verify.py - Verify/unverify now handled through check.py forwarded message processor - DM-only /verify and /unverify commands remain for whitelist management
1 parent a0c709b commit 1984c32

10 files changed

Lines changed: 841 additions & 199 deletions

File tree

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,12 @@ uv run pytest -v
150150
### Test Coverage
151151

152152
The project maintains comprehensive test coverage:
153-
- **Coverage**: 99% (1,057 statements, 2 missed)
154-
- **Tests**: 310 total
155-
- **Pass Rate**: 100% (310/310 passed)
153+
- **Coverage**: 99% (1,179 statements, 9 missed)
154+
- **Tests**: 328 total
155+
- **Pass Rate**: 100% (328/328 passed)
156156
- **All modules**: 99% coverage including JobQueue scheduler integration, captcha verification, and anti-spam enforcement
157157
- Services: `bot_info.py` (100%), `scheduler.py` (100%), `user_checker.py` (100%), `telegram_utils.py` (100%), `captcha_recovery.py` (100%)
158-
- Handlers: `anti_spam.py` (98%), `captcha.py` (100%), `dm.py` (100%), `message.py` (100%), `topic_guard.py` (100%), `verify.py` (100%)
158+
- Handlers: `anti_spam.py` (98%), `captcha.py` (100%), `check.py` (95%), `dm.py` (100%), `message.py` (100%), `topic_guard.py` (100%), `verify.py` (100%)
159159
- Database: `service.py` (100%), `models.py` (100%)
160160
- Config: `config.py` (100%)
161161
- Constants: `constants.py` (100%)

src/bot/constants.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,11 +157,26 @@ def format_hours_display(hours: int) -> str:
157157
"✅ {user_mention} telah diverifikasi oleh admin. Silakan berdiskusi kembali."
158158
)
159159

160-
FORWARD_VERIFY_PROMPT = (
161-
"📋 User: {user_mention} (ID: {user_id})\n\n"
162-
"Pilih aksi untuk user ini:"
160+
ADMIN_CHECK_PROMPT = (
161+
"📋 User: {user_mention} (ID: `{user_id}`)\n\n"
162+
"Status Profil:\n"
163+
"• Foto Profil: {photo_status}\n"
164+
"• Username: {username_status}\n\n"
165+
"{action_prompt}"
163166
)
164167

168+
ADMIN_CHECK_ACTION_COMPLETE = "✅ Profil lengkap, tidak ada aksi yang diperlukan."
169+
170+
ADMIN_CHECK_ACTION_INCOMPLETE = "⚠️ Profil tidak lengkap. Pilih aksi:"
171+
172+
ADMIN_WARN_USER_MESSAGE = (
173+
"⚠️ Hai {user_mention}, mohon lengkapi {missing_text} kamu "
174+
"untuk mematuhi aturan grup.\n\n"
175+
"📖 [Baca aturan grup]({rules_link})"
176+
)
177+
178+
ADMIN_WARN_SENT_MESSAGE = "✅ Peringatan telah dikirim ke {user_mention} di grup."
179+
165180
# Anti-spam probation warning for new users
166181
NEW_USER_SPAM_WARNING = (
167182
"⚠️ {user_mention} baru bergabung dan sedang dalam masa percobaan.\n"

src/bot/handlers/check.py

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
"""
2+
Admin check handler for the PythonID bot.
3+
4+
This module handles admin commands to manually check user profiles:
5+
1. /check <user_id> - Check a user's profile status
6+
2. Forwarded message - Check profile and show action buttons
7+
3. Warn button callback - Send warning to user in group
8+
"""
9+
10+
import logging
11+
12+
from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup, Update
13+
from telegram.error import TimedOut
14+
from telegram.ext import ContextTypes
15+
16+
from bot.config import get_settings
17+
from bot.constants import (
18+
ADMIN_CHECK_ACTION_COMPLETE,
19+
ADMIN_CHECK_ACTION_INCOMPLETE,
20+
ADMIN_CHECK_PROMPT,
21+
ADMIN_WARN_SENT_MESSAGE,
22+
ADMIN_WARN_USER_MESSAGE,
23+
MISSING_ITEMS_SEPARATOR,
24+
)
25+
from bot.database.service import get_database
26+
from bot.services.telegram_utils import extract_forwarded_user, get_user_mention_by_id
27+
from bot.services.user_checker import check_user_profile
28+
29+
logger = logging.getLogger(__name__)
30+
31+
32+
async def _build_check_response(
33+
bot: Bot, user_id: int, user_name: str
34+
) -> tuple[str, InlineKeyboardMarkup | None]:
35+
"""
36+
Build the check response message and keyboard.
37+
38+
Args:
39+
bot: Telegram bot instance.
40+
user_id: ID of the user to check.
41+
user_name: Display name of the user.
42+
43+
Returns:
44+
Tuple of (message text, optional keyboard markup).
45+
"""
46+
try:
47+
chat = await bot.get_chat(user_id)
48+
result = await check_user_profile(bot, chat) # type: ignore
49+
except Exception as e:
50+
logger.error(f"Failed to check profile for user {user_id}: {e}")
51+
raise
52+
53+
user_mention = get_user_mention_by_id(user_id, user_name)
54+
photo_status = "✅" if result.has_profile_photo else "❌"
55+
username_status = "✅" if result.has_username else "❌"
56+
57+
db = get_database()
58+
is_whitelisted = db.is_user_photo_whitelisted(user_id)
59+
60+
if result.is_complete:
61+
action_prompt = ADMIN_CHECK_ACTION_COMPLETE
62+
if is_whitelisted:
63+
keyboard = InlineKeyboardMarkup([
64+
[InlineKeyboardButton("❌ Unverify User", callback_data=f"unverify:{user_id}")]
65+
])
66+
else:
67+
keyboard = None
68+
else:
69+
action_prompt = ADMIN_CHECK_ACTION_INCOMPLETE
70+
# Store missing items in callback data (photo,username format)
71+
missing_code = ""
72+
if not result.has_profile_photo:
73+
missing_code += "p"
74+
if not result.has_username:
75+
missing_code += "u"
76+
keyboard = InlineKeyboardMarkup([
77+
[
78+
InlineKeyboardButton("⚠️ Warn User", callback_data=f"warn:{user_id}:{missing_code}"),
79+
InlineKeyboardButton("✅ Verify User", callback_data=f"verify:{user_id}"),
80+
]
81+
])
82+
83+
message = ADMIN_CHECK_PROMPT.format(
84+
user_mention=user_mention,
85+
user_id=user_id,
86+
photo_status=photo_status,
87+
username_status=username_status,
88+
action_prompt=action_prompt,
89+
)
90+
91+
return message, keyboard
92+
93+
94+
async def handle_check_command(
95+
update: Update, context: ContextTypes.DEFAULT_TYPE
96+
) -> None:
97+
"""
98+
Handle /check command to manually check a user's profile.
99+
100+
Usage: /check USER_ID (e.g., /check 123456789)
101+
102+
Only works in bot DMs for admins.
103+
"""
104+
if not update.message or not update.message.from_user:
105+
return
106+
107+
if update.effective_chat and update.effective_chat.type != "private":
108+
await update.message.reply_text(
109+
"❌ Perintah ini hanya bisa digunakan di chat pribadi dengan bot."
110+
)
111+
return
112+
113+
admin_user_id = update.message.from_user.id
114+
admin_ids = context.bot_data.get("admin_ids", [])
115+
116+
if admin_user_id not in admin_ids:
117+
await update.message.reply_text("❌ Kamu tidak memiliki izin untuk menggunakan perintah ini.")
118+
logger.warning(
119+
f"Non-admin user {admin_user_id} ({update.message.from_user.full_name}) "
120+
f"attempted to use /check command"
121+
)
122+
return
123+
124+
if not context.args or len(context.args) == 0:
125+
await update.message.reply_text("❌ Penggunaan: /check USER_ID")
126+
return
127+
128+
try:
129+
target_user_id = int(context.args[0])
130+
except ValueError:
131+
await update.message.reply_text("❌ User ID harus berupa angka.")
132+
return
133+
134+
try:
135+
# Get user info for display name
136+
chat = await context.bot.get_chat(target_user_id)
137+
user_name = chat.full_name or f"User {target_user_id}"
138+
139+
message, keyboard = await _build_check_response(context.bot, target_user_id, user_name)
140+
await update.message.reply_text(message, reply_markup=keyboard, parse_mode="Markdown")
141+
142+
logger.info(
143+
f"Admin {admin_user_id} ({update.message.from_user.full_name}) "
144+
f"checked profile for user {target_user_id}"
145+
)
146+
except TimedOut:
147+
await update.message.reply_text("⏳ Request timeout. Silakan coba lagi.")
148+
logger.warning(f"Timeout checking user {target_user_id}")
149+
except Exception as e:
150+
await update.message.reply_text(f"❌ Gagal memeriksa user: {e}")
151+
logger.error(f"Error checking user {target_user_id}: {e}", exc_info=True)
152+
153+
154+
async def handle_check_forwarded_message(
155+
update: Update, context: ContextTypes.DEFAULT_TYPE
156+
) -> None:
157+
"""
158+
Handle forwarded messages from admins to check user profile.
159+
160+
When an admin forwards a user's message to the bot in DM, this handler
161+
checks the user's profile and shows action buttons.
162+
"""
163+
if not update.message or not update.message.from_user:
164+
return
165+
166+
admin_user_id = update.message.from_user.id
167+
admin_ids = context.bot_data.get("admin_ids", [])
168+
169+
if admin_user_id not in admin_ids:
170+
await update.message.reply_text("❌ Kamu tidak memiliki izin untuk menggunakan fitur ini.")
171+
logger.warning(
172+
f"Non-admin user {admin_user_id} ({update.message.from_user.full_name}) "
173+
f"attempted to forward message for check"
174+
)
175+
return
176+
177+
forwarded_info = extract_forwarded_user(update.message)
178+
if not forwarded_info:
179+
await update.message.reply_text(
180+
"❌ Tidak dapat mengekstrak informasi user dari pesan yang diteruskan.\n"
181+
"Pastikan user tidak menyembunyikan status forward di pengaturan privasi."
182+
)
183+
return
184+
185+
user_id, user_name = forwarded_info
186+
187+
try:
188+
message, keyboard = await _build_check_response(context.bot, user_id, user_name)
189+
await update.message.reply_text(message, reply_markup=keyboard, parse_mode="Markdown")
190+
191+
logger.info(
192+
f"Admin {admin_user_id} ({update.message.from_user.full_name}) "
193+
f"forwarded message from user {user_id} for profile check"
194+
)
195+
except TimedOut:
196+
await update.message.reply_text("⏳ Request timeout. Silakan coba lagi.")
197+
logger.warning(f"Timeout checking forwarded user {user_id}")
198+
except Exception as e:
199+
await update.message.reply_text(f"❌ Gagal memeriksa user: {e}")
200+
logger.error(f"Error checking forwarded user {user_id}: {e}", exc_info=True)
201+
202+
203+
async def handle_warn_callback(
204+
update: Update, context: ContextTypes.DEFAULT_TYPE
205+
) -> None:
206+
"""
207+
Handle callback query for warn button.
208+
209+
Sends a warning message to the user in the group.
210+
"""
211+
query = update.callback_query
212+
if not query or not query.from_user or not query.data:
213+
return
214+
215+
await query.answer()
216+
217+
admin_user_id = query.from_user.id
218+
admin_ids = context.bot_data.get("admin_ids", [])
219+
220+
if admin_user_id not in admin_ids:
221+
await query.edit_message_text("❌ Kamu tidak memiliki izin untuk menggunakan perintah ini.")
222+
logger.warning(
223+
f"Non-admin user {admin_user_id} ({query.from_user.full_name}) "
224+
f"attempted to use warn callback"
225+
)
226+
return
227+
228+
# Parse callback data: warn:<user_id>:<missing_code>
229+
try:
230+
parts = query.data.split(":")
231+
target_user_id = int(parts[1])
232+
missing_code = parts[2] if len(parts) > 2 else ""
233+
except (IndexError, ValueError):
234+
await query.edit_message_text("❌ Data callback tidak valid.")
235+
logger.error(f"Invalid callback_data format: {query.data}")
236+
return
237+
238+
# Build missing items text
239+
missing_items = []
240+
if "p" in missing_code:
241+
missing_items.append("foto profil publik")
242+
if "u" in missing_code:
243+
missing_items.append("username")
244+
missing_text = MISSING_ITEMS_SEPARATOR.join(missing_items) if missing_items else "profil"
245+
246+
settings = get_settings()
247+
248+
try:
249+
# Get user info for mention
250+
chat = await context.bot.get_chat(target_user_id)
251+
user_mention = get_user_mention_by_id(target_user_id, chat.full_name or f"User {target_user_id}")
252+
253+
# Send warning to group
254+
warn_message = ADMIN_WARN_USER_MESSAGE.format(
255+
user_mention=user_mention,
256+
missing_text=missing_text,
257+
rules_link=settings.rules_link,
258+
)
259+
await context.bot.send_message(
260+
chat_id=settings.group_id,
261+
message_thread_id=settings.warning_topic_id,
262+
text=warn_message,
263+
parse_mode="Markdown",
264+
)
265+
266+
# Update the original message
267+
success_message = ADMIN_WARN_SENT_MESSAGE.format(user_mention=user_mention)
268+
await query.edit_message_text(success_message, parse_mode="Markdown")
269+
270+
logger.info(
271+
f"Admin {admin_user_id} ({query.from_user.full_name}) "
272+
f"sent warning to user {target_user_id} in group"
273+
)
274+
except TimedOut:
275+
await query.edit_message_text("⏳ Request timeout. Silakan coba lagi.")
276+
logger.warning(f"Timeout sending warning to user {target_user_id}")
277+
except Exception as e:
278+
await query.edit_message_text(f"❌ Gagal mengirim peringatan: {e}")
279+
logger.error(f"Error sending warning to user {target_user_id}: {e}", exc_info=True)

src/bot/handlers/dm.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
DM_NO_RESTRICTION_MESSAGE,
2525
DM_UNRESTRICTION_NOTIFICATION,
2626
DM_UNRESTRICTION_SUCCESS_MESSAGE,
27+
MISSING_ITEMS_SEPARATOR,
2728
)
2829
from bot.database.service import get_database
2930
from bot.services.telegram_utils import get_user_mention, get_user_status, unrestrict_user
@@ -93,7 +94,7 @@ async def handle_dm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
9394
# Profile still incomplete - tell them what's missing
9495
if not result.is_complete:
9596
missing = result.get_missing_items()
96-
missing_text = " dan ".join(missing)
97+
missing_text = MISSING_ITEMS_SEPARATOR.join(missing)
9798
reply_message = DM_INCOMPLETE_PROFILE_MESSAGE.format(
9899
missing_text=missing_text,
99100
rules_link=settings.rules_link,

0 commit comments

Comments
 (0)