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:
@@ -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'
|
||||||
'• <b>Create an expense report</b> from these receipts<br>'
|
f' - Create an expense report from these receipts\n'
|
||||||
'• <b>Import products</b> from this data<br>'
|
f' - Import products from this data\n'
|
||||||
'• <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
|
||||||
|
|||||||
Reference in New Issue
Block a user