Files
replypier/backend/app/channels/interactive.py
T
WangShen007 83a16ed07d feat(modules): 帮助中心、工单、工作流与多渠道接入
补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
2026-10-17 22:05:00 +08:00

201 lines
8.5 KiB
Python

"""
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Interactive (choice) messages: validation against a channel's limits, and
building one from the agent's plain list of options.
Checked before sending, so a bad message fails here with a readable reason
instead of as an opaque platform 400. Each channel's limits are in
channels/interactive_limits.py; the header, footer, section, list-button and
link-button limits below are WhatsApp's, the only channel with those parts.
"""
from typing import List, Optional
from app.channels.base import (
InteractiveButton, InteractiveKind, InteractiveMessage, InteractiveRow,
InteractiveSection,
)
from app.channels.constants import MAX_CHOICE_OPTIONS
from app.channels.interactive_limits import WHATSAPP_LIMITS, InteractiveLimits
SECTION_TITLE_MAX = 24
LIST_BUTTON_LABEL_MAX = 20
CTA_DISPLAY_TEXT_MAX = 20
HEADER_MAX = 60
FOOTER_MAX = 60
DEFAULT_LIST_BUTTON_LABEL = "Choose"
# The body of the choices when the reply itself was too long to carry them
DEFAULT_CHOICES_BODY = "Choose an option:"
ELLIPSIS = "…"
# Ids we give the agent's options
_QUICK_REPLY_ID_PREFIX = "qr_"
def _quick_reply_id(index: int) -> str:
return f"{_QUICK_REPLY_ID_PREFIX}{index}"
def truncate(text: str, limit: int) -> str:
"""Cut to `limit` characters, marking the cut with an ellipsis."""
text = text.strip()
return text if len(text) <= limit else text[:limit - len(ELLIPSIS)].rstrip() + ELLIPSIS
def from_options(body: str, options: List[str],
limits: InteractiveLimits = WHATSAPP_LIMITS) -> InteractiveMessage:
"""Build the message for the agent's options on a channel.
Buttons when there are few enough options and every one fits a button
title, or when the channel has no lists (titles are cut to fit; the full
text is kept with each); otherwise a list, whose rows allow longer titles
and carry the full text in the description when the title had to be cut.
"""
options = options[:MAX_CHOICE_OPTIONS]
fits = len(options) <= limits.max_buttons and all(len(o) <= limits.button_title_max for o in options)
if fits or not limits.supports_list:
return InteractiveMessage(
kind=InteractiveKind.BUTTONS,
body=body,
buttons=[InteractiveButton(id=_quick_reply_id(i), title=truncate(o, limits.button_title_max),
description=o if len(o) > limits.button_title_max else None)
for i, o in enumerate(options[:limits.max_buttons])],
)
rows = [
InteractiveRow(
id=_quick_reply_id(i),
title=truncate(o, limits.row_title_max),
description=truncate(o, limits.row_description_max) if len(o) > limits.row_title_max else None,
)
for i, o in enumerate(options[:limits.max_list_rows])
]
return InteractiveMessage(
kind=InteractiveKind.LIST,
body=body,
list_button_label=DEFAULT_LIST_BUTTON_LABEL,
sections=[InteractiveSection(rows=rows)],
)
def validate_interactive(message: InteractiveMessage,
limits: InteractiveLimits = WHATSAPP_LIMITS) -> List[str]:
"""Every reason the channel would reject this message; empty when valid."""
errors: List[str] = []
_check_text(errors, "body", message.body, limits.body_max, required=True)
_check_text(errors, "header", message.header, HEADER_MAX)
_check_text(errors, "footer", message.footer, FOOTER_MAX)
if message.kind == InteractiveKind.BUTTONS:
_validate_buttons(errors, message.buttons, limits)
elif message.kind == InteractiveKind.LIST:
if limits.supports_list:
_validate_list(errors, message, limits)
else:
errors.append("this channel can't show a list")
elif message.kind == InteractiveKind.CTA_URL:
if limits.supports_cta:
_validate_cta(errors, message)
else:
errors.append("this channel can't show a link button")
return errors
def to_text_fallback(message: InteractiveMessage, max_length: Optional[int] = None) -> str:
"""The same choices as plain text, for when the interactive form can't be
sent: the customer can still answer by typing the option. When the
channel caps message length, the body is shortened — never the choices."""
if message.kind == InteractiveKind.CTA_URL:
tail = f"{message.display_text}: {message.url}" if message.display_text else message.url
suffix = f"\n{tail}"
else:
lines = [f"{i}. {_fallback_line(c)}" for i, c in enumerate(all_choices(message), start=1)]
suffix = "\n\n" + "\n".join(lines) if lines else ""
body = message.body
if max_length is not None and len(body) + len(suffix) > max_length:
body = truncate(body, max(max_length - len(suffix), len(ELLIPSIS)))
return body + suffix
def choice_text(choice) -> str:
"""The full text of one choice: its title, or for a button or row whose
title had to be cut, its description (see from_options)."""
description = getattr(choice, "description", None)
if description and choice.title.endswith(ELLIPSIS):
return description
return choice.title
def all_choices(message: InteractiveMessage) -> list:
"""Every button and list row, in order."""
return list(message.buttons) + [r for s in message.sections for r in s.rows]
def _fallback_line(choice) -> str:
text = choice_text(choice)
description = getattr(choice, "description", None)
# A real description (not the cut title's full text) is worth showing too
return f"{text} — {description}" if description and description != text else text
def _check_text(errors: List[str], name: str, value: Optional[str], limit: int,
required: bool = False) -> None:
if not value or not value.strip():
if required:
errors.append(f"{name} is required")
return
if len(value) > limit:
errors.append(f"{name} must be at most {limit} characters")
def _check_ids(errors: List[str], name: str, ids: List[str], limits: InteractiveLimits) -> None:
if any(not i or len(i.encode()) > limits.id_max_bytes for i in ids):
errors.append(f"every {name} id must be 1-{limits.id_max_bytes} bytes")
if len(set(ids)) != len(ids):
errors.append(f"{name} ids must be unique")
def _validate_buttons(errors: List[str], buttons: List[InteractiveButton], limits: InteractiveLimits) -> None:
if not 1 <= len(buttons) <= limits.max_buttons:
errors.append(f"buttons needs 1-{limits.max_buttons} buttons")
for button in buttons:
_check_text(errors, "button title", button.title, limits.button_title_max, required=True)
_check_ids(errors, "button", [b.id for b in buttons], limits)
def _validate_list(errors: List[str], message: InteractiveMessage, limits: InteractiveLimits) -> None:
_check_text(errors, "list button label", message.list_button_label,
LIST_BUTTON_LABEL_MAX, required=True)
rows = [r for s in message.sections for r in s.rows]
if not message.sections or any(not s.rows for s in message.sections):
errors.append("every list section needs at least one row")
if not 1 <= len(rows) <= limits.max_list_rows:
errors.append(f"a list needs 1-{limits.max_list_rows} rows in total")
if len(message.sections) > 1 and any(not s.title for s in message.sections):
errors.append("section titles are required when there is more than one section")
for section in message.sections:
_check_text(errors, "section title", section.title, SECTION_TITLE_MAX)
for row in rows:
_check_text(errors, "row title", row.title, limits.row_title_max, required=True)
_check_text(errors, "row description", row.description, limits.row_description_max)
_check_ids(errors, "row", [r.id for r in rows], limits)
def _validate_cta(errors: List[str], message: InteractiveMessage) -> None:
_check_text(errors, "display text", message.display_text, CTA_DISPLAY_TEXT_MAX, required=True)
if not (message.url or "").lower().startswith("https://"):
errors.append("url must be an https:// link")