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,252 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Help-center settings lifecycle: lazy get-or-create (which is also the
|
||||
"org has adopted the feature" marker), slug generation and the agent
|
||||
auto-default for AI search.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Iterable, Optional, Sequence
|
||||
from urllib.parse import unquote, urlsplit
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logger import get_logger
|
||||
from app.models.agent import Agent
|
||||
from app.models.faq import FAQ, FAQ_SLUG_MAX_LENGTH, FAQ_URL_PATH_MAX_LENGTH
|
||||
from app.models.help_center import HelpCenterSettings
|
||||
from app.models.knowledge_to_agent import KnowledgeToAgent
|
||||
from app.repositories.faq import FAQRepository
|
||||
from app.repositories.help_center import HelpCenterRepository
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
SLUG_MAX_LENGTH = 63
|
||||
_SLUG_CLEAN_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
# First path segments the public help center already owns (app.api
|
||||
# help_center_public routes, its uploads/images mounts, the /help/{slug}
|
||||
# dispatch prefix, and /.well-known, which ACME needs for TLS issuance). A
|
||||
# preserved path may never start with one of these or it would shadow the real
|
||||
# route. Matching on the FIRST SEGMENT rather than a string prefix keeps
|
||||
# "/apix/..." usable while still rejecting "/api/...".
|
||||
FAQ_RESERVED_FIRST_SEGMENTS = frozenset(
|
||||
{"a", "ask", "api", "help", "healthz", "robots.txt", "sitemap.xml", ".well-known", "static", "uploads"}
|
||||
)
|
||||
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f]")
|
||||
|
||||
|
||||
def slugify_org_name(name: str) -> str:
|
||||
slug = _SLUG_CLEAN_RE.sub("-", (name or "").casefold()).strip("-")
|
||||
return slug[:SLUG_MAX_LENGTH].strip("-") or "help"
|
||||
|
||||
|
||||
def _faq_base_slug(question: str) -> str:
|
||||
base = _SLUG_CLEAN_RE.sub("-", (question or "").casefold()).strip("-")
|
||||
return base[:FAQ_SLUG_MAX_LENGTH].strip("-") or "article"
|
||||
|
||||
|
||||
def _dedupe_slug(base: str, is_taken) -> str:
|
||||
"""First free slug from `base`, appending -2, -3, … while is_taken(candidate)."""
|
||||
candidate = base
|
||||
suffix = 2
|
||||
while is_taken(candidate):
|
||||
tail = f"-{suffix}"
|
||||
candidate = f"{base[:FAQ_SLUG_MAX_LENGTH - len(tail)]}{tail}"
|
||||
suffix += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def generate_faq_slug(db: Session, organization_id: UUID, question: str) -> str:
|
||||
"""A per-org-unique URL slug from an FAQ question. Collisions get -2, -3, …
|
||||
Assigned once at creation and kept stable afterwards so article URLs persist."""
|
||||
repo = FAQRepository(db)
|
||||
return _dedupe_slug(_faq_base_slug(question), lambda c: repo.slug_exists(organization_id, c))
|
||||
|
||||
|
||||
def resolve_faq_slug(
|
||||
db: Session, organization_id: UUID, requested: str, exclude_id: Optional[UUID] = None
|
||||
) -> str:
|
||||
"""A hand-typed slug, reduced to the same shape generation produces
|
||||
(lowercase, a-z0-9 and single hyphens, length-capped) and made unique within
|
||||
the org. `exclude_id` is the FAQ being edited, so keeping its own slug is a
|
||||
no-op rather than a collision that appends -2."""
|
||||
repo = FAQRepository(db)
|
||||
return _dedupe_slug(
|
||||
_faq_base_slug(requested),
|
||||
lambda c: repo.slug_exists(organization_id, c, exclude_id=exclude_id),
|
||||
)
|
||||
|
||||
|
||||
def assign_faq_slugs(db: Session, faqs) -> None:
|
||||
"""Give every FAQ in a batch a unique slug in place, deduping against both the
|
||||
DB and the other rows in the same batch. Rows that already have a slug are
|
||||
left untouched (and reserved so batch-mates don't collide with them)."""
|
||||
repo = FAQRepository(db)
|
||||
taken: set = set()
|
||||
for faq in faqs:
|
||||
if faq.slug:
|
||||
taken.add(faq.slug)
|
||||
continue
|
||||
slug = _dedupe_slug(
|
||||
_faq_base_slug(faq.question),
|
||||
lambda c: c in taken or repo.slug_exists(faq.organization_id, c),
|
||||
)
|
||||
faq.slug = slug
|
||||
taken.add(slug)
|
||||
|
||||
|
||||
def normalize_url_path(raw: Optional[str]) -> Optional[str]:
|
||||
"""A full URL or hand-typed path reduced to the root-relative path an article
|
||||
can be served at — or None when it can't be used at all, in which case the
|
||||
caller falls back to /a/{slug}.
|
||||
|
||||
Returned percent-DECODED, because that is the form the ASGI scope hands the
|
||||
router to match against; every emitted URL re-encodes it (see
|
||||
help_center_seo.article_url). Getting that direction backwards silently 404s
|
||||
every non-ASCII path."""
|
||||
if not raw:
|
||||
return None
|
||||
candidate = raw.strip()
|
||||
if "://" in candidate or candidate.startswith("//"):
|
||||
candidate = urlsplit(candidate).path # drop scheme/host/port
|
||||
candidate = candidate.split("#", 1)[0].split("?", 1)[0] # drop fragment/query
|
||||
candidate = unquote(candidate)
|
||||
if _CONTROL_CHARS_RE.search(candidate):
|
||||
return None
|
||||
if not candidate.startswith("/"):
|
||||
candidate = f"/{candidate}"
|
||||
candidate = re.sub(r"/{2,}", "/", candidate).rstrip("/")
|
||||
if len(candidate) > FAQ_URL_PATH_MAX_LENGTH:
|
||||
return None
|
||||
segments = candidate.split("/")[1:]
|
||||
if not segments or not segments[0]:
|
||||
return None # "" or "/" — that's the index, not an article
|
||||
if "." in segments or ".." in segments:
|
||||
return None # no traversal, and nothing that normalises to another path
|
||||
if segments[0].lower() in FAQ_RESERVED_FIRST_SEGMENTS:
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
def resolve_faq_url_path(
|
||||
db: Session, organization_id: UUID, requested: str, exclude_id: Optional[UUID] = None
|
||||
) -> Optional[str]:
|
||||
"""A validated preserved path that is free within the org, else None.
|
||||
|
||||
Deliberately does NOT dedupe with -2/-3 the way slugs do: a mangled path is
|
||||
no longer the URL the org already ranks for, so silently altering it would
|
||||
defeat the whole point. The caller decides what to do instead — fall back to
|
||||
/a/{slug} on import, or reject the edit in the admin API."""
|
||||
path = normalize_url_path(requested)
|
||||
if path is None:
|
||||
return None
|
||||
repo = FAQRepository(db)
|
||||
return None if repo.url_path_exists(organization_id, path, exclude_id=exclude_id) else path
|
||||
|
||||
|
||||
def assign_faq_url_paths(db: Session, faqs: Sequence[FAQ], sources: Iterable[str]) -> int:
|
||||
"""Give each FAQ in a batch its source URL's path, in place, deduping against
|
||||
both the DB and the other rows in the batch. Returns how many were applied.
|
||||
|
||||
Rows whose path is unusable or taken keep url_path=None and are served at
|
||||
/a/{slug} — a partial result still imports every article, which beats
|
||||
failing the whole job over one bad URL.
|
||||
|
||||
MUST run before the batch insert, so the unique index can never reject it."""
|
||||
repo = FAQRepository(db)
|
||||
taken: set = set()
|
||||
applied = 0
|
||||
for faq, source in zip(faqs, sources):
|
||||
path = normalize_url_path(source)
|
||||
if path is None:
|
||||
continue
|
||||
if path in taken or repo.url_path_exists(faq.organization_id, path):
|
||||
logger.warning(f"Preserved URL path already taken, using the slug instead: {path}")
|
||||
continue
|
||||
faq.url_path = path
|
||||
taken.add(path)
|
||||
applied += 1
|
||||
return applied
|
||||
|
||||
|
||||
def generate_unique_slug(db: Session, name: str) -> str:
|
||||
"""Slug from the org name; reserved labels and collisions get -2, -3, …"""
|
||||
repo = HelpCenterRepository(db)
|
||||
base = slugify_org_name(name)
|
||||
if base in settings.HELP_CENTER_RESERVED_SLUGS:
|
||||
base = f"{base}-help"[:SLUG_MAX_LENGTH]
|
||||
candidate = base
|
||||
suffix = 2
|
||||
while repo.slug_exists(candidate) or candidate in settings.HELP_CENTER_RESERVED_SLUGS:
|
||||
tail = f"-{suffix}"
|
||||
candidate = f"{base[:SLUG_MAX_LENGTH - len(tail)]}{tail}"
|
||||
suffix += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def default_agent_id(db: Session, organization_id: UUID) -> Optional[UUID]:
|
||||
"""The org's only active agent; else the first agent with linked knowledge;
|
||||
else None (user picks manually)."""
|
||||
active_agents = (
|
||||
db.query(Agent)
|
||||
.filter(Agent.organization_id == organization_id, Agent.is_active.is_(True))
|
||||
.order_by(Agent.id)
|
||||
.all()
|
||||
)
|
||||
if len(active_agents) == 1:
|
||||
return active_agents[0].id
|
||||
with_knowledge = (
|
||||
db.query(Agent)
|
||||
.join(KnowledgeToAgent, KnowledgeToAgent.agent_id == Agent.id)
|
||||
.filter(Agent.organization_id == organization_id)
|
||||
.order_by(Agent.id)
|
||||
.first()
|
||||
)
|
||||
return with_knowledge.id if with_knowledge else None
|
||||
|
||||
|
||||
def get_or_create_settings(db: Session, organization) -> HelpCenterSettings:
|
||||
"""Fetch the org's help-center settings, creating the row (slug + agent
|
||||
auto-default) on first access."""
|
||||
repo = HelpCenterRepository(db)
|
||||
row = repo.get_by_org(organization.id)
|
||||
if row:
|
||||
return row
|
||||
row = HelpCenterSettings(
|
||||
organization_id=organization.id,
|
||||
slug=generate_unique_slug(db, organization.name),
|
||||
agent_id=default_agent_id(db, organization.id),
|
||||
)
|
||||
created = repo.create(row)
|
||||
logger.info(f"Created help center settings for org {organization.id} (slug={created.slug})")
|
||||
return created
|
||||
|
||||
|
||||
def live_url(row: HelpCenterSettings) -> str:
|
||||
"""The public URL the help center is (or will be) served at.
|
||||
|
||||
A verified custom domain always wins. Otherwise the URL shape follows
|
||||
HELP_CENTER_PUBLIC_MODE: "subdomain" advertises {slug}.<base> (cloud); the
|
||||
default "path" mode serves same-origin as the API at {BACKEND_URL}/help/{slug}
|
||||
(self-host — no DNS/TLS/proxy needed, and correct scheme+port via BACKEND_URL)."""
|
||||
if row.domain_verified:
|
||||
return f"https://{row.custom_domain}"
|
||||
if settings.HELP_CENTER_PUBLIC_MODE == "subdomain":
|
||||
return f"https://{row.slug}.{settings.HELP_CENTER_BASE_DOMAIN}"
|
||||
return f"{settings.BACKEND_URL.rstrip('/')}/help/{row.slug}"
|
||||
Reference in new issue
Block a user