agent-messenger 2.28.0 → 2.29.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 (159) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +29 -1
  3. package/dist/package.json +1 -1
  4. package/dist/src/platforms/discord/client.d.ts +6 -2
  5. package/dist/src/platforms/discord/client.d.ts.map +1 -1
  6. package/dist/src/platforms/discord/client.js +10 -3
  7. package/dist/src/platforms/discord/client.js.map +1 -1
  8. package/dist/src/platforms/instagram/client.d.ts +24 -2
  9. package/dist/src/platforms/instagram/client.d.ts.map +1 -1
  10. package/dist/src/platforms/instagram/client.js +343 -53
  11. package/dist/src/platforms/instagram/client.js.map +1 -1
  12. package/dist/src/platforms/instagram/commands/auth.d.ts +1 -0
  13. package/dist/src/platforms/instagram/commands/auth.d.ts.map +1 -1
  14. package/dist/src/platforms/instagram/commands/auth.js +123 -11
  15. package/dist/src/platforms/instagram/commands/auth.js.map +1 -1
  16. package/dist/src/platforms/instagram/credential-manager.d.ts +4 -1
  17. package/dist/src/platforms/instagram/credential-manager.d.ts.map +1 -1
  18. package/dist/src/platforms/instagram/credential-manager.js +17 -1
  19. package/dist/src/platforms/instagram/credential-manager.js.map +1 -1
  20. package/dist/src/platforms/instagram/types.d.ts +10 -8
  21. package/dist/src/platforms/instagram/types.d.ts.map +1 -1
  22. package/dist/src/platforms/instagram/types.js.map +1 -1
  23. package/dist/src/platforms/teams/app-config.d.ts +36 -0
  24. package/dist/src/platforms/teams/app-config.d.ts.map +1 -0
  25. package/dist/src/platforms/teams/app-config.js +69 -0
  26. package/dist/src/platforms/teams/app-config.js.map +1 -0
  27. package/dist/src/platforms/teams/client.d.ts +20 -2
  28. package/dist/src/platforms/teams/client.d.ts.map +1 -1
  29. package/dist/src/platforms/teams/client.js +311 -7
  30. package/dist/src/platforms/teams/client.js.map +1 -1
  31. package/dist/src/platforms/teams/commands/auth.d.ts +10 -0
  32. package/dist/src/platforms/teams/commands/auth.d.ts.map +1 -1
  33. package/dist/src/platforms/teams/commands/auth.js +137 -3
  34. package/dist/src/platforms/teams/commands/auth.js.map +1 -1
  35. package/dist/src/platforms/teams/commands/file.d.ts +3 -0
  36. package/dist/src/platforms/teams/commands/file.d.ts.map +1 -1
  37. package/dist/src/platforms/teams/commands/file.js +59 -2
  38. package/dist/src/platforms/teams/commands/file.js.map +1 -1
  39. package/dist/src/platforms/teams/commands/message.d.ts +11 -0
  40. package/dist/src/platforms/teams/commands/message.d.ts.map +1 -1
  41. package/dist/src/platforms/teams/commands/message.js +121 -2
  42. package/dist/src/platforms/teams/commands/message.js.map +1 -1
  43. package/dist/src/platforms/teams/credential-manager.d.ts +23 -1
  44. package/dist/src/platforms/teams/credential-manager.d.ts.map +1 -1
  45. package/dist/src/platforms/teams/credential-manager.js +46 -2
  46. package/dist/src/platforms/teams/credential-manager.js.map +1 -1
  47. package/dist/src/platforms/teams/device-code.d.ts +48 -0
  48. package/dist/src/platforms/teams/device-code.d.ts.map +1 -0
  49. package/dist/src/platforms/teams/device-code.js +199 -0
  50. package/dist/src/platforms/teams/device-code.js.map +1 -0
  51. package/dist/src/platforms/teams/device-login.d.ts +37 -0
  52. package/dist/src/platforms/teams/device-login.d.ts.map +1 -0
  53. package/dist/src/platforms/teams/device-login.js +129 -0
  54. package/dist/src/platforms/teams/device-login.js.map +1 -0
  55. package/dist/src/platforms/teams/ensure-auth.d.ts.map +1 -1
  56. package/dist/src/platforms/teams/ensure-auth.js +12 -0
  57. package/dist/src/platforms/teams/ensure-auth.js.map +1 -1
  58. package/dist/src/platforms/teams/index.d.ts +7 -3
  59. package/dist/src/platforms/teams/index.d.ts.map +1 -1
  60. package/dist/src/platforms/teams/index.js +5 -2
  61. package/dist/src/platforms/teams/index.js.map +1 -1
  62. package/dist/src/platforms/teams/listener.d.ts +40 -0
  63. package/dist/src/platforms/teams/listener.d.ts.map +1 -0
  64. package/dist/src/platforms/teams/listener.js +286 -0
  65. package/dist/src/platforms/teams/listener.js.map +1 -0
  66. package/dist/src/platforms/teams/realm-discovery.d.ts +3 -0
  67. package/dist/src/platforms/teams/realm-discovery.d.ts.map +1 -0
  68. package/dist/src/platforms/teams/realm-discovery.js +33 -0
  69. package/dist/src/platforms/teams/realm-discovery.js.map +1 -0
  70. package/dist/src/platforms/teams/token-extractor.d.ts +3 -0
  71. package/dist/src/platforms/teams/token-extractor.d.ts.map +1 -1
  72. package/dist/src/platforms/teams/token-extractor.js +60 -0
  73. package/dist/src/platforms/teams/token-extractor.js.map +1 -1
  74. package/dist/src/platforms/teams/token-provider.d.ts +25 -0
  75. package/dist/src/platforms/teams/token-provider.d.ts.map +1 -0
  76. package/dist/src/platforms/teams/token-provider.js +190 -0
  77. package/dist/src/platforms/teams/token-provider.js.map +1 -0
  78. package/dist/src/platforms/teams/trouter.d.ts +38 -0
  79. package/dist/src/platforms/teams/trouter.d.ts.map +1 -0
  80. package/dist/src/platforms/teams/trouter.js +266 -0
  81. package/dist/src/platforms/teams/trouter.js.map +1 -0
  82. package/dist/src/platforms/teams/types.d.ts +103 -0
  83. package/dist/src/platforms/teams/types.d.ts.map +1 -1
  84. package/dist/src/platforms/teams/types.js +30 -0
  85. package/dist/src/platforms/teams/types.js.map +1 -1
  86. package/dist/src/platforms/webex/types.d.ts +1 -1
  87. package/dist/src/tui/adapters/instagram-adapter.d.ts.map +1 -1
  88. package/dist/src/tui/adapters/instagram-adapter.js +16 -0
  89. package/dist/src/tui/adapters/instagram-adapter.js.map +1 -1
  90. package/dist/src/tui/adapters/teams-adapter.d.ts +3 -0
  91. package/dist/src/tui/adapters/teams-adapter.d.ts.map +1 -1
  92. package/dist/src/tui/adapters/teams-adapter.js +21 -0
  93. package/dist/src/tui/adapters/teams-adapter.js.map +1 -1
  94. package/dist/src/tui/app.js +3 -3
  95. package/docs/content/docs/cli/instagram.mdx +1 -0
  96. package/docs/content/docs/cli/teams.mdx +36 -0
  97. package/docs/content/docs/sdk/teams.mdx +30 -0
  98. package/package.json +1 -1
  99. package/skills/agent-channeltalk/SKILL.md +1 -1
  100. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  101. package/skills/agent-discord/SKILL.md +1 -1
  102. package/skills/agent-discordbot/SKILL.md +1 -1
  103. package/skills/agent-imessage/SKILL.md +1 -1
  104. package/skills/agent-instagram/SKILL.md +1 -1
  105. package/skills/agent-instagram/references/authentication.md +11 -0
  106. package/skills/agent-kakaotalk/SKILL.md +1 -1
  107. package/skills/agent-line/SKILL.md +1 -1
  108. package/skills/agent-slack/SKILL.md +1 -1
  109. package/skills/agent-slackbot/SKILL.md +1 -1
  110. package/skills/agent-teams/SKILL.md +82 -6
  111. package/skills/agent-teams/references/authentication.md +31 -1
  112. package/skills/agent-telegram/SKILL.md +1 -1
  113. package/skills/agent-telegrambot/SKILL.md +1 -1
  114. package/skills/agent-webex/SKILL.md +1 -1
  115. package/skills/agent-webexbot/SKILL.md +1 -1
  116. package/skills/agent-wechatbot/SKILL.md +1 -1
  117. package/skills/agent-whatsapp/SKILL.md +1 -1
  118. package/skills/agent-whatsappbot/SKILL.md +1 -1
  119. package/src/platforms/discord/client.test.ts +86 -0
  120. package/src/platforms/discord/client.ts +10 -3
  121. package/src/platforms/instagram/client.test.ts +478 -33
  122. package/src/platforms/instagram/client.ts +449 -68
  123. package/src/platforms/instagram/commands/auth.test.ts +177 -1
  124. package/src/platforms/instagram/commands/auth.ts +182 -11
  125. package/src/platforms/instagram/credential-manager.test.ts +30 -0
  126. package/src/platforms/instagram/credential-manager.ts +23 -1
  127. package/src/platforms/instagram/types.ts +11 -8
  128. package/src/platforms/teams/app-config.ts +92 -0
  129. package/src/platforms/teams/client.test.ts +335 -1
  130. package/src/platforms/teams/client.ts +370 -7
  131. package/src/platforms/teams/commands/auth.test.ts +93 -1
  132. package/src/platforms/teams/commands/auth.ts +182 -3
  133. package/src/platforms/teams/commands/file.test.ts +92 -1
  134. package/src/platforms/teams/commands/file.ts +76 -1
  135. package/src/platforms/teams/commands/message.test.ts +95 -1
  136. package/src/platforms/teams/commands/message.ts +145 -3
  137. package/src/platforms/teams/credential-manager.test.ts +20 -0
  138. package/src/platforms/teams/credential-manager.ts +68 -3
  139. package/src/platforms/teams/device-code.test.ts +311 -0
  140. package/src/platforms/teams/device-code.ts +277 -0
  141. package/src/platforms/teams/device-login.test.ts +288 -0
  142. package/src/platforms/teams/device-login.ts +206 -0
  143. package/src/platforms/teams/ensure-auth.ts +11 -0
  144. package/src/platforms/teams/index.test.ts +10 -0
  145. package/src/platforms/teams/index.ts +17 -1
  146. package/src/platforms/teams/listener.test.ts +243 -0
  147. package/src/platforms/teams/listener.ts +351 -0
  148. package/src/platforms/teams/realm-discovery.test.ts +73 -0
  149. package/src/platforms/teams/realm-discovery.ts +42 -0
  150. package/src/platforms/teams/token-extractor.ts +69 -0
  151. package/src/platforms/teams/token-provider.test.ts +227 -0
  152. package/src/platforms/teams/token-provider.ts +284 -0
  153. package/src/platforms/teams/trouter.test.ts +174 -0
  154. package/src/platforms/teams/trouter.ts +323 -0
  155. package/src/platforms/teams/types.test.ts +17 -0
  156. package/src/platforms/teams/types.ts +96 -0
  157. package/src/tui/adapters/instagram-adapter.ts +13 -0
  158. package/src/tui/adapters/teams-adapter.ts +23 -0
  159. package/src/tui/app.ts +3 -3
@@ -0,0 +1,206 @@
1
+ import { getTeamsAppClientId } from './app-config'
2
+ import { TeamsClient } from './client'
3
+ import { TeamsCredentialManager } from './credential-manager'
4
+ import {
5
+ type AadToken,
6
+ type DeviceCodeInfo,
7
+ decodeJwtTid,
8
+ exchangeDeviceCode,
9
+ exchangeForSkypeScope,
10
+ isConsumerTenant,
11
+ mintSkypeToken,
12
+ pollDeviceToken,
13
+ requestDeviceCode,
14
+ resolveWorkTenantId,
15
+ } from './device-code'
16
+ import { type TeamsAccountType, TeamsError } from './types'
17
+
18
+ const PERSONAL_ACCOUNT_HINT =
19
+ 'This looks like a personal Microsoft account. Run `agent-teams auth login --account-type personal` to sign in.'
20
+
21
+ export interface DeviceCodePrompt {
22
+ verificationUri: string
23
+ verificationUriComplete: string
24
+ userCode: string
25
+ expiresAt: number
26
+ }
27
+
28
+ export interface DeviceLoginResult {
29
+ accountType: TeamsAccountType
30
+ userName: string
31
+ teams: Array<{ id: string; name: string }>
32
+ current: string | null
33
+ }
34
+
35
+ interface LoginCallbacks {
36
+ onCode: (prompt: DeviceCodePrompt) => void | Promise<void>
37
+ onPending?: () => void
38
+ debug?: (message: string) => void
39
+ clientId?: string
40
+ accountType?: TeamsAccountType
41
+ }
42
+
43
+ export async function startDeviceCode(
44
+ clientIdOverride?: string,
45
+ accountType: TeamsAccountType = 'personal',
46
+ ): Promise<{ info: DeviceCodeInfo; clientId: string }> {
47
+ const clientId = clientIdOverride ?? getTeamsAppClientId(accountType).clientId
48
+ const info = await requestDeviceCode(clientId, accountType)
49
+ return { info, clientId }
50
+ }
51
+
52
+ export async function completeDeviceCode(
53
+ deviceCode: string,
54
+ clientId: string,
55
+ credManager: TeamsCredentialManager = new TeamsCredentialManager(),
56
+ accountType: TeamsAccountType = 'personal',
57
+ ): Promise<DeviceLoginResult> {
58
+ const first = await exchangeDeviceCode(deviceCode, clientId, accountType)
59
+ if (first.status === 'pending' || first.status === 'slow_down') {
60
+ throw new PendingApprovalError()
61
+ }
62
+ if (first.status !== 'success') {
63
+ throw new Error(
64
+ first.status === 'expired'
65
+ ? 'Device code expired.'
66
+ : first.status === 'declined'
67
+ ? 'Authorization declined.'
68
+ : `Login failed: ${first.message}`,
69
+ )
70
+ }
71
+ return finalize(first.token, clientId, accountType, credManager)
72
+ }
73
+
74
+ export async function loginWithDeviceCode(
75
+ callbacks: LoginCallbacks,
76
+ credManager: TeamsCredentialManager = new TeamsCredentialManager(),
77
+ ): Promise<DeviceLoginResult> {
78
+ const accountType = callbacks.accountType ?? 'personal'
79
+ const { info, clientId } = await startDeviceCode(callbacks.clientId, accountType)
80
+ await callbacks.onCode({
81
+ verificationUri: info.verificationUri,
82
+ verificationUriComplete: info.verificationUriComplete,
83
+ userCode: info.userCode,
84
+ expiresAt: Date.now() + info.expiresIn * 1000,
85
+ })
86
+
87
+ const aad = await pollDeviceToken(info.deviceCode, info.interval, info.expiresIn, clientId, accountType)
88
+
89
+ // A work login can resolve to a personal (MSA) account, whose token carries a
90
+ // consumer tenant. Such accounts have no org tenant and can't be finished on
91
+ // the work flow, so stop with an actionable hint rather than fail obscurely.
92
+ if (accountType === 'work' && isPersonalAccount(aad)) {
93
+ throw new TeamsError(PERSONAL_ACCOUNT_HINT, 'teams_personal_account_on_work_login')
94
+ }
95
+
96
+ callbacks.onPending?.()
97
+ return finalize(aad, clientId, accountType, credManager, callbacks.debug)
98
+ }
99
+
100
+ function isPersonalAccount(aad: AadToken): boolean {
101
+ const tid = decodeJwtTid(aad.accessToken)
102
+ return tid !== undefined && isConsumerTenant(tid)
103
+ }
104
+
105
+ async function finalize(
106
+ initialAad: AadToken,
107
+ clientId: string,
108
+ accountType: TeamsAccountType,
109
+ credManager: TeamsCredentialManager,
110
+ debug?: (message: string) => void,
111
+ ): Promise<DeviceLoginResult> {
112
+ if (!initialAad.refreshToken) {
113
+ throw new Error(
114
+ 'Sign-in did not return a refresh token (needed to mint the Teams token). Retry `auth login`, or use `auth extract` if this persists.',
115
+ )
116
+ }
117
+
118
+ let tenantId: string | undefined
119
+ if (accountType === 'work') {
120
+ debug?.('Resolving Teams tenant...')
121
+ tenantId = await resolveWorkTenantId(initialAad.accessToken)
122
+ }
123
+
124
+ debug?.('Exchanging token for skype audience...')
125
+ const skypeScoped = await exchangeForSkypeScope(initialAad.refreshToken, clientId, accountType, tenantId)
126
+
127
+ debug?.('Minting skype token...')
128
+ const minted = await mintSkypeToken(skypeScoped.accessToken, accountType)
129
+
130
+ const client = await new TeamsClient().login({ token: minted.skypeToken, accountType })
131
+ const authInfo = await client.testAuth()
132
+ const teams = await client.listTeams()
133
+
134
+ const teamMap: Record<string, { team_id: string; team_name: string }> = {}
135
+ for (const team of teams) {
136
+ teamMap[team.id] = { team_id: team.id, team_name: team.name }
137
+ }
138
+ const currentTeam = teams[0]?.id ?? null
139
+
140
+ await credManager.setDeviceCodeAccount({
141
+ accountType,
142
+ token: minted.skypeToken,
143
+ tokenExpiresAt: minted.skypeTokenExpiresAt,
144
+ aadRefreshToken: skypeScoped.refreshToken,
145
+ aadClientId: clientId,
146
+ aadTenantId: tenantId,
147
+ region: minted.region ?? (accountType === 'work' ? client.getRegion() : undefined),
148
+ userName: authInfo.displayName,
149
+ teams: teamMap,
150
+ currentTeam,
151
+ authMethod: 'device-code',
152
+ })
153
+
154
+ return {
155
+ accountType,
156
+ userName: authInfo.displayName,
157
+ teams: teams.map((t) => ({ id: t.id, name: t.name })),
158
+ current: currentTeam,
159
+ }
160
+ }
161
+
162
+ export async function refreshDeviceCodeAccount(
163
+ accountType: TeamsAccountType,
164
+ credManager: TeamsCredentialManager = new TeamsCredentialManager(),
165
+ debug?: (message: string) => void,
166
+ ): Promise<boolean> {
167
+ const config = await credManager.loadConfig()
168
+ const account = config?.accounts[accountType]
169
+ if (!account || account.auth_method !== 'device-code' || !account.aad_refresh_token) {
170
+ return false
171
+ }
172
+ const clientId = account.aad_client_id ?? getTeamsAppClientId(accountType).clientId
173
+
174
+ try {
175
+ debug?.('Silently refreshing skype token...')
176
+ const tenantId = accountType === 'work' ? account.aad_tenant_id : undefined
177
+ const skypeScoped = await exchangeForSkypeScope(account.aad_refresh_token, clientId, accountType, tenantId)
178
+ const minted = await mintSkypeToken(skypeScoped.accessToken, accountType)
179
+
180
+ await credManager.setDeviceCodeAccount({
181
+ accountType,
182
+ token: minted.skypeToken,
183
+ tokenExpiresAt: minted.skypeTokenExpiresAt,
184
+ aadRefreshToken: skypeScoped.refreshToken,
185
+ aadClientId: clientId,
186
+ aadTenantId: tenantId,
187
+ region: minted.region ?? account.region,
188
+ userName: account.user_name,
189
+ teams: account.teams,
190
+ currentTeam: account.current_team,
191
+ authMethod: 'device-code',
192
+ makeCurrent: false,
193
+ })
194
+ return true
195
+ } catch (error) {
196
+ debug?.(`Silent refresh failed: ${(error as Error).message}`)
197
+ return false
198
+ }
199
+ }
200
+
201
+ export class PendingApprovalError extends Error {
202
+ constructor() {
203
+ super('Authorization pending. Approve in the browser, then retry.')
204
+ this.name = 'PendingApprovalError'
205
+ }
206
+ }
@@ -2,6 +2,7 @@ import { warn } from '@/shared/utils/stderr'
2
2
 
3
3
  import { TeamsClient } from './client'
4
4
  import { TeamsCredentialManager } from './credential-manager'
5
+ import { refreshDeviceCodeAccount } from './device-login'
5
6
  import { TeamsTokenExtractor } from './token-extractor'
6
7
  import type { TeamsAccount, TeamsAccountType, TeamsConfig } from './types'
7
8
 
@@ -12,6 +13,8 @@ export async function ensureTeamsAuth(): Promise<void> {
12
13
 
13
14
  if (config && hasValidToken(config)) return
14
15
 
16
+ if (config && (await trySilentRefresh(config, credManager))) return
17
+
15
18
  const extractor = new TeamsTokenExtractor()
16
19
  const extracted = await extractor.extract()
17
20
  if (extracted.length === 0) return
@@ -92,6 +95,14 @@ async function resolveAccountType(
92
95
  throw lastError ?? new Error('Token validation failed')
93
96
  }
94
97
 
98
+ async function trySilentRefresh(config: TeamsConfig, credManager: TeamsCredentialManager): Promise<boolean> {
99
+ const key = TeamsCredentialManager.accountOverride ?? config.current_account
100
+ if (!key) return false
101
+ const account = config.accounts[key]
102
+ if (account?.auth_method !== 'device-code' || !account.aad_refresh_token) return false
103
+ return refreshDeviceCodeAccount(key as TeamsAccountType, credManager)
104
+ }
105
+
95
106
  function hasValidToken(config: TeamsConfig): boolean {
96
107
  const key = TeamsCredentialManager.accountOverride ?? config.current_account
97
108
  if (!key) return false
@@ -13,7 +13,9 @@ import {
13
13
  TeamsFileSchema,
14
14
  TeamsMessageSchema,
15
15
  TeamsReactionSchema,
16
+ TeamsSearchResultSchema,
16
17
  TeamsTeamSchema,
18
+ TeamsTokenProvider,
17
19
  TeamsUserSchema,
18
20
  } from '@/platforms/teams/index'
19
21
 
@@ -29,6 +31,10 @@ it('TeamsCredentialManager is exported from barrel', () => {
29
31
  expect(typeof TeamsCredentialManager).toBe('function')
30
32
  })
31
33
 
34
+ it('TeamsTokenProvider is exported from barrel', () => {
35
+ expect(typeof TeamsTokenProvider).toBe('function')
36
+ })
37
+
32
38
  it('TeamsTeamSchema is exported from barrel', () => {
33
39
  expect(typeof TeamsTeamSchema.parse).toBe('function')
34
40
  })
@@ -41,6 +47,10 @@ it('TeamsMessageSchema is exported from barrel', () => {
41
47
  expect(typeof TeamsMessageSchema.parse).toBe('function')
42
48
  })
43
49
 
50
+ it('TeamsSearchResultSchema is exported from barrel', () => {
51
+ expect(typeof TeamsSearchResultSchema.parse).toBe('function')
52
+ })
53
+
44
54
  it('TeamsUserSchema is exported from barrel', () => {
45
55
  expect(typeof TeamsUserSchema.parse).toBe('function')
46
56
  })
@@ -1,6 +1,16 @@
1
1
  export { TeamsClient } from './client'
2
2
  export { TeamsCredentialManager } from './credential-manager'
3
- export { TeamsError } from './types'
3
+ export { TeamsTokenProvider } from './token-provider'
4
+ export {
5
+ completeDeviceCode,
6
+ loginWithDeviceCode,
7
+ PendingApprovalError,
8
+ refreshDeviceCodeAccount,
9
+ startDeviceCode,
10
+ } from './device-login'
11
+ export type { DeviceCodePrompt, DeviceLoginResult } from './device-login'
12
+ export { TeamsListener } from './listener'
13
+ export { TeamsAuthCapabilityError, TeamsError } from './types'
4
14
  export type {
5
15
  TeamsAccount,
6
16
  TeamsAccountType,
@@ -9,9 +19,14 @@ export type {
9
19
  TeamsConfigLegacy,
10
20
  TeamsCredentials,
11
21
  TeamsFile,
22
+ TeamsListenerEventMap,
23
+ TeamsMention,
12
24
  TeamsMessage,
25
+ TeamsRealtimeMessage,
13
26
  TeamsReaction,
27
+ TeamsSearchResult,
14
28
  TeamsTeam,
29
+ TeamsTrouterGenericEvent,
15
30
  TeamsUser,
16
31
  } from './types'
17
32
  export {
@@ -24,6 +39,7 @@ export {
24
39
  TeamsFileSchema,
25
40
  TeamsMessageSchema,
26
41
  TeamsReactionSchema,
42
+ TeamsSearchResultSchema,
27
43
  TeamsTeamSchema,
28
44
  TeamsUserSchema,
29
45
  } from './types'
@@ -0,0 +1,243 @@
1
+ import { afterEach, describe, expect, mock, it } from 'bun:test'
2
+
3
+ import { TeamsListener } from '@/platforms/teams/listener'
4
+ import type { TeamsRealtimeMessage } from '@/platforms/teams/types'
5
+
6
+ type WsHandler = (...args: any[]) => void
7
+
8
+ let mockWsInstance: MockWs
9
+
10
+ class MockWs {
11
+ static OPEN = 1
12
+ static CLOSED = 3
13
+ readyState = MockWs.OPEN
14
+
15
+ private handlers = new Map<string, WsHandler[]>()
16
+ sent: string[] = []
17
+
18
+ constructor(_url: string, _options?: any) {
19
+ // oxlint-disable-next-line typescript-eslint/no-this-alias
20
+ mockWsInstance = this
21
+ }
22
+
23
+ on(event: string, handler: WsHandler) {
24
+ const list = this.handlers.get(event) ?? []
25
+ list.push(handler)
26
+ this.handlers.set(event, list)
27
+ }
28
+
29
+ send(data: string) {
30
+ this.sent.push(data)
31
+ }
32
+
33
+ close() {
34
+ this.readyState = MockWs.CLOSED
35
+ }
36
+
37
+ emit(event: string, ...args: any[]) {
38
+ for (const handler of this.handlers.get(event) ?? []) {
39
+ handler(...args)
40
+ }
41
+ }
42
+
43
+ simulateFrame(frame: string) {
44
+ this.emit('message', Buffer.from(frame))
45
+ }
46
+ }
47
+
48
+ mock.module('ws', () => ({ default: MockWs, __esModule: true }))
49
+
50
+ const actualTrouter = await import('@/platforms/teams/trouter')
51
+ mock.module('@/platforms/teams/trouter', () => ({
52
+ ...actualTrouter,
53
+ fetchTrouterInfo: mock(() => Promise.resolve({ socketio: 'wss://fake/', surl: 'https://fake/', connectparams: {} })),
54
+ fetchTrouterSessionId: mock(() => Promise.resolve('SESSION')),
55
+ registerEndpoint: mock(() => Promise.resolve()),
56
+ }))
57
+
58
+ function messageFrame(resource: Record<string, unknown>): string {
59
+ const body = JSON.stringify({ resourceType: 'NewMessage', resource })
60
+ return `3:::${JSON.stringify({ id: 1, url: '/v4/f/HASH/messaging', body })}`
61
+ }
62
+
63
+ function conversationLink(conversationId: string): string {
64
+ return `https://notifications.skype.net/v1/users/ME/conversations/${conversationId}`
65
+ }
66
+
67
+ function createMockClient(channelTeamMap: Map<string, string> = new Map()) {
68
+ return {
69
+ getToken: mock(() => 'skype-token'),
70
+ getIdToken: mock(() => Promise.resolve('id-token')),
71
+ getAccountType: mock(() => 'work'),
72
+ buildChannelTeamMap: mock(() => Promise.resolve(channelTeamMap)),
73
+ } as any
74
+ }
75
+
76
+ async function startAndCapture(
77
+ client: ReturnType<typeof createMockClient>,
78
+ ): Promise<{ listener: TeamsListener; messages: TeamsRealtimeMessage[] }> {
79
+ const listener = new TeamsListener(client)
80
+ const messages: TeamsRealtimeMessage[] = []
81
+ listener.on('message', (message) => messages.push(message))
82
+ await listener.start()
83
+ return { listener, messages }
84
+ }
85
+
86
+ async function flushMicrotasks(): Promise<void> {
87
+ for (let i = 0; i < 5; i++) await Promise.resolve()
88
+ }
89
+
90
+ describe('TeamsListener.emitMessage', () => {
91
+ let listener: TeamsListener
92
+
93
+ afterEach(() => {
94
+ listener?.stop()
95
+ })
96
+
97
+ it('classifies a 1:1 chat and carries no team/channel context', async () => {
98
+ const client = createMockClient()
99
+ const started = await startAndCapture(client)
100
+ listener = started.listener
101
+
102
+ mockWsInstance.simulateFrame(
103
+ messageFrame({
104
+ id: 'm1',
105
+ messagetype: 'RichText/Html',
106
+ content: 'hello there',
107
+ from: 'https://x/contacts/8:orgid:alice',
108
+ imdisplayname: 'Alice',
109
+ conversationLink: conversationLink('19:uni01_abc@unq.gbl.spaces'),
110
+ }),
111
+ )
112
+ await flushMicrotasks()
113
+
114
+ expect(started.messages).toHaveLength(1)
115
+ const message = started.messages[0]
116
+ expect(message.chatId).toBe('19:uni01_abc@unq.gbl.spaces')
117
+ expect(message.conversationType).toBe('chat')
118
+ expect(message.teamId).toBeUndefined()
119
+ expect(message.channelId).toBeUndefined()
120
+ expect(message.content).toBe('hello there')
121
+ expect(message.mentions).toEqual([])
122
+ expect(message.author).toEqual({ id: '8:orgid:alice', displayName: 'Alice' })
123
+ })
124
+
125
+ it('classifies a group chat thread with no groupId as a chat', async () => {
126
+ const client = createMockClient()
127
+ const started = await startAndCapture(client)
128
+ listener = started.listener
129
+
130
+ mockWsInstance.simulateFrame(
131
+ messageFrame({
132
+ id: 'm2',
133
+ messagetype: 'RichText/Html',
134
+ content: 'group hi',
135
+ from: 'https://x/contacts/8:orgid:bob',
136
+ imdisplayname: 'Bob',
137
+ conversationLink: conversationLink('19:group_xyz@thread.tacv2'),
138
+ }),
139
+ )
140
+ await flushMicrotasks()
141
+
142
+ expect(started.messages).toHaveLength(1)
143
+ const message = started.messages[0]
144
+ expect(message.chatId).toBe('19:group_xyz@thread.tacv2')
145
+ expect(message.conversationType).toBe('chat')
146
+ expect(message.teamId).toBeUndefined()
147
+ expect(message.channelId).toBeUndefined()
148
+ })
149
+
150
+ it('does not refresh the channel map for every group-chat message', async () => {
151
+ const client = createMockClient()
152
+ const started = await startAndCapture(client)
153
+ listener = started.listener
154
+
155
+ for (const id of ['g1', 'g2', 'g3']) {
156
+ mockWsInstance.simulateFrame(
157
+ messageFrame({
158
+ id,
159
+ messagetype: 'RichText/Html',
160
+ content: 'group hi',
161
+ from: 'https://x/contacts/8:orgid:bob',
162
+ imdisplayname: 'Bob',
163
+ conversationLink: conversationLink('19:group_xyz@thread.tacv2'),
164
+ }),
165
+ )
166
+ await flushMicrotasks()
167
+ }
168
+
169
+ expect(started.messages).toHaveLength(3)
170
+ // One build at start() + one refresh on the first miss; the negative cache
171
+ // prevents a fetch on the 2nd and 3rd messages.
172
+ expect(client.buildChannelTeamMap).toHaveBeenCalledTimes(2)
173
+ })
174
+
175
+ it('classifies a team channel message and resolves teamId plus mentions', async () => {
176
+ const channelId = '19:channel_abc@thread.tacv2'
177
+ const client = createMockClient(new Map([[channelId, 'team-group-id']]))
178
+ const started = await startAndCapture(client)
179
+ listener = started.listener
180
+
181
+ mockWsInstance.simulateFrame(
182
+ messageFrame({
183
+ id: 'm3',
184
+ messagetype: 'RichText/Html',
185
+ content: '<span itemtype="http://schema.skype.com/Mention" itemscope itemid="0">Alice</span> please review',
186
+ from: 'https://x/contacts/8:orgid:bob',
187
+ imdisplayname: 'Bob',
188
+ conversationLink: conversationLink(channelId),
189
+ properties: {
190
+ mentions: [{ itemid: '0', mri: '8:orgid:alice', mentionType: 'person', displayName: 'Alice' }],
191
+ },
192
+ }),
193
+ )
194
+ await flushMicrotasks()
195
+
196
+ expect(started.messages).toHaveLength(1)
197
+ const message = started.messages[0]
198
+ expect(message.conversationType).toBe('channel')
199
+ expect(message.teamId).toBe('team-group-id')
200
+ expect(message.channelId).toBe(channelId)
201
+ expect(message.content).toBe('Alice please review')
202
+ expect(message.mentions).toEqual([{ id: '0', mri: '8:orgid:alice', displayName: 'Alice' }])
203
+ })
204
+
205
+ it('does not re-emit a message with a duplicate id', async () => {
206
+ const client = createMockClient()
207
+ const started = await startAndCapture(client)
208
+ listener = started.listener
209
+
210
+ const frame = messageFrame({
211
+ id: 'dup',
212
+ messagetype: 'Text',
213
+ content: 'once',
214
+ from: 'https://x/contacts/8:orgid:alice',
215
+ imdisplayname: 'Alice',
216
+ conversationLink: conversationLink('19:uni01_abc@unq.gbl.spaces'),
217
+ })
218
+ mockWsInstance.simulateFrame(frame)
219
+ await flushMicrotasks()
220
+ mockWsInstance.simulateFrame(frame)
221
+ await flushMicrotasks()
222
+
223
+ expect(started.messages).toHaveLength(1)
224
+ })
225
+
226
+ it('ignores non-text message types', async () => {
227
+ const client = createMockClient()
228
+ const started = await startAndCapture(client)
229
+ listener = started.listener
230
+
231
+ mockWsInstance.simulateFrame(
232
+ messageFrame({
233
+ id: 'sys',
234
+ messagetype: 'ThreadActivity/AddMember',
235
+ content: '',
236
+ conversationLink: conversationLink('19:uni01_abc@unq.gbl.spaces'),
237
+ }),
238
+ )
239
+ await flushMicrotasks()
240
+
241
+ expect(started.messages).toHaveLength(0)
242
+ })
243
+ })