@tritard/waterbrother 0.16.23 → 0.16.25
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 +4 -1
- package/package.json +1 -1
- package/src/cli.js +110 -6
- package/src/gateway.js +132 -7
- package/src/shared-project.js +100 -6
package/README.md
CHANGED
|
@@ -265,6 +265,7 @@ Shared project foundation is now live:
|
|
|
265
265
|
- enable it with `waterbrother project share`
|
|
266
266
|
- inspect it with `waterbrother room status`
|
|
267
267
|
- control conversation vs execution with `waterbrother room mode chat|plan|execute`
|
|
268
|
+
- manage collaborators with `waterbrother room members`, `waterbrother room add`, and `waterbrother room remove`
|
|
268
269
|
- claim or release the shared operator lock with `waterbrother room claim` and `waterbrother room release`
|
|
269
270
|
- shared project metadata lives in `.waterbrother/shared.json`
|
|
270
271
|
- human collaboration notes live in `ROUNDTABLE.md`
|
|
@@ -277,8 +278,10 @@ Current Telegram behavior:
|
|
|
277
278
|
- pending pairings are explicit and expire automatically after 12 hours unless approved
|
|
278
279
|
- paired Telegram users drive the same live session and permissions as the terminal operator when the TUI bridge is attached
|
|
279
280
|
- Telegram now supports remote workspace control with `/cwd`, `/use <path>`, `/desktop`, and `/new-project <name>`
|
|
280
|
-
- shared projects now support `/room`, `/mode`, `/claim`, and `/
|
|
281
|
+
- shared projects now support `/room`, `/members`, `/mode`, `/claim`, `/release`, `/invite`, and `/remove-member` from Telegram
|
|
281
282
|
- shared Telegram execution only runs when the shared room is in `execute` mode
|
|
283
|
+
- room administration is owner-only, and only owners/editors can hold the operator lock
|
|
284
|
+
- in Telegram groups, Waterbrother only responds when directly targeted: slash commands, `@botname` mentions, or replies to a bot message
|
|
282
285
|
- pairing is now explicit: first DM creates a pending request, then approve locally with `waterbrother gateway pair telegram <user-id>`
|
|
283
286
|
|
|
284
287
|
## Release flow
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -63,9 +63,12 @@ import {
|
|
|
63
63
|
enableSharedProject,
|
|
64
64
|
formatSharedProjectStatus,
|
|
65
65
|
getSharedProjectPaths,
|
|
66
|
+
listSharedMembers,
|
|
66
67
|
loadSharedProject,
|
|
67
68
|
releaseSharedOperator,
|
|
68
|
-
|
|
69
|
+
removeSharedMember,
|
|
70
|
+
setSharedRoomMode,
|
|
71
|
+
upsertSharedMember
|
|
69
72
|
} from "./shared-project.js";
|
|
70
73
|
|
|
71
74
|
const execFileAsync = promisify(execFile);
|
|
@@ -148,6 +151,9 @@ const INTERACTIVE_COMMANDS = [
|
|
|
148
151
|
{ name: "/share-project", description: "Enable shared-project mode in the current cwd" },
|
|
149
152
|
{ name: "/unshare-project", description: "Disable shared-project mode in the current cwd" },
|
|
150
153
|
{ name: "/room", description: "Show shared room status for the current project" },
|
|
154
|
+
{ name: "/room members", description: "List shared-project members" },
|
|
155
|
+
{ name: "/room add <id> [owner|editor|observer]", description: "Add or update a shared-project member" },
|
|
156
|
+
{ name: "/room remove <id>", description: "Remove a shared-project member" },
|
|
151
157
|
{ name: "/room mode <chat|plan|execute>", description: "Set collaboration mode for the shared room" },
|
|
152
158
|
{ name: "/room claim", description: "Claim operator control for the shared room" },
|
|
153
159
|
{ name: "/room release", description: "Release operator control for the shared room" },
|
|
@@ -272,6 +278,9 @@ Usage:
|
|
|
272
278
|
waterbrother project share
|
|
273
279
|
waterbrother project unshare
|
|
274
280
|
waterbrother room status
|
|
281
|
+
waterbrother room members
|
|
282
|
+
waterbrother room add <member-id> [owner|editor|observer]
|
|
283
|
+
waterbrother room remove <member-id>
|
|
275
284
|
waterbrother room mode <chat|plan|execute>
|
|
276
285
|
waterbrother room claim
|
|
277
286
|
waterbrother room release
|
|
@@ -3758,6 +3767,7 @@ async function runProjectCommand(positional, { cwd = process.cwd(), asJson = fal
|
|
|
3758
3767
|
|
|
3759
3768
|
async function runRoomCommand(positional, { cwd = process.cwd(), asJson = false } = {}) {
|
|
3760
3769
|
const sub = String(positional[1] || "status").trim().toLowerCase();
|
|
3770
|
+
const operator = getLocalOperatorIdentity();
|
|
3761
3771
|
if (sub === "status") {
|
|
3762
3772
|
const project = await loadSharedProject(cwd);
|
|
3763
3773
|
if (asJson) {
|
|
@@ -3768,8 +3778,57 @@ async function runRoomCommand(positional, { cwd = process.cwd(), asJson = false
|
|
|
3768
3778
|
return;
|
|
3769
3779
|
}
|
|
3770
3780
|
|
|
3781
|
+
if (sub === "members") {
|
|
3782
|
+
const members = await listSharedMembers(cwd);
|
|
3783
|
+
if (asJson) {
|
|
3784
|
+
printData({ ok: true, members }, true);
|
|
3785
|
+
return;
|
|
3786
|
+
}
|
|
3787
|
+
if (!members.length) {
|
|
3788
|
+
console.log("No shared-project members");
|
|
3789
|
+
return;
|
|
3790
|
+
}
|
|
3791
|
+
for (const member of members) {
|
|
3792
|
+
console.log(`${member.id}\t${member.role}\t${member.name || ""}`);
|
|
3793
|
+
}
|
|
3794
|
+
return;
|
|
3795
|
+
}
|
|
3796
|
+
|
|
3797
|
+
if (sub === "add") {
|
|
3798
|
+
const memberId = String(positional[2] || "").trim();
|
|
3799
|
+
const role = String(positional[3] || "editor").trim().toLowerCase();
|
|
3800
|
+
const name = String(positional.slice(4).join(" ") || memberId).trim();
|
|
3801
|
+
if (!memberId) {
|
|
3802
|
+
throw new Error("Usage: waterbrother room add <member-id> [owner|editor|observer] [display name]");
|
|
3803
|
+
}
|
|
3804
|
+
const project = await upsertSharedMember(
|
|
3805
|
+
cwd,
|
|
3806
|
+
{ id: memberId, role, name, paired: true },
|
|
3807
|
+
{ actorId: operator.id }
|
|
3808
|
+
);
|
|
3809
|
+
if (asJson) {
|
|
3810
|
+
printData({ ok: true, action: "add", memberId, role, project }, true);
|
|
3811
|
+
return;
|
|
3812
|
+
}
|
|
3813
|
+
console.log(`Shared-project member ${memberId} set to ${role}`);
|
|
3814
|
+
return;
|
|
3815
|
+
}
|
|
3816
|
+
|
|
3817
|
+
if (sub === "remove") {
|
|
3818
|
+
const memberId = String(positional[2] || "").trim();
|
|
3819
|
+
if (!memberId) {
|
|
3820
|
+
throw new Error("Usage: waterbrother room remove <member-id>");
|
|
3821
|
+
}
|
|
3822
|
+
const project = await removeSharedMember(cwd, memberId, { actorId: operator.id });
|
|
3823
|
+
if (asJson) {
|
|
3824
|
+
printData({ ok: true, action: "remove", memberId, project }, true);
|
|
3825
|
+
return;
|
|
3826
|
+
}
|
|
3827
|
+
console.log(`Shared-project member ${memberId} removed`);
|
|
3828
|
+
return;
|
|
3829
|
+
}
|
|
3830
|
+
|
|
3771
3831
|
if (sub === "claim") {
|
|
3772
|
-
const operator = getLocalOperatorIdentity();
|
|
3773
3832
|
const project = await claimSharedOperator(cwd, operator);
|
|
3774
3833
|
if (asJson) {
|
|
3775
3834
|
printData({ ok: true, action: "claim", project }, true);
|
|
@@ -3780,8 +3839,7 @@ async function runRoomCommand(positional, { cwd = process.cwd(), asJson = false
|
|
|
3780
3839
|
}
|
|
3781
3840
|
|
|
3782
3841
|
if (sub === "release") {
|
|
3783
|
-
const
|
|
3784
|
-
const project = await releaseSharedOperator(cwd, operator.id);
|
|
3842
|
+
const project = await releaseSharedOperator(cwd, operator.id, { actorId: operator.id });
|
|
3785
3843
|
if (asJson) {
|
|
3786
3844
|
printData({ ok: true, action: "release", project }, true);
|
|
3787
3845
|
return;
|
|
@@ -3801,7 +3859,7 @@ async function runRoomCommand(positional, { cwd = process.cwd(), asJson = false
|
|
|
3801
3859
|
console.log(project?.roomMode || "shared project: off");
|
|
3802
3860
|
return;
|
|
3803
3861
|
}
|
|
3804
|
-
const project = await setSharedRoomMode(cwd, nextMode);
|
|
3862
|
+
const project = await setSharedRoomMode(cwd, nextMode, { actorId: operator.id });
|
|
3805
3863
|
if (asJson) {
|
|
3806
3864
|
printData({ ok: true, action: "mode", roomMode: project.roomMode, project }, true);
|
|
3807
3865
|
return;
|
|
@@ -3810,7 +3868,7 @@ async function runRoomCommand(positional, { cwd = process.cwd(), asJson = false
|
|
|
3810
3868
|
return;
|
|
3811
3869
|
}
|
|
3812
3870
|
|
|
3813
|
-
throw new Error("Usage: waterbrother room status|mode <chat|plan|execute>|claim|release");
|
|
3871
|
+
throw new Error("Usage: waterbrother room status|members|add <member-id> [owner|editor|observer] [display name]|remove <member-id>|mode <chat|plan|execute>|claim|release");
|
|
3814
3872
|
}
|
|
3815
3873
|
|
|
3816
3874
|
async function runMcpCommand(positional, runtime, { cwd, asJson = false } = {}) {
|
|
@@ -7638,6 +7696,52 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
|
|
|
7638
7696
|
continue;
|
|
7639
7697
|
}
|
|
7640
7698
|
|
|
7699
|
+
if (line === "/room members") {
|
|
7700
|
+
try {
|
|
7701
|
+
await runRoomCommand(["room", "members"], { cwd: context.cwd, asJson: false });
|
|
7702
|
+
} catch (error) {
|
|
7703
|
+
console.log(`room members failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7704
|
+
}
|
|
7705
|
+
continue;
|
|
7706
|
+
}
|
|
7707
|
+
|
|
7708
|
+
if (line.startsWith("/room add ")) {
|
|
7709
|
+
const raw = line.replace("/room add", "").trim();
|
|
7710
|
+
if (!raw) {
|
|
7711
|
+
console.log("Usage: /room add <member-id> [owner|editor|observer] [display name]");
|
|
7712
|
+
continue;
|
|
7713
|
+
}
|
|
7714
|
+
const parts = raw.split(/\s+/).filter(Boolean);
|
|
7715
|
+
const memberId = parts.shift() || "";
|
|
7716
|
+
let role = "editor";
|
|
7717
|
+
if (parts.length && ["owner", "editor", "observer"].includes(String(parts[0] || "").toLowerCase())) {
|
|
7718
|
+
role = String(parts.shift() || "editor").toLowerCase();
|
|
7719
|
+
}
|
|
7720
|
+
const displayName = parts.join(" ").trim();
|
|
7721
|
+
try {
|
|
7722
|
+
const positional = ["room", "add", memberId, role];
|
|
7723
|
+
if (displayName) positional.push(...displayName.split(" "));
|
|
7724
|
+
await runRoomCommand(positional, { cwd: context.cwd, asJson: false });
|
|
7725
|
+
} catch (error) {
|
|
7726
|
+
console.log(`room add failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7727
|
+
}
|
|
7728
|
+
continue;
|
|
7729
|
+
}
|
|
7730
|
+
|
|
7731
|
+
if (line.startsWith("/room remove ")) {
|
|
7732
|
+
const memberId = line.replace("/room remove", "").trim();
|
|
7733
|
+
if (!memberId) {
|
|
7734
|
+
console.log("Usage: /room remove <member-id>");
|
|
7735
|
+
continue;
|
|
7736
|
+
}
|
|
7737
|
+
try {
|
|
7738
|
+
await runRoomCommand(["room", "remove", memberId], { cwd: context.cwd, asJson: false });
|
|
7739
|
+
} catch (error) {
|
|
7740
|
+
console.log(`room remove failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7741
|
+
}
|
|
7742
|
+
continue;
|
|
7743
|
+
}
|
|
7744
|
+
|
|
7641
7745
|
if (line.startsWith("/room mode ")) {
|
|
7642
7746
|
const nextMode = line.replace("/room mode", "").trim().toLowerCase();
|
|
7643
7747
|
if (!nextMode) {
|
package/src/gateway.js
CHANGED
|
@@ -8,7 +8,7 @@ import { createSession, listSessions, loadSession, saveSession } from "./session
|
|
|
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
10
|
import { canonicalizeLoosePath } from "./path-utils.js";
|
|
11
|
-
import { claimSharedOperator, loadSharedProject, releaseSharedOperator, setSharedRoom, setSharedRoomMode } from "./shared-project.js";
|
|
11
|
+
import { claimSharedOperator, getSharedMember, loadSharedProject, releaseSharedOperator, setSharedRoom, setSharedRoomMode, upsertSharedMember, removeSharedMember } from "./shared-project.js";
|
|
12
12
|
|
|
13
13
|
const execFileAsync = promisify(execFile);
|
|
14
14
|
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -26,6 +26,7 @@ const TELEGRAM_COMMANDS = [
|
|
|
26
26
|
{ command: "cwd", description: "Show the current remote working directory" },
|
|
27
27
|
{ command: "runtime", description: "Show active runtime status" },
|
|
28
28
|
{ command: "room", description: "Show shared room status" },
|
|
29
|
+
{ command: "members", description: "List shared room members" },
|
|
29
30
|
{ command: "mode", description: "Show or set shared room mode" },
|
|
30
31
|
{ command: "claim", description: "Claim operator control for a shared project" },
|
|
31
32
|
{ command: "release", description: "Release operator control for a shared project" },
|
|
@@ -199,6 +200,9 @@ function buildRemoteHelp() {
|
|
|
199
200
|
"<code>/new-project <name></code> create a folder on Desktop and switch into it",
|
|
200
201
|
"<code>/runtime</code> show active provider/model/runtime state",
|
|
201
202
|
"<code>/room</code> show shared project room status",
|
|
203
|
+
"<code>/members</code> list shared project members",
|
|
204
|
+
"<code>/invite <user-id> [owner|editor|observer]</code> add or update a shared project member",
|
|
205
|
+
"<code>/remove-member <user-id></code> remove a shared project member",
|
|
202
206
|
"<code>/mode</code> or <code>/mode <chat|plan|execute></code> inspect or change shared room mode",
|
|
203
207
|
"<code>/claim</code> claim operator control for a shared project",
|
|
204
208
|
"<code>/release</code> release operator control for a shared project",
|
|
@@ -292,6 +296,30 @@ function formatTelegramRoomMarkup(project) {
|
|
|
292
296
|
].join("\n");
|
|
293
297
|
}
|
|
294
298
|
|
|
299
|
+
function formatTelegramMembersMarkup(project) {
|
|
300
|
+
if (!project?.enabled) {
|
|
301
|
+
return "This project is not shared.";
|
|
302
|
+
}
|
|
303
|
+
const members = Array.isArray(project.members) ? project.members : [];
|
|
304
|
+
if (!members.length) {
|
|
305
|
+
return "<b>Shared members</b>\n• none";
|
|
306
|
+
}
|
|
307
|
+
return [
|
|
308
|
+
"<b>Shared members</b>",
|
|
309
|
+
...members.map((member) => `• ${escapeTelegramHtml(member.name || member.id)} <i>(${escapeTelegramHtml(member.role || "editor")})</i> <code>${escapeTelegramHtml(member.id || "")}</code>`)
|
|
310
|
+
].join("\n");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function parseInviteCommand(text) {
|
|
314
|
+
const parts = String(text || "").trim().split(/\s+/).filter(Boolean);
|
|
315
|
+
const userId = String(parts[1] || "").trim();
|
|
316
|
+
let role = "editor";
|
|
317
|
+
if (parts[2] && ["owner", "editor", "observer"].includes(String(parts[2]).toLowerCase())) {
|
|
318
|
+
role = String(parts[2]).toLowerCase();
|
|
319
|
+
}
|
|
320
|
+
return { userId, role };
|
|
321
|
+
}
|
|
322
|
+
|
|
295
323
|
function extractRetryDelayMs(error, attempt) {
|
|
296
324
|
const retryAfter = Number(error?.retryAfterSeconds);
|
|
297
325
|
if (Number.isFinite(retryAfter) && retryAfter > 0) {
|
|
@@ -319,6 +347,7 @@ class TelegramGateway {
|
|
|
319
347
|
this.channel = runtime.channels?.telegram || {};
|
|
320
348
|
this.gateway = runtime.gateway || {};
|
|
321
349
|
this.state = null;
|
|
350
|
+
this.me = null;
|
|
322
351
|
}
|
|
323
352
|
|
|
324
353
|
get botToken() {
|
|
@@ -428,6 +457,40 @@ class TelegramGateway {
|
|
|
428
457
|
return allowed.includes(userId);
|
|
429
458
|
}
|
|
430
459
|
|
|
460
|
+
isGroupChat(message) {
|
|
461
|
+
const type = String(message?.chat?.type || "").trim().toLowerCase();
|
|
462
|
+
return type === "group" || type === "supergroup";
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
normalizeTelegramCommandText(text) {
|
|
466
|
+
const value = String(text || "").trim();
|
|
467
|
+
const username = String(this.me?.username || "").trim();
|
|
468
|
+
if (!value || !username) return value;
|
|
469
|
+
return value.replace(new RegExp(`^(/\\w+)@${username}\\b`, "i"), "$1");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
messageTargetsBot(message) {
|
|
473
|
+
if (!message?.chat) return false;
|
|
474
|
+
if (String(message.chat.type || "").trim() === "private") return true;
|
|
475
|
+
const username = String(this.me?.username || "").trim().toLowerCase();
|
|
476
|
+
const text = String(message?.text || "").trim();
|
|
477
|
+
if (!text) return false;
|
|
478
|
+
const normalized = this.normalizeTelegramCommandText(text);
|
|
479
|
+
if (normalized.startsWith("/")) return true;
|
|
480
|
+
if (username && new RegExp(`(^|\\s)@${username}(\\b|\\s|$)`, "i").test(text)) return true;
|
|
481
|
+
const replyFromId = String(message?.reply_to_message?.from?.id || "").trim();
|
|
482
|
+
const myId = String(this.me?.id || "").trim();
|
|
483
|
+
if (replyFromId && myId && replyFromId === myId) return true;
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
stripBotMention(text) {
|
|
488
|
+
const value = String(text || "").trim();
|
|
489
|
+
const username = String(this.me?.username || "").trim();
|
|
490
|
+
if (!value || !username) return value;
|
|
491
|
+
return value.replace(new RegExp(`(^|\\s)@${username}(\\b|\\s|$)`, "ig"), " ").replace(/\s+/g, " ").trim();
|
|
492
|
+
}
|
|
493
|
+
|
|
431
494
|
prunePairings() {
|
|
432
495
|
const { state, pruned } = prunePendingPairings(this.state, this.pairingExpiryMinutes);
|
|
433
496
|
this.state = state;
|
|
@@ -831,15 +894,16 @@ class TelegramGateway {
|
|
|
831
894
|
}
|
|
832
895
|
|
|
833
896
|
async handleTextMessage(message) {
|
|
834
|
-
if (!message?.chat
|
|
897
|
+
if (!message?.chat) return;
|
|
835
898
|
if (!message?.text) return;
|
|
899
|
+
if (!this.messageTargetsBot(message)) return;
|
|
836
900
|
this.prunePairings();
|
|
837
901
|
if (!this.isAuthorizedUser(message.from)) {
|
|
838
902
|
await this.rejectUnpairedMessage(message);
|
|
839
903
|
return;
|
|
840
904
|
}
|
|
841
905
|
|
|
842
|
-
const text = String(message.text || "").trim();
|
|
906
|
+
const text = this.normalizeTelegramCommandText(String(message.text || "").trim());
|
|
843
907
|
const userId = String(message.from.id);
|
|
844
908
|
|
|
845
909
|
if (text === "/help" || text === "/start") {
|
|
@@ -880,6 +944,12 @@ class TelegramGateway {
|
|
|
880
944
|
return;
|
|
881
945
|
}
|
|
882
946
|
|
|
947
|
+
if (text === "/members") {
|
|
948
|
+
const { project } = await this.bindSharedRoomForMessage(message, sessionId);
|
|
949
|
+
await this.sendMessage(message.chat.id, formatTelegramMembersMarkup(project), message.message_id);
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
|
|
883
953
|
if (text === "/mode") {
|
|
884
954
|
const { project } = await this.bindSharedRoomForMessage(message, sessionId);
|
|
885
955
|
await this.sendMessage(
|
|
@@ -900,7 +970,7 @@ class TelegramGateway {
|
|
|
900
970
|
return;
|
|
901
971
|
}
|
|
902
972
|
try {
|
|
903
|
-
const next = await setSharedRoomMode(session.cwd || this.cwd, requestedMode);
|
|
973
|
+
const next = await setSharedRoomMode(session.cwd || this.cwd, requestedMode, { actorId: userId });
|
|
904
974
|
await this.sendMessage(message.chat.id, `Shared room mode set to <code>${escapeTelegramHtml(next.roomMode)}</code>`, message.message_id);
|
|
905
975
|
} catch (error) {
|
|
906
976
|
await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
|
|
@@ -933,7 +1003,7 @@ class TelegramGateway {
|
|
|
933
1003
|
return;
|
|
934
1004
|
}
|
|
935
1005
|
try {
|
|
936
|
-
const released = await releaseSharedOperator(session.cwd || this.cwd, userId);
|
|
1006
|
+
const released = await releaseSharedOperator(session.cwd || this.cwd, userId, { actorId: userId });
|
|
937
1007
|
await this.sendMessage(message.chat.id, released.activeOperator?.id ? formatTelegramRoomMarkup(released) : "Shared room released.", message.message_id);
|
|
938
1008
|
} catch (error) {
|
|
939
1009
|
await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
|
|
@@ -941,6 +1011,59 @@ class TelegramGateway {
|
|
|
941
1011
|
return;
|
|
942
1012
|
}
|
|
943
1013
|
|
|
1014
|
+
if (text.startsWith("/invite ")) {
|
|
1015
|
+
const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
|
|
1016
|
+
if (!project?.enabled) {
|
|
1017
|
+
await this.sendMessage(message.chat.id, "This project is not shared.", message.message_id);
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
const { userId: nextUserId, role } = parseInviteCommand(text);
|
|
1021
|
+
if (!nextUserId) {
|
|
1022
|
+
await this.sendMessage(message.chat.id, "Usage: /invite <user-id> [owner|editor|observer]", message.message_id);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
try {
|
|
1026
|
+
const next = await upsertSharedMember(
|
|
1027
|
+
session.cwd || this.cwd,
|
|
1028
|
+
{ id: nextUserId, role, name: nextUserId, paired: true },
|
|
1029
|
+
{ actorId: userId }
|
|
1030
|
+
);
|
|
1031
|
+
const member = getSharedMember(next, nextUserId);
|
|
1032
|
+
await this.sendMessage(
|
|
1033
|
+
message.chat.id,
|
|
1034
|
+
`Shared member set: <code>${escapeTelegramHtml(member?.id || nextUserId)}</code> <i>(${escapeTelegramHtml(member?.role || role)})</i>`,
|
|
1035
|
+
message.message_id
|
|
1036
|
+
);
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
|
|
1039
|
+
}
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
if (text.startsWith("/remove-member ")) {
|
|
1044
|
+
const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
|
|
1045
|
+
if (!project?.enabled) {
|
|
1046
|
+
await this.sendMessage(message.chat.id, "This project is not shared.", message.message_id);
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
const nextUserId = text.replace("/remove-member", "").trim();
|
|
1050
|
+
if (!nextUserId) {
|
|
1051
|
+
await this.sendMessage(message.chat.id, "Usage: /remove-member <user-id>", message.message_id);
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
try {
|
|
1055
|
+
await removeSharedMember(session.cwd || this.cwd, nextUserId, { actorId: userId });
|
|
1056
|
+
await this.sendMessage(
|
|
1057
|
+
message.chat.id,
|
|
1058
|
+
`Removed shared member <code>${escapeTelegramHtml(nextUserId)}</code>`,
|
|
1059
|
+
message.message_id
|
|
1060
|
+
);
|
|
1061
|
+
} catch (error) {
|
|
1062
|
+
await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
|
|
1063
|
+
}
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
944
1067
|
if (text === "/runtime") {
|
|
945
1068
|
const status = await this.runRuntimeStatus();
|
|
946
1069
|
await this.sendMessage(message.chat.id, formatRuntimeStatus(status), message.message_id);
|
|
@@ -1057,8 +1180,9 @@ class TelegramGateway {
|
|
|
1057
1180
|
return;
|
|
1058
1181
|
}
|
|
1059
1182
|
previewMessage = await this.sendProgressMessage(message.chat.id, message.message_id);
|
|
1060
|
-
const
|
|
1061
|
-
|
|
1183
|
+
const promptText = this.stripBotMention(text);
|
|
1184
|
+
const content = (await this.runPromptViaBridge(message, sessionId, promptText))
|
|
1185
|
+
?? (await this.runPromptFallback(sessionId, promptText));
|
|
1062
1186
|
await this.deliverPromptResult(message.chat.id, message.message_id, previewMessage, content);
|
|
1063
1187
|
} catch (error) {
|
|
1064
1188
|
await this.deliverPromptFailure(message.chat.id, message.message_id, previewMessage, error);
|
|
@@ -1084,6 +1208,7 @@ class TelegramGateway {
|
|
|
1084
1208
|
this.state = await loadGatewayState("telegram");
|
|
1085
1209
|
this.prunePairings();
|
|
1086
1210
|
const me = await this.getMe();
|
|
1211
|
+
this.me = me;
|
|
1087
1212
|
await this.registerCommands();
|
|
1088
1213
|
await this.persistState();
|
|
1089
1214
|
this.io.log?.(`Telegram gateway connected as @${me.username || me.id}`);
|
package/src/shared-project.js
CHANGED
|
@@ -15,6 +15,14 @@ function normalizeMember(member = {}) {
|
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
function memberRoleWeight(role = "") {
|
|
19
|
+
const normalized = String(role || "").trim().toLowerCase();
|
|
20
|
+
if (normalized === "owner") return 3;
|
|
21
|
+
if (normalized === "editor") return 2;
|
|
22
|
+
if (normalized === "observer") return 1;
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
function normalizeSharedProject(project = {}, cwd = process.cwd()) {
|
|
19
27
|
const members = Array.isArray(project.members) ? project.members.map(normalizeMember).filter((item) => item.id) : [];
|
|
20
28
|
const activeOperator = project.activeOperator && typeof project.activeOperator === "object"
|
|
@@ -200,9 +208,9 @@ export async function setSharedRoom(cwd, room = {}) {
|
|
|
200
208
|
return next;
|
|
201
209
|
}
|
|
202
210
|
|
|
203
|
-
export async function setSharedRoomMode(cwd, roomMode = "chat") {
|
|
211
|
+
export async function setSharedRoomMode(cwd, roomMode = "chat", options = {}) {
|
|
204
212
|
const existing = await loadSharedProject(cwd);
|
|
205
|
-
|
|
213
|
+
requireOwner(existing, options.actorId);
|
|
206
214
|
const normalized = String(roomMode || "").trim().toLowerCase();
|
|
207
215
|
if (!["chat", "plan", "execute"].includes(normalized)) {
|
|
208
216
|
throw new Error("Invalid room mode. Expected one of chat, plan, execute.");
|
|
@@ -215,15 +223,95 @@ export async function setSharedRoomMode(cwd, roomMode = "chat") {
|
|
|
215
223
|
return next;
|
|
216
224
|
}
|
|
217
225
|
|
|
226
|
+
export function getSharedMember(project, memberId = "") {
|
|
227
|
+
const normalizedId = String(memberId || "").trim();
|
|
228
|
+
if (!normalizedId || !project?.members?.length) return null;
|
|
229
|
+
return project.members.find((member) => String(member?.id || "").trim() === normalizedId) || null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function memberHasAtLeastRole(project, memberId = "", role = "editor") {
|
|
233
|
+
const member = getSharedMember(project, memberId);
|
|
234
|
+
return memberRoleWeight(member?.role) >= memberRoleWeight(role);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function requireSharedProject(project) {
|
|
238
|
+
if (!project?.enabled) {
|
|
239
|
+
throw new Error("Project is not shared.");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function requireOwner(project, actorId = "") {
|
|
244
|
+
requireSharedProject(project);
|
|
245
|
+
if (!memberHasAtLeastRole(project, actorId, "owner")) {
|
|
246
|
+
throw new Error("Only a shared-project owner can do that.");
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function listSharedMembers(cwd) {
|
|
251
|
+
const project = await loadSharedProject(cwd);
|
|
252
|
+
requireSharedProject(project);
|
|
253
|
+
return project.members || [];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function upsertSharedMember(cwd, member = {}, options = {}) {
|
|
257
|
+
const existing = await loadSharedProject(cwd);
|
|
258
|
+
requireOwner(existing, options.actorId);
|
|
259
|
+
const nextMember = normalizeMember(member);
|
|
260
|
+
if (!nextMember.id) throw new Error("member id is required");
|
|
261
|
+
const members = Array.isArray(existing.members) ? [...existing.members] : [];
|
|
262
|
+
const index = members.findIndex((item) => item.id === nextMember.id);
|
|
263
|
+
if (index >= 0) {
|
|
264
|
+
members[index] = { ...members[index], ...nextMember };
|
|
265
|
+
} else {
|
|
266
|
+
members.push(nextMember);
|
|
267
|
+
}
|
|
268
|
+
const next = await saveSharedProject(cwd, {
|
|
269
|
+
...existing,
|
|
270
|
+
members
|
|
271
|
+
});
|
|
272
|
+
await appendRoundtableEvent(
|
|
273
|
+
cwd,
|
|
274
|
+
`- ${new Date().toISOString()}: member ${nextMember.name || nextMember.id} set to role ${nextMember.role}`
|
|
275
|
+
);
|
|
276
|
+
return next;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export async function removeSharedMember(cwd, memberId = "", options = {}) {
|
|
280
|
+
const existing = await loadSharedProject(cwd);
|
|
281
|
+
requireOwner(existing, options.actorId);
|
|
282
|
+
const normalizedId = String(memberId || "").trim();
|
|
283
|
+
if (!normalizedId) throw new Error("member id is required");
|
|
284
|
+
const current = getSharedMember(existing, normalizedId);
|
|
285
|
+
if (!current) throw new Error(`No shared-project member found for ${normalizedId}`);
|
|
286
|
+
const owners = (existing.members || []).filter((member) => member.role === "owner");
|
|
287
|
+
if (current.role === "owner" && owners.length <= 1) {
|
|
288
|
+
throw new Error("Cannot remove the last owner from a shared project.");
|
|
289
|
+
}
|
|
290
|
+
const next = await saveSharedProject(cwd, {
|
|
291
|
+
...existing,
|
|
292
|
+
members: (existing.members || []).filter((member) => member.id !== normalizedId),
|
|
293
|
+
activeOperator: existing.activeOperator?.id === normalizedId ? null : existing.activeOperator
|
|
294
|
+
});
|
|
295
|
+
await appendRoundtableEvent(
|
|
296
|
+
cwd,
|
|
297
|
+
`- ${new Date().toISOString()}: member removed ${current.name || current.id}`
|
|
298
|
+
);
|
|
299
|
+
return next;
|
|
300
|
+
}
|
|
301
|
+
|
|
218
302
|
export async function claimSharedOperator(cwd, operator = {}) {
|
|
219
303
|
const existing = await loadSharedProject(cwd);
|
|
220
|
-
|
|
304
|
+
requireSharedProject(existing);
|
|
221
305
|
const nextOperator = {
|
|
222
306
|
id: String(operator.id || "").trim(),
|
|
223
307
|
name: String(operator.name || "").trim(),
|
|
224
308
|
claimedAt: new Date().toISOString()
|
|
225
309
|
};
|
|
226
310
|
if (!nextOperator.id) throw new Error("operator id is required");
|
|
311
|
+
const existingMember = getSharedMember(existing, nextOperator.id);
|
|
312
|
+
if (existingMember && !memberHasAtLeastRole(existing, nextOperator.id, "editor")) {
|
|
313
|
+
throw new Error("Observers cannot claim operator control.");
|
|
314
|
+
}
|
|
227
315
|
if (existing.activeOperator?.id && existing.activeOperator.id !== nextOperator.id) {
|
|
228
316
|
throw new Error(`Room is currently claimed by ${existing.activeOperator.name || existing.activeOperator.id}`);
|
|
229
317
|
}
|
|
@@ -240,11 +328,17 @@ export async function claimSharedOperator(cwd, operator = {}) {
|
|
|
240
328
|
return next;
|
|
241
329
|
}
|
|
242
330
|
|
|
243
|
-
export async function releaseSharedOperator(cwd, operatorId = "") {
|
|
331
|
+
export async function releaseSharedOperator(cwd, operatorId = "", options = {}) {
|
|
244
332
|
const existing = await loadSharedProject(cwd);
|
|
245
|
-
|
|
333
|
+
requireSharedProject(existing);
|
|
246
334
|
const normalizedId = String(operatorId || "").trim();
|
|
247
|
-
|
|
335
|
+
const actorId = String(options.actorId || "").trim();
|
|
336
|
+
if (
|
|
337
|
+
existing.activeOperator?.id &&
|
|
338
|
+
normalizedId &&
|
|
339
|
+
existing.activeOperator.id !== normalizedId &&
|
|
340
|
+
!memberHasAtLeastRole(existing, actorId, "owner")
|
|
341
|
+
) {
|
|
248
342
|
throw new Error(`Room is currently claimed by ${existing.activeOperator.name || existing.activeOperator.id}`);
|
|
249
343
|
}
|
|
250
344
|
const next = await saveSharedProject(cwd, {
|