agent-messenger 2.19.4 → 2.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -0
- package/dist/package.json +1 -1
- package/dist/src/platforms/line/client.d.ts +10 -1
- package/dist/src/platforms/line/client.d.ts.map +1 -1
- package/dist/src/platforms/line/client.js +156 -11
- package/dist/src/platforms/line/client.js.map +1 -1
- package/dist/src/platforms/line/e2ee-storage.d.ts +16 -0
- package/dist/src/platforms/line/e2ee-storage.d.ts.map +1 -0
- package/dist/src/platforms/line/e2ee-storage.js +93 -0
- package/dist/src/platforms/line/e2ee-storage.js.map +1 -0
- package/dist/src/platforms/line/index.d.ts +1 -1
- package/dist/src/platforms/line/index.d.ts.map +1 -1
- package/dist/src/platforms/line/index.js.map +1 -1
- package/dist/src/platforms/line/listener.d.ts.map +1 -1
- package/dist/src/platforms/line/listener.js +3 -2
- package/dist/src/platforms/line/listener.js.map +1 -1
- package/dist/src/platforms/line/types.d.ts +13 -0
- package/dist/src/platforms/line/types.d.ts.map +1 -1
- package/dist/src/platforms/line/types.js +6 -0
- package/dist/src/platforms/line/types.js.map +1 -1
- package/dist/src/platforms/teams/cli.d.ts.map +1 -1
- package/dist/src/platforms/teams/cli.js +2 -1
- package/dist/src/platforms/teams/cli.js.map +1 -1
- package/dist/src/platforms/teams/client.d.ts +4 -1
- package/dist/src/platforms/teams/client.d.ts.map +1 -1
- package/dist/src/platforms/teams/client.js +84 -0
- package/dist/src/platforms/teams/client.js.map +1 -1
- package/dist/src/platforms/teams/commands/chat.d.ts +13 -0
- package/dist/src/platforms/teams/commands/chat.d.ts.map +1 -0
- package/dist/src/platforms/teams/commands/chat.js +111 -0
- package/dist/src/platforms/teams/commands/chat.js.map +1 -0
- package/dist/src/platforms/teams/commands/index.d.ts +1 -0
- package/dist/src/platforms/teams/commands/index.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/index.js +1 -0
- package/dist/src/platforms/teams/commands/index.js.map +1 -1
- package/dist/src/platforms/teams/types.d.ts +24 -0
- package/dist/src/platforms/teams/types.d.ts.map +1 -1
- package/dist/src/platforms/teams/types.js +8 -0
- package/dist/src/platforms/teams/types.js.map +1 -1
- package/dist/src/tui/adapters/line-adapter.js +1 -1
- package/dist/src/tui/adapters/line-adapter.js.map +1 -1
- package/dist/src/vendor/linejs/_dist/client/login.d.ts +2 -1
- package/dist/src/vendor/linejs/client/login.js +3 -2
- package/dist/src/vendor/linejs/client/login.test.ts +11 -0
- package/docs/content/docs/cli/line.mdx +13 -11
- 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 +1 -1
- 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 +7 -5
- package/skills/agent-line/references/common-patterns.md +12 -3
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +20 -2
- package/skills/agent-teams/references/common-patterns.md +28 -0
- 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-wechatbot/SKILL.md +1 -1
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/platforms/line/client.test.ts +190 -2
- package/src/platforms/line/client.ts +183 -13
- package/src/platforms/line/e2ee-storage.test.ts +154 -0
- package/src/platforms/line/e2ee-storage.ts +119 -0
- package/src/platforms/line/index.test.ts +10 -0
- package/src/platforms/line/index.ts +1 -0
- package/src/platforms/line/listener.test.ts +32 -0
- package/src/platforms/line/listener.ts +5 -4
- package/src/platforms/line/types.test.ts +17 -0
- package/src/platforms/line/types.ts +13 -0
- package/src/platforms/slack/commands/auth.test.ts +16 -6
- package/src/platforms/slack/token-extractor.test.ts +34 -7
- package/src/platforms/teams/cli.ts +2 -0
- package/src/platforms/teams/client.test.ts +96 -0
- package/src/platforms/teams/client.ts +133 -0
- package/src/platforms/teams/commands/chat.test.ts +100 -0
- package/src/platforms/teams/commands/chat.ts +131 -0
- package/src/platforms/teams/commands/index.ts +1 -0
- package/src/platforms/teams/types.ts +20 -0
- package/src/tui/adapters/line-adapter.ts +1 -1
- package/src/vendor/linejs/_dist/client/login.d.ts +2 -1
- package/src/vendor/linejs/client/login.js +3 -2
- package/src/vendor/linejs/client/login.test.ts +11 -0
|
@@ -87,6 +87,23 @@ describe('LineMessageSchema', () => {
|
|
|
87
87
|
expect(result.text).toBeNull()
|
|
88
88
|
})
|
|
89
89
|
|
|
90
|
+
it('parses message with decryption error', () => {
|
|
91
|
+
const data = {
|
|
92
|
+
message_id: 'msg789',
|
|
93
|
+
chat_id: 'u1234567890abcdef',
|
|
94
|
+
author_id: 'u9876543210fedcba',
|
|
95
|
+
text: null,
|
|
96
|
+
decryption_error: {
|
|
97
|
+
code: 'missing_e2ee_key',
|
|
98
|
+
message: 'LINE message is encrypted with Letter Sealing, but this session has no saved E2EE key material.',
|
|
99
|
+
},
|
|
100
|
+
content_type: 'NONE',
|
|
101
|
+
sent_at: '2026-03-29T00:00:00.000Z',
|
|
102
|
+
}
|
|
103
|
+
const result = LineMessageSchema.parse(data)
|
|
104
|
+
expect(result.decryption_error?.code).toBe('missing_e2ee_key')
|
|
105
|
+
})
|
|
106
|
+
|
|
90
107
|
it('rejects missing required fields', () => {
|
|
91
108
|
expect(() => LineMessageSchema.parse({ message_id: 'msg123' })).toThrow()
|
|
92
109
|
})
|
|
@@ -43,10 +43,16 @@ export interface LineMessage {
|
|
|
43
43
|
author_id: string
|
|
44
44
|
author_name?: string
|
|
45
45
|
text: string | null
|
|
46
|
+
decryption_error?: LineDecryptionError
|
|
46
47
|
content_type: string
|
|
47
48
|
sent_at: string
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
export interface LineDecryptionError {
|
|
52
|
+
code: 'missing_e2ee_key' | 'decrypt_failed'
|
|
53
|
+
message: string
|
|
54
|
+
}
|
|
55
|
+
|
|
50
56
|
export interface LineSendResult {
|
|
51
57
|
success: boolean
|
|
52
58
|
chat_id: string
|
|
@@ -106,6 +112,12 @@ export const LineMessageSchema = z.object({
|
|
|
106
112
|
author_id: z.string(),
|
|
107
113
|
author_name: z.string().optional(),
|
|
108
114
|
text: z.string().nullable(),
|
|
115
|
+
decryption_error: z
|
|
116
|
+
.object({
|
|
117
|
+
code: z.enum(['missing_e2ee_key', 'decrypt_failed']),
|
|
118
|
+
message: z.string(),
|
|
119
|
+
})
|
|
120
|
+
.optional(),
|
|
109
121
|
content_type: z.string(),
|
|
110
122
|
sent_at: z.string(),
|
|
111
123
|
})
|
|
@@ -137,6 +149,7 @@ export interface LinePushMessageEvent {
|
|
|
137
149
|
message_id: string
|
|
138
150
|
author_id: string
|
|
139
151
|
text: string | null
|
|
152
|
+
decryption_error?: LineDecryptionError
|
|
140
153
|
content_type: string
|
|
141
154
|
// Raw LINE contentMetadata (sticker IDs, file name/size, media URLs); empty for plain text.
|
|
142
155
|
content_metadata: Record<string, string>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterAll, beforeEach, describe, expect, mock, it } from 'bun:test'
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, mock, spyOn, it } from 'bun:test'
|
|
2
2
|
import { mkdirSync, rmSync } from 'node:fs'
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
@@ -14,6 +14,16 @@ import { type ExtractedWorkspace, TokenExtractor } from '@/platforms/slack/token
|
|
|
14
14
|
const testConfigDir = join(import.meta.dir, '.test-auth-config')
|
|
15
15
|
const testSlackDir = join(import.meta.dir, '.test-slack-data')
|
|
16
16
|
|
|
17
|
+
async function extractWithoutBrowserFallback(extractor: TokenExtractor): Promise<ExtractedWorkspace[]> {
|
|
18
|
+
const extractFromBrowsersSpy = spyOn(TokenExtractor.prototype, 'extractFromBrowsers').mockResolvedValue([])
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
return await extractor.extract()
|
|
22
|
+
} finally {
|
|
23
|
+
extractFromBrowsersSpy.mockRestore()
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
describe('TokenExtractor', () => {
|
|
18
28
|
let extractor: TokenExtractor
|
|
19
29
|
|
|
@@ -85,7 +95,7 @@ describe('TokenExtractor', () => {
|
|
|
85
95
|
extractor = new TokenExtractor('darwin', nonExistentPath)
|
|
86
96
|
|
|
87
97
|
// when
|
|
88
|
-
const result = await extractor
|
|
98
|
+
const result = await extractWithoutBrowserFallback(extractor)
|
|
89
99
|
|
|
90
100
|
// then
|
|
91
101
|
expect(result).toEqual([])
|
|
@@ -97,7 +107,7 @@ describe('TokenExtractor', () => {
|
|
|
97
107
|
extractor = new TokenExtractor('darwin', testSlackDir)
|
|
98
108
|
|
|
99
109
|
// When: extract is called
|
|
100
|
-
const result = await extractor
|
|
110
|
+
const result = await extractWithoutBrowserFallback(extractor)
|
|
101
111
|
|
|
102
112
|
// Then: Should return empty array
|
|
103
113
|
expect(result).toEqual([])
|
|
@@ -463,7 +473,7 @@ describe('Error Handling', () => {
|
|
|
463
473
|
const extractor = new TokenExtractor('darwin', nonExistentPath)
|
|
464
474
|
|
|
465
475
|
// when/then — falls back to browser profiles, returns empty array
|
|
466
|
-
const result = await extractor
|
|
476
|
+
const result = await extractWithoutBrowserFallback(extractor)
|
|
467
477
|
expect(result).toEqual([])
|
|
468
478
|
})
|
|
469
479
|
|
|
@@ -472,7 +482,7 @@ describe('Error Handling', () => {
|
|
|
472
482
|
const extractor = new TokenExtractor('darwin', testSlackDir)
|
|
473
483
|
|
|
474
484
|
// When: Trying to extract from empty directory
|
|
475
|
-
const result = await extractor
|
|
485
|
+
const result = await extractWithoutBrowserFallback(extractor)
|
|
476
486
|
|
|
477
487
|
// Then: Should return empty array
|
|
478
488
|
expect(result).toEqual([])
|
|
@@ -484,7 +494,7 @@ describe('Error Handling', () => {
|
|
|
484
494
|
const extractor = new TokenExtractor('darwin', testSlackDir)
|
|
485
495
|
|
|
486
496
|
// When: Trying to extract
|
|
487
|
-
const result = await extractor
|
|
497
|
+
const result = await extractWithoutBrowserFallback(extractor)
|
|
488
498
|
|
|
489
499
|
// Then: Should return empty array (no tokens found)
|
|
490
500
|
expect(result).toEqual([])
|
|
@@ -12,6 +12,7 @@ import { ExtractedWorkspace, TokenExtractor } from './token-extractor'
|
|
|
12
12
|
|
|
13
13
|
const tempDirs: string[] = []
|
|
14
14
|
const originalAgentBrowserProfile = process.env.AGENT_BROWSER_PROFILE
|
|
15
|
+
const originalLocalAppData = process.env.LOCALAPPDATA
|
|
15
16
|
|
|
16
17
|
afterEach(() => {
|
|
17
18
|
if (originalAgentBrowserProfile) {
|
|
@@ -20,12 +21,34 @@ afterEach(() => {
|
|
|
20
21
|
delete process.env.AGENT_BROWSER_PROFILE
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
if (originalLocalAppData) {
|
|
25
|
+
process.env.LOCALAPPDATA = originalLocalAppData
|
|
26
|
+
} else {
|
|
27
|
+
delete process.env.LOCALAPPDATA
|
|
28
|
+
}
|
|
29
|
+
|
|
23
30
|
for (const dir of tempDirs) {
|
|
24
31
|
rmSync(dir, { recursive: true, force: true })
|
|
25
32
|
}
|
|
26
33
|
tempDirs.length = 0
|
|
27
34
|
})
|
|
28
35
|
|
|
36
|
+
async function extractDesktopOnly(extractor: TokenExtractor): Promise<ExtractedWorkspace[]> {
|
|
37
|
+
const extractFromBrowsersSpy = spyOn(TokenExtractor.prototype, 'extractFromBrowsers').mockResolvedValue([])
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
return await extractor.extract()
|
|
41
|
+
} finally {
|
|
42
|
+
extractFromBrowsersSpy.mockRestore()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function useEmptyWindowsBrowserRoot(): void {
|
|
47
|
+
const browserRoot = mkdtempSync(join(tmpdir(), 'empty-browser-root-'))
|
|
48
|
+
tempDirs.push(browserRoot)
|
|
49
|
+
process.env.LOCALAPPDATA = browserRoot
|
|
50
|
+
}
|
|
51
|
+
|
|
29
52
|
function createCookiesDb(
|
|
30
53
|
dbPath: string,
|
|
31
54
|
cookies: { name: string; value: string; encrypted_value: Uint8Array; host_key: string; last_access_utc: number }[],
|
|
@@ -60,7 +83,7 @@ describe('TokenExtractor token deduplication', () => {
|
|
|
60
83
|
|
|
61
84
|
// when
|
|
62
85
|
const extractor = new TokenExtractor('darwin', slackDir)
|
|
63
|
-
const result = await extractor
|
|
86
|
+
const result = await extractDesktopOnly(extractor)
|
|
64
87
|
|
|
65
88
|
// then — first token wins, but team name is upgraded
|
|
66
89
|
expect(result.length).toBe(1)
|
|
@@ -383,7 +406,7 @@ describe('TokenExtractor debug logging', () => {
|
|
|
383
406
|
|
|
384
407
|
// when
|
|
385
408
|
const extractor = new TokenExtractor('darwin', slackDir, undefined, debugLog)
|
|
386
|
-
await extractor
|
|
409
|
+
await extractDesktopOnly(extractor)
|
|
387
410
|
|
|
388
411
|
// then — should have emitted debug messages
|
|
389
412
|
expect(messages.length).toBeGreaterThan(0)
|
|
@@ -397,7 +420,7 @@ describe('TokenExtractor debug logging', () => {
|
|
|
397
420
|
|
|
398
421
|
// when — then — should not throw
|
|
399
422
|
const extractor = new TokenExtractor('darwin', slackDir)
|
|
400
|
-
const result = await extractor
|
|
423
|
+
const result = await extractDesktopOnly(extractor)
|
|
401
424
|
expect(result).toEqual([])
|
|
402
425
|
})
|
|
403
426
|
})
|
|
@@ -576,7 +599,7 @@ describe('TokenExtractor Windows DPAPI', () => {
|
|
|
576
599
|
|
|
577
600
|
// when
|
|
578
601
|
const extractor = new TestTokenExtractor('win32', slackDir)
|
|
579
|
-
const result = await extractor
|
|
602
|
+
const result = await extractDesktopOnly(extractor)
|
|
580
603
|
|
|
581
604
|
// then
|
|
582
605
|
expect(result).toEqual([
|
|
@@ -711,7 +734,7 @@ describe('TokenExtractor IndexedDB blob files', () => {
|
|
|
711
734
|
|
|
712
735
|
// when
|
|
713
736
|
const extractor = new TokenExtractor('darwin', slackDir)
|
|
714
|
-
const result = await extractor
|
|
737
|
+
const result = await extractDesktopOnly(extractor)
|
|
715
738
|
|
|
716
739
|
// then
|
|
717
740
|
expect(result.length).toBe(0)
|
|
@@ -802,17 +825,21 @@ describe('TokenExtractor getWorkspaceDomains', () => {
|
|
|
802
825
|
|
|
803
826
|
describe('TokenExtractor browser fallback', () => {
|
|
804
827
|
it('extractFromBrowsers returns empty array when no browser profiles have tokens', async () => {
|
|
828
|
+
useEmptyWindowsBrowserRoot()
|
|
829
|
+
|
|
805
830
|
const slackDir = mkdtempSync(join(tmpdir(), 'slack-nonexistent-'))
|
|
806
831
|
tempDirs.push(slackDir)
|
|
807
832
|
rmSync(slackDir, { recursive: true, force: true })
|
|
808
833
|
|
|
809
|
-
const extractor = new TokenExtractor('
|
|
834
|
+
const extractor = new TokenExtractor('win32', slackDir)
|
|
810
835
|
const result = await extractor.extractFromBrowsers()
|
|
811
836
|
expect(result).toEqual([])
|
|
812
837
|
})
|
|
813
838
|
|
|
814
839
|
it('resolves Local State from agent-browser profile root for encrypted cookies', async () => {
|
|
815
840
|
// given
|
|
841
|
+
useEmptyWindowsBrowserRoot()
|
|
842
|
+
|
|
816
843
|
const agentBrowserProfile = mkdtempSync(join(tmpdir(), 'agent-browser-slack-profile-'))
|
|
817
844
|
tempDirs.push(agentBrowserProfile)
|
|
818
845
|
process.env.AGENT_BROWSER_PROFILE = agentBrowserProfile
|
|
@@ -839,7 +866,7 @@ describe('TokenExtractor browser fallback', () => {
|
|
|
839
866
|
|
|
840
867
|
try {
|
|
841
868
|
// when
|
|
842
|
-
const extractor = new TokenExtractor('
|
|
869
|
+
const extractor = new TokenExtractor('win32', join(agentBrowserProfile, 'missing-desktop'))
|
|
843
870
|
const result = await extractor.extractFromBrowsers()
|
|
844
871
|
|
|
845
872
|
// then
|
|
@@ -7,6 +7,7 @@ import pkg from '../../../package.json' with { type: 'json' }
|
|
|
7
7
|
import {
|
|
8
8
|
authCommand,
|
|
9
9
|
channelCommand,
|
|
10
|
+
chatCommand,
|
|
10
11
|
fileCommand,
|
|
11
12
|
messageCommand,
|
|
12
13
|
reactionCommand,
|
|
@@ -49,6 +50,7 @@ program.hook('preAction', async (_thisCommand, actionCommand) => {
|
|
|
49
50
|
program.addCommand(authCommand)
|
|
50
51
|
program.addCommand(teamCommand)
|
|
51
52
|
program.addCommand(channelCommand)
|
|
53
|
+
program.addCommand(chatCommand)
|
|
52
54
|
program.addCommand(fileCommand)
|
|
53
55
|
program.addCommand(messageCommand)
|
|
54
56
|
program.addCommand(reactionCommand)
|
|
@@ -169,6 +169,102 @@ describe('TeamsClient', () => {
|
|
|
169
169
|
})
|
|
170
170
|
})
|
|
171
171
|
|
|
172
|
+
describe('listChats', () => {
|
|
173
|
+
it('classifies chats and excludes teams', async () => {
|
|
174
|
+
mockResponse({
|
|
175
|
+
conversations: [
|
|
176
|
+
{
|
|
177
|
+
id: '19:team@thread.tacv2',
|
|
178
|
+
threadProperties: { groupId: '111', spaceThreadTopic: 'Team One', threadType: 'space' },
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: '48:notes',
|
|
182
|
+
threadProperties: { threadType: 'streamofnotes', productThreadType: 'StreamOfNotes' },
|
|
183
|
+
lastMessage: { content: 'Hi', composetime: '2024-01-03T00:00:00.000Z' },
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: '19:1on1@unq.gbl.spaces',
|
|
187
|
+
lastMessage: { content: '<p>Hi there</p>', composetime: '2024-01-01T00:00:00.000Z' },
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
id: '19:group@thread.tacv2',
|
|
191
|
+
threadProperties: { topic: 'Group Chat', threadType: 'chat' },
|
|
192
|
+
lastMessage: { content: 'Hello group', composetime: '2024-01-02T00:00:00.000Z' },
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
const client = await new TeamsClient().login({ token: 'test-token', accountType: 'personal' })
|
|
198
|
+
const chats = await client.listChats()
|
|
199
|
+
|
|
200
|
+
expect(chats).toHaveLength(3)
|
|
201
|
+
expect(chats[0]).toMatchObject({ id: '48:notes', type: 'self', last_message: 'Hi' })
|
|
202
|
+
expect(chats[1]).toMatchObject({ id: '19:1on1@unq.gbl.spaces', type: 'oneOnOne', last_message: 'Hi there' })
|
|
203
|
+
expect(chats[2]).toMatchObject({ id: '19:group@thread.tacv2', type: 'group', topic: 'Group Chat' })
|
|
204
|
+
expect(fetchCalls[0].url).toBe(
|
|
205
|
+
'https://msgapi.teams.live.com/v1/users/ME/conversations?view=msnp24Equivalent&pageSize=500',
|
|
206
|
+
)
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
describe('getChatMessages', () => {
|
|
211
|
+
it('returns user messages and filters system events', async () => {
|
|
212
|
+
mockResponse({
|
|
213
|
+
messages: [
|
|
214
|
+
{
|
|
215
|
+
id: 'm1',
|
|
216
|
+
content: '<p>Hello</p>',
|
|
217
|
+
from: 'host/users/ME/contacts/8:alice',
|
|
218
|
+
imdisplayname: 'Alice',
|
|
219
|
+
composetime: '2024-01-01T00:00:00.000Z',
|
|
220
|
+
messagetype: 'RichText/Html',
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
id: 'm2',
|
|
224
|
+
content: 'Bob joined',
|
|
225
|
+
imdisplayname: 'System',
|
|
226
|
+
composetime: '2024-01-01T00:01:00.000Z',
|
|
227
|
+
messagetype: 'ThreadActivity/AddMember',
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
const client = await new TeamsClient().login({ token: 'test-token', accountType: 'personal' })
|
|
233
|
+
const messages = await client.getChatMessages('19:1on1@unq.gbl.spaces', 30)
|
|
234
|
+
|
|
235
|
+
expect(messages).toHaveLength(1)
|
|
236
|
+
expect(messages[0].id).toBe('m1')
|
|
237
|
+
expect(messages[0].content).toBe('Hello')
|
|
238
|
+
expect(messages[0].author.displayName).toBe('Alice')
|
|
239
|
+
expect(messages[0].channel_id).toBe('19:1on1@unq.gbl.spaces')
|
|
240
|
+
expect(fetchCalls[0].url).toBe(
|
|
241
|
+
'https://msgapi.teams.live.com/v1/users/ME/conversations/19%3A1on1%40unq.gbl.spaces/messages?startTime=0&view=msnp24Equivalent&pageSize=30',
|
|
242
|
+
)
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
describe('sendChatMessage', () => {
|
|
247
|
+
it('sends an HTML-escaped message to a chat', async () => {
|
|
248
|
+
mockResponse({ OriginalArrivalTime: 1704067200000 })
|
|
249
|
+
|
|
250
|
+
const client = await new TeamsClient().login({ token: 'test-token', accountType: 'personal' })
|
|
251
|
+
const message = await client.sendChatMessage('19:1on1@unq.gbl.spaces', 'a <b> & c')
|
|
252
|
+
|
|
253
|
+
expect(message.content).toBe('a <b> & c')
|
|
254
|
+
expect(fetchCalls[0].url).toBe(
|
|
255
|
+
'https://msgapi.teams.live.com/v1/users/ME/conversations/19%3A1on1%40unq.gbl.spaces/messages',
|
|
256
|
+
)
|
|
257
|
+
expect(fetchCalls[0].options?.method).toBe('POST')
|
|
258
|
+
expect(fetchCalls[0].options?.body).toBe(
|
|
259
|
+
JSON.stringify({
|
|
260
|
+
content: 'a <b> & c',
|
|
261
|
+
messagetype: 'RichText/Html',
|
|
262
|
+
contenttype: 'text',
|
|
263
|
+
}),
|
|
264
|
+
)
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
172
268
|
describe('getTeam', () => {
|
|
173
269
|
it('returns team info', async () => {
|
|
174
270
|
mockResponse({ id: '111', name: 'Test Team', description: 'A test team' })
|
|
@@ -5,6 +5,8 @@ import { TeamsCredentialManager } from './credential-manager'
|
|
|
5
5
|
import type {
|
|
6
6
|
TeamsAccountType,
|
|
7
7
|
TeamsChannel,
|
|
8
|
+
TeamsChat,
|
|
9
|
+
TeamsChatType,
|
|
8
10
|
TeamsFile,
|
|
9
11
|
TeamsMessage,
|
|
10
12
|
TeamsRegion,
|
|
@@ -25,6 +27,41 @@ const BASE_BACKOFF_MS = 100
|
|
|
25
27
|
const DEFAULT_REGION: TeamsRegion = 'amer'
|
|
26
28
|
const REGIONS: TeamsRegion[] = ['amer', 'emea', 'apac']
|
|
27
29
|
|
|
30
|
+
function stripHtml(content: string | undefined): string | undefined {
|
|
31
|
+
if (content === undefined) return undefined
|
|
32
|
+
const stripped = content.replace(/<[^>]*>/g, '')
|
|
33
|
+
return stripped
|
|
34
|
+
.replace(/&/g, '&')
|
|
35
|
+
.replace(/</g, '<')
|
|
36
|
+
.replace(/>/g, '>')
|
|
37
|
+
.replace(/"/g, '"')
|
|
38
|
+
.replace(/'/g, "'")
|
|
39
|
+
.replace(/\s+/g, ' ')
|
|
40
|
+
.trim()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function escapeHtml(value: string): string {
|
|
44
|
+
return value
|
|
45
|
+
.replaceAll('&', '&')
|
|
46
|
+
.replaceAll('<', '<')
|
|
47
|
+
.replaceAll('>', '>')
|
|
48
|
+
.replaceAll('"', '"')
|
|
49
|
+
.replaceAll("'", ''')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// groupId => Teams/channel thread (handled by listTeams). "48:notes"/
|
|
53
|
+
// streamofnotes => the user's self ("to me") chat. Anything else without a
|
|
54
|
+
// non-chat threadType is a normal 1:1 (no topic) or group (has topic) chat.
|
|
55
|
+
function classifyChat(
|
|
56
|
+
id: string,
|
|
57
|
+
tp?: { topic?: string; threadType?: string; groupId?: string },
|
|
58
|
+
): TeamsChatType | null {
|
|
59
|
+
if (tp?.groupId) return null
|
|
60
|
+
if (id === '48:notes' || tp?.threadType === 'streamofnotes') return 'self'
|
|
61
|
+
if (tp?.threadType && tp.threadType !== 'chat') return null
|
|
62
|
+
return tp?.topic ? 'group' : 'oneOnOne'
|
|
63
|
+
}
|
|
64
|
+
|
|
28
65
|
export class TeamsClient {
|
|
29
66
|
private token: string | null = null
|
|
30
67
|
private tokenExpiresAt?: Date
|
|
@@ -324,6 +361,102 @@ export class TeamsClient {
|
|
|
324
361
|
return Array.from(teamsMap.values())
|
|
325
362
|
}
|
|
326
363
|
|
|
364
|
+
async listChats(): Promise<TeamsChat[]> {
|
|
365
|
+
interface ConversationMessage {
|
|
366
|
+
content?: string
|
|
367
|
+
composetime?: string
|
|
368
|
+
originalarrivaltime?: string
|
|
369
|
+
}
|
|
370
|
+
interface Conversation {
|
|
371
|
+
id: string
|
|
372
|
+
threadProperties?: {
|
|
373
|
+
topic?: string
|
|
374
|
+
threadType?: string
|
|
375
|
+
groupId?: string
|
|
376
|
+
}
|
|
377
|
+
lastMessage?: ConversationMessage
|
|
378
|
+
}
|
|
379
|
+
interface ConversationsResponse {
|
|
380
|
+
conversations: Conversation[]
|
|
381
|
+
}
|
|
382
|
+
const data = await this.request<ConversationsResponse>(
|
|
383
|
+
'GET',
|
|
384
|
+
'/users/ME/conversations?view=msnp24Equivalent&pageSize=500',
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
const chats: TeamsChat[] = []
|
|
388
|
+
for (const conv of data.conversations ?? []) {
|
|
389
|
+
const type = classifyChat(conv.id, conv.threadProperties)
|
|
390
|
+
if (!type) continue
|
|
391
|
+
|
|
392
|
+
chats.push({
|
|
393
|
+
id: conv.id,
|
|
394
|
+
type,
|
|
395
|
+
topic: conv.threadProperties?.topic,
|
|
396
|
+
last_message: stripHtml(conv.lastMessage?.content),
|
|
397
|
+
last_message_at: conv.lastMessage?.composetime ?? conv.lastMessage?.originalarrivaltime,
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return chats
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async getChatMessages(chatId: string, limit: number = 50): Promise<TeamsMessage[]> {
|
|
405
|
+
interface ChatMessage {
|
|
406
|
+
id: string
|
|
407
|
+
content?: string
|
|
408
|
+
from?: string
|
|
409
|
+
imdisplayname?: string
|
|
410
|
+
composetime?: string
|
|
411
|
+
originalarrivaltime?: string
|
|
412
|
+
messagetype?: string
|
|
413
|
+
}
|
|
414
|
+
interface MessagesResponse {
|
|
415
|
+
messages: ChatMessage[]
|
|
416
|
+
}
|
|
417
|
+
const encodedChatId = encodeURIComponent(chatId)
|
|
418
|
+
const data = await this.request<MessagesResponse>(
|
|
419
|
+
'GET',
|
|
420
|
+
`/users/ME/conversations/${encodedChatId}/messages?startTime=0&view=msnp24Equivalent&pageSize=${limit}`,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
const userMessageTypes = new Set(['Text', 'RichText/Html', 'RichText/Media_CallRecording'])
|
|
424
|
+
return (data.messages ?? [])
|
|
425
|
+
.filter((msg) => !msg.messagetype || userMessageTypes.has(msg.messagetype))
|
|
426
|
+
.slice(0, limit)
|
|
427
|
+
.map((msg) => ({
|
|
428
|
+
id: msg.id,
|
|
429
|
+
channel_id: chatId,
|
|
430
|
+
author: {
|
|
431
|
+
id: msg.from ?? '',
|
|
432
|
+
displayName: msg.imdisplayname ?? 'Unknown',
|
|
433
|
+
},
|
|
434
|
+
content: stripHtml(msg.content) ?? '',
|
|
435
|
+
timestamp: msg.composetime ?? msg.originalarrivaltime ?? '',
|
|
436
|
+
}))
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async sendChatMessage(chatId: string, content: string): Promise<TeamsMessage> {
|
|
440
|
+
interface SendResponse {
|
|
441
|
+
OriginalArrivalTime?: number
|
|
442
|
+
}
|
|
443
|
+
const encodedChatId = encodeURIComponent(chatId)
|
|
444
|
+
const response = await this.request<SendResponse>('POST', `/users/ME/conversations/${encodedChatId}/messages`, {
|
|
445
|
+
content: escapeHtml(content),
|
|
446
|
+
messagetype: 'RichText/Html',
|
|
447
|
+
contenttype: 'text',
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
const arrivalTime = response?.OriginalArrivalTime
|
|
451
|
+
return {
|
|
452
|
+
id: arrivalTime ? String(arrivalTime) : '',
|
|
453
|
+
channel_id: chatId,
|
|
454
|
+
author: { id: 'ME', displayName: 'Me' },
|
|
455
|
+
content,
|
|
456
|
+
timestamp: arrivalTime ? new Date(arrivalTime).toISOString() : new Date().toISOString(),
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
327
460
|
async getTeam(teamId: string): Promise<TeamsTeam> {
|
|
328
461
|
return this.request<TeamsTeam>('GET', `/csa/api/v1/teams/${teamId}`, undefined, CSA_API_BASE)
|
|
329
462
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, mock, spyOn, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { TeamsClient } from '../client'
|
|
4
|
+
import { TeamsCredentialManager } from '../credential-manager'
|
|
5
|
+
import { historyAction, listAction, sendAction } from './chat'
|
|
6
|
+
|
|
7
|
+
let clientListChatsSpy: ReturnType<typeof spyOn>
|
|
8
|
+
let clientGetChatMessagesSpy: ReturnType<typeof spyOn>
|
|
9
|
+
let clientSendChatMessageSpy: ReturnType<typeof spyOn>
|
|
10
|
+
let credManagerLoadSpy: ReturnType<typeof spyOn>
|
|
11
|
+
const originalConsoleLog = console.log
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
clientListChatsSpy = spyOn(TeamsClient.prototype, 'listChats').mockResolvedValue([
|
|
15
|
+
{ id: '19:1on1@unq.gbl.spaces', type: 'oneOnOne', last_message: 'Hi', last_message_at: '2025-01-29T10:00:00Z' },
|
|
16
|
+
{ id: '19:group@thread.tacv2', type: 'group', topic: 'Group Chat' },
|
|
17
|
+
])
|
|
18
|
+
|
|
19
|
+
clientGetChatMessagesSpy = spyOn(TeamsClient.prototype, 'getChatMessages').mockResolvedValue([
|
|
20
|
+
{
|
|
21
|
+
id: 'msg_123',
|
|
22
|
+
channel_id: '19:1on1@unq.gbl.spaces',
|
|
23
|
+
author: { id: 'user_789', displayName: 'Alice' },
|
|
24
|
+
content: 'Hello world',
|
|
25
|
+
timestamp: '2025-01-29T10:00:00Z',
|
|
26
|
+
},
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
clientSendChatMessageSpy = spyOn(TeamsClient.prototype, 'sendChatMessage').mockResolvedValue({
|
|
30
|
+
id: '1704067200000',
|
|
31
|
+
channel_id: '19:1on1@unq.gbl.spaces',
|
|
32
|
+
author: { id: 'ME', displayName: 'Me' },
|
|
33
|
+
content: 'Hello world',
|
|
34
|
+
timestamp: '2025-01-29T10:00:00Z',
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
credManagerLoadSpy = spyOn(TeamsCredentialManager.prototype, 'loadConfig').mockResolvedValue({
|
|
38
|
+
current_account: 'personal',
|
|
39
|
+
accounts: {
|
|
40
|
+
personal: {
|
|
41
|
+
token: 'test_token',
|
|
42
|
+
account_type: 'personal' as const,
|
|
43
|
+
current_team: null,
|
|
44
|
+
teams: {},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
clientListChatsSpy?.mockRestore()
|
|
52
|
+
clientGetChatMessagesSpy?.mockRestore()
|
|
53
|
+
clientSendChatMessageSpy?.mockRestore()
|
|
54
|
+
credManagerLoadSpy?.mockRestore()
|
|
55
|
+
console.log = originalConsoleLog
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('list: returns array of chats', async () => {
|
|
59
|
+
const consoleSpy = mock((_msg: string) => {})
|
|
60
|
+
console.log = consoleSpy
|
|
61
|
+
|
|
62
|
+
await listAction({ pretty: false })
|
|
63
|
+
|
|
64
|
+
expect(consoleSpy).toHaveBeenCalled()
|
|
65
|
+
const output = consoleSpy.mock.calls[0][0]
|
|
66
|
+
expect(output).toContain('19:1on1@unq.gbl.spaces')
|
|
67
|
+
expect(output).toContain('19:group@thread.tacv2')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('history: returns array of messages', async () => {
|
|
71
|
+
const consoleSpy = mock((_msg: string) => {})
|
|
72
|
+
console.log = consoleSpy
|
|
73
|
+
|
|
74
|
+
await historyAction('19:1on1@unq.gbl.spaces', { limit: 50, pretty: false })
|
|
75
|
+
|
|
76
|
+
expect(consoleSpy).toHaveBeenCalled()
|
|
77
|
+
const output = consoleSpy.mock.calls[0][0]
|
|
78
|
+
expect(output).toContain('msg_123')
|
|
79
|
+
expect(output).toContain('Alice')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('history: falls back to default limit when given a non-positive value', async () => {
|
|
83
|
+
const consoleSpy = mock((_msg: string) => {})
|
|
84
|
+
console.log = consoleSpy
|
|
85
|
+
|
|
86
|
+
await historyAction('19:1on1@unq.gbl.spaces', { limit: -5, pretty: false })
|
|
87
|
+
|
|
88
|
+
expect(clientGetChatMessagesSpy).toHaveBeenCalledWith('19:1on1@unq.gbl.spaces', 50)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('send: returns sent message', async () => {
|
|
92
|
+
const consoleSpy = mock((_msg: string) => {})
|
|
93
|
+
console.log = consoleSpy
|
|
94
|
+
|
|
95
|
+
await sendAction('19:1on1@unq.gbl.spaces', 'Hello world', { pretty: false })
|
|
96
|
+
|
|
97
|
+
expect(consoleSpy).toHaveBeenCalled()
|
|
98
|
+
const output = consoleSpy.mock.calls[0][0]
|
|
99
|
+
expect(output).toContain('Hello world')
|
|
100
|
+
})
|