From 7d260ca526fd2e3e2166226ef2e42d7af104ca16 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Sat, 16 May 2026 12:21:58 -0400 Subject: [PATCH] Fix HTML display: use plain text + _text_to_html for all bot messages All bot messages now built as plain text and converted via _text_to_html() which escapes content and converts newlines to
. This avoids raw HTML tags appearing literally in Odoo 18 Discuss. - _describe_zip: returns plain str (no Markup/HTML) - _post_file_clarification: builds plain text, posts via _text_to_html() - _find_pending_attachments: strip HTML before phrase matching - _text_to_html: new helper shared by clarification and agent replies Co-Authored-By: Claude Sonnet 4.6 --- addons/activeblue_ai/models/ab_ai_mail.py | 64 +++++++++++------------ 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/addons/activeblue_ai/models/ab_ai_mail.py b/addons/activeblue_ai/models/ab_ai_mail.py index d612c35..3ea938c 100644 --- a/addons/activeblue_ai/models/ab_ai_mail.py +++ b/addons/activeblue_ai/models/ab_ai_mail.py @@ -33,30 +33,33 @@ def _ext(filename: str) -> str: return filename.rsplit('.', 1)[-1].lower() if '.' in filename else '' -def _describe_zip(datas_b64: str, zip_name: str) -> Markup: - """Return a safe HTML summary of a ZIP's contents without extracting data.""" +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 _describe_zip(datas_b64: str, zip_name: str) -> str: + """Return a plain-text summary of a ZIP archive's contents.""" try: raw = base64.b64decode(datas_b64) with zipfile.ZipFile(io.BytesIO(raw)) as zf: members = [m for m in zf.namelist() if not m.endswith('/')] if not members: - return Markup(f'{escape(zip_name)} (empty archive)') + return f'{zip_name} (empty archive)' counts: dict[str, int] = {} for m in members: label = _EXT_LABELS.get(_ext(m), 'file') counts[label] = counts.get(label, 0) + 1 - type_summary = escape(', '.join(f'{n} {t}(s)' for t, n in counts.items())) - lines: list[Markup] = [ - Markup(f'{escape(zip_name)} — {len(members)} item(s): {type_summary}') - ] + type_summary = ', '.join(f'{n} {t}(s)' for t, n in counts.items()) + lines = [f'{zip_name} -- {len(members)} item(s): {type_summary}'] for m in members[:8]: - lines.append(Markup(f'  • {escape(m)}')) + lines.append(f' - {m}') if len(members) > 8: - lines.append(Markup(f'  … and {len(members) - 8} more')) - return Markup('
').join(lines) + lines.append(f' ... and {len(members) - 8} more') + return '\n'.join(lines) except Exception as exc: _logger.warning('_describe_zip failed for %s: %s', zip_name, exc) - return Markup(f'{escape(zip_name)} (could not inspect contents)') + return f'{zip_name} (could not inspect contents)' class DiscussChannel(models.Model): @@ -90,7 +93,6 @@ class DiscussChannel(models.Model): # Odoo 18 Discuss uploads attachments before posting the message and # passes their IDs in kwargs as attachment_ids (list of ints or ORM # commands). result.attachment_ids resolves those after super() runs. - # Log both so we can see exactly what arrives. _logger.info( 'AB AI mail hook: body=%r kwargs_keys=%s ' 'attachment_ids_kwarg=%r result.attachment_ids=%s', @@ -106,13 +108,13 @@ class DiscussChannel(models.Model): if not text and not attachments: return result - # ── Case 1: file(s) with no instruction ────────────────────────────── + # -- Case 1: file(s) with no instruction -------------------------------- # Show the user what we received and ask what to do with it. if attachments and not text: self._post_file_clarification(attachments, bot_partner) return result - # ── Case 2: text only — look back for a pending file upload ────────── + # -- Case 2: text only -- look back for a pending file upload ----------- # If the user just replied to our clarification question, find the # attachment(s) they uploaded earlier in this conversation. pending = self.env['ir.attachment'].browse() @@ -121,7 +123,7 @@ class DiscussChannel(models.Model): effective_attachments = attachments or pending - # ── Case 3: text (+ possibly pending files) → dispatch ─────────────── + # -- Case 3: text (+ possibly pending files) -> dispatch ---------------- 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 @@ -147,10 +149,8 @@ class DiscussChannel(models.Model): reply_text = ((response or {}).get('reply') or (response or {}).get('message') or 'I could not process your request right now.') - # Agent replies are plain text; convert newlines to
for Discuss. - reply_html = Markup('
').join(Markup(escape(ln)) for ln in reply_text.split('\n')) self.sudo().message_post( - body=reply_html, + body=_text_to_html(reply_text), author_id=bot_partner.id, message_type='comment', subtype_xmlid='mail.mt_comment', @@ -162,28 +162,28 @@ class DiscussChannel(models.Model): def _post_file_clarification(self, attachments, bot_partner): """Describe the uploaded file(s) and ask the user what to do with them.""" - lines: list[Markup] = [] + file_lines = [] for att in attachments: name = att.name or 'file' ext = _ext(name) if ext == 'zip' and att.datas: - lines.append(_describe_zip(att.datas, name)) + file_lines.append(_describe_zip(att.datas, name)) else: label = _EXT_LABELS.get(ext, 'file') - lines.append(Markup(f'{escape(name)} ({escape(label)})')) + file_lines.append(f'{name} ({label})') - file_summary = Markup('
').join(lines) - question = Markup( - 'I received the following file(s):
' - ) + file_summary + Markup( - '

' - 'What would you like me to do with them? Some options:
' - '• Create an expense report from these receipts
' - '• Import products from this data
' - '• Something else — just tell me what you need' + file_summary = '\n'.join(file_lines) + question = ( + f'I received the following file(s):\n' + f'{file_summary}\n' + f'\n' + f'What would you like me to do with them? Some options:\n' + f' - Create an expense report from these receipts\n' + f' - Import products from this data\n' + f' - Something else -- just tell me what you need' ) self.sudo().message_post( - body=question, + body=_text_to_html(question), author_id=bot_partner.id, message_type='comment', subtype_xmlid='mail.mt_comment', @@ -207,7 +207,7 @@ class DiscussChannel(models.Model): for msg in messages: is_bot = msg.author_id == bot_partner if is_bot: - body_lower = (msg.body or '').lower() + body_lower = _strip_html(msg.body or '').lower() if any(p in body_lower for p in _bot_question_phrases): prev_was_bot_question = True continue