Skip to content

Commit 7e5b447

Browse files
fix(discord): remove duplicate text when posting card messages (#256)
* Resolves #246 * Add card test coverage for postMessage/editMessage and changeset * fix(discord): clear content on edit to prevent text persisting alongside card * test(discord): add card content edge case tests --------- Co-authored-by: dancer <josh@afterima.ge>
1 parent 53c6b68 commit 7e5b447

3 files changed

Lines changed: 155 additions & 6 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@chat-adapter/discord": patch
3+
---
4+
5+
Fix duplicate content display when sending card messages on Discord

packages/adapter-discord/src/index.test.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1563,6 +1563,77 @@ describe("postMessage", () => {
15631563

15641564
spy.mockRestore();
15651565
});
1566+
1567+
it("does not include content text when posting a card message", async () => {
1568+
const mockResponse = new Response(
1569+
JSON.stringify({
1570+
id: "msg005",
1571+
channel_id: "channel456",
1572+
content: "",
1573+
timestamp: "2021-01-01T00:00:00.000Z",
1574+
author: { id: "test-app-id", username: "bot" },
1575+
}),
1576+
{ status: 200, headers: { "Content-Type": "application/json" } }
1577+
);
1578+
const spy = vi
1579+
.spyOn(adapter as any, "discordFetch")
1580+
.mockResolvedValue(mockResponse);
1581+
1582+
const cardMessage = {
1583+
card: Card({
1584+
title: "Test Card",
1585+
children: [Actions([Button({ id: "btn1", label: "Click me" })])],
1586+
}),
1587+
};
1588+
1589+
await adapter.postMessage("discord:guild1:channel456", cardMessage);
1590+
1591+
const calledPayload = spy.mock.calls[0]?.[2] as {
1592+
content?: string;
1593+
embeds?: unknown[];
1594+
components?: unknown[];
1595+
};
1596+
expect(calledPayload.content).toBeUndefined();
1597+
expect(calledPayload.embeds).toBeDefined();
1598+
expect(calledPayload.components).toBeDefined();
1599+
1600+
spy.mockRestore();
1601+
});
1602+
1603+
it("uses card over text when message has both", async () => {
1604+
const mockResponse = new Response(
1605+
JSON.stringify({
1606+
id: "msg006",
1607+
channel_id: "channel456",
1608+
content: "",
1609+
timestamp: "2021-01-01T00:00:00.000Z",
1610+
author: { id: "test-app-id", username: "bot" },
1611+
}),
1612+
{ status: 200, headers: { "Content-Type": "application/json" } }
1613+
);
1614+
const spy = vi
1615+
.spyOn(adapter as any, "discordFetch")
1616+
.mockResolvedValue(mockResponse);
1617+
1618+
const mixedMessage = {
1619+
raw: "Some text that should be ignored",
1620+
card: Card({
1621+
title: "Card Wins",
1622+
children: [Actions([Button({ id: "btn1", label: "Click" })])],
1623+
}),
1624+
};
1625+
1626+
await adapter.postMessage("discord:guild1:channel456", mixedMessage);
1627+
1628+
const calledPayload = spy.mock.calls[0]?.[2] as {
1629+
content?: string;
1630+
embeds?: unknown[];
1631+
};
1632+
expect(calledPayload.content).toBeUndefined();
1633+
expect(calledPayload.embeds).toBeDefined();
1634+
1635+
spy.mockRestore();
1636+
});
15661637
});
15671638

15681639
// ============================================================================
@@ -1669,6 +1740,77 @@ describe("editMessage", () => {
16691740

16701741
spy.mockRestore();
16711742
});
1743+
1744+
it("clears content when editing to a card message", async () => {
1745+
const mockResponse = new Response(
1746+
JSON.stringify({
1747+
id: "msg004",
1748+
channel_id: "channel456",
1749+
content: "",
1750+
timestamp: "2021-01-01T00:00:00.000Z",
1751+
author: { id: "test-app-id", username: "bot" },
1752+
}),
1753+
{ status: 200, headers: { "Content-Type": "application/json" } }
1754+
);
1755+
const spy = vi
1756+
.spyOn(adapter as any, "discordFetch")
1757+
.mockResolvedValue(mockResponse);
1758+
1759+
const cardMessage = {
1760+
card: Card({
1761+
title: "Test Card",
1762+
children: [Actions([Button({ id: "btn1", label: "Click me" })])],
1763+
}),
1764+
};
1765+
1766+
await adapter.editMessage(
1767+
"discord:guild1:channel456",
1768+
"msg004",
1769+
cardMessage
1770+
);
1771+
1772+
const calledPayload = spy.mock.calls[0]?.[2] as {
1773+
content?: string;
1774+
embeds?: unknown[];
1775+
components?: unknown[];
1776+
};
1777+
expect(calledPayload.content).toBe("");
1778+
expect(calledPayload.embeds).toBeDefined();
1779+
expect(calledPayload.components).toBeDefined();
1780+
1781+
spy.mockRestore();
1782+
});
1783+
1784+
it("restores content when editing from card back to text", async () => {
1785+
const mockResponse = new Response(
1786+
JSON.stringify({
1787+
id: "msg004",
1788+
channel_id: "channel456",
1789+
content: "New text message",
1790+
timestamp: "2021-01-01T00:00:00.000Z",
1791+
author: { id: "test-app-id", username: "bot" },
1792+
}),
1793+
{ status: 200, headers: { "Content-Type": "application/json" } }
1794+
);
1795+
const spy = vi
1796+
.spyOn(adapter as any, "discordFetch")
1797+
.mockResolvedValue(mockResponse);
1798+
1799+
await adapter.editMessage("discord:guild1:channel456", "msg004", {
1800+
raw: "Updated to plain text",
1801+
});
1802+
1803+
const calledPayload = spy.mock.calls[0]?.[2] as {
1804+
content?: string;
1805+
embeds?: unknown[];
1806+
components?: unknown[];
1807+
};
1808+
expect(calledPayload.content).toBe("Updated to plain text");
1809+
expect(calledPayload.embeds).toBeUndefined();
1810+
expect(calledPayload.components).toBeUndefined();
1811+
1812+
spy.mockRestore();
1813+
});
16721814
});
16731815

16741816
// ============================================================================
@@ -2578,9 +2720,12 @@ describe("postChannelMessage", () => {
25782720
await adapter.postChannelMessage("discord:guild1:channel456", cardMessage);
25792721

25802722
const calledPayload = spy.mock.calls[0]?.[2] as {
2723+
content?: string;
25812724
embeds?: unknown[];
25822725
components?: unknown[];
25832726
};
2727+
// Should NOT include content text when card is present (avoids duplicate display)
2728+
expect(calledPayload.content).toBeUndefined();
25842729
expect(calledPayload.embeds).toBeDefined();
25852730
expect(Array.isArray(calledPayload.embeds)).toBe(true);
25862731
expect((calledPayload.embeds ?? []).length).toBeGreaterThan(0);

packages/adapter-discord/src/index.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ import {
5656
InteractionResponseType as DiscordInteractionResponseType,
5757
verifyKey,
5858
} from "discord-interactions";
59-
import { cardToDiscordPayload, cardToFallbackText } from "./cards";
59+
import { cardToDiscordPayload } from "./cards";
6060
import { DiscordFormatConverter } from "./markdown";
6161
import {
6262
type DiscordActionRow,
@@ -814,8 +814,7 @@ export class DiscordAdapter implements Adapter<DiscordThreadId, unknown> {
814814
const cardPayload = cardToDiscordPayload(card);
815815
embeds.push(...cardPayload.embeds);
816816
components.push(...cardPayload.components);
817-
// Fallback text (truncated to Discord's limit)
818-
payload.content = this.truncateContent(cardToFallbackText(card));
817+
// Don't include text - Discord shows both text and card if text is present
819818
} else {
820819
// Regular text message (truncated to Discord's limit)
821820
payload.content = this.truncateContent(
@@ -1178,8 +1177,8 @@ export class DiscordAdapter implements Adapter<DiscordThreadId, unknown> {
11781177
const cardPayload = cardToDiscordPayload(card);
11791178
embeds.push(...cardPayload.embeds);
11801179
components.push(...cardPayload.components);
1181-
// Fallback text (truncated to Discord's limit)
1182-
payload.content = this.truncateContent(cardToFallbackText(card));
1180+
// Clear content so old text doesn't persist alongside the card (Discord PATCH keeps omitted fields)
1181+
payload.content = "";
11831182
} else {
11841183
// Regular text message (truncated to Discord's limit)
11851184
payload.content = this.truncateContent(
@@ -2399,7 +2398,7 @@ export class DiscordAdapter implements Adapter<DiscordThreadId, unknown> {
23992398
const cardPayload = cardToDiscordPayload(card);
24002399
embeds.push(...cardPayload.embeds);
24012400
components.push(...cardPayload.components);
2402-
payload.content = this.truncateContent(cardToFallbackText(card));
2401+
// Don't include text - Discord shows both text and card if text is present
24032402
} else {
24042403
payload.content = this.truncateContent(
24052404
convertEmojiPlaceholders(

0 commit comments

Comments
 (0)