TelegramChannelService.php 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712
  1. <?php
  2. namespace App\Services;
  3. use Illuminate\Database\Query\Builder;
  4. use Illuminate\Support\Facades\Crypt;
  5. use Illuminate\Support\Facades\DB;
  6. use Illuminate\Support\Facades\Storage;
  7. use Illuminate\Support\Facades\Log;
  8. use Illuminate\Support\Facades\Validator;
  9. use Illuminate\Validation\ValidationException;
  10. use RuntimeException;
  11. use Telegram\Bot\Api;
  12. use Telegram\Bot\Exceptions\TelegramResponseException;
  13. use Telegram\Bot\FileUpload\InputFile;
  14. class TelegramChannelService
  15. {
  16. public const LEGACY_FIELD = 'channel_message';
  17. public const VIEW = 'admin/telegramChannel/list';
  18. public const MANAGE = 'admin/telegramChannel/save';
  19. public const PUBLISH = 'admin/telegramChannel/publish';
  20. public function list(): array
  21. {
  22. return $this->joinedChannels()->orderBy('channels.id')
  23. ->get()
  24. ->map(fn ($row) => $this->present($row, (int) $row->default_channel_id === (int) $row->id))
  25. ->all();
  26. }
  27. public function detail(int $id): array
  28. {
  29. $row = $this->find($id);
  30. return $this->present($row, (int) $row->default_channel_id === $id, true);
  31. }
  32. public function save(#[\SensitiveParameter] array $input, bool $legacy = false): array
  33. {
  34. if (is_string($input['name'] ?? null)) {
  35. $input['name'] = trim($input['name']);
  36. if ($input['name'] === '') {
  37. $input['name'] = null;
  38. }
  39. }
  40. $hasMessage = array_intersect(['type', 'text', 'image', 'video', 'button'], array_keys($input)) !== [];
  41. $fields = Validator::make($input, [
  42. 'id' => $legacy ? 'prohibited' : 'sometimes|required|integer|min:1',
  43. 'name' => 'nullable|string|max:255',
  44. 'chatId' => 'required|string|max:64',
  45. 'bot_id' => 'sometimes|nullable|integer|min:1|prohibits:bot_token',
  46. 'bot_username' => ['nullable', 'string', 'max:64', 'regex:/^@?[a-z][a-z0-9_]*$/iD'],
  47. 'bot_token' => ['nullable', 'string', 'max:255', 'regex:/^[1-9][0-9]*:[a-z0-9_-]+$/iD'],
  48. 'enabled' => 'sometimes|required|boolean',
  49. 'type' => [($legacy || $hasMessage) ? 'required' : 'sometimes', 'string', 'in:text,image,video'],
  50. ], [], ['name' => '频道名称'])->validate();
  51. $target = self::normalizeTarget($fields['chatId']);
  52. if ($target === null) {
  53. throw ValidationException::withMessages(['chatId' => '请输入频道用户名或负数频道 ID']);
  54. }
  55. $message = isset($fields['type']) ? self::message($input) : null;
  56. $row = isset($fields['id']) && !$legacy ? $this->find((int) $fields['id']) : null;
  57. if ($legacy) {
  58. $row = $this->joinedChannels()->where(fn (Builder $query) => $query
  59. ->where('channels.target', $target)->orWhere('channels.telegram_chat_id', $target))->first();
  60. }
  61. $newToken = $fields['bot_token'] ?? null;
  62. $botId = array_key_exists('bot_id', $fields) ? $fields['bot_id'] : $row?->bot_id;
  63. $selectedBot = $botId === null ? null : $this->findBot((int) $botId);
  64. $bot = $newToken === null ? [] : $this->validateBot($fields['bot_username'] ?? null, $newToken);
  65. if ($newToken === null && isset($fields['bot_username'])
  66. && strcasecmp(ltrim($fields['bot_username'], '@'), $selectedBot->bot_username ?? '') !== 0) {
  67. throw ValidationException::withMessages(['bot_username' => '请从机器人管理中选择机器人']);
  68. }
  69. $targetUnchanged = $row !== null && in_array($target, [$row->target, $row->telegram_chat_id], true);
  70. $unbound = !$legacy && $botId === null && $newToken === null;
  71. $bindingUnchanged = (int) $botId === (int) $row?->bot_id && $newToken === null;
  72. if ($targetUnchanged && ($bindingUnchanged || $unbound)) {
  73. $identity = ['target' => $row->target, 'telegram_chat_id' => $row->telegram_chat_id, 'title' => $row->name];
  74. } elseif ($unbound) {
  75. $identity = ['target' => $target, 'telegram_chat_id' => str_starts_with($target, '-') ? $target : null, 'title' => ltrim($target, '@')];
  76. } else {
  77. $identity = $this->resolve($target, $newToken ?? $this->storedToken($selectedBot));
  78. }
  79. return DB::transaction(function () use ($fields, $identity, $message, $legacy, $row, $bot, $selectedBot) {
  80. $default = $this->settings()->lockForUpdate()->first();
  81. $channels = DB::table('telegram_channels');
  82. $existing = $row === null ? null : (clone $channels)->where('id', $row->id)->first();
  83. if ($row !== null && ($existing === null || $existing->target !== $row->target
  84. || $existing->telegram_chat_id !== $row->telegram_chat_id || $existing->bot_id !== $row->bot_id)) {
  85. throw ValidationException::withMessages(['id' => '频道已变更,请刷新后重试']);
  86. }
  87. $match = (clone $channels)->where(function (Builder $query) use ($identity) {
  88. $query->where('target', $identity['target']);
  89. if ($identity['telegram_chat_id'] !== null) {
  90. $query->orWhere('telegram_chat_id', $identity['telegram_chat_id']);
  91. }
  92. })->get();
  93. if ($legacy && $row === null && $match->count() === 1) {
  94. $existing = $match->first();
  95. if ($existing->bot_id !== null && $bot === [] && !array_key_exists('bot_id', $fields)) {
  96. throw ValidationException::withMessages(['chatId' => '请使用已保存的频道标识,或通过频道管理按 ID 编辑']);
  97. }
  98. }
  99. if ($match->contains(fn ($channel) => (int) $channel->id !== (int) $existing?->id)) {
  100. throw ValidationException::withMessages(['chatId' => '该频道已存在']);
  101. }
  102. if ($selectedBot !== null && $this->findBot((int) $selectedBot->id)->bot_token !== $selectedBot->bot_token) {
  103. throw ValidationException::withMessages(['bot_id' => '机器人配置已更新,请刷新后重试']);
  104. }
  105. $binding = $selectedBot?->id;
  106. if ($bot !== []) {
  107. $registered = DB::table('telegram_bots')->where('bot_username', $bot['bot_username'])->first();
  108. if ($registered !== null && $this->storedToken($registered) !== $this->storedToken((object) $bot)) {
  109. throw ValidationException::withMessages(['bot_token' => '请在机器人管理中更新已有机器人的 Token']);
  110. }
  111. $binding = $registered?->id ?? DB::table('telegram_bots')->insertGetId($bot + [
  112. 'created_at' => now(), 'updated_at' => now(),
  113. ]);
  114. }
  115. $enabled = (bool) ($fields['enabled'] ?? $existing?->enabled ?? true);
  116. $values = [
  117. 'name' => $fields['name'] ?? $existing?->name ?? $identity['title'],
  118. 'target' => $identity['target'],
  119. 'telegram_chat_id' => $identity['telegram_chat_id'],
  120. 'enabled' => $enabled,
  121. 'message' => $message === null
  122. ? ($existing?->message ?? 'null')
  123. : json_encode($message, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
  124. 'bot_id' => $binding,
  125. 'updated_at' => now(),
  126. ];
  127. if ($existing === null) {
  128. $firstLegacy = $legacy && !$channels->exists();
  129. $id = (int) $channels->insertGetId($values + ['created_at' => now()]);
  130. if ($firstLegacy && $enabled) {
  131. $this->settings()->update(['default_channel_id' => $id]);
  132. }
  133. } else {
  134. $id = (int) $existing->id;
  135. $channels->where('id', $id)->update($values);
  136. if (!$enabled && (int) $default->default_channel_id === $id) {
  137. $this->settings()->update(['default_channel_id' => null]);
  138. }
  139. }
  140. return $this->detail($id);
  141. }, 3);
  142. }
  143. public function delete(int $id): void
  144. {
  145. DB::transaction(function () use ($id) {
  146. $default = $this->settings()->lockForUpdate()->first();
  147. $this->find($id);
  148. if ((int) $default->default_channel_id === $id) {
  149. $this->settings()->update(['default_channel_id' => null]);
  150. }
  151. DB::table('telegram_channels')->where('id', $id)->delete();
  152. }, 3);
  153. }
  154. public function legacyMessage(): ?array
  155. {
  156. $row = $this->defaultChannel();
  157. return $row === null ? null : ['chatId' => ltrim($row->target, '@')] + $this->storedMessage($row);
  158. }
  159. public function roomNoticeTarget(): ?string
  160. {
  161. $row = $this->defaultChannel();
  162. return $row === null ? null : ($row->telegram_chat_id ?? $row->target);
  163. }
  164. public function publish(int $id, bool $pin): array
  165. {
  166. $row = $this->find($id);
  167. return $this->publishContent($row, $this->storedMessage($row), $pin);
  168. }
  169. public function publishMessage(int $id, array $message, bool $pin, string $target): array
  170. {
  171. $row = $this->find($id);
  172. if ($row->bot_id === null) {
  173. throw ValidationException::withMessages(['channel_id' => '请先为频道绑定机器人']);
  174. }
  175. if (($row->telegram_chat_id ?? $row->target) !== $target) {
  176. throw ValidationException::withMessages(['channel_id' => '频道标识已变更,请重新创建发送记录']);
  177. }
  178. return $this->publishContent($row, $message, $pin);
  179. }
  180. private function publishContent(object $row, array $message, bool $pin): array
  181. {
  182. $channel = $this->present($row, false) + $message;
  183. if (!$channel['enabled']) {
  184. throw ValidationException::withMessages(['id' => '请先启用频道']);
  185. }
  186. if ($channel['type'] === null) {
  187. throw ValidationException::withMessages(['type' => '请先保存要发布的消息']);
  188. }
  189. $params = ['chat_id' => $channel['telegram_chat_id'] ?? $channel['chatId']];
  190. if ($channel['button'] !== []) {
  191. $params['reply_markup'] = json_encode(['inline_keyboard' => $channel['button']], JSON_THROW_ON_ERROR);
  192. }
  193. try {
  194. $telegram = $this->telegram($this->storedToken($row));
  195. $response = $this->send($telegram, $channel, $params);
  196. } catch (TelegramResponseException $exception) {
  197. if (($exception->getHttpStatusCode() ?? 500) < 500) {
  198. return ['sent' => false, 'pinned' => null, 'message_id' => null, 'error' => 'Telegram 拒绝发布,请检查机器人发帖权限、频道和消息内容'];
  199. }
  200. return ['sent' => null, 'pinned' => null, 'message_id' => null, 'error' => '未能确认发布结果,请先检查频道'];
  201. } catch (\Exception) {
  202. return ['sent' => null, 'pinned' => null, 'message_id' => null, 'error' => '未能确认发布结果,请先检查频道'];
  203. }
  204. $messageId = $response['message_id'] ?? null;
  205. if (isset($response['body_error'])) {
  206. return ['sent' => null, 'pinned' => null, 'message_id' => $messageId, 'error' => $response['body_error']];
  207. }
  208. if ($messageId === null) {
  209. return ['sent' => null, 'pinned' => null, 'message_id' => null, 'error' => '未能确认发布结果,请先检查频道'];
  210. }
  211. $pinned = null;
  212. $error = null;
  213. if ($pin) {
  214. try {
  215. $pinned = $telegram->pinChatMessage(['chat_id' => $params['chat_id'], 'message_id' => $messageId]);
  216. if (!$pinned) {
  217. $error = '消息已发布,置顶未成功';
  218. }
  219. } catch (\Exception) {
  220. $pinned = false;
  221. $error = '消息已发布,请在频道中检查置顶状态';
  222. }
  223. }
  224. return ['sent' => true, 'pinned' => $pinned, 'message_id' => $messageId, 'error' => $error];
  225. }
  226. private function send(Api $telegram, array $channel, array $params): array
  227. {
  228. if ($channel['type'] === 'text') {
  229. return $telegram->post('sendMessage', $params + ['text' => $channel['text']])->getResult() ?: [];
  230. }
  231. $text = $channel['text'];
  232. if (mb_strlen($text) <= 1024) {
  233. return $this->sendMedia($telegram, $channel, $params + ['caption' => $text]);
  234. }
  235. $response = $this->sendMedia($telegram, $channel, ['chat_id' => $params['chat_id'], 'caption' => '']);
  236. if (isset($response['message_id'])) {
  237. try {
  238. $body = $telegram->post('sendMessage', $params + ['text' => $text])->getResult();
  239. if (isset($body['message_id'])) {
  240. return $response;
  241. }
  242. } catch (\Exception) {
  243. // The media was already sent. Do not retry the entire message.
  244. }
  245. $response['body_error'] = '媒体已发送,正文发送结果未确认,请在 Telegram 中核对';
  246. }
  247. return $response;
  248. }
  249. private function sendMedia(Api $telegram, array $channel, array $params): array
  250. {
  251. $image = $channel['type'] === 'image';
  252. $media = $channel[$channel['type']];
  253. $field = $image ? 'photo' : 'video';
  254. // Only read files produced by the admin upload endpoints on the configured public disk.
  255. $disk = Storage::disk('public');
  256. $prefix = rtrim($disk->url(''), '/') . '/';
  257. $path = str_starts_with($media, $prefix) ? substr($media, strlen($prefix)) : '';
  258. if (preg_match('~\A(?:images|videos)/[a-f0-9]{32}\.(?:jpg|jpeg|png|webp|mp4|mov|avi|webm)\z~i', $path) && $disk->exists($path)) {
  259. $stream = $disk->readStream($path);
  260. if (!is_resource($stream)) {
  261. throw new RuntimeException('Unable to read uploaded media');
  262. }
  263. try {
  264. $params[$field] = InputFile::create($stream, basename($path));
  265. $response = $image ? $telegram->sendPhoto($params) : $telegram->sendVideo($params);
  266. return ['message_id' => $response->get('message_id')];
  267. } finally {
  268. if (is_resource($stream)) {
  269. fclose($stream);
  270. }
  271. }
  272. }
  273. // Telegram fetches external media directly.
  274. return $telegram->post($image ? 'sendPhoto' : 'sendVideo', $params + [$field => $media])->getResult() ?: [];
  275. }
  276. public function bots(): array
  277. {
  278. return DB::table('telegram_bots')->orderBy('bot_username')
  279. ->get(['id', 'bot_username', 'bot_name'])->all();
  280. }
  281. public function saveBot(#[\SensitiveParameter] array $input): array
  282. {
  283. $fields = Validator::make($input, [
  284. 'id' => 'sometimes|required|integer|min:1',
  285. 'bot_username' => ['nullable', 'string', 'max:64', 'regex:/^@?[a-z][a-z0-9_]*$/iD'],
  286. 'bot_token' => ['required', 'string', 'max:255', 'regex:/^[1-9][0-9]*:[a-z0-9_-]+$/iD'],
  287. ])->validate();
  288. $id = isset($fields['id']) ? (int) $fields['id'] : null;
  289. $row = $id === null ? null : $this->findBot($id);
  290. $bot = $this->validateBot($fields['bot_username'] ?? $row?->bot_username, $fields['bot_token']);
  291. if ($row !== null && strcasecmp($row->bot_username, $bot['bot_username']) !== 0) {
  292. throw ValidationException::withMessages(['bot_username' => '请使用此机器人的 Token,更换机器人请新增后再绑定']);
  293. }
  294. return DB::transaction(function () use ($id, $row, $bot) {
  295. $this->settings()->lockForUpdate()->first();
  296. $bots = DB::table('telegram_bots');
  297. $duplicate = (clone $bots)->where('bot_username', $bot['bot_username'])->value('id');
  298. if ($duplicate !== null && (int) $duplicate !== $id) {
  299. throw ValidationException::withMessages(['bot_username' => '该机器人已存在']);
  300. }
  301. $values = $bot + ['updated_at' => now()];
  302. if ($id === null) {
  303. $id = (int) $bots->insertGetId($values + ['created_at' => now()]);
  304. } else {
  305. if ($this->findBot($id)->bot_token !== $row->bot_token) {
  306. throw ValidationException::withMessages(['id' => '机器人已更新,请刷新后重试']);
  307. }
  308. $bots->where('id', $id)->update($values);
  309. }
  310. return ['id' => $id, 'bot_username' => $bot['bot_username'], 'bot_name' => $bot['bot_name']];
  311. }, 3);
  312. }
  313. public function deleteBot(int $id): void
  314. {
  315. DB::transaction(function () use ($id) {
  316. $this->settings()->lockForUpdate()->first();
  317. $this->findBot($id);
  318. if (DB::table('telegram_channels')->where('bot_id', $id)->exists()) {
  319. throw ValidationException::withMessages(['id' => '机器人仍绑定频道,请先在频道中解除绑定']);
  320. }
  321. DB::table('telegram_bots')->where('id', $id)->delete();
  322. }, 3);
  323. }
  324. public function privateMenu(int $id, string $language): array
  325. {
  326. return $this->privateMenus($id)[$language] ?? ['reply' => '', 'buttons' => [], 'type' => 'text', 'image' => '', 'video' => ''];
  327. }
  328. public function privateMenus(int $id): array
  329. {
  330. $menus = json_decode($this->findBot($id)->private_menus ?? '{}', true);
  331. foreach ($menus as &$menu) {
  332. $menu += ['type' => 'text', 'image' => '', 'video' => ''];
  333. }
  334. return $menus;
  335. }
  336. public function savePrivateMenu(int $id, string $language, array $input): array
  337. {
  338. return $this->savePrivateMenus($id, [$language => $input])[$language];
  339. }
  340. public function savePrivateMenus(int $id, array $input): array
  341. {
  342. $menus = [];
  343. foreach ($input as $language => $menu) {
  344. $menus[$language] = $this->validatePrivateMenu($menu);
  345. }
  346. DB::transaction(function () use ($id, $menus) {
  347. $bot = DB::table('telegram_bots')->where('id', $id)->lockForUpdate()->first()
  348. ?? throw ValidationException::withMessages(['id' => '机器人不存在']);
  349. $existing = json_decode($bot->private_menus ?? '{}', true);
  350. DB::table('telegram_bots')->where('id', $id)->update([
  351. 'private_menus' => json_encode(array_replace($existing, $menus), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
  352. 'updated_at' => now(),
  353. ]);
  354. }, 3);
  355. return $menus;
  356. }
  357. private function validatePrivateMenu(array $input): array
  358. {
  359. $menu = Validator::make($input, [
  360. 'buttons' => 'present|array|list|max:20',
  361. 'buttons.*' => 'required|array|list',
  362. 'buttons.*.*' => 'required|string|max:128',
  363. ])->validate();
  364. $content = self::message([
  365. 'type' => $input['type'] ?? 'text',
  366. 'text' => $input['reply'] ?? null,
  367. 'image' => $input['image'] ?? null,
  368. 'video' => $input['video'] ?? null,
  369. ]);
  370. $menu += [
  371. 'type' => $content['type'],
  372. 'reply' => $content['text'],
  373. 'image' => $content['type'] === 'image' ? $content['image'] : '',
  374. 'video' => $content['type'] === 'video' ? $content['video'] : '',
  375. ];
  376. return $menu;
  377. }
  378. public function registerPrivateWebhook(int $id): array
  379. {
  380. $bot = $this->findBot($id);
  381. $token = $this->storedToken($bot);
  382. if ($token === config('services.telegram.token')) {
  383. return ['system_bot' => true];
  384. }
  385. $secret = $bot->webhook_secret;
  386. if ($secret === null) {
  387. DB::table('telegram_bots')->where('id', $id)->whereNull('webhook_secret')
  388. ->update(['webhook_secret' => bin2hex(random_bytes(32))]);
  389. $bot = $this->findBot($id);
  390. $secret = $bot->webhook_secret;
  391. }
  392. $webhookUrl = rtrim((string) config('app.url'), '/') . '/api/telegramBot/' . $id . '/webhook';
  393. try {
  394. $telegram = $this->telegram($token);
  395. $existingUrl = (string) $telegram->getWebhookInfo()->get('url');
  396. } catch (\Exception) {
  397. throw ValidationException::withMessages(['id' => '无法读取 Telegram 接入状态,请检查 Bot Token']);
  398. }
  399. if ($existingUrl !== '' && $existingUrl !== $webhookUrl) {
  400. throw ValidationException::withMessages(['id' => '机器人已接入其他 Webhook,请确认停用原接入后重试']);
  401. }
  402. try {
  403. $registered = $telegram->setWebhook([
  404. 'url' => $webhookUrl,
  405. 'secret_token' => $secret,
  406. 'allowed_updates' => ['message'],
  407. ]);
  408. } catch (\Exception) {
  409. $registered = false;
  410. }
  411. if (!$registered) {
  412. throw ValidationException::withMessages(['id' => '无法接入 Telegram,请检查 Bot Token 和站点 HTTPS 配置']);
  413. }
  414. return ['system_bot' => false];
  415. }
  416. public function handlePrivateWebhook(int $id, string $secret, array $update): void
  417. {
  418. $bot = DB::table('telegram_bots')->where('id', $id)->first();
  419. abort_unless($bot?->webhook_secret && hash_equals($bot->webhook_secret, $secret), 404);
  420. if (is_int($update['update_id'] ?? null)) {
  421. rescue(fn () => Log::channel('telegram_webhook')->info('Telegram 收到更新', ['bot_id' => $id, 'update' => $update]));
  422. }
  423. $message = $update['message'] ?? [];
  424. $chatId = $message['chat']['id'] ?? null;
  425. if (($message['chat']['type'] ?? null) !== 'private' || !is_int($chatId)
  426. || !is_string($message['text'] ?? null) || !preg_match('/^\/start(?:\s|$)/D', $message['text'])) {
  427. return;
  428. }
  429. $language = strtolower(substr((string) ($message['from']['language_code'] ?? ''), 0, 2));
  430. $menu = $this->configuredPrivateMenu($bot, $language);
  431. if ($menu === null) {
  432. return;
  433. }
  434. try {
  435. $this->sendPrivateMenu($this->telegram($this->storedToken($bot)), $chatId, $menu);
  436. } catch (\Exception) {
  437. Log::warning('Telegram 私聊菜单发送失败', ['bot_id' => $id]);
  438. abort(503);
  439. }
  440. }
  441. public function sendSystemPrivateMenu($chatId, string $language): bool
  442. {
  443. $token = config('services.telegram.token');
  444. if (!$token) {
  445. return false;
  446. }
  447. foreach (DB::table('telegram_bots')->whereNotNull('private_menus')->cursor() as $bot) {
  448. if ($token === $this->storedToken($bot) && ($menu = $this->configuredPrivateMenu($bot, $language)) !== null) {
  449. $this->sendPrivateMenu($this->telegram(null), $chatId, $menu);
  450. return true;
  451. }
  452. }
  453. return false;
  454. }
  455. private function configuredPrivateMenu(object $bot, string $language): ?array
  456. {
  457. $menus = json_decode($bot->private_menus ?? '{}', true);
  458. return $menus[$language] ?? $menus['en'] ?? $menus['zh'] ?? ($menus['vi'] ?? null);
  459. }
  460. private function sendPrivateMenu(Api $telegram, $chatId, array $menu): void
  461. {
  462. $keyboard = $menu['buttons'] === []
  463. ? ['remove_keyboard' => true]
  464. : ['keyboard' => $menu['buttons'], 'resize_keyboard' => true];
  465. $response = $this->send($telegram, ['text' => $menu['reply']] + $menu + ['type' => 'text'], [
  466. 'chat_id' => $chatId,
  467. 'reply_markup' => json_encode($keyboard, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
  468. ]);
  469. if (isset($response['body_error'])) {
  470. Log::warning('Telegram 私聊菜单正文发送结果未确认', ['message_id' => $response['message_id']]);
  471. }
  472. }
  473. private function findBot(int $id): object
  474. {
  475. return DB::table('telegram_bots')->where('id', $id)->first()
  476. ?? throw ValidationException::withMessages(['bot_id' => '机器人不存在']);
  477. }
  478. private function validateBot(?string $username, #[\SensitiveParameter] string $token): array
  479. {
  480. try {
  481. $bot = $this->telegram($token)->getMe();
  482. } catch (\Exception) {
  483. throw ValidationException::withMessages(['bot_token' => '无法验证 Bot Token,请检查 Token 或 Telegram 连接']);
  484. }
  485. $actualUsername = $bot->get('username');
  486. if (!$bot->get('is_bot') || !is_string($actualUsername) || $actualUsername === '') {
  487. throw ValidationException::withMessages(['bot_token' => 'Bot Token 未返回有效的机器人账号']);
  488. }
  489. if ($username !== null && strcasecmp(ltrim($username, '@'), $actualUsername) !== 0) {
  490. throw ValidationException::withMessages(['bot_username' => '机器人用户名和 Bot Token 不匹配']);
  491. }
  492. return [
  493. 'bot_username' => strtolower($actualUsername),
  494. 'bot_name' => mb_substr((string) $bot->get('first_name'), 0, 255),
  495. 'bot_token' => Crypt::encryptString($token),
  496. ];
  497. }
  498. private function storedToken(?object $row): ?string
  499. {
  500. return $row?->bot_token === null ? null : Crypt::decryptString($row->bot_token);
  501. }
  502. protected function telegram(#[\SensitiveParameter] ?string $token): Api
  503. {
  504. return $token === null ? app(Api::class) : new Api($token);
  505. }
  506. private function resolve(string $target, #[\SensitiveParameter] ?string $token): array
  507. {
  508. if ($token === null && !config('services.telegram.token')) {
  509. throw ValidationException::withMessages(['bot_id' => '系统机器人尚未配置,请选择已添加的机器人']);
  510. }
  511. try {
  512. $chat = $this->telegram($token)->getChat(['chat_id' => $target]);
  513. } catch (\Exception) {
  514. $bot = $token === null ? '系统机器人' : '所选机器人';
  515. throw ValidationException::withMessages(['chatId' => "无法读取频道,请检查频道标识,并确认{$bot}已加入目标频道"]);
  516. }
  517. $id = (string) $chat->get('id');
  518. if ($chat->get('type') !== 'channel' || !preg_match('/^-[1-9]\d{1,19}$/D', $id)) {
  519. throw ValidationException::withMessages(['chatId' => '请输入 Telegram 频道,群组和私聊无法使用']);
  520. }
  521. $username = $chat->get('username');
  522. return [
  523. 'target' => $username ? self::normalizeTarget($username) ?? $id : $id,
  524. 'telegram_chat_id' => $id,
  525. 'title' => mb_substr((string) ($chat->get('title') ?: $target), 0, 255),
  526. ];
  527. }
  528. public static function legacyChannel(mixed $value): ?array
  529. {
  530. if (!is_array($value) || ($target = self::normalizeTarget($value['chatId'] ?? null)) === null) {
  531. return null;
  532. }
  533. $value['type'] ??= !empty($value['video']) ? 'video' : (!empty($value['image']) ? 'image' : 'text');
  534. try {
  535. $message = self::message($value);
  536. } catch (ValidationException) {
  537. return null;
  538. }
  539. return [
  540. 'name' => ltrim($target, '@'),
  541. 'target' => $target,
  542. 'telegram_chat_id' => str_starts_with($target, '-') ? $target : null,
  543. 'enabled' => true,
  544. 'message' => json_encode($message, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
  545. ];
  546. }
  547. private static function normalizeTarget(mixed $value): ?string
  548. {
  549. if (!is_string($value)) {
  550. return null;
  551. }
  552. $value = trim($value);
  553. if (preg_match('/^-[1-9]\d{1,19}$/D', $value)) {
  554. return $value;
  555. }
  556. return preg_match('/^@?[a-z][a-z0-9_]{3,31}$/iD', $value) ? '@' . strtolower(ltrim($value, '@')) : null;
  557. }
  558. public static function message(array $input): array
  559. {
  560. $data = Validator::make($input, [
  561. 'type' => 'required|in:text,image,video',
  562. 'text' => 'nullable|required_if:type,text|string|max:4096',
  563. 'image' => 'nullable|required_if:type,image|string|max:2048|url:http,https',
  564. 'video' => 'nullable|required_if:type,video|string|max:2048|url:http,https',
  565. 'button' => 'sometimes|array|list',
  566. 'button.*' => 'required|array|list',
  567. 'button.*.*' => 'required|array',
  568. 'button.*.*.text' => 'required|string',
  569. 'button.*.*.url' => 'required|string|url:http,https,tg',
  570. ])->validate();
  571. foreach (['image', 'video'] as $field) {
  572. $url = $data[$field] ?? '';
  573. $host = rtrim(strtolower(trim(parse_url($url, PHP_URL_HOST) ?? '', '[]')), '.');
  574. if ($url !== '' && (
  575. parse_url($url, PHP_URL_USER) !== null
  576. || $host === 'localhost'
  577. || str_ends_with($host, '.localhost')
  578. || (filter_var($host, FILTER_VALIDATE_IP) && !filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE))
  579. || str_contains(strtolower(rawurldecode(parse_url($url, PHP_URL_PATH) ?? '')), '/_ignition/')
  580. )) {
  581. throw ValidationException::withMessages([$field => '媒体地址须可公开访问']);
  582. }
  583. }
  584. return [
  585. 'type' => $data['type'],
  586. 'text' => $data['text'] ?? '',
  587. 'image' => $data['image'] ?? '',
  588. 'video' => $data['video'] ?? '',
  589. 'button' => array_map(fn ($row) => array_map(
  590. fn ($button) => ['text' => $button['text'], 'url' => $button['url']],
  591. $row,
  592. ), $data['button'] ?? []),
  593. ];
  594. }
  595. private function settings(): Builder
  596. {
  597. return DB::table('telegram_channel_settings')->where('id', 1);
  598. }
  599. private function defaultChannel(): ?object
  600. {
  601. return $this->joinedChannels()->where('channels.enabled', true)
  602. ->whereColumn('channels.id', 'settings.default_channel_id')->first();
  603. }
  604. private function find(int $id): object
  605. {
  606. return $this->joinedChannels()->where('channels.id', $id)->first()
  607. ?? throw ValidationException::withMessages(['id' => '频道不存在']);
  608. }
  609. private function joinedChannels(): Builder
  610. {
  611. return DB::table('telegram_channels as channels')
  612. ->crossJoin('telegram_channel_settings as settings')
  613. ->leftJoin('telegram_bots as bots', 'bots.id', '=', 'channels.bot_id')
  614. ->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')
  615. ->where('settings.id', 1);
  616. }
  617. private function present(object $row, bool $default, bool $withMessage = false): array
  618. {
  619. $data = [
  620. 'id' => (int) $row->id,
  621. 'name' => $row->name,
  622. 'chatId' => $row->target,
  623. 'telegram_chat_id' => $row->telegram_chat_id,
  624. 'bot_id' => $row->bot_id === null ? null : (int) $row->bot_id,
  625. 'bot_username' => $row->bot_username,
  626. 'bot_name' => $row->bot_name,
  627. 'has_bot_token' => $row->bot_token !== null,
  628. 'enabled' => (bool) $row->enabled,
  629. 'is_default' => $default,
  630. ];
  631. return $withMessage ? $data + $this->storedMessage($row) : $data;
  632. }
  633. private function storedMessage(object $row): array
  634. {
  635. return json_decode($row->message, true) ?? [
  636. 'type' => null,
  637. 'text' => '',
  638. 'image' => '',
  639. 'video' => '',
  640. 'button' => [],
  641. ];
  642. }
  643. }