| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312 |
- import Foundation
- protocol PostGenerationServiceProtocol: Sendable {
- func generatePost(from draft: PostDraft) async throws -> GeneratedPost
- }
- enum PostGenerationServiceFactory {
- static func make(hasPremiumAccess: Bool) -> any PostGenerationServiceProtocol {
- if hasPremiumAccess, let apiKey = AIConfiguration.apiKey {
- return OpenAIPostGenerationService(apiKey: apiKey)
- }
- return MockPostGenerationService()
- }
- }
- enum PostGenerationError: LocalizedError {
- case emptyTopic
- case emptySubreddit
- case invalidSubreddit
- case apiUnavailable(String)
- case invalidResponse
- var errorDescription: String? {
- switch self {
- case .emptyTopic:
- "Enter a topic or keywords for AI generation."
- case .emptySubreddit:
- "Enter a subreddit to tailor the post."
- case .invalidSubreddit:
- "Subreddit names must be 3–21 characters and use only letters, numbers, or underscores."
- case .apiUnavailable(let message):
- UserFacingError.sanitized(message) ?? UserFacingError.generic
- case .invalidResponse:
- UserFacingError.generic
- }
- }
- }
- // MARK: - OpenAI
- struct OpenAIPostGenerationService: PostGenerationServiceProtocol {
- let apiKey: String
- var model: String = AIRequestConfiguration.model
- func generatePost(from draft: PostDraft) async throws -> GeneratedPost {
- try PostDraftValidator.validateForGeneration(topic: draft.topic, subreddit: draft.subreddit)
- let subreddit = PostDraftValidator.normalizedSubreddit(draft.subreddit)
- let topic = draft.topic.trimmingCharacters(in: .whitespaces)
- let prompt = buildPrompt(draft: draft, subreddit: subreddit, topic: topic)
- var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
- request.httpMethod = "POST"
- request.timeoutInterval = AIRequestConfiguration.requestTimeout
- request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
- let body: [String: Any] = [
- "model": model,
- "temperature": 0.6,
- "max_tokens": AIRequestConfiguration.postGeneratorMaxTokens(for: draft.postType),
- "response_format": ["type": "json_object"],
- "messages": [
- [
- "role": "system",
- "content": """
- Write Reddit posts. JSON only: title, body, suggestedFlair (string or null), \
- pollOptions (2-6 strings for polls, else null).
- Keep body concise (under 250 words). Match tone and post type.
- """,
- ],
- ["role": "user", "content": prompt],
- ],
- ]
- request.httpBody = try JSONSerialization.data(withJSONObject: body)
- let data: Data
- let response: URLResponse
- do {
- (data, response) = try await NetworkClient.aiData(for: request)
- } catch let error as URLError {
- throw PostGenerationError.apiUnavailable(UserFacingError.networkMessage(for: error))
- }
- if let http = response as? HTTPURLResponse, !(200 ... 299).contains(http.statusCode) {
- throw PostGenerationError.apiUnavailable(
- OpenAIHTTPError.userMessage(statusCode: http.statusCode, data: data)
- )
- }
- return try parseResponse(data, postType: draft.postType)
- }
- private func buildPrompt(draft: PostDraft, subreddit: String, topic: String) -> String {
- var lines = [
- "Subreddit: r/\(subreddit)",
- "Post type: \(draft.postType.title)",
- "Topic: \(topic)",
- "Tone: \(draft.tone.title)",
- ]
- if draft.isNSFW { lines.append("Tag: NSFW") }
- if draft.isSpoiler { lines.append("Tag: Spoiler") }
- if draft.isOC { lines.append("Tag: Original Content") }
- if !draft.flair.isEmpty { lines.append("Preferred flair: \(draft.flair)") }
- switch draft.postType {
- case .text:
- lines.append("Write a concise text post with light markdown. Under 250 words.")
- case .image:
- lines.append("Write a short image caption. Body under 80 words.")
- case .link:
- lines.append("Write a brief link post description. Under 100 words.")
- case .video:
- lines.append("Write a short video post hook. Under 100 words.")
- case .poll:
- lines.append("Write brief poll context and provide \(draft.pollOptions.count) poll options.")
- }
- return lines.joined(separator: "\n")
- }
- private func parseResponse(_ data: Data, postType: RedditPostType) throws -> GeneratedPost {
- struct APIEnvelope: Decodable {
- struct Choice: Decodable {
- struct Message: Decodable {
- let content: String
- }
- let message: Message
- }
- let choices: [Choice]
- }
- struct Payload: Decodable {
- let title: String
- let body: String
- let suggestedFlair: String?
- let pollOptions: [String]?
- }
- let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data)
- guard let content = envelope.choices.first?.message.content,
- let payloadData = AIJSONDecoding.payloadData(from: content) else {
- throw PostGenerationError.invalidResponse
- }
- let payload = try JSONDecoder().decode(Payload.self, from: payloadData)
- let title = String(payload.title.prefix(PostDraftValidator.maxTitleLength))
- let body = String(payload.body.prefix(PostDraftValidator.maxBodyLength))
- var pollOptions = payload.pollOptions
- if postType == .poll {
- pollOptions = pollOptions?.map {
- $0.trimmingCharacters(in: .whitespaces)
- }.filter { !$0.isEmpty }
- if let pollOptions, pollOptions.count < 2 {
- throw PostGenerationError.invalidResponse
- }
- } else {
- pollOptions = nil
- }
- return GeneratedPost(
- title: title,
- body: body,
- suggestedFlair: payload.suggestedFlair,
- pollOptions: pollOptions
- )
- }
- }
- // MARK: - Mock
- struct MockPostGenerationService: PostGenerationServiceProtocol {
- func generatePost(from draft: PostDraft) async throws -> GeneratedPost {
- try PostDraftValidator.validateForGeneration(topic: draft.topic, subreddit: draft.subreddit)
- let subreddit = PostDraftValidator.normalizedSubreddit(draft.subreddit)
- let topic = draft.topic.trimmingCharacters(in: .whitespaces)
- let tone = draft.tone.title.lowercased()
- let title = makeTitle(topic: topic, subreddit: subreddit, tone: draft.tone, tags: draft)
- let body = makeBody(
- topic: topic,
- subreddit: subreddit,
- tone: tone,
- postType: draft.postType,
- tags: draft
- )
- let flair = suggestFlair(for: draft.postType, draft: draft)
- let pollOptions = draft.postType == .poll ? makePollOptions(topic: topic, subreddit: subreddit) : nil
- return GeneratedPost(
- title: String(title.prefix(PostDraftValidator.maxTitleLength)),
- body: String(body.prefix(PostDraftValidator.maxBodyLength)),
- suggestedFlair: flair,
- pollOptions: pollOptions
- )
- }
- private func makeTitle(
- topic: String,
- subreddit: String,
- tone: PostTone,
- tags: PostDraft
- ) -> String {
- let prefix = tags.isNSFW ? "[NSFW] " : ""
- let base: String = switch tone {
- case .humorous:
- "I tried \(topic) so you don't have to — r/\(subreddit) was not wrong"
- case .professional:
- "Analysis: \(topic) — findings from r/\(subreddit)"
- case .informative:
- "Everything you need to know about \(topic) [Guide]"
- case .controversial:
- "Unpopular opinion: \(topic) is overrated. Change my mind."
- case .storytelling:
- "How \(topic) completely changed my perspective"
- case .casual:
- "Anyone else obsessed with \(topic) lately?"
- }
- return prefix + base
- }
- private func makeBody(
- topic: String,
- subreddit: String,
- tone: String,
- postType: RedditPostType,
- tags: PostDraft
- ) -> String {
- var sections: [String] = []
- if tags.isSpoiler {
- sections.append("> **Spoiler warning** — details below.")
- }
- if tags.isOC {
- sections.append("*Original content — created by me.*")
- }
- let core: String = switch postType {
- case .text:
- """
- Hey r/\(subreddit),
- I've been diving deep into **\(topic)** and wanted to share what I've learned.
- **Key points:**
- - Started exploring this after seeing it trending here
- - The community's take has been incredibly helpful
- - Still have a few open questions (see below)
- **My experience:**
- This started as a casual experiment but turned into something I genuinely care about. Would love to hear how others in this subreddit approach \(topic).
- **Questions for the community:**
- 1. What's your go-to resource for \(topic)?
- 2. Any common mistakes beginners should avoid?
- 3. Who else here is working on something similar?
- Written in a \(tone) tone — happy to refine based on feedback!
- """
- case .image:
- """
- Visual breakdown of **\(topic)** — hope this helps someone in r/\(subreddit).
- Happy to answer questions in the comments!
- """
- case .link:
- """
- Found this resource on **\(topic)** and thought r/\(subreddit) would find it useful.
- **Why it matters:** concise overview with practical takeaways for this community.
- """
- case .video:
- """
- Just watched this breakdown of **\(topic)** and had to share with r/\(subreddit).
- The section that really nails it is around the middle — curious if you agree.
- """
- case .poll:
- """
- Curious where r/\(subreddit) stands on **\(topic)**. Vote below and share your reasoning!
- """
- }
- sections.append(core)
- return sections.joined(separator: "\n\n")
- }
- private func makePollOptions(topic: String, subreddit: String) -> [String] {
- [
- "Love it — best thing for r/\(subreddit)",
- "It's fine, but overhyped",
- "Not for me",
- "Still figuring out \(topic)",
- ]
- }
- private func suggestFlair(for postType: RedditPostType, draft: PostDraft) -> String {
- if !draft.flair.isEmpty { return draft.flair }
- if draft.isNSFW { return "NSFW" }
- return switch postType {
- case .text: "Discussion"
- case .image: "Media"
- case .link: "News"
- case .video: "Video"
- case .poll: "Poll"
- }
- }
- }
|