@zooid/transport-matrix 0.7.1 → 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 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,30 @@ 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
+ if (/already (in the room|invited|a member|joined)/i.test(body)) return;
123
+ throw new Error(`invite(${opts.targetUserId}) failed: 403 ${body}`);
124
+ }
125
+ throw new Error(`invite(${opts.targetUserId}) failed: ${r.status}`);
126
+ }
87
127
  async joinRoom(roomIdOrAlias, asUserId) {
88
128
  const url = `${this.homeserver}/_matrix/client/v3/join/${encodeURIComponent(roomIdOrAlias)}?user_id=${encodeURIComponent(asUserId)}`;
89
129
  const r = await this.fetch(url, {
@@ -412,7 +452,7 @@ function route(event, agents, threadStates) {
412
452
  const threadState = threadRoot ? threadStates?.get(threadRoot) : void 0;
413
453
  for (const a of agents) {
414
454
  if (event.sender === a.userId) continue;
415
- if (!a.rooms.includes(event.room_id ?? "")) continue;
455
+ if (!a.rooms.some((r) => r.alias === event.room_id)) continue;
416
456
  if (a.trigger === "any") {
417
457
  matches.push(a);
418
458
  continue;
@@ -439,15 +479,50 @@ async function ensureWorkforceSpace(opts) {
439
479
  const existing = await opts.client.resolveAlias(alias);
440
480
  if (existing) return existing;
441
481
  const display = opts.spaceLocalpart.charAt(0).toUpperCase() + opts.spaceLocalpart.slice(1);
442
- return opts.client.createRoomRaw({
482
+ const body = {
483
+ room_alias_name: opts.spaceLocalpart,
484
+ name: display,
485
+ preset: opts.preset,
486
+ creation_content: { type: "m.space" },
487
+ // A workspace is joined by invitation, not self-service. Pin the space's
488
+ // join rule to invite regardless of preset so it can't be walked into
489
+ // (which would otherwise satisfy every restricted child room's allow).
490
+ initial_state: [{ type: "m.room.join_rules", state_key: "", content: { join_rule: "invite" } }]
491
+ };
492
+ if (opts.admins && opts.admins.length > 0) {
493
+ body.invite = opts.admins;
494
+ const users = { [opts.asUserId]: 100 };
495
+ for (const a of opts.admins) users[a] = 100;
496
+ body.power_level_content_override = { users };
497
+ }
498
+ return opts.client.createRoomRaw({ asUserId: opts.asUserId, body });
499
+ }
500
+ async function ensureDefaultChannel(opts) {
501
+ const localpart2 = opts.channelLocalpart ?? "general";
502
+ const alias = `#${localpart2}:${opts.serverName}`;
503
+ const existing = await opts.client.resolveAlias(alias);
504
+ if (existing) return existing;
505
+ let userPowerLevels;
506
+ if (opts.admins && opts.admins.length > 0) {
507
+ userPowerLevels = { [opts.asUserId]: 100 };
508
+ for (const a of opts.admins) userPowerLevels[a] = 100;
509
+ }
510
+ const roomId = await opts.client.createRoom({
511
+ roomAliasName: localpart2,
512
+ invite: [],
513
+ senderUserId: opts.asUserId,
514
+ name: localpart2.charAt(0).toUpperCase() + localpart2.slice(1),
515
+ restrictedToSpaceId: opts.spaceId,
516
+ ...userPowerLevels ? { userPowerLevels } : {}
517
+ });
518
+ await opts.client.sendStateEvent({
519
+ roomId: opts.spaceId,
443
520
  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
- }
521
+ eventType: "m.space.child",
522
+ stateKey: roomId,
523
+ content: { via: [opts.serverName] }
450
524
  });
525
+ return roomId;
451
526
  }
452
527
  function serverNameFromMxid(mxid) {
453
528
  const colon = mxid.indexOf(":");
@@ -478,8 +553,23 @@ var BotPool = class {
478
553
  } catch (err) {
479
554
  console.warn(`[matrix] setDisplayName(${a.userId}) failed: ${err.message}`);
480
555
  }
556
+ if (opts.spaceRoomId && opts.asUserId) {
557
+ try {
558
+ await this.client.invite({
559
+ roomId: opts.spaceRoomId,
560
+ asUserId: opts.asUserId,
561
+ targetUserId: a.userId
562
+ });
563
+ await this.client.joinRoom(opts.spaceRoomId, a.userId);
564
+ } catch (err) {
565
+ console.warn(
566
+ `[matrix] space membership for ${a.userId} failed: ${err.message}`
567
+ );
568
+ }
569
+ }
481
570
  for (let i = 0; i < a.rooms.length; i++) {
482
- const room = a.rooms[i];
571
+ const binding = a.rooms[i];
572
+ const room = binding.alias;
483
573
  try {
484
574
  let resolved = room;
485
575
  if (room.startsWith("#")) {
@@ -494,17 +584,25 @@ var BotPool = class {
494
584
  const colon = room.indexOf(":");
495
585
  const aliasLocalpart = colon > 1 ? room.slice(1, colon) : room.slice(1);
496
586
  const sender = opts.adminUserId ?? a.userId;
587
+ const userPowerLevels = buildUserPowerLevels(
588
+ opts.asUserId,
589
+ opts.adminUserIds,
590
+ this.agents,
591
+ room
592
+ );
497
593
  resolved = await this.client.createRoom({
498
594
  roomAliasName: aliasLocalpart,
499
595
  invite: opts.adminUserId ? [opts.adminUserId] : [],
500
596
  senderUserId: sender,
501
- name: aliasLocalpart
597
+ name: aliasLocalpart,
598
+ ...opts.spaceRoomId ? { restrictedToSpaceId: opts.spaceRoomId } : {},
599
+ ...userPowerLevels ? { userPowerLevels } : {}
502
600
  });
503
601
  }
504
602
  aliasToId.set(room, resolved);
505
603
  }
506
604
  }
507
- a.rooms[i] = resolved;
605
+ binding.alias = resolved;
508
606
  await this.client.joinRoom(resolved, a.userId);
509
607
  if (opts.spaceRoomId && opts.asUserId && !attachedToSpace.has(resolved)) {
510
608
  attachedToSpace.add(resolved);
@@ -543,6 +641,18 @@ function localpart(userId) {
543
641
  if (!m) throw new Error(`bad user id: ${userId}`);
544
642
  return m[1];
545
643
  }
644
+ function buildUserPowerLevels(asUserId, admins, agents, roomAlias) {
645
+ const users = {};
646
+ if (asUserId) users[asUserId] = 100;
647
+ if (admins) for (const a of admins) users[a] = 100;
648
+ for (const a of agents) {
649
+ for (const r of a.rooms) {
650
+ if (r.alias !== roomAlias) continue;
651
+ if (r.powerLevel !== void 0) users[a.userId] = r.powerLevel;
652
+ }
653
+ }
654
+ return Object.keys(users).length > 0 ? users : void 0;
655
+ }
546
656
 
547
657
  // src/transport.ts
548
658
  import { Hono } from "hono";
@@ -696,12 +806,17 @@ function toMatrixHtml(markdown) {
696
806
  // src/transport.ts
697
807
  var STARTUP_GRACE_MS = 5e3;
698
808
  var SEEN_EVENT_CAP = 5e3;
809
+ var DRAIN_QUIET_MS = 300;
810
+ var DRAIN_MAX_MS = 3e3;
811
+ var delay = (ms) => new Promise((r) => setTimeout(r, ms));
699
812
  function inboundThreadRoot2(evt) {
700
813
  const r = evt.content?.["m.relates_to"];
701
814
  return r?.rel_type === "m.thread" && r.event_id ? r.event_id : void 0;
702
815
  }
703
816
  function createMatrixTransport(opts) {
704
817
  const { agents, approvals, client, bindings, hsToken, adminUserId } = opts;
818
+ const drainQuietMs = opts.drainQuietMs ?? DRAIN_QUIET_MS;
819
+ const drainMaxMs = opts.drainMaxMs ?? DRAIN_MAX_MS;
705
820
  const pool = new BotPool(client, bindings);
706
821
  const sessions = /* @__PURE__ */ new Map();
707
822
  const buffers = /* @__PURE__ */ new Map();
@@ -983,6 +1098,14 @@ function createMatrixTransport(opts) {
983
1098
  channelId: evt.room_id,
984
1099
  content: [{ type: "text", text: promptText }]
985
1100
  });
1101
+ const drainStart = Date.now();
1102
+ let drained = buffers.get(sessionId) ?? "";
1103
+ while (drainQuietMs > 0 && Date.now() - drainStart < drainMaxMs) {
1104
+ await delay(drainQuietMs);
1105
+ const next = buffers.get(sessionId) ?? "";
1106
+ if (next === drained) break;
1107
+ drained = next;
1108
+ }
986
1109
  const text = buffers.get(sessionId) ?? "";
987
1110
  if (text.length > 0) {
988
1111
  const html = toMatrixHtml(text);
@@ -1034,7 +1157,7 @@ function createMatrixTransport(opts) {
1034
1157
  }
1035
1158
  async function rebuildThreadState(client, roomId, rootEventId, bindings) {
1036
1159
  const state = { participants: [], rootMentions: [] };
1037
- const asUser = (bindings.find((b) => b.rooms.includes(roomId)) ?? bindings[0])?.userId;
1160
+ const asUser = (bindings.find((b) => b.rooms.some((r) => r.alias === roomId)) ?? bindings[0])?.userId;
1038
1161
  if (!asUser) return state;
1039
1162
  const root = await client.fetchEvent(roomId, rootEventId, asUser);
1040
1163
  if (root) {
@@ -1089,7 +1212,11 @@ function truncate(s, n) {
1089
1212
  function buildWorkforceRoster(agents) {
1090
1213
  return {
1091
1214
  version: 1,
1092
- agents: agents.map((a) => ({ user_id: a.userId, name: a.name, rooms: a.rooms }))
1215
+ agents: agents.map((a) => ({
1216
+ user_id: a.userId,
1217
+ name: a.name,
1218
+ rooms: a.rooms.map((r) => r.alias)
1219
+ }))
1093
1220
  };
1094
1221
  }
1095
1222
  async function publishWorkforce(opts) {
@@ -1117,6 +1244,7 @@ export {
1117
1244
  MatrixContextProvider,
1118
1245
  buildWorkforceRoster,
1119
1246
  createMatrixTransport,
1247
+ ensureDefaultChannel,
1120
1248
  ensureWorkforceSpace,
1121
1249
  extractMentions,
1122
1250
  publishWorkforce,