@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 CHANGED
@@ -1,4 +1,4 @@
1
- import { TransportContextProvider, HistoryOptions, HistoryPage, ThreadOverviewPage, Member, ChannelInfo, AcpRegistry, ApprovalCorrelator } from '@zooid/core';
1
+ import { TransportContextProvider, HistoryOptions, HistoryPage, ThreadOverviewPage, Member, ChannelInfo, RoomBinding, AcpRegistry, ApprovalCorrelator } from '@zooid/core';
2
2
  import * as hono_types from 'hono/types';
3
3
  import { Hono } from 'hono';
4
4
 
@@ -53,6 +53,20 @@ declare class MatrixClient {
53
53
  /** Optional `m.room.name`. When set, sent in the createRoom body so the
54
54
  * room has a display name from the moment it exists. */
55
55
  name?: string;
56
+ /** When set, the room is created with a `restricted` join rule whose allow
57
+ * condition references this space room ID — i.e. joinable by space members
58
+ * only, rather than the whole homeserver. */
59
+ restrictedToSpaceId?: string;
60
+ /** Explicit room version. Restricted join rules require v8+; omit to use the
61
+ * homeserver default (modern Tuwunel defaults to v10/v11). */
62
+ roomVersion?: string;
63
+ /**
64
+ * Seeds `m.room.power_levels.users` at creation via
65
+ * `power_level_content_override.users`. The caller owns the full map —
66
+ * typically the AS bot at 100, plus operator and any agents with
67
+ * declared PLs. Empty/absent → no override (the preset's defaults apply).
68
+ */
69
+ userPowerLevels?: Record<string, number>;
56
70
  }): Promise<string>;
57
71
  createRoomRaw(opts: {
58
72
  asUserId: string;
@@ -67,6 +81,17 @@ declare class MatrixClient {
67
81
  }): Promise<{
68
82
  event_id: string;
69
83
  }>;
84
+ /**
85
+ * Invite a user to a room. Sent as the inviter (`asUserId`) — that user
86
+ * needs invite power in the room. Tolerates the "already in room /
87
+ * already invited" responses idempotently so bootstrap can run on a
88
+ * fresh AND a populated homeserver without branching.
89
+ */
90
+ invite(opts: {
91
+ roomId: string;
92
+ asUserId: string;
93
+ targetUserId: string;
94
+ }): Promise<void>;
70
95
  joinRoom(roomIdOrAlias: string, asUserId: string): Promise<void>;
71
96
  sendMessage(input: SendMessageInput): Promise<{
72
97
  event_id: string;
@@ -188,7 +213,13 @@ interface AgentBinding {
188
213
  userId: string;
189
214
  /** Optional human-readable display name. Falls back to the user_id localpart. */
190
215
  displayName?: string;
191
- rooms: string[];
216
+ /**
217
+ * Rooms this agent is bound to. Each entry's `alias` starts out as the
218
+ * configured `#alias` (or `!id`) and is rewritten to the canonical room
219
+ * ID by `BotPool.bootstrap`. Optional `powerLevel` is seeded into the
220
+ * room's `m.room.power_levels.users` at room creation only.
221
+ */
222
+ rooms: RoomBinding[];
192
223
  trigger: 'mention' | 'any';
193
224
  }
194
225
  interface ThreadState {
@@ -219,11 +250,17 @@ interface BootstrapOpts {
219
250
  spaceRoomId?: string;
220
251
  /** AS bot user ID. Required when spaceRoomId is set; sender of the m.space.child write. */
221
252
  asUserId?: string;
253
+ /**
254
+ * Operator MXIDs seeded at PL 100 in every agent room this pool creates.
255
+ * Applied via `power_level_content_override.users` at room creation only —
256
+ * never reconciled. Empty/absent = no operator entries.
257
+ */
258
+ adminUserIds?: string[];
222
259
  }
223
260
  declare class BotPool {
224
261
  private readonly client;
225
262
  private readonly agents;
226
- constructor(client: Pick<MatrixClient, 'registerBot' | 'joinRoom' | 'resolveAlias' | 'createRoom' | 'sendStateEvent' | 'setDisplayName'>, agents: AgentBinding[]);
263
+ constructor(client: Pick<MatrixClient, 'registerBot' | 'invite' | 'joinRoom' | 'resolveAlias' | 'createRoom' | 'sendStateEvent' | 'setDisplayName'>, agents: AgentBinding[]);
227
264
  bootstrap(opts?: BootstrapOpts): Promise<void>;
228
265
  findByUserId(userId: string): AgentBinding | undefined;
229
266
  findByName(name: string): AgentBinding | undefined;
@@ -237,12 +274,19 @@ interface CreateMatrixTransportOptions {
237
274
  hsToken: string;
238
275
  /** Admin Matrix user ID. When set, BotPool.bootstrap invites this user into rooms it creates. */
239
276
  adminUserId?: string;
277
+ /** Post-turn drain: keep collecting trailing `agent_message_chunk`s until the
278
+ * buffer is quiet for this long before flushing. Defaults to `DRAIN_QUIET_MS`.
279
+ * Set to 0 to disable the drain (e.g. in tests). */
280
+ drainQuietMs?: number;
281
+ /** Hard cap on the post-turn drain. Defaults to `DRAIN_MAX_MS`. */
282
+ drainMaxMs?: number;
240
283
  }
241
284
  declare function createMatrixTransport(opts: CreateMatrixTransportOptions): {
242
285
  app: Hono<hono_types.BlankEnv, hono_types.BlankSchema, "/">;
243
286
  bootstrap: (bootstrapOpts?: {
244
287
  spaceRoomId?: string;
245
288
  asUserId?: string;
289
+ adminUserIds?: string[];
246
290
  }) => Promise<void>;
247
291
  pool: BotPool;
248
292
  };
@@ -253,8 +297,38 @@ interface EnsureSpaceOpts {
253
297
  serverName: string;
254
298
  spaceLocalpart: string;
255
299
  preset: 'public_chat' | 'private_chat';
300
+ /**
301
+ * Operator MXIDs to seed at PL 100 in the space's `m.room.power_levels`
302
+ * at creation. The AS bot is always included. Empty/absent → no override
303
+ * (the preset's PL defaults apply). Only consulted on first creation —
304
+ * if the alias already resolves we return the existing room untouched.
305
+ */
306
+ admins?: string[];
256
307
  }
257
308
  declare function ensureWorkforceSpace(opts: EnsureSpaceOpts): Promise<string>;
309
+ interface EnsureDefaultChannelOpts {
310
+ client: MatrixClient;
311
+ asUserId: string;
312
+ serverName: string;
313
+ spaceId: string;
314
+ /** Localpart of the default channel; defaults to `general`. */
315
+ channelLocalpart?: string;
316
+ /**
317
+ * Operator MXIDs to seed at PL 100 in the channel's `m.room.power_levels`
318
+ * at creation. The AS bot is always included. Empty/absent → no override
319
+ * (the preset's PL defaults apply). Only consulted on first creation —
320
+ * if the alias already resolves we return the existing room untouched.
321
+ */
322
+ admins?: string[];
323
+ }
324
+ /**
325
+ * Ensure a space has a default channel (`#general` by default), restricted to
326
+ * the space's members and attached as an `m.space.child`. Idempotent: returns
327
+ * the existing room if the alias already resolves. Has no agent — it's the
328
+ * human landing room, so it is created here at provisioning time rather than
329
+ * via the agent-room path.
330
+ */
331
+ declare function ensureDefaultChannel(opts: EnsureDefaultChannelOpts): Promise<string>;
258
332
  declare function serverNameFromMxid(mxid: string): string;
259
333
 
260
334
  interface WorkforceRoster {
@@ -285,4 +359,4 @@ interface StartOpts {
285
359
  }
286
360
  declare function startWorkforcePublisher(opts: StartOpts): Promise<PublisherHandle>;
287
361
 
288
- export { type AgentBinding, BotPool, type CreateMatrixTransportOptions, type EnsureSpaceOpts, MatrixClient, type MatrixClientOptions, MatrixContextProvider, type MatrixContextProviderOpts, type MatrixTransportConfig, type MaybeMessage, type PublishOpts, type PublisherHandle, type RouteMatch, type SendCustomEventInput, type SendMessageInput, type StartOpts as StartWorkforcePublisherOpts, type WorkforceRoster, buildWorkforceRoster, createMatrixTransport, ensureWorkforceSpace, extractMentions, publishWorkforce, renderRegistration, route, serverNameFromMxid, startWorkforcePublisher };
362
+ export { type AgentBinding, BotPool, type CreateMatrixTransportOptions, type EnsureDefaultChannelOpts, type EnsureSpaceOpts, MatrixClient, type MatrixClientOptions, MatrixContextProvider, type MatrixContextProviderOpts, type MatrixTransportConfig, type MaybeMessage, type PublishOpts, type PublisherHandle, type RouteMatch, type SendCustomEventInput, type SendMessageInput, type StartOpts as StartWorkforcePublisherOpts, type WorkforceRoster, buildWorkforceRoster, createMatrixTransport, ensureDefaultChannel, ensureWorkforceSpace, extractMentions, publishWorkforce, renderRegistration, route, serverNameFromMxid, startWorkforcePublisher };
package/dist/index.js CHANGED
@@ -39,6 +39,22 @@ var MatrixClient = class {
39
39
  preset: opts.preset ?? "public_chat"
40
40
  };
41
41
  if (opts.name !== void 0) body.name = opts.name;
42
+ if (opts.roomVersion !== void 0) body.room_version = opts.roomVersion;
43
+ if (opts.restrictedToSpaceId !== void 0) {
44
+ body.initial_state = [
45
+ {
46
+ type: "m.room.join_rules",
47
+ state_key: "",
48
+ content: {
49
+ join_rule: "restricted",
50
+ allow: [{ type: "m.room_membership", room_id: opts.restrictedToSpaceId }]
51
+ }
52
+ }
53
+ ];
54
+ }
55
+ if (opts.userPowerLevels && Object.keys(opts.userPowerLevels).length > 0) {
56
+ body.power_level_content_override = { users: opts.userPowerLevels };
57
+ }
42
58
  const r = await this.fetch(
43
59
  `${this.homeserver}/_matrix/client/v3/createRoom?user_id=${encodeURIComponent(opts.senderUserId)}`,
44
60
  {
@@ -84,6 +100,31 @@ var MatrixClient = class {
84
100
  if (!r.ok) throw new Error(`sendStateEvent ${opts.eventType} failed: ${r.status}`);
85
101
  return await r.json();
86
102
  }
103
+ /**
104
+ * Invite a user to a room. Sent as the inviter (`asUserId`) — that user
105
+ * needs invite power in the room. Tolerates the "already in room /
106
+ * already invited" responses idempotently so bootstrap can run on a
107
+ * fresh AND a populated homeserver without branching.
108
+ */
109
+ async invite(opts) {
110
+ const url = `${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/invite?user_id=${encodeURIComponent(opts.asUserId)}`;
111
+ const r = await this.fetch(url, {
112
+ method: "POST",
113
+ headers: {
114
+ Authorization: `Bearer ${this.asToken}`,
115
+ "content-type": "application/json"
116
+ },
117
+ body: JSON.stringify({ user_id: opts.targetUserId })
118
+ });
119
+ if (r.ok) return;
120
+ if (r.status === 403) {
121
+ const body = await r.text();
122
+ const idempotent = /already (in the room|invited|a member|joined)/i.test(body) || /user that is joined/i.test(body);
123
+ if (idempotent) return;
124
+ throw new Error(`invite(${opts.targetUserId}) failed: 403 ${body}`);
125
+ }
126
+ throw new Error(`invite(${opts.targetUserId}) failed: ${r.status}`);
127
+ }
87
128
  async joinRoom(roomIdOrAlias, asUserId) {
88
129
  const url = `${this.homeserver}/_matrix/client/v3/join/${encodeURIComponent(roomIdOrAlias)}?user_id=${encodeURIComponent(asUserId)}`;
89
130
  const r = await this.fetch(url, {
@@ -412,7 +453,7 @@ function route(event, agents, threadStates) {
412
453
  const threadState = threadRoot ? threadStates?.get(threadRoot) : void 0;
413
454
  for (const a of agents) {
414
455
  if (event.sender === a.userId) continue;
415
- if (!a.rooms.includes(event.room_id ?? "")) continue;
456
+ if (!a.rooms.some((r) => r.alias === event.room_id)) continue;
416
457
  if (a.trigger === "any") {
417
458
  matches.push(a);
418
459
  continue;
@@ -439,15 +480,50 @@ async function ensureWorkforceSpace(opts) {
439
480
  const existing = await opts.client.resolveAlias(alias);
440
481
  if (existing) return existing;
441
482
  const display = opts.spaceLocalpart.charAt(0).toUpperCase() + opts.spaceLocalpart.slice(1);
442
- return opts.client.createRoomRaw({
483
+ const body = {
484
+ room_alias_name: opts.spaceLocalpart,
485
+ name: display,
486
+ preset: opts.preset,
487
+ creation_content: { type: "m.space" },
488
+ // A workspace is joined by invitation, not self-service. Pin the space's
489
+ // join rule to invite regardless of preset so it can't be walked into
490
+ // (which would otherwise satisfy every restricted child room's allow).
491
+ initial_state: [{ type: "m.room.join_rules", state_key: "", content: { join_rule: "invite" } }]
492
+ };
493
+ if (opts.admins && opts.admins.length > 0) {
494
+ body.invite = opts.admins;
495
+ const users = { [opts.asUserId]: 100 };
496
+ for (const a of opts.admins) users[a] = 100;
497
+ body.power_level_content_override = { users };
498
+ }
499
+ return opts.client.createRoomRaw({ asUserId: opts.asUserId, body });
500
+ }
501
+ async function ensureDefaultChannel(opts) {
502
+ const localpart2 = opts.channelLocalpart ?? "general";
503
+ const alias = `#${localpart2}:${opts.serverName}`;
504
+ const existing = await opts.client.resolveAlias(alias);
505
+ if (existing) return existing;
506
+ let userPowerLevels;
507
+ if (opts.admins && opts.admins.length > 0) {
508
+ userPowerLevels = { [opts.asUserId]: 100 };
509
+ for (const a of opts.admins) userPowerLevels[a] = 100;
510
+ }
511
+ const roomId = await opts.client.createRoom({
512
+ roomAliasName: localpart2,
513
+ invite: [],
514
+ senderUserId: opts.asUserId,
515
+ name: localpart2.charAt(0).toUpperCase() + localpart2.slice(1),
516
+ restrictedToSpaceId: opts.spaceId,
517
+ ...userPowerLevels ? { userPowerLevels } : {}
518
+ });
519
+ await opts.client.sendStateEvent({
520
+ roomId: opts.spaceId,
443
521
  asUserId: opts.asUserId,
444
- body: {
445
- room_alias_name: opts.spaceLocalpart,
446
- name: display,
447
- preset: opts.preset,
448
- creation_content: { type: "m.space" }
449
- }
522
+ eventType: "m.space.child",
523
+ stateKey: roomId,
524
+ content: { via: [opts.serverName] }
450
525
  });
526
+ return roomId;
451
527
  }
452
528
  function serverNameFromMxid(mxid) {
453
529
  const colon = mxid.indexOf(":");
@@ -478,8 +554,23 @@ var BotPool = class {
478
554
  } catch (err) {
479
555
  console.warn(`[matrix] setDisplayName(${a.userId}) failed: ${err.message}`);
480
556
  }
557
+ if (opts.spaceRoomId && opts.asUserId) {
558
+ try {
559
+ await this.client.invite({
560
+ roomId: opts.spaceRoomId,
561
+ asUserId: opts.asUserId,
562
+ targetUserId: a.userId
563
+ });
564
+ await this.client.joinRoom(opts.spaceRoomId, a.userId);
565
+ } catch (err) {
566
+ console.warn(
567
+ `[matrix] space membership for ${a.userId} failed: ${err.message}`
568
+ );
569
+ }
570
+ }
481
571
  for (let i = 0; i < a.rooms.length; i++) {
482
- const room = a.rooms[i];
572
+ const binding = a.rooms[i];
573
+ const room = binding.alias;
483
574
  try {
484
575
  let resolved = room;
485
576
  if (room.startsWith("#")) {
@@ -494,17 +585,25 @@ var BotPool = class {
494
585
  const colon = room.indexOf(":");
495
586
  const aliasLocalpart = colon > 1 ? room.slice(1, colon) : room.slice(1);
496
587
  const sender = opts.adminUserId ?? a.userId;
588
+ const userPowerLevels = buildUserPowerLevels(
589
+ opts.asUserId,
590
+ opts.adminUserIds,
591
+ this.agents,
592
+ room
593
+ );
497
594
  resolved = await this.client.createRoom({
498
595
  roomAliasName: aliasLocalpart,
499
596
  invite: opts.adminUserId ? [opts.adminUserId] : [],
500
597
  senderUserId: sender,
501
- name: aliasLocalpart
598
+ name: aliasLocalpart,
599
+ ...opts.spaceRoomId ? { restrictedToSpaceId: opts.spaceRoomId } : {},
600
+ ...userPowerLevels ? { userPowerLevels } : {}
502
601
  });
503
602
  }
504
603
  aliasToId.set(room, resolved);
505
604
  }
506
605
  }
507
- a.rooms[i] = resolved;
606
+ binding.alias = resolved;
508
607
  await this.client.joinRoom(resolved, a.userId);
509
608
  if (opts.spaceRoomId && opts.asUserId && !attachedToSpace.has(resolved)) {
510
609
  attachedToSpace.add(resolved);
@@ -543,6 +642,18 @@ function localpart(userId) {
543
642
  if (!m) throw new Error(`bad user id: ${userId}`);
544
643
  return m[1];
545
644
  }
645
+ function buildUserPowerLevels(asUserId, admins, agents, roomAlias) {
646
+ const users = {};
647
+ if (asUserId) users[asUserId] = 100;
648
+ if (admins) for (const a of admins) users[a] = 100;
649
+ for (const a of agents) {
650
+ for (const r of a.rooms) {
651
+ if (r.alias !== roomAlias) continue;
652
+ if (r.powerLevel !== void 0) users[a.userId] = r.powerLevel;
653
+ }
654
+ }
655
+ return Object.keys(users).length > 0 ? users : void 0;
656
+ }
546
657
 
547
658
  // src/transport.ts
548
659
  import { Hono } from "hono";
@@ -696,12 +807,17 @@ function toMatrixHtml(markdown) {
696
807
  // src/transport.ts
697
808
  var STARTUP_GRACE_MS = 5e3;
698
809
  var SEEN_EVENT_CAP = 5e3;
810
+ var DRAIN_QUIET_MS = 300;
811
+ var DRAIN_MAX_MS = 3e4;
812
+ var delay = (ms) => new Promise((r) => setTimeout(r, ms));
699
813
  function inboundThreadRoot2(evt) {
700
814
  const r = evt.content?.["m.relates_to"];
701
815
  return r?.rel_type === "m.thread" && r.event_id ? r.event_id : void 0;
702
816
  }
703
817
  function createMatrixTransport(opts) {
704
818
  const { agents, approvals, client, bindings, hsToken, adminUserId } = opts;
819
+ const drainQuietMs = opts.drainQuietMs ?? DRAIN_QUIET_MS;
820
+ const drainMaxMs = opts.drainMaxMs ?? DRAIN_MAX_MS;
705
821
  const pool = new BotPool(client, bindings);
706
822
  const sessions = /* @__PURE__ */ new Map();
707
823
  const buffers = /* @__PURE__ */ new Map();
@@ -983,6 +1099,14 @@ function createMatrixTransport(opts) {
983
1099
  channelId: evt.room_id,
984
1100
  content: [{ type: "text", text: promptText }]
985
1101
  });
1102
+ const drainStart = Date.now();
1103
+ let drained = buffers.get(sessionId) ?? "";
1104
+ while (drainQuietMs > 0 && Date.now() - drainStart < drainMaxMs) {
1105
+ await delay(drainQuietMs);
1106
+ const next = buffers.get(sessionId) ?? "";
1107
+ if (next === drained && next.length > 0) break;
1108
+ drained = next;
1109
+ }
986
1110
  const text = buffers.get(sessionId) ?? "";
987
1111
  if (text.length > 0) {
988
1112
  const html = toMatrixHtml(text);
@@ -1034,7 +1158,7 @@ function createMatrixTransport(opts) {
1034
1158
  }
1035
1159
  async function rebuildThreadState(client, roomId, rootEventId, bindings) {
1036
1160
  const state = { participants: [], rootMentions: [] };
1037
- const asUser = (bindings.find((b) => b.rooms.includes(roomId)) ?? bindings[0])?.userId;
1161
+ const asUser = (bindings.find((b) => b.rooms.some((r) => r.alias === roomId)) ?? bindings[0])?.userId;
1038
1162
  if (!asUser) return state;
1039
1163
  const root = await client.fetchEvent(roomId, rootEventId, asUser);
1040
1164
  if (root) {
@@ -1089,7 +1213,11 @@ function truncate(s, n) {
1089
1213
  function buildWorkforceRoster(agents) {
1090
1214
  return {
1091
1215
  version: 1,
1092
- agents: agents.map((a) => ({ user_id: a.userId, name: a.name, rooms: a.rooms }))
1216
+ agents: agents.map((a) => ({
1217
+ user_id: a.userId,
1218
+ name: a.name,
1219
+ rooms: a.rooms.map((r) => r.alias)
1220
+ }))
1093
1221
  };
1094
1222
  }
1095
1223
  async function publishWorkforce(opts) {
@@ -1117,6 +1245,7 @@ export {
1117
1245
  MatrixContextProvider,
1118
1246
  buildWorkforceRoster,
1119
1247
  createMatrixTransport,
1248
+ ensureDefaultChannel,
1120
1249
  ensureWorkforceSpace,
1121
1250
  extractMentions,
1122
1251
  publishWorkforce,