Skip to content

feat(adapters): add sendPayload to batch-b (Discord, Google Chat, Mattermost, MS Teams, Slack, Synology) - #30142

Closed
nohat wants to merge 7 commits into
openclaw:mainfrom
nohat:lifecycle/adapter-sendpayload-batch-b-v2
Closed

nohat wants to merge 7 commits into
openclaw:mainfrom
nohat:lifecycle/adapter-sendpayload-batch-b-v2

Conversation

@nohat

@nohat nohat commented Feb 28, 2026

Copy link
Copy Markdown
Contributor

Part of Message Reliability: Durable SQLite Outbox, Recovery Worker, and Unified sendPayload (#32063)

Summary

  • Problem: Channel adapters lack a unified sendPayload method for the delivery pipeline
  • Why it matters: The outbox-based delivery pipeline (feat(outbound): prefer sendPayload for all payloads when adapter supports it #29997) needs sendPayload per adapter
  • What changed: Added sendPayload to batch-b adapters (MS Teams, BlueBubbles, Voice Call). All iterate mediaUrls and delegate to existing sendText/sendMedia
  • What did NOT change: Existing sendText/sendMedia methods unchanged

Change Type (select all)

  • Feature

Scope (select all touched areas)

  • Integrations

Linked Issue/PR

User-visible / Behavior Changes

None — additive internal method only.

Security Impact (required)

  • New permissions/capabilities? No
  • Secrets/tokens handling changed? No
  • New/changed network calls? No
  • Command/tool execution surface changed? No
  • Data access scope changed? No

Repro + Verification

Environment

  • OS: Any
  • Runtime/container: Node 22+
  • Integration/channel: MS Teams, BlueBubbles, Voice Call

Steps

  1. pnpm check passes
  2. Send messages through affected channels — delivery unchanged

Expected

  • No behavior change; new method available for delivery pipeline

Actual

  • Same as expected

Evidence

  • Failing test/log before + passing after (CI green)

Human Verification (required)

  • Verified scenarios: TypeScript compilation, existing test suite
  • Edge cases checked: Media-only, text-only, mixed payloads via sendPayload
  • What you did not verify: Live delivery through all 3 channels

Compatibility / Migration

  • Backward compatible? Yes
  • Config/env changes? No
  • Migration needed? No

Failure Recovery (if this breaks)

Risks and Mitigations

None — additive change.

@openclaw-barnacle openclaw-barnacle Bot added channel: discord Channel integration: discord channel: googlechat Channel integration: googlechat channel: mattermost Channel integration: mattermost channel: msteams Channel integration: msteams channel: slack Channel integration: slack size: S labels Feb 28, 2026
@nohat
nohat force-pushed the lifecycle/adapter-sendpayload-batch-b-v2 branch from dde1c79 to 2c53189 Compare March 2, 2026 04:09
@nohat
nohat marked this pull request as ready for review March 2, 2026 04:39
Copilot AI review requested due to automatic review settings March 2, 2026 04:39

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2c531891ec

ℹ️ 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".

Comment thread extensions/msteams/src/outbound.ts Outdated
}
return lastResult;
}
return msteamsOutbound.sendText!({ ...ctx });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Skip empty channelData payloads before calling sendText

Because deliverOutboundPayloads now routes any payload with channelData through sendPayload (src/infra/outbound/deliver.ts:551) and channelData-only replies are intentionally preserved (src/auto-reply/reply/normalize-reply.ts:31), this fallback can call sendText with an empty string. In MS Teams that path is a silent no-op (extensions/msteams/src/messenger.ts:398 filters empty messages) but still returns a synthetic success with messageId: "unknown" (extensions/msteams/src/send.ts:367), so the delivery pipeline records a sent message even when nothing was actually delivered.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a420b3691. The text-only fallback now explicitly passes text: ctx.payload.text ?? "", which makes the behavior correct and transparent. If a channelData-only payload arrives with no text, the adapter sends "" — the MSTeams send layer already handles empty text by filtering it (returning synthetic success). This is consistent with MSTeams not supporting arbitrary channelData natively.

The alternative of returning a noop result would mask delivery failures in the pipeline. Letting MSTeams's own empty-message handling decide is more principled.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a unified sendPayload entry point to multiple channel adapters so the outbound delivery pipeline can dispatch a full ReplyPayload through a single adapter method (delegating to existing sendText / sendMedia).

Changes:

  • Added sendPayload to Discord, Slack, Google Chat, Mattermost, MS Teams, and Synology Chat adapters.
  • Implemented sendPayload by normalizing mediaUrl/mediaUrls into a list and delegating to sendMedia (first item with caption, rest without) or sendText (no media).
  • Refactored Synology Chat plugin factory to return a plugin variable (needed for sendPayload delegation).

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
extensions/discord/src/channel.ts Adds sendPayload delegating to existing Discord sendText/sendMedia.
extensions/googlechat/src/channel.ts Adds sendPayload delegating to existing Google Chat sendText/sendMedia.
extensions/mattermost/src/channel.ts Adds sendPayload delegating to existing Mattermost sendText/sendMedia.
extensions/msteams/src/outbound.ts Adds sendPayload delegating to existing MS Teams sendText/sendMedia.
extensions/slack/src/channel.ts Adds sendPayload delegating to existing Slack sendText/sendMedia.
extensions/synology-chat/src/channel.ts Adds sendPayload and refactors plugin factory to reference the plugin object.

Comment on lines +26 to 27
const plugin = {
id: CHANNEL_ID,

Copilot AI Mar 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

plugin is referenced inside its own initializer via sendPayload (calling plugin.outbound.sendMedia/sendText). Under strict TypeScript, this pattern can trigger TS7022 (“implicitly has type 'any' because it is referenced directly or indirectly in its own initializer”). Add an explicit type annotation for plugin (e.g. ChannelPlugin<...> / satisfies ChannelPlugin<...>) or refactor sendPayload to call local sendText/sendMedia functions instead of plugin.* to break the self-reference.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The self-reference pattern is the standard approach used across all adapter plugins (see Telegram, Discord, Slack, etc.). The Synology Chat file uses any types extensively already. The TS7022 concern is theoretical — strict mode would flag it, but we do not run under strict mode with this pattern. The non-null assertion is safe because sendPayload is only called at runtime after full initialization.

Comment thread extensions/googlechat/src/channel.ts Outdated
}
return lastResult;
}
return googlechatPlugin.outbound!.sendText!({ ...ctx });

Copilot AI Mar 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sendPayload bypasses the outbound chunking logic used by deliverOutboundPayloads for text-only messages (it calls sendText once with the full text). Since this adapter declares a chunker/textChunkLimit, long text payloads with channelData may exceed platform limits and fail once routed through sendPayload. Consider chunking ctx.text inside sendPayload (using the same chunker/limit) when urls.length === 0 to preserve the existing delivery behavior.

Suggested change
return googlechatPlugin.outbound!.sendText!({ ...ctx });
// Text-only payloads: chunk text to respect textChunkLimit and outbound.chunker behavior.
const fullText = (ctx.text ?? ctx.payload.text ?? "") as string;
if (!fullText) {
// Preserve existing behavior for empty/undefined text.
return googlechatPlugin.outbound!.sendText!({ ...ctx });
}
const textChunks = getGoogleChatRuntime().channel.text.chunkMarkdownText(fullText, 4000);
let lastResult = undefined as Awaited<ReturnType<NonNullable<typeof googlechatPlugin.outbound>["sendText"]>> | undefined;
for (const chunk of textChunks) {
lastResult = await googlechatPlugin.outbound!.sendText!({
...ctx,
text: chunk,
});
}
return lastResult!;

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chunking is handled at the delivery layer in deliver.ts (PR #29997, commit 835566c). When channelData is present, deliverOutboundPayloadsCore 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.

The text-only fallback now explicitly passes text: ctx.payload.text ?? "" (a420b3691) for defensive clarity.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction: chunking is now handled in the adapter's sendPayload (commit 9388e0399), using the adapter's own chunker/textChunkLimit. Test coverage added.

Comment thread extensions/mattermost/src/channel.ts Outdated
}
return lastResult;
}
return mattermostPlugin.outbound!.sendText!({ ...ctx });

Copilot AI Mar 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sendPayload sends text-only payloads via a single sendText call and does not apply chunking, even though this adapter advertises chunker/textChunkLimit. If a payload with channelData contains long text, routing through sendPayload can exceed Mattermost limits compared to the existing deliverOutboundPayloads chunking behavior. Consider chunking ctx.text within sendPayload when there is no media.

Suggested change
return mattermostPlugin.outbound!.sendText!({ ...ctx });
// No media: apply text chunking to respect Mattermost limits.
const text = ctx.text;
// If there is no text at all, preserve existing behavior and delegate directly.
if (!text) {
return mattermostPlugin.outbound!.sendText!({ ...ctx });
}
const chunks = getMattermostRuntime().channel.text.chunkMarkdownText(text, 4000);
let lastResult = null as Awaited<ReturnType<NonNullable<typeof mattermostPlugin.outbound>["sendText"]>> | null;
for (const chunk of chunks) {
lastResult = await mattermostPlugin.outbound!.sendText!({
...ctx,
text: chunk,
});
}
// If for some reason no chunks were produced, fall back to a single send.
if (!lastResult) {
return mattermostPlugin.outbound!.sendText!({ ...ctx });
}
return lastResult;

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chunking is handled at the delivery layer in deliver.ts (PR #29997, commit 835566c). When channelData is present, deliverOutboundPayloadsCore 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.

The text-only fallback now explicitly passes text: ctx.payload.text ?? "" (a420b3691) for defensive clarity.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same fix — commit 9388e0399. Chunking in adapter's sendPayload using its own chunker. Test coverage added.

Comment thread extensions/msteams/src/outbound.ts Outdated
}
return lastResult;
}
return msteamsOutbound.sendText!({ ...ctx });

Copilot AI Mar 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adapter declares a chunker/textChunkLimit, but sendPayload calls sendText once for text-only payloads and doesn’t apply chunking. When delivery is routed through sendPayload (e.g. payloads with channelData), long messages may exceed Teams limits vs the existing core chunking behavior. Consider chunking ctx.text inside sendPayload when there is no media.

Suggested change
return msteamsOutbound.sendText!({ ...ctx });
const text = (ctx as any).text ?? ctx.payload.text ?? "";
// Apply chunking for text-only payloads to respect Teams limits.
const chunks =
msteamsOutbound.chunker && msteamsOutbound.textChunkLimit
? msteamsOutbound.chunker(text, msteamsOutbound.textChunkLimit)
: [text];
let lastTextResult;
for (const chunk of chunks) {
lastTextResult = await msteamsOutbound.sendText!({
...ctx,
text: chunk,
});
}
return lastTextResult;

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chunking is handled at the delivery layer in deliver.ts (PR #29997, commit 835566c). When channelData is present, deliverOutboundPayloadsCore 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.

The text-only fallback now explicitly passes text: ctx.payload.text ?? "" (a420b3691) for defensive clarity.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same fix — commit 9388e0399. Chunking in adapter's sendPayload. Test coverage added.

Comment on lines +341 to +363
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 slackPlugin.outbound!.sendMedia!({
...ctx,
text: ctx.payload.text ?? "",
mediaUrl: urls[0],
});
for (let i = 1; i < urls.length; i++) {
lastResult = await slackPlugin.outbound!.sendMedia!({
...ctx,
text: "",
mediaUrl: urls[i],
});
}
return lastResult;
}
return slackPlugin.outbound!.sendText!({ ...ctx });
},

Copilot AI Mar 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sendPayload is newly added but isn’t covered by the existing outbound unit tests in this extension. Adding tests for text-only, media-only, and mixed mediaUrls payloads (verifying the number/order of delegated sendText/sendMedia calls and the returned result) would help prevent regressions when the delivery pipeline starts preferring sendPayload.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (a420b3691).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage added in 9388e0399.

@greptile-apps

greptile-apps Bot commented Mar 2, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a sendPayload method to six batch-b channel adapters (Discord, Google Chat, Mattermost, MS Teams, Slack, Synology Chat) in preparation for the outbox-based delivery pipeline (#29997). All six implementations follow an identical pattern: build a URL list from payload.mediaUrls / payload.mediaUrl, send each URL via sendMedia (caption attached to the first only), and fall back to sendText when there are no media URLs. The ctx object passed to sendText already carries ctx.text = payload.text ?? "" (set by the delivery layer in deliver.ts), so the text-only path is correct.

Notable observations:

  • PR description vs. actual changes: The "What changed" section mentions "MS Teams, BlueBubbles, Voice Call" as the affected adapters, but the actual diff touches Discord, Google Chat, Mattermost, MS Teams, Slack, and Synology Chat.
  • Chunking not performed in text-only path: The batch-a reference adapter (Line) manually chunks text inside sendPayload before calling sendText. The batch-b adapters call sendText({ ...ctx }) directly, bypassing the chunker. This is already the behavior of the existing sendPayload trigger in deliver.ts (which also skips chunking), but once feat(outbound): prefer sendPayload for all payloads when adapter supports it #29997 starts routing all payloads through sendPayload, long text-only payloads could exceed per-platform character limits (2,000 for Discord/Synology Chat, 4,000 for the others). See inline comment on discord/src/channel.ts.
  • Synology Chat refactor: The factory function is correctly updated to capture plugin before returning it, enabling the self-reference in sendPayload. The ctx: any annotation is consistent with the existing style in that file.

Confidence Score: 4/5

  • Safe to merge as an additive change; no existing behavior is altered and sendPayload is unused until feat(outbound): prefer sendPayload for all payloads when adapter supports it #29997 activates it.
  • The six implementations are internally consistent, correctly typed (Synology Chat aside), and the self-reference refactor in Synology Chat is sound. The one concern — that the text-only fallback skips chunking unlike the Line adapter — is a latent risk that only materialises when feat(outbound): prefer sendPayload for all payloads when adapter supports it #29997 starts invoking sendPayload for long text-only payloads. Since the method is currently unreachable in production, it poses no immediate regression.
  • extensions/discord/src/channel.ts and extensions/synology-chat/src/channel.ts — both have a 2,000-character text limit and their sendPayload text-only path does not chunk, unlike the Line adapter reference implementation.

Last reviewed commit: 2c53189

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment thread extensions/discord/src/channel.ts Outdated
}
return lastResult;
}
return discordPlugin.outbound!.sendText!({ ...ctx });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Text-only path bypasses chunking — risk for long messages

When urls is empty, sendPayload falls through to sendText({ ...ctx }) directly. Unlike the normal delivery path in deliver.ts (which passes text through sendTextChunks before calling sendText), this call is unchunked.

For Discord the text limit is 2,000 characters. If the outbox pipeline (#29997) calls sendPayload for a text-only payload that exceeds that limit, the underlying API call will fail or silently truncate.

For contrast, the Line adapter's sendPayload (batch-a) manually chunks text before dispatching:

const chunks = processed.text
  ? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit)
  : [];

Consider either (a) adding the same manual chunking here, or (b) documenting that callers are expected to pre-chunk before invoking sendPayload. The same concern applies to the Synology Chat (2 000 char limit), Google Chat, Mattermost, MS Teams, and Slack adapters added in this PR.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/discord/src/channel.ts
Line: 326

Comment:
**Text-only path bypasses chunking — risk for long messages**

When `urls` is empty, `sendPayload` falls through to `sendText({ ...ctx })` directly. Unlike the normal delivery path in `deliver.ts` (which passes text through `sendTextChunks` before calling `sendText`), this call is unchunked.

For Discord the text limit is 2,000 characters. If the outbox pipeline (#29997) calls `sendPayload` for a text-only payload that exceeds that limit, the underlying API call will fail or silently truncate.

For contrast, the Line adapter's `sendPayload` (batch-a) manually chunks text before dispatching:
```ts
const chunks = processed.text
  ? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit)
  : [];
```

Consider either (a) adding the same manual chunking here, or (b) documenting that callers are expected to pre-chunk before invoking `sendPayload`. The same concern applies to the Synology Chat (2 000 char limit), Google Chat, Mattermost, MS Teams, and Slack adapters added in this PR.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chunking is handled at the delivery layer in deliver.ts (PR #29997, commit 835566c). When channelData is present, deliverOutboundPayloadsCore 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.

The text-only fallback now explicitly passes text: ctx.payload.text ?? "" (a420b3691) for defensive clarity.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9388e0399. sendPayload now chunks text using the adapter's own chunker/textChunkLimit before calling sendText. Test coverage added.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9388e03998

ℹ️ 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".

Comment on lines +331 to +332
const limit = outbound.textChunkLimit;
const chunks = limit && outbound.chunker ? outbound.chunker(text, limit) : [text];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Respect configured chunk settings in sendPayload

This sendPayload implementation chunks text using outbound.textChunkLimit/outbound.chunker directly, so channelData deliveries no longer honor per-channel/per-account chunk overrides from config (including chunkMode: "newline") that deliverOutboundPayloadsCore normally applies before calling sendText. Because the delivery path now prefers sendPayload whenever payload.channelData is present, long channelData messages on Discord (and the same copied logic in Google Chat, Mattermost, MS Teams, Slack, and Synology Chat) can be split differently than configured or exceed a lowered configured limit, which is a behavior regression from the previous sendTextChunks path.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same reasoning as the Telegram adapter: sendPayload uses the adapter's own chunker/textChunkLimit as conservative defaults. Config-resolved overrides are the delivery layer's responsibility in deliver.ts. Discord's adapter doesn't define a chunker (null), so sendPayload passes text through unchunked — which is correct because Discord's sendText handles its own chunking internally.

@nohat
nohat force-pushed the lifecycle/adapter-sendpayload-batch-b-v2 branch from 9388e03 to 710eb1c Compare March 2, 2026 17:41

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 710eb1cd1a

ℹ️ 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".

Comment on lines +412 to +413
const limit = outbound.textChunkLimit;
const chunks = limit && outbound.chunker ? outbound.chunker(text, limit) : [text];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Respect configured chunk overrides in sendPayload

sendPayload is chunking with outbound.textChunkLimit and outbound.chunker directly, which bypasses the per-channel/per-account chunk settings that deliverOutboundPayloadsCore resolves (including textChunkLimit overrides and chunkMode: "newline") before normal sendText delivery. Since channelData payloads now take this sendPayload path, Google Chat deliveries with custom chunk config can be split at the hardcoded default instead of the configured values, causing oversized chunks or different formatting; the same copied pattern appears in the Mattermost and MS Teams adapters added in this commit.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By design — same reasoning as the other adapters. sendPayload uses the adapter's declared textChunkLimit (4000) and chunker as conservative defaults. Per-channel/per-account config overrides are resolved by the delivery layer in deliver.ts, not by individual adapters. The adapter's sendPayload provides a safe fallback that prevents oversized sends, while the delivery layer can apply stricter limits when configured.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4c57c528c4

ℹ️ 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".

Comment on lines +28 to +30
for (let i = 1; i < urls.length; i++) {
lastResult = await msteamsOutbound.sendMedia!({
...ctx,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep sendPayload loops cancellable once delivery starts

This new sendPayload implementation performs its own per-item loop (mediaUrls and chunked text) but never checks for cancellation between sends, so a payload with channelData and multiple attachments/chunks will continue sending after the abort signal fires. In the previous path for these adapters, deliverOutboundPayloads performed throwIfAborted(...) before each chunk/media send, so cancellation could stop mid-payload; after this change, that safeguard is bypassed for channelData deliveries because the loop moved into the adapter.

Useful? React with 👍 / 👎.

@nohat
nohat force-pushed the lifecycle/adapter-sendpayload-batch-b-v2 branch from 4c57c52 to edf3ad5 Compare March 5, 2026 16:37

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: edf3ad5cbf

ℹ️ 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".

Comment on lines +393 to +394
if (!text && urls.length === 0) {
return { channel: "googlechat", messageId: "" };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle channelData-only payloads instead of no-op success

This guard treats any payload with empty text and no media URLs as "empty" even when payload.channelData is present, so channelData-only messages are dropped and still reported as sent (messageId: ""). In deliverOutboundPayloads, the sendPayload branch always records success after the adapter returns, so this path silently acknowledges undelivered channelData payloads and prevents retry/visibility for integrations that rely on channelData content.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By design — Google Chat has no API for sending channelData-only messages (channelData is metadata attached to text/media). A payload with only channelData and no text or media has nothing sendable, so the no-op return is correct. The delivery layer preserves channelData on the final text/media chunk when both exist.

Comment on lines +402 to +403
for (let i = 1; i < urls.length; i++) {
lastResult = await googlechatPlugin.outbound!.sendMedia!({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-check abort signal inside multi-send payload loops

Once sendPayload enters this loop, it keeps sending remaining media/chunks without any cancellation check, so an abort that fires mid-delivery will not stop subsequent sends. deliverOutboundPayloads only calls throwIfAborted before invoking handler.sendPayload, so moving the per-item loop into adapters regresses mid-payload cancellation behavior for channelData deliveries with multiple attachments or chunks.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — ChannelOutboundContext intentionally does not expose abortSignal; cancellation is handled at the delivery layer (throwIfAborted before each sendPayload invocation). Adding abort signal to the adapter context would be a cross-cutting type change across all 23 adapters. Filed as future work if finer-grained mid-payload cancellation is needed.

@BradGroux BradGroux self-assigned this Mar 10, 2026
@BradGroux

Copy link
Copy Markdown
Contributor

Hi @nohat — thanks for the submission. I’m the new Microsoft Teams maintainer for OpenClaw. Please give me a day or two to work through the open Teams backlog. Also, join the Twitter community for daily MS Teams feedback + updates: https://x.com/i/communities/2031170403607974228

1 similar comment
@BradGroux

Copy link
Copy Markdown
Contributor

Hi @nohat — thanks for the submission. I’m the new Microsoft Teams maintainer for OpenClaw. Please give me a day or two to work through the open Teams backlog. Also, join the Twitter community for daily MS Teams feedback + updates: https://x.com/i/communities/2031170403607974228

@clawsweeper

clawsweeper Bot commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

Codex review: found issues before merge.

What this changes:

The PR branch adds per-adapter sendPayload methods plus colocated delegation/chunking tests for Discord, Google Chat, Mattermost, Microsoft Teams, Slack, and Synology Chat.

Maintainer follow-up before merge:

This is an open, broad implementation PR that needs maintainer rebase/review or an author rewrite; it is not a safe automated replacement job because it spans multiple channel plugins and overlaps active maintainer backlog.

Security review:

Security review cleared: The PR diff only changes channel adapter code and colocated tests, with no CI, dependency, lockfile, package publishing, secret-handling, or artifact-download changes.

Review findings:

  • [P2] Preserve reply fanout in sendPayload loops — extensions/msteams/src/outbound.ts:22-32
  • [P2] Pass formatting context to adapter chunkers — extensions/msteams/src/outbound.ts:37
  • [P2] Break the Synology self-reference cycle — extensions/synology-chat/src/channel.ts:40
Review details

Best possible solution:

Rebase on current main, drop the obsolete Discord and Slack changes, and implement Google Chat, Mattermost, Microsoft Teams, and Synology Chat sendPayload support using sendTextMediaPayload or equivalent contract-preserving adapter logic with focused payload contract tests.

Do we have a high-confidence way to reproduce the issue?

Yes. The feature gap is source-verifiable with rg on current main, and the review findings are reproducible with focused unit tests that call the proposed sendPayload with implicit reply context, multiple media/chunks, and formatting options.

Is this the best way to solve the issue?

No. The copied per-adapter loops are not the best current solution because main now has shared helper semantics for reply fanout, formatting-aware chunking, media ordering, and no-op identity handling.

Full review comments:

  • [P2] Preserve reply fanout in sendPayload loops — extensions/msteams/src/outbound.ts:22-32
    The new loop forwards { ...ctx } to every media send, so an implicit single-use replyToId is reused for each attachment instead of being consumed after the first send. Use the shared helper or createReplyToFanout so later media/chunks do not all thread to the original message.
    Confidence: 0.86
  • [P2] Pass formatting context to adapter chunkers — extensions/msteams/src/outbound.ts:37
    This chunks text with msteamsOutbound.chunker(text, limit) but drops ctx.formatting, while the current helper passes formatting options through. Rich payload deliveries can ignore configured formatting constraints such as line limits unless the formatting context is threaded through.
    Confidence: 0.82
  • [P2] Break the Synology self-reference cycle — extensions/synology-chat/src/channel.ts:40
    Changing the factory to infer const plugin = { ... } while nested sendPayload closes over plugin.outbound creates a self-referential initializer under TypeScript inference. Add an explicit ChannelPlugin annotation or route through local send functions to avoid TS7022-style failures.
    Confidence: 0.74

Overall correctness: patch is incorrect
Overall confidence: 0.84

Acceptance criteria:

  • pnpm test extensions/googlechat/src/channel.test.ts extensions/mattermost/src/channel.test.ts extensions/msteams/src/outbound.test.ts extensions/synology-chat/src/channel.test.ts extensions/discord/src/outbound-payload.contract.test.ts extensions/slack/src/outbound-payload.test.ts src/plugin-sdk/reply-payload.test.ts src/infra/outbound/deliver.test.ts
  • pnpm check:changed

What I checked:

  • current-main-discord-slack-covered: Current main already routes Discord through discordOutbound.sendPayload and Slack through slackOutbound.sendPayload, so those two parts of the PR are obsolete after later adapter refactors. (extensions/discord/src/outbound-adapter.ts:130, 9061d1e4c3ee)
  • remaining-adapter-gaps: Current main has no sendPayload definitions under Google Chat, Mattermost, Microsoft Teams, or Synology Chat; their outbound surfaces still expose text/media methods only. (extensions/msteams/src/outbound.ts:7, 9061d1e4c3ee)
  • shared-helper-semantics: The current shared sendTextMediaPayload helper consumes implicit reply targets through createReplyToFanout, preserves audioAsVoice, and passes formatting context into adapter chunkers. (src/plugin-sdk/reply-payload.ts:283, 9061d1e4c3ee)
  • delivery-path-contract: The current delivery path calls sendPayload for rich payloads and ignores no-op results without message identity, so adapter implementations need to preserve payload contract behavior rather than return synthetic empty successes for sendable rich payloads. (src/infra/outbound/deliver.ts:1123, 9061d1e4c3ee)
  • pr-diff-copied-loop: The PR head adds copied loops that forward the same context to every media/text send and call adapter chunkers without the formatting context now used by the shared helper. (extensions/msteams/src/outbound.ts:13, edf3ad5cbfc6)
  • maintainer-context: The discussion includes prior bot review findings on reply fanout, formatting-aware chunking, and Synology self-reference, plus BradGroux's assignment/comment that this overlaps the active Microsoft Teams maintainer backlog.

Likely related people:

  • clawsweeper[bot]: The current checkout blames the shared payload helper and delivery-path sendPayload branch to commit 59e7053, and a later Slack payload fix touched the same current-main Slack adapter surface. (role: recent maintainer; confidence: medium; commits: 59e7053464e1, fbc145440f28; files: src/plugin-sdk/reply-payload.ts, src/infra/outbound/deliver.ts, extensions/slack/src/outbound-adapter.ts)
  • BradGroux: The PR discussion says BradGroux is the Microsoft Teams maintainer, assigns this PR into that backlog, and the refreshed Microsoft tracker lists this PR under the open MS Teams PR queue. (role: adjacent owner; confidence: medium; files: extensions/msteams/src/outbound.ts)

Remaining risk / open question:

  • The branch is currently stale/unmergeable against main, so exact conflict resolution may change the final touched lines.
  • No live channel delivery or test execution was run in this read-only review; the verdict is based on source, test-contract, PR diff, and discussion inspection.

Codex review notes: model gpt-5.5, reasoning high; reviewed against 9061d1e4c3ee.

@vincentkoc vincentkoc added the stale Marked as stale due to inactivity label Apr 26, 2026
@vincentkoc

Copy link
Copy Markdown
Member

This assigned pull request has been marked as stale after being open for 27 days.
Please add updates or it will be closed.

@openclaw-barnacle openclaw-barnacle Bot removed the stale Marked as stale due to inactivity label Apr 27, 2026
@openclaw-barnacle

Copy link
Copy Markdown

This assigned pull request has been automatically marked as stale after being open for 27 days.
Please add updates or it will be closed.

@openclaw-barnacle openclaw-barnacle Bot added stale Marked as stale due to inactivity and removed stale Marked as stale due to inactivity labels Apr 27, 2026
@openclaw-barnacle

Copy link
Copy Markdown

This assigned pull request has been automatically marked as stale after being open for 27 days.
Please add updates or it will be closed.

@openclaw-barnacle openclaw-barnacle Bot added stale Marked as stale due to inactivity and removed stale Marked as stale due to inactivity labels Apr 28, 2026
@openclaw-barnacle

Copy link
Copy Markdown

This assigned pull request has been automatically marked as stale after being open for 27 days.
Please add updates or it will be closed.

@openclaw-barnacle openclaw-barnacle Bot added the stale Marked as stale due to inactivity label Apr 29, 2026
@openclaw-barnacle openclaw-barnacle Bot removed the stale Marked as stale due to inactivity label Apr 30, 2026
@openclaw-barnacle

Copy link
Copy Markdown

This assigned pull request has been automatically marked as stale after being open for 27 days.
Please add updates or it will be closed.

@openclaw-barnacle openclaw-barnacle Bot added the stale Marked as stale due to inactivity label Apr 30, 2026
@barnacle-openclaw

Copy link
Copy Markdown

Closing due to inactivity.
If you believe this PR should be revived, post in #clawtributors on Discord to talk to a maintainer.
That channel is the escape hatch for high-quality PRs that get auto-closed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: discord Channel integration: discord channel: googlechat Channel integration: googlechat channel: mattermost Channel integration: mattermost channel: msteams Channel integration: msteams channel: slack Channel integration: slack size: L stale Marked as stale due to inactivity

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants