This commit is contained in:
Corrado Mulas
2026-06-19 16:18:21 +02:00
parent 2f09978de6
commit 1630ed9358
6 changed files with 211 additions and 29 deletions

View File

@@ -19,9 +19,11 @@ The following commands can ONLY be run from the admin chat:
#### User Management
```
/admin_add_user <username> - Add new user to database
/admin_add_user - Add a forwarded user to database
/admin_get_users - View default users list
/admin_set_users <id_or_username> ... - Replace entire default users list
/admin_add_users <id_or_username> ... - Add users to default list
/admin_add_users - Add a forwarded user to default list
/admin_remove_users <id_or_username> - Remove users from default list
```

View File

@@ -88,8 +88,10 @@ docker run telegram-groupfactory
### Admin Commands (Admin Chat Only)
```
/admin_add_user <username> - Add user to database
/admin_add_user - Add a forwarded user to database
/admin_set_users <id_or_username> ... - Set default users for groups
/admin_add_users <id_or_username> ... - Add users to default list
/admin_add_users - Add a forwarded user to default list
/admin_remove_users <id_or_username> - Remove users from default list
/admin_get_users - Show current default users
/admin_set_qr <qr_payload> - Store GroupHelp backup payload rendered as a QR image

View File

@@ -32,10 +32,34 @@ class AdminHandler:
return user, True
def _format_user(self, user: User, user_id: int, created: bool = False) -> str:
created_note = " (placeholder created)" if created else ""
notes = []
if created:
notes.append("placeholder created")
if not user.username and not user.access_hash:
notes.append("not inviteable yet")
created_note = f" ({', '.join(notes)})" if notes else ""
username = f"@{user.username}" if user.username else user.name
return f"{username} (ID: {user_id}){created_note}"
def _uninviteable_user_ids(self, user_ids: List[int]) -> List[int]:
missing = []
for user_id in user_ids:
user = self.user_service.get_user_by_id(user_id)
if user and not user.username and not user.access_hash:
missing.append(user_id)
return missing
def _append_uninviteable_warning(self, response: str, user_ids: List[int]) -> str:
missing = self._uninviteable_user_ids(user_ids)
if missing:
response += (
"\n\n⚠️ These numeric IDs are stored but not inviteable yet: "
f"{missing}. Re-add them with @username, or run `/admin_add_user` "
"or `/admin_add_users` with no args and forward a user message "
"so Telegram access_hash can be stored."
)
return response
def _is_numeric_id(self, value) -> bool:
return str(value).strip().lstrip("-").isdigit()
@@ -46,8 +70,16 @@ class AdminHandler:
username=username.lstrip("@") if username else None,
first_name=getattr(entity, "first_name", None) or getattr(entity, "title", None),
last_name=getattr(entity, "last_name", None),
access_hash=getattr(entity, "access_hash", None),
)
def _save_user_entity(self, entity, fallback_username: str = None) -> tuple:
user = self._user_from_entity(entity, fallback_username=fallback_username)
existed = self.user_service.get_user_by_id(user.id) is not None
if not self.user_service.save_user(user):
raise RuntimeError(f"Failed to save user {user.id}")
return user.id, user, not existed
async def _resolve_user_identifier(self, identifier) -> tuple:
identifier = str(identifier).strip().rstrip(",")
if not identifier:
@@ -58,13 +90,9 @@ class AdminHandler:
if self.telegram_client:
try:
entity = await self.telegram_client.get_entity(user_id)
user = self._user_from_entity(entity)
existed = self.user_service.get_user_by_id(user.id) is not None
if not self.user_service.save_user(user):
raise RuntimeError(f"Failed to save user {user.id}")
return user.id, user, not existed
except Exception:
logger.info("Could not resolve numeric user ID %s, creating placeholder", user_id)
return self._save_user_entity(entity)
except Exception as e:
logger.info("Could not resolve numeric user ID %s, creating placeholder: %s", user_id, e)
user, created = self._ensure_user_record(user_id)
return user_id, user, created
@@ -73,11 +101,7 @@ class AdminHandler:
username = identifier.lstrip("@")
entity = await self.telegram_client.get_entity(username)
user = self._user_from_entity(entity, fallback_username=username)
existed = self.user_service.get_user_by_id(user.id) is not None
if not self.user_service.save_user(user):
raise RuntimeError(f"Failed to save user {user.id}")
return user.id, user, not existed
return self._save_user_entity(entity, fallback_username=username)
async def _resolve_user_identifiers(self, identifiers: List) -> tuple:
resolved = []
@@ -151,6 +175,7 @@ class AdminHandler:
response += "\n\n New user records were created."
if errors:
response += "\n\n⚠️ Some identifiers could not be resolved:\n" + "\n".join(errors)
response = self._append_uninviteable_warning(response, valid_user_ids)
return response
else:
@@ -194,6 +219,7 @@ class AdminHandler:
response += "\n\n New user records were created."
if errors:
response += "\n\n⚠️ Some identifiers could not be resolved:\n" + "\n".join(errors)
response = self._append_uninviteable_warning(response, valid_user_ids)
return response
else:
@@ -263,6 +289,34 @@ class AdminHandler:
logger.error(f"Error adding user {username}: {e}")
return f"❌ Error adding user: {str(e)}"
async def handle_add_user_entity(self, chat_id: int, entity, add_to_defaults: bool = False) -> str:
"""Add a Telegram entity exposed by a forwarded user message."""
is_admin, error = self.verify_access(chat_id)
if not is_admin:
return error
try:
user_id, user, created = self._save_user_entity(entity)
action = "added" if created else "updated"
user_label = f"@{user.username}" if user.username else user.name
response = f"✅ User {user_label} {action} successfully (ID: {user_id})."
if add_to_defaults:
current_users = get_default_group_users()
if user_id not in current_users:
if set_default_group_users(current_users + [user_id]):
response += "\n✅ User added to default group users."
else:
response += "\n❌ Failed to add user to default group users."
else:
response += "\n User is already in default group users."
response = self._append_uninviteable_warning(response, [user_id])
return response
except Exception as e:
logger.error(f"Error adding forwarded user entity: {e}")
return f"❌ Error adding forwarded user: {str(e)}"
async def handle_get_qr_backup(self, chat_id: int) -> str:
"""Retrieve current QR backup data (Admin only)"""
is_admin, error = self.verify_access(chat_id)
@@ -332,9 +386,11 @@ class AdminHandler:
**Default Group Users Management:**
• `/admin_set_users <id_or_username> ...` - Replace entire default users list
• `/admin_add_users <id_or_username> ...` - Add users to default list
• `/admin_add_users` - Wait for a forwarded user message and add it to defaults
• `/admin_remove_users <id_or_username> ...` - Remove users from default list
• `/admin_get_users` - Show current default users
• `/admin_add_user <username>` - Resolve and save a user in the database
• `/admin_add_user` - Wait for a forwarded user message and save it
**QR Code Backup:**
• `/admin_get_qr` - Get current QR backup data

View File

@@ -1,7 +1,7 @@
import asyncio
import logging
import uvicorn
from telethon import TelegramClient, events
from telethon import TelegramClient, events, types
from src.config import load_config
from src.services.mongodb_service import MongoDBService
from src.services.user_service import UserService
@@ -53,6 +53,7 @@ async def main():
admin_handler.set_client(client)
group_conversations = {}
qr_import_sessions = {}
user_import_sessions = {}
async def create_group_from_staff_flow(event, name: str, description: str):
response = await group_handler.handle_create_group(
@@ -93,6 +94,27 @@ async def main():
return await admin_handler.handle_set_qr_backup_from_image(event.chat_id, image_bytes)
async def handle_forwarded_user_import(event, add_to_defaults: bool) -> str:
message = event.message
forward_info = getattr(message, "fwd_from", None)
if not forward_info:
return "❌ Please forward a message from the Telegram user. Do not paste a numeric ID."
from_id = getattr(forward_info, "from_id", None)
if not isinstance(from_id, types.PeerUser):
return "❌ Forwarded message must come from a Telegram user, not a channel/group or hidden sender."
try:
entity = await client.get_entity(from_id)
except Exception as e:
return f"❌ Could not resolve forwarded user entity: {e}"
return await admin_handler.handle_add_user_entity(
event.chat_id,
entity,
add_to_defaults=add_to_defaults,
)
@client.on(events.NewMessage())
async def message_handler(event):
"""Handle incoming messages and route to appropriate handlers"""
@@ -134,6 +156,9 @@ async def main():
if sender_id in qr_import_sessions:
del qr_import_sessions[sender_id]
canceled = True
if sender_id in user_import_sessions:
del user_import_sessions[sender_id]
canceled = True
if canceled:
await event.respond('🛑 Operation canceled.')
@@ -141,7 +166,11 @@ async def main():
await event.respond('❓ No active operation to cancel.')
return
if sender_id in qr_import_sessions and not lower_text.startswith('/admin_set_qr'):
if (
sender_id in qr_import_sessions
and not lower_text.startswith('/admin_set_qr')
and (message.fwd_from or not raw_text.startswith('/'))
):
session = qr_import_sessions[sender_id]
if session["chat_id"] != chat_id:
return
@@ -152,6 +181,23 @@ async def main():
await event.respond(response)
return
if (
sender_id in user_import_sessions
and (message.fwd_from or not raw_text.startswith('/'))
):
session = user_import_sessions[sender_id]
if session["chat_id"] != chat_id:
return
response = await handle_forwarded_user_import(
event,
add_to_defaults=session["add_to_defaults"],
)
if response.startswith(""):
user_import_sessions.pop(sender_id, None)
await event.respond(response)
return
if lower_text.startswith('!newgrp'):
if lower_text != '!newgrp':
cmd_args = raw_text[len('!newgrp'):].strip()
@@ -242,9 +288,23 @@ async def main():
parts = text.split()
if len(parts) > 1:
response = await admin_handler.handle_add_to_default_users(chat_id, parts[1:])
user_import_sessions.pop(sender_id, None)
qr_import_sessions.pop(sender_id, None)
await event.respond(response)
else:
await event.respond("❌ Please provide at least one user ID or username.\n\nUsage: `/admin_add_users <id_or_username1> <id_or_username2> ...`")
is_admin, error = admin_handler.verify_access(chat_id)
if not is_admin:
await event.respond(error)
else:
user_import_sessions[sender_id] = {
"chat_id": chat_id,
"add_to_defaults": True,
}
qr_import_sessions.pop(sender_id, None)
await event.respond(
"👤 Forward a message from the Telegram user to add them to the database and default group users.\n\n"
"The forward must expose the sender. Send `!cancel` to abort."
)
elif text.startswith('/admin_remove_users'):
parts = text.split()
@@ -255,14 +315,27 @@ async def main():
await event.respond("❌ Please provide at least one user ID or username.\n\nUsage: `/admin_remove_users <id_or_username1> <id_or_username2> ...`")
elif text.startswith('/admin_add_user'):
# Parse username from command
parts = text.split(maxsplit=1)
if len(parts) > 1:
username = parts[1].strip()
response = await admin_handler.handle_add_user_to_db(chat_id, username)
user_import_sessions.pop(sender_id, None)
qr_import_sessions.pop(sender_id, None)
await event.respond(response)
else:
await event.respond("❌ Please provide a username.\n\nUsage: `/admin_add_user <username>`")
is_admin, error = admin_handler.verify_access(chat_id)
if not is_admin:
await event.respond(error)
else:
user_import_sessions[sender_id] = {
"chat_id": chat_id,
"add_to_defaults": False,
}
qr_import_sessions.pop(sender_id, None)
await event.respond(
"👤 Forward a message from the Telegram user to add them to the database.\n\n"
"The forward must expose the sender. Send `!cancel` to abort."
)
elif text.startswith('/admin_get_qr'):
response = await admin_handler.handle_get_qr_backup(chat_id)
@@ -274,6 +347,7 @@ async def main():
qr_data = parts[1].strip()
response = await admin_handler.handle_set_qr_backup(chat_id, qr_data)
qr_import_sessions.pop(sender_id, None)
user_import_sessions.pop(sender_id, None)
await event.respond(response)
else:
is_admin, error = admin_handler.verify_access(chat_id)
@@ -281,6 +355,7 @@ async def main():
await event.respond(error)
else:
qr_import_sessions[sender_id] = {"chat_id": chat_id}
user_import_sessions.pop(sender_id, None)
await event.respond(
"📷 Forward the original GroupHelp QR image message here.\n\n"
"It must be a forwarded image message with `.importbackup` as its caption/body. "
@@ -383,8 +458,10 @@ async def main():
• `/admin_get_users` - Show default group users
• `/admin_set_users <id_or_username> ...` - Set default users
• `/admin_add_users <id_or_username> ...` - Add users to default list
• `/admin_add_users` - Add a forwarded user to default list
• `/admin_remove_users <id_or_username> ...` - Remove users from default list
• `/admin_add_user <username>` - Add new user to database
• `/admin_add_user` - Add a forwarded user to database
• `/admin_get_qr` - Get QR backup data
• `/admin_set_qr <qr_payload>` - Set QR backup data directly
• `/admin_set_qr` - Decode a forwarded `.importbackup` QR image

View File

@@ -8,6 +8,7 @@ class User:
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
access_hash: Optional[int] = None
def __post_init__(self):
if self.id is None:
@@ -25,7 +26,8 @@ class User:
id=data.get('id'),
username=data.get('username'),
first_name=data.get('first_name'),
last_name=data.get('last_name')
last_name=data.get('last_name'),
access_hash=data.get('access_hash')
)
def to_dict(self):
@@ -34,5 +36,6 @@ class User:
'id': self.id,
'username': self.username,
'first_name': self.first_name,
'last_name': self.last_name
'last_name': self.last_name,
'access_hash': self.access_hash
}

View File

@@ -93,6 +93,49 @@ class GroupService:
logger.warning(f"Failed to export invite link: {e}")
return None
def _user_label(self, user) -> str:
if user.username:
return f"@{user.username}"
return str(user.id)
def _save_resolved_access_hash(self, user, input_user) -> None:
access_hash = getattr(input_user, "access_hash", None)
user_id = getattr(input_user, "user_id", None) or getattr(input_user, "id", None)
if access_hash and user_id:
user.id = user_id
user.access_hash = access_hash
self.mongo_service.save_user(user)
async def _resolve_input_user(self, user):
if user.access_hash:
return types.InputUser(user.id, user.access_hash)
references = []
if user.username:
references.append(user.username)
references.append(user.id)
last_error = None
for reference in references:
try:
input_user = await self.client.get_input_entity(reference)
self._save_resolved_access_hash(user, input_user)
access_hash = getattr(input_user, "access_hash", None)
user_id = getattr(input_user, "user_id", None) or getattr(input_user, "id", None)
if access_hash and user_id:
return types.InputUser(user_id, access_hash)
return input_user
except ValueError as e:
last_error = e
if not user.username:
raise ValueError(
"numeric ID has no stored access_hash. Add this user by @username "
"or use /admin_add_user or /admin_add_users with no args and "
"forward a user message."
) from last_error
raise last_error or ValueError("could not resolve Telegram user")
async def create_group(
self,
group_name: str,
@@ -147,8 +190,8 @@ class GroupService:
f"👥 Adding users: {index}/{len(users)} completed",
)
user_ref = user.username or user.id
user_to_add = await self.client.get_input_entity(user_ref)
user_ref = self._user_label(user)
user_to_add = await self._resolve_input_user(user)
await self.client(InviteToChannelRequest(target_group, [user_to_add]))
await self._promote_user(target_group, user_to_add, full_admin=False)
success_count += 1
@@ -159,18 +202,17 @@ class GroupService:
await self._notify(status_callback, "⚠️ Telegram flood limit reached. Pausing for 30 seconds...")
await asyncio.sleep(30)
except UserPrivacyRestrictedError:
logger.warning(f"User {user.username or user.id} has privacy restrictions")
logger.warning(f"User {self._user_label(user)} has privacy restrictions")
error_count += 1
except ValueError as e:
logger.error(f"Could not resolve Telegram entity for user {user.username or user.id}: {e}")
logger.error(f"Could not resolve Telegram entity for user {self._user_label(user)}: {e}")
await self._notify(
status_callback,
f"⚠️ Could not resolve user {user.username or user.id}. "
"If this is a numeric ID, the userbot must have seen that user or you must store a username."
f"⚠️ Could not resolve user {self._user_label(user)}: {e}"
)
error_count += 1
except Exception as e:
logger.error(f"Error adding user {user.username or user.id}: {e}")
logger.error(f"Error adding user {self._user_label(user)}: {e}")
error_count += 1
if error_count > 10:
await self._notify(status_callback, "❌ Too many errors, aborting user addition!")
@@ -272,9 +314,9 @@ class GroupService:
group = await self.client.get_input_entity(group_id)
for user in users:
user_ref = user.username or user.id
user_ref = self._user_label(user)
try:
user_to_add = await self.client.get_input_entity(user_ref)
user_to_add = await self._resolve_input_user(user)
await self.client(InviteToChannelRequest(group, [user_to_add]))
except ValueError as e:
logger.error(f"Could not resolve Telegram entity for user {user_ref}: {e}")