Fix path
This commit is contained in:
@@ -20,15 +20,16 @@ The following commands can ONLY be run from the admin chat:
|
||||
```
|
||||
/admin_add_user <username> - Add new user to database
|
||||
/admin_get_users - View default users list
|
||||
/admin_set_users <id1> <id2> ... - Replace entire default users list
|
||||
/admin_add_users <id1> <id2> ... - Add users to default list
|
||||
/admin_remove_users <id1> <id2> - Remove users from default list
|
||||
/admin_set_users <id_or_username> ... - Replace entire default users list
|
||||
/admin_add_users <id_or_username> ... - Add users to default list
|
||||
/admin_remove_users <id_or_username> - Remove users from default list
|
||||
```
|
||||
|
||||
#### GroupHelp QR Code Backup
|
||||
```
|
||||
/admin_get_qr - Retrieve GroupHelp backup QR data
|
||||
/admin_set_qr <qr_code> - Store GroupHelp backup QR data for `.importbackup`
|
||||
/admin_set_qr <qr_payload> - Store GroupHelp backup payload rendered as a QR image for `.importbackup`
|
||||
/admin_set_qr - Wait for a forwarded `.importbackup` QR image and decode it
|
||||
```
|
||||
|
||||
#### Help
|
||||
@@ -119,6 +120,11 @@ Bot: ✅ Default users updated successfully:
|
||||
|
||||
Admin: /admin_set_qr 0001a8ac0123456789abcdef...
|
||||
Bot: ✅ QR backup data updated successfully!
|
||||
|
||||
Admin: /admin_set_qr
|
||||
Bot: 📷 Forward the original GroupHelp QR image message here.
|
||||
Admin: forwards original `.importbackup` QR image message
|
||||
Bot: ✅ QR backup image decoded and stored successfully!
|
||||
```
|
||||
|
||||
### User Usage (any chat)
|
||||
@@ -215,14 +221,14 @@ MONGODB_COLLECTION=ghconfig
|
||||
### Test GroupHelp QR Backup
|
||||
```bash
|
||||
# In admin chat:
|
||||
/admin_set_qr myqrcode123
|
||||
/admin_set_qr new_390530c569ec8902a6cbd8482d51d70d2857d4a0
|
||||
# ✅ QR backup data updated successfully!
|
||||
|
||||
/admin_get_qr
|
||||
# 📊 Current QR Backup Data: myqrcode123
|
||||
# 📊 Current QR Backup Data: new_390530c569ec8902a6cbd8482d51d70d2857d4a0
|
||||
|
||||
# In the target group:
|
||||
.importbackup myqrcode123
|
||||
# During !newgrp, GroupFactory sends the payload as a QR image with:
|
||||
.importbackup
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
@@ -131,30 +131,38 @@ This will create ProjectBeta with only alice and bob, regardless of the default
|
||||
|
||||
### What is QR Backup Data?
|
||||
|
||||
QR backup data is the GroupHelp settings backup payload generated by GroupHelp itself. It is used to copy GroupHelp settings, such as welcome-message configuration, from one group to another by pasting the QR payload as the body of a `.importbackup` message.
|
||||
QR backup data is the GroupHelp settings backup payload generated by GroupHelp itself. It is used to copy GroupHelp settings, such as welcome-message configuration, from one group to another by rendering the payload as a QR image and sending it with a `.importbackup` caption.
|
||||
|
||||
### Step 1: Generate or Obtain QR Code
|
||||
|
||||
Generate the QR backup from GroupHelp in the source group, then store the decoded/pasted payload here so it can be reused when importing those settings into another group.
|
||||
Generate the QR backup from GroupHelp in the source group. You can either store the decoded payload directly, or start the interactive importer and forward the original GroupHelp QR image message.
|
||||
|
||||
### Step 2: Set GroupHelp QR Backup Data
|
||||
|
||||
Store the GroupHelp QR backup data in the database:
|
||||
Store the GroupHelp QR backup payload directly:
|
||||
|
||||
```
|
||||
/set_qr_backup YOUR_QR_CODE_HERE
|
||||
/admin_set_qr YOUR_QR_PAYLOAD_HERE
|
||||
```
|
||||
|
||||
Example:
|
||||
```
|
||||
/set_qr_backup 0001a8ac0123456789abcdef0123456789abcdef01234567
|
||||
/admin_set_qr new_390530c569ec8902a6cbd8482d51d70d2857d4a0
|
||||
```
|
||||
|
||||
Or start the interactive QR importer:
|
||||
|
||||
```
|
||||
/admin_set_qr
|
||||
```
|
||||
|
||||
Then forward the original GroupHelp QR image message into the staff chat. The forwarded image message must have `.importbackup` as its caption/body. GroupFactory decodes the QR image and stores the decoded payload.
|
||||
|
||||
Response:
|
||||
```
|
||||
✅ QR backup data updated successfully!
|
||||
|
||||
Data: `0001a8ac0123456789abcdef0123456789abcdef01234567`
|
||||
Data: `new_390530c569ec8902a6cbd8482d51d70d2857d4a0`
|
||||
```
|
||||
|
||||
### Step 3: Retrieve GroupHelp QR Backup Data
|
||||
@@ -162,21 +170,21 @@ Data: `0001a8ac0123456789abcdef0123456789abcdef01234567`
|
||||
Retrieve the stored GroupHelp QR backup data anytime:
|
||||
|
||||
```
|
||||
/get_qr_backup
|
||||
/admin_get_qr
|
||||
```
|
||||
|
||||
Response:
|
||||
```
|
||||
📊 Current QR Backup Data:
|
||||
`0001a8ac0123456789abcdef0123456789abcdef01234567`
|
||||
`new_390530c569ec8902a6cbd8482d51d70d2857d4a0`
|
||||
```
|
||||
|
||||
### Step 4: Use QR Backup with GroupHelp
|
||||
|
||||
Once you have the QR backup data stored, you can:
|
||||
1. Retrieve it with `/get_qr_backup`
|
||||
2. Paste it into the target group as the message body for `.importbackup`
|
||||
3. Let GroupHelp import the backed-up settings into that group
|
||||
1. Retrieve it with `/admin_get_qr`
|
||||
2. Let GroupFactory render it as a QR image during `!newgrp`
|
||||
3. Let GroupHelp import the backed-up settings from the `.importbackup` image message
|
||||
|
||||
---
|
||||
|
||||
@@ -241,10 +249,10 @@ The configuration data is stored in MongoDB with the following structure:
|
||||
|
||||
```bash
|
||||
# Retrieve the stored GroupHelp QR payload:
|
||||
/get_qr_backup
|
||||
/admin_get_qr
|
||||
|
||||
# In the target group, paste the payload with:
|
||||
.importbackup <paste_qr_code_here>
|
||||
# During !newgrp, GroupFactory sends the payload as a QR image with:
|
||||
.importbackup
|
||||
```
|
||||
|
||||
---
|
||||
@@ -265,8 +273,8 @@ The configuration data is stored in MongoDB with the following structure:
|
||||
|
||||
### Issue: GroupHelp QR backup data appears empty
|
||||
**Solution:**
|
||||
1. Check if data was stored: `/get_qr_backup`
|
||||
2. Re-set the data: `/set_qr_backup <your_qr_code>`
|
||||
1. Check if data was stored: `/admin_get_qr`
|
||||
2. Re-set the data: `/admin_set_qr <your_qr_payload>` or use `/admin_set_qr` and forward the `.importbackup` QR image
|
||||
|
||||
---
|
||||
|
||||
@@ -309,8 +317,9 @@ FACTORY_BOT_USERNAME=your_bot_username
|
||||
- `/set_default_users <id1> <id2> ...` - Set default users (replace)
|
||||
- `/add_default_users <id1> <id2> ...` - Add users to default list
|
||||
- `/remove_default_users <id1> <id2> ...` - Remove users from default list
|
||||
- `/get_qr_backup` - View GroupHelp QR backup data
|
||||
- `/set_qr_backup <data>` - Set GroupHelp QR backup data
|
||||
- `/admin_get_qr` - View GroupHelp QR backup data
|
||||
- `/admin_set_qr <qr_payload>` - Set GroupHelp QR backup data directly
|
||||
- `/admin_set_qr` - Decode a forwarded `.importbackup` QR image
|
||||
- `/config_help` - Show configuration command help
|
||||
- `/help` - Show all available commands
|
||||
|
||||
@@ -362,7 +371,7 @@ db.ghconfig.find({key: 'qr_backup_data'})
|
||||
## Operational Notes
|
||||
|
||||
1. QR backup data is a GroupHelp configuration payload, not a Telegram or Telethon session.
|
||||
2. Use `.importbackup` in the target group to import the stored GroupHelp settings.
|
||||
2. During `!newgrp`, GroupFactory sends `.importbackup` with the stored GroupHelp settings as a QR image.
|
||||
3. Limit configuration commands to trusted chats so group settings are changed intentionally.
|
||||
4. Monitor command usage if multiple operators manage the same groups.
|
||||
|
||||
|
||||
11
README.md
11
README.md
@@ -7,7 +7,7 @@ A Telegram user API service for managing groups with MongoDB backend and admin-o
|
||||
- ✅ Create and manage Telegram groups with default user lists
|
||||
- ✅ Admin-only configuration (requires STAFF_CHAT_ID)
|
||||
- ✅ User management with MongoDB storage
|
||||
- ✅ GroupHelp settings backup QR storage for `.importbackup`
|
||||
- ✅ GroupHelp settings backup payload rendered as a QR image for `.importbackup`
|
||||
- ✅ Interactive admin role selection for group creators
|
||||
- ✅ Modular architecture with separation of concerns
|
||||
- ✅ Telegram user API integration using Telethon
|
||||
@@ -88,11 +88,12 @@ docker run telegram-groupfactory
|
||||
### Admin Commands (Admin Chat Only)
|
||||
```
|
||||
/admin_add_user <username> - Add user to database
|
||||
/admin_set_users <id1> <id2> ... - Set default users for groups
|
||||
/admin_add_users <id1> <id2> ... - Add users to default list
|
||||
/admin_remove_users <id1> <id2> - Remove users from default list
|
||||
/admin_set_users <id_or_username> ... - Set default users for groups
|
||||
/admin_add_users <id_or_username> ... - Add users 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_code> - Store GroupHelp backup QR data
|
||||
/admin_set_qr <qr_payload> - Store GroupHelp backup payload rendered as a QR image
|
||||
/admin_set_qr - Decode a forwarded `.importbackup` QR image
|
||||
/admin_get_qr - Retrieve GroupHelp backup QR data
|
||||
```
|
||||
|
||||
|
||||
@@ -4,3 +4,6 @@ python-dotenv>=0.19.0
|
||||
fastapi>=0.95.0
|
||||
uvicorn>=0.21.0
|
||||
pydantic>=1.10.0
|
||||
numpy>=1.26.0
|
||||
opencv-python-headless>=4.9.0.80
|
||||
qrcode[pil]>=7.4.2
|
||||
|
||||
@@ -6,14 +6,19 @@ from src.config import (
|
||||
)
|
||||
from src.services.user_service import UserService
|
||||
from src.models.user import User
|
||||
from src.utils.qr_backup import decode_qr_image_payload, normalize_qr_payload
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AdminHandler:
|
||||
"""Handler class for admin configuration commands"""
|
||||
|
||||
def __init__(self, user_service: UserService):
|
||||
def __init__(self, user_service: UserService, telegram_client=None):
|
||||
self.user_service = user_service
|
||||
self.telegram_client = telegram_client
|
||||
|
||||
def set_client(self, telegram_client):
|
||||
self.telegram_client = telegram_client
|
||||
|
||||
def _ensure_user_record(self, user_id: int) -> tuple:
|
||||
user = self.user_service.get_user_by_id(user_id)
|
||||
@@ -31,6 +36,66 @@ class AdminHandler:
|
||||
username = f"@{user.username}" if user.username else user.name
|
||||
return f" • {username} (ID: {user_id}){created_note}"
|
||||
|
||||
def _is_numeric_id(self, value) -> bool:
|
||||
return str(value).strip().lstrip("-").isdigit()
|
||||
|
||||
def _user_from_entity(self, entity, fallback_username: str = None) -> User:
|
||||
username = getattr(entity, "username", None) or fallback_username
|
||||
return User(
|
||||
id=entity.id,
|
||||
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),
|
||||
)
|
||||
|
||||
async def _resolve_user_identifier(self, identifier) -> tuple:
|
||||
identifier = str(identifier).strip().rstrip(",")
|
||||
if not identifier:
|
||||
raise ValueError("Empty user identifier")
|
||||
|
||||
if self._is_numeric_id(identifier):
|
||||
user_id = int(identifier)
|
||||
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)
|
||||
user, created = self._ensure_user_record(user_id)
|
||||
return user_id, user, created
|
||||
|
||||
if not self.telegram_client:
|
||||
raise RuntimeError("Telegram client is required to resolve usernames")
|
||||
|
||||
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
|
||||
|
||||
async def _resolve_user_identifiers(self, identifiers: List) -> tuple:
|
||||
resolved = []
|
||||
created_users = []
|
||||
errors = []
|
||||
|
||||
for identifier in identifiers:
|
||||
try:
|
||||
user_id, _, created = await self._resolve_user_identifier(identifier)
|
||||
resolved.append(user_id)
|
||||
if created:
|
||||
created_users.append(user_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to resolve user identifier {identifier}: {e}")
|
||||
errors.append(f"{identifier}: {e}")
|
||||
|
||||
return resolved, created_users, errors
|
||||
|
||||
def verify_access(self, chat_id: int) -> tuple:
|
||||
"""Verify admin access"""
|
||||
return verify_admin_access(chat_id)
|
||||
@@ -44,13 +109,13 @@ class AdminHandler:
|
||||
try:
|
||||
default_users = get_default_group_users()
|
||||
if not default_users:
|
||||
return "📋 No default users configured yet.\n\nUse `/admin_set_users <user_id1> <user_id2> ...` to configure them."
|
||||
return "📋 No default users configured yet.\n\nUse `/admin_set_users <id_or_username1> <id_or_username2> ...` to configure them."
|
||||
|
||||
user_list = []
|
||||
for user_id in default_users:
|
||||
user = self.user_service.get_user_by_id(user_id)
|
||||
if user:
|
||||
user_list.append(f" • {user.username} (ID: {user_id})")
|
||||
user_list.append(self._format_user(user, user_id))
|
||||
else:
|
||||
user_list.append(f" • Unknown User (ID: {user_id})")
|
||||
|
||||
@@ -59,7 +124,7 @@ class AdminHandler:
|
||||
logger.error(f"Error getting default users: {e}")
|
||||
return f"❌ Error retrieving default users: {str(e)}"
|
||||
|
||||
async def handle_set_default_users(self, chat_id: int, user_ids: List[int]) -> str:
|
||||
async def handle_set_default_users(self, chat_id: int, user_ids: List) -> str:
|
||||
"""Set default users for new groups (Admin only)"""
|
||||
is_admin, error = self.verify_access(chat_id)
|
||||
if not is_admin:
|
||||
@@ -67,16 +132,11 @@ class AdminHandler:
|
||||
|
||||
try:
|
||||
if not user_ids:
|
||||
return "❌ Please provide at least one user ID.\n\nUsage: `/admin_set_users <user_id1> <user_id2> ...`"
|
||||
return "❌ Please provide at least one user ID or username.\n\nUsage: `/admin_set_users <id_or_username1> <id_or_username2> ...`"
|
||||
|
||||
valid_user_ids = []
|
||||
created_users = []
|
||||
|
||||
for user_id in user_ids:
|
||||
_, created = self._ensure_user_record(user_id)
|
||||
valid_user_ids.append(user_id)
|
||||
if created:
|
||||
created_users.append(user_id)
|
||||
valid_user_ids, created_users, errors = await self._resolve_user_identifiers(user_ids)
|
||||
if not valid_user_ids:
|
||||
return "❌ No valid users could be resolved.\n" + "\n".join(errors)
|
||||
|
||||
# Set default users
|
||||
if set_default_group_users(valid_user_ids):
|
||||
@@ -88,7 +148,9 @@ class AdminHandler:
|
||||
|
||||
response = "✅ Default users updated successfully:\n" + "\n".join(user_list)
|
||||
if created_users:
|
||||
response += "\n\nℹ️ Placeholder records were created for new Telegram IDs."
|
||||
response += "\n\nℹ️ New user records were created."
|
||||
if errors:
|
||||
response += "\n\n⚠️ Some identifiers could not be resolved:\n" + "\n".join(errors)
|
||||
|
||||
return response
|
||||
else:
|
||||
@@ -97,7 +159,7 @@ class AdminHandler:
|
||||
logger.error(f"Error setting default users: {e}")
|
||||
return f"❌ Error setting default users: {str(e)}"
|
||||
|
||||
async def handle_add_to_default_users(self, chat_id: int, user_ids: List[int]) -> str:
|
||||
async def handle_add_to_default_users(self, chat_id: int, user_ids: List) -> str:
|
||||
"""Add users to existing default users list (Admin only)"""
|
||||
is_admin, error = self.verify_access(chat_id)
|
||||
if not is_admin:
|
||||
@@ -105,22 +167,18 @@ class AdminHandler:
|
||||
|
||||
try:
|
||||
if not user_ids:
|
||||
return "❌ Please provide at least one user ID.\n\nUsage: `/admin_add_users <user_id1> <user_id2> ...`"
|
||||
return "❌ Please provide at least one user ID or username.\n\nUsage: `/admin_add_users <id_or_username1> <id_or_username2> ...`"
|
||||
|
||||
current_users = get_default_group_users()
|
||||
|
||||
valid_user_ids = []
|
||||
created_users = []
|
||||
|
||||
for user_id in user_ids:
|
||||
if user_id not in current_users:
|
||||
_, created = self._ensure_user_record(user_id)
|
||||
valid_user_ids.append(user_id)
|
||||
if created:
|
||||
created_users.append(user_id)
|
||||
resolved_user_ids, created_users, errors = await self._resolve_user_identifiers(user_ids)
|
||||
valid_user_ids = [user_id for user_id in resolved_user_ids if user_id not in current_users]
|
||||
|
||||
if not valid_user_ids:
|
||||
return "ℹ️ All provided users are already in the default list."
|
||||
response = "ℹ️ All resolved users are already in the default list."
|
||||
if errors:
|
||||
response += "\n\n⚠️ Some identifiers could not be resolved:\n" + "\n".join(errors)
|
||||
return response
|
||||
|
||||
# Add to default users
|
||||
updated_users = current_users + valid_user_ids
|
||||
@@ -133,7 +191,9 @@ class AdminHandler:
|
||||
|
||||
response = "✅ Users added to default list:\n" + "\n".join(user_list)
|
||||
if created_users:
|
||||
response += "\n\nℹ️ Placeholder records were created for new Telegram IDs."
|
||||
response += "\n\nℹ️ New user records were created."
|
||||
if errors:
|
||||
response += "\n\n⚠️ Some identifiers could not be resolved:\n" + "\n".join(errors)
|
||||
|
||||
return response
|
||||
else:
|
||||
@@ -142,7 +202,7 @@ class AdminHandler:
|
||||
logger.error(f"Error adding to default users: {e}")
|
||||
return f"❌ Error adding to default users: {str(e)}"
|
||||
|
||||
async def handle_remove_from_default_users(self, chat_id: int, user_ids: List[int]) -> str:
|
||||
async def handle_remove_from_default_users(self, chat_id: int, user_ids: List) -> str:
|
||||
"""Remove users from default users list (Admin only)"""
|
||||
is_admin, error = self.verify_access(chat_id)
|
||||
if not is_admin:
|
||||
@@ -150,16 +210,20 @@ class AdminHandler:
|
||||
|
||||
try:
|
||||
if not user_ids:
|
||||
return "❌ Please provide at least one user ID.\n\nUsage: `/admin_remove_users <user_id1> <user_id2> ...`"
|
||||
return "❌ Please provide at least one user ID or username.\n\nUsage: `/admin_remove_users <id_or_username1> <id_or_username2> ...`"
|
||||
|
||||
current_users = get_default_group_users()
|
||||
resolved_user_ids, _, errors = await self._resolve_user_identifiers(user_ids)
|
||||
|
||||
# Find users to remove
|
||||
users_to_remove = [uid for uid in user_ids if uid in current_users]
|
||||
users_to_remove = [uid for uid in resolved_user_ids if uid in current_users]
|
||||
|
||||
if not users_to_remove:
|
||||
not_in_list = [uid for uid in user_ids if uid not in current_users]
|
||||
return f"ℹ️ These users are not in the default list: {not_in_list}"
|
||||
not_in_list = [uid for uid in resolved_user_ids if uid not in current_users]
|
||||
response = f"ℹ️ These users are not in the default list: {not_in_list}"
|
||||
if errors:
|
||||
response += "\n\n⚠️ Some identifiers could not be resolved:\n" + "\n".join(errors)
|
||||
return response
|
||||
|
||||
# Remove users
|
||||
updated_users = [uid for uid in current_users if uid not in users_to_remove]
|
||||
@@ -172,7 +236,10 @@ class AdminHandler:
|
||||
else:
|
||||
user_list.append(f" • Unknown User (ID: {user_id})")
|
||||
|
||||
return "✅ Users removed from default list:\n" + "\n".join(user_list)
|
||||
response = "✅ Users removed from default list:\n" + "\n".join(user_list)
|
||||
if errors:
|
||||
response += "\n\n⚠️ Some identifiers could not be resolved:\n" + "\n".join(errors)
|
||||
return response
|
||||
else:
|
||||
return "❌ Failed to update default users"
|
||||
except Exception as e:
|
||||
@@ -189,18 +256,9 @@ class AdminHandler:
|
||||
if not username or len(username.strip()) == 0:
|
||||
return "❌ Please provide a username.\n\nUsage: `/admin_add_user <username>`"
|
||||
|
||||
username = username.strip()
|
||||
|
||||
# Generate user_id as a hash of the username
|
||||
import hashlib
|
||||
user_id = int(hashlib.md5(username.encode()).hexdigest()[:8], 16)
|
||||
|
||||
user = User(id=user_id, username=username, first_name=username)
|
||||
success = self.user_service.save_user(user)
|
||||
if success:
|
||||
return f"✅ User {username} added successfully (ID: {user_id})"
|
||||
else:
|
||||
return "❌ Failed to add user"
|
||||
user_id, user, created = await self._resolve_user_identifier(username)
|
||||
action = "added" if created else "updated"
|
||||
return f"✅ User @{user.username or username.lstrip('@')} {action} successfully (ID: {user_id})"
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding user {username}: {e}")
|
||||
return f"❌ Error adding user: {str(e)}"
|
||||
@@ -216,11 +274,20 @@ class AdminHandler:
|
||||
if qr_data:
|
||||
return f"📊 Current QR Backup Data:\n`{qr_data}`"
|
||||
else:
|
||||
return "📋 No QR backup data configured yet.\n\nUse `/admin_set_qr <qr_code>` to configure it."
|
||||
return "📋 No QR backup data configured yet.\n\nUse `/admin_set_qr <qr_payload>` to configure it."
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting QR backup: {e}")
|
||||
return f"❌ Error retrieving QR backup: {str(e)}"
|
||||
|
||||
def _store_qr_backup_payload(self, qr_data: str) -> tuple:
|
||||
payload = normalize_qr_payload(qr_data)
|
||||
if not payload:
|
||||
return False, "❌ QR backup data cannot be empty.\n\nUsage: `/admin_set_qr <qr_payload>`", None
|
||||
|
||||
if set_qr_backup_data(payload):
|
||||
return True, f"✅ QR backup data updated successfully!\n\nData: `{payload}`", payload
|
||||
return False, "❌ Failed to save QR backup data", None
|
||||
|
||||
async def handle_set_qr_backup(self, chat_id: int, qr_data: str) -> str:
|
||||
"""Set QR backup data (Admin only)"""
|
||||
is_admin, error = self.verify_access(chat_id)
|
||||
@@ -228,17 +295,32 @@ class AdminHandler:
|
||||
return error
|
||||
|
||||
try:
|
||||
if not qr_data or len(qr_data.strip()) == 0:
|
||||
return "❌ QR backup data cannot be empty.\n\nUsage: `/admin_set_qr <qr_code>`"
|
||||
|
||||
if set_qr_backup_data(qr_data):
|
||||
return f"✅ QR backup data updated successfully!\n\nData: `{qr_data}`"
|
||||
else:
|
||||
return "❌ Failed to save QR backup data"
|
||||
_, message, _ = self._store_qr_backup_payload(qr_data)
|
||||
return message
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting QR backup: {e}")
|
||||
return f"❌ Error setting QR backup: {str(e)}"
|
||||
|
||||
async def handle_set_qr_backup_from_image(self, chat_id: int, image_bytes: bytes) -> str:
|
||||
"""Decode and store QR backup data from a forwarded GroupHelp QR image."""
|
||||
is_admin, error = self.verify_access(chat_id)
|
||||
if not is_admin:
|
||||
return error
|
||||
|
||||
try:
|
||||
payload = decode_qr_image_payload(image_bytes)
|
||||
saved, message, _ = self._store_qr_backup_payload(payload)
|
||||
if saved:
|
||||
return message.replace(
|
||||
"QR backup data updated successfully",
|
||||
"QR backup image decoded and stored successfully",
|
||||
1,
|
||||
)
|
||||
return message
|
||||
except Exception as e:
|
||||
logger.error(f"Error decoding QR backup image: {e}")
|
||||
return f"❌ Error decoding QR backup image: {str(e)}"
|
||||
|
||||
async def handle_admin_help(self, chat_id: int) -> str:
|
||||
"""Show admin command help (Admin only)"""
|
||||
is_admin, error = self.verify_access(chat_id)
|
||||
@@ -248,18 +330,19 @@ class AdminHandler:
|
||||
return """🔐 Admin Configuration Commands:
|
||||
|
||||
**Default Group Users Management:**
|
||||
• `/admin_set_users <id1> <id2> ...` - Replace entire default users list
|
||||
• `/admin_add_users <id1> <id2> ...` - Add users to default list
|
||||
• `/admin_remove_users <id1> <id2> ...` - Remove users from default list
|
||||
• `/admin_set_users <id_or_username> ...` - Replace entire default users list
|
||||
• `/admin_add_users <id_or_username> ...` - Add users to default list
|
||||
• `/admin_remove_users <id_or_username> ...` - Remove users from default list
|
||||
• `/admin_get_users` - Show current default users
|
||||
• `/admin_add_user <username>` - Add new user to database
|
||||
• `/admin_add_user <username>` - Resolve and save a user in the database
|
||||
|
||||
**QR Code Backup:**
|
||||
• `/admin_get_qr` - Get current QR backup data
|
||||
• `/admin_set_qr <qr_code>` - Set GroupHelp backup QR data
|
||||
• `/admin_set_qr <qr_payload>` - Set GroupHelp backup payload rendered as a QR image
|
||||
• `/admin_set_qr` - Wait for a forwarded `.importbackup` QR image and decode it
|
||||
|
||||
**Notes:**
|
||||
✓ All admin commands work ONLY from the admin chat
|
||||
✓ Users will be automatically added to new groups
|
||||
✓ QR backup data is used with GroupHelp `.importbackup`
|
||||
✓ QR backup data is sent as a QR image with GroupHelp `.importbackup`
|
||||
✓ All config changes are stored in MongoDB"""
|
||||
|
||||
@@ -163,7 +163,7 @@ class ConfigHandler:
|
||||
if qr_data:
|
||||
return f"📊 Current QR Backup Data:\n`{qr_data}`"
|
||||
else:
|
||||
return "📋 No QR backup data configured yet.\n\nUse `/set_qr_backup <qr_code>` to configure it."
|
||||
return "📋 No QR backup data configured yet.\n\nUse `/set_qr_backup <qr_payload>` to configure it."
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting QR backup: {e}")
|
||||
return f"❌ Error retrieving QR backup: {str(e)}"
|
||||
@@ -172,7 +172,7 @@ class ConfigHandler:
|
||||
"""Set QR backup data"""
|
||||
try:
|
||||
if not qr_data or len(qr_data.strip()) == 0:
|
||||
return "❌ QR backup data cannot be empty.\n\nUsage: `/set_qr_backup <qr_code>`"
|
||||
return "❌ QR backup data cannot be empty.\n\nUsage: `/set_qr_backup <qr_payload>`"
|
||||
|
||||
if set_qr_backup_data(qr_data):
|
||||
return f"✅ QR backup data updated successfully!\n\nData: `{qr_data}`"
|
||||
@@ -194,6 +194,6 @@ class ConfigHandler:
|
||||
|
||||
**QR Code Backup:**
|
||||
• `/get_qr_backup` - Get current QR backup data
|
||||
• `/set_qr_backup <qr_code>` - Set GroupHelp backup QR data for `.importbackup`
|
||||
• `/set_qr_backup <qr_payload>` - Set GroupHelp backup payload rendered as a QR image for `.importbackup`
|
||||
|
||||
When a new group is created without specifying users, all configured default users will be automatically added."""
|
||||
|
||||
96
src/main.py
96
src/main.py
@@ -50,7 +50,9 @@ async def main():
|
||||
config['telegram']['api_hash']
|
||||
)
|
||||
group_service.set_client(client)
|
||||
admin_handler.set_client(client)
|
||||
group_conversations = {}
|
||||
qr_import_sessions = {}
|
||||
|
||||
async def create_group_from_staff_flow(event, name: str, description: str):
|
||||
response = await group_handler.handle_create_group(
|
||||
@@ -66,11 +68,36 @@ async def main():
|
||||
def is_staff_chat(chat_id: int) -> bool:
|
||||
return chat_id == staff_chat_id
|
||||
|
||||
def is_importbackup_body(raw_text: str) -> bool:
|
||||
return (raw_text or "").strip().lower() in (".importbackup", "/importbackup")
|
||||
|
||||
def has_image_media(message) -> bool:
|
||||
if getattr(message, "photo", None):
|
||||
return True
|
||||
document = getattr(message, "document", None)
|
||||
mime_type = getattr(document, "mime_type", "") if document else ""
|
||||
return mime_type.startswith("image/")
|
||||
|
||||
async def handle_forwarded_qr_import(event, raw_text: str) -> str:
|
||||
message = event.message
|
||||
if not message.fwd_from:
|
||||
return "❌ Please forward the original GroupHelp QR image message. Do not upload or paste it manually."
|
||||
if not is_importbackup_body(raw_text):
|
||||
return "❌ Forwarded QR message must have `.importbackup` as its caption/body."
|
||||
if not message.media or not has_image_media(message):
|
||||
return "❌ Forwarded `.importbackup` message must contain a QR image."
|
||||
|
||||
image_bytes = await client.download_media(message, file=bytes)
|
||||
if not image_bytes:
|
||||
return "❌ Could not download the forwarded QR image."
|
||||
|
||||
return await admin_handler.handle_set_qr_backup_from_image(event.chat_id, image_bytes)
|
||||
|
||||
@client.on(events.NewMessage())
|
||||
async def message_handler(event):
|
||||
"""Handle incoming messages and route to appropriate handlers"""
|
||||
message = event.message
|
||||
text = message.text or ""
|
||||
text = getattr(message, "raw_text", None) or getattr(message, "text", None) or getattr(message, "message", "") or ""
|
||||
chat_id = event.chat_id
|
||||
sender_id = event.sender_id
|
||||
|
||||
@@ -100,13 +127,31 @@ async def main():
|
||||
return
|
||||
|
||||
if lower_text == '!cancel':
|
||||
canceled = False
|
||||
if sender_id in group_conversations:
|
||||
del group_conversations[sender_id]
|
||||
canceled = True
|
||||
if sender_id in qr_import_sessions:
|
||||
del qr_import_sessions[sender_id]
|
||||
canceled = True
|
||||
|
||||
if canceled:
|
||||
await event.respond('🛑 Operation canceled.')
|
||||
else:
|
||||
await event.respond('❓ No active operation to cancel.')
|
||||
return
|
||||
|
||||
if sender_id in qr_import_sessions and not lower_text.startswith('/admin_set_qr'):
|
||||
session = qr_import_sessions[sender_id]
|
||||
if session["chat_id"] != chat_id:
|
||||
return
|
||||
|
||||
response = await handle_forwarded_qr_import(event, raw_text)
|
||||
if response.startswith("✅"):
|
||||
qr_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()
|
||||
@@ -186,43 +231,28 @@ async def main():
|
||||
await event.respond(response)
|
||||
|
||||
elif text.startswith('/admin_set_users'):
|
||||
# Parse user IDs from command
|
||||
parts = text.split()
|
||||
if len(parts) > 1:
|
||||
try:
|
||||
user_ids = [int(uid) for uid in parts[1:]]
|
||||
response = await admin_handler.handle_set_default_users(chat_id, user_ids)
|
||||
response = await admin_handler.handle_set_default_users(chat_id, parts[1:])
|
||||
await event.respond(response)
|
||||
except ValueError:
|
||||
await event.respond("❌ Invalid user IDs. Please provide numeric IDs.\n\nUsage: `/admin_set_users <user_id1> <user_id2> ...`")
|
||||
else:
|
||||
await event.respond("❌ Please provide at least one user ID.\n\nUsage: `/admin_set_users <user_id1> <user_id2> ...`")
|
||||
await event.respond("❌ Please provide at least one user ID or username.\n\nUsage: `/admin_set_users <id_or_username1> <id_or_username2> ...`")
|
||||
|
||||
elif text.startswith('/admin_add_users'):
|
||||
# Parse user IDs from command
|
||||
parts = text.split()
|
||||
if len(parts) > 1:
|
||||
try:
|
||||
user_ids = [int(uid) for uid in parts[1:]]
|
||||
response = await admin_handler.handle_add_to_default_users(chat_id, user_ids)
|
||||
response = await admin_handler.handle_add_to_default_users(chat_id, parts[1:])
|
||||
await event.respond(response)
|
||||
except ValueError:
|
||||
await event.respond("❌ Invalid user IDs. Please provide numeric IDs.\n\nUsage: `/admin_add_users <user_id1> <user_id2> ...`")
|
||||
else:
|
||||
await event.respond("❌ Please provide at least one user ID.\n\nUsage: `/admin_add_users <user_id1> <user_id2> ...`")
|
||||
await event.respond("❌ Please provide at least one user ID or username.\n\nUsage: `/admin_add_users <id_or_username1> <id_or_username2> ...`")
|
||||
|
||||
elif text.startswith('/admin_remove_users'):
|
||||
# Parse user IDs from command
|
||||
parts = text.split()
|
||||
if len(parts) > 1:
|
||||
try:
|
||||
user_ids = [int(uid) for uid in parts[1:]]
|
||||
response = await admin_handler.handle_remove_from_default_users(chat_id, user_ids)
|
||||
response = await admin_handler.handle_remove_from_default_users(chat_id, parts[1:])
|
||||
await event.respond(response)
|
||||
except ValueError:
|
||||
await event.respond("❌ Invalid user IDs. Please provide numeric IDs.\n\nUsage: `/admin_remove_users <user_id1> <user_id2> ...`")
|
||||
else:
|
||||
await event.respond("❌ Please provide at least one user ID.\n\nUsage: `/admin_remove_users <user_id1> <user_id2> ...`")
|
||||
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
|
||||
@@ -239,14 +269,23 @@ async def main():
|
||||
await event.respond(response)
|
||||
|
||||
elif text.startswith('/admin_set_qr'):
|
||||
# Parse QR data from command
|
||||
parts = text.split(maxsplit=1)
|
||||
if len(parts) > 1:
|
||||
qr_data = parts[1].strip()
|
||||
response = await admin_handler.handle_set_qr_backup(chat_id, qr_data)
|
||||
qr_import_sessions.pop(sender_id, None)
|
||||
await event.respond(response)
|
||||
else:
|
||||
await event.respond("❌ Please provide QR backup data.\n\nUsage: `/admin_set_qr <qr_code>`")
|
||||
is_admin, error = admin_handler.verify_access(chat_id)
|
||||
if not is_admin:
|
||||
await event.respond(error)
|
||||
else:
|
||||
qr_import_sessions[sender_id] = {"chat_id": chat_id}
|
||||
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. "
|
||||
"Send `!cancel` to abort."
|
||||
)
|
||||
|
||||
elif text.startswith('/admin_help'):
|
||||
response = await admin_handler.handle_admin_help(chat_id)
|
||||
@@ -342,12 +381,13 @@ async def main():
|
||||
|
||||
**Admin Commands (Admin Chat Only):**
|
||||
• `/admin_get_users` - Show default group users
|
||||
• `/admin_set_users <id1> <id2>` - Set default users
|
||||
• `/admin_add_users <id1> <id2>` - Add users to default list
|
||||
• `/admin_remove_users <id1> <id2>` - Remove users from default list
|
||||
• `/admin_set_users <id_or_username> ...` - Set default users
|
||||
• `/admin_add_users <id_or_username> ...` - Add users to default list
|
||||
• `/admin_remove_users <id_or_username> ...` - Remove users from default list
|
||||
• `/admin_add_user <username>` - Add new user to database
|
||||
• `/admin_get_qr` - Get QR backup data
|
||||
• `/admin_set_qr <data>` - Set QR backup data
|
||||
• `/admin_set_qr <qr_payload>` - Set QR backup data directly
|
||||
• `/admin_set_qr` - Decode a forwarded `.importbackup` QR image
|
||||
• `/admin_help` - Show admin command help"""
|
||||
await event.respond(help_text)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from telethon.tl.functions.channels import CreateChannelRequest, InviteToChannel
|
||||
|
||||
from src.services.mongodb_service import MongoDBService
|
||||
from src.config import get_default_group_users, get_qr_data
|
||||
from src.utils.qr_backup import build_qr_image
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -92,12 +93,6 @@ class GroupService:
|
||||
logger.warning(f"Failed to export invite link: {e}")
|
||||
return None
|
||||
|
||||
def _build_importbackup_message(self, qr_data: str) -> str:
|
||||
qr_data = qr_data.strip()
|
||||
if qr_data.lower().startswith((".importbackup", "/importbackup")):
|
||||
return qr_data
|
||||
return f".importbackup {qr_data}"
|
||||
|
||||
async def create_group(
|
||||
self,
|
||||
group_name: str,
|
||||
@@ -220,16 +215,19 @@ class GroupService:
|
||||
qr_imported = False
|
||||
qr_data = get_qr_data()
|
||||
if qr_data:
|
||||
logger.info("Sending GroupHelp QR backup import command")
|
||||
await self.client.send_message(
|
||||
logger.info("Sending GroupHelp QR backup import image")
|
||||
qr_image = build_qr_image(qr_data)
|
||||
await self.client.send_file(
|
||||
target_group,
|
||||
self._build_importbackup_message(qr_data),
|
||||
qr_image,
|
||||
caption=".importbackup",
|
||||
force_document=False,
|
||||
)
|
||||
qr_imported = True
|
||||
if staff_chat_id:
|
||||
await self.client.send_message(staff_chat_id, "✅ GroupHelp QR backup import sent to the new group.")
|
||||
await self.client.send_message(staff_chat_id, "✅ GroupHelp QR backup image sent to the new group.")
|
||||
elif staff_chat_id:
|
||||
await self.client.send_message(staff_chat_id, "⚠️ No GroupHelp QR backup data configured. Use `/admin_set_qr <qr_data>`.")
|
||||
await self.client.send_message(staff_chat_id, "⚠️ No GroupHelp QR backup data configured. Use `/admin_set_qr <qr_payload>`.")
|
||||
|
||||
invite_link = await self._export_invite_link(target_group)
|
||||
if staff_chat_id:
|
||||
|
||||
1
src/utils/__init__.py
Normal file
1
src/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
77
src/utils/qr_backup.py
Normal file
77
src/utils/qr_backup.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import io
|
||||
|
||||
|
||||
def normalize_qr_payload(qr_data: str) -> str:
|
||||
payload = (qr_data or "").strip()
|
||||
lower_payload = payload.lower()
|
||||
for command in (".importbackup", "/importbackup"):
|
||||
if lower_payload.startswith(command):
|
||||
return payload[len(command):].strip()
|
||||
return payload
|
||||
|
||||
|
||||
def build_qr_image(qr_data: str) -> io.BytesIO:
|
||||
payload = normalize_qr_payload(qr_data)
|
||||
if not payload:
|
||||
raise ValueError("QR backup payload is empty")
|
||||
|
||||
import qrcode
|
||||
|
||||
qr = qrcode.QRCode(
|
||||
version=None,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||||
box_size=10,
|
||||
border=4,
|
||||
)
|
||||
qr.add_data(payload)
|
||||
qr.make(fit=True)
|
||||
|
||||
image = qr.make_image(fill_color="black", back_color="white")
|
||||
output = io.BytesIO()
|
||||
image.save(output, format="PNG")
|
||||
output.name = "grouphelp-importbackup.png"
|
||||
output.seek(0)
|
||||
return output
|
||||
|
||||
|
||||
def decode_qr_image_payload(image_bytes: bytes) -> str:
|
||||
if not image_bytes:
|
||||
raise ValueError("QR image is empty")
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
try:
|
||||
image = ImageOps.exif_transpose(Image.open(io.BytesIO(image_bytes))).convert("RGB")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Could not open image: {e}") from e
|
||||
|
||||
detector = cv2.QRCodeDetector()
|
||||
rgb = np.asarray(image)
|
||||
bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
|
||||
gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
|
||||
|
||||
candidates = [bgr, gray]
|
||||
height, width = gray.shape[:2]
|
||||
if min(height, width) < 900:
|
||||
scale = max(2, int(900 / max(1, min(height, width))))
|
||||
candidates.extend([
|
||||
cv2.resize(bgr, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC),
|
||||
cv2.resize(gray, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC),
|
||||
])
|
||||
|
||||
for candidate in candidates:
|
||||
data, _, _ = detector.detectAndDecode(candidate)
|
||||
payload = normalize_qr_payload(data)
|
||||
if payload:
|
||||
return payload
|
||||
|
||||
ok, decoded, _, _ = detector.detectAndDecodeMulti(candidate)
|
||||
if ok:
|
||||
for item in decoded:
|
||||
payload = normalize_qr_payload(item)
|
||||
if payload:
|
||||
return payload
|
||||
|
||||
raise ValueError("No QR code could be decoded from the image")
|
||||
Reference in New Issue
Block a user