telegram自定义客户端Telethon


制作客户端前提条件
干净的网络住宅IP代理,一般的梯子和自己搭的公网IP不行,可以使用cf的免费代理,然后在隐私模式下访问
https://my.telegram.org
写注册手机号(带+号和国际区号)telegram 的APP会出现一个验证码,将这个验证码输入登录,选→ API Development Tools,提交一个表单,通常只需要写这几项:
App title
Short name
平台写Desktop,然后提交获得一个API,这是操作个人账号的API

文档:https://docs.telethon.dev/en/stable/
uv pip install --upgrade telethon cryptg
安装cryptg会让加载加速(使用c解密)

客户端方法

@client.on(events.NewMessage(incoming=True))注册事件处理装饰器,只处理入站该函数中的一个参数,会被自动传入event对象,这个函数需要先被挂载(同步调用一次)

client.catch_up() 客户端离线期间“追赶”错过的更新,上次离线到现在中间

client.run_until_disconnected() 运行直到退出,异步调用,要先注册事件处理装饰器再执行此步

代码示范

简单的登录示范:

py
from telethon import TelegramClient

# 配置API ID和API Hash
API_ID = 应用的id
API_HASH = 应用hash
SESSION_NAME = 'leeken' # 会话名称,用来存储用户每个登录用户都会生成独立的名称

# 创建客户并登录
client = TelegramClient(SESSION_NAME, API_ID, API_HASH)

# 登录
client.start()

这会触发让你输入手机号(需带+号和国际区号)和验证码,然后输入正确后回生成你指定的名称'leeken'的session文件,生成的文件除非主动注销,否则身份信息一直生效

批量登录示范

py
from telethon import TelegramClient, errors
import env  # 引入配置
import os
import asyncio

# 登录函数调用
async def login(phone: str):
    # 补充+号
    phone_norm = f'+{phone}'
    # 拼接session文件路径
    session_path = f'{env.SESSION_DIR}/{phone}'

    # 创建客户端,如需代理,可在此处添加:proxy
    client = TelegramClient(session_path, env.API_ID, env.API_HASH)

    # 显式建立连接(不会触发 start 的交互式登录),这里不能使用async with client
    await client.connect()
    try:
        # 未授权才进行验证码登录
        if not await client.is_user_authorized():
            try:
                # 触发发送验证码(优先发到 Telegram 官方对话,必要时可尝试 force_sms)
                await client.send_code_request(phone_norm)
                # await client.send_code_request(phone_norm, force_sms=True)
            except errors.FloodWaitError as e:
                print(f'被限流,需要等待 {e.seconds} 秒后再试')
                return

            code = input('输入收到的验证码: ').strip()
            try:
                # 提交验证码完成登录
                await client.sign_in(phone=phone_norm, code=code)
            except errors.SessionPasswordNeededError:
                # 如果账号开了二步验证
                pwd = input('开启了两步验证,请输入密码: ').strip()
                await client.sign_in(password=pwd)

        print('已经成功登录,session已生成/更新')
    finally:
        # 显式断开连接,释放资源(即使发生异常也能执行)
        await client.disconnect()

# 测试调用
if __name__ == '__main__':
    # 如果没有 session 目录,则创建
    os.makedirs(env.SESSION_DIR, exist_ok=True)
    asyncio.run(login('8618681102500'))

复用身份文件

py
from telethon import TelegramClient    #+导入客户端与事件
from env import API_ID, API_HASH

# 创建客户端时直接传入name就行,它会自动使用这个文件做登录
client = TelegramClient("leeken", API_ID, API_HASH)

# 获取自己的信息
async def get_me():
    # 操作必须在客户端的上下文管理器中运行
    async with client:
        me = await client.get_me() # 打印自己的信息
        print(me.stringify())

if __name__ == '__main__':
    client.loop.run_until_complete(get_me())

实现监听信息,封装发送信息函数

py
# pip install telethon
import asyncio
from telethon import TelegramClient, events
from env import API_ID, API_HASH

# 假设已经登录了leeken
client = TelegramClient("leeken", API_ID, API_HASH)

# 归一化各种 peer 将字符串用户名、数字ID、实体对象转换为实体对象,
async def _resolve_peer(peer_like):
    """
    归一化各种 peer:
    - 字符串用户名('flintpensky'、'me')
    - 数字ID(用户ID、群/频道ID,超级群/频道多为负数)
    - 已是实体对象(InputPeer/Entity)
    """
    # 如果已经是实体对象,直接返回
    from telethon.tl.types import InputPeerUser, InputPeerChat, InputPeerChannel
    if isinstance(peer_like, (InputPeerUser, InputPeerChat, InputPeerChannel)):
        return peer_like

    # 否则交给 Telethon 去解析(用户名/ID/链接都可以解析为实体)
    return await client.get_entity(peer_like)  # 可能走网络解析,拿到 access_hash 等
    # get_entity 会把用户名/ID/链接解析成可用实体;纯数字ID需要Telethon有上下文,解析不到会抛错

# ========== 发送封装 ==========
async def send_text(peer, text: str):
    """
    peer 可以是:
      - 'username'(私聊/群的 @用户名)
      - chat_id 整数(用户ID或群ID,超级群常见是负数如 -100xxxxxxxxxx)
      - 从 client.get_entity(...) 得到的实体
    """
    if not client.is_connected():
        await client.connect()
    # 这里的peer可以是用户名字符串、整数ID(用户ID/群ID)、或 实体对象(get_entity()/事件里取到的 entity,例如调用_resolve_peer得到的实体)
    await client.send_message(peer, text)

# 发送文件
async def send_file(peer, file_path: str, caption: str | None = None):
    if not client.is_connected():
        await client.connect()
    await client.send_file(peer, file=file_path, caption=caption)

# ========== 监听封装 ==========
async def attach_handlers():
    @client.on(events.NewMessage(incoming=True))
    async def on_new_message(event):
        # 忽略自己发出的消息,避免回环
        if event.out:
            return

        # 基本元信息
        scope = "私聊" if event.is_private else ("群聊" if event.is_group else ("频道" if event.is_channel else "其它"))
        sender = await event.get_sender()
        text = event.raw_text or ""

        print(
            f"[{scope}] chat_id={event.chat_id} "
            f"from={getattr(sender, 'username', None) or sender.id} "
            f"text={text}"
        )

        # 私聊自动回复(按需保留/删除)
        if scope == "私聊":
            await send_text(event.chat_id, "已收到~")

        # 如需保存对方发来的媒体,解注释:
        # if event.photo or event.document:
        #     path = await event.download_media(file="downloads/")
        #     print(f"[保存媒体] {path}")

# ========== 主程序 ==========
async def main():
    await client.connect()                  # 用已有 session 连接(不会触发登录交互)
    if not await client.is_user_authorized():
        print("会话无效或被吊销,请先重新登录生成 .session")
        return

    await attach_handlers()                 # 挂载事件处理
    me = await client.get_me()
    print(f"已上线:{me.id} {me.first_name} {me.last_name}")

    # 示例:上线后给自己发一条消息(可删)
    await send_text("me", "这是一个客户端测试发送")

    print("开始监听,Ctrl+C 退出")
    await client.run_until_disconnected()   # 持续运行直到断开(会自动重连)

if __name__ == "__main__":
    # 也可以使用client.loop.run_until_complete(main())
    asyncio.run(main())

小demo

客户端线程池

py
from dataclasses import dataclass, field  # 导入数据类装饰器和字段工厂函数
from telethon import TelegramClient, events,errors  # 导入Telegram客户端和事件处理
from datetime import datetime, timezone, timedelta  # 时间处理(UTC、时间窗)  # 中文注释
import asyncio  # 导入异步编程支持库
import contextlib  # 导入上下文管理器工具
import env  # 导入环境配置模块
from typing import Optional  # 导入可选类型注解
from crowdpulse.rags.rag import chat_query # 导入rag查询
from log_ser.as_log import Logger
from fastapi_ser.fast_db import (
    db_get_all_active_telegram_session,
    db_list_user_telegram_session_ids,
    db_update_telegram_session_is_active,
)

log = Logger(__name__)


@dataclass  # 使用数据类装饰器,自动生成初始化方法等
class ClientEntry:  # 定义客户端条目数据类
    client: TelegramClient  # Telegram客户端实例
    user_id: int  # 用户ID
    telegram_id: int  # Telegram账号唯一ID
    telegram_username: str | None = None
    task: Optional[asyncio.Task] = None  # 可选的异步任务引用,默认为None
    lock: asyncio.Lock = field(
        default_factory=asyncio.Lock
    )  # 异步锁,用于保护共享资源,自动创建新锁实例
    keywords: list[str] = field(
        default_factory=list
    )  # 每个实例独立新列表,因为list是可变标量,因此需要独立新列表
    monitor_chat_ids: list[int] = field(default_factory=list)
    switch_all_chat: bool = False  # 是否响应所有聊天
    exclude_ids: list[int] = field(default_factory=list)  # 排除的账号ID列表,主要用来防止自己的多个账号无限制互聊天
    accept_time: datetime = field(  # 新增:只接受该时间之后的消息  # 中文注释
        default_factory=lambda: datetime.now(timezone.utc) - timedelta(hours=2)
    )

# 定义客户端管理器类
class ClientManager:
    def __init__(self):  # 初始化方法
        self._clients: dict[int, ClientEntry] = {}  # 存储客户端条目的字典,键为telegram_id
        self._global_lock = asyncio.Lock()  # 全局异步锁,保护客户端字典的并发访问
        self._shutting_down = False  # 关机标志

    def make_client(
        self, telegram_id: int, proxy: dict | None = None, device_model: str | None = None
    ) -> TelegramClient:  # 创建Telegram客户端的私有方法
        """
        创建Telegram客户端(以 telegram_id 作为会话文件名)。

        参数:
            telegram_id (int): Telegram 唯一ID
            proxy (dict | None): 代理配置
            device_model (str | None): 设备型号

        返回:
            TelegramClient: Telethon 客户端实例
        """
        # 先判断客户端是否已经在运行,如果已存在优先从线程池中返回实例
        if self.is_running(telegram_id):
            return self._clients[telegram_id].client
        
        # # 构建会话文件路径
        # session_path = Path(env.TG_SESSION_DIR, f"{telegram_id}.session").read_text()
        return TelegramClient(
            f"{env.TG_SESSION_DIR}/{telegram_id}.session",
            env.TG_API_ID,
            env.TG_API_HASH,
            proxy=proxy,
            device_model=device_model,
            app_version="CrowdPulse 1.0.1",
        )  # 创建并返回Telegram客户端实例

    def is_running(self, telegram_id: int) -> bool:
        """判断指定 telegram_id 的客户端是否在运行(任务存在且未结束)。"""
        entry = self._clients.get(telegram_id)
        return bool(entry and entry.task and not entry.task.done())

    # 事件监听处理器
    def _attach_handlers(
        self, client: TelegramClient, telegram_id: int
    ):  # 私有附加处理器方法,接收客户端和telegram_id
        @client.on(
            events.NewMessage(incoming=True)
        )  # 注册新消息事件处理器,只处理入站消息
        async def on_msg(event):  # 消息处理函数
            if event.out:  # 如果是自己发出的消息
                return  # 直接返回,不处理

            # 取到当前entry - 通过telegram_id直接查找
            entry = self._clients.get(telegram_id)
            if not entry:
                return  # 未找到或已停止

            # 安全获取发信人唯一ID(int 或 None)
            sid = event.sender_id
            # 如果发送人唯一ID在排除列表中则不处理
            if sid is not None and sid in entry.exclude_ids:
                log.info(f"{telegram_id} 检测到排除列表,不处理")
                return

            # 安全获取发送人信息
            sender = await event.get_sender()
            # 如果是bot则不处理
            if getattr(sender, "bot", False):
                return

            text = event.raw_text or ""  # 获取消息文本,处理空文本情况
            # await log.info(f"[{telegram_id}] ({event.chat_id}) : {text}")  # 打印消息信息
            # 如果是私聊
            if event.is_private:
                result = await chat_query(entry.user_id,text)
                await event.respond(result)  # 发送自动回复
                return

            # 如果是群组,则检查是否在监控列表中
            if event.is_group:

                # 判断是否响应所有聊天
                if entry.switch_all_chat:
                    # await log.info(f"{telegram_id}监听{event.chat_id}:{text}")
                    result = await chat_query(entry.user_id,text)
                    # 回复该消息
					await event.respond(result)
                    return
                # 再检查是否在监控列表中
                elif event.chat_id in entry.monitor_chat_ids:
                    # await log.info(f"{telegram_id}监听{event.chat_id}:{text}")
                    # 如果消息是@我或回复我,或触发关键词则回复,这里的关键词从entry中获取
                    if event.message.mentioned or any(
                        keyword in text for keyword in entry.keywords
                    ):  
                        result = await chat_query(entry.user_id,text)
                        await event.respond(result)

    # 启动一个账号
    async def start(
        self,
        user_id: int,
        telegram_id: int,
        telegram_username: str | None = None,
        keywords: list[str] | None = None,
        monitor_chat_ids: list[int] | None = None,
        switch_all_chat: bool = False,
        proxy: dict | None = None,
        device_model: str | None = None,
        exclude_ids: list[int] | None = None,
    ) -> str:  # 异步启动方法,仅以 telegram_id 标识客户端
        """
        幂等启动:若已在运行直接返回提示;若失败,确保释放连接并回收entry。
        """
        # 1) 先登记/获取 entry,但不要长时间持有全局锁
        async with self._global_lock:  # 获取全局锁,保护客户端字典操作
            if self._shutting_down:
                return "服务关机中"  # 关机中,拒绝新启动
            entry = self._clients.get(telegram_id)  # 获取指定telegram_id的客户端条目
            if entry and entry.task and not entry.task.done():  # 检查是否已在运行
                return f"[{telegram_id}] 已在运行,忽略重复启动"  # 返回已在运行的提示信息
            if not entry:  # 如果不存在该条目
                # 创建新的客户端条目
                entry = ClientEntry(
                    client=self.make_client(telegram_id, proxy, device_model),
                    user_id=user_id,
                    telegram_id=telegram_id,
                    telegram_username=telegram_username,
                    keywords=list(keywords or []),
                    monitor_chat_ids=list(monitor_chat_ids or []),
                    switch_all_chat=bool(switch_all_chat),
                    exclude_ids=list(exclude_ids or []),
                )
                self._clients[telegram_id] = (
                    entry  # 暂时登记到管理字典,后续失败会在 finally 里清理
                )

        # 2) 进入专属锁,做真正的启动流程
        runner_started = False  # 标记后台任务是否成功启动
        async with entry.lock:  # 获取客户端条目的专属锁
            # 2.1 清理残留任务
            if entry.task and not entry.task.done():  # 如果存在未完成的任务
                entry.task.cancel()  # 取消该任务
                with contextlib.suppress(asyncio.CancelledError):  # 抑制取消异常
                    await entry.task  # 等待任务结束
                entry.task = None  # 清空任务引用

            # 2.2 显式连接
            await entry.client.connect()  # 建立Telegram连接
            try:
                # 授权校验
                if not await entry.client.is_user_authorized():  # 检查是否已授权
                    await db_update_telegram_session_is_active(telegram_id,False,False)
                    await entry.client.disconnect()  # 释放连接
                    return "身份信息失效,请重新登录"  # 返回登录失效提示


                # 只挂一次事件处理器(避免重复)
                if not getattr(
                    entry.client, "_handlers_attached", False
                ):  # 检查是否已附加处理器
                    self._attach_handlers(entry.client, telegram_id)  # 附加事件处理器
                    entry.client._handlers_attached = True  # 标记已附加处理器

                # 2.5 后台常驻
                entry.task = asyncio.create_task(
                    self._runner(entry), name=f"runner:{telegram_id}"
                )  # 创建后台运行任务
                runner_started = True  # 标记任务启动成功
                return "success"  # 返回成功状态

            finally:
                # 2.6 若 runner 未成功启动,断开连接并回收 entry,避免"半初始化"残留
                if not runner_started:  # 如果后台任务未能成功启动
                    with contextlib.suppress(Exception):  # 抑制异常
                        await entry.client.disconnect()  # 断开连接
                    # 从管理表回收未启动成功的 entry
                    async with self._global_lock:  # 获取全局锁
                        cur = self._clients.get(telegram_id)  # 获取当前条目
                        if cur is entry and (
                            entry.task is None or entry.task.done()
                        ):  # 确认是同一实例且任务未运行
                            self._clients.pop(telegram_id, None)  # 从字典中移除该条目

    async def stop(
        self, telegram_id: int
    ) -> str:  # 异步停止方法,接收telegram_id,返回状态字符串
        """
        停止:先停任务/断开连接,再从表里删除,避免竞态。
        """
        # 1) 抓到 entry 引用,不要急着从表里删
        async with self._global_lock:  # 获取全局锁
            entry = self._clients.get(telegram_id)  # 直接获取指定telegram_id的客户端条目

            if not entry:
                return f"账号{telegram_id}没有在运行"  # 返回未运行提示

        # 2) 专属锁内做真正停机
        async with entry.lock:  # 获取客户端条目的专属锁
            if entry.task and not entry.task.done():  # 如果存在运行中的任务
                entry.task.cancel()  # 取消任务
                with contextlib.suppress(asyncio.CancelledError):  # 抑制取消异常
                    await entry.task  # 等待任务结束
            entry.task = None  # 清空任务引用

            with contextlib.suppress(Exception):  # 抑制异常
                await entry.client.disconnect()  # 断开客户端连接

        # 3) 现在再从表里删除,避免与同时 start 产生竞态
        async with self._global_lock:  # 获取全局锁
            self._clients.pop(telegram_id, None)  # 从字典中移除该条目
        return "success"  # 返回成功状态

    async def send_text(
        self, telegram_id: int, peer, text: str, reply_to: int | None = None
    ) -> str:  # 异步发送文本方法,接收telegram_id、目标和文本内容,返回状态字符串
        """
        发送:要求该账号已在运行;若断线自动重连。
        参数:
        telegram_id: Telegram账号ID
        peer: 目标
        text: 文本内容
        reply_to: 回复消息的ID
        返回:
        - "success": 发送成功
        - 其它字符串:错误提示(例如:主体不受支持)
        """
        async with self._global_lock:  # 获取全局锁
            entry = self._clients.get(telegram_id)  # 直接获取指定telegram_id的客户端条目

            if not entry:
                return f"账号{telegram_id}未运行,请先启动"  # 返回未运行提示

        # 确保连接
        if not entry.client.is_connected():  # 检查是否已连接
            await entry.client.connect()  # 建立连接

        # 可按需补:若未授权则提示
        if not await entry.client.is_user_authorized():  # 检查是否已授权
            with contextlib.suppress(Exception):  # 抑制异常
                await entry.client.disconnect()  # 断开连接
            return f"[{telegram_id}] 会话失效,请重新登录"  # 返回会话失效提示

        # 尝试解析 peer 为 Telegram 实体;若解析失败,给出自定义错误文本
        try:
            entity = await entry.client.get_input_entity(peer)  # 获取目标实体信息
        except Exception:
            # 统一返回友好提示:仅支持 用户名/链接/群ID/群用户名;手机号必须在联系人中
            return (
                "您输入的主体不受支持,只能用户名或https://t.me/**,群ID,群用户名,如果是电话号码必须是联系人"
            )
        await entry.client.send_message(entity, text, reply_to=reply_to)  # 发送文本消息
        return "success"  # 返回成功状态

    def list_running(
        self, user_id: int | None = None
    ) -> list[dict]:  # 获取运行中的客户端列表方法,返回电话号码列表
        """
        只返回"确实在跑"的账号(task 存在且未结束)
        """
        result: list[dict] = []

        for tid, entry in self._clients.items():
            # 只取正在运行(task存在且未结束)
            if not (entry.task and not entry.task.done()):
                continue
            # 如果指定了 user_id,则进行过滤
            if user_id is not None and entry.user_id != user_id:
                continue
            # 组装返回信息
            result.append(
                {
                    "user_id": entry.user_id,
                    "telegram_id": entry.telegram_id,
                    "telegram_username": entry.telegram_username,
                    "keywords": list(entry.keywords) if entry.keywords else [],
                    "monitor_chat_ids": (
                        list(entry.monitor_chat_ids) if entry.monitor_chat_ids else []
                    ),
                    "switch_all_chat": bool(entry.switch_all_chat),
                    "exclude_ids": list(entry.exclude_ids) if entry.exclude_ids else [],
                }
            )
        return result  # 中文注释:返回包含所需字段的字典列表

    # 自动启动所有活跃的账号(从数据库中获取)
    async def auto_start_telegram_clients(self) -> None:
        """
        从数据库中获取所有is_active=True的账号并自动启动它们
        """
        try:
            # 从数据库获取所有活跃的Telegram账号
            active_sessions = await db_get_all_active_telegram_session()

            if not active_sessions:
                await log.info("没有找到需要自动启动的活跃账号")
                return

            await log.info(f"发现 {len(active_sessions)} 个需要自动启动的账号")

            # 逐个启动账号
            for session in active_sessions:
                try:
                    # 检查账号是否已经在运行
                    async with self._global_lock:
                        entry = self._clients.get(session.telegram_id)
                        if entry and entry.task and not entry.task.done():
                            await log.info(f"账号 {session.telegram_id} 已在运行中,跳过启动")
                            continue

                    # 获取用户自身的所有账号ID
                    exclude_ids = await db_list_user_telegram_session_ids(
                        session.user_id
                    )

                    # 启动账号(仅以 telegram_id 标识)
                    result = await self.start(
                        user_id=session.user_id,
                        telegram_id=session.telegram_id,
                        telegram_username=session.telegram_username,
                        keywords=session.keywords or [],
                        monitor_chat_ids=session.monitor_chat_ids or [],
                        switch_all_chat=session.switch_all_chat or False,
                        proxy=None,
                        device_model=session.device_model,
                        exclude_ids=exclude_ids,
                    )

                    if result == "success":
                        await log.info(f"账号 {session.telegram_id} 启动成功")
                    else:
                        await log.err(
                            f"账号 {session.telegram_id} 启动失败: {result}", kind="Telegram"
                        )

                except Exception as e:
                    await log.err(f"启动账号 {session.telegram_id} 时发生异常: {e}", kind="Telegram")

        except Exception as e:
            await log.err(f"自动启动账号时发生异常: {e}", kind="Telegram")

    # 修改某个telegram_id的keywords,switch_all_chat,monitor_chat_ids
    async def update_config(
        self,
        telegram_id: int,
        keywords: list[str] | None = None,
        monitor_chat_ids: list[int] | None = None,
        switch_all_chat: bool | None = None,
    ) -> str:
        """
        修改指定telegram账号的配置信息

        参数:
            telegram_id (int): Telegram账号ID
            keywords (list[str] | None): 要设置的关键字列表,为None则不修改
            monitor_chat_ids (list[int] | None): 要监控的聊天ID列表,为None则不修改
            switch_all_chat (bool | None): 是否响应所有聊天,为None则不修改

        返回:
            str: 操作结果,"success"表示成功
        """
        # 先拿锁,避免并发修改
        async with self._global_lock:
            entry = self._clients.get(telegram_id)  # 直接获取指定telegram_id的客户端条目

            if not entry:
                return "success"

        # 再拿锁,避免并发修改
        async with entry.lock:
            if keywords is not None:
                entry.keywords = (
                    keywords  # 直接赋值,keywords已经是正确的类型  # 中文注释
                )
            if switch_all_chat is not None:
                entry.switch_all_chat = bool(switch_all_chat)
            if monitor_chat_ids is not None:
                entry.monitor_chat_ids = monitor_chat_ids
        return "success"

    # 修改某个用户所有账号的exclude_ids
    async def update_user_exclude_ids(
        self, user_id: int, exclude_ids: list[int] | None = None
    ) -> str:
        """
        修改某个用户所有账号的exclude_ids

        当用户添加或删除了某个账号时,应调用此函数来更新该用户所有运行中账号的exclude_ids,
        防止同一用户的不同账号互相聊天。

        参数:
            user_id (int): 用户ID
            exclude_ids (list[int] | None): 要设置的排除账号ID列表。
                如果为None,则从数据库获取该用户的所有账号ID作为排除列表。

        返回:
            str: 操作结果,"success"表示成功,其他字符串表示错误信息
        """
        try:

            # 获取该用户所有运行中的账号
            running_accounts = []
            async with self._global_lock:
                for telegram_id, entry in self._clients.items():
                    # 只处理正在运行且属于指定用户的账号
                    if (
                        entry.task
                        and not entry.task.done()
                        and entry.user_id == user_id
                    ):
                        running_accounts.append((telegram_id, entry))
            
            if not running_accounts:
                # 如果没有正在运行中的账号,直接返回成功
                return "success"

            # 如果exclude_ids为None,从数据库获取该用户的所有账号ID
            if exclude_ids is None:
                exclude_ids = await db_list_user_telegram_session_ids(user_id)
                if not exclude_ids:
                    await log.info(f"用户 {user_id} 没有找到任何Telegram账号")
                    return "success"  # 没有账号也算成功,不需要更新

            # 更新每个运行中账号的exclude_ids
            for telegram_id, entry in running_accounts:
                async with entry.lock:
                    # 更新exclude_ids(排除自身账号ID)
                    old_exclude_ids = (
                        list(entry.exclude_ids) if entry.exclude_ids else []
                    )
                    entry.exclude_ids = list(exclude_ids)

                    # 记录变更(仅在有变化时记录)
                    if set(old_exclude_ids) != set(exclude_ids):
                        await log.info(
                            f"账号 {telegram_id} exclude_ids已更新: {old_exclude_ids} -> {exclude_ids}"
                        )

            return "success"

        except Exception as e:
            await log.err(
                f"更新用户 {user_id} 的exclude_ids时发生异常: {e}", kind="Telegram"
            )
            return f"更新失败: {str(e)}"

    # 后台运行方法
    async def _runner(self, entry: ClientEntry):  # 私有后台运行方法,接收客户端条目
        telegram_id = entry.telegram_id  # 获取telegram_id
        try:
            await log.info(f"[{telegram_id}] 客户端启动")  # 打印启动信息
            await entry.client.run_until_disconnected()  # 运行客户端直到断开连接
        except asyncio.CancelledError:  # 捕获取消异常
            await log.info(f"[{telegram_id}] 客户端收到取消")  # 打印取消信息
            raise  # 重新抛出异常
        except Exception as ex:  # 捕获其他异常
            # 如果是身份和权限错误,则停止客户端
            if isinstance(ex, (errors.UnauthorizedError,        # 401 未授权  # 中文注释
                          errors.AuthKeyError,             # 授权 key 类问题(基类)  # 中文注释
                          errors.common.AuthKeyNotFound)):
                await log.err(f"{telegram_id}客户端身份失效",kind="Telegram")  # 打印异常信息
                # 停止客户端
                await self.stop(telegram_id)
                # 更新数据库
                await db_update_telegram_session_is_active(telegram_id,False,False)
                return
            await log.info(f"[{telegram_id}] runner 异常:{ex}")  # 打印异常信息
        finally:
            with contextlib.suppress(Exception):  # 抑制异常
                await entry.client.disconnect()  # 断开连接
            await log.info(f"[{telegram_id}] 客户端结束")  # 打印结束信息

    # 停止所有客户端运行
    async def stop_running(self):
        self._shutting_down = True  # ← 置位,阻止新的 start()
        try:
            # 先快照,避免长时间持有全局锁
            async with self._global_lock:
                items = list(self._clients.items())
            for _, entry in items:
                async with entry.lock:
                    if entry.task and not entry.task.done():
                        entry.task.cancel()
                        with contextlib.suppress(asyncio.CancelledError):
                            await entry.task
                        entry.task = None
                    with contextlib.suppress(Exception):
                        await entry.client.disconnect()
            # 清空管理表
            async with self._global_lock:
                self._clients.clear()
        finally:
            self._shutting_down = False  # 关机状态复位


    # @某个用户的“安全mention”工具(即使没有用户名也能@上)
    def mention_user(self, user_id: int, name: str | None = None) -> str:
        """
        生成可点击的 @ 提及(Markdown)
        - 需要在 client 里 parse_mode='md' 或默认支持 Markdown
        """
        # None/空串兜底
        name = name or "user"
        # 转义特殊字符
        safe = (
            name.replace("\\", "\\\\")
            .replace("[", "\\[")
            .replace("]", "\\]")
            .replace("(", "\\(")
            .replace(")", "\\)")
            .replace("_", "\\_")
        )
        return f"[{safe}](tg://user?id={user_id})"

manager = ClientManager()  # 实例化客户端管理器

fastapi实现调用和登录

py
from click.core import F
from pydantic import BaseModel, Field
from typing import Annotated,Optional
from fastapi import Response,APIRouter,Depends,UploadFile,Form
from db_ser.models import KolOrderModel
from fastapi_ser.routes.utils import Msg,random_device_model
from log_ser.as_log import Logger
import json
from fastapi_ser.jwtser import verify_auth
from telegram.client_manager import manager # 引入tg客户端管理器
from telethon import TelegramClient, errors
from uuid import uuid4
from fastapi_ser.routes.cache import set_tg_txid_cache,get_tg_txid_cache,delete_tg_txid_cache
import env
from fastapi_ser.fast_db import (
    db_add_telegram_session,
    db_check_telegram_session,
    db_list_telegram_session,
    db_transfer_telegram_session,
    db_check_telegram_session_is_owner,
    db_update_telegram_session_is_active,
    db_get_user_telegram_session,
    db_update_telegram_session_config
)
import os
log = Logger(__name__) # 日志对象

# 创建路由器
router = APIRouter(
    prefix="/telegram",  # 路由前缀
    tags=["前台 / Telegram管理"]     # API文档标签
)

# ====== 请求体模型 ======
# 发送验证码请求体
class SendCodeReq(BaseModel):
    phone: Annotated[str,Field(..., description="国际区号+手机号码"),Msg("移动电话号码不能为空")]
    force_sms: bool = Field(False, description="是否走短信通道")

# 校验验证码请求体
class VerifyCodeReq(BaseModel):
    txid:Annotated[str,Field(..., description="第一次发码返回的事务ID"),Msg("事务ID不能为空")]
    code:Annotated[str,Field(..., description="用户输入的验证码"),Msg("验证码不能为空")]
    password: str | None = Field(None, description="若账号开启了2FA,这里带上密码(可选)")

# 登录第一步发送验证码
@router.post("/send_code",summary="登录第一步,发送验证码")
async def send_code(req: SendCodeReq,user_id:int=Depends(verify_auth(1))) -> str:
    # 检查手机号格式前面是否有+号,如果存在,则去掉
    if req.phone.startswith('+'):
        phone = req.phone[1:]
    else:
        phone = req.phone

    # 检查号码归属用户
    check_user_id = await db_check_telegram_session(phone)
    if check_user_id and check_user_id != user_id:
        return Response(content="该Telegram号已绑定其他用户并登录,联系该用户用户转让给您", status_code=400)
 
    client = TelegramClient(f'{env.TG_SESSION_DIR}/{phone}', env.TG_API_ID, env.TG_API_HASH)  #构造客户端实例
    # 显式建立连接
    await client.connect()
    try:
        # 如果未登录才触发登录
        if not await client.is_user_authorized():
            # 补齐+号
            plus_phone = f'+{phone}'
            # 触发发码(通常发到应用内“Telegram 官方”对话,必要时尝试 force_sms)
            try:
                sent = await client.send_code_request(plus_phone, force_sms=req.force_sms)  #发送验证码
                txid = uuid4().hex  # 生成事务ID
                # 保存到redis
                await set_tg_txid_cache(txid, sent.phone_code_hash, phone)
                return txid  # 返回事务ID,供后续提交验证码
            except errors.FloodWaitError as e:
                return Response(content=f"限流,请 {e.seconds}s 后重试", status_code=429)  #被风控限流
            except errors.PhoneNumberInvalidError:
                return Response(content="手机号无效", status_code=400)                     #手机号格式或归属异常
            except Exception as ex:
                # 这里要同时写进err日志
                log.err(f"tg发码失败:{ex}",kind="Telegram")
                return Response(content=f"发码失败:{ex}", status_code=500)                #其它异常
        # 如果已登录,则返回已登录
        return Response(content="该Telegram号已登录", status_code=400)
    finally:
        # 显式断开连接
        await client.disconnect()


# 登录第二部,核验验证码+2FA密码
@router.post("/verify_code",summary="登录第二步,核验验证码+2FA密码")
async def verify_code(req: VerifyCodeReq,user_id:int=Depends(verify_auth(1))) -> Response:
    # 根据txid读取缓存
    cached_data = await get_tg_txid_cache(req.txid)
    if not cached_data:
        return Response(content="验证码已过期,重新发起登录", status_code=400)

    phone_code_hash = cached_data["phone_code_hash"]
    phone = cached_data["phone"]
    # 随机生成一个设备型号
    device_model = await random_device_model()
    # 创建客户端实列
    client = TelegramClient(f'{env.TG_SESSION_DIR}/{phone}', env.TG_API_ID, env.TG_API_HASH,device_model=device_model,app_version='CrowdPulse 1.0.1')  #构造客户端实例
    # 显式建立连接
    await client.connect()
    try: 
        # 如果未登录才触发登录
        if not await client.is_user_authorized():
            try:
                # 继续用发码时保存的 phone_code_hash 完成登录
                await client.sign_in(             # 提交验证码完成登录
                    phone=phone,
                    code=req.code,
                    phone_code_hash=phone_code_hash
                )
            except errors.SessionPasswordNeededError:
                # 需要二步验证密码
                if not req.password:
                    return Response(content="该账号开启了二步验证,请提交password", status_code=401)
                await client.sign_in(password=req.password)  # 提交二步验证密码再次登录
            except errors.PhoneCodeInvalidError:
                return Response(content="验证码错误", status_code=400)  #验证码不正确
            except errors.PhoneCodeExpiredError:
                return Response(content="验证码已过期", status_code=400)  #验证码过期
            except errors.FloodWaitError as e:
                return Response(content=f"限流,请 {e.seconds}s 后重试", status_code=400)  #限流重试
            except Exception as ex:
                # 这里要同时写进err日志
                log.err(f"tg登录失败:{ex}",kind="Telegram")
                return Response(content=f"登录失败:{ex}", status_code=500)  #其它异常
            # 走到这里表示登录成功,取登录用户信息
            me = await client.get_me()

            # 清理redis中的txid
            await delete_tg_txid_cache(req.txid)

            # 写入数据库,映射表
            result = await db_add_telegram_session(dict(
                user_id=user_id,
                phone=phone,
                telegram_id=me.id,
                telegram_username=me.username or None,
                device_model=device_model,
            ))
            if result != "success":
                return Response(content=result, status_code=500)
            
            # 返回登录成功
            return Response(content=json.dumps({
                "id": me.id,
                "username": me.username or None,
                "name": f"{me.first_name or ''}{me.last_name or ''}".strip(),
            }), status_code=200)

        # 如果已登录,则返回已登录
        return Response(content="该TG号已登录", status_code=400)

    finally:
        # 显式断开连接
        await client.disconnect()

# 列出用户绑定的Telegram账号
@router.get("/list",summary="列出用户绑定的Telegram账号")
async def list_telegram_session(user_id:int=Depends(verify_auth(1))) -> list[dict]:
    """
    列出用户绑定的Telegram账号
    """
    return await db_list_telegram_session(user_id)

# 列出用户所有运行的账号
@router.get("/list_running",summary="列出所有运行的账号")
async def list_running(user_id:int=Depends(verify_auth(1))) -> list[dict]:
    return manager.list_running(user_id)

# 转让Telegram账号
@router.post("/transfer",summary="转让Telegram账号")
async def transfer_telegram_session(phone:str,new_user:str,user_id:int=Depends(verify_auth(1))) -> Response:
    """
    转让Telegram账号
    """
    result = await db_transfer_telegram_session(phone,user_id,new_user)
    if result != "success":
        return Response(content=result, status_code=400)
    return Response(content="转让成功", status_code=200)


# 检查登录状态
@router.get("/check_login_status",summary="检查登录状态")
async def auth_status(phone: str,response:Response,user_id:int=Depends(verify_auth(1))) -> bool|str:
    """
    检查登录状态,该操作建议仅对未在运行的客户端去检查,已运行的客户端从list_running获取
    """
    # 检查用户是否拥有该telegram号
    is_owner = await db_check_telegram_session_is_owner(phone,user_id)
    if not is_owner:
        response.status_code = 400
        return "您没有权限操作该Telegram账号"

    client = TelegramClient(f'{env.TG_SESSION_DIR}/{phone}', env.TG_API_ID, env.TG_API_HASH)
    # 显式建立连接
    await client.connect()
    try:
        # 如果未登录才触发登录
        if await client.is_user_authorized():
            return True
        return False
    finally:
        # 显式断开连接
        await client.disconnect()



# 启动一个账号
@router.post("/client_start",summary="启动一个账号")
async def start_client(phone:str,user_id:int=Depends(verify_auth(1))) -> Response:
    # 判断session文件是否存在
    if not os.path.exists(f'{env.TG_SESSION_DIR}/{phone}.session'):
        return Response(content="该Telegram账号不存在,请先登录", status_code=400)

    # 取该用户该账号信息
    session_info = await db_get_user_telegram_session(user_id,phone)
    if not session_info:
        return Response(content="您名下没有该Telegram账号", status_code=400)

    # 启动账号,传递数据库中的信息
    res = await manager.start(phone,user_id,session_info.telegram_id,session_info.telegram_username,session_info.keywords or None,session_info.monitor_chat_ids or None,session_info.switch_all_chat)
    if res != "success":
        return Response(content=res, status_code=400)

    # 更新数据库中的激活状态
    await db_update_telegram_session_is_active(phone,True)
    return res

# 停止一个账号
@router.post("/client_stop",summary="停止一个账号")
async def stop_client(phone: str,user_id:int=Depends(verify_auth(1))) -> Response:
    # 检查用户是否拥有该telegram号
    is_owner = await db_check_telegram_session_is_owner(phone,user_id)
    if not is_owner:
        return Response(content="您没有权限操作该Telegram账号", status_code=400)

    res = await manager.stop(phone)
    if res != "success":
        return Response(content=res, status_code=400)

    # 更新数据库中的激活状态
    await db_update_telegram_session_is_active(phone,False)

    return Response(content="success", status_code=200)


# 取用户信息
@router.get("/client_get_user_info",summary="取用户信息")
async def get_user_info(phone: str,user_id:int=Depends(verify_auth(1))) -> Response:
    """
    获取指定账号的用户信息

    参数:
        phone: 电话号码,作为账号标识符

    返回:
        Response: 包含用户信息的响应对象
    """
    # 检查用户是否拥有该telegram号
    is_owner = await db_check_telegram_session_is_owner(phone,user_id)
    if not is_owner:
        return Response(content="您没有权限操作该Telegram账号", status_code=400)
    
    client = TelegramClient(f'{env.TG_SESSION_DIR}/{phone}', env.TG_API_ID, env.TG_API_HASH)
    # 显式建立连接
    await client.connect()
    try:
        # 如果未登录才触发登录
        if await client.is_user_authorized():
            me = await client.get_me()
 
            return Response(content=json.dumps({
                "id": me.id,
                "username": me.username or None,
                "first_name": (me.first_name or None),
                "last_name": (me.last_name or None),
                "phone": me.phone,
                "premium": me.premium,
            }, ensure_ascii=False, indent=2), status_code=200)
        
        return Response(content="用户未登录", status_code=400)

    except Exception as e:
        return Response(content=str(e), status_code=400)
    finally:
        # 显式断开连接
        await client.disconnect() 

# 发送消息
@router.post("/client_send_message",summary="发送消息")
async def send_message(phone: str, message: str, peer: str="me",user_id:int=Depends(verify_auth(1))) -> Response:
    """
    phone: 账号
    message: 消息
    peer: 接收方,默认me,即自己
    """
    # 检查用户是否拥有该telegram号
    is_owner = await db_check_telegram_session_is_owner(phone,user_id)
    if not is_owner:
        return Response(content="您没有权限操作该Telegram账号", status_code=400)

    res = await manager.send_text(phone, peer, message)
    if res != "success":
        return Response(content=res, status_code=400)
    return res

# 获取所有群组信息
@router.get("/client_get_all_groups",summary="指定账号,获取所有群组信息")
async def get_all_groups(phone: str,user_id:int=Depends(verify_auth(1))) -> Response:
    # 检查用户是否拥有该telegram号
    is_owner = await db_check_telegram_session_is_owner(phone,user_id)
    if not is_owner:
        return Response(content="您没有权限操作该Telegram账号", status_code=400)

    try:
        data = await manager.get_all_groups(phone)
        return data
    except Exception as e:
        return Response(content=str(e), status_code=400)

# 修改配置模型
class UpdateConfigReq(BaseModel):
    phone: Annotated[str,Field(..., description="账号"),Msg("账号不能为空")]
    keywords: list[str] = Field([], description="触发关键词列表")
    monitor_chat_ids: list[int] = Field([], description="监听群组列表")
    switch_all_chat: bool = Field(False, description="是否响应所有聊天")

# 修改账号配置
@router.post("/client_update_config",summary="修改账号配置")
async def update_config(req: UpdateConfigReq,user_id:int=Depends(verify_auth(1))) -> Response:
    """
    修改账号配置
    """
    # 检查用户是否拥有该telegram号
    is_owner = await db_check_telegram_session_is_owner(req.phone,user_id)
    if not is_owner:
        return Response(content="您没有权限操作该Telegram账号", status_code=400)

    # 写入数据库
    result = await db_update_telegram_session_config(req.phone, req.keywords, req.monitor_chat_ids, req.switch_all_chat)
    if result != "success":
        return Response(content=result, status_code=400)
    # 更新内存中的配置
    await manager.update_config(req.phone, req.keywords, req.monitor_chat_ids, req.switch_all_chat)
    return Response(content="success", status_code=200)

StringSession 存储方式

telethon默认的存储session的格式是sqlite,除了身份验证还有一些缓存作用,以及拉取历史消息的游标等功能,使用StringSession存储则只存储一个身份验证密钥,极其轻量,但是需要自己维护游标等功能

ini
from telethon import TelegramClient
from telethon.sessions import StringSession,SQLiteSession

# 在内存中构建客户端实列
client = TelegramClient(StringSession(), env.TG_API_ID, env.TG_API_HASH)

# 假设这里执行了显式建立连接,并执行登录等操作

# 取出session字符串,这个字符串可以保存到数据库或文件
session_str = client.session.save()

# 第二次复用身份信息时,直接使用这个字符串
client = TelegramClient(StringSession(session_str), env.TG_API_ID, env.TG_API_HASH)

# 显式建立连接
await client.connect()

# 转存为SQLiteSession
# 创建文件
dst = SQLiteSession(f"{env.TG_SESSION_DIR}/{me.id}.session")
# 从上面的客户端中导入一些参数
dst.set_dc(client.session.dc_id, client.session.server_address, client.session.port)
dst.auth_key   = client.session.auth_key                                  
dst.takeout_id = getattr(client.session, "takeout_id", None) # 安全写入,因为这里通常为None
dst.save() # 保存
dst.close() # 关闭连接
# 显式断开连接
await client.disconnect()

# 后续使用sqlite方法再使用时:
TelegramClient(
            f"{env.TG_SESSION_DIR}/{telegram_id}.session", # 这里直接填之之前保存的session文件路径即可
            env.TG_API_ID,
            env.TG_API_HASH,
            proxy=proxy,
            device_model=device_model,
            app_version="CrowdPulse 1.0.1",
        )