Skip to content

Commit

Permalink
Report StackOverflow questions (#119)
Browse files Browse the repository at this point in the history
  • Loading branch information
MahdiBM authored Oct 13, 2023
1 parent e015ef9 commit eb4e6d2
Show file tree
Hide file tree
Showing 17 changed files with 401 additions and 22 deletions.
6 changes: 2 additions & 4 deletions Lambdas/GHHooks/EventHandler/Handlers/TicketReporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,9 @@ struct TicketReporter {
repoID: repoID,
number: number
)
return
default: break
default:
try response.guardSuccess()
}

try response.guardSuccess()
}
}

Expand Down
5 changes: 5 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ let upcomingFeaturesSwiftSettings: [SwiftSetting] = [
/// This one shouldn't do much to be honest, but shouldn't hurt as well.
.enableUpcomingFeature("ForwardTrailingClosures"),

/// https://github.com/apple/swift-evolution/blob/main/proposals/0401-remove-property-wrapper-isolation.md
/// Remove Actor Isolation Inference caused by Property Wrappers
/// Won't do much in Penny.
.enableUpcomingFeature("DisableOutwardActorInference"),

/// https://github.com/apple/swift-evolution/blob/main/proposals/0354-regex-literals.md
/// `BareSlashRegexLiterals` not enabled since we don't use regex anywhere.

Expand Down
5 changes: 5 additions & 0 deletions Sources/Penny/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ enum Constants {
["", "\u{200d}\u{2640}\u{fe0f}", "\u{200d}\u{2642}\u{fe0f}"]
}

enum StackOverflow {
static let apiKey = env("SO_API_KEY")
}

enum ServerEmojis {
case coin
case vapor
Expand Down Expand Up @@ -69,6 +73,7 @@ enum Constants {
case logs = "1067060193982156880"
case proposals = "1104650517549953094"
case thanks = "443074453719744522"
case stackOverflow = "473249028142923787"

var id: ChannelSnowflake {
self.rawValue
Expand Down
1 change: 1 addition & 0 deletions Sources/Penny/HandlerContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ struct HandlerContext: Sendable {
let discordService: DiscordService
let renderClient: RenderClient
let proposalsChecker: ProposalsChecker
let soChecker: SOChecker
let reactionCache: ReactionCache
}

Expand Down
12 changes: 10 additions & 2 deletions Sources/Penny/MainService/PennyService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,17 +93,23 @@ struct PennyService: MainService {
let faqsService = DefaultFaqsService(httpClient: httpClient)
let autoFaqsService = DefaultAutoFaqsService(httpClient: httpClient)
let proposalsService = DefaultProposalsService(httpClient: httpClient)
let soService = DefaultSOService(httpClient: httpClient)
let discordService = DiscordService(discordClient: bot.client, cache: cache)
let proposalsChecker = ProposalsChecker(
proposalsService: proposalsService,
discordService: discordService
)
let soChecker = SOChecker(
soService: soService,
discordService: discordService
)
let reactionCache = ReactionCache()
let cachesService = DefaultCachesService(
awsClient: awsClient,
context: .init(
autoFaqsService: autoFaqsService,
proposalsChecker: proposalsChecker,
soChecker: soChecker,
reactionCache: reactionCache
)
)
Expand All @@ -117,11 +123,12 @@ struct PennyService: MainService {
renderClient: .init(
renderer: try .forPenny(
httpClient: httpClient,
logger: Logger(label: "Tests_Penny+Leaf"),
logger: Logger(label: "Penny+Leaf"),
on: httpClient.eventLoopGroup.next()
)
),
proposalsChecker: proposalsChecker,
soChecker: soChecker,
reactionCache: reactionCache
)
let context = HandlerContext(
Expand All @@ -144,8 +151,9 @@ struct PennyService: MainService {
/// Initialize `BotStateManager` after `bot.connect()` and `bot.makeEventsStream()`.
/// since it communicates through Discord and will need the Gateway connection.
await context.botStateManager.start {
/// ProposalsChecker contains cached stuff and needs to wait for `BotStateManager`.
/// These contain cached stuff and need to wait for `BotStateManager`.
context.services.proposalsChecker.run()
context.services.soChecker.run()
}
}
}
10 changes: 5 additions & 5 deletions Sources/Penny/ProposalsChecker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ actor ProposalsChecker {

nonisolated func run() {
Task { [self] in
if Task.isCancelled { return }
do {
try await self.check()
try await Task.sleep(for: .seconds(60 * 15)) /// 15 mins
Expand Down Expand Up @@ -250,7 +251,7 @@ actor ProposalsChecker {
proposalLink: String?
) async -> [Interaction.ActionRow] {
var buttons: [Interaction.ActionRow] = [[]]

if let proposalLink,
let link = await findForumPostLink(link: proposalLink) {
let description = link.description.trimmingCharacters(in: .punctuationCharacters).capitalized
Expand All @@ -261,13 +262,13 @@ actor ProposalsChecker {
))
)
}

if let link = makeForumSearchLink(proposal: proposal) {
buttons[0].components.append(
.button(.init(label: "Related Posts", url: link))
)
}

return buttons
}

Expand Down Expand Up @@ -324,8 +325,7 @@ actor ProposalsChecker {
}

func consumeCachesStorageData(_ storage: Storage) {
self.storage.previousProposals = storage.previousProposals
self.storage.queuedProposals = storage.queuedProposals
self.storage = storage
}

func getCachedDataForCachesStorage() -> Storage {
Expand Down
147 changes: 147 additions & 0 deletions Sources/Penny/SOChecker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import Logging
import DiscordBM
import Markdown
import Foundation

actor SOChecker {

struct Storage: Sendable, Codable {
var lastCheckDate: Date?
}

var storage = Storage()

let soService: any SOService
let discordService: DiscordService
let logger = Logger(label: "SOChecker")

init(soService: any SOService, discordService: DiscordService) {
self.soService = soService
self.discordService = discordService
}

nonisolated func run() {
Task { [self] in
if Task.isCancelled { return }
do {
try await self.check()
} catch {
logger.report("Couldn't check SO questions", error: error)
}
try await Task.sleep(for: .seconds(60 * 5)) /// 5 mins
self.run()
}
}

func check() async throws {
let after = storage.lastCheckDate ?? Date().addingTimeInterval(-60 * 60)
let questions = try await soService.listQuestions(after: after)
storage.lastCheckDate = Date()

for question in questions {
await discordService.sendMessage(
channelId: Constants.Channels.stackOverflow.id,
payload: .init(embeds: [.init(
title: question.title.htmlDecoded().unicodesPrefix(256),
url: question.link,
timestamp: Date(timeIntervalSince1970: Double(question.creationDate)),
color: .mint,
footer: .init(
text: "By \(question.owner.displayName)",
icon_url: question.owner.profileImage.map { .exact($0) }
)
)])
)
}
}

func consumeCachesStorageData(_ storage: Storage) {
self.storage = storage
}

func getCachedDataForCachesStorage() -> Storage {
return self.storage
}
}

// MARK: +String
private extension String {
func htmlDecoded() -> String {
Document(parsing: self).format()
}
}

// MARK: - SOQuestions
struct SOQuestions: Codable {

struct Item: Codable {

struct Owner: Codable {
let accountID: Int?
let reputation: Int?
let userID: Int?
let userType: String
let acceptRate: Int?
let profileImage: String?
let displayName: String
let link: String?

enum CodingKeys: String, CodingKey {
case accountID = "account_id"
case reputation
case userID = "user_id"
case userType = "user_type"
case acceptRate = "accept_rate"
case profileImage = "profile_image"
case displayName = "display_name"
case link
}
}

let tags: [String]
let owner: Owner
let isAnswered: Bool
let viewCount: Int
let acceptedAnswerID: Int?
let answerCount: Int
let score: Int
let lastActivityDate: Int
let creationDate: Int
let questionID: Int
let contentLicense: String?
let link: String
let title: String
let lastEditDate: Int?
let closedDate: Int?
let closedReason: String?

enum CodingKeys: String, CodingKey {
case tags, owner
case isAnswered = "is_answered"
case viewCount = "view_count"
case acceptedAnswerID = "accepted_answer_id"
case answerCount = "answer_count"
case score
case lastActivityDate = "last_activity_date"
case creationDate = "creation_date"
case questionID = "question_id"
case contentLicense = "content_license"
case link, title
case lastEditDate = "last_edit_date"
case closedDate = "closed_date"
case closedReason = "closed_reason"
}
}

let items: [Item]
let hasMore: Bool
let quotaMax: Int
let quotaRemaining: Int

enum CodingKeys: String, CodingKey {
case items
case hasMore = "has_more"
case quotaMax = "quota_max"
case quotaRemaining = "quota_remaining"
}
}
15 changes: 10 additions & 5 deletions Sources/Penny/Services/CachesService/CachesStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,23 @@ struct CachesStorage: Sendable, Codable {
struct Context {
let autoFaqsService: any AutoFaqsService
let proposalsChecker: ProposalsChecker
let soChecker: SOChecker
let reactionCache: ReactionCache
}

var reactionCacheData: ReactionCache.Storage?
var proposalsCheckerData: ProposalsChecker.Storage?
var soCheckerData: SOChecker.Storage?
var autoFaqsResponseRateLimiter: DefaultAutoFaqsService.ResponseRateLimiter?

init() { }

static func makeFromCachedData(context: Context) async -> CachesStorage {
var storage = CachesStorage()
storage.reactionCacheData = await context.reactionCache.getCachedDataForCachesStorage()
storage.proposalsCheckerData = await context.proposalsChecker
.getCachedDataForCachesStorage()
storage.autoFaqsResponseRateLimiter = await context.autoFaqsService
.getCachedDataForCachesStorage()
storage.proposalsCheckerData = await context.proposalsChecker.getCachedDataForCachesStorage()
storage.soCheckerData = await context.soChecker.getCachedDataForCachesStorage()
storage.autoFaqsResponseRateLimiter = await context.autoFaqsService.getCachedDataForCachesStorage()
return storage
}

Expand All @@ -32,6 +33,9 @@ struct CachesStorage: Sendable, Codable {
if let proposalsCheckerData {
await context.proposalsChecker.consumeCachesStorageData(proposalsCheckerData)
}
if let soCheckerData {
await context.soChecker.consumeCachesStorageData(soCheckerData)
}
if let autoFaqsResponseRateLimiter {
await context.autoFaqsService.consumeCachesStorageData(autoFaqsResponseRateLimiter)
}
Expand All @@ -50,7 +54,8 @@ struct CachesStorage: Sendable, Codable {
Logger(label: "CachesStorage").notice("Recovered the cached stuff", metadata: [
"reactionCache_counts": .stringConvertible(reactionCacheDataCounts),
"proposalsChecker_counts": .stringConvertible(proposalsCheckerDataCounts),
"autoFaqsRateLimiter_counts": .stringConvertible(autoFaqsResponseRateLimiterCounts),
"soChecker_isNotNil": .stringConvertible(soCheckerData != nil),
"autoFaqsLimiter_counts": .stringConvertible(autoFaqsResponseRateLimiterCounts),
])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ struct DefaultProposalsService: ProposalsService {
.init(url: "https://download.swift.org/swift-evolution/proposals.json"),
deadline: .now() + .seconds(15)
)
let buffer = try await response.body.collect(upTo: 1 << 23) /// 8 MB
let buffer = try await response.body.collect(upTo: 1 << 25) /// 32 MB
let proposals = try decoder.decode([Proposal].self, from: buffer)
if proposals.isEmpty {
throw Errors.emptyProposals
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import AsyncHTTPClient
import NIOCore
import Foundation

struct DefaultSOService: SOService {
let httpClient: HTTPClient
let decoder = JSONDecoder()

func listQuestions(after: Date) async throws -> [SOQuestions.Item] {
let queries: KeyValuePairs = [
"site": "stackoverflow",
"tagged": "vapor",
"nottagged": "laravel", /// Don't be a "laravel" questin
"page": "1",
"sort": "creation",
"order": "desc",
"fromdate": "\(Int(after.timeIntervalSince1970))",
"pagesize": "100",
"key": Constants.StackOverflow.apiKey,
]
let url = "https://api.stackexchange.com/2.3/search/advanced" + queries.makeForURLQueryUnchecked()
let request = HTTPClientRequest(url: url)
let response = try await httpClient.execute(request, deadline: .now() + .seconds(15))
let buffer = try await response.body.collect(upTo: 1 << 25) /// 32 MB
let questions = try decoder.decode(
SOQuestions.self,
from: buffer
).items
return questions
}
}

private extension KeyValuePairs<String, String> {
/// Doesn't do url-query encoding.
/// Assumes the values are already safe.
func makeForURLQueryUnchecked() -> String {
"?" + self.map { "\($0)=\($1)" }.joined(separator: "&")
}
}
5 changes: 5 additions & 0 deletions Sources/Penny/Services/StackoverflowService /SOService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import Foundation

protocol SOService {
func listQuestions(after: Date) async throws -> [SOQuestions.Item]
}
Loading

0 comments on commit eb4e6d2

Please sign in to comment.