Compare commits

...

7 Commits

@ -1,13 +1,21 @@
# Task WhatsApp Chatbot
## Future Steps
A WhatsApp chatbot for task management, designed to work with Evolution API in a secure internal network environment.
### Core Principles
1. **Trust-but-Verify Approach**: Leverage WhatsApp's message metadata to minimize API calls while maintaining security
2. **Progressive Validation**: Verify users naturally through interactions rather than upfront checks
3. **Background Reconciliation**: Use periodic syncs to maintain data accuracy without impacting real-time performance
## 📌 Overview
This service provides a WhatsApp interface for task management within WhatsApp groups. It:
- Listens for `/tarea` commands in WhatsApp groups
- Stores tasks in a SQLite database
- Manages user permissions and group membership
- Integrates with Evolution API for WhatsApp connectivity
### Message Processing Flow
## 🔐 Security Model
- **Internal Networking**: The webhook only accepts connections from Evolution API via internal Docker networking
- **Environment Variables**: Sensitive configuration is managed through environment variables
- **Group Restrictions**: Only operates within pre-approved WhatsApp groups
- **Input Validation**: Sanitizes and validates all user inputs
## 🧱 Architecture
```mermaid
graph TD
A[Webhook Received] --> B{Valid Payload?}
@ -24,25 +32,77 @@ graph TD
H -->|No| J[Process Command]
```
### Alias for actions
- I want that, when an user sends `/tarea` to the bot, with no extra arguments, it should show all the pending tasks for the user in a private message (the same behaviour as calling `/tarea mostrar`
### Periodic Sync Strategy
1. **Rotating Group Check**:
- Verify 1-2 groups per sync cycle
- Prioritize recently active groups
2. **User Reconciliation**:
- Add newly discovered users
- Update `last_confirmed` for active users
3. **Optimizations**:
- Cache active group IDs in memory
- Batch database writes
- Exponential backoff for API failures
### Security Considerations
- Reject messages from:
- Non-community groups
- Unknown private chats
- Implement rate limiting
- Maintain audit logs of verification events
## ✅ Current Features
- Task creation with optional due dates
- Basic command parsing (`/tarea nueva`, `/tarea mostrar`)
- Group membership tracking
- SQLite database persistence
- Health check endpoint
- Environment validation
- Input validation for dates and commands
## 🛠️ Setup
### Environment Variables
```env
EVOLUTION_API_URL=http://evolution-api:3000
EVOLUTION_API_KEY=your-api-key
EVOLUTION_API_INSTANCE=main
WHATSAPP_COMMUNITY_ID=your-community-id
CHATBOT_PHONE_NUMBER=1234567890
WEBHOOK_URL=https://your-webhook.com
PORT=3007
NODE_ENV=production
```
### Development Setup
```bash
# Install dependencies
bun install
# Start development server
bun run dev
# Run tests
bun test
```
## 📅 Roadmap
### High Priority
- [ ] Implement ResponseQueue processing logic with retries
- [ ] Add database schema validation and migrations
- [ ] Add error recovery with transaction rollback
- [ ] Implement group sync delta updates
### Medium Priority
- [ ] Add task assignment and ownership
- [ ] Implement user permissions system
- [ ] Add rate limiting for API calls
- [ ] Create task history tracking
### Low Priority
- [ ] Add task reminders system
- [ ] Implement multi-language support
- [ ] Create analytics dashboard
- [ ] Add user-friendly task list UI
## 🧪 Testing
### Running Tests
```bash
bun test
```
### Test Coverage
- Webhook validation
- Command parsing
- Environment checks
- Basic error handling
- Input validation
## 🧑‍💻 Contributing
1. Fork the repository
2. Create a feature branch
3. Add tests for new functionality
4. Submit a pull request
## 📚 Documentation
For detailed API documentation and architecture decisions, see the [docs/](docs/) directory.

@ -41,12 +41,6 @@ export class WebhookServer {
if (process.env.NODE_ENV !== 'test') {
console.log(' Incoming webhook request:')
// console.log(' Incoming webhook request:', {
// method: request.method,
// path: url.pathname,
// time: new Date().toISOString()
// });
}
// 1. Method validation
@ -83,10 +77,12 @@ export class WebhookServer {
switch (payload.event) {
case 'messages.upsert':
if (process.env.NODE_ENV !== 'test') {
console.log(' Handling message upsert:', {
groupId: payload.data?.key?.remoteJid,
message: payload.data?.message?.conversation
});
}
await WebhookServer.handleMessageUpsert(payload.data);
break;
// Other events will be added later
@ -105,8 +101,10 @@ export class WebhookServer {
static async handleMessageUpsert(data: any) {
if (!data?.key?.remoteJid || !data.message || !data.message.conversation) {
if (process.env.NODE_ENV !== 'test') {
console.log('⚠️ Invalid message format - missing required fields');
console.log(data);
}
return;
}
@ -129,6 +127,9 @@ export class WebhookServer {
let dueDate = '';
// Process remaining parts
const dateCandidates = [];
// First collect all valid future dates
for (let i = 2; i < commandParts.length; i++) {
const part = commandParts[i];
// Check for date (YYYY-MM-DD)
@ -138,16 +139,31 @@ export class WebhookServer {
today.setHours(0, 0, 0, 0);
if (date >= today) {
dueDate = part;
} else {
descriptionParts.push(part); // Keep past dates in description
dateCandidates.push({ index: i, date, part });
}
} else {
}
}
// Use the last valid future date as due date
if (dateCandidates.length > 0) {
const lastDate = dateCandidates[dateCandidates.length - 1];
dueDate = lastDate.part;
// Add all parts except the last date to description
for (let i = 2; i < commandParts.length; i++) {
// Skip the due date part
if (i === lastDate.index) continue;
// Add to description if it's a date candidate but not selected or a regular part
const part = commandParts[i];
descriptionParts.push(part);
}
} else {
// No valid future dates, add all parts to description
descriptionParts = commandParts.slice(2);
}
const description = descriptionParts.join(' ');
const description = descriptionParts.join(' ').trim();
console.log('🔍 Detected /tarea command:', {
timestamp: new Date().toISOString(),
@ -156,7 +172,7 @@ export class WebhookServer {
rawMessage: messageText
});
const mentions = data.contextInfo?.mentionedJid || [];
const mentions = data.message?.contextInfo?.mentionedJid || [];
console.log('✅ Successfully parsed command:', {
action,
description,

@ -223,6 +223,12 @@ describe('WebhookServer', () => {
}
});
function getFutureDate(days: number): string {
const date = new Date();
date.setDate(date.getDate() + days);
return date.toISOString().split('T')[0];
}
describe('/tarea command logging', () => {
let consoleSpy: any;
@ -268,6 +274,7 @@ describe('WebhookServer', () => {
});
test('should log command with due date', async () => {
const futureDate = getFutureDate(3); // Get date 3 days in future
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
@ -277,7 +284,7 @@ describe('WebhookServer', () => {
participant: 'user123@s.whatsapp.net'
},
message: {
conversation: '/tarea nueva Finish project @user2 2025-04-30',
conversation: `/tarea nueva Finish project @user2 ${futureDate}`,
contextInfo: {
mentionedJid: ['user2@s.whatsapp.net']
}
@ -288,24 +295,25 @@ describe('WebhookServer', () => {
await WebhookServer.handleRequest(createTestRequest(payload));
// Verify the two command-related log calls
expect(consoleSpy).toHaveBeenCalledWith(
'🔍 Detected /tarea command:',
expect.objectContaining({
expect(consoleSpy).toHaveBeenCalledTimes(2);
// First call should be the command detection
expect(consoleSpy.mock.calls[0][0]).toBe('🔍 Detected /tarea command:');
expect(consoleSpy.mock.calls[0][1]).toEqual({
timestamp: expect.any(String),
from: 'user123@s.whatsapp.net',
group: 'group123@g.us',
rawMessage: '/tarea nueva Finish project @user2 2025-04-30'
})
);
rawMessage: `/tarea nueva Finish project @user2 ${futureDate}`
});
expect(consoleSpy).toHaveBeenCalledWith(
'✅ Successfully parsed command:',
expect.objectContaining({
// Second call should be the successful parsing
expect(consoleSpy.mock.calls[1][0]).toBe('✅ Successfully parsed command:');
expect(consoleSpy.mock.calls[1][1]).toEqual({
action: 'nueva',
description: 'Finish project @user2',
dueDate: '2025-04-30',
mentionCount: expect.any(Number)
})
);
dueDate: futureDate,
mentionCount: 1
});
});
});
@ -327,4 +335,115 @@ describe('WebhookServer', () => {
expect(response.status).toBe(200);
expect(mockAdd).toHaveBeenCalled();
});
test('should handle multiple dates in command (use last one as due date)', async () => {
const futureDate1 = getFutureDate(3);
const futureDate2 = getFutureDate(5);
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net'
},
message: {
conversation: `/tarea nueva Test task ${futureDate1} some text ${futureDate2}`
}
}
};
await WebhookServer.handleRequest(createTestRequest(payload));
expect(console.log).toHaveBeenCalledWith(
'✅ Successfully parsed command:',
expect.objectContaining({
description: `Test task ${futureDate1} some text`,
dueDate: futureDate2
})
);
});
test('should ignore past dates as due dates', async () => {
const pastDate = '2020-01-01';
const futureDate = getFutureDate(2);
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net'
},
message: {
conversation: `/tarea nueva Test task ${pastDate} more text ${futureDate}`
}
}
};
await WebhookServer.handleRequest(createTestRequest(payload));
expect(console.log).toHaveBeenCalledWith(
'✅ Successfully parsed command:',
expect.objectContaining({
description: `Test task ${pastDate} more text`,
dueDate: futureDate
})
);
});
test('should handle multiple past dates correctly', async () => {
const pastDate1 = '2020-01-01';
const pastDate2 = '2021-01-01';
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net'
},
message: {
conversation: `/tarea nueva Test task ${pastDate1} and ${pastDate2}`
}
}
};
await WebhookServer.handleRequest(createTestRequest(payload));
expect(console.log).toHaveBeenCalledWith(
'✅ Successfully parsed command:',
expect.objectContaining({
description: `Test task ${pastDate1} and ${pastDate2}`,
dueDate: 'none'
})
);
});
test('should handle mixed valid and invalid date formats', async () => {
const futureDate = getFutureDate(2);
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net'
},
message: {
conversation: `/tarea nueva Test task 2023-13-01 (invalid) ${futureDate} 25/12/2023 (invalid)`
}
}
};
await WebhookServer.handleRequest(createTestRequest(payload));
expect(console.log).toHaveBeenCalledWith(
'✅ Successfully parsed command:',
expect.objectContaining({
description: 'Test task 2023-13-01 (invalid) 25/12/2023 (invalid)',
dueDate: futureDate
})
);
});
});

Loading…
Cancel
Save