joinedChannels()->orderBy('channels.id') ->get() ->map(fn ($row) => $this->present($row, (int) $row->default_channel_id === (int) $row->id)) ->all(); } public function detail(int $id): array { $row = $this->find($id); return $this->present($row, (int) $row->default_channel_id === $id, true); } public function save(#[\SensitiveParameter] array $input, bool $legacy = false): array { if (is_string($input['name'] ?? null)) { $input['name'] = trim($input['name']); if ($input['name'] === '') { $input['name'] = null; } } $hasMessage = array_intersect(['type', 'text', 'image', 'video', 'button'], array_keys($input)) !== []; $fields = Validator::make($input, [ 'id' => $legacy ? 'prohibited' : 'sometimes|required|integer|min:1', 'name' => 'nullable|string|max:255', 'chatId' => 'required|string|max:64', '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', 'type' => [($legacy || $hasMessage) ? 'required' : 'sometimes', 'string', 'in:text,image,video'], ], [], ['name' => '频道名称'])->validate(); $target = self::normalizeTarget($fields['chatId']); if ($target === null) { throw ValidationException::withMessages(['chatId' => '请输入频道用户名或负数频道 ID']); } $message = isset($fields['type']) ? self::message($input) : null; $row = isset($fields['id']) && !$legacy ? $this->find((int) $fields['id']) : null; if ($legacy) { $row = $this->joinedChannels()->where(fn (Builder $query) => $query ->where('channels.target', $target)->orWhere('channels.telegram_chat_id', $target))->first(); } $newToken = $fields['bot_token'] ?? null; $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); $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($selectedBot)); 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_id !== $row->bot_id)) { throw ValidationException::withMessages(['id' => '频道已变更,请刷新后重试']); } $match = (clone $channels)->where(function (Builder $query) use ($identity) { $query->where('target', $identity['target']); if ($identity['telegram_chat_id'] !== null) { $query->orWhere('telegram_chat_id', $identity['telegram_chat_id']); } })->get(); if ($legacy && $row === null && $match->count() === 1) { $existing = $match->first(); if ($existing->bot_id !== null && $bot === [] && !array_key_exists('bot_id', $fields)) { throw ValidationException::withMessages(['chatId' => '请使用已保存的频道标识,或通过频道管理按 ID 编辑']); } } if ($match->contains(fn ($channel) => (int) $channel->id !== (int) $existing?->id)) { 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'], 'target' => $identity['target'], 'telegram_chat_id' => $identity['telegram_chat_id'], 'enabled' => $enabled, 'message' => $message === null ? ($existing?->message ?? 'null') : json_encode($message, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'bot_id' => $binding, 'updated_at' => now(), ]; if ($existing === null) { $firstLegacy = $legacy && !$channels->exists(); $id = (int) $channels->insertGetId($values + ['created_at' => now()]); if ($firstLegacy && $enabled) { $this->settings()->update(['default_channel_id' => $id]); } } else { $id = (int) $existing->id; $channels->where('id', $id)->update($values); if (!$enabled && (int) $default->default_channel_id === $id) { $this->settings()->update(['default_channel_id' => null]); } } return $this->detail($id); }, 3); } public function setDefault(int $id): array { return DB::transaction(function () use ($id) { $this->settings()->lockForUpdate()->first(); $row = $this->find($id); if (!$row->enabled) { throw ValidationException::withMessages(['id' => '请先启用频道']); } $this->settings()->update(['default_channel_id' => $id]); return $this->detail($id); }, 3); } public function delete(int $id): void { DB::transaction(function () use ($id) { $default = $this->settings()->lockForUpdate()->first(); $this->find($id); if ((int) $default->default_channel_id === $id) { $this->settings()->update(['default_channel_id' => null]); } DB::table('telegram_channels')->where('id', $id)->delete(); }, 3); } public function legacyMessage(): ?array { $row = $this->defaultChannel(); return $row === null ? null : ['chatId' => ltrim($row->target, '@')] + $this->storedMessage($row); } public function roomNoticeTarget(): ?string { $row = $this->defaultChannel(); return $row === null ? null : ($row->telegram_chat_id ?? $row->target); } public function publish(int $id, bool $pin): array { $row = $this->find($id); 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' => '请先启用频道']); } if ($channel['type'] === null) { throw ValidationException::withMessages(['type' => '请先保存要发布的消息']); } $params = ['chat_id' => $channel['telegram_chat_id'] ?? $channel['chatId']]; if ($channel['button'] !== []) { $params['reply_markup'] = json_encode(['inline_keyboard' => $channel['button']], JSON_THROW_ON_ERROR); } try { $telegram = $this->telegram($this->storedToken($row)); $response = $this->send($telegram, $channel, $params); } catch (TelegramResponseException $exception) { if (($exception->getHttpStatusCode() ?? 500) < 500) { return ['sent' => false, 'pinned' => null, 'message_id' => null, 'error' => 'Telegram 拒绝发布,请检查机器人发帖权限、频道和消息内容']; } return ['sent' => null, 'pinned' => null, 'message_id' => null, 'error' => '未能确认发布结果,请先检查频道']; } catch (\Exception) { return ['sent' => null, 'pinned' => null, 'message_id' => null, 'error' => '未能确认发布结果,请先检查频道']; } $messageId = $response['message_id'] ?? null; if ($messageId === null) { return ['sent' => null, 'pinned' => null, 'message_id' => null, 'error' => '未能确认发布结果,请先检查频道']; } $pinned = null; $error = null; if ($pin) { try { $pinned = $telegram->pinChatMessage(['chat_id' => $params['chat_id'], 'message_id' => $messageId]); if (!$pinned) { $error = '消息已发布,置顶未成功'; } } catch (\Exception) { $pinned = false; $error = '消息已发布,请在频道中检查置顶状态'; } } return ['sent' => true, 'pinned' => $pinned, 'message_id' => $messageId, 'error' => $error]; } private function send(Api $telegram, array $channel, array $params): array { if ($channel['type'] === 'text') { return $telegram->post('sendMessage', $params + ['text' => $channel['text']])->getResult() ?: []; } $image = $channel['type'] === 'image'; $media = $channel[$channel['type']]; $field = $image ? 'photo' : 'video'; $params['caption'] = $channel['text']; // Only read files produced by the admin upload endpoints on the configured public disk. $disk = Storage::disk('public'); $prefix = rtrim($disk->url(''), '/') . '/'; $path = str_starts_with($media, $prefix) ? substr($media, strlen($prefix)) : ''; if (preg_match('~\A(?:images|videos)/[a-f0-9]{32}\.(?:jpg|jpeg|png|webp|mp4|mov|avi|webm)\z~i', $path) && $disk->exists($path)) { $stream = $disk->readStream($path); if (!is_resource($stream)) { throw new RuntimeException('Unable to read uploaded media'); } try { $params[$field] = InputFile::create($stream, basename($path)); $response = $image ? $telegram->sendPhoto($params) : $telegram->sendVideo($params); return ['message_id' => $response->get('message_id')]; } finally { if (is_resource($stream)) { fclose($stream); } } } // Telegram fetches external media directly. return $telegram->post($image ? 'sendPhoto' : 'sendVideo', $params + [$field => $media])->getResult() ?: []; } public function bots(): array { 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); } 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() ?? throw ValidationException::withMessages(['bot_id' => '机器人不存在']); } private function validateBot(?string $username, #[\SensitiveParameter] string $token): array { 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' => strtolower($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 { if ($token === null && !config('services.telegram.token')) { throw ValidationException::withMessages(['bot_id' => '系统机器人尚未配置,请选择已添加的机器人']); } try { $chat = $this->telegram($token)->getChat(['chat_id' => $target]); } catch (\Exception) { $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)) { throw ValidationException::withMessages(['chatId' => '请输入 Telegram 频道,群组和私聊无法使用']); } $username = $chat->get('username'); return [ 'target' => $username ? self::normalizeTarget($username) ?? $id : $id, 'telegram_chat_id' => $id, 'title' => mb_substr((string) ($chat->get('title') ?: $target), 0, 255), ]; } public static function legacyChannel(mixed $value): ?array { if (!is_array($value) || ($target = self::normalizeTarget($value['chatId'] ?? null)) === null) { return null; } $value['type'] ??= !empty($value['video']) ? 'video' : (!empty($value['image']) ? 'image' : 'text'); try { $message = self::message($value); } catch (ValidationException) { return null; } return [ 'name' => ltrim($target, '@'), 'target' => $target, 'telegram_chat_id' => str_starts_with($target, '-') ? $target : null, 'enabled' => true, 'message' => json_encode($message, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), ]; } private static function normalizeTarget(mixed $value): ?string { if (!is_string($value)) { return null; } $value = trim($value); if (preg_match('/^-[1-9]\d{1,19}$/D', $value)) { return $value; } return preg_match('/^@?[a-z][a-z0-9_]{3,31}$/iD', $value) ? '@' . strtolower(ltrim($value, '@')) : null; } public static function message(array $input): array { $data = Validator::make($input, [ 'type' => 'required|in:text,image,video', 'text' => 'nullable|required_if:type,text|string|max:' . (($input['type'] ?? null) === 'text' ? 4096 : 1024), 'image' => 'nullable|required_if:type,image|string|max:2048|url:http,https', 'video' => 'nullable|required_if:type,video|string|max:2048|url:http,https', 'button' => 'sometimes|array|list', 'button.*' => 'required|array|list', 'button.*.*' => 'required|array', 'button.*.*.text' => 'required|string', 'button.*.*.url' => 'required|string|url:http,https,tg', ])->validate(); foreach (['image', 'video'] as $field) { $url = $data[$field] ?? ''; $host = rtrim(strtolower(trim(parse_url($url, PHP_URL_HOST) ?? '', '[]')), '.'); if ($url !== '' && ( parse_url($url, PHP_URL_USER) !== null || $host === 'localhost' || str_ends_with($host, '.localhost') || (filter_var($host, FILTER_VALIDATE_IP) && !filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE)) || str_contains(strtolower(rawurldecode(parse_url($url, PHP_URL_PATH) ?? '')), '/_ignition/') )) { throw ValidationException::withMessages([$field => '媒体地址须可公开访问']); } } return [ 'type' => $data['type'], 'text' => $data['text'] ?? '', 'image' => $data['image'] ?? '', 'video' => $data['video'] ?? '', 'button' => array_map(fn ($row) => array_map( fn ($button) => ['text' => $button['text'], 'url' => $button['url']], $row, ), $data['button'] ?? []), ]; } private function settings(): Builder { return DB::table('telegram_channel_settings')->where('id', 1); } private function defaultChannel(): ?object { return $this->joinedChannels()->where('channels.enabled', true) ->whereColumn('channels.id', 'settings.default_channel_id')->first(); } private function find(int $id): object { return $this->joinedChannels()->where('channels.id', $id)->first() ?? throw ValidationException::withMessages(['id' => '频道不存在']); } private function joinedChannels(): Builder { return DB::table('telegram_channels as channels') ->crossJoin('telegram_channel_settings as settings') ->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); } private function present(object $row, bool $default, bool $withMessage = false): array { $data = [ 'id' => (int) $row->id, '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, 'enabled' => (bool) $row->enabled, 'is_default' => $default, ]; return $withMessage ? $data + $this->storedMessage($row) : $data; } private function storedMessage(object $row): array { return json_decode($row->message, true) ?? [ 'type' => null, 'text' => '', 'image' => '', 'video' => '', 'button' => [], ]; } }