agent-messenger 2.31.0 → 2.32.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 (98) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +14 -1
  3. package/bun.lock +7 -0
  4. package/dist/package.json +2 -1
  5. package/dist/src/platforms/channeltalk/token-extractor.d.ts.map +1 -1
  6. package/dist/src/platforms/channeltalk/token-extractor.js +67 -18
  7. package/dist/src/platforms/channeltalk/token-extractor.js.map +1 -1
  8. package/dist/src/platforms/discord/client.d.ts +11 -2
  9. package/dist/src/platforms/discord/client.d.ts.map +1 -1
  10. package/dist/src/platforms/discord/client.js +109 -4
  11. package/dist/src/platforms/discord/client.js.map +1 -1
  12. package/dist/src/platforms/discord/commands/channel.d.ts.map +1 -1
  13. package/dist/src/platforms/discord/commands/channel.js +3 -2
  14. package/dist/src/platforms/discord/commands/channel.js.map +1 -1
  15. package/dist/src/platforms/discord/commands/mention.d.ts +5 -0
  16. package/dist/src/platforms/discord/commands/mention.d.ts.map +1 -1
  17. package/dist/src/platforms/discord/commands/mention.js +60 -4
  18. package/dist/src/platforms/discord/commands/mention.js.map +1 -1
  19. package/dist/src/platforms/discord/commands/reaction.js +1 -1
  20. package/dist/src/platforms/discord/commands/reaction.js.map +1 -1
  21. package/dist/src/platforms/discord/commands/snapshot.d.ts.map +1 -1
  22. package/dist/src/platforms/discord/commands/snapshot.js +7 -6
  23. package/dist/src/platforms/discord/commands/snapshot.js.map +1 -1
  24. package/dist/src/platforms/discord/index.d.ts +1 -1
  25. package/dist/src/platforms/discord/index.d.ts.map +1 -1
  26. package/dist/src/platforms/discord/index.js.map +1 -1
  27. package/dist/src/platforms/discord/listener.d.ts +4 -1
  28. package/dist/src/platforms/discord/listener.d.ts.map +1 -1
  29. package/dist/src/platforms/discord/listener.js +28 -1
  30. package/dist/src/platforms/discord/listener.js.map +1 -1
  31. package/dist/src/platforms/discord/types.d.ts +60 -9
  32. package/dist/src/platforms/discord/types.d.ts.map +1 -1
  33. package/dist/src/platforms/discord/types.js +58 -7
  34. package/dist/src/platforms/discord/types.js.map +1 -1
  35. package/dist/src/platforms/slack/index.d.ts +1 -0
  36. package/dist/src/platforms/slack/index.d.ts.map +1 -1
  37. package/dist/src/platforms/slack/index.js.map +1 -1
  38. package/dist/src/platforms/slack/qr-confirmation.d.ts +19 -0
  39. package/dist/src/platforms/slack/qr-confirmation.d.ts.map +1 -0
  40. package/dist/src/platforms/slack/qr-confirmation.js +68 -0
  41. package/dist/src/platforms/slack/qr-confirmation.js.map +1 -0
  42. package/dist/src/platforms/slack/qr-cookie-jar.d.ts +7 -0
  43. package/dist/src/platforms/slack/qr-cookie-jar.d.ts.map +1 -0
  44. package/dist/src/platforms/slack/qr-cookie-jar.js +48 -0
  45. package/dist/src/platforms/slack/qr-cookie-jar.js.map +1 -0
  46. package/dist/src/platforms/slack/qr-http-login.d.ts +2 -1
  47. package/dist/src/platforms/slack/qr-http-login.d.ts.map +1 -1
  48. package/dist/src/platforms/slack/qr-http-login.js +88 -53
  49. package/dist/src/platforms/slack/qr-http-login.js.map +1 -1
  50. package/docs/content/docs/cli/channeltalk.mdx +4 -4
  51. package/docs/content/docs/cli/discord.mdx +7 -0
  52. package/docs/content/docs/sdk/discord.mdx +38 -0
  53. package/e2e/README.md +2 -2
  54. package/e2e/discord.e2e.test.ts +16 -2
  55. package/package.json +2 -1
  56. package/skills/agent-channeltalk/SKILL.md +5 -5
  57. package/skills/agent-channeltalk/references/authentication.md +9 -5
  58. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  59. package/skills/agent-discord/SKILL.md +26 -4
  60. package/skills/agent-discord/references/common-patterns.md +7 -3
  61. package/skills/agent-discordbot/SKILL.md +1 -1
  62. package/skills/agent-imessage/SKILL.md +1 -1
  63. package/skills/agent-instagram/SKILL.md +1 -1
  64. package/skills/agent-kakaotalk/SKILL.md +1 -1
  65. package/skills/agent-line/SKILL.md +1 -1
  66. package/skills/agent-slack/SKILL.md +25 -2
  67. package/skills/agent-slackbot/SKILL.md +1 -1
  68. package/skills/agent-teams/SKILL.md +1 -1
  69. package/skills/agent-telegram/SKILL.md +1 -1
  70. package/skills/agent-telegrambot/SKILL.md +1 -1
  71. package/skills/agent-webex/SKILL.md +1 -1
  72. package/skills/agent-webexbot/SKILL.md +1 -1
  73. package/skills/agent-wechatbot/SKILL.md +1 -1
  74. package/skills/agent-whatsapp/SKILL.md +1 -1
  75. package/skills/agent-whatsappbot/SKILL.md +1 -1
  76. package/src/platforms/channeltalk/token-extractor.test.ts +235 -0
  77. package/src/platforms/channeltalk/token-extractor.ts +81 -22
  78. package/src/platforms/discord/client.test.ts +230 -9
  79. package/src/platforms/discord/client.ts +122 -5
  80. package/src/platforms/discord/commands/channel.test.ts +33 -23
  81. package/src/platforms/discord/commands/channel.ts +3 -2
  82. package/src/platforms/discord/commands/mention.test.ts +84 -0
  83. package/src/platforms/discord/commands/mention.ts +78 -15
  84. package/src/platforms/discord/commands/reaction.test.ts +1 -1
  85. package/src/platforms/discord/commands/reaction.ts +1 -1
  86. package/src/platforms/discord/commands/snapshot.test.ts +80 -2
  87. package/src/platforms/discord/commands/snapshot.ts +7 -6
  88. package/src/platforms/discord/index.ts +3 -0
  89. package/src/platforms/discord/listener.test.ts +69 -2
  90. package/src/platforms/discord/listener.ts +31 -2
  91. package/src/platforms/discord/types.test.ts +56 -0
  92. package/src/platforms/discord/types.ts +91 -10
  93. package/src/platforms/slack/index.ts +1 -0
  94. package/src/platforms/slack/qr-confirmation.ts +86 -0
  95. package/src/platforms/slack/qr-cookie-jar.ts +54 -0
  96. package/src/platforms/slack/qr-http-login-2fa.test.ts +398 -0
  97. package/src/platforms/slack/qr-http-login.test.ts +10 -18
  98. package/src/platforms/slack/qr-http-login.ts +109 -52
@@ -1,6 +1,6 @@
1
1
  import { afterEach, describe, expect, mock, it } from 'bun:test'
2
2
 
3
- import { DiscordListener } from '@/platforms/discord/listener'
3
+ import { DiscordListener, parseReadStates } from '@/platforms/discord/listener'
4
4
  import type {
5
5
  DiscordGatewayGenericEvent,
6
6
  DiscordGatewayMessageCreateEvent,
@@ -68,7 +68,7 @@ class MockWs {
68
68
  this.simulateMessage({ op: 10, d: { heartbeat_interval: interval } })
69
69
  }
70
70
 
71
- simulateReady(sessionId = 'session_123') {
71
+ simulateReady(sessionId = 'session_123', readState?: unknown) {
72
72
  this.simulateMessage({
73
73
  op: 0,
74
74
  t: 'READY',
@@ -77,6 +77,7 @@ class MockWs {
77
77
  session_id: sessionId,
78
78
  resume_gateway_url: 'wss://resume.discord.gg',
79
79
  user: { id: 'U_SELF', username: 'testbot' },
80
+ ...(readState === undefined ? {} : { read_state: readState }),
80
81
  },
81
82
  })
82
83
  }
@@ -135,6 +136,33 @@ async function startConnecting(listener: DiscordListener): Promise<MockWs> {
135
136
  return ws
136
137
  }
137
138
 
139
+ describe('parseReadStates', () => {
140
+ it('filters out non-channel read state types', () => {
141
+ const result = parseReadStates([
142
+ { id: 'c1', last_message_id: '100', mention_count: 1 },
143
+ { id: 'c2', read_state_type: 2, mention_count: 5 },
144
+ ])
145
+
146
+ expect(result).toEqual([{ channelId: 'c1', lastMessageId: '100', mentionCount: 1 }])
147
+ })
148
+
149
+ it('normalizes missing last_message_id and mention_count', () => {
150
+ const result = parseReadStates([{ id: 'c1' }])
151
+
152
+ expect(result).toEqual([{ channelId: 'c1', lastMessageId: null, mentionCount: 0 }])
153
+ })
154
+
155
+ it('skips malformed entries but keeps valid ones', () => {
156
+ const result = parseReadStates([{ id: 'c1', mention_count: 1 }, { mention_count: 3 }])
157
+
158
+ expect(result).toEqual([{ channelId: 'c1', lastMessageId: null, mentionCount: 1 }])
159
+ })
160
+
161
+ it('throws on a non-array, non-versioned payload', () => {
162
+ expect(() => parseReadStates({ foo: 'bar' })).toThrow(/invalid read_state/)
163
+ })
164
+ })
165
+
138
166
  describe('DiscordListener', () => {
139
167
  let listener: DiscordListener
140
168
 
@@ -235,6 +263,45 @@ describe('DiscordListener', () => {
235
263
  })
236
264
  })
237
265
 
266
+ describe('read state', () => {
267
+ it('captures raw-array read_state from READY', async () => {
268
+ const client = createMockClient()
269
+ listener = new DiscordListener(client)
270
+
271
+ const started = listener.start()
272
+ const ws = await waitForSocket()
273
+ ws.simulateOpen()
274
+ ws.simulateHello()
275
+ ws.simulateReady('session_123', [{ id: 'c1', last_message_id: '100', mention_count: 2 }])
276
+ await started
277
+
278
+ expect(listener.getReadState()).toEqual([{ channelId: 'c1', lastMessageId: '100', mentionCount: 2 }])
279
+ })
280
+
281
+ it('captures versioned { entries } read_state from READY', async () => {
282
+ const client = createMockClient()
283
+ listener = new DiscordListener(client)
284
+
285
+ const started = listener.start()
286
+ const ws = await waitForSocket()
287
+ ws.simulateOpen()
288
+ ws.simulateHello()
289
+ ws.simulateReady('session_123', { version: 7, entries: [{ id: 'c1', last_message_id: '100', mention_count: 1 }] })
290
+ await started
291
+
292
+ expect(listener.getReadState()).toEqual([{ channelId: 'c1', lastMessageId: '100', mentionCount: 1 }])
293
+ })
294
+
295
+ it('defaults to an empty read state when READY omits read_state', async () => {
296
+ const client = createMockClient()
297
+ listener = new DiscordListener(client)
298
+
299
+ await startAndReady(listener)
300
+
301
+ expect(listener.getReadState()).toEqual([])
302
+ })
303
+ })
304
+
238
305
  describe('identify', () => {
239
306
  it('sends Identify after Hello', async () => {
240
307
  const client = createMockClient()
@@ -3,8 +3,30 @@ import { EventEmitter } from 'events'
3
3
  import WebSocket from 'ws'
4
4
 
5
5
  import type { DiscordClient } from './client'
6
- import type { DiscordListenerEventMap, DiscordGatewayGenericEvent } from './types'
7
- import { DiscordGatewayOpcode } from './types'
6
+ import type { DiscordListenerEventMap, DiscordGatewayGenericEvent, DiscordReadState } from './types'
7
+ import { DiscordGatewayOpcode, DiscordRawReadStateSchema, DiscordReadyReadStateSchema } from './types'
8
+
9
+ export function parseReadStates(value: unknown): DiscordReadState[] {
10
+ const parsed = DiscordReadyReadStateSchema.safeParse(value)
11
+ if (!parsed.success) {
12
+ throw new Error('Discord READY contained an invalid read_state payload')
13
+ }
14
+
15
+ const entries = Array.isArray(parsed.data) ? parsed.data : parsed.data.entries
16
+
17
+ const readStates: DiscordReadState[] = []
18
+ for (const entry of entries) {
19
+ const state = DiscordRawReadStateSchema.safeParse(entry)
20
+ if (!state.success) continue
21
+ if ((state.data.read_state_type ?? 0) !== 0) continue
22
+ readStates.push({
23
+ channelId: state.data.id,
24
+ lastMessageId: state.data.last_message_id ?? null,
25
+ mentionCount: state.data.mention_count ?? 0,
26
+ })
27
+ }
28
+ return readStates
29
+ }
8
30
 
9
31
  const GATEWAY_URL = 'wss://gateway.discord.gg/?v=10&encoding=json'
10
32
  const GATEWAY_QUERY = '?v=10&encoding=json'
@@ -57,6 +79,7 @@ export class DiscordListener {
57
79
  private resumeGatewayUrl: string | null = null
58
80
  private token: string | null = null
59
81
  private cachedUser: { id: string; username: string } | null = null
82
+ private readState: DiscordReadState[] | null = null
60
83
  private generation = 0
61
84
  private startPromise: Promise<void> | null = null
62
85
  private pendingStartReject: ((error: Error) => void) | null = null
@@ -151,6 +174,7 @@ export class DiscordListener {
151
174
  this.resumeGatewayUrl = null
152
175
  this.token = null
153
176
  this.cachedUser = null
177
+ this.readState = null
154
178
  rejectStart?.(new Error('Discord gateway start was stopped before becoming ready'))
155
179
  }
156
180
 
@@ -174,6 +198,10 @@ export class DiscordListener {
174
198
  return this
175
199
  }
176
200
 
201
+ getReadState(): DiscordReadState[] | null {
202
+ return this.readState
203
+ }
204
+
177
205
  private isCurrent(generation: number, ws?: WebSocket): boolean {
178
206
  if (generation !== this.generation || !this.running) return false
179
207
  if (ws !== undefined && this.ws !== ws) return false
@@ -298,6 +326,7 @@ export class DiscordListener {
298
326
  this.sessionId = d.session_id
299
327
  this.resumeGatewayUrl = d.resume_gateway_url
300
328
  this.cachedUser = d.user
329
+ this.readState = d.read_state === undefined ? [] : parseReadStates(d.read_state)
301
330
  this.reconnectAttempts = 0
302
331
  this.emitter.emit('connected', { user: d.user, sessionId: d.session_id })
303
332
  return
@@ -2,6 +2,7 @@ import { expect, it } from 'bun:test'
2
2
 
3
3
  import {
4
4
  DiscordChannelSchema,
5
+ DiscordChannelType,
5
6
  DiscordConfigSchema,
6
7
  DiscordCredentialsSchema,
7
8
  DiscordError,
@@ -10,6 +11,8 @@ import {
10
11
  DiscordMessageSchema,
11
12
  DiscordReactionSchema,
12
13
  DiscordUserSchema,
14
+ isListableChannel,
15
+ isMessageReadableChannel,
13
16
  } from './types'
14
17
 
15
18
  it('DiscordGuildSchema validates correct guild', () => {
@@ -150,6 +153,17 @@ it('DiscordReactionSchema validates reaction with custom emoji', () => {
150
153
  expect(result.success).toBe(true)
151
154
  })
152
155
 
156
+ it('DiscordReactionSchema validates nullable emoji fields', () => {
157
+ const result = DiscordReactionSchema.safeParse({
158
+ emoji: {
159
+ id: null,
160
+ name: null,
161
+ },
162
+ count: 1,
163
+ })
164
+ expect(result.success).toBe(true)
165
+ })
166
+
153
167
  it('DiscordReactionSchema rejects missing required fields', () => {
154
168
  const result = DiscordReactionSchema.safeParse({
155
169
  emoji: {
@@ -244,3 +258,45 @@ it('DiscordError is instance of Error', () => {
244
258
  const error = new DiscordError('Test error', 'TEST_CODE')
245
259
  expect(error instanceof Error).toBe(true)
246
260
  })
261
+
262
+ it('isListableChannel includes text, announcement, voice, stage, forum, media, and directory', () => {
263
+ const listable = [
264
+ DiscordChannelType.GUILD_TEXT,
265
+ DiscordChannelType.GUILD_ANNOUNCEMENT,
266
+ DiscordChannelType.GUILD_VOICE,
267
+ DiscordChannelType.GUILD_STAGE_VOICE,
268
+ DiscordChannelType.GUILD_FORUM,
269
+ DiscordChannelType.GUILD_MEDIA,
270
+ DiscordChannelType.GUILD_DIRECTORY,
271
+ ]
272
+ expect(listable.every((type) => isListableChannel({ type }))).toBe(true)
273
+ })
274
+
275
+ it('isListableChannel excludes categories and threads', () => {
276
+ const excluded = [
277
+ DiscordChannelType.GUILD_CATEGORY,
278
+ DiscordChannelType.ANNOUNCEMENT_THREAD,
279
+ DiscordChannelType.PUBLIC_THREAD,
280
+ DiscordChannelType.PRIVATE_THREAD,
281
+ ]
282
+ expect(excluded.some((type) => isListableChannel({ type }))).toBe(false)
283
+ })
284
+
285
+ it('isMessageReadableChannel includes text, announcement, voice, and stage', () => {
286
+ const readable = [
287
+ DiscordChannelType.GUILD_TEXT,
288
+ DiscordChannelType.GUILD_ANNOUNCEMENT,
289
+ DiscordChannelType.GUILD_VOICE,
290
+ DiscordChannelType.GUILD_STAGE_VOICE,
291
+ ]
292
+ expect(readable.every((type) => isMessageReadableChannel({ type }))).toBe(true)
293
+ })
294
+
295
+ it('isMessageReadableChannel excludes forum, media, and directory containers', () => {
296
+ const notReadable = [
297
+ DiscordChannelType.GUILD_FORUM,
298
+ DiscordChannelType.GUILD_MEDIA,
299
+ DiscordChannelType.GUILD_DIRECTORY,
300
+ ]
301
+ expect(notReadable.some((type) => isMessageReadableChannel({ type }))).toBe(false)
302
+ })
@@ -27,6 +27,47 @@ export interface DiscordChannel {
27
27
  }
28
28
  }
29
29
 
30
+ // https://discord.com/developers/docs/resources/channel#channel-object-channel-types
31
+ export const DiscordChannelType = {
32
+ GUILD_TEXT: 0,
33
+ DM: 1,
34
+ GUILD_VOICE: 2,
35
+ GROUP_DM: 3,
36
+ GUILD_CATEGORY: 4,
37
+ GUILD_ANNOUNCEMENT: 5,
38
+ ANNOUNCEMENT_THREAD: 10,
39
+ PUBLIC_THREAD: 11,
40
+ PRIVATE_THREAD: 12,
41
+ GUILD_STAGE_VOICE: 13,
42
+ GUILD_DIRECTORY: 14,
43
+ GUILD_FORUM: 15,
44
+ GUILD_MEDIA: 16,
45
+ } as const
46
+
47
+ const THREAD_CHANNEL_TYPES: ReadonlySet<number> = new Set([
48
+ DiscordChannelType.ANNOUNCEMENT_THREAD,
49
+ DiscordChannelType.PUBLIC_THREAD,
50
+ DiscordChannelType.PRIVATE_THREAD,
51
+ ])
52
+
53
+ // Channels with no directly fetchable message timeline:
54
+ // forum/media messages live in child posts/threads, and directory channels
55
+ // are hub listings, not message channels. Fetching messages from these aborts.
56
+ const NON_MESSAGE_CHANNEL_TYPES: ReadonlySet<number> = new Set([
57
+ DiscordChannelType.GUILD_FORUM,
58
+ DiscordChannelType.GUILD_MEDIA,
59
+ DiscordChannelType.GUILD_DIRECTORY,
60
+ ])
61
+
62
+ // Voice/stage channels are listed too because they carry embedded text chat.
63
+ export function isListableChannel(channel: Pick<DiscordChannel, 'type'>): boolean {
64
+ return channel.type !== DiscordChannelType.GUILD_CATEGORY && !THREAD_CHANNEL_TYPES.has(channel.type)
65
+ }
66
+
67
+ export function isMessageReadableChannel(channel: Pick<DiscordChannel, 'type'>): boolean {
68
+ return isListableChannel(channel) && !NON_MESSAGE_CHANNEL_TYPES.has(channel.type)
69
+ }
70
+
30
71
  export interface DiscordMessage {
31
72
  id: string
32
73
  channel_id: string
@@ -38,6 +79,7 @@ export interface DiscordMessage {
38
79
  timestamp: string
39
80
  edited_timestamp?: string
40
81
  thread_id?: string
82
+ reactions?: DiscordReaction[]
41
83
  }
42
84
 
43
85
  export interface DiscordUser {
@@ -58,8 +100,8 @@ export interface DiscordDMChannel {
58
100
 
59
101
  export interface DiscordReaction {
60
102
  emoji: {
61
- id?: string
62
- name: string
103
+ id?: string | null
104
+ name: string | null
63
105
  }
64
106
  count: number
65
107
  }
@@ -91,6 +133,26 @@ export interface DiscordUserNote {
91
133
  note: string
92
134
  }
93
135
 
136
+ export interface DiscordReadState {
137
+ channelId: string
138
+ lastMessageId: string | null
139
+ mentionCount: number
140
+ }
141
+
142
+ export interface DiscordUnreadMention extends DiscordMention {
143
+ mention_count: number
144
+ }
145
+
146
+ export interface DiscordUnreadMentionsResult {
147
+ mentions: DiscordUnreadMention[]
148
+ count: number
149
+ // Account-wide unread-mention badge total; not narrowed by guildId because
150
+ // READY read states carry no guild association.
151
+ badgeCount: number
152
+ complete: boolean
153
+ windowDays: number
154
+ }
155
+
94
156
  export interface DiscordSearchResult {
95
157
  id: string
96
158
  channel_id: string
@@ -190,6 +252,14 @@ export const DiscordChannelSchema = z.object({
190
252
  position: z.number().optional(),
191
253
  })
192
254
 
255
+ export const DiscordReactionSchema = z.object({
256
+ emoji: z.object({
257
+ id: z.string().nullish(),
258
+ name: z.string().nullable(),
259
+ }),
260
+ count: z.number(),
261
+ })
262
+
193
263
  export const DiscordMessageSchema = z.object({
194
264
  id: z.string(),
195
265
  channel_id: z.string(),
@@ -201,6 +271,7 @@ export const DiscordMessageSchema = z.object({
201
271
  timestamp: z.string(),
202
272
  edited_timestamp: z.string().optional(),
203
273
  thread_id: z.string().optional(),
274
+ reactions: z.array(DiscordReactionSchema).optional(),
204
275
  })
205
276
 
206
277
  export const DiscordUserSchema = z.object({
@@ -219,14 +290,6 @@ export const DiscordDMChannelSchema = z.object({
219
290
  name: z.string().optional(),
220
291
  })
221
292
 
222
- export const DiscordReactionSchema = z.object({
223
- emoji: z.object({
224
- id: z.string().optional(),
225
- name: z.string(),
226
- }),
227
- count: z.number(),
228
- })
229
-
230
293
  export const DiscordFileSchema = z.object({
231
294
  id: z.string(),
232
295
  filename: z.string(),
@@ -402,6 +465,24 @@ export interface DiscordGatewayGenericEvent {
402
465
  [key: string]: unknown
403
466
  }
404
467
 
468
+ // read_state_type 0 = channel read state (carries last_message_id); non-zero types
469
+ // carry last_acked_id instead and must be filtered out for mention correlation.
470
+ export const DiscordRawReadStateSchema = z.object({
471
+ id: z.string(),
472
+ read_state_type: z.number().optional(),
473
+ last_message_id: z.string().nullish(),
474
+ mention_count: z.number().optional(),
475
+ })
476
+
477
+ export type DiscordRawReadState = z.infer<typeof DiscordRawReadStateSchema>
478
+
479
+ // READY sends read_state as a raw array, or as { entries, version } when the
480
+ // VERSIONED_READ_STATES gateway capability is negotiated.
481
+ export const DiscordReadyReadStateSchema = z.union([
482
+ z.array(z.unknown()),
483
+ z.object({ entries: z.array(z.unknown()), version: z.number().optional() }),
484
+ ])
485
+
405
486
  export type DiscordGatewayEvent =
406
487
  | DiscordGatewayMessageCreateEvent
407
488
  | DiscordGatewayMessageUpdateEvent
@@ -3,6 +3,7 @@ export { SlackCredentialManager, CredentialManager } from './credential-manager'
3
3
  export { SlackListener } from './listener'
4
4
  export { loginWithQr } from './qr-http-login'
5
5
  export type { QrLoginOptions, QrSession } from './qr-http-login'
6
+ export type { SlackConfirmationChallenge, SlackConfirmationCodeRequest } from './qr-confirmation'
6
7
  export { decodeSlackQr } from './qr-login'
7
8
  export type { SlackQrLogin } from './qr-login'
8
9
  export type {
@@ -0,0 +1,86 @@
1
+ import type { SlackQrLogin } from './qr-login'
2
+
3
+ export interface SlackConfirmationChallenge {
4
+ readonly email: string
5
+ readonly type: string | null
6
+ }
7
+
8
+ export type SlackConfirmationCodeRequest = (challenge: SlackConfirmationChallenge) => Promise<string>
9
+
10
+ type SigninProps = {
11
+ readonly magicLogin: string
12
+ readonly emailAddress: string
13
+ readonly twoFactorType: string | null
14
+ }
15
+
16
+ export function parseConfirmationPage(html: string): SigninProps | null {
17
+ const match = html.match(/id="props_node"[^>]*\bdata-props="([^"]*)"/i)
18
+ const encoded = match?.[1]
19
+ if (!encoded) return null
20
+
21
+ let parsed: unknown
22
+ try {
23
+ parsed = JSON.parse(decodeHtml(encoded))
24
+ } catch {
25
+ return null
26
+ }
27
+ if (!isRecord(parsed)) return null
28
+
29
+ const magicLogin = parsed.magicLogin
30
+ const emailAddress = parsed.emailAddress
31
+ if (typeof magicLogin !== 'string' || magicLogin === '') return null
32
+ if (typeof emailAddress !== 'string' || emailAddress === '') return null
33
+ return {
34
+ magicLogin,
35
+ emailAddress,
36
+ twoFactorType: typeof parsed.twoFactorType === 'string' ? parsed.twoFactorType : null,
37
+ }
38
+ }
39
+
40
+ export function buildConfirmationRequest(
41
+ login: SlackQrLogin,
42
+ finalUrl: string,
43
+ props: SigninProps,
44
+ code: string,
45
+ ): { readonly url: string; readonly body: string } | null {
46
+ const expected = expectedConfirmationPath(login)
47
+ if (!expected) return null
48
+ const url = new URL(finalUrl)
49
+ if (url.hostname !== `${login.workspace}.slack.com`) return null
50
+ if (url.pathname !== expected) return null
51
+
52
+ url.search = ''
53
+ url.searchParams.set('domain', login.workspace)
54
+ url.searchParams.set('domainLogin', '1')
55
+ url.searchParams.set('email', props.emailAddress)
56
+
57
+ return {
58
+ url: url.toString(),
59
+ body: new URLSearchParams({
60
+ '2fa_magiclogin': props.magicLogin,
61
+ remember: '0',
62
+ has_remember: 'false',
63
+ '2fa_code': code.trim(),
64
+ '2fa_action': 'submit_primary',
65
+ }).toString(),
66
+ }
67
+ }
68
+
69
+ export function expectedConfirmationPath(login: SlackQrLogin): string | null {
70
+ const qrSecret = new URL(login.url).pathname.split('/').at(-1)
71
+ if (!qrSecret || !qrSecret.startsWith('z-app-')) return null
72
+ return `/login/${qrSecret}`
73
+ }
74
+
75
+ function decodeHtml(value: string): string {
76
+ return value
77
+ .replaceAll('&quot;', '"')
78
+ .replaceAll('&#39;', "'")
79
+ .replaceAll('&lt;', '<')
80
+ .replaceAll('&gt;', '>')
81
+ .replaceAll('&amp;', '&')
82
+ }
83
+
84
+ function isRecord(value: unknown): value is Record<string, unknown> {
85
+ return typeof value === 'object' && value !== null
86
+ }
@@ -0,0 +1,54 @@
1
+ import { CookieJar } from 'tough-cookie'
2
+
3
+ const BROWSER_USER_AGENT =
4
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36'
5
+
6
+ const SESSION_COOKIE_NAME = 'd'
7
+ const SESSION_COOKIE_PREFIX = 'xoxd-'
8
+
9
+ export class SlackCookieJar {
10
+ readonly #jar = new CookieJar()
11
+
12
+ async fetch(doFetch: typeof fetch, url: string, init: RequestInit = {}): Promise<Response> {
13
+ const cookie = await this.#jar.getCookieString(url)
14
+ const response = await doFetch(url, {
15
+ ...init,
16
+ redirect: 'manual',
17
+ headers: {
18
+ 'User-Agent': BROWSER_USER_AGENT,
19
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
20
+ 'Accept-Language': 'en-US,en;q=0.9',
21
+ ...init.headers,
22
+ ...(cookie ? { Cookie: cookie } : {}),
23
+ },
24
+ })
25
+ for (const setCookie of getSetCookies(response)) {
26
+ await this.#jar.setCookie(setCookie, url, { ignoreError: true })
27
+ }
28
+ return response
29
+ }
30
+
31
+ async sessionCookie(url: string): Promise<string | null> {
32
+ const cookies = await this.#jar.getCookies(url)
33
+ for (const cookie of cookies) {
34
+ if (cookie.key === SESSION_COOKIE_NAME && cookie.value.startsWith(SESSION_COOKIE_PREFIX)) {
35
+ return cookie.value
36
+ }
37
+ }
38
+ return null
39
+ }
40
+ }
41
+
42
+ export function setCookieNames(response: Response): readonly string[] {
43
+ return getSetCookies(response).flatMap((setCookie) => {
44
+ const name = setCookie.split('=')[0]?.trim()
45
+ return name ? [name] : []
46
+ })
47
+ }
48
+
49
+ function getSetCookies(response: Response): readonly string[] {
50
+ const withGetter = response.headers as Headers & { getSetCookie?: () => string[] }
51
+ if (typeof withGetter.getSetCookie === 'function') return withGetter.getSetCookie()
52
+ const single = response.headers.get('set-cookie')
53
+ return single ? [single] : []
54
+ }