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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +14 -1
- package/bun.lock +7 -0
- package/dist/package.json +2 -1
- package/dist/src/platforms/channeltalk/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/channeltalk/token-extractor.js +67 -18
- package/dist/src/platforms/channeltalk/token-extractor.js.map +1 -1
- package/dist/src/platforms/discord/client.d.ts +11 -2
- package/dist/src/platforms/discord/client.d.ts.map +1 -1
- package/dist/src/platforms/discord/client.js +109 -4
- package/dist/src/platforms/discord/client.js.map +1 -1
- package/dist/src/platforms/discord/commands/channel.d.ts.map +1 -1
- package/dist/src/platforms/discord/commands/channel.js +3 -2
- package/dist/src/platforms/discord/commands/channel.js.map +1 -1
- package/dist/src/platforms/discord/commands/mention.d.ts +5 -0
- package/dist/src/platforms/discord/commands/mention.d.ts.map +1 -1
- package/dist/src/platforms/discord/commands/mention.js +60 -4
- package/dist/src/platforms/discord/commands/mention.js.map +1 -1
- package/dist/src/platforms/discord/commands/reaction.js +1 -1
- package/dist/src/platforms/discord/commands/reaction.js.map +1 -1
- package/dist/src/platforms/discord/commands/snapshot.d.ts.map +1 -1
- package/dist/src/platforms/discord/commands/snapshot.js +7 -6
- package/dist/src/platforms/discord/commands/snapshot.js.map +1 -1
- package/dist/src/platforms/discord/index.d.ts +1 -1
- package/dist/src/platforms/discord/index.d.ts.map +1 -1
- package/dist/src/platforms/discord/index.js.map +1 -1
- package/dist/src/platforms/discord/listener.d.ts +4 -1
- package/dist/src/platforms/discord/listener.d.ts.map +1 -1
- package/dist/src/platforms/discord/listener.js +28 -1
- package/dist/src/platforms/discord/listener.js.map +1 -1
- package/dist/src/platforms/discord/types.d.ts +60 -9
- package/dist/src/platforms/discord/types.d.ts.map +1 -1
- package/dist/src/platforms/discord/types.js +58 -7
- package/dist/src/platforms/discord/types.js.map +1 -1
- package/dist/src/platforms/slack/index.d.ts +1 -0
- package/dist/src/platforms/slack/index.d.ts.map +1 -1
- package/dist/src/platforms/slack/index.js.map +1 -1
- package/dist/src/platforms/slack/qr-confirmation.d.ts +19 -0
- package/dist/src/platforms/slack/qr-confirmation.d.ts.map +1 -0
- package/dist/src/platforms/slack/qr-confirmation.js +68 -0
- package/dist/src/platforms/slack/qr-confirmation.js.map +1 -0
- package/dist/src/platforms/slack/qr-cookie-jar.d.ts +7 -0
- package/dist/src/platforms/slack/qr-cookie-jar.d.ts.map +1 -0
- package/dist/src/platforms/slack/qr-cookie-jar.js +48 -0
- package/dist/src/platforms/slack/qr-cookie-jar.js.map +1 -0
- package/dist/src/platforms/slack/qr-http-login.d.ts +2 -1
- package/dist/src/platforms/slack/qr-http-login.d.ts.map +1 -1
- package/dist/src/platforms/slack/qr-http-login.js +88 -53
- package/dist/src/platforms/slack/qr-http-login.js.map +1 -1
- package/docs/content/docs/cli/channeltalk.mdx +4 -4
- package/docs/content/docs/cli/discord.mdx +7 -0
- package/docs/content/docs/sdk/discord.mdx +38 -0
- package/e2e/README.md +2 -2
- package/e2e/discord.e2e.test.ts +16 -2
- package/package.json +2 -1
- package/skills/agent-channeltalk/SKILL.md +5 -5
- package/skills/agent-channeltalk/references/authentication.md +9 -5
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +26 -4
- package/skills/agent-discord/references/common-patterns.md +7 -3
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-imessage/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 +25 -2
- 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/channeltalk/token-extractor.test.ts +235 -0
- package/src/platforms/channeltalk/token-extractor.ts +81 -22
- package/src/platforms/discord/client.test.ts +230 -9
- package/src/platforms/discord/client.ts +122 -5
- package/src/platforms/discord/commands/channel.test.ts +33 -23
- package/src/platforms/discord/commands/channel.ts +3 -2
- package/src/platforms/discord/commands/mention.test.ts +84 -0
- package/src/platforms/discord/commands/mention.ts +78 -15
- package/src/platforms/discord/commands/reaction.test.ts +1 -1
- package/src/platforms/discord/commands/reaction.ts +1 -1
- package/src/platforms/discord/commands/snapshot.test.ts +80 -2
- package/src/platforms/discord/commands/snapshot.ts +7 -6
- package/src/platforms/discord/index.ts +3 -0
- package/src/platforms/discord/listener.test.ts +69 -2
- package/src/platforms/discord/listener.ts +31 -2
- package/src/platforms/discord/types.test.ts +56 -0
- package/src/platforms/discord/types.ts +91 -10
- package/src/platforms/slack/index.ts +1 -0
- package/src/platforms/slack/qr-confirmation.ts +86 -0
- package/src/platforms/slack/qr-cookie-jar.ts +54 -0
- package/src/platforms/slack/qr-http-login-2fa.test.ts +398 -0
- package/src/platforms/slack/qr-http-login.test.ts +10 -18
- 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('"', '"')
|
|
78
|
+
.replaceAll(''', "'")
|
|
79
|
+
.replaceAll('<', '<')
|
|
80
|
+
.replaceAll('>', '>')
|
|
81
|
+
.replaceAll('&', '&')
|
|
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
|
+
}
|