diff --git a/.github/workflows/chatbot-pr-check.yml b/.github/workflows/chatbot-pr-check.yml new file mode 100644 index 000000000..5aa489d5f --- /dev/null +++ b/.github/workflows/chatbot-pr-check.yml @@ -0,0 +1,117 @@ +name: Chatbot PR validation + +on: + push: + branches: + - fix/chatbot-reliability-security + paths: + - 'supabase/functions/studenthubhelp-chat/**' + - 'supabase/migrations/*chatbot*' + - 'ai-chatbot/**' + - 'widget.js' + - '.github/workflows/chatbot-pr-check.yml' + pull_request: + paths: + - 'supabase/functions/studenthubhelp-chat/**' + - 'supabase/migrations/*chatbot*' + - 'ai-chatbot/**' + - 'widget.js' + - '.github/workflows/chatbot-pr-check.yml' + +permissions: + contents: write + +jobs: + edge-function-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - name: Type-check Supabase Edge Function + run: deno check supabase/functions/studenthubhelp-chat/index.ts + + react-chatbot-check: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ai-chatbot + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: actions/setup-node@v6 + with: + node-version: 20 + cache: npm + cache-dependency-path: ai-chatbot/package-lock.json + - name: Install dependencies + run: npm install --no-audit + - name: Update only audited vulnerable packages + run: npm update vite body-parser proxy-addr qs source-map-js --no-audit + - name: Persist patched dependency lockfile + env: + TARGET_BRANCH: ${{ github.head_ref || github.ref_name }} + HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + run: | + if [ "$HEAD_REPOSITORY" = "$GITHUB_REPOSITORY" ] && [ -n "$TARGET_BRANCH" ]; then + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add package-lock.json + if ! git diff --cached --quiet; then + git commit -m "chore(chatbot): patch audited dependencies [skip ci]" + git push origin "HEAD:$TARGET_BRANCH" + fi + fi + - name: Audit dependency tree + run: npm audit + - name: Report dependency advisories + if: always() + run: | + npm audit --json > /tmp/studenthubhelp-npm-audit.json || true + node - <<'NODE' + const fs = require('fs'); + const report = JSON.parse(fs.readFileSync('/tmp/studenthubhelp-npm-audit.json', 'utf8')); + const vulnerabilities = report.vulnerabilities || {}; + const entries = Object.entries(vulnerabilities); + console.log('npm audit summary:', JSON.stringify(report.metadata?.vulnerabilities || {})); + for (const [name, item] of entries) { + console.log(JSON.stringify({ + name, + severity: item.severity, + range: item.range, + direct: item.isDirect, + fixAvailable: item.fixAvailable, + via: item.via + })); + } + NODE + - name: Validate regression scripts and production API references + run: | + node --check tests/chatbot-regression.mjs + node --check ../widget.js + node --check public/widget.js + if grep -R -nE 'https?://(localhost|127\.0\.0\.1):3000' src; then echo 'Local Express API reference found in client source'; exit 1; fi + - name: Type-check React chatbot component + run: npx tsc --noEmit --skipLibCheck --jsx react-jsx --target ES2020 --module ESNext --moduleResolution bundler --allowImportingTsExtensions --types vite/client src/components/ChatWidget.tsx + - name: Restore Vite source entry for validation + run: | + cat > index.html <<'EOF' + + + + + + StudentHubHelp AI Chatbot + + +
+ + + + EOF + - name: Build chatbot + run: | + npm run build + test ! -f dist/server.cjs diff --git a/admin/src/components/ChatbotAdminControls.tsx b/admin/src/components/ChatbotAdminControls.tsx index 7d0756a38..d6ca5e5f5 100644 --- a/admin/src/components/ChatbotAdminControls.tsx +++ b/admin/src/components/ChatbotAdminControls.tsx @@ -111,10 +111,11 @@ export const ChatbotAdminControls: React.FC = ({ const { data: userData } = await supabase.auth.getUser(); const updatedBy = userData.user?.email || 'admin'; const payload = { ...config, updated_by: updatedBy, updated_at: new Date().toISOString() }; - const { error } = await supabase.from('chatbot_admin_config').update(payload).eq('id', 1); + const { data, error } = await supabase.from('chatbot_admin_config').update(payload).eq('id', 1).select('id, updated_at').single(); if (error) throw error; - setConfig(payload); - setMessage('AI control settings saved to Supabase.'); + if (!data || Number(data.id) !== 1) throw new Error('Settings save verify nahi ho payi. Config row id=1 missing ya access denied ho sakta hai.'); + setConfig({ ...payload, updated_at: data.updated_at || payload.updated_at }); + setMessage('AI control settings saved and verified in Supabase.'); } catch (e: any) { setMessage(e?.message || 'Unable to save AI controls.'); } finally { @@ -241,7 +242,7 @@ export const ChatbotAdminControls: React.FC = ({
{[ ['enabled', 'AI assistant enabled', ShieldCheck], - ['ai_rerank_enabled', 'Gemini AI reranking', Brain], + ['ai_rerank_enabled', 'Gemini AI response enhancement', Brain], ['lead_capture_enabled', 'Lead capture', SlidersHorizontal], ['comparison_enabled', 'Live comparison mode', SlidersHorizontal], ['budget_planner_enabled', 'Live budget planner', SlidersHorizontal], diff --git a/admin/src/components/IntelligenceViews.tsx b/admin/src/components/IntelligenceViews.tsx index 8da9732c7..6972dadca 100644 --- a/admin/src/components/IntelligenceViews.tsx +++ b/admin/src/components/IntelligenceViews.tsx @@ -1272,7 +1272,7 @@ export const IntelligenceViews: React.FC = ({

- + ); diff --git a/ai-chatbot/.env.production b/ai-chatbot/.env.production new file mode 100644 index 000000000..f0f4bcbfb --- /dev/null +++ b/ai-chatbot/.env.production @@ -0,0 +1 @@ +VITE_CHAT_API_URL=https://idurlccrarznnnqixxsd.supabase.co/functions/v1/studenthubhelp-chat diff --git a/ai-chatbot/README.md b/ai-chatbot/README.md index e8f317d45..fd8df8ecb 100644 --- a/ai-chatbot/README.md +++ b/ai-chatbot/README.md @@ -1,39 +1,36 @@ # StudentHubHelp AI Chatbot -Production-oriented StudentHubHelp AI chatbot based on Gemini 3.8 Flash and live Supabase data. +StudentHubHelp's public chatbot is served by the Supabase Edge Function and reads active listings from the category-specific Supabase tables. -## Data rule +## Production request path -Property recommendations are loaded from the StudentHubHelp public property tables: +- Public website embed: `widget.js` -> `/functions/v1/studenthubhelp-chat` +- React chatbot: `ai-chatbot/src/components/ChatWidget.tsx` -> the same Edge Function +- Edge Function: authoritative runtime config, rate limits, deterministic listing search, and optional Gemini response enhancement +- Supabase: `hostels`, `tiffins`, `libraries`, `cafes`, and `bookstores` -- `hostels` -- `tiffins` -- `libraries` -- `cafes` -- `bookstores` +Both browser clients use the same production endpoint. Do not point either client at the legacy Express `/api/chat` route. -Only rows whose `status` is `active` are exposed to the chatbot. No property records, prices, ratings, contacts or recommendations are hardcoded as demo data. +## Database and security + +- Only rows with `status = 'active'` are eligible for public recommendations. +- The Edge Function uses the private `chatbot_rate_limits` table through the `consume_chatbot_rate_limit` security-definer RPC. Apply the checked-in migration before deploying the Edge Function version that calls it. +- Keep the Supabase service-role key server-side only. Browser clients use the public/publishable key. +- The chatbot fails closed when its admin config is missing/unavailable. If Cloudflare's client-IP header is absent, it falls back to lower-threshold session/user-agent and explicitly untrusted proxy keys; this fallback is weaker than a verified edge IP and must be checked in staging. ## Environment -Set: +For the Edge Function, configure the Supabase URL/key and Gemini key as Supabase Function secrets. The React build accepts `VITE_CHAT_API_URL` and `VITE_SUPABASE_ANON_KEY`; the default chat URL is the production Edge Function. -- `GEMINI_API_KEY` -- `SUPABASE_URL` -- `SUPABASE_ANON_KEY` -- `APP_URL` (optional) +## Legacy Express preview -The Supabase key is used server-side for public active-listing reads. Never expose a service-role key in browser code. +`server.ts` and its `/api/*` routes are a legacy local preview harness, not the production source of truth. Its config and lead arrays are in-memory and reset when the process restarts. Do not deploy those endpoints as a public production API or use them for authoritative chatbot settings, leads, or property data. -## Local run +Run the React UI locally with `npm run dev`. To intentionally start the isolated legacy Express preview, use `npm run dev:legacy-server`. The production build is static Vite output only; it does not bundle `server.ts` or the legacy `/api/*` routes. ```bash npm install npm run dev +# Optional legacy preview only: +npm run dev:legacy-server ``` - -## Production architecture - -GitHub Pages / public website -> embedded `public/widget.js` -> deployed Node/Express API -> Gemini + Supabase. - -The current project is prepared as the chatbot application. Public-site embedding and the admin AI section should be integrated after the live backend deployment endpoint is chosen. diff --git a/ai-chatbot/package-lock.json b/ai-chatbot/package-lock.json index b00258fa0..63e7a55b3 100644 --- a/ai-chatbot/package-lock.json +++ b/ai-chatbot/package-lock.json @@ -17,7 +17,7 @@ "motion": "^12.23.24", "react": "^19.0.1", "react-dom": "^19.0.1", - "vite": "^6.2.3" + "vite": "^6.4.4" }, "devDependencies": { "@types/express": "^4.17.21", @@ -27,7 +27,7 @@ "tailwindcss": "^4.1.14", "tsx": "^4.21.0", "typescript": "~5.8.2", - "vite": "^6.2.3" + "vite": "^6.4.4" } }, "node_modules/@babel/code-frame": { @@ -1736,9 +1736,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.8", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", - "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "version": "1.20.9", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz", + "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -1774,22 +1774,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/body-parser/node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", - "license": "BSD-3-Clause", - "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/browserslist": { "version": "4.28.9", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", @@ -3253,9 +3237,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -3263,12 +3247,16 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.17.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.1.tgz", + "integrity": "sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -3563,9 +3551,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -4198,9 +4186,9 @@ } }, "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "version": "6.4.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz", + "integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==", "license": "MIT", "dependencies": { "esbuild": "^0.25.0", diff --git a/ai-chatbot/package.json b/ai-chatbot/package.json index 08eb5ef1d..5c195ced4 100644 --- a/ai-chatbot/package.json +++ b/ai-chatbot/package.json @@ -4,12 +4,13 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "tsx server.ts", - "build": "vite build && esbuild server.ts --bundle --platform=node --format=cjs --packages=external --sourcemap --outfile=dist/server.cjs", - "start": "node dist/server.cjs", + "dev": "vite", + "build": "npm run clean && vite build", + "start": "vite preview --host 0.0.0.0", "preview": "vite preview", "clean": "rm -rf dist server.js", - "lint": "tsc --noEmit" + "lint": "tsc --noEmit", + "dev:legacy-server": "tsx server.ts" }, "dependencies": { "@google/genai": "^2.4.0", @@ -18,7 +19,7 @@ "lucide-react": "^0.546.0", "react": "^19.0.1", "react-dom": "^19.0.1", - "vite": "^6.2.3", + "vite": "^6.4.4", "express": "^4.21.2", "dotenv": "^17.2.3", "motion": "^12.23.24" @@ -30,7 +31,13 @@ "tailwindcss": "^4.1.14", "tsx": "^4.21.0", "typescript": "~5.8.2", - "vite": "^6.2.3", + "vite": "^6.4.4", "@types/express": "^4.17.21" + }, + "overrides": { + "body-parser": "^1.20.9", + "proxy-addr": "^2.0.8", + "qs": "^6.16.1", + "source-map-js": "^1.2.2" } } diff --git a/ai-chatbot/public/widget.js b/ai-chatbot/public/widget.js index f7a00be53..f3c03c288 100644 --- a/ai-chatbot/public/widget.js +++ b/ai-chatbot/public/widget.js @@ -18,6 +18,8 @@ const SHH_WIDGET=(()=>{ let history=[],speaking=false,busy=false,expanded=false,lastData=null;let sessionId=(window.crypto&&crypto.randomUUID)?crypto.randomUUID():'chat-'+Date.now(); const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));const safeUrl=v=>{try{const u=new URL(String(v));return ['http:','https:'].includes(u.protocol)?u.href:''}catch{return ''}};const icon=t=>({hostel:'🏠',tiffin:'🍱',library:'📚',cafe:'☕',bookstore:'📖'})[t]||'📍'; const strip=t=>String(t??'').replace(/\s{2,}/g,' ').trim();function formatText(t){let x=esc(strip(t));x=x.replace(/\*\*(.+?)\*\*/g,'$1');return x.replace(/\n/g,'
')} + function asksForNearbyLocation(text){return /(?:\bnear me\b|\bnearby\b|\bnearest\b|mere paas|mere pass|mere aas paas|aas paas|पास में|मेरे पास)/i.test(text)&&!/(allen|clc|vibrant|matrix|gurukripa|piprali road|piprali rd|nawalgarh road)/i.test(text)} + function getUserDeviceLocation(){if(!navigator.geolocation)return Promise.resolve(null);return new Promise(resolve=>{try{navigator.geolocation.getCurrentPosition(pos=>resolve({lat:pos.coords.latitude,lng:pos.coords.longitude}),()=>resolve(null),{timeout:5000,maximumAge:60000,enableHighAccuracy:false})}catch{resolve(null)}})} function speak(text){if(!speaking||!window.speechSynthesis)return;window.speechSynthesis.cancel();const u=new SpeechSynthesisUtterance(String(text).replace(/[*_#]/g,' ').replace(/\n+/g,'. '));u.lang='hi-IN';const vs=window.speechSynthesis.getVoices();const v=vs.find(x=>/^hi(-|_)/i.test(x.lang))||vs.find(x=>/^en(-|_)/i.test(x.lang));if(v)u.voice=v;window.speechSynthesis.speak(u)} function showHostelPills(){const wrap=document.createElement('div');wrap.style.cssText='align-self:stretch;width:100%;';const box=document.createElement('div');box.style.cssText='background:#fff;border:1px solid #e1e8f0;border-radius:16px;padding:12px;box-shadow:0 3px 12px rgba(15,23,42,.06)';const title=document.createElement('div');title.textContent='🏠 Hostel / PG preference';title.style.cssText='font-weight:900;font-size:12px;margin-bottom:8px;color:#172033';box.appendChild(title);const groups=[['Gender',['👧 Girls','👦 Boys','Any gender']],['Monthly budget',['Under ₹5,000','₹5,000–₹8,000','₹8,000+','Any budget']],['Coaching nearby',['Near CLC','Near Matrix','Near Gurukripa','Near Allen']]];groups.forEach(([label,items])=>{const h=document.createElement('div');h.textContent=label;h.style.cssText='font-size:10px;font-weight:800;color:#64748b;margin:9px 0 5px';box.appendChild(h);const row=document.createElement('div');row.style.cssText='display:flex;flex-wrap:wrap;gap:6px';items.forEach(item=>{const b=document.createElement('button');b.type='button';b.textContent=item;b.style.cssText='background:#f8fafc;color:#17365c;border:1px solid #d6e1ee;border-radius:999px;padding:7px 9px;font-size:10px;font-weight:800;cursor:pointer';b.onclick=()=>{const q=label==='Gender'?(item==='Any gender'?'hostel / PG any gender':item+' hostel / PG'):label==='Monthly budget'?(item==='Any budget'?'hostel / PG any budget':'hostel / PG budget '+item):'hostel / PG '+item;sendText(q)};row.appendChild(b)});box.appendChild(row)});wrap.appendChild(box);messages.appendChild(wrap);messages.scrollTop=messages.scrollHeight} function showOwnerLeadForm(){ @@ -45,7 +47,7 @@ const SHH_WIDGET=(()=>{ }; messages.appendChild(wrap);messages.scrollTop=messages.scrollHeight; } - async function sendText(value,meta={}){const text=String(value??'').trim()||field.value.trim();if(!text||busy)return false;if(!meta.action&&/(add\s+(my\s+)?(property|business|listing)|register\s+(my\s+)?(property|business|listing)|submit\s+(my\s+)?(property|business|listing)|owner\s+(listing|property)\s+(request|submit|add)|add\s+(my\s+)?(hostel|pg|tiffin|library|cafe|bookstore)\s+(listing|property))/i.test(text)){showOwnerLeadForm();return}if(/\bappointment\b|\bmeeting\b|\bconsultation\b/i.test(text)&&/(book|schedule|chahiye|chahta|chahti|karna|karo|fix|set|request|meet)/i.test(text)){addMessage(text,false);showAppointmentForm();return}field.value='';addMessage(text,false);busy=true;document.getElementById('shh-status').textContent='Thinking · checking live listings…';try{const ctl=new AbortController(),timer=setTimeout(()=>ctl.abort(),18000);let r;try{r=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json',apikey:KEY,Authorization:'Bearer '+KEY},body:JSON.stringify({message:text,history:history.slice(-8),sessionId,action:meta.action||'',focusPropertyId:meta.focusPropertyId||'',focusPropertyType:meta.focusPropertyType||'',shownPropertyIds:meta.shownPropertyIds||[],ownerLead:meta.ownerLead||null}),signal:ctl.signal})}finally{clearTimeout(timer)}if(!r.ok)throw Error('Chat request failed ('+r.status+')');const d=await r.json();lastData=d;const reply=d?.reply||'Ji, main StudentHubHelp par aapki help karta hoon.';addMessage(reply,true,d);history.push({role:'user',text},{role:'assistant',text:reply});if(history.length>16)history=history.slice(-16);const topic=document.getElementById('shh-topic');if(d?.primaryTopic){topic.textContent='Focus · '+strip(d.primaryTopic);topic.style.display='block'}document.getElementById('shh-status').textContent='Ready · Live listing support';return true}catch(e){addMessage(e?.name==='AbortError'?'Response thoda slow ho gaya. Please try again.':'Ji, live AI service me temporary issue aaya. Please try again.',true);document.getElementById('shh-status').textContent='Ready · Please try again';return false}finally{busy=false}} + async function sendText(value,meta={}){const text=String(value??'').trim()||field.value.trim();if(!text||busy)return false;if(!meta.action&&/(add\s+(my\s+)?(property|business|listing)|register\s+(my\s+)?(property|business|listing)|submit\s+(my\s+)?(property|business|listing)|owner\s+(listing|property)\s+(request|submit|add)|add\s+(my\s+)?(hostel|pg|tiffin|library|cafe|bookstore)\s+(listing|property))/i.test(text)){showOwnerLeadForm();return}if(/\bappointment\b|\bmeeting\b|\bconsultation\b/i.test(text)&&/(book|schedule|chahiye|chahta|chahti|karna|karo|fix|set|request|meet)/i.test(text)){addMessage(text,false);showAppointmentForm();return}field.value='';addMessage(text,false);busy=true;document.getElementById('shh-status').textContent='Thinking · checking live listings…';try{const clientCoords=asksForNearbyLocation(text)?await getUserDeviceLocation():null;const ctl=new AbortController(),timer=setTimeout(()=>ctl.abort(),18000);let r;try{r=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json',apikey:KEY,Authorization:'Bearer '+KEY},body:JSON.stringify({message:text,history:history.slice(-8),sessionId,clientCoords,action:meta.action||'',focusPropertyId:meta.focusPropertyId||'',focusPropertyType:meta.focusPropertyType||'',shownPropertyIds:meta.shownPropertyIds||[],ownerLead:meta.ownerLead||null}),signal:ctl.signal})}finally{clearTimeout(timer)}if(!r.ok)throw Error('Chat request failed ('+r.status+')');const d=await r.json();lastData=d;const reply=d?.reply||'Ji, main StudentHubHelp par aapki help karta hoon.';addMessage(reply,true,d);history.push({role:'user',text},{role:'assistant',text:reply});if(history.length>16)history=history.slice(-16);const topic=document.getElementById('shh-topic');if(d?.primaryTopic){topic.textContent='Focus · '+strip(d.primaryTopic);topic.style.display='block'}document.getElementById('shh-status').textContent='Ready · Live listing support';return true}catch(e){addMessage(e?.name==='AbortError'?'Response thoda slow ho gaya. Please try again.':'Ji, live AI service me temporary issue aaya. Please try again.',true);document.getElementById('shh-status').textContent='Ready · Please try again';return false}finally{busy=false}} const tts=header.querySelector('#shh-tts');tts.onclick=()=>{speaking=!speaking;tts.textContent=speaking?'🔊':'🔇';if(!speaking&&window.speechSynthesis)window.speechSynthesis.cancel()};header.querySelector('#shh-max').onclick=()=>{expanded=!expanded;root.classList.toggle('shh-expanded',expanded);header.querySelector('#shh-max').textContent=expanded?'↙':'↗'};header.querySelector('#shh-close-btn').onclick=()=>{win.style.display='none';launcher.style.display='flex'};launcher.onclick=()=>{win.style.display='flex';launcher.style.display='none';messages.scrollTop=messages.scrollHeight};input.querySelector('#shh-send-btn').onclick=()=>sendText(); const field=input.querySelector('#shh-input');field.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();sendText()}});input.querySelector('#shh-clear').onclick=()=>{history=[];lastData=null;messages.innerHTML='';document.getElementById('shh-topic').style.display='none';addMessage(getWelcome(),true)}; function getWelcome(){return'Namaste! 🙏 Main StudentHubHelp ka **Ultra Advance AI Assistant** hoon.\n\nAap jo bhi requirement batayenge—Hostel, Tiffin, Library, Cafe, Bookstore, area, budget ya nearby location—main aapki requirement samajhkar live StudentHubHelp listings mein relevant options dhoondunga.\n\n**Aapko kis cheez ki jankari chahiye?**'} diff --git a/ai-chatbot/server.ts b/ai-chatbot/server.ts index 8e8d0d7fe..e7b7f049b 100644 --- a/ai-chatbot/server.ts +++ b/ai-chatbot/server.ts @@ -1,3 +1,4 @@ +// LEGACY LOCAL PREVIEW ONLY. Not imported by the React client and excluded from the production Vite build. import express, { Request, Response } from 'express'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -623,8 +624,9 @@ CRITICAL DATA SAFETY: parts: [{ text: preferredTopic ? `[Priority Focus: ${preferredTopic}] ${message}` : message }] }); + let timeoutId: ReturnType | undefined; const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('Gemini API timeout after 6 seconds')), 6000); + timeoutId = setTimeout(() => reject(new Error('Gemini API timeout after 8 seconds')), 8000); }); const apiPromise = ai.models.generateContent({ @@ -699,7 +701,9 @@ CRITICAL DATA SAFETY: } }); - const response = (await Promise.race([apiPromise, timeoutPromise])) as any; + let response: any; + try { response = await Promise.race([apiPromise, timeoutPromise]); } + finally { if (timeoutId) clearTimeout(timeoutId); } const latencyMs = Date.now() - startTime; const parsed = JSON.parse(response.text?.trim() || '{}'); diff --git a/ai-chatbot/src/components/BudgetPlannerCard.tsx b/ai-chatbot/src/components/BudgetPlannerCard.tsx index b253daea3..2c0ed2ad9 100644 --- a/ai-chatbot/src/components/BudgetPlannerCard.tsx +++ b/ai-chatbot/src/components/BudgetPlannerCard.tsx @@ -285,7 +285,7 @@ export const BudgetPlannerCard: React.FC = ({ type="button" onClick={() => { if (onSendQuery) { - onSendQuery(`Mera monthly budget ₹${sliderBudget} hai, isme best verified hostel aur tiffin combo suggest karo`); + onSendQuery(`Mera monthly budget ₹${sliderBudget} hai, ${initialPlan.city ? initialPlan.city + " mein " : ""}best verified hostel aur tiffin combo suggest karo`); } }} className="flex-1 min-w-[140px] bg-amber-400 hover:bg-amber-300 text-slate-950 font-extrabold text-[11px] py-2 px-3 rounded-xl transition flex items-center justify-center gap-1.5 shadow-md" diff --git a/ai-chatbot/src/components/ChatWidget.tsx b/ai-chatbot/src/components/ChatWidget.tsx index ff2380159..4b8012108 100644 --- a/ai-chatbot/src/components/ChatWidget.tsx +++ b/ai-chatbot/src/components/ChatWidget.tsx @@ -29,9 +29,9 @@ import { ChatMessage, ChatApiResponse, SentimentType } from '../types'; import { BudgetPlannerCard } from './BudgetPlannerCard'; import { ComparisonMatrixCard } from './ComparisonMatrixCard'; -const CHAT_API_URL = - (import.meta as any).env?.VITE_CHAT_API_URL || - 'https://idurlccrarznnnqixxsd.supabase.co/functions/v1/studenthubhelp-chat'; +const PRODUCTION_CHAT_API = 'https://idurlccrarznnnqixxsd.supabase.co/functions/v1/studenthubhelp-chat'; +const configuredChatApi = (import.meta as any).env?.VITE_CHAT_API_URL || PRODUCTION_CHAT_API; +const CHAT_API_URL = (import.meta as any).env?.PROD ? PRODUCTION_CHAT_API : configuredChatApi; const SUPABASE_ANON_KEY = (import.meta as any).env?.VITE_SUPABASE_ANON_KEY || ''; @@ -54,6 +54,27 @@ const QUICK_TOPICS = [ { label: 'Search by Area', query: 'Mere area ke active listings dikhao', topic: 'Area Discovery' }, ]; +async function getUserDeviceLocation(): Promise<{ lat: number; lng: number } | null> { + if (typeof navigator === 'undefined' || !navigator.geolocation) return null; + return new Promise((resolve) => { + try { + navigator.geolocation.getCurrentPosition( + (position) => resolve({ lat: position.coords.latitude, lng: position.coords.longitude }), + () => resolve(null), + { timeout: 5000, maximumAge: 60000, enableHighAccuracy: false } + ); + } catch { + resolve(null); + } + }); +} + +function asksForNearbyLocation(text: string): boolean { + return /(?:\bnear me\b|\bnearby\b|\bnearest\b|mere paas|mere pass|mere aas paas|aas paas|पास में|मेरे पास)/i.test(text) + && !/(allen|clc|vibrant|matrix|gurukripa|piprali road|piprali rd|nawalgarh road)/i.test(text); +} + + export const ChatWidget: React.FC = ({ onDiagnosisUpdate, onLeadCaptured, @@ -64,6 +85,8 @@ export const ChatWidget: React.FC = ({ const [inputText, setInputText] = useState(''); const [isLoading, setIsLoading] = useState(false); const [isListening, setIsListening] = useState(false); + const [isSpeechSupported, setIsSpeechSupported] = useState(false); + const [retryText, setRetryText] = useState(null); const [isSpeakingEnabled, setIsSpeakingEnabled] = useState(true); const [unreadCount, setUnreadCount] = useState(0); const [recentLeadAlert, setRecentLeadAlert] = useState(null); @@ -87,7 +110,8 @@ export const ChatWidget: React.FC = ({ useEffect(() => { if (isOpen) { scrollToBottom(); setUnreadCount(0); } }, [messages, isOpen]); useEffect(() => { - const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; + const SpeechRecognition = typeof window !== "undefined" ? ((window as any).SpeechRecognition || (window as any).webkitSpeechRecognition) : undefined; + setIsSpeechSupported(Boolean(SpeechRecognition)); if (SpeechRecognition) { const recognition = new SpeechRecognition(); recognition.continuous = false; recognition.interimResults = false; recognition.lang = 'hi-IN'; recognition.onresult = (event: any) => { const transcript = event.results[0][0].transcript; setInputText(prev => prev ? `${prev} ${transcript}` : transcript); setIsListening(false); }; @@ -96,7 +120,10 @@ export const ChatWidget: React.FC = ({ }, []); const toggleSpeechRecognition = () => { - if (!recognitionRef.current) { alert('Browser voice recognition is not supported in this browser. Please use Chrome, Edge or Safari.'); return; } + if (!recognitionRef.current) { + setMessages(prev => [...prev, { id: `voice-unsupported-${Date.now()}`, sender: 'bot', text: 'Aapke browser mein voice input support nahi hai. Kripya apna sawaal type karein.', timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }]); + return; + } if (isListening) { recognitionRef.current.stop(); setIsListening(false); } else { try { recognitionRef.current.start(); setIsListening(true); } catch { setIsListening(false); } } }; const speakText = (text: string) => { @@ -106,7 +133,7 @@ export const ChatWidget: React.FC = ({ }; const handleSendMessage = async (textToSend?: string, topicOverride?: string) => { - const text = (textToSend || inputText).trim(); if (!text || isLoading) return; const topicToPrioritize = topicOverride || activeTopic; + const text = (textToSend || inputText).trim(); if (!text || isLoading) return; const topicToPrioritize = topicOverride || activeTopic; setRetryText(null); const userMessage: ChatMessage = { id: `user-${Date.now()}`, sender: 'user', text, timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }; setMessages(prev => [...prev, userMessage]); setInputText(''); setIsLoading(true); try { @@ -121,22 +148,24 @@ export const ChatWidget: React.FC = ({ }) || lastPropertyMessage?.recommendedProperties?.[0] : undefined; if (!SUPABASE_ANON_KEY) throw new Error('Missing VITE_SUPABASE_ANON_KEY. Add the public Supabase anon/publishable key to the Vite build environment.'); - const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), 20000); let res: Response; + const clientCoords = asksForNearbyLocation(text) ? await getUserDeviceLocation() : null; + const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), 15000); let res: Response; try { res = await fetch(CHAT_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', apikey: SUPABASE_ANON_KEY, Authorization: `Bearer ${SUPABASE_ANON_KEY}` }, body: JSON.stringify({ message: text, history, preferredTopic: topicToPrioritize, sessionId, + clientCoords, focusPropertyId: focusedProperty?.id ? String(focusedProperty.id) : undefined, focusPropertyType: focusedProperty?.type ? String(focusedProperty.type) : undefined }), signal: controller.signal }); } finally { window.clearTimeout(timeoutId); } - if (!res.ok) { let detail = ''; try { const errorBody = await res.json(); detail = errorBody?.error || errorBody?.message || ''; } catch {} throw new Error(detail || `Chat request failed (${res.status})`); } + if (!res.ok) { let detail = '', errorId = ''; try { const errorBody = await res.json(); detail = errorBody?.error || errorBody?.message || ''; errorId = errorBody?.error_id || ''; } catch {} const failure: any = new Error(detail || `Chat request failed (${res.status})`); failure.status = res.status; failure.errorId = errorId; throw failure; } const data: ChatApiResponse = await res.json(); if (!data || typeof data.reply !== 'string' || !data.reply.trim()) throw new Error('Chat backend returned an invalid response.'); const botMessage: ChatMessage = { id: `bot-${Date.now()}`, sender: 'bot', text: data.reply, timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), diagnosis: { intent: data.intent, primaryTopic: data.primaryTopic, topicDirectAnswer: data.topicDirectAnswer, topicPriorityReason: data.topicPriorityReason, userNeedSummary: data.userNeedSummary, sentiment: data.sentiment as SentimentType, suggestedFollowUps: data.suggestedFollowUps || [] }, recommendedProperties: data.recommendedProperties || data.recommendations || [], budgetPlan: data.budgetPlan, comparisonMatrix: data.comparisonMatrix }; setMessages(prev => [...prev, botMessage]); if (!isOpen) setUnreadCount(prev => prev + 1); if (data.capturedLead) { setRecentLeadAlert('Lead captured from this conversation.'); onLeadCaptured?.(data.capturedLead); } onDiagnosisUpdate?.(botMessage.diagnosis, text); setActiveTopic(null); speakText(data.reply); - } catch (err: any) { setMessages(prev => [...prev, { id: `bot-error-${Date.now()}`, sender: 'bot', text: err?.name === 'AbortError' ? 'The response took too long. Please try again.' : (err?.message || 'Chat service is temporarily unavailable.'), timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }]); } + } catch (err: any) { if (err?.errorId) console.error('[StudentHubHelp chatbot error]', err.errorId); if (err?.name === 'AbortError' || Number(err?.status) >= 500) setRetryText(text); setMessages(prev => [...prev, { id: `bot-error-${Date.now()}`, sender: 'bot', text: err?.name === 'AbortError' ? 'The response took too long. Please try again.' : (err?.message || 'Chat service is temporarily unavailable.'), timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }]); } finally { setIsLoading(false); } }; const handleQuickTopic = (topic: typeof QUICK_TOPICS[number]) => { setActiveTopic(topic.topic); void handleSendMessage(topic.query, topic.topic); }; @@ -361,8 +390,8 @@ export const ChatWidget: React.FC = ({ {isOpen &&
SH
StudentHubHelp AI
Live listing assistant
Live Supabase data · Smart assistant
{QUICK_TOPICS.map(topic=>)}
-
{recentLeadAlert&&
{recentLeadAlert}
}{messages.map(m=>
{m.sender==='user'?:}{m.sender==='user'?'You':'StudentHubHelp AI'}{m.timestamp}
{m.text}
{m.diagnosis?.primaryTopic&&m.sender==='bot'&&
Topic: {m.diagnosis.primaryTopic}
}{m.recommendedProperties?.length>0&&
{m.recommendedProperties.map(renderPropertyCard)}
}{m.budgetPlan&&
}{m.comparisonMatrix&&
}{m.diagnosis?.suggestedFollowUps?.length>0&&m.sender==='bot'&&
{m.diagnosis.suggestedFollowUps.slice(0,4).map((f:string)=>)}
}
)}{isLoading&&
AI is checking live listings…
}
-
setInputText(e.target.value)} onKeyDown={handleKeyDown} placeholder="Type any topic (Hostel, Tiffin, Library, Rates)..." className="min-w-0 flex-1 h-11 rounded-xl border border-slate-200 px-2.5 text-sm outline-none focus:border-amber-400"/>
Live listing support
+
{recentLeadAlert&&
{recentLeadAlert}
}{messages.map(m=>
{m.sender==='user'?:}{m.sender==='user'?'You':'StudentHubHelp AI'}{m.timestamp}
{m.text}
{m.diagnosis?.primaryTopic&&m.sender==='bot'&&
Topic: {m.diagnosis.primaryTopic}
}{m.recommendedProperties?.length>0&&
{m.recommendedProperties.map(renderPropertyCard)}
}{m.budgetPlan&&
void handleSendMessage(query)} />
}{m.comparisonMatrix&&
}{m.diagnosis?.suggestedFollowUps?.length>0&&m.sender==='bot'&&
{m.diagnosis.suggestedFollowUps.slice(0,4).map((f:string)=>)}
}
)}{isLoading&&
AI is checking live listings…
}{retryText&&!isLoading&&
}
+
setInputText(e.target.value)} onKeyDown={handleKeyDown} placeholder="Type any topic (Hostel, Tiffin, Library, Rates)..." className="min-w-0 flex-1 h-11 rounded-xl border border-slate-200 px-2.5 text-sm outline-none focus:border-amber-400"/>
Live listing support
}
; }; diff --git a/ai-chatbot/src/components/EmbedCodeModal.tsx b/ai-chatbot/src/components/EmbedCodeModal.tsx index 450599e22..f07d83528 100644 --- a/ai-chatbot/src/components/EmbedCodeModal.tsx +++ b/ai-chatbot/src/components/EmbedCodeModal.tsx @@ -25,8 +25,9 @@ export const EmbedCodeModal: React.FC = ({ isOpen, onClose if (!isOpen) return null; - const currentHost = typeof window !== 'undefined' ? window.location.origin : 'https://studenthubhelp.github.io'; - const embedScript = ``; + const currentHost = typeof window !== 'undefined' ? window.location.origin : 'https://studenthubhelp.in'; + const chatApiUrl = 'https://idurlccrarznnnqixxsd.supabase.co/functions/v1/studenthubhelp-chat'; + const embedScript = ``; const handleCopy = () => { navigator.clipboard.writeText(embedScript); diff --git a/ai-chatbot/tests/chatbot-regression.mjs b/ai-chatbot/tests/chatbot-regression.mjs new file mode 100644 index 000000000..798dae1a1 --- /dev/null +++ b/ai-chatbot/tests/chatbot-regression.mjs @@ -0,0 +1,103 @@ +const endpoint = process.env.CHATBOT_TEST_URL || 'http://localhost:54321/functions/v1/studenthubhelp-chat'; +const apiKey = process.env.SUPABASE_ANON_KEY || process.env.VITE_SUPABASE_ANON_KEY || ''; + +if (!apiKey) { + console.error('Set SUPABASE_ANON_KEY (local Supabase anon key) before running regression tests.'); + process.exit(2); +} + +const cases = [ + { query: 'Sikar mein Allen ke paas boys hostel', city: 'Sikar', category: 'hostel', forbiddenCity: 'Kota' }, + { query: 'Kota Talwandi mein budget tiffin', city: 'Kota', category: 'tiffin', forbiddenCategory: 'hostel' }, + { query: 'Jhunjhunu me cafe', city: 'Jhunjhunu', category: 'cafe' }, + { query: 'Piprali Road hostel under 5000', city: 'Sikar', category: 'hostel', maxBudget: 5000, allowNoResults: true }, + { query: 'Kota mein ₹15000 me hostel aur tiffin', city: 'Kota', combo: true, maxBudget: 15000 }, + { query: 'Mere paas hostel dikhao', expectLocationPrompt: true }, +]; + +function numericPrice(value) { + const match = String(value ?? '').replace(/,/g, '').match(/\d+(?:\.\d+)?/); + return match ? Number(match[0]) : null; +} + +async function runCase(testCase, index) { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + apikey: apiKey, + Authorization: 'Bearer ' + apiKey, + }, + body: JSON.stringify({ + message: testCase.query, + sessionId: 'regression-' + Date.now() + '-' + index, + }), + }); + + let data; + try { + data = await response.json(); + } catch { + throw new Error('HTTP ' + response.status + ': response was not valid JSON'); + } + if (!response.ok) throw new Error('HTTP ' + response.status + ': ' + (data?.error || data?.reply || 'request failed')); + + const listings = data.recommendedProperties || data.recommendations || []; + const city = String(data.targetCity || data.aiEntities?.city || '').toLowerCase(); + const category = String(data.targetCategory || data.aiEntities?.category || '').toLowerCase(); + const failures = []; + if (testCase.expectLocationPrompt) { + if (data.intent !== 'Location Required' || listings.length !== 0 || !String(data.reply || data.message || '').toLowerCase().includes('location')) failures.push('nearby query without GPS did not request device location/area'); + if (failures.length) throw new Error(failures.join('; ')); + return testCase.query + ' (GPS prompt returned)'; + } + if (testCase.combo) { + const plan = data.budgetPlan; + const types = listings.map(item => String(item.type || '').toLowerCase()); + if (data.intent !== 'Combined Budget Plan' || !plan) failures.push('combined budget planner did not return a budgetPlan'); + if (!types.some(type => type.includes('hostel')) || !types.some(type => type.includes('tiffin'))) failures.push('combo must contain both a hostel and a tiffin listing'); + if (Number(plan?.totalEstimated) > testCase.maxBudget) failures.push('combined monthly total exceeds budget'); + if (Number(plan?.totalEstimated) <= 0) failures.push('combined monthly total is missing/invalid'); + const itemTotal = Array.isArray(plan?.items) ? plan.items.reduce((sum, item) => sum + Number(item.amount || 0), 0) : 0; + if (Math.abs(itemTotal - Number(plan?.totalEstimated)) > 0.01) failures.push('budget breakdown items do not add up to totalEstimated'); + if (listings.some(item => String(item.city || '').toLowerCase() !== testCase.city.toLowerCase())) failures.push('combo contains a listing from another city'); + if (failures.length) throw new Error(failures.join('; ')); + return testCase.query + ' (combo total ₹' + Number(plan.totalEstimated).toLocaleString('en-IN') + ')'; + } + + if (!city.includes(testCase.city.toLowerCase())) failures.push('expected target city ' + testCase.city + ', received ' + (city || 'none')); + if (testCase.category && !category.includes(testCase.category)) failures.push('expected category ' + testCase.category + ', received ' + (category || 'none')); + if (!Array.isArray(listings) || (listings.length === 0 && !testCase.allowNoResults)) failures.push('no live listing returned'); + for (const listing of listings) { + const listingCity = String(listing.city || '').toLowerCase(); + const listingCategory = String(listing.type || listing.category || '').toLowerCase(); + if (!listingCategory) failures.push('listing category missing: ' + (listing.name || listing.id)); + if (!listingCity) failures.push('listing city missing: ' + (listing.name || listing.id)); + if (listingCity && listingCity !== testCase.city.toLowerCase()) failures.push('cross-city listing leaked: ' + listing.city); + if (testCase.forbiddenCity && listingCity.includes(testCase.forbiddenCity.toLowerCase())) failures.push('forbidden city leaked: ' + listing.city); + if (testCase.forbiddenCategory && listingCategory.includes(testCase.forbiddenCategory)) failures.push('forbidden category leaked: ' + listingCategory); + if (testCase.category && listingCategory && !listingCategory.includes(testCase.category)) failures.push('wrong category listing: ' + listingCategory); + if (testCase.maxBudget) { + const price = numericPrice(listing.price); + if (price !== null && price > testCase.maxBudget) failures.push('budget exceeded: ' + listing.price); + if (price === null) failures.push('listing price missing; cannot verify max budget: ' + (listing.name || listing.id)); + } + } + + if (failures.length) throw new Error(failures.join('; ')); + return testCase.query + ' (' + listings.length + ' listings)'; +} + +console.log('StudentHubHelp chatbot regression suite: ' + endpoint); +let failed = 0; +for (const [index, testCase] of cases.entries()) { + try { + const detail = await runCase(testCase, index); + console.log('PASS: ' + detail); + } catch (error) { + failed += 1; + console.error('FAIL: ' + testCase.query + '\n ' + (error instanceof Error ? error.message : String(error))); + } +} +console.log('\nResult: ' + (cases.length - failed) + '/' + cases.length + ' passed'); +if (failed) process.exit(1); diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 97d93ce28..37a87c362 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -1,38 +1,79 @@ import "jsr:@supabase/functions-js/edge-runtime.d.ts"; +declare const EdgeRuntime: { waitUntil(promise: Promise): void }; + const U=(Deno.env.get("SUPABASE_URL")||"").replace(/\/$/,""); const K=Deno.env.get("SUPABASE_SECRET_KEY")||Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")||""; const GEMINI=Deno.env.get("GEMINI_API_KEY")||""; const MODEL="gemini-3.8-flash"; -const RATE_WINDOW_MS=60_000, RATE_MAX=30; -const rateMap=new Map(); -const ownerLeadRateMap=new Map(); -const OWNER_LEAD_WINDOW_MS=10*60_000, OWNER_LEAD_MAX=3; -function ownerLeadAllowed(req:Request){const key=rateKey(req),now=Date.now(),old=ownerLeadRateMap.get(key);if(!old||now-old.start>=OWNER_LEAD_WINDOW_MS){ownerLeadRateMap.set(key,{start:now,count:1});return true}if(old.count>=OWNER_LEAD_MAX)return false;old.count++;return true} -const H={"Access-Control-Allow-Origin":"*","Access-Control-Allow-Headers":"authorization,x-client-info,apikey,content-type","Access-Control-Allow-Methods":"POST,OPTIONS","Content-Type":"application/json"}; +const H={"Access-Control-Allow-Origin":"*","Access-Control-Allow-Headers":"authorization,x-client-info,apikey,content-type,x-session-id","Access-Control-Allow-Methods":"POST,OPTIONS","Content-Type":"application/json"}; const SRC=[["hostels","hostel"],["tiffins","tiffin"],["libraries","library"],["cafes","cafe"],["bookstores","bookstore"]] as const; const n=(v:any)=>String(v??"").toLowerCase().normalize("NFKC").replace(/[^a-z0-9\u0900-\u097f]+/gi," ").replace(/\s+/g," ").trim(); const num=(v:any)=>{const x=Number(String(v??"").replace(/,/g,""));return Number.isFinite(x)?x:undefined}; -function rateKey(req:Request){const f=req.headers.get("x-forwarded-for")||req.headers.get("cf-connecting-ip")||"unknown";return f.split(",")[0].trim().slice(0,100)||"unknown"} -function allowed(req:Request){const key=rateKey(req),now=Date.now(),old=rateMap.get(key);if(!old||now-old.start>=RATE_WINDOW_MS){rateMap.set(key,{start:now,count:1});return true}if(old.count>=RATE_MAX)return false;old.count++;return true} -async function db(path:string):Promise{if(!U||!K)throw Error("database configuration missing");const r=await fetch(U+path,{headers:{apikey:K,Authorization:`Bearer ${K}`,"Content-Type":"application/json"}});if(!r.ok)throw Error(`database ${r.status}`);return await r.json() as T} +function rateKeys(req:Request,sessionId:string){const cfIp=req.headers.get("cf-connecting-ip")?.trim()||"";const forwarded=req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()||"";const userAgent=(req.headers.get("user-agent")||"unknown-agent").slice(0,80);const stableSession=(req.headers.get("x-session-id")||sessionId||"anonymous").slice(0,160);const keys:{scopeKey:string;limit:number;windowSeconds:number}[]=[];if(cfIp&&cfIp.length<=100&&!/[\s,]/.test(cfIp)){keys.push({scopeKey:"ip:"+cfIp,limit:30,windowSeconds:60})}else{console.warn("[RATE_LIMIT] trusted cf-connecting-ip missing; using untrusted proxy/session fallback");if(forwarded&&forwarded.length<=100)keys.push({scopeKey:"proxy-untrusted:"+forwarded,limit:8,windowSeconds:60});keys.push({scopeKey:"fallback-session-ua:"+stableSession+":"+userAgent,limit:10,windowSeconds:60})}keys.push({scopeKey:"session:"+stableSession,limit:15,windowSeconds:60});return keys} +async function consumeDualRateLimit(scope:string,req:Request,sessionId:string,limit:number,windowSeconds:number){for(const item of rateKeys(req,sessionId)){const allowed=await consumeRateLimit(scope,item.scopeKey,Math.min(limit,item.limit),Math.max(windowSeconds,item.windowSeconds));if(!allowed)return false}return true} +async function consumeRateLimit(scope:string,key:string,limit:number,windowSeconds:number){if(!U||!K)throw Error("rate limiter database configuration missing");const r=await fetch(U+"/rest/v1/rpc/consume_chatbot_rate_limit",{method:"POST",headers:{apikey:K,Authorization:"Bearer "+K,"Content-Type":"application/json"},body:JSON.stringify({p_scope:scope,p_key:key,p_limit:limit,p_window_seconds:windowSeconds}),signal:AbortSignal.timeout(4000)});if(!r.ok)throw Error("distributed rate limiter unavailable: "+r.status);return await r.json()===true} +async function db(path:string):Promise{if(!U||!K)throw Error("database configuration missing");const r=await fetch(U+path,{headers:{apikey:K,Authorization:`Bearer ${K}`,"Content-Type":"application/json"},signal:AbortSignal.timeout(4000)});if(!r.ok)throw Error(`database ${r.status}`);return await r.json() as T} +type ChatbotRuntimeConfig={enabled:boolean;ai_rerank_enabled:boolean;lead_capture_enabled:boolean;comparison_enabled:boolean;budget_planner_enabled:boolean;max_candidates:number;max_results:number;max_output_tokens:number;temperature:number;maintenance_message:string}; +const SAFE_FALLBACK_CONFIG:ChatbotRuntimeConfig={enabled:false,ai_rerank_enabled:false,lead_capture_enabled:false,comparison_enabled:false,budget_planner_enabled:false,max_candidates:10,max_results:1,max_output_tokens:200,temperature:0,maintenance_message:"Chatbot system maintenance par hai. Kripya thodi der baad koshish karein ya seedhe support par sampark karein."}; +const DEFAULT_RUNTIME_CONFIG:ChatbotRuntimeConfig=SAFE_FALLBACK_CONFIG; +let runtimeConfigCache:{value:ChatbotRuntimeConfig;expiresAt:number}|null=null; +async function getRuntimeConfig():Promise{if(runtimeConfigCache&&runtimeConfigCache.expiresAt>Date.now())return runtimeConfigCache.value;try{const rows=await db("/rest/v1/chatbot_admin_config?select=*&id=eq.1&limit=1");const row=rows?.[0];if(!row){console.error("[CRITICAL] chatbot_admin_config row id=1 is missing; disabling chatbot");runtimeConfigCache={value:SAFE_FALLBACK_CONFIG,expiresAt:Date.now()+2000};return SAFE_FALLBACK_CONFIG}const value:ChatbotRuntimeConfig={enabled:row.enabled===true,ai_rerank_enabled:row.ai_rerank_enabled===true,lead_capture_enabled:row.lead_capture_enabled===true,comparison_enabled:row.comparison_enabled===true,budget_planner_enabled:row.budget_planner_enabled===true,max_candidates:Math.max(10,Math.min(100,Number(row.max_candidates)||40)),max_results:Math.max(1,Math.min(20,Number(row.max_results)||8)),max_output_tokens:Math.max(200,Math.min(3000,Number(row.max_output_tokens)||900)),temperature:Math.max(0,Math.min(1,Number.isFinite(Number(row.temperature))?Number(row.temperature):0.2)),maintenance_message:String(row.maintenance_message||"").slice(0,500)};runtimeConfigCache={value,expiresAt:Date.now()+2000};return value}catch(e){console.error("[CRITICAL] chatbot admin config fetch failed; disabling chatbot",e);runtimeConfigCache={value:SAFE_FALLBACK_CONFIG,expiresAt:Date.now()+2000};return SAFE_FALLBACK_CONFIG}} const LANDMARKS=[{key:"allen",label:"Allen Career Institute Sikar",aliases:["allen","allen sikar","allen coaching","allen career","allen sanskar","allen piprali"],city:"Sikar",lat:27.62624,lng:75.17721},{key:"clc",label:"CLC Sikar",aliases:["clc","clc sikar"],city:"Sikar",lat:27.6206291,lng:75.1616672},{key:"vibrant",label:"Vibrant Academy Sikar",aliases:["vibrant","vibrant coaching","vibrant academy","vibrant academy ssc","vibrant ssc"],city:"Sikar"},{key:"matrix",label:"Matrix Coaching Sikar",aliases:["matrix","matrix academy","matrix coaching","matrix sikar"],city:"Sikar"},{key:"gurukripa",label:"Gurukripa Coaching Sikar",aliases:["gurukripa","guru kripa","gurukripa coaching","gurukripa sikar"],city:"Sikar"}]; -function city(s:string){const x=n(s),m:[string,string][]=[["sikar","Sikar"],["piprali","Sikar"],["पिपराली","Sikar"],["सिकर","Sikar"],["kota","Kota"],["talwandi","Kota"],["jaipur","Jaipur"],["ajmer","Ajmer"],["bikaner","Bikaner"],["jodhpur","Jodhpur"],["udaipur","Udaipur"],["alwar","Alwar"],["delhi","Delhi"],["ahmedabad","Ahmedabad"],["lucknow","Lucknow"],["hyderabad","Hyderabad"],["pune","Pune"]];return m.find(([a])=>x.includes(n(a)))?.[1]||""} -function findLandmark(s:string){const x=n(s);for(const l of LANDMARKS)if(l.aliases.some(a=>x.includes(n(a))))return l;return undefined} -function nearTerm(s:string){const x=n(s),lm=findLandmark(x);if(lm)return lm.key;const known=["vibrant coaching","vibrant career institute","nawalgarh road","nawalgarh rd","nawalgarh","clc","allen","gurukripa","matrix","matrix academy","prayas eduhub","banco career academy","inspector ssc academy"];const hit=known.find(k=>x.includes(n(k)));if(hit)return hit;const m=x.match(/(?:near|nearby|around|paas|pass|ke paas|k pass|aas paas|bagal|nazdeek|पास|आस पास|बगल)(?:\s+me)?\s+([a-z0-9\u0900-\u097f][a-z0-9\u0900-\u097f ]{1,50}?)(?=\s+(?:chahiye|dikhao|dikhado|hai|ho|rehna|rahna|book|konsa|kaunsa|aur|bhi)\b|$)/i);if(m)return m[1].trim();if(/(?:piprali road|piprali rd|पिपराली रोड)/i.test(x))return "piprali road";return ""} +function city(s:string){const x=n(s),m:[string,string][]=[["sikar","Sikar"],["piprali","Sikar"],["पिपराली","Sikar"],["सिकर","Sikar"],["udaipurwati","Udaipurwati"],["उदयपुरवाटी","Udaipurwati"],["jhunjhunu","Jhunjhunu"],["झुंझुनूं","Jhunjhunu"],["झुंझुनू","Jhunjhunu"],["kota","Kota"],["talwandi","Kota"],["jaipur","Jaipur"],["ajmer","Ajmer"],["bikaner","Bikaner"],["jodhpur","Jodhpur"],["udaipur","Udaipur"],["alwar","Alwar"],["delhi","Delhi"],["ahmedabad","Ahmedabad"],["lucknow","Lucknow"],["hyderabad","Hyderabad"],["pune","Pune"]];return m.find(([a])=>x.includes(n(a)))?.[1]||""} +function findLandmark(s:string){const x=n(normalizeSearchTerms(s));for(const l of LANDMARKS)if(l.aliases.some(a=>x.includes(n(a))))return l;return undefined} +function nearTerm(s:string){const x=n(normalizeSearchTerms(s)),lm=findLandmark(x);if(lm)return lm.key;const known=["vibrant coaching","vibrant career institute","nawalgarh road","nawalgarh rd","nawalgarh","clc","allen","gurukripa","matrix","matrix academy","prayas eduhub","banco career academy","inspector ssc academy"];const hit=known.find(k=>x.includes(n(k)));if(hit)return hit;const m=x.match(/(?:near|nearby|around|paas|pass|ke paas|k pass|aas paas|bagal|nazdeek|पास|आस पास|बगल)(?:\s+me)?\s+([a-z0-9\u0900-\u097f][a-z0-9\u0900-\u097f ]{1,50}?)(?=\s+(?:chahiye|dikhao|dikhado|hai|ho|rehna|rahna|book|konsa|kaunsa|aur|bhi)\b|$)/i);if(m)return m[1].trim();if(/(?:piprali road|piprali rd|पिपराली रोड)/i.test(x))return "piprali road";return ""} function cat(s:string){const x=n(s),hits:[string,number][]=[];const defs:[string,RegExp][]=[["hostel",/hostel|pg|room|stay|accommodation|girls|boys|female|male|rehne|rehna|रहने|हॉस्टल|पीजी/],["tiffin",/tiffin|mess|khana|food|meal|jain food|टिफिन|मेस|खाना/],["library",/library|reading|seat|लाइब्रेरी/],["cafe",/cafe|coffee|restaurant|hangout|कैफे/],["bookstore",/bookstore|books|stationery|bookshop|किताब|बुकस्टोर/]];for(const [type,re] of defs)for(const m of x.matchAll(new RegExp(re.source,"gi")))hits.push([type,m.index??-1]);const near=new Set(hits.filter(([,pos])=>/(near|nearby|around|paas|pass|ke paas|k pass|aas paas|bagal|nazdeek|पास|आस पास|बगल)/i.test(x.slice(Math.max(0,pos-50),pos))).map(([type])=>type));return hits.find(([type])=>!near.has(type))?.[0]||hits[0]?.[0]} function secondaryCats(s:string){const x=n(s),out:string[]=[];const defs:[string,RegExp][]=[["hostel",/hostel|pg|room|stay|accommodation|girls|boys|female|male|rehne|rehna|रहने|हॉस्टल|पीजी/],["tiffin",/tiffin|mess|khana|food|meal|टिफिन|मेस|खाना/],["library",/library|reading|seat|लाइब्रेरी/],["cafe",/cafe|coffee|restaurant|hangout|कैफे/],["bookstore",/bookstore|books|stationery|bookshop|किताब|बुकस्टोर/]];for(const [type,re] of defs)for(const m of x.matchAll(new RegExp(re.source,"gi"))){const pos=m.index??-1;if(/(near|nearby|around|paas|pass|ke paas|k pass|aas paas|bagal|nazdeek|पास|आस पास|बगल)/i.test(x.slice(Math.max(0,pos-50),pos)))out.push(type)}return [...new Set(out)]} -function coords(p:any){const lat=Number(p.latitude??p.lat??p.location_latitude??p.latitude_deg),lng=Number(p.longitude??p.lng??p.lon??p.location_longitude??p.longitude_deg);return Number.isFinite(lat)&&Number.isFinite(lng)?{lat,lng}:null} +function coords(p:any){const lat=Number(p.latitude??p.lat??p.location_latitude??p.latitude_deg),lng=Number(p.longitude??p.lng??p.lon??p.location_longitude??p.longitude_deg);return Number.isFinite(lat)&&Number.isFinite(lng)&&lat>=-90&&lat<=90&&lng>=-180&&lng<=180?{lat,lng}:null} function distanceKm(a:any,b:any){const A=coords(a),B=coords(b);if(!A||!B)return undefined;const r=(v:number)=>v*Math.PI/180,dLat=r(B.lat-A.lat),dLon=r(B.lng-A.lng),h=Math.sin(dLat/2)**2+Math.cos(r(A.lat))*Math.cos(r(B.lat))*Math.sin(dLon/2)**2;return 6371*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h))} function distanceToLandmark(p:any,l:any){const A=coords(p),B=l?{latitude:l.lat,longitude:l.lng}:null;if(!A||!B)return undefined;return distanceKm(A,B)} -function searchText(p:any){return n([p.name,p.title,p.business_name,p.service_name,p.city,p.area,p.service_area,p.address,p.landmark,p.description,p.facilities,p.amenities].filter(Boolean).join(" "))} -async function direct(type:string,ct?:string){const table=SRC.find(x=>x[1]===type)?.[0];if(!table)return [];const rows=await db(`/rest/v1/${table}?select=*&limit=1000`);return (Array.isArray(rows)?rows:[]).map(p=>({...p,_type:type})).filter(p=>!ct||n(p.city||"").includes(n(ct)))} -async function multiDirect(types:string[],ct?:string){const unique=[...new Set(types.filter(Boolean))];const groups=await Promise.all(unique.map(type=>direct(type,ct)));return groups.flat()} -function genderWanted(s:string){const x=n(s);if(/\b(girls|girl|female|ladki|ladkiyo|ladkiyon|महिला|लड़क|लड़की|girls hostel)\b/i.test(x))return "girls";if(/\b(boys|boy|male|ladke|ladkon|लड़के|लड़का|boys hostel)\b/i.test(x))return "boys";return ""} -function areaWanted(s:string){const x=n(s);if(/nawalgarh road|nawalgarh rd|नवलगढ़ रोड/i.test(x))return "nawalgarh";if(/piprali road|piprali rd|पिपराली रोड/i.test(x))return "piprali";return ""} -function priceLabel(p:any,c:string){const v=Number(p.monthly_rent??p.monthly_fee??p.monthly_charge??p.price);if(!Number.isFinite(v)||v<=0)return "";if(c==="hostel"||c==="tiffin"||c==="library")return "₹"+v.toLocaleString("en-IN")+"/mo";return "₹"+v.toLocaleString("en-IN")} -function card(p:any){const c=p._type,price=priceLabel(p,c),d=p._distanceKm;return{id:String(p.id),name:p.name||p.title||p.service_name||p.business_name||"Active listing",type:c,city:p.city||"",area:p.area||p.service_area||"",address:p.address||"",phone:([p.phone,p.contact_phone,p.whatsapp,p.whatsapp_number].find(v=>{const s=String(v??"").trim();return s&&s!=="0"&&s.replace(/\D/g,"").length>=7})||""),rating:num(p.rating),verified:p.verified===true,price,distanceKm:d===undefined?undefined:Number(d.toFixed(1)),distanceLabel:d===undefined?"":`~${d.toFixed(1)} km`,nearbyMatch:p._nearbyVerified===true||p._nearbyAreaMatch===true,highlights:["wifi","ac_available","ac","cctv","food_available","mess_available","available_beds","room_types","room_sharing","gender_type","timing","timings","delivery_available","nearby_coaching"].map(k=>[k,p[k]]).filter(([,v])=>v!==null&&v!==undefined&&String(v).trim()!==""&&String(v).toLowerCase()!=="false").slice(0,4).map(([k,v])=>k+": "+String(v)),description:p.description&&String(p.description).trim().length<=280?String(p.description).trim():"",mapsUrl:p.google_maps_url||"",detailsUrl:p.slug?`property-details.html?type=${c}&slug=${encodeURIComponent(p.slug)}`:undefined}} -function rank(p:any,q:string,ct:string,near:string){const x=searchText(p),t=n(q);let s=0;const a=areaWanted(q);if(a&&x.includes(a))s+=25;if(ct&&n(p.city||"").includes(n(ct)))s+=30;for(const w of t.split(" ").filter(z=>z.length>2).slice(0,20))if(x.includes(w))s+=2;if(near){if(near==="allen"||near==="clc"){if(p._distanceKm!==undefined)s+=Math.max(0,20-p._distanceKm*5);if(x.includes(near))s+=10}else if(near==="vibrant"){if(x.includes("vibrant"))s+=20;if(x.includes("nawalgarh"))s+=8}else if(near==="matrix"||near==="gurukripa"){if(x.includes(near))s+=30;else s-=3}else if(x.includes(n(near)))s+=20}if(p.verified===true)s+=4;if(Number.isFinite(Number(p.rating)))s+=Math.min(5,Number(p.rating));return s} +function searchText(p:any){return n([p.name,p.title,p.business_name,p.service_name,p.type,p.gender_type,p.gender,p.city,p.area,p.service_area,p.address,p.landmark,p.nearby_coaching,p.description,p.facilities,p.amenities].filter(Boolean).join(" "))} +async function direct(type:string,ct?:string,maxBudget?:number,locationTerm?:string,genderFilter?:string){ + const table=SRC.find(x=>x[1]===type)?.[0];if(!table)return []; + const pageSize=250,maxRows=5000;const params=new URLSearchParams({select:"*",status:"eq.active",limit:String(pageSize),order:"id.asc"}); + if(ct)params.set("city",`ilike.*${ct.replace(/[%,()]/g,"")}*`); + const orGroups:string[]=[]; + if(maxBudget!==undefined&&Number.isFinite(maxBudget)&&maxBudget>0){ + const budget=String(Math.floor(maxBudget)); + orGroups.push(type==="hostel" + ? `monthly_rent.lte.${budget},monthly_fee.lte.${budget},price.lte.${budget}` + : type==="tiffin" ? `monthly_plan.lte.${budget},monthly_fee.lte.${budget}` : `monthly_fee.lte.${budget},price.lte.${budget}`); + } + const term=String(locationTerm||"").replace(/[^a-zA-Z0-9\u0900-\u097f -]/g," ").replace(/\s+/g," ").trim(); + if(term)orGroups.push(["name","area","address","landmark","nearby_coaching","description"].map(col=>`${col}.ilike.*${term}*`).join(",")); + if(type==="hostel"&&(genderFilter==="girls"||genderFilter==="boys")){ + const variants=genderFilter==="girls"?["girls","girl","female","women","ladies","ladki","महिला","लड़क"]:["boys","boy","male","men","ladke","लड़के"]; + orGroups.push(variants.flatMap(v=>["name","description","type"].map(col=>`${col}.ilike.*${v}*`)).join(",")); + } + if(orGroups.length===1)params.set("or",`(${orGroups[0]})`); + else if(orGroups.length>1)params.set("and",`(${orGroups.map(group=>`or(${group})`).join(",")})`); + const page:any[]=[];let cursor:string|number|undefined;const pagingDeadline=Date.now()+8000;while(page.length(`/rest/v1/${table}?${params.toString()}`);const batch=Array.isArray(rows)?rows:[];if(!batch.length)break;page.push(...batch);const lastId=batch[batch.length-1]?.id;if(lastId===undefined||batch.length=pagingDeadline)console.warn(`[SEARCH] ${type} cursor pagination reached its 8s budget; returning bounded partial results`); + // Keyset pagination on the category primary key; filters run in Postgres before each bounded page. + return page.map(p=>({...p,_type:type})).filter(p=>!ct||n(p.city||"").includes(n(ct))); +} +async function multiDirect(types:string[],ct?:string,maxBudget?:number,locationTerm?:string,genderFilter?:string){ + const unique=[...new Set(types.filter(Boolean))]; + const groups=await Promise.all(unique.map(type=>direct(type,ct,maxBudget,locationTerm,genderFilter))); + return groups.flat(); +} +function positiveAmount(...values:any[]){for(const raw of values){const value=num(raw);if(value!==undefined&&value>0)return value}return undefined} +function monthlyAmount(p:any,type:string){return type==="hostel"?positiveAmount(p.monthly_rent,p.monthly_fee,p.price):positiveAmount(p.monthly_plan,p.monthly_fee)} +async function planComboBudget(cityName:string,totalBudget:number,genderFilter?:string){ + const hostelCap=Math.floor(totalBudget*0.70),tiffinCap=Math.floor(totalBudget*0.35); + const [hostelRows,tiffinRows]=await Promise.all([direct("hostel",cityName,hostelCap,undefined,genderFilter),direct("tiffin",cityName,tiffinCap)]); + const hostels=hostelRows.filter(p=>n(p.city||"")===n(cityName));const tiffins=tiffinRows.filter(p=>n(p.city||"")===n(cityName)); + const pricedHostels=hostels.map(p=>({p,cost:monthlyAmount(p,"hostel")})).filter(x=>x.cost!==undefined) as Array<{p:any;cost:number}>; + const pricedTiffins=tiffins.map(p=>({p,cost:monthlyAmount(p,"tiffin")})).filter(x=>x.cost!==undefined) as Array<{p:any;cost:number}>; + let best:{hostel:any;tiffin:any;hostelCost:number;tiffinCost:number;total:number;score:number}|undefined; + for(const h of pricedHostels)for(const t of pricedTiffins){const total=h.cost+t.cost;if(total>totalBudget)continue;const score=Math.abs(h.cost-totalBudget*0.65)+Math.abs(t.cost-totalBudget*0.35)+(totalBudget-total)*0.15;if(!best||score{const s=String(v??"").trim();return s&&s!=="0"&&s.replace(/\D/g,"").length>=7})||""),rating:num(p.rating),verified:p.verified===true,price,distanceKm:d===undefined?null:Number(d.toFixed(1)),distanceVerified:d!==undefined,distanceLabel:d===undefined?(p._nearbyAreaMatch?`${p.area||p.service_area||p.city||"Area"} area match · exact distance unavailable`:""):`~${d.toFixed(1)} km (GPS straight-line)`,nearbyMatch:p._nearbyVerified===true||p._nearbyAreaMatch===true,highlights:["wifi","ac_available","ac","cctv","food_available","mess_available","available_beds","room_types","room_sharing","gender_type","timing","timings","delivery_available","nearby_coaching"].map(k=>[k,p[k]]).filter(([,v])=>v!==null&&v!==undefined&&String(v).trim()!==""&&String(v).toLowerCase()!=="false").slice(0,4).map(([k,v])=>k+": "+String(v)),description:p.description&&String(p.description).trim().length<=280?String(p.description).trim():"",mapsUrl:p.google_maps_url||"",detailsUrl:p.slug?`property-details.html?type=${c}&slug=${encodeURIComponent(p.slug)}`:undefined}} +function rank(p:any,q:string,ct:string,near:string){const x=searchText(p),t=n(q);let s=0;if(p._distanceKm!==undefined)s+=Math.max(0,25-p._distanceKm*5);const a=areaWanted(q);if(a&&x.includes(a))s+=25;if(ct&&n(p.city||"").includes(n(ct)))s+=30;for(const w of t.split(" ").filter(z=>z.length>2).slice(0,20))if(x.includes(w))s+=2;if(near){if(near==="allen"||near==="clc"){if(p._distanceKm!==undefined)s+=Math.max(0,20-p._distanceKm*5);if(x.includes(near))s+=10}else if(near==="vibrant"){if(x.includes("vibrant"))s+=20;if(x.includes("nawalgarh"))s+=8}else if(near==="matrix"||near==="gurukripa"){if(x.includes(near))s+=30;else s-=3}else if(x.includes(n(near)))s+=20}if(p.verified===true)s+=4;if(Number.isFinite(Number(p.rating)))s+=Math.min(5,Number(p.rating));return s} function isGreeting(s:string){return /^(hi|hello|hey|namaste|good morning|good afternoon|good evening|good night|gm|gn|hii|hlo|नमस्ते|सुप्रभात|शुभ प्रभात|शुभ संध्या)[!.\s]*$/i.test(s.trim())} function isAccountSupport(s:string){ const x=n(s); @@ -65,14 +106,18 @@ function greeting(){return "Namaste! 🙏 Main StudentHubHelp ka **Ultra Advance function label(c:string){return c==="hostel"?"hostel / PG":c==="tiffin"?"tiffin service":c==="library"?"library":c==="cafe"?"cafe":c==="bookstore"?"bookstore":"student service"} function fallbackReply(c:string|undefined,recs:any[],near:string,cityName:string=""){const l=label(c||""),place=near?(near==="allen"?"Allen ke paas":near==="clc"?"CLC ke paas":`${near} area mein`):"";if(recs.length)return `Bilkul 😊 ${place?place+" ":""}${recs.length} active ${l} listing${recs.length>1?"s":""} mili ${recs.length>1?"hain":"hai"}. Neeche live StudentHubHelp options diye hain.`;if(cityName)return `Maine live StudentHubHelp listings check ki hain, lekin abhi **${cityName}** mein koi active matching ${l} listing nahi mili. Main fake options nahi dikhaunga. Aap ${cityName} ka area/landmark bata sakte hain, ya kisi doosri category ki listing search kar sakte hain.`;return `Abhi ${place?place+" ":""}koi matching **${l}** listing nahi mili. Aap city aur area/landmark bataiye; main live listings ko uske hisaab se check karunga.`} function safeJson(s:string){try{return JSON.parse(s)}catch{const m=s.match(/\{[\s\S]*\}/);if(m)try{return JSON.parse(m[0])}catch{}return null}} -function aiIntentDefaults(){return{intent:"general",confidence:0,category:"",categories:[],city:"",locality:"",area:"",landmark:"",nearRelation:"",gender:"",budgetMin:null,budgetMax:null,facilities:[],referenceIndex:null,action:"",followUp:false,correction:false,needsClarification:false,clarificationQuestion:"",searchScope:""}} +const SEARCH_ALIASES:[RegExp,string][]=[[/\b(?:piparli\s+rd|piprali\s+rd|pipli\s+road|piprali)\b/gi,"piprali road"],[/\b(?:allun|allan|allen\s+institute|allen\s+career)\b/gi,"allen"],[/\b(?:sasta|saste|kam\s+budget)\b/gi,"budget"],[/\b(?:ladko\s+ka|ladkon\s+ka)\b/gi,"boys"],[/\b(?:ladkiyo\s+ka|ladkiyon\s+ka)\b/gi,"girls"]]; +function normalizeSearchTerms(input:string){let value=String(input||"").normalize("NFKC");for(const [pattern,replacement] of SEARCH_ALIASES)value=value.replace(pattern,replacement);return value} +type ChatIntent={intent:string;confidence:number;category:string;categories:string[];city:string;locality:string;area:string;landmark:string;nearRelation:string;gender:string;budgetMin:number|null;budgetMax:number|null;facilities:string[];referenceIndex:number|null;action:string;followUp:boolean;correction:boolean;needsClarification:boolean;clarificationQuestion:string;searchScope:string}; +type StrictLocation={city?:string;locality?:string;area?:string;landmark?:string}; +function aiIntentDefaults():ChatIntent{return{intent:"general",confidence:0,category:"",categories:[],city:"",locality:"",area:"",landmark:"",nearRelation:"",gender:"",budgetMin:null,budgetMax:null,facilities:[],referenceIndex:null,action:"",followUp:false,correction:false,needsClarification:false,clarificationQuestion:"",searchScope:""}} function offlineIntent(msg:string,history:any[]){ - const o=aiIntentDefaults(),x=n(msg),defs:[string,RegExp][]=[["hostel",/hostel|\bpg\b|room|stay|accommodation|girls hostel|boys hostel|हॉस्टल|पीजी/],["tiffin",/tiffin|mess|khana|food service|टिफिन|मेस/],["library",/library|reading room|study room|लाइब्रेरी/],["cafe",/cafe|coffee shop|कैफे/],["bookstore",/bookstore|book shop|bookshop|stationery|बुकस्टोर/]]; + const o=aiIntentDefaults(),x=n(normalizeSearchTerms(msg)),defs:[string,RegExp][]=[["hostel",/hostel|\bpg\b|room|stay|accommodation|girls hostel|boys hostel|हॉस्टल|पीजी/],["tiffin",/tiffin|mess|khana|food service|टिफिन|मेस/],["library",/library|reading room|study room|लाइब्रेरी/],["cafe",/cafe|coffee shop|कैफे/],["bookstore",/bookstore|book shop|bookshop|stationery|बुकस्टोर/]]; const cats=defs.filter(([,re])=>re.test(x)).map(([c])=>c); o.categories=[...new Set(cats)];o.category=o.categories[0]||cat(msg)||""; const loc=strictLocation(msg);o.city=loc.city||city(msg)||"";o.locality=loc.locality||"";o.area=loc.area||"";o.landmark=loc.landmark||""; o.gender=genderWanted(msg); - const budget=x.match(/(?:under|below|within|budget|upto|up to|kam|less than|₹|rs\.?\s*)(\d[\d,]*)/i); + const budget=x.match(/(?:under|below|within|budget|upto|up to|kam|less than|₹|rs\.?\s*)(\d[\d,]*)/i)||x.match(/(\d[\d,]*)\s*(?:se\s*)?(?:kam|ke andar|under|below)/i); if(budget)o.budgetMax=num(budget[1])??null; o.facilities=[...["wifi","wi fi","ac","non ac","cctv","food","mess","parking"].filter(v=>x.includes(n(v)))]; if(o.category||o.categories.length||loc.city||loc.area||loc.locality||loc.landmark||nearTerm(msg)){ @@ -99,19 +144,21 @@ function normalizeIntent(x:any){ o.referenceIndex=Number.isInteger(Number(o.referenceIndex))?Number(o.referenceIndex):null;o.confidence=Math.max(0,Math.min(1,Number(o.confidence)||0)); o.followUp=!!o.followUp;o.correction=!!o.correction;o.needsClarification=!!o.needsClarification;return o; } -function deterministicLocation(s:string){ +function deterministicLocation(s:string):StrictLocation{ const x=n(s); if(/\b(talwandi|tilwandi|तालवंडी|तलवंडी)\b/i.test(x))return{city:"Kota",locality:"Talwandi"}; + if(/\b(udaipurwati|उदयपुरवाटी)\b/i.test(x))return{city:"Udaipurwati"}; + if(/\b(jhunjhunu|झुंझुनूं|झुंझुनू)\b/i.test(x))return{city:"Jhunjhunu"}; if(/nawalgarh road|nawalgarh rd|nawalgarh|नवलगढ़ रोड|नवलगढ रोड/i.test(x))return{city:"Sikar",area:"Nawalgarh Road"}; if(/piprali road|piprali rd|piprali|पिपराली रोड|पिपराली/i.test(x))return{city:"Sikar",area:"Piprali Road"}; const lm=findLandmark(x);if(lm)return{city:lm.city,landmark:lm.key};return{}; } -function strictLocation(s:string){ - const d=deterministicLocation(s),x=n(s); +function strictLocation(s:string):StrictLocation{ + const normalized=normalizeSearchTerms(s),d=deterministicLocation(normalized),x=n(normalized); if(d.city||d.locality||d.area||d.landmark)return d; - const c=city(s);return c?{city:c}:{}; + const c=city(normalized);return c?{city:c}:{}; } -async function aiUnderstand(msg:string,history:any[]){ +async function aiUnderstand(msg:string,history:any[],cfg:ChatbotRuntimeConfig=DEFAULT_RUNTIME_CONFIG,deadlineAt?:number){ const fallback=offlineIntent(msg,history);if(!GEMINI)return fallback; try{ const h=history.slice(-8).map(x=>`${x.role==="user"?"USER":"ASSISTANT"}: ${String(x.text||"").slice(0,1200)}`).join("\n"); @@ -133,8 +180,7 @@ CURRENT MESSAGE: ${msg} RECENT CONVERSATION: ${h}`; - const r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,{method:"POST",headers:{"Content-Type":"application/json","x-goog-api-key":GEMINI},body:JSON.stringify({contents:[{role:"user",parts:[{text:prompt}]}],generationConfig:{responseMimeType:"application/json",maxOutputTokens:700,thinkingConfig:{thinkingLevel:"medium"}}})}); - if(!r.ok)return fallback;const j=await r.json(),txt=j?.candidates?.[0]?.content?.parts?.map((p:any)=>p.text||"").join("")||"";return normalizeIntent(safeJson(txt)); + const intentTimeout=Math.min(2500,Math.max(0,deadlineAt===undefined?2500:deadlineAt-Date.now()));if(intentTimeout<100)return fallback;const intentController=new AbortController(),intentTimer=setTimeout(()=>intentController.abort(),intentTimeout);try{const r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,{method:"POST",headers:{"Content-Type":"application/json","x-goog-api-key":GEMINI},body:JSON.stringify({contents:[{role:"user",parts:[{text:prompt}]}],generationConfig:{responseMimeType:"application/json",maxOutputTokens:cfg.max_output_tokens,temperature:cfg.temperature,thinkingConfig:{thinkingLevel:"medium"}}}),signal:intentController.signal});if(!r.ok)return fallback;const j=await r.json(),txt=j?.candidates?.[0]?.content?.parts?.map((p:any)=>p.text||"").join("")||"";return normalizeIntent(safeJson(txt))}finally{clearTimeout(intentTimer)} }catch{return fallback} } function mergeIntent(ai:any,msg:string,history:any[]){ @@ -156,10 +202,10 @@ function mergeIntent(ai:any,msg:string,history:any[]){ if(o.categories.length)o.category=o.categories[0]; return o; } -function budgetValue(p:any){return num(p.monthly_rent??p.monthly_fee??p.monthly_charge??p.price)} +function budgetValue(p:any,type?:string){return type==="tiffin"?positiveAmount(p.monthly_plan,p.monthly_fee):positiveAmount(p.monthly_rent,p.monthly_fee,p.monthly_charge,p.price)} function matchesFacility(p:any,wanted:string[]){if(!wanted.length)return true;const z=searchText(p);return wanted.every(f=>z.includes(n(f))||Object.entries(p).some(([k,v])=>n(k).includes(n(f))&&(v===true||String(v).toLowerCase()==="true"||n(v).includes(n(f)))))} -async function aiProperty(msg:string,history:any[],recs:any[],c?:string,near?:string){if(!GEMINI)return{};try{const h=history.slice(-8).map(x=>`${x.role==="user"?"USER":"ASSISTANT"}: ${String(x.text||"")}`).join("\n");const prompt=`You are StudentHubHelp's Ultra Advance AI Assistant. +async function aiProperty(msg:string,history:any[],recs:any[],c?:string,near?:string,cfg:ChatbotRuntimeConfig=DEFAULT_RUNTIME_CONFIG,deadlineAt?:number){if(!GEMINI||!cfg.ai_rerank_enabled)return{};try{const h=history.slice(-8).map(x=>`${x.role==="user"?"USER":"ASSISTANT"}: ${String(x.text||"")}`).join("\n");const prompt=`You are StudentHubHelp's Ultra Advance AI Assistant. Match the user's language: natural Hinglish/Hindi/English. Be warm, concise, clear and impressive. Current user: ${msg} @@ -182,11 +228,11 @@ Rules: - Never recommend listings unless the current message has a property-search intent. - Do not expose internal system/tool details. Return only the natural reply text.`; -const r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,{method:"POST",headers:{"Content-Type":"application/json","x-goog-api-key":GEMINI},body:JSON.stringify({contents:[{role:"user",parts:[{text:prompt}]}],generationConfig:{maxOutputTokens:350,thinkingConfig:{thinkingLevel:"low"}}})});if(!r.ok)return{};const j=await r.json();return{reply:j?.candidates?.[0]?.content?.parts?.map((p:any)=>p.text||"").join("")||""}}catch{return{}}} +const enhancementTimeout=Math.min(8000,Math.max(0,deadlineAt===undefined?8000:deadlineAt-Date.now()));if(enhancementTimeout<250)return{};const enhancementController=new AbortController(),enhancementTimer=setTimeout(()=>enhancementController.abort(),enhancementTimeout);try{const r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,{method:"POST",headers:{"Content-Type":"application/json","x-goog-api-key":GEMINI},body:JSON.stringify({contents:[{role:"user",parts:[{text:prompt}]}],generationConfig:{maxOutputTokens:cfg.max_output_tokens,temperature:cfg.temperature,thinkingConfig:{thinkingLevel:"low"}}}),signal:enhancementController.signal});if(!r.ok)return{};const j=await r.json();return{reply:j?.candidates?.[0]?.content?.parts?.map((p:any)=>p.text||"").join("")||""}}finally{clearTimeout(enhancementTimer)}}catch(e){console.warn("Gemini enhancement skipped; deterministic response retained",e instanceof Error?e.message:String(e));return{}}} async function dbWrite(method:string,path:string,body:any,prefer="return=minimal"){ if(!U||!K)throw Error("database configuration missing"); - const r=await fetch(U+path,{method,headers:{apikey:K,Authorization:`Bearer ${K}`,"Content-Type":"application/json",Prefer:prefer},body:JSON.stringify(body)}); + const r=await fetch(U+path,{method,headers:{apikey:K,Authorization:`Bearer ${K}`,"Content-Type":"application/json",Prefer:prefer},body:JSON.stringify(body),signal:AbortSignal.timeout(4000)}); if(!r.ok)throw Error(`database write ${r.status}: ${(await r.text()).slice(0,200)}`); if(r.status===204)return null; return await r.json().catch(()=>null); @@ -220,7 +266,7 @@ async function logChatTurn(sessionId:string,userMessage:string,payload:any){ } } async function chatResponse(payload:any,init:any,sessionId:string,userMessage:string){ - void logChatTurn(sessionId,userMessage,payload); + EdgeRuntime.waitUntil(logChatTurn(sessionId,userMessage,payload)); return new Response(JSON.stringify(payload),init); } function offlineGeneralReply(msg:string){ @@ -233,7 +279,7 @@ function offlineGeneralReply(msg:string){ if(/hello|hi|hey|namaste|kaise ho|kya haal/i.test(x))return "Namaste 😊 Main yahin hoon—study questions, general knowledge, ideas ya StudentHubHelp listings mein help kar sakta hoon. Aaj kya karna hai?"; return "Main bina external AI API key ke live StudentHubHelp listings, location-based search, booking guidance aur kuch common study/general questions handle kar sakta hoon. Is sawaal ka reliable jawab dene ke liye thoda aur context ya specific question bhej dijiye 😊"; } -async function aiGeneral(msg:string,history:any[]){const offline=offlineGeneralReply(msg);if(!GEMINI||offline!=="Main bina external AI API key ke live StudentHubHelp listings, location-based search, booking guidance aur kuch common study/general questions handle kar sakta hoon. Is sawaal ka reliable jawab dene ke liye thoda aur context ya specific question bhej dijiye 😊")return{reply:offline,webGrounded:false,webSources:[]};try{const h=history.slice(-8).map(x=>({role:x.role==="assistant"?"model":"user",parts:[{text:String(x.text||"").slice(0,1500)}]}));const system=`You are StudentHubHelp's Ultra Advance AI Assistant for students. +async function aiGeneral(msg:string,history:any[],cfg:ChatbotRuntimeConfig=DEFAULT_RUNTIME_CONFIG,deadlineAt?:number){const offline=offlineGeneralReply(msg);if(!GEMINI||offline!=="Main bina external AI API key ke live StudentHubHelp listings, location-based search, booking guidance aur kuch common study/general questions handle kar sakta hoon. Is sawaal ka reliable jawab dene ke liye thoda aur context ya specific question bhej dijiye 😊")return{reply:offline,webGrounded:false,webSources:[]};try{const h=history.slice(-8).map(x=>({role:x.role==="assistant"?"model":"user",parts:[{text:String(x.text||"").slice(0,1500)}]}));const system=`You are StudentHubHelp's Ultra Advance AI Assistant for students. You are a natural conversational, study and general knowledge assistant. Match the user's language and tone: Hindi, Hinglish or English. You can discuss normal life, boredom, tiredness, motivation, hobbies and casual topics in a friendly respectful way. @@ -243,18 +289,25 @@ Do not claim StudentHubHelp property facts here. Property data belongs to the se Do not pretend to know personal facts about the user. Keep normal chat concise and human; for study questions be clear and useful. Current message: ${msg}`; -const body:any={contents:[...h,{role:"user",parts:[{text:system}]}],generationConfig:{maxOutputTokens:700,thinkingConfig:{thinkingLevel:"medium"}}};if(wantsCurrentWeb(msg))body.tools=[{google_search:{}}];const r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,{method:"POST",headers:{"Content-Type":"application/json","x-goog-api-key":GEMINI},body:JSON.stringify(body)});if(!r.ok)return{reply:offlineGeneralReply(msg),webGrounded:false,webSources:[]};const j=await r.json();const reply=j?.candidates?.[0]?.content?.parts?.map((p:any)=>p.text||"").join("")||"";const chunks=j?.candidates?.[0]?.groundingMetadata?.groundingChunks||[];const sources=chunks.map((x:any)=>x?.web?.uri&&{url:x.web.uri,title:x.web.title||x.web.uri}).filter(Boolean).slice(0,5);return{reply:reply||offlineGeneralReply(msg),webGrounded:sources.length>0,webSources:sources}}catch{return{reply:offlineGeneralReply(msg),webGrounded:false,webSources:[]}}} +const body:any={contents:[...h,{role:"user",parts:[{text:system}]}],generationConfig:{maxOutputTokens:cfg.max_output_tokens,temperature:cfg.temperature,thinkingConfig:{thinkingLevel:"medium"}}};if(wantsCurrentWeb(msg))body.tools=[{google_search:{}}];const aiTimeout=Math.min(8000,Math.max(0,deadlineAt===undefined?8000:deadlineAt-Date.now()));if(aiTimeout<250)return{reply:offlineGeneralReply(msg),webGrounded:false,webSources:[]};const aiController=new AbortController(),aiTimer=setTimeout(()=>aiController.abort(),aiTimeout);let r:Response;try{r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,{method:"POST",headers:{"Content-Type":"application/json","x-goog-api-key":GEMINI},body:JSON.stringify(body),signal:aiController.signal})}finally{clearTimeout(aiTimer)};if(!r.ok)return{reply:offlineGeneralReply(msg),webGrounded:false,webSources:[]};const j=await r.json();const reply=j?.candidates?.[0]?.content?.parts?.map((p:any)=>p.text||"").join("")||"";const chunks=j?.candidates?.[0]?.groundingMetadata?.groundingChunks||[];const sources=chunks.map((x:any)=>x?.web?.uri&&{url:x.web.uri,title:x.web.title||x.web.uri}).filter(Boolean).slice(0,5);return{reply:reply||offlineGeneralReply(msg),webGrounded:sources.length>0,webSources:sources}}catch{return{reply:offlineGeneralReply(msg),webGrounded:false,webSources:[]}}} Deno.serve(async(req:Request)=>{ + const requestDeadlineAt=Date.now()+12000; if(req.method==="OPTIONS")return new Response("ok",{headers:H}); if(req.method!=="POST")return new Response(JSON.stringify({error:"POST required"}),{status:405,headers:H}); try{ - if(!allowed(req))return new Response(JSON.stringify({error:"Too many requests. Please try again shortly."}),{status:429,headers:{...H,"Retry-After":"60"}}); const contentLength=Number(req.headers.get("content-length")||0);if(contentLength>32768)return new Response(JSON.stringify({error:"Request is too large."}),{status:413,headers:H}); - const b=await req.json(),msg=String(b?.message||"").trim().slice(0,1500),history=Array.isArray(b?.history)?b.history.slice(-8).map((x:any)=>({role:String(x?.role||"").slice(0,20),text:String(x?.text||"").slice(0,1500)})):[]; + const rawBody=await req.text();if(new TextEncoder().encode(rawBody).byteLength>32768)return new Response(JSON.stringify({error:"Request is too large."}),{status:413,headers:H}); + let b:any;try{b=JSON.parse(rawBody)}catch{return new Response(JSON.stringify({error:"Invalid JSON request."}),{status:400,headers:H})} + const msg:string=String(b?.message||"").trim().slice(0,1500),history:{role:string;text:string}[]=Array.isArray(b?.history)?b.history.slice(-8).map((x:any)=>({role:String(x?.role||"").slice(0,20),text:String(x?.text||"").slice(0,1500)})):[]; if(!msg)return new Response(JSON.stringify({error:"Message is required"}),{status:400,headers:H}); - const sessionId=String(b?.sessionId||crypto.randomUUID()); + const sessionId=String(b?.sessionId||crypto.randomUUID()).slice(0,160); + try{if(!await consumeDualRateLimit("chat",req,sessionId,30,60))return new Response(JSON.stringify({error:"Too many requests. Please try again shortly."}),{status:429,headers:{...H,"Retry-After":"60"}})}catch(e){console.error(e);return new Response(JSON.stringify({error:"Chat service temporarily unavailable."}),{status:503,headers:H})} + const cfg=await getRuntimeConfig(); + if(!cfg.enabled)return new Response(JSON.stringify({reply:cfg.maintenance_message||"StudentHubHelp AI assistant is temporarily unavailable. Please try again shortly.",grounded:false,maintenance:true}),{status:503,headers:H}); if(String(b?.action||"")==="submit_owner_lead"){ + if(!cfg.lead_capture_enabled)return new Response(JSON.stringify({error:"Owner listing requests are temporarily disabled."}),{status:403,headers:H}); + try{if(!await consumeDualRateLimit("owner_lead",req,sessionId,3,600))return new Response(JSON.stringify({error:"Too many owner requests. Please try again later."}),{status:429,headers:{...H,"Retry-After":"600"}})}catch(e){console.error(e);return new Response(JSON.stringify({error:"Owner request service temporarily unavailable."}),{status:503,headers:H})} const lead=b?.ownerLead||{}; const name=String(lead.name||"").trim().replace(/\s+/g," ").slice(0,80); const phone=String(lead.phone||"").trim().slice(0,20); @@ -277,7 +330,7 @@ Deno.serve(async(req:Request)=>{ const requestedFocusId=String(b?.focusPropertyId||""); const requestedFocusType=String(b?.focusPropertyType||""); if((isPhoneIntent(msg)||isDetailIntent(msg))&&requestedFocusId){ - const focusHistory=history.filter(x=>x.role==="user").map(x=>x.text||"").join(" "); + const focusHistory=history.filter((x:{role:string;text:string})=>x.role==="user").map((x:{role:string;text:string})=>x.text||"").join(" "); const focusCombined=focusHistory+" "+msg; const focusType=requestedFocusType||cat(focusCombined)||"hostel"; const focusRows=await multiDirect([focusType],city(focusCombined)||""); @@ -290,34 +343,38 @@ Deno.serve(async(req:Request)=>{ const reply=validPhone?"Ji 😊 **"+fp.name+"** ka listed phone number: **"+validPhone+"**":"Is listing ka valid phone number abhi StudentHubHelp data me available nahi hai."; return await chatResponse({reply,intent:"Property Contact",primaryTopic:label(focusType)+" contact",recommendedProperties:[fp],recommendations:[fp],suggestedFollowUps:["Iske full details batao","Allen ke paas aur options dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg); } - const ar=await aiProperty(msg,history,[fp],focusType,nearTerm(focusCombined)); + const ar=await aiProperty(msg,history,[fp],focusType,nearTerm(focusCombined),cfg,requestDeadlineAt); const reply=ar.reply||("Bilkul 😊 **"+fp.name+"** ki available details neeche di hain."); return await chatResponse({reply,intent:"Property Details",primaryTopic:label(focusType)+" details",recommendedProperties:[fp],recommendations:[fp],suggestedFollowUps:["Iska phone number batao","Allen ke paas aur options dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg); } } if(isAccountSupport(msg))return await chatResponse({reply:accountSupportReply(msg),intent:"Account / Login Support",primaryTopic:"Account access",suggestedFollowUps:["I forgot my password","Google login is not working","Dashboard is not opening","Contact support"],recommendations:[],recommendedProperties:[],grounded:true,sessionId},{headers:H},sessionId,msg); if(isContact(msg)||String(b?.action||"")==="contact_support")return await chatResponse({reply:"Ji bilkul 😊 StudentHubHelp team se contact ke liye:\n\n📧 Email: studenthubhelpp@gmail.com\n📞 Phone: +91 9929718264",intent:"Support / Contact",primaryTopic:"Contact StudentHubHelp",suggestedFollowUps:["Find a hostel","Find a library","Search by area","Back to search"],recommendations:[],recommendedProperties:[],grounded:true,sessionId},{headers:H},sessionId,msg); - const previous=history.filter(x=>x.role==="user").map(x=>x.text||"").join(" "),combined=previous+" "+msg,aiRaw=await aiUnderstand(msg,history),u=mergeIntent(aiRaw,msg,history),explicitCat=cat(msg),nearCats=secondaryCats(msg),aiCats=u.categories||[],c=u.category||explicitCat||(u.followUp?cat(previous):""),propertyMode=u.intent.startsWith("property_")||isPropertyQuery(msg,u.followUp?previous:""),ct=u.city||(u.followUp?city(previous):""),near=u.landmark||u.area||u.locality||(u.nearRelation?nearTerm(msg):""),locationForFilter=strictLocation(msg),action=String(b?.action||u.action||""),shownList=(Array.isArray(b?.shownPropertyIds)?b.shownPropertyIds:[]).map((x:any)=>String(x)),shownIds=new Set(shownList); + const previous=history.filter(x=>x.role==="user").map(x=>x.text||"").join(" "),combined=previous+" "+msg,aiRaw=await aiUnderstand(msg,history,cfg,requestDeadlineAt),u=mergeIntent(aiRaw,msg,history),explicitCat=cat(msg),nearCats=secondaryCats(msg),multiCategoryRequest=/(?:\b(?:and|plus|with)\b|\+|\baur\b|और)/i.test(n(msg)),detectedCats=["hostel","tiffin","library","cafe","bookstore"].filter(type=>new RegExp(type==="hostel"?"hostel|\\bpg\\b|हॉस्टल|पीजी":type==="tiffin"?"tiffin|mess|meal|टिफिन|मेस":type==="library"?"library|reading room|लाइब्रेरी":type==="cafe"?"cafe|कैफे":"bookstore|book shop|stationery|बुकस्टोर","i").test(n(msg))),aiCats=explicitCat&&!multiCategoryRequest?[explicitCat]:[...new Set([...(u.categories||[]),...detectedCats])],c=explicitCat&&!multiCategoryRequest?explicitCat:(u.category||explicitCat||(u.followUp?cat(previous):"")),propertyMode=u.intent.startsWith("property_")||isPropertyQuery(msg,u.followUp?previous:""),ct=u.city||(u.followUp?city(previous):""),near=u.landmark||u.area||u.locality||(u.nearRelation?nearTerm(msg):""),locationForFilter=strictLocation(msg),action=String(b?.action||u.action||""),shownList=(Array.isArray(b?.shownPropertyIds)?b.shownPropertyIds:[]).map((x:any)=>String(x)),shownIds=new Set(shownList); + const clientCoords=coords(b?.clientCoords); + const genericNearbyIntent=/(?:\bnear me\b|\bnearby\b|\bnearest\b|mere paas|mere pass|mere aas paas|aas paas|पास में|मेरे पास)/i.test(n(msg))&&!u.landmark&&!u.area&&!u.locality&&!nearTerm(msg); + if(propertyMode&&genericNearbyIntent&&!clientCoords)return await chatResponse({reply:"Aapke aas-paas listings dikhane ke liye device location ki zaroorat hai. Kripya location access allow karein ya apna landmark/area batayein (jaise Piprali Road ya Allen ke paas).",intent:"Location Required",primaryTopic:"Nearby listing search",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Search by area","Search by city"],grounded:true,sessionId},{headers:H},sessionId,msg); if(action==="search_area"&&!city(msg)&&!nearTerm(msg))return await chatResponse({reply:"Bilkul 😊 Aap kis **area / locality** mein search karna chahte hain? Area ka naam bhejiye, main current requirement ke saath search refine kar dunga.",intent:"Area Refinement",primaryTopic:c?label(c)+" discovery":"Student Services Discovery",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Search by city","Show more options","Contact support"],grounded:true,sessionId},{headers:H},sessionId,msg); if(u.needsClarification&&u.clarificationQuestion&&!u.category&&!u.categories.length&&!u.locality&&!u.area&&!u.landmark)return await chatResponse({reply:u.clarificationQuestion,intent:"Clarification",primaryTopic:"Requirement clarification",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Find a hostel","Find a library","Find a cafe","Find a tiffin service"],grounded:true,sessionId},{headers:H},sessionId,msg); if(u.intent==="property_reference"&&u.referenceIndex){ const all=await multiDirect(["hostel","tiffin","library","cafe","bookstore"],ct); - const ordered=shownList.length?shownList.map(id=>all.find(p=>String(p.id)===id)).filter(Boolean):[]; + const ordered=shownList.length?shownList.map((id:string)=>all.find(p=>String(p.id)===id)).filter(Boolean):[]; const target=ordered[u.referenceIndex-1]||all.find(p=>String(p.id)===shownList[u.referenceIndex-1]); if(target){ const fp=card(target),wantPhone=u.action==="phone"||isPhoneIntent(msg); if(wantPhone){const ph=String(fp.phone||"").trim();const reply=ph&&ph.replace(/\\D/g,"").length>=7?"Ji 😊 **"+fp.name+"** ka listed phone number: **"+ph+"**":"Is listing ka valid phone number abhi StudentHubHelp data me available nahi hai.";return await chatResponse({reply,intent:"Property Contact",primaryTopic:"Referenced property contact",recommendedProperties:[fp],recommendations:[fp],suggestedFollowUps:["Iske full details batao","Aur options dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg)} - const ar=await aiProperty(msg,history,[fp],fp._type,near),reply=ar.reply||("Bilkul 😊 **"+fp.name+"** ki available details neeche di hain.");return await chatResponse({reply,intent:"Property Reference",primaryTopic:"Referenced property",recommendedProperties:[fp],recommendations:[fp],suggestedFollowUps:["Iska phone number batao","Aur options dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg) + const ar=await aiProperty(msg,history,[fp],fp.type,near,cfg,requestDeadlineAt),reply=ar.reply||("Bilkul 😊 **"+fp.name+"** ki available details neeche di hain.");return await chatResponse({reply,intent:"Property Reference",primaryTopic:"Referenced property",recommendedProperties:[fp],recommendations:[fp],suggestedFollowUps:["Iska phone number batao","Aur options dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg) } } - if(/(?:konsa|kaunsa|kaunsi|which|should i|chahiye).{0,35}(book|hostel|pg)|(?:book|hostel|pg).{0,35}(konsa|kaunsa|kaunsi|which|should)/i.test(n(msg))&&!isBookingInfoQuestion(msg)){ - const decisionRows=await multiDirect([c||"hostel"],ct); + if(cfg.comparison_enabled&&(/(?:konsa|kaunsa|kaunsi|which|should i|chahiye).{0,35}(book|hostel|pg)|(?:book|hostel|pg).{0,35}(konsa|kaunsa|kaunsi|which|should)/i.test(n(msg)))&&!isBookingInfoQuestion(msg)){ + const decisionRows=await multiDirect([c||"hostel"],ct,u.budgetMax??undefined,u.area||u.locality||undefined,u.gender||genderWanted(msg)); const decisionLandmark=findLandmark(near||""); - if(decisionLandmark){for(const p of decisionRows){const d=distanceToLandmark(p,decisionLandmark);if(d!==undefined)p._distanceKm=d}} + if(decisionLandmark){for(const p of decisionRows){const d=distanceToLandmark(p,decisionLandmark);if(d!==undefined)p._distanceKm=d;else if(searchText(p).includes(n(decisionLandmark.key)))p._nearbyAreaMatch=true}} let decision=decisionRows.filter(p=>!shownIds.has(String(p.id))); - if(decisionLandmark)decision=decision.filter(p=>n(p.city||"")===n(decisionLandmark.city)&&(p._distanceKm===undefined||p._distanceKm<=10)); - const rankedDecision=decision.map(p=>({...p,_score:rank(p,combined,ct,near)})).sort((a,z)=>z._score-a._score).slice(0,8).map(card); - const ar=await aiProperty(msg,history,rankedDecision,c||"hostel",near); + if(decisionLandmark)decision=decision.filter(p=>n(p.city||"")===n(decisionLandmark.city)&&((p._distanceKm!==undefined&&p._distanceKm<=10)||p._nearbyAreaMatch===true)); + if(u.budgetMax!==null||u.budgetMin!==null)decision=decision.filter(p=>{const v=budgetValue(p,p._type||c);return v!==undefined&&(u.budgetMax===null||v<=u.budgetMax)&&(u.budgetMin===null||v>=u.budgetMin)}); + const rankedDecision=decision.map(p=>({...p,_score:rank(p,combined,ct,near)})).sort((a,z)=>z._score-a._score).slice(0,cfg.max_results).map(card); + const ar=await aiProperty(msg,history,rankedDecision,c||"hostel",near,cfg,requestDeadlineAt); const reply=ar.reply||"Aapki requirement ke hisaab se live options compare kar sakte hain."; return await chatResponse({reply,intent:"Property Comparison",primaryTopic:label(c||"hostel")+" comparison",recommendedProperties:rankedDecision,recommendations:rankedDecision,suggestedFollowUps:["Boys hostel dikhao","Girls hostel dikhao","Budget ke according dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg); } @@ -374,29 +431,41 @@ const ownerDetailsIntent=/(?:owner|malik|maalik|proprietor|manager).{0,35}(?:det } if(isPropertyAdviceQuestion(msg)){const adviceCat=cat(msg)||cat(previous)||"hostel";return await chatResponse({reply:adviceReply(adviceCat),intent:"Property Selection Guidance",primaryTopic:label(adviceCat)+" selection guidance",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Find a property","Search by area","Show more options","Contact support"],grounded:true,sessionId},{headers:H},sessionId,msg);} - if(isBookingInfoQuestion(msg)){const ar=await aiGeneral(`The user asks how to book/reserve a hostel or PG. Explain a practical StudentHubHelp booking process: shortlist suitable live listings, open full details, check price/room type/availability, call owner, confirm terms and visit/verify before paying. Do not invent any property-specific availability or booking facility.`,history);const reply=ar.reply||"Hostel book karne ka simple process: pehle suitable listing shortlist karein, full details me price/room type check karein, owner ko Call karke availability aur terms confirm karein, aur payment se pehle property/owner verify karein.";return await chatResponse({reply,intent:"Booking Guidance",primaryTopic:"Hostel / PG Booking",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Allen ke paas hostel dikhao","Budget ke according hostel dikhao","Boys hostel dikhao","Girls hostel dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg);} - if(!propertyMode){const ar=await aiGeneral(msg,history);const reply=ar.reply||"Bilkul 😊 Main yahin hoon. Aap jo poochna chahein, seedha poochiye—study, ideas, facts ya normal conversation, sab par baat kar sakte hain.";return await chatResponse({reply,intent:"General / Study / Conversation",primaryTopic:"Student AI Assistant",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Ask a study question","Tell me something interesting","Find a property","Contact support"],grounded:true,webGrounded:ar.webGrounded||false,webSources:ar.webSources||[],sessionId},{headers:H},sessionId,msg);} - let rows=await multiDirect(aiCats.length?aiCats:(c?[c]:(nearCats.length?nearCats:["hostel"])),ct); + if(isBookingInfoQuestion(msg)){const ar=await aiGeneral(`The user asks how to book/reserve a hostel or PG. Explain a practical StudentHubHelp booking process: shortlist suitable live listings, open full details, check price/room type/availability, call owner, confirm terms and visit/verify before paying. Do not invent any property-specific availability or booking facility.`,history,cfg,requestDeadlineAt);const reply=ar.reply||"Hostel book karne ka simple process: pehle suitable listing shortlist karein, full details me price/room type check karein, owner ko Call karke availability aur terms confirm karein, aur payment se pehle property/owner verify karein.";return await chatResponse({reply,intent:"Booking Guidance",primaryTopic:"Hostel / PG Booking",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Allen ke paas hostel dikhao","Budget ke according hostel dikhao","Boys hostel dikhao","Girls hostel dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg);} + if(!propertyMode){const ar=await aiGeneral(msg,history,cfg,requestDeadlineAt);const reply=ar.reply||"Bilkul 😊 Main yahin hoon. Aap jo poochna chahein, seedha poochiye—study, ideas, facts ya normal conversation, sab par baat kar sakte hain.";return await chatResponse({reply,intent:"General / Study / Conversation",primaryTopic:"Student AI Assistant",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Ask a study question","Tell me something interesting","Find a property","Contact support"],grounded:true,webGrounded:ar.webGrounded||false,webSources:ar.webSources||[],sessionId},{headers:H},sessionId,msg);} + const comboCategories=[...new Set([...(c?[c]:[]),...aiCats,...nearCats,...(["hostel","tiffin","library","cafe","bookstore"].filter(type=>new RegExp(type==="hostel"?"hostel|\\bpg\\b":type==="tiffin"?"tiffin|mess":type==="library"?"library":type==="cafe"?"cafe":"bookstore","i").test(n(msg))))])]; + const budgetFromText=Number(u.budgetMax)||Number(String(msg.match(/(?:₹|rs\.?|inr)?\s*(\d{1,3}(?:,\d{3})+|\d{4,6})/i)?.[1]||"").replace(/,/g,""))||0; + if(cfg.budget_planner_enabled&&budgetFromText>0&&comboCategories.includes("hostel")&&comboCategories.includes("tiffin")){ + if(!ct)return await chatResponse({reply:"Hostel + tiffin ka exact monthly combo banane ke liye apni city bata dijiye.",intent:"Budget Planning",primaryTopic:"Hostel + Tiffin Combo Budget",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Sikar mein ₹8000 budget","Kota mein ₹8000 budget","Jaipur mein ₹8000 budget"],grounded:true,sessionId},{headers:H},sessionId,msg); + const combo=await planComboBudget(ct,budgetFromText,u.gender||genderWanted(msg)); + if(!combo)return await chatResponse({reply:`${ct} mein ₹${budgetFromText.toLocaleString("en-IN")} ke andar dono categories ke verified monthly prices wala combo nahi mila. Kuch listings mein monthly rent/plan missing hai, isliye main unka total guess nahi karunga. Aap budget badha sakte hain ya hostel aur tiffin alag-alag search kar sakte hain.`,intent:"Budget Planning",primaryTopic:"Hostel + Tiffin Combo Budget",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Hostel alag search karo","Tiffin alag search karo","Budget badhakar try karo"],grounded:true,sessionId},{headers:H},sessionId,msg); + const hostelCard=card(combo.hostel),tiffinCard=card(combo.tiffin),total=combo.total,savings=budgetFromText-total; + const budgetPlan={targetBudget:budgetFromText,city:ct,livingType:"pg_tiffin" as const,items:[{category:"rent" as const,label:`${hostelCard.name} — ₹${combo.hostelCost.toLocaleString("en-IN")}/month`,amount:combo.hostelCost,note:[hostelCard.area,hostelCard.city].filter(Boolean).join(", ")},{category:"food" as const,label:`${tiffinCard.name} — ₹${combo.tiffinCost.toLocaleString("en-IN")}/month`,amount:combo.tiffinCost,note:[tiffinCard.area,tiffinCard.city].filter(Boolean).join(", ")}],totalEstimated:total,savingsOrOverrun:savings,status:savings>300?"under_budget" as const:savings< -300?"over_budget" as const:"balanced" as const,savingsTips:["Prices are taken from active StudentHubHelp listings; confirm inclusions and availability with the provider before payment."],recommendedCombo:`${hostelCard.name} + ${tiffinCard.name}`}; + const reply=`Aapke ₹${budgetFromText.toLocaleString("en-IN")} monthly budget mein ${ct} ka best available combo:\n\n🏠 **Hostel:** ${hostelCard.name} — ₹${combo.hostelCost.toLocaleString("en-IN")}/month\n🍲 **Tiffin:** ${tiffinCard.name} — ₹${combo.tiffinCost.toLocaleString("en-IN")}/month\n\n💰 **Total:** ₹${total.toLocaleString("en-IN")}/month\n**Bachat:** ₹${savings.toLocaleString("en-IN")}/month\n\nYeh prices active listing records se li gayi hain; final inclusions aur availability provider se confirm karein.`; + return await chatResponse({reply,intent:"Combined Budget Plan",primaryTopic:"Hostel + Tiffin Combo Budget",budgetPlan,recommendedProperties:[hostelCard,tiffinCard],recommendations:[hostelCard,tiffinCard],suggestedFollowUps:["Hostel ki full details batao","Tiffin ki full details batao","Aur combo options dikhao"],grounded:true,sessionId,targetCity:ct,targetCategory:"hostel+tiffin"},{headers:H},sessionId,msg); + } + let rows=await multiDirect(aiCats.length?aiCats:(c?[c]:(nearCats.length?nearCats:["hostel"])),ct,u.budgetMax??undefined,u.area||u.locality||undefined,u.gender||genderWanted(msg)); + if(clientCoords)for(const p of rows){const d=distanceKm(clientCoords,p);if(d!==undefined)p._distanceKm=d} const wantedGender=u.gender||genderWanted(msg),wantedArea=u.area?(/nawalgarh/i.test(u.area)?"nawalgarh":/piprali/i.test(u.area)?"piprali":u.area.toLowerCase()):areaWanted(msg); const wantedLocality=n(u.locality||""); - if(wantedGender)rows=rows.filter(p=>{const z=searchText(p);const g=n([p.gender_type,p.gender,p.hostel_type,p.name,p.title,p.description].filter(Boolean).join(" "));return wantedGender==="girls"?/girls|girl|female|women|ladki|ladkiyon|महिला|लड़क/i.test(g):/boys|boy|male|men|ladke|लड़के/i.test(g)}); + if(wantedGender)rows=rows.filter(p=>{const z=searchText(p);const g=n([p.gender_type,p.gender,p.hostel_type,p.type,p.name,p.title,p.description].filter(Boolean).join(" "));return wantedGender==="girls"?/girls|girl|female|women|ladki|ladkiyon|महिला|लड़क/i.test(g):/boys|boy|male|men|ladke|लड़के/i.test(g)}); // Explicit city is a hard boundary; never leak another city into the answer. if(locationForFilter.city)rows=rows.filter(p=>n(p.city||"")===n(locationForFilter.city)); if(wantedArea)rows=rows.filter(p=>searchText(p).includes(n(wantedArea))); if(wantedLocality)rows=rows.filter(p=>searchText(p).includes(wantedLocality)); // An explicit road/locality is also a hard constraint when the user asked for it. if(locationForFilter.area)rows=rows.filter(p=>searchText(p).includes(n(locationForFilter.area))); - if(u.budgetMax!==null||u.budgetMin!==null)rows=rows.filter(p=>{const v=budgetValue(p);if(v===undefined)return false;return (u.budgetMax===null||v<=u.budgetMax)&&(u.budgetMin===null||v>=u.budgetMin)}); + if(u.budgetMax!==null||u.budgetMin!==null)rows=rows.filter(p=>{const v=budgetValue(p,p._type||c);if(v===undefined)return false;return (u.budgetMax===null||v<=u.budgetMax)&&(u.budgetMin===null||v>=u.budgetMin)}); if(u.facilities?.length)rows=rows.filter(p=>matchesFacility(p,u.facilities)); const landmark=u.landmark?findLandmark(u.landmark)||LANDMARKS.find(l=>l.key===u.landmark):findLandmark(near||""); - if(landmark){for(const p of rows){const d=distanceToLandmark(p,landmark);if(d!==undefined)p._distanceKm=d}rows=rows.filter(p=>n(p.city||"")===n(landmark.city)&&(p._distanceKm===undefined||p._distanceKm<=10))} - else if(near){const q=n(near);const exact=rows.filter(p=>searchText(p).includes(q));if(exact.length)rows=exact;else if(!wantedArea)rows=[]} + if(landmark){for(const p of rows){const d=distanceToLandmark(p,landmark);if(d!==undefined)p._distanceKm=d;else if(searchText(p).includes(n(landmark.key)))p._nearbyAreaMatch=true}rows=rows.filter(p=>n(p.city||"")===n(landmark.city)&&((p._distanceKm!==undefined&&p._distanceKm<=10)||p._nearbyAreaMatch===true))} + else if(near){const q=n(normalizeSearchTerms(near));const exact=rows.filter(p=>searchText(p).includes(q));if(exact.length){for(const p of exact)if(p._distanceKm===undefined)p._nearbyAreaMatch=true;rows=exact}else rows=[]} if(nearCats.length){const secondaryRows=await multiDirect(nearCats,ct);for(const p of rows){let best=Infinity,hasCoords=false;for(const q of secondaryRows){const d=distanceKm(p,q);if(d!==undefined){hasCoords=true;best=Math.min(best,d)}else if(p.area&&searchText(q).includes(n(p.area)))best=Math.min(best,1)}if(best<=3){p._nearbyDistanceKm=best;p._nearbyVerified=hasCoords;p._nearbyAreaMatch=!hasCoords}}rows=rows.filter(p=>p._nearbyDistanceKm!==undefined)} rows=rows.filter(p=>!shownIds.has(String(p.id))); - const ranked=rows.map(p=>({...p,_score:rank(p,combined,ct,near)})).sort((a,z)=>z._score-a._score); - let recs=ranked.slice(0,8).map(card); + const ranked=rows.map(p=>({...p,_score:rank(p,combined,ct,near)})).sort((a,z)=>z._score-a._score).slice(0,cfg.max_candidates); + let recs=ranked.slice(0,cfg.max_results).map(card); if(action==="view_details"){const focusId=String(b?.focusPropertyId||""),focusType=String(b?.focusPropertyType||c||"hostel"),focusRows=await multiDirect([focusType],ct),focus=focusRows.find(p=>String(p.id)===focusId);recs=focus?[card(focus)]:recs.slice(0,1)} - const ar=await aiProperty(msg,history,recs,c,near),reply=ar.reply||fallbackReply(c,recs,near,ct),followups=["View full details","Search by area","Show more options","Contact support"]; + const ar=await aiProperty(msg,history,recs,c,near,cfg,requestDeadlineAt),reply=ar.reply||fallbackReply(c,recs,near,ct),followups=["View full details","Search by area","Show more options","Contact support"]; return await chatResponse({reply,intent:"Live Property Search",primaryTopic:c?label(c)+" discovery":"Student Services Discovery",recommendedProperties:recs,recommendations:recs,suggestedFollowUps:followups,grounded:true,sessionId,searchMode:"ultra_advance_live_supabase_ai",targetCity:ct||undefined,targetCategory:c||undefined,nearbyTerm:near||undefined,aiIntent:u.intent,aiConfidence:u.confidence,aiEntities:{category:u.category,categories:u.categories,city:u.city,locality:u.locality,area:u.area,landmark:u.landmark,gender:u.gender,budgetMin:u.budgetMin,budgetMax:u.budgetMax,facilities:u.facilities,referenceIndex:u.referenceIndex}},{headers:H},sessionId,msg); - }catch(e){return new Response(JSON.stringify({reply:"Ji, live search me temporary issue aaya. Please same query dobara bhejiye.",grounded:false,error:"Temporary server error"}),{status:200,headers:H})} + }catch(e){const errorId=crypto.randomUUID();console.error(`[ERROR_REF:${errorId}] Internal chatbot failure`,{message:e instanceof Error?e.message:String(e),stack:e instanceof Error?e.stack:undefined,timestamp:new Date().toISOString()});return new Response(JSON.stringify({success:false,reply:"Server par temporary connectivity issue aaya hai. Kripya 1 minute baad punah prayas karein.",error:"Temporary server error",error_id:errorId,can_retry:true,grounded:false}),{status:500,headers:{...H,"Retry-After":"60"}})} }); \ No newline at end of file diff --git a/supabase/migrations/20261108164241_chatbot_distributed_rate_limit.sql b/supabase/migrations/20261108164241_chatbot_distributed_rate_limit.sql new file mode 100644 index 000000000..26f28c6a8 --- /dev/null +++ b/supabase/migrations/20261108164241_chatbot_distributed_rate_limit.sql @@ -0,0 +1,37 @@ +-- Durable, atomic rate limiting for the StudentHubHelp chatbot. +create table if not exists private.chatbot_rate_limits ( + scope text not null, + key_hash text not null, + window_started_at timestamptz not null, + hit_count integer not null default 0, + primary key (scope, key_hash) +); +alter table private.chatbot_rate_limits enable row level security; +revoke all on table private.chatbot_rate_limits from public, anon, authenticated; +grant all on table private.chatbot_rate_limits to service_role; + +create or replace function public.consume_chatbot_rate_limit( + p_scope text, p_key text, p_limit integer, p_window_seconds integer +) returns boolean +language plpgsql security definer set search_path = '' +as $$ +declare v_count integer; +begin + if p_scope not in ('chat', 'owner_lead') or p_key is null or length(p_key)=0 + or p_limit < 1 or p_limit > 1000 or p_window_seconds < 1 or p_window_seconds > 86400 then + raise exception 'Invalid rate limit arguments'; + end if; + insert into private.chatbot_rate_limits(scope,key_hash,window_started_at,hit_count) + values(p_scope,md5(p_key),now(),1) + on conflict(scope,key_hash) do update + set window_started_at=case when private.chatbot_rate_limits.window_started_at <= now()-make_interval(secs=>p_window_seconds) then now() else private.chatbot_rate_limits.window_started_at end, + hit_count=case when private.chatbot_rate_limits.window_started_at <= now()-make_interval(secs=>p_window_seconds) then 1 else private.chatbot_rate_limits.hit_count+1 end + returning hit_count into v_count; + if mod(extract(epoch from now())::bigint,100)=0 then + delete from private.chatbot_rate_limits where window_started_at < now()-interval '2 days'; + end if; + return v_count <= p_limit; +end; +$$; +revoke all on function public.consume_chatbot_rate_limit(text,text,integer,integer) from public,anon,authenticated; +grant execute on function public.consume_chatbot_rate_limit(text,text,integer,integer) to service_role; diff --git a/widget.js b/widget.js index 3a2922fad..e4ec76383 100644 --- a/widget.js +++ b/widget.js @@ -18,9 +18,11 @@ const SHH_WIDGET=(()=>{ let history=[],speaking=false,busy=false,expanded=false,lastData=null;let sessionId=(window.crypto&&crypto.randomUUID)?crypto.randomUUID():'chat-'+Date.now(); const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));const safeUrl=v=>{try{const u=new URL(String(v));return ['http:','https:'].includes(u.protocol)?u.href:''}catch{return ''}};const icon=t=>({hostel:'🏠',tiffin:'🍱',library:'📚',cafe:'☕',bookstore:'📖'})[t]||'📍'; const strip=t=>String(t??'').replace(/\s{2,}/g,' ').trim();function formatText(t){let x=esc(strip(t));x=x.replace(/\*\*(.+?)\*\*/g,'$1');return x.replace(/\n/g,'
')} + function asksForNearbyLocation(text){return /(?:\bnear me\b|\bnearby\b|\bnearest\b|mere paas|mere pass|mere aas paas|aas paas|पास में|मेरे पास)/i.test(text)&&!/(allen|clc|vibrant|matrix|gurukripa|piprali road|piprali rd|nawalgarh road)/i.test(text)} + function getUserDeviceLocation(){if(!navigator.geolocation)return Promise.resolve(null);return new Promise(resolve=>{try{navigator.geolocation.getCurrentPosition(pos=>resolve({lat:pos.coords.latitude,lng:pos.coords.longitude}),()=>resolve(null),{timeout:5000,maximumAge:60000,enableHighAccuracy:false})}catch{resolve(null)}})} function speak(text){if(!speaking||!window.speechSynthesis)return;window.speechSynthesis.cancel();const u=new SpeechSynthesisUtterance(String(text).replace(/[*_#]/g,' ').replace(/\n+/g,'. '));u.lang='hi-IN';const vs=window.speechSynthesis.getVoices();const v=vs.find(x=>/^hi(-|_)/i.test(x.lang))||vs.find(x=>/^en(-|_)/i.test(x.lang));if(v)u.voice=v;window.speechSynthesis.speak(u)} function addMessage(text,isBot,data){const wrap=document.createElement('div');wrap.style.cssText='align-self:'+(isBot?'stretch':'flex-end')+';max-width:'+(isBot?'100%':'88%')+';width:'+(isBot?'100%':'auto')+';';const bubble=document.createElement('div');bubble.style.cssText='background:'+(isBot?'#fff':'#071a33')+';color:'+(isBot?'#172033':'#fff')+';border:'+(isBot?'1px solid #e1e8f0':'0')+';border-radius:18px;padding:11px 13px;font-size:13px;line-height:1.58;box-shadow:0 3px 12px rgba(15,23,42,.06);word-break:break-word;';if(isBot){const meta=document.createElement('div');meta.style.cssText='display:flex;align-items:center;gap:6px;font-size:9px;font-weight:800;color:#64748b;margin-bottom:5px';meta.innerHTML='● StudentHubHelp AI';bubble.appendChild(meta)}const body=document.createElement('div');body.innerHTML=formatText(text);bubble.appendChild(body);wrap.appendChild(bubble);if(isBot&&data)renderData(wrap,data);messages.appendChild(wrap);messages.scrollTop=messages.scrollHeight;if(isBot)speak(strip(text))} - function renderPropertyCard(p){const card=document.createElement('div');card.className='shh-card';const loc=[p.area,p.city].filter(Boolean).join(', '),phone=p.phone||p.whatsapp,maps=safeUrl(p.mapsUrl),details=safeUrl(p.detailsUrl);const top=document.createElement('div');top.style.cssText='display:flex;justify-content:space-between;gap:8px;align-items:flex-start';top.innerHTML='
'+icon(p.type)+' '+esc(p.name||'Active listing')+'
'+(loc?'
📍 '+esc(loc)+'
':'')+'
'+(p.verified?'✓ Verified Active':'Active')+'';card.appendChild(top);if(p.price){const z=document.createElement('div');z.className='shh-card-price';z.textContent=p.price;card.appendChild(z)}if(p.rating!==undefined&&p.rating!==null){const z=document.createElement('div');z.style.cssText='margin-top:5px;font-size:10px;font-weight:900;color:#8a6415';z.textContent='⭐ '+p.rating;card.appendChild(z)}if(p.address){const z=document.createElement('div');z.style.cssText='margin-top:7px;font-size:10px;color:#475569;line-height:1.45';z.innerHTML='Address: '+esc(p.address);card.appendChild(z)}if(p.description){const z=document.createElement('div');z.className='shh-card-desc';z.textContent=strip(p.description);card.appendChild(z)}const vals=Array.isArray(p.highlights)?p.highlights.map(strip).filter(Boolean):[];if(vals.length){const h=document.createElement('div');h.className='shh-tags';vals.slice(0,6).forEach(x=>{const z=document.createElement('span');z.className='shh-tag';z.textContent=x;h.appendChild(z)});card.appendChild(h)}const acts=document.createElement('div');acts.className='shh-actions';if(phone){const a=document.createElement('a');a.className='call';a.href='tel:'+encodeURIComponent(phone);a.textContent='📞 Call Owner';acts.appendChild(a)}if(maps){const a=document.createElement('a');a.className='map';a.href=maps;a.target='_blank';a.rel='noopener noreferrer';a.textContent='🗺️ Google Maps';acts.appendChild(a)}if(details){const a=document.createElement('a');a.className='view';a.href=details;a.textContent='View';acts.appendChild(a)}if(acts.children.length)card.appendChild(acts);return card} + function renderPropertyCard(p){const card=document.createElement('div');card.className='shh-card';const loc=[p.area,p.city].filter(Boolean).join(', '),phone=p.phone||p.whatsapp,maps=safeUrl(p.mapsUrl),details=safeUrl(p.detailsUrl);const top=document.createElement('div');top.style.cssText='display:flex;justify-content:space-between;gap:8px;align-items:flex-start';top.innerHTML='
'+icon(p.type)+' '+esc(p.name||'Active listing')+'
'+(loc?'
📍 '+esc(loc)+'
':'')+'
'+(p.verified?'✓ Verified Active':'Active')+'';card.appendChild(top);if(p.distanceLabel){const z=document.createElement("div");z.style.cssText="margin-top:5px;font-size:10px;font-weight:700;color:#475569;line-height:1.4";z.textContent=p.distanceLabel;card.appendChild(z)}if(p.price){const z=document.createElement('div');z.className='shh-card-price';z.textContent=p.price;card.appendChild(z)}if(p.rating!==undefined&&p.rating!==null){const z=document.createElement('div');z.style.cssText='margin-top:5px;font-size:10px;font-weight:900;color:#8a6415';z.textContent='⭐ '+p.rating;card.appendChild(z)}if(p.address){const z=document.createElement('div');z.style.cssText='margin-top:7px;font-size:10px;color:#475569;line-height:1.45';z.innerHTML='Address: '+esc(p.address);card.appendChild(z)}if(p.description){const z=document.createElement('div');z.className='shh-card-desc';z.textContent=strip(p.description);card.appendChild(z)}const vals=Array.isArray(p.highlights)?p.highlights.map(strip).filter(Boolean):[];if(vals.length){const h=document.createElement('div');h.className='shh-tags';vals.slice(0,6).forEach(x=>{const z=document.createElement('span');z.className='shh-tag';z.textContent=x;h.appendChild(z)});card.appendChild(h)}const acts=document.createElement('div');acts.className='shh-actions';if(phone){const a=document.createElement('a');a.className='call';a.href='tel:'+encodeURIComponent(phone);a.textContent='📞 Call Owner';acts.appendChild(a)}if(maps){const a=document.createElement('a');a.className='map';a.href=maps;a.target='_blank';a.rel='noopener noreferrer';a.textContent='🗺️ Google Maps';acts.appendChild(a)}if(details){const a=document.createElement('a');a.className='view';a.href=details;a.textContent='View';acts.appendChild(a)}if(acts.children.length)card.appendChild(acts);return card} function renderData(wrap,data){const recs=Array.isArray(data?.recommendedProperties)?data.recommendedProperties:(Array.isArray(data?.recommendations)?data.recommendations:[]);if(recs.length){const title=document.createElement('div');title.style.cssText='margin-top:9px;font-size:11px;font-weight:900;color:#1e293b';title.textContent='✨ Relevant StudentHubHelp options';wrap.appendChild(title);recs.slice(0,6).forEach(p=>wrap.appendChild(renderPropertyCard(p)))}const chips=Array.isArray(data?.suggestedFollowUps)?data.suggestedFollowUps.filter(Boolean).slice(0,4):[];if(chips.length){const row=document.createElement('div');row.className='shh-follow';chips.forEach(q=>{const b=document.createElement('button');b.type='button';b.textContent=q;b.onclick=()=>{const action=q==='View full details'?'view_details':q==='Search by area'?'search_area':q==='Show more options'?'show_more':q==='Contact support'?'contact_support':'';const first=recs[0];sendText(q,{action,focusPropertyId:first?.id||'',focusPropertyType:first?.type||'',shownPropertyIds:recs.map(x=>x.id)})};row.appendChild(b)});wrap.appendChild(row)}} function showAppointmentForm(){ const wrap=document.createElement('div');wrap.style.cssText='margin-top:9px;background:#fff;border:1px solid #dbe5ef;border-radius:17px;padding:12px;box-shadow:0 7px 22px rgba(15,23,42,.07);'; @@ -34,7 +36,7 @@ const SHH_WIDGET=(()=>{ }; messages.appendChild(wrap);messages.scrollTop=messages.scrollHeight; } - async function sendText(value,meta={}){const text=String(value??'').trim()||field.value.trim();if(!text||busy)return;if(/\bappointment\b|\bmeeting\b|\bconsultation\b/i.test(text)&&/(book|schedule|chahiye|chahta|chahti|karna|karo|fix|set|request|meet)/i.test(text)){addMessage(text,false);showAppointmentForm();return}field.value='';addMessage(text,false);busy=true;document.getElementById('shh-status').textContent='Thinking · checking live listings…';try{const ctl=new AbortController(),timer=setTimeout(()=>ctl.abort(),18000);let r;try{r=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json',apikey:KEY,Authorization:'Bearer '+KEY},body:JSON.stringify({message:text,history:history.slice(-8),sessionId,action:meta.action||'',focusPropertyId:meta.focusPropertyId||'',focusPropertyType:meta.focusPropertyType||'',shownPropertyIds:meta.shownPropertyIds||[]}),signal:ctl.signal})}finally{clearTimeout(timer)}if(!r.ok)throw Error('Chat request failed ('+r.status+')');const d=await r.json();lastData=d;const reply=d?.reply||'Ji, main StudentHubHelp par aapki help karta hoon.';addMessage(reply,true,d);history.push({role:'user',text},{role:'assistant',text:reply});if(history.length>16)history=history.slice(-16);const topic=document.getElementById('shh-topic');if(d?.primaryTopic){topic.textContent='Focus · '+strip(d.primaryTopic);topic.style.display='block'}document.getElementById('shh-status').textContent='Ready · Live listing support'}catch(e){addMessage(e?.name==='AbortError'?'Response thoda slow ho gaya. Please try again.':'Ji, live AI service me temporary issue aaya. Please try again.',true);document.getElementById('shh-status').textContent='Ready · Please try again'}finally{busy=false}} + async function sendText(value,meta={}){const text=String(value??'').trim()||field.value.trim();if(!text||busy)return;if(/\bappointment\b|\bmeeting\b|\bconsultation\b/i.test(text)&&/(book|schedule|chahiye|chahta|chahti|karna|karo|fix|set|request|meet)/i.test(text)){addMessage(text,false);showAppointmentForm();return}field.value='';addMessage(text,false);busy=true;document.getElementById('shh-status').textContent='Thinking · checking live listings…';try{const clientCoords=asksForNearbyLocation(text)?await getUserDeviceLocation():null;const ctl=new AbortController(),timer=setTimeout(()=>ctl.abort(),15000);let r;try{r=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json',apikey:KEY,Authorization:'Bearer '+KEY},body:JSON.stringify({message:text,history:history.slice(-8),sessionId,clientCoords,action:meta.action||'',focusPropertyId:meta.focusPropertyId||'',focusPropertyType:meta.focusPropertyType||'',shownPropertyIds:meta.shownPropertyIds||[]}),signal:ctl.signal})}finally{clearTimeout(timer)}const d=await r.json().catch(()=>({}));if(!r.ok){const err=new Error('Chat request failed ('+r.status+')');err.payload=d;err.status=r.status;throw err}lastData=d;const reply=d?.reply||'Ji, main StudentHubHelp par aapki help karta hoon.';addMessage(reply,true,d);history.push({role:'user',text},{role:'assistant',text:reply});if(history.length>16)history=history.slice(-16);const topic=document.getElementById('shh-topic');if(d?.primaryTopic){topic.textContent='Focus · '+strip(d.primaryTopic);topic.style.display='block'}document.getElementById('shh-status').textContent='Ready · Live listing support'}catch(e){const payload=e?.payload||{};if(payload.error_id)console.error('[StudentHubHelp chatbot error]',payload.error_id);addMessage(e?.name==='AbortError'?'Response thoda slow ho gaya. Please try again.':(payload.reply||'Ji, live AI service me temporary issue aaya. Please try again.'),true);if(payload.can_retry||e?.name==='AbortError'||Number(e?.status)>=500){const retryWrap=document.createElement('div');retryWrap.style.cssText='display:flex;justify-content:flex-start;margin:4px 0 8px';const retry=document.createElement('button');retry.type='button';retry.textContent='↻ Retry';retry.style.cssText='border:1px solid #cbd5e1;border-radius:999px;padding:7px 13px;background:#fff;color:#334155;font-size:12px;font-weight:800;cursor:pointer';retry.onclick=()=>{retry.disabled=true;retry.textContent='Retrying…';sendText(text)};retryWrap.appendChild(retry);messages.appendChild(retryWrap);messages.scrollTop=messages.scrollHeight}document.getElementById('shh-status').textContent='Ready · Please try again'}finally{busy=false}} const tts=header.querySelector('#shh-tts');tts.onclick=()=>{speaking=!speaking;tts.textContent=speaking?'🔊':'🔇';if(!speaking&&window.speechSynthesis)window.speechSynthesis.cancel()};header.querySelector('#shh-max').onclick=()=>{expanded=!expanded;root.classList.toggle('shh-expanded',expanded);header.querySelector('#shh-max').textContent=expanded?'↙':'↗'};header.querySelector('#shh-close-btn').onclick=()=>{win.style.display='none';launcher.style.display='flex'};launcher.onclick=()=>{win.style.display='flex';launcher.style.display='none';messages.scrollTop=messages.scrollHeight};input.querySelector('#shh-send-btn').onclick=()=>sendText(); const field=input.querySelector('#shh-input');field.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();sendText()}});input.querySelector('#shh-clear').onclick=()=>{history=[];lastData=null;messages.innerHTML='';document.getElementById('shh-topic').style.display='none';addMessage(getWelcome(),true)}; function getWelcome(){return'Namaste! 🙏 Main StudentHubHelp ka **Ultra Advance AI Assistant** hoon.\n\nAap jo bhi requirement batayenge—Hostel, Tiffin, Library, Cafe, Bookstore, area, budget ya nearby location—main aapki requirement samajhkar live StudentHubHelp listings mein relevant options dhoondunga.\n\n**Aapko kis cheez ki jankari chahiye?**'}