@tritard/waterbrother 0.16.47 → 0.16.49

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/README.md CHANGED
@@ -297,6 +297,8 @@ Current Telegram behavior:
297
297
  - pending pairings are explicit and expire automatically after 12 hours unless approved
298
298
  - paired Telegram users drive the same live session and permissions as the terminal operator when the TUI bridge is attached
299
299
  - Telegram now supports remote workspace control with `/project`, `/project use <path|name>`, `/project new <name>`, `/project share`, plus the lower-level `/cwd`, `/use <path>`, `/desktop`, and `/new-project <name>` commands
300
+ - Telegram owners can pair visible chat participants conversationally or with `/pair <user-id|@username>`, then continue with shared-room invites without dropping back to the terminal
301
+ - Telegram owners can also use conversational project and member requests like “share this project in this chat”, “switch to TelegramTest”, or “add Austin as editor”, and Waterbrother routes those through the same project and shared-room actions
300
302
  - shared projects now support `/room`, `/events`, `/members`, `/invites`, `/whoami`, `/people`, `/tasks`, `/mode`, `/claim`, `/release`, `/invite`, `/accept-invite`, `/approve-invite`, `/reject-invite`, `/remove-member`, `/room-runtime`, and `/task ...` from Telegram
301
303
  - Telegram now supports `/whoami` for Telegram-id and shared-room membership visibility, plus `/events` for recent shared-room activity
302
304
  - `/room` now includes pending invite count plus task ownership summaries
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tritard/waterbrother",
3
- "version": "0.16.47",
3
+ "version": "0.16.49",
4
4
  "description": "Waterbrother: bring-your-own-model coding CLI with local tools, sessions, operator modes, and approval controls",
5
5
  "type": "module",
6
6
  "bin": {
package/src/gateway.js CHANGED
@@ -7,6 +7,7 @@ import { promisify } from "node:util";
7
7
  import { createSession, listSessions, loadSession, saveSession } from "./session-store.js";
8
8
  import { DEFAULT_PENDING_PAIRING_TTL_MINUTES, loadGatewayBridge, loadGatewayState, prunePendingPairings, saveGatewayBridge, saveGatewayState } from "./gateway-state.js";
9
9
  import { getGatewayStatus, getChannelSpec } from "./channels.js";
10
+ import { getConfigPath, loadConfigLayers, saveConfig } from "./config.js";
10
11
  import { canonicalizeLoosePath } from "./path-utils.js";
11
12
  import { acceptSharedInvite, addSharedTask, approveSharedInvite, assignSharedTask, claimSharedOperator, claimSharedTask, commentSharedTask, createSharedInvite, enableSharedProject, getSharedTaskHistory, listSharedEvents, listSharedInvites, listSharedTasks, loadSharedProject, moveSharedTask, rejectSharedInvite, releaseSharedOperator, setSharedRoom, setSharedRoomMode, setSharedRuntimeProfile, removeSharedMember, upsertSharedMember } from "./shared-project.js";
12
13
  import { buildSelfAwarenessManifest, formatAboutWaterbrother, formatSelfState, resolveLocalConceptQuestion } from "./self-awareness.js";
@@ -29,6 +30,7 @@ const TELEGRAM_COMMANDS = [
29
30
  { command: "status", description: "Show the linked remote session" },
30
31
  { command: "whoami", description: "Show your Telegram identity and room membership" },
31
32
  { command: "people", description: "Show recently seen Telegram people in this chat" },
33
+ { command: "pair", description: "Pair a known Telegram user from this chat" },
32
34
  { command: "project", description: "Show or change the linked project" },
33
35
  { command: "cwd", description: "Show the current remote working directory" },
34
36
  { command: "runtime", description: "Show active runtime status" },
@@ -210,6 +212,7 @@ function buildRemoteHelp() {
210
212
  "<code>/status</code> show the current linked remote session",
211
213
  "<code>/whoami</code> show your Telegram identity and room membership",
212
214
  "<code>/people</code> list recent Telegram users in this chat for easier invites",
215
+ "<code>/pair &lt;user-id|@username&gt;</code> pair a known Telegram user from this chat",
213
216
  "<code>/project</code> show the linked project and sharing state",
214
217
  "<code>/project use &lt;path|name&gt;</code> switch to another project or directory",
215
218
  "<code>/project new &lt;name&gt;</code> create a Desktop project and switch into it",
@@ -328,6 +331,54 @@ function parseTelegramProjectIntent(text = "") {
328
331
  return null;
329
332
  }
330
333
 
334
+ function parseTelegramMemberIntent(text = "") {
335
+ const value = normalizeTelegramProjectIntentText(text);
336
+ const lowered = value.toLowerCase();
337
+ if (!value) return null;
338
+ if (/^(how|what|why|when|where|who)\b/.test(lowered)) return null;
339
+
340
+ const roleMatch = lowered.match(/\b(owner|editor|observer)s?\b/);
341
+ const role = roleMatch?.[1] || "editor";
342
+ const explicitUsername = value.match(/@([a-zA-Z0-9_]+)/);
343
+
344
+ if (explicitUsername && /\b(add|invite|make|set|promote)\b/.test(lowered)) {
345
+ return { action: "member-upsert", target: `@${explicitUsername[1]}`, role };
346
+ }
347
+
348
+ const patterns = [
349
+ /^(?:add|invite)\s+(.+?)\s+as\s+(owner|editor|observer)\s*$/i,
350
+ /^(?:add|invite)\s+(.+?)\s*$/i,
351
+ /^(?:make|set|promote)\s+(.+?)\s+(?:an?\s+)?(owner|editor|observer)\s*$/i
352
+ ];
353
+ for (const pattern of patterns) {
354
+ const match = value.match(pattern);
355
+ if (!match) continue;
356
+ const target = String(match[1] || "").trim();
357
+ const nextRole = String(match[2] || role || "editor").trim().toLowerCase();
358
+ if (target) {
359
+ return { action: "member-upsert", target, role: nextRole };
360
+ }
361
+ }
362
+
363
+ return null;
364
+ }
365
+
366
+ function parseTelegramPairingIntent(text = "") {
367
+ const value = normalizeTelegramProjectIntentText(text);
368
+ const lowered = value.toLowerCase();
369
+ if (!value) return null;
370
+ if (/^(how|what|why|when|where|who)\b/.test(lowered)) return null;
371
+ const explicitUsername = value.match(/@([a-zA-Z0-9_]+)/);
372
+ if (explicitUsername && /\b(pair|approve|allow)\b/.test(lowered)) {
373
+ return { action: "pair", target: `@${explicitUsername[1]}` };
374
+ }
375
+ const match = value.match(/^(?:pair|approve|allow)\s+(.+?)\s*$/i);
376
+ if (match?.[1]) {
377
+ return { action: "pair", target: match[1].trim() };
378
+ }
379
+ return null;
380
+ }
381
+
331
382
  async function buildTelegramSelfAwarenessMarkup({ cwd, runtime, currentSession, kind = "about" }) {
332
383
  const manifest = await buildSelfAwarenessManifest({ cwd, runtime, currentSession });
333
384
  if (kind === "state") {
@@ -918,6 +969,114 @@ class TelegramGateway {
918
969
  throw new Error(`Unknown Telegram user reference: ${value}. Use /people to list recent users in this chat.`);
919
970
  }
920
971
 
972
+ resolveKnownPerson(message, target) {
973
+ const value = String(target || "").trim();
974
+ if (!value) {
975
+ throw new Error("member target is required");
976
+ }
977
+ if (/^\d+$/.test(value) || value.startsWith("@")) {
978
+ const match = this.resolveInviteTarget(message, value);
979
+ return { userId: match.userId, displayName: match.displayName };
980
+ }
981
+
982
+ const normalized = value.toLowerCase();
983
+ const known = this.listKnownChatPeople(message);
984
+ const exact = known.find((person) => String(person.displayName || "").trim().toLowerCase() === normalized);
985
+ if (exact) {
986
+ return { userId: exact.userId, displayName: exact.displayName || exact.userId };
987
+ }
988
+ const partial = known.filter((person) => {
989
+ const display = String(person.displayName || "").trim().toLowerCase();
990
+ const handle = String(person.usernameHandle || "").trim().toLowerCase();
991
+ return display.includes(normalized) || handle === normalized.replace(/^@/, "");
992
+ });
993
+ if (partial.length === 1) {
994
+ return { userId: partial[0].userId, displayName: partial[0].displayName || partial[0].userId };
995
+ }
996
+ if (partial.length > 1) {
997
+ throw new Error(`Multiple Telegram users matched ${value}. Use /people and invite by id or @username.`);
998
+ }
999
+ throw new Error(`Unknown Telegram person: ${value}. Use /people to see recent users in this chat.`);
1000
+ }
1001
+
1002
+ async handleConversationalMemberIntent(message, sessionId, intent, peer) {
1003
+ const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
1004
+ if (!project?.enabled) {
1005
+ throw new Error("This project is not shared yet. Use /project share first.");
1006
+ }
1007
+ const target = this.resolveKnownPerson(message, intent.target);
1008
+ const actorId = String(message?.from?.id || "").trim();
1009
+ const actorName = peer?.username || [message?.from?.first_name, message?.from?.last_name].filter(Boolean).join(" ").trim() || actorId;
1010
+ const existingMember = Array.isArray(project.members)
1011
+ ? project.members.find((entry) => String(entry?.id || "").trim() === target.userId) || null
1012
+ : null;
1013
+ if (existingMember) {
1014
+ const nextProject = await upsertSharedMember(
1015
+ session.cwd || this.cwd,
1016
+ { id: target.userId, name: target.displayName || target.userId, role: intent.role, paired: true },
1017
+ { actorId, actorName }
1018
+ );
1019
+ return {
1020
+ kind: "member",
1021
+ project: nextProject,
1022
+ markup: `Updated <code>${escapeTelegramHtml(target.displayName || target.userId)}</code> to <code>${escapeTelegramHtml(intent.role)}</code> in this shared project.`
1023
+ };
1024
+ }
1025
+ const result = await createSharedInvite(
1026
+ session.cwd || this.cwd,
1027
+ { id: target.userId, role: intent.role, name: target.displayName || target.userId, paired: true },
1028
+ { actorId, actorName }
1029
+ );
1030
+ return {
1031
+ kind: "invite",
1032
+ project: result.project,
1033
+ markup: `Created pending invite <code>${escapeTelegramHtml(result.invite.id)}</code> for <code>${escapeTelegramHtml(target.displayName || target.userId)}</code> as <code>${escapeTelegramHtml(result.invite.role)}</code>.\nAsk them to run <code>/accept-invite ${escapeTelegramHtml(result.invite.id)}</code>.`
1034
+ };
1035
+ }
1036
+
1037
+ async pairKnownTelegramUser(message, target) {
1038
+ const { session, project } = await this.bindSharedRoomForMessage(message, await this.ensurePeerSession(message));
1039
+ if (project?.enabled) {
1040
+ const owner = Array.isArray(project.members)
1041
+ ? project.members.find((entry) => String(entry?.id || "").trim() === String(message?.from?.id || "").trim())
1042
+ : null;
1043
+ if (!owner || String(owner.role || "").trim() !== "owner") {
1044
+ throw new Error("Only a shared-project owner can pair new Telegram users from this chat.");
1045
+ }
1046
+ }
1047
+
1048
+ const person = this.resolveKnownPerson(message, target);
1049
+ const { userConfig, projectConfig } = await loadConfigLayers(session.cwd || this.cwd);
1050
+ const targetScope = projectConfig?.channels?.telegram ? "project" : "user";
1051
+ const targetConfig = targetScope === "project" ? { ...projectConfig } : { ...userConfig };
1052
+ const channels = targetConfig.channels && typeof targetConfig.channels === "object" ? { ...targetConfig.channels } : {};
1053
+ const telegram = channels.telegram && typeof channels.telegram === "object" ? { ...channels.telegram } : {};
1054
+ const allowed = new Set(Array.isArray(telegram.allowedUserIds) ? telegram.allowedUserIds.map((value) => String(value || "").trim()).filter(Boolean) : []);
1055
+ allowed.add(person.userId);
1056
+ telegram.enabled = telegram.enabled !== false;
1057
+ if (!telegram.pairingMode) telegram.pairingMode = "manual";
1058
+ telegram.allowedUserIds = [...allowed];
1059
+ channels.telegram = telegram;
1060
+ targetConfig.channels = channels;
1061
+ await saveConfig(targetConfig, { scope: targetScope, cwd: session.cwd || this.cwd });
1062
+
1063
+ this.channel.allowedUserIds = telegram.allowedUserIds;
1064
+ if (this.runtime.channels?.telegram) {
1065
+ this.runtime.channels.telegram.allowedUserIds = telegram.allowedUserIds;
1066
+ }
1067
+
1068
+ if (this.state.pendingPairings?.[person.userId]) {
1069
+ delete this.state.pendingPairings[person.userId];
1070
+ }
1071
+ await this.persistState();
1072
+
1073
+ return {
1074
+ userId: person.userId,
1075
+ displayName: person.displayName || person.userId,
1076
+ configPath: getConfigPath({ scope: targetScope, cwd: session.cwd || this.cwd })
1077
+ };
1078
+ }
1079
+
921
1080
  async addPendingPairing(message) {
922
1081
  const { userId, usernameHandle, displayName } = this.describeTelegramUser(message?.from || {});
923
1082
  this.state.pendingPairings[userId] = {
@@ -1507,6 +1666,25 @@ class TelegramGateway {
1507
1666
  return;
1508
1667
  }
1509
1668
 
1669
+ if (text.startsWith("/pair")) {
1670
+ const rawTarget = text.replace("/pair", "").trim();
1671
+ if (!rawTarget) {
1672
+ await this.sendMarkup(message.chat.id, "Usage: <code>/pair &lt;user-id|@username&gt;</code>", message.message_id);
1673
+ return;
1674
+ }
1675
+ try {
1676
+ const result = await this.pairKnownTelegramUser(message, rawTarget);
1677
+ await this.sendMarkup(
1678
+ message.chat.id,
1679
+ `Paired <code>${escapeTelegramHtml(result.displayName)}</code> <i>(${escapeTelegramHtml(result.userId)})</i> via <code>${escapeTelegramHtml(result.configPath)}</code>.`,
1680
+ message.message_id
1681
+ );
1682
+ } catch (error) {
1683
+ await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
1684
+ }
1685
+ return;
1686
+ }
1687
+
1510
1688
  if (text === "/project") {
1511
1689
  const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
1512
1690
  await this.sendMarkup(
@@ -2126,6 +2304,30 @@ Ask them to run <code>/whoami</code> and then <code>/accept-invite ${escapeTeleg
2126
2304
  ? (isExplicitRun ? { kind: "execution", reason: "explicit /run" } : classifyTelegramGroupIntent(promptText))
2127
2305
  : { kind: "execution", reason: "private chat" };
2128
2306
  const sharedBinding = await this.bindSharedRoomForMessage(message, sessionId);
2307
+ const pairingIntent = parseTelegramPairingIntent(promptText);
2308
+ if (pairingIntent) {
2309
+ try {
2310
+ const result = await this.pairKnownTelegramUser(message, pairingIntent.target);
2311
+ await this.sendMarkup(
2312
+ message.chat.id,
2313
+ `Paired <code>${escapeTelegramHtml(result.displayName)}</code> <i>(${escapeTelegramHtml(result.userId)})</i> via <code>${escapeTelegramHtml(result.configPath)}</code>.`,
2314
+ message.message_id
2315
+ );
2316
+ } catch (error) {
2317
+ await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
2318
+ }
2319
+ return;
2320
+ }
2321
+ const memberIntent = parseTelegramMemberIntent(promptText);
2322
+ if (memberIntent) {
2323
+ try {
2324
+ const result = await this.handleConversationalMemberIntent(message, sessionId, memberIntent, peer);
2325
+ await this.sendMarkup(message.chat.id, result.markup, message.message_id);
2326
+ } catch (error) {
2327
+ await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
2328
+ }
2329
+ return;
2330
+ }
2129
2331
  const projectIntent = parseTelegramProjectIntent(promptText);
2130
2332
  if (projectIntent?.action === "show-project") {
2131
2333
  await this.sendMarkup(