Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
16 changes: 16 additions & 0 deletions Jared.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */; };
Expand Down Expand Up @@ -262,6 +267,9 @@
B3BBA7CF1CB327C200E1749C /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Main.strings; sourceTree = "<group>"; };
B3BBA7D01CB327C200E1749C /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
B3BF17C1244EBE9A00CC44C5 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = "<group>"; };
EC70RF00000000000000A001 /* RuntimeFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeFlags.swift; sourceTree = "<group>"; };
EC70FP00000000000000A001 /* InboundFilterPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboundFilterPolicy.swift; sourceTree = "<group>"; };
EC70PT00000000000000A001 /* InboundFilterPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboundFilterPolicyTests.swift; sourceTree = "<group>"; };
B3BF17C3244EBF4900CC44C5 /* InternalModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalModule.swift; sourceTree = "<group>"; };
B3BF17C5244EC47700CC44C5 /* MessageDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDelegate.swift; sourceTree = "<group>"; };
EC000001000000000000AB00 /* RouteProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteProvider.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand All @@ -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 */,
Expand Down
25 changes: 25 additions & 0 deletions Jared/InboundFilterPolicy.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
11 changes: 6 additions & 5 deletions Jared/Jared.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}

Expand Down
27 changes: 12 additions & 15 deletions Jared/Router.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
25 changes: 25 additions & 0 deletions Jared/RuntimeFlags.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
82 changes: 82 additions & 0 deletions JaredTests/InboundFilterPolicyTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 4 additions & 0 deletions JaredTests/Mocks/MockPluginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
import Foundation
import JaredFramework

struct StubRuntimeFlags: RuntimeFlags {
var isDisabled: Bool
}

let startWithString = "/startWith"
let containsString = "/contains"
let isString = "/is"
Expand Down
22 changes: 22 additions & 0 deletions JaredTests/RouterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
Loading