Skip to content

Commit 9f33150

Browse files
committed
feat: block non-whitelisted guest bot messages with progressive restriction
Telegram Bot API 10.0 (May 2026) introduced Guest Mode, allowing any user to @mention a bot in any group chat without the bot being a member. The bot posts a reply directly in the chat. This feature adds moderation for those guest bot messages. New handler (src/bot/handlers/guest_bot.py): - GuestBotFilter: custom MessageFilter matching only guest bot messages (guest_bot_caller_user or guest_bot_caller_chat set) - is_guest_bot_whitelisted: case-insensitive username matching against per-group whitelist (strips @, lowercases) - handle_guest_bot_message: deletes non-whitelisted guest bot messages and progressively restricts the invoking user (not the bot) using the existing UserWarning state machine with a new warning_kind=guest_bot discriminator — 1st violation: warning, 2nd to (N-1): silent increment, Nth: restrict + notification. Admins/trusted users are exempt. Channel callers are delete-only (no human to restrict). Already-restricted callers skip new warning cycles. Plugin wiring: - New plugin guest_bot_block registered at group=1, before inline_keyboard_spam, with GuestBotFilter for precise matching - Gated by guard_plugin(guest_bot_block) for per-group toggle control - Blocking handler (no block=False) so ApplicationHandlerStop works Config: - guest_bot_whitelist field in Settings (NoDecode annotation for comma-separated env parsing) and GroupConfig (normalization validator) - GUEST_BOT_WHITELIST env var and groups.json support - Plugin toggle via plugins map in groups.json DB schema: - Added warning_kind column to UserWarning (default profile) - SQLite migration for existing databases - All DB service methods accept and filter by warning_kind - Scheduler only processes profile warnings (not guest bot) - DM/verify unrestriction only lifts profile restrictions Tests: 1048 passed, ruff clean, mypy clean
1 parent 5f492c9 commit 9f33150

16 files changed

Lines changed: 512 additions & 19 deletions

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ BIO_BAIT_MONITOR_ONLY=false
8686
# Example: PLUGINS_DEFAULT={"captcha":true,"dm":false}
8787
# PLUGINS_DEFAULT={"captcha":true,"dm":false}
8888

89+
# Guest Bot Whitelist (Telegram Guest Mode - Bot API 10.0)
90+
# Comma-separated list of bot usernames allowed to post guest messages
91+
# Messages from non-whitelisted guest bots are deleted and the invoking user is warned
92+
# Usernames are case-insensitive, @ prefix is optional
93+
# Example: GUEST_BOT_WHITELIST=@somebot,anotherbot
94+
GUEST_BOT_WHITELIST=
95+
8996
# Logfire Configuration (optional - for production logging)
9097
# Get your token from https://logfire.pydantic.dev
9198
LOGFIRE_TOKEN=your_logfire_token_here

groups.json.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"bio_bait_monitor_only": false,
2121
"bio_bait_alert_chat_id": null,
2222
"moderation_topic_id": null,
23+
"guest_bot_whitelist": [],
2324
"plugins": {
2425
"captcha": false,
2526
"dm": true,
@@ -47,6 +48,7 @@
4748
"bio_bait_monitor_only": false,
4849
"bio_bait_alert_chat_id": null,
4950
"moderation_topic_id": null,
51+
"guest_bot_whitelist": ["somebot"],
5052
"plugins": {
5153
"contact_spam": false,
5254
"duplicate_spam": false,

src/bot/config.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@
1111
import os
1212
from functools import lru_cache
1313
from pathlib import Path
14+
from typing import Annotated
1415

1516
from pydantic import field_validator
16-
from pydantic_settings import BaseSettings, SettingsConfigDict
17+
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
1718

1819
logger = logging.getLogger(__name__)
1920

@@ -84,6 +85,7 @@ class Settings(BaseSettings):
8485
bio_bait_monitor_only: bool = False
8586
bio_bait_alert_chat_id: int | None = None
8687
moderation_topic_id: int | None = None
88+
guest_bot_whitelist: Annotated[list[str], NoDecode] = []
8789
groups_config_path: str = "groups.json"
8890
logfire_token: str | None = None
8991
logfire_service_name: str = "pythonid-bot"
@@ -120,6 +122,18 @@ def parse_and_validate_plugins_default(cls, v: object) -> dict[str, bool]:
120122
from bot.plugins.config import validate_plugin_map
121123
return validate_plugin_map(parsed)
122124

125+
@field_validator("guest_bot_whitelist", mode="before")
126+
@classmethod
127+
def parse_guest_bot_whitelist(cls, v: object) -> list[str]:
128+
"""Parse GUEST_BOT_WHITELIST env var as comma-separated usernames."""
129+
if isinstance(v, list):
130+
return [str(entry).strip().removeprefix("@").lower() for entry in v if str(entry).strip()]
131+
if isinstance(v, str):
132+
if not v.strip():
133+
return []
134+
return [entry.strip().removeprefix("@").lower() for entry in v.split(",") if entry.strip()]
135+
return []
136+
123137
def model_post_init(self, __context):
124138
"""Validate and log non-sensitive configuration values after initialization."""
125139
if self.group_id >= 0:

src/bot/constants.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,19 @@ def format_hours_display(hours: int) -> str:
344344
"📌 [Peraturan Grup]({rules_link})"
345345
)
346346

347+
GUEST_BOT_WARNING = (
348+
"⚠️ {user_mention}, bot tamu tidak diizinkan di grup ini. "
349+
"Pelanggaran berikutnya dapat menyebabkan pembatasan setelah "
350+
"{warning_threshold} pesan.\n\n"
351+
"Silakan baca [peraturan grup]({rules_link})."
352+
)
353+
354+
GUEST_BOT_RESTRICTION = (
355+
"🔇 {user_mention} dibatasi setelah memanggil bot tamu sebanyak "
356+
"{message_count} kali.\n\n"
357+
"Silakan baca [peraturan grup]({rules_link})."
358+
)
359+
347360
# Duplicate message spam notification
348361
DUPLICATE_SPAM_RESTRICTION = (
349362
"🚫 *Spam Pesan Duplikat*\n\n"

src/bot/database/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ class UserWarning(SQLModel, table=True):
3232
restricted_by_bot: True if restriction was applied by this bot
3333
(vs manually by an admin). Only bot-created restrictions
3434
can be lifted via DM.
35+
warning_kind: Discriminator for the warning source
36+
(``"profile"`` or ``"guest_bot"``). Prevents cross-source
37+
state interference.
3538
"""
3639

3740
__tablename__ = "user_warnings"
@@ -44,6 +47,7 @@ class UserWarning(SQLModel, table=True):
4447
last_message_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
4548
is_restricted: bool = Field(default=False)
4649
restricted_by_bot: bool = Field(default=False)
50+
warning_kind: str = Field(default="profile", index=True)
4751

4852

4953
class PhotoVerificationWhitelist(SQLModel, table=True):

src/bot/database/service.py

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ def __init__(self, database_path: str):
5858

5959
# Migrate existing tables: add new columns if missing
6060
self._migrate_trusted_users()
61+
self._migrate_user_warnings()
6162

6263
def _migrate_trusted_users(self) -> None:
6364
"""Add new columns to trusted_users if missing."""
@@ -80,7 +81,24 @@ def _migrate_trusted_users(self) -> None:
8081
logger.info(f"Migrated trusted_users: added {col} column")
8182
conn.commit()
8283

83-
def get_or_create_user_warning(self, user_id: int, group_id: int) -> UserWarning:
84+
def _migrate_user_warnings(self) -> None:
85+
"""Add warning_kind column to user_warnings if missing."""
86+
with self._engine.connect() as conn:
87+
columns = {
88+
row[1] for row in conn.exec_driver_sql(
89+
"PRAGMA table_info(user_warnings)"
90+
).fetchall()
91+
}
92+
if "warning_kind" not in columns:
93+
conn.exec_driver_sql(
94+
"ALTER TABLE user_warnings ADD COLUMN warning_kind TEXT DEFAULT 'profile'"
95+
)
96+
logger.info("Migrated user_warnings: added warning_kind column")
97+
conn.commit()
98+
99+
def get_or_create_user_warning(
100+
self, user_id: int, group_id: int, warning_kind: str = "profile"
101+
) -> UserWarning:
84102
"""
85103
Get existing warning record or create a new one.
86104
@@ -90,6 +108,7 @@ def get_or_create_user_warning(self, user_id: int, group_id: int) -> UserWarning
90108
Args:
91109
user_id: Telegram user ID.
92110
group_id: Telegram group ID.
111+
warning_kind: Discriminator for the warning source.
93112
94113
Returns:
95114
UserWarning: Active warning record for the user.
@@ -99,13 +118,14 @@ def get_or_create_user_warning(self, user_id: int, group_id: int) -> UserWarning
99118
statement = select(UserWarning).where(
100119
UserWarning.user_id == user_id,
101120
UserWarning.group_id == group_id,
121+
UserWarning.warning_kind == warning_kind,
102122
~UserWarning.is_restricted,
103123
)
104124
record = session.exec(statement).first()
105125

106126
if record:
107127
logger.info(
108-
f"Returning existing warning for user_id={user_id}, group_id={group_id}"
128+
f"Returning existing warning for user_id={user_id}, group_id={group_id}, kind={warning_kind}"
109129
)
110130
return record
111131

@@ -116,16 +136,19 @@ def get_or_create_user_warning(self, user_id: int, group_id: int) -> UserWarning
116136
message_count=1,
117137
first_warned_at=datetime.now(UTC),
118138
last_message_at=datetime.now(UTC),
139+
warning_kind=warning_kind,
119140
)
120141
session.add(new_record)
121142
session.commit()
122143
session.refresh(new_record)
123144
logger.info(
124-
f"Created new warning for user_id={user_id}, group_id={group_id}"
145+
f"Created new warning for user_id={user_id}, group_id={group_id}, kind={warning_kind}"
125146
)
126147
return new_record
127148

128-
def increment_message_count(self, user_id: int, group_id: int) -> UserWarning:
149+
def increment_message_count(
150+
self, user_id: int, group_id: int, warning_kind: str = "profile"
151+
) -> UserWarning:
129152
"""
130153
Increment message count for an existing warning record.
131154
@@ -135,6 +158,7 @@ def increment_message_count(self, user_id: int, group_id: int) -> UserWarning:
135158
Args:
136159
user_id: Telegram user ID.
137160
group_id: Telegram group ID.
161+
warning_kind: Discriminator for the warning source.
138162
139163
Returns:
140164
UserWarning: Updated warning record.
@@ -146,6 +170,7 @@ def increment_message_count(self, user_id: int, group_id: int) -> UserWarning:
146170
statement = select(UserWarning).where(
147171
UserWarning.user_id == user_id,
148172
UserWarning.group_id == group_id,
173+
UserWarning.warning_kind == warning_kind,
149174
~UserWarning.is_restricted,
150175
)
151176
record = session.exec(statement).first()
@@ -157,15 +182,17 @@ def increment_message_count(self, user_id: int, group_id: int) -> UserWarning:
157182
session.commit()
158183
session.refresh(record)
159184
logger.info(
160-
f"Incremented message count for user_id={user_id}, group_id={group_id}, new_count={record.message_count}"
185+
f"Incremented message count for user_id={user_id}, group_id={group_id}, kind={warning_kind}, new_count={record.message_count}"
161186
)
162187
return record
163188

164189
raise ValueError(
165-
f"No warning record found for user {user_id} in group {group_id}"
190+
f"No warning record found for user {user_id} in group {group_id} (kind={warning_kind})"
166191
)
167192

168-
def mark_user_restricted(self, user_id: int, group_id: int) -> UserWarning:
193+
def mark_user_restricted(
194+
self, user_id: int, group_id: int, warning_kind: str = "profile"
195+
) -> UserWarning:
169196
"""
170197
Mark user as restricted after reaching threshold.
171198
@@ -175,6 +202,7 @@ def mark_user_restricted(self, user_id: int, group_id: int) -> UserWarning:
175202
Args:
176203
user_id: Telegram user ID.
177204
group_id: Telegram group ID.
205+
warning_kind: Discriminator for the warning source.
178206
179207
Returns:
180208
UserWarning: Updated warning record.
@@ -186,6 +214,7 @@ def mark_user_restricted(self, user_id: int, group_id: int) -> UserWarning:
186214
statement = select(UserWarning).where(
187215
UserWarning.user_id == user_id,
188216
UserWarning.group_id == group_id,
217+
UserWarning.warning_kind == warning_kind,
189218
~UserWarning.is_restricted,
190219
)
191220
record = session.exec(statement).first()
@@ -198,15 +227,17 @@ def mark_user_restricted(self, user_id: int, group_id: int) -> UserWarning:
198227
session.commit()
199228
session.refresh(record)
200229
logger.info(
201-
f"Marked user as restricted: user_id={user_id}, group_id={group_id}"
230+
f"Marked user as restricted: user_id={user_id}, group_id={group_id}, kind={warning_kind}"
202231
)
203232
return record
204233

205234
raise ValueError(
206-
f"No warning record found for user {user_id} in group {group_id}"
235+
f"No warning record found for user {user_id} in group {group_id} (kind={warning_kind})"
207236
)
208237

209-
def is_user_restricted_by_bot(self, user_id: int, group_id: int) -> bool:
238+
def is_user_restricted_by_bot(
239+
self, user_id: int, group_id: int, warning_kind: str = "profile"
240+
) -> bool:
210241
"""
211242
Check if user was restricted by this bot.
212243
@@ -217,6 +248,7 @@ def is_user_restricted_by_bot(self, user_id: int, group_id: int) -> bool:
217248
Args:
218249
user_id: Telegram user ID.
219250
group_id: Telegram group ID.
251+
warning_kind: Discriminator for the warning source.
220252
221253
Returns:
222254
bool: True if user was restricted by this bot.
@@ -225,13 +257,16 @@ def is_user_restricted_by_bot(self, user_id: int, group_id: int) -> bool:
225257
statement = select(UserWarning).where(
226258
UserWarning.user_id == user_id,
227259
UserWarning.group_id == group_id,
260+
UserWarning.warning_kind == warning_kind,
228261
UserWarning.is_restricted,
229262
UserWarning.restricted_by_bot,
230263
)
231264
record = session.exec(statement).first()
232265
return record is not None
233266

234-
def mark_user_unrestricted(self, user_id: int, group_id: int) -> None:
267+
def mark_user_unrestricted(
268+
self, user_id: int, group_id: int, warning_kind: str = "profile"
269+
) -> None:
235270
"""
236271
Clear bot restriction flag after user is unrestricted via DM.
237272
@@ -241,11 +276,13 @@ def mark_user_unrestricted(self, user_id: int, group_id: int) -> None:
241276
Args:
242277
user_id: Telegram user ID.
243278
group_id: Telegram group ID.
279+
warning_kind: Discriminator for the warning source.
244280
"""
245281
with Session(self._engine) as session:
246282
statement = select(UserWarning).where(
247283
UserWarning.user_id == user_id,
248284
UserWarning.group_id == group_id,
285+
UserWarning.warning_kind == warning_kind,
249286
UserWarning.is_restricted,
250287
UserWarning.restricted_by_bot,
251288
)
@@ -256,10 +293,12 @@ def mark_user_unrestricted(self, user_id: int, group_id: int) -> None:
256293
session.add(record)
257294
session.commit()
258295
logger.info(
259-
f"Cleared restriction flag: user_id={user_id}, group_id={group_id}"
296+
f"Cleared restriction flag: user_id={user_id}, group_id={group_id}, kind={warning_kind}"
260297
)
261298

262-
def delete_user_warnings(self, user_id: int, group_id: int) -> int:
299+
def delete_user_warnings(
300+
self, user_id: int, group_id: int, warning_kind: str = "profile"
301+
) -> int:
263302
"""
264303
Delete all warning records for a user in a specific group.
265304
@@ -269,6 +308,7 @@ def delete_user_warnings(self, user_id: int, group_id: int) -> int:
269308
Args:
270309
user_id: Telegram user ID.
271310
group_id: Telegram group ID.
311+
warning_kind: Discriminator for the warning source.
272312
273313
Returns:
274314
int: Number of warning records deleted.
@@ -277,16 +317,19 @@ def delete_user_warnings(self, user_id: int, group_id: int) -> int:
277317
delete_statement = delete(UserWarning).where(
278318
UserWarning.user_id == user_id,
279319
UserWarning.group_id == group_id,
320+
UserWarning.warning_kind == warning_kind,
280321
)
281322
result = session.exec(delete_statement)
282323
session.commit()
283324
count = result.rowcount
284325
logger.info(
285-
f"Deleted warnings: user_id={user_id}, group_id={group_id}, count={count}"
326+
f"Deleted warnings: user_id={user_id}, group_id={group_id}, kind={warning_kind}, count={count}"
286327
)
287328
return count
288329

289-
def get_active_user_warning(self, user_id: int, group_id: int) -> UserWarning | None:
330+
def get_active_user_warning(
331+
self, user_id: int, group_id: int, warning_kind: str = "profile"
332+
) -> UserWarning | None:
290333
"""
291334
Get an existing active (non-restricted) warning record without creating one.
292335
@@ -297,6 +340,7 @@ def get_active_user_warning(self, user_id: int, group_id: int) -> UserWarning |
297340
Args:
298341
user_id: Telegram user ID.
299342
group_id: Telegram group ID.
343+
warning_kind: Discriminator for the warning source.
300344
301345
Returns:
302346
UserWarning | None: Active warning record, or None if none exists.
@@ -305,6 +349,7 @@ def get_active_user_warning(self, user_id: int, group_id: int) -> UserWarning |
305349
statement = select(UserWarning).where(
306350
UserWarning.user_id == user_id,
307351
UserWarning.group_id == group_id,
352+
UserWarning.warning_kind == warning_kind,
308353
~UserWarning.is_restricted,
309354
)
310355
return session.exec(statement).first()
@@ -540,14 +585,15 @@ def get_trusted_users(self) -> list[TrustedUser]:
540585
return list(session.exec(statement).all())
541586

542587
def get_warnings_past_time_threshold_for_group(
543-
self, group_id: int, threshold: timedelta
588+
self, group_id: int, threshold: timedelta, warning_kind: str = "profile"
544589
) -> list[UserWarning]:
545590
"""
546591
Find active warnings for a specific group that exceeded the time threshold.
547592
548593
Args:
549594
group_id: Telegram group ID to filter by.
550595
threshold: Time duration since first warning to trigger restriction.
596+
warning_kind: Discriminator for the warning source.
551597
552598
Returns:
553599
list[UserWarning]: Warning records that should be auto-restricted.
@@ -556,6 +602,7 @@ def get_warnings_past_time_threshold_for_group(
556602
cutoff_time = datetime.now(UTC) - threshold
557603
statement = select(UserWarning).where(
558604
UserWarning.group_id == group_id,
605+
UserWarning.warning_kind == warning_kind,
559606
~UserWarning.is_restricted,
560607
UserWarning.first_warned_at <= cutoff_time,
561608
)

0 commit comments

Comments
 (0)