Merge pull request #2265 from glifocat/fix/send-card-bridge
fix(channels): support display cards (send_card) in Chat SDK bridge
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { Adapter } from 'chat';
|
import type { Adapter, AdapterPostableMessage, RawMessage } from 'chat';
|
||||||
|
|
||||||
import { createChatSdkBridge, splitForLimit } from './chat-sdk-bridge.js';
|
import { createChatSdkBridge, splitForLimit } from './chat-sdk-bridge.js';
|
||||||
|
|
||||||
@@ -8,6 +8,20 @@ function stubAdapter(partial: Partial<Adapter>): Adapter {
|
|||||||
return { name: 'stub', ...partial } as unknown as Adapter;
|
return { name: 'stub', ...partial } as unknown as Adapter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PostCall {
|
||||||
|
threadId: string;
|
||||||
|
message: AdapterPostableMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePostCapture() {
|
||||||
|
const calls: PostCall[] = [];
|
||||||
|
const postMessage = async (threadId: string, message: AdapterPostableMessage): Promise<RawMessage<unknown>> => {
|
||||||
|
calls.push({ threadId, message });
|
||||||
|
return { id: 'msg-stub', threadId, raw: {} };
|
||||||
|
};
|
||||||
|
return { calls, postMessage };
|
||||||
|
}
|
||||||
|
|
||||||
describe('splitForLimit', () => {
|
describe('splitForLimit', () => {
|
||||||
it('returns a single chunk when text fits', () => {
|
it('returns a single chunk when text fits', () => {
|
||||||
expect(splitForLimit('short text', 100)).toEqual(['short text']);
|
expect(splitForLimit('short text', 100)).toEqual(['short text']);
|
||||||
@@ -78,3 +92,116 @@ describe('createChatSdkBridge', () => {
|
|||||||
expect(typeof bridge.subscribe).toBe('function');
|
expect(typeof bridge.subscribe).toBe('function');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('createChatSdkBridge.deliver — display cards (send_card)', () => {
|
||||||
|
// The send_card MCP tool writes outbound rows with `{ type: 'card', card, fallbackText }`.
|
||||||
|
// Before this branch existed the bridge silently dropped them: cards have no
|
||||||
|
// `text` / `markdown`, so the trailing fallback `if (text)` was false and the
|
||||||
|
// function returned without calling the adapter. These tests pin the contract
|
||||||
|
// for the dedicated card branch.
|
||||||
|
|
||||||
|
it('renders title, description, and string children, then posts via the adapter', async () => {
|
||||||
|
const { calls, postMessage } = makePostCapture();
|
||||||
|
const bridge = createChatSdkBridge({
|
||||||
|
adapter: stubAdapter({ postMessage }),
|
||||||
|
supportsThreads: false,
|
||||||
|
});
|
||||||
|
const id = await bridge.deliver('telegram:42', null, {
|
||||||
|
kind: 'chat-sdk',
|
||||||
|
content: {
|
||||||
|
type: 'card',
|
||||||
|
card: {
|
||||||
|
title: 'Daily',
|
||||||
|
description: 'Your plate today',
|
||||||
|
children: ['• item one', '• item two'],
|
||||||
|
},
|
||||||
|
fallbackText: 'Daily: your plate',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(id).toBe('msg-stub');
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
const msg = calls[0].message as { card?: unknown; fallbackText?: string };
|
||||||
|
expect(msg.fallbackText).toBe('Daily: your plate');
|
||||||
|
expect(msg.card).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops actions without url (send_card is fire-and-forget; non-URL buttons would have nowhere to land)', async () => {
|
||||||
|
const { calls, postMessage } = makePostCapture();
|
||||||
|
const bridge = createChatSdkBridge({
|
||||||
|
adapter: stubAdapter({ postMessage }),
|
||||||
|
supportsThreads: false,
|
||||||
|
});
|
||||||
|
await bridge.deliver('discord:guild:chan', null, {
|
||||||
|
kind: 'chat-sdk',
|
||||||
|
content: {
|
||||||
|
type: 'card',
|
||||||
|
card: {
|
||||||
|
title: 'Card',
|
||||||
|
description: 'has only label-only actions',
|
||||||
|
actions: [{ label: 'Add' }, { label: 'Skip' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
// Cast through the public Card shape to read the children we set
|
||||||
|
const msg = calls[0].message as { card?: { children?: Array<{ type?: string }> } };
|
||||||
|
const childTypes = (msg.card?.children ?? []).map((c) => c.type);
|
||||||
|
expect(childTypes).not.toContain('actions');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders url actions as link buttons inside an Actions row', async () => {
|
||||||
|
const { calls, postMessage } = makePostCapture();
|
||||||
|
const bridge = createChatSdkBridge({
|
||||||
|
adapter: stubAdapter({ postMessage }),
|
||||||
|
supportsThreads: false,
|
||||||
|
});
|
||||||
|
await bridge.deliver('discord:guild:chan', null, {
|
||||||
|
kind: 'chat-sdk',
|
||||||
|
content: {
|
||||||
|
type: 'card',
|
||||||
|
card: {
|
||||||
|
title: 'Docs',
|
||||||
|
actions: [{ label: 'Open', url: 'https://example.com' }, { label: 'No-link' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const msg = calls[0].message as {
|
||||||
|
card?: { children?: Array<{ type?: string; children?: Array<{ type?: string; url?: string }> }> };
|
||||||
|
};
|
||||||
|
const actionsRow = msg.card?.children?.find((c) => c.type === 'actions');
|
||||||
|
expect(actionsRow).toBeDefined();
|
||||||
|
const buttons = actionsRow?.children ?? [];
|
||||||
|
expect(buttons).toHaveLength(1);
|
||||||
|
expect(buttons[0].type).toBe('link-button');
|
||||||
|
expect(buttons[0].url).toBe('https://example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips delivery when the card has neither title nor body content', async () => {
|
||||||
|
const { calls, postMessage } = makePostCapture();
|
||||||
|
const bridge = createChatSdkBridge({
|
||||||
|
adapter: stubAdapter({ postMessage }),
|
||||||
|
supportsThreads: false,
|
||||||
|
});
|
||||||
|
const id = await bridge.deliver('telegram:42', null, {
|
||||||
|
kind: 'chat-sdk',
|
||||||
|
content: { type: 'card', card: {} },
|
||||||
|
});
|
||||||
|
expect(id).toBeUndefined();
|
||||||
|
expect(calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls through to the text branch for non-card chat-sdk payloads (no regression)', async () => {
|
||||||
|
const { calls, postMessage } = makePostCapture();
|
||||||
|
const bridge = createChatSdkBridge({
|
||||||
|
adapter: stubAdapter({ postMessage }),
|
||||||
|
supportsThreads: false,
|
||||||
|
});
|
||||||
|
await bridge.deliver('telegram:42', null, {
|
||||||
|
kind: 'chat-sdk',
|
||||||
|
content: { text: 'plain hello' },
|
||||||
|
});
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
const msg = calls[0].message as { markdown?: string };
|
||||||
|
expect(msg.markdown).toBe('plain hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
CardText,
|
CardText,
|
||||||
Actions,
|
Actions,
|
||||||
Button,
|
Button,
|
||||||
|
LinkButton,
|
||||||
|
type CardChild,
|
||||||
type Adapter,
|
type Adapter,
|
||||||
type ConcurrencyStrategy,
|
type ConcurrencyStrategy,
|
||||||
type Message as ChatMessage,
|
type Message as ChatMessage,
|
||||||
@@ -399,6 +401,59 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
|||||||
return result?.id;
|
return result?.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Display card (send_card MCP tool) — returns immediately, no callback flow.
|
||||||
|
// Non-URL actions are dropped: send_card's contract is fire-and-forget, so a
|
||||||
|
// callback button would have nowhere to land. URL actions render as link buttons.
|
||||||
|
if (content.type === 'card' && content.card && typeof content.card === 'object') {
|
||||||
|
const cardSpec = content.card as Record<string, unknown>;
|
||||||
|
const title = (cardSpec.title as string) || '';
|
||||||
|
const fallbackText = (content.fallbackText as string) || (cardSpec.description as string) || title || '';
|
||||||
|
|
||||||
|
const cardChildren: CardChild[] = [];
|
||||||
|
if (typeof cardSpec.description === 'string' && cardSpec.description) {
|
||||||
|
cardChildren.push(CardText(cardSpec.description));
|
||||||
|
}
|
||||||
|
if (Array.isArray(cardSpec.children)) {
|
||||||
|
for (const child of cardSpec.children) {
|
||||||
|
if (typeof child === 'string' && child) {
|
||||||
|
cardChildren.push(CardText(child));
|
||||||
|
} else if (
|
||||||
|
child &&
|
||||||
|
typeof child === 'object' &&
|
||||||
|
typeof (child as Record<string, unknown>).text === 'string'
|
||||||
|
) {
|
||||||
|
cardChildren.push(CardText((child as Record<string, string>).text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(cardSpec.actions)) {
|
||||||
|
const linkButtons = (cardSpec.actions as Array<Record<string, unknown>>)
|
||||||
|
.filter((a) => typeof a.url === 'string' && a.url && typeof a.label === 'string' && a.label)
|
||||||
|
.map((a) => {
|
||||||
|
const style = a.style;
|
||||||
|
const safeStyle: 'primary' | 'danger' | 'default' | undefined =
|
||||||
|
style === 'primary' || style === 'danger' || style === 'default' ? style : undefined;
|
||||||
|
return LinkButton({
|
||||||
|
label: a.label as string,
|
||||||
|
url: a.url as string,
|
||||||
|
style: safeStyle,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (linkButtons.length > 0) {
|
||||||
|
cardChildren.push(Actions(linkButtons));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardChildren.length === 0 && !title) {
|
||||||
|
log.warn('send_card payload empty, skipping delivery');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = Card({ title, children: cardChildren });
|
||||||
|
const result = await adapter.postMessage(tid, { card, fallbackText });
|
||||||
|
return result?.id;
|
||||||
|
}
|
||||||
|
|
||||||
// Normal message
|
// Normal message
|
||||||
const rawText = (content.markdown as string) || (content.text as string);
|
const rawText = (content.markdown as string) || (content.text as string);
|
||||||
const text = rawText ? transformText(rawText) : rawText;
|
const text = rawText ? transformText(rawText) : rawText;
|
||||||
|
|||||||
Reference in New Issue
Block a user