from __future__ import annotations import base64 import logging import re import threading import time 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'<[^>]+>') # Matches /web/content/ir.attachment// or /web/image/ir.attachment// _ATT_URL_RE = re.compile(r'/(?:web/content|web/image)/ir\.attachment/(\d+)/') 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 _read_message_attachments(db: str, message_id: int) -> list[tuple[str, bytes, str]]: """Re-read attachment bytes for a message using a fresh DB cursor. Called from the background agent thread as a fallback when the message_post override ran before the transaction that linked the attachment to the message had committed (common in Odoo 18 Discuss). Retries up to 3 times with 0.5s delay. """ for attempt in range(3): try: with odoo_registry(db).cursor() as cr: env = api.Environment(cr, SUPERUSER_ID, {}) msg = env['mail.message'].browse(message_id) att_data = [] for att in msg.attachment_ids: try: data = base64.b64decode(att.datas) if att.datas else b'' if data: att_data.append((att.name or 'attachment', data, att.mimetype or 'application/octet-stream')) except Exception as exc: _logger.warning('_read_message_attachments: decode att %s: %s', att.id, exc) if att_data: return att_data except Exception as exc: _logger.warning('_read_message_attachments attempt %d: %s', attempt, exc) if attempt < 2: time.sleep(0.5) return [] 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, message_id: int = 0): """ 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. """ # Deferred attachment detection: if no att_data was found in message_post # (happens in Odoo 18 when the transaction linking attachments to the message # commits after our override already ran), wait briefly and retry using a # fresh DB cursor so we see the committed attachment data. if not att_data and message_id: time.sleep(1.0) att_data = _read_message_attachments(db, message_id) if att_data: _logger.info('_agent_thread: deferred read found %d attachment(s) for msg %d', len(att_data), message_id) 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) message_id = result.id # ── Attachment detection ───────────────────────────────────────────── # In Odoo 18, file attachments in Discuss can be linked to a message # via three different mechanisms depending on the upload path. Try all # three so we don't miss any files. # Method 1: standard Many2many relation table (mail.message → ir.attachment) attachments = result.attachment_ids # Method 2: ir.attachment records with res_model='mail.message' (Odoo 15+ style) if not attachments: attachments = self.env['ir.attachment'].sudo().search([ ('res_model', '=', 'mail.message'), ('res_id', '=', message_id), ]) # Method 3: attachment IDs embedded in HTML body links # e.g. file.zip if not attachments: body_att_ids = [int(m) for m in _ATT_URL_RE.findall(body or '')] if body_att_ids: attachments = self.env['ir.attachment'].sudo().browse(body_att_ids).exists() # Read the raw bytes for each attachment found att_data: list[tuple[str, bytes, str]] = [] for att in attachments: try: data = base64.b64decode(att.datas) if att.datas else b'' if data: att_data.append((att.name or 'attachment', data, att.mimetype or 'application/octet-stream')) else: _logger.warning('message_post: attachment %s (%s) has no data — ' 'will retry in background thread', att.id, att.name) except Exception as exc: _logger.warning('Could not read attachment %s: %s', att.id, exc) # Nothing to process if not text and not att_data and not attachments: return result # ── Fire the agent ─────────────────────────────────────────────────── 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 _logger.debug('message_post: channel=%s msg=%s text=%r att_count=%d deferred=%s', channel_id, message_id, text[:60] if text else '', len(att_data), bool(attachments and not att_data)) # Launch the agent call in a daemon thread — message_post returns immediately. # message_id is passed so the thread can re-read attachments if they weren't # yet committed when we read them above (Odoo 18 timing race). threading.Thread( target=_agent_thread, args=(db, uid, text, att_data, bot_partner_id, channel_id, bot_url, bot_secret), kwargs={'message_id': message_id}, daemon=True, ).start() return result