253 lines
9.9 KiB
Python
253 lines
9.9 KiB
Python
"""
|
|
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}"
|