| """Entry point for ORTOS AI Consultant. |
| Webhook handler for Bitrix24 Open Lines (outgoing webhooks). |
| """ |
|
|
| import os, json, logging, html, urllib.parse, re |
| from http.server import HTTPServer, BaseHTTPRequestHandler |
| from dotenv import load_dotenv |
|
|
| logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") |
| for _lib in ("httpx", "httpcore"): |
| logging.getLogger(_lib).setLevel(logging.WARNING) |
| logger = logging.getLogger(__name__) |
|
|
| load_dotenv() |
|
|
| PORT = int(os.getenv('PORT', '7860')) |
| BITRIX_WEBHOOK_URL = os.getenv('BITRIX_WEBHOOK_URL') |
| BOT_ID = int(os.getenv('BOT_ID', '0')) |
|
|
| from bot import process_message |
|
|
|
|
| _auto_bot_id = 0 |
| _webhook_auth = {} |
|
|
|
|
| def _parse_form(body: bytes) -> dict: |
| parsed = urllib.parse.parse_qs(body.decode("utf-8", errors="replace")) |
| out = {} |
| for key, vals in parsed.items(): |
| val = vals[0] if len(vals) == 1 else vals |
| brackets = re.findall(r"\[([^\]]+)\]", key) |
| if brackets: |
| section = key.split("[")[0] |
| d = out.setdefault(section, {}) |
| for b in brackets[:-1]: |
| d = d.setdefault(b, {}) |
| d[brackets[-1]] = val |
| else: |
| out[key] = val |
| return out |
|
|
|
|
| def _ensure_bot_id(data: dict): |
| global _auto_bot_id |
| if _auto_bot_id: |
| return |
| bots = data.get("data", {}).get("BOT", {}) |
| for bid in bots: |
| try: |
| _auto_bot_id = int(bid) |
| logger.info(f"Auto-detected BOT_ID={_auto_bot_id}") |
| return |
| except (ValueError, TypeError): |
| pass |
|
|
|
|
| def _send_reply(dialog_id: str, text: str): |
| bid = _auto_bot_id or BOT_ID |
| if not bid: |
| logger.error(f"Cannot send: bot_id={bid}") |
| return |
|
|
| import httpx |
|
|
| client_id = _webhook_auth.get("application_token", "") if _webhook_auth else "" |
|
|
| |
| if BITRIX_WEBHOOK_URL and client_id: |
| url = f"{BITRIX_WEBHOOK_URL}imbot.message.add.json" |
| try: |
| resp = httpx.post(url, params={"CLIENT_ID": client_id}, json={ |
| "BOT_ID": bid, |
| "DIALOG_ID": dialog_id, |
| "MESSAGE": text, |
| }, timeout=30) |
| logger.info(f"Reply (webhook+CLIENT_ID) {dialog_id}: {resp.status_code} {resp.text[:300]}") |
| if resp.status_code == 200: |
| return |
| except Exception as e: |
| logger.warning(f"Webhook+CLIENT_ID failed: {e}") |
|
|
| |
| if BITRIX_WEBHOOK_URL: |
| url = f"{BITRIX_WEBHOOK_URL}imbot.message.add.json" |
| try: |
| resp = httpx.post(url, json={ |
| "BOT_ID": bid, |
| "DIALOG_ID": dialog_id, |
| "MESSAGE": text, |
| }, timeout=30) |
| logger.info(f"Reply (webhook no CLIENT_ID) {dialog_id}: {resp.status_code} {resp.text[:300]}") |
| if resp.status_code == 200: |
| return |
| except Exception as e: |
| logger.warning(f"Webhook URL attempt failed: {e}") |
|
|
| |
| if BITRIX_WEBHOOK_URL: |
| url = f"{BITRIX_WEBHOOK_URL}im.message.add.json" |
| try: |
| resp = httpx.post(url, json={ |
| "DIALOG_ID": dialog_id, |
| "MESSAGE": text, |
| }, timeout=30) |
| logger.info(f"Reply (im.message) {dialog_id}: {resp.status_code} {resp.text[:300]}") |
| except Exception as e: |
| logger.error(f"All send methods failed: {e}") |
|
|
|
|
| def transfer_to_operator(dialog_id: str): |
| chat_id = None |
| if dialog_id.startswith("chat"): |
| try: |
| chat_id = int(dialog_id.replace("chat", "")) |
| except ValueError: |
| pass |
| if not chat_id: |
| logger.error(f"Cannot transfer: no chat_id from {dialog_id}") |
| return |
|
|
| import httpx |
| client_id = _webhook_auth.get("application_token", "") if _webhook_auth else "" |
|
|
| |
| _send_reply(dialog_id, "Оператор сейчас подключится. Пожалуйста, ожидайте.") |
|
|
| |
| if BITRIX_WEBHOOK_URL: |
| url = f"{BITRIX_WEBHOOK_URL}imopenlines.bot.session.operator" |
| try: |
| resp = httpx.post(url, json={"CHAT_ID": chat_id}, timeout=30) |
| logger.info(f"Transfer to operator: {resp.status_code} {resp.text[:300]}") |
| except Exception as e: |
| logger.error(f"Transfer error: {e}") |
|
|
|
|
| class WebhookHandler(BaseHTTPRequestHandler): |
| def do_GET(self): |
| if self.path in ("/", "/health"): |
| self.send_response(200) |
| self.send_header("Content-Type", "text/plain") |
| self.end_headers() |
| self.wfile.write(b"OK") |
| return |
|
|
| if self.path == "/logs": |
| from log_store import get_log |
| entries = get_log() |
| page = """<!DOCTYPE html><html lang="ru"><head> |
| <meta charset="utf-8"><title>ORTOS Bot Logs</title> |
| <style> |
| body{font-family:sans-serif;margin:20px;background:#111;color:#eee} |
| table{border-collapse:collapse;width:100%;font-size:13px} |
| th,td{text-align:left;padding:6px 10px;border-bottom:1px solid #333;vertical-align:top} |
| th{background:#222;color:#0f0;position:sticky;top:0} |
| tr:hover{background:#1a1a1a} |
| .mode{font-weight:bold;padding:2px 6px;border-radius:3px;font-size:11px;white-space:nowrap} |
| .groq{background:#1a3a1a;color:#4f4} |
| .local{background:#3a1a1a;color:#f88} |
| .fallback{background:#3a3a1a;color:#ff4} |
| .greeting{background:#1a1a3a;color:#44f} |
| .operator{background:#3a1a3a;color:#f4f} |
| .q{color:#ffa;max-width:250px;word-break:break-word} |
| .r{color:#afa;max-width:350px;word-break:break-word} |
| .detail{color:#888;font-size:11px;margin-top:4px;border-top:1px solid #333;padding-top:4px} |
| .lbl{color:#666} |
| .val{color:#eee} |
| .src{color:#8af} |
| .err{color:#f44} |
| summary{cursor:pointer;color:#8af;font-size:12px} |
| </style></head><body> |
| <h2>ORTOS Consultant — last 50 interactions</h2> |
| <table><thead><tr><th>Time</th><th>Mode</th><th>Q</th><th>Response</th><th>RAG</th></tr></thead>""" |
| for e in entries: |
| css = e["mode"] |
| md = e["search_method"] |
| llm = html.escape(e["llm_model"]) |
| ms = e["timing_ms"] |
|
|
| rag_html = f"<span class='src'>{html.escape(md)}</span>" |
| rag_html += f"<br><span class='lbl'>LLM:</span> <span class='val'>{llm}</span>" |
| rag_html += f"<br><span class='lbl'>⏱</span> <span class='val'>{ms}ms</span>" |
|
|
| if e.get("search_details"): |
| rag_html += "<details><summary>search results</summary>" |
| for d in e["search_details"]: |
| rag_html += f"<div class='detail'>" |
| rag_html += f"<b class='src'>{html.escape(d.get('title',''))}</b>" |
| br = d.get("bm25_rank") |
| er = d.get("embed_rank") |
| rs = d.get("rrf_score") |
| if br is not None: |
| rag_html += f"<br><span class='lbl'>BM25 rank:</span> <span class='val'>{br}</span>" |
| if er is not None: |
| rag_html += f"<br><span class='lbl'>bge-m3 rank:</span> <span class='val'>{er}</span>" |
| if rs is not None: |
| rag_html += f"<br><span class='lbl'>RRF score:</span> <span class='val'>{rs}</span>" |
| rag_html += "</div>" |
| rag_html += "</details>" |
|
|
| page += f"<tr><td>{e['time']}</td><td><span class='mode {css}'>{e['mode']}</span></td>" |
| page += f"<td class='q'>{html.escape(e['question'])}</td>" |
| page += f"<td class='r'>{html.escape(e['response'])}</td>" |
| page += f"<td>{rag_html}</td></tr>" |
| page += "</table></body></html>" |
|
|
| self.send_response(200) |
| self.send_header("Content-Type", "text/html; charset=utf-8") |
| self.end_headers() |
| self.wfile.write(page.encode()) |
| return |
|
|
| self.send_response(404) |
| self.end_headers() |
|
|
| def do_POST(self): |
| length = int(self.headers.get("Content-Length", 0)) |
| body = self.rfile.read(length) |
| path_only = urllib.parse.urlparse(self.path).path |
|
|
| logger.info(f"POST {path_only} event={body.decode('utf-8', errors='replace')[:200]}") |
|
|
| if path_only != "/bitrix/openlines_webhook": |
| self._json_response(200, {"status": "unknown_path"}) |
| return |
|
|
| data = _parse_form(body) |
| _ensure_bot_id(data) |
|
|
| global _webhook_auth |
| _webhook_auth = data.get("auth", {}) |
|
|
| event = data.get("event", "") |
| params = data.get("data", {}).get("PARAMS", {}) |
|
|
| logger.info(f"Parsed event={event} dialog={params.get('DIALOG_ID','')} msg='{str(params.get('MESSAGE',''))[:60]}'") |
|
|
| if event == "ONIMBOTJOINCHAT": |
| dialog_id = params.get("DIALOG_ID", "") |
| if dialog_id: |
| welcome = "Здравствуйте! Я — консультант салона ORTOS. Задайте мне вопрос о стельках, ценах, доставке или записи." |
| _send_reply(dialog_id, welcome) |
| self._json_response(200, {}) |
|
|
| elif event == "ONIMBOTMESSAGEADD": |
| dialog_id = params.get("DIALOG_ID", "") |
| text = params.get("MESSAGE", params.get("TEXT", "")) |
| from_user = params.get("FROM_USER_ID", "") |
| is_system = params.get("SYSTEM", "N") == "Y" |
| if text and dialog_id and not is_system and str(from_user) != "0": |
| logger.info(f"Processing message from user {from_user}: {text[:80]}") |
| reply = process_message(text, dialog_id) |
| if reply.startswith("Переход на оператора") or "переведу вас на оператора" in reply.lower(): |
| transfer_to_operator(dialog_id) |
| else: |
| _send_reply(dialog_id, reply) |
| elif not is_system and str(from_user) == "0": |
| logger.info("Skipping bot's own message") |
| self._json_response(200, {}) |
|
|
| else: |
| self._json_response(200, {}) |
|
|
| def _json_response(self, status, data): |
| body_b = json.dumps(data, ensure_ascii=False).encode() |
| self.send_response(status) |
| self.send_header("Content-Type", "application/json; charset=utf-8") |
| self.send_header("Content-Length", str(len(body_b))) |
| self.end_headers() |
| self.wfile.write(body_b) |
|
|
| def log_message(self, *args): |
| pass |
|
|
|
|
| def main(): |
| server = HTTPServer(('0.0.0.0', PORT), WebhookHandler) |
| logger.info(f"ORTOS Consultant running on port {PORT}") |
| logger.info(f"Webhook URL: POST /bitrix/openlines_webhook") |
| server.serve_forever() |
|
|
|
|
| if __name__ == '__main__': |
| main() |
|
|