TelegramChannelMessageService.php 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  1. <?php
  2. namespace App\Services;
  3. use Carbon\CarbonImmutable;
  4. use Illuminate\Support\Facades\DB;
  5. use Illuminate\Support\Facades\Validator;
  6. use Illuminate\Validation\ValidationException;
  7. class TelegramChannelMessageService
  8. {
  9. private const TABLE = 'telegram_channel_messages';
  10. public function __construct(private TelegramChannelService $channels) {}
  11. public function list(int $page): array
  12. {
  13. $items = DB::table(self::TABLE)->orderByDesc('id')->paginate(20, ['*'], 'page', $page);
  14. return ['list' => array_map($this->present(...), $items->items()), 'total' => $items->total()];
  15. }
  16. public function create(array $input): array
  17. {
  18. $fields = Validator::make($input, [
  19. 'channel_id' => 'required|integer|min:1',
  20. 'scheduled_at' => 'nullable|date_format:Y-m-d\TH:i:sP',
  21. 'is_top' => 'sometimes|boolean',
  22. ])->validate();
  23. $message = TelegramChannelService::message($input);
  24. $channel = $this->channels->detail((int) $fields['channel_id']);
  25. if (!$channel['enabled']) {
  26. throw ValidationException::withMessages(['channel_id' => '请先启用频道']);
  27. }
  28. $scheduled = isset($fields['scheduled_at']) ? CarbonImmutable::parse($fields['scheduled_at'])->utc() : null;
  29. if ($scheduled !== null && $scheduled->lessThanOrEqualTo(now('UTC'))) {
  30. throw ValidationException::withMessages(['scheduled_at' => '定时发送时间必须晚于当前时间']);
  31. }
  32. $id = (int) DB::table(self::TABLE)->insertGetId([
  33. 'channel_id' => $channel['id'],
  34. 'channel_name' => $channel['name'],
  35. 'target' => $channel['telegram_chat_id'] ?? $channel['chatId'],
  36. 'message' => json_encode($message, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
  37. 'is_top' => $fields['is_top'] ?? false,
  38. 'status' => 'scheduled',
  39. 'scheduled_at' => $scheduled ?? now('UTC'),
  40. 'created_at' => now('UTC'),
  41. 'updated_at' => now('UTC'),
  42. ]);
  43. if ($scheduled === null) {
  44. $this->dispatch($id);
  45. }
  46. return $this->present(DB::table(self::TABLE)->find($id));
  47. }
  48. public function cancel(int $id): void
  49. {
  50. if (!DB::table(self::TABLE)->where('id', $id)->where('status', 'scheduled')->update([
  51. 'status' => 'cancelled',
  52. 'updated_at' => now('UTC'),
  53. ])) {
  54. throw ValidationException::withMessages(['id' => '只有待发送消息可以取消']);
  55. }
  56. }
  57. public function dispatchDue(): void
  58. {
  59. DB::table(self::TABLE)->where('status', 'sending')
  60. ->where('attempted_at', '<=', now('UTC')->subMinutes(10))->update([
  61. 'status' => 'unknown', 'error' => '发送中断,结果未知,请到频道核对', 'updated_at' => now('UTC'),
  62. ]);
  63. $ids = DB::table(self::TABLE)->where('status', 'scheduled')
  64. ->where('scheduled_at', '<=', now('UTC'))->orderBy('scheduled_at')->orderBy('id')->limit(20)->pluck('id');
  65. foreach ($ids as $id) {
  66. $this->dispatch((int) $id);
  67. }
  68. }
  69. private function dispatch(int $id): void
  70. {
  71. $claimed = DB::table(self::TABLE)->where('id', $id)->where('status', 'scheduled')
  72. ->where('scheduled_at', '<=', now('UTC'))->update([
  73. 'status' => 'sending', 'attempted_at' => now('UTC'), 'updated_at' => now('UTC'),
  74. ]);
  75. if (!$claimed) {
  76. return;
  77. }
  78. $row = DB::table(self::TABLE)->find($id);
  79. try {
  80. $result = $this->channels->publishMessage(
  81. (int) $row->channel_id,
  82. json_decode($row->message, true, 512, JSON_THROW_ON_ERROR),
  83. (bool) $row->is_top,
  84. $row->target,
  85. );
  86. } catch (ValidationException $e) {
  87. $result = ['sent' => false, 'pinned' => null, 'message_id' => null, 'error' => $e->validator->errors()->first()];
  88. }
  89. DB::table(self::TABLE)->where('id', $id)->where('status', 'sending')->update([
  90. 'status' => match ($result['sent']) { true => 'sent', false => 'failed', null => 'unknown' },
  91. 'telegram_message_id' => $result['message_id'],
  92. 'pinned' => $result['pinned'],
  93. 'error' => $result['error'],
  94. 'updated_at' => now('UTC'),
  95. ]);
  96. }
  97. private function present(object $row): array
  98. {
  99. $data = (array) $row;
  100. $data['message'] = json_decode($row->message, true);
  101. $data['is_top'] = (bool) $row->is_top;
  102. $data['pinned'] = $row->pinned === null ? null : (bool) $row->pinned;
  103. foreach (['scheduled_at', 'attempted_at', 'created_at', 'updated_at'] as $key) {
  104. $data[$key] = $row->$key === null ? null : CarbonImmutable::parse($row->$key, 'UTC')->toIso8601String();
  105. }
  106. return $data;
  107. }
  108. }