whatsapp-claude-gpt
Version:
WhatsApp-Claude-GPT is a WhatsApp chatbot that supports multiple AI providers for chat, optional image generation/editing, and voice (speech-to-text and text-to-speech). It’s built for natural, contextual conversations and can now also handle reminders an
380 lines (336 loc) • 14.9 kB
text/typescript
import { Reminder, ReminderCreateInput } from '../interfaces/reminder';
import { remindersTable as remindersTable } from '../db/schema';
import logger from '../logger';
import { v4 as uuidv4 } from 'uuid';
import { format, fromZonedTime, toZonedTime } from 'date-fns-tz';
import { AiMessage, AIRole, OperationResult, ToolExecutionContext } from "../interfaces/ai-interfaces";
import { chatConfigurationManager } from "../config/chat-configurations";
import { addSeconds, extractAnswer, getUserName } from "../utils";
import { addDays, addMonths, addWeeks } from 'date-fns';
import { CONFIG } from "../config";
import Roboto from "../bot/roboto";
import WspWeb from "../bot/wsp-web";
import { db } from "../db";
import { and, eq } from "drizzle-orm";
import { Chat, Message } from "whatsapp-web.js";
class ReminderManager {
private reminderInterval: NodeJS.Timeout | null = null;
private isChecking = false;
constructor() {
// Reminder checker is now started from the 'ready' event in src/index.ts
// to ensure the WhatsApp client is available.
}
public startReminderChecker() {
if (this.reminderInterval) {
logger.warn('Reminder checker already running, skipping duplicate start.');
return;
}
logger.info('Starting reminder checker');
this.reminderInterval = setInterval(() => {
this.checkReminders().catch((e) => {
logger.error(`[ReminderManager] Unhandled error in checkReminders: ${e.message}`);
});
}, 59 * 1000);
}
public stopReminderChecker() {
if (this.reminderInterval) {
clearInterval(this.reminderInterval);
this.reminderInterval = null;
}
}
private async checkReminders() {
// Prevent overlapping runs when a previous check is still in progress.
if (this.isChecking) {
logger.warn('Reminder check skipped: previous check still in progress.');
return;
}
this.isChecking = true;
try {
const now = new Date();
const dueReminders = await db.select()
.from(remindersTable)
.where(and(
eq(remindersTable.isActive, true)
))
.all();
for (const r of dueReminders) {
const reminder = this.mapRowToReminder(r);
const scheduledDate = fromZonedTime(reminder.reminderDate, reminder.reminderDateTZ);
if (scheduledDate > now) continue;
try {
const diffMs = now.getTime() - scheduledDate.getTime();
if((diffMs / 60000) <= 60) {
if (diffMs > 60000) {
logger.warn(`Reminder for ${reminder.chatId} (${reminder.id}) is firing ${Math.round(diffMs / 60000)} minute(s) late.`);
}
await this.sendReminderMessage(reminder);
} else {
logger.info(`Reminder ${reminder.id} for chat ${reminder.chatId} has expired. Reminder not sent`);
}
if (reminder.recurrenceType && reminder.recurrenceType !== 'none') {
const nextDate = this.calculateNextRecurrence(reminder);
if (nextDate) {
const zonedDate = toZonedTime(nextDate, reminder.reminderDateTZ);
await this.updateReminder(reminder.id, reminder.chatId, {
reminderDate: format(zonedDate, "yyyy-MM-dd'T'HH:mm:ss", { timeZone: reminder.reminderDateTZ }),
updatedAt: new Date(),
});
logger.info(`Recurring reminder updated for next occurrence: ${reminder.id}`);
} else {
await this.deactivateReminder(reminder.id, reminder.chatId);
logger.info(`Recurring reminder completed and deactivated: ${reminder.id}`);
}
} else {
await this.deleteReminder(reminder.id, reminder.chatId);
}
logger.info(`Reminder ${reminder.id} for chat ${reminder.chatId} processed.`);
} catch (err) {
logger.error(`Error processing reminder ${reminder.id} for chat ${reminder.chatId}: ${err.message}`);
}
}
} finally {
this.isChecking = false;
}
}
private async sendReminderMessage(reminder: Reminder){
const chatConfig = await chatConfigurationManager.getChatConfig(reminder.chatId, reminder.chatName);
const systemPrompt = CONFIG.getSystemPrompt(chatConfig);
const aiMessage: AiMessage = {
role: AIRole.USER,
name: 'SYSTEM',
content: [{
type: 'text',
value: `SYSTEM: The user has a new reminder, write a message to remind them of the following: "${reminder.message}". RecipientName: ${reminder.chatName}. Date: "${reminder.reminderDate}"`,
dateString: reminder.reminderDate,
author_id: 'SYSTEM'
}]
};
const aiResponse = await Roboto.sendMessageToAi([aiMessage], systemPrompt, chatConfig, false);
const reminderMsg = extractAnswer(aiResponse, chatConfig.botName);
if (!reminderMsg || !reminderMsg.message) return false;
return WspWeb.getWspClient().sendMessage(reminder.chatId, reminderMsg.message);
}
/**
* Calculates the next recurrence date for a reminder
*/
private calculateNextRecurrence(reminder: Reminder): Date | null {
const currentDate = fromZonedTime(reminder.reminderDate, reminder.reminderDateTZ);
const interval = reminder.recurrenceInterval || 1;
let nextDate: Date;
switch (reminder.recurrenceType) {
case 'minutes':
nextDate = addSeconds(currentDate, interval*60);
break;
case 'daily':
nextDate = addDays(currentDate, interval);
break;
case 'weekly':
nextDate = addWeeks(currentDate, interval);
break;
case 'monthly':
nextDate = addMonths(currentDate, interval);
break;
default:
return null;
}
if (reminder.recurrenceEndDate && reminder.recurrenceEndDateTZ) {
const endDate = fromZonedTime(reminder.recurrenceEndDate, reminder.recurrenceEndDateTZ);
if (nextDate > endDate) {
return null;
}
}
return nextDate;
}
public async processFunctionCall(args, context?: ToolExecutionContext): Promise<OperationResult>{
const {
action,
message: reminderMessage,
reminder_date,
reminder_date_timezone,
reminder_id,
recurrence_type,
recurrence_interval,
recurrence_end_date,
recurrence_end_date_timezone,
msg_id
} = args;
let chatId: string;
let chatName: string;
// Use server-side context when available (AI tool calls); fall back to
// deriving from msg_id for backward compatibility with manual flows.
if (context) {
chatId = context.chatId;
chatName = context.chatName;
} else {
const wspMsg: Message = await WspWeb.getWspClient().getMessageById(msg_id)
const chatData: Chat = await wspMsg.getChat();
chatId = chatData.id._serialized;
chatName = chatData.name ?? await getUserName(wspMsg);
}
let responseMessage = '';
let reminder;
switch (action) {
case 'list':
const remindersList = await this.getRemindersByUser(chatId);
responseMessage = JSON.stringify(remindersList);
break;
case 'create':
reminder = await this.createReminder({
message: reminderMessage,
reminderDate: reminder_date,
reminderDateTZ: reminder_date_timezone || CONFIG.BotConfig.botTimezone,
chatId: chatId,
chatName: chatName,
recurrenceType: recurrence_type || 'none',
recurrenceInterval: recurrence_interval || 1,
recurrenceEndDate: recurrence_end_date || null,
recurrenceEndDateTZ: recurrence_end_date_timezone || CONFIG.BotConfig.botTimezone
});
responseMessage = `Reminder created successfully. Data: ${JSON.stringify(reminder)}. (Do not mention the reminder id to the user)`;
break;
case 'update':
reminder = await this.updateReminder(reminder_id, chatId, {
message: reminderMessage,
reminderDate: reminder_date,
reminderDateTZ: reminder_date_timezone || CONFIG.BotConfig.botTimezone,
recurrenceType: recurrence_type,
recurrenceInterval: recurrence_interval,
recurrenceEndDate: recurrence_end_date,
recurrenceEndDateTZ: recurrence_end_date_timezone || CONFIG.BotConfig.botTimezone
});
responseMessage = `Reminder updated successfully. Data: ${JSON.stringify(reminder)}. (Do not mention the reminder id to the user)`;
break;
case 'delete':
await this.deleteReminder(reminder_id, chatId);
responseMessage = `Reminder deleted successfully`;
break;
case 'deactivate':
await this.deactivateReminder(reminder_id, chatId);
responseMessage = `Reminder deactivated successfully`;
break;
case 'reactivate':
await this.reactivateReminder(reminder_id, chatId);
responseMessage = `Reminder reactivated successfully`;
break;
}
return {success: true, result: responseMessage};
}
/**
* Creates a new reminder
*/
private async createReminder(input: ReminderCreateInput): Promise<Reminder> {
const now = new Date();
const reminder: Reminder = {
id: uuidv4(),
message: input.message,
reminderDate: input.reminderDate,
reminderDateTZ: input.reminderDateTZ,
chatId: input.chatId,
chatName: input.chatName,
isActive: true,
createdAt: now,
updatedAt: now,
recurrenceType: input.recurrenceType ?? 'none',
recurrenceInterval: input.recurrenceInterval ?? 1,
recurrenceEndDate: input.recurrenceEndDate ?? null,
recurrenceEndDateTZ: input.recurrenceEndDateTZ ?? null,
};
await db.insert(remindersTable).values({
...reminder,
createdAt: reminder.createdAt.toISOString(),
updatedAt: reminder.updatedAt.toISOString(),
}).run();
logger.info(`Created reminder with ID: ${reminder.id} for user: ${reminder.chatId}`);
return reminder;
}
/**
* Updates an existing reminder
*/
private async updateReminder(id: string, chatId: string, updates: Partial<ReminderCreateInput>): Promise<Reminder | null> {
const updatedAt = new Date().toISOString();
const result = await db.update(remindersTable)
.set({ ...updates , updatedAt })
.where(and(
eq(remindersTable.id, id),
eq(remindersTable.chatId, chatId)
))
.returning();
if (result.length === 0) {
logger.warn(`Reminder with ID ${id} not found for chat ${chatId}`);
return null;
}
logger.info(`Updated reminder with ID: ${id} for chat ${chatId}`);
return this.mapRowToReminder(result[0]);
}
/**
* Deletes a reminder scoped to the owning chat.
*/
private async deleteReminder(id: string, chatId: string): Promise<boolean> {
const result = await db.delete(remindersTable)
.where(and(
eq(remindersTable.id, id),
eq(remindersTable.chatId, chatId)
))
.run();
if (result.changes === 0) {
logger.warn(`Reminder with ID ${id} not found for chat ${chatId}`);
return false;
}
logger.info(`Deleted reminder with ID: ${id} for chat ${chatId}`);
return true;
}
/**
* Gets all reminders for a specific user
*/
private async getRemindersByUser(userId: string): Promise<Reminder[]> {
const rows = await db.select().from(remindersTable).where(eq(remindersTable.chatId, userId)).all();
return rows.map(this.mapRowToReminder);
}
/**
* Deactivates a reminder without deleting it, scoped to the owning chat.
*/
private async deactivateReminder(id: string, chatId: string): Promise<boolean> {
const updatedAt = new Date().toISOString();
const result = await db.update(remindersTable)
.set({ isActive: false, updatedAt })
.where(and(
eq(remindersTable.id, id),
eq(remindersTable.chatId, chatId)
))
.run();
if (result.changes === 0) {
logger.warn(`Reminder with ID ${id} not found for chat ${chatId}`);
return false;
}
logger.info(`Deactivated reminder with ID: ${id} for chat ${chatId}`);
return true;
}
/**
* Reactivates a reminder, scoped to the owning chat.
*/
private async reactivateReminder(id: string, chatId: string): Promise<boolean> {
const updatedAt = new Date().toISOString();
const result = await db.update(remindersTable)
.set({ isActive: true, updatedAt })
.where(and(
eq(remindersTable.id, id),
eq(remindersTable.chatId, chatId)
))
.run();
if (result.changes === 0) {
logger.warn(`Reminder with ID ${id} not found for chat ${chatId}`);
return false;
}
logger.info(`Reactivated reminder with ID: ${id} for chat ${chatId}`);
return true;
}
private mapRowToReminder(row: any): Reminder {
return {
...row,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
};
}
}
const Reminders = new ReminderManager();
export default Reminders;