import { CARD_BLOCK_INSTRUCTION, CARD_GUIDANCE, FORM_BLOCK_INSTRUCTION, FORM_GUIDANCE, PLACE_BLOCK_INSTRUCTION, RATING_REQUEST_GUIDANCE, RATING_REQUEST_BLOCK_INSTRUCTION, BUTTONS_GUIDANCE, BUTTONS_BLOCK_INSTRUCTION, PAYMENT_BLOCK_INSTRUCTION, PAYMENT_GUIDANCE, LINK_LINE_INSTRUCTION, SELECTION_GUIDANCE, SELECTION_BLOCK_INSTRUCTION, selectionReplyContext } from "@relaymessenger/sdk"; import type { Message, Relay, RelayWebhookEvent, } from "@relaymessenger/sdk"; import { buildChannelInboundEventContext, resolveChannelInboundRouteEnvelope, } from "openclaw/plugin-sdk/channel-inbound"; import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime"; import { bindIngressLifecycleToReplyOptions } from "openclaw/plugin-sdk/channel-outbound"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload"; import { buildRelayInboundFacts, renderRelayMessageParts } from "./inbound.js"; import type { RelayIngressLifecycle } from "./ingress.js"; import type { PluginRuntime } from "./runtime.js"; import { type RelayChatTurns, waitForIdleChat } from "./turns.js"; import type { RelayCoreConfig, RelayInboundFacts, ResolvedRelayAccount, } from "./types.js"; export type RelayTurnActivation = | { kind: "direct"; wasMentioned: false; implicitMentionKinds: []; } | { kind: "mention"; wasMentioned: true; implicitMentionKinds: []; } | { kind: "reply"; wasMentioned: false; implicitMentionKinds: ["reply_to_bot"]; }; type RelayReplyLookup = Pick; function isReplyToAgentMessage( message: Message, chatId: string, ): boolean { return message.chat_id === chatId && message.is_from_me === true; } /** * Relay's structured mention and reply facts are authoritative. In * particular, this does not infer an activation from visible `@handle` text. */ export async function resolveRelayTurnActivation(params: { facts: RelayInboundFacts; relay: RelayReplyLookup; /** The replied-to Message when the caller already read it. */ replyTarget?: Message; }): Promise { if (params.facts.chatType === "direct") { return { kind: "direct", wasMentioned: false, implicitMentionKinds: [], }; } const ownerHandle = params.facts.ownerHandle; if ( ownerHandle?.kind === "agent" && params.facts.mentionHandles.includes(ownerHandle.handle) ) { return { kind: "mention", wasMentioned: true, implicitMentionKinds: [], }; } if (!params.facts.replyToId) return null; const replyTarget = params.replyTarget ?? await params.relay.messages.retrieve(params.facts.replyToId); if (!isReplyToAgentMessage(replyTarget, params.facts.chatId)) return null; return { kind: "reply", wasMentioned: false, implicitMentionKinds: ["reply_to_bot"], }; } /** * The Message a swipe-reply answers, read once for the quote and for group * activation. A failed read is logged and the turn runs without the quote; * group activation then reads it itself and fails the delivery as before. */ async function readReplyTarget(params: { facts: RelayInboundFacts; relay: RelayReplyLookup; warn: ((message: string) => void) | undefined; }): Promise { if (!params.facts.replyToId) return undefined; try { return await params.relay.messages.retrieve(params.facts.replyToId); } catch (error) { params.warn?.( `relay: could not read the Message ${params.facts.replyToId} that ${params.facts.messageId} replies to: ${error instanceof Error ? error.message : String(error)}`, ); return undefined; } } /** * OpenClaw's own reply context, `supplemental.quote`, which it renders to the * model as "Reply target of current user message" (id, sender, body), as its * Telegram channel fills it from Telegram's `reply_to_message`. A reply names * one bubble: a multipart target is narrowed to the swiped part, the rule * Relay's iOS app uses to draw the quote (the SDK's `replyTargetParts`); a * tap or a selection answer names a part with no words, so it keeps the * whole Message. */ export function relayReplyQuote( facts: Pick, target: Message | undefined, ) { if (!target || target.chat_id !== facts.chatId) return undefined; const parts = target.parts ?? []; const swiped = parts.length > 1 && facts.replyToPartIndex !== undefined ? parts[facts.replyToPartIndex] : undefined; const body = renderRelayMessageParts( swiped && swiped.type !== "buttons" && swiped.type !== "selection" ? [swiped] : parts, ); const sender = target.from_handle?.display_name?.trim() || target.from_handle?.handle || target.from || undefined; return { id: target.id, ...(body ? { body } : {}), ...(sender ? { sender } : {}), senderAllowed: true, }; } /** * Whether the part a reply names may itself be replied to. A tap names the * agent's buttons part and a selection answer its selection part; no reply may * point at those (Relay v1 ReplyTo.part_index), so a person's tap keeps its * own Message as the reply target. */ function repliable(target: Message | undefined, partIndex: number | undefined): boolean { const part = target?.parts?.[partIndex ?? 0]; return part !== undefined && part.type !== "buttons" && part.type !== "selection"; } /** * The answer to another agent names the Message it answers: the model's own * reply target when it chose one, else the agent's Message. Where no reply may * point (a Message opening with buttons or a selection), OpenClaw's implicit * current-message reply is removed. */ export function agentReplyPayload( payload: ReplyPayload, facts: Pick, ): ReplyPayload { if (facts.agentReplyLink) { return { ...payload, replyToId: payload.replyToId ?? facts.agentReplyLink }; } if (payload.replyToId !== facts.messageId) return payload; const { replyToId: _unlinked, ...rest } = payload; return rest; } /** * How the agent's answer can carry Relay's parts, for every turn: the same * fenced blocks and link lines the SDK's `answerMessages` reads. A direct * chat also names the location tools, which Relay refuses in a group. */ export function relayAnswerGuidance(chatType: RelayInboundFacts["chatType"]): string { return [ BUTTONS_BLOCK_INSTRUCTION, LINK_LINE_INSTRUCTION, BUTTONS_GUIDANCE, SELECTION_BLOCK_INSTRUCTION, SELECTION_GUIDANCE, FORM_BLOCK_INSTRUCTION, FORM_GUIDANCE, CARD_BLOCK_INSTRUCTION, CARD_GUIDANCE, PLACE_BLOCK_INSTRUCTION, PAYMENT_BLOCK_INSTRUCTION, PAYMENT_GUIDANCE, RATING_REQUEST_BLOCK_INSTRUCTION, RATING_REQUEST_GUIDANCE, ...(chatType === "direct" ? ["relay_request_location asks the person to share their location; relay_read_location reads where everyone sharing is now."] : []), ].join(" "); } export async function dispatchRelayEvent(params: { event: RelayWebhookEvent; lifecycle: RelayIngressLifecycle; account: ResolvedRelayAccount; cfg: RelayCoreConfig; relay: Pick; runtime: PluginRuntime; turns: RelayChatTurns; warn?: (message: string) => void; }): Promise { const facts = buildRelayInboundFacts(params.event); if (!facts) { params.warn?.( `relay: durably accepted ${params.event.event_type} event ${params.event.event_id} without an agent turn`, ); return; } const repliedTo = await readReplyTarget({ facts, relay: params.relay, warn: params.warn, }); const activation = await resolveRelayTurnActivation({ facts, relay: params.relay, ...(repliedTo ? { replyTarget: repliedTo } : {}), }); if (!activation) { params.warn?.( `relay: durably accepted unmentioned group Message ${facts.messageId} without an agent turn`, ); return; } const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({ cfg: params.cfg as OpenClawConfig, channel: "relay", accountId: params.account.accountId, peer: { kind: facts.chatType, id: facts.chatId, }, }); const restricted = params.account.allowFrom.length > 0; const effectiveAllowFrom = restricted ? params.account.allowFrom : ["*"]; const access = await resolveStableChannelMessageIngress({ channelId: "relay", accountId: params.account.accountId, identity: { key: "contactId", kind: "stable-id", // OpenClaw expands each allowlist value into primary and alias entries. // A prefix alone reuses one opaque ID across those fields, allowing the // mutable handle entry to shadow the asserted Contact ID authentication. // Keep field-qualified IDs; do not change the allowlist or alias policy. resolveEntryId: ({ entryIndex, fieldKey }) => `relay-contact-${entryIndex + 1}:${fieldKey}`, aliases: [ { key: "handle", kind: "username", normalize: (value) => value.trim().replace(/^@/u, "").toLowerCase(), dangerous: true, }, ], }, subject: { stableId: facts.contactId, aliases: { handle: facts.handle }, }, conversation: { kind: facts.chatType, id: facts.chatId, title: facts.chatType === "direct" ? facts.displayName : facts.chatId, }, contextBinding: { agentId: route.agentId, sessionKey: route.sessionKey, messageId: facts.messageId, nativeChannelId: facts.chatId, inboundEventKind: "user_request", }, dmPolicy: restricted ? "allowlist" : "open", groupPolicy: restricted ? "allowlist" : "open", policy: { groupAllowFromFallbackToAllowFrom: true, ...(facts.chatType === "group" ? { activation: { requireMention: true, allowTextCommands: false, implicitMentions: { replyToBot: true, quotedBot: false, threadParticipation: false, }, allowedImplicitMentionKinds: ["reply_to_bot"], }, } : {}), }, ...(facts.chatType === "group" ? { mentionFacts: { canDetectMention: true, wasMentioned: activation.wasMentioned, hasAnyMention: facts.mentionHandles.length > 0, implicitMentionKinds: activation.implicitMentionKinds, }, } : {}), allowFrom: effectiveAllowFrom, groupAllowFrom: effectiveAllowFrom, }); if (access.ingress.admission !== "dispatch") { params.warn?.( `relay: Contact @${facts.handle} did not pass OpenClaw ingress (${access.ingress.decision}:${access.ingress.reasonCode})`, ); return; } // An agent's reply target is only its own Message (agentReplyLink). A // person's is the Message they replied to, OpenClaw's ReplyToId, as its // Telegram channel sets `reply.replyToId` to Telegram's reply_to_message: // the prompt names it beside the quote. A tap or a selection answer names a // part no reply may target, so it keeps the person's own Message. const quote = relayReplyQuote(facts, repliedTo); const replyTarget = facts.fromAgent ? facts.agentReplyLink : quote && repliable(repliedTo, facts.replyToPartIndex) ? quote.id : facts.replyAnchorId ?? facts.replyToId; const body = buildEnvelope({ channel: "Relay", from: `${facts.displayName} (@${facts.handle})`, ...(facts.timestamp ? { timestamp: facts.timestamp } : {}), body: facts.text, }); const ctxPayload = buildChannelInboundEventContext({ channel: "relay", accountId: route.accountId ?? params.account.accountId, messageId: facts.messageId, messageIdFull: facts.messageId, ...(facts.timestamp ? { timestamp: facts.timestamp } : {}), from: facts.chatId, sender: { id: facts.contactId, name: facts.displayName, username: facts.handle, }, conversation: { kind: facts.chatType, id: facts.chatId, label: facts.chatType === "group" ? facts.chatId : facts.displayName, nativeChannelId: facts.chatId, }, route: { agentId: route.agentId, accountId: route.accountId, routeSessionKey: route.sessionKey, dispatchSessionKey: route.sessionKey, ...(route.dmScope ? { dmScope: route.dmScope } : {}), }, reply: { to: facts.chatId, originatingTo: facts.chatId, ...(replyTarget ? { replyToId: replyTarget } : {}), }, message: { inboundEventKind: "user_request", body, bodyForAgent: [facts.text, selectionReplyContext(facts.selection, facts.richMessage), relayAnswerGuidance(facts.chatType), ].filter(Boolean).join("\n\n"), rawBody: facts.text, commandBody: facts.text, }, ...(quote ? { supplemental: { quote } } : {}), channelIngress: access, access: { commands: { authorized: access.senderAccess.allowed, }, mentions: { canDetectMention: facts.chatType === "group", wasMentioned: activation.wasMentioned, hasAnyMention: facts.mentionHandles.length > 0, explicitlyMentionedBot: activation.kind === "mention", implicitMentionKinds: activation.implicitMentionKinds, requireMention: facts.chatType === "group", effectiveWasMentioned: facts.chatType === "group", }, }, }); // Another agent's Message waits for the turn running in its Chat, so it // gets a turn and an answer of its own (turns.ts). if (facts.fromAgent) { await waitForIdleChat({ turns: params.turns, chatId: facts.chatId, lifecycle: params.lifecycle, }); } await Promise.allSettled([ params.relay.chats.markAsRead(facts.chatId), params.relay.chats.startTyping(facts.chatId), ]).then((results) => { for (const result of results) { if (result.status === "rejected") { params.warn?.(`relay: pre-dispatch Chat state failed: ${String(result.reason)}`); } } }); let deliveryError: unknown; try { await params.turns.track(facts.chatId, () => params.runtime.channel.inbound.dispatch({ cfg: params.cfg as OpenClawConfig, channel: "relay", accountId: params.account.accountId, route: { agentId: route.agentId, sessionKey: route.sessionKey, ...(route.dmScope ? { dmScope: route.dmScope } : {}), }, ctxPayload, delivery: { durable: { to: facts.chatId, replyToId: facts.fromAgent ? facts.agentReplyLink ?? null : null, requiredCapabilities: { reconcileUnknownSend: true }, }, // Every answer to another agent names its Message, whatever // `replyToMode` the operator chose; OpenClaw's own implicit // current-message reply is dropped where no reply may point. ...(facts.fromAgent ? { preparePayload: (payload: ReplyPayload) => agentReplyPayload(payload, facts), } : {}), deliver: async (_payload, info) => { if (info.kind === "final") { throw new Error( "relay: durable final Message delivery was unavailable", ); } return { visibleReplySent: false }; }, onError: (error) => { deliveryError ??= error; }, }, replyPipeline: {}, replyOptions: { ...bindIngressLifecycleToReplyOptions(params.lifecycle), disableBlockStreaming: true, }, record: { onRecordError: (error) => { throw error instanceof Error ? error : new Error(`relay: session record failed: ${String(error)}`); }, }, })); if (deliveryError) { throw deliveryError instanceof Error ? deliveryError : new Error(`relay: reply delivery failed: ${String(deliveryError)}`); } } finally { await params.relay.chats.stopTyping(facts.chatId).catch((error: unknown) => { params.warn?.(`relay: stop typing failed: ${String(error)}`); }); } }