refactor: unify inline directives and media fetch

This commit is contained in:
Peter Steinberger
2026-01-10 03:01:04 +01:00
parent 4075895c4c
commit f28a4a34ad
15 changed files with 345 additions and 178 deletions

View File

@@ -33,9 +33,8 @@ import {
import { stripHeartbeatToken } from "../heartbeat.js";
import type { OriginatingChannelType, TemplateContext } from "../templating.js";
import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import { parseAudioTag } from "./audio-tags.js";
import {
createAudioAsVoiceBuffer,
createBlockReplyPipeline,
@@ -48,6 +47,7 @@ import {
type QueueSettings,
scheduleFollowupDrain,
} from "./queue.js";
import { parseReplyDirectives } from "./reply-directives.js";
import {
applyReplyTagsToPayload,
applyReplyThreading,
@@ -550,28 +550,39 @@ export async function runReplyAgent(params: {
!payload.audioAsVoice
)
return;
const audioTagResult = parseAudioTag(taggedPayload.text);
const cleaned = audioTagResult.text || undefined;
const parsed = parseReplyDirectives(
taggedPayload.text ?? "",
{
currentMessageId: sessionCtx.MessageSid,
silentToken: SILENT_REPLY_TOKEN,
},
);
const cleaned = parsed.text || undefined;
const hasMedia =
Boolean(taggedPayload.mediaUrl) ||
(taggedPayload.mediaUrls?.length ?? 0) > 0;
// Skip empty payloads unless they have audioAsVoice flag (need to track it)
if (!cleaned && !hasMedia && !payload.audioAsVoice) return;
if (
isSilentReplyText(cleaned, SILENT_REPLY_TOKEN) &&
!hasMedia
!cleaned &&
!hasMedia &&
!payload.audioAsVoice &&
!parsed.audioAsVoice
)
return;
if (parsed.isSilent && !hasMedia) return;
const blockPayload: ReplyPayload = applyReplyToMode({
...taggedPayload,
text: cleaned,
audioAsVoice:
audioTagResult.audioAsVoice || payload.audioAsVoice,
audioAsVoice: parsed.audioAsVoice || payload.audioAsVoice,
replyToId: taggedPayload.replyToId ?? parsed.replyToId,
replyToTag: taggedPayload.replyToTag || parsed.replyToTag,
replyToCurrent:
taggedPayload.replyToCurrent || parsed.replyToCurrent,
});
void typingSignals
.signalTextDelta(taggedPayload.text)
.signalTextDelta(cleaned ?? taggedPayload.text)
.catch((err) => {
logVerbose(
`block reply typing signal failed: ${String(err)}`,
@@ -735,11 +746,21 @@ export async function runReplyAgent(params: {
currentMessageId: sessionCtx.MessageSid,
})
.map((payload) => {
const audioTagResult = parseAudioTag(payload.text);
const parsed = parseReplyDirectives(payload.text ?? "", {
currentMessageId: sessionCtx.MessageSid,
silentToken: SILENT_REPLY_TOKEN,
});
const mediaUrls = payload.mediaUrls ?? parsed.mediaUrls;
const mediaUrl = payload.mediaUrl ?? parsed.mediaUrl ?? mediaUrls?.[0];
return {
...payload,
text: audioTagResult.text ? audioTagResult.text : undefined,
audioAsVoice: audioTagResult.audioAsVoice,
text: parsed.text ? parsed.text : undefined,
mediaUrls,
mediaUrl,
replyToId: payload.replyToId ?? parsed.replyToId,
replyToTag: payload.replyToTag || parsed.replyToTag,
replyToCurrent: payload.replyToCurrent || parsed.replyToCurrent,
audioAsVoice: payload.audioAsVoice || parsed.audioAsVoice,
};
})
.filter(isRenderablePayload);
@@ -775,8 +796,7 @@ export async function runReplyAgent(params: {
const shouldSignalTyping = replyPayloads.some((payload) => {
const trimmed = payload.text?.trim();
if (trimmed && !isSilentReplyText(trimmed, SILENT_REPLY_TOKEN))
return true;
if (trimmed) return true;
if (payload.mediaUrl) return true;
if (payload.mediaUrls && payload.mediaUrls.length > 0) return true;
return false;

View File

@@ -0,0 +1,49 @@
import { splitMediaFromOutput } from "../../media/parse.js";
import { parseInlineDirectives } from "../../utils/directive-tags.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
export type ReplyDirectiveParseResult = {
text: string;
mediaUrls?: string[];
mediaUrl?: string;
replyToId?: string;
replyToCurrent: boolean;
replyToTag: boolean;
audioAsVoice?: boolean;
isSilent: boolean;
};
export function parseReplyDirectives(
raw: string,
options: { currentMessageId?: string; silentToken?: string } = {},
): ReplyDirectiveParseResult {
const split = splitMediaFromOutput(raw);
let text = split.text ?? "";
const replyParsed = parseInlineDirectives(text, {
currentMessageId: options.currentMessageId,
stripAudioTag: false,
stripReplyTags: true,
});
if (replyParsed.hasReplyTag) {
text = replyParsed.text;
}
const silentToken = options.silentToken ?? SILENT_REPLY_TOKEN;
const isSilent = isSilentReplyText(text, silentToken);
if (isSilent) {
text = "";
}
return {
text,
mediaUrls: split.mediaUrls,
mediaUrl: split.mediaUrl,
replyToId: replyParsed.replyToId,
replyToCurrent: replyParsed.replyToCurrent,
replyToTag: replyParsed.hasReplyTag,
audioAsVoice: split.audioAsVoice,
isSilent,
};
}

View File

@@ -10,8 +10,23 @@ export function applyReplyTagsToPayload(
payload: ReplyPayload,
currentMessageId?: string,
): ReplyPayload {
if (typeof payload.text !== "string") return payload;
const { cleaned, replyToId, hasTag } = extractReplyToTag(
if (typeof payload.text !== "string") {
if (!payload.replyToCurrent || payload.replyToId) return payload;
return {
...payload,
replyToId: currentMessageId?.trim() || undefined,
};
}
const shouldParseTags = payload.text.includes("[[");
if (!shouldParseTags) {
if (!payload.replyToCurrent || payload.replyToId) return payload;
return {
...payload,
replyToId: currentMessageId?.trim() || undefined,
replyToTag: payload.replyToTag ?? true,
};
}
const { cleaned, replyToId, replyToCurrent, hasTag } = extractReplyToTag(
payload.text,
currentMessageId,
);
@@ -20,6 +35,7 @@ export function applyReplyTagsToPayload(
text: cleaned ? cleaned : undefined,
replyToId: replyToId ?? payload.replyToId,
replyToTag: hasTag || payload.replyToTag,
replyToCurrent: replyToCurrent || payload.replyToCurrent,
};
}

View File

@@ -1,12 +1,4 @@
const REPLY_TAG_RE =
/\[\[\s*(?:reply_to_current|reply_to\s*:\s*([^\]\n]+))\s*\]\]/gi;
function normalizeReplyText(text: string) {
return text
.replace(/[ \t]+/g, " ")
.replace(/[ \t]*\n[ \t]*/g, "\n")
.trim();
}
import { parseInlineDirectives } from "../../utils/directive-tags.js";
export function extractReplyToTag(
text?: string,
@@ -14,31 +6,17 @@ export function extractReplyToTag(
): {
cleaned: string;
replyToId?: string;
replyToCurrent: boolean;
hasTag: boolean;
} {
if (!text) return { cleaned: "", hasTag: false };
let sawCurrent = false;
let lastExplicitId: string | undefined;
let hasTag = false;
const cleaned = normalizeReplyText(
text.replace(REPLY_TAG_RE, (_full, idRaw: string | undefined) => {
hasTag = true;
if (idRaw === undefined) {
sawCurrent = true;
return " ";
}
const id = idRaw.trim();
if (id) lastExplicitId = id;
return " ";
}),
);
const replyToId =
lastExplicitId ??
(sawCurrent ? currentMessageId?.trim() || undefined : undefined);
return { cleaned, replyToId, hasTag };
const result = parseInlineDirectives(text, {
currentMessageId,
stripAudioTag: false,
});
return {
cleaned: result.text,
replyToId: result.replyToId,
replyToCurrent: result.replyToCurrent,
hasTag: result.hasReplyTag,
};
}

View File

@@ -31,6 +31,8 @@ export type ReplyPayload = {
mediaUrls?: string[];
replyToId?: string;
replyToTag?: boolean;
/** True when [[reply_to_current]] was present but not yet mapped to a message id. */
replyToCurrent?: boolean;
/** Send audio as voice message (bubble) instead of audio file. Defaults to false. */
audioAsVoice?: boolean;
isError?: boolean;