AuthorBot commited on
Commit
2b2591e
·
1 Parent(s): 7e0e662

Fix chat session init crash and improve embed reliability

Browse files
app/admin/templates/admin.html CHANGED
@@ -596,43 +596,32 @@
596
 
597
  <!-- ═══ EMBED CODE ═══ -->
598
  <div class="page" id="page-embed">
599
- <div class="page-header"><h1 class="page-title">🚀 Embed & Test Widget</h1><p class="page-sub">Get your chatbot live on your website in under 2 minutes</p></div>
600
-
601
- <!-- Step 1: Test it live -->
602
- <div class="card" style="border:2px solid var(--accent);margin-bottom:16px;">
603
- <div class="card-title" style="color:var(--accent);">Step 1 — Test Your Widget Live</div>
604
- <div class="card-sub">Preview how the chatbot will look and work before adding to your site.</div>
605
- <div style="display:flex;gap:12px;flex-wrap:wrap;margin-top:16px;">
606
- <button class="btn btn-primary" onclick="openWidgetPreview('midnight')">🌙 Test Dark Theme</button>
607
- <button class="btn btn-secondary" onclick="openWidgetPreview('minimal')">☀️ Test Light Theme</button>
608
- <button class="btn btn-secondary" onclick="openWidgetPreview('ocean')">🌊 Test Ocean Theme</button>
609
- </div>
610
- <div style="margin-top:12px;padding:12px;background:var(--surface2);border-radius:8px;font-size:13px;color:var(--muted);">
611
- ⚠️ <strong>Note:</strong> You need an active <strong>Subscription Token</strong> to test the real chatbot. Ask your SuperAdmin to grant access. The preview above shows the UI without a real token.
612
  </div>
613
  </div>
614
 
615
- <!-- Step 2: Subscription Status + Token -->
616
  <div class="card" style="margin-bottom:16px;">
617
- <div class="card-title">Step 2Your Subscription Status</div>
618
- <div class="card-sub">Your embed script is auto-generated with your secure token already included.</div>
619
  <div id="embed-token-status" style="margin-top:14px;padding:12px 16px;background:var(--surface2);border-radius:8px;font-size:13px;">
620
  <span style="color:var(--muted);">🔄 Checking subscription...</span>
621
  </div>
622
- <div style="margin-top:10px;font-size:13px;color:var(--muted);">
623
- 📋 <strong>Your Author ID:</strong> <code style="background:var(--surface2);padding:3px 8px;border-radius:6px;font-size:12px;" id="slug-display">Loading...</code>
624
- </div>
625
- </div>
626
-
627
- <!-- Step 3: Embed code -->
628
- <div class="card" style="margin-bottom:16px;">
629
- <div class="card-title">Step 3 — Copy Your Embed Script</div>
630
- <div class="card-sub">Paste this snippet just before the <code>&lt;/body&gt;</code> tag on every page of your website. Your token is already embedded — just copy and paste.</div>
631
  <div style="position:relative;margin-top:14px;">
632
- <pre style="background:#0d1117;color:#e6edf3;border:1px solid var(--border);border-radius:10px;padding:20px;font-size:12px;overflow-x:auto;line-height:1.7;white-space:pre-wrap;word-break:break-all;" id="embed-code">⏳ Loading your personalised embed script...</pre>
633
- <button class="btn btn-primary btn-sm" style="position:absolute;top:12px;right:12px;" onclick="copyEmbedCode()">📋 Copy</button>
634
  </div>
635
- <div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap;">
636
  <label style="font-size:13px;font-weight:600;color:var(--muted);">Theme:</label>
637
  <select class="form-input" style="width:auto;margin:0;padding:4px 10px;font-size:13px;" id="embed-theme" onchange="generateEmbedCode()">
638
  <option value="midnight">Midnight (Dark)</option>
@@ -647,23 +636,30 @@
647
  <option value="bottom-left">Bottom Left</option>
648
  </select>
649
  </div>
650
- <div style="margin-top:12px;padding:10px 14px;background:var(--surface2);border-radius:8px;font-size:12px;color:var(--muted);">
651
- 🔒 <strong>Security note:</strong> Your token is cryptographically signed and tied to your account. Do not share it publicly or commit it to version control.
652
- </div>
653
  </div>
654
 
655
-
656
- <!-- Platform instructions -->
657
- <div class="card">
658
- <div class="card-title">Platform-Specific Instructions</div>
659
  <div style="display:flex;gap:8px;flex-wrap:wrap;margin:14px 0;">
660
  <button class="btn btn-secondary btn-sm" onclick="showPlatform('wordpress')" id="plat-wordpress">WordPress</button>
661
  <button class="btn btn-secondary btn-sm" onclick="showPlatform('squarespace')" id="plat-squarespace">Squarespace</button>
662
  <button class="btn btn-secondary btn-sm" onclick="showPlatform('webflow')" id="plat-webflow">Webflow</button>
663
- <button class="btn btn-secondary btn-sm" onclick="showPlatform('html')" id="plat-html">Plain HTML</button>
664
  <button class="btn btn-secondary btn-sm" onclick="showPlatform('shopify')" id="plat-shopify">Shopify</button>
 
 
 
 
 
 
 
 
 
 
 
665
  </div>
666
- <div id="platform-instructions" style="font-size:14px;line-height:1.8;color:var(--text2);"></div>
667
  </div>
668
  </div>
669
 
@@ -1207,87 +1203,120 @@ async function loadTokenUsage() {
1207
 
1208
  /* ── EMBED CODE ── */
1209
  let _cachedEmbedToken = null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1210
  async function generateEmbedCode() {
1211
  const origin = window.location.origin;
1212
  const theme = document.getElementById('embed-theme')?.value || 'midnight';
1213
  const pos = document.getElementById('embed-pos')?.value || 'bottom-right';
1214
- document.getElementById('slug-display').textContent = authorSlug;
1215
 
1216
- // Show loading state
1217
  const codeEl = document.getElementById('embed-code');
1218
  const statusEl = document.getElementById('embed-token-status');
 
1219
 
1220
  if (!_cachedEmbedToken) {
1221
- if (codeEl) codeEl.textContent = '⏳ Fetching your subscription token...';
1222
  try {
1223
  const data = await apiGet(`/admin/${authorSlug}/embed-token`);
1224
  _cachedEmbedToken = data.token;
1225
- // Show subscription status
1226
  if (statusEl) {
1227
  const d = data.days_remaining || 0;
1228
  const t = (data.tokens_remaining || 0).toLocaleString();
1229
- statusEl.innerHTML = `<span style="color:var(--green);font-weight:600;">✅ Active subscription</span> &mdash; ${d} days remaining &bull; ${t} tokens left`;
1230
  }
1231
  } catch(e) {
1232
- if (codeEl) codeEl.textContent = '❌ ' + (e.message || 'No active subscription found. Ask your SuperAdmin to grant access.');
1233
- if (statusEl) statusEl.innerHTML = `<span style="color:var(--red);">❌ No active subscription — contact your administrator</span>`;
 
1234
  return;
1235
  }
1236
  }
1237
 
1238
  const tok = _cachedEmbedToken;
1239
- codeEl.textContent =
1240
- '<!-- AuthorBot Chat Widget -->\n' +
1241
- '<scr' + 'ipt>\n' +
1242
- ' window.AuthorBotConfig = {\n' +
1243
- ' token: "' + tok + '",\n' +
1244
- ' slug: "' + authorSlug + '",\n' +
1245
- ' apiBase: "' + origin + '/api",\n' +
1246
- ' theme: "' + theme + '",\n' +
1247
- ' position: "' + pos + '",\n' +
1248
- ' autoOpenDelay: 0\n' +
1249
- ' };\n' +
1250
- '<\/scr' + 'ipt>\n' +
1251
- '<scr' + 'ipt src="' + origin + '/static/widget.js" async><\/scr' + 'ipt>';
1252
  }
 
1253
  function copyEmbedCode() {
1254
- navigator.clipboard.writeText(document.getElementById('embed-code').textContent);
1255
- toast('Embed code copied to clipboard!', 'success');
 
 
 
 
 
1256
  }
1257
- function openWidgetPreview(theme) {
1258
- const url = `${window.location.origin}/api/widget/${authorSlug}?theme=${theme}&position=bottom-right&autoOpen=1`;
 
 
 
 
1259
  window.open(url, '_blank', 'width=500,height=720,resizable=yes');
1260
  }
 
1261
  const PLATFORM_GUIDES = {
1262
- wordpress: `<strong>WordPress Installation:</strong><br>
1263
- 1. In your WordPress dashboard, go to <strong>Appearance Theme Editor</strong><br>
1264
- 2. Open your theme's <strong>footer.php</strong> file<br>
1265
- 3. Paste the embed code just before the <code>&lt;/body&gt;</code> tag<br>
1266
- 4. Save changes — the widget appears on all pages immediately<br><br>
1267
- <em>Alternative: Use a plugin like <strong>Insert Headers and Footers</strong> to add code without editing theme files.</em>`,
1268
- squarespace: `<strong>Squarespace Installation:</strong><br>
1269
  1. Go to <strong>Settings → Advanced → Code Injection</strong><br>
1270
- 2. Paste the embed code into the <strong>Footer</strong> section<br>
1271
- 3. Click <strong>Save</strong> — widget appears on all pages`,
1272
- webflow: `<strong>Webflow Installation:</strong><br>
1273
- 1. Open your Webflow project settings<br>
1274
- 2. Go to <strong>Custom Code Footer Code</strong><br>
1275
- 3. Paste the embed code and click <strong>Save</strong><br>
1276
- 4. Publish your site`,
1277
- html: `<strong>Plain HTML Installation:</strong><br>
1278
- 1. Open your HTML file in a text editor<br>
1279
- 2. Find the closing <code>&lt;/body&gt;</code> tag<br>
1280
- 3. Paste the embed code just before it:<br><br>
1281
- <pre style="background:#0d1117;color:#e6edf3;padding:12px;border-radius:8px;font-size:12px;"> ...your page content...
1282
- &lt;!-- AuthorBot Widget --&gt;
1283
- &lt;script&gt;...&lt;/script&gt;
1284
- &lt;script src="...widget.js"&gt;&lt;/script&gt;
1285
- &lt;/body&gt;</pre>`,
1286
- shopify: `<strong>Shopify Installation:</strong><br>
1287
- 1. In Shopify admin, go to <strong>Online Store → Themes → Actions → Edit Code</strong><br>
1288
- 2. Open the <strong>Layout/theme.liquid</strong> file<br>
1289
- 3. Find the <code>&lt;/body&gt;</code> tag and paste the embed code just before it<br>
1290
- 4. Click <strong>Save</strong>`
1291
  };
1292
  function showPlatform(name) {
1293
  document.querySelectorAll('[id^="plat-"]').forEach(b => b.classList.remove('btn-primary'));
 
596
 
597
  <!-- ═══ EMBED CODE ═══ -->
598
  <div class="page" id="page-embed">
599
+ <div class="page-header">
600
+ <h1 class="page-title">Add Chatbot to Your Website</h1>
601
+ <p class="page-sub">Copy one line, paste it in your website footer — your chatbot goes live instantly</p>
602
+ </div>
603
+
604
+ <!-- Live preview notice -->
605
+ <div class="card" style="border:2px solid var(--green);margin-bottom:16px;background:linear-gradient(135deg,rgba(34,197,94,0.08),transparent);">
606
+ <div class="card-title" style="color:var(--green);"> See it working right now</div>
607
+ <div class="card-sub">Look at the <strong>💬 chat bubble in the bottom corner of this page</strong>. That is exactly what your readers will see on your website.</div>
608
+ <div id="embed-live-status" style="margin-top:12px;padding:12px 16px;background:var(--surface2);border-radius:8px;font-size:13px;">
609
+ <span style="color:var(--muted);">🔄 Loading your chatbot preview...</span>
 
 
610
  </div>
611
  </div>
612
 
613
+ <!-- Step 1: Copy code -->
614
  <div class="card" style="margin-bottom:16px;">
615
+ <div class="card-title">Step 1Copy this one line</div>
616
+ <div class="card-sub">Click the copy button. Your secure access token is already included — nothing else to configure.</div>
617
  <div id="embed-token-status" style="margin-top:14px;padding:12px 16px;background:var(--surface2);border-radius:8px;font-size:13px;">
618
  <span style="color:var(--muted);">🔄 Checking subscription...</span>
619
  </div>
 
 
 
 
 
 
 
 
 
620
  <div style="position:relative;margin-top:14px;">
621
+ <pre style="background:#0d1117;color:#e6edf3;border:1px solid var(--border);border-radius:10px;padding:20px;font-size:12px;overflow-x:auto;line-height:1.7;white-space:pre-wrap;word-break:break-all;" id="embed-code">⏳ Preparing your embed code...</pre>
622
+ <button class="btn btn-primary btn-sm" style="position:absolute;top:12px;right:12px;" onclick="copyEmbedCode()">📋 Copy Code</button>
623
  </div>
624
+ <div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
625
  <label style="font-size:13px;font-weight:600;color:var(--muted);">Theme:</label>
626
  <select class="form-input" style="width:auto;margin:0;padding:4px 10px;font-size:13px;" id="embed-theme" onchange="generateEmbedCode()">
627
  <option value="midnight">Midnight (Dark)</option>
 
636
  <option value="bottom-left">Bottom Left</option>
637
  </select>
638
  </div>
 
 
 
639
  </div>
640
 
641
+ <!-- Step 2: Paste -->
642
+ <div class="card" style="margin-bottom:16px;">
643
+ <div class="card-title">Step 2 — Paste it on your website</div>
644
+ <div class="card-sub">Paste the code in your website's <strong>footer</strong> section. Every platform has a place for this — pick yours below.</div>
645
  <div style="display:flex;gap:8px;flex-wrap:wrap;margin:14px 0;">
646
  <button class="btn btn-secondary btn-sm" onclick="showPlatform('wordpress')" id="plat-wordpress">WordPress</button>
647
  <button class="btn btn-secondary btn-sm" onclick="showPlatform('squarespace')" id="plat-squarespace">Squarespace</button>
648
  <button class="btn btn-secondary btn-sm" onclick="showPlatform('webflow')" id="plat-webflow">Webflow</button>
649
+ <button class="btn btn-secondary btn-sm" onclick="showPlatform('wix')" id="plat-wix">Wix</button>
650
  <button class="btn btn-secondary btn-sm" onclick="showPlatform('shopify')" id="plat-shopify">Shopify</button>
651
+ <button class="btn btn-secondary btn-sm" onclick="showPlatform('html')" id="plat-html">Other / HTML</button>
652
+ </div>
653
+ <div id="platform-instructions" style="font-size:14px;line-height:1.8;color:var(--text2);padding:14px;background:var(--surface2);border-radius:8px;"></div>
654
+ </div>
655
+
656
+ <!-- Step 3: Done -->
657
+ <div class="card">
658
+ <div class="card-title">Step 3 — You're done!</div>
659
+ <div class="card-sub">Visit your website and look for the 💬 bubble in the corner. Click it to test a conversation. Come back here anytime to change the theme or copy the code again.</div>
660
+ <div style="margin-top:14px;display:flex;gap:12px;flex-wrap:wrap;">
661
+ <button class="btn btn-primary" onclick="openWidgetPreview('midnight')">Open Full-Screen Preview</button>
662
  </div>
 
663
  </div>
664
  </div>
665
 
 
1203
 
1204
  /* ── EMBED CODE ── */
1205
  let _cachedEmbedToken = null;
1206
+
1207
+ function buildEmbedSnippet(origin, token, slug, theme, pos) {
1208
+ return (
1209
+ '<scr' + 'ipt src="' + origin + '/static/widget.js" defer\n' +
1210
+ ' data-authorbot-token="' + token + '"\n' +
1211
+ ' data-authorbot-slug="' + slug + '"\n' +
1212
+ ' data-authorbot-api="' + origin + '/api"\n' +
1213
+ ' data-authorbot-theme="' + theme + '"\n' +
1214
+ ' data-authorbot-position="' + pos + '"><\/scr' + 'ipt>'
1215
+ );
1216
+ }
1217
+
1218
+ function mountLiveWidgetPreview(origin, token, theme, pos) {
1219
+ document.getElementById('ab-root')?.remove();
1220
+ window.__authorBotMounted = false;
1221
+ document.getElementById('authorbot-preview-script')?.remove();
1222
+
1223
+ const script = document.createElement('script');
1224
+ script.id = 'authorbot-preview-script';
1225
+ script.src = origin + '/static/widget.js';
1226
+ script.defer = true;
1227
+ script.setAttribute('data-authorbot-token', token);
1228
+ script.setAttribute('data-authorbot-slug', authorSlug);
1229
+ script.setAttribute('data-authorbot-api', origin + '/api');
1230
+ script.setAttribute('data-authorbot-theme', theme);
1231
+ script.setAttribute('data-authorbot-position', pos);
1232
+ document.body.appendChild(script);
1233
+
1234
+ const liveStatus = document.getElementById('embed-live-status');
1235
+ if (liveStatus) {
1236
+ liveStatus.innerHTML = '<span style="color:var(--green);font-weight:600;">✅ Your chatbot is live on this page — click the 💬 bubble to try it!</span>';
1237
+ }
1238
+ }
1239
+
1240
  async function generateEmbedCode() {
1241
  const origin = window.location.origin;
1242
  const theme = document.getElementById('embed-theme')?.value || 'midnight';
1243
  const pos = document.getElementById('embed-pos')?.value || 'bottom-right';
 
1244
 
 
1245
  const codeEl = document.getElementById('embed-code');
1246
  const statusEl = document.getElementById('embed-token-status');
1247
+ const liveStatus = document.getElementById('embed-live-status');
1248
 
1249
  if (!_cachedEmbedToken) {
1250
+ if (codeEl) codeEl.textContent = '⏳ Preparing your embed code...';
1251
  try {
1252
  const data = await apiGet(`/admin/${authorSlug}/embed-token`);
1253
  _cachedEmbedToken = data.token;
 
1254
  if (statusEl) {
1255
  const d = data.days_remaining || 0;
1256
  const t = (data.tokens_remaining || 0).toLocaleString();
1257
+ statusEl.innerHTML = `<span style="color:var(--green);font-weight:600;">✅ Your chatbot is active</span> ${d} days remaining, ${t} tokens left`;
1258
  }
1259
  } catch(e) {
1260
+ if (codeEl) codeEl.textContent = '❌ ' + (e.message || 'Your account is not active yet. Please contact support.');
1261
+ if (statusEl) statusEl.innerHTML = `<span style="color:var(--red);">❌ Not active yetplease contact your administrator to activate your chatbot</span>`;
1262
+ if (liveStatus) liveStatus.innerHTML = `<span style="color:var(--red);">❌ Cannot preview — your account needs to be activated first</span>`;
1263
  return;
1264
  }
1265
  }
1266
 
1267
  const tok = _cachedEmbedToken;
1268
+ if (codeEl) codeEl.textContent = buildEmbedSnippet(origin, tok, authorSlug, theme, pos);
1269
+ mountLiveWidgetPreview(origin, tok, theme, pos);
1270
+ if (!document.getElementById('platform-instructions')?.innerHTML) showPlatform('wordpress');
 
 
 
 
 
 
 
 
 
 
1271
  }
1272
+
1273
  function copyEmbedCode() {
1274
+ const text = document.getElementById('embed-code').textContent;
1275
+ if (text.startsWith('⏳') || text.startsWith('')) {
1276
+ toast('Please wait for your embed code to load first', 'error');
1277
+ return;
1278
+ }
1279
+ navigator.clipboard.writeText(text);
1280
+ toast('Copied! Now paste it in your website footer.', 'success');
1281
  }
1282
+
1283
+ async function openWidgetPreview(theme) {
1284
+ if (!_cachedEmbedToken) await generateEmbedCode();
1285
+ if (!_cachedEmbedToken) return;
1286
+ const pos = document.getElementById('embed-pos')?.value || 'bottom-right';
1287
+ const url = `${window.location.origin}/api/widget/${authorSlug}?token=${encodeURIComponent(_cachedEmbedToken)}&slug=${encodeURIComponent(authorSlug)}&theme=${theme}&position=${pos}&autoOpen=1`;
1288
  window.open(url, '_blank', 'width=500,height=720,resizable=yes');
1289
  }
1290
+
1291
  const PLATFORM_GUIDES = {
1292
+ wordpress: `<strong>WordPress — 2 minutes:</strong><br>
1293
+ 1. Install the free plugin <strong>"WPCode"</strong> or <strong>"Insert Headers and Footers"</strong><br>
1294
+ 2. Go to the plugin settings → <strong>Footer</strong> section<br>
1295
+ 3. Paste your copied code click <strong>Save</strong><br>
1296
+ 4. Visit your website — the 💬 bubble appears immediately`,
1297
+ squarespace: `<strong>Squarespace 2 minutes:</strong><br>
 
1298
  1. Go to <strong>Settings → Advanced → Code Injection</strong><br>
1299
+ 2. Paste your copied code in the <strong>Footer</strong> box<br>
1300
+ 3. Click <strong>Save</strong> — done!`,
1301
+ webflow: `<strong>Webflow — 2 minutes:</strong><br>
1302
+ 1. Open <strong>Project Settings Custom Code</strong><br>
1303
+ 2. Paste your copied code in <strong>Footer Code</strong><br>
1304
+ 3. Click <strong>Save</strong>, then <strong>Publish</strong> your site`,
1305
+ wix: `<strong>Wix 2 minutes:</strong><br>
1306
+ 1. Go to <strong>Settings Custom Code</strong><br>
1307
+ 2. Click <strong>+ Add Custom Code</strong><br>
1308
+ 3. Paste your copied code, set placement to <strong>Body - end</strong><br>
1309
+ 4. Apply to <strong>All Pages</strong> click <strong>Publish</strong>`,
1310
+ shopify: `<strong>Shopify 3 minutes:</strong><br>
1311
+ 1. Go to <strong>Online Store → Themes → Edit Code</strong><br>
1312
+ 2. Open <strong>theme.liquid</strong><br>
1313
+ 3. Paste your copied code just before <code>&lt;/body&gt;</code><br>
1314
+ 4. Click <strong>Save</strong>`,
1315
+ html: `<strong>Any website — paste before the closing body tag:</strong><br>
1316
+ 1. Open your website's HTML editor<br>
1317
+ 2. Find <code>&lt;/body&gt;</code> near the bottom<br>
1318
+ 3. Paste your copied code on the line above it<br>
1319
+ 4. Save and refresh your page`,
1320
  };
1321
  function showPlatform(name) {
1322
  document.querySelectorAll('[id^="plat-"]').forEach(b => b.classList.remove('btn-primary'));
app/api/chat.py CHANGED
@@ -15,7 +15,8 @@ RULE: Visitor fingerprint is generated server-side.
15
  import uuid
16
  import hashlib
17
 
18
- from fastapi import APIRouter, Depends, Header, Request
 
19
  from fastapi.responses import StreamingResponse
20
  from sqlalchemy.ext.asyncio import AsyncSession
21
 
@@ -26,6 +27,7 @@ from app.repositories.book_repo import BookRepository
26
  from app.schemas.chatbot import ChatRequest, ChatResponse, SessionInitResponse
27
 
28
  router = APIRouter()
 
29
 
30
 
31
  def _fingerprint(request: Request) -> str:
@@ -44,53 +46,58 @@ async def init_session(
44
  request: Request,
45
  author=Depends(get_subscription_author),
46
  db: AsyncSession = Depends(get_db),
47
- redis=Depends(get_redis),
48
  ):
49
  """Initialize a new chat session for a visitor."""
50
- session_id = str(uuid.uuid4())
51
- visitor_fp = _fingerprint(request)
52
-
53
- book_repo = BookRepository(db)
54
- active_books = await book_repo.list_active_for_author(author.id)
55
-
56
- from app.models.chat_session import ChatSession
57
- from app.services.analytics_core.geo import get_geo_info
58
- from app.services.analytics_core.tracker import parse_device_info
59
-
60
- geo = await get_geo_info(request)
61
- device = parse_device_info(request)
62
 
63
- session = ChatSession(
64
- id=session_id,
65
- author_id=author.id,
66
- visitor_fingerprint=visitor_fp,
67
- country_code=geo.get("country_code"),
68
- country_name=geo.get("country_name"),
69
- city=geo.get("city"),
70
- device_type=device.get("device_type"),
71
- browser=device.get("browser"),
72
- os=device.get("os"),
73
- )
74
- db.add(session)
75
- await db.commit()
76
 
77
- return SessionInitResponse(
78
- session_id=session_id,
79
- bot_name=author.bot_name,
80
- welcome_message=author.welcome_message,
81
- widget_theme=author.widget_theme,
82
- books=[
83
- {
84
- "id": b.id,
85
- "title": b.title,
86
- "tagline": b.tagline,
87
- "cover_path": b.cover_path,
88
- "ai_summary": b.ai_summary,
89
- }
90
- for b in active_books
91
- ],
92
- show_book_selector=len(active_books) > 1,
93
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
 
96
  @router.post("/{author_slug}", response_model=ChatResponse)
 
15
  import uuid
16
  import hashlib
17
 
18
+ import structlog
19
+ from fastapi import APIRouter, Depends, Header, HTTPException, Request
20
  from fastapi.responses import StreamingResponse
21
  from sqlalchemy.ext.asyncio import AsyncSession
22
 
 
27
  from app.schemas.chatbot import ChatRequest, ChatResponse, SessionInitResponse
28
 
29
  router = APIRouter()
30
+ logger = structlog.get_logger(__name__)
31
 
32
 
33
  def _fingerprint(request: Request) -> str:
 
46
  request: Request,
47
  author=Depends(get_subscription_author),
48
  db: AsyncSession = Depends(get_db),
 
49
  ):
50
  """Initialize a new chat session for a visitor."""
51
+ try:
52
+ session_id = str(uuid.uuid4())
53
+ visitor_fp = _fingerprint(request)
 
 
 
 
 
 
 
 
 
54
 
55
+ book_repo = BookRepository(db)
56
+ active_books = await book_repo.list_active_for_author(author.id)
 
 
 
 
 
 
 
 
 
 
 
57
 
58
+ from app.models.chat_session import ChatSession
59
+ from app.services.analytics_core.geo import get_geo_info
60
+ from app.services.analytics_core.tracker import parse_device_info
61
+
62
+ geo = await get_geo_info(request)
63
+ device = parse_device_info(request)
64
+
65
+ session = ChatSession(
66
+ id=session_id,
67
+ author_id=author.id,
68
+ visitor_fingerprint=visitor_fp,
69
+ country_code=geo.get("country_code") or None,
70
+ country_name=geo.get("country_name") or None,
71
+ city=geo.get("city") or None,
72
+ device_type=device.get("device_type"),
73
+ browser=device.get("browser"),
74
+ os=device.get("os"),
75
+ )
76
+ db.add(session)
77
+ await db.flush()
78
+
79
+ return SessionInitResponse(
80
+ session_id=session_id,
81
+ bot_name=author.bot_name or "Book Advisor",
82
+ welcome_message=author.welcome_message or "Hi! How can I help you today?",
83
+ widget_theme=author.widget_theme or "midnight",
84
+ books=[
85
+ {
86
+ "id": b.id,
87
+ "title": b.title,
88
+ "tagline": b.tagline,
89
+ "cover_path": b.cover_path,
90
+ "ai_summary": b.ai_summary,
91
+ }
92
+ for b in active_books
93
+ ],
94
+ show_book_selector=len(active_books) > 1,
95
+ )
96
+ except HTTPException:
97
+ raise
98
+ except Exception as e:
99
+ logger.error("session init failed", author_id=author.id, error=str(e), exc_info=True)
100
+ raise HTTPException(status_code=500, detail="Could not start chat session")
101
 
102
 
103
  @router.post("/{author_slug}", response_model=ChatResponse)
app/core/access/subscription.py CHANGED
@@ -65,7 +65,11 @@ async def validate_subscription_token(
65
  token_hash = hash_subscription_token(token)
66
 
67
  # Step 3: Redis revocation blacklist (fastest check — O(1))
68
- is_revoked = await redis.exists(f"{REVOCATION_REDIS_PREFIX}{token_hash}")
 
 
 
 
69
  if is_revoked:
70
  logger.warning("Revoked token used", author_id=author_id, grant_id=grant_id)
71
  raise AccessRevokedError()
@@ -87,7 +91,11 @@ async def validate_subscription_token(
87
  raise AccessRevokedError(access_record.revoke_reason or "Access revoked")
88
 
89
  # Step 5: Token budget check
90
- budget_exhausted = await redis.exists(f"{BUDGET_EXHAUSTED_REDIS_PREFIX}{author_id}")
 
 
 
 
91
  if budget_exhausted:
92
  raise BudgetExhaustedError()
93
 
@@ -122,7 +130,10 @@ async def _add_to_revocation_blacklist(redis: Redis, token_hash: str, expires_at
122
  """Internal: add token to Redis blacklist with appropriate TTL."""
123
  from datetime import datetime, timezone
124
  now = datetime.now(timezone.utc)
125
- ttl_seconds = max(int((expires_at - now).total_seconds()), 1)
 
 
 
126
  await redis.setex(
127
  name=f"{REVOCATION_REDIS_PREFIX}{token_hash}",
128
  time=ttl_seconds,
 
65
  token_hash = hash_subscription_token(token)
66
 
67
  # Step 3: Redis revocation blacklist (fastest check — O(1))
68
+ try:
69
+ is_revoked = await redis.exists(f"{REVOCATION_REDIS_PREFIX}{token_hash}")
70
+ except Exception as e:
71
+ logger.warning("Redis revocation check skipped", error=str(e))
72
+ is_revoked = 0
73
  if is_revoked:
74
  logger.warning("Revoked token used", author_id=author_id, grant_id=grant_id)
75
  raise AccessRevokedError()
 
91
  raise AccessRevokedError(access_record.revoke_reason or "Access revoked")
92
 
93
  # Step 5: Token budget check
94
+ try:
95
+ budget_exhausted = await redis.exists(f"{BUDGET_EXHAUSTED_REDIS_PREFIX}{author_id}")
96
+ except Exception as e:
97
+ logger.warning("Redis budget check skipped", error=str(e))
98
+ budget_exhausted = 0
99
  if budget_exhausted:
100
  raise BudgetExhaustedError()
101
 
 
130
  """Internal: add token to Redis blacklist with appropriate TTL."""
131
  from datetime import datetime, timezone
132
  now = datetime.now(timezone.utc)
133
+ exp = expires_at
134
+ if exp.tzinfo is None:
135
+ exp = exp.replace(tzinfo=timezone.utc)
136
+ ttl_seconds = max(int((exp - now).total_seconds()), 1)
137
  await redis.setex(
138
  name=f"{REVOCATION_REDIS_PREFIX}{token_hash}",
139
  time=ttl_seconds,
app/core/access/token_crypto.py CHANGED
@@ -110,6 +110,13 @@ def decode_jwt(token: str) -> dict[str, Any]:
110
 
111
  # ─── HMAC Subscription Tokens ─────────────────────────────────────────────────
112
 
 
 
 
 
 
 
 
113
  def create_subscription_token(
114
  author_id: str,
115
  grant_id: str,
@@ -133,8 +140,8 @@ def create_subscription_token(
133
  payload = {
134
  "author_id": author_id,
135
  "grant_id": grant_id,
136
- "granted_at": int(granted_at.timestamp()),
137
- "expires_at": int(expires_at.timestamp()),
138
  }
139
  payload_bytes = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
140
  payload_b64 = urlsafe_b64encode(payload_bytes).rstrip(b"=")
 
110
 
111
  # ─── HMAC Subscription Tokens ─────────────────────────────────────────────────
112
 
113
+ def _datetime_to_unix(dt: datetime) -> int:
114
+ """Convert datetime to unix timestamp, treating naive values as UTC."""
115
+ if dt.tzinfo is None:
116
+ dt = dt.replace(tzinfo=timezone.utc)
117
+ return int(dt.timestamp())
118
+
119
+
120
  def create_subscription_token(
121
  author_id: str,
122
  grant_id: str,
 
140
  payload = {
141
  "author_id": author_id,
142
  "grant_id": grant_id,
143
+ "granted_at": _datetime_to_unix(granted_at),
144
+ "expires_at": _datetime_to_unix(expires_at),
145
  }
146
  payload_bytes = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
147
  payload_b64 = urlsafe_b64encode(payload_bytes).rstrip(b"=")
app/dependencies.py CHANGED
@@ -155,9 +155,28 @@ async def get_subscription_author(
155
  db: AsyncSession = Depends(get_db),
156
  redis: aioredis.Redis = Depends(get_redis),
157
  ):
158
- """Validate subscription token for chatbot endpoints. Raises on any failure."""
159
  from app.core.access.subscription import validate_subscription_token
160
- return await validate_subscription_token(x_subscription_token, db, redis)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
 
162
 
163
  # ─── Health Check ─────────────────────────────────────────────────────────────
 
155
  db: AsyncSession = Depends(get_db),
156
  redis: aioredis.Redis = Depends(get_redis),
157
  ):
158
+ """Validate subscription token for chatbot endpoints."""
159
  from app.core.access.subscription import validate_subscription_token
160
+ from app.exceptions.access import (
161
+ AccessRevokedError,
162
+ BudgetExhaustedError,
163
+ InvalidSubscriptionTokenError,
164
+ SubscriptionExpiredError,
165
+ SubscriptionNotFoundError,
166
+ )
167
+
168
+ try:
169
+ return await validate_subscription_token(x_subscription_token, db, redis)
170
+ except InvalidSubscriptionTokenError:
171
+ raise HTTPException(status_code=401, detail="Invalid or expired subscription token")
172
+ except SubscriptionExpiredError:
173
+ raise HTTPException(status_code=403, detail="This chatbot service is currently unavailable")
174
+ except AccessRevokedError:
175
+ raise HTTPException(status_code=403, detail="This chatbot service is currently unavailable")
176
+ except SubscriptionNotFoundError:
177
+ raise HTTPException(status_code=403, detail="No active subscription. Contact support.")
178
+ except BudgetExhaustedError:
179
+ raise HTTPException(status_code=403, detail="Monthly token budget exhausted")
180
 
181
 
182
  # ─── Health Check ─────────────────────────────────────────────────────────────
app/repositories/book_repo.py CHANGED
@@ -22,7 +22,7 @@ class BookRepository(BaseRepository[Book]):
22
  Returns:
23
  List of active Book instances ordered by display_order ascending.
24
  """
25
- result = await self._session.execute(
26
  select(Book)
27
  .where(Book.author_id == author_id, Book.is_active == True)
28
  .order_by(Book.display_order.asc())
@@ -48,7 +48,7 @@ class BookRepository(BaseRepository[Book]):
48
  ordered_ids: List of book UUIDs in desired display order.
49
  """
50
  for position, book_id in enumerate(ordered_ids):
51
- await self._session.execute(
52
  update(Book)
53
  .where(Book.id == book_id, Book.author_id == author_id)
54
  .values(display_order=position)
@@ -63,7 +63,7 @@ class BookRepository(BaseRepository[Book]):
63
  Returns:
64
  Book instance or None.
65
  """
66
- result = await self._session.execute(
67
  select(Book).where(Book.chroma_collection_id == collection_id)
68
  )
69
  return result.scalar_one_or_none()
@@ -80,7 +80,7 @@ class BookRepository(BaseRepository[Book]):
80
  values = {"status": status}
81
  if error is not None:
82
  values["error_message"] = error
83
- await self._session.execute(
84
  update(Book)
85
  .where(Book.id == book_id, Book.author_id == author_id)
86
  .values(**values)
 
22
  Returns:
23
  List of active Book instances ordered by display_order ascending.
24
  """
25
+ result = await self.session.execute(
26
  select(Book)
27
  .where(Book.author_id == author_id, Book.is_active == True)
28
  .order_by(Book.display_order.asc())
 
48
  ordered_ids: List of book UUIDs in desired display order.
49
  """
50
  for position, book_id in enumerate(ordered_ids):
51
+ await self.session.execute(
52
  update(Book)
53
  .where(Book.id == book_id, Book.author_id == author_id)
54
  .values(display_order=position)
 
63
  Returns:
64
  Book instance or None.
65
  """
66
+ result = await self.session.execute(
67
  select(Book).where(Book.chroma_collection_id == collection_id)
68
  )
69
  return result.scalar_one_or_none()
 
80
  values = {"status": status}
81
  if error is not None:
82
  values["error_message"] = error
83
+ await self.session.execute(
84
  update(Book)
85
  .where(Book.id == book_id, Book.author_id == author_id)
86
  .values(**values)
app/repositories/document_repo.py CHANGED
@@ -23,7 +23,7 @@ class DocumentRepository(BaseRepository[Document]):
23
  Returns:
24
  Existing Document or None.
25
  """
26
- result = await self._session.execute(
27
  select(Document).where(
28
  Document.file_hash == file_hash,
29
  Document.author_id == author_id,
@@ -40,7 +40,7 @@ class DocumentRepository(BaseRepository[Document]):
40
  Returns:
41
  Document or None.
42
  """
43
- result = await self._session.execute(
44
  select(Document).where(Document.upload_id == upload_id)
45
  )
46
  return result.scalar_one_or_none()
@@ -65,7 +65,7 @@ class DocumentRepository(BaseRepository[Document]):
65
  values = {"status": status, **extra_fields}
66
  if error_message is not None:
67
  values["error_message"] = error_message
68
- await self._session.execute(
69
  update(Document)
70
  .where(Document.id == doc_id, Document.author_id == author_id)
71
  .values(**values)
@@ -81,7 +81,7 @@ class DocumentRepository(BaseRepository[Document]):
81
  Returns:
82
  List of Document instances.
83
  """
84
- result = await self._session.execute(
85
  select(Document).where(
86
  Document.book_id == book_id,
87
  Document.author_id == author_id,
 
23
  Returns:
24
  Existing Document or None.
25
  """
26
+ result = await self.session.execute(
27
  select(Document).where(
28
  Document.file_hash == file_hash,
29
  Document.author_id == author_id,
 
40
  Returns:
41
  Document or None.
42
  """
43
+ result = await self.session.execute(
44
  select(Document).where(Document.upload_id == upload_id)
45
  )
46
  return result.scalar_one_or_none()
 
65
  values = {"status": status, **extra_fields}
66
  if error_message is not None:
67
  values["error_message"] = error_message
68
+ await self.session.execute(
69
  update(Document)
70
  .where(Document.id == doc_id, Document.author_id == author_id)
71
  .values(**values)
 
81
  Returns:
82
  List of Document instances.
83
  """
84
+ result = await self.session.execute(
85
  select(Document).where(
86
  Document.book_id == book_id,
87
  Document.author_id == author_id,
app/repositories/link_repo.py CHANGED
@@ -12,7 +12,6 @@ class LinkRepository(BaseRepository[Link]):
12
 
13
  def __init__(self, session: AsyncSession) -> None:
14
  super().__init__(Link, session)
15
- self._session = session
16
 
17
  async def get_for_book(self, book_id: str, author_id: str) -> Link | None:
18
  """Get the links record for a specific book.
@@ -24,7 +23,7 @@ class LinkRepository(BaseRepository[Link]):
24
  Returns:
25
  Link instance or None.
26
  """
27
- result = await self._session.execute(
28
  select(Link).where(
29
  Link.book_id == book_id,
30
  Link.author_id == author_id,
 
12
 
13
  def __init__(self, session: AsyncSession) -> None:
14
  super().__init__(Link, session)
 
15
 
16
  async def get_for_book(self, book_id: str, author_id: str) -> Link | None:
17
  """Get the links record for a specific book.
 
23
  Returns:
24
  Link instance or None.
25
  """
26
+ result = await self.session.execute(
27
  select(Link).where(
28
  Link.book_id == book_id,
29
  Link.author_id == author_id,
static/widget.js CHANGED
@@ -1,58 +1,81 @@
1
  /**
2
  * AuthorBot Chat Widget — widget.js
3
- * Vanilla JS, zero dependencies, ~8KB minified.
4
  * Embeds as a floating chat bubble on any author website.
5
  *
6
- * Usage:
7
- * window.AuthorBotConfig = { token: "YOUR_TOKEN", theme: "midnight", position: "bottom-right" };
8
- * <script src="https://cdn.authorbot.io/widget.min.js" async></script>
 
 
 
 
9
  */
10
 
11
  (function() {
12
  'use strict';
13
 
14
- // ── Configuration ──────────────────────────────────────────
15
- const CONFIG = Object.assign({
16
- theme: 'midnight',
17
- position: 'bottom-right',
18
- autoOpenDelay: 0,
19
- apiBase: '',
20
- slug: '',
21
- }, window.AuthorBotConfig || {});
22
-
23
- if (!CONFIG.token) {
24
- console.warn('[AuthorBot] No token provided. Widget disabled.');
25
- return;
 
 
 
 
 
 
 
 
 
26
  }
27
 
28
- // ── Theme Definitions ──────────────────────────────────────
29
- const THEMES = {
30
- midnight: { bg: '#0d0f1a', surface: '#13161f', border: '#ffffff0f', brand: '#6366f1', text: '#e2e8f0', sub: '#94a3b8' },
31
- ocean: { bg: '#0a1628', surface: '#0f1e38', border: '#ffffff0f', brand: '#0ea5e9', text: '#e0f2fe', sub: '#7dd3fc' },
32
- forest: { bg: '#0a1a0f', surface: '#0f2416', border: '#ffffff0f', brand: '#22c55e', text: '#dcfce7', sub: '#86efac' },
33
- sunset: { bg: '#1a0d0a', surface: '#261510', border: '#ffffff0f', brand: '#f97316', text: '#fff7ed', sub: '#fdba74' },
34
- minimal: { bg: '#ffffff', surface: '#f8fafc', border: '#e2e8f0', brand: '#4f46e5', text: '#1e293b', sub: '#64748b' },
35
- };
36
-
37
- const T = THEMES[CONFIG.theme] || THEMES.midnight;
38
- const isDark = CONFIG.theme !== 'minimal';
39
-
40
- // ── State ───────────────────────────────────────────────────
41
- let sessionId = null;
42
- let isOpen = false;
43
- let isLoading = false;
44
- let selectedBookId = null;
45
- let books = [];
46
- let turnCount = 0;
47
-
48
- // ── Position helpers ────────────────────────────────────────
49
- const POS = CONFIG.position.split('-');
50
- const vPos = POS[0]; // top / bottom
51
- const hPos = POS[1]; // left / right
52
-
53
- // ── CSS injection ────────────────────────────────────────────
54
- const style = document.createElement('style');
55
- style.textContent = `
 
 
 
 
 
 
 
 
 
 
56
  #ab-root * { box-sizing: border-box; font-family: -apple-system, 'Inter', BlinkMacSystemFont, sans-serif; }
57
  #ab-root { position: fixed; ${vPos}: 24px; ${hPos}: 24px; z-index: 2147483647; display: flex; flex-direction: column; align-items: ${hPos === 'right' ? 'flex-end' : 'flex-start'}; gap: 12px; }
58
 
@@ -170,12 +193,11 @@
170
  @keyframes ab-slide-up { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:translateY(0)} }
171
  @keyframes ab-bounce { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-4px)} }
172
  `;
173
- document.head.appendChild(style);
174
 
175
- // ── DOM construction ─────────────────────────────────────────
176
- const root = document.createElement('div');
177
- root.id = 'ab-root';
178
- root.innerHTML = `
179
  <div id="ab-window" class="ab-hidden">
180
  <div id="ab-header">
181
  <div id="ab-avatar">🤖</div>
@@ -195,222 +217,219 @@
195
  <button id="ab-bubble" aria-label="Open book advisor chat">💬</button>
196
  `;
197
 
198
- // Bubble is above window — reverse for bottom positioning
199
- if (vPos === 'bottom') {
200
- root.style.flexDirection = 'column-reverse';
201
- }
202
 
203
- document.body.appendChild(root);
204
-
205
- // ── DOM refs ─────────────────────────────────────────────────
206
- const $window = root.querySelector('#ab-window');
207
- const $messages = root.querySelector('#ab-messages');
208
- const $input = root.querySelector('#ab-input');
209
- const $send = root.querySelector('#ab-send');
210
- const $bubble = root.querySelector('#ab-bubble');
211
- const $close = root.querySelector('#ab-close-btn');
212
- const $botName = root.querySelector('#ab-bot-name');
213
-
214
- // ── Toggle open/close ─────────────────────────────────────────
215
- function openChat() {
216
- isOpen = true;
217
- $window.classList.remove('ab-hidden');
218
- $bubble.style.display = 'none';
219
- if (!sessionId) initSession();
220
- else $input.focus();
221
- }
222
 
223
- function closeChat() {
224
- isOpen = false;
225
- $window.classList.add('ab-hidden');
226
- $bubble.style.display = '';
227
- }
228
 
229
- $bubble.addEventListener('click', openChat);
230
- $close.addEventListener('click', closeChat);
231
-
232
- // ── Session init ──────────────────────────────────────────────
233
- async function initSession() {
234
- try {
235
- const res = await apiPost(`/chat/${CONFIG.slug}/session/init`, {}, { 'X-Subscription-Token': CONFIG.token });
236
- sessionId = res.session_id;
237
- books = res.books || [];
238
- $botName.textContent = res.bot_name || 'Book Advisor';
239
- addBotMessage(res.welcome_message || 'Hi! How can I help you today?', [], null);
240
- if (res.show_book_selector && books.length > 1) {
241
- addBotMessage("Which book are you curious about?", [], books);
 
 
 
242
  }
243
- $input.focus();
244
- } catch (e) {
245
- addBotMessage("Sorry, I'm having trouble starting. Please refresh and try again.", [], null);
246
  }
247
- }
248
 
249
- // ── Send message ──────────────────────────────────────────────
250
- async function sendMessage() {
251
- const text = $input.value.trim();
252
- if (!text || isLoading || !sessionId) return;
253
-
254
- addUserMessage(text);
255
- $input.value = '';
256
- resizeInput();
257
-
258
- const typingEl = addTypingIndicator();
259
- isLoading = true;
260
- $send.disabled = true;
261
- turnCount++;
262
-
263
- try {
264
- const res = await apiPost(`/chat/${CONFIG.slug}`, {
265
- session_id: sessionId,
266
- message: text,
267
- selected_book_id: selectedBookId,
268
- }, { 'X-Subscription-Token': CONFIG.token });
269
-
270
- typingEl.remove();
271
- addBotMessage(res.text, res.links || [], res.book_selector || null);
272
- } catch (e) {
273
- typingEl.remove();
274
- addBotMessage("Sorry, something went wrong. Please try again.", [], null);
275
- } finally {
276
- isLoading = false;
277
- $send.disabled = false;
278
- $input.focus();
279
- }
280
- }
281
 
282
- // ── Message rendering ─────────────────────────────────────────
283
- function addUserMessage(text) {
284
- const el = document.createElement('div');
285
- el.className = 'ab-msg ab-user';
286
- el.innerHTML = `<div class="ab-bubble-text">${escHtml(text)}</div>`;
287
- $messages.appendChild(el);
288
- scrollToBottom();
289
- }
290
 
291
- function addBotMessage(text, links, bookSelector) {
292
- const el = document.createElement('div');
293
- el.className = 'ab-msg ab-bot';
 
294
 
295
- let html = `<div class="ab-bubble-text">${escHtml(text)}</div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
 
297
- if (links && links.length) {
298
- html += `<div class="ab-links">`;
299
- links.forEach(l => {
300
- html += `<a class="ab-link-btn" href="${escAttr(l.url)}" target="_blank" rel="noopener" data-type="${escAttr(l.type)}">${escHtml(l.icon)} ${escHtml(l.label)}</a>`;
301
- });
302
- html += `</div>`;
303
  }
304
 
305
- if (bookSelector && bookSelector.length) {
306
- html += `<div class="ab-book-selector">`;
307
- bookSelector.forEach(b => {
308
- html += `<button class="ab-book-btn" data-book-id="${escAttr(b.id)}">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  <span class="ab-book-thumb">📖</span>
310
  <div style="text-align:left">
311
  <div style="font-weight:600;font-size:13px">${escHtml(b.title)}</div>
312
  ${b.tagline ? `<div style="font-size:11px;opacity:0.7;margin-top:1px">${escHtml(b.tagline)}</div>` : ''}
313
  </div>
314
  </button>`;
315
- });
316
- html += `</div>`;
317
- }
318
 
319
- el.innerHTML = html;
320
 
321
- // Book selection handler
322
- el.querySelectorAll('.ab-book-btn').forEach(btn => {
323
- btn.addEventListener('click', () => {
324
- const bookId = btn.getAttribute('data-book-id');
325
- selectedBookId = bookId;
326
- const book = bookSelector.find(b => b.id === bookId);
327
- addUserMessage(`Tell me about "${book?.title}"`);
328
- apiPost(`/chat/${CONFIG.slug}`, {
329
- session_id: sessionId,
330
- message: `Tell me about "${book?.title}"`,
331
- selected_book_id: bookId,
332
- }, { 'X-Subscription-Token': CONFIG.token }).then(res => {
333
- addBotMessage(res.text, res.links || [], null);
 
 
334
  });
335
- // Disable all book buttons after selection
336
- el.querySelectorAll('.ab-book-btn').forEach(b => b.style.opacity = '0.4');
337
- btn.style.opacity = '1'; btn.style.borderColor = T.brand;
338
  });
339
- });
340
 
341
- // Link click tracking
342
- el.querySelectorAll('.ab-link-btn').forEach(a => {
343
- a.addEventListener('click', () => {
344
- try {
345
- apiPost(`/chat/${CONFIG.slug}/track-click`, {
346
- session_id: sessionId,
347
- link_type: a.getAttribute('data-type'),
348
- }, { 'X-Subscription-Token': CONFIG.token }).catch(() => {});
349
- } catch {}
350
  });
351
- });
352
-
353
- $messages.appendChild(el);
354
- scrollToBottom();
355
- }
356
 
357
- function addTypingIndicator() {
358
- const el = document.createElement('div');
359
- el.className = 'ab-msg ab-bot';
360
- el.innerHTML = `<div class="ab-bubble-text ab-typing"><div class="ab-dot"></div><div class="ab-dot"></div><div class="ab-dot"></div></div>`;
361
- $messages.appendChild(el);
362
- scrollToBottom();
363
- return el;
364
- }
365
 
366
- // ── Input handling ─────────────────────────────────────────────
367
- function resizeInput() {
368
- $input.style.height = 'auto';
369
- $input.style.height = Math.min($input.scrollHeight, 100) + 'px';
370
- }
 
 
 
371
 
372
- $input.addEventListener('input', resizeInput);
373
- $input.addEventListener('keydown', e => {
374
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
375
- });
376
- $send.addEventListener('click', sendMessage);
377
 
378
- // ── Utilities ──────────────────────────────────────────────────
379
- function scrollToBottom() {
380
- requestAnimationFrame(() => {
381
- $messages.scrollTop = $messages.scrollHeight;
382
  });
383
- }
384
 
385
- function escHtml(str) {
386
- return String(str || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
387
- }
 
 
388
 
389
- function escAttr(str) {
390
- return String(str || '').replace(/"/g, '&quot;');
391
- }
392
 
393
- async function apiPost(path, body, headers) {
394
- const base = CONFIG.apiBase || window.location.origin + '/api';
395
- const res = await fetch(`${base}${path}`, {
396
- method: 'POST',
397
- headers: { 'Content-Type': 'application/json', ...headers },
398
- body: JSON.stringify(body),
399
- });
400
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
401
- return res.json();
402
- }
403
 
404
- // ── Auto-open ──────────────────────────────────────────────────
405
- if (CONFIG.autoOpenDelay > 0) {
406
- setTimeout(openChat, CONFIG.autoOpenDelay * 1000);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
407
  }
408
 
409
- // ── Expose to window for programmatic control ──────────────────
410
- window.AuthorBot = {
411
- open: openChat,
412
- close: closeChat,
413
- toggle: () => isOpen ? closeChat() : openChat(),
414
- };
 
415
 
 
416
  })();
 
1
  /**
2
  * AuthorBot Chat Widget — widget.js
3
+ * Vanilla JS, zero dependencies.
4
  * Embeds as a floating chat bubble on any author website.
5
  *
6
+ * One-line install (recommended):
7
+ * <script src="https://YOUR-BACKEND/static/widget.js" defer
8
+ * data-authorbot-token="TOKEN"
9
+ * data-authorbot-slug="AUTHOR_ID"
10
+ * data-authorbot-api="https://YOUR-BACKEND/api"
11
+ * data-authorbot-theme="midnight"
12
+ * data-authorbot-position="bottom-right"></script>
13
  */
14
 
15
  (function() {
16
  'use strict';
17
 
18
+ if (window.__authorBotMounted) return;
19
+
20
+ function readConfig() {
21
+ const script = document.currentScript;
22
+ const fromData = script ? {
23
+ token: script.getAttribute('data-authorbot-token') || '',
24
+ slug: script.getAttribute('data-authorbot-slug') || '',
25
+ apiBase: script.getAttribute('data-authorbot-api') || '',
26
+ theme: script.getAttribute('data-authorbot-theme') || '',
27
+ position: script.getAttribute('data-authorbot-position') || '',
28
+ autoOpenDelay: Number(script.getAttribute('data-authorbot-auto-open') || 0),
29
+ } : {};
30
+
31
+ return Object.assign({
32
+ theme: 'midnight',
33
+ position: 'bottom-right',
34
+ autoOpenDelay: 0,
35
+ apiBase: '',
36
+ slug: '',
37
+ token: '',
38
+ }, window.AuthorBotConfig || {}, fromData);
39
  }
40
 
41
+ function mount() {
42
+ if (window.__authorBotMounted) return;
43
+ if (!document.body) return;
44
+
45
+ const CONFIG = readConfig();
46
+
47
+ if (!CONFIG.token) {
48
+ console.warn('[AuthorBot] No token provided. Widget disabled.');
49
+ return;
50
+ }
51
+
52
+ window.__authorBotMounted = true;
53
+
54
+ // ── Theme Definitions ──────────────────────────────────────
55
+ const THEMES = {
56
+ midnight: { bg: '#0d0f1a', surface: '#13161f', border: '#ffffff0f', brand: '#6366f1', text: '#e2e8f0', sub: '#94a3b8' },
57
+ ocean: { bg: '#0a1628', surface: '#0f1e38', border: '#ffffff0f', brand: '#0ea5e9', text: '#e0f2fe', sub: '#7dd3fc' },
58
+ forest: { bg: '#0a1a0f', surface: '#0f2416', border: '#ffffff0f', brand: '#22c55e', text: '#dcfce7', sub: '#86efac' },
59
+ sunset: { bg: '#1a0d0a', surface: '#261510', border: '#ffffff0f', brand: '#f97316', text: '#fff7ed', sub: '#fdba74' },
60
+ minimal: { bg: '#ffffff', surface: '#f8fafc', border: '#e2e8f0', brand: '#4f46e5', text: '#1e293b', sub: '#64748b' },
61
+ };
62
+
63
+ const T = THEMES[CONFIG.theme] || THEMES.midnight;
64
+
65
+ // ── State ───────────────────────────────────────────────────
66
+ let sessionId = null;
67
+ let isOpen = false;
68
+ let isLoading = false;
69
+ let selectedBookId = null;
70
+ let books = [];
71
+ let turnCount = 0;
72
+
73
+ const POS = CONFIG.position.split('-');
74
+ const vPos = POS[0];
75
+ const hPos = POS[1];
76
+
77
+ const style = document.createElement('style');
78
+ style.textContent = `
79
  #ab-root * { box-sizing: border-box; font-family: -apple-system, 'Inter', BlinkMacSystemFont, sans-serif; }
80
  #ab-root { position: fixed; ${vPos}: 24px; ${hPos}: 24px; z-index: 2147483647; display: flex; flex-direction: column; align-items: ${hPos === 'right' ? 'flex-end' : 'flex-start'}; gap: 12px; }
81
 
 
193
  @keyframes ab-slide-up { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:translateY(0)} }
194
  @keyframes ab-bounce { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-4px)} }
195
  `;
196
+ (document.head || document.documentElement).appendChild(style);
197
 
198
+ const root = document.createElement('div');
199
+ root.id = 'ab-root';
200
+ root.innerHTML = `
 
201
  <div id="ab-window" class="ab-hidden">
202
  <div id="ab-header">
203
  <div id="ab-avatar">🤖</div>
 
217
  <button id="ab-bubble" aria-label="Open book advisor chat">💬</button>
218
  `;
219
 
220
+ if (vPos === 'bottom') {
221
+ root.style.flexDirection = 'column-reverse';
222
+ }
 
223
 
224
+ document.body.appendChild(root);
225
+
226
+ const $window = root.querySelector('#ab-window');
227
+ const $messages = root.querySelector('#ab-messages');
228
+ const $input = root.querySelector('#ab-input');
229
+ const $send = root.querySelector('#ab-send');
230
+ const $bubble = root.querySelector('#ab-bubble');
231
+ const $close = root.querySelector('#ab-close-btn');
232
+ const $botName = root.querySelector('#ab-bot-name');
233
+
234
+ function openChat() {
235
+ isOpen = true;
236
+ $window.classList.remove('ab-hidden');
237
+ $bubble.style.display = 'none';
238
+ if (!sessionId) initSession();
239
+ else $input.focus();
240
+ }
 
 
241
 
242
+ function closeChat() {
243
+ isOpen = false;
244
+ $window.classList.add('ab-hidden');
245
+ $bubble.style.display = '';
246
+ }
247
 
248
+ $bubble.addEventListener('click', openChat);
249
+ $close.addEventListener('click', closeChat);
250
+
251
+ async function initSession() {
252
+ try {
253
+ const res = await apiPost(`/chat/${CONFIG.slug}/session/init`, {}, { 'X-Subscription-Token': CONFIG.token });
254
+ sessionId = res.session_id;
255
+ books = res.books || [];
256
+ $botName.textContent = res.bot_name || 'Book Advisor';
257
+ addBotMessage(res.welcome_message || 'Hi! How can I help you today?', [], null);
258
+ if (res.show_book_selector && books.length > 1) {
259
+ addBotMessage("Which book are you curious about?", [], books);
260
+ }
261
+ $input.focus();
262
+ } catch (e) {
263
+ addBotMessage("Sorry, I'm having trouble starting. Please refresh and try again.", [], null);
264
  }
 
 
 
265
  }
 
266
 
267
+ async function sendMessage() {
268
+ const text = $input.value.trim();
269
+ if (!text || isLoading || !sessionId) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
 
271
+ addUserMessage(text);
272
+ $input.value = '';
273
+ resizeInput();
 
 
 
 
 
274
 
275
+ const typingEl = addTypingIndicator();
276
+ isLoading = true;
277
+ $send.disabled = true;
278
+ turnCount++;
279
 
280
+ try {
281
+ const res = await apiPost(`/chat/${CONFIG.slug}`, {
282
+ session_id: sessionId,
283
+ message: text,
284
+ selected_book_id: selectedBookId,
285
+ }, { 'X-Subscription-Token': CONFIG.token });
286
+
287
+ typingEl.remove();
288
+ addBotMessage(res.text, res.links || [], res.book_selector || null);
289
+ } catch (e) {
290
+ typingEl.remove();
291
+ addBotMessage("Sorry, something went wrong. Please try again.", [], null);
292
+ } finally {
293
+ isLoading = false;
294
+ $send.disabled = false;
295
+ $input.focus();
296
+ }
297
+ }
298
 
299
+ function addUserMessage(text) {
300
+ const el = document.createElement('div');
301
+ el.className = 'ab-msg ab-user';
302
+ el.innerHTML = `<div class="ab-bubble-text">${escHtml(text)}</div>`;
303
+ $messages.appendChild(el);
304
+ scrollToBottom();
305
  }
306
 
307
+ function addBotMessage(text, links, bookSelector) {
308
+ const el = document.createElement('div');
309
+ el.className = 'ab-msg ab-bot';
310
+
311
+ let html = `<div class="ab-bubble-text">${escHtml(text)}</div>`;
312
+
313
+ if (links && links.length) {
314
+ html += `<div class="ab-links">`;
315
+ links.forEach(l => {
316
+ html += `<a class="ab-link-btn" href="${escAttr(l.url)}" target="_blank" rel="noopener" data-type="${escAttr(l.type)}">${escHtml(l.icon)} ${escHtml(l.label)}</a>`;
317
+ });
318
+ html += `</div>`;
319
+ }
320
+
321
+ if (bookSelector && bookSelector.length) {
322
+ html += `<div class="ab-book-selector">`;
323
+ bookSelector.forEach(b => {
324
+ html += `<button class="ab-book-btn" data-book-id="${escAttr(b.id)}">
325
  <span class="ab-book-thumb">📖</span>
326
  <div style="text-align:left">
327
  <div style="font-weight:600;font-size:13px">${escHtml(b.title)}</div>
328
  ${b.tagline ? `<div style="font-size:11px;opacity:0.7;margin-top:1px">${escHtml(b.tagline)}</div>` : ''}
329
  </div>
330
  </button>`;
331
+ });
332
+ html += `</div>`;
333
+ }
334
 
335
+ el.innerHTML = html;
336
 
337
+ el.querySelectorAll('.ab-book-btn').forEach(btn => {
338
+ btn.addEventListener('click', () => {
339
+ const bookId = btn.getAttribute('data-book-id');
340
+ selectedBookId = bookId;
341
+ const book = bookSelector.find(b => b.id === bookId);
342
+ addUserMessage(`Tell me about "${book?.title}"`);
343
+ apiPost(`/chat/${CONFIG.slug}`, {
344
+ session_id: sessionId,
345
+ message: `Tell me about "${book?.title}"`,
346
+ selected_book_id: bookId,
347
+ }, { 'X-Subscription-Token': CONFIG.token }).then(res => {
348
+ addBotMessage(res.text, res.links || [], null);
349
+ });
350
+ el.querySelectorAll('.ab-book-btn').forEach(b => b.style.opacity = '0.4');
351
+ btn.style.opacity = '1'; btn.style.borderColor = T.brand;
352
  });
 
 
 
353
  });
 
354
 
355
+ el.querySelectorAll('.ab-link-btn').forEach(a => {
356
+ a.addEventListener('click', () => {
357
+ try {
358
+ apiPost(`/chat/${CONFIG.slug}/track-click`, {
359
+ session_id: sessionId,
360
+ link_type: a.getAttribute('data-type'),
361
+ }, { 'X-Subscription-Token': CONFIG.token }).catch(() => {});
362
+ } catch {}
363
+ });
364
  });
 
 
 
 
 
365
 
366
+ $messages.appendChild(el);
367
+ scrollToBottom();
368
+ }
 
 
 
 
 
369
 
370
+ function addTypingIndicator() {
371
+ const el = document.createElement('div');
372
+ el.className = 'ab-msg ab-bot';
373
+ el.innerHTML = `<div class="ab-bubble-text ab-typing"><div class="ab-dot"></div><div class="ab-dot"></div><div class="ab-dot"></div></div>`;
374
+ $messages.appendChild(el);
375
+ scrollToBottom();
376
+ return el;
377
+ }
378
 
379
+ function resizeInput() {
380
+ $input.style.height = 'auto';
381
+ $input.style.height = Math.min($input.scrollHeight, 100) + 'px';
382
+ }
 
383
 
384
+ $input.addEventListener('input', resizeInput);
385
+ $input.addEventListener('keydown', e => {
386
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
 
387
  });
388
+ $send.addEventListener('click', sendMessage);
389
 
390
+ function scrollToBottom() {
391
+ requestAnimationFrame(() => {
392
+ $messages.scrollTop = $messages.scrollHeight;
393
+ });
394
+ }
395
 
396
+ function escHtml(str) {
397
+ return String(str || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
398
+ }
399
 
400
+ function escAttr(str) {
401
+ return String(str || '').replace(/"/g, '&quot;');
402
+ }
 
 
 
 
 
 
 
403
 
404
+ async function apiPost(path, body, headers) {
405
+ const base = CONFIG.apiBase || window.location.origin + '/api';
406
+ const res = await fetch(`${base}${path}`, {
407
+ method: 'POST',
408
+ headers: { 'Content-Type': 'application/json', ...headers },
409
+ body: JSON.stringify(body),
410
+ });
411
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
412
+ return res.json();
413
+ }
414
+
415
+ if (CONFIG.autoOpenDelay > 0) {
416
+ setTimeout(openChat, CONFIG.autoOpenDelay * 1000);
417
+ }
418
+
419
+ window.AuthorBot = {
420
+ open: openChat,
421
+ close: closeChat,
422
+ toggle: () => isOpen ? closeChat() : openChat(),
423
+ };
424
  }
425
 
426
+ function boot() {
427
+ if (document.readyState === 'loading') {
428
+ document.addEventListener('DOMContentLoaded', mount);
429
+ } else {
430
+ mount();
431
+ }
432
+ }
433
 
434
+ boot();
435
  })();