diff --git a/ADMIN_IMPLEMENTATION.md b/ADMIN_IMPLEMENTATION.md index 0b1a804..682a953 100644 --- a/ADMIN_IMPLEMENTATION.md +++ b/ADMIN_IMPLEMENTATION.md @@ -19,9 +19,11 @@ The following commands can ONLY be run from the admin chat: #### User Management ``` /admin_add_user - Add new user to database +/admin_add_user - Add a forwarded user to database /admin_get_users - View default users list /admin_set_users ... - Replace entire default users list /admin_add_users ... - Add users to default list +/admin_add_users - Add a forwarded user to default list /admin_remove_users - Remove users from default list ``` diff --git a/README.md b/README.md index 00e24f4..746b538 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,10 @@ docker run telegram-groupfactory ### Admin Commands (Admin Chat Only) ``` /admin_add_user - Add user to database +/admin_add_user - Add a forwarded user to database /admin_set_users ... - Set default users for groups /admin_add_users ... - Add users to default list +/admin_add_users - Add a forwarded user to default list /admin_remove_users - Remove users from default list /admin_get_users - Show current default users /admin_set_qr - Store GroupHelp backup payload rendered as a QR image diff --git a/src/handlers/admin_handler.py b/src/handlers/admin_handler.py index c04b025..50fbb4a 100644 --- a/src/handlers/admin_handler.py +++ b/src/handlers/admin_handler.py @@ -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: @@ -262,6 +288,34 @@ class AdminHandler: except Exception as e: 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)""" @@ -332,9 +386,11 @@ class AdminHandler: **Default Group Users Management:** • `/admin_set_users ...` - Replace entire default users list • `/admin_add_users ...` - Add users to default list +• `/admin_add_users` - Wait for a forwarded user message and add it to defaults • `/admin_remove_users ...` - Remove users from default list • `/admin_get_users` - Show current default users • `/admin_add_user ` - 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 diff --git a/src/main.py b/src/main.py index 6d3f74f..20bb57a 100644 --- a/src/main.py +++ b/src/main.py @@ -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( @@ -92,6 +93,27 @@ async def main(): return "❌ Could not download the forwarded QR image." 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): @@ -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 ...`") + 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 ...`") 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 `") + 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 ...` - Set default users • `/admin_add_users ...` - Add users to default list +• `/admin_add_users` - Add a forwarded user to default list • `/admin_remove_users ...` - Remove users from default list • `/admin_add_user ` - Add new user to database +• `/admin_add_user` - Add a forwarded user to database • `/admin_get_qr` - Get QR backup data • `/admin_set_qr ` - Set QR backup data directly • `/admin_set_qr` - Decode a forwarded `.importbackup` QR image diff --git a/src/models/user.py b/src/models/user.py index d1f4dde..ccbe53c 100644 --- a/src/models/user.py +++ b/src/models/user.py @@ -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 } diff --git a/src/services/group_service.py b/src/services/group_service.py index 871e902..a2ff2d5 100644 --- a/src/services/group_service.py +++ b/src/services/group_service.py @@ -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}")