@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.
@@ -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
+ }