Skip to content

Commit 0cae438

Browse files
committed
fix(auto-reply): scope hook-mutated media delivery
1 parent 4f4035a commit 0cae438

2 files changed

Lines changed: 130 additions & 52 deletions

File tree

src/infra/outbound/deliver.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1279,6 +1279,56 @@ describe("deliverOutboundPayloads", () => {
12791279
resolveMediaAccessSpy.mockRestore();
12801280
});
12811281

1282+
it("scopes media access after reply payload hooks add local media", async () => {
1283+
hookMocks.runner.hasHooks.mockImplementation(
1284+
(hookName?: string) => hookName === "reply_payload_sending",
1285+
);
1286+
hookMocks.runner.runReplyPayloadSending.mockResolvedValueOnce({
1287+
payload: {
1288+
text: "hook media",
1289+
mediaUrl: "file:///tmp/hook-added.png",
1290+
},
1291+
});
1292+
const resolveMediaAccessSpy = vi.spyOn(
1293+
mediaCapabilityModule,
1294+
"resolveAgentScopedOutboundMediaAccess",
1295+
);
1296+
const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m5", roomId: "!room:example" });
1297+
1298+
await deliverOutboundPayloads({
1299+
cfg: {},
1300+
channel: "matrix",
1301+
to: "!room:example",
1302+
payloads: [{ text: "hello" }],
1303+
deps: { matrix: sendMatrix },
1304+
session: {
1305+
key: "agent:main:matrix:room:ops",
1306+
agentId: "main",
1307+
requesterSenderId: "sender-1",
1308+
},
1309+
replyPayloadSendingHook: {
1310+
kind: "final",
1311+
channel: "matrix",
1312+
context: { channelId: "matrix", conversationId: "!room:example" },
1313+
},
1314+
});
1315+
1316+
const [mediaAccessOptions] = requireMockCall(resolveMediaAccessSpy, "media access") as [
1317+
{
1318+
mediaSources?: unknown;
1319+
requesterSenderId?: unknown;
1320+
sessionKey?: unknown;
1321+
},
1322+
];
1323+
expect(mediaAccessOptions?.mediaSources).toEqual(["file:///tmp/hook-added.png"]);
1324+
expect(mediaAccessOptions?.sessionKey).toBe("agent:main:matrix:room:ops");
1325+
expect(mediaAccessOptions?.requesterSenderId).toBe("sender-1");
1326+
const sendOptions = requireMatrixSendCall(sendMatrix)[2] as Record<string, unknown>;
1327+
expect(sendOptions.mediaUrl).toBe("file:///tmp/hook-added.png");
1328+
expect(typeof sendOptions.mediaReadFile).toBe("function");
1329+
resolveMediaAccessSpy.mockRestore();
1330+
});
1331+
12821332
it("chunks direct adapter text and preserves delivery overrides across sends", async () => {
12831333
const sendText = vi.fn().mockImplementation(async ({ text }: { text: string }) => ({
12841334
channel: "matrix" as const,

src/infra/outbound/deliver.ts

Lines changed: 80 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -655,10 +655,6 @@ type DeliverOutboundPayloadsCoreRuntimeParams = DeliverOutboundPayloadsCoreParam
655655
onPlatformSendStart?: () => Promise<void>;
656656
};
657657

658-
function collectPayloadMediaSources(plan: readonly OutboundPayloadPlan[]): string[] {
659-
return plan.flatMap((entry) => entry.parts.mediaUrls);
660-
}
661-
662658
/**
663659
* @deprecated Direct outbound delivery is compatibility/runtime substrate.
664660
* New message lifecycle code should use `sendDurableMessageBatch` from
@@ -1391,8 +1387,7 @@ async function deliverOutboundPayloadsCore(
13911387
const accountId = params.accountId;
13921388
const deps = params.deps;
13931389
const abortSignal = params.abortSignal;
1394-
const mediaSources = collectPayloadMediaSources(outboundPayloadPlan);
1395-
const mediaAccess =
1390+
const resolveMediaAccess = (mediaSources: readonly string[]): OutboundMediaAccess =>
13961391
mediaSources.length > 0
13971392
? resolveAgentScopedOutboundMediaAccess({
13981393
cfg,
@@ -1408,25 +1403,42 @@ async function deliverOutboundPayloadsCore(
14081403
requesterSenderE164: params.session?.requesterSenderE164,
14091404
})
14101405
: (params.mediaAccess ?? {});
1406+
const createHandler = (mediaSources: readonly string[]) =>
1407+
createChannelHandler({
1408+
cfg,
1409+
channel,
1410+
to,
1411+
deps,
1412+
accountId,
1413+
replyToId: params.replyToId,
1414+
replyToMode: params.replyToMode,
1415+
formatting: params.formatting,
1416+
threadId: params.threadId,
1417+
identity: params.identity,
1418+
gifPlayback: params.gifPlayback,
1419+
forceDocument: params.forceDocument,
1420+
silent: params.silent,
1421+
mediaAccess: resolveMediaAccess(mediaSources),
1422+
gatewayClientScopes: params.gatewayClientScopes,
1423+
...(params.onPlatformSendStart ? { onPlatformSendStart: params.onPlatformSendStart } : {}),
1424+
});
1425+
const baseHandler = await createHandler([]);
1426+
const handlerByMediaSources = new Map<string, Promise<ChannelHandler>>();
1427+
const getDeliveryHandler = (mediaSources: readonly string[]): Promise<ChannelHandler> => {
1428+
if (mediaSources.length === 0) {
1429+
return Promise.resolve(baseHandler);
1430+
}
1431+
const key = JSON.stringify(mediaSources);
1432+
const cached = handlerByMediaSources.get(key);
1433+
if (cached) {
1434+
return cached;
1435+
}
1436+
const created = createHandler(mediaSources);
1437+
handlerByMediaSources.set(key, created);
1438+
return created;
1439+
};
14111440
const results: OutboundDeliveryResult[] = [];
1412-
const handler = await createChannelHandler({
1413-
cfg,
1414-
channel,
1415-
to,
1416-
deps,
1417-
accountId,
1418-
replyToId: params.replyToId,
1419-
replyToMode: params.replyToMode,
1420-
formatting: params.formatting,
1421-
threadId: params.threadId,
1422-
identity: params.identity,
1423-
gifPlayback: params.gifPlayback,
1424-
forceDocument: params.forceDocument,
1425-
silent: params.silent,
1426-
mediaAccess,
1427-
gatewayClientScopes: params.gatewayClientScopes,
1428-
...(params.onPlatformSendStart ? { onPlatformSendStart: params.onPlatformSendStart } : {}),
1429-
});
1441+
const handler = baseHandler;
14301442
const configuredTextLimit = handler.chunker
14311443
? resolveTextChunkLimit(cfg, channel, accountId, {
14321444
fallbackLimit: handler.textChunkLimit,
@@ -1445,13 +1457,17 @@ async function deliverOutboundPayloadsCore(
14451457
replyToMode: params.replyToMode,
14461458
});
14471459

1448-
const sendTextChunks = async (text: string, overrides: OutboundMessageSendOverrides = {}) => {
1460+
const sendTextChunks = async (
1461+
sendHandler: ChannelHandler,
1462+
text: string,
1463+
overrides: OutboundMessageSendOverrides = {},
1464+
) => {
14491465
const units = planOutboundTextMessageUnits({
14501466
text,
14511467
overrides,
1452-
chunker: handler.chunker,
1453-
chunkerMode: handler.chunkerMode,
1454-
chunkedTextFormatting: handler.chunkedTextFormatting,
1468+
chunker: sendHandler.chunker,
1469+
chunkerMode: sendHandler.chunkerMode,
1470+
chunkedTextFormatting: sendHandler.chunkedTextFormatting,
14551471
textLimit,
14561472
chunkMode,
14571473
formatting: params.formatting,
@@ -1465,7 +1481,7 @@ async function deliverOutboundPayloadsCore(
14651481
continue;
14661482
}
14671483
throwIfAborted(abortSignal);
1468-
results.push(await handler.sendText(unit.text, unit.overrides));
1484+
results.push(await sendHandler.sendText(unit.text, unit.overrides));
14691485
}
14701486
};
14711487
const normalizedPayloads = normalizePayloadsForChannelDelivery(outboundPayloadPlan, handler);
@@ -1606,11 +1622,17 @@ async function deliverOutboundPayloadsCore(
16061622
continue;
16071623
}
16081624
deliveryPayload = hookResult.payload;
1625+
const presentationHandler = await getDeliveryHandler(
1626+
buildPayloadSummary(deliveryPayload).mediaUrls,
1627+
);
16091628
const renderedPayload = stripInternalRuntimeScaffoldingFromPayload(
1610-
await renderPresentationForDelivery(handler, deliveryPayload),
1629+
await renderPresentationForDelivery(presentationHandler, deliveryPayload),
1630+
);
1631+
const renderedHandler = await getDeliveryHandler(
1632+
buildPayloadSummary(renderedPayload).mediaUrls,
16111633
);
1612-
const normalizedEffectivePayload = handler.normalizePayload
1613-
? handler.normalizePayload(renderedPayload)
1634+
const normalizedEffectivePayload = renderedHandler.normalizePayload
1635+
? renderedHandler.normalizePayload(renderedPayload)
16141636
: renderedPayload;
16151637
const effectivePayload = normalizedEffectivePayload
16161638
? normalizeEmptyPayloadForDelivery(
@@ -1631,6 +1653,7 @@ async function deliverOutboundPayloadsCore(
16311653
continue;
16321654
}
16331655
payloadSummary = buildPayloadSummary(effectivePayload);
1656+
const deliveryHandler = await getDeliveryHandler(payloadSummary.mediaUrls);
16341657
startDeliveryDiagnostics(deliveryKindForPayload(effectivePayload, payloadSummary));
16351658

16361659
params.onPayload?.(payloadSummary);
@@ -1648,18 +1671,19 @@ async function deliverOutboundPayloadsCore(
16481671
applyReplyToConsumption(overrides, {
16491672
consumeImplicitReply: replyToResolution.source === "implicit",
16501673
});
1651-
const deliveryTarget = handler.buildTargetRef({ threadId: sendOverrides.threadId });
1674+
const deliveryTarget = deliveryHandler.buildTargetRef({ threadId: sendOverrides.threadId });
16521675
if (
1653-
handler.sendPayload &&
1654-
((effectivePayload.isError === true && handler.sendTextOnlyErrorPayloads === true) ||
1676+
deliveryHandler.sendPayload &&
1677+
((effectivePayload.isError === true &&
1678+
deliveryHandler.sendTextOnlyErrorPayloads === true) ||
16551679
hasReplyPayloadContent({
16561680
presentation: effectivePayload.presentation,
16571681
interactive: effectivePayload.interactive,
16581682
channelData: effectivePayload.channelData,
16591683
}) ||
16601684
effectivePayload.audioAsVoice === true)
16611685
) {
1662-
const delivery = await handler.sendPayload(
1686+
const delivery = await deliveryHandler.sendPayload(
16631687
effectivePayload,
16641688
applySendReplyToConsumption(sendOverrides),
16651689
);
@@ -1677,13 +1701,13 @@ async function deliverOutboundPayloadsCore(
16771701
recordPayloadOutcome({ index: payloadIndex, status: "sent", results: [delivery] });
16781702
recordDeliveredMirrorPayload(payloadSummary, [delivery]);
16791703
await maybePinDeliveredMessage({
1680-
handler,
1704+
handler: deliveryHandler,
16811705
payload: effectivePayload,
16821706
target: deliveryTarget,
16831707
messageId: delivery.messageId,
16841708
});
16851709
await maybeNotifyAfterDeliveredPayload({
1686-
handler,
1710+
handler: deliveryHandler,
16871711
payload: effectivePayload,
16881712
target: deliveryTarget,
16891713
results: [delivery],
@@ -1698,15 +1722,15 @@ async function deliverOutboundPayloadsCore(
16981722
}
16991723
if (payloadSummary.mediaUrls.length === 0) {
17001724
const beforeCount = results.length;
1701-
if (handler.sendFormattedText) {
1725+
if (deliveryHandler.sendFormattedText) {
17021726
results.push(
1703-
...(await handler.sendFormattedText(
1727+
...(await deliveryHandler.sendFormattedText(
17041728
payloadSummary.text,
17051729
applySendReplyToConsumption(sendOverrides),
17061730
)),
17071731
);
17081732
} else {
1709-
await sendTextChunks(payloadSummary.text, sendOverrides);
1733+
await sendTextChunks(deliveryHandler, payloadSummary.text, sendOverrides);
17101734
}
17111735
const deliveredResults = results.slice(beforeCount);
17121736
if (deliveredResults.length > 0) {
@@ -1727,13 +1751,13 @@ async function deliverOutboundPayloadsCore(
17271751
const messageId = results.at(-1)?.messageId;
17281752
const pinMessageId = deliveredResults.find((entry) => entry.messageId)?.messageId;
17291753
await maybePinDeliveredMessage({
1730-
handler,
1754+
handler: deliveryHandler,
17311755
payload: effectivePayload,
17321756
target: deliveryTarget,
17331757
messageId: pinMessageId,
17341758
});
17351759
await maybeNotifyAfterDeliveredPayload({
1736-
handler,
1760+
handler: deliveryHandler,
17371761
payload: effectivePayload,
17381762
target: deliveryTarget,
17391763
results: deliveredResults,
@@ -1747,7 +1771,7 @@ async function deliverOutboundPayloadsCore(
17471771
continue;
17481772
}
17491773

1750-
if (!handler.supportsMedia) {
1774+
if (!deliveryHandler.supportsMedia) {
17511775
log.warn(
17521776
"Plugin outbound adapter does not implement sendMedia; media URLs will be dropped and text fallback will be used",
17531777
{
@@ -1763,7 +1787,7 @@ async function deliverOutboundPayloadsCore(
17631787
);
17641788
}
17651789
const beforeCount = results.length;
1766-
await sendTextChunks(fallbackText, sendOverrides);
1790+
await sendTextChunks(deliveryHandler, fallbackText, sendOverrides);
17671791
const deliveredResults = results.slice(beforeCount);
17681792
if (deliveredResults.length > 0) {
17691793
recordPayloadOutcome({
@@ -1783,13 +1807,13 @@ async function deliverOutboundPayloadsCore(
17831807
const messageId = results.at(-1)?.messageId;
17841808
const pinMessageId = deliveredResults.find((entry) => entry.messageId)?.messageId;
17851809
await maybePinDeliveredMessage({
1786-
handler,
1810+
handler: deliveryHandler,
17871811
payload: effectivePayload,
17881812
target: deliveryTarget,
17891813
messageId: pinMessageId,
17901814
});
17911815
await maybeNotifyAfterDeliveredPayload({
1792-
handler,
1816+
handler: deliveryHandler,
17931817
payload: effectivePayload,
17941818
target: deliveryTarget,
17951819
results: deliveredResults,
@@ -1817,21 +1841,25 @@ async function deliverOutboundPayloadsCore(
18171841
continue;
18181842
}
18191843
throwIfAborted(abortSignal);
1820-
const delivery = handler.sendFormattedMedia
1821-
? await handler.sendFormattedMedia(unit.caption ?? "", unit.mediaUrl, unit.overrides)
1822-
: await handler.sendMedia(unit.caption ?? "", unit.mediaUrl, unit.overrides);
1844+
const delivery = deliveryHandler.sendFormattedMedia
1845+
? await deliveryHandler.sendFormattedMedia(
1846+
unit.caption ?? "",
1847+
unit.mediaUrl,
1848+
unit.overrides,
1849+
)
1850+
: await deliveryHandler.sendMedia(unit.caption ?? "", unit.mediaUrl, unit.overrides);
18231851
results.push(delivery);
18241852
firstMessageId ??= delivery.messageId;
18251853
lastMessageId = delivery.messageId;
18261854
}
18271855
await maybePinDeliveredMessage({
1828-
handler,
1856+
handler: deliveryHandler,
18291857
payload: effectivePayload,
18301858
target: deliveryTarget,
18311859
messageId: firstMessageId,
18321860
});
18331861
await maybeNotifyAfterDeliveredPayload({
1834-
handler,
1862+
handler: deliveryHandler,
18351863
payload: effectivePayload,
18361864
target: deliveryTarget,
18371865
results: results.slice(beforeCount),

0 commit comments

Comments
 (0)