import { locationContext, selectionReply, selectionReplyContext } from "@relaymessenger/sdk"; import type { MessagePartResponse, RelayWebhookEvent, } from "@relaymessenger/sdk"; import type { RelayInboundFacts, RelayMessageReceivedEvent, } from "./types.js"; function renderPart(part: MessagePartResponse): string | undefined { switch (part.type) { case "text": return part.value; case "link": return part.value; case "media": return `[Attachment: ${part.filename} (${part.mime_type})] ${part.url}`; case "system": return part.value; // The agent's own buttons part reads as nothing, as on the server; its // question is the text beside it. A tap arrives as ordinary text. case "buttons": case "selection": case "selection_response": return undefined; } } export function renderRelayMessageParts( parts: readonly MessagePartResponse[], ): string { // A pin and a location share have no words; their data follows the words. const words = parts .map(renderPart) .filter((value): value is string => Boolean(value?.trim())) .join("\n"); return [words, locationContext(parts)].filter(Boolean).join("\n\n"); } export function isRelayMessageReceivedEvent( event: RelayWebhookEvent, ): event is RelayMessageReceivedEvent { if (event.event_type !== "message.received") return false; const data = event.data as Partial; return ( typeof data.id === "string" && typeof data.chat?.id === "string" && data.direction === "inbound" && typeof data.sender_handle?.id === "string" && Array.isArray(data.parts) ); } /** * Map inbound Messages from either contract-defined Contact kind. Sender * authorization and direct/group activation remain the dispatcher's concern. */ export function buildRelayInboundFacts( event: RelayWebhookEvent, ): RelayInboundFacts | null { if (!isRelayMessageReceivedEvent(event)) return null; // A sender with no Handle is skipped; isRelayMessageReceivedEvent already // rejects it at runtime. if (!event.data.sender_handle) return null; if ( event.data.sender_handle.kind !== "user" && event.data.sender_handle.kind !== "agent" ) return null; const text = renderRelayMessageParts(event.data.parts); const message = { parts: event.data.parts, ...(event.data.reply_to ? { reply_to: event.data.reply_to } : {}) }; const richMessage = selectionReplyContext(undefined, message) ? message : undefined; if (!text.trim() && !richMessage) return null; const mentionHandles = event.data.parts.flatMap((part) => part.type === "text" && typeof part.mention === "string" && part.mention.length > 0 ? [part.mention] : [], ); const timestampValue = event.data.sent_at ?? event.created_at; const timestamp = Date.parse(timestampValue); const selection = selectionReply(event.data.parts, event.data.reply_to); const fromAgent = event.data.sender_handle.kind === "agent"; // Another agent's Message is named by the answer, as Relay's CLI bridges // do (packages/cli/src/bridge-turn.ts, PR 366), so an agent that sent // several knows which one is answered. A Message that opens with buttons or a // selection is not named: an agent may not reply to those parts, and a // reply names part 0. const opening = event.data.parts[0]?.type; const agentReplyLink = fromAgent && opening !== "buttons" && opening !== "selection" ? event.data.id : undefined; return { ...(selection ? { selection } : {}), ...(richMessage ? { richMessage } : {}), fromAgent, ...(agentReplyLink ? { agentReplyLink } : {}), eventId: event.event_id, messageId: event.data.id, chatId: event.data.chat.id, chatType: event.data.chat.is_group === true ? "group" : "direct", contactId: event.data.sender_handle.id, handle: event.data.sender_handle.handle, displayName: event.data.sender_handle.display_name?.trim() || event.data.sender_handle.handle, text, mentionHandles, ...(event.data.chat.owner_handle ? { ownerHandle: event.data.chat.owner_handle } : {}), ...(event.data.reply_to?.message_id ? { replyToId: event.data.reply_to.message_id, ...(event.data.reply_to.part_index === undefined ? {} : { replyToPartIndex: event.data.reply_to.part_index }), // The answer quotes the person's message, the one it answers (a bot's // reply_to in Telegram and Discord names the person's message). A // tap's reply_to names the agent's buttons part, which no reply may // target, so this is also what keeps a tap answerable. An agent's // Message is quoted only through agentReplyLink. ...(fromAgent ? {} : { replyAnchorId: event.data.id }), } : {}), ...(Number.isFinite(timestamp) ? { timestamp } : {}), }; }