Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { expect } from 'chai';
import sinon from 'sinon';

import { AgentPlatformEnum } from '../../shared/enums/agent-platform.enum';
import { SLACK_MARKDOWN_TEXT_LIMIT } from '../../shared/util/slack-section-limits';
import { OutboundGateway } from './outbound.gateway';
import { OutboundDeliveryInfo } from './outbound-delivery-info.service';

describe('OutboundGateway branding', () => {
function makeGateway() {
const logger = {
setContext: sinon.stub(),
warn: sinon.stub(),
error: sinon.stub(),
};

const gateway = new OutboundGateway(
{} as any,
{} as any,
{} as any,
{ prepareContentForDelivery: sinon.stub().callsFake(async (content) => content) } as any,
{} as any,
new OutboundDeliveryInfo(),
logger as any
);

return gateway;
}

const brandedSlack = {
removeNovuBranding: false,
agentIdentifier: 'my-agent',
platform: AgentPlatformEnum.SLACK,
};

const unbrandedSlack = {
removeNovuBranding: true,
agentIdentifier: 'my-agent',
platform: AgentPlatformEnum.SLACK,
};

it('keeps branded text replies on the markdown path instead of converting to a card', () => {
const gateway = makeGateway();

const postable = (gateway as any).buildAdapterPostableMessage({ markdown: 'Hello **world**' }, brandedSlack);

expect(postable.card).to.equal(undefined);
expect(postable.markdown).to.be.a('string');
expect(postable.markdown).to.include('Hello **world**');
expect(postable.markdown).to.include('Powered by [Novu](');
expect(postable.markdown).to.include('go.novu.co/agent-powered');
});

it('leaves cards untouched so approval and connect cards keep their structure', () => {
const gateway = makeGateway();
const card = {
type: 'card',
children: [
{ type: 'text', content: 'Approve tool?' },
{
type: 'actions',
children: [{ type: 'button', id: 'approve', label: 'Approve' }],
},
],
};

const postable = (gateway as any).buildAdapterPostableMessage({ card }, brandedSlack);

expect(postable.card).to.deep.equal(card);
expect(postable.markdown).to.equal(undefined);
});

it('skips the watermark when removeNovuBranding is enabled', () => {
const gateway = makeGateway();

const postable = (gateway as any).buildAdapterPostableMessage({ markdown: 'Hello **world**' }, unbrandedSlack);

expect(postable).to.deep.equal({ markdown: 'Hello **world**', files: undefined });
});

it('falls back to a split card when Slack markdown exceeds the markdown_text limit', () => {
const gateway = makeGateway();
const oversized = `x`.repeat(SLACK_MARKDOWN_TEXT_LIMIT + 1);

const postable = (gateway as any).buildAdapterPostableMessage({ markdown: oversized }, brandedSlack);

expect(postable.markdown).to.equal(undefined);
expect(postable.card).to.be.an('object');
expect(postable.card.type).to.equal('card');
expect(postable.card.children.length).to.be.greaterThan(1);
expect(postable.card.children.every((child: { type: string }) => child.type === 'text')).to.equal(true);
expect(
postable.card.children.every((child: { content: string }) => child.content.length <= 3000)
).to.equal(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import { AgentPlatformEnum } from '../../shared/enums/agent-platform.enum';
import { extractCardPlainText } from '../../shared/util/card-plain-text.util';
import { toDeliveryError } from '../../shared/util/delivery-error.util';
import { esmImport } from '../../shared/util/esm-import';
import { buildBrandedMarkdownReply, contentHasPoweredByWatermark } from '../../shared/util/novu-powered-by-watermark';
import { splitOversizedSlackText } from '../../shared/util/slack-section-limits';
import { appendPoweredByWatermark, contentHasPoweredByWatermark } from '../../shared/util/novu-powered-by-watermark';
import { SLACK_MARKDOWN_TEXT_LIMIT, splitOversizedSlackText } from '../../shared/util/slack-section-limits';
import { type AgentActionTokenBinding, AgentActionTokenService } from '../action-token/agent-action-token.service';
import { AgentConversationService } from '../conversation/agent-conversation.service';
import { ChatInstanceRegistry, type ChatWithAdapters, type PlatformAdapters } from '../ingress/chat-instance.registry';
Expand Down Expand Up @@ -899,13 +899,9 @@ export class OutboundGateway {
}

/**
* Wraps outbound markdown replies with a muted "Powered by Novu" footnote for
* organizations that have not removed Novu branding (free plan). Pro and above
* can disable it via the existing `removeNovuBranding` org setting, resolved
* once per delivery by `AgentConfigResolver`.
*
* Only plain markdown replies are branded — cards/action messages are left
* untouched.
* Appends a "Powered by Novu" markdown footer for orgs that have not removed
* Novu branding. Pro and above can disable it via `removeNovuBranding`.
* Cards and action messages are left untouched.
*/
private applyOutboundBranding(content: ChatSdkReplyContent, branding: OutboundBrandingContext): ChatSdkReplyContent {
if (content.card || !content.markdown || contentHasPoweredByWatermark(content.markdown)) {
Expand All @@ -916,9 +912,10 @@ export class OutboundGateway {
return content;
}

const card = buildBrandedMarkdownReply(content.markdown, branding.agentIdentifier, branding.platform);

return { ...content, card, markdown: undefined };
return {
...content,
markdown: appendPoweredByWatermark(content.markdown, branding.agentIdentifier, branding.platform),
};
}

/**
Expand All @@ -941,8 +938,20 @@ export class OutboundGateway {
} as AdapterPostableMessage;
}

const markdown = deliveryContent.markdown ?? '';

if (branding.platform === AgentPlatformEnum.SLACK && markdown.length > SLACK_MARKDOWN_TEXT_LIMIT) {
return {
card: splitOversizedSlackText({
type: 'card',
children: [{ type: 'text', content: markdown }],
}),
...(deliveryContent.files?.length ? { files: deliveryContent.files } : {}),
} as AdapterPostableMessage;
}

return {
markdown: deliveryContent.markdown ?? '',
markdown,
files: deliveryContent.files,
} as AdapterPostableMessage;
}
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/app/agents/e2e/agent-slack-roundtrip.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ describe('Agent Slack Roundtrip - emulate.dev #novu-v2', () => {
expect(bridgeStub.calls.length, 'bridge executor invoked').to.be.gte(1);

// Plain markdown replies from orgs without `removeNovuBranding` get the
// free-plan "Powered by Novu" watermark appended as the last line, so the
// free-plan "Powered by Novu" watermark appended as a markdown footer, so the
// delivered text is no longer exactly the bridge reply.
const replyMessage = await pollFor(async () => {
const replies = await getThreadReplies(channel.id, threadTs);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { expect } from 'chai';

import { AgentPlatformEnum } from '../enums/agent-platform.enum';
import {
buildBrandedMarkdownReply,
appendPoweredByWatermark,
buildPoweredByWatermark,
contentHasPoweredByWatermark,
NOVU_AGENT_POWERED_URL,
Expand All @@ -17,37 +17,35 @@ describe('novu-powered-by-watermark', () => {
expect(watermark.length).to.be.greaterThan(NOVU_AGENT_POWERED_WATERMARK_TEXT.length);
});

it('returns attributed Slack mrkdwn link on Slack with only Novu linked', () => {
it('returns italic attributed markdown link on Slack with only Novu linked', () => {
const watermark = buildPoweredByWatermark('my-agent', AgentPlatformEnum.SLACK);

expect(watermark.startsWith('Powered by <')).to.equal(true);
expect(watermark).to.include('|Novu>');
expect(watermark).to.not.include('[Novu](');
expect(watermark.startsWith('_Powered by [Novu](')).to.equal(true);
expect(watermark.endsWith(')_')).to.equal(true);
expect(watermark).to.not.include('Powered by <');
expect(watermark).to.not.include('|Novu>');
expect(watermark).to.include(NOVU_AGENT_POWERED_URL);
expect(watermark).to.include('utm_source=my-agent');
expect(watermark).to.include('utm_channel=slack');
});

it('returns attributed markdown link on Teams with only Novu linked', () => {
it('returns italic attributed markdown link on Teams with only Novu linked', () => {
const watermark = buildPoweredByWatermark('my-agent', AgentPlatformEnum.TEAMS);

expect(watermark.startsWith('Powered by [Novu](')).to.equal(true);
expect(watermark.startsWith('_Powered by [Novu](')).to.equal(true);
expect(watermark).to.not.include('[Powered by Novu](');
expect(watermark).to.include(NOVU_AGENT_POWERED_URL);
expect(watermark).to.include('utm_source=my-agent');
expect(watermark).to.include('utm_channel=teams');
});

it('wraps markdown in a card with a muted watermark footnote', () => {
const card = buildBrandedMarkdownReply('Hello there', 'my-agent', AgentPlatformEnum.SLACK);
it('appends the watermark as a markdown footer', () => {
const branded = appendPoweredByWatermark('Hello there', 'my-agent', AgentPlatformEnum.SLACK);

expect(card.type).to.equal('card');
expect(card.children).to.have.length(2);
expect(card.children[0]).to.deep.equal({ type: 'text', content: 'Hello there' });
expect(card.children[1]?.type).to.equal('text');
expect((card.children[1] as { style?: string }).style).to.equal('muted');
expect((card.children[1] as { content?: string }).content).to.include('Powered by <');
expect((card.children[1] as { content?: string }).content).to.include('|Novu>');
expect(branded.startsWith('Hello there\n\n')).to.equal(true);
expect(branded).to.include('_Powered by [Novu](');
expect(branded).to.include(NOVU_AGENT_POWERED_URL);
expect(contentHasPoweredByWatermark(branded)).to.equal(true);
});

it('detects Slack mrkdwn watermark in markdown', () => {
Expand All @@ -62,6 +60,12 @@ describe('novu-powered-by-watermark', () => {
expect(contentHasPoweredByWatermark(markdown)).to.equal(true);
});

it('detects italic attributed watermark in markdown', () => {
const markdown = `Hello\n\n_Powered by [Novu](${NOVU_AGENT_POWERED_URL}?utm_campaign=agent-powered)_`;

expect(contentHasPoweredByWatermark(markdown)).to.equal(true);
});

it('detects legacy attributed watermark in markdown', () => {
const markdown = `Hello\n\n[Powered by Novu](${NOVU_AGENT_POWERED_URL}?utm_campaign=agent-powered)`;

Expand Down
24 changes: 5 additions & 19 deletions apps/api/src/app/agents/shared/util/novu-powered-by-watermark.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { CardElement } from 'chat';
import { AgentPlatformEnum, supportsMarkdownLinks } from '../enums/agent-platform.enum';
import { supportsMarkdownLinks } from '../enums/agent-platform.enum';
import { buildAttributedNovuUrl } from './novu-attribution-url';

export const NOVU_AGENT_POWERED_URL = 'https://go.novu.co/agent-powered';
Expand All @@ -10,38 +9,25 @@ const NOVU_POWERED_WATERMARK_MARKER = '\u200B';

const ATTRIBUTED_POWERED_BY_WATERMARK_PREFIX = `Powered by [Novu](${NOVU_AGENT_POWERED_URL}`;

// Previous Slack card path used mrkdwn (`<url|Novu>`). Keep detecting it so we don't double-stamp.
const SLACK_ATTRIBUTED_POWERED_BY_WATERMARK_PREFIX = `Powered by <${NOVU_AGENT_POWERED_URL}`;

const LEGACY_ATTRIBUTED_POWERED_BY_WATERMARK_PREFIX = `[${NOVU_AGENT_POWERED_WATERMARK_TEXT}](${NOVU_AGENT_POWERED_URL}`;

function formatPoweredByLink(label: string, url: string, platform: string): string {
if (platform === AgentPlatformEnum.SLACK) {
return `<${url}|${label}>`;
}

return `[${label}](${url})`;
}

export function buildPoweredByWatermark(agentIdentifier: string, platform: string): string {
if (!supportsMarkdownLinks(platform)) {
return `${NOVU_AGENT_POWERED_WATERMARK_TEXT}${NOVU_POWERED_WATERMARK_MARKER}`;
}

const url = buildAttributedNovuUrl(NOVU_AGENT_POWERED_URL, 'agent-powered', agentIdentifier, platform);

return `Powered by ${formatPoweredByLink('Novu', url, platform)}`;
return `_Powered by [Novu](${url})_`;
}

export function buildBrandedMarkdownReply(markdown: string, agentIdentifier: string, platform: string): CardElement {
export function appendPoweredByWatermark(markdown: string, agentIdentifier: string, platform: string): string {
const watermark = buildPoweredByWatermark(agentIdentifier, platform);

return {
type: 'card',
children: [
{ type: 'text', content: markdown },
{ type: 'text', content: watermark, style: 'muted' },
],
};
return `${markdown}\n\n${watermark}`;
}

export function contentHasPoweredByWatermark(markdown: string): boolean {
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/app/agents/shared/util/slack-section-limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import type { CardElement } from 'chat';
*/
const SLACK_SECTION_TEXT_LIMIT = 3000;

/** Slack `markdown_text` limit; oversize payloads are rejected rather than truncated. */
export const SLACK_MARKDOWN_TEXT_LIMIT = 12_000;

function pack(parts: string[], separator: string, limit: number, splitPart: (part: string) => string[]): string[] {
const chunks: string[] = [];
let current = '';
Expand Down
43 changes: 41 additions & 2 deletions packages/chat-adapter-sendblue/src/adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,41 @@ describe('SendblueAdapterImpl', () => {
});

describe('postMessage', () => {
it('passes plain text and markdown through to the vendor unchanged', async () => {
it('passes plain text through and converts markdown to plain text for iMessage/SMS', async () => {
const postMessage = spyOnVendorPostMessage();
const adapter = new SendblueAdapterImpl(CONFIG);

await adapter.postMessage('sendblue:t', 'hello');
await adapter.postMessage('sendblue:t', { markdown: 'Hello **world**' });

expect(postMessage).toHaveBeenNthCalledWith(1, 'sendblue:t', 'hello');
expect(postMessage).toHaveBeenNthCalledWith(2, 'sendblue:t', { markdown: 'Hello **world**' });
expect(postMessage).toHaveBeenNthCalledWith(2, 'sendblue:t', { markdown: 'Hello world' });
});

it('keeps attachments when converting markdown to plain text', async () => {
const postMessage = spyOnVendorPostMessage();
const adapter = new SendblueAdapterImpl(CONFIG);
const files = [{ name: 'photo.png', mimeType: 'image/png', data: Buffer.from('img') }];

await adapter.postMessage('sendblue:t', { markdown: 'See **this**', files } as never);

expect(postMessage).toHaveBeenCalledWith('sendblue:t', { markdown: 'See this', files });
});

it('converts markdown tables and links before handing off to the vendor', async () => {
const postMessage = spyOnVendorPostMessage();
const adapter = new SendblueAdapterImpl(CONFIG);

await adapter.postMessage('sendblue:t', {
markdown: ['See [docs](https://example.com).', '', '| A | B |', '| --- | --- |', '| 1 | 2 |'].join('\n'),
});

const delivered = postMessage.mock.calls[0]?.[1] as { markdown: string };
expect(delivered.markdown).toContain('docs (https://example.com)');
expect(delivered.markdown).toContain('1');
expect(delivered.markdown).toContain('2');
expect(delivered.markdown).not.toContain('| ---');
expect(delivered.markdown).not.toContain('[docs]');
});

it('flattens a bare card to markdown text, since the vendor adapter cannot render cards', async () => {
Expand All @@ -89,6 +115,19 @@ describe('SendblueAdapterImpl', () => {
});
});

it('keeps attachments posted alongside a card', async () => {
const postMessage = spyOnVendorPostMessage();
const adapter = new SendblueAdapterImpl(CONFIG);
const files = [{ name: 'receipt.pdf', mimeType: 'application/pdf', data: Buffer.from('pdf') }];

await adapter.postMessage('sendblue:t', {
card: { type: 'card', children: [{ type: 'text', content: 'Your order shipped' }] },
files,
} as never);

expect(postMessage).toHaveBeenCalledWith('sendblue:t', { markdown: 'Your order shipped', files });
});

it('prefers an explicit fallbackText over rendering the card', async () => {
const postMessage = spyOnVendorPostMessage();
const adapter = new SendblueAdapterImpl(CONFIG);
Expand Down
Loading
Loading