Fix path
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
87
src/main.py
87
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(
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user