@zooid/transport-matrix 0.7.0 → 0.7.2
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 +194 -14
- 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/event-encoders.test.ts +86 -0
- package/src/event-encoders.ts +29 -0
- package/src/index.ts +2 -2
- package/src/matrix-client.test.ts +160 -0
- package/src/matrix-client.ts +64 -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 +72 -4
- package/src/workforce-publisher.test.ts +12 -2
- package/src/workforce-publisher.ts +5 -1
|
@@ -305,3 +305,163 @@ describe('MatrixClient', () => {
|
|
|
305
305
|
})
|
|
306
306
|
})
|
|
307
307
|
})
|
|
308
|
+
|
|
309
|
+
describe('MatrixClient.invite', () => {
|
|
310
|
+
it('POSTs to /invite with the user_id payload, impersonating the inviter', async () => {
|
|
311
|
+
const fetch = fakeFetch(async ({ url, init }) => {
|
|
312
|
+
expect(url).toBe(
|
|
313
|
+
'https://hs.example.com/_matrix/client/v3/rooms/!space%3Aexample.com/invite' +
|
|
314
|
+
'?user_id=%40zooid%3Aexample.com',
|
|
315
|
+
)
|
|
316
|
+
expect(init.method).toBe('POST')
|
|
317
|
+
expect(JSON.parse(init.body as string)).toEqual({ user_id: '@planner:example.com' })
|
|
318
|
+
return new Response('{}', { status: 200 })
|
|
319
|
+
})
|
|
320
|
+
const client = new MatrixClient({
|
|
321
|
+
homeserver: 'https://hs.example.com',
|
|
322
|
+
asToken: 'as',
|
|
323
|
+
fetch: fetch as unknown as typeof globalThis.fetch,
|
|
324
|
+
})
|
|
325
|
+
await client.invite({
|
|
326
|
+
roomId: '!space:example.com',
|
|
327
|
+
asUserId: '@zooid:example.com',
|
|
328
|
+
targetUserId: '@planner:example.com',
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('silently swallows the idempotent "already in the room" 403', async () => {
|
|
333
|
+
const fetch = fakeFetch(async () =>
|
|
334
|
+
new Response(
|
|
335
|
+
JSON.stringify({ errcode: 'M_FORBIDDEN', error: 'User is already in the room' }),
|
|
336
|
+
{ status: 403 },
|
|
337
|
+
),
|
|
338
|
+
)
|
|
339
|
+
const client = new MatrixClient({
|
|
340
|
+
homeserver: 'https://hs.example.com',
|
|
341
|
+
asToken: 'as',
|
|
342
|
+
fetch: fetch as unknown as typeof globalThis.fetch,
|
|
343
|
+
})
|
|
344
|
+
await expect(
|
|
345
|
+
client.invite({
|
|
346
|
+
roomId: '!r:example.com',
|
|
347
|
+
asUserId: '@zooid:example.com',
|
|
348
|
+
targetUserId: '@planner:example.com',
|
|
349
|
+
}),
|
|
350
|
+
).resolves.toBeUndefined()
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('rethrows a real permission 403 (no idempotency phrase in body)', async () => {
|
|
354
|
+
const fetch = fakeFetch(async () =>
|
|
355
|
+
new Response(
|
|
356
|
+
JSON.stringify({ errcode: 'M_FORBIDDEN', error: 'You do not have power to invite' }),
|
|
357
|
+
{ status: 403 },
|
|
358
|
+
),
|
|
359
|
+
)
|
|
360
|
+
const client = new MatrixClient({
|
|
361
|
+
homeserver: 'https://hs.example.com',
|
|
362
|
+
asToken: 'as',
|
|
363
|
+
fetch: fetch as unknown as typeof globalThis.fetch,
|
|
364
|
+
})
|
|
365
|
+
await expect(
|
|
366
|
+
client.invite({
|
|
367
|
+
roomId: '!r:example.com',
|
|
368
|
+
asUserId: '@zooid:example.com',
|
|
369
|
+
targetUserId: '@planner:example.com',
|
|
370
|
+
}),
|
|
371
|
+
).rejects.toThrow(/invite/)
|
|
372
|
+
})
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
describe('MatrixClient.createRoom userPowerLevels', () => {
|
|
376
|
+
it('forwards userPowerLevels into power_level_content_override.users', async () => {
|
|
377
|
+
const fetch = fakeFetch(async ({ init }) => {
|
|
378
|
+
const body = JSON.parse(init.body as string)
|
|
379
|
+
expect(body.power_level_content_override).toEqual({
|
|
380
|
+
users: {
|
|
381
|
+
'@zooid:example.com': 100,
|
|
382
|
+
'@admin:example.com': 100,
|
|
383
|
+
'@mod:example.com': 50,
|
|
384
|
+
},
|
|
385
|
+
})
|
|
386
|
+
return new Response(JSON.stringify({ room_id: '!r:example.com' }), { status: 200 })
|
|
387
|
+
})
|
|
388
|
+
const client = new MatrixClient({
|
|
389
|
+
homeserver: 'https://hs.example.com',
|
|
390
|
+
asToken: 'as',
|
|
391
|
+
fetch: fetch as unknown as typeof globalThis.fetch,
|
|
392
|
+
})
|
|
393
|
+
await client.createRoom({
|
|
394
|
+
roomAliasName: 'x',
|
|
395
|
+
invite: [],
|
|
396
|
+
senderUserId: '@zooid:example.com',
|
|
397
|
+
userPowerLevels: {
|
|
398
|
+
'@zooid:example.com': 100,
|
|
399
|
+
'@admin:example.com': 100,
|
|
400
|
+
'@mod:example.com': 50,
|
|
401
|
+
},
|
|
402
|
+
})
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
it('omits power_level_content_override when userPowerLevels is empty or absent', async () => {
|
|
406
|
+
const fetch = fakeFetch(async ({ init }) => {
|
|
407
|
+
const body = JSON.parse(init.body as string)
|
|
408
|
+
expect(body.power_level_content_override).toBeUndefined()
|
|
409
|
+
return new Response(JSON.stringify({ room_id: '!r:example.com' }), { status: 200 })
|
|
410
|
+
})
|
|
411
|
+
const client = new MatrixClient({
|
|
412
|
+
homeserver: 'https://hs.example.com',
|
|
413
|
+
asToken: 'as',
|
|
414
|
+
fetch: fetch as unknown as typeof globalThis.fetch,
|
|
415
|
+
})
|
|
416
|
+
await client.createRoom({
|
|
417
|
+
roomAliasName: 'x',
|
|
418
|
+
invite: [],
|
|
419
|
+
senderUserId: '@zooid:example.com',
|
|
420
|
+
})
|
|
421
|
+
})
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
describe('MatrixClient.createRoom restricted', () => {
|
|
425
|
+
it('injects a restricted join rule referencing the space when restrictedToSpaceId is set', async () => {
|
|
426
|
+
const fetch = fakeFetch(async ({ url, init }) => {
|
|
427
|
+
expect(url).toContain('/_matrix/client/v3/createRoom')
|
|
428
|
+
const body = JSON.parse(init.body as string)
|
|
429
|
+
expect(body.room_alias_name).toBe('design')
|
|
430
|
+
expect(body.initial_state).toContainEqual({
|
|
431
|
+
type: 'm.room.join_rules',
|
|
432
|
+
state_key: '',
|
|
433
|
+
content: {
|
|
434
|
+
join_rule: 'restricted',
|
|
435
|
+
allow: [{ type: 'm.room_membership', room_id: '!space:example.com' }],
|
|
436
|
+
},
|
|
437
|
+
})
|
|
438
|
+
return new Response(JSON.stringify({ room_id: '!new:example.com' }), { status: 200 })
|
|
439
|
+
})
|
|
440
|
+
const client = new MatrixClient({
|
|
441
|
+
homeserver: 'https://hs.example.com',
|
|
442
|
+
asToken: 'as-secret',
|
|
443
|
+
fetch: fetch as unknown as typeof globalThis.fetch,
|
|
444
|
+
})
|
|
445
|
+
const id = await client.createRoom({
|
|
446
|
+
roomAliasName: 'design',
|
|
447
|
+
invite: [],
|
|
448
|
+
senderUserId: '@architect:example.com',
|
|
449
|
+
restrictedToSpaceId: '!space:example.com',
|
|
450
|
+
})
|
|
451
|
+
expect(id).toBe('!new:example.com')
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
it('omits the restricted join rule when no space is given (public room, unchanged)', async () => {
|
|
455
|
+
const fetch = fakeFetch(async ({ init }) => {
|
|
456
|
+
const body = JSON.parse(init.body as string)
|
|
457
|
+
expect(body.initial_state).toBeUndefined()
|
|
458
|
+
return new Response(JSON.stringify({ room_id: '!r:example.com' }), { status: 200 })
|
|
459
|
+
})
|
|
460
|
+
const client = new MatrixClient({
|
|
461
|
+
homeserver: 'https://hs.example.com',
|
|
462
|
+
asToken: 'as-secret',
|
|
463
|
+
fetch: fetch as unknown as typeof globalThis.fetch,
|
|
464
|
+
})
|
|
465
|
+
await client.createRoom({ roomAliasName: 'x', invite: [], senderUserId: '@a:example.com' })
|
|
466
|
+
})
|
|
467
|
+
})
|
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,40 @@ 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
|
+
const body = await r.text()
|
|
208
|
+
if (/already (in the room|invited|a member|joined)/i.test(body)) return
|
|
209
|
+
throw new Error(`invite(${opts.targetUserId}) failed: 403 ${body}`)
|
|
210
|
+
}
|
|
211
|
+
throw new Error(`invite(${opts.targetUserId}) failed: ${r.status}`)
|
|
212
|
+
}
|
|
213
|
+
|
|
150
214
|
async joinRoom(roomIdOrAlias: string, asUserId: string): Promise<void> {
|
|
151
215
|
const url =
|
|
152
216
|
`${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 {
|