From 7cbf4c94acb72e5cef6a11834fffc572aa44210b Mon Sep 17 00:00:00 2001 From: SupremeEvilGod Date: Mon, 23 Mar 2026 15:53:07 +0530 Subject: [PATCH 1/7] created a component for chating interface similar to famous llm chatbots --- src/components/ChatPanel.tsx | 292 +++++++++++++++++++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 src/components/ChatPanel.tsx diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx new file mode 100644 index 0000000..2dda2a1 --- /dev/null +++ b/src/components/ChatPanel.tsx @@ -0,0 +1,292 @@ +import { useState, useRef, useEffect } from "react"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { supabase } from "@/integrations/supabase/client"; +import { toast } from "sonner"; +import { Send, Mic, Image, Square, Loader2, Bot, User, Wrench } from "lucide-react"; + +interface Message { + role: "user" | "assistant" | "status"; + content: string; + toolCalls?: { tool: string; args: any }[]; +} + +interface ChatPanelProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onEventChanged?: () => void; +} + +const ChatPanel = ({ open, onOpenChange, onEventChanged }: ChatPanelProps) => { + const [messages, setMessages] = useState([ + { role: "assistant", content: "šŸ‘‹ Hey! I'm Maantis, your AI scheduling assistant. I can create events, check your schedule, resolve conflicts, and more. What can I help with?" } + ]); + const [input, setInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [isRecording, setIsRecording] = useState(false); + const scrollRef = useRef(null); + const fileInputRef = useRef(null); + const mediaRecorder = useRef(null); + const audioChunks = useRef([]); + const conversationHistory = useRef([]); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [messages]); + + const sendToAgent = async (userMessage: string, inputType: string = "text", fileData?: string) => { + setIsLoading(true); + setMessages(prev => [...prev, { role: "status", content: "🧠 Thinking..." }]); + + try { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { + toast.error("Please log in first"); + setIsLoading(false); + return; + } + + const { data, error } = await supabase.functions.invoke('agent', { + body: { + user_id: user.id, + message: userMessage, + input_type: inputType, + file_data: fileData, + conversation_history: conversationHistory.current.slice(-10), // Last 10 messages for context + }, + }); + + if (error) throw new Error(error.message); + + // Remove the "thinking" status + setMessages(prev => prev.filter(m => m.role !== "status")); + + // Add transcription note if voice + if (data.transcription && inputType === "voice") { + setMessages(prev => [...prev, { role: "status", content: `šŸŽ¤ Heard: "${data.transcription}"` }]); + } + + // Add the response + const assistantMsg: Message = { + role: "assistant", + content: data.response || "I processed your request.", + toolCalls: data.tool_calls_made, + }; + setMessages(prev => [...prev, assistantMsg]); + + // Update conversation history for context + conversationHistory.current.push({ role: "user", content: userMessage }); + conversationHistory.current.push({ role: "assistant", content: data.response }); + + // Refresh events in the calendar if tools modified data + if (data.tool_calls_made?.some((t: any) => ["create_event", "update_event", "delete_event"].includes(t.tool))) { + onEventChanged?.(); + } + + } catch (err: any) { + setMessages(prev => prev.filter(m => m.role !== "status")); + setMessages(prev => [...prev, { role: "assistant", content: `āš ļø Error: ${err.message}` }]); + } finally { + setIsLoading(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!input.trim() || isLoading) return; + const msg = input.trim(); + setInput(""); + setMessages(prev => [...prev, { role: "user", content: msg }]); + await sendToAgent(msg); + }; + + const startRecording = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + mediaRecorder.current = new MediaRecorder(stream); + audioChunks.current = []; + + mediaRecorder.current.ondataavailable = (e) => { + if (e.data.size > 0) audioChunks.current.push(e.data); + }; + + mediaRecorder.current.onstop = async () => { + const audioBlob = new Blob(audioChunks.current, { type: 'audio/webm' }); + stream.getTracks().forEach(track => track.stop()); + + // Convert to base64 + const reader = new FileReader(); + reader.onloadend = async () => { + const base64 = (reader.result as string).split(',')[1]; + setMessages(prev => [...prev, { role: "user", content: "šŸŽ¤ Voice message" }]); + await sendToAgent("", "voice", base64); + }; + reader.readAsDataURL(audioBlob); + }; + + mediaRecorder.current.start(); + setIsRecording(true); + } catch (err) { + toast.error("Could not access microphone."); + } + }; + + const stopRecording = () => { + if (mediaRecorder.current && isRecording) { + mediaRecorder.current.stop(); + setIsRecording(false); + } + }; + + const handleImageUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onloadend = async () => { + const base64 = (reader.result as string).split(',')[1]; + setMessages(prev => [...prev, { role: "user", content: `šŸ“· Image: ${file.name}` }]); + await sendToAgent(input || "", "image", base64); + setInput(""); + }; + reader.readAsDataURL(file); + e.target.value = ""; + }; + + return ( + + + {/* Header */} +
+
+ +
+
+

Maantis Agent

+

AI Scheduling Assistant

+
+
+ + {/* Messages */} + +
+ {messages.map((msg, i) => ( +
+ {msg.role === "assistant" && ( +
+ +
+ )} +
+

{msg.content}

+ {msg.toolCalls && msg.toolCalls.length > 0 && ( +
+

+ Tools used +

+ {msg.toolCalls.map((tc, j) => ( + + {tc.tool} + + ))} +
+ )} +
+ {msg.role === "user" && ( +
+ +
+ )} +
+ ))} + {isLoading && ( +
+
+ +
+
+ +
+
+ )} +
+
+ + {/* Input Bar */} +
+
+ + + + {isRecording ? ( + + ) : ( + + )} + + setInput(e.target.value)} + placeholder="Ask me anything..." + disabled={isLoading || isRecording} + className="h-9 text-sm" + /> + + +
+
+
+
+ ); +}; + +export default ChatPanel; From 9304002cd4b6a3ed6d0fd706d71ff5b17a89d563 Mon Sep 17 00:00:00 2001 From: SupremeEvilGod Date: Mon, 23 Mar 2026 16:00:00 +0530 Subject: [PATCH 2/7] removed the old buttons and corrected the coatbot --- src/components/ChatPanel.tsx | 23 ++++-------- src/components/EventManager.tsx | 1 + src/components/QuickActions.tsx | 35 +++++------------- src/pages/Index.tsx | 65 +++++---------------------------- 4 files changed, 28 insertions(+), 96 deletions(-) diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 2dda2a1..be38bd6 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -21,7 +21,7 @@ interface ChatPanelProps { const ChatPanel = ({ open, onOpenChange, onEventChanged }: ChatPanelProps) => { const [messages, setMessages] = useState([ - { role: "assistant", content: "šŸ‘‹ Hey! I'm Maantis, your AI scheduling assistant. I can create events, check your schedule, resolve conflicts, and more. What can I help with?" } + { role: "assistant", content: "Hey! I'm Maantis, your AI scheduling assistant. I can create events, check your schedule, resolve conflicts, and more. What can I help with?" } ]); const [input, setInput] = useState(""); const [isLoading, setIsLoading] = useState(false); @@ -40,7 +40,7 @@ const ChatPanel = ({ open, onOpenChange, onEventChanged }: ChatPanelProps) => { const sendToAgent = async (userMessage: string, inputType: string = "text", fileData?: string) => { setIsLoading(true); - setMessages(prev => [...prev, { role: "status", content: "🧠 Thinking..." }]); + setMessages(prev => [...prev, { role: "status", content: "Thinking..." }]); try { const { data: { user } } = await supabase.auth.getUser(); @@ -56,21 +56,18 @@ const ChatPanel = ({ open, onOpenChange, onEventChanged }: ChatPanelProps) => { message: userMessage, input_type: inputType, file_data: fileData, - conversation_history: conversationHistory.current.slice(-10), // Last 10 messages for context + conversation_history: conversationHistory.current.slice(-10), }, }); if (error) throw new Error(error.message); - // Remove the "thinking" status setMessages(prev => prev.filter(m => m.role !== "status")); - // Add transcription note if voice if (data.transcription && inputType === "voice") { - setMessages(prev => [...prev, { role: "status", content: `šŸŽ¤ Heard: "${data.transcription}"` }]); + setMessages(prev => [...prev, { role: "status", content: `Heard: "${data.transcription}"` }]); } - // Add the response const assistantMsg: Message = { role: "assistant", content: data.response || "I processed your request.", @@ -78,18 +75,16 @@ const ChatPanel = ({ open, onOpenChange, onEventChanged }: ChatPanelProps) => { }; setMessages(prev => [...prev, assistantMsg]); - // Update conversation history for context conversationHistory.current.push({ role: "user", content: userMessage }); conversationHistory.current.push({ role: "assistant", content: data.response }); - // Refresh events in the calendar if tools modified data if (data.tool_calls_made?.some((t: any) => ["create_event", "update_event", "delete_event"].includes(t.tool))) { onEventChanged?.(); } } catch (err: any) { setMessages(prev => prev.filter(m => m.role !== "status")); - setMessages(prev => [...prev, { role: "assistant", content: `āš ļø Error: ${err.message}` }]); + setMessages(prev => [...prev, { role: "assistant", content: `Error: ${err.message}` }]); } finally { setIsLoading(false); } @@ -118,11 +113,10 @@ const ChatPanel = ({ open, onOpenChange, onEventChanged }: ChatPanelProps) => { const audioBlob = new Blob(audioChunks.current, { type: 'audio/webm' }); stream.getTracks().forEach(track => track.stop()); - // Convert to base64 const reader = new FileReader(); reader.onloadend = async () => { const base64 = (reader.result as string).split(',')[1]; - setMessages(prev => [...prev, { role: "user", content: "šŸŽ¤ Voice message" }]); + setMessages(prev => [...prev, { role: "user", content: "Voice message" }]); await sendToAgent("", "voice", base64); }; reader.readAsDataURL(audioBlob); @@ -149,7 +143,7 @@ const ChatPanel = ({ open, onOpenChange, onEventChanged }: ChatPanelProps) => { const reader = new FileReader(); reader.onloadend = async () => { const base64 = (reader.result as string).split(',')[1]; - setMessages(prev => [...prev, { role: "user", content: `šŸ“· Image: ${file.name}` }]); + setMessages(prev => [...prev, { role: "user", content: `Image: ${file.name}` }]); await sendToAgent(input || "", "image", base64); setInput(""); }; @@ -160,7 +154,6 @@ const ChatPanel = ({ open, onOpenChange, onEventChanged }: ChatPanelProps) => { return ( - {/* Header */}
@@ -171,7 +164,6 @@ const ChatPanel = ({ open, onOpenChange, onEventChanged }: ChatPanelProps) => {
- {/* Messages */}
{messages.map((msg, i) => ( @@ -222,7 +214,6 @@ const ChatPanel = ({ open, onOpenChange, onEventChanged }: ChatPanelProps) => {
- {/* Input Bar */}
{ addEvent, updateEvent, deleteEvent, + fetchEvents, }; }; \ No newline at end of file diff --git a/src/components/QuickActions.tsx b/src/components/QuickActions.tsx index 8b6c69c..7b5a97c 100644 --- a/src/components/QuickActions.tsx +++ b/src/components/QuickActions.tsx @@ -1,16 +1,14 @@ import { useState, useRef, useEffect } from "react"; import { Button } from "@/components/ui/button"; -import { Plus, Mic, Image, MessageSquare, Send } from "lucide-react"; +import { Plus, MessageCircle, Send } from "lucide-react"; interface QuickActionsProps { onAddEvent: () => void; - onVoiceClick: () => void; - onImageClick: () => void; - onTextClick: () => void; + onChatClick: () => void; onTelegramClick: () => void; } -const QuickActions = ({ onAddEvent, onVoiceClick, onImageClick, onTextClick, onTelegramClick }: QuickActionsProps) => { +const QuickActions = ({ onAddEvent, onChatClick, onTelegramClick }: QuickActionsProps) => { const [isOpen, setIsOpen] = useState(false); const menuRef = useRef(null); @@ -38,15 +36,17 @@ const QuickActions = ({ onAddEvent, onVoiceClick, onImageClick, onTextClick, onT variant="outline" size="icon" className="rounded-full shadow-md w-14 h-14 bg-background" - onClick={() => { setIsOpen(false); onTextClick(); }} + onClick={() => { setIsOpen(false); onAddEvent(); }} + title="Add Event Manually" > - + @@ -54,25 +54,10 @@ const QuickActions = ({ onAddEvent, onVoiceClick, onImageClick, onTextClick, onT variant="outline" size="icon" className="rounded-full shadow-md w-14 h-14 bg-background" - onClick={() => { setIsOpen(false); onImageClick(); }} - > - - - -
)} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 76e1aa2..9dc33f2 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -5,13 +5,10 @@ import EditEventDialog from "@/components/EditEventDialog"; import EventCalendar from "@/components/EventCalendar"; import EventPanel from "@/components/EventPanel"; import QuickActions from "@/components/QuickActions"; -import TextInputDialog from "@/components/TextInputDialog"; -import VoiceInputDialog from "@/components/VoiceInputDialog"; -import ImageUploadDialog from "@/components/ImageUploadDialog"; +import ChatPanel from "@/components/ChatPanel"; import DeleteRecurringDialog from "@/components/DeleteRecurringDialog"; import TelegramLinkingDialog from "@/components/TelegramLinkingDialog"; import { Event } from "@/types/event"; -import { ParsedEvent } from "@/lib/ai"; import { isEventOnDate } from "@/lib/dateUtils"; import { supabase } from "@/integrations/supabase/client"; import { Button } from "@/components/ui/button"; @@ -24,9 +21,7 @@ const Index = () => { const [date, setDate] = useState(new Date()); const [isAddEventOpen, setIsAddEventOpen] = useState(false); const [isEditEventOpen, setIsEditEventOpen] = useState(false); - const [isTextInputOpen, setIsTextInputOpen] = useState(false); - const [isVoiceInputOpen, setIsVoiceInputOpen] = useState(false); - const [isImageUploadOpen, setIsImageUploadOpen] = useState(false); + const [isChatOpen, setIsChatOpen] = useState(false); const [isTelegramLinkingOpen, setIsTelegramLinkingOpen] = useState(false); const [aiInitialData, setAiInitialData] = useState | undefined>(undefined); const [selectedEvent, setSelectedEvent] = useState(null); @@ -36,7 +31,7 @@ const Index = () => { const [guestEvents, setGuestEvents] = useState([]); const [isGuestMode, setIsGuestMode] = useState(false); - const { events, addEvent, updateEvent, deleteEvent } = useEventManager(); + const { events, addEvent, updateEvent, deleteEvent, fetchEvents } = useEventManager(); const handleDateSelect = (selectedDate: Date | undefined) => { setDate(selectedDate); @@ -123,32 +118,6 @@ const Index = () => { setEventToDelete(null); }; - const handleAIParsed = (parsed: ParsedEvent) => { - const today = new Date(); - const defaultDate = today.toLocaleDateString('en-CA'); - - const eventToSave = { - title: parsed.title, - date: parsed.date || defaultDate, - time: parsed.time || "09:00", // Default to 9 AM if time is missing - description: parsed.description, - category: parsed.category, - recurrence: parsed.recurrence, - }; - - if (isGuestMode) { - handleGuestAddEvent(eventToSave); - } else { - addEvent(eventToSave); - } - - if (!parsed.date || !parsed.time) { - toast.info("Assumed defaults: " + eventToSave.date + " " + eventToSave.time); - } else { - toast.success("AI Found: " + eventToSave.date + " at " + eventToSave.time); - } - }; - const getEventsForDate = useCallback((date: Date | undefined) => { if (!date) return []; @@ -236,12 +205,16 @@ const Index = () => { setAiInitialData(undefined); setIsAddEventOpen(true); }} - onVoiceClick={() => setIsVoiceInputOpen(true)} - onImageClick={() => setIsImageUploadOpen(true)} - onTextClick={() => setIsTextInputOpen(true)} + onChatClick={() => setIsChatOpen(true)} onTelegramClick={() => setIsTelegramLinkingOpen(true)} /> + + setIsTelegramLinkingOpen(false)} @@ -262,24 +235,6 @@ const Index = () => { event={selectedEvent} /> - - - - - - !open && setEventToDelete(null)} From 255a9b38eaaa7d3a804139417e154e4c08a86ee8 Mon Sep 17 00:00:00 2001 From: SupremeEvilGod Date: Mon, 23 Mar 2026 16:08:03 +0530 Subject: [PATCH 3/7] created the agent by defining it and adding prompt --- supabase/functions/agent/index.ts | 423 ++++++++++++++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 supabase/functions/agent/index.ts diff --git a/supabase/functions/agent/index.ts b/supabase/functions/agent/index.ts new file mode 100644 index 0000000..3c6ea61 --- /dev/null +++ b/supabase/functions/agent/index.ts @@ -0,0 +1,423 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts" +import { encode } from "https://deno.land/std@0.168.0/encoding/base64.ts" +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.7" +import Groq from "npm:groq-sdk" +import * as chrono from "npm:chrono-node" + +declare const Deno: { env: { get(name: string): string | undefined } }; + +const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; +const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; +const GROQ_API_KEY = Deno.env.get("GROQ_API_KEY")!; + +const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); +const groq = new Groq({ apiKey: GROQ_API_KEY }); + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +const toolDefinitions = [ + { + type: "function" as const, + function: { + name: "create_event", + description: "Create a new calendar event. Backend automatically checks for conflicts before creating.", + parameters: { + type: "object", + properties: { + title: { type: "string", description: "Event title" }, + date: { type: "string", description: "Date in YYYY-MM-DD format" }, + time: { type: "string", description: "Time in HH:MM format (24h). Default 09:00" }, + description: { type: "string", description: "Event description" }, + category: { type: "string", enum: ["work", "personal", "family", "health", "social", ""], description: "Event category" }, + recurrence: { type: "string", enum: ["none", "daily", "weekly", "monthly", "yearly"], description: "Recurrence pattern. Default none" }, + }, + required: ["title", "date"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "get_events", + description: "Fetch calendar events within a date range. Use to check schedule, find conflicts, or list events.", + parameters: { + type: "object", + properties: { + start_date: { type: "string", description: "Start date (YYYY-MM-DD)" }, + end_date: { type: "string", description: "End date (YYYY-MM-DD)" }, + search_term: { type: "string", description: "Optional: filter by title (fuzzy match)" }, + }, + required: ["start_date", "end_date"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "update_event", + description: "Update an existing event. Pass the event_id and any fields to change.", + parameters: { + type: "object", + properties: { + event_id: { type: "string", description: "The UUID of the event to update" }, + title: { type: "string", description: "New title (optional)" }, + date: { type: "string", description: "New date YYYY-MM-DD (optional)" }, + time: { type: "string", description: "New time HH:MM (optional)" }, + description: { type: "string", description: "New description (optional)" }, + category: { type: "string", description: "New category (optional)" }, + }, + required: ["event_id"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "delete_event", + description: "Delete an event. First call returns event details for confirmation. Call again with confirmed=true to actually delete.", + parameters: { + type: "object", + properties: { + event_id: { type: "string", description: "UUID of event to delete" }, + confirmed: { type: "boolean", description: "Set to true to confirm deletion. Default false." }, + }, + required: ["event_id"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "analyze_schedule", + description: "Analyze the user's schedule. Returns busiest day, free slots, conflicts, and total events for the given period.", + parameters: { + type: "object", + properties: { + start_date: { type: "string", description: "Analysis period start (YYYY-MM-DD)" }, + end_date: { type: "string", description: "Analysis period end (YYYY-MM-DD)" }, + }, + required: ["start_date", "end_date"], + }, + }, + }, +]; + +async function toolCreateEvent(userId: string, args: any) { + const time = args.time || "09:00"; + const startDate = new Date(`${args.date}T${time}:00+05:30`).toISOString(); + + const startCheck = new Date(`${args.date}T00:00:00+05:30`).toISOString(); + const endCheck = new Date(`${args.date}T23:59:59+05:30`).toISOString(); + const { data: existing } = await supabase + .from('events').select('*').eq('user_id', userId) + .gte('start_date', startCheck).lte('start_date', endCheck); + + if (existing && existing.length > 0) { + const newHour = parseInt(time.split(':')[0]); + const conflicts = existing.filter(e => { + const eHour = new Date(e.start_date).getUTCHours(); + return Math.abs(eHour - newHour) < 1; + }); + if (conflicts.length > 0) { + return { + conflict: true, + message: `Time conflict detected on ${args.date}`, + conflicting_events: conflicts.map(e => ({ + id: e.id, title: e.title, + date: e.start_date.split('T')[0], + time: e.start_date.split('T')[1].substring(0, 5), + })), + existing_events_that_day: existing.map(e => ({ + id: e.id, title: e.title, + time: e.start_date.split('T')[1].substring(0, 5), + })), + }; + } + } + + const { data, error } = await supabase.from('events').insert([{ + user_id: userId, + title: args.title, + description: args.description || "", + start_date: startDate, + category: args.category || "", + recurrence: args.recurrence || "none", + }]).select().single(); + + if (error) return { error: error.message }; + return { + created: true, + event: { id: data.id, title: data.title, date: args.date, time, category: data.category }, + }; +} + +async function toolGetEvents(userId: string, args: any) { + const start = new Date(`${args.start_date}T00:00:00+05:30`).toISOString(); + const end = new Date(`${args.end_date}T23:59:59+05:30`).toISOString(); + + let query = supabase.from('events').select('*').eq('user_id', userId) + .gte('start_date', start).lte('start_date', end) + .order('start_date', { ascending: true }); + + if (args.search_term) { + query = query.ilike('title', `%${args.search_term}%`); + } + + const { data, error } = await query.limit(20); + if (error) return { error: error.message }; + + return { + count: data?.length || 0, + events: (data || []).map(e => ({ + id: e.id, + title: e.title, + date: e.start_date.split('T')[0], + time: e.start_date.split('T')[1].substring(0, 5), + category: e.category || "", + description: e.description || "", + })), + }; +} + +async function toolUpdateEvent(userId: string, args: any) { + const updateData: any = {}; + if (args.title) updateData.title = args.title; + if (args.description) updateData.description = args.description; + if (args.category) updateData.category = args.category; + if (args.date || args.time) { + const { data: existing } = await supabase.from('events').select('start_date').eq('id', args.event_id).single(); + if (!existing) return { error: "Event not found" }; + const curDate = existing.start_date.split('T')[0]; + const curTime = existing.start_date.split('T')[1].substring(0, 5); + const newDate = args.date || curDate; + const newTime = args.time || curTime; + updateData.start_date = new Date(`${newDate}T${newTime}:00+05:30`).toISOString(); + } + + const { data, error } = await supabase.from('events').update(updateData) + .eq('id', args.event_id).eq('user_id', userId).select().single(); + if (error) return { error: error.message }; + return { + updated: true, + event: { id: data.id, title: data.title, date: data.start_date.split('T')[0], time: data.start_date.split('T')[1].substring(0, 5) }, + }; +} + +async function toolDeleteEvent(userId: string, args: any) { + const { data: event } = await supabase.from('events').select('*') + .eq('id', args.event_id).eq('user_id', userId).single(); + if (!event) return { error: "Event not found" }; + + if (!args.confirmed) { + return { + requires_confirmation: true, + event: { id: event.id, title: event.title, date: event.start_date.split('T')[0], time: event.start_date.split('T')[1].substring(0, 5) }, + message: `Are you sure you want to delete "${event.title}" on ${event.start_date.split('T')[0]}?`, + }; + } + + const { error } = await supabase.from('events').delete().eq('id', args.event_id).eq('user_id', userId); + if (error) return { error: error.message }; + return { deleted: true, title: event.title }; +} + +async function toolAnalyzeSchedule(userId: string, args: any) { + const start = new Date(`${args.start_date}T00:00:00+05:30`).toISOString(); + const end = new Date(`${args.end_date}T23:59:59+05:30`).toISOString(); + + const { data: events } = await supabase.from('events').select('*').eq('user_id', userId) + .gte('start_date', start).lte('start_date', end) + .order('start_date', { ascending: true }); + + if (!events || events.length === 0) { + return { total_events: 0, busiest_day: null, free_slots: [], conflicts: [], message: "No events in this period." }; + } + + const dayCounts: Record = {}; + const dayEvents: Record = {}; + for (const e of events) { + const day = e.start_date.split('T')[0]; + dayCounts[day] = (dayCounts[day] || 0) + 1; + if (!dayEvents[day]) dayEvents[day] = []; + dayEvents[day].push({ title: e.title, time: e.start_date.split('T')[1].substring(0, 5) }); + } + + const busiestDay = Object.entries(dayCounts).sort((a, b) => b[1] - a[1])[0]; + + const conflicts: any[] = []; + for (const [day, evts] of Object.entries(dayEvents)) { + for (let i = 0; i < evts.length; i++) { + for (let j = i + 1; j < evts.length; j++) { + const h1 = parseInt(evts[i].time.split(':')[0]); + const h2 = parseInt(evts[j].time.split(':')[0]); + if (Math.abs(h1 - h2) < 1) { + conflicts.push({ day, event1: evts[i].title, event2: evts[j].title, overlap: evts[i].time }); + } + } + } + } + + const freeSlots: any[] = []; + const allDays = Object.keys(dayCounts); + for (const day of allDays) { + const busyHours = new Set(dayEvents[day].map(e => parseInt(e.time.split(':')[0]))); + const slots: string[] = []; + for (let h = 9; h < 18; h++) { + if (!busyHours.has(h)) slots.push(`${h}:00-${h + 1}:00`); + } + if (slots.length > 0) freeSlots.push({ day, slots }); + } + + return { + total_events: events.length, + busiest_day: busiestDay ? { date: busiestDay[0], count: busiestDay[1] } : null, + conflicts, + free_slots: freeSlots, + }; +} + +async function executeTool(userId: string, toolName: string, args: any): Promise { + switch (toolName) { + case "create_event": return await toolCreateEvent(userId, args); + case "get_events": return await toolGetEvents(userId, args); + case "update_event": return await toolUpdateEvent(userId, args); + case "delete_event": return await toolDeleteEvent(userId, args); + case "analyze_schedule": return await toolAnalyzeSchedule(userId, args); + default: return { error: `Unknown tool: ${toolName}` }; + } +} + +function buildSystemPrompt(todayStr: string) { + return `You are Maantis, an AI scheduling assistant. Today is ${todayStr}. + +You can: create, update, delete events, analyze schedules, detect conflicts. + +RULES: +1. ALWAYS call get_events before creating to check for time conflicts. +2. If a conflict exists, suggest 2-3 alternative times. Do NOT auto-create over a conflict. +3. For DELETE: show what will be deleted and ask "Should I proceed?". Only call delete_event with confirmed=true after the user says yes. +4. Use tools whenever real data is needed. NEVER guess or hallucinate event data. +5. Be concise but helpful. +6. When resolving dates like "tomorrow", "next Friday", compute the actual YYYY-MM-DD date. +7. For "plan my week" or "what's my schedule": call get_events with the appropriate date range. +8. For "when am I most busy?" or schedule insights: call analyze_schedule. +9. If user says "move/reschedule [event]": first get_events to find it, then update_event. +10. Always respond with the final result in natural language.`; +} + +serve(async (req: any) => { + if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders }); + + try { + const body = await req.json(); + const { user_id, message, input_type, file_data, conversation_history } = body; + + if (!user_id) return new Response(JSON.stringify({ error: "user_id required" }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + + const todayStr = new Date().toLocaleDateString('en-CA'); + let userMessage = message || ""; + + if (input_type === "voice" && file_data) { + const binaryData = Uint8Array.from(atob(file_data), c => c.charCodeAt(0)); + const file = new File([binaryData], "voice.ogg", { type: "audio/ogg" }); + const transcription = await groq.audio.transcriptions.create({ file, model: "whisper-large-v3-turbo" }); + userMessage = transcription.text; + } + + if (input_type === "image" && file_data) { + const visionCompletion = await groq.chat.completions.create({ + messages: [ + { role: "user", content: [ + { type: "text", text: `Extract any event details (title, date, time, location) from this image. ${userMessage ? "Additional context: " + userMessage : ""}` }, + { type: "image_url", image_url: { url: `data:image/jpeg;base64,${file_data}` } }, + ]}, + ], + model: "meta-llama/llama-4-scout-17b-16e-instruct", + }); + userMessage = visionCompletion.choices[0]?.message?.content || userMessage; + } + + if (!userMessage.trim()) { + return new Response(JSON.stringify({ error: "No message provided" }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } + + const messages: any[] = [ + { role: "system", content: buildSystemPrompt(todayStr) }, + ]; + + if (conversation_history && Array.isArray(conversation_history)) { + for (const msg of conversation_history) { + messages.push(msg); + } + } + + messages.push({ role: "user", content: userMessage }); + + let maxSteps = 5; + let finalResponse = ""; + const toolCalls: any[] = []; + + while (maxSteps--) { + const completion = await groq.chat.completions.create({ + messages, + model: "llama-3.3-70b-versatile", + tools: toolDefinitions, + tool_choice: "auto", + temperature: 0.2, + }); + + const choice = completion.choices[0]; + + if (!choice.message.tool_calls || choice.message.tool_calls.length === 0) { + finalResponse = choice.message.content || ""; + break; + } + + messages.push(choice.message); + + for (const toolCall of choice.message.tool_calls) { + const fnName = toolCall.function.name; + const fnArgs = JSON.parse(toolCall.function.arguments); + + console.log("Tool:", fnName, JSON.stringify(fnArgs)); + toolCalls.push({ tool: fnName, args: fnArgs }); + + let result: any; + try { + result = await executeTool(user_id, fnName, fnArgs); + } catch (e: any) { + console.error("Tool error:", fnName, e.message); + result = { error: "Tool execution failed: " + e.message }; + } + + console.log("Result:", JSON.stringify(result)); + + messages.push({ + role: "tool", + tool_call_id: toolCall.id, + name: fnName, + content: JSON.stringify(result), + }); + } + } + + return new Response(JSON.stringify({ + response: finalResponse, + tool_calls_made: toolCalls, + transcription: input_type === "voice" ? userMessage : undefined, + }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 200, + }); + + } catch (error: any) { + console.error("Agent Error:", error); + return new Response(JSON.stringify({ error: error.message }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 500, + }); + } +}); From b2bd1fa7a499d995fd0f54bbdfd0764e574d978b Mon Sep 17 00:00:00 2001 From: SupremeEvilGod Date: Sat, 28 Mar 2026 00:15:26 +0530 Subject: [PATCH 4/7] fixed bugs and added readme --- README.md | 31 +- supabase/functions/agent/index.ts | 142 ++-- supabase/functions/telegram-bot/index.ts | 736 ++++++++++-------- .../20260323165200_add_last_bot_response.sql | 1 + ...0260323173000_add_conversation_history.sql | 1 + 5 files changed, 525 insertions(+), 386 deletions(-) create mode 100644 supabase/migrations/20260323165200_add_last_bot_response.sql create mode 100644 supabase/migrations/20260323173000_add_conversation_history.sql diff --git a/README.md b/README.md index bd60d90..560bb03 100644 --- a/README.md +++ b/README.md @@ -6,24 +6,34 @@ ![TailwindCSS](https://img.shields.io/badge/tailwindcss-%2338B2AC.svg?style=for-the-badge&logo=tailwind-css&logoColor=white) ![Supabase](https://img.shields.io/badge/Supabase-3ECF8E?style=for-the-badge&logo=supabase&logoColor=white) -A modern, responsive, and dynamic Event Scheduling application built with React, Vite, TypeScript, and Supabase. The application allows users to manage their events seamlessly, featuring an intuitive calendar interface, categorical filtering, search functionality, and automated Telegram notifications. +A modern, responsive, and dynamic Event Scheduling application built with React, Vite, TypeScript, and Supabase. The application allows users to manage their events seamlessly using AI-powered natural language processing, voice commands, and image recognition. ## Live Demo - **Web Application:** [https://smart-scheduling.vercel.app/](https://smart-scheduling.vercel.app/) - **Telegram Bot:** [@Maantisbot](https://t.me/Maantisbot) (Link: [https://t.me/Maantisbot](https://t.me/Maantisbot)) - ## Features +- **AI Event Parsing:** Create structured events from raw text, voice, or images using LLMs. +- **Voice-to-Event:** Record your voice and let the AI transcribe and schedule the event automatically. +- **Image/OCR Scheduling:** Take a picture of a physical note or schedule and extract event details instantly. - **User Authentication:** Secure signup and login using Supabase Auth. - **Guest Mode:** Experience the app without creating an account (events are stored in-memory). - **Interactive Calendar:** Visual event calendar for easy date selection and event viewing. -- **Event Management:** Add, edit, and delete events with details like title, description, category, and date. - **Telegram Notifications:** Get notified via Telegram bot when an event starts. - **Advanced Filtering & Search:** Search events by keyword and filter them by custom categories. - **Responsive Design:** Fully responsive UI built with Tailwind CSS and Shadcn UI components. -- **Real-time Toasts:** Instant feedback on user actions using Sonner. + +## AI Capabilities + +The "Smart" in Smart Scheduling is powered by a robust AI pipeline: + +- **Natural Language Processing:** Powered by Groq (Llama 3.1 8B) to extract titles, dates, times, and categories from unstructured text. +- **Voice Transcription:** Utilizes Groq Whisper (whisper-large-v3-turbo) for high-accuracy voice-to-text conversion. +- **Date Intelligence:** Combines LLM analysis with Chrono-node for precise, deterministic date and recurrence calculations. +- **OCR (Optical Character Recognition):** Integrated with Tesseract.js to process images and extract textual event data. + ## Tech Stack @@ -72,6 +82,7 @@ erDiagram The application leverages Supabase Edge Functions and `pg_cron` for background tasks: +- **analyze-event:** An Edge Function that utilizes Groq Llama and Whisper models to parse events from text and audio inputs. - **send-notifications:** An Edge Function that scans for upcoming events and sends Telegram messages to users with linked accounts. - **pg_cron:** Managed via the `process-notifications-every-minute` job, which triggers the notification engine every minute. @@ -109,16 +120,20 @@ Ensure you have the following installed on your local machine: VITE_SUPABASE_ANON_KEY=your_supabase_anon_key ``` -### Telegram Bot Setup +## Setup Secrets -To enable notifications, you must configure a Telegram Bot: +To enable full functionality, you must configure the following Supabase Secrets: -1. Create a bot using [@BotFather](https://t.me/botfather) and obtain the `TELEGRAM_BOT_TOKEN`. -2. Set the token in your Supabase project secrets: +1. **Telegram Notifications:** Create a bot via [@BotFather](https://t.me/botfather) and set the token: ```bash supabase secrets set TELEGRAM_BOT_TOKEN=your_token ``` +2. **Groq AI Capabilities:** Obtain an API key from Groq Console and set it: + ```bash + supabase secrets set GROQ_API_KEY=your_key + ``` + 4. **Start the Development Server:** ```bash npm run dev diff --git a/supabase/functions/agent/index.ts b/supabase/functions/agent/index.ts index 3c6ea61..9cae64c 100644 --- a/supabase/functions/agent/index.ts +++ b/supabase/functions/agent/index.ts @@ -23,16 +23,16 @@ const toolDefinitions = [ type: "function" as const, function: { name: "create_event", - description: "Create a new calendar event. Backend automatically checks for conflicts before creating.", + description: "Create event. Auto-checks conflicts.", parameters: { type: "object", properties: { - title: { type: "string", description: "Event title" }, - date: { type: "string", description: "Date in YYYY-MM-DD format" }, - time: { type: "string", description: "Time in HH:MM format (24h). Default 09:00" }, - description: { type: "string", description: "Event description" }, - category: { type: "string", enum: ["work", "personal", "family", "health", "social", ""], description: "Event category" }, - recurrence: { type: "string", enum: ["none", "daily", "weekly", "monthly", "yearly"], description: "Recurrence pattern. Default none" }, + title: { type: "string" }, + date: { type: "string", description: "YYYY-MM-DD" }, + time: { type: "string", description: "HH:MM 24h. Default 09:00" }, + description: { type: "string" }, + category: { type: "string", enum: ["work", "personal", "family", "health", "social", ""] }, + recurrence: { type: "string", enum: ["none", "daily", "weekly", "monthly", "yearly"] }, }, required: ["title", "date"], }, @@ -42,15 +42,14 @@ const toolDefinitions = [ type: "function" as const, function: { name: "get_events", - description: "Fetch calendar events within a date range. Use to check schedule, find conflicts, or list events.", + description: "Fetch events. Supports date range, keyword search, or both. At least one of start_date or query is required.", parameters: { type: "object", properties: { - start_date: { type: "string", description: "Start date (YYYY-MM-DD)" }, - end_date: { type: "string", description: "End date (YYYY-MM-DD)" }, - search_term: { type: "string", description: "Optional: filter by title (fuzzy match)" }, + start_date: { type: "string", description: "YYYY-MM-DD" }, + end_date: { type: "string", description: "YYYY-MM-DD" }, + query: { type: "string", description: "Keyword to search in title, category, description" }, }, - required: ["start_date", "end_date"], }, }, }, @@ -58,16 +57,16 @@ const toolDefinitions = [ type: "function" as const, function: { name: "update_event", - description: "Update an existing event. Pass the event_id and any fields to change.", + description: "Update event fields by event_id.", parameters: { type: "object", properties: { - event_id: { type: "string", description: "The UUID of the event to update" }, - title: { type: "string", description: "New title (optional)" }, - date: { type: "string", description: "New date YYYY-MM-DD (optional)" }, - time: { type: "string", description: "New time HH:MM (optional)" }, - description: { type: "string", description: "New description (optional)" }, - category: { type: "string", description: "New category (optional)" }, + event_id: { type: "string" }, + title: { type: "string" }, + date: { type: "string", description: "YYYY-MM-DD" }, + time: { type: "string", description: "HH:MM" }, + description: { type: "string" }, + category: { type: "string" }, }, required: ["event_id"], }, @@ -77,12 +76,12 @@ const toolDefinitions = [ type: "function" as const, function: { name: "delete_event", - description: "Delete an event. First call returns event details for confirmation. Call again with confirmed=true to actually delete.", + description: "Delete event. Returns details first. Pass confirmed=true to confirm.", parameters: { type: "object", properties: { - event_id: { type: "string", description: "UUID of event to delete" }, - confirmed: { type: "boolean", description: "Set to true to confirm deletion. Default false." }, + event_id: { type: "string" }, + confirmed: { type: "boolean" }, }, required: ["event_id"], }, @@ -92,12 +91,12 @@ const toolDefinitions = [ type: "function" as const, function: { name: "analyze_schedule", - description: "Analyze the user's schedule. Returns busiest day, free slots, conflicts, and total events for the given period.", + description: "Analyze schedule: busiest day, free slots, conflicts.", parameters: { type: "object", properties: { - start_date: { type: "string", description: "Analysis period start (YYYY-MM-DD)" }, - end_date: { type: "string", description: "Analysis period end (YYYY-MM-DD)" }, + start_date: { type: "string", description: "YYYY-MM-DD" }, + end_date: { type: "string", description: "YYYY-MM-DD" }, }, required: ["start_date", "end_date"], }, @@ -155,29 +154,29 @@ async function toolCreateEvent(userId: string, args: any) { } async function toolGetEvents(userId: string, args: any) { - const start = new Date(`${args.start_date}T00:00:00+05:30`).toISOString(); - const end = new Date(`${args.end_date}T23:59:59+05:30`).toISOString(); - let query = supabase.from('events').select('*').eq('user_id', userId) - .gte('start_date', start).lte('start_date', end) .order('start_date', { ascending: true }); - if (args.search_term) { - query = query.ilike('title', `%${args.search_term}%`); + if (args.start_date && args.end_date) { + const start = new Date(`${args.start_date}T00:00:00+05:30`).toISOString(); + const end = new Date(`${args.end_date}T23:59:59+05:30`).toISOString(); + query = query.gte('start_date', start).lte('start_date', end); + } + + if (args.query) { + query = query.or(`title.ilike.%${args.query}%,category.ilike.%${args.query}%,description.ilike.%${args.query}%`); } const { data, error } = await query.limit(20); if (error) return { error: error.message }; + if (!data || data.length === 0) return { count: 0, events: [], message: "No matching events found." }; return { - count: data?.length || 0, - events: (data || []).map(e => ({ - id: e.id, - title: e.title, + count: data.length, + events: data.map((e: any) => ({ + id: e.id, title: e.title, date: e.start_date.split('T')[0], time: e.start_date.split('T')[1].substring(0, 5), - category: e.category || "", - description: e.description || "", })), }; } @@ -291,21 +290,25 @@ async function executeTool(userId: string, toolName: string, args: any): Promise } function buildSystemPrompt(todayStr: string) { - return `You are Maantis, an AI scheduling assistant. Today is ${todayStr}. - -You can: create, update, delete events, analyze schedules, detect conflicts. - -RULES: -1. ALWAYS call get_events before creating to check for time conflicts. -2. If a conflict exists, suggest 2-3 alternative times. Do NOT auto-create over a conflict. -3. For DELETE: show what will be deleted and ask "Should I proceed?". Only call delete_event with confirmed=true after the user says yes. -4. Use tools whenever real data is needed. NEVER guess or hallucinate event data. -5. Be concise but helpful. -6. When resolving dates like "tomorrow", "next Friday", compute the actual YYYY-MM-DD date. -7. For "plan my week" or "what's my schedule": call get_events with the appropriate date range. -8. For "when am I most busy?" or schedule insights: call analyze_schedule. -9. If user says "move/reschedule [event]": first get_events to find it, then update_event. -10. Always respond with the final result in natural language.`; + return `You are Maantis, a scheduling agent. Today is ${todayStr}. Year is ${todayStr.split('-')[0]}. + +You have FULL access to the user's calendar via tools. Act immediately. + +BEHAVIOR: +- When user asks about events, schedules, birthdays, meetings, or anything calendar-related: IMMEDIATELY call get_events with appropriate query and/or date range. Do NOT ask permission. Do NOT say "let me check" or "please confirm". Just call the tool and return results. +- For read operations (listing, searching, checking): NEVER ask confirmation. Just do it. +- Only ask confirmation for DELETE operations. +- Check conflicts before creating events. + +TOOL USAGE: +- get_events: pass query for keyword search (e.g. query:"birthday"), date range for time filtering, or both. Backend filters server-side. +- When user mentions multiple topics (e.g. "hiring challenges and meetups"), call get_events ONCE with a broad date range and no query, then filter the results yourself. Or call get_events multiple times with different queries. +- All dates must use year ${todayStr.split('-')[0]}. + +CONTEXT: +- If user replies "yes", "ok", "sure", "list them": continue your previous task immediately. Do NOT restart the conversation. +- Return ONLY data from tool responses. Never invent events. +- Be concise. Give direct answers.`; } serve(async (req: any) => { @@ -361,13 +364,25 @@ serve(async (req: any) => { const toolCalls: any[] = []; while (maxSteps--) { - const completion = await groq.chat.completions.create({ - messages, - model: "llama-3.3-70b-versatile", - tools: toolDefinitions, - tool_choice: "auto", - temperature: 0.2, - }); + let completion; + try { + completion = await groq.chat.completions.create({ + messages, + model: "llama-3.3-70b-versatile", + tools: toolDefinitions, + tool_choice: "auto", + temperature: 0.2, + }); + } catch (apiErr: any) { + console.error("Groq API error, retrying without tools:", apiErr.message); + const fallback = await groq.chat.completions.create({ + messages, + model: "llama-3.3-70b-versatile", + temperature: 0.2, + }); + finalResponse = fallback.choices[0]?.message?.content || "Sorry, I could not process that request."; + break; + } const choice = completion.choices[0]; @@ -404,6 +419,15 @@ serve(async (req: any) => { } } + if (!finalResponse && messages.length > 2) { + const summary = await groq.chat.completions.create({ + messages: [...messages, { role: "user", content: "Summarize what you just did in one concise sentence." }], + model: "llama-3.3-70b-versatile", + temperature: 0.2, + }); + finalResponse = summary.choices[0]?.message?.content || "Done."; + } + return new Response(JSON.stringify({ response: finalResponse, tool_calls_made: toolCalls, diff --git a/supabase/functions/telegram-bot/index.ts b/supabase/functions/telegram-bot/index.ts index 917943e..2de3743 100644 --- a/supabase/functions/telegram-bot/index.ts +++ b/supabase/functions/telegram-bot/index.ts @@ -1,14 +1,8 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts" -import { encode } from "https://deno.land/std@0.168.0/encoding/base64.ts" import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.7" import Groq from "npm:groq-sdk" -import * as chrono from "npm:chrono-node" -declare const Deno: { - env: { - get(name: string): string | undefined; - }; -}; +declare const Deno: { env: { get(name: string): string | undefined } }; const TELEGRAM_BOT_TOKEN = Deno.env.get("TELEGRAM_BOT_TOKEN"); const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; @@ -21,366 +15,470 @@ const groq = new Groq({ apiKey: GROQ_API_KEY }); const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +const toolDefinitions = [ + { + type: "function" as const, + function: { + name: "create_event", + description: "Create event. Auto-checks conflicts.", + parameters: { + type: "object", + properties: { + title: { type: "string" }, + date: { type: "string", description: "YYYY-MM-DD" }, + time: { type: "string", description: "HH:MM 24h. Default 09:00" }, + description: { type: "string" }, + category: { type: "string", enum: ["work", "personal", "family", "health", "social", ""] }, + recurrence: { type: "string", enum: ["none", "daily", "weekly", "monthly", "yearly"] }, + }, + required: ["title", "date"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "get_events", + description: "Fetch events. Supports date range, keyword search, or both. At least one of start_date or query is required.", + parameters: { + type: "object", + properties: { + start_date: { type: "string", description: "YYYY-MM-DD" }, + end_date: { type: "string", description: "YYYY-MM-DD" }, + query: { type: "string", description: "Keyword to search in title, category, description" }, + }, + }, + }, + }, + { + type: "function" as const, + function: { + name: "update_event", + description: "Update event fields by event_id.", + parameters: { + type: "object", + properties: { + event_id: { type: "string" }, + title: { type: "string" }, + date: { type: "string", description: "YYYY-MM-DD" }, + time: { type: "string", description: "HH:MM" }, + description: { type: "string" }, + category: { type: "string" }, + }, + required: ["event_id"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "delete_event", + description: "Delete event. Returns details first. Pass confirmed=true to confirm.", + parameters: { + type: "object", + properties: { + event_id: { type: "string" }, + confirmed: { type: "boolean" }, + }, + required: ["event_id"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "analyze_schedule", + description: "Analyze schedule: busiest day, free slots, conflicts.", + parameters: { + type: "object", + properties: { + start_date: { type: "string", description: "YYYY-MM-DD" }, + end_date: { type: "string", description: "YYYY-MM-DD" }, + }, + required: ["start_date", "end_date"], + }, + }, + }, +]; + +async function toolCreateEvent(userId: string, args: any) { + const time = args.time || "09:00"; + const startDate = new Date(`${args.date}T${time}:00+05:30`).toISOString(); + + const startCheck = new Date(`${args.date}T00:00:00+05:30`).toISOString(); + const endCheck = new Date(`${args.date}T23:59:59+05:30`).toISOString(); + const { data: existing } = await supabase + .from('events').select('*').eq('user_id', userId) + .gte('start_date', startCheck).lte('start_date', endCheck); + + if (existing && existing.length > 0) { + const newHour = parseInt(time.split(':')[0]); + const conflicts = existing.filter(e => { + const eHour = new Date(e.start_date).getUTCHours(); + return Math.abs(eHour - newHour) < 1; + }); + if (conflicts.length > 0) { + return { + conflict: true, + message: `Time conflict detected on ${args.date}`, + conflicting_events: conflicts.map(e => ({ + id: e.id, title: e.title, + date: e.start_date.split('T')[0], + time: e.start_date.split('T')[1].substring(0, 5), + })), + existing_events_that_day: existing.map(e => ({ + id: e.id, title: e.title, + time: e.start_date.split('T')[1].substring(0, 5), + })), + }; + } + } + + const { data, error } = await supabase.from('events').insert([{ + user_id: userId, + title: args.title, + description: args.description || "", + start_date: startDate, + category: args.category || "", + recurrence: args.recurrence || "none", + }]).select().single(); + + if (error) return { error: error.message }; + return { + created: true, + event: { id: data.id, title: data.title, date: args.date, time, category: data.category }, + }; } -serve(async (req: any) => { - if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders }); - try { - const update = await req.json(); - if (update.message) await handleMessage(update.message); - else if (update.callback_query) await handleCallbackQuery(update.callback_query); - return new Response(JSON.stringify({ ok: true }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 200 }); - } catch (error: any) { - console.error("Update Error:", error); - return new Response(JSON.stringify({ error: error.message }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 200 }); +async function toolGetEvents(userId: string, args: any) { + let query = supabase.from('events').select('*').eq('user_id', userId) + .order('start_date', { ascending: true }); + + if (args.start_date && args.end_date) { + const start = new Date(`${args.start_date}T00:00:00+05:30`).toISOString(); + const end = new Date(`${args.end_date}T23:59:59+05:30`).toISOString(); + query = query.gte('start_date', start).lte('start_date', end); } -}); -// ── Fix 4: Extract event ID from a replied-to bot message ── -function extractEventIdFromReply(replyMessage: any): string | null { - if (!replyMessage) return null; - // Check callback_data in inline keyboards - if (replyMessage.reply_markup?.inline_keyboard) { - for (const row of replyMessage.reply_markup.inline_keyboard) { - for (const btn of row) { - if (btn.callback_data?.startsWith('del_')) { - return btn.callback_data.replace('del_', ''); - } - } - } + if (args.query) { + query = query.or(`title.ilike.%${args.query}%,category.ilike.%${args.query}%,description.ilike.%${args.query}%`); } - return null; + + const { data, error } = await query.limit(20); + if (error) return { error: error.message }; + if (!data || data.length === 0) return { count: 0, events: [], message: "No matching events found." }; + + return { + count: data.length, + events: data.map((e: any) => ({ + id: e.id, title: e.title, + date: e.start_date.split('T')[0], + time: e.start_date.split('T')[1].substring(0, 5), + })), + }; } -async function handleMessage(message: any) { - const chatId = message.chat.id; - const { data: profile } = await supabase.from('profiles').select('id, username, last_event_id').eq('telegram_chat_id', chatId).single(); +async function toolUpdateEvent(userId: string, args: any) { + const updateData: any = {}; + if (args.title) updateData.title = args.title; + if (args.description) updateData.description = args.description; + if (args.category) updateData.category = args.category; + if (args.date || args.time) { + const { data: existing } = await supabase.from('events').select('start_date').eq('id', args.event_id).single(); + if (!existing) return { error: "Event not found" }; + const curDate = existing.start_date.split('T')[0]; + const curTime = existing.start_date.split('T')[1].substring(0, 5); + const newDate = args.date || curDate; + const newTime = args.time || curTime; + updateData.start_date = new Date(`${newDate}T${newTime}:00+05:30`).toISOString(); + } - if (!profile) { - const text = message.text; - if (text?.startsWith('/link') || text?.startsWith('/start')) { - const parts = text.split(' '); - const code = parts.length > 1 ? parts[1] : null; - if (code && !code.includes('@')) { - const { data: userData } = await supabase.from('profiles').update({ telegram_chat_id: chatId, link_code: null }).eq('link_code', code.trim().toUpperCase()).select('username').single(); - if (userData) return await sendTelegramMessage(chatId, `šŸŽ‰ *Linked!* Welcome @${userData.username}.`); + const { data, error } = await supabase.from('events').update(updateData) + .eq('id', args.event_id).eq('user_id', userId).select().single(); + if (error) return { error: error.message }; + return { + updated: true, + event: { id: data.id, title: data.title, date: data.start_date.split('T')[0], time: data.start_date.split('T')[1].substring(0, 5) }, + }; +} + +async function toolDeleteEvent(userId: string, args: any) { + const { data: event } = await supabase.from('events').select('*') + .eq('id', args.event_id).eq('user_id', userId).single(); + if (!event) return { error: "Event not found" }; + + if (!args.confirmed) { + return { + requires_confirmation: true, + event: { id: event.id, title: event.title, date: event.start_date.split('T')[0], time: event.start_date.split('T')[1].substring(0, 5) }, + message: `Are you sure you want to delete "${event.title}" on ${event.start_date.split('T')[0]}?`, + }; + } + + const { error } = await supabase.from('events').delete().eq('id', args.event_id).eq('user_id', userId); + if (error) return { error: error.message }; + return { deleted: true, title: event.title }; +} + +async function toolAnalyzeSchedule(userId: string, args: any) { + const start = new Date(`${args.start_date}T00:00:00+05:30`).toISOString(); + const end = new Date(`${args.end_date}T23:59:59+05:30`).toISOString(); + + const { data: events } = await supabase.from('events').select('*').eq('user_id', userId) + .gte('start_date', start).lte('start_date', end) + .order('start_date', { ascending: true }); + + if (!events || events.length === 0) { + return { total_events: 0, busiest_day: null, free_slots: [], conflicts: [], message: "No events in this period." }; + } + + const dayCounts: Record = {}; + const dayEvents: Record = {}; + for (const e of events) { + const day = e.start_date.split('T')[0]; + dayCounts[day] = (dayCounts[day] || 0) + 1; + if (!dayEvents[day]) dayEvents[day] = []; + dayEvents[day].push({ title: e.title, time: e.start_date.split('T')[1].substring(0, 5) }); + } + + const busiestDay = Object.entries(dayCounts).sort((a, b) => b[1] - a[1])[0]; + + const conflicts: any[] = []; + for (const [day, evts] of Object.entries(dayEvents)) { + for (let i = 0; i < evts.length; i++) { + for (let j = i + 1; j < evts.length; j++) { + const h1 = parseInt(evts[i].time.split(':')[0]); + const h2 = parseInt(evts[j].time.split(':')[0]); + if (Math.abs(h1 - h2) < 1) { + conflicts.push({ day, event1: evts[i].title, event2: evts[j].title, overlap: evts[i].time }); + } } - return await sendTelegramMessage(chatId, "šŸ‘‹ Welcome! Send `/link YOUR-CODE` from the web app."); } - return await sendTelegramMessage(chatId, "āš ļø Not linked. Send `/link YOUR-CODE`."); } - // Fix 4: Check if replying to a bot message → extract that event's ID - let replyEventId: string | null = null; - if (message.reply_to_message) { - replyEventId = extractEventIdFromReply(message.reply_to_message); + const freeSlots: any[] = []; + for (const day of Object.keys(dayCounts)) { + const busyHours = new Set(dayEvents[day].map(e => parseInt(e.time.split(':')[0]))); + const slots: string[] = []; + for (let h = 9; h < 18; h++) { + if (!busyHours.has(h)) slots.push(`${h}:00-${h + 1}:00`); + } + if (slots.length > 0) freeSlots.push({ day, slots }); } - // Fix 5: Read caption from photos - const caption = message.caption || ""; + return { + total_events: events.length, + busiest_day: busiestDay ? { date: busiestDay[0], count: busiestDay[1] } : null, + conflicts, + free_slots: freeSlots, + }; +} - if (message.text) { - await processEvent(chatId, { type: 'text', content: message.text }, profile, replyEventId); - } else if (message.voice) { - await processEvent(chatId, { type: 'voice', fileId: message.voice.file_id }, profile, replyEventId); - } else if (message.photo) { - const fileId = message.photo[message.photo.length - 1].file_id; - await processEvent(chatId, { type: 'photo', fileId, caption }, profile, replyEventId); +async function executeTool(userId: string, toolName: string, args: any): Promise { + switch (toolName) { + case "create_event": return await toolCreateEvent(userId, args); + case "get_events": return await toolGetEvents(userId, args); + case "update_event": return await toolUpdateEvent(userId, args); + case "delete_event": return await toolDeleteEvent(userId, args); + case "analyze_schedule": return await toolAnalyzeSchedule(userId, args); + default: return { error: `Unknown tool: ${toolName}` }; } } -async function processEvent(chatId: number, input: any, profile: any, replyEventId: string | null) { - try { - let textToParse = ""; - let base64Image = ""; - const todayStr = new Date().toLocaleDateString('en-CA'); - - if (input.type === 'text') { - textToParse = input.content; - } else if (input.type === 'voice') { - await sendTelegramMessage(chatId, "šŸ‘‚ Listening to your voice note..."); - const fileUrl = await getTelegramFileUrl(input.fileId); - const response = await fetch(fileUrl); - const blob = await response.blob(); - const file = new File([blob], "voice.ogg", { type: "audio/ogg" }); - const transcription = await groq.audio.transcriptions.create({ file, model: "whisper-large-v3-turbo" }); - textToParse = transcription.text; - } else if (input.type === 'photo') { - await sendTelegramMessage(chatId, "šŸ” Analyzing photo..."); - const fileUrl = await getTelegramFileUrl(input.fileId); - const response = await fetch(fileUrl); - const buffer = await response.arrayBuffer(); - base64Image = encode(new Uint8Array(buffer)); - // Fix 5: Include caption as text context - if (input.caption) textToParse = input.caption; - } +function buildSystemPrompt(todayStr: string) { + return `You are Maantis, a scheduling agent on Telegram. Today is ${todayStr}. Year is ${todayStr.split('-')[0]}. - const todayDateObj = new Date(todayStr + "T12:00:00Z"); - - // Context: last event OR replied-to event - let contextEvent = null; - let contextEventId = replyEventId || profile.last_event_id; - let lastEventContext = "None"; - if (contextEventId) { - const { data } = await supabase.from('events').select('*').eq('id', contextEventId).single(); - if (data) { - contextEvent = data; - lastEventContext = `ID: ${data.id}, Title: "${data.title}", Date: ${data.start_date.split('T')[0]}`; - } - } +You have FULL access to the user's calendar via tools. Act immediately. + +BEHAVIOR: +- When user asks about events, schedules, birthdays, meetings, or anything calendar-related: IMMEDIATELY call get_events with appropriate query and/or date range. Do NOT ask permission. Do NOT say "let me check" or "please confirm". Just call the tool and return results. +- For read operations (listing, searching, checking): NEVER ask confirmation. Just do it. +- Only ask confirmation for DELETE operations. +- Check conflicts before creating events. + +TOOL USAGE: +- get_events: pass query for keyword search (e.g. query:"birthday"), date range for time filtering, or both. Backend filters server-side. +- When user mentions multiple topics (e.g. "hiring challenges and meetups"), call get_events ONCE with a broad date range and no query, then filter the results yourself. Or call get_events multiple times with different queries. +- All dates must use year ${todayStr.split('-')[0]}. + +CONTEXT: +- If user replies "yes", "ok", "sure", "list them": continue your previous task immediately. Do NOT restart the conversation. +- Return ONLY data from tool responses. Never invent events. +- Be concise. Give direct answers.`; +} + +async function sendTelegramMessage(chatId: number, text: string, replyMarkup?: any) { + await fetch(`https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chat_id: chatId, text, parse_mode: 'Markdown', reply_markup: replyMarkup }), + }); +} - const systemPrompt = ` -You are a smart scheduling assistant. Today: ${todayStr}. -${replyEventId ? `The user is REPLYING to this specific event → ${lastEventContext}. Treat as UPDATE unless clearly a new event.` : `Last Active Event: ${lastEventContext}.`} - -CRITICAL RULES: -1. Separate the ANCHOR DATE from any OFFSET: - - "remind 3 days before the 21st" → intent: CREATE, event_date_reference: "21st", offset_days: -3 - - "deadline May 15, remind a week early" → intent: CREATE, event_date_reference: "May 15", offset_days: -7 - - "meeting next Friday" → intent: CREATE, event_date_reference: "next Friday", offset_days: 0 - -2. UPDATE means modifying the LAST ACTIVE EVENT. Use offset_days relative to that event: - - "make it a week earlier" → intent: UPDATE, event_date_reference: null, offset_days: -7 - - "push it back 3 days" → intent: UPDATE, event_date_reference: null, offset_days: 3 - - "change date to March 25" → intent: UPDATE, event_date_reference: "March 25", offset_days: 0 - - IMPORTANT: If user says something like "make the month march but a week before" and the event is ALREADY in March, set event_date_reference to null, offset_days: -7. Do NOT set event_date_reference to just a month name like "March" — that would resolve to the 1st of the month. - - Only set event_date_reference to a SPECIFIC DATE (e.g., "March 25", "next Friday"), never just a bare month name. - -3. RESCHEDULE means finding an OLD event BY NAME and changing it: - - "reschedule my [event name] to Friday" → intent: RESCHEDULE, search_term: "[event name]" - - Only use RESCHEDULE when the user mentions a specific event name that is NOT the last active event. - -4. Do NOT invent event names. search_term must come from the user's actual words. - -Return JSON: -{ - "intent": "CREATE" | "UPDATE" | "LIST" | "DELETE" | "SEARCH" | "RESCHEDULE", - "title": string, - "event_date_reference": string or null, - "offset_days": number (default 0), - "time": string (HH:MM, default 09:00), - "description": string, - "category": string, - "recurrence": string, - "search_term": string, - "list_range": "day" | "week" | "month" | "all" +async function getTelegramFileUrl(fileId: string) { + const res = await fetch(`https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getFile?file_id=${fileId}`); + const { result } = await res.json(); + return `https://api.telegram.org/file/bot${TELEGRAM_BOT_TOKEN}/${result.file_path}`; } -`; +async function runAgent(userId: string, userMessage: string, history: any[] = []): Promise { + const todayStr = new Date().toLocaleDateString('en-CA'); + const messages: any[] = [ + { role: "system", content: buildSystemPrompt(todayStr) }, + ...history, + { role: "user", content: userMessage }, + ]; + + let maxSteps = 5; + let finalResponse = ""; + + while (maxSteps--) { let completion; - if (base64Image) { - const userContent: any[] = []; - if (textToParse) userContent.push({ type: "text", text: `Additional context from user: "${textToParse}". Extract event details from this image.` }); - else userContent.push({ type: "text", text: "Extract event details from this image." }); - userContent.push({ type: "image_url", image_url: { url: `data:image/jpeg;base64,${base64Image}` } }); - + try { completion = await groq.chat.completions.create({ - messages: [{ role: "system", content: systemPrompt }, { role: "user", content: userContent }], - model: "meta-llama/llama-4-scout-17b-16e-instruct", - response_format: { type: "json_object" }, + messages, + model: "llama-3.3-70b-versatile", + tools: toolDefinitions, + tool_choice: "auto", + temperature: 0.2, }); - } else { - completion = await groq.chat.completions.create({ - messages: [{ role: "system", content: systemPrompt }, { role: "user", content: textToParse }], - model: "llama-3.1-8b-instant", - response_format: { type: "json_object" }, + } catch (apiErr: any) { + console.error("Groq API error, retrying without tools:", apiErr.message); + const fallback = await groq.chat.completions.create({ + messages, + model: "llama-3.3-70b-versatile", + temperature: 0.2, }); + finalResponse = fallback.choices[0]?.message?.content || "Sorry, I could not process that request."; + break; } - const parsed = JSON.parse(completion.choices[0]?.message?.content || "{}"); - console.log("LLM Output:", JSON.stringify(parsed)); + const choice = completion.choices[0]; - // ── Route by Intent ── - if (parsed.intent === "LIST") return await handleList(chatId, parsed, profile, todayDateObj); - if (parsed.intent === "SEARCH") return await handleSearch(chatId, parsed, profile); - if (parsed.intent === "DELETE") return await handleDelete(chatId, parsed, profile, todayDateObj); - - // ── Deterministic Date Arithmetic ── - const offset = parseInt(parsed.offset_days) || 0; - let referenceDate = todayDateObj; - if ((parsed.intent === "UPDATE" || parsed.intent === "RESCHEDULE") && contextEvent) { - referenceDate = new Date(contextEvent.start_date); + if (!choice.message.tool_calls || choice.message.tool_calls.length === 0) { + finalResponse = choice.message.content || ""; + break; } - // Detect bare month names that would resolve to the 1st of the month (e.g., "March", "April") - const bareMonthNames = ["january","february","march","april","may","june","july","august","september","october","november","december"]; - let dateRef = parsed.event_date_reference; - if (dateRef && typeof dateRef === "string") { - const trimmed = dateRef.trim().toLowerCase(); - if (bareMonthNames.includes(trimmed) && (parsed.intent === "UPDATE" || parsed.intent === "RESCHEDULE")) { - // Bare month name during UPDATE → ignore it, use offset from current event - dateRef = null; - } - } + messages.push(choice.message); - if (dateRef && dateRef !== "null" && dateRef.trim() !== "") { - const anchorDate = chrono.parseDate(dateRef, referenceDate, { forwardDate: true }); - if (anchorDate) { - const finalDate = new Date(anchorDate); - finalDate.setDate(finalDate.getDate() + offset); - parsed.date = finalDate.toISOString().split('T')[0]; - } - } else if (offset !== 0 && contextEvent) { - // No explicit date given, but offset provided → apply offset to the CURRENT event's date - const currentDate = new Date(contextEvent.start_date); - currentDate.setDate(currentDate.getDate() + offset); - parsed.date = currentDate.toISOString().split('T')[0]; - } - // Fallback for legacy field - if (!parsed.date && parsed.date_reference) { - const res = chrono.parseDate(parsed.date_reference, referenceDate, { forwardDate: true }); - if (res) parsed.date = res.toISOString().split('T')[0]; - } + for (const toolCall of choice.message.tool_calls) { + const fnName = toolCall.function.name; + const fnArgs = JSON.parse(toolCall.function.arguments); + + console.log("Tool:", fnName, JSON.stringify(fnArgs)); - // ── Fix 3: RESCHEDULE (search by name, then update) ── - if (parsed.intent === "RESCHEDULE") { - const term = parsed.search_term || parsed.title; - if (!term) return await sendTelegramMessage(chatId, "šŸ¤” Which event should I reschedule? Give me the name."); - const { data: matches } = await supabase.from('events').select('*').eq('user_id', profile.id).ilike('title', `%${term}%`).limit(5); - if (!matches || matches.length === 0) return await sendTelegramMessage(chatId, `āš ļø No event matching "${term}" found.`); - if (matches.length > 1) { - const btns = matches.map(m => [{ text: `šŸ“ ${m.title} (${m.start_date.split('T')[0]})`, callback_data: `resch_${m.id}_${parsed.date || ''}_${parsed.time || ''}` }]); - return await sendTelegramMessage(chatId, "Multiple matches. Which one to reschedule?", { inline_keyboard: btns }); + let result: any; + try { + result = await executeTool(userId, fnName, fnArgs); + } catch (e: any) { + console.error("Tool error:", fnName, e.message); + result = { error: "Tool execution failed: " + e.message }; } - const target = matches[0]; - const updateData: any = {}; - if (parsed.title && parsed.title.toLowerCase() !== term.toLowerCase()) updateData.title = parsed.title; - const resDate = parsed.date || target.start_date.split('T')[0]; - const resTime = parsed.time || target.start_date.split('T')[1].substring(0, 5); - updateData.start_date = new Date(`${resDate}T${resTime}:00+05:30`).toISOString(); - await supabase.from('events').update(updateData).eq('id', target.id); - await supabase.from('profiles').update({ last_event_id: target.id }).eq('id', profile.id); - return await sendTelegramMessage(chatId, `āœ… *Rescheduled:* ${target.title}\nšŸ“… ${resDate} @ ${resTime}`); - } - // ── UPDATE (last event or replied-to event) ── - if (parsed.intent === "UPDATE" && contextEventId && contextEvent) { - const updateData: any = {}; - if (parsed.title) updateData.title = parsed.title; - const resDate = parsed.date || contextEvent.start_date.split('T')[0]; - const resTime = parsed.time || contextEvent.start_date.split('T')[1].substring(0, 5); - updateData.start_date = new Date(`${resDate}T${resTime}:00+05:30`).toISOString(); - await supabase.from('events').update(updateData).eq('id', contextEventId); - return await sendTelegramMessage(chatId, `āœ… *Updated:* ${updateData.title || contextEvent.title}\nšŸ“… ${resDate} @ ${resTime}`); - } + console.log("Result:", JSON.stringify(result)); - // ── CREATE ── - if (!parsed.title || !parsed.date) return await sendTelegramMessage(chatId, "šŸ¤” I couldn't find clear event details. Could you be more specific?"); - const { data, error } = await supabase.from('events').insert([{ - user_id: profile.id, - title: parsed.title, - description: parsed.description || "", - start_date: new Date(`${parsed.date}T${parsed.time || '09:00'}:00+05:30`).toISOString(), - category: parsed.category || "", - recurrence: parsed.recurrence || "none" - }]).select().single(); - if (!error) { - await supabase.from('profiles').update({ last_event_id: data.id }).eq('id', profile.id); - await sendTelegramMessage(chatId, `šŸ“… *Saved:* ${parsed.title}\nšŸ“… ${parsed.date} @ ${parsed.time || '09:00'}`, { - inline_keyboard: [[{ text: "šŸ—‘ļø Delete", callback_data: `del_${data.id}` }]] + messages.push({ + role: "tool", + tool_call_id: toolCall.id, + name: fnName, + content: JSON.stringify(result), }); } - } catch (err: any) { - console.error(err); - await sendTelegramMessage(chatId, "āš ļø Sorry, something went wrong. (" + err.message + ")"); } -} -// ── LIST ── -async function handleList(chatId: number, parsed: any, profile: any, today: Date) { - let query = supabase.from('events').select('*').eq('user_id', profile.id).order('start_date', { ascending: true }); - if (parsed.event_date_reference || parsed.date_reference) { - const ref = parsed.event_date_reference || parsed.date_reference; - const resDate = chrono.parseDate(ref, today); - if (resDate) { - const start = new Date(resDate); start.setHours(0,0,0,0); - const end = new Date(resDate); end.setHours(23,59,59,999); - if (parsed.list_range === "month") { start.setDate(1); end.setMonth(end.getMonth() + 1); end.setDate(0); } - query = query.gte('start_date', start.toISOString()).lte('start_date', end.toISOString()); - } - } else { query = query.gte('start_date', new Date().toISOString()); } - const { data: events } = await query.limit(15); - if (!events || events.length === 0) return await sendTelegramMessage(chatId, "šŸ“­ No events found."); - const list = events.map(e => `• *${e.title}*\n šŸ“… ${e.start_date.split('T')[0]} @ ${e.start_date.split('T')[1].substring(0,5)}`).join("\n\n"); - await sendTelegramMessage(chatId, `šŸ—“ļø *Schedule:*\n\n${list}`); -} + if (!finalResponse && messages.length > 2) { + const summary = await groq.chat.completions.create({ + messages: [...messages, { role: "user", content: "Summarize what you just did in one concise sentence." }], + model: "llama-3.3-70b-versatile", + temperature: 0.2, + }); + finalResponse = summary.choices[0]?.message?.content || "Done."; + } -// ── SEARCH ── -async function handleSearch(chatId: number, parsed: any, profile: any) { - const term = parsed.search_term || parsed.title; - const { data: results } = await supabase.from('events').select('*').eq('user_id', profile.id).ilike('title', `%${term}%`).limit(5); - if (!results || results.length === 0) return await sendTelegramMessage(chatId, "šŸ” No matching events found."); - const list = results.map(e => `• *${e.title}*\n šŸ“… ${e.start_date.split('T')[0]} @ ${e.start_date.split('T')[1].substring(0,5)}`).join("\n\n"); - await sendTelegramMessage(chatId, `šŸ” *Found:*\n\n${list}`); + return finalResponse; } -// ── DELETE ── -async function handleDelete(chatId: number, parsed: any, profile: any, today: Date) { - let query = supabase.from('events').select('*').eq('user_id', profile.id); - if (parsed.event_date_reference || parsed.date_reference) { - const ref = parsed.event_date_reference || parsed.date_reference; - const resDate = chrono.parseDate(ref, today); - if (resDate) { - const start = new Date(resDate); start.setHours(0,0,0,0); - const end = new Date(resDate); end.setHours(23,59,59,999); - query = query.gte('start_date', start.toISOString()).lte('start_date', end.toISOString()); +async function handleMessage(message: any) { + const chatId = message.chat.id; + const { data: profile } = await supabase.from('profiles').select('id, username, conversation_history').eq('telegram_chat_id', chatId).single(); + + if (!profile) { + const text = message.text; + if (text?.startsWith('/link') || text?.startsWith('/start')) { + const parts = text.split(' '); + const code = parts.length > 1 ? parts[1] : null; + if (code && !code.includes('@')) { + const { data: userData } = await supabase.from('profiles').update({ telegram_chat_id: chatId, link_code: null }).eq('link_code', code.trim().toUpperCase()).select('username').single(); + if (userData) return await sendTelegramMessage(chatId, `Linked! Welcome @${userData.username}.\n\nYou can now send me events like:\n- "Lunch with Sarah tomorrow at 1pm"\n- Send a voice note\n- Send a photo of a flyer`); + } + return await sendTelegramMessage(chatId, "Welcome! Send /link YOUR-CODE from the web app."); } + return await sendTelegramMessage(chatId, "Not linked. Send /link YOUR-CODE."); } - if (parsed.search_term || parsed.title) query = query.ilike('title', `%${parsed.search_term || parsed.title}%`); - const { data: matches } = await query.limit(5); - if (!matches || matches.length === 0) return await sendTelegramMessage(chatId, "āš ļø Couldn't find that event to delete."); - if (matches.length > 1) { - const btns = matches.map(m => [{ text: `šŸ—‘ļø ${m.title} (${m.start_date.split('T')[0]})`, callback_data: `del_${m.id}` }]); - return await sendTelegramMessage(chatId, "Multiple matches. Which to delete?", { inline_keyboard: btns }); - } - await supabase.from('events').delete().eq('id', matches[0].id); - await sendTelegramMessage(chatId, `šŸ—‘ļø Deleted: *${matches[0].title}*`); -} -// ── Helpers ── -async function getTelegramFileUrl(fileId: string) { - const res = await fetch(`https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getFile?file_id=${fileId}`); - const { result } = await res.json(); - return `https://api.telegram.org/file/bot${TELEGRAM_BOT_TOKEN}/${result.file_path}`; -} + let userMessage = ""; -async function handleCallbackQuery(callbackQuery: any) { - const chatId = callbackQuery.message.chat.id; - const data = callbackQuery.data; - if (data.startsWith('del_')) { - const eventId = data.split('_')[1]; - await supabase.from('events').delete().eq('id', eventId); - await editTelegramMessage(chatId, callbackQuery.message.message_id, "šŸ—‘ļø Event deleted."); - } - if (data.startsWith('resch_')) { - const parts = data.split('_'); - const eventId = parts[1]; - const newDate = parts[2] || null; - const newTime = parts[3] || null; - if (newDate || newTime) { - const { data: target } = await supabase.from('events').select('*').eq('id', eventId).single(); - if (target) { - const resDate = newDate || target.start_date.split('T')[0]; - const resTime = newTime || target.start_date.split('T')[1].substring(0, 5); - await supabase.from('events').update({ start_date: new Date(`${resDate}T${resTime}:00+05:30`).toISOString() }).eq('id', eventId); - await editTelegramMessage(chatId, callbackQuery.message.message_id, `āœ… Rescheduled: *${target.title}* → ${resDate} @ ${resTime}`); - } - } + if (message.text) { + userMessage = message.text; + } else if (message.voice) { + const fileUrl = await getTelegramFileUrl(message.voice.file_id); + const response = await fetch(fileUrl); + const buffer = await response.arrayBuffer(); + const file = new File([buffer], "voice.ogg", { type: "audio/ogg" }); + const transcription = await groq.audio.transcriptions.create({ file, model: "whisper-large-v3-turbo" }); + userMessage = transcription.text; + } else if (message.photo) { + const fileId = message.photo[message.photo.length - 1].file_id; + const fileUrl = await getTelegramFileUrl(fileId); + const response = await fetch(fileUrl); + const buffer = await response.arrayBuffer(); + const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer))); + + const visionCompletion = await groq.chat.completions.create({ + messages: [ + { role: "user", content: [ + { type: "text", text: `Extract any event details from this image. ${message.caption || ""}` }, + { type: "image_url", image_url: { url: `data:image/jpeg;base64,${base64}` } }, + ]}, + ], + model: "meta-llama/llama-4-scout-17b-16e-instruct", + }); + userMessage = visionCompletion.choices[0]?.message?.content || message.caption || ""; } -} -async function sendTelegramMessage(chatId: number, text: string, replyMarkup?: any) { - await fetch(`https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ chat_id: chatId, text, parse_mode: 'Markdown', reply_markup: replyMarkup }), - }); -} + if (!userMessage.trim()) return; -async function editTelegramMessage(chatId: number, messageId: number, text: string) { - await fetch(`https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/editMessageText`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ chat_id: chatId, message_id: messageId, text, parse_mode: 'Markdown' }), - }); + let history: any[] = []; + try { + history = JSON.parse(profile.conversation_history || "[]"); + } catch { history = []; } + + try { + const agentResponse = await runAgent(profile.id, userMessage, history); + await sendTelegramMessage(chatId, agentResponse || "Done."); + + history.push({ role: "user", content: userMessage }); + history.push({ role: "assistant", content: agentResponse }); + if (history.length > 6) history = history.slice(-6); + + await supabase.from('profiles').update({ conversation_history: JSON.stringify(history) }).eq('id', profile.id); + } catch (err: any) { + console.error("Agent error:", err); + await sendTelegramMessage(chatId, "Sorry, something went wrong: " + err.message); + } } + +serve(async (req: any) => { + if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders }); + try { + const update = await req.json(); + if (update.message) await handleMessage(update.message); + return new Response(JSON.stringify({ ok: true }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 200 }); + } catch (error: any) { + console.error("Update Error:", error); + return new Response(JSON.stringify({ error: error.message }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 200 }); + } +}); diff --git a/supabase/migrations/20260323165200_add_last_bot_response.sql b/supabase/migrations/20260323165200_add_last_bot_response.sql new file mode 100644 index 0000000..3f0d98e --- /dev/null +++ b/supabase/migrations/20260323165200_add_last_bot_response.sql @@ -0,0 +1 @@ +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS last_bot_response TEXT DEFAULT ''; diff --git a/supabase/migrations/20260323173000_add_conversation_history.sql b/supabase/migrations/20260323173000_add_conversation_history.sql new file mode 100644 index 0000000..c2ccba0 --- /dev/null +++ b/supabase/migrations/20260323173000_add_conversation_history.sql @@ -0,0 +1 @@ +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS conversation_history TEXT DEFAULT '[]'; From 1651bb6915cad8676da49405858691a37d8183dc Mon Sep 17 00:00:00 2001 From: SupremeEvilGod Date: Sat, 28 Mar 2026 00:41:15 +0530 Subject: [PATCH 5/7] updated readme --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 560bb03..4d0024a 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ A modern, responsive, and dynamic Event Scheduling application built with React, ## Features +- **Conversational AI Agent:** Interact with an intelligent chatbot interface (`ChatPanel`) that manages context, processes intents, and handles multi-turn scheduling entirely with natural language. - **AI Event Parsing:** Create structured events from raw text, voice, or images using LLMs. - **Voice-to-Event:** Record your voice and let the AI transcribe and schedule the event automatically. - **Image/OCR Scheduling:** Take a picture of a physical note or schedule and extract event details instantly. @@ -60,6 +61,8 @@ erDiagram bigint telegram_chat_id "Linked Telegram Chat ID" text link_code "Unique linking code for Telegram" uuid last_event_id FK "Reference to the last interacted event" + text last_bot_response "Text of the most recent agent response" + text conversation_history "JSON structure storing sequential user-agent chat history" } events { @@ -82,6 +85,7 @@ erDiagram The application leverages Supabase Edge Functions and `pg_cron` for background tasks: +- **agent:** A state-aware conversational routing function that interprets advanced temporal logic, handles complex intents, and drives the chatbot UI. - **analyze-event:** An Edge Function that utilizes Groq Llama and Whisper models to parse events from text and audio inputs. - **send-notifications:** An Edge Function that scans for upcoming events and sends Telegram messages to users with linked accounts. - **pg_cron:** Managed via the `process-notifications-every-minute` job, which triggers the notification engine every minute. From 130f8e73cea89430b57a12f3aeb7f349747c2726 Mon Sep 17 00:00:00 2001 From: SupremeEvilGod Date: Sat, 28 Mar 2026 00:58:47 +0530 Subject: [PATCH 6/7] added fuzzy search for better seach in the database, fixed the bug which was causing the notification to be blocked --- supabase/config.toml | 2 +- supabase/functions/agent/index.ts | 63 +++++++++++++++++------- supabase/functions/telegram-bot/index.ts | 63 +++++++++++++++++------- 3 files changed, 91 insertions(+), 37 deletions(-) diff --git a/supabase/config.toml b/supabase/config.toml index 8d587a4..cccbc73 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -4,7 +4,7 @@ project_id = "pdcimvqzzpprkwuqbxnr" verify_jwt = false [functions.send-notifications] enabled = true -verify_jwt = true +verify_jwt = false import_map = "./functions/send-notifications/deno.json" # Uncomment to specify a custom file path to the entrypoint. # Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx diff --git a/supabase/functions/agent/index.ts b/supabase/functions/agent/index.ts index 9cae64c..30f14b7 100644 --- a/supabase/functions/agent/index.ts +++ b/supabase/functions/agent/index.ts @@ -154,31 +154,57 @@ async function toolCreateEvent(userId: string, args: any) { } async function toolGetEvents(userId: string, args: any) { - let query = supabase.from('events').select('*').eq('user_id', userId) - .order('start_date', { ascending: true }); - - if (args.start_date && args.end_date) { - const start = new Date(`${args.start_date}T00:00:00+05:30`).toISOString(); - const end = new Date(`${args.end_date}T23:59:59+05:30`).toISOString(); - query = query.gte('start_date', start).lte('start_date', end); - } - - if (args.query) { - query = query.or(`title.ilike.%${args.query}%,category.ilike.%${args.query}%,description.ilike.%${args.query}%`); - } + const buildQuery = (searchTerm?: string) => { + let q = supabase.from('events').select('*').eq('user_id', userId) + .order('start_date', { ascending: true }); + + if (args.start_date && args.end_date) { + const start = new Date(`${args.start_date}T00:00:00+05:30`).toISOString(); + const end = new Date(`${args.end_date}T23:59:59+05:30`).toISOString(); + q = q.gte('start_date', start).lte('start_date', end); + } - const { data, error } = await query.limit(20); - if (error) return { error: error.message }; - if (!data || data.length === 0) return { count: 0, events: [], message: "No matching events found." }; + if (searchTerm) { + q = q.or(`title.ilike.%${searchTerm}%,category.ilike.%${searchTerm}%,description.ilike.%${searchTerm}%`); + } + return q; + }; - return { + const formatEvents = (data: any[]) => ({ count: data.length, events: data.map((e: any) => ({ id: e.id, title: e.title, date: e.start_date.split('T')[0], time: e.start_date.split('T')[1].substring(0, 5), })), - }; + }); + + // Primary search + const { data, error } = await buildQuery(args.query).limit(20); + if (error) return { error: error.message }; + if (data && data.length > 0) return formatEvents(data); + + // Fuzzy fallback: if query has multiple words, search each word individually + if (args.query) { + const words = args.query.trim().split(/\s+/).filter((w: string) => w.length >= 2); + if (words.length > 1) { + const seen = new Set(); + const allMatches: any[] = []; + for (const word of words) { + const { data: wordData } = await buildQuery(word).limit(20); + if (wordData) { + for (const ev of wordData) { + if (!seen.has(ev.id)) { seen.add(ev.id); allMatches.push(ev); } + } + } + } + if (allMatches.length > 0) { + return { ...formatEvents(allMatches), note: "Fuzzy match — exact query had no results, matched on individual words." }; + } + } + } + + return { count: 0, events: [], message: "No matching events found." }; } async function toolUpdateEvent(userId: string, args: any) { @@ -301,7 +327,8 @@ BEHAVIOR: - Check conflicts before creating events. TOOL USAGE: -- get_events: pass query for keyword search (e.g. query:"birthday"), date range for time filtering, or both. Backend filters server-side. +- get_events: pass query for keyword search (e.g. query:"birthday"), date range for time filtering, or both. Backend filters server-side and does fuzzy word-by-word fallback automatically. +- If a name/keyword search returns 0 results, try alternate spellings or just the last name or a broader term. For example, if "Minvith Das" returns nothing, try "Das" or "birthday". - When user mentions multiple topics (e.g. "hiring challenges and meetups"), call get_events ONCE with a broad date range and no query, then filter the results yourself. Or call get_events multiple times with different queries. - All dates must use year ${todayStr.split('-')[0]}. diff --git a/supabase/functions/telegram-bot/index.ts b/supabase/functions/telegram-bot/index.ts index 2de3743..58a8f71 100644 --- a/supabase/functions/telegram-bot/index.ts +++ b/supabase/functions/telegram-bot/index.ts @@ -153,31 +153,57 @@ async function toolCreateEvent(userId: string, args: any) { } async function toolGetEvents(userId: string, args: any) { - let query = supabase.from('events').select('*').eq('user_id', userId) - .order('start_date', { ascending: true }); - - if (args.start_date && args.end_date) { - const start = new Date(`${args.start_date}T00:00:00+05:30`).toISOString(); - const end = new Date(`${args.end_date}T23:59:59+05:30`).toISOString(); - query = query.gte('start_date', start).lte('start_date', end); - } - - if (args.query) { - query = query.or(`title.ilike.%${args.query}%,category.ilike.%${args.query}%,description.ilike.%${args.query}%`); - } + const buildQuery = (searchTerm?: string) => { + let q = supabase.from('events').select('*').eq('user_id', userId) + .order('start_date', { ascending: true }); + + if (args.start_date && args.end_date) { + const start = new Date(`${args.start_date}T00:00:00+05:30`).toISOString(); + const end = new Date(`${args.end_date}T23:59:59+05:30`).toISOString(); + q = q.gte('start_date', start).lte('start_date', end); + } - const { data, error } = await query.limit(20); - if (error) return { error: error.message }; - if (!data || data.length === 0) return { count: 0, events: [], message: "No matching events found." }; + if (searchTerm) { + q = q.or(`title.ilike.%${searchTerm}%,category.ilike.%${searchTerm}%,description.ilike.%${searchTerm}%`); + } + return q; + }; - return { + const formatEvents = (data: any[]) => ({ count: data.length, events: data.map((e: any) => ({ id: e.id, title: e.title, date: e.start_date.split('T')[0], time: e.start_date.split('T')[1].substring(0, 5), })), - }; + }); + + // Primary search + const { data, error } = await buildQuery(args.query).limit(20); + if (error) return { error: error.message }; + if (data && data.length > 0) return formatEvents(data); + + // Fuzzy fallback: if query has multiple words, search each word individually + if (args.query) { + const words = args.query.trim().split(/\s+/).filter((w: string) => w.length >= 2); + if (words.length > 1) { + const seen = new Set(); + const allMatches: any[] = []; + for (const word of words) { + const { data: wordData } = await buildQuery(word).limit(20); + if (wordData) { + for (const ev of wordData) { + if (!seen.has(ev.id)) { seen.add(ev.id); allMatches.push(ev); } + } + } + } + if (allMatches.length > 0) { + return { ...formatEvents(allMatches), note: "Fuzzy match — exact query had no results, matched on individual words." }; + } + } + } + + return { count: 0, events: [], message: "No matching events found." }; } async function toolUpdateEvent(userId: string, args: any) { @@ -299,7 +325,8 @@ BEHAVIOR: - Check conflicts before creating events. TOOL USAGE: -- get_events: pass query for keyword search (e.g. query:"birthday"), date range for time filtering, or both. Backend filters server-side. +- get_events: pass query for keyword search (e.g. query:"birthday"), date range for time filtering, or both. Backend filters server-side and does fuzzy word-by-word fallback automatically. +- If a name/keyword search returns 0 results, try alternate spellings or just the last name or a broader term. For example, if "Minvith Das" returns nothing, try "Das" or "birthday". - When user mentions multiple topics (e.g. "hiring challenges and meetups"), call get_events ONCE with a broad date range and no query, then filter the results yourself. Or call get_events multiple times with different queries. - All dates must use year ${todayStr.split('-')[0]}. From 48584c8d1ce0979ae84e25a629a1856d36826b8a Mon Sep 17 00:00:00 2001 From: SupremeEvilGod Date: Sat, 28 Mar 2026 01:11:50 +0530 Subject: [PATCH 7/7] changed part of supabase which was returning zero while filtering --- supabase/functions/agent/index.ts | 74 +++++++++++------------- supabase/functions/telegram-bot/index.ts | 74 +++++++++++------------- 2 files changed, 70 insertions(+), 78 deletions(-) diff --git a/supabase/functions/agent/index.ts b/supabase/functions/agent/index.ts index 30f14b7..01573a8 100644 --- a/supabase/functions/agent/index.ts +++ b/supabase/functions/agent/index.ts @@ -154,53 +154,49 @@ async function toolCreateEvent(userId: string, args: any) { } async function toolGetEvents(userId: string, args: any) { - const buildQuery = (searchTerm?: string) => { - let q = supabase.from('events').select('*').eq('user_id', userId) - .order('start_date', { ascending: true }); - - if (args.start_date && args.end_date) { - const start = new Date(`${args.start_date}T00:00:00+05:30`).toISOString(); - const end = new Date(`${args.end_date}T23:59:59+05:30`).toISOString(); - q = q.gte('start_date', start).lte('start_date', end); - } + let query = supabase.from('events').select('*').eq('user_id', userId) + .order('start_date', { ascending: true }); - if (searchTerm) { - q = q.or(`title.ilike.%${searchTerm}%,category.ilike.%${searchTerm}%,description.ilike.%${searchTerm}%`); - } - return q; - }; + if (args.start_date && args.end_date) { + const start = new Date(`${args.start_date}T00:00:00+05:30`).toISOString(); + const end = new Date(`${args.end_date}T23:59:59+05:30`).toISOString(); + query = query.gte('start_date', start).lte('start_date', end); + } + + const { data, error } = await query.limit(100); + if (error) return { error: error.message }; + if (!data || data.length === 0) return { count: 0, events: [], message: "No matching events found." }; - const formatEvents = (data: any[]) => ({ - count: data.length, - events: data.map((e: any) => ({ + const formatEvents = (events: any[]) => ({ + count: events.length, + events: events.map((e: any) => ({ id: e.id, title: e.title, date: e.start_date.split('T')[0], time: e.start_date.split('T')[1].substring(0, 5), })), }); - // Primary search - const { data, error } = await buildQuery(args.query).limit(20); - if (error) return { error: error.message }; - if (data && data.length > 0) return formatEvents(data); - - // Fuzzy fallback: if query has multiple words, search each word individually - if (args.query) { - const words = args.query.trim().split(/\s+/).filter((w: string) => w.length >= 2); - if (words.length > 1) { - const seen = new Set(); - const allMatches: any[] = []; - for (const word of words) { - const { data: wordData } = await buildQuery(word).limit(20); - if (wordData) { - for (const ev of wordData) { - if (!seen.has(ev.id)) { seen.add(ev.id); allMatches.push(ev); } - } - } - } - if (allMatches.length > 0) { - return { ...formatEvents(allMatches), note: "Fuzzy match — exact query had no results, matched on individual words." }; - } + // If no keyword query, return all fetched events + if (!args.query) return formatEvents(data); + + const q = args.query.toLowerCase(); + const matchesText = (event: any, term: string) => { + const t = term.toLowerCase(); + return (event.title || '').toLowerCase().includes(t) || + (event.category || '').toLowerCase().includes(t) || + (event.description || '').toLowerCase().includes(t); + }; + + // Primary: exact phrase match + const exactMatches = data.filter((e: any) => matchesText(e, q)); + if (exactMatches.length > 0) return formatEvents(exactMatches); + + // Fallback: match any individual word (handles typos like "Minvith Das" → "Das" still matches) + const words = args.query.trim().split(/\s+/).filter((w: string) => w.length >= 2); + if (words.length > 1) { + const wordMatches = data.filter((e: any) => words.some((w: string) => matchesText(e, w))); + if (wordMatches.length > 0) { + return { ...formatEvents(wordMatches), note: "Fuzzy match — matched on individual words." }; } } diff --git a/supabase/functions/telegram-bot/index.ts b/supabase/functions/telegram-bot/index.ts index 58a8f71..d90dcfa 100644 --- a/supabase/functions/telegram-bot/index.ts +++ b/supabase/functions/telegram-bot/index.ts @@ -153,53 +153,49 @@ async function toolCreateEvent(userId: string, args: any) { } async function toolGetEvents(userId: string, args: any) { - const buildQuery = (searchTerm?: string) => { - let q = supabase.from('events').select('*').eq('user_id', userId) - .order('start_date', { ascending: true }); - - if (args.start_date && args.end_date) { - const start = new Date(`${args.start_date}T00:00:00+05:30`).toISOString(); - const end = new Date(`${args.end_date}T23:59:59+05:30`).toISOString(); - q = q.gte('start_date', start).lte('start_date', end); - } + let query = supabase.from('events').select('*').eq('user_id', userId) + .order('start_date', { ascending: true }); - if (searchTerm) { - q = q.or(`title.ilike.%${searchTerm}%,category.ilike.%${searchTerm}%,description.ilike.%${searchTerm}%`); - } - return q; - }; + if (args.start_date && args.end_date) { + const start = new Date(`${args.start_date}T00:00:00+05:30`).toISOString(); + const end = new Date(`${args.end_date}T23:59:59+05:30`).toISOString(); + query = query.gte('start_date', start).lte('start_date', end); + } + + const { data, error } = await query.limit(100); + if (error) return { error: error.message }; + if (!data || data.length === 0) return { count: 0, events: [], message: "No matching events found." }; - const formatEvents = (data: any[]) => ({ - count: data.length, - events: data.map((e: any) => ({ + const formatEvents = (events: any[]) => ({ + count: events.length, + events: events.map((e: any) => ({ id: e.id, title: e.title, date: e.start_date.split('T')[0], time: e.start_date.split('T')[1].substring(0, 5), })), }); - // Primary search - const { data, error } = await buildQuery(args.query).limit(20); - if (error) return { error: error.message }; - if (data && data.length > 0) return formatEvents(data); - - // Fuzzy fallback: if query has multiple words, search each word individually - if (args.query) { - const words = args.query.trim().split(/\s+/).filter((w: string) => w.length >= 2); - if (words.length > 1) { - const seen = new Set(); - const allMatches: any[] = []; - for (const word of words) { - const { data: wordData } = await buildQuery(word).limit(20); - if (wordData) { - for (const ev of wordData) { - if (!seen.has(ev.id)) { seen.add(ev.id); allMatches.push(ev); } - } - } - } - if (allMatches.length > 0) { - return { ...formatEvents(allMatches), note: "Fuzzy match — exact query had no results, matched on individual words." }; - } + // If no keyword query, return all fetched events + if (!args.query) return formatEvents(data); + + const q = args.query.toLowerCase(); + const matchesText = (event: any, term: string) => { + const t = term.toLowerCase(); + return (event.title || '').toLowerCase().includes(t) || + (event.category || '').toLowerCase().includes(t) || + (event.description || '').toLowerCase().includes(t); + }; + + // Primary: exact phrase match + const exactMatches = data.filter((e: any) => matchesText(e, q)); + if (exactMatches.length > 0) return formatEvents(exactMatches); + + // Fallback: match any individual word (handles typos like "Minvith Das" → "Das" still matches) + const words = args.query.trim().split(/\s+/).filter((w: string) => w.length >= 2); + if (words.length > 1) { + const wordMatches = data.filter((e: any) => words.some((w: string) => matchesText(e, w))); + if (wordMatches.length > 0) { + return { ...formatEvents(wordMatches), note: "Fuzzy match — matched on individual words." }; } }