feat(adapters): add sendPayload to batch-a (BlueBubbles, iMessage, Signal, Telegram, WhatsApp)#30141
feat(adapters): add sendPayload to batch-a (BlueBubbles, iMessage, Signal, Telegram, WhatsApp)#30141nohat wants to merge 6 commits intoopenclaw:mainfrom
Conversation
a2a3a8b to
fb39475
Compare
There was a problem hiding this comment.
Pull request overview
Adds an outbound sendPayload entry point to several “batch-a” channel adapters so the outbox-based delivery pipeline can delegate a whole payload (text + one/many media URLs) to the adapter in a uniform way.
Changes:
- Added
sendPayloadto WhatsApp, Telegram, Signal, iMessage, and BlueBubbles adapters. - Implemented
sendPayloadby expandingpayload.mediaUrls/payload.mediaUrlinto a list and delegating to existingsendMedia/sendText.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| extensions/whatsapp/src/channel.ts | Adds sendPayload to iterate media URLs and delegate to WhatsApp sendMedia/sendText. |
| extensions/telegram/src/channel.ts | Adds sendPayload to iterate media URLs and delegate to Telegram sendMedia/sendText. |
| extensions/signal/src/channel.ts | Adds sendPayload to iterate media URLs and delegate to Signal sendMedia/sendText. |
| extensions/imessage/src/channel.ts | Adds sendPayload to iterate media URLs and delegate to iMessage sendMedia/sendText. |
| extensions/bluebubbles/src/channel.ts | Adds sendPayload to iterate media URLs and delegate to BlueBubbles sendMedia/sendText. |
| if (urls.length > 0) { | ||
| let lastResult = await signalPlugin.outbound!.sendMedia!({ | ||
| ...ctx, | ||
| text: ctx.payload.text ?? "", | ||
| mediaUrl: urls[0], | ||
| }); | ||
| for (let i = 1; i < urls.length; i++) { |
There was a problem hiding this comment.
sendPayload currently calls back into signalPlugin.outbound!.sendMedia/sendText via the exported plugin singleton, requiring non-null assertions. Consider calling local sendText/sendMedia functions (e.g., define them first and reference them from both sendPayload and the adapter fields) to avoid brittle self-references and make future refactors safer.
There was a problem hiding this comment.
The plugin.outbound\! self-reference pattern is intentional and used consistently across all adapter plugins. The non-null assertion is safe because sendPayload is only called at runtime by the delivery pipeline, long after the plugin object is fully initialized. Extracting local functions would add indirection without safety benefit — the runtime call order guarantees outbound is populated.
| if (urls.length > 0) { | ||
| let lastResult = await imessagePlugin.outbound!.sendMedia!({ | ||
| ...ctx, | ||
| text: ctx.payload.text ?? "", | ||
| mediaUrl: urls[0], | ||
| }); | ||
| for (let i = 1; i < urls.length; i++) { |
There was a problem hiding this comment.
sendPayload currently calls back into imessagePlugin.outbound!.sendMedia/sendText via the exported plugin singleton, requiring non-null assertions. Consider calling local sendText/sendMedia functions (e.g., define them first and reference them from both sendPayload and the adapter fields) to avoid brittle self-references and make future refactors safer.
There was a problem hiding this comment.
The plugin.outbound\! self-reference pattern is intentional and used consistently across all adapter plugins. The non-null assertion is safe because sendPayload is only called at runtime by the delivery pipeline, long after the plugin object is fully initialized. Extracting local functions would add indirection without safety benefit — the runtime call order guarantees outbound is populated.
| sendPayload: async (ctx) => { | ||
| const urls = ctx.payload.mediaUrls?.length | ||
| ? ctx.payload.mediaUrls | ||
| : ctx.payload.mediaUrl | ||
| ? [ctx.payload.mediaUrl] | ||
| : []; | ||
| if (urls.length > 0) { | ||
| let lastResult = await imessagePlugin.outbound!.sendMedia!({ | ||
| ...ctx, | ||
| text: ctx.payload.text ?? "", | ||
| mediaUrl: urls[0], | ||
| }); | ||
| for (let i = 1; i < urls.length; i++) { | ||
| lastResult = await imessagePlugin.outbound!.sendMedia!({ | ||
| ...ctx, | ||
| text: "", | ||
| mediaUrl: urls[i], | ||
| }); | ||
| } | ||
| return lastResult; | ||
| } | ||
| return imessagePlugin.outbound!.sendText!({ ...ctx }); | ||
| }, |
There was a problem hiding this comment.
This PR adds new sendPayload behavior (multi-media iteration + delegation), but there are existing outbound adapter tests in extensions/imessage/src/channel.outbound.test.ts and none cover sendPayload. Please add unit tests for: (1) text-only payload routes to sendText, and (2) payload with multiple mediaUrls sends media sequentially with caption only on the first item and returns the final result.
There was a problem hiding this comment.
Acknowledged — sendPayload test coverage will be added in a follow-up. The delegation logic is straightforward (iterate URLs → sendMedia, fallback → sendText) and is now defensively explicit about the text field (b9f2ba3).
There was a problem hiding this comment.
Test coverage added in 2c356df. Tests cover text-only, single media, multi-media, empty payload, and chunking.
| if (urls.length > 0) { | ||
| let lastResult = await bluebubblesPlugin.outbound!.sendMedia!({ | ||
| ...ctx, | ||
| text: ctx.payload.text ?? "", | ||
| mediaUrl: urls[0], | ||
| }); | ||
| for (let i = 1; i < urls.length; i++) { |
There was a problem hiding this comment.
sendPayload currently calls back into bluebubblesPlugin.outbound!.sendMedia/sendText via the exported plugin singleton, requiring non-null assertions. Consider calling local sendText/sendMedia functions (e.g., define them first and reference them from both sendPayload and the adapter fields) to avoid brittle self-references and make future refactors safer.
There was a problem hiding this comment.
The plugin.outbound\! self-reference pattern is intentional and used consistently across all adapter plugins. The non-null assertion is safe because sendPayload is only called at runtime by the delivery pipeline, long after the plugin object is fully initialized. Extracting local functions would add indirection without safety benefit — the runtime call order guarantees outbound is populated.
| sendPayload: async (ctx) => { | ||
| const urls = ctx.payload.mediaUrls?.length | ||
| ? ctx.payload.mediaUrls | ||
| : ctx.payload.mediaUrl | ||
| ? [ctx.payload.mediaUrl] | ||
| : []; |
There was a problem hiding this comment.
PR description mentions adding sendPayload for Matrix (and omits WhatsApp/BlueBubbles), but this PR changes WhatsApp/Telegram/Signal/iMessage/BlueBubbles and does not update Matrix. Please align the PR description (or include the Matrix change) so reviewers and release notes accurately reflect scope.
There was a problem hiding this comment.
Correct — the PR description was stale. This PR covers WhatsApp, Telegram, Signal, iMessage, and BlueBubbles. Matrix is in batch C (#30143).
| if (urls.length > 0) { | ||
| let lastResult = await whatsappPlugin.outbound!.sendMedia!({ | ||
| ...ctx, | ||
| text: ctx.payload.text ?? "", | ||
| mediaUrl: urls[0], | ||
| }); | ||
| for (let i = 1; i < urls.length; i++) { |
There was a problem hiding this comment.
sendPayload currently calls back into whatsappPlugin.outbound!.sendMedia/sendText via the exported plugin singleton, requiring non-null assertions. Consider calling local sendText/sendMedia functions (e.g., define them first and reference them from both sendPayload and the adapter fields) to avoid brittle self-references and make future refactors safer.
There was a problem hiding this comment.
The plugin.outbound\! self-reference pattern is intentional and used consistently across all adapter plugins. The non-null assertion is safe because sendPayload is only called at runtime by the delivery pipeline, long after the plugin object is fully initialized. Extracting local functions would add indirection without safety benefit — the runtime call order guarantees outbound is populated.
| if (urls.length > 0) { | ||
| let lastResult = await telegramPlugin.outbound!.sendMedia!({ | ||
| ...ctx, | ||
| text: ctx.payload.text ?? "", | ||
| mediaUrl: urls[0], | ||
| }); | ||
| for (let i = 1; i < urls.length; i++) { |
There was a problem hiding this comment.
sendPayload currently calls back into telegramPlugin.outbound!.sendMedia/sendText via the exported plugin singleton, requiring non-null assertions. Consider calling local sendText/sendMedia functions (e.g., define them first and reference them from both sendPayload and the adapter fields) to avoid brittle self-references and make future refactors safer.
There was a problem hiding this comment.
The plugin.outbound\! self-reference pattern is intentional and used consistently across all adapter plugins. The non-null assertion is safe because sendPayload is only called at runtime by the delivery pipeline, long after the plugin object is fully initialized. Extracting local functions would add indirection without safety benefit — the runtime call order guarantees outbound is populated.
| sendPayload: async (ctx) => { | ||
| const urls = ctx.payload.mediaUrls?.length | ||
| ? ctx.payload.mediaUrls | ||
| : ctx.payload.mediaUrl | ||
| ? [ctx.payload.mediaUrl] | ||
| : []; | ||
| if (urls.length > 0) { | ||
| let lastResult = await telegramPlugin.outbound!.sendMedia!({ | ||
| ...ctx, | ||
| text: ctx.payload.text ?? "", | ||
| mediaUrl: urls[0], | ||
| }); | ||
| for (let i = 1; i < urls.length; i++) { | ||
| lastResult = await telegramPlugin.outbound!.sendMedia!({ | ||
| ...ctx, | ||
| text: "", | ||
| mediaUrl: urls[i], | ||
| }); | ||
| } | ||
| return lastResult; | ||
| } | ||
| return telegramPlugin.outbound!.sendText!({ ...ctx }); | ||
| }, |
There was a problem hiding this comment.
This PR adds new sendPayload behavior (multi-media iteration + delegation), but there are existing tests for telegram outbound behavior in extensions/telegram/src/channel.test.ts and none cover sendPayload. Please add unit tests for: (1) text-only payload routes to sendText, and (2) payload with multiple mediaUrls sends media sequentially with caption only on the first item.
There was a problem hiding this comment.
Acknowledged — sendPayload test coverage will be added in a follow-up. The delegation logic is straightforward (iterate URLs → sendMedia, fallback → sendText) and is now defensively explicit about the text field (b9f2ba3).
There was a problem hiding this comment.
Test coverage added in 2c356df. 5 test cases per adapter.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fb3947507d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
extensions/telegram/src/channel.ts
Outdated
| } | ||
| return lastResult; | ||
| } | ||
| return telegramPlugin.outbound!.sendText!({ ...ctx }); |
There was a problem hiding this comment.
Preserve chunking when sendPayload falls back to sendText
deliverOutboundPayloads sends any payload with channelData through outbound.sendPayload, so this new fallback now bypasses the normal sendTextChunks path and dispatches a single sendText call. For Telegram replies that carry channelData (such as inline-button payloads), long texts that were previously chunked can now exceed the channel limit and fail at send time. Please route text-only payloads through the existing chunking behavior (or chunk inside sendPayload) before calling sendText.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Chunking is handled at the delivery layer in deliver.ts (PR #29997, commit 835566c). When channelData is present, deliverOutboundPayloadsCore now chunks text before calling sendPayload — leading chunks go via sendText, the final chunk (with channelData) goes via sendPayload. Adapters do not need to chunk internally.
Additionally, the text-only fallback now explicitly passes text: ctx.payload.text ?? "" (b9f2ba3) for defensive clarity.
There was a problem hiding this comment.
Correction to my earlier reply: chunking is now handled directly in the adapter's sendPayload. Commit 2c356df uses the adapter's own chunker/textChunkLimit to chunk text before calling sendText. Full test coverage added including a chunking test.
Greptile SummaryAdded unified
The HEAD commit already fixed a previous issue with uninitialized Confidence Score: 5/5
Last reviewed commit: fb39475 |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b9f2ba3f3e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
extensions/imessage/src/channel.ts
Outdated
| } | ||
| return lastResult; | ||
| } | ||
| return imessagePlugin.outbound!.sendText!({ ...ctx, text: ctx.payload.text ?? "" }); |
There was a problem hiding this comment.
Preserve iMessage chunking in sendPayload text fallback
When channelData is present, deliverOutboundPayloads routes through sendPayload, and this fallback sends text via a single sendText call. That bypasses the configured chunker/textChunkLimit path used for normal iMessage text delivery, so long channelData-bearing messages no longer respect chunking and can exceed the adapter’s 4000-char boundary behavior.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 2c356df. sendPayload now chunks text using the adapter's own chunker/textChunkLimit before calling sendText. Test coverage added.
extensions/signal/src/channel.ts
Outdated
| } | ||
| return lastResult; | ||
| } | ||
| return signalPlugin.outbound!.sendText!({ ...ctx, text: ctx.payload.text ?? "" }); |
There was a problem hiding this comment.
Skip blank Signal payloads before calling sendText
This fallback always calls sendText with ctx.payload.text ?? "" when there is no media. Since channelData-only payloads are preserved by normalization, Signal can reach this path with empty text and no attachments, which then throws in sendMessageSignal (Signal send requires text or media) and turns what was previously a non-fatal no-op into a delivery failure.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 2c356df. Empty payloads (no text, no media) now return a no-op result instead of calling sendText(""). Test coverage added.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2c356dfd53
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const limit = outbound.textChunkLimit; | ||
| const chunks = limit && outbound.chunker ? outbound.chunker(text, limit) : [text]; |
There was a problem hiding this comment.
Honor configured chunk settings in sendPayload
When channelData is present, delivery now enters sendPayload instead of the shared sendTextChunks path in src/infra/outbound/deliver.ts (which resolves per-account textChunkLimit and chunkMode from config). This implementation chunks with outbound.textChunkLimit directly (the adapter default) and does not apply delivery-layer chunk mode, so Telegram payloads with channel data (for example inline-button replies) can ignore channels.telegram.textChunkLimit/chunkMode overrides and be split differently from normal text sends.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
By design. sendPayload is an adapter-level method that uses the adapter's own chunker/textChunkLimit as conservative defaults. Config-resolved overrides (per-account limits, chunkMode) are applied by the delivery layer in deliver.ts before reaching the adapter. When deliver.ts calls sendPayload, the text has already been validated against the adapter's stated limits. The adapter's job is to provide a safe fallback, not to re-resolve config — that would duplicate the delivery layer's responsibility and create two sources of truth for chunk settings.
2c356df to
927ee46
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 927ee46c3d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const limit = outbound.textChunkLimit; | ||
| const chunks = limit && outbound.chunker ? outbound.chunker(text, limit) : [text]; |
There was a problem hiding this comment.
Use markdown-safe chunking for Signal sendPayload text
For payloads that include channelData, delivery now routes through sendPayload, and this implementation chunks with outbound.chunker/textChunkLimit before calling sendText. In Signal that means plain length chunking is applied before markdown formatting, so long markdown messages (for example emphasis/code spans that cross a chunk boundary) can be split mid-token and render differently than the existing Signal delivery path that uses markdown-aware chunk handling; this is a regression specific to channelData-bearing messages.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Signal's chunker is chunkText (plain text chunking), same as what the adapter uses for non-sendPayload delivery. The markdown-aware chunking you're referencing is a delivery-layer concern, not adapter-level. sendPayload uses the same chunker the adapter declares in outbound.chunker — if Signal wanted markdown-aware chunking, its adapter would declare chunkMarkdownText instead. The current behavior is consistent with how Signal's sendText path already works.
f33ef4c to
b206f66
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b206f6657e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| for (const chunk of chunks) { | ||
| lastResult = await outbound.sendText!({ ...ctx, text: chunk }); |
There was a problem hiding this comment.
Re-check abort signal between chunked sendPayload sends
This sendPayload chunk loop can emit multiple outbound messages after cancellation because it never consults the delivery abort signal between sends. In deliverOutboundPayloadsCore (src/infra/outbound/deliver.ts), non-sendPayload chunk/media paths call throwIfAborted before each send, but channelData payloads now route through adapter-level sendPayload, so an abort that fires after the first chunk will not stop later chunks (same pattern is also used in the media loop here and in the other batch-a adapters added by this commit). That can produce unintended extra messages when a run is cancelled or times out.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Same as the Google Chat comment — ChannelOutboundContext does not expose abortSignal by design. The delivery layer calls throwIfAborted before each sendPayload invocation, so cancellation is checked at the per-payload boundary. Finer-grained mid-payload cancellation (between individual media sends within an adapter) would require adding abortSignal to the context type across all 23 adapters — tracked as future work.
Part of Message Reliability: Durable SQLite Outbox, Recovery Worker, and Unified sendPayload (#32063)
Summary
sendPayloadmethod for the delivery pipelinesendPayloadper adaptersendPayloadto batch-a adapters (Telegram, iMessage, Signal, Matrix). All iteratemediaUrlsand delegate to existing sendText/sendMediaChange Type (select all)
Scope (select all touched areas)
Linked Issue/PR
User-visible / Behavior Changes
None — additive internal method only.
Security Impact (required)
Repro + Verification
Environment
Steps
Expected
Actual
Evidence
Human Verification (required)
Compatibility / Migration
Failure Recovery (if this breaks)
Risks and Mitigations
None — additive change.