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,181 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Direct email delivery for ticket notifications — the fallback when a ticket
|
||||
has no linked chat conversation to deliver through (manual tickets created
|
||||
from the dashboard). Reuses the email channel's SMTP plumbing: the org's
|
||||
connected email inbox when one exists (correct SPF/DKIM from their own
|
||||
domain), platform SMTP otherwise.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import secrets
|
||||
from email.message import EmailMessage
|
||||
from email.utils import make_msgid
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.channels.email import open_smtp, platform_smtp_config, smtp_config
|
||||
from app.core.config import settings
|
||||
from app.core.logger import get_logger
|
||||
from app.models.channels import ChannelAccount
|
||||
from app.models.ticket import TICKET_NUMBER_PREFIX
|
||||
from app.repositories.channels.accounts import ChannelAccountRepository
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
EMAIL_CHANNEL_TYPE = "email"
|
||||
|
||||
# Message-ID local part for ticket mail: 'ticket-<uuid hex>.<unique>'. The
|
||||
# ticket id lives in the address itself, so a reply that quotes any of our
|
||||
# Message-IDs back in References identifies its ticket with no lookup table.
|
||||
# Every sent message still gets its own unique id — reusing one id across
|
||||
# messages makes Gmail collapse them into a single copy.
|
||||
_TICKET_MSGID_RE = re.compile(r"ticket-([0-9a-f]{32})(?:\.[^@\s>]+)?@", re.I)
|
||||
# The phantom id every ticket mail references. Never sent as a message itself:
|
||||
# it is the shared thread root that ties separate notifications together.
|
||||
_ROOT_SUFFIX = "root"
|
||||
_SUBJECT_TOKEN_RE = re.compile(rf"\[{TICKET_NUMBER_PREFIX}-(\d+)\]", re.I)
|
||||
|
||||
DEFAULT_MSGID_DOMAIN = "replypier.site"
|
||||
|
||||
|
||||
def _msgid_domain(from_email: Optional[str] = None) -> str:
|
||||
"""Right-hand side of generated Message-IDs. Cosmetic only — the parser
|
||||
matches on the local part, so mail sent from a customer's own inbox domain
|
||||
still threads back to its ticket."""
|
||||
candidate = from_email or settings.FROM_EMAIL or ""
|
||||
_, _, domain = candidate.rpartition("@")
|
||||
return domain.strip("> ").lower() or DEFAULT_MSGID_DOMAIN
|
||||
|
||||
|
||||
def ticket_root_message_id(ticket_id, from_email: Optional[str] = None) -> str:
|
||||
"""The stable per-ticket thread root, carried in References on every mail."""
|
||||
return f"<ticket-{UUID(str(ticket_id)).hex}.{_ROOT_SUFFIX}@{_msgid_domain(from_email)}>"
|
||||
|
||||
|
||||
def new_ticket_message_id(ticket_id, from_email: Optional[str] = None) -> str:
|
||||
"""A fresh Message-ID for one outbound ticket mail."""
|
||||
return (
|
||||
f"<ticket-{UUID(str(ticket_id)).hex}.{secrets.token_hex(8)}"
|
||||
f"@{_msgid_domain(from_email)}>"
|
||||
)
|
||||
|
||||
|
||||
def build_thread_headers(
|
||||
ticket_id, in_reply_to: Optional[str] = None, from_email: Optional[str] = None
|
||||
) -> dict:
|
||||
"""Threading headers for one ticket notification.
|
||||
|
||||
References always starts at the ticket root so every notification for a
|
||||
ticket lands in one client-side thread; In-Reply-To points at the
|
||||
customer's most recent reply when we have seen one, so their client keeps
|
||||
the conversation in order.
|
||||
"""
|
||||
root = ticket_root_message_id(ticket_id, from_email)
|
||||
references: List[str] = [root]
|
||||
if in_reply_to and in_reply_to != root:
|
||||
references.append(in_reply_to)
|
||||
return {
|
||||
"Message-ID": new_ticket_message_id(ticket_id, from_email),
|
||||
"In-Reply-To": in_reply_to or root,
|
||||
"References": " ".join(references),
|
||||
}
|
||||
|
||||
|
||||
def ticket_ids_from_references(*values: Optional[str]) -> List[UUID]:
|
||||
"""Ticket ids encoded in In-Reply-To/References/Message-ID headers, most
|
||||
recently referenced first — References lists ancestors oldest-first, and
|
||||
the nearest ancestor is the better match."""
|
||||
found: List[UUID] = []
|
||||
for value in values:
|
||||
if not value:
|
||||
continue
|
||||
for hex_id in _TICKET_MSGID_RE.findall(str(value)):
|
||||
try:
|
||||
ticket_id = UUID(hex_id)
|
||||
except ValueError:
|
||||
continue
|
||||
if ticket_id not in found:
|
||||
found.append(ticket_id)
|
||||
found.reverse()
|
||||
return found
|
||||
|
||||
|
||||
def ticket_number_from_subject(subject: Optional[str]) -> Optional[int]:
|
||||
"""The [TKT-12] token a customer's client keeps in the reply subject —
|
||||
the fallback when a mail client drops the threading headers."""
|
||||
if not subject:
|
||||
return None
|
||||
match = _SUBJECT_TOKEN_RE.search(str(subject))
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def _org_email_account(db: Session, organization_id) -> Optional[ChannelAccount]:
|
||||
accounts = ChannelAccountRepository(db).list_by_org(organization_id, EMAIL_CHANNEL_TYPE)
|
||||
for account in accounts:
|
||||
if account.is_active:
|
||||
return account
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_smtp(db: Session, organization_id) -> dict:
|
||||
account = _org_email_account(db, organization_id)
|
||||
if account is not None:
|
||||
return smtp_config(account)
|
||||
return platform_smtp_config(settings.FROM_EMAIL)
|
||||
|
||||
|
||||
def _smtp_send(cfg: dict, message: EmailMessage) -> None:
|
||||
with open_smtp(cfg) as smtp:
|
||||
smtp.send_message(message)
|
||||
|
||||
|
||||
async def send_ticket_email(
|
||||
db: Session,
|
||||
organization_id,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
ticket_id=None,
|
||||
in_reply_to: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Send a plain-text ticket notification email. Returns False (and logs)
|
||||
on failure — ticket mutations must never fail because SMTP hiccuped.
|
||||
|
||||
With a ticket_id the mail carries ticket-scoped threading headers, so the
|
||||
customer's reply comes back identifiable as a reply to that ticket."""
|
||||
try:
|
||||
cfg = _resolve_smtp(db, organization_id)
|
||||
message = EmailMessage()
|
||||
message["From"] = cfg["from_email"]
|
||||
message["To"] = to_email
|
||||
message["Subject"] = subject
|
||||
if ticket_id is not None:
|
||||
for header, value in build_thread_headers(
|
||||
ticket_id, in_reply_to, cfg["from_email"]
|
||||
).items():
|
||||
message[header] = value
|
||||
else:
|
||||
message["Message-ID"] = make_msgid()
|
||||
message.set_content(body)
|
||||
await asyncio.to_thread(_smtp_send, cfg, message)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ticket email to {to_email} failed: {e}")
|
||||
return False
|
||||
Reference in new issue
Block a user