实现完整的Freshdesk和Freshchat集成支持,包含会话守卫、错误定义、消息去重、配置管理、webhook处理、出站消息发送、状态监控、安全校验、配对功能和流式回复支持
188 lines
5.9 KiB
Python
188 lines
5.9 KiB
Python
import html as html_mod
|
|
import re
|
|
from time import time as _time
|
|
|
|
ALLOWED_TAGS = {
|
|
"p",
|
|
"br",
|
|
"b",
|
|
"strong",
|
|
"i",
|
|
"em",
|
|
"u",
|
|
"s",
|
|
"del",
|
|
"ul",
|
|
"ol",
|
|
"li",
|
|
"a",
|
|
"img",
|
|
"table",
|
|
"tr",
|
|
"td",
|
|
"th",
|
|
"h1",
|
|
"h2",
|
|
"h3",
|
|
"h4",
|
|
"h5",
|
|
"h6",
|
|
"hr",
|
|
"blockquote",
|
|
"code",
|
|
"pre",
|
|
"span",
|
|
}
|
|
|
|
ALLOWED_ATTRS = {
|
|
"a": {"href", "rel", "target"},
|
|
"img": {"src", "alt", "width", "height"},
|
|
"td": {"colspan", "rowspan"},
|
|
"th": {"colspan", "rowspan"},
|
|
}
|
|
|
|
FORBIDDEN_TAGS = {"script", "style", "iframe", "object", "embed", "form", "input"}
|
|
|
|
|
|
def sanitize_html(html_body: str) -> str:
|
|
for tag in FORBIDDEN_TAGS:
|
|
html_body = re.sub(
|
|
rf"<\s*{tag}[^>]*>.*?<\s*/\s*{tag}\s*>",
|
|
"",
|
|
html_body,
|
|
flags=re.DOTALL | re.IGNORECASE,
|
|
)
|
|
html_body = re.sub(
|
|
rf"<\s*{tag}[^>]*/?\s*>",
|
|
"",
|
|
html_body,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
return html_body
|
|
|
|
|
|
def markdown_to_html(md_text: str) -> str:
|
|
text = html_mod.escape(md_text)
|
|
|
|
text = re.sub(r"```(\w+)?\n?(.*?)```", r"<pre><code>\2</code></pre>", text, flags=re.DOTALL)
|
|
text = re.sub(r"`([^`]+)`", r"<code>\1</code>", text)
|
|
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
|
|
text = re.sub(r"\*(.+?)\*", r"<em>\1</em>", text)
|
|
text = re.sub(r"~~(.+?)~~", r"<s>\1</s>", text)
|
|
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'<a href="\2" rel="nofollow">\1</a>', text)
|
|
|
|
text = re.sub(r"^[\-\*]\s+(.+)$", r"<li>\1</li>", text, flags=re.MULTILINE)
|
|
text = re.sub(r"(<li>.*?</li>(\n?<li>.*?</li>)*)", r"<ul>\1</ul>", text)
|
|
|
|
text = re.sub(r"^\d+\.\s+(.+)$", r"<li>\1</li>", text, flags=re.MULTILINE)
|
|
text = re.sub(r"(<li>.*?</li>(\n?<li>.*?</li>)*)", r"<ol>\1</ol>", text)
|
|
|
|
lines = text.split("\n")
|
|
result_lines = []
|
|
in_table = False
|
|
table_rows = []
|
|
for line in lines:
|
|
if line.strip().startswith("|") and line.strip().endswith("|"):
|
|
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
|
if all(c.startswith("-") and len(c) > 1 for c in cells):
|
|
continue
|
|
table_rows.append("<tr>" + "".join(f"<td>{c}</td>" for c in cells) + "</tr>")
|
|
in_table = True
|
|
else:
|
|
if in_table and table_rows:
|
|
result_lines.append("<table>" + "".join(table_rows) + "</table>")
|
|
table_rows = []
|
|
in_table = False
|
|
result_lines.append(line)
|
|
if in_table and table_rows:
|
|
result_lines.append("<table>" + "".join(table_rows) + "</table>")
|
|
text = "\n".join(result_lines)
|
|
|
|
text = re.sub(r"^>\s+(.+)$", r"<blockquote>\1</blockquote>", text, flags=re.MULTILINE)
|
|
text = re.sub(r"^---+$", r"<hr>", text, flags=re.MULTILINE)
|
|
text = re.sub(r"^#{1,2}\s+(.+)$", r"<h3>\1</h3>", text, flags=re.MULTILINE)
|
|
text = re.sub(r"^#{3,6}\s+(.+)$", r"<h4>\1</h4>", text, flags=re.MULTILINE)
|
|
|
|
lines = text.split("\n")
|
|
result = []
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if not stripped:
|
|
result.append("<br>")
|
|
elif stripped.startswith("<") and not stripped.startswith("<br"):
|
|
result.append(stripped)
|
|
else:
|
|
result.append(f"<p>{stripped}</p>")
|
|
return "\n".join(result)
|
|
|
|
|
|
def message_parts_to_text(parts: list[dict]) -> str:
|
|
texts = []
|
|
for part in parts:
|
|
if "text" in part:
|
|
texts.append(part["text"].get("content", ""))
|
|
elif "image" in part:
|
|
img = part["image"]
|
|
texts.append(f"[图片: {img.get('name', img.get('url', ''))}]")
|
|
elif "file" in part:
|
|
f = part["file"]
|
|
texts.append(f"[文件: {f.get('name', f.get('url', ''))}]")
|
|
elif "card" in part:
|
|
card = part["card"]
|
|
texts.append(f"[卡片: {card.get('title', '')}]")
|
|
return "\n".join(texts)
|
|
|
|
|
|
def get_agent_prompt_rules(mode: str = "both") -> list[str]:
|
|
rules = [
|
|
"Your response will be sent to Freshdesk/Freshworks as an AI agent reply.",
|
|
]
|
|
|
|
if mode in ("freshdesk", "both"):
|
|
rules.extend(
|
|
[
|
|
"--- Freshdesk (Ticket System) Format Rules ---",
|
|
"The message body is HTML. Use <strong> for bold, <em> for italic.",
|
|
'Use <a href="URL" rel="nofollow">text</a> for links.',
|
|
"Use <ul><li>item</li></ul> for unordered lists.",
|
|
"Use <ol><li>item</li></ol> for ordered lists.",
|
|
"Use <code> for inline code and <pre><code> for code blocks.",
|
|
"Use <blockquote>text</blockquote> for quotations.",
|
|
"Use <h3> or <h4> for headings.",
|
|
"Use <table><tr><td>...</td></tr></table> for tables.",
|
|
"Do NOT use <script>, <style>, <iframe>, <div> tags.",
|
|
"Do NOT use CSS class or id attributes.",
|
|
]
|
|
)
|
|
|
|
if mode in ("freshchat", "both"):
|
|
rules.extend(
|
|
[
|
|
"--- Freshchat (Messaging Platform) Format Rules ---",
|
|
"The message is plain text. Rich formatting via **bold** and *italic*.",
|
|
"URLs are auto-detected and rendered as clickable links.",
|
|
"Use Quick Reply buttons when asking for user choice (options: 2-5 items).",
|
|
"Keep messages concise and conversational.",
|
|
]
|
|
)
|
|
|
|
rules.extend(
|
|
[
|
|
"--- General Rules ---",
|
|
"Identify yourself as an AI assistant when relevant.",
|
|
"If the issue requires human intervention, suggest escalation.",
|
|
]
|
|
)
|
|
|
|
return rules
|
|
|
|
|
|
def parse_timestamp(ts: str) -> int:
|
|
try:
|
|
from datetime import datetime
|
|
|
|
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
return int(dt.timestamp() * 1000)
|
|
except (ValueError, TypeError):
|
|
return int(_time() * 1000)
|