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 ''
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 <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:
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'<b>{escape(zip_name)}</b> (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'<b>{escape(zip_name)}</b> — {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('<br>').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'<b>{escape(zip_name)}</b> (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 <br> for Discuss.
reply_html = Markup('<br>').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'<b>{escape(name)}</b> ({escape(label)})'))
file_lines.append(f'{name} ({label})')
file_summary = Markup('<br>').join(lines)
question = Markup(
'I received the following file(s):<br>'
) + file_summary + Markup(
'<br><br>'
'What would you like me to do with them? Some options:<br>'
'&#x2022; <b>Create an expense report</b> from these receipts<br>'
'&#x2022; <b>Import products</b> from this data<br>'
'&#x2022; <b>Something else</b> — 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