fix(telegram): expose live location updates to hooks (#122185)

* fix(telegram): expose live location updates to hooks

* test(telegram): keep location coverage focused

* fix(telegram): emit stopped live locations

* test(plugin-sdk): refresh location hook contracts

* docs(changelog): note Telegram live location hooks

* fix(telegram): complete live-location observer path

---------

Co-authored-by: mbelinky <mbelinky@users.noreply.github.com>
This commit is contained in:
Mariano
2026-08-11 23:26:23 +02:00
committed by GitHub
parent cfc684e49c
commit edb7a1692e
48 changed files with 502 additions and 40 deletions
+1
View File
@@ -68,6 +68,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- **Telegram live locations:** expose initial, moving, and stopped live-location updates through the channel-neutral `message_received` hook without starting agent turns for edits.
- **Updater plugin convergence:** keep pre-plugin doctor passes from installing configured plugins before the updater's plugin sweep, while preserving the final post-plugin migration pass and preventing ambient update-phase state from leaking into fresh doctor processes.
- **Control UI browser tab identity:** keep selected tab styling, accessibility, focus, address, and page snapshot aligned across in-place navigation and tab reordering. Fixes #120745. Thanks @shakkernerd.
- **Control UI staged attachments:** preserve unsent images, files, pasted images, and large pasted text across same-tab route and narrow split-pane remounts while keeping pane close, mismatched pane/session/Gateway remounts, application shutdown, and hard reload as cleanup boundaries. Fixes #121519. Thanks @shakkernerd.
@@ -1 +1 @@
{"contentHash":"00b95749d23bbf126f7081ee8da4a303b2c984f181be7b68f8bf48b12b67e901","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
{"contentHash":"6d42269794e0473fdae832d6c1af49854a822438f4b3b0943fb855846cbd5f64","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"a806b806c516e82741cf2731d0d0375736eda65dcf130f0a66f3a631b6b1b482","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
{"contentHash":"d5a6edec36b8675110181c771b79fac5753d242ce8adc58022743eb0184c34f2","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"c97e7fbdbd52d6afe181fe0685495582ad5802f3f3fb7e531b2e123ddd4f6a32","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"}
{"contentHash":"02efab07a69f7899421c36ab7cee6dd2bd84ce2d689d10549129dc4dc45a1f15","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"}
@@ -1 +1 @@
{"contentHash":"f93ca33e4a11459e4def3d6c09463fcbe0f93c6cd6bb967edf7ab6ebd3dbe584","entrypoint":"channel-contract","importSpecifier":"openclaw/plugin-sdk/channel-contract"}
{"contentHash":"30b463a4e09c326f52255ad4015c8546c7bca5475d33d784969e2cc4f1feed40","entrypoint":"channel-contract","importSpecifier":"openclaw/plugin-sdk/channel-contract"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"60730746ea4ad9d8aef60a7f4d4aecd7cd28ae5d28b21e44646b3fe1398e6c95","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
{"contentHash":"74e044d5e8a93fd319609f0d130eea0c1e636111e7c2597cbc3c98b847515bd3","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
@@ -1 +1 @@
{"contentHash":"3d0d58f29870cbb7889517e24b874ea5b63dfde6c15433c13414e8484b74d266","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
{"contentHash":"176592119d65008bfa8d1b465670c9556b0275d432e738353ea5a25d0a61985d","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
@@ -1 +1 @@
{"contentHash":"dbbddd5dc44f96642d3885073c6d2939bca17d1ee59c4e65dfd734ccaa115b4b","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"}
{"contentHash":"39d7eea281be7a268c1878155930034027f73ae6404622ab09041300bda41991","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"}
@@ -1 +1 @@
{"contentHash":"f185748f1394f1d64653b21889954229bf0049e174526554e52c8e0bfc2c7dc5","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
{"contentHash":"d10f59babb0166e4db0a66e801ca674ad23b158737939d9213c3bc6258f2cc5c","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
@@ -1 +1 @@
{"contentHash":"59cfee1296e172c295a550d4adbfff8776f10b73e07902394a68aa143efecabc","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
{"contentHash":"dcbaf714f293fbe933587344f798c0d86bfd65dc74e05e552c08666695da5125","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
@@ -1 +1 @@
{"contentHash":"07f8d2b5d1186410f5233394028b5df8a5cdbcb7a40011ca089db78e202d3f8f","entrypoint":"channel-pairing","importSpecifier":"openclaw/plugin-sdk/channel-pairing"}
{"contentHash":"b09818bcd98562155b66aafd6643b7487a4c267f662c1da87434b47a03446b4e","entrypoint":"channel-pairing","importSpecifier":"openclaw/plugin-sdk/channel-pairing"}
@@ -1 +1 @@
{"contentHash":"400ffb2b574275188fcbaeb2bc4fd6c5dd6cc1007c76223b1d63d2056b3ddd99","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
{"contentHash":"5e92fe659c6fa93639b6e197bca678ec533c190208c65eee3ef4cb24f3929848","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
@@ -1 +1 @@
{"contentHash":"a5feee467592d71486b388de14c3a08ccaf776c5e31d9355aa49bbfa3dbcb9d1","entrypoint":"command-auth-native","importSpecifier":"openclaw/plugin-sdk/command-auth-native"}
{"contentHash":"41c8a0c1c9454afec387fb980900d4795ef29f45a05a025aeb1602bb7739b7a8","entrypoint":"command-auth-native","importSpecifier":"openclaw/plugin-sdk/command-auth-native"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"e0e0ab127fcab6833850a208ecf21cc26bbfda73d4240dcddebae4790ea641db","entrypoint":"command-auth","importSpecifier":"openclaw/plugin-sdk/command-auth"}
{"contentHash":"59750b3a09074d7e6743f4f1ec4b9ee3623862521d6be196131ed50977a15539","entrypoint":"command-auth","importSpecifier":"openclaw/plugin-sdk/command-auth"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"f42f280c5fec25a7dae84fa52387525e3ef21c78ac1ee2c0ae19f8e58db829c1","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"}
{"contentHash":"96df262c825f0096a8050df72f6391bfa8c399555d148491dfbdbd6c402fd63b","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"}
@@ -1 +1 @@
{"contentHash":"c48ebc2d528a2f3fb2496828c20b871f72c09dc8636ec8b12e94f9e91199f751","entrypoint":"conversation-runtime","importSpecifier":"openclaw/plugin-sdk/conversation-runtime"}
{"contentHash":"b0b4951cd020c358a1d27888c7794a6021c9e714502fa73becafaae100e67003","entrypoint":"conversation-runtime","importSpecifier":"openclaw/plugin-sdk/conversation-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"589c2e34ab4910b95df172434a94e7a74838280c31f0955319fd696be3f3bc78","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
{"contentHash":"db4f872ae8ea2b1c36068c4fa4d0550e49550f34d31cbb4898533cdde9f4d103","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"55b33df13c3362c01f13580f9903900075a2bb50b05cae56533a3ce9a5170639","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
{"contentHash":"9a9729aaa9a46400464a7c2b5240541a9f379271b863b25e1c4dd1c3b7508e73","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"6066a7ed3f68774a9e108b71a5bedfa002582dfdfa64e53ba8fefecb2310c09f","entrypoint":"hook-runtime","importSpecifier":"openclaw/plugin-sdk/hook-runtime"}
{"contentHash":"372c9a2f49fd5d431197b7f35c37321a4d4296d7a1cddc2b71c3ee757ebe95a7","entrypoint":"hook-runtime","importSpecifier":"openclaw/plugin-sdk/hook-runtime"}
@@ -1 +1 @@
{"contentHash":"61c67e6ebab5fc4d69226afa0d94f794e8d6798658dfaef60184506551b97a47","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
{"contentHash":"0006fe56489e1a95488e436e5b2d881c353003ba24163aa5620f8b52c87c0d99","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"d5e2f7be4ffffaff8ff33cf86761e8e88ccca74b10a9d5cc31ab7cd99012af74","entrypoint":"media-runtime","importSpecifier":"openclaw/plugin-sdk/media-runtime"}
{"contentHash":"04ca7bf038cd8ad69f3145ebd3c774b8b27b18ed96fa8227e289abcecdbd83b5","entrypoint":"media-runtime","importSpecifier":"openclaw/plugin-sdk/media-runtime"}
@@ -1 +1 @@
{"contentHash":"b6ca0750b3c2ada85cec932d08e323e28284566869e2b38d4ae2a849d0947ba5","entrypoint":"media-understanding-runtime","importSpecifier":"openclaw/plugin-sdk/media-understanding-runtime"}
{"contentHash":"7d5fd82c531675b86446df475d1744902b721dbcc345aeabaf7bdfee27a77bc0","entrypoint":"media-understanding-runtime","importSpecifier":"openclaw/plugin-sdk/media-understanding-runtime"}
@@ -1 +1 @@
{"contentHash":"8d9f9721597b619189cbaa30747e10d2fb8adcf8e8404880b85683fb8fcebd47","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
{"contentHash":"4d4adfe9f53f025c8bb22ee1a1dff2e224dae6bb25158ac1ac991cf7ebad249b","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"b25cfc5913de6cc44719995ec258226c7cb9fd1623afc277d75a5bc2e9870ccf","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
{"contentHash":"61e6ba4e3564811059b3d27ecfee1df8b63f151a8a43bab7190336b02f76889d","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"0a01c88224f2d5dfd489f7478940032838e8246c9b00ff94fb6bcaf8d6bf84f5","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
{"contentHash":"647420ef973a58b7078e974eba6ab85b6d2df96f28e07c3bce108c0da3f52c68","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
@@ -1 +1 @@
{"contentHash":"54687a6ab40ae684e91ee657a2188685a46df434232982807f5ebcadd941d3b8","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
{"contentHash":"61e41f87d2a11f6c85ff712b500fc2cf50b974ee2f701e9c69c1693dc206e15d","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
@@ -1 +1 @@
{"contentHash":"d852e0d74a12f6eae8695aabc3a3659ec447a5289bf8e9e3a91fce7ad24c9f5a","entrypoint":"reply-dispatch-runtime","importSpecifier":"openclaw/plugin-sdk/reply-dispatch-runtime"}
{"contentHash":"3d4bc42977575af9ec21f48099cb3e74a781839c08e6d3e30baec39f2009ce03","entrypoint":"reply-dispatch-runtime","importSpecifier":"openclaw/plugin-sdk/reply-dispatch-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"3517a6a2ecc546ffac96bec9cfdaab32a08848c150794149581ca4b3932545eb","entrypoint":"reply-runtime","importSpecifier":"openclaw/plugin-sdk/reply-runtime"}
{"contentHash":"d07602091d9f19dc5a53f2df01557a1fb579ea4b298caf596ce9d4e6eb3bef8d","entrypoint":"reply-runtime","importSpecifier":"openclaw/plugin-sdk/reply-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"db430e3abd5c061bbb057dcbc5d542d22ce33ee62a07170f8bac5fcbd4c92e8d","entrypoint":"runtime-store","importSpecifier":"openclaw/plugin-sdk/runtime-store"}
{"contentHash":"6acb8e3118fe9f7a8bf0c3fa16838b0e9c8b69bf440b84c0757d4789a66950bc","entrypoint":"runtime-store","importSpecifier":"openclaw/plugin-sdk/runtime-store"}
@@ -1 +1 @@
{"contentHash":"791e6d3eccf652c146c79a743edaf6bc7cb5b6fdd313dade78faaf329b5507a1","entrypoint":"session-catalog","importSpecifier":"openclaw/plugin-sdk/session-catalog"}
{"contentHash":"3b52fbfd0855f234f80dce3c062c8d0b48f18d512aa7a26223c9c62b8056848e","entrypoint":"session-catalog","importSpecifier":"openclaw/plugin-sdk/session-catalog"}
@@ -1 +1 @@
{"contentHash":"552e06b12f79cbcd09e31fd40be2316546dfeb7a1ea6235b06de4f33db3b721b","entrypoint":"session-store-runtime","importSpecifier":"openclaw/plugin-sdk/session-store-runtime"}
{"contentHash":"3da3531231ad1e533b81a215f53d3feb555a3c73420bc6d5e13d170edb8dedcc","entrypoint":"session-store-runtime","importSpecifier":"openclaw/plugin-sdk/session-store-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"92132ddbd26e3d81621f59004521f3875158b4704ee0b9e39f4798766f229702","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
{"contentHash":"d2d5d792a37398f060d0e069077d794d7e2d91a23fb0a4849ec66b8591f86daa","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
@@ -1 +1 @@
{"contentHash":"50ffb05c64c0f2898b3543900132261e152eda3999462154360efc3db7194359","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
{"contentHash":"ae862c22e6d6f96239ea1666faa208c5445aef89b6f3bc4425e3a259c3c6cb2b","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
+2 -1
View File
@@ -755,7 +755,8 @@ Use message hooks for channel-level routing and delivery policy:
- `message_received`: observe inbound content, sender, `threadId`,
`messageId`, `senderId`, optional run/session correlation, ordered `media`,
and metadata.
normalized `location`, stable `providerUpdate` identity when supplied by the
channel, and metadata.
- `message_sending`: rewrite `content` or return `{ cancel: true }`.
- `reply_payload_sending`: rewrite normalized `ReplyPayload` objects
(including `presentation`, `delivery`, media refs, and text) or return
@@ -24,11 +24,12 @@ import {
} from "./bot/helpers.js";
import { TelegramPairingStoreReadError } from "./bot/helpers.js";
import type { TelegramContext, TelegramGetChat } from "./bot/types.js";
import { emitTelegramLiveLocationMessageHook } from "./location-message-hook.js";
import type { TelegramMessageDispatchReplayClaim } from "./message-dispatch-dedupe.js";
type TelegramMessageHandlerParams = Pick<
RegisterTelegramHandlerParams,
"bot" | "shouldSkipUpdate"
"accountId" | "bot" | "shouldSkipUpdate"
> & {
opts: Pick<RegisterTelegramHandlerParams["opts"], "botInfo">;
runtime: Pick<RegisterTelegramHandlerParams["runtime"], "error">;
@@ -57,7 +58,7 @@ interface TelegramInboundHandlers {
}
function createTelegramInboundHandlers(
{ bot, opts, runtime, shouldSkipUpdate }: TelegramMessageHandlerParams,
{ accountId, bot, opts, runtime, shouldSkipUpdate }: TelegramMessageHandlerParams,
messageRuntime: TelegramMessageHandlerRuntime,
authorizationRuntime: Pick<TelegramHandlerAuthorization, "authorizeInboundMessage">,
inboundRuntime: Pick<TelegramInboundProcessing, "processInboundMessage">,
@@ -128,6 +129,7 @@ function createTelegramInboundHandlers(
msg: Message;
requireConfiguredGroup: boolean;
botUserId: number;
providerUpdate?: { id: number; kind: "edited_message" | "edited_channel_post" };
}) => {
if (shouldSkipUpdate(params.ctxForDedupe)) {
return;
@@ -157,6 +159,15 @@ function createTelegramInboundHandlers(
return;
}
await recordMessageForReplyChain(normalizedMsg, gate.context.threadSpec, params.botUserId);
if (params.providerUpdate) {
emitTelegramLiveLocationMessageHook({
accountId,
msg: normalizedMsg,
updateId: params.providerUpdate.id,
updateKind: params.providerUpdate.kind,
isForum,
});
}
};
const handleInboundMessageLike = async (
@@ -319,6 +330,10 @@ function createTelegramInboundHandlers(
msg,
requireConfiguredGroup: false,
botUserId: resolveBotUserId(ctx),
providerUpdate:
typeof ctx.update?.update_id === "number"
? { id: ctx.update.update_id, kind: "edited_message" }
: undefined,
});
return { kind: "recorded" };
};
@@ -364,6 +379,10 @@ function createTelegramInboundHandlers(
msg: normalizeChannelPostMessage(post),
requireConfiguredGroup: true,
botUserId: resolveBotUserId(ctx),
providerUpdate:
typeof ctx.update?.update_id === "number"
? { id: ctx.update.update_id, kind: "edited_channel_post" }
: undefined,
});
return { kind: "recorded" };
};
@@ -153,9 +153,14 @@ export function buildSyntheticTextMessage(params: {
}
export const buildSyntheticContext = (
ctx: Pick<TelegramContext, "me" | "getFile">,
ctx: Pick<TelegramContext, "me" | "getFile" | "update">,
message: Message,
): TelegramContext => ({ message, me: ctx.me, getFile: ctx.getFile.bind(ctx) });
): TelegramContext => ({
message,
update: ctx.update,
me: ctx.me,
getFile: ctx.getFile.bind(ctx),
});
export function formatTelegramAmbientTranscriptBody(
messages: readonly Message[],
@@ -585,6 +585,18 @@ export async function buildTelegramInboundContextPayload(params: {
: `telegram:${chatId}`;
const telegramTo = buildTelegramInboundOriginTarget(chatId, threadSpec);
const locationContext = locationData ? toLocationContext(locationData) : undefined;
const telegramUpdate = primaryCtx.update;
const providerUpdateKind = telegramUpdate
? "edited_message" in telegramUpdate
? "edited_message"
: "message" in telegramUpdate
? "message"
: "edited_channel_post" in telegramUpdate
? "edited_channel_post"
: "channel_post" in telegramUpdate
? "channel_post"
: undefined
: undefined;
const inboundHistory =
hasGroupHistoryContext && historyKey && historyLimit > 0
? groupHistoryPromptEntries.length > 0
@@ -734,6 +746,18 @@ export async function buildTelegramInboundContextPayload(params: {
StickerMediaIncluded: allMedia[0]?.stickerMetadata ? currentMediaFacts.length > 0 : undefined,
SkipStickerMediaUnderstanding: stickerCacheHit ? true : undefined,
...locationContext,
ProviderUpdateId:
typeof telegramUpdate?.update_id === "number"
? String(telegramUpdate.update_id)
: undefined,
ProviderUpdateKind: providerUpdateKind,
ProviderMessageTimestamp: primaryCtx.message?.date
? primaryCtx.message.date * 1000
: undefined,
ProviderEditTimestamp: primaryCtx.message?.edit_date
? primaryCtx.message.edit_date * 1000
: undefined,
LocationLivePeriodSeconds: primaryCtx.message?.location?.live_period,
IsForum: isForum,
TopicName: isForum && topicName ? topicName : undefined,
},
@@ -295,6 +295,7 @@ describe("telegram inbound media", () => {
const cases = [
{
updateId: 7005,
message: {
chat: { id: 42, type: "private" as const },
message_id: 5,
@@ -313,9 +314,13 @@ describe("telegram inbound media", () => {
expect(payload.LocationLon).toBe(2.294351);
expect(payload.LocationSource).toBe("pin");
expect(payload.LocationIsLive).toBe(false);
expect(payload.ProviderUpdateId).toBe("7005");
expect(payload.ProviderUpdateKind).toBe("message");
expect(payload.ProviderMessageTimestamp).toBe(1736380800000);
},
},
{
updateId: 7006,
message: {
chat: { id: 42, type: "private" as const },
message_id: 6,
@@ -338,6 +343,7 @@ describe("telegram inbound media", () => {
for (const testCase of cases) {
replySpy.mockClear();
await handler({
update: { update_id: testCase.updateId, message: testCase.message },
message: testCase.message,
me: { username: "openclaw_bot" },
getFile: async () => ({ file_path: "unused" }),
+2 -2
View File
@@ -1,6 +1,6 @@
// Telegram type declarations define plugin contracts.
import type { Context } from "grammy";
import type { ChatFullInfo, Message, UserFromGetMe } from "grammy/types";
import type { ChatFullInfo, Message, Update, UserFromGetMe } from "grammy/types";
/** App-specific stream mode for Telegram stream previews. */
export type TelegramStreamMode = "off" | "partial" | "block" | "progress";
@@ -20,7 +20,7 @@ export type TelegramGetChat = (chatId: number | string) => Promise<TelegramChatD
*/
export type TelegramContext = {
message: Message;
update?: unknown;
update?: Update;
me?: UserFromGetMe;
getFile: TelegramGetFile;
};
@@ -0,0 +1,181 @@
import type { Message } from "grammy/types";
import { beforeEach, describe, expect, it, vi } from "vitest";
const hookRunner = vi.hoisted(() => ({
hasHooks: vi.fn(() => true),
runInboundClaim: vi.fn(async () => undefined),
runMessageReceived: vi.fn(async () => undefined),
}));
vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/plugin-runtime")>();
return { ...actual, getGlobalHookRunner: () => hookRunner };
});
const { emitTelegramLiveLocationMessageHook } = await import("./location-message-hook.js");
describe("Telegram location message hooks", () => {
beforeEach(() => {
hookRunner.hasHooks.mockClear();
hookRunner.runInboundClaim.mockClear();
hookRunner.runMessageReceived.mockReset();
hookRunner.runMessageReceived.mockResolvedValue(undefined);
});
it("ignores non-location edits", () => {
const msg = {
chat: { id: 1234, type: "private" },
message_id: 456,
date: 1_786_094_460,
edit_date: 1_786_094_520,
from: { id: 789, is_bot: false, first_name: "Mariano" },
text: "edited text",
} as Message;
emitTelegramLiveLocationMessageHook({
accountId: "main",
msg,
updateId: 9002,
updateKind: "edited_message",
isForum: false,
});
expect(hookRunner.runMessageReceived).not.toHaveBeenCalled();
});
it("emits edited live locations through the global message-received contract", () => {
const msg = {
chat: { id: 1234, type: "private" },
message_id: 456,
date: 1_786_094_460,
edit_date: 1_786_094_520,
from: { id: 789, is_bot: false, first_name: "Pat" },
location: {
latitude: 43.8376,
longitude: 18.4534,
horizontal_accuracy: 12,
live_period: 900,
},
} as Message;
emitTelegramLiveLocationMessageHook({
accountId: "main",
msg,
updateId: 9002,
updateKind: "edited_message",
isForum: false,
});
expect(hookRunner.hasHooks).toHaveBeenCalledWith("message_received", expect.any(Object));
expect(hookRunner.runMessageReceived).toHaveBeenCalledWith(
expect.objectContaining({
messageId: "456",
senderId: "789",
timestamp: 1_786_094_520_000,
location: expect.objectContaining({
latitude: 43.8376,
longitude: 18.4534,
accuracy: 12,
source: "live",
isLive: true,
livePeriodSeconds: 900,
}),
providerUpdate: {
id: "9002",
kind: "edited_message",
messageId: "456",
messageTimestamp: 1_786_094_460_000,
editedTimestamp: 1_786_094_520_000,
},
}),
expect.objectContaining({
channelId: "telegram",
accountId: "main",
conversationId: "telegram:1234",
messageId: "456",
senderId: "789",
}),
);
expect(hookRunner.runInboundClaim).not.toHaveBeenCalled();
});
it("emits the terminal edit when live-location sharing stops", () => {
const msg = {
chat: { id: 1234, type: "private" },
message_id: 456,
date: 1_786_094_460,
edit_date: 1_786_094_580,
from: { id: 789, is_bot: false, first_name: "Pat" },
location: { latitude: 43.8376, longitude: 18.4534 },
} as Message;
emitTelegramLiveLocationMessageHook({
accountId: "main",
msg,
updateId: 9003,
updateKind: "edited_message",
isForum: false,
});
expect(hookRunner.runMessageReceived).toHaveBeenCalledWith(
expect.objectContaining({
location: expect.objectContaining({ isLive: false }),
providerUpdate: expect.objectContaining({ id: "9003", kind: "edited_message" }),
}),
expect.any(Object),
);
expect(hookRunner.runInboundClaim).not.toHaveBeenCalled();
});
it("emits edited channel-post live locations with their provider update kind", () => {
const msg = {
chat: { id: -1001234, type: "supergroup", title: "Travel updates" },
message_id: 456,
date: 1_786_094_460,
edit_date: 1_786_094_580,
from: { id: 789, is_bot: true, first_name: "Travel updates" },
location: { latitude: 43.8376, longitude: 18.4534, live_period: 900 },
} as Message;
emitTelegramLiveLocationMessageHook({
accountId: "main",
msg,
updateId: 9004,
updateKind: "edited_channel_post",
isForum: false,
});
expect(hookRunner.runMessageReceived).toHaveBeenCalledWith(
expect.objectContaining({
providerUpdate: expect.objectContaining({
id: "9004",
kind: "edited_channel_post",
}),
}),
expect.any(Object),
);
});
it("does not wait for plugin observers on the Telegram inbound path", () => {
hookRunner.runMessageReceived.mockReturnValueOnce(new Promise(() => {}));
const msg = {
chat: { id: 1234, type: "private" },
message_id: 456,
date: 1_786_094_460,
edit_date: 1_786_094_520,
from: { id: 789, is_bot: false, first_name: "Pat" },
location: { latitude: 43.8376, longitude: 18.4534, live_period: 900 },
} as Message;
expect(
emitTelegramLiveLocationMessageHook({
accountId: "main",
msg,
updateId: 9005,
updateKind: "edited_message",
isForum: false,
}),
).toBeUndefined();
expect(hookRunner.runMessageReceived).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,93 @@
// Telegram live-location edits bypass agent dispatch but still need the normal observation hook.
import type { Message } from "grammy/types";
import { formatLocationText } from "openclaw/plugin-sdk/channel-inbound";
import {
deriveInboundMessageHookContext,
fireAndForgetHook,
toPluginMessageContext,
toPluginMessageReceivedEvent,
} from "openclaw/plugin-sdk/hook-runtime";
import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime";
import { extractTelegramLocation } from "./bot/body-helpers.js";
import {
buildTelegramGroupFrom,
buildTelegramInboundOriginTarget,
resolveTelegramMessageThreadSpec,
} from "./bot/helpers.js";
function buildTelegramLocationMessageHook(params: {
accountId: string;
msg: Message;
updateId: number;
updateKind: "message" | "edited_message" | "channel_post" | "edited_channel_post";
isForum: boolean;
}) {
const location = extractTelegramLocation(params.msg);
if (!location) {
return null;
}
const msg = params.msg;
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
const threadSpec = resolveTelegramMessageThreadSpec(msg, params.isForum);
const originatingTo = buildTelegramInboundOriginTarget(msg.chat.id, threadSpec);
const from = isGroup
? buildTelegramGroupFrom(msg.chat.id, threadSpec.id)
: `telegram:${msg.chat.id}`;
const canonical = deriveInboundMessageHookContext({
From: from,
To: originatingTo,
OriginatingChannel: "telegram",
OriginatingTo: originatingTo,
Provider: "telegram",
Surface: "telegram",
AccountId: params.accountId,
MessageSid: String(msg.message_id),
MessageSidFull: String(msg.message_id),
SenderId: msg.from?.id != null ? String(msg.from.id) : undefined,
SenderName: [msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(" ") || undefined,
SenderUsername: msg.from?.username,
Timestamp:
params.updateKind.startsWith("edited_") && msg.edit_date
? msg.edit_date * 1000
: msg.date
? msg.date * 1000
: undefined,
Body: formatLocationText(location),
RawBody: formatLocationText(location),
BodyForAgent: formatLocationText(location),
MessageThreadId: threadSpec.id,
GroupSubject: isGroup ? msg.chat.title : undefined,
LocationLat: location.latitude,
LocationLon: location.longitude,
LocationAccuracy: location.accuracy,
LocationName: location.name,
LocationAddress: location.address,
LocationSource: location.source,
LocationIsLive: location.isLive,
LocationLivePeriodSeconds: msg.location?.live_period,
LocationCaption: location.caption,
ProviderUpdateId: String(params.updateId),
ProviderUpdateKind: params.updateKind,
ProviderMessageTimestamp: msg.date ? msg.date * 1000 : undefined,
ProviderEditTimestamp: msg.edit_date ? msg.edit_date * 1000 : undefined,
CommandAuthorized: false,
});
return {
event: toPluginMessageReceivedEvent(canonical),
context: toPluginMessageContext(canonical),
};
}
export function emitTelegramLiveLocationMessageHook(
params: Parameters<typeof buildTelegramLocationMessageHook>[0],
): void {
const pair = buildTelegramLocationMessageHook(params);
const runner = getGlobalHookRunner();
if (!pair || !runner?.hasHooks("message_received", pair.context)) {
return;
}
fireAndForgetHook(
runner.runMessageReceived(pair.event, pair.context),
"message_received plugin hook failed",
);
}
+2 -1
View File
@@ -270,7 +270,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
// +1: add the account-aware native approval request selector.
// +3: add canonical coercion exports while retaining the shipped asString compatibility name.
// +2: add high-use coercion primitives while retaining shipped object-record exports.
4868,
// +2: channel-neutral location and provider-update hook contracts.
4870,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
+9
View File
@@ -325,7 +325,16 @@ export type MsgContext = Partial<CanonicalInboundText> & {
LocationAddress?: string;
LocationSource?: string;
LocationIsLive?: boolean;
LocationLivePeriodSeconds?: number;
LocationCaption?: string;
/** Stable identity of the provider update that carried this message. */
ProviderUpdateId?: string;
/** Provider update kind, for example `message` or `edited_message`. */
ProviderUpdateKind?: string;
/** Provider-native timestamp for the original message. */
ProviderMessageTimestamp?: number;
/** Provider-native timestamp for an edited message update. */
ProviderEditTimestamp?: number;
/** Provider label. */
Provider?: string;
/** Provider surface label. Prefer this over `Provider` when available. */
+38
View File
@@ -820,4 +820,42 @@ describe("message hook mappers", () => {
groupId: "demo-chat:chat:456",
});
});
it("projects normalized location and stable provider update identity", () => {
const canonical = deriveInboundMessageHookContext(
makeInboundCtx({
LocationLat: 43.8376,
LocationLon: 18.4534,
LocationAccuracy: 12,
LocationSource: "live",
LocationIsLive: true,
LocationLivePeriodSeconds: 900,
ProviderUpdateId: "9002",
ProviderUpdateKind: "edited_message",
ProviderMessageTimestamp: 1_786_094_460_000,
ProviderEditTimestamp: 1_786_094_520_000,
}),
);
const { event } = toPluginInboundClaimPair(canonical);
expect(event.location).toEqual({
latitude: 43.8376,
longitude: 18.4534,
accuracy: 12,
source: "live",
isLive: true,
livePeriodSeconds: 900,
});
expect(event.providerUpdate).toEqual({
id: "9002",
kind: "edited_message",
messageId: "msg-1",
messageTimestamp: 1_786_094_460_000,
editedTimestamp: 1_786_094_520_000,
});
expect(toPluginMessageReceivedEvent(canonical)).toMatchObject({
location: event.location,
providerUpdate: event.providerUpdate,
});
});
});
+54
View File
@@ -81,6 +81,8 @@ type CanonicalInboundMessageHookContext = {
isGroup: boolean;
groupId?: string;
topicName?: string;
location?: PluginHookInboundClaimEvent["location"];
providerUpdate?: PluginHookInboundClaimEvent["providerUpdate"];
trace?: DiagnosticTraceContext;
callDepth?: number;
};
@@ -168,6 +170,17 @@ export function deriveInboundMessageHookContext(
const mediaUrls = compact(media.map((fact) => fact.url ?? fact.path));
const mediaTypes = compact(media.map((fact) => fact.contentType ?? fact.kind));
const firstMedia = media[0];
const hasLocation =
typeof ctx.LocationLat === "number" &&
Number.isFinite(ctx.LocationLat) &&
typeof ctx.LocationLon === "number" &&
Number.isFinite(ctx.LocationLon);
const locationSource =
ctx.LocationSource === "pin" || ctx.LocationSource === "place" || ctx.LocationSource === "live"
? ctx.LocationSource
: undefined;
const providerUpdateId = normalizeOptionalString(ctx.ProviderUpdateId);
const providerUpdateKind = normalizeOptionalString(ctx.ProviderUpdateKind);
return {
from: ctx.From ?? "",
to: ctx.To,
@@ -217,6 +230,43 @@ export function deriveInboundMessageHookContext(
isGroup,
groupId: isGroup ? conversationId : undefined,
topicName: ctx.TopicName,
...(hasLocation
? {
location: {
latitude: ctx.LocationLat as number,
longitude: ctx.LocationLon as number,
...(typeof ctx.LocationAccuracy === "number" ? { accuracy: ctx.LocationAccuracy } : {}),
...(ctx.LocationName ? { name: ctx.LocationName } : {}),
...(ctx.LocationAddress ? { address: ctx.LocationAddress } : {}),
...(locationSource ? { source: locationSource } : {}),
...(typeof ctx.LocationIsLive === "boolean" ? { isLive: ctx.LocationIsLive } : {}),
...(typeof ctx.LocationLivePeriodSeconds === "number" &&
Number.isFinite(ctx.LocationLivePeriodSeconds)
? { livePeriodSeconds: ctx.LocationLivePeriodSeconds }
: {}),
...(ctx.LocationCaption ? { caption: ctx.LocationCaption } : {}),
},
}
: {}),
...(providerUpdateId && providerUpdateKind
? {
providerUpdate: {
id: providerUpdateId,
kind: providerUpdateKind,
...(normalizeOptionalString(ctx.MessageSidFull ?? ctx.MessageSid)
? { messageId: normalizeOptionalString(ctx.MessageSidFull ?? ctx.MessageSid) }
: {}),
...(typeof ctx.ProviderMessageTimestamp === "number" &&
Number.isFinite(ctx.ProviderMessageTimestamp)
? { messageTimestamp: ctx.ProviderMessageTimestamp }
: {}),
...(typeof ctx.ProviderEditTimestamp === "number" &&
Number.isFinite(ctx.ProviderEditTimestamp)
? { editedTimestamp: ctx.ProviderEditTimestamp }
: {}),
},
}
: {}),
};
}
@@ -436,6 +486,8 @@ function buildPluginInboundClaimEvent(
isGroup: canonical.isGroup,
commandAuthorized: extras?.commandAuthorized,
wasMentioned: extras?.wasMentioned,
...(canonical.location ? { location: { ...canonical.location } } : {}),
...(canonical.providerUpdate ? { providerUpdate: { ...canonical.providerUpdate } } : {}),
...projectHookMediaState(canonical),
metadata: {
from: canonical.from,
@@ -504,6 +556,8 @@ export function toPluginMessageReceivedEvent(
...(canonical.replyToIsQuote !== undefined ? { replyToIsQuote: canonical.replyToIsQuote } : {}),
sessionKey: canonical.sessionKey,
runId: canonical.runId,
...(canonical.location ? { location: { ...canonical.location } } : {}),
...(canonical.providerUpdate ? { providerUpdate: { ...canonical.providerUpdate } } : {}),
...projectHookMediaState(canonical),
metadata: {
to: canonical.to,
+2
View File
@@ -163,8 +163,10 @@ export type {
PluginHookInboundClaimEvent,
PluginHookInboundClaimResult,
PluginHookInboundMessageMetadata,
PluginHookLocation,
PluginHookMediaFact,
PluginHookMessageReceivedEvent,
PluginHookProviderUpdate,
PluginHookSkillArtifact,
PluginHookSkillBundleFile,
PluginHookSkillBundleSnapshot,
+26
View File
@@ -5,6 +5,28 @@ import type { PluginConversationBinding } from "./conversation-binding.types.js"
/** Ordered media fact exposed by inbound message hooks. */
export type PluginHookMediaFact = MessageHookMediaFact;
/** Channel-neutral geographic fix carried by an inbound provider update. */
export type PluginHookLocation = {
latitude: number;
longitude: number;
accuracy?: number;
name?: string;
address?: string;
source?: "pin" | "place" | "live";
isLive?: boolean;
livePeriodSeconds?: number;
caption?: string;
};
/** Stable provider update identity for transport-level correlation and deduplication. */
export type PluginHookProviderUpdate = {
id: string;
kind: string;
messageId?: string;
messageTimestamp?: number;
editedTimestamp?: number;
};
/** Provider metadata plus deprecated media aliases retained during the SDK migration window. */
export type PluginHookInboundMessageMetadata = Record<string, unknown> & {
/** @deprecated Use the first `event.media` fact with a defined `path`. */
@@ -129,6 +151,8 @@ export type PluginHookInboundClaimEvent = {
commandAuthorized?: boolean;
senderIsOwner?: boolean;
wasMentioned?: boolean;
location?: PluginHookLocation;
providerUpdate?: PluginHookProviderUpdate;
/** Staged, locally usable attachments in stable source order. */
media?: PluginHookMediaFact[];
/** Original attachment facts when local staging has not completed yet. */
@@ -156,6 +180,8 @@ export type PluginHookMessageReceivedEvent = {
traceId?: string;
spanId?: string;
parentSpanId?: string;
location?: PluginHookLocation;
providerUpdate?: PluginHookProviderUpdate;
/** Staged, locally usable attachments in stable source order. */
media?: PluginHookMediaFact[];
/** Original attachment facts when local staging has not completed yet. */
+2
View File
@@ -65,12 +65,14 @@ export type {
PluginHookInboundClaimContext,
PluginHookInboundClaimEvent,
PluginHookInboundMessageMetadata,
PluginHookLocation,
PluginHookMediaFact,
PluginHookMessageContext,
PluginHookMessageReceivedEvent,
PluginHookMessageSendingEvent,
PluginHookMessageSendingResult,
PluginHookMessageSentEvent,
PluginHookProviderUpdate,
} from "./hook-message.types.js";
export {
PluginApprovalResolutions,