agent-messenger 2.29.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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +12 -1
- package/dist/package.json +1 -1
- package/dist/src/platforms/teams/client.d.ts +1 -0
- package/dist/src/platforms/teams/client.d.ts.map +1 -1
- package/dist/src/platforms/teams/client.js +16 -0
- package/dist/src/platforms/teams/client.js.map +1 -1
- package/dist/src/platforms/teams/index.d.ts +1 -1
- package/dist/src/platforms/teams/index.d.ts.map +1 -1
- package/dist/src/platforms/teams/index.js.map +1 -1
- package/dist/src/platforms/teams/listener.d.ts +5 -0
- package/dist/src/platforms/teams/listener.d.ts.map +1 -1
- package/dist/src/platforms/teams/listener.js +54 -4
- package/dist/src/platforms/teams/listener.js.map +1 -1
- package/dist/src/platforms/teams/trouter.d.ts +7 -0
- package/dist/src/platforms/teams/trouter.d.ts.map +1 -1
- package/dist/src/platforms/teams/trouter.js +85 -0
- package/dist/src/platforms/teams/trouter.js.map +1 -1
- package/dist/src/platforms/teams/types.d.ts +9 -0
- package/dist/src/platforms/teams/types.d.ts.map +1 -1
- package/dist/src/platforms/teams/types.js.map +1 -1
- package/docs/content/docs/cli/teams.mdx +15 -1
- package/docs/content/docs/sdk/teams.mdx +30 -0
- 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-imessage/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +1 -1
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +25 -2
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-telegrambot/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +1 -1
- package/skills/agent-webexbot/SKILL.md +1 -1
- package/skills/agent-wechatbot/SKILL.md +1 -1
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/platforms/teams/client.ts +28 -0
- package/src/platforms/teams/index.ts +1 -0
- package/src/platforms/teams/listener.test.ts +243 -0
- package/src/platforms/teams/listener.ts +59 -3
- package/src/platforms/teams/trouter.test.ts +60 -0
- package/src/platforms/teams/trouter.ts +95 -0
- package/src/platforms/teams/types.ts +10 -0
|
@@ -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
|
-
|
|
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(/&/g, '&')
|
|
318
|
+
.replace(/</g, '<')
|
|
319
|
+
.replace(/>/g, '>')
|
|
320
|
+
.replace(/"/g, '"')
|
|
321
|
+
.replace(/'/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
|