@hed-hog/core 0.0.347 → 0.0.350

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/auth/auth.controller.d.ts +1 -1
  2. package/dist/auth/auth.service.d.ts +1 -1
  3. package/dist/core.module.d.ts.map +1 -1
  4. package/dist/core.module.js +3 -0
  5. package/dist/core.module.js.map +1 -1
  6. package/dist/dashboard/dashboard-user/dashboard-user.controller.d.ts +3 -3
  7. package/dist/dashboard/dashboard-user/dashboard-user.service.d.ts +3 -3
  8. package/dist/index.d.ts +3 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +4 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/mcp/guards/mcp-auth.guard.d.ts +6 -1
  13. package/dist/mcp/guards/mcp-auth.guard.d.ts.map +1 -1
  14. package/dist/mcp/guards/mcp-auth.guard.js +29 -2
  15. package/dist/mcp/guards/mcp-auth.guard.js.map +1 -1
  16. package/dist/mcp/mcp-api-key.controller.d.ts +32 -0
  17. package/dist/mcp/mcp-api-key.controller.d.ts.map +1 -0
  18. package/dist/mcp/mcp-api-key.controller.js +64 -0
  19. package/dist/mcp/mcp-api-key.controller.js.map +1 -0
  20. package/dist/mcp/mcp-api-key.service.d.ts +27 -0
  21. package/dist/mcp/mcp-api-key.service.d.ts.map +1 -0
  22. package/dist/mcp/mcp-api-key.service.js +74 -0
  23. package/dist/mcp/mcp-api-key.service.js.map +1 -0
  24. package/dist/mcp/mcp-registry.service.d.ts +1 -2
  25. package/dist/mcp/mcp-registry.service.d.ts.map +1 -1
  26. package/dist/mcp/mcp-registry.service.js +6 -2
  27. package/dist/mcp/mcp-registry.service.js.map +1 -1
  28. package/dist/mcp/mcp.module.d.ts.map +1 -1
  29. package/dist/mcp/mcp.module.js +8 -1
  30. package/dist/mcp/mcp.module.js.map +1 -1
  31. package/dist/mcp-chat/mcp-chat.controller.d.ts +4 -0
  32. package/dist/mcp-chat/mcp-chat.controller.d.ts.map +1 -1
  33. package/dist/mcp-chat/mcp-chat.controller.js +46 -0
  34. package/dist/mcp-chat/mcp-chat.controller.js.map +1 -1
  35. package/dist/mcp-chat/mcp-chat.service.d.ts +6 -1
  36. package/dist/mcp-chat/mcp-chat.service.d.ts.map +1 -1
  37. package/dist/mcp-chat/mcp-chat.service.js +125 -17
  38. package/dist/mcp-chat/mcp-chat.service.js.map +1 -1
  39. package/dist/notification/dto/create-notification.dto.d.ts +26 -0
  40. package/dist/notification/dto/create-notification.dto.d.ts.map +1 -0
  41. package/dist/notification/dto/create-notification.dto.js +86 -0
  42. package/dist/notification/dto/create-notification.dto.js.map +1 -0
  43. package/dist/notification/dto/update-progress.dto.d.ts +6 -0
  44. package/dist/notification/dto/update-progress.dto.d.ts.map +1 -0
  45. package/dist/notification/dto/update-progress.dto.js +33 -0
  46. package/dist/notification/dto/update-progress.dto.js.map +1 -0
  47. package/dist/notification/notification.controller.d.ts +108 -0
  48. package/dist/notification/notification.controller.d.ts.map +1 -0
  49. package/dist/notification/notification.controller.js +118 -0
  50. package/dist/notification/notification.controller.js.map +1 -0
  51. package/dist/notification/notification.module.d.ts +3 -0
  52. package/dist/notification/notification.module.d.ts.map +1 -0
  53. package/dist/notification/notification.module.js +29 -0
  54. package/dist/notification/notification.module.js.map +1 -0
  55. package/dist/notification/notification.service.d.ts +98 -0
  56. package/dist/notification/notification.service.d.ts.map +1 -0
  57. package/dist/notification/notification.service.js +123 -0
  58. package/dist/notification/notification.service.js.map +1 -0
  59. package/dist/profile/profile.controller.d.ts +1 -1
  60. package/dist/profile/profile.service.d.ts +1 -1
  61. package/dist/setting/dto/update-setting-list.dto.d.ts +9 -0
  62. package/dist/setting/dto/update-setting-list.dto.d.ts.map +1 -0
  63. package/dist/setting/dto/update-setting-list.dto.js +44 -0
  64. package/dist/setting/dto/update-setting-list.dto.js.map +1 -0
  65. package/dist/setting/setting.controller.d.ts +19 -0
  66. package/dist/setting/setting.controller.d.ts.map +1 -1
  67. package/dist/setting/setting.controller.js +12 -0
  68. package/dist/setting/setting.controller.js.map +1 -1
  69. package/dist/setting/setting.service.d.ts +19 -0
  70. package/dist/setting/setting.service.d.ts.map +1 -1
  71. package/dist/setting/setting.service.js +79 -0
  72. package/dist/setting/setting.service.js.map +1 -1
  73. package/dist/task/task.service.d.ts +12 -0
  74. package/dist/task/task.service.d.ts.map +1 -1
  75. package/dist/task/task.service.js +60 -0
  76. package/dist/task/task.service.js.map +1 -1
  77. package/dist/user/user.controller.d.ts +2 -2
  78. package/dist/user/user.service.d.ts +3 -3
  79. package/dist/webhook-integration/webhook-integration.service.d.ts +4 -0
  80. package/dist/webhook-integration/webhook-integration.service.d.ts.map +1 -1
  81. package/dist/webhook-integration/webhook-integration.service.js +184 -34
  82. package/dist/webhook-integration/webhook-integration.service.js.map +1 -1
  83. package/dist/webhook-integration/webhook-utils.d.ts +1 -0
  84. package/dist/webhook-integration/webhook-utils.d.ts.map +1 -1
  85. package/dist/webhook-integration/webhook-utils.js +38 -0
  86. package/dist/webhook-integration/webhook-utils.js.map +1 -1
  87. package/hedhog/data/menu.yaml +48 -4
  88. package/hedhog/data/route.yaml +133 -0
  89. package/hedhog/data/setting_group.yaml +45 -0
  90. package/hedhog/data/setting_subgroup.yaml +16 -0
  91. package/hedhog/frontend/app/account/components/mcp-tokens.tsx.ejs +285 -0
  92. package/hedhog/frontend/app/account/layout.tsx.ejs +32 -55
  93. package/hedhog/frontend/app/account/mcp-tokens/page.tsx.ejs +5 -0
  94. package/hedhog/frontend/app/integrations-webhooks/page.tsx.ejs +2098 -1835
  95. package/hedhog/frontend/app/mcp_chat/chat-components.tsx.ejs +595 -0
  96. package/hedhog/frontend/app/mcp_chat/chat-types.ts.ejs +16 -0
  97. package/hedhog/frontend/app/mcp_chat/page.tsx.ejs +554 -383
  98. package/hedhog/frontend/app/setting-list/page.tsx.ejs +649 -0
  99. package/hedhog/frontend/app/users/page.tsx.ejs +445 -203
  100. package/hedhog/frontend/app/users/user-sheet.tsx.ejs +16 -37
  101. package/hedhog/frontend/messages/en.json +241 -2
  102. package/hedhog/frontend/messages/pt.json +241 -2
  103. package/hedhog/table/mcp_api_key.yaml +30 -0
  104. package/hedhog/table/notification.yaml +72 -0
  105. package/hedhog/table/webhook_action_log.yaml +49 -0
  106. package/package.json +2 -2
  107. package/src/core.module.ts +3 -0
  108. package/src/index.ts +5 -0
  109. package/src/mcp/guards/mcp-auth.guard.ts +64 -32
  110. package/src/mcp/mcp-api-key.controller.ts +42 -0
  111. package/src/mcp/mcp-api-key.service.ts +66 -0
  112. package/src/mcp/mcp-registry.service.ts +75 -69
  113. package/src/mcp/mcp.module.ts +43 -36
  114. package/src/mcp-chat/mcp-chat.controller.ts +113 -60
  115. package/src/mcp-chat/mcp-chat.service.ts +494 -332
  116. package/src/notification/dto/create-notification.dto.ts +71 -0
  117. package/src/notification/dto/update-progress.dto.ts +16 -0
  118. package/src/notification/notification.controller.ts +71 -0
  119. package/src/notification/notification.module.ts +16 -0
  120. package/src/notification/notification.service.ts +124 -0
  121. package/src/setting/dto/update-setting-list.dto.ts +32 -0
  122. package/src/setting/setting.controller.ts +9 -0
  123. package/src/setting/setting.service.ts +91 -0
  124. package/src/task/task.service.ts +72 -0
  125. package/src/webhook-integration/webhook-integration.service.ts +934 -735
  126. package/src/webhook-integration/webhook-utils.ts +136 -79
@@ -1,332 +1,494 @@
1
- import { PrismaService } from '@hed-hog/api-prisma';
2
- import {
3
- BadRequestException,
4
- ForbiddenException,
5
- forwardRef,
6
- HttpException,
7
- HttpStatus,
8
- Inject,
9
- Injectable,
10
- NotFoundException,
11
- } from '@nestjs/common';
12
- import axios from 'axios';
13
- import { SettingService } from '../setting/setting.service';
14
- import { McpContext } from '../mcp/types/mcp-context.type';
15
- import { McpRegistryService } from '../mcp/mcp-registry.service';
16
- import { CreateConversationDTO } from './dto/create-conversation.dto';
17
-
18
- type OpenAiMessage =
19
- | { role: 'system'; content: string }
20
- | { role: 'user'; content: string }
21
- | { role: 'assistant'; content: string | null; tool_calls?: OpenAiToolCall[] }
22
- | { role: 'tool'; tool_call_id: string; content: string };
23
-
24
- interface OpenAiToolCall {
25
- id: string;
26
- type: 'function';
27
- function: { name: string; arguments: string };
28
- }
29
-
30
- const SYSTEM_PROMPT =
31
- 'You are a helpful assistant with access to tools from the HedHog system. ' +
32
- 'Use the available tools to answer the user\'s questions accurately. ' +
33
- 'Always respond in the same language the user writes in.';
34
-
35
- const sanitize = (n: string) => n.replace(/\./g, '__');
36
- const restore = (n: string) => n.replace(/__/g, '.');
37
-
38
- @Injectable()
39
- export class McpChatService {
40
- constructor(
41
- @Inject(forwardRef(() => PrismaService))
42
- private readonly prisma: PrismaService,
43
- @Inject(forwardRef(() => SettingService))
44
- private readonly setting: SettingService,
45
- @Inject(forwardRef(() => McpRegistryService))
46
- private readonly registry: McpRegistryService,
47
- ) {}
48
-
49
- async listConversations(userId: number) {
50
- return this.prisma.mcp_conversation.findMany({
51
- where: { user_id: userId },
52
- orderBy: { created_at: 'desc' },
53
- select: { id: true, title: true, created_at: true, updated_at: true },
54
- });
55
- }
56
-
57
- async createConversation(userId: number, dto: CreateConversationDTO) {
58
- return this.prisma.mcp_conversation.create({
59
- data: {
60
- user_id: userId,
61
- title: dto.title ?? null,
62
- },
63
- });
64
- }
65
-
66
- async deleteConversations(userId: number, ids: number[]) {
67
- await this.prisma.mcp_conversation.deleteMany({
68
- where: { id: { in: ids }, user_id: userId },
69
- });
70
- return { count: ids.length };
71
- }
72
-
73
- async getMessages(conversationId: number, userId: number) {
74
- await this.assertOwnership(conversationId, userId);
75
- return this.prisma.mcp_conversation_message.findMany({
76
- where: { conversation_id: conversationId },
77
- orderBy: { created_at: 'asc' },
78
- });
79
- }
80
-
81
- async sendMessage(
82
- conversationId: number,
83
- userId: number,
84
- locale: string,
85
- message: string,
86
- ) {
87
- await this.assertOwnership(conversationId, userId);
88
-
89
- const apiKey = await this.getOpenAiKey();
90
-
91
- // Save user message
92
- await this.prisma.mcp_conversation_message.create({
93
- data: {
94
- conversation_id: conversationId,
95
- role: 'user',
96
- content: message,
97
- },
98
- });
99
-
100
- // Auto-set title on first message
101
- const conv = await this.prisma.mcp_conversation.findUnique({
102
- where: { id: conversationId },
103
- });
104
- if (!conv!.title) {
105
- await this.prisma.mcp_conversation.update({
106
- where: { id: conversationId },
107
- data: { title: message.slice(0, 60) },
108
- });
109
- }
110
-
111
- // Build OpenAI messages from history
112
- const history = await this.prisma.mcp_conversation_message.findMany({
113
- where: { conversation_id: conversationId },
114
- orderBy: { created_at: 'asc' },
115
- });
116
-
117
- const openAiMessages: OpenAiMessage[] = [
118
- { role: 'system', content: SYSTEM_PROMPT },
119
- ...this.toOpenAiMessages(history),
120
- ];
121
-
122
- // Build tool definitions
123
- const tools = this.registry.getAll().map((entry) => ({
124
- type: 'function' as const,
125
- function: {
126
- name: sanitize(entry.definition.name),
127
- description: entry.definition.description,
128
- parameters: entry.definition.inputSchema ?? {
129
- type: 'object',
130
- properties: {},
131
- },
132
- },
133
- }));
134
-
135
- // Tool-calling loop
136
- let maxIterations = 10;
137
- while (maxIterations-- > 0) {
138
- let response: any;
139
- try {
140
- response = await axios.post(
141
- 'https://api.openai.com/v1/chat/completions',
142
- {
143
- model: 'gpt-4o-mini',
144
- messages: openAiMessages,
145
- tools: tools.length > 0 ? tools : undefined,
146
- tool_choice: tools.length > 0 ? 'auto' : undefined,
147
- },
148
- {
149
- headers: {
150
- Authorization: `Bearer ${apiKey}`,
151
- 'Content-Type': 'application/json',
152
- },
153
- },
154
- );
155
- } catch (err: any) {
156
- const status = err?.response?.status;
157
- const errBody = err?.response?.data?.error;
158
- if (status === 429) {
159
- throw new HttpException(
160
- errBody?.message ?? 'OpenAI rate limit exceeded. Please try again later.',
161
- HttpStatus.TOO_MANY_REQUESTS,
162
- );
163
- }
164
- if (status === 401) {
165
- throw new BadRequestException('Invalid OpenAI API key.');
166
- }
167
- throw new HttpException(
168
- errBody?.message ?? 'OpenAI API error.',
169
- status ?? HttpStatus.BAD_GATEWAY,
170
- );
171
- }
172
-
173
- const choice = response.data?.choices?.[0];
174
- const assistantMsg = choice?.message;
175
-
176
- if (assistantMsg?.tool_calls?.length) {
177
- // Add assistant tool-call message to context
178
- openAiMessages.push({
179
- role: 'assistant',
180
- content: null,
181
- tool_calls: assistantMsg.tool_calls,
182
- });
183
-
184
- // Execute each tool call
185
- for (const toolCall of assistantMsg.tool_calls as OpenAiToolCall[]) {
186
- const originalName = restore(toolCall.function.name);
187
- const argsJson = toolCall.function.arguments;
188
- let args: Record<string, any> = {};
189
- try {
190
- args = JSON.parse(argsJson);
191
- } catch {
192
- args = {};
193
- }
194
-
195
- // Persist tool_call message
196
- await this.prisma.mcp_conversation_message.create({
197
- data: {
198
- conversation_id: conversationId,
199
- role: 'tool_call',
200
- content: argsJson,
201
- tool_name: originalName,
202
- tool_call_id: toolCall.id,
203
- },
204
- });
205
-
206
- // Execute the tool
207
- let resultContent: string;
208
- const entry = this.registry.get(originalName);
209
- if (entry) {
210
- const context: McpContext = {
211
- userId,
212
- sessionId: '',
213
- locale,
214
- toolName: originalName,
215
- };
216
- try {
217
- const result = await entry.handler(args, context);
218
- resultContent = JSON.stringify(result);
219
- } catch (e: any) {
220
- resultContent = JSON.stringify({ error: e.message });
221
- }
222
- } else {
223
- resultContent = JSON.stringify({ error: `Tool '${originalName}' not found` });
224
- }
225
-
226
- // Persist tool_result message
227
- await this.prisma.mcp_conversation_message.create({
228
- data: {
229
- conversation_id: conversationId,
230
- role: 'tool_result',
231
- content: resultContent,
232
- tool_name: originalName,
233
- tool_call_id: toolCall.id,
234
- },
235
- });
236
-
237
- // Add tool result to context for next iteration
238
- openAiMessages.push({
239
- role: 'tool',
240
- tool_call_id: toolCall.id,
241
- content: resultContent,
242
- });
243
- }
244
-
245
- continue; // next iteration with tool results
246
- }
247
-
248
- // Final assistant response
249
- const finalContent: string = assistantMsg?.content || '';
250
- await this.prisma.mcp_conversation_message.create({
251
- data: {
252
- conversation_id: conversationId,
253
- role: 'assistant',
254
- content: finalContent,
255
- },
256
- });
257
-
258
- return this.prisma.mcp_conversation_message.findMany({
259
- where: { conversation_id: conversationId },
260
- orderBy: { created_at: 'asc' },
261
- });
262
- }
263
-
264
- throw new BadRequestException('Maximum tool call iterations exceeded.');
265
- }
266
-
267
- private toOpenAiMessages(
268
- history: Array<{
269
- role: string;
270
- content: string;
271
- tool_name: string | null;
272
- tool_call_id: string | null;
273
- }>,
274
- ): OpenAiMessage[] {
275
- const messages: OpenAiMessage[] = [];
276
- const toolCallMap = new Map<string, string>(); // tool_call_id → sanitized name
277
-
278
- for (const msg of history) {
279
- if (msg.role === 'user') {
280
- messages.push({ role: 'user', content: msg.content });
281
- } else if (msg.role === 'assistant') {
282
- messages.push({ role: 'assistant', content: msg.content });
283
- } else if (msg.role === 'tool_call' && msg.tool_call_id && msg.tool_name) {
284
- const sanitizedName = sanitize(msg.tool_name);
285
- toolCallMap.set(msg.tool_call_id, sanitizedName);
286
- messages.push({
287
- role: 'assistant',
288
- content: null,
289
- tool_calls: [
290
- {
291
- id: msg.tool_call_id,
292
- type: 'function',
293
- function: { name: sanitizedName, arguments: msg.content },
294
- },
295
- ],
296
- });
297
- } else if (msg.role === 'tool_result' && msg.tool_call_id) {
298
- messages.push({
299
- role: 'tool',
300
- tool_call_id: msg.tool_call_id,
301
- content: msg.content,
302
- });
303
- }
304
- }
305
-
306
- return messages;
307
- }
308
-
309
- private async assertOwnership(conversationId: number, userId: number) {
310
- const conv = await this.prisma.mcp_conversation.findUnique({
311
- where: { id: conversationId },
312
- select: { user_id: true },
313
- });
314
- if (!conv) {
315
- throw new NotFoundException(`Conversation ${conversationId} not found.`);
316
- }
317
- if (conv.user_id !== userId) {
318
- throw new ForbiddenException('Access denied to this conversation.');
319
- }
320
- }
321
-
322
- private async getOpenAiKey(): Promise<string> {
323
- const settings = await this.setting.getSettingValues(['ai-openai-api-key']);
324
- const key = settings['ai-openai-api-key'];
325
- if (!key) {
326
- throw new BadRequestException(
327
- 'OpenAI API key is not configured (setting "ai-openai-api-key").',
328
- );
329
- }
330
- return key;
331
- }
332
- }
1
+ import { PrismaService } from '@hed-hog/api-prisma';
2
+ import {
3
+ BadRequestException,
4
+ ForbiddenException,
5
+ forwardRef,
6
+ HttpException,
7
+ HttpStatus,
8
+ Inject,
9
+ Injectable,
10
+ Logger,
11
+ NotFoundException,
12
+ } from '@nestjs/common';
13
+ import axios from 'axios';
14
+ import { McpRegistryService } from '../mcp/mcp-registry.service';
15
+ import { McpContext } from '../mcp/types/mcp-context.type';
16
+ import { SettingService } from '../setting/setting.service';
17
+ import { CreateConversationDTO } from './dto/create-conversation.dto';
18
+
19
+ type OpenAiMessage =
20
+ | { role: 'system'; content: string }
21
+ | { role: 'user'; content: string }
22
+ | { role: 'assistant'; content: string | null; tool_calls?: OpenAiToolCall[] }
23
+ | { role: 'tool'; tool_call_id: string; content: string };
24
+
25
+ interface OpenAiToolCall {
26
+ id: string;
27
+ type: 'function';
28
+ function: { name: string; arguments: string };
29
+ }
30
+
31
+ interface OpenAiToolDefinition {
32
+ type: 'function';
33
+ function: {
34
+ name: string;
35
+ description: string;
36
+ parameters: Record<string, any>;
37
+ };
38
+ }
39
+
40
+ interface ScoredTool {
41
+ name: string;
42
+ readOnly: boolean;
43
+ priorityBucket: number;
44
+ keywordBoost: number;
45
+ index: number;
46
+ tool: OpenAiToolDefinition;
47
+ }
48
+
49
+ const MAX_OPENAI_TOOLS = 128;
50
+
51
+ const SYSTEM_PROMPT =
52
+ 'You are a helpful assistant with access to tools from the HedHog system. ' +
53
+ 'Use the available tools to answer the user\'s questions accurately. ' +
54
+ 'Always respond in the same language the user writes in.';
55
+
56
+ const sanitize = (n: string) => n.replace(/\./g, '__');
57
+ const restore = (n: string) => n.replace(/__/g, '.');
58
+
59
+ @Injectable()
60
+ export class McpChatService {
61
+ private readonly logger = new Logger(McpChatService.name);
62
+
63
+ constructor(
64
+ @Inject(forwardRef(() => PrismaService))
65
+ private readonly prisma: PrismaService,
66
+ @Inject(forwardRef(() => SettingService))
67
+ private readonly setting: SettingService,
68
+ @Inject(forwardRef(() => McpRegistryService))
69
+ private readonly registry: McpRegistryService,
70
+ ) {}
71
+
72
+ async listConversations(userId: number) {
73
+ return this.prisma.mcp_conversation.findMany({
74
+ where: { user_id: userId },
75
+ orderBy: { created_at: 'desc' },
76
+ select: { id: true, title: true, created_at: true, updated_at: true },
77
+ });
78
+ }
79
+
80
+ async createConversation(userId: number, dto: CreateConversationDTO) {
81
+ return this.prisma.mcp_conversation.create({
82
+ data: {
83
+ user_id: userId,
84
+ title: dto.title ?? null,
85
+ },
86
+ });
87
+ }
88
+
89
+ async deleteConversations(userId: number, ids: number[]) {
90
+ await this.prisma.mcp_conversation.deleteMany({
91
+ where: { id: { in: ids }, user_id: userId },
92
+ });
93
+ return { count: ids.length };
94
+ }
95
+
96
+ async getMessages(conversationId: number, userId: number) {
97
+ await this.assertOwnership(conversationId, userId);
98
+ return this.prisma.mcp_conversation_message.findMany({
99
+ where: { conversation_id: conversationId },
100
+ orderBy: { created_at: 'asc' },
101
+ });
102
+ }
103
+
104
+ async sendMessage(
105
+ conversationId: number,
106
+ userId: number,
107
+ locale: string,
108
+ message: string,
109
+ ) {
110
+ await this.assertOwnership(conversationId, userId);
111
+
112
+ const apiKey = await this.getOpenAiKey();
113
+
114
+ // Save user message
115
+ await this.prisma.mcp_conversation_message.create({
116
+ data: {
117
+ conversation_id: conversationId,
118
+ role: 'user',
119
+ content: message,
120
+ },
121
+ });
122
+
123
+ // Auto-set title on first message
124
+ const conv = await this.prisma.mcp_conversation.findUnique({
125
+ where: { id: conversationId },
126
+ });
127
+ if (!conv!.title) {
128
+ await this.prisma.mcp_conversation.update({
129
+ where: { id: conversationId },
130
+ data: { title: message.slice(0, 60) },
131
+ });
132
+ }
133
+
134
+ // Build OpenAI messages from history
135
+ const history = await this.prisma.mcp_conversation_message.findMany({
136
+ where: { conversation_id: conversationId },
137
+ orderBy: { created_at: 'asc' },
138
+ });
139
+
140
+ const openAiMessages: OpenAiMessage[] = [
141
+ { role: 'system', content: SYSTEM_PROMPT },
142
+ ...this.toOpenAiMessages(history),
143
+ ];
144
+
145
+ // Build tool definitions
146
+ const tools = this.buildOpenAiTools(message, conversationId, userId);
147
+
148
+ // Tool-calling loop
149
+ let maxIterations = 10;
150
+ while (maxIterations-- > 0) {
151
+ let response: any;
152
+ try {
153
+ response = await axios.post(
154
+ 'https://api.openai.com/v1/chat/completions',
155
+ {
156
+ model: 'gpt-4o-mini',
157
+ messages: openAiMessages,
158
+ tools: tools.length > 0 ? tools : undefined,
159
+ tool_choice: tools.length > 0 ? 'auto' : undefined,
160
+ },
161
+ {
162
+ headers: {
163
+ Authorization: `Bearer ${apiKey}`,
164
+ 'Content-Type': 'application/json',
165
+ },
166
+ },
167
+ );
168
+ } catch (err: any) {
169
+ const status = err?.response?.status;
170
+ const errBody = err?.response?.data?.error;
171
+ if (status === 429) {
172
+ throw new HttpException(
173
+ errBody?.message ?? 'OpenAI rate limit exceeded. Please try again later.',
174
+ HttpStatus.TOO_MANY_REQUESTS,
175
+ );
176
+ }
177
+ if (status === 401) {
178
+ throw new BadRequestException('Invalid OpenAI API key.');
179
+ }
180
+ throw new HttpException(
181
+ errBody?.message ?? 'OpenAI API error.',
182
+ status ?? HttpStatus.BAD_GATEWAY,
183
+ );
184
+ }
185
+
186
+ const choice = response.data?.choices?.[0];
187
+ const assistantMsg = choice?.message;
188
+
189
+ if (assistantMsg?.tool_calls?.length) {
190
+ // Add assistant tool-call message to context
191
+ openAiMessages.push({
192
+ role: 'assistant',
193
+ content: null,
194
+ tool_calls: assistantMsg.tool_calls,
195
+ });
196
+
197
+ // Execute each tool call
198
+ for (const toolCall of assistantMsg.tool_calls as OpenAiToolCall[]) {
199
+ const originalName = restore(toolCall.function.name);
200
+ const argsJson = toolCall.function.arguments;
201
+ let args: Record<string, any> = {};
202
+ try {
203
+ args = JSON.parse(argsJson);
204
+ } catch {
205
+ args = {};
206
+ }
207
+
208
+ // Persist tool_call message
209
+ await this.prisma.mcp_conversation_message.create({
210
+ data: {
211
+ conversation_id: conversationId,
212
+ role: 'tool_call',
213
+ content: argsJson,
214
+ tool_name: originalName,
215
+ tool_call_id: toolCall.id,
216
+ },
217
+ });
218
+
219
+ // Execute the tool
220
+ let resultContent: string;
221
+ const entry = this.registry.get(originalName);
222
+ if (entry) {
223
+ const context: McpContext = {
224
+ userId,
225
+ sessionId: '',
226
+ locale,
227
+ toolName: originalName,
228
+ };
229
+ try {
230
+ const result = await entry.handler(args, context);
231
+ resultContent = JSON.stringify(result);
232
+ } catch (e: any) {
233
+ resultContent = JSON.stringify({ error: e.message });
234
+ }
235
+ } else {
236
+ resultContent = JSON.stringify({ error: `Tool '${originalName}' not found` });
237
+ }
238
+
239
+ // Persist tool_result message
240
+ await this.prisma.mcp_conversation_message.create({
241
+ data: {
242
+ conversation_id: conversationId,
243
+ role: 'tool_result',
244
+ content: resultContent,
245
+ tool_name: originalName,
246
+ tool_call_id: toolCall.id,
247
+ },
248
+ });
249
+
250
+ // Add tool result to context for next iteration
251
+ openAiMessages.push({
252
+ role: 'tool',
253
+ tool_call_id: toolCall.id,
254
+ content: resultContent,
255
+ });
256
+ }
257
+
258
+ continue; // next iteration with tool results
259
+ }
260
+
261
+ // Final assistant response
262
+ const finalContent: string = assistantMsg?.content || '';
263
+ await this.prisma.mcp_conversation_message.create({
264
+ data: {
265
+ conversation_id: conversationId,
266
+ role: 'assistant',
267
+ content: finalContent,
268
+ },
269
+ });
270
+
271
+ return this.prisma.mcp_conversation_message.findMany({
272
+ where: { conversation_id: conversationId },
273
+ orderBy: { created_at: 'asc' },
274
+ });
275
+ }
276
+
277
+ throw new BadRequestException('Maximum tool call iterations exceeded.');
278
+ }
279
+
280
+ private toOpenAiMessages(
281
+ history: Array<{
282
+ role: string;
283
+ content: string;
284
+ tool_name: string | null;
285
+ tool_call_id: string | null;
286
+ }>,
287
+ ): OpenAiMessage[] {
288
+ const messages: OpenAiMessage[] = [];
289
+ const toolCallMap = new Map<string, string>(); // tool_call_id → sanitized name
290
+
291
+ for (const msg of history) {
292
+ if (msg.role === 'user') {
293
+ messages.push({ role: 'user', content: msg.content });
294
+ } else if (msg.role === 'assistant') {
295
+ messages.push({ role: 'assistant', content: msg.content });
296
+ } else if (msg.role === 'tool_call' && msg.tool_call_id && msg.tool_name) {
297
+ const sanitizedName = sanitize(msg.tool_name);
298
+ toolCallMap.set(msg.tool_call_id, sanitizedName);
299
+ messages.push({
300
+ role: 'assistant',
301
+ content: null,
302
+ tool_calls: [
303
+ {
304
+ id: msg.tool_call_id,
305
+ type: 'function',
306
+ function: { name: sanitizedName, arguments: msg.content },
307
+ },
308
+ ],
309
+ });
310
+ } else if (msg.role === 'tool_result' && msg.tool_call_id) {
311
+ messages.push({
312
+ role: 'tool',
313
+ tool_call_id: msg.tool_call_id,
314
+ content: msg.content,
315
+ });
316
+ }
317
+ }
318
+
319
+ return messages;
320
+ }
321
+
322
+ private async assertOwnership(conversationId: number, userId: number) {
323
+ const conv = await this.prisma.mcp_conversation.findUnique({
324
+ where: { id: conversationId },
325
+ select: { user_id: true },
326
+ });
327
+ if (!conv) {
328
+ throw new NotFoundException(`Conversation ${conversationId} not found.`);
329
+ }
330
+ if (conv.user_id !== userId) {
331
+ throw new ForbiddenException('Access denied to this conversation.');
332
+ }
333
+ }
334
+
335
+ private async getOpenAiKey(): Promise<string> {
336
+ const settings = await this.setting.getSettingValues(['ai-openai-api-key']);
337
+ const key = settings['ai-openai-api-key'];
338
+ if (!key) {
339
+ throw new BadRequestException(
340
+ 'OpenAI API key is not configured (setting "ai-openai-api-key").',
341
+ );
342
+ }
343
+ return key;
344
+ }
345
+
346
+ private buildOpenAiTools(
347
+ message: string,
348
+ conversationId: number,
349
+ userId: number,
350
+ ): OpenAiToolDefinition[] {
351
+ const allEntries = this.registry.getAll();
352
+
353
+ const dedupedBySanitized = new Map<string, ScoredTool>();
354
+ const excludedDuplicates: string[] = [];
355
+ const normalizedMessage = message.toLowerCase();
356
+
357
+ for (const [index, entry] of allEntries.entries()) {
358
+ const originalName = entry.definition.name;
359
+ const sanitizedName = sanitize(originalName);
360
+ const readOnly = Boolean(entry.definition.readOnly);
361
+
362
+ if (dedupedBySanitized.has(sanitizedName)) {
363
+ excludedDuplicates.push(originalName);
364
+ continue;
365
+ }
366
+
367
+ const scored: ScoredTool = {
368
+ name: originalName,
369
+ readOnly,
370
+ priorityBucket: this.getDomainPriorityBucket(originalName),
371
+ keywordBoost: this.getKeywordBoost(normalizedMessage, originalName),
372
+ index,
373
+ tool: {
374
+ type: 'function',
375
+ function: {
376
+ name: sanitizedName,
377
+ description: entry.definition.description,
378
+ parameters: entry.definition.inputSchema ?? {
379
+ type: 'object',
380
+ properties: {},
381
+ },
382
+ },
383
+ },
384
+ };
385
+
386
+ dedupedBySanitized.set(sanitizedName, scored);
387
+ }
388
+
389
+ const scoredTools = Array.from(dedupedBySanitized.values());
390
+ scoredTools.sort((a, b) => {
391
+ if (a.priorityBucket !== b.priorityBucket) {
392
+ return a.priorityBucket - b.priorityBucket;
393
+ }
394
+ if (a.keywordBoost !== b.keywordBoost) {
395
+ return b.keywordBoost - a.keywordBoost;
396
+ }
397
+ if (a.readOnly !== b.readOnly) {
398
+ return a.readOnly ? -1 : 1;
399
+ }
400
+ return a.index - b.index;
401
+ });
402
+
403
+ const selected = scoredTools.slice(0, MAX_OPENAI_TOOLS);
404
+ const excludedByLimit = scoredTools
405
+ .slice(MAX_OPENAI_TOOLS)
406
+ .map((tool) => tool.name);
407
+
408
+ if (excludedDuplicates.length > 0) {
409
+ this.logger.warn(
410
+ `MCP tools duplicate sanitize names ignored: ${excludedDuplicates.length}. ` +
411
+ `Examples: ${excludedDuplicates.slice(0, 10).join(', ')}`,
412
+ );
413
+ }
414
+
415
+ if (excludedByLimit.length > 0) {
416
+ this.logger.warn(
417
+ `MCP tools truncated for OpenAI limit (${MAX_OPENAI_TOOLS}). ` +
418
+ `conversationId=${conversationId} userId=${userId} ` +
419
+ `available=${scoredTools.length} selected=${selected.length} excluded=${excludedByLimit.length}. ` +
420
+ `Top excluded: ${excludedByLimit.slice(0, 12).join(', ')}`,
421
+ );
422
+ }
423
+
424
+ this.logger.log(
425
+ `MCP tools prepared: conversationId=${conversationId} userId=${userId} ` +
426
+ `raw=${allEntries.length} deduped=${scoredTools.length} selected=${selected.length}`,
427
+ );
428
+
429
+ return selected.map((entry) => entry.tool);
430
+ }
431
+
432
+ private getDomainPriorityBucket(toolName: string): number {
433
+ if (toolName.startsWith('operations.')) return 0;
434
+ if (toolName.startsWith('lms.')) return 1;
435
+ if (toolName.startsWith('core.')) return 2;
436
+ return 3;
437
+ }
438
+
439
+ private getKeywordBoost(message: string, toolName: string): number {
440
+ const terms = this.getMessageKeywordSet(message);
441
+ if (terms.size === 0) {
442
+ return 0;
443
+ }
444
+
445
+ let boost = 0;
446
+ if (
447
+ terms.has('task') &&
448
+ (toolName.includes('.task') || toolName.includes('.tasks.'))
449
+ ) {
450
+ boost += 4;
451
+ }
452
+ if (
453
+ terms.has('project') &&
454
+ (toolName.includes('.project') || toolName.includes('.projects.'))
455
+ ) {
456
+ boost += 3;
457
+ }
458
+ if (
459
+ terms.has('class') &&
460
+ (toolName.includes('.class') ||
461
+ toolName.includes('.course') ||
462
+ toolName.includes('.training'))
463
+ ) {
464
+ boost += 3;
465
+ }
466
+ if (
467
+ terms.has('pending') &&
468
+ (toolName.includes('.task') ||
469
+ toolName.includes('.approval') ||
470
+ toolName.includes('.time-off'))
471
+ ) {
472
+ boost += 2;
473
+ }
474
+
475
+ return boost;
476
+ }
477
+
478
+ private getMessageKeywordSet(message: string): Set<string> {
479
+ const keywords = new Set<string>();
480
+ if (/(tarefa|tarefas|task|tasks)/i.test(message)) {
481
+ keywords.add('task');
482
+ }
483
+ if (/(projeto|projetos|project|projects)/i.test(message)) {
484
+ keywords.add('project');
485
+ }
486
+ if (/(turma|class|curso|cursos|course|training)/i.test(message)) {
487
+ keywords.add('class');
488
+ }
489
+ if (/(pendente|pendentes|pending|open|aberta|abertas)/i.test(message)) {
490
+ keywords.add('pending');
491
+ }
492
+ return keywords;
493
+ }
494
+ }