Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
2 changes: 2 additions & 0 deletions media/temp/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# This file ensures the media/temp directory is tracked by git
# Temporary media files will be processed in this directory
2 changes: 2 additions & 0 deletions media/uploads/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# This file ensures the media/uploads directory is tracked by git
# Media files will be stored in this directory
181 changes: 179 additions & 2 deletions src/components/SimplifiedChatInterface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ interface SimplifiedChatInterfaceProps {
onNewConversation: () => void
}

type InterfaceMode = 'chat' | 'templates' | 'template-params'
type InterfaceMode = 'chat' | 'templates' | 'template-params' | 'file-input'

export const SimplifiedChatInterface: FC<SimplifiedChatInterfaceProps> = ({
apiClient,
Expand Down Expand Up @@ -44,6 +44,14 @@ export const SimplifiedChatInterface: FC<SimplifiedChatInterfaceProps> = ({
)
const [templatesLoading, setTemplatesLoading] = useState(false)

// File input state
const [filePath, setFilePath] = useState('')
const [fileCaption, setFileCaption] = useState('')
const [fileUploadStatus, setFileUploadStatus] = useState<
'idle' | 'uploading' | 'success' | 'error'
>('idle')
const [fileUploadError, setFileUploadError] = useState('')

const terminal = useTerminal()

// Calculate max messages based on available space - conservative approach
Expand Down Expand Up @@ -122,6 +130,10 @@ export const SimplifiedChatInterface: FC<SimplifiedChatInterfaceProps> = ({
if (key.ctrl && input === 't') {
void handleTemplateMode()
}

if (key.ctrl && input === 'f') {
handleFileInputMode()
}
} else if (mode === 'templates') {
if (key.upArrow && selectedTemplateIndex > 0) {
setSelectedTemplateIndex(selectedTemplateIndex - 1)
Expand All @@ -141,6 +153,18 @@ export const SimplifiedChatInterface: FC<SimplifiedChatInterfaceProps> = ({
} else if (mode === 'template-params') {
// Template parameter input is now handled by TemplateVariableCollector
// No additional input handling needed here
} else if (mode === 'file-input') {
if (key.return && filePath.trim()) {
void handleFileUpload()
}

if (key.escape) {
setMode('chat')
setFilePath('')
setFileCaption('')
setFileUploadStatus('idle')
setFileUploadError('')
}
}
})

Expand All @@ -158,6 +182,14 @@ export const SimplifiedChatInterface: FC<SimplifiedChatInterfaceProps> = ({
}
}

const handleFileInputMode = () => {
setMode('file-input')
setFilePath('')
setFileCaption('')
setFileUploadStatus('idle')
setFileUploadError('')
}

const handleSelectTemplate = (template: Template) => {
setSelectedTemplate(template)
setTemplateParams({})
Expand Down Expand Up @@ -297,6 +329,68 @@ export const SimplifiedChatInterface: FC<SimplifiedChatInterfaceProps> = ({
)
}

const renderFileInput = () => {
const renderFileUploadStatus = () => {
switch (fileUploadStatus) {
case 'uploading':
return <Text color="yellow">Uploading file...</Text>
case 'success':
return <Text color="green">✓ File sent successfully</Text>
case 'error':
return <Text color="red">✗ {fileUploadError}</Text>
default:
return null
}
}

return (
<Box flexDirection="column">
<Box marginBottom={2}>
<Text color="cyan">📎 Send File</Text>
</Box>

<Box flexDirection="column" marginBottom={2}>
<Box marginBottom={1}>
<Text color="white">File Path:</Text>
</Box>
<Box marginBottom={2}>
<TextInput
value={filePath}
onChange={setFilePath}
placeholder="Enter file path (e.g., /path/to/file.jpg)"
focus={true}
/>
</Box>

<Box marginBottom={1}>
<Text color="white">Caption (optional):</Text>
</Box>
<Box marginBottom={2}>
<TextInput
value={fileCaption}
onChange={setFileCaption}
placeholder="Enter caption for the file"
focus={false}
/>
</Box>

{fileUploadStatus !== 'idle' && (
<Box marginBottom={1}>{renderFileUploadStatus()}</Box>
)}

<Box justifyContent="space-between" marginTop={1}>
<Text color="gray" dimColor>
Enter: Send File | Esc: Back to chat
</Text>
<Text color="gray" dimColor>
Supported: images, documents, audio, video
</Text>
</Box>
</Box>
</Box>
)
}

const handleTemplateVariablesComplete = async (
parameters: Record<string, string>
) => {
Expand Down Expand Up @@ -343,6 +437,87 @@ export const SimplifiedChatInterface: FC<SimplifiedChatInterfaceProps> = ({
setTemplateParams({})
}

const handleFileUpload = async () => {
if (!filePath.trim()) {
setFileUploadError('Please enter a file path')
return
}

setFileUploadStatus('uploading')
setFileUploadError('')

try {
// Determine file type from extension
const extension = filePath.toLowerCase().split('.').pop() || ''
let messageType: 'image' | 'document' | 'audio' | 'video' | 'sticker' =
'document'

if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(extension)) {
messageType = 'image'
} else if (['mp4', 'mov', 'avi', 'mkv', 'webm'].includes(extension)) {
messageType = 'video'
} else if (['mp3', 'wav', 'aac', 'ogg', 'm4a'].includes(extension)) {
messageType = 'audio'
} else if (['webp'].includes(extension)) {
messageType = 'sticker'
}
Comment on lines +455 to +463

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

webp handled twice – sticker branch is dead code

Because webp appears in the first condition, the final else if (['webp'].includes(extension)) is never reached, meaning a sticker will be mis-classified as an image.

- if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(extension)) {
+ if (['jpg', 'jpeg', 'png', 'gif'].includes(extension)) {
   messageType = 'image'
 ...
- } else if (['webp'].includes(extension)) {
+ } else if (extension === 'webp') {
   messageType = 'sticker'
 }

Alternatively switch to a switch/map for clarity.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(extension)) {
messageType = 'image'
} else if (['mp4', 'mov', 'avi', 'mkv', 'webm'].includes(extension)) {
messageType = 'video'
} else if (['mp3', 'wav', 'aac', 'ogg', 'm4a'].includes(extension)) {
messageType = 'audio'
} else if (['webp'].includes(extension)) {
messageType = 'sticker'
}
if (['jpg', 'jpeg', 'png', 'gif'].includes(extension)) {
messageType = 'image'
} else if (['mp4', 'mov', 'avi', 'mkv', 'webm'].includes(extension)) {
messageType = 'video'
} else if (['mp3', 'wav', 'aac', 'ogg', 'm4a'].includes(extension)) {
messageType = 'audio'
} else if (extension === 'webp') {
messageType = 'sticker'
}
🤖 Prompt for AI Agents
In src/components/SimplifiedChatInterface.tsx around lines 455 to 463, the file
extension 'webp' is checked twice, first classifying it as an image and later as
a sticker, making the sticker condition unreachable. To fix this, remove 'webp'
from the image extensions list and keep it only in the sticker condition, or
refactor the entire conditional block into a switch statement or a mapping
object for clearer and mutually exclusive classification.


// Create the payload for the mock/simulate-message endpoint
const payload = {
from: userPhoneNumber,
to: botPhoneNumber,
message: {
id: `msg_${Date.now()}`,
timestamp: Math.floor(Date.now() / 1000).toString(),
type: messageType,
filePath: filePath,
...(fileCaption && { caption: fileCaption }),
},
}

// Send file upload request to mock/simulate-message endpoint
const baseUrl =
process.env.API_BASE_URL ||
`http://localhost:${process.env.PORT ?? 3010}`
const response = await fetch(`${baseUrl}/mock/simulate-message`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})

if (!response.ok) {
const errorData = await response.json().catch(() => null)
throw new Error(
errorData?.error?.message ||
`HTTP ${response.status}: ${response.statusText}`
)
}

const result = await response.json()
console.log('File upload successful:', result)

setFileUploadStatus('success')
setMode('chat')
setFilePath('')
setFileCaption('')

// Reload conversation to show the new message
await loadConversation()

// Clear status after 2 seconds
setTimeout(() => {
setFileUploadStatus('idle')
}, 2000)
} catch (error) {
setFileUploadStatus('error')
setFileUploadError(
error instanceof Error ? error.message : 'Failed to upload file'
)
}
}

return (
<Box flexDirection="column" height="100%">
{/* Main area */}
Expand All @@ -355,6 +530,7 @@ export const SimplifiedChatInterface: FC<SimplifiedChatInterfaceProps> = ({
onCancel={handleTemplateVariablesCancel}
/>
)}
{mode === 'file-input' && renderFileInput()}

{mode === 'chat' && (
<>
Expand Down Expand Up @@ -465,7 +641,8 @@ export const SimplifiedChatInterface: FC<SimplifiedChatInterfaceProps> = ({
Press Enter to send message
</Text>
<Text color="gray" dimColor>
Ctrl+R: Refresh | Ctrl+N: New Chat | Ctrl+T: Templates
Ctrl+R: Refresh | Ctrl+N: New Chat | Ctrl+T: Templates | Ctrl+F:
Send File
</Text>
</Box>
</Box>
Expand Down
118 changes: 117 additions & 1 deletion src/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@ export interface WebhookConfig {
fallbackUrl: string | null
}

export interface MediaConfig {
/** Base directory for media storage */
baseDir: string
/** Directory for uploaded files */
uploadsDir: string
/** Directory for temporary files */
tempDir: string
/** Maximum file size in bytes (default: 10MB) */
maxFileSize: number
/** Allowed file extensions */
allowedExtensions: string[]
/** Allowed MIME types */
allowedMimeTypes: string[]
}

/**
* Parse CLI arguments for webhook URL mappings
* Format: --webhook-url phone:url
Expand Down Expand Up @@ -77,8 +92,79 @@ function initializeWebhookConfig(): WebhookConfig {
}
}

// Global configuration instance
/**
* Initialize media configuration with default values
*/
function initializeMediaConfig(): MediaConfig {
const baseDir = process.env.MEDIA_DIR || './media'
const maxFileSizeMB = process.env.MAX_FILE_SIZE_MB
? Number.parseInt(process.env.MAX_FILE_SIZE_MB)
: 10

Comment on lines +100 to +103

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Robustness: handle non-numeric MAX_FILE_SIZE_MB.

Number.parseInt will return NaN for an invalid value (e.g. "10MB").
Multiplying NaN by 1024 * 1024 propagates the NaN, silently disabling the size guard.

- const maxFileSizeMB = process.env.MAX_FILE_SIZE_MB
-  ? Number.parseInt(process.env.MAX_FILE_SIZE_MB)
-  : 10
+ const rawMb = process.env.MAX_FILE_SIZE_MB
+ const maxFileSizeMB =
+   rawMb && !Number.isNaN(Number.parseInt(rawMb))
+     ? Number.parseInt(rawMb)
+     : 10
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const maxFileSizeMB = process.env.MAX_FILE_SIZE_MB
? Number.parseInt(process.env.MAX_FILE_SIZE_MB)
: 10
const rawMb = process.env.MAX_FILE_SIZE_MB
const maxFileSizeMB =
rawMb && !Number.isNaN(Number.parseInt(rawMb))
? Number.parseInt(rawMb)
: 10
🤖 Prompt for AI Agents
In src/server/config.ts around lines 100 to 103, the code uses Number.parseInt
on MAX_FILE_SIZE_MB without validating if the result is a valid number, which
can lead to NaN and disable the size guard. Fix this by checking if the parsed
value is a valid number using isNaN or a similar method, and if it is not valid,
fall back to the default value of 10 before using it in calculations.

return {
baseDir,
uploadsDir: `${baseDir}/uploads`,
tempDir: `${baseDir}/temp`,
maxFileSize: maxFileSizeMB * 1024 * 1024, // Convert MB to bytes
Comment on lines +104 to +108

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Cross-platform path handling.

String interpolation with '/' breaks on Windows and complicates future refactors (nested dirs, symlinks, etc.). Prefer path.join.

+import { join } from 'node:path';
 ...
- uploadsDir: `${baseDir}/uploads`,
- tempDir: `${baseDir}/temp`,
+ uploadsDir: join(baseDir, 'uploads'),
+ tempDir:   join(baseDir, 'temp'),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return {
baseDir,
uploadsDir: `${baseDir}/uploads`,
tempDir: `${baseDir}/temp`,
maxFileSize: maxFileSizeMB * 1024 * 1024, // Convert MB to bytes
// Add at the top of src/server/config.ts
import { join } from 'node:path';
// Replace the return block
return {
baseDir,
- uploadsDir: `${baseDir}/uploads`,
- tempDir: `${baseDir}/temp`,
+ uploadsDir: join(baseDir, 'uploads'),
+ tempDir: join(baseDir, 'temp'),
maxFileSize: maxFileSizeMB * 1024 * 1024, // Convert MB to bytes
};
🤖 Prompt for AI Agents
In src/server/config.ts around lines 104 to 108, replace string interpolation
using '/' for constructing paths with path.join to ensure cross-platform
compatibility. Import the 'path' module if not already done, then use
path.join(baseDir, 'uploads') and path.join(baseDir, 'temp') instead of
`${baseDir}/uploads` and `${baseDir}/temp`. This change will handle path
separators correctly on all operating systems.

allowedExtensions: [
// Images
'.jpg',
'.jpeg',
'.png',
'.gif',
'.webp',
// Documents
'.pdf',
'.doc',
'.docx',
'.txt',
'.csv',
// Audio
'.mp3',
'.wav',
'.ogg',
'.m4a',
// Video
'.mp4',
'.mov',
'.avi',
'.webm',
// Other
'.zip',
'.rar',
],
allowedMimeTypes: [
// Images
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
// Documents
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain',
'text/csv',
// Audio
'audio/mpeg',
'audio/wav',
'audio/ogg',
'audio/mp4',
// Video
'video/mp4',
'video/quicktime',
'video/x-msvideo',
'video/webm',
// Other
'application/zip',
'application/x-rar-compressed',
],
}
}

// Global configuration instances
let webhookConfig: WebhookConfig | null = null
const mediaConfig: MediaConfig = initializeMediaConfig()

/**
* Get the global webhook configuration (lazy initialization)
Expand All @@ -90,6 +176,13 @@ export function getWebhookConfig(): WebhookConfig {
return webhookConfig
}

/**
* Get the global media configuration
*/
export function getMediaConfig(): MediaConfig {
return mediaConfig
}

/**
* Get webhook URL for a specific phone number
* @param phoneNumber - The phone number to get webhook URL for
Expand Down Expand Up @@ -133,3 +226,26 @@ export function setWebhookUrl(phoneNumber: string, url: string): void {
}
config.mappings.set(phoneNumber, url)
}

/**
* Retrieves the allowed API tokens from environment variables.
* @returns {string[]} An array of allowed tokens.
*/
export function getAllowedTokens(): string[] {
const tokens = process.env.MOCK_API_TOKENS || ''
return tokens.split(',').filter(Boolean)
}

/**
* Retrieves the rate limit configuration from environment variables.
* @returns {{windowMs: number, maxRequests: number}} The rate limit configuration.
*/
export function getRateLimitConfig(): {
windowMs: number
maxRequests: number
} {
return {
windowMs: Number(process.env.RATE_LIMIT_WINDOW_MS) || 60000, // 1 minute
maxRequests: Number(process.env.RATE_LIMIT_MAX_REQUESTS) || 100, // 100 requests per window
}
Comment on lines +247 to +250

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

|| swallows intentional zeroes & NaN — use nullish coalescing.

If someone sets RATE_LIMIT_MAX_REQUESTS=0 (to disable traffic) it falls back to 100.
Likewise Number('abc') produces NaN, which should trigger the default, not propagate.

-return {
-  windowMs: Number(process.env.RATE_LIMIT_WINDOW_MS) || 60000,
-  maxRequests: Number(process.env.RATE_LIMIT_MAX_REQUESTS) || 100,
-}
+return {
+  windowMs:
+    Number(process.env.RATE_LIMIT_WINDOW_MS) ?? 60000 /* 1 min default */,
+  maxRequests:
+    Number.isNaN(Number(process.env.RATE_LIMIT_MAX_REQUESTS))
+      ? 100
+      : Number(process.env.RATE_LIMIT_MAX_REQUESTS) ?? 100,
+}

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/server/config.ts around lines 247 to 250, replace the logical OR (||)
operators with nullish coalescing operators (??) to correctly handle zero values
without falling back to defaults. Additionally, validate the conversion from
environment variables to numbers to ensure that NaN values trigger the default
values instead of propagating. This means explicitly checking if the parsed
number is NaN and using the default in that case, while allowing zero to be a
valid value.

}
Loading