diff --git a/AI_CONTEXT.md b/AI_CONTEXT.md new file mode 100644 index 0000000..bd04902 --- /dev/null +++ b/AI_CONTEXT.md @@ -0,0 +1,316 @@ +# SwiftBot — AI Agent Context +> **START HERE.** Read this file before making any changes to the SwiftBot codebase. +> This is the single authoritative reference for AI agents (Claude, Gemini, Kimi, Codex). +> See also: `ARCHITECTURE.md` (technical deep-dive), `AI_GUIDE.md` (common tasks & patterns). + +--- + +## 1. Project Overview + +SwiftBot is a **native macOS Discord bot manager** built entirely with Swift and SwiftUI. + +| Property | Value | +|----------|-------| +| Platform | macOS 13+ | +| Language | Swift with Concurrency (async/await, actors) | +| Framework | SwiftUI — Apple-platform-first, no web frameworks | +| Design Language | Apple Human Interface Guidelines — Liquid Glass / modern macOS | +| Architecture | MVVM + actor isolation + EventBus pub/sub | +| Build System | Xcode + Swift Package Manager | + +**What it does:** Connects to Discord via WebSocket gateway, monitors server events, and executes automated rules when events match. Also supports AI replies, wiki lookups, update monitoring (Patchy), multi-node cluster failover (SwiftMesh), and a web-based admin UI. + +**Philosophy:** Apple-platform-first. Native macOS only. No external UI frameworks. SwiftUI throughout. Visual rule builder inspired by Apple Shortcuts. + +--- + +## 2. Core Architecture + +### Event Pipeline + +``` +Discord Gateway WebSocket + ↓ +DiscordService.receiveLoop() + ↓ +DiscordService.handleGatewayPayload() + ↓ +AppModel.handlePayload() + ├── handleMessageCreate() → executeCommand() / rule evaluation + ├── handleVoiceStateUpdate() → EventBus.publish(VoiceJoined/Left) + ├── handleReady() + └── handleGuildCreate() + ↓ +EventBus → Plugins (WeeklySummaryPlugin, etc.) + ↓ +RuleEngine.evaluateRules(event) [MainActor] + ↓ +Rule.processedActions (runtime migration of legacy bool toggles → modifier blocks) + ↓ +Pipeline: Trigger → Filters → Modifiers → Actions + ↓ +DiscordService.execute(action, for: event, context: &context) + ├── Update PipelineContext (modifiers / AI) + ├── sendMessage() + └── Discord REST API calls (roles, moderation, etc.) + ↓ +Discord REST API (https://discord.com/api/v10) +``` + +### Block-Based Rule Pipeline + +Rules execute in a strict linear sequence: + +``` +START + ↓ +[Trigger] — What fires the rule (optional on new/unconfigured rules) + ↓ +[Filters] — 0+ conditions: server, channel, user, duration, role + ↓ +[Modifiers] — 0+ message modifiers: replyToTrigger, mentionUser, sendToDM, sendToChannel + ↓ +[Actions] — 1+ outputs: sendMessage, addRole, kick, AI generation, etc. + ↓ +END +``` + +**Critical:** `rule.trigger == nil` and `rule.actions == []` are **valid, expected states** for new rules. Never pre-populate. Never assume a rule has content. + +### PipelineContext + +Mutable context threaded through execution. Modifiers set fields; Actions read them: + +```swift +struct PipelineContext { + var aiResponse: String? // set by generateAIResponse block + var targetChannelId: String? // override by sendToChannel modifier + var mentionUser: Bool = true // set by mentionUser modifier + var replyToTriggerMessage: Bool // set by replyToTrigger modifier + var mentionRole: String? // set by mentionRole modifier + var isDirectMessage: Bool // set by sendToDM modifier +} +``` + +--- + +## 3. Key Services + +| File | Purpose | +|------|---------| +| `AppModel.swift` | Primary app state `@MainActor ObservableObject`. Bot lifecycle, settings, gateway coordination, rule engine orchestration, Patchy scheduler, plugin management. | +| `AppModel+Commands.swift` | Slash and prefix command handlers | +| `AppModel+Gateway.swift` | Gateway event parsing and dispatch | +| `AppModel+AI.swift` | AI provider routing and response generation | +| `DiscordService.swift` | Discord WebSocket gateway + REST API actor. Rule action execution. AI replies. Wiki lookup. | +| `Models.swift` | All data types: Rule, RuleAction, Condition, TriggerType, ActionType, BlockCategory, ContextVariable, EventBus, RuleEngine, RuleStore, BotSettings, GuildSettings. | +| `ClusterCoordinator.swift` | SwiftMesh cluster: leader election, health monitoring, replication, failover | +| `Persistence.swift` | ConfigStore, RuleConfigStore, DiscordCacheStore, SwiftMeshConfigStore, MeshCursorStore (all actors). Keychain for secrets. | +| `AdminWebServer.swift` | HTTP REST API for web admin UI. Discord OAuth. | +| `VoiceActionsView.swift` | 3-pane rule editor UI (rule list + block library + canvas). **Claude's primary file.** | +| `VoiceRuleListView.swift` | Rule list panel with empty state onboarding | +| `EmptyRuleOnboardingView.swift` | Ghost placeholder shown when a rule has no blocks yet | +| `Sources/UpdateEngine` | Standalone Swift package: vendor-agnostic update detection used by Patchy | + +### Storage + +| Data | Path | +|------|------| +| Settings (non-sensitive) | `~/Library/Application Support/SwiftBot/settings.json` | +| Rules | `~/Library/Application Support/SwiftBot/rules.json` | +| Discord metadata cache | `~/Library/Application Support/SwiftBot/discord-cache.json` | +| SwiftMesh config | `~/Library/Application Support/SwiftBot/swiftmesh-config.json` | +| Mesh replication cursors | `~/Library/Application Support/SwiftBot/mesh-cursors.json` | +| **All secrets** | **macOS Keychain only** — never written to disk | + +### Keychain Accounts + +`"discord-token"` · `"openai-api-key"` · `"cluster-shared-secret"` · `"admin-discord-client-secret"` · `"admin-web-cloudflare-token"` + +--- + +## 4. SwiftMesh Cluster Rules + +### Discord Output Gate — STRICTLY ENFORCED + +| Node Mode | May Send Discord Messages | +|-----------|--------------------------| +| **Standalone** | ✅ YES | +| **Leader (Primary)** | ✅ YES | +| **Standby** | ❌ NO — monitoring only, may promote | +| **Worker** | ❌ NO — processing only, never sends | + +**All Discord output is gated through `DiscordService.outputAllowed`. This gate must never be bypassed.** + +### Cluster Safety Rules + +- **Term validation:** All `/v1/mesh/sync/*` routes MUST reject if incoming `leaderTerm` < local `leaderTerm` → `403 Forbidden` +- **Monotonic cursors:** Replication cursors only advance forward. Never regress. +- **Startup split-brain reconciliation:** A node configured as leader probes `/cluster/status` and demotes to standby if an active remote leader is found. +- **Auth is fail-closed:** All non-`/health` mesh routes require valid HMAC when `sharedSecret` is set. +- **Cursor keying:** Use stable `nodeName`, NOT endpoint URL. URL changes during failover must not orphan replication state. +- **Promotion side-effects:** `promoteToLeader()` intentionally wipes `replicationCursors` — correct behavior, not a bug. +- **Standby must run HTTP server:** Standby nodes must have their HTTP server active to receive sync pushes. + +### Mesh Phases (All Complete ✅) +Phase 1: failover + term safety · Phase 2: conversation sync + pagination + cursors · Phase 2.1: nodeName cursor keying · Phase 3: wiki cache replication + +--- + +## 5. UI Design Rules + +All UI in SwiftBot **must** follow: + +1. **Apple Human Interface Guidelines** — always, without exception +2. **SwiftUI only** — no AppKit views unless unavoidable (use `NSViewRepresentable`) +3. **System Settings layout style** — sidebar navigation, HSplitView, material backgrounds +4. **Liquid Glass / modern macOS design language** — `.ultraThinMaterial`, `.thinMaterial`, semantic system colors +5. **No web-style patterns** — no CSS-like layouts, no custom scrollbars, no card grids +6. **No external UI frameworks** — zero SwiftPM UI dependencies + +**Typography:** System fonts only (`.headline`, `.subheadline`, `.caption`, `.title2`, `.title3`) +**Color:** Semantic system colors (`.primary`, `.secondary`, `.accentColor`) — avoid hard-coded hex +**Spacing:** 16–20pt horizontal padding, 8–16pt vertical, generous 16–24pt between sections + +--- + +## 6. Rule Builder System + +### 3-Pane View Architecture + +``` +VoiceWorkspaceView (HSplitView) +├── RuleListView (220–320px) — VoiceRuleListView.swift +│ ├── isLoading → ProgressView() +│ ├── rules.isEmpty → RuleListEmptyStateView ("No Rules Yet") +│ └── normal → RuleRowView list +└── RuleEditorView — VoiceActionsView.swift + ├── Block Library pane (250–300px) + │ └── RuleBuilderLibraryView (ScrollViewReader + ScrollView) + │ ├── [Triggers] .id("library-triggers") + │ ├── [Filters] + │ ├── [Message Modifiers] + │ ├── [AI Blocks] + │ ├── [Actions] + │ ├── [Moderation] + │ └── [Utilities] + └── Canvas pane + ├── Rule name TextField + ├── ValidationBannerView (errors/warnings) + └── if rule.isEmptyRule + → EmptyRuleOnboardingView (ghost placeholder, pulsing arrow) + else + → 4-section pipeline canvas +``` + +### New Rule Onboarding Flow + +``` +1. ruleStore.addNewRule() → Rule.empty() { trigger: nil, actions: [] } +2. RuleListEmptyStateView → "Create First Rule" button +3. RuleEditorView.isEmptyRule == true → EmptyRuleOnboardingView ghost card +4. .onAppear: if !hasSeenRuleOnboarding → FirstRuleOnboardingCard sheet + ├── "Create Example Rule" → hello world rule, marks onboarding seen + └── "Start Empty" → guidedStep = .trigger (amber highlight on Trigger section) +5. User adds trigger → guidedStep = .action (mint highlight on Action section) +6. User adds action → guidedStep = .none, canvas shows normally +``` + +### Key Rule Computed Properties + +```swift +var isEmptyRule: Bool // trigger == nil && conditions.isEmpty && actions.isEmpty +var triggerSummary: String // "No trigger set" if nil +var validationIssues: [ValidationIssue] +var processedActions: [RuleAction] // runtime migration: legacy booleans → modifier blocks +``` + +--- + +## 7. Development Constraints + +### Always Do + +- ✅ Swift Concurrency (`async/await`, `actor`, `@MainActor`, `Task`) +- ✅ Keychain for ALL secrets — never write to disk +- ✅ Add new `.swift` files to `project.pbxproj` (4 entries: PBXFileReference, PBXBuildFile, PBXGroup, PBXSourcesBuildPhase) +- ✅ `.id(selectionID)` on all list-detail views to prevent SwiftUI state leakage +- ✅ Look up current selection INSIDE binding closures — never capture IDs at creation time +- ✅ `await MainActor.run {}` when updating `@Published` from background actors +- ✅ Use `embedJSON` from UpdateEngine directly for Patchy Discord notifications +- ✅ Persist Discord metadata cache on disconnect (offline config editing must work) + +### Never Do + +- ❌ Pre-populate new rules with default trigger or actions +- ❌ Change `Models.swift` structure without cross-agent approval +- ❌ Add new SwiftPM dependencies without explicit justification +- ❌ Break existing UI layouts or SplitView behaviors +- ❌ Send Discord output from Standby or Worker nodes +- ❌ Use `#if DEBUG` to gate production logic +- ❌ Call `test*` methods from production code paths +- ❌ Key mesh cursors by endpoint URL (use `nodeName`) +- ❌ Bypass `DiscordService.outputAllowed` gate +- ❌ Manually reconstruct Patchy Discord embeds + +--- + +## 8. Multi-Agent File Ownership + +When multiple agents work on SwiftBot simultaneously, respect file ownership: + +| File | Owner | +|------|-------| +| `VoiceActionsView.swift` | **claude** | +| `VoiceRuleListView.swift` | **claude** | +| `EmptyRuleOnboardingView.swift` | **claude** | +| `Models.swift` | **kimi** | +| `AppModel.swift` | **gemini** | +| `DiscordService.swift` | **gemini** | + +**Rule:** Post in `#swiftbotdev` before editing another agent's file. Coordinate task splits before starting any work. + +--- + +## 9. Data Types Quick Reference + +### Rule +```swift +struct Rule: Identifiable, Codable, Equatable { + var trigger: TriggerType? // nil = unconfigured (valid state) + var conditions: [Condition] = [] // filter blocks + var modifiers: [RuleAction] = [] // modifier blocks + var actions: [RuleAction] = [] // action blocks (empty = valid state) + var isEnabled: Bool = true +} +static func empty() -> Rule { Rule(trigger: nil, actions: []) } +``` + +### TriggerType (7 cases) +`userJoinedVoice` · `userLeftVoice` · `userMovedVoice` · `messageContains` · `memberJoined` · `reactionAdded` · `slashCommand` + +### ActionType by Category +- **Messaging:** `sendMessage`, `replyToMessage`, `sendDM`, `deleteMessage`, `addReaction` +- **Modifiers:** `replyToTrigger`, `mentionUser`, `mentionRole`, `sendToDM`, `sendToChannel` +- **AI:** `generateAIResponse` → stores output in `{ai.response}` +- **Moderation:** `addRole`, `removeRole`, `timeoutMember`, `kickMember`, `moveMember` +- **Utility:** `createChannel`, `webhook`, `addLogEntry`, `setStatus`, `delay`, `setVariable`, `randomChoice` + +### ContextVariable (20 tokens) +`{user}` `{user.id}` `{user.name}` `{user.nickname}` `{user.mention}` `{message}` `{message.id}` `{channel}` `{channel.id}` `{channel.name}` `{guild}` `{guild.id}` `{guild.name}` `{voice.channel}` `{voice.channel.id}` `{reaction}` `{reaction.emoji}` `{duration}` `{memberCount}` `{ai.response}` + +--- + +## 10. Before Marking Any Task Complete + +- [ ] Build succeeds — 0 errors, 0 new warnings +- [ ] Modified feature works as expected +- [ ] No regressions in unrelated features +- [ ] `CHANGELOG.md` updated +- [ ] Any new `.swift` files added to `project.pbxproj` +- [ ] Posted results in `#swiftbotdev` + +--- + +*Last updated: 2026-03-11* +*See `ARCHITECTURE.md` for full technical detail · `AI_GUIDE.md` for common task recipes* diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 18d4c7b..851f5cd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -136,12 +136,17 @@ DiscordService.processRuleActionsIfNeeded() ↓ DiscordService.parseVoiceRuleEvent() / parseMessageRuleEvent() ↓ -RuleEngine.evaluate(event) [MainActor] +RuleEngine.evaluateRules(event) [MainActor] ↓ -DiscordService.execute(action) - ├→ sendMessage() +Rule.processedActions (Runtime Migration) + ↓ +Pipeline Loop [for action in actions] + ↓ +DiscordService.execute(action, context) + ├→ Update PipelineContext (Modifiers/AI) + ├→ sendMessage(context) ├→ updatePresence() - └→ addLogEntry (no-op) + └→ REST Actions (Roles, Moderation, etc.) ``` ### EventBus Flow (Plugins) diff --git a/SwiftBot.xcodeproj/project.pbxproj b/SwiftBot.xcodeproj/project.pbxproj index b78367f..fd80b75 100644 --- a/SwiftBot.xcodeproj/project.pbxproj +++ b/SwiftBot.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 2E1AB44CA953D8D1C2F83AC2 /* AppModel+DiscordParsers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E1AB44BA953D8D1C2F83AC2 /* AppModel+DiscordParsers.swift */; }; 2FB770B8A11D22E33F44C550 /* AppModelTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FB770B7A11D22E33F44C550 /* AppModelTypes.swift */; }; 538A5B6B3E4166EE17B3A077 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35480F12EB2C7DFB546BD550 /* RootView.swift */; }; + 5B2FA002B2C3D4E5F6071901 /* EmptyRuleOnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B2FA001B2C3D4E5F6071900 /* EmptyRuleOnboardingView.swift */; }; 5B1E9801A1B2C3D4E5F60718 /* VoiceRuleListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B1E9800A1B2C3D4E5F60718 /* VoiceRuleListView.swift */; }; 6F1B40D1A2B3C4D5E6F70819 /* AppModel+Gateway.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F1B40D0A2B3C4D5E6F70819 /* AppModel+Gateway.swift */; }; 6F1B40D3A2B3C4D5E6F70819 /* AppModel+Commands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F1B40D2A2B3C4D5E6F70819 /* AppModel+Commands.swift */; }; @@ -78,6 +79,7 @@ 44A31413B04848EB75D50EFA /* SwiftBot.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftBot.app; sourceTree = BUILT_PRODUCTS_DIR; }; 5015DDB02F554EF200618C6D /* SwiftBot.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = SwiftBot.icon; sourceTree = SOURCE_ROOT; }; 5015DDB42F55851D00618C6D /* SwiftBot-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "SwiftBot-Info.plist"; sourceTree = ""; }; + 5B2FA001B2C3D4E5F6071900 /* EmptyRuleOnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyRuleOnboardingView.swift; sourceTree = ""; }; 5B1E9800A1B2C3D4E5F60718 /* VoiceRuleListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceRuleListView.swift; sourceTree = ""; }; 6337960E98AEBC0A19A67531 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; 6F1B40D0A2B3C4D5E6F70819 /* AppModel+Gateway.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppModel+Gateway.swift"; sourceTree = ""; }; @@ -181,6 +183,7 @@ D1A2B3C50102030405060708 /* DiagnosticsView.swift */, D6E7F8091A2B3C4D5E6F7081 /* IntelligenceGlowBorder.swift */, E4C1A9010102030405060708 /* WikiBridgeView.swift */, + 5B2FA001B2C3D4E5F6071900 /* EmptyRuleOnboardingView.swift */, 5B1E9800A1B2C3D4E5F60718 /* VoiceRuleListView.swift */, 0A6B7D201122334455667788 /* Resources */, 35480F12EB2C7DFB546BD550 /* RootView.swift */, @@ -327,6 +330,7 @@ AA1000011122334455667705 /* WebUIPreferencesView.swift in Sources */, AA1000011122334455667706 /* UpdatesPreferencesView.swift in Sources */, AA1000011122334455667707 /* AdvancedPreferencesView.swift in Sources */, + 5B2FA002B2C3D4E5F6071901 /* EmptyRuleOnboardingView.swift in Sources */, 5B1E9801A1B2C3D4E5F60718 /* VoiceRuleListView.swift in Sources */, D1A2B3C40102030405060708 /* DiagnosticsView.swift in Sources */, E4C1A9000102030405060708 /* WikiBridgeView.swift in Sources */, diff --git a/SwiftBotApp/AdminWebServer.swift b/SwiftBotApp/AdminWebServer.swift index 7db45ce..57a138c 100644 --- a/SwiftBotApp/AdminWebServer.swift +++ b/SwiftBotApp/AdminWebServer.swift @@ -185,6 +185,13 @@ struct AdminWebActionsPayload: Codable { let servers: [AdminWebSimpleOption] let textChannelsByServer: [String: [AdminWebSimpleOption]] let voiceChannelsByServer: [String: [AdminWebSimpleOption]] + + /// Server-driven metadata for generic WEBUI rendering + /// This replaces hard-coded assumptions with dynamic configuration + let builderMetadata: AdminWebBuilderMetadata + + /// Deprecated: Kept for backwards compatibility with older WEBUI versions + /// New WEBUI should use builderMetadata instead let conditionTypes: [String] let actionTypes: [String] } @@ -1654,3 +1661,789 @@ private final class AdminWebNIOHTTPHandler: ChannelInboundHandler { return 0 } } +import Foundation + +// MARK: - Server-Driven Metadata for WEBUI Action Builder +// This file defines the canonical contract exposed by the backend +// to enable generic, resilient WEBUI rendering without hard-coded assumptions. + +/// Complete metadata payload for the WEBUI action builder +/// This replaces hard-coded assumptions in the WEBUI with server-driven configuration +struct AdminWebBuilderMetadata: Codable { + /// Available trigger types with full metadata + let triggers: [AdminWebTriggerMetadata] + + /// Available condition/filter types with full metadata + let conditions: [AdminWebBlockMetadata] + + /// Available block types organized by category + let categories: [AdminWebCategoryMetadata] + + /// All available action/modifier/AI blocks with full metadata + let blocks: [AdminWebBlockMetadata] + + /// All available context variables for templating + let variables: [AdminWebVariableMetadata] + + /// Version of the metadata schema for forward compatibility + let schemaVersion: Int +} + +/// Metadata for a trigger type +struct AdminWebTriggerMetadata: Codable { + let id: String // Raw value (e.g., "messageCreated") + let name: String // Display name (e.g., "Message Created") + let symbol: String // SF Symbol name + let providedVariables: [String] // Context variables this trigger provides + let description: String? // Optional help text +} + +/// Metadata for a block (AI, Modifier, or Action) +struct AdminWebBlockMetadata: Codable { + let id: String // Raw value (e.g., "sendMessage") + let name: String // Display name (e.g., "Send Message") + let symbol: String // SF Symbol name + let category: String // Category ID (e.g., "actions", "ai") + let description: String? // Optional help text + + /// Variables required for this block to function + let requiredVariables: [String] + + /// Variables this block populates in context + let outputVariables: [String] + + /// Field definitions for configuring this block + let fields: [AdminWebFieldMetadata] + + /// Whether this block produces Discord output + let producesOutput: Bool + + /// Whether this block is an AI processing block + let isAIBlock: Bool + + /// Whether this block is a message modifier + let isModifier: Bool +} + +/// Metadata for a block category +struct AdminWebCategoryMetadata: Codable { + let id: String // Raw value (e.g., "actions") + let name: String // Display name (e.g., "Actions") + let symbol: String // SF Symbol name + let description: String? // Optional help text + let blockIds: [String] // IDs of blocks in this category + let order: Int // Display order in UI +} + +/// Metadata for a context variable +struct AdminWebVariableMetadata: Codable { + let id: String // Raw value (e.g., "{ai.response}") + let name: String // Display name (e.g., "AI Response") + let category: String // Category (e.g., "AI", "User", "Message") + let description: String? // Optional help text +} + +/// Metadata for a configuration field within a block +struct AdminWebFieldMetadata: Codable { + let id: String // Field identifier (e.g., "message", "channelId") + let name: String // Display name (e.g., "Message Content") + let type: AdminWebFieldType // Field type + let required: Bool // Whether field is required + let defaultValue: String? // Optional default value + let description: String? // Optional help text + let placeholder: String? // Optional placeholder text + + /// For picker/dropdown fields, the source of options + let optionsSource: AdminWebOptionsSource? +} + +/// Types of configuration fields +enum AdminWebFieldType: String, Codable { + case text // Single-line text input + case multiline // Multi-line text editor (with variable support) + case number // Numeric input + case boolean // Toggle/switch + case picker // Single-select dropdown + case searchablePicker // Searchable dropdown (for servers, channels, roles) + case emoji // Emoji picker + case duration // Duration input (seconds) +} + +/// Source for dropdown options +enum AdminWebOptionsSource: String, Codable { + case servers // Available Discord servers + case textChannels // Text channels (requires server context) + case voiceChannels // Voice channels (requires server context) + case roles // Roles (requires server context) + case predefined // Predefined static options +} + +// MARK: - Metadata Generation Helpers + +extension AdminWebBuilderMetadata { + /// Generate complete metadata from native Swift models + static func generateFromNativeModels() -> AdminWebBuilderMetadata { + return AdminWebBuilderMetadata( + triggers: TriggerType.allCases.map { $0.toMetadata() }, + conditions: ConditionType.allCases.map { $0.toMetadata() }, + categories: BlockCategory.allCases.map { $0.toMetadata() }, + blocks: ActionType.allCases.map { $0.toMetadata() }, + variables: ContextVariable.allCases.map { $0.toMetadata() }, + schemaVersion: 2 + ) + } +} + +// MARK: - Native Model to Metadata Conversions + +extension TriggerType { + func toMetadata() -> AdminWebTriggerMetadata { + return AdminWebTriggerMetadata( + id: self.rawValue, + name: self.rawValue, + symbol: self.symbol, + providedVariables: self.providedVariables.map { $0.rawValue }, + description: nil + ) + } +} + +extension ConditionType { + func toMetadata() -> AdminWebBlockMetadata { + return AdminWebBlockMetadata( + id: self.rawValue, + name: self.rawValue, + symbol: self.symbol, + category: "filters", + description: self.conditionDescription, + requiredVariables: self.requiredVariables.map { $0.rawValue }, + outputVariables: [], + fields: self.conditionFieldMetadata, + producesOutput: false, + isAIBlock: false, + isModifier: false + ) + } + + private var conditionDescription: String? { + switch self { + case .server: return "Only trigger in a specific server" + case .voiceChannel: return "Only trigger in a specific voice channel" + case .usernameContains: return "Only trigger if username contains text" + case .minimumDuration: return "Only trigger after user has been in channel for X minutes" + case .channelIs: return "Only trigger in a specific text channel" + case .channelCategory: return "Only trigger in channels of a specific category" + case .userHasRole: return "Only trigger if user has a specific role" + case .userJoinedRecently: return "Only trigger if user joined within X minutes" + case .messageContains: return "Only trigger if message contains text" + case .messageStartsWith: return "Only trigger if message starts with text" + case .messageRegex: return "Only trigger if message matches regex pattern" + case .isDirectMessage: return "Only trigger for direct messages" + case .isFromBot: return "Only trigger if message is from a bot" + case .isFromUser: return "Only trigger if message is from a user (not bot)" + case .channelType: return "Only trigger for specific channel types" + } + } + + private var conditionFieldMetadata: [AdminWebFieldMetadata] { + switch self { + case .server: + return [AdminWebFieldMetadata( + id: "value", + name: "Server", + type: .searchablePicker, + required: true, + defaultValue: nil, + description: "Select the server", + placeholder: "Select a server", + optionsSource: .servers + )] + case .voiceChannel: + return [AdminWebFieldMetadata( + id: "value", + name: "Voice Channel", + type: .searchablePicker, + required: true, + defaultValue: nil, + description: "Select the voice channel", + placeholder: "Select a voice channel", + optionsSource: .voiceChannels + )] + case .usernameContains: + return [AdminWebFieldMetadata( + id: "value", + name: "Username Contains", + type: .text, + required: true, + defaultValue: nil, + description: "Username must contain this text", + placeholder: "Enter text to match...", + optionsSource: nil + )] + case .minimumDuration: + return [AdminWebFieldMetadata( + id: "value", + name: "Minimum Duration (minutes)", + type: .number, + required: true, + defaultValue: "5", + description: "User must be in channel for at least this many minutes", + placeholder: "5", + optionsSource: nil + )] + case .channelIs: + return [AdminWebFieldMetadata( + id: "value", + name: "Channel", + type: .searchablePicker, + required: true, + defaultValue: nil, + description: "Select the text channel", + placeholder: "Select a channel", + optionsSource: .textChannels + )] + case .channelCategory: + return [AdminWebFieldMetadata( + id: "value", + name: "Category Name", + type: .text, + required: true, + defaultValue: nil, + description: "Channel category name to match", + placeholder: "Enter category name...", + optionsSource: nil + )] + case .userHasRole: + return [AdminWebFieldMetadata( + id: "value", + name: "Role", + type: .searchablePicker, + required: true, + defaultValue: nil, + description: "Select the required role", + placeholder: "Select a role", + optionsSource: .roles + )] + case .userJoinedRecently: + return [AdminWebFieldMetadata( + id: "value", + name: "Joined Within (minutes)", + type: .number, + required: true, + defaultValue: "60", + description: "User must have joined within this many minutes", + placeholder: "60", + optionsSource: nil + )] + case .messageContains: + return [AdminWebFieldMetadata( + id: "value", + name: "Message Contains", + type: .text, + required: true, + defaultValue: nil, + description: "Message must contain this text", + placeholder: "Enter text to search for...", + optionsSource: nil + )] + case .messageStartsWith: + return [AdminWebFieldMetadata( + id: "value", + name: "Starts With", + type: .text, + required: true, + defaultValue: nil, + description: "Message must start with this text", + placeholder: "Enter prefix...", + optionsSource: nil + )] + case .messageRegex: + return [AdminWebFieldMetadata( + id: "value", + name: "Regex Pattern", + type: .text, + required: true, + defaultValue: nil, + description: "Message must match this regular expression", + placeholder: "Enter regex pattern...", + optionsSource: nil + )] + case .isDirectMessage, .isFromBot, .isFromUser: + // Boolean conditions - no additional fields needed + return [] + case .channelType: + return [AdminWebFieldMetadata( + id: "value", + name: "Channel Type", + type: .picker, + required: true, + defaultValue: "text", + description: "Select the channel type", + placeholder: nil, + optionsSource: .predefined + )] + } + } +} + +extension ActionType { + func toMetadata() -> AdminWebBlockMetadata { + return AdminWebBlockMetadata( + id: self.rawValue, + name: self.rawValue, + symbol: self.symbol, + category: self.category.rawValue, + description: self.description, + requiredVariables: self.requiredVariables.map { $0.rawValue }, + outputVariables: self.outputVariables.map { $0.rawValue }, + fields: self.fieldMetadata, + producesOutput: self.producesOutput, + isAIBlock: self.isAIBlock, + isModifier: self.isModifier + ) + } + + /// Whether this action type produces Discord output + private var producesOutput: Bool { + switch self { + case .sendMessage, .sendDM, .addReaction, .deleteMessage, + .addRole, .removeRole, .timeoutMember, .kickMember, + .moveMember, .createChannel, .webhook, .setStatus, .addLogEntry: + return true + case .generateAIResponse, .summariseMessage, .classifyMessage, + .extractEntities, .rewriteMessage, .delay, .setVariable, + .randomChoice, .replyToTrigger, .mentionUser, .mentionRole, + .disableMention, .sendToChannel, .sendToDM: + return false + } + } + + /// Whether this is an AI processing block + private var isAIBlock: Bool { + switch self { + case .generateAIResponse, .summariseMessage, .classifyMessage, + .extractEntities, .rewriteMessage: + return true + default: + return false + } + } + + /// Whether this is a message modifier + private var isModifier: Bool { + switch self { + case .replyToTrigger, .mentionUser, .mentionRole, + .disableMention, .sendToChannel, .sendToDM: + return true + default: + return false + } + } + + /// Field metadata for each action type + private var fieldMetadata: [AdminWebFieldMetadata] { + switch self { + case .sendMessage: + return [ + AdminWebFieldMetadata( + id: "destinationMode", + name: "Destination", + type: .picker, + required: true, + defaultValue: "replyToTrigger", + description: "Where the message should be sent by default", + placeholder: nil, + optionsSource: .predefined + ), + AdminWebFieldMetadata( + id: "serverId", + name: "Server", + type: .searchablePicker, + required: false, + defaultValue: nil, + description: "Only used when Destination is 'Specific Channel'", + placeholder: "Select a server", + optionsSource: .servers + ), + AdminWebFieldMetadata( + id: "channelId", + name: "Channel", + type: .searchablePicker, + required: false, + defaultValue: nil, + description: "Only used when Destination is 'Specific Channel'", + placeholder: "Select a channel", + optionsSource: .textChannels + ), + AdminWebFieldMetadata( + id: "contentSource", + name: "Content Source", + type: .picker, + required: true, + defaultValue: "custom", + description: "Source of the message content", + placeholder: nil, + optionsSource: .predefined + ), + AdminWebFieldMetadata( + id: "message", + name: "Message Content", + type: .multiline, + required: false, + defaultValue: nil, + description: "Message content (only used when Content Source is 'Custom Message')", + placeholder: "Enter message content...", + optionsSource: nil + ) + ] + + case .sendDM: + // Send DM is now a routing modifier only - content comes from Send Message action + return [] + + case .generateAIResponse: + return [ + AdminWebFieldMetadata( + id: "message", + name: "AI Prompt", + type: .multiline, + required: true, + defaultValue: "You are a helpful assistant. {message}", + description: "Prompt for AI generation. Result available as {ai.response}", + placeholder: "Enter AI prompt...", + optionsSource: nil + ) + ] + + case .summariseMessage: + return [ + AdminWebFieldMetadata( + id: "message", + name: "Context (Optional)", + type: .multiline, + required: false, + defaultValue: nil, + description: "Additional context for summarization. Result available as {ai.summary}", + placeholder: "Enter optional context...", + optionsSource: nil + ) + ] + + case .classifyMessage: + return [ + AdminWebFieldMetadata( + id: "categories", + name: "Categories", + type: .text, + required: false, + defaultValue: "question, feedback, spam, other", + description: "Comma-separated categories. Result available as {ai.classification}", + placeholder: "question, feedback, spam, other", + optionsSource: nil + ) + ] + + case .extractEntities: + return [ + AdminWebFieldMetadata( + id: "entityTypes", + name: "Entity Types", + type: .text, + required: false, + defaultValue: "names, dates, locations, organizations", + description: "Comma-separated entity types. Result available as {ai.entities}", + placeholder: "names, dates, locations, organizations", + optionsSource: nil + ) + ] + + case .rewriteMessage: + return [ + AdminWebFieldMetadata( + id: "rewriteStyle", + name: "Style", + type: .text, + required: false, + defaultValue: "professional", + description: "Target style for rewriting. Result available as {ai.rewrite}", + placeholder: "professional, casual, formal...", + optionsSource: nil + ) + ] + + case .addReaction: + return [ + AdminWebFieldMetadata( + id: "emoji", + name: "Emoji", + type: .emoji, + required: true, + defaultValue: "👍", + description: "Emoji to react with", + placeholder: nil, + optionsSource: nil + ) + ] + + case .addRole, .removeRole: + return [ + AdminWebFieldMetadata( + id: "roleId", + name: "Role", + type: .searchablePicker, + required: true, + defaultValue: nil, + description: "Target role", + placeholder: "Select a role", + optionsSource: .roles + ) + ] + + case .timeoutMember: + return [ + AdminWebFieldMetadata( + id: "timeoutDuration", + name: "Duration (seconds)", + type: .duration, + required: true, + defaultValue: "3600", + description: "Timeout duration in seconds", + placeholder: "3600", + optionsSource: nil + ) + ] + + case .kickMember: + return [ + AdminWebFieldMetadata( + id: "kickReason", + name: "Reason (Optional)", + type: .text, + required: false, + defaultValue: nil, + description: "Reason for kicking", + placeholder: "Enter reason...", + optionsSource: nil + ) + ] + + case .moveMember: + return [ + AdminWebFieldMetadata( + id: "targetVoiceChannelId", + name: "Target Voice Channel", + type: .searchablePicker, + required: true, + defaultValue: nil, + description: "Voice channel to move member to", + placeholder: "Select a voice channel", + optionsSource: .voiceChannels + ) + ] + + case .createChannel: + return [ + AdminWebFieldMetadata( + id: "newChannelName", + name: "Channel Name", + type: .text, + required: true, + defaultValue: nil, + description: "Name for the new channel", + placeholder: "new-channel", + optionsSource: nil + ) + ] + + case .webhook: + return [ + AdminWebFieldMetadata( + id: "webhookURL", + name: "Webhook URL", + type: .text, + required: true, + defaultValue: nil, + description: "Webhook endpoint URL", + placeholder: "https://...", + optionsSource: nil + ), + AdminWebFieldMetadata( + id: "webhookContent", + name: "Payload Content", + type: .multiline, + required: true, + defaultValue: nil, + description: "JSON payload content", + placeholder: "{\"content\": \"...\"}", + optionsSource: nil + ) + ] + + case .delay: + return [ + AdminWebFieldMetadata( + id: "delaySeconds", + name: "Delay (seconds)", + type: .duration, + required: true, + defaultValue: "5", + description: "Delay before next action", + placeholder: "5", + optionsSource: nil + ) + ] + + case .setVariable: + return [ + AdminWebFieldMetadata( + id: "variableName", + name: "Variable Name", + type: .text, + required: true, + defaultValue: nil, + description: "Name of the variable", + placeholder: "myVariable", + optionsSource: nil + ), + AdminWebFieldMetadata( + id: "variableValue", + name: "Variable Value", + type: .text, + required: true, + defaultValue: nil, + description: "Value to store", + placeholder: "Enter value...", + optionsSource: nil + ) + ] + + case .setStatus: + return [ + AdminWebFieldMetadata( + id: "statusText", + name: "Status Text", + type: .text, + required: true, + defaultValue: "Bot is active", + description: "Bot presence status", + placeholder: "Enter status...", + optionsSource: nil + ) + ] + + case .addLogEntry: + return [ + AdminWebFieldMetadata( + id: "message", + name: "Log Message", + type: .text, + required: true, + defaultValue: "Rule executed", + description: "Message to log", + placeholder: "Enter log message...", + optionsSource: nil + ) + ] + + case .deleteMessage: + return [ + AdminWebFieldMetadata( + id: "deleteDelaySeconds", + name: "Delete Delay (seconds)", + type: .duration, + required: false, + defaultValue: "0", + description: "Delay before deleting (0 for immediate)", + placeholder: "0", + optionsSource: nil + ) + ] + + // Modifiers - no additional fields beyond the toggle + case .replyToTrigger, .mentionUser, .mentionRole, .disableMention, .sendToChannel, .sendToDM: + return [] + + case .randomChoice: + // Random choice would need array of options - simplified for now + return [] + } + } + + private var description: String? { + switch self { + case .sendMessage: + return "Sends a message to a channel" + case .sendDM: + return "Sends a direct message to the triggering user" + case .generateAIResponse: + return "Generates an AI response. Result: {ai.response}" + case .summariseMessage: + return "Summarizes the message. Result: {ai.summary}" + case .classifyMessage: + return "Classifies the message. Result: {ai.classification}" + case .extractEntities: + return "Extracts entities from the message. Result: {ai.entities}" + case .rewriteMessage: + return "Rewrites the message in a different style. Result: {ai.rewrite}" + default: + return nil + } + } +} + +extension BlockCategory { + func toMetadata() -> AdminWebCategoryMetadata { + // Get block IDs for this category + let blockIds = ActionType.allCases + .filter { $0.category == self } + .map { $0.rawValue } + + return AdminWebCategoryMetadata( + id: self.rawValue, + name: self.rawValue, + symbol: self.symbol, + description: self.description, + blockIds: blockIds, + order: self.displayOrder + ) + } + + private var displayOrder: Int { + switch self { + case .triggers: return 0 + case .filters: return 1 + case .ai: return 2 + case .messaging: return 3 + case .actions: return 4 + case .moderation: return 5 + } + } + + private var description: String? { + switch self { + case .triggers: + return "Events that start a rule" + case .filters: + return "Conditions that must be met" + case .ai: + return "AI processing blocks that generate data" + case .messaging: + return "Modifiers that control message delivery" + case .actions: + return "Blocks that produce Discord output" + case .moderation: + return "Server management actions" + } + } +} + +extension ContextVariable { + func toMetadata() -> AdminWebVariableMetadata { + return AdminWebVariableMetadata( + id: self.rawValue, + name: self.displayName, + category: self.category, + description: nil + ) + } +} diff --git a/SwiftBotApp/AppModel+Gateway.swift b/SwiftBotApp/AppModel+Gateway.swift index 26e1dc1..d60afa4 100644 --- a/SwiftBotApp/AppModel+Gateway.swift +++ b/SwiftBotApp/AppModel+Gateway.swift @@ -46,6 +46,8 @@ extension AppModel { await handleChannelCreate(payload.d) case "GUILD_MEMBER_ADD": await handleMemberJoin(payload.d) + case "GUILD_MEMBER_REMOVE": + await handleMemberLeave(payload.d) case "GUILD_DELETE": await handleGuildDelete(payload.d) default: @@ -248,6 +250,12 @@ extension AppModel { if settings.localAIDMReplyEnabled { guard await checkRateLimit(userId: userId, username: username, channelId: channelId, isDM: true) else { return } + // Skip AI reply if message was already handled by rule actions + if await service.wasMessageHandledByRules(messageId: messageId) { + logs.append("AI DM reply skipped: message \(messageId) was handled by rule actions") + return + } + let scope = MemoryScope.directMessageUser(userId) let (messages, wikiContext) = await aiMessagesForScope( scope: scope, diff --git a/SwiftBotApp/AppModel.swift b/SwiftBotApp/AppModel.swift index 81452d2..b175ae4 100644 --- a/SwiftBotApp/AppModel.swift +++ b/SwiftBotApp/AppModel.swift @@ -1650,6 +1650,7 @@ final class AppModel: ObservableObject { servers: servers, textChannelsByServer: textChannelsByServer, voiceChannelsByServer: voiceChannelsByServer, + builderMetadata: AdminWebBuilderMetadata.generateFromNativeModels(), conditionTypes: ConditionType.allCases.map(\.rawValue), actionTypes: ActionType.allCases.map(\.rawValue) ) @@ -1967,6 +1968,7 @@ final class AppModel: ObservableObject { servers: [], textChannelsByServer: [:], voiceChannelsByServer: [:], + builderMetadata: AdminWebBuilderMetadata.generateFromNativeModels(), conditionTypes: ConditionType.allCases.map(\.rawValue), actionTypes: ActionType.allCases.map(\.rawValue) ) @@ -3379,6 +3381,15 @@ final class AppModel: ObservableObject { _ = await send(channelId, message) } + let joinedAt: Date? = { + if case let .string(dateStr)? = map["joined_at"] { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: dateStr) ?? ISO8601DateFormatter().date(from: dateStr) + } + return nil + }() + // Rule-based execution: evaluate any enabled "Member Joined" trigger rules. let ruleEvent = VoiceRuleEvent( kind: .memberJoin, @@ -3395,18 +3406,23 @@ final class AppModel: ObservableObject { triggerChannelId: nil, triggerGuildId: guildId, triggerUserId: userId, - isDirectMessage: false + isDirectMessage: false, + authorIsBot: nil, + joinedAt: joinedAt ) - let matchedActions = ruleEngine.evaluate(event: ruleEvent) - for action in matchedActions where action.type == .sendMessage { - let ruleMessage = action.message - .replacingOccurrences(of: "{username}", with: safeUsername) - .replacingOccurrences(of: "{server}", with: serverName) - .replacingOccurrences(of: "{memberCount}", with: "\(memberCount)") - .replacingOccurrences(of: "{userId}", with: userId) - let targetChannel = action.channelId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !targetChannel.isEmpty else { continue } - _ = await send(targetChannel, ruleMessage) + let matchedRules = ruleEngine.evaluateRules(event: ruleEvent) + for rule in matchedRules { + var context = PipelineContext() + for action in rule.processedActions where action.type == .sendMessage { + let ruleMessage = action.message + .replacingOccurrences(of: "{username}", with: safeUsername) + .replacingOccurrences(of: "{server}", with: serverName) + .replacingOccurrences(of: "{memberCount}", with: "\(memberCount)") + .replacingOccurrences(of: "{userId}", with: userId) + let targetChannel = action.channelId.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + guard !targetChannel.isEmpty else { continue } + _ = await send(targetChannel, ruleMessage) + } } // Log username only — no internal IDs or metadata. @@ -3414,6 +3430,57 @@ final class AppModel: ObservableObject { logs.append("Member join welcome sent for \(safeUsername) in \(serverName)") } + func handleMemberLeave(_ raw: DiscordJSON?) async { + guard case let .object(map)? = raw, + case let .object(user)? = map["user"], + case let .string(userId)? = user["id"], + case let .string(guildId)? = map["guild_id"] + else { return } + + let now = Date() + + // Best-effort member count decrement + if let count = guildMemberCounts[guildId] { + guildMemberCounts[guildId] = max(0, count - 1) + } + + let username: String = { + if case let .string(name)? = user["global_name"] ?? user["username"] { return name } + return "Unknown" + }() + + let ruleEvent = VoiceRuleEvent( + kind: .memberLeave, + guildId: guildId, + userId: userId, + username: username, + channelId: "", + fromChannelId: nil, + toChannelId: nil, + durationSeconds: nil, + messageContent: nil, + messageId: nil, + triggerMessageId: nil, + triggerChannelId: nil, + triggerGuildId: guildId, + triggerUserId: userId, + isDirectMessage: false, + authorIsBot: nil, + joinedAt: nil + ) + + let matchedRules = ruleEngine.evaluateRules(event: ruleEvent) + for rule in matchedRules { + var context = PipelineContext() + for action in rule.processedActions { + await service.execute(action: action, for: ruleEvent, context: &context) + } + } + + addEvent(ActivityEvent(timestamp: now, kind: .info, message: "🚪 \(username) left the server")) + logs.append("Member leave handled for \(username)") + } + func handleGuildCreate(_ raw: DiscordJSON?) async { guard case let .object(map)? = raw, case let .string(guildId)? = map["id"] diff --git a/SwiftBotApp/DiscordService.swift b/SwiftBotApp/DiscordService.swift index 614b299..e802aa2 100644 --- a/SwiftBotApp/DiscordService.swift +++ b/SwiftBotApp/DiscordService.swift @@ -391,6 +391,10 @@ actor DiscordService { private var localOpenAIModel = "gpt-4o-mini" private var localAISystemPrompt = "" + /// Tracks message IDs that were handled by rule actions to prevent duplicate AI replies + private var ruleHandledMessageIds: Set = [] + private let ruleHandledLock = NSLock() + private let session = URLSession(configuration: .default) /// Dedicated session for Discord identity probes (/users/@me, /oauth2/applications/@me). @@ -459,6 +463,26 @@ actor DiscordService { localAISystemPrompt = systemPrompt.trimmingCharacters(in: .whitespacesAndNewlines) } + /// Checks if a message was already handled by rule actions (prevents duplicate AI replies) + func wasMessageHandledByRules(messageId: String) -> Bool { + ruleHandledLock.lock() + defer { ruleHandledLock.unlock() } + return ruleHandledMessageIds.contains(messageId) + } + + /// Marks a message as handled by rule actions + func markMessageHandledByRules(messageId: String) { + ruleHandledLock.lock() + ruleHandledMessageIds.insert(messageId) + // Cleanup old entries to prevent memory growth (keep last 1000) + if ruleHandledMessageIds.count > 1000 { + // Remove oldest entries by converting to array and back + let sortedIds = Array(ruleHandledMessageIds) + ruleHandledMessageIds = Set(sortedIds.suffix(1000)) + } + ruleHandledLock.unlock() + } + func detectOllamaModel(baseURL: String) async -> String? { await detectOllamaModel(baseURL: baseURL, preferredModel: nil) } @@ -2022,12 +2046,31 @@ actor DiscordService { guard let event else { return } let engine = ruleEngine - let actions = await MainActor.run { - engine?.evaluate(event: event) ?? [] + let ruleActions = await MainActor.run { + engine?.evaluateRules(event: event).map { (isDM: event.isDirectMessage, actions: $0.processedActions) } ?? [] } - for action in actions { - await execute(action: action, for: event) + for ruleResult in ruleActions { + var context = PipelineContext() + context.isDirectMessage = ruleResult.isDM + context.triggerGuildId = event.triggerGuildId + context.triggerChannelId = event.triggerChannelId + context.triggerMessageId = event.triggerMessageId + + discordLogger.debug("Executing rule pipeline: \(ruleResult.actions.count) blocks. Initial context: \(context)") + + for (index, action) in ruleResult.actions.enumerated() { + await execute(action: action, for: event, context: &context) + discordLogger.debug(" [\(index)] Executed \(action.type.rawValue). Updated context: \(context)") + } + + // Mark message as handled if any action produced output + if context.eventHandled, let messageId = event.triggerMessageId { + markMessageHandledByRules(messageId: messageId) + discordLogger.debug("Message \(messageId) handled by rule actions - AI reply will be skipped") + } + + discordLogger.debug("Rule pipeline execution complete.") } } @@ -2063,7 +2106,9 @@ actor DiscordService { triggerChannelId: nil, triggerGuildId: guildId, triggerUserId: userId, - isDirectMessage: false + isDirectMessage: false, + authorIsBot: nil, + joinedAt: nil ) } @@ -2087,7 +2132,9 @@ actor DiscordService { triggerChannelId: nil, triggerGuildId: guildId, triggerUserId: userId, - isDirectMessage: false + isDirectMessage: false, + authorIsBot: nil, + joinedAt: nil ) } @@ -2111,7 +2158,9 @@ actor DiscordService { triggerChannelId: nil, triggerGuildId: guildId, triggerUserId: userId, - isDirectMessage: false + isDirectMessage: false, + authorIsBot: nil, + joinedAt: nil ) } @@ -2128,9 +2177,10 @@ actor DiscordService { case let .string(channelId)? = map["channel_id"] else { return nil } - if case let .bool(isBot)? = author["bot"], isBot { - return nil - } + let authorIsBot: Bool = { + if case let .bool(isBot)? = author["bot"] { return isBot } + return false + }() let guildId: String = { if case let .string(gid)? = map["guild_id"] { return gid } @@ -2154,7 +2204,9 @@ actor DiscordService { triggerChannelId: channelId, triggerGuildId: guildId, triggerUserId: userId, - isDirectMessage: isDirectMessage + isDirectMessage: isDirectMessage, + authorIsBot: authorIsBot, + joinedAt: nil ) } @@ -2165,6 +2217,131 @@ actor DiscordService { return channelTypeById[channelId] } + func sendDM(userId: String, content: String) async throws { + guard let token = botToken else { return } + + // 1. Create DM channel + var req = URLRequest(url: restBase.appendingPathComponent("users/@me/channels")) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("Bot \(token)", forHTTPHeaderField: "Authorization") + req.httpBody = try JSONSerialization.data(withJSONObject: ["recipient_id": userId]) + + let (data, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode), + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let channelId = json["id"] as? String else { + throw NSError(domain: "DiscordService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to create DM channel"]) + } + + // 2. Send message to that channel + try await sendMessage(channelId: channelId, content: content, token: token) + } + + func deleteMessage(channelId: String, messageId: String, token: String) async throws { + var req = URLRequest(url: restBase.appendingPathComponent("channels/\(channelId)/messages/\(messageId)")) + req.httpMethod = "DELETE" + req.setValue("Bot \(token)", forHTTPHeaderField: "Authorization") + let (_, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw NSError(domain: "DiscordService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to delete message"]) + } + } + + func addRole(guildId: String, userId: String, roleId: String, token: String) async throws { + var req = URLRequest(url: restBase.appendingPathComponent("guilds/\(guildId)/members/\(userId)/roles/\(roleId)")) + req.httpMethod = "PUT" + req.setValue("Bot \(token)", forHTTPHeaderField: "Authorization") + let (_, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw NSError(domain: "DiscordService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to add role"]) + } + } + + func removeRole(guildId: String, userId: String, roleId: String, token: String) async throws { + var req = URLRequest(url: restBase.appendingPathComponent("guilds/\(guildId)/members/\(userId)/roles/\(roleId)")) + req.httpMethod = "DELETE" + req.setValue("Bot \(token)", forHTTPHeaderField: "Authorization") + let (_, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw NSError(domain: "DiscordService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to remove role"]) + } + } + + func timeoutMember(guildId: String, userId: String, durationSeconds: Int, token: String) async throws { + let until = Date().addingTimeInterval(TimeInterval(durationSeconds)) + let formatter = ISO8601DateFormatter() + let body: [String: Any] = ["communication_disabled_until": formatter.string(from: until)] + + var req = URLRequest(url: restBase.appendingPathComponent("guilds/\(guildId)/members/\(userId)")) + req.httpMethod = "PATCH" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("Bot \(token)", forHTTPHeaderField: "Authorization") + req.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (_, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw NSError(domain: "DiscordService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to timeout member"]) + } + } + + func kickMember(guildId: String, userId: String, reason: String, token: String) async throws { + var components = URLComponents(url: restBase.appendingPathComponent("guilds/\(guildId)/members/\(userId)"), resolvingAgainstBaseURL: false) + if !reason.isEmpty { + components?.queryItems = [URLQueryItem(name: "reason", value: reason)] + } + guard let url = components?.url else { return } + + var req = URLRequest(url: url) + req.httpMethod = "DELETE" + req.setValue("Bot \(token)", forHTTPHeaderField: "Authorization") + let (_, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw NSError(domain: "DiscordService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to kick member"]) + } + } + + func moveMember(guildId: String, userId: String, channelId: String, token: String) async throws { + let body: [String: Any] = ["channel_id": channelId.isEmpty ? NSNull() : channelId] + var req = URLRequest(url: restBase.appendingPathComponent("guilds/\(guildId)/members/\(userId)")) + req.httpMethod = "PATCH" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("Bot \(token)", forHTTPHeaderField: "Authorization") + req.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (_, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw NSError(domain: "DiscordService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to move member"]) + } + } + + func createChannel(guildId: String, name: String, token: String) async throws { + let body: [String: Any] = ["name": name, "type": 0] // Text channel + var req = URLRequest(url: restBase.appendingPathComponent("guilds/\(guildId)/channels")) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("Bot \(token)", forHTTPHeaderField: "Authorization") + req.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (_, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw NSError(domain: "DiscordService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to create channel"]) + } + } + + func sendWebhook(url: String, content: String) async throws { + guard let webhookUrl = URL(string: url) else { return } + var req = URLRequest(url: webhookUrl) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONSerialization.data(withJSONObject: ["content": content]) + + let (_, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw NSError(domain: "DiscordService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to send webhook"]) + } + } + private func parseUsername(from map: [String: DiscordJSON], userId: String) -> String { if case let .object(member)? = map["member"], case let .object(user)? = member["user"], @@ -2174,96 +2351,195 @@ actor DiscordService { return "User \(userId.suffix(4))" } - private func execute(action: Action, for event: VoiceRuleEvent) async { + func execute(action: Action, for event: VoiceRuleEvent, context: inout PipelineContext) async { + guard let token = botToken else { return } + switch action.type { - case .sendMessage: - guard let token = botToken else { return } - let targetChannelId: String - if action.replyToTriggerMessage { - targetChannelId = event.triggerChannelId ?? "" - } else { - targetChannelId = action.channelId - } - guard !targetChannelId.isEmpty else { return } - - let rendered: String - if event.kind == .message, - event.isDirectMessage, - !action.replyWithAI, - localAIDMReplyEnabled, - let userMessage = event.messageContent { - - let scope = MemoryScope.directMessageUser(event.userId) - let history = await historyProvider?(scope) ?? [] - - // Ensure the current message is included if not already in history - var messagesToProcess = history - if !messagesToProcess.contains(where: { $0.content == userMessage && $0.role == .user }) { - messagesToProcess.append(Message( - channelID: event.channelId, - userID: event.userId, - username: event.username, - content: userMessage, - role: .user - )) - } - - discordLogger.debug("Local AI DM reply triggered for user \(event.userId, privacy: .private)") - if let aiReply = await generateLocalAIDMReply( - messages: messagesToProcess, - serverName: guildNamesById[event.guildId], - channelName: "Direct Message" - ) { - rendered = aiReply - } else { - rendered = renderMessage(template: action.message, event: event, mentionUser: action.mentionUser) - } - } else { - if action.replyWithAI { - let prompt = renderMessage(template: action.message, event: event, mentionUser: action.mentionUser) - if let aiReply = await generateRuleActionAIReply(prompt: prompt, event: event) { - rendered = aiReply - } else { - rendered = renderMessage(template: action.message, event: event, mentionUser: action.mentionUser) - } - } else { - rendered = renderMessage(template: action.message, event: event, mentionUser: action.mentionUser) - } + case .mentionUser: + context.prependUserMention = true + case .mentionRole: + context.mentionRole = action.roleId + case .disableMention: + context.mentionUser = false + case .sendToChannel: + context.targetChannelId = action.channelId + case .sendToDM: + context.sendToDM = true + case .replyToTrigger: + context.replyToTriggerMessage = true + if let triggerChannelId = event.triggerChannelId { + context.targetChannelId = triggerChannelId + } + case .generateAIResponse: + let prompt = renderMessage(template: action.message, event: event, context: context) + if let aiReply = await generateRuleActionAIReply(prompt: prompt, event: event) { + context.aiResponse = aiReply + } + case .summariseMessage: + guard let content = event.messageContent, !content.isEmpty else { break } + let prompt = "Summarize the following message concisely:\n\n\(content)" + if let summary = await generateRuleActionAIReply(prompt: prompt, event: event) { + context.aiSummary = summary + } + case .classifyMessage: + guard let content = event.messageContent, !content.isEmpty else { break } + let categories = action.categories.isEmpty ? "question, feedback, spam, other" : action.categories + let prompt = "Classify the following message into one of these categories [\(categories)]:\n\n\(content)\n\nCategory:" + if let classification = await generateRuleActionAIReply(prompt: prompt, event: event) { + context.aiClassification = classification.trimmingCharacters(in: .whitespacesAndNewlines) + } + case .extractEntities: + guard let content = event.messageContent, !content.isEmpty else { break } + let entityTypes = action.entityTypes.isEmpty ? "names, dates, locations, organizations" : action.entityTypes + let prompt = "Extract \(entityTypes) from the following message as a comma-separated list:\n\n\(content)\n\nEntities:" + if let entities = await generateRuleActionAIReply(prompt: prompt, event: event) { + context.aiEntities = entities.trimmingCharacters(in: .whitespacesAndNewlines) + } + case .rewriteMessage: + guard let content = event.messageContent, !content.isEmpty else { break } + let style = action.rewriteStyle.isEmpty ? "professional" : action.rewriteStyle + let prompt = "Rewrite the following message in a \(style) style:\n\n\(content)\n\nRewritten:" + if let rewrite = await generateRuleActionAIReply(prompt: prompt, event: event) { + context.aiRewrite = rewrite } - - if action.replyToTriggerMessage, - let triggerMessageId = event.triggerMessageId { + case .sendMessage: + // Determine content based on contentSource + let messageContent: String + switch action.contentSource { + case .custom: + messageContent = action.message + case .aiResponse: + messageContent = context.aiResponse ?? "{ai.response} not available" + case .aiSummary: + messageContent = context.aiSummary ?? "{ai.summary} not available" + case .aiClassification: + messageContent = context.aiClassification ?? "{ai.classification} not available" + case .aiEntities: + messageContent = context.aiEntities ?? "{ai.entities} not available" + case .aiRewrite: + messageContent = context.aiRewrite ?? "{ai.rewrite} not available" + } + + let targetIsDM = context.sendToDM + let rendered = renderMessage(template: messageContent, event: event, context: context) + + if targetIsDM && !event.userId.isEmpty { + _ = try? await sendDM(userId: event.userId, content: rendered) + context.eventHandled = true + return + } + + let modifierTargetChannelId = context.targetChannelId + let triggerMessageId = context.triggerMessageId ?? event.triggerMessageId + let triggerChannelId = context.triggerChannelId ?? event.triggerChannelId + + if context.replyToTriggerMessage, + let triggerMessageId, + let triggerChannelId, + !triggerChannelId.isEmpty { let payload: [String: Any] = [ "content": rendered, "message_reference": [ "message_id": triggerMessageId, - "channel_id": targetChannelId, + "channel_id": triggerChannelId, "fail_if_not_exists": false ] ] - _ = try? await sendMessage(channelId: targetChannelId, payload: payload, token: token) - } else { + _ = try? await sendMessage(channelId: triggerChannelId, payload: payload, token: token) + context.eventHandled = true + return + } + + let destinationMode = action.destinationMode ?? MessageDestination.defaultMode(for: event, context: context) + + switch destinationMode { + case .replyToTrigger: + if let triggerMessageId, + let triggerChannelId, + !triggerChannelId.isEmpty { + let payload: [String: Any] = [ + "content": rendered, + "message_reference": [ + "message_id": triggerMessageId, + "channel_id": triggerChannelId, + "fail_if_not_exists": false + ] + ] + _ = try? await sendMessage(channelId: triggerChannelId, payload: payload, token: token) + context.eventHandled = true + } else if let fallbackChannelId = modifierTargetChannelId ?? triggerChannelId, !fallbackChannelId.isEmpty { + try? await sendMessage(channelId: fallbackChannelId, content: rendered, token: token) + context.eventHandled = true + } else if !action.channelId.isEmpty { + try? await sendMessage(channelId: action.channelId, content: rendered, token: token) + context.eventHandled = true + } + case .sameChannel: + let targetChannelId = modifierTargetChannelId ?? triggerChannelId ?? event.channelId + guard !targetChannelId.isEmpty else { return } + try? await sendMessage(channelId: targetChannelId, content: rendered, token: token) + context.eventHandled = true + case .specificChannel: + let targetChannelId = modifierTargetChannelId ?? action.channelId + guard !targetChannelId.isEmpty else { return } try? await sendMessage(channelId: targetChannelId, content: rendered, token: token) + context.eventHandled = true } case .addLogEntry: return case .setStatus: - let statusText: String - if action.replyWithAI { - if let aiStatus = await generateRuleActionAIReply(prompt: action.statusText, event: event) { - statusText = aiStatus - } else { - statusText = action.statusText - } - } else { - statusText = action.statusText - } + let statusText = renderMessage(template: action.statusText, event: event, context: context) guard !statusText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } await updatePresence(text: statusText) + case .sendDM: + let rendered = renderMessage(template: action.dmContent, event: event, context: context) + _ = try? await sendDM(userId: event.userId, content: rendered) + context.eventHandled = true + case .addReaction: + guard let triggerMessageId = event.triggerMessageId, let triggerChannelId = event.triggerChannelId else { return } + _ = try? await addReaction(channelId: triggerChannelId, messageId: triggerMessageId, emoji: action.emoji, token: token) + context.eventHandled = true + case .deleteMessage: + guard let triggerMessageId = event.triggerMessageId, let triggerChannelId = event.triggerChannelId else { return } + if action.deleteDelaySeconds > 0 { + Task { + try? await Task.sleep(nanoseconds: UInt64(action.deleteDelaySeconds) * 1_000_000_000) + _ = try? await deleteMessage(channelId: triggerChannelId, messageId: triggerMessageId, token: token) + } + } else { + _ = try? await deleteMessage(channelId: triggerChannelId, messageId: triggerMessageId, token: token) + } + context.eventHandled = true + case .addRole: + _ = try? await addRole(guildId: event.guildId, userId: event.userId, roleId: action.roleId, token: token) + context.eventHandled = true + case .removeRole: + _ = try? await removeRole(guildId: event.guildId, userId: event.userId, roleId: action.roleId, token: token) + context.eventHandled = true + case .timeoutMember: + _ = try? await timeoutMember(guildId: event.guildId, userId: event.userId, durationSeconds: action.timeoutDuration, token: token) + context.eventHandled = true + case .kickMember: + _ = try? await kickMember(guildId: event.guildId, userId: event.userId, reason: action.kickReason, token: token) + context.eventHandled = true + case .moveMember: + _ = try? await moveMember(guildId: event.guildId, userId: event.userId, channelId: action.targetVoiceChannelId, token: token) + context.eventHandled = true + case .createChannel: + _ = try? await createChannel(guildId: event.guildId, name: action.newChannelName, token: token) + context.eventHandled = true + case .webhook: + _ = try? await sendWebhook(url: action.webhookURL, content: action.webhookContent) + case .delay: + try? await Task.sleep(nanoseconds: UInt64(action.delaySeconds) * 1_000_000_000) + case .setVariable, .randomChoice: + // TODO: Implement variables and random choice logic + discordLogger.debug("Action \(action.type.rawValue) not yet fully implemented") + return } } - private func renderMessage(template: String, event: VoiceRuleEvent, mentionUser: Bool) -> String { + private func renderMessage(template: String, event: VoiceRuleEvent, context: PipelineContext) -> String { let channelId = event.channelId let fromChannelId = event.fromChannelId ?? channelId let toChannelId = event.toChannelId ?? channelId @@ -2285,11 +2561,20 @@ actor DiscordService { .replacingOccurrences(of: "{fromChannelId}", with: fromChannelId) .replacingOccurrences(of: "{toChannelId}", with: toChannelId) .replacingOccurrences(of: "{duration}", with: formatDuration(seconds: event.durationSeconds)) + .replacingOccurrences(of: "{ai.response}", with: context.aiResponse ?? "") - if !mentionUser { + if !context.mentionUser { output = output.replacingOccurrences(of: "<@\(event.userId)>", with: event.username) } + if context.prependUserMention { + output = "<@\(event.userId)> " + output + } + + if let roleMention = context.mentionRole { + output = "<@&\(roleMention)> " + output + } + return output } diff --git a/SwiftBotApp/EmptyRuleOnboardingView.swift b/SwiftBotApp/EmptyRuleOnboardingView.swift new file mode 100644 index 0000000..7c04078 --- /dev/null +++ b/SwiftBotApp/EmptyRuleOnboardingView.swift @@ -0,0 +1,74 @@ +import SwiftUI + +// MARK: - Empty Rule Onboarding + +/// Ghost placeholder empty state shown when a rule has no blocks yet. +/// Follows Apple Human Interface Guidelines for empty states. +struct EmptyRuleOnboardingView: View { + let onAddTriggerTapped: () -> Void + + @State private var arrowPulse: Bool = false + + var body: some View { + VStack(spacing: 0) { + Spacer(minLength: 48) + + // Ghost placeholder card + VStack(spacing: 20) { + // SF Symbol: bolt.circle + Image(systemName: "bolt.circle") + .font(.system(size: 48)) + .foregroundStyle(.secondary.opacity(0.6)) + + VStack(spacing: 8) { + Text("No Trigger Selected") + .font(.title3.weight(.bold)) + .foregroundStyle(.primary) + + Text("Choose a trigger from the Block Library to begin building this rule.") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + // Primary action button + Button(action: onAddTriggerTapped) { + Label("Choose Trigger", systemImage: "plus") + .font(.body.weight(.medium)) + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + .tint(.accentColor.opacity(0.8)) + .padding(.top, 8) + } + .padding(.horizontal, 40) + .padding(.vertical, 40) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.ultraThinMaterial) + .strokeBorder(.white.opacity(0.1), lineWidth: 0.5) + ) + .shadow(color: .black.opacity(0.1), radius: 20, x: 0, y: 10) + .frame(maxWidth: 380) + + Spacer(minLength: 32) + + // Directional hint with subtle pulse toward Block Library + HStack(spacing: 6) { + Image(systemName: "arrow.left") + .font(.caption.weight(.semibold)) + .opacity(arrowPulse ? 1.0 : 0.4) + .animation( + .easeInOut(duration: 1.2).repeatForever(autoreverses: true), + value: arrowPulse + ) + Text("Select a trigger from the Block Library") + .font(.caption) + } + .foregroundStyle(.secondary.opacity(0.8)) + .padding(.bottom, 32) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { arrowPulse = true } + } +} diff --git a/SwiftBotApp/Models.swift b/SwiftBotApp/Models.swift index ca61941..849562f 100644 --- a/SwiftBotApp/Models.swift +++ b/SwiftBotApp/Models.swift @@ -2382,6 +2382,7 @@ struct VoiceRuleEvent { case move case message case memberJoin + case memberLeave } let kind: Kind @@ -2399,6 +2400,8 @@ struct VoiceRuleEvent { let triggerGuildId: String let triggerUserId: String let isDirectMessage: Bool + let authorIsBot: Bool? + let joinedAt: Date? } @MainActor @@ -2406,29 +2409,25 @@ 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? init() { Task { + isLoading = true let loaded = await store.load() - if let loaded, !loaded.isEmpty { - rules = loaded - } else { - rules = [Rule(name: "Join Action")] - } + rules = loaded ?? [] selectedRuleID = nil + isLoading = false } } func addNewRule(serverId: String = "", channelId: String = "") { - var action = RuleAction() - action.serverId = serverId - action.channelId = channelId - var rule = Rule(name: "New Action") + var rule = Rule.empty() rule.triggerServerId = serverId - rule.actions = [action] + // New rules start empty - users add blocks via Block Library rules.append(rule) selectedRuleID = rule.id scheduleAutoSave() @@ -2465,12 +2464,14 @@ final class RuleStore: ObservableObject { } func reloadFromDisk() async { - guard let loaded = await store.load() else { return } - rules = loaded.isEmpty ? [Rule(name: "Join Action")] : loaded + 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() { @@ -2510,39 +2511,60 @@ final class RuleStore: ObservableObject { } } +/// 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)]" + } +} + @MainActor final class RuleEngine { private var cancellable: AnyCancellable? private var activeRules: [Rule] = [] init(store: RuleStore) { + activeRules = store.rules.filter(\.isEnabled) cancellable = store.$rules.sink { [weak self] rules in self?.activeRules = rules.filter(\.isEnabled) } } - func evaluate(event: VoiceRuleEvent) -> [Action] { + func evaluateRules(event: VoiceRuleEvent) -> [Rule] { activeRules .filter { rule in matchesTrigger(rule: rule, event: event) && matchesConditions(rule: rule, event: event) } - .flatMap(\.actions) } private func matchesTrigger(rule: Rule, event: VoiceRuleEvent) -> Bool { - switch (rule.trigger, event.kind) { - case (.userJoinedVoice, .join): - return true - case (.userLeftVoice, .leave): - return true - case (.userMovedVoice, .move): - return true - case (.messageContains, .message): - let needle = rule.triggerMessageContains.trimmingCharacters(in: .whitespacesAndNewlines) - guard !needle.isEmpty, let content = event.messageContent else { return false } - if event.isDirectMessage { - return rule.replyToDMs && content.localizedCaseInsensitiveContains(needle) - } - return content.localizedCaseInsensitiveContains(needle) - case (.memberJoined, .memberJoin): + guard let trigger = rule.trigger else { return false } + switch (trigger, event.kind) { + case (.userJoinedVoice, .join), + (.userLeftVoice, .leave), + (.userMovedVoice, .move), + (.messageCreated, .message), + (.memberJoined, .memberJoin): return true default: return false @@ -2550,7 +2572,7 @@ final class RuleEngine { } private func matchesConditions(rule: Rule, event: VoiceRuleEvent) -> Bool { - for condition in rule.conditions where condition.enabled { + for condition in rule.conditions { if !matches(condition: condition, event: event) { return false } } return true @@ -2562,18 +2584,55 @@ final class RuleEngine { case .server: return value.isEmpty || event.guildId == value case .voiceChannel: - // Voice channel conditions don't apply to member join events — always pass. - if event.kind == .memberJoin { return true } + // 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 { return true } + 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 } } } @@ -2792,7 +2851,7 @@ enum SidebarItem: String, CaseIterable, Identifiable { switch self { case .overview: return "square.grid.2x2.fill" case .patchy: return "hammer.fill" - case .voice: return "point.3.filled.connected.trianglepath.dotted" + case .voice: return "bolt.circle" case .commands: return "terminal.fill" case .commandLog: return "list.bullet.clipboard.fill" case .wikiBridge: return "book.pages.fill" @@ -2806,22 +2865,284 @@ enum SidebarItem: String, CaseIterable, Identifiable { // 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}" + + 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" + } + } + + 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" + } + } +} + +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 = "User Joins Voice" - case userLeftVoice = "User Leaves Voice" - case userMovedVoice = "User Moves Voice" - case messageContains = "Message Contains" + 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" 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 .messageContains: return "text.bubble" + 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" } } @@ -2830,8 +3151,11 @@ enum TriggerType: String, CaseIterable, Identifiable, Codable { 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 .messageContains: return "nm you?" + 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!" } } @@ -2840,8 +3164,27 @@ enum TriggerType: String, CaseIterable, Identifiable, Codable { case .userJoinedVoice: return "Join Action" case .userLeftVoice: return "Leave Action" case .userMovedVoice: return "Move Action" - case .messageContains: return "Message Reply" + 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" + } + } + + /// 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] } } @@ -2860,6 +3203,17 @@ enum ConditionType: String, CaseIterable, Identifiable, Codable { 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 } @@ -2869,6 +3223,41 @@ enum ConditionType: String, CaseIterable, Identifiable, Codable { 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] } } } @@ -2877,6 +3266,34 @@ 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 } @@ -2885,16 +3302,154 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { 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 = "" - var enabled: Bool = true } struct RuleAction: Identifiable, Codable, Equatable { @@ -2907,6 +3462,33 @@ struct RuleAction: Identifiable, Codable, Equatable { 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 @@ -2918,6 +3500,26 @@ struct RuleAction: Identifiable, Codable, Equatable { 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() {} @@ -2933,19 +3535,151 @@ struct RuleAction: Identifiable, Codable, Equatable { 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(replyToTriggerMessage, forKey: .replyToTriggerMessage) - try container.encode(replyWithAI, forKey: .replyWithAI) + 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, .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 } } @@ -2954,26 +3688,381 @@ typealias Action = RuleAction struct Rule: Identifiable, Codable, Equatable { var id: UUID = UUID() var name: String = "New Action" - var trigger: TriggerType = .userJoinedVoice + var trigger: TriggerType? var conditions: [Condition] = [] - var actions: [RuleAction] = [RuleAction()] + 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 = "up to?" + 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 .messageContains: - return triggerMessageContains.isEmpty ? "When message contains text" : "When message contains \"\(triggerMessageContains)\"" + 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" + } + } + + /// 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/OverviewView.swift b/SwiftBotApp/OverviewView.swift index b711475..1157d78 100644 --- a/SwiftBotApp/OverviewView.swift +++ b/SwiftBotApp/OverviewView.swift @@ -164,7 +164,7 @@ struct OverviewView: View { title: "Actions", value: "\(enabledActionRuleCount) active", subtitle: "\(actionRuleCount) total rules", - symbol: "point.3.filled.connected.trianglepath.dotted", + symbol: "bolt.circle", detail: helpSummary, color: .red ) @@ -231,7 +231,7 @@ struct OverviewView: View { title: "Actions", value: "\(enabledActionRuleCount) active", subtitle: "\(actionRuleCount) total rules", - symbol: "point.3.filled.connected.trianglepath.dotted", + symbol: "bolt.circle", detail: "Errors \(app.stats.errors)", color: .indigo ), diff --git a/SwiftBotApp/Resources/admin/index.html b/SwiftBotApp/Resources/admin/index.html index 6114da8..b1f0031 100644 --- a/SwiftBotApp/Resources/admin/index.html +++ b/SwiftBotApp/Resources/admin/index.html @@ -1962,6 +1962,10 @@

Recent Activity

panelsOrder: ['cluster', 'botinfo', 'voice', 'commands', 'invoice', 'controls'], panelsHidden: [] }; + // Server-driven metadata from /api/actions - provides all block types, fields, and variables + let builderMetadata = null; + + // Legacy metadata fallbacks (deprecated - use builderMetadata instead) const actionConditionMeta = { 'Server Is': { icon: 'dns', desc: 'Match events from one specific server.' }, 'Voice Channel Is': { icon: 'headset_mic', desc: 'Run only when users join a selected channel.' }, @@ -1974,6 +1978,62 @@

Recent Activity

'Set Bot Status': { icon: 'bolt', desc: 'Change the bot presence status text.' } }; + // Get Material icon for SF Symbol name (best-effort mapping) + function sfSymbolToMaterial(sfName) { + const map = { + 'paperplane.fill': 'send', + 'list.bullet.clipboard': 'list_alt', + 'dot.radiowaves.left.and.right': 'wifi_tethering', + 'envelope.fill': 'mail', + 'trash.fill': 'delete', + 'face.smiling': 'sentiment_satisfied', + 'person.crop.circle.badge.plus': 'person_add', + 'person.crop.circle.badge.minus': 'person_remove', + 'clock.badge.exclamationmark': 'timer', + 'door.left.hand.open': 'logout', + 'arrow.right.circle': 'arrow_forward', + 'plus.rectangle': 'add_box', + 'link': 'link', + 'clock.arrow.circlepath': 'schedule', + 'character.textbox': 'text_fields', + 'shuffle': 'shuffle', + 'arrowshape.turn.up.left.fill': 'reply', + 'at': 'alternate_email', + 'at.badge.plus': 'group_add', + 'at.badge.minus': 'group_remove', + 'number.circle.fill': 'pin', + 'sparkles': 'auto_awesome', + 'text.alignleft': 'format_align_left', + 'tag.fill': 'label', + 'pencil': 'edit', + 'person.crop.circle.badge.xmark': 'person_off', + 'arrow.left.arrow.right.circle': 'swap_horiz', + 'text.bubble': 'chat', + 'person.badge.plus': 'person_add', + 'person.badge.minus': 'person_remove', + 'slash.circle': 'block', + 'building.2': 'apartment', + 'waveform': 'graphic_eq', + 'text.magnifyingglass': 'search', + 'number': 'numbers', + 'folder': 'folder', + 'person.crop.circle.badge.checkmark': 'verified_user', + 'clock.arrow.circlepath': 'history', + 'text.quote': 'format_quote', + 'text.alignleft': 'format_align_left', + 'asterisk.circle': 'emergency', + 'envelope.badge.shield.half.filled': 'security', + 'bot': 'smart_toy', + 'person': 'person', + 'number.square': 'looks_one', + 'bolt.fill': 'bolt', + 'line.3.horizontal.decrease.circle': 'filter_alt', + 'text.bubble.fill': 'chat_bubble', + 'shield.fill': 'security' + }; + return map[sfName] || 'widgets'; + } + function defaultDiscordAvatar(userID) { try { const mod = Number(BigInt(userID) % 6n); @@ -2544,6 +2604,8 @@

${metric.title}

if (!response.ok) throw new Error('Actions unavailable'); const data = await response.json(); actionsData = data; + // Store server-driven metadata for 5-stage pipeline rendering + builderMetadata = data.builderMetadata || null; if (!selectedActionRuleID && data.rules && data.rules.length > 0) { selectedActionRuleID = data.rules[0].id; } @@ -2685,43 +2747,116 @@

${metric.title}

document.getElementById('actionAddRule')?.addEventListener('click', async () => { await createActionRule(); }); + // New: Block library uses server-driven metadata for 5-stage pipeline document.getElementById('actionLibrary')?.addEventListener('click', async (event) => { - const conditionButton = event.target.closest('[data-add-condition]'); - const actionButton = event.target.closest('[data-add-action]'); - if (!conditionButton && !actionButton) return; + const blockButton = event.target.closest('[data-add-block]'); + if (!blockButton) return; const rule = findSelectedRule(); if (!rule) return; + + const blockId = blockButton.dataset.addBlock; + const blockType = blockButton.dataset.blockType; // 'ai', 'modifier', 'action' + const category = blockButton.dataset.blockCategory; + const next = structuredClone(rule); - if (conditionButton) { - next.conditions = next.conditions || []; - const firstType = actionsData?.conditionTypes?.[0] || 'Server Is'; - next.conditions.push({ - id: uuid(), - type: conditionButton.dataset.addCondition || firstType, - value: '', - secondaryValue: '', - enabled: true - }); - } else if (actionButton) { + const newBlock = createBlockFromMetadata(blockId, category); + + // Route to correct pipeline stage array + if (blockType === 'ai' || category === 'ai') { + next.aiBlocks = next.aiBlocks || []; + next.aiBlocks.push(newBlock); + } else if (blockType === 'modifier' || category === 'messaging') { + next.modifiers = next.modifiers || []; + next.modifiers.push(newBlock); + } else { next.actions = next.actions || []; - const firstType = actionsData?.actionTypes?.[0] || 'Send Message'; - next.actions.push({ - id: uuid(), - type: actionButton.dataset.addAction || firstType, - serverId: (actionsData?.servers?.[0]?.id || ''), - channelId: '', - mentionUser: true, - replyToTriggerMessage: false, - replyWithAI: false, - message: '🔊 <@{userId}> connected to <#{channelId}>', - statusText: 'Voice notifier active' - }); + next.actions.push(newBlock); } scheduleActionRuleSave(next); }); actionsStaticBound = true; } + function defaultDestinationModeForTrigger(trigger) { + switch (trigger) { + case 'Message Created': + case 'Reaction Added': + return 'replyToTrigger'; + case 'Slash Command': + return 'sameChannel'; + default: + return 'specificChannel'; + } + } + + // Create a new block instance from metadata defaults + function createBlockFromMetadata(blockId, category) { + // Find block metadata + let blockMeta = null; + if (builderMetadata) { + // Search in conditions first (for filter blocks) + blockMeta = (builderMetadata.conditions || []).find(c => c.id === blockId); + + // Then search in categories/blocks + if (!blockMeta) { + for (const cat of builderMetadata.categories || []) { + if (cat.blockIds?.includes(blockId)) { + blockMeta = (builderMetadata.blocks || []).find(b => b.id === blockId); + break; + } + } + } + } + + // For condition/filter blocks, return condition structure + if (category === 'filters') { + return { + id: uuid(), + type: blockId, + value: '', + secondaryValue: '', + enabled: true + }; + } + + const base = { + id: uuid(), + type: blockId, + serverId: '', + channelId: '', + contentSource: 'custom', + destinationMode: blockId === 'Send Message' ? 'replyToTrigger' : undefined, + mentionUser: true, + replyToTriggerMessage: false, // Legacy field - migrated to modifier block + message: '', + statusText: '', + // AI block fields + categories: '', + entityTypes: '', + rewriteStyle: '' + }; + + // Set default message based on block type + if (blockId === 'Send Message') { + base.message = '🔊 <@{userId}> triggered action'; + } else if (blockId === 'Generate AI Response') { + base.message = 'Generate an AI response to: {message}'; + } else if (blockId === 'Summarise Message') { + base.message = 'Summarize this: {message}'; + } else if (blockId === 'Classify Message') { + base.categories = 'question,feedback,bug,other'; + base.message = 'Classify this message into one of: {categories}'; + } else if (blockId === 'Extract Entities') { + base.entityTypes = 'person,organization,date'; + base.message = 'Extract entities from: {message}'; + } else if (blockId === 'Rewrite Message') { + base.rewriteStyle = 'professional'; + base.message = 'Rewrite this in a {style} tone: {message}'; + } + + return base; + } + function renderActionLibrary() { const node = document.getElementById('actionLibrary'); if (!node) return; @@ -2729,6 +2864,14 @@

${metric.title}

node.innerHTML = ''; return; } + + // Use server-driven metadata if available (5-stage pipeline) + if (builderMetadata && builderMetadata.categories) { + renderPipelineBlockLibrary(node); + return; + } + + // Legacy fallback const conditionTypes = actionsData.conditionTypes || []; const actionTypes = actionsData.actionTypes || []; const conditionRows = conditionTypes.map(type => { @@ -2759,6 +2902,67 @@

${metric.title}

${actionRows || '
No action types available.
'} `; } + + // Render block library using server-driven metadata (5-stage pipeline) + function renderPipelineBlockLibrary(node) { + const blocks = builderMetadata.blocks || []; + const conditions = builderMetadata.conditions || []; + const categories = (builderMetadata.categories || []).sort((a, b) => a.order - b.order); + + // Build Filters section first (from conditions) + let filtersSection = ''; + if (conditions.length > 0) { + const conditionRows = conditions.map(cond => { + const icon = sfSymbolToMaterial(cond.symbol); + const desc = cond.description || `${cond.name} filter`; + return ` +
+ ${icon} +
${cond.name}
${desc}
+ +
+ `; + }).join(''); + filtersSection = ` + + ${conditionRows} + `; + } + + // Build category sections (AI, Messaging, Actions, Moderation) + const categorySections = categories.map(cat => { + const catBlocks = cat.blockIds?.map(id => blocks.find(b => b.id === id)).filter(Boolean) || []; + if (catBlocks.length === 0) return ''; + + const blockRows = catBlocks.map(block => { + const icon = sfSymbolToMaterial(block.symbol); + const desc = block.description || `${block.name} block`; + // Determine block type based on category + let blockType = 'action'; + if (cat.id === 'ai') blockType = 'ai'; + else if (cat.id === 'messaging') blockType = 'modifier'; + + return ` +
+ ${icon} +
${block.name}
${desc}
+ +
+ `; + }).join(''); + + const catIcon = sfSymbolToMaterial(cat.symbol); + return ` + + ${blockRows} + `; + }).filter(Boolean); + + // Combine: Filters first, then categories + const allSections = [filtersSection, ...categorySections].filter(Boolean).join('
'); + + node.innerHTML = allSections || '
No blocks available.
'; + } function renderActionsView() { bindActionsStaticButtons(); @@ -2809,6 +3013,321 @@

${metric.title}

return; } + // Use 5-stage pipeline renderer if we have server metadata + if (builderMetadata) { + renderPipelineEditor(editorNode, selected); + return; + } + + // Legacy fallback for old API responses + renderLegacyEditor(editorNode, selected); + } + + // Render the new 5-stage pipeline editor + function renderPipelineEditor(editorNode, selected) { + const triggers = builderMetadata.triggers || []; + const conditions = builderMetadata.conditions || []; + const triggerOptions = triggers.map(t => + `` + ).join(''); + + // Get context variables available for this trigger + const selectedTrigger = triggers.find(t => t.id === selected.trigger); + const availableVars = selectedTrigger?.providedVariables || []; + + editorNode.innerHTML = ` +
tune Rule Builder (5-Stage Pipeline)
+ + +
+
bolt 1. Trigger - When this rule runs
+
+
+
Rule Name
+ +
+
+
Trigger Type
+ +
+
+ ${selectedTrigger ? ` +
+ Available variables: ${availableVars.map(v => `{${v}}`).join(', ')} +
+ ` : ''} +
+ + +
+
filter_alt 2. Filters - Additional matching criteria
+ ${renderBlocksList(selected.conditions || [], 'condition', selected)} + ${(selected.conditions || []).length === 0 ? '
No filters. Add from Block Library to restrict when this rule runs.
' : ''} +
+ + +
+
auto_awesome 3. AI Processing - Generate AI content (runs before actions)
+ ${renderBlocksList(selected.aiBlocks || [], 'aiBlock', selected)} + ${(selected.aiBlocks || []).length === 0 ? '
No AI blocks. Add from Block Library to populate {ai.response}, {ai.summary}, etc.
' : ''} +
+ + +
+
text_fields 4. Message Modifiers - How messages are sent
+ ${renderBlocksList(selected.modifiers || [], 'modifier', selected)} + ${(selected.modifiers || []).length === 0 ? '
No modifiers. Messages will use default settings.
' : ''} +
+ + +
+
send 5. Actions - What the rule does
+ ${renderBlocksList(selected.actions || [], 'action', selected)} + ${(selected.actions || []).length === 0 ? '
No actions. Add from Block Library to produce output.
' : ''} +
+ `; + + bindPipelineEditorEvents(editorNode, selected); + } + + // Render a list of blocks with their field editors + function renderBlocksList(blocks, blockType, rule) { + if (!blocks || blocks.length === 0) return ''; + + const allBlockMeta = [ + ...(builderMetadata.conditions || []), + ...(builderMetadata.blocks || []) + ]; + + return blocks.map((block, idx) => { + const meta = allBlockMeta.find(m => m.id === block.type) || { + name: block.type, + symbol: 'widgets', + fields: [], + description: '' + }; + const icon = sfSymbolToMaterial(meta.symbol); + const fields = renderBlockFields(block, meta, blockType, idx, rule); + + return ` +
+
+ ${icon} + ${meta.name} + +
+ ${fields} +
+ `; + }).join(''); + } + + // Render fields for a specific block based on metadata + function renderBlockFields(block, meta, blockType, idx, rule) { + const fields = meta.fields || []; + if (fields.length === 0) { + return `
${meta.description || 'No configuration needed'}
`; + } + + const effectiveDestinationMode = block.destinationMode || defaultDestinationModeForTrigger(rule?.trigger); + const serverID = block.serverId || actionsData.servers?.[0]?.id || ''; + + return fields.map(field => { + const value = block[field.id] ?? field.defaultValue ?? ''; + const placeholder = field.placeholder || ''; + const effectiveContentSource = block.contentSource || 'custom'; + const isMessageField = field.id === 'message'; + const isSpecificChannelField = field.id === 'serverId' || field.id === 'channelId'; + const messageConditionalAttr = isMessageField ? ' data-show-if-content="custom"' : ''; + const messageHiddenStyle = isMessageField && effectiveContentSource !== 'custom' ? ' style="display:none;"' : ''; + const destinationConditionalAttr = isSpecificChannelField ? ' data-show-if-destination="specificChannel"' : ''; + const destinationHiddenStyle = isSpecificChannelField && effectiveDestinationMode !== 'specificChannel' ? ' style="display:none;"' : ''; + + // Render based on field type + switch (field.type) { + case 'text': + return ` +
+
${field.name}
+ +
+ `; + case 'multiline': + return ` +
+
${field.name}
+ +
Use {variable} syntax for context variables
+
+ `; + case 'number': + return ` +
+
${field.name}
+ +
+ `; + case 'boolean': + return ` +
+ + ${field.name} +
+ `; + case 'picker': + case 'searchablePicker': + let options = ''; + if (field.optionsSource === 'servers') { + options = asOptions(actionsData.servers, value); + } else if (field.optionsSource === 'textChannels') { + options = asOptions(actionsData.textChannelsByServer?.[serverID], value); + } else if (field.optionsSource === 'voiceChannels') { + options = asOptions(actionsData.voiceChannelsByServer?.[serverID], value); + } else if (field.optionsSource === 'roles') { + options = ''; // Roles require server context + } else if (field.optionsSource === 'predefined' && field.id === 'contentSource') { + // Content source predefined options + const sources = [ + { id: 'custom', name: 'Custom Message' }, + { id: 'ai.response', name: 'AI Response' }, + { id: 'ai.summary', name: 'AI Summary' }, + { id: 'ai.classification', name: 'AI Classification' }, + { id: 'ai.entities', name: 'AI Entities' }, + { id: 'ai.rewrite', name: 'AI Rewrite' } + ]; + options = sources.map(s => ``).join(''); + } else if (field.optionsSource === 'predefined' && field.id === 'destinationMode') { + const destinations = [ + { id: 'replyToTrigger', name: 'Reply to Trigger' }, + { id: 'sameChannel', name: 'Same Channel' }, + { id: 'specificChannel', name: 'Specific Channel' } + ]; + options = destinations.map(d => ``).join(''); + } else { + options = ``; + } + return ` +
+
${field.name}
+ +
+ `; + default: + return ` +
+
${field.name}
+ +
+ `; + } + }).join(''); + } + + // Bind events for the 5-stage pipeline editor + function bindPipelineEditorEvents(editorNode, selected) { + const commit = (mutator) => { + const next = structuredClone(selected); + mutator(next); + scheduleActionRuleSave(next); + }; + + // Rule name + editorNode.querySelector('#ruleName')?.addEventListener('input', (e) => commit(r => { r.name = e.target.value; })); + + // Trigger change + editorNode.querySelector('#ruleTrigger')?.addEventListener('change', (e) => commit(r => { + r.trigger = e.target.value || null; + })); + + // Block field changes + editorNode.querySelectorAll('[data-block-type]').forEach(blockEl => { + const blockType = blockEl.dataset.blockType; + const idx = Number(blockEl.dataset.blockIndex); + const updateConditionalFields = () => { + const contentSource = blockEl.querySelector('[data-field="contentSource"]')?.value || 'custom'; + const destinationMode = blockEl.querySelector('[data-field="destinationMode"]')?.value + || defaultDestinationModeForTrigger(selected.trigger); + + blockEl.querySelectorAll('[data-show-if-content]').forEach(el => { + el.style.display = contentSource === 'custom' ? '' : 'none'; + }); + blockEl.querySelectorAll('[data-show-if-destination]').forEach(el => { + el.style.display = destinationMode === 'specificChannel' ? '' : 'none'; + }); + }; + + // Delete block + blockEl.querySelector('[data-delete-block]')?.addEventListener('click', () => { + commit(r => { + if (blockType === 'condition') r.conditions.splice(idx, 1); + else if (blockType === 'aiBlock') r.aiBlocks.splice(idx, 1); + else if (blockType === 'modifier') r.modifiers.splice(idx, 1); + else if (blockType === 'action') r.actions.splice(idx, 1); + }); + }); + + // Field changes + blockEl.querySelectorAll('.block-field').forEach(fieldEl => { + const fieldId = fieldEl.dataset.field; + const isCheckbox = fieldEl.type === 'checkbox'; + + const handler = (e) => { + const value = isCheckbox ? e.target.checked : e.target.value; + commit(r => { + let block; + if (blockType === 'condition') block = r.conditions[idx]; + else if (blockType === 'aiBlock') block = r.aiBlocks[idx]; + else if (blockType === 'modifier') block = r.modifiers[idx]; + else if (blockType === 'action') block = r.actions[idx]; + + if (block) { + block[fieldId] = value; + + if (fieldId === 'destinationMode' && value === 'specificChannel') { + block.serverId = block.serverId || actionsData.servers?.[0]?.id || ''; + const channels = actionsData.textChannelsByServer?.[block.serverId] || []; + if (!channels.some(ch => ch.id === block.channelId)) { + block.channelId = channels[0]?.id || ''; + } + } + + if (fieldId === 'serverId') { + const channels = actionsData.textChannelsByServer?.[block.serverId] || []; + if (!channels.some(ch => ch.id === block.channelId)) { + block.channelId = channels[0]?.id || ''; + } + } + } + }); + }; + + if (isCheckbox) { + fieldEl.addEventListener('change', handler); + } else if (fieldEl.tagName === 'SELECT') { + fieldEl.addEventListener('change', handler); + } else { + fieldEl.addEventListener('input', handler); + } + + if (fieldId === 'contentSource' || fieldId === 'destinationMode') { + fieldEl.addEventListener('change', (e) => { + updateConditionalFields(); + }); + } + }); + + updateConditionalFields(); + }); + } + + // Legacy editor renderer for backwards compatibility + function renderLegacyEditor(editorNode, selected) { const serverID = selected.triggerServerId || actionsData.servers?.[0]?.id || ''; const voiceOptions = asOptions(actionsData.voiceChannelsByServer?.[serverID], selected.triggerVoiceChannelId || ''); const textOptionsFor = (sid, cid) => asOptions(actionsData.textChannelsByServer?.[sid], cid || ''); @@ -2818,7 +3337,7 @@

${metric.title}

const actionTypeOptions = (actionsData.actionTypes || []).length ? actionsData.actionTypes : ['Send Message']; editorNode.innerHTML = ` -
tune Action Builder
+
tune Action Builder (Legacy)
@@ -2829,26 +3348,6 @@

${metric.title}

Trigger
-
-
Trigger Server
- -
-
-
Voice Channel
- -
-
-
Message Contains
- -
-
- - Reply to DMs -
-
- - Include Stage Channels -
@@ -2865,10 +3364,6 @@

${metric.title}

Value
-
- - Enabled -
@@ -2886,34 +3381,10 @@

${metric.title}

Type
-
-
Server
- -
-
-
Channel
- -
Message
-
-
Status Text
- -
-
- - Mention User -
-
- - Reply to Trigger -
-
- - Reply with AI -
@@ -2931,30 +3402,18 @@

${metric.title}

editorNode.querySelector('#ruleName')?.addEventListener('input', (e) => commit(r => { r.name = e.target.value; })); editorNode.querySelector('#ruleTrigger')?.addEventListener('change', (e) => commit(r => { r.trigger = e.target.value; })); - editorNode.querySelector('#ruleTriggerServer')?.addEventListener('change', (e) => commit(r => { r.triggerServerId = e.target.value; })); - editorNode.querySelector('#ruleTriggerVoiceChannel')?.addEventListener('change', (e) => commit(r => { r.triggerVoiceChannelId = e.target.value; })); - editorNode.querySelector('#ruleTriggerText')?.addEventListener('input', (e) => commit(r => { r.triggerMessageContains = e.target.value; })); - editorNode.querySelector('#ruleReplyToDMs')?.addEventListener('change', (e) => commit(r => { r.replyToDMs = e.target.checked; })); - editorNode.querySelector('#ruleIncludeStage')?.addEventListener('change', (e) => commit(r => { r.includeStageChannels = e.target.checked; })); editorNode.querySelectorAll('[data-condition-index]').forEach(block => { const idx = Number(block.dataset.conditionIndex); block.querySelector('.cond-type')?.addEventListener('change', (e) => commit(r => { r.conditions[idx].type = e.target.value; })); block.querySelector('.cond-value')?.addEventListener('input', (e) => commit(r => { r.conditions[idx].value = e.target.value; })); - block.querySelector('.cond-enabled')?.addEventListener('change', (e) => commit(r => { r.conditions[idx].enabled = e.target.checked; })); block.querySelector('.cond-delete')?.addEventListener('click', () => commit(r => { r.conditions.splice(idx, 1); })); }); editorNode.querySelectorAll('[data-action-index]').forEach(block => { const idx = Number(block.dataset.actionIndex); block.querySelector('.act-type')?.addEventListener('change', (e) => commit(r => { r.actions[idx].type = e.target.value; })); - block.querySelector('.act-server')?.addEventListener('change', (e) => commit(r => { r.actions[idx].serverId = e.target.value; })); - block.querySelector('.act-channel')?.addEventListener('change', (e) => commit(r => { r.actions[idx].channelId = e.target.value; })); block.querySelector('.act-message')?.addEventListener('input', (e) => commit(r => { r.actions[idx].message = e.target.value; })); - block.querySelector('.act-status')?.addEventListener('input', (e) => commit(r => { r.actions[idx].statusText = e.target.value; })); - block.querySelector('.act-mention')?.addEventListener('change', (e) => commit(r => { r.actions[idx].mentionUser = e.target.checked; })); - block.querySelector('.act-reply')?.addEventListener('change', (e) => commit(r => { r.actions[idx].replyToTriggerMessage = e.target.checked; })); - block.querySelector('.act-ai')?.addEventListener('change', (e) => commit(r => { r.actions[idx].replyWithAI = e.target.checked; })); block.querySelector('.act-delete')?.addEventListener('click', () => commit(r => { r.actions.splice(idx, 1); })); }); } diff --git a/SwiftBotApp/VoiceActionsView.swift b/SwiftBotApp/VoiceActionsView.swift index 7919a64..6694b3b 100644 --- a/SwiftBotApp/VoiceActionsView.swift +++ b/SwiftBotApp/VoiceActionsView.swift @@ -39,7 +39,8 @@ struct VoiceWorkspaceView: View { }, onDeleteRuleID: { ruleID in ruleStore.deleteRule(id: ruleID, undoManager: undoManager) - } + }, + isLoading: ruleStore.isLoading ) .frame(minWidth: 220, idealWidth: 260, maxWidth: 320) @@ -129,6 +130,10 @@ struct RuleEditorView: View { @Binding var rule: Rule @EnvironmentObject var app: AppModel + @State private var hasSeenRuleOnboarding: Bool = false + @State private var guidedStep: GuidedBuildStep = .none + @State private var scrollToTriggersSignal: Bool = false + private var serverIds: [String] { app.connectedServers.keys.sorted { (app.connectedServers[$0] ?? $0).localizedCaseInsensitiveCompare(app.connectedServers[$1] ?? $1) == .orderedAscending @@ -139,6 +144,143 @@ struct RuleEditorView: View { app.connectedServers[serverId] ?? "Server \(serverId.suffix(4))" } + private var ruleCanvasContent: some View { + VStack(alignment: .leading, spacing: 18) { + if rule.trigger == nil && !hasSeenRuleOnboarding { + EmptyRuleStateView( + icon: "bolt.circle", + title: "Choose a Trigger", + description: "Select a trigger from the Block Library to begin building this rule.", + onShowMe: { + scrollToTriggersSignal = true + guidedStep = .trigger + }, + onContinue: { + hasSeenRuleOnboarding = true + } + ) + .padding(.top, 40) + .transition( + .asymmetric( + insertion: .opacity.combined(with: .scale(scale: 0.96)), + removal: .opacity.combined(with: .scale(scale: 0.96)) + ) + ) + } else { + RuleCanvasSection(title: "Trigger", systemImage: "bolt.fill", accent: .yellow, + guidedHighlight: guidedStep == .trigger) { + TriggerSectionView( + triggerType: rule.trigger + ) + if guidedStep == .trigger { + Label("Select a trigger from the Block Library to begin.", systemImage: "arrow.left") + .font(.caption) + .foregroundStyle(.yellow.opacity(0.8)) + .padding(.top, 4) + } + // Trigger can be replaced but not deleted + Button { + rule.isEditingTrigger = true + scrollToTriggersSignal = true + guidedStep = .trigger + } label: { + Label("Change Trigger", systemImage: "arrow.triangle.2.circlepath") + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 4) + .padding(.vertical, 2) + } + .buttonStyle(.bordered) + .tint(.yellow) + .padding(.top, 4) + } + + Divider().opacity(0.4) + + // Filters Section + RuleCanvasSection(title: "Filters", systemImage: "line.3.horizontal.decrease.circle", accent: .blue) { + ConditionsSectionView( + conditions: $rule.conditions, + hasTrigger: rule.trigger != nil, + serverIds: serverIds, + serverName: serverName(for:), + voiceChannels: app.availableVoiceChannelsByServer.values.flatMap { $0 }, + textChannels: app.availableTextChannelsByServer.values.flatMap { $0 }, + roles: app.availableRolesByServer.values.flatMap { $0 }, + incompatibleBlocks: rule.incompatibleBlocks, + availableVariables: rule.trigger?.providedVariables ?? [] + ) + } + + Divider().opacity(0.4) + + // AI Processing Section + RuleCanvasSection(title: "AI Processing", systemImage: "sparkles", accent: .purple) { + ActionsSectionView( + actions: $rule.aiBlocks, + category: .ai, + allModifiers: rule.modifiers, + currentTrigger: rule.trigger, + hasTrigger: rule.trigger != nil, + serverIds: serverIds, + serverName: serverName(for:), + textChannelsByServer: app.availableTextChannelsByServer, + voiceChannelsByServer: app.availableVoiceChannelsByServer, + rolesByServer: app.availableRolesByServer, + knownUsers: app.knownUsersById, + incompatibleBlocks: rule.incompatibleBlocks, + availableVariables: rule.trigger?.providedVariables ?? [] + ) + } + + Divider().opacity(0.4) + + RuleCanvasSection(title: "Message Modifiers", systemImage: "slider.horizontal.3", accent: .orange) { + ActionsSectionView( + actions: $rule.modifiers, + category: .messaging, + allModifiers: rule.modifiers, + currentTrigger: rule.trigger, + hasTrigger: rule.trigger != nil, + serverIds: serverIds, + serverName: serverName(for:), + textChannelsByServer: app.availableTextChannelsByServer, + voiceChannelsByServer: app.availableVoiceChannelsByServer, + rolesByServer: app.availableRolesByServer, + knownUsers: app.knownUsersById, + incompatibleBlocks: rule.incompatibleBlocks, + availableVariables: rule.trigger?.providedVariables ?? [] + ) + } + + RuleFlowArrow() + + RuleCanvasSection(title: "Actions", systemImage: "paperplane.fill", accent: .mint, + guidedHighlight: guidedStep == .action) { + ActionsSectionView( + actions: $rule.actions, + category: .actions, + allModifiers: rule.modifiers, + currentTrigger: rule.trigger, + hasTrigger: rule.trigger != nil, + serverIds: serverIds, + serverName: serverName(for:), + textChannelsByServer: app.availableTextChannelsByServer, + voiceChannelsByServer: app.availableVoiceChannelsByServer, + rolesByServer: app.availableRolesByServer, + knownUsers: app.knownUsersById, + isGuided: guidedStep == .action, + incompatibleBlocks: rule.incompatibleBlocks, + availableVariables: rule.trigger?.providedVariables ?? [] + ) + } + } + } + .animation(.easeInOut(duration: 0.22), value: rule.isEmptyRule) + .frame(maxWidth: 880, alignment: .leading) + .padding(.horizontal, 20) + .padding(.vertical, 20) + } + var body: some View { HStack(alignment: .top, spacing: 0) { VStack(spacing: 0) { @@ -148,17 +290,26 @@ struct RuleEditorView: View { systemImage: "square.stack.3d.up.fill" ) - ScrollView { - RuleBuilderLibraryView( + RuleBuilderLibraryView( serverIds: serverIds, onAddCondition: addCondition(_:), onAddAction: addAction(_:), - focusTrigger: { applyTriggerDefaults(for: rule.trigger) } + onSetTrigger: { type in + rule.trigger = type + rule.isEditingTrigger = false + hasSeenRuleOnboarding = true + applyTriggerDefaults(for: type) + if guidedStep == .trigger { guidedStep = .action } + }, + focusTrigger: { + if let trigger = rule.trigger { + applyTriggerDefaults(for: trigger) + } + }, + scrollToTriggersSignal: $scrollToTriggersSignal, + currentTrigger: rule.trigger, + isEditingTrigger: rule.isEditingTrigger ) - .padding(.horizontal, 18) - .padding(.top, 20) - .padding(.bottom, 16) - } } .frame(minWidth: 250, idealWidth: 270, maxWidth: 300) .background(rulePaneBackground) @@ -171,7 +322,7 @@ struct RuleEditorView: View { RulePaneHeader( title: rule.name.isEmpty ? "Action Rule" : rule.name, subtitle: rule.triggerSummary, - systemImage: "point.3.filled.connected.trianglepath.dotted" + systemImage: "bolt.circle" ) TextField("Rule Name", text: $rule.name) @@ -192,44 +343,15 @@ struct RuleEditorView: View { .padding(.bottom, 16) .background(rulePaneBackground) - ScrollView { - VStack(alignment: .leading, spacing: 18) { - RuleCanvasSection(title: "Trigger Block", systemImage: "bolt.fill", accent: .yellow) { - TriggerSectionView( - triggerType: $rule.trigger, - triggerServerId: $rule.triggerServerId, - triggerVoiceChannelId: $rule.triggerVoiceChannelId, - triggerMessageContains: $rule.triggerMessageContains, - replyToDMs: $rule.replyToDMs, - includeStageChannels: $rule.includeStageChannels, - serverIds: serverIds, - serverName: serverName(for:), - voiceChannels: app.availableVoiceChannelsByServer[rule.triggerServerId] ?? [] - ) - } - - RuleFlowArrow() - - RuleCanvasSection(title: "Filter Blocks", systemImage: "line.3.horizontal.decrease.circle", accent: .cyan) { - ConditionsSectionView( - conditions: $rule.conditions, - serverIds: serverIds, - serverName: serverName(for:), - voiceChannels: app.availableVoiceChannelsByServer[rule.triggerServerId] ?? [] - ) - } - - RuleFlowArrow() + if !rule.isEmptyRule && !rule.isEditingTrigger && !rule.validationIssues.isEmpty { + ValidationBannerView(issues: rule.validationIssues) + .padding(.horizontal, 20) + .padding(.bottom, 8) + } - RuleCanvasSection(title: "Action Blocks", systemImage: "paperplane.fill", accent: .mint) { - ActionsSectionView( - actions: $rule.actions, - serverIds: serverIds, - serverName: serverName(for:), - textChannelsByServer: app.availableTextChannelsByServer - ) - } - } + ScrollView { + ruleCanvasContent + .animation(.easeInOut(duration: 0.22), value: rule.isEmptyRule) .frame(maxWidth: 880, alignment: .leading) .padding(.horizontal, 20) .padding(.vertical, 20) @@ -241,11 +363,18 @@ struct RuleEditorView: View { .onAppear { initializeRuleDefaultsIfNeeded() } + .onChange(of: rule.actions) { _, newActions in + if guidedStep == .trigger && !newActions.isEmpty { + guidedStep = .none + } + } .onChange(of: rule) { app.ruleStore.scheduleAutoSave() } .onChange(of: rule.trigger) { _, newTrigger in - applyTriggerDefaults(for: newTrigger) + if let newTrigger = newTrigger { + applyTriggerDefaults(for: newTrigger) + } } } @@ -257,50 +386,79 @@ struct RuleEditorView: View { private func addAction(_ type: ActionType) { var action = RuleAction() action.type = type - action.serverId = serverIds.first ?? "" - action.channelId = app.availableTextChannelsByServer[action.serverId]?.first?.id ?? "" - action.message = rule.trigger.defaultMessage + action.message = rule.trigger?.defaultMessage ?? "" switch type { case .sendMessage: - break + action.destinationMode = MessageDestination.defaultMode(for: rule.trigger) + if action.destinationMode == .specificChannel { + action.serverId = serverIds.first ?? "" + action.channelId = app.availableTextChannelsByServer[action.serverId]?.first?.id ?? "" + } case .addLogEntry: action.message = "Rule fired for {username}" case .setStatus: - action.statusText = "Handling \(rule.trigger.rawValue.lowercased())" + action.statusText = "Handling \(rule.trigger?.rawValue.lowercased() ?? "action")" + // Modifier blocks + case .replyToTrigger, .mentionUser, .mentionRole, .disableMention, .sendToChannel, .sendToDM: + break + // AI blocks + case .generateAIResponse: + action.message = "You are a helpful assistant. {message}" + case .summariseMessage: + action.message = "" + case .classifyMessage: + action.message = "" + action.categories = "question, feedback, spam, other" + case .extractEntities: + action.message = "" + action.entityTypes = "names, dates, locations, organizations" + case .rewriteMessage: + action.message = "" + action.rewriteStyle = "professional" + // Other action types + case .sendDM, .deleteMessage, .addReaction, .addRole, .removeRole, + .timeoutMember, .kickMember, .moveMember, .createChannel, .webhook, .delay, + .setVariable, .randomChoice: + break } - rule.actions.append(action) + // Route blocks to their correct section based on category + switch type.category { + case .ai: + rule.aiBlocks.append(action) + case .messaging: + rule.modifiers.append(action) + case .actions, .moderation: + rule.actions.append(action) + default: + rule.actions.append(action) + } app.ruleStore.scheduleAutoSave() } private func initializeRuleDefaultsIfNeeded() { var didChange = false - if rule.triggerServerId.isEmpty { - rule.triggerServerId = serverIds.first ?? "" - didChange = true - } + for index in rule.actions.indices where rule.actions[index].type == .sendMessage { + if rule.actions[index].destinationMode == nil { + rule.actions[index].destinationMode = MessageDestination.defaultMode(for: rule.trigger) + didChange = true + } - if rule.actions.isEmpty { - var action = RuleAction() - action.serverId = serverIds.first ?? "" - let channels = app.availableTextChannelsByServer[action.serverId] ?? [] - action.channelId = channels.first?.id ?? "" - action.message = rule.trigger.defaultMessage - rule.actions = [action] - didChange = true - } else { - if rule.actions[0].serverId.isEmpty, let first = serverIds.first { - rule.actions[0].serverId = first + let destinationMode = rule.actions[index].destinationMode ?? MessageDestination.defaultMode(for: rule.trigger) + guard destinationMode == .specificChannel else { continue } + + if rule.actions[index].serverId.isEmpty, let first = serverIds.first { + rule.actions[index].serverId = first didChange = true } - if rule.actions[0].channelId.isEmpty { - let channels = app.availableTextChannelsByServer[rule.actions[0].serverId] ?? [] - if let first = channels.first { - rule.actions[0].channelId = first.id - didChange = true - } + + let channels = app.availableTextChannelsByServer[rule.actions[index].serverId] ?? [] + if !channels.contains(where: { $0.id == rule.actions[index].channelId }), + let first = channels.first { + rule.actions[index].channelId = first.id + didChange = true } } @@ -309,6 +467,23 @@ struct RuleEditorView: View { } } + private func applyExampleRule() { + rule.name = "Hello World" + rule.trigger = .messageCreated + + var filter = Condition(type: .messageContains) + filter.value = "@swiftbot hello" + rule.conditions = [filter] + + var action = RuleAction() + action.type = .sendMessage + action.destinationMode = .replyToTrigger + action.message = "Hello World 👋" + rule.actions = [action] + + app.ruleStore.scheduleAutoSave() + } + private func applyTriggerDefaults(for newTrigger: TriggerType) { let defaults = TriggerType.allDefaultMessages var didChange = false @@ -326,9 +501,10 @@ struct RuleEditorView: View { didChange = true } - if newTrigger == .messageContains, - rule.triggerMessageContains.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - rule.triggerMessageContains = "up to?" + // Auto-insert a messageContains filter when that trigger is selected (if not already present) + if newTrigger == .messageCreated, + !rule.conditions.contains(where: { $0.type == .messageContains }) { + rule.conditions.append(Condition(type: .messageContains)) didChange = true } @@ -347,52 +523,154 @@ struct RuleBuilderLibraryView: View { let serverIds: [String] let onAddCondition: (ConditionType) -> Void let onAddAction: (ActionType) -> Void + let onSetTrigger: (TriggerType) -> Void let focusTrigger: () -> Void + @Binding var scrollToTriggersSignal: Bool + let currentTrigger: TriggerType? + let isEditingTrigger: Bool + + @State private var triggerSectionHighlighted: Bool = false + private let triggersSectionID = "library-triggers" + + private func types(for category: BlockCategory) -> [ActionType] { + ActionType.allCases.filter { $0.category == category } + } + + private func isCompatible(_ reqs: Set) -> Bool { + guard let currentTrigger = currentTrigger else { return reqs.isEmpty } + return reqs.isSubset(of: currentTrigger.providedVariables) + } + + private func tooltipFor(_ reqs: Set) -> String? { + guard !isCompatible(reqs) else { return nil } + return "Requires \(reqs.friendlyRequirement)." + } var body: some View { - VStack(alignment: .leading, spacing: 14) { - RuleLibrarySection(title: "Start") { - RuleLibraryButton( - title: "Trigger Block", - subtitle: "Choose the event that starts this rule", - systemImage: "bolt.fill", - accent: .yellow, - action: focusTrigger - ) - } + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if currentTrigger == nil || isEditingTrigger { + RuleLibrarySection(title: currentTrigger == nil ? "Select a Trigger" : "Triggers", highlighted: triggerSectionHighlighted) { + ForEach(TriggerType.allCases) { type in + RuleLibraryButton( + title: type.rawValue, + subtitle: "Set this as the rule trigger", + systemImage: type.symbol, + accent: .yellow, + action: { onSetTrigger(type); focusTrigger() } + ) + } + } + .id(triggersSectionID) + } RuleLibrarySection(title: "Filters") { ForEach(ConditionType.allCases) { type in - RuleLibraryButton( - title: type.rawValue, - subtitle: "Add a reusable filter block", - systemImage: type.symbol, - accent: .cyan, - action: { onAddCondition(type) } - ) + if type.isCompatible(with: currentTrigger) { + RuleLibraryButton( + title: type.rawValue, + subtitle: "Add a filter condition", + systemImage: type.symbol, + accent: .cyan, + action: { onAddCondition(type) } + ) + } } } - RuleLibrarySection(title: "Actions") { - ForEach(ActionType.allCases) { type in - RuleLibraryButton( - title: type.rawValue, - subtitle: "Insert this output block into the flow", - systemImage: type.symbol, - accent: .mint, - action: { onAddAction(type) } - ) + let aiTypes = types(for: .ai) + if !aiTypes.isEmpty { + RuleLibrarySection(title: "AI Blocks") { + ForEach(aiTypes) { type in + if type.isCompatible(with: currentTrigger) { + RuleLibraryButton( + title: type.rawValue, + subtitle: "Process input with AI", + systemImage: type.symbol, + accent: .purple, + action: { onAddAction(type) } + ) + } + } } } - if serverIds.isEmpty { - Text("Connect the bot to Discord to unlock server and channel pickers in action blocks.") - .font(.caption) - .foregroundStyle(.secondary) + let messageTypes = types(for: .messaging) + if !messageTypes.isEmpty { + RuleLibrarySection(title: "Message Modifiers") { // Changed title from "Message" + ForEach(messageTypes) { type in + if type.isCompatible(with: currentTrigger) { + RuleLibraryButton( + title: type.rawValue, + subtitle: "Formatting and routing modifiers", + systemImage: type.symbol, + accent: .orange, + action: { onAddAction(type) } + ) + } + } + } + } + + let actionTypes = types(for: .actions) + if !actionTypes.isEmpty { + RuleLibrarySection(title: "Actions") { + ForEach(actionTypes) { type in + if type.isCompatible(with: currentTrigger) { + RuleLibraryButton( + title: type.rawValue, + subtitle: "Output blocks", + systemImage: type.symbol, + accent: .mint, + action: { onAddAction(type) } + ) + } + } + } + } + + let moderationTypes = types(for: .moderation) + if !moderationTypes.isEmpty { + RuleLibrarySection(title: "Moderation") { + ForEach(moderationTypes) { type in + if type.isCompatible(with: currentTrigger) { + RuleLibraryButton( + title: type.rawValue, + subtitle: "Server management", + systemImage: type.symbol, + accent: .red, + action: { onAddAction(type) } + ) + } + } + } + } + + if serverIds.isEmpty { + Text("Connect the bot to Discord to unlock server and channel pickers in action blocks.") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, 4) + } + } + .padding(.horizontal, 18) + .padding(.top, 20) + .padding(.bottom, 16) + } + .onChange(of: scrollToTriggersSignal) { _, newValue in + guard newValue else { return } + withAnimation(.easeInOut(duration: 0.3)) { + proxy.scrollTo(triggersSectionID, anchor: .top) + } + triggerSectionHighlighted = true + scrollToTriggersSignal = false + Task { + try? await Task.sleep(for: .seconds(1.5)) + await MainActor.run { triggerSectionHighlighted = false } + } } } - .frame(maxHeight: .infinity, alignment: .topLeading) - .padding(.vertical, 4) } } @@ -426,17 +704,28 @@ struct RulePaneHeader: View { struct RuleLibrarySection: View { let title: String + var highlighted: Bool = false @ViewBuilder let content: Content var body: some View { VStack(alignment: .leading, spacing: 10) { Text(title.uppercased()) .font(.system(size: 11, weight: .semibold, design: .rounded)) - .foregroundStyle(.secondary) + .foregroundStyle(highlighted ? .yellow : .secondary) VStack(alignment: .leading, spacing: 8) { content } } + .padding(highlighted ? 8 : 0) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(highlighted ? Color.yellow.opacity(0.12) : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(highlighted ? Color.yellow.opacity(0.35) : Color.clear, lineWidth: 1) + ) + .animation(.easeInOut(duration: 0.25), value: highlighted) } } @@ -445,6 +734,9 @@ struct RuleLibraryButton: View { let subtitle: String let systemImage: String let accent: Color + var isDisabled: Bool = false + var disabledReason: String? = nil + var dragItem: String? = nil let action: () -> Void var body: some View { @@ -452,23 +744,27 @@ struct RuleLibraryButton: View { HStack(alignment: .top, spacing: 10) { Image(systemName: systemImage) .font(.headline) - .foregroundStyle(accent) + .foregroundStyle(isDisabled ? .secondary : accent) .frame(width: 24) VStack(alignment: .leading, spacing: 2) { Text(title) .font(.subheadline.weight(.semibold)) - Text(subtitle) + .foregroundStyle(isDisabled ? .secondary : .primary) + Text(isDisabled ? (disabledReason ?? subtitle) : subtitle) .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(isDisabled ? .red.opacity(0.8) : .secondary) } Spacer() - Image(systemName: "plus.circle.fill") - .foregroundStyle(accent) + Image(systemName: isDisabled ? "nosign" : "plus.circle.fill") + .foregroundStyle(isDisabled ? .secondary : accent) } .padding(12) .glassCard(cornerRadius: 18, tint: .white.opacity(0.05), stroke: .white.opacity(0.14)) + .opacity(isDisabled ? 0.6 : 1.0) } .buttonStyle(.plain) + .disabled(isDisabled) + .help(isDisabled ? (disabledReason ?? "Incompatible") : "") } } @@ -476,6 +772,7 @@ struct RuleCanvasSection: View { let title: String let systemImage: String let accent: Color + var guidedHighlight: Bool = false @ViewBuilder let content: Content var body: some View { @@ -491,7 +788,8 @@ struct RuleCanvasSection: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(16) - .glassCard(cornerRadius: 22, tint: .white.opacity(0.10), stroke: .white.opacity(0.18)) + .glassCard(cornerRadius: 22, tint: .white.opacity(0.10), stroke: guidedHighlight ? accent.opacity(0.6) : .white.opacity(0.18)) + .animation(.easeInOut(duration: 0.3), value: guidedHighlight) } } @@ -531,112 +829,93 @@ struct RuleGroupSection: View { } struct TriggerSectionView: View { - @Binding var triggerType: TriggerType - @Binding var triggerServerId: String - @Binding var triggerVoiceChannelId: String - @Binding var triggerMessageContains: String - @Binding var replyToDMs: Bool - @Binding var includeStageChannels: Bool - - let serverIds: [String] - let serverName: (String) -> String - let voiceChannels: [GuildVoiceChannel] + let triggerType: TriggerType? var body: some View { - VStack(alignment: .leading, spacing: 10) { - Picker("Event", selection: $triggerType) { - ForEach(TriggerType.allCases) { trigger in - Label(trigger.rawValue, systemImage: trigger.symbol).tag(trigger) - } - } - - Picker("Server", selection: $triggerServerId) { - ForEach(serverIds, id: \.self) { serverId in - Text(serverName(serverId)).tag(serverId) - } - } - - if triggerType != .messageContains, triggerType != .memberJoined, !voiceChannels.isEmpty { - Picker("Voice Channel", selection: $triggerVoiceChannelId) { - Text("Any Channel").tag("") - ForEach(voiceChannels) { channel in - Text(channel.name).tag(channel.id) - } - } - } - - if triggerType == .messageContains { - TextField("Message contains…", text: $triggerMessageContains) - Toggle("Reply to DMs", isOn: $replyToDMs) - } - - if triggerType == .userJoinedVoice || triggerType == .userMovedVoice { - Toggle("Include Stage Channels", isOn: $includeStageChannels) - } + if let type = triggerType { + Label(type.rawValue, systemImage: type.symbol) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.yellow) + } else { + Text("No trigger selected.") + .foregroundStyle(.secondary) } } } struct ConditionsSectionView: View { @Binding var conditions: [Condition] + let hasTrigger: Bool let serverIds: [String] let serverName: (String) -> String let voiceChannels: [GuildVoiceChannel] + let textChannels: [GuildTextChannel] + let roles: [GuildRole] + var incompatibleBlocks: [UUID] = [] + var availableVariables: Set = [] var body: some View { VStack(alignment: .leading, spacing: 10) { if conditions.isEmpty { - Text("No conditions configured. Rules will run for all matching events.") + Text("No filters yet. Use the Block Library to add one.") + .font(.subheadline) .foregroundStyle(.secondary) + .padding(.vertical, 4) } else { ForEach($conditions) { $condition in + let isCompat = !incompatibleBlocks.contains(condition.id) + let missing = condition.type.requiredVariables.subtracting(availableVariables) + ConditionRowView( condition: $condition, + isIncompatible: !isCompat, + missingContext: missing.isEmpty ? nil : "Requires \(missing.friendlyRequirement)", serverIds: serverIds, serverName: serverName, voiceChannels: voiceChannels, + textChannels: textChannels, + roles: roles, onDelete: { conditions.removeAll { $0.id == condition.id } } ) } } - - Menu { - ForEach(ConditionType.allCases) { type in - Button { - conditions.append(Condition(type: type)) - } label: { - Label(type.rawValue, systemImage: type.symbol) - } - } - } label: { - Label("Add Condition", systemImage: "plus") - } - .menuStyle(.borderlessButton) } + .disabled(!hasTrigger) + .opacity(hasTrigger ? 1.0 : 0.5) } } struct ConditionRowView: View { @Binding var condition: Condition + var isIncompatible: Bool = false + var missingContext: String? = nil let serverIds: [String] let serverName: (String) -> String let voiceChannels: [GuildVoiceChannel] + let textChannels: [GuildTextChannel] + let roles: [GuildRole] let onDelete: () -> Void var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { - Picker("Condition", selection: $condition.type) { - ForEach(ConditionType.allCases) { type in - Label(type.rawValue, systemImage: type.symbol).tag(type) - } + Label(condition.type.rawValue, systemImage: condition.type.symbol) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.cyan) + + if isIncompatible { + Label(missingContext ?? "Incompatible with trigger", systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + .padding(.leading, 8) } - Toggle("Enabled", isOn: $condition.enabled) - .toggleStyle(.switch) + + Spacer() + Button(role: .destructive, action: onDelete) { Image(systemName: "trash") } @@ -646,7 +925,8 @@ struct ConditionRowView: View { conditionEditor } .padding(10) - .glassCard(cornerRadius: 18, tint: .white.opacity(0.08), stroke: .white.opacity(0.16)) + .glassCard(cornerRadius: 18, tint: .white.opacity(0.08), stroke: isIncompatible ? Color.orange.opacity(0.4) : .white.opacity(0.16)) + .opacity(isIncompatible ? 0.6 : 1.0) } @ViewBuilder @@ -666,43 +946,125 @@ struct ConditionRowView: View { } } case .usernameContains: - TextField("Username contains…", text: $condition.value) + TextField("Enter username fragment…", text: $condition.value) case .minimumDuration: HStack { - TextField("Minimum", text: $condition.value) + TextField("Minutes", text: $condition.value) .frame(width: 80) Text("minutes in channel") .foregroundStyle(.secondary) } + // New condition types - placeholder UI + case .channelIs: + SearchableIDPicker( + title: "Channel", + selectionID: $condition.value, + items: textChannels.map { .init(id: $0.id, name: "#\($0.name)") }, + prompt: "Select a channel..." + ) + case .channelCategory: + TextField("Enter category name or ID…", text: $condition.value) + .foregroundStyle(.secondary) + case .userHasRole: + SearchableIDPicker( + title: "Role", + selectionID: $condition.value, + items: roles.map { .init(id: $0.id, name: $0.name) }, + prompt: "Select a role..." + ) + case .userJoinedRecently: + HStack { + TextField("Minutes", text: $condition.value) + .textFieldStyle(.roundedBorder) + .frame(width: 60) + Text("minutes") + .font(.caption) + .foregroundStyle(.secondary) + } + case .messageContains: + TextField("Enter text to match…", text: $condition.value) + case .messageStartsWith: + TextField("Enter prefix to match…", text: $condition.value) + case .messageRegex: + TextField("Enter regex pattern…", text: $condition.value) + case .isDirectMessage: + Text("Passes if the triggering message was sent in a DM.") + .font(.caption) + .foregroundStyle(.secondary) + case .isFromBot: + Text("Passes if the triggering user is a bot.") + .font(.caption) + .foregroundStyle(.secondary) + case .isFromUser: + Text("Passes if the triggering user is a human.") + .font(.caption) + .foregroundStyle(.secondary) + case .channelType: + Picker("Channel Type", selection: $condition.value) { + Text("Text Channel").tag("0") + Text("DM").tag("1") + Text("Voice Channel").tag("2") + Text("Group DM").tag("3") + Text("Category").tag("4") + Text("News").tag("5") + Text("Stage").tag("13") + } } } } struct ActionsSectionView: View { @Binding var actions: [Action] + let category: BlockCategory + let allModifiers: [Action] + let currentTrigger: TriggerType? + let hasTrigger: Bool let serverIds: [String] let serverName: (String) -> String let textChannelsByServer: [String: [GuildTextChannel]] + let voiceChannelsByServer: [String: [GuildVoiceChannel]] + let rolesByServer: [String: [GuildRole]] + let knownUsers: [String: String] + var isGuided: Bool = false + var incompatibleBlocks: [UUID] = [] + var availableVariables: Set = [] var body: some View { VStack(alignment: .leading, spacing: 10) { if actions.isEmpty { - Button { - var action = Action() - action.serverId = serverIds.first ?? "" - action.channelId = textChannelsByServer[action.serverId]?.first?.id ?? "" - actions = [action] - } label: { - Label("Add First Action Block", systemImage: "plus") + VStack(spacing: 10) { + Image(systemName: "rectangle.stack.badge.plus") + .font(.title2) + .foregroundStyle(.secondary) + Text("No blocks yet") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + Text(textForEmptyState(category: category, isGuided: isGuided)) + .font(.caption) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) } else { ForEach($actions) { $action in + let isCompat = !incompatibleBlocks.contains(action.id) + let missing = action.type.requiredVariables.subtracting(availableVariables) + ActionSectionView( action: $action, + category: category, + allModifiers: allModifiers, + currentTrigger: currentTrigger, + isIncompatible: !isCompat, + missingContext: missing.isEmpty ? nil : "Requires \(missing.friendlyRequirement)", serverIds: serverIds, serverName: serverName, - textChannels: textChannelsByServer[action.serverId] ?? [], + textChannelsByServer: textChannelsByServer, + voiceChannelsByServer: voiceChannelsByServer, + rolesByServer: rolesByServer, + knownUsers: knownUsers, onDelete: { actions.removeAll { $0.id == action.id } } @@ -710,26 +1072,100 @@ struct ActionsSectionView: View { } } } + .disabled(!hasTrigger) + .opacity(hasTrigger ? 1.0 : 0.5) + } + + private func textForEmptyState(category: BlockCategory, isGuided: Bool) -> String { + switch category { + case .ai: + return "No AI processing blocks yet. Add one from the Block Library.\n\nExamples:\nGenerate AI Response\nSummarise Message\nClassify Message" + default: + return isGuided + ? "Select a block from the Block Library to the left." + : "Use the Block Library to add your first block." + } } } struct ActionSectionView: View { @Binding var action: Action + let category: BlockCategory + let allModifiers: [Action] + let currentTrigger: TriggerType? + var isIncompatible: Bool = false + var missingContext: String? = nil let serverIds: [String] let serverName: (String) -> String - let textChannels: [GuildTextChannel] + let textChannelsByServer: [String: [GuildTextChannel]] + let voiceChannelsByServer: [String: [GuildVoiceChannel]] + let rolesByServer: [String: [GuildRole]] + let knownUsers: [String: String] let onDelete: () -> Void + private var resolvedDestinationMode: MessageDestination { + action.destinationMode ?? MessageDestination.defaultMode(for: currentTrigger) + } + + private var resolvedServerId: String { + if !action.serverId.isEmpty { + return action.serverId + } + return serverIds.first ?? "" + } + + private var textChannels: [GuildTextChannel] { + textChannelsByServer[resolvedServerId] ?? [] + } + + private var voiceChannels: [GuildVoiceChannel] { + voiceChannelsByServer[resolvedServerId] ?? [] + } + + private var roles: [GuildRole] { + rolesByServer[resolvedServerId] ?? [] + } + + private var destinationBinding: Binding { + Binding( + get: { resolvedDestinationMode }, + set: { newValue in + action.destinationMode = newValue + if newValue == .specificChannel { + ensureSpecificChannelSelection() + } + } + ) + } + + private func ensureSpecificChannelSelection() { + if action.serverId.isEmpty { + action.serverId = serverIds.first ?? "" + } + let availableChannels = textChannelsByServer[action.serverId] ?? [] + if !availableChannels.contains(where: { $0.id == action.channelId }) { + action.channelId = availableChannels.first?.id ?? "" + } + } + var body: some View { VStack(alignment: .leading, spacing: 10) { + // Block header — immutable once created; type is fixed at drop time HStack { - Picker("Action", selection: $action.type) { - ForEach(ActionType.allCases) { actionType in - Label(actionType.rawValue, systemImage: actionType.symbol).tag(actionType) - } + Label(action.type.rawValue, systemImage: action.type.symbol) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(category == .messaging ? .orange : .mint) + + if isIncompatible { + Label(missingContext ?? "Incompatible with trigger", systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + .padding(.leading, 8) } + Spacer() + Button(role: .destructive, action: onDelete) { Image(systemName: "trash") } @@ -738,55 +1174,84 @@ struct ActionSectionView: View { switch action.type { case .sendMessage: - if serverIds.isEmpty { - Text("No connected servers available yet.") - .foregroundStyle(.secondary) - } else { - Picker("Server", selection: $action.serverId) { - ForEach(serverIds, id: \.self) { serverId in - Text(serverName(serverId)).tag(serverId) - } + Picker("Destination", selection: destinationBinding) { + ForEach(MessageDestination.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) } } - Toggle("Mention user in message", isOn: $action.mentionUser) - Toggle("Reply to trigger message", isOn: $action.replyToTriggerMessage) - Toggle("Reply with AI", isOn: $action.replyWithAI) - - if action.replyToTriggerMessage { - Text("Reply will be sent in the same channel as the triggering message.") + switch resolvedDestinationMode { + case .replyToTrigger: + Label("Replies to the triggering message automatically.", systemImage: "arrowshape.turn.up.left.fill") .font(.caption) .foregroundStyle(.secondary) - } else { - if textChannels.isEmpty { - Text("No text channels discovered for this server.") + case .sameChannel: + Label("Uses the trigger channel automatically.", systemImage: "number") + .font(.caption) + .foregroundStyle(.secondary) + case .specificChannel: + if serverIds.isEmpty { + Text("No connected servers available yet.") .foregroundStyle(.secondary) } else { - Picker("Text Channel", selection: $action.channelId) { - ForEach(textChannels) { channel in - Text("#\(channel.name)").tag(channel.id) + Picker("Server", selection: $action.serverId) { + ForEach(serverIds, id: \.self) { serverId in + Text(serverName(serverId)).tag(serverId) + } + } + if textChannels.isEmpty { + Text("No text channels discovered for this server.") + .foregroundStyle(.secondary) + } else { + Picker("Text Channel", selection: $action.channelId) { + ForEach(textChannels) { channel in + Text("#\(channel.name)").tag(channel.id) + } } } } } - VStack(alignment: .leading, spacing: 6) { - Text(action.replyWithAI ? "AI Prompt" : "Message") - .font(.subheadline.weight(.semibold)) - TextEditor(text: $action.message) - .scrollContentBackground(.hidden) - .frame(minHeight: 120) - .padding(6) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder(.white.opacity(0.16), lineWidth: 1) - ) + Picker("Content Source", selection: $action.contentSource) { + ForEach(ContentSource.allCases, id: \.self) { source in + Text(source.displayName).tag(source) + } } - if action.replyWithAI { - Text("AI will generate the final reply from this prompt template.") - .font(.caption) - .foregroundStyle(.secondary) + + if action.contentSource == .custom { + VStack(alignment: .leading, spacing: 6) { + Text("Message") + .font(.subheadline.weight(.semibold)) + VariableAwareTextEditor(text: $action.message) + if action.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Label("Message content is required.", systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + } + } + } else { + HStack { + Image(systemName: "wand.and.stars") + .foregroundStyle(.indigo) + Text("Uses AI output from \(action.contentSource.displayName)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if category == .actions { + VStack(alignment: .leading, spacing: 4) { + let activeModifiers = allModifiers.map { $0.type.rawValue }.joined(separator: ", ") + if !activeModifiers.isEmpty { + Label("Active Modifiers: \(activeModifiers)", systemImage: "info.circle") + .font(.caption2) + .foregroundStyle(.indigo) + } else { + Text("Add modifier blocks (Reply To Trigger, Send To Channel…) above this action to control message routing.") + .font(.caption) + .foregroundStyle(.secondary) + } + } } case .addLogEntry: TextField("Log message", text: $action.message) @@ -798,24 +1263,469 @@ struct ActionSectionView: View { .font(.caption) .foregroundStyle(.secondary) } + case .sendDM: + Toggle("Mention user", isOn: $action.mentionUser) + case .deleteMessage: + Text("Delete the triggering message") + .foregroundStyle(.secondary) + case .addReaction: + TextField("Emoji", text: $action.emoji) + case .addRole, .removeRole: + SearchableIDPicker( + title: "Role", + selectionID: $action.roleId, + items: roles.map { .init(id: $0.id, name: $0.name) }, + prompt: "Select a role..." + ) + case .timeoutMember: + HStack { + TextField("Duration (seconds)", value: $action.timeoutDuration, format: .number) + Text("seconds") + .foregroundStyle(.secondary) + } + case .kickMember: + TextField("Reason (optional)", text: $action.kickReason) + case .moveMember: + SearchableIDPicker( + title: "Target Voice Channel", + selectionID: $action.targetVoiceChannelId, + items: voiceChannels.map { .init(id: $0.id, name: $0.name) }, + prompt: "Select a voice channel..." + ) + case .createChannel: + TextField("Channel Name", text: $action.newChannelName) + case .webhook: + TextField("Webhook URL", text: $action.webhookURL) + VStack(alignment: .leading, spacing: 6) { + Text("Payload Content") + .font(.subheadline.weight(.semibold)) + VariableAwareTextEditor(text: $action.webhookContent) + } + case .delay: + HStack { + TextField("Seconds", value: $action.delaySeconds, format: .number) + Text("seconds") + .foregroundStyle(.secondary) + } + case .setVariable: + HStack { + TextField("Variable name", text: $action.variableName) + Text("=") + TextField("Value", text: $action.variableValue) + } + case .randomChoice: + Text("Random options not yet configurable") + .foregroundStyle(.secondary) + // Message Modifier blocks + case .replyToTrigger: + Text("Replies to the message that triggered this rule.") + .font(.caption) + .foregroundStyle(.secondary) + case .mentionUser: + Text("Prefixes the message with a mention of the triggering user.") + .font(.caption) + .foregroundStyle(.secondary) + case .mentionRole: + SearchableIDPicker( + title: "Role to Mention", + selectionID: $action.roleId, + items: roles.map { .init(id: $0.id, name: $0.name) }, + prompt: "Select a role..." + ) + case .disableMention: + Text("Strips any existing user mentions from the message template.") + .font(.caption) + .foregroundStyle(.secondary) + case .sendToChannel: + SearchableIDPicker( + title: "Target Channel", + selectionID: $action.channelId, + items: textChannels.map { .init(id: $0.id, name: "#\($0.name)") }, + prompt: "Select a channel..." + ) + case .sendToDM: + Text("Sends the message as a DM to the triggering user.") + .font(.caption) + .foregroundStyle(.secondary) + // AI blocks + case .generateAIResponse: + VStack(alignment: .leading, spacing: 6) { + Text("AI Prompt") + .font(.subheadline.weight(.semibold)) + VariableAwareTextEditor(text: $action.message) + Text("The AI response is available as {ai.response} in later modifiers and actions.") + .font(.caption) + .foregroundStyle(.secondary) + } + case .summariseMessage: + VStack(alignment: .leading, spacing: 6) { + Text("Summarization Prompt (Optional)") + .font(.subheadline.weight(.semibold)) + VariableAwareTextEditor(text: $action.message) + Text("The AI summary is available as {ai.summary} in later modifiers and actions.") + .font(.caption) + .foregroundStyle(.secondary) + } + case .classifyMessage: + VStack(alignment: .leading, spacing: 6) { + Text("Classification Prompt") + .font(.subheadline.weight(.semibold)) + VariableAwareTextEditor(text: $action.message) + Text("The AI classification is available as {ai.classification} in later modifiers and actions.") + .font(.caption) + .foregroundStyle(.secondary) + } + case .extractEntities: + VStack(alignment: .leading, spacing: 6) { + Text("Entity Extraction Prompt") + .font(.subheadline.weight(.semibold)) + VariableAwareTextEditor(text: $action.message) + Text("The extracted entities are available as {ai.entities} in later modifiers and actions.") + .font(.caption) + .foregroundStyle(.secondary) + } + case .rewriteMessage: + VStack(alignment: .leading, spacing: 6) { + Text("Rewriting Prompt") + .font(.subheadline.weight(.semibold)) + VariableAwareTextEditor(text: $action.message) + Text("The rewritten message is available as {ai.rewrite} in later modifiers and actions.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .padding(10) + .glassCard(cornerRadius: 18, tint: .white.opacity(0.08), stroke: isIncompatible ? Color.orange.opacity(0.4) : .white.opacity(0.16)) + .opacity(isIncompatible ? 0.6 : 1.0) + .onAppear { + if action.type == .sendMessage, resolvedDestinationMode == .specificChannel { + ensureSpecificChannelSelection() + } + } + .onChange(of: action.serverId) { _, _ in + if action.type == .sendMessage, resolvedDestinationMode == .specificChannel { + ensureSpecificChannelSelection() + } + } + } +} + +// MARK: - Validation Banner + +struct EmptyRuleStateView: View { + let icon: String + let title: String + let description: String + var onShowMe: (() -> Void)? = nil + var onContinue: (() -> Void)? = nil + + var body: some View { + VStack(spacing: 24) { + Image(systemName: icon) + .font(.system(size: 48)) + .foregroundStyle(.yellow) + .symbolEffect(.bounce, value: true) + + VStack(spacing: 8) { + Text(title) + .font(.title2.weight(.bold)) + Text(description) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + if onShowMe != nil || onContinue != nil { + HStack(spacing: 12) { + if let onShowMe = onShowMe { + Button("Show Me", action: onShowMe) + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + if let onContinue = onContinue { + Button("Continue", action: onContinue) + .buttonStyle(.bordered) + .controlSize(.large) + } + } + .padding(.top, 8) + } + } + .padding(40) + .frame(maxWidth: .infinity) + .glassCard(cornerRadius: 24, tint: .white.opacity(0.05), stroke: .white.opacity(0.1)) + } +} + +struct ValidationBannerView: View { + let issues: [ValidationIssue] + + private var errors: [ValidationIssue] { issues.filter { $0.severity == .error } } + private var warnings: [ValidationIssue] { issues.filter { $0.severity == .warning } } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ForEach(errors) { issue in + ValidationIssueRow(issue: issue) + } + ForEach(warnings) { issue in + ValidationIssueRow(issue: issue) } + } + } +} + +private struct ValidationIssueRow: View { + let issue: ValidationIssue - Text("Use placeholders in messages: {userId}, {username}, {channelId}, {channelName}, {guildName}, {duration}") + private var accent: Color { issue.severity == .error ? .red : .orange } + + var body: some View { + HStack(spacing: 8) { + Image(systemName: issue.severity.icon) + .foregroundStyle(accent) + .font(.caption.weight(.semibold)) + Text(issue.message) .font(.caption) + .foregroundStyle(.primary) + Spacer() + Text(issue.blockType.rawValue) + .font(.caption2) .foregroundStyle(.secondary) } - .padding(12) - .glassCard(cornerRadius: 18, tint: .white.opacity(0.06), stroke: .white.opacity(0.16)) - .onAppear { - if action.serverId.isEmpty { - action.serverId = serverIds.first ?? "" + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(accent.opacity(0.30), lineWidth: 1) + ) + } +} + +// MARK: - Variable-Aware Text Editor + +struct VariableAwareTextEditor: View { + @Binding var text: String + @State private var showPicker = false + @State private var cursorAtEnd = false + + var body: some View { + ZStack(alignment: .topTrailing) { + TextEditor(text: $text) + .scrollContentBackground(.hidden) + .frame(minHeight: 100) + .padding(6) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(.white.opacity(0.16), lineWidth: 1) + ) + .onChange(of: text) { _, newValue in + if newValue.hasSuffix("{") { + showPicker = true + } + } + + Button { + showPicker = true + } label: { + Label("Insert Variable", systemImage: "curlybraces") + .font(.caption.weight(.medium)) + .labelStyle(.iconOnly) + .padding(6) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) } - if action.channelId.isEmpty { - action.channelId = textChannels.first?.id ?? "" + .buttonStyle(.plain) + .padding(8) + .popover(isPresented: $showPicker, arrowEdge: .top) { + VariablePickerPopover { variable in + // Remove the trailing `{` that triggered the picker if present + if text.hasSuffix("{") { + text.removeLast() + } + text += variable.rawValue + showPicker = false + } } } - .onChange(of: action.serverId) { - action.channelId = textChannels.first?.id ?? "" + } +} + +// MARK: - Variable Picker Popover + +struct VariablePickerPopover: View { + let onSelect: (ContextVariable) -> Void + + private var grouped: [(category: String, variables: [ContextVariable])] { + let categories = ["User", "Message", "Channel", "Server", "Voice", "Reaction", "Other"] + return categories.compactMap { cat in + let vars = ContextVariable.allCases.filter { $0.category == cat } + return vars.isEmpty ? nil : (category: cat, variables: vars) } } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text("Insert Variable") + .font(.headline) + .padding(.horizontal, 14) + .padding(.top, 12) + .padding(.bottom, 8) + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 12) { + ForEach(grouped, id: \.category) { group in + VStack(alignment: .leading, spacing: 4) { + Text(group.category.uppercased()) + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + + ForEach(group.variables, id: \.self) { variable in + Button { + onSelect(variable) + } label: { + HStack(spacing: 10) { + Text(variable.rawValue) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.cyan) + .frame(minWidth: 120, alignment: .leading) + Text(variable.displayName) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 5) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + } + } + .padding(.vertical, 8) + } + } + .frame(width: 320, height: 340) + } +} + +// MARK: - Searchable ID Picker + +struct SearchablePickerItem: Identifiable { + let id: String + let name: String +} + +struct SearchableIDPicker: View { + let title: String + @Binding var selectionID: String + let items: [SearchablePickerItem] + let prompt: String + + @State private var showPopover = false + @State private var searchText = "" + + private var filteredItems: [SearchablePickerItem] { + if searchText.isEmpty { + return items + } + return items.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + } + + private var selectedName: String { + items.first { $0.id == selectionID }?.name ?? (selectionID.isEmpty ? prompt : selectionID) + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + if !title.isEmpty { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + } + + Button { + showPopover = true + } label: { + HStack { + Text(selectedName) + .font(.subheadline) + .foregroundStyle(selectionID.isEmpty ? .secondary : .primary) + .lineLimit(1) + Spacer() + Image(systemName: "chevron.up.chevron.down") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(.white.opacity(0.12), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + .popover(isPresented: $showPopover, arrowEdge: .trailing) { + VStack(spacing: 0) { + HStack { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Search...", text: $searchText) + .textFieldStyle(.plain) + } + .padding(10) + .background(.white.opacity(0.05)) + + Divider() + + if filteredItems.isEmpty { + Text("No results found") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.vertical, 20) + .frame(maxWidth: .infinity) + } else { + ScrollView { + VStack(spacing: 0) { + ForEach(filteredItems) { (item: SearchablePickerItem) in + Button { + selectionID = item.id + showPopover = false + } label: { + HStack { + Text(item.name) + .font(.subheadline) + Spacer() + if selectionID == item.id { + Image(systemName: "checkmark") + .foregroundStyle(Color.accentColor) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + } + .frame(minWidth: 220, maxHeight: 300) + } + } + } + } +} + +// MARK: - Guided Build Step + +enum GuidedBuildStep { + case none, trigger, action } diff --git a/SwiftBotApp/VoiceRuleListView.swift b/SwiftBotApp/VoiceRuleListView.swift index abbf26d..9630d6d 100644 --- a/SwiftBotApp/VoiceRuleListView.swift +++ b/SwiftBotApp/VoiceRuleListView.swift @@ -5,40 +5,50 @@ struct RuleListView: View { @Binding var selectedRuleID: UUID? let onAddNew: () -> Void let onDeleteRuleID: (UUID) -> Void + var isLoading: Bool = false var body: some View { VStack(spacing: 0) { RulePaneHeader( title: "Actions", subtitle: "Build reusable flows from triggers, filters, and outputs.", - systemImage: "point.3.filled.connected.trianglepath.dotted" + systemImage: "bolt.circle" ) - ScrollView { - VStack(alignment: .leading, spacing: 14) { - Button(action: onAddNew) { - Label("New Rule", systemImage: "plus") - .frame(maxWidth: .infinity) - } - .buttonStyle(GlassActionButtonStyle()) + if isLoading { + Spacer() + ProgressView() + .padding() + Spacer() + } else if rules.isEmpty { + RuleListEmptyStateView(onCreateRule: onAddNew) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Button(action: onAddNew) { + Label("New Rule", systemImage: "plus") + .frame(maxWidth: .infinity) + } + .buttonStyle(GlassActionButtonStyle()) - LazyVStack(spacing: 10) { - ForEach($rules) { $rule in - RuleRowView( - rule: $rule, - isSelected: selectedRuleID == rule.id, - onSelect: { - withAnimation(.snappy(duration: 0.12)) { - selectedRuleID = rule.id - } - }, - onDelete: { onDeleteRuleID(rule.id) } - ) + LazyVStack(spacing: 10) { + ForEach($rules) { $rule in + RuleRowView( + rule: $rule, + isSelected: selectedRuleID == rule.id, + onSelect: { + withAnimation(.snappy(duration: 0.12)) { + selectedRuleID = rule.id + } + }, + onDelete: { onDeleteRuleID(rule.id) } + ) + } } } + .padding(.horizontal, 16) + .padding(.vertical, 16) } - .padding(.horizontal, 16) - .padding(.vertical, 16) } } .background(rulePaneBackground) @@ -50,6 +60,41 @@ struct RuleListView: View { } } +// MARK: - Empty State + +private struct RuleListEmptyStateView: View { + let onCreateRule: () -> Void + + var body: some View { + VStack(spacing: 0) { + Spacer() + VStack(spacing: 16) { + Image(systemName: "bolt.circle") + .font(.system(size: 40)) + .foregroundStyle(.secondary) + + VStack(spacing: 6) { + Text("No Rules Yet") + .font(.headline) + Text("SwiftBot automations let you respond to\nevents in your Discord server.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + Button(action: onCreateRule) { + Label("Create First Rule", systemImage: "plus") + .frame(maxWidth: .infinity) + } + .buttonStyle(GlassActionButtonStyle()) + .padding(.top, 4) + } + .padding(.horizontal, 24) + Spacer() + } + } +} + struct RuleRowView: View { @Binding var rule: Rule let isSelected: Bool