TelegramChannelService.php 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  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\Validator;
  8. use Illuminate\Validation\ValidationException;
  9. use RuntimeException;
  10. use Telegram\Bot\Api;
  11. use Telegram\Bot\Exceptions\TelegramResponseException;
  12. use Telegram\Bot\FileUpload\InputFile;
  13. class TelegramChannelService
  14. {
  15. public const LEGACY_FIELD = 'channel_message';
  16. public const VIEW = 'admin/telegramChannel/list';
  17. public const MANAGE = 'admin/telegramChannel/save';
  18. public const PUBLISH = 'admin/telegramChannel/publish';
  19. public function list(): array
  20. {
  21. return $this->joinedChannels()->orderBy('channels.id')
  22. ->get()
  23. ->map(fn ($row) => $this->present($row, (int) $row->default_channel_id === (int) $row->id))
  24. ->all();
  25. }
  26. public function detail(int $id): array
  27. {
  28. $row = $this->find($id);
  29. return $this->present($row, (int) $row->default_channel_id === $id, true);
  30. }
  31. public function save(#[\SensitiveParameter] array $input, bool $legacy = false): array
  32. {
  33. if (is_string($input['name'] ?? null)) {
  34. $input['name'] = trim($input['name']);
  35. if ($input['name'] === '') {
  36. $input['name'] = null;
  37. }
  38. }
  39. $hasMessage = array_intersect(['type', 'text', 'image', 'video', 'button'], array_keys($input)) !== [];
  40. $fields = Validator::make($input, [
  41. 'id' => $legacy ? 'prohibited' : 'sometimes|required|integer|min:1',
  42. 'name' => 'nullable|string|max:255',
  43. 'chatId' => 'required|string|max:64',
  44. 'bot_id' => 'nullable|integer|min:1|prohibits:bot_token',
  45. 'bot_username' => ['nullable', 'string', 'max:64', 'regex:/^@?[a-z][a-z0-9_]*$/iD'],
  46. 'bot_token' => ['nullable', 'string', 'max:255', 'regex:/^[1-9][0-9]*:[a-z0-9_-]+$/iD'],
  47. 'enabled' => 'sometimes|required|boolean',
  48. 'type' => [($legacy || $hasMessage) ? 'required' : 'sometimes', 'string', 'in:text,image,video'],
  49. ], [], ['name' => '频道名称'])->validate();
  50. $target = self::normalizeTarget($fields['chatId']);
  51. if ($target === null) {
  52. throw ValidationException::withMessages(['chatId' => '请输入频道用户名或负数频道 ID']);
  53. }
  54. $message = isset($fields['type']) ? self::message($input) : null;
  55. $row = isset($fields['id']) && !$legacy ? $this->find((int) $fields['id']) : null;
  56. if ($legacy) {
  57. $row = $this->joinedChannels()->where(fn (Builder $query) => $query
  58. ->where('channels.target', $target)->orWhere('channels.telegram_chat_id', $target))->first();
  59. }
  60. $newToken = $fields['bot_token'] ?? null;
  61. $botId = array_key_exists('bot_id', $fields) ? $fields['bot_id'] : $row?->bot_id;
  62. $selectedBot = $botId === null ? null : $this->findBot((int) $botId);
  63. $bot = $newToken === null ? [] : $this->validateBot($fields['bot_username'] ?? null, $newToken);
  64. if ($newToken === null && isset($fields['bot_username'])
  65. && strcasecmp(ltrim($fields['bot_username'], '@'), $selectedBot->bot_username ?? '') !== 0) {
  66. throw ValidationException::withMessages(['bot_username' => '请从机器人管理中选择机器人']);
  67. }
  68. $targetUnchanged = $row !== null && in_array($target, [$row->target, $row->telegram_chat_id], true);
  69. $bindingUnchanged = (int) $botId === (int) $row?->bot_id && $newToken === null;
  70. $identity = $targetUnchanged && $bindingUnchanged
  71. ? ['target' => $row->target, 'telegram_chat_id' => $row->telegram_chat_id, 'title' => $row->name]
  72. : $this->resolve($target, $newToken ?? $this->storedToken($selectedBot));
  73. return DB::transaction(function () use ($fields, $identity, $message, $legacy, $row, $bot, $selectedBot) {
  74. $default = $this->settings()->lockForUpdate()->first();
  75. $channels = DB::table('telegram_channels');
  76. $existing = $row === null ? null : (clone $channels)->where('id', $row->id)->first();
  77. if ($row !== null && ($existing === null || $existing->target !== $row->target
  78. || $existing->telegram_chat_id !== $row->telegram_chat_id || $existing->bot_id !== $row->bot_id)) {
  79. throw ValidationException::withMessages(['id' => '频道已变更,请刷新后重试']);
  80. }
  81. $match = (clone $channels)->where(function (Builder $query) use ($identity) {
  82. $query->where('target', $identity['target']);
  83. if ($identity['telegram_chat_id'] !== null) {
  84. $query->orWhere('telegram_chat_id', $identity['telegram_chat_id']);
  85. }
  86. })->get();
  87. if ($legacy && $row === null && $match->count() === 1) {
  88. $existing = $match->first();
  89. if ($existing->bot_id !== null && $bot === [] && !array_key_exists('bot_id', $fields)) {
  90. throw ValidationException::withMessages(['chatId' => '请使用已保存的频道标识,或通过频道管理按 ID 编辑']);
  91. }
  92. }
  93. if ($match->contains(fn ($channel) => (int) $channel->id !== (int) $existing?->id)) {
  94. throw ValidationException::withMessages(['chatId' => '该频道已存在']);
  95. }
  96. if ($selectedBot !== null && $this->findBot((int) $selectedBot->id)->bot_token !== $selectedBot->bot_token) {
  97. throw ValidationException::withMessages(['bot_id' => '机器人配置已更新,请刷新后重试']);
  98. }
  99. $binding = $selectedBot?->id;
  100. if ($bot !== []) {
  101. $registered = DB::table('telegram_bots')->where('bot_username', $bot['bot_username'])->first();
  102. if ($registered !== null && $this->storedToken($registered) !== $this->storedToken((object) $bot)) {
  103. throw ValidationException::withMessages(['bot_token' => '请在机器人管理中更新已有机器人的 Token']);
  104. }
  105. $binding = $registered?->id ?? DB::table('telegram_bots')->insertGetId($bot + [
  106. 'created_at' => now(), 'updated_at' => now(),
  107. ]);
  108. }
  109. $enabled = (bool) ($fields['enabled'] ?? $existing?->enabled ?? true);
  110. $values = [
  111. 'name' => $fields['name'] ?? $existing?->name ?? $identity['title'],
  112. 'target' => $identity['target'],
  113. 'telegram_chat_id' => $identity['telegram_chat_id'],
  114. 'enabled' => $enabled,
  115. 'message' => $message === null
  116. ? ($existing?->message ?? 'null')
  117. : json_encode($message, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
  118. 'bot_id' => $binding,
  119. 'updated_at' => now(),
  120. ];
  121. if ($existing === null) {
  122. $firstLegacy = $legacy && !$channels->exists();
  123. $id = (int) $channels->insertGetId($values + ['created_at' => now()]);
  124. if ($firstLegacy && $enabled) {
  125. $this->settings()->update(['default_channel_id' => $id]);
  126. }
  127. } else {
  128. $id = (int) $existing->id;
  129. $channels->where('id', $id)->update($values);
  130. if (!$enabled && (int) $default->default_channel_id === $id) {
  131. $this->settings()->update(['default_channel_id' => null]);
  132. }
  133. }
  134. return $this->detail($id);
  135. }, 3);
  136. }
  137. public function setDefault(int $id): array
  138. {
  139. return DB::transaction(function () use ($id) {
  140. $this->settings()->lockForUpdate()->first();
  141. $row = $this->find($id);
  142. if (!$row->enabled) {
  143. throw ValidationException::withMessages(['id' => '请先启用频道']);
  144. }
  145. $this->settings()->update(['default_channel_id' => $id]);
  146. return $this->detail($id);
  147. }, 3);
  148. }
  149. public function delete(int $id): void
  150. {
  151. DB::transaction(function () use ($id) {
  152. $default = $this->settings()->lockForUpdate()->first();
  153. $this->find($id);
  154. if ((int) $default->default_channel_id === $id) {
  155. $this->settings()->update(['default_channel_id' => null]);
  156. }
  157. DB::table('telegram_channels')->where('id', $id)->delete();
  158. }, 3);
  159. }
  160. public function legacyMessage(): ?array
  161. {
  162. $row = $this->defaultChannel();
  163. return $row === null ? null : ['chatId' => ltrim($row->target, '@')] + $this->storedMessage($row);
  164. }
  165. public function roomNoticeTarget(): ?string
  166. {
  167. $row = $this->defaultChannel();
  168. return $row === null ? null : ($row->telegram_chat_id ?? $row->target);
  169. }
  170. public function publish(int $id, bool $pin): array
  171. {
  172. $row = $this->find($id);
  173. $channel = $this->present($row, (int) $row->default_channel_id === $id, true);
  174. if (!$channel['enabled']) {
  175. throw ValidationException::withMessages(['id' => '请先启用频道']);
  176. }
  177. if ($channel['type'] === null) {
  178. throw ValidationException::withMessages(['type' => '请先保存要发布的消息']);
  179. }
  180. $params = ['chat_id' => $channel['telegram_chat_id'] ?? $channel['chatId']];
  181. if ($channel['button'] !== []) {
  182. $params['reply_markup'] = json_encode(['inline_keyboard' => $channel['button']], JSON_THROW_ON_ERROR);
  183. }
  184. try {
  185. $telegram = $this->telegram($this->storedToken($row));
  186. $response = $this->send($telegram, $channel, $params);
  187. } catch (TelegramResponseException $exception) {
  188. if (($exception->getHttpStatusCode() ?? 500) < 500) {
  189. return ['sent' => false, 'pinned' => null, 'message_id' => null, 'error' => 'Telegram 拒绝发布,请检查机器人发帖权限、频道和消息内容'];
  190. }
  191. return ['sent' => null, 'pinned' => null, 'message_id' => null, 'error' => '未能确认发布结果,请先检查频道'];
  192. } catch (\Exception) {
  193. return ['sent' => null, 'pinned' => null, 'message_id' => null, 'error' => '未能确认发布结果,请先检查频道'];
  194. }
  195. $messageId = $response['message_id'] ?? null;
  196. if ($messageId === null) {
  197. return ['sent' => null, 'pinned' => null, 'message_id' => null, 'error' => '未能确认发布结果,请先检查频道'];
  198. }
  199. $pinned = null;
  200. $error = null;
  201. if ($pin) {
  202. try {
  203. $pinned = $telegram->pinChatMessage(['chat_id' => $params['chat_id'], 'message_id' => $messageId]);
  204. if (!$pinned) {
  205. $error = '消息已发布,置顶未成功';
  206. }
  207. } catch (\Exception) {
  208. $pinned = false;
  209. $error = '消息已发布,请在频道中检查置顶状态';
  210. }
  211. }
  212. return ['sent' => true, 'pinned' => $pinned, 'message_id' => $messageId, 'error' => $error];
  213. }
  214. private function send(Api $telegram, array $channel, array $params): array
  215. {
  216. if ($channel['type'] === 'text') {
  217. return $telegram->post('sendMessage', $params + ['text' => $channel['text']])->getResult() ?: [];
  218. }
  219. $image = $channel['type'] === 'image';
  220. $media = $channel[$channel['type']];
  221. $field = $image ? 'photo' : 'video';
  222. $params['caption'] = $channel['text'];
  223. // Only read files produced by the admin upload endpoints on the configured public disk.
  224. $disk = Storage::disk('public');
  225. $prefix = rtrim($disk->url(''), '/') . '/';
  226. $path = str_starts_with($media, $prefix) ? substr($media, strlen($prefix)) : '';
  227. if (preg_match('~\A(?:images|videos)/[a-f0-9]{32}\.(?:jpg|jpeg|png|webp|mp4|mov|avi|webm)\z~i', $path) && $disk->exists($path)) {
  228. $stream = $disk->readStream($path);
  229. if (!is_resource($stream)) {
  230. throw new RuntimeException('Unable to read uploaded media');
  231. }
  232. try {
  233. $params[$field] = InputFile::create($stream, basename($path));
  234. $response = $image ? $telegram->sendPhoto($params) : $telegram->sendVideo($params);
  235. return ['message_id' => $response->get('message_id')];
  236. } finally {
  237. if (is_resource($stream)) {
  238. fclose($stream);
  239. }
  240. }
  241. }
  242. // Telegram fetches external media directly.
  243. return $telegram->post($image ? 'sendPhoto' : 'sendVideo', $params + [$field => $media])->getResult() ?: [];
  244. }
  245. public function bots(): array
  246. {
  247. return DB::table('telegram_bots')->orderBy('bot_username')
  248. ->get(['id', 'bot_username', 'bot_name'])->all();
  249. }
  250. public function saveBot(#[\SensitiveParameter] array $input): array
  251. {
  252. $fields = Validator::make($input, [
  253. 'id' => 'sometimes|required|integer|min:1',
  254. 'bot_username' => ['nullable', 'string', 'max:64', 'regex:/^@?[a-z][a-z0-9_]*$/iD'],
  255. 'bot_token' => ['required', 'string', 'max:255', 'regex:/^[1-9][0-9]*:[a-z0-9_-]+$/iD'],
  256. ])->validate();
  257. $id = isset($fields['id']) ? (int) $fields['id'] : null;
  258. $row = $id === null ? null : $this->findBot($id);
  259. $bot = $this->validateBot($fields['bot_username'] ?? $row?->bot_username, $fields['bot_token']);
  260. if ($row !== null && strcasecmp($row->bot_username, $bot['bot_username']) !== 0) {
  261. throw ValidationException::withMessages(['bot_username' => '请使用此机器人的 Token,更换机器人请新增后再绑定']);
  262. }
  263. return DB::transaction(function () use ($id, $row, $bot) {
  264. $this->settings()->lockForUpdate()->first();
  265. $bots = DB::table('telegram_bots');
  266. $duplicate = (clone $bots)->where('bot_username', $bot['bot_username'])->value('id');
  267. if ($duplicate !== null && (int) $duplicate !== $id) {
  268. throw ValidationException::withMessages(['bot_username' => '该机器人已存在']);
  269. }
  270. $values = $bot + ['updated_at' => now()];
  271. if ($id === null) {
  272. $id = (int) $bots->insertGetId($values + ['created_at' => now()]);
  273. } else {
  274. if ($this->findBot($id)->bot_token !== $row->bot_token) {
  275. throw ValidationException::withMessages(['id' => '机器人已更新,请刷新后重试']);
  276. }
  277. $bots->where('id', $id)->update($values);
  278. }
  279. return ['id' => $id, 'bot_username' => $bot['bot_username'], 'bot_name' => $bot['bot_name']];
  280. }, 3);
  281. }
  282. public function deleteBot(int $id): void
  283. {
  284. DB::transaction(function () use ($id) {
  285. $this->settings()->lockForUpdate()->first();
  286. $this->findBot($id);
  287. if (DB::table('telegram_channels')->where('bot_id', $id)->exists()) {
  288. throw ValidationException::withMessages(['id' => '机器人仍绑定频道,请先在频道中解除绑定']);
  289. }
  290. DB::table('telegram_bots')->where('id', $id)->delete();
  291. }, 3);
  292. }
  293. private function findBot(int $id): object
  294. {
  295. return DB::table('telegram_bots')->where('id', $id)->first()
  296. ?? throw ValidationException::withMessages(['bot_id' => '机器人不存在']);
  297. }
  298. private function validateBot(?string $username, #[\SensitiveParameter] string $token): array
  299. {
  300. try {
  301. $bot = $this->telegram($token)->getMe();
  302. } catch (\Exception) {
  303. throw ValidationException::withMessages(['bot_token' => '无法验证 Bot Token,请检查 Token 或 Telegram 连接']);
  304. }
  305. $actualUsername = $bot->get('username');
  306. if (!$bot->get('is_bot') || !is_string($actualUsername) || $actualUsername === '') {
  307. throw ValidationException::withMessages(['bot_token' => 'Bot Token 未返回有效的机器人账号']);
  308. }
  309. if ($username !== null && strcasecmp(ltrim($username, '@'), $actualUsername) !== 0) {
  310. throw ValidationException::withMessages(['bot_username' => '机器人用户名和 Bot Token 不匹配']);
  311. }
  312. return [
  313. 'bot_username' => strtolower($actualUsername),
  314. 'bot_name' => mb_substr((string) $bot->get('first_name'), 0, 255),
  315. 'bot_token' => Crypt::encryptString($token),
  316. ];
  317. }
  318. private function storedToken(?object $row): ?string
  319. {
  320. return $row?->bot_token === null ? null : Crypt::decryptString($row->bot_token);
  321. }
  322. protected function telegram(#[\SensitiveParameter] ?string $token): Api
  323. {
  324. return $token === null ? app(Api::class) : new Api($token);
  325. }
  326. private function resolve(string $target, #[\SensitiveParameter] ?string $token): array
  327. {
  328. try {
  329. $chat = $this->telegram($token)->getChat(['chat_id' => $target]);
  330. } catch (\Exception) {
  331. throw ValidationException::withMessages(['chatId' => '无法读取频道,请检查频道标识及机器人是否已加入']);
  332. }
  333. $id = (string) $chat->get('id');
  334. if ($chat->get('type') !== 'channel' || !preg_match('/^-[1-9]\d{1,19}$/D', $id)) {
  335. throw ValidationException::withMessages(['chatId' => '请输入 Telegram 频道,群组和私聊无法使用']);
  336. }
  337. $username = $chat->get('username');
  338. return [
  339. 'target' => $username ? self::normalizeTarget($username) ?? $id : $id,
  340. 'telegram_chat_id' => $id,
  341. 'title' => mb_substr((string) ($chat->get('title') ?: $target), 0, 255),
  342. ];
  343. }
  344. public static function legacyChannel(mixed $value): ?array
  345. {
  346. if (!is_array($value) || ($target = self::normalizeTarget($value['chatId'] ?? null)) === null) {
  347. return null;
  348. }
  349. $value['type'] ??= !empty($value['video']) ? 'video' : (!empty($value['image']) ? 'image' : 'text');
  350. try {
  351. $message = self::message($value);
  352. } catch (ValidationException) {
  353. return null;
  354. }
  355. return [
  356. 'name' => ltrim($target, '@'),
  357. 'target' => $target,
  358. 'telegram_chat_id' => str_starts_with($target, '-') ? $target : null,
  359. 'enabled' => true,
  360. 'message' => json_encode($message, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
  361. ];
  362. }
  363. private static function normalizeTarget(mixed $value): ?string
  364. {
  365. if (!is_string($value)) {
  366. return null;
  367. }
  368. $value = trim($value);
  369. if (preg_match('/^-[1-9]\d{1,19}$/D', $value)) {
  370. return $value;
  371. }
  372. return preg_match('/^@?[a-z][a-z0-9_]{3,31}$/iD', $value) ? '@' . strtolower(ltrim($value, '@')) : null;
  373. }
  374. private static function message(array $input): array
  375. {
  376. $data = Validator::make($input, [
  377. 'type' => 'required|in:text,image,video',
  378. 'text' => 'nullable|required_if:type,text|string|max:' . (($input['type'] ?? null) === 'text' ? 4096 : 1024),
  379. 'image' => 'nullable|required_if:type,image|string|max:2048|url:http,https',
  380. 'video' => 'nullable|required_if:type,video|string|max:2048|url:http,https',
  381. 'button' => 'sometimes|array|list',
  382. 'button.*' => 'required|array|list',
  383. 'button.*.*' => 'required|array',
  384. 'button.*.*.text' => 'required|string',
  385. 'button.*.*.url' => 'required|string|url:http,https,tg',
  386. ])->validate();
  387. foreach (['image', 'video'] as $field) {
  388. $url = $data[$field] ?? '';
  389. $host = rtrim(strtolower(trim(parse_url($url, PHP_URL_HOST) ?? '', '[]')), '.');
  390. if ($url !== '' && (
  391. parse_url($url, PHP_URL_USER) !== null
  392. || $host === 'localhost'
  393. || str_ends_with($host, '.localhost')
  394. || (filter_var($host, FILTER_VALIDATE_IP) && !filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE))
  395. || str_contains(strtolower(rawurldecode(parse_url($url, PHP_URL_PATH) ?? '')), '/_ignition/')
  396. )) {
  397. throw ValidationException::withMessages([$field => '媒体地址须可公开访问']);
  398. }
  399. }
  400. return [
  401. 'type' => $data['type'],
  402. 'text' => $data['text'] ?? '',
  403. 'image' => $data['image'] ?? '',
  404. 'video' => $data['video'] ?? '',
  405. 'button' => array_map(fn ($row) => array_map(
  406. fn ($button) => ['text' => $button['text'], 'url' => $button['url']],
  407. $row,
  408. ), $data['button'] ?? []),
  409. ];
  410. }
  411. private function settings(): Builder
  412. {
  413. return DB::table('telegram_channel_settings')->where('id', 1);
  414. }
  415. private function defaultChannel(): ?object
  416. {
  417. return $this->joinedChannels()->where('channels.enabled', true)
  418. ->whereColumn('channels.id', 'settings.default_channel_id')->first();
  419. }
  420. private function find(int $id): object
  421. {
  422. return $this->joinedChannels()->where('channels.id', $id)->first()
  423. ?? throw ValidationException::withMessages(['id' => '频道不存在']);
  424. }
  425. private function joinedChannels(): Builder
  426. {
  427. return DB::table('telegram_channels as channels')
  428. ->crossJoin('telegram_channel_settings as settings')
  429. ->leftJoin('telegram_bots as bots', 'bots.id', '=', 'channels.bot_id')
  430. ->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')
  431. ->where('settings.id', 1);
  432. }
  433. private function present(object $row, bool $default, bool $withMessage = false): array
  434. {
  435. $data = [
  436. 'id' => (int) $row->id,
  437. 'name' => $row->name,
  438. 'chatId' => $row->target,
  439. 'telegram_chat_id' => $row->telegram_chat_id,
  440. 'bot_id' => $row->bot_id === null ? null : (int) $row->bot_id,
  441. 'bot_username' => $row->bot_username,
  442. 'bot_name' => $row->bot_name,
  443. 'has_bot_token' => $row->bot_token !== null,
  444. 'enabled' => (bool) $row->enabled,
  445. 'is_default' => $default,
  446. ];
  447. return $withMessage ? $data + $this->storedMessage($row) : $data;
  448. }
  449. private function storedMessage(object $row): array
  450. {
  451. return json_decode($row->message, true) ?? [
  452. 'type' => null,
  453. 'text' => '',
  454. 'image' => '',
  455. 'video' => '',
  456. 'button' => [],
  457. ];
  458. }
  459. }