agent-messenger 2.27.2 → 2.28.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/.claude-plugin/README.md +11 -1
- package/.claude-plugin/marketplace.json +14 -1
- package/.claude-plugin/plugin.json +4 -2
- package/AGENTS.md +4 -0
- package/README.md +40 -9
- package/dist/package.json +10 -2
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +3 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/platforms/discordbot/cli.js +1 -1
- package/dist/src/platforms/imessage/cli.d.ts +5 -0
- package/dist/src/platforms/imessage/cli.d.ts.map +1 -0
- package/dist/src/platforms/imessage/cli.js +31 -0
- package/dist/src/platforms/imessage/cli.js.map +1 -0
- package/dist/src/platforms/imessage/client.d.ts +43 -0
- package/dist/src/platforms/imessage/client.d.ts.map +1 -0
- package/dist/src/platforms/imessage/client.js +190 -0
- package/dist/src/platforms/imessage/client.js.map +1 -0
- package/dist/src/platforms/imessage/commands/auth.d.ts +31 -0
- package/dist/src/platforms/imessage/commands/auth.d.ts.map +1 -0
- package/dist/src/platforms/imessage/commands/auth.js +100 -0
- package/dist/src/platforms/imessage/commands/auth.js.map +1 -0
- package/dist/src/platforms/imessage/commands/chat.d.ts +3 -0
- package/dist/src/platforms/imessage/commands/chat.d.ts.map +1 -0
- package/dist/src/platforms/imessage/commands/chat.js +42 -0
- package/dist/src/platforms/imessage/commands/chat.js.map +1 -0
- package/dist/src/platforms/imessage/commands/doctor.d.ts +25 -0
- package/dist/src/platforms/imessage/commands/doctor.d.ts.map +1 -0
- package/dist/src/platforms/imessage/commands/doctor.js +101 -0
- package/dist/src/platforms/imessage/commands/doctor.js.map +1 -0
- package/dist/src/platforms/imessage/commands/index.d.ts +7 -0
- package/dist/src/platforms/imessage/commands/index.d.ts.map +1 -0
- package/dist/src/platforms/imessage/commands/index.js +7 -0
- package/dist/src/platforms/imessage/commands/index.js.map +1 -0
- package/dist/src/platforms/imessage/commands/message.d.ts +5 -0
- package/dist/src/platforms/imessage/commands/message.d.ts.map +1 -0
- package/dist/src/platforms/imessage/commands/message.js +127 -0
- package/dist/src/platforms/imessage/commands/message.js.map +1 -0
- package/dist/src/platforms/imessage/commands/setup.d.ts +20 -0
- package/dist/src/platforms/imessage/commands/setup.d.ts.map +1 -0
- package/dist/src/platforms/imessage/commands/setup.js +57 -0
- package/dist/src/platforms/imessage/commands/setup.js.map +1 -0
- package/dist/src/platforms/imessage/commands/shared.d.ts +10 -0
- package/dist/src/platforms/imessage/commands/shared.d.ts.map +1 -0
- package/dist/src/platforms/imessage/commands/shared.js +53 -0
- package/dist/src/platforms/imessage/commands/shared.js.map +1 -0
- package/dist/src/platforms/imessage/commands/whoami.d.ts +3 -0
- package/dist/src/platforms/imessage/commands/whoami.d.ts.map +1 -0
- package/dist/src/platforms/imessage/commands/whoami.js +20 -0
- package/dist/src/platforms/imessage/commands/whoami.js.map +1 -0
- package/dist/src/platforms/imessage/credential-manager.d.ts +22 -0
- package/dist/src/platforms/imessage/credential-manager.d.ts.map +1 -0
- package/dist/src/platforms/imessage/credential-manager.js +111 -0
- package/dist/src/platforms/imessage/credential-manager.js.map +1 -0
- package/dist/src/platforms/imessage/ensure-auth.d.ts +2 -0
- package/dist/src/platforms/imessage/ensure-auth.d.ts.map +1 -0
- package/dist/src/platforms/imessage/ensure-auth.js +13 -0
- package/dist/src/platforms/imessage/ensure-auth.js.map +1 -0
- package/dist/src/platforms/imessage/errors.d.ts +3 -0
- package/dist/src/platforms/imessage/errors.d.ts.map +1 -0
- package/dist/src/platforms/imessage/errors.js +25 -0
- package/dist/src/platforms/imessage/errors.js.map +1 -0
- package/dist/src/platforms/imessage/index.d.ts +7 -0
- package/dist/src/platforms/imessage/index.d.ts.map +1 -0
- package/dist/src/platforms/imessage/index.js +5 -0
- package/dist/src/platforms/imessage/index.js.map +1 -0
- package/dist/src/platforms/imessage/rpc.d.ts +20 -0
- package/dist/src/platforms/imessage/rpc.d.ts.map +1 -0
- package/dist/src/platforms/imessage/rpc.js +133 -0
- package/dist/src/platforms/imessage/rpc.js.map +1 -0
- package/dist/src/platforms/imessage/types.d.ts +59 -0
- package/dist/src/platforms/imessage/types.d.ts.map +1 -0
- package/dist/src/platforms/imessage/types.js +29 -0
- package/dist/src/platforms/imessage/types.js.map +1 -0
- package/dist/src/platforms/instagram/cli.js +1 -1
- package/dist/src/platforms/instagram/client.d.ts +6 -1
- package/dist/src/platforms/instagram/client.d.ts.map +1 -1
- package/dist/src/platforms/instagram/client.js +17 -0
- package/dist/src/platforms/instagram/client.js.map +1 -1
- package/dist/src/platforms/instagram/hybrid-listener.d.ts +48 -0
- package/dist/src/platforms/instagram/hybrid-listener.d.ts.map +1 -0
- package/dist/src/platforms/instagram/hybrid-listener.js +154 -0
- package/dist/src/platforms/instagram/hybrid-listener.js.map +1 -0
- package/dist/src/platforms/instagram/index.d.ts +2 -0
- package/dist/src/platforms/instagram/index.d.ts.map +1 -1
- package/dist/src/platforms/instagram/index.js +2 -0
- package/dist/src/platforms/instagram/index.js.map +1 -1
- package/dist/src/platforms/instagram/mqtt/connection.d.ts +4 -0
- package/dist/src/platforms/instagram/mqtt/connection.d.ts.map +1 -0
- package/dist/src/platforms/instagram/mqtt/connection.js +66 -0
- package/dist/src/platforms/instagram/mqtt/connection.js.map +1 -0
- package/dist/src/platforms/instagram/mqtt/thrift.d.ts +22 -0
- package/dist/src/platforms/instagram/mqtt/thrift.d.ts.map +1 -0
- package/dist/src/platforms/instagram/mqtt/thrift.js +143 -0
- package/dist/src/platforms/instagram/mqtt/thrift.js.map +1 -0
- package/dist/src/platforms/instagram/mqtt/transport.d.ts +42 -0
- package/dist/src/platforms/instagram/mqtt/transport.d.ts.map +1 -0
- package/dist/src/platforms/instagram/mqtt/transport.js +246 -0
- package/dist/src/platforms/instagram/mqtt/transport.js.map +1 -0
- package/dist/src/platforms/instagram/realtime-listener.d.ts +33 -0
- package/dist/src/platforms/instagram/realtime-listener.d.ts.map +1 -0
- package/dist/src/platforms/instagram/realtime-listener.js +151 -0
- package/dist/src/platforms/instagram/realtime-listener.js.map +1 -0
- package/dist/src/platforms/kakaotalk/cli.js +1 -1
- package/dist/src/platforms/line/cli.js +1 -1
- package/dist/src/platforms/webex/cli.js +1 -1
- package/dist/src/platforms/webexbot/cli.js +1 -1
- package/dist/src/platforms/wechatbot/cli.js +1 -1
- package/dist/src/platforms/whatsapp/cli.js +1 -1
- package/dist/src/platforms/whatsappbot/cli.js +1 -1
- package/dist/src/tui/adapters/imessage-adapter.d.ts +21 -0
- package/dist/src/tui/adapters/imessage-adapter.d.ts.map +1 -0
- package/dist/src/tui/adapters/imessage-adapter.js +110 -0
- package/dist/src/tui/adapters/imessage-adapter.js.map +1 -0
- package/dist/src/tui/app.d.ts.map +1 -1
- package/dist/src/tui/app.js +9 -0
- package/dist/src/tui/app.js.map +1 -1
- package/docs/content/docs/agent-skills.mdx +1 -1
- package/docs/content/docs/cli/imessage.mdx +99 -0
- package/docs/content/docs/cli/instagram.mdx +1 -1
- package/docs/content/docs/cli/meta.json +1 -0
- package/docs/content/docs/index.mdx +9 -6
- package/docs/content/docs/quick-start.mdx +2 -0
- package/docs/content/docs/sdk/instagram.mdx +90 -5
- package/docs/content/docs/tui.mdx +4 -3
- package/package.json +10 -2
- package/scripts/postbuild.ts +12 -15
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +1 -1
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-imessage/SKILL.md +133 -0
- package/skills/agent-imessage/references/authentication.md +66 -0
- package/skills/agent-imessage/references/permissions.md +31 -0
- package/skills/agent-imessage/references/setup.md +49 -0
- package/skills/agent-instagram/SKILL.md +72 -2
- package/skills/agent-instagram/references/common-patterns.md +36 -0
- package/skills/agent-instagram/templates/monitor-chat.sh +5 -0
- package/skills/agent-kakaotalk/SKILL.md +1 -1
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +1 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-telegrambot/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +1 -1
- package/skills/agent-webexbot/SKILL.md +1 -1
- package/skills/agent-wechatbot/SKILL.md +1 -1
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/cli.ts +4 -0
- package/src/platforms/imessage/cli.ts +39 -0
- package/src/platforms/imessage/client.test.ts +137 -0
- package/src/platforms/imessage/client.ts +253 -0
- package/src/platforms/imessage/commands/auth.ts +142 -0
- package/src/platforms/imessage/commands/chat.ts +51 -0
- package/src/platforms/imessage/commands/doctor.test.ts +80 -0
- package/src/platforms/imessage/commands/doctor.ts +139 -0
- package/src/platforms/imessage/commands/index.ts +6 -0
- package/src/platforms/imessage/commands/message.test.ts +20 -0
- package/src/platforms/imessage/commands/message.ts +151 -0
- package/src/platforms/imessage/commands/setup.test.ts +52 -0
- package/src/platforms/imessage/commands/setup.ts +76 -0
- package/src/platforms/imessage/commands/shared.test.ts +19 -0
- package/src/platforms/imessage/commands/shared.ts +69 -0
- package/src/platforms/imessage/commands/whoami.ts +22 -0
- package/src/platforms/imessage/credential-manager.test.ts +122 -0
- package/src/platforms/imessage/credential-manager.ts +123 -0
- package/src/platforms/imessage/ensure-auth.ts +18 -0
- package/src/platforms/imessage/errors.ts +27 -0
- package/src/platforms/imessage/index.test.ts +13 -0
- package/src/platforms/imessage/index.ts +16 -0
- package/src/platforms/imessage/rpc.ts +157 -0
- package/src/platforms/imessage/test-stub-imsg.mjs +147 -0
- package/src/platforms/imessage/types.test.ts +47 -0
- package/src/platforms/imessage/types.ts +95 -0
- package/src/platforms/instagram/client.ts +23 -0
- package/src/platforms/instagram/hybrid-listener.test.ts +135 -0
- package/src/platforms/instagram/hybrid-listener.ts +196 -0
- package/src/platforms/instagram/index.ts +10 -0
- package/src/platforms/instagram/mqtt/connection.ts +72 -0
- package/src/platforms/instagram/mqtt/thrift.test.ts +91 -0
- package/src/platforms/instagram/mqtt/thrift.ts +159 -0
- package/src/platforms/instagram/mqtt/transport.ts +285 -0
- package/src/platforms/instagram/realtime-listener.test.ts +34 -0
- package/src/platforms/instagram/realtime-listener.ts +201 -0
- package/src/platforms/webexbot/cli.ts +0 -0
- package/src/tui/adapters/imessage-adapter.ts +131 -0
- package/src/tui/app.ts +13 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
|
|
3
|
+
import { formatOutput } from '@/shared/utils/output'
|
|
4
|
+
|
|
5
|
+
import { IMessageCredentialManager } from '../credential-manager'
|
|
6
|
+
import { createAccountId } from '../types'
|
|
7
|
+
import { runDoctor } from './doctor'
|
|
8
|
+
|
|
9
|
+
const CHECKLIST = `iMessage runs on THIS Mac through the "imsg" tool. One-time setup:
|
|
10
|
+
1. Install imsg: brew install steipete/tap/imsg
|
|
11
|
+
2. Sign Messages.app into your Apple ID (a dedicated Apple ID is recommended).
|
|
12
|
+
3. Grant Full Disk Access to the app/terminal that launches agent-messenger
|
|
13
|
+
(System Settings > Privacy & Security > Full Disk Access). macOS grants to the parent process.
|
|
14
|
+
4. Grant Automation > Messages when first prompted (needed to send).
|
|
15
|
+
|
|
16
|
+
Basic send / read / watch / standard tapbacks need NO SIP changes.
|
|
17
|
+
Typing, edit/unsend, group management need "imsg launch" (SIP disabled) — a later tier.`
|
|
18
|
+
|
|
19
|
+
export async function performSetup(options: {
|
|
20
|
+
bin?: string
|
|
21
|
+
region?: string
|
|
22
|
+
manager?: IMessageCredentialManager
|
|
23
|
+
}): Promise<{ ok: boolean; report: Awaited<ReturnType<typeof runDoctor>>; account_id?: string }> {
|
|
24
|
+
const report = await runDoctor({ bin: options.bin, region: options.region })
|
|
25
|
+
if (!report.ok) {
|
|
26
|
+
return { ok: false, report }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const manager = options.manager ?? new IMessageCredentialManager()
|
|
30
|
+
const now = new Date().toISOString()
|
|
31
|
+
const accountId = createAccountId(options.bin ?? 'default')
|
|
32
|
+
await manager.setAccount({
|
|
33
|
+
account_id: accountId,
|
|
34
|
+
provider: 'imsg',
|
|
35
|
+
binary_path: options.bin,
|
|
36
|
+
region: options.region,
|
|
37
|
+
created_at: now,
|
|
38
|
+
updated_at: now,
|
|
39
|
+
})
|
|
40
|
+
await manager.setCurrent(accountId)
|
|
41
|
+
|
|
42
|
+
return { ok: true, report, account_id: accountId }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function runSetup(options: { bin?: string; region?: string; pretty?: boolean }): Promise<void> {
|
|
46
|
+
console.error(CHECKLIST)
|
|
47
|
+
console.error('')
|
|
48
|
+
|
|
49
|
+
const result = await performSetup({ bin: options.bin, region: options.region })
|
|
50
|
+
if (!result.ok) {
|
|
51
|
+
console.log(formatOutput(result.report, options.pretty))
|
|
52
|
+
process.exit(1)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log(
|
|
56
|
+
formatOutput(
|
|
57
|
+
{
|
|
58
|
+
success: true,
|
|
59
|
+
account_id: result.account_id,
|
|
60
|
+
imsg_version: result.report.imsg_version,
|
|
61
|
+
full_disk_access: result.report.full_disk_access,
|
|
62
|
+
},
|
|
63
|
+
options.pretty,
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
process.exit(0)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const setupCommand = new Command('setup')
|
|
70
|
+
.description('Guided setup: verify imsg + permissions and save an account')
|
|
71
|
+
.option('--bin <path>', 'Path to the imsg binary (default: imsg on PATH)')
|
|
72
|
+
.option('--region <code>', 'Default region for local-format phone numbers')
|
|
73
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
74
|
+
.action(async (opts: { bin?: string; region?: string; pretty?: boolean }) => runSetup(opts))
|
|
75
|
+
|
|
76
|
+
export { runSetup }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { resolveChatTarget } from './shared'
|
|
4
|
+
|
|
5
|
+
describe('resolveChatTarget', () => {
|
|
6
|
+
it('treats a digits-only ref as a chat id (rowid)', () => {
|
|
7
|
+
expect(resolveChatTarget('42')).toEqual({ chatId: 42 })
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('passes a portable guid/identifier through as chat_guid', () => {
|
|
11
|
+
expect(resolveChatTarget('iMessage;-;+15551234567')).toEqual({ chatGuid: 'iMessage;-;+15551234567' })
|
|
12
|
+
expect(resolveChatTarget('iMessage;+;chat99')).toEqual({ chatGuid: 'iMessage;+;chat99' })
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('treats a non-numeric, non-guid ref as a recipient handle', () => {
|
|
16
|
+
expect(resolveChatTarget('+15551234567')).toEqual({ to: '+15551234567' })
|
|
17
|
+
expect(resolveChatTarget('jane@icloud.com')).toEqual({ to: 'jane@icloud.com' })
|
|
18
|
+
})
|
|
19
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { formatOutput } from '@/shared/utils/output'
|
|
2
|
+
|
|
3
|
+
import { ImsgClient, type SendTarget } from '../client'
|
|
4
|
+
import { IMessageCredentialManager } from '../credential-manager'
|
|
5
|
+
import { IMessageError } from '../types'
|
|
6
|
+
|
|
7
|
+
export interface AccountOption {
|
|
8
|
+
account?: string
|
|
9
|
+
pretty?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function parseLimitOption(rawLimit: string | undefined, defaultValue: number, maxValue = 100): number {
|
|
13
|
+
const trimmed = (rawLimit ?? `${defaultValue}`).trim()
|
|
14
|
+
if (!/^\d+$/.test(trimmed)) {
|
|
15
|
+
throw new IMessageError(`--limit must be an integer between 1 and ${maxValue}.`, 'invalid_limit')
|
|
16
|
+
}
|
|
17
|
+
const parsed = Number.parseInt(trimmed, 10)
|
|
18
|
+
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > maxValue) {
|
|
19
|
+
throw new IMessageError(`--limit must be an integer between 1 and ${maxValue}.`, 'invalid_limit')
|
|
20
|
+
}
|
|
21
|
+
return parsed
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function resolveChatTarget(chatRef: string): SendTarget {
|
|
25
|
+
if (/^\d+$/.test(chatRef)) return { chatId: Number.parseInt(chatRef, 10) }
|
|
26
|
+
if (chatRef.includes(';')) return { chatGuid: chatRef }
|
|
27
|
+
return { to: chatRef }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const CHAT_RESOLVE_LIMIT = 1000
|
|
31
|
+
|
|
32
|
+
export async function resolveChatId(client: ImsgClient, chatRef: string): Promise<number> {
|
|
33
|
+
if (/^\d+$/.test(chatRef)) return Number.parseInt(chatRef, 10)
|
|
34
|
+
|
|
35
|
+
if (chatRef.includes(';')) {
|
|
36
|
+
const chats = await client.listChats(CHAT_RESOLVE_LIMIT)
|
|
37
|
+
const match = chats.find((c) => c.guid === chatRef || c.identifier === chatRef)
|
|
38
|
+
if (match) return match.id
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
throw new IMessageError(
|
|
42
|
+
`Could not resolve "${chatRef}" to a chat id (searched the ${CHAT_RESOLVE_LIMIT} most recent chats). Use a numeric chat id from "agent-imessage chat list".`,
|
|
43
|
+
'chat_not_found',
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function withImsgClient<T>(options: AccountOption, fn: (client: ImsgClient) => Promise<T>): Promise<T> {
|
|
48
|
+
const resolved = await new IMessageCredentialManager().resolveAccount(options.account)
|
|
49
|
+
if (!resolved) {
|
|
50
|
+
console.log(
|
|
51
|
+
formatOutput(
|
|
52
|
+
{
|
|
53
|
+
error: 'Not configured. Run "agent-imessage setup" first.',
|
|
54
|
+
code: 'not_authenticated',
|
|
55
|
+
},
|
|
56
|
+
options.pretty,
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
process.exit(1)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const client = await new ImsgClient().login({ binaryPath: resolved.binary_path, region: resolved.region })
|
|
63
|
+
try {
|
|
64
|
+
await client.connect()
|
|
65
|
+
return await fn(client)
|
|
66
|
+
} finally {
|
|
67
|
+
await client.close()
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
|
|
3
|
+
import { handleError } from '@/shared/utils/error-handler'
|
|
4
|
+
import { formatOutput } from '@/shared/utils/output'
|
|
5
|
+
|
|
6
|
+
import { withImsgClient } from './shared'
|
|
7
|
+
|
|
8
|
+
async function whoamiAction(options: { account?: string; pretty?: boolean }): Promise<void> {
|
|
9
|
+
try {
|
|
10
|
+
const status = await withImsgClient(options, (client) => client.getStatus())
|
|
11
|
+
console.log(formatOutput(status, options.pretty))
|
|
12
|
+
process.exit(0)
|
|
13
|
+
} catch (error) {
|
|
14
|
+
handleError(error as Error)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const whoamiCommand = new Command('whoami')
|
|
19
|
+
.description('Show the active iMessage account and imsg status')
|
|
20
|
+
.option('--account <id>', 'Use a specific iMessage account')
|
|
21
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
22
|
+
.action(whoamiAction)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { IMessageCredentialManager } from './credential-manager'
|
|
6
|
+
import type { IMessageAccount } from './types'
|
|
7
|
+
|
|
8
|
+
const testDirs: string[] = []
|
|
9
|
+
const saved: Record<string, string | undefined> = {}
|
|
10
|
+
|
|
11
|
+
function setup(): IMessageCredentialManager {
|
|
12
|
+
const dir = join(import.meta.dir, `.test-imsg-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
|
13
|
+
testDirs.push(dir)
|
|
14
|
+
return new IMessageCredentialManager(dir)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function makeAccount(overrides?: Partial<IMessageAccount>): IMessageAccount {
|
|
18
|
+
const now = new Date().toISOString()
|
|
19
|
+
return {
|
|
20
|
+
account_id: 'home',
|
|
21
|
+
provider: 'imsg',
|
|
22
|
+
binary_path: '/opt/homebrew/bin/imsg',
|
|
23
|
+
region: 'US',
|
|
24
|
+
created_at: now,
|
|
25
|
+
updated_at: now,
|
|
26
|
+
...overrides,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function clearEnv(): void {
|
|
31
|
+
for (const k of ['AGENT_IMESSAGE_BIN', 'AGENT_IMESSAGE_REGION']) {
|
|
32
|
+
saved[k] = process.env[k]
|
|
33
|
+
delete process.env[k]
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
for (const [k, v] of Object.entries(saved)) {
|
|
39
|
+
if (v === undefined) delete process.env[k]
|
|
40
|
+
else process.env[k] = v
|
|
41
|
+
}
|
|
42
|
+
for (const dir of testDirs) rmSync(dir, { recursive: true, force: true })
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('IMessageCredentialManager', () => {
|
|
46
|
+
it('persists provider-aware schema with enforced 0600', async () => {
|
|
47
|
+
clearEnv()
|
|
48
|
+
const manager = setup()
|
|
49
|
+
await manager.setAccount(makeAccount())
|
|
50
|
+
const path = join(testDirs[testDirs.length - 1]!, 'imessage-credentials.json')
|
|
51
|
+
expect(existsSync(path)).toBe(true)
|
|
52
|
+
expect(statSync(path).mode & 0o777).toBe(0o600)
|
|
53
|
+
const config = await manager.loadConfig()
|
|
54
|
+
expect(config.accounts.home?.provider).toBe('imsg')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('resolveAccount: env AGENT_IMESSAGE_BIN/REGION override stored, not persisted', async () => {
|
|
58
|
+
clearEnv()
|
|
59
|
+
const manager = setup()
|
|
60
|
+
await manager.setAccount(makeAccount({ binary_path: '/stored/imsg', region: 'GB' }))
|
|
61
|
+
|
|
62
|
+
expect(await manager.resolveAccount()).toEqual({ binary_path: '/stored/imsg', region: 'GB' })
|
|
63
|
+
|
|
64
|
+
process.env.AGENT_IMESSAGE_BIN = '/env/imsg'
|
|
65
|
+
process.env.AGENT_IMESSAGE_REGION = 'US'
|
|
66
|
+
expect(await manager.resolveAccount()).toEqual({ binary_path: '/env/imsg', region: 'US' })
|
|
67
|
+
|
|
68
|
+
const fresh = await manager.getAccount('home')
|
|
69
|
+
expect(fresh?.binary_path).toBe('/stored/imsg')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('resolveAccount returns env-only when no account but AGENT_IMESSAGE_BIN set', async () => {
|
|
73
|
+
clearEnv()
|
|
74
|
+
const manager = setup()
|
|
75
|
+
expect(await manager.resolveAccount()).toBeNull()
|
|
76
|
+
process.env.AGENT_IMESSAGE_BIN = '/env/imsg'
|
|
77
|
+
expect(await manager.resolveAccount()).toEqual({ binary_path: '/env/imsg', region: undefined })
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('coerces malformed config and drops invalid accounts', async () => {
|
|
81
|
+
clearEnv()
|
|
82
|
+
const dir = join(import.meta.dir, `.test-imsg-bad-${Date.now()}`)
|
|
83
|
+
testDirs.push(dir)
|
|
84
|
+
mkdirSync(dir, { recursive: true })
|
|
85
|
+
writeFileSync(
|
|
86
|
+
join(dir, 'imessage-credentials.json'),
|
|
87
|
+
JSON.stringify({ current: 'missing', accounts: { good: { account_id: 'good' }, junk: 'x' } }),
|
|
88
|
+
)
|
|
89
|
+
const config = await new IMessageCredentialManager(dir).loadConfig()
|
|
90
|
+
expect(Object.keys(config.accounts)).toEqual(['good'])
|
|
91
|
+
expect(config.current).toBe('good')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('re-indexes accounts by validated account_id when the persisted key differs', async () => {
|
|
95
|
+
clearEnv()
|
|
96
|
+
const dir = join(import.meta.dir, `.test-imsg-reindex-${Date.now()}`)
|
|
97
|
+
testDirs.push(dir)
|
|
98
|
+
mkdirSync(dir, { recursive: true })
|
|
99
|
+
writeFileSync(
|
|
100
|
+
join(dir, 'imessage-credentials.json'),
|
|
101
|
+
JSON.stringify({
|
|
102
|
+
current: 'home',
|
|
103
|
+
accounts: { 'stale-key': { account_id: 'home', server_url: undefined, binary_path: '/x/imsg' } },
|
|
104
|
+
}),
|
|
105
|
+
)
|
|
106
|
+
const manager = new IMessageCredentialManager(dir)
|
|
107
|
+
const config = await manager.loadConfig()
|
|
108
|
+
expect(Object.keys(config.accounts)).toEqual(['home'])
|
|
109
|
+
expect(config.current).toBe('home')
|
|
110
|
+
expect((await manager.getAccount('home'))?.binary_path).toBe('/x/imsg')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('removeAccount reassigns current; setCurrent false for unknown', async () => {
|
|
114
|
+
clearEnv()
|
|
115
|
+
const manager = setup()
|
|
116
|
+
await manager.setAccount(makeAccount({ account_id: 'a' }))
|
|
117
|
+
await manager.setAccount(makeAccount({ account_id: 'b' }))
|
|
118
|
+
expect(await manager.removeAccount('a')).toBe(true)
|
|
119
|
+
expect((await manager.getAccount())?.account_id).toBe('b')
|
|
120
|
+
expect(await manager.setCurrent('ghost')).toBe(false)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { getConfigDir } from '../../shared/utils/config-dir'
|
|
6
|
+
import { createAccountId, type IMessageAccount, type IMessageConfig } from './types'
|
|
7
|
+
|
|
8
|
+
export interface ResolvedAccount {
|
|
9
|
+
binary_path: string
|
|
10
|
+
region?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function coerceConfig(raw: unknown): IMessageConfig {
|
|
14
|
+
if (typeof raw !== 'object' || raw === null) return { current: null, accounts: {} }
|
|
15
|
+
|
|
16
|
+
const obj = raw as Record<string, unknown>
|
|
17
|
+
const accounts: Record<string, IMessageAccount> = {}
|
|
18
|
+
|
|
19
|
+
if (typeof obj.accounts === 'object' && obj.accounts !== null) {
|
|
20
|
+
for (const value of Object.values(obj.accounts as Record<string, unknown>)) {
|
|
21
|
+
if (typeof value !== 'object' || value === null) continue
|
|
22
|
+
const a = value as Record<string, unknown>
|
|
23
|
+
if (typeof a.account_id !== 'string' || a.account_id.length === 0) continue
|
|
24
|
+
accounts[a.account_id] = {
|
|
25
|
+
account_id: a.account_id,
|
|
26
|
+
provider: 'imsg',
|
|
27
|
+
label: typeof a.label === 'string' ? a.label : undefined,
|
|
28
|
+
binary_path: typeof a.binary_path === 'string' ? a.binary_path : undefined,
|
|
29
|
+
region: typeof a.region === 'string' ? a.region : undefined,
|
|
30
|
+
created_at: typeof a.created_at === 'string' ? a.created_at : new Date().toISOString(),
|
|
31
|
+
updated_at: typeof a.updated_at === 'string' ? a.updated_at : new Date().toISOString(),
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const current =
|
|
37
|
+
typeof obj.current === 'string' && accounts[obj.current] ? obj.current : (Object.keys(accounts)[0] ?? null)
|
|
38
|
+
return { current, accounts }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class IMessageCredentialManager {
|
|
42
|
+
private configDir: string
|
|
43
|
+
private credentialsPath: string
|
|
44
|
+
|
|
45
|
+
constructor(configDir?: string) {
|
|
46
|
+
this.configDir = configDir ?? getConfigDir()
|
|
47
|
+
this.credentialsPath = join(this.configDir, 'imessage-credentials.json')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async loadConfig(): Promise<IMessageConfig> {
|
|
51
|
+
if (!existsSync(this.credentialsPath)) return { current: null, accounts: {} }
|
|
52
|
+
try {
|
|
53
|
+
return coerceConfig(JSON.parse(await readFile(this.credentialsPath, 'utf-8')))
|
|
54
|
+
} catch {
|
|
55
|
+
return { current: null, accounts: {} }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async saveConfig(config: IMessageConfig): Promise<void> {
|
|
60
|
+
await mkdir(this.configDir, { recursive: true })
|
|
61
|
+
await writeFile(this.credentialsPath, JSON.stringify(config, null, 2), { mode: 0o600 })
|
|
62
|
+
await chmod(this.credentialsPath, 0o600)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async getAccount(accountId?: string): Promise<IMessageAccount | null> {
|
|
66
|
+
const config = await this.loadConfig()
|
|
67
|
+
if (!accountId) return config.current ? (config.accounts[config.current] ?? null) : null
|
|
68
|
+
return config.accounts[accountId] ?? config.accounts[createAccountId(accountId)] ?? null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async listAccounts(): Promise<Array<IMessageAccount & { is_current: boolean }>> {
|
|
72
|
+
const config = await this.loadConfig()
|
|
73
|
+
return Object.values(config.accounts).map((account) => ({
|
|
74
|
+
...account,
|
|
75
|
+
is_current: account.account_id === config.current,
|
|
76
|
+
}))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async setAccount(account: IMessageAccount): Promise<void> {
|
|
80
|
+
const config = await this.loadConfig()
|
|
81
|
+
config.accounts[account.account_id] = account
|
|
82
|
+
if (!config.current) config.current = account.account_id
|
|
83
|
+
await this.saveConfig(config)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async setCurrent(accountId: string): Promise<boolean> {
|
|
87
|
+
const config = await this.loadConfig()
|
|
88
|
+
const account = config.accounts[accountId] ?? config.accounts[createAccountId(accountId)]
|
|
89
|
+
if (!account) return false
|
|
90
|
+
config.current = account.account_id
|
|
91
|
+
await this.saveConfig(config)
|
|
92
|
+
return true
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async removeAccount(accountId: string): Promise<boolean> {
|
|
96
|
+
const config = await this.loadConfig()
|
|
97
|
+
const account = config.accounts[accountId] ?? config.accounts[createAccountId(accountId)]
|
|
98
|
+
if (!account) return false
|
|
99
|
+
delete config.accounts[account.account_id]
|
|
100
|
+
if (config.current === account.account_id) {
|
|
101
|
+
config.current = Object.keys(config.accounts)[0] ?? null
|
|
102
|
+
}
|
|
103
|
+
await this.saveConfig(config)
|
|
104
|
+
return true
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async clearCredentials(): Promise<void> {
|
|
108
|
+
if (existsSync(this.credentialsPath)) await rm(this.credentialsPath, { force: true })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async resolveAccount(accountId?: string): Promise<ResolvedAccount | null> {
|
|
112
|
+
const envBin = process.env.AGENT_IMESSAGE_BIN
|
|
113
|
+
const envRegion = process.env.AGENT_IMESSAGE_REGION
|
|
114
|
+
|
|
115
|
+
const account = await this.getAccount(accountId)
|
|
116
|
+
if (!account && !envBin) return null
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
binary_path: envBin ?? account?.binary_path ?? 'imsg',
|
|
120
|
+
region: envRegion ?? account?.region,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { formatOutput } from '@/shared/utils/output'
|
|
2
|
+
|
|
3
|
+
import { IMessageCredentialManager } from './credential-manager'
|
|
4
|
+
|
|
5
|
+
export async function ensureIMessageAuth(): Promise<void> {
|
|
6
|
+
const resolved = await new IMessageCredentialManager().resolveAccount()
|
|
7
|
+
|
|
8
|
+
if (!resolved) {
|
|
9
|
+
console.log(
|
|
10
|
+
formatOutput({
|
|
11
|
+
error:
|
|
12
|
+
'Not configured. iMessage runs on this Mac via the "imsg" tool. Run "agent-imessage setup" for a guided walkthrough, or "agent-imessage doctor" to check requirements.',
|
|
13
|
+
code: 'not_authenticated',
|
|
14
|
+
}),
|
|
15
|
+
)
|
|
16
|
+
process.exit(1)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { IMessageError, type IMessageErrorCode } from './types'
|
|
2
|
+
|
|
3
|
+
const FULL_DISK_ACCESS = /full disk access|cannot access messages database|unable to open database/i
|
|
4
|
+
const AUTOMATION_DENIED = /automation|not authorized|not allowed to send/i
|
|
5
|
+
const APPLESCRIPT_SEND = /unjoined empty outgoing row|delivery to the target chat was not confirmed|applescript/i
|
|
6
|
+
|
|
7
|
+
export function classifyImsgFailure(text: string, fallback: IMessageErrorCode = 'rpc_error'): IMessageError {
|
|
8
|
+
if (FULL_DISK_ACCESS.test(text)) {
|
|
9
|
+
return new IMessageError('imsg cannot read the Messages database.', 'full_disk_access', {
|
|
10
|
+
suggestion:
|
|
11
|
+
'Grant Full Disk Access to the app/terminal that launches agent-messenger (System Settings → Privacy & Security → Full Disk Access).',
|
|
12
|
+
doctorCommand: 'agent-imessage doctor',
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
if (AUTOMATION_DENIED.test(text)) {
|
|
16
|
+
return new IMessageError('imsg is not allowed to control Messages.app.', 'automation_denied', {
|
|
17
|
+
suggestion: 'Grant Automation → Messages in System Settings → Privacy & Security.',
|
|
18
|
+
doctorCommand: 'agent-imessage doctor',
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
if (APPLESCRIPT_SEND.test(text)) {
|
|
22
|
+
return new IMessageError(text || 'imsg could not send the message.', 'send_failed', {
|
|
23
|
+
doctorCommand: 'agent-imessage doctor',
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
return new IMessageError(text || 'imsg request failed.', fallback, { doctorCommand: 'agent-imessage doctor' })
|
|
27
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import * as imessage from './index'
|
|
4
|
+
|
|
5
|
+
describe('imessage SDK barrel', () => {
|
|
6
|
+
it('exports client, rpc, credential manager, error, helper', () => {
|
|
7
|
+
expect(typeof imessage.ImsgClient).toBe('function')
|
|
8
|
+
expect(typeof imessage.ImsgRpc).toBe('function')
|
|
9
|
+
expect(typeof imessage.IMessageCredentialManager).toBe('function')
|
|
10
|
+
expect(typeof imessage.IMessageError).toBe('function')
|
|
11
|
+
expect(typeof imessage.createAccountId).toBe('function')
|
|
12
|
+
})
|
|
13
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { ImsgClient } from './client'
|
|
2
|
+
export type { OneShotRunner } from './client'
|
|
3
|
+
export { ImsgRpc } from './rpc'
|
|
4
|
+
export { IMessageCredentialManager } from './credential-manager'
|
|
5
|
+
export type { ResolvedAccount } from './credential-manager'
|
|
6
|
+
export {
|
|
7
|
+
createAccountId,
|
|
8
|
+
IMessageError,
|
|
9
|
+
type IMessageAccount,
|
|
10
|
+
type IMessageChatSummary,
|
|
11
|
+
type IMessageConfig,
|
|
12
|
+
type IMessageErrorCode,
|
|
13
|
+
type IMessageMessageSummary,
|
|
14
|
+
type IMessageProvider,
|
|
15
|
+
type IMessageStatus,
|
|
16
|
+
} from './types'
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process'
|
|
2
|
+
import { createInterface, type Interface } from 'node:readline'
|
|
3
|
+
|
|
4
|
+
import { classifyImsgFailure } from './errors'
|
|
5
|
+
import { IMessageError } from './types'
|
|
6
|
+
|
|
7
|
+
interface JsonRpcResponse {
|
|
8
|
+
jsonrpc: '2.0'
|
|
9
|
+
id?: string | number
|
|
10
|
+
result?: unknown
|
|
11
|
+
error?: { code: number; message: string; data?: unknown }
|
|
12
|
+
method?: string
|
|
13
|
+
params?: { subscription?: number; message?: unknown; error?: { message?: string } }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type Pending = { resolve: (value: unknown) => void; reject: (err: Error) => void }
|
|
17
|
+
type MessageHandler = (message: unknown) => void
|
|
18
|
+
type ErrorHandler = (message: string) => void
|
|
19
|
+
|
|
20
|
+
function normalizeRpcError(error: { code: number; message: string; data?: unknown }): IMessageError {
|
|
21
|
+
const detail = typeof error.data === 'string' ? error.data : error.message
|
|
22
|
+
const text = `${error.message}${typeof error.data === 'string' ? `: ${error.data}` : ''}`
|
|
23
|
+
return classifyImsgFailure(detail.length > 0 ? `${detail} ${text}` : text, 'rpc_error')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class ImsgRpc {
|
|
27
|
+
private child: ChildProcessWithoutNullStreams | null = null
|
|
28
|
+
private reader: Interface | null = null
|
|
29
|
+
private nextId = 1
|
|
30
|
+
private pending = new Map<string | number, Pending>()
|
|
31
|
+
private subscriptions = new Map<number, { onMessage: MessageHandler; onError?: ErrorHandler }>()
|
|
32
|
+
private stderrBuffer = ''
|
|
33
|
+
|
|
34
|
+
async start(binaryPath = 'imsg'): Promise<void> {
|
|
35
|
+
if (this.child) return
|
|
36
|
+
|
|
37
|
+
const child = await new Promise<ChildProcessWithoutNullStreams>((resolve, reject) => {
|
|
38
|
+
let proc: ChildProcessWithoutNullStreams
|
|
39
|
+
try {
|
|
40
|
+
proc = spawn(binaryPath, ['rpc'], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
41
|
+
} catch {
|
|
42
|
+
reject(this.spawnError(binaryPath))
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
proc.once('error', (err: NodeJS.ErrnoException) => {
|
|
46
|
+
reject(err.code === 'ENOENT' ? this.spawnError(binaryPath) : err)
|
|
47
|
+
})
|
|
48
|
+
proc.once('spawn', () => resolve(proc))
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
this.child = child
|
|
52
|
+
child.stderr.setEncoding('utf8')
|
|
53
|
+
child.stderr.on('data', (chunk: string) => {
|
|
54
|
+
this.stderrBuffer = (this.stderrBuffer + chunk).slice(-4096)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
this.reader = createInterface({ input: child.stdout })
|
|
58
|
+
this.reader.on('line', (line: string) => this.handleLine(line))
|
|
59
|
+
|
|
60
|
+
child.once('exit', () => this.handleExit())
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private spawnError(binaryPath: string): IMessageError {
|
|
64
|
+
return new IMessageError(`Could not run "${binaryPath}".`, 'imsg_not_found', {
|
|
65
|
+
suggestion: 'Install imsg: "brew install steipete/tap/imsg", or set --bin / AGENT_IMESSAGE_BIN.',
|
|
66
|
+
doctorCommand: 'agent-imessage doctor',
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private handleLine(line: string): void {
|
|
71
|
+
const trimmed = line.trim()
|
|
72
|
+
if (!trimmed) return
|
|
73
|
+
|
|
74
|
+
let frame: JsonRpcResponse
|
|
75
|
+
try {
|
|
76
|
+
frame = JSON.parse(trimmed) as JsonRpcResponse
|
|
77
|
+
} catch {
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (frame.method === 'message' && frame.params?.subscription !== undefined) {
|
|
82
|
+
this.subscriptions.get(frame.params.subscription)?.onMessage(frame.params.message)
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
if (frame.method === 'error' && frame.params?.subscription !== undefined) {
|
|
86
|
+
this.subscriptions.get(frame.params.subscription)?.onError?.(frame.params.error?.message ?? 'watch error')
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (frame.id === undefined) return
|
|
91
|
+
const pending = this.pending.get(frame.id)
|
|
92
|
+
if (!pending) return
|
|
93
|
+
this.pending.delete(frame.id)
|
|
94
|
+
|
|
95
|
+
if (frame.error) {
|
|
96
|
+
pending.reject(normalizeRpcError(frame.error))
|
|
97
|
+
} else {
|
|
98
|
+
pending.resolve(frame.result)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private handleExit(): void {
|
|
103
|
+
const err = new IMessageError(
|
|
104
|
+
`imsg rpc process exited.${this.stderrBuffer ? ` ${this.stderrBuffer.trim()}` : ''}`,
|
|
105
|
+
'rpc_error',
|
|
106
|
+
)
|
|
107
|
+
for (const pending of this.pending.values()) pending.reject(err)
|
|
108
|
+
this.pending.clear()
|
|
109
|
+
this.child = null
|
|
110
|
+
this.reader?.close()
|
|
111
|
+
this.reader = null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async request<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
|
115
|
+
if (!this.child) throw new IMessageError('imsg rpc is not running. Call start() first.', 'rpc_error')
|
|
116
|
+
const id = this.nextId++
|
|
117
|
+
const frame = `${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`
|
|
118
|
+
|
|
119
|
+
return await new Promise<T>((resolve, reject) => {
|
|
120
|
+
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject })
|
|
121
|
+
this.child!.stdin.write(frame, (err) => {
|
|
122
|
+
if (err) {
|
|
123
|
+
this.pending.delete(id)
|
|
124
|
+
reject(err)
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async subscribe(params: Record<string, unknown>, onMessage: MessageHandler, onError?: ErrorHandler): Promise<number> {
|
|
131
|
+
const result = await this.request<{ subscription: number }>('watch.subscribe', params)
|
|
132
|
+
this.subscriptions.set(result.subscription, { onMessage, onError })
|
|
133
|
+
return result.subscription
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async unsubscribe(subscription: number): Promise<void> {
|
|
137
|
+
this.subscriptions.delete(subscription)
|
|
138
|
+
try {
|
|
139
|
+
await this.request('watch.unsubscribe', { subscription })
|
|
140
|
+
} catch {
|
|
141
|
+
this.subscriptions.delete(subscription)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
close(): void {
|
|
146
|
+
const err = new IMessageError('imsg rpc connection closed.', 'rpc_error')
|
|
147
|
+
for (const pending of this.pending.values()) pending.reject(err)
|
|
148
|
+
this.pending.clear()
|
|
149
|
+
this.subscriptions.clear()
|
|
150
|
+
if (this.child) {
|
|
151
|
+
this.child.stdin.end()
|
|
152
|
+
this.child = null
|
|
153
|
+
}
|
|
154
|
+
this.reader?.close()
|
|
155
|
+
this.reader = null
|
|
156
|
+
}
|
|
157
|
+
}
|