diff --git a/README.md b/README.md index bd60d90..4d0024a 100644 --- a/README.md +++ b/README.md @@ -6,24 +6,35 @@ ![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 +- **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. - **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 @@ -50,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 { @@ -72,6 +85,8 @@ 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. @@ -109,16 +124,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/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx new file mode 100644 index 0000000..be38bd6 --- /dev/null +++ b/src/components/ChatPanel.tsx @@ -0,0 +1,283 @@ +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), + }, + }); + + if (error) throw new Error(error.message); + + setMessages(prev => prev.filter(m => m.role !== "status")); + + if (data.transcription && inputType === "voice") { + setMessages(prev => [...prev, { role: "status", content: `Heard: "${data.transcription}"` }]); + } + + const assistantMsg: Message = { + role: "assistant", + content: data.response || "I processed your request.", + toolCalls: data.tool_calls_made, + }; + setMessages(prev => [...prev, assistantMsg]); + + conversationHistory.current.push({ role: "user", content: userMessage }); + conversationHistory.current.push({ role: "assistant", content: data.response }); + + 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()); + + 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 ( + + +
+
+ +
+
+

Maantis Agent

+

AI Scheduling Assistant

+
+
+ + +
+ {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 && ( +
+
+ +
+
+ +
+
+ )} +
+
+ +
+
+ + + + {isRecording ? ( + + ) : ( + + )} + + setInput(e.target.value)} + placeholder="Ask me anything..." + disabled={isLoading || isRecording} + className="h-9 text-sm" + /> + + +
+
+
+
+ ); +}; + +export default ChatPanel; diff --git a/src/components/EventManager.tsx b/src/components/EventManager.tsx index da6abaf..4166efa 100644 --- a/src/components/EventManager.tsx +++ b/src/components/EventManager.tsx @@ -111,5 +111,6 @@ export const useEventManager = () => { 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)} 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 new file mode 100644 index 0000000..01573a8 --- /dev/null +++ b/supabase/functions/agent/index.ts @@ -0,0 +1,470 @@ +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 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 }, + }; +} + +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); + } + + 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 = (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), + })), + }); + + // 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." }; + } + } + + return { count: 0, events: [], message: "No matching events found." }; +} + +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, 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 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]}. + +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) => { + 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--) { + 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]; + + 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), + }); + } + } + + 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, + 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, + }); + } +}); diff --git a/supabase/functions/telegram-bot/index.ts b/supabase/functions/telegram-bot/index.ts index 917943e..d90dcfa 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,493 @@ 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_', ''); - } - } + 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 = (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), + })), + }); + + // 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." }; } } - return null; + + return { count: 0, events: [], message: "No matching events found." }; } -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 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]}. + +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)); - - // ── 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); + const choice = completion.choices[0]; - // ── 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); - // ── 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 }); + console.log("Tool:", fnName, JSON.stringify(fnArgs)); + + 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 '[]';