agent-messenger 2.27.1 → 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/kakaotalk/client.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.js +4 -4
- package/dist/src/platforms/kakaotalk/client.js.map +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/kakaotalk/client.test.ts +40 -0
- package/src/platforms/kakaotalk/client.ts +6 -4
- 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,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
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Test stub for the imsg binary. Behavior controlled by IMSG_STUB_MODE.
|
|
3
|
+
// Modes: ok (default), fda_denied, send_fail
|
|
4
|
+
const mode = process.env.IMSG_STUB_MODE || 'ok'
|
|
5
|
+
const arg = process.argv[2]
|
|
6
|
+
|
|
7
|
+
if (arg === '--version') {
|
|
8
|
+
if (mode === 'novers') {
|
|
9
|
+
process.exit(1)
|
|
10
|
+
}
|
|
11
|
+
process.stdout.write('0.11.1\n')
|
|
12
|
+
process.exit(0)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (arg === 'react') {
|
|
16
|
+
if (mode === 'react_automation_denied') {
|
|
17
|
+
process.stderr.write('AppleScript error: Messages got an error: Not authorized to send Apple events\n')
|
|
18
|
+
process.exit(1)
|
|
19
|
+
}
|
|
20
|
+
if (mode === 'react_fail') {
|
|
21
|
+
process.stdout.write('error: could not react\n')
|
|
22
|
+
process.exit(1)
|
|
23
|
+
}
|
|
24
|
+
process.stdout.write(JSON.stringify({ success: true, chat_id: 42, reaction_type: 'love' }) + '\n')
|
|
25
|
+
process.exit(0)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (arg === 'rpc') {
|
|
29
|
+
let buf = ''
|
|
30
|
+
process.stdin.on('data', (d) => {
|
|
31
|
+
buf += d
|
|
32
|
+
let i
|
|
33
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
34
|
+
const line = buf.slice(0, i).trim()
|
|
35
|
+
buf = buf.slice(i + 1)
|
|
36
|
+
if (!line) continue
|
|
37
|
+
const req = JSON.parse(line)
|
|
38
|
+
const reply = (result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\n')
|
|
39
|
+
const fail = (code, message, data) =>
|
|
40
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: req.id, error: { code, message, data } }) + '\n')
|
|
41
|
+
|
|
42
|
+
if (req.method === 'chats.list') {
|
|
43
|
+
if (mode === 'fda_denied') {
|
|
44
|
+
fail(-32603, 'Internal error', 'Permission Error: Cannot access Messages database')
|
|
45
|
+
} else if (mode === 'connect_rpc_fail') {
|
|
46
|
+
fail(-32603, 'Internal error', 'some unexpected internal failure')
|
|
47
|
+
} else {
|
|
48
|
+
reply({
|
|
49
|
+
chats: [
|
|
50
|
+
{
|
|
51
|
+
id: 42,
|
|
52
|
+
name: 'Jane',
|
|
53
|
+
identifier: 'iMessage;-;+15551234567',
|
|
54
|
+
guid: 'iMessage;-;+15551234567',
|
|
55
|
+
service: 'iMessage',
|
|
56
|
+
is_group: false,
|
|
57
|
+
participants: ['+15551234567'],
|
|
58
|
+
last_message: {
|
|
59
|
+
id: 1,
|
|
60
|
+
chat_id: 42,
|
|
61
|
+
guid: 'm1',
|
|
62
|
+
sender: '+15551234567',
|
|
63
|
+
is_from_me: false,
|
|
64
|
+
text: 'hi',
|
|
65
|
+
created_at: '2026-06-24T00:00:00.000Z',
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: 43,
|
|
70
|
+
name: 'Crew',
|
|
71
|
+
identifier: 'iMessage;+;chat99',
|
|
72
|
+
guid: 'iMessage;+;chat99',
|
|
73
|
+
service: 'iMessage',
|
|
74
|
+
is_group: true,
|
|
75
|
+
participants: ['+15551111111', '+15552222222'],
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
} else if (req.method === 'messages.history') {
|
|
81
|
+
reply({
|
|
82
|
+
messages: [
|
|
83
|
+
{
|
|
84
|
+
id: 2,
|
|
85
|
+
chat_id: 42,
|
|
86
|
+
guid: 'm2',
|
|
87
|
+
sender: '',
|
|
88
|
+
is_from_me: true,
|
|
89
|
+
text: 'yo',
|
|
90
|
+
created_at: '2026-06-24T00:01:00.000Z',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: 1,
|
|
94
|
+
chat_id: 42,
|
|
95
|
+
guid: 'm1',
|
|
96
|
+
sender: '+15551234567',
|
|
97
|
+
is_from_me: false,
|
|
98
|
+
text: 'hi',
|
|
99
|
+
created_at: '2026-06-24T00:00:00.000Z',
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
})
|
|
103
|
+
} else if (req.method === 'send') {
|
|
104
|
+
if (mode === 'send_fail') {
|
|
105
|
+
fail(-32603, 'Internal error', 'Messages accepted the chat send but wrote an unjoined empty outgoing row')
|
|
106
|
+
} else if (mode === 'automation_denied') {
|
|
107
|
+
fail(
|
|
108
|
+
-32603,
|
|
109
|
+
'Internal error',
|
|
110
|
+
'AppleScript error: Messages got an error: Not authorized to send Apple events',
|
|
111
|
+
)
|
|
112
|
+
} else {
|
|
113
|
+
reply({ ok: true, id: 99, guid: 'sent-guid', service: 'iMessage' })
|
|
114
|
+
}
|
|
115
|
+
} else if (req.method === 'watch.subscribe') {
|
|
116
|
+
reply({ subscription: 1 })
|
|
117
|
+
setTimeout(
|
|
118
|
+
() =>
|
|
119
|
+
process.stdout.write(
|
|
120
|
+
JSON.stringify({
|
|
121
|
+
jsonrpc: '2.0',
|
|
122
|
+
method: 'message',
|
|
123
|
+
params: {
|
|
124
|
+
subscription: 1,
|
|
125
|
+
message: {
|
|
126
|
+
id: 3,
|
|
127
|
+
chat_id: 42,
|
|
128
|
+
guid: 'm3',
|
|
129
|
+
sender: '+15551234567',
|
|
130
|
+
is_from_me: false,
|
|
131
|
+
text: 'new!',
|
|
132
|
+
created_at: '2026-06-24T00:02:00.000Z',
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
}) + '\n',
|
|
136
|
+
),
|
|
137
|
+
30,
|
|
138
|
+
)
|
|
139
|
+
} else if (req.method === 'watch.unsubscribe') {
|
|
140
|
+
reply({ ok: true })
|
|
141
|
+
} else {
|
|
142
|
+
fail(-32601, 'Method not found')
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
process.stdin.on('end', () => process.exit(0))
|
|
147
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { createAccountId, IMessageError, type IMessageErrorCode } from './types'
|
|
4
|
+
|
|
5
|
+
describe('createAccountId', () => {
|
|
6
|
+
it('slugifies deterministically and is filesystem-safe', () => {
|
|
7
|
+
expect(createAccountId('/opt/homebrew/bin/imsg')).toBe(createAccountId('/opt/homebrew/bin/imsg'))
|
|
8
|
+
expect(createAccountId('My Mac')).toBe('my-mac')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('falls back to default for empty input', () => {
|
|
12
|
+
expect(createAccountId(' ')).toBe('default')
|
|
13
|
+
})
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe('IMessageError', () => {
|
|
17
|
+
const codes: IMessageErrorCode[] = [
|
|
18
|
+
'imsg_not_found',
|
|
19
|
+
'full_disk_access',
|
|
20
|
+
'automation_denied',
|
|
21
|
+
'rpc_error',
|
|
22
|
+
'send_failed',
|
|
23
|
+
'not_authenticated',
|
|
24
|
+
'invalid_limit',
|
|
25
|
+
'chat_not_found',
|
|
26
|
+
'private_api_required',
|
|
27
|
+
'imessage_error',
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
it('is instantiable with every code and exposes .code', () => {
|
|
31
|
+
for (const code of codes) {
|
|
32
|
+
const err = new IMessageError('m', code)
|
|
33
|
+
expect(err.code).toBe(code)
|
|
34
|
+
expect(err).toBeInstanceOf(Error)
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('toJSON includes suggestion/doctorCommand only when present', () => {
|
|
39
|
+
expect(new IMessageError('m', 'rpc_error').toJSON()).toEqual({ error: 'm', code: 'rpc_error' })
|
|
40
|
+
expect(new IMessageError('m', 'imsg_not_found', { suggestion: 's', doctorCommand: 'd' }).toJSON()).toEqual({
|
|
41
|
+
error: 'm',
|
|
42
|
+
code: 'imsg_not_found',
|
|
43
|
+
suggestion: 's',
|
|
44
|
+
doctorCommand: 'd',
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export type IMessageProvider = 'imsg'
|
|
2
|
+
|
|
3
|
+
export interface IMessageAccount {
|
|
4
|
+
account_id: string
|
|
5
|
+
provider: IMessageProvider
|
|
6
|
+
label?: string
|
|
7
|
+
binary_path?: string
|
|
8
|
+
region?: string
|
|
9
|
+
created_at: string
|
|
10
|
+
updated_at: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface IMessageConfig {
|
|
14
|
+
current: string | null
|
|
15
|
+
accounts: Record<string, IMessageAccount>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface IMessageChatSummary {
|
|
19
|
+
id: number
|
|
20
|
+
guid: string | null
|
|
21
|
+
identifier: string | null
|
|
22
|
+
name: string
|
|
23
|
+
service: string
|
|
24
|
+
is_group: boolean
|
|
25
|
+
participants: string[]
|
|
26
|
+
last_message?: IMessageMessageSummary
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface IMessageMessageSummary {
|
|
30
|
+
id: number
|
|
31
|
+
guid: string
|
|
32
|
+
chat_id: number
|
|
33
|
+
from: string
|
|
34
|
+
from_name?: string
|
|
35
|
+
is_outgoing: boolean
|
|
36
|
+
timestamp: string
|
|
37
|
+
text?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface IMessageStatus {
|
|
41
|
+
imsg_version: string | null
|
|
42
|
+
binary_path: string
|
|
43
|
+
full_disk_access: boolean
|
|
44
|
+
automation: 'ok' | 'unknown' | 'denied'
|
|
45
|
+
bridge_available: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type IMessageErrorCode =
|
|
49
|
+
| 'imsg_not_found'
|
|
50
|
+
| 'full_disk_access'
|
|
51
|
+
| 'automation_denied'
|
|
52
|
+
| 'rpc_error'
|
|
53
|
+
| 'send_failed'
|
|
54
|
+
| 'not_authenticated'
|
|
55
|
+
| 'invalid_limit'
|
|
56
|
+
| 'chat_not_found'
|
|
57
|
+
| 'private_api_required'
|
|
58
|
+
| 'imessage_error'
|
|
59
|
+
|
|
60
|
+
export class IMessageError extends Error {
|
|
61
|
+
code: IMessageErrorCode
|
|
62
|
+
suggestion?: string
|
|
63
|
+
doctorCommand?: string
|
|
64
|
+
|
|
65
|
+
constructor(
|
|
66
|
+
message: string,
|
|
67
|
+
code: IMessageErrorCode = 'imessage_error',
|
|
68
|
+
extra?: { suggestion?: string; doctorCommand?: string },
|
|
69
|
+
) {
|
|
70
|
+
super(message)
|
|
71
|
+
this.name = 'IMessageError'
|
|
72
|
+
this.code = code
|
|
73
|
+
this.suggestion = extra?.suggestion
|
|
74
|
+
this.doctorCommand = extra?.doctorCommand
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
toJSON(): { error: string; code: IMessageErrorCode; suggestion?: string; doctorCommand?: string } {
|
|
78
|
+
return {
|
|
79
|
+
error: this.message,
|
|
80
|
+
code: this.code,
|
|
81
|
+
...(this.suggestion ? { suggestion: this.suggestion } : {}),
|
|
82
|
+
...(this.doctorCommand ? { doctorCommand: this.doctorCommand } : {}),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createAccountId(input: string): string {
|
|
88
|
+
const normalized = input
|
|
89
|
+
.trim()
|
|
90
|
+
.toLowerCase()
|
|
91
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
92
|
+
.replace(/^-+|-+$/g, '')
|
|
93
|
+
|
|
94
|
+
return normalized || 'default'
|
|
95
|
+
}
|
|
@@ -298,6 +298,24 @@ export class InstagramClient {
|
|
|
298
298
|
return threads.map((t) => this.mapThread(t))
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
async fetchIrisBootstrap(): Promise<{ seqId: number; snapshotAtMs: number }> {
|
|
302
|
+
this.ensureSession()
|
|
303
|
+
const { data } = await this.request('GET', '/direct_v2/inbox/?limit=1')
|
|
304
|
+
|
|
305
|
+
if (data['status'] !== 'ok') {
|
|
306
|
+
throw new InstagramError('Failed to fetch iris bootstrap', 'inbox_error')
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const seqId = data['seq_id'] as number | undefined
|
|
310
|
+
const snapshotAtMs = data['snapshot_at_ms'] as number | undefined
|
|
311
|
+
|
|
312
|
+
if (typeof seqId !== 'number' || typeof snapshotAtMs !== 'number') {
|
|
313
|
+
throw new InstagramError('Iris bootstrap missing seq_id or snapshot_at_ms', 'iris_bootstrap_missing')
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return { seqId, snapshotAtMs }
|
|
317
|
+
}
|
|
318
|
+
|
|
301
319
|
async searchChats(query: string, limit = 20): Promise<InstagramChatSummary[]> {
|
|
302
320
|
const allChats = await this.listChats(Math.max(limit, 50))
|
|
303
321
|
const lower = query.toLowerCase()
|
|
@@ -405,6 +423,11 @@ export class InstagramClient {
|
|
|
405
423
|
return this.userId
|
|
406
424
|
}
|
|
407
425
|
|
|
426
|
+
getSessionState(): InstagramSessionState {
|
|
427
|
+
this.ensureSession()
|
|
428
|
+
return this.session!
|
|
429
|
+
}
|
|
430
|
+
|
|
408
431
|
async getProfile(): Promise<{
|
|
409
432
|
user_id: string
|
|
410
433
|
username: string
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, mock } from 'bun:test'
|
|
2
|
+
import { EventEmitter } from 'node:events'
|
|
3
|
+
|
|
4
|
+
import { InstagramHybridListener } from '@/platforms/instagram/hybrid-listener'
|
|
5
|
+
import type { InstagramRealtimeListener } from '@/platforms/instagram/realtime-listener'
|
|
6
|
+
|
|
7
|
+
function makeRealtimeFailingClient() {
|
|
8
|
+
return {
|
|
9
|
+
getUserId: () => '123',
|
|
10
|
+
getSessionState: () => {
|
|
11
|
+
throw new Error('realtime bootstrap failed')
|
|
12
|
+
},
|
|
13
|
+
fetchIrisBootstrap: async () => {
|
|
14
|
+
throw new Error('realtime bootstrap failed')
|
|
15
|
+
},
|
|
16
|
+
listChats: mock(async () => []),
|
|
17
|
+
} as never
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class FakeRealtimeListener {
|
|
21
|
+
private emitter = new EventEmitter()
|
|
22
|
+
stopped = false
|
|
23
|
+
|
|
24
|
+
on(event: string, handler: (...args: unknown[]) => void) {
|
|
25
|
+
this.emitter.on(event, handler)
|
|
26
|
+
return this
|
|
27
|
+
}
|
|
28
|
+
off() {
|
|
29
|
+
return this
|
|
30
|
+
}
|
|
31
|
+
once() {
|
|
32
|
+
return this
|
|
33
|
+
}
|
|
34
|
+
async start() {
|
|
35
|
+
this.emitter.emit('connected', { userId: '123' })
|
|
36
|
+
}
|
|
37
|
+
stop() {
|
|
38
|
+
this.stopped = true
|
|
39
|
+
this.emitter.emit('disconnected')
|
|
40
|
+
}
|
|
41
|
+
fail() {
|
|
42
|
+
this.emitter.emit('error', new Error('realtime connection lost'))
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class TestableHybridListener extends InstagramHybridListener {
|
|
47
|
+
lastFake: FakeRealtimeListener | null = null
|
|
48
|
+
protected override createRealtimeListener(): InstagramRealtimeListener {
|
|
49
|
+
const fake = new FakeRealtimeListener()
|
|
50
|
+
this.lastFake = fake
|
|
51
|
+
return fake as unknown as InstagramRealtimeListener
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('InstagramHybridListener', () => {
|
|
56
|
+
let listener: InstagramHybridListener
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
listener?.stop()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('falls back to polling and emits a single error when realtime startup fails', async () => {
|
|
63
|
+
const client = makeRealtimeFailingClient()
|
|
64
|
+
listener = new InstagramHybridListener(client, {
|
|
65
|
+
pollInterval: 60_000,
|
|
66
|
+
realtimeRetryBaseMs: 60_000,
|
|
67
|
+
realtimeRetryMaxMs: 60_000,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const errors: Error[] = []
|
|
71
|
+
listener.on('error', (err) => errors.push(err))
|
|
72
|
+
|
|
73
|
+
const connected = new Promise<{ userId: string; transport: string }>((resolve) => {
|
|
74
|
+
listener.on('connected', resolve)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
await listener.start()
|
|
78
|
+
const info = await connected
|
|
79
|
+
|
|
80
|
+
// given a realtime failure, when startup fails, then it falls back to polling
|
|
81
|
+
expect(info.transport).toBe('polling')
|
|
82
|
+
// and the failure is reported exactly once (no recursive re-entry)
|
|
83
|
+
expect(errors).toHaveLength(1)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('falls back to polling exactly once when a connected realtime listener fails', async () => {
|
|
87
|
+
const client = {
|
|
88
|
+
getUserId: () => '123',
|
|
89
|
+
listChats: mock(async () => []),
|
|
90
|
+
} as never
|
|
91
|
+
const testable = new TestableHybridListener(client, {
|
|
92
|
+
pollInterval: 60_000,
|
|
93
|
+
realtimeRetryBaseMs: 60_000,
|
|
94
|
+
realtimeRetryMaxMs: 60_000,
|
|
95
|
+
})
|
|
96
|
+
listener = testable
|
|
97
|
+
|
|
98
|
+
const errors: Error[] = []
|
|
99
|
+
const transports: string[] = []
|
|
100
|
+
testable.on('error', (err) => errors.push(err))
|
|
101
|
+
testable.on('connected', ({ transport }) => transports.push(transport))
|
|
102
|
+
|
|
103
|
+
await testable.start()
|
|
104
|
+
// given an established realtime connection
|
|
105
|
+
expect(transports).toContain('realtime')
|
|
106
|
+
|
|
107
|
+
const pollingConnected = new Promise<void>((resolve) => {
|
|
108
|
+
testable.on('connected', ({ transport }) => {
|
|
109
|
+
if (transport === 'polling') resolve()
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// when the connected realtime listener fails
|
|
114
|
+
testable.lastFake!.fail()
|
|
115
|
+
await pollingConnected
|
|
116
|
+
|
|
117
|
+
// then the failure is reported once and it falls back to polling without recursion
|
|
118
|
+
expect(errors).toHaveLength(1)
|
|
119
|
+
expect(transports.filter((t) => t === 'polling')).toHaveLength(1)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('uses polling directly when realtime is disabled', async () => {
|
|
123
|
+
const client = makeRealtimeFailingClient()
|
|
124
|
+
listener = new InstagramHybridListener(client, { pollInterval: 60_000, disableRealtime: true })
|
|
125
|
+
|
|
126
|
+
const connected = new Promise<{ transport: string }>((resolve) => {
|
|
127
|
+
listener.on('connected', resolve)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
await listener.start()
|
|
131
|
+
const info = await connected
|
|
132
|
+
|
|
133
|
+
expect(info.transport).toBe('polling')
|
|
134
|
+
})
|
|
135
|
+
})
|