agent-messenger 2.29.0 → 2.30.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 (120) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +27 -1
  3. package/dist/package.json +1 -1
  4. package/dist/src/platforms/discord/client.d.ts +1 -0
  5. package/dist/src/platforms/discord/client.d.ts.map +1 -1
  6. package/dist/src/platforms/discord/client.js +3 -0
  7. package/dist/src/platforms/discord/client.js.map +1 -1
  8. package/dist/src/platforms/discord/commands/message.d.ts +3 -0
  9. package/dist/src/platforms/discord/commands/message.d.ts.map +1 -1
  10. package/dist/src/platforms/discord/commands/message.js +30 -0
  11. package/dist/src/platforms/discord/commands/message.js.map +1 -1
  12. package/dist/src/platforms/discordbot/commands/message.d.ts +1 -1
  13. package/dist/src/platforms/discordbot/commands/message.d.ts.map +1 -1
  14. package/dist/src/platforms/discordbot/commands/message.js +4 -4
  15. package/dist/src/platforms/discordbot/commands/message.js.map +1 -1
  16. package/dist/src/platforms/slack/commands/message.js +4 -4
  17. package/dist/src/platforms/slack/commands/message.js.map +1 -1
  18. package/dist/src/platforms/slackbot/commands/message.js +4 -4
  19. package/dist/src/platforms/slackbot/commands/message.js.map +1 -1
  20. package/dist/src/platforms/teams/client.d.ts +2 -0
  21. package/dist/src/platforms/teams/client.d.ts.map +1 -1
  22. package/dist/src/platforms/teams/client.js +35 -0
  23. package/dist/src/platforms/teams/client.js.map +1 -1
  24. package/dist/src/platforms/teams/commands/chat.d.ts +3 -0
  25. package/dist/src/platforms/teams/commands/chat.d.ts.map +1 -1
  26. package/dist/src/platforms/teams/commands/chat.js +34 -1
  27. package/dist/src/platforms/teams/commands/chat.js.map +1 -1
  28. package/dist/src/platforms/teams/index.d.ts +1 -1
  29. package/dist/src/platforms/teams/index.d.ts.map +1 -1
  30. package/dist/src/platforms/teams/index.js.map +1 -1
  31. package/dist/src/platforms/teams/listener.d.ts +5 -0
  32. package/dist/src/platforms/teams/listener.d.ts.map +1 -1
  33. package/dist/src/platforms/teams/listener.js +54 -4
  34. package/dist/src/platforms/teams/listener.js.map +1 -1
  35. package/dist/src/platforms/teams/trouter.d.ts +7 -0
  36. package/dist/src/platforms/teams/trouter.d.ts.map +1 -1
  37. package/dist/src/platforms/teams/trouter.js +85 -0
  38. package/dist/src/platforms/teams/trouter.js.map +1 -1
  39. package/dist/src/platforms/teams/types.d.ts +9 -0
  40. package/dist/src/platforms/teams/types.d.ts.map +1 -1
  41. package/dist/src/platforms/teams/types.js.map +1 -1
  42. package/dist/src/platforms/telegram/client.d.ts +1 -0
  43. package/dist/src/platforms/telegram/client.d.ts.map +1 -1
  44. package/dist/src/platforms/telegram/client.js +21 -0
  45. package/dist/src/platforms/telegram/client.js.map +1 -1
  46. package/dist/src/platforms/telegram/commands/message.d.ts.map +1 -1
  47. package/dist/src/platforms/telegram/commands/message.js +25 -1
  48. package/dist/src/platforms/telegram/commands/message.js.map +1 -1
  49. package/dist/src/platforms/telegrambot/commands/message.d.ts +1 -1
  50. package/dist/src/platforms/telegrambot/commands/message.d.ts.map +1 -1
  51. package/dist/src/platforms/telegrambot/commands/message.js +3 -3
  52. package/dist/src/platforms/telegrambot/commands/message.js.map +1 -1
  53. package/dist/src/platforms/whatsapp/client.d.ts +3 -1
  54. package/dist/src/platforms/whatsapp/client.d.ts.map +1 -1
  55. package/dist/src/platforms/whatsapp/client.js +19 -3
  56. package/dist/src/platforms/whatsapp/client.js.map +1 -1
  57. package/dist/src/platforms/whatsapp/commands/message.d.ts.map +1 -1
  58. package/dist/src/platforms/whatsapp/commands/message.js +18 -0
  59. package/dist/src/platforms/whatsapp/commands/message.js.map +1 -1
  60. package/docs/content/docs/cli/discordbot.mdx +2 -2
  61. package/docs/content/docs/cli/slack.mdx +2 -2
  62. package/docs/content/docs/cli/slackbot.mdx +2 -2
  63. package/docs/content/docs/cli/teams.mdx +15 -1
  64. package/docs/content/docs/cli/telegrambot.mdx +1 -1
  65. package/docs/content/docs/sdk/teams.mdx +30 -0
  66. package/e2e/README.md +3 -3
  67. package/e2e/discordbot.e2e.test.ts +2 -2
  68. package/e2e/slack.e2e.test.ts +2 -2
  69. package/e2e/slackbot.e2e.test.ts +2 -2
  70. package/e2e/telegrambot.e2e.test.ts +2 -2
  71. package/package.json +1 -1
  72. package/skills/agent-channeltalk/SKILL.md +1 -1
  73. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  74. package/skills/agent-discord/SKILL.md +5 -1
  75. package/skills/agent-discordbot/SKILL.md +3 -3
  76. package/skills/agent-discordbot/references/common-patterns.md +2 -2
  77. package/skills/agent-imessage/SKILL.md +1 -1
  78. package/skills/agent-instagram/SKILL.md +1 -1
  79. package/skills/agent-kakaotalk/SKILL.md +1 -1
  80. package/skills/agent-line/SKILL.md +1 -1
  81. package/skills/agent-slack/SKILL.md +3 -3
  82. package/skills/agent-slack/references/common-patterns.md +2 -2
  83. package/skills/agent-slackbot/SKILL.md +3 -3
  84. package/skills/agent-slackbot/references/common-patterns.md +4 -4
  85. package/skills/agent-teams/SKILL.md +30 -2
  86. package/skills/agent-telegram/SKILL.md +4 -1
  87. package/skills/agent-telegrambot/SKILL.md +2 -2
  88. package/skills/agent-webex/SKILL.md +1 -1
  89. package/skills/agent-webexbot/SKILL.md +1 -1
  90. package/skills/agent-wechatbot/SKILL.md +1 -1
  91. package/skills/agent-whatsapp/SKILL.md +25 -1
  92. package/skills/agent-whatsappbot/SKILL.md +1 -1
  93. package/src/platforms/discord/client.test.ts +22 -0
  94. package/src/platforms/discord/client.ts +4 -0
  95. package/src/platforms/discord/commands/message.test.ts +26 -1
  96. package/src/platforms/discord/commands/message.ts +41 -0
  97. package/src/platforms/discordbot/commands/message.test.ts +6 -6
  98. package/src/platforms/discordbot/commands/message.ts +4 -4
  99. package/src/platforms/slack/commands/message.test.ts +4 -4
  100. package/src/platforms/slack/commands/message.ts +4 -4
  101. package/src/platforms/slackbot/commands/message.ts +4 -4
  102. package/src/platforms/teams/client.test.ts +24 -0
  103. package/src/platforms/teams/client.ts +56 -0
  104. package/src/platforms/teams/commands/chat.test.ts +23 -1
  105. package/src/platforms/teams/commands/chat.ts +44 -0
  106. package/src/platforms/teams/index.ts +1 -0
  107. package/src/platforms/teams/listener.test.ts +243 -0
  108. package/src/platforms/teams/listener.ts +59 -3
  109. package/src/platforms/teams/trouter.test.ts +60 -0
  110. package/src/platforms/teams/trouter.ts +95 -0
  111. package/src/platforms/teams/types.ts +10 -0
  112. package/src/platforms/telegram/client.test.ts +60 -0
  113. package/src/platforms/telegram/client.ts +24 -0
  114. package/src/platforms/telegram/commands/message.test.ts +38 -0
  115. package/src/platforms/telegram/commands/message.ts +39 -0
  116. package/src/platforms/telegrambot/commands/message.ts +3 -3
  117. package/src/platforms/whatsapp/client.test.ts +58 -0
  118. package/src/platforms/whatsapp/client.ts +23 -3
  119. package/src/platforms/whatsapp/commands/message.test.ts +43 -0
  120. package/src/platforms/whatsapp/commands/message.ts +25 -0
@@ -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
+ })
@@ -16,6 +16,8 @@ import {
16
16
  fetchTrouterInfo,
17
17
  fetchTrouterSessionId,
18
18
  isMessageLossFrame,
19
+ isThreadConversation,
20
+ parseMentions,
19
21
  parseRequestFrame,
20
22
  registerEndpoint,
21
23
  type TrouterInfo,
@@ -40,6 +42,7 @@ interface IncomingResource {
40
42
  imdisplayname?: string
41
43
  conversationLink?: string
42
44
  resourceLink?: string
45
+ properties?: unknown
43
46
  }
44
47
 
45
48
  export class TeamsListener {
@@ -56,6 +59,9 @@ export class TeamsListener {
56
59
  private reregisterTimer: ReturnType<typeof setInterval> | null = null
57
60
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null
58
61
  private seenMessageIds = new Set<string>()
62
+ private channelTeamMap = new Map<string, string>()
63
+ private channelMapRefreshing: Promise<void> | null = null
64
+ private nonChannelThreads = new Set<string>()
59
65
 
60
66
  constructor(client: TeamsClient) {
61
67
  this.client = client
@@ -65,6 +71,7 @@ export class TeamsListener {
65
71
  if (this.running) return
66
72
  this.running = true
67
73
  this.reconnectAttempts = 0
74
+ await this.refreshChannelMap()
68
75
  await this.connect()
69
76
  }
70
77
 
@@ -214,7 +221,9 @@ export class TeamsListener {
214
221
  resource?: IncomingResource
215
222
  }
216
223
  if (decoded.resourceType === 'NewMessage' && decoded.resource) {
217
- this.emitMessage(decoded.resource)
224
+ void this.emitMessage(decoded.resource).catch((error) => {
225
+ this.emitter.emit('error', error instanceof Error ? error : new Error(String(error)))
226
+ })
218
227
  }
219
228
  this.emitter.emit('teams_event', { resourceType: decoded.resourceType ?? 'unknown', ...decoded })
220
229
  } catch (error) {
@@ -222,18 +231,29 @@ export class TeamsListener {
222
231
  }
223
232
  }
224
233
 
225
- private emitMessage(resource: IncomingResource): void {
234
+ private async emitMessage(resource: IncomingResource): Promise<void> {
226
235
  if (!resource.messagetype || !TEXT_MESSAGE_TYPES.has(resource.messagetype)) return
227
236
 
228
237
  const chatId = extractChatId(resource.conversationLink ?? resource.resourceLink)
229
238
  if (!chatId || !resource.id) return
239
+
240
+ // Dedup before the async classification so duplicate frames don't trigger
241
+ // redundant channel-map refreshes.
230
242
  if (this.seenMessageIds.has(resource.id)) return
231
243
  this.rememberMessageId(resource.id)
232
244
 
245
+ const classification = await this.classifyConversation(chatId)
246
+ if (!this.running) return
247
+
248
+ const rawContent = resource.content ?? ''
233
249
  const message: TeamsRealtimeMessage = {
234
250
  id: resource.id,
235
251
  chatId,
236
- content: stripHtml(resource.content ?? ''),
252
+ conversationType: classification.conversationType,
253
+ teamId: classification.teamId,
254
+ channelId: classification.channelId,
255
+ content: stripHtml(rawContent),
256
+ mentions: parseMentions(resource.properties, rawContent),
237
257
  author: {
238
258
  id: extractUserId(resource.from) ?? 'unknown',
239
259
  displayName: resource.imdisplayname || extractUserId(resource.from) || 'unknown',
@@ -244,6 +264,42 @@ export class TeamsListener {
244
264
  this.emitter.emit('message', message)
245
265
  }
246
266
 
267
+ private async classifyConversation(
268
+ chatId: string,
269
+ ): Promise<Pick<TeamsRealtimeMessage, 'conversationType' | 'teamId' | 'channelId'>> {
270
+ if (!isThreadConversation(chatId)) return { conversationType: 'chat' }
271
+
272
+ let teamId = this.channelTeamMap.get(chatId)
273
+ // A thread we haven't cached may be a channel created/joined since start;
274
+ // one best-effort refresh resolves it. Group chats also look like threads
275
+ // but never carry a groupId, so remember the miss to avoid re-fetching
276
+ // /users/ME/conversations on every one of their messages.
277
+ if (!teamId && !this.nonChannelThreads.has(chatId)) {
278
+ await this.refreshChannelMap()
279
+ teamId = this.channelTeamMap.get(chatId)
280
+ if (!teamId) this.nonChannelThreads.add(chatId)
281
+ }
282
+ if (!teamId) return { conversationType: 'chat' }
283
+
284
+ return { conversationType: 'channel', teamId, channelId: chatId }
285
+ }
286
+
287
+ private async refreshChannelMap(): Promise<void> {
288
+ if (this.channelMapRefreshing) return this.channelMapRefreshing
289
+ this.channelMapRefreshing = this.client
290
+ .buildChannelTeamMap()
291
+ .then((map) => {
292
+ this.channelTeamMap = map
293
+ })
294
+ .catch((error) => {
295
+ this.emitter.emit('error', error instanceof Error ? error : new Error(String(error)))
296
+ })
297
+ .finally(() => {
298
+ this.channelMapRefreshing = null
299
+ })
300
+ return this.channelMapRefreshing
301
+ }
302
+
247
303
  private rememberMessageId(id: string): void {
248
304
  this.seenMessageIds.add(id)
249
305
  if (this.seenMessageIds.size > MESSAGE_CACHE_LIMIT) {
@@ -11,6 +11,8 @@ import {
11
11
  decodeMessageBody,
12
12
  extractChatId,
13
13
  isMessageLossFrame,
14
+ isThreadConversation,
15
+ parseMentions,
14
16
  parseRequestFrame,
15
17
  type TrouterInfo,
16
18
  } from './trouter'
@@ -112,3 +114,61 @@ it('decodeMessageBody unwraps nested gp (base64) payloads', () => {
112
114
  const decoded = decodeMessageBody({}, JSON.stringify({ gp }))
113
115
  expect(decoded).toEqual({ resourceType: 'NewMessage' })
114
116
  })
117
+
118
+ it('isThreadConversation flags @thread ids and rejects 1:1 conversations', () => {
119
+ expect(isThreadConversation('19:abc@thread.tacv2')).toBe(true)
120
+ expect(isThreadConversation('19:abc@thread.v2')).toBe(true)
121
+ expect(isThreadConversation('19:uni01_abc@unq.gbl.spaces')).toBe(false)
122
+ expect(isThreadConversation('8:orgid:user@oneToOne.skype')).toBe(false)
123
+ })
124
+
125
+ it('parseMentions prefers the authoritative properties.mentions array', () => {
126
+ const properties = {
127
+ mentions: [
128
+ { itemid: '0', mri: '8:orgid:aaa', mentionType: 'person', displayName: 'Alice' },
129
+ { itemid: '1', mri: '8:orgid:bbb', mentionType: 'person', displayName: 'Bob' },
130
+ ],
131
+ }
132
+ const content =
133
+ '<span itemtype="http://schema.skype.com/Mention" itemscope itemid="0">Alice</span> ' +
134
+ '<span itemtype="http://schema.skype.com/Mention" itemscope itemid="1">Bob</span> hi'
135
+ expect(parseMentions(properties, content)).toEqual([
136
+ { id: '0', mri: '8:orgid:aaa', displayName: 'Alice' },
137
+ { id: '1', mri: '8:orgid:bbb', displayName: 'Bob' },
138
+ ])
139
+ })
140
+
141
+ it('parseMentions accepts JSON-stringified properties and mentions', () => {
142
+ const properties = JSON.stringify({
143
+ mentions: JSON.stringify([{ itemid: '0', mri: '8:orgid:aaa', displayName: 'Alice' }]),
144
+ })
145
+ expect(parseMentions(properties, '')).toEqual([{ id: '0', mri: '8:orgid:aaa', displayName: 'Alice' }])
146
+ })
147
+
148
+ it('parseMentions keeps tag mentions', () => {
149
+ const properties = { mentions: [{ itemid: '0', mri: 'tag:eng', mentionType: 'tag', displayName: 'Engineering' }] }
150
+ expect(parseMentions(properties, '')).toEqual([{ id: '0', mri: 'tag:eng', displayName: 'Engineering' }])
151
+ })
152
+
153
+ it('parseMentions falls back to content spans when properties are absent', () => {
154
+ const content =
155
+ '<readonly class="skipProofing" itemtype="http://schema.skype.com/Mention" contenteditable="false">' +
156
+ '<span itemtype="http://schema.skype.com/Mention" itemscope itemid="0">Alice</span></readonly> hi'
157
+ expect(parseMentions(undefined, content)).toEqual([{ id: '0', displayName: 'Alice' }])
158
+ })
159
+
160
+ it('parseMentions skips property entries missing itemid', () => {
161
+ const properties = {
162
+ mentions: [
163
+ { mri: '8:orgid:aaa', displayName: 'Alice' },
164
+ { itemid: '1', displayName: 'Bob' },
165
+ ],
166
+ }
167
+ expect(parseMentions(properties, '')).toEqual([{ id: '1', mri: undefined, displayName: 'Bob' }])
168
+ })
169
+
170
+ it('parseMentions returns an empty array for malformed data', () => {
171
+ expect(parseMentions('not-json', '')).toEqual([])
172
+ expect(parseMentions({ mentions: 'not-json' }, '')).toEqual([])
173
+ expect(parseMentions(null, 'plain text with no mentions')).toEqual([])
174
+ })
@@ -226,3 +226,98 @@ export function extractChatId(link: string | undefined): string | null {
226
226
  const match = link.match(/conversations\/([^/]+)/)
227
227
  return match ? decodeURIComponent(match[1]) : null
228
228
  }
229
+
230
+ // `id` is Teams' positional mention index (the content span's `itemid`); `mri`
231
+ // is the target's Skype MRI, present only via `properties.mentions` metadata.
232
+ export interface TrouterMention {
233
+ id: string
234
+ mri?: string
235
+ displayName: string
236
+ }
237
+
238
+ // 1:1 chats resolve to a unique roster / one-to-one conversation id; anything on
239
+ // a thread (group chat OR team channel) shares the @thread.* family. The thread
240
+ // family alone cannot tell a group chat from a channel — that needs the
241
+ // conversation's groupId, which the realtime resource does not carry.
242
+ export function isThreadConversation(conversationId: string): boolean {
243
+ return conversationId.includes('@thread.tacv2') || conversationId.includes('@thread.v2')
244
+ }
245
+
246
+ function parseJsonRecord(value: unknown): Record<string, unknown> | undefined {
247
+ const source = typeof value === 'string' ? safeJsonParse(value) : value
248
+ if (typeof source !== 'object' || source === null || Array.isArray(source)) return undefined
249
+ return source as Record<string, unknown>
250
+ }
251
+
252
+ function parseJsonArray(value: unknown): unknown[] | undefined {
253
+ const source = typeof value === 'string' ? safeJsonParse(value) : value
254
+ return Array.isArray(source) ? source : undefined
255
+ }
256
+
257
+ function safeJsonParse(value: string): unknown {
258
+ try {
259
+ return JSON.parse(value)
260
+ } catch {
261
+ return undefined
262
+ }
263
+ }
264
+
265
+ // Mentions arrive two ways: the authoritative `properties.mentions` array (JSON
266
+ // or JSON-string) carries real MRIs, while the message content only has
267
+ // positional `<span itemtype=".../Mention" itemid="N">Name</span>` markup with
268
+ // no MRI. Prefer the metadata; fall back to scraping the content spans so a
269
+ // mention is still surfaced when properties are missing. Never throws — bad
270
+ // data yields an empty list.
271
+ export function parseMentions(properties: unknown, content: string): TrouterMention[] {
272
+ const fromProperties = parseMentionsFromProperties(properties)
273
+ if (fromProperties.length > 0) return fromProperties
274
+ return parseMentionsFromContent(content)
275
+ }
276
+
277
+ function parseMentionsFromProperties(properties: unknown): TrouterMention[] {
278
+ const record = parseJsonRecord(properties)
279
+ if (!record) return []
280
+ const rawMentions = parseJsonArray(record.mentions)
281
+ if (!rawMentions) return []
282
+
283
+ const mentions: TrouterMention[] = []
284
+ for (const entry of rawMentions) {
285
+ if (typeof entry !== 'object' || entry === null) continue
286
+ const item = entry as Record<string, unknown>
287
+ const id = item.itemid
288
+ if (typeof id !== 'string' && typeof id !== 'number') continue
289
+ mentions.push({
290
+ id: String(id),
291
+ mri: typeof item.mri === 'string' ? item.mri : undefined,
292
+ displayName: typeof item.displayName === 'string' ? item.displayName : '',
293
+ })
294
+ }
295
+ return mentions
296
+ }
297
+
298
+ const MENTION_SPAN_REGEX = /<span\b[^>]*itemtype=["'][^"']*Mention[^"']*["'][^>]*>(.*?)<\/span>/gi
299
+
300
+ function parseMentionsFromContent(content: string): TrouterMention[] {
301
+ const mentions: TrouterMention[] = []
302
+ for (const match of content.matchAll(MENTION_SPAN_REGEX)) {
303
+ const attributes = match[0]
304
+ const itemId = attributes.match(/itemid=["']([^"']*)["']/i)?.[1]
305
+ if (itemId === undefined) continue
306
+ mentions.push({
307
+ id: itemId,
308
+ displayName: stripTags(match[1]),
309
+ })
310
+ }
311
+ return mentions
312
+ }
313
+
314
+ function stripTags(html: string): string {
315
+ return html
316
+ .replace(/<[^>]*>/g, '')
317
+ .replace(/&amp;/g, '&')
318
+ .replace(/&lt;/g, '<')
319
+ .replace(/&gt;/g, '>')
320
+ .replace(/&quot;/g, '"')
321
+ .replace(/&#39;/g, "'")
322
+ .trim()
323
+ }
@@ -252,10 +252,20 @@ export const TeamsConfigLegacySchema = z.object({
252
252
  ),
253
253
  })
254
254
 
255
+ export interface TeamsMention {
256
+ id: string
257
+ mri?: string
258
+ displayName: string
259
+ }
260
+
255
261
  export interface TeamsRealtimeMessage {
256
262
  id: string
257
263
  chatId: string
264
+ conversationType: 'chat' | 'channel'
265
+ teamId?: string
266
+ channelId?: string
258
267
  content: string
268
+ mentions: TeamsMention[]
259
269
  author: {
260
270
  id: string
261
271
  displayName: string
@@ -235,3 +235,63 @@ describe('sendMessage confirmation', () => {
235
235
  expect(result.id).toBe(serverId)
236
236
  })
237
237
  })
238
+
239
+ describe('editMessage', () => {
240
+ it('returns the edited message from the editMessageText response', async () => {
241
+ const events: any[] = []
242
+ let editRequest: any = null
243
+
244
+ const createClientId = mock(() => 1)
245
+ const send = mock((_clientId: number, request: any) => {
246
+ if (request['@type'] === 'getAuthorizationState') {
247
+ events.push({
248
+ '@type': 'updateAuthorizationState',
249
+ authorization_state: { '@type': 'authorizationStateReady' },
250
+ '@extra': request['@extra'],
251
+ })
252
+ }
253
+
254
+ if (request['@type'] === 'getChat') {
255
+ events.push({ '@type': 'chat', id: 42, title: 'test', unread_count: 0, '@extra': request['@extra'] })
256
+ }
257
+
258
+ if (request['@type'] === 'editMessageText') {
259
+ editRequest = request
260
+ events.push({
261
+ '@type': 'message',
262
+ id: 555,
263
+ chat_id: 42,
264
+ date: 1_710_000_002,
265
+ is_outgoing: true,
266
+ sender_id: { '@type': 'messageSenderUser', user_id: 1 },
267
+ content: {
268
+ '@type': 'messageText',
269
+ text: { '@type': 'formattedText', text: 'edited text', entities: [] },
270
+ },
271
+ '@extra': request['@extra'],
272
+ })
273
+ }
274
+ })
275
+
276
+ const receive = mock(() => events.shift() ?? null)
277
+ const client = new (TelegramTdlibClient as unknown as new (
278
+ account: TelegramAccount,
279
+ paths: TelegramAccountPaths,
280
+ tdjson: any,
281
+ ) => TelegramTdlibClient)(mockAccount, mockPaths, {
282
+ createClientId,
283
+ send,
284
+ receive,
285
+ libraryPath: '/mock/lib',
286
+ })
287
+
288
+ const result = await client.editMessage('42', 555, 'edited text')
289
+
290
+ expect(result.id).toBe(555)
291
+ expect(result.text).toBe('edited text')
292
+ expect(editRequest.chat_id).toBe(42)
293
+ expect(editRequest.message_id).toBe(555)
294
+ expect(editRequest.input_message_content['@type']).toBe('inputMessageText')
295
+ expect(editRequest.input_message_content.text.text).toBe('edited text')
296
+ })
297
+ })
@@ -312,6 +312,30 @@ export class TelegramTdlibClient {
312
312
  return simplifyMessage(message, chat.id)
313
313
  }
314
314
 
315
+ async editMessage(reference: string, messageId: number, text: string): Promise<TelegramMessageSummary> {
316
+ await this.ensureReady()
317
+ const chat = await this.resolveChat(reference)
318
+
319
+ const message = (await this.call({
320
+ '@type': 'editMessageText',
321
+ chat_id: chat.id,
322
+ message_id: messageId,
323
+ reply_markup: null,
324
+ input_message_content: {
325
+ '@type': 'inputMessageText',
326
+ text: {
327
+ '@type': 'formattedText',
328
+ text,
329
+ entities: [],
330
+ },
331
+ link_preview_options: null,
332
+ clear_draft: false,
333
+ },
334
+ })) as TdMessage
335
+
336
+ return simplifyMessage(message, chat.id)
337
+ }
338
+
315
339
  private async ensureReady(): Promise<void> {
316
340
  const state = await this.connect()
317
341
  if (state?.['@type'] !== 'authorizationStateReady') {