TelegramChannelService.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. <?php
  2. namespace App\Services;
  3. use Illuminate\Database\Query\Builder;
  4. use Illuminate\Support\Facades\DB;
  5. use Illuminate\Support\Facades\Storage;
  6. use Illuminate\Support\Facades\Validator;
  7. use Illuminate\Validation\ValidationException;
  8. use RuntimeException;
  9. use Telegram\Bot\Api;
  10. use Telegram\Bot\Exceptions\TelegramResponseException;
  11. use Telegram\Bot\FileUpload\InputFile;
  12. class TelegramChannelService
  13. {
  14. public const LEGACY_FIELD = 'channel_message';
  15. public const VIEW = 'admin/telegramChannel/list';
  16. public const MANAGE = 'admin/telegramChannel/save';
  17. public const PUBLISH = 'admin/telegramChannel/publish';
  18. public function list(): array
  19. {
  20. return $this->joinedChannels()->orderBy('channels.id')
  21. ->get()
  22. ->map(fn ($row) => $this->present($row, (int) $row->default_channel_id === (int) $row->id))
  23. ->all();
  24. }
  25. public function detail(int $id): array
  26. {
  27. $row = $this->find($id);
  28. return $this->present($row, (int) $row->default_channel_id === $id, true);
  29. }
  30. public function save(array $input, bool $legacy = false): array
  31. {
  32. $hasMessage = array_intersect(['type', 'text', 'image', 'video', 'button'], array_keys($input)) !== [];
  33. $fields = Validator::make($input, [
  34. 'id' => $legacy ? 'prohibited' : 'sometimes|required|integer|min:1',
  35. 'name' => 'sometimes|required|string|max:255',
  36. 'chatId' => 'required|string|max:64',
  37. 'enabled' => 'sometimes|required|boolean',
  38. 'type' => [($legacy || $hasMessage) ? 'required' : 'sometimes', 'string', 'in:text,image,video'],
  39. ])->validate();
  40. $target = self::normalizeTarget($fields['chatId']);
  41. if ($target === null) {
  42. throw ValidationException::withMessages(['chatId' => '请输入频道用户名或负数频道 ID']);
  43. }
  44. $message = isset($fields['type']) ? self::message($input) : null;
  45. $row = !$legacy && isset($fields['id']) ? $this->find((int) $fields['id']) : null;
  46. $targetUnchanged = $row !== null && in_array($target, [$row->target, $row->telegram_chat_id], true);
  47. $identity = $targetUnchanged
  48. ? ['target' => $row->target, 'telegram_chat_id' => $row->telegram_chat_id, 'title' => $row->name]
  49. : $this->resolve($target);
  50. return DB::transaction(function () use ($fields, $identity, $message, $legacy, $row) {
  51. $default = $this->settings()->lockForUpdate()->first();
  52. $channels = DB::table('telegram_channels');
  53. $existing = $row === null ? null : (clone $channels)->where('id', $row->id)->first();
  54. if ($row !== null && ($existing === null || $existing->target !== $row->target || $existing->telegram_chat_id !== $row->telegram_chat_id)) {
  55. throw ValidationException::withMessages(['id' => '频道已变更,请刷新后重试']);
  56. }
  57. $match = (clone $channels)->where(function (Builder $query) use ($identity) {
  58. $query->where('target', $identity['target']);
  59. if ($identity['telegram_chat_id'] !== null) {
  60. $query->orWhere('telegram_chat_id', $identity['telegram_chat_id']);
  61. }
  62. })->get();
  63. if ($legacy && $match->count() === 1) {
  64. $existing = $match->first();
  65. }
  66. if ($match->contains(fn ($channel) => (int) $channel->id !== (int) $existing?->id)) {
  67. throw ValidationException::withMessages(['chatId' => '该频道已存在']);
  68. }
  69. $enabled = (bool) ($fields['enabled'] ?? $existing?->enabled ?? true);
  70. $values = [
  71. 'name' => $fields['name'] ?? $existing?->name ?? $identity['title'],
  72. 'target' => $identity['target'],
  73. 'telegram_chat_id' => $identity['telegram_chat_id'],
  74. 'enabled' => $enabled,
  75. 'message' => $message === null
  76. ? ($existing?->message ?? 'null')
  77. : json_encode($message, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
  78. 'updated_at' => now(),
  79. ];
  80. if ($existing === null) {
  81. $firstLegacy = $legacy && !$channels->exists();
  82. $id = (int) $channels->insertGetId($values + ['created_at' => now()]);
  83. if ($firstLegacy && $enabled) {
  84. $this->settings()->update(['default_channel_id' => $id]);
  85. }
  86. } else {
  87. $id = (int) $existing->id;
  88. $channels->where('id', $id)->update($values);
  89. if (!$enabled && (int) $default->default_channel_id === $id) {
  90. $this->settings()->update(['default_channel_id' => null]);
  91. }
  92. }
  93. return $this->detail($id);
  94. }, 3);
  95. }
  96. public function setDefault(int $id): array
  97. {
  98. return DB::transaction(function () use ($id) {
  99. $this->settings()->lockForUpdate()->first();
  100. $row = $this->find($id);
  101. if (!$row->enabled) {
  102. throw ValidationException::withMessages(['id' => '请先启用频道']);
  103. }
  104. $this->settings()->update(['default_channel_id' => $id]);
  105. return $this->detail($id);
  106. }, 3);
  107. }
  108. public function delete(int $id): void
  109. {
  110. DB::transaction(function () use ($id) {
  111. $default = $this->settings()->lockForUpdate()->first();
  112. $this->find($id);
  113. if ((int) $default->default_channel_id === $id) {
  114. $this->settings()->update(['default_channel_id' => null]);
  115. }
  116. DB::table('telegram_channels')->where('id', $id)->delete();
  117. }, 3);
  118. }
  119. public function legacyMessage(): ?array
  120. {
  121. $row = $this->defaultChannel();
  122. return $row === null ? null : ['chatId' => ltrim($row->target, '@')] + $this->storedMessage($row);
  123. }
  124. public function roomNoticeTarget(): ?string
  125. {
  126. $row = $this->defaultChannel();
  127. return $row === null ? null : ($row->telegram_chat_id ?? $row->target);
  128. }
  129. public function publish(array $channel, bool $pin): array
  130. {
  131. if (!$channel['enabled']) {
  132. throw ValidationException::withMessages(['id' => '请先启用频道']);
  133. }
  134. if ($channel['type'] === null) {
  135. throw ValidationException::withMessages(['type' => '请先保存要发布的消息']);
  136. }
  137. $params = ['chat_id' => $channel['telegram_chat_id'] ?? $channel['chatId']];
  138. if ($channel['button'] !== []) {
  139. $params['reply_markup'] = json_encode(['inline_keyboard' => $channel['button']], JSON_THROW_ON_ERROR);
  140. }
  141. try {
  142. $telegram = app(Api::class);
  143. $response = $this->send($telegram, $channel, $params);
  144. } catch (TelegramResponseException $exception) {
  145. if (($exception->getHttpStatusCode() ?? 500) < 500) {
  146. return ['sent' => false, 'pinned' => null, 'message_id' => null, 'error' => 'Telegram 拒绝发布,请检查机器人发帖权限、频道和消息内容'];
  147. }
  148. return ['sent' => null, 'pinned' => null, 'message_id' => null, 'error' => '未能确认发布结果,请先检查频道'];
  149. } catch (\Exception) {
  150. return ['sent' => null, 'pinned' => null, 'message_id' => null, 'error' => '未能确认发布结果,请先检查频道'];
  151. }
  152. $messageId = $response['message_id'] ?? null;
  153. if ($messageId === null) {
  154. return ['sent' => null, 'pinned' => null, 'message_id' => null, 'error' => '未能确认发布结果,请先检查频道'];
  155. }
  156. $pinned = null;
  157. $error = null;
  158. if ($pin) {
  159. try {
  160. $pinned = $telegram->pinChatMessage(['chat_id' => $params['chat_id'], 'message_id' => $messageId]);
  161. if (!$pinned) {
  162. $error = '消息已发布,置顶未成功';
  163. }
  164. } catch (\Exception) {
  165. $pinned = false;
  166. $error = '消息已发布,请在频道中检查置顶状态';
  167. }
  168. }
  169. return ['sent' => true, 'pinned' => $pinned, 'message_id' => $messageId, 'error' => $error];
  170. }
  171. private function send(Api $telegram, array $channel, array $params): array
  172. {
  173. if ($channel['type'] === 'text') {
  174. return $telegram->post('sendMessage', $params + ['text' => $channel['text']])->getResult() ?: [];
  175. }
  176. $image = $channel['type'] === 'image';
  177. $media = $channel[$channel['type']];
  178. $field = $image ? 'photo' : 'video';
  179. $params['caption'] = $channel['text'];
  180. // Only read files produced by the admin upload endpoints on the configured public disk.
  181. $disk = Storage::disk('public');
  182. $prefix = rtrim($disk->url(''), '/') . '/';
  183. $path = str_starts_with($media, $prefix) ? substr($media, strlen($prefix)) : '';
  184. if (preg_match('~\A(?:images|videos)/[a-f0-9]{32}\.(?:jpg|jpeg|png|webp|mp4|mov|avi|webm)\z~i', $path) && $disk->exists($path)) {
  185. $stream = $disk->readStream($path);
  186. if (!is_resource($stream)) {
  187. throw new RuntimeException('Unable to read uploaded media');
  188. }
  189. try {
  190. $params[$field] = InputFile::create($stream, basename($path));
  191. $response = $image ? $telegram->sendPhoto($params) : $telegram->sendVideo($params);
  192. return ['message_id' => $response->get('message_id')];
  193. } finally {
  194. if (is_resource($stream)) {
  195. fclose($stream);
  196. }
  197. }
  198. }
  199. // Telegram fetches external media directly.
  200. return $telegram->post($image ? 'sendPhoto' : 'sendVideo', $params + [$field => $media])->getResult() ?: [];
  201. }
  202. private function resolve(string $target): array
  203. {
  204. try {
  205. $chat = app(Api::class)->getChat(['chat_id' => $target]);
  206. } catch (\Exception) {
  207. throw ValidationException::withMessages(['chatId' => '无法读取频道,请检查频道标识及机器人是否已加入']);
  208. }
  209. $id = (string) $chat->get('id');
  210. if ($chat->get('type') !== 'channel' || !preg_match('/^-[1-9]\d{1,19}$/D', $id)) {
  211. throw ValidationException::withMessages(['chatId' => '请输入 Telegram 频道,群组和私聊无法使用']);
  212. }
  213. $username = $chat->get('username');
  214. return [
  215. 'target' => $username ? self::normalizeTarget($username) ?? $id : $id,
  216. 'telegram_chat_id' => $id,
  217. 'title' => mb_substr((string) ($chat->get('title') ?: $target), 0, 255),
  218. ];
  219. }
  220. public static function legacyChannel(mixed $value): ?array
  221. {
  222. if (!is_array($value) || ($target = self::normalizeTarget($value['chatId'] ?? null)) === null) {
  223. return null;
  224. }
  225. $value['type'] ??= !empty($value['video']) ? 'video' : (!empty($value['image']) ? 'image' : 'text');
  226. try {
  227. $message = self::message($value);
  228. } catch (ValidationException) {
  229. return null;
  230. }
  231. return [
  232. 'name' => ltrim($target, '@'),
  233. 'target' => $target,
  234. 'telegram_chat_id' => str_starts_with($target, '-') ? $target : null,
  235. 'enabled' => true,
  236. 'message' => json_encode($message, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
  237. ];
  238. }
  239. private static function normalizeTarget(mixed $value): ?string
  240. {
  241. if (!is_string($value)) {
  242. return null;
  243. }
  244. $value = trim($value);
  245. if (preg_match('/^-[1-9]\d{1,19}$/D', $value)) {
  246. return $value;
  247. }
  248. return preg_match('/^@?[a-z][a-z0-9_]{3,31}$/iD', $value) ? '@' . strtolower(ltrim($value, '@')) : null;
  249. }
  250. private static function message(array $input): array
  251. {
  252. $data = Validator::make($input, [
  253. 'type' => 'required|in:text,image,video',
  254. 'text' => 'nullable|required_if:type,text|string|max:' . (($input['type'] ?? null) === 'text' ? 4096 : 1024),
  255. 'image' => 'nullable|required_if:type,image|string|max:2048|url:http,https',
  256. 'video' => 'nullable|required_if:type,video|string|max:2048|url:http,https',
  257. 'button' => 'sometimes|array|list',
  258. 'button.*' => 'required|array|list',
  259. 'button.*.*' => 'required|array',
  260. 'button.*.*.text' => 'required|string',
  261. 'button.*.*.url' => 'required|string|url:http,https,tg',
  262. ])->validate();
  263. foreach (['image', 'video'] as $field) {
  264. $url = $data[$field] ?? '';
  265. $host = rtrim(strtolower(trim(parse_url($url, PHP_URL_HOST) ?? '', '[]')), '.');
  266. if ($url !== '' && (
  267. parse_url($url, PHP_URL_USER) !== null
  268. || $host === 'localhost'
  269. || str_ends_with($host, '.localhost')
  270. || (filter_var($host, FILTER_VALIDATE_IP) && !filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE))
  271. || str_contains(strtolower(rawurldecode(parse_url($url, PHP_URL_PATH) ?? '')), '/_ignition/')
  272. )) {
  273. throw ValidationException::withMessages([$field => '媒体地址须可公开访问']);
  274. }
  275. }
  276. return [
  277. 'type' => $data['type'],
  278. 'text' => $data['text'] ?? '',
  279. 'image' => $data['image'] ?? '',
  280. 'video' => $data['video'] ?? '',
  281. 'button' => array_map(fn ($row) => array_map(
  282. fn ($button) => ['text' => $button['text'], 'url' => $button['url']],
  283. $row,
  284. ), $data['button'] ?? []),
  285. ];
  286. }
  287. private function settings(): Builder
  288. {
  289. return DB::table('telegram_channel_settings')->where('id', 1);
  290. }
  291. private function defaultChannel(): ?object
  292. {
  293. return $this->joinedChannels()->where('channels.enabled', true)
  294. ->whereColumn('channels.id', 'settings.default_channel_id')->first();
  295. }
  296. private function find(int $id): object
  297. {
  298. return $this->joinedChannels()->where('channels.id', $id)->first()
  299. ?? throw ValidationException::withMessages(['id' => '频道不存在']);
  300. }
  301. private function joinedChannels(): Builder
  302. {
  303. return DB::table('telegram_channels as channels')
  304. ->crossJoin('telegram_channel_settings as settings')
  305. ->select('channels.*', 'settings.default_channel_id')
  306. ->where('settings.id', 1);
  307. }
  308. private function present(object $row, bool $default, bool $withMessage = false): array
  309. {
  310. $data = [
  311. 'id' => (int) $row->id,
  312. 'name' => $row->name,
  313. 'chatId' => $row->target,
  314. 'telegram_chat_id' => $row->telegram_chat_id,
  315. 'enabled' => (bool) $row->enabled,
  316. 'is_default' => $default,
  317. ];
  318. return $withMessage ? $data + $this->storedMessage($row) : $data;
  319. }
  320. private function storedMessage(object $row): array
  321. {
  322. return json_decode($row->message, true) ?? [
  323. 'type' => null,
  324. 'text' => '',
  325. 'image' => '',
  326. 'video' => '',
  327. 'button' => [],
  328. ];
  329. }
  330. }