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