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
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ DatabaseHandler (SQLite polling)
| `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/CoreModule.swift` | Built-in commands: `/ping`, `/send`, `/name`, `/whoami`, `/barf`, etc. Side effects injected via ports (`ContactNameService`, `Clock`, `SendRateLimiter`) so commands are testable |
| `Jared/ContactNameService.swift` | Port hiding `CNContactStore` for `/name`; `CNContactNameService` adapter (no `try!`), fakeable in tests |
| `Jared/Clock.swift` | Async sleep port (`RealClock`) so `/send` delays run instantly under test |
| `Jared/SendRateLimiter.swift` | Per-handle concurrency limiter for `/send`; deterministic, no async timing |
| `Jared/InternalModule.swift` | `/help`, `/reload`, `/enable`, `/disable` commands |
| `Jared/WebHookManager.swift` | Implements both `MessageDelegate` (notify on all messages) and `RoutingModule` (webhook-defined routes) |
| `Jared/JaredWebServer.swift` | REST API web server |
Expand Down
24 changes: 24 additions & 0 deletions Jared.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
B319235824DD319D001904BA /* InternalModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BF17C3244EBF4900CC44C5 /* InternalModule.swift */; };
B319236224DE76A2001904BA /* JaredUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B319236124DE76A2001904BA /* JaredUITests.swift */; };
B32DE51524D51F9C00AB3A71 /* RouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32DE51424D51F9C00AB3A71 /* RouterTests.swift */; };
EC71CM00000000000000A002 /* CoreModuleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC71CM00000000000000A001 /* CoreModuleTests.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 */; };
Expand Down Expand Up @@ -109,6 +110,13 @@
TM0000010000000000000004 /* TunnelManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TM0000010000000000000005 /* TunnelManagerTests.swift */; };
B3B844D224DA6BBB00DC162A /* JaredWebServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C59283224DD17900116ECB /* JaredWebServer.swift */; };
B3BBA7D11CB3284A00E1749C /* CoreModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = B39EC17D1CB01FEF002C3161 /* CoreModule.swift */; };
EC71CS00000000000000A002 /* ContactNameService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC71CS00000000000000A001 /* ContactNameService.swift */; };
EC71CS00000000000000A004 /* ContactNameService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC71CS00000000000000A001 /* ContactNameService.swift */; };
EC71CK00000000000000A002 /* Clock.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC71CK00000000000000A001 /* Clock.swift */; };
EC71CK00000000000000A004 /* Clock.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC71CK00000000000000A001 /* Clock.swift */; };
EC71RL00000000000000A002 /* SendRateLimiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC71RL00000000000000A001 /* SendRateLimiter.swift */; };
EC71RL00000000000000A004 /* SendRateLimiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC71RL00000000000000A001 /* SendRateLimiter.swift */; };
EC71C000000000000000A010 /* 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 */; };
Expand Down Expand Up @@ -195,6 +203,7 @@
B319236124DE76A2001904BA /* JaredUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JaredUITests.swift; sourceTree = "<group>"; };
B319236324DE76A2001904BA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
B32DE51424D51F9C00AB3A71 /* RouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterTests.swift; sourceTree = "<group>"; };
EC71CM00000000000000A001 /* CoreModuleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreModuleTests.swift; sourceTree = "<group>"; };
B32DE51924D5524300AB3A71 /* Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = "<group>"; };
B32DE51B24D552D300AB3A71 /* MockPluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPluginManager.swift; sourceTree = "<group>"; };
B33278511CB2C94700BBDD3A /* AppKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKit.framework; path = System/Library/Frameworks/AppKit.framework; sourceTree = SDKROOT; };
Expand Down Expand Up @@ -244,6 +253,9 @@
B37E82811FA85B210069E15D /* SendTextSingleBuddy.scpt */ = {isa = PBXFileReference; lastKnownFileType = file; path = SendTextSingleBuddy.scpt; sourceTree = "<group>"; };
B39A65DF1CC1FB1B003E26B0 /* PluginManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PluginManager.swift; sourceTree = "<group>"; };
B39EC17D1CB01FEF002C3161 /* CoreModule.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreModule.swift; sourceTree = "<group>"; };
EC71CS00000000000000A001 /* ContactNameService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactNameService.swift; sourceTree = "<group>"; };
EC71CK00000000000000A001 /* Clock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Clock.swift; sourceTree = "<group>"; };
EC71RL00000000000000A001 /* SendRateLimiter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendRateLimiter.swift; sourceTree = "<group>"; };
B39F2B381CB3BCA700C0D35C /* SendText.scpt */ = {isa = PBXFileReference; lastKnownFileType = file; path = SendText.scpt; sourceTree = "<group>"; };
B3A4C61323B867E300B7F009 /* MessageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequest.swift; sourceTree = "<group>"; };
B3B7E52324E9EE2000A3FBD1 /* AutomationPermissionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationPermissionState.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -396,6 +408,7 @@
B347A4AC24DD170C00E93657 /* ActionTest.swift */,
B3B844B924D77A4800DC162A /* SendStyleTest.swift */,
B32DE51424D51F9C00AB3A71 /* RouterTests.swift */,
EC71CM00000000000000A001 /* CoreModuleTests.swift */,
EC70PT00000000000000A001 /* InboundFilterPolicyTests.swift */,
B3B844CF24DA6AC800DC162A /* JaredWebServerTest.swift */,
TM0000010000000000000005 /* TunnelManagerTests.swift */,
Expand Down Expand Up @@ -501,6 +514,9 @@
children = (
B3BF17C3244EBF4900CC44C5 /* InternalModule.swift */,
B39EC17D1CB01FEF002C3161 /* CoreModule.swift */,
EC71CS00000000000000A001 /* ContactNameService.swift */,
EC71CK00000000000000A001 /* Clock.swift */,
EC71RL00000000000000A001 /* SendRateLimiter.swift */,
B3F185F524E51C880051C696 /* CoreModule.xcdatamodeld */,
);
name = Modules;
Expand Down Expand Up @@ -789,6 +805,9 @@
B3A4C61423B867E300B7F009 /* MessageRequest.swift in Sources */,
B39A65E01CC1FB1B003E26B0 /* PluginManager.swift in Sources */,
B3BBA7D11CB3284A00E1749C /* CoreModule.swift in Sources */,
EC71CS00000000000000A002 /* ContactNameService.swift in Sources */,
EC71CK00000000000000A002 /* Clock.swift in Sources */,
EC71RL00000000000000A002 /* SendRateLimiter.swift in Sources */,
B36563EF1F3D68980064B114 /* TouchBarTextFieldExtension.swift in Sources */,
B3F185F724E51C880051C696 /* CoreModule.xcdatamodeld in Sources */,
B35CE7152196AD4E002F52A7 /* DatabaseHandler.swift in Sources */,
Expand Down Expand Up @@ -879,6 +898,11 @@
TM0000010000000000000004 /* TunnelManagerTests.swift in Sources */,
TM0000010000000000000002 /* TunnelManager.swift in Sources */,
B32DE51524D51F9C00AB3A71 /* RouterTests.swift in Sources */,
EC71CM00000000000000A002 /* CoreModuleTests.swift in Sources */,
EC71C000000000000000A010 /* CoreModule.swift in Sources */,
EC71CS00000000000000A004 /* ContactNameService.swift in Sources */,
EC71CK00000000000000A004 /* Clock.swift in Sources */,
EC71RL00000000000000A004 /* SendRateLimiter.swift in Sources */,
EC70PT00000000000000A002 /* InboundFilterPolicyTests.swift in Sources */,
B3EF1DA4220687DD00953DE7 /* URLProtocolMock.swift in Sources */,
B3EF1DA02206849000953DE7 /* WebHookManager.swift in Sources */,
Expand Down
18 changes: 18 additions & 0 deletions Jared/Clock.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//
// Clock.swift
// Jared
//
// Async sleep port so timed operations (e.g. `/send` delays) run instantly in tests.
//

import Foundation

protocol Clock {
func sleep(seconds: Int) async
}

struct RealClock: Clock {
func sleep(seconds: Int) async {
try? await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000)
}
}
58 changes: 58 additions & 0 deletions Jared/ContactNameService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//
// ContactNameService.swift
// Jared
//
// Port hiding CNContactStore behind a small interface so `/name` is testable
// without Contacts permission, and contact create/update has no `try!`.
//

import Foundation
import Contacts

protocol ContactNameService {
var isAuthorized: Bool { get }
/// Sets the given name for the contact matching `handle`, creating the
/// contact when none exists. Throws if the contact store write fails.
func setGivenName(_ name: String, forHandle handle: String) throws
}

struct CNContactNameService: ContactNameService {
var isAuthorized: Bool {
CNContactStore.authorizationStatus(for: .contacts) == .authorized
}

func setGivenName(_ name: String, forHandle handle: String) throws {
let store = CNContactStore()

let searchPredicate: NSPredicate
if !handle.contains("@") {
searchPredicate = CNContact.predicateForContacts(matching: CNPhoneNumber(stringValue: handle))
} else {
searchPredicate = CNContact.predicateForContacts(matchingEmailAddress: handle)
}

let found = try store.unifiedContacts(
matching: searchPredicate,
keysToFetch: [CNContactFamilyNameKey as CNKeyDescriptor, CNContactGivenNameKey as CNKeyDescriptor]
)

let saveRequest = CNSaveRequest()
if let existing = found.first {
let mutable = existing.mutableCopy() as! CNMutableContact
mutable.givenName = name
saveRequest.update(mutable)
} else {
let newContact = CNMutableContact()
newContact.givenName = name
newContact.note = "Created By jared.app"
if handle.contains("@") {
newContact.emailAddresses = [CNLabeledValue(label: CNLabelHome, value: handle as NSString)]
} else {
newContact.phoneNumbers = [CNLabeledValue(label: "iPhone", value: CNPhoneNumber(stringValue: handle))]
}
saveRequest.add(newContact, toContainerWithIdentifier: nil)
}

try store.execute(saveRequest)
}
}
128 changes: 47 additions & 81 deletions Jared/CoreModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,30 @@
import Foundation
import Cocoa
import JaredFramework
import Contacts

class CoreModule: RoutingModule {
static let MAXIMUM_CONCURRENT_SENDS = 3
var description: String = NSLocalizedString("CoreDescription")
var routes: [Route] = []
var sender: MessageSender
let MAXIMUM_CONCURRENT_SENDS = 3
var currentSends: [String: Int] = [:]

required public init(sender: MessageSender) {
private let clock: Clock
private let rateLimiter: SendRateLimiter
private let contacts: ContactNameService

required convenience public init(sender: MessageSender) {
self.init(sender: sender, clock: RealClock(),
rateLimiter: SendRateLimiter(max: CoreModule.MAXIMUM_CONCURRENT_SENDS),
contacts: CNContactNameService())
}

init(sender: MessageSender, clock: Clock = RealClock(),
rateLimiter: SendRateLimiter = SendRateLimiter(max: CoreModule.MAXIMUM_CONCURRENT_SENDS),
contacts: ContactNameService = CNContactNameService()) {
self.sender = sender
let appsupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("Jared").appendingPathComponent("CoreModule")
try! FileManager.default.createDirectory(at: appsupport, withIntermediateDirectories: true, attributes: nil)

self.clock = clock
self.rateLimiter = rateLimiter
self.contacts = contacts

let ping = Route(name:"/ping", comparisons: [.startsWith: ["/ping"]], call: {[weak self] in self?.pingCall($0)}, description: NSLocalizedString("pingDescription"))

let thankYou = Route(name:"Thank You", comparisons: [.startsWith: [NSLocalizedString("ThanksJaredCommand")]], call: {[weak self] in self?.thanksJared($0)}, description: NSLocalizedString("ThanksJaredResponse"))
Expand All @@ -49,7 +59,8 @@ class CoreModule: RoutingModule {
}

func barf(_ incoming: Message) -> Void {
sender.send(String(data: try! JSONEncoder().encode(incoming), encoding: .utf8) ?? "nil", to: incoming.RespondTo())
let encoded = (try? JSONEncoder().encode(incoming)).flatMap { String(data: $0, encoding: .utf8) }
sender.send(encoded ?? "nil", to: incoming.RespondTo())
}

func getWho(_ message: Message) -> Void {
Expand Down Expand Up @@ -87,97 +98,52 @@ class CoreModule: RoutingModule {
return sender.send("Wrong arguments. The third argument must be the message you wish to send.", to: message.RespondTo())
}

guard (currentSends[message.sender.handle] ?? 0) < MAXIMUM_CONCURRENT_SENDS else {
return sender.send("You can only have \(MAXIMUM_CONCURRENT_SENDS) send operations going at once.", to: message.RespondTo())
}

if (currentSends[message.sender.handle] == nil)
{
currentSends[message.sender.handle] = 0
guard rateLimiter.tryAcquire(message.sender.handle) else {
return sender.send("You can only have \(CoreModule.MAXIMUM_CONCURRENT_SENDS) send operations going at once.", to: message.RespondTo())
}

//Increment the concurrent send counter for this user
currentSends[message.sender.handle] = currentSends[message.sender.handle]! + 1


//If there are commas in the message, take the whole message
if parameters.count > 4 {
textToSend = parameters[3...(parameters.count - 1)].joined(separator: ",")
}
//Go through the repeat loop asynchronously

let finalText = textToSend
Task { [weak self] in
guard let self = self else { return }
for _ in 1...repeatNum {
self.sender.send(textToSend, to: message.RespondTo())
try? await Task.sleep(nanoseconds: UInt64(delay) * 1_000_000_000)
}
self.currentSends[message.sender.handle] = (self.currentSends[message.sender.handle] ?? 0) - 1
await self?.performSend(message, text: finalText, times: repeatNum, delay: delay)
}
}

/// Sends `text` to the message's responder `times` times, pausing `delay`
/// seconds between sends, then releases the rate-limiter slot. Extracted so
/// the send loop is awaitable/testable with an injected `Clock`.
func performSend(_ message: Message, text: String, times: Int, delay: Int = 0) async {
for _ in 1...times {
sender.send(text, to: message.RespondTo())
await clock.sleep(seconds: delay)
}
rateLimiter.release(message.sender.handle)
}



func changeName(_ message: Message) {
guard let parsedMessage = message.getTextParameters() else {
return sender.send("Inappropriate input type", to:message.RespondTo())
return sender.send("Inappropriate input type", to: message.RespondTo())
}
if (parsedMessage.count == 1) {

guard parsedMessage.count > 1 else {
return sender.send("Wrong arguments.", to: message.RespondTo())
}
guard (CNContactStore.authorizationStatus(for: CNEntityType.contacts) == .authorized) else {

guard contacts.isAuthorized else {
return sender.send("Sorry, I do not have access to contacts.", to: message.RespondTo())
}
let store = CNContactStore()

let searchPredicate: NSPredicate
if (!(message.sender.handle.contains("@"))) {
searchPredicate = CNContact.predicateForContacts(matching: CNPhoneNumber(stringValue: message.sender.handle ))
} else {
searchPredicate = CNContact.predicateForContacts(matchingEmailAddress: message.sender.handle )
}

let peopleFound = try! store.unifiedContacts(matching: searchPredicate, keysToFetch:[CNContactFamilyNameKey as CNKeyDescriptor, CNContactGivenNameKey as CNKeyDescriptor])


//We need to create the contact
if (peopleFound.count == 0) {
// Creating a new contact
let newContact = CNMutableContact()
newContact.givenName = parsedMessage[1]
newContact.note = "Created By jared.app"

//If it contains an at, add the handle as email, otherwise add it as phone
if (message.sender.handle.contains("@")) {
let homeEmail = CNLabeledValue(label: CNLabelHome, value: (message.sender.handle) as NSString)
newContact.emailAddresses = [homeEmail]
}
else {
let iPhonePhone = CNLabeledValue(label: "iPhone", value: CNPhoneNumber(stringValue:message.sender.handle))
newContact.phoneNumbers = [iPhonePhone]
}

let saveRequest = CNSaveRequest()
saveRequest.add(newContact, toContainerWithIdentifier:nil)
do {
try store.execute(saveRequest)
} catch {
return sender.send("There was an error saving your contact..", to: message.RespondTo())
}

sender.send("Ok, I'll call you \(parsedMessage[1]) from now on.", to: message.RespondTo())
}
//The contact already exists, modify the value
else {
let mutableContact = peopleFound[0].mutableCopy() as! CNMutableContact
mutableContact.givenName = parsedMessage[1]

let saveRequest = CNSaveRequest()
saveRequest.update(mutableContact)
try! store.execute(saveRequest)


do {
try contacts.setGivenName(parsedMessage[1], forHandle: message.sender.handle)
sender.send("Ok, I'll call you \(parsedMessage[1]) from now on.", to: message.RespondTo())
} catch {
sender.send("There was an error saving your contact..", to: message.RespondTo())
}
}

Expand Down
Loading
Loading