from odoo import models, fields, api, _ from odoo.exceptions import UserError from datetime import timedelta import requests import logging _logger = logging.getLogger(__name__) AGENT_SERVICE_URL_PARAM = 'activeblue_ai.agent_service_url' WEBHOOK_SECRET_PARAM = 'activeblue_ai.webhook_secret' class AbAiBot(models.Model): _name = 'ab.ai.bot' _description = 'ActiveBlue AI Bot' _rec_name = 'display_name' display_name = fields.Char(string='Bot Name', default='ActiveBlue AI', required=True) active = fields.Boolean(default=True) agent_service_url = fields.Char( string='Agent Service URL', default='http://192.168.2.47:8001', required=True, ) webhook_secret = fields.Char(string='Webhook Secret') privacy_mode = fields.Selection( [('local', 'Local (Ollama only)'), ('hybrid', 'Hybrid'), ('cloud', 'Cloud (Claude)')], string='Privacy Mode', default='local', required=True, ) status = fields.Selection( [('online', 'Online'), ('offline', 'Offline'), ('error', 'Error')], string='Status', default='offline', readonly=True, ) last_ping = fields.Datetime(string='Last Ping', readonly=True) notes = fields.Text(string='Notes') @api.model def get_active_bot(self): bot = self.search([('active', '=', True)], limit=1) if not bot: raise UserError(_('No active AI bot configured. Please configure ActiveBlue AI.')) return bot def _get_service_url(self): self.ensure_one() return (self.agent_service_url or '').rstrip('/') def _build_headers(self): self.ensure_one() headers = {'Content-Type': 'application/json'} if self.webhook_secret: headers['X-ActiveBlue-Signature'] = self.webhook_secret return headers def action_ping(self): self.ensure_one() url = self._get_service_url() + '/health/detailed' try: resp = requests.get(url, timeout=5, headers=self._build_headers()) if resp.status_code != 200: self.write({'status': 'error'}) return {'type': 'ir.actions.client', 'tag': 'display_notification', 'params': {'message': _('AI service returned %s') % resp.status_code, 'type': 'warning'}} data = resp.json() if resp.content else {} # Bot is only "online" when every backend the LLM router needs is ok. # Local privacy mode requires Ollama; cloud requires Claude. We treat # any backend whose status is not 'ok' as a hard failure for the # privacy mode in use, plus DB and master agent are always required. db_ok = data.get('db') == 'ok' master_ok = data.get('master_agent') == 'ok' mode = data.get('privacy_mode') or self.privacy_mode ollama_ok = data.get('ollama') == 'ok' llm_ok = ollama_ok if mode == 'local' else True if db_ok and master_ok and llm_ok: self.write({'status': 'online', 'last_ping': fields.Datetime.now()}) return {'type': 'ir.actions.client', 'tag': 'display_notification', 'params': {'message': _('AI service is online'), 'type': 'success'}} self.write({'status': 'error', 'last_ping': fields.Datetime.now()}) reason = ', '.join( f'{k}={data.get(k)}' for k in ('db', 'master_agent', 'ollama') if data.get(k) and data.get(k) != 'ok' ) or 'degraded' return {'type': 'ir.actions.client', 'tag': 'display_notification', 'params': {'message': _('AI service degraded: %s') % reason, 'type': 'warning'}} except Exception as exc: self.write({'status': 'offline'}) return {'type': 'ir.actions.client', 'tag': 'display_notification', 'params': {'message': _('AI service unreachable: %s') % exc, 'type': 'danger'}} def dispatch_message(self, user_id, message, context=None, session_id=None): self.ensure_one() url = self._get_service_url() + '/dispatch' payload = { 'user_id': str(user_id), 'message': message, 'context': context or {}, } if session_id: payload['session_id'] = session_id try: resp = requests.post(url, json=payload, headers=self._build_headers(), timeout=600) resp.raise_for_status() return resp.json() except requests.exceptions.Timeout: raise UserError(_('AI service timed out. Please try again.')) except requests.exceptions.RequestException as exc: _logger.error('dispatch_message failed: %s', exc) raise UserError(_('Could not reach AI service: %s') % exc) def dispatch_message_with_files(self, user_id, message, attachments, context=None, session_id=None): """Send a message with file attachments to the /upload endpoint as multipart.""" self.ensure_one() import base64 url = self._get_service_url() + '/upload' files = [] for att in attachments: try: data = base64.b64decode(att.datas) if att.datas else b'' files.append(('files', (att.name or 'attachment', data, att.mimetype or 'application/octet-stream'))) except Exception as exc: _logger.warning('Could not encode attachment %s: %s', att.id, exc) # Omit Content-Type so requests sets the multipart boundary automatically headers = {} if self.webhook_secret: headers['X-ActiveBlue-Signature'] = self.webhook_secret form_data = { 'user_id': str(user_id), 'message': message or 'Create an employee expense report from these receipts.', 'session_id': session_id or '', } try: resp = requests.post(url, data=form_data, files=files or [('files', ('empty', b'', 'text/plain'))], headers=headers, timeout=600) resp.raise_for_status() return resp.json() except requests.exceptions.Timeout: raise UserError(_('AI service timed out. Please try again.')) except requests.exceptions.RequestException as exc: _logger.error('dispatch_message_with_files failed: %s', exc) raise UserError(_('Could not reach AI service: %s') % exc) @api.model def cron_ping_all(self): any_online = False for bot in self.search([('active', '=', True)]): try: bot.action_ping() if bot.status == 'online': any_online = True except Exception as exc: _logger.warning('Ping failed for bot %s: %s', bot.id, exc) # Mirror agent-service health to the bot user's Discuss presence so it # shows a green dot when the agent is reachable. self._sync_bot_user_presence(online=any_online) @api.model def _sync_bot_user_presence(self, online): bot_user = self.env['res.users'].sudo().search( [('login', 'in', ('activeblue_ai_bot', 'activeblue_ai_bot@local'))], limit=1) if not bot_user: return if 'bus.presence' not in self.env: return try: Presence = self.env['bus.presence'] now = fields.Datetime.now() # bus.presence.status is a computed field — write only last_poll/last_presence. # When online: set last_poll 90s ahead so the bot stays "online" across the # full 60s cron cycle (Odoo DISCONNECTION_TIMER is 30s). # When offline: set last_poll an hour in the past to force "offline" state. if online: poll_time = now + timedelta(seconds=90) presence_time = now else: poll_time = now - timedelta(hours=1) presence_time = now - timedelta(hours=1) vals = {'last_poll': poll_time, 'last_presence': presence_time} rec = Presence.sudo().search([('user_id', '=', bot_user.id)], limit=1) if rec: rec.write(vals) else: vals['user_id'] = bot_user.id Presence.sudo().create(vals) except Exception as exc: _logger.warning('Could not update bot user presence: %s', exc)