refactor(telegram-adapter): 整理导入顺序并实现多账号管理增强

1.  整理多个文件的导入顺序,统一模块导入规范
2.  为多账号管理器添加线程安全的账号守卫和生命周期钩子
3.  新增账号增删、启停和配置变更的完整操作方法
4.  重构轮询偏移存储,使用适配器状态替代本地缓存
5.  优化BotCommandScope的参数构建逻辑
This commit is contained in:
Kris 2026-05-13 16:15:39 +08:00
parent 69f4319023
commit 31736aef74
8 changed files with 180 additions and 28 deletions

View File

@ -1,13 +1,50 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from typing import Any
AccountEventHandler = Callable[[str, dict[str, Any]], Any]
class AccountGuard:
def __init__(self):
self._lock = asyncio.Lock()
self._pending: dict[str, bool] = {}
async def acquire(self, account_id: str) -> bool:
async with self._lock:
if account_id in self._pending:
return False
self._pending[account_id] = True
return True
async def release(self, account_id: str) -> None:
async with self._lock:
self._pending.pop(account_id, None)
@property
def active_count(self) -> int:
return len(self._pending)
def has_pending(self, account_id: str) -> bool:
return account_id in self._pending
class MultiAccountManager:
def __init__(self, config: dict[str, Any] | None = None):
cfg = config or {}
self._accounts: dict[str, dict[str, Any]] = {}
self._primary_account_id: str | None = None
self._guard = AccountGuard()
self._lifecycle_hooks: dict[str, list[AccountEventHandler]] = {
"on_account_added": [],
"on_account_removed": [],
"on_account_enabled": [],
"on_account_disabled": [],
"on_account_config_changed": [],
}
accounts_list = cfg.get("accounts", [])
if isinstance(accounts_list, list):
@ -73,3 +110,109 @@ class MultiAccountManager:
def is_multi_account(self) -> bool:
return len(self._accounts) > 1
def register_hook(self, event: str, handler: AccountEventHandler) -> None:
if event in self._lifecycle_hooks:
self._lifecycle_hooks[event].append(handler)
async def _fire_hook(self, event: str, account_id: str, account_data: dict[str, Any]) -> None:
handlers = self._lifecycle_hooks.get(event, [])
for handler in handlers:
try:
result = handler(account_id, account_data)
if asyncio.iscoroutine(result):
await result
except Exception:
pass
async def add_account(self, account_data: dict[str, Any]) -> bool:
account_id = account_data.get("id")
if not account_id:
return False
if not await self._guard.acquire(account_id):
return False
try:
if account_id in self._accounts:
return False
self._accounts[account_id] = {
"id": account_id,
"token": account_data.get("token") or account_data.get("bot_token", ""),
"token_file": account_data.get("token_file", ""),
"label": account_data.get("label", f"account-{account_id}"),
"api_root": account_data.get("api_root", ""),
"proxy": account_data.get("proxy", ""),
}
if self._primary_account_id is None:
self._primary_account_id = account_id
await self._fire_hook("on_account_added", account_id, self._accounts[account_id])
return True
finally:
await self._guard.release(account_id)
async def remove_account(self, account_id: str) -> bool:
if not await self._guard.acquire(account_id):
return False
try:
if account_id not in self._accounts:
return False
removed = self._accounts.pop(account_id)
if self._primary_account_id == account_id:
self._primary_account_id = next(iter(self._accounts), None)
await self._fire_hook("on_account_removed", account_id, removed)
return True
finally:
await self._guard.release(account_id)
async def on_account_config_changed(self, account_id: str, new_config: dict[str, Any]) -> None:
if account_id not in self._accounts:
return
if not await self._guard.acquire(account_id):
return
try:
account = self._accounts[account_id]
old_config = dict(account)
account.update(
{
"token": new_config.get("token", account.get("token", "")),
"token_file": new_config.get("token_file", account.get("token_file", "")),
"label": new_config.get("label", account.get("label", "")),
"api_root": new_config.get("api_root", account.get("api_root", "")),
"proxy": new_config.get("proxy", account.get("proxy", "")),
}
)
await self._fire_hook(
"on_account_config_changed",
account_id,
{
"old": old_config,
"new": dict(account),
},
)
finally:
await self._guard.release(account_id)
async def enable_account(self, account_id: str) -> bool:
if not await self._guard.acquire(account_id):
return False
try:
account = self._accounts.get(account_id)
if not account:
return False
account["enabled"] = True
await self._fire_hook("on_account_enabled", account_id, account)
return True
finally:
await self._guard.release(account_id)
async def disable_account(self, account_id: str) -> bool:
if not await self._guard.acquire(account_id):
return False
try:
account = self._accounts.get(account_id)
if not account:
return False
account["enabled"] = False
await self._fire_hook("on_account_disabled", account_id, account)
return True
finally:
await self._guard.release(account_id)

View File

@ -7,15 +7,17 @@ from collections.abc import AsyncIterator
from typing import Any, Literal
from telegram import BotCommand, BotCommandScope, Update
from telegram.error import TelegramError, InvalidToken, RetryAfter, Forbidden, BadRequest, TimedOut, NetworkError
from telegram.error import BadRequest, Forbidden, InvalidToken, NetworkError, RetryAfter, TelegramError, TimedOut
from telegram.ext import Application, ApplicationBuilder, MessageHandler, filters
from yuxi.channels.base import BaseChannelAdapter
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
from yuxi.channels.capabilities import ChannelCapabilities, TTSCapabilities, TTSVoiceCapabilities
from yuxi.channels.exceptions import (
ChannelAuthenticationError,
ChannelNotConnectedError,
)
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
from yuxi.channels.meta import ChannelMeta
from yuxi.channels.models import (
Attachment,
ChannelIdentity,
@ -31,26 +33,24 @@ from yuxi.channels.models import (
MessageType,
)
from yuxi.channels.registry import register_builtin_adapter
from yuxi.utils.datetime_utils import utc_now_naive
from yuxi.utils.logging_config import logger
from .send import send_with_retry, send_stream_edit
from yuxi.channels.sdk.chunking import ChunkConfig, chunk_message as sdk_chunk_message
from yuxi.channels.sdk.chunking import ChunkConfig
from yuxi.channels.sdk.chunking import chunk_message as sdk_chunk_message
from yuxi.channels.sdk.format import markdown_to_html as sdk_markdown_to_html
from yuxi.channels.sdk.format import strip_html_tags as sdk_strip_html_tags
from yuxi.channels.capabilities import ChannelCapabilities, TTSVoiceCapabilities, TTSCapabilities
from yuxi.channels.meta import ChannelMeta
from yuxi.utils.datetime_utils import utc_now_naive
from yuxi.utils.logging_config import logger
from .accounts.token import resolve_bot_token
from .connect.allowed_updates import get_allowed_updates
from .connect.polling_liveness import PollingLiveness
from .connect.update_offset_store import UpdateOffsetStore
from .debounce import MessageDebouncer
from .reactions.reaction_level import ReactionLevelController
from .reactions.reaction_notifications import ReactionNotifications
from .tools.sendchataction_401_backoff import SendChatAction401Backoff
from .tools.request_timeouts import resolve_global_timeout
from .debounce import MessageDebouncer
from .send import send_stream_edit, send_with_retry
from .thread_persistence import ThreadPersistence
from .tools.request_timeouts import resolve_global_timeout
from .tools.sendchataction_401_backoff import SendChatAction401Backoff
from .topics.auto_topic_label import suggest_icon_color
from .topics.topic_name_cache import TopicNameCache
@ -71,11 +71,14 @@ def _build_command_scope(scope_config: str | dict) -> BotCommandScope | None:
scope_type = scope_config.get("type", "default")
if scope_type == "default":
return None
return BotCommandScope(
type=scope_type,
chat_id=scope_config.get("chat_id"),
user_id=scope_config.get("user_id"),
)
kwargs = {"type": scope_type}
chat_id = scope_config.get("chat_id")
if chat_id is not None:
kwargs["chat_id"] = chat_id
user_id = scope_config.get("user_id")
if user_id is not None:
kwargs["user_id"] = user_id
return BotCommandScope(**kwargs)
return None
@ -207,9 +210,10 @@ class TelegramAdapter(BaseChannelAdapter):
dns_order = network_cfg.get("dnsResultOrder")
if dns_order:
from telegram.request import HTTPXRequest
import socket
from telegram.request import HTTPXRequest
httpx_kwargs: dict[str, Any] = {}
if dns_order == "ipv4first":
httpx_kwargs["socket_options"] = [(socket.AF_INET, None)]
@ -534,7 +538,7 @@ class TelegramAdapter(BaseChannelAdapter):
if not self._application:
return DeliveryResult(success=False, error="Application not initialized")
try:
from telegram import InputMediaPhoto, InputMediaVideo, InputMediaAudio, InputMediaDocument
from telegram import InputMediaAudio, InputMediaDocument, InputMediaPhoto, InputMediaVideo
input_media = []
for item in media:
@ -968,6 +972,7 @@ class TelegramAdapter(BaseChannelAdapter):
chat_type = ChatType.GUILD_CHANNEL
event_type = EventType.MESSAGE_RECEIVED
metadata_extra = {}
if msg.new_chat_members:
event_type = EventType.MEMBER_JOINED
elif msg.left_chat_member:
@ -983,8 +988,6 @@ class TelegramAdapter(BaseChannelAdapter):
elif msg.forum_topic_reopened:
event_type = EventType.SYSTEM_EVENT
metadata_extra = {"forum_topic_reopened": True}
else:
metadata_extra = {}
content = msg.text or msg.caption or ""
message_type = MessageType.TEXT
@ -1670,6 +1673,7 @@ class TelegramAdapter(BaseChannelAdapter):
self._liveness.record_activity(update.update_id)
self._offset_store.set_offset(self._token_hash, update.update_id)
await self.state_set("polling_offset", update.update_id, namespace="connection")
if update.callback_query:
try:
@ -1739,7 +1743,11 @@ class TelegramAdapter(BaseChannelAdapter):
poll_interval = self.config.get("poll_interval", 1.0)
saved_offset = self._offset_store.get_offset(self._token_hash)
saved_offset = await self.state_get("polling_offset", namespace="connection") or 0
if saved_offset <= 0:
saved_offset = self._offset_store.get_offset(self._token_hash)
else:
self._offset_store.set_offset(self._token_hash, saved_offset)
if saved_offset > 0:
logger.info(f"[Telegram] Resuming from saved offset: {saved_offset}")
@ -1781,6 +1789,7 @@ class TelegramAdapter(BaseChannelAdapter):
status = self._liveness.get_status()
if status["last_update_id"] > 0:
self._offset_store.set_offset(self._token_hash, status["last_update_id"])
await self.state_set("polling_offset", status["last_update_id"], namespace="connection")
self._polling_task = asyncio.create_task(_poll())
asyncio.create_task(_monitor_liveness())

View File

@ -14,7 +14,6 @@ from telegram.error import (
TimedOut,
)
RECOVERABLE_ERROR_TYPES = (TimedOut, NetworkError, OSError, asyncio.TimeoutError)
NON_RECOVERABLE_ERROR_TYPES = (InvalidToken, Forbidden, BadRequest, Conflict)

View File

@ -5,9 +5,9 @@ import secrets
import time
from typing import Any
from .security_audit import audit_security_policy
from .group_migration import GroupMigrationHandler
from .audit_membership import MembershipAuditor
from .group_migration import GroupMigrationHandler
from .security_audit import audit_security_policy
class TelegramSecurityPolicy:

View File

@ -8,7 +8,8 @@ from telegram import Bot, ReplyParameters
from telegram.error import BadRequest, NetworkError, RetryAfter, TelegramError, TimedOut
from telegram.ext import Application
from yuxi.channels.sdk.format import markdown_to_html, strip_html_tags as strip_all_tags
from yuxi.channels.sdk.format import markdown_to_html
from yuxi.channels.sdk.format import strip_html_tags as strip_all_tags
from yuxi.utils.logging_config import logger

View File

@ -2,8 +2,8 @@ from __future__ import annotations
from typing import Any
from yuxi.utils.logging_config import logger
from yuxi.channels.adapters.telegram.setup.setup_core import probe_token
from yuxi.utils.logging_config import logger
async def run_setup_wizard(config_dir: str | None = None) -> dict[str, Any]:

View File

@ -4,6 +4,7 @@ from collections import OrderedDict
from typing import Any
from yuxi.utils.logging_config import logger
from .sticker_cache_store import StickerCacheStore

View File

@ -8,7 +8,6 @@ from telegram.error import TelegramError
from yuxi.utils.logging_config import logger
_VOICE_COMPATIBLE_FORMATS = frozenset({"ogg", "opus", "flac", "wav", "m4a", "mp3", "aac"})