feat(providers): normalize location parsing
This commit is contained in:
@@ -66,6 +66,7 @@
|
|||||||
- Telegram: honor routing.groupChat.mentionPatterns for group mention gating. Thanks Kevin Kern (@regenrek) for PR #242.
|
- Telegram: honor routing.groupChat.mentionPatterns for group mention gating. Thanks Kevin Kern (@regenrek) for PR #242.
|
||||||
- Telegram: gate groups via `telegram.groups` allowlist (align with WhatsApp/iMessage). Thanks @kitze for PR #241.
|
- Telegram: gate groups via `telegram.groups` allowlist (align with WhatsApp/iMessage). Thanks @kitze for PR #241.
|
||||||
- Telegram: support media groups (multi-image messages). Thanks @obviyus for PR #220.
|
- Telegram: support media groups (multi-image messages). Thanks @obviyus for PR #220.
|
||||||
|
- Telegram/WhatsApp: parse shared locations (pins, places, live) and expose structured ctx fields. Thanks @nachoiacovino for PR #194.
|
||||||
- Auto-reply: block unauthorized `/reset` and infer WhatsApp senders from E.164 inputs.
|
- Auto-reply: block unauthorized `/reset` and infer WhatsApp senders from E.164 inputs.
|
||||||
- Auto-reply: track compaction count in session status; verbose mode announces auto-compactions.
|
- Auto-reply: track compaction count in session status; verbose mode announces auto-compactions.
|
||||||
- Telegram: send GIF media as animations (auto-play) and improve filename sniffing.
|
- Telegram: send GIF media as animations (auto-play) and improve filename sniffing.
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ Example:
|
|||||||
## Core Contributors
|
## Core Contributors
|
||||||
|
|
||||||
- **Maxim Vovshin** (@Hyaxia, 36747317+Hyaxia@users.noreply.github.com) — Blogwatcher skill
|
- **Maxim Vovshin** (@Hyaxia, 36747317+Hyaxia@users.noreply.github.com) — Blogwatcher skill
|
||||||
|
- **Nacho Iacovino** (@nachoiacovino, nacho.iacovino@gmail.com) — Location parsing (Telegram + WhatsApp)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
46
docs/location.md
Normal file
46
docs/location.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
summary: "Inbound provider location parsing (Telegram + WhatsApp) and context fields"
|
||||||
|
read_when:
|
||||||
|
- Adding or modifying provider location parsing
|
||||||
|
- Using location context fields in agent prompts or tools
|
||||||
|
---
|
||||||
|
|
||||||
|
# Provider location parsing
|
||||||
|
|
||||||
|
Clawdbot normalizes shared locations from chat providers into:
|
||||||
|
- human-readable text appended to the inbound body, and
|
||||||
|
- structured fields in the auto-reply context payload.
|
||||||
|
|
||||||
|
Currently supported:
|
||||||
|
- **Telegram** (location pins + venues + live locations)
|
||||||
|
- **WhatsApp** (locationMessage + liveLocationMessage)
|
||||||
|
|
||||||
|
## Text formatting
|
||||||
|
Locations are rendered as friendly lines without brackets:
|
||||||
|
|
||||||
|
- Pin:
|
||||||
|
- `📍 48.858844, 2.294351 ±12m`
|
||||||
|
- Named place:
|
||||||
|
- `📍 Eiffel Tower — Champ de Mars, Paris (48.858844, 2.294351 ±12m)`
|
||||||
|
- Live share:
|
||||||
|
- `🛰 Live location: 48.858844, 2.294351 ±12m`
|
||||||
|
|
||||||
|
If the provider includes a caption/comment, it is appended on the next line:
|
||||||
|
```
|
||||||
|
📍 48.858844, 2.294351 ±12m
|
||||||
|
Meet here
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context fields
|
||||||
|
When a location is present, these fields are added to `ctx`:
|
||||||
|
- `LocationLat` (number)
|
||||||
|
- `LocationLon` (number)
|
||||||
|
- `LocationAccuracy` (number, meters; optional)
|
||||||
|
- `LocationName` (string; optional)
|
||||||
|
- `LocationAddress` (string; optional)
|
||||||
|
- `LocationSource` (`pin | place | live`)
|
||||||
|
- `LocationIsLive` (boolean)
|
||||||
|
|
||||||
|
## Provider notes
|
||||||
|
- **Telegram**: venues map to `LocationName/LocationAddress`; live locations use `live_period`.
|
||||||
|
- **WhatsApp**: `locationMessage.comment` and `liveLocationMessage.caption` are appended as the caption line.
|
||||||
60
src/providers/location.test.ts
Normal file
60
src/providers/location.test.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { formatLocationText, toLocationContext } from "./location.js";
|
||||||
|
|
||||||
|
describe("provider location helpers", () => {
|
||||||
|
it("formats pin locations with accuracy", () => {
|
||||||
|
const text = formatLocationText({
|
||||||
|
latitude: 48.858844,
|
||||||
|
longitude: 2.294351,
|
||||||
|
accuracy: 12,
|
||||||
|
});
|
||||||
|
expect(text).toBe("📍 48.858844, 2.294351 ±12m");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats named places with address and caption", () => {
|
||||||
|
const text = formatLocationText({
|
||||||
|
latitude: 40.689247,
|
||||||
|
longitude: -74.044502,
|
||||||
|
name: "Statue of Liberty",
|
||||||
|
address: "Liberty Island, NY",
|
||||||
|
accuracy: 8,
|
||||||
|
caption: "Bring snacks",
|
||||||
|
});
|
||||||
|
expect(text).toBe(
|
||||||
|
"📍 Statue of Liberty — Liberty Island, NY (40.689247, -74.044502 ±8m)\nBring snacks",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats live locations with live label", () => {
|
||||||
|
const text = formatLocationText({
|
||||||
|
latitude: 37.819929,
|
||||||
|
longitude: -122.478255,
|
||||||
|
accuracy: 20,
|
||||||
|
caption: "On the move",
|
||||||
|
isLive: true,
|
||||||
|
source: "live",
|
||||||
|
});
|
||||||
|
expect(text).toBe(
|
||||||
|
"🛰 Live location: 37.819929, -122.478255 ±20m\nOn the move",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds ctx fields with normalized source", () => {
|
||||||
|
const ctx = toLocationContext({
|
||||||
|
latitude: 1,
|
||||||
|
longitude: 2,
|
||||||
|
name: "Cafe",
|
||||||
|
address: "Main St",
|
||||||
|
});
|
||||||
|
expect(ctx).toEqual({
|
||||||
|
LocationLat: 1,
|
||||||
|
LocationLon: 2,
|
||||||
|
LocationAccuracy: undefined,
|
||||||
|
LocationName: "Cafe",
|
||||||
|
LocationAddress: "Main St",
|
||||||
|
LocationSource: "place",
|
||||||
|
LocationIsLive: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
78
src/providers/location.ts
Normal file
78
src/providers/location.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
export type LocationSource = "pin" | "place" | "live";
|
||||||
|
|
||||||
|
export type NormalizedLocation = {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
accuracy?: number;
|
||||||
|
name?: string;
|
||||||
|
address?: string;
|
||||||
|
isLive?: boolean;
|
||||||
|
source?: LocationSource;
|
||||||
|
caption?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResolvedLocation = NormalizedLocation & {
|
||||||
|
source: LocationSource;
|
||||||
|
isLive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveLocation(location: NormalizedLocation): ResolvedLocation {
|
||||||
|
const source =
|
||||||
|
location.source ??
|
||||||
|
(location.isLive
|
||||||
|
? "live"
|
||||||
|
: location.name || location.address
|
||||||
|
? "place"
|
||||||
|
: "pin");
|
||||||
|
const isLive = Boolean(location.isLive ?? source === "live");
|
||||||
|
return { ...location, source, isLive };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAccuracy(accuracy?: number): string {
|
||||||
|
if (!Number.isFinite(accuracy)) return "";
|
||||||
|
return ` ±${Math.round(accuracy ?? 0)}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCoords(latitude: number, longitude: number): string {
|
||||||
|
return `${latitude.toFixed(6)}, ${longitude.toFixed(6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLocationText(location: NormalizedLocation): string {
|
||||||
|
const resolved = resolveLocation(location);
|
||||||
|
const coords = formatCoords(resolved.latitude, resolved.longitude);
|
||||||
|
const accuracy = formatAccuracy(resolved.accuracy);
|
||||||
|
const caption = resolved.caption?.trim();
|
||||||
|
let header = "";
|
||||||
|
|
||||||
|
if (resolved.source === "live" || resolved.isLive) {
|
||||||
|
header = `🛰 Live location: ${coords}${accuracy}`;
|
||||||
|
} else if (resolved.name || resolved.address) {
|
||||||
|
const label = [resolved.name, resolved.address].filter(Boolean).join(" — ");
|
||||||
|
header = `📍 ${label} (${coords}${accuracy})`;
|
||||||
|
} else {
|
||||||
|
header = `📍 ${coords}${accuracy}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return caption ? `${header}\n${caption}` : header;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toLocationContext(location: NormalizedLocation): {
|
||||||
|
LocationLat: number;
|
||||||
|
LocationLon: number;
|
||||||
|
LocationAccuracy?: number;
|
||||||
|
LocationName?: string;
|
||||||
|
LocationAddress?: string;
|
||||||
|
LocationSource: LocationSource;
|
||||||
|
LocationIsLive: boolean;
|
||||||
|
} {
|
||||||
|
const resolved = resolveLocation(location);
|
||||||
|
return {
|
||||||
|
LocationLat: resolved.latitude,
|
||||||
|
LocationLon: resolved.longitude,
|
||||||
|
LocationAccuracy: resolved.accuracy,
|
||||||
|
LocationName: resolved.name,
|
||||||
|
LocationAddress: resolved.address,
|
||||||
|
LocationSource: resolved.source,
|
||||||
|
LocationIsLive: resolved.isLive,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -341,3 +341,84 @@ describe("telegram media groups", () => {
|
|||||||
fetchSpy.mockRestore();
|
fetchSpy.mockRestore();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("telegram location parsing", () => {
|
||||||
|
it("includes location text and ctx fields for pins", async () => {
|
||||||
|
const { createTelegramBot } = await import("./bot.js");
|
||||||
|
const replyModule = await import("../auto-reply/reply.js");
|
||||||
|
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||||
|
typeof vi.fn
|
||||||
|
>;
|
||||||
|
|
||||||
|
onSpy.mockReset();
|
||||||
|
replySpy.mockReset();
|
||||||
|
|
||||||
|
createTelegramBot({ token: "tok" });
|
||||||
|
const handler = onSpy.mock.calls[0]?.[1] as (
|
||||||
|
ctx: Record<string, unknown>,
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
await handler({
|
||||||
|
message: {
|
||||||
|
chat: { id: 42, type: "private" },
|
||||||
|
message_id: 5,
|
||||||
|
caption: "Meet here",
|
||||||
|
date: 1736380800,
|
||||||
|
location: {
|
||||||
|
latitude: 48.858844,
|
||||||
|
longitude: 2.294351,
|
||||||
|
horizontal_accuracy: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
me: { username: "clawdbot_bot" },
|
||||||
|
getFile: async () => ({ file_path: "unused" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = replySpy.mock.calls[0][0];
|
||||||
|
expect(payload.Body).toContain("Meet here");
|
||||||
|
expect(payload.Body).toContain("48.858844");
|
||||||
|
expect(payload.LocationLat).toBe(48.858844);
|
||||||
|
expect(payload.LocationLon).toBe(2.294351);
|
||||||
|
expect(payload.LocationSource).toBe("pin");
|
||||||
|
expect(payload.LocationIsLive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures venue fields for named places", async () => {
|
||||||
|
const { createTelegramBot } = await import("./bot.js");
|
||||||
|
const replyModule = await import("../auto-reply/reply.js");
|
||||||
|
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||||
|
typeof vi.fn
|
||||||
|
>;
|
||||||
|
|
||||||
|
onSpy.mockReset();
|
||||||
|
replySpy.mockReset();
|
||||||
|
|
||||||
|
createTelegramBot({ token: "tok" });
|
||||||
|
const handler = onSpy.mock.calls[0]?.[1] as (
|
||||||
|
ctx: Record<string, unknown>,
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
await handler({
|
||||||
|
message: {
|
||||||
|
chat: { id: 42, type: "private" },
|
||||||
|
message_id: 6,
|
||||||
|
date: 1736380800,
|
||||||
|
venue: {
|
||||||
|
title: "Eiffel Tower",
|
||||||
|
address: "Champ de Mars, Paris",
|
||||||
|
location: { latitude: 48.858844, longitude: 2.294351 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
me: { username: "clawdbot_bot" },
|
||||||
|
getFile: async () => ({ file_path: "unused" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = replySpy.mock.calls[0][0];
|
||||||
|
expect(payload.Body).toContain("Eiffel Tower");
|
||||||
|
expect(payload.LocationName).toBe("Eiffel Tower");
|
||||||
|
expect(payload.LocationAddress).toBe("Champ de Mars, Paris");
|
||||||
|
expect(payload.LocationSource).toBe("place");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ import { getChildLogger } from "../logging.js";
|
|||||||
import { mediaKindFromMime } from "../media/constants.js";
|
import { mediaKindFromMime } from "../media/constants.js";
|
||||||
import { detectMime, isGifMedia } from "../media/mime.js";
|
import { detectMime, isGifMedia } from "../media/mime.js";
|
||||||
import { saveMediaBuffer } from "../media/store.js";
|
import { saveMediaBuffer } from "../media/store.js";
|
||||||
|
import {
|
||||||
|
formatLocationText,
|
||||||
|
type NormalizedLocation,
|
||||||
|
toLocationContext,
|
||||||
|
} from "../providers/location.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { loadWebMedia } from "../web/media.js";
|
import { loadWebMedia } from "../web/media.js";
|
||||||
|
|
||||||
@@ -68,12 +73,6 @@ interface TelegramVenue {
|
|||||||
google_place_type?: string;
|
google_place_type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extended message type that may include location/venue */
|
|
||||||
type TelegramMessageWithLocation = TelegramMessage & {
|
|
||||||
location?: TelegramLocation;
|
|
||||||
venue?: TelegramVenue;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TelegramContext = {
|
type TelegramContext = {
|
||||||
message: TelegramMessage;
|
message: TelegramMessage;
|
||||||
me?: { username?: string };
|
me?: { username?: string };
|
||||||
@@ -252,15 +251,13 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
else if (msg.document) placeholder = "<media:document>";
|
else if (msg.document) placeholder = "<media:document>";
|
||||||
|
|
||||||
const replyTarget = describeReplyTarget(msg);
|
const replyTarget = describeReplyTarget(msg);
|
||||||
const locationText = formatLocationMessage(
|
const locationData = extractTelegramLocation(msg);
|
||||||
msg as TelegramMessageWithLocation,
|
const locationText = locationData
|
||||||
);
|
? formatLocationText(locationData)
|
||||||
const rawBody = (
|
: undefined;
|
||||||
msg.text ??
|
const rawText = (msg.text ?? msg.caption ?? "").trim();
|
||||||
msg.caption ??
|
let rawBody = [rawText, locationText].filter(Boolean).join("\n").trim();
|
||||||
locationText ??
|
if (!rawBody) rawBody = placeholder;
|
||||||
placeholder
|
|
||||||
).trim();
|
|
||||||
if (!rawBody && allMedia.length === 0) return;
|
if (!rawBody && allMedia.length === 0) return;
|
||||||
|
|
||||||
let bodyText = rawBody;
|
let bodyText = rawBody;
|
||||||
@@ -282,8 +279,6 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
body: `${bodyText}${replySuffix}`,
|
body: `${bodyText}${replySuffix}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const locationData = extractLocationData(msg);
|
|
||||||
|
|
||||||
const ctxPayload = {
|
const ctxPayload = {
|
||||||
Body: body,
|
Body: body,
|
||||||
From: isGroup ? `group:${chatId}` : `telegram:${chatId}`,
|
From: isGroup ? `group:${chatId}` : `telegram:${chatId}`,
|
||||||
@@ -309,11 +304,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
allMedia.length > 0
|
allMedia.length > 0
|
||||||
? (allMedia.map((m) => m.contentType).filter(Boolean) as string[])
|
? (allMedia.map((m) => m.contentType).filter(Boolean) as string[])
|
||||||
: undefined,
|
: undefined,
|
||||||
LocationLat: locationData?.latitude,
|
...(locationData ? toLocationContext(locationData) : undefined),
|
||||||
LocationLon: locationData?.longitude,
|
|
||||||
LocationAccuracy: locationData?.accuracy,
|
|
||||||
VenueName: locationData?.venueName,
|
|
||||||
VenueAddress: locationData?.venueAddress,
|
|
||||||
CommandAuthorized: commandAuthorized,
|
CommandAuthorized: commandAuthorized,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -739,10 +730,10 @@ function describeReplyTarget(msg: TelegramMessage) {
|
|||||||
else if (reply.video) body = "<media:video>";
|
else if (reply.video) body = "<media:video>";
|
||||||
else if (reply.audio || reply.voice) body = "<media:audio>";
|
else if (reply.audio || reply.voice) body = "<media:audio>";
|
||||||
else if (reply.document) body = "<media:document>";
|
else if (reply.document) body = "<media:document>";
|
||||||
else if ((reply as TelegramMessageWithLocation).location)
|
else {
|
||||||
body =
|
const locationData = extractTelegramLocation(reply);
|
||||||
formatLocationMessage(reply as TelegramMessageWithLocation) ??
|
if (locationData) body = formatLocationText(locationData);
|
||||||
"<location>";
|
}
|
||||||
}
|
}
|
||||||
if (!body) return null;
|
if (!body) return null;
|
||||||
const sender = buildSenderName(reply);
|
const sender = buildSenderName(reply);
|
||||||
@@ -754,60 +745,38 @@ function describeReplyTarget(msg: TelegramMessage) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function extractTelegramLocation(
|
||||||
* Extract structured location data from a message.
|
msg: TelegramMessage,
|
||||||
*/
|
): NormalizedLocation | null {
|
||||||
function extractLocationData(msg: TelegramMessage): {
|
const msgWithLocation = msg as {
|
||||||
latitude: number;
|
location?: TelegramLocation;
|
||||||
longitude: number;
|
venue?: TelegramVenue;
|
||||||
accuracy?: number;
|
};
|
||||||
venueName?: string;
|
|
||||||
venueAddress?: string;
|
|
||||||
} | null {
|
|
||||||
const msgWithLocation = msg as TelegramMessageWithLocation;
|
|
||||||
const { venue, location } = msgWithLocation;
|
const { venue, location } = msgWithLocation;
|
||||||
|
|
||||||
if (venue) {
|
if (venue) {
|
||||||
return {
|
return {
|
||||||
latitude: venue.location.latitude,
|
latitude: venue.location.latitude,
|
||||||
longitude: venue.location.longitude,
|
longitude: venue.location.longitude,
|
||||||
venueName: venue.title,
|
accuracy: venue.location.horizontal_accuracy,
|
||||||
venueAddress: venue.address,
|
name: venue.title,
|
||||||
|
address: venue.address,
|
||||||
|
source: "place",
|
||||||
|
isLive: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (location) {
|
if (location) {
|
||||||
|
const isLive =
|
||||||
|
typeof location.live_period === "number" && location.live_period > 0;
|
||||||
return {
|
return {
|
||||||
latitude: location.latitude,
|
latitude: location.latitude,
|
||||||
longitude: location.longitude,
|
longitude: location.longitude,
|
||||||
accuracy: location.horizontal_accuracy,
|
accuracy: location.horizontal_accuracy,
|
||||||
|
source: isLive ? "live" : "pin",
|
||||||
|
isLive,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Format location or venue message into text.
|
|
||||||
* Handles both raw location shares and venue shares (places with names).
|
|
||||||
*/
|
|
||||||
function formatLocationMessage(
|
|
||||||
msg: TelegramMessageWithLocation,
|
|
||||||
): string | null {
|
|
||||||
const { venue, location } = msg;
|
|
||||||
|
|
||||||
if (venue) {
|
|
||||||
const { latitude, longitude } = venue.location;
|
|
||||||
return `[Venue: ${venue.title} - ${venue.address} (${latitude.toFixed(6)}, ${longitude.toFixed(6)})]`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (location) {
|
|
||||||
const { latitude, longitude, horizontal_accuracy } = location;
|
|
||||||
const accuracy = horizontal_accuracy
|
|
||||||
? ` ±${Math.round(horizontal_accuracy)}m`
|
|
||||||
: "";
|
|
||||||
return `[Location: ${latitude.toFixed(6)}, ${longitude.toFixed(6)}${accuracy}]`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
|
|||||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
|
import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
|
||||||
import { createSubsystemLogger, getChildLogger } from "../logging.js";
|
import { createSubsystemLogger, getChildLogger } from "../logging.js";
|
||||||
|
import { toLocationContext } from "../providers/location.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
import { isSelfChatMode, jidToE164, normalizeE164 } from "../utils.js";
|
import { isSelfChatMode, jidToE164, normalizeE164 } from "../utils.js";
|
||||||
import { setActiveWebListener } from "./active-listener.js";
|
import { setActiveWebListener } from "./active-listener.js";
|
||||||
@@ -1216,6 +1217,7 @@ export async function monitorWebProvider(
|
|||||||
SenderName: msg.senderName,
|
SenderName: msg.senderName,
|
||||||
SenderE164: msg.senderE164,
|
SenderE164: msg.senderE164,
|
||||||
WasMentioned: msg.wasMentioned,
|
WasMentioned: msg.wasMentioned,
|
||||||
|
...(msg.location ? toLocationContext(msg.location) : {}),
|
||||||
Surface: "whatsapp",
|
Surface: "whatsapp",
|
||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { extractMediaPlaceholder, extractText } from "./inbound.js";
|
import {
|
||||||
|
extractLocationData,
|
||||||
|
extractMediaPlaceholder,
|
||||||
|
extractText,
|
||||||
|
} from "./inbound.js";
|
||||||
|
|
||||||
describe("web inbound helpers", () => {
|
describe("web inbound helpers", () => {
|
||||||
it("prefers the main conversation body", () => {
|
it("prefers the main conversation body", () => {
|
||||||
@@ -45,4 +49,46 @@ describe("web inbound helpers", () => {
|
|||||||
} as unknown as import("@whiskeysockets/baileys").proto.IMessage),
|
} as unknown as import("@whiskeysockets/baileys").proto.IMessage),
|
||||||
).toBe("<media:audio>");
|
).toBe("<media:audio>");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("extracts WhatsApp location messages", () => {
|
||||||
|
const location = extractLocationData({
|
||||||
|
locationMessage: {
|
||||||
|
degreesLatitude: 48.858844,
|
||||||
|
degreesLongitude: 2.294351,
|
||||||
|
name: "Eiffel Tower",
|
||||||
|
address: "Champ de Mars, Paris",
|
||||||
|
accuracyInMeters: 12,
|
||||||
|
comment: "Meet here",
|
||||||
|
},
|
||||||
|
} as unknown as import("@whiskeysockets/baileys").proto.IMessage);
|
||||||
|
expect(location).toEqual({
|
||||||
|
latitude: 48.858844,
|
||||||
|
longitude: 2.294351,
|
||||||
|
accuracy: 12,
|
||||||
|
name: "Eiffel Tower",
|
||||||
|
address: "Champ de Mars, Paris",
|
||||||
|
caption: "Meet here",
|
||||||
|
source: "place",
|
||||||
|
isLive: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts WhatsApp live location messages", () => {
|
||||||
|
const location = extractLocationData({
|
||||||
|
liveLocationMessage: {
|
||||||
|
degreesLatitude: 37.819929,
|
||||||
|
degreesLongitude: -122.478255,
|
||||||
|
accuracyInMeters: 20,
|
||||||
|
caption: "On the move",
|
||||||
|
},
|
||||||
|
} as unknown as import("@whiskeysockets/baileys").proto.IMessage);
|
||||||
|
expect(location).toEqual({
|
||||||
|
latitude: 37.819929,
|
||||||
|
longitude: -122.478255,
|
||||||
|
accuracy: 20,
|
||||||
|
caption: "On the move",
|
||||||
|
source: "live",
|
||||||
|
isLive: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ import { loadConfig } from "../config/config.js";
|
|||||||
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
||||||
import { createSubsystemLogger, getChildLogger } from "../logging.js";
|
import { createSubsystemLogger, getChildLogger } from "../logging.js";
|
||||||
import { saveMediaBuffer } from "../media/store.js";
|
import { saveMediaBuffer } from "../media/store.js";
|
||||||
|
import {
|
||||||
|
formatLocationText,
|
||||||
|
type NormalizedLocation,
|
||||||
|
} from "../providers/location.js";
|
||||||
import {
|
import {
|
||||||
isSelfChatMode,
|
isSelfChatMode,
|
||||||
jidToE164,
|
jidToE164,
|
||||||
@@ -56,6 +60,7 @@ export type WebInboundMessage = {
|
|||||||
mentionedJids?: string[];
|
mentionedJids?: string[];
|
||||||
selfJid?: string | null;
|
selfJid?: string | null;
|
||||||
selfE164?: string | null;
|
selfE164?: string | null;
|
||||||
|
location?: NormalizedLocation;
|
||||||
sendComposing: () => Promise<void>;
|
sendComposing: () => Promise<void>;
|
||||||
reply: (text: string) => Promise<void>;
|
reply: (text: string) => Promise<void>;
|
||||||
sendMedia: (payload: AnyMessageContent) => Promise<void>;
|
sendMedia: (payload: AnyMessageContent) => Promise<void>;
|
||||||
@@ -241,7 +246,12 @@ export async function monitorWebInbox(options: {
|
|||||||
// but we skip triggering the auto-reply logic to avoid spamming old context.
|
// but we skip triggering the auto-reply logic to avoid spamming old context.
|
||||||
if (upsert.type === "append") continue;
|
if (upsert.type === "append") continue;
|
||||||
|
|
||||||
|
const location = extractLocationData(msg.message ?? undefined);
|
||||||
|
const locationText = location ? formatLocationText(location) : undefined;
|
||||||
let body = extractText(msg.message ?? undefined);
|
let body = extractText(msg.message ?? undefined);
|
||||||
|
if (locationText) {
|
||||||
|
body = [body, locationText].filter(Boolean).join("\n").trim();
|
||||||
|
}
|
||||||
if (!body) {
|
if (!body) {
|
||||||
body = extractMediaPlaceholder(msg.message ?? undefined);
|
body = extractMediaPlaceholder(msg.message ?? undefined);
|
||||||
if (!body) continue;
|
if (!body) continue;
|
||||||
@@ -319,6 +329,7 @@ export async function monitorWebInbox(options: {
|
|||||||
mentionedJids: mentionedJids ?? undefined,
|
mentionedJids: mentionedJids ?? undefined,
|
||||||
selfJid,
|
selfJid,
|
||||||
selfE164,
|
selfE164,
|
||||||
|
location: location ?? undefined,
|
||||||
sendComposing,
|
sendComposing,
|
||||||
reply,
|
reply,
|
||||||
sendMedia,
|
sendMedia,
|
||||||
@@ -598,6 +609,62 @@ export function extractMediaPlaceholder(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extractLocationData(
|
||||||
|
rawMessage: proto.IMessage | undefined,
|
||||||
|
): NormalizedLocation | null {
|
||||||
|
const message = unwrapMessage(rawMessage);
|
||||||
|
if (!message) return null;
|
||||||
|
|
||||||
|
const live = message.liveLocationMessage ?? undefined;
|
||||||
|
if (live) {
|
||||||
|
const latitudeRaw = live.degreesLatitude;
|
||||||
|
const longitudeRaw = live.degreesLongitude;
|
||||||
|
if (latitudeRaw != null && longitudeRaw != null) {
|
||||||
|
const latitude = Number(latitudeRaw);
|
||||||
|
const longitude = Number(longitudeRaw);
|
||||||
|
if (Number.isFinite(latitude) && Number.isFinite(longitude)) {
|
||||||
|
return {
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
accuracy: live.accuracyInMeters ?? undefined,
|
||||||
|
caption: live.caption ?? undefined,
|
||||||
|
source: "live",
|
||||||
|
isLive: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = message.locationMessage ?? undefined;
|
||||||
|
if (location) {
|
||||||
|
const latitudeRaw = location.degreesLatitude;
|
||||||
|
const longitudeRaw = location.degreesLongitude;
|
||||||
|
if (latitudeRaw != null && longitudeRaw != null) {
|
||||||
|
const latitude = Number(latitudeRaw);
|
||||||
|
const longitude = Number(longitudeRaw);
|
||||||
|
if (Number.isFinite(latitude) && Number.isFinite(longitude)) {
|
||||||
|
const isLive = Boolean(location.isLive);
|
||||||
|
return {
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
accuracy: location.accuracyInMeters ?? undefined,
|
||||||
|
name: location.name ?? undefined,
|
||||||
|
address: location.address ?? undefined,
|
||||||
|
caption: location.comment ?? undefined,
|
||||||
|
source: isLive
|
||||||
|
? "live"
|
||||||
|
: location.name || location.address
|
||||||
|
? "place"
|
||||||
|
: "pin",
|
||||||
|
isLive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function describeReplyContext(rawMessage: proto.IMessage | undefined): {
|
function describeReplyContext(rawMessage: proto.IMessage | undefined): {
|
||||||
id?: string;
|
id?: string;
|
||||||
body: string;
|
body: string;
|
||||||
@@ -610,7 +677,11 @@ function describeReplyContext(rawMessage: proto.IMessage | undefined): {
|
|||||||
contextInfo?.quotedMessage as proto.IMessage | undefined,
|
contextInfo?.quotedMessage as proto.IMessage | undefined,
|
||||||
) as proto.IMessage | undefined;
|
) as proto.IMessage | undefined;
|
||||||
if (!quoted) return null;
|
if (!quoted) return null;
|
||||||
const body = extractText(quoted) ?? extractMediaPlaceholder(quoted);
|
const location = extractLocationData(quoted);
|
||||||
|
const locationText = location ? formatLocationText(location) : undefined;
|
||||||
|
const text = extractText(quoted);
|
||||||
|
let body = [text, locationText].filter(Boolean).join("\n").trim();
|
||||||
|
if (!body) body = extractMediaPlaceholder(quoted);
|
||||||
if (!body) {
|
if (!body) {
|
||||||
const quotedType = quoted ? getContentType(quoted) : undefined;
|
const quotedType = quoted ? getContentType(quoted) : undefined;
|
||||||
logVerbose(
|
logVerbose(
|
||||||
|
|||||||
Reference in New Issue
Block a user