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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +27 -1
- package/dist/package.json +1 -1
- package/dist/src/platforms/discord/client.d.ts +1 -0
- package/dist/src/platforms/discord/client.d.ts.map +1 -1
- package/dist/src/platforms/discord/client.js +3 -0
- package/dist/src/platforms/discord/client.js.map +1 -1
- package/dist/src/platforms/discord/commands/message.d.ts +3 -0
- package/dist/src/platforms/discord/commands/message.d.ts.map +1 -1
- package/dist/src/platforms/discord/commands/message.js +30 -0
- package/dist/src/platforms/discord/commands/message.js.map +1 -1
- package/dist/src/platforms/discordbot/commands/message.d.ts +1 -1
- package/dist/src/platforms/discordbot/commands/message.d.ts.map +1 -1
- package/dist/src/platforms/discordbot/commands/message.js +4 -4
- package/dist/src/platforms/discordbot/commands/message.js.map +1 -1
- package/dist/src/platforms/slack/commands/message.js +4 -4
- package/dist/src/platforms/slack/commands/message.js.map +1 -1
- package/dist/src/platforms/slackbot/commands/message.js +4 -4
- package/dist/src/platforms/slackbot/commands/message.js.map +1 -1
- package/dist/src/platforms/teams/client.d.ts +2 -0
- package/dist/src/platforms/teams/client.d.ts.map +1 -1
- package/dist/src/platforms/teams/client.js +35 -0
- package/dist/src/platforms/teams/client.js.map +1 -1
- package/dist/src/platforms/teams/commands/chat.d.ts +3 -0
- package/dist/src/platforms/teams/commands/chat.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/chat.js +34 -1
- package/dist/src/platforms/teams/commands/chat.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/dist/src/platforms/telegram/client.d.ts +1 -0
- package/dist/src/platforms/telegram/client.d.ts.map +1 -1
- package/dist/src/platforms/telegram/client.js +21 -0
- package/dist/src/platforms/telegram/client.js.map +1 -1
- package/dist/src/platforms/telegram/commands/message.d.ts.map +1 -1
- package/dist/src/platforms/telegram/commands/message.js +25 -1
- package/dist/src/platforms/telegram/commands/message.js.map +1 -1
- package/dist/src/platforms/telegrambot/commands/message.d.ts +1 -1
- package/dist/src/platforms/telegrambot/commands/message.d.ts.map +1 -1
- package/dist/src/platforms/telegrambot/commands/message.js +3 -3
- package/dist/src/platforms/telegrambot/commands/message.js.map +1 -1
- package/dist/src/platforms/whatsapp/client.d.ts +3 -1
- package/dist/src/platforms/whatsapp/client.d.ts.map +1 -1
- package/dist/src/platforms/whatsapp/client.js +19 -3
- package/dist/src/platforms/whatsapp/client.js.map +1 -1
- package/dist/src/platforms/whatsapp/commands/message.d.ts.map +1 -1
- package/dist/src/platforms/whatsapp/commands/message.js +18 -0
- package/dist/src/platforms/whatsapp/commands/message.js.map +1 -1
- package/docs/content/docs/cli/discordbot.mdx +2 -2
- package/docs/content/docs/cli/slack.mdx +2 -2
- package/docs/content/docs/cli/slackbot.mdx +2 -2
- package/docs/content/docs/cli/teams.mdx +15 -1
- package/docs/content/docs/cli/telegrambot.mdx +1 -1
- package/docs/content/docs/sdk/teams.mdx +30 -0
- package/e2e/README.md +3 -3
- package/e2e/discordbot.e2e.test.ts +2 -2
- package/e2e/slack.e2e.test.ts +2 -2
- package/e2e/slackbot.e2e.test.ts +2 -2
- package/e2e/telegrambot.e2e.test.ts +2 -2
- 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 +5 -1
- package/skills/agent-discordbot/SKILL.md +3 -3
- package/skills/agent-discordbot/references/common-patterns.md +2 -2
- 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 +3 -3
- package/skills/agent-slack/references/common-patterns.md +2 -2
- package/skills/agent-slackbot/SKILL.md +3 -3
- package/skills/agent-slackbot/references/common-patterns.md +4 -4
- package/skills/agent-teams/SKILL.md +30 -2
- package/skills/agent-telegram/SKILL.md +4 -1
- package/skills/agent-telegrambot/SKILL.md +2 -2
- 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 +25 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/platforms/discord/client.test.ts +22 -0
- package/src/platforms/discord/client.ts +4 -0
- package/src/platforms/discord/commands/message.test.ts +26 -1
- package/src/platforms/discord/commands/message.ts +41 -0
- package/src/platforms/discordbot/commands/message.test.ts +6 -6
- package/src/platforms/discordbot/commands/message.ts +4 -4
- package/src/platforms/slack/commands/message.test.ts +4 -4
- package/src/platforms/slack/commands/message.ts +4 -4
- package/src/platforms/slackbot/commands/message.ts +4 -4
- package/src/platforms/teams/client.test.ts +24 -0
- package/src/platforms/teams/client.ts +56 -0
- package/src/platforms/teams/commands/chat.test.ts +23 -1
- package/src/platforms/teams/commands/chat.ts +44 -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
- package/src/platforms/telegram/client.test.ts +60 -0
- package/src/platforms/telegram/client.ts +24 -0
- package/src/platforms/telegram/commands/message.test.ts +38 -0
- package/src/platforms/telegram/commands/message.ts +39 -0
- package/src/platforms/telegrambot/commands/message.ts +3 -3
- package/src/platforms/whatsapp/client.test.ts +58 -0
- package/src/platforms/whatsapp/client.ts +23 -3
- package/src/platforms/whatsapp/commands/message.test.ts +43 -0
- 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
|
-
|
|
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
|
|
@@ -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') {
|