Kaynağa Gözat

支持机器人私聊菜单和频道消息定时发送

doge 11 saat önce
ebeveyn
işleme
e990cbfdb6

+ 18 - 0
app/Console/Commands/DispatchTelegramChannelMessages.php

@@ -0,0 +1,18 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\TelegramChannelMessageService;
+use Illuminate\Console\Command;
+
+class DispatchTelegramChannelMessages extends Command
+{
+    protected $signature = 'telegram-channel:dispatch';
+    protected $description = 'Dispatch due Telegram channel messages';
+
+    public function handle(TelegramChannelMessageService $messages): int
+    {
+        $messages->dispatchDue();
+        return self::SUCCESS;
+    }
+}

+ 1 - 0
app/Console/Kernel.php

@@ -27,6 +27,7 @@ class Kernel extends ConsoleKernel
 
         $schedule->command('sport')->dailyAt('23:59:00');
         $schedule->command('messages:dispatch')->everyMinute()->withoutOverlapping(10);
+        $schedule->command('telegram-channel:dispatch')->everyMinute()->withoutOverlapping(10)->runInBackground();
         $schedule->command('reporting:sync')->everyMinute()->withoutOverlapping(30);
         $schedule->command('promotions:sync')->everyMinute()->withoutOverlapping(30);
         $schedule->command('third-game:sync-orders')

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

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\admin;
 use App\Constants\HttpStatus;
 use App\Http\Controllers\Controller;
 use App\Services\TelegramChannelService;
+use App\Services\TelegramChannelMessageService;
 use Closure;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
@@ -17,6 +18,27 @@ class TelegramChannel extends Controller
         return $this->success(['list' => $channels->list()]);
     }
 
+    public function messages(Request $request, TelegramChannelMessageService $messages): JsonResponse
+    {
+        return $this->respond(function () use ($request, $messages) {
+            $data = $request->validate(['page' => 'sometimes|required|integer|min:1']);
+            return $messages->list((int) ($data['page'] ?? 1));
+        });
+    }
+
+    public function createMessage(Request $request, TelegramChannelMessageService $messages): JsonResponse
+    {
+        return $this->respond(fn () => $messages->create($request->all()));
+    }
+
+    public function cancelMessage(Request $request, TelegramChannelMessageService $messages): JsonResponse
+    {
+        return $this->respond(function () use ($request, $messages) {
+            $messages->cancel($this->id($request));
+            return [];
+        });
+    }
+
     public function bots(TelegramChannelService $channels): JsonResponse
     {
         return $this->success(['list' => $channels->bots()]);
@@ -35,6 +57,26 @@ class TelegramChannel extends Controller
         });
     }
 
+    public function privateMenu(Request $request, TelegramChannelService $channels): JsonResponse
+    {
+        return $this->respond(fn () => $channels->privateMenu($this->id($request), $this->menuLanguage($request)));
+    }
+
+    public function savePrivateMenu(Request $request, TelegramChannelService $channels): JsonResponse
+    {
+        return $this->respond(fn () => $channels->savePrivateMenu($this->id($request), $this->menuLanguage($request), $request->all()));
+    }
+
+    public function registerPrivateWebhook(Request $request, TelegramChannelService $channels): JsonResponse
+    {
+        return $this->respond(fn () => $channels->registerPrivateWebhook($this->id($request)));
+    }
+
+    private function menuLanguage(Request $request): string
+    {
+        return $request->validate(['language' => 'required|in:zh,en,vi'])['language'];
+    }
+
     public function detail(Request $request, TelegramChannelService $channels): JsonResponse
     {
         return $this->respond(fn () => $channels->detail($this->id($request)));

+ 21 - 0
app/Http/Controllers/api/TelegramBotWebhook.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Http\Controllers\api;
+
+use App\Services\TelegramChannelService;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+
+class TelegramBotWebhook
+{
+    public function __invoke(Request $request, TelegramChannelService $channels, int $id): JsonResponse
+    {
+        $channels->handlePrivateWebhook(
+            $id,
+            (string) $request->header('X-Telegram-Bot-Api-Secret-Token'),
+            $request->json()->all(),
+        );
+
+        return response()->json(['status' => 'ok']);
+    }
+}

+ 4 - 0
app/Http/Controllers/api/TelegramWebHook.php

@@ -13,6 +13,7 @@ use App\Services\SecretService;
 use App\Services\SettlementService;
 use App\Services\TopUpService;
 use App\Services\TelegramFirstDeposit;
+use App\Services\TelegramChannelService;
 use App\Services\UserService;
 use App\Services\WalletService;
 use App\Services\WithdrawService;
@@ -215,6 +216,9 @@ class TelegramWebHook extends BaseController
      */
     public static function setReplyKeyboard($chatId, $language = 'en'): void
     {
+        if (app(TelegramChannelService::class)->sendSystemPrivateMenu($chatId, $language ?? 'en')) {
+            return;
+        }
         $replyInfo = KeyboardService::findOne(['button' => '开始使用', 'language' => $language]);
         if (empty($replyInfo)) {
             $replyInfo = KeyboardService::findOne(['button' => '开始使用', 'language' => 'en']);

+ 120 - 0
app/Services/TelegramChannelMessageService.php

@@ -0,0 +1,120 @@
+<?php
+
+namespace App\Services;
+
+use Carbon\CarbonImmutable;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\ValidationException;
+
+class TelegramChannelMessageService
+{
+    private const TABLE = 'telegram_channel_messages';
+
+    public function __construct(private TelegramChannelService $channels) {}
+
+    public function list(int $page): array
+    {
+        $items = DB::table(self::TABLE)->orderByDesc('id')->paginate(20, ['*'], 'page', $page);
+        return ['list' => array_map($this->present(...), $items->items()), 'total' => $items->total()];
+    }
+
+    public function create(array $input): array
+    {
+        $fields = Validator::make($input, [
+            'channel_id' => 'required|integer|min:1',
+            'scheduled_at' => 'nullable|date_format:Y-m-d\TH:i:sP',
+            'is_top' => 'sometimes|boolean',
+        ])->validate();
+        $message = TelegramChannelService::message($input);
+        $channel = $this->channels->detail((int) $fields['channel_id']);
+        if (!$channel['enabled']) {
+            throw ValidationException::withMessages(['channel_id' => '请先启用频道']);
+        }
+        $scheduled = isset($fields['scheduled_at']) ? CarbonImmutable::parse($fields['scheduled_at'])->utc() : null;
+        if ($scheduled !== null && $scheduled->lessThanOrEqualTo(now('UTC'))) {
+            throw ValidationException::withMessages(['scheduled_at' => '定时发送时间必须晚于当前时间']);
+        }
+
+        $id = (int) DB::table(self::TABLE)->insertGetId([
+            'channel_id' => $channel['id'],
+            'channel_name' => $channel['name'],
+            'target' => $channel['telegram_chat_id'] ?? $channel['chatId'],
+            'message' => json_encode($message, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
+            'is_top' => $fields['is_top'] ?? false,
+            'status' => 'scheduled',
+            'scheduled_at' => $scheduled ?? now('UTC'),
+            'created_at' => now('UTC'),
+            'updated_at' => now('UTC'),
+        ]);
+        if ($scheduled === null) {
+            $this->dispatch($id);
+        }
+        return $this->present(DB::table(self::TABLE)->find($id));
+    }
+
+    public function cancel(int $id): void
+    {
+        if (!DB::table(self::TABLE)->where('id', $id)->where('status', 'scheduled')->update([
+            'status' => 'cancelled',
+            'updated_at' => now('UTC'),
+        ])) {
+            throw ValidationException::withMessages(['id' => '只有待发送消息可以取消']);
+        }
+    }
+
+    public function dispatchDue(): void
+    {
+        DB::table(self::TABLE)->where('status', 'sending')
+            ->where('attempted_at', '<=', now('UTC')->subMinutes(10))->update([
+                'status' => 'unknown', 'error' => '发送中断,结果未知,请到频道核对', 'updated_at' => now('UTC'),
+            ]);
+
+        $ids = DB::table(self::TABLE)->where('status', 'scheduled')
+            ->where('scheduled_at', '<=', now('UTC'))->orderBy('scheduled_at')->orderBy('id')->limit(20)->pluck('id');
+        foreach ($ids as $id) {
+            $this->dispatch((int) $id);
+        }
+    }
+
+    private function dispatch(int $id): void
+    {
+        $claimed = DB::table(self::TABLE)->where('id', $id)->where('status', 'scheduled')
+            ->where('scheduled_at', '<=', now('UTC'))->update([
+                'status' => 'sending', 'attempted_at' => now('UTC'), 'updated_at' => now('UTC'),
+            ]);
+        if (!$claimed) {
+            return;
+        }
+        $row = DB::table(self::TABLE)->find($id);
+        try {
+            $result = $this->channels->publishMessage(
+                (int) $row->channel_id,
+                json_decode($row->message, true, 512, JSON_THROW_ON_ERROR),
+                (bool) $row->is_top,
+                $row->target,
+            );
+        } catch (ValidationException $e) {
+            $result = ['sent' => false, 'pinned' => null, 'message_id' => null, 'error' => $e->validator->errors()->first()];
+        }
+        DB::table(self::TABLE)->where('id', $id)->where('status', 'sending')->update([
+            'status' => match ($result['sent']) { true => 'sent', false => 'failed', null => 'unknown' },
+            'telegram_message_id' => $result['message_id'],
+            'pinned' => $result['pinned'],
+            'error' => $result['error'],
+            'updated_at' => now('UTC'),
+        ]);
+    }
+
+    private function present(object $row): array
+    {
+        $data = (array) $row;
+        $data['message'] = json_decode($row->message, true);
+        $data['is_top'] = (bool) $row->is_top;
+        $data['pinned'] = $row->pinned === null ? null : (bool) $row->pinned;
+        foreach (['scheduled_at', 'attempted_at', 'created_at', 'updated_at'] as $key) {
+            $data[$key] = $row->$key === null ? null : CarbonImmutable::parse($row->$key, 'UTC')->toIso8601String();
+        }
+        return $data;
+    }
+}

+ 153 - 4
app/Services/TelegramChannelService.php

@@ -6,6 +6,7 @@ use Illuminate\Database\Query\Builder;
 use Illuminate\Support\Facades\Crypt;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Validator;
 use Illuminate\Validation\ValidationException;
 use RuntimeException;
@@ -47,7 +48,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_id' => 'sometimes|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',
@@ -188,7 +189,21 @@ class TelegramChannelService
     public function publish(int $id, bool $pin): array
     {
         $row = $this->find($id);
-        $channel = $this->present($row, (int) $row->default_channel_id === $id, true);
+        return $this->publishContent($row, $this->storedMessage($row), $pin);
+    }
+
+    public function publishMessage(int $id, array $message, bool $pin, string $target): array
+    {
+        $row = $this->find($id);
+        if (($row->telegram_chat_id ?? $row->target) !== $target) {
+            throw ValidationException::withMessages(['channel_id' => '频道标识已变更,请重新创建发送记录']);
+        }
+        return $this->publishContent($row, $message, $pin);
+    }
+
+    private function publishContent(object $row, array $message, bool $pin): array
+    {
+        $channel = $this->present($row, false) + $message;
         if (!$channel['enabled']) {
             throw ValidationException::withMessages(['id' => '请先启用频道']);
         }
@@ -323,6 +338,136 @@ class TelegramChannelService
         }, 3);
     }
 
+    public function privateMenu(int $id, string $language): array
+    {
+        $menus = json_decode($this->findBot($id)->private_menus ?? '{}', true);
+        return $menus[$language] ?? ['reply' => '', 'buttons' => []];
+    }
+
+    public function savePrivateMenu(int $id, string $language, array $input): array
+    {
+        $menu = Validator::make($input, [
+            'reply' => 'required|string|max:4096',
+            'buttons' => 'present|array|list|max:20',
+            'buttons.*' => 'required|array|list|max:8',
+            'buttons.*.*' => 'required|string|max:128',
+        ])->validate();
+
+        return DB::transaction(function () use ($id, $language, $menu) {
+            $bot = DB::table('telegram_bots')->where('id', $id)->lockForUpdate()->first()
+                ?? throw ValidationException::withMessages(['id' => '机器人不存在']);
+            $menus = json_decode($bot->private_menus ?? '{}', true);
+            $menus[$language] = $menu;
+            DB::table('telegram_bots')->where('id', $id)->update([
+                'private_menus' => json_encode($menus, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
+                'updated_at' => now(),
+            ]);
+            return $menu;
+        }, 3);
+    }
+
+    public function registerPrivateWebhook(int $id): array
+    {
+        $bot = $this->findBot($id);
+        $token = $this->storedToken($bot);
+        if ($token === config('services.telegram.token')) {
+            return ['system_bot' => true];
+        }
+
+        $secret = $bot->webhook_secret;
+        if ($secret === null) {
+            DB::table('telegram_bots')->where('id', $id)->whereNull('webhook_secret')
+                ->update(['webhook_secret' => bin2hex(random_bytes(32))]);
+            $bot = $this->findBot($id);
+            $secret = $bot->webhook_secret;
+        }
+        $webhookUrl = rtrim((string) config('app.url'), '/') . '/api/telegramBot/' . $id . '/webhook';
+        try {
+            $telegram = $this->telegram($token);
+            $existingUrl = (string) $telegram->getWebhookInfo()->get('url');
+        } catch (\Exception) {
+            throw ValidationException::withMessages(['id' => '无法读取 Telegram 接入状态,请检查 Bot Token']);
+        }
+        if ($existingUrl !== '' && $existingUrl !== $webhookUrl) {
+            throw ValidationException::withMessages(['id' => '机器人已接入其他 Webhook,请确认停用原接入后重试']);
+        }
+        try {
+            $registered = $telegram->setWebhook([
+                'url' => $webhookUrl,
+                'secret_token' => $secret,
+                'allowed_updates' => ['message'],
+            ]);
+        } catch (\Exception) {
+            $registered = false;
+        }
+        if (!$registered) {
+            throw ValidationException::withMessages(['id' => '无法接入 Telegram,请检查 Bot Token 和站点 HTTPS 配置']);
+        }
+        return ['system_bot' => false];
+    }
+
+    public function handlePrivateWebhook(int $id, string $secret, array $update): void
+    {
+        $bot = DB::table('telegram_bots')->where('id', $id)->first();
+        abort_unless($bot?->webhook_secret && hash_equals($bot->webhook_secret, $secret), 404);
+        if (is_int($update['update_id'] ?? null)) {
+            rescue(fn () => Log::channel('telegram_webhook')->info('Telegram 收到更新', ['bot_id' => $id, 'update' => $update]));
+        }
+
+        $message = $update['message'] ?? [];
+        $chatId = $message['chat']['id'] ?? null;
+        if (($message['chat']['type'] ?? null) !== 'private' || !is_int($chatId)
+            || !is_string($message['text'] ?? null) || !preg_match('/^\/start(?:\s|$)/D', $message['text'])) {
+            return;
+        }
+
+        $language = strtolower(substr((string) ($message['from']['language_code'] ?? ''), 0, 2));
+        $menu = $this->configuredPrivateMenu($bot, $language);
+        if ($menu === null) {
+            return;
+        }
+        try {
+            $this->sendPrivateMenu($this->telegram($this->storedToken($bot)), $chatId, $menu);
+        } catch (\Exception) {
+            Log::warning('Telegram 私聊菜单发送失败', ['bot_id' => $id]);
+            abort(503);
+        }
+    }
+
+    public function sendSystemPrivateMenu($chatId, string $language): bool
+    {
+        $token = config('services.telegram.token');
+        if (!$token) {
+            return false;
+        }
+
+        foreach (DB::table('telegram_bots')->whereNotNull('private_menus')->cursor() as $bot) {
+            if ($token === $this->storedToken($bot) && ($menu = $this->configuredPrivateMenu($bot, $language)) !== null) {
+                $this->sendPrivateMenu($this->telegram(null), $chatId, $menu);
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private function configuredPrivateMenu(object $bot, string $language): ?array
+    {
+        $menus = json_decode($bot->private_menus ?? '{}', true);
+        return $menus[$language] ?? $menus['en'] ?? $menus['zh'] ?? ($menus['vi'] ?? null);
+    }
+
+    private function sendPrivateMenu(Api $telegram, $chatId, array $menu): void
+    {
+        $keyboard = $menu['buttons'] === []
+            ? ['remove_keyboard' => true]
+            : ['keyboard' => $menu['buttons'], 'resize_keyboard' => true];
+        $telegram->sendMessage([
+            'chat_id' => $chatId,
+            'text' => $menu['reply'],
+            'reply_markup' => json_encode($keyboard, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
+        ]);
+    }
+
     private function findBot(int $id): object
     {
         return DB::table('telegram_bots')->where('id', $id)->first()
@@ -364,10 +509,14 @@ class TelegramChannelService
 
     private function resolve(string $target, #[\SensitiveParameter] ?string $token): array
     {
+        if ($token === null && !config('services.telegram.token')) {
+            throw ValidationException::withMessages(['bot_id' => '系统机器人尚未配置,请选择已添加的机器人']);
+        }
         try {
             $chat = $this->telegram($token)->getChat(['chat_id' => $target]);
         } catch (\Exception) {
-            throw ValidationException::withMessages(['chatId' => '无法读取频道,请检查频道标识及机器人是否已加入']);
+            $bot = $token === null ? '系统机器人' : '所选机器人';
+            throw ValidationException::withMessages(['chatId' => "无法读取频道,请检查频道标识,并确认{$bot}已加入目标频道"]);
         }
         $id = (string) $chat->get('id');
         if ($chat->get('type') !== 'channel' || !preg_match('/^-[1-9]\d{1,19}$/D', $id)) {
@@ -413,7 +562,7 @@ class TelegramChannelService
         return preg_match('/^@?[a-z][a-z0-9_]{3,31}$/iD', $value) ? '@' . strtolower(ltrim($value, '@')) : null;
     }
 
-    private static function message(array $input): array
+    public static function message(array $input): array
     {
         $data = Validator::make($input, [
             'type' => 'required|in:text,image,video',

+ 22 - 0
database/migrations/2026_11_13_150400_add_private_menu_to_telegram_bots.php

@@ -0,0 +1,22 @@
+<?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_bots', function (Blueprint $table) {
+            $table->json('private_menus')->nullable();
+            $table->string('webhook_secret', 64)->nullable();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('telegram_bots', function (Blueprint $table) {
+            $table->dropColumn(['private_menus', 'webhook_secret']);
+        });
+    }
+};

+ 32 - 0
database/migrations/2026_11_13_150500_create_telegram_channel_messages.php

@@ -0,0 +1,32 @@
+<?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::create('telegram_channel_messages', function (Blueprint $table) {
+            $table->id();
+            $table->unsignedBigInteger('channel_id');
+            $table->string('channel_name', 255);
+            $table->string('target', 64);
+            $table->json('message');
+            $table->boolean('is_top')->default(false);
+            $table->string('status', 20);
+            $table->dateTime('scheduled_at');
+            $table->dateTime('attempted_at')->nullable();
+            $table->unsignedBigInteger('telegram_message_id')->nullable();
+            $table->boolean('pinned')->nullable();
+            $table->text('error')->nullable();
+            $table->timestamps();
+            $table->index(['status', 'scheduled_at', 'id']);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('telegram_channel_messages');
+    }
+};

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

@@ -1,85 +1,56 @@
 # Telegram 频道管理
 
-「频道管理」页面包含「频道消息」「机器人管理」「私聊底部菜单」三个标签。请求带 `Authorization: Bearer <管理员 token>`,POST 使用 JSON,文件上传使用表单。业务校验失败时展示 `msg`。发布接口在 `code=0` 时还需检查 `data.sent` 和 `data.pinned`。
+页面分为「频道管理」「频道消息」「机器人管理」。请求带 `Authorization: Bearer <管理员 token>`,POST 使用 JSON,文件上传使用表单。成功返回 `code=0`,业务校验失败时展示 `msg`。
 
-## 接口与权限
+## 频道管理
 
-| 方法与地址 | 请求参数 | 返回 `data` | 按钮权限 |
-| --- | --- | --- | --- |
-| `GET /admin/telegramChannel/list` | 无 | `{ "list": [...] }` | `admin/telegramChannel/list` |
-| `GET /admin/telegramChannel/detail?id=12` | `id` | 频道详情 | `admin/telegramChannel/list` |
-| `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` |
+| 方法与地址 | 参数和返回 |
+| --- | --- |
+| `GET /admin/telegramChannel/list` | 返回 `data.list` |
+| `GET /admin/telegramChannel/detail?id=12` | 返回频道详情 |
+| `POST /admin/telegramChannel/save` | 提交频道资料,返回详情 |
+| `POST /admin/telegramChannel/setDefault` | 提交 `{ "id": 12 }`,返回详情 |
+| `POST /admin/telegramChannel/delete` | 提交 `{ "id": 12 }`,返回 `[]` |
 
-列表返回 `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`。
+读取权限为 `admin/telegramChannel/list`,修改权限为 `admin/telegramChannel/save`。
 
-## 机器人管理
-
-| 方法与地址 | 请求参数 | 返回 `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 输入框使用密码类型,每次添加或更换时手动填写,接口不会回传。一个机器人可供多个频道选择。机器人仍被频道使用时,先调整频道绑定,再删除机器人。
+保存示例为 `{"chatId":"@example_channel","name":"活动频道","bot_id":8,"enabled":true}`。编辑时加 `id`。频道标识可以用公开用户名或 `"-100…"`,选定的机器人要能读取该频道。名称可留空,新增时使用 Telegram 频道名称,编辑时保留原名称。传 `bot_id: null` 使用系统机器人,编辑时省略会保留原绑定。详情中的 `id` 是后台频道 ID,`telegram_chat_id` 是 Telegram 数字 ID 字符串,`is_default` 表示房间通知目标。
 
-## 上传、保存与回读
+## 频道消息
 
-- 图片上传用 `POST /admin/upload/uploadFile`,表单字段为 `image`。视频上传用 `POST /admin/upload/uploadVideo`,表单字段为 `file`。把返回的 `data.path` 填入保存请求的 `image` 或 `video`。
-- `chatId` 必填,可用 `@频道用户名` 或 `"-100…"`。新增或变更目标时需要机器人能读取该频道。
-- 频道下拉项来自机器人列表。保存频道时提交所选机器人的 `bot_id`。传 `null` 使用系统机器人,编辑时省略则保留现有绑定。
-- `name` 是后台显示的频道名称,可留空或省略。新增时默认使用 Telegram 频道名称,编辑时留空会保留原名称。新增默认启用。只改名称或启停时提交 `id`、当前 `chatId` 和要修改的字段,原消息会保留。
-- 消息 `type` 支持 `text`、`image`、`video`。纯文字需要 `text`,图片需要 `image`,视频需要 `video`。图片和视频的说明也用 `text`。`button` 是按行排列的二维数组,每个按钮填写 `text` 和 `url`。编辑消息时提交完整消息字段,清空按钮传 `[]`。
+| 方法与地址 | 参数和返回 |
+| --- | --- |
+| `GET /admin/telegramChannelMessage/list?page=1` | 返回 `data.list` 和 `data.total`,每页 20 条 |
+| `POST /admin/telegramChannelMessage/create` | 新增立即或定时发送记录,返回该记录 |
+| `POST /admin/telegramChannelMessage/cancel` | 提交 `{ "id": 42 }`,仅待发送记录可取消 |
 
-新增图片菜单示例。编辑同一频道时加上 `"id": 12`。
+读取权限为 `admin/telegramChannel/list`,新增和取消使用 `admin/telegramChannel/publish`。新增时从启用的频道中选择 `channel_id`。立即发送时省略 `scheduled_at`,定时时传带时区的 ISO 8601 时间。图片通过 `POST /admin/upload/uploadFile`(字段 `image`)上传,视频通过 `POST /admin/upload/uploadVideo`(字段 `file`)上传。用返回的 `data.path` 填入 `image` 或 `video`。
 
 ```json
 {
-  "chatId": "@example_channel",
-  "name": "活动频道",
-  "bot_id": 8,
-  "enabled": true,
+  "channel_id": 12,
   "type": "image",
   "text": "欢迎加入",
   "image": "https://example.com/storage/images/menu.jpg",
-  "button": [
-    [
-      {"text": "进入网站", "url": "https://example.com"}
-    ]
-  ]
+  "button": [[{"text":"进入网站","url":"https://example.com"}]],
+  "is_top": false,
+  "scheduled_at": "2026-11-14T21:00:00+08:00"
 }
 ```
 
-保存成功后用返回的频道详情更新表单,再刷新列表。可先添加机器人,再创建频道并选择该机器人。详情的 `type` 为 `null` 时提示先保存内容再发布。只有启用的频道可以设为默认。停用或删除默认频道会清除默认状态,房间通知暂停,直到重新设定。删除频道配置后,Telegram 上已发布的帖文仍可查看。
-
-## 更新机器人私聊底部菜单
-
-页面操作可标为「保存私聊菜单」和「发布频道消息」。只更新私聊底部菜单时,使用现有「关键字回复 → 开始使用」配置。
-
-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。
+`type` 支持 `text`、`image`、`video`,对应内容必填。图片和视频的说明也填 `text`,`button` 是帖子下的链接按钮。列表记录包含 `id`、`channel_id`、`channel_name`、`message`、`scheduled_at`、`status`、`telegram_message_id`、`pinned` 和 `error`。状态有 `scheduled`(待发送)、`sending`、`sent`、`failed`、`unknown` 和 `cancelled`。发送接口返回 `code=0` 时仍需读状态。结果未知或置顶失败时先在频道核对,避免重复发送。列表记录通过该消息页创建的发送任务。
 
-## 发布频道消息与置顶
-
-保存只更新后台内容。点击发布时提交 `id`,`isTop` 省略时默认为 `false`。请求期间禁用发布按钮。发布和置顶使用该频道绑定的机器人。机器人需要有频道发帖权限,置顶还需要相应权限。
+## 机器人管理
 
-```json
-{
-  "code": 0,
-  "data": {
-    "sent": true,
-    "pinned": false,
-    "message_id": 37,
-    "error": "消息已发布,置顶未成功"
-  }
-}
-```
+| 方法与地址 | 参数和返回 |
+| --- | --- |
+| `GET /admin/telegramBot/list` | 返回 `data.list`,元素含 `id`、`bot_username` 和 `bot_name` |
+| `POST /admin/telegramBot/save` | 提交 `bot_token`,可选 `bot_username`,更换 Token 时带 `id` |
+| `POST /admin/telegramBot/delete` | 提交 `{ "id": 8 }` |
+| `GET /admin/telegramBot/privateMenu?id=8&language=zh` | 返回该机器人该语言的 `reply` 和 `buttons` |
+| `POST /admin/telegramBot/privateMenu` | 保存机器人私聊菜单 |
+| `POST /admin/telegramBot/privateWebhook` | 提交 `{ "id": 8 }`,首次接入 Telegram |
 
-- `sent=true` 表示已发帖,`message_id` 是帖子 ID。若 `pinned=false`,提示检查置顶状态,避免重复发布。`pinned=null` 表示未请求置顶。
-- `sent=false` 表示 Telegram 拒绝发布,展示 `error`。
-- `sent=null` 表示发布结果未知,先检查频道再决定是否重试。发布请求超时也按结果未知处理。
+读取权限为 `admin/telegramChannel/list`,修改权限为 `admin/telegramChannel/save`。Token 输入框使用密码类型,接口不会回传。一个机器人可供多个频道选择,解除所有频道绑定后可删除。
 
-旧 `GET /admin/config/get?field=channel_message` 和 `getAll` 只显示当前默认频道。多频道页面使用列表和详情接口。
+在机器人行的「私聊底部菜单」保存,如 `{"id":8,"language":"zh","reply":"欢迎","buttons":[["💁联系客服"],["帮助","关于"] ]}`。按钮是按行排列的文字,`[]` 会在下次 `/start` 时收起键盘。用户发送 `/start` 后收到该机器人的回复和底部键盘。频道专用机器人首次使用时点「接入 Telegram」。系统机器人使用现有接入。已经绑定到其他 Webhook 的机器人会提示先处理原接入。

+ 15 - 0
routes/admin.php

@@ -351,10 +351,25 @@ Route::middleware(['admin.jwt'])->group(function () {
                 ->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::get('/privateMenu', [TelegramChannel::class, 'privateMenu'])
+                ->defaults('permission_uri', TelegramChannelService::VIEW)->defaults('require_active_button', true);
+            Route::post('/privateMenu', [TelegramChannel::class, 'savePrivateMenu'])
+                ->defaults('permission_uri', TelegramChannelService::MANAGE)->defaults('require_active_button', true);
+            Route::post('/privateWebhook', [TelegramChannel::class, 'registerPrivateWebhook'])
+                ->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('/telegramChannelMessage')->group(function () {
+            Route::get('/list', [TelegramChannel::class, 'messages'])
+                ->defaults('permission_uri', TelegramChannelService::VIEW)->defaults('require_active_button', true);
+            Route::post('/create', [TelegramChannel::class, 'createMessage'])
+                ->defaults('permission_uri', TelegramChannelService::PUBLISH)->defaults('require_active_button', true);
+            Route::post('/cancel', [TelegramChannel::class, 'cancelMessage'])
+                ->defaults('permission_uri', TelegramChannelService::PUBLISH)->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'])

+ 2 - 0
routes/api.php

@@ -11,8 +11,10 @@ use App\Http\Controllers\api\Pay;
 use App\Http\Controllers\api\NewPc;
 use App\Http\Controllers\api\PcIssue;
 use App\Http\Controllers\api\Wallet;
+use App\Http\Controllers\api\TelegramBotWebhook;
 
 Route::post("/onMessage", [TelegramWebHook::class, 'handle']);
+Route::post('/telegramBot/{id}/webhook', TelegramBotWebhook::class)->whereNumber('id');
 // 机器人运维 URL 需带 ?token=CRON_TOKEN
 Route::middleware('cron.token')->group(function () {
     Route::get("/setWebHook", [Home::class, 'setWebHook']);

+ 62 - 1
tests/Integration/TelegramChannelTest.php

@@ -7,6 +7,7 @@ use App\Http\Controllers\admin\TelegramChannel;
 use App\Http\Middleware\CheckButtonPermission;
 use App\Services\ChannelService;
 use App\Services\TelegramChannelService;
+use App\Services\TelegramChannelMessageService;
 use GuzzleHttp\Client;
 use GuzzleHttp\Handler\MockHandler;
 use GuzzleHttp\HandlerStack;
@@ -46,7 +47,8 @@ class TelegramChannelTest extends TestCase
         Facade::clearResolvedInstances();
         $this->app = new Application(dirname(__DIR__, 2));
         $this->app->instance('config', new Repository([
-            'app' => ['locale' => 'zh'],
+            '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',
             ]]],
@@ -103,6 +105,8 @@ 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();
+        (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
@@ -392,6 +396,63 @@ class TelegramChannelTest extends TestCase
         $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();