From d6f78558b1cef0e7d382663473220ba5783b5efe Mon Sep 17 00:00:00 2001 From: johnwatso Date: Wed, 11 Mar 2026 13:27:33 +1300 Subject: [PATCH 01/18] [beta] Actions Menu Overhaul [beta] - This is a beta build of the next release of SwiftBot and should be used with Caution. Actions are now buildable Added onboarding for first rule as per apple guidelines Added new Actions as per Discord API Added the ability to delete and add actions and filters etc within the UI Fixed issue Where App Crashes when building first rule Added Migration for legacy rules prior to this update - This has not been tested in a production setup and should be used with caution --- AI_CONTEXT.md | 316 +++++++++ ARCHITECTURE.md | 13 +- SwiftBot.xcodeproj/project.pbxproj | 4 + SwiftBotApp/AppModel.swift | 23 +- SwiftBotApp/DiscordService.swift | 295 +++++++-- SwiftBotApp/EmptyRuleOnboardingView.swift | 74 +++ SwiftBotApp/Models.swift | 693 ++++++++++++++++++- SwiftBotApp/VoiceActionsView.swift | 771 ++++++++++++++++++---- SwiftBotApp/VoiceRuleListView.swift | 87 ++- 9 files changed, 2025 insertions(+), 251 deletions(-) create mode 100644 AI_CONTEXT.md create mode 100644 SwiftBotApp/EmptyRuleOnboardingView.swift 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/AppModel.swift b/SwiftBotApp/AppModel.swift index 81452d2..590dbec 100644 --- a/SwiftBotApp/AppModel.swift +++ b/SwiftBotApp/AppModel.swift @@ -3397,16 +3397,19 @@ final class AppModel: ObservableObject { triggerUserId: userId, isDirectMessage: false ) - 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. diff --git a/SwiftBotApp/DiscordService.swift b/SwiftBotApp/DiscordService.swift index 614b299..4daa81d 100644 --- a/SwiftBotApp/DiscordService.swift +++ b/SwiftBotApp/DiscordService.swift @@ -2022,12 +2022,17 @@ 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 + + for action in ruleResult.actions { + await execute(action: action, for: event, context: &context) + } } } @@ -2165,6 +2170,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 +2304,110 @@ actor DiscordService { return "User \(userId.suffix(4))" } - private func execute(action: Action, for event: VoiceRuleEvent) async { + private func execute(action: Action, for event: VoiceRuleEvent, context: inout PipelineContext) async { + guard let token = botToken else { return } + switch action.type { + case .mentionUser: + context.mentionUser = true + case .mentionRole: + context.mentionRole = action.roleId + case .sendToDM: + context.targetChannelId = nil // Signifies DM + case .sendToChannel: + context.targetChannelId = action.channelId + 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 .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 - )) - } + let targetChannelId = context.targetChannelId ?? action.channelId + guard !targetChannelId.isEmpty || (context.targetChannelId == nil && !event.userId.isEmpty) else { return } - 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) - } - } + let rendered = renderMessage(template: action.message, event: event, context: context) - if action.replyToTriggerMessage, - let triggerMessageId = event.triggerMessageId { + if context.replyToTriggerMessage, + let triggerMessageId = event.triggerMessageId, + let replyChannelId = event.triggerChannelId { let payload: [String: Any] = [ "content": rendered, "message_reference": [ "message_id": triggerMessageId, - "channel_id": targetChannelId, + "channel_id": replyChannelId, "fail_if_not_exists": false ] ] - _ = try? await sendMessage(channelId: targetChannelId, payload: payload, token: token) + _ = try? await sendMessage(channelId: replyChannelId, payload: payload, token: token) + } else if context.targetChannelId == nil && !event.userId.isEmpty { + // Send to DM + _ = try? await sendDM(userId: event.userId, content: rendered) } else { try? await sendMessage(channelId: targetChannelId, content: rendered, token: token) } 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 .replyToMessage: + guard let triggerMessageId = event.triggerMessageId, let triggerChannelId = event.triggerChannelId else { return } + let rendered = renderMessage(template: action.message, event: event, context: context) + 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) + case .sendDM: + let rendered = renderMessage(template: action.dmContent, event: event, context: context) + _ = try? await sendDM(userId: event.userId, content: rendered) + 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) + 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) + } + case .addRole: + _ = try? await addRole(guildId: event.guildId, userId: event.userId, roleId: action.roleId, token: token) + case .removeRole: + _ = try? await removeRole(guildId: event.guildId, userId: event.userId, roleId: action.roleId, token: token) + case .timeoutMember: + _ = try? await timeoutMember(guildId: event.guildId, userId: event.userId, durationSeconds: action.timeoutDuration, token: token) + case .kickMember: + _ = try? await kickMember(guildId: event.guildId, userId: event.userId, reason: action.kickReason, token: token) + case .moveMember: + _ = try? await moveMember(guildId: event.guildId, userId: event.userId, channelId: action.targetVoiceChannelId, token: token) + case .createChannel: + _ = try? await createChannel(guildId: event.guildId, name: action.newChannelName, token: token) + 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 +2429,16 @@ 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 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..fb4736b --- /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 blocks yet") + .font(.title3.weight(.bold)) + .foregroundStyle(.primary) + + Text("Add a trigger to begin building this rule.") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + // Primary action button: [ + Add Trigger ] + Button(action: onAddTriggerTapped) { + Label("Add 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("Drag 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..472b99b 100644 --- a/SwiftBotApp/Models.swift +++ b/SwiftBotApp/Models.swift @@ -2406,29 +2406,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 +2461,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,25 +2508,37 @@ final class RuleStore: ObservableObject { } } +/// Context maintained during a single rule execution pipeline +struct PipelineContext { + var aiResponse: String? + var targetChannelId: String? + var targetServerId: String? + var mentionUser: Bool = true + var replyToTriggerMessage: Bool = false + var mentionRole: String? + var isDirectMessage: Bool = false +} + @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) { + guard let trigger = rule.trigger else { return false } + switch (trigger, event.kind) { case (.userJoinedVoice, .join): return true case (.userLeftVoice, .leave): @@ -2574,6 +2584,12 @@ final class RuleEngine { 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 true + case .userHasRole: + // Role conditions not yet implemented for voice events — always pass + return true } } } @@ -2806,12 +2822,224 @@ 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}" + + 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" + } + } + + 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: + return "AI" + } + } +} + +// 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 memberJoined = "Member Joined" + case reactionAdded = "Reaction Added" + case slashCommand = "Slash Command" var id: String { rawValue } @@ -2822,6 +3050,8 @@ enum TriggerType: String, CaseIterable, Identifiable, Codable { case .userMovedVoice: return "arrow.left.arrow.right.circle" case .messageContains: return "text.bubble" case .memberJoined: return "person.badge.plus" + case .reactionAdded: return "face.smiling" + case .slashCommand: return "slash.circle" } } @@ -2832,6 +3062,8 @@ enum TriggerType: String, CaseIterable, Identifiable, Codable { case .userMovedVoice: return "🔀 <@{userId}> moved from <#{fromChannelId}> to <#{toChannelId}>" case .messageContains: return "nm you?" case .memberJoined: return "👋 Welcome to {server}, {username}! You're member #{memberCount}." + case .reactionAdded: return "👍 Reaction added!" + case .slashCommand: return "Command received!" } } @@ -2842,6 +3074,24 @@ enum TriggerType: String, CaseIterable, Identifiable, Codable { case .userMovedVoice: return "Move Action" case .messageContains: return "Message Reply" case .memberJoined: return "Member Join Welcome" + 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 .messageContains: + return [.user, .userId, .username, .userMention, .message, .messageId, .channel, .channelId, .channelName, .guild, .guildId, .guildName] + case .memberJoined: + 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 +3110,8 @@ 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 userHasRole = "User Has Role" var id: String { rawValue } @@ -2869,6 +3121,26 @@ enum ConditionType: String, CaseIterable, Identifiable, Codable { case .voiceChannel: return "waveform" case .usernameContains: return "text.magnifyingglass" case .minimumDuration: return "timer" + case .channelIs: return "number" + case .userHasRole: return "person.crop.circle.badge.checkmark" + } + } + + /// 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: + return [.channel, .channelId] + case .userHasRole: + return [.user, .userId] } } } @@ -2877,6 +3149,30 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { case sendMessage = "Send Message" case addLogEntry = "Add Log Entry" case setStatus = "Set Bot Status" + case replyToMessage = "Reply to Message" + 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 sendToDM = "Send To DM" + case sendToChannel = "Send To Channel" + + // AI Types + case generateAIResponse = "Generate AI Response" var id: String { rawValue } @@ -2885,10 +3181,139 @@ 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 .replyToMessage: return "arrow.turn.down.left" + 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 .sendToDM: return "envelope.fill" + case .sendToChannel: return "number.circle.fill" + case .generateAIResponse: return "sparkles" } } -} + + /// Variables required by this action type + var requiredVariables: Set { + switch self { + case .sendMessage, .sendDM, .setStatus, .addLogEntry, .delay, .setVariable, .randomChoice, .createChannel, .webhook: + return [] + case .replyToMessage, .deleteMessage, .addReaction, .replyToTrigger: + return [.message, .messageId] + case .addRole, .removeRole, .timeoutMember, .kickMember, .moveMember, .mentionUser: + return [.user, .userId] + case .sendToChannel: + return [.channel] + case .generateAIResponse, .mentionRole, .sendToDM: + return [] + } + } + + /// Variables provided/output by this action type + var outputVariables: Set { + switch self { + case .generateAIResponse: + return [.aiResponse] + case .sendMessage, .replyToMessage, .sendDM, .deleteMessage, .addReaction, .addRole, + .removeRole, .timeoutMember, .kickMember, .moveMember, .createChannel, .webhook, + .setStatus, .addLogEntry, .delay, .setVariable, .randomChoice, .replyToTrigger, + .mentionUser, .mentionRole, .sendToDM, .sendToChannel: + return [] + } + } + + /// Discord permissions required for this action + var requiredPermissions: Set { + switch self { + case .sendMessage, .replyToMessage, .sendDM, .addLogEntry, .setStatus, .delay, .setVariable, .randomChoice, .generateAIResponse, .mentionUser, .mentionRole, .sendToDM, .sendToChannel, .replyToTrigger: + 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 .sendMessage, .replyToMessage, .sendDM, .deleteMessage, .addReaction: + return .messaging + case .addRole, .removeRole, .timeoutMember, .kickMember, .moveMember: + return .moderation + case .createChannel: + return .channel + case .webhook: + return .integration + case .addLogEntry: + return .logging + case .setStatus: + return .bot + case .delay, .setVariable, .randomChoice: + return .utility + case .replyToTrigger, .mentionUser, .mentionRole, .sendToDM, .sendToChannel: + return .modifiers + case .generateAIResponse: + return .ai + } + } +} + +/// Block categories for library organization +enum BlockCategory: String, CaseIterable, Identifiable { + case triggers = "Triggers" + case filters = "Filters" + case modifiers = "Message Modifiers" + case ai = "AI Blocks" + case messaging = "Actions" + case moderation = "Moderation" + case channel = "Channel" + case integration = "Integration" + case logging = "Logging" + case bot = "Bot" + case utility = "Utilities" + + var id: String { rawValue } + var symbol: String { + switch self { + case .triggers: return "bolt.fill" + case .filters: return "line.3.horizontal.decrease.circle" + case .modifiers: return "slider.horizontal.3" + case .ai: return "sparkles" + case .messaging: return "paperplane.fill" + case .moderation: return "shield.fill" + case .channel: return "number" + case .integration: return "link" + case .logging: return "list.bullet.clipboard" + case .bot: return "cpu.fill" + case .utility: return "wrench.fill" + } + } +} struct Condition: Identifiable, Codable, Equatable { var id = UUID() var type: ConditionType @@ -2907,6 +3332,22 @@ 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) enum CodingKeys: String, CodingKey { case id @@ -2918,6 +3359,21 @@ 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 } init() {} @@ -2933,6 +3389,21 @@ 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 } func encode(to encoder: Encoder) throws { @@ -2946,6 +3417,21 @@ struct RuleAction: Identifiable, Codable, Equatable { try container.encode(replyWithAI, 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) } } @@ -2954,9 +3440,10 @@ 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 isEnabled: Bool = true var triggerServerId: String = "" @@ -2966,7 +3453,52 @@ struct Rule: Identifiable, Codable, Equatable { var includeStageChannels: Bool = true + var isEmptyRule: Bool { + trigger == nil && conditions.isEmpty && actions.isEmpty && modifiers.isEmpty + } + + static func empty() -> Rule { + Rule(trigger: nil, conditions: [], modifiers: [], actions: []) + } + + /// Provides the full pipeline of blocks for the rule engine, including migrated legacy toggles + var processedActions: [RuleAction] { + var pipeline: [RuleAction] = [] + + // Add explicit modifiers + pipeline.append(contentsOf: modifiers) + + // Convert legacy toggles from actions into modifier blocks if they aren't already represented + for action in actions { + var actionWithModifiers = action + + if action.replyWithAI { + var aiBlock = RuleAction() + aiBlock.type = .generateAIResponse + pipeline.append(aiBlock) + actionWithModifiers.replyWithAI = false + } + + if action.replyToTriggerMessage { + var replyBlock = RuleAction() + replyBlock.type = .replyToTrigger + pipeline.append(replyBlock) + actionWithModifiers.replyToTriggerMessage = false + } + + if !action.mentionUser { // Default was true in legacy + // We'll skip adding a "No Mention" for now to keep it simple, + // or add it if we have a specific block for it. + } + + 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" @@ -2974,6 +3506,131 @@ struct Rule: Identifiable, Codable, Equatable { case .messageContains: return triggerMessageContains.isEmpty ? "When message contains text" : "When message contains \"\(triggerMessageContains)\"" case .memberJoined: return "When a member joins the server" + case .reactionAdded: return "When a reaction is added" + case .slashCommand: return "When a slash command is used" + } + } + + /// Validates the rule and returns any issues found + var validationIssues: [ValidationIssue] { + var issues: [ValidationIssue] = [] + + // Get variables available from trigger + let availableVariables = trigger?.providedVariables ?? [] + + // Check conditions for variable availability + for condition in conditions where condition.enabled { + let requiredVars = condition.type.requiredVariables + let missingVars = requiredVars.subtracting(availableVariables) + if !missingVars.isEmpty { + issues.append(.init( + severity: .error, + message: "Condition '\(condition.type.rawValue)' requires variables not available: \(missingVars.map(\.displayName).joined(separator: ", "))", + 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: .error, + message: "Modifier '\(modifier.type.rawValue)' requires context not available: \(missingVars.map(\.displayName).joined(separator: ", "))", + 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: .error, + message: "Action '\(action.type.rawValue)' requires context not available: \(missingVars.map(\.displayName).joined(separator: ", "))", + 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 + )) + } + } + + 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 trigger = "Trigger" + case condition = "Filter" + case modifier = "Modifier" + case action = "Action" } } diff --git a/SwiftBotApp/VoiceActionsView.swift b/SwiftBotApp/VoiceActionsView.swift index 7919a64..14e588d 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,11 @@ struct RuleEditorView: View { @Binding var rule: Rule @EnvironmentObject var app: AppModel + @AppStorage("hasSeenRuleOnboarding") private var hasSeenRuleOnboarding: Bool = false + @State private var showOnboardingCard = 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 @@ -148,17 +154,22 @@ 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 + applyTriggerDefaults(for: type) + if guidedStep == .trigger { guidedStep = .action } + }, + focusTrigger: { + if let trigger = rule.trigger { + applyTriggerDefaults(for: trigger) + } + }, + scrollToTriggersSignal: $scrollToTriggersSignal ) - .padding(.horizontal, 18) - .padding(.top, 20) - .padding(.bottom, 16) - } } .frame(minWidth: 250, idealWidth: 270, maxWidth: 300) .background(rulePaneBackground) @@ -192,44 +203,94 @@ struct RuleEditorView: View { .padding(.bottom, 16) .background(rulePaneBackground) + if !rule.validationIssues.isEmpty { + ValidationBannerView(issues: rule.validationIssues) + .padding(.horizontal, 20) + .padding(.bottom, 8) + } + 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] ?? [] + if rule.isEmptyRule { + EmptyRuleOnboardingView { + scrollToTriggersSignal = true + } + .transition( + .asymmetric( + insertion: .opacity.combined(with: .scale(scale: 0.96)), + removal: .opacity.combined(with: .scale(scale: 0.96)) + ) ) - } + } else { + RuleCanvasSection(title: "Trigger Block", systemImage: "bolt.fill", accent: .yellow, + guidedHighlight: guidedStep == .trigger) { + 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] ?? [] + ) + 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.trigger = nil + guidedStep = .trigger + } label: { + Label("Change Trigger", systemImage: "arrow.triangle.2.circlepath") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + .padding(.top, 4) + } - RuleFlowArrow() + 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] ?? [] - ) - } + 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() + RuleFlowArrow() - RuleCanvasSection(title: "Action Blocks", systemImage: "paperplane.fill", accent: .mint) { - ActionsSectionView( - actions: $rule.actions, - serverIds: serverIds, - serverName: serverName(for:), - textChannelsByServer: app.availableTextChannelsByServer - ) + RuleCanvasSection(title: "Message Modifiers", systemImage: "slider.horizontal.3", accent: .orange) { + ActionsSectionView( + actions: $rule.modifiers, + serverIds: serverIds, + serverName: serverName(for:), + textChannelsByServer: app.availableTextChannelsByServer + ) + } + + RuleFlowArrow() + + RuleCanvasSection(title: "Action Blocks", systemImage: "paperplane.fill", accent: .mint, + guidedHighlight: guidedStep == .action) { + ActionsSectionView( + actions: $rule.actions, + serverIds: serverIds, + serverName: serverName(for:), + textChannelsByServer: app.availableTextChannelsByServer, + isGuided: guidedStep == .action + ) + } } } + .animation(.easeInOut(duration: 0.22), value: rule.isEmptyRule) .frame(maxWidth: 880, alignment: .leading) .padding(.horizontal, 20) .padding(.vertical, 20) @@ -238,14 +299,38 @@ struct RuleEditorView: View { .background(rulePaneBackground) } .navigationTitle("") + .sheet(isPresented: $showOnboardingCard) { + FirstRuleOnboardingCard( + onCreateExample: { + showOnboardingCard = false + hasSeenRuleOnboarding = true + applyExampleRule() + }, + onStartEmpty: { + showOnboardingCard = false + hasSeenRuleOnboarding = true + guidedStep = .trigger + } + ) + } .onAppear { initializeRuleDefaultsIfNeeded() + if !hasSeenRuleOnboarding && rule.actions.isEmpty { + showOnboardingCard = true + } + } + .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) + } } } @@ -259,7 +344,7 @@ struct RuleEditorView: View { 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: @@ -267,7 +352,18 @@ struct RuleEditorView: View { 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, .sendToDM, .sendToChannel: + break + // AI block + case .generateAIResponse: + action.message = "You are a helpful assistant. {message}" + // Other action types + case .replyToMessage, .sendDM, .deleteMessage, .addReaction, .addRole, .removeRole, + .timeoutMember, .kickMember, .moveMember, .createChannel, .webhook, .delay, + .setVariable, .randomChoice: + break } rule.actions.append(action) @@ -282,15 +378,9 @@ struct RuleEditorView: View { 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 { + // Fix missing server/channel IDs on existing actions (legacy rules). + // Never pre-populate a default action — empty rules show the empty-state UI. + if !rule.actions.isEmpty { if rule.actions[0].serverId.isEmpty, let first = serverIds.first { rule.actions[0].serverId = first didChange = true @@ -309,6 +399,22 @@ struct RuleEditorView: View { } } + private func applyExampleRule() { + rule.name = "Hello World" + rule.trigger = .messageContains + rule.triggerMessageContains = "@swiftbot hello" + rule.triggerServerId = serverIds.first ?? "" + + var action = RuleAction() + action.type = .sendMessage + action.serverId = serverIds.first ?? "" + action.channelId = app.availableTextChannelsByServer[action.serverId]?.first?.id ?? "" + action.message = "Hello World 👋" + rule.actions = [action] + + app.ruleStore.scheduleAutoSave() + } + private func applyTriggerDefaults(for newTrigger: TriggerType) { let defaults = TriggerType.allDefaultMessages var didChange = false @@ -347,25 +453,39 @@ 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 + + @State private var triggerSectionHighlighted: Bool = false + private let triggersSectionID = "library-triggers" + + private func types(for category: BlockCategory) -> [ActionType] { + ActionType.allCases.filter { $0.category == category } + } 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) { + RuleLibrarySection(title: "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", + subtitle: "Add a filter condition", systemImage: type.symbol, accent: .cyan, action: { onAddCondition(type) } @@ -373,26 +493,105 @@ struct RuleBuilderLibraryView: View { } } - 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 modifiers = types(for: .modifiers) + if !modifiers.isEmpty { + RuleLibrarySection(title: "Message Modifiers") { + ForEach(modifiers) { type in + RuleLibraryButton( + title: type.rawValue, + subtitle: "Modify message routing or formatting", + systemImage: type.symbol, + accent: .orange, + 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 aiBlocks = types(for: .ai) + if !aiBlocks.isEmpty { + RuleLibrarySection(title: "AI") { + ForEach(aiBlocks) { type in + RuleLibraryButton( + title: type.rawValue, + subtitle: "Generate content using AI", + systemImage: type.symbol, + accent: .indigo, + action: { onAddAction(type) } + ) + } + } + } + + let messagingTypes = types(for: .messaging) + if !messagingTypes.isEmpty { + RuleLibrarySection(title: "Actions") { + ForEach(messagingTypes) { type in + RuleLibraryButton( + title: type.rawValue, + subtitle: "Insert action into pipeline", + systemImage: type.symbol, + accent: .mint, + action: { onAddAction(type) } + ) + } + } + } + + let moderationTypes = types(for: .moderation) + if !moderationTypes.isEmpty { + RuleLibrarySection(title: "Moderation") { + ForEach(moderationTypes) { type in + RuleLibraryButton( + title: type.rawValue, + subtitle: "Moderation action", + systemImage: type.symbol, + accent: .red, + action: { onAddAction(type) } + ) + } + } + } + + let utilityTypes = types(for: .utility) + if !utilityTypes.isEmpty { + RuleLibrarySection(title: "Utilities") { + ForEach(utilityTypes) { type in + RuleLibraryButton( + title: type.rawValue, + subtitle: "Insert utility block", + 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) + .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 +625,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) } } @@ -476,6 +686,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 +702,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,7 +743,7 @@ struct RuleGroupSection: View { } struct TriggerSectionView: View { - @Binding var triggerType: TriggerType + @Binding var triggerType: TriggerType? @Binding var triggerServerId: String @Binding var triggerVoiceChannelId: String @Binding var triggerMessageContains: String @@ -545,8 +757,9 @@ struct TriggerSectionView: View { var body: some View { VStack(alignment: .leading, spacing: 10) { Picker("Event", selection: $triggerType) { + Text("Select a trigger...").tag(TriggerType?.none) ForEach(TriggerType.allCases) { trigger in - Label(trigger.rawValue, systemImage: trigger.symbol).tag(trigger) + Label(trigger.rawValue, systemImage: trigger.symbol).tag(Optional(trigger)) } } @@ -556,22 +769,24 @@ struct TriggerSectionView: View { } } - 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 let type = triggerType { + if type != .messageContains, type != .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 type == .messageContains { + TextField("Message contains…", text: $triggerMessageContains) + Toggle("Reply to DMs", isOn: $replyToDMs) + } - if triggerType == .userJoinedVoice || triggerType == .userMovedVoice { - Toggle("Include Stage Channels", isOn: $includeStageChannels) + if type == .userJoinedVoice || type == .userMovedVoice { + Toggle("Include Stage Channels", isOn: $includeStageChannels) + } } } } @@ -674,6 +889,13 @@ struct ConditionRowView: View { Text("minutes in channel") .foregroundStyle(.secondary) } + // New condition types - placeholder UI + case .channelIs: + TextField("Channel ID", text: $condition.value) + .foregroundStyle(.secondary) + case .userHasRole: + TextField("Role ID", text: $condition.value) + .foregroundStyle(.secondary) } } } @@ -684,18 +906,27 @@ struct ActionsSectionView: View { let serverIds: [String] let serverName: (String) -> String let textChannelsByServer: [String: [GuildTextChannel]] + var isGuided: Bool = false 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 actions yet") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + Text(isGuided + ? "Select an action from the Block Library to the left." + : "Use the Block Library to add your first action.") + .font(.caption) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) } else { ForEach($actions) { $action in ActionSectionView( @@ -747,17 +978,6 @@ struct ActionSectionView: View { Text(serverName(serverId)).tag(serverId) } } - } - - 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.") - .font(.caption) - .foregroundStyle(.secondary) - } else { if textChannels.isEmpty { Text("No text channels discovered for this server.") .foregroundStyle(.secondary) @@ -771,22 +991,9 @@ struct ActionSectionView: View { } VStack(alignment: .leading, spacing: 6) { - Text(action.replyWithAI ? "AI Prompt" : "Message") + Text("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) - ) - } - if action.replyWithAI { - Text("AI will generate the final reply from this prompt template.") - .font(.caption) - .foregroundStyle(.secondary) + VariableAwareTextEditor(text: $action.message) } case .addLogEntry: TextField("Log message", text: $action.message) @@ -798,9 +1005,100 @@ struct ActionSectionView: View { .font(.caption) .foregroundStyle(.secondary) } + // New action types + case .replyToMessage: + VStack(alignment: .leading, spacing: 6) { + Text("Reply Message") + .font(.subheadline.weight(.semibold)) + VariableAwareTextEditor(text: $action.message) + } + case .sendDM: + Toggle("Mention user", isOn: $action.mentionUser) + VStack(alignment: .leading, spacing: 6) { + Text("DM Content") + .font(.subheadline.weight(.semibold)) + VariableAwareTextEditor(text: $action.dmContent) + } + case .deleteMessage: + Text("Delete the triggering message") + .foregroundStyle(.secondary) + case .addReaction: + TextField("Emoji", text: $action.emoji) + case .addRole, .removeRole: + TextField("Role ID", text: $action.roleId) + 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: + TextField("Target Voice Channel ID", text: $action.targetVoiceChannelId) + 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: + TextField("Role ID to mention", text: $action.roleId) + case .sendToDM: + Text("Routes the message to the triggering user's DMs.") + .font(.caption) + .foregroundStyle(.secondary) + case .sendToChannel: + if textChannels.isEmpty { + Text("No text channels discovered for this server.") + .foregroundStyle(.secondary) + } else { + Picker("Target Channel", selection: $action.channelId) { + ForEach(textChannels) { channel in + Text("#\(channel.name)").tag(channel.id) + } + } + } + // AI block + 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 subsequent blocks.") + .font(.caption) + .foregroundStyle(.secondary) + } } - Text("Use placeholders in messages: {userId}, {username}, {channelId}, {channelName}, {guildName}, {duration}") + Text("Type { to insert a variable placeholder") .font(.caption) .foregroundStyle(.secondary) } @@ -819,3 +1117,226 @@ struct ActionSectionView: View { } } } + +// MARK: - Guided Build Step + +enum GuidedBuildStep { + case none, trigger, action +} + +// MARK: - First Rule Onboarding Card + +struct FirstRuleOnboardingCard: View { + let onCreateExample: () -> Void + let onStartEmpty: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + HStack(spacing: 12) { + Image(systemName: "wand.and.stars") + .font(.title) + .foregroundStyle(.yellow) + VStack(alignment: .leading, spacing: 4) { + Text("First Time Using SwiftBot Rules?") + .font(.headline) + Text("Automations are built from triggers, filters, and actions.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Label("**Trigger** — what event starts the rule", systemImage: "bolt.fill") + .font(.subheadline) + Label("**Filters** — optional conditions to narrow scope", systemImage: "line.3.horizontal.decrease.circle") + .font(.subheadline) + Label("**Actions** — what the bot does when triggered", systemImage: "paperplane.fill") + .font(.subheadline) + } + .foregroundStyle(.secondary) + + Divider() + + HStack(spacing: 12) { + Button(action: onCreateExample) { + Label("Create Example Rule", systemImage: "sparkles") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + + Button(action: onStartEmpty) { + Text("Start Empty") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.large) + } + } + .padding(28) + .frame(width: 440) + } +} + +// MARK: - Validation Banner + +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 + + 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(.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)) + } + .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 + } + } + } + } +} + +// 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) + } +} + + diff --git a/SwiftBotApp/VoiceRuleListView.swift b/SwiftBotApp/VoiceRuleListView.swift index abbf26d..aa27b4f 100644 --- a/SwiftBotApp/VoiceRuleListView.swift +++ b/SwiftBotApp/VoiceRuleListView.swift @@ -5,6 +5,7 @@ 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) { @@ -14,31 +15,40 @@ struct RuleListView: View { systemImage: "point.3.filled.connected.trianglepath.dotted" ) - 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: "point.3.filled.connected.trianglepath.dotted") + .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 From f19c5dd3304f555d18dbbf8140379f38250dd29f Mon Sep 17 00:00:00 2001 From: johnwatso Date: Wed, 11 Mar 2026 14:44:32 +1300 Subject: [PATCH 02/18] [Beta] Ongoing Work with Actions Updating Actions builder Fine tuning action rule builder This is a beta and should be treated as such --- SwiftBotApp/AppModel+Gateway.swift | 2 + SwiftBotApp/AppModel.swift | 62 ++++++++- SwiftBotApp/DiscordService.swift | 26 ++-- SwiftBotApp/Models.swift | 214 ++++++++++++++++++++++++----- SwiftBotApp/VoiceActionsView.swift | 144 +++++++++---------- 5 files changed, 314 insertions(+), 134 deletions(-) diff --git a/SwiftBotApp/AppModel+Gateway.swift b/SwiftBotApp/AppModel+Gateway.swift index 26e1dc1..b1be6cf 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: diff --git a/SwiftBotApp/AppModel.swift b/SwiftBotApp/AppModel.swift index 590dbec..10c5441 100644 --- a/SwiftBotApp/AppModel.swift +++ b/SwiftBotApp/AppModel.swift @@ -3379,6 +3379,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,7 +3404,8 @@ final class AppModel: ObservableObject { triggerChannelId: nil, triggerGuildId: guildId, triggerUserId: userId, - isDirectMessage: false + isDirectMessage: false, + joinedAt: joinedAt ) let matchedRules = ruleEngine.evaluateRules(event: ruleEvent) for rule in matchedRules { @@ -3417,6 +3427,56 @@ 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, + 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 4daa81d..78b39f1 100644 --- a/SwiftBotApp/DiscordService.swift +++ b/SwiftBotApp/DiscordService.swift @@ -2068,7 +2068,8 @@ actor DiscordService { triggerChannelId: nil, triggerGuildId: guildId, triggerUserId: userId, - isDirectMessage: false + isDirectMessage: false, + joinedAt: nil ) } @@ -2092,7 +2093,8 @@ actor DiscordService { triggerChannelId: nil, triggerGuildId: guildId, triggerUserId: userId, - isDirectMessage: false + isDirectMessage: false, + joinedAt: nil ) } @@ -2116,7 +2118,8 @@ actor DiscordService { triggerChannelId: nil, triggerGuildId: guildId, triggerUserId: userId, - isDirectMessage: false + isDirectMessage: false, + joinedAt: nil ) } @@ -2159,7 +2162,8 @@ actor DiscordService { triggerChannelId: channelId, triggerGuildId: guildId, triggerUserId: userId, - isDirectMessage: isDirectMessage + isDirectMessage: isDirectMessage, + joinedAt: nil ) } @@ -2304,7 +2308,7 @@ actor DiscordService { return "User \(userId.suffix(4))" } - private func execute(action: Action, for event: VoiceRuleEvent, context: inout PipelineContext) async { + func execute(action: Action, for event: VoiceRuleEvent, context: inout PipelineContext) async { guard let token = botToken else { return } switch action.type { @@ -2356,18 +2360,6 @@ actor DiscordService { let statusText = renderMessage(template: action.statusText, event: event, context: context) guard !statusText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } await updatePresence(text: statusText) - case .replyToMessage: - guard let triggerMessageId = event.triggerMessageId, let triggerChannelId = event.triggerChannelId else { return } - let rendered = renderMessage(template: action.message, event: event, context: context) - 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) case .sendDM: let rendered = renderMessage(template: action.dmContent, event: event, context: context) _ = try? await sendDM(userId: event.userId, content: rendered) diff --git a/SwiftBotApp/Models.swift b/SwiftBotApp/Models.swift index 472b99b..aa46036 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,7 @@ struct VoiceRuleEvent { let triggerGuildId: String let triggerUserId: String let isDirectMessage: Bool + let joinedAt: Date? } @MainActor @@ -2539,20 +2541,11 @@ final class RuleEngine { private func matchesTrigger(rule: Rule, event: VoiceRuleEvent) -> Bool { guard let trigger = rule.trigger else { return false } switch (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): + case (.userJoinedVoice, .join), + (.userLeftVoice, .leave), + (.userMovedVoice, .move), + (.messageCreated, .message), + (.memberJoined, .memberJoin): return true default: return false @@ -2572,24 +2565,44 @@ 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 } } } @@ -3033,23 +3046,43 @@ enum DiscordPermission: String, CaseIterable, Codable, Hashable { // 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" } @@ -3060,8 +3093,9 @@ 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!" } @@ -3072,8 +3106,9 @@ 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" } @@ -3084,9 +3119,9 @@ enum TriggerType: String, CaseIterable, Identifiable, Codable { switch self { case .userJoinedVoice, .userLeftVoice, .userMovedVoice: return [.user, .userId, .username, .userMention, .voiceChannel, .voiceChannelId, .guild, .guildId, .guildName, .duration] - case .messageContains: + case .messageCreated: return [.user, .userId, .username, .userMention, .message, .messageId, .channel, .channelId, .channelName, .guild, .guildId, .guildName] - case .memberJoined: + 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] @@ -3111,7 +3146,12 @@ enum ConditionType: String, CaseIterable, Identifiable, Codable { 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" var id: String { rawValue } @@ -3122,7 +3162,12 @@ enum ConditionType: String, CaseIterable, Identifiable, Codable { 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" } } @@ -3137,10 +3182,12 @@ enum ConditionType: String, CaseIterable, Identifiable, Codable { return [.user, .username] case .minimumDuration: return [.duration] - case .channelIs: + case .channelIs, .channelCategory: return [.channel, .channelId] - case .userHasRole: + case .userHasRole, .userJoinedRecently: return [.user, .userId] + case .messageContains, .messageStartsWith, .messageRegex: + return [.message] } } } @@ -3149,7 +3196,6 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { case sendMessage = "Send Message" case addLogEntry = "Add Log Entry" case setStatus = "Set Bot Status" - case replyToMessage = "Reply to Message" case sendDM = "Send DM" case deleteMessage = "Delete Message" case addReaction = "Add Reaction" @@ -3181,7 +3227,6 @@ 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 .replyToMessage: return "arrow.turn.down.left" case .sendDM: return "envelope.fill" case .deleteMessage: return "trash.fill" case .addReaction: return "face.smiling" @@ -3209,8 +3254,9 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { switch self { case .sendMessage, .sendDM, .setStatus, .addLogEntry, .delay, .setVariable, .randomChoice, .createChannel, .webhook: return [] - case .replyToMessage, .deleteMessage, .addReaction, .replyToTrigger: + case .deleteMessage, .addReaction, .replyToTrigger: return [.message, .messageId] + case .addRole, .removeRole, .timeoutMember, .kickMember, .moveMember, .mentionUser: return [.user, .userId] case .sendToChannel: @@ -3225,7 +3271,7 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { switch self { case .generateAIResponse: return [.aiResponse] - case .sendMessage, .replyToMessage, .sendDM, .deleteMessage, .addReaction, .addRole, + case .sendMessage, .sendDM, .deleteMessage, .addReaction, .addRole, .removeRole, .timeoutMember, .kickMember, .moveMember, .createChannel, .webhook, .setStatus, .addLogEntry, .delay, .setVariable, .randomChoice, .replyToTrigger, .mentionUser, .mentionRole, .sendToDM, .sendToChannel: @@ -3236,7 +3282,7 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { /// Discord permissions required for this action var requiredPermissions: Set { switch self { - case .sendMessage, .replyToMessage, .sendDM, .addLogEntry, .setStatus, .delay, .setVariable, .randomChoice, .generateAIResponse, .mentionUser, .mentionRole, .sendToDM, .sendToChannel, .replyToTrigger: + case .sendMessage, .sendDM, .addLogEntry, .setStatus, .delay, .setVariable, .randomChoice, .generateAIResponse, .mentionUser, .mentionRole, .sendToDM, .sendToChannel, .replyToTrigger: return [] case .deleteMessage: return [.manageMessages] @@ -3260,8 +3306,9 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { /// Category for block library organization var category: BlockCategory { switch self { - case .sendMessage, .replyToMessage, .sendDM, .deleteMessage, .addReaction: + case .sendMessage, .sendDM, .deleteMessage, .addReaction: return .messaging + case .addRole, .removeRole, .timeoutMember, .kickMember, .moveMember: return .moderation case .createChannel: @@ -3446,13 +3493,42 @@ struct Rule: Identifiable, Codable, Equatable { var actions: [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 + /// 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 + ) { + 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 + } + var isEmptyRule: Bool { trigger == nil && conditions.isEmpty && actions.isEmpty && modifiers.isEmpty } @@ -3461,6 +3537,58 @@ struct Rule: Identifiable, Codable, Equatable { Rule(trigger: nil, conditions: [], modifiers: [], actions: []) } + // MARK: - Codable Migration + + /// Coding keys for Rule + enum CodingKeys: String, CodingKey { + case id, name, trigger, conditions, modifiers, actions, isEnabled + case triggerServerId, triggerVoiceChannelId, triggerMessageContains, replyToDMs, includeStageChannels + } + + /// Custom decoder that migrates legacy trigger properties into filter conditions + 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) + 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) + } + } + /// Provides the full pipeline of blocks for the rule engine, including migrated legacy toggles var processedActions: [RuleAction] { var pipeline: [RuleAction] = [] @@ -3503,9 +3631,9 @@ struct Rule: Identifiable, Codable, Equatable { 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" } @@ -3569,6 +3697,16 @@ struct Rule: Identifiable, Codable, Equatable { )) } + // Task 5: Prevent empty Send Message actions + if action.type == .sendMessage && 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 + )) + } + // Check permissions (warnings, not errors - bot may have permissions) let requiredPerms = action.type.requiredPermissions if !requiredPerms.isEmpty { diff --git a/SwiftBotApp/VoiceActionsView.swift b/SwiftBotApp/VoiceActionsView.swift index 14e588d..5b86eab 100644 --- a/SwiftBotApp/VoiceActionsView.swift +++ b/SwiftBotApp/VoiceActionsView.swift @@ -225,15 +225,7 @@ struct RuleEditorView: View { RuleCanvasSection(title: "Trigger Block", systemImage: "bolt.fill", accent: .yellow, guidedHighlight: guidedStep == .trigger) { 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] ?? [] + triggerType: $rule.trigger ) if guidedStep == .trigger { Label("Select a trigger from the Block Library to begin.", systemImage: "arrow.left") @@ -261,7 +253,7 @@ struct RuleEditorView: View { conditions: $rule.conditions, serverIds: serverIds, serverName: serverName(for:), - voiceChannels: app.availableVoiceChannelsByServer[rule.triggerServerId] ?? [] + voiceChannels: app.availableVoiceChannelsByServer.values.flatMap { $0 } ) } @@ -270,6 +262,8 @@ struct RuleEditorView: View { RuleCanvasSection(title: "Message Modifiers", systemImage: "slider.horizontal.3", accent: .orange) { ActionsSectionView( actions: $rule.modifiers, + category: .modifiers, + allModifiers: rule.modifiers, serverIds: serverIds, serverName: serverName(for:), textChannelsByServer: app.availableTextChannelsByServer @@ -282,6 +276,8 @@ struct RuleEditorView: View { guidedHighlight: guidedStep == .action) { ActionsSectionView( actions: $rule.actions, + category: .messaging, + allModifiers: rule.modifiers, serverIds: serverIds, serverName: serverName(for:), textChannelsByServer: app.availableTextChannelsByServer, @@ -360,7 +356,7 @@ struct RuleEditorView: View { case .generateAIResponse: action.message = "You are a helpful assistant. {message}" // Other action types - case .replyToMessage, .sendDM, .deleteMessage, .addReaction, .addRole, .removeRole, + case .sendDM, .deleteMessage, .addReaction, .addRole, .removeRole, .timeoutMember, .kickMember, .moveMember, .createChannel, .webhook, .delay, .setVariable, .randomChoice: break @@ -373,11 +369,6 @@ struct RuleEditorView: View { private func initializeRuleDefaultsIfNeeded() { var didChange = false - if rule.triggerServerId.isEmpty { - rule.triggerServerId = serverIds.first ?? "" - didChange = true - } - // Fix missing server/channel IDs on existing actions (legacy rules). // Never pre-populate a default action — empty rules show the empty-state UI. if !rule.actions.isEmpty { @@ -401,9 +392,11 @@ struct RuleEditorView: View { private func applyExampleRule() { rule.name = "Hello World" - rule.trigger = .messageContains - rule.triggerMessageContains = "@swiftbot hello" - rule.triggerServerId = serverIds.first ?? "" + rule.trigger = .messageCreated + + var filter = Condition(type: .messageContains) + filter.value = "@swiftbot hello" + rule.conditions = [filter] var action = RuleAction() action.type = .sendMessage @@ -432,9 +425,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 } @@ -744,49 +738,12 @@ 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] var body: some View { - VStack(alignment: .leading, spacing: 10) { - Picker("Event", selection: $triggerType) { - Text("Select a trigger...").tag(TriggerType?.none) - ForEach(TriggerType.allCases) { trigger in - Label(trigger.rawValue, systemImage: trigger.symbol).tag(Optional(trigger)) - } - } - - Picker("Server", selection: $triggerServerId) { - ForEach(serverIds, id: \.self) { serverId in - Text(serverName(serverId)).tag(serverId) - } - } - - if let type = triggerType { - if type != .messageContains, type != .memberJoined, !voiceChannels.isEmpty { - Picker("Voice Channel", selection: $triggerVoiceChannelId) { - Text("Any Channel").tag("") - ForEach(voiceChannels) { channel in - Text(channel.name).tag(channel.id) - } - } - } - - if type == .messageContains { - TextField("Message contains…", text: $triggerMessageContains) - Toggle("Reply to DMs", isOn: $replyToDMs) - } - - if type == .userJoinedVoice || type == .userMovedVoice { - Toggle("Include Stage Channels", isOn: $includeStageChannels) - } + Picker("Event", selection: $triggerType) { + Text("Select a trigger...").tag(TriggerType?.none) + ForEach(TriggerType.allCases) { trigger in + Label(trigger.rawValue, systemImage: trigger.symbol).tag(Optional(trigger)) } } } @@ -881,27 +838,39 @@ 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: - TextField("Channel ID", text: $condition.value) + TextField("Enter channel name or ID…", text: $condition.value) + case .channelCategory: + TextField("Enter category name or ID…", text: $condition.value) .foregroundStyle(.secondary) case .userHasRole: - TextField("Role ID", text: $condition.value) + TextField("Enter role name or ID…", text: $condition.value) + case .userJoinedRecently: + TextField("Joined within (days)…", text: $condition.value) .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) } } } struct ActionsSectionView: View { @Binding var actions: [Action] + let category: BlockCategory + let allModifiers: [Action] let serverIds: [String] let serverName: (String) -> String @@ -915,12 +884,12 @@ struct ActionsSectionView: View { Image(systemName: "rectangle.stack.badge.plus") .font(.title2) .foregroundStyle(.secondary) - Text("No actions yet") + Text("No blocks yet") .font(.subheadline.weight(.medium)) .foregroundStyle(.secondary) Text(isGuided - ? "Select an action from the Block Library to the left." - : "Use the Block Library to add your first action.") + ? "Select a block from the Block Library to the left." + : "Use the Block Library to add your first block.") .font(.caption) .foregroundStyle(.tertiary) .multilineTextAlignment(.center) @@ -931,6 +900,8 @@ struct ActionsSectionView: View { ForEach($actions) { $action in ActionSectionView( action: $action, + category: category, + allModifiers: allModifiers, serverIds: serverIds, serverName: serverName, textChannels: textChannelsByServer[action.serverId] ?? [], @@ -946,6 +917,8 @@ struct ActionsSectionView: View { struct ActionSectionView: View { @Binding var action: Action + let category: BlockCategory + let allModifiers: [Action] let serverIds: [String] let serverName: (String) -> String @@ -955,8 +928,8 @@ struct ActionSectionView: View { var body: some View { VStack(alignment: .leading, spacing: 10) { HStack { - Picker("Action", selection: $action.type) { - ForEach(ActionType.allCases) { actionType in + Picker(category == .modifiers ? "Modifier" : "Action", selection: $action.type) { + ForEach(ActionType.allCases.filter { $0.category == category }) { actionType in Label(actionType.rawValue, systemImage: actionType.symbol).tag(actionType) } } @@ -994,6 +967,27 @@ struct ActionSectionView: View { 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) + } + } + + // UI refinement: show active modifiers that affect this action + if category == .messaging { + 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) @@ -1006,12 +1000,6 @@ struct ActionSectionView: View { .foregroundStyle(.secondary) } // New action types - case .replyToMessage: - VStack(alignment: .leading, spacing: 6) { - Text("Reply Message") - .font(.subheadline.weight(.semibold)) - VariableAwareTextEditor(text: $action.message) - } case .sendDM: Toggle("Mention user", isOn: $action.mentionUser) VStack(alignment: .leading, spacing: 6) { From 450a164f5c5a2af23f6812166786446f6dd339e8 Mon Sep 17 00:00:00 2001 From: johnwatso Date: Wed, 11 Mar 2026 15:10:28 +1300 Subject: [PATCH 03/18] [Beta] Ongoing Action Builder Changes Known Issues: Modifiers don't work WEBUI will show rules but will not create them due to new logic Causes repeating errors to pop up in the GUI of the Server App --- SwiftBotApp/DiscordService.swift | 17 +++++++++++++---- SwiftBotApp/Models.swift | 29 +++++++++++++++++++---------- SwiftBotApp/VoiceActionsView.swift | 22 +++++++++++++--------- 3 files changed, 45 insertions(+), 23 deletions(-) diff --git a/SwiftBotApp/DiscordService.swift b/SwiftBotApp/DiscordService.swift index 78b39f1..87eae7b 100644 --- a/SwiftBotApp/DiscordService.swift +++ b/SwiftBotApp/DiscordService.swift @@ -2030,9 +2030,14 @@ actor DiscordService { var context = PipelineContext() context.isDirectMessage = ruleResult.isDM - for action in ruleResult.actions { + 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)") } + + discordLogger.debug("Rule pipeline execution complete.") } } @@ -2313,11 +2318,11 @@ actor DiscordService { switch action.type { case .mentionUser: - context.mentionUser = true + context.prependUserMention = true case .mentionRole: context.mentionRole = action.roleId - case .sendToDM: - context.targetChannelId = nil // Signifies DM + case .disableMention: + context.mentionUser = false case .sendToChannel: context.targetChannelId = action.channelId case .replyToTrigger: @@ -2427,6 +2432,10 @@ actor DiscordService { output = output.replacingOccurrences(of: "<@\(event.userId)>", with: event.username) } + if context.prependUserMention { + output = "<@\(event.userId)> " + output + } + if let roleMention = context.mentionRole { output = "<@&\(roleMention)> " + output } diff --git a/SwiftBotApp/Models.swift b/SwiftBotApp/Models.swift index aa46036..4701b70 100644 --- a/SwiftBotApp/Models.swift +++ b/SwiftBotApp/Models.swift @@ -2511,14 +2511,21 @@ final class RuleStore: ObservableObject { } /// Context maintained during a single rule execution pipeline -struct PipelineContext { +struct PipelineContext: CustomStringConvertible { var aiResponse: 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 description: String { + let ai = aiResponse != nil ? "AI(\(aiResponse!.count) chars)" : "nil" + let target = targetChannelId ?? "default" + return "[PipelineContext target: \(target), mentionUser: \(mentionUser), prepend: \(prependUserMention), reply: \(replyToTriggerMessage), role: \(mentionRole ?? "nil"), ai: \(ai)]" + } } @MainActor @@ -3214,7 +3221,7 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { case replyToTrigger = "Reply To Trigger Message" case mentionUser = "Mention User" case mentionRole = "Mention Role" - case sendToDM = "Send To DM" + case disableMention = "Disable User Mentions" case sendToChannel = "Send To Channel" // AI Types @@ -3243,7 +3250,7 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { case .replyToTrigger: return "arrowshape.turn.up.left.fill" case .mentionUser: return "at" case .mentionRole: return "at.badge.plus" - case .sendToDM: return "envelope.fill" + case .disableMention: return "at.badge.minus" case .sendToChannel: return "number.circle.fill" case .generateAIResponse: return "sparkles" } @@ -3257,11 +3264,11 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { case .deleteMessage, .addReaction, .replyToTrigger: return [.message, .messageId] - case .addRole, .removeRole, .timeoutMember, .kickMember, .moveMember, .mentionUser: + case .addRole, .removeRole, .timeoutMember, .kickMember, .moveMember, .mentionUser, .disableMention: return [.user, .userId] case .sendToChannel: return [.channel] - case .generateAIResponse, .mentionRole, .sendToDM: + case .generateAIResponse, .mentionRole: return [] } } @@ -3274,7 +3281,7 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { case .sendMessage, .sendDM, .deleteMessage, .addReaction, .addRole, .removeRole, .timeoutMember, .kickMember, .moveMember, .createChannel, .webhook, .setStatus, .addLogEntry, .delay, .setVariable, .randomChoice, .replyToTrigger, - .mentionUser, .mentionRole, .sendToDM, .sendToChannel: + .mentionUser, .mentionRole, .disableMention, .sendToChannel: return [] } } @@ -3282,7 +3289,7 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { /// Discord permissions required for this action var requiredPermissions: Set { switch self { - case .sendMessage, .sendDM, .addLogEntry, .setStatus, .delay, .setVariable, .randomChoice, .generateAIResponse, .mentionUser, .mentionRole, .sendToDM, .sendToChannel, .replyToTrigger: + case .sendMessage, .sendDM, .addLogEntry, .setStatus, .delay, .setVariable, .randomChoice, .generateAIResponse, .mentionUser, .mentionRole, .disableMention, .sendToChannel, .replyToTrigger: return [] case .deleteMessage: return [.manageMessages] @@ -3321,7 +3328,7 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { return .bot case .delay, .setVariable, .randomChoice: return .utility - case .replyToTrigger, .mentionUser, .mentionRole, .sendToDM, .sendToChannel: + case .replyToTrigger, .mentionUser, .mentionRole, .disableMention, .sendToChannel: return .modifiers case .generateAIResponse: return .ai @@ -3615,8 +3622,10 @@ struct Rule: Identifiable, Codable, Equatable { } if !action.mentionUser { // Default was true in legacy - // We'll skip adding a "No Mention" for now to keep it simple, - // or add it if we have a specific block for it. + var disableMentionBlock = RuleAction() + disableMentionBlock.type = .disableMention + pipeline.append(disableMentionBlock) + actionWithModifiers.mentionUser = true // Reset so we don't repeat } pipeline.append(actionWithModifiers) diff --git a/SwiftBotApp/VoiceActionsView.swift b/SwiftBotApp/VoiceActionsView.swift index 5b86eab..9858aaa 100644 --- a/SwiftBotApp/VoiceActionsView.swift +++ b/SwiftBotApp/VoiceActionsView.swift @@ -350,7 +350,7 @@ struct RuleEditorView: View { case .setStatus: action.statusText = "Handling \(rule.trigger?.rawValue.lowercased() ?? "action")" // Modifier blocks - case .replyToTrigger, .mentionUser, .mentionRole, .sendToDM, .sendToChannel: + case .replyToTrigger, .mentionUser, .mentionRole, .disableMention, .sendToChannel: break // AI block case .generateAIResponse: @@ -362,7 +362,12 @@ struct RuleEditorView: View { break } - rule.actions.append(action) + // Fix: Route modifiers to rule.modifiers, actions to rule.actions + if type.category == .modifiers { + rule.modifiers.append(action) + } else { + rule.actions.append(action) + } app.ruleStore.scheduleAutoSave() } @@ -927,12 +932,11 @@ struct ActionSectionView: View { var body: some View { VStack(alignment: .leading, spacing: 10) { + // Block header — immutable once created; type is fixed at drop time HStack { - Picker(category == .modifiers ? "Modifier" : "Action", selection: $action.type) { - ForEach(ActionType.allCases.filter { $0.category == category }) { actionType in - Label(actionType.rawValue, systemImage: actionType.symbol).tag(actionType) - } - } + Label(action.type.rawValue, systemImage: action.type.symbol) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(category == .modifiers ? .orange : .mint) Spacer() Button(role: .destructive, action: onDelete) { Image(systemName: "trash") @@ -1059,8 +1063,8 @@ struct ActionSectionView: View { .foregroundStyle(.secondary) case .mentionRole: TextField("Role ID to mention", text: $action.roleId) - case .sendToDM: - Text("Routes the message to the triggering user's DMs.") + case .disableMention: + Text("Strips any existing user mentions from the message template.") .font(.caption) .foregroundStyle(.secondary) case .sendToChannel: From aca762fb4a5015ae7e1620fedc4a2f15d8c40313 Mon Sep 17 00:00:00 2001 From: johnwatso Date: Wed, 11 Mar 2026 16:06:38 +1300 Subject: [PATCH 04/18] [Beta] ongoing Actions clean up Streamlining Action Building WEBUI needs to reflect new rule building structure This change introduces drag and drop, which does not comply with apples Guidelines. Therefore the following commit will be rebuilt to show this change --- SwiftBotApp/AppModel.swift | 2 + SwiftBotApp/DiscordService.swift | 11 +- SwiftBotApp/Models.swift | 69 +++++++- SwiftBotApp/VoiceActionsView.swift | 267 ++++++++++++++++++++++++++--- 4 files changed, 311 insertions(+), 38 deletions(-) diff --git a/SwiftBotApp/AppModel.swift b/SwiftBotApp/AppModel.swift index 10c5441..35de2d2 100644 --- a/SwiftBotApp/AppModel.swift +++ b/SwiftBotApp/AppModel.swift @@ -3405,6 +3405,7 @@ final class AppModel: ObservableObject { triggerGuildId: guildId, triggerUserId: userId, isDirectMessage: false, + authorIsBot: nil, joinedAt: joinedAt ) let matchedRules = ruleEngine.evaluateRules(event: ruleEvent) @@ -3462,6 +3463,7 @@ final class AppModel: ObservableObject { triggerGuildId: guildId, triggerUserId: userId, isDirectMessage: false, + authorIsBot: nil, joinedAt: nil ) diff --git a/SwiftBotApp/DiscordService.swift b/SwiftBotApp/DiscordService.swift index 87eae7b..f219541 100644 --- a/SwiftBotApp/DiscordService.swift +++ b/SwiftBotApp/DiscordService.swift @@ -2074,6 +2074,7 @@ actor DiscordService { triggerGuildId: guildId, triggerUserId: userId, isDirectMessage: false, + authorIsBot: nil, joinedAt: nil ) } @@ -2099,6 +2100,7 @@ actor DiscordService { triggerGuildId: guildId, triggerUserId: userId, isDirectMessage: false, + authorIsBot: nil, joinedAt: nil ) } @@ -2124,6 +2126,7 @@ actor DiscordService { triggerGuildId: guildId, triggerUserId: userId, isDirectMessage: false, + authorIsBot: nil, joinedAt: nil ) } @@ -2141,9 +2144,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 } @@ -2168,6 +2172,7 @@ actor DiscordService { triggerGuildId: guildId, triggerUserId: userId, isDirectMessage: isDirectMessage, + authorIsBot: authorIsBot, joinedAt: nil ) } diff --git a/SwiftBotApp/Models.swift b/SwiftBotApp/Models.swift index 4701b70..06e1a06 100644 --- a/SwiftBotApp/Models.swift +++ b/SwiftBotApp/Models.swift @@ -2400,6 +2400,7 @@ struct VoiceRuleEvent { let triggerGuildId: String let triggerUserId: String let isDirectMessage: Bool + let authorIsBot: Bool? let joinedAt: Date? } @@ -2560,7 +2561,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 @@ -2610,6 +2611,17 @@ final class RuleEngine { 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 } } } @@ -3159,6 +3171,10 @@ enum ConditionType: String, CaseIterable, Identifiable, Codable { 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 } @@ -3175,6 +3191,10 @@ enum ConditionType: String, CaseIterable, Identifiable, Codable { 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" } } @@ -3195,6 +3215,10 @@ enum ConditionType: String, CaseIterable, Identifiable, Codable { return [.user, .userId] case .messageContains, .messageStartsWith, .messageRegex: return [.message] + case .isDirectMessage, .isFromBot, .isFromUser: + return [.message, .channel] + case .channelType: + return [.channel, .channelId] } } } @@ -3373,7 +3397,6 @@ struct Condition: Identifiable, Codable, Equatable { var type: ConditionType var value: String = "" var secondaryValue: String = "" - var enabled: Bool = true } struct RuleAction: Identifiable, Codable, Equatable { @@ -3507,6 +3530,9 @@ struct Rule: Identifiable, Codable, Equatable { 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(), @@ -3520,7 +3546,8 @@ struct Rule: Identifiable, Codable, Equatable { triggerVoiceChannelId: String = "", triggerMessageContains: String = "", replyToDMs: Bool = false, - includeStageChannels: Bool = true + includeStageChannels: Bool = true, + isEditingTrigger: Bool = false ) { self.id = id self.name = name @@ -3534,6 +3561,7 @@ struct Rule: Identifiable, Codable, Equatable { self.triggerMessageContains = triggerMessageContains self.replyToDMs = replyToDMs self.includeStageChannels = includeStageChannels + self.isEditingTrigger = isEditingTrigger } var isEmptyRule: Bool { @@ -3648,15 +3676,40 @@ struct Rule: Identifiable, Codable, Equatable { } } - /// Validates the rule and returns any issues found + /// 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] { - var issues: [ValidationIssue] = [] + guard let trigger = trigger, !isEditingTrigger else { + return [] + } - // Get variables available from trigger - let availableVariables = trigger?.providedVariables ?? [] + var issues: [ValidationIssue] = [] + let availableVariables = trigger.providedVariables // Check conditions for variable availability - for condition in conditions where condition.enabled { + for condition in conditions { let requiredVars = condition.type.requiredVariables let missingVars = requiredVars.subtracting(availableVariables) if !missingVars.isEmpty { diff --git a/SwiftBotApp/VoiceActionsView.swift b/SwiftBotApp/VoiceActionsView.swift index 9858aaa..72faefa 100644 --- a/SwiftBotApp/VoiceActionsView.swift +++ b/SwiftBotApp/VoiceActionsView.swift @@ -134,6 +134,9 @@ struct RuleEditorView: View { @State private var showOnboardingCard = false @State private var guidedStep: GuidedBuildStep = .none @State private var scrollToTriggersSignal: Bool = false + + @State private var shakeOffset: CGFloat = 0 + @State private var dropWarningMessage: String? = nil private var serverIds: [String] { app.connectedServers.keys.sorted { @@ -160,6 +163,7 @@ struct RuleEditorView: View { onAddAction: addAction(_:), onSetTrigger: { type in rule.trigger = type + rule.isEditingTrigger = false applyTriggerDefaults(for: type) if guidedStep == .trigger { guidedStep = .action } }, @@ -168,7 +172,8 @@ struct RuleEditorView: View { applyTriggerDefaults(for: trigger) } }, - scrollToTriggersSignal: $scrollToTriggersSignal + scrollToTriggersSignal: $scrollToTriggersSignal, + currentTrigger: rule.trigger ) } .frame(minWidth: 250, idealWidth: 270, maxWidth: 300) @@ -203,7 +208,11 @@ struct RuleEditorView: View { .padding(.bottom, 16) .background(rulePaneBackground) - if !rule.validationIssues.isEmpty { + if rule.trigger == nil { + NeutralBannerView(message: "No trigger selected. Select a trigger to configure this rule.") + .padding(.horizontal, 20) + .padding(.bottom, 8) + } else if !rule.isEditingTrigger && !rule.validationIssues.isEmpty { ValidationBannerView(issues: rule.validationIssues) .padding(.horizontal, 20) .padding(.bottom, 8) @@ -225,7 +234,7 @@ struct RuleEditorView: View { RuleCanvasSection(title: "Trigger Block", systemImage: "bolt.fill", accent: .yellow, guidedHighlight: guidedStep == .trigger) { TriggerSectionView( - triggerType: $rule.trigger + triggerType: rule.trigger ) if guidedStep == .trigger { Label("Select a trigger from the Block Library to begin.", systemImage: "arrow.left") @@ -235,6 +244,7 @@ struct RuleEditorView: View { } // Trigger can be replaced but not deleted Button { + rule.isEditingTrigger = true rule.trigger = nil guidedStep = .trigger } label: { @@ -253,7 +263,9 @@ struct RuleEditorView: View { conditions: $rule.conditions, serverIds: serverIds, serverName: serverName(for:), - voiceChannels: app.availableVoiceChannelsByServer.values.flatMap { $0 } + voiceChannels: app.availableVoiceChannelsByServer.values.flatMap { $0 }, + incompatibleBlocks: rule.incompatibleBlocks, + availableVariables: rule.trigger?.providedVariables ?? [] ) } @@ -266,7 +278,9 @@ struct RuleEditorView: View { allModifiers: rule.modifiers, serverIds: serverIds, serverName: serverName(for:), - textChannelsByServer: app.availableTextChannelsByServer + textChannelsByServer: app.availableTextChannelsByServer, + incompatibleBlocks: rule.incompatibleBlocks, + availableVariables: rule.trigger?.providedVariables ?? [] ) } @@ -281,7 +295,9 @@ struct RuleEditorView: View { serverIds: serverIds, serverName: serverName(for:), textChannelsByServer: app.availableTextChannelsByServer, - isGuided: guidedStep == .action + isGuided: guidedStep == .action, + incompatibleBlocks: rule.incompatibleBlocks, + availableVariables: rule.trigger?.providedVariables ?? [] ) } } @@ -290,6 +306,69 @@ struct RuleEditorView: View { .frame(maxWidth: 880, alignment: .leading) .padding(.horizontal, 20) .padding(.vertical, 20) + .offset(x: shakeOffset) + .overlay(alignment: .top) { + if let warning = dropWarningMessage { + Label(warning, systemImage: "exclamationmark.triangle.fill") + .font(.caption.weight(.medium)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.orange.opacity(0.2), in: Capsule()) + .overlay(Capsule().strokeBorder(Color.orange.opacity(0.4))) + .foregroundStyle(.orange) + .padding(.top, 10) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + } + .dropDestination(for: String.self) { items, location in + guard let item = items.first else { return false } + + func rejectDrop(missing: String) -> Bool { + withAnimation(.snappy(duration: 0.3)) { + dropWarningMessage = "Requires \(missing) context" + } + + withAnimation(.linear(duration: 0.05).repeatCount(4, autoreverses: true)) { + shakeOffset = 6 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + shakeOffset = 0 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { + withAnimation(.easeOut) { + dropWarningMessage = nil + } + } + return false + } + + if item.hasPrefix("condition:") { + let typeStr = String(item.dropFirst("condition:".count)) + if let type = ConditionType(rawValue: typeStr) { + let available = rule.trigger?.providedVariables ?? [] + let missing = type.requiredVariables.subtracting(available) + if !missing.isEmpty { + return rejectDrop(missing: missing.first!.rawValue) + } + addCondition(type) + return true + } + } else if item.hasPrefix("action:") { + let typeStr = String(item.dropFirst("action:".count)) + if let type = ActionType(rawValue: typeStr) { + let available = rule.trigger?.providedVariables ?? [] + let missing = type.requiredVariables.subtracting(available) + if !missing.isEmpty { + return rejectDrop(missing: missing.first!.rawValue) + } + addAction(type) + return true + } + } + return false } } .background(rulePaneBackground) @@ -455,6 +534,7 @@ struct RuleBuilderLibraryView: View { let onSetTrigger: (TriggerType) -> Void let focusTrigger: () -> Void @Binding var scrollToTriggersSignal: Bool + let currentTrigger: TriggerType? @State private var triggerSectionHighlighted: Bool = false private let triggersSectionID = "library-triggers" @@ -463,6 +543,21 @@ struct RuleBuilderLibraryView: View { 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 } + guard let currentTrigger = currentTrigger else { return "Requires a trigger to be selected." } + let missing = reqs.subtracting(currentTrigger.providedVariables) + if let first = missing.first { + return "Requires \(first.rawValue) context." + } + return "Incompatible with current trigger." + } + var body: some View { ScrollViewReader { proxy in ScrollView { @@ -487,6 +582,9 @@ struct RuleBuilderLibraryView: View { subtitle: "Add a filter condition", systemImage: type.symbol, accent: .cyan, + isDisabled: !isCompatible(type.requiredVariables), + disabledReason: tooltipFor(type.requiredVariables), + dragItem: "condition:\(type.rawValue)", action: { onAddCondition(type) } ) } @@ -501,6 +599,9 @@ struct RuleBuilderLibraryView: View { subtitle: "Modify message routing or formatting", systemImage: type.symbol, accent: .orange, + isDisabled: !isCompatible(type.requiredVariables), + disabledReason: tooltipFor(type.requiredVariables), + dragItem: "action:\(type.rawValue)", action: { onAddAction(type) } ) } @@ -516,6 +617,9 @@ struct RuleBuilderLibraryView: View { subtitle: "Generate content using AI", systemImage: type.symbol, accent: .indigo, + isDisabled: !isCompatible(type.requiredVariables), + disabledReason: tooltipFor(type.requiredVariables), + dragItem: "action:\(type.rawValue)", action: { onAddAction(type) } ) } @@ -531,6 +635,9 @@ struct RuleBuilderLibraryView: View { subtitle: "Insert action into pipeline", systemImage: type.symbol, accent: .mint, + isDisabled: !isCompatible(type.requiredVariables), + disabledReason: tooltipFor(type.requiredVariables), + dragItem: "action:\(type.rawValue)", action: { onAddAction(type) } ) } @@ -546,6 +653,9 @@ struct RuleBuilderLibraryView: View { subtitle: "Moderation action", systemImage: type.symbol, accent: .red, + isDisabled: !isCompatible(type.requiredVariables), + disabledReason: tooltipFor(type.requiredVariables), + dragItem: "action:\(type.rawValue)", action: { onAddAction(type) } ) } @@ -561,6 +671,9 @@ struct RuleBuilderLibraryView: View { subtitle: "Insert utility block", systemImage: type.symbol, accent: .purple, + isDisabled: !isCompatible(type.requiredVariables), + disabledReason: tooltipFor(type.requiredVariables), + dragItem: "action:\(type.rawValue)", action: { onAddAction(type) } ) } @@ -654,6 +767,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 { @@ -661,23 +777,33 @@ 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") : "") + .background { + if let dragItem = dragItem { + Color.clear + .draggable(dragItem) + } + } } } @@ -742,14 +868,16 @@ struct RuleGroupSection: View { } struct TriggerSectionView: View { - @Binding var triggerType: TriggerType? + let triggerType: TriggerType? var body: some View { - Picker("Event", selection: $triggerType) { - Text("Select a trigger...").tag(TriggerType?.none) - ForEach(TriggerType.allCases) { trigger in - Label(trigger.rawValue, systemImage: trigger.symbol).tag(Optional(trigger)) - } + if let type = triggerType { + Label(type.rawValue, systemImage: type.symbol) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.yellow) + } else { + Text("No trigger selected.") + .foregroundStyle(.secondary) } } } @@ -760,6 +888,8 @@ struct ConditionsSectionView: View { let serverIds: [String] let serverName: (String) -> String let voiceChannels: [GuildVoiceChannel] + var incompatibleBlocks: [UUID] = [] + var availableVariables: Set = [] var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -768,8 +898,13 @@ struct ConditionsSectionView: View { .foregroundStyle(.secondary) } 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.first.map { "Requires \($0.rawValue) context" }, serverIds: serverIds, serverName: serverName, voiceChannels: voiceChannels, @@ -798,6 +933,8 @@ struct ConditionsSectionView: View { struct ConditionRowView: View { @Binding var condition: Condition + var isIncompatible: Bool = false + var missingContext: String? = nil let serverIds: [String] let serverName: (String) -> String @@ -807,13 +944,19 @@ struct ConditionRowView: View { 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") } @@ -823,7 +966,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 @@ -860,14 +1004,42 @@ struct ConditionRowView: View { case .userHasRole: TextField("Enter role name or ID…", text: $condition.value) case .userJoinedRecently: - TextField("Joined within (days)…", text: $condition.value) - .foregroundStyle(.secondary) + 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") + } } } } @@ -881,6 +1053,8 @@ struct ActionsSectionView: View { let serverName: (String) -> String let textChannelsByServer: [String: [GuildTextChannel]] var isGuided: Bool = false + var incompatibleBlocks: [UUID] = [] + var availableVariables: Set = [] var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -903,10 +1077,15 @@ struct ActionsSectionView: View { .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, + isIncompatible: !isCompat, + missingContext: missing.first.map { "Requires \($0.rawValue) context" }, serverIds: serverIds, serverName: serverName, textChannels: textChannelsByServer[action.serverId] ?? [], @@ -924,6 +1103,8 @@ struct ActionSectionView: View { @Binding var action: Action let category: BlockCategory let allModifiers: [Action] + var isIncompatible: Bool = false + var missingContext: String? = nil let serverIds: [String] let serverName: (String) -> String @@ -937,6 +1118,14 @@ struct ActionSectionView: View { Label(action.type.rawValue, systemImage: action.type.symbol) .font(.subheadline.weight(.semibold)) .foregroundStyle(category == .modifiers ? .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") @@ -1095,7 +1284,8 @@ struct ActionSectionView: View { .foregroundStyle(.secondary) } .padding(12) - .glassCard(cornerRadius: 18, tint: .white.opacity(0.06), stroke: .white.opacity(0.16)) + .glassCard(cornerRadius: 18, tint: .white.opacity(0.06), stroke: isIncompatible ? Color.orange.opacity(0.4) : .white.opacity(0.16)) + .opacity(isIncompatible ? 0.6 : 1.0) .onAppear { if action.serverId.isEmpty { action.serverId = serverIds.first ?? "" @@ -1174,6 +1364,29 @@ struct FirstRuleOnboardingCard: View { // MARK: - Validation Banner +struct NeutralBannerView: View { + let message: String + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "info.circle.fill") + .foregroundStyle(.blue) + .font(.caption.weight(.semibold)) + Text(message) + .font(.caption) + .foregroundStyle(.primary) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.blue.opacity(0.12), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(Color.blue.opacity(0.30), lineWidth: 1) + ) + } +} + struct ValidationBannerView: View { let issues: [ValidationIssue] From bca887c34a587326636b63c896be317cb8ccc9e8 Mon Sep 17 00:00:00 2001 From: johnwatso Date: Wed, 11 Mar 2026 16:46:52 +1300 Subject: [PATCH 05/18] [beta] --- SwiftBotApp/Models.swift | 32 ++++++-- SwiftBotApp/VoiceActionsView.swift | 124 +++++++---------------------- 2 files changed, 53 insertions(+), 103 deletions(-) diff --git a/SwiftBotApp/Models.swift b/SwiftBotApp/Models.swift index 06e1a06..c0f25e1 100644 --- a/SwiftBotApp/Models.swift +++ b/SwiftBotApp/Models.swift @@ -2926,6 +2926,26 @@ enum ContextVariable: String, CaseIterable, Codable, Hashable { } } +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 @@ -3714,8 +3734,8 @@ struct Rule: Identifiable, Codable, Equatable { let missingVars = requiredVars.subtracting(availableVariables) if !missingVars.isEmpty { issues.append(.init( - severity: .error, - message: "Condition '\(condition.type.rawValue)' requires variables not available: \(missingVars.map(\.displayName).joined(separator: ", "))", + severity: .warning, // Task 1: Use warning style + message: "Requires \(requiredVars.friendlyRequirement)", // Task 1: User-friendly wording blockType: .condition, blockId: condition.id )) @@ -3728,8 +3748,8 @@ struct Rule: Identifiable, Codable, Equatable { let missingVars = requiredVars.subtracting(availableVariables) if !missingVars.isEmpty { issues.append(.init( - severity: .error, - message: "Modifier '\(modifier.type.rawValue)' requires context not available: \(missingVars.map(\.displayName).joined(separator: ", "))", + severity: .warning, // Task 1: Use warning style + message: "Requires \(requiredVars.friendlyRequirement)", // Task 1: User-friendly wording blockType: .modifier, blockId: modifier.id )) @@ -3752,8 +3772,8 @@ struct Rule: Identifiable, Codable, Equatable { let missingVars = requiredVars.subtracting(availableVariables) if !missingVars.isEmpty { issues.append(.init( - severity: .error, - message: "Action '\(action.type.rawValue)' requires context not available: \(missingVars.map(\.displayName).joined(separator: ", "))", + severity: .warning, // Task 1: Use warning style + message: "Requires \(requiredVars.friendlyRequirement)", // Task 1: User-friendly wording blockType: .action, blockId: action.id )) diff --git a/SwiftBotApp/VoiceActionsView.swift b/SwiftBotApp/VoiceActionsView.swift index 72faefa..2a2e677 100644 --- a/SwiftBotApp/VoiceActionsView.swift +++ b/SwiftBotApp/VoiceActionsView.swift @@ -134,9 +134,6 @@ struct RuleEditorView: View { @State private var showOnboardingCard = false @State private var guidedStep: GuidedBuildStep = .none @State private var scrollToTriggersSignal: Bool = false - - @State private var shakeOffset: CGFloat = 0 - @State private var dropWarningMessage: String? = nil private var serverIds: [String] { app.connectedServers.keys.sorted { @@ -173,7 +170,8 @@ struct RuleEditorView: View { } }, scrollToTriggersSignal: $scrollToTriggersSignal, - currentTrigger: rule.trigger + currentTrigger: rule.trigger, + isEditingTrigger: rule.isEditingTrigger ) } .frame(minWidth: 250, idealWidth: 270, maxWidth: 300) @@ -231,7 +229,7 @@ struct RuleEditorView: View { ) ) } else { - RuleCanvasSection(title: "Trigger Block", systemImage: "bolt.fill", accent: .yellow, + RuleCanvasSection(title: "Trigger", systemImage: "bolt.fill", accent: .yellow, guidedHighlight: guidedStep == .trigger) { TriggerSectionView( triggerType: rule.trigger @@ -245,20 +243,22 @@ struct RuleEditorView: View { // Trigger can be replaced but not deleted Button { rule.isEditingTrigger = true - rule.trigger = nil + scrollToTriggersSignal = true guidedStep = .trigger } label: { Label("Change Trigger", systemImage: "arrow.triangle.2.circlepath") - .font(.caption) - .foregroundStyle(.secondary) + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 4) + .padding(.vertical, 2) } - .buttonStyle(.borderless) + .buttonStyle(.bordered) + .tint(.yellow) .padding(.top, 4) } RuleFlowArrow() - RuleCanvasSection(title: "Filter Blocks", systemImage: "line.3.horizontal.decrease.circle", accent: .cyan) { + RuleCanvasSection(title: "Filters", systemImage: "line.3.horizontal.decrease.circle", accent: .cyan) { ConditionsSectionView( conditions: $rule.conditions, serverIds: serverIds, @@ -286,7 +286,7 @@ struct RuleEditorView: View { RuleFlowArrow() - RuleCanvasSection(title: "Action Blocks", systemImage: "paperplane.fill", accent: .mint, + RuleCanvasSection(title: "Actions", systemImage: "paperplane.fill", accent: .mint, guidedHighlight: guidedStep == .action) { ActionsSectionView( actions: $rule.actions, @@ -306,69 +306,6 @@ struct RuleEditorView: View { .frame(maxWidth: 880, alignment: .leading) .padding(.horizontal, 20) .padding(.vertical, 20) - .offset(x: shakeOffset) - .overlay(alignment: .top) { - if let warning = dropWarningMessage { - Label(warning, systemImage: "exclamationmark.triangle.fill") - .font(.caption.weight(.medium)) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(Color.orange.opacity(0.2), in: Capsule()) - .overlay(Capsule().strokeBorder(Color.orange.opacity(0.4))) - .foregroundStyle(.orange) - .padding(.top, 10) - .transition(.move(edge: .top).combined(with: .opacity)) - } - } - } - .dropDestination(for: String.self) { items, location in - guard let item = items.first else { return false } - - func rejectDrop(missing: String) -> Bool { - withAnimation(.snappy(duration: 0.3)) { - dropWarningMessage = "Requires \(missing) context" - } - - withAnimation(.linear(duration: 0.05).repeatCount(4, autoreverses: true)) { - shakeOffset = 6 - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - shakeOffset = 0 - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { - withAnimation(.easeOut) { - dropWarningMessage = nil - } - } - return false - } - - if item.hasPrefix("condition:") { - let typeStr = String(item.dropFirst("condition:".count)) - if let type = ConditionType(rawValue: typeStr) { - let available = rule.trigger?.providedVariables ?? [] - let missing = type.requiredVariables.subtracting(available) - if !missing.isEmpty { - return rejectDrop(missing: missing.first!.rawValue) - } - addCondition(type) - return true - } - } else if item.hasPrefix("action:") { - let typeStr = String(item.dropFirst("action:".count)) - if let type = ActionType(rawValue: typeStr) { - let available = rule.trigger?.providedVariables ?? [] - let missing = type.requiredVariables.subtracting(available) - if !missing.isEmpty { - return rejectDrop(missing: missing.first!.rawValue) - } - addAction(type) - return true - } - } - return false } } .background(rulePaneBackground) @@ -535,6 +472,7 @@ struct RuleBuilderLibraryView: View { let focusTrigger: () -> Void @Binding var scrollToTriggersSignal: Bool let currentTrigger: TriggerType? + let isEditingTrigger: Bool @State private var triggerSectionHighlighted: Bool = false private let triggersSectionID = "library-triggers" @@ -551,29 +489,27 @@ struct RuleBuilderLibraryView: View { private func tooltipFor(_ reqs: Set) -> String? { guard !isCompatible(reqs) else { return nil } guard let currentTrigger = currentTrigger else { return "Requires a trigger to be selected." } - let missing = reqs.subtracting(currentTrigger.providedVariables) - if let first = missing.first { - return "Requires \(first.rawValue) context." - } - return "Incompatible with current trigger." + return "Requires \(reqs.friendlyRequirement)." } var body: some View { ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 16) { - RuleLibrarySection(title: "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() } - ) + 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) } - .id(triggersSectionID) RuleLibrarySection(title: "Filters") { ForEach(ConditionType.allCases) { type in @@ -798,12 +734,6 @@ struct RuleLibraryButton: View { .buttonStyle(.plain) .disabled(isDisabled) .help(isDisabled ? (disabledReason ?? "Incompatible") : "") - .background { - if let dragItem = dragItem { - Color.clear - .draggable(dragItem) - } - } } } @@ -904,7 +834,7 @@ struct ConditionsSectionView: View { ConditionRowView( condition: $condition, isIncompatible: !isCompat, - missingContext: missing.first.map { "Requires \($0.rawValue) context" }, + missingContext: missing.isEmpty ? nil : "Requires \(missing.friendlyRequirement)", serverIds: serverIds, serverName: serverName, voiceChannels: voiceChannels, @@ -1085,7 +1015,7 @@ struct ActionsSectionView: View { category: category, allModifiers: allModifiers, isIncompatible: !isCompat, - missingContext: missing.first.map { "Requires \($0.rawValue) context" }, + missingContext: missing.isEmpty ? nil : "Requires \(missing.friendlyRequirement)", serverIds: serverIds, serverName: serverName, textChannels: textChannelsByServer[action.serverId] ?? [], From 10d21dcb2c3f29adf49e7d197e1f46fc4049f256 Mon Sep 17 00:00:00 2001 From: johnwatso Date: Wed, 11 Mar 2026 17:43:41 +1300 Subject: [PATCH 06/18] [Beta] Ongoing Rule Improvements --- SwiftBotApp/VoiceActionsView.swift | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/SwiftBotApp/VoiceActionsView.swift b/SwiftBotApp/VoiceActionsView.swift index 2a2e677..dbd8dd8 100644 --- a/SwiftBotApp/VoiceActionsView.swift +++ b/SwiftBotApp/VoiceActionsView.swift @@ -824,8 +824,10 @@ struct ConditionsSectionView: View { 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) @@ -844,19 +846,6 @@ struct ConditionsSectionView: View { ) } } - - 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) } } } From 094a6b83a3383d9fb1b31f70307c268a60f7a823 Mon Sep 17 00:00:00 2001 From: johnwatso Date: Wed, 11 Mar 2026 18:16:05 +1300 Subject: [PATCH 07/18] [Beta] Ongoing Actions Improvement Improving Actions Builder --- SwiftBotApp/EmptyRuleOnboardingView.swift | 10 +- SwiftBotApp/Models.swift | 59 +++---- SwiftBotApp/VoiceActionsView.swift | 188 +++++++++------------- 3 files changed, 111 insertions(+), 146 deletions(-) diff --git a/SwiftBotApp/EmptyRuleOnboardingView.swift b/SwiftBotApp/EmptyRuleOnboardingView.swift index fb4736b..7c04078 100644 --- a/SwiftBotApp/EmptyRuleOnboardingView.swift +++ b/SwiftBotApp/EmptyRuleOnboardingView.swift @@ -21,19 +21,19 @@ struct EmptyRuleOnboardingView: View { .foregroundStyle(.secondary.opacity(0.6)) VStack(spacing: 8) { - Text("No blocks yet") + Text("No Trigger Selected") .font(.title3.weight(.bold)) .foregroundStyle(.primary) - Text("Add a trigger to begin building this rule.") + Text("Choose a trigger from the Block Library to begin building this rule.") .font(.callout) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } - // Primary action button: [ + Add Trigger ] + // Primary action button Button(action: onAddTriggerTapped) { - Label("Add Trigger", systemImage: "plus") + Label("Choose Trigger", systemImage: "plus") .font(.body.weight(.medium)) } .buttonStyle(.borderedProminent) @@ -62,7 +62,7 @@ struct EmptyRuleOnboardingView: View { .easeInOut(duration: 1.2).repeatForever(autoreverses: true), value: arrowPulse ) - Text("Drag a trigger from the Block Library") + Text("Select a trigger from the Block Library") .font(.caption) } .foregroundStyle(.secondary.opacity(0.8)) diff --git a/SwiftBotApp/Models.swift b/SwiftBotApp/Models.swift index c0f25e1..7cedbc4 100644 --- a/SwiftBotApp/Models.swift +++ b/SwiftBotApp/Models.swift @@ -3357,42 +3357,25 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { /// Category for block library organization var category: BlockCategory { switch self { - case .sendMessage, .sendDM, .deleteMessage, .addReaction: + case .sendMessage, .sendDM, .replyToTrigger, .disableMention: return .messaging - + case .addReaction, .deleteMessage, .generateAIResponse: + return .actions case .addRole, .removeRole, .timeoutMember, .kickMember, .moveMember: return .moderation - case .createChannel: - return .channel - case .webhook: - return .integration - case .addLogEntry: - return .logging - case .setStatus: - return .bot - case .delay, .setVariable, .randomChoice: - return .utility - case .replyToTrigger, .mentionUser, .mentionRole, .disableMention, .sendToChannel: - return .modifiers - case .generateAIResponse: - return .ai + case .createChannel, .webhook, .addLogEntry, .setStatus, .delay, .setVariable, .randomChoice, .sendToChannel, .mentionUser, .mentionRole: + return .actions } } } -/// Block categories for library organization +/// Block categories for library organization (Task 5) enum BlockCategory: String, CaseIterable, Identifiable { case triggers = "Triggers" case filters = "Filters" - case modifiers = "Message Modifiers" - case ai = "AI Blocks" - case messaging = "Actions" + case messaging = "Message" + case actions = "Actions" case moderation = "Moderation" - case channel = "Channel" - case integration = "Integration" - case logging = "Logging" - case bot = "Bot" - case utility = "Utilities" var id: String { rawValue } @@ -3400,18 +3383,28 @@ enum BlockCategory: String, CaseIterable, Identifiable { switch self { case .triggers: return "bolt.fill" case .filters: return "line.3.horizontal.decrease.circle" - case .modifiers: return "slider.horizontal.3" - case .ai: return "sparkles" - case .messaging: return "paperplane.fill" + case .messaging: return "text.bubble.fill" + case .actions: return "paperplane.fill" case .moderation: return "shield.fill" - case .channel: return "number" - case .integration: return "link" - case .logging: return "list.bullet.clipboard" - case .bot: return "cpu.fill" - case .utility: return "wrench.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 diff --git a/SwiftBotApp/VoiceActionsView.swift b/SwiftBotApp/VoiceActionsView.swift index dbd8dd8..e52add8 100644 --- a/SwiftBotApp/VoiceActionsView.swift +++ b/SwiftBotApp/VoiceActionsView.swift @@ -206,11 +206,7 @@ struct RuleEditorView: View { .padding(.bottom, 16) .background(rulePaneBackground) - if rule.trigger == nil { - NeutralBannerView(message: "No trigger selected. Select a trigger to configure this rule.") - .padding(.horizontal, 20) - .padding(.bottom, 8) - } else if !rule.isEditingTrigger && !rule.validationIssues.isEmpty { + if !rule.isEmptyRule && !rule.isEditingTrigger && !rule.validationIssues.isEmpty { ValidationBannerView(issues: rule.validationIssues) .padding(.horizontal, 20) .padding(.bottom, 8) @@ -218,10 +214,13 @@ struct RuleEditorView: View { ScrollView { VStack(alignment: .leading, spacing: 18) { - if rule.isEmptyRule { - EmptyRuleOnboardingView { - scrollToTriggersSignal = true - } + if rule.trigger == nil { + EmptyRuleStateView( + icon: "bolt.fill", + title: "No Trigger Selected", + description: "Choose a trigger from the Block Library to begin building this rule." + ) + .padding(.top, 40) .transition( .asymmetric( insertion: .opacity.combined(with: .scale(scale: 0.96)), @@ -261,6 +260,7 @@ struct RuleEditorView: View { RuleCanvasSection(title: "Filters", systemImage: "line.3.horizontal.decrease.circle", accent: .cyan) { ConditionsSectionView( conditions: $rule.conditions, + hasTrigger: rule.trigger != nil, serverIds: serverIds, serverName: serverName(for:), voiceChannels: app.availableVoiceChannelsByServer.values.flatMap { $0 }, @@ -274,8 +274,9 @@ struct RuleEditorView: View { RuleCanvasSection(title: "Message Modifiers", systemImage: "slider.horizontal.3", accent: .orange) { ActionsSectionView( actions: $rule.modifiers, - category: .modifiers, + category: .messaging, // Used to be .modifiers allModifiers: rule.modifiers, + hasTrigger: rule.trigger != nil, serverIds: serverIds, serverName: serverName(for:), textChannelsByServer: app.availableTextChannelsByServer, @@ -290,8 +291,9 @@ struct RuleEditorView: View { guidedHighlight: guidedStep == .action) { ActionsSectionView( actions: $rule.actions, - category: .messaging, + category: .actions, // Used to be .messaging allModifiers: rule.modifiers, + hasTrigger: rule.trigger != nil, serverIds: serverIds, serverName: serverName(for:), textChannelsByServer: app.availableTextChannelsByServer, @@ -379,7 +381,7 @@ struct RuleEditorView: View { } // Fix: Route modifiers to rule.modifiers, actions to rule.actions - if type.category == .modifiers { + if type.category == .messaging { rule.modifiers.append(action) } else { rule.actions.append(action) @@ -513,69 +515,48 @@ struct RuleBuilderLibraryView: View { RuleLibrarySection(title: "Filters") { ForEach(ConditionType.allCases) { type in - RuleLibraryButton( - title: type.rawValue, - subtitle: "Add a filter condition", - systemImage: type.symbol, - accent: .cyan, - isDisabled: !isCompatible(type.requiredVariables), - disabledReason: tooltipFor(type.requiredVariables), - dragItem: "condition:\(type.rawValue)", - action: { onAddCondition(type) } - ) - } - } - - let modifiers = types(for: .modifiers) - if !modifiers.isEmpty { - RuleLibrarySection(title: "Message Modifiers") { - ForEach(modifiers) { type in + if type.isCompatible(with: currentTrigger) { RuleLibraryButton( title: type.rawValue, - subtitle: "Modify message routing or formatting", + subtitle: "Add a filter condition", systemImage: type.symbol, - accent: .orange, - isDisabled: !isCompatible(type.requiredVariables), - disabledReason: tooltipFor(type.requiredVariables), - dragItem: "action:\(type.rawValue)", - action: { onAddAction(type) } + accent: .cyan, + action: { onAddCondition(type) } ) } } } - let aiBlocks = types(for: .ai) - if !aiBlocks.isEmpty { - RuleLibrarySection(title: "AI") { - ForEach(aiBlocks) { type in - RuleLibraryButton( - title: type.rawValue, - subtitle: "Generate content using AI", - systemImage: type.symbol, - accent: .indigo, - isDisabled: !isCompatible(type.requiredVariables), - disabledReason: tooltipFor(type.requiredVariables), - dragItem: "action:\(type.rawValue)", - action: { onAddAction(type) } - ) + let messageTypes = types(for: .messaging) + if !messageTypes.isEmpty { + RuleLibrarySection(title: "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 messagingTypes = types(for: .messaging) - if !messagingTypes.isEmpty { + let actionTypes = types(for: .actions) + if !actionTypes.isEmpty { RuleLibrarySection(title: "Actions") { - ForEach(messagingTypes) { type in - RuleLibraryButton( - title: type.rawValue, - subtitle: "Insert action into pipeline", - systemImage: type.symbol, - accent: .mint, - isDisabled: !isCompatible(type.requiredVariables), - disabledReason: tooltipFor(type.requiredVariables), - dragItem: "action:\(type.rawValue)", - action: { onAddAction(type) } - ) + ForEach(actionTypes) { type in + if type.isCompatible(with: currentTrigger) { + RuleLibraryButton( + title: type.rawValue, + subtitle: "Output blocks", + systemImage: type.symbol, + accent: .mint, + action: { onAddAction(type) } + ) + } } } } @@ -584,34 +565,15 @@ struct RuleBuilderLibraryView: View { if !moderationTypes.isEmpty { RuleLibrarySection(title: "Moderation") { ForEach(moderationTypes) { type in - RuleLibraryButton( - title: type.rawValue, - subtitle: "Moderation action", - systemImage: type.symbol, - accent: .red, - isDisabled: !isCompatible(type.requiredVariables), - disabledReason: tooltipFor(type.requiredVariables), - dragItem: "action:\(type.rawValue)", - action: { onAddAction(type) } - ) - } - } - } - - let utilityTypes = types(for: .utility) - if !utilityTypes.isEmpty { - RuleLibrarySection(title: "Utilities") { - ForEach(utilityTypes) { type in - RuleLibraryButton( - title: type.rawValue, - subtitle: "Insert utility block", - systemImage: type.symbol, - accent: .purple, - isDisabled: !isCompatible(type.requiredVariables), - disabledReason: tooltipFor(type.requiredVariables), - dragItem: "action:\(type.rawValue)", - action: { onAddAction(type) } - ) + if type.isCompatible(with: currentTrigger) { + RuleLibraryButton( + title: type.rawValue, + subtitle: "Server management", + systemImage: type.symbol, + accent: .red, + action: { onAddAction(type) } + ) + } } } } @@ -814,6 +776,7 @@ struct TriggerSectionView: View { struct ConditionsSectionView: View { @Binding var conditions: [Condition] + let hasTrigger: Bool let serverIds: [String] let serverName: (String) -> String @@ -847,6 +810,8 @@ struct ConditionsSectionView: View { } } } + .disabled(!hasTrigger) + .opacity(hasTrigger ? 1.0 : 0.5) } } @@ -967,6 +932,7 @@ struct ActionsSectionView: View { @Binding var actions: [Action] let category: BlockCategory let allModifiers: [Action] + let hasTrigger: Bool let serverIds: [String] let serverName: (String) -> String @@ -1015,6 +981,8 @@ struct ActionsSectionView: View { } } } + .disabled(!hasTrigger) + .opacity(hasTrigger ? 1.0 : 0.5) } } @@ -1036,7 +1004,7 @@ struct ActionSectionView: View { HStack { Label(action.type.rawValue, systemImage: action.type.symbol) .font(.subheadline.weight(.semibold)) - .foregroundStyle(category == .modifiers ? .orange : .mint) + .foregroundStyle(category == .messaging ? .orange : .mint) if isIncompatible { Label(missingContext ?? "Incompatible with trigger", systemImage: "exclamationmark.triangle.fill") @@ -1283,26 +1251,30 @@ struct FirstRuleOnboardingCard: View { // MARK: - Validation Banner -struct NeutralBannerView: View { - let message: String +struct EmptyRuleStateView: View { + let icon: String + let title: String + let description: String var body: some View { - HStack(spacing: 8) { - Image(systemName: "info.circle.fill") - .foregroundStyle(.blue) - .font(.caption.weight(.semibold)) - Text(message) - .font(.caption) - .foregroundStyle(.primary) - Spacer() + VStack(spacing: 16) { + 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) + } } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(Color.blue.opacity(0.12), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder(Color.blue.opacity(0.30), lineWidth: 1) - ) + .padding(40) + .frame(maxWidth: .infinity) + .glassCard(cornerRadius: 24, tint: .white.opacity(0.05), stroke: .white.opacity(0.1)) } } From 25b324a0c5c08b8f8fd8f0ba0efcb51d3860f9d0 Mon Sep 17 00:00:00 2001 From: johnwatso Date: Wed, 11 Mar 2026 18:54:32 +1300 Subject: [PATCH 08/18] [Beta] Ongoing Improvement to Action onboarding --- SwiftBotApp/VoiceActionsView.swift | 144 ++++++++--------------------- 1 file changed, 39 insertions(+), 105 deletions(-) diff --git a/SwiftBotApp/VoiceActionsView.swift b/SwiftBotApp/VoiceActionsView.swift index e52add8..b7996d3 100644 --- a/SwiftBotApp/VoiceActionsView.swift +++ b/SwiftBotApp/VoiceActionsView.swift @@ -131,7 +131,6 @@ struct RuleEditorView: View { @EnvironmentObject var app: AppModel @AppStorage("hasSeenRuleOnboarding") private var hasSeenRuleOnboarding: Bool = false - @State private var showOnboardingCard = false @State private var guidedStep: GuidedBuildStep = .none @State private var scrollToTriggersSignal: Bool = false @@ -161,6 +160,7 @@ struct RuleEditorView: View { onSetTrigger: { type in rule.trigger = type rule.isEditingTrigger = false + hasSeenRuleOnboarding = true applyTriggerDefaults(for: type) if guidedStep == .trigger { guidedStep = .action } }, @@ -214,11 +214,18 @@ struct RuleEditorView: View { ScrollView { VStack(alignment: .leading, spacing: 18) { - if rule.trigger == nil { + if rule.trigger == nil && !hasSeenRuleOnboarding { EmptyRuleStateView( - icon: "bolt.fill", - title: "No Trigger Selected", - description: "Choose a trigger from the Block Library to begin building this rule." + 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( @@ -313,25 +320,8 @@ struct RuleEditorView: View { .background(rulePaneBackground) } .navigationTitle("") - .sheet(isPresented: $showOnboardingCard) { - FirstRuleOnboardingCard( - onCreateExample: { - showOnboardingCard = false - hasSeenRuleOnboarding = true - applyExampleRule() - }, - onStartEmpty: { - showOnboardingCard = false - hasSeenRuleOnboarding = true - guidedStep = .trigger - } - ) - } .onAppear { initializeRuleDefaultsIfNeeded() - if !hasSeenRuleOnboarding && rule.actions.isEmpty { - showOnboardingCard = true - } } .onChange(of: rule.actions) { _, newActions in if guidedStep == .trigger && !newActions.isEmpty { @@ -490,7 +480,6 @@ struct RuleBuilderLibraryView: View { private func tooltipFor(_ reqs: Set) -> String? { guard !isCompatible(reqs) else { return nil } - guard let currentTrigger = currentTrigger else { return "Requires a trigger to be selected." } return "Requires \(reqs.friendlyRequirement)." } @@ -1014,6 +1003,7 @@ struct ActionSectionView: View { } Spacer() + Button(role: .destructive, action: onDelete) { Image(systemName: "trash") } @@ -1055,7 +1045,7 @@ struct ActionSectionView: View { } // UI refinement: show active modifiers that affect this action - if category == .messaging { + if category == .actions { // Action blocks section VStack(alignment: .leading, spacing: 4) { let activeModifiers = allModifiers.map { $0.type.rawValue }.joined(separator: ", ") if !activeModifiers.isEmpty { @@ -1079,7 +1069,6 @@ struct ActionSectionView: View { .font(.caption) .foregroundStyle(.secondary) } - // New action types case .sendDM: Toggle("Mention user", isOn: $action.mentionUser) VStack(alignment: .leading, spacing: 6) { @@ -1165,87 +1154,10 @@ struct ActionSectionView: View { .foregroundStyle(.secondary) } } - - Text("Type { to insert a variable placeholder") - .font(.caption) - .foregroundStyle(.secondary) } - .padding(12) - .glassCard(cornerRadius: 18, tint: .white.opacity(0.06), stroke: isIncompatible ? Color.orange.opacity(0.4) : .white.opacity(0.16)) + .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.serverId.isEmpty { - action.serverId = serverIds.first ?? "" - } - if action.channelId.isEmpty { - action.channelId = textChannels.first?.id ?? "" - } - } - .onChange(of: action.serverId) { - action.channelId = textChannels.first?.id ?? "" - } - } -} - -// MARK: - Guided Build Step - -enum GuidedBuildStep { - case none, trigger, action -} - -// MARK: - First Rule Onboarding Card - -struct FirstRuleOnboardingCard: View { - let onCreateExample: () -> Void - let onStartEmpty: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 20) { - HStack(spacing: 12) { - Image(systemName: "wand.and.stars") - .font(.title) - .foregroundStyle(.yellow) - VStack(alignment: .leading, spacing: 4) { - Text("First Time Using SwiftBot Rules?") - .font(.headline) - Text("Automations are built from triggers, filters, and actions.") - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - - Divider() - - VStack(alignment: .leading, spacing: 8) { - Label("**Trigger** — what event starts the rule", systemImage: "bolt.fill") - .font(.subheadline) - Label("**Filters** — optional conditions to narrow scope", systemImage: "line.3.horizontal.decrease.circle") - .font(.subheadline) - Label("**Actions** — what the bot does when triggered", systemImage: "paperplane.fill") - .font(.subheadline) - } - .foregroundStyle(.secondary) - - Divider() - - HStack(spacing: 12) { - Button(action: onCreateExample) { - Label("Create Example Rule", systemImage: "sparkles") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - - Button(action: onStartEmpty) { - Text("Start Empty") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .controlSize(.large) - } - } - .padding(28) - .frame(width: 440) } } @@ -1255,9 +1167,11 @@ 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: 16) { + VStack(spacing: 24) { Image(systemName: icon) .font(.system(size: 48)) .foregroundStyle(.yellow) @@ -1271,6 +1185,22 @@ struct EmptyRuleStateView: View { .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) @@ -1435,4 +1365,8 @@ struct VariablePickerPopover: View { } } +// MARK: - Guided Build Step +enum GuidedBuildStep { + case none, trigger, action +} From 27e7719dc8757680c10c9cde72c67263216262f9 Mon Sep 17 00:00:00 2001 From: johnwatso Date: Wed, 11 Mar 2026 19:47:06 +1300 Subject: [PATCH 09/18] [Beta] - Actions Improvement Actions are now in a state where they are ready for main release. still some bugs but otherwise good. --- SwiftBotApp/Models.swift | 2 +- SwiftBotApp/OverviewView.swift | 4 ++-- SwiftBotApp/VoiceActionsView.swift | 4 ++-- SwiftBotApp/VoiceRuleListView.swift | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/SwiftBotApp/Models.swift b/SwiftBotApp/Models.swift index 7cedbc4..a40c6b1 100644 --- a/SwiftBotApp/Models.swift +++ b/SwiftBotApp/Models.swift @@ -2840,7 +2840,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" 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/VoiceActionsView.swift b/SwiftBotApp/VoiceActionsView.swift index b7996d3..c633f2a 100644 --- a/SwiftBotApp/VoiceActionsView.swift +++ b/SwiftBotApp/VoiceActionsView.swift @@ -130,7 +130,7 @@ struct RuleEditorView: View { @Binding var rule: Rule @EnvironmentObject var app: AppModel - @AppStorage("hasSeenRuleOnboarding") private var hasSeenRuleOnboarding: Bool = false + @State private var hasSeenRuleOnboarding: Bool = false @State private var guidedStep: GuidedBuildStep = .none @State private var scrollToTriggersSignal: Bool = false @@ -185,7 +185,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) diff --git a/SwiftBotApp/VoiceRuleListView.swift b/SwiftBotApp/VoiceRuleListView.swift index aa27b4f..9630d6d 100644 --- a/SwiftBotApp/VoiceRuleListView.swift +++ b/SwiftBotApp/VoiceRuleListView.swift @@ -12,7 +12,7 @@ struct RuleListView: View { RulePaneHeader( title: "Actions", subtitle: "Build reusable flows from triggers, filters, and outputs.", - systemImage: "point.3.filled.connected.trianglepath.dotted" + systemImage: "bolt.circle" ) if isLoading { @@ -69,7 +69,7 @@ private struct RuleListEmptyStateView: View { VStack(spacing: 0) { Spacer() VStack(spacing: 16) { - Image(systemName: "point.3.filled.connected.trianglepath.dotted") + Image(systemName: "bolt.circle") .font(.system(size: 40)) .foregroundStyle(.secondary) From 9936d6c0ba7d0f4b0f66c7e2fdaa40f5a8f9e8c7 Mon Sep 17 00:00:00 2001 From: johnwatso Date: Wed, 11 Mar 2026 20:32:37 +1300 Subject: [PATCH 10/18] Update VoiceActionsView.swift --- SwiftBotApp/VoiceActionsView.swift | 185 ++++++++++++++++++++++++++--- 1 file changed, 169 insertions(+), 16 deletions(-) diff --git a/SwiftBotApp/VoiceActionsView.swift b/SwiftBotApp/VoiceActionsView.swift index c633f2a..d5321a8 100644 --- a/SwiftBotApp/VoiceActionsView.swift +++ b/SwiftBotApp/VoiceActionsView.swift @@ -270,7 +270,9 @@ struct RuleEditorView: View { hasTrigger: rule.trigger != nil, serverIds: serverIds, serverName: serverName(for:), - voiceChannels: app.availableVoiceChannelsByServer.values.flatMap { $0 }, + voiceChannels: rule.triggerServerId.isEmpty ? [] : (app.availableVoiceChannelsByServer[rule.triggerServerId] ?? []), + textChannels: rule.triggerServerId.isEmpty ? [] : (app.availableTextChannelsByServer[rule.triggerServerId] ?? []), + roles: rule.triggerServerId.isEmpty ? [] : (app.availableRolesByServer[rule.triggerServerId] ?? []), incompatibleBlocks: rule.incompatibleBlocks, availableVariables: rule.trigger?.providedVariables ?? [] ) @@ -287,6 +289,9 @@ struct RuleEditorView: View { serverIds: serverIds, serverName: serverName(for:), textChannelsByServer: app.availableTextChannelsByServer, + voiceChannelsByServer: app.availableVoiceChannelsByServer, + rolesByServer: app.availableRolesByServer, + knownUsers: app.knownUsersById, incompatibleBlocks: rule.incompatibleBlocks, availableVariables: rule.trigger?.providedVariables ?? [] ) @@ -304,6 +309,9 @@ struct RuleEditorView: View { 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 ?? [] @@ -770,6 +778,8 @@ struct ConditionsSectionView: View { let serverIds: [String] let serverName: (String) -> String let voiceChannels: [GuildVoiceChannel] + let textChannels: [GuildTextChannel] + let roles: [GuildRole] var incompatibleBlocks: [UUID] = [] var availableVariables: Set = [] @@ -792,6 +802,8 @@ struct ConditionsSectionView: View { serverIds: serverIds, serverName: serverName, voiceChannels: voiceChannels, + textChannels: textChannels, + roles: roles, onDelete: { conditions.removeAll { $0.id == condition.id } } @@ -812,6 +824,8 @@ struct ConditionRowView: View { let serverIds: [String] let serverName: (String) -> String let voiceChannels: [GuildVoiceChannel] + let textChannels: [GuildTextChannel] + let roles: [GuildRole] let onDelete: () -> Void var body: some View { @@ -870,12 +884,22 @@ struct ConditionRowView: View { } // New condition types - placeholder UI case .channelIs: - TextField("Enter channel name or ID…", text: $condition.value) + 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: - TextField("Enter role name or ID…", text: $condition.value) + 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) @@ -926,6 +950,9 @@ struct ActionsSectionView: View { 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 = [] @@ -963,6 +990,9 @@ struct ActionsSectionView: View { serverIds: serverIds, serverName: serverName, textChannels: textChannelsByServer[action.serverId] ?? [], + voiceChannels: voiceChannelsByServer[action.serverId] ?? [], + roles: rolesByServer[action.serverId] ?? [], + knownUsers: knownUsers, onDelete: { actions.removeAll { $0.id == action.id } } @@ -985,6 +1015,9 @@ struct ActionSectionView: View { let serverIds: [String] let serverName: (String) -> String let textChannels: [GuildTextChannel] + let voiceChannels: [GuildVoiceChannel] + let roles: [GuildRole] + let knownUsers: [String: String] let onDelete: () -> Void var body: some View { @@ -1082,7 +1115,12 @@ struct ActionSectionView: View { case .addReaction: TextField("Emoji", text: $action.emoji) case .addRole, .removeRole: - TextField("Role ID", text: $action.roleId) + 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) @@ -1092,7 +1130,12 @@ struct ActionSectionView: View { case .kickMember: TextField("Reason (optional)", text: $action.kickReason) case .moveMember: - TextField("Target Voice Channel ID", text: $action.targetVoiceChannelId) + 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: @@ -1127,22 +1170,23 @@ struct ActionSectionView: View { .font(.caption) .foregroundStyle(.secondary) case .mentionRole: - TextField("Role ID to mention", text: $action.roleId) + 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: - if textChannels.isEmpty { - Text("No text channels discovered for this server.") - .foregroundStyle(.secondary) - } else { - Picker("Target Channel", selection: $action.channelId) { - ForEach(textChannels) { channel in - Text("#\(channel.name)").tag(channel.id) - } - } - } + SearchableIDPicker( + title: "Target Channel", + selectionID: $action.channelId, + items: textChannels.map { .init(id: $0.id, name: "#\($0.name)") }, + prompt: "Select a channel..." + ) // AI block case .generateAIResponse: VStack(alignment: .leading, spacing: 6) { @@ -1365,6 +1409,115 @@ struct VariablePickerPopover: View { } } +// 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 { From e1aaec3936962965ae46037e588d18eb0f949fd8 Mon Sep 17 00:00:00 2001 From: johnwatso Date: Wed, 11 Mar 2026 23:04:26 +1300 Subject: [PATCH 11/18] [Beta] Ongoing Actions improvement --- SwiftBotApp/AppModel+Gateway.swift | 6 ++++ SwiftBotApp/DiscordService.swift | 42 +++++++++++++++++++++++++++ SwiftBotApp/Models.swift | 27 ++++++++++++++---- SwiftBotApp/VoiceActionsView.swift | 46 ++++++++++++++++++++---------- 4 files changed, 101 insertions(+), 20 deletions(-) diff --git a/SwiftBotApp/AppModel+Gateway.swift b/SwiftBotApp/AppModel+Gateway.swift index b1be6cf..d60afa4 100644 --- a/SwiftBotApp/AppModel+Gateway.swift +++ b/SwiftBotApp/AppModel+Gateway.swift @@ -250,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/DiscordService.swift b/SwiftBotApp/DiscordService.swift index f219541..d8919cd 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) } @@ -2037,6 +2061,12 @@ actor DiscordService { 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.") } } @@ -2358,11 +2388,14 @@ actor DiscordService { ] ] _ = try? await sendMessage(channelId: replyChannelId, payload: payload, token: token) + context.eventHandled = true } else if context.targetChannelId == nil && !event.userId.isEmpty { // Send to DM _ = try? await sendDM(userId: event.userId, content: rendered) + context.eventHandled = true } else { try? await sendMessage(channelId: targetChannelId, content: rendered, token: token) + context.eventHandled = true } case .addLogEntry: return @@ -2373,9 +2406,11 @@ actor DiscordService { 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 { @@ -2386,18 +2421,25 @@ actor DiscordService { } 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: diff --git a/SwiftBotApp/Models.swift b/SwiftBotApp/Models.swift index a40c6b1..dfbd027 100644 --- a/SwiftBotApp/Models.swift +++ b/SwiftBotApp/Models.swift @@ -2521,11 +2521,12 @@ struct PipelineContext: CustomStringConvertible { var replyToTriggerMessage: Bool = false var mentionRole: String? var isDirectMessage: Bool = false + var eventHandled: Bool = false var description: String { let ai = aiResponse != nil ? "AI(\(aiResponse!.count) chars)" : "nil" let target = targetChannelId ?? "default" - return "[PipelineContext target: \(target), mentionUser: \(mentionUser), prepend: \(prependUserMention), reply: \(replyToTriggerMessage), role: \(mentionRole ?? "nil"), ai: \(ai)]" + return "[PipelineContext target: \(target), mentionUser: \(mentionUser), prepend: \(prependUserMention), reply: \(replyToTriggerMessage), role: \(mentionRole ?? "nil"), ai: \(ai), handled: \(eventHandled)]" } } @@ -3359,8 +3360,10 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { switch self { case .sendMessage, .sendDM, .replyToTrigger, .disableMention: return .messaging - case .addReaction, .deleteMessage, .generateAIResponse: + case .addReaction, .deleteMessage: return .actions + case .generateAIResponse: + return .ai case .addRole, .removeRole, .timeoutMember, .kickMember, .moveMember: return .moderation case .createChannel, .webhook, .addLogEntry, .setStatus, .delay, .setVariable, .randomChoice, .sendToChannel, .mentionUser, .mentionRole: @@ -3373,6 +3376,7 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { enum BlockCategory: String, CaseIterable, Identifiable { case triggers = "Triggers" case filters = "Filters" + case ai = "AI Blocks" case messaging = "Message" case actions = "Actions" case moderation = "Moderation" @@ -3383,6 +3387,7 @@ enum BlockCategory: String, CaseIterable, Identifiable { 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" @@ -3534,6 +3539,7 @@ struct Rule: Identifiable, Codable, Equatable { var conditions: [Condition] = [] var modifiers: [RuleAction] = [] var actions: [RuleAction] = [] + var aiBlocks: [RuleAction] = [] var isEnabled: Bool = true // Legacy trigger properties - preserved for JSON compatibility, migrated to conditions on load @@ -3792,9 +3798,19 @@ struct Rule: Identifiable, Codable, Equatable { blockId: action.id )) } - } - - return issues + } + + // 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 @@ -3841,6 +3857,7 @@ struct ValidationIssue: Identifiable, Hashable { } enum BlockType: String, Codable, CaseIterable { + case rule = "Rule" case trigger = "Trigger" case condition = "Filter" case modifier = "Modifier" diff --git a/SwiftBotApp/VoiceActionsView.swift b/SwiftBotApp/VoiceActionsView.swift index d5321a8..0c0ed36 100644 --- a/SwiftBotApp/VoiceActionsView.swift +++ b/SwiftBotApp/VoiceActionsView.swift @@ -262,23 +262,27 @@ struct RuleEditorView: View { .padding(.top, 4) } - RuleFlowArrow() + Divider().opacity(0.4) - RuleCanvasSection(title: "Filters", systemImage: "line.3.horizontal.decrease.circle", accent: .cyan) { - ConditionsSectionView( - conditions: $rule.conditions, + // AI Processing Section + RuleCanvasSection(title: "AI Processing", systemImage: "sparkles", accent: .purple) { + ActionsSectionView( + actions: $rule.aiBlocks, + category: .ai, + allModifiers: rule.modifiers, hasTrigger: rule.trigger != nil, serverIds: serverIds, serverName: serverName(for:), - voiceChannels: rule.triggerServerId.isEmpty ? [] : (app.availableVoiceChannelsByServer[rule.triggerServerId] ?? []), - textChannels: rule.triggerServerId.isEmpty ? [] : (app.availableTextChannelsByServer[rule.triggerServerId] ?? []), - roles: rule.triggerServerId.isEmpty ? [] : (app.availableRolesByServer[rule.triggerServerId] ?? []), + textChannelsByServer: app.availableTextChannelsByServer, + voiceChannelsByServer: app.availableVoiceChannelsByServer, + rolesByServer: app.availableRolesByServer, + knownUsers: app.knownUsersById, incompatibleBlocks: rule.incompatibleBlocks, availableVariables: rule.trigger?.providedVariables ?? [] ) } - RuleFlowArrow() + Divider().opacity(0.4) RuleCanvasSection(title: "Message Modifiers", systemImage: "slider.horizontal.3", accent: .orange) { ActionsSectionView( @@ -524,9 +528,26 @@ struct RuleBuilderLibraryView: View { } } + 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) } + ) + } + } + } + } + let messageTypes = types(for: .messaging) if !messageTypes.isEmpty { - RuleLibrarySection(title: "Message") { + RuleLibrarySection(title: "Message Modifiers") { // Changed title from "Message" ForEach(messageTypes) { type in if type.isCompatible(with: currentTrigger) { RuleLibraryButton( @@ -1104,11 +1125,6 @@ struct ActionSectionView: View { } case .sendDM: Toggle("Mention user", isOn: $action.mentionUser) - VStack(alignment: .leading, spacing: 6) { - Text("DM Content") - .font(.subheadline.weight(.semibold)) - VariableAwareTextEditor(text: $action.dmContent) - } case .deleteMessage: Text("Delete the triggering message") .foregroundStyle(.secondary) @@ -1193,7 +1209,7 @@ struct ActionSectionView: View { Text("AI Prompt") .font(.subheadline.weight(.semibold)) VariableAwareTextEditor(text: $action.message) - Text("The AI response is available as {ai.response} in subsequent blocks.") + Text("The AI response is available as {ai.response} in later modifiers and actions.") .font(.caption) .foregroundStyle(.secondary) } From 0d6c22e7c062c33e7f3d6ca49a3bd7fa93269d98 Mon Sep 17 00:00:00 2001 From: johnwatso Date: Thu, 12 Mar 2026 00:00:47 +1300 Subject: [PATCH 12/18] [Beta] ongoing action fixes --- SwiftBotApp/DiscordService.swift | 27 +++++++++ SwiftBotApp/Models.swift | 88 ++++++++++++++++++++++++++---- SwiftBotApp/VoiceActionsView.swift | 77 ++++++++++++++++++++++++-- 3 files changed, 176 insertions(+), 16 deletions(-) diff --git a/SwiftBotApp/DiscordService.swift b/SwiftBotApp/DiscordService.swift index d8919cd..a4adec2 100644 --- a/SwiftBotApp/DiscordService.swift +++ b/SwiftBotApp/DiscordService.swift @@ -2370,6 +2370,33 @@ actor DiscordService { 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 + } case .sendMessage: let targetChannelId = context.targetChannelId ?? action.channelId guard !targetChannelId.isEmpty || (context.targetChannelId == nil && !event.userId.isEmpty) else { return } diff --git a/SwiftBotApp/Models.swift b/SwiftBotApp/Models.swift index dfbd027..8cbaed0 100644 --- a/SwiftBotApp/Models.swift +++ b/SwiftBotApp/Models.swift @@ -2514,6 +2514,10 @@ 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 targetChannelId: String? var targetServerId: String? var mentionUser: Bool = true @@ -2525,8 +2529,9 @@ struct PipelineContext: CustomStringConvertible { 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" - return "[PipelineContext target: \(target), mentionUser: \(mentionUser), prepend: \(prependUserMention), reply: \(replyToTriggerMessage), role: \(mentionRole ?? "nil"), ai: \(ai), handled: \(eventHandled)]" + return "[PipelineContext target: \(target), mentionUser: \(mentionUser), prepend: \(prependUserMention), reply: \(replyToTriggerMessage), role: \(mentionRole ?? "nil"), ai: \(ai), summary: \(summary), handled: \(eventHandled)]" } } @@ -2879,6 +2884,10 @@ enum ContextVariable: String, CaseIterable, Codable, Hashable { 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 { @@ -2902,6 +2911,10 @@ enum ContextVariable: String, CaseIterable, Codable, Hashable { 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" } } @@ -2921,7 +2934,7 @@ enum ContextVariable: String, CaseIterable, Codable, Hashable { return "Reaction" case .duration, .memberCount: return "Other" - case .aiResponse: + case .aiResponse, .aiSummary, .aiClassification, .aiEntities, .aiRewrite: return "AI" } } @@ -3271,6 +3284,10 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { // 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 } @@ -3298,6 +3315,10 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { case .disableMention: return "at.badge.minus" case .sendToChannel: return "number.circle.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" } } @@ -3313,7 +3334,7 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { return [.user, .userId] case .sendToChannel: return [.channel] - case .generateAIResponse, .mentionRole: + case .generateAIResponse, .mentionRole, .summariseMessage, .classifyMessage, .extractEntities, .rewriteMessage: return [] } } @@ -3323,6 +3344,14 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { 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, @@ -3334,7 +3363,7 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { /// Discord permissions required for this action var requiredPermissions: Set { switch self { - case .sendMessage, .sendDM, .addLogEntry, .setStatus, .delay, .setVariable, .randomChoice, .generateAIResponse, .mentionUser, .mentionRole, .disableMention, .sendToChannel, .replyToTrigger: + case .sendMessage, .sendDM, .addLogEntry, .setStatus, .delay, .setVariable, .randomChoice, .generateAIResponse, .mentionUser, .mentionRole, .disableMention, .sendToChannel, .replyToTrigger, .summariseMessage, .classifyMessage, .extractEntities, .rewriteMessage: return [] case .deleteMessage: return [.manageMessages] @@ -3362,7 +3391,7 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { return .messaging case .addReaction, .deleteMessage: return .actions - case .generateAIResponse: + case .generateAIResponse, .summariseMessage, .classifyMessage, .extractEntities, .rewriteMessage: return .ai case .addRole, .removeRole, .timeoutMember, .kickMember, .moveMember: return .moderation @@ -3443,6 +3472,11 @@ struct RuleAction: Identifiable, Codable, Equatable { 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) enum CodingKeys: String, CodingKey { case id @@ -3469,6 +3503,9 @@ struct RuleAction: Identifiable, Codable, Equatable { case variableValue case randomOptions case deleteDelaySeconds + case categories + case entityTypes + case rewriteStyle } init() {} @@ -3499,6 +3536,9 @@ struct RuleAction: Identifiable, Codable, Equatable { 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) ?? "" } func encode(to encoder: Encoder) throws { @@ -3527,6 +3567,9 @@ struct RuleAction: Identifiable, Codable, Equatable { 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) } } @@ -3595,11 +3638,11 @@ struct Rule: Identifiable, Codable, Equatable { /// Coding keys for Rule enum CodingKeys: String, CodingKey { - case id, name, trigger, conditions, modifiers, actions, isEnabled + case id, name, trigger, conditions, modifiers, actions, aiBlocks, isEnabled case triggerServerId, triggerVoiceChannelId, triggerMessageContains, replyToDMs, includeStageChannels } - /// Custom decoder that migrates legacy trigger properties into filter conditions + /// 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) @@ -3609,6 +3652,7 @@ struct Rule: Identifiable, Codable, Equatable { 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 @@ -3641,26 +3685,47 @@ struct Rule: Identifiable, Codable, Equatable { 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 + } } - /// Provides the full pipeline of blocks for the rule engine, including migrated legacy toggles + /// Provides the full pipeline of blocks for the rule engine in execution order: + /// AI Processing → Message Modifiers → Actions var processedActions: [RuleAction] { var pipeline: [RuleAction] = [] - // Add explicit modifiers + // 1. AI Processing blocks first + pipeline.append(contentsOf: aiBlocks) + + // 2. Message Modifiers pipeline.append(contentsOf: modifiers) - // Convert legacy toggles from actions into modifier blocks if they aren't already represented + // 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.replyWithAI { var aiBlock = RuleAction() aiBlock.type = .generateAIResponse - pipeline.append(aiBlock) + // 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.replyToTriggerMessage { var replyBlock = RuleAction() replyBlock.type = .replyToTrigger @@ -3668,6 +3733,7 @@ struct Rule: Identifiable, Codable, Equatable { actionWithModifiers.replyToTriggerMessage = false } + // Extract mention disable as a modifier if !action.mentionUser { // Default was true in legacy var disableMentionBlock = RuleAction() disableMentionBlock.type = .disableMention diff --git a/SwiftBotApp/VoiceActionsView.swift b/SwiftBotApp/VoiceActionsView.swift index 0c0ed36..c1a6f77 100644 --- a/SwiftBotApp/VoiceActionsView.swift +++ b/SwiftBotApp/VoiceActionsView.swift @@ -372,9 +372,20 @@ struct RuleEditorView: View { // Modifier blocks case .replyToTrigger, .mentionUser, .mentionRole, .disableMention, .sendToChannel: break - // AI block + // 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, @@ -988,9 +999,7 @@ struct ActionsSectionView: View { Text("No blocks yet") .font(.subheadline.weight(.medium)) .foregroundStyle(.secondary) - Text(isGuided - ? "Select a block from the Block Library to the left." - : "Use the Block Library to add your first block.") + Text(textForEmptyState(category: category, isGuided: isGuided)) .font(.caption) .foregroundStyle(.tertiary) .multilineTextAlignment(.center) @@ -1024,6 +1033,17 @@ 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 { @@ -1203,7 +1223,7 @@ struct ActionSectionView: View { items: textChannels.map { .init(id: $0.id, name: "#\($0.name)") }, prompt: "Select a channel..." ) - // AI block + // AI blocks case .generateAIResponse: VStack(alignment: .leading, spacing: 6) { Text("AI Prompt") @@ -1213,12 +1233,59 @@ struct ActionSectionView: View { .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) } + + 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." + } + } } // MARK: - Validation Banner From fa42845797eaf6eb81edfb5c533fdba3afce5004 Mon Sep 17 00:00:00 2001 From: johnwatso Date: Thu, 12 Mar 2026 00:32:06 +1300 Subject: [PATCH 13/18] Update Models.swift --- SwiftBotApp/Models.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/SwiftBotApp/Models.swift b/SwiftBotApp/Models.swift index 8cbaed0..a4ee048 100644 --- a/SwiftBotApp/Models.swift +++ b/SwiftBotApp/Models.swift @@ -3387,16 +3387,15 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { /// Category for block library organization var category: BlockCategory { switch self { - case .sendMessage, .sendDM, .replyToTrigger, .disableMention: + case .replyToTrigger, .disableMention, .sendToChannel, .mentionUser, .mentionRole: return .messaging - case .addReaction, .deleteMessage: + 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 - case .createChannel, .webhook, .addLogEntry, .setStatus, .delay, .setVariable, .randomChoice, .sendToChannel, .mentionUser, .mentionRole: - return .actions } } } From 9d15625588c1bc83950a18af39565a81c1d55433 Mon Sep 17 00:00:00 2001 From: johnwatso Date: Thu, 12 Mar 2026 07:17:15 +1300 Subject: [PATCH 14/18] [Beta] Improvements to WEBUI Actions --- SwiftBotApp/AdminWebServer.swift | 769 +++++++++++++++++++++++++++++++ SwiftBotApp/AppModel.swift | 2 + 2 files changed, 771 insertions(+) diff --git a/SwiftBotApp/AdminWebServer.swift b/SwiftBotApp/AdminWebServer.swift index 7db45ce..34852f0 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,765 @@ 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 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() }, + variables: ContextVariable.allCases.map { $0.toMetadata() }, + schemaVersion: 1 + ) + } +} + +// 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: + 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: + return true + default: + return false + } + } + + /// Field metadata for each action type + private var fieldMetadata: [AdminWebFieldMetadata] { + switch self { + case .sendMessage: + return [ + AdminWebFieldMetadata( + id: "serverId", + name: "Server", + type: .searchablePicker, + required: false, + defaultValue: nil, + description: "Target server", + placeholder: "Select a server", + optionsSource: .servers + ), + AdminWebFieldMetadata( + id: "channelId", + name: "Channel", + type: .searchablePicker, + required: false, + defaultValue: nil, + description: "Target channel", + placeholder: "Select a channel", + optionsSource: .textChannels + ), + AdminWebFieldMetadata( + id: "message", + name: "Message Content", + type: .multiline, + required: true, + defaultValue: nil, + description: "Message content with variable support", + 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: + 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.swift b/SwiftBotApp/AppModel.swift index 35de2d2..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) ) From 1a3c265888c34b67ed4b375b129758d2843665ff Mon Sep 17 00:00:00 2001 From: johnwatso Date: Thu, 12 Mar 2026 09:28:49 +1300 Subject: [PATCH 15/18] [Beta] ongoing actions changes --- SwiftBotApp/AdminWebServer.swift | 4 + SwiftBotApp/Resources/admin/index.html | 558 +++++++++++++++++++++---- SwiftBotApp/VoiceActionsView.swift | 256 +++++++----- 3 files changed, 618 insertions(+), 200 deletions(-) diff --git a/SwiftBotApp/AdminWebServer.swift b/SwiftBotApp/AdminWebServer.swift index 34852f0..d8ff7c6 100644 --- a/SwiftBotApp/AdminWebServer.swift +++ b/SwiftBotApp/AdminWebServer.swift @@ -1679,6 +1679,9 @@ struct AdminWebBuilderMetadata: Codable { /// 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] @@ -1784,6 +1787,7 @@ extension 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: 1 ) diff --git a/SwiftBotApp/Resources/admin/index.html b/SwiftBotApp/Resources/admin/index.html index 6114da8..ac39cf7 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,102 @@

${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; } + // 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: (actionsData?.servers?.[0]?.id || ''), + channelId: '', + mentionUser: true, + replyToTriggerMessage: false, + 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 +2850,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 +2888,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 +2999,260 @@

${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); + + return ` +
+
+ ${icon} + ${meta.name} + +
+ ${fields} +
+ `; + }).join(''); + } + + // Render fields for a specific block based on metadata + function renderBlockFields(block, meta, blockType, idx) { + const fields = meta.fields || []; + if (fields.length === 0) { + return `
${meta.description || 'No configuration needed'}
`; + } + + const serverID = block.serverId || actionsData.servers?.[0]?.id || ''; + + return fields.map(field => { + const value = block[field.id] || field.defaultValue || ''; + const placeholder = field.placeholder || ''; + + // 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 { + 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); + + // 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 (isCheckbox) { + fieldEl.addEventListener('change', handler); + } else if (fieldEl.tagName === 'SELECT') { + fieldEl.addEventListener('change', handler); + } else { + fieldEl.addEventListener('input', handler); + } + }); + }); + } + + // 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 +3262,7 @@

${metric.title}

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

${metric.title}

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

${metric.title}

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

${metric.title}

Type
-
-
Server
- -
-
-
Channel
- -
Message
-
-
Status Text
- -
-
- - Mention User -
-
- - Reply to Trigger -
-
- - Reply with AI -
@@ -2931,30 +3327,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 c1a6f77..cae4bba 100644 --- a/SwiftBotApp/VoiceActionsView.swift +++ b/SwiftBotApp/VoiceActionsView.swift @@ -144,6 +144,140 @@ 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, + 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, + 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, + 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) { @@ -213,116 +347,7 @@ struct RuleEditorView: View { } ScrollView { - 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) - - // AI Processing Section - RuleCanvasSection(title: "AI Processing", systemImage: "sparkles", accent: .purple) { - ActionsSectionView( - actions: $rule.aiBlocks, - category: .ai, - allModifiers: rule.modifiers, - 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, // Used to be .modifiers - allModifiers: rule.modifiers, - 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, // Used to be .messaging - allModifiers: rule.modifiers, - 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 ?? [] - ) - } - } - } + ruleCanvasContent .animation(.easeInOut(duration: 0.22), value: rule.isEmptyRule) .frame(maxWidth: 880, alignment: .leading) .padding(.horizontal, 20) @@ -393,10 +418,15 @@ struct RuleEditorView: View { break } - // Fix: Route modifiers to rule.modifiers, actions to rule.actions - if type.category == .messaging { + // Route blocks to their correct section based on category + switch type.category { + case .ai: + rule.aiBlocks.append(action) + case .messaging: rule.modifiers.append(action) - } else { + case .actions, .moderation: + rule.actions.append(action) + default: rule.actions.append(action) } app.ruleStore.scheduleAutoSave() From 7765cf44a735f67401260b2a5a07fa91ba01f6bf Mon Sep 17 00:00:00 2001 From: johnwatso Date: Thu, 12 Mar 2026 10:20:56 +1300 Subject: [PATCH 16/18] [Beta] action build out --- SwiftBotApp/AdminWebServer.swift | 34 ++++++++-- SwiftBotApp/DiscordService.swift | 70 ++++++++++++++++---- SwiftBotApp/Models.swift | 90 ++++++++++++++++++++++++-- SwiftBotApp/Resources/admin/index.html | 57 +++++++++++++++- SwiftBotApp/VoiceActionsView.swift | 80 ++++++++++++++++------- 5 files changed, 283 insertions(+), 48 deletions(-) diff --git a/SwiftBotApp/AdminWebServer.swift b/SwiftBotApp/AdminWebServer.swift index d8ff7c6..3528df1 100644 --- a/SwiftBotApp/AdminWebServer.swift +++ b/SwiftBotApp/AdminWebServer.swift @@ -2013,7 +2013,7 @@ extension ActionType { case .generateAIResponse, .summariseMessage, .classifyMessage, .extractEntities, .rewriteMessage, .delay, .setVariable, .randomChoice, .replyToTrigger, .mentionUser, .mentionRole, - .disableMention, .sendToChannel: + .disableMention, .sendToChannel, .sendToDM: return false } } @@ -2033,7 +2033,7 @@ extension ActionType { private var isModifier: Bool { switch self { case .replyToTrigger, .mentionUser, .mentionRole, - .disableMention, .sendToChannel: + .disableMention, .sendToChannel, .sendToDM: return true default: return false @@ -2045,13 +2045,23 @@ extension ActionType { switch self { case .sendMessage: return [ + AdminWebFieldMetadata( + id: "destinationMode", + name: "Destination", + type: .picker, + required: true, + defaultValue: "specificChannel", + description: "Where to send the message (Reply to Trigger requires a message trigger)", + placeholder: nil, + optionsSource: .predefined + ), AdminWebFieldMetadata( id: "serverId", name: "Server", type: .searchablePicker, required: false, defaultValue: nil, - description: "Target server", + description: "Target server (only used when Destination is 'Specific Channel')", placeholder: "Select a server", optionsSource: .servers ), @@ -2061,17 +2071,27 @@ extension ActionType { type: .searchablePicker, required: false, defaultValue: nil, - description: "Target channel", + description: "Target channel (only used when Destination is 'Specific Channel', ignored if Send To DM modifier is active)", 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: true, + required: false, defaultValue: nil, - description: "Message content with variable support", + description: "Message content (only used when Content Source is 'Custom Message')", placeholder: "Enter message content...", optionsSource: nil ) @@ -2340,7 +2360,7 @@ extension ActionType { ] // Modifiers - no additional fields beyond the toggle - case .replyToTrigger, .mentionUser, .mentionRole, .disableMention, .sendToChannel: + case .replyToTrigger, .mentionUser, .mentionRole, .disableMention, .sendToChannel, .sendToDM: return [] case .randomChoice: diff --git a/SwiftBotApp/DiscordService.swift b/SwiftBotApp/DiscordService.swift index a4adec2..59509dd 100644 --- a/SwiftBotApp/DiscordService.swift +++ b/SwiftBotApp/DiscordService.swift @@ -2360,6 +2360,8 @@ actor DiscordService { context.mentionUser = false case .sendToChannel: context.targetChannelId = action.channelId + case .sendToDM: + context.sendToDM = true case .replyToTrigger: context.replyToTriggerMessage = true if let triggerChannelId = event.triggerChannelId { @@ -2398,29 +2400,71 @@ actor DiscordService { context.aiRewrite = rewrite } case .sendMessage: - let targetChannelId = context.targetChannelId ?? action.channelId - guard !targetChannelId.isEmpty || (context.targetChannelId == nil && !event.userId.isEmpty) else { return } + // 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" + } + + // Determine destination based on destinationMode (per UX spec) + let destinationMode = action.destinationMode ?? .specificChannel + let targetIsDM = context.sendToDM // DM modifier takes precedence + + // Resolve target channel and whether to reply + let targetChannelId: String + let shouldReply: Bool + + switch destinationMode { + case .replyToTrigger: + // Reply to the triggering message in its channel + targetChannelId = event.triggerChannelId ?? action.channelId + shouldReply = event.triggerMessageId != nil + case .sameChannel: + // Send in same channel as trigger (no reply reference) + targetChannelId = event.triggerChannelId ?? action.channelId + shouldReply = false + case .specificChannel: + // Send to explicitly configured channel + targetChannelId = action.channelId + // Fallback: if configured channel matches trigger channel, could reply + shouldReply = (action.channelId == event.triggerChannelId && event.triggerMessageId != nil) + } + + guard targetIsDM || !targetChannelId.isEmpty else { return } - let rendered = renderMessage(template: action.message, event: event, context: context) + let rendered = renderMessage(template: messageContent, event: event, context: context) - if context.replyToTriggerMessage, - let triggerMessageId = event.triggerMessageId, - let replyChannelId = event.triggerChannelId { + if targetIsDM && !event.userId.isEmpty { + // DM modifier takes precedence + _ = try? await sendDM(userId: event.userId, content: rendered) + context.eventHandled = true + } else if shouldReply, + let triggerMessageId = event.triggerMessageId, + !targetChannelId.isEmpty { + // Send as reply to trigger message let payload: [String: Any] = [ "content": rendered, "message_reference": [ "message_id": triggerMessageId, - "channel_id": replyChannelId, + "channel_id": targetChannelId, "fail_if_not_exists": false ] ] - _ = try? await sendMessage(channelId: replyChannelId, payload: payload, token: token) + _ = try? await sendMessage(channelId: targetChannelId, payload: payload, token: token) context.eventHandled = true - } else if context.targetChannelId == nil && !event.userId.isEmpty { - // Send to DM - _ = try? await sendDM(userId: event.userId, content: rendered) - context.eventHandled = true - } else { + } else if !targetChannelId.isEmpty { + // Send regular message to target channel try? await sendMessage(channelId: targetChannelId, content: rendered, token: token) context.eventHandled = true } diff --git a/SwiftBotApp/Models.swift b/SwiftBotApp/Models.swift index a4ee048..53ba97d 100644 --- a/SwiftBotApp/Models.swift +++ b/SwiftBotApp/Models.swift @@ -2525,6 +2525,7 @@ struct PipelineContext: CustomStringConvertible { var replyToTriggerMessage: Bool = false var mentionRole: String? var isDirectMessage: Bool = false + var sendToDM: Bool = false var eventHandled: Bool = false var description: String { @@ -3281,6 +3282,7 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { 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" @@ -3314,6 +3316,7 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { 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" @@ -3330,7 +3333,7 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { case .deleteMessage, .addReaction, .replyToTrigger: return [.message, .messageId] - case .addRole, .removeRole, .timeoutMember, .kickMember, .moveMember, .mentionUser, .disableMention: + case .addRole, .removeRole, .timeoutMember, .kickMember, .moveMember, .mentionUser, .disableMention, .sendToDM: return [.user, .userId] case .sendToChannel: return [.channel] @@ -3355,7 +3358,7 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { case .sendMessage, .sendDM, .deleteMessage, .addReaction, .addRole, .removeRole, .timeoutMember, .kickMember, .moveMember, .createChannel, .webhook, .setStatus, .addLogEntry, .delay, .setVariable, .randomChoice, .replyToTrigger, - .mentionUser, .mentionRole, .disableMention, .sendToChannel: + .mentionUser, .mentionRole, .disableMention, .sendToChannel, .sendToDM: return [] } } @@ -3363,7 +3366,7 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { /// Discord permissions required for this action var requiredPermissions: Set { switch self { - case .sendMessage, .sendDM, .addLogEntry, .setStatus, .delay, .setVariable, .randomChoice, .generateAIResponse, .mentionUser, .mentionRole, .disableMention, .sendToChannel, .replyToTrigger, .summariseMessage, .classifyMessage, .extractEntities, .rewriteMessage: + case .sendMessage, .sendDM, .addLogEntry, .setStatus, .delay, .setVariable, .randomChoice, .generateAIResponse, .mentionUser, .mentionRole, .disableMention, .sendToChannel, .sendToDM, .replyToTrigger, .summariseMessage, .classifyMessage, .extractEntities, .rewriteMessage: return [] case .deleteMessage: return [.manageMessages] @@ -3387,7 +3390,7 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { /// Category for block library organization var category: BlockCategory { switch self { - case .replyToTrigger, .disableMention, .sendToChannel, .mentionUser, .mentionRole: + case .replyToTrigger, .disableMention, .sendToChannel, .sendToDM, .mentionUser, .mentionRole: return .messaging case .sendMessage, .sendDM, .addReaction, .deleteMessage, .createChannel, .webhook, .addLogEntry, .setStatus, .delay, .setVariable, .randomChoice: @@ -3476,6 +3479,12 @@ struct RuleAction: Identifiable, Codable, Equatable { 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 @@ -3505,6 +3514,8 @@ struct RuleAction: Identifiable, Codable, Equatable { case categories case entityTypes case rewriteStyle + case contentSource + case destinationMode } init() {} @@ -3538,6 +3549,39 @@ struct RuleAction: Identifiable, Codable, Equatable { 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 { @@ -3569,6 +3613,44 @@ struct RuleAction: Identifiable, Codable, Equatable { 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" + } } } diff --git a/SwiftBotApp/Resources/admin/index.html b/SwiftBotApp/Resources/admin/index.html index ac39cf7..af212f1 100644 --- a/SwiftBotApp/Resources/admin/index.html +++ b/SwiftBotApp/Resources/admin/index.html @@ -3134,8 +3134,12 @@

${metric.title}

`; case 'multiline': + // Message field only shows when contentSource is 'custom' + const isMessageField = field.id === 'message'; + const msgConditionalAttr = isMessageField ? ' data-show-if-content="custom"' : ''; + const msgHiddenStyle = isMessageField && block.contentSource !== 'custom' && block.contentSource !== undefined ? ' style="display:none;"' : ''; return ` -
+
${field.name}
Use {variable} syntax for context variables
@@ -3169,11 +3173,34 @@

${metric.title}

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') { + // Message destination mode predefined options + const destinations = [ + { id: 'replyToTrigger', name: 'Reply to Trigger Message' }, + { id: 'sameChannel', name: 'Same Channel as Trigger' }, + { id: 'specificChannel', name: 'Specific Channel' } + ]; + options = destinations.map(d => ``).join(''); } else { options = ``; } + // Conditional visibility for server/channel fields based on destinationMode + const isConditionalField = field.id === 'serverId' || field.id === 'channelId'; + const conditionalAttr = isConditionalField ? ' data-show-if-destination="specificChannel"' : ''; + const hiddenStyle = isConditionalField && block.destinationMode !== 'specificChannel' && block.destinationMode !== undefined ? ' style="display:none;"' : ''; return ` -
+
${field.name}
@@ -3247,6 +3274,32 @@

${metric.title}

} else { fieldEl.addEventListener('input', handler); } + + // Special handling for destinationMode to show/hide server/channel fields + if (fieldId === 'destinationMode') { + fieldEl.addEventListener('change', (e) => { + const showSpecific = e.target.value === 'specificChannel'; + const parent = blockEl.closest('.block-row'); + if (parent) { + parent.querySelectorAll('[data-show-if-destination]').forEach(el => { + el.style.display = showSpecific ? '' : 'none'; + }); + } + }); + } + + // Special handling for contentSource to show/hide message field + if (fieldId === 'contentSource') { + fieldEl.addEventListener('change', (e) => { + const showCustom = e.target.value === 'custom'; + const parent = blockEl.closest('.block-row'); + if (parent) { + parent.querySelectorAll('[data-show-if-content]').forEach(el => { + el.style.display = showCustom ? '' : 'none'; + }); + } + }); + } }); }); } diff --git a/SwiftBotApp/VoiceActionsView.swift b/SwiftBotApp/VoiceActionsView.swift index cae4bba..9e391e0 100644 --- a/SwiftBotApp/VoiceActionsView.swift +++ b/SwiftBotApp/VoiceActionsView.swift @@ -395,7 +395,7 @@ struct RuleEditorView: View { case .setStatus: action.statusText = "Handling \(rule.trigger?.rawValue.lowercased() ?? "action")" // Modifier blocks - case .replyToTrigger, .mentionUser, .mentionRole, .disableMention, .sendToChannel: + case .replyToTrigger, .mentionUser, .mentionRole, .disableMention, .sendToChannel, .sendToDM: break // AI blocks case .generateAIResponse: @@ -1116,35 +1116,67 @@ 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) - } + // Destination Mode picker (per UX spec) + Picker("Destination", selection: Binding( + get: { action.destinationMode ?? .specificChannel }, + set: { action.destinationMode = $0 } + )) { + ForEach(MessageDestination.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) } - if textChannels.isEmpty { - Text("No text channels discovered for this server.") + } + + // Only show server/channel pickers for specificChannel mode + if action.destinationMode == .specificChannel || action.destinationMode == nil { + 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("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") + + // Content Source picker + Picker("Content Source", selection: $action.contentSource) { + ForEach(ContentSource.allCases, id: \.self) { source in + Text(source.displayName).tag(source) + } + } + + // Show message editor for custom content, AI source info for AI content + 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 { + // Show AI content source info + HStack { + Image(systemName: "wand.and.stars") + .foregroundStyle(.indigo) + Text("Uses AI output from \(action.contentSource.displayName)") .font(.caption) - .foregroundStyle(.orange) + .foregroundStyle(.secondary) } } @@ -1253,6 +1285,10 @@ struct ActionSectionView: View { 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) { From fe6c1b92ebe8b34dae80918465443cfd5ca08137 Mon Sep 17 00:00:00 2001 From: johnwatso Date: Thu, 12 Mar 2026 10:51:32 +1300 Subject: [PATCH 17/18] [Beta] Action improvements --- SwiftBotApp/AdminWebServer.swift | 14 ++------- SwiftBotApp/DiscordService.swift | 30 ++++--------------- SwiftBotApp/Models.swift | 2 +- SwiftBotApp/Resources/admin/index.html | 29 ++----------------- SwiftBotApp/VoiceActionsView.swift | 40 +++++++++----------------- 5 files changed, 25 insertions(+), 90 deletions(-) diff --git a/SwiftBotApp/AdminWebServer.swift b/SwiftBotApp/AdminWebServer.swift index 3528df1..5761767 100644 --- a/SwiftBotApp/AdminWebServer.swift +++ b/SwiftBotApp/AdminWebServer.swift @@ -2045,23 +2045,13 @@ extension ActionType { switch self { case .sendMessage: return [ - AdminWebFieldMetadata( - id: "destinationMode", - name: "Destination", - type: .picker, - required: true, - defaultValue: "specificChannel", - description: "Where to send the message (Reply to Trigger requires a message trigger)", - placeholder: nil, - optionsSource: .predefined - ), AdminWebFieldMetadata( id: "serverId", name: "Server", type: .searchablePicker, required: false, defaultValue: nil, - description: "Target server (only used when Destination is 'Specific Channel')", + description: "Target server (used as fallback when no routing modifier is active)", placeholder: "Select a server", optionsSource: .servers ), @@ -2071,7 +2061,7 @@ extension ActionType { type: .searchablePicker, required: false, defaultValue: nil, - description: "Target channel (only used when Destination is 'Specific Channel', ignored if Send To DM modifier is active)", + description: "Target channel (used as fallback when no routing modifier is active, ignored if Send To DM modifier is active)", placeholder: "Select a channel", optionsSource: .textChannels ), diff --git a/SwiftBotApp/DiscordService.swift b/SwiftBotApp/DiscordService.swift index 59509dd..9de9010 100644 --- a/SwiftBotApp/DiscordService.swift +++ b/SwiftBotApp/DiscordService.swift @@ -2417,29 +2417,11 @@ actor DiscordService { messageContent = context.aiRewrite ?? "{ai.rewrite} not available" } - // Determine destination based on destinationMode (per UX spec) - let destinationMode = action.destinationMode ?? .specificChannel - let targetIsDM = context.sendToDM // DM modifier takes precedence - - // Resolve target channel and whether to reply - let targetChannelId: String - let shouldReply: Bool - - switch destinationMode { - case .replyToTrigger: - // Reply to the triggering message in its channel - targetChannelId = event.triggerChannelId ?? action.channelId - shouldReply = event.triggerMessageId != nil - case .sameChannel: - // Send in same channel as trigger (no reply reference) - targetChannelId = event.triggerChannelId ?? action.channelId - shouldReply = false - case .specificChannel: - // Send to explicitly configured channel - targetChannelId = action.channelId - // Fallback: if configured channel matches trigger channel, could reply - shouldReply = (action.channelId == event.triggerChannelId && event.triggerMessageId != nil) - } + // Determine destination based on modifiers (pipeline architecture) + // Priority: DM modifier > Reply To Trigger modifier > Send To Channel modifier > Action's channelId + let targetIsDM = context.sendToDM + let shouldReply = context.replyToTriggerMessage + let targetChannelId = context.targetChannelId ?? action.channelId guard targetIsDM || !targetChannelId.isEmpty else { return } @@ -2452,7 +2434,7 @@ actor DiscordService { } else if shouldReply, let triggerMessageId = event.triggerMessageId, !targetChannelId.isEmpty { - // Send as reply to trigger message + // Reply To Trigger modifier - send as reply to trigger message let payload: [String: Any] = [ "content": rendered, "message_reference": [ diff --git a/SwiftBotApp/Models.swift b/SwiftBotApp/Models.swift index 53ba97d..29d1749 100644 --- a/SwiftBotApp/Models.swift +++ b/SwiftBotApp/Models.swift @@ -3366,7 +3366,7 @@ enum ActionType: String, CaseIterable, Identifiable, Codable { /// Discord permissions required for this action var requiredPermissions: Set { switch self { - case .sendMessage, .sendDM, .addLogEntry, .setStatus, .delay, .setVariable, .randomChoice, .generateAIResponse, .mentionUser, .mentionRole, .disableMention, .sendToChannel, .sendToDM, .replyToTrigger, .summariseMessage, .classifyMessage, .extractEntities, .rewriteMessage: + case .sendMessage, .sendDM, .addLogEntry, .setStatus, .delay, .setVariable, .randomChoice, .generateAIResponse, .replyToTrigger, .mentionUser, .mentionRole, .disableMention, .sendToChannel, .sendToDM, .summariseMessage, .classifyMessage, .extractEntities, .rewriteMessage: return [] case .deleteMessage: return [.manageMessages] diff --git a/SwiftBotApp/Resources/admin/index.html b/SwiftBotApp/Resources/admin/index.html index af212f1..6abfec2 100644 --- a/SwiftBotApp/Resources/admin/index.html +++ b/SwiftBotApp/Resources/admin/index.html @@ -2813,7 +2813,7 @@

${metric.title}

serverId: (actionsData?.servers?.[0]?.id || ''), channelId: '', mentionUser: true, - replyToTriggerMessage: false, + replyToTriggerMessage: false, // Legacy field - migrated to modifier block message: '', statusText: '', // AI block fields @@ -3184,23 +3184,11 @@

${metric.title}

{ id: 'ai.rewrite', name: 'AI Rewrite' } ]; options = sources.map(s => ``).join(''); - } else if (field.optionsSource === 'predefined' && field.id === 'destinationMode') { - // Message destination mode predefined options - const destinations = [ - { id: 'replyToTrigger', name: 'Reply to Trigger Message' }, - { id: 'sameChannel', name: 'Same Channel as Trigger' }, - { id: 'specificChannel', name: 'Specific Channel' } - ]; - options = destinations.map(d => ``).join(''); } else { options = ``; } - // Conditional visibility for server/channel fields based on destinationMode - const isConditionalField = field.id === 'serverId' || field.id === 'channelId'; - const conditionalAttr = isConditionalField ? ' data-show-if-destination="specificChannel"' : ''; - const hiddenStyle = isConditionalField && block.destinationMode !== 'specificChannel' && block.destinationMode !== undefined ? ' style="display:none;"' : ''; return ` -
+
${field.name}
@@ -3275,19 +3263,6 @@

${metric.title}

fieldEl.addEventListener('input', handler); } - // Special handling for destinationMode to show/hide server/channel fields - if (fieldId === 'destinationMode') { - fieldEl.addEventListener('change', (e) => { - const showSpecific = e.target.value === 'specificChannel'; - const parent = blockEl.closest('.block-row'); - if (parent) { - parent.querySelectorAll('[data-show-if-destination]').forEach(el => { - el.style.display = showSpecific ? '' : 'none'; - }); - } - }); - } - // Special handling for contentSource to show/hide message field if (fieldId === 'contentSource') { fieldEl.addEventListener('change', (e) => { diff --git a/SwiftBotApp/VoiceActionsView.swift b/SwiftBotApp/VoiceActionsView.swift index 9e391e0..7ec1deb 100644 --- a/SwiftBotApp/VoiceActionsView.swift +++ b/SwiftBotApp/VoiceActionsView.swift @@ -1116,35 +1116,23 @@ struct ActionSectionView: View { switch action.type { case .sendMessage: - // Destination Mode picker (per UX spec) - Picker("Destination", selection: Binding( - get: { action.destinationMode ?? .specificChannel }, - set: { action.destinationMode = $0 } - )) { - ForEach(MessageDestination.allCases, id: \.self) { mode in - Text(mode.displayName).tag(mode) + // Server/Channel selection (used when no routing modifier is active) + 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) + } } - } - - // Only show server/channel pickers for specificChannel mode - if action.destinationMode == .specificChannel || action.destinationMode == nil { - if serverIds.isEmpty { - Text("No connected servers available yet.") + if textChannels.isEmpty { + Text("No text channels discovered for this server.") .foregroundStyle(.secondary) } else { - 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) - } + Picker("Text Channel", selection: $action.channelId) { + ForEach(textChannels) { channel in + Text("#\(channel.name)").tag(channel.id) } } } From 019cd8f3951e8ededd168e7b1a3d610d0b75b5bc Mon Sep 17 00:00:00 2001 From: johnwatso Date: Thu, 12 Mar 2026 11:23:30 +1300 Subject: [PATCH 18/18] [Beta] Fixed Final Send Action --- SwiftBotApp/AdminWebServer.swift | 16 ++- SwiftBotApp/DiscordService.swift | 67 +++++++--- SwiftBotApp/Models.swift | 67 +++++++++- SwiftBotApp/Resources/admin/index.html | 93 +++++++++---- SwiftBotApp/VoiceActionsView.swift | 175 +++++++++++++++++-------- 5 files changed, 317 insertions(+), 101 deletions(-) diff --git a/SwiftBotApp/AdminWebServer.swift b/SwiftBotApp/AdminWebServer.swift index 5761767..57a138c 100644 --- a/SwiftBotApp/AdminWebServer.swift +++ b/SwiftBotApp/AdminWebServer.swift @@ -1789,7 +1789,7 @@ extension AdminWebBuilderMetadata { categories: BlockCategory.allCases.map { $0.toMetadata() }, blocks: ActionType.allCases.map { $0.toMetadata() }, variables: ContextVariable.allCases.map { $0.toMetadata() }, - schemaVersion: 1 + schemaVersion: 2 ) } } @@ -2045,13 +2045,23 @@ extension ActionType { 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: "Target server (used as fallback when no routing modifier is active)", + description: "Only used when Destination is 'Specific Channel'", placeholder: "Select a server", optionsSource: .servers ), @@ -2061,7 +2071,7 @@ extension ActionType { type: .searchablePicker, required: false, defaultValue: nil, - description: "Target channel (used as fallback when no routing modifier is active, ignored if Send To DM modifier is active)", + description: "Only used when Destination is 'Specific Channel'", placeholder: "Select a channel", optionsSource: .textChannels ), diff --git a/SwiftBotApp/DiscordService.swift b/SwiftBotApp/DiscordService.swift index 9de9010..e802aa2 100644 --- a/SwiftBotApp/DiscordService.swift +++ b/SwiftBotApp/DiscordService.swift @@ -2053,6 +2053,9 @@ actor DiscordService { 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)") @@ -2417,36 +2420,68 @@ actor DiscordService { messageContent = context.aiRewrite ?? "{ai.rewrite} not available" } - // Determine destination based on modifiers (pipeline architecture) - // Priority: DM modifier > Reply To Trigger modifier > Send To Channel modifier > Action's channelId let targetIsDM = context.sendToDM - let shouldReply = context.replyToTriggerMessage - let targetChannelId = context.targetChannelId ?? action.channelId - - guard targetIsDM || !targetChannelId.isEmpty else { return } - let rendered = renderMessage(template: messageContent, event: event, context: context) if targetIsDM && !event.userId.isEmpty { - // DM modifier takes precedence _ = try? await sendDM(userId: event.userId, content: rendered) context.eventHandled = true - } else if shouldReply, - let triggerMessageId = event.triggerMessageId, - !targetChannelId.isEmpty { - // Reply To Trigger modifier - send as reply to trigger message + 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) + _ = 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 - } else if !targetChannelId.isEmpty { - // Send regular message to target channel + case .specificChannel: + let targetChannelId = modifierTargetChannelId ?? action.channelId + guard !targetChannelId.isEmpty else { return } try? await sendMessage(channelId: targetChannelId, content: rendered, token: token) context.eventHandled = true } diff --git a/SwiftBotApp/Models.swift b/SwiftBotApp/Models.swift index 29d1749..849562f 100644 --- a/SwiftBotApp/Models.swift +++ b/SwiftBotApp/Models.swift @@ -2518,6 +2518,9 @@ struct PipelineContext: CustomStringConvertible { 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 @@ -2532,7 +2535,8 @@ struct PipelineContext: CustomStringConvertible { let ai = aiResponse != nil ? "AI(\(aiResponse!.count) chars)" : "nil" let summary = aiSummary != nil ? "Summary(\(aiSummary!.count) chars)" : "nil" let target = targetChannelId ?? "default" - return "[PipelineContext target: \(target), mentionUser: \(mentionUser), prepend: \(prependUserMention), reply: \(replyToTriggerMessage), role: \(mentionRole ?? "nil"), ai: \(ai), summary: \(summary), handled: \(eventHandled)]" + 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)]" } } @@ -3586,13 +3590,15 @@ struct RuleAction: Identifiable, Codable, Equatable { 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 @@ -3654,6 +3660,29 @@ enum MessageDestination: String, Codable, CaseIterable { } } +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 + } +} + typealias Action = RuleAction struct Rule: Identifiable, Codable, Equatable { @@ -3780,6 +3809,19 @@ struct Rule: Identifiable, Codable, Equatable { 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: @@ -3798,7 +3840,7 @@ struct Rule: Identifiable, Codable, Equatable { var actionWithModifiers = action // Legacy: replyWithAI toggle creates an AI block - if action.replyWithAI { + if action.type == .sendMessage && action.replyWithAI && action.contentSource == .custom { var aiBlock = RuleAction() aiBlock.type = .generateAIResponse // Insert AI block at the beginning (before modifiers) @@ -3807,7 +3849,7 @@ struct Rule: Identifiable, Codable, Equatable { } // Extract reply-to-trigger as a modifier - if action.replyToTriggerMessage { + if action.type == .sendMessage && action.replyToTriggerMessage && action.destinationMode == nil { var replyBlock = RuleAction() replyBlock.type = .replyToTrigger pipeline.append(replyBlock) @@ -3926,7 +3968,9 @@ struct Rule: Identifiable, Codable, Equatable { } // Task 5: Prevent empty Send Message actions - if action.type == .sendMessage && action.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + 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.", @@ -3934,6 +3978,17 @@ struct Rule: Identifiable, Codable, Equatable { 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 diff --git a/SwiftBotApp/Resources/admin/index.html b/SwiftBotApp/Resources/admin/index.html index 6abfec2..b1f0031 100644 --- a/SwiftBotApp/Resources/admin/index.html +++ b/SwiftBotApp/Resources/admin/index.html @@ -2777,6 +2777,18 @@

${metric.title}

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 @@ -2810,8 +2822,10 @@

${metric.title}

const base = { id: uuid(), type: blockId, - serverId: (actionsData?.servers?.[0]?.id || ''), + serverId: '', channelId: '', + contentSource: 'custom', + destinationMode: blockId === 'Send Message' ? 'replyToTrigger' : undefined, mentionUser: true, replyToTriggerMessage: false, // Legacy field - migrated to modifier block message: '', @@ -3096,7 +3110,7 @@

${metric.title}

description: '' }; const icon = sfSymbolToMaterial(meta.symbol); - const fields = renderBlockFields(block, meta, blockType, idx); + const fields = renderBlockFields(block, meta, blockType, idx, rule); return `
@@ -3112,34 +3126,38 @@

${metric.title}

} // Render fields for a specific block based on metadata - function renderBlockFields(block, meta, blockType, idx) { + 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 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': - // Message field only shows when contentSource is 'custom' - const isMessageField = field.id === 'message'; - const msgConditionalAttr = isMessageField ? ' data-show-if-content="custom"' : ''; - const msgHiddenStyle = isMessageField && block.contentSource !== 'custom' && block.contentSource !== undefined ? ' style="display:none;"' : ''; return ` -
+
${field.name}
Use {variable} syntax for context variables
@@ -3147,14 +3165,14 @@

${metric.title}

`; case 'number': return ` -
+
${field.name}
`; case 'boolean': return ` -
+