@tritard/waterbrother 0.16.62 → 0.16.64
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/package.json +1 -1
- package/src/gateway-state.js +16 -2
- package/src/gateway.js +177 -3
package/package.json
CHANGED
package/src/gateway-state.js
CHANGED
|
@@ -18,10 +18,24 @@ function gatewayBridgePath(serviceId) {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
function normalizeGatewayState(parsed = {}) {
|
|
21
|
+
const continuations = parsed?.continuations && typeof parsed.continuations === "object" ? parsed.continuations : {};
|
|
21
22
|
return {
|
|
22
23
|
offset: Number.isFinite(Number(parsed?.offset)) ? Math.max(0, Math.floor(Number(parsed.offset))) : 0,
|
|
23
24
|
peers: parsed?.peers && typeof parsed.peers === "object" ? parsed.peers : {},
|
|
24
|
-
pendingPairings: parsed?.pendingPairings && typeof parsed.pendingPairings === "object" ? parsed.pendingPairings : {}
|
|
25
|
+
pendingPairings: parsed?.pendingPairings && typeof parsed.pendingPairings === "object" ? parsed.pendingPairings : {},
|
|
26
|
+
continuations: Object.fromEntries(
|
|
27
|
+
Object.entries(continuations).map(([key, item]) => [
|
|
28
|
+
key,
|
|
29
|
+
{
|
|
30
|
+
chatId: String(item?.chatId || "").trim(),
|
|
31
|
+
userId: String(item?.userId || "").trim(),
|
|
32
|
+
lastPrompt: String(item?.lastPrompt || "").trim(),
|
|
33
|
+
kind: String(item?.kind || "follow-up").trim() || "follow-up",
|
|
34
|
+
source: String(item?.source || "assistant-question").trim() || "assistant-question",
|
|
35
|
+
expiresAt: String(item?.expiresAt || "").trim()
|
|
36
|
+
}
|
|
37
|
+
])
|
|
38
|
+
)
|
|
25
39
|
};
|
|
26
40
|
}
|
|
27
41
|
|
|
@@ -114,7 +128,7 @@ export async function loadGatewayState(serviceId) {
|
|
|
114
128
|
return normalizeGatewayState(JSON.parse(raw));
|
|
115
129
|
} catch (error) {
|
|
116
130
|
if (error?.code === "ENOENT") {
|
|
117
|
-
return { offset: 0, peers: {}, pendingPairings: {} };
|
|
131
|
+
return { offset: 0, peers: {}, pendingPairings: {}, continuations: {} };
|
|
118
132
|
}
|
|
119
133
|
throw error;
|
|
120
134
|
}
|
package/src/gateway.js
CHANGED
|
@@ -22,6 +22,7 @@ const TELEGRAM_TYPING_INTERVAL_MS = 4000;
|
|
|
22
22
|
const TELEGRAM_PREVIEW_TEXT = "<i>Working…</i>";
|
|
23
23
|
const TELEGRAM_BRIDGE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
24
24
|
const TELEGRAM_BRIDGE_POLL_MS = 250;
|
|
25
|
+
const TELEGRAM_CONTINUATION_TTL_MS = 15 * 60 * 1000;
|
|
25
26
|
const TELEGRAM_COMMANDS = [
|
|
26
27
|
{ command: "help", description: "Show Telegram control help" },
|
|
27
28
|
{ command: "about", description: "Show local Waterbrother identity and capabilities" },
|
|
@@ -98,6 +99,10 @@ function getTelegramBotAliases(me = {}) {
|
|
|
98
99
|
return [...aliases].filter(Boolean);
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
function continuationKey(chatId, userId) {
|
|
103
|
+
return `${String(chatId || "").trim()}:${String(userId || "").trim()}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
101
106
|
function splitLongPlainText(text, maxLength = TELEGRAM_MESSAGE_LIMIT) {
|
|
102
107
|
const value = String(text || "").trim();
|
|
103
108
|
if (!value) return [""];
|
|
@@ -749,6 +754,16 @@ function classifyTelegramGroupIntent(text = "") {
|
|
|
749
754
|
return { kind: "chat", reason: "default non-execution" };
|
|
750
755
|
}
|
|
751
756
|
|
|
757
|
+
function buildContinuationPrompt(text = "", continuation = null) {
|
|
758
|
+
const body = String(text || "").trim();
|
|
759
|
+
if (!continuation?.lastPrompt) return body;
|
|
760
|
+
return [
|
|
761
|
+
`Continue the active Telegram follow-up.`,
|
|
762
|
+
`Your last question/request to this user was: ${continuation.lastPrompt}`,
|
|
763
|
+
`User reply: ${body}`
|
|
764
|
+
].join("\n");
|
|
765
|
+
}
|
|
766
|
+
|
|
752
767
|
function suggestTaskText(text = "") {
|
|
753
768
|
return String(text || "")
|
|
754
769
|
.replace(/^@[\w_]+\s*/g, "")
|
|
@@ -944,6 +959,7 @@ class TelegramGateway {
|
|
|
944
959
|
const replyFromId = String(message?.reply_to_message?.from?.id || "").trim();
|
|
945
960
|
const myId = String(this.me?.id || "").trim();
|
|
946
961
|
if (replyFromId && myId && replyFromId === myId) return true;
|
|
962
|
+
if (this.hasPendingContinuation(message)) return true;
|
|
947
963
|
return false;
|
|
948
964
|
}
|
|
949
965
|
|
|
@@ -960,7 +976,15 @@ class TelegramGateway {
|
|
|
960
976
|
|
|
961
977
|
prunePairings() {
|
|
962
978
|
const { state, pruned } = prunePendingPairings(this.state, this.pairingExpiryMinutes);
|
|
963
|
-
|
|
979
|
+
const continuations = {};
|
|
980
|
+
const now = Date.now();
|
|
981
|
+
for (const [key, item] of Object.entries(state.continuations || {})) {
|
|
982
|
+
const expiresAtMs = Date.parse(String(item?.expiresAt || ""));
|
|
983
|
+
if (Number.isFinite(expiresAtMs) && expiresAtMs > now) {
|
|
984
|
+
continuations[key] = item;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
this.state = { ...state, continuations };
|
|
964
988
|
return pruned;
|
|
965
989
|
}
|
|
966
990
|
|
|
@@ -969,6 +993,142 @@ class TelegramGateway {
|
|
|
969
993
|
await saveGatewayState("telegram", this.state);
|
|
970
994
|
}
|
|
971
995
|
|
|
996
|
+
hasPendingContinuation(message) {
|
|
997
|
+
const key = continuationKey(message?.chat?.id, message?.from?.id);
|
|
998
|
+
const item = this.state?.continuations?.[key];
|
|
999
|
+
if (!item) return false;
|
|
1000
|
+
const expiresAtMs = Date.parse(String(item.expiresAt || ""));
|
|
1001
|
+
return Number.isFinite(expiresAtMs) && expiresAtMs > Date.now();
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
getPendingContinuation(message) {
|
|
1005
|
+
const key = continuationKey(message?.chat?.id, message?.from?.id);
|
|
1006
|
+
const item = this.state?.continuations?.[key];
|
|
1007
|
+
if (!item) return null;
|
|
1008
|
+
const expiresAtMs = Date.parse(String(item.expiresAt || ""));
|
|
1009
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) return null;
|
|
1010
|
+
return item;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
clearContinuation(message) {
|
|
1014
|
+
const key = continuationKey(message?.chat?.id, message?.from?.id);
|
|
1015
|
+
if (this.state?.continuations?.[key]) {
|
|
1016
|
+
delete this.state.continuations[key];
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
async rememberContinuation(message, text = "") {
|
|
1021
|
+
const body = String(text || "").trim();
|
|
1022
|
+
if (!body) return;
|
|
1023
|
+
const asksFollowUp =
|
|
1024
|
+
/\?\s*$/.test(body)
|
|
1025
|
+
|| /\b(would you like|do you want|which\b|what\b.*\?|how\b.*\?|who\b.*\?|where\b.*\?)\b/i.test(body);
|
|
1026
|
+
if (!asksFollowUp) return;
|
|
1027
|
+
const key = continuationKey(message?.chat?.id, message?.from?.id);
|
|
1028
|
+
this.state.continuations[key] = {
|
|
1029
|
+
chatId: String(message?.chat?.id || "").trim(),
|
|
1030
|
+
userId: String(message?.from?.id || "").trim(),
|
|
1031
|
+
lastPrompt: body.slice(0, 400),
|
|
1032
|
+
kind: "follow-up",
|
|
1033
|
+
source: "assistant-question",
|
|
1034
|
+
expiresAt: new Date(Date.now() + TELEGRAM_CONTINUATION_TTL_MS).toISOString()
|
|
1035
|
+
};
|
|
1036
|
+
await this.persistState();
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
async loadTrustedChatProject(message) {
|
|
1040
|
+
if (!this.isGroupChat(message)) return { cwd: "", project: null };
|
|
1041
|
+
const chatId = String(message?.chat?.id || "").trim();
|
|
1042
|
+
const bridge = await loadGatewayBridge("telegram");
|
|
1043
|
+
const candidateCwds = [
|
|
1044
|
+
String(bridge.activeHost?.cwd || "").trim(),
|
|
1045
|
+
String(this.cwd || "").trim()
|
|
1046
|
+
].filter(Boolean);
|
|
1047
|
+
for (const candidateCwd of candidateCwds) {
|
|
1048
|
+
try {
|
|
1049
|
+
const project = await loadSharedProject(candidateCwd);
|
|
1050
|
+
if (project?.enabled && String(project.room?.provider || "") === "telegram" && String(project.room?.chatId || "") === chatId) {
|
|
1051
|
+
return { cwd: candidateCwd, project };
|
|
1052
|
+
}
|
|
1053
|
+
} catch {}
|
|
1054
|
+
}
|
|
1055
|
+
return { cwd: "", project: null };
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
async autoPairTrustedParticipant(message, cwd) {
|
|
1059
|
+
const { userId, usernameHandle, displayName } = this.describeTelegramUser(message?.from || {});
|
|
1060
|
+
const { userConfig, projectConfig } = await loadConfigLayers(cwd || this.cwd);
|
|
1061
|
+
const targetScope = projectConfig?.channels?.telegram ? "project" : "user";
|
|
1062
|
+
const targetConfig = targetScope === "project" ? { ...projectConfig } : { ...userConfig };
|
|
1063
|
+
const channels = targetConfig.channels && typeof targetConfig.channels === "object" ? { ...targetConfig.channels } : {};
|
|
1064
|
+
const telegram = channels.telegram && typeof channels.telegram === "object" ? { ...channels.telegram } : {};
|
|
1065
|
+
const allowed = new Set(Array.isArray(telegram.allowedUserIds) ? telegram.allowedUserIds.map((value) => String(value || "").trim()).filter(Boolean) : []);
|
|
1066
|
+
allowed.add(userId);
|
|
1067
|
+
telegram.enabled = telegram.enabled !== false;
|
|
1068
|
+
if (!telegram.pairingMode) telegram.pairingMode = "manual";
|
|
1069
|
+
telegram.allowedUserIds = [...allowed];
|
|
1070
|
+
channels.telegram = telegram;
|
|
1071
|
+
targetConfig.channels = channels;
|
|
1072
|
+
await saveConfig(targetConfig, { scope: targetScope, cwd: cwd || this.cwd });
|
|
1073
|
+
this.channel.allowedUserIds = telegram.allowedUserIds;
|
|
1074
|
+
if (this.runtime.channels?.telegram) {
|
|
1075
|
+
this.runtime.channels.telegram.allowedUserIds = telegram.allowedUserIds;
|
|
1076
|
+
}
|
|
1077
|
+
delete this.state.pendingPairings[userId];
|
|
1078
|
+
this.state.peers[userId] = {
|
|
1079
|
+
...(this.state.peers[userId] || {}),
|
|
1080
|
+
chatId: String(message.chat.id),
|
|
1081
|
+
username: displayName,
|
|
1082
|
+
usernameHandle,
|
|
1083
|
+
displayName,
|
|
1084
|
+
linkedAt: this.state.peers[userId]?.linkedAt || new Date().toISOString(),
|
|
1085
|
+
lastSeenAt: new Date().toISOString(),
|
|
1086
|
+
lastMessageId: message.message_id,
|
|
1087
|
+
sessions: Array.isArray(this.state.peers[userId]?.sessions) ? this.state.peers[userId].sessions : []
|
|
1088
|
+
};
|
|
1089
|
+
await this.persistState();
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
async maybeAutoOnboardTrustedParticipant(message) {
|
|
1093
|
+
if (!this.isGroupChat(message)) return null;
|
|
1094
|
+
const { cwd, project } = await this.loadTrustedChatProject(message);
|
|
1095
|
+
if (!project?.enabled) return null;
|
|
1096
|
+
const actor = this.describeTelegramUser(message?.from || {});
|
|
1097
|
+
const existingMember = Array.isArray(project.members)
|
|
1098
|
+
? project.members.find((member) => String(member?.id || "").trim() === actor.userId) || null
|
|
1099
|
+
: null;
|
|
1100
|
+
if (existingMember && this.isAuthorizedUser(message.from)) return null;
|
|
1101
|
+
if (!this.isAuthorizedUser(message.from)) {
|
|
1102
|
+
await this.autoPairTrustedParticipant(message, cwd);
|
|
1103
|
+
}
|
|
1104
|
+
const nextProject = existingMember
|
|
1105
|
+
? project
|
|
1106
|
+
: await upsertSharedMember(
|
|
1107
|
+
cwd,
|
|
1108
|
+
{ id: actor.userId, name: actor.displayName || actor.userId, role: "observer", paired: true },
|
|
1109
|
+
{ actorId: "system:trusted-room", actorName: "trusted room" }
|
|
1110
|
+
);
|
|
1111
|
+
return {
|
|
1112
|
+
cwd,
|
|
1113
|
+
project: nextProject,
|
|
1114
|
+
actor
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
buildTrustedRoomIntroMarkup({ project, actor }) {
|
|
1119
|
+
return [
|
|
1120
|
+
"<b>Roundtable room</b>",
|
|
1121
|
+
`${escapeTelegramHtml(actor.displayName || actor.userId)} is now in <code>${escapeTelegramHtml(project.projectName || "this project")}</code> as <code>observer</code>.`,
|
|
1122
|
+
`room mode: <code>${escapeTelegramHtml(project.roomMode || "chat")}</code>`,
|
|
1123
|
+
project.activeOperator?.id
|
|
1124
|
+
? `active operator: <code>${escapeTelegramHtml(project.activeOperator.name || project.activeOperator.id)}</code>`
|
|
1125
|
+
: "active operator: <code>none</code>",
|
|
1126
|
+
"As observer, you can ask questions, discuss plans, and review work here.",
|
|
1127
|
+
"An owner can promote you to editor or owner conversationally.",
|
|
1128
|
+
"Examples: <code>what project is this chat bound to?</code>, <code>who is in the room?</code>, <code>what can I do here?</code>"
|
|
1129
|
+
].join("\n");
|
|
1130
|
+
}
|
|
1131
|
+
|
|
972
1132
|
describeTelegramUser(from = {}) {
|
|
973
1133
|
const userId = String(from?.id || "").trim();
|
|
974
1134
|
const usernameHandle = String(from?.username || "").trim();
|
|
@@ -1837,12 +1997,20 @@ class TelegramGateway {
|
|
|
1837
1997
|
if (!message?.text) return;
|
|
1838
1998
|
if (!this.messageTargetsBot(message)) return;
|
|
1839
1999
|
this.prunePairings();
|
|
2000
|
+
const trustedOnboarding = !this.isAuthorizedUser(message.from)
|
|
2001
|
+
? await this.maybeAutoOnboardTrustedParticipant(message)
|
|
2002
|
+
: null;
|
|
1840
2003
|
if (!this.isAuthorizedUser(message.from)) {
|
|
1841
2004
|
await this.rejectUnpairedMessage(message);
|
|
1842
2005
|
return;
|
|
1843
2006
|
}
|
|
2007
|
+
if (trustedOnboarding) {
|
|
2008
|
+
await this.sendMarkup(message.chat.id, this.buildTrustedRoomIntroMarkup(trustedOnboarding), message.message_id);
|
|
2009
|
+
}
|
|
1844
2010
|
|
|
1845
2011
|
const text = this.normalizeTelegramCommandText(String(message.text || "").trim());
|
|
2012
|
+
const continuation = this.getPendingContinuation(message);
|
|
2013
|
+
this.clearContinuation(message);
|
|
1846
2014
|
const userId = String(message.from.id);
|
|
1847
2015
|
|
|
1848
2016
|
if (text === "/help" || text === "/start") {
|
|
@@ -2561,9 +2729,14 @@ Ask them to run <code>/whoami</code> and then <code>/accept-invite ${escapeTeleg
|
|
|
2561
2729
|
}
|
|
2562
2730
|
|
|
2563
2731
|
const isExplicitRun = text.startsWith("/run ");
|
|
2564
|
-
const
|
|
2732
|
+
const rawPromptText = this.stripBotMention(isExplicitRun ? text.replace("/run", "").trim() : text);
|
|
2733
|
+
const promptText = continuation ? buildContinuationPrompt(rawPromptText, continuation) : rawPromptText;
|
|
2565
2734
|
const groupIntent = this.isGroupChat(message)
|
|
2566
|
-
? (isExplicitRun
|
|
2735
|
+
? (isExplicitRun
|
|
2736
|
+
? { kind: "execution", reason: "explicit /run" }
|
|
2737
|
+
: continuation
|
|
2738
|
+
? { kind: "execution", reason: "follow-up continuation" }
|
|
2739
|
+
: classifyTelegramGroupIntent(rawPromptText))
|
|
2567
2740
|
: { kind: "execution", reason: "private chat" };
|
|
2568
2741
|
const shouldExecutePrompt = !this.isGroupChat(message) || groupIntent.kind === "execution";
|
|
2569
2742
|
const sharedBinding = await this.bindSharedRoomForMessage(message, sessionId);
|
|
@@ -2738,6 +2911,7 @@ Ask them to run <code>/whoami</code> and then <code>/accept-invite ${escapeTeleg
|
|
|
2738
2911
|
const content = (await this.runPromptViaBridge(message, sessionId, promptText, { explicitExecution: shouldExecutePrompt }))
|
|
2739
2912
|
?? (await this.runPromptFallback(sessionId, promptText));
|
|
2740
2913
|
await this.deliverPromptResult(message.chat.id, message.message_id, previewMessage, content);
|
|
2914
|
+
await this.rememberContinuation(message, content);
|
|
2741
2915
|
} catch (error) {
|
|
2742
2916
|
await this.deliverPromptFailure(message.chat.id, message.message_id, previewMessage, error);
|
|
2743
2917
|
} finally {
|