refactor: unify inline directives and media fetch
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
|||||||
SettingsManager,
|
SettingsManager,
|
||||||
} from "@mariozechner/pi-coding-agent";
|
} from "@mariozechner/pi-coding-agent";
|
||||||
import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js";
|
import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js";
|
||||||
|
import { parseReplyDirectives } from "../auto-reply/reply/reply-directives.js";
|
||||||
import type {
|
import type {
|
||||||
ReasoningLevel,
|
ReasoningLevel,
|
||||||
ThinkLevel,
|
ThinkLevel,
|
||||||
@@ -28,7 +29,6 @@ import type { ClawdbotConfig } from "../config/config.js";
|
|||||||
import { resolveProviderCapabilities } from "../config/provider-capabilities.js";
|
import { resolveProviderCapabilities } from "../config/provider-capabilities.js";
|
||||||
import { getMachineDisplayName } from "../infra/machine-name.js";
|
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||||
import { createSubsystemLogger } from "../logging.js";
|
import { createSubsystemLogger } from "../logging.js";
|
||||||
import { splitMediaFromOutput } from "../media/parse.js";
|
|
||||||
import {
|
import {
|
||||||
type enqueueCommand,
|
type enqueueCommand,
|
||||||
enqueueCommandInLane,
|
enqueueCommandInLane,
|
||||||
@@ -1626,6 +1626,9 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
media?: string[];
|
media?: string[];
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
audioAsVoice?: boolean;
|
audioAsVoice?: boolean;
|
||||||
|
replyToId?: string;
|
||||||
|
replyToTag?: boolean;
|
||||||
|
replyToCurrent?: boolean;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
const errorText = lastAssistant
|
const errorText = lastAssistant
|
||||||
@@ -1646,12 +1649,18 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
text: cleanedText,
|
text: cleanedText,
|
||||||
mediaUrls,
|
mediaUrls,
|
||||||
audioAsVoice,
|
audioAsVoice,
|
||||||
} = splitMediaFromOutput(agg);
|
replyToId,
|
||||||
|
replyToTag,
|
||||||
|
replyToCurrent,
|
||||||
|
} = parseReplyDirectives(agg);
|
||||||
if (cleanedText)
|
if (cleanedText)
|
||||||
replyItems.push({
|
replyItems.push({
|
||||||
text: cleanedText,
|
text: cleanedText,
|
||||||
media: mediaUrls,
|
media: mediaUrls,
|
||||||
audioAsVoice,
|
audioAsVoice,
|
||||||
|
replyToId,
|
||||||
|
replyToTag,
|
||||||
|
replyToCurrent,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1675,7 +1684,10 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
text: cleanedText,
|
text: cleanedText,
|
||||||
mediaUrls,
|
mediaUrls,
|
||||||
audioAsVoice,
|
audioAsVoice,
|
||||||
} = splitMediaFromOutput(text);
|
replyToId,
|
||||||
|
replyToTag,
|
||||||
|
replyToCurrent,
|
||||||
|
} = parseReplyDirectives(text);
|
||||||
if (
|
if (
|
||||||
!cleanedText &&
|
!cleanedText &&
|
||||||
(!mediaUrls || mediaUrls.length === 0) &&
|
(!mediaUrls || mediaUrls.length === 0) &&
|
||||||
@@ -1686,6 +1698,9 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
text: cleanedText,
|
text: cleanedText,
|
||||||
media: mediaUrls,
|
media: mediaUrls,
|
||||||
audioAsVoice,
|
audioAsVoice,
|
||||||
|
replyToId,
|
||||||
|
replyToTag,
|
||||||
|
replyToCurrent,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1699,6 +1714,9 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
mediaUrls: item.media?.length ? item.media : undefined,
|
mediaUrls: item.media?.length ? item.media : undefined,
|
||||||
mediaUrl: item.media?.[0],
|
mediaUrl: item.media?.[0],
|
||||||
isError: item.isError,
|
isError: item.isError,
|
||||||
|
replyToId: item.replyToId,
|
||||||
|
replyToTag: item.replyToTag,
|
||||||
|
replyToCurrent: item.replyToCurrent,
|
||||||
// Apply audioAsVoice to media payloads if tag was found anywhere in response
|
// Apply audioAsVoice to media payloads if tag was found anywhere in response
|
||||||
audioAsVoice:
|
audioAsVoice:
|
||||||
item.audioAsVoice || (hasAudioAsVoiceTag && item.media?.length),
|
item.audioAsVoice || (hasAudioAsVoiceTag && item.media?.length),
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import path from "node:path";
|
|||||||
import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
|
import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||||
import type { AgentSession } from "@mariozechner/pi-coding-agent";
|
import type { AgentSession } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { parseReplyDirectives } from "../auto-reply/reply/reply-directives.js";
|
||||||
import type { ReasoningLevel } from "../auto-reply/thinking.js";
|
import type { ReasoningLevel } from "../auto-reply/thinking.js";
|
||||||
import { formatToolAggregate } from "../auto-reply/tool-meta.js";
|
import { formatToolAggregate } from "../auto-reply/tool-meta.js";
|
||||||
import { resolveStateDir } from "../config/paths.js";
|
import { resolveStateDir } from "../config/paths.js";
|
||||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||||
import { createSubsystemLogger } from "../logging.js";
|
import { createSubsystemLogger } from "../logging.js";
|
||||||
import { splitMediaFromOutput } from "../media/parse.js";
|
|
||||||
import { truncateUtf16Safe } from "../utils.js";
|
import { truncateUtf16Safe } from "../utils.js";
|
||||||
import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js";
|
import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js";
|
||||||
import { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js";
|
import { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js";
|
||||||
@@ -383,7 +383,7 @@ export function subscribeEmbeddedPiSession(params: {
|
|||||||
const emitToolSummary = (toolName?: string, meta?: string) => {
|
const emitToolSummary = (toolName?: string, meta?: string) => {
|
||||||
if (!params.onToolResult) return;
|
if (!params.onToolResult) return;
|
||||||
const agg = formatToolAggregate(toolName, meta ? [meta] : undefined);
|
const agg = formatToolAggregate(toolName, meta ? [meta] : undefined);
|
||||||
const { text: cleanedText, mediaUrls } = splitMediaFromOutput(agg);
|
const { text: cleanedText, mediaUrls } = parseReplyDirectives(agg);
|
||||||
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) return;
|
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) return;
|
||||||
try {
|
try {
|
||||||
void params.onToolResult({
|
void params.onToolResult({
|
||||||
@@ -437,7 +437,7 @@ export function subscribeEmbeddedPiSession(params: {
|
|||||||
lastBlockReplyText = chunk;
|
lastBlockReplyText = chunk;
|
||||||
assistantTexts.push(chunk);
|
assistantTexts.push(chunk);
|
||||||
if (!params.onBlockReply) return;
|
if (!params.onBlockReply) return;
|
||||||
const splitResult = splitMediaFromOutput(chunk);
|
const splitResult = parseReplyDirectives(chunk);
|
||||||
const { text: cleanedText, mediaUrls, audioAsVoice } = splitResult;
|
const { text: cleanedText, mediaUrls, audioAsVoice } = splitResult;
|
||||||
// Skip empty payloads, but always emit if audioAsVoice is set (to propagate the flag)
|
// Skip empty payloads, but always emit if audioAsVoice is set (to propagate the flag)
|
||||||
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0) && !audioAsVoice)
|
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0) && !audioAsVoice)
|
||||||
@@ -739,7 +739,7 @@ export function subscribeEmbeddedPiSession(params: {
|
|||||||
if (next && next !== lastStreamedAssistant) {
|
if (next && next !== lastStreamedAssistant) {
|
||||||
lastStreamedAssistant = next;
|
lastStreamedAssistant = next;
|
||||||
const { text: cleanedText, mediaUrls } =
|
const { text: cleanedText, mediaUrls } =
|
||||||
splitMediaFromOutput(next);
|
parseReplyDirectives(next);
|
||||||
emitAgentEvent({
|
emitAgentEvent({
|
||||||
runId: params.runId,
|
runId: params.runId,
|
||||||
stream: "assistant",
|
stream: "assistant",
|
||||||
@@ -868,7 +868,7 @@ export function subscribeEmbeddedPiSession(params: {
|
|||||||
text: cleanedText,
|
text: cleanedText,
|
||||||
mediaUrls,
|
mediaUrls,
|
||||||
audioAsVoice,
|
audioAsVoice,
|
||||||
} = splitMediaFromOutput(text);
|
} = parseReplyDirectives(text);
|
||||||
// Emit if there's content OR audioAsVoice flag (to propagate the flag)
|
// Emit if there's content OR audioAsVoice flag (to propagate the flag)
|
||||||
if (
|
if (
|
||||||
cleanedText ||
|
cleanedText ||
|
||||||
|
|||||||
@@ -33,9 +33,8 @@ import {
|
|||||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
import { stripHeartbeatToken } from "../heartbeat.js";
|
||||||
import type { OriginatingChannelType, TemplateContext } from "../templating.js";
|
import type { OriginatingChannelType, TemplateContext } from "../templating.js";
|
||||||
import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.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 type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||||
import { parseAudioTag } from "./audio-tags.js";
|
|
||||||
import {
|
import {
|
||||||
createAudioAsVoiceBuffer,
|
createAudioAsVoiceBuffer,
|
||||||
createBlockReplyPipeline,
|
createBlockReplyPipeline,
|
||||||
@@ -48,6 +47,7 @@ import {
|
|||||||
type QueueSettings,
|
type QueueSettings,
|
||||||
scheduleFollowupDrain,
|
scheduleFollowupDrain,
|
||||||
} from "./queue.js";
|
} from "./queue.js";
|
||||||
|
import { parseReplyDirectives } from "./reply-directives.js";
|
||||||
import {
|
import {
|
||||||
applyReplyTagsToPayload,
|
applyReplyTagsToPayload,
|
||||||
applyReplyThreading,
|
applyReplyThreading,
|
||||||
@@ -550,28 +550,39 @@ export async function runReplyAgent(params: {
|
|||||||
!payload.audioAsVoice
|
!payload.audioAsVoice
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
const audioTagResult = parseAudioTag(taggedPayload.text);
|
const parsed = parseReplyDirectives(
|
||||||
const cleaned = audioTagResult.text || undefined;
|
taggedPayload.text ?? "",
|
||||||
|
{
|
||||||
|
currentMessageId: sessionCtx.MessageSid,
|
||||||
|
silentToken: SILENT_REPLY_TOKEN,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const cleaned = parsed.text || undefined;
|
||||||
const hasMedia =
|
const hasMedia =
|
||||||
Boolean(taggedPayload.mediaUrl) ||
|
Boolean(taggedPayload.mediaUrl) ||
|
||||||
(taggedPayload.mediaUrls?.length ?? 0) > 0;
|
(taggedPayload.mediaUrls?.length ?? 0) > 0;
|
||||||
// Skip empty payloads unless they have audioAsVoice flag (need to track it)
|
// Skip empty payloads unless they have audioAsVoice flag (need to track it)
|
||||||
if (!cleaned && !hasMedia && !payload.audioAsVoice) return;
|
|
||||||
if (
|
if (
|
||||||
isSilentReplyText(cleaned, SILENT_REPLY_TOKEN) &&
|
!cleaned &&
|
||||||
!hasMedia
|
!hasMedia &&
|
||||||
|
!payload.audioAsVoice &&
|
||||||
|
!parsed.audioAsVoice
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
if (parsed.isSilent && !hasMedia) return;
|
||||||
|
|
||||||
const blockPayload: ReplyPayload = applyReplyToMode({
|
const blockPayload: ReplyPayload = applyReplyToMode({
|
||||||
...taggedPayload,
|
...taggedPayload,
|
||||||
text: cleaned,
|
text: cleaned,
|
||||||
audioAsVoice:
|
audioAsVoice: parsed.audioAsVoice || payload.audioAsVoice,
|
||||||
audioTagResult.audioAsVoice || payload.audioAsVoice,
|
replyToId: taggedPayload.replyToId ?? parsed.replyToId,
|
||||||
|
replyToTag: taggedPayload.replyToTag || parsed.replyToTag,
|
||||||
|
replyToCurrent:
|
||||||
|
taggedPayload.replyToCurrent || parsed.replyToCurrent,
|
||||||
});
|
});
|
||||||
|
|
||||||
void typingSignals
|
void typingSignals
|
||||||
.signalTextDelta(taggedPayload.text)
|
.signalTextDelta(cleaned ?? taggedPayload.text)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`block reply typing signal failed: ${String(err)}`,
|
`block reply typing signal failed: ${String(err)}`,
|
||||||
@@ -735,11 +746,21 @@ export async function runReplyAgent(params: {
|
|||||||
currentMessageId: sessionCtx.MessageSid,
|
currentMessageId: sessionCtx.MessageSid,
|
||||||
})
|
})
|
||||||
.map((payload) => {
|
.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 {
|
return {
|
||||||
...payload,
|
...payload,
|
||||||
text: audioTagResult.text ? audioTagResult.text : undefined,
|
text: parsed.text ? parsed.text : undefined,
|
||||||
audioAsVoice: audioTagResult.audioAsVoice,
|
mediaUrls,
|
||||||
|
mediaUrl,
|
||||||
|
replyToId: payload.replyToId ?? parsed.replyToId,
|
||||||
|
replyToTag: payload.replyToTag || parsed.replyToTag,
|
||||||
|
replyToCurrent: payload.replyToCurrent || parsed.replyToCurrent,
|
||||||
|
audioAsVoice: payload.audioAsVoice || parsed.audioAsVoice,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(isRenderablePayload);
|
.filter(isRenderablePayload);
|
||||||
@@ -775,8 +796,7 @@ export async function runReplyAgent(params: {
|
|||||||
|
|
||||||
const shouldSignalTyping = replyPayloads.some((payload) => {
|
const shouldSignalTyping = replyPayloads.some((payload) => {
|
||||||
const trimmed = payload.text?.trim();
|
const trimmed = payload.text?.trim();
|
||||||
if (trimmed && !isSilentReplyText(trimmed, SILENT_REPLY_TOKEN))
|
if (trimmed) return true;
|
||||||
return true;
|
|
||||||
if (payload.mediaUrl) return true;
|
if (payload.mediaUrl) return true;
|
||||||
if (payload.mediaUrls && payload.mediaUrls.length > 0) return true;
|
if (payload.mediaUrls && payload.mediaUrls.length > 0) return true;
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
49
src/auto-reply/reply/reply-directives.ts
Normal file
49
src/auto-reply/reply/reply-directives.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,8 +10,23 @@ export function applyReplyTagsToPayload(
|
|||||||
payload: ReplyPayload,
|
payload: ReplyPayload,
|
||||||
currentMessageId?: string,
|
currentMessageId?: string,
|
||||||
): ReplyPayload {
|
): ReplyPayload {
|
||||||
if (typeof payload.text !== "string") return payload;
|
if (typeof payload.text !== "string") {
|
||||||
const { cleaned, replyToId, hasTag } = extractReplyToTag(
|
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,
|
payload.text,
|
||||||
currentMessageId,
|
currentMessageId,
|
||||||
);
|
);
|
||||||
@@ -20,6 +35,7 @@ export function applyReplyTagsToPayload(
|
|||||||
text: cleaned ? cleaned : undefined,
|
text: cleaned ? cleaned : undefined,
|
||||||
replyToId: replyToId ?? payload.replyToId,
|
replyToId: replyToId ?? payload.replyToId,
|
||||||
replyToTag: hasTag || payload.replyToTag,
|
replyToTag: hasTag || payload.replyToTag,
|
||||||
|
replyToCurrent: replyToCurrent || payload.replyToCurrent,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
const REPLY_TAG_RE =
|
import { parseInlineDirectives } from "../../utils/directive-tags.js";
|
||||||
/\[\[\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();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractReplyToTag(
|
export function extractReplyToTag(
|
||||||
text?: string,
|
text?: string,
|
||||||
@@ -14,31 +6,17 @@ export function extractReplyToTag(
|
|||||||
): {
|
): {
|
||||||
cleaned: string;
|
cleaned: string;
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
|
replyToCurrent: boolean;
|
||||||
hasTag: boolean;
|
hasTag: boolean;
|
||||||
} {
|
} {
|
||||||
if (!text) return { cleaned: "", hasTag: false };
|
const result = parseInlineDirectives(text, {
|
||||||
|
currentMessageId,
|
||||||
let sawCurrent = false;
|
stripAudioTag: false,
|
||||||
let lastExplicitId: string | undefined;
|
});
|
||||||
let hasTag = false;
|
return {
|
||||||
|
cleaned: result.text,
|
||||||
const cleaned = normalizeReplyText(
|
replyToId: result.replyToId,
|
||||||
text.replace(REPLY_TAG_RE, (_full, idRaw: string | undefined) => {
|
replyToCurrent: result.replyToCurrent,
|
||||||
hasTag = true;
|
hasTag: result.hasReplyTag,
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export type ReplyPayload = {
|
|||||||
mediaUrls?: string[];
|
mediaUrls?: string[];
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
replyToTag?: boolean;
|
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. */
|
/** Send audio as voice message (bubble) instead of audio file. Defaults to false. */
|
||||||
audioAsVoice?: boolean;
|
audioAsVoice?: boolean;
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ import { formatDurationSeconds } from "../infra/format-duration.js";
|
|||||||
import { recordProviderActivity } from "../infra/provider-activity.js";
|
import { recordProviderActivity } from "../infra/provider-activity.js";
|
||||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
import { getChildLogger } from "../logging.js";
|
import { getChildLogger } from "../logging.js";
|
||||||
import { detectMime } from "../media/mime.js";
|
import { fetchRemoteMedia } from "../media/fetch.js";
|
||||||
import { saveMediaBuffer } from "../media/store.js";
|
import { saveMediaBuffer } from "../media/store.js";
|
||||||
import { buildPairingReply } from "../pairing/pairing-messages.js";
|
import { buildPairingReply } from "../pairing/pairing-messages.js";
|
||||||
import {
|
import {
|
||||||
@@ -1879,19 +1879,16 @@ async function resolveMediaList(
|
|||||||
const out: DiscordMediaInfo[] = [];
|
const out: DiscordMediaInfo[] = [];
|
||||||
for (const attachment of attachments) {
|
for (const attachment of attachments) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(attachment.url);
|
const fetched = await fetchRemoteMedia({
|
||||||
if (!res.ok) {
|
url: attachment.url,
|
||||||
throw new Error(
|
filePathHint: attachment.filename ?? attachment.url,
|
||||||
`Failed to download discord attachment: HTTP ${res.status}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const buffer = Buffer.from(await res.arrayBuffer());
|
|
||||||
const mime = await detectMime({
|
|
||||||
buffer,
|
|
||||||
headerMime: attachment.content_type ?? res.headers.get("content-type"),
|
|
||||||
filePath: attachment.filename ?? attachment.url,
|
|
||||||
});
|
});
|
||||||
const saved = await saveMediaBuffer(buffer, mime, "inbound", maxBytes);
|
const saved = await saveMediaBuffer(
|
||||||
|
fetched.buffer,
|
||||||
|
fetched.contentType ?? attachment.content_type,
|
||||||
|
"inbound",
|
||||||
|
maxBytes,
|
||||||
|
);
|
||||||
out.push({
|
out.push({
|
||||||
path: saved.path,
|
path: saved.path,
|
||||||
contentType: saved.contentType,
|
contentType: saved.contentType,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { parseInlineDirectives } from "../utils/directive-tags.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract audio mode tag from text.
|
* Extract audio mode tag from text.
|
||||||
* Supports [[audio_as_voice]] to send audio as voice bubble instead of file.
|
* Supports [[audio_as_voice]] to send audio as voice bubble instead of file.
|
||||||
@@ -8,24 +10,10 @@ export function parseAudioTag(text?: string): {
|
|||||||
audioAsVoice: boolean;
|
audioAsVoice: boolean;
|
||||||
hadTag: boolean;
|
hadTag: boolean;
|
||||||
} {
|
} {
|
||||||
if (!text) return { text: "", audioAsVoice: false, hadTag: false };
|
const result = parseInlineDirectives(text, { stripReplyTags: false });
|
||||||
let cleaned = text;
|
return {
|
||||||
let audioAsVoice = false; // default: audio file (backward compatible)
|
text: result.text,
|
||||||
let hadTag = false;
|
audioAsVoice: result.audioAsVoice,
|
||||||
|
hadTag: result.hasAudioTag,
|
||||||
// [[audio_as_voice]] -> send as voice bubble (opt-in)
|
};
|
||||||
const voiceMatch = cleaned.match(/\[\[audio_as_voice\]\]/i);
|
|
||||||
if (voiceMatch) {
|
|
||||||
cleaned = cleaned.replace(/\[\[audio_as_voice\]\]/gi, " ");
|
|
||||||
audioAsVoice = true;
|
|
||||||
hadTag = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up whitespace
|
|
||||||
cleaned = cleaned
|
|
||||||
.replace(/[ \t]+/g, " ")
|
|
||||||
.replace(/[ \t]*\n[ \t]*/g, "\n")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
return { text: cleaned, audioAsVoice, hadTag };
|
|
||||||
}
|
}
|
||||||
|
|||||||
18
src/media/audio.ts
Normal file
18
src/media/audio.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { getFileExtension } from "./mime.js";
|
||||||
|
|
||||||
|
const VOICE_AUDIO_EXTENSIONS = new Set([".oga", ".ogg", ".opus"]);
|
||||||
|
|
||||||
|
export function isVoiceCompatibleAudio(opts: {
|
||||||
|
contentType?: string | null;
|
||||||
|
fileName?: string | null;
|
||||||
|
}): boolean {
|
||||||
|
const mime = opts.contentType?.toLowerCase();
|
||||||
|
if (mime && (mime.includes("ogg") || mime.includes("opus"))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const fileName = opts.fileName?.trim();
|
||||||
|
if (!fileName) return false;
|
||||||
|
const ext = getFileExtension(fileName);
|
||||||
|
if (!ext) return false;
|
||||||
|
return VOICE_AUDIO_EXTENSIONS.has(ext);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { fetchRemoteMedia } from "../media/fetch.js";
|
||||||
import { detectMime } from "../media/mime.js";
|
import { detectMime } from "../media/mime.js";
|
||||||
import { saveMediaBuffer } from "../media/store.js";
|
import { saveMediaBuffer } from "../media/store.js";
|
||||||
|
|
||||||
@@ -740,23 +741,28 @@ export async function downloadMSTeamsImageAttachments(params: {
|
|||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (!isUrlAllowed(candidate.url, allowHosts)) continue;
|
if (!isUrlAllowed(candidate.url, allowHosts)) continue;
|
||||||
try {
|
try {
|
||||||
const res = await fetchWithAuthFallback({
|
const fetchImpl: typeof fetch = (input) => {
|
||||||
|
const url =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.toString()
|
||||||
|
: input.url;
|
||||||
|
return fetchWithAuthFallback({
|
||||||
|
url,
|
||||||
|
tokenProvider: params.tokenProvider,
|
||||||
|
fetchFn: params.fetchFn,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const fetched = await fetchRemoteMedia({
|
||||||
url: candidate.url,
|
url: candidate.url,
|
||||||
tokenProvider: params.tokenProvider,
|
fetchImpl,
|
||||||
fetchFn: params.fetchFn,
|
filePathHint: candidate.fileHint,
|
||||||
});
|
|
||||||
if (!res.ok) continue;
|
|
||||||
const buffer = Buffer.from(await res.arrayBuffer());
|
|
||||||
if (buffer.byteLength > params.maxBytes) continue;
|
|
||||||
const mime = await detectMime({
|
|
||||||
buffer,
|
|
||||||
headerMime:
|
|
||||||
candidate.contentTypeHint ?? res.headers.get("content-type"),
|
|
||||||
filePath: candidate.fileHint ?? candidate.url,
|
|
||||||
});
|
});
|
||||||
|
if (fetched.buffer.byteLength > params.maxBytes) continue;
|
||||||
const saved = await saveMediaBuffer(
|
const saved = await saveMediaBuffer(
|
||||||
buffer,
|
fetched.buffer,
|
||||||
mime,
|
fetched.contentType ?? candidate.contentTypeHint,
|
||||||
"inbound",
|
"inbound",
|
||||||
params.maxBytes,
|
params.maxBytes,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import {
|
|||||||
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
||||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
import { getChildLogger } from "../logging.js";
|
import { getChildLogger } from "../logging.js";
|
||||||
import { detectMime } from "../media/mime.js";
|
import { fetchRemoteMedia } from "../media/fetch.js";
|
||||||
import { saveMediaBuffer } from "../media/store.js";
|
import { saveMediaBuffer } from "../media/store.js";
|
||||||
import { buildPairingReply } from "../pairing/pairing-messages.js";
|
import { buildPairingReply } from "../pairing/pairing-messages.js";
|
||||||
import {
|
import {
|
||||||
@@ -355,27 +355,28 @@ async function resolveSlackMedia(params: {
|
|||||||
const url = file.url_private_download ?? file.url_private;
|
const url = file.url_private_download ?? file.url_private;
|
||||||
if (!url) continue;
|
if (!url) continue;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
const fetchImpl: typeof fetch = (input, init) => {
|
||||||
headers: { Authorization: `Bearer ${params.token}` },
|
const headers = new Headers(init?.headers);
|
||||||
});
|
headers.set("Authorization", `Bearer ${params.token}`);
|
||||||
if (!res.ok) continue;
|
return fetch(input, { ...init, headers });
|
||||||
const buffer = Buffer.from(await res.arrayBuffer());
|
};
|
||||||
if (buffer.byteLength > params.maxBytes) continue;
|
const fetched = await fetchRemoteMedia({
|
||||||
const contentType = await detectMime({
|
url,
|
||||||
buffer,
|
fetchImpl,
|
||||||
headerMime: res.headers.get("content-type"),
|
filePathHint: file.name,
|
||||||
filePath: file.name,
|
|
||||||
});
|
});
|
||||||
|
if (fetched.buffer.byteLength > params.maxBytes) continue;
|
||||||
const saved = await saveMediaBuffer(
|
const saved = await saveMediaBuffer(
|
||||||
buffer,
|
fetched.buffer,
|
||||||
contentType ?? file.mimetype,
|
fetched.contentType ?? file.mimetype,
|
||||||
"inbound",
|
"inbound",
|
||||||
params.maxBytes,
|
params.maxBytes,
|
||||||
);
|
);
|
||||||
|
const label = fetched.fileName ?? file.name;
|
||||||
return {
|
return {
|
||||||
path: saved.path,
|
path: saved.path,
|
||||||
contentType: saved.contentType,
|
contentType: saved.contentType,
|
||||||
placeholder: file.name ? `[Slack file: ${file.name}]` : "[Slack file]",
|
placeholder: label ? `[Slack file: ${label}]` : "[Slack file]",
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore download failures and fall through to the next file.
|
// Ignore download failures and fall through to the next file.
|
||||||
|
|||||||
@@ -1,18 +1,10 @@
|
|||||||
import { getFileExtension } from "../media/mime.js";
|
import { isVoiceCompatibleAudio } from "../media/audio.js";
|
||||||
|
|
||||||
export function isTelegramVoiceCompatible(opts: {
|
export function isTelegramVoiceCompatible(opts: {
|
||||||
contentType?: string | null;
|
contentType?: string | null;
|
||||||
fileName?: string | null;
|
fileName?: string | null;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const mime = opts.contentType?.toLowerCase();
|
return isVoiceCompatibleAudio(opts);
|
||||||
if (mime && (mime.includes("ogg") || mime.includes("opus"))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const fileName = opts.fileName?.trim();
|
|
||||||
if (!fileName) return false;
|
|
||||||
const ext = getFileExtension(fileName);
|
|
||||||
if (!ext) return false;
|
|
||||||
return ext === ".ogg" || ext === ".opus" || ext === ".oga";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveTelegramVoiceDecision(opts: {
|
export function resolveTelegramVoiceDecision(opts: {
|
||||||
|
|||||||
87
src/utils/directive-tags.ts
Normal file
87
src/utils/directive-tags.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
export type InlineDirectiveParseResult = {
|
||||||
|
text: string;
|
||||||
|
audioAsVoice: boolean;
|
||||||
|
replyToId?: string;
|
||||||
|
replyToCurrent: boolean;
|
||||||
|
hasAudioTag: boolean;
|
||||||
|
hasReplyTag: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InlineDirectiveParseOptions = {
|
||||||
|
currentMessageId?: string;
|
||||||
|
stripAudioTag?: boolean;
|
||||||
|
stripReplyTags?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AUDIO_TAG_RE = /\[\[\s*audio_as_voice\s*\]\]/gi;
|
||||||
|
const REPLY_TAG_RE =
|
||||||
|
/\[\[\s*(?:reply_to_current|reply_to\s*:\s*([^\]\n]+))\s*\]\]/gi;
|
||||||
|
|
||||||
|
function normalizeDirectiveWhitespace(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/[ \t]+/g, " ")
|
||||||
|
.replace(/[ \t]*\n[ \t]*/g, "\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseInlineDirectives(
|
||||||
|
text?: string,
|
||||||
|
options: InlineDirectiveParseOptions = {},
|
||||||
|
): InlineDirectiveParseResult {
|
||||||
|
const {
|
||||||
|
currentMessageId,
|
||||||
|
stripAudioTag = true,
|
||||||
|
stripReplyTags = true,
|
||||||
|
} = options;
|
||||||
|
if (!text) {
|
||||||
|
return {
|
||||||
|
text: "",
|
||||||
|
audioAsVoice: false,
|
||||||
|
replyToCurrent: false,
|
||||||
|
hasAudioTag: false,
|
||||||
|
hasReplyTag: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let cleaned = text;
|
||||||
|
let audioAsVoice = false;
|
||||||
|
let hasAudioTag = false;
|
||||||
|
let hasReplyTag = false;
|
||||||
|
let sawCurrent = false;
|
||||||
|
let lastExplicitId: string | undefined;
|
||||||
|
|
||||||
|
cleaned = cleaned.replace(AUDIO_TAG_RE, (match) => {
|
||||||
|
audioAsVoice = true;
|
||||||
|
hasAudioTag = true;
|
||||||
|
return stripAudioTag ? " " : match;
|
||||||
|
});
|
||||||
|
|
||||||
|
cleaned = cleaned.replace(
|
||||||
|
REPLY_TAG_RE,
|
||||||
|
(match, idRaw: string | undefined) => {
|
||||||
|
hasReplyTag = true;
|
||||||
|
if (idRaw === undefined) {
|
||||||
|
sawCurrent = true;
|
||||||
|
} else {
|
||||||
|
const id = idRaw.trim();
|
||||||
|
if (id) lastExplicitId = id;
|
||||||
|
}
|
||||||
|
return stripReplyTags ? " " : match;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
cleaned = normalizeDirectiveWhitespace(cleaned);
|
||||||
|
|
||||||
|
const replyToId =
|
||||||
|
lastExplicitId ??
|
||||||
|
(sawCurrent ? currentMessageId?.trim() || undefined : undefined);
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: cleaned,
|
||||||
|
audioAsVoice,
|
||||||
|
replyToId,
|
||||||
|
replyToCurrent: sawCurrent,
|
||||||
|
hasAudioTag,
|
||||||
|
hasReplyTag,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -54,43 +54,60 @@ async function loadWebMediaInternal(
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
if (/^https?:\/\//i.test(mediaUrl)) {
|
const clampAndFinalize = async (params: {
|
||||||
const fetched = await fetchRemoteMedia({ url: mediaUrl });
|
buffer: Buffer;
|
||||||
const { buffer, contentType, fileName } = fetched;
|
contentType?: string;
|
||||||
const kind = mediaKindFromMime(contentType);
|
kind: MediaKind;
|
||||||
|
fileName?: string;
|
||||||
|
}): Promise<WebMediaResult> => {
|
||||||
const cap = Math.min(
|
const cap = Math.min(
|
||||||
maxBytes ?? maxBytesForKind(kind),
|
maxBytes ?? maxBytesForKind(params.kind),
|
||||||
maxBytesForKind(kind),
|
maxBytesForKind(params.kind),
|
||||||
);
|
);
|
||||||
if (kind === "image") {
|
if (params.kind === "image") {
|
||||||
// Skip optimization for GIFs to preserve animation.
|
const isGif = params.contentType === "image/gif";
|
||||||
if (contentType === "image/gif" || !optimizeImages) {
|
if (isGif || !optimizeImages) {
|
||||||
if (buffer.length > cap) {
|
if (params.buffer.length > cap) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${
|
`${
|
||||||
contentType === "image/gif" ? "GIF" : "Media"
|
isGif ? "GIF" : "Media"
|
||||||
} exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${(
|
} exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${(
|
||||||
buffer.length / (1024 * 1024)
|
params.buffer.length / (1024 * 1024)
|
||||||
).toFixed(2)}MB)`,
|
).toFixed(2)}MB)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { buffer, contentType, kind, fileName };
|
return {
|
||||||
|
buffer: params.buffer,
|
||||||
|
contentType: params.contentType,
|
||||||
|
kind: params.kind,
|
||||||
|
fileName: params.fileName,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return { ...(await optimizeAndClampImage(buffer, cap)), fileName };
|
return {
|
||||||
|
...(await optimizeAndClampImage(params.buffer, cap)),
|
||||||
|
fileName: params.fileName,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (buffer.length > cap) {
|
if (params.buffer.length > cap) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Media exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${(
|
`Media exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${(
|
||||||
buffer.length / (1024 * 1024)
|
params.buffer.length / (1024 * 1024)
|
||||||
).toFixed(2)}MB)`,
|
).toFixed(2)}MB)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
buffer,
|
buffer: params.buffer,
|
||||||
contentType: contentType ?? undefined,
|
contentType: params.contentType ?? undefined,
|
||||||
kind,
|
kind: params.kind,
|
||||||
fileName,
|
fileName: params.fileName,
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(mediaUrl)) {
|
||||||
|
const fetched = await fetchRemoteMedia({ url: mediaUrl });
|
||||||
|
const { buffer, contentType, fileName } = fetched;
|
||||||
|
const kind = mediaKindFromMime(contentType);
|
||||||
|
return await clampAndFinalize({ buffer, contentType, kind, fileName });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local path
|
// Local path
|
||||||
@@ -102,34 +119,12 @@ async function loadWebMediaInternal(
|
|||||||
const ext = extensionForMime(mime);
|
const ext = extensionForMime(mime);
|
||||||
if (ext) fileName = `${fileName}${ext}`;
|
if (ext) fileName = `${fileName}${ext}`;
|
||||||
}
|
}
|
||||||
const cap = Math.min(
|
return await clampAndFinalize({
|
||||||
maxBytes ?? maxBytesForKind(kind),
|
buffer: data,
|
||||||
maxBytesForKind(kind),
|
contentType: mime,
|
||||||
);
|
kind,
|
||||||
if (kind === "image") {
|
fileName,
|
||||||
// Skip optimization for GIFs to preserve animation.
|
});
|
||||||
if (mime === "image/gif" || !optimizeImages) {
|
|
||||||
if (data.length > cap) {
|
|
||||||
throw new Error(
|
|
||||||
`${
|
|
||||||
mime === "image/gif" ? "GIF" : "Media"
|
|
||||||
} exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${(
|
|
||||||
data.length / (1024 * 1024)
|
|
||||||
).toFixed(2)}MB)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { buffer: data, contentType: mime, kind, fileName };
|
|
||||||
}
|
|
||||||
return { ...(await optimizeAndClampImage(data, cap)), fileName };
|
|
||||||
}
|
|
||||||
if (data.length > cap) {
|
|
||||||
throw new Error(
|
|
||||||
`Media exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${(
|
|
||||||
data.length / (1024 * 1024)
|
|
||||||
).toFixed(2)}MB)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { buffer: data, contentType: mime, kind, fileName };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadWebMedia(
|
export async function loadWebMedia(
|
||||||
|
|||||||
Reference in New Issue
Block a user