This commit is contained in:
2026-06-26 22:55:08 +03:00
parent b71632fe4d
commit 4ec8127971
2 changed files with 140 additions and 63 deletions

View File

@@ -8,6 +8,7 @@ from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.state import State, StatesGroup
from aiogram.fsm.storage.memory import MemoryStorage from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.exceptions import TelegramBadRequest
from config import ( from config import (
TELEGRAM_BOT_TOKEN, ADMIN_TELEGRAM_IDS, TELEGRAM_BOT_TOKEN, ADMIN_TELEGRAM_IDS,
@@ -45,6 +46,17 @@ def generate_secret(length: int = 16) -> str:
alphabet = string.ascii_letters + string.digits alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(length)) return ''.join(secrets.choice(alphabet) for _ in range(length))
async def safe_edit_text(callback: CallbackQuery, text: str, reply_markup=None, parse_mode=None):
"""Edit message text, ignoring 'message is not modified' errors."""
try:
await callback.message.edit_text(text, reply_markup=reply_markup, parse_mode=parse_mode)
except TelegramBadRequest as e:
if "message is not modified" in str(e).lower():
pass
else:
raise
await callback.answer()
# ============== KEYBOARDS ============== # ============== KEYBOARDS ==============
def main_menu_keyboard(is_admin_user: bool = False): def main_menu_keyboard(is_admin_user: bool = False):
@@ -99,12 +111,12 @@ async def cmd_start(message: Message):
@dp.callback_query(F.data == "register") @dp.callback_query(F.data == "register")
async def start_registration(callback: CallbackQuery, state: FSMContext): async def start_registration(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_text( await safe_edit_text(
callback,
"Enter the desired extension number (digits only, e.g. 1001):\n" "Enter the desired extension number (digits only, e.g. 1001):\n"
"Or send /cancel to abort." "Or send /cancel to abort."
) )
await state.set_state(RegistrationStates.waiting_for_number) await state.set_state(RegistrationStates.waiting_for_number)
await callback.answer()
@dp.message(RegistrationStates.waiting_for_number) @dp.message(RegistrationStates.waiting_for_number)
async def process_number(message: Message, state: FSMContext): async def process_number(message: Message, state: FSMContext):
@@ -121,6 +133,12 @@ async def process_number(message: Message, state: FSMContext):
await message.answer("This number is already taken. Please choose another one.") await message.answer("This number is already taken. Please choose another one.")
return return
# Check if number already exists in MikoPBX
miko_existing = await mikopbx.get_employee_by_number(number)
if miko_existing:
await message.answer("This number is already occupied in MikoPBX. Please choose another one.")
return
# Check if this user already has an account (one user = one number) # Check if this user already has an account (one user = one number)
user_accounts = await db.get_user_sip_accounts(message.from_user.id) user_accounts = await db.get_user_sip_accounts(message.from_user.id)
if user_accounts: if user_accounts:
@@ -163,14 +181,15 @@ async def use_generated_secret(callback: CallbackQuery, state: FSMContext):
username = data.get("username", f"User {number}") username = data.get("username", f"User {number}")
secret = generate_secret(12) secret = generate_secret(12)
await callback.answer()
await create_account_and_reply(callback.message, state, number, username, secret, callback.from_user.id) await create_account_and_reply(callback.message, state, number, username, secret, callback.from_user.id)
@dp.callback_query(F.data == "custom_secret", StateFilter(RegistrationStates.waiting_for_custom_secret)) @dp.callback_query(F.data == "custom_secret", StateFilter(RegistrationStates.waiting_for_custom_secret))
async def ask_for_custom_secret(callback: CallbackQuery, state: FSMContext): async def ask_for_custom_secret(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_text( await safe_edit_text(
callback,
"Please enter the password you want to use for this SIP account (min 6 characters):" "Please enter the password you want to use for this SIP account (min 6 characters):"
) )
await callback.answer()
@dp.message(RegistrationStates.waiting_for_custom_secret) @dp.message(RegistrationStates.waiting_for_custom_secret)
async def process_custom_secret(message: Message, state: FSMContext): async def process_custom_secret(message: Message, state: FSMContext):
@@ -248,7 +267,8 @@ async def show_my_accounts(callback: CallbackQuery):
accounts = await db.get_user_sip_accounts(callback.from_user.id) accounts = await db.get_user_sip_accounts(callback.from_user.id)
if not accounts: if not accounts:
await callback.message.edit_text( await safe_edit_text(
callback,
"You don't have any registered accounts yet.", "You don't have any registered accounts yet.",
reply_markup=main_menu_keyboard(is_admin(callback.from_user.id)) reply_markup=main_menu_keyboard(is_admin(callback.from_user.id))
) )
@@ -267,11 +287,11 @@ async def show_my_accounts(callback: CallbackQuery):
InlineKeyboardButton(text="Back", callback_data="back_main") InlineKeyboardButton(text="Back", callback_data="back_main")
]) ])
await callback.message.edit_text( await safe_edit_text(
callback,
"Your SIP accounts. Select one to view details:", "Your SIP accounts. Select one to view details:",
reply_markup=keyboard reply_markup=keyboard
) )
await callback.answer()
@dp.callback_query(F.data.startswith("my_account_")) @dp.callback_query(F.data.startswith("my_account_"))
async def show_my_account_details(callback: CallbackQuery): async def show_my_account_details(callback: CallbackQuery):
@@ -295,8 +315,7 @@ async def show_my_account_details(callback: CallbackQuery):
[InlineKeyboardButton(text="Back to my accounts", callback_data="my_accounts")] [InlineKeyboardButton(text="Back to my accounts", callback_data="my_accounts")]
]) ])
await callback.message.edit_text(text, reply_markup=keyboard) await safe_edit_text(callback, text, reply_markup=keyboard)
await callback.answer()
@dp.callback_query(F.data.startswith("show_params_")) @dp.callback_query(F.data.startswith("show_params_"))
async def show_connection_parameters(callback: CallbackQuery): async def show_connection_parameters(callback: CallbackQuery):
@@ -328,8 +347,7 @@ async def show_connection_parameters(callback: CallbackQuery):
[InlineKeyboardButton(text="Back", callback_data=f"my_account_{number}")] [InlineKeyboardButton(text="Back", callback_data=f"my_account_{number}")]
]) ])
await callback.message.edit_text(connection_info, reply_markup=keyboard) await safe_edit_text(callback, connection_info, reply_markup=keyboard)
await callback.answer()
@dp.callback_query(F.data.startswith("delete_my_account_")) @dp.callback_query(F.data.startswith("delete_my_account_"))
async def confirm_delete_my_account(callback: CallbackQuery): async def confirm_delete_my_account(callback: CallbackQuery):
@@ -340,12 +358,12 @@ async def confirm_delete_my_account(callback: CallbackQuery):
[InlineKeyboardButton(text="Cancel", callback_data=f"my_account_{number}")] [InlineKeyboardButton(text="Cancel", callback_data=f"my_account_{number}")]
]) ])
await callback.message.edit_text( await safe_edit_text(
callback,
f"Are you sure you want to delete extension {number}?\n" f"Are you sure you want to delete extension {number}?\n"
"This action cannot be undone.", "This action cannot be undone.",
reply_markup=keyboard reply_markup=keyboard
) )
await callback.answer()
@dp.callback_query(F.data.startswith("confirm_delete_my_")) @dp.callback_query(F.data.startswith("confirm_delete_my_"))
async def delete_my_account(callback: CallbackQuery): async def delete_my_account(callback: CallbackQuery):
@@ -357,11 +375,11 @@ async def delete_my_account(callback: CallbackQuery):
# Delete from local DB # Delete from local DB
await db.delete_sip_account(number) await db.delete_sip_account(number)
await callback.message.edit_text( await safe_edit_text(
callback,
f"Extension {number} has been deleted.", f"Extension {number} has been deleted.",
reply_markup=main_menu_keyboard(is_admin(callback.from_user.id)) reply_markup=main_menu_keyboard(is_admin(callback.from_user.id))
) )
await callback.answer()
# ============== ADMIN HANDLERS ============== # ============== ADMIN HANDLERS ==============
@@ -371,12 +389,12 @@ async def show_admin_menu(callback: CallbackQuery):
await callback.answer("Access denied.") await callback.answer("Access denied.")
return return
await callback.message.edit_text( await safe_edit_text(
callback,
"Administrator panel\n\n" "Administrator panel\n\n"
"Select an action:", "Select an action:",
reply_markup=admin_menu_keyboard() reply_markup=admin_menu_keyboard()
) )
await callback.answer()
# ============== LINK EXISTING MIKOPBX ACCOUNT ============== # ============== LINK EXISTING MIKOPBX ACCOUNT ==============
@@ -386,11 +404,11 @@ async def admin_start_link_existing(callback: CallbackQuery, state: FSMContext):
await callback.answer("Access denied.") await callback.answer("Access denied.")
return return
await callback.message.edit_text( await safe_edit_text(
callback,
"Enter the existing MikoPBX extension number (exactly 7 digits) that you want to link to a Telegram user:" "Enter the existing MikoPBX extension number (exactly 7 digits) that you want to link to a Telegram user:"
) )
await state.set_state(AdminEditStates.waiting_for_existing_number) await state.set_state(AdminEditStates.waiting_for_existing_number)
await callback.answer()
@dp.message(AdminEditStates.waiting_for_existing_number) @dp.message(AdminEditStates.waiting_for_existing_number)
async def admin_process_existing_number(message: Message, state: FSMContext): async def admin_process_existing_number(message: Message, state: FSMContext):
@@ -479,16 +497,17 @@ async def admin_list_accounts(callback: CallbackQuery):
accounts = await db.get_all_sip_accounts() accounts = await db.get_all_sip_accounts()
if not accounts: if not accounts:
await callback.message.edit_text( await safe_edit_text(
callback,
"No accounts registered yet.", "No accounts registered yet.",
reply_markup=admin_menu_keyboard() reply_markup=admin_menu_keyboard()
) )
else: else:
await callback.message.edit_text( await safe_edit_text(
callback,
"Registered accounts (click to view details):", "Registered accounts (click to view details):",
reply_markup=account_list_keyboard(accounts, prefix="admin_view") reply_markup=account_list_keyboard(accounts, prefix="admin_view")
) )
await callback.answer()
@dp.callback_query(F.data.startswith("admin_view_")) @dp.callback_query(F.data.startswith("admin_view_"))
async def admin_view_account(callback: CallbackQuery): async def admin_view_account(callback: CallbackQuery):
@@ -528,8 +547,7 @@ async def admin_view_account(callback: CallbackQuery):
[InlineKeyboardButton(text="Back to list", callback_data="admin_list")] [InlineKeyboardButton(text="Back to list", callback_data="admin_list")]
]) ])
await callback.message.edit_text(text, reply_markup=keyboard) await safe_edit_text(callback, text, reply_markup=keyboard)
await callback.answer()
@dp.callback_query(F.data.startswith("admin_change_number_")) @dp.callback_query(F.data.startswith("admin_change_number_"))
async def admin_start_change_number(callback: CallbackQuery, state: FSMContext): async def admin_start_change_number(callback: CallbackQuery, state: FSMContext):
@@ -540,11 +558,11 @@ async def admin_start_change_number(callback: CallbackQuery, state: FSMContext):
number = callback.data.replace("admin_change_number_", "") number = callback.data.replace("admin_change_number_", "")
await state.update_data(old_number=number) await state.update_data(old_number=number)
await callback.message.edit_text( await safe_edit_text(
callback,
f"Enter new number for extension {number}:" f"Enter new number for extension {number}:"
) )
await state.set_state(AdminEditStates.waiting_for_new_number) await state.set_state(AdminEditStates.waiting_for_new_number)
await callback.answer()
@dp.message(AdminEditStates.waiting_for_new_number) @dp.message(AdminEditStates.waiting_for_new_number)
async def admin_process_new_number(message: Message, state: FSMContext): async def admin_process_new_number(message: Message, state: FSMContext):
@@ -616,12 +634,12 @@ async def admin_delete_account(callback: CallbackQuery):
[InlineKeyboardButton(text="Cancel", callback_data="admin_list")] [InlineKeyboardButton(text="Cancel", callback_data="admin_list")]
]) ])
await callback.message.edit_text( await safe_edit_text(
callback,
f"Are you sure you want to delete extension {number}?\n" f"Are you sure you want to delete extension {number}?\n"
f"This will remove it from both the database and MikoPBX.", f"This will remove it from both the database and MikoPBX.",
reply_markup=keyboard reply_markup=keyboard
) )
await callback.answer()
@dp.callback_query(F.data.startswith("confirm_delete_")) @dp.callback_query(F.data.startswith("confirm_delete_"))
async def admin_confirm_delete(callback: CallbackQuery): async def admin_confirm_delete(callback: CallbackQuery):
@@ -631,7 +649,7 @@ async def admin_confirm_delete(callback: CallbackQuery):
number = callback.data.replace("confirm_delete_", "") number = callback.data.replace("confirm_delete_", "")
await callback.message.edit_text("Deleting account...") await safe_edit_text(callback, "Deleting account...")
# Delete from MikoPBX # Delete from MikoPBX
mikopbx_result = await mikopbx.delete_extension(number) mikopbx_result = await mikopbx.delete_extension(number)
@@ -640,16 +658,17 @@ async def admin_confirm_delete(callback: CallbackQuery):
db_success = await db.delete_sip_account(number) db_success = await db.delete_sip_account(number)
if mikopbx_result.get("success") or db_success: if mikopbx_result.get("success") or db_success:
await callback.message.edit_text( await safe_edit_text(
callback,
f"Extension {number} has been deleted.", f"Extension {number} has been deleted.",
reply_markup=admin_menu_keyboard() reply_markup=admin_menu_keyboard()
) )
else: else:
await callback.message.edit_text( await safe_edit_text(
callback,
f"Failed to delete {number}.", f"Failed to delete {number}.",
reply_markup=admin_menu_keyboard() reply_markup=admin_menu_keyboard()
) )
await callback.answer()
@dp.callback_query(F.data.startswith("admin_change_secret_")) @dp.callback_query(F.data.startswith("admin_change_secret_"))
async def admin_start_change_secret(callback: CallbackQuery, state: FSMContext): async def admin_start_change_secret(callback: CallbackQuery, state: FSMContext):
@@ -660,11 +679,11 @@ async def admin_start_change_secret(callback: CallbackQuery, state: FSMContext):
number = callback.data.replace("admin_change_secret_", "") number = callback.data.replace("admin_change_secret_", "")
await state.update_data(edit_number=number) await state.update_data(edit_number=number)
await callback.message.edit_text( await safe_edit_text(
callback,
f"Enter new password (secret) for extension {number}:" f"Enter new password (secret) for extension {number}:"
) )
await state.set_state(AdminEditStates.waiting_for_new_secret) await state.set_state(AdminEditStates.waiting_for_new_secret)
await callback.answer()
@dp.message(AdminEditStates.waiting_for_new_secret) @dp.message(AdminEditStates.waiting_for_new_secret)
async def admin_process_new_secret(message: Message, state: FSMContext): async def admin_process_new_secret(message: Message, state: FSMContext):
@@ -709,11 +728,11 @@ async def admin_process_new_secret(message: Message, state: FSMContext):
async def back_to_main(callback: CallbackQuery): async def back_to_main(callback: CallbackQuery):
is_admin_user = is_admin(callback.from_user.id) is_admin_user = is_admin(callback.from_user.id)
keyboard = main_menu_keyboard(is_admin_user) keyboard = main_menu_keyboard(is_admin_user)
await callback.message.edit_text( await safe_edit_text(
callback,
"Main menu:", "Main menu:",
reply_markup=keyboard reply_markup=keyboard
) )
await callback.answer()
@dp.message(Command("cancel")) @dp.message(Command("cancel"))
async def cancel_handler(message: Message, state: FSMContext): async def cancel_handler(message: Message, state: FSMContext):

View File

@@ -45,33 +45,17 @@ class MikoPBXService:
session = await self._get_session() session = await self._get_session()
url = f"{self.host}{endpoint}" url = f"{self.host}{endpoint}"
headers = self._get_auth_headers() headers = self._get_auth_headers()
method = method.upper()
logger.info(f"\n{'='*70}") logger.info(f"\n{'='*70}")
logger.info(f">>> REQUEST: {method.upper()} {url}") logger.info(f">>> REQUEST: {method} {url}")
if params: if params:
logger.info(f">>> PARAMS: {params}") logger.info(f">>> PARAMS: {params}")
if json_data: if json_data:
logger.info(f">>> BODY:\n{json.dumps(json_data, indent=2, ensure_ascii=False)}") logger.info(f">>> BODY:\n{json.dumps(json_data, indent=2, ensure_ascii=False)}")
try: try:
if method.upper() == "GET": async with session.request(method, url, params=params, json=json_data, headers=headers) as resp:
async with session.get(url, params=params, headers=headers) as resp:
text = await resp.text()
logger.info(f"<<< RESPONSE [{resp.status}]:\n{text[:3000]}")
try:
return await resp.json()
except:
return {"result": False, "raw": text}
elif method.upper() == "DELETE":
async with session.delete(url, headers=headers) as resp:
text = await resp.text()
logger.info(f"<<< RESPONSE [{resp.status}]:\n{text[:3000]}")
try:
return await resp.json()
except:
return {"result": False, "raw": text}
else:
async with session.post(url, json=json_data, headers=headers) as resp:
text = await resp.text() text = await resp.text()
logger.info(f"<<< RESPONSE [{resp.status}]:\n{text[:3000]}") logger.info(f"<<< RESPONSE [{resp.status}]:\n{text[:3000]}")
try: try:
@@ -91,6 +75,14 @@ class MikoPBXService:
""" """
logger.info(f"\n>>> Creating employee/extension {number} via /v3/employees ...") logger.info(f"\n>>> Creating employee/extension {number} via /v3/employees ...")
# Check if number already exists in MikoPBX
existing = await self.get_employee_by_number(number)
if existing:
return {
"success": False,
"error": [f"Internal number {number} is already occupied"]
}
payload = { payload = {
"number": number, "number": number,
"user_username": username or f"User {number}", "user_username": username or f"User {number}",
@@ -129,6 +121,13 @@ class MikoPBXService:
"error": error "error": error
} }
def _extract_employee_id(self, employee: Dict) -> Optional[str]:
"""Extract and validate employee ID for URL usage (v3 expects string id)."""
emp_id = employee.get("id")
if emp_id is None or emp_id == "":
return None
return str(emp_id)
async def get_employee_by_number(self, number: str) -> Optional[Dict]: async def get_employee_by_number(self, number: str) -> Optional[Dict]:
"""Get employee record by extension number (returns full record with real 'id')""" """Get employee record by extension number (returns full record with real 'id')"""
# Get list and search by number # Get list and search by number
@@ -146,12 +145,29 @@ class MikoPBXService:
"""Get employee by number""" """Get employee by number"""
return await self.get_employee_by_number(number) return await self.get_employee_by_number(number)
async def get_full_employee(self, employee_id) -> Optional[Dict]:
"""Get full employee record by ID (used for PUT updates)"""
response = await self._make_request("GET", f"/pbxcore/api/v3/employees/{employee_id}")
if response.get("result") and response.get("data"):
data = response.get("data")
if isinstance(data, dict):
return data
return None
async def set_extension_secret(self, number: str, new_secret: str) -> Dict[str, Any]: async def set_extension_secret(self, number: str, new_secret: str) -> Dict[str, Any]:
current = await self.get_employee_by_number(number) current = await self.get_employee_by_number(number)
if not current: if not current:
return {"success": False, "error": "Extension not found"} return {"success": False, "error": "Extension not found"}
real_id = current.get("id") real_id = self._extract_employee_id(current)
if not real_id:
return {"success": False, "error": "Employee ID is missing"}
# Try to get full employee record to avoid overwriting fields
full_current = await self.get_full_employee(real_id)
if full_current:
current = full_current
current["sip_secret"] = new_secret current["sip_secret"] = new_secret
response = await self._make_request( response = await self._make_request(
@@ -161,13 +177,55 @@ class MikoPBXService:
) )
return {"success": response.get("result", False), "messages": response.get("messages", [])} return {"success": response.get("result", False), "messages": response.get("messages", [])}
async def change_extension_number(self, old_number: str, new_number: str) -> Dict[str, Any]:
"""
Change employee extension number in MikoPBX.
Finds the employee by old number, updates the number field and sends PUT.
"""
current = await self.get_employee_by_number(old_number)
if not current:
return {"success": False, "error": f"Extension {old_number} not found in MikoPBX"}
# Check if the new number is already occupied
existing_new = await self.get_employee_by_number(new_number)
if existing_new:
return {"success": False, "error": f"Number {new_number} is already occupied in MikoPBX"}
real_id = self._extract_employee_id(current)
if not real_id:
return {"success": False, "error": "Employee ID is missing"}
# Try to get full employee record to avoid overwriting fields
full_current = await self.get_full_employee(real_id)
if full_current:
current = full_current
current["number"] = new_number
response = await self._make_request(
"PUT",
f"/pbxcore/api/v3/employees/{real_id}",
json_data=current
)
if response.get("result"):
logger.info(f">>> SUCCESS: Number changed from {old_number} to {new_number}")
return {"success": True, "data": response.get("data", {})}
else:
error = response.get("messages") or response.get("error") or ["Unknown error"]
logger.error(f">>> Failed to change number: {error}")
return {"success": False, "error": error}
async def delete_extension(self, number: str) -> Dict[str, Any]: async def delete_extension(self, number: str) -> Dict[str, Any]:
"""Delete employee by extension number (finds real ID first)""" """Delete employee by extension number (finds real ID first)"""
employee = await self.get_employee_by_number(number) employee = await self.get_employee_by_number(number)
if not employee: if not employee:
return {"success": False, "error": "Employee not found"} return {"success": False, "error": "Employee not found"}
real_id = employee.get("id") real_id = self._extract_employee_id(employee)
if not real_id:
return {"success": False, "error": "Employee ID is missing"}
logger.info(f">>> Deleting employee with real ID: {real_id}") logger.info(f">>> Deleting employee with real ID: {real_id}")
response = await self._make_request( response = await self._make_request(