@zooid/transport-matrix 0.7.1 → 0.7.3
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/dist/index.d.ts +78 -4
- package/dist/index.js +142 -13
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/bot-pool.test.ts +200 -13
- package/src/bot-pool.ts +73 -5
- package/src/index.ts +2 -2
- package/src/matrix-client.test.ts +184 -0
- package/src/matrix-client.ts +75 -0
- package/src/router.test.ts +3 -3
- package/src/router.ts +11 -2
- package/src/space-provisioner.test.ts +191 -1
- package/src/space-provisioner.ts +77 -7
- package/src/transport.test.ts +58 -2
- package/src/transport.ts +56 -2
- package/src/workforce-publisher.test.ts +12 -2
- package/src/workforce-publisher.ts +5 -1
package/src/matrix-client.ts
CHANGED
|
@@ -80,6 +80,20 @@ export class MatrixClient {
|
|
|
80
80
|
/** Optional `m.room.name`. When set, sent in the createRoom body so the
|
|
81
81
|
* room has a display name from the moment it exists. */
|
|
82
82
|
name?: string
|
|
83
|
+
/** When set, the room is created with a `restricted` join rule whose allow
|
|
84
|
+
* condition references this space room ID — i.e. joinable by space members
|
|
85
|
+
* only, rather than the whole homeserver. */
|
|
86
|
+
restrictedToSpaceId?: string
|
|
87
|
+
/** Explicit room version. Restricted join rules require v8+; omit to use the
|
|
88
|
+
* homeserver default (modern Tuwunel defaults to v10/v11). */
|
|
89
|
+
roomVersion?: string
|
|
90
|
+
/**
|
|
91
|
+
* Seeds `m.room.power_levels.users` at creation via
|
|
92
|
+
* `power_level_content_override.users`. The caller owns the full map —
|
|
93
|
+
* typically the AS bot at 100, plus operator and any agents with
|
|
94
|
+
* declared PLs. Empty/absent → no override (the preset's defaults apply).
|
|
95
|
+
*/
|
|
96
|
+
userPowerLevels?: Record<string, number>
|
|
83
97
|
}): Promise<string> {
|
|
84
98
|
const body: Record<string, unknown> = {
|
|
85
99
|
room_alias_name: opts.roomAliasName,
|
|
@@ -87,6 +101,22 @@ export class MatrixClient {
|
|
|
87
101
|
preset: opts.preset ?? 'public_chat',
|
|
88
102
|
}
|
|
89
103
|
if (opts.name !== undefined) body.name = opts.name
|
|
104
|
+
if (opts.roomVersion !== undefined) body.room_version = opts.roomVersion
|
|
105
|
+
if (opts.restrictedToSpaceId !== undefined) {
|
|
106
|
+
body.initial_state = [
|
|
107
|
+
{
|
|
108
|
+
type: 'm.room.join_rules',
|
|
109
|
+
state_key: '',
|
|
110
|
+
content: {
|
|
111
|
+
join_rule: 'restricted',
|
|
112
|
+
allow: [{ type: 'm.room_membership', room_id: opts.restrictedToSpaceId }],
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
if (opts.userPowerLevels && Object.keys(opts.userPowerLevels).length > 0) {
|
|
118
|
+
body.power_level_content_override = { users: opts.userPowerLevels }
|
|
119
|
+
}
|
|
90
120
|
const r = await this.fetch(
|
|
91
121
|
`${this.homeserver}/_matrix/client/v3/createRoom?user_id=${encodeURIComponent(opts.senderUserId)}`,
|
|
92
122
|
{
|
|
@@ -147,6 +177,51 @@ export class MatrixClient {
|
|
|
147
177
|
return (await r.json()) as { event_id: string }
|
|
148
178
|
}
|
|
149
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Invite a user to a room. Sent as the inviter (`asUserId`) — that user
|
|
182
|
+
* needs invite power in the room. Tolerates the "already in room /
|
|
183
|
+
* already invited" responses idempotently so bootstrap can run on a
|
|
184
|
+
* fresh AND a populated homeserver without branching.
|
|
185
|
+
*/
|
|
186
|
+
async invite(opts: {
|
|
187
|
+
roomId: string
|
|
188
|
+
asUserId: string
|
|
189
|
+
targetUserId: string
|
|
190
|
+
}): Promise<void> {
|
|
191
|
+
const url =
|
|
192
|
+
`${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/invite` +
|
|
193
|
+
`?user_id=${encodeURIComponent(opts.asUserId)}`
|
|
194
|
+
const r = await this.fetch(url, {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
headers: {
|
|
197
|
+
Authorization: `Bearer ${this.asToken}`,
|
|
198
|
+
'content-type': 'application/json',
|
|
199
|
+
},
|
|
200
|
+
body: JSON.stringify({ user_id: opts.targetUserId }),
|
|
201
|
+
})
|
|
202
|
+
if (r.ok) return
|
|
203
|
+
if (r.status === 403) {
|
|
204
|
+
// Tuwunel/Synapse use M_FORBIDDEN both for permission errors AND for
|
|
205
|
+
// "already a member / already invited". Inspect the body so we only
|
|
206
|
+
// swallow the idempotent case.
|
|
207
|
+
//
|
|
208
|
+
// Phrasings seen in the wild:
|
|
209
|
+
// - Synapse: "is already in the room", "is already invited"
|
|
210
|
+
// - Tuwunel: "cannot invite user that is joined or banned" (one
|
|
211
|
+
// string for both "joined" and "banned" — we can't tell which
|
|
212
|
+
// from the body; the bot-pool's outer try-catch surfaces a real
|
|
213
|
+
// ban via the subsequent joinRoom failure)
|
|
214
|
+
// - Generic: "X is already a member of the room"
|
|
215
|
+
const body = await r.text()
|
|
216
|
+
const idempotent =
|
|
217
|
+
/already (in the room|invited|a member|joined)/i.test(body) ||
|
|
218
|
+
/user that is joined/i.test(body)
|
|
219
|
+
if (idempotent) return
|
|
220
|
+
throw new Error(`invite(${opts.targetUserId}) failed: 403 ${body}`)
|
|
221
|
+
}
|
|
222
|
+
throw new Error(`invite(${opts.targetUserId}) failed: ${r.status}`)
|
|
223
|
+
}
|
|
224
|
+
|
|
150
225
|
async joinRoom(roomIdOrAlias: string, asUserId: string): Promise<void> {
|
|
151
226
|
const url =
|
|
152
227
|
`${this.homeserver}/_matrix/client/v3/join/${encodeURIComponent(roomIdOrAlias)}` +
|
package/src/router.test.ts
CHANGED
|
@@ -5,13 +5,13 @@ const agents: AgentBinding[] = [
|
|
|
5
5
|
{
|
|
6
6
|
name: 'architect',
|
|
7
7
|
userId: '@architect:example.com',
|
|
8
|
-
rooms: ['!room1:example.com'],
|
|
8
|
+
rooms: [{ alias: '!room1:example.com' }],
|
|
9
9
|
trigger: 'mention',
|
|
10
10
|
},
|
|
11
11
|
{
|
|
12
12
|
name: 'monitor',
|
|
13
13
|
userId: '@monitor:example.com',
|
|
14
|
-
rooms: ['!alerts:example.com'],
|
|
14
|
+
rooms: [{ alias: '!alerts:example.com' }],
|
|
15
15
|
trigger: 'any',
|
|
16
16
|
},
|
|
17
17
|
]
|
|
@@ -67,7 +67,7 @@ describe('route', () => {
|
|
|
67
67
|
{
|
|
68
68
|
name: 'qa',
|
|
69
69
|
userId: '@qa:example.com',
|
|
70
|
-
rooms: ['!room1:example.com'],
|
|
70
|
+
rooms: [{ alias: '!room1:example.com' }],
|
|
71
71
|
trigger: 'mention',
|
|
72
72
|
},
|
|
73
73
|
]
|
package/src/router.ts
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
|
+
import type { RoomBinding } from '@zooid/core'
|
|
1
2
|
import { extractMentions } from './mentions.js'
|
|
2
3
|
|
|
4
|
+
export type { RoomBinding }
|
|
5
|
+
|
|
3
6
|
export interface AgentBinding {
|
|
4
7
|
name: string
|
|
5
8
|
userId: string
|
|
6
9
|
/** Optional human-readable display name. Falls back to the user_id localpart. */
|
|
7
10
|
displayName?: string
|
|
8
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Rooms this agent is bound to. Each entry's `alias` starts out as the
|
|
13
|
+
* configured `#alias` (or `!id`) and is rewritten to the canonical room
|
|
14
|
+
* ID by `BotPool.bootstrap`. Optional `powerLevel` is seeded into the
|
|
15
|
+
* room's `m.room.power_levels.users` at room creation only.
|
|
16
|
+
*/
|
|
17
|
+
rooms: RoomBinding[]
|
|
9
18
|
trigger: 'mention' | 'any'
|
|
10
19
|
}
|
|
11
20
|
|
|
@@ -47,7 +56,7 @@ export function route(
|
|
|
47
56
|
|
|
48
57
|
for (const a of agents) {
|
|
49
58
|
if (event.sender === a.userId) continue
|
|
50
|
-
if (!a.rooms.
|
|
59
|
+
if (!a.rooms.some((r) => r.alias === event.room_id)) continue
|
|
51
60
|
if (a.trigger === 'any') {
|
|
52
61
|
matches.push(a)
|
|
53
62
|
continue
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest'
|
|
2
2
|
import { MatrixClient } from './matrix-client.js'
|
|
3
|
-
import { ensureWorkforceSpace, serverNameFromMxid } from './space-provisioner.js'
|
|
3
|
+
import { ensureDefaultChannel, ensureWorkforceSpace, serverNameFromMxid } from './space-provisioner.js'
|
|
4
4
|
|
|
5
5
|
function clientWithFetches(...handlers: Array<(url: string, init?: RequestInit) => Response>) {
|
|
6
6
|
let i = 0
|
|
@@ -74,6 +74,196 @@ describe('ensureWorkforceSpace', () => {
|
|
|
74
74
|
})
|
|
75
75
|
})
|
|
76
76
|
|
|
77
|
+
describe('ensureWorkforceSpace privacy', () => {
|
|
78
|
+
it('creates the space invite-only (overriding any public preset)', async () => {
|
|
79
|
+
const { client } = clientWithFetches(
|
|
80
|
+
() => new Response(JSON.stringify({ errcode: 'M_NOT_FOUND' }), { status: 404 }),
|
|
81
|
+
(url, init) => {
|
|
82
|
+
expect(url).toContain('/_matrix/client/v3/createRoom')
|
|
83
|
+
const body = JSON.parse(init!.body as string)
|
|
84
|
+
expect(body.creation_content).toEqual({ type: 'm.space' })
|
|
85
|
+
expect(body.initial_state).toContainEqual({
|
|
86
|
+
type: 'm.room.join_rules',
|
|
87
|
+
state_key: '',
|
|
88
|
+
content: { join_rule: 'invite' },
|
|
89
|
+
})
|
|
90
|
+
return new Response(JSON.stringify({ room_id: '!space:hs.zoon.local' }), { status: 200 })
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
const id = await ensureWorkforceSpace({
|
|
94
|
+
client,
|
|
95
|
+
asUserId: '@zooid:hs.zoon.local',
|
|
96
|
+
serverName: 'hs.zoon.local',
|
|
97
|
+
spaceLocalpart: 'dev',
|
|
98
|
+
preset: 'public_chat',
|
|
99
|
+
})
|
|
100
|
+
expect(id).toBe('!space:hs.zoon.local')
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('ensureWorkforceSpace admins', () => {
|
|
105
|
+
it('emits power_level_content_override with bot + admins at 100 on creation', async () => {
|
|
106
|
+
const { client } = clientWithFetches(
|
|
107
|
+
() => new Response(JSON.stringify({ errcode: 'M_NOT_FOUND' }), { status: 404 }),
|
|
108
|
+
(url, init) => {
|
|
109
|
+
expect(url).toContain('/_matrix/client/v3/createRoom')
|
|
110
|
+
const body = JSON.parse(init!.body as string)
|
|
111
|
+
expect(body.power_level_content_override).toEqual({
|
|
112
|
+
users: {
|
|
113
|
+
'@zooid:hs.zoon.local': 100,
|
|
114
|
+
'@admin:hs.zoon.local': 100,
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
return new Response(JSON.stringify({ room_id: '!space:hs.zoon.local' }), { status: 200 })
|
|
118
|
+
},
|
|
119
|
+
)
|
|
120
|
+
await ensureWorkforceSpace({
|
|
121
|
+
client,
|
|
122
|
+
asUserId: '@zooid:hs.zoon.local',
|
|
123
|
+
serverName: 'hs.zoon.local',
|
|
124
|
+
spaceLocalpart: 'dev',
|
|
125
|
+
preset: 'public_chat',
|
|
126
|
+
admins: ['@admin:hs.zoon.local'],
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('invites the admins so they can actually enter the invite-only space', async () => {
|
|
131
|
+
const { client } = clientWithFetches(
|
|
132
|
+
() => new Response(JSON.stringify({ errcode: 'M_NOT_FOUND' }), { status: 404 }),
|
|
133
|
+
(_url, init) => {
|
|
134
|
+
const body = JSON.parse(init!.body as string)
|
|
135
|
+
expect(body.invite).toEqual(['@admin:hs.zoon.local'])
|
|
136
|
+
return new Response(JSON.stringify({ room_id: '!space:hs.zoon.local' }), { status: 200 })
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
await ensureWorkforceSpace({
|
|
140
|
+
client,
|
|
141
|
+
asUserId: '@zooid:hs.zoon.local',
|
|
142
|
+
serverName: 'hs.zoon.local',
|
|
143
|
+
spaceLocalpart: 'dev',
|
|
144
|
+
preset: 'public_chat',
|
|
145
|
+
admins: ['@admin:hs.zoon.local'],
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('omits the override when admins is empty/absent', async () => {
|
|
150
|
+
const { client } = clientWithFetches(
|
|
151
|
+
() => new Response(JSON.stringify({ errcode: 'M_NOT_FOUND' }), { status: 404 }),
|
|
152
|
+
(_url, init) => {
|
|
153
|
+
const body = JSON.parse(init!.body as string)
|
|
154
|
+
expect(body.power_level_content_override).toBeUndefined()
|
|
155
|
+
return new Response(JSON.stringify({ room_id: '!space:hs.zoon.local' }), { status: 200 })
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
await ensureWorkforceSpace({
|
|
159
|
+
client,
|
|
160
|
+
asUserId: '@zooid:hs.zoon.local',
|
|
161
|
+
serverName: 'hs.zoon.local',
|
|
162
|
+
spaceLocalpart: 'dev',
|
|
163
|
+
preset: 'public_chat',
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('ensureDefaultChannel', () => {
|
|
169
|
+
it('returns the existing #general room when its alias resolves', async () => {
|
|
170
|
+
const { client, fetch } = clientWithFetches((url) => {
|
|
171
|
+
expect(url).toContain('/_matrix/client/v3/directory/room/%23general%3Ahs.zoon.local')
|
|
172
|
+
return new Response(JSON.stringify({ room_id: '!gen:hs.zoon.local' }), { status: 200 })
|
|
173
|
+
})
|
|
174
|
+
const id = await ensureDefaultChannel({
|
|
175
|
+
client,
|
|
176
|
+
asUserId: '@zooid:hs.zoon.local',
|
|
177
|
+
serverName: 'hs.zoon.local',
|
|
178
|
+
spaceId: '!space:hs.zoon.local',
|
|
179
|
+
channelLocalpart: 'general',
|
|
180
|
+
})
|
|
181
|
+
expect(id).toBe('!gen:hs.zoon.local')
|
|
182
|
+
expect(fetch).toHaveBeenCalledTimes(1)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('creates a restricted #general and attaches it to the space when absent', async () => {
|
|
186
|
+
const { client, fetch } = clientWithFetches(
|
|
187
|
+
() => new Response(JSON.stringify({ errcode: 'M_NOT_FOUND' }), { status: 404 }),
|
|
188
|
+
(url, init) => {
|
|
189
|
+
expect(url).toContain('/_matrix/client/v3/createRoom')
|
|
190
|
+
const body = JSON.parse(init!.body as string)
|
|
191
|
+
expect(body.room_alias_name).toBe('general')
|
|
192
|
+
expect(body.initial_state).toContainEqual({
|
|
193
|
+
type: 'm.room.join_rules',
|
|
194
|
+
state_key: '',
|
|
195
|
+
content: {
|
|
196
|
+
join_rule: 'restricted',
|
|
197
|
+
allow: [{ type: 'm.room_membership', room_id: '!space:hs.zoon.local' }],
|
|
198
|
+
},
|
|
199
|
+
})
|
|
200
|
+
return new Response(JSON.stringify({ room_id: '!gen:hs.zoon.local' }), { status: 200 })
|
|
201
|
+
},
|
|
202
|
+
(url, init) => {
|
|
203
|
+
expect(url).toContain(
|
|
204
|
+
'/_matrix/client/v3/rooms/!space%3Ahs.zoon.local/state/m.space.child/!gen%3Ahs.zoon.local',
|
|
205
|
+
)
|
|
206
|
+
expect(JSON.parse(init!.body as string)).toMatchObject({ via: ['hs.zoon.local'] })
|
|
207
|
+
return new Response(JSON.stringify({ event_id: '$e' }), { status: 200 })
|
|
208
|
+
},
|
|
209
|
+
)
|
|
210
|
+
const id = await ensureDefaultChannel({
|
|
211
|
+
client,
|
|
212
|
+
asUserId: '@zooid:hs.zoon.local',
|
|
213
|
+
serverName: 'hs.zoon.local',
|
|
214
|
+
spaceId: '!space:hs.zoon.local',
|
|
215
|
+
channelLocalpart: 'general',
|
|
216
|
+
})
|
|
217
|
+
expect(id).toBe('!gen:hs.zoon.local')
|
|
218
|
+
expect(fetch).toHaveBeenCalledTimes(3)
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
describe('ensureDefaultChannel admins', () => {
|
|
223
|
+
it('seeds operator + bot at PL 100 in the default channel on creation', async () => {
|
|
224
|
+
const { client } = clientWithFetches(
|
|
225
|
+
() => new Response(JSON.stringify({ errcode: 'M_NOT_FOUND' }), { status: 404 }),
|
|
226
|
+
(url, init) => {
|
|
227
|
+
expect(url).toContain('/_matrix/client/v3/createRoom')
|
|
228
|
+
const body = JSON.parse(init!.body as string)
|
|
229
|
+
expect(body.power_level_content_override).toEqual({
|
|
230
|
+
users: {
|
|
231
|
+
'@zooid:hs.zoon.local': 100,
|
|
232
|
+
'@admin:hs.zoon.local': 100,
|
|
233
|
+
},
|
|
234
|
+
})
|
|
235
|
+
return new Response(JSON.stringify({ room_id: '!gen:hs.zoon.local' }), { status: 200 })
|
|
236
|
+
},
|
|
237
|
+
() => new Response(JSON.stringify({ event_id: '$e' }), { status: 200 }),
|
|
238
|
+
)
|
|
239
|
+
await ensureDefaultChannel({
|
|
240
|
+
client,
|
|
241
|
+
asUserId: '@zooid:hs.zoon.local',
|
|
242
|
+
serverName: 'hs.zoon.local',
|
|
243
|
+
spaceId: '!space:hs.zoon.local',
|
|
244
|
+
admins: ['@admin:hs.zoon.local'],
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('omits the override when admins is empty/absent', async () => {
|
|
249
|
+
const { client } = clientWithFetches(
|
|
250
|
+
() => new Response(JSON.stringify({ errcode: 'M_NOT_FOUND' }), { status: 404 }),
|
|
251
|
+
(_url, init) => {
|
|
252
|
+
const body = JSON.parse(init!.body as string)
|
|
253
|
+
expect(body.power_level_content_override).toBeUndefined()
|
|
254
|
+
return new Response(JSON.stringify({ room_id: '!gen:hs.zoon.local' }), { status: 200 })
|
|
255
|
+
},
|
|
256
|
+
() => new Response(JSON.stringify({ event_id: '$e' }), { status: 200 }),
|
|
257
|
+
)
|
|
258
|
+
await ensureDefaultChannel({
|
|
259
|
+
client,
|
|
260
|
+
asUserId: '@zooid:hs.zoon.local',
|
|
261
|
+
serverName: 'hs.zoon.local',
|
|
262
|
+
spaceId: '!space:hs.zoon.local',
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
|
|
77
267
|
describe('serverNameFromMxid', () => {
|
|
78
268
|
it('returns the part after the first colon', () => {
|
|
79
269
|
expect(serverNameFromMxid('@zooid:zoon.local')).toBe('zoon.local')
|
package/src/space-provisioner.ts
CHANGED
|
@@ -6,6 +6,13 @@ export interface EnsureSpaceOpts {
|
|
|
6
6
|
serverName: string
|
|
7
7
|
spaceLocalpart: string
|
|
8
8
|
preset: 'public_chat' | 'private_chat'
|
|
9
|
+
/**
|
|
10
|
+
* Operator MXIDs to seed at PL 100 in the space's `m.room.power_levels`
|
|
11
|
+
* at creation. The AS bot is always included. Empty/absent → no override
|
|
12
|
+
* (the preset's PL defaults apply). Only consulted on first creation —
|
|
13
|
+
* if the alias already resolves we return the existing room untouched.
|
|
14
|
+
*/
|
|
15
|
+
admins?: string[]
|
|
9
16
|
}
|
|
10
17
|
|
|
11
18
|
export async function ensureWorkforceSpace(opts: EnsureSpaceOpts): Promise<string> {
|
|
@@ -14,15 +21,78 @@ export async function ensureWorkforceSpace(opts: EnsureSpaceOpts): Promise<strin
|
|
|
14
21
|
if (existing) return existing
|
|
15
22
|
|
|
16
23
|
const display = opts.spaceLocalpart.charAt(0).toUpperCase() + opts.spaceLocalpart.slice(1)
|
|
17
|
-
|
|
24
|
+
const body: Record<string, unknown> = {
|
|
25
|
+
room_alias_name: opts.spaceLocalpart,
|
|
26
|
+
name: display,
|
|
27
|
+
preset: opts.preset,
|
|
28
|
+
creation_content: { type: 'm.space' },
|
|
29
|
+
// A workspace is joined by invitation, not self-service. Pin the space's
|
|
30
|
+
// join rule to invite regardless of preset so it can't be walked into
|
|
31
|
+
// (which would otherwise satisfy every restricted child room's allow).
|
|
32
|
+
initial_state: [{ type: 'm.room.join_rules', state_key: '', content: { join_rule: 'invite' } }],
|
|
33
|
+
}
|
|
34
|
+
if (opts.admins && opts.admins.length > 0) {
|
|
35
|
+
// Invite each admin so they actually become members — PL 100 alone does
|
|
36
|
+
// not grant membership in an invite-only space.
|
|
37
|
+
body.invite = opts.admins
|
|
38
|
+
const users: Record<string, number> = { [opts.asUserId]: 100 }
|
|
39
|
+
for (const a of opts.admins) users[a] = 100
|
|
40
|
+
body.power_level_content_override = { users }
|
|
41
|
+
}
|
|
42
|
+
return opts.client.createRoomRaw({ asUserId: opts.asUserId, body })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface EnsureDefaultChannelOpts {
|
|
46
|
+
client: MatrixClient
|
|
47
|
+
asUserId: string
|
|
48
|
+
serverName: string
|
|
49
|
+
spaceId: string
|
|
50
|
+
/** Localpart of the default channel; defaults to `general`. */
|
|
51
|
+
channelLocalpart?: string
|
|
52
|
+
/**
|
|
53
|
+
* Operator MXIDs to seed at PL 100 in the channel's `m.room.power_levels`
|
|
54
|
+
* at creation. The AS bot is always included. Empty/absent → no override
|
|
55
|
+
* (the preset's PL defaults apply). Only consulted on first creation —
|
|
56
|
+
* if the alias already resolves we return the existing room untouched.
|
|
57
|
+
*/
|
|
58
|
+
admins?: string[]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Ensure a space has a default channel (`#general` by default), restricted to
|
|
63
|
+
* the space's members and attached as an `m.space.child`. Idempotent: returns
|
|
64
|
+
* the existing room if the alias already resolves. Has no agent — it's the
|
|
65
|
+
* human landing room, so it is created here at provisioning time rather than
|
|
66
|
+
* via the agent-room path.
|
|
67
|
+
*/
|
|
68
|
+
export async function ensureDefaultChannel(opts: EnsureDefaultChannelOpts): Promise<string> {
|
|
69
|
+
const localpart = opts.channelLocalpart ?? 'general'
|
|
70
|
+
const alias = `#${localpart}:${opts.serverName}`
|
|
71
|
+
const existing = await opts.client.resolveAlias(alias)
|
|
72
|
+
if (existing) return existing
|
|
73
|
+
|
|
74
|
+
let userPowerLevels: Record<string, number> | undefined
|
|
75
|
+
if (opts.admins && opts.admins.length > 0) {
|
|
76
|
+
userPowerLevels = { [opts.asUserId]: 100 }
|
|
77
|
+
for (const a of opts.admins) userPowerLevels[a] = 100
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const roomId = await opts.client.createRoom({
|
|
81
|
+
roomAliasName: localpart,
|
|
82
|
+
invite: [],
|
|
83
|
+
senderUserId: opts.asUserId,
|
|
84
|
+
name: localpart.charAt(0).toUpperCase() + localpart.slice(1),
|
|
85
|
+
restrictedToSpaceId: opts.spaceId,
|
|
86
|
+
...(userPowerLevels ? { userPowerLevels } : {}),
|
|
87
|
+
})
|
|
88
|
+
await opts.client.sendStateEvent({
|
|
89
|
+
roomId: opts.spaceId,
|
|
18
90
|
asUserId: opts.asUserId,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
preset: opts.preset,
|
|
23
|
-
creation_content: { type: 'm.space' },
|
|
24
|
-
},
|
|
91
|
+
eventType: 'm.space.child',
|
|
92
|
+
stateKey: roomId,
|
|
93
|
+
content: { via: [opts.serverName] },
|
|
25
94
|
})
|
|
95
|
+
return roomId
|
|
26
96
|
}
|
|
27
97
|
|
|
28
98
|
export function serverNameFromMxid(mxid: string): string {
|
package/src/transport.test.ts
CHANGED
|
@@ -51,12 +51,12 @@ const baseAgents = [
|
|
|
51
51
|
{
|
|
52
52
|
name: 'architect',
|
|
53
53
|
userId: '@architect:example.com',
|
|
54
|
-
rooms: ['!r:example.com'],
|
|
54
|
+
rooms: [{ alias: '!r:example.com' }],
|
|
55
55
|
trigger: 'mention' as const,
|
|
56
56
|
},
|
|
57
57
|
]
|
|
58
58
|
|
|
59
|
-
function makeTransport() {
|
|
59
|
+
function makeTransport(drain?: { drainQuietMs?: number; drainMaxMs?: number }) {
|
|
60
60
|
const { reg, finishPrompt } = fakeRegistry()
|
|
61
61
|
const approvals = fakeApprovals()
|
|
62
62
|
const client = fakeClient()
|
|
@@ -66,6 +66,10 @@ function makeTransport() {
|
|
|
66
66
|
client: client as never,
|
|
67
67
|
bindings: baseAgents,
|
|
68
68
|
hsToken: 'hs-secret',
|
|
69
|
+
// Disable post-turn drain by default so settleTurn (microtasks) suffices.
|
|
70
|
+
// Tests covering trailing-chunk behavior pass an explicit window.
|
|
71
|
+
drainQuietMs: drain?.drainQuietMs ?? 0,
|
|
72
|
+
drainMaxMs: drain?.drainMaxMs,
|
|
69
73
|
})
|
|
70
74
|
return { transport, agents: reg, approvals, client, finishPrompt }
|
|
71
75
|
}
|
|
@@ -145,6 +149,58 @@ describe('matrix transport /transactions', () => {
|
|
|
145
149
|
)
|
|
146
150
|
})
|
|
147
151
|
|
|
152
|
+
it('drains trailing agent_message_chunks that arrive after prompt() resolves', async () => {
|
|
153
|
+
// ACP doesn't guarantee all session/update chunks precede the prompt
|
|
154
|
+
// response for a normal turn; opencode flushes a trailing chunk just after
|
|
155
|
+
// the stopReason. The post-turn drain must wait for it instead of sending
|
|
156
|
+
// the truncated buffer.
|
|
157
|
+
const { transport, agents, client } = makeTransport({ drainQuietMs: 20, drainMaxMs: 500 })
|
|
158
|
+
const events = [
|
|
159
|
+
{
|
|
160
|
+
type: 'm.room.message',
|
|
161
|
+
event_id: '$root',
|
|
162
|
+
room_id: '!r:example.com',
|
|
163
|
+
sender: '@alice:example.com',
|
|
164
|
+
content: {
|
|
165
|
+
msgtype: 'm.text',
|
|
166
|
+
body: 'hi',
|
|
167
|
+
'm.mentions': { user_ids: ['@architect:example.com'] },
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
]
|
|
171
|
+
agents.prompt.mockImplementation(async (_name: string, p: { threadId: string }) => {
|
|
172
|
+
const sessionId = 'sess-' + p.threadId
|
|
173
|
+
// First chunk arrives during the turn…
|
|
174
|
+
agents.onEvent('architect', {
|
|
175
|
+
type: 'agent_message_chunk',
|
|
176
|
+
sessionId,
|
|
177
|
+
content: { type: 'text', text: 'Hi.' },
|
|
178
|
+
})
|
|
179
|
+
// …a second chunk lands shortly AFTER the prompt response resolves.
|
|
180
|
+
setTimeout(() => {
|
|
181
|
+
agents.onEvent('architect', {
|
|
182
|
+
type: 'agent_message_chunk',
|
|
183
|
+
sessionId,
|
|
184
|
+
content: { type: 'text', text: ' How can I help?' },
|
|
185
|
+
})
|
|
186
|
+
}, 5)
|
|
187
|
+
return { stopReason: 'end_turn' as const }
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const res = await postTxn(transport.app, { events })
|
|
191
|
+
expect(res.status).toBe(200)
|
|
192
|
+
// Wait past the drain window for the turn to finalize.
|
|
193
|
+
await new Promise((r) => setTimeout(r, 150))
|
|
194
|
+
|
|
195
|
+
// Exactly one message, containing BOTH the early and the late chunk.
|
|
196
|
+
expect(client.sendMessage).toHaveBeenCalledTimes(1)
|
|
197
|
+
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
198
|
+
expect.objectContaining({
|
|
199
|
+
content: expect.objectContaining({ body: 'Hi. How can I help?' }),
|
|
200
|
+
}),
|
|
201
|
+
)
|
|
202
|
+
})
|
|
203
|
+
|
|
148
204
|
it('uses an in-thread message event_id as the thread root when one is set', async () => {
|
|
149
205
|
const { transport, agents, client } = makeTransport()
|
|
150
206
|
const events = [
|
package/src/transport.ts
CHANGED
|
@@ -18,6 +18,12 @@ export interface CreateMatrixTransportOptions {
|
|
|
18
18
|
hsToken: string
|
|
19
19
|
/** Admin Matrix user ID. When set, BotPool.bootstrap invites this user into rooms it creates. */
|
|
20
20
|
adminUserId?: string
|
|
21
|
+
/** Post-turn drain: keep collecting trailing `agent_message_chunk`s until the
|
|
22
|
+
* buffer is quiet for this long before flushing. Defaults to `DRAIN_QUIET_MS`.
|
|
23
|
+
* Set to 0 to disable the drain (e.g. in tests). */
|
|
24
|
+
drainQuietMs?: number
|
|
25
|
+
/** Hard cap on the post-turn drain. Defaults to `DRAIN_MAX_MS`. */
|
|
26
|
+
drainMaxMs?: number
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
interface SessionContext {
|
|
@@ -43,6 +49,25 @@ interface MatrixEvent {
|
|
|
43
49
|
const STARTUP_GRACE_MS = 5_000
|
|
44
50
|
const SEEN_EVENT_CAP = 5_000
|
|
45
51
|
|
|
52
|
+
// ACP only guarantees that an agent flushes pending `session/update`
|
|
53
|
+
// notifications before the `session/prompt` response in the *cancellation*
|
|
54
|
+
// path; for a normal turn the ordering is unspecified. Some agents (e.g.
|
|
55
|
+
// opencode) emit trailing `agent_message_chunk`s a few ms after the stopReason
|
|
56
|
+
// response, so finalizing the moment `prompt()` resolves truncates the reply.
|
|
57
|
+
// After the turn resolves we wait for the buffer to stay unchanged for
|
|
58
|
+
// DRAIN_QUIET_MS (debounce — re-arms on each late chunk) before flushing,
|
|
59
|
+
// capped at DRAIN_MAX_MS so a misbehaving stream can't hang the turn.
|
|
60
|
+
const DRAIN_QUIET_MS = 300
|
|
61
|
+
// 30s upper bound on how long we wait after `session/prompt` resolves before
|
|
62
|
+
// flushing whatever we have (or declaring an empty turn). Set high because
|
|
63
|
+
// some agents — opencode especially — resolve the prompt promise *before*
|
|
64
|
+
// the agent_message_chunk stream starts, and the chunk burst can be 5–15s
|
|
65
|
+
// after that. The drain still short-circuits via DRAIN_QUIET_MS once any
|
|
66
|
+
// content has settled, so this cap only kicks in for genuinely-stuck turns.
|
|
67
|
+
const DRAIN_MAX_MS = 30_000
|
|
68
|
+
|
|
69
|
+
const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
|
|
70
|
+
|
|
46
71
|
function inboundThreadRoot(evt: MatrixEvent): string | undefined {
|
|
47
72
|
const r = evt.content?.['m.relates_to']
|
|
48
73
|
return r?.rel_type === 'm.thread' && r.event_id ? r.event_id : undefined
|
|
@@ -50,6 +75,8 @@ function inboundThreadRoot(evt: MatrixEvent): string | undefined {
|
|
|
50
75
|
|
|
51
76
|
export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
52
77
|
const { agents, approvals, client, bindings, hsToken, adminUserId } = opts
|
|
78
|
+
const drainQuietMs = opts.drainQuietMs ?? DRAIN_QUIET_MS
|
|
79
|
+
const drainMaxMs = opts.drainMaxMs ?? DRAIN_MAX_MS
|
|
53
80
|
const pool = new BotPool(client, bindings)
|
|
54
81
|
const sessions = new Map<string, SessionContext>()
|
|
55
82
|
const buffers = new Map<string, string>()
|
|
@@ -418,6 +445,27 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
418
445
|
channelId: evt.room_id,
|
|
419
446
|
content: [{ type: 'text', text: promptText }],
|
|
420
447
|
})
|
|
448
|
+
// Drain: the prompt promise resolves on the stopReason response, but
|
|
449
|
+
// trailing chunks may still arrive (see DRAIN_* above). Wait until the
|
|
450
|
+
// buffer is quiet for DRAIN_QUIET_MS, re-arming on each new chunk.
|
|
451
|
+
//
|
|
452
|
+
// Subtlety: some agents (opencode in particular) resolve `session/prompt`
|
|
453
|
+
// *before* the agent_message_chunk stream starts. So the buffer can be
|
|
454
|
+
// empty for several seconds after prompt resolves, and only then do the
|
|
455
|
+
// chunks arrive. We can't break the drain just because the buffer is
|
|
456
|
+
// empty — we have to wait up to drainMaxMs for chunks to *start*. Once
|
|
457
|
+
// any content arrives, the "quiet for drainQuietMs" rule kicks in.
|
|
458
|
+
const drainStart = Date.now()
|
|
459
|
+
let drained = buffers.get(sessionId) ?? ''
|
|
460
|
+
while (drainQuietMs > 0 && Date.now() - drainStart < drainMaxMs) {
|
|
461
|
+
await delay(drainQuietMs)
|
|
462
|
+
const next = buffers.get(sessionId) ?? ''
|
|
463
|
+
// Stop only when we have content AND it hasn't grown — i.e. the
|
|
464
|
+
// generation has actually started and is now done. An unchanged
|
|
465
|
+
// empty buffer means the stream hasn't started yet; keep waiting.
|
|
466
|
+
if (next === drained && next.length > 0) break
|
|
467
|
+
drained = next
|
|
468
|
+
}
|
|
421
469
|
const text = buffers.get(sessionId) ?? ''
|
|
422
470
|
if (text.length > 0) {
|
|
423
471
|
const html = toMatrixHtml(text)
|
|
@@ -464,7 +512,13 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
464
512
|
|
|
465
513
|
return {
|
|
466
514
|
app,
|
|
467
|
-
bootstrap: async (
|
|
515
|
+
bootstrap: async (
|
|
516
|
+
bootstrapOpts: {
|
|
517
|
+
spaceRoomId?: string
|
|
518
|
+
asUserId?: string
|
|
519
|
+
adminUserIds?: string[]
|
|
520
|
+
} = {},
|
|
521
|
+
) => {
|
|
468
522
|
await pool.bootstrap({ adminUserId, ...bootstrapOpts })
|
|
469
523
|
await Promise.allSettled(
|
|
470
524
|
bindings.map((b) =>
|
|
@@ -494,7 +548,7 @@ export async function rebuildThreadState(
|
|
|
494
548
|
// Impersonate an agent that's actually a member of this room (AS reads
|
|
495
549
|
// require room membership). Falling through to the first binding would
|
|
496
550
|
// 403 if that agent never joined the target room.
|
|
497
|
-
const asUser = (bindings.find((b) => b.rooms.
|
|
551
|
+
const asUser = (bindings.find((b) => b.rooms.some((r) => r.alias === roomId)) ?? bindings[0])?.userId
|
|
498
552
|
if (!asUser) return state
|
|
499
553
|
|
|
500
554
|
const root = await client.fetchEvent(roomId, rootEventId, asUser)
|
|
@@ -4,8 +4,18 @@ import { buildWorkforceRoster, publishWorkforce } from './workforce-publisher.js
|
|
|
4
4
|
import type { AgentBinding } from './router.js'
|
|
5
5
|
|
|
6
6
|
const agents: AgentBinding[] = [
|
|
7
|
-
{
|
|
8
|
-
|
|
7
|
+
{
|
|
8
|
+
name: 'planner',
|
|
9
|
+
userId: '@planner:zoon.local',
|
|
10
|
+
rooms: [{ alias: '!eng:zoon.local' }],
|
|
11
|
+
trigger: 'mention',
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: 'reviewer',
|
|
15
|
+
userId: '@reviewer:zoon.local',
|
|
16
|
+
rooms: [{ alias: '!eng:zoon.local' }, { alias: '!review:zoon.local' }],
|
|
17
|
+
trigger: 'any',
|
|
18
|
+
},
|
|
9
19
|
]
|
|
10
20
|
|
|
11
21
|
describe('buildWorkforceRoster', () => {
|
|
@@ -9,7 +9,11 @@ export interface WorkforceRoster {
|
|
|
9
9
|
export function buildWorkforceRoster(agents: AgentBinding[]): WorkforceRoster {
|
|
10
10
|
return {
|
|
11
11
|
version: 1,
|
|
12
|
-
agents: agents.map((a) => ({
|
|
12
|
+
agents: agents.map((a) => ({
|
|
13
|
+
user_id: a.userId,
|
|
14
|
+
name: a.name,
|
|
15
|
+
rooms: a.rooms.map((r) => r.alias),
|
|
16
|
+
})),
|
|
13
17
|
}
|
|
14
18
|
}
|
|
15
19
|
|