doge 11 часов назад
Родитель
Сommit
28d4b38355

+ 18 - 0
app/Http/Controllers/admin/TelegramChannel.php

@@ -17,6 +17,24 @@ class TelegramChannel extends Controller
         return $this->success(['list' => $channels->list()]);
     }
 
+    public function bots(TelegramChannelService $channels): JsonResponse
+    {
+        return $this->success(['list' => $channels->bots()]);
+    }
+
+    public function saveBot(Request $request, TelegramChannelService $channels): JsonResponse
+    {
+        return $this->respond(fn () => $channels->saveBot($request->all()));
+    }
+
+    public function deleteBot(Request $request, TelegramChannelService $channels): JsonResponse
+    {
+        return $this->respond(function () use ($request, $channels) {
+            $channels->deleteBot($this->id($request));
+            return [];
+        });
+    }
+
     public function detail(Request $request, TelegramChannelService $channels): JsonResponse
     {
         return $this->respond(fn () => $channels->detail($this->id($request)));

+ 94 - 16
app/Services/TelegramChannelService.php

@@ -47,6 +47,7 @@ class TelegramChannelService
             'id' => $legacy ? 'prohibited' : 'sometimes|required|integer|min:1',
             'name' => 'nullable|string|max:255',
             'chatId' => 'required|string|max:64',
+            'bot_id' => 'nullable|integer|min:1|prohibits:bot_token',
             'bot_username' => ['nullable', 'string', 'max:64', 'regex:/^@?[a-z][a-z0-9_]*$/iD'],
             'bot_token' => ['nullable', 'string', 'max:255', 'regex:/^[1-9][0-9]*:[a-z0-9_-]+$/iD'],
             'enabled' => 'sometimes|required|boolean',
@@ -60,21 +61,30 @@ class TelegramChannelService
         $message = isset($fields['type']) ? self::message($input) : null;
         $row = isset($fields['id']) && !$legacy ? $this->find((int) $fields['id']) : null;
         if ($legacy) {
-            $row = DB::table('telegram_channels')->where('target', $target)->orWhere('telegram_chat_id', $target)->first();
+            $row = $this->joinedChannels()->where(fn (Builder $query) => $query
+                ->where('channels.target', $target)->orWhere('channels.telegram_chat_id', $target))->first();
         }
         $newToken = $fields['bot_token'] ?? null;
-        $bot = $this->validateBot($fields['bot_username'] ?? null, $newToken, $row);
+        $botId = array_key_exists('bot_id', $fields) ? $fields['bot_id'] : $row?->bot_id;
+        $selectedBot = $botId === null ? null : $this->findBot((int) $botId);
+        $bot = $newToken === null ? [] : $this->validateBot($fields['bot_username'] ?? null, $newToken);
+        if ($newToken === null && isset($fields['bot_username'])
+            && strcasecmp(ltrim($fields['bot_username'], '@'), $selectedBot->bot_username ?? '') !== 0) {
+            throw ValidationException::withMessages(['bot_username' => '请从机器人管理中选择机器人']);
+        }
+
         $targetUnchanged = $row !== null && in_array($target, [$row->target, $row->telegram_chat_id], true);
-        $identity = $targetUnchanged && $newToken === null
+        $bindingUnchanged = (int) $botId === (int) $row?->bot_id && $newToken === null;
+        $identity = $targetUnchanged && $bindingUnchanged
             ? ['target' => $row->target, 'telegram_chat_id' => $row->telegram_chat_id, 'title' => $row->name]
-            : $this->resolve($target, $newToken ?? $this->storedToken($row));
+            : $this->resolve($target, $newToken ?? $this->storedToken($selectedBot));
 
-        return DB::transaction(function () use ($fields, $identity, $message, $legacy, $row, $bot) {
+        return DB::transaction(function () use ($fields, $identity, $message, $legacy, $row, $bot, $selectedBot) {
             $default = $this->settings()->lockForUpdate()->first();
             $channels = DB::table('telegram_channels');
             $existing = $row === null ? null : (clone $channels)->where('id', $row->id)->first();
             if ($row !== null && ($existing === null || $existing->target !== $row->target
-                || $existing->telegram_chat_id !== $row->telegram_chat_id || $existing->bot_token !== $row->bot_token)) {
+                || $existing->telegram_chat_id !== $row->telegram_chat_id || $existing->bot_id !== $row->bot_id)) {
                 throw ValidationException::withMessages(['id' => '频道已变更,请刷新后重试']);
             }
 
@@ -86,7 +96,7 @@ class TelegramChannelService
             })->get();
             if ($legacy && $row === null && $match->count() === 1) {
                 $existing = $match->first();
-                if ($existing->bot_token !== null && $bot === []) {
+                if ($existing->bot_id !== null && $bot === [] && !array_key_exists('bot_id', $fields)) {
                     throw ValidationException::withMessages(['chatId' => '请使用已保存的频道标识,或通过频道管理按 ID 编辑']);
                 }
             }
@@ -94,6 +104,20 @@ class TelegramChannelService
                 throw ValidationException::withMessages(['chatId' => '该频道已存在']);
             }
 
+            if ($selectedBot !== null && $this->findBot((int) $selectedBot->id)->bot_token !== $selectedBot->bot_token) {
+                throw ValidationException::withMessages(['bot_id' => '机器人配置已更新,请刷新后重试']);
+            }
+            $binding = $selectedBot?->id;
+            if ($bot !== []) {
+                $registered = DB::table('telegram_bots')->where('bot_username', $bot['bot_username'])->first();
+                if ($registered !== null && $this->storedToken($registered) !== $this->storedToken((object) $bot)) {
+                    throw ValidationException::withMessages(['bot_token' => '请在机器人管理中更新已有机器人的 Token']);
+                }
+                $binding = $registered?->id ?? DB::table('telegram_bots')->insertGetId($bot + [
+                    'created_at' => now(), 'updated_at' => now(),
+                ]);
+            }
+
             $enabled = (bool) ($fields['enabled'] ?? $existing?->enabled ?? true);
             $values = [
                 'name' => $fields['name'] ?? $existing?->name ?? $identity['title'],
@@ -103,8 +127,9 @@ class TelegramChannelService
                 'message' => $message === null
                     ? ($existing?->message ?? 'null')
                     : json_encode($message, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
+                'bot_id' => $binding,
                 'updated_at' => now(),
-            ] + $bot;
+            ];
             if ($existing === null) {
                 $firstLegacy = $legacy && !$channels->exists();
                 $id = (int) $channels->insertGetId($values + ['created_at' => now()]);
@@ -246,15 +271,66 @@ class TelegramChannelService
         return $telegram->post($image ? 'sendPhoto' : 'sendVideo', $params + [$field => $media])->getResult() ?: [];
     }
 
-    private function validateBot(?string $username, #[\SensitiveParameter] ?string $token, ?object $row): array
+    public function bots(): array
     {
-        if ($token === null) {
-            if ($username !== null && strcasecmp(ltrim($username, '@'), $row->bot_username ?? '') !== 0) {
-                throw ValidationException::withMessages(['bot_token' => '绑定或更换机器人时请填写 Bot Token']);
-            }
-            return [];
+        return DB::table('telegram_bots')->orderBy('bot_username')
+            ->get(['id', 'bot_username', 'bot_name'])->all();
+    }
+
+    public function saveBot(#[\SensitiveParameter] array $input): array
+    {
+        $fields = Validator::make($input, [
+            'id' => 'sometimes|required|integer|min:1',
+            'bot_username' => ['nullable', 'string', 'max:64', 'regex:/^@?[a-z][a-z0-9_]*$/iD'],
+            'bot_token' => ['required', 'string', 'max:255', 'regex:/^[1-9][0-9]*:[a-z0-9_-]+$/iD'],
+        ])->validate();
+        $id = isset($fields['id']) ? (int) $fields['id'] : null;
+        $row = $id === null ? null : $this->findBot($id);
+        $bot = $this->validateBot($fields['bot_username'] ?? $row?->bot_username, $fields['bot_token']);
+        if ($row !== null && strcasecmp($row->bot_username, $bot['bot_username']) !== 0) {
+            throw ValidationException::withMessages(['bot_username' => '请使用此机器人的 Token,更换机器人请新增后再绑定']);
         }
 
+        return DB::transaction(function () use ($id, $row, $bot) {
+            $this->settings()->lockForUpdate()->first();
+            $bots = DB::table('telegram_bots');
+            $duplicate = (clone $bots)->where('bot_username', $bot['bot_username'])->value('id');
+            if ($duplicate !== null && (int) $duplicate !== $id) {
+                throw ValidationException::withMessages(['bot_username' => '该机器人已存在']);
+            }
+            $values = $bot + ['updated_at' => now()];
+            if ($id === null) {
+                $id = (int) $bots->insertGetId($values + ['created_at' => now()]);
+            } else {
+                if ($this->findBot($id)->bot_token !== $row->bot_token) {
+                    throw ValidationException::withMessages(['id' => '机器人已更新,请刷新后重试']);
+                }
+                $bots->where('id', $id)->update($values);
+            }
+            return ['id' => $id, 'bot_username' => $bot['bot_username'], 'bot_name' => $bot['bot_name']];
+        }, 3);
+    }
+
+    public function deleteBot(int $id): void
+    {
+        DB::transaction(function () use ($id) {
+            $this->settings()->lockForUpdate()->first();
+            $this->findBot($id);
+            if (DB::table('telegram_channels')->where('bot_id', $id)->exists()) {
+                throw ValidationException::withMessages(['id' => '机器人仍绑定频道,请先在频道中解除绑定']);
+            }
+            DB::table('telegram_bots')->where('id', $id)->delete();
+        }, 3);
+    }
+
+    private function findBot(int $id): object
+    {
+        return DB::table('telegram_bots')->where('id', $id)->first()
+            ?? throw ValidationException::withMessages(['bot_id' => '机器人不存在']);
+    }
+
+    private function validateBot(?string $username, #[\SensitiveParameter] string $token): array
+    {
         try {
             $bot = $this->telegram($token)->getMe();
         } catch (\Exception) {
@@ -270,7 +346,7 @@ class TelegramChannelService
         }
 
         return [
-            'bot_username' => $actualUsername,
+            'bot_username' => strtolower($actualUsername),
             'bot_name' => mb_substr((string) $bot->get('first_name'), 0, 255),
             'bot_token' => Crypt::encryptString($token),
         ];
@@ -398,7 +474,8 @@ class TelegramChannelService
     {
         return DB::table('telegram_channels as channels')
             ->crossJoin('telegram_channel_settings as settings')
-            ->select('channels.*', 'settings.default_channel_id')
+            ->leftJoin('telegram_bots as bots', 'bots.id', '=', 'channels.bot_id')
+            ->select('channels.id', 'channels.name', 'channels.target', 'channels.telegram_chat_id', 'channels.enabled', 'channels.message', 'channels.bot_id', 'settings.default_channel_id', 'bots.bot_username', 'bots.bot_name', 'bots.bot_token')
             ->where('settings.id', 1);
     }
 
@@ -409,6 +486,7 @@ class TelegramChannelService
             'name' => $row->name,
             'chatId' => $row->target,
             'telegram_chat_id' => $row->telegram_chat_id,
+            'bot_id' => $row->bot_id === null ? null : (int) $row->bot_id,
             'bot_username' => $row->bot_username,
             'bot_name' => $row->bot_name,
             'has_bot_token' => $row->bot_token !== null,

+ 65 - 0
database/migrations/2026_11_13_150300_create_telegram_bots.php

@@ -0,0 +1,65 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        if (!Schema::hasTable('telegram_bots')) {
+            Schema::create('telegram_bots', function (Blueprint $table) {
+                $table->id();
+                $table->string('bot_username', 64)->unique();
+                $table->string('bot_name', 255);
+                $table->text('bot_token');
+                $table->timestamps();
+            });
+        }
+
+        if (!Schema::hasColumn('telegram_channels', 'bot_id')) {
+            Schema::table('telegram_channels', function (Blueprint $table) {
+                $table->foreignId('bot_id')->nullable()->constrained('telegram_bots')->restrictOnDelete();
+            });
+        }
+
+        DB::transaction(function () {
+            DB::table('telegram_channels')->whereNull('bot_id')->whereNotNull('bot_token')
+                ->orderByDesc('updated_at')->orderByDesc('id')->get()->each(function ($channel) {
+                    $username = strtolower($channel->bot_username);
+                    $botId = DB::table('telegram_bots')->where('bot_username', $username)->value('id')
+                        ?? DB::table('telegram_bots')->insertGetId([
+                            'bot_username' => $username,
+                            'bot_name' => $channel->bot_name ?? $username,
+                            'bot_token' => $channel->bot_token,
+                            'created_at' => now(),
+                            'updated_at' => now(),
+                        ]);
+                    DB::table('telegram_channels')->where('id', $channel->id)->update([
+                        'bot_id' => $botId,
+                        'bot_username' => null,
+                        'bot_name' => null,
+                        'bot_token' => null,
+                    ]);
+                });
+        });
+    }
+
+    public function down(): void
+    {
+        DB::transaction(function () {
+            foreach (DB::table('telegram_bots')->get() as $bot) {
+                DB::table('telegram_channels')->where('bot_id', $bot->id)->update([
+                    'bot_username' => $bot->bot_username,
+                    'bot_name' => $bot->bot_name,
+                    'bot_token' => $bot->bot_token,
+                ]);
+            }
+        });
+        Schema::table('telegram_channels', function (Blueprint $table) {
+            $table->dropConstrainedForeignId('bot_id');
+        });
+        Schema::dropIfExists('telegram_bots');
+    }
+};

+ 16 - 8
docs/前端交付/bot-28/后台/Telegram频道管理-前端接入.md

@@ -1,6 +1,6 @@
 # Telegram 频道管理
 
-在现有「频道管理」页面(路由名 `Message`)接入频道列表和单频道编辑。请求带 `Authorization: Bearer <管理员 token>`,POST 使用 JSON,文件上传使用表单。业务校验失败时展示 `msg`。发布接口在 `code=0` 时还需检查 `data.sent` 和 `data.pinned`。
+「频道管理」页面包含「频道消息」「机器人管理」「私聊底部菜单」三个标签。请求带 `Authorization: Bearer <管理员 token>`,POST 使用 JSON,文件上传使用表单。业务校验失败时展示 `msg`。发布接口在 `code=0` 时还需检查 `data.sent` 和 `data.pinned`。
 
 ## 接口与权限
 
@@ -8,19 +8,28 @@
 | --- | --- | --- | --- |
 | `GET /admin/telegramChannel/list` | 无 | `{ "list": [...] }` | `admin/telegramChannel/list` |
 | `GET /admin/telegramChannel/detail?id=12` | `id` | 频道详情 | `admin/telegramChannel/list` |
-| `POST /admin/telegramChannel/save` | 频道资料、机器人绑定和可选消息内容 | 保存后的频道详情 | `admin/telegramChannel/save` |
+| `POST /admin/telegramChannel/save` | 频道资料、`bot_id` 和可选消息内容 | 保存后的频道详情 | `admin/telegramChannel/save` |
 | `POST /admin/telegramChannel/setDefault` | `{ "id": 12 }` | 更新后的频道详情 | `admin/telegramChannel/save` |
 | `POST /admin/telegramChannel/delete` | `{ "id": 12 }` | `[]` | `admin/telegramChannel/save` |
 | `POST /admin/telegramChannel/publish` | `{ "id": 12, "isTop": true }` | 发布及置顶结果 | `admin/telegramChannel/publish` |
 
-列表返回 `id`、`name`、`chatId`、`telegram_chat_id`、`bot_username`、`bot_name`、`has_bot_token`、`enabled`、`is_default`。详情额外返回 `type`、`text`、`image`、`video`、`button`。所有编辑、删除、设默认和发布操作使用后台返回的 `id`。频道的 Telegram 数字 ID 以字符串处理,`telegram_chat_id` 在旧频道尚未解析时可为 `null`。
+列表返回 `id`、`name`、`chatId`、`telegram_chat_id`、`bot_id`、`bot_username`、`bot_name`、`has_bot_token`、`enabled`、`is_default`。详情额外返回 `type`、`text`、`image`、`video`、`button`。所有编辑、删除、设默认和发布操作使用后台返回的 `id`。频道的 Telegram 数字 ID 以字符串处理,`telegram_chat_id` 在旧频道尚未解析时可为 `null`。
+
+## 机器人管理
+
+| 方法与地址 | 请求参数 | 返回 `data` | 按钮权限 |
+| --- | --- | --- | --- |
+| `GET /admin/telegramBot/list` | 无 | `{ "list": [...] }` | `admin/telegramChannel/list` |
+| `POST /admin/telegramBot/save` | `bot_token`,可选 `bot_username`,更换 Token 时带 `id` | 机器人资料 | `admin/telegramChannel/save` |
+| `POST /admin/telegramBot/delete` | `{ "id": 8 }` | `[]` | `admin/telegramChannel/save` |
+
+机器人列表和保存响应返回 `id`、`bot_username`、`bot_name`。Token 输入框使用密码类型,每次添加或更换时手动填写,接口不会回传。一个机器人可供多个频道选择。机器人仍被频道使用时,先调整频道绑定,再删除机器人。
 
 ## 上传、保存与回读
 
 - 图片上传用 `POST /admin/upload/uploadFile`,表单字段为 `image`。视频上传用 `POST /admin/upload/uploadVideo`,表单字段为 `file`。把返回的 `data.path` 填入保存请求的 `image` 或 `video`。
 - `chatId` 必填,可用 `@频道用户名` 或 `"-100…"`。新增或变更目标时需要机器人能读取该频道。
-- 绑定或更换频道机器人时填写 `bot_token`(BotFather 发放的 Token)。`bot_username` 可选,填机器人的 `@用户名`,用于核对 Token 对应账号。保存后返回 Telegram 确认的 `bot_username` 和显示名 `bot_name`。
-- Token 输入框使用密码类型,详情回读时留空。编辑时省略 `bot_token` 或传 `null` 会保留已绑定的 Token。接口只返回 `has_bot_token` 表示是否已绑定专用 Token。未绑定时沿用系统机器人。
+- 频道下拉项来自机器人列表。保存频道时提交所选机器人的 `bot_id`。传 `null` 使用系统机器人,编辑时省略则保留现有绑定。
 - `name` 是后台显示的频道名称,可留空或省略。新增时默认使用 Telegram 频道名称,编辑时留空会保留原名称。新增默认启用。只改名称或启停时提交 `id`、当前 `chatId` 和要修改的字段,原消息会保留。
 - 消息 `type` 支持 `text`、`image`、`video`。纯文字需要 `text`,图片需要 `image`,视频需要 `video`。图片和视频的说明也用 `text`。`button` 是按行排列的二维数组,每个按钮填写 `text` 和 `url`。编辑消息时提交完整消息字段,清空按钮传 `[]`。
 
@@ -30,8 +39,7 @@
 {
   "chatId": "@example_channel",
   "name": "活动频道",
-  "bot_username": "@example_channel_bot",
-  "bot_token": "123456789:REPLACE_WITH_BOTFATHER_TOKEN",
+  "bot_id": 8,
   "enabled": true,
   "type": "image",
   "text": "欢迎加入",
@@ -44,7 +52,7 @@
 }
 ```
 
-保存成功后用返回的频道详情更新表单,再刷新列表。新建频道可以先绑定机器人,再编辑消息。详情的 `type` 为 `null` 时提示先保存内容再发布。只有启用的频道可以设为默认。停用或删除默认频道会清除默认状态,房间通知暂停,直到重新设定。删除频道配置后,Telegram 上已发布的帖文仍可查看。
+保存成功后用返回的频道详情更新表单,再刷新列表。可先添加机器人,再创建频道并选择该机器人。详情的 `type` 为 `null` 时提示先保存内容再发布。只有启用的频道可以设为默认。停用或删除默认频道会清除默认状态,房间通知暂停,直到重新设定。删除频道配置后,Telegram 上已发布的帖文仍可查看。
 
 ## 更新机器人私聊底部菜单
 

+ 9 - 0
routes/admin.php

@@ -346,6 +346,15 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::post('/setField', [Config::class, 'setField']);
         });
 
+        Route::prefix('/telegramBot')->group(function () {
+            Route::get('/list', [TelegramChannel::class, 'bots'])
+                ->defaults('permission_uri', TelegramChannelService::VIEW)->defaults('require_active_button', true);
+            Route::post('/save', [TelegramChannel::class, 'saveBot'])
+                ->defaults('permission_uri', TelegramChannelService::MANAGE)->defaults('require_active_button', true);
+            Route::post('/delete', [TelegramChannel::class, 'deleteBot'])
+                ->defaults('permission_uri', TelegramChannelService::MANAGE)->defaults('require_active_button', true);
+        });
+
         Route::prefix('/telegramChannel')->group(function () {
             Route::get('/list', [TelegramChannel::class, 'index'])->defaults('require_active_button', true);
             Route::get('/detail', [TelegramChannel::class, 'detail'])

+ 56 - 4
tests/Integration/TelegramChannelTest.php

@@ -102,6 +102,7 @@ class TelegramChannelTest extends TestCase
         }
         (require database_path('migrations/2026_11_13_150000_create_telegram_channels.php'))->up();
         (require database_path('migrations/2026_11_13_150200_add_bot_credentials_to_telegram_channels.php'))->up();
+        (require database_path('migrations/2026_11_13_150300_create_telegram_bots.php'))->up();
     }
 
     private function request(array $data = [], string $method = 'POST', int $userId = 1): Request
@@ -316,7 +317,7 @@ class TelegramChannelTest extends TestCase
         $this->assertSame('机器人一', $first['bot_name']);
         $this->assertTrue($first['has_bot_token']);
         $this->assertArrayNotHasKey('bot_token', $first);
-        $encrypted = DB::table('telegram_channels')->where('id', $first['id'])->value('bot_token');
+        $encrypted = DB::table('telegram_bots')->where('id', $first['bot_id'])->value('bot_token');
         $this->assertNotSame($firstToken, $encrypted);
         $this->assertSame($firstToken, Crypt::decryptString($encrypted));
 
@@ -324,7 +325,7 @@ class TelegramChannelTest extends TestCase
         $this->telegramChannel(-1001000000002, 'second_channel');
         $second = $this->service->save($this->message('@second_channel', '二') + ['bot_token' => $secondToken]);
         $this->service->save(['id' => $first['id'], 'chatId' => $first['chatId'], 'name' => '改名', 'bot_token' => null]);
-        $this->assertSame($encrypted, DB::table('telegram_channels')->where('id', $first['id'])->value('bot_token'));
+        $this->assertSame($encrypted, DB::table('telegram_bots')->where('id', $first['bot_id'])->value('bot_token'));
 
         $this->telegramResult(['message_id' => 10]);
         $this->telegramResult(true);
@@ -340,13 +341,64 @@ class TelegramChannelTest extends TestCase
         $this->assertArrayNotHasKey('bot_token', $this->service->list()[0]);
     }
 
+    public function test_registered_bot_can_be_reused_rotated_and_deleted_after_unbinding(): void
+    {
+        $this->migrate();
+        $this->telegramResult(['id' => 111, 'is_bot' => true, 'username' => 'shared_bot', 'first_name' => '机器人']);
+        $bot = $this->service->saveBot(['bot_token' => '111:fixture_initial']);
+        $this->assertArrayNotHasKey('bot_token', $bot);
+        $this->assertSame($bot['id'], (int) $this->service->bots()[0]->id);
+
+        $this->telegramChannel(-1001000000001, 'first_channel');
+        $first = $this->service->save($this->message('@first_channel', '一') + ['bot_id' => $bot['id']]);
+        $this->telegramChannel(-1001000000002, 'second_channel');
+        $second = $this->service->save($this->message('@second_channel', '二') + ['bot_id' => $bot['id']]);
+        $this->assertSame($first['bot_id'], $second['bot_id']);
+        $request = $this->request(['id' => $bot['id']]);
+        $this->assertSame(-3, (new TelegramChannel())->deleteBot($request, $this->service)->getData(true)['code']);
+
+        $this->telegramResult(['id' => 111, 'is_bot' => true, 'username' => 'shared_bot', 'first_name' => '机器人']);
+        $this->service->saveBot(['id' => $bot['id'], 'bot_token' => '111:fixture_rotated']);
+        $this->telegramResult(['message_id' => 10]);
+        $this->assertTrue($this->service->publish($first['id'], false)['sent']);
+        $this->assertSame('/bot111:fixture_rotated/sendMessage', $this->requests[4]['request']->getUri()->getPath());
+
+        $this->telegramChannel(-1001000000001, 'first_channel');
+        $first = $this->service->save(['id' => $first['id'], 'chatId' => $first['chatId'], 'bot_id' => null]);
+        $this->assertNull($first['bot_id']);
+        $this->service->delete($second['id']);
+        $this->service->deleteBot($bot['id']);
+        $this->assertSame([], $this->service->bots());
+    }
+
+    public function test_migration_preserves_shared_channel_bot_and_chooses_the_latest_token(): void
+    {
+        $this->migrate();
+        $migration = require database_path('migrations/2026_11_13_150300_create_telegram_bots.php');
+        $migration->down();
+        foreach (['old', 'new'] as $index => $suffix) {
+            DB::table('telegram_channels')->insert([
+                'name' => $suffix, 'target' => '@channel_' . $suffix, 'enabled' => true, 'message' => 'null',
+                'bot_username' => 'shared_bot', 'bot_name' => '机器人',
+                'bot_token' => Crypt::encryptString('111:fixture_' . $suffix),
+                'updated_at' => now()->addMinutes($index),
+            ]);
+        }
+        $migration->up();
+        $migration->up();
+        $this->assertCount(1, $this->service->bots());
+        $this->assertSame('111:fixture_new', Crypt::decryptString(DB::table('telegram_bots')->value('bot_token')));
+        $this->assertCount(1, DB::table('telegram_channels')->distinct()->pluck('bot_id'));
+        $this->assertSame(0, DB::table('telegram_channels')->whereNotNull('bot_token')->count());
+    }
+
     public function test_wrong_bot_username_does_not_replace_existing_token(): void
     {
         $this->migrate();
         $this->telegramResult(['id' => 111, 'is_bot' => true, 'username' => 'first_bot', 'first_name' => '机器人一']);
         $this->telegramChannel(-1001000000001, 'first_channel');
         $first = $this->service->save($this->message('@first_channel', '一') + ['bot_token' => '111:fixture_first']);
-        $before = DB::table('telegram_channels')->where('id', $first['id'])->value('bot_token');
+        $before = DB::table('telegram_bots')->where('id', $first['bot_id'])->value('bot_token');
 
         $this->telegramResult(['id' => 222, 'is_bot' => true, 'username' => 'second_bot', 'first_name' => '机器人二']);
         $request = $this->request([
@@ -357,7 +409,7 @@ class TelegramChannelTest extends TestCase
         $this->assertSame(-3, $result['code']);
         $this->assertStringContainsString('不匹配', $result['msg']);
         $this->assertStringNotContainsString('222:fixture_second', $result['msg']);
-        $this->assertSame($before, DB::table('telegram_channels')->where('id', $first['id'])->value('bot_token'));
+        $this->assertSame($before, DB::table('telegram_bots')->where('id', $first['bot_id'])->value('bot_token'));
     }
 
     public function test_publish_permission_is_independent_from_save_permission(): void