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 # Systems that must all report 'ok' for the bot to be considered online. REQUIRED_SYSTEMS = ['db', 'odoo', 'ollama', 'master_agent'] 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', 'last_ping': fields.Datetime.now()}) self._sync_bot_user_presence(online=False) 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 {} # Check every required system individually. checks = {s: data.get(s) == 'ok' for s in self.REQUIRED_SYSTEMS} failing = [s for s, ok in checks.items() if not ok] if not failing: self.write({'status': 'online', 'last_ping': fields.Datetime.now()}) self._sync_bot_user_presence(online=True) return {'type': 'ir.actions.client', 'tag': 'display_notification', 'params': {'message': _('AI service is online — all systems operational'), 'type': 'success'}} self.write({'status': 'error', 'last_ping': fields.Datetime.now()}) self._sync_bot_user_presence(online=False) return {'type': 'ir.actions.client', 'tag': 'display_notification', 'params': {'message': _('AI service degraded — failing: %s') % ', '.join(failing), 'type': 'warning'}} except Exception as exc: self.write({'status': 'offline', 'last_ping': fields.Datetime.now()}) self._sync_bot_user_presence(online=False) 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): for bot in self.search([('active', '=', True)]): try: bot.action_ping() except Exception as exc: _logger.warning('Ping failed for bot %s: %s', bot.id, exc) @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 both 24h ahead so the bot stays "online" regardless of # cron timing. The cron explicitly marks offline by setting them to the past. if online: poll_time = now + timedelta(hours=24) presence_time = now + timedelta(hours=24) 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)