@tloncorp/openclaw 0.1.0 → 0.2.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.
@@ -1,5 +1,6 @@
1
1
  import { format } from "node:util";
2
2
  import { getTlonRuntime } from "../runtime.js";
3
+ import { setSessionRole } from "../session-roles.js";
3
4
  import { createSettingsManager } from "../settings.js";
4
5
  import { normalizeShip, parseChannelNest } from "../targets.js";
5
6
  import { resolveTlonAccount } from "../types.js";
@@ -9,12 +10,22 @@ import { configureTlonApiWithPoke } from "../urbit/api-client.js";
9
10
  import { sendDm, sendChannelPost } from "../urbit/send.js";
10
11
  import { markdownToStory } from "../urbit/story.js";
11
12
  import { UrbitSSEClient } from "../urbit/sse-client.js";
12
- import { createPendingApproval, formatApprovalRequest, formatApprovalConfirmation, parseApprovalResponse, isApprovalResponse, findPendingApproval, removePendingApproval, parseAdminCommand, isAdminCommand, formatBlockedList, formatPendingList, } from "./approval.js";
13
+ import { createPendingApproval, formatApprovalRequest, formatApprovalConfirmation, findPendingApproval, removePendingApproval, pruneExpired, formatBlockedList, formatPendingList, isExpired, emojiToApprovalAction, normalizeNotificationId, } from "./approval.js";
14
+ import { setBridge, removeBridge } from "./command-bridge.js";
13
15
  import { fetchAllChannels, fetchInitData } from "./discovery.js";
14
16
  import { cacheMessage, lookupCachedMessage, getChannelHistory, fetchThreadHistory } from "./history.js";
15
17
  import { downloadMessageImages } from "./media.js";
16
18
  import { createProcessedMessageTracker } from "./processed-messages.js";
17
- import { extractMessageText, extractCites, formatModelName, isBotMentioned, stripBotMention, isDmAllowed, isSummarizationRequest, } from "./utils.js";
19
+ import { extractMessageText, extractCites, formatModelName, isBotMentioned, stripBotMention, isDmAllowed, isSummarizationRequest, sanitizeMessageText, } from "./utils.js";
20
+ /**
21
+ * Extract ship from author field, handling both string (ship) and object (bot-meta) formats.
22
+ */
23
+ function extractAuthorShip(author) {
24
+ if (typeof author === "object" && author !== null && "ship" in author) {
25
+ return author.ship;
26
+ }
27
+ return typeof author === "string" ? author : "";
28
+ }
18
29
  /**
19
30
  * Resolve channel authorization by merging file config with settings store.
20
31
  * Settings store takes precedence for fields it defines.
@@ -116,6 +127,11 @@ export async function monitorTlonProvider(opts = {}) {
116
127
  let groupChannels = [];
117
128
  const channelToGroup = new Map();
118
129
  let botNickname = null;
130
+ let botAvatar = null;
131
+ // Helper to get bot profile for outbound messages
132
+ const getBotProfile = () => botNickname || botAvatar
133
+ ? { nickname: botNickname || "", avatar: botAvatar || "" }
134
+ : undefined;
119
135
  // Settings store manager for hot-reloading config
120
136
  const settingsManager = createSettingsManager(api, {
121
137
  log: (msg) => runtime.log?.(msg),
@@ -135,6 +151,12 @@ export async function monitorTlonProvider(opts = {}) {
135
151
  let currentSettings = {};
136
152
  // Track threads we've participated in (by parentId) - respond without mention requirement
137
153
  const participatedThreads = new Set();
154
+ // Track consecutive bot responses per channel/DM for rate limiting
155
+ // Key: channel nest or dm partner ship, Value: count of consecutive bot messages
156
+ const consecutiveBotMessages = new Map();
157
+ // Known bot ships (ships that have sent messages with BotProfile author)
158
+ const knownBotShips = new Set();
159
+ const maxBotResponses = account.maxConsecutiveBotResponses ?? 3;
138
160
  // Track DM senders per session to detect shared sessions (security warning)
139
161
  const dmSendersBySession = new Map();
140
162
  let sharedSessionWarningSent = false;
@@ -160,9 +182,10 @@ export async function monitorTlonProvider(opts = {}) {
160
182
  if (selfProfile && typeof selfProfile === "object") {
161
183
  const profile = selfProfile;
162
184
  botNickname = profile.nickname?.value || null;
185
+ botAvatar = profile.avatar?.value || null;
163
186
  if (botNickname) {
164
187
  runtime.log?.(`[tlon] Bot nickname: ${botNickname}`);
165
- nicknameCache.set(botShipName, botNickname);
188
+ nicknameCache.set(botShipName, sanitizeNickname(botNickname));
166
189
  }
167
190
  }
168
191
  }
@@ -176,7 +199,7 @@ export async function monitorTlonProvider(opts = {}) {
176
199
  for (const [ship, contact] of Object.entries(allContacts)) {
177
200
  const nickname = contact?.nickname?.value ?? contact?.nickname;
178
201
  if (nickname && typeof nickname === "string") {
179
- nicknameCache.set(normalizeShip(ship), nickname);
202
+ nicknameCache.set(normalizeShip(ship), sanitizeNickname(nickname));
180
203
  }
181
204
  }
182
205
  runtime.log?.(`[tlon] Loaded ${nicknameCache.size} contact nickname(s)`);
@@ -187,6 +210,22 @@ export async function monitorTlonProvider(opts = {}) {
187
210
  }
188
211
  // Store init foreigns for processing after settings are loaded
189
212
  let initForeigns = null;
213
+ // Group name cache for human-readable display (flag -> title)
214
+ const groupNameCache = new Map();
215
+ // Build display context for approval formatting
216
+ function buildDisplayContext() {
217
+ const channelNames = new Map();
218
+ for (const nest of watchedChannels) {
219
+ const parsed = parseChannelNest(nest);
220
+ if (parsed) {
221
+ channelNames.set(nest, parsed.channelName);
222
+ }
223
+ }
224
+ return {
225
+ channelNames,
226
+ groupNames: groupNameCache,
227
+ };
228
+ }
190
229
  // Migrate file config to settings store (seed on first run)
191
230
  async function migrateConfigToSettings() {
192
231
  const migrations = [
@@ -230,12 +269,17 @@ export async function monitorTlonProvider(opts = {}) {
230
269
  fileValue: account.showModelSignature,
231
270
  settingsValue: currentSettings.showModelSig,
232
271
  },
272
+ {
273
+ key: "ownerShip",
274
+ fileValue: account.ownerShip,
275
+ settingsValue: currentSettings.ownerShip,
276
+ },
233
277
  ];
234
278
  for (const { key, fileValue, settingsValue } of migrations) {
235
279
  // Only migrate if file has a value and settings store doesn't
236
280
  const hasFileValue = Array.isArray(fileValue) ? fileValue.length > 0 : fileValue != null;
237
281
  const hasSettingsValue = Array.isArray(settingsValue)
238
- ? settingsValue.length > 0
282
+ ? true // empty array = intentionally set in settings store
239
283
  : settingsValue != null;
240
284
  if (hasFileValue && !hasSettingsValue) {
241
285
  try {
@@ -273,9 +317,9 @@ export async function monitorTlonProvider(opts = {}) {
273
317
  effectiveAutoDiscoverChannels = currentSettings.autoDiscoverChannels;
274
318
  runtime.log?.(`[tlon] Using autoDiscoverChannels from settings store: ${effectiveAutoDiscoverChannels}`);
275
319
  }
276
- if (currentSettings.dmAllowlist?.length) {
320
+ if (currentSettings.dmAllowlist !== undefined) {
277
321
  effectiveDmAllowlist = currentSettings.dmAllowlist;
278
- runtime.log?.(`[tlon] Using dmAllowlist from settings store: ${effectiveDmAllowlist.join(", ")}`);
322
+ runtime.log?.(`[tlon] Using dmAllowlist from settings store: ${effectiveDmAllowlist.length > 0 ? effectiveDmAllowlist.join(", ") : "(empty)"}`);
279
323
  }
280
324
  if (currentSettings.showModelSig !== undefined) {
281
325
  effectiveShowModelSig = currentSettings.showModelSig;
@@ -315,6 +359,10 @@ export async function monitorTlonProvider(opts = {}) {
315
359
  for (const [nest, groupFlag] of initData.channelToGroup) {
316
360
  channelToGroup.set(nest, groupFlag);
317
361
  }
362
+ // Populate group name cache for human-readable display
363
+ for (const [flag, title] of initData.groupNames) {
364
+ groupNameCache.set(flag, title);
365
+ }
318
366
  initForeigns = initData.foreigns;
319
367
  }
320
368
  catch (error) {
@@ -427,6 +475,33 @@ export async function monitorTlonProvider(opts = {}) {
427
475
  runtime.error?.(`[tlon] Failed to update dmAllowlist: ${String(err)}`);
428
476
  }
429
477
  }
478
+ // Helper to remove ship from dmAllowlist in both memory and settings store
479
+ async function removeFromDmAllowlist(ship) {
480
+ const normalizedShip = normalizeShip(ship);
481
+ const before = effectiveDmAllowlist.length;
482
+ effectiveDmAllowlist = effectiveDmAllowlist.filter((s) => s !== normalizedShip);
483
+ if (effectiveDmAllowlist.length === before) {
484
+ return; // Ship wasn't on the list
485
+ }
486
+ try {
487
+ await api.poke({
488
+ app: "settings",
489
+ mark: "settings-event",
490
+ json: {
491
+ "put-entry": {
492
+ desk: "moltbot",
493
+ "bucket-key": "tlon",
494
+ "entry-key": "dmAllowlist",
495
+ value: effectiveDmAllowlist,
496
+ },
497
+ },
498
+ });
499
+ runtime.log?.(`[tlon] Removed ${normalizedShip} from dmAllowlist`);
500
+ }
501
+ catch (err) {
502
+ runtime.error?.(`[tlon] Failed to update dmAllowlist: ${String(err)}`);
503
+ }
504
+ }
430
505
  // Helper to update channelRules in settings store
431
506
  async function addToChannelAllowlist(ship, channelNest) {
432
507
  const normalizedShip = normalizeShip(ship);
@@ -476,12 +551,26 @@ export async function monitorTlonProvider(opts = {}) {
476
551
  runtime.error?.(`[tlon] Failed to block ship ${normalizedShip}: ${String(err)}`);
477
552
  }
478
553
  }
554
+ /**
555
+ * Scry the chat agent's blocked ship list with an explicit timeout.
556
+ * The urbitFetch timeout (30s) may not fire if the underlying connection
557
+ * stalls (e.g. after a chat-block-ship poke causes the agent to restart).
558
+ * This wrapper guarantees resolution within SCRY_TIMEOUT_MS.
559
+ */
560
+ const SCRY_TIMEOUT_MS = 15_000;
561
+ async function scryBlockedShips() {
562
+ const blocked = await Promise.race([
563
+ api.scry("/chat/blocked.json"),
564
+ new Promise((_, reject) => setTimeout(() => reject(new Error("blocked list scry timeout")), SCRY_TIMEOUT_MS)),
565
+ ]);
566
+ return Array.isArray(blocked) ? blocked : [];
567
+ }
479
568
  // Check if a ship is blocked using Tlon's native block list
480
569
  async function isShipBlocked(ship) {
481
570
  const normalizedShip = normalizeShip(ship);
482
571
  try {
483
- const blocked = (await api.scry("/chat/blocked.json"));
484
- return Array.isArray(blocked) && blocked.some((s) => normalizeShip(s) === normalizedShip);
572
+ const blocked = await scryBlockedShips();
573
+ return blocked.some((s) => normalizeShip(s) === normalizedShip);
485
574
  }
486
575
  catch (err) {
487
576
  runtime.log?.(`[tlon] Failed to check blocked list: ${String(err)}`);
@@ -491,8 +580,7 @@ export async function monitorTlonProvider(opts = {}) {
491
580
  // Get all blocked ships
492
581
  async function getBlockedShips() {
493
582
  try {
494
- const blocked = (await api.scry("/chat/blocked.json"));
495
- return Array.isArray(blocked) ? blocked : [];
583
+ return await scryBlockedShips();
496
584
  }
497
585
  catch (err) {
498
586
  runtime.log?.(`[tlon] Failed to get blocked list: ${String(err)}`);
@@ -516,24 +604,65 @@ export async function monitorTlonProvider(opts = {}) {
516
604
  return false;
517
605
  }
518
606
  }
519
- // Helper to send DM notification to owner
607
+ // Helper to send DM notification to owner. Returns the message ID if sent successfully.
520
608
  async function sendOwnerNotification(message) {
521
609
  if (!effectiveOwnerShip) {
522
610
  runtime.log?.("[tlon] No ownerShip configured, cannot send notification");
523
- return;
611
+ return undefined;
524
612
  }
525
613
  try {
526
- await sendDm({
614
+ const result = await sendDm({
615
+ botProfile: getBotProfile(),
527
616
  fromShip: botShipName,
528
617
  toShip: effectiveOwnerShip,
529
618
  text: message,
530
619
  });
531
620
  runtime.log?.(`[tlon] Sent notification to owner ${effectiveOwnerShip}`);
621
+ return result.messageId;
532
622
  }
533
623
  catch (err) {
534
624
  runtime.error?.(`[tlon] Failed to send notification to owner: ${String(err)}`);
625
+ return undefined;
535
626
  }
536
627
  }
628
+ // Regex to match block directives in agent responses
629
+ // Format: [BLOCK_USER: ~ship-name | reason for blocking]
630
+ const blockDirectiveRegex = /\[BLOCK_USER:\s*(~[\w-]+)\s*\|\s*(.+?)\]/g;
631
+ // Process block directives from agent response and return text with directives stripped
632
+ async function processBlockDirectives(text, senderShip) {
633
+ const matches = [...text.matchAll(blockDirectiveRegex)];
634
+ if (matches.length > 0) {
635
+ runtime.log?.(`[tlon] Found ${matches.length} block directive(s) in response`);
636
+ runtime.log?.(`[tlon] Sender ship: "${senderShip}" -> normalized: "${normalizeShip(senderShip)}"`);
637
+ runtime.log?.(`[tlon] Owner ship: "${effectiveOwnerShip}"`);
638
+ }
639
+ for (const match of matches) {
640
+ const targetShip = normalizeShip(match[1]);
641
+ const reason = match[2].trim();
642
+ runtime.log?.(`[tlon] Processing block directive: target="${targetShip}", reason="${reason}"`);
643
+ // Safety: Never block the owner
644
+ if (effectiveOwnerShip && targetShip === effectiveOwnerShip) {
645
+ runtime.log?.(`[tlon] Agent attempted to block owner ship ${targetShip} - ignoring`);
646
+ continue;
647
+ }
648
+ // Only allow blocking the current message sender (not arbitrary third parties)
649
+ const normalizedSender = normalizeShip(senderShip);
650
+ if (targetShip !== normalizedSender) {
651
+ runtime.log?.(`[tlon] Agent tried to block "${targetShip}" but sender is "${normalizedSender}" - ignoring`);
652
+ continue;
653
+ }
654
+ // Block the abusive sender
655
+ runtime.log?.(`[tlon] Executing block for ${targetShip}...`);
656
+ await blockShip(targetShip);
657
+ // Notify owner
658
+ if (effectiveOwnerShip) {
659
+ await sendOwnerNotification(`[Agent Action] Blocked ${targetShip}\nReason: ${reason}`);
660
+ }
661
+ runtime.log?.(`[tlon] Agent blocked ${targetShip}: ${reason}`);
662
+ }
663
+ // Strip directives from visible response
664
+ return text.replace(blockDirectiveRegex, "").trim();
665
+ }
537
666
  // Queue a new approval request and notify the owner
538
667
  async function queueApprovalRequest(approval) {
539
668
  // Check if ship is blocked - silently ignore
@@ -554,33 +683,36 @@ export async function monitorTlonProvider(opts = {}) {
554
683
  existing.messagePreview = approval.messagePreview;
555
684
  }
556
685
  runtime.log?.(`[tlon] Updated existing approval for ${approval.requestingShip} (${approval.type}) - re-sending notification`);
686
+ // Send notification first, then save once with the notification ID.
687
+ // Saving before sendOwnerNotification causes a race: the settings subscription
688
+ // event replaces pendingApprovals in-memory, so the notificationMessageId
689
+ // set on the old object reference is lost.
690
+ const existMsg = formatApprovalRequest(existing, buildDisplayContext());
691
+ const existNotifId = await sendOwnerNotification(existMsg);
692
+ if (existNotifId) {
693
+ existing.notificationMessageId = normalizeNotificationId(existNotifId);
694
+ }
557
695
  await savePendingApprovals();
558
- const message = formatApprovalRequest(existing);
559
- await sendOwnerNotification(message);
560
696
  return;
561
697
  }
698
+ // Send notification before saving so notificationMessageId is included
699
+ // in the single save. See comment above about the settings subscription race.
700
+ const message = formatApprovalRequest(approval, buildDisplayContext());
701
+ const notifId = await sendOwnerNotification(message);
702
+ if (notifId) {
703
+ approval.notificationMessageId = normalizeNotificationId(notifId);
704
+ }
562
705
  pendingApprovals.push(approval);
563
706
  await savePendingApprovals();
564
- const message = formatApprovalRequest(approval);
565
- await sendOwnerNotification(message);
566
707
  runtime.log?.(`[tlon] Queued approval request: ${approval.id} (${approval.type} from ${approval.requestingShip})`);
567
708
  }
568
- // Process the owner's approval response
569
- async function handleApprovalResponse(text) {
570
- const parsed = parseApprovalResponse(text);
571
- if (!parsed) {
572
- return false;
573
- }
574
- const approval = findPendingApproval(pendingApprovals, parsed.id);
575
- if (!approval) {
576
- await sendOwnerNotification("No pending approval found" + (parsed.id ? ` for ID: ${parsed.id}` : ""));
577
- return true; // Still consumed the message
578
- }
579
- if (parsed.action === "approve") {
709
+ // ── Approval action execution ─────────────────────────────────────
710
+ // Shared by the slash command bridge and the reaction-based approval handler.
711
+ async function executeApprovalAction(approval, action) {
712
+ if (action === "approve") {
580
713
  switch (approval.type) {
581
714
  case "dm":
582
715
  await addToDmAllowlist(approval.requestingShip);
583
- // Process the original message if available
584
716
  if (approval.originalMessage) {
585
717
  runtime.log?.(`[tlon] Processing original message from ${approval.requestingShip} after approval`);
586
718
  await processMessage({
@@ -596,9 +728,8 @@ export async function monitorTlonProvider(opts = {}) {
596
728
  case "channel":
597
729
  if (approval.channelNest) {
598
730
  await addToChannelAllowlist(approval.requestingShip, approval.channelNest);
599
- // Process the original message if available
600
731
  if (approval.originalMessage) {
601
- const parsed = parseChannelNest(approval.channelNest);
732
+ const nest = parseChannelNest(approval.channelNest);
602
733
  runtime.log?.(`[tlon] Processing original message from ${approval.requestingShip} in ${approval.channelNest} after approval`);
603
734
  await processMessage({
604
735
  messageId: approval.originalMessage.messageId,
@@ -607,8 +738,8 @@ export async function monitorTlonProvider(opts = {}) {
607
738
  messageContent: approval.originalMessage.messageContent,
608
739
  isGroup: true,
609
740
  channelNest: approval.channelNest,
610
- hostShip: parsed?.hostShip,
611
- channelName: parsed?.channelName,
741
+ hostShip: nest?.hostShip,
742
+ channelName: nest?.channelName,
612
743
  timestamp: approval.originalMessage.timestamp,
613
744
  parentId: approval.originalMessage.parentId,
614
745
  isThreadReply: approval.originalMessage.isThreadReply,
@@ -617,7 +748,6 @@ export async function monitorTlonProvider(opts = {}) {
617
748
  }
618
749
  break;
619
750
  case "group":
620
- // Accept the group invite (don't add to allowlist - each invite requires approval)
621
751
  if (approval.groupFlag) {
622
752
  try {
623
753
  await api.poke({
@@ -629,8 +759,6 @@ export async function monitorTlonProvider(opts = {}) {
629
759
  },
630
760
  });
631
761
  runtime.log?.(`[tlon] Joined group ${approval.groupFlag} after approval`);
632
- // Immediately discover channels from the newly joined group
633
- // Small delay to allow the join to propagate
634
762
  setTimeout(async () => {
635
763
  try {
636
764
  const discoveredChannels = await fetchAllChannels(api, runtime);
@@ -656,58 +784,52 @@ export async function monitorTlonProvider(opts = {}) {
656
784
  }
657
785
  break;
658
786
  }
659
- await sendOwnerNotification(formatApprovalConfirmation(approval, "approve"));
660
787
  }
661
- else if (parsed.action === "block") {
662
- // Block the ship using Tlon's native blocking
788
+ else if (action === "block") {
663
789
  await blockShip(approval.requestingShip);
664
- await sendOwnerNotification(formatApprovalConfirmation(approval, "block"));
790
+ await removeFromDmAllowlist(approval.requestingShip);
665
791
  }
666
- else {
667
- // Denied - just remove from pending, no notification to requester
668
- await sendOwnerNotification(formatApprovalConfirmation(approval, "deny"));
669
- }
670
- // Remove from pending
792
+ // "deny" — no side effects beyond removing from pending
671
793
  pendingApprovals = removePendingApproval(pendingApprovals, approval.id);
672
794
  await savePendingApprovals();
673
- return true;
795
+ return formatApprovalConfirmation(approval, action, buildDisplayContext());
674
796
  }
675
- // Handle admin commands from owner (unblock, blocked, pending)
676
- async function handleAdminCommand(text) {
677
- const command = parseAdminCommand(text);
678
- if (!command) {
679
- return false;
680
- }
681
- switch (command.type) {
682
- case "blocked": {
683
- const blockedShips = await getBlockedShips();
684
- await sendOwnerNotification(formatBlockedList(blockedShips));
685
- runtime.log?.(`[tlon] Owner requested blocked ships list (${blockedShips.length} ships)`);
686
- return true;
687
- }
688
- case "pending": {
689
- await sendOwnerNotification(formatPendingList(pendingApprovals));
690
- runtime.log?.(`[tlon] Owner requested pending approvals list (${pendingApprovals.length} pending)`);
691
- return true;
692
- }
693
- case "unblock": {
694
- const shipToUnblock = command.ship;
695
- const isBlocked = await isShipBlocked(shipToUnblock);
696
- if (!isBlocked) {
697
- await sendOwnerNotification(`${shipToUnblock} is not blocked.`);
698
- return true;
699
- }
700
- const success = await unblockShip(shipToUnblock);
701
- if (success) {
702
- await sendOwnerNotification(`Unblocked ${shipToUnblock}.`);
703
- }
704
- else {
705
- await sendOwnerNotification(`Failed to unblock ${shipToUnblock}.`);
706
- }
707
- return true;
797
+ // ── Command bridge ──────────────────────────────────────────────────
798
+ // Exposes approval/admin actions to slash commands registered in index.ts.
799
+ // Handlers return response text; the slash command framework sends it back.
800
+ const accountKey = opts.accountId ?? undefined;
801
+ const commandBridge = {
802
+ get ownerShip() {
803
+ return effectiveOwnerShip;
804
+ },
805
+ async handleAction(action, id) {
806
+ // Prune expired approvals
807
+ pendingApprovals = pruneExpired(pendingApprovals);
808
+ await savePendingApprovals();
809
+ const approval = findPendingApproval(pendingApprovals, id);
810
+ if (!approval) {
811
+ return "No pending approval found" + (id ? ` for ID: #${id}` : ".");
708
812
  }
709
- }
710
- }
813
+ return executeApprovalAction(approval, action);
814
+ },
815
+ async getPendingList() {
816
+ return formatPendingList(pendingApprovals, buildDisplayContext());
817
+ },
818
+ async getBlockedList() {
819
+ const blockedShips = await getBlockedShips();
820
+ return formatBlockedList(blockedShips);
821
+ },
822
+ async handleUnblock(ship) {
823
+ runtime.log?.(`[tlon] handleUnblock: checking if ${ship} is blocked...`);
824
+ const blocked = await isShipBlocked(ship);
825
+ if (!blocked) {
826
+ return `${ship} is not blocked.`;
827
+ }
828
+ const success = await unblockShip(ship);
829
+ return success ? `Unblocked ${ship}.` : `Failed to unblock ${ship}.`;
830
+ },
831
+ };
832
+ setBridge(accountKey, commandBridge);
711
833
  // Check if a ship is the owner (always allowed to DM)
712
834
  function isOwner(ship) {
713
835
  if (!effectiveOwnerShip) {
@@ -731,14 +853,14 @@ export async function monitorTlonProvider(opts = {}) {
731
853
  return /^~?[a-z-]+$/i.test(normalized) ? normalized : "";
732
854
  }
733
855
  const processMessage = async (params) => {
734
- const { messageId, senderShip, isGroup, channelNest, hostShip, channelName, timestamp, parentId, isThreadReply, messageContent, } = params;
856
+ const { messageId, senderShip, isGroup, channelNest, hostShip: _hostShip, channelName: _channelName, timestamp, parentId, isThreadReply, messageContent, } = params;
735
857
  // replyParentId overrides parentId for the deliver callback (thread reply routing)
736
858
  // but doesn't affect the ctx payload (MessageThreadId/ReplyToId).
737
859
  // Used for reactions: agent sees no thread context (so it responds), but
738
860
  // the reply is still delivered as a thread reply.
739
861
  const deliverParentId = params.replyParentId ?? parentId;
740
862
  const groupChannel = channelNest; // For compatibility
741
- let messageText = params.messageText;
863
+ let messageText = sanitizeMessageText(params.messageText);
742
864
  const rawMessageText = messageText; // Preserve original before any modifications
743
865
  // Strip bot mention EARLY, before thread context is prepended.
744
866
  // This ensures [Current message] in thread context won't contain the bot ship name,
@@ -803,7 +925,7 @@ export async function monitorTlonProvider(opts = {}) {
803
925
  if (threadHistory.length > 0) {
804
926
  const threadContext = threadHistory
805
927
  .slice(-10) // Last 10 messages for context
806
- .map((msg) => `${msg.author}: ${msg.content}`)
928
+ .map((msg) => `${msg.author}: ${sanitizeMessageText(msg.content)}`)
807
929
  .join("\n");
808
930
  // Prepend thread context to the message
809
931
  // Include note about ongoing conversation for agent judgment
@@ -824,6 +946,7 @@ export async function monitorTlonProvider(opts = {}) {
824
946
  const noHistoryMsg = "I couldn't fetch any messages for this channel. It might be empty or there might be a permissions issue.";
825
947
  if (isGroup && groupChannel) {
826
948
  await sendChannelPost({
949
+ botProfile: getBotProfile(),
827
950
  fromShip: botShipName,
828
951
  nest: groupChannel,
829
952
  story: markdownToStory(noHistoryMsg),
@@ -831,6 +954,7 @@ export async function monitorTlonProvider(opts = {}) {
831
954
  }
832
955
  else {
833
956
  await sendDm({
957
+ botProfile: getBotProfile(),
834
958
  fromShip: botShipName,
835
959
  toShip: senderShip,
836
960
  text: noHistoryMsg,
@@ -839,7 +963,7 @@ export async function monitorTlonProvider(opts = {}) {
839
963
  return;
840
964
  }
841
965
  const historyText = history
842
- .map((msg) => `[${new Date(msg.timestamp).toLocaleString()}] ${msg.author}: ${msg.content}`)
966
+ .map((msg) => `[${new Date(msg.timestamp).toLocaleString()}] ${msg.author}: ${sanitizeMessageText(msg.content)}`)
843
967
  .join("\n");
844
968
  messageText =
845
969
  `Please summarize this channel conversation (${history.length} recent messages):\n\n${historyText}\n\n` +
@@ -853,13 +977,14 @@ export async function monitorTlonProvider(opts = {}) {
853
977
  const errorMsg = `Sorry, I encountered an error while fetching the channel history: ${error?.message ?? String(error)}`;
854
978
  if (isGroup && groupChannel) {
855
979
  await sendChannelPost({
980
+ botProfile: getBotProfile(),
856
981
  fromShip: botShipName,
857
982
  nest: groupChannel,
858
983
  story: markdownToStory(errorMsg),
859
984
  });
860
985
  }
861
986
  else {
862
- await sendDm({ fromShip: botShipName, toShip: senderShip, text: errorMsg });
987
+ await sendDm({ botProfile: getBotProfile(), fromShip: botShipName, toShip: senderShip, text: errorMsg });
863
988
  }
864
989
  return;
865
990
  }
@@ -894,6 +1019,7 @@ export async function monitorTlonProvider(opts = {}) {
894
1019
  `Docs: https://docs.openclaw.ai/concepts/session#secure-dm-mode`;
895
1020
  // Send async, don't block message processing
896
1021
  sendDm({
1022
+ botProfile: getBotProfile(),
897
1023
  fromShip: botShipName,
898
1024
  toShip: effectiveOwnerShip,
899
1025
  text: warningMsg,
@@ -903,6 +1029,9 @@ export async function monitorTlonProvider(opts = {}) {
903
1029
  senders.add(senderShip);
904
1030
  }
905
1031
  const senderRole = isOwner(senderShip) ? "owner" : "user";
1032
+ // Store role for before_tool_call hook (tool access control)
1033
+ setSessionRole(route.sessionKey, senderRole);
1034
+ runtime.log?.(`[tlon] Stored session role: sessionKey=${route.sessionKey}, role=${senderRole}`);
906
1035
  const senderDisplay = formatShipWithNickname(senderShip);
907
1036
  const fromLabel = isGroup
908
1037
  ? `${senderDisplay} [${senderRole}] in ${channelNest}`
@@ -969,7 +1098,7 @@ export async function monitorTlonProvider(opts = {}) {
969
1098
  // Include downloaded media attachments
970
1099
  ...(attachments.length > 0 && { Attachments: attachments }),
971
1100
  OriginatingChannel: "tlon",
972
- OriginatingTo: `tlon:${isGroup ? groupChannel : botShipName}`,
1101
+ OriginatingTo: `tlon:${isGroup ? groupChannel : senderShip}`,
973
1102
  // Include thread context for automatic reply routing
974
1103
  ...(parentId && { MessageThreadId: String(parentId), ReplyToId: String(parentId) }),
975
1104
  });
@@ -987,18 +1116,33 @@ export async function monitorTlonProvider(opts = {}) {
987
1116
  if (!replyText) {
988
1117
  return;
989
1118
  }
1119
+ // Process any block directives in the response (strips them from text)
1120
+ replyText = await processBlockDirectives(replyText, senderShip);
1121
+ if (!replyText) {
1122
+ return;
1123
+ } // Response was only a directive
990
1124
  // Use settings store value if set, otherwise fall back to file config
991
1125
  const showSignature = effectiveShowModelSig;
992
1126
  if (showSignature) {
1127
+ const modelCfg = cfg.agents?.defaults?.model;
993
1128
  const modelInfo = payload.metadata?.model ||
994
1129
  payload.model ||
995
1130
  route.model ||
996
- cfg.agents?.defaults?.model?.primary;
1131
+ (typeof modelCfg === "string" ? modelCfg : modelCfg?.primary);
997
1132
  replyText = `${replyText}\n\n_[Generated by ${formatModelName(modelInfo)}]_`;
998
1133
  }
1134
+ // Add addendum if this is the last response before bot rate limit
1135
+ if (isGroup && groupChannel && knownBotShips.has(senderShip)) {
1136
+ const count = consecutiveBotMessages.get(groupChannel) ?? 0;
1137
+ if (maxBotResponses > 0 && count === maxBotResponses) {
1138
+ const otherBot = formatShipWithNickname(senderShip);
1139
+ replyText += `\n\n---\n_This is my last response to ${otherBot} for now. To continue our conversation, someone will need to mention me._`;
1140
+ }
1141
+ }
999
1142
  if (isGroup && groupChannel) {
1000
1143
  // Send to any channel type (chat, heap, diary) using the nest directly
1001
1144
  await sendChannelPost({
1145
+ botProfile: getBotProfile(),
1002
1146
  fromShip: botShipName,
1003
1147
  nest: groupChannel,
1004
1148
  story: markdownToStory(replyText),
@@ -1011,7 +1155,13 @@ export async function monitorTlonProvider(opts = {}) {
1011
1155
  }
1012
1156
  }
1013
1157
  else {
1014
- await sendDm({ fromShip: botShipName, toShip: senderShip, text: replyText, replyToId: deliverParentId ? String(deliverParentId) : undefined });
1158
+ await sendDm({
1159
+ botProfile: getBotProfile(),
1160
+ fromShip: botShipName,
1161
+ toShip: senderShip,
1162
+ text: replyText,
1163
+ replyToId: deliverParentId ? String(deliverParentId) : undefined,
1164
+ });
1015
1165
  }
1016
1166
  },
1017
1167
  onError: (err, info) => {
@@ -1117,16 +1267,16 @@ export async function monitorTlonProvider(opts = {}) {
1117
1267
  // Handle post responses (new posts and replies)
1118
1268
  const essay = response?.post?.["r-post"]?.set?.essay;
1119
1269
  const memo = response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.memo;
1120
- if (!essay && !memo) {
1270
+ const content = memo || essay;
1271
+ if (!content) {
1121
1272
  return;
1122
1273
  }
1123
- const content = memo || essay;
1124
1274
  const isThreadReply = Boolean(memo);
1125
1275
  const messageId = isThreadReply ? response?.post?.["r-post"]?.reply?.id : response?.post?.id;
1126
1276
  if (!processedTracker.mark(messageId)) {
1127
1277
  return;
1128
1278
  }
1129
- const senderShip = normalizeShip(content?.author ?? "");
1279
+ const senderShip = normalizeShip(extractAuthorShip(content?.author));
1130
1280
  if (!senderShip) {
1131
1281
  return;
1132
1282
  }
@@ -1144,10 +1294,18 @@ export async function monitorTlonProvider(opts = {}) {
1144
1294
  timestamp: content.sent || Date.now(),
1145
1295
  id: messageId,
1146
1296
  });
1297
+ // Check if sender is a bot (BotProfile object has ship, nickname, avatar)
1298
+ const authorRaw = content?.author;
1299
+ const isSenderBot = typeof authorRaw === 'object' && authorRaw !== null && 'ship' in authorRaw;
1300
+ if (isSenderBot) {
1301
+ knownBotShips.add(senderShip);
1302
+ }
1147
1303
  // Skip processing bot's own messages (but they're already cached above)
1148
1304
  if (senderShip === botShipName) {
1149
1305
  return;
1150
1306
  }
1307
+ // Check if sender is a known bot (for rate limiting later)
1308
+ const isKnownBot = isSenderBot || knownBotShips.has(senderShip);
1151
1309
  // Get thread info early for participation check
1152
1310
  const seal = isThreadReply
1153
1311
  ? response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.seal
@@ -1165,6 +1323,22 @@ export async function monitorTlonProvider(opts = {}) {
1165
1323
  if (inParticipatedThread && !mentioned) {
1166
1324
  runtime.log?.(`[tlon] Responding to thread we participated in (no mention): ${parentId}`);
1167
1325
  }
1326
+ // Rate limit consecutive bot responses (only in group channels)
1327
+ if (isKnownBot) {
1328
+ const count = (consecutiveBotMessages.get(nest) ?? 0) + 1;
1329
+ consecutiveBotMessages.set(nest, count);
1330
+ runtime.log?.(`[tlon] Bot mention from ${senderShip} in ${nest}: consecutive count = ${count}`);
1331
+ if (maxBotResponses > 0 && count > maxBotResponses) {
1332
+ runtime.log?.(`[tlon] Rate limiting: skipping response to bot ${senderShip} (count ${count} > limit ${maxBotResponses})`);
1333
+ return;
1334
+ }
1335
+ }
1336
+ else {
1337
+ // Human mention resets the consecutive bot counter
1338
+ // (requires explicit engagement, not just any human message)
1339
+ consecutiveBotMessages.set(nest, 0);
1340
+ runtime.log?.(`[tlon] Human mention from ${senderShip} in ${nest}: reset bot counter`);
1341
+ }
1168
1342
  // Owner is always allowed
1169
1343
  if (isOwner(senderShip)) {
1170
1344
  runtime.log?.(`[tlon] Owner ${senderShip} is always allowed in channels`);
@@ -1189,7 +1363,7 @@ export async function monitorTlonProvider(opts = {}) {
1189
1363
  parentId: parentId ?? undefined,
1190
1364
  isThreadReply,
1191
1365
  },
1192
- });
1366
+ }, pendingApprovals.map((a) => a.id));
1193
1367
  await queueApprovalRequest(approval);
1194
1368
  }
1195
1369
  else {
@@ -1268,7 +1442,7 @@ export async function monitorTlonProvider(opts = {}) {
1268
1442
  type: "dm",
1269
1443
  requestingShip: ship,
1270
1444
  messagePreview: "(DM invite - no message yet)",
1271
- });
1445
+ }, pendingApprovals.map((a) => a.id));
1272
1446
  await queueApprovalRequest(approval);
1273
1447
  processedDmInvites.add(ship); // Mark as processed to avoid duplicate notifications
1274
1448
  }
@@ -1290,9 +1464,34 @@ export async function monitorTlonProvider(opts = {}) {
1290
1464
  if (dmAddReact || dmDelReact) {
1291
1465
  const isAdd = Boolean(dmAddReact);
1292
1466
  const reactData = dmAddReact || dmDelReact;
1293
- const reactAuthor = normalizeShip(reactData?.author ?? reactData?.ship ?? "");
1294
- const reactEmoji = reactData?.react ?? "";
1467
+ const reactAuthor = normalizeShip(extractAuthorShip(reactData?.author) || reactData?.ship || "");
1468
+ const reactEmoji = dmAddReact?.react ?? "";
1295
1469
  if (reactAuthor && reactAuthor !== botShipName) {
1470
+ // Check if this is an approval reaction from the owner on a notification message
1471
+ if (isAdd && isOwner(reactAuthor)) {
1472
+ const approvalAction = emojiToApprovalAction(reactEmoji);
1473
+ if (approvalAction) {
1474
+ const normalizedEventId = normalizeNotificationId(messageId);
1475
+ const matchedApproval = pendingApprovals.find((a) => a.notificationMessageId === normalizedEventId);
1476
+ if (matchedApproval) {
1477
+ if (isExpired(matchedApproval)) {
1478
+ runtime.log?.(`[tlon] Ignoring reaction on expired approval #${matchedApproval.id}`);
1479
+ // Fall through to normal reaction handling
1480
+ }
1481
+ else {
1482
+ runtime.log?.(`[tlon] Reaction-based approval: ${reactEmoji} → ${approvalAction} for #${matchedApproval.id}`);
1483
+ try {
1484
+ const confirmText = await executeApprovalAction(matchedApproval, approvalAction);
1485
+ await sendOwnerNotification(confirmText);
1486
+ }
1487
+ catch (err) {
1488
+ runtime.error?.(`[tlon] Reaction approval error: ${String(err)}`);
1489
+ }
1490
+ return;
1491
+ }
1492
+ }
1493
+ }
1494
+ }
1296
1495
  try {
1297
1496
  const partnerShip = extractDmPartnerShip(whom);
1298
1497
  const route = core.channel.routing.resolveAgentRoute({
@@ -1354,7 +1553,7 @@ export async function monitorTlonProvider(opts = {}) {
1354
1553
  dmReplyOwnId = dmReply.id ?? dmReply.delta?.add?.id;
1355
1554
  // If no explicit reply ID, construct from author/sent (same format as our outbound)
1356
1555
  if (!dmReplyOwnId && dmReplyMemo?.author && dmReplyMemo?.sent) {
1357
- dmReplyOwnId = `${normalizeShip(dmReplyMemo.author)}/${dmReplyMemo.sent}`;
1556
+ dmReplyOwnId = `${normalizeShip(extractAuthorShip(dmReplyMemo.author))}/${dmReplyMemo.sent}`;
1358
1557
  }
1359
1558
  }
1360
1559
  if (!dmContent) {
@@ -1365,7 +1564,7 @@ export async function monitorTlonProvider(opts = {}) {
1365
1564
  if (!processedTracker.mark(effectiveMessageId)) {
1366
1565
  return;
1367
1566
  }
1368
- const authorShip = normalizeShip(dmContent?.author ?? "");
1567
+ const authorShip = normalizeShip(extractAuthorShip(dmContent?.author));
1369
1568
  const partnerShip = extractDmPartnerShip(whom);
1370
1569
  const senderShip = partnerShip || authorShip;
1371
1570
  // Cache DM messages (including bot's own) so reaction lookups have context
@@ -1397,22 +1596,6 @@ export async function monitorTlonProvider(opts = {}) {
1397
1596
  if (!messageText.trim()) {
1398
1597
  return;
1399
1598
  }
1400
- // Check if this is the owner sending an approval response
1401
- if (isOwner(senderShip) && isApprovalResponse(messageText)) {
1402
- const handled = await handleApprovalResponse(messageText);
1403
- if (handled) {
1404
- runtime.log?.(`[tlon] Processed approval response from owner: ${messageText}`);
1405
- return;
1406
- }
1407
- }
1408
- // Check if this is the owner sending an admin command
1409
- if (isOwner(senderShip) && isAdminCommand(messageText)) {
1410
- const handled = await handleAdminCommand(messageText);
1411
- if (handled) {
1412
- runtime.log?.(`[tlon] Processed admin command from owner: ${messageText}`);
1413
- return;
1414
- }
1415
- }
1416
1599
  // Owner is always allowed to DM (bypass allowlist)
1417
1600
  if (isOwner(senderShip)) {
1418
1601
  runtime.log?.(`[tlon] Processing DM from owner ${senderShip}${isDmThreadReply ? ` (thread reply, parent=${dmReplyParentId}, replyId=${effectiveMessageId})` : ""}`);
@@ -1442,7 +1625,7 @@ export async function monitorTlonProvider(opts = {}) {
1442
1625
  messageContent: dmContent.content,
1443
1626
  timestamp: dmContent.sent || Date.now(),
1444
1627
  },
1445
- });
1628
+ }, pendingApprovals.map((a) => a.id));
1446
1629
  await queueApprovalRequest(approval);
1447
1630
  }
1448
1631
  else {
@@ -1471,7 +1654,7 @@ export async function monitorTlonProvider(opts = {}) {
1471
1654
  await api.subscribe({
1472
1655
  app: "channels",
1473
1656
  path: "/v2",
1474
- event: handleChannelsFirehose,
1657
+ event: (data) => handleChannelsFirehose(data),
1475
1658
  err: (error) => {
1476
1659
  runtime.error?.(`[tlon] Channels firehose error: ${String(error)}`);
1477
1660
  },
@@ -1484,7 +1667,7 @@ export async function monitorTlonProvider(opts = {}) {
1484
1667
  await api.subscribe({
1485
1668
  app: "chat",
1486
1669
  path: "/v3",
1487
- event: handleChatFirehose,
1670
+ event: (data) => handleChatFirehose(data),
1488
1671
  err: (error) => {
1489
1672
  runtime.error?.(`[tlon] Chat firehose error: ${String(error)}`);
1490
1673
  },
@@ -1502,18 +1685,23 @@ export async function monitorTlonProvider(opts = {}) {
1502
1685
  // Look for self profile updates
1503
1686
  if (event?.self) {
1504
1687
  const selfUpdate = event.self;
1505
- if (selfUpdate?.contact?.nickname?.value !== undefined) {
1688
+ if (selfUpdate?.contact?.nickname?.value !== undefined || selfUpdate?.contact?.avatar?.value !== undefined) {
1506
1689
  const newNickname = selfUpdate.contact.nickname.value || null;
1507
1690
  if (newNickname !== botNickname) {
1508
1691
  botNickname = newNickname;
1509
1692
  runtime.log?.(`[tlon] Bot nickname updated: ${botNickname}`);
1510
1693
  if (botNickname) {
1511
- nicknameCache.set(botShipName, botNickname);
1694
+ nicknameCache.set(botShipName, sanitizeNickname(botNickname));
1512
1695
  }
1513
1696
  else {
1514
1697
  nicknameCache.delete(botShipName);
1515
1698
  }
1516
1699
  }
1700
+ const newAvatar = selfUpdate.contact?.avatar?.value || null;
1701
+ if (newAvatar !== botAvatar) {
1702
+ botAvatar = newAvatar;
1703
+ runtime.log?.(`[tlon] Bot avatar updated: ${botAvatar ? "set" : "cleared"}`);
1704
+ }
1517
1705
  }
1518
1706
  }
1519
1707
  // Look for peer profile updates (other users)
@@ -1522,7 +1710,7 @@ export async function monitorTlonProvider(opts = {}) {
1522
1710
  const nickname = event.peer.contact?.nickname?.value ?? event.peer.contact?.nickname;
1523
1711
  if (ship) {
1524
1712
  if (nickname && typeof nickname === "string") {
1525
- nicknameCache.set(ship, nickname);
1713
+ nicknameCache.set(ship, sanitizeNickname(nickname));
1526
1714
  }
1527
1715
  else {
1528
1716
  nicknameCache.delete(ship);
@@ -1557,11 +1745,10 @@ export async function monitorTlonProvider(opts = {}) {
1557
1745
  // Note: we don't remove channels from watchedChannels to avoid missing messages
1558
1746
  // during transitions. The authorization check handles access control.
1559
1747
  }
1560
- // Update DM allowlist
1748
+ // Update DM allowlist — respect empty lists (don't fall back to file config)
1561
1749
  if (newSettings.dmAllowlist !== undefined) {
1562
- effectiveDmAllowlist =
1563
- newSettings.dmAllowlist.length > 0 ? newSettings.dmAllowlist : account.dmAllowlist;
1564
- runtime.log?.(`[tlon] Settings: dmAllowlist updated to ${effectiveDmAllowlist.join(", ")}`);
1750
+ effectiveDmAllowlist = newSettings.dmAllowlist;
1751
+ runtime.log?.(`[tlon] Settings: dmAllowlist updated to ${effectiveDmAllowlist.length > 0 ? effectiveDmAllowlist.join(", ") : "(empty)"}`);
1565
1752
  }
1566
1753
  // Update model signature setting
1567
1754
  if (newSettings.showModelSig !== undefined) {
@@ -1803,7 +1990,8 @@ export async function monitorTlonProvider(opts = {}) {
1803
1990
  type: "group",
1804
1991
  requestingShip: inviterShip,
1805
1992
  groupFlag,
1806
- });
1993
+ groupTitle: validInvite.preview?.meta?.title,
1994
+ }, pendingApprovals.map((a) => a.id));
1807
1995
  await queueApprovalRequest(approval);
1808
1996
  processedGroupInvites.add(groupFlag);
1809
1997
  }
@@ -1822,7 +2010,8 @@ export async function monitorTlonProvider(opts = {}) {
1822
2010
  type: "group",
1823
2011
  requestingShip: inviterShip,
1824
2012
  groupFlag,
1825
- });
2013
+ groupTitle: validInvite.preview?.meta?.title,
2014
+ }, pendingApprovals.map((a) => a.id));
1826
2015
  await queueApprovalRequest(approval);
1827
2016
  processedGroupInvites.add(groupFlag);
1828
2017
  }
@@ -1915,11 +2104,36 @@ export async function monitorTlonProvider(opts = {}) {
1915
2104
  }
1916
2105
  }
1917
2106
  }, 2 * 60 * 1000);
2107
+ // Periodically re-scry settings as a fallback for stale subscriptions.
2108
+ // The settings subscription can silently die (SSE quit without reconnect),
2109
+ // leaving the in-memory allowlist permanently stale.
2110
+ const settingsRefreshInterval = setInterval(async () => {
2111
+ if (opts.abortSignal?.aborted) {
2112
+ return;
2113
+ }
2114
+ try {
2115
+ const refreshed = await settingsManager.load();
2116
+ if (refreshed.dmAllowlist !== undefined) {
2117
+ const newList = refreshed.dmAllowlist;
2118
+ if (JSON.stringify(newList) !== JSON.stringify(effectiveDmAllowlist)) {
2119
+ effectiveDmAllowlist = newList;
2120
+ runtime.log?.(`[tlon] Settings refresh: dmAllowlist updated to ${effectiveDmAllowlist.join(", ")}`);
2121
+ }
2122
+ }
2123
+ if (refreshed.defaultAuthorizedShips !== undefined) {
2124
+ currentSettings = { ...currentSettings, defaultAuthorizedShips: refreshed.defaultAuthorizedShips };
2125
+ }
2126
+ }
2127
+ catch (err) {
2128
+ runtime.error?.(`[tlon] Settings refresh failed: ${String(err)}`);
2129
+ }
2130
+ }, 5 * 60 * 1000);
1918
2131
  if (opts.abortSignal) {
1919
2132
  const signal = opts.abortSignal;
1920
2133
  await new Promise((resolve) => {
1921
2134
  signal.addEventListener("abort", () => {
1922
2135
  clearInterval(pollInterval);
2136
+ clearInterval(settingsRefreshInterval);
1923
2137
  resolve(null);
1924
2138
  }, { once: true });
1925
2139
  });
@@ -1929,6 +2143,7 @@ export async function monitorTlonProvider(opts = {}) {
1929
2143
  }
1930
2144
  }
1931
2145
  finally {
2146
+ removeBridge(accountKey, commandBridge);
1932
2147
  try {
1933
2148
  await api?.close();
1934
2149
  }