feat(modules): 帮助中心、工单、工作流与多渠道接入
补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
1 parent
69a71aabbb
commit
e0baca15a9
225 files changed
+59337
No files matched your search
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from app.channels.base import (
|
||||
InteractiveButton, InteractiveKind, InteractiveMessage, InteractiveRow,
|
||||
InteractiveSection,
|
||||
)
|
||||
from app.channels.interactive import from_options, to_text_fallback, truncate, validate_interactive
|
||||
from app.channels.interactive_limits import WHATSAPP_LIMITS
|
||||
from app.channels.sent_choices import decode_sent_choices, encode_sent_choices, sent_choice_text
|
||||
|
||||
|
||||
class TestFromOptions:
|
||||
def test_few_short_options_become_buttons(self):
|
||||
message = from_options("Confirm the booking?", ["Yes", "No"])
|
||||
assert message.kind == InteractiveKind.BUTTONS
|
||||
assert [(b.id, b.title) for b in message.buttons] == [("qr_0", "Yes"), ("qr_1", "No")]
|
||||
assert validate_interactive(message) == []
|
||||
|
||||
def test_more_than_three_options_become_a_list(self):
|
||||
options = [f"Option {i}" for i in range(WHATSAPP_LIMITS.max_buttons + 1)]
|
||||
message = from_options("Pick one", options)
|
||||
assert message.kind == InteractiveKind.LIST
|
||||
assert [r.title for r in message.sections[0].rows] == options
|
||||
assert validate_interactive(message) == []
|
||||
|
||||
def test_one_long_option_switches_to_a_list_with_the_full_text(self):
|
||||
long_option = "Reschedule to next Saturday morning"
|
||||
message = from_options("What now?", ["Cancel", long_option])
|
||||
assert message.kind == InteractiveKind.LIST
|
||||
row = message.sections[0].rows[1]
|
||||
assert len(row.title) <= WHATSAPP_LIMITS.row_title_max and row.title.endswith("…")
|
||||
assert row.description == long_option
|
||||
assert validate_interactive(message) == []
|
||||
|
||||
def test_options_beyond_the_list_cap_are_dropped(self):
|
||||
message = from_options("Pick", [str(i) for i in range(WHATSAPP_LIMITS.max_list_rows + 5)])
|
||||
assert len(message.sections[0].rows) == WHATSAPP_LIMITS.max_list_rows
|
||||
|
||||
|
||||
class TestValidation:
|
||||
def test_buttons_limits(self):
|
||||
message = InteractiveMessage(
|
||||
kind=InteractiveKind.BUTTONS, body="x",
|
||||
buttons=[InteractiveButton(id="a", title="t" * (WHATSAPP_LIMITS.button_title_max + 1)),
|
||||
InteractiveButton(id="a", title="ok")] * 2)
|
||||
errors = validate_interactive(message)
|
||||
assert any("1-3 buttons" in e for e in errors)
|
||||
assert any("button title" in e for e in errors)
|
||||
assert any("unique" in e for e in errors)
|
||||
|
||||
def test_body_is_required(self):
|
||||
message = InteractiveMessage(kind=InteractiveKind.BUTTONS, body=" ",
|
||||
buttons=[InteractiveButton(id="a", title="A")])
|
||||
assert "body is required" in validate_interactive(message)
|
||||
|
||||
def test_list_needs_label_and_titled_sections_when_several(self):
|
||||
message = InteractiveMessage(
|
||||
kind=InteractiveKind.LIST, body="x",
|
||||
sections=[InteractiveSection(rows=[InteractiveRow(id="1", title="a")]),
|
||||
InteractiveSection(rows=[InteractiveRow(id="2", title="b")])])
|
||||
errors = validate_interactive(message)
|
||||
assert "list button label is required" in errors
|
||||
assert any("section titles are required" in e for e in errors)
|
||||
|
||||
def test_list_row_cap_counts_across_sections(self):
|
||||
rows = [InteractiveRow(id=str(i), title=str(i)) for i in range(WHATSAPP_LIMITS.max_list_rows)]
|
||||
message = InteractiveMessage(
|
||||
kind=InteractiveKind.LIST, body="x", list_button_label="Pick",
|
||||
sections=[InteractiveSection(title="A", rows=rows),
|
||||
InteractiveSection(title="B", rows=[InteractiveRow(id="x", title="x")])])
|
||||
assert any("rows in total" in e for e in validate_interactive(message))
|
||||
|
||||
def test_cta_url_must_be_https(self):
|
||||
message = InteractiveMessage(kind=InteractiveKind.CTA_URL, body="x",
|
||||
display_text="Open", url="http://example.com")
|
||||
assert any("https" in e for e in validate_interactive(message))
|
||||
message.url = "https://example.com"
|
||||
assert validate_interactive(message) == []
|
||||
|
||||
|
||||
class TestTextFallback:
|
||||
def test_numbered_choices_after_the_body(self):
|
||||
text = to_text_fallback(from_options("Confirm?", ["Yes", "No"]))
|
||||
assert text == "Confirm?\n\n1. Yes\n2. No"
|
||||
|
||||
def test_cut_titles_fall_back_to_the_full_option(self):
|
||||
long_option = "Reschedule to next Saturday morning"
|
||||
text = to_text_fallback(from_options("What now?", ["Cancel", long_option]))
|
||||
assert text.endswith(f"2. {long_option}")
|
||||
|
||||
def test_cta_shows_the_link(self):
|
||||
message = InteractiveMessage(kind=InteractiveKind.CTA_URL, body="Invoice ready",
|
||||
display_text="Pay", url="https://pay.example/1")
|
||||
assert to_text_fallback(message) == "Invoice ready\nPay: https://pay.example/1"
|
||||
|
||||
|
||||
class TestTextFallbackLimits:
|
||||
def test_a_long_body_is_shortened_never_the_choices(self):
|
||||
text = to_text_fallback(from_options("x" * 5000, ["Yes", "No"]), max_length=4096)
|
||||
assert len(text) <= 4096
|
||||
assert text.endswith("1. Yes\n2. No")
|
||||
assert "…" in text
|
||||
|
||||
def test_no_limit_keeps_everything(self):
|
||||
assert to_text_fallback(from_options("x" * 5000, ["Yes"])).startswith("x" * 5000)
|
||||
|
||||
def test_a_real_row_description_is_shown(self):
|
||||
message = InteractiveMessage(
|
||||
kind=InteractiveKind.LIST, body="Pick", list_button_label="Pick",
|
||||
sections=[InteractiveSection(rows=[InteractiveRow(id="s1", title="U12", description="09:00")])])
|
||||
assert to_text_fallback(message).endswith("1. U12 — 09:00")
|
||||
|
||||
|
||||
class TestSentChoicesRecord:
|
||||
def test_record_round_trips_encrypted_with_full_texts(self):
|
||||
long_option = "Reschedule to next Saturday morning"
|
||||
record = encode_sent_choices(from_options("What now?", ["Cancel", long_option]), as_text=False)
|
||||
|
||||
assert long_option not in str(record) # encrypted at rest
|
||||
decoded = decode_sent_choices(record)
|
||||
assert decoded["kind"] == "list" and decoded["as_text"] is False
|
||||
assert sent_choice_text(decoded, "qr_1") == long_option
|
||||
assert sent_choice_text(decoded, "qr_9") is None
|
||||
|
||||
def test_unreadable_records_are_ignored(self):
|
||||
assert decode_sent_choices(None) is None
|
||||
assert decode_sent_choices("garbage") is None
|
||||
assert sent_choice_text(None, "qr_0") is None
|
||||
|
||||
|
||||
def test_truncate_marks_the_cut():
|
||||
assert truncate("short", 10) == "short"
|
||||
assert truncate("a" * 30, 10) == "a" * 9 + "…"
|
||||
Reference in new issue
Block a user