app = new Application(dirname(__DIR__, 2)); $this->app->instance('config', new Repository([ 'app' => ['locale' => 'zh', 'url' => 'https://api.example.test'], 'services' => ['telegram' => ['token' => '1:fixture']], 'filesystems' => ['disks' => ['public' => [ 'driver' => 'local', 'root' => sys_get_temp_dir(), 'url' => 'https://admin.example.com/storage', ]]], ])); Facade::setFacadeApplication($this->app); $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']); $db->setAsGlobal(); $db->bootEloquent(); $this->app->instance('db', $db->getDatabaseManager()); $this->app->bind('db.schema', fn () => $db->schema()); $translator = new Translator(new ArrayLoader(), 'zh'); $this->app->instance('translator', $translator); $this->app->instance('validator', new Factory($translator, $this->app)); $this->app->instance(ResponseFactory::class, new JsonResponseFactory()); Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); }); Schema::create('config', function (Blueprint $table) { $table->id(); $table->string('field')->unique(); $table->text('val'); $table->integer('group_id')->default(2); }); $this->telegram = new MockHandler(); $stack = HandlerStack::create($this->telegram); $stack->push(Middleware::history($this->requests)); $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 { DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); } private function migrate(?array $legacy = null): void { if ($legacy !== null) { 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_150200_add_bot_credentials_to_telegram_channels.php'))->up(); (require database_path('migrations/2026_11_13_150300_create_telegram_bots.php'))->up(); (require database_path('migrations/2026_11_13_150400_add_private_menu_to_telegram_bots.php'))->up(); (require database_path('migrations/2026_11_13_150500_create_telegram_channel_messages.php'))->up(); } private function request(array $data = [], string $method = 'POST', int $userId = 1): Request { $request = TelegramChannelTestRequest::create('/admin/telegramChannel', $method, $data); $request->user = (object) ['id' => $userId]; $this->app->instance('request', $request); return $request; } private function telegramResult(mixed $result): void { $this->telegram->append(new Response(200, [], json_encode(['ok' => true, 'result' => $result]))); } private function telegramChannel(int $id, ?string $username = null): void { $this->telegramResult(['id' => $id, 'type' => 'channel', 'username' => $username, 'title' => '频道']); } private function message(string $chatId, string $text): array { return [ 'chatId' => $chatId, 'type' => 'text', 'text' => $text, 'button' => [[['text' => '网站', 'url' => 'https://example.com']]], ]; } public function test_migrates_old_image_message_and_does_not_overwrite_it_when_saving_another_channel(): void { $legacy = [ 'chatId' => 'OriginalChannel', 'image' => 'https://example.com/menu.jpg', 'text' => '旧文案', 'button' => [[['text' => '网站', 'url' => 'https://example.com']]], ]; $this->migrate($legacy); $this->assertSame([], $this->requests); $default = $this->service->list()[0]; $this->assertTrue($default['is_default']); $this->telegramChannel(-1001234567890); $this->request($this->message('-1001234567890', '第二个频道') + ['isSend' => false]); $this->assertSame(0, (new Config())->sendChannelMessage($this->service)->getData(true)['code']); $this->assertCount(1, $this->requests); $this->assertCount(2, $this->service->list()); $this->request(['field' => 'channel_message'], 'GET'); $controller = new Config(); $value = $controller->get()->getData(true)['data']['channel_message']; $this->assertSame('originalchannel', $value['chatId']); $this->assertSame('image', $value['type']); $this->assertSame($legacy['button'], $value['button']); $this->assertSame($value, $controller->getAll()->getData(true)['data']['channel_message']); $this->assertSame(json_encode($legacy), DB::table('config')->value('val')); } public function test_quarantines_local_debug_url_and_denies_generic_write_without_channel_permission(): void { $legacy = [ 'chatId' => 'test', 'type' => 'image', 'text' => 'test', 'image' => 'http://127.0.0.1/_ignition/execute-solution', 'video' => '', ]; $this->migrate($legacy); $this->assertSame([], $this->service->list()); $this->assertNull(ChannelService::createRoomNotice('missing')); $this->request(['field' => 'channel_message', 'val' => json_encode($legacy)]); $this->assertNotSame(0, (new Config())->setField()->getData(true)['code']); $this->installPermissions(); $this->request(['field' => 'channel_message', 'val' => $this->message('@another', '禁止')], userId: 2); $this->assertSame(101009, (new Config())->setField()->getData(true)['code']); $this->assertSame(json_encode($legacy), DB::table('config')->value('val')); $this->assertSame([], $this->requests); } public function test_edits_one_channel_and_routes_room_notices_only_to_the_enabled_default(): void { $this->migrate(); Schema::create('rooms', function (Blueprint $table) { $table->string('room_id'); $table->string('game_name'); $table->integer('rounds'); $table->integer('base_score'); $table->integer('participants'); }); DB::table('rooms')->insert(['room_id' => '123', 'game_name' => '游戏', 'rounds' => 3, 'base_score' => 10, 'participants' => 4]); $this->telegramChannel(-1001000000001, 'first_channel'); $first = $this->service->save($this->message('@first_channel', '一')); $this->telegramChannel(-1001000000002, 'second_channel'); $second = $this->service->save(['chatId' => '@second_channel']); $this->assertNull($second['type']); $request = $this->request(['id' => $second['id']]); $this->assertSame(-3, (new TelegramChannel())->publish($request, $this->service)->getData(true)['code']); $this->service->setDefault($second['id']); $this->assertSame('-1001000000002', ChannelService::createRoomNotice('123')['chat_id']); $this->service->save($this->message('@second_channel', '二') + ['id' => $second['id'], 'enabled' => false]); $this->assertSame('一', $this->service->detail($first['id'])['text']); $this->assertSame('二', $this->service->detail($second['id'])['text']); $this->assertNull(ChannelService::createRoomNotice('123')); $this->service->setDefault($first['id']); $this->service->delete($first['id']); $this->assertNull(ChannelService::createRoomNotice('123')); $this->assertCount(2, $this->requests); } public function test_publishes_only_to_selected_channel_and_reports_pin_failure_separately(): void { $this->migrate(); $this->telegramChannel(-1001000000001, 'first_channel'); $first = $this->service->save($this->message('@first_channel', '一')); $this->service->setDefault($first['id']); $this->telegramChannel(-1001000000002); $second = $this->service->save($this->message('-1001000000002', '二')); $this->telegramResult(['message_id' => 37]); $this->telegram->append(new Response(400, [], '{"ok":false,"error_code":400,"description":"pin rejected"}')); $request = $this->request(['id' => $second['id'], 'isTop' => true]); $result = (new TelegramChannel())->publish($request, $this->service)->getData(true); $this->assertSame(0, $result['code']); $this->assertSame(['sent' => true, 'pinned' => false, 'message_id' => 37], array_intersect_key($result['data'], array_flip(['sent', 'pinned', 'message_id']))); $this->assertCount(4, $this->requests); parse_str((string) $this->requests[2]['request']->getBody(), $sent); $this->assertSame('-1001000000002', $sent['chat_id']); $this->assertSame('二', $sent['text']); $this->assertSame($second['button'], json_decode($sent['reply_markup'], true)['inline_keyboard']); parse_str((string) $this->requests[3]['request']->getBody(), $pin); $this->assertSame(['chat_id' => '-1001000000002', 'message_id' => '37'], $pin); } private function installPermissions(): void { Schema::create('menus', function (Blueprint $table) { $table->id(); $table->unsignedBigInteger('parent_id')->default(0); $table->string('title')->default(''); $table->string('uri'); $table->string('permission_name')->nullable(); $table->integer('type'); $table->integer('sort')->default(0); $table->integer('status')->default(1); $table->timestamps(); }); Schema::create('role_menu', function (Blueprint $table) { $table->unsignedBigInteger('menu_id'); $table->unsignedBigInteger('role_id'); $table->primary(['menu_id', 'role_id']); }); Schema::create('roles', function (Blueprint $table) { $table->id(); }); Schema::create('role_user', function (Blueprint $table) { $table->unsignedBigInteger('user_id'); $table->unsignedBigInteger('role_id'); }); $previous = DB::table('menus')->insertGetId(['uri' => 'admin/config/sendChannelMessage', 'type' => 2]); DB::table('role_menu')->insert(['menu_id' => $previous, 'role_id' => 8]); (require database_path('migrations/2026_11_13_150100_add_telegram_channel_permissions.php'))->up(); } public function test_old_permission_is_carried_forward_and_channel_ids_are_deduplicated(): void { $this->migrate(); $this->installPermissions(); $role = DB::table('role_menu')->join('menus', 'menus.id', '=', 'role_menu.menu_id')->where('role_id', 8); $this->assertContains(TelegramChannelService::MANAGE, $role->pluck('uri')->all()); $this->assertContains(TelegramChannelService::PUBLISH, $role->pluck('uri')->all()); DB::table('role_user')->insert(['user_id' => 2, 'role_id' => 8]); $this->telegramChannel(-1001000000001, 'first_channel'); $this->request(['field' => 'channel_message', 'val' => $this->message('@first_channel', '允许')], userId: 2); $this->assertSame(0, (new Config())->setField()->getData(true)['code']); $this->telegramChannel(-1001000000001, 'first_channel'); $request = $this->request(['chatId' => '-1001000000001']); $this->assertSame(-3, (new TelegramChannel())->save($request, $this->service)->getData(true)['code']); $this->assertCount(1, $this->service->list()); } public function test_blank_channel_name_defaults_on_create_and_is_preserved_on_edit(): void { $this->migrate(); $this->telegramChannel(-1001000000001, 'first_channel'); $request = $this->request(['chatId' => '@first_channel', 'name' => ' ']); $controller = new TelegramChannel(); $saved = $controller->save($request, $this->service)->getData(true); $this->assertSame(0, $saved['code']); $this->assertSame('频道', $saved['data']['name']); $edit = ['id' => $saved['data']['id'], 'chatId' => '@first_channel']; $request = $this->request($edit + ['name' => ' 后台自定义名称 ']); $this->assertSame('后台自定义名称', $controller->save($request, $this->service)->getData(true)['data']['name']); $request = $this->request($edit + ['name' => null]); $this->assertSame('后台自定义名称', $controller->save($request, $this->service)->getData(true)['data']['name']); $this->assertCount(1, $this->requests); } 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_bots')->where('id', $first['bot_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_bots')->where('id', $first['bot_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_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_start_uses_the_menu_and_token_of_the_webhook_bot(): void { $this->migrate(); $this->telegramResult(['id' => 111, 'is_bot' => true, 'username' => 'first_bot']); $first = $this->service->saveBot(['bot_token' => '111:fixture_first']); $this->telegramResult(['id' => 222, 'is_bot' => true, 'username' => 'second_bot']); $second = $this->service->saveBot(['bot_token' => '222:fixture_second']); $this->service->savePrivateMenu($first['id'], 'zh', ['reply' => '欢迎一', 'buttons' => [['联系客服']]]); $this->service->savePrivateMenu($second['id'], 'zh', ['reply' => '欢迎二', 'buttons' => [['帮助']]]); $this->telegramResult(['url' => '']); $this->telegramResult(true); $this->service->registerPrivateWebhook($second['id']); parse_str((string) $this->requests[3]['request']->getBody(), $registration); $this->assertSame('https://api.example.test/api/telegramBot/' . $second['id'] . '/webhook', $registration['url']); $update = ['message' => ['chat' => ['id' => 7, 'type' => 'private'], 'from' => ['language_code' => 'zh-cn'], 'text' => '/start']]; try { $this->service->handlePrivateWebhook($second['id'], 'wrong-secret', $update); $this->fail('An invalid webhook secret was accepted'); } catch (\Symfony\Component\HttpKernel\Exception\NotFoundHttpException) { $this->assertCount(4, $this->requests); } $this->telegramResult(['message_id' => 25]); $this->service->handlePrivateWebhook($second['id'], $registration['secret_token'], $update); $request = $this->requests[4]['request']; $this->assertSame('/bot222:fixture_second/sendMessage', $request->getUri()->getPath()); parse_str((string) $request->getBody(), $sent); $this->assertSame('欢迎二', $sent['text']); $this->assertSame([['帮助']], json_decode($sent['reply_markup'], true)['keyboard']); } public function test_system_bot_channel_and_scheduled_message_are_saved_independently(): void { $this->migrate(); $this->telegramChannel(-1001000000001, 'first_channel'); $channel = $this->service->save(['chatId' => '@first_channel', 'bot_id' => null]); $this->assertNull($channel['bot_id']); $this->assertSame('/bot1:fixture/getChat', $this->requests[0]['request']->getUri()->getPath()); $messages = new TelegramChannelMessageService($this->service); $input = ['channel_id' => $channel['id'], 'type' => 'text', 'text' => '定时消息', 'scheduled_at' => now('UTC')->addHour()->toIso8601String()]; $record = $messages->create($input); $cancelled = $messages->create($input); $messages->cancel($cancelled['id']); $messages->dispatchDue(); $this->assertSame('scheduled', $record['status']); $this->assertCount(1, $this->requests); DB::table('telegram_channel_messages')->update(['scheduled_at' => now('UTC')->subMinute()]); $this->telegramResult(['message_id' => 26]); $messages->dispatchDue(); $messages->dispatchDue(); $this->assertCount(2, $this->requests); $list = $messages->list(1); $this->assertSame(['cancelled', 'sent'], array_column($list['list'], 'status')); $this->assertSame(26, (int) $list['list'][1]['telegram_message_id']); $this->assertNull($this->service->detail($channel['id'])['type']); } 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_bots')->where('id', $first['bot_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_bots')->where('id', $first['bot_id'])->value('bot_token')); } public function test_publish_permission_is_independent_from_save_permission(): void { $this->migrate(); $this->installPermissions(); DB::table('roles')->insert(['id' => 9]); DB::table('role_user')->insert(['user_id' => 2, 'role_id' => 9]); $saveButton = DB::table('menus')->where('uri', TelegramChannelService::MANAGE)->value('id'); DB::table('role_menu')->insert(['menu_id' => $saveButton, 'role_id' => 9]); $request = $this->request(userId: 2); $middleware = new CheckButtonPermission(); $save = (new Route('POST', TelegramChannelService::MANAGE, fn () => null))->defaults('require_active_button', true); $request->setRouteResolver(fn () => $save); $this->assertTrue($middleware->handle($request, fn () => true)); $publish = (new Route('POST', TelegramChannelService::PUBLISH, fn () => null))->defaults('require_active_button', true); $request->setRouteResolver(fn () => $publish); $this->assertSame(-1, $middleware->handle($request, fn () => true)->getData(true)['code']); $this->assertSame([], $this->requests); } public function test_external_media_url_is_passed_to_telegram_without_downloading_it(): void { $this->migrate(); $this->app->register(FilesystemServiceProvider::class); $this->telegramChannel(-1001000000001, 'first_channel'); $media = 'https://files.example.test/photo.jpg'; $channel = $this->service->save(['chatId' => '@first_channel', 'type' => 'image', 'image' => $media]); $this->telegramResult(['message_id' => 18]); $this->assertTrue($this->service->publish($channel['id'], false)['sent']); $this->assertStringEndsWith('/sendPhoto', $this->requests[1]['request']->getUri()->getPath()); parse_str((string) $this->requests[1]['request']->getBody(), $sent); $this->assertSame($media, $sent['photo']); } public function test_admin_upload_is_sent_as_a_file_and_migration_can_be_retried(): void { $this->migrate(); $this->app->register(FilesystemServiceProvider::class); $disk = Storage::disk('public'); $file = 'videos/' . bin2hex(random_bytes(16)) . '.mp4'; $disk->put($file, 'test video'); try { $this->telegramChannel(-1001000000001, 'first_channel'); $channel = $this->service->save([ 'chatId' => '@first_channel', 'type' => 'video', 'video' => $disk->url($file), ]); $this->service->setDefault($channel['id']); (require database_path('migrations/2026_11_13_150000_create_telegram_channels.php'))->up(); $this->assertTrue($this->service->detail($channel['id'])['is_default']); $this->telegram->append(function ($request) { $this->assertStringEndsWith('/sendVideo', $request->getUri()->getPath()); $this->assertStringStartsWith('multipart/form-data;', $request->getHeaderLine('Content-Type')); $this->assertStringContainsString('test video', (string) $request->getBody()); return new Response(200, [], '{"ok":true,"result":{"message_id":18}}'); }); $this->assertTrue($this->service->publish($channel['id'], false)['sent']); } finally { $disk->delete($file); } } } class TelegramChannelTestRequest extends Request { public object $user; }