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,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
|
+
})
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
|
|
3
|
+
import type { InstagramClient } from './client'
|
|
4
|
+
import { InstagramListener } from './listener'
|
|
5
|
+
import { InstagramRealtimeListener } from './realtime-listener'
|
|
6
|
+
import type { InstagramMessageSummary } from './types'
|
|
7
|
+
|
|
8
|
+
export interface InstagramHybridListenerEventMap {
|
|
9
|
+
message: [InstagramMessageSummary]
|
|
10
|
+
error: [Error]
|
|
11
|
+
connected: [{ userId: string; transport: 'realtime' | 'polling' }]
|
|
12
|
+
disconnected: []
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type EventKey = keyof InstagramHybridListenerEventMap
|
|
16
|
+
|
|
17
|
+
const DEFAULT_REALTIME_RETRY_BASE_MS = 30_000
|
|
18
|
+
const DEFAULT_REALTIME_RETRY_MAX_MS = 5 * 60_000
|
|
19
|
+
|
|
20
|
+
export interface InstagramHybridListenerOptions {
|
|
21
|
+
pollInterval?: number
|
|
22
|
+
realtimeRetryBaseMs?: number
|
|
23
|
+
realtimeRetryMaxMs?: number
|
|
24
|
+
disableRealtime?: boolean
|
|
25
|
+
connackTimeoutMs?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class InstagramHybridListener {
|
|
29
|
+
private client: InstagramClient
|
|
30
|
+
private emitter = new EventEmitter()
|
|
31
|
+
private realtime: InstagramRealtimeListener | null = null
|
|
32
|
+
private poller: InstagramListener | null = null
|
|
33
|
+
private options: Required<
|
|
34
|
+
Pick<InstagramHybridListenerOptions, 'realtimeRetryBaseMs' | 'realtimeRetryMaxMs' | 'disableRealtime'>
|
|
35
|
+
> &
|
|
36
|
+
Pick<InstagramHybridListenerOptions, 'pollInterval' | 'connackTimeoutMs'>
|
|
37
|
+
private running = false
|
|
38
|
+
private realtimeRetryTimer: ReturnType<typeof setTimeout> | null = null
|
|
39
|
+
private realtimeFailures = 0
|
|
40
|
+
private activeTransport: 'realtime' | 'polling' | null = null
|
|
41
|
+
private tearingDownRealtime = false
|
|
42
|
+
|
|
43
|
+
constructor(client: InstagramClient, options: InstagramHybridListenerOptions = {}) {
|
|
44
|
+
this.client = client
|
|
45
|
+
this.options = {
|
|
46
|
+
pollInterval: options.pollInterval,
|
|
47
|
+
realtimeRetryBaseMs: options.realtimeRetryBaseMs ?? DEFAULT_REALTIME_RETRY_BASE_MS,
|
|
48
|
+
realtimeRetryMaxMs: options.realtimeRetryMaxMs ?? DEFAULT_REALTIME_RETRY_MAX_MS,
|
|
49
|
+
disableRealtime: options.disableRealtime ?? false,
|
|
50
|
+
connackTimeoutMs: options.connackTimeoutMs,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async start(): Promise<void> {
|
|
55
|
+
if (this.running) return
|
|
56
|
+
this.running = true
|
|
57
|
+
|
|
58
|
+
if (this.options.disableRealtime) {
|
|
59
|
+
this.startPolling()
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await this.tryRealtime()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
stop(): void {
|
|
67
|
+
this.running = false
|
|
68
|
+
this.activeTransport = null
|
|
69
|
+
this.clearRealtimeRetry()
|
|
70
|
+
this.teardownRealtime()
|
|
71
|
+
this.teardownPolling()
|
|
72
|
+
this.emitter.emit('disconnected')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
on<K extends EventKey>(event: K, listener: (...args: InstagramHybridListenerEventMap[K]) => void): this {
|
|
76
|
+
this.emitter.on(event, listener as (...args: unknown[]) => void)
|
|
77
|
+
return this
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
off<K extends EventKey>(event: K, listener: (...args: InstagramHybridListenerEventMap[K]) => void): this {
|
|
81
|
+
this.emitter.off(event, listener as (...args: unknown[]) => void)
|
|
82
|
+
return this
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
once<K extends EventKey>(event: K, listener: (...args: InstagramHybridListenerEventMap[K]) => void): this {
|
|
86
|
+
this.emitter.once(event, listener as (...args: unknown[]) => void)
|
|
87
|
+
return this
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
protected createRealtimeListener(): InstagramRealtimeListener {
|
|
91
|
+
return new InstagramRealtimeListener(this.client, { connackTimeoutMs: this.options.connackTimeoutMs })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async tryRealtime(): Promise<void> {
|
|
95
|
+
const realtime = this.createRealtimeListener()
|
|
96
|
+
this.realtime = realtime
|
|
97
|
+
|
|
98
|
+
realtime.on('message', (message) => this.emitter.emit('message', message))
|
|
99
|
+
realtime.on('connected', ({ userId }) => {
|
|
100
|
+
this.realtimeFailures = 0
|
|
101
|
+
this.activeTransport = 'realtime'
|
|
102
|
+
this.teardownPolling()
|
|
103
|
+
this.emitter.emit('connected', { userId, transport: 'realtime' })
|
|
104
|
+
})
|
|
105
|
+
realtime.on('error', (error) => this.onRealtimeFailure(error))
|
|
106
|
+
realtime.on('disconnected', () => {
|
|
107
|
+
// Ignore the 'disconnected' that our own teardown triggers; only an
|
|
108
|
+
// unexpected close (still marked realtime-active) is a real failure.
|
|
109
|
+
if (this.tearingDownRealtime) return
|
|
110
|
+
if (this.running && this.activeTransport === 'realtime') {
|
|
111
|
+
this.onRealtimeFailure(new Error('Realtime connection closed'))
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
await realtime.start()
|
|
117
|
+
// stop() may have run during the bootstrap await; if so, don't leave an MQTT
|
|
118
|
+
// connection alive after the hybrid listener was already torn down.
|
|
119
|
+
if (!this.running) {
|
|
120
|
+
realtime.stop()
|
|
121
|
+
if (this.realtime === realtime) this.realtime = null
|
|
122
|
+
}
|
|
123
|
+
} catch (error) {
|
|
124
|
+
this.onRealtimeFailure(error instanceof Error ? error : new Error(String(error)))
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private onRealtimeFailure(error: Error): void {
|
|
129
|
+
if (!this.running) return
|
|
130
|
+
|
|
131
|
+
this.activeTransport = null
|
|
132
|
+
this.emitter.emit('error', error)
|
|
133
|
+
this.realtimeFailures++
|
|
134
|
+
// teardownRealtime() suppresses the 'disconnected' it triggers, so this path
|
|
135
|
+
// does not re-enter via the realtime disconnected handler.
|
|
136
|
+
this.teardownRealtime()
|
|
137
|
+
this.startPolling()
|
|
138
|
+
this.scheduleRealtimeRetry()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private scheduleRealtimeRetry(): void {
|
|
142
|
+
if (!this.running || this.realtimeRetryTimer) return
|
|
143
|
+
|
|
144
|
+
const delay = Math.min(
|
|
145
|
+
this.options.realtimeRetryBaseMs * 2 ** (this.realtimeFailures - 1),
|
|
146
|
+
this.options.realtimeRetryMaxMs,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
this.realtimeRetryTimer = setTimeout(() => {
|
|
150
|
+
this.realtimeRetryTimer = null
|
|
151
|
+
if (this.running) void this.tryRealtime()
|
|
152
|
+
}, delay)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private startPolling(): void {
|
|
156
|
+
if (this.poller) return
|
|
157
|
+
|
|
158
|
+
const poller = new InstagramListener(this.client, { pollInterval: this.options.pollInterval })
|
|
159
|
+
this.poller = poller
|
|
160
|
+
|
|
161
|
+
poller.on('message', (message) => this.emitter.emit('message', message))
|
|
162
|
+
poller.on('error', (error) => this.emitter.emit('error', error))
|
|
163
|
+
poller.on('connected', ({ userId }) => {
|
|
164
|
+
this.activeTransport = 'polling'
|
|
165
|
+
this.emitter.emit('connected', { userId, transport: 'polling' })
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
void poller.start()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private teardownRealtime(): void {
|
|
172
|
+
if (this.realtime) {
|
|
173
|
+
this.tearingDownRealtime = true
|
|
174
|
+
try {
|
|
175
|
+
this.realtime.stop()
|
|
176
|
+
} finally {
|
|
177
|
+
this.tearingDownRealtime = false
|
|
178
|
+
}
|
|
179
|
+
this.realtime = null
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private teardownPolling(): void {
|
|
184
|
+
if (this.poller) {
|
|
185
|
+
this.poller.stop()
|
|
186
|
+
this.poller = null
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private clearRealtimeRetry(): void {
|
|
191
|
+
if (this.realtimeRetryTimer) {
|
|
192
|
+
clearTimeout(this.realtimeRetryTimer)
|
|
193
|
+
this.realtimeRetryTimer = null
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
export { InstagramClient } from './client'
|
|
2
2
|
export { InstagramCredentialManager } from './credential-manager'
|
|
3
|
+
export {
|
|
4
|
+
InstagramHybridListener,
|
|
5
|
+
type InstagramHybridListenerEventMap,
|
|
6
|
+
type InstagramHybridListenerOptions,
|
|
7
|
+
} from './hybrid-listener'
|
|
3
8
|
export { InstagramListener, type InstagramListenerEventMap } from './listener'
|
|
9
|
+
export {
|
|
10
|
+
InstagramRealtimeListener,
|
|
11
|
+
type InstagramRealtimeListenerEventMap,
|
|
12
|
+
type InstagramRealtimeListenerOptions,
|
|
13
|
+
} from './realtime-listener'
|
|
4
14
|
export { InstagramTokenExtractor, type ExtractedInstagramCookies } from './token-extractor'
|
|
5
15
|
export {
|
|
6
16
|
createAccountId,
|