@promptbook/cli 0.104.0-2 → 0.104.0-4

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 (66) hide show
  1. package/apps/agents-server/package.json +3 -4
  2. package/apps/agents-server/public/swagger.json +115 -0
  3. package/apps/agents-server/scripts/generate-reserved-paths/generate-reserved-paths.ts +11 -7
  4. package/apps/agents-server/src/app/AddAgentButton.tsx +1 -2
  5. package/apps/agents-server/src/app/admin/chat-feedback/ChatFeedbackClient.tsx +221 -274
  6. package/apps/agents-server/src/app/admin/chat-history/ChatHistoryClient.tsx +94 -137
  7. package/apps/agents-server/src/app/admin/metadata/MetadataClient.tsx +8 -8
  8. package/apps/agents-server/src/app/agents/[agentName]/AgentChatWrapper.tsx +15 -1
  9. package/apps/agents-server/src/app/agents/[agentName]/AgentOptionsMenu.tsx +1 -3
  10. package/apps/agents-server/src/app/agents/[agentName]/AgentProfileChat.tsx +29 -16
  11. package/apps/agents-server/src/app/agents/[agentName]/api/chat/route.ts +3 -0
  12. package/apps/agents-server/src/app/agents/[agentName]/api/mcp/route.ts +6 -11
  13. package/apps/agents-server/src/app/agents/[agentName]/api/voice/route.ts +4 -1
  14. package/apps/agents-server/src/app/agents/[agentName]/code/api/route.ts +8 -6
  15. package/apps/agents-server/src/app/agents/[agentName]/code/page.tsx +33 -30
  16. package/apps/agents-server/src/app/api/agents/[agentName]/clone/route.ts +10 -12
  17. package/apps/agents-server/src/app/api/agents/[agentName]/route.ts +1 -2
  18. package/apps/agents-server/src/app/api/agents/route.ts +1 -1
  19. package/apps/agents-server/src/app/api/api-tokens/route.ts +6 -7
  20. package/apps/agents-server/src/app/api/docs/book.md/route.ts +3 -0
  21. package/apps/agents-server/src/app/api/metadata/route.ts +5 -6
  22. package/apps/agents-server/src/app/api/upload/route.ts +9 -0
  23. package/apps/agents-server/src/app/page.tsx +1 -1
  24. package/apps/agents-server/src/app/swagger/page.tsx +14 -0
  25. package/apps/agents-server/src/components/AgentProfile/AgentProfile.tsx +4 -2
  26. package/apps/agents-server/src/components/AgentProfile/QrCodeModal.tsx +0 -1
  27. package/apps/agents-server/src/components/Auth/AuthControls.tsx +5 -4
  28. package/apps/agents-server/src/components/Header/Header.tsx +27 -5
  29. package/apps/agents-server/src/components/Homepage/AgentCard.tsx +22 -3
  30. package/apps/agents-server/src/components/_utils/headlessParam.tsx +7 -3
  31. package/apps/agents-server/src/database/migrate.ts +34 -1
  32. package/apps/agents-server/src/database/migrations/2025-12-0402-message-table.sql +42 -0
  33. package/apps/agents-server/src/generated/reservedPaths.ts +6 -1
  34. package/apps/agents-server/src/message-providers/email/_common/Email.ts +73 -0
  35. package/apps/agents-server/src/message-providers/email/_common/utils/TODO.txt +1 -0
  36. package/apps/agents-server/src/message-providers/email/_common/utils/parseEmailAddress.test.ts.todo +108 -0
  37. package/apps/agents-server/src/message-providers/email/_common/utils/parseEmailAddress.ts +62 -0
  38. package/apps/agents-server/src/message-providers/email/_common/utils/parseEmailAddresses.test.ts.todo +117 -0
  39. package/apps/agents-server/src/message-providers/email/_common/utils/parseEmailAddresses.ts +19 -0
  40. package/apps/agents-server/src/message-providers/email/_common/utils/stringifyEmailAddress.test.ts.todo +119 -0
  41. package/apps/agents-server/src/message-providers/email/_common/utils/stringifyEmailAddress.ts +19 -0
  42. package/apps/agents-server/src/message-providers/email/_common/utils/stringifyEmailAddresses.test.ts.todo +74 -0
  43. package/apps/agents-server/src/message-providers/email/_common/utils/stringifyEmailAddresses.ts +14 -0
  44. package/apps/agents-server/src/message-providers/email/sendgrid/SendgridMessageProvider.ts +44 -0
  45. package/apps/agents-server/src/message-providers/email/zeptomail/ZeptomailMessageProvider.ts +43 -0
  46. package/apps/agents-server/src/message-providers/index.ts +13 -0
  47. package/apps/agents-server/src/message-providers/interfaces/MessageProvider.ts +11 -0
  48. package/apps/agents-server/src/middleware.ts +12 -3
  49. package/apps/agents-server/src/utils/auth.ts +117 -17
  50. package/apps/agents-server/src/utils/getUserIdFromRequest.ts +3 -1
  51. package/apps/agents-server/src/utils/handleChatCompletion.ts +9 -5
  52. package/apps/agents-server/src/utils/messages/sendMessage.ts +91 -0
  53. package/apps/agents-server/src/utils/normalization/filenameToPrompt.test.ts +36 -0
  54. package/apps/agents-server/src/utils/normalization/filenameToPrompt.ts +6 -2
  55. package/apps/agents-server/src/utils/validateApiKey.ts +5 -10
  56. package/esm/index.es.js +86 -9
  57. package/esm/index.es.js.map +1 -1
  58. package/esm/typings/src/_packages/types.index.d.ts +2 -0
  59. package/esm/typings/src/book-components/Chat/types/ChatMessage.d.ts +7 -11
  60. package/esm/typings/src/llm-providers/_multiple/MultipleLlmExecutionTools.d.ts +6 -2
  61. package/esm/typings/src/llm-providers/remote/RemoteLlmExecutionTools.d.ts +1 -0
  62. package/esm/typings/src/types/Message.d.ts +49 -0
  63. package/esm/typings/src/version.d.ts +1 -1
  64. package/package.json +1 -1
  65. package/umd/index.umd.js +86 -9
  66. package/umd/index.umd.js.map +1 -1
@@ -0,0 +1,43 @@
1
+ import { removeMarkdownFormatting } from '@promptbook-local/markdown-utils';
2
+ import type { really_any } from '@promptbook-local/types';
3
+ import { marked } from 'marked';
4
+ // @ts-expect-error: Zeptomail types are not resolving correctly
5
+ import { SendMailClient } from 'zeptomail';
6
+ import { MessageProvider } from '../../interfaces/MessageProvider';
7
+ import { OutboundEmail } from '../_common/Email';
8
+
9
+ export class ZeptomailMessageProvider implements MessageProvider {
10
+ constructor(private readonly apiKey: string) {}
11
+
12
+ public async send(message: OutboundEmail): Promise<really_any> {
13
+ const client = new SendMailClient({ url: 'api.zeptomail.com/', token: this.apiKey });
14
+
15
+ const sender = message.sender as really_any;
16
+ const recipients = (Array.isArray(message.recipients) ? message.recipients : [message.recipients]).filter(
17
+ Boolean,
18
+ ) as really_any[];
19
+
20
+ const textbody = removeMarkdownFormatting(message.content);
21
+ const htmlbody = await marked.parse(message.content);
22
+
23
+ const response = await client.sendMail({
24
+ from: {
25
+ address: sender.email || sender.baseEmail || sender,
26
+ name: sender.name || sender.fullName || undefined,
27
+ },
28
+ to: recipients.map((r) => ({
29
+ email_address: {
30
+ address: r.email || r.baseEmail || r,
31
+ name: r.name || r.fullName || undefined,
32
+ },
33
+ })),
34
+ subject: message.metadata?.subject || 'No Subject',
35
+ textbody,
36
+ htmlbody,
37
+ track_clicks: true,
38
+ track_opens: true,
39
+ });
40
+
41
+ return response;
42
+ }
43
+ }
@@ -0,0 +1,13 @@
1
+ import { SendgridMessageProvider } from './email/sendgrid/SendgridMessageProvider';
2
+ import { ZeptomailMessageProvider } from './email/zeptomail/ZeptomailMessageProvider';
3
+ import { MessageProvider } from './interfaces/MessageProvider';
4
+
5
+ export const EMAIL_PROVIDERS: Record<string, MessageProvider> = {};
6
+
7
+ if (process.env.ZEPTOMAIL_API_KEY) {
8
+ EMAIL_PROVIDERS['ZEPTOMAIL'] = new ZeptomailMessageProvider(process.env.ZEPTOMAIL_API_KEY);
9
+ }
10
+
11
+ if (process.env.SENDGRID_API_KEY) {
12
+ EMAIL_PROVIDERS['SENDGRID'] = new SendgridMessageProvider(process.env.SENDGRID_API_KEY);
13
+ }
@@ -0,0 +1,11 @@
1
+ import type { Message, really_any, string_email } from '@promptbook-local/types';
2
+
3
+ export type MessageProvider = {
4
+ /**
5
+ * Sends a message through the provider
6
+ *
7
+ * @param message The message to send
8
+ * @returns Raw response from the provider
9
+ */
10
+ send(message: Message<string_email>): Promise<really_any>;
11
+ };
@@ -1,7 +1,7 @@
1
1
  import { TODO_any } from '@promptbook-local/types';
2
2
  import { createClient } from '@supabase/supabase-js';
3
3
  import { NextRequest, NextResponse } from 'next/server';
4
- import { SERVERS, SUPABASE_TABLE_PREFIX } from '../config';
4
+ import { SERVERS } from '../config';
5
5
  import { $getTableName } from './database/$getTableName';
6
6
  import { RESERVED_PATHS } from './generated/reservedPaths';
7
7
  import { isIpAllowed } from './utils/isIpAllowed';
@@ -9,7 +9,7 @@ import { isIpAllowed } from './utils/isIpAllowed';
9
9
  // Note: Re-implementing normalizeTo_PascalCase to avoid importing from @promptbook-local/utils which might have Node.js dependencies !!!!
10
10
  function normalizeTo_PascalCase(text: string): string {
11
11
  return text
12
- .replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => {
12
+ .replace(/(?:^\w|[A-Z]|\b\w)/g, (word) => {
13
13
  return word.toUpperCase();
14
14
  })
15
15
  .replace(/\s+/g, '');
@@ -35,8 +35,12 @@ export async function middleware(req: NextRequest) {
35
35
  const host = req.headers.get('host');
36
36
 
37
37
  if (host) {
38
+ /*
39
+ Note: [🐔] This code was commented out because results of it are unused
40
+
38
41
  let tablePrefix = SUPABASE_TABLE_PREFIX;
39
42
 
43
+
40
44
  if (SERVERS && SERVERS.length > 0) {
41
45
  // Logic mirrored from src/tools/$provideServer.ts
42
46
  if (SERVERS.some((server) => server === host)) {
@@ -46,6 +50,7 @@ export async function middleware(req: NextRequest) {
46
50
  tablePrefix = `server_${serverName}_`;
47
51
  }
48
52
  }
53
+ */
49
54
 
50
55
  const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
51
56
  const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
@@ -84,6 +89,9 @@ export async function middleware(req: NextRequest) {
84
89
  const token = authHeader.split(' ')[1];
85
90
 
86
91
  if (token.startsWith('ptbk_')) {
92
+ /*
93
+ Note: [🐔] This code was commented out because results of it are unused
94
+
87
95
  const host = req.headers.get('host');
88
96
  let tablePrefix = SUPABASE_TABLE_PREFIX;
89
97
 
@@ -95,6 +103,7 @@ export async function middleware(req: NextRequest) {
95
103
  tablePrefix = `server_${serverName}_`;
96
104
  }
97
105
  }
106
+ */
98
107
 
99
108
  const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
100
109
  const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
@@ -230,7 +239,7 @@ export async function middleware(req: NextRequest) {
230
239
  let serverName = serverHost;
231
240
  serverName = serverName.replace(/\.ptbk\.io$/, '');
232
241
  serverName = normalizeTo_PascalCase(serverName);
233
- const prefix = `server_${serverName}_`;
242
+ // const prefix = `server_${serverName}_`;
234
243
 
235
244
  // Search for agent with matching META LINK
236
245
  // agentProfile->links is an array of strings
@@ -1,33 +1,133 @@
1
- import { randomBytes, scrypt, timingSafeEqual } from 'crypto';
1
+ import { createHash, randomBytes, scrypt, timingSafeEqual } from 'crypto';
2
2
  import { promisify } from 'util';
3
+ import { PASSWORD_SECURITY_CONFIG } from '../../../../security.config';
3
4
 
4
5
  const scryptAsync = promisify(scrypt);
5
6
 
6
7
  /**
7
- * Hashes a password using scrypt
8
- *
9
- * @param password The plain text password
8
+ * Validates password input to prevent edge cases and DoS attacks
9
+ *
10
+ * @param password The password to validate
11
+ * @throws Error if password is invalid
12
+ */
13
+ function validatePasswordInput(password: string): void {
14
+ if (typeof password !== 'string') {
15
+ throw new Error('Password must be a string');
16
+ }
17
+ if (password.length === 0) {
18
+ throw new Error('Password cannot be empty');
19
+ }
20
+ if (password.length < PASSWORD_SECURITY_CONFIG.MIN_PASSWORD_LENGTH) {
21
+ throw new Error(`Password must be at least ${PASSWORD_SECURITY_CONFIG.MIN_PASSWORD_LENGTH} characters`);
22
+ }
23
+ // Note: No hard max limit - long passwords are compacted via compactPassword()
24
+ }
25
+
26
+ /**
27
+ * Compacts a password for secure processing
28
+ *
29
+ * If the password is within MAX_PASSWORD_LENGTH, it is returned as-is.
30
+ * If longer, the password is split at MAX_PASSWORD_LENGTH and the second part
31
+ * is hashed with SHA256 before being appended to the first part.
32
+ *
33
+ * This prevents DoS attacks via extremely long passwords while still utilizing
34
+ * the full entropy of longer passwords.
35
+ *
36
+ * @param password The password to compact
37
+ * @returns The compacted password
38
+ */
39
+ function compactPassword(password: string): string {
40
+ if (password.length <= PASSWORD_SECURITY_CONFIG.MAX_PASSWORD_LENGTH) {
41
+ return password;
42
+ }
43
+
44
+ const firstPart = password.slice(0, PASSWORD_SECURITY_CONFIG.MAX_PASSWORD_LENGTH);
45
+ const secondPart = password.slice(PASSWORD_SECURITY_CONFIG.MAX_PASSWORD_LENGTH);
46
+
47
+ // Hash the overflow part with SHA256 to bound its length while preserving entropy
48
+ const secondPartHash = createHash('sha256').update(secondPart, 'utf8').digest('hex');
49
+
50
+ return firstPart + secondPartHash;
51
+ }
52
+
53
+ /**
54
+ * Hashes a password using scrypt with secure parameters
55
+ *
56
+ * @param password The plain text password (minimum 8 characters, no maximum - long passwords are compacted)
10
57
  * @returns The salt and hash formatted as "salt:hash"
58
+ * @throws Error if password validation fails
11
59
  */
12
60
  export async function hashPassword(password: string): Promise<string> {
13
- const salt = randomBytes(16).toString('hex');
14
- const derivedKey = (await scryptAsync(password, salt, 64)) as Buffer;
61
+ validatePasswordInput(password);
62
+
63
+ // Compact long passwords to prevent DoS while preserving entropy
64
+ const compactedPassword = compactPassword(password);
65
+
66
+ const salt = randomBytes(PASSWORD_SECURITY_CONFIG.SALT_LENGTH).toString('hex');
67
+ const derivedKey = (await scryptAsync(compactedPassword, salt, PASSWORD_SECURITY_CONFIG.KEY_LENGTH)) as Buffer;
68
+
69
+ // Clear password from memory as soon as possible (best effort)
70
+ // Note: JavaScript strings are immutable, so this is limited in effectiveness
15
71
  return `${salt}:${derivedKey.toString('hex')}`;
16
72
  }
17
73
 
18
74
  /**
19
- * Verifies a password against a stored hash
20
- *
21
- * @param password The plain text password
75
+ * Verifies a password against a stored hash using constant-time comparison
76
+ *
77
+ * @param password The plain text password to verify
22
78
  * @param storedHash The stored hash in format "salt:hash"
23
- * @returns True if the password matches
79
+ * @returns True if the password matches, false otherwise
24
80
  */
25
81
  export async function verifyPassword(password: string, storedHash: string): Promise<boolean> {
26
- const [salt, key] = storedHash.split(':');
27
- if (!salt || !key) return false;
28
-
29
- const derivedKey = (await scryptAsync(password, salt, 64)) as Buffer;
30
- const keyBuffer = Buffer.from(key, 'hex');
31
-
32
- return timingSafeEqual(derivedKey, keyBuffer);
82
+ // Validate inputs
83
+ if (typeof password !== 'string' || typeof storedHash !== 'string') {
84
+ return false;
85
+ }
86
+
87
+ if (password.length === 0) {
88
+ return false;
89
+ }
90
+
91
+ // Compact long passwords the same way as during hashing
92
+ const compactedPassword = compactPassword(password);
93
+
94
+ const parts = storedHash.split(':');
95
+ if (parts.length !== 2) {
96
+ return false;
97
+ }
98
+
99
+ const [salt, key] = parts;
100
+ if (!salt || !key) {
101
+ return false;
102
+ }
103
+
104
+ // Validate salt and key format (should be hex strings of expected length)
105
+ const expectedSaltLength = PASSWORD_SECURITY_CONFIG.SALT_LENGTH * 2; // hex encoding doubles length
106
+ const expectedKeyLength = PASSWORD_SECURITY_CONFIG.KEY_LENGTH * 2;
107
+
108
+ if (salt.length !== expectedSaltLength || key.length !== expectedKeyLength) {
109
+ return false;
110
+ }
111
+
112
+ // Validate hex format
113
+ const hexRegex = /^[0-9a-fA-F]+$/;
114
+ if (!hexRegex.test(salt) || !hexRegex.test(key)) {
115
+ return false;
116
+ }
117
+
118
+ try {
119
+ const derivedKey = (await scryptAsync(compactedPassword, salt, PASSWORD_SECURITY_CONFIG.KEY_LENGTH)) as Buffer;
120
+ const keyBuffer = Buffer.from(key, 'hex');
121
+
122
+ // Ensure buffers are same length before timing-safe comparison
123
+ // This should always be true given our validation, but defense in depth
124
+ if (derivedKey.length !== keyBuffer.length) {
125
+ return false;
126
+ }
127
+
128
+ return timingSafeEqual(derivedKey, keyBuffer);
129
+ } catch {
130
+ // Any error during verification should return false, not leak information
131
+ return false;
132
+ }
33
133
  }
@@ -1,9 +1,12 @@
1
1
  import { NextRequest } from 'next/server';
2
+ import { keepUnused } from '../../../../src/utils/organization/keepUnused';
2
3
  import { $getTableName } from '../database/$getTableName';
3
4
  import { $provideSupabaseForServer } from '../database/$provideSupabaseForServer';
4
5
  import { getSession } from './session';
5
6
 
6
7
  export async function getUserIdFromRequest(request: NextRequest): Promise<number | null> {
8
+ keepUnused(request); // Unused because we get user from session cookie for now
9
+
7
10
  try {
8
11
  // 1. Try to get user from session (cookie)
9
12
  const session = await getSession();
@@ -24,7 +27,6 @@ export async function getUserIdFromRequest(request: NextRequest): Promise<number
24
27
  // TODO: [🧠] Implement linking API keys to users if needed
25
28
  // const authHeader = request.headers.get('authorization');
26
29
  // ...
27
-
28
30
  } catch (error) {
29
31
  console.error('Error getting user ID from request:', error);
30
32
  }
@@ -3,12 +3,11 @@ import { $provideSupabaseForServer } from '@/src/database/$provideSupabaseForSer
3
3
  import { $provideAgentCollectionForServer } from '@/src/tools/$provideAgentCollectionForServer';
4
4
  import { $provideOpenAiAssistantExecutionToolsForServer } from '@/src/tools/$provideOpenAiAssistantExecutionToolsForServer';
5
5
  import { Agent, computeAgentHash, parseAgentSource, PROMPTBOOK_ENGINE_VERSION } from '@promptbook-local/core';
6
- import { OpenAiAssistantExecutionTools } from '@promptbook-local/openai';
7
6
  import { ChatMessage, ChatPromptResult, Prompt, string_book, TODO_any } from '@promptbook-local/types';
8
7
  import { computeHash } from '@promptbook-local/utils';
9
8
  import { NextRequest, NextResponse } from 'next/server';
10
- import { validateApiKey } from './validateApiKey';
11
9
  import { isAgentDeleted } from '../app/agents/[agentName]/_utils';
10
+ import { validateApiKey } from './validateApiKey';
12
11
 
13
12
  export async function handleChatCompletion(
14
13
  request: NextRequest,
@@ -125,7 +124,9 @@ export async function handleChatCompletion(
125
124
  let openAiAssistantExecutionTools = await $provideOpenAiAssistantExecutionToolsForServer();
126
125
 
127
126
  if (assistantCache?.assistantId) {
128
- console.log(`[🐱‍🚀] Reusing assistant ${assistantCache.assistantId} for agent ${agentName} (hash: ${agentHash})`);
127
+ console.log(
128
+ `[🐱‍🚀] Reusing assistant ${assistantCache.assistantId} for agent ${agentName} (hash: ${agentHash})`,
129
+ );
129
130
  openAiAssistantExecutionTools = openAiAssistantExecutionTools.getAssistant(assistantCache.assistantId);
130
131
  } else {
131
132
  console.log(`[🐱‍🚀] Creating NEW assistant for agent ${agentName} (hash: ${agentHash})`);
@@ -138,7 +139,9 @@ export async function handleChatCompletion(
138
139
  // Note: Append context to instructions
139
140
  const contextLines = agentSource.split('\n').filter((line) => line.startsWith('CONTEXT '));
140
141
  const contextInstructions = contextLines.join('\n');
141
- const instructions = contextInstructions ? `${baseInstructions}\n\n${contextInstructions}` : baseInstructions;
142
+ const instructions = contextInstructions
143
+ ? `${baseInstructions}\n\n${contextInstructions}`
144
+ : baseInstructions;
142
145
 
143
146
  // Create assistant
144
147
  const newAssistantTools = await openAiAssistantExecutionTools.createNewAssistant({
@@ -182,8 +185,9 @@ export async function handleChatCompletion(
182
185
  const previousMessages = threadMessages.slice(0, -1);
183
186
 
184
187
  const thread: ChatMessage[] = previousMessages.map((msg: TODO_any, index: number) => ({
188
+ // channel: 'PROMPTBOOK_CHAT',
185
189
  id: `msg-${index}`, // Placeholder ID
186
- from: msg.role === 'assistant' ? 'agent' : 'user', // Mapping standard OpenAI roles
190
+ sender: msg.role === 'assistant' ? 'agent' : 'user', // Mapping standard OpenAI roles
187
191
  content: msg.content,
188
192
  isComplete: true,
189
193
  date: new Date(), // We don't have the real date, using current
@@ -0,0 +1,91 @@
1
+ import type { really_any } from '@promptbook-local/types';
2
+ import { $getTableName } from '../../database/$getTableName';
3
+ import { $provideSupabaseForServer } from '../../database/$provideSupabaseForServer';
4
+ import { EMAIL_PROVIDERS } from '../../message-providers';
5
+ import { OutboundEmail } from '../../message-providers/email/_common/Email';
6
+
7
+ /**
8
+ * Sends a message
9
+ */
10
+ export async function sendMessage(message: OutboundEmail): Promise<void> {
11
+ const supabase = await $provideSupabaseForServer();
12
+ // @ts-expect-error: Tables are not yet in types
13
+ const messageTable = await $getTableName('Message');
14
+ // @ts-expect-error: Tables are not yet in types
15
+ const messageSendAttemptTable = await $getTableName('MessageSendAttempt');
16
+
17
+ // 1. Insert message
18
+ const { data: insertedMessage, error: insertError } = await supabase
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
+ .from(messageTable as any)
21
+ .insert({
22
+ channel: message.channel || 'UNKNOWN',
23
+ direction: message.direction || 'OUTBOUND',
24
+ sender: message.sender,
25
+ recipients: message.recipients,
26
+ content: message.content,
27
+ threadId: message.threadId,
28
+ metadata: message.metadata,
29
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
+ } as any)
31
+ .select()
32
+ .single();
33
+
34
+ if (insertError) {
35
+ throw new Error(`Failed to insert message: ${insertError.message}`);
36
+ }
37
+
38
+ if (!insertedMessage) {
39
+ throw new Error('Failed to insert message: No data returned');
40
+ }
41
+
42
+ // 2. If outbound and email, try to send
43
+ if (message.direction === 'OUTBOUND' && message.channel === 'EMAIL') {
44
+ const providers = Object.keys(EMAIL_PROVIDERS);
45
+
46
+ if (providers.length === 0) {
47
+ console.warn('No email providers configured');
48
+ return;
49
+ }
50
+
51
+ let isSent = false;
52
+
53
+ for (const providerName of providers) {
54
+ const provider = EMAIL_PROVIDERS[providerName];
55
+ let isSuccessful = false;
56
+ let raw: really_any = null;
57
+
58
+ try {
59
+ raw = await provider.send(message);
60
+ isSuccessful = true;
61
+ isSent = true;
62
+ } catch (error) {
63
+ console.error(`Failed to send email via ${providerName}`, error);
64
+ raw = { error: error instanceof Error ? error.message : String(error) };
65
+ }
66
+
67
+ // 3. Log attempt
68
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
69
+ await supabase.from(messageSendAttemptTable as any).insert({
70
+ // @ts-expect-error: insertedMessage is any
71
+ messageId: insertedMessage.id,
72
+ providerName,
73
+ isSuccessful,
74
+ raw,
75
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
76
+ } as any);
77
+
78
+ if (isSuccessful) {
79
+ break;
80
+ }
81
+ }
82
+
83
+ if (!isSent) {
84
+ throw new Error('Failed to send email via any provider');
85
+ }
86
+ }
87
+ }
88
+
89
+ /**
90
+ * TODO: !!!! Move to `message-providers` and rename `message-providers` -> `messages`
91
+ */
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from '@jest/globals';
2
+ import { filenameToPrompt } from './filenameToPrompt';
3
+
4
+ describe('how filenameToPrompt works', () => {
5
+ it('will convert filename with dashes', () => {
6
+ expect(filenameToPrompt('cat-sitting-on-keyboard.png')).toEqual('Cat sitting on keyboard');
7
+ expect(filenameToPrompt('hello-world.jpg')).toEqual('Hello world');
8
+ });
9
+
10
+ it('will convert filename with underscores', () => {
11
+ expect(filenameToPrompt('cat_sitting_on_keyboard.png')).toEqual('Cat sitting on keyboard');
12
+ expect(filenameToPrompt('hello_world.jpg')).toEqual('Hello world');
13
+ });
14
+
15
+ it('will convert filename with mixed separators', () => {
16
+ expect(filenameToPrompt('cat-sitting_on-keyboard.png')).toEqual('Cat sitting on keyboard');
17
+ });
18
+
19
+ it('will handle single word filename', () => {
20
+ expect(filenameToPrompt('cat.png')).toEqual('Cat');
21
+ expect(filenameToPrompt('HELLO.jpg')).toEqual('HELLO');
22
+ });
23
+
24
+ it('will handle filename without extension', () => {
25
+ expect(filenameToPrompt('cat-sitting-on-keyboard')).toEqual('Cat sitting on keyboard');
26
+ });
27
+
28
+ it('will handle filename with multiple dots', () => {
29
+ expect(filenameToPrompt('cat.sitting.on.keyboard.png')).toEqual('Cat.sitting.on.keyboard');
30
+ });
31
+
32
+ it('will handle capitalized words after first word', () => {
33
+ expect(filenameToPrompt('Cat-Sitting-On-Keyboard.png')).toEqual('Cat sitting on keyboard');
34
+ expect(filenameToPrompt('HELLO-WORLD.png')).toEqual('HELLO world');
35
+ });
36
+ });
@@ -1,4 +1,4 @@
1
- import { capitalize } from '../../../../../src/utils/normalization/capitalize';
1
+ import { capitalize } from '@promptbook/utils';
2
2
 
3
3
  /**
4
4
  * Converts a filename like "cat-sitting-on-keyboard.png" to a prompt like "Cat sitting on keyboard"
@@ -15,7 +15,11 @@ export function filenameToPrompt(filename: string): string {
15
15
 
16
16
  // Capitalize each word
17
17
  const words = withSpaces.split(' ');
18
- const capitalizedWords = words.map(word => capitalize(word));
18
+ const capitalizedWords = words.map((word, index) => (index === 0 ? capitalize(word) : word.toLowerCase()));
19
19
 
20
20
  return capitalizedWords.join(' ');
21
21
  }
22
+
23
+ /**
24
+ * TODO: [🧠][🏰] Make standard normalization function exported from `@promptbook/utils`
25
+ */
@@ -1,17 +1,7 @@
1
1
  import { createClient } from '@supabase/supabase-js';
2
2
  import { NextRequest } from 'next/server';
3
- import { SERVERS, SUPABASE_TABLE_PREFIX } from '../../config';
4
3
  import { $getTableName } from '../database/$getTableName';
5
4
 
6
- // Note: Re-implementing normalizeTo_PascalCase to avoid importing from @promptbook-local/utils which might have Node.js dependencies
7
- function normalizeTo_PascalCase(text: string): string {
8
- return text
9
- .replace(/(?:^\w|[A-Z]|\b\w)/g, (word) => {
10
- return word.toUpperCase();
11
- })
12
- .replace(/\s+/g, '');
13
- }
14
-
15
5
  export type ApiKeyValidationResult = {
16
6
  isValid: boolean;
17
7
  token?: string;
@@ -63,10 +53,14 @@ export async function validateApiKey(request: NextRequest): Promise<ApiKeyValida
63
53
  };
64
54
  }
65
55
 
56
+ /*
57
+ Note: [🐔] This code was commented out because results of it are unused
58
+
66
59
  // Determine the table prefix based on the host
67
60
  const host = request.headers.get('host');
68
61
  let tablePrefix = SUPABASE_TABLE_PREFIX;
69
62
 
63
+
70
64
  if (host && SERVERS && SERVERS.length > 0) {
71
65
  if (SERVERS.some((server) => server === host)) {
72
66
  let serverName = host;
@@ -75,6 +69,7 @@ export async function validateApiKey(request: NextRequest): Promise<ApiKeyValida
75
69
  tablePrefix = `server_${serverName}_`;
76
70
  }
77
71
  }
72
+ */
78
73
 
79
74
  const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
80
75
  const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;