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.
Files changed (88) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +5 -0
  3. package/dist/package.json +1 -1
  4. package/dist/src/platforms/line/client.d.ts +10 -1
  5. package/dist/src/platforms/line/client.d.ts.map +1 -1
  6. package/dist/src/platforms/line/client.js +156 -11
  7. package/dist/src/platforms/line/client.js.map +1 -1
  8. package/dist/src/platforms/line/e2ee-storage.d.ts +16 -0
  9. package/dist/src/platforms/line/e2ee-storage.d.ts.map +1 -0
  10. package/dist/src/platforms/line/e2ee-storage.js +93 -0
  11. package/dist/src/platforms/line/e2ee-storage.js.map +1 -0
  12. package/dist/src/platforms/line/index.d.ts +1 -1
  13. package/dist/src/platforms/line/index.d.ts.map +1 -1
  14. package/dist/src/platforms/line/index.js.map +1 -1
  15. package/dist/src/platforms/line/listener.d.ts.map +1 -1
  16. package/dist/src/platforms/line/listener.js +3 -2
  17. package/dist/src/platforms/line/listener.js.map +1 -1
  18. package/dist/src/platforms/line/types.d.ts +13 -0
  19. package/dist/src/platforms/line/types.d.ts.map +1 -1
  20. package/dist/src/platforms/line/types.js +6 -0
  21. package/dist/src/platforms/line/types.js.map +1 -1
  22. package/dist/src/platforms/teams/cli.d.ts.map +1 -1
  23. package/dist/src/platforms/teams/cli.js +2 -1
  24. package/dist/src/platforms/teams/cli.js.map +1 -1
  25. package/dist/src/platforms/teams/client.d.ts +4 -1
  26. package/dist/src/platforms/teams/client.d.ts.map +1 -1
  27. package/dist/src/platforms/teams/client.js +84 -0
  28. package/dist/src/platforms/teams/client.js.map +1 -1
  29. package/dist/src/platforms/teams/commands/chat.d.ts +13 -0
  30. package/dist/src/platforms/teams/commands/chat.d.ts.map +1 -0
  31. package/dist/src/platforms/teams/commands/chat.js +111 -0
  32. package/dist/src/platforms/teams/commands/chat.js.map +1 -0
  33. package/dist/src/platforms/teams/commands/index.d.ts +1 -0
  34. package/dist/src/platforms/teams/commands/index.d.ts.map +1 -1
  35. package/dist/src/platforms/teams/commands/index.js +1 -0
  36. package/dist/src/platforms/teams/commands/index.js.map +1 -1
  37. package/dist/src/platforms/teams/types.d.ts +24 -0
  38. package/dist/src/platforms/teams/types.d.ts.map +1 -1
  39. package/dist/src/platforms/teams/types.js +8 -0
  40. package/dist/src/platforms/teams/types.js.map +1 -1
  41. package/dist/src/tui/adapters/line-adapter.js +1 -1
  42. package/dist/src/tui/adapters/line-adapter.js.map +1 -1
  43. package/dist/src/vendor/linejs/_dist/client/login.d.ts +2 -1
  44. package/dist/src/vendor/linejs/client/login.js +3 -2
  45. package/dist/src/vendor/linejs/client/login.test.ts +11 -0
  46. package/docs/content/docs/cli/line.mdx +13 -11
  47. package/package.json +1 -1
  48. package/skills/agent-channeltalk/SKILL.md +1 -1
  49. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  50. package/skills/agent-discord/SKILL.md +1 -1
  51. package/skills/agent-discordbot/SKILL.md +1 -1
  52. package/skills/agent-instagram/SKILL.md +1 -1
  53. package/skills/agent-kakaotalk/SKILL.md +1 -1
  54. package/skills/agent-line/SKILL.md +7 -5
  55. package/skills/agent-line/references/common-patterns.md +12 -3
  56. package/skills/agent-slack/SKILL.md +1 -1
  57. package/skills/agent-slackbot/SKILL.md +1 -1
  58. package/skills/agent-teams/SKILL.md +20 -2
  59. package/skills/agent-teams/references/common-patterns.md +28 -0
  60. package/skills/agent-telegram/SKILL.md +1 -1
  61. package/skills/agent-telegrambot/SKILL.md +1 -1
  62. package/skills/agent-webex/SKILL.md +1 -1
  63. package/skills/agent-wechatbot/SKILL.md +1 -1
  64. package/skills/agent-whatsapp/SKILL.md +1 -1
  65. package/skills/agent-whatsappbot/SKILL.md +1 -1
  66. package/src/platforms/line/client.test.ts +190 -2
  67. package/src/platforms/line/client.ts +183 -13
  68. package/src/platforms/line/e2ee-storage.test.ts +154 -0
  69. package/src/platforms/line/e2ee-storage.ts +119 -0
  70. package/src/platforms/line/index.test.ts +10 -0
  71. package/src/platforms/line/index.ts +1 -0
  72. package/src/platforms/line/listener.test.ts +32 -0
  73. package/src/platforms/line/listener.ts +5 -4
  74. package/src/platforms/line/types.test.ts +17 -0
  75. package/src/platforms/line/types.ts +13 -0
  76. package/src/platforms/slack/commands/auth.test.ts +16 -6
  77. package/src/platforms/slack/token-extractor.test.ts +34 -7
  78. package/src/platforms/teams/cli.ts +2 -0
  79. package/src/platforms/teams/client.test.ts +96 -0
  80. package/src/platforms/teams/client.ts +133 -0
  81. package/src/platforms/teams/commands/chat.test.ts +100 -0
  82. package/src/platforms/teams/commands/chat.ts +131 -0
  83. package/src/platforms/teams/commands/index.ts +1 -0
  84. package/src/platforms/teams/types.ts +20 -0
  85. package/src/tui/adapters/line-adapter.ts +1 -1
  86. package/src/vendor/linejs/_dist/client/login.d.ts +2 -1
  87. package/src/vendor/linejs/client/login.js +3 -2
  88. 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.extract()
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.extract()
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.extract()
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.extract()
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.extract()
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.extract()
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.extract()
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.extract()
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.extract()
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.extract()
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('darwin', slackDir)
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('darwin', join(agentBrowserProfile, 'missing-desktop'))
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 &lt;b&gt; &amp; 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(/&amp;/g, '&')
35
+ .replace(/&lt;/g, '<')
36
+ .replace(/&gt;/g, '>')
37
+ .replace(/&quot;/g, '"')
38
+ .replace(/&#39;/g, "'")
39
+ .replace(/\s+/g, ' ')
40
+ .trim()
41
+ }
42
+
43
+ function escapeHtml(value: string): string {
44
+ return value
45
+ .replaceAll('&', '&amp;')
46
+ .replaceAll('<', '&lt;')
47
+ .replaceAll('>', '&gt;')
48
+ .replaceAll('"', '&quot;')
49
+ .replaceAll("'", '&#39;')
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
+ })