orderByDesc('id')->paginate(20, ['*'], 'page', $page); return ['list' => array_map($this->present(...), $items->items()), 'total' => $items->total()]; } public function create(array $input): array { $fields = Validator::make($input, [ 'channel_id' => 'required|integer|min:1', 'scheduled_at' => 'nullable|date_format:Y-m-d\TH:i:sP', 'is_top' => 'sometimes|boolean', ])->validate(); $message = TelegramChannelService::message($input); $channel = $this->channels->detail((int) $fields['channel_id']); if (!$channel['enabled']) { throw ValidationException::withMessages(['channel_id' => '请先启用频道']); } $scheduled = isset($fields['scheduled_at']) ? CarbonImmutable::parse($fields['scheduled_at'])->utc() : null; if ($scheduled !== null && $scheduled->lessThanOrEqualTo(now('UTC'))) { throw ValidationException::withMessages(['scheduled_at' => '定时发送时间必须晚于当前时间']); } $id = (int) DB::table(self::TABLE)->insertGetId([ 'channel_id' => $channel['id'], 'channel_name' => $channel['name'], 'target' => $channel['telegram_chat_id'] ?? $channel['chatId'], 'message' => json_encode($message, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'is_top' => $fields['is_top'] ?? false, 'status' => 'scheduled', 'scheduled_at' => $scheduled ?? now('UTC'), 'created_at' => now('UTC'), 'updated_at' => now('UTC'), ]); if ($scheduled === null) { $this->dispatch($id); } return $this->present(DB::table(self::TABLE)->find($id)); } public function cancel(int $id): void { if (!DB::table(self::TABLE)->where('id', $id)->where('status', 'scheduled')->update([ 'status' => 'cancelled', 'updated_at' => now('UTC'), ])) { throw ValidationException::withMessages(['id' => '只有待发送消息可以取消']); } } public function dispatchDue(): void { DB::table(self::TABLE)->where('status', 'sending') ->where('attempted_at', '<=', now('UTC')->subMinutes(10))->update([ 'status' => 'unknown', 'error' => '发送中断,结果未知,请到频道核对', 'updated_at' => now('UTC'), ]); $ids = DB::table(self::TABLE)->where('status', 'scheduled') ->where('scheduled_at', '<=', now('UTC'))->orderBy('scheduled_at')->orderBy('id')->limit(20)->pluck('id'); foreach ($ids as $id) { $this->dispatch((int) $id); } } private function dispatch(int $id): void { $claimed = DB::table(self::TABLE)->where('id', $id)->where('status', 'scheduled') ->where('scheduled_at', '<=', now('UTC'))->update([ 'status' => 'sending', 'attempted_at' => now('UTC'), 'updated_at' => now('UTC'), ]); if (!$claimed) { return; } $row = DB::table(self::TABLE)->find($id); try { $result = $this->channels->publishMessage( (int) $row->channel_id, json_decode($row->message, true, 512, JSON_THROW_ON_ERROR), (bool) $row->is_top, $row->target, ); } catch (ValidationException $e) { $result = ['sent' => false, 'pinned' => null, 'message_id' => null, 'error' => $e->validator->errors()->first()]; } DB::table(self::TABLE)->where('id', $id)->where('status', 'sending')->update([ 'status' => match ($result['sent']) { true => 'sent', false => 'failed', null => 'unknown' }, 'telegram_message_id' => $result['message_id'], 'pinned' => $result['pinned'], 'error' => $result['error'], 'updated_at' => now('UTC'), ]); } private function present(object $row): array { $data = (array) $row; $data['message'] = json_decode($row->message, true); $data['is_top'] = (bool) $row->is_top; $data['pinned'] = $row->pinned === null ? null : (bool) $row->pinned; foreach (['scheduled_at', 'attempted_at', 'created_at', 'updated_at'] as $key) { $data[$key] = $row->$key === null ? null : CarbonImmutable::parse($row->$key, 'UTC')->toIso8601String(); } return $data; } }