diff --git a/AGENTS.md b/AGENTS.md index 8429a55..1b5f05a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,9 @@ DatabaseHandler (SQLite polling) | `Jared/DatabaseHandler.swift` | Polls `chat.db` (WAL watch + interval), applies inbound deferral policy, feeds `Message`s to `Router` | | `Jared/ChatDBReader.swift` | SQLite read layer: SQL → row DTOs (`MessageRow`, `GroupParticipantRow`, `AttachmentRow`), incl. `attributedBody` decoding. No `Message`/routing knowledge | | `Jared/MessageBuilder.swift` | DTO → `Message`/`Person`/`Group`/`Attachment`; injectable `contactNameResolver` (unit-testable without SQLite or Contacts) | -| `Jared/Router.swift` | Matches incoming messages against all registered `Route`s | +| `Jared/Router.swift` | Inbound pipeline runner: notify delegates → `InboundFilterPolicy` → match (`MessageMatcher`) → invoke route | +| `Jared/InboundFilterPolicy.swift` | Inbound gate: self-message, body-type, global-disabled + `/enable` bypass; reads `RuntimeFlags` | +| `Jared/RuntimeFlags.swift` | Port abstracting Jared's disabled state; `UserDefaultsRuntimeFlags` is the production read | | `Jared/PluginManager.swift` | Manages the list of built-in `RoutingModule`s | | `Jared/CoreModule.swift` | Built-in commands: `/ping`, `/send`, `/schedule`, `/name`, `/whoami`, `/barf`, etc. | | `Jared/InternalModule.swift` | `/help`, `/reload`, `/enable`, `/disable` commands | diff --git a/Jared.xcodeproj/project.pbxproj b/Jared.xcodeproj/project.pbxproj index 3351594..47611ec 100644 --- a/Jared.xcodeproj/project.pbxproj +++ b/Jared.xcodeproj/project.pbxproj @@ -20,6 +20,9 @@ B319236224DE76A2001904BA /* JaredUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B319236124DE76A2001904BA /* JaredUITests.swift */; }; B32DE51524D51F9C00AB3A71 /* RouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32DE51424D51F9C00AB3A71 /* RouterTests.swift */; }; B32DE51624D522DA00AB3A71 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BF17C1244EBE9A00CC44C5 /* Router.swift */; }; + EC70RF00000000000000A004 /* RuntimeFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC70RF00000000000000A001 /* RuntimeFlags.swift */; }; + EC70FP00000000000000A004 /* InboundFilterPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC70FP00000000000000A001 /* InboundFilterPolicy.swift */; }; + EC70PT00000000000000A002 /* InboundFilterPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC70PT00000000000000A001 /* InboundFilterPolicyTests.swift */; }; B32DE51A24D5524300AB3A71 /* Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32DE51924D5524300AB3A71 /* Action.swift */; }; B32DE51C24D552D300AB3A71 /* MockPluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32DE51B24D552D300AB3A71 /* MockPluginManager.swift */; }; B332787A1CB2CB1300BBDD3A /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B33278791CB2CB1300BBDD3A /* AppDelegate.swift */; }; @@ -107,6 +110,8 @@ B3B844D224DA6BBB00DC162A /* JaredWebServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C59283224DD17900116ECB /* JaredWebServer.swift */; }; B3BBA7D11CB3284A00E1749C /* CoreModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = B39EC17D1CB01FEF002C3161 /* CoreModule.swift */; }; B3BF17C2244EBE9A00CC44C5 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BF17C1244EBE9A00CC44C5 /* Router.swift */; }; + EC70RF00000000000000A002 /* RuntimeFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC70RF00000000000000A001 /* RuntimeFlags.swift */; }; + EC70FP00000000000000A002 /* InboundFilterPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC70FP00000000000000A001 /* InboundFilterPolicy.swift */; }; B3BF17C4244EBF4900CC44C5 /* InternalModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BF17C3244EBF4900CC44C5 /* InternalModule.swift */; }; B3BF17C6244EC47700CC44C5 /* MessageDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BF17C5244EC47700CC44C5 /* MessageDelegate.swift */; }; EC000001000000000000AB01 /* RouteProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AB00 /* RouteProvider.swift */; }; @@ -262,6 +267,9 @@ B3BBA7CF1CB327C200E1749C /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Main.strings; sourceTree = ""; }; B3BBA7D01CB327C200E1749C /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; B3BF17C1244EBE9A00CC44C5 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = ""; }; + EC70RF00000000000000A001 /* RuntimeFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeFlags.swift; sourceTree = ""; }; + EC70FP00000000000000A001 /* InboundFilterPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboundFilterPolicy.swift; sourceTree = ""; }; + EC70PT00000000000000A001 /* InboundFilterPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboundFilterPolicyTests.swift; sourceTree = ""; }; B3BF17C3244EBF4900CC44C5 /* InternalModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalModule.swift; sourceTree = ""; }; B3BF17C5244EC47700CC44C5 /* MessageDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDelegate.swift; sourceTree = ""; }; EC000001000000000000AB00 /* RouteProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteProvider.swift; sourceTree = ""; }; @@ -388,6 +396,7 @@ B347A4AC24DD170C00E93657 /* ActionTest.swift */, B3B844B924D77A4800DC162A /* SendStyleTest.swift */, B32DE51424D51F9C00AB3A71 /* RouterTests.swift */, + EC70PT00000000000000A001 /* InboundFilterPolicyTests.swift */, B3B844CF24DA6AC800DC162A /* JaredWebServerTest.swift */, TM0000010000000000000005 /* TunnelManagerTests.swift */, B3B844BF24D8F91900DC162A /* DatabaseHandlerTest.swift */, @@ -463,6 +472,8 @@ B361EEBA1F1DE24C0041113C /* config.json */, B3EF1DA92206E09B00953DE7 /* Jared.swift */, B3BF17C1244EBE9A00CC44C5 /* Router.swift */, + EC70RF00000000000000A001 /* RuntimeFlags.swift */, + EC70FP00000000000000A001 /* InboundFilterPolicy.swift */, B39A65DF1CC1FB1B003E26B0 /* PluginManager.swift */, B3327DC522066427009DD882 /* WebHookManager.swift */, EC66DC00000000000000A001 /* WebhookDeliveryClient.swift */, @@ -800,6 +811,8 @@ EC000001000000000000AD01 /* SystemSleepPreventer.swift in Sources */, B3BF17C4244EBF4900CC44C5 /* InternalModule.swift in Sources */, B3BF17C2244EBE9A00CC44C5 /* Router.swift in Sources */, + EC70RF00000000000000A002 /* RuntimeFlags.swift in Sources */, + EC70FP00000000000000A002 /* InboundFilterPolicy.swift in Sources */, B375A65921EB14470073F09F /* AboutViewController.swift in Sources */, EC000001000000000000AC03 /* WebhookManagementWindowController.swift in Sources */, EC000001000000000000AC05 /* TunnelManagementWindowController.swift in Sources */, @@ -841,6 +854,8 @@ EC000001000000000000AB02 /* RouteProvider.swift in Sources */, B3B7E52524EA00BC00A3FBD1 /* JaredConstants.swift in Sources */, B32DE51624D522DA00AB3A71 /* Router.swift in Sources */, + EC70RF00000000000000A004 /* RuntimeFlags.swift in Sources */, + EC70FP00000000000000A004 /* InboundFilterPolicy.swift in Sources */, B3BF17CA244FDA3F00CC44C5 /* MessageDelegate.swift in Sources */, B3B844C524D9288000DC162A /* RouterDelegate.swift in Sources */, B3B844B824D7791800DC162A /* ActionTypeTest.swift in Sources */, @@ -864,6 +879,7 @@ TM0000010000000000000004 /* TunnelManagerTests.swift in Sources */, TM0000010000000000000002 /* TunnelManager.swift in Sources */, B32DE51524D51F9C00AB3A71 /* RouterTests.swift in Sources */, + EC70PT00000000000000A002 /* InboundFilterPolicyTests.swift in Sources */, B3EF1DA4220687DD00953DE7 /* URLProtocolMock.swift in Sources */, B3EF1DA02206849000953DE7 /* WebHookManager.swift in Sources */, EC66DC00000000000000A003 /* WebhookDeliveryClient.swift in Sources */, diff --git a/Jared/InboundFilterPolicy.swift b/Jared/InboundFilterPolicy.swift new file mode 100644 index 0000000..f9963e4 --- /dev/null +++ b/Jared/InboundFilterPolicy.swift @@ -0,0 +1,25 @@ +// +// InboundFilterPolicy.swift +// Jared +// + +import Foundation +import JaredFramework + +/// Decides whether an inbound message should proceed to route matching. +/// Concentrates the self-message, body-type, and global-disabled policy that +/// previously lived inline in `Router.route`. +struct InboundFilterPolicy { + let flags: RuntimeFlags + + func shouldRoute(_ message: Message) -> Bool { + if let sender = message.sender as? Person, sender.isMe { return false } + guard message.body is TextBody || message.action != nil else { return false } + + // `/enable` must work even when Jared is globally disabled, otherwise the + // user could never turn it back on from iMessage. + let isEnable = (message.body as? TextBody)?.message.lowercased() == "/enable" + guard !flags.isDisabled || isEnable else { return false } + return true + } +} diff --git a/Jared/Jared.swift b/Jared/Jared.swift index 02c42d2..10cdb11 100644 --- a/Jared/Jared.swift +++ b/Jared/Jared.swift @@ -18,8 +18,11 @@ private actor SendQueue { public class Jared: MessageSender { private let sendQueue = SendQueue() - - init() {} + private let flags: RuntimeFlags + + init(flags: RuntimeFlags = UserDefaultsRuntimeFlags()) { + self.flags = flags + } public func send(_ body: String, to recipient: RecipientEntity?) { guard var recipient = recipient else { @@ -37,10 +40,8 @@ public class Jared: MessageSender { public func send(_ message: Message) { NSLog("Attemping to send message: \(message)") - let defaults = UserDefaults.standard - //Don't send the message if Jared is currently disabled. - guard !defaults.bool(forKey: JaredConstants.jaredIsDisabled) else { + guard !flags.isDisabled else { return } diff --git a/Jared/Router.swift b/Jared/Router.swift index e32bca6..f4adb75 100644 --- a/Jared/Router.swift +++ b/Jared/Router.swift @@ -13,26 +13,23 @@ class Router : RouterDelegate { var pluginManager: RouteProvider var messageDelegates: [MessageDelegate] private let matcher = MessageMatcher() - - init(pluginManager: RouteProvider, messageDelegates: [MessageDelegate]) { + private let filterPolicy: InboundFilterPolicy + + init(pluginManager: RouteProvider, messageDelegates: [MessageDelegate], + flags: RuntimeFlags = UserDefaultsRuntimeFlags()) { self.pluginManager = pluginManager self.messageDelegates = messageDelegates + self.filterPolicy = InboundFilterPolicy(flags: flags) } - + func route(message myMessage: Message) { + // Stage 1 — NotifyDelegates: every message (incl. outgoing) reaches delegates. messageDelegates.forEach { delegate in delegate.didProcess(message: myMessage) } - - if let sender = myMessage.sender as? Person, sender.isMe { - return - } - - guard myMessage.body is TextBody || myMessage.action != nil else { return } - - let defaults = UserDefaults.standard - let isDisabled = defaults.bool(forKey: JaredConstants.jaredIsDisabled) - let isEnable = (myMessage.body as? TextBody)?.message.lowercased() == "/enable" - guard !isDisabled || isEnable else { return } - + + // Stage 2 — FilterPolicy: self-message, body-type, global-disabled + /enable bypass. + guard filterPolicy.shouldRoute(myMessage) else { return } + + // Stage 3/4 — MatchRoutes + InvokeRoute. for route in pluginManager.getAllRoutes() { guard pluginManager.enabled(routeName: route.name) else { continue } if let deliverable = matcher.matchingMessage(route: route, message: myMessage) { diff --git a/Jared/RuntimeFlags.swift b/Jared/RuntimeFlags.swift new file mode 100644 index 0000000..ea9f1e2 --- /dev/null +++ b/Jared/RuntimeFlags.swift @@ -0,0 +1,25 @@ +// +// RuntimeFlags.swift +// Jared +// +// Port abstracting Jared's runtime on/off state away from direct UserDefaults reads. +// + +import Foundation + +protocol RuntimeFlags { + /// True when Jared is globally disabled (no inbound routing except `/enable`). + var isDisabled: Bool { get } +} + +struct UserDefaultsRuntimeFlags: RuntimeFlags { + let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + var isDisabled: Bool { + defaults.bool(forKey: JaredConstants.jaredIsDisabled) + } +} diff --git a/JaredTests/InboundFilterPolicyTests.swift b/JaredTests/InboundFilterPolicyTests.swift new file mode 100644 index 0000000..b792390 --- /dev/null +++ b/JaredTests/InboundFilterPolicyTests.swift @@ -0,0 +1,82 @@ +// +// InboundFilterPolicyTests.swift +// JaredTests +// + +import XCTest +import JaredFramework +@testable import Jared + +private struct NonTextBody: MessageBody {} + +class InboundFilterPolicyTests: XCTestCase { + private let me = Person(givenName: "me", handle: "me@email.com", isMe: true) + private let other = Person(givenName: "taylor", handle: "taylor@swift.org", isMe: false) + + private func policy(disabled: Bool = false) -> InboundFilterPolicy { + InboundFilterPolicy(flags: StubRuntimeFlags(isDisabled: disabled)) + } + + private func textMessage(_ text: String, from sender: Person) -> Message { + Message(body: TextBody(text), date: Date(), sender: sender, recipient: me) + } + + func testAllowsNormalIncomingTextMessage() { + let message = textMessage("/ping", from: other) + XCTAssertTrue(policy().shouldRoute(message)) + } + + func testBlocksSelfMessage() { + let message = textMessage("/ping", from: me) + XCTAssertFalse(policy().shouldRoute(message)) + } + + func testBlocksNonTextNonActionBody() { + let message = Message(body: NonTextBody(), date: Date(), sender: other, recipient: me) + XCTAssertFalse(policy().shouldRoute(message)) + } + + func testBlocksWhenDisabled() { + let message = textMessage("/ping", from: other) + XCTAssertFalse(policy(disabled: true).shouldRoute(message)) + } + + func testAllowsEnableCommandWhenDisabled() { + XCTAssertTrue(policy(disabled: true).shouldRoute(textMessage("/enable", from: other))) + } + + func testEnableBypassIsCaseInsensitive() { + XCTAssertTrue(policy(disabled: true).shouldRoute(textMessage("/ENABLE", from: other))) + } + + func testAllowsReactionWithNonTextBody() { + let reaction = Message(body: NonTextBody(), date: Date(), sender: other, recipient: me, + associatedMessageType: 2000, associatedMessageGUID: "p:0/some-guid") + XCTAssertNotNil(reaction.action) + XCTAssertTrue(policy().shouldRoute(reaction)) + } +} + +class UserDefaultsRuntimeFlagsTests: XCTestCase { + private var defaults: UserDefaults! + private let suite = "UserDefaultsRuntimeFlagsTests" + + override func setUp() { + defaults = UserDefaults(suiteName: suite) + defaults.removePersistentDomain(forName: suite) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suite) + defaults = nil + } + + func testIsDisabledTrueWhenKeySet() { + defaults.set(true, forKey: JaredConstants.jaredIsDisabled) + XCTAssertTrue(UserDefaultsRuntimeFlags(defaults: defaults).isDisabled) + } + + func testIsDisabledFalseWhenKeyUnset() { + XCTAssertFalse(UserDefaultsRuntimeFlags(defaults: defaults).isDisabled) + } +} diff --git a/JaredTests/Mocks/MockPluginManager.swift b/JaredTests/Mocks/MockPluginManager.swift index c2c59bc..74f577a 100644 --- a/JaredTests/Mocks/MockPluginManager.swift +++ b/JaredTests/Mocks/MockPluginManager.swift @@ -9,6 +9,10 @@ import Foundation import JaredFramework +struct StubRuntimeFlags: RuntimeFlags { + var isDisabled: Bool +} + let startWithString = "/startWith" let containsString = "/contains" let isString = "/is" diff --git a/JaredTests/RouterTests.swift b/JaredTests/RouterTests.swift index 2c61fe4..6b54234 100644 --- a/JaredTests/RouterTests.swift +++ b/JaredTests/RouterTests.swift @@ -93,6 +93,28 @@ class RouterTests: XCTestCase { XCTAssertEqual(mockPluginManager.callCounts[isString], 1, "Contains delegate was not called") } + func testDisabledFlagsSkipPluginRoutes() { + router = Router(pluginManager: mockPluginManager, messageDelegates: [], + flags: StubRuntimeFlags(isDisabled: true)) + router.route(message: RouterTests.startsWithMessage) + XCTAssertNil(mockPluginManager.callCounts[startWithString], "Disabled Jared must not route plugin commands") + } + + func testEnableCommandRoutesEvenWhenDisabled() { + let enableRoute = MockRoute(sender: JaredMock()) + enableRoute.add(route: Route(name: "/enable", comparisons: [.is: ["/enable"]], + call: { _ in self.mockPluginManager.increment(routeName: "/enable") })) + mockPluginManager.add(module: enableRoute) + router = Router(pluginManager: mockPluginManager, messageDelegates: [], + flags: StubRuntimeFlags(isDisabled: true)) + + let enable = Message(body: TextBody("/enable"), date: Date(), + sender: RouterTests.swiftPerson, recipient: RouterTests.mePerson) + router.route(message: enable) + + XCTAssertEqual(mockPluginManager.callCounts["/enable"], 1, "/enable must bypass the global disable") + } + func testOutgoingMessageNotifiesDelegatesButSkipsPluginRoutes() { let delegate = MessageDelegateSpy() router = Router(pluginManager: mockPluginManager, messageDelegates: [delegate])