agent-messenger 2.26.0 → 2.27.1
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 +16 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +27 -0
- package/dist/package.json +1 -1
- package/dist/src/platforms/discord/listener.d.ts +9 -4
- package/dist/src/platforms/discord/listener.d.ts.map +1 -1
- package/dist/src/platforms/discord/listener.js +110 -16
- package/dist/src/platforms/discord/listener.js.map +1 -1
- package/dist/src/platforms/discordbot/listener.d.ts +6 -0
- package/dist/src/platforms/discordbot/listener.d.ts.map +1 -1
- package/dist/src/platforms/discordbot/listener.js +78 -2
- package/dist/src/platforms/discordbot/listener.js.map +1 -1
- package/dist/src/platforms/slack/qr-http-login.d.ts.map +1 -1
- package/dist/src/platforms/slack/qr-http-login.js +105 -6
- package/dist/src/platforms/slack/qr-http-login.js.map +1 -1
- package/docs/content/docs/sdk/discord.mdx +1 -9
- package/e2e/README.md +3 -1
- package/package.json +1 -1
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +37 -8
- package/skills/agent-discord/references/authentication.md +46 -8
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +1 -1
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +7 -4
- package/skills/agent-slack/references/authentication.md +8 -3
- 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/platforms/discord/listener.test.ts +187 -147
- package/src/platforms/discord/listener.ts +122 -18
- package/src/platforms/discordbot/listener.test.ts +186 -184
- package/src/platforms/discordbot/listener.ts +83 -2
- package/src/platforms/slack/qr-http-login.test.ts +47 -0
- package/src/platforms/slack/qr-http-login.ts +112 -7
|
@@ -13,6 +13,11 @@ const RECONNECT_MAX_DELAY = 30_000
|
|
|
13
13
|
const NON_RECOVERABLE_CLOSE_CODES = [4004, 4010, 4011, 4012, 4013, 4014]
|
|
14
14
|
const SESSION_RESET_CLOSE_CODES = [4007, 4009]
|
|
15
15
|
|
|
16
|
+
// Hello arrives within milliseconds and READY within a few seconds on a healthy gateway;
|
|
17
|
+
// 15s is generous for a successful handshake while still failing fast on bad tokens,
|
|
18
|
+
// network stalls, or a gateway that accepts the socket but never delivers READY.
|
|
19
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000
|
|
20
|
+
|
|
16
21
|
const DEFAULT_INTENTS =
|
|
17
22
|
DiscordIntent.Guilds |
|
|
18
23
|
DiscordIntent.GuildMessages |
|
|
@@ -26,11 +31,13 @@ type EventKey = keyof DiscordBotListenerEventMap
|
|
|
26
31
|
|
|
27
32
|
export interface DiscordBotListenerOptions {
|
|
28
33
|
intents?: number
|
|
34
|
+
connectTimeoutMs?: number
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
export class DiscordBotListener {
|
|
32
38
|
private client: DiscordBotClient
|
|
33
39
|
private intents: number
|
|
40
|
+
private connectTimeoutMs: number
|
|
34
41
|
private running = false
|
|
35
42
|
private ws: WebSocket | null = null
|
|
36
43
|
private emitter = new EventEmitter()
|
|
@@ -39,6 +46,7 @@ export class DiscordBotListener {
|
|
|
39
46
|
private heartbeatJitterTimer: ReturnType<typeof setTimeout> | null = null
|
|
40
47
|
private invalidSessionTimer: ReturnType<typeof setTimeout> | null = null
|
|
41
48
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
49
|
+
private connectTimeoutTimer: ReturnType<typeof setTimeout> | null = null
|
|
42
50
|
private reconnectAttempts = 0
|
|
43
51
|
private sequence: number | null = null
|
|
44
52
|
private sessionId: string | null = null
|
|
@@ -46,24 +54,91 @@ export class DiscordBotListener {
|
|
|
46
54
|
private token: string | null = null
|
|
47
55
|
private cachedUser: { id: string; username: string } | null = null
|
|
48
56
|
private generation = 0
|
|
57
|
+
private startPromise: Promise<void> | null = null
|
|
58
|
+
private pendingStartReject: ((error: Error) => void) | null = null
|
|
49
59
|
|
|
50
60
|
constructor(client: DiscordBotClient, options?: DiscordBotListenerOptions) {
|
|
51
61
|
this.client = client
|
|
52
62
|
this.intents = options?.intents ?? DEFAULT_INTENTS
|
|
63
|
+
this.connectTimeoutMs = options?.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
|
|
53
64
|
}
|
|
54
65
|
|
|
55
66
|
async start(): Promise<void> {
|
|
67
|
+
if (this.startPromise) return this.startPromise
|
|
56
68
|
if (this.running) return
|
|
69
|
+
|
|
57
70
|
this.running = true
|
|
58
71
|
this.reconnectAttempts = 0
|
|
59
|
-
this.generation
|
|
60
|
-
|
|
72
|
+
const generation = ++this.generation
|
|
73
|
+
|
|
74
|
+
const ready = new Promise<void>((resolve, reject) => {
|
|
75
|
+
let settled = false
|
|
76
|
+
|
|
77
|
+
const cleanup = () => {
|
|
78
|
+
this.emitter.off('connected', onConnected)
|
|
79
|
+
this.emitter.off('error', onError)
|
|
80
|
+
this.pendingStartReject = null
|
|
81
|
+
if (this.connectTimeoutTimer) {
|
|
82
|
+
clearTimeout(this.connectTimeoutTimer)
|
|
83
|
+
this.connectTimeoutTimer = null
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const onConnected = () => {
|
|
88
|
+
if (settled || !this.isCurrent(generation)) return
|
|
89
|
+
settled = true
|
|
90
|
+
cleanup()
|
|
91
|
+
resolve()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const onError = (error: Error) => {
|
|
95
|
+
if (settled || !this.isCurrent(generation)) return
|
|
96
|
+
settled = true
|
|
97
|
+
cleanup()
|
|
98
|
+
this.teardownFailedStart(generation)
|
|
99
|
+
reject(error)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
this.emitter.once('connected', onConnected)
|
|
103
|
+
this.emitter.once('error', onError)
|
|
104
|
+
|
|
105
|
+
// Generation-agnostic on purpose: stop() invokes this to reject an in-flight start
|
|
106
|
+
// (after bumping generation) without leaking the once() handlers.
|
|
107
|
+
this.pendingStartReject = (error: Error) => {
|
|
108
|
+
if (settled) return
|
|
109
|
+
settled = true
|
|
110
|
+
cleanup()
|
|
111
|
+
reject(error)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
this.connectTimeoutTimer = setTimeout(() => {
|
|
115
|
+
onError(new Error(`Discord gateway did not become ready within ${this.connectTimeoutMs}ms`))
|
|
116
|
+
}, this.connectTimeoutMs)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const run = async (): Promise<void> => {
|
|
120
|
+
try {
|
|
121
|
+
await Promise.all([this.connect(generation), ready])
|
|
122
|
+
} finally {
|
|
123
|
+
if (this.generation === generation) this.startPromise = null
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const startPromise = run()
|
|
127
|
+
this.startPromise = startPromise
|
|
128
|
+
return startPromise
|
|
61
129
|
}
|
|
62
130
|
|
|
63
131
|
stop(): void {
|
|
64
132
|
this.running = false
|
|
65
133
|
this.generation++
|
|
66
134
|
this.clearTimers()
|
|
135
|
+
if (this.connectTimeoutTimer) {
|
|
136
|
+
clearTimeout(this.connectTimeoutTimer)
|
|
137
|
+
this.connectTimeoutTimer = null
|
|
138
|
+
}
|
|
139
|
+
const rejectStart = this.pendingStartReject
|
|
140
|
+
this.pendingStartReject = null
|
|
141
|
+
this.startPromise = null
|
|
67
142
|
if (this.ws) {
|
|
68
143
|
this.ws.close()
|
|
69
144
|
this.ws = null
|
|
@@ -73,6 +148,12 @@ export class DiscordBotListener {
|
|
|
73
148
|
this.resumeGatewayUrl = null
|
|
74
149
|
this.token = null
|
|
75
150
|
this.cachedUser = null
|
|
151
|
+
rejectStart?.(new Error('Discord gateway start was stopped before becoming ready'))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private teardownFailedStart(generation: number): void {
|
|
155
|
+
if (!this.isCurrent(generation)) return
|
|
156
|
+
this.stop()
|
|
76
157
|
}
|
|
77
158
|
|
|
78
159
|
on<K extends EventKey>(event: K, listener: (...args: DiscordBotListenerEventMap[K]) => void): this {
|
|
@@ -132,6 +132,53 @@ describe('loginWithQr', () => {
|
|
|
132
132
|
await expect(promise).rejects.toThrow(/expired/)
|
|
133
133
|
})
|
|
134
134
|
|
|
135
|
+
it('reports SSO enforcement when the z-app hop redirects to an identity provider', async () => {
|
|
136
|
+
// Given a workspace whose z-app hop redirects off Slack to an SSO IdP (no d cookie issued)
|
|
137
|
+
const dataUrl = await qrDataUrl(ZAPP_URL)
|
|
138
|
+
const fetchImpl = (async (input: string | URL) => {
|
|
139
|
+
const url = typeof input === 'string' ? input : input.toString()
|
|
140
|
+
if (url.startsWith('https://app.slack.com/t/')) {
|
|
141
|
+
return redirect(`https://${WORKSPACE}.slack.com/z-app-secret`)
|
|
142
|
+
}
|
|
143
|
+
return redirect('https://accounts.google.com/o/oauth2/auth?redirect_uri=https://slack.com/sso/google')
|
|
144
|
+
}) as typeof fetch
|
|
145
|
+
|
|
146
|
+
// When logging in, the error names SSO + the detected provider and points to a working auth method
|
|
147
|
+
await expect(loginWithQr(dataUrl, { fetchImpl })).rejects.toThrow(/enforces SSO \(via Google\)/)
|
|
148
|
+
await expect(loginWithQr(dataUrl, { fetchImpl })).rejects.toThrow(/auth extract/)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('reports SSO enforcement for an unrecognized/vanity IdP host', async () => {
|
|
152
|
+
// Given a z-app hop that redirects off Slack to a custom SAML IdP not in the known-provider list
|
|
153
|
+
const dataUrl = await qrDataUrl(ZAPP_URL)
|
|
154
|
+
const fetchImpl = (async (input: string | URL) => {
|
|
155
|
+
const url = typeof input === 'string' ? input : input.toString()
|
|
156
|
+
if (url.startsWith('https://app.slack.com/t/')) {
|
|
157
|
+
return redirect(`https://${WORKSPACE}.slack.com/z-app-secret`)
|
|
158
|
+
}
|
|
159
|
+
return redirect('https://login.acme.example/saml')
|
|
160
|
+
}) as typeof fetch
|
|
161
|
+
|
|
162
|
+
// When logging in, it is still classified as SSO (without naming a provider) and routes to auth extract
|
|
163
|
+
await expect(loginWithQr(dataUrl, { fetchImpl })).rejects.toThrow(/enforces SSO/)
|
|
164
|
+
await expect(loginWithQr(dataUrl, { fetchImpl })).rejects.toThrow(/auth extract/)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('reports an expired link when the final page is Slack\u2019s "Link Expired" page', async () => {
|
|
168
|
+
// Given a z-app chain that completes on a Slack host but returns the expired page with no d cookie
|
|
169
|
+
const dataUrl = await qrDataUrl(ZAPP_URL)
|
|
170
|
+
const fetchImpl = (async (input: string | URL) => {
|
|
171
|
+
const url = typeof input === 'string' ? input : input.toString()
|
|
172
|
+
if (url.startsWith('https://app.slack.com/t/')) {
|
|
173
|
+
return redirect(`https://${WORKSPACE}.slack.com/z-app-secret`)
|
|
174
|
+
}
|
|
175
|
+
return new Response('<title>Link Expired | Slack</title>', { status: 200 })
|
|
176
|
+
}) as typeof fetch
|
|
177
|
+
|
|
178
|
+
// When logging in, the error names the expiry explicitly
|
|
179
|
+
await expect(loginWithQr(dataUrl, { fetchImpl })).rejects.toThrow(/has expired or was already used/)
|
|
180
|
+
})
|
|
181
|
+
|
|
135
182
|
it('fails with qr_token_failed when the token cannot be retrieved', async () => {
|
|
136
183
|
const dataUrl = await qrDataUrl(ZAPP_URL)
|
|
137
184
|
|
|
@@ -23,10 +23,10 @@ export async function loginWithQr(dataUrl: string, options: QrLoginOptions = {})
|
|
|
23
23
|
const debug = options.debug
|
|
24
24
|
debug?.(`Decoded QR for workspace ${login.workspace}`)
|
|
25
25
|
|
|
26
|
-
const cookie = await captureDCookie(login.url, options)
|
|
26
|
+
const { cookie, denialReason, ssoProvider } = await captureDCookie(login.url, options)
|
|
27
27
|
if (!cookie) {
|
|
28
28
|
throw new SlackError(
|
|
29
|
-
|
|
29
|
+
qrSessionFailureMessage(denialReason, { workspace: login.workspace, ssoProvider }),
|
|
30
30
|
'qr_session_failed',
|
|
31
31
|
)
|
|
32
32
|
}
|
|
@@ -44,13 +44,74 @@ export async function loginWithQr(dataUrl: string, options: QrLoginOptions = {})
|
|
|
44
44
|
return { token, cookie, workspace: login.workspace }
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
interface QrFailureContext {
|
|
48
|
+
workspace?: string
|
|
49
|
+
ssoProvider?: string | null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function qrSessionFailureMessage(denialReason: string | null, context: QrFailureContext = {}): string {
|
|
53
|
+
const workspace = context.workspace ? `"${context.workspace}"` : 'this workspace'
|
|
54
|
+
|
|
55
|
+
if (denialReason === 'sso_required') {
|
|
56
|
+
const via = context.ssoProvider ? ` (via ${context.ssoProvider})` : ''
|
|
57
|
+
return [
|
|
58
|
+
`Slack workspace ${workspace} enforces SSO${via}, so QR sign-in cannot complete here.`,
|
|
59
|
+
'',
|
|
60
|
+
`Why: scanning the QR redirects the login to your identity provider${via}. agent-slack follows the`,
|
|
61
|
+
'redirect chain over plain HTTP and never sends your session cookie off Slack-owned hosts, so it',
|
|
62
|
+
'stops at the IdP and no Slack session (the "d" cookie) is ever issued. Completing SSO requires a',
|
|
63
|
+
'real browser, which a headless environment does not have.',
|
|
64
|
+
'',
|
|
65
|
+
'What to do instead:',
|
|
66
|
+
' On a computer where you are already signed in to Slack (desktop app or a Chromium browser),',
|
|
67
|
+
' run: agent-slack auth extract',
|
|
68
|
+
' This reads your existing session locally — no SSO re-login, no DevTools.',
|
|
69
|
+
'',
|
|
70
|
+
'Background: https://github.com/agent-messenger/agent-messenger/issues/260',
|
|
71
|
+
].join('\n')
|
|
72
|
+
}
|
|
73
|
+
if (denialReason === 'link_expired') {
|
|
74
|
+
return [
|
|
75
|
+
`The Slack QR code for ${workspace} has expired or was already used.`,
|
|
76
|
+
'The z-app sign-in link is single-use and short-lived: it is consumed the first time it is opened',
|
|
77
|
+
'(including previewing or scanning it), and re-using it returns "Link Expired".',
|
|
78
|
+
'',
|
|
79
|
+
'Generate a fresh QR (your name → "Sign in on mobile") and pipe it in immediately, without',
|
|
80
|
+
'previewing or scanning it first.',
|
|
81
|
+
].join('\n')
|
|
82
|
+
}
|
|
83
|
+
if (denialReason === 'awaiting_device_confirmation') {
|
|
84
|
+
return [
|
|
85
|
+
`Slack requires this sign-in to ${workspace} to be confirmed on an already-authenticated device.`,
|
|
86
|
+
'QR sign-in over HTTP cannot complete that approval step.',
|
|
87
|
+
'',
|
|
88
|
+
'Use agent-slack auth extract on a computer already signed in to Slack instead.',
|
|
89
|
+
].join('\n')
|
|
90
|
+
}
|
|
91
|
+
return [
|
|
92
|
+
`Could not establish a Slack session for ${workspace} from the QR code.`,
|
|
93
|
+
'The link may have expired, or the workspace requires a sign-in step that QR-over-HTTP cannot complete.',
|
|
94
|
+
'',
|
|
95
|
+
'Generate a fresh QR and try again, or run agent-slack auth extract on a computer already signed in',
|
|
96
|
+
'to Slack.',
|
|
97
|
+
].join('\n')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface CookieCapture {
|
|
101
|
+
cookie: string | null
|
|
102
|
+
denialReason: string | null
|
|
103
|
+
ssoProvider: string | null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function captureDCookie(startUrl: string, options: QrLoginOptions): Promise<CookieCapture> {
|
|
48
107
|
const doFetch = options.fetchImpl ?? fetch
|
|
49
108
|
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS
|
|
50
109
|
const debug = options.debug
|
|
51
110
|
|
|
52
111
|
let url = startUrl
|
|
53
112
|
let dCookie: string | null = null
|
|
113
|
+
let sessionDenialReason: string | null = null
|
|
114
|
+
let ssoProvider: string | null = null
|
|
54
115
|
const cookieJar: string[] = []
|
|
55
116
|
|
|
56
117
|
for (let hop = 0; hop < maxRedirects; hop++) {
|
|
@@ -72,27 +133,71 @@ async function captureDCookie(startUrl: string, options: QrLoginOptions): Promis
|
|
|
72
133
|
},
|
|
73
134
|
})
|
|
74
135
|
|
|
136
|
+
const cookieNames: string[] = []
|
|
75
137
|
for (const setCookie of getSetCookies(response)) {
|
|
76
138
|
const value = parseDCookie(setCookie)
|
|
77
139
|
if (value) dCookie = value
|
|
140
|
+
const name = setCookie.split('=')[0]?.trim()
|
|
141
|
+
if (name) cookieNames.push(name)
|
|
78
142
|
const pair = setCookie.split(';')[0]?.trim()
|
|
79
143
|
if (pair) cookieJar.push(pair)
|
|
80
144
|
}
|
|
81
145
|
|
|
82
|
-
debug?.(`hop ${hop}: ${response.status}`)
|
|
146
|
+
debug?.(`hop ${hop}: ${response.status} set-cookie=[${cookieNames.join(',') || 'none'}]`)
|
|
83
147
|
|
|
84
148
|
const location = response.status >= 300 && response.status < 400 ? response.headers.get('location') : null
|
|
85
|
-
if (!location)
|
|
149
|
+
if (!location) {
|
|
150
|
+
if (!dCookie) sessionDenialReason = await classifySessionDenial(response)
|
|
151
|
+
break
|
|
152
|
+
}
|
|
86
153
|
|
|
87
154
|
const next = new URL(location, url).toString()
|
|
88
155
|
if (!isSlackHost(next)) {
|
|
89
|
-
|
|
156
|
+
// A no-cookie login QR that redirects off Slack is an SSO/IdP hand-off the
|
|
157
|
+
// HTTP flow cannot complete — classify it regardless of whether the IdP
|
|
158
|
+
// host is a recognized provider (custom/vanity SAML domains included).
|
|
159
|
+
if (!dCookie) {
|
|
160
|
+
sessionDenialReason = 'sso_required'
|
|
161
|
+
ssoProvider = ssoProviderFromUrl(next)
|
|
162
|
+
}
|
|
163
|
+
debug?.(`hop ${hop}: redirect target is not a Slack host (${new URL(next).hostname}), stopping`)
|
|
90
164
|
break
|
|
91
165
|
}
|
|
92
166
|
url = next
|
|
93
167
|
}
|
|
94
168
|
|
|
95
|
-
return dCookie
|
|
169
|
+
return { cookie: dCookie, denialReason: sessionDenialReason, ssoProvider }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function ssoProviderFromUrl(rawUrl: string): string | null {
|
|
173
|
+
let hostname: string
|
|
174
|
+
try {
|
|
175
|
+
hostname = new URL(rawUrl).hostname
|
|
176
|
+
} catch {
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
if (hostname === 'accounts.google.com') return 'Google'
|
|
180
|
+
if (hostname.endsWith('.okta.com') || hostname.endsWith('.oktapreview.com')) return 'Okta'
|
|
181
|
+
if (hostname === 'login.microsoftonline.com') return 'Microsoft'
|
|
182
|
+
if (hostname.endsWith('.onelogin.com')) return 'OneLogin'
|
|
183
|
+
if (hostname.endsWith('.pingidentity.com') || hostname.endsWith('.pingone.com')) return 'Ping Identity'
|
|
184
|
+
if (hostname.endsWith('.auth0.com')) return 'Auth0'
|
|
185
|
+
return null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function classifySessionDenial(response: Response): Promise<string | null> {
|
|
189
|
+
try {
|
|
190
|
+
const body = await response.text()
|
|
191
|
+
if (/Link Expired/i.test(body) || /trouble signing you in/i.test(body)) {
|
|
192
|
+
return 'link_expired'
|
|
193
|
+
}
|
|
194
|
+
if (/sign in on your other device/i.test(body) || /open Slack/i.test(body) || /confirm/i.test(body)) {
|
|
195
|
+
return 'awaiting_device_confirmation'
|
|
196
|
+
}
|
|
197
|
+
return null
|
|
198
|
+
} catch {
|
|
199
|
+
return null
|
|
200
|
+
}
|
|
96
201
|
}
|
|
97
202
|
|
|
98
203
|
export function isSlackHost(rawUrl: string): boolean {
|