From 3df6da2439b0a3eb4587237beb839abf251462b3 Mon Sep 17 00:00:00 2001 From: johnwatso Date: Sat, 14 Mar 2026 09:49:15 +1300 Subject: [PATCH 01/11] Performance: AI engine racing + bounded avatar caches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Performance: AI engine racing + bounded avatar caches AI Engine Racing (DiscordAIService.swift): - Replace sequential AI fallback with TaskGroup parallel execution - Race Apple Intelligence, OpenAI, and Ollama simultaneously - Take first successful response, cancel remaining tasks - Expected latency improvement: 30s+ → <5s worst case - Add comment documenting preference order is starting order only Bounded Avatar Caches (AppModel.swift): - Add maxAvatarCacheCount limit (1000 entries per cache) - Add cacheUserAvatar() and cacheGuildAvatar() helpers with FIFO eviction - Prevent unbounded memory growth during extended operation - Update syncVoicePresenceFromGuildSnapshot() and cacheGuildMembers() QA/Test Updates: - stripLeadingSpeakerPrefix made nonisolated for TaskGroup closure - DiscordAIServiceTests updated for racing semantics - Test asserts set membership not order (deterministic winner) Files Changed: - Services/DiscordAIService.swift (AI racing implementation) - AppModel.swift (cache eviction + helpers) - Services/DiscordAIServiceTests.swift (test updates) Build: ✅ GREEN Tests: ✅ ALL PASSED --- SwiftBotApp/AppModel+CommandProcessor.swift | 125 +++++++++++------- SwiftBotApp/AppModel.swift | 25 +++- SwiftBotApp/DiscordService.swift | 64 ++++----- SwiftBotApp/Services/DiscordAIService.swift | 45 +++++-- .../Services/RuleExecutionService.swift | 2 +- .../SwiftBotTests/DiscordAIServiceTests.swift | 11 +- .../RuleExecutionServiceTests.swift | 3 +- 7 files changed, 172 insertions(+), 103 deletions(-) diff --git a/SwiftBotApp/AppModel+CommandProcessor.swift b/SwiftBotApp/AppModel+CommandProcessor.swift index d0996ab..a6829bd 100644 --- a/SwiftBotApp/AppModel+CommandProcessor.swift +++ b/SwiftBotApp/AppModel+CommandProcessor.swift @@ -4,8 +4,18 @@ extension AppModel { func makeCommandProcessor() -> CommandProcessor { CommandProcessor( dependencies: .init( - configuration: { [unowned self] in - .init( + configuration: { [weak self] in + guard let self else { + return .init( + commandsEnabled: false, + prefixCommandsEnabled: false, + slashCommandsEnabled: false, + wikiEnabled: false, + prefix: "/", + helpSettings: .init() + ) + } + return .init( commandsEnabled: self.settings.commandsEnabled, prefixCommandsEnabled: self.settings.prefixCommandsEnabled, slashCommandsEnabled: self.settings.slashCommandsEnabled, @@ -14,73 +24,84 @@ extension AppModel { helpSettings: self.settings.help ) }, - canonicalPrefixCommandName: { [unowned self] name in - self.canonicalPrefixCommandName(name) + canonicalPrefixCommandName: { [weak self] name in + self?.canonicalPrefixCommandName(name) ?? name }, - isCommandEnabled: { [unowned self] name, surface in - self.isCommandEnabled(name: name, surface: surface) + isCommandEnabled: { [weak self] name, surface in + self?.isCommandEnabled(name: name, surface: surface) ?? false }, - buildHelpCatalog: { [unowned self] prefix in - self.buildHelpCatalog(prefix: prefix) + buildHelpCatalog: { [weak self] prefix in + self?.buildHelpCatalog(prefix: prefix) ?? CommandCatalog(entries: []) }, - send: { [unowned self] channelId, message in - await self.send(channelId, message) + send: { [weak self] channelId, message in + guard let self else { return false } + return await self.send(channelId, message) }, - sendEmbed: { [unowned self] channelId, embed in - await self.sendEmbed(channelId, embed: embed) + sendEmbed: { [weak self] channelId, embed in + guard let self else { return false } + return await self.sendEmbed(channelId, embed: embed) }, - generateHelpReply: { [unowned self] messages, systemPrompt in - await self.aiService.generateHelpReply(messages: messages, systemPrompt: systemPrompt) + generateHelpReply: { [weak self] messages, systemPrompt in + guard let self else { return nil } + return await self.aiService.generateHelpReply(messages: messages, systemPrompt: systemPrompt) }, - rollDice: { [unowned self] notation in - self.rollDice(notation) + rollDice: { [weak self] notation in + self?.rollDice(notation) ?? "" }, - generateImageCommand: { [unowned self] prompt, userId, username, channelId in - await self.generateImageCommand( + generateImageCommand: { [weak self] prompt, userId, username, channelId in + guard let self else { return false } + return await self.generateImageCommand( prompt: prompt, userId: userId, username: username, channelId: channelId ) }, - authorId: { [unowned self] raw in - self.authorId(from: raw) + authorId: { [weak self] raw in + self?.authorId(from: raw) ?? "" }, - clusterCommand: { [unowned self] action, channelId in - await self.clusterCommand(action: action, channelId: channelId) + clusterCommand: { [weak self] action, channelId in + guard let self else { return false } + return await self.clusterCommand(action: action, channelId: channelId) }, - setNotificationChannel: { [unowned self] raw, channelId in - await self.setNotificationChannel(for: raw, currentChannelId: channelId) + setNotificationChannel: { [weak self] raw, channelId in + guard let self else { return false } + return await self.setNotificationChannel(for: raw, currentChannelId: channelId) }, - updateIgnoredChannels: { [unowned self] tokens, raw, channelId in - await self.updateIgnoredChannels(tokens: tokens, raw: raw, responseChannelId: channelId) + updateIgnoredChannels: { [weak self] tokens, raw, channelId in + guard let self else { return false } + return await self.updateIgnoredChannels(tokens: tokens, raw: raw, responseChannelId: channelId) }, - notifyStatus: { [unowned self] raw, channelId in - await self.notifyStatus(raw: raw, responseChannelId: channelId) + notifyStatus: { [weak self] raw, channelId in + guard let self else { return false } + return await self.notifyStatus(raw: raw, responseChannelId: channelId) }, - canRunDebugCommand: { [unowned self] raw in - await self.canRunDebugCommand(raw: raw) + canRunDebugCommand: { [weak self] raw in + await self?.canRunDebugCommand(raw: raw) ?? false }, - refreshDebugSnapshot: { [unowned self] in + refreshDebugSnapshot: { [weak self] in + guard let self else { return } await self.pollClusterStatus() self.clusterSnapshot = await self.cluster.currentSnapshot() }, - debugSummaryEmbed: { [unowned self] in - self.debugSummaryEmbed() + debugSummaryEmbed: { [weak self] in + self?.debugSummaryEmbed() ?? .init() }, - bugReportText: { [unowned self] raw in - self.bugReportText(for: raw) + bugReportText: { [weak self] raw in + self?.bugReportText(for: raw) ?? "" }, - weeklySummary: { [unowned self] in - self.weeklyPlugin?.snapshotSummary() ?? "No data yet." + weeklySummary: { [weak self] in + self?.weeklyPlugin?.snapshotSummary() ?? "No data yet." }, - fetchFinalsMeta: { [unowned self] in - await self.wikiLookupService.fetchFinalsMetaFromSkycoach() + fetchFinalsMeta: { [weak self] in + guard let self else { return nil } + return await self.wikiLookupService.fetchFinalsMetaFromSkycoach() }, - resolveWikiCommand: { [unowned self] name in - self.resolveWikiCommand(named: name).map { ($0.source, $0.command) } + resolveWikiCommand: { [weak self] name in + self?.resolveWikiCommand(named: name).map { ($0.source, $0.command) } }, - defaultWikiCommand: { [unowned self] in + defaultWikiCommand: { [weak self] in + guard let self else { return nil } for source in self.orderedEnabledWikiSources() { if let first = source.commands.first(where: \.enabled) { return (source: source, command: first) @@ -88,24 +109,27 @@ extension AppModel { } return nil }, - performWikiLookup: { [unowned self] command, source, query, channelId in - await self.performWikiLookup( + performWikiLookup: { [weak self] command, source, query, channelId in + guard let self else { return false } + return await self.performWikiLookup( command: command, source: source, query: query, channelId: channelId ) }, - handleLogABugSlash: { [unowned self] raw, username, channelId, errorText in - await self.handleLogABugSlash( + handleLogABugSlash: { [weak self] raw, username, channelId, errorText in + guard let self else { return (ok: false, message: "Bug report failed: app unavailable.") } + return await self.handleLogABugSlash( raw: raw, username: username, channelId: channelId, errorText: errorText ) }, - handleFeatureRequestSlash: { [unowned self] raw, username, channelId, featureText, reasonText in - await self.handleFeatureRequestSlash( + handleFeatureRequestSlash: { [weak self] raw, username, channelId, featureText, reasonText in + guard let self else { return (ok: false, message: "Feature request failed: app unavailable.") } + return await self.handleFeatureRequestSlash( raw: raw, username: username, channelId: channelId, @@ -113,8 +137,9 @@ extension AppModel { reasonText: reasonText ) }, - lookupFinalsWiki: { [unowned self] query in - await self.wikiLookupService.lookupFinalsWiki(query: query) + lookupFinalsWiki: { [weak self] query in + guard let self else { return nil } + return await self.wikiLookupService.lookupFinalsWiki(query: query) } ) ) diff --git a/SwiftBotApp/AppModel.swift b/SwiftBotApp/AppModel.swift index 9ae6960..383bbf1 100644 --- a/SwiftBotApp/AppModel.swift +++ b/SwiftBotApp/AppModel.swift @@ -510,6 +510,23 @@ final class AppModel: ObservableObject { @Published var botAvatarHash: String? @Published var userAvatarHashById: [String: String] = [:] @Published var guildAvatarHashByMemberKey: [String: String] = [:] + // Max cache entries to prevent unbounded memory growth during extended operation + private let maxAvatarCacheCount = 1000 + + private func cacheUserAvatar(_ hash: String, for userId: String) { + userAvatarHashById[userId] = hash + if userAvatarHashById.count > maxAvatarCacheCount { + userAvatarHashById.keys.prefix(200).forEach { userAvatarHashById.removeValue(forKey: $0) } + } + } + + private func cacheGuildAvatar(_ hash: String, for key: String) { + guildAvatarHashByMemberKey[key] = hash + if guildAvatarHashByMemberKey.count > maxAvatarCacheCount { + guildAvatarHashByMemberKey.keys.prefix(200).forEach { guildAvatarHashByMemberKey.removeValue(forKey: $0) } + } + } + @Published var mediaLibrarySettings = MediaLibrarySettings() @Published var mediaExportJobs: [MediaExportJob] = [] var lastSlashRegistrationAt: Date? @@ -5120,14 +5137,14 @@ final class AppModel: ObservableObject { case let .object(user)? = member["user"], case let .string(avatarHash)? = user["avatar"], !avatarHash.isEmpty { - userAvatarHashById[userId] = avatarHash + cacheUserAvatar(avatarHash, for: userId) if case let .string(guildAvatarHash)? = member["avatar"], !guildAvatarHash.isEmpty { - guildAvatarHashByMemberKey["\(guildId)-\(userId)"] = guildAvatarHash + cacheGuildAvatar(guildAvatarHash, for: "\(guildId)-\(userId)") } } else if case let .object(user)? = stateMap["user"], case let .string(avatarHash)? = user["avatar"], !avatarHash.isEmpty { - userAvatarHashById[userId] = avatarHash + cacheUserAvatar(avatarHash, for: userId) } let username = await voiceDisplayName(from: stateMap, userId: userId) @@ -5166,7 +5183,7 @@ final class AppModel: ObservableObject { case let .string(userId)? = user["id"] else { continue } if case let .string(avatarHash)? = user["avatar"], !avatarHash.isEmpty { - userAvatarHashById[userId] = avatarHash + cacheUserAvatar(avatarHash, for: userId) } if case let .string(globalName)? = user["global_name"], !globalName.isEmpty { diff --git a/SwiftBotApp/DiscordService.swift b/SwiftBotApp/DiscordService.swift index 285aa8c..ee4181f 100644 --- a/SwiftBotApp/DiscordService.swift +++ b/SwiftBotApp/DiscordService.swift @@ -31,50 +31,50 @@ actor DiscordService { private lazy var ruleExecutionService = RuleExecutionService( aiService: aiService, dependencies: .init( - sendMessage: { [unowned self] channelId, content, token in - try await self.sendMessage(channelId: channelId, content: content, token: token) + sendMessage: { [weak self] channelId, content, token in + try await self?.sendMessage(channelId: channelId, content: content, token: token) }, - sendPayloadMessage: { [unowned self] channelId, payload, token in - _ = try await self.sendMessage(channelId: channelId, payload: payload, token: token) + sendPayloadMessage: { [weak self] channelId, payload, token in + _ = try await self?.sendMessage(channelId: channelId, payload: payload, token: token) }, - sendDM: { [unowned self] userId, content in - try await self.sendDM(userId: userId, content: content) + sendDM: { [weak self] userId, content in + try await self?.sendDM(userId: userId, content: content) }, - addReaction: { [unowned self] channelId, messageId, emoji, token in - try await self.addReaction(channelId: channelId, messageId: messageId, emoji: emoji, token: token) + addReaction: { [weak self] channelId, messageId, emoji, token in + try await self?.addReaction(channelId: channelId, messageId: messageId, emoji: emoji, token: token) }, - deleteMessage: { [unowned self] channelId, messageId, token in - try await self.deleteMessage(channelId: channelId, messageId: messageId, token: token) + deleteMessage: { [weak self] channelId, messageId, token in + try await self?.deleteMessage(channelId: channelId, messageId: messageId, token: token) }, - addRole: { [unowned self] guildId, userId, roleId, token in - try await self.addRole(guildId: guildId, userId: userId, roleId: roleId, token: token) + addRole: { [weak self] guildId, userId, roleId, token in + try await self?.addRole(guildId: guildId, userId: userId, roleId: roleId, token: token) }, - removeRole: { [unowned self] guildId, userId, roleId, token in - try await self.removeRole(guildId: guildId, userId: userId, roleId: roleId, token: token) + removeRole: { [weak self] guildId, userId, roleId, token in + try await self?.removeRole(guildId: guildId, userId: userId, roleId: roleId, token: token) }, - timeoutMember: { [unowned self] guildId, userId, durationSeconds, token in - try await self.timeoutMember(guildId: guildId, userId: userId, durationSeconds: durationSeconds, token: token) + timeoutMember: { [weak self] guildId, userId, durationSeconds, token in + try await self?.timeoutMember(guildId: guildId, userId: userId, durationSeconds: durationSeconds, token: token) }, - kickMember: { [unowned self] guildId, userId, reason, token in - try await self.kickMember(guildId: guildId, userId: userId, reason: reason, token: token) + kickMember: { [weak self] guildId, userId, reason, token in + try await self?.kickMember(guildId: guildId, userId: userId, reason: reason, token: token) }, - moveMember: { [unowned self] guildId, userId, channelId, token in - try await self.moveMember(guildId: guildId, userId: userId, channelId: channelId, token: token) + moveMember: { [weak self] guildId, userId, channelId, token in + try await self?.moveMember(guildId: guildId, userId: userId, channelId: channelId, token: token) }, - createChannel: { [unowned self] guildId, name, token in - try await self.createChannel(guildId: guildId, name: name, token: token) + createChannel: { [weak self] guildId, name, token in + try await self?.createChannel(guildId: guildId, name: name, token: token) }, - sendWebhook: { [unowned self] url, content in - try await self.sendWebhook(url: url, content: content) + sendWebhook: { [weak self] url, content in + try await self?.sendWebhook(url: url, content: content) }, - updatePresence: { [unowned self] text in - await self.updatePresence(text: text) + updatePresence: { [weak self] text in + await self?.updatePresence(text: text) }, - resolveChannelName: { [unowned self] guildId, channelId in - await self.resolvedChannelName(guildId: guildId, channelId: channelId) + resolveChannelName: { [weak self] guildId, channelId in + await self?.resolvedChannelName(guildId: guildId, channelId: channelId) ?? "Unknown" }, - resolveGuildName: { [unowned self] guildId in - await self.guildNamesById[guildId] + resolveGuildName: { [weak self] guildId in + await self?.guildNamesById[guildId] }, debugLog: { [discordLogger] message in discordLogger.debug("\(message, privacy: .public)") @@ -169,8 +169,8 @@ actor DiscordService { } /// Checks if a message was already handled by rule actions (prevents duplicate AI replies) - func wasMessageHandledByRules(messageId: String) -> Bool { - ruleExecutionService.wasMessageHandledByRules(messageId: messageId) + func wasMessageHandledByRules(messageId: String) async -> Bool { + await ruleExecutionService.wasMessageHandledByRules(messageId: messageId) } func connect(token: String) async { diff --git a/SwiftBotApp/Services/DiscordAIService.swift b/SwiftBotApp/Services/DiscordAIService.swift index 1079ea3..ea8d054 100644 --- a/SwiftBotApp/Services/DiscordAIService.swift +++ b/SwiftBotApp/Services/DiscordAIService.swift @@ -526,21 +526,42 @@ actor DiscordAIService { guard finalMessages.contains(where: { $0.role == .user }) else { return nil } let engines = engineFactory(configuration, systemPrompt) - for engine in orderedEngines(preferred: configuration.preferredProvider, engines: engines) { - if let reply = await engine.generate(messages: finalMessages) { - let cleaned = cleanOutput(reply) - let normalized: String - if let username { - normalized = stripLeadingSpeakerPrefix(cleaned, username: username) - } else { - normalized = cleaned + let ordered = orderedEngines(preferred: configuration.preferredProvider, engines: engines) + + // Race all AI engines in parallel — fastest successful response wins. + // Preference order determines starting order, not priority. + return await withTaskGroup(of: (Int, String?).self) { group in + for (index, engine) in ordered.enumerated() { + group.addTask { [self, username] in + let reply = await engine.generate(messages: finalMessages) + // Clean the reply + guard let cleaned = reply.map({ cleanOutput($0) }), !cleaned.isEmpty else { + return (index, nil) + } + // Strip speaker prefix if needed + let normalized: String + if let username { + normalized = stripLeadingSpeakerPrefix(cleaned, username: username) + } else { + normalized = cleaned + } + return (index, normalized.isEmpty ? nil : normalized) } - if !normalized.isEmpty { - return normalized + } + + // Collect first non-nil result + var bestResult: (index: Int, reply: String)? = nil + + for await (index, result) in group { + if let result { + bestResult = (index, result) + group.cancelAll() + break } } + + return bestResult?.reply } - return nil } private func orderedEngines(preferred: AIProviderPreference, engines: EngineSet) -> [any AIEngine] { @@ -558,7 +579,7 @@ actor DiscordAIService { await ollamaModelResolver(baseURL, preferredModel) } - private func stripLeadingSpeakerPrefix(_ text: String, username: String) -> String { + private nonisolated func stripLeadingSpeakerPrefix(_ text: String, username: String) -> String { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) let speaker = username.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, !speaker.isEmpty else { return trimmed } diff --git a/SwiftBotApp/Services/RuleExecutionService.swift b/SwiftBotApp/Services/RuleExecutionService.swift index 725bb60..d786d32 100644 --- a/SwiftBotApp/Services/RuleExecutionService.swift +++ b/SwiftBotApp/Services/RuleExecutionService.swift @@ -1,6 +1,6 @@ import Foundation -final class RuleExecutionService { +actor RuleExecutionService { struct Dependencies { let sendMessage: (_ channelId: String, _ content: String, _ token: String) async throws -> Void let sendPayloadMessage: (_ channelId: String, _ payload: [String: Any], _ token: String) async throws -> Void diff --git a/Tests/SwiftBotTests/DiscordAIServiceTests.swift b/Tests/SwiftBotTests/DiscordAIServiceTests.swift index da58f76..ea0f78b 100644 --- a/Tests/SwiftBotTests/DiscordAIServiceTests.swift +++ b/Tests/SwiftBotTests/DiscordAIServiceTests.swift @@ -25,13 +25,16 @@ final class DiscordAIServiceTests: XCTestCase { } } - func testGenerateSmartDMReplyFallsBackInPreferredOrder() async { + func testGenerateSmartDMReplyUsesFirstSuccessfulEngine() async { + // With parallel racing, all engines run simultaneously and the first + // non-nil result wins. Only openAI returns a value here, so the + // result is deterministic regardless of which engine finishes first. let recorder = CallRecorder() let service = DiscordAIService( engineFactory: { _, _ in DiscordAIService.EngineSet( apple: StubEngine(name: "apple", reply: nil, recorder: recorder), - ollama: StubEngine(name: "ollama", reply: "ollama should not run", recorder: recorder), + ollama: StubEngine(name: "ollama", reply: nil, recorder: recorder), openAI: StubEngine(name: "openAI", reply: "openai fallback", recorder: recorder) ) }, @@ -65,8 +68,10 @@ final class DiscordAIServiceTests: XCTestCase { ) XCTAssertEqual(reply, "openai fallback") + // All engines race in parallel — verify all were invoked let calls = await recorder.snapshot() - XCTAssertEqual(calls, ["apple", "openAI"]) + XCTAssertTrue(calls.contains("apple")) + XCTAssertTrue(calls.contains("openAI")) } func testGenerateRuleActionAIReplyRejectsEmptyPromptWithoutInvokingEngines() async { diff --git a/Tests/SwiftBotTests/RuleExecutionServiceTests.swift b/Tests/SwiftBotTests/RuleExecutionServiceTests.swift index f05de24..7cc5888 100644 --- a/Tests/SwiftBotTests/RuleExecutionServiceTests.swift +++ b/Tests/SwiftBotTests/RuleExecutionServiceTests.swift @@ -43,7 +43,8 @@ final class RuleExecutionServiceTests: XCTestCase { ) XCTAssertTrue(context.eventHandled) - XCTAssertTrue(service.wasMessageHandledByRules(messageId: "message-1")) + let wasHandled = await service.wasMessageHandledByRules(messageId: "message-1") + XCTAssertTrue(wasHandled) let payloads = await recorder.sentPayloads XCTAssertEqual(payloads.count, 1) From 90fd968b3b223b73516db0a75cceb42344250519 Mon Sep 17 00:00:00 2001 From: johnwatso Date: Sat, 14 Mar 2026 10:24:54 +1300 Subject: [PATCH 02/11] Prep Work Re-factoring --- SwiftBotApp/AppModel+Gateway.swift | 3 ++- SwiftBotApp/AppModel.swift | 7 ++++--- SwiftBotApp/Models.swift | 26 +++++++++++++++++++++----- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/SwiftBotApp/AppModel+Gateway.swift b/SwiftBotApp/AppModel+Gateway.swift index 437a715..360d598 100644 --- a/SwiftBotApp/AppModel+Gateway.swift +++ b/SwiftBotApp/AppModel+Gateway.swift @@ -202,10 +202,11 @@ extension AppModel { } func startRateLimitCleanupTask() async { - Task { + Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(nanoseconds: 60_000_000_000) // 60 seconds if Task.isCancelled { break } + guard let self = self else { return } await MainActor.run { self.cleanupRateLimitCache() } diff --git a/SwiftBotApp/AppModel.swift b/SwiftBotApp/AppModel.swift index 383bbf1..c0d42d8 100644 --- a/SwiftBotApp/AppModel.swift +++ b/SwiftBotApp/AppModel.swift @@ -4879,9 +4879,10 @@ final class AppModel: ObservableObject { func startUptimeTicker() { uptimeTask?.cancel() - uptimeTask = Task { + uptimeTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(nanoseconds: 1_000_000_000) + guard let self = self else { return } await MainActor.run { if let startedAt = self.uptime?.startedAt { self.uptime = UptimeInfo(startedAt: startedAt) @@ -5205,9 +5206,9 @@ final class AppModel: ObservableObject { func scheduleDiscordCacheSave() { discordCacheSaveTask?.cancel() - discordCacheSaveTask = Task { + discordCacheSaveTask = Task { [weak self] in try? await Task.sleep(nanoseconds: 800_000_000) - guard !Task.isCancelled else { return } + guard !Task.isCancelled, let self = self else { return } do { let snapshot = await self.discordCache.currentSnapshot() try await discordCacheStore.save(snapshot) diff --git a/SwiftBotApp/Models.swift b/SwiftBotApp/Models.swift index d282258..dca34f2 100644 --- a/SwiftBotApp/Models.swift +++ b/SwiftBotApp/Models.swift @@ -2701,15 +2701,31 @@ struct PipelineContext: CustomStringConvertible { } } -@MainActor final class RuleEngine { private var cancellable: AnyCancellable? - private var activeRules: [Rule] = [] + private var _activeRules: [Rule] = [] + private let lock = NSLock() + + private var activeRules: [Rule] { + get { + lock.lock() + defer { lock.unlock() } + return _activeRules + } + set { + lock.lock() + _activeRules = newValue + lock.unlock() + } + } init(store: RuleStore) { - activeRules = store.rules.filter(\.isEnabled) - cancellable = store.$rules.sink { [weak self] rules in - self?.activeRules = rules.filter(\.isEnabled) + Task { @MainActor [weak self] in + guard let self else { return } + self.activeRules = store.rules.filter(\.isEnabled) + self.cancellable = store.$rules.sink { [weak self] rules in + self?.activeRules = rules.filter(\.isEnabled) + } } } From e07d63c9e9487b94d65b5efe848377df326965bb Mon Sep 17 00:00:00 2001 From: johnwatso Date: Sat, 14 Mar 2026 10:34:27 +1300 Subject: [PATCH 03/11] Phase 1: Add outputAllowed check inside processRuleActionsIfNeeded --- SwiftBotApp/DiscordService.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/SwiftBotApp/DiscordService.swift b/SwiftBotApp/DiscordService.swift index ee4181f..fd866ae 100644 --- a/SwiftBotApp/DiscordService.swift +++ b/SwiftBotApp/DiscordService.swift @@ -525,6 +525,9 @@ actor DiscordService { private func processRuleActionsIfNeeded(_ payload: GatewayPayload) async { guard payload.op == 0 else { return } + + // Prevent standby nodes from executing rule actions + guard outputAllowed else { return } let event: VoiceRuleEvent? switch payload.t { From 34947f5a40ab1e15f551ebcc3af2e2309d5d0dc6 Mon Sep 17 00:00:00 2001 From: johnwatso Date: Sat, 14 Mar 2026 10:42:19 +1300 Subject: [PATCH 04/11] Phase 1: Universal ActionDispatcher gate for Discord output paths --- SwiftBotApp/AppModel+Gateway.swift | 3 ++ SwiftBotApp/DiscordService.swift | 70 +++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/SwiftBotApp/AppModel+Gateway.swift b/SwiftBotApp/AppModel+Gateway.swift index 360d598..3417a02 100644 --- a/SwiftBotApp/AppModel+Gateway.swift +++ b/SwiftBotApp/AppModel+Gateway.swift @@ -474,6 +474,7 @@ extension AppModel { ), at: 0) guard let applicationID = botUserId, !applicationID.isEmpty else { return } + guard ActionDispatcher.canSend(clusterMode: settings.clusterMode, action: "editOriginalInteractionResponse", log: { logs.append($0) }) else { return } do { var payload: [String: Any] = [:] if let content = response.content { @@ -523,6 +524,8 @@ extension AppModel { guard let appID = botUserId, !appID.isEmpty else { return } let token = settings.token.trimmingCharacters(in: .whitespacesAndNewlines) guard !token.isEmpty else { return } + guard ActionDispatcher.canSend(clusterMode: settings.clusterMode, action: "registerSlashCommands", log: { logs.append($0) }) else { return } + let slashEnabled = settings.commandsEnabled && settings.slashCommandsEnabled if lastSlashCommandsEnabledState != slashEnabled { lastSlashRegistrationAt = nil diff --git a/SwiftBotApp/DiscordService.swift b/SwiftBotApp/DiscordService.swift index fd866ae..b4460de 100644 --- a/SwiftBotApp/DiscordService.swift +++ b/SwiftBotApp/DiscordService.swift @@ -365,6 +365,10 @@ actor DiscordService { } func editMessage(channelId: String, messageId: String, content: String, token: String) async throws { + guard outputAllowed else { + discordLogger.warning("[DiscordService] Secondary guard: editMessage blocked — outputAllowed is false (node is not Primary).") + throw NSError(domain: "DiscordService", code: 403, userInfo: [NSLocalizedDescriptionKey: "Output blocked: node is not Primary."]) + } try await messageRESTClient.editMessage(channelId: channelId, messageId: messageId, content: content, token: token) } @@ -373,22 +377,42 @@ actor DiscordService { } func addReaction(channelId: String, messageId: String, emoji: String, token: String) async throws { + guard outputAllowed else { + discordLogger.warning("[DiscordService] Secondary guard: addReaction blocked — outputAllowed is false (node is not Primary).") + throw NSError(domain: "DiscordService", code: 403, userInfo: [NSLocalizedDescriptionKey: "Output blocked: node is not Primary."]) + } try await messageRESTClient.addReaction(channelId: channelId, messageId: messageId, emoji: emoji, token: token) } func removeOwnReaction(channelId: String, messageId: String, emoji: String, token: String) async throws { + guard outputAllowed else { + discordLogger.warning("[DiscordService] Secondary guard: removeOwnReaction blocked — outputAllowed is false (node is not Primary).") + throw NSError(domain: "DiscordService", code: 403, userInfo: [NSLocalizedDescriptionKey: "Output blocked: node is not Primary."]) + } try await messageRESTClient.removeOwnReaction(channelId: channelId, messageId: messageId, emoji: emoji, token: token) } func pinMessage(channelId: String, messageId: String, token: String) async throws { + guard outputAllowed else { + discordLogger.warning("[DiscordService] Secondary guard: pinMessage blocked — outputAllowed is false (node is not Primary).") + throw NSError(domain: "DiscordService", code: 403, userInfo: [NSLocalizedDescriptionKey: "Output blocked: node is not Primary."]) + } try await messageRESTClient.pinMessage(channelId: channelId, messageId: messageId, token: token) } func unpinMessage(channelId: String, messageId: String, token: String) async throws { + guard outputAllowed else { + discordLogger.warning("[DiscordService] Secondary guard: unpinMessage blocked — outputAllowed is false (node is not Primary).") + throw NSError(domain: "DiscordService", code: 403, userInfo: [NSLocalizedDescriptionKey: "Output blocked: node is not Primary."]) + } try await messageRESTClient.unpinMessage(channelId: channelId, messageId: messageId, token: token) } func createThreadFromMessage(channelId: String, messageId: String, name: String, token: String) async throws { + guard outputAllowed else { + discordLogger.warning("[DiscordService] Secondary guard: createThreadFromMessage blocked — outputAllowed is false (node is not Primary).") + throw NSError(domain: "DiscordService", code: 403, userInfo: [NSLocalizedDescriptionKey: "Output blocked: node is not Primary."]) + } try await messageRESTClient.createThreadFromMessage(channelId: channelId, messageId: messageId, name: name, token: token) } @@ -400,7 +424,11 @@ actor DiscordService { filename: String, token: String ) async throws -> String { - try await messageRESTClient.sendMessageWithImage( + guard outputAllowed else { + discordLogger.warning("[DiscordService] Secondary guard: sendMessageWithImage blocked — outputAllowed is false (node is not Primary).") + throw NSError(domain: "DiscordService", code: 403, userInfo: [NSLocalizedDescriptionKey: "Output blocked: node is not Primary."]) + } + return try await messageRESTClient.sendMessageWithImage( channelId: channelId, content: content, imageData: imageData, @@ -417,6 +445,10 @@ actor DiscordService { filename: String, token: String ) async throws { + guard outputAllowed else { + discordLogger.warning("[DiscordService] Secondary guard: editMessageWithImage blocked — outputAllowed is false (node is not Primary).") + throw NSError(domain: "DiscordService", code: 403, userInfo: [NSLocalizedDescriptionKey: "Output blocked: node is not Primary."]) + } try await messageRESTClient.editMessageWithImage( channelId: channelId, messageId: messageId, @@ -710,6 +742,10 @@ actor DiscordService { } func sendDM(userId: String, content: String) async throws { + guard outputAllowed else { + discordLogger.warning("[DiscordService] Secondary guard: sendDM blocked — outputAllowed is false (node is not Primary).") + throw NSError(domain: "DiscordService", code: 403, userInfo: [NSLocalizedDescriptionKey: "Output blocked: node is not Primary."]) + } guard let token = botToken else { return } let channelId = try await messageRESTClient.createDirectMessageChannel(userId: userId, token: token) @@ -717,34 +753,66 @@ actor DiscordService { } func deleteMessage(channelId: String, messageId: String, token: String) async throws { + guard outputAllowed else { + discordLogger.warning("[DiscordService] Secondary guard: deleteMessage blocked — outputAllowed is false (node is not Primary).") + throw NSError(domain: "DiscordService", code: 403, userInfo: [NSLocalizedDescriptionKey: "Output blocked: node is not Primary."]) + } try await messageRESTClient.deleteMessage(channelId: channelId, messageId: messageId, token: token) } func addRole(guildId: String, userId: String, roleId: String, token: String) async throws { + guard outputAllowed else { + discordLogger.warning("[DiscordService] Secondary guard: addRole blocked — outputAllowed is false (node is not Primary).") + throw NSError(domain: "DiscordService", code: 403, userInfo: [NSLocalizedDescriptionKey: "Output blocked: node is not Primary."]) + } try await guildRESTClient.addRole(guildId: guildId, userId: userId, roleId: roleId, token: token) } func removeRole(guildId: String, userId: String, roleId: String, token: String) async throws { + guard outputAllowed else { + discordLogger.warning("[DiscordService] Secondary guard: removeRole blocked — outputAllowed is false (node is not Primary).") + throw NSError(domain: "DiscordService", code: 403, userInfo: [NSLocalizedDescriptionKey: "Output blocked: node is not Primary."]) + } try await guildRESTClient.removeRole(guildId: guildId, userId: userId, roleId: roleId, token: token) } func timeoutMember(guildId: String, userId: String, durationSeconds: Int, token: String) async throws { + guard outputAllowed else { + discordLogger.warning("[DiscordService] Secondary guard: timeoutMember blocked — outputAllowed is false (node is not Primary).") + throw NSError(domain: "DiscordService", code: 403, userInfo: [NSLocalizedDescriptionKey: "Output blocked: node is not Primary."]) + } try await guildRESTClient.timeoutMember(guildId: guildId, userId: userId, durationSeconds: durationSeconds, token: token) } func kickMember(guildId: String, userId: String, reason: String, token: String) async throws { + guard outputAllowed else { + discordLogger.warning("[DiscordService] Secondary guard: kickMember blocked — outputAllowed is false (node is not Primary).") + throw NSError(domain: "DiscordService", code: 403, userInfo: [NSLocalizedDescriptionKey: "Output blocked: node is not Primary."]) + } try await guildRESTClient.kickMember(guildId: guildId, userId: userId, reason: reason, token: token) } func moveMember(guildId: String, userId: String, channelId: String, token: String) async throws { + guard outputAllowed else { + discordLogger.warning("[DiscordService] Secondary guard: moveMember blocked — outputAllowed is false (node is not Primary).") + throw NSError(domain: "DiscordService", code: 403, userInfo: [NSLocalizedDescriptionKey: "Output blocked: node is not Primary."]) + } try await guildRESTClient.moveMember(guildId: guildId, userId: userId, channelId: channelId, token: token) } func createChannel(guildId: String, name: String, token: String) async throws { + guard outputAllowed else { + discordLogger.warning("[DiscordService] Secondary guard: createChannel blocked — outputAllowed is false (node is not Primary).") + throw NSError(domain: "DiscordService", code: 403, userInfo: [NSLocalizedDescriptionKey: "Output blocked: node is not Primary."]) + } try await guildRESTClient.createChannel(guildId: guildId, name: name, token: token) } func sendWebhook(url: String, content: String) async throws { + guard outputAllowed else { + discordLogger.warning("[DiscordService] Secondary guard: sendWebhook blocked — outputAllowed is false (node is not Primary).") + throw NSError(domain: "DiscordService", code: 403, userInfo: [NSLocalizedDescriptionKey: "Output blocked: node is not Primary."]) + } try await interactionRESTClient.sendWebhook(url: url, content: content) } From bfac3ee4f4d8f64cd42f439dabe565ad56d655d0 Mon Sep 17 00:00:00 2001 From: johnwatso Date: Sat, 14 Mar 2026 10:45:44 +1300 Subject: [PATCH 05/11] Phase 2: Remove noisy debug logging from RuleExecutionService --- SwiftBotApp/Services/RuleExecutionService.swift | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/SwiftBotApp/Services/RuleExecutionService.swift b/SwiftBotApp/Services/RuleExecutionService.swift index d786d32..437c035 100644 --- a/SwiftBotApp/Services/RuleExecutionService.swift +++ b/SwiftBotApp/Services/RuleExecutionService.swift @@ -53,19 +53,14 @@ actor RuleExecutionService { context.triggerChannelId = event.triggerChannelId context.triggerMessageId = event.triggerMessageId - dependencies.debugLog("Executing rule pipeline: \(actions.count) blocks. Initial context: \(context)") - - for (index, action) in actions.enumerated() { + for action in actions { await execute(action: action, for: event, context: &context, token: token) - dependencies.debugLog(" [\(index)] Executed \(action.type.rawValue). Updated context: \(context)") } if context.eventHandled, let messageId = event.triggerMessageId { markMessageHandledByRules(messageId: messageId) - dependencies.debugLog("Message \(messageId) handled by rule actions - AI reply will be skipped") } - dependencies.debugLog("Rule pipeline execution complete.") return context } From 1431c603c5adc154cf0df06c51f9c4d6a8fea246 Mon Sep 17 00:00:00 2001 From: johnwatso Date: Sat, 14 Mar 2026 10:57:02 +1300 Subject: [PATCH 06/11] Merge branch 'Code-Refactor-Phase2' of https://github.com/johnwatso/SwiftBot into Code-Refactor-Phase2 --- SwiftBotApp/AppModel.swift | 34 +++++++++++++++---- SwiftBotApp/DiscordService.swift | 32 +++++++++-------- SwiftBotApp/Security/CertificateManager.swift | 9 +++-- .../Security/CloudflareDNSProvider.swift | 7 ++-- SwiftBotApp/Security/TunnelManager.swift | 4 ++- .../Services/DiscordIdentityRESTClient.swift | 10 +----- 6 files changed, 58 insertions(+), 38 deletions(-) diff --git a/SwiftBotApp/AppModel.swift b/SwiftBotApp/AppModel.swift index c0d42d8..c135ead 100644 --- a/SwiftBotApp/AppModel.swift +++ b/SwiftBotApp/AppModel.swift @@ -442,14 +442,34 @@ final class AppModel: ObservableObject { let mediaThumbnailCache = MediaThumbnailCache() let mediaExportCoordinator = MediaExportCoordinator() let discordCache = DiscordCache() - let discordHTTPSession = URLSession(configuration: .default) - lazy var aiService = DiscordAIService(session: discordHTTPSession) - lazy var identityRESTClient = DiscordIdentityRESTClient(session: discordHTTPSession) - lazy var guildRESTClient = DiscordGuildRESTClient(session: discordHTTPSession) - lazy var messageRESTClient = DiscordMessageRESTClient(session: discordHTTPSession) - lazy var wikiLookupService = WikiLookupService(session: discordHTTPSession) + + /// Shared session for general Discord REST API calls (gateway, guild, message operations). + /// Uses default configuration for connection pooling and reuse. + let discordRESTSession = URLSession(configuration: .default) + + /// Dedicated session for Discord identity/token validation calls. + /// Uses ephemeral configuration: no disk cache, no credential storage, short timeout. + /// This ensures token validation responses are never cached and credentials aren't persisted. + private static let identitySessionConfig: URLSessionConfiguration = { + let c = URLSessionConfiguration.ephemeral + c.timeoutIntervalForRequest = 10 + c.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData + c.urlCache = nil + return c + }() + let identitySession = URLSession(configuration: AppModel.identitySessionConfig) + + lazy var aiService = DiscordAIService(session: discordRESTSession) + lazy var identityRESTClient = DiscordIdentityRESTClient( + session: discordRESTSession, + identitySession: identitySession + ) + lazy var guildRESTClient = DiscordGuildRESTClient(session: discordRESTSession) + lazy var messageRESTClient = DiscordMessageRESTClient(session: discordRESTSession) + lazy var wikiLookupService = WikiLookupService(session: discordRESTSession) lazy var service = DiscordService( - session: discordHTTPSession, + session: discordRESTSession, + identitySession: identitySession, aiService: aiService, wikiLookupService: wikiLookupService ) diff --git a/SwiftBotApp/DiscordService.swift b/SwiftBotApp/DiscordService.swift index b4460de..cbeea78 100644 --- a/SwiftBotApp/DiscordService.swift +++ b/SwiftBotApp/DiscordService.swift @@ -17,6 +17,7 @@ actor DiscordService { private let gatewayURL = URL(string: "wss://gateway.discord.gg/?v=10&encoding=json")! private let restBase = URL(string: "https://discord.com/api/v10")! private let session: URLSession + private let identitySession: URLSession private var botToken: String? private var ruleEngine: RuleEngine? private var voiceRuleStateStore = VoiceRuleStateStore() @@ -88,23 +89,22 @@ actor DiscordService { typealias HistoryProvider = @Sendable (MemoryScope) async -> [Message] private var historyProvider: HistoryProvider? - /// Dedicated session for Discord identity probes (/users/@me, /oauth2/applications/@me). - /// Short timeout, no caching — token never cached locally. - private static let identitySessionConfig: URLSessionConfiguration = { + private static func makeDefaultIdentitySession() -> URLSession { let c = URLSessionConfiguration.ephemeral c.timeoutIntervalForRequest = 10 c.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData c.urlCache = nil - return c - }() - private let identitySession = URLSession(configuration: DiscordService.identitySessionConfig) + return URLSession(configuration: c) + } init( session: URLSession = URLSession(configuration: .default), + identitySession: URLSession = DiscordService.makeDefaultIdentitySession(), aiService: DiscordAIService? = nil, wikiLookupService: WikiLookupService? = nil ) { self.session = session + self.identitySession = identitySession self.aiService = aiService ?? DiscordAIService(session: session) self.wikiLookupService = wikiLookupService ?? WikiLookupService(session: session) } @@ -152,10 +152,14 @@ actor DiscordService { } private func handleInboundGatewayPayload(_ payload: GatewayPayload) async { - seedChannelTypesIfNeeded(payload) - seedGuildNameIfNeeded(payload) - seedVoiceChannelsIfNeeded(payload) - seedVoiceStateIfNeeded(payload) + // Run independent seed operations in parallel for faster gateway event processing. + // These operate on disjoint state, so no synchronization is needed. + async let seedChannelTypes = seedChannelTypesIfNeeded(payload) + async let seedGuildName = seedGuildNameIfNeeded(payload) + async let seedVoiceChannels = seedVoiceChannelsIfNeeded(payload) + async let seedVoiceState = seedVoiceStateIfNeeded(payload) + // Wait for all seed operations to complete + await (seedChannelTypes, seedGuildName, seedVoiceChannels, seedVoiceState) await processRuleActionsIfNeeded(payload) await onPayload?(payload) } @@ -464,7 +468,7 @@ actor DiscordService { await messageRESTClient.triggerTyping(channelId: channelId, token: token) } - private func seedGuildNameIfNeeded(_ payload: GatewayPayload) { + private func seedGuildNameIfNeeded(_ payload: GatewayPayload) async { guard payload.op == 0, payload.t == "GUILD_CREATE" else { return } guard case let .object(guildMap)? = payload.d, case let .string(guildId)? = guildMap["id"], @@ -473,7 +477,7 @@ actor DiscordService { guildNamesById[guildId] = guildName } - private func seedVoiceChannelsIfNeeded(_ payload: GatewayPayload) { + private func seedVoiceChannelsIfNeeded(_ payload: GatewayPayload) async { guard payload.op == 0, payload.t == "GUILD_CREATE" else { return } guard case let .object(guildMap)? = payload.d, case let .string(guildId)? = guildMap["id"], @@ -499,7 +503,7 @@ actor DiscordService { } } - private func seedChannelTypesIfNeeded(_ payload: GatewayPayload) { + private func seedChannelTypesIfNeeded(_ payload: GatewayPayload) async { guard payload.op == 0 else { return } switch payload.t { case "GUILD_CREATE": @@ -535,7 +539,7 @@ actor DiscordService { } } - private func seedVoiceStateIfNeeded(_ payload: GatewayPayload) { + private func seedVoiceStateIfNeeded(_ payload: GatewayPayload) async { guard payload.op == 0, payload.t == "GUILD_CREATE" else { return } guard case let .object(guildMap)? = payload.d, case let .string(guildId)? = guildMap["id"], diff --git a/SwiftBotApp/Security/CertificateManager.swift b/SwiftBotApp/Security/CertificateManager.swift index 955756a..8473df4 100644 --- a/SwiftBotApp/Security/CertificateManager.swift +++ b/SwiftBotApp/Security/CertificateManager.swift @@ -1,10 +1,12 @@ import Foundation import Crypto +import OSLog import SwiftASN1 import X509 import Darwin actor CertificateManager { + private let logger = Logger(subsystem: "com.swiftbot", category: "security") enum ValidationStatus: String, Sendable { case pending case success @@ -244,10 +246,7 @@ actor CertificateManager { if !normalizedDomain.isEmpty { #if DEBUG - print("HTTPS Validation") - print("Hostname:", normalizedDomain) - print("Detected zone:", detectedZone ?? "Unavailable") - print("Querying Cloudflare zone:", detectedZone ?? "Unavailable") + logger.debug("HTTPS Validation — hostname: \(normalizedDomain), detected zone: \(detectedZone ?? "Unavailable")") #endif } @@ -714,7 +713,7 @@ actor CertificateManager { Task.detached { if await dnsProvider.verifyAPIToken() { #if DEBUG - print("Cloudflare: Token verified successfully in background.") + self.logger.debug("Cloudflare: Token verified successfully in background.") #endif } else { await log("⚠️ Cloudflare: Initial token verification timed out or failed. Certificate issuance will still be attempted.") diff --git a/SwiftBotApp/Security/CloudflareDNSProvider.swift b/SwiftBotApp/Security/CloudflareDNSProvider.swift index 879f0c7..e9c8ab4 100644 --- a/SwiftBotApp/Security/CloudflareDNSProvider.swift +++ b/SwiftBotApp/Security/CloudflareDNSProvider.swift @@ -1,6 +1,9 @@ import Foundation +import OSLog struct CloudflareDNSProvider: Sendable { + private static let logger = Logger(subsystem: "com.swiftbot", category: "security") + private static let cloudflareDebugLoggingEnabled: Bool = { #if DEBUG true @@ -356,7 +359,7 @@ struct CloudflareDNSProvider: Sendable { response = try decoder.decode(CloudflareZoneResponse.self, from: data) } catch { #if DEBUG - print("Cloudflare zone lookup decode error: \(error)") + Self.logger.debug("Cloudflare zone lookup decode error: \(error)") #endif throw Error.apiFailed("Cloudflare zone lookup failed.") } @@ -412,7 +415,7 @@ struct CloudflareDNSProvider: Sendable { response = try decoder.decode(CloudflareDNSResponse.self, from: data) } catch { #if DEBUG - print("Cloudflare DNS record lookup decode error: \(error)") + Self.logger.debug("Cloudflare DNS record lookup decode error: \(error)") #endif throw Error.apiFailed("Cloudflare DNS lookup failed.") } diff --git a/SwiftBotApp/Security/TunnelManager.swift b/SwiftBotApp/Security/TunnelManager.swift index f78bc8e..a5b1c16 100644 --- a/SwiftBotApp/Security/TunnelManager.swift +++ b/SwiftBotApp/Security/TunnelManager.swift @@ -1,4 +1,5 @@ import Foundation +import OSLog struct AdminWebPublicAccessRuntimeStatus: Sendable, Equatable { enum State: String, Sendable { @@ -57,6 +58,7 @@ actor TunnelManager: TunnelProvider { "/opt/homebrew/bin/cloudflared" ] + private let osLogger = Logger(subsystem: "com.swiftbot", category: "security") private var desiredConfiguration: TunnelRuntimeConfiguration? private var process: Process? private var restartTask: Task? @@ -165,7 +167,7 @@ actor TunnelManager: TunnelProvider { if let output = String(data: data, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines), !output.isEmpty { - print("TunnelManager:", output) + self.osLogger.debug("TunnelManager: \(output)") } #endif } diff --git a/SwiftBotApp/Services/DiscordIdentityRESTClient.swift b/SwiftBotApp/Services/DiscordIdentityRESTClient.swift index 0e35c8f..9530b85 100644 --- a/SwiftBotApp/Services/DiscordIdentityRESTClient.swift +++ b/SwiftBotApp/Services/DiscordIdentityRESTClient.swift @@ -3,21 +3,13 @@ import Foundation struct DiscordIdentityRESTClient { static let defaultRestBase = URL(string: "https://discord.com/api/v10")! - static func makeIdentitySession() -> URLSession { - let configuration = URLSessionConfiguration.ephemeral - configuration.timeoutIntervalForRequest = 10 - configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData - configuration.urlCache = nil - return URLSession(configuration: configuration) - } - let session: URLSession let identitySession: URLSession let restBase: URL init( session: URLSession, - identitySession: URLSession = DiscordIdentityRESTClient.makeIdentitySession(), + identitySession: URLSession, restBase: URL = DiscordIdentityRESTClient.defaultRestBase ) { self.session = session From 6dc05a3eed16dc41566ef3d8adef4f741915c27b Mon Sep 17 00:00:00 2001 From: johnwatso Date: Sat, 14 Mar 2026 11:25:35 +1300 Subject: [PATCH 07/11] Phase 3: Extract EventBus types to Models/EventBus.swift --- SwiftBotApp/Models.swift | 2424 ----------------------------- SwiftBotApp/Models/EventBus.swift | 124 ++ 2 files changed, 124 insertions(+), 2424 deletions(-) create mode 100644 SwiftBotApp/Models/EventBus.swift diff --git a/SwiftBotApp/Models.swift b/SwiftBotApp/Models.swift index dca34f2..60ffdf1 100644 --- a/SwiftBotApp/Models.swift +++ b/SwiftBotApp/Models.swift @@ -15,128 +15,6 @@ enum AITestOverrides { } #endif -// MARK: - EventBus System - -/// A marker protocol for events that can be published and subscribed through `EventBus`. -protocol Event {} - -/// A token representing a subscription to an event. -/// Use this token to unsubscribe from the event. -struct SubscriptionToken: Hashable, Identifiable { - let id: UUID - init() { - self.id = UUID() - } -} - -/// A thread-safe event bus supporting typed publish/subscribe with async handlers. -final class EventBus { - private actor Storage { - private var subscribers: [ObjectIdentifier: [SubscriptionToken: (Any) async -> Void]] = [:] - - func add(type: ObjectIdentifier, token: SubscriptionToken, handler: @escaping (Any) async -> Void) { - if subscribers[type] != nil { - subscribers[type]![token] = handler - } else { - subscribers[type] = [token: handler] - } - } - - func remove(token: SubscriptionToken) { - for (key, var dict) in subscribers { - dict[token] = nil - if dict.isEmpty { - subscribers[key] = nil - } else { - subscribers[key] = dict - } - } - } - - func snapshotHandlers(for type: ObjectIdentifier) -> [(Any) async -> Void] { - guard let dict = subscribers[type] else { return [] } - return Array(dict.values) - } - } - - private let storage = Storage() - - /// Subscribes to events of the specified type. - @discardableResult - func subscribe(_ type: E.Type, handler: @escaping (E) async -> Void) async -> SubscriptionToken { - let token = SubscriptionToken() - let wrappedHandler: (Any) async -> Void = { anyEvent in - guard let event = anyEvent as? E else { return } - await handler(event) - } - await storage.add(type: ObjectIdentifier(type), token: token, handler: wrappedHandler) - return token - } - - /// Unsubscribes from an event using the given subscription token. - func unsubscribe(_ token: SubscriptionToken) async { - await storage.remove(token: token) - } - - /// Publishes an event to all subscribers of its type. - func publish(_ event: E) async { - let handlers = await storage.snapshotHandlers(for: ObjectIdentifier(E.self)) - for handler in handlers { - await handler(event) - } - } -} - -/// An event signaling a user has joined a voice channel. -struct VoiceJoined: Event { - let guildId: String - let userId: String - let username: String - let channelId: String - - init(guildId: String, userId: String, username: String, channelId: String) { - self.guildId = guildId - self.userId = userId - self.username = username - self.channelId = channelId - } -} - -/// An event signaling a user has left a voice channel. -struct VoiceLeft: Event { - let guildId: String - let userId: String - let username: String - let channelId: String - let durationSeconds: Int - - init(guildId: String, userId: String, username: String, channelId: String, durationSeconds: Int) { - self.guildId = guildId - self.userId = userId - self.username = username - self.channelId = channelId - self.durationSeconds = durationSeconds - } -} - -/// An event signaling that a message was received. -struct MessageReceived: Event { - let guildId: String? - let channelId: String - let userId: String - let username: String - let content: String - let isDirectMessage: Bool - - init(guildId: String?, channelId: String, userId: String, username: String, content: String, isDirectMessage: Bool) { - self.guildId = guildId - self.channelId = channelId - self.userId = userId - self.username = username - self.content = content - self.isDirectMessage = isDirectMessage - } -} // MARK: - Core Models @@ -1539,534 +1417,6 @@ actor WikiContextCache { } } -enum PatchySourceKind: String, Codable, CaseIterable, Identifiable { - case nvidia = "NVIDIA" - case amd = "AMD" - case intel = "Intel Arc" - case steam = "Steam" - - var id: String { rawValue } -} - -struct PatchyDeliveryTarget: Codable, Hashable, Identifiable { - var id: UUID = UUID() - var isEnabled: Bool = true - var name: String = "Target" - var serverId: String = "" - var channelId: String = "" - var roleIDs: [String] = [] -} - -struct PatchySourceTarget: Codable, Hashable, Identifiable { - var id: UUID = UUID() - var isEnabled: Bool = true - var source: PatchySourceKind = .nvidia - var steamAppID: String = "570" - var serverId: String = "" - var channelId: String = "" - var roleIDs: [String] = [] - var lastCheckedAt: Date? - var lastRunAt: Date? - var lastStatus: String = "Never checked" -} - -struct PatchySettings: Codable, Hashable { - var monitoringEnabled: Bool = false - var showDebug: Bool = false - var sourceTargets: [PatchySourceTarget] = [] - var steamAppNames: [String: String] = [:] - - // Legacy fields kept for migration compatibility. - var source: PatchySourceKind = .nvidia - var steamAppID: String = "570" - var saveAfterFetch: Bool = true - var targets: [PatchyDeliveryTarget] = [] -} - -struct SwiftMeshSettings: Codable, Hashable { - var mode: ClusterMode = .standalone - var nodeName: String = Host.current().localizedName ?? "SwiftBot Node" - var leaderAddress: String = "" - var leaderPort: Int = 38787 - var listenPort: Int = 38787 - var sharedSecret: String = "" - var leaderTerm: Int = 0 -} - -struct MeshSyncedFile: Codable, Hashable { - let fileName: String - let base64Data: String -} - -/// A lightweight snapshot of all user-configurable settings, used to detect unsaved changes in the UI. -struct AppPreferencesSnapshot: Equatable { - // General - var token = "" - var prefix = "/" - var autoStart = false - - // SwiftMesh - var clusterMode: ClusterMode = .standalone - var clusterNodeName = "" - var clusterLeaderAddress = "" - var clusterLeaderPort = 38787 - var clusterListenPort = 38787 - var clusterSharedSecret = "" - var clusterWorkerOffloadEnabled = false - var clusterOffloadAIReplies = false - var clusterOffloadWikiLookups = false - - // Media Library - var mediaSourcesJSON = "" - - // Admin Web UI - var adminWebEnabled = false - var adminWebHost = "" - var adminWebPort = 38888 - var adminWebBaseURL = "" - var adminWebHTTPSEnabled = false - var adminWebCertificateMode: AdminWebUICertificateMode = .automatic - var adminWebHostname = "" - var adminWebCloudflareToken = "" - var adminWebPublicAccessEnabled = false - var adminWebImportedCertificateFile = "" - var adminWebImportedPrivateKeyFile = "" - var adminWebImportedCertificateChainFile = "" - var adminLocalAuthEnabled = false - var adminLocalAuthUsername = "" - var adminLocalAuthPassword = "" - var adminRestrictSpecificUsers = false - var adminDiscordClientID = "" - var adminDiscordClientSecret = "" - var adminAllowedUserIDs = "" - var adminRedirectPath = "" - - // AI Bots - var localAIDMReplyEnabled = false - var useAIInGuildChannels = false - var allowDMs = false - var preferredAIProvider: AIProviderPreference = .apple - var ollamaBaseURL = "" - var ollamaModel = "" - var ollamaEnabled = false - var openAIEnabled = false - var openAIAPIKey = "" - var openAIModel = "" - var openAIImageGenerationEnabled = false - var openAIImageModel = "" - var openAIImageMonthlyLimitPerUser = 0 - var localAISystemPrompt = "" - - // Developer & Bug Auto-Fix - var devFeaturesEnabled = false - var bugAutoFixEnabled = false - var bugAutoFixTriggerEmoji = "🤖" - var bugAutoFixCommandTemplate = "codex exec \"$SWIFTBOT_BUG_PROMPT\"" - var bugAutoFixRepoPath = "" - var bugAutoFixGitBranch = "main" - var bugAutoFixVersionBumpEnabled = true - var bugAutoFixPushEnabled = true - var bugAutoFixRequireApproval = true - var bugAutoFixApproveEmoji = "🚀" - var bugAutoFixRejectEmoji = "🛑" - var bugAutoFixAllowedUsernames = "" -} - -struct MeshSyncedFilesPayload: Codable, Hashable { - let generatedAt: Date - let files: [MeshSyncedFile] -} - -enum ClusterMode: String, Codable, CaseIterable, Identifiable { - case standalone = "Standalone" - case leader = "Leader" - case worker = "Worker" - case standby = "Standby" - - var id: String { rawValue } - - static var selectableCases: [ClusterMode] { - [.standalone, .leader, .standby] - } - - var displayName: String { - switch self { - case .standalone: return "Standalone" - case .leader: return "Primary" - case .worker: return "Worker" - case .standby: return "Fail Over" - } - } - - var description: String { - switch self { - case .standalone: return "Normal operation. All bot features are managed locally." - case .leader: return "This node acts as the Primary node for the SwiftMesh cluster." - case .worker: return "Deprecated. This node performs offloaded compute tasks for the Primary node." - case .standby: return "This node will automatically promote to Primary node if the current Leader fails. (Fail Over node)" - } - } -} - -/// Central authority for Discord output actions in a SwiftMesh cluster. -/// -/// All outbound Discord actions must pass through this gate before execution. -/// Only Primary nodes (`.standalone` or `.leader`) are permitted to perform -/// Discord side-effects. Worker and Standby nodes are blocked at this layer. -/// -/// This design is intentionally extensible: in future, `canSend` can be updated -/// to route blocked actions to a Primary node via SwiftMesh HTTP instead of -/// simply discarding them, enabling distributed task delegation. -enum ActionDispatcher { - - /// Returns `true` if the current node is permitted to send Discord output. - /// - /// - Parameters: - /// - clusterMode: The current SwiftMesh cluster role of this node. - /// - action: A descriptive label for the action being attempted (used in logs). - /// - log: A closure that receives warning messages when an action is blocked. - /// - Returns: `true` if the node may proceed; `false` if the action is blocked. - static func canSend( - clusterMode: ClusterMode, - action: String, - log: (String) -> Void - ) -> Bool { - guard clusterMode == .standalone || clusterMode == .leader else { - log("⚠️ [ActionDispatcher] Blocked '\(action)' — node role '\(clusterMode.rawValue)' is not authorised to send Discord output. Only Primary (Standalone/Leader) may perform Discord side-effects.") - return false - } - return true - } -} - -// MARK: - SwiftMesh Protocol Types (Phase 1) - -/// Sent by the leader to notify workers and standbys that a new leader has taken over. -/// Workers must reject this if `term` is not newer than their current known term. -struct MeshLeaderChangedPayload: Codable, Sendable { - let term: Int - let leaderAddress: String - let leaderNodeName: String - let sharedSecret: String -} - -/// Sent by the leader to the standby to replicate the registered worker list. -struct MeshWorkerRegistryPayload: Codable, Sendable { - struct WorkerEntry: Codable, Sendable { - let nodeName: String - let baseURL: String - let listenPort: Int - } - let workers: [WorkerEntry] - let leaderTerm: Int -} - -/// Incremental conversation sync payload sent leader → standby. -/// Records are ordered by (timestamp ascending, id ascending) for deterministic replay. -struct MeshSyncPayload: Codable, Sendable { - let conversations: [MemoryRecord] - let imageUsage: [String: Int]? - let commandLog: [CommandLogEntry]? - let voiceLog: [VoiceEventLogEntry]? - let activeVoice: [VoiceMemberPresence]? - let configFilesChanged: Bool - let leaderTerm: Int - /// ID of the last record in this batch — standby stores as its new cursor. - let cursorRecordID: String? - /// True if more records exist beyond this batch; standby should request resync for next page. - let hasMore: Bool - /// The cursor the leader assumed this node held when building this batch. - /// Node compares against its own lastMergedRecordID to detect gaps. - let fromCursorRecordID: String? - - init( - conversations: [MemoryRecord], - imageUsage: [String: Int]? = nil, - commandLog: [CommandLogEntry]? = nil, - voiceLog: [VoiceEventLogEntry]? = nil, - activeVoice: [VoiceMemberPresence]? = nil, - configFilesChanged: Bool = false, - leaderTerm: Int, - cursorRecordID: String? = nil, - hasMore: Bool = false, - fromCursorRecordID: String? = nil - ) { - self.conversations = conversations - self.imageUsage = imageUsage - self.commandLog = commandLog - self.voiceLog = voiceLog - self.activeVoice = activeVoice - self.configFilesChanged = configFilesChanged - self.leaderTerm = leaderTerm - self.cursorRecordID = cursorRecordID - self.hasMore = hasMore - self.fromCursorRecordID = fromCursorRecordID - } -} - -/// Standby → leader: request a bounded checkpoint batch starting from a cursor. -struct MeshResyncRequest: Codable, Sendable { - /// ID of the last successfully merged record (nil = start from beginning). - let fromRecordID: String? - let pageSize: Int -} - -/// Leader tracks one cursor per registered node (keyed by node base URL). -/// Persisted to disk so leader restart does not force blind full-replay. -struct ReplicationCursor: Codable, Sendable { - /// The leader term in which this cursor was last updated. - var leaderTerm: Int - /// ID of the last record successfully delivered to this node. - var lastSentRecordID: String? - /// When this cursor was last advanced. - var updatedAt: Date -} - -enum ClusterConnectionState: String { - case inactive - case starting - case listening - case connected - case degraded - case stopped - case failed -} - -enum ClusterJobRoute: String { - case local - case remote - case unavailable -} - -enum ClusterNodeRole: String, Codable, Hashable { - case leader - case worker - - var displayName: String { - rawValue.capitalized - } -} - -enum ClusterNodeConnectionStatus: String, Codable, Hashable { - case connected - case disconnected - case degraded - case starting - case failed - - var displayName: String { - rawValue.capitalized - } -} - -enum ClusterNodeHealthStatus: String, Codable, Hashable { - case healthy - case degraded - case disconnected - - var displayName: String { - switch self { - case .healthy: return "Healthy" - case .degraded: return "Degraded" - case .disconnected: return "Disconnected" - } - } - - init(connectionStatus: ClusterNodeConnectionStatus) { - switch connectionStatus { - case .connected: - self = .healthy - case .starting, .degraded: - self = .degraded - case .failed, .disconnected: - self = .disconnected - } - } - - var connectionStatus: ClusterNodeConnectionStatus { - switch self { - case .healthy: - return .connected - case .degraded: - return .degraded - case .disconnected: - return .disconnected - } - } -} - -extension ClusterConnectionState { - var nodeConnectionStatus: ClusterNodeConnectionStatus { - switch self { - case .connected, .listening: - return .connected - case .starting: - return .starting - case .degraded: - return .degraded - case .failed: - return .failed - case .inactive, .stopped: - return .disconnected - } - } - - var nodeHealthStatus: ClusterNodeHealthStatus { - ClusterNodeHealthStatus(connectionStatus: nodeConnectionStatus) - } -} - -struct ClusterNodeStatus: Identifiable, Codable, Hashable { - var id: String - var hostname: String - var displayName: String - var role: ClusterNodeRole - var hardwareModel: String - var cpu: Double - var mem: Double - var cpuName: String - var physicalMemoryBytes: UInt64 - var uptime: TimeInterval - var latencyMs: Double? - var status: ClusterNodeHealthStatus - var jobsActive: Int - - var hardwareName: String { displayName } - var uptimeSeconds: TimeInterval { uptime } - var connectionStatus: ClusterNodeConnectionStatus { status.connectionStatus } - var connectionStatusText: String { status.displayName } - - init( - id: String, - hostname: String, - displayName: String, - role: ClusterNodeRole, - hardwareModel: String, - cpu: Double, - mem: Double, - cpuName: String = "Unknown CPU", - physicalMemoryBytes: UInt64 = 0, - uptime: TimeInterval, - latencyMs: Double?, - status: ClusterNodeHealthStatus, - jobsActive: Int - ) { - self.id = id - self.hostname = hostname - self.displayName = displayName - self.role = role - self.hardwareModel = hardwareModel - self.cpu = cpu - self.mem = mem - self.cpuName = cpuName - self.physicalMemoryBytes = physicalMemoryBytes - self.uptime = uptime - self.latencyMs = latencyMs - self.status = status - self.jobsActive = jobsActive - } - - private enum CodingKeys: String, CodingKey { - case id - case hostname - case displayName - case role - case hardwareModel - case cpu - case mem - case cpuName - case physicalMemoryBytes - case uptime - case latencyMs - case status - case jobsActive - - // Legacy decode compatibility. - case hardwareName - case uptimeSeconds - case connectionStatus - case connectionStatusText - case cpuPercent - case memoryPercent - case memoryBytes - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString - hostname = try container.decodeIfPresent(String.self, forKey: .hostname) ?? "unknown-host" - displayName = try container.decodeIfPresent(String.self, forKey: .displayName) - ?? (try container.decodeIfPresent(String.self, forKey: .hardwareName) ?? hostname) - role = try container.decodeIfPresent(ClusterNodeRole.self, forKey: .role) ?? .worker - hardwareModel = try container.decodeIfPresent(String.self, forKey: .hardwareModel) ?? "Mac" - cpu = try container.decodeIfPresent(Double.self, forKey: .cpu) - ?? (try container.decodeIfPresent(Double.self, forKey: .cpuPercent) ?? 0) - mem = try container.decodeIfPresent(Double.self, forKey: .mem) - ?? (try container.decodeIfPresent(Double.self, forKey: .memoryPercent) ?? 0) - cpuName = try container.decodeIfPresent(String.self, forKey: .cpuName) ?? "Unknown CPU" - physicalMemoryBytes = try container.decodeIfPresent(UInt64.self, forKey: .physicalMemoryBytes) - ?? (try container.decodeIfPresent(UInt64.self, forKey: .memoryBytes) ?? 0) - uptime = try container.decodeIfPresent(TimeInterval.self, forKey: .uptime) - ?? (try container.decodeIfPresent(TimeInterval.self, forKey: .uptimeSeconds) ?? 0) - latencyMs = try container.decodeIfPresent(Double.self, forKey: .latencyMs) - jobsActive = try container.decodeIfPresent(Int.self, forKey: .jobsActive) ?? 0 - - if let decodedStatus = try container.decodeIfPresent(ClusterNodeHealthStatus.self, forKey: .status) { - status = decodedStatus - } else if let legacyConnection = try container.decodeIfPresent(ClusterNodeConnectionStatus.self, forKey: .connectionStatus) { - status = ClusterNodeHealthStatus(connectionStatus: legacyConnection) - } else { - let legacyText = (try container.decodeIfPresent(String.self, forKey: .connectionStatusText) ?? "").lowercased() - if legacyText.contains("degrad") || legacyText.contains("start") { - status = .degraded - } else if legacyText.contains("disconnect") || legacyText.contains("fail") || legacyText.contains("offline") || legacyText.contains("unavailable") { - status = .disconnected - } else { - status = .healthy - } - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(hostname, forKey: .hostname) - try container.encode(displayName, forKey: .displayName) - try container.encode(role, forKey: .role) - try container.encode(hardwareModel, forKey: .hardwareModel) - try container.encode(cpu, forKey: .cpu) - try container.encode(mem, forKey: .mem) - try container.encode(cpuName, forKey: .cpuName) - try container.encode(physicalMemoryBytes, forKey: .physicalMemoryBytes) - try container.encode(uptime, forKey: .uptime) - try container.encodeIfPresent(latencyMs, forKey: .latencyMs) - try container.encode(status, forKey: .status) - try container.encode(jobsActive, forKey: .jobsActive) - } -} - -struct ClusterStatusResponse: Codable, Hashable { - var mode: ClusterMode - var generatedAt: String - var nodes: [ClusterNodeStatus] -} - -struct ClusterSnapshot: Hashable { - var mode: ClusterMode = .standalone - var nodeName: String = Host.current().localizedName ?? "SwiftBot Node" - var listenPort: Int = 38787 - var leaderAddress: String = "" - var leaderTerm: Int = 0 - var serverState: ClusterConnectionState = .inactive - var workerState: ClusterConnectionState = .inactive - var serverStatusText: String = "Disabled" - var workerStatusText: String = "Local only" - var lastJobRoute: ClusterJobRoute = .local - var lastJobSummary: String = "No remote jobs yet" - var lastJobNode: String = Host.current().localizedName ?? "SwiftBot Node" - var diagnostics: String = "No diagnostics yet" -} - enum BotStatus: String { case stopped case connecting @@ -2487,1777 +1837,3 @@ struct UptimeInfo { } } -struct GatewayPayload: Codable { - let op: Int - let d: DiscordJSON? - let s: Int? - let t: String? -} - -enum DiscordJSON: Codable, Equatable { - case string(String) - case int(Int) - case double(Double) - case bool(Bool) - case object([String: DiscordJSON]) - case array([DiscordJSON]) - case null - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if container.decodeNil() { self = .null } - else if let value = try? container.decode(String.self) { self = .string(value) } - else if let value = try? container.decode(Int.self) { self = .int(value) } - else if let value = try? container.decode(Double.self) { self = .double(value) } - else if let value = try? container.decode(Bool.self) { self = .bool(value) } - else if let value = try? container.decode([String: DiscordJSON].self) { self = .object(value) } - else if let value = try? container.decode([DiscordJSON].self) { self = .array(value) } - else { throw DecodingError.typeMismatch(DiscordJSON.self, .init(codingPath: decoder.codingPath, debugDescription: "Unsupported JSON type")) } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .string(let v): try container.encode(v) - case .int(let v): try container.encode(v) - case .double(let v): try container.encode(v) - case .bool(let v): try container.encode(v) - case .object(let v): try container.encode(v) - case .array(let v): try container.encode(v) - case .null: try container.encodeNil() - } - } -} - -struct VoiceRuleEvent { - enum Kind { - case join - case leave - case move - case message - case memberJoin - case memberLeave - case mediaAdded - } - - let kind: Kind - let guildId: String - let userId: String - let username: String - let channelId: String - let fromChannelId: String? - let toChannelId: String? - let durationSeconds: Int? - let messageContent: String? - let messageId: String? - let mediaFileName: String? - let mediaRelativePath: String? - let mediaSourceName: String? - let mediaNodeName: String? - let triggerMessageId: String? - let triggerChannelId: String? - let triggerGuildId: String - let triggerUserId: String - let isDirectMessage: Bool - let authorIsBot: Bool? - let joinedAt: Date? -} - -@MainActor -final class RuleStore: ObservableObject { - @Published var rules: [Rule] = [] - @Published var selectedRuleID: UUID? - @Published var lastSavedAt: Date? - @Published var isLoading: Bool = false - - private let store = RuleConfigStore() - private var autoSaveTask: Task? - var onPersisted: (@Sendable () async -> Void)? - - init() { - Task { - isLoading = true - let loaded = await store.load() - rules = loaded ?? [] - selectedRuleID = nil - isLoading = false - } - } - - func addNewRule(serverId: String = "", channelId: String = "") { - var rule = Rule.empty() - rule.triggerServerId = serverId - // New rules start empty - users add blocks via Block Library - rules.append(rule) - selectedRuleID = rule.id - scheduleAutoSave() - } - - func deleteRules(at offsets: IndexSet, undoManager: UndoManager?) { - let sortedOffsets = offsets.sorted() - guard !sortedOffsets.isEmpty else { return } - let removed = sortedOffsets.map { ($0, rules[$0]) } - let previousSelection = selectedRuleID - - for index in sortedOffsets.reversed() { - rules.remove(at: index) - } - reseatSelection(previousSelection: previousSelection) - scheduleAutoSave() - - undoManager?.registerUndo(withTarget: self) { target in - target.restoreRules(removed, previousSelection: previousSelection, undoManager: undoManager) - } - } - - func deleteRule(id: UUID, undoManager: UndoManager?) { - guard let idx = rules.firstIndex(where: { $0.id == id }) else { return } - deleteRules(at: IndexSet(integer: idx), undoManager: undoManager) - } - - func save() { - let snapshot = rules - Task { - try? await store.save(snapshot) - lastSavedAt = Date() - await onPersisted?() - } - } - - func reloadFromDisk() async { - isLoading = true - let loaded = await store.load() - rules = loaded ?? [] - if let selected = selectedRuleID, - !rules.contains(where: { $0.id == selected }) { - selectedRuleID = nil - } - isLoading = false - } - - func scheduleAutoSave() { - autoSaveTask?.cancel() - autoSaveTask = Task { - try? await Task.sleep(nanoseconds: 500_000_000) - guard !Task.isCancelled else { return } - save() - } - } - - private func restoreRules(_ removed: [(Int, Rule)], previousSelection: UUID?, undoManager: UndoManager?) { - for (index, rule) in removed.sorted(by: { $0.0 < $1.0 }) { - let insertIndex = min(index, rules.count) - rules.insert(rule, at: insertIndex) - } - selectedRuleID = previousSelection ?? removed.first?.1.id - scheduleAutoSave() - - undoManager?.registerUndo(withTarget: self) { target in - let offsets = IndexSet(removed.map(\.0)) - target.deleteRules(at: offsets, undoManager: undoManager) - } - } - - private func reseatSelection(previousSelection: UUID?) { - guard let previousSelection else { - selectedRuleID = nil - return - } - - if rules.contains(where: { $0.id == previousSelection }) { - selectedRuleID = previousSelection - } else { - selectedRuleID = nil - } - } -} - -/// Context maintained during a single rule execution pipeline -struct PipelineContext: CustomStringConvertible { - var aiResponse: String? - var aiSummary: String? - var aiClassification: String? - var aiEntities: String? - var aiRewrite: String? - var triggerGuildId: String? - var triggerChannelId: String? - var triggerMessageId: String? - var targetChannelId: String? - var targetServerId: String? - var mentionUser: Bool = true - var prependUserMention: Bool = false - var replyToTriggerMessage: Bool = false - var mentionRole: String? - var isDirectMessage: Bool = false - var sendToDM: Bool = false - var eventHandled: Bool = false - - var description: String { - let ai = aiResponse != nil ? "AI(\(aiResponse!.count) chars)" : "nil" - let summary = aiSummary != nil ? "Summary(\(aiSummary!.count) chars)" : "nil" - let target = targetChannelId ?? "default" - let trigger = triggerChannelId ?? "none" - return "[PipelineContext target: \(target), trigger: \(trigger), mentionUser: \(mentionUser), prepend: \(prependUserMention), reply: \(replyToTriggerMessage), role: \(mentionRole ?? "nil"), ai: \(ai), summary: \(summary), handled: \(eventHandled)]" - } -} - -final class RuleEngine { - private var cancellable: AnyCancellable? - private var _activeRules: [Rule] = [] - private let lock = NSLock() - - private var activeRules: [Rule] { - get { - lock.lock() - defer { lock.unlock() } - return _activeRules - } - set { - lock.lock() - _activeRules = newValue - lock.unlock() - } - } - - init(store: RuleStore) { - Task { @MainActor [weak self] in - guard let self else { return } - self.activeRules = store.rules.filter(\.isEnabled) - self.cancellable = store.$rules.sink { [weak self] rules in - self?.activeRules = rules.filter(\.isEnabled) - } - } - } - - func evaluateRules(event: VoiceRuleEvent) -> [Rule] { - activeRules - .filter { rule in matchesTrigger(rule: rule, event: event) && matchesConditions(rule: rule, event: event) } - } - - private func matchesTrigger(rule: Rule, event: VoiceRuleEvent) -> Bool { - guard let trigger = rule.trigger else { return false } - switch (trigger, event.kind) { - case (.userJoinedVoice, .join), - (.userLeftVoice, .leave), - (.userMovedVoice, .move), - (.messageCreated, .message), - (.memberJoined, .memberJoin), - (.mediaAdded, .mediaAdded): - return true - default: - return false - } - } - - private func matchesConditions(rule: Rule, event: VoiceRuleEvent) -> Bool { - for condition in rule.conditions { - if !matches(condition: condition, event: event) { return false } - } - return true - } - - private func matches(condition: Condition, event: VoiceRuleEvent) -> Bool { - let value = condition.value.trimmingCharacters(in: .whitespacesAndNewlines) - switch condition.type { - case .server: - return value.isEmpty || event.guildId == value - case .voiceChannel: - // Voice channel conditions don't apply to member join/leave events — always pass. - if event.kind == .memberJoin || event.kind == .memberLeave { return true } - return value.isEmpty || event.channelId == value || event.fromChannelId == value || event.toChannelId == value - case .usernameContains: - guard !value.isEmpty else { return true } - return event.username.localizedCaseInsensitiveContains(value) - case .minimumDuration: - // Duration conditions don't apply to member join events — always pass. - if event.kind == .memberJoin || event.kind == .memberLeave { return true } - guard let minimum = Int(value), minimum > 0 else { return true } - guard let durationSeconds = event.durationSeconds else { return false } - return durationSeconds >= (minimum * 60) - case .channelIs: - // Channel conditions don't apply to voice events — always pass for now - return value.isEmpty || event.channelId == value - case .channelCategory: - // Channel category matching logic: typically we'd need channel metadata - // For now, treat as placeholder that always passes if not configured - return true - case .userHasRole: - // Role conditions not yet implemented for voice events — always pass - return true - case .userJoinedRecently: - guard let minutes = Int(value), minutes > 0 else { return true } - guard let joinedAt = event.joinedAt else { return false } - return Date().timeIntervalSince(joinedAt) <= Double(minutes * 60) - case .messageContains: - guard !value.isEmpty, let content = event.messageContent else { return true } - return content.localizedCaseInsensitiveContains(value) - case .messageStartsWith: - guard !value.isEmpty, let content = event.messageContent else { return true } - return content.lowercased().hasPrefix(value.lowercased()) - case .messageRegex: - guard !value.isEmpty, let content = event.messageContent else { return true } - // Basic regex matching - returns true on invalid regex to avoid breaking rules - guard let regex = try? NSRegularExpression(pattern: value, options: [.caseInsensitive]) else { return true } - let range = NSRange(content.startIndex..., in: content) - return regex.firstMatch(in: content, options: [], range: range) != nil - case .isDirectMessage: - return event.isDirectMessage - case .isFromBot: - return event.authorIsBot ?? false - case .isFromUser: - // Filter out bot messages if value is empty or "true" - return !(event.authorIsBot ?? false) - case .channelType: - // Channel type matching - placeholder for now - // Would need channel type metadata from Discord - return true - } - } -} - -protocol BotPlugin { - var name: String { get } - func register(on bus: EventBus) async - func unregister(from bus: EventBus) async -} - -final class PluginManager { - private var plugins: [BotPlugin] = [] - private let bus: EventBus - - init(bus: EventBus) { self.bus = bus } - - func add(_ plugin: BotPlugin) async { - plugins.append(plugin) - await plugin.register(on: bus) - } - - func removeAll() async { - for p in plugins { await p.unregister(from: bus) } - plugins.removeAll() - } -} - -final class WeeklySummaryPlugin: BotPlugin { - let name = "WeeklySummary" - - private var tokens: [SubscriptionToken] = [] - private var voiceDurations: [String: Int] = [:] // userId -> accumulated seconds - - init() {} - - func register(on bus: EventBus) async { - let joinToken = await bus.subscribe(VoiceJoined.self) { _ in - // No-op for accumulation; could log here if needed - } - tokens.append(joinToken) - - let leftToken = await bus.subscribe(VoiceLeft.self) { [weak self] event in - guard let self = self else { return } - self.voiceDurations[event.userId, default: 0] += max(0, event.durationSeconds) - } - tokens.append(leftToken) - } - - func unregister(from bus: EventBus) async { - for token in tokens { - await bus.unsubscribe(token) - } - tokens.removeAll() - } - - func snapshotSummary() -> String { - let sortedUsers = voiceDurations.sorted { $0.value > $1.value } - guard !sortedUsers.isEmpty else { - return "No voice activity recorded yet." - } - - let summaryLines = sortedUsers.prefix(5).map { userId, seconds in - let minutes = seconds / 60 - return "\(userId): \(minutes) minute\(minutes == 1 ? "" : "s")" - } - - return "Weekly Voice Summary:\n" + summaryLines.joined(separator: "\n") - } -} - -/// Single owner for AI prompt composition — tone prompt, context enrichment, and message shaping. -/// Both AppModel and DiscordService should go through this to ensure consistent prompt structure. -enum PromptComposer { - static let defaultTonePrompt = - "You are a friendly, casual Discord bot. Keep replies short and conversational — " + - "1 to 3 sentences max unless asked for detail. Use contractions naturally. " + - "Don't restate what the user said. Don't open every reply the same way. " + - "Match the energy of the conversation." - - private static let timeFormatter: DateFormatter = { - let f = DateFormatter() - f.timeStyle = .short - f.dateStyle = .medium - return f - }() - - /// Builds the fully-enriched system prompt string. - static func buildSystemPrompt( - base: String, - serverName: String?, - channelName: String?, - wikiContext: String? - ) -> String { - var prompt = base.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? defaultTonePrompt - : base.trimmingCharacters(in: .whitespacesAndNewlines) - if let wiki = wikiContext, !wiki.isEmpty { - prompt += "\n\n\(wiki)" - } - if let server = serverName, !server.isEmpty { - prompt += "\nServer: \(server)" - } - if let channel = channelName, !channel.isEmpty { - prompt += "\nChannel: \(channel)" - } - prompt += "\nCurrent Time: \(timeFormatter.string(from: Date()))" - return prompt - } - - /// Prepends a system message and filters empty/system-role messages from history. - static func buildMessages(systemPrompt: String, history: [Message]) -> [Message] { - let clean = history.filter { - !$0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - $0.role != .system - } - let systemMessage = Message( - channelID: "system", - userID: "system", - username: "System", - content: systemPrompt, - role: .system - ) - return [systemMessage] + clean - } -} - -/// A simple helper for interacting with the macOS Keychain. -enum KeychainHelper { - private static let service = "com.swiftbot.app" - private static let account = "discord-token" - - /// Saves the token to the Keychain. - @discardableResult - static func saveToken(_ token: String) -> Bool { - save(token, account: account) - } - - @discardableResult - static func save(_ value: String, account: String) -> Bool { - guard let data = value.data(using: .utf8) else { return false } - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecValueData as String: data - ] - - // Delete any existing item before saving the new one. - SecItemDelete(query as CFDictionary) - - let status = SecItemAdd(query as CFDictionary, nil) - return status == errSecSuccess - } - - /// Retrieves the token from the Keychain. - static func loadToken() -> String? { - load(account: account) - } - - static func load(account: String) -> String? { - var query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account - ] - query[kSecReturnData as String] = true - query[kSecMatchLimit as String] = kSecMatchLimitOne - - var dataTypeRef: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) - - if status == errSecSuccess, let data = dataTypeRef as? Data { - return String(data: data, encoding: .utf8) - } - - return nil - } - - /// Deletes the token from the Keychain. - @discardableResult - static func deleteToken() -> Bool { - delete(account: account) - } - - @discardableResult - static func delete(account: String) -> Bool { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account - ] - - let status = SecItemDelete(query as CFDictionary) - return status == errSecSuccess - } -} - -// MARK: - Navigation Models - -enum SidebarItem: String, CaseIterable, Identifiable { - case overview = "Overview" - case patchy = "Patchy" - case voice = "Actions" - case commands = "Commands" - case commandLog = "Command Log" - case wikiBridge = "WikiBridge" - case logs = "Logs" - case aiBots = "AI Bots" - case diagnostics = "Diagnostics" - case swiftMesh = "SwiftMesh" - - var id: String { rawValue } - - var icon: String { - switch self { - case .overview: return "square.grid.2x2.fill" - case .patchy: return "hammer.fill" - case .voice: return "bolt.circle" - case .commands: return "terminal.fill" - case .commandLog: return "list.bullet.clipboard.fill" - case .wikiBridge: return "book.pages.fill" - case .logs: return "list.bullet.clipboard.fill" - case .aiBots: return "sparkles.rectangle.stack.fill" - case .diagnostics: return "waveform.path.ecg" - case .swiftMesh: return "point.3.connected.trianglepath.dotted" - } - } -} - -// MARK: - Automation Models - -// MARK: - Context Variables - -/// Variables available in rule templates based on trigger context -enum ContextVariable: String, CaseIterable, Codable, Hashable { - case user = "{user}" - case userId = "{user.id}" - case username = "{user.name}" - case userNickname = "{user.nickname}" - case userMention = "{user.mention}" - case message = "{message}" - case messageId = "{message.id}" - case channel = "{channel}" - case channelId = "{channel.id}" - case channelName = "{channel.name}" - case guild = "{guild}" - case guildId = "{guild.id}" - case guildName = "{guild.name}" - case voiceChannel = "{voice.channel}" - case voiceChannelId = "{voice.channel.id}" - case reaction = "{reaction}" - case reactionEmoji = "{reaction.emoji}" - case duration = "{duration}" - case memberCount = "{memberCount}" - case aiResponse = "{ai.response}" - case aiSummary = "{ai.summary}" - case aiClassification = "{ai.classification}" - case aiEntities = "{ai.entities}" - case aiRewrite = "{ai.rewrite}" - case mediaFile = "{media.file}" - case mediaPath = "{media.path}" - case mediaSource = "{media.source}" - case mediaNode = "{media.node}" - - var displayName: String { - switch self { - case .user: return "User" - case .userId: return "User ID" - case .username: return "Username" - case .userNickname: return "Nickname" - case .userMention: return "@Mention" - case .message: return "Message Content" - case .messageId: return "Message ID" - case .channel: return "Channel" - case .channelId: return "Channel ID" - case .channelName: return "Channel Name" - case .guild: return "Server" - case .guildId: return "Server ID" - case .guildName: return "Server Name" - case .voiceChannel: return "Voice Channel" - case .voiceChannelId: return "Voice Channel ID" - case .reaction: return "Reaction" - case .reactionEmoji: return "Emoji" - case .duration: return "Duration" - case .memberCount: return "Member Count" - case .aiResponse: return "AI Response" - case .aiSummary: return "AI Summary" - case .aiClassification: return "AI Classification" - case .aiEntities: return "AI Entities" - case .aiRewrite: return "AI Rewrite" - case .mediaFile: return "Media File" - case .mediaPath: return "Media Path" - case .mediaSource: return "Media Source" - case .mediaNode: return "Media Node" - } - } - - var category: String { - switch self { - case .user, .userId, .username, .userNickname, .userMention: - return "User" - case .message, .messageId: - return "Message" - case .channel, .channelId, .channelName: - return "Channel" - case .guild, .guildId, .guildName: - return "Server" - case .voiceChannel, .voiceChannelId: - return "Voice" - case .reaction, .reactionEmoji: - return "Reaction" - case .duration, .memberCount: - return "Other" - case .aiResponse, .aiSummary, .aiClassification, .aiEntities, .aiRewrite: - return "AI" - case .mediaFile, .mediaPath, .mediaSource, .mediaNode: - return "Media" - } - } -} - -extension Set where Element == ContextVariable { - /// Returns a user-friendly description of the required context (Task 1) - var friendlyRequirement: String { - if self.isEmpty { return "" } - - // Priority based on trigger types - if self.contains(where: { $0.category == "Message" || $0.category == "Reaction" }) { - return "a message trigger" - } - if self.contains(where: { $0.category == "Channel" || $0.category == "Voice" }) { - return "a channel event" - } - if self.contains(where: { $0.category == "User" }) { - return "a user trigger" - } - - return "additional context" - } -} - -// MARK: - Discord Permissions - -/// Discord permission flags for validation -enum DiscordPermission: String, CaseIterable, Codable, Hashable { - case createInstantInvite = "CREATE_INSTANT_INVITE" - case kickMembers = "KICK_MEMBERS" - case banMembers = "BAN_MEMBERS" - case administrator = "ADMINISTRATOR" - case manageChannels = "MANAGE_CHANNELS" - case manageGuild = "MANAGE_GUILD" - case addReactions = "ADD_REACTIONS" - case viewAuditLog = "VIEW_AUDIT_LOG" - case prioritySpeaker = "PRIORITY_SPEAKER" - case stream = "STREAM" - case viewChannel = "VIEW_CHANNEL" - case sendMessages = "SEND_MESSAGES" - case sendTTSMessages = "SEND_TTS_MESSAGES" - case manageMessages = "MANAGE_MESSAGES" - case embedLinks = "EMBED_LINKS" - case attachFiles = "ATTACH_FILES" - case readMessageHistory = "READ_MESSAGE_HISTORY" - case mentionEveryone = "MENTION_EVERYONE" - case useExternalEmojis = "USE_EXTERNAL_EMOJIS" - case connect = "CONNECT" - case speak = "SPEAK" - case muteMembers = "MUTE_MEMBERS" - case deafenMembers = "DEAFEN_MEMBERS" - case moveMembers = "MOVE_MEMBERS" - case useVAD = "USE_VAD" - case changeNickname = "CHANGE_NICKNAME" - case manageNicknames = "MANAGE_NICKNAMES" - case manageRoles = "MANAGE_ROLES" - case manageWebhooks = "MANAGE_WEBHOOKS" - case manageEmojis = "MANAGE_EMOJIS_AND_STICKERS" - case useApplicationCommands = "USE_APPLICATION_COMMANDS" - case requestToSpeak = "REQUEST_TO_SPEAK" - case manageEvents = "MANAGE_EVENTS" - case manageThreads = "MANAGE_THREADS" - case createPublicThreads = "CREATE_PUBLIC_THREADS" - case createPrivateThreads = "CREATE_PRIVATE_THREADS" - case useExternalStickers = "USE_EXTERNAL_STICKERS" - case sendMessagesInThreads = "SEND_MESSAGES_IN_THREADS" - case useEmbeddedActivities = "USE_EMBEDDED_ACTIVITIES" - case moderateMembers = "MODERATE_MEMBERS" - - var displayName: String { - switch self { - case .createInstantInvite: return "Create Invite" - case .kickMembers: return "Kick Members" - case .banMembers: return "Ban Members" - case .administrator: return "Administrator" - case .manageChannels: return "Manage Channels" - case .manageGuild: return "Manage Server" - case .addReactions: return "Add Reactions" - case .viewAuditLog: return "View Audit Log" - case .prioritySpeaker: return "Priority Speaker" - case .stream: return "Video/Stream" - case .viewChannel: return "View Channel" - case .sendMessages: return "Send Messages" - case .sendTTSMessages: return "Send TTS" - case .manageMessages: return "Manage Messages" - case .embedLinks: return "Embed Links" - case .attachFiles: return "Attach Files" - case .readMessageHistory: return "Read History" - case .mentionEveryone: return "Mention @everyone" - case .useExternalEmojis: return "Use External Emojis" - case .connect: return "Connect" - case .speak: return "Speak" - case .muteMembers: return "Mute Members" - case .deafenMembers: return "Deafen Members" - case .moveMembers: return "Move Members" - case .useVAD: return "Use Voice Activity" - case .changeNickname: return "Change Nickname" - case .manageNicknames: return "Manage Nicknames" - case .manageRoles: return "Manage Roles" - case .manageWebhooks: return "Manage Webhooks" - case .manageEmojis: return "Manage Emojis" - case .useApplicationCommands: return "Use Commands" - case .requestToSpeak: return "Request to Speak" - case .manageEvents: return "Manage Events" - case .manageThreads: return "Manage Threads" - case .createPublicThreads: return "Create Public Threads" - case .createPrivateThreads: return "Create Private Threads" - case .useExternalStickers: return "Use External Stickers" - case .sendMessagesInThreads: return "Send in Threads" - case .useEmbeddedActivities: return "Use Activities" - case .moderateMembers: return "Timeout Members" - } - } - - var bitValue: UInt64 { - switch self { - case .createInstantInvite: return 1 << 0 - case .kickMembers: return 1 << 1 - case .banMembers: return 1 << 2 - case .administrator: return 1 << 3 - case .manageChannels: return 1 << 4 - case .manageGuild: return 1 << 5 - case .addReactions: return 1 << 6 - case .viewAuditLog: return 1 << 7 - case .prioritySpeaker: return 1 << 8 - case .stream: return 1 << 9 - case .viewChannel: return 1 << 10 - case .sendMessages: return 1 << 11 - case .sendTTSMessages: return 1 << 12 - case .manageMessages: return 1 << 13 - case .embedLinks: return 1 << 14 - case .attachFiles: return 1 << 15 - case .readMessageHistory: return 1 << 16 - case .mentionEveryone: return 1 << 17 - case .useExternalEmojis: return 1 << 18 - case .connect: return 1 << 20 - case .speak: return 1 << 21 - case .muteMembers: return 1 << 22 - case .deafenMembers: return 1 << 23 - case .moveMembers: return 1 << 24 - case .useVAD: return 1 << 25 - case .changeNickname: return 1 << 26 - case .manageNicknames: return 1 << 27 - case .manageRoles: return 1 << 28 - case .manageWebhooks: return 1 << 29 - case .manageEmojis: return 1 << 30 - case .useApplicationCommands: return 1 << 31 - case .requestToSpeak: return 1 << 32 - case .manageEvents: return 1 << 33 - case .manageThreads: return 1 << 34 - case .createPublicThreads: return 1 << 35 - case .createPrivateThreads: return 1 << 36 - case .useExternalStickers: return 1 << 37 - case .sendMessagesInThreads: return 1 << 38 - case .useEmbeddedActivities: return 1 << 39 - case .moderateMembers: return 1 << 40 - } - } -} - -// MARK: - Trigger Types - -enum TriggerType: String, CaseIterable, Identifiable, Codable { - case userJoinedVoice = "Voice Joined" - case userLeftVoice = "Voice Left" - case userMovedVoice = "Voice Moved" - case messageCreated = "Message Created" - case memberJoined = "Member Joined" - case memberLeft = "Member Left" - case reactionAdded = "Reaction Added" - case slashCommand = "Slash Command" - case mediaAdded = "New Media Added" - - var id: String { rawValue } - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - let raw = try container.decode(String.self) - if let match = TriggerType(rawValue: raw) { - self = match - } else if raw == "Message Contains" { - self = .messageCreated - } else if raw == "User Joins Voice" { - self = .userJoinedVoice - } else if raw == "User Leaves Voice" { - self = .userLeftVoice - } else if raw == "User Moves Voice" { - self = .userMovedVoice - } else { - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid TriggerType: \(raw)") - } - } - - var symbol: String { - switch self { - case .userJoinedVoice: return "person.crop.circle.badge.plus" - case .userLeftVoice: return "person.crop.circle.badge.xmark" - case .userMovedVoice: return "arrow.left.arrow.right.circle" - case .messageCreated: return "text.bubble" - case .memberJoined: return "person.badge.plus" - case .memberLeft: return "person.badge.minus" - case .reactionAdded: return "face.smiling" - case .slashCommand: return "slash.circle" - case .mediaAdded: return "video" - } - } - - var defaultMessage: String { - switch self { - case .userJoinedVoice: return "🔊 <@{userId}> connected to <#{channelId}>" - case .userLeftVoice: return "🔌 <@{userId}> disconnected from <#{channelId}> (Online for {duration})" - case .userMovedVoice: return "🔀 <@{userId}> moved from <#{fromChannelId}> to <#{toChannelId}>" - case .messageCreated: return "nm you?" - case .memberJoined: return "👋 Welcome to {server}, {username}! You're member #{memberCount}." - case .memberLeft: return "👋 {username} left the server." - case .reactionAdded: return "👍 Reaction added!" - case .slashCommand: return "Command received!" - case .mediaAdded: return "🎬 New media detected: {media.file}" - } - } - - var defaultRuleName: String { - switch self { - case .userJoinedVoice: return "Join Action" - case .userLeftVoice: return "Leave Action" - case .userMovedVoice: return "Move Action" - case .messageCreated: return "Message Reply" - case .memberJoined: return "Member Join Welcome" - case .memberLeft: return "Member Leave Log" - case .reactionAdded: return "Reaction Handler" - case .slashCommand: return "Command Handler" - case .mediaAdded: return "Media Added" - } - } - - /// Variables provided by this trigger type - var providedVariables: Set { - switch self { - case .userJoinedVoice, .userLeftVoice, .userMovedVoice: - return [.user, .userId, .username, .userMention, .voiceChannel, .voiceChannelId, .guild, .guildId, .guildName, .duration] - case .messageCreated: - return [.user, .userId, .username, .userMention, .message, .messageId, .channel, .channelId, .channelName, .guild, .guildId, .guildName] - case .memberJoined, .memberLeft: - return [.user, .userId, .username, .userMention, .guild, .guildId, .guildName, .memberCount] - case .reactionAdded: - return [.user, .userId, .username, .userMention, .message, .messageId, .channel, .channelId, .reaction, .reactionEmoji, .guild, .guildId] - case .slashCommand: - return [.user, .userId, .username, .userMention, .channel, .channelId, .guild, .guildId, .guildName] - case .mediaAdded: - return [.mediaFile, .mediaPath, .mediaSource, .mediaNode] - } - } - - static var allDefaultMessages: Set { - var messages = Set(allCases.map(\.defaultMessage)) - // Include legacy defaults so trigger changes still auto-populate - messages.insert("🔊 <@{userId}> connected to <#{channelId}>") - messages.insert("🔌 <@{userId}> disconnected from <#{channelId}>") - messages.insert("🔀 <@{userId}> moved from <#{fromChannelId}> to <#{toChannelId}>") - return messages - } -} - -enum ConditionType: String, CaseIterable, Identifiable, Codable { - case server = "Server Is" - case voiceChannel = "Voice Channel Is" - case usernameContains = "Username Contains" - case minimumDuration = "Duration In Channel" - case channelIs = "Channel Is" - case channelCategory = "Channel Category Is" - case userHasRole = "User Has Role" - case userJoinedRecently = "User Joined Recently" - case messageContains = "Message Contains" - case messageStartsWith = "Message Starts With" - case messageRegex = "Message Matches Regex" - case isDirectMessage = "Message Is DM" - case isFromBot = "Message Is From Bot" - case isFromUser = "Message Is From User" - case channelType = "Channel Type Is" - - var id: String { rawValue } - - var symbol: String { - switch self { - case .server: return "building.2" - case .voiceChannel: return "waveform" - case .usernameContains: return "text.magnifyingglass" - case .minimumDuration: return "timer" - case .channelIs: return "number" - case .channelCategory: return "folder" - case .userHasRole: return "person.crop.circle.badge.checkmark" - case .userJoinedRecently: return "clock.arrow.circlepath" - case .messageContains: return "text.quote" - case .messageStartsWith: return "text.alignleft" - case .messageRegex: return "asterisk.circle" - case .isDirectMessage: return "envelope.badge.shield.half.filled" - case .isFromBot: return "bot" - case .isFromUser: return "person" - case .channelType: return "number.square" - } - } - - /// Variables required to evaluate this condition - var requiredVariables: Set { - switch self { - case .server: - return [.guild, .guildId] - case .voiceChannel: - return [.voiceChannel, .voiceChannelId] - case .usernameContains: - return [.user, .username] - case .minimumDuration: - return [.duration] - case .channelIs, .channelCategory: - return [.channel, .channelId] - case .userHasRole, .userJoinedRecently: - return [.user, .userId] - case .messageContains, .messageStartsWith, .messageRegex: - return [.message] - case .isDirectMessage, .isFromBot, .isFromUser: - return [.message, .channel] - case .channelType: - return [.channel, .channelId] - } - } -} - -enum ActionType: String, CaseIterable, Identifiable, Codable { - case sendMessage = "Send Message" - case addLogEntry = "Add Log Entry" - case setStatus = "Set Bot Status" - case sendDM = "Send DM" - case deleteMessage = "Delete Message" - case addReaction = "Add Reaction" - case addRole = "Add Role" - case removeRole = "Remove Role" - case timeoutMember = "Timeout Member" - case kickMember = "Kick Member" - case moveMember = "Move Member" - case createChannel = "Create Channel" - case webhook = "Send Webhook" - case delay = "Delay" - case setVariable = "Set Variable" - case randomChoice = "Random" - - // New Modifier Types - case replyToTrigger = "Reply To Trigger Message" - case mentionUser = "Mention User" - case mentionRole = "Mention Role" - case disableMention = "Disable User Mentions" - case sendToChannel = "Send To Channel" - case sendToDM = "Send To DM" - - // AI Types - case generateAIResponse = "Generate AI Response" - case summariseMessage = "Summarise Message" - case classifyMessage = "Classify Message" - case extractEntities = "Extract Entities" - case rewriteMessage = "Rewrite Message" - - var id: String { rawValue } - - var symbol: String { - switch self { - case .sendMessage: return "paperplane.fill" - case .addLogEntry: return "list.bullet.clipboard" - case .setStatus: return "dot.radiowaves.left.and.right" - case .sendDM: return "envelope.fill" - case .deleteMessage: return "trash.fill" - case .addReaction: return "face.smiling" - case .addRole: return "person.crop.circle.badge.plus" - case .removeRole: return "person.crop.circle.badge.minus" - case .timeoutMember: return "clock.badge.exclamationmark" - case .kickMember: return "door.left.hand.open" - case .moveMember: return "arrow.right.circle" - case .createChannel: return "plus.rectangle" - case .webhook: return "link" - case .delay: return "clock.arrow.circlepath" - case .setVariable: return "character.textbox" - case .randomChoice: return "shuffle" - case .replyToTrigger: return "arrowshape.turn.up.left.fill" - case .mentionUser: return "at" - case .mentionRole: return "at.badge.plus" - case .disableMention: return "at.badge.minus" - case .sendToChannel: return "number.circle.fill" - case .sendToDM: return "envelope.fill" - case .generateAIResponse: return "sparkles" - case .summariseMessage: return "text.alignleft" - case .classifyMessage: return "tag.fill" - case .extractEntities: return "list.bullet.clipboard" - case .rewriteMessage: return "pencil" - } - } - - /// Variables required by this action type - var requiredVariables: Set { - switch self { - case .sendMessage, .sendDM, .setStatus, .addLogEntry, .delay, .setVariable, .randomChoice, .createChannel, .webhook: - return [] - case .deleteMessage, .addReaction, .replyToTrigger: - return [.message, .messageId] - - case .addRole, .removeRole, .timeoutMember, .kickMember, .moveMember, .mentionUser, .disableMention, .sendToDM: - return [.user, .userId] - case .sendToChannel: - return [.channel] - case .generateAIResponse, .mentionRole, .summariseMessage, .classifyMessage, .extractEntities, .rewriteMessage: - return [] - } - } - - /// Variables provided/output by this action type - var outputVariables: Set { - switch self { - case .generateAIResponse: - return [.aiResponse] - case .summariseMessage: - return [.aiSummary] - case .classifyMessage: - return [.aiClassification] - case .extractEntities: - return [.aiEntities] - case .rewriteMessage: - return [.aiRewrite] - case .sendMessage, .sendDM, .deleteMessage, .addReaction, .addRole, - .removeRole, .timeoutMember, .kickMember, .moveMember, .createChannel, .webhook, - .setStatus, .addLogEntry, .delay, .setVariable, .randomChoice, .replyToTrigger, - .mentionUser, .mentionRole, .disableMention, .sendToChannel, .sendToDM: - return [] - } - } - - /// Discord permissions required for this action - var requiredPermissions: Set { - switch self { - case .sendMessage, .sendDM, .addLogEntry, .setStatus, .delay, .setVariable, .randomChoice, .generateAIResponse, .replyToTrigger, .mentionUser, .mentionRole, .disableMention, .sendToChannel, .sendToDM, .summariseMessage, .classifyMessage, .extractEntities, .rewriteMessage: - return [] - case .deleteMessage: - return [.manageMessages] - case .addReaction: - return [.addReactions] - case .addRole, .removeRole: - return [.manageRoles] - case .timeoutMember: - return [.moderateMembers] - case .kickMember: - return [.kickMembers] - case .moveMember: - return [.moveMembers] - case .createChannel: - return [.manageChannels] - case .webhook: - return [.manageWebhooks] - } - } - - /// Category for block library organization - var category: BlockCategory { - switch self { - case .replyToTrigger, .disableMention, .sendToChannel, .sendToDM, .mentionUser, .mentionRole: - return .messaging - case .sendMessage, .sendDM, .addReaction, .deleteMessage, .createChannel, .webhook, - .addLogEntry, .setStatus, .delay, .setVariable, .randomChoice: - return .actions - case .generateAIResponse, .summariseMessage, .classifyMessage, .extractEntities, .rewriteMessage: - return .ai - case .addRole, .removeRole, .timeoutMember, .kickMember, .moveMember: - return .moderation - } - } -} - -/// Block categories for library organization (Task 5) -enum BlockCategory: String, CaseIterable, Identifiable { - case triggers = "Triggers" - case filters = "Filters" - case ai = "AI Blocks" - case messaging = "Message" - case actions = "Actions" - case moderation = "Moderation" - - var id: String { rawValue } - - var symbol: String { - switch self { - case .triggers: return "bolt.fill" - case .filters: return "line.3.horizontal.decrease.circle" - case .ai: return "sparkles" - case .messaging: return "text.bubble.fill" - case .actions: return "paperplane.fill" - case .moderation: return "shield.fill" - } - } -} - -extension ConditionType { - /// Returns true if this condition is compatible with the given trigger (Task 4) - func isCompatible(with trigger: TriggerType?) -> Bool { - guard let trigger = trigger else { return true } // No trigger means everything is potentially visible - return self.requiredVariables.isSubset(of: trigger.providedVariables) - } -} - -extension ActionType { - /// Returns true if this action is compatible with the given trigger (Task 4) - func isCompatible(with trigger: TriggerType?) -> Bool { - guard let trigger = trigger else { return true } - return self.requiredVariables.isSubset(of: trigger.providedVariables) - } -} -struct Condition: Identifiable, Codable, Equatable { - var id = UUID() - var type: ConditionType - var value: String = "" - var secondaryValue: String = "" -} - -struct RuleAction: Identifiable, Codable, Equatable { - var id = UUID() - var type: ActionType = .sendMessage - var serverId: String = "" - var channelId: String = "" - var mentionUser: Bool = true - var replyToTriggerMessage: Bool = false - var replyWithAI: Bool = false - var message: String = "🔊 <@{userId}> connected to <#{channelId}>" - var statusText: String = "Voice notifier active" - - // New fields for extended action types - var dmContent: String = "" // For sendDM - var emoji: String = "👍" // For addReaction - var roleId: String = "" // For addRole/removeRole - var timeoutDuration: Int = 3600 // For timeoutMember (seconds) - var kickReason: String = "" // For kickMember - var targetVoiceChannelId: String = "" // For moveMember - var newChannelName: String = "" // For createChannel - var webhookURL: String = "" // For webhook - var webhookContent: String = "" // For webhook - var delaySeconds: Int = 5 // For delay - var variableName: String = "" // For setVariable - var variableValue: String = "" // For setVariable - var randomOptions: [String] = [] // For randomChoice - var deleteDelaySeconds: Int = 0 // For deleteMessage (delayed delete) - - // AI Processing block fields - var categories: String = "" // For classifyMessage (comma-separated categories) - var entityTypes: String = "" // For extractEntities (comma-separated entity types) - var rewriteStyle: String = "" // For rewriteMessage (style description) - - // Unified Send Message content source (replaces replyWithAI, etc.) - var contentSource: ContentSource = .custom - - // Message destination mode (per UX spec: replyToTrigger, sameChannel, specificChannel) - var destinationMode: MessageDestination? = nil - - enum CodingKeys: String, CodingKey { - case id - case type - case serverId - case channelId - case mentionUser - case replyToTriggerMessage - case replyWithAI - case message - case statusText - // New fields - case dmContent - case emoji - case roleId - case timeoutDuration - case kickReason - case targetVoiceChannelId - case newChannelName - case webhookURL - case webhookContent - case delaySeconds - case variableName - case variableValue - case randomOptions - case deleteDelaySeconds - case categories - case entityTypes - case rewriteStyle - case contentSource - case destinationMode - } - - init() {} - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() - type = try container.decodeIfPresent(ActionType.self, forKey: .type) ?? .sendMessage - serverId = try container.decodeIfPresent(String.self, forKey: .serverId) ?? "" - channelId = try container.decodeIfPresent(String.self, forKey: .channelId) ?? "" - mentionUser = try container.decodeIfPresent(Bool.self, forKey: .mentionUser) ?? true - replyToTriggerMessage = try container.decodeIfPresent(Bool.self, forKey: .replyToTriggerMessage) ?? false - replyWithAI = try container.decodeIfPresent(Bool.self, forKey: .replyWithAI) ?? false - message = try container.decodeIfPresent(String.self, forKey: .message) ?? "🔊 <@{userId}> connected to <#{channelId}>" - statusText = try container.decodeIfPresent(String.self, forKey: .statusText) ?? "Voice notifier active" - // New fields with defaults - dmContent = try container.decodeIfPresent(String.self, forKey: .dmContent) ?? "" - emoji = try container.decodeIfPresent(String.self, forKey: .emoji) ?? "👍" - roleId = try container.decodeIfPresent(String.self, forKey: .roleId) ?? "" - timeoutDuration = try container.decodeIfPresent(Int.self, forKey: .timeoutDuration) ?? 3600 - kickReason = try container.decodeIfPresent(String.self, forKey: .kickReason) ?? "" - targetVoiceChannelId = try container.decodeIfPresent(String.self, forKey: .targetVoiceChannelId) ?? "" - newChannelName = try container.decodeIfPresent(String.self, forKey: .newChannelName) ?? "" - webhookURL = try container.decodeIfPresent(String.self, forKey: .webhookURL) ?? "" - webhookContent = try container.decodeIfPresent(String.self, forKey: .webhookContent) ?? "" - delaySeconds = try container.decodeIfPresent(Int.self, forKey: .delaySeconds) ?? 5 - variableName = try container.decodeIfPresent(String.self, forKey: .variableName) ?? "" - variableValue = try container.decodeIfPresent(String.self, forKey: .variableValue) ?? "" - randomOptions = try container.decodeIfPresent([String].self, forKey: .randomOptions) ?? [] - deleteDelaySeconds = try container.decodeIfPresent(Int.self, forKey: .deleteDelaySeconds) ?? 0 - categories = try container.decodeIfPresent(String.self, forKey: .categories) ?? "" - entityTypes = try container.decodeIfPresent(String.self, forKey: .entityTypes) ?? "" - rewriteStyle = try container.decodeIfPresent(String.self, forKey: .rewriteStyle) ?? "" - - // Decode contentSource with legacy migration - let decodedContentSource = try container.decodeIfPresent(ContentSource.self, forKey: .contentSource) - let decodedReplyWithAI = try container.decodeIfPresent(Bool.self, forKey: .replyWithAI) ?? false - - // Migration: replyWithAI true -> contentSource = aiResponse - if decodedContentSource == nil && decodedReplyWithAI && type == .sendMessage { - contentSource = .aiResponse - } else { - contentSource = decodedContentSource ?? .custom - } - - // Decode destinationMode with legacy migration - let decodedDestinationMode = try container.decodeIfPresent(MessageDestination.self, forKey: .destinationMode) - let decodedReplyToTrigger = try container.decodeIfPresent(Bool.self, forKey: .replyToTriggerMessage) ?? false - let hasExplicitChannel = !(try container.decodeIfPresent(String.self, forKey: .channelId) ?? "").isEmpty - - // Migration logic per UX spec: - // - Existing destinationMode -> keep it - // - Legacy replyToTriggerMessage=true -> replyToTrigger - // - Explicit serverId/channelId -> specificChannel - // - Message trigger + no explicit IDs -> sameChannel (handled in UI defaults) - // - Non-message trigger + no IDs -> specificChannel (conservative default) - if let existingMode = decodedDestinationMode { - destinationMode = existingMode - } else if decodedReplyToTrigger { - destinationMode = .replyToTrigger - } else if hasExplicitChannel { - destinationMode = .specificChannel - } else { - // Default: nil means conservative behavior (will be set by UI based on trigger type) - destinationMode = nil - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - let legacyReplyToTrigger = type == .sendMessage ? (destinationMode == .replyToTrigger) : replyToTriggerMessage - let legacyReplyWithAI = type == .sendMessage ? (contentSource == .aiResponse) : replyWithAI - try container.encode(id, forKey: .id) - try container.encode(type, forKey: .type) - try container.encode(serverId, forKey: .serverId) - try container.encode(channelId, forKey: .channelId) - try container.encode(mentionUser, forKey: .mentionUser) - try container.encode(legacyReplyToTrigger, forKey: .replyToTriggerMessage) - try container.encode(legacyReplyWithAI, forKey: .replyWithAI) - try container.encode(message, forKey: .message) - try container.encode(statusText, forKey: .statusText) - // New fields - try container.encode(dmContent, forKey: .dmContent) - try container.encode(emoji, forKey: .emoji) - try container.encode(roleId, forKey: .roleId) - try container.encode(timeoutDuration, forKey: .timeoutDuration) - try container.encode(kickReason, forKey: .kickReason) - try container.encode(targetVoiceChannelId, forKey: .targetVoiceChannelId) - try container.encode(newChannelName, forKey: .newChannelName) - try container.encode(webhookURL, forKey: .webhookURL) - try container.encode(webhookContent, forKey: .webhookContent) - try container.encode(delaySeconds, forKey: .delaySeconds) - try container.encode(variableName, forKey: .variableName) - try container.encode(variableValue, forKey: .variableValue) - try container.encode(randomOptions, forKey: .randomOptions) - try container.encode(deleteDelaySeconds, forKey: .deleteDelaySeconds) - try container.encode(categories, forKey: .categories) - try container.encode(entityTypes, forKey: .entityTypes) - try container.encode(rewriteStyle, forKey: .rewriteStyle) - try container.encode(contentSource, forKey: .contentSource) - try container.encode(destinationMode, forKey: .destinationMode) - } -} - -/// Content source options for Send Message action -enum ContentSource: String, Codable, CaseIterable { - case custom = "custom" - case aiResponse = "ai.response" - case aiSummary = "ai.summary" - case aiClassification = "ai.classification" - case aiEntities = "ai.entities" - case aiRewrite = "ai.rewrite" - - var displayName: String { - switch self { - case .custom: return "Custom Message" - case .aiResponse: return "AI Response" - case .aiSummary: return "AI Summary" - case .aiClassification: return "AI Classification" - case .aiEntities: return "AI Entities" - case .aiRewrite: return "AI Rewrite" - } - } -} - -/// Destination mode for Send Message action -enum MessageDestination: String, Codable, CaseIterable { - case replyToTrigger = "replyToTrigger" - case sameChannel = "sameChannel" - case specificChannel = "specificChannel" - - var displayName: String { - switch self { - case .replyToTrigger: return "Reply to Trigger" - case .sameChannel: return "Same Channel" - case .specificChannel: return "Specific Channel" - } - } -} - -extension MessageDestination { - static func defaultMode(for trigger: TriggerType?) -> MessageDestination { - switch trigger { - case .messageCreated, .reactionAdded: - return .replyToTrigger - case .slashCommand: - return .sameChannel - case .userJoinedVoice, .userLeftVoice, .userMovedVoice, .memberJoined, .memberLeft, .mediaAdded, .none: - return .specificChannel - } - } - - static func defaultMode(for event: VoiceRuleEvent, context: PipelineContext) -> MessageDestination { - if context.triggerMessageId != nil || event.triggerMessageId != nil { - return .replyToTrigger - } - if context.triggerChannelId != nil || event.triggerChannelId != nil { - return .sameChannel - } - return .specificChannel - } -} - -typealias Action = RuleAction - -struct Rule: Identifiable, Codable, Equatable { - var id: UUID = UUID() - var name: String = "New Action" - var trigger: TriggerType? - var conditions: [Condition] = [] - var modifiers: [RuleAction] = [] - var actions: [RuleAction] = [] - var aiBlocks: [RuleAction] = [] - var isEnabled: Bool = true - - // Legacy trigger properties - preserved for JSON compatibility, migrated to conditions on load - var triggerServerId: String = "" - var triggerVoiceChannelId: String = "" - var triggerMessageContains: String = "" - var replyToDMs: Bool = false - var includeStageChannels: Bool = true - - /// UI state indicating trigger selection is in progress (Validation suspended) - var isEditingTrigger: Bool = false - - /// Memberwise initializer (explicit due to custom Codable conformance) - init( - id: UUID = UUID(), - name: String = "New Action", - trigger: TriggerType? = nil, - conditions: [Condition] = [], - modifiers: [RuleAction] = [], - actions: [RuleAction] = [], - isEnabled: Bool = true, - triggerServerId: String = "", - triggerVoiceChannelId: String = "", - triggerMessageContains: String = "", - replyToDMs: Bool = false, - includeStageChannels: Bool = true, - isEditingTrigger: Bool = false - ) { - self.id = id - self.name = name - self.trigger = trigger - self.conditions = conditions - self.modifiers = modifiers - self.actions = actions - self.isEnabled = isEnabled - self.triggerServerId = triggerServerId - self.triggerVoiceChannelId = triggerVoiceChannelId - self.triggerMessageContains = triggerMessageContains - self.replyToDMs = replyToDMs - self.includeStageChannels = includeStageChannels - self.isEditingTrigger = isEditingTrigger - } - - var isEmptyRule: Bool { - trigger == nil && conditions.isEmpty && actions.isEmpty && modifiers.isEmpty - } - - static func empty() -> Rule { - Rule(trigger: nil, conditions: [], modifiers: [], actions: []) - } - - // MARK: - Codable Migration - - /// Coding keys for Rule - enum CodingKeys: String, CodingKey { - case id, name, trigger, conditions, modifiers, actions, aiBlocks, isEnabled - case triggerServerId, triggerVoiceChannelId, triggerMessageContains, replyToDMs, includeStageChannels - } - - /// Custom decoder that migrates legacy properties and separates AI blocks from actions - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - id = try container.decode(UUID.self, forKey: .id) - name = try container.decode(String.self, forKey: .name) - trigger = try container.decodeIfPresent(TriggerType.self, forKey: .trigger) - conditions = try container.decode([Condition].self, forKey: .conditions) - modifiers = try container.decode([RuleAction].self, forKey: .modifiers) - actions = try container.decode([RuleAction].self, forKey: .actions) - aiBlocks = try container.decodeIfPresent([RuleAction].self, forKey: .aiBlocks) ?? [] - isEnabled = try container.decode(Bool.self, forKey: .isEnabled) - - // Legacy properties - keep for backwards compatibility but migrate to conditions - triggerServerId = try container.decodeIfPresent(String.self, forKey: .triggerServerId) ?? "" - triggerVoiceChannelId = try container.decodeIfPresent(String.self, forKey: .triggerVoiceChannelId) ?? "" - triggerMessageContains = try container.decodeIfPresent(String.self, forKey: .triggerMessageContains) ?? "" - replyToDMs = try container.decodeIfPresent(Bool.self, forKey: .replyToDMs) ?? false - includeStageChannels = try container.decodeIfPresent(Bool.self, forKey: .includeStageChannels) ?? true - - // Migration: Convert legacy trigger properties to filter conditions - // Only add if not already present to avoid duplicates on repeated saves - var migratedConditions: [Condition] = [] - - // Migrate triggerServerId -> Condition.server - if !triggerServerId.isEmpty && !conditions.contains(where: { $0.type == .server }) { - migratedConditions.append(Condition(type: .server, value: triggerServerId)) - } - - // Migrate triggerVoiceChannelId -> Condition.voiceChannel - if !triggerVoiceChannelId.isEmpty && !conditions.contains(where: { $0.type == .voiceChannel }) { - migratedConditions.append(Condition(type: .voiceChannel, value: triggerVoiceChannelId)) - } - - // Migrate triggerMessageContains -> Condition.messageContains - if !triggerMessageContains.isEmpty && triggerMessageContains != "up to?" && !conditions.contains(where: { $0.type == .messageContains }) { - migratedConditions.append(Condition(type: .messageContains, value: triggerMessageContains)) - } - - // Append migrated conditions to existing conditions - if !migratedConditions.isEmpty { - conditions.append(contentsOf: migratedConditions) - } - - // Migration: Move AI blocks from actions to aiBlocks for backwards compatibility - let aiBlockTypes: [ActionType] = [.generateAIResponse, .summariseMessage, .classifyMessage, .extractEntities, .rewriteMessage] - let (aiBlocksFromActions, remainingActions) = actions.reduce(into: ([RuleAction](), [RuleAction]())) { result, action in - if aiBlockTypes.contains(action.type) { - result.0.append(action) - } else { - result.1.append(action) - } - } - if !aiBlocksFromActions.isEmpty { - aiBlocks.append(contentsOf: aiBlocksFromActions) - actions = remainingActions - } - - actions = actions.map { action in - guard action.type == .sendMessage, action.destinationMode == nil else { return action } - var updated = action - if action.replyToTriggerMessage { - updated.destinationMode = .replyToTrigger - } else if !action.channelId.isEmpty || !action.serverId.isEmpty { - updated.destinationMode = .specificChannel - } else { - updated.destinationMode = MessageDestination.defaultMode(for: trigger) - } - return updated - } - } - - /// Provides the full pipeline of blocks for the rule engine in execution order: - /// AI Processing → Message Modifiers → Actions - var processedActions: [RuleAction] { - var pipeline: [RuleAction] = [] - - // 1. AI Processing blocks first - pipeline.append(contentsOf: aiBlocks) - - // 2. Message Modifiers - pipeline.append(contentsOf: modifiers) - - // 3. Actions (excluding AI blocks and extracting embedded modifiers) - for action in actions { - var actionWithModifiers = action - - // Legacy: replyWithAI toggle creates an AI block - if action.type == .sendMessage && action.replyWithAI && action.contentSource == .custom { - var aiBlock = RuleAction() - aiBlock.type = .generateAIResponse - // Insert AI block at the beginning (before modifiers) - pipeline.insert(aiBlock, at: aiBlocks.count) - actionWithModifiers.replyWithAI = false - } - - // Extract reply-to-trigger as a modifier - if action.type == .sendMessage && action.replyToTriggerMessage && action.destinationMode == nil { - var replyBlock = RuleAction() - replyBlock.type = .replyToTrigger - pipeline.append(replyBlock) - actionWithModifiers.replyToTriggerMessage = false - } - - // Extract mention disable as a modifier - if !action.mentionUser { // Default was true in legacy - var disableMentionBlock = RuleAction() - disableMentionBlock.type = .disableMention - pipeline.append(disableMentionBlock) - actionWithModifiers.mentionUser = true // Reset so we don't repeat - } - - pipeline.append(actionWithModifiers) - } - - return pipeline - } - - var triggerSummary: String { - guard let trigger = trigger else { return "No trigger set" } - switch trigger { - case .userJoinedVoice: return "When someone joins voice" - case .userLeftVoice: return "When someone leaves voice" - case .userMovedVoice: return "When someone moves voice" - case .messageCreated: return "When a message is received" - case .memberJoined: return "When a member joins the server" - case .memberLeft: return "When a member leaves the server" - case .reactionAdded: return "When a reaction is added" - case .slashCommand: return "When a slash command is used" - case .mediaAdded: return "When new media is detected" - } - } - - /// Returns any blocks that are incompatible with the current trigger - var incompatibleBlocks: [UUID] { - guard let trigger = trigger else { return [] } - let available = trigger.providedVariables - var ids: [UUID] = [] - - for condition in conditions { - if !condition.type.requiredVariables.isSubset(of: available) { - ids.append(condition.id) - } - } - for modifier in modifiers { - if !modifier.type.requiredVariables.isSubset(of: available) { - ids.append(modifier.id) - } - } - for action in actions { - if !action.type.requiredVariables.isSubset(of: available) { - ids.append(action.id) - } - } - return ids - } - - var validationIssues: [ValidationIssue] { - guard let trigger = trigger, !isEditingTrigger else { - return [] - } - - var issues: [ValidationIssue] = [] - let availableVariables = trigger.providedVariables - - // Check conditions for variable availability - for condition in conditions { - let requiredVars = condition.type.requiredVariables - let missingVars = requiredVars.subtracting(availableVariables) - if !missingVars.isEmpty { - issues.append(.init( - severity: .warning, // Task 1: Use warning style - message: "Requires \(requiredVars.friendlyRequirement)", // Task 1: User-friendly wording - blockType: .condition, - blockId: condition.id - )) - } - } - - // Check modifiers for variable availability and permissions - for modifier in modifiers { - let requiredVars = modifier.type.requiredVariables - let missingVars = requiredVars.subtracting(availableVariables) - if !missingVars.isEmpty { - issues.append(.init( - severity: .warning, // Task 1: Use warning style - message: "Requires \(requiredVars.friendlyRequirement)", // Task 1: User-friendly wording - blockType: .modifier, - blockId: modifier.id - )) - } - - let requiredPerms = modifier.type.requiredPermissions - if !requiredPerms.isEmpty { - issues.append(.init( - severity: .warning, - message: "Requires permissions: \(requiredPerms.map(\.displayName).joined(separator: ", "))", - blockType: .modifier, - blockId: modifier.id - )) - } - } - - // Check actions for variable availability and permissions - for action in actions { - let requiredVars = action.type.requiredVariables - let missingVars = requiredVars.subtracting(availableVariables) - if !missingVars.isEmpty { - issues.append(.init( - severity: .warning, // Task 1: Use warning style - message: "Requires \(requiredVars.friendlyRequirement)", // Task 1: User-friendly wording - blockType: .action, - blockId: action.id - )) - } - - // Task 5: Prevent empty Send Message actions - if action.type == .sendMessage, - action.contentSource == .custom, - action.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - issues.append(.init( - severity: .error, - message: "Message content is required for 'Send Message' actions.", - blockType: .action, - blockId: action.id - )) - } - - if action.type == .sendMessage, - (action.destinationMode ?? MessageDestination.defaultMode(for: trigger)) == .specificChannel, - action.channelId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - issues.append(.init( - severity: .error, - message: "Select a channel when destination is set to 'Specific Channel'.", - blockType: .action, - blockId: action.id - )) - } - - // Check permissions (warnings, not errors - bot may have permissions) - let requiredPerms = action.type.requiredPermissions - if !requiredPerms.isEmpty { - issues.append(.init( - severity: .warning, - message: "Requires permissions: \(requiredPerms.map(\.displayName).joined(separator: ", "))", - blockType: .action, - blockId: action.id - )) - } - } - - // Rule must contain at least one Action - if actions.isEmpty { - issues.append(.init( - severity: .warning, - message: "This rule has no actions and will not produce any output. Add an Action such as “Send Message”.", - blockType: .rule, - blockId: id - )) - } - - return issues - } - - /// Checks if rule has any blocking errors - var hasBlockingErrors: Bool { - validationIssues.contains { $0.severity == .error } - } - - /// Returns just the errors (not warnings) - var validationErrors: [ValidationIssue] { - validationIssues.filter { $0.severity == .error } - } - - /// Returns just the warnings - var validationWarnings: [ValidationIssue] { - validationIssues.filter { $0.severity == .warning } - } -} - -/// Represents a validation issue with a rule -struct ValidationIssue: Identifiable, Hashable { - let id = UUID() - let severity: ValidationSeverity - let message: String - let blockType: BlockType - let blockId: UUID - - enum ValidationSeverity: String, Codable, CaseIterable { - case warning = "Warning" - case error = "Error" - - var icon: String { - switch self { - case .warning: return "exclamationmark.triangle" - case .error: return "xmark.octagon" - } - } - - var color: String { - switch self { - case .warning: return "orange" - case .error: return "red" - } - } - } - - enum BlockType: String, Codable, CaseIterable { - case rule = "Rule" - case trigger = "Trigger" - case condition = "Filter" - case modifier = "Modifier" - case action = "Action" - } -} diff --git a/SwiftBotApp/Models/EventBus.swift b/SwiftBotApp/Models/EventBus.swift new file mode 100644 index 0000000..e1263f6 --- /dev/null +++ b/SwiftBotApp/Models/EventBus.swift @@ -0,0 +1,124 @@ +import Foundation + +// MARK: - EventBus System + +/// A marker protocol for events that can be published and subscribed through `EventBus`. +protocol Event {} + +/// A token representing a subscription to an event. +/// Use this token to unsubscribe from the event. +struct SubscriptionToken: Hashable, Identifiable { + let id: UUID + init() { + self.id = UUID() + } +} + +/// A thread-safe event bus supporting typed publish/subscribe with async handlers. +final class EventBus { + private actor Storage { + private var subscribers: [ObjectIdentifier: [SubscriptionToken: (Any) async -> Void]] = [:] + + func add(type: ObjectIdentifier, token: SubscriptionToken, handler: @escaping (Any) async -> Void) { + if subscribers[type] != nil { + subscribers[type]![token] = handler + } else { + subscribers[type] = [token: handler] + } + } + + func remove(token: SubscriptionToken) { + for (key, var dict) in subscribers { + dict[token] = nil + if dict.isEmpty { + subscribers[key] = nil + } else { + subscribers[key] = dict + } + } + } + + func snapshotHandlers(for type: ObjectIdentifier) -> [(Any) async -> Void] { + guard let dict = subscribers[type] else { return [] } + return Array(dict.values) + } + } + + private let storage = Storage() + + /// Subscribes to events of the specified type. + @discardableResult + func subscribe(_ type: E.Type, handler: @escaping (E) async -> Void) async -> SubscriptionToken { + let token = SubscriptionToken() + let wrappedHandler: (Any) async -> Void = { anyEvent in + guard let event = anyEvent as? E else { return } + await handler(event) + } + await storage.add(type: ObjectIdentifier(type), token: token, handler: wrappedHandler) + return token + } + + /// Unsubscribes from an event using the given subscription token. + func unsubscribe(_ token: SubscriptionToken) async { + await storage.remove(token: token) + } + + /// Publishes an event to all subscribers of its type. + func publish(_ event: E) async { + let handlers = await storage.snapshotHandlers(for: ObjectIdentifier(E.self)) + for handler in handlers { + await handler(event) + } + } +} + +/// An event signaling a user has joined a voice channel. +struct VoiceJoined: Event { + let guildId: String + let userId: String + let username: String + let channelId: String + + init(guildId: String, userId: String, username: String, channelId: String) { + self.guildId = guildId + self.userId = userId + self.username = username + self.channelId = channelId + } +} + +/// An event signaling a user has left a voice channel. +struct VoiceLeft: Event { + let guildId: String + let userId: String + let username: String + let channelId: String + let durationSeconds: Int + + init(guildId: String, userId: String, username: String, channelId: String, durationSeconds: Int) { + self.guildId = guildId + self.userId = userId + self.username = username + self.channelId = channelId + self.durationSeconds = durationSeconds + } +} + +/// An event signaling that a message was received. +struct MessageReceived: Event { + let guildId: String? + let channelId: String + let userId: String + let username: String + let content: String + let isDirectMessage: Bool + + init(guildId: String?, channelId: String, userId: String, username: String, content: String, isDirectMessage: Bool) { + self.guildId = guildId + self.channelId = channelId + self.userId = userId + self.username = username + self.content = content + self.isDirectMessage = isDirectMessage + } +} From 9dab8f531d044a9fdd0251e8d9fef4db8308d4d7 Mon Sep 17 00:00:00 2001 From: johnwatso Date: Sat, 14 Mar 2026 12:39:43 +1300 Subject: [PATCH 08/11] Ongoing Code Improvements --- SwiftBotApp/AppModel+Commands.swift | 2 +- SwiftBotApp/AppModel+Gateway.swift | 12 +- SwiftBotApp/AppModel.swift | 29 +- SwiftBotApp/Models.swift | 1824 --------------------- SwiftBotApp/Models/AIModels.swift | 384 +++++ SwiftBotApp/Models/BotSettings.swift | 987 +++++++++++ SwiftBotApp/Models/BotStateModels.swift | 183 +++ SwiftBotApp/Models/ClusterModels.swift | 541 ++++++ SwiftBotApp/Models/DiscordCache.swift | 251 +++ SwiftBotApp/Models/GatewayModels.swift | 114 ++ SwiftBotApp/Models/KeychainHelper.swift | 72 + SwiftBotApp/Models/RuleEngineModels.swift | 1603 ++++++++++++++++++ extract_eventbus.py | 30 + 13 files changed, 4175 insertions(+), 1857 deletions(-) create mode 100644 SwiftBotApp/Models/AIModels.swift create mode 100644 SwiftBotApp/Models/BotSettings.swift create mode 100644 SwiftBotApp/Models/BotStateModels.swift create mode 100644 SwiftBotApp/Models/ClusterModels.swift create mode 100644 SwiftBotApp/Models/DiscordCache.swift create mode 100644 SwiftBotApp/Models/GatewayModels.swift create mode 100644 SwiftBotApp/Models/KeychainHelper.swift create mode 100644 SwiftBotApp/Models/RuleEngineModels.swift create mode 100644 extract_eventbus.py diff --git a/SwiftBotApp/AppModel+Commands.swift b/SwiftBotApp/AppModel+Commands.swift index 69dfd12..1ea1ec0 100644 --- a/SwiftBotApp/AppModel+Commands.swift +++ b/SwiftBotApp/AppModel+Commands.swift @@ -2115,7 +2115,7 @@ extension AppModel { currentContent: String ) async -> (messages: [Message], wikiContext: String) { let maxHistory = 8 - var recent = await conversationStore.recentMessages(for: scope, limit: maxHistory) + var recent = await conversationStore.recentMessages(in: scope, limit: maxHistory) if !currentContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { recent.append( diff --git a/SwiftBotApp/AppModel+Gateway.swift b/SwiftBotApp/AppModel+Gateway.swift index 3417a02..eca34e7 100644 --- a/SwiftBotApp/AppModel+Gateway.swift +++ b/SwiftBotApp/AppModel+Gateway.swift @@ -88,14 +88,16 @@ extension AppModel { // Idempotent merge. for record in payload.conversations { - await conversationStore.appendIfNotExists( - scope: record.scope, - messageID: record.id, + let message = Message( + id: record.id, + channelID: record.scope.id, userID: record.userID, + username: "", content: record.content, - role: record.role, - timestamp: record.timestamp + timestamp: record.timestamp, + role: record.role ) + await conversationStore.appendIfNotExists(message) } // Merge image usage counts diff --git a/SwiftBotApp/AppModel.swift b/SwiftBotApp/AppModel.swift index c135ead..71dac1b 100644 --- a/SwiftBotApp/AppModel.swift +++ b/SwiftBotApp/AppModel.swift @@ -29,31 +29,6 @@ enum ViewMode: String, Codable, CaseIterable, Identifiable { } } -struct BugAutoFixPendingApproval { - let bugMessageID: String - let channelID: String - let guildID: String - let sourceRepoPath: String - let isolatedRepoPath: String - let branch: String - let updateChannelID: String - let version: String - let build: String -} - -struct BugAutoFixPendingStart { - let bugMessageID: String - let channelID: String - let guildID: String - let sourceRepoPath: String - let isolatedRepoPath: String - let branch: String - let updateChannelID: String - let version: String - let build: String - let requestedByUserID: String -} - private struct AdminWebCertificateRenewalConfiguration: Equatable { let enabled: Bool let domain: String @@ -773,7 +748,7 @@ final class AppModel: ObservableObject { return await self.localMediaFrameResponse(itemID: itemID, atSeconds: seconds) }, conversationFetcher: { [weak self] fromRecordID, limit in - guard let self else { return ([], false) } + guard let self, let fromRecordID else { return ([], false) } return await self.conversationStore.recordsSince(fromRecordID: fromRecordID, limit: limit) }, onPromotion: { [weak self] in @@ -4515,7 +4490,7 @@ final class AppModel: ObservableObject { let currentTerm = await cluster.currentLeaderTerm() for (nodeName, baseURL) in nodes { let cursor = await cluster.currentReplicationCursor(for: nodeName) - let fromID = cursor?.lastSentRecordID + let fromID = cursor?.lastSentRecordID ?? "" let (records, hasMore) = await conversationStore.recordsSince(fromRecordID: fromID, limit: 500) let lastID = records.last?.id let payload = MeshSyncPayload( diff --git a/SwiftBotApp/Models.swift b/SwiftBotApp/Models.swift index 60ffdf1..1d86322 100644 --- a/SwiftBotApp/Models.swift +++ b/SwiftBotApp/Models.swift @@ -1,1828 +1,4 @@ -import Combine import Foundation -import Network -import Security - -#if DEBUG -/// Task-local overrides for AI timing and response behavior in unit tests. -/// Only available in DEBUG builds — release logic must not depend on this enum. -enum AITestOverrides { - @TaskLocal static var softNoticeNs: UInt64? - @TaskLocal static var hardTimeoutNs: UInt64? - @TaskLocal static var typingRefreshNs: UInt64? - @TaskLocal static var replyOverride: String? - @TaskLocal static var replyDelaySeconds: Double = 0 -} -#endif - - -// MARK: - Core Models - -struct GuildSettings: Codable, Hashable { - var notificationChannelId: String? - var ignoredVoiceChannelIds: Set = [] - var monitoredVoiceChannelIds: Set = [] - var notifyOnJoin: Bool = true - var notifyOnLeave: Bool = true - var notifyOnMove: Bool = true - var joinNotificationTemplate: String = "🔊 {username} joined {channelName}" - var leaveNotificationTemplate: String = "🔌 {username} left {channelName}" - var moveNotificationTemplate: String = "🔁 {username} moved: {fromChannelName} → {toChannelName}" -} - -enum AdminWebUICertificateMode: String, Codable, Hashable, CaseIterable, Identifiable { - case automatic - case importCertificate - - var id: String { rawValue } - - var displayName: String { - switch self { - case .automatic: - return "Automatic (Let's Encrypt)" - case .importCertificate: - return "Import Certificate" - } - } -} - -struct OAuthProviderSettings: Codable, Hashable { - var enabled: Bool = false - var clientID: String = "" - var clientSecret: String = "" -} - -struct AdminWebUISettings: Codable, Hashable { - // Internal constants (not user-configurable) - static let defaultBindHost = "127.0.0.1" - static let defaultPort = 38888 - - var enabled: Bool = false - var publicBaseURL: String = "" - var internetAccessEnabled: Bool = false - var hostname: String = "" - var subdomain: String = "swiftbot" - var selectedZoneID: String = "" - var selectedZoneName: String = "" - var cloudflareAPIToken: String = "" - - // Legacy compatibility - always returns fixed values - var bindHost: String { Self.defaultBindHost } - var port: Int { Self.defaultPort } - var httpsEnabled: Bool { false } - var certificateMode: AdminWebUICertificateMode { .automatic } - var publicAccessEnabled: Bool { internetAccessEnabled } - var publicAccessTunnelID: String = "" - var publicAccessTunnelName: String = "" - var publicAccessTunnelAccountID: String = "" - var publicAccessTunnelToken: String = "" - var importedCertificateFile: String = "" - var importedPrivateKeyFile: String = "" - var importedCertificateChainFile: String = "" - - // OAuth Providers (Discord is active, others are placeholders) - var discordOAuth = OAuthProviderSettings() - var appleOAuth = OAuthProviderSettings() - var steamOAuth = OAuthProviderSettings() - var githubOAuth = OAuthProviderSettings() - var localAuthEnabled: Bool = false - var localAuthUsername: String = "admin" - var localAuthPassword: String = "" - - // Legacy compatibility - migrated to oauth providers - var discordClientID: String { discordOAuth.clientID } - var discordClientSecret: String { discordOAuth.clientSecret } - var redirectPath: String = "/auth/discord/callback" - var restrictAccessToSpecificUsers: Bool = false - var allowedUserIDs: [String] = [] - - var normalizedHostname: String { - if !subdomain.isEmpty && !selectedZoneName.isEmpty { - return "\(subdomain.lowercased()).\(selectedZoneName.lowercased())" - } - return normalizeHostname(hostname) - } - - private enum CodingKeys: String, CodingKey { - case enabled - case publicBaseURL - case internetAccessEnabled - case hostname - case subdomain - case selectedZoneID - case selectedZoneName - case cloudflareAPIToken - case publicAccessTunnelID - case publicAccessTunnelName - case publicAccessTunnelAccountID - case publicAccessTunnelToken - case discordOAuth - case appleOAuth - case steamOAuth - case githubOAuth - case localAuthEnabled - case localAuthUsername - case localAuthPassword - case redirectPath - case restrictAccessToSpecificUsers - case allowedUserIDs - // Legacy keys for migration - case bindHost - case port - case httpsEnabled - case certificateMode - case publicAccessEnabled - case importedCertificateFile - case importedPrivateKeyFile - case importedCertificateChainFile - case discordClientID - case discordClientSecret - } - - init() { - self.discordOAuth.enabled = true - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? false - publicBaseURL = try container.decodeIfPresent(String.self, forKey: .publicBaseURL) ?? "" - - // Migration: prefer hostname - hostname = try container.decodeIfPresent(String.self, forKey: .hostname) ?? "" - subdomain = try container.decodeIfPresent(String.self, forKey: .subdomain) ?? "swiftbot" - selectedZoneID = try container.decodeIfPresent(String.self, forKey: .selectedZoneID) ?? "" - selectedZoneName = try container.decodeIfPresent(String.self, forKey: .selectedZoneName) ?? "" - - cloudflareAPIToken = try container.decodeIfPresent(String.self, forKey: .cloudflareAPIToken) ?? "" - - // Migration: internetAccessEnabled replaces publicAccessEnabled - let decodedInternetAccessEnabled = try container.decodeIfPresent(Bool.self, forKey: .internetAccessEnabled) - let decodedPublicAccessEnabled = try container.decodeIfPresent(Bool.self, forKey: .publicAccessEnabled) - internetAccessEnabled = decodedInternetAccessEnabled ?? decodedPublicAccessEnabled ?? false - - publicAccessTunnelID = try container.decodeIfPresent(String.self, forKey: .publicAccessTunnelID) ?? "" - publicAccessTunnelName = try container.decodeIfPresent(String.self, forKey: .publicAccessTunnelName) ?? "" - publicAccessTunnelAccountID = try container.decodeIfPresent(String.self, forKey: .publicAccessTunnelAccountID) ?? "" - publicAccessTunnelToken = try container.decodeIfPresent(String.self, forKey: .publicAccessTunnelToken) ?? "" - - // OAuth Providers - decode or migrate from legacy fields - discordOAuth = try container.decodeIfPresent(OAuthProviderSettings.self, forKey: .discordOAuth) - ?? OAuthProviderSettings( - enabled: (try? container.decodeIfPresent(String.self, forKey: .discordClientID))?.isEmpty == false, - clientID: try container.decodeIfPresent(String.self, forKey: .discordClientID) ?? "", - clientSecret: try container.decodeIfPresent(String.self, forKey: .discordClientSecret) ?? "" - ) - appleOAuth = try container.decodeIfPresent(OAuthProviderSettings.self, forKey: .appleOAuth) ?? OAuthProviderSettings() - steamOAuth = try container.decodeIfPresent(OAuthProviderSettings.self, forKey: .steamOAuth) ?? OAuthProviderSettings() - githubOAuth = try container.decodeIfPresent(OAuthProviderSettings.self, forKey: .githubOAuth) ?? OAuthProviderSettings() - localAuthEnabled = try container.decodeIfPresent(Bool.self, forKey: .localAuthEnabled) ?? false - localAuthUsername = try container.decodeIfPresent(String.self, forKey: .localAuthUsername) ?? "admin" - localAuthPassword = try container.decodeIfPresent(String.self, forKey: .localAuthPassword) ?? "" - - redirectPath = try container.decodeIfPresent(String.self, forKey: .redirectPath) ?? "/auth/discord/callback" - allowedUserIDs = try container.decodeIfPresent([String].self, forKey: .allowedUserIDs) ?? [] - restrictAccessToSpecificUsers = try container.decodeIfPresent(Bool.self, forKey: .restrictAccessToSpecificUsers) - ?? !allowedUserIDs.isEmpty - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(enabled, forKey: .enabled) - try container.encode(publicBaseURL, forKey: .publicBaseURL) - try container.encode(hostname, forKey: .hostname) - try container.encode(subdomain, forKey: .subdomain) - try container.encode(selectedZoneID, forKey: .selectedZoneID) - try container.encode(selectedZoneName, forKey: .selectedZoneName) - try container.encode(cloudflareAPIToken, forKey: .cloudflareAPIToken) - try container.encode(internetAccessEnabled, forKey: .internetAccessEnabled) - try container.encode(publicAccessTunnelID, forKey: .publicAccessTunnelID) - try container.encode(publicAccessTunnelName, forKey: .publicAccessTunnelName) - try container.encode(publicAccessTunnelAccountID, forKey: .publicAccessTunnelAccountID) - try container.encode(publicAccessTunnelToken, forKey: .publicAccessTunnelToken) - try container.encode(importedCertificateFile, forKey: .importedCertificateFile) - try container.encode(importedPrivateKeyFile, forKey: .importedPrivateKeyFile) - try container.encode(importedCertificateChainFile, forKey: .importedCertificateChainFile) - try container.encode(discordOAuth, forKey: .discordOAuth) - try container.encode(appleOAuth, forKey: .appleOAuth) - try container.encode(steamOAuth, forKey: .steamOAuth) - try container.encode(githubOAuth, forKey: .githubOAuth) - try container.encode(localAuthEnabled, forKey: .localAuthEnabled) - try container.encode(localAuthUsername, forKey: .localAuthUsername) - try container.encode(localAuthPassword, forKey: .localAuthPassword) - try container.encode(redirectPath, forKey: .redirectPath) - try container.encode(restrictAccessToSpecificUsers, forKey: .restrictAccessToSpecificUsers) - try container.encode(allowedUserIDs, forKey: .allowedUserIDs) - } - - var normalizedAllowedUserIDs: [String] { - allowedUserIDs - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - } - - var normalizedImportedCertificateFile: String { - importedCertificateFile.trimmingCharacters(in: .whitespacesAndNewlines) - } - - var normalizedImportedPrivateKeyFile: String { - importedPrivateKeyFile.trimmingCharacters(in: .whitespacesAndNewlines) - } - - var normalizedImportedCertificateChainFile: String { - importedCertificateChainFile.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private func normalizeHostname(_ rawValue: String) -> String { - let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return "" } - - if let url = URL(string: trimmed), let host = url.host { - return host.lowercased() - } - - let normalized = trimmed - .trimmingCharacters(in: CharacterSet(charactersIn: "/")) - .replacingOccurrences(of: " ", with: "") - .lowercased() - - if let slashIndex = normalized.firstIndex(of: "/") { - return String(normalized[.. String { - UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() -} - -struct BotSettings: Codable, Hashable { - var token: String = "" - var launchMode: AppLaunchMode = .standaloneBot - var remoteMode = RemoteModeSettings() - var remoteAccessToken: String = generatedRemoteAccessToken() - var prefix: String = "/" - var commandsEnabled: Bool = true - var prefixCommandsEnabled: Bool = true - var slashCommandsEnabled: Bool = true - var bugTrackingEnabled: Bool = true - var disabledCommandKeys: Set = [] - var autoStart: Bool = false - var guildSettings: [String: GuildSettings] = [:] - var clusterMode: ClusterMode = .standalone - var clusterNodeName: String = Host.current().localizedName ?? "SwiftBot Node" - var clusterLeaderAddress: String = "" - var clusterLeaderPort: Int = 38787 - var clusterListenPort: Int = 38787 - var clusterSharedSecret: String = "" - var clusterLeaderTerm: Int = 0 - var clusterWorkerOffloadEnabled: Bool = false - var clusterOffloadAIReplies: Bool = false - var clusterOffloadWikiLookups: Bool = false - - // Local AI reply settings for DMs and guild mentions. - var localAIDMReplyEnabled: Bool = false - var localAIProvider: AIProvider = .appleIntelligence - var preferredAIProvider: AIProviderPreference = .apple - var localAIEndpoint: String = "http://127.0.0.1:1234/v1/chat/completions" - var localAIModel: String = "local-model" - var ollamaBaseURL: String = "http://localhost:11434" - var ollamaEnabled: Bool = true - var openAIEnabled: Bool = true - var openAIAPIKey: String = "" - var openAIModel: String = "gpt-4o-mini" - var openAIImageGenerationEnabled: Bool = true - var openAIImageModel: String = "gpt-image-1" - var openAIImageMonthlyLimitPerUser: Int = 5 - var openAIImageMonthlyHardCap: Int = 100 - var openAIImageUsageByUserMonth: [String: Int] = [:] - var devFeaturesEnabled: Bool = false - var bugAutoFixEnabled: Bool = false - var bugAutoFixTriggerEmoji: String = "🤖" - var bugAutoFixCommandTemplate: String = "codex exec \"$SWIFTBOT_BUG_PROMPT\"" - var bugAutoFixRepoPath: String = "" - var bugAutoFixGitBranch: String = "main" - var bugAutoFixVersionBumpEnabled: Bool = true - var bugAutoFixPushEnabled: Bool = true - var bugAutoFixRequireApproval: Bool = true - var bugAutoFixApproveEmoji: String = "🚀" - var bugAutoFixRejectEmoji: String = "🛑" - var bugAutoFixAllowedUsernames: [String] = [] - var aiMemoryNotes: [AIMemoryNote] = [] - var localAISystemPrompt: String = "You are a friendly, casual Discord bot. Keep replies short and conversational — 1 to 3 sentences max unless asked for detail. Use contractions naturally. Don't restate what the user said. Don't open every reply the same way. Match the energy of the conversation." - var behavior = BotBehaviorSettings() - var wikiBot = WikiBotSettings() - var patchy = PatchySettings() - var help = HelpSettings() - var adminWebUI = AdminWebUISettings() - - var swiftMeshSettings: SwiftMeshSettings { - get { - SwiftMeshSettings( - mode: clusterMode, - nodeName: clusterNodeName, - leaderAddress: clusterLeaderAddress, - leaderPort: clusterLeaderPort, - listenPort: clusterListenPort, - sharedSecret: clusterSharedSecret, - leaderTerm: clusterLeaderTerm - ) - } - set { - clusterMode = newValue.mode - clusterNodeName = newValue.nodeName - clusterLeaderAddress = newValue.leaderAddress - clusterLeaderPort = newValue.leaderPort - clusterListenPort = newValue.listenPort - clusterSharedSecret = newValue.sharedSecret - clusterLeaderTerm = newValue.leaderTerm - } - } - - private enum CodingKeys: String, CodingKey { - case token - case launchMode - case remoteMode - case remoteAccessToken - case prefix - case commandsEnabled - case prefixCommandsEnabled - case slashCommandsEnabled - case bugTrackingEnabled - case disabledCommandKeys - case autoStart - case guildSettings - case clusterMode - case clusterNodeName - case clusterLeaderAddress - case clusterLeaderPort - case clusterWorkerBaseURLLegacy = "clusterWorkerBaseURL" - case clusterListenPort - case clusterSharedSecret - case clusterLeaderTerm - case clusterWorkerOffloadEnabled - case clusterOffloadAIReplies - case clusterOffloadWikiLookups - case localAIDMReplyEnabled - case localAIProvider - case preferredAIProvider - case localAIEndpoint - case localAIModel - case ollamaBaseURL - case ollamaEnabled - case openAIEnabled - case openAIAPIKey - case openAIModel - case openAIImageGenerationEnabled - case openAIImageModel - case openAIImageMonthlyLimitPerUser - case openAIImageMonthlyHardCap - case openAIImageUsageByUserMonth - case devFeaturesEnabled - case bugAutoFixEnabled - case bugAutoFixTriggerEmoji - case bugAutoFixCommandTemplate - case bugAutoFixRepoPath - case bugAutoFixGitBranch - case bugAutoFixVersionBumpEnabled - case bugAutoFixPushEnabled - case bugAutoFixRequireApproval - case bugAutoFixApproveEmoji - case bugAutoFixRejectEmoji - case bugAutoFixAllowedUsernames - case aiMemoryNotes - case localAISystemPrompt - case behavior - case wikiBot - case patchy - case help - case adminWebUI - } - - init() {} - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - token = try container.decodeIfPresent(String.self, forKey: .token) ?? "" - launchMode = try container.decodeIfPresent(AppLaunchMode.self, forKey: .launchMode) ?? .standaloneBot - remoteMode = try container.decodeIfPresent(RemoteModeSettings.self, forKey: .remoteMode) ?? RemoteModeSettings() - remoteAccessToken = try container.decodeIfPresent(String.self, forKey: .remoteAccessToken) ?? generatedRemoteAccessToken() - prefix = try container.decodeIfPresent(String.self, forKey: .prefix) ?? "/" - commandsEnabled = try container.decodeIfPresent(Bool.self, forKey: .commandsEnabled) ?? true - prefixCommandsEnabled = try container.decodeIfPresent(Bool.self, forKey: .prefixCommandsEnabled) ?? true - slashCommandsEnabled = try container.decodeIfPresent(Bool.self, forKey: .slashCommandsEnabled) ?? true - bugTrackingEnabled = try container.decodeIfPresent(Bool.self, forKey: .bugTrackingEnabled) ?? true - disabledCommandKeys = try container.decodeIfPresent(Set.self, forKey: .disabledCommandKeys) ?? [] - autoStart = try container.decodeIfPresent(Bool.self, forKey: .autoStart) ?? false - guildSettings = try container.decodeIfPresent([String: GuildSettings].self, forKey: .guildSettings) ?? [:] - clusterMode = try container.decodeIfPresent(ClusterMode.self, forKey: .clusterMode) ?? .standalone - clusterNodeName = try container.decodeIfPresent(String.self, forKey: .clusterNodeName) ?? (Host.current().localizedName ?? "SwiftBot Node") - clusterLeaderAddress = try container.decodeIfPresent(String.self, forKey: .clusterLeaderAddress) - ?? (try container.decodeIfPresent(String.self, forKey: .clusterWorkerBaseURLLegacy) ?? "") - clusterLeaderPort = try container.decodeIfPresent(Int.self, forKey: .clusterLeaderPort) ?? 38787 - clusterListenPort = try container.decodeIfPresent(Int.self, forKey: .clusterListenPort) ?? 38787 - clusterSharedSecret = try container.decodeIfPresent(String.self, forKey: .clusterSharedSecret) ?? "" - clusterLeaderTerm = try container.decodeIfPresent(Int.self, forKey: .clusterLeaderTerm) ?? 0 - let decodedOffloadAIReplies = try container.decodeIfPresent(Bool.self, forKey: .clusterOffloadAIReplies) ?? false - let decodedOffloadWikiLookups = try container.decodeIfPresent(Bool.self, forKey: .clusterOffloadWikiLookups) ?? false - clusterWorkerOffloadEnabled = try container.decodeIfPresent(Bool.self, forKey: .clusterWorkerOffloadEnabled) - ?? (decodedOffloadAIReplies || decodedOffloadWikiLookups) - clusterOffloadAIReplies = decodedOffloadAIReplies - clusterOffloadWikiLookups = decodedOffloadWikiLookups - localAIDMReplyEnabled = try container.decodeIfPresent(Bool.self, forKey: .localAIDMReplyEnabled) ?? false - localAIProvider = try container.decodeIfPresent(AIProvider.self, forKey: .localAIProvider) ?? .appleIntelligence - preferredAIProvider = try container.decodeIfPresent(AIProviderPreference.self, forKey: .preferredAIProvider) ?? .apple - localAIEndpoint = try container.decodeIfPresent(String.self, forKey: .localAIEndpoint) ?? "http://127.0.0.1:1234/v1/chat/completions" - localAIModel = try container.decodeIfPresent(String.self, forKey: .localAIModel) ?? "local-model" - ollamaBaseURL = try container.decodeIfPresent(String.self, forKey: .ollamaBaseURL) ?? "http://localhost:11434" - ollamaEnabled = try container.decodeIfPresent(Bool.self, forKey: .ollamaEnabled) ?? true - openAIEnabled = try container.decodeIfPresent(Bool.self, forKey: .openAIEnabled) ?? true - openAIAPIKey = try container.decodeIfPresent(String.self, forKey: .openAIAPIKey) ?? "" - openAIModel = try container.decodeIfPresent(String.self, forKey: .openAIModel) ?? "gpt-4o-mini" - openAIImageGenerationEnabled = try container.decodeIfPresent(Bool.self, forKey: .openAIImageGenerationEnabled) ?? true - openAIImageModel = try container.decodeIfPresent(String.self, forKey: .openAIImageModel) ?? "gpt-image-1" - openAIImageMonthlyLimitPerUser = try container.decodeIfPresent(Int.self, forKey: .openAIImageMonthlyLimitPerUser) ?? 5 - openAIImageMonthlyHardCap = try container.decodeIfPresent(Int.self, forKey: .openAIImageMonthlyHardCap) ?? 100 - openAIImageUsageByUserMonth = try container.decodeIfPresent([String: Int].self, forKey: .openAIImageUsageByUserMonth) ?? [:] - devFeaturesEnabled = try container.decodeIfPresent(Bool.self, forKey: .devFeaturesEnabled) ?? false - bugAutoFixEnabled = try container.decodeIfPresent(Bool.self, forKey: .bugAutoFixEnabled) ?? false - bugAutoFixTriggerEmoji = try container.decodeIfPresent(String.self, forKey: .bugAutoFixTriggerEmoji) ?? "🤖" - bugAutoFixCommandTemplate = try container.decodeIfPresent(String.self, forKey: .bugAutoFixCommandTemplate) ?? "codex exec \"$SWIFTBOT_BUG_PROMPT\"" - bugAutoFixRepoPath = try container.decodeIfPresent(String.self, forKey: .bugAutoFixRepoPath) ?? "" - bugAutoFixGitBranch = try container.decodeIfPresent(String.self, forKey: .bugAutoFixGitBranch) ?? "main" - bugAutoFixVersionBumpEnabled = try container.decodeIfPresent(Bool.self, forKey: .bugAutoFixVersionBumpEnabled) ?? true - bugAutoFixPushEnabled = try container.decodeIfPresent(Bool.self, forKey: .bugAutoFixPushEnabled) ?? true - bugAutoFixRequireApproval = try container.decodeIfPresent(Bool.self, forKey: .bugAutoFixRequireApproval) ?? true - bugAutoFixApproveEmoji = try container.decodeIfPresent(String.self, forKey: .bugAutoFixApproveEmoji) ?? "🚀" - bugAutoFixRejectEmoji = try container.decodeIfPresent(String.self, forKey: .bugAutoFixRejectEmoji) ?? "🛑" - bugAutoFixAllowedUsernames = try container.decodeIfPresent([String].self, forKey: .bugAutoFixAllowedUsernames) ?? [] - aiMemoryNotes = try container.decodeIfPresent([AIMemoryNote].self, forKey: .aiMemoryNotes) ?? [] - localAISystemPrompt = try container.decodeIfPresent(String.self, forKey: .localAISystemPrompt) ?? "You are a friendly, casual Discord bot. Keep replies short and conversational — 1 to 3 sentences max unless asked for detail. Use contractions naturally. Don't restate what the user said. Don't open every reply the same way. Match the energy of the conversation." - behavior = try container.decodeIfPresent(BotBehaviorSettings.self, forKey: .behavior) ?? BotBehaviorSettings() - wikiBot = try container.decodeIfPresent(WikiBotSettings.self, forKey: .wikiBot) ?? WikiBotSettings() - patchy = try container.decodeIfPresent(PatchySettings.self, forKey: .patchy) ?? PatchySettings() - help = try container.decodeIfPresent(HelpSettings.self, forKey: .help) ?? HelpSettings() - adminWebUI = try container.decodeIfPresent(AdminWebUISettings.self, forKey: .adminWebUI) ?? AdminWebUISettings() - remoteMode.normalize() - remoteAccessToken = remoteAccessToken.trimmingCharacters(in: .whitespacesAndNewlines) - if remoteAccessToken.isEmpty { - remoteAccessToken = generatedRemoteAccessToken() - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(token, forKey: .token) - try container.encode(launchMode, forKey: .launchMode) - try container.encode(remoteMode, forKey: .remoteMode) - try container.encode(remoteAccessToken, forKey: .remoteAccessToken) - try container.encode(prefix, forKey: .prefix) - try container.encode(commandsEnabled, forKey: .commandsEnabled) - try container.encode(prefixCommandsEnabled, forKey: .prefixCommandsEnabled) - try container.encode(slashCommandsEnabled, forKey: .slashCommandsEnabled) - try container.encode(bugTrackingEnabled, forKey: .bugTrackingEnabled) - try container.encode(disabledCommandKeys, forKey: .disabledCommandKeys) - try container.encode(autoStart, forKey: .autoStart) - try container.encode(guildSettings, forKey: .guildSettings) - try container.encode(clusterMode, forKey: .clusterMode) - try container.encode(clusterNodeName, forKey: .clusterNodeName) - try container.encode(clusterLeaderAddress, forKey: .clusterLeaderAddress) - try container.encode(clusterListenPort, forKey: .clusterListenPort) - try container.encode(clusterSharedSecret, forKey: .clusterSharedSecret) - try container.encode(clusterLeaderTerm, forKey: .clusterLeaderTerm) - try container.encode(clusterWorkerOffloadEnabled, forKey: .clusterWorkerOffloadEnabled) - try container.encode(clusterOffloadAIReplies, forKey: .clusterOffloadAIReplies) - try container.encode(clusterOffloadWikiLookups, forKey: .clusterOffloadWikiLookups) - try container.encode(localAIDMReplyEnabled, forKey: .localAIDMReplyEnabled) - - try container.encode(localAIProvider, forKey: .localAIProvider) - try container.encode(preferredAIProvider, forKey: .preferredAIProvider) - try container.encode(localAIEndpoint, forKey: .localAIEndpoint) - try container.encode(localAIModel, forKey: .localAIModel) - try container.encode(ollamaBaseURL, forKey: .ollamaBaseURL) - try container.encode(ollamaEnabled, forKey: .ollamaEnabled) - try container.encode(openAIEnabled, forKey: .openAIEnabled) - try container.encode(openAIAPIKey, forKey: .openAIAPIKey) - try container.encode(openAIModel, forKey: .openAIModel) - try container.encode(openAIImageGenerationEnabled, forKey: .openAIImageGenerationEnabled) - try container.encode(openAIImageModel, forKey: .openAIImageModel) - try container.encode(openAIImageMonthlyLimitPerUser, forKey: .openAIImageMonthlyLimitPerUser) - try container.encode(openAIImageMonthlyHardCap, forKey: .openAIImageMonthlyHardCap) - try container.encode(openAIImageUsageByUserMonth, forKey: .openAIImageUsageByUserMonth) - try container.encode(devFeaturesEnabled, forKey: .devFeaturesEnabled) - try container.encode(bugAutoFixEnabled, forKey: .bugAutoFixEnabled) - try container.encode(bugAutoFixTriggerEmoji, forKey: .bugAutoFixTriggerEmoji) - try container.encode(bugAutoFixCommandTemplate, forKey: .bugAutoFixCommandTemplate) - try container.encode(bugAutoFixRepoPath, forKey: .bugAutoFixRepoPath) - try container.encode(bugAutoFixGitBranch, forKey: .bugAutoFixGitBranch) - try container.encode(bugAutoFixVersionBumpEnabled, forKey: .bugAutoFixVersionBumpEnabled) - try container.encode(bugAutoFixPushEnabled, forKey: .bugAutoFixPushEnabled) - try container.encode(bugAutoFixRequireApproval, forKey: .bugAutoFixRequireApproval) - try container.encode(bugAutoFixApproveEmoji, forKey: .bugAutoFixApproveEmoji) - try container.encode(bugAutoFixRejectEmoji, forKey: .bugAutoFixRejectEmoji) - try container.encode(bugAutoFixAllowedUsernames, forKey: .bugAutoFixAllowedUsernames) - try container.encode(aiMemoryNotes, forKey: .aiMemoryNotes) - try container.encode(localAISystemPrompt, forKey: .localAISystemPrompt) - try container.encode(behavior, forKey: .behavior) - try container.encode(wikiBot, forKey: .wikiBot) - try container.encode(patchy, forKey: .patchy) - try container.encode(help, forKey: .help) - try container.encode(adminWebUI, forKey: .adminWebUI) - } -} - -struct BotBehaviorSettings: Codable, Hashable { - var allowDMs: Bool = false - var useAIInGuildChannels: Bool = true - - // Member join welcome (P0.5) - var memberJoinWelcomeEnabled: Bool = false - var memberJoinWelcomeChannelId: String = "" - var memberJoinWelcomeTemplate: String = "👋 Welcome {username} to **{server}**!" - - // Voice activity log — global fallback channel when no per-guild channel is set (P0.5) - var voiceActivityLogEnabled: Bool = false - var voiceActivityLogChannelId: String = "" -} - -struct WikiCommand: Codable, Hashable, Identifiable { - var id: UUID = UUID() - var trigger: String = "!wiki" - var endpoint: String = "search" - var description: String = "" - var enabled: Bool = true - - private enum CodingKeys: String, CodingKey { - case id - case trigger - case endpoint - case description - case enabled - } - - init( - id: UUID = UUID(), - trigger: String = "!wiki", - endpoint: String = "search", - description: String = "", - enabled: Bool = true - ) { - self.id = id - self.trigger = trigger - self.endpoint = endpoint - self.description = description - self.enabled = enabled - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() - trigger = try container.decodeIfPresent(String.self, forKey: .trigger) ?? "!wiki" - endpoint = try container.decodeIfPresent(String.self, forKey: .endpoint) ?? "search" - description = try container.decodeIfPresent(String.self, forKey: .description) ?? "" - enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? true - } -} - -struct WikiFormatting: Codable, Hashable { - var includeStatBlocks: Bool = true - var useEmbeds: Bool = false - var compactMode: Bool = false -} - -struct WikiParsingRule: Codable, Hashable, Identifiable { - var id: UUID = UUID() - var pageType: String = "weapon" - var templateName: String = "Weapon" -} - -struct WikiSource: Codable, Hashable, Identifiable { - var id: UUID = UUID() - var name: String = "Wiki Source" - var baseURL: String = "https://example.fandom.com" - var apiPath: String = "/api.php" - var enabled: Bool = true - var isPrimary: Bool = false - var commands: [WikiCommand] = [] - var formatting: WikiFormatting = WikiFormatting() - var parsingRules: [WikiParsingRule] = [] - var lastLookupAt: Date? - var lastStatus: String = "Never used" - - init( - id: UUID = UUID(), - name: String = "Wiki Source", - baseURL: String = "https://example.fandom.com", - apiPath: String = "/api.php", - enabled: Bool = true, - isPrimary: Bool = false, - commands: [WikiCommand] = [], - formatting: WikiFormatting = WikiFormatting(), - parsingRules: [WikiParsingRule] = [], - lastLookupAt: Date? = nil, - lastStatus: String = "Never used" - ) { - self.id = id - self.name = name - self.baseURL = baseURL - self.apiPath = apiPath - self.enabled = enabled - self.isPrimary = isPrimary - self.commands = commands - self.formatting = formatting - self.parsingRules = parsingRules - self.lastLookupAt = lastLookupAt - self.lastStatus = lastStatus - } - - static func defaultFinals() -> WikiSource { - WikiSource( - id: UUID(), - name: "THE FINALS Wiki", - baseURL: "https://www.thefinals.wiki", - apiPath: "/api.php", - enabled: true, - isPrimary: true, - commands: [ - WikiCommand(trigger: "!wiki", endpoint: "search", description: "Search wiki pages", enabled: true), - WikiCommand(trigger: "!weapon", endpoint: "weaponPage", description: "Lookup weapon stats", enabled: true), - WikiCommand(trigger: "!finals", endpoint: "search", description: "Search THE FINALS wiki", enabled: true) - ], - formatting: WikiFormatting( - includeStatBlocks: true, - useEmbeds: false, - compactMode: false - ), - parsingRules: [ - WikiParsingRule(pageType: "weapon", templateName: "Weapon") - ], - lastLookupAt: nil, - lastStatus: "Ready" - ) - } - - static func genericTemplate() -> WikiSource { - WikiSource( - id: UUID(), - name: "New Wiki", - baseURL: "https://example.fandom.com", - apiPath: "/api.php", - enabled: true, - isPrimary: false, - commands: [ - WikiCommand(trigger: "!wiki", endpoint: "search", description: "Search wiki pages", enabled: true) - ], - formatting: WikiFormatting( - includeStatBlocks: false, - useEmbeds: false, - compactMode: false - ), - parsingRules: [], - lastLookupAt: nil, - lastStatus: "Ready" - ) - } - - private enum CodingKeys: String, CodingKey { - case id - case name - case baseURL - case apiPath - case enabled - case isPrimary - case commands - case formatting - case parsingRules - case lastLookupAt - case lastStatus - // Legacy key - case isEnabled - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() - name = try container.decodeIfPresent(String.self, forKey: .name) ?? "Wiki Source" - baseURL = try container.decodeIfPresent(String.self, forKey: .baseURL) ?? "https://example.fandom.com" - apiPath = try container.decodeIfPresent(String.self, forKey: .apiPath) ?? "/api.php" - enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) - ?? (try container.decodeIfPresent(Bool.self, forKey: .isEnabled)) - ?? true - isPrimary = try container.decodeIfPresent(Bool.self, forKey: .isPrimary) ?? false - commands = try container.decodeIfPresent([WikiCommand].self, forKey: .commands) ?? [] - formatting = try container.decodeIfPresent(WikiFormatting.self, forKey: .formatting) ?? WikiFormatting() - parsingRules = try container.decodeIfPresent([WikiParsingRule].self, forKey: .parsingRules) ?? [] - lastLookupAt = try container.decodeIfPresent(Date.self, forKey: .lastLookupAt) - lastStatus = try container.decodeIfPresent(String.self, forKey: .lastStatus) ?? "Never used" - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(name, forKey: .name) - try container.encode(baseURL, forKey: .baseURL) - try container.encode(apiPath, forKey: .apiPath) - try container.encode(enabled, forKey: .enabled) - try container.encode(isPrimary, forKey: .isPrimary) - try container.encode(commands, forKey: .commands) - try container.encode(formatting, forKey: .formatting) - try container.encode(parsingRules, forKey: .parsingRules) - try container.encodeIfPresent(lastLookupAt, forKey: .lastLookupAt) - try container.encode(lastStatus, forKey: .lastStatus) - } -} - -private struct LegacyWikiBridgeSourceTarget: Decodable { - enum LegacyKind: String, Decodable { - case finals = "THE FINALS" - case mediaWiki = "MediaWiki" - } - - var id: UUID? - var isEnabled: Bool? - var name: String? - var kind: LegacyKind? - var baseURL: String? - var apiPath: String? - var lastLookupAt: Date? - var lastStatus: String? -} - -struct WikiBotSettings: Codable, Hashable { - var isEnabled: Bool = true - var sources: [WikiSource] = [] - - private enum CodingKeys: String, CodingKey { - case isEnabled - case sources - // Legacy key - case defaultSourceID - // Legacy keys - case allowFinalsCommand - case allowWikiAlias - case allowWeaponCommand - case includeWeaponStats - case sourceTargets - } - - init() { - let defaultSource = WikiSource.defaultFinals() - sources = [defaultSource] - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true - - let allowFinalsCommand = try container.decodeIfPresent(Bool.self, forKey: .allowFinalsCommand) ?? true - let allowWikiAlias = try container.decodeIfPresent(Bool.self, forKey: .allowWikiAlias) ?? true - let allowWeaponCommand = try container.decodeIfPresent(Bool.self, forKey: .allowWeaponCommand) ?? true - let includeWeaponStats = try container.decodeIfPresent(Bool.self, forKey: .includeWeaponStats) ?? true - - if let decodedSources = try container.decodeIfPresent([WikiSource].self, forKey: .sources) { - sources = decodedSources - } else if let legacyTargets = try container.decodeIfPresent([LegacyWikiBridgeSourceTarget].self, forKey: .sourceTargets) { - sources = Self.sourcesFromLegacyTargets( - legacyTargets, - allowFinalsCommand: allowFinalsCommand, - allowWikiAlias: allowWikiAlias, - allowWeaponCommand: allowWeaponCommand, - includeWeaponStats: includeWeaponStats - ) - } else { - sources = [] - } - - let legacyPrimaryID = try container.decodeIfPresent(UUID.self, forKey: .defaultSourceID) - if let legacyPrimaryID, !sources.contains(where: { $0.isPrimary }) { - sources = sources.map { source in - var updated = source - updated.isPrimary = source.id == legacyPrimaryID - return updated - } - } - normalizeSources() - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(isEnabled, forKey: .isEnabled) - try container.encode(sources, forKey: .sources) - } - - mutating func normalizeSources() { - if sources.isEmpty { - let defaultSource = WikiSource.defaultFinals() - sources = [defaultSource] - return - } - - sources = sources.map { source in - var updated = source - updated.name = source.name.trimmingCharacters(in: .whitespacesAndNewlines) - updated.baseURL = source.baseURL.trimmingCharacters(in: .whitespacesAndNewlines) - updated.apiPath = source.apiPath.trimmingCharacters(in: .whitespacesAndNewlines) - updated.commands = source.commands.map { command in - var normalized = command - normalized.trigger = command.trigger.trimmingCharacters(in: .whitespacesAndNewlines) - normalized.endpoint = command.endpoint.trimmingCharacters(in: .whitespacesAndNewlines) - normalized.description = command.description.trimmingCharacters(in: .whitespacesAndNewlines) - return normalized - } - updated.parsingRules = source.parsingRules.map { rule in - var normalized = rule - normalized.pageType = rule.pageType.trimmingCharacters(in: .whitespacesAndNewlines) - normalized.templateName = rule.templateName.trimmingCharacters(in: .whitespacesAndNewlines) - return normalized - } - if updated.name.isEmpty { - updated.name = "Wiki Source" - } - if updated.baseURL.isEmpty { - updated.baseURL = "https://example.fandom.com" - } - if updated.apiPath.isEmpty { - updated.apiPath = "/api.php" - } - if updated.commands.isEmpty { - updated.commands = [WikiCommand(trigger: "!wiki", endpoint: "search", description: "Search wiki pages", enabled: true)] - } - return updated - } - - let primaryID: UUID? = { - if let primaryEnabled = sources.first(where: { $0.isPrimary && $0.enabled }) { - return primaryEnabled.id - } - if let firstEnabled = sources.first(where: { $0.enabled }) { - return firstEnabled.id - } - if let explicitPrimary = sources.first(where: { $0.isPrimary }) { - return explicitPrimary.id - } - return sources.first?.id - }() - - if let primaryID { - sources = sources.map { source in - var updated = source - updated.isPrimary = source.id == primaryID - return updated - } - } - } - - mutating func setPrimarySource(_ sourceID: UUID) { - guard sources.contains(where: { $0.id == sourceID }) else { return } - sources = sources.map { source in - var updated = source - updated.isPrimary = source.id == sourceID - return updated - } - normalizeSources() - } - - func primarySource() -> WikiSource? { - if let primaryEnabled = sources.first(where: { $0.isPrimary && $0.enabled }) { - return primaryEnabled - } - if let firstEnabled = sources.first(where: { $0.enabled }) { - return firstEnabled - } - return sources.first(where: { $0.isPrimary }) ?? sources.first - } - - private static func sourcesFromLegacyTargets( - _ legacyTargets: [LegacyWikiBridgeSourceTarget], - allowFinalsCommand: Bool, - allowWikiAlias: Bool, - allowWeaponCommand: Bool, - includeWeaponStats: Bool - ) -> [WikiSource] { - guard !legacyTargets.isEmpty else { - return [finalsSourceFromLegacyFlags( - allowFinalsCommand: allowFinalsCommand, - allowWikiAlias: allowWikiAlias, - allowWeaponCommand: allowWeaponCommand, - includeWeaponStats: includeWeaponStats - )] - } - - return legacyTargets.map { legacy in - let isFinals = legacy.kind == .finals || - (legacy.baseURL?.lowercased().contains("thefinals.wiki") ?? false) - if isFinals { - var finals = finalsSourceFromLegacyFlags( - allowFinalsCommand: allowFinalsCommand, - allowWikiAlias: allowWikiAlias, - allowWeaponCommand: allowWeaponCommand, - includeWeaponStats: includeWeaponStats - ) - finals.id = legacy.id ?? finals.id - finals.enabled = legacy.isEnabled ?? true - finals.name = legacy.name?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? finals.name - finals.baseURL = legacy.baseURL?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? finals.baseURL - finals.apiPath = legacy.apiPath?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? finals.apiPath - finals.lastLookupAt = legacy.lastLookupAt - finals.lastStatus = legacy.lastStatus ?? finals.lastStatus - return finals - } - - return WikiSource( - id: legacy.id ?? UUID(), - name: legacy.name?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "Wiki Source", - baseURL: legacy.baseURL?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "https://example.fandom.com", - apiPath: legacy.apiPath?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "/api.php", - enabled: legacy.isEnabled ?? true, - isPrimary: false, - commands: [ - WikiCommand(trigger: "!wiki", endpoint: "search", description: "Search wiki pages", enabled: allowWikiAlias) - ], - formatting: WikiFormatting( - includeStatBlocks: false, - useEmbeds: false, - compactMode: false - ), - parsingRules: [], - lastLookupAt: legacy.lastLookupAt, - lastStatus: legacy.lastStatus ?? "Ready" - ) - } - } - - private static func finalsSourceFromLegacyFlags( - allowFinalsCommand: Bool, - allowWikiAlias: Bool, - allowWeaponCommand: Bool, - includeWeaponStats: Bool - ) -> WikiSource { - var source = WikiSource.defaultFinals() - source.isPrimary = false - source.commands = source.commands.map { command in - var updated = command - let key = command.trigger.lowercased() - if key == "!finals" { - updated.enabled = allowFinalsCommand - } else if key == "!wiki" { - updated.enabled = allowWikiAlias - } else if key == "!weapon" { - updated.enabled = allowWeaponCommand - } - return updated - } - source.formatting.includeStatBlocks = includeWeaponStats - return source - } -} - -private extension String { - var nonEmpty: String? { - let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } -} - -// MARK: - Help Engine Settings - -enum HelpMode: String, Codable, CaseIterable, Identifiable { - case classic = "Classic" - case smart = "Smart" - case hybrid = "Hybrid" - - var id: String { rawValue } - - var description: String { - switch self { - case .classic: return "Plain structured text — no AI." - case .smart: return "AI rewrites the response. Falls back to Classic if unavailable." - case .hybrid: return "AI on first attempt; Classic on failure." - } - } -} - -enum HelpTone: String, Codable, CaseIterable, Identifiable { - case concise = "Concise" - case friendly = "Friendly" - case detailed = "Detailed" - - var id: String { rawValue } -} - -struct HelpSettings: Codable, Hashable { - var mode: HelpMode = .classic - var tone: HelpTone = .concise - var customIntro: String = "" - var customFooter: String = "" - var showAdvanced: Bool = false -} - -enum AIProvider: String, Codable, CaseIterable, Identifiable { - case appleIntelligence = "Apple Intelligence" - case ollama = "Ollama" - case openAI = "OpenAI (ChatGPT)" - - var id: String { rawValue } -} - -enum AIProviderPreference: String, Codable, CaseIterable, Identifiable { - case apple = "Apple Intelligence" - case ollama = "Ollama" - case openAI = "OpenAI (ChatGPT)" - - var id: String { rawValue } -} - -enum MessageRole: String, Codable, Hashable, Sendable { - case user - case assistant - case system -} - -struct AIMemoryNote: Identifiable, Codable, Hashable, Sendable { - let id: UUID - let createdAt: Date - let createdByUserID: String - let createdByUsername: String - let text: String - - init( - id: UUID = UUID(), - createdAt: Date = Date(), - createdByUserID: String, - createdByUsername: String, - text: String - ) { - self.id = id - self.createdAt = createdAt - self.createdByUserID = createdByUserID - self.createdByUsername = createdByUsername - self.text = text - } -} - -enum MemoryScopeType: String, Codable, Hashable, Sendable { - case guildTextChannel - case directMessageUser -} - -struct MemoryScope: Hashable, Codable, Sendable { - let id: String - let type: MemoryScopeType - - static func guildTextChannel(_ channelID: String) -> MemoryScope { - MemoryScope(id: channelID, type: .guildTextChannel) - } - - static func directMessageUser(_ userID: String) -> MemoryScope { - MemoryScope(id: userID, type: .directMessageUser) - } -} - -struct Message: Identifiable, Codable, Hashable, Sendable { - let id: String - let channelID: String - let userID: String - let username: String - let content: String - let timestamp: Date - let role: MessageRole - - init( - id: String = UUID().uuidString, - channelID: String, - userID: String, - username: String, - content: String, - timestamp: Date = Date(), - role: MessageRole - ) { - self.id = id - self.channelID = channelID - self.userID = userID - self.username = username - self.content = content - self.timestamp = timestamp - self.role = role - } -} - -struct MemorySummary: Identifiable, Hashable, Sendable { - let scope: MemoryScope - let messageCount: Int - let lastMessageAt: Date? - - var id: String { "\(scope.type.rawValue):\(scope.id)" } -} - -struct MemoryRecord: Identifiable, Hashable, Codable, Sendable { - let id: String - let scope: MemoryScope - let userID: String - let content: String - let timestamp: Date - let role: MessageRole -} - -actor ConversationStore { - private var messagesByScope: [MemoryScope: [MemoryRecord]] = [:] - private var updateContinuations: [UUID: AsyncStream.Continuation] = [:] - - var updates: AsyncStream { - AsyncStream { continuation in - let id = UUID() - updateContinuations[id] = continuation - continuation.onTermination = { [weak self] _ in - Task { await self?.removeUpdateContinuation(id) } - } - } - } - - func append(_ message: Message) { - let scope = MemoryScope.guildTextChannel(message.channelID) - let record = MemoryRecord( - id: message.id, - scope: scope, - userID: message.userID, - content: message.content, - timestamp: message.timestamp, - role: message.role - ) - messagesByScope[scope, default: []].append(record) - emitUpdate() - } - - func append(_ messages: [Message]) { - guard !messages.isEmpty else { return } - for message in messages { - let scope = MemoryScope.guildTextChannel(message.channelID) - let record = MemoryRecord( - id: message.id, - scope: scope, - userID: message.userID, - content: message.content, - timestamp: message.timestamp, - role: message.role - ) - messagesByScope[scope, default: []].append(record) - } - emitUpdate() - } - - func append( - scope: MemoryScope, - messageID: String = UUID().uuidString, - userID: String, - content: String, - timestamp: Date = Date(), - role: MessageRole - ) { - let record = MemoryRecord( - id: messageID, - scope: scope, - userID: userID, - content: content, - timestamp: timestamp, - role: role - ) - messagesByScope[scope, default: []].append(record) - emitUpdate() - } - - func messages(for scope: MemoryScope) -> [MemoryRecord] { - messagesByScope[scope] ?? [] - } - - func recentMessages(for scope: MemoryScope, limit: Int) -> [MemoryRecord] { - guard limit > 0 else { return [] } - let scopedMessages = messagesByScope[scope] ?? [] - return Array(scopedMessages.suffix(limit)) - } - - func messages(for channelID: String) -> [MemoryRecord] { - messages(for: .guildTextChannel(channelID)) - } - - func recentMessages(for channelID: String, limit: Int) -> [MemoryRecord] { - recentMessages(for: .guildTextChannel(channelID), limit: limit) - } - - func allRecords() -> [MemoryRecord] { - messagesByScope.values.flatMap { $0 } - } - - /// All records globally sorted by (timestamp ascending, id ascending) — deterministic sync order. - func allRecordsSorted() -> [MemoryRecord] { - allRecords().sorted { - if $0.timestamp != $1.timestamp { return $0.timestamp < $1.timestamp } - return $0.id < $1.id - } - } - - /// Returns up to `limit` records that come after `fromRecordID` in sorted order. - /// Returns `hasMore: true` if additional records exist beyond this page. - func recordsSince(fromRecordID: String?, limit: Int) -> (records: [MemoryRecord], hasMore: Bool) { - let sorted = allRecordsSorted() - let startIndex: Int - if let cursorID = fromRecordID, - let idx = sorted.firstIndex(where: { $0.id == cursorID }) { - startIndex = sorted.index(after: idx) - } else { - startIndex = sorted.startIndex - } - guard startIndex < sorted.endIndex else { return ([], false) } - let slice = sorted[startIndex...] - let batch = Array(slice.prefix(limit)) - let hasMore = slice.count > limit - return (batch, hasMore) - } - - /// Appends a record only if no record with the same id already exists (idempotent merge). - func appendIfNotExists(scope: MemoryScope, messageID: String, userID: String, content: String, role: MessageRole, timestamp: Date) { - let existing = messagesByScope[scope] ?? [] - guard !existing.contains(where: { $0.id == messageID }) else { return } - let record = MemoryRecord(id: messageID, scope: scope, userID: userID, content: content, timestamp: timestamp, role: role) - messagesByScope[scope, default: []].append(record) - emitUpdate() - } - - func allMessages() -> [Message] { - messagesByScope.flatMap { scope, records in - records.map { record in - Message( - id: record.id, - channelID: record.scope.id, - userID: record.userID, - username: "", // will be resolved on other end - content: record.content, - timestamp: record.timestamp, - role: record.role - ) - } - } - } - - func summaries() -> [MemorySummary] { - messagesByScope.map { scope, messages in - MemorySummary( - scope: scope, - messageCount: messages.count, - lastMessageAt: messages.last?.timestamp - ) - } - .sorted { lhs, rhs in - if lhs.messageCount != rhs.messageCount { - return lhs.messageCount > rhs.messageCount - } - if lhs.lastMessageAt != rhs.lastMessageAt { - switch (lhs.lastMessageAt, rhs.lastMessageAt) { - case let (left?, right?): - return left > right - case (_?, nil): - return true - case (nil, _?): - return false - case (nil, nil): - break - } - } - if lhs.scope.type != rhs.scope.type { - return lhs.scope.type.rawValue < rhs.scope.type.rawValue - } - return lhs.scope.id < rhs.scope.id - } - } - - func clear(scope: MemoryScope) { - guard messagesByScope[scope] != nil else { return } - messagesByScope.removeValue(forKey: scope) - emitUpdate() - } - - func clear(channelID: String) { - clear(scope: .guildTextChannel(channelID)) - } - - func clearAll() { - guard !messagesByScope.isEmpty else { return } - messagesByScope.removeAll() - emitUpdate() - } - - private func emitUpdate() { - for continuation in updateContinuations.values { - continuation.yield(()) - } - } - - private func removeUpdateContinuation(_ id: UUID) { - updateContinuations.removeValue(forKey: id) - } -} - -struct WikiContextEntry: Identifiable, Hashable, Codable, Sendable { - let id: String - let sourceName: String - let query: String - let title: String - let extract: String - let url: String - let cachedAt: Date -} - -actor WikiContextCache { - private var entries: [WikiContextEntry] = [] - private let maxEntries = 120 - - func store(sourceName: String, query: String, result: FinalsWikiLookupResult) { - let key = normalizedKey(sourceName) + "|" + normalizedKey(result.title) - let entry = WikiContextEntry( - id: key, - sourceName: sourceName, - query: query, - title: result.title, - extract: result.extract, - url: result.url, - cachedAt: Date() - ) - - upsertEntry(entry) - } - - func upsertEntry(_ entry: WikiContextEntry) { - entries.removeAll { $0.id == entry.id } - entries.insert(entry, at: 0) - if entries.count > maxEntries { - entries.removeLast(entries.count - maxEntries) - } - } - - func contextEntries(for prompt: String, limit: Int = 3) -> [WikiContextEntry] { - let tokens = promptTokens(prompt) - let now = Date() - let freshnessCutoff = now.addingTimeInterval(-(60 * 60 * 24 * 7)) - let candidates = entries.filter { $0.cachedAt >= freshnessCutoff } - guard !candidates.isEmpty else { return [] } - - let scored: [(WikiContextEntry, Int)] = candidates.map { entry in - let haystack = [ - normalizedKey(entry.sourceName), - normalizedKey(entry.query), - normalizedKey(entry.title), - normalizedKey(entry.extract) - ].joined(separator: " ") - - let score = tokens.reduce(0) { partial, token in - partial + (haystack.contains(token) ? 1 : 0) - } - return (entry, score) - } - - let matched = scored - .filter { $0.1 > 0 } - .sorted { lhs, rhs in - if lhs.1 == rhs.1 { - return lhs.0.cachedAt > rhs.0.cachedAt - } - return lhs.1 > rhs.1 - } - .map(\.0) - - if !matched.isEmpty { - return Array(matched.prefix(limit)) - } - - return Array(candidates.prefix(limit)) - } - - func allEntries() -> [WikiContextEntry] { - entries - } - - private func promptTokens(_ raw: String) -> [String] { - raw - .lowercased() - .split(whereSeparator: { !$0.isLetter && !$0.isNumber }) - .map(String.init) - .filter { $0.count >= 3 } - } - - private func normalizedKey(_ raw: String) -> String { - raw - .lowercased() - .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) - .trimmingCharacters(in: .whitespacesAndNewlines) - } -} - -enum BotStatus: String { - case stopped - case connecting - case running - case reconnecting -} - -struct StatCounter { - var commandsRun = 0 - var voiceJoins = 0 - var voiceLeaves = 0 - var errors = 0 -} - -struct ActivityEvent: Identifiable, Hashable { - enum Kind: String, Codable { - case voiceJoin - case voiceLeave - case voiceMove - case command - case info - case warning - case error - } - - let id = UUID() - let timestamp: Date - let kind: Kind - let message: String -} - -struct CommandLogEntry: Identifiable, Hashable, Codable { - let id: UUID - let time: Date - let user: String - let server: String - let command: String - let channel: String - let executionRoute: String - let executionNode: String - let ok: Bool - - init( - id: UUID = UUID(), - time: Date, - user: String, - server: String, - command: String, - channel: String, - executionRoute: String, - executionNode: String, - ok: Bool - ) { - self.id = id - self.time = time - self.user = user - self.server = server - self.command = command - self.channel = channel - self.executionRoute = executionRoute - self.executionNode = executionNode - self.ok = ok - } -} - -enum BugStatus: String, Codable, Hashable { - case new = "New" - case workingOn = "Working On" - case inProgress = "In Progress" - case blocked = "Blocked" - case resolved = "Resolved" - - var emoji: String { - switch self { - case .new: - return "🐞" - case .workingOn: - return "🔧" - case .inProgress: - return "🟡" - case .blocked: - return "⛔" - case .resolved: - return "✅" - } - } -} - -struct BugEntry: Hashable, Codable { - let bugMessageID: String - let sourceMessageID: String - let channelID: String - let guildID: String - let reporterID: String - let createdBy: String - var status: BugStatus - var timestamp: Date -} - -struct VoiceMemberPresence: Identifiable, Hashable, Codable { - let id: String - let userId: String - let username: String - let guildId: String - let channelId: String - let channelName: String - let joinedAt: Date -} - -struct VoiceEventLogEntry: Identifiable, Hashable, Codable { - let id: UUID - let time: Date - let description: String - - init(id: UUID = UUID(), time: Date, description: String) { - self.id = id - self.time = time - self.description = description - } -} - -struct FinalsWikiLookupResult: Codable, Hashable { - let title: String - let extract: String - let url: String - let weaponStats: FinalsWeaponStats? -} - -struct FinalsWeaponStats: Codable, Hashable { - let type: String? - let bodyDamage: String? - let headshotDamage: String? - let fireRate: String? - let dropoffStart: String? - let dropoffEnd: String? - let minimumDamage: String? - let magazineSize: String? - let shortReload: String? - let longReload: String? -} - -struct GuildVoiceChannel: Identifiable, Hashable, Codable { - let id: String - let name: String -} - -struct GuildTextChannel: Identifiable, Hashable, Codable { - let id: String - let name: String -} - -struct GuildRole: Identifiable, Hashable, Codable { - let id: String - let name: String - let permissions: String? -} - -struct DiscordCacheSnapshot: Codable, Hashable { - var updatedAt: Date = Date() - var connectedServers: [String: String] = [:] - var availableVoiceChannelsByServer: [String: [GuildVoiceChannel]] = [:] - var availableTextChannelsByServer: [String: [GuildTextChannel]] = [:] - var availableRolesByServer: [String: [GuildRole]] = [:] - var usernamesById: [String: String] = [:] - var channelTypesById: [String: Int] = [:] - - private enum CodingKeys: String, CodingKey { - case updatedAt - case connectedServers - case availableVoiceChannelsByServer - case availableTextChannelsByServer - case availableRolesByServer - case usernamesById - case channelTypesById - } - - init() {} - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? Date() - connectedServers = try container.decodeIfPresent([String: String].self, forKey: .connectedServers) ?? [:] - availableVoiceChannelsByServer = try container.decodeIfPresent([String: [GuildVoiceChannel]].self, forKey: .availableVoiceChannelsByServer) ?? [:] - availableTextChannelsByServer = try container.decodeIfPresent([String: [GuildTextChannel]].self, forKey: .availableTextChannelsByServer) ?? [:] - availableRolesByServer = try container.decodeIfPresent([String: [GuildRole]].self, forKey: .availableRolesByServer) ?? [:] - usernamesById = try container.decodeIfPresent([String: String].self, forKey: .usernamesById) ?? [:] - channelTypesById = try container.decodeIfPresent([String: Int].self, forKey: .channelTypesById) ?? [:] - } -} - -actor DiscordCache { - private var snapshot: DiscordCacheSnapshot - private var updateContinuations: [UUID: AsyncStream.Continuation] = [:] - - init(snapshot: DiscordCacheSnapshot = DiscordCacheSnapshot()) { - self.snapshot = snapshot - } - - var updates: AsyncStream { - AsyncStream { continuation in - let id = UUID() - updateContinuations[id] = continuation - continuation.onTermination = { [weak self] _ in - Task { await self?.removeUpdateContinuation(id) } - } - } - } - - func replace(with snapshot: DiscordCacheSnapshot) { - self.snapshot = snapshot - emitUpdate() - } - - func currentSnapshot() -> DiscordCacheSnapshot { - var copy = snapshot - copy.updatedAt = Date() - return copy - } - - func guildName(for guildID: String) -> String? { - snapshot.connectedServers[guildID] - } - - func userName(for userID: String) -> String? { - snapshot.usernamesById[userID] - } - - func channelName(for channelID: String) -> String? { - for channels in snapshot.availableTextChannelsByServer.values { - if let channel = channels.first(where: { $0.id == channelID }) { - return channel.name - } - } - for channels in snapshot.availableVoiceChannelsByServer.values { - if let channel = channels.first(where: { $0.id == channelID }) { - return channel.name - } - } - return nil - } - - func channelType(for channelID: String) -> Int? { - snapshot.channelTypesById[channelID] - } - - func setChannelType(channelID: String, type: Int) { - snapshot.channelTypesById[channelID] = type - emitUpdate() - } - - func mergeChannelTypes(_ channelTypes: [String: Int]) { - guard !channelTypes.isEmpty else { return } - var didChange = false - for (channelID, type) in channelTypes { - if snapshot.channelTypesById[channelID] != type { - snapshot.channelTypesById[channelID] = type - didChange = true - } - } - if didChange { - emitUpdate() - } - } - - func allGuildNames() -> [String: String] { - snapshot.connectedServers - } - - func voiceChannelsByGuild() -> [String: [GuildVoiceChannel]] { - snapshot.availableVoiceChannelsByServer - } - - func textChannelsByGuild() -> [String: [GuildTextChannel]] { - snapshot.availableTextChannelsByServer - } - - func rolesByGuild() -> [String: [GuildRole]] { - snapshot.availableRolesByServer - } - - func allUserNames() -> [String: String] { - snapshot.usernamesById - } - - func upsertGuild(id guildID: String, name: String?) { - let fallback = "Server \(guildID.suffix(4))" - let candidate = (name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - - if !candidate.isEmpty { - if snapshot.connectedServers[guildID] != candidate { - snapshot.connectedServers[guildID] = candidate - emitUpdate() - } - return - } - - // Preserve any known guild name when only an ID is available. - if snapshot.connectedServers[guildID] == nil { - snapshot.connectedServers[guildID] = fallback - emitUpdate() - } - } - - func removeGuild(id guildID: String) { - let textChannels = snapshot.availableTextChannelsByServer[guildID] ?? [] - let voiceChannels = snapshot.availableVoiceChannelsByServer[guildID] ?? [] - for channel in textChannels { - snapshot.channelTypesById[channel.id] = nil - } - for channel in voiceChannels { - snapshot.channelTypesById[channel.id] = nil - } - snapshot.connectedServers[guildID] = nil - snapshot.availableVoiceChannelsByServer[guildID] = nil - snapshot.availableTextChannelsByServer[guildID] = nil - snapshot.availableRolesByServer[guildID] = nil - emitUpdate() - } - - func setGuildVoiceChannels(guildID: String, channels: [GuildVoiceChannel]) { - let oldChannels = snapshot.availableVoiceChannelsByServer[guildID] ?? [] - for channel in oldChannels { - snapshot.channelTypesById[channel.id] = nil - } - snapshot.availableVoiceChannelsByServer[guildID] = channels - for channel in channels { - snapshot.channelTypesById[channel.id] = 2 - } - emitUpdate() - } - - func setGuildTextChannels(guildID: String, channels: [GuildTextChannel]) { - let oldChannels = snapshot.availableTextChannelsByServer[guildID] ?? [] - for channel in oldChannels { - snapshot.channelTypesById[channel.id] = nil - } - snapshot.availableTextChannelsByServer[guildID] = channels - for channel in channels { - snapshot.channelTypesById[channel.id] = 0 - } - emitUpdate() - } - - func setGuildRoles(guildID: String, roles: [GuildRole]) { - snapshot.availableRolesByServer[guildID] = roles - emitUpdate() - } - - func upsertChannel(guildID: String?, channelID: String, name: String, type: Int) { - let cleaned = name.trimmingCharacters(in: .whitespacesAndNewlines) - guard !cleaned.isEmpty else { return } - snapshot.channelTypesById[channelID] = type - - if type == 1 || type == 3 { - emitUpdate() - return - } - guard let guildID else { - emitUpdate() - return - } - - if type == 0 || type == 5 { - var channels = snapshot.availableTextChannelsByServer[guildID] ?? [] - if let index = channels.firstIndex(where: { $0.id == channelID }) { - channels[index] = GuildTextChannel(id: channelID, name: cleaned) - } else { - channels.append(GuildTextChannel(id: channelID, name: cleaned)) - } - channels.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - snapshot.availableTextChannelsByServer[guildID] = channels - emitUpdate() - return - } - - if type == 2 || type == 13 { - var channels = snapshot.availableVoiceChannelsByServer[guildID] ?? [] - if let index = channels.firstIndex(where: { $0.id == channelID }) { - channels[index] = GuildVoiceChannel(id: channelID, name: cleaned) - } else { - channels.append(GuildVoiceChannel(id: channelID, name: cleaned)) - } - channels.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - snapshot.availableVoiceChannelsByServer[guildID] = channels - emitUpdate() - } - } - - func upsertUser(id userID: String, preferredName: String?) { - let cleaned = (preferredName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !cleaned.isEmpty else { return } - if snapshot.usernamesById[userID] == cleaned { return } - snapshot.usernamesById[userID] = cleaned - emitUpdate() - } - - private func emitUpdate() { - for continuation in updateContinuations.values { - continuation.yield(()) - } - } - - private func removeUpdateContinuation(_ id: UUID) { - updateContinuations.removeValue(forKey: id) - } -} struct UptimeInfo { let startedAt: Date diff --git a/SwiftBotApp/Models/AIModels.swift b/SwiftBotApp/Models/AIModels.swift new file mode 100644 index 0000000..2e85f4f --- /dev/null +++ b/SwiftBotApp/Models/AIModels.swift @@ -0,0 +1,384 @@ +import Foundation + +// MARK: - Help Engine Settings + +enum HelpMode: String, Codable, CaseIterable, Identifiable { + case classic = "Classic" + case smart = "Smart" + case hybrid = "Hybrid" + + var id: String { rawValue } + + var description: String { + switch self { + case .classic: return "Plain structured text — no AI." + case .smart: return "AI rewrites the response. Falls back to Classic if unavailable." + case .hybrid: return "AI on first attempt; Classic on failure." + } + } +} + +enum HelpTone: String, Codable, CaseIterable, Identifiable { + case concise = "Concise" + case friendly = "Friendly" + case detailed = "Detailed" + + var id: String { rawValue } +} + +struct HelpSettings: Codable, Hashable { + var mode: HelpMode = .classic + var tone: HelpTone = .concise + var customIntro: String = "" + var customFooter: String = "" + var showAdvanced: Bool = false +} + +enum AIProvider: String, Codable, CaseIterable, Identifiable { + case appleIntelligence = "Apple Intelligence" + case ollama = "Ollama" + case openAI = "OpenAI (ChatGPT)" + + var id: String { rawValue } +} + +enum AIProviderPreference: String, Codable, CaseIterable, Identifiable { + case apple = "Apple Intelligence" + case ollama = "Ollama" + case openAI = "OpenAI (ChatGPT)" + + var id: String { rawValue } +} + +enum MessageRole: String, Codable, Hashable, Sendable { + case user + case assistant + case system +} + +struct AIMemoryNote: Identifiable, Codable, Hashable, Sendable { + let id: UUID + let createdAt: Date + let createdByUserID: String + let createdByUsername: String + let text: String + + init( + id: UUID = UUID(), + createdAt: Date = Date(), + createdByUserID: String, + createdByUsername: String, + text: String + ) { + self.id = id + self.createdAt = createdAt + self.createdByUserID = createdByUserID + self.createdByUsername = createdByUsername + self.text = text + } +} + +enum MemoryScopeType: String, Codable, Hashable, Sendable { + case guildTextChannel + case directMessageUser +} + +struct MemoryScope: Hashable, Codable, Sendable { + let id: String + let type: MemoryScopeType + + static func guildTextChannel(_ channelID: String) -> MemoryScope { + MemoryScope(id: channelID, type: .guildTextChannel) + } + + static func directMessageUser(_ userID: String) -> MemoryScope { + MemoryScope(id: userID, type: .directMessageUser) + } +} + +struct Message: Identifiable, Codable, Hashable, Sendable { + let id: String + let channelID: String + let userID: String + let username: String + let content: String + let timestamp: Date + let role: MessageRole + + init( + id: String = UUID().uuidString, + channelID: String, + userID: String, + username: String, + content: String, + timestamp: Date = Date(), + role: MessageRole + ) { + self.id = id + self.channelID = channelID + self.userID = userID + self.username = username + self.content = content + self.timestamp = timestamp + self.role = role + } +} + +struct MemorySummary: Identifiable, Hashable, Sendable { + let scope: MemoryScope + let messageCount: Int + let lastMessageAt: Date? + + var id: String { "\(scope.type.rawValue):\(scope.id)" } +} + +struct MemoryRecord: Identifiable, Hashable, Codable, Sendable { + let id: String + let scope: MemoryScope + let userID: String + let content: String + let timestamp: Date + let role: MessageRole +} + +actor ConversationStore { + private var messagesByScope: [MemoryScope: [MemoryRecord]] = [:] + private var updateContinuations: [UUID: AsyncStream.Continuation] = [:] + + var updates: AsyncStream { + AsyncStream { continuation in + let id = UUID() + updateContinuations[id] = continuation + continuation.onTermination = { [weak self] _ in + Task { await self?.removeUpdateContinuation(id) } + } + } + } + + func append(_ message: Message) { + let scope = MemoryScope.guildTextChannel(message.channelID) + let record = MemoryRecord( + id: message.id, + scope: scope, + userID: message.userID, + content: message.content, + timestamp: message.timestamp, + role: message.role + ) + messagesByScope[scope, default: []].append(record) + emitUpdate() + } + + func append(_ messages: [Message]) { + guard !messages.isEmpty else { return } + for message in messages { + let scope = MemoryScope.guildTextChannel(message.channelID) + let record = MemoryRecord( + id: message.id, + scope: scope, + userID: message.userID, + content: message.content, + timestamp: message.timestamp, + role: message.role + ) + messagesByScope[scope, default: []].append(record) + } + emitUpdate() + } + + func append( + scope: MemoryScope, + messageID: String = UUID().uuidString, + userID: String, + content: String, + timestamp: Date = Date(), + role: MessageRole + ) { + let record = MemoryRecord( + id: messageID, + scope: scope, + userID: userID, + content: content, + timestamp: timestamp, + role: role + ) + messagesByScope[scope, default: []].append(record) + emitUpdate() + } + + func messages(in scope: MemoryScope) -> [MemoryRecord] { + messagesByScope[scope] ?? [] + } + + func allMessages() -> [MemoryRecord] { + messagesByScope.values.flatMap { $0 } + } + + func clear(scope: MemoryScope) { + messagesByScope.removeValue(forKey: scope) + emitUpdate() + } + + func clearAll() { + messagesByScope.removeAll() + emitUpdate() + } + + func summaries() -> [MemorySummary] { + messagesByScope.map { (scope, records) in + MemorySummary( + scope: scope, + messageCount: records.count, + lastMessageAt: records.max(by: { $0.timestamp < $1.timestamp })?.timestamp + ) + } + } + + func allRecordsSorted() -> [MemoryRecord] { + allMessages().sorted { $0.timestamp < $1.timestamp } + } + + func recordsSince(fromRecordID: String?, limit: Int) -> (records: [MemoryRecord], hasMore: Bool) { + let all = allRecordsSorted() + guard let fromRecordID else { + return (Array(all.prefix(limit)), all.count > limit) + } + guard let startIndex = all.firstIndex(where: { $0.id > fromRecordID }) else { + return ([], false) + } + let remaining = Array(all[startIndex...]) + return (Array(remaining.prefix(limit)), remaining.count > limit) + } + + func appendIfNotExists(_ message: Message) { + let scope = MemoryScope.guildTextChannel(message.channelID) + let existing = messagesByScope[scope] ?? [] + guard !existing.contains(where: { $0.id == message.id }) else { return } + append(message) + } + + func appendIfNotExists( + scope: MemoryScope, + messageID: String, + userID: String, + content: String, + role: MessageRole, + timestamp: Date + ) { + let existing = messagesByScope[scope] ?? [] + guard !existing.contains(where: { $0.id == messageID }) else { return } + append(scope: scope, messageID: messageID, userID: userID, content: content, timestamp: timestamp, role: role) + } + + func recentMessages(in scope: MemoryScope, limit: Int) -> [MemoryRecord] { + let messages = messagesByScope[scope] ?? [] + return messages.sorted { $0.timestamp > $1.timestamp }.prefix(limit).map { $0 } + } + + private func emitUpdate() { + for continuation in updateContinuations.values { + continuation.yield() + } + } + + private func removeUpdateContinuation(_ id: UUID) { + updateContinuations.removeValue(forKey: id) + } +} + +// MARK: - Wiki Context Cache + +struct WikiContextEntry: Identifiable, Hashable, Codable, Sendable { + let id: String + let sourceName: String + let query: String + let title: String + let extract: String + let url: String + let cachedAt: Date +} + +actor WikiContextCache { + private var entries: [WikiContextEntry] = [] + private let maxEntries = 120 + + func store(sourceName: String, query: String, result: FinalsWikiLookupResult) { + let key = normalizedKey(sourceName) + "|" + normalizedKey(result.title) + let entry = WikiContextEntry( + id: key, + sourceName: sourceName, + query: query, + title: result.title, + extract: result.extract, + url: result.url, + cachedAt: Date() + ) + + upsertEntry(entry) + } + + func upsertEntry(_ entry: WikiContextEntry) { + entries.removeAll { $0.id == entry.id } + entries.insert(entry, at: 0) + if entries.count > maxEntries { + entries.removeLast(entries.count - maxEntries) + } + } + + func contextEntries(for prompt: String, limit: Int = 3) -> [WikiContextEntry] { + let tokens = promptTokens(prompt) + let now = Date() + let freshnessCutoff = now.addingTimeInterval(-(60 * 60 * 24 * 7)) + let candidates = entries.filter { $0.cachedAt >= freshnessCutoff } + guard !candidates.isEmpty else { return [] } + + let scored: [(WikiContextEntry, Int)] = candidates.map { entry in + let haystack = [ + normalizedKey(entry.sourceName), + normalizedKey(entry.query), + normalizedKey(entry.title), + normalizedKey(entry.extract) + ].joined(separator: " ") + + let score = tokens.reduce(0) { partial, token in + partial + (haystack.contains(token) ? 1 : 0) + } + return (entry, score) + } + + let matched = scored + .filter { $0.1 > 0 } + .sorted { lhs, rhs in + if lhs.1 == rhs.1 { + return lhs.0.cachedAt > rhs.0.cachedAt + } + return lhs.1 > rhs.1 + } + .map(\.0) + + if !matched.isEmpty { + return Array(matched.prefix(limit)) + } + + return Array(candidates.prefix(limit)) + } + + func allEntries() -> [WikiContextEntry] { + entries + } + + private func promptTokens(_ raw: String) -> [String] { + raw + .lowercased() + .split(whereSeparator: { !$0.isLetter && !$0.isNumber }) + .map(String.init) + .filter { $0.count >= 3 } + } + + private func normalizedKey(_ raw: String) -> String { + raw + .lowercased() + .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/SwiftBotApp/Models/BotSettings.swift b/SwiftBotApp/Models/BotSettings.swift new file mode 100644 index 0000000..4072415 --- /dev/null +++ b/SwiftBotApp/Models/BotSettings.swift @@ -0,0 +1,987 @@ +import Combine +import Foundation +import Network +import Security + +#if DEBUG +/// Task-local overrides for AI timing and response behavior in unit tests. +/// Only available in DEBUG builds — release logic must not depend on this enum. +enum AITestOverrides { + @TaskLocal static var softNoticeNs: UInt64? + @TaskLocal static var hardTimeoutNs: UInt64? + @TaskLocal static var typingRefreshNs: UInt64? + @TaskLocal static var replyOverride: String? + @TaskLocal static var replyDelaySeconds: Double = 0 +} +#endif + + +// MARK: - Core Models + +struct GuildSettings: Codable, Hashable { + var notificationChannelId: String? + var ignoredVoiceChannelIds: Set = [] + var monitoredVoiceChannelIds: Set = [] + var notifyOnJoin: Bool = true + var notifyOnLeave: Bool = true + var notifyOnMove: Bool = true + var joinNotificationTemplate: String = "🔊 {username} joined {channelName}" + var leaveNotificationTemplate: String = "🔌 {username} left {channelName}" + var moveNotificationTemplate: String = "🔁 {username} moved: {fromChannelName} → {toChannelName}" +} + +enum AdminWebUICertificateMode: String, Codable, Hashable, CaseIterable, Identifiable { + case automatic + case importCertificate + + var id: String { rawValue } + + var displayName: String { + switch self { + case .automatic: + return "Automatic (Let's Encrypt)" + case .importCertificate: + return "Import Certificate" + } + } +} + +struct OAuthProviderSettings: Codable, Hashable { + var enabled: Bool = false + var clientID: String = "" + var clientSecret: String = "" +} + +struct AdminWebUISettings: Codable, Hashable { + // Internal constants (not user-configurable) + static let defaultBindHost = "127.0.0.1" + static let defaultPort = 38888 + + var enabled: Bool = false + var publicBaseURL: String = "" + var internetAccessEnabled: Bool = false + var hostname: String = "" + var subdomain: String = "swiftbot" + var selectedZoneID: String = "" + var selectedZoneName: String = "" + var cloudflareAPIToken: String = "" + + // Legacy compatibility - always returns fixed values + var bindHost: String { Self.defaultBindHost } + var port: Int { Self.defaultPort } + var httpsEnabled: Bool { false } + var certificateMode: AdminWebUICertificateMode { .automatic } + var publicAccessEnabled: Bool { internetAccessEnabled } + var publicAccessTunnelID: String = "" + var publicAccessTunnelName: String = "" + var publicAccessTunnelAccountID: String = "" + var publicAccessTunnelToken: String = "" + var importedCertificateFile: String = "" + var importedPrivateKeyFile: String = "" + var importedCertificateChainFile: String = "" + + // OAuth Providers (Discord is active, others are placeholders) + var discordOAuth = OAuthProviderSettings() + var appleOAuth = OAuthProviderSettings() + var steamOAuth = OAuthProviderSettings() + var githubOAuth = OAuthProviderSettings() + var localAuthEnabled: Bool = false + var localAuthUsername: String = "admin" + var localAuthPassword: String = "" + + // Legacy compatibility - migrated to oauth providers + var discordClientID: String { discordOAuth.clientID } + var discordClientSecret: String { discordOAuth.clientSecret } + var redirectPath: String = "/auth/discord/callback" + var restrictAccessToSpecificUsers: Bool = false + var allowedUserIDs: [String] = [] + + var normalizedHostname: String { + if !subdomain.isEmpty && !selectedZoneName.isEmpty { + return "\(subdomain.lowercased()).\(selectedZoneName.lowercased())" + } + return normalizeHostname(hostname) + } + + private enum CodingKeys: String, CodingKey { + case enabled + case publicBaseURL + case internetAccessEnabled + case hostname + case subdomain + case selectedZoneID + case selectedZoneName + case cloudflareAPIToken + case publicAccessTunnelID + case publicAccessTunnelName + case publicAccessTunnelAccountID + case publicAccessTunnelToken + case discordOAuth + case appleOAuth + case steamOAuth + case githubOAuth + case localAuthEnabled + case localAuthUsername + case localAuthPassword + case redirectPath + case restrictAccessToSpecificUsers + case allowedUserIDs + // Legacy keys for migration + case bindHost + case port + case httpsEnabled + case certificateMode + case publicAccessEnabled + case importedCertificateFile + case importedPrivateKeyFile + case importedCertificateChainFile + case discordClientID + case discordClientSecret + } + + init() { + self.discordOAuth.enabled = true + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? false + publicBaseURL = try container.decodeIfPresent(String.self, forKey: .publicBaseURL) ?? "" + + // Migration: prefer hostname + hostname = try container.decodeIfPresent(String.self, forKey: .hostname) ?? "" + subdomain = try container.decodeIfPresent(String.self, forKey: .subdomain) ?? "swiftbot" + selectedZoneID = try container.decodeIfPresent(String.self, forKey: .selectedZoneID) ?? "" + selectedZoneName = try container.decodeIfPresent(String.self, forKey: .selectedZoneName) ?? "" + + cloudflareAPIToken = try container.decodeIfPresent(String.self, forKey: .cloudflareAPIToken) ?? "" + + // Migration: internetAccessEnabled replaces publicAccessEnabled + let decodedInternetAccessEnabled = try container.decodeIfPresent(Bool.self, forKey: .internetAccessEnabled) + let decodedPublicAccessEnabled = try container.decodeIfPresent(Bool.self, forKey: .publicAccessEnabled) + internetAccessEnabled = decodedInternetAccessEnabled ?? decodedPublicAccessEnabled ?? false + + publicAccessTunnelID = try container.decodeIfPresent(String.self, forKey: .publicAccessTunnelID) ?? "" + publicAccessTunnelName = try container.decodeIfPresent(String.self, forKey: .publicAccessTunnelName) ?? "" + publicAccessTunnelAccountID = try container.decodeIfPresent(String.self, forKey: .publicAccessTunnelAccountID) ?? "" + publicAccessTunnelToken = try container.decodeIfPresent(String.self, forKey: .publicAccessTunnelToken) ?? "" + + // OAuth Providers - decode or migrate from legacy fields + discordOAuth = try container.decodeIfPresent(OAuthProviderSettings.self, forKey: .discordOAuth) + ?? OAuthProviderSettings( + enabled: (try? container.decodeIfPresent(String.self, forKey: .discordClientID))?.isEmpty == false, + clientID: try container.decodeIfPresent(String.self, forKey: .discordClientID) ?? "", + clientSecret: try container.decodeIfPresent(String.self, forKey: .discordClientSecret) ?? "" + ) + appleOAuth = try container.decodeIfPresent(OAuthProviderSettings.self, forKey: .appleOAuth) ?? OAuthProviderSettings() + steamOAuth = try container.decodeIfPresent(OAuthProviderSettings.self, forKey: .steamOAuth) ?? OAuthProviderSettings() + githubOAuth = try container.decodeIfPresent(OAuthProviderSettings.self, forKey: .githubOAuth) ?? OAuthProviderSettings() + localAuthEnabled = try container.decodeIfPresent(Bool.self, forKey: .localAuthEnabled) ?? false + localAuthUsername = try container.decodeIfPresent(String.self, forKey: .localAuthUsername) ?? "admin" + localAuthPassword = try container.decodeIfPresent(String.self, forKey: .localAuthPassword) ?? "" + + redirectPath = try container.decodeIfPresent(String.self, forKey: .redirectPath) ?? "/auth/discord/callback" + allowedUserIDs = try container.decodeIfPresent([String].self, forKey: .allowedUserIDs) ?? [] + restrictAccessToSpecificUsers = try container.decodeIfPresent(Bool.self, forKey: .restrictAccessToSpecificUsers) + ?? !allowedUserIDs.isEmpty + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(enabled, forKey: .enabled) + try container.encode(publicBaseURL, forKey: .publicBaseURL) + try container.encode(hostname, forKey: .hostname) + try container.encode(subdomain, forKey: .subdomain) + try container.encode(selectedZoneID, forKey: .selectedZoneID) + try container.encode(selectedZoneName, forKey: .selectedZoneName) + try container.encode(cloudflareAPIToken, forKey: .cloudflareAPIToken) + try container.encode(internetAccessEnabled, forKey: .internetAccessEnabled) + try container.encode(publicAccessTunnelID, forKey: .publicAccessTunnelID) + try container.encode(publicAccessTunnelName, forKey: .publicAccessTunnelName) + try container.encode(publicAccessTunnelAccountID, forKey: .publicAccessTunnelAccountID) + try container.encode(publicAccessTunnelToken, forKey: .publicAccessTunnelToken) + try container.encode(importedCertificateFile, forKey: .importedCertificateFile) + try container.encode(importedPrivateKeyFile, forKey: .importedPrivateKeyFile) + try container.encode(importedCertificateChainFile, forKey: .importedCertificateChainFile) + try container.encode(discordOAuth, forKey: .discordOAuth) + try container.encode(appleOAuth, forKey: .appleOAuth) + try container.encode(steamOAuth, forKey: .steamOAuth) + try container.encode(githubOAuth, forKey: .githubOAuth) + try container.encode(localAuthEnabled, forKey: .localAuthEnabled) + try container.encode(localAuthUsername, forKey: .localAuthUsername) + try container.encode(localAuthPassword, forKey: .localAuthPassword) + try container.encode(redirectPath, forKey: .redirectPath) + try container.encode(restrictAccessToSpecificUsers, forKey: .restrictAccessToSpecificUsers) + try container.encode(allowedUserIDs, forKey: .allowedUserIDs) + } + + var normalizedAllowedUserIDs: [String] { + allowedUserIDs + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + + var normalizedImportedCertificateFile: String { + importedCertificateFile.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var normalizedImportedPrivateKeyFile: String { + importedPrivateKeyFile.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var normalizedImportedCertificateChainFile: String { + importedCertificateChainFile.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func normalizeHostname(_ rawValue: String) -> String { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + + if let url = URL(string: trimmed), let host = url.host { + return host.lowercased() + } + + let normalized = trimmed + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + .replacingOccurrences(of: " ", with: "") + .lowercased() + + if let slashIndex = normalized.firstIndex(of: "/") { + return String(normalized[.. String { + UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() +} + +struct BotSettings: Codable, Hashable { + var token: String = "" + var launchMode: AppLaunchMode = .standaloneBot + var remoteMode = RemoteModeSettings() + var remoteAccessToken: String = generatedRemoteAccessToken() + var prefix: String = "/" + var commandsEnabled: Bool = true + var prefixCommandsEnabled: Bool = true + var slashCommandsEnabled: Bool = true + var bugTrackingEnabled: Bool = true + var disabledCommandKeys: Set = [] + var autoStart: Bool = false + var guildSettings: [String: GuildSettings] = [:] + var clusterMode: ClusterMode = .standalone + var clusterNodeName: String = Host.current().localizedName ?? "SwiftBot Node" + var clusterLeaderAddress: String = "" + var clusterLeaderPort: Int = 38787 + var clusterListenPort: Int = 38787 + var clusterSharedSecret: String = "" + var clusterLeaderTerm: Int = 0 + var clusterWorkerOffloadEnabled: Bool = false + var clusterOffloadAIReplies: Bool = false + var clusterOffloadWikiLookups: Bool = false + + // Local AI reply settings for DMs and guild mentions. + var localAIDMReplyEnabled: Bool = false + var localAIProvider: AIProvider = .appleIntelligence + var preferredAIProvider: AIProviderPreference = .apple + var localAIEndpoint: String = "http://127.0.0.1:1234/v1/chat/completions" + var localAIModel: String = "local-model" + var ollamaBaseURL: String = "http://localhost:11434" + var ollamaEnabled: Bool = true + var openAIEnabled: Bool = true + var openAIAPIKey: String = "" + var openAIModel: String = "gpt-4o-mini" + var openAIImageGenerationEnabled: Bool = true + var openAIImageModel: String = "gpt-image-1" + var openAIImageMonthlyLimitPerUser: Int = 5 + var openAIImageMonthlyHardCap: Int = 100 + var openAIImageUsageByUserMonth: [String: Int] = [:] + var devFeaturesEnabled: Bool = false + var bugAutoFixEnabled: Bool = false + var bugAutoFixTriggerEmoji: String = "🤖" + var bugAutoFixCommandTemplate: String = "codex exec \"$SWIFTBOT_BUG_PROMPT\"" + var bugAutoFixRepoPath: String = "" + var bugAutoFixGitBranch: String = "main" + var bugAutoFixVersionBumpEnabled: Bool = true + var bugAutoFixPushEnabled: Bool = true + var bugAutoFixRequireApproval: Bool = true + var bugAutoFixApproveEmoji: String = "🚀" + var bugAutoFixRejectEmoji: String = "🛑" + var bugAutoFixAllowedUsernames: [String] = [] + var aiMemoryNotes: [AIMemoryNote] = [] + var localAISystemPrompt: String = "You are a friendly, casual Discord bot. Keep replies short and conversational — 1 to 3 sentences max unless asked for detail. Use contractions naturally. Don't restate what the user said. Don't open every reply the same way. Match the energy of the conversation." + var behavior = BotBehaviorSettings() + var wikiBot = WikiBotSettings() + var patchy = PatchySettings() + var help = HelpSettings() + var adminWebUI = AdminWebUISettings() + + var swiftMeshSettings: SwiftMeshSettings { + get { + SwiftMeshSettings( + mode: clusterMode, + nodeName: clusterNodeName, + leaderAddress: clusterLeaderAddress, + leaderPort: clusterLeaderPort, + listenPort: clusterListenPort, + sharedSecret: clusterSharedSecret, + leaderTerm: clusterLeaderTerm + ) + } + set { + clusterMode = newValue.mode + clusterNodeName = newValue.nodeName + clusterLeaderAddress = newValue.leaderAddress + clusterLeaderPort = newValue.leaderPort + clusterListenPort = newValue.listenPort + clusterSharedSecret = newValue.sharedSecret + clusterLeaderTerm = newValue.leaderTerm + } + } + + private enum CodingKeys: String, CodingKey { + case token + case launchMode + case remoteMode + case remoteAccessToken + case prefix + case commandsEnabled + case prefixCommandsEnabled + case slashCommandsEnabled + case bugTrackingEnabled + case disabledCommandKeys + case autoStart + case guildSettings + case clusterMode + case clusterNodeName + case clusterLeaderAddress + case clusterLeaderPort + case clusterWorkerBaseURLLegacy = "clusterWorkerBaseURL" + case clusterListenPort + case clusterSharedSecret + case clusterLeaderTerm + case clusterWorkerOffloadEnabled + case clusterOffloadAIReplies + case clusterOffloadWikiLookups + case localAIDMReplyEnabled + case localAIProvider + case preferredAIProvider + case localAIEndpoint + case localAIModel + case ollamaBaseURL + case ollamaEnabled + case openAIEnabled + case openAIAPIKey + case openAIModel + case openAIImageGenerationEnabled + case openAIImageModel + case openAIImageMonthlyLimitPerUser + case openAIImageMonthlyHardCap + case openAIImageUsageByUserMonth + case devFeaturesEnabled + case bugAutoFixEnabled + case bugAutoFixTriggerEmoji + case bugAutoFixCommandTemplate + case bugAutoFixRepoPath + case bugAutoFixGitBranch + case bugAutoFixVersionBumpEnabled + case bugAutoFixPushEnabled + case bugAutoFixRequireApproval + case bugAutoFixApproveEmoji + case bugAutoFixRejectEmoji + case bugAutoFixAllowedUsernames + case aiMemoryNotes + case localAISystemPrompt + case behavior + case wikiBot + case patchy + case help + case adminWebUI + } + + init() {} + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + token = try container.decodeIfPresent(String.self, forKey: .token) ?? "" + launchMode = try container.decodeIfPresent(AppLaunchMode.self, forKey: .launchMode) ?? .standaloneBot + remoteMode = try container.decodeIfPresent(RemoteModeSettings.self, forKey: .remoteMode) ?? RemoteModeSettings() + remoteAccessToken = try container.decodeIfPresent(String.self, forKey: .remoteAccessToken) ?? generatedRemoteAccessToken() + prefix = try container.decodeIfPresent(String.self, forKey: .prefix) ?? "/" + commandsEnabled = try container.decodeIfPresent(Bool.self, forKey: .commandsEnabled) ?? true + prefixCommandsEnabled = try container.decodeIfPresent(Bool.self, forKey: .prefixCommandsEnabled) ?? true + slashCommandsEnabled = try container.decodeIfPresent(Bool.self, forKey: .slashCommandsEnabled) ?? true + bugTrackingEnabled = try container.decodeIfPresent(Bool.self, forKey: .bugTrackingEnabled) ?? true + disabledCommandKeys = try container.decodeIfPresent(Set.self, forKey: .disabledCommandKeys) ?? [] + autoStart = try container.decodeIfPresent(Bool.self, forKey: .autoStart) ?? false + guildSettings = try container.decodeIfPresent([String: GuildSettings].self, forKey: .guildSettings) ?? [:] + clusterMode = try container.decodeIfPresent(ClusterMode.self, forKey: .clusterMode) ?? .standalone + clusterNodeName = try container.decodeIfPresent(String.self, forKey: .clusterNodeName) ?? (Host.current().localizedName ?? "SwiftBot Node") + clusterLeaderAddress = try container.decodeIfPresent(String.self, forKey: .clusterLeaderAddress) + ?? (try container.decodeIfPresent(String.self, forKey: .clusterWorkerBaseURLLegacy) ?? "") + clusterLeaderPort = try container.decodeIfPresent(Int.self, forKey: .clusterLeaderPort) ?? 38787 + clusterListenPort = try container.decodeIfPresent(Int.self, forKey: .clusterListenPort) ?? 38787 + clusterSharedSecret = try container.decodeIfPresent(String.self, forKey: .clusterSharedSecret) ?? "" + clusterLeaderTerm = try container.decodeIfPresent(Int.self, forKey: .clusterLeaderTerm) ?? 0 + let decodedOffloadAIReplies = try container.decodeIfPresent(Bool.self, forKey: .clusterOffloadAIReplies) ?? false + let decodedOffloadWikiLookups = try container.decodeIfPresent(Bool.self, forKey: .clusterOffloadWikiLookups) ?? false + clusterWorkerOffloadEnabled = try container.decodeIfPresent(Bool.self, forKey: .clusterWorkerOffloadEnabled) + ?? (decodedOffloadAIReplies || decodedOffloadWikiLookups) + clusterOffloadAIReplies = decodedOffloadAIReplies + clusterOffloadWikiLookups = decodedOffloadWikiLookups + localAIDMReplyEnabled = try container.decodeIfPresent(Bool.self, forKey: .localAIDMReplyEnabled) ?? false + localAIProvider = try container.decodeIfPresent(AIProvider.self, forKey: .localAIProvider) ?? .appleIntelligence + preferredAIProvider = try container.decodeIfPresent(AIProviderPreference.self, forKey: .preferredAIProvider) ?? .apple + localAIEndpoint = try container.decodeIfPresent(String.self, forKey: .localAIEndpoint) ?? "http://127.0.0.1:1234/v1/chat/completions" + localAIModel = try container.decodeIfPresent(String.self, forKey: .localAIModel) ?? "local-model" + ollamaBaseURL = try container.decodeIfPresent(String.self, forKey: .ollamaBaseURL) ?? "http://localhost:11434" + ollamaEnabled = try container.decodeIfPresent(Bool.self, forKey: .ollamaEnabled) ?? true + openAIEnabled = try container.decodeIfPresent(Bool.self, forKey: .openAIEnabled) ?? true + openAIAPIKey = try container.decodeIfPresent(String.self, forKey: .openAIAPIKey) ?? "" + openAIModel = try container.decodeIfPresent(String.self, forKey: .openAIModel) ?? "gpt-4o-mini" + openAIImageGenerationEnabled = try container.decodeIfPresent(Bool.self, forKey: .openAIImageGenerationEnabled) ?? true + openAIImageModel = try container.decodeIfPresent(String.self, forKey: .openAIImageModel) ?? "gpt-image-1" + openAIImageMonthlyLimitPerUser = try container.decodeIfPresent(Int.self, forKey: .openAIImageMonthlyLimitPerUser) ?? 5 + openAIImageMonthlyHardCap = try container.decodeIfPresent(Int.self, forKey: .openAIImageMonthlyHardCap) ?? 100 + openAIImageUsageByUserMonth = try container.decodeIfPresent([String: Int].self, forKey: .openAIImageUsageByUserMonth) ?? [:] + devFeaturesEnabled = try container.decodeIfPresent(Bool.self, forKey: .devFeaturesEnabled) ?? false + bugAutoFixEnabled = try container.decodeIfPresent(Bool.self, forKey: .bugAutoFixEnabled) ?? false + bugAutoFixTriggerEmoji = try container.decodeIfPresent(String.self, forKey: .bugAutoFixTriggerEmoji) ?? "🤖" + bugAutoFixCommandTemplate = try container.decodeIfPresent(String.self, forKey: .bugAutoFixCommandTemplate) ?? "codex exec \"$SWIFTBOT_BUG_PROMPT\"" + bugAutoFixRepoPath = try container.decodeIfPresent(String.self, forKey: .bugAutoFixRepoPath) ?? "" + bugAutoFixGitBranch = try container.decodeIfPresent(String.self, forKey: .bugAutoFixGitBranch) ?? "main" + bugAutoFixVersionBumpEnabled = try container.decodeIfPresent(Bool.self, forKey: .bugAutoFixVersionBumpEnabled) ?? true + bugAutoFixPushEnabled = try container.decodeIfPresent(Bool.self, forKey: .bugAutoFixPushEnabled) ?? true + bugAutoFixRequireApproval = try container.decodeIfPresent(Bool.self, forKey: .bugAutoFixRequireApproval) ?? true + bugAutoFixApproveEmoji = try container.decodeIfPresent(String.self, forKey: .bugAutoFixApproveEmoji) ?? "🚀" + bugAutoFixRejectEmoji = try container.decodeIfPresent(String.self, forKey: .bugAutoFixRejectEmoji) ?? "🛑" + bugAutoFixAllowedUsernames = try container.decodeIfPresent([String].self, forKey: .bugAutoFixAllowedUsernames) ?? [] + aiMemoryNotes = try container.decodeIfPresent([AIMemoryNote].self, forKey: .aiMemoryNotes) ?? [] + localAISystemPrompt = try container.decodeIfPresent(String.self, forKey: .localAISystemPrompt) ?? "You are a friendly, casual Discord bot. Keep replies short and conversational — 1 to 3 sentences max unless asked for detail. Use contractions naturally. Don't restate what the user said. Don't open every reply the same way. Match the energy of the conversation." + behavior = try container.decodeIfPresent(BotBehaviorSettings.self, forKey: .behavior) ?? BotBehaviorSettings() + wikiBot = try container.decodeIfPresent(WikiBotSettings.self, forKey: .wikiBot) ?? WikiBotSettings() + patchy = try container.decodeIfPresent(PatchySettings.self, forKey: .patchy) ?? PatchySettings() + help = try container.decodeIfPresent(HelpSettings.self, forKey: .help) ?? HelpSettings() + adminWebUI = try container.decodeIfPresent(AdminWebUISettings.self, forKey: .adminWebUI) ?? AdminWebUISettings() + remoteMode.normalize() + remoteAccessToken = remoteAccessToken.trimmingCharacters(in: .whitespacesAndNewlines) + if remoteAccessToken.isEmpty { + remoteAccessToken = generatedRemoteAccessToken() + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(token, forKey: .token) + try container.encode(launchMode, forKey: .launchMode) + try container.encode(remoteMode, forKey: .remoteMode) + try container.encode(remoteAccessToken, forKey: .remoteAccessToken) + try container.encode(prefix, forKey: .prefix) + try container.encode(commandsEnabled, forKey: .commandsEnabled) + try container.encode(prefixCommandsEnabled, forKey: .prefixCommandsEnabled) + try container.encode(slashCommandsEnabled, forKey: .slashCommandsEnabled) + try container.encode(bugTrackingEnabled, forKey: .bugTrackingEnabled) + try container.encode(disabledCommandKeys, forKey: .disabledCommandKeys) + try container.encode(autoStart, forKey: .autoStart) + try container.encode(guildSettings, forKey: .guildSettings) + try container.encode(clusterMode, forKey: .clusterMode) + try container.encode(clusterNodeName, forKey: .clusterNodeName) + try container.encode(clusterLeaderAddress, forKey: .clusterLeaderAddress) + try container.encode(clusterListenPort, forKey: .clusterListenPort) + try container.encode(clusterSharedSecret, forKey: .clusterSharedSecret) + try container.encode(clusterLeaderTerm, forKey: .clusterLeaderTerm) + try container.encode(clusterWorkerOffloadEnabled, forKey: .clusterWorkerOffloadEnabled) + try container.encode(clusterOffloadAIReplies, forKey: .clusterOffloadAIReplies) + try container.encode(clusterOffloadWikiLookups, forKey: .clusterOffloadWikiLookups) + try container.encode(localAIDMReplyEnabled, forKey: .localAIDMReplyEnabled) + + try container.encode(localAIProvider, forKey: .localAIProvider) + try container.encode(preferredAIProvider, forKey: .preferredAIProvider) + try container.encode(localAIEndpoint, forKey: .localAIEndpoint) + try container.encode(localAIModel, forKey: .localAIModel) + try container.encode(ollamaBaseURL, forKey: .ollamaBaseURL) + try container.encode(ollamaEnabled, forKey: .ollamaEnabled) + try container.encode(openAIEnabled, forKey: .openAIEnabled) + try container.encode(openAIAPIKey, forKey: .openAIAPIKey) + try container.encode(openAIModel, forKey: .openAIModel) + try container.encode(openAIImageGenerationEnabled, forKey: .openAIImageGenerationEnabled) + try container.encode(openAIImageModel, forKey: .openAIImageModel) + try container.encode(openAIImageMonthlyLimitPerUser, forKey: .openAIImageMonthlyLimitPerUser) + try container.encode(openAIImageMonthlyHardCap, forKey: .openAIImageMonthlyHardCap) + try container.encode(openAIImageUsageByUserMonth, forKey: .openAIImageUsageByUserMonth) + try container.encode(devFeaturesEnabled, forKey: .devFeaturesEnabled) + try container.encode(bugAutoFixEnabled, forKey: .bugAutoFixEnabled) + try container.encode(bugAutoFixTriggerEmoji, forKey: .bugAutoFixTriggerEmoji) + try container.encode(bugAutoFixCommandTemplate, forKey: .bugAutoFixCommandTemplate) + try container.encode(bugAutoFixRepoPath, forKey: .bugAutoFixRepoPath) + try container.encode(bugAutoFixGitBranch, forKey: .bugAutoFixGitBranch) + try container.encode(bugAutoFixVersionBumpEnabled, forKey: .bugAutoFixVersionBumpEnabled) + try container.encode(bugAutoFixPushEnabled, forKey: .bugAutoFixPushEnabled) + try container.encode(bugAutoFixRequireApproval, forKey: .bugAutoFixRequireApproval) + try container.encode(bugAutoFixApproveEmoji, forKey: .bugAutoFixApproveEmoji) + try container.encode(bugAutoFixRejectEmoji, forKey: .bugAutoFixRejectEmoji) + try container.encode(bugAutoFixAllowedUsernames, forKey: .bugAutoFixAllowedUsernames) + try container.encode(aiMemoryNotes, forKey: .aiMemoryNotes) + try container.encode(localAISystemPrompt, forKey: .localAISystemPrompt) + try container.encode(behavior, forKey: .behavior) + try container.encode(wikiBot, forKey: .wikiBot) + try container.encode(patchy, forKey: .patchy) + try container.encode(help, forKey: .help) + try container.encode(adminWebUI, forKey: .adminWebUI) + } +} + +struct BotBehaviorSettings: Codable, Hashable { + var allowDMs: Bool = false + var useAIInGuildChannels: Bool = true + + // Member join welcome (P0.5) + var memberJoinWelcomeEnabled: Bool = false + var memberJoinWelcomeChannelId: String = "" + var memberJoinWelcomeTemplate: String = "👋 Welcome {username} to **{server}**!" + + // Voice activity log — global fallback channel when no per-guild channel is set (P0.5) + var voiceActivityLogEnabled: Bool = false + var voiceActivityLogChannelId: String = "" +} + +struct WikiCommand: Codable, Hashable, Identifiable { + var id: UUID = UUID() + var trigger: String = "!wiki" + var endpoint: String = "search" + var description: String = "" + var enabled: Bool = true + + private enum CodingKeys: String, CodingKey { + case id + case trigger + case endpoint + case description + case enabled + } + + init( + id: UUID = UUID(), + trigger: String = "!wiki", + endpoint: String = "search", + description: String = "", + enabled: Bool = true + ) { + self.id = id + self.trigger = trigger + self.endpoint = endpoint + self.description = description + self.enabled = enabled + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + trigger = try container.decodeIfPresent(String.self, forKey: .trigger) ?? "!wiki" + endpoint = try container.decodeIfPresent(String.self, forKey: .endpoint) ?? "search" + description = try container.decodeIfPresent(String.self, forKey: .description) ?? "" + enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? true + } +} + +struct WikiFormatting: Codable, Hashable { + var includeStatBlocks: Bool = true + var useEmbeds: Bool = false + var compactMode: Bool = false +} + +struct WikiParsingRule: Codable, Hashable, Identifiable { + var id: UUID = UUID() + var pageType: String = "weapon" + var templateName: String = "Weapon" +} + +struct WikiSource: Codable, Hashable, Identifiable { + var id: UUID = UUID() + var name: String = "Wiki Source" + var baseURL: String = "https://example.fandom.com" + var apiPath: String = "/api.php" + var enabled: Bool = true + var isPrimary: Bool = false + var commands: [WikiCommand] = [] + var formatting: WikiFormatting = WikiFormatting() + var parsingRules: [WikiParsingRule] = [] + var lastLookupAt: Date? + var lastStatus: String = "Never used" + + init( + id: UUID = UUID(), + name: String = "Wiki Source", + baseURL: String = "https://example.fandom.com", + apiPath: String = "/api.php", + enabled: Bool = true, + isPrimary: Bool = false, + commands: [WikiCommand] = [], + formatting: WikiFormatting = WikiFormatting(), + parsingRules: [WikiParsingRule] = [], + lastLookupAt: Date? = nil, + lastStatus: String = "Never used" + ) { + self.id = id + self.name = name + self.baseURL = baseURL + self.apiPath = apiPath + self.enabled = enabled + self.isPrimary = isPrimary + self.commands = commands + self.formatting = formatting + self.parsingRules = parsingRules + self.lastLookupAt = lastLookupAt + self.lastStatus = lastStatus + } + + static func defaultFinals() -> WikiSource { + WikiSource( + id: UUID(), + name: "THE FINALS Wiki", + baseURL: "https://www.thefinals.wiki", + apiPath: "/api.php", + enabled: true, + isPrimary: true, + commands: [ + WikiCommand(trigger: "!wiki", endpoint: "search", description: "Search wiki pages", enabled: true), + WikiCommand(trigger: "!weapon", endpoint: "weaponPage", description: "Lookup weapon stats", enabled: true), + WikiCommand(trigger: "!finals", endpoint: "search", description: "Search THE FINALS wiki", enabled: true) + ], + formatting: WikiFormatting( + includeStatBlocks: true, + useEmbeds: false, + compactMode: false + ), + parsingRules: [ + WikiParsingRule(pageType: "weapon", templateName: "Weapon") + ], + lastLookupAt: nil, + lastStatus: "Ready" + ) + } + + static func genericTemplate() -> WikiSource { + WikiSource( + id: UUID(), + name: "New Wiki", + baseURL: "https://example.fandom.com", + apiPath: "/api.php", + enabled: true, + isPrimary: false, + commands: [ + WikiCommand(trigger: "!wiki", endpoint: "search", description: "Search wiki pages", enabled: true) + ], + formatting: WikiFormatting( + includeStatBlocks: false, + useEmbeds: false, + compactMode: false + ), + parsingRules: [], + lastLookupAt: nil, + lastStatus: "Ready" + ) + } + + private enum CodingKeys: String, CodingKey { + case id + case name + case baseURL + case apiPath + case enabled + case isPrimary + case commands + case formatting + case parsingRules + case lastLookupAt + case lastStatus + // Legacy key + case isEnabled + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + name = try container.decodeIfPresent(String.self, forKey: .name) ?? "Wiki Source" + baseURL = try container.decodeIfPresent(String.self, forKey: .baseURL) ?? "https://example.fandom.com" + apiPath = try container.decodeIfPresent(String.self, forKey: .apiPath) ?? "/api.php" + enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) + ?? (try container.decodeIfPresent(Bool.self, forKey: .isEnabled)) + ?? true + isPrimary = try container.decodeIfPresent(Bool.self, forKey: .isPrimary) ?? false + commands = try container.decodeIfPresent([WikiCommand].self, forKey: .commands) ?? [] + formatting = try container.decodeIfPresent(WikiFormatting.self, forKey: .formatting) ?? WikiFormatting() + parsingRules = try container.decodeIfPresent([WikiParsingRule].self, forKey: .parsingRules) ?? [] + lastLookupAt = try container.decodeIfPresent(Date.self, forKey: .lastLookupAt) + lastStatus = try container.decodeIfPresent(String.self, forKey: .lastStatus) ?? "Never used" + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(baseURL, forKey: .baseURL) + try container.encode(apiPath, forKey: .apiPath) + try container.encode(enabled, forKey: .enabled) + try container.encode(isPrimary, forKey: .isPrimary) + try container.encode(commands, forKey: .commands) + try container.encode(formatting, forKey: .formatting) + try container.encode(parsingRules, forKey: .parsingRules) + try container.encodeIfPresent(lastLookupAt, forKey: .lastLookupAt) + try container.encode(lastStatus, forKey: .lastStatus) + } +} + +private struct LegacyWikiBridgeSourceTarget: Decodable { + enum LegacyKind: String, Decodable { + case finals = "THE FINALS" + case mediaWiki = "MediaWiki" + } + + var id: UUID? + var isEnabled: Bool? + var name: String? + var kind: LegacyKind? + var baseURL: String? + var apiPath: String? + var lastLookupAt: Date? + var lastStatus: String? +} + +struct WikiBotSettings: Codable, Hashable { + var isEnabled: Bool = true + var sources: [WikiSource] = [] + + private enum CodingKeys: String, CodingKey { + case isEnabled + case sources + // Legacy key + case defaultSourceID + // Legacy keys + case allowFinalsCommand + case allowWikiAlias + case allowWeaponCommand + case includeWeaponStats + case sourceTargets + } + + init() { + let defaultSource = WikiSource.defaultFinals() + sources = [defaultSource] + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true + + let allowFinalsCommand = try container.decodeIfPresent(Bool.self, forKey: .allowFinalsCommand) ?? true + let allowWikiAlias = try container.decodeIfPresent(Bool.self, forKey: .allowWikiAlias) ?? true + let allowWeaponCommand = try container.decodeIfPresent(Bool.self, forKey: .allowWeaponCommand) ?? true + let includeWeaponStats = try container.decodeIfPresent(Bool.self, forKey: .includeWeaponStats) ?? true + + if let decodedSources = try container.decodeIfPresent([WikiSource].self, forKey: .sources) { + sources = decodedSources + } else if let legacyTargets = try container.decodeIfPresent([LegacyWikiBridgeSourceTarget].self, forKey: .sourceTargets) { + sources = Self.sourcesFromLegacyTargets( + legacyTargets, + allowFinalsCommand: allowFinalsCommand, + allowWikiAlias: allowWikiAlias, + allowWeaponCommand: allowWeaponCommand, + includeWeaponStats: includeWeaponStats + ) + } else { + sources = [] + } + + let legacyPrimaryID = try container.decodeIfPresent(UUID.self, forKey: .defaultSourceID) + if let legacyPrimaryID, !sources.contains(where: { $0.isPrimary }) { + sources = sources.map { source in + var updated = source + updated.isPrimary = source.id == legacyPrimaryID + return updated + } + } + normalizeSources() + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(isEnabled, forKey: .isEnabled) + try container.encode(sources, forKey: .sources) + } + + mutating func normalizeSources() { + if sources.isEmpty { + let defaultSource = WikiSource.defaultFinals() + sources = [defaultSource] + return + } + + sources = sources.map { source in + var updated = source + updated.name = source.name.trimmingCharacters(in: .whitespacesAndNewlines) + updated.baseURL = source.baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + updated.apiPath = source.apiPath.trimmingCharacters(in: .whitespacesAndNewlines) + updated.commands = source.commands.map { command in + var normalized = command + normalized.trigger = command.trigger.trimmingCharacters(in: .whitespacesAndNewlines) + normalized.endpoint = command.endpoint.trimmingCharacters(in: .whitespacesAndNewlines) + normalized.description = command.description.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized + } + updated.parsingRules = source.parsingRules.map { rule in + var normalized = rule + normalized.pageType = rule.pageType.trimmingCharacters(in: .whitespacesAndNewlines) + normalized.templateName = rule.templateName.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized + } + if updated.name.isEmpty { + updated.name = "Wiki Source" + } + if updated.baseURL.isEmpty { + updated.baseURL = "https://example.fandom.com" + } + if updated.apiPath.isEmpty { + updated.apiPath = "/api.php" + } + if updated.commands.isEmpty { + updated.commands = [WikiCommand(trigger: "!wiki", endpoint: "search", description: "Search wiki pages", enabled: true)] + } + return updated + } + + let primaryID: UUID? = { + if let primaryEnabled = sources.first(where: { $0.isPrimary && $0.enabled }) { + return primaryEnabled.id + } + if let firstEnabled = sources.first(where: { $0.enabled }) { + return firstEnabled.id + } + if let explicitPrimary = sources.first(where: { $0.isPrimary }) { + return explicitPrimary.id + } + return sources.first?.id + }() + + if let primaryID { + sources = sources.map { source in + var updated = source + updated.isPrimary = source.id == primaryID + return updated + } + } + } + + mutating func setPrimarySource(_ sourceID: UUID) { + guard sources.contains(where: { $0.id == sourceID }) else { return } + sources = sources.map { source in + var updated = source + updated.isPrimary = source.id == sourceID + return updated + } + normalizeSources() + } + + func primarySource() -> WikiSource? { + if let primaryEnabled = sources.first(where: { $0.isPrimary && $0.enabled }) { + return primaryEnabled + } + if let firstEnabled = sources.first(where: { $0.enabled }) { + return firstEnabled + } + return sources.first(where: { $0.isPrimary }) ?? sources.first + } + + private static func sourcesFromLegacyTargets( + _ legacyTargets: [LegacyWikiBridgeSourceTarget], + allowFinalsCommand: Bool, + allowWikiAlias: Bool, + allowWeaponCommand: Bool, + includeWeaponStats: Bool + ) -> [WikiSource] { + guard !legacyTargets.isEmpty else { + return [finalsSourceFromLegacyFlags( + allowFinalsCommand: allowFinalsCommand, + allowWikiAlias: allowWikiAlias, + allowWeaponCommand: allowWeaponCommand, + includeWeaponStats: includeWeaponStats + )] + } + + return legacyTargets.map { legacy in + let isFinals = legacy.kind == .finals || + (legacy.baseURL?.lowercased().contains("thefinals.wiki") ?? false) + if isFinals { + var finals = finalsSourceFromLegacyFlags( + allowFinalsCommand: allowFinalsCommand, + allowWikiAlias: allowWikiAlias, + allowWeaponCommand: allowWeaponCommand, + includeWeaponStats: includeWeaponStats + ) + finals.id = legacy.id ?? finals.id + finals.enabled = legacy.isEnabled ?? true + finals.name = legacy.name?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? finals.name + finals.baseURL = legacy.baseURL?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? finals.baseURL + finals.apiPath = legacy.apiPath?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? finals.apiPath + finals.lastLookupAt = legacy.lastLookupAt + finals.lastStatus = legacy.lastStatus ?? finals.lastStatus + return finals + } + + return WikiSource( + id: legacy.id ?? UUID(), + name: legacy.name?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "Wiki Source", + baseURL: legacy.baseURL?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "https://example.fandom.com", + apiPath: legacy.apiPath?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "/api.php", + enabled: legacy.isEnabled ?? true, + isPrimary: false, + commands: [ + WikiCommand(trigger: "!wiki", endpoint: "search", description: "Search wiki pages", enabled: allowWikiAlias) + ], + formatting: WikiFormatting( + includeStatBlocks: false, + useEmbeds: false, + compactMode: false + ), + parsingRules: [], + lastLookupAt: legacy.lastLookupAt, + lastStatus: legacy.lastStatus ?? "Ready" + ) + } + } + + private static func finalsSourceFromLegacyFlags( + allowFinalsCommand: Bool, + allowWikiAlias: Bool, + allowWeaponCommand: Bool, + includeWeaponStats: Bool + ) -> WikiSource { + var source = WikiSource.defaultFinals() + source.isPrimary = false + source.commands = source.commands.map { command in + var updated = command + let key = command.trigger.lowercased() + if key == "!finals" { + updated.enabled = allowFinalsCommand + } else if key == "!wiki" { + updated.enabled = allowWikiAlias + } else if key == "!weapon" { + updated.enabled = allowWeaponCommand + } + return updated + } + source.formatting.includeStatBlocks = includeWeaponStats + return source + } +} + +private extension String { + var nonEmpty: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} + diff --git a/SwiftBotApp/Models/BotStateModels.swift b/SwiftBotApp/Models/BotStateModels.swift new file mode 100644 index 0000000..9969789 --- /dev/null +++ b/SwiftBotApp/Models/BotStateModels.swift @@ -0,0 +1,183 @@ +import Foundation + +enum BotStatus: String { + case stopped + case connecting + case running + case reconnecting +} + +struct StatCounter { + var commandsRun = 0 + var voiceJoins = 0 + var voiceLeaves = 0 + var errors = 0 +} + +struct ActivityEvent: Identifiable, Hashable { + enum Kind: String, Codable { + case voiceJoin + case voiceLeave + case voiceMove + case command + case info + case warning + case error + } + + let id = UUID() + let timestamp: Date + let kind: Kind + let message: String +} + +struct CommandLogEntry: Identifiable, Hashable, Codable { + let id: UUID + let time: Date + let user: String + let server: String + let command: String + let channel: String + let executionRoute: String + let executionNode: String + let ok: Bool + + init( + id: UUID = UUID(), + time: Date, + user: String, + server: String, + command: String, + channel: String, + executionRoute: String, + executionNode: String, + ok: Bool + ) { + self.id = id + self.time = time + self.user = user + self.server = server + self.command = command + self.channel = channel + self.executionRoute = executionRoute + self.executionNode = executionNode + self.ok = ok + } +} + +enum BugStatus: String, Codable, Hashable { + case new = "New" + case workingOn = "Working On" + case inProgress = "In Progress" + case blocked = "Blocked" + case resolved = "Resolved" + + var emoji: String { + switch self { + case .new: + return "🐞" + case .workingOn: + return "🔧" + case .inProgress: + return "🟡" + case .blocked: + return "⛔" + case .resolved: + return "✅" + } + } +} + +struct BugEntry: Hashable, Codable { + let bugMessageID: String + let sourceMessageID: String + let channelID: String + let guildID: String + let reporterID: String + let createdBy: String + var status: BugStatus + var timestamp: Date +} + +struct BugAutoFixPendingStart: Hashable, Codable { + let bugMessageID: String + let channelID: String + let guildID: String + let sourceRepoPath: String + let isolatedRepoPath: String + let branch: String + let updateChannelID: String + let version: String + let build: String + let requestedByUserID: String +} + +struct BugAutoFixPendingApproval: Hashable, Codable { + let bugMessageID: String + let channelID: String + let guildID: String + let sourceRepoPath: String + let isolatedRepoPath: String + let branch: String + let updateChannelID: String + let version: String + let build: String +} + +struct VoiceMemberPresence: Identifiable, Hashable, Codable { + let id: String + let userId: String + let username: String + let guildId: String + let channelId: String + let channelName: String + let joinedAt: Date +} + +struct VoiceEventLogEntry: Identifiable, Hashable, Codable { + let id: UUID + let time: Date + let description: String + + init(id: UUID = UUID(), time: Date, description: String) { + self.id = id + self.time = time + self.description = description + } +} + +struct FinalsWikiLookupResult: Codable, Hashable { + let title: String + let extract: String + let url: String + let weaponStats: FinalsWeaponStats? +} + +struct FinalsWeaponStats: Codable, Hashable { + let type: String? + let bodyDamage: String? + let headshotDamage: String? + let fireRate: String? + let dropoffStart: String? + let dropoffEnd: String? + let minimumDamage: String? + let magazineSize: String? + let shortReload: String? + let longReload: String? +} + +struct GuildVoiceChannel: Identifiable, Hashable, Codable { + let id: String + let name: String +} + +struct GuildTextChannel: Identifiable, Hashable, Codable { + let id: String + let name: String +} + +struct GuildRole: Identifiable, Hashable, Codable { + let id: String + let name: String + let permissions: String? +} diff --git a/SwiftBotApp/Models/ClusterModels.swift b/SwiftBotApp/Models/ClusterModels.swift new file mode 100644 index 0000000..f023f19 --- /dev/null +++ b/SwiftBotApp/Models/ClusterModels.swift @@ -0,0 +1,541 @@ +import Foundation + +// MARK: - Patchy Settings + +enum PatchySourceKind: String, Codable, CaseIterable, Identifiable { + case nvidia = "NVIDIA" + case amd = "AMD" + case intel = "Intel Arc" + case steam = "Steam" + + var id: String { rawValue } +} + +struct PatchyDeliveryTarget: Codable, Hashable, Identifiable { + var id: UUID = UUID() + var isEnabled: Bool = true + var name: String = "Target" + var serverId: String = "" + var channelId: String = "" + var roleIDs: [String] = [] +} + +struct PatchySourceTarget: Codable, Hashable, Identifiable { + var id: UUID = UUID() + var isEnabled: Bool = true + var source: PatchySourceKind = .nvidia + var steamAppID: String = "570" + var serverId: String = "" + var channelId: String = "" + var roleIDs: [String] = [] + var lastCheckedAt: Date? + var lastRunAt: Date? + var lastStatus: String = "Never checked" +} + +struct PatchySettings: Codable, Hashable { + var monitoringEnabled: Bool = false + var showDebug: Bool = false + var sourceTargets: [PatchySourceTarget] = [] + var steamAppNames: [String: String] = [:] + + // Legacy fields kept for migration compatibility. + var source: PatchySourceKind = .nvidia + var steamAppID: String = "570" + var saveAfterFetch: Bool = true + var targets: [PatchyDeliveryTarget] = [] +} + +// MARK: - SwiftMesh Settings + +struct SwiftMeshSettings: Codable, Hashable { + var mode: ClusterMode = .standalone + var nodeName: String = Host.current().localizedName ?? "SwiftBot Node" + var leaderAddress: String = "" + var leaderPort: Int = 38787 + var listenPort: Int = 38787 + var sharedSecret: String = "" + var leaderTerm: Int = 0 +} + +struct MeshSyncedFile: Codable, Hashable { + let fileName: String + let base64Data: String +} + +/// A lightweight snapshot of all user-configurable settings, used to detect unsaved changes in the UI. +struct AppPreferencesSnapshot: Equatable { + // General + var token = "" + var prefix = "/" + var autoStart = false + + // SwiftMesh + var clusterMode: ClusterMode = .standalone + var clusterNodeName = "" + var clusterLeaderAddress = "" + var clusterLeaderPort = 38787 + var clusterListenPort = 38787 + var clusterSharedSecret = "" + var clusterWorkerOffloadEnabled = false + var clusterOffloadAIReplies = false + var clusterOffloadWikiLookups = false + + // Media Library + var mediaSourcesJSON = "" + + // Admin Web UI + var adminWebEnabled = false + var adminWebHost = "" + var adminWebPort = 38888 + var adminWebBaseURL = "" + var adminWebHTTPSEnabled = false + var adminWebCertificateMode: AdminWebUICertificateMode = .automatic + var adminWebHostname = "" + var adminWebCloudflareToken = "" + var adminWebPublicAccessEnabled = false + var adminWebImportedCertificateFile = "" + var adminWebImportedPrivateKeyFile = "" + var adminWebImportedCertificateChainFile = "" + var adminLocalAuthEnabled = false + var adminLocalAuthUsername = "" + var adminLocalAuthPassword = "" + var adminRestrictSpecificUsers = false + var adminDiscordClientID = "" + var adminDiscordClientSecret = "" + var adminAllowedUserIDs = "" + var adminRedirectPath = "" + + // AI Bots + var localAIDMReplyEnabled = false + var useAIInGuildChannels = false + var allowDMs = false + var preferredAIProvider: AIProviderPreference = .apple + var ollamaBaseURL = "" + var ollamaModel = "" + var ollamaEnabled = false + var openAIEnabled = false + var openAIAPIKey = "" + var openAIModel = "" + var openAIImageGenerationEnabled = false + var openAIImageModel = "" + var openAIImageMonthlyLimitPerUser = 0 + var localAISystemPrompt = "" + + // Developer & Bug Auto-Fix + var devFeaturesEnabled = false + var bugAutoFixEnabled = false + var bugAutoFixTriggerEmoji = "🤖" + var bugAutoFixCommandTemplate = "codex exec \"$SWIFTBOT_BUG_PROMPT\"" + var bugAutoFixRepoPath = "" + var bugAutoFixGitBranch = "main" + var bugAutoFixVersionBumpEnabled = true + var bugAutoFixPushEnabled = true + var bugAutoFixRequireApproval = true + var bugAutoFixApproveEmoji = "🚀" + var bugAutoFixRejectEmoji = "🛑" + var bugAutoFixAllowedUsernames = "" +} + +struct MeshSyncedFilesPayload: Codable, Hashable { + let generatedAt: Date + let files: [MeshSyncedFile] +} + +// MARK: - Cluster Mode + +enum ClusterMode: String, Codable, CaseIterable, Identifiable { + case standalone = "Standalone" + case leader = "Leader" + case worker = "Worker" + case standby = "Standby" + + var id: String { rawValue } + + static var selectableCases: [ClusterMode] { + [.standalone, .leader, .standby] + } + + var displayName: String { + switch self { + case .standalone: return "Standalone" + case .leader: return "Primary" + case .worker: return "Worker" + case .standby: return "Fail Over" + } + } + + var description: String { + switch self { + case .standalone: return "Normal operation. All bot features are managed locally." + case .leader: return "This node acts as the Primary node for the SwiftMesh cluster." + case .worker: return "Deprecated. This node performs offloaded compute tasks for the Primary node." + case .standby: return "This node will automatically promote to Primary node if the current Leader fails. (Fail Over node)" + } + } +} + +// MARK: - Action Dispatcher + +/// Central authority for Discord output actions in a SwiftMesh cluster. +/// +/// All outbound Discord actions must pass through this gate before execution. +/// Only Primary nodes (`.standalone` or `.leader`) are permitted to perform +/// Discord side-effects. Worker and Standby nodes are blocked at this layer. +/// +/// This design is intentionally extensible: in future, `canSend` can be updated +/// to route blocked actions to a Primary node via SwiftMesh HTTP instead of +/// simply discarding them, enabling distributed task delegation. +enum ActionDispatcher { + + /// Returns `true` if the current node is permitted to send Discord output. + /// + /// - Parameters: + /// - clusterMode: The current SwiftMesh cluster role of this node. + /// - action: A descriptive label for the action being attempted (used in logs). + /// - log: A closure that receives warning messages when an action is blocked. + /// - Returns: `true` if the node may proceed; `false` if the action is blocked. + static func canSend( + clusterMode: ClusterMode, + action: String, + log: (String) -> Void + ) -> Bool { + guard clusterMode == .standalone || clusterMode == .leader else { + log("⚠️ [ActionDispatcher] Blocked '\(action)' — node role '\(clusterMode.rawValue)' is not authorised to send Discord output. Only Primary (Standalone/Leader) may perform Discord side-effects.") + return false + } + return true + } +} + +// MARK: - SwiftMesh Protocol Types + +/// Sent by the leader to notify workers and standbys that a new leader has taken over. +/// Workers must reject this if `term` is not newer than their current known term. +struct MeshLeaderChangedPayload: Codable, Sendable { + let term: Int + let leaderAddress: String + let leaderNodeName: String + let sharedSecret: String +} + +/// Sent by the leader to the standby to replicate the registered worker list. +struct MeshWorkerRegistryPayload: Codable, Sendable { + struct WorkerEntry: Codable, Sendable { + let nodeName: String + let baseURL: String + let listenPort: Int + } + let workers: [WorkerEntry] + let leaderTerm: Int +} + +/// Incremental conversation sync payload sent leader → standby. +/// Records are ordered by (timestamp ascending, id ascending) for deterministic replay. +struct MeshSyncPayload: Codable, Sendable { + let conversations: [MemoryRecord] + let imageUsage: [String: Int]? + let commandLog: [CommandLogEntry]? + let voiceLog: [VoiceEventLogEntry]? + let activeVoice: [VoiceMemberPresence]? + let configFilesChanged: Bool + let leaderTerm: Int + /// ID of the last record in this batch — standby stores as its new cursor. + let cursorRecordID: String? + /// True if more records exist beyond this batch; standby should request resync for next page. + let hasMore: Bool + /// The cursor the leader assumed this node held when building this batch. + /// Node compares against its own lastMergedRecordID to detect gaps. + let fromCursorRecordID: String? + + init( + conversations: [MemoryRecord], + imageUsage: [String: Int]? = nil, + commandLog: [CommandLogEntry]? = nil, + voiceLog: [VoiceEventLogEntry]? = nil, + activeVoice: [VoiceMemberPresence]? = nil, + configFilesChanged: Bool = false, + leaderTerm: Int, + cursorRecordID: String? = nil, + hasMore: Bool = false, + fromCursorRecordID: String? = nil + ) { + self.conversations = conversations + self.imageUsage = imageUsage + self.commandLog = commandLog + self.voiceLog = voiceLog + self.activeVoice = activeVoice + self.configFilesChanged = configFilesChanged + self.leaderTerm = leaderTerm + self.cursorRecordID = cursorRecordID + self.hasMore = hasMore + self.fromCursorRecordID = fromCursorRecordID + } +} + +/// Standby → leader: request a bounded checkpoint batch starting from a cursor. +struct MeshResyncRequest: Codable, Sendable { + /// ID of the last successfully merged record (nil = start from beginning). + let fromRecordID: String? + let pageSize: Int +} + +/// Leader tracks one cursor per registered node (keyed by node base URL). +/// Persisted to disk so leader restart does not force blind full-replay. +struct ReplicationCursor: Codable, Sendable { + /// The leader term in which this cursor was last updated. + var leaderTerm: Int + /// ID of the last record successfully delivered to this node. + var lastSentRecordID: String? + /// When this cursor was last advanced. + var updatedAt: Date +} + +// MARK: - Cluster State Enums + +enum ClusterConnectionState: String { + case inactive + case starting + case listening + case connected + case degraded + case stopped + case failed +} + +enum ClusterJobRoute: String { + case local + case remote + case unavailable +} + +enum ClusterNodeRole: String, Codable, Hashable { + case leader + case worker + + var displayName: String { + rawValue.capitalized + } +} + +enum ClusterNodeConnectionStatus: String, Codable, Hashable { + case connected + case disconnected + case degraded + case starting + case failed + + var displayName: String { + rawValue.capitalized + } +} + +enum ClusterNodeHealthStatus: String, Codable, Hashable { + case healthy + case degraded + case disconnected + + var displayName: String { + switch self { + case .healthy: return "Healthy" + case .degraded: return "Degraded" + case .disconnected: return "Disconnected" + } + } + + init(connectionStatus: ClusterNodeConnectionStatus) { + switch connectionStatus { + case .connected: + self = .healthy + case .starting, .degraded: + self = .degraded + case .failed, .disconnected: + self = .disconnected + } + } + + var connectionStatus: ClusterNodeConnectionStatus { + switch self { + case .healthy: + return .connected + case .degraded: + return .degraded + case .disconnected: + return .disconnected + } + } +} + +extension ClusterConnectionState { + var nodeConnectionStatus: ClusterNodeConnectionStatus { + switch self { + case .connected, .listening: + return .connected + case .starting: + return .starting + case .degraded: + return .degraded + case .failed: + return .failed + case .inactive, .stopped: + return .disconnected + } + } + + var nodeHealthStatus: ClusterNodeHealthStatus { + ClusterNodeHealthStatus(connectionStatus: nodeConnectionStatus) + } +} + +// MARK: - Cluster Node Status + +struct ClusterNodeStatus: Identifiable, Codable, Hashable { + var id: String + var hostname: String + var displayName: String + var role: ClusterNodeRole + var hardwareModel: String + var cpu: Double + var mem: Double + var cpuName: String + var physicalMemoryBytes: UInt64 + var uptime: TimeInterval + var latencyMs: Double? + var status: ClusterNodeHealthStatus + var jobsActive: Int + + var hardwareName: String { displayName } + var uptimeSeconds: TimeInterval { uptime } + var connectionStatus: ClusterNodeConnectionStatus { status.connectionStatus } + var connectionStatusText: String { status.displayName } + + init( + id: String, + hostname: String, + displayName: String, + role: ClusterNodeRole, + hardwareModel: String, + cpu: Double, + mem: Double, + cpuName: String = "Unknown CPU", + physicalMemoryBytes: UInt64 = 0, + uptime: TimeInterval, + latencyMs: Double?, + status: ClusterNodeHealthStatus, + jobsActive: Int + ) { + self.id = id + self.hostname = hostname + self.displayName = displayName + self.role = role + self.hardwareModel = hardwareModel + self.cpu = cpu + self.mem = mem + self.cpuName = cpuName + self.physicalMemoryBytes = physicalMemoryBytes + self.uptime = uptime + self.latencyMs = latencyMs + self.status = status + self.jobsActive = jobsActive + } + + private enum CodingKeys: String, CodingKey { + case id + case hostname + case displayName + case role + case hardwareModel + case cpu + case mem + case cpuName + case physicalMemoryBytes + case uptime + case latencyMs + case status + case jobsActive + + // Legacy decode compatibility. + case hardwareName + case uptimeSeconds + case connectionStatus + case connectionStatusText + case cpuPercent + case memoryPercent + case memoryBytes + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString + hostname = try container.decodeIfPresent(String.self, forKey: .hostname) ?? "unknown-host" + displayName = try container.decodeIfPresent(String.self, forKey: .displayName) + ?? (try container.decodeIfPresent(String.self, forKey: .hardwareName) ?? hostname) + role = try container.decodeIfPresent(ClusterNodeRole.self, forKey: .role) ?? .worker + hardwareModel = try container.decodeIfPresent(String.self, forKey: .hardwareModel) ?? "Mac" + cpu = try container.decodeIfPresent(Double.self, forKey: .cpu) + ?? (try container.decodeIfPresent(Double.self, forKey: .cpuPercent) ?? 0) + mem = try container.decodeIfPresent(Double.self, forKey: .mem) + ?? (try container.decodeIfPresent(Double.self, forKey: .memoryPercent) ?? 0) + cpuName = try container.decodeIfPresent(String.self, forKey: .cpuName) ?? "Unknown CPU" + physicalMemoryBytes = try container.decodeIfPresent(UInt64.self, forKey: .physicalMemoryBytes) + ?? (try container.decodeIfPresent(UInt64.self, forKey: .memoryBytes) ?? 0) + uptime = try container.decodeIfPresent(TimeInterval.self, forKey: .uptime) + ?? (try container.decodeIfPresent(TimeInterval.self, forKey: .uptimeSeconds) ?? 0) + latencyMs = try container.decodeIfPresent(Double.self, forKey: .latencyMs) + jobsActive = try container.decodeIfPresent(Int.self, forKey: .jobsActive) ?? 0 + + if let decodedStatus = try container.decodeIfPresent(ClusterNodeHealthStatus.self, forKey: .status) { + status = decodedStatus + } else if let legacyConnection = try container.decodeIfPresent(ClusterNodeConnectionStatus.self, forKey: .connectionStatus) { + status = ClusterNodeHealthStatus(connectionStatus: legacyConnection) + } else { + let legacyText = (try container.decodeIfPresent(String.self, forKey: .connectionStatusText) ?? "").lowercased() + if legacyText.contains("degrad") || legacyText.contains("start") { + status = .degraded + } else if legacyText.contains("disconnect") || legacyText.contains("fail") || legacyText.contains("offline") || legacyText.contains("unavailable") { + status = .disconnected + } else { + status = .healthy + } + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(hostname, forKey: .hostname) + try container.encode(displayName, forKey: .displayName) + try container.encode(role, forKey: .role) + try container.encode(hardwareModel, forKey: .hardwareModel) + try container.encode(cpu, forKey: .cpu) + try container.encode(mem, forKey: .mem) + try container.encode(cpuName, forKey: .cpuName) + try container.encode(physicalMemoryBytes, forKey: .physicalMemoryBytes) + try container.encode(uptime, forKey: .uptime) + try container.encodeIfPresent(latencyMs, forKey: .latencyMs) + try container.encode(status, forKey: .status) + try container.encode(jobsActive, forKey: .jobsActive) + } +} + +struct ClusterStatusResponse: Codable, Hashable { + var mode: ClusterMode + var generatedAt: String + var nodes: [ClusterNodeStatus] +} + +struct ClusterSnapshot: Hashable { + var mode: ClusterMode = .standalone + var nodeName: String = Host.current().localizedName ?? "SwiftBot Node" + var listenPort: Int = 38787 + var leaderAddress: String = "" + var leaderTerm: Int = 0 + var serverState: ClusterConnectionState = .inactive + var workerState: ClusterConnectionState = .inactive + var serverStatusText: String = "Disabled" + var workerStatusText: String = "Local only" + var lastJobRoute: ClusterJobRoute = .local + var lastJobSummary: String = "No remote jobs yet" + var lastJobNode: String = Host.current().localizedName ?? "SwiftBot Node" + var diagnostics: String = "No diagnostics yet" +} diff --git a/SwiftBotApp/Models/DiscordCache.swift b/SwiftBotApp/Models/DiscordCache.swift new file mode 100644 index 0000000..3b3aa08 --- /dev/null +++ b/SwiftBotApp/Models/DiscordCache.swift @@ -0,0 +1,251 @@ +import Foundation + +struct DiscordCacheSnapshot: Codable, Hashable { + var updatedAt: Date = Date() + var connectedServers: [String: String] = [:] + var availableVoiceChannelsByServer: [String: [GuildVoiceChannel]] = [:] + var availableTextChannelsByServer: [String: [GuildTextChannel]] = [:] + var availableRolesByServer: [String: [GuildRole]] = [:] + var usernamesById: [String: String] = [:] + var channelTypesById: [String: Int] = [:] + + private enum CodingKeys: String, CodingKey { + case updatedAt + case connectedServers + case availableVoiceChannelsByServer + case availableTextChannelsByServer + case availableRolesByServer + case usernamesById + case channelTypesById + } + + init() {} + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? Date() + connectedServers = try container.decodeIfPresent([String: String].self, forKey: .connectedServers) ?? [:] + availableVoiceChannelsByServer = try container.decodeIfPresent([String: [GuildVoiceChannel]].self, forKey: .availableVoiceChannelsByServer) ?? [:] + availableTextChannelsByServer = try container.decodeIfPresent([String: [GuildTextChannel]].self, forKey: .availableTextChannelsByServer) ?? [:] + availableRolesByServer = try container.decodeIfPresent([String: [GuildRole]].self, forKey: .availableRolesByServer) ?? [:] + usernamesById = try container.decodeIfPresent([String: String].self, forKey: .usernamesById) ?? [:] + channelTypesById = try container.decodeIfPresent([String: Int].self, forKey: .channelTypesById) ?? [:] + } +} + +actor DiscordCache { + private var snapshot: DiscordCacheSnapshot + private var updateContinuations: [UUID: AsyncStream.Continuation] = [:] + + init(snapshot: DiscordCacheSnapshot = DiscordCacheSnapshot()) { + self.snapshot = snapshot + } + + var updates: AsyncStream { + AsyncStream { continuation in + let id = UUID() + updateContinuations[id] = continuation + continuation.onTermination = { [weak self] _ in + Task { await self?.removeUpdateContinuation(id) } + } + } + } + + func replace(with snapshot: DiscordCacheSnapshot) { + self.snapshot = snapshot + emitUpdate() + } + + func currentSnapshot() -> DiscordCacheSnapshot { + var copy = snapshot + copy.updatedAt = Date() + return copy + } + + func guildName(for guildID: String) -> String? { + snapshot.connectedServers[guildID] + } + + func userName(for userID: String) -> String? { + snapshot.usernamesById[userID] + } + + func channelName(for channelID: String) -> String? { + for channels in snapshot.availableTextChannelsByServer.values { + if let channel = channels.first(where: { $0.id == channelID }) { + return channel.name + } + } + for channels in snapshot.availableVoiceChannelsByServer.values { + if let channel = channels.first(where: { $0.id == channelID }) { + return channel.name + } + } + return nil + } + + func channelType(for channelID: String) -> Int? { + snapshot.channelTypesById[channelID] + } + + func setChannelType(channelID: String, type: Int) { + snapshot.channelTypesById[channelID] = type + emitUpdate() + } + + func mergeChannelTypes(_ channelTypes: [String: Int]) { + guard !channelTypes.isEmpty else { return } + var didChange = false + for (channelID, type) in channelTypes { + if snapshot.channelTypesById[channelID] != type { + snapshot.channelTypesById[channelID] = type + didChange = true + } + } + if didChange { + emitUpdate() + } + } + + func allGuildNames() -> [String: String] { + snapshot.connectedServers + } + + func voiceChannelsByGuild() -> [String: [GuildVoiceChannel]] { + snapshot.availableVoiceChannelsByServer + } + + func textChannelsByGuild() -> [String: [GuildTextChannel]] { + snapshot.availableTextChannelsByServer + } + + func rolesByGuild() -> [String: [GuildRole]] { + snapshot.availableRolesByServer + } + + func allUserNames() -> [String: String] { + snapshot.usernamesById + } + + func upsertGuild(id guildID: String, name: String?) { + let fallback = "Server \(guildID.suffix(4))" + let candidate = (name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + + if !candidate.isEmpty { + if snapshot.connectedServers[guildID] != candidate { + snapshot.connectedServers[guildID] = candidate + emitUpdate() + } + return + } + + // Preserve any known guild name when only an ID is available. + if snapshot.connectedServers[guildID] == nil { + snapshot.connectedServers[guildID] = fallback + emitUpdate() + } + } + + func removeGuild(id guildID: String) { + let textChannels = snapshot.availableTextChannelsByServer[guildID] ?? [] + let voiceChannels = snapshot.availableVoiceChannelsByServer[guildID] ?? [] + for channel in textChannels { + snapshot.channelTypesById[channel.id] = nil + } + for channel in voiceChannels { + snapshot.channelTypesById[channel.id] = nil + } + snapshot.connectedServers[guildID] = nil + snapshot.availableVoiceChannelsByServer[guildID] = nil + snapshot.availableTextChannelsByServer[guildID] = nil + snapshot.availableRolesByServer[guildID] = nil + emitUpdate() + } + + func setGuildVoiceChannels(guildID: String, channels: [GuildVoiceChannel]) { + let oldChannels = snapshot.availableVoiceChannelsByServer[guildID] ?? [] + for channel in oldChannels { + snapshot.channelTypesById[channel.id] = nil + } + snapshot.availableVoiceChannelsByServer[guildID] = channels + for channel in channels { + snapshot.channelTypesById[channel.id] = 2 + } + emitUpdate() + } + + func setGuildTextChannels(guildID: String, channels: [GuildTextChannel]) { + let oldChannels = snapshot.availableTextChannelsByServer[guildID] ?? [] + for channel in oldChannels { + snapshot.channelTypesById[channel.id] = nil + } + snapshot.availableTextChannelsByServer[guildID] = channels + for channel in channels { + snapshot.channelTypesById[channel.id] = 0 + } + emitUpdate() + } + + func setGuildRoles(guildID: String, roles: [GuildRole]) { + snapshot.availableRolesByServer[guildID] = roles + emitUpdate() + } + + func upsertChannel(guildID: String?, channelID: String, name: String, type: Int) { + let cleaned = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleaned.isEmpty else { return } + snapshot.channelTypesById[channelID] = type + + if type == 1 || type == 3 { + emitUpdate() + return + } + guard let guildID else { + emitUpdate() + return + } + + if type == 0 || type == 5 { + var channels = snapshot.availableTextChannelsByServer[guildID] ?? [] + if let index = channels.firstIndex(where: { $0.id == channelID }) { + channels[index] = GuildTextChannel(id: channelID, name: cleaned) + } else { + channels.append(GuildTextChannel(id: channelID, name: cleaned)) + } + channels.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + snapshot.availableTextChannelsByServer[guildID] = channels + emitUpdate() + return + } + + if type == 2 || type == 13 { + var channels = snapshot.availableVoiceChannelsByServer[guildID] ?? [] + if let index = channels.firstIndex(where: { $0.id == channelID }) { + channels[index] = GuildVoiceChannel(id: channelID, name: cleaned) + } else { + channels.append(GuildVoiceChannel(id: channelID, name: cleaned)) + } + channels.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + snapshot.availableVoiceChannelsByServer[guildID] = channels + emitUpdate() + } + } + + func upsertUser(id userID: String, preferredName: String?) { + let cleaned = (preferredName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleaned.isEmpty else { return } + if snapshot.usernamesById[userID] == cleaned { return } + snapshot.usernamesById[userID] = cleaned + emitUpdate() + } + + private func emitUpdate() { + for continuation in updateContinuations.values { + continuation.yield(()) + } + } + + private func removeUpdateContinuation(_ id: UUID) { + updateContinuations.removeValue(forKey: id) + } +} diff --git a/SwiftBotApp/Models/GatewayModels.swift b/SwiftBotApp/Models/GatewayModels.swift new file mode 100644 index 0000000..7fe92db --- /dev/null +++ b/SwiftBotApp/Models/GatewayModels.swift @@ -0,0 +1,114 @@ +import Foundation + +// MARK: - Gateway Payload + +struct GatewayPayload: Codable { + let op: Int + let d: DiscordJSON? + let s: Int? + let t: String? +} + +// MARK: - Discord JSON + +enum DiscordJSON: Codable, Equatable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case object([String: DiscordJSON]) + case array([DiscordJSON]) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { self = .null } + else if let value = try? container.decode(String.self) { self = .string(value) } + else if let value = try? container.decode(Int.self) { self = .int(value) } + else if let value = try? container.decode(Double.self) { self = .double(value) } + else if let value = try? container.decode(Bool.self) { self = .bool(value) } + else if let value = try? container.decode([String: DiscordJSON].self) { self = .object(value) } + else if let value = try? container.decode([DiscordJSON].self) { self = .array(value) } + else { throw DecodingError.typeMismatch(DiscordJSON.self, .init(codingPath: decoder.codingPath, debugDescription: "Unsupported JSON type")) } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let v): try container.encode(v) + case .int(let v): try container.encode(v) + case .double(let v): try container.encode(v) + case .bool(let v): try container.encode(v) + case .object(let v): try container.encode(v) + case .array(let v): try container.encode(v) + case .null: try container.encodeNil() + } + } +} + +// MARK: - Voice Rule Event + +struct VoiceRuleEvent { + enum Kind { + case join + case leave + case move + case message + case memberJoin + case memberLeave + case mediaAdded + } + + let kind: Kind + let guildId: String + let userId: String + let username: String + let channelId: String + let fromChannelId: String? + let toChannelId: String? + let durationSeconds: Int? + let messageContent: String? + let messageId: String? + let mediaFileName: String? + let mediaRelativePath: String? + let mediaSourceName: String? + let mediaNodeName: String? + let triggerMessageId: String? + let triggerChannelId: String? + let triggerGuildId: String + let triggerUserId: String + let isDirectMessage: Bool + let authorIsBot: Bool? + let joinedAt: Date? +} + +// MARK: - Pipeline Context + +/// Context maintained during a single rule execution pipeline +struct PipelineContext: CustomStringConvertible { + var aiResponse: String? + var aiSummary: String? + var aiClassification: String? + var aiEntities: String? + var aiRewrite: String? + var triggerGuildId: String? + var triggerChannelId: String? + var triggerMessageId: String? + var targetChannelId: String? + var targetServerId: String? + var mentionUser: Bool = true + var prependUserMention: Bool = false + var replyToTriggerMessage: Bool = false + var mentionRole: String? + var isDirectMessage: Bool = false + var sendToDM: Bool = false + var eventHandled: Bool = false + + var description: String { + let ai = aiResponse != nil ? "AI(\(aiResponse!.count) chars)" : "nil" + let summary = aiSummary != nil ? "Summary(\(aiSummary!.count) chars)" : "nil" + let target = targetChannelId ?? "default" + let trigger = triggerChannelId ?? "none" + return "[PipelineContext target: \(target), trigger: \(trigger), mentionUser: \(mentionUser), prepend: \(prependUserMention), reply: \(replyToTriggerMessage), role: \(mentionRole ?? "nil"), ai: \(ai), summary: \(summary), handled: \(eventHandled)]" + } +} diff --git a/SwiftBotApp/Models/KeychainHelper.swift b/SwiftBotApp/Models/KeychainHelper.swift new file mode 100644 index 0000000..7d116f4 --- /dev/null +++ b/SwiftBotApp/Models/KeychainHelper.swift @@ -0,0 +1,72 @@ +import Foundation + +enum KeychainHelper { + private static let service = "com.swiftbot.app" + private static let account = "discord-token" + + /// Saves the token to the Keychain. + @discardableResult + static func saveToken(_ token: String) -> Bool { + save(token, account: account) + } + + @discardableResult + static func save(_ value: String, account: String) -> Bool { + guard let data = value.data(using: .utf8) else { return false } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecValueData as String: data + ] + + // Delete any existing item before saving the new one. + SecItemDelete(query as CFDictionary) + + let status = SecItemAdd(query as CFDictionary, nil) + return status == errSecSuccess + } + + /// Retrieves the token from the Keychain. + static func loadToken() -> String? { + load(account: account) + } + + static func load(account: String) -> String? { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var dataTypeRef: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) + + if status == errSecSuccess, let data = dataTypeRef as? Data { + return String(data: data, encoding: .utf8) + } + + return nil + } + + /// Deletes the token from the Keychain. + @discardableResult + static func deleteToken() -> Bool { + delete(account: account) + } + + @discardableResult + static func delete(account: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess + } +} diff --git a/SwiftBotApp/Models/RuleEngineModels.swift b/SwiftBotApp/Models/RuleEngineModels.swift new file mode 100644 index 0000000..c39fdc1 --- /dev/null +++ b/SwiftBotApp/Models/RuleEngineModels.swift @@ -0,0 +1,1603 @@ +import Foundation +import Combine +import SwiftUI + +@MainActor +final class RuleStore: ObservableObject { + @Published var rules: [Rule] = [] + @Published var selectedRuleID: UUID? + @Published var lastSavedAt: Date? + @Published var isLoading: Bool = false + + private let store = RuleConfigStore() + private var autoSaveTask: Task? + var onPersisted: (@Sendable () async -> Void)? + + init() { + Task { + isLoading = true + let loaded = await store.load() + rules = loaded ?? [] + selectedRuleID = nil + isLoading = false + } + } + + func addNewRule(serverId: String = "", channelId: String = "") { + var rule = Rule.empty() + rule.triggerServerId = serverId + // New rules start empty - users add blocks via Block Library + rules.append(rule) + selectedRuleID = rule.id + scheduleAutoSave() + } + + func deleteRules(at offsets: IndexSet, undoManager: UndoManager?) { + let sortedOffsets = offsets.sorted() + guard !sortedOffsets.isEmpty else { return } + let removed = sortedOffsets.map { ($0, rules[$0]) } + let previousSelection = selectedRuleID + + for index in sortedOffsets.reversed() { + rules.remove(at: index) + } + reseatSelection(previousSelection: previousSelection) + scheduleAutoSave() + + undoManager?.registerUndo(withTarget: self) { target in + target.restoreRules(removed, previousSelection: previousSelection, undoManager: undoManager) + } + } + + func deleteRule(id: UUID, undoManager: UndoManager?) { + guard let idx = rules.firstIndex(where: { $0.id == id }) else { return } + deleteRules(at: IndexSet(integer: idx), undoManager: undoManager) + } + + func save() { + let snapshot = rules + Task { + try? await store.save(snapshot) + lastSavedAt = Date() + await onPersisted?() + } + } + + func reloadFromDisk() async { + isLoading = true + let loaded = await store.load() + rules = loaded ?? [] + if let selected = selectedRuleID, + !rules.contains(where: { $0.id == selected }) { + selectedRuleID = nil + } + isLoading = false + } + + func scheduleAutoSave() { + autoSaveTask?.cancel() + autoSaveTask = Task { + try? await Task.sleep(nanoseconds: 500_000_000) + guard !Task.isCancelled else { return } + save() + } + } + + private func restoreRules(_ removed: [(Int, Rule)], previousSelection: UUID?, undoManager: UndoManager?) { + for (index, rule) in removed.sorted(by: { $0.0 < $1.0 }) { + let insertIndex = min(index, rules.count) + rules.insert(rule, at: insertIndex) + } + selectedRuleID = previousSelection ?? removed.first?.1.id + scheduleAutoSave() + + undoManager?.registerUndo(withTarget: self) { target in + let offsets = IndexSet(removed.map(\.0)) + target.deleteRules(at: offsets, undoManager: undoManager) + } + } + + private func reseatSelection(previousSelection: UUID?) { + guard let previousSelection else { + selectedRuleID = nil + return + } + + if rules.contains(where: { $0.id == previousSelection }) { + selectedRuleID = previousSelection + } else { + selectedRuleID = nil + } + } +} + +final class RuleEngine { + private var cancellable: AnyCancellable? + private var _activeRules: [Rule] = [] + private let lock = NSLock() + + private var activeRules: [Rule] { + get { + lock.lock() + defer { lock.unlock() } + return _activeRules + } + set { + lock.lock() + _activeRules = newValue + lock.unlock() + } + } + + init(store: RuleStore) { + Task { @MainActor [weak self] in + guard let self else { return } + self.activeRules = store.rules.filter(\.isEnabled) + self.cancellable = store.$rules.sink { [weak self] rules in + self?.activeRules = rules.filter(\.isEnabled) + } + } + } + + func evaluateRules(event: VoiceRuleEvent) -> [Rule] { + activeRules + .filter { rule in matchesTrigger(rule: rule, event: event) && matchesConditions(rule: rule, event: event) } + } + + private func matchesTrigger(rule: Rule, event: VoiceRuleEvent) -> Bool { + guard let trigger = rule.trigger else { return false } + switch (trigger, event.kind) { + case (.userJoinedVoice, .join), + (.userLeftVoice, .leave), + (.userMovedVoice, .move), + (.messageCreated, .message), + (.memberJoined, .memberJoin), + (.mediaAdded, .mediaAdded): + return true + default: + return false + } + } + + private func matchesConditions(rule: Rule, event: VoiceRuleEvent) -> Bool { + for condition in rule.conditions { + if !matches(condition: condition, event: event) { return false } + } + return true + } + + private func matches(condition: Condition, event: VoiceRuleEvent) -> Bool { + let value = condition.value.trimmingCharacters(in: .whitespacesAndNewlines) + switch condition.type { + case .server: + return value.isEmpty || event.guildId == value + case .voiceChannel: + // Voice channel conditions don't apply to member join/leave events — always pass. + if event.kind == .memberJoin || event.kind == .memberLeave { return true } + return value.isEmpty || event.channelId == value || event.fromChannelId == value || event.toChannelId == value + case .usernameContains: + guard !value.isEmpty else { return true } + return event.username.localizedCaseInsensitiveContains(value) + case .minimumDuration: + // Duration conditions don't apply to member join events — always pass. + if event.kind == .memberJoin || event.kind == .memberLeave { return true } + guard let minimum = Int(value), minimum > 0 else { return true } + guard let durationSeconds = event.durationSeconds else { return false } + return durationSeconds >= (minimum * 60) + case .channelIs: + // Channel conditions don't apply to voice events — always pass for now + return value.isEmpty || event.channelId == value + case .channelCategory: + // Channel category matching logic: typically we'd need channel metadata + // For now, treat as placeholder that always passes if not configured + return true + case .userHasRole: + // Role conditions not yet implemented for voice events — always pass + return true + case .userJoinedRecently: + guard let minutes = Int(value), minutes > 0 else { return true } + guard let joinedAt = event.joinedAt else { return false } + return Date().timeIntervalSince(joinedAt) <= Double(minutes * 60) + case .messageContains: + guard !value.isEmpty, let content = event.messageContent else { return true } + return content.localizedCaseInsensitiveContains(value) + case .messageStartsWith: + guard !value.isEmpty, let content = event.messageContent else { return true } + return content.lowercased().hasPrefix(value.lowercased()) + case .messageRegex: + guard !value.isEmpty, let content = event.messageContent else { return true } + // Basic regex matching - returns true on invalid regex to avoid breaking rules + guard let regex = try? NSRegularExpression(pattern: value, options: [.caseInsensitive]) else { return true } + let range = NSRange(content.startIndex..., in: content) + return regex.firstMatch(in: content, options: [], range: range) != nil + case .isDirectMessage: + return event.isDirectMessage + case .isFromBot: + return event.authorIsBot ?? false + case .isFromUser: + // Filter out bot messages if value is empty or "true" + return !(event.authorIsBot ?? false) + case .channelType: + // Channel type matching - placeholder for now + // Would need channel type metadata from Discord + return true + } + } +} + +protocol BotPlugin { + var name: String { get } + func register(on bus: EventBus) async + func unregister(from bus: EventBus) async +} + +final class PluginManager { + private var plugins: [BotPlugin] = [] + private let bus: EventBus + + init(bus: EventBus) { self.bus = bus } + + func add(_ plugin: BotPlugin) async { + plugins.append(plugin) + await plugin.register(on: bus) + } + + func removeAll() async { + for p in plugins { await p.unregister(from: bus) } + plugins.removeAll() + } +} + +final class WeeklySummaryPlugin: BotPlugin { + let name = "WeeklySummary" + + private var tokens: [SubscriptionToken] = [] + private var voiceDurations: [String: Int] = [:] // userId -> accumulated seconds + + init() {} + + func register(on bus: EventBus) async { + let joinToken = await bus.subscribe(VoiceJoined.self) { _ in + // No-op for accumulation; could log here if needed + } + tokens.append(joinToken) + + let leftToken = await bus.subscribe(VoiceLeft.self) { [weak self] event in + guard let self = self else { return } + self.voiceDurations[event.userId, default: 0] += max(0, event.durationSeconds) + } + tokens.append(leftToken) + } + + func unregister(from bus: EventBus) async { + for token in tokens { + await bus.unsubscribe(token) + } + tokens.removeAll() + } + + func snapshotSummary() -> String { + let sortedUsers = voiceDurations.sorted { $0.value > $1.value } + guard !sortedUsers.isEmpty else { + return "No voice activity recorded yet." + } + + let summaryLines = sortedUsers.prefix(5).map { userId, seconds in + let minutes = seconds / 60 + return "\(userId): \(minutes) minute\(minutes == 1 ? "" : "s")" + } + + return "Weekly Voice Summary:\n" + summaryLines.joined(separator: "\n") + } +} + +/// Single owner for AI prompt composition — tone prompt, context enrichment, and message shaping. +/// Both AppModel and DiscordService should go through this to ensure consistent prompt structure. +enum PromptComposer { + static let defaultTonePrompt = + "You are a friendly, casual Discord bot. Keep replies short and conversational — " + + "1 to 3 sentences max unless asked for detail. Use contractions naturally. " + + "Don't restate what the user said. Don't open every reply the same way. " + + "Match the energy of the conversation." + + private static let timeFormatter: DateFormatter = { + let f = DateFormatter() + f.timeStyle = .short + f.dateStyle = .medium + return f + }() + + /// Builds the fully-enriched system prompt string. + static func buildSystemPrompt( + base: String, + serverName: String?, + channelName: String?, + wikiContext: String? + ) -> String { + var prompt = base.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? defaultTonePrompt + : base.trimmingCharacters(in: .whitespacesAndNewlines) + if let wiki = wikiContext, !wiki.isEmpty { + prompt += "\n\n\(wiki)" + } + if let server = serverName, !server.isEmpty { + prompt += "\nServer: \(server)" + } + if let channel = channelName, !channel.isEmpty { + prompt += "\nChannel: \(channel)" + } + prompt += "\nCurrent Time: \(timeFormatter.string(from: Date()))" + return prompt + } + + /// Prepends a system message and filters empty/system-role messages from history. + static func buildMessages(systemPrompt: String, history: [Message]) -> [Message] { + let clean = history.filter { + !$0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + $0.role != .system + } + let systemMessage = Message( + channelID: "system", + userID: "system", + username: "System", + content: systemPrompt, + role: .system + ) + return [systemMessage] + clean + } +} + +/// A simple helper for interacting with the macOS Keychain. + +// MARK: - Navigation Models + +enum SidebarItem: String, CaseIterable, Identifiable { + case overview = "Overview" + case patchy = "Patchy" + case voice = "Actions" + case commands = "Commands" + case commandLog = "Command Log" + case wikiBridge = "WikiBridge" + case logs = "Logs" + case aiBots = "AI Bots" + case diagnostics = "Diagnostics" + case swiftMesh = "SwiftMesh" + + var id: String { rawValue } + + var icon: String { + switch self { + case .overview: return "square.grid.2x2.fill" + case .patchy: return "hammer.fill" + case .voice: return "bolt.circle" + case .commands: return "terminal.fill" + case .commandLog: return "list.bullet.clipboard.fill" + case .wikiBridge: return "book.pages.fill" + case .logs: return "list.bullet.clipboard.fill" + case .aiBots: return "sparkles.rectangle.stack.fill" + case .diagnostics: return "waveform.path.ecg" + case .swiftMesh: return "point.3.connected.trianglepath.dotted" + } + } +} + +// MARK: - Automation Models + +// MARK: - Context Variables + +/// Variables available in rule templates based on trigger context +enum ContextVariable: String, CaseIterable, Codable, Hashable { + case user = "{user}" + case userId = "{user.id}" + case username = "{user.name}" + case userNickname = "{user.nickname}" + case userMention = "{user.mention}" + case message = "{message}" + case messageId = "{message.id}" + case channel = "{channel}" + case channelId = "{channel.id}" + case channelName = "{channel.name}" + case guild = "{guild}" + case guildId = "{guild.id}" + case guildName = "{guild.name}" + case voiceChannel = "{voice.channel}" + case voiceChannelId = "{voice.channel.id}" + case reaction = "{reaction}" + case reactionEmoji = "{reaction.emoji}" + case duration = "{duration}" + case memberCount = "{memberCount}" + case aiResponse = "{ai.response}" + case aiSummary = "{ai.summary}" + case aiClassification = "{ai.classification}" + case aiEntities = "{ai.entities}" + case aiRewrite = "{ai.rewrite}" + case mediaFile = "{media.file}" + case mediaPath = "{media.path}" + case mediaSource = "{media.source}" + case mediaNode = "{media.node}" + + var displayName: String { + switch self { + case .user: return "User" + case .userId: return "User ID" + case .username: return "Username" + case .userNickname: return "Nickname" + case .userMention: return "@Mention" + case .message: return "Message Content" + case .messageId: return "Message ID" + case .channel: return "Channel" + case .channelId: return "Channel ID" + case .channelName: return "Channel Name" + case .guild: return "Server" + case .guildId: return "Server ID" + case .guildName: return "Server Name" + case .voiceChannel: return "Voice Channel" + case .voiceChannelId: return "Voice Channel ID" + case .reaction: return "Reaction" + case .reactionEmoji: return "Emoji" + case .duration: return "Duration" + case .memberCount: return "Member Count" + case .aiResponse: return "AI Response" + case .aiSummary: return "AI Summary" + case .aiClassification: return "AI Classification" + case .aiEntities: return "AI Entities" + case .aiRewrite: return "AI Rewrite" + case .mediaFile: return "Media File" + case .mediaPath: return "Media Path" + case .mediaSource: return "Media Source" + case .mediaNode: return "Media Node" + } + } + + var category: String { + switch self { + case .user, .userId, .username, .userNickname, .userMention: + return "User" + case .message, .messageId: + return "Message" + case .channel, .channelId, .channelName: + return "Channel" + case .guild, .guildId, .guildName: + return "Server" + case .voiceChannel, .voiceChannelId: + return "Voice" + case .reaction, .reactionEmoji: + return "Reaction" + case .duration, .memberCount: + return "Other" + case .aiResponse, .aiSummary, .aiClassification, .aiEntities, .aiRewrite: + return "AI" + case .mediaFile, .mediaPath, .mediaSource, .mediaNode: + return "Media" + } + } +} + +extension Set where Element == ContextVariable { + /// Returns a user-friendly description of the required context (Task 1) + var friendlyRequirement: String { + if self.isEmpty { return "" } + + // Priority based on trigger types + if self.contains(where: { $0.category == "Message" || $0.category == "Reaction" }) { + return "a message trigger" + } + if self.contains(where: { $0.category == "Channel" || $0.category == "Voice" }) { + return "a channel event" + } + if self.contains(where: { $0.category == "User" }) { + return "a user trigger" + } + + return "additional context" + } +} + +// MARK: - Discord Permissions + +/// Discord permission flags for validation +enum DiscordPermission: String, CaseIterable, Codable, Hashable { + case createInstantInvite = "CREATE_INSTANT_INVITE" + case kickMembers = "KICK_MEMBERS" + case banMembers = "BAN_MEMBERS" + case administrator = "ADMINISTRATOR" + case manageChannels = "MANAGE_CHANNELS" + case manageGuild = "MANAGE_GUILD" + case addReactions = "ADD_REACTIONS" + case viewAuditLog = "VIEW_AUDIT_LOG" + case prioritySpeaker = "PRIORITY_SPEAKER" + case stream = "STREAM" + case viewChannel = "VIEW_CHANNEL" + case sendMessages = "SEND_MESSAGES" + case sendTTSMessages = "SEND_TTS_MESSAGES" + case manageMessages = "MANAGE_MESSAGES" + case embedLinks = "EMBED_LINKS" + case attachFiles = "ATTACH_FILES" + case readMessageHistory = "READ_MESSAGE_HISTORY" + case mentionEveryone = "MENTION_EVERYONE" + case useExternalEmojis = "USE_EXTERNAL_EMOJIS" + case connect = "CONNECT" + case speak = "SPEAK" + case muteMembers = "MUTE_MEMBERS" + case deafenMembers = "DEAFEN_MEMBERS" + case moveMembers = "MOVE_MEMBERS" + case useVAD = "USE_VAD" + case changeNickname = "CHANGE_NICKNAME" + case manageNicknames = "MANAGE_NICKNAMES" + case manageRoles = "MANAGE_ROLES" + case manageWebhooks = "MANAGE_WEBHOOKS" + case manageEmojis = "MANAGE_EMOJIS_AND_STICKERS" + case useApplicationCommands = "USE_APPLICATION_COMMANDS" + case requestToSpeak = "REQUEST_TO_SPEAK" + case manageEvents = "MANAGE_EVENTS" + case manageThreads = "MANAGE_THREADS" + case createPublicThreads = "CREATE_PUBLIC_THREADS" + case createPrivateThreads = "CREATE_PRIVATE_THREADS" + case useExternalStickers = "USE_EXTERNAL_STICKERS" + case sendMessagesInThreads = "SEND_MESSAGES_IN_THREADS" + case useEmbeddedActivities = "USE_EMBEDDED_ACTIVITIES" + case moderateMembers = "MODERATE_MEMBERS" + + var displayName: String { + switch self { + case .createInstantInvite: return "Create Invite" + case .kickMembers: return "Kick Members" + case .banMembers: return "Ban Members" + case .administrator: return "Administrator" + case .manageChannels: return "Manage Channels" + case .manageGuild: return "Manage Server" + case .addReactions: return "Add Reactions" + case .viewAuditLog: return "View Audit Log" + case .prioritySpeaker: return "Priority Speaker" + case .stream: return "Video/Stream" + case .viewChannel: return "View Channel" + case .sendMessages: return "Send Messages" + case .sendTTSMessages: return "Send TTS" + case .manageMessages: return "Manage Messages" + case .embedLinks: return "Embed Links" + case .attachFiles: return "Attach Files" + case .readMessageHistory: return "Read History" + case .mentionEveryone: return "Mention @everyone" + case .useExternalEmojis: return "Use External Emojis" + case .connect: return "Connect" + case .speak: return "Speak" + case .muteMembers: return "Mute Members" + case .deafenMembers: return "Deafen Members" + case .moveMembers: return "Move Members" + case .useVAD: return "Use Voice Activity" + case .changeNickname: return "Change Nickname" + case .manageNicknames: return "Manage Nicknames" + case .manageRoles: return "Manage Roles" + case .manageWebhooks: return "Manage Webhooks" + case .manageEmojis: return "Manage Emojis" + case .useApplicationCommands: return "Use Commands" + case .requestToSpeak: return "Request to Speak" + case .manageEvents: return "Manage Events" + case .manageThreads: return "Manage Threads" + case .createPublicThreads: return "Create Public Threads" + case .createPrivateThreads: return "Create Private Threads" + case .useExternalStickers: return "Use External Stickers" + case .sendMessagesInThreads: return "Send in Threads" + case .useEmbeddedActivities: return "Use Activities" + case .moderateMembers: return "Timeout Members" + } + } + + var bitValue: UInt64 { + switch self { + case .createInstantInvite: return 1 << 0 + case .kickMembers: return 1 << 1 + case .banMembers: return 1 << 2 + case .administrator: return 1 << 3 + case .manageChannels: return 1 << 4 + case .manageGuild: return 1 << 5 + case .addReactions: return 1 << 6 + case .viewAuditLog: return 1 << 7 + case .prioritySpeaker: return 1 << 8 + case .stream: return 1 << 9 + case .viewChannel: return 1 << 10 + case .sendMessages: return 1 << 11 + case .sendTTSMessages: return 1 << 12 + case .manageMessages: return 1 << 13 + case .embedLinks: return 1 << 14 + case .attachFiles: return 1 << 15 + case .readMessageHistory: return 1 << 16 + case .mentionEveryone: return 1 << 17 + case .useExternalEmojis: return 1 << 18 + case .connect: return 1 << 20 + case .speak: return 1 << 21 + case .muteMembers: return 1 << 22 + case .deafenMembers: return 1 << 23 + case .moveMembers: return 1 << 24 + case .useVAD: return 1 << 25 + case .changeNickname: return 1 << 26 + case .manageNicknames: return 1 << 27 + case .manageRoles: return 1 << 28 + case .manageWebhooks: return 1 << 29 + case .manageEmojis: return 1 << 30 + case .useApplicationCommands: return 1 << 31 + case .requestToSpeak: return 1 << 32 + case .manageEvents: return 1 << 33 + case .manageThreads: return 1 << 34 + case .createPublicThreads: return 1 << 35 + case .createPrivateThreads: return 1 << 36 + case .useExternalStickers: return 1 << 37 + case .sendMessagesInThreads: return 1 << 38 + case .useEmbeddedActivities: return 1 << 39 + case .moderateMembers: return 1 << 40 + } + } +} + +// MARK: - Trigger Types + +enum TriggerType: String, CaseIterable, Identifiable, Codable { + case userJoinedVoice = "Voice Joined" + case userLeftVoice = "Voice Left" + case userMovedVoice = "Voice Moved" + case messageCreated = "Message Created" + case memberJoined = "Member Joined" + case memberLeft = "Member Left" + case reactionAdded = "Reaction Added" + case slashCommand = "Slash Command" + case mediaAdded = "New Media Added" + + var id: String { rawValue } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + if let match = TriggerType(rawValue: raw) { + self = match + } else if raw == "Message Contains" { + self = .messageCreated + } else if raw == "User Joins Voice" { + self = .userJoinedVoice + } else if raw == "User Leaves Voice" { + self = .userLeftVoice + } else if raw == "User Moves Voice" { + self = .userMovedVoice + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid TriggerType: \(raw)") + } + } + + var symbol: String { + switch self { + case .userJoinedVoice: return "person.crop.circle.badge.plus" + case .userLeftVoice: return "person.crop.circle.badge.xmark" + case .userMovedVoice: return "arrow.left.arrow.right.circle" + case .messageCreated: return "text.bubble" + case .memberJoined: return "person.badge.plus" + case .memberLeft: return "person.badge.minus" + case .reactionAdded: return "face.smiling" + case .slashCommand: return "slash.circle" + case .mediaAdded: return "video" + } + } + + var defaultMessage: String { + switch self { + case .userJoinedVoice: return "🔊 <@{userId}> connected to <#{channelId}>" + case .userLeftVoice: return "🔌 <@{userId}> disconnected from <#{channelId}> (Online for {duration})" + case .userMovedVoice: return "🔀 <@{userId}> moved from <#{fromChannelId}> to <#{toChannelId}>" + case .messageCreated: return "nm you?" + case .memberJoined: return "👋 Welcome to {server}, {username}! You're member #{memberCount}." + case .memberLeft: return "👋 {username} left the server." + case .reactionAdded: return "👍 Reaction added!" + case .slashCommand: return "Command received!" + case .mediaAdded: return "🎬 New media detected: {media.file}" + } + } + + var defaultRuleName: String { + switch self { + case .userJoinedVoice: return "Join Action" + case .userLeftVoice: return "Leave Action" + case .userMovedVoice: return "Move Action" + case .messageCreated: return "Message Reply" + case .memberJoined: return "Member Join Welcome" + case .memberLeft: return "Member Leave Log" + case .reactionAdded: return "Reaction Handler" + case .slashCommand: return "Command Handler" + case .mediaAdded: return "Media Added" + } + } + + /// Variables provided by this trigger type + var providedVariables: Set { + switch self { + case .userJoinedVoice, .userLeftVoice, .userMovedVoice: + return [.user, .userId, .username, .userMention, .voiceChannel, .voiceChannelId, .guild, .guildId, .guildName, .duration] + case .messageCreated: + return [.user, .userId, .username, .userMention, .message, .messageId, .channel, .channelId, .channelName, .guild, .guildId, .guildName] + case .memberJoined, .memberLeft: + return [.user, .userId, .username, .userMention, .guild, .guildId, .guildName, .memberCount] + case .reactionAdded: + return [.user, .userId, .username, .userMention, .message, .messageId, .channel, .channelId, .reaction, .reactionEmoji, .guild, .guildId] + case .slashCommand: + return [.user, .userId, .username, .userMention, .channel, .channelId, .guild, .guildId, .guildName] + case .mediaAdded: + return [.mediaFile, .mediaPath, .mediaSource, .mediaNode] + } + } + + static var allDefaultMessages: Set { + var messages = Set(allCases.map(\.defaultMessage)) + // Include legacy defaults so trigger changes still auto-populate + messages.insert("🔊 <@{userId}> connected to <#{channelId}>") + messages.insert("🔌 <@{userId}> disconnected from <#{channelId}>") + messages.insert("🔀 <@{userId}> moved from <#{fromChannelId}> to <#{toChannelId}>") + return messages + } +} + +enum ConditionType: String, CaseIterable, Identifiable, Codable { + case server = "Server Is" + case voiceChannel = "Voice Channel Is" + case usernameContains = "Username Contains" + case minimumDuration = "Duration In Channel" + case channelIs = "Channel Is" + case channelCategory = "Channel Category Is" + case userHasRole = "User Has Role" + case userJoinedRecently = "User Joined Recently" + case messageContains = "Message Contains" + case messageStartsWith = "Message Starts With" + case messageRegex = "Message Matches Regex" + case isDirectMessage = "Message Is DM" + case isFromBot = "Message Is From Bot" + case isFromUser = "Message Is From User" + case channelType = "Channel Type Is" + + var id: String { rawValue } + + var symbol: String { + switch self { + case .server: return "building.2" + case .voiceChannel: return "waveform" + case .usernameContains: return "text.magnifyingglass" + case .minimumDuration: return "timer" + case .channelIs: return "number" + case .channelCategory: return "folder" + case .userHasRole: return "person.crop.circle.badge.checkmark" + case .userJoinedRecently: return "clock.arrow.circlepath" + case .messageContains: return "text.quote" + case .messageStartsWith: return "text.alignleft" + case .messageRegex: return "asterisk.circle" + case .isDirectMessage: return "envelope.badge.shield.half.filled" + case .isFromBot: return "bot" + case .isFromUser: return "person" + case .channelType: return "number.square" + } + } + + /// Variables required to evaluate this condition + var requiredVariables: Set { + switch self { + case .server: + return [.guild, .guildId] + case .voiceChannel: + return [.voiceChannel, .voiceChannelId] + case .usernameContains: + return [.user, .username] + case .minimumDuration: + return [.duration] + case .channelIs, .channelCategory: + return [.channel, .channelId] + case .userHasRole, .userJoinedRecently: + return [.user, .userId] + case .messageContains, .messageStartsWith, .messageRegex: + return [.message] + case .isDirectMessage, .isFromBot, .isFromUser: + return [.message, .channel] + case .channelType: + return [.channel, .channelId] + } + } +} + +enum ActionType: String, CaseIterable, Identifiable, Codable { + case sendMessage = "Send Message" + case addLogEntry = "Add Log Entry" + case setStatus = "Set Bot Status" + case sendDM = "Send DM" + case deleteMessage = "Delete Message" + case addReaction = "Add Reaction" + case addRole = "Add Role" + case removeRole = "Remove Role" + case timeoutMember = "Timeout Member" + case kickMember = "Kick Member" + case moveMember = "Move Member" + case createChannel = "Create Channel" + case webhook = "Send Webhook" + case delay = "Delay" + case setVariable = "Set Variable" + case randomChoice = "Random" + + // New Modifier Types + case replyToTrigger = "Reply To Trigger Message" + case mentionUser = "Mention User" + case mentionRole = "Mention Role" + case disableMention = "Disable User Mentions" + case sendToChannel = "Send To Channel" + case sendToDM = "Send To DM" + + // AI Types + case generateAIResponse = "Generate AI Response" + case summariseMessage = "Summarise Message" + case classifyMessage = "Classify Message" + case extractEntities = "Extract Entities" + case rewriteMessage = "Rewrite Message" + + var id: String { rawValue } + + var symbol: String { + switch self { + case .sendMessage: return "paperplane.fill" + case .addLogEntry: return "list.bullet.clipboard" + case .setStatus: return "dot.radiowaves.left.and.right" + case .sendDM: return "envelope.fill" + case .deleteMessage: return "trash.fill" + case .addReaction: return "face.smiling" + case .addRole: return "person.crop.circle.badge.plus" + case .removeRole: return "person.crop.circle.badge.minus" + case .timeoutMember: return "clock.badge.exclamationmark" + case .kickMember: return "door.left.hand.open" + case .moveMember: return "arrow.right.circle" + case .createChannel: return "plus.rectangle" + case .webhook: return "link" + case .delay: return "clock.arrow.circlepath" + case .setVariable: return "character.textbox" + case .randomChoice: return "shuffle" + case .replyToTrigger: return "arrowshape.turn.up.left.fill" + case .mentionUser: return "at" + case .mentionRole: return "at.badge.plus" + case .disableMention: return "at.badge.minus" + case .sendToChannel: return "number.circle.fill" + case .sendToDM: return "envelope.fill" + case .generateAIResponse: return "sparkles" + case .summariseMessage: return "text.alignleft" + case .classifyMessage: return "tag.fill" + case .extractEntities: return "list.bullet.clipboard" + case .rewriteMessage: return "pencil" + } + } + + /// Variables required by this action type + var requiredVariables: Set { + switch self { + case .sendMessage, .sendDM, .setStatus, .addLogEntry, .delay, .setVariable, .randomChoice, .createChannel, .webhook: + return [] + case .deleteMessage, .addReaction, .replyToTrigger: + return [.message, .messageId] + + case .addRole, .removeRole, .timeoutMember, .kickMember, .moveMember, .mentionUser, .disableMention, .sendToDM: + return [.user, .userId] + case .sendToChannel: + return [.channel] + case .generateAIResponse, .mentionRole, .summariseMessage, .classifyMessage, .extractEntities, .rewriteMessage: + return [] + } + } + + /// Variables provided/output by this action type + var outputVariables: Set { + switch self { + case .generateAIResponse: + return [.aiResponse] + case .summariseMessage: + return [.aiSummary] + case .classifyMessage: + return [.aiClassification] + case .extractEntities: + return [.aiEntities] + case .rewriteMessage: + return [.aiRewrite] + case .sendMessage, .sendDM, .deleteMessage, .addReaction, .addRole, + .removeRole, .timeoutMember, .kickMember, .moveMember, .createChannel, .webhook, + .setStatus, .addLogEntry, .delay, .setVariable, .randomChoice, .replyToTrigger, + .mentionUser, .mentionRole, .disableMention, .sendToChannel, .sendToDM: + return [] + } + } + + /// Discord permissions required for this action + var requiredPermissions: Set { + switch self { + case .sendMessage, .sendDM, .addLogEntry, .setStatus, .delay, .setVariable, .randomChoice, .generateAIResponse, .replyToTrigger, .mentionUser, .mentionRole, .disableMention, .sendToChannel, .sendToDM, .summariseMessage, .classifyMessage, .extractEntities, .rewriteMessage: + return [] + case .deleteMessage: + return [.manageMessages] + case .addReaction: + return [.addReactions] + case .addRole, .removeRole: + return [.manageRoles] + case .timeoutMember: + return [.moderateMembers] + case .kickMember: + return [.kickMembers] + case .moveMember: + return [.moveMembers] + case .createChannel: + return [.manageChannels] + case .webhook: + return [.manageWebhooks] + } + } + + /// Category for block library organization + var category: BlockCategory { + switch self { + case .replyToTrigger, .disableMention, .sendToChannel, .sendToDM, .mentionUser, .mentionRole: + return .messaging + case .sendMessage, .sendDM, .addReaction, .deleteMessage, .createChannel, .webhook, + .addLogEntry, .setStatus, .delay, .setVariable, .randomChoice: + return .actions + case .generateAIResponse, .summariseMessage, .classifyMessage, .extractEntities, .rewriteMessage: + return .ai + case .addRole, .removeRole, .timeoutMember, .kickMember, .moveMember: + return .moderation + } + } +} + +/// Block categories for library organization (Task 5) +enum BlockCategory: String, CaseIterable, Identifiable { + case triggers = "Triggers" + case filters = "Filters" + case ai = "AI Blocks" + case messaging = "Message" + case actions = "Actions" + case moderation = "Moderation" + + var id: String { rawValue } + + var symbol: String { + switch self { + case .triggers: return "bolt.fill" + case .filters: return "line.3.horizontal.decrease.circle" + case .ai: return "sparkles" + case .messaging: return "text.bubble.fill" + case .actions: return "paperplane.fill" + case .moderation: return "shield.fill" + } + } +} + +extension ConditionType { + /// Returns true if this condition is compatible with the given trigger (Task 4) + func isCompatible(with trigger: TriggerType?) -> Bool { + guard let trigger = trigger else { return true } // No trigger means everything is potentially visible + return self.requiredVariables.isSubset(of: trigger.providedVariables) + } +} + +extension ActionType { + /// Returns true if this action is compatible with the given trigger (Task 4) + func isCompatible(with trigger: TriggerType?) -> Bool { + guard let trigger = trigger else { return true } + return self.requiredVariables.isSubset(of: trigger.providedVariables) + } +} +struct Condition: Identifiable, Codable, Equatable { + var id = UUID() + var type: ConditionType + var value: String = "" + var secondaryValue: String = "" +} + +struct RuleAction: Identifiable, Codable, Equatable { + var id = UUID() + var type: ActionType = .sendMessage + var serverId: String = "" + var channelId: String = "" + var mentionUser: Bool = true + var replyToTriggerMessage: Bool = false + var replyWithAI: Bool = false + var message: String = "🔊 <@{userId}> connected to <#{channelId}>" + var statusText: String = "Voice notifier active" + + // New fields for extended action types + var dmContent: String = "" // For sendDM + var emoji: String = "👍" // For addReaction + var roleId: String = "" // For addRole/removeRole + var timeoutDuration: Int = 3600 // For timeoutMember (seconds) + var kickReason: String = "" // For kickMember + var targetVoiceChannelId: String = "" // For moveMember + var newChannelName: String = "" // For createChannel + var webhookURL: String = "" // For webhook + var webhookContent: String = "" // For webhook + var delaySeconds: Int = 5 // For delay + var variableName: String = "" // For setVariable + var variableValue: String = "" // For setVariable + var randomOptions: [String] = [] // For randomChoice + var deleteDelaySeconds: Int = 0 // For deleteMessage (delayed delete) + + // AI Processing block fields + var categories: String = "" // For classifyMessage (comma-separated categories) + var entityTypes: String = "" // For extractEntities (comma-separated entity types) + var rewriteStyle: String = "" // For rewriteMessage (style description) + + // Unified Send Message content source (replaces replyWithAI, etc.) + var contentSource: ContentSource = .custom + + // Message destination mode (per UX spec: replyToTrigger, sameChannel, specificChannel) + var destinationMode: MessageDestination? = nil + + enum CodingKeys: String, CodingKey { + case id + case type + case serverId + case channelId + case mentionUser + case replyToTriggerMessage + case replyWithAI + case message + case statusText + // New fields + case dmContent + case emoji + case roleId + case timeoutDuration + case kickReason + case targetVoiceChannelId + case newChannelName + case webhookURL + case webhookContent + case delaySeconds + case variableName + case variableValue + case randomOptions + case deleteDelaySeconds + case categories + case entityTypes + case rewriteStyle + case contentSource + case destinationMode + } + + init() {} + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + type = try container.decodeIfPresent(ActionType.self, forKey: .type) ?? .sendMessage + serverId = try container.decodeIfPresent(String.self, forKey: .serverId) ?? "" + channelId = try container.decodeIfPresent(String.self, forKey: .channelId) ?? "" + mentionUser = try container.decodeIfPresent(Bool.self, forKey: .mentionUser) ?? true + replyToTriggerMessage = try container.decodeIfPresent(Bool.self, forKey: .replyToTriggerMessage) ?? false + replyWithAI = try container.decodeIfPresent(Bool.self, forKey: .replyWithAI) ?? false + message = try container.decodeIfPresent(String.self, forKey: .message) ?? "🔊 <@{userId}> connected to <#{channelId}>" + statusText = try container.decodeIfPresent(String.self, forKey: .statusText) ?? "Voice notifier active" + // New fields with defaults + dmContent = try container.decodeIfPresent(String.self, forKey: .dmContent) ?? "" + emoji = try container.decodeIfPresent(String.self, forKey: .emoji) ?? "👍" + roleId = try container.decodeIfPresent(String.self, forKey: .roleId) ?? "" + timeoutDuration = try container.decodeIfPresent(Int.self, forKey: .timeoutDuration) ?? 3600 + kickReason = try container.decodeIfPresent(String.self, forKey: .kickReason) ?? "" + targetVoiceChannelId = try container.decodeIfPresent(String.self, forKey: .targetVoiceChannelId) ?? "" + newChannelName = try container.decodeIfPresent(String.self, forKey: .newChannelName) ?? "" + webhookURL = try container.decodeIfPresent(String.self, forKey: .webhookURL) ?? "" + webhookContent = try container.decodeIfPresent(String.self, forKey: .webhookContent) ?? "" + delaySeconds = try container.decodeIfPresent(Int.self, forKey: .delaySeconds) ?? 5 + variableName = try container.decodeIfPresent(String.self, forKey: .variableName) ?? "" + variableValue = try container.decodeIfPresent(String.self, forKey: .variableValue) ?? "" + randomOptions = try container.decodeIfPresent([String].self, forKey: .randomOptions) ?? [] + deleteDelaySeconds = try container.decodeIfPresent(Int.self, forKey: .deleteDelaySeconds) ?? 0 + categories = try container.decodeIfPresent(String.self, forKey: .categories) ?? "" + entityTypes = try container.decodeIfPresent(String.self, forKey: .entityTypes) ?? "" + rewriteStyle = try container.decodeIfPresent(String.self, forKey: .rewriteStyle) ?? "" + + // Decode contentSource with legacy migration + let decodedContentSource = try container.decodeIfPresent(ContentSource.self, forKey: .contentSource) + let decodedReplyWithAI = try container.decodeIfPresent(Bool.self, forKey: .replyWithAI) ?? false + + // Migration: replyWithAI true -> contentSource = aiResponse + if decodedContentSource == nil && decodedReplyWithAI && type == .sendMessage { + contentSource = .aiResponse + } else { + contentSource = decodedContentSource ?? .custom + } + + // Decode destinationMode with legacy migration + let decodedDestinationMode = try container.decodeIfPresent(MessageDestination.self, forKey: .destinationMode) + let decodedReplyToTrigger = try container.decodeIfPresent(Bool.self, forKey: .replyToTriggerMessage) ?? false + let hasExplicitChannel = !(try container.decodeIfPresent(String.self, forKey: .channelId) ?? "").isEmpty + + // Migration logic per UX spec: + // - Existing destinationMode -> keep it + // - Legacy replyToTriggerMessage=true -> replyToTrigger + // - Explicit serverId/channelId -> specificChannel + // - Message trigger + no explicit IDs -> sameChannel (handled in UI defaults) + // - Non-message trigger + no IDs -> specificChannel (conservative default) + if let existingMode = decodedDestinationMode { + destinationMode = existingMode + } else if decodedReplyToTrigger { + destinationMode = .replyToTrigger + } else if hasExplicitChannel { + destinationMode = .specificChannel + } else { + // Default: nil means conservative behavior (will be set by UI based on trigger type) + destinationMode = nil + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + let legacyReplyToTrigger = type == .sendMessage ? (destinationMode == .replyToTrigger) : replyToTriggerMessage + let legacyReplyWithAI = type == .sendMessage ? (contentSource == .aiResponse) : replyWithAI + try container.encode(id, forKey: .id) + try container.encode(type, forKey: .type) + try container.encode(serverId, forKey: .serverId) + try container.encode(channelId, forKey: .channelId) + try container.encode(mentionUser, forKey: .mentionUser) + try container.encode(legacyReplyToTrigger, forKey: .replyToTriggerMessage) + try container.encode(legacyReplyWithAI, forKey: .replyWithAI) + try container.encode(message, forKey: .message) + try container.encode(statusText, forKey: .statusText) + // New fields + try container.encode(dmContent, forKey: .dmContent) + try container.encode(emoji, forKey: .emoji) + try container.encode(roleId, forKey: .roleId) + try container.encode(timeoutDuration, forKey: .timeoutDuration) + try container.encode(kickReason, forKey: .kickReason) + try container.encode(targetVoiceChannelId, forKey: .targetVoiceChannelId) + try container.encode(newChannelName, forKey: .newChannelName) + try container.encode(webhookURL, forKey: .webhookURL) + try container.encode(webhookContent, forKey: .webhookContent) + try container.encode(delaySeconds, forKey: .delaySeconds) + try container.encode(variableName, forKey: .variableName) + try container.encode(variableValue, forKey: .variableValue) + try container.encode(randomOptions, forKey: .randomOptions) + try container.encode(deleteDelaySeconds, forKey: .deleteDelaySeconds) + try container.encode(categories, forKey: .categories) + try container.encode(entityTypes, forKey: .entityTypes) + try container.encode(rewriteStyle, forKey: .rewriteStyle) + try container.encode(contentSource, forKey: .contentSource) + try container.encode(destinationMode, forKey: .destinationMode) + } +} + +/// Content source options for Send Message action +enum ContentSource: String, Codable, CaseIterable { + case custom = "custom" + case aiResponse = "ai.response" + case aiSummary = "ai.summary" + case aiClassification = "ai.classification" + case aiEntities = "ai.entities" + case aiRewrite = "ai.rewrite" + + var displayName: String { + switch self { + case .custom: return "Custom Message" + case .aiResponse: return "AI Response" + case .aiSummary: return "AI Summary" + case .aiClassification: return "AI Classification" + case .aiEntities: return "AI Entities" + case .aiRewrite: return "AI Rewrite" + } + } +} + +/// Destination mode for Send Message action +enum MessageDestination: String, Codable, CaseIterable { + case replyToTrigger = "replyToTrigger" + case sameChannel = "sameChannel" + case specificChannel = "specificChannel" + + var displayName: String { + switch self { + case .replyToTrigger: return "Reply to Trigger" + case .sameChannel: return "Same Channel" + case .specificChannel: return "Specific Channel" + } + } +} + +extension MessageDestination { + static func defaultMode(for trigger: TriggerType?) -> MessageDestination { + switch trigger { + case .messageCreated, .reactionAdded: + return .replyToTrigger + case .slashCommand: + return .sameChannel + case .userJoinedVoice, .userLeftVoice, .userMovedVoice, .memberJoined, .memberLeft, .mediaAdded, .none: + return .specificChannel + } + } + + static func defaultMode(for event: VoiceRuleEvent, context: PipelineContext) -> MessageDestination { + if context.triggerMessageId != nil || event.triggerMessageId != nil { + return .replyToTrigger + } + if context.triggerChannelId != nil || event.triggerChannelId != nil { + return .sameChannel + } + return .specificChannel + } +} + +typealias Action = RuleAction + +struct Rule: Identifiable, Codable, Equatable { + var id: UUID = UUID() + var name: String = "New Action" + var trigger: TriggerType? + var conditions: [Condition] = [] + var modifiers: [RuleAction] = [] + var actions: [RuleAction] = [] + var aiBlocks: [RuleAction] = [] + var isEnabled: Bool = true + + // Legacy trigger properties - preserved for JSON compatibility, migrated to conditions on load + var triggerServerId: String = "" + var triggerVoiceChannelId: String = "" + var triggerMessageContains: String = "" + var replyToDMs: Bool = false + var includeStageChannels: Bool = true + + /// UI state indicating trigger selection is in progress (Validation suspended) + var isEditingTrigger: Bool = false + + /// Memberwise initializer (explicit due to custom Codable conformance) + init( + id: UUID = UUID(), + name: String = "New Action", + trigger: TriggerType? = nil, + conditions: [Condition] = [], + modifiers: [RuleAction] = [], + actions: [RuleAction] = [], + isEnabled: Bool = true, + triggerServerId: String = "", + triggerVoiceChannelId: String = "", + triggerMessageContains: String = "", + replyToDMs: Bool = false, + includeStageChannels: Bool = true, + isEditingTrigger: Bool = false + ) { + self.id = id + self.name = name + self.trigger = trigger + self.conditions = conditions + self.modifiers = modifiers + self.actions = actions + self.isEnabled = isEnabled + self.triggerServerId = triggerServerId + self.triggerVoiceChannelId = triggerVoiceChannelId + self.triggerMessageContains = triggerMessageContains + self.replyToDMs = replyToDMs + self.includeStageChannels = includeStageChannels + self.isEditingTrigger = isEditingTrigger + } + + var isEmptyRule: Bool { + trigger == nil && conditions.isEmpty && actions.isEmpty && modifiers.isEmpty + } + + static func empty() -> Rule { + Rule(trigger: nil, conditions: [], modifiers: [], actions: []) + } + + // MARK: - Codable Migration + + /// Coding keys for Rule + enum CodingKeys: String, CodingKey { + case id, name, trigger, conditions, modifiers, actions, aiBlocks, isEnabled + case triggerServerId, triggerVoiceChannelId, triggerMessageContains, replyToDMs, includeStageChannels + } + + /// Custom decoder that migrates legacy properties and separates AI blocks from actions + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + trigger = try container.decodeIfPresent(TriggerType.self, forKey: .trigger) + conditions = try container.decode([Condition].self, forKey: .conditions) + modifiers = try container.decode([RuleAction].self, forKey: .modifiers) + actions = try container.decode([RuleAction].self, forKey: .actions) + aiBlocks = try container.decodeIfPresent([RuleAction].self, forKey: .aiBlocks) ?? [] + isEnabled = try container.decode(Bool.self, forKey: .isEnabled) + + // Legacy properties - keep for backwards compatibility but migrate to conditions + triggerServerId = try container.decodeIfPresent(String.self, forKey: .triggerServerId) ?? "" + triggerVoiceChannelId = try container.decodeIfPresent(String.self, forKey: .triggerVoiceChannelId) ?? "" + triggerMessageContains = try container.decodeIfPresent(String.self, forKey: .triggerMessageContains) ?? "" + replyToDMs = try container.decodeIfPresent(Bool.self, forKey: .replyToDMs) ?? false + includeStageChannels = try container.decodeIfPresent(Bool.self, forKey: .includeStageChannels) ?? true + + // Migration: Convert legacy trigger properties to filter conditions + // Only add if not already present to avoid duplicates on repeated saves + var migratedConditions: [Condition] = [] + + // Migrate triggerServerId -> Condition.server + if !triggerServerId.isEmpty && !conditions.contains(where: { $0.type == .server }) { + migratedConditions.append(Condition(type: .server, value: triggerServerId)) + } + + // Migrate triggerVoiceChannelId -> Condition.voiceChannel + if !triggerVoiceChannelId.isEmpty && !conditions.contains(where: { $0.type == .voiceChannel }) { + migratedConditions.append(Condition(type: .voiceChannel, value: triggerVoiceChannelId)) + } + + // Migrate triggerMessageContains -> Condition.messageContains + if !triggerMessageContains.isEmpty && triggerMessageContains != "up to?" && !conditions.contains(where: { $0.type == .messageContains }) { + migratedConditions.append(Condition(type: .messageContains, value: triggerMessageContains)) + } + + // Append migrated conditions to existing conditions + if !migratedConditions.isEmpty { + conditions.append(contentsOf: migratedConditions) + } + + // Migration: Move AI blocks from actions to aiBlocks for backwards compatibility + let aiBlockTypes: [ActionType] = [.generateAIResponse, .summariseMessage, .classifyMessage, .extractEntities, .rewriteMessage] + let (aiBlocksFromActions, remainingActions) = actions.reduce(into: ([RuleAction](), [RuleAction]())) { result, action in + if aiBlockTypes.contains(action.type) { + result.0.append(action) + } else { + result.1.append(action) + } + } + if !aiBlocksFromActions.isEmpty { + aiBlocks.append(contentsOf: aiBlocksFromActions) + actions = remainingActions + } + + actions = actions.map { action in + guard action.type == .sendMessage, action.destinationMode == nil else { return action } + var updated = action + if action.replyToTriggerMessage { + updated.destinationMode = .replyToTrigger + } else if !action.channelId.isEmpty || !action.serverId.isEmpty { + updated.destinationMode = .specificChannel + } else { + updated.destinationMode = MessageDestination.defaultMode(for: trigger) + } + return updated + } + } + + /// Provides the full pipeline of blocks for the rule engine in execution order: + /// AI Processing → Message Modifiers → Actions + var processedActions: [RuleAction] { + var pipeline: [RuleAction] = [] + + // 1. AI Processing blocks first + pipeline.append(contentsOf: aiBlocks) + + // 2. Message Modifiers + pipeline.append(contentsOf: modifiers) + + // 3. Actions (excluding AI blocks and extracting embedded modifiers) + for action in actions { + var actionWithModifiers = action + + // Legacy: replyWithAI toggle creates an AI block + if action.type == .sendMessage && action.replyWithAI && action.contentSource == .custom { + var aiBlock = RuleAction() + aiBlock.type = .generateAIResponse + // Insert AI block at the beginning (before modifiers) + pipeline.insert(aiBlock, at: aiBlocks.count) + actionWithModifiers.replyWithAI = false + } + + // Extract reply-to-trigger as a modifier + if action.type == .sendMessage && action.replyToTriggerMessage && action.destinationMode == nil { + var replyBlock = RuleAction() + replyBlock.type = .replyToTrigger + pipeline.append(replyBlock) + actionWithModifiers.replyToTriggerMessage = false + } + + // Extract mention disable as a modifier + if !action.mentionUser { // Default was true in legacy + var disableMentionBlock = RuleAction() + disableMentionBlock.type = .disableMention + pipeline.append(disableMentionBlock) + actionWithModifiers.mentionUser = true // Reset so we don't repeat + } + + pipeline.append(actionWithModifiers) + } + + return pipeline + } + + var triggerSummary: String { + guard let trigger = trigger else { return "No trigger set" } + switch trigger { + case .userJoinedVoice: return "When someone joins voice" + case .userLeftVoice: return "When someone leaves voice" + case .userMovedVoice: return "When someone moves voice" + case .messageCreated: return "When a message is received" + case .memberJoined: return "When a member joins the server" + case .memberLeft: return "When a member leaves the server" + case .reactionAdded: return "When a reaction is added" + case .slashCommand: return "When a slash command is used" + case .mediaAdded: return "When new media is detected" + } + } + + /// Returns any blocks that are incompatible with the current trigger + var incompatibleBlocks: [UUID] { + guard let trigger = trigger else { return [] } + let available = trigger.providedVariables + var ids: [UUID] = [] + + for condition in conditions { + if !condition.type.requiredVariables.isSubset(of: available) { + ids.append(condition.id) + } + } + for modifier in modifiers { + if !modifier.type.requiredVariables.isSubset(of: available) { + ids.append(modifier.id) + } + } + for action in actions { + if !action.type.requiredVariables.isSubset(of: available) { + ids.append(action.id) + } + } + return ids + } + + var validationIssues: [ValidationIssue] { + guard let trigger = trigger, !isEditingTrigger else { + return [] + } + + var issues: [ValidationIssue] = [] + let availableVariables = trigger.providedVariables + + // Check conditions for variable availability + for condition in conditions { + let requiredVars = condition.type.requiredVariables + let missingVars = requiredVars.subtracting(availableVariables) + if !missingVars.isEmpty { + issues.append(.init( + severity: .warning, // Task 1: Use warning style + message: "Requires \(requiredVars.friendlyRequirement)", // Task 1: User-friendly wording + blockType: .condition, + blockId: condition.id + )) + } + } + + // Check modifiers for variable availability and permissions + for modifier in modifiers { + let requiredVars = modifier.type.requiredVariables + let missingVars = requiredVars.subtracting(availableVariables) + if !missingVars.isEmpty { + issues.append(.init( + severity: .warning, // Task 1: Use warning style + message: "Requires \(requiredVars.friendlyRequirement)", // Task 1: User-friendly wording + blockType: .modifier, + blockId: modifier.id + )) + } + + let requiredPerms = modifier.type.requiredPermissions + if !requiredPerms.isEmpty { + issues.append(.init( + severity: .warning, + message: "Requires permissions: \(requiredPerms.map(\.displayName).joined(separator: ", "))", + blockType: .modifier, + blockId: modifier.id + )) + } + } + + // Check actions for variable availability and permissions + for action in actions { + let requiredVars = action.type.requiredVariables + let missingVars = requiredVars.subtracting(availableVariables) + if !missingVars.isEmpty { + issues.append(.init( + severity: .warning, // Task 1: Use warning style + message: "Requires \(requiredVars.friendlyRequirement)", // Task 1: User-friendly wording + blockType: .action, + blockId: action.id + )) + } + + // Task 5: Prevent empty Send Message actions + if action.type == .sendMessage, + action.contentSource == .custom, + action.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + issues.append(.init( + severity: .error, + message: "Message content is required for 'Send Message' actions.", + blockType: .action, + blockId: action.id + )) + } + + if action.type == .sendMessage, + (action.destinationMode ?? MessageDestination.defaultMode(for: trigger)) == .specificChannel, + action.channelId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + issues.append(.init( + severity: .error, + message: "Select a channel when destination is set to 'Specific Channel'.", + blockType: .action, + blockId: action.id + )) + } + + // Check permissions (warnings, not errors - bot may have permissions) + let requiredPerms = action.type.requiredPermissions + if !requiredPerms.isEmpty { + issues.append(.init( + severity: .warning, + message: "Requires permissions: \(requiredPerms.map(\.displayName).joined(separator: ", "))", + blockType: .action, + blockId: action.id + )) + } + } + + // Rule must contain at least one Action + if actions.isEmpty { + issues.append(.init( + severity: .warning, + message: "This rule has no actions and will not produce any output. Add an Action such as “Send Message”.", + blockType: .rule, + blockId: id + )) + } + + return issues + } + + /// Checks if rule has any blocking errors + var hasBlockingErrors: Bool { + validationIssues.contains { $0.severity == .error } + } + + /// Returns just the errors (not warnings) + var validationErrors: [ValidationIssue] { + validationIssues.filter { $0.severity == .error } + } + + /// Returns just the warnings + var validationWarnings: [ValidationIssue] { + validationIssues.filter { $0.severity == .warning } + } +} + +/// Represents a validation issue with a rule +struct ValidationIssue: Identifiable, Hashable { + let id = UUID() + let severity: ValidationSeverity + let message: String + let blockType: BlockType + let blockId: UUID + + enum ValidationSeverity: String, Codable, CaseIterable { + case warning = "Warning" + case error = "Error" + + var icon: String { + switch self { + case .warning: return "exclamationmark.triangle" + case .error: return "xmark.octagon" + } + } + + var color: String { + switch self { + case .warning: return "orange" + case .error: return "red" + } + } + } + + enum BlockType: String, Codable, CaseIterable { + case rule = "Rule" + case trigger = "Trigger" + case condition = "Filter" + case modifier = "Modifier" + case action = "Action" + } +} diff --git a/extract_eventbus.py b/extract_eventbus.py new file mode 100644 index 0000000..ebdc000 --- /dev/null +++ b/extract_eventbus.py @@ -0,0 +1,30 @@ +import os + +filepath = '/Users/john/Documents/GitHub/SwiftBot/SwiftBotApp/Models.swift' +with open(filepath, 'r') as f: + lines = f.readlines() + +start_idx = -1 +end_idx = -1 +for i, line in enumerate(lines): + if '// MARK: - EventBus System' in line: + start_idx = i + if '// MARK: - Core Models' in line: + end_idx = i + break + +if start_idx != -1 and end_idx != -1: + eventbus_code = "import Foundation\n\n" + "".join(lines[start_idx:end_idx]) + + # Ensure Models directory exists + os.makedirs('/Users/john/Documents/GitHub/SwiftBot/SwiftBotApp/Models', exist_ok=True) + + with open('/Users/john/Documents/GitHub/SwiftBot/SwiftBotApp/Models/EventBus.swift', 'w') as f: + f.write(eventbus_code) + + new_models = lines[:start_idx] + lines[end_idx:] + with open(filepath, 'w') as f: + f.writelines(new_models) + print("Successfully extracted EventBus.swift") +else: + print("Indices not found") From 39dc199f42d35c35ad73ed48811d4e60dfef1423 Mon Sep 17 00:00:00 2001 From: johnwatso Date: Sat, 14 Mar 2026 13:16:30 +1300 Subject: [PATCH 09/11] Phase 3 improvements of Code Refactor --- SwiftBotApp/AppModel.swift | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/SwiftBotApp/AppModel.swift b/SwiftBotApp/AppModel.swift index 71dac1b..318b8dd 100644 --- a/SwiftBotApp/AppModel.swift +++ b/SwiftBotApp/AppModel.swift @@ -753,10 +753,11 @@ final class AppModel: ObservableObject { }, onPromotion: { [weak self] in guard let self else { return } - // When promoted to leader, start connecting to Discord. + // Promoted to Primary — enable Discord output. If already connected + // in passive standby mode, no reconnect is needed; output gate flips instantly. await MainActor.run { [weak self] in guard let self else { return } - logs.append("🚀 Promoted to Primary. Connecting to Discord...") + logs.append("🚀 Promoted to Primary.") Task { await self.connectDiscordAfterPromotion() } } } @@ -2256,6 +2257,9 @@ final class AppModel: ObservableObject { let runtimeMode = await cluster.currentSnapshot().mode if runtimeMode == .standby { + // Block all Discord output — standby observes events for live dashboard + // but must not respond until promoted to Primary. + await service.setOutputAllowed(false) logs.append("Fail Over mode active. Connecting to Discord in passive mode; live work remains delegated/primary-only.") } @@ -2274,6 +2278,18 @@ final class AppModel: ObservableObject { } func connectDiscordAfterPromotion() async { + // Allow output immediately — the gateway connection is already live if this + // node was running in standby (passive) mode. Avoid reconnecting if already + // connected to prevent the brief downtime a disconnect/reconnect would cause. + await service.setOutputAllowed(true) + + if status == .running { + // Already connected and receiving events — just flip the output gate. + logs.append("✅ Output enabled. Now responding as Primary.") + return + } + + // Not yet connected (e.g. fresh start without prior standby connection). let normalizedToken = normalizedDiscordToken(from: settings.token) if settings.token != normalizedToken { settings.token = normalizedToken From cf6f0d5722fe8fde81f53634e1a28e0bb0989966 Mon Sep 17 00:00:00 2001 From: johnwatso Date: Sat, 14 Mar 2026 13:26:39 +1300 Subject: [PATCH 10/11] Update project.pbxproj --- SwiftBot.xcodeproj/project.pbxproj | 36 ++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/SwiftBot.xcodeproj/project.pbxproj b/SwiftBot.xcodeproj/project.pbxproj index 73daa9a..9cdc397 100644 --- a/SwiftBot.xcodeproj/project.pbxproj +++ b/SwiftBot.xcodeproj/project.pbxproj @@ -46,6 +46,15 @@ 6F1B40D9A2B3C4D5E6F70819 /* AppModel+VoicePresence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F1B40D8A2B3C4D5E6F70819 /* AppModel+VoicePresence.swift */; }; 73BAC11337B101CC5C7AFCD2 /* DiscordService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3205CAA1A44F7E79578277 /* DiscordService.swift */; }; 75F7879D2B8A080849E4D4A2 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 010969C7B6435248430DD012 /* Models.swift */; }; + 5125A5586D3B4765960059A3 /* Models/AIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27EFCFFB1B1A45FABEAE7686 /* Models/AIModels.swift */; }; + 346EE31F9E1D4CB28BCE37A8 /* Models/BotSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7436E2097EB456B85B3138E /* Models/BotSettings.swift */; }; + 77516EDB305B452A9063B036 /* Models/BotStateModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D44391E12144C8A9215A079 /* Models/BotStateModels.swift */; }; + 4B164E69FD8746C58CA0E842 /* Models/ClusterModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0870D97E081745428605B82E /* Models/ClusterModels.swift */; }; + 829DC28FA2E7429B93795C74 /* Models/DiscordCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFA0F326F22A4CF2817AA2AB /* Models/DiscordCache.swift */; }; + 212CE68E56C348E2B16F8E20 /* Models/EventBus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F2038AEE60943658748C2A8 /* Models/EventBus.swift */; }; + F6B4A085F1EF4FC8B45EA1A5 /* Models/GatewayModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50331CC0992C4A59BFC07663 /* Models/GatewayModels.swift */; }; + 4310D51BEC4040248D9F8E66 /* Models/KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DEA97F20B664678BC69DB03 /* Models/KeychainHelper.swift */; }; + 08DCC0CBE7324CA3B5253825 /* Models/RuleEngineModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD8A97AEC9B4F4AAAEF2C7B /* Models/RuleEngineModels.swift */; }; 8D8E9F001122334455667788 /* AppUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D8E9F101122334455667788 /* AppUpdater.swift */; }; 8E8D7C6B5A4F3E2D1C0B9A88 /* HelpEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E8D7C6B5A4F3E2D1C0B9A89 /* HelpEngine.swift */; }; 9A8B7C601122334455667788 /* SwiftBot.icon in Resources */ = {isa = PBXBuildFile; fileRef = 5015DDB02F554EF200618C6D /* SwiftBot.icon */; }; @@ -95,6 +104,15 @@ /* Begin PBXFileReference section */ 0011223344556677AABBCCDD /* CommonUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonUI.swift; sourceTree = ""; }; 010969C7B6435248430DD012 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + 27EFCFFB1B1A45FABEAE7686 /* Models/AIModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Models/AIModels.swift"; sourceTree = ""; }; + B7436E2097EB456B85B3138E /* Models/BotSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Models/BotSettings.swift"; sourceTree = ""; }; + 7D44391E12144C8A9215A079 /* Models/BotStateModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Models/BotStateModels.swift"; sourceTree = ""; }; + 0870D97E081745428605B82E /* Models/ClusterModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Models/ClusterModels.swift"; sourceTree = ""; }; + AFA0F326F22A4CF2817AA2AB /* Models/DiscordCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Models/DiscordCache.swift"; sourceTree = ""; }; + 8F2038AEE60943658748C2A8 /* Models/EventBus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Models/EventBus.swift"; sourceTree = ""; }; + 50331CC0992C4A59BFC07663 /* Models/GatewayModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Models/GatewayModels.swift"; sourceTree = ""; }; + 7DEA97F20B664678BC69DB03 /* Models/KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Models/KeychainHelper.swift"; sourceTree = ""; }; + ACD8A97AEC9B4F4AAAEF2C7B /* Models/RuleEngineModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Models/RuleEngineModels.swift"; sourceTree = ""; }; 07080011223344556677AABB /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 0A6B7D201122334455667788 /* Resources */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Resources; sourceTree = ""; }; 0B3205CAA1A44F7E79578277 /* DiscordService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscordService.swift; sourceTree = ""; }; @@ -268,6 +286,15 @@ EA30CC27A218AF533E2E4C0E /* SwiftBotApp.swift */, 0B3205CAA1A44F7E79578277 /* DiscordService.swift */, 010969C7B6435248430DD012 /* Models.swift */, + 27EFCFFB1B1A45FABEAE7686 /* Models/AIModels.swift */, + B7436E2097EB456B85B3138E /* Models/BotSettings.swift */, + 7D44391E12144C8A9215A079 /* Models/BotStateModels.swift */, + 0870D97E081745428605B82E /* Models/ClusterModels.swift */, + AFA0F326F22A4CF2817AA2AB /* Models/DiscordCache.swift */, + 8F2038AEE60943658748C2A8 /* Models/EventBus.swift */, + 50331CC0992C4A59BFC07663 /* Models/GatewayModels.swift */, + 7DEA97F20B664678BC69DB03 /* Models/KeychainHelper.swift */, + ACD8A97AEC9B4F4AAAEF2C7B /* Models/RuleEngineModels.swift */, F9564766EDA4C18D06A84BCB /* Persistence.swift */, B4F6C2021122334455667788 /* SchemaSettings.swift */, C3D4E5F70112233445566778 /* SwiftMeshView.swift */, @@ -423,6 +450,15 @@ 20382A9EF51DD3FD3E6D9FA2 /* SwiftBotApp.swift in Sources */, 73BAC11337B101CC5C7AFCD2 /* DiscordService.swift in Sources */, 75F7879D2B8A080849E4D4A2 /* Models.swift in Sources */, + 5125A5586D3B4765960059A3 /* Models/AIModels.swift in Sources */, + 346EE31F9E1D4CB28BCE37A8 /* Models/BotSettings.swift in Sources */, + 77516EDB305B452A9063B036 /* Models/BotStateModels.swift in Sources */, + 4B164E69FD8746C58CA0E842 /* Models/ClusterModels.swift in Sources */, + 829DC28FA2E7429B93795C74 /* Models/DiscordCache.swift in Sources */, + 212CE68E56C348E2B16F8E20 /* Models/EventBus.swift in Sources */, + F6B4A085F1EF4FC8B45EA1A5 /* Models/GatewayModels.swift in Sources */, + 4310D51BEC4040248D9F8E66 /* Models/KeychainHelper.swift in Sources */, + 08DCC0CBE7324CA3B5253825 /* Models/RuleEngineModels.swift in Sources */, F2BE0FA6AB43AF1AB21CD5D7 /* Persistence.swift in Sources */, B4F6C2011122334455667788 /* SchemaSettings.swift in Sources */, C3D4E5F60112233445566778 /* SwiftMeshView.swift in Sources */, From 1d8646268d01cade78acdd41262d134152e3d79c Mon Sep 17 00:00:00 2001 From: johnwatso Date: Sat, 14 Mar 2026 14:00:46 +1300 Subject: [PATCH 11/11] [BETA] Refactor: Decompose Models.swift + WebUI Local Fallback fix --- SwiftBotApp/AdminWebServer.swift | 8 ++++++-- SwiftBotApp/AppModel.swift | 3 ++- SwiftBotApp/DiscordService.swift | 12 ++++++------ SwiftBotApp/Resources/admin/index.html | 8 +++++++- SwiftBotApp/WebUIPreferencesView.swift | 2 ++ 5 files changed, 23 insertions(+), 10 deletions(-) diff --git a/SwiftBotApp/AdminWebServer.swift b/SwiftBotApp/AdminWebServer.swift index 414b0a7..ab0d8d9 100644 --- a/SwiftBotApp/AdminWebServer.swift +++ b/SwiftBotApp/AdminWebServer.swift @@ -347,6 +347,7 @@ actor AdminWebServer { var redirectPath: String var allowedUserIDs: [String] var remoteAccessToken: String + var devFeaturesEnabled: Bool } private struct HTTPRequest { @@ -407,7 +408,8 @@ actor AdminWebServer { localAuthPassword: "", redirectPath: "/auth/discord/callback", allowedUserIDs: [], - remoteAccessToken: "" + remoteAccessToken: "", + devFeaturesEnabled: false ) private var listener: NWListener? private var nioChannel: Channel? @@ -1710,10 +1712,12 @@ actor AdminWebServer { config.localAuthEnabled && !config.localAuthUsername.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !config.localAuthPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let devFeaturesEnabled = config.devFeaturesEnabled return jsonResponse([ "discordEnabled": discordConfigured, - "localEnabled": localEnabled + "localEnabled": localEnabled && devFeaturesEnabled, + "devFeaturesEnabled": devFeaturesEnabled ]) } diff --git a/SwiftBotApp/AppModel.swift b/SwiftBotApp/AppModel.swift index 318b8dd..f7817e8 100644 --- a/SwiftBotApp/AppModel.swift +++ b/SwiftBotApp/AppModel.swift @@ -3354,7 +3354,8 @@ final class AppModel: ObservableObject { allowedUserIDs: settings.adminWebUI.restrictAccessToSpecificUsers ? settings.adminWebUI.normalizedAllowedUserIDs : [], - remoteAccessToken: settings.remoteAccessToken + remoteAccessToken: settings.remoteAccessToken, + devFeaturesEnabled: settings.devFeaturesEnabled ) let runtimeState = await adminWebServer.configure( diff --git a/SwiftBotApp/DiscordService.swift b/SwiftBotApp/DiscordService.swift index cbeea78..71a28be 100644 --- a/SwiftBotApp/DiscordService.swift +++ b/SwiftBotApp/DiscordService.swift @@ -154,12 +154,12 @@ actor DiscordService { private func handleInboundGatewayPayload(_ payload: GatewayPayload) async { // Run independent seed operations in parallel for faster gateway event processing. // These operate on disjoint state, so no synchronization is needed. - async let seedChannelTypes = seedChannelTypesIfNeeded(payload) - async let seedGuildName = seedGuildNameIfNeeded(payload) - async let seedVoiceChannels = seedVoiceChannelsIfNeeded(payload) - async let seedVoiceState = seedVoiceStateIfNeeded(payload) - // Wait for all seed operations to complete - await (seedChannelTypes, seedGuildName, seedVoiceChannels, seedVoiceState) + await withTaskGroup(of: Void.self) { group in + group.addTask { await self.seedChannelTypesIfNeeded(payload) } + group.addTask { await self.seedGuildNameIfNeeded(payload) } + group.addTask { await self.seedVoiceChannelsIfNeeded(payload) } + group.addTask { await self.seedVoiceStateIfNeeded(payload) } + } await processRuleActionsIfNeeded(payload) await onPayload?(payload) } diff --git a/SwiftBotApp/Resources/admin/index.html b/SwiftBotApp/Resources/admin/index.html index 805a773..ad3f027 100644 --- a/SwiftBotApp/Resources/admin/index.html +++ b/SwiftBotApp/Resources/admin/index.html @@ -1796,7 +1796,7 @@

Welcome to SwiftBot

-

Authenticate with Discord or use the local fallback for testing.

+

Authenticate with Discord.

Sign in with Discord
or
@@ -2369,6 +2369,7 @@

Recent Activity

const discordButton = document.getElementById('discordAuthButton'); const localForm = document.getElementById('localAuthForm'); const divider = document.getElementById('authDivider'); + const promptText = document.getElementById('authPromptText'); if (discordButton) { discordButton.style.display = authOptions.discordEnabled ? '' : 'none'; } @@ -2378,6 +2379,11 @@

Recent Activity

if (divider) { divider.style.display = authOptions.discordEnabled && authOptions.localEnabled ? '' : 'none'; } + if (promptText) { + promptText.textContent = authOptions.localEnabled + ? 'Authenticate with Discord or use the local fallback for testing.' + : 'Authenticate with Discord.'; + } } function renderStats(metrics) { diff --git a/SwiftBotApp/WebUIPreferencesView.swift b/SwiftBotApp/WebUIPreferencesView.swift index e9f2e5d..6a99b66 100644 --- a/SwiftBotApp/WebUIPreferencesView.swift +++ b/SwiftBotApp/WebUIPreferencesView.swift @@ -831,6 +831,7 @@ struct AdminWebAuthenticationSection: View { redirectURL: redirectURL(for: "discord") ) + if app.settings.devFeaturesEnabled { VStack(alignment: .leading, spacing: 14) { HStack(spacing: 12) { Image(systemName: "lock.shield") @@ -877,6 +878,7 @@ struct AdminWebAuthenticationSection: View { } .padding(14) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } // devFeaturesEnabled (Local Fallback) if app.settings.devFeaturesEnabled { OAuthProviderCard(