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.
Files changed (43) hide show
  1. package/.claude-plugin/README.md +16 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +27 -0
  4. package/dist/package.json +1 -1
  5. package/dist/src/platforms/discord/listener.d.ts +9 -4
  6. package/dist/src/platforms/discord/listener.d.ts.map +1 -1
  7. package/dist/src/platforms/discord/listener.js +110 -16
  8. package/dist/src/platforms/discord/listener.js.map +1 -1
  9. package/dist/src/platforms/discordbot/listener.d.ts +6 -0
  10. package/dist/src/platforms/discordbot/listener.d.ts.map +1 -1
  11. package/dist/src/platforms/discordbot/listener.js +78 -2
  12. package/dist/src/platforms/discordbot/listener.js.map +1 -1
  13. package/dist/src/platforms/slack/qr-http-login.d.ts.map +1 -1
  14. package/dist/src/platforms/slack/qr-http-login.js +105 -6
  15. package/dist/src/platforms/slack/qr-http-login.js.map +1 -1
  16. package/docs/content/docs/sdk/discord.mdx +1 -9
  17. package/e2e/README.md +3 -1
  18. package/package.json +1 -1
  19. package/skills/agent-channeltalk/SKILL.md +1 -1
  20. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  21. package/skills/agent-discord/SKILL.md +37 -8
  22. package/skills/agent-discord/references/authentication.md +46 -8
  23. package/skills/agent-discordbot/SKILL.md +1 -1
  24. package/skills/agent-instagram/SKILL.md +1 -1
  25. package/skills/agent-kakaotalk/SKILL.md +1 -1
  26. package/skills/agent-line/SKILL.md +1 -1
  27. package/skills/agent-slack/SKILL.md +7 -4
  28. package/skills/agent-slack/references/authentication.md +8 -3
  29. package/skills/agent-slackbot/SKILL.md +1 -1
  30. package/skills/agent-teams/SKILL.md +1 -1
  31. package/skills/agent-telegram/SKILL.md +1 -1
  32. package/skills/agent-telegrambot/SKILL.md +1 -1
  33. package/skills/agent-webex/SKILL.md +1 -1
  34. package/skills/agent-webexbot/SKILL.md +1 -1
  35. package/skills/agent-wechatbot/SKILL.md +1 -1
  36. package/skills/agent-whatsapp/SKILL.md +1 -1
  37. package/skills/agent-whatsappbot/SKILL.md +1 -1
  38. package/src/platforms/discord/listener.test.ts +187 -147
  39. package/src/platforms/discord/listener.ts +122 -18
  40. package/src/platforms/discordbot/listener.test.ts +186 -184
  41. package/src/platforms/discordbot/listener.ts +83 -2
  42. package/src/platforms/slack/qr-http-login.test.ts +47 -0
  43. 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
- await this.connect(this.generation)
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
- 'Could not establish a Slack session from the QR code. The link may have expired — generate a new QR code and try again.',
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
- async function captureDCookie(startUrl: string, options: QrLoginOptions): Promise<string | null> {
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) break
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
- debug?.(`hop ${hop}: redirect target is not a Slack host, stopping`)
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 {