@vox-ai-app/integrations 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -0
- package/package.json +42 -0
- package/src/imessage/def.js +41 -0
- package/src/imessage/index.js +9 -0
- package/src/imessage/mac/data.js +144 -0
- package/src/imessage/mac/reply.js +68 -0
- package/src/imessage/mac/service.js +141 -0
- package/src/imessage/tools.js +44 -0
- package/src/index.js +7 -0
- package/src/mail/def.js +317 -0
- package/src/mail/index.js +165 -0
- package/src/mail/manage/index.js +10 -0
- package/src/mail/manage/mac/index.js +275 -0
- package/src/mail/read/index.js +1 -0
- package/src/mail/read/mac/accounts.js +53 -0
- package/src/mail/read/mac/index.js +170 -0
- package/src/mail/read/mac/permission.js +29 -0
- package/src/mail/read/mac/sync.js +98 -0
- package/src/mail/read/mac/transform.js +55 -0
- package/src/mail/send/index.js +1 -0
- package/src/mail/send/mac/index.js +93 -0
- package/src/mail/shared/index.js +6 -0
- package/src/mail/shared/mac/index.js +48 -0
- package/src/mail/tools.js +41 -0
- package/src/screen/capture/index.js +1 -0
- package/src/screen/capture/mac/index.js +109 -0
- package/src/screen/control/index.js +15 -0
- package/src/screen/control/mac/accessibility.js +25 -0
- package/src/screen/control/mac/apps.js +62 -0
- package/src/screen/control/mac/exec.js +66 -0
- package/src/screen/control/mac/helpers.js +5 -0
- package/src/screen/control/mac/index.js +10 -0
- package/src/screen/control/mac/keyboard.js +34 -0
- package/src/screen/control/mac/keycodes.js +87 -0
- package/src/screen/control/mac/mouse.js +59 -0
- package/src/screen/control/mac/python-keyboard.js +66 -0
- package/src/screen/control/mac/python-mouse.js +66 -0
- package/src/screen/control/mac/python.js +2 -0
- package/src/screen/control/mac/ui-scan.js +45 -0
- package/src/screen/def.js +304 -0
- package/src/screen/index.js +17 -0
- package/src/screen/queue.js +54 -0
- package/src/screen/tools.js +50 -0
- package/src/tools.js +6 -0
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# @vox-ai-app/vox-integrations
|
|
2
|
+
|
|
3
|
+
macOS system integrations for Vox: Apple Mail, Screen control, and iMessage. Each integration ships with tool implementations and LLM tool definitions.
|
|
4
|
+
|
|
5
|
+
Requires macOS. Each integration needs specific system permissions granted by the user.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @vox-ai-app/vox-integrations
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Peer dependency: `electron >= 28`
|
|
14
|
+
|
|
15
|
+
## Exports
|
|
16
|
+
|
|
17
|
+
| Export | Contents |
|
|
18
|
+
| --------------------------------------------- | ----------------------------- |
|
|
19
|
+
| `@vox-ai-app/vox-integrations` | All exports |
|
|
20
|
+
| `@vox-ai-app/vox-integrations/defs` | All tool definitions |
|
|
21
|
+
| `@vox-ai-app/vox-integrations/mail` | Mail functions |
|
|
22
|
+
| `@vox-ai-app/vox-integrations/screen` | Screen capture + control |
|
|
23
|
+
| `@vox-ai-app/vox-integrations/screen/capture` | Capture only |
|
|
24
|
+
| `@vox-ai-app/vox-integrations/screen/control` | Control only |
|
|
25
|
+
| `@vox-ai-app/vox-integrations/screen/queue` | Session acquire/release |
|
|
26
|
+
| `@vox-ai-app/vox-integrations/imessage` | iMessage data, reply, service |
|
|
27
|
+
|
|
28
|
+
## Mail
|
|
29
|
+
|
|
30
|
+
Requires **Automation permission** for Mail (System Settings → Privacy & Security → Automation).
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
import {
|
|
34
|
+
sendEmail,
|
|
35
|
+
readEmails,
|
|
36
|
+
searchContacts,
|
|
37
|
+
replyToEmail
|
|
38
|
+
} from '@vox-ai-app/vox-integrations/mail'
|
|
39
|
+
|
|
40
|
+
const emails = await readEmails({ account: 'Work', folder: 'INBOX', limit: 20 })
|
|
41
|
+
await sendEmail({ to: 'user@example.com', subject: 'Hi', body: 'Hello.' })
|
|
42
|
+
await replyToEmail({ messageId: '...', body: 'Thanks!' })
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Tool definitions:
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
import { MAIL_TOOL_DEFINITIONS } from '@vox-ai-app/vox-integrations/defs'
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Screen
|
|
52
|
+
|
|
53
|
+
Requires **Accessibility permission** (System Settings → Privacy & Security → Accessibility).
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
import {
|
|
57
|
+
captureFullScreen,
|
|
58
|
+
clickAt,
|
|
59
|
+
typeText,
|
|
60
|
+
getUiElements
|
|
61
|
+
} from '@vox-ai-app/vox-integrations/screen'
|
|
62
|
+
import { acquireScreen, releaseScreen } from '@vox-ai-app/vox-integrations/screen/queue'
|
|
63
|
+
|
|
64
|
+
const session = await acquireScreen()
|
|
65
|
+
try {
|
|
66
|
+
const img = await captureFullScreen()
|
|
67
|
+
await clickAt({ x: 100, y: 200 })
|
|
68
|
+
await typeText({ text: 'Hello' })
|
|
69
|
+
} finally {
|
|
70
|
+
await releaseScreen(session)
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Tool definitions:
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
import { SCREEN_TOOL_DEFINITIONS } from '@vox-ai-app/vox-integrations/defs'
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## iMessage
|
|
81
|
+
|
|
82
|
+
Requires **Full Disk Access** (System Settings → Privacy & Security → Full Disk Access).
|
|
83
|
+
|
|
84
|
+
### Tool use (read conversations, send messages)
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
import { listConversations, listContacts, sendReply } from '@vox-ai-app/vox-integrations/imessage'
|
|
88
|
+
|
|
89
|
+
const conversations = listConversations()
|
|
90
|
+
const contacts = listContacts()
|
|
91
|
+
await sendReply('+15551234567', 'Hello from Vox!')
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Gateway service (AI replies to incoming iMessages)
|
|
95
|
+
|
|
96
|
+
```js
|
|
97
|
+
import { createIMessageService } from '@vox-ai-app/vox-integrations/imessage'
|
|
98
|
+
|
|
99
|
+
const svc = createIMessageService({
|
|
100
|
+
logger,
|
|
101
|
+
onTranscript: (text, handle) => {
|
|
102
|
+
/* emit to UI */
|
|
103
|
+
},
|
|
104
|
+
onOpenSettings: () => shell.openExternal('x-apple.systempreferences:...'),
|
|
105
|
+
onMessage: async (text, handle) => {
|
|
106
|
+
// call your AI here, return the reply string
|
|
107
|
+
return await askAI(text)
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
svc.start('my-passphrase')
|
|
112
|
+
// user sends "my-passphrase\nWhat's the weather?" → AI replies back
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`onMessage` must return a `Promise<string | null>`. Returning `null` skips the reply.
|
|
116
|
+
|
|
117
|
+
Tool definitions:
|
|
118
|
+
|
|
119
|
+
```js
|
|
120
|
+
import { IMESSAGE_TOOL_DEFINITIONS } from '@vox-ai-app/vox-integrations/defs'
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vox-ai-app/integrations",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "macOS integrations (Mail, Screen, iMessage) for Vox",
|
|
7
|
+
"main": "./src/index.js",
|
|
8
|
+
"private": false,
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js",
|
|
11
|
+
"./defs": "./src/defs/index.js",
|
|
12
|
+
"./defs/mail": "./src/defs/mail.js",
|
|
13
|
+
"./defs/screen": "./src/defs/screen.js",
|
|
14
|
+
"./defs/imessage": "./src/defs/imessage.js",
|
|
15
|
+
"./mail": "./src/mail/index.js",
|
|
16
|
+
"./screen": "./src/screen/index.js",
|
|
17
|
+
"./screen/capture": "./src/screen/capture/index.js",
|
|
18
|
+
"./screen/control": "./src/screen/control/index.js",
|
|
19
|
+
"./screen/queue": "./src/screen/queue.js",
|
|
20
|
+
"./imessage": "./src/imessage/index.js"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public",
|
|
24
|
+
"registry": "https://registry.npmjs.org"
|
|
25
|
+
},
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://github.com/vox-ai-app/vox"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"src",
|
|
32
|
+
"LICENSE",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"electron": ">=28.0.0"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@vox-ai-app/tools": "^1.0.0",
|
|
40
|
+
"better-sqlite3": "^12.0.0"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export const IMESSAGE_TOOL_DEFINITIONS = [
|
|
2
|
+
{
|
|
3
|
+
name: 'list_imessage_conversations',
|
|
4
|
+
description:
|
|
5
|
+
"List recent iMessage/SMS conversations from the user's macOS Messages app. Returns the 50 most recent threads with contact handle and a short snippet. Requires Full Disk Access in System Settings → Privacy & Security.",
|
|
6
|
+
parameters: {
|
|
7
|
+
type: 'object',
|
|
8
|
+
properties: {},
|
|
9
|
+
required: []
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'list_imessage_contacts',
|
|
14
|
+
description:
|
|
15
|
+
"List contacts from the user's macOS AddressBook with their iMessage handles (phone numbers and email addresses). Useful for looking up who to message.",
|
|
16
|
+
parameters: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {},
|
|
19
|
+
required: []
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'send_imessage',
|
|
24
|
+
description:
|
|
25
|
+
'Send an iMessage or SMS to a contact via the macOS Messages app using AppleScript. The handle must be a phone number (e.g. +14155551234) or email address registered with iMessage.',
|
|
26
|
+
parameters: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: {
|
|
29
|
+
handle: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
description: 'Phone number or email address of the recipient.'
|
|
32
|
+
},
|
|
33
|
+
text: {
|
|
34
|
+
type: 'string',
|
|
35
|
+
description: 'Message text to send.'
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
required: ['handle', 'text']
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
]
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'fs'
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
import Database from 'better-sqlite3'
|
|
4
|
+
|
|
5
|
+
const HOME = process.env.HOME
|
|
6
|
+
const MESSAGES_DB = `${HOME}/Library/Messages/chat.db`
|
|
7
|
+
const AB_DIR = join(HOME, 'Library/Application Support/AddressBook')
|
|
8
|
+
|
|
9
|
+
export const canReadDb = () => {
|
|
10
|
+
try {
|
|
11
|
+
const db = new Database(MESSAGES_DB, { readonly: true, fileMustExist: true })
|
|
12
|
+
db.close()
|
|
13
|
+
return true
|
|
14
|
+
} catch {
|
|
15
|
+
return false
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const queryNewMessages = (afterRowId) => {
|
|
20
|
+
const db = new Database(MESSAGES_DB, { readonly: true, fileMustExist: true })
|
|
21
|
+
try {
|
|
22
|
+
return db
|
|
23
|
+
.prepare(
|
|
24
|
+
`SELECT m.ROWID, m.text,
|
|
25
|
+
CASE
|
|
26
|
+
WHEN m.is_from_me = 0 THEN h.id
|
|
27
|
+
ELSE COALESCE(
|
|
28
|
+
(
|
|
29
|
+
SELECT h2.id FROM chat_message_join cmj
|
|
30
|
+
JOIN chat_handle_join chj ON chj.chat_id = cmj.chat_id
|
|
31
|
+
JOIN handle h2 ON h2.ROWID = chj.handle_id
|
|
32
|
+
WHERE cmj.message_id = m.ROWID
|
|
33
|
+
LIMIT 1
|
|
34
|
+
),
|
|
35
|
+
(
|
|
36
|
+
SELECT REPLACE(REPLACE(REPLACE(c.chat_identifier, 'iMessage;-;', ''), 'SMS;-;', ''), 'tel:', '')
|
|
37
|
+
FROM chat_message_join cmj2
|
|
38
|
+
JOIN chat c ON c.ROWID = cmj2.chat_id
|
|
39
|
+
WHERE cmj2.message_id = m.ROWID
|
|
40
|
+
LIMIT 1
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
END AS reply_handle
|
|
44
|
+
FROM message m
|
|
45
|
+
LEFT JOIN handle h ON m.handle_id = h.ROWID
|
|
46
|
+
WHERE m.ROWID > ?
|
|
47
|
+
AND m.text IS NOT NULL AND m.text != ''
|
|
48
|
+
ORDER BY m.ROWID ASC`
|
|
49
|
+
)
|
|
50
|
+
.all(afterRowId)
|
|
51
|
+
} finally {
|
|
52
|
+
db.close()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const getMaxRowId = () => {
|
|
57
|
+
const db = new Database(MESSAGES_DB, { readonly: true, fileMustExist: true })
|
|
58
|
+
try {
|
|
59
|
+
const row = db.prepare(`SELECT MAX(ROWID) AS maxId FROM message`).get()
|
|
60
|
+
return row?.maxId ?? 0
|
|
61
|
+
} finally {
|
|
62
|
+
db.close()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const listConversations = () => {
|
|
67
|
+
const db = new Database(MESSAGES_DB, { readonly: true, fileMustExist: true })
|
|
68
|
+
try {
|
|
69
|
+
const rows = db
|
|
70
|
+
.prepare(
|
|
71
|
+
`SELECT h.id AS handle_id,
|
|
72
|
+
MAX(m.date) AS last_date,
|
|
73
|
+
(SELECT text FROM message WHERE handle_id = h.ROWID AND text IS NOT NULL AND text != '' ORDER BY date DESC LIMIT 1) AS snippet
|
|
74
|
+
FROM handle h
|
|
75
|
+
JOIN message m ON m.handle_id = h.ROWID
|
|
76
|
+
WHERE m.text IS NOT NULL
|
|
77
|
+
GROUP BY h.id
|
|
78
|
+
ORDER BY last_date DESC
|
|
79
|
+
LIMIT 50`
|
|
80
|
+
)
|
|
81
|
+
.all()
|
|
82
|
+
return rows.map((r) => ({
|
|
83
|
+
handle: r.handle_id,
|
|
84
|
+
snippet: r.snippet ? String(r.snippet).slice(0, 80) : ''
|
|
85
|
+
}))
|
|
86
|
+
} finally {
|
|
87
|
+
db.close()
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const findAddressBookDbs = () => {
|
|
92
|
+
const dbs = []
|
|
93
|
+
const main = join(AB_DIR, 'AddressBook-v22.abcddb')
|
|
94
|
+
if (existsSync(main)) dbs.push(main)
|
|
95
|
+
try {
|
|
96
|
+
const sources = join(AB_DIR, 'Sources')
|
|
97
|
+
for (const entry of readdirSync(sources)) {
|
|
98
|
+
const p = join(sources, entry, 'AddressBook-v22.abcddb')
|
|
99
|
+
if (existsSync(p)) dbs.push(p)
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
void 0
|
|
103
|
+
}
|
|
104
|
+
return dbs
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const CONTACTS_QUERY = `
|
|
108
|
+
SELECT COALESCE(r.ZNAME, TRIM(COALESCE(r.ZFIRSTNAME,'') || ' ' || COALESCE(r.ZLASTNAME,''))) AS name,
|
|
109
|
+
p.ZFULLNUMBER AS handle
|
|
110
|
+
FROM ZABCDRECORD r
|
|
111
|
+
JOIN ZABCDPHONENUMBER p ON p.ZOWNER = r.Z_PK
|
|
112
|
+
WHERE name != '' AND handle IS NOT NULL AND handle != ''
|
|
113
|
+
UNION ALL
|
|
114
|
+
SELECT COALESCE(r.ZNAME, TRIM(COALESCE(r.ZFIRSTNAME,'') || ' ' || COALESCE(r.ZLASTNAME,''))) AS name,
|
|
115
|
+
e.ZADDRESS AS handle
|
|
116
|
+
FROM ZABCDRECORD r
|
|
117
|
+
JOIN ZABCDEMAILADDRESS e ON e.ZOWNER = r.Z_PK
|
|
118
|
+
WHERE name != '' AND handle IS NOT NULL AND handle != ''
|
|
119
|
+
ORDER BY name ASC
|
|
120
|
+
LIMIT 500`
|
|
121
|
+
|
|
122
|
+
export const listContacts = (onError) => {
|
|
123
|
+
const seen = new Set()
|
|
124
|
+
const results = []
|
|
125
|
+
for (const dbPath of findAddressBookDbs()) {
|
|
126
|
+
try {
|
|
127
|
+
const db = new Database(dbPath, { readonly: true, fileMustExist: true })
|
|
128
|
+
try {
|
|
129
|
+
for (const row of db.prepare(CONTACTS_QUERY).all()) {
|
|
130
|
+
const key = `${row.name}|${row.handle}`
|
|
131
|
+
if (!seen.has(key)) {
|
|
132
|
+
seen.add(key)
|
|
133
|
+
results.push({ name: row.name, handle: row.handle })
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} finally {
|
|
137
|
+
db.close()
|
|
138
|
+
}
|
|
139
|
+
} catch (err) {
|
|
140
|
+
onError?.(err)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return results
|
|
144
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from 'fs/promises'
|
|
2
|
+
import os from 'os'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { promisify } from 'util'
|
|
5
|
+
import { exec } from 'child_process'
|
|
6
|
+
|
|
7
|
+
const execAsync = promisify(exec)
|
|
8
|
+
|
|
9
|
+
const writeTmp = async (content, ext) => {
|
|
10
|
+
const file = path.join(os.tmpdir(), `vox_ims_${Date.now()}.${ext}`)
|
|
11
|
+
await fs.writeFile(file, content, 'utf8')
|
|
12
|
+
return file
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const escapeAppleScript = (s) => String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
16
|
+
|
|
17
|
+
const toAppleScriptString = (s) => {
|
|
18
|
+
const lines = escapeAppleScript(s).split('\n')
|
|
19
|
+
if (lines.length === 1) return `"${lines[0]}"`
|
|
20
|
+
return lines.map((l) => `"${l}"`).join(' & return & ')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const sendReply = async (handle, text, filePaths = []) => {
|
|
24
|
+
const handleEsc = escapeAppleScript(handle)
|
|
25
|
+
const textExpr = toAppleScriptString(text)
|
|
26
|
+
|
|
27
|
+
const sendText = text
|
|
28
|
+
? `
|
|
29
|
+
try
|
|
30
|
+
send ${textExpr} to buddy "${handleEsc}" of service "iMessage"
|
|
31
|
+
set sent to true
|
|
32
|
+
end try
|
|
33
|
+
if not sent then
|
|
34
|
+
try
|
|
35
|
+
send ${textExpr} to buddy "${handleEsc}" of (first service)
|
|
36
|
+
set sent to true
|
|
37
|
+
end try
|
|
38
|
+
end if`
|
|
39
|
+
: ''
|
|
40
|
+
|
|
41
|
+
const sendFiles = filePaths
|
|
42
|
+
.map((p) => {
|
|
43
|
+
const pEsc = escapeAppleScript(p)
|
|
44
|
+
return `
|
|
45
|
+
try
|
|
46
|
+
send POSIX file "${pEsc}" to buddy "${handleEsc}" of service "iMessage"
|
|
47
|
+
set sent to true
|
|
48
|
+
end try`
|
|
49
|
+
})
|
|
50
|
+
.join('')
|
|
51
|
+
|
|
52
|
+
const script = `tell application "Messages"
|
|
53
|
+
set sent to false
|
|
54
|
+
${sendText}
|
|
55
|
+
${sendFiles}
|
|
56
|
+
if not sent then
|
|
57
|
+
error "Could not send reply to ${handleEsc}"
|
|
58
|
+
end if
|
|
59
|
+
end tell`
|
|
60
|
+
|
|
61
|
+
const scriptFile = await writeTmp(script, 'scpt')
|
|
62
|
+
try {
|
|
63
|
+
await execAsync(`osascript "${scriptFile}"`, { timeout: 15_000 })
|
|
64
|
+
console.info('[imessage] Reply sent to', handle, 'files:', filePaths.length)
|
|
65
|
+
} finally {
|
|
66
|
+
await fs.unlink(scriptFile).catch(() => {})
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import {
|
|
2
|
+
canReadDb,
|
|
3
|
+
getMaxRowId,
|
|
4
|
+
queryNewMessages,
|
|
5
|
+
listConversations as listConversationsFromDb,
|
|
6
|
+
listContacts as listContactsFromDb
|
|
7
|
+
} from './data.js'
|
|
8
|
+
import { sendReply } from './reply.js'
|
|
9
|
+
|
|
10
|
+
const POLL_INTERVAL_MS = 3_000
|
|
11
|
+
const REPLY_TIMEOUT_MS = 90_000
|
|
12
|
+
const FDA_ERROR_MESSAGE =
|
|
13
|
+
'Vox needs Full Disk Access to read Messages. Opening System Settings → Privacy & Security → Full Disk Access — please enable it for Vox and try again.'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Creates a self-contained iMessage watcher service.
|
|
17
|
+
*
|
|
18
|
+
* @param {object} opts
|
|
19
|
+
* @param {(text: string, handle: string) => Promise<string|null>} opts.onMessage
|
|
20
|
+
* Called when a passphrase-matched message arrives. Must return the AI reply text (or null to skip reply).
|
|
21
|
+
* @param {(text: string, handle: string) => void} [opts.onTranscript]
|
|
22
|
+
* Optional: called before onMessage, useful for emitting transcript events upstream.
|
|
23
|
+
* @param {() => void} [opts.onOpenSettings]
|
|
24
|
+
* Optional: called when Full Disk Access is required. Default opens nothing.
|
|
25
|
+
* @param {{ info: Function, warn: Function, error: Function }} [opts.logger]
|
|
26
|
+
* Optional: defaults to console.
|
|
27
|
+
* @param {number} [opts.pollIntervalMs]
|
|
28
|
+
* Poll interval in ms. Default 3000.
|
|
29
|
+
* @returns {{ start, stop, getPassphrase, listConversations, listContacts, openSettings }}
|
|
30
|
+
*/
|
|
31
|
+
export const createIMessageService = ({
|
|
32
|
+
onMessage,
|
|
33
|
+
onTranscript,
|
|
34
|
+
onOpenSettings,
|
|
35
|
+
logger,
|
|
36
|
+
pollIntervalMs = POLL_INTERVAL_MS
|
|
37
|
+
} = {}) => {
|
|
38
|
+
const _log = logger ?? console
|
|
39
|
+
|
|
40
|
+
let _pollTimer = null
|
|
41
|
+
let _passphrase = null
|
|
42
|
+
let _lastSeenRowId = 0
|
|
43
|
+
|
|
44
|
+
const openSettings = () => {
|
|
45
|
+
onOpenSettings?.()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const handleIncomingMessage = async (row) => {
|
|
49
|
+
if (!_passphrase) return
|
|
50
|
+
const raw = String(row.text || '')
|
|
51
|
+
const newline = raw.indexOf('\n')
|
|
52
|
+
if (newline === -1) return
|
|
53
|
+
const firstLine = raw.slice(0, newline).trim()
|
|
54
|
+
if (firstLine !== _passphrase) return
|
|
55
|
+
const text = raw.slice(newline + 1).trim()
|
|
56
|
+
if (!text) return
|
|
57
|
+
|
|
58
|
+
const sender = row.reply_handle
|
|
59
|
+
_log.info('[imessage] Passphrase match, reply_handle:', sender, 'text:', text.slice(0, 60))
|
|
60
|
+
if (!sender) return
|
|
61
|
+
|
|
62
|
+
onTranscript?.(text, sender)
|
|
63
|
+
|
|
64
|
+
let aiText = null
|
|
65
|
+
try {
|
|
66
|
+
aiText = await Promise.race([
|
|
67
|
+
onMessage(text, sender),
|
|
68
|
+
new Promise((resolve) => setTimeout(() => resolve(null), REPLY_TIMEOUT_MS))
|
|
69
|
+
])
|
|
70
|
+
} catch (err) {
|
|
71
|
+
_log.error('[imessage] onMessage error:', err?.message)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!aiText) {
|
|
76
|
+
_log.warn('[imessage] No AI response received')
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
await sendReply(sender, aiText)
|
|
82
|
+
} catch (err) {
|
|
83
|
+
_log.error('[imessage] Reply failed:', err?.message)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const poll = async () => {
|
|
88
|
+
if (!_passphrase || !canReadDb()) return
|
|
89
|
+
try {
|
|
90
|
+
const rows = queryNewMessages(_lastSeenRowId)
|
|
91
|
+
for (const row of rows) {
|
|
92
|
+
if (row.ROWID > _lastSeenRowId) _lastSeenRowId = row.ROWID
|
|
93
|
+
await handleIncomingMessage(row)
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
_log.error('[imessage] Poll error:', err?.message)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const start = (passphrase) => {
|
|
101
|
+
if (!canReadDb()) {
|
|
102
|
+
openSettings()
|
|
103
|
+
throw Object.assign(new Error(FDA_ERROR_MESSAGE), { code: 'IMESSAGE_FDA_REQUIRED' })
|
|
104
|
+
}
|
|
105
|
+
if (!passphrase?.trim()) throw new Error('Passphrase cannot be empty.')
|
|
106
|
+
|
|
107
|
+
stop()
|
|
108
|
+
_passphrase = passphrase.trim()
|
|
109
|
+
_lastSeenRowId = getMaxRowId()
|
|
110
|
+
_pollTimer = setInterval(poll, pollIntervalMs)
|
|
111
|
+
_log.info('[imessage] Watching all messages with passphrase')
|
|
112
|
+
return { passphrase: _passphrase }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const stop = () => {
|
|
116
|
+
if (_pollTimer) {
|
|
117
|
+
clearInterval(_pollTimer)
|
|
118
|
+
_pollTimer = null
|
|
119
|
+
}
|
|
120
|
+
_passphrase = null
|
|
121
|
+
_lastSeenRowId = 0
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const getPassphrase = () => _passphrase
|
|
125
|
+
|
|
126
|
+
const listConversations = () => {
|
|
127
|
+
if (!canReadDb()) {
|
|
128
|
+
openSettings()
|
|
129
|
+
throw Object.assign(new Error(FDA_ERROR_MESSAGE), { code: 'IMESSAGE_FDA_REQUIRED' })
|
|
130
|
+
}
|
|
131
|
+
return listConversationsFromDb()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const listContacts = () => {
|
|
135
|
+
return listContactsFromDb((err) => {
|
|
136
|
+
_log.warn('[imessage] Skipping AddressBook DB:', err?.message)
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { start, stop, getPassphrase, listConversations, listContacts, openSettings }
|
|
141
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { IMESSAGE_TOOL_DEFINITIONS } from './def.js'
|
|
2
|
+
import { listConversations, listContacts } from './mac/data.js'
|
|
3
|
+
import { sendReply } from './mac/reply.js'
|
|
4
|
+
|
|
5
|
+
const DARWIN_ONLY = () => {
|
|
6
|
+
throw new Error('iMessage is only available on macOS.')
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const listImessageConversations = async () => {
|
|
10
|
+
const conversations = listConversations()
|
|
11
|
+
return { conversations }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const listImessageContacts = async () => {
|
|
15
|
+
const contacts = listContacts()
|
|
16
|
+
return { contacts }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const sendImessage = async ({ handle, text }) => {
|
|
20
|
+
if (!handle) throw new Error('"handle" is required.')
|
|
21
|
+
if (!text) throw new Error('"text" is required.')
|
|
22
|
+
await sendReply(handle, String(text))
|
|
23
|
+
return { ok: true, handle }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const macExecutors = {
|
|
27
|
+
list_imessage_conversations: (_ctx) => listImessageConversations,
|
|
28
|
+
list_imessage_contacts: (_ctx) => listImessageContacts,
|
|
29
|
+
send_imessage: (_ctx) => sendImessage
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const executors =
|
|
33
|
+
process.platform === 'darwin'
|
|
34
|
+
? macExecutors
|
|
35
|
+
: {
|
|
36
|
+
list_imessage_conversations: (_ctx) => DARWIN_ONLY,
|
|
37
|
+
list_imessage_contacts: (_ctx) => DARWIN_ONLY,
|
|
38
|
+
send_imessage: (_ctx) => DARWIN_ONLY
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const IMESSAGE_TOOLS = IMESSAGE_TOOL_DEFINITIONS.map((def) => ({
|
|
42
|
+
definition: def,
|
|
43
|
+
execute: executors[def.name]
|
|
44
|
+
}))
|
package/src/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { MAIL_TOOL_DEFINITIONS } from './mail/def.js'
|
|
2
|
+
export { SCREEN_TOOL_DEFINITIONS } from './screen/def.js'
|
|
3
|
+
export { IMESSAGE_TOOL_DEFINITIONS } from './imessage/def.js'
|
|
4
|
+
export * from './mail/index.js'
|
|
5
|
+
export * from './screen/index.js'
|
|
6
|
+
export * from './imessage/index.js'
|
|
7
|
+
export { ALL_INTEGRATION_TOOLS, SCREEN_TOOLS, MAIL_TOOLS, IMESSAGE_TOOLS } from './tools.js'
|