Skip to content

Commit c58e2c8

Browse files
authored
feat(api-service): adopt Chat SDK emoji system for cross-platform agent reactions (#10764)
1 parent a66d807 commit c58e2c8

14 files changed

Lines changed: 232 additions & 42 deletions

apps/api/src/app/agents/agents.controller.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { DeleteAgentCommand } from './usecases/delete-agent/delete-agent.command
4949
import { DeleteAgent } from './usecases/delete-agent/delete-agent.usecase';
5050
import { GetAgentCommand } from './usecases/get-agent/get-agent.command';
5151
import { GetAgent } from './usecases/get-agent/get-agent.usecase';
52+
import { type AgentEmojiEntry, ListAgentEmoji } from './usecases/list-agent-emoji/list-agent-emoji.usecase';
5253
import { ListAgentIntegrationsCommand } from './usecases/list-agent-integrations/list-agent-integrations.command';
5354
import { ListAgentIntegrations } from './usecases/list-agent-integrations/list-agent-integrations.usecase';
5455
import { ListAgentsCommand } from './usecases/list-agents/list-agents.command';
@@ -77,9 +78,22 @@ export class AgentsController {
7778
private readonly addAgentIntegrationUsecase: AddAgentIntegration,
7879
private readonly listAgentIntegrationsUsecase: ListAgentIntegrations,
7980
private readonly updateAgentIntegrationUsecase: UpdateAgentIntegration,
80-
private readonly removeAgentIntegrationUsecase: RemoveAgentIntegration
81+
private readonly removeAgentIntegrationUsecase: RemoveAgentIntegration,
82+
private readonly listAgentEmojiUsecase: ListAgentEmoji
8183
) {}
8284

85+
@Get('/emoji')
86+
@ApiOperation({
87+
summary: 'List available emoji',
88+
description:
89+
'Returns the set of well-known cross-platform emoji names supported for agent reactions. ' +
90+
'Each entry includes the normalized name and a unicode representation for display.',
91+
})
92+
@RequirePermissions(PermissionsEnum.AGENT_READ)
93+
listAgentEmoji(): Promise<AgentEmojiEntry[]> {
94+
return this.listAgentEmojiUsecase.execute();
95+
}
96+
8397
@Post('/')
8498
@ApiResponse(AgentResponseDto, 201)
8599
@ApiOperation({

apps/api/src/app/agents/dtos/agent-behavior.dto.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,29 @@
11
import { ApiPropertyOptional } from '@nestjs/swagger';
2-
import { IsBoolean, IsOptional, IsString, ValidateIf, ValidateNested } from 'class-validator';
2+
import { IsBoolean, IsOptional, ValidateIf, ValidateNested } from 'class-validator';
33
import { Type } from 'class-transformer';
4+
import { IsWellKnownEmoji } from '../validators/is-well-known-emoji.validator';
45

56
export class AgentReactionSettingsDto {
67
@ApiPropertyOptional({
7-
description: 'Emoji reaction for incoming messages. Emoji name string to customize, null to disable. Default: "eyes" (👀)',
8+
description:
9+
'Cross-platform emoji name for incoming messages (e.g. "eyes", "thumbs_up"). ' +
10+
'Set to null to disable. Default: "eyes"',
811
default: 'eyes',
912
})
1013
@IsOptional()
1114
@ValidateIf((_, value) => value !== null)
12-
@IsString()
15+
@IsWellKnownEmoji()
1316
onMessageReceived?: string | null;
1417

1518
@ApiPropertyOptional({
16-
description: 'Emoji reaction when a conversation is resolved. Emoji name string to customize, null to disable. Default: "check" (✅)',
19+
description:
20+
'Cross-platform emoji name for resolved conversations (e.g. "check", "star"). ' +
21+
'Set to null to disable. Default: "check"',
1722
default: 'check',
1823
})
1924
@IsOptional()
2025
@ValidateIf((_, value) => value !== null)
21-
@IsString()
26+
@IsWellKnownEmoji()
2227
onResolved?: string | null;
2328
}
2429

apps/api/src/app/agents/e2e/agent-webhook.e2e.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
import { testServer } from '@novu/testing';
88
import { expect } from 'chai';
99
import sinon from 'sinon';
10+
import type { EmojiValue } from 'chat';
1011
import { AgentEventEnum } from '../dtos/agent-event.enum';
1112
import { AgentConfigResolver } from '../services/agent-config-resolver.service';
1213
import { AgentInboundHandler, InboundReactionEvent } from '../services/agent-inbound-handler.service';
@@ -20,6 +21,10 @@ import {
2021
} from './helpers/agent-test-setup';
2122
import { buildSlackChallenge, signSlackRequest } from './helpers/providers/slack';
2223

24+
function mockEmoji(name: string): EmojiValue {
25+
return { name, toJSON: () => `{{emoji:${name}}}`, toString: () => `{{emoji:${name}}}` };
26+
}
27+
2328
function mockSentMessage() {
2429
return {
2530
addReaction: async () => {},
@@ -359,7 +364,7 @@ describe('Agent Webhook - inbound flow #novu-v2', () => {
359364
bridgeCalls = [];
360365

361366
const reactionEvent: InboundReactionEvent = {
362-
emoji: { name: 'thumbs_up' },
367+
emoji: mockEmoji('thumbs_up'),
363368
added: true,
364369
messageId: msg.id,
365370
message: msg as any,
@@ -379,7 +384,7 @@ describe('Agent Webhook - inbound flow #novu-v2', () => {
379384

380385
it('should skip reaction when no conversation exists for the thread', async () => {
381386
const reactionEvent: InboundReactionEvent = {
382-
emoji: { name: 'wave' },
387+
emoji: mockEmoji('wave'),
383388
added: true,
384389
messageId: 'msg-orphan',
385390
thread: mockThread(`T_NOCONV_${Date.now()}`) as any,
@@ -392,7 +397,7 @@ describe('Agent Webhook - inbound flow #novu-v2', () => {
392397

393398
it('should skip reaction when thread context is missing', async () => {
394399
const reactionEvent: InboundReactionEvent = {
395-
emoji: { name: 'fire' },
400+
emoji: mockEmoji('fire'),
396401
added: false,
397402
messageId: 'msg-no-thread',
398403
};
@@ -410,7 +415,7 @@ describe('Agent Webhook - inbound flow #novu-v2', () => {
410415
bridgeCalls = [];
411416

412417
const reactionEvent: InboundReactionEvent = {
413-
emoji: { name: 'tada' },
418+
emoji: mockEmoji('tada'),
414419
added: true,
415420
messageId: msg.id,
416421
message: msg as any,
@@ -443,7 +448,7 @@ describe('Agent Webhook - inbound flow #novu-v2', () => {
443448
);
444449

445450
const reactionEvent: InboundReactionEvent = {
446-
emoji: { name: 'heart' },
451+
emoji: mockEmoji('heart'),
447452
added: true,
448453
messageId: msg.id,
449454
message: msg as any,

apps/api/src/app/agents/services/agent-config-resolver.service.ts

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,22 @@ import {
88
IntegrationRepository,
99
} from '@novu/dal';
1010
import { FeatureFlagsKeysEnum } from '@novu/shared';
11+
import type { WellKnownEmoji } from 'chat';
1112
import { AgentPlatformEnum } from '../dtos/agent-platform.enum';
13+
import { esmImport } from '../utils/esm-import';
1214
import { resolveAgentPlatform } from '../utils/provider-to-platform';
1315

16+
let cachedEmojiNames: Set<string> | null = null;
17+
18+
async function loadEmojiNames(): Promise<Set<string>> {
19+
if (cachedEmojiNames) return cachedEmojiNames;
20+
21+
const { DEFAULT_EMOJI_MAP } = await esmImport('chat');
22+
cachedEmojiNames = new Set<string>(Object.keys(DEFAULT_EMOJI_MAP));
23+
24+
return cachedEmojiNames;
25+
}
26+
1427
export interface ResolvedAgentConfig {
1528
platform: AgentPlatformEnum;
1629
credentials: ICredentialsEntity;
@@ -21,25 +34,36 @@ export interface ResolvedAgentConfig {
2134
integrationIdentifier: string;
2235
integrationId: string;
2336
thinkingIndicatorEnabled: boolean;
24-
reactionOnMessageReceived: string | null;
25-
reactionOnResolved: string | null;
37+
reactionOnMessageReceived: WellKnownEmoji | null;
38+
reactionOnResolved: WellKnownEmoji | null;
2639
bridgeUrl?: string;
2740
devBridgeUrl?: string;
2841
devBridgeActive?: boolean;
2942
}
3043

31-
const DEFAULT_REACTION_ON_MESSAGE = 'eyes';
32-
const DEFAULT_REACTION_ON_RESOLVED = 'check';
44+
const DEFAULT_REACTION_ON_MESSAGE: WellKnownEmoji = 'eyes';
45+
const DEFAULT_REACTION_ON_RESOLVED: WellKnownEmoji = 'check';
3346

3447
function resolveThinkingIndicator(agent: { behavior?: { thinkingIndicatorEnabled?: boolean } }): boolean {
3548
return agent.behavior?.thinkingIndicatorEnabled !== false;
3649
}
3750

38-
function resolveReaction(value: string | null | undefined, defaultEmoji: string): string | null {
51+
async function resolveReaction(
52+
value: string | null | undefined,
53+
defaultEmoji: WellKnownEmoji,
54+
log: PinoLogger
55+
): Promise<WellKnownEmoji | null> {
3956
if (value === null) return null;
4057
if (value === undefined) return defaultEmoji;
4158

42-
return value;
59+
const known = await loadEmojiNames();
60+
if (!known.has(value)) {
61+
log.warn(`Unknown emoji "${value}" in agent config, falling back to default "${defaultEmoji}"`);
62+
63+
return defaultEmoji;
64+
}
65+
66+
return value as WellKnownEmoji;
4367
}
4468

4569
@Injectable()
@@ -133,11 +157,16 @@ export class AgentConfigResolver {
133157
integrationIdentifier,
134158
integrationId: integration._id,
135159
thinkingIndicatorEnabled: resolveThinkingIndicator(agent),
136-
reactionOnMessageReceived: resolveReaction(
160+
reactionOnMessageReceived: await resolveReaction(
137161
agent.behavior?.reactions?.onMessageReceived,
138-
DEFAULT_REACTION_ON_MESSAGE
162+
DEFAULT_REACTION_ON_MESSAGE,
163+
this.logger
164+
),
165+
reactionOnResolved: await resolveReaction(
166+
agent.behavior?.reactions?.onResolved,
167+
DEFAULT_REACTION_ON_RESOLVED,
168+
this.logger
139169
),
140-
reactionOnResolved: resolveReaction(agent.behavior?.reactions?.onResolved, DEFAULT_REACTION_ON_RESOLVED),
141170
bridgeUrl: agent.bridgeUrl,
142171
devBridgeUrl: agent.devBridgeUrl,
143172
devBridgeActive: agent.devBridgeActive,

apps/api/src/app/agents/services/agent-inbound-handler.service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
ConversationRepository,
77
SubscriberRepository,
88
} from '@novu/dal';
9-
import type { Message, Thread } from 'chat';
9+
import type { EmojiValue, Message, Thread } from 'chat';
1010
import { AgentEventEnum } from '../dtos/agent-event.enum';
1111
import { PLATFORMS_WITHOUT_TYPING_INDICATOR } from '../dtos/agent-platform.enum';
1212
import { HandleAgentReplyCommand } from '../usecases/handle-agent-reply/handle-agent-reply.command';
@@ -22,7 +22,7 @@ const ONBOARDING_NO_BRIDGE_REPLY_MARKDOWN = `*You're connected to Novu*
2222
Your bot is linked successfully. Go back to the *Novu dashboard* to complete onboarding.`;
2323

2424
export interface InboundReactionEvent {
25-
emoji: { name: string };
25+
emoji: EmojiValue;
2626
added: boolean;
2727
messageId: string;
2828
message?: Message;

apps/api/src/app/agents/services/chat-sdk.service.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { BadRequestException, forwardRef, Inject, Injectable, OnModuleDestroy } from '@nestjs/common';
22
import { PinoLogger } from '@novu/application-generic';
3-
import type { Chat, Message, Thread } from 'chat';
3+
import type { Chat, EmojiValue, Message, ReactionEvent, Thread } from 'chat';
44
import { Request as ExpressRequest, Response as ExpressResponse } from 'express';
55
import { LRUCache } from 'lru-cache';
66
import { AgentEventEnum } from '../dtos/agent-event.enum';
77
import { AgentPlatformEnum } from '../dtos/agent-platform.enum';
88
import type { ReplyContentDto } from '../dtos/agent-reply-payload.dto';
9+
import { esmImport } from '../utils/esm-import';
910
import { sendWebResponse, toWebRequest } from '../utils/express-to-web-request';
1011
import { AgentConfigResolver, ResolvedAgentConfig } from './agent-config-resolver.service';
1112
import { AgentInboundHandler } from './agent-inbound-handler.service';
@@ -26,11 +27,6 @@ import { AgentInboundHandler } from './agent-inbound-handler.service';
2627
* credentials.phoneNumberIdentification → phoneNumberId
2728
*/
2829

29-
// Chat SDK packages are ESM-only; SWC rewrites import() → require() for CJS output.
30-
// Wrapping in new Function prevents SWC from seeing the import() keyword.
31-
// eslint-disable-next-line @typescript-eslint/no-implied-eval
32-
const esmImport = new Function('specifier', 'return import(specifier)') as (s: string) => Promise<any>;
33-
3430
const MAX_CACHED_INSTANCES = 200;
3531
const INSTANCE_TTL_MS = 1000 * 60 * 30;
3632

@@ -133,14 +129,15 @@ export class ChatSdkService implements OnModuleDestroy {
133129
platform: string,
134130
platformThreadId: string,
135131
platformMessageId: string,
136-
emoji: string
132+
emojiName: string
137133
): Promise<void> {
138134
const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier);
139135
const instanceKey = `${agentId}:${integrationIdentifier}`;
140136
const chat = await this.getOrCreate(instanceKey, agentId, config.platform, config);
141137

142138
const adapter = chat.getAdapter(platform);
143-
await adapter.removeReaction(platformThreadId, platformMessageId, emoji);
139+
const resolved = await this.resolveEmoji(emojiName);
140+
await adapter.removeReaction(platformThreadId, platformMessageId, resolved);
144141
}
145142

146143
async reactToMessage(
@@ -149,14 +146,25 @@ export class ChatSdkService implements OnModuleDestroy {
149146
platform: string,
150147
platformThreadId: string,
151148
platformMessageId: string,
152-
emoji: string
149+
emojiName: string
153150
): Promise<void> {
154151
const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier);
155152
const instanceKey = `${agentId}:${integrationIdentifier}`;
156153
const chat = await this.getOrCreate(instanceKey, agentId, config.platform, config);
157154

158155
const adapter = chat.getAdapter(platform);
159-
await adapter.addReaction(platformThreadId, platformMessageId, emoji);
156+
const resolved = await this.resolveEmoji(emojiName);
157+
await adapter.addReaction(platformThreadId, platformMessageId, resolved);
158+
}
159+
160+
private async resolveEmoji(name: string): Promise<EmojiValue> {
161+
const { getEmoji } = await esmImport('chat');
162+
const resolved = getEmoji(name);
163+
if (!resolved) {
164+
throw new Error(`Unknown emoji name: "${name}". Use GET /agents/emoji to list supported options.`);
165+
}
166+
167+
return resolved;
160168
}
161169

162170
private async getOrCreate(
@@ -379,14 +387,14 @@ export class ChatSdkService implements OnModuleDestroy {
379387
}
380388
});
381389

382-
cached.chat.onReaction(async (event: any) => {
390+
cached.chat.onReaction(async (event: ReactionEvent) => {
383391
try {
384392
await this.inboundHandler.handleReaction(agentId, cached.config, {
385393
emoji: event.emoji,
386394
added: event.added,
387395
messageId: event.messageId,
388396
message: event.message,
389-
thread: event.thread,
397+
thread: event.thread as Thread | undefined,
390398
user: event.user,
391399
});
392400
} catch (err) {

apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.command.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1+
import type { Signal } from '@novu/framework';
12
import { Type } from 'class-transformer';
23
import { IsArray, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
3-
import type { Signal } from '@novu/framework';
44
import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';
55
import { ReplyContentDto } from '../../dtos/agent-reply-payload.dto';
66

apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export class HandleAgentReply {
104104
}
105105

106106
if (command.resolve) {
107-
await this.executeResolveSignal(command, config!, conversation, channel, command.resolve);
107+
await this.resolveConversation(command, config!, conversation, channel, command.resolve);
108108
}
109109

110110
return { status: 'ok' };
@@ -255,12 +255,12 @@ export class HandleAgentReply {
255255
]);
256256
}
257257

258-
private async executeResolveSignal(
258+
private async resolveConversation(
259259
command: HandleAgentReplyCommand,
260260
config: ResolvedAgentConfig,
261261
conversation: ConversationEntity,
262262
channel: ConversationChannel,
263-
signal: { summary?: string }
263+
options: { summary?: string }
264264
): Promise<void> {
265265
await Promise.all([
266266
this.conversationRepository.updateStatus(
@@ -276,8 +276,8 @@ export class HandleAgentReply {
276276
integrationId: channel._integrationId,
277277
platformThreadId: channel.platformThreadId,
278278
agentId: command.agentIdentifier,
279-
content: signal.summary ?? 'Conversation resolved',
280-
signalData: { type: 'resolve', payload: signal.summary ? { summary: signal.summary } : undefined },
279+
content: options.summary ?? 'Conversation resolved',
280+
signalData: { type: 'resolve', payload: options.summary ? { summary: options.summary } : undefined },
281281
environmentId: command.environmentId,
282282
organizationId: command.organizationId,
283283
}),

apps/api/src/app/agents/usecases/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { CreateAgent } from './create-agent/create-agent.usecase';
33
import { DeleteAgent } from './delete-agent/delete-agent.usecase';
44
import { GetAgent } from './get-agent/get-agent.usecase';
55
import { HandleAgentReply } from './handle-agent-reply/handle-agent-reply.usecase';
6+
import { ListAgentEmoji } from './list-agent-emoji/list-agent-emoji.usecase';
67
import { ListAgentIntegrations } from './list-agent-integrations/list-agent-integrations.usecase';
78
import { ListAgents } from './list-agents/list-agents.usecase';
89
import { RemoveAgentIntegration } from './remove-agent-integration/remove-agent-integration.usecase';
@@ -16,6 +17,7 @@ export const USE_CASES = [
1617
UpdateAgent,
1718
DeleteAgent,
1819
AddAgentIntegration,
20+
ListAgentEmoji,
1921
ListAgentIntegrations,
2022
UpdateAgentIntegration,
2123
RemoveAgentIntegration,

0 commit comments

Comments
 (0)