@zooid/transport-matrix 0.7.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/LICENSE +21 -0
- package/dist/index.d.ts +288 -0
- package/dist/index.js +1076 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
- package/src/bot-pool.test.ts +307 -0
- package/src/bot-pool.ts +112 -0
- package/src/context-provider.test.ts +317 -0
- package/src/context-provider.ts +187 -0
- package/src/event-encoders.test.ts +124 -0
- package/src/event-encoders.ts +66 -0
- package/src/index.ts +26 -0
- package/src/markdown-to-matrix-html.test.ts +102 -0
- package/src/markdown-to-matrix-html.ts +41 -0
- package/src/matrix-client.test.ts +307 -0
- package/src/matrix-client.ts +361 -0
- package/src/mentions.test.ts +90 -0
- package/src/mentions.ts +38 -0
- package/src/registration.test.ts +41 -0
- package/src/registration.ts +44 -0
- package/src/router.test.ts +90 -0
- package/src/router.ts +72 -0
- package/src/space-provisioner.test.ts +89 -0
- package/src/space-provisioner.ts +34 -0
- package/src/transport.test.ts +1164 -0
- package/src/transport.ts +521 -0
- package/src/workforce-publisher.test.ts +76 -0
- package/src/workforce-publisher.ts +53 -0
package/src/bot-pool.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { MatrixClient } from './matrix-client.js'
|
|
2
|
+
import type { AgentBinding } from './router.js'
|
|
3
|
+
import { serverNameFromMxid } from './space-provisioner.js'
|
|
4
|
+
|
|
5
|
+
export interface BootstrapOpts {
|
|
6
|
+
/** Invited to any newly-created room; absent = no invite. */
|
|
7
|
+
adminUserId?: string
|
|
8
|
+
/** Workforce space room ID. When set, every resolved agent room is attached as m.space.child. */
|
|
9
|
+
spaceRoomId?: string
|
|
10
|
+
/** AS bot user ID. Required when spaceRoomId is set; sender of the m.space.child write. */
|
|
11
|
+
asUserId?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class BotPool {
|
|
15
|
+
constructor(
|
|
16
|
+
private readonly client: Pick<
|
|
17
|
+
MatrixClient,
|
|
18
|
+
'registerBot' | 'joinRoom' | 'resolveAlias' | 'createRoom' | 'sendStateEvent' | 'setDisplayName'
|
|
19
|
+
>,
|
|
20
|
+
private readonly agents: AgentBinding[],
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
async bootstrap(opts: BootstrapOpts = {}): Promise<void> {
|
|
24
|
+
const aliasToId = new Map<string, string>()
|
|
25
|
+
const attachedToSpace = new Set<string>()
|
|
26
|
+
for (const a of this.agents) {
|
|
27
|
+
const lp = localpart(a.userId)
|
|
28
|
+
try {
|
|
29
|
+
await this.client.registerBot(lp)
|
|
30
|
+
} catch (err) {
|
|
31
|
+
console.warn(`[matrix] register failed for ${a.userId}: ${(err as Error).message}`)
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
await this.client.setDisplayName(a.userId, a.displayName ?? lp)
|
|
35
|
+
} catch (err) {
|
|
36
|
+
console.warn(`[matrix] setDisplayName(${a.userId}) failed: ${(err as Error).message}`)
|
|
37
|
+
}
|
|
38
|
+
for (let i = 0; i < a.rooms.length; i++) {
|
|
39
|
+
const room = a.rooms[i]
|
|
40
|
+
try {
|
|
41
|
+
let resolved = room
|
|
42
|
+
if (room.startsWith('#')) {
|
|
43
|
+
const cached = aliasToId.get(room)
|
|
44
|
+
if (cached) {
|
|
45
|
+
resolved = cached
|
|
46
|
+
} else {
|
|
47
|
+
const existing = await this.client.resolveAlias(room)
|
|
48
|
+
if (existing) {
|
|
49
|
+
resolved = existing
|
|
50
|
+
} else {
|
|
51
|
+
const colon = room.indexOf(':')
|
|
52
|
+
const aliasLocalpart = colon > 1 ? room.slice(1, colon) : room.slice(1)
|
|
53
|
+
const sender = opts.adminUserId ?? a.userId
|
|
54
|
+
resolved = await this.client.createRoom({
|
|
55
|
+
roomAliasName: aliasLocalpart,
|
|
56
|
+
invite: opts.adminUserId ? [opts.adminUserId] : [],
|
|
57
|
+
senderUserId: sender,
|
|
58
|
+
name: aliasLocalpart,
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
aliasToId.set(room, resolved)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Store the canonical room_id on the binding so the router (which
|
|
65
|
+
// matches on event.room_id) sees a hit when Tuwunel pushes events.
|
|
66
|
+
a.rooms[i] = resolved
|
|
67
|
+
await this.client.joinRoom(resolved, a.userId)
|
|
68
|
+
|
|
69
|
+
if (
|
|
70
|
+
opts.spaceRoomId &&
|
|
71
|
+
opts.asUserId &&
|
|
72
|
+
!attachedToSpace.has(resolved)
|
|
73
|
+
) {
|
|
74
|
+
attachedToSpace.add(resolved)
|
|
75
|
+
const via = serverNameFromMxid(a.userId)
|
|
76
|
+
try {
|
|
77
|
+
await this.client.sendStateEvent({
|
|
78
|
+
roomId: opts.spaceRoomId,
|
|
79
|
+
asUserId: opts.asUserId,
|
|
80
|
+
eventType: 'm.space.child',
|
|
81
|
+
stateKey: resolved,
|
|
82
|
+
content: { via: [via] },
|
|
83
|
+
})
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.warn(
|
|
86
|
+
`[matrix] m.space.child(${resolved}) failed: ${(err as Error).message}`,
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch (err) {
|
|
91
|
+
console.warn(
|
|
92
|
+
`[matrix] join failed (${a.userId} → ${room}): ${(err as Error).message}`,
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
findByUserId(userId: string): AgentBinding | undefined {
|
|
100
|
+
return this.agents.find((a) => a.userId === userId)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
findByName(name: string): AgentBinding | undefined {
|
|
104
|
+
return this.agents.find((a) => a.name === name)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function localpart(userId: string): string {
|
|
109
|
+
const m = /^@([^:]+):/.exec(userId)
|
|
110
|
+
if (!m) throw new Error(`bad user id: ${userId}`)
|
|
111
|
+
return m[1]
|
|
112
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { MatrixContextProvider } from './context-provider.js'
|
|
3
|
+
import type { MatrixClient } from './matrix-client.js'
|
|
4
|
+
|
|
5
|
+
function fakeClient(overrides: Partial<MatrixClient> = {}): MatrixClient {
|
|
6
|
+
return {
|
|
7
|
+
fetchRoomMessages: vi.fn(),
|
|
8
|
+
getJoinedMembers: vi.fn(),
|
|
9
|
+
fetchRoomName: vi.fn(),
|
|
10
|
+
fetchEvent: vi.fn(),
|
|
11
|
+
fetchThreadRelations: vi.fn(),
|
|
12
|
+
...overrides,
|
|
13
|
+
} as unknown as MatrixClient
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('MatrixContextProvider', () => {
|
|
17
|
+
it('maps Matrix m.room.message events into Message[] oldest-first', async () => {
|
|
18
|
+
const client = fakeClient({
|
|
19
|
+
fetchRoomMessages: vi.fn().mockResolvedValue({
|
|
20
|
+
chunk: [
|
|
21
|
+
{
|
|
22
|
+
event_id: '$e2',
|
|
23
|
+
sender: '@bob:hs',
|
|
24
|
+
origin_server_ts: 2000,
|
|
25
|
+
type: 'm.room.message',
|
|
26
|
+
content: { msgtype: 'm.text', body: 'second' },
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
event_id: '$e1',
|
|
30
|
+
sender: '@alice:hs',
|
|
31
|
+
origin_server_ts: 1000,
|
|
32
|
+
type: 'm.room.message',
|
|
33
|
+
content: { msgtype: 'm.text', body: 'first' },
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
end: 'matrix-pagination-token',
|
|
37
|
+
}),
|
|
38
|
+
} as unknown as Partial<MatrixClient>)
|
|
39
|
+
const provider = new MatrixContextProvider({
|
|
40
|
+
client,
|
|
41
|
+
asUserId: '@_zooid:hs',
|
|
42
|
+
agentBots: new Map([['@architect:hs', 'architect']]),
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const page = await provider.getRoomHistory('!room:hs', { limit: 50 })
|
|
46
|
+
|
|
47
|
+
expect(page.messages.map((m) => m.id)).toEqual(['$e1', '$e2'])
|
|
48
|
+
expect(page.messages[0].sender).toBe('@alice:hs')
|
|
49
|
+
expect(page.messages[0].is_agent).toBe(false)
|
|
50
|
+
expect(page.next_before).toBe('matrix-pagination-token')
|
|
51
|
+
expect(page.has_more).toBe(true)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('flags messages from registered agent bots as is_agent + agent_name', async () => {
|
|
55
|
+
const client = fakeClient({
|
|
56
|
+
fetchRoomMessages: vi.fn().mockResolvedValue({
|
|
57
|
+
chunk: [
|
|
58
|
+
{
|
|
59
|
+
event_id: '$e1',
|
|
60
|
+
sender: '@architect:hs',
|
|
61
|
+
origin_server_ts: 1000,
|
|
62
|
+
type: 'm.room.message',
|
|
63
|
+
content: { msgtype: 'm.text', body: 'thinking...' },
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
end: undefined,
|
|
67
|
+
}),
|
|
68
|
+
} as unknown as Partial<MatrixClient>)
|
|
69
|
+
const provider = new MatrixContextProvider({
|
|
70
|
+
client,
|
|
71
|
+
asUserId: '@_zooid:hs',
|
|
72
|
+
agentBots: new Map([['@architect:hs', 'architect']]),
|
|
73
|
+
})
|
|
74
|
+
const page = await provider.getRoomHistory('!room:hs', {})
|
|
75
|
+
expect(page.messages[0]).toMatchObject({
|
|
76
|
+
sender: '@architect:hs',
|
|
77
|
+
is_agent: true,
|
|
78
|
+
agent_name: 'architect',
|
|
79
|
+
})
|
|
80
|
+
expect(page.has_more).toBe(false)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('surfaces thread_id on messages that belong to a thread', async () => {
|
|
84
|
+
const client = fakeClient({
|
|
85
|
+
fetchRoomMessages: vi.fn().mockResolvedValue({
|
|
86
|
+
chunk: [
|
|
87
|
+
{
|
|
88
|
+
event_id: '$reply',
|
|
89
|
+
sender: '@alice:hs',
|
|
90
|
+
origin_server_ts: 2000,
|
|
91
|
+
type: 'm.room.message',
|
|
92
|
+
content: {
|
|
93
|
+
msgtype: 'm.text',
|
|
94
|
+
body: 'in-thread reply',
|
|
95
|
+
'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
event_id: '$top',
|
|
100
|
+
sender: '@bob:hs',
|
|
101
|
+
origin_server_ts: 1000,
|
|
102
|
+
type: 'm.room.message',
|
|
103
|
+
content: { msgtype: 'm.text', body: 'top-level' },
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
end: undefined,
|
|
107
|
+
}),
|
|
108
|
+
} as unknown as Partial<MatrixClient>)
|
|
109
|
+
const provider = new MatrixContextProvider({
|
|
110
|
+
client,
|
|
111
|
+
asUserId: '@_zooid:hs',
|
|
112
|
+
agentBots: new Map(),
|
|
113
|
+
})
|
|
114
|
+
const page = await provider.getRoomHistory('!room:hs', {})
|
|
115
|
+
const byId = new Map(page.messages.map((m) => [m.id, m]))
|
|
116
|
+
expect(byId.get('$reply')?.thread_id).toBe('$root')
|
|
117
|
+
expect(byId.get('$top')?.thread_id).toBeUndefined()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('passes channelId and pagination opts through to the matrix client without thread filtering', async () => {
|
|
121
|
+
const fetchRoomMessages = vi.fn().mockResolvedValue({ chunk: [], end: undefined })
|
|
122
|
+
const provider = new MatrixContextProvider({
|
|
123
|
+
client: fakeClient({ fetchRoomMessages } as unknown as Partial<MatrixClient>),
|
|
124
|
+
asUserId: '@_zooid:hs',
|
|
125
|
+
agentBots: new Map(),
|
|
126
|
+
})
|
|
127
|
+
await provider.getRoomHistory('!room:hs', { limit: 25, before: 'cursor-1' })
|
|
128
|
+
expect(fetchRoomMessages).toHaveBeenCalledWith({
|
|
129
|
+
roomId: '!room:hs',
|
|
130
|
+
asUserId: '@_zooid:hs',
|
|
131
|
+
limit: 25,
|
|
132
|
+
from: 'cursor-1',
|
|
133
|
+
filter: { types: ['m.room.message'] },
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('getChannelMembers returns joined members with is_agent flags', async () => {
|
|
138
|
+
const client = fakeClient({
|
|
139
|
+
getJoinedMembers: vi.fn().mockResolvedValue({
|
|
140
|
+
joined: {
|
|
141
|
+
'@alice:hs': { display_name: 'Alice' },
|
|
142
|
+
'@architect:hs': { display_name: 'architect' },
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
} as unknown as Partial<MatrixClient>)
|
|
146
|
+
const provider = new MatrixContextProvider({
|
|
147
|
+
client,
|
|
148
|
+
asUserId: '@_zooid:hs',
|
|
149
|
+
agentBots: new Map([['@architect:hs', 'architect']]),
|
|
150
|
+
})
|
|
151
|
+
const members = await provider.getChannelMembers('!room:hs')
|
|
152
|
+
expect(members).toEqual([
|
|
153
|
+
{ id: '@alice:hs', name: 'Alice', is_agent: false },
|
|
154
|
+
{ id: '@architect:hs', name: 'architect', is_agent: true, agent_name: 'architect' },
|
|
155
|
+
])
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('getChannelInfo returns the room name and transport: matrix', async () => {
|
|
159
|
+
const client = fakeClient({
|
|
160
|
+
fetchRoomName: vi.fn().mockResolvedValue('engineering'),
|
|
161
|
+
} as unknown as Partial<MatrixClient>)
|
|
162
|
+
const provider = new MatrixContextProvider({
|
|
163
|
+
client,
|
|
164
|
+
asUserId: '@_zooid:hs',
|
|
165
|
+
agentBots: new Map(),
|
|
166
|
+
})
|
|
167
|
+
const info = await provider.getChannelInfo('!room:hs')
|
|
168
|
+
expect(info).toEqual({ id: '!room:hs', name: 'engineering', transport: 'matrix' })
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('getRecentThreads returns top-level entries newest-first with bundled thread metadata, skipping thread replies', async () => {
|
|
172
|
+
const client = fakeClient({
|
|
173
|
+
fetchRoomMessages: vi.fn().mockResolvedValue({
|
|
174
|
+
chunk: [
|
|
175
|
+
{
|
|
176
|
+
event_id: '$top2',
|
|
177
|
+
sender: '@bob:hs',
|
|
178
|
+
origin_server_ts: 3000,
|
|
179
|
+
type: 'm.room.message',
|
|
180
|
+
content: { msgtype: 'm.text', body: 'standalone' },
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
event_id: '$reply1',
|
|
184
|
+
sender: '@alice:hs',
|
|
185
|
+
origin_server_ts: 2500,
|
|
186
|
+
type: 'm.room.message',
|
|
187
|
+
content: {
|
|
188
|
+
msgtype: 'm.text',
|
|
189
|
+
body: 'in-thread',
|
|
190
|
+
'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
event_id: '$root',
|
|
195
|
+
sender: '@alice:hs',
|
|
196
|
+
origin_server_ts: 1000,
|
|
197
|
+
type: 'm.room.message',
|
|
198
|
+
content: { msgtype: 'm.text', body: 'thread root' },
|
|
199
|
+
unsigned: {
|
|
200
|
+
'm.relations': {
|
|
201
|
+
'm.thread': { count: 3, latest_event: { origin_server_ts: 2500 } },
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
end: 'next-page',
|
|
207
|
+
}),
|
|
208
|
+
} as unknown as Partial<MatrixClient>)
|
|
209
|
+
const provider = new MatrixContextProvider({
|
|
210
|
+
client,
|
|
211
|
+
asUserId: '@_zooid:hs',
|
|
212
|
+
agentBots: new Map(),
|
|
213
|
+
})
|
|
214
|
+
const page = await provider.getRecentThreads('!room:hs', { limit: 50 })
|
|
215
|
+
expect(page.threads.map((t) => t.id)).toEqual(['$top2', '$root'])
|
|
216
|
+
expect(page.threads[0]).toMatchObject({
|
|
217
|
+
id: '$top2',
|
|
218
|
+
reply_count: 0,
|
|
219
|
+
last_activity_at: new Date(3000).toISOString(),
|
|
220
|
+
})
|
|
221
|
+
expect(page.threads[1]).toMatchObject({
|
|
222
|
+
id: '$root',
|
|
223
|
+
reply_count: 3,
|
|
224
|
+
last_activity_at: new Date(2500).toISOString(),
|
|
225
|
+
})
|
|
226
|
+
expect(page.has_more).toBe(true)
|
|
227
|
+
expect(page.next_before).toBe('next-page')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('getThreadHistory prepends the root event then appends replies oldest-first', async () => {
|
|
231
|
+
const client = fakeClient({
|
|
232
|
+
fetchEvent: vi.fn().mockResolvedValue({
|
|
233
|
+
event_id: '$root',
|
|
234
|
+
sender: '@alice:hs',
|
|
235
|
+
origin_server_ts: 1000,
|
|
236
|
+
type: 'm.room.message',
|
|
237
|
+
content: { msgtype: 'm.text', body: 'root msg' },
|
|
238
|
+
}),
|
|
239
|
+
fetchThreadRelations: vi.fn().mockResolvedValue({
|
|
240
|
+
chunk: [
|
|
241
|
+
{
|
|
242
|
+
event_id: '$r1',
|
|
243
|
+
sender: '@bob:hs',
|
|
244
|
+
origin_server_ts: 1500,
|
|
245
|
+
type: 'm.room.message',
|
|
246
|
+
content: {
|
|
247
|
+
msgtype: 'm.text',
|
|
248
|
+
body: 'first reply',
|
|
249
|
+
'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
event_id: '$r2',
|
|
254
|
+
sender: '@alice:hs',
|
|
255
|
+
origin_server_ts: 2000,
|
|
256
|
+
type: 'm.room.message',
|
|
257
|
+
content: {
|
|
258
|
+
msgtype: 'm.text',
|
|
259
|
+
body: 'second reply',
|
|
260
|
+
'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
next_batch: undefined,
|
|
265
|
+
}),
|
|
266
|
+
} as unknown as Partial<MatrixClient>)
|
|
267
|
+
const provider = new MatrixContextProvider({
|
|
268
|
+
client,
|
|
269
|
+
asUserId: '@_zooid:hs',
|
|
270
|
+
agentBots: new Map(),
|
|
271
|
+
})
|
|
272
|
+
const page = await provider.getThreadHistory('!room:hs', '$root', {})
|
|
273
|
+
expect(page.messages.map((m) => m.id)).toEqual(['$root', '$r1', '$r2'])
|
|
274
|
+
expect(page.messages.every((m) => m.thread_id === '$root')).toBe(true)
|
|
275
|
+
expect(page.has_more).toBe(false)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('getThreadHistory skips the root fetch when paginating (before is set)', async () => {
|
|
279
|
+
const fetchEvent = vi.fn()
|
|
280
|
+
const fetchThreadRelations = vi.fn().mockResolvedValue({
|
|
281
|
+
chunk: [],
|
|
282
|
+
next_batch: 'next-cursor',
|
|
283
|
+
})
|
|
284
|
+
const provider = new MatrixContextProvider({
|
|
285
|
+
client: fakeClient({ fetchEvent, fetchThreadRelations } as unknown as Partial<MatrixClient>),
|
|
286
|
+
asUserId: '@_zooid:hs',
|
|
287
|
+
agentBots: new Map(),
|
|
288
|
+
})
|
|
289
|
+
const page = await provider.getThreadHistory('!room:hs', '$root', {
|
|
290
|
+
limit: 50,
|
|
291
|
+
before: 'cursor-1',
|
|
292
|
+
})
|
|
293
|
+
expect(fetchEvent).not.toHaveBeenCalled()
|
|
294
|
+
expect(fetchThreadRelations).toHaveBeenCalledWith({
|
|
295
|
+
roomId: '!room:hs',
|
|
296
|
+
rootEventId: '$root',
|
|
297
|
+
asUserId: '@_zooid:hs',
|
|
298
|
+
limit: 50,
|
|
299
|
+
from: 'cursor-1',
|
|
300
|
+
})
|
|
301
|
+
expect(page.next_before).toBe('next-cursor')
|
|
302
|
+
expect(page.has_more).toBe(true)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('falls back to the room id when no name state is set', async () => {
|
|
306
|
+
const client = fakeClient({
|
|
307
|
+
fetchRoomName: vi.fn().mockResolvedValue(null),
|
|
308
|
+
} as unknown as Partial<MatrixClient>)
|
|
309
|
+
const provider = new MatrixContextProvider({
|
|
310
|
+
client,
|
|
311
|
+
asUserId: '@_zooid:hs',
|
|
312
|
+
agentBots: new Map(),
|
|
313
|
+
})
|
|
314
|
+
const info = await provider.getChannelInfo('!room:hs')
|
|
315
|
+
expect(info.name).toBe('!room:hs')
|
|
316
|
+
})
|
|
317
|
+
})
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TransportContextProvider,
|
|
3
|
+
HistoryOptions,
|
|
4
|
+
HistoryPage,
|
|
5
|
+
Member,
|
|
6
|
+
ChannelInfo,
|
|
7
|
+
Message,
|
|
8
|
+
ThreadOverview,
|
|
9
|
+
ThreadOverviewPage,
|
|
10
|
+
} from '@zooid/core'
|
|
11
|
+
import type { MatrixClient } from './matrix-client.js'
|
|
12
|
+
|
|
13
|
+
interface MatrixMessageEvent {
|
|
14
|
+
event_id: string
|
|
15
|
+
sender: string
|
|
16
|
+
origin_server_ts: number
|
|
17
|
+
type: string
|
|
18
|
+
content?: {
|
|
19
|
+
msgtype?: string
|
|
20
|
+
body?: string
|
|
21
|
+
'm.relates_to'?: { rel_type?: string; event_id?: string }
|
|
22
|
+
}
|
|
23
|
+
unsigned?: {
|
|
24
|
+
'm.relations'?: {
|
|
25
|
+
'm.thread'?: {
|
|
26
|
+
count?: number
|
|
27
|
+
latest_event?: { origin_server_ts?: number }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MatrixContextProviderOpts {
|
|
34
|
+
client: MatrixClient
|
|
35
|
+
/** AS sender_localpart user (read access). */
|
|
36
|
+
asUserId: string
|
|
37
|
+
/** Map of Matrix user IDs → agent names, for is_agent / agent_name flags. */
|
|
38
|
+
agentBots: Map<string, string>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class MatrixContextProvider implements TransportContextProvider {
|
|
42
|
+
constructor(private readonly opts: MatrixContextProviderOpts) {}
|
|
43
|
+
|
|
44
|
+
async getRoomHistory(channelId: string, hopts: HistoryOptions): Promise<HistoryPage> {
|
|
45
|
+
// Server-side filter: only `m.room.message` events. Without this we'd
|
|
46
|
+
// burn the page budget on reactions, `eco.zoon.*` custom events, typing
|
|
47
|
+
// notifications, etc., and routinely return empty pages with a stale
|
|
48
|
+
// `has_more` cursor.
|
|
49
|
+
const { chunk, end } = await this.opts.client.fetchRoomMessages({
|
|
50
|
+
roomId: channelId,
|
|
51
|
+
asUserId: this.opts.asUserId,
|
|
52
|
+
limit: hopts.limit,
|
|
53
|
+
from: hopts.before,
|
|
54
|
+
filter: { types: ['m.room.message'] },
|
|
55
|
+
})
|
|
56
|
+
const messages: Message[] = []
|
|
57
|
+
for (let i = chunk.length - 1; i >= 0; i--) {
|
|
58
|
+
const ev = chunk[i] as unknown as MatrixMessageEvent
|
|
59
|
+
const msg = this.toMessage(ev)
|
|
60
|
+
if (msg) messages.push(msg)
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
messages,
|
|
64
|
+
next_before: end,
|
|
65
|
+
has_more: end !== undefined,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async getRecentThreads(
|
|
70
|
+
channelId: string,
|
|
71
|
+
hopts: HistoryOptions,
|
|
72
|
+
): Promise<ThreadOverviewPage> {
|
|
73
|
+
// Server-side filter: `m.room.message` only, and exclude thread replies
|
|
74
|
+
// (`not_rel_types: ['m.thread']`) so the overview shows top-level entries
|
|
75
|
+
// and thread roots, not the reply noise underneath them.
|
|
76
|
+
const { chunk, end } = await this.opts.client.fetchRoomMessages({
|
|
77
|
+
roomId: channelId,
|
|
78
|
+
asUserId: this.opts.asUserId,
|
|
79
|
+
limit: hopts.limit,
|
|
80
|
+
from: hopts.before,
|
|
81
|
+
filter: { types: ['m.room.message'], not_rel_types: ['m.thread'] },
|
|
82
|
+
})
|
|
83
|
+
// /messages returns newest-first; keep that order for the overview.
|
|
84
|
+
const threads: ThreadOverview[] = []
|
|
85
|
+
for (const ev of chunk as unknown as MatrixMessageEvent[]) {
|
|
86
|
+
if (ev.type !== 'm.room.message') continue
|
|
87
|
+
if (ev.content?.msgtype !== 'm.text' || typeof ev.content.body !== 'string') continue
|
|
88
|
+
const relatesTo = ev.content['m.relates_to']
|
|
89
|
+
if (relatesTo?.rel_type === 'm.thread') continue // skip thread replies
|
|
90
|
+
const agent = this.opts.agentBots.get(ev.sender)
|
|
91
|
+
const bundled = ev.unsigned?.['m.relations']?.['m.thread']
|
|
92
|
+
const replyCount = bundled?.count ?? 0
|
|
93
|
+
const latestTs = bundled?.latest_event?.origin_server_ts ?? ev.origin_server_ts
|
|
94
|
+
threads.push({
|
|
95
|
+
id: ev.event_id,
|
|
96
|
+
sender: ev.sender,
|
|
97
|
+
text: ev.content.body,
|
|
98
|
+
timestamp: new Date(ev.origin_server_ts).toISOString(),
|
|
99
|
+
is_agent: agent !== undefined,
|
|
100
|
+
...(agent !== undefined ? { agent_name: agent } : {}),
|
|
101
|
+
reply_count: replyCount,
|
|
102
|
+
last_activity_at: new Date(latestTs).toISOString(),
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
threads,
|
|
107
|
+
next_before: end,
|
|
108
|
+
has_more: end !== undefined,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async getThreadHistory(
|
|
113
|
+
channelId: string,
|
|
114
|
+
threadId: string,
|
|
115
|
+
hopts: HistoryOptions,
|
|
116
|
+
): Promise<HistoryPage> {
|
|
117
|
+
// Root event first (only on the first page when no pagination cursor).
|
|
118
|
+
const messages: Message[] = []
|
|
119
|
+
if (!hopts.before) {
|
|
120
|
+
const root = (await this.opts.client.fetchEvent(
|
|
121
|
+
channelId,
|
|
122
|
+
threadId,
|
|
123
|
+
this.opts.asUserId,
|
|
124
|
+
)) as unknown as MatrixMessageEvent | null
|
|
125
|
+
if (root) {
|
|
126
|
+
const rootMsg = this.toMessage(root)
|
|
127
|
+
if (rootMsg) messages.push({ ...rootMsg, thread_id: threadId })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const { chunk, next_batch } = await this.opts.client.fetchThreadRelations({
|
|
131
|
+
roomId: channelId,
|
|
132
|
+
rootEventId: threadId,
|
|
133
|
+
asUserId: this.opts.asUserId,
|
|
134
|
+
limit: hopts.limit,
|
|
135
|
+
from: hopts.before,
|
|
136
|
+
})
|
|
137
|
+
for (const ev of chunk as unknown as MatrixMessageEvent[]) {
|
|
138
|
+
const reply = this.toMessage(ev)
|
|
139
|
+
if (reply) messages.push({ ...reply, thread_id: threadId })
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
messages,
|
|
143
|
+
next_before: next_batch,
|
|
144
|
+
has_more: next_batch !== undefined,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private toMessage(ev: MatrixMessageEvent): Message | null {
|
|
149
|
+
if (ev.type !== 'm.room.message') return null
|
|
150
|
+
if (ev.content?.msgtype !== 'm.text' || typeof ev.content.body !== 'string') return null
|
|
151
|
+
const agent = this.opts.agentBots.get(ev.sender)
|
|
152
|
+
const relatesTo = ev.content['m.relates_to']
|
|
153
|
+
const threadId =
|
|
154
|
+
relatesTo?.rel_type === 'm.thread' && relatesTo.event_id ? relatesTo.event_id : undefined
|
|
155
|
+
return {
|
|
156
|
+
id: ev.event_id,
|
|
157
|
+
sender: ev.sender,
|
|
158
|
+
text: ev.content.body,
|
|
159
|
+
timestamp: new Date(ev.origin_server_ts).toISOString(),
|
|
160
|
+
is_agent: agent !== undefined,
|
|
161
|
+
...(agent !== undefined ? { agent_name: agent } : {}),
|
|
162
|
+
...(threadId !== undefined ? { thread_id: threadId } : {}),
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async getChannelMembers(channelId: string): Promise<Member[]> {
|
|
167
|
+
const { joined } = await this.opts.client.getJoinedMembers(channelId, this.opts.asUserId)
|
|
168
|
+
return Object.entries(joined).map(([id, info]) => {
|
|
169
|
+
const agent = this.opts.agentBots.get(id)
|
|
170
|
+
return {
|
|
171
|
+
id,
|
|
172
|
+
name: info.display_name ?? id,
|
|
173
|
+
is_agent: agent !== undefined,
|
|
174
|
+
...(agent !== undefined ? { agent_name: agent } : {}),
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async getChannelInfo(channelId: string): Promise<ChannelInfo> {
|
|
180
|
+
const name = await this.opts.client.fetchRoomName(channelId, this.opts.asUserId)
|
|
181
|
+
return {
|
|
182
|
+
id: channelId,
|
|
183
|
+
name: name ?? channelId,
|
|
184
|
+
transport: 'matrix',
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|