@llblab/pi-kit 0.36.2 → 0.37.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.
- package/CHANGELOG.md +10 -0
- package/README.md +2 -2
- package/node_modules/@llblab/pi-telegram/AGENTS.md +3 -3
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +27 -1
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +31 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +2 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +2 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +16 -14
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +8 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +6 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +12 -10
- package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +58 -39
- package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +21 -12
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.d.ts +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/menu.d.ts +4 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +3 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +1 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +4 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +10 -9
- package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +1 -15
- package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +2 -15
- package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +2 -2
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +16 -2
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +3 -1
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +4 -0
- package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +18 -17
- package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +11 -4
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +8 -0
- package/node_modules/@llblab/pi-telegram/lib/commands.ts +87 -51
- package/node_modules/@llblab/pi-telegram/lib/extension.ts +22 -13
- package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/menu.ts +4 -4
- package/node_modules/@llblab/pi-telegram/lib/routing.ts +5 -2
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +11 -7
- package/node_modules/@llblab/pi-telegram/lib/updates.ts +2 -26
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/scripts/run-macos-tests.mjs +43 -0
- package/package.json +2 -2
|
@@ -80,7 +80,7 @@ const telegramBusProtocolIdentity = Bus.createTelegramCurrentBusProtocolIdentity
|
|
|
80
80
|
export default function (pi) {
|
|
81
81
|
Skills.registerTelegramSkillDiscovery(pi);
|
|
82
82
|
const piRuntime = Pi.createExtensionApiRuntimePorts(pi);
|
|
83
|
-
const { getActiveTools, getCommands, getThinkingLevel, sendUserMessage,
|
|
83
|
+
const { getActiveTools, getCommands, getThinkingLevel, sendUserMessage, setActiveTools, setModel, setThinkingLevel, } = piRuntime;
|
|
84
84
|
const bridgeRuntime = Runtime.createTelegramBridgeRuntime();
|
|
85
85
|
const runtimeDiagnostics = Logging.createTelegramRuntimeDiagnosticsRuntime({
|
|
86
86
|
sharedFile: true,
|
|
@@ -242,7 +242,6 @@ export default function (pi) {
|
|
|
242
242
|
captureAuthority: captureTelegramStateAuthority,
|
|
243
243
|
});
|
|
244
244
|
const sessionActionAssembly = Commands.createTelegramSessionActionAssembly({
|
|
245
|
-
registerCommand,
|
|
246
245
|
sendUserMessage,
|
|
247
246
|
store: threadStore,
|
|
248
247
|
getProfileName: configStore.getActiveProfileName,
|
|
@@ -1093,6 +1092,25 @@ export default function (pi) {
|
|
|
1093
1092
|
},
|
|
1094
1093
|
getAdmissionScope: getTelegramUpdateAdmissionScope,
|
|
1095
1094
|
getAdmissionJournalBinding: getTelegramQueueJournalBinding,
|
|
1095
|
+
getCommandReplyTarget(message) {
|
|
1096
|
+
if (message.chat.type !== "private" ||
|
|
1097
|
+
"business_connection_id" in message ||
|
|
1098
|
+
!Updates.getTelegramUpdateExecutionFence(message) ||
|
|
1099
|
+
lockRuntime.owns())
|
|
1100
|
+
return undefined;
|
|
1101
|
+
Updates.assertTelegramUpdateExecutionCurrent(message);
|
|
1102
|
+
const binding = resolveTelegramFollowerJournalBinding();
|
|
1103
|
+
const target = telegramBusFollowerRegistrationState.getTarget();
|
|
1104
|
+
if (!telegramBusFollowerRegistrationState.isRegistered() ||
|
|
1105
|
+
!binding ||
|
|
1106
|
+
binding.recoveryKey !==
|
|
1107
|
+
followerAdmissionLifecycleRuntime.getJournalBindingKey() ||
|
|
1108
|
+
!target ||
|
|
1109
|
+
(threadStore.getBotState().threadMode === "enabled" &&
|
|
1110
|
+
target.threadId === undefined))
|
|
1111
|
+
throw new Error("Telegram command reply lost its registered recipient.");
|
|
1112
|
+
return { ...target };
|
|
1113
|
+
},
|
|
1096
1114
|
beginCommandEffectWork: publicationRuntime.beginWork,
|
|
1097
1115
|
getMessageOwnership: messageOwnershipRuntime.getForwardOwnership,
|
|
1098
1116
|
recordMessageOwnership: messageOwnershipRuntime.recordRouted,
|
|
@@ -1400,15 +1418,6 @@ export default function (pi) {
|
|
|
1400
1418
|
return binding.hasAuthority?.() === true;
|
|
1401
1419
|
});
|
|
1402
1420
|
},
|
|
1403
|
-
recoverRejectedRecipientTarget(error) {
|
|
1404
|
-
const rejected = error instanceof TelegramApi.TelegramApiFollowerNotAllowedError
|
|
1405
|
-
? error.requestTarget
|
|
1406
|
-
: undefined;
|
|
1407
|
-
const current = telegramBusFollowerRegistrationState.getTarget();
|
|
1408
|
-
return rejected && current?.threadId !== undefined
|
|
1409
|
-
? { rejected, current: { chatId: current.chatId, threadId: current.threadId } }
|
|
1410
|
-
: undefined;
|
|
1411
|
-
},
|
|
1412
1421
|
prepareUpdateForExecution(update) {
|
|
1413
1422
|
return BusFollower.prepareTelegramBusFollowerJournaledUpdateForExecution(update, textGroupRuntime.prepareForwardedMessage);
|
|
1414
1423
|
},
|
|
@@ -2283,9 +2292,9 @@ export default function (pi) {
|
|
|
2283
2292
|
}
|
|
2284
2293
|
},
|
|
2285
2294
|
}.reset);
|
|
2286
|
-
sessionActionsRuntime.register();
|
|
2287
2295
|
Bindings.registerTelegramCommandsAndTools({
|
|
2288
2296
|
pi,
|
|
2297
|
+
handleInternalAction: sessionActionsRuntime.handleInternalAction,
|
|
2289
2298
|
agentDir: Paths.resolveAgentDir(),
|
|
2290
2299
|
configStore,
|
|
2291
2300
|
persistConfig: persistTelegramConfigWithSync,
|
|
@@ -52,7 +52,7 @@ export interface TelegramSettingsMenuCallbackDeps extends TelegramSettingsMutati
|
|
|
52
52
|
sectionRegistry?: TelegramSectionRegistry;
|
|
53
53
|
}
|
|
54
54
|
export interface TelegramSettingsMenuRuntime<TContext> {
|
|
55
|
-
openSettingsMenu: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
55
|
+
openSettingsMenu: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
56
56
|
handleCallbackQuery: (query: {
|
|
57
57
|
id: string;
|
|
58
58
|
data?: string;
|
|
@@ -87,7 +87,7 @@ export interface TelegramMenuActionRuntimeDeps<TContext, TModel extends MenuMode
|
|
|
87
87
|
storeModelMenuState: (state: TelegramModelMenuState<TModel>) => void;
|
|
88
88
|
isIdle: (ctx: TContext) => boolean;
|
|
89
89
|
canOfferInFlightModelSwitch: (ctx: TContext) => boolean;
|
|
90
|
-
sendTextReply: (chatId: number, replyToMessageId: number, text: string, options?: {
|
|
90
|
+
sendTextReply: (chatId: number, replyToMessageId: number | undefined, text: string, options?: {
|
|
91
91
|
target?: {
|
|
92
92
|
chatId: number;
|
|
93
93
|
threadId?: number;
|
|
@@ -102,9 +102,9 @@ export interface TelegramMenuActionRuntime<TContext, TModel extends MenuModel =
|
|
|
102
102
|
updateModelMenuMessage: (state: TelegramModelMenuState<TModel>, ctx: TContext) => Promise<void>;
|
|
103
103
|
updateThinkingMenuMessage: (state: TelegramModelMenuState<TModel>, ctx: TContext) => Promise<void>;
|
|
104
104
|
updateStatusMessage: (state: TelegramModelMenuState<TModel>, ctx: TContext) => Promise<void>;
|
|
105
|
-
sendStatusMessage: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
106
|
-
openModelMenu: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
107
|
-
openThinkingMenu: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
105
|
+
sendStatusMessage: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
106
|
+
openModelMenu: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
107
|
+
openThinkingMenu: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
108
108
|
}
|
|
109
109
|
export declare function handleTelegramMenuCallbackEntry(callbackQueryId: string, data: string | undefined, state: TelegramModelMenuState | undefined, deps: TelegramMenuCallbackEntryDeps): Promise<void>;
|
|
110
110
|
export declare function handleStoredTelegramMenuCallback<TModel extends MenuModel = MenuModel>(query: MenuCallbackQuery, deps: StoredTelegramMenuCallbackDeps<TModel>): Promise<void>;
|
|
@@ -174,6 +174,7 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
|
|
|
174
174
|
getCurrentInstanceId?: () => string | undefined;
|
|
175
175
|
getAdmissionScope?: () => string | undefined;
|
|
176
176
|
getAdmissionJournalBinding?: () => string | undefined;
|
|
177
|
+
getCommandReplyTarget?: Commands.TelegramCommandTargetRuntimeDeps<TContext>["getReplyTarget"];
|
|
177
178
|
getMessageOwnership?: Updates.TelegramMessageOwnershipLookup;
|
|
178
179
|
getTargetOwnership?: Updates.TelegramTargetOwnershipLookup;
|
|
179
180
|
recordMessageOwnership?: Updates.TelegramMessageOwnershipRecorder;
|
|
@@ -256,7 +257,7 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
|
|
|
256
257
|
isVoiceReplyActive?: () => boolean;
|
|
257
258
|
updateSettingsMenuMessage?: (state: Menu.TelegramModelMenuState<TModel>, ctx: TContext) => Promise<void>;
|
|
258
259
|
openQueueMenu: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
259
|
-
openSettingsMenu?: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
260
|
+
openSettingsMenu?: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
260
261
|
settingsMenuCallbackHandler?: (query: TCallbackQuery, ctx: TContext) => Promise<boolean>;
|
|
261
262
|
queueMenuCallbackHandler: (query: TCallbackQuery, ctx: TContext) => Promise<boolean>;
|
|
262
263
|
buttonActionStore?: OutboundHandlers.TelegramButtonActionStore;
|
|
@@ -303,7 +304,7 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
|
|
|
303
304
|
}) => Promise<string | undefined>;
|
|
304
305
|
/** Starts the animated placeholder on an answered guest inline message. */
|
|
305
306
|
startGuestPlaceholder?: (inlineMessageId: string) => void;
|
|
306
|
-
sendTextReply: (chatId: number, replyToMessageId: number, text: string, options?: {
|
|
307
|
+
sendTextReply: (chatId: number, replyToMessageId: number | undefined, text: string, options?: {
|
|
307
308
|
parseMode?: "HTML";
|
|
308
309
|
target?: Queue.TelegramQueueTarget;
|
|
309
310
|
assertAuthority?: TelegramApiCallOptions["assertAuthority"];
|
|
@@ -7139,6 +7139,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7139
7139
|
appendControlItem: deps.queueMutationRuntime.append,
|
|
7140
7140
|
getAdmissionScope: deps.getAdmissionScope,
|
|
7141
7141
|
getAdmissionJournalBinding: deps.getAdmissionJournalBinding,
|
|
7142
|
+
getReplyTarget: deps.getCommandReplyTarget,
|
|
7142
7143
|
onControlQueued: (message, receipt) => reportQueueAdmission([message], [receipt]),
|
|
7143
7144
|
showStatus: deps.menuActions.sendStatusMessage,
|
|
7144
7145
|
openModelMenu: deps.menuActions.openModelMenu,
|
|
@@ -505,16 +505,18 @@ export declare class TelegramApiStaleTargetError extends Error {
|
|
|
505
505
|
threadId: number;
|
|
506
506
|
});
|
|
507
507
|
}
|
|
508
|
-
/**
|
|
508
|
+
/** A follower API refusal; legacy text alone does not prove the request was rejected before execution. */
|
|
509
509
|
export declare class TelegramApiFollowerNotAllowedError extends Error {
|
|
510
510
|
readonly requestTarget: {
|
|
511
511
|
chatId: number;
|
|
512
512
|
threadId: number;
|
|
513
513
|
} | undefined;
|
|
514
|
+
/** Present only after a structured pre-execution refusal matches the requested API method. */
|
|
515
|
+
readonly rejectedRequestMethod: string | undefined;
|
|
514
516
|
constructor(message: string, requestTarget?: {
|
|
515
517
|
chatId: number;
|
|
516
518
|
threadId: number;
|
|
517
|
-
});
|
|
519
|
+
}, rejectedRequestMethod?: string);
|
|
518
520
|
}
|
|
519
521
|
export declare function getTelegramApiErrorRequestTarget(error: unknown): {
|
|
520
522
|
chatId: number;
|
|
@@ -338,13 +338,16 @@ export class TelegramApiStaleTargetError extends Error {
|
|
|
338
338
|
this.requestTarget = { ...requestTarget };
|
|
339
339
|
}
|
|
340
340
|
}
|
|
341
|
-
/**
|
|
341
|
+
/** A follower API refusal; legacy text alone does not prove the request was rejected before execution. */
|
|
342
342
|
export class TelegramApiFollowerNotAllowedError extends Error {
|
|
343
343
|
requestTarget;
|
|
344
|
-
|
|
344
|
+
/** Present only after a structured pre-execution refusal matches the requested API method. */
|
|
345
|
+
rejectedRequestMethod;
|
|
346
|
+
constructor(message, requestTarget, rejectedRequestMethod) {
|
|
345
347
|
super(message);
|
|
346
348
|
this.name = "TelegramApiFollowerNotAllowedError";
|
|
347
349
|
this.requestTarget = requestTarget ? { ...requestTarget } : undefined;
|
|
350
|
+
this.rejectedRequestMethod = rejectedRequestMethod;
|
|
348
351
|
}
|
|
349
352
|
}
|
|
350
353
|
export function getTelegramApiErrorRequestTarget(error) {
|
|
@@ -1036,6 +1039,7 @@ export function createTelegramBridgeApiRuntime(deps) {
|
|
|
1036
1039
|
const chatActionMinIntervalMs = Math.max(0, deps.chatActionMinIntervalMs ?? 2_000);
|
|
1037
1040
|
const chatActionMaxGates = Math.max(1, deps.chatActionMaxGates ?? 256);
|
|
1038
1041
|
const chatActionGates = new Map();
|
|
1042
|
+
// Pin shared cooldowns until every concurrent Thread settles; this state never owns their sends.
|
|
1039
1043
|
const chatActionChatGates = new Map();
|
|
1040
1044
|
const getChatActionKeys = (method, body) => {
|
|
1041
1045
|
if (method !== "sendChatAction")
|
|
@@ -1066,7 +1070,7 @@ export function createTelegramBridgeApiRuntime(deps) {
|
|
|
1066
1070
|
}
|
|
1067
1071
|
}
|
|
1068
1072
|
for (const [key, candidate] of chatActionChatGates) {
|
|
1069
|
-
if (
|
|
1073
|
+
if (candidate.pendingRequests === 0 && nowMs >= candidate.notBeforeMs) {
|
|
1070
1074
|
chatActionChatGates.delete(key);
|
|
1071
1075
|
}
|
|
1072
1076
|
}
|
|
@@ -1082,13 +1086,11 @@ export function createTelegramBridgeApiRuntime(deps) {
|
|
|
1082
1086
|
if (chatActionChatGates.size >= chatActionMaxGates) {
|
|
1083
1087
|
return true;
|
|
1084
1088
|
}
|
|
1085
|
-
chatGate = { notBeforeMs: 0 };
|
|
1089
|
+
chatGate = { pendingRequests: 0, notBeforeMs: 0 };
|
|
1086
1090
|
chatActionChatGates.set(chatActionKeys.chat, chatGate);
|
|
1087
1091
|
}
|
|
1088
1092
|
if (gate.inFlight)
|
|
1089
1093
|
return (await gate.inFlight);
|
|
1090
|
-
if (chatGate.inFlight)
|
|
1091
|
-
return true;
|
|
1092
1094
|
if (nowMs < gate.notBeforeMs || nowMs < chatGate.notBeforeMs) {
|
|
1093
1095
|
return true;
|
|
1094
1096
|
}
|
|
@@ -1124,11 +1126,10 @@ export function createTelegramBridgeApiRuntime(deps) {
|
|
|
1124
1126
|
.finally(() => {
|
|
1125
1127
|
if (gate.inFlight === request)
|
|
1126
1128
|
gate.inFlight = undefined;
|
|
1127
|
-
|
|
1128
|
-
chatGate.inFlight = undefined;
|
|
1129
|
+
chatGate.pendingRequests--;
|
|
1129
1130
|
});
|
|
1130
1131
|
gate.inFlight = request;
|
|
1131
|
-
chatGate.
|
|
1132
|
+
chatGate.pendingRequests++;
|
|
1132
1133
|
return request;
|
|
1133
1134
|
}
|
|
1134
1135
|
try {
|
|
@@ -1487,7 +1487,7 @@ export interface TelegramUpdateAdmissionLifecycleAssemblyDeps<TUpdate extends Te
|
|
|
1487
1487
|
runtimeBinding: TelegramUpdateAdmissionRuntimeBinding<TContext>;
|
|
1488
1488
|
inputCustodyBus?: TelegramInputCustodyBusBindingRuntime<TContext>;
|
|
1489
1489
|
acquireSourceReference?: (role: "leader" | "follower", binding: TelegramUpdateAdmissionLifecycleJournalBinding) => () => void;
|
|
1490
|
-
worker: Omit<TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate, TContext>, "journal" | "getJournalBindingKey" | "getRecipientBindingKey" | "hasAuthority" | "prepareUpdateForExecution"
|
|
1490
|
+
worker: Omit<TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate, TContext>, "journal" | "getJournalBindingKey" | "getRecipientBindingKey" | "hasAuthority" | "prepareUpdateForExecution">;
|
|
1491
1491
|
leader: {
|
|
1492
1492
|
resolveBinding: () => TelegramUpdateAdmissionLifecycleJournalBinding | undefined;
|
|
1493
1493
|
hasAuthority: (ctx: TContext) => boolean;
|
|
@@ -1497,7 +1497,6 @@ export interface TelegramUpdateAdmissionLifecycleAssemblyDeps<TUpdate extends Te
|
|
|
1497
1497
|
isRegistered: () => boolean;
|
|
1498
1498
|
getGeneration: () => string | undefined;
|
|
1499
1499
|
prepareUpdateForExecution: (update: TUpdate) => TUpdate;
|
|
1500
|
-
recoverRejectedRecipientTarget?: NonNullable<TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate, TContext>["recoverRejectedRecipientTarget"]>;
|
|
1501
1500
|
/** Session succession before worker creation, under the exact generation-fenced binding. */
|
|
1502
1501
|
prepareBinding?: (binding: TelegramUpdateAdmissionLifecycleJournalBinding & {
|
|
1503
1502
|
hasAuthority?: () => boolean;
|
|
@@ -1509,19 +1508,6 @@ export type TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate extends TelegramJou
|
|
|
1509
1508
|
inputCustody?: TelegramCustodyExecutionJournal;
|
|
1510
1509
|
defaultHandle: (update: TUpdate, ctx: TContext, execution?: TelegramUpdateExecutionFence) => Promise<void>;
|
|
1511
1510
|
prepareUpdateForExecution?: (update: TUpdate) => TUpdate;
|
|
1512
|
-
/**
|
|
1513
|
-
* Recipient-only recovery for a journaled message whose own Thread changed (for example after its Thread was re-created):
|
|
1514
|
-
* classifies a leader refusal of a reply to the original Thread and returns that Thread plus the owner's current one.
|
|
1515
|
-
*/
|
|
1516
|
-
recoverRejectedRecipientTarget?: (error: unknown) => {
|
|
1517
|
-
rejected: {
|
|
1518
|
-
chatId: number;
|
|
1519
|
-
threadId: number;
|
|
1520
|
-
};
|
|
1521
|
-
current: TelegramTarget & {
|
|
1522
|
-
threadId: number;
|
|
1523
|
-
};
|
|
1524
|
-
} | undefined;
|
|
1525
1511
|
registry?: TelegramUpdateHandlerRegistry;
|
|
1526
1512
|
};
|
|
1527
1513
|
/** Compose source-bound routing and late grouped settlement under one worker. */
|
|
@@ -6359,7 +6359,6 @@ export function createTelegramUpdateAdmissionWorkerRuntime(deps) {
|
|
|
6359
6359
|
const typedUpdate = update;
|
|
6360
6360
|
const prepared = deps.prepareUpdateForExecution?.(typedUpdate) ?? typedUpdate;
|
|
6361
6361
|
// Journal/source evidence remains the original; only the warm released handler projection changes destination.
|
|
6362
|
-
// Journal/source evidence remains the original; only the warm released handler projection changes destination.
|
|
6363
6362
|
const projectTo = (destination) => ({ ...prepared,
|
|
6364
6363
|
...(prepared.message ? { message: prepared.message.message_thread_id === destination.threadId ? { ...prepared.message }
|
|
6365
6364
|
: withoutTelegramTurnReplyAnchor({ ...prepared.message, message_thread_id: destination.threadId, reply_to_message: undefined }) } : {}),
|
|
@@ -6374,17 +6373,8 @@ export function createTelegramUpdateAdmissionWorkerRuntime(deps) {
|
|
|
6374
6373
|
if (await deps.settleTerminalExecutionFailure?.(error)) {
|
|
6375
6374
|
return { kind: "complete" };
|
|
6376
6375
|
}
|
|
6377
|
-
//
|
|
6378
|
-
//
|
|
6379
|
-
const recovery = !target ? deps.recoverRejectedRecipientTarget?.(error) : undefined;
|
|
6380
|
-
const message = prepared.message ?? prepared.edited_message;
|
|
6381
|
-
if (recovery && message && message.message_thread_id === recovery.rejected.threadId &&
|
|
6382
|
-
message.chat?.id === recovery.rejected.chatId && recovery.current.chatId === recovery.rejected.chatId &&
|
|
6383
|
-
recovery.current.threadId !== recovery.rejected.threadId) {
|
|
6384
|
-
deps.recordRuntimeEvent?.("inbound-worker", error, { phase: "recipient-target-recovery", updateId: update.update_id,
|
|
6385
|
-
chatId: recovery.current.chatId, staleThreadId: recovery.rejected.threadId, threadId: recovery.current.threadId });
|
|
6386
|
-
return await executeUpdate(projectTo(recovery.current), ctx, signal);
|
|
6387
|
-
}
|
|
6376
|
+
// A refused reply proves only that send was not issued, not that the handler made no prior effects.
|
|
6377
|
+
// Leave failure settlement to the worker's retry policy; never re-enter the handler to recover delivery here.
|
|
6388
6378
|
throw error;
|
|
6389
6379
|
}
|
|
6390
6380
|
},
|
|
@@ -6457,9 +6447,6 @@ export function createTelegramUpdateAdmissionLifecycleAssembly(deps) {
|
|
|
6457
6447
|
spendHistoricalInput: undefined,
|
|
6458
6448
|
onHeldSourcesPrepared: undefined,
|
|
6459
6449
|
prepareUpdateForExecution: deps.follower.prepareUpdateForExecution,
|
|
6460
|
-
...(deps.follower.recoverRejectedRecipientTarget
|
|
6461
|
-
? { recoverRejectedRecipientTarget: deps.follower.recoverRejectedRecipientTarget }
|
|
6462
|
-
: {}),
|
|
6463
6450
|
});
|
|
6464
6451
|
},
|
|
6465
6452
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
@@ -42,7 +42,7 @@ Keep this boundary explicit:
|
|
|
42
42
|
|
|
43
43
|
### Instance, Session, And Context Cost
|
|
44
44
|
|
|
45
|
-
Live Telegram routing belongs to the active authenticated Pi instance, while a durable Workspace Thread belongs to the exact profile/CWD/session identity. Ordinary Telegram prompts enter the session currently bound to that target. Local `/resume` replaces session-scoped runtime state and selects the destination session's binding; it never inherits the source Thread merely because the process is unchanged. Telegram exposes compaction and new-session replacement for the active session: `/new` first completes and removes its exact durable update, then dispatches
|
|
45
|
+
Live Telegram routing belongs to the active authenticated Pi instance, while a durable Workspace Thread belongs to the exact profile/CWD/session identity. Ordinary Telegram prompts enter the session currently bound to that target. Local `/resume` replaces session-scoped runtime state and selects the destination session's binding; it never inherits the source Thread merely because the process is unchanged. Telegram exposes compaction and new-session replacement for the active session: `/new` first completes and removes its exact durable update, then dispatches the runtime-armed `/telegram-connect --internal new <token>` branch through `pi.sendUserMessage(..., { expandPromptTemplates: true })`, without entering ordinary Connect profile or transport startup, so the handler runs with a real `ExtensionCommandContext` and calls the same `AgentSessionRuntime.newSession()` path as the terminal. Before invoking that path, the current transport owner CAS-publishes one expiring replacement intent inside the existing profile target snapshot. The intent discriminates `workspace-thread` from `classic-chat`. A same- or cross-process successor may consume either only for the exact profile, CWD, source session, target, and fresh lifetime: Threaded continuity re-keys the retained Workspace binding and preserves Thread, slot, and display identity, while Classic continuity preserves the exact chat target without creating a Workspace binding or entering topic lifecycle APIs. A confirmed callback is acknowledged and its dialog is deleted before session replacement begins. Once the successor has delivery authority, it atomically claims the intent by CAS-clearing it and then sends one separate terminal success result with bounded in-process transport retry. The old `withSession` path never publishes success, and an unclaimed intent never sends, preventing duplicate terminal notices across same-process startup and cleanup-ack ambiguity. Mismatch or expiry never authorizes binding takeover. Resume, fork, tree navigation, session switching, and full reload remain outside the stable Telegram API until Pi exposes safe public extension hooks for them.
|
|
46
46
|
|
|
47
47
|
`/telegram-connect` never launches a hidden or headless Pi process. A long-lived background Pi process can own Telegram only when something else explicitly launched that process and it satisfies the normal lock/runtime rules. Pi `print` and `json` modes stay passive and exit rather than becoming hidden polling owners.
|
|
48
48
|
|
|
@@ -685,7 +685,7 @@ Native typing during compaction follows connected-instance activity rather than
|
|
|
685
685
|
- Thread-targeted typing is sent only to the concrete thread. Aggregate `All` mirroring is intentionally omitted because duplicating every keepalive multiplies shared-chat flood pressure. Compaction completion, failure, or timeout stops only a loop actually started by the compaction observer; a pre-existing agent-owned loop remains active. Ownership is sticky across a repeated compaction start, whose shared-loop start reports `false` for the observer's own running loop. Authority loss and shutdown still stop the keyed loop.
|
|
686
686
|
- Pi `ui_prompt_start` pauses typing while an extension-owned local prompt waits for the operator; `ui_prompt_end` emits the matching Activity boundary and resumes typing whenever agent or compaction work remains unsettled.
|
|
687
687
|
|
|
688
|
-
At every connected instance `agent_start`, the lifecycle binding starts Telegram's native `…typing` indicator in that instance's assigned target, whether the run came from Telegram, the local TUI, or an autonomous continuation such as Grow Loop. Terminal `Active` remains Telegram-turn-specific; the native indicator answers the separate question of whether the instance is doing agent work. Streaming keepalives reuse a valid typing owner with the same session generation, transport and target rather than revoking its pending HTTP/IPC action. Fresh context wrappers may reuse that owner within a proven generation; without generation evidence, context identity must match. Explicit queue dispatch and Telegram prompt handoff still replace the owner. Queue dispatch carries the queued turn's exact target into typing before `agent_start`, rather than reducing it to a chat ID. In Threaded Mode, missing or invalid Thread targets are refused before starting a loop or invoking the API; mode changes also invalidate a captured Classic chat-only loop. A rejected start never replaces an existing valid Thread loop. The starter also requires the captured Pi context and session generation to remain current at start, queued send, tick and late failure boundaries, even if the transport epoch is unchanged; replacing a context object in place cannot revive old activity. Message activity hooks capture generation across awaited preview work, and agent-start bindings recheck it before post-await activity/typing, so an obsolete finally or continuation cannot acquire replacement ownership. The typing sender carries its captured loop/context/generation/transport assertion as process-local call authority: direct API issuance rechecks it after queued work, and the follower caller rechecks it after registration waits and immediately before socket issuance without putting callbacks in IPC data. Automatic typing uses one HTTP attempt with no transport-family fallback or delayed replay, including forwarded calls; a future keepalive is a fresh owner-checked attempt. An already issued HTTP/RPC cannot be retroactively unsent. Each loop refreshes its exact target every three seconds and keeps one action in flight. The leader API runtime coalesces identical chat/thread/action calls for two seconds,
|
|
688
|
+
At every connected instance `agent_start`, the lifecycle binding starts Telegram's native `…typing` indicator in that instance's assigned target, whether the run came from Telegram, the local TUI, or an autonomous continuation such as Grow Loop. Terminal `Active` remains Telegram-turn-specific; the native indicator answers the separate question of whether the instance is doing agent work. Streaming keepalives reuse a valid typing owner with the same session generation, transport and target rather than revoking its pending HTTP/IPC action. Fresh context wrappers may reuse that owner within a proven generation; without generation evidence, context identity must match. Explicit queue dispatch and Telegram prompt handoff still replace the owner. Queue dispatch carries the queued turn's exact target into typing before `agent_start`, rather than reducing it to a chat ID. In Threaded Mode, missing or invalid Thread targets are refused before starting a loop or invoking the API; mode changes also invalidate a captured Classic chat-only loop. A rejected start never replaces an existing valid Thread loop. The starter also requires the captured Pi context and session generation to remain current at start, queued send, tick and late failure boundaries, even if the transport epoch is unchanged; replacing a context object in place cannot revive old activity. Message activity hooks capture generation across awaited preview work, and agent-start bindings recheck it before post-await activity/typing, so an obsolete finally or continuation cannot acquire replacement ownership. The typing sender carries its captured loop/context/generation/transport assertion as process-local call authority: direct API issuance rechecks it after queued work, and the follower caller rechecks it after registration waits and immediately before socket issuance without putting callbacks in IPC data. Automatic typing uses one HTTP attempt with no transport-family fallback or delayed replay, including forwarded calls; a future keepalive is a fresh owner-checked attempt. An already issued HTTP/RPC cannot be retroactively unsent. Each loop refreshes its exact target every three seconds and keeps one action in flight. The leader API runtime coalesces identical chat/thread/action calls for two seconds, admits different Threads independently, and shares a real Telegram 429 `retry_after` fence across every Thread key in that chat. A pending-request count retains that shared state until every concurrent action settles, so completion of a faster peer cannot orphan a late 429; expired gates prune opportunistically and each gate family retains at most 256 active keys. Suppression never schedules a delayed retry. A failed typing action remains a structured diagnostic but cannot project `error` onto an otherwise healthy connected/leader/follower status. Assistant message start/update hooks still re-arm typing during Telegram-owned turns so transient provider/model errors do not leave a continuing run without activity feedback, and agent/session completion stops it.
|
|
689
689
|
|
|
690
690
|
### Rendering And Delivery
|
|
691
691
|
|
|
@@ -485,7 +485,7 @@ Threaded Mode should make follower threads behave like normal Telegram instance
|
|
|
485
485
|
| Replies/finals | Final replies land in the same thread | Follower finals go through leader transport into follower thread | Outbound calls carry target and inject `message_thread_id` | Reply delivery and bus API tests |
|
|
486
486
|
| Previews/Rich Drafts | Draft previews use the active thread target | Follower previews use the same native draft lifecycle through the leader | Preview transport preserves target and draft id | Preview thread-target tests |
|
|
487
487
|
| Attachments/voice | Files and voice upload in the instance thread | Follower uploads route through leader multipart transport | Multipart calls are target-scoped and follower-authorized | Bus allowlist and outbound delivery tests |
|
|
488
|
-
| Native activity status | `sendChatAction(typing)` refreshes Telegram's native `…typing` indicator only in the assigned Thread for every agent run, including local and autonomous work; aggregate `All` is not mirrored, terminal identity remains `connected`, and active work stays included in the green Queue count | Followers route the exact Thread action through leader transport while retaining their stable terminal `follower` identity and green activity count; the leader admits
|
|
488
|
+
| Native activity status | `sendChatAction(typing)` refreshes Telegram's native `…typing` indicator only in the assigned Thread for every agent run, including local and autonomous work; aggregate `All` is not mirrored, terminal identity remains `connected`, and active work stays included in the green Queue count | Followers route the exact Thread action through leader transport while retaining their stable terminal `follower` identity and green activity count; the leader admits different Threads independently, coalesces exact chat/Thread/action calls and shares real Telegram 429 cooldown across that chat's Thread keys | Agent lifecycle targets the active Telegram turn when present and otherwise the instance binding; one keyed loop avoids duplicate aggregate sends/rate-limit pressure | Agent-start binding, typing-loop, target-routing, and terminal-status regressions |
|
|
489
489
|
| Leader election / promotion | Current leader keeps its thread across reload | A promoted follower keeps its existing thread, slot, and name when elected and after later reload | Promotion converts the current follower binding into the leader profile before forced lock acquisition, so leader startup reuses it instead of provisioning a new thread | Follower heartbeat recovery passes binding snapshot into promotion; own-topic provisioner reuses promoted bindings |
|
|
490
490
|
| `/start` command/menu bootstrap | Registers visible bot commands and opens the menu | Follower `/start` can refresh the bot command menu through the leader and open its local menu without warnings | Bot command registration is a validated global Bot API call allowed through trusted follower bus transport | Bus allowlist regression for `setMyCommands` |
|
|
491
491
|
| Follower reconnect | Existing leader binding is reused only when still usable | Same-process `/new` or `/reload` suspends the old follower socket/context and automatically re-registers the new session to the exact prior target; explicit reconnect to a genuinely closed/stale Telegram tab still recreates a visible thread before success | A short-lived handoff carries the assigned target across session replacement, and the leader transfers that binding to the new runtime instance id by stable manual-follower identity; stale Bot API errors remain the proof for fresh provisioning | Session handoff/refresh, leader binding-transfer, persisted leader-reload reuse, and stale-target replacement regressions |
|
|
@@ -673,12 +673,26 @@ This inspection authorizes no reload/restart, live-state mutation, fault injecti
|
|
|
673
673
|
## Security Boundaries
|
|
674
674
|
|
|
675
675
|
- Messages, edits, callbacks, and reactions check user authorization, not only chat/thread membership.
|
|
676
|
-
- Followers authenticate to the local leader IPC with a leader-minted capability secret carried in the active lock entry; registration, heartbeat, forwarded updates, and follower API calls without the secret are rejected. Registration rejections are surfaced verbatim in the follower `/telegram-connect` result, registration waits through leader-side Telegram thread provisioning, and successful registrations send an immediate heartbeat before the interval ticker so the leader does not prune a live follower before its first scheduled heartbeat. The local bus socket is also created under a private `0700` directory with `0600` socket permissions as a first local-only boundary.
|
|
676
|
+
- Followers authenticate to the local leader IPC with a leader-minted capability secret carried in the active lock entry; registration, heartbeat, forwarded updates, and follower API calls without the secret are rejected. Registration rejections are surfaced verbatim in the follower `/telegram-connect` result, registration waits through leader-side Telegram thread provisioning, and successful registrations send an immediate heartbeat before the interval ticker so the leader does not prune a live follower before its first scheduled heartbeat. The local bus socket is also created under a private `0700` directory with `0600` socket permissions as a first local-only boundary. Follower API authorization refusal surfaces as `TelegramApiFollowerNotAllowedError` with the requested target when available. Only the leader's structured `follower-api-not-allowed` negative acknowledgement, emitted before API delegation and matched to the requested method, populates `rejectedRequestMethod`. Legacy error text, identical text thrown after delegation, a mismatched method or a lost acknowledgement supplies no such proof; old peers continue to receive the same human-readable rejection. The structured refusal proves only that the rejected API request was not executed, not that the command had no earlier effects. Admission therefore never immediately re-enters the handler or retargets its source to recover a reply. The original input remains in durable `retry-wait`; ordinary retries retain their documented at-least-once semantics. Safe reply-request recovery for re-created recipient Threads remains open in the backlog.
|
|
677
677
|
- Follower Bot API proxying is allowlisted and target-scoped where applicable. Ordinary sends remain confined to the follower's assigned thread; trusted runtime-marked `telegram_message` cross-target sends may address only a different thread inside the same paired chat, and the leader strips the internal marker before calling Telegram. This preserves requested inter-thread delivery without granting arbitrary bot or cross-chat control.
|
|
678
678
|
- Button and section callbacks verify authorized `from.id` and owning target/instance.
|
|
679
679
|
- Generated artifacts stay scoped to the owning thread after leader failover.
|
|
680
680
|
- Diagnostics redact bot tokens, large prompts, attachment paths, and handler output.
|
|
681
681
|
|
|
682
|
+
### Reply Recovery Ownership Boundary
|
|
683
|
+
|
|
684
|
+
A destination belongs to the complete delivery operation, not just the outgoing Bot API body. `replies.sendTelegramChunkSequence` captures one target for all chunks and records message ownership against it. Menu state independently retains `chatId`, `threadId` and the returned message ID through `menu-model.createTelegramMenuDelivery`. Its numeric send result cannot report a relocated Thread to the menu owner. Rewriting only a refused send's `message_thread_id` would therefore separate actual delivery from ownership and callback state; moving only later chunks would also split one reply across tabs.
|
|
685
|
+
|
|
686
|
+
Follower API arguments use one private JSON-wire snapshot made before registration waits. Issued method/body, refusal target/method and slow-call diagnostics derive from that same snapshot; later caller mutations cannot relabel the attempt. Caller serialization runs once, with local authority checked before capture and again before issuance.
|
|
687
|
+
|
|
688
|
+
Admission and recipient identity are separate. The local update execution fence carries its admission generation, update ID and abort signal; the queue-owner snapshot carries instance, process/birth and session-generation identity. Neither contains a complete recipient/session/profile/registration/target grant. Receipt and Workspace-admission namespaces are not the follower's registered profile key: selected-menu composition supplies that registered key separately, while the leader independently captures its active Workspace scope. No ordinary recipient-scoped reply-delivery lease joins these views today.
|
|
689
|
+
|
|
690
|
+
Pre-publication destination selection is separate from refused-send recovery. For an executing private-DM follower journal, the status/model/settings and text-reply target adapters select the acknowledged recipient target while the active profile/session journal binding still matches the worker. The execution fence checks admission currency; it does not grant delivery. The selected target is passed to the menu owner before state construction and to the existing reply sender before chunking, with the old reply anchor removed on a Thread change. The original input and its journal are not rewritten. A different chat, a Thread-to-All fallback or missing recipient binding refuses selection; explicit selected-menu authority keeps its original fixed destination. The ordinary leader-side API authorization remains unchanged. This prevents a stale source address from choosing the initial reply destination; it neither relocates an issued request nor supplies the missing reply-recovery lease.
|
|
691
|
+
|
|
692
|
+
Structured refusal is evidence about one API attempt, not a delivery or command recovery grant. Current delivery propagates that refusal without a replacement send, without editing the target or reply anchor, and without replaying acknowledged chunks. Already acknowledged messages keep their original ownership; the refused message acquires none. This applies to interactive HTML menus, split native Markdown and single Rich Message sections.
|
|
693
|
+
|
|
694
|
+
Guarded follower delivery must use a recipient-scoped transport that enforces authority at the leader, not serialize a local callback through the ordinary proxy. The existing selected-menu channel is bound to an exact live-rebind operation, source cohort and recipient; a raw journaled command with no matching retained operation cannot borrow that grant. Any future reply-only recovery must establish its own exact authority, keep the destination consistent in delivery and menu ownership, clear stale reply metadata, and refuse relocation after partial publication or an unknown outcome. Handler-replay containment stays enabled until that boundary is proven; this is not an automatic recovery feature.
|
|
695
|
+
|
|
682
696
|
## Acceptance Criteria
|
|
683
697
|
|
|
684
698
|
- [x] The lock semantics are redesigned as Telegram bus leadership with liveness proof and stale takeover rules.
|
|
@@ -44,6 +44,8 @@ Stable commands inside Pi:
|
|
|
44
44
|
- `/telegram-disconnect` — after destructive confirmation, stop polling and release ownership without deleting or silencing accepted local queue state. A successful command queues the corresponding hidden, non-triggering disconnected context note; cancelled or failed disconnects do not publish a false state transition. In Threaded Mode it deletes this instance's current Telegram thread; a follower waits for its active leader to confirm generation-fenced cleanup before stopping. Graceful Pi `quit` performs the same teardown without prompting, while session replacement suspends transport without disconnect teardown. Local `/resume` carries connection intent rather than the source Thread; other supported replacements retain their existing continuity rules.
|
|
45
45
|
- `/telegram-status` — show connection, polling, execution, queue, and recent event diagnostics; debug output separates poller and worker progress, durable automatic-retry state, exact foreign queued-owner identity, and negotiated protocol/build/capabilities.
|
|
46
46
|
|
|
47
|
+
The `/telegram-connect --internal new <token>` form is a runtime-only dispatch, not a profile connection or a manual new-session shortcut. It requires the exact prepared one-use action and bypasses profile activation, connection intents and startup/recovery. Missing, forged, replayed, unsupported or malformed internal calls fail closed; invalid calls do not consume a valid pending action. The flag is reserved, but `internal` without the leading dashes remains a valid profile name. The standalone `/telegram-internal` command is no longer registered.
|
|
48
|
+
|
|
47
49
|
### Telegram commands
|
|
48
50
|
|
|
49
51
|
- `/name Name` — set the durable manual display name of the current Thread. The routed leader or follower uses the authenticated target-fenced mutation and edits the visible title over the active automatic projection. Bare `/name` immediately opens expiring exact-target input; the next valid name is consumed before agent dispatch. Cancel is always available; **Reset to automatic** appears only when a manual name exists. Entering a bare uppercase slot letter such as `A` is also treated as an explicit reset to the current automatic display projection rather than as a manual name.
|
|
@@ -51,7 +53,7 @@ Stable commands inside Pi:
|
|
|
51
53
|
Stable commands inside the paired Telegram DM:
|
|
52
54
|
|
|
53
55
|
- `/start` — pair when needed and open the main application menu.
|
|
54
|
-
- `/new` — after idle and empty-queue checks, request a new Pi session in the current classic chat or Thread. The bridge acknowledges the callback, deletes its confirmation, completes and removes the exact durable update, then dispatches one runtime-armed typed action through the internal `/telegram-internal
|
|
56
|
+
- `/new` — after idle and empty-queue checks, request a new Pi session in the current classic chat or Thread. The bridge acknowledges the callback, deletes its confirmation, completes and removes the exact durable update, then dispatches one runtime-armed typed action through the internal `/telegram-connect --internal new <token>` Pi gateway using `pi.sendUserMessage(..., { expandPromptTemplates: true })`. Manual invocation reports that the gateway cannot be run manually; the armed handler receives a real `ExtensionCommandContext` and calls `ctx.newSession()`; one discriminated durable intent preserves either exact classic Profile/CWD/session/chat continuity or the Thread binding with slot/name re-key. The successor CAS-claims that intent before sending one terminal result. In a follower Thread the leader publishes and claims the intent on the follower's behalf over capability-gated, exact-generation local bus requests; followers never persist leader-owned state. Busy, identity-mismatch, stale-registration, incompatible-leader, and unavailable-host paths fail closed.
|
|
55
57
|
- `/compact` — open confirmation and compact when idle.
|
|
56
58
|
- `/next` — abort active work first when needed, attempt the interrupted prompt's abort notice, then reply `Dispatching next queued turn.` to the exact queued prompt selected for the next model turn. That dispatch notice owns the turn's one reply header; later answer messages do not reply to the same prompt again. Abort-notice failure is diagnostic and cannot block dispatch. A later `/abort` or `/stop` cancels both pending transition notices before taking ownership. The `/next` command itself is never the lifecycle-notice reply target, and aborted pending assistant text is suppressed.
|
|
57
59
|
- `/continue` — enqueue a priority `continue` prompt.
|
|
@@ -869,6 +869,8 @@ export function createTelegramLiveTargetWorkObserver<TContext>(deps: {
|
|
|
869
869
|
|
|
870
870
|
interface TelegramCommandsAndToolsBindingDeps {
|
|
871
871
|
pi: Pi.ExtensionAPI;
|
|
872
|
+
handleInternalAction:
|
|
873
|
+
Commands.TelegramSessionActionRuntime["handleInternalAction"];
|
|
872
874
|
agentDir: string;
|
|
873
875
|
configStore: Config.TelegramConfigStore;
|
|
874
876
|
persistConfig: (config?: Config.TelegramConfig) => Promise<void>;
|
|
@@ -926,6 +928,7 @@ interface TelegramCommandsAndToolsBindingDeps {
|
|
|
926
928
|
|
|
927
929
|
export function registerTelegramCommandsAndTools({
|
|
928
930
|
pi,
|
|
931
|
+
handleInternalAction,
|
|
929
932
|
agentDir,
|
|
930
933
|
configStore,
|
|
931
934
|
persistConfig,
|
|
@@ -1023,6 +1026,7 @@ export function registerTelegramCommandsAndTools({
|
|
|
1023
1026
|
);
|
|
1024
1027
|
};
|
|
1025
1028
|
Commands.registerTelegramBridgeCommands(pi, {
|
|
1029
|
+
handleInternalAction,
|
|
1026
1030
|
promptForConfig: async (ctx, profileName) => {
|
|
1027
1031
|
const nextProfileName = profileName ?? undefined;
|
|
1028
1032
|
if (profileName && !Config.isValidTelegramProfileName(profileName)) {
|
|
@@ -1919,11 +1919,16 @@ export function createTelegramBusFollowerApiCaller(
|
|
|
1919
1919
|
method: string,
|
|
1920
1920
|
args: unknown[],
|
|
1921
1921
|
localOptions: Pick<TelegramApiCallOptions, "assertAuthority"> | undefined,
|
|
1922
|
-
timing: { registeredAtMs?: number },
|
|
1922
|
+
timing: { registeredAtMs?: number; apiMethod?: string },
|
|
1923
1923
|
): Promise<unknown> => {
|
|
1924
1924
|
const assertion = localOptions?.assertAuthority;
|
|
1925
1925
|
const assertCurrent = () => assertTelegramApiCallAuthority(assertion, false);
|
|
1926
1926
|
assertCurrent();
|
|
1927
|
+
// Match IPC's JSON semantics once, before awaits; send and refusal metadata share this identity.
|
|
1928
|
+
const requestArgs = JSON.parse(JSON.stringify(args)) as unknown[];
|
|
1929
|
+
const apiMethod = (method === "call" || method === "callMultipart") && typeof requestArgs[0] === "string"
|
|
1930
|
+
? requestArgs[0] : method;
|
|
1931
|
+
timing.apiMethod = apiMethod;
|
|
1927
1932
|
const registration = await resolveTelegramBusFollowerRegistration(
|
|
1928
1933
|
deps,
|
|
1929
1934
|
timeoutMs,
|
|
@@ -1943,18 +1948,13 @@ export function createTelegramBusFollowerApiCaller(
|
|
|
1943
1948
|
instanceId: deps.instanceId,
|
|
1944
1949
|
registrationGeneration: registration.generation,
|
|
1945
1950
|
method,
|
|
1946
|
-
args,
|
|
1951
|
+
args: requestArgs,
|
|
1947
1952
|
sentAtMs: getNowMs(),
|
|
1948
1953
|
},
|
|
1949
1954
|
assertion ? assertCurrent : undefined,
|
|
1950
1955
|
);
|
|
1951
1956
|
} catch (error) {
|
|
1952
1957
|
if (error instanceof TelegramBusLocalAuthorityError) throw new TelegramApiAuthorityError(error.requestIssued);
|
|
1953
|
-
const apiMethod =
|
|
1954
|
-
(method === "call" || method === "callMultipart") &&
|
|
1955
|
-
typeof args[0] === "string"
|
|
1956
|
-
? args[0]
|
|
1957
|
-
: method;
|
|
1958
1958
|
if (!isTelegramApiMethodRetrySafe(apiMethod)) {
|
|
1959
1959
|
throw new TelegramApiCommitUnknownError(apiMethod, error);
|
|
1960
1960
|
}
|
|
@@ -1988,25 +1988,30 @@ export function createTelegramBusFollowerApiCaller(
|
|
|
1988
1988
|
new Error(message ?? "Telegram bus API call result is ambiguous."),
|
|
1989
1989
|
);
|
|
1990
1990
|
}
|
|
1991
|
-
|
|
1991
|
+
const refusedMethod = response?.kind === "bus.ack" && !response.ok &&
|
|
1992
|
+
response.error?.code === "follower-api-not-allowed" &&
|
|
1993
|
+
response.error.method === apiMethod
|
|
1994
|
+
? apiMethod : undefined;
|
|
1995
|
+
if (refusedMethod || message === TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE) {
|
|
1992
1996
|
const body =
|
|
1993
|
-
(method === "call" || method === "callMultipart") && isRecord(
|
|
1994
|
-
?
|
|
1997
|
+
(method === "call" || method === "callMultipart") && isRecord(requestArgs[1])
|
|
1998
|
+
? requestArgs[1]
|
|
1995
1999
|
: undefined;
|
|
1996
2000
|
const chatId = Number(body?.chat_id);
|
|
1997
2001
|
const threadId = Number(body?.message_thread_id);
|
|
1998
2002
|
throw new TelegramApiFollowerNotAllowedError(
|
|
1999
|
-
message,
|
|
2003
|
+
message ?? TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE,
|
|
2000
2004
|
Number.isSafeInteger(chatId) && Number.isSafeInteger(threadId)
|
|
2001
2005
|
? { chatId, threadId }
|
|
2002
2006
|
: undefined,
|
|
2007
|
+
refusedMethod,
|
|
2003
2008
|
);
|
|
2004
2009
|
}
|
|
2005
2010
|
throw new Error(message ?? "Telegram bus API call failed.");
|
|
2006
2011
|
};
|
|
2007
2012
|
return async (method, args, localOptions) => {
|
|
2008
2013
|
const startedAtMs = getNowMs();
|
|
2009
|
-
const timing: { registeredAtMs?: number } = {};
|
|
2014
|
+
const timing: { registeredAtMs?: number; apiMethod?: string } = {};
|
|
2010
2015
|
let outcome = "ok";
|
|
2011
2016
|
try {
|
|
2012
2017
|
return await caller(method, args, localOptions, timing);
|
|
@@ -2016,11 +2021,7 @@ export function createTelegramBusFollowerApiCaller(
|
|
|
2016
2021
|
} finally {
|
|
2017
2022
|
const elapsedMs = getNowMs() - startedAtMs;
|
|
2018
2023
|
if (elapsedMs >= slowCallMs) {
|
|
2019
|
-
const apiMethod =
|
|
2020
|
-
(method === "call" || method === "callMultipart") &&
|
|
2021
|
-
typeof args[0] === "string"
|
|
2022
|
-
? args[0]
|
|
2023
|
-
: method;
|
|
2024
|
+
const apiMethod = timing.apiMethod ?? method;
|
|
2024
2025
|
try {
|
|
2025
2026
|
deps.recordRuntimeEvent?.(
|
|
2026
2027
|
"bus",
|
|
@@ -4189,10 +4189,17 @@ async function handleFollowerApiCall(
|
|
|
4189
4189
|
args: envelope.args,
|
|
4190
4190
|
})
|
|
4191
4191
|
) {
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4192
|
+
const apiMethod = getFollowerApiMethodAndBody(envelope).apiMethod;
|
|
4193
|
+
return {
|
|
4194
|
+
...rejectTelegramBusRequest(
|
|
4195
|
+
envelope.requestId,
|
|
4196
|
+
TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE,
|
|
4197
|
+
),
|
|
4198
|
+
// Only this pre-dispatch authorizer refusal certifies that the API method was not called.
|
|
4199
|
+
...(/^[A-Za-z][A-Za-z0-9]{0,63}$/.test(apiMethod)
|
|
4200
|
+
? { error: { code: "follower-api-not-allowed" as const, method: apiMethod } }
|
|
4201
|
+
: {}),
|
|
4202
|
+
};
|
|
4196
4203
|
}
|
|
4197
4204
|
if (!deps.callApi) {
|
|
4198
4205
|
return rejectTelegramBusRequest(
|
|
@@ -1260,6 +1260,7 @@ export type TelegramBusEnvelope = (
|
|
|
1260
1260
|
| "ledger-overloaded"
|
|
1261
1261
|
| "incompatible-protocol"
|
|
1262
1262
|
| "stale-target"
|
|
1263
|
+
| "follower-api-not-allowed"
|
|
1263
1264
|
| "workspace-binding-unavailable";
|
|
1264
1265
|
method?: string;
|
|
1265
1266
|
chatId?: number;
|
|
@@ -4148,10 +4149,17 @@ function parseAckEnvelope(
|
|
|
4148
4149
|
code === "ledger-overloaded" ||
|
|
4149
4150
|
code === "incompatible-protocol" ||
|
|
4150
4151
|
code === "stale-target" ||
|
|
4152
|
+
code === "follower-api-not-allowed" ||
|
|
4151
4153
|
code === "workspace-binding-unavailable"
|
|
4152
4154
|
) {
|
|
4153
4155
|
const chatId = value.error.chatId;
|
|
4154
4156
|
const threadId = value.error.threadId;
|
|
4157
|
+
if (
|
|
4158
|
+
code === "follower-api-not-allowed" &&
|
|
4159
|
+
(value.ok || Object.hasOwn(value, "result") ||
|
|
4160
|
+
typeof value.error.method !== "string" ||
|
|
4161
|
+
!/^[A-Za-z][A-Za-z0-9]{0,63}$/.test(value.error.method))
|
|
4162
|
+
) return undefined;
|
|
4155
4163
|
if (
|
|
4156
4164
|
code === "stale-target" &&
|
|
4157
4165
|
(!Number.isSafeInteger(chatId) || !Number.isSafeInteger(threadId))
|