@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/src/router.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { extractMentions } from './mentions.js'
2
+
3
+ export interface AgentBinding {
4
+ name: string
5
+ userId: string
6
+ /** Optional human-readable display name. Falls back to the user_id localpart. */
7
+ displayName?: string
8
+ rooms: string[]
9
+ trigger: 'mention' | 'any'
10
+ }
11
+
12
+ export interface ThreadState {
13
+ /** Agent names that have posted in this thread, in order. */
14
+ participants: string[]
15
+ /** Agent names @mentioned in the thread root event (or subsequently). */
16
+ rootMentions: string[]
17
+ }
18
+
19
+ interface MaybeEvent {
20
+ type?: string
21
+ room_id?: string
22
+ sender?: string
23
+ content?: {
24
+ msgtype?: string
25
+ 'm.relates_to'?: { rel_type?: string; event_id?: string }
26
+ }
27
+ }
28
+
29
+ export type RouteMatch = AgentBinding
30
+
31
+ function inboundThreadRoot(event: MaybeEvent): string | undefined {
32
+ const r = event.content?.['m.relates_to']
33
+ return r?.rel_type === 'm.thread' && r.event_id ? r.event_id : undefined
34
+ }
35
+
36
+ export function route(
37
+ event: MaybeEvent,
38
+ agents: AgentBinding[],
39
+ threadStates?: Map<string, ThreadState>,
40
+ ): RouteMatch[] {
41
+ if (event.type !== 'm.room.message') return []
42
+ if (!event.content?.msgtype) return []
43
+ const mentions = new Set(extractMentions(event as never))
44
+ const matches: RouteMatch[] = []
45
+ const threadRoot = inboundThreadRoot(event)
46
+ const threadState = threadRoot ? threadStates?.get(threadRoot) : undefined
47
+
48
+ for (const a of agents) {
49
+ if (event.sender === a.userId) continue
50
+ if (!a.rooms.includes(event.room_id ?? '')) continue
51
+ if (a.trigger === 'any') {
52
+ matches.push(a)
53
+ continue
54
+ }
55
+ // trigger === 'mention'
56
+ if (mentions.has(a.userId)) {
57
+ matches.push(a)
58
+ continue
59
+ }
60
+ // Implicit trigger in a thread: most-recent-poster, or root-mention
61
+ // inheritance if no agent has posted yet.
62
+ if (threadState) {
63
+ const lastPoster = threadState.participants.at(-1)
64
+ if (lastPoster) {
65
+ if (lastPoster === a.name) matches.push(a)
66
+ } else if (threadState.rootMentions.includes(a.name)) {
67
+ matches.push(a)
68
+ }
69
+ }
70
+ }
71
+ return matches
72
+ }
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { MatrixClient } from './matrix-client.js'
3
+ import { ensureWorkforceSpace, serverNameFromMxid } from './space-provisioner.js'
4
+
5
+ function clientWithFetches(...handlers: Array<(url: string, init?: RequestInit) => Response>) {
6
+ let i = 0
7
+ const fetch = vi.fn(async (url: string, init?: RequestInit) => {
8
+ const h = handlers[i++]
9
+ if (!h) throw new Error(`unexpected fetch #${i}: ${url}`)
10
+ return h(url, init)
11
+ })
12
+ const client = new MatrixClient({
13
+ homeserver: 'https://hs.zoon.local',
14
+ asToken: 'as-tok',
15
+ fetch: fetch as unknown as typeof globalThis.fetch,
16
+ })
17
+ return { client, fetch }
18
+ }
19
+
20
+ describe('ensureWorkforceSpace', () => {
21
+ it('returns the room ID when the alias already resolves', async () => {
22
+ const { client, fetch } = clientWithFetches((url) => {
23
+ expect(url).toContain('/_matrix/client/v3/directory/room/%23dev%3Ahs.zoon.local')
24
+ return new Response(JSON.stringify({ room_id: '!existing:hs.zoon.local' }), { status: 200 })
25
+ })
26
+ const id = await ensureWorkforceSpace({
27
+ client,
28
+ asUserId: '@zooid:hs.zoon.local',
29
+ serverName: 'hs.zoon.local',
30
+ spaceLocalpart: 'dev',
31
+ preset: 'public_chat',
32
+ })
33
+ expect(id).toBe('!existing:hs.zoon.local')
34
+ expect(fetch).toHaveBeenCalledTimes(1)
35
+ })
36
+
37
+ it('creates the space when the alias is unknown', async () => {
38
+ const { client, fetch } = clientWithFetches(
39
+ () => new Response(JSON.stringify({ errcode: 'M_NOT_FOUND' }), { status: 404 }),
40
+ (url, init) => {
41
+ expect(url).toContain('/_matrix/client/v3/createRoom')
42
+ const body = JSON.parse(init!.body as string)
43
+ expect(body).toMatchObject({
44
+ room_alias_name: 'dev',
45
+ name: 'Dev',
46
+ preset: 'public_chat',
47
+ creation_content: { type: 'm.space' },
48
+ })
49
+ return new Response(JSON.stringify({ room_id: '!new:hs.zoon.local' }), { status: 200 })
50
+ },
51
+ )
52
+ const id = await ensureWorkforceSpace({
53
+ client,
54
+ asUserId: '@zooid:hs.zoon.local',
55
+ serverName: 'hs.zoon.local',
56
+ spaceLocalpart: 'dev',
57
+ preset: 'public_chat',
58
+ })
59
+ expect(id).toBe('!new:hs.zoon.local')
60
+ expect(fetch).toHaveBeenCalledTimes(2)
61
+ })
62
+
63
+ it('throws on resolveAlias errors other than 404', async () => {
64
+ const { client } = clientWithFetches(() => new Response('boom', { status: 500 }))
65
+ await expect(
66
+ ensureWorkforceSpace({
67
+ client,
68
+ asUserId: '@zooid:hs.zoon.local',
69
+ serverName: 'hs.zoon.local',
70
+ spaceLocalpart: 'dev',
71
+ preset: 'public_chat',
72
+ }),
73
+ ).rejects.toThrow(/500/)
74
+ })
75
+ })
76
+
77
+ describe('serverNameFromMxid', () => {
78
+ it('returns the part after the first colon', () => {
79
+ expect(serverNameFromMxid('@zooid:zoon.local')).toBe('zoon.local')
80
+ })
81
+
82
+ it('handles federated server names with ports', () => {
83
+ expect(serverNameFromMxid('@zooid:hs.example.com:8448')).toBe('hs.example.com:8448')
84
+ })
85
+
86
+ it('throws on an mxid without a server', () => {
87
+ expect(() => serverNameFromMxid('@zooid')).toThrow(/server/)
88
+ })
89
+ })
@@ -0,0 +1,34 @@
1
+ import { MatrixClient } from './matrix-client.js'
2
+
3
+ export interface EnsureSpaceOpts {
4
+ client: MatrixClient
5
+ asUserId: string
6
+ serverName: string
7
+ spaceLocalpart: string
8
+ preset: 'public_chat' | 'private_chat'
9
+ }
10
+
11
+ export async function ensureWorkforceSpace(opts: EnsureSpaceOpts): Promise<string> {
12
+ const alias = `#${opts.spaceLocalpart}:${opts.serverName}`
13
+ const existing = await opts.client.resolveAlias(alias)
14
+ if (existing) return existing
15
+
16
+ const display = opts.spaceLocalpart.charAt(0).toUpperCase() + opts.spaceLocalpart.slice(1)
17
+ return opts.client.createRoomRaw({
18
+ asUserId: opts.asUserId,
19
+ body: {
20
+ room_alias_name: opts.spaceLocalpart,
21
+ name: display,
22
+ preset: opts.preset,
23
+ creation_content: { type: 'm.space' },
24
+ },
25
+ })
26
+ }
27
+
28
+ export function serverNameFromMxid(mxid: string): string {
29
+ const colon = mxid.indexOf(':')
30
+ if (colon < 0) {
31
+ throw new Error(`mxid lacks server: ${mxid}`)
32
+ }
33
+ return mxid.slice(colon + 1)
34
+ }