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 <br>. 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 <noreply@anthropic.com>
This commit is contained in:
Carlos Garcia
2026-05-16 12:21:58 -04:00
parent 9e3fe974dc
commit 7d260ca526

View File

@@ -33,30 +33,33 @@ def _ext(filename: str) -> str:
return filename.rsplit('.', 1)[-1].lower() if '.' in filename else '' return filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
def _describe_zip(datas_b64: str, zip_name: str) -> Markup: def _text_to_html(text: str) -> Markup:
"""Return a safe HTML summary of a ZIP's contents without extracting data.""" """Convert plain text to HTML — escapes content, turns newlines into <br>."""
return Markup('<br>').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: try:
raw = base64.b64decode(datas_b64) raw = base64.b64decode(datas_b64)
with zipfile.ZipFile(io.BytesIO(raw)) as zf: with zipfile.ZipFile(io.BytesIO(raw)) as zf:
members = [m for m in zf.namelist() if not m.endswith('/')] members = [m for m in zf.namelist() if not m.endswith('/')]
if not members: if not members:
return Markup(f'<b>{escape(zip_name)}</b> (empty archive)') return f'{zip_name} (empty archive)'
counts: dict[str, int] = {} counts: dict[str, int] = {}
for m in members: for m in members:
label = _EXT_LABELS.get(_ext(m), 'file') label = _EXT_LABELS.get(_ext(m), 'file')
counts[label] = counts.get(label, 0) + 1 counts[label] = counts.get(label, 0) + 1
type_summary = escape(', '.join(f'{n} {t}(s)' for t, n in counts.items())) type_summary = ', '.join(f'{n} {t}(s)' for t, n in counts.items())
lines: list[Markup] = [ lines = [f'{zip_name} -- {len(members)} item(s): {type_summary}']
Markup(f'<b>{escape(zip_name)}</b> — {len(members)} item(s): {type_summary}')
]
for m in members[:8]: for m in members[:8]:
lines.append(Markup(f'  • {escape(m)}')) lines.append(f' - {m}')
if len(members) > 8: if len(members) > 8:
lines.append(Markup(f'  … and {len(members) - 8} more')) lines.append(f' ... and {len(members) - 8} more')
return Markup('<br>').join(lines) return '\n'.join(lines)
except Exception as exc: except Exception as exc:
_logger.warning('_describe_zip failed for %s: %s', zip_name, exc) _logger.warning('_describe_zip failed for %s: %s', zip_name, exc)
return Markup(f'<b>{escape(zip_name)}</b> (could not inspect contents)') return f'{zip_name} (could not inspect contents)'
class DiscussChannel(models.Model): class DiscussChannel(models.Model):
@@ -90,7 +93,6 @@ class DiscussChannel(models.Model):
# Odoo 18 Discuss uploads attachments before posting the message and # Odoo 18 Discuss uploads attachments before posting the message and
# passes their IDs in kwargs as attachment_ids (list of ints or ORM # passes their IDs in kwargs as attachment_ids (list of ints or ORM
# commands). result.attachment_ids resolves those after super() runs. # commands). result.attachment_ids resolves those after super() runs.
# Log both so we can see exactly what arrives.
_logger.info( _logger.info(
'AB AI mail hook: body=%r kwargs_keys=%s ' 'AB AI mail hook: body=%r kwargs_keys=%s '
'attachment_ids_kwarg=%r result.attachment_ids=%s', 'attachment_ids_kwarg=%r result.attachment_ids=%s',
@@ -106,13 +108,13 @@ class DiscussChannel(models.Model):
if not text and not attachments: if not text and not attachments:
return result 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. # Show the user what we received and ask what to do with it.
if attachments and not text: if attachments and not text:
self._post_file_clarification(attachments, bot_partner) self._post_file_clarification(attachments, bot_partner)
return result 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 # If the user just replied to our clarification question, find the
# attachment(s) they uploaded earlier in this conversation. # attachment(s) they uploaded earlier in this conversation.
pending = self.env['ir.attachment'].browse() pending = self.env['ir.attachment'].browse()
@@ -121,7 +123,7 @@ class DiscussChannel(models.Model):
effective_attachments = attachments or pending 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] human_partner = member_partners.filtered(lambda p: p != bot_partner)[:1]
user = self.env['res.users'].search([('partner_id', '=', human_partner.id)], limit=1) user = self.env['res.users'].search([('partner_id', '=', human_partner.id)], limit=1)
uid = user.id if user else self.env.uid 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 reply_text = ((response or {}).get('reply') or (response or {}).get('message') or
'I could not process your request right now.') 'I could not process your request right now.')
# Agent replies are plain text; convert newlines to <br> for Discuss.
reply_html = Markup('<br>').join(Markup(escape(ln)) for ln in reply_text.split('\n'))
self.sudo().message_post( self.sudo().message_post(
body=reply_html, body=_text_to_html(reply_text),
author_id=bot_partner.id, author_id=bot_partner.id,
message_type='comment', message_type='comment',
subtype_xmlid='mail.mt_comment', subtype_xmlid='mail.mt_comment',
@@ -162,28 +162,28 @@ class DiscussChannel(models.Model):
def _post_file_clarification(self, attachments, bot_partner): def _post_file_clarification(self, attachments, bot_partner):
"""Describe the uploaded file(s) and ask the user what to do with them.""" """Describe the uploaded file(s) and ask the user what to do with them."""
lines: list[Markup] = [] file_lines = []
for att in attachments: for att in attachments:
name = att.name or 'file' name = att.name or 'file'
ext = _ext(name) ext = _ext(name)
if ext == 'zip' and att.datas: if ext == 'zip' and att.datas:
lines.append(_describe_zip(att.datas, name)) file_lines.append(_describe_zip(att.datas, name))
else: else:
label = _EXT_LABELS.get(ext, 'file') label = _EXT_LABELS.get(ext, 'file')
lines.append(Markup(f'<b>{escape(name)}</b> ({escape(label)})')) file_lines.append(f'{name} ({label})')
file_summary = Markup('<br>').join(lines) file_summary = '\n'.join(file_lines)
question = Markup( question = (
'I received the following file(s):<br>' f'I received the following file(s):\n'
) + file_summary + Markup( f'{file_summary}\n'
'<br><br>' f'\n'
'What would you like me to do with them? Some options:<br>' f'What would you like me to do with them? Some options:\n'
'&#x2022; <b>Create an expense report</b> from these receipts<br>' f' - Create an expense report from these receipts\n'
'&#x2022; <b>Import products</b> from this data<br>' f' - Import products from this data\n'
'&#x2022; <b>Something else</b> — just tell me what you need' f' - Something else -- just tell me what you need'
) )
self.sudo().message_post( self.sudo().message_post(
body=question, body=_text_to_html(question),
author_id=bot_partner.id, author_id=bot_partner.id,
message_type='comment', message_type='comment',
subtype_xmlid='mail.mt_comment', subtype_xmlid='mail.mt_comment',
@@ -207,7 +207,7 @@ class DiscussChannel(models.Model):
for msg in messages: for msg in messages:
is_bot = msg.author_id == bot_partner is_bot = msg.author_id == bot_partner
if is_bot: 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): if any(p in body_lower for p in _bot_question_phrases):
prev_was_bot_question = True prev_was_bot_question = True
continue continue