doge vor 12 Stunden
Ursprung
Commit
8551418e95

+ 1 - 0
app/Exceptions/Handler.php

@@ -34,6 +34,7 @@ class Handler extends ExceptionHandler
         'current_password',
         'current_password',
         'password',
         'password',
         'password_confirmation',
         'password_confirmation',
+        'bot_token',
     ];
     ];
 
 
     /**
     /**

+ 1 - 1
app/Http/Controllers/admin/Config.php

@@ -414,7 +414,7 @@ class Config extends Controller
                 return $this->success();
                 return $this->success();
             }
             }
 
 
-            $result = $channels->publish($channel, (bool) ($data['isTop'] ?? false));
+            $result = $channels->publish($channel['id'], (bool) ($data['isTop'] ?? false));
             if ($result['sent'] !== true) {
             if ($result['sent'] !== true) {
                 return $this->error(HttpStatus::CUSTOM_ERROR, '配置已保存,' . $result['error'], $result);
                 return $this->error(HttpStatus::CUSTOM_ERROR, '配置已保存,' . $result['error'], $result);
             }
             }

+ 1 - 1
app/Http/Controllers/admin/TelegramChannel.php

@@ -44,7 +44,7 @@ class TelegramChannel extends Controller
     {
     {
         return $this->respond(function () use ($request, $channels) {
         return $this->respond(function () use ($request, $channels) {
             $data = $request->validate(['id' => 'required|integer|min:1', 'isTop' => 'sometimes|required|boolean']);
             $data = $request->validate(['id' => 'required|integer|min:1', 'isTop' => 'sometimes|required|boolean']);
-            return $channels->publish($channels->detail((int) $data['id']), (bool) ($data['isTop'] ?? false));
+            return $channels->publish((int) $data['id'], (bool) ($data['isTop'] ?? false));
         });
         });
     }
     }
 
 

+ 69 - 12
app/Services/TelegramChannelService.php

@@ -3,6 +3,7 @@
 namespace App\Services;
 namespace App\Services;
 
 
 use Illuminate\Database\Query\Builder;
 use Illuminate\Database\Query\Builder;
+use Illuminate\Support\Facades\Crypt;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Facades\Validator;
 use Illuminate\Support\Facades\Validator;
@@ -33,13 +34,15 @@ class TelegramChannelService
         return $this->present($row, (int) $row->default_channel_id === $id, true);
         return $this->present($row, (int) $row->default_channel_id === $id, true);
     }
     }
 
 
-    public function save(array $input, bool $legacy = false): array
+    public function save(#[\SensitiveParameter] array $input, bool $legacy = false): array
     {
     {
         $hasMessage = array_intersect(['type', 'text', 'image', 'video', 'button'], array_keys($input)) !== [];
         $hasMessage = array_intersect(['type', 'text', 'image', 'video', 'button'], array_keys($input)) !== [];
         $fields = Validator::make($input, [
         $fields = Validator::make($input, [
             'id' => $legacy ? 'prohibited' : 'sometimes|required|integer|min:1',
             'id' => $legacy ? 'prohibited' : 'sometimes|required|integer|min:1',
             'name' => 'sometimes|required|string|max:255',
             'name' => 'sometimes|required|string|max:255',
             'chatId' => 'required|string|max:64',
             'chatId' => 'required|string|max:64',
+            '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',
             'enabled' => 'sometimes|required|boolean',
             'type' => [($legacy || $hasMessage) ? 'required' : 'sometimes', 'string', 'in:text,image,video'],
             'type' => [($legacy || $hasMessage) ? 'required' : 'sometimes', 'string', 'in:text,image,video'],
         ])->validate();
         ])->validate();
@@ -49,17 +52,23 @@ class TelegramChannelService
             throw ValidationException::withMessages(['chatId' => '请输入频道用户名或负数频道 ID']);
             throw ValidationException::withMessages(['chatId' => '请输入频道用户名或负数频道 ID']);
         }
         }
         $message = isset($fields['type']) ? self::message($input) : null;
         $message = isset($fields['type']) ? self::message($input) : null;
-        $row = !$legacy && isset($fields['id']) ? $this->find((int) $fields['id']) : 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();
+        }
+        $newToken = $fields['bot_token'] ?? null;
+        $bot = $this->validateBot($fields['bot_username'] ?? null, $newToken, $row);
         $targetUnchanged = $row !== null && in_array($target, [$row->target, $row->telegram_chat_id], true);
         $targetUnchanged = $row !== null && in_array($target, [$row->target, $row->telegram_chat_id], true);
-        $identity = $targetUnchanged
+        $identity = $targetUnchanged && $newToken === null
             ? ['target' => $row->target, 'telegram_chat_id' => $row->telegram_chat_id, 'title' => $row->name]
             ? ['target' => $row->target, 'telegram_chat_id' => $row->telegram_chat_id, 'title' => $row->name]
-            : $this->resolve($target);
+            : $this->resolve($target, $newToken ?? $this->storedToken($row));
 
 
-        return DB::transaction(function () use ($fields, $identity, $message, $legacy, $row) {
+        return DB::transaction(function () use ($fields, $identity, $message, $legacy, $row, $bot) {
             $default = $this->settings()->lockForUpdate()->first();
             $default = $this->settings()->lockForUpdate()->first();
             $channels = DB::table('telegram_channels');
             $channels = DB::table('telegram_channels');
             $existing = $row === null ? null : (clone $channels)->where('id', $row->id)->first();
             $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)) {
+            if ($row !== null && ($existing === null || $existing->target !== $row->target
+                || $existing->telegram_chat_id !== $row->telegram_chat_id || $existing->bot_token !== $row->bot_token)) {
                 throw ValidationException::withMessages(['id' => '频道已变更,请刷新后重试']);
                 throw ValidationException::withMessages(['id' => '频道已变更,请刷新后重试']);
             }
             }
 
 
@@ -69,8 +78,11 @@ class TelegramChannelService
                     $query->orWhere('telegram_chat_id', $identity['telegram_chat_id']);
                     $query->orWhere('telegram_chat_id', $identity['telegram_chat_id']);
                 }
                 }
             })->get();
             })->get();
-            if ($legacy && $match->count() === 1) {
+            if ($legacy && $row === null && $match->count() === 1) {
                 $existing = $match->first();
                 $existing = $match->first();
+                if ($existing->bot_token !== null && $bot === []) {
+                    throw ValidationException::withMessages(['chatId' => '请使用已保存的频道标识,或通过频道管理按 ID 编辑']);
+                }
             }
             }
             if ($match->contains(fn ($channel) => (int) $channel->id !== (int) $existing?->id)) {
             if ($match->contains(fn ($channel) => (int) $channel->id !== (int) $existing?->id)) {
                 throw ValidationException::withMessages(['chatId' => '该频道已存在']);
                 throw ValidationException::withMessages(['chatId' => '该频道已存在']);
@@ -86,7 +98,7 @@ class TelegramChannelService
                     ? ($existing?->message ?? 'null')
                     ? ($existing?->message ?? 'null')
                     : json_encode($message, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
                     : json_encode($message, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
                 'updated_at' => now(),
                 'updated_at' => now(),
-            ];
+            ] + $bot;
             if ($existing === null) {
             if ($existing === null) {
                 $firstLegacy = $legacy && !$channels->exists();
                 $firstLegacy = $legacy && !$channels->exists();
                 $id = (int) $channels->insertGetId($values + ['created_at' => now()]);
                 $id = (int) $channels->insertGetId($values + ['created_at' => now()]);
@@ -142,8 +154,10 @@ class TelegramChannelService
         return $row === null ? null : ($row->telegram_chat_id ?? $row->target);
         return $row === null ? null : ($row->telegram_chat_id ?? $row->target);
     }
     }
 
 
-    public function publish(array $channel, bool $pin): array
+    public function publish(int $id, bool $pin): array
     {
     {
+        $row = $this->find($id);
+        $channel = $this->present($row, (int) $row->default_channel_id === $id, true);
         if (!$channel['enabled']) {
         if (!$channel['enabled']) {
             throw ValidationException::withMessages(['id' => '请先启用频道']);
             throw ValidationException::withMessages(['id' => '请先启用频道']);
         }
         }
@@ -158,7 +172,7 @@ class TelegramChannelService
         }
         }
 
 
         try {
         try {
-            $telegram = app(Api::class);
+            $telegram = $this->telegram($this->storedToken($row));
             $response = $this->send($telegram, $channel, $params);
             $response = $this->send($telegram, $channel, $params);
         } catch (TelegramResponseException $exception) {
         } catch (TelegramResponseException $exception) {
             if (($exception->getHttpStatusCode() ?? 500) < 500) {
             if (($exception->getHttpStatusCode() ?? 500) < 500) {
@@ -226,10 +240,50 @@ class TelegramChannelService
         return $telegram->post($image ? 'sendPhoto' : 'sendVideo', $params + [$field => $media])->getResult() ?: [];
         return $telegram->post($image ? 'sendPhoto' : 'sendVideo', $params + [$field => $media])->getResult() ?: [];
     }
     }
 
 
-    private function resolve(string $target): array
+    private function validateBot(?string $username, #[\SensitiveParameter] ?string $token, ?object $row): array
+    {
+        if ($token === null) {
+            if ($username !== null && strcasecmp(ltrim($username, '@'), $row->bot_username ?? '') !== 0) {
+                throw ValidationException::withMessages(['bot_token' => '绑定或更换机器人时请填写 Bot Token']);
+            }
+            return [];
+        }
+
+        try {
+            $bot = $this->telegram($token)->getMe();
+        } catch (\Exception) {
+            throw ValidationException::withMessages(['bot_token' => '无法验证 Bot Token,请检查 Token 或 Telegram 连接']);
+        }
+
+        $actualUsername = $bot->get('username');
+        if (!$bot->get('is_bot') || !is_string($actualUsername) || $actualUsername === '') {
+            throw ValidationException::withMessages(['bot_token' => 'Bot Token 未返回有效的机器人账号']);
+        }
+        if ($username !== null && strcasecmp(ltrim($username, '@'), $actualUsername) !== 0) {
+            throw ValidationException::withMessages(['bot_username' => '机器人用户名和 Bot Token 不匹配']);
+        }
+
+        return [
+            'bot_username' => $actualUsername,
+            'bot_name' => mb_substr((string) $bot->get('first_name'), 0, 255),
+            'bot_token' => Crypt::encryptString($token),
+        ];
+    }
+
+    private function storedToken(?object $row): ?string
+    {
+        return $row?->bot_token === null ? null : Crypt::decryptString($row->bot_token);
+    }
+
+    protected function telegram(#[\SensitiveParameter] ?string $token): Api
+    {
+        return $token === null ? app(Api::class) : new Api($token);
+    }
+
+    private function resolve(string $target, #[\SensitiveParameter] ?string $token): array
     {
     {
         try {
         try {
-            $chat = app(Api::class)->getChat(['chat_id' => $target]);
+            $chat = $this->telegram($token)->getChat(['chat_id' => $target]);
         } catch (\Exception) {
         } catch (\Exception) {
             throw ValidationException::withMessages(['chatId' => '无法读取频道,请检查频道标识及机器人是否已加入']);
             throw ValidationException::withMessages(['chatId' => '无法读取频道,请检查频道标识及机器人是否已加入']);
         }
         }
@@ -349,6 +403,9 @@ class TelegramChannelService
             'name' => $row->name,
             'name' => $row->name,
             'chatId' => $row->target,
             'chatId' => $row->target,
             'telegram_chat_id' => $row->telegram_chat_id,
             'telegram_chat_id' => $row->telegram_chat_id,
+            'bot_username' => $row->bot_username,
+            'bot_name' => $row->bot_name,
+            'has_bot_token' => $row->bot_token !== null,
             'enabled' => (bool) $row->enabled,
             'enabled' => (bool) $row->enabled,
             'is_default' => $default,
             'is_default' => $default,
         ];
         ];

+ 23 - 0
database/migrations/2026_11_13_150200_add_bot_credentials_to_telegram_channels.php

@@ -0,0 +1,23 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        Schema::table('telegram_channels', function (Blueprint $table) {
+            $table->string('bot_username', 64)->nullable();
+            $table->string('bot_name', 255)->nullable();
+            $table->text('bot_token')->nullable();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('telegram_channels', function (Blueprint $table) {
+            $table->dropColumn(['bot_username', 'bot_name', 'bot_token']);
+        });
+    }
+};

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

@@ -8,17 +8,19 @@
 | --- | --- | --- | --- |
 | --- | --- | --- | --- |
 | `GET /admin/telegramChannel/list` | 无 | `{ "list": [...] }` | `admin/telegramChannel/list` |
 | `GET /admin/telegramChannel/list` | 无 | `{ "list": [...] }` | `admin/telegramChannel/list` |
 | `GET /admin/telegramChannel/detail?id=12` | `id` | 频道详情 | `admin/telegramChannel/list` |
 | `GET /admin/telegramChannel/detail?id=12` | `id` | 频道详情 | `admin/telegramChannel/list` |
-| `POST /admin/telegramChannel/save` | 频道资料和可选消息内容 | 保存后的频道详情 | `admin/telegramChannel/save` |
+| `POST /admin/telegramChannel/save` | 频道资料、机器人绑定和可选消息内容 | 保存后的频道详情 | `admin/telegramChannel/save` |
 | `POST /admin/telegramChannel/setDefault` | `{ "id": 12 }` | 更新后的频道详情 | `admin/telegramChannel/save` |
 | `POST /admin/telegramChannel/setDefault` | `{ "id": 12 }` | 更新后的频道详情 | `admin/telegramChannel/save` |
 | `POST /admin/telegramChannel/delete` | `{ "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` |
 | `POST /admin/telegramChannel/publish` | `{ "id": 12, "isTop": true }` | 发布及置顶结果 | `admin/telegramChannel/publish` |
 
 
-列表返回 `id`、`name`、`chatId`、`telegram_chat_id`、`enabled`、`is_default`。详情额外返回 `type`、`text`、`image`、`video`、`button`。所有编辑、删除、设默认和发布操作使用后台返回的 `id`。频道的 Telegram 数字 ID 以字符串处理,`telegram_chat_id` 在旧频道尚未解析时可为 `null`。
+列表返回 `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`。
 
 
 ## 上传、保存与回读
 ## 上传、保存与回读
 
 
 - 图片上传用 `POST /admin/upload/uploadFile`,表单字段为 `image`。视频上传用 `POST /admin/upload/uploadVideo`,表单字段为 `file`。把返回的 `data.path` 填入保存请求的 `image` 或 `video`。
 - 图片上传用 `POST /admin/upload/uploadFile`,表单字段为 `image`。视频上传用 `POST /admin/upload/uploadVideo`,表单字段为 `file`。把返回的 `data.path` 填入保存请求的 `image` 或 `video`。
-- `chatId` 必填,可用 `@频道用户名` 或 `"-100…"`。新增或变更目标时需要机器人能读取该频道。频道复用系统 Bot,页面无需 Bot Token。
+- `chatId` 必填,可用 `@频道用户名` 或 `"-100…"`。新增或变更目标时需要机器人能读取该频道。
+- 绑定或更换频道机器人时填写 `bot_token`(BotFather 发放的 Token)。`bot_username` 可选,填机器人的 `@用户名`,用于核对 Token 对应账号。保存后返回 Telegram 确认的 `bot_username` 和显示名 `bot_name`。
+- Token 输入框使用密码类型,详情回读时留空。编辑时省略 `bot_token` 或传 `null` 会保留已绑定的 Token。接口只返回 `has_bot_token` 表示是否已绑定专用 Token。未绑定时沿用系统机器人。
 - `name` 可省略,新增时默认用频道名称。新增默认启用。只改名称或启停时提交 `id`、当前 `chatId` 和要修改的字段,原消息会保留。
 - `name` 可省略,新增时默认用频道名称。新增默认启用。只改名称或启停时提交 `id`、当前 `chatId` 和要修改的字段,原消息会保留。
 - 消息 `type` 支持 `text`、`image`、`video`。纯文字需要 `text`,图片需要 `image`,视频需要 `video`。图片和视频的说明也用 `text`。`button` 是按行排列的二维数组,每个按钮填写 `text` 和 `url`。编辑消息时提交完整消息字段,清空按钮传 `[]`。
 - 消息 `type` 支持 `text`、`image`、`video`。纯文字需要 `text`,图片需要 `image`,视频需要 `video`。图片和视频的说明也用 `text`。`button` 是按行排列的二维数组,每个按钮填写 `text` 和 `url`。编辑消息时提交完整消息字段,清空按钮传 `[]`。
 
 
@@ -28,6 +30,8 @@
 {
 {
   "chatId": "@example_channel",
   "chatId": "@example_channel",
   "name": "活动频道",
   "name": "活动频道",
+  "bot_username": "@example_channel_bot",
+  "bot_token": "123456789:REPLACE_WITH_BOTFATHER_TOKEN",
   "enabled": true,
   "enabled": true,
   "type": "image",
   "type": "image",
   "text": "欢迎加入",
   "text": "欢迎加入",
@@ -40,11 +44,19 @@
 }
 }
 ```
 ```
 
 
-保存成功后用返回的频道详情更新表单,再刷新列表。新建频道时可先仅提交 `chatId` 和 `name`,详情的 `type` 为 `null` 时提示先保存内容再发布。只有启用的频道可以设为默认。停用或删除默认频道会清除默认状态,房间通知暂停,直到重新设定。删除频道配置后,Telegram 上已发布的帖文仍可查看。
+保存成功后用返回的频道详情更新表单,再刷新列表。新建频道可以先绑定机器人,再编辑消息。详情的 `type` 为 `null` 时提示先保存内容再发布。只有启用的频道可以设为默认。停用或删除默认频道会清除默认状态,房间通知暂停,直到重新设定。删除频道配置后,Telegram 上已发布的帖文仍可查看。
 
 
-## 发布与置顶
+## 更新机器人私聊底部菜单
 
 
-保存只更新后台内容。点击发布时提交 `id`,`isTop` 省略时默认为 `false`。请求期间禁用发布按钮。机器人需要有频道发帖权限,置顶还需要相应权限。
+页面操作可标为「保存私聊菜单」和「发布频道消息」。只更新私聊底部菜单时,使用现有「关键字回复 → 开始使用」配置。
+
+1. 用 `GET /admin/keyboard?button=开始使用&language=zh` 读取中文菜单,从 `data.data` 取出对应记录。
+2. 用 `POST /admin/keyboard/submit` 保存该记录的 `id`、`language`、`reply` 和完整的 `buttons` 二维数组。有图片或说明时,带回原来的 `image` 和 `explain`。保留其他按钮和既有动作值。例如,已有同名关键词回复的按钮可用 `{"text":"💁联系客服","url":"💁联系客服"}`。这里的 `url` 是动作字段,允许普通文本。读取和保存的按钮权限分别为 `admin/keyboard`、`admin/keyboard/submit`。
+3. 保存后,在该机器人私聊发送 `/start` 刷新菜单。这套入口适用于已接入现有私聊 Webhook 的系统机器人。频道绑定的其他机器人需要单独接入私聊 Webhook。
+
+## 发布频道消息与置顶
+
+保存只更新后台内容。点击发布时提交 `id`,`isTop` 省略时默认为 `false`。请求期间禁用发布按钮。发布和置顶使用该频道绑定的机器人。机器人需要有频道发帖权限,置顶还需要相应权限。
 
 
 ```json
 ```json
 {
 {

+ 77 - 4
tests/Integration/TelegramChannelTest.php

@@ -16,10 +16,12 @@ use Illuminate\Config\Repository;
 use Illuminate\Contracts\Routing\ResponseFactory;
 use Illuminate\Contracts\Routing\ResponseFactory;
 use Illuminate\Database\Capsule\Manager;
 use Illuminate\Database\Capsule\Manager;
 use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Encryption\Encrypter;
 use Illuminate\Filesystem\FilesystemServiceProvider;
 use Illuminate\Filesystem\FilesystemServiceProvider;
 use Illuminate\Foundation\Application;
 use Illuminate\Foundation\Application;
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
 use Illuminate\Routing\Route;
 use Illuminate\Routing\Route;
+use Illuminate\Support\Facades\Crypt;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Facade;
 use Illuminate\Support\Facades\Facade;
 use Illuminate\Support\Facades\Schema;
 use Illuminate\Support\Facades\Schema;
@@ -73,8 +75,17 @@ class TelegramChannelTest extends TestCase
         $this->telegram = new MockHandler();
         $this->telegram = new MockHandler();
         $stack = HandlerStack::create($this->telegram);
         $stack = HandlerStack::create($this->telegram);
         $stack->push(Middleware::history($this->requests));
         $stack->push(Middleware::history($this->requests));
-        $this->app->instance(Api::class, new Api('1:fixture', false, new GuzzleHttpClient(new Client(['handler' => $stack]))));
-        $this->service = new TelegramChannelService();
+        $client = new GuzzleHttpClient(new Client(['handler' => $stack]));
+        $this->app->instance(Api::class, new Api('1:fixture', false, $client));
+        $this->app->instance('encrypter', new Encrypter(random_bytes(32), 'aes-256-cbc'));
+        $this->service = new class($client) extends TelegramChannelService {
+            public function __construct(private GuzzleHttpClient $client) {}
+
+            protected function telegram(#[\SensitiveParameter] ?string $token): Api
+            {
+                return $token === null ? parent::telegram(null) : new Api($token, false, $this->client);
+            }
+        };
     }
     }
 
 
     protected function tearDown(): void
     protected function tearDown(): void
@@ -90,6 +101,7 @@ class TelegramChannelTest extends TestCase
             DB::table('config')->insert(['field' => 'channel_message', 'val' => json_encode($legacy)]);
             DB::table('config')->insert(['field' => 'channel_message', 'val' => json_encode($legacy)]);
         }
         }
         (require database_path('migrations/2026_11_13_150000_create_telegram_channels.php'))->up();
         (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();
     }
     }
 
 
     private function request(array $data = [], string $method = 'POST', int $userId = 1): Request
     private function request(array $data = [], string $method = 'POST', int $userId = 1): Request
@@ -269,6 +281,67 @@ class TelegramChannelTest extends TestCase
         $this->assertCount(1, $this->service->list());
         $this->assertCount(1, $this->service->list());
     }
     }
 
 
+    public function test_channel_tokens_are_encrypted_and_the_correct_bot_publishes_each_channel(): void
+    {
+        $this->migrate();
+        $firstToken = '111:fixture_first';
+        $secondToken = '222:fixture_second';
+        $this->telegramResult(['id' => 111, 'is_bot' => true, 'username' => 'first_bot', 'first_name' => '机器人一']);
+        $this->telegramChannel(-1001000000001, 'first_channel');
+        $request = $this->request($this->message('@first_channel', '一') + [
+            'bot_username' => '@FIRST_BOT', 'bot_token' => $firstToken,
+        ]);
+        $saved = (new TelegramChannel())->save($request, $this->service)->getData(true);
+        $this->assertSame(0, $saved['code']);
+        $first = $saved['data'];
+        $this->assertSame('first_bot', $first['bot_username']);
+        $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');
+        $this->assertNotSame($firstToken, $encrypted);
+        $this->assertSame($firstToken, Crypt::decryptString($encrypted));
+
+        $this->telegramResult(['id' => 222, 'is_bot' => true, 'username' => 'second_bot', 'first_name' => '机器人二']);
+        $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->telegramResult(['message_id' => 10]);
+        $this->telegramResult(true);
+        $this->telegramResult(['message_id' => 20]);
+        $this->assertTrue($this->service->publish($first['id'], true)['pinned']);
+        $this->assertTrue($this->service->publish($second['id'], false)['sent']);
+        $paths = array_map(fn ($entry) => $entry['request']->getUri()->getPath(), $this->requests);
+        $this->assertSame([
+            "/bot$firstToken/getMe", "/bot$firstToken/getChat", "/bot$secondToken/getMe", "/bot$secondToken/getChat",
+            "/bot$firstToken/sendMessage", "/bot$firstToken/pinChatMessage", "/bot$secondToken/sendMessage",
+        ], $paths);
+        $this->assertSame('1:fixture', $this->app->make(Api::class)->getAccessToken());
+        $this->assertArrayNotHasKey('bot_token', $this->service->list()[0]);
+    }
+
+    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');
+
+        $this->telegramResult(['id' => 222, 'is_bot' => true, 'username' => 'second_bot', 'first_name' => '机器人二']);
+        $request = $this->request([
+            'id' => $first['id'], 'chatId' => $first['chatId'],
+            'bot_username' => 'first_bot', 'bot_token' => '222:fixture_second',
+        ]);
+        $result = (new TelegramChannel())->save($request, $this->service)->getData(true);
+        $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'));
+    }
+
     public function test_publish_permission_is_independent_from_save_permission(): void
     public function test_publish_permission_is_independent_from_save_permission(): void
     {
     {
         $this->migrate();
         $this->migrate();
@@ -299,7 +372,7 @@ class TelegramChannelTest extends TestCase
         $channel = $this->service->save(['chatId' => '@first_channel', 'type' => 'image', 'image' => $media]);
         $channel = $this->service->save(['chatId' => '@first_channel', 'type' => 'image', 'image' => $media]);
         $this->telegramResult(['message_id' => 18]);
         $this->telegramResult(['message_id' => 18]);
 
 
-        $this->assertTrue($this->service->publish($channel, false)['sent']);
+        $this->assertTrue($this->service->publish($channel['id'], false)['sent']);
         $this->assertStringEndsWith('/sendPhoto', $this->requests[1]['request']->getUri()->getPath());
         $this->assertStringEndsWith('/sendPhoto', $this->requests[1]['request']->getUri()->getPath());
         parse_str((string) $this->requests[1]['request']->getBody(), $sent);
         parse_str((string) $this->requests[1]['request']->getBody(), $sent);
         $this->assertSame($media, $sent['photo']);
         $this->assertSame($media, $sent['photo']);
@@ -326,7 +399,7 @@ class TelegramChannelTest extends TestCase
 
 
                 return new Response(200, [], '{"ok":true,"result":{"message_id":18}}');
                 return new Response(200, [], '{"ok":true,"result":{"message_id":18}}');
             });
             });
-            $this->assertTrue($this->service->publish($channel, false)['sent']);
+            $this->assertTrue($this->service->publish($channel['id'], false)['sent']);
         } finally {
         } finally {
             $disk->delete($file);
             $disk->delete($file);
         }
         }