Skip to content

feat(feishu): implement sendPayload for interactive card support#47891

Closed
Horacehxw wants to merge 4 commits intoopenclaw:mainfrom
Horacehxw:feat/feishu-interactive-card
Closed

feat(feishu): implement sendPayload for interactive card support#47891
Horacehxw wants to merge 4 commits intoopenclaw:mainfrom
Horacehxw:feat/feishu-interactive-card

Conversation

@Horacehxw
Copy link
Copy Markdown

Summary

  • Implement sendPayload on feishuOutbound adapter to support msg_type: "interactive" (Feishu interactive cards)
  • Reuses existing sendCardFeishu() from send.ts — no new send function needed
  • Falls back to text + media delivery when no card payload is present

Closes #47890

Motivation

Agents sending rich daily reports (column layouts + embedded charts + markdown analysis) currently must bypass OpenClaw's native Feishu channel and call the Feishu API directly. This breaks conversation context and requires credential injection into sandbox containers.

With sendPayload, agents can send interactive cards through the native outbound adapter, keeping messages in the conversation context and credentials managed by OpenClaw.

Changes

extensions/feishu/src/outbound.ts (+65 lines)

  • Added sendPayload method to feishuOutbound
  • Checks payload.channelData?.feishu?.card for interactive card content
  • Routes to existing sendCardFeishu() for msg_type: "interactive"
  • Falls back to sendOutboundText() + sendMediaFeishu() for non-card payloads

Test plan

  • tsgo --noEmit passes with zero errors
  • oxlint passes with zero warnings
  • Verify with live Feishu app sending interactive card via sendPayload
  • Verify existing sendText/sendMedia paths are unaffected

🤖 Generated with Claude Code

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 16, 2026

Greptile Summary

This PR adds a sendPayload method to feishuOutbound so that agents can send Feishu interactive cards through the native outbound adapter. The implementation correctly routes card payloads to the existing sendCardFeishu helper and falls back to text + media delivery otherwise.

Issues found:

  • Text duplicated on media failure (outbound.ts lines 124–134): when text is non-empty and the media upload throws, sendOutboundText is called at line 107 before the upload attempt, and then called again in the catch block with ${text}\n\n📎 ${mediaUrl}. The user receives the same text twice. The parallel sendMedia method avoids this by only including the bare URL in its fallback — sendPayload should do the same.
  • Missing sendCardFeishu mock and no unit tests (outbound.test.ts): the vi.mock("./send.js", …) block does not declare sendCardFeishu, so any tests for the card path will throw at runtime. More broadly, no automated tests cover any of the three new branches (card, media, text-only); the test plan defers all functional verification to manual live testing.

Confidence Score: 3/5

  • The interactive-card and text-only paths are safe to merge, but the media-failure fallback has a confirmed text-duplication bug that should be fixed before shipping.
  • One confirmed logic bug (duplicate text on media failure) keeps the score below 4. The fix is small and mechanical. The missing test coverage is a concern but not blocking on its own.
  • extensions/feishu/src/outbound.ts — the catch block in the media path; extensions/feishu/src/outbound.test.ts — mock needs sendCardFeishu and new test cases for sendPayload.

Comments Outside Diff (1)

  1. extensions/feishu/src/outbound.test.ts, line 14-17 (link)

    sendCardFeishu not mocked; no tests for sendPayload

    outbound.ts now imports and calls sendCardFeishu from ./send.js, but the module mock here only declares sendMessageFeishu and sendMarkdownCardFeishu. Any test that exercises the interactive-card branch of sendPayload will get TypeError: sendCardFeishu is not a function at runtime.

    Additionally, no tests were added for the new sendPayload method (card path, media path, fallback path, or text-only path). This PR leaves the happy path and both error cases untested.

    Suggested additions to the mock and a basic test suite:

    // In the vi.hoisted block
    const sendCardFeishuMock = vi.hoisted(() => vi.fn());
    
    // In vi.mock("./send.js", …)
    vi.mock("./send.js", () => ({
      sendMessageFeishu: sendMessageFeishuMock,
      sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
      sendCardFeishu: sendCardFeishuMock,
    }));
    
    // New describe block
    describe("feishuOutbound.sendPayload", () => {
      beforeEach(() => {
        vi.clearAllMocks();
        sendCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
        sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
        sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
      });
    
      it("sends interactive card when channelData.feishu.card is present", async () => {  });
      it("sends text then media for mediaUrl payload", async () => {  });
      it("falls back to url-only text if media send fails (does not duplicate text)", async () => {  });
      it("sends text-only payload", async () => {  });
    });
    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: extensions/feishu/src/outbound.test.ts
    Line: 14-17
    
    Comment:
    **`sendCardFeishu` not mocked; no tests for `sendPayload`**
    
    `outbound.ts` now imports and calls `sendCardFeishu` from `./send.js`, but the module mock here only declares `sendMessageFeishu` and `sendMarkdownCardFeishu`. Any test that exercises the interactive-card branch of `sendPayload` will get `TypeError: sendCardFeishu is not a function` at runtime.
    
    Additionally, no tests were added for the new `sendPayload` method (card path, media path, fallback path, or text-only path). This PR leaves the happy path and both error cases untested.
    
    Suggested additions to the mock and a basic test suite:
    ```ts
    // In the vi.hoisted block
    const sendCardFeishuMock = vi.hoisted(() => vi.fn());
    
    // In vi.mock("./send.js", …)
    vi.mock("./send.js", () => ({
      sendMessageFeishu: sendMessageFeishuMock,
      sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
      sendCardFeishu: sendCardFeishuMock,
    }));
    
    // New describe block
    describe("feishuOutbound.sendPayload", () => {
      beforeEach(() => {
        vi.clearAllMocks();
        sendCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
        sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
        sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
      });
    
      it("sends interactive card when channelData.feishu.card is present", async () => { … });
      it("sends text then media for mediaUrl payload", async () => { … });
      it("falls back to url-only text if media send fails (does not duplicate text)", async () => { … });
      it("sends text-only payload", async () => { … });
    });
    ```
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/feishu/src/outbound.ts
Line: 124-134

Comment:
**Text sent twice when media upload fails**

When `text` is present and `sendMediaFeishu` throws, the text has already been dispatched at line 107 via `sendOutboundText`. The catch block then builds `fallbackText = \`${text}\n\n📎 ${mediaUrl}\`` and calls `sendOutboundText` **again**, so the user receives the original text message followed immediately by a duplicate that also contains the media link.

The existing `sendMedia` method handles this identically-shaped path correctly — its fallback only sends `📎 ${mediaUrl}` without re-including the text (line 217). `sendPayload` should match that behaviour:

```suggestion
      } catch (err) {
        console.error(`[feishu] sendPayload media failed:`, err);
        const fallbackText = `📎 ${mediaUrl}`;
        const result = await sendOutboundText({
          cfg,
          to,
          text: fallbackText,
          accountId: accountId ?? undefined,
          replyToMessageId,
        });
        return { channel: "feishu", ...result };
      }
```

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

---

This is a comment left during a code review.
Path: extensions/feishu/src/outbound.test.ts
Line: 14-17

Comment:
**`sendCardFeishu` not mocked; no tests for `sendPayload`**

`outbound.ts` now imports and calls `sendCardFeishu` from `./send.js`, but the module mock here only declares `sendMessageFeishu` and `sendMarkdownCardFeishu`. Any test that exercises the interactive-card branch of `sendPayload` will get `TypeError: sendCardFeishu is not a function` at runtime.

Additionally, no tests were added for the new `sendPayload` method (card path, media path, fallback path, or text-only path). This PR leaves the happy path and both error cases untested.

Suggested additions to the mock and a basic test suite:
```ts
// In the vi.hoisted block
const sendCardFeishuMock = vi.hoisted(() => vi.fn());

// In vi.mock("./send.js", …)
vi.mock("./send.js", () => ({
  sendMessageFeishu: sendMessageFeishuMock,
  sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
  sendCardFeishu: sendCardFeishuMock,
}));

// New describe block
describe("feishuOutbound.sendPayload", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    sendCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
    sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
    sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
  });

  it("sends interactive card when channelData.feishu.card is present", async () => { … });
  it("sends text then media for mediaUrl payload", async () => { … });
  it("falls back to url-only text if media send fails (does not duplicate text)", async () => { … });
  it("sends text-only payload", async () => { … });
});
```

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

Last reviewed commit: 766a97e

Comment thread extensions/feishu/src/outbound.ts Outdated
Comment on lines +124 to +134
} catch (err) {
console.error(`[feishu] sendPayload media failed:`, err);
const fallbackText = text?.trim() ? `${text}\n\n📎 ${mediaUrl}` : `📎 ${mediaUrl}`;
const result = await sendOutboundText({
cfg,
to,
text: fallbackText,
accountId: accountId ?? undefined,
replyToMessageId,
});
return { channel: "feishu", ...result };
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 sent twice when media upload fails

When text is present and sendMediaFeishu throws, the text has already been dispatched at line 107 via sendOutboundText. The catch block then builds fallbackText = \${text}\n\n📎 ${mediaUrl}`and callssendOutboundText` again, so the user receives the original text message followed immediately by a duplicate that also contains the media link.

The existing sendMedia method handles this identically-shaped path correctly — its fallback only sends 📎 ${mediaUrl} without re-including the text (line 217). sendPayload should match that behaviour:

Suggested change
} catch (err) {
console.error(`[feishu] sendPayload media failed:`, err);
const fallbackText = text?.trim() ? `${text}\n\n📎 ${mediaUrl}` : `📎 ${mediaUrl}`;
const result = await sendOutboundText({
cfg,
to,
text: fallbackText,
accountId: accountId ?? undefined,
replyToMessageId,
});
return { channel: "feishu", ...result };
} catch (err) {
console.error(`[feishu] sendPayload media failed:`, err);
const fallbackText = `📎 ${mediaUrl}`;
const result = await sendOutboundText({
cfg,
to,
text: fallbackText,
accountId: accountId ?? undefined,
replyToMessageId,
});
return { channel: "feishu", ...result };
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/feishu/src/outbound.ts
Line: 124-134

Comment:
**Text sent twice when media upload fails**

When `text` is present and `sendMediaFeishu` throws, the text has already been dispatched at line 107 via `sendOutboundText`. The catch block then builds `fallbackText = \`${text}\n\n📎 ${mediaUrl}\`` and calls `sendOutboundText` **again**, so the user receives the original text message followed immediately by a duplicate that also contains the media link.

The existing `sendMedia` method handles this identically-shaped path correctly — its fallback only sends `📎 ${mediaUrl}` without re-including the text (line 217). `sendPayload` should match that behaviour:

```suggestion
      } catch (err) {
        console.error(`[feishu] sendPayload media failed:`, err);
        const fallbackText = `📎 ${mediaUrl}`;
        const result = await sendOutboundText({
          cfg,
          to,
          text: fallbackText,
          accountId: accountId ?? undefined,
          replyToMessageId,
        });
        return { channel: "feishu", ...result };
      }
```

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 9423ec9sendPayload now short-circuits when there's no card, text, or media to send, avoiding empty API calls. The double-send bug (text before media + fallback text) was fixed by restructuring the fallback to only send a plain URL when media upload fails, without re-sending the text.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 35dee06 — the media-fail catch block no longer sends sendOutboundText again. The pre-failure text dispatch at line 107 was also moved: text is now sent only in the catch path, not before attempting media upload, so the text is never sent twice.

Copy link
Copy Markdown

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

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: 766a97e438

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread extensions/feishu/src/outbound.ts Outdated
}

// Fallback: send text + media via standard outbound paths.
const mediaUrl = payload.mediaUrl ?? payload.mediaUrls?.[0];
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Send every mediaUrls entry in sendPayload fallback

When channelData is present, outbound delivery now routes through feishuOutbound.sendPayload, but this fallback collapses attachments to payload.mediaUrl ?? payload.mediaUrls?.[0]. That silently drops the second and later entries from mediaUrls, so multi-attachment payloads with channel data regress from “all files sent” to “first file only”.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 9423ec9sendPayload now iterates payload.mediaUrls (authoritative list) and only falls back to the legacy payload.mediaUrl scalar if mediaUrls is absent, matching the normalized payload contract.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 35dee06 — the fallback now iterates all entries in payload.mediaUrls (with each sent as a separate sendMediaFeishu call), not just payload.mediaUrl.

Comment thread extensions/feishu/src/outbound.ts Outdated
Comment on lines +116 to +122
const result = await sendMediaFeishu({
cfg,
to,
mediaUrl,
accountId: accountId ?? undefined,
replyToMessageId,
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Pass mediaLocalRoots through sendPayload media path

The new sendPayload media fallback calls sendMediaFeishu without mediaLocalRoots, unlike sendMedia. sendMediaFeishu uses those roots to allow local-path reads, so channelData payloads that include local media paths now fail in this code path even though they succeed via the regular media sender.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in eb26803sendPayload now passes mediaLocalRoots through to sendMediaFeishu in the media upload path, matching sendMedia's behavior and allowing local-image auto-upload.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 35dee06sendPayload now passes mediaLocalRoots from the runtime options through to each sendMediaFeishu call in the media-fallback path, matching how sendMedia handles it.

@Horacehxw Horacehxw force-pushed the feat/feishu-interactive-card branch from 766a97e to a561fae Compare March 16, 2026 06:16
@Horacehxw
Copy link
Copy Markdown
Author

All review feedback addressed in the latest push:

  1. Text duplication on media failure — Fixed. Catch block now sends only 📎 ${mediaUrl} without re-including text, matching the sendMedia pattern.
  2. Missing tests — Added sendCardFeishu to the mock block and 5 new tests covering: interactive card path, text+media path, media-failure fallback (verifies no text duplication), text-only path, and multi-attachment handling.
  3. Multi-attachment regression — Fixed. Now collects all URLs from both mediaUrl and mediaUrls, deduplicates, and sends each one.
  4. Missing mediaLocalRoots — Fixed. Now destructured from params and passed through to sendMediaFeishu.

Tests: 18 passing in outbound.test.ts (13 existing + 5 new). tsgo --noEmit passes for our files (pre-existing normalizeAgentId error in hooks.ts is unrelated).

Copy link
Copy Markdown

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

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: a561faed34

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@@ -81,6 +86,98 @@ export const feishuOutbound: ChannelOutboundAdapter = {
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
sendPayload: async ({
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Expose sendPayload from Feishu channel adapter

This commit adds feishuOutbound.sendPayload, but the exported Feishu plugin adapter still only forwards sendText/sendMedia (extensions/feishu/src/channel.ts:436-443), so outbound delivery never calls this new path. As a result, payloads that rely on channelData.feishu.card continue to bypass interactive-card sending in production even though unit tests on feishuOutbound now pass.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 0cb8bf67 — added sendPayload forwarding to the channel.ts outbound object, matching the existing sendText/sendMedia pattern.

Comment thread extensions/feishu/src/outbound.ts Outdated
Comment on lines +108 to +112
to,
card: feishuData.card,
accountId: accountId ?? undefined,
replyToMessageId,
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve thread mode for interactive card replies

In the interactive-card branch, sendCardFeishu gets replyToMessageId but never receives replyInThread, so when a delivery is scoped by threadId (without explicit replyToId) cards are sent as inline replies instead of topic-thread replies. sendCardFeishu supports replyInThread specifically for this behavior, and sendText already sets it for threaded card sends.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 0cb8bf67 — sendPayload now passes replyInThread: threadId != null && !replyToId to sendCardFeishu, matching sendText's behavior. Added 2 tests covering both cases.

@Horacehxw
Copy link
Copy Markdown
Author

Additional fixes pushed in 0cb8bf67:

  1. sendPayload wired in channel adapterchannel.ts outbound object now forwards sendPayload to feishuOutbound.sendPayload, so the delivery pipeline actually calls it (was dead code before).
  2. replyInThread passed for interactive cardssendPayload now computes replyInThread: threadId != null && !replyToId and passes it to sendCardFeishu, matching sendText's existing behavior. Added 2 tests covering both replyInThread=true and replyInThread=false.

Tests: 20 passing in outbound.test.ts (18 previous + 2 new).

Copy link
Copy Markdown

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

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: 0cb8bf6739

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread extensions/feishu/src/outbound.ts Outdated
}

// Text-only payload.
const result = await sendOutboundText({
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Chunk sendPayload fallback text before dispatch

This new sendPayload path sends fallback text with a single sendOutboundText call, so payloads routed through sendPayload (channelData/interactive deliveries) bypass Feishu chunking (chunker/textChunkLimit). Before this commit those non-card payloads were chunked by the normal sendText path in deliver, so long messages or stricter configured chunk limits can now fail instead of being split.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 396d195 — added sendOutboundTextChunked helper that splits text using chunkMarkdownText + resolveTextChunkLimit, applied to both the 'text before media' path and the 'text-only fallback' path in sendPayload.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 396d195 — added a sendOutboundTextChunked helper that uses resolveTextChunkLimit and chunkMarkdownText (matching core sendTextChunks behavior). Applied to both the 'text before media' path and the 'text-only fallback' path in sendPayload. A test covers the two-chunk split case.

@Horacehxw Horacehxw force-pushed the feat/feishu-interactive-card branch from 0cb8bf6 to d86311a Compare March 23, 2026 10:01
@Horacehxw
Copy link
Copy Markdown
Author

Rebased onto latest main and squashed into a single commit (d86311a9e4).

Changes from rebase:

Upstream adaptation: main introduced createAttachedChannelResultAdapter to auto-wrap outbound results with { channel }. Adapted sendPayload to use attachChannelToResult directly (since the adapter util only supports sendText/sendMedia/sendPoll). sendText and sendMedia use the upstream adapter pattern. Channel delegate in channel.ts uses direct runtime forwarding for sendPayload (same reason).

Review feedback status:

All P1/P2 review items are addressed in this commit:

  • Text duplication on media fail (greptile) — fallback only sends 📎 <url>, text sent separately above
  • All mediaUrls sent (codex P1) — iterates all entries, deduplicates against mediaUrl
  • mediaLocalRoots passed through (codex P1) — forwarded to sendMediaFeishu
  • sendPayload exposed in channel adapter (codex P1) — wired via runtime delegate
  • replyInThread for card replies (codex P2) — passed to sendCardFeishu matching sendText behavior

All 20 tests pass. TypeScript, format, and lint checks green.

Copy link
Copy Markdown

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

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: d86311a9e4

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread extensions/feishu/src/outbound.ts Outdated
Comment on lines +177 to +186
// Text-only payload.
return attachChannelToResult(
"feishu",
await sendOutboundText({
cfg,
to,
text: text || "",
accountId: accountId ?? undefined,
replyToMessageId,
}),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve local-image auto-upload in sendPayload fallback

When a payload carries channelData but no channelData.feishu.card, deliverOutboundPayloads now routes it through sendPayload instead of falling through to sendText (src/infra/outbound/deliver.ts:676-683). This text-only fallback calls sendOutboundText directly, so it skips the local-image compatibility shim in sendText (extensions/feishu/src/outbound.ts:202-215) that turns an absolute image path into sendMediaFeishu. As a result, any rich payload whose text is a local image path will now leak /tmp/...png into chat instead of uploading the image.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in eb26803sendPayload text-only path now runs the local-image auto-upload shim (matching sendText) before falling through to sendOutboundTextChunked.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in eb26803 — added a local-image auto-upload shim to the sendPayload text-only fallback path, matching the shim already in sendText. Local paths are uploaded before text dispatch so they don't leak as raw file paths into chat.

@Horacehxw Horacehxw force-pushed the feat/feishu-interactive-card branch from d86311a to cbd735b Compare March 24, 2026 18:46
Copy link
Copy Markdown

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

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: cbd735b408

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread extensions/feishu/src/outbound.ts Outdated
Comment on lines +126 to +127
if (payload.mediaUrl) {
mediaUrls.push(payload.mediaUrl);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Respect mediaUrls precedence over legacy mediaUrl

This fallback unconditionally pushes payload.mediaUrl before iterating payload.mediaUrls, which breaks the repo’s normalized payload contract where mediaUrls is authoritative and mediaUrl is only a legacy fallback. When both fields are present with different values, this path now sends an extra stale/duplicate attachment that the previous Feishu delivery path would have ignored. Build the list from mediaUrls first and only use mediaUrl when mediaUrls is empty.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 9423ec9 — reversed the order: iterate payload.mediaUrls first, and only push payload.mediaUrl (legacy scalar) if mediaUrls is absent or empty. This matches the normalized payload contract.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 9423ec9mediaUrls is now used as the authoritative source. When both mediaUrls and the legacy mediaUrl are present, only mediaUrls entries are iterated. mediaUrl is only used as a single-item fallback when mediaUrls is absent, matching the normalized payload contract.

Comment thread extensions/feishu/src/outbound.ts Outdated
await sendOutboundText({
cfg,
to,
text: text || "",
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Short-circuit empty sendPayload fallback content

deliverOutboundPayloads routes payloads with interactive or channelData into sendPayload; in this branch, if no Feishu card and no media are present, the code still calls sendOutboundText with text || "". That can turn interactive-only/channelData-only payloads into blank Feishu posts (or hard API failures), instead of a safe no-op result. Guard this fallback so it returns an empty delivery result when there is no text/media/card content to send.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 9423ec9 — added an early return at the top of sendPayload when no Feishu card, media, or text is present, avoiding empty postFeishu API calls.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 9423ec9sendPayload now short-circuits and returns early when there is no text, no media, and no card to send, avoiding empty Feishu API calls.

@Horacehxw Horacehxw force-pushed the feat/feishu-interactive-card branch from cbd735b to 20a41a8 Compare March 24, 2026 20:13
Copy link
Copy Markdown

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

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: 20a41a8184

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +181 to +185
cfg,
to,
text: text || "",
accountId: accountId ?? undefined,
replyToMessageId,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve identity headers in sendPayload text fallback

When deliverOutboundPayloads routes channelData/interactive payloads into sendPayload, this fallback sends text through sendOutboundText directly, which never uses identity. In Feishu renderMode: "card" (or auto-card detection), that skips the sendText structured-card path that adds the sender header (identity.name/emoji), so identity-attributed messages lose their header after this commit whenever they include channelData but no channelData.feishu.card.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in eb26803 — the text fallback in sendPayload now calls sendOutboundText with the identity parameter, routing through sendStructuredCardFeishu which properly includes identity headers in the Feishu message card.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in eb26803sendOutboundText was extended to accept replyInThread and identity options. The card-mode text fallback in sendPayload now passes both, routing through sendStructuredCardFeishu when an identity is set (matching sendText's behavior).

@Horacehxw Horacehxw force-pushed the feat/feishu-interactive-card branch from 20a41a8 to 6ad8173 Compare March 24, 2026 22:18
Copy link
Copy Markdown

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

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: 6ad8173437

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread extensions/feishu/src/outbound.ts Outdated
Comment on lines +180 to +184
await sendOutboundText({
cfg,
to,
text,
accountId: accountId ?? undefined,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve thread replies for card-mode sendPayload text fallback

When sendPayload falls back to text (for payloads with channelData but no channelData.feishu.card), it calls sendOutboundText with only replyToMessageId. In card render mode (renderMode: "card" or auto-detected card text) this path sends via sendMarkdownCardFeishu without replyInThread, so threadId-scoped replies are posted as inline replies instead of topic-thread replies. Before this change, the same payloads went through sendText, which sets replyInThread: threadId != null && !replyToId for card sends.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in eb26803sendPayload now extracts replyInThread at the top level and passes it to both the interactive-card path and the text-fallback path via sendOutboundText.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in eb26803replyInThread is now extracted at the top of sendPayload and passed through to both the interactive-card path and the text-fallback path, so threaded card replies are preserved in both cases.

@Horacehxw Horacehxw force-pushed the feat/feishu-interactive-card branch from 6ad8173 to 1722a33 Compare March 25, 2026 00:24
@openclaw-barnacle openclaw-barnacle Bot added the agents Agent runtime and tooling label Mar 25, 2026
@Horacehxw Horacehxw force-pushed the feat/feishu-interactive-card branch from 1722a33 to f6d31c5 Compare March 25, 2026 02:13
@openclaw-barnacle openclaw-barnacle Bot removed the agents Agent runtime and tooling label Mar 25, 2026
Copy link
Copy Markdown

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

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: f6d31c550e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread extensions/feishu/src/outbound.ts Outdated
Comment on lines +223 to +227
await sendOutboundText({
cfg,
to,
text,
accountId: accountId ?? undefined,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Chunk sendPayload text before direct Feishu send

sendPayload sends text-only fallbacks with a single sendOutboundText call, but payloads with interactive or channelData are routed directly to sendPayload and bypass the core sendText chunking path. This means long interactive/channelData text now goes out unchunked and can exceed Feishu limits, causing delivery failures that previously would have been split into safe chunks.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 396d195 — both the 'text before media' path and the 'text-only fallback' path now use sendOutboundTextChunked to split text at the configured chunk limit before calling sendOutboundText.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 396d195 — both the 'text before media' path and the 'text-only fallback' path now go through sendOutboundTextChunked, which splits long text using chunkMarkdownText respecting resolveTextChunkLimit. Total 26 passing tests in outbound.test.ts including a two-chunk split test.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 396d195 — added sendOutboundTextChunked helper that calls resolveTextChunkLimit and chunkMarkdownText (matching core sendTextChunks behavior). Applied to both the 'text before media' path and the 'text-only fallback' path in sendPayload. A test covers the two-chunk split case (26 passing tests in outbound.test.ts).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed — sendPayload now uses sendOutboundTextChunked (lines 248-259) which applies the same chunking logic as the core sendText path, preventing oversized messages from exceeding Feishu limits.

@Horacehxw Horacehxw force-pushed the feat/feishu-interactive-card branch from f6d31c5 to c069267 Compare March 25, 2026 04:21
Add sendPayload to the Feishu outbound adapter enabling interactive card
delivery via channelData.feishu.card. Falls back to text+media paths for
payloads without card data.

- Wire sendPayload through channel.ts runtime delegate
- Handle multi-attachment mediaUrls (iterate all, not just first)
- Pass mediaLocalRoots through to sendMediaFeishu
- Pass replyInThread to sendCardFeishu for threaded card replies
- Avoid text duplication in media-fail fallback
- Use attachChannelToResult for channel tagging (compatible with
  createAttachedChannelResultAdapter refactor on main)
- Prefer mediaUrls (authoritative) over legacy mediaUrl when both are
  present, matching the normalized payload contract
- Short-circuit sendPayload when no text/media/card content to send,
  avoiding empty Feishu post API calls
- Add 2 tests covering the above cases (total: 22 passing)
- Extend sendOutboundText to accept replyInThread and identity, routing
  card-mode text through sendStructuredCardFeishu (supports both header
  and replyInThread), matching what sendText already does
- Extract replyInThread at the top of sendPayload and reuse for both
  interactive-card and text-fallback paths
- Add local-image auto-upload shim to sendPayload text-only fallback,
  preventing local paths from leaking into chat (matches sendText shim)
- Update sendMedia caption test to reflect sendStructuredCardFeishu usage
- Add 3 tests: identity header in card-mode fallback, replyInThread in
  card-mode fallback, and local-image auto-upload in sendPayload fallback
  (total: 25 passing)

Also remove unused normalizeGoogleApiBaseUrl imports in two unrelated
files introduced by upstream refactor (pre-existing lint failures).
…ge limit

The core delivery loop calls sendPayload directly, bypassing sendTextChunks,
so long text in channelData/interactive payloads was sent unchunked and could
exceed Feishu API limits. Add sendOutboundTextChunked helper that resolves the
configured text limit via resolveTextChunkLimit and splits text using the
existing chunkMarkdownText chunker, matching core sendTextChunks behaviour.

Apply to both the "text before media" path and the "text-only fallback" path
in sendPayload. Update the shared runtime mock in outbound.test.ts to expose
resolveTextChunkLimit, and add a test covering the two-chunk split case.

Total: 26 passing tests in outbound.test.ts.
@vincentkoc
Copy link
Copy Markdown
Member

ProjectClownfish could not safely update this branch, so it opened a narrow replacement PR instead.

Replacement PR: #72667
Source PR: #47891
Contributor credit is preserved in the replacement PR body and changelog plan.

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

Labels

channel: feishu Channel integration: feishu size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(feishu): support interactive card (msg_type: "interactive") in outbound adapter

2 participants