from __future__ import annotations import base64 import logging import re import threading import requests as _requests from markupsafe import Markup, escape from odoo import SUPERUSER_ID, api, registry as odoo_registry, models _logger = logging.getLogger(__name__) _HTML_TAG = re.compile(r'<[^>]+>') def _strip_html(html: str) -> str: return _HTML_TAG.sub(' ', html or '').strip() def _text_to_html(text: str) -> Markup: """Convert plain text to HTML — escapes content, turns newlines into
.""" return Markup('
').join(Markup(escape(line)) for line in text.split('\n')) def _post_bot_reply(db: str, channel_id: int, bot_partner_id: int, reply_text: str): """Open a fresh DB cursor and post the bot reply to the channel.""" try: with odoo_registry(db).cursor() as cr: env = api.Environment(cr, SUPERUSER_ID, {}) env['discuss.channel'].browse(channel_id).sudo().message_post( body=_text_to_html(reply_text), author_id=bot_partner_id, message_type='comment', subtype_xmlid='mail.mt_comment', ) cr.commit() except Exception as exc: _logger.error('_post_bot_reply failed channel=%s: %s', channel_id, exc) def _agent_thread(db: str, uid: int, text: str, att_data: list, bot_partner_id: int, channel_id: int, bot_url: str, bot_secret: str): """ Background thread: calls the agent service and posts the reply. All messages — text, files, or both — are routed here so the LLM handles every response. Nothing is intercepted or templated in Odoo. """ try: headers = {} if bot_secret: headers['X-ActiveBlue-Signature'] = bot_secret if att_data: # Send files (with or without text) to the upload endpoint. # Passing an empty message lets the agent service decide intent # from the file contents rather than a scripted default. files = [('files', (name, data, mime)) for name, data, mime in att_data] form = { 'user_id': str(uid), 'message': text, # may be empty — agent service handles that 'session_id': '', } resp = _requests.post(bot_url + '/upload', data=form, files=files, headers=headers, timeout=600) else: payload = {'user_id': str(uid), 'message': text, 'context': {'source': 'discuss'}} headers['Content-Type'] = 'application/json' resp = _requests.post(bot_url + '/dispatch', json=payload, headers=headers, timeout=600) resp.raise_for_status() response = resp.json() reply_text = (response or {}).get('reply') or (response or {}).get('message') or \ 'I could not process your request right now.' except _requests.exceptions.Timeout: reply_text = 'The request timed out. Please try again.' except _requests.exceptions.HTTPError as exc: detail = '' try: detail = exc.response.json().get('detail') or '' except Exception: pass _logger.error('Agent HTTP error channel=%s status=%s: %s', channel_id, exc.response.status_code if exc.response else '?', exc) if detail: reply_text = f'The agent returned an error: {detail}' else: reply_text = (f'The agent service returned an unexpected error ' f'(HTTP {exc.response.status_code if exc.response else "?"}). ' f'Please try again or contact your administrator.') except Exception as exc: _logger.error('Agent thread error channel=%s: %s', channel_id, exc) reply_text = f'I encountered an error: {exc}' _post_bot_reply(db, channel_id, bot_partner_id, reply_text) class DiscussChannel(models.Model): _inherit = 'discuss.channel' @api.model def _ai_bot_partner(self): return self.env.ref('activeblue_ai.partner_activeblue_ai', raise_if_not_found=False) def message_post(self, *, body='', author_id=None, **kwargs): result = super().message_post(body=body, author_id=author_id, **kwargs) # Only intercept direct-message channels if self.channel_type != 'chat': return result bot_partner = self._ai_bot_partner() if not bot_partner: return result member_partners = self.channel_member_ids.partner_id if bot_partner not in member_partners: return result # Never react to the bot's own messages if author_id == bot_partner.id: return result text = _strip_html(body) attachments = result.attachment_ids # Nothing to process if not text and not attachments: return result # Read attachment bytes NOW, inside the current transaction att_data: list[tuple[str, bytes, str]] = [] for att in attachments: try: data = base64.b64decode(att.datas) if att.datas else b'' att_data.append((att.name or 'attachment', data, att.mimetype or 'application/octet-stream')) except Exception as exc: _logger.warning('Could not read attachment %s: %s', att.id, exc) human_partner = member_partners.filtered(lambda p: p != bot_partner)[:1] user = self.env['res.users'].search([('partner_id', '=', human_partner.id)], limit=1) uid = user.id if user else self.env.uid bot = self.env['ab.ai.bot'].sudo().search([('active', '=', True)], limit=1) if not bot: return result db = self.env.cr.dbname bot_url = bot._get_service_url() bot_secret = bot.webhook_secret or '' channel_id = self.id bot_partner_id = bot_partner.id # Launch the agent call in a daemon thread — message_post returns immediately threading.Thread( target=_agent_thread, args=(db, uid, text, att_data, bot_partner_id, channel_id, bot_url, bot_secret), daemon=True, ).start() return result