diff --git a/backend/app/api/channels/__init__.py b/backend/app/api/channels/__init__.py new file mode 100644 index 0000000..f6190b6 --- /dev/null +++ b/backend/app/api/channels/__init__.py @@ -0,0 +1,40 @@ +""" +Copyright 2024-2026 ChatterMate + +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 fastapi import APIRouter + +from app.api.channels.accounts import router as accounts_router +from app.api.channels.telegram import router as telegram_router +from app.api.channels.meta import router as meta_router +from app.api.channels.whatsapp_messaging import router as whatsapp_messaging_router +from app.api.channels.slack import router as slack_router +from app.api.channels.email import router as email_router +from app.api.channels.sms import router as sms_router +from app.api.channels.line import router as line_router +from app.api.channels.agent_config import router as agent_config_router + +# Aggregates channel onboarding/management routes under /channels +router = APIRouter() +router.include_router(accounts_router, tags=["channels"]) +router.include_router(telegram_router, prefix="/telegram", tags=["channels"]) +router.include_router(meta_router, prefix="/meta", tags=["channels"]) +# Same prefix: onboarding and messaging are separate modules, one URL space. +router.include_router(whatsapp_messaging_router, prefix="/meta", tags=["channels"]) +router.include_router(slack_router, prefix="/slack", tags=["channels"]) +router.include_router(email_router, prefix="/email", tags=["channels"]) +router.include_router(sms_router, prefix="/sms", tags=["channels"]) +router.include_router(line_router, prefix="/line", tags=["channels"]) +router.include_router(agent_config_router, prefix="/agent-config", tags=["channels"]) diff --git a/backend/app/api/channels/accounts.py b/backend/app/api/channels/accounts.py new file mode 100644 index 0000000..f50f992 --- /dev/null +++ b/backend/app/api/channels/accounts.py @@ -0,0 +1,113 @@ +""" +Copyright 2024-2026 ChatterMate + +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 typing import List +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.core.auth import ( + INBOX_PERMISSIONS, + check_permissions, + get_current_organization, + require_any_permission, + require_permissions, +) +from app.services.meta_own_app import AUTO_WEBHOOK_SETTING, is_own_app, own_app_webhook_url +from app.core.config import webhooks_base_url +from app.database import get_db +from app.models.channels import ChannelType +from app.models.organization import Organization +from app.models.schemas.channel import ChannelAccountOut +from app.models.user import User +from app.repositories.channels import ChannelAccountRepository, AgentChannelConfigRepository +from app.repositories.channels.accounts import needs_reconnect +from app.core.logger import get_logger + +router = APIRouter() +logger = get_logger(__name__) + +# Sentinel: distinguishes "config not passed, look it up" from "no config" +_UNRESOLVED = object() + + +def channel_webhook_url(account) -> str | None: + """The webhook URL the customer must configure on their provider, for the + channels where that's a manual step. Auto-managed channels return None.""" + base = webhooks_base_url() + if account.channel_type == ChannelType.EMAIL.value: + return f"{base}/email/{account.id}?token={account.webhook_secret}" + if account.channel_type == ChannelType.SMS.value: + provider = (account.settings or {}).get("provider", "twilio") + return f"{base}/sms/{provider}/{account.id}?token={account.webhook_secret}" + if is_own_app(account): + return own_app_webhook_url(account) + return None + + +def get_org_account_or_404(db: Session, account_id: UUID, organization: Organization): + """Load a channel account and enforce org ownership.""" + account = ChannelAccountRepository(db).get_by_id(account_id) + if account is None or account.organization_id != organization.id: + raise HTTPException(status_code=404, detail="Channel account not found") + return account + + +def to_account_out( + db: Session, account, config=_UNRESOLVED, include_webhook_url: bool = True +) -> ChannelAccountOut: + """Serialise a channel account. + + Pass include_webhook_url=False for callers who are not org admins: the URL + embeds `webhook_secret`, which is the ONLY authentication on the inbound + email and SMS webhooks (api/webhooks/email.py, api/webhooks/sms.py). + Handing it to an agent would let them post forged inbound messages into any + of the org's conversations. + """ + if config is _UNRESOLVED: + config = AgentChannelConfigRepository(db).get_by_account(account.id) + out = ChannelAccountOut.model_validate(account) + out.agent_id = config.agent_id if config and config.is_active else None + out.webhook_url = channel_webhook_url(account) if include_webhook_url else None + out.needs_reconnect = needs_reconnect(account) + # Meta asks for this token when the customer points their app at us + if include_webhook_url and is_own_app(account): + out.webhook_verify_token = account.webhook_secret + out.webhook_auto_configured = (account.settings or {}).get(AUTO_WEBHOOK_SETTING) + return out + + +@router.get("/accounts", response_model=List[ChannelAccountOut]) +async def list_channel_accounts( + # Org admins (Integrations settings) OR inbox agents: the inbox reads this + # to know whether there is a WhatsApp number to start a conversation from, + # so gating it admin-only hid the feature from the people it is for. + current_user: User = Depends( + require_any_permission("manage_organization", *INBOX_PERMISSIONS)), + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db), +): + """All connected channel accounts for the organization.""" + accounts = ChannelAccountRepository(db).list_by_org(organization.id) + configs = AgentChannelConfigRepository(db).map_by_accounts([a.id for a in accounts]) + # Only the Integrations settings screen needs the webhook URL, and only an + # org admin can act on it — see to_account_out. + include_webhook_url = check_permissions(current_user, ["manage_organization"]) + return [ + to_account_out(db, account, configs.get(account.id), include_webhook_url) + for account in accounts + ] diff --git a/backend/app/api/channels/agent_config.py b/backend/app/api/channels/agent_config.py new file mode 100644 index 0000000..360f51e --- /dev/null +++ b/backend/app/api/channels/agent_config.py @@ -0,0 +1,68 @@ +""" +Copyright 2024-2026 ChatterMate + +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 uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.api.channels.accounts import get_org_account_or_404, to_account_out +from app.core.auth import get_current_organization, require_permissions +from app.database import get_db +from app.models.organization import Organization +from app.models.schemas.channel import AgentChannelConfigRequest, ChannelAccountOut +from app.models.user import User +from app.repositories.agent import AgentRepository +from app.repositories.channels import AgentChannelConfigRepository +from app.services import telegram_webhooks +from app.core.logger import get_logger + +router = APIRouter() +logger = get_logger(__name__) + + +@router.post("/{account_id}", response_model=ChannelAccountOut) +async def set_account_agent( + account_id: UUID, + request: AgentChannelConfigRequest, + current_user: User = Depends(require_permissions("manage_organization")), + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db), +): + """Route a connected channel account to an AI agent.""" + account = get_org_account_or_404(db, account_id, organization) + + agent = AgentRepository(db).get_agent(request.agent_id) + if agent is None or agent.organization_id != organization.id: + raise HTTPException(status_code=404, detail="Agent not found") + + AgentChannelConfigRepository(db).set_agent(account.id, request.agent_id, request.is_active) + # A bot connected before buttons existed doesn't receive taps yet + await telegram_webhooks.reregister_if_buttons_on(account, agent) + return to_account_out(db, account) + + +@router.delete("/{account_id}") +async def clear_account_agent( + account_id: UUID, + current_user: User = Depends(require_permissions("manage_organization")), + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db), +): + """Stop routing a channel account to any agent (messages will be ignored).""" + account = get_org_account_or_404(db, account_id, organization) + AgentChannelConfigRepository(db).delete_by_account(account.id) + return {"status": "cleared"} diff --git a/backend/app/api/channels/email.py b/backend/app/api/channels/email.py new file mode 100644 index 0000000..0e9ec91 --- /dev/null +++ b/backend/app/api/channels/email.py @@ -0,0 +1,149 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import asyncio +import smtplib +from email.utils import parseaddr +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.api.channels.accounts import get_org_account_or_404, to_account_out +from app.channels.email import inbox_smtp_config, validate_smtp +from app.core.auth import get_current_organization, require_permissions +from app.core.config import settings +from app.database import get_db +from app.models.channels import ChannelType +from app.models.organization import Organization +from app.models.schemas.channel import ChannelAccountOut, EmailConnectRequest +from app.models.user import User +from app.repositories.channels import ChannelAccountRepository +from app.core.logger import get_logger + +router = APIRouter() +logger = get_logger(__name__) + + +def _webhook_url(account) -> str: + return (f"{settings.BACKEND_URL.rstrip('/')}{settings.API_V1_STR}" + f"/webhooks/email/{account.id}?token={account.webhook_secret}") + + +async def _build_smtp_credentials(request: EmailConnectRequest, address: str) -> dict: + """Assemble (and validate) per-inbox SMTP credentials, or {} to use the + platform SMTP settings. Raises HTTPException on invalid/unreachable SMTP.""" + if not request.smtp_host: + return {} + if not (request.smtp_username and request.smtp_password): + raise HTTPException(status_code=400, + detail="SMTP username and password are required when an SMTP host is set") + creds = { + "smtp_host": request.smtp_host.strip(), + "smtp_port": request.smtp_port or 587, + "smtp_username": request.smtp_username, + "smtp_password": request.smtp_password, + "from_email": (request.from_email or address).lower(), + "smtp_use_ssl": request.smtp_use_ssl, + } + cfg = inbox_smtp_config(creds, address) + try: + await asyncio.to_thread(validate_smtp, cfg) + except smtplib.SMTPAuthenticationError as e: + raise HTTPException(status_code=400, detail=_auth_error_detail(cfg["host"], e)) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Could not connect to SMTP server: {e}") + return creds + + +def _auth_error_detail(host: str, exc: Exception) -> str: + """Friendlier message for an SMTP login rejection — Gmail in particular + needs an App Password, not the account password.""" + host = (host or "").lower() + if "gmail" in host or "google" in host: + return ("Gmail rejected the login. Gmail requires a 16-character App Password " + "(not your normal account password), and 2-Step Verification must be " + "enabled — create one at myaccount.google.com/apppasswords, then paste " + "it here with the spaces removed.") + return f"SMTP authentication failed — check the username and password ({exc})" + + +@router.post("", response_model=ChannelAccountOut) +async def connect_email( + request: EmailConnectRequest, + current_user: User = Depends(require_permissions("manage_organization")), + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db), +): + """Connect a support inbox. The response's webhook_url must be configured + as the inbound-parse/forwarding target on the email provider. Outbound + replies use the inbox's own SMTP when provided, else the platform SMTP.""" + _, address = parseaddr(request.inbound_address) + address = (address or "").lower() + if "@" not in address: + raise HTTPException(status_code=400, detail="Enter a valid email address") + + credentials = await _build_smtp_credentials(request, address) + + repo = ChannelAccountRepository(db) + existing = repo.get_by_external_id(ChannelType.EMAIL.value, address) + if existing is not None: + if existing.organization_id != organization.id: + raise HTTPException(status_code=409, detail="This address is already connected to another organization") + # Only overwrite SMTP creds when new ones were supplied — reconnecting + # to update the agent/webhook must not silently wipe stored SMTP. + if credentials: + repo.update_credentials(existing, credentials) + account = repo.set_active(existing, True) + else: + account = repo.create_account( + organization_id=organization.id, + channel_type=ChannelType.EMAIL.value, + external_account_id=address, + credentials=credentials, # {} => platform SMTP fallback + display_name=request.display_name or address, + ) + out = to_account_out(db, account) + out.webhook_url = _webhook_url(account) + return out + + +@router.get("/{account_id}/webhook-url") +async def get_email_webhook_url( + account_id: UUID, + current_user: User = Depends(require_permissions("manage_organization")), + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db), +): + """Re-show the inbound webhook URL for an existing email account.""" + account = get_org_account_or_404(db, account_id, organization) + if account.channel_type != ChannelType.EMAIL.value: + raise HTTPException(status_code=404, detail="Channel account not found") + return {"webhook_url": _webhook_url(account)} + + +@router.delete("/{account_id}") +async def disconnect_email( + account_id: UUID, + current_user: User = Depends(require_permissions("manage_organization")), + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db), +): + account = get_org_account_or_404(db, account_id, organization) + if account.channel_type != ChannelType.EMAIL.value: + raise HTTPException(status_code=404, detail="Channel account not found") + ChannelAccountRepository(db).delete(account) + return {"status": "disconnected"} diff --git a/backend/app/api/channels/line.py b/backend/app/api/channels/line.py new file mode 100644 index 0000000..eb24e88 --- /dev/null +++ b/backend/app/api/channels/line.py @@ -0,0 +1,95 @@ +""" +Copyright 2024-2026 ChatterMate + +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 uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.api.channels.accounts import get_org_account_or_404, to_account_out +from app.channels import line as line_api +from app.core.auth import get_current_organization, require_permissions +from app.core.config import settings +from app.database import get_db +from app.models.channels import ChannelType +from app.models.organization import Organization +from app.models.schemas.channel import ChannelAccountOut, LineConnectRequest +from app.models.user import User +from app.repositories.channels import ChannelAccountRepository +from app.core.logger import get_logger + +router = APIRouter() +logger = get_logger(__name__) + + +def _webhook_url(account_id) -> str: + return f"{settings.BACKEND_URL.rstrip('/')}{settings.API_V1_STR}/webhooks/line/{account_id}" + + +@router.post("", response_model=ChannelAccountOut) +async def connect_line( + request: LineConnectRequest, + current_user: User = Depends(require_permissions("manage_organization")), + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db), +): + """Connect a LINE Official Account (channel secret + access token from the + LINE Developers console). The webhook endpoint is set automatically.""" + bot = await line_api.get_bot_info(request.channel_access_token.strip()) + if bot is None: + raise HTTPException(status_code=400, detail="Invalid LINE channel access token") + + bot_user_id = str(bot.get("userId", "")) + credentials = { + "channel_access_token": request.channel_access_token.strip(), + "channel_secret": request.channel_secret.strip(), + } + repo = ChannelAccountRepository(db) + existing = repo.get_by_external_id(ChannelType.LINE.value, bot_user_id) + if existing is not None: + if existing.organization_id != organization.id: + raise HTTPException(status_code=409, detail="This LINE account is already connected to another organization") + repo.update_credentials(existing, credentials) + account = repo.set_active(existing, True) + else: + account = repo.create_account( + organization_id=organization.id, + channel_type=ChannelType.LINE.value, + external_account_id=bot_user_id, + credentials=credentials, + display_name=bot.get("displayName") or bot.get("basicId") or bot_user_id, + ) + + if not await line_api.set_webhook_endpoint(credentials["channel_access_token"], _webhook_url(account.id)): + logger.warning(f"LINE webhook auto-set failed for {account.id}; set it manually in the console") + + out = to_account_out(db, account) + out.webhook_url = _webhook_url(account.id) + return out + + +@router.delete("/{account_id}") +async def disconnect_line( + account_id: UUID, + current_user: User = Depends(require_permissions("manage_organization")), + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db), +): + account = get_org_account_or_404(db, account_id, organization) + if account.channel_type != ChannelType.LINE.value: + raise HTTPException(status_code=404, detail="Channel account not found") + ChannelAccountRepository(db).delete(account) + return {"status": "disconnected"} diff --git a/backend/app/api/channels/meta.py b/backend/app/api/channels/meta.py new file mode 100644 index 0000000..c2dfda0 --- /dev/null +++ b/backend/app/api/channels/meta.py @@ -0,0 +1,690 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import json +import secrets +import time +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.api.channels.accounts import get_org_account_or_404, to_account_out +from app.channels.meta_base import ( + debug_token, + exchange_for_long_lived_token, + exchange_instagram_code, + exchange_instagram_long_lived, + exchange_signup_code, + graph_get, + graph_detail, + graph_list_all, + instagram_token_payload, + register_phone_number, + subscribe_app, + subscribe_instagram_app, + GRAPH_INSTAGRAM_BASE, +) +from app.services.meta_own_app import ( + APP_SECRET_KEY, + AppCheckUnavailable, + AppSecretMismatch, + OwnApp, + leave_own_app, + own_app_token, + resolve_own_app, + sync_own_app, +) +from app.core.security import decrypt_api_key, encrypt_api_key +from app.core.auth import ( + get_current_organization, + require_permissions, +) +from app.core.config import settings +from app.core.error_handlers import ClientSafeHTTPException +from app.database import get_db +from app.models.channels import ChannelType +from app.models.organization import Organization +from app.models.schemas.channel import ( + ChannelAccountOut, + EmbeddedSignupConfigOut, + EmbeddedSignupRequest, + WhatsAppConnectRequest, + MessengerConnectRequest, + MessengerSignupConnectRequest, + MessengerSignupPagesOut, + MessengerSignupRequest, + MessengerPageOut, + InstagramConnectRequest, + InstagramLoginRequest, +) +from app.models.user import User +from app.repositories.channels import ChannelAccountRepository +from app.core.logger import get_logger + +router = APIRouter() +logger = get_logger(__name__) + +def _whatsapp_display_name(profile: dict, phone_number_id: str) -> str: + """How a connected number is labelled in the UI, from its Graph profile.""" + return (f"{profile.get('verified_name', 'WhatsApp')} " + f"({profile.get('display_phone_number', phone_number_id)})") + + +async def _page_name(page_id: str, access_token: str) -> str: + """The Page's name for the UI label, falling back to its id. + + Reading it needs pages_read_engagement, which messaging does not require — + so a token without it still connects and simply shows the id. + """ + ok, data = await graph_get(page_id, access_token, params={"fields": "name"}) + if not ok or not data.get("name"): + logger.info(f"No name for page {page_id} (pages_read_engagement not granted); " + f"labelling with the id") + return page_id + return data["name"] + + +async def _verify_signup_assets(waba_id: str, phone_number_id: str, access_token: str) -> dict: + """Prove the exchanged token really owns the number being claimed, and + return that number's profile. + + The code only proves the caller completed *a* signup — waba_id and + phone_number_id arrive in the request body and are entirely caller + controlled. Without this, an org could pair a genuine code from a throwaway + WABA with someone else's phone number id: webhook routing resolves accounts + by phone number id alone, with no org scoping, so their inbound messages + would land in the attacker's inbox. Listing the WABA's numbers with the + token checks both ids at once. + """ + ok, data = await graph_get(f"{waba_id}/phone_numbers", access_token, + params={"fields": "id,display_phone_number,verified_name"}) + if not ok: + raise HTTPException( + status_code=400, + detail=graph_detail(data, "Could not read that WhatsApp Business Account")) + + numbers = data.get("data") + if not isinstance(numbers, list): + raise HTTPException(status_code=400, detail="Unexpected response from Meta") + + for number in numbers: + if str(number.get("id")) == phone_number_id: + return number + raise HTTPException(status_code=400, + detail="That number is not on this WhatsApp Business Account") + + +def _generate_verification_pin() -> str: + """A fresh two-step-verification PIN for a number we register. + + The customer never types it — it exists only so the number can be + registered — so it is random rather than chosen, and kept for re-registers. + """ + return f"{secrets.randbelow(1_000_000):06d}" + + +# Channels that can be onboarded through a Meta login popup, each with the Meta +# configuration that drives it. WhatsApp and Messenger run Facebook logins; +# Instagram runs Instagram Login, which needs no Facebook Page at all. +SIGNUP_CHANNELS = ( + ChannelType.WHATSAPP.value, + ChannelType.MESSENGER.value, + ChannelType.INSTAGRAM.value, +) + +# Webhook fields a Page must subscribe our app to for Messenger to work. +MESSENGER_SUBSCRIBED_FIELDS = "messages,messaging_postbacks" + + +def _signup_config_id(channel: str) -> str: + """The Facebook Login-for-Business configuration that onboards this channel, + read at call time so a settings override (tests, reloads) is always seen. + + Instagram Login has no configuration — it is gated on its own app id. + """ + return { + ChannelType.WHATSAPP.value: settings.META_CONFIG_ID, + ChannelType.MESSENGER.value: settings.META_MESSENGER_CONFIG_ID, + }.get(channel, "") + + +def _signup_app_id(channel: str) -> str: + """The app the login popup authenticates against. Instagram Login uses the + Instagram app; the Facebook flows use the Meta app.""" + if channel == ChannelType.INSTAGRAM.value: + return settings.INSTAGRAM_APP_ID + return settings.META_APP_ID + + +def _signup_available(channel: str) -> bool: + """Whether this deployment can offer the login flow for this channel. + + Onboarding happens under *our* app, so without its credentials it + structurally cannot work — a self-hoster has none and gets the manual + credentials form instead. That is the whole gate: like every other + integration, signup is not restricted by plan. + """ + if channel == ChannelType.INSTAGRAM.value: + return bool(settings.INSTAGRAM_APP_ID and settings.INSTAGRAM_APP_SECRET) + return bool(_signup_config_id(channel)) + + +def _signup_allowed_for(user: User) -> bool: + """Whether this account may use one-click signup. + + An empty allowlist means everyone, which is the end state. While the Meta + app is in App Review the login only succeeds for people with a role on that + app, so the allowlist keeps the button off everyone it would only fail for. + Read at call time so the list can be changed without a code deploy. + """ + allowed = {email.strip().lower() + for email in settings.SIGNUP_ALLOWED_EMAILS.split(",") + if email.strip()} + return not allowed or (user.email or "").strip().lower() in allowed + + +def check_signup_access(channel: str, user: User) -> None: + if not _signup_available(channel): + raise HTTPException( + status_code=403, + detail="Signup is not configured on this deployment for this channel. " + "Connect with credentials instead.", + ) + # Separate from the deployment gate so the reason is honest: the flow exists + # here, this account just isn't one of the ones it is open to yet. + if not _signup_allowed_for(user): + raise HTTPException( + status_code=403, + detail="One-click connect is limited to selected accounts on this " + "deployment. Connect with credentials instead.", + ) + + +# The signup code is single-use and lives ~10 minutes; the sealed page list it +# produces should not outlive it, so a stale one fails cleanly with a retry. +SIGNUP_TOKEN_TTL_SECONDS = 600 +# 500 Pages; an agency ceiling, not a limit a real customer reaches. +PAGE_LIST_MAX_PAGES = 5 + + +def _seal_signup_pages(organization_id, pages: list[dict]) -> str: + """Carry a signup's Page tokens through the picker without the browser ever + holding them in the clear. + + The tokens go back to the client as ciphertext only we can open, so step 2 + needs no server-side session — which matters because the two requests are + not guaranteed to hit the same worker. Binding the org and an expiry inside + means a leaked blob cannot be replayed by another tenant, or after the + signup's own code would already have died. + """ + payload = { + "org": str(organization_id), + "exp": int(time.time()) + SIGNUP_TOKEN_TTL_SECONDS, + "pages": pages, + } + return encrypt_api_key(json.dumps(payload)) + + +def _open_signup_pages(signup_token: str, organization: Organization) -> list[dict]: + """The sealed page list, or 400 if it is forged, expired or another org's. + + One generic message across every reject path, so it is not an oracle for + which check tripped. + """ + invalid = HTTPException(status_code=400, + detail="That signup has expired — please connect again") + try: + # decrypt raises ValueError on a bad token; json.loads raises + # JSONDecodeError, a ValueError subclass — one except covers both. + payload = json.loads(decrypt_api_key(signup_token)) + except ValueError: + raise invalid + if not isinstance(payload, dict) or payload.get("org") != str(organization.id): + raise invalid + if float(payload.get("exp", 0)) <= time.time(): + raise invalid + pages = payload.get("pages") + if not isinstance(pages, list): + raise invalid + return pages + + +async def _list_manageable_pages(user_token: str, fields: str) -> list[dict]: + """The Pages this signup granted us, each with its own Page access token.""" + ok, data = await graph_list_all( + "me/accounts", user_token, {"fields": fields, "limit": 100}, + max_pages=PAGE_LIST_MAX_PAGES) + if not ok: + raise HTTPException( + status_code=400, + detail=graph_detail(data, "Could not list your Facebook Pages")) + return data + + +async def _signup_user_token(request: MessengerSignupRequest) -> str: + """Trade a Facebook Login-for-Business code for a long-lived user token. + + Page tokens inherit the user token's lifetime, so it is extended first + (best-effort; a failure only risks earlier expiry). Instagram does not come + through here — Instagram Login is its own OAuth against its own host. + """ + ok, data = await exchange_signup_code(request.code, redirect_uri=request.redirect_uri) + user_token = data.get("access_token") if ok else None + if not user_token: + # The code is short-lived (~10 minutes) and single-use, so a stale or + # replayed one lands here rather than on a Graph error later. + raise HTTPException(status_code=400, detail=graph_detail( + data, "Could not complete signup — please try connecting again")) + + ok, extended = await exchange_for_long_lived_token(user_token) + if ok and extended.get("access_token"): + return extended["access_token"] + logger.warning("Could not extend the signup token; page tokens may expire early") + return user_token + + +def _upsert_account(db: Session, organization: Organization, channel_type: str, + external_account_id: str, credentials: dict, display_name: str, + forget: tuple = ()): + """Create the account or refresh credentials on reconnect (same org only). + `forget` names stored credentials that no longer apply (an app secret, + once the account is connected through ChatterMate's own app).""" + repo = ChannelAccountRepository(db) + existing = repo.get_by_external_id(channel_type, external_account_id) + if existing is not None: + if existing.organization_id != organization.id: + raise HTTPException(status_code=409, + detail="This account is already connected to another organization") + # Merge rather than replace: a reconnect that omits an optional field + # must not silently drop it. Someone reconnecting without re-entering + # their WABA id would otherwise lose template management, and the + # signup PIN cannot be read back from Meta at all. + merged = {**repo.get_credentials(existing), + **{k: v for k, v in credentials.items() if v is not None}} + for key in forget: + merged.pop(key, None) + repo.update_credentials(existing, merged) + return repo.set_active(existing, True) + return repo.create_account( + organization_id=organization.id, + channel_type=channel_type, + external_account_id=external_account_id, + credentials=credentials, + display_name=display_name, + ) + + +def _previous_account(db: Session, organization: Organization, channel_type: str, external_account_id: str): + """This organization's earlier connection of the account, if any (another + organization's is a 409, before anything is asked of Meta).""" + previous = ChannelAccountRepository(db).get_by_external_id(channel_type, external_account_id) + if previous is not None and previous.organization_id != organization.id: + raise HTTPException(status_code=409, detail="This account is already connected to another organization") + return previous + + +def _own_app_credentials(own: OwnApp | None) -> dict: + return {APP_SECRET_KEY: own.secret} if own else {} + + +async def _own_app(access_token: str, entered_secret: str | None, previous) -> OwnApp | None: + """resolve_own_app, with its failures as answers the form can show.""" + try: + return await resolve_own_app(access_token, entered_secret, previous) + except AppSecretMismatch: + raise HTTPException(status_code=400, detail=( + "That app secret is not the one for the Meta app this token comes from. " + "Copy it from your app's App settings → Basic." if entered_secret else + "The app secret saved before doesn't match this token. Enter the app secret again.")) from None + except AppCheckUnavailable as e: + raise ClientSafeHTTPException( + status_code=502, detail=f"Could not check the app secret with Meta, try again ({e})") from e + + +@router.get("/embedded-signup-config", response_model=EmbeddedSignupConfigOut) +async def get_embedded_signup_config( + channel: str = ChannelType.WHATSAPP.value, + current_user: User = Depends(require_permissions("manage_organization")), +): + """What the connect UI needs to decide between a Meta login flow and the + manual credentials form, for the given channel. The app id is returned here + rather than baked into the frontend build, so a self-hoster can point at + their own app. A disabled channel returns no ids, so one channel's config + never leaks on another's request.""" + if channel not in SIGNUP_CHANNELS: + raise HTTPException(status_code=404, detail="No signup flow for this channel") + # Both halves of the same gate the connect endpoints enforce, so an account + # outside the allowlist is shown the manual form rather than a button that + # would 403 on click. + enabled = _signup_available(channel) and _signup_allowed_for(current_user) + return EmbeddedSignupConfigOut( + enabled=enabled, + # Instagram Login has no configuration id, only an app id. + config_id=(_signup_config_id(channel) or None) if enabled else None, + app_id=_signup_app_id(channel) if enabled else None, + graph_version=settings.META_GRAPH_VERSION, + ) + + +@router.post("/whatsapp", response_model=ChannelAccountOut) +async def connect_whatsapp( + request: WhatsAppConnectRequest, + current_user: User = Depends(require_permissions("manage_organization")), + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db), +): + """Connect a WhatsApp Cloud API number with manual credentials + (phone_number_id + permanent token from the customer's own Meta app).""" + ok, data = await graph_get(request.phone_number_id, request.access_token, + params={"fields": "display_phone_number,verified_name"}) + if not ok: + raise HTTPException(status_code=400, + detail=f"Could not verify WhatsApp credentials: {data.get('error', {}).get('message', 'invalid token or phone_number_id')}") + + previous = _previous_account(db, organization, ChannelType.WHATSAPP.value, request.phone_number_id) + own = await _own_app(request.access_token, request.app_secret, previous) + previous_token = own_app_token(previous) + + display = request.display_name or _whatsapp_display_name(data, request.phone_number_id) + account = _upsert_account( + db, organization, ChannelType.WHATSAPP.value, + external_account_id=request.phone_number_id, + credentials={"access_token": request.access_token, "waba_id": request.waba_id, + **_own_app_credentials(own)}, + display_name=display, + forget=() if own else (APP_SECRET_KEY,), + ) + # Route WABA events to our webhook (best-effort; needs the WABA id) + if request.waba_id: + if not await subscribe_app(request.waba_id, request.access_token): + logger.warning(f"WABA subscribe failed for {request.waba_id}; webhook may need manual subscription") + await sync_own_app(db, account, own, request.access_token, previous_token) + return to_account_out(db, account) + + +@router.post("/whatsapp/embedded-signup", response_model=ChannelAccountOut) +async def connect_whatsapp_embedded_signup( + request: EmbeddedSignupRequest, + current_user: User = Depends(require_permissions("manage_organization")), + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db), +): + """Finish an Embedded Signup flow: trade the code for the customer's + business token and connect the number it created. + + Only the way the credentials are obtained differs from connect_whatsapp — + everything after that is the same upsert and webhook subscribe. + """ + check_signup_access(ChannelType.WHATSAPP.value, current_user) + + ok, data = await exchange_signup_code(request.code) + access_token = data.get("access_token") if ok else None + if not access_token: + # The code is short-lived (~10 minutes) and single-use, so a stale or + # replayed one lands here rather than on a Graph error later. + raise HTTPException( + status_code=400, + detail=graph_detail(data, "Could not complete signup — please try connecting again"), + ) + + profile = await _verify_signup_assets( + request.waba_id, request.phone_number_id, access_token) + + # A number from Embedded Signup is attached to the WABA but cannot send + # until it is registered for Cloud API use. + pin = _generate_verification_pin() + registered, register_data = await register_phone_number( + request.phone_number_id, access_token, pin) + if not registered: + logger.warning( + f"Phone {request.phone_number_id} not registered after signup: " + f"{graph_detail(register_data, 'unknown error')}") + + credentials = {"access_token": access_token, "waba_id": request.waba_id} + # Only keep the PIN we actually set on the number. Storing one from a failed + # register would overwrite a working PIN with a value Meta never accepted — + # and Meta cannot read it back, so the real one would be unrecoverable. + if registered: + credentials["verification_pin"] = pin + + # Connected from the customer's own app before: its token is the one that + # can hand the number's webhooks back + previous_token = own_app_token(_previous_account( + db, organization, ChannelType.WHATSAPP.value, request.phone_number_id)) + account = _upsert_account( + db, organization, ChannelType.WHATSAPP.value, + external_account_id=request.phone_number_id, + credentials=credentials, + display_name=request.display_name or _whatsapp_display_name( + profile, request.phone_number_id), + forget=(APP_SECRET_KEY,), + ) + if not await subscribe_app(request.waba_id, access_token): + logger.warning(f"WABA subscribe failed for {request.waba_id} after Embedded Signup") + await sync_own_app(db, account, None, access_token, previous_token) + return to_account_out(db, account) + + +@router.post("/messenger", response_model=ChannelAccountOut) +async def connect_messenger( + request: MessengerConnectRequest, + current_user: User = Depends(require_permissions("manage_organization")), + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db), +): + """Connect a Facebook Page for Messenger with a page access token.""" + # Inspect the token rather than reading the Page node: `me?fields=id,name` + # needs pages_read_engagement, which a messaging token has no reason to + # carry — it would reject a token that sends perfectly well. + ok, info = await debug_token(request.page_access_token) + if not ok or not info.get("is_valid"): + raise HTTPException(status_code=400, detail=graph_detail( + info, "That token is not valid — generate a fresh Page access token")) + if info.get("type") != "PAGE": + raise HTTPException(status_code=400, detail=( + f"That is a {str(info.get('type', 'user')).lower()} access token, not a Page " + f"access token. In the Meta app dashboard go to Messenger → Settings and " + f"click Generate on the row for your Page.")) + if str(info.get("profile_id")) != request.page_id: + raise HTTPException(status_code=400, detail=( + f"That token is for Page {info.get('profile_id')}, not the Page ID you " + f"entered. Webhooks are routed by Page ID, so the two must match.")) + + previous = _previous_account(db, organization, ChannelType.MESSENGER.value, request.page_id) + own = await _own_app(request.page_access_token, request.app_secret, previous) + previous_token = own_app_token(previous) + + display_name = request.display_name or await _page_name( + request.page_id, request.page_access_token) + account = _upsert_account( + db, organization, ChannelType.MESSENGER.value, + external_account_id=request.page_id, + credentials={"access_token": request.page_access_token, **_own_app_credentials(own)}, + display_name=display_name, + forget=() if own else (APP_SECRET_KEY,), + ) + if not await subscribe_app(request.page_id, request.page_access_token, + subscribed_fields=MESSENGER_SUBSCRIBED_FIELDS): + logger.warning(f"Page subscribe failed for {request.page_id}; webhook may need manual subscription") + await sync_own_app(db, account, own, request.page_access_token, previous_token) + return to_account_out(db, account) + + +@router.post("/messenger/signup/pages", response_model=MessengerSignupPagesOut) +async def list_messenger_signup_pages( + request: MessengerSignupRequest, + current_user: User = Depends(require_permissions("manage_organization")), + organization: Organization = Depends(get_current_organization), +): + """Step 1 of Facebook Login for Business: trade the code for the customer's + Pages so they can pick which to connect. + + The Page tokens are sealed into signup_token and never reach the browser; + only the ids and names come back for the picker. + """ + check_signup_access(ChannelType.MESSENGER.value, current_user) + user_token = await _signup_user_token(request) + + pages = await _list_manageable_pages(user_token, "id,name,access_token") + if not pages: + raise HTTPException(status_code=400, detail=( + "No Facebook Pages were shared with ChatterMate. Run the connect " + "again and tick the Page you want to use.")) + + return MessengerSignupPagesOut( + signup_token=_seal_signup_pages(organization.id, pages), + pages=[MessengerPageOut(id=p["id"], name=p.get("name") or p["id"]) for p in pages], + ) + + +@router.post("/messenger/signup/connect", response_model=ChannelAccountOut) +async def connect_messenger_signup( + request: MessengerSignupConnectRequest, + current_user: User = Depends(require_permissions("manage_organization")), + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db), +): + """Step 2: connect the Page the customer picked in step 1. + + page_id is a selector into the list we fetched ourselves, never a caller + claim: the token stored is the one /me/accounts returned for that Page, so a + caller cannot connect a Page they do not administer. This is the Messenger + equivalent of _verify_signup_assets, and matters for the same reason — + webhooks resolve accounts by external id with no org scoping. + """ + check_signup_access(ChannelType.MESSENGER.value, current_user) + pages = _open_signup_pages(request.signup_token, organization) + + page = next((p for p in pages if p.get("id") == request.page_id), None) + if page is None or not page.get("access_token"): + raise HTTPException(status_code=400, detail="That Page was not part of this signup") + + previous_token = own_app_token(_previous_account(db, organization, ChannelType.MESSENGER.value, page["id"])) + account = _upsert_account( + db, organization, ChannelType.MESSENGER.value, + external_account_id=page["id"], + credentials={"access_token": page["access_token"]}, + display_name=request.display_name or page.get("name") or page["id"], + forget=(APP_SECRET_KEY,), + ) + if not await subscribe_app(page["id"], page["access_token"], + subscribed_fields=MESSENGER_SUBSCRIBED_FIELDS): + logger.warning(f"Page subscribe failed for {page['id']} after signup") + await sync_own_app(db, account, None, page["access_token"], previous_token) + return to_account_out(db, account) + + +@router.post("/instagram/login/connect", response_model=ChannelAccountOut) +async def connect_instagram_login( + request: InstagramLoginRequest, + current_user: User = Depends(require_permissions("manage_organization")), + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db), +): + """Connect an Instagram professional account through Instagram Login. + + Unlike Messenger this needs no Facebook Page: the business signs in with + Instagram itself, so the login returns exactly one account and there is + nothing to pick. The token is an Instagram user token, used against + graph.instagram.com rather than the Facebook graph. + """ + check_signup_access(ChannelType.INSTAGRAM.value, current_user) + + ok, data = await exchange_instagram_code(request.code, request.redirect_uri) + token_data = instagram_token_payload(data) if ok else {} + access_token = token_data.get("access_token") + user_id = token_data.get("user_id") + if not access_token or not user_id: + # The code is single-use and short-lived, so a stale or replayed one + # lands here rather than on a Graph error later. + raise HTTPException(status_code=400, detail=graph_detail( + data, "Could not complete Instagram login — please try connecting again")) + + # The login token lasts about an hour; without extending it the connection + # dies silently mid-conversation. Best-effort, like the Page flow. + ok, extended = await exchange_instagram_long_lived(access_token) + if ok and extended.get("access_token"): + access_token = extended["access_token"] + else: + logger.warning("Could not extend the Instagram token; it may expire within the hour") + + ok, profile = await graph_get("me", access_token, + params={"fields": "user_id,username"}, + base=GRAPH_INSTAGRAM_BASE) + username = profile.get("username") if ok else None + # Webhooks route Instagram events by the professional account id, which /me + # reports as user_id. The login response also carries a user_id but it can + # be app-scoped, and keying on the wrong one means inbound DMs never match + # any account — silently, with no error. Prefer the authoritative one. + account_id = str((profile.get("user_id") if ok else None) or user_id) + + account = _upsert_account( + db, organization, ChannelType.INSTAGRAM.value, + external_account_id=account_id, + credentials={"access_token": access_token}, + display_name=request.display_name or ("@" + (username or account_id)), + ) + if not await subscribe_instagram_app(access_token): + logger.warning(f"Instagram webhook subscribe failed for {account_id}; DMs may not arrive") + return to_account_out(db, account) + + +@router.post("/instagram", response_model=ChannelAccountOut) +async def connect_instagram( + request: InstagramConnectRequest, + current_user: User = Depends(require_permissions("manage_organization")), + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db), +): + """Connect an Instagram professional account (via its linked page token).""" + ok, data = await graph_get(request.ig_id, request.page_access_token, + params={"fields": "id,username"}) + if not ok: + raise HTTPException(status_code=400, + detail=f"Could not verify Instagram credentials: {data.get('error', {}).get('message', 'invalid token or account id')}") + # Webhook routing matches entry.id against this value, so reject anything + # that isn't the actual IG business-account id (e.g. a pasted Page id — + # the page token can read that node too, so ok=True alone isn't enough). + if str(data.get("id")) != request.ig_id: + raise HTTPException(status_code=400, + detail="The ID entered is not an Instagram professional account ID") + + account = _upsert_account( + db, organization, ChannelType.INSTAGRAM.value, + external_account_id=request.ig_id, + credentials={"access_token": request.page_access_token}, + display_name=request.display_name or f"@{data.get('username', request.ig_id)}", + ) + return to_account_out(db, account) + + +@router.delete("/{account_id}") +async def disconnect_meta_account( + account_id: UUID, + current_user: User = Depends(require_permissions("manage_organization")), + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db), +): + """Disconnect a WhatsApp/Messenger/Instagram account.""" + account = get_org_account_or_404(db, account_id, organization) + if account.channel_type not in (ChannelType.WHATSAPP.value, + ChannelType.MESSENGER.value, + ChannelType.INSTAGRAM.value): + raise HTTPException(status_code=404, detail="Channel account not found") + await leave_own_app(account) + ChannelAccountRepository(db).delete(account) + return {"status": "disconnected"} diff --git a/backend/app/api/channels/slack.py b/backend/app/api/channels/slack.py new file mode 100644 index 0000000..b2ff4f6 --- /dev/null +++ b/backend/app/api/channels/slack.py @@ -0,0 +1,162 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import secrets +from urllib.parse import urlencode +from uuid import UUID + +import httpx +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import RedirectResponse +from sqlalchemy.orm import Session + +from app.api.channels.accounts import get_org_account_or_404 +from app.channels.slack import SLACK_API_BASE, slack_api +from app.core.auth import get_current_organization, require_permissions +from app.core.config import settings +from app.core.oauth_state import pop_state, store_state +from app.database import get_db +from app.models.channels import ChannelAccount, ChannelType +from app.models.organization import Organization +from app.models.user import User +from app.repositories.channels import ChannelAccountRepository +from app.core.logger import get_logger + +router = APIRouter() +logger = get_logger(__name__) + +# Bot scopes for the customer-chat use case. Keep this minimal — the +# Marketplace review asks us to justify each one, and once listed the app can +# only use approved scopes: +# app_mentions:read receive app_mention, so the agent answers when @mentioned +# chat:write post the reply and the "typing…" placeholder +# im:history receive message.im, so the agent answers DMs +# users:read users.info, so a customer shows as a name not a raw U0… id +OAUTH_SCOPES = "app_mentions:read,chat:write,im:history,users:read" + +def _redirect_uri() -> str: + return f"{settings.BACKEND_URL.rstrip('/')}{settings.API_V1_STR}/channels/slack/callback" + + +def _settings_redirect(status: str, reason: str = None) -> RedirectResponse: + params = {"status": status, "integration": "slack"} + if reason: + params["reason"] = reason + return RedirectResponse(f"{settings.FRONTEND_URL}/settings/integrations?{urlencode(params)}") + + +@router.get("/install") +async def install_slack( + current_user: User = Depends(require_permissions("manage_organization")), + organization: Organization = Depends(get_current_organization), +): + """Start the Slack OAuth install: redirect the browser to Slack's consent + page with a CSRF state bound to the organization.""" + if not settings.SLACK_CLIENT_ID or not settings.SLACK_CLIENT_SECRET: + raise HTTPException(status_code=400, detail="Slack app credentials are not configured") + state = secrets.token_urlsafe(24) + store_state("slack", state, str(organization.id)) + params = urlencode({ + "client_id": settings.SLACK_CLIENT_ID, + "scope": OAUTH_SCOPES, + "redirect_uri": _redirect_uri(), + "state": state, + }) + return RedirectResponse(f"https://slack.com/oauth/v2/authorize?{params}") + + +@router.get("/callback") +async def slack_oauth_callback( + code: str = None, + state: str = None, + error: str = None, + db: Session = Depends(get_db), +): + """Complete the OAuth install: exchange the code, store the workspace as a + channel account, and bounce back to the settings page.""" + if error: + return _settings_redirect("failure", "cancelled" if error == "access_denied" else error) + org_id = pop_state("slack", state or "") + if org_id is None or not code: + return _settings_redirect("failure", "invalid_state") + + async with httpx.AsyncClient(timeout=15.0) as client: + response = await client.post(f"{SLACK_API_BASE}/oauth.v2.access", data={ + "client_id": settings.SLACK_CLIENT_ID, + "client_secret": settings.SLACK_CLIENT_SECRET, + "code": code, + "redirect_uri": _redirect_uri(), + }) + data = response.json() + if not data.get("ok"): + logger.error(f"Slack OAuth exchange failed: {data.get('error')}") + return _settings_redirect("failure", str(data.get("error", "oauth_failed"))) + + team = data.get("team") or {} + team_id = team.get("id", "") + repo = ChannelAccountRepository(db) + existing = repo.get_by_external_id(ChannelType.SLACK.value, team_id) + credentials = { + "access_token": data.get("access_token"), + "bot_user_id": data.get("bot_user_id"), + } + if existing is not None: + if str(existing.organization_id) != org_id: + return _settings_redirect("failure", "workspace_connected_elsewhere") + repo.update_credentials(existing, credentials) + repo.set_active(existing, True) + else: + repo.create_account( + organization_id=UUID(org_id), + channel_type=ChannelType.SLACK.value, + external_account_id=team_id, + credentials=credentials, + display_name=team.get("name", team_id), + ) + logger.info(f"Connected Slack workspace {team.get('name')} for org {org_id}") + return _settings_redirect("success") + + +async def _revoke_token(repo: ChannelAccountRepository, account: ChannelAccount) -> None: + """Best-effort: tell Slack to invalidate the bot token before we drop it. + + Never blocks the disconnect — an already-revoked token, an undecryptable + blob, or an unreachable Slack must still leave the workspace disconnected + on our side. + """ + try: + token = repo.get_credentials(account).get("access_token") + if not token: + return + await slack_api("auth.revoke", token, {}, form=True) + except Exception as e: + logger.warning(f"Slack auth.revoke failed, disconnecting anyway: {e}") + + +@router.delete("/{account_id}") +async def disconnect_slack( + account_id: UUID, + current_user: User = Depends(require_permissions("manage_organization")), + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db), +): + account = get_org_account_or_404(db, account_id, organization) + if account.channel_type != ChannelType.SLACK.value: + raise HTTPException(status_code=404, detail="Channel account not found") + repo = ChannelAccountRepository(db) + await _revoke_token(repo, account) + repo.delete(account) + return {"status": "disconnected"} diff --git a/backend/app/api/channels/sms.py b/backend/app/api/channels/sms.py new file mode 100644 index 0000000..886eba4 --- /dev/null +++ b/backend/app/api/channels/sms.py @@ -0,0 +1,108 @@ +""" +Copyright 2024-2026 ChatterMate + +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 typing import List +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.api.channels.accounts import get_org_account_or_404, to_account_out +from app.channels.sms import get_provider, list_providers +from app.core.auth import get_current_organization, require_permissions +from app.core.config import settings +from app.database import get_db +from app.models.channels import ChannelType +from app.models.organization import Organization +from app.models.schemas.channel import ChannelAccountOut, SmsConnectRequest, SmsProviderInfo +from app.models.user import User +from app.repositories.channels import ChannelAccountRepository +from app.core.logger import get_logger + +router = APIRouter() +logger = get_logger(__name__) + + +@router.get("/providers", response_model=List[SmsProviderInfo]) +async def list_sms_providers( + current_user: User = Depends(require_permissions("manage_organization")), +): + """Available SMS providers and the credential fields each needs — drives + the connect form's provider dropdown.""" + return [ + SmsProviderInfo( + name=p.name, label=p.label, + fields=[{"key": f.key, "label": f.label, "secret": f.secret, "optional": f.optional} + for f in p.credential_fields], + ) + for p in list_providers() + ] + + +@router.post("", response_model=ChannelAccountOut) +async def connect_sms( + request: SmsConnectRequest, + current_user: User = Depends(require_permissions("manage_organization")), + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db), +): + """Connect an SMS number through one of the supported providers. Validates + the credentials with the provider before saving.""" + provider = get_provider(request.provider) + if provider is None: + raise HTTPException(status_code=400, detail=f"Unknown SMS provider '{request.provider}'") + + phone = request.phone_number.strip() + try: + await provider.validate_credentials(phone, request.credentials) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + repo = ChannelAccountRepository(db) + existing = repo.get_by_external_id(ChannelType.SMS.value, phone) + if existing is not None: + if existing.organization_id != organization.id: + raise HTTPException(status_code=409, detail="This number is already connected to another organization") + repo.update_credentials(existing, request.credentials) + existing.settings = {**(existing.settings or {}), "provider": request.provider} + db.commit() + account = repo.set_active(existing, True) + else: + account = repo.create_account( + organization_id=organization.id, + channel_type=ChannelType.SMS.value, + external_account_id=phone, + credentials=request.credentials, + display_name=f"SMS {phone} ({provider.label})", + settings={"provider": request.provider}, + ) + out = to_account_out(db, account) + out.webhook_url = f"{settings.BACKEND_URL.rstrip('/')}{settings.API_V1_STR}/webhooks/sms/{request.provider}/{account.id}" + return out + + +@router.delete("/{account_id}") +async def disconnect_sms( + account_id: UUID, + current_user: User = Depends(require_permissions("manage_organization")), + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db), +): + account = get_org_account_or_404(db, account_id, organization) + if account.channel_type != ChannelType.SMS.value: + raise HTTPException(status_code=404, detail="Channel account not found") + ChannelAccountRepository(db).delete(account) + return {"status": "disconnected"} diff --git a/backend/app/api/channels/telegram.py b/backend/app/api/channels/telegram.py new file mode 100644 index 0000000..6009978 --- /dev/null +++ b/backend/app/api/channels/telegram.py @@ -0,0 +1,104 @@ +""" +Copyright 2024-2026 ChatterMate + +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 uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.api.channels.accounts import get_org_account_or_404, to_account_out +from app.channels import telegram as telegram_api +from app.core.auth import get_current_organization, require_permissions +from app.core.error_handlers import ClientSafeHTTPException +from app.database import get_db +from app.models.channels import ChannelType +from app.models.organization import Organization +from app.models.schemas.channel import ChannelAccountOut, TelegramConnectRequest +from app.models.user import User +from app.repositories.channels import ChannelAccountRepository +from app.services import telegram_webhooks +from app.core.logger import get_logger + +router = APIRouter() +logger = get_logger(__name__) + + +@router.post("", response_model=ChannelAccountOut) +async def connect_telegram( + request: TelegramConnectRequest, + current_user: User = Depends(require_permissions("manage_organization")), + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db), +): + """Connect a Telegram bot: validate the token with getMe, store the + encrypted credentials, and register our webhook with a secret token.""" + bot_token = request.bot_token.strip() + bot = await telegram_api.get_me(bot_token) + if bot is None: + raise HTTPException(status_code=400, detail="Invalid bot token") + + repo = ChannelAccountRepository(db) + existing = repo.get_by_external_id(ChannelType.TELEGRAM.value, str(bot["id"])) + if existing is not None: + if existing.organization_id != organization.id: + raise HTTPException(status_code=409, detail="This bot is already connected to another organization") + repo.update_credentials(existing, {"bot_token": bot_token}) + account = repo.set_active(existing, True) + created = False + else: + account = repo.create_account( + organization_id=organization.id, + channel_type=ChannelType.TELEGRAM.value, + external_account_id=str(bot["id"]), + credentials={"bot_token": bot_token}, + display_name=f"@{bot.get('username', bot['id'])}", + ) + created = True + + webhook_ok, webhook_error = await telegram_webhooks.register(account, bot_token) + if not webhook_ok: + # Roll back only a freshly created account; keep an existing one (and + # its conversation history) intact on a transient webhook failure. + if created: + repo.delete(account) + detail = f"Failed to register Telegram webhook: {webhook_error}" + if "https" in webhook_error.lower(): + detail += " — set BACKEND_URL to a public HTTPS URL (e.g. your ngrok domain)" + raise ClientSafeHTTPException(status_code=502, detail=detail) + + logger.info(f"Connected Telegram bot @{bot.get('username')} for org {organization.id}") + return to_account_out(db, account) + + +@router.delete("/{account_id}") +async def disconnect_telegram( + account_id: UUID, + current_user: User = Depends(require_permissions("manage_organization")), + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db), +): + """Disconnect a Telegram bot: remove the webhook and delete the account.""" + account = get_org_account_or_404(db, account_id, organization) + if account.channel_type != ChannelType.TELEGRAM.value: + raise HTTPException(status_code=404, detail="Channel account not found") + + repo = ChannelAccountRepository(db) + try: + await telegram_api.delete_webhook(repo.get_credentials(account)["bot_token"]) + except Exception as e: + logger.warning(f"Failed to remove Telegram webhook during disconnect: {e}") + repo.delete(account) + return {"status": "disconnected"} diff --git a/backend/app/api/channels/whatsapp_messaging.py b/backend/app/api/channels/whatsapp_messaging.py new file mode 100644 index 0000000..0397fe4 --- /dev/null +++ b/backend/app/api/channels/whatsapp_messaging.py @@ -0,0 +1,217 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +"""Sending on an already-connected WhatsApp number: templates and outbound +conversations. + +Separate from meta.py, which onboards accounts. These routes are inbox work +done by support agents on a live number, not org-admin onboarding, and they are +the only ones that care about WhatsApp Business Accounts and templates. They +mount under the same /meta prefix, so the URLs are unchanged. +""" + +from urllib.parse import urlencode +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.api.channels.accounts import get_org_account_or_404 +from app.channels import get_adapter +from app.channels.meta_base import fetch_message_templates, graph_auth_refused, graph_detail, graph_get +from app.channels.constants import MessageSource +from app.core.error_handlers import ClientSafeHTTPException +from app.core.auth import ( + CHAT_MANAGE_PERMISSIONS, + INBOX_PERMISSIONS, + get_current_organization, + require_any_permission, + require_permissions, +) +from app.database import get_db +from app.models.channels import ChannelType +from app.models.organization import Organization +from app.models.schemas.channel import ( + OutboundConversationOut, + OutboundConversationRequest, + TemplateLibraryOut, + TemplateOut, + TemplateSendRequest, +) +from app.models.user import User +from app.repositories.channels import ChannelAccountRepository, ChannelConversationRepository +from app.services.whatsapp_outbound import OutboundError, start_outbound_conversation + +router = APIRouter() + +# Meta's own template authoring UI, which is where we send people to write one. +TEMPLATE_LIBRARY_URL = "https://business.facebook.com/latest/whatsapp_manager/template_library" + +# Reading the template list is inbox work — the picker needs it — so any role +# that works the inbox qualifies. INBOX_PERMISSIONS is shared with the People +# page so the two surfaces can't disagree about who that is. +require_inbox_agent = require_any_permission(*INBOX_PERMISSIONS) + +# Sending is not: an outbound template is a billable Meta message to a phone +# number, with opt-in obligations attached. It takes the same grant as taking +# over or reassigning a chat, so a view-only role can browse templates without +# being able to fire one. +require_inbox_sender = require_any_permission(*CHAT_MANAGE_PERMISSIONS) + + +def _whatsapp_account_or_404(db: Session, account_id: UUID, organization: Organization): + account = get_org_account_or_404(db, account_id, organization) + if account.channel_type != ChannelType.WHATSAPP.value: + raise HTTPException(status_code=404, detail="Channel account not found") + return account + + +async def _fetch_all_templates(db: Session, account) -> list[TemplateOut]: + """Every template on the account's WABA (transport lives in meta_base; this + wrapper owns the HTTP error surface).""" + waba_id, access_token = _waba_credentials(db, account) + ok, data = await fetch_message_templates(waba_id, access_token) + if not ok: + if graph_auth_refused(data): + ChannelAccountRepository(db).record_auth_failure( + account, graph_detail(data, "Authentication Error")) + raise ClientSafeHTTPException(status_code=502, + detail=graph_detail(data, "Could not list templates")) + return [TemplateOut(**template) for template in data] + + +def _waba_credentials(db: Session, account) -> tuple[str, str]: + """(waba_id, access_token) for a WhatsApp account. + + Templates live on the WhatsApp Business Account, not the phone number, and + the WABA id is optional in the credential blob — accounts connected without + it can send and receive but cannot manage templates. + """ + credentials = ChannelAccountRepository(db).get_credentials(account) + waba_id = credentials.get("waba_id") + if not waba_id: + raise HTTPException( + status_code=400, + detail="Reconnect this number with its WhatsApp Business Account ID to manage templates", + ) + return waba_id, credentials["access_token"] + + +@router.post("/whatsapp/{account_id}/conversations", response_model=OutboundConversationOut) +async def start_whatsapp_conversation( + account_id: UUID, + request: OutboundConversationRequest, + current_user: User = Depends(require_inbox_sender), + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db), +): + """Start a WhatsApp conversation with a phone number via an approved + Utility/Authentication template. The thin route: policy lives in the + service so the Phase-2 scheduler can call the same function.""" + account = _whatsapp_account_or_404(db, account_id, organization) + try: + sent = await start_outbound_conversation( + db, account, + to=request.to, + template_name=request.template_name, + language=request.language, + components=request.components, + customer_id=request.customer_id, + customer_name=request.customer_name, + source=MessageSource.HUMAN, + ) + except OutboundError as e: + # OutboundError details are written for the operator ("Recipient not on + # WhatsApp"), so they survive the 5xx sanitizer. + raise ClientSafeHTTPException(status_code=e.status_code, detail=e.detail) + return OutboundConversationOut(session_id=sent.session_id) + + +@router.post("/whatsapp/{account_id}/send-template") +async def send_whatsapp_template( + account_id: UUID, + request: TemplateSendRequest, + current_user: User = Depends(require_inbox_sender), + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db), +): + """Send an approved template message to reopen an expired 24h window.""" + account = _whatsapp_account_or_404(db, account_id, organization) + + conversation = ChannelConversationRepository(db).get_by_session(request.session_id) + if conversation is None or conversation.channel_account_id != account.id: + raise HTTPException(status_code=404, detail="Conversation not found for this account") + + adapter = get_adapter(ChannelType.WHATSAPP.value) + result = await adapter.send_template( + account, conversation, + template_name=request.template_name, + language=request.language, + components=request.components, + ) + ChannelAccountRepository(db).track_send_result(account, result) + if not result.ok: + raise ClientSafeHTTPException(status_code=502, detail=result.error or "Template send failed") + return {"status": "sent", "external_message_id": result.external_message_id} + + +@router.get("/whatsapp/{account_id}/templates", response_model=list[TemplateOut]) +async def list_whatsapp_templates( + account_id: UUID, + # Inbox-agent, not org-admin: this is what the send-template picker and the + # New-conversation modal read. Gating it tighter than the send it feeds made + # the loosening of those endpoints a no-op — the agent could send a template + # but never see one to pick. + current_user: User = Depends(require_inbox_agent), + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db), +): + """List the message templates on this number's WhatsApp Business Account.""" + account = _whatsapp_account_or_404(db, account_id, organization) + return await _fetch_all_templates(db, account) + + +@router.get("/whatsapp/{account_id}/template-library", response_model=TemplateLibraryOut) +async def get_whatsapp_template_library( + account_id: UUID, + current_user: User = Depends(require_permissions("manage_organization")), + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db), +): + """Where to go to write a template: Meta's own Template Library, opened on + this number's WhatsApp Business Account. + + Templates are not authored here. Meta's library holds ~150 pre-written, + pre-localised utility templates plus its authentication ones, all shaped to + pass its own review — a form of ours could only ever be a worse version of + that, and every rule it encodes (categories, variable numbering, which + buttons may be mixed) is Meta's to change without telling us. + + The link is built per account rather than hardcoded: business_id scopes the + page to the right portfolio. It is best-effort — a business we cannot read + gives a link that lands less precisely, not a dead button. + """ + account = _whatsapp_account_or_404(db, account_id, organization) + waba_id, access_token = _waba_credentials(db, account) + + params = {"asset_id": waba_id} + ok, data = await graph_get(waba_id, access_token, params={"fields": "owner_business_info"}) + if ok: + business_id = (data.get("owner_business_info") or {}).get("id") + if business_id: + params["business_id"] = business_id + + return TemplateLibraryOut(url=f"{TEMPLATE_LIBRARY_URL}?{urlencode(params)}") diff --git a/backend/app/api/help_center/__init__.py b/backend/app/api/help_center/__init__.py new file mode 100644 index 0000000..b73b8bc --- /dev/null +++ b/backend/app/api/help_center/__init__.py @@ -0,0 +1,32 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +Admin API for the FAQ manager + help center, split by concern +(settings/branding, FAQ content, generation jobs; the custom-domain router +joins in the domain-verification unit). Mounted at /api/v1/help-center. +""" + +from fastapi import APIRouter + +from app.api.help_center.branding import router as branding_router +from app.api.help_center.domain import router as domain_router +from app.api.help_center.faqs import router as faqs_router +from app.api.help_center.generation import router as generation_router + +router = APIRouter() +router.include_router(branding_router) +router.include_router(faqs_router) +router.include_router(generation_router) +router.include_router(domain_router) diff --git a/backend/app/api/help_center/branding.py b/backend/app/api/help_center/branding.py new file mode 100644 index 0000000..ed01850 --- /dev/null +++ b/backend/app/api/help_center/branding.py @@ -0,0 +1,245 @@ +""" +Copyright 2024-2026 ChatterMate + +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 + branding endpoints (settings get/put, logo upload). +""" + +from uuid import UUID, uuid4 + +from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile +from sqlalchemy.orm import Session, selectinload + +from app.core.auth import require_permissions +from app.core.config import settings as app_settings +from app.core.cors import update_cors_middleware +from app.database import get_db +from app.models.agent import Agent +from app.models.help_center import HelpCenterSettings +from app.models.schemas.help_center import ( + DnsRecord, + DomainStatusResponse, + HelpCenterAgentOption, + HelpCenterSettingsResponse, + HelpCenterSettingsUpdate, +) +from app.models.user import User +from app.repositories.faq import FAQRepository +from app.repositories.help_center import HelpCenterRepository +from app.services.file_storage import resolve_public_url, store_upload +from app.services.help_center_access import check_help_center_access, help_center_allowed +from app.services.help_center_settings import get_or_create_settings, live_url +from app.services.help_center_images import absolute_upload_url +from app.services.image_security import sanitize_image + +router = APIRouter() + +MAX_LOGO_BYTES = 2 * 1024 * 1024 +# Raster only — SVG is active markup and would be stored XSS on the public help +# center, so it is not accepted; the admin cropper rasterises to PNG client-side. +_LOGO_TYPES = {"image/png", "image/jpeg", "image/webp"} +# Reject anything larger than this on a side up front (decompression-bomb guard); +# the stored logo is then downscaled to LOGO_FIT_DIM (header renders it ~30px). +MAX_LOGO_DIM = 4000 +LOGO_FIT_DIM = 512 + +# Favicon: same raster-only policy (SVG/ICO rejected — stored XSS / unsupported by +# sanitize_image). Stored small and square-ish; the browser scales the PNG. +MAX_FAVICON_BYTES = 1 * 1024 * 1024 +_FAVICON_TYPES = {"image/png", "image/jpeg", "image/webp"} +MAX_FAVICON_DIM = 2000 +FAVICON_FIT_DIM = 128 + + +def domain_status_response(row: HelpCenterSettings) -> DomainStatusResponse: + """DNS-records table for the admin UI, shaped from stored state.""" + records = [] + if row.custom_domain: + records = [ + DnsRecord( + type="CNAME", + host=row.custom_domain, + value=app_settings.HELP_CENTER_CNAME_TARGET, + verified=row.cname_record_verified, + ), + DnsRecord( + type="TXT", + host=f"_chattermate.{row.custom_domain}", + value=f"cm-verify={row.domain_verification_token}", + verified=row.txt_record_verified, + ), + ] + return DomainStatusResponse( + custom_domain=row.custom_domain, + domain_status=row.domain_status, + ssl_status=row.ssl_status, + records=records, + domain_verified_at=row.domain_verified_at, + ) + + +async def settings_response( + db: Session, row: HelpCenterSettings, organization_id: UUID +) -> HelpCenterSettingsResponse: + agents = ( + db.query(Agent) + .options(selectinload(Agent.widgets)) # avoid a lazy-load query per agent + .filter(Agent.organization_id == organization_id) + .order_by(Agent.name) + .all() + ) + response = HelpCenterSettingsResponse.model_validate(row) + # Absolute (api-origin) URL: the admin runs on app.chattermate.chat, whose + # nginx serves any *.png path as a static asset — a host-relative + # /api/v1/uploads/*.png would 404 there. Anchor to the backend origin. + response.logo_url = ( + absolute_upload_url(await resolve_public_url(row.logo_url)) if row.logo_url else None + ) + response.favicon_url = ( + absolute_upload_url(await resolve_public_url(row.favicon_url)) if row.favicon_url else None + ) + response.live_url = live_url(row) + response.published_count = FAQRepository(db).count_published(organization_id) + response.plan_allowed = help_center_allowed(db, organization_id) + response.agents = [ + HelpCenterAgentOption( + id=agent.id, + name=agent.display_name or agent.name, + has_widget=bool(agent.widgets), + ) + for agent in agents + ] + response.domain = domain_status_response(row) + return response + + +@router.get("/settings", response_model=HelpCenterSettingsResponse) +async def get_settings( + current_user: User = Depends(require_permissions("manage_knowledge")), + db: Session = Depends(get_db), +): + """Get-or-create the org's help center settings. Ungated read: the response + carries plan_allowed so the UI can render the upgrade lock.""" + row = get_or_create_settings(db, current_user.organization) + return await settings_response(db, row, current_user.organization_id) + + +@router.put("/settings", response_model=HelpCenterSettingsResponse) +async def update_settings( + payload: HelpCenterSettingsUpdate, + request: Request, + current_user: User = Depends(require_permissions("manage_knowledge")), + db: Session = Depends(get_db), +): + check_help_center_access(db, current_user.organization_id) + row = get_or_create_settings(db, current_user.organization) + updates = payload.model_dump(exclude_unset=True) + if updates.get("agent_id") is not None: + agent = db.query(Agent).filter( + Agent.id == updates["agent_id"], + Agent.organization_id == current_user.organization_id, + ).first() + if not agent: + raise HTTPException(status_code=404, detail="Agent not found") + was_enabled = row.enabled + for field, value in updates.items(): + setattr(row, field, value) + row = HelpCenterRepository(db).update(row) + # Toggling `enabled` changes which {slug}.{base} origins the widget may call, + # so refresh the CORS allowlist (and propagate to other workers via Redis). + if "enabled" in updates and updates["enabled"] != was_enabled: + update_cors_middleware(request.app) + return await settings_response(db, row, current_user.organization_id) + + +@router.post("/logo", response_model=HelpCenterSettingsResponse) +async def upload_logo( + file: UploadFile = File(...), + current_user: User = Depends(require_permissions("manage_knowledge")), + db: Session = Depends(get_db), +): + check_help_center_access(db, current_user.organization_id) + row = get_or_create_settings(db, current_user.organization) + + content = await file.read() + # Validate, bomb-guard, downscale and re-encode to a clean PNG/JPEG — the + # stored bytes carry no metadata or non-image payload. + safe_bytes, content_type, ext = sanitize_image( + content, + allowed_content_types=_LOGO_TYPES, + max_bytes=MAX_LOGO_BYTES, + max_dim=MAX_LOGO_DIM, + fit=LOGO_FIT_DIM, + ) + file_name = f"{uuid4()}{ext}" + row.logo_url = await store_upload( + safe_bytes, + f"help_center/{current_user.organization_id}", + file_name, + content_type=content_type, + ) + row = HelpCenterRepository(db).update(row) + return await settings_response(db, row, current_user.organization_id) + + +@router.delete("/logo", response_model=HelpCenterSettingsResponse) +async def remove_logo( + current_user: User = Depends(require_permissions("manage_knowledge")), + db: Session = Depends(get_db), +): + check_help_center_access(db, current_user.organization_id) + row = get_or_create_settings(db, current_user.organization) + row.logo_url = None + row = HelpCenterRepository(db).update(row) + return await settings_response(db, row, current_user.organization_id) + + +@router.post("/favicon", response_model=HelpCenterSettingsResponse) +async def upload_favicon( + file: UploadFile = File(...), + current_user: User = Depends(require_permissions("manage_knowledge")), + db: Session = Depends(get_db), +): + check_help_center_access(db, current_user.organization_id) + row = get_or_create_settings(db, current_user.organization) + + content = await file.read() + safe_bytes, content_type, ext = sanitize_image( + content, + allowed_content_types=_FAVICON_TYPES, + max_bytes=MAX_FAVICON_BYTES, + max_dim=MAX_FAVICON_DIM, + fit=FAVICON_FIT_DIM, + ) + file_name = f"{uuid4()}{ext}" + row.favicon_url = await store_upload( + safe_bytes, + f"help_center/{current_user.organization_id}", + file_name, + content_type=content_type, + ) + row = HelpCenterRepository(db).update(row) + return await settings_response(db, row, current_user.organization_id) + + +@router.delete("/favicon", response_model=HelpCenterSettingsResponse) +async def remove_favicon( + current_user: User = Depends(require_permissions("manage_knowledge")), + db: Session = Depends(get_db), +): + check_help_center_access(db, current_user.organization_id) + row = get_or_create_settings(db, current_user.organization) + row.favicon_url = None + row = HelpCenterRepository(db).update(row) + return await settings_response(db, row, current_user.organization_id) diff --git a/backend/app/api/help_center/domain.py b/backend/app/api/help_center/domain.py new file mode 100644 index 0000000..10d6da4 --- /dev/null +++ b/backend/app/api/help_center/domain.py @@ -0,0 +1,101 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +Custom-domain lifecycle for the help center: claim, verify (DNS + SSL), +inspect status, remove. +""" + +import asyncio + +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.auth import require_permissions +from app.core.cors import update_cors_middleware +from app.database import get_db +from app.models.schemas.help_center import DomainRequest, DomainStatusResponse +from app.models.user import User +from app.services.domain_verification import ( + clear_custom_domain, + set_custom_domain, + verify_custom_domain, +) +from app.services.help_center_access import check_help_center_access +from app.services.help_center_settings import get_or_create_settings + +router = APIRouter() + + +def _domain_response(row) -> DomainStatusResponse: + from app.api.help_center.branding import domain_status_response + return domain_status_response(row) + + +@router.post("/domain", response_model=DomainStatusResponse) +async def set_domain( + payload: DomainRequest, + current_user: User = Depends(require_permissions("manage_knowledge")), + db: Session = Depends(get_db), +): + check_help_center_access(db, current_user.organization_id) + row = get_or_create_settings(db, current_user.organization) + try: + row = set_custom_domain(db, row, payload.domain) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except IntegrityError: + db.rollback() + raise HTTPException(status_code=409, detail="That domain is already in use.") + return _domain_response(row) + + +@router.delete("/domain", response_model=DomainStatusResponse) +async def remove_domain( + current_user: User = Depends(require_permissions("manage_knowledge")), + db: Session = Depends(get_db), +): + check_help_center_access(db, current_user.organization_id) + row = get_or_create_settings(db, current_user.organization) + return _domain_response(clear_custom_domain(db, row)) + + +@router.post("/domain/verify", response_model=DomainStatusResponse) +async def verify_domain( + request: Request, + current_user: User = Depends(require_permissions("manage_knowledge")), + db: Session = Depends(get_db), +): + check_help_center_access(db, current_user.organization_id) + row = get_or_create_settings(db, current_user.organization) + if not row.custom_domain: + raise HTTPException(status_code=400, detail="Set a custom domain first.") + # DNS + HTTPS probes block; keep them off the event loop. + row = await asyncio.to_thread(verify_custom_domain, db, row) + # Once verified, the custom domain becomes a widget origin — allow it in CORS. + if row.domain_verified: + update_cors_middleware(request.app) + return _domain_response(row) + + +@router.get("/domain/status", response_model=DomainStatusResponse) +async def domain_status( + current_user: User = Depends(require_permissions("manage_knowledge")), + db: Session = Depends(get_db), +): + """Stored state only — cheap enough for the UI's pending-domain polling. + POST /domain/verify is the endpoint that re-runs live DNS checks.""" + row = get_or_create_settings(db, current_user.organization) + return _domain_response(row) diff --git a/backend/app/api/help_center/faqs.py b/backend/app/api/help_center/faqs.py new file mode 100644 index 0000000..155d9be --- /dev/null +++ b/backend/app/api/help_center/faqs.py @@ -0,0 +1,240 @@ +""" +Copyright 2024-2026 ChatterMate + +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 typing import List, Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile +from sqlalchemy.orm import Session + +from app.core.auth import require_permissions +from app.database import get_db +from app.models.faq import FAQ, FAQStatus +from app.models.schemas.faq import ( + FAQBulkDeleteRequest, + FAQBulkStatusRequest, + FAQCreate, + FAQListResponse, + FAQResponse, + FAQUpdate, +) +from app.models.schemas.pagination import Pagination +from app.models.user import User +from app.repositories.faq import FAQRepository +from app.services.help_center_access import check_help_center_access +from app.services.image_security import sanitize_image +from app.core.params import PageNumber +from app.services.help_center_images import ( + FAQ_IMAGE_FIT_DIM, + FAQ_IMAGE_MAX_DIM, + FAQ_IMAGE_TYPES, + MAX_FAQ_IMAGE_BYTES, + store_article_image, +) +from app.services.help_center_settings import ( + generate_faq_slug, + normalize_url_path, + resolve_faq_slug, + resolve_faq_url_path, +) + +router = APIRouter() + + +def _get_owned_faq(faq_id: UUID, current_user: User, db: Session) -> FAQ: + faq = FAQRepository(db).get_by_id(faq_id, current_user.organization_id) + # 404 for both missing and cross-org ids (don't reveal existence). + if not faq: + raise HTTPException(status_code=404, detail="FAQ not found") + return faq + + +@router.get("/faqs", response_model=FAQListResponse) +async def list_faqs( + status: Optional[FAQStatus] = Query(default=None), + category: Optional[str] = Query(default=None), + q: Optional[str] = Query(default=None, max_length=200), + page: PageNumber = 1, + page_size: int = Query(default=100, ge=1, le=200), + current_user: User = Depends(require_permissions("manage_knowledge")), + db: Session = Depends(get_db), +): + items, total = FAQRepository(db).list_for_org( + current_user.organization_id, + status=status, + category=category, + search=q, + skip=(page - 1) * page_size, + limit=page_size, + ) + return FAQListResponse( + faqs=[FAQResponse.model_validate(item) for item in items], + pagination=Pagination.build(total=total, page=page, page_size=page_size), + ) + + +@router.get("/faqs/categories", response_model=List[str]) +async def list_categories( + current_user: User = Depends(require_permissions("manage_knowledge")), + db: Session = Depends(get_db), +): + return FAQRepository(db).get_categories(current_user.organization_id) + + +@router.post("/faqs", response_model=FAQResponse, status_code=201) +async def create_faq( + payload: FAQCreate, + current_user: User = Depends(require_permissions("manage_knowledge")), + db: Session = Depends(get_db), +): + check_help_center_access(db, current_user.organization_id) + org_id = current_user.organization_id + faq = FAQ( + organization_id=org_id, + question=payload.question, + answer=payload.answer, + category=payload.category, + status=payload.status, + # A hand-typed slug is normalized and de-duplicated the same way a + # generated one is; omitting it derives the slug from the question. + slug=( + resolve_faq_slug(db, org_id, payload.slug) + if payload.slug + else generate_faq_slug(db, org_id, payload.question) + ), + url_path=_resolve_url_path(db, org_id, payload.url_path), + meta_title=payload.meta_title, + meta_description=payload.meta_description, + source_label="Added manually", + created_by=current_user.id, + ) + return FAQResponse.model_validate(FAQRepository(db).create(faq)) + + +def _resolve_url_path( + db: Session, org_id: UUID, requested: Optional[str], exclude_id: Optional[UUID] = None +) -> Optional[str]: + """Validate a hand-typed preserved URL path, or raise. None passes through + (no path requested / cleared). Unlike the slug this is never auto-corrected: + an altered path is no longer the URL the org migrated in, so a bad one is + surfaced instead of silently changed.""" + if not requested: + return None + if normalize_url_path(requested) is None: + raise HTTPException( + status_code=400, + detail="That isn't a usable URL path — it can't be empty, or start with a " + "reserved prefix like /a/, /ask or /api.", + ) + resolved = resolve_faq_url_path(db, org_id, requested, exclude_id=exclude_id) + if resolved is None: + raise HTTPException(status_code=409, detail="Another article already uses that URL path.") + return resolved + + +@router.post("/faqs/image") +async def upload_faq_image( + file: UploadFile = File(...), + current_user: User = Depends(require_permissions("manage_knowledge")), + db: Session = Depends(get_db), +): + """Upload an image for embedding in an article's Markdown. Returns a stable + absolute URL the editor inserts as ![](url).""" + check_help_center_access(db, current_user.organization_id) + content = await file.read() + # Validate, bomb-guard, downscale and re-encode from decoded pixels — strips + # metadata/payloads and normalises to PNG/JPEG (content type is derived here, + # never from the client filename). + safe_bytes, content_type, _ext = sanitize_image( + content, + allowed_content_types=set(FAQ_IMAGE_TYPES), + max_bytes=MAX_FAQ_IMAGE_BYTES, + max_dim=FAQ_IMAGE_MAX_DIM, + fit=FAQ_IMAGE_FIT_DIM, + ) + return {"url": await store_article_image(safe_bytes, content_type)} + + +@router.put("/faqs/{faq_id}", response_model=FAQResponse) +async def update_faq( + faq_id: UUID, + payload: FAQUpdate, + current_user: User = Depends(require_permissions("manage_knowledge")), + db: Session = Depends(get_db), +): + check_help_center_access(db, current_user.organization_id) + faq = _get_owned_faq(faq_id, current_user, db) + updates = payload.model_dump(exclude_unset=True) + # The slug is never taken verbatim from the client — normalize and dedupe it + # (excluding this row, so re-saving an unchanged slug isn't a self-collision). + requested_slug = updates.pop("slug", None) + # Note the asymmetry with the slug: a blank slug is a no-op (an article must + # always have one), but a blank url_path CLEARS it, moving the article back + # to /a/{slug} — so this branches on presence, not truthiness. + has_url_path = "url_path" in updates + requested_url_path = updates.pop("url_path", None) + for field, value in updates.items(): + setattr(faq, field, value) + if requested_slug: + faq.slug = resolve_faq_slug( + db, current_user.organization_id, requested_slug, exclude_id=faq.id + ) + if has_url_path: + faq.url_path = _resolve_url_path( + db, current_user.organization_id, requested_url_path, exclude_id=faq.id + ) + # Backfill a slug for legacy/generated FAQs the first time they're edited; + # otherwise keep it stable so published article URLs never change on their own. + if not faq.slug: + faq.slug = generate_faq_slug(db, current_user.organization_id, faq.question) + return FAQResponse.model_validate(FAQRepository(db).update(faq)) + + +@router.delete("/faqs/{faq_id}", status_code=204) +async def delete_faq( + faq_id: UUID, + current_user: User = Depends(require_permissions("manage_knowledge")), + db: Session = Depends(get_db), +): + check_help_center_access(db, current_user.organization_id) + faq = _get_owned_faq(faq_id, current_user, db) + FAQRepository(db).delete(faq) + + +@router.post("/faqs/bulk-status") +async def bulk_set_status( + payload: FAQBulkStatusRequest, + current_user: User = Depends(require_permissions("manage_knowledge")), + db: Session = Depends(get_db), +): + """Publish/unpublish one or many FAQs in a single call.""" + check_help_center_access(db, current_user.organization_id) + updated = FAQRepository(db).bulk_set_status( + current_user.organization_id, payload.faq_ids, payload.status + ) + return {"updated": updated} + + +@router.post("/faqs/bulk-delete") +async def bulk_delete( + payload: FAQBulkDeleteRequest, + current_user: User = Depends(require_permissions("manage_knowledge")), + db: Session = Depends(get_db), +): + """Delete many FAQs in one call (org-scoped; foreign ids are ignored).""" + check_help_center_access(db, current_user.organization_id) + deleted = FAQRepository(db).bulk_delete(current_user.organization_id, payload.faq_ids) + return {"deleted": deleted} diff --git a/backend/app/api/help_center/generation.py b/backend/app/api/help_center/generation.py new file mode 100644 index 0000000..5b30c47 --- /dev/null +++ b/backend/app/api/help_center/generation.py @@ -0,0 +1,281 @@ +""" +Copyright 2024-2026 ChatterMate + +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 typing import Optional +from uuid import uuid4 + +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.auth import require_permissions +from app.core.config import settings +from app.core.file_validation import PDF_MAGIC, read_validated, safe_filename +from app.database import get_db +from app.knowledge.url_safety import resolves_to_blocked_host +from app.models.faq_generation_job import FAQGenerationJob, FAQJobType +from app.models.knowledge import Knowledge +from app.models.schemas.faq import ( + GenerateRequest, + GenerationEstimateResponse, + GenerationJobResponse, + GenerationSourceResponse, + ImportRequest, +) +from app.models.user import User +from app.repositories.ai_config import AIConfigRepository +from app.repositories.faq_generation_job import FAQGenerationJobRepository +from app.services.faq_usage import ( + ensure_generation_budget, + estimate_generation_calls, + generation_is_metered, + remaining_message_credits, +) +from app.services.help_center_access import check_help_center_access +from app.core.params import DbIntId + +router = APIRouter() + +# Budget floor for imports whose call count isn't known up front — blocks only +# a fully exhausted plan; the per-call metering happens in the worker. +MIN_IMPORT_CALL_ESTIMATE = 1 + +MAX_IMPORT_PDF_BYTES = 25 * 1024 * 1024 +# Stored under its own folder so import buffers never mix with article images. +_PDF_IMPORT_FOLDER = "help_center_imports" + + +def _require_ai_config(db: Session, organization_id) -> None: + if not AIConfigRepository(db).get_active_config(organization_id): + raise HTTPException( + status_code=400, + detail="No active AI configuration. Set up an AI model in AI Configuration first.", + ) + + +def _enqueue( + db: Session, + current_user: User, + job_type: FAQJobType, + estimated_calls: int = MIN_IMPORT_CALL_ESTIMATE, + requires_llm: bool = True, + **fields, +) -> FAQGenerationJob: + """Shared enqueue guard: one active job per type per org (409 on dupes), + fail fast when no AI model is configured, 402 when a metered run exceeds + the remaining message credits. Stamps the job's `metered` flag from the + org's current AI config. requires_llm=False (article import) skips the + AI-config and credit checks entirely.""" + org_id = current_user.organization_id + check_help_center_access(db, org_id) + if requires_llm: + _require_ai_config(db, org_id) + ensure_generation_budget(db, org_id, estimated_calls) + job_repo = FAQGenerationJobRepository(db) + # Clear jobs a dead worker left in 'processing' so they don't block this + # enqueue (the one-active-per-type guard + partial unique index). + job_repo.fail_orphaned_processing( + "Worker stopped before the job finished — please try again.", + older_than_seconds=settings.FAQ_JOB_STALE_SECONDS, + organization_id=org_id, + ) + if job_repo.get_active_for_org(org_id, job_type): + raise HTTPException(status_code=409, detail="A job of this type is already running.") + try: + return job_repo.create( + FAQGenerationJob( + organization_id=org_id, + user_id=current_user.id, + job_type=job_type.value, + metered=requires_llm and generation_is_metered(db, org_id), + **fields, + ) + ) + except IntegrityError: + # Concurrent enqueue lost the race against the partial unique index. + db.rollback() + raise HTTPException(status_code=409, detail="A job of this type is already running.") + + +def _validate_org_knowledge_ids(db: Session, organization_id, knowledge_ids) -> None: + if not knowledge_ids: + return + owned = { + row[0] + for row in db.query(Knowledge.id) + .filter(Knowledge.organization_id == organization_id, Knowledge.id.in_(knowledge_ids)) + .all() + } + if set(knowledge_ids) - owned: + raise HTTPException(status_code=400, detail="Unknown knowledge source in selection.") + + +@router.get("/generate/estimate", response_model=GenerationEstimateResponse) +async def generation_estimate( + include_pages: bool = Query( + default=True, + description="False skips the per-source page scan — cheap counts for button labels", + ), + current_user: User = Depends(require_permissions("manage_knowledge")), + db: Session = Depends(get_db), +): + """Pre-generation numbers for the confirm dialog: new (ungenerated) + sources, page count, estimated LLM calls and remaining credits.""" + org_id = current_user.organization_id + check_help_center_access(db, org_id) + estimate = estimate_generation_calls(db, org_id, count_pages=include_pages) + metered = generation_is_metered(db, org_id) + return GenerationEstimateResponse( + total_sources=estimate.total_sources, + new_sources=estimate.new_sources, + pages=estimate.pages, + estimated_calls=estimate.estimated_calls, + metered=metered, + remaining_credits=remaining_message_credits(db, org_id) if metered else None, + sources=[ + GenerationSourceResponse( + id=s.id, name=s.name, source_type=s.source_type, + has_faqs=s.has_faqs, pages=s.pages, estimated_calls=s.estimated_calls, + ) + for s in estimate.sources + ], + ) + + +@router.post("/generate", response_model=GenerationJobResponse, status_code=202) +async def start_generation( + payload: Optional[GenerateRequest] = None, + current_user: User = Depends(require_permissions("manage_knowledge")), + db: Session = Depends(get_db), +): + """Draft FAQs from the knowledge base (additive; the worker dedups against + existing questions). By default only sources without FAQs are read; + knowledge_ids explicitly targets sources instead.""" + org_id = current_user.organization_id + # Plan gate and AI-config checks first: a locked plan must see 403 and a + # missing model its setup hint, not source-count errors. + check_help_center_access(db, org_id) + _require_ai_config(db, org_id) + knowledge_ids = payload.knowledge_ids if payload else None + _validate_org_knowledge_ids(db, org_id, knowledge_ids) + estimate = estimate_generation_calls(db, org_id, knowledge_ids) + if estimate.total_sources == 0: + raise HTTPException(status_code=400, detail="No knowledge sources to generate from. Add knowledge first.") + if estimate.new_sources == 0: + raise HTTPException( + status_code=409, + detail="All knowledge sources already have FAQs. Delete a source's FAQs to regenerate it.", + ) + job = _enqueue( + db, + current_user, + FAQJobType.GENERATE_ALL, + estimated_calls=estimate.estimated_calls, + knowledge_ids=knowledge_ids, + ) + return GenerationJobResponse.model_validate(job) + + +@router.post("/import", response_model=GenerationJobResponse, status_code=202) +async def start_import( + payload: ImportRequest, + current_user: User = Depends(require_permissions("manage_knowledge")), + db: Session = Depends(get_db), +): + """Migrate an existing help center. mode=qa extracts Q&A pairs from the + page with the org's model (uses credits); mode=articles crawls the page's + linked article pages and imports each one as-is (Markdown, no LLM).""" + # Early SSRF rejection for immediate feedback; the worker re-validates at + # fetch time (including every redirect hop). + if resolves_to_blocked_host(payload.url): + raise HTTPException(status_code=400, detail="That URL points to a blocked or internal host.") + if payload.mode == "articles": + # No LLM → no AI-config requirement, no credit checks. + job = _enqueue( + db, current_user, FAQJobType.IMPORT_ARTICLES, + requires_llm=False, source_url=payload.url, + preserve_source_urls=payload.preserve_urls, + ) + else: + job = _enqueue(db, current_user, FAQJobType.IMPORT_URL, source_url=payload.url) + return GenerationJobResponse.model_validate(job) + + +@router.post("/import/pdf", response_model=GenerationJobResponse, status_code=202) +async def start_pdf_import( + file: UploadFile = File(...), + current_user: User = Depends(require_permissions("manage_knowledge")), + db: Session = Depends(get_db), +): + """Extract Q&A pairs from an uploaded PDF with the org's model. The file + is persisted via shared storage (the worker runs in another container) + and deleted after processing.""" + from app.services.file_storage import store_upload + + content = await read_validated( + file, + max_size=MAX_IMPORT_PDF_BYTES, + allowed_content_types=("application/pdf",), + magic_prefix=PDF_MAGIC, + label="PDF", + ) + stored = await store_upload( + content=content, + folder=_PDF_IMPORT_FOLDER, + file_name=f"{uuid4().hex}.pdf", + content_type="application/pdf", + ) + # safe_filename strips traversal; keep only the readable tail for the label. + original = safe_filename(file.filename or "document.pdf").split("_", 1)[-1][:255] + try: + job = _enqueue( + db, current_user, FAQJobType.IMPORT_PDF, + source_url=stored, source_file_name=original, + ) + except HTTPException: + # Enqueue refused (plan/config/duplicate/budget) — don't leak the buffer. + from app.services.file_storage import delete_upload + await delete_upload(stored) + raise + return GenerationJobResponse.model_validate(job) + + +@router.get("/jobs", response_model=Optional[GenerationJobResponse]) +async def get_job( + active: bool = Query(default=True, description="Active job only; false = most recent job"), + current_user: User = Depends(require_permissions("manage_knowledge")), + db: Session = Depends(get_db), +): + """The UI's polling endpoint: the running job if any (active=true), or the + most recent job of any status (active=false) for showing the last outcome.""" + job_repo = FAQGenerationJobRepository(db) + if active: + job = job_repo.get_active_for_org(current_user.organization_id) + else: + job = job_repo.get_latest_for_org(current_user.organization_id) + return GenerationJobResponse.model_validate(job) if job else None + + +@router.get("/jobs/{job_id}", response_model=GenerationJobResponse) +async def get_job_by_id( + job_id: DbIntId, + current_user: User = Depends(require_permissions("manage_knowledge")), + db: Session = Depends(get_db), +): + job = FAQGenerationJobRepository(db).get_by_id_for_org(job_id, current_user.organization_id) + if not job: + raise HTTPException(status_code=404, detail="Job not found") + return GenerationJobResponse.model_validate(job) diff --git a/backend/app/api/help_center_images.py b/backend/app/api/help_center_images.py new file mode 100644 index 0000000..e8032f7 --- /dev/null +++ b/backend/app/api/help_center_images.py @@ -0,0 +1,86 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +Public delivery of help-center article images. + +Article Markdown bakes an image URL in permanently, so it cannot hold a signed +S3 URL (those expire) and it cannot hold a raw S3 URL either (the bucket is +private). Instead store_article_image bakes this stable app path, and this +route resolves it per request — redirecting to a freshly signed URL on S3, or +serving the file directly on local storage. + +Registered on BOTH the main app (path mode) and public_app (subdomain/custom +domain host mode), because article pages are served by whichever one owns the +origin and the baked path is host-relative. +""" + +from fastapi import APIRouter, HTTPException +from fastapi.responses import RedirectResponse + +from app.core.config import settings +from app.core.s3 import sign_s3_key +from app.services.help_center_images import ARTICLE_IMAGE_NAME_RE, UPLOAD_FOLDER + +router = APIRouter() + +# The object is immutable — a fresh uuid per upload — so let clients and CDNs +# keep it for a while. +_MAX_CACHE_SECONDS = 300 + +# Allow-list of redirect targets. S3 presigns come back either virtual-hosted +# (bucket.s3..amazonaws.com) or path-style against the regional or +# legacy global endpoint, depending on bucket name and region; local storage +# goes to our own static mount. Nothing else is ever a valid destination. +_ALLOWED_REDIRECT_PREFIXES = ( + f"https://{settings.S3_BUCKET}.s3.", + "https://s3.", + f"{settings.API_V1_STR}/uploads/", +) + +_NOT_FOUND = HTTPException(status_code=404, detail="Not found") + + +def _cache_control(max_age: int) -> str: + return f"public, max-age={max_age}" + + +@router.get("/images/{file_name}", include_in_schema=False) +async def get_article_image(file_name: str): + """Resolve a baked article-image path to the actual image. + + Both branches redirect rather than serve bytes: S3 to a freshly signed URL, + local storage to the /api/v1/uploads mount that already serves that + directory on both the main app and public_app. + """ + if not ARTICLE_IMAGE_NAME_RE.match(file_name): + raise _NOT_FOUND + + if settings.S3_FILE_STORAGE: + target = sign_s3_key(f"{UPLOAD_FOLDER}/{file_name}") + # Never cache the redirect longer than the signature it points at, or a + # cached target outlives its own presign and starts 403ing. + max_age = min(_MAX_CACHE_SECONDS, settings.S3_PRESIGN_EXPIRY_SECONDS // 2) + else: + target = f"{settings.API_V1_STR}/uploads/{UPLOAD_FOLDER}/{file_name}" + max_age = _MAX_CACHE_SECONDS + + if not target.startswith(_ALLOWED_REDIRECT_PREFIXES): + # sign_s3_url hands back its input unchanged when it cannot sign; never + # bounce a visitor anywhere outside the allow-list. + raise _NOT_FOUND + + return RedirectResponse( + target, status_code=307, headers={"Cache-Control": _cache_control(max_age)} + ) diff --git a/backend/app/api/help_center_public.py b/backend/app/api/help_center_public.py new file mode 100644 index 0000000..1b056ac --- /dev/null +++ b/backend/app/api/help_center_public.py @@ -0,0 +1,422 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +The public help-center site: a standalone FastAPI app the host-dispatch layer +routes {slug}. and verified custom domains to. Server-rendered +Jinja2 HTML for SEO (landing list + per-article pages); the only JSON endpoint +is the rate-limited "Ask AI". +""" + +from html import escape +from urllib.parse import quote + +from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.utils.client_ip import forwarded_client_ip +from app.api.help_center_images import router as help_center_images_router +from app.core.config import settings +from app.core.error_handlers import ClientSafeHTTPException, install_error_handlers +from app.database import get_db +from app.models.faq import FAQ +from app.models.help_center import HelpCenterSettings +from app.repositories.faq import FAQRepository +from app.services.file_storage import resolve_public_url +from app.services.help_center_content import ( + excerpt, + read_time_label, + render_article_html, + to_plain_text, +) +from app.services.help_center_public import ( + MAX_QUESTION_CHARS, + answer_question, + ask_available, + category_colors, + contrast_ink, + get_published_article, + get_published_article_by_path, + normalize_host, + published_faq_groups, + related_articles, + resolve_help_center, + widget_id_for, +) +from app.services.help_center_seo import ( + absolute_asset_url, + article_description, + article_href, + article_json_ld, + article_path, + article_title, + article_url, + breadcrumbs, + index_description, + index_json_ld, + index_title, + site_url, +) +from app.services.public_rate_limit import allow_request + +public_app = FastAPI(title="ChatterMate Help Center", docs_url=None, redoc_url=None, openapi_url=None) +install_error_handlers(public_app) +# Serve ONLY help-center images on this app, so relative logo/article-image paths +# resolve on a subdomain/custom-domain origin (host dispatch). Scoped to the +# help_center/ subtree — the help center must never expose chat_attachments, +# knowledge, avatars, etc. (in path mode these paths are served by the main app +# instead). check_dir=False: the dir may not exist until the first upload; a +# missing file 404s. Local storage only — S3 URLs never hit this mount. +public_app.mount( + "/api/v1/uploads/help_center", + StaticFiles(directory="uploads/help_center", check_dir=False), + name="help_center_uploads", +) +# Article images uploaded from this release on are baked as +# {API_V1_STR}/help-center/images/ instead, which resolves on either +# storage backend. The mount above stays for URLs baked before that. +public_app.include_router( + help_center_images_router, + prefix=f"{settings.API_V1_STR}/help-center", +) +templates = Jinja2Templates(directory="app/templates") + +PAGE_CACHE_CONTROL = "public, max-age=60" +ASK_LIMIT_PER_MINUTE = 10 +ASK_LIMIT_PER_DAY = 100 + +# Paths robots.txt keeps crawlers off. The articles themselves stay fully +# crawlable ("Allow: /"); these are the non-content paths only: +# /feedback, /search Not navigable pages. The catch-all resolves them as +# article slugs and 404s, which is how they reached +# Search Console as "Not found (404)". +# /*?q=, /*?topic= The landing page re-renders itself filtered for both: +# one URL per category, and one per search phrase — an +# unbounded set of thin near-duplicates, none of them in +# sitemap.xml. ("/search" is not a route; it is listed so +# a future one cannot leak before anyone thinks of this.) +CRAWLER_DISALLOWED_PATHS = ("/feedback", "/search", "/*?q=", "/*?topic=") + + +class AskRequest(BaseModel): + question: str = Field(min_length=3, max_length=MAX_QUESTION_CHARS) + + +class FeedbackRequest(BaseModel): + helpful: bool + + +def _resolve_or_404(request: Request, db: Session): + hc = request.scope.get("help_center") + if hc: # path dispatch (/help/{slug}) — org from the path slug, not Host + row = resolve_help_center(db, slug=hc["slug"]) + else: + row = resolve_help_center(db, host=normalize_host(request.headers.get("host"))) + if not row: + raise HTTPException(status_code=404, detail="Help center not found") + return row + + +def _base_path(request: Request) -> str: + """URL prefix for internal links: "/help/{slug}" under path dispatch, "" otherwise.""" + return request.scope.get("help_center", {}).get("base_path", "") + + +async def _chrome_context(row: HelpCenterSettings, base_path: str = "") -> dict: + """Shared header/footer/widget context used by every rendered page. + + `base_path` is the URL prefix templates prepend to internal links: "" for + host dispatch (site at origin root) and "/help/{slug}" for path dispatch.""" + return { + "row": row, + "base_path": base_path, + "brand_color": row.brand_color, + "brand_ink": contrast_ink(row.brand_color), + # Relative (/api/v1/uploads/...) so the logo resolves against whichever + # origin serves the page — public_app now mounts uploads for host mode, + # and path mode is same-origin as the API. S3 stays absolute (signed). + "logo_url": await resolve_public_url(row.logo_url) if row.logo_url else None, + "favicon_url": await resolve_public_url(row.favicon_url) if row.favicon_url else None, + "header_links": row.header_links or [], + "widget_id": widget_id_for(row), + # The widget LOADER (chattermate.min.js) is served by the frontend, while + # the widget APP it pulls in (/assets/widget.js) is served from + # VITE_WIDGET_URL by the backend. In prod both resolve to the app domain. + "widget_script_url": f"{settings.FRONTEND_URL}/webclient/chattermate.min.js", + # This install's API root, handed to the loader as window.chattermateBaseUrl + # (same contract as the dashboard's embed snippet). Without it the loader + # uses its build-time default — the vendor cloud — which no self-hosted + # deployment can talk to. + "widget_api_url": f"{settings.BACKEND_URL.rstrip('/')}{settings.API_V1_STR}", + } + + +def _social_context(row: HelpCenterSettings, site: str) -> dict: + """Open Graph / Twitter card values shared by both page types. + + og:image must be absolute (scrapers don't resolve relative URLs), so a + relative logo is anchored to the serving origin. Deliberately NOT signed + like the rendered logo: social scrapers cache the URL and re-fetch it long + after a signature would expire, so this uses the stored URL — the same + unsigned form article images already bake in, which S3 setups serve via a + public-read help_center/ prefix. + """ + logo_url = absolute_asset_url(row.logo_url, site) + return { + "og_site_name": index_title(row), + "og_image": logo_url, + "seo_logo_url": logo_url, + } + + +def _card_view(faq: FAQ) -> dict: + """List-card view model: question links to the article, with a plain-text + preview and read time computed from the Markdown answer. Search fields are + split so the client can rank title hits above body hits, and the body is + plain text (raw Markdown would make URLs/syntax searchable noise). + + `href` is base_path-less; the template adds the serving prefix.""" + return { + "question": faq.question, + "href": article_href(faq), + "preview": excerpt(faq.answer), + "read_time": read_time_label(faq.answer), + "search_title": faq.question.lower(), + "search_text": f"{to_plain_text(faq.answer)} {faq.category}".lower(), + } + + +@public_app.get("/", response_class=HTMLResponse) +async def index(request: Request, q: str = "", db: Session = Depends(get_db)): + row = _resolve_or_404(request, db) + search = q.strip()[:200] or None + groups = published_faq_groups(db, row, search=search) + colors = category_colors([category for category, _faqs in groups]) + card_groups = [(category, [_card_view(faq) for faq in faqs]) for category, faqs in groups] + site = site_url(row) + social = _social_context(row, site) + published = [faq for _category, faqs in groups for faq in faqs] + context = { + **await _chrome_context(row, _base_path(request)), + **social, + "request": request, + "groups": card_groups, + "colors": colors, + "search": search or "", + "title": index_title(row), + "description": index_description(row), + "canonical_url": f"{site}/", + "og_type": "website", + "json_ld": index_json_ld(row, published, site, social["seo_logo_url"]), + "ask_enabled": ask_available(row), + } + response = templates.TemplateResponse(request, "help_center/index.html", context) + response.headers["Cache-Control"] = PAGE_CACHE_CONTROL + return response + + +@public_app.get("/a/{slug}", response_class=HTMLResponse) +async def article(slug: str, request: Request, db: Session = Depends(get_db)): + row = _resolve_or_404(request, db) + faq = get_published_article(db, row, slug) + if not faq: + raise HTTPException(status_code=404, detail="Article not found") + if faq.url_path: + # The article kept its original URL from a help-center migration, so + # that is the canonical one and this is the alias. Cache-Control bounds + # how long a browser pins the redirect: without it a 301 is cached + # effectively forever, stranding return visitors if the path is cleared. + return RedirectResponse( + f"{_base_path(request)}{faq.url_path}", + status_code=301, + headers={"Cache-Control": PAGE_CACHE_CONTROL}, + ) + return await _render_article(faq, row, request, db) + + +async def _render_article( + faq: FAQ, row: HelpCenterSettings, request: Request, db: Session +) -> Response: + """Render one article page. Shared by /a/{slug} and the preserved-path + route below, which reach the same FAQ by different URLs.""" + # All published categories (unfiltered) drive the sidebar + stable colors. + all_groups = published_faq_groups(db, row) + colors = category_colors([category for category, _faqs in all_groups]) + default_color = colors.get(faq.category, "#6d5bd0") + topics = [ + { + "category": category, + "count": len(faqs), + "color": colors.get(category, default_color), + "active": category == faq.category, + } + for category, faqs in all_groups + ] + related = [ + { + "question": rel.question, + "href": article_href(rel), + "read_time": read_time_label(rel.answer), + "color": colors.get(rel.category, default_color), + } + for rel in related_articles(db, row, faq) + ] + base_path = _base_path(request) + # Cross-article links are stored as /a/{slug}; point the ones whose target + # kept an original URL straight at it, so a reader never takes the 301 hop. + # Built from all_groups, which is already loaded above — no extra query. + link_paths = { + other.slug: quote(article_path(other), safe="/") + for _category, faqs in all_groups + for other in faqs + if other.slug and other.url_path + } + article_view = { + "question": faq.question, + "category": faq.category, + "color": default_color, + "read_time": read_time_label(faq.answer), + "body_html": render_article_html(faq.answer, base_path, link_paths), + # Feedback always posts to the slug route, whatever URL the page itself + # is served at — location.pathname would miss on a preserved path. None + # for a slug-less legacy row reached by its preserved path: there's no + # route to post to, so the widget is dropped rather than rendered broken. + "feedback_url": f"{base_path}/a/{quote(faq.slug)}/feedback" if faq.slug else None, + "related": related, + } + site = site_url(row) + social = _social_context(row, site) + crumbs = breadcrumbs(row, faq, base_path, site) + context = { + **await _chrome_context(row, base_path), + **social, + "request": request, + "article": article_view, + "breadcrumbs": crumbs, + "topics": topics, + "title": article_title(row, faq), + "description": article_description(faq), + "canonical_url": article_url(site, faq), + "og_type": "article", + "json_ld": article_json_ld( + row, faq, site, crumbs, social["seo_logo_url"], social["og_image"] + ), + } + response = templates.TemplateResponse(request, "help_center/article.html", context) + response.headers["Cache-Control"] = PAGE_CACHE_CONTROL + return response + + +@public_app.post("/ask") +async def ask(payload: AskRequest, request: Request, db: Session = Depends(get_db)): + row = _resolve_or_404(request, db) + if not ask_available(row): + raise HTTPException(status_code=404, detail="AI answers are not enabled") + ip = forwarded_client_ip(request) + if not allow_request(f"ask:{ip}:1m", ASK_LIMIT_PER_MINUTE, 60) or not allow_request( + f"ask:{ip}:1d", ASK_LIMIT_PER_DAY, 86400 + ): + raise HTTPException(status_code=429, detail="Too many questions — please try again later.") + # answer_question manages its own short sessions around the slow LLM call. + answer = await answer_question(row.organization_id, row.agent_id, payload.question) + if not answer: + raise ClientSafeHTTPException(status_code=503, detail="Could not answer right now — please try again.") + return {"answer": answer} + + +@public_app.post("/a/{slug}/feedback") +async def article_feedback( + slug: str, payload: FeedbackRequest, request: Request, db: Session = Depends(get_db) +): + """Record a 'Was this helpful?' vote. One vote per article per IP per day + (deduped via the rate limiter; fail-open, idempotent — a repeat vote is a + silent no-op rather than an error).""" + row = _resolve_or_404(request, db) + faq = get_published_article(db, row, slug) + if not faq: + raise HTTPException(status_code=404, detail="Article not found") + ip = forwarded_client_ip(request) + if allow_request(f"faqfb:{faq.id}:{ip}", 1, 86400): + FAQRepository(db).record_feedback(faq.id, payload.helpful) + return {"ok": True} + + +@public_app.get("/sitemap.xml") +async def sitemap(request: Request, db: Session = Depends(get_db)): + row = _resolve_or_404(request, db) + base = site_url(row) + groups = published_faq_groups(db, row) + urls = [f" {base}/"] + for _category, faqs in groups: + for faq in faqs: + if faq.slug or faq.url_path: + urls.append(f" {escape(article_url(base, faq))}") + xml = ( + '\n' + '\n' + + "\n".join(urls) + + "\n\n" + ) + return Response(content=xml, media_type="application/xml", headers={"Cache-Control": PAGE_CACHE_CONTROL}) + + +@public_app.get("/robots.txt", response_class=PlainTextResponse) +async def robots(request: Request, db: Session = Depends(get_db)): + row = _resolve_or_404(request, db) + base_path = _base_path(request) + lines = ["User-agent: *", "Allow: /"] + lines += [f"Disallow: {base_path}{path}" for path in CRAWLER_DISALLOWED_PATHS] + lines.append(f"Sitemap: {site_url(row)}/sitemap.xml") + return PlainTextResponse("\n".join(lines) + "\n") + + +@public_app.get("/healthz") +async def healthz(): + """Liveness for the SSL-provisioning probe; host-level only, no org data.""" + return {"status": "ok"} + + +# KEEP THIS LAST. Starlette serves the first route that fully matches, so every +# route above — and the uploads mount — still wins; moving this up would shadow +# all of them. It only sees URLs nothing else claimed: articles that kept their +# original path through a help-center migration, plus the trailing-slash forms +# Starlette's redirect_slashes used to handle (that fallback only fires when +# NOTHING matched, and a catch-all always matches). +@public_app.get("/{full_path:path}", response_class=HTMLResponse) +async def preserved_article(full_path: str, request: Request, db: Session = Depends(get_db)): + # Already percent-decoded by the ASGI server, which is why url_path is + # stored decoded too. + raw = f"/{full_path}" + path = raw.rstrip("/") or "/" + row = _resolve_or_404(request, db) + base_path = _base_path(request) + + faq = get_published_article_by_path(db, row, path) if path != "/" else None + if faq is None and path.startswith("/a/"): + # "/a/{slug}/" — keep the trailing-slash redirect we'd otherwise lose. + faq = get_published_article(db, row, path[len("/a/"):]) + if faq: + return RedirectResponse(f"{base_path}{article_path(faq)}", status_code=301) + if faq is None: + raise HTTPException(status_code=404, detail="Article not found") + if raw != path: # trailing slash on a preserved path — canonicalise it + # article_path(faq), not `path`: identical here (the row was found BY + # path), but the Location is then built purely from stored, validated + # data instead of from the request — nothing user-supplied reaches it. + return RedirectResponse(f"{base_path}{article_path(faq)}", status_code=301) + return await _render_article(faq, row, request, db) diff --git a/backend/app/api/jira.py b/backend/app/api/jira.py new file mode 100644 index 0000000..a9dab0b --- /dev/null +++ b/backend/app/api/jira.py @@ -0,0 +1,558 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import traceback +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import RedirectResponse +from sqlalchemy.orm import Session +from sqlalchemy import String +import secrets +import uuid + +from app.database import get_db +from app.services.jira import JiraService +from app.models.jira import JiraToken, AgentJiraConfig +from app.models.agent import Agent +from app.core.auth import get_current_organization, require_permissions +from app.models.organization import Organization +from app.models.user import User +from app.core.logger import get_logger +from app.core.config import settings +from app.core.oauth_state import pop_state, store_state +from pydantic import BaseModel +from typing import Optional, List +from app.core.exceptions import JiraAuthError + +router = APIRouter() +jira_service = JiraService() +logger = get_logger(__name__) + + +def _require_jira_agent_in_org(db, agent_id, org_id) -> None: + """The agent must belong to the caller's org, else 404. + + A malformed agent_id is treated as not-found rather than a 500. + """ + from app.repositories.agent import AgentRepository + try: + agent_uuid = uuid.UUID(str(agent_id)) + except (ValueError, TypeError): + raise HTTPException(status_code=404, detail="Agent not found") + if not AgentRepository(db).get_agent_in_org(agent_uuid, org_id): + raise HTTPException(status_code=404, detail="Agent not found") + +class JiraProject(BaseModel): + id: str + key: str + name: str + +class JiraIssueType(BaseModel): + id: str + name: str + description: Optional[str] = None + +class JiraPriority(BaseModel): + id: str + name: str + description: Optional[str] = None + iconUrl: Optional[str] = None + +class AgentJiraConfigModel(BaseModel): + enabled: bool + projectKey: Optional[str] = None + issueTypeId: Optional[str] = None + +class CreateJiraIssueModel(BaseModel): + projectKey: str + issueTypeId: str + summary: str + description: str + priority: Optional[str] = None + chatId: Optional[str] = None + +@router.get("/status") +async def jira_status( + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db) +): + """ + Check if Jira is connected for the current organization. + """ + token = db.query(JiraToken).filter( + JiraToken.organization_id == organization.id + ).first() + + if not token: + return {"connected": False} + + # Check if token is valid + is_valid = jira_service.validate_token(token) + + # If token is expired, try to refresh it + if not is_valid: + try: + token_data = await jira_service.refresh_token(token.refresh_token) + + # Update token in database + for key, value in token_data.items(): + setattr(token, key, value) + + db.commit() + is_valid = True + except Exception as e: + logger.error(f"Failed to refresh Jira token: {e}") + is_valid = False + + return { + "connected": is_valid, + "site_url": token.site_url if is_valid else None + } + +@router.delete("/disconnect") +async def disconnect_jira( + current_user: User = Depends(require_permissions("manage_organization")), + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db) +): + """ + Disconnect Jira integration for the current organization. + """ + token = db.query(JiraToken).filter( + JiraToken.organization_id == organization.id + ).first() + + if not token: + raise HTTPException(status_code=404, detail="No Jira connection found") + + # Delete all agent Jira configurations for this organization + try: + agent_configs = db.query(AgentJiraConfig).join( + Agent, AgentJiraConfig.agent_id == Agent.id.cast(String) + ).filter( + Agent.organization_id == organization.id + ).all() + + config_count = len(agent_configs) + for config in agent_configs: + db.delete(config) + + # Delete the Jira token + db.delete(token) + db.commit() + + logger.info(f"Jira disconnected for organization {organization.id} by user {current_user.id}. Deleted {config_count} agent configurations.") + except Exception as e: + db.rollback() + logger.error(f"Error disconnecting Jira: {e}") + raise HTTPException(status_code=500, detail=f"Error disconnecting Jira: {str(e)}") + + return {"message": "Jira disconnected successfully"} + +@router.get("/authorize") +async def authorize_jira( + request: Request, + current_user: User = Depends(require_permissions("manage_organization")), + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db) +): + """ + Start the Jira OAuth flow by redirecting to Jira's authorization page. + """ + logger.info(f"Authorizing Jira for organization: {organization.id}") + + # Generate a random state for CSRF protection + state = secrets.token_urlsafe(32) + + store_state("jira", state, str(organization.id)) + + # Get authorization URL + auth_url = jira_service.get_authorization_url(state) + logger.info(f"Redirecting to Jira authorization URL: {auth_url}") + return RedirectResponse(url=auth_url) + +@router.get("/callback") +async def jira_oauth_callback( + code: Optional[str] = None, + state: Optional[str] = None, + error: Optional[str] = None, + error_description: Optional[str] = None, + request: Request = None, + db: Session = Depends(get_db) +): + """ + Handle the OAuth callback from Jira. + This endpoint handles both successful authentication and cancellation/errors. + """ + # Handle cancellation or error from Jira + if error or not code or not state: + logger.warning(f"Jira OAuth flow cancelled or error: {error} - {error_description}") + pop_state("jira", state or "") + # Redirect to failure page + redirect_url = f"{settings.FRONTEND_URL}/settings/integrations?status=failure&reason={error or 'cancelled'}" + return RedirectResponse(url=redirect_url) + + org_id = pop_state("jira", state) + + if not org_id: + logger.error(f"Invalid or expired state parameter: {state}") + redirect_url = f"{settings.FRONTEND_URL}/settings/integrations?status=failure&reason=invalid_state" + return RedirectResponse(url=redirect_url) + + try: + org_id = uuid.UUID(org_id) + # Exchange code for token + token_data = await jira_service.exchange_code_for_token(code) + + # Get Jira Cloud ID and site URL + cloud_data = await jira_service.get_cloud_id(token_data["access_token"]) + logger.info(f"Cloud data: {cloud_data}") + # Store token in database + jira_token = JiraToken( + organization_id=org_id, + access_token=token_data["access_token"], + refresh_token=token_data["refresh_token"], + token_type=token_data["token_type"], + expires_at=token_data["expires_at"], + cloud_id=cloud_data["cloud_id"], + site_url=cloud_data["site_url"] + ) + + # Check if token already exists for this organization + existing_token = db.query(JiraToken).filter( + JiraToken.organization_id == org_id + ).first() + + if existing_token: + # Update existing token + for key, value in token_data.items(): + setattr(existing_token, key, value) + existing_token.cloud_id = cloud_data["cloud_id"] + existing_token.site_url = cloud_data["site_url"] + else: + db.add(jira_token) + + db.commit() + + # Redirect to success page using FRONTEND_URL from settings + redirect_url = f"{settings.FRONTEND_URL}/settings/integrations?status=success" + return RedirectResponse(url=redirect_url) + + except Exception as e: + logger.error(f"Error during Jira OAuth callback: {e}") + traceback.print_exc() + # Redirect to failure page with error message + error_message = str(e).replace(" ", "_")[:100] # Limit length and replace spaces + redirect_url = f"{settings.FRONTEND_URL}/settings/integrations?status=failure&reason={error_message}" + return RedirectResponse(url=redirect_url) + +@router.get("/refresh") +async def refresh_jira_token( + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db) +): + """ + Refresh the Jira access token using the refresh token. + """ + token = db.query(JiraToken).filter( + JiraToken.organization_id == organization.id + ).first() + + if not token: + raise HTTPException(status_code=404, detail="No Jira token found") + + try: + token_data = await jira_service.refresh_token(token.refresh_token) + + # Update token in database + for key, value in token_data.items(): + setattr(token, key, value) + + db.commit() + return {"message": "Token refreshed successfully"} + + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + +@router.get("/projects", response_model=List[JiraProject]) +async def get_jira_projects( + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db) +): + """ + Get all Jira projects for the current organization. + """ + token = db.query(JiraToken).filter( + JiraToken.organization_id == organization.id + ).first() + + if not token: + raise HTTPException(status_code=404, detail="No Jira connection found") + + # Check if token is valid + is_valid = jira_service.validate_token(token) + + # If token is expired, try to refresh it + if not is_valid: + try: + token_data = await jira_service.refresh_token(token.refresh_token) + + # Update token in database + for key, value in token_data.items(): + setattr(token, key, value) + + db.commit() + except Exception as e: + logger.error(f"Failed to refresh Jira token: {e}") + raise HTTPException(status_code=401, detail="Jira token expired and could not be refreshed") + + try: + projects = await jira_service.get_projects(token.access_token, token.cloud_id) + return projects + except Exception as e: + logger.error(f"Failed to get Jira projects: {e}") + raise HTTPException(status_code=500, detail="Failed to get Jira projects") + +@router.get("/projects/{project_key}/issue-types", response_model=List[JiraIssueType]) +async def get_jira_issue_types( + project_key: str, + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db) +): + """ + Get all issue types for a Jira project. + """ + token = db.query(JiraToken).filter( + JiraToken.organization_id == organization.id + ).first() + + if not token: + raise HTTPException(status_code=404, detail="No Jira connection found") + + # Check if token is valid + is_valid = jira_service.validate_token(token) + + # If token is expired, try to refresh it + if not is_valid: + try: + token_data = await jira_service.refresh_token(token.refresh_token) + + # Update token in database + for key, value in token_data.items(): + setattr(token, key, value) + + db.commit() + except Exception as e: + logger.error(f"Failed to refresh Jira token: {e}") + raise HTTPException(status_code=401, detail="Jira token expired and could not be refreshed") + + try: + issue_types = await jira_service.get_issue_types(token.access_token, token.cloud_id, project_key) + return issue_types + except Exception as e: + logger.error(f"Failed to get Jira issue types: {e}") + raise HTTPException(status_code=500, detail="Failed to get Jira issue types") + +@router.get("/priorities", response_model=List[JiraPriority]) +async def get_jira_priorities( + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db) +): + """ + Get all Jira priorities. + """ + token = db.query(JiraToken).filter( + JiraToken.organization_id == organization.id + ).first() + + if not token: + raise HTTPException(status_code=404, detail="No Jira connection found") + + # Check if token is valid + is_valid = jira_service.validate_token(token) + + # If token is expired, try to refresh it + if not is_valid: + try: + token_data = await jira_service.refresh_token(token.refresh_token) + + # Update token in database + for key, value in token_data.items(): + setattr(token, key, value) + + db.commit() + except Exception as e: + logger.error(f"Failed to refresh Jira token: {e}") + raise HTTPException(status_code=401, detail="Jira token expired and could not be refreshed") + + try: + priorities = await jira_service.get_priorities(token.access_token, token.cloud_id) + return priorities + except Exception as e: + logger.error(f"Failed to get Jira priorities: {e}") + raise HTTPException(status_code=500, detail="Failed to get Jira priorities") + +@router.get("/projects/{project_key}/issue-types/{issue_type_id}/has-priority") +async def check_priority_availability( + project_key: str, + issue_type_id: str, + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db) +): + """ + Check if priority field is available for a specific project and issue type. + """ + token = db.query(JiraToken).filter( + JiraToken.organization_id == organization.id + ).first() + + if not token: + raise HTTPException(status_code=404, detail="No Jira connection found") + + # Check if token is valid + is_valid = jira_service.validate_token(token) + + # If token is expired, try to refresh it + if not is_valid: + try: + token_data = await jira_service.refresh_token(token.refresh_token) + + # Update token in database + for key, value in token_data.items(): + setattr(token, key, value) + + db.commit() + except Exception as e: + logger.error(f"Failed to refresh Jira token: {e}") + raise HTTPException(status_code=401, detail="Jira token expired and could not be refreshed") + + try: + has_priority = await jira_service.is_priority_available( + token.access_token, + token.cloud_id, + project_key, + issue_type_id + ) + return {"hasPriority": has_priority} + except Exception as e: + logger.error(f"Failed to check priority availability: {e}") + raise HTTPException(status_code=500, detail="Failed to check priority availability") + +@router.post("/issues") +async def create_jira_issue( + issue_data: CreateJiraIssueModel, + organization: Organization = Depends(get_current_organization), + current_user: User = Depends(require_permissions("manage_organization")), + db: Session = Depends(get_db) +): + """ + Create a new Jira issue. + """ + try: + # Create the issue using the wrapper method + result = await jira_service.create_issue( + organization=organization, + db=db, + issue_data=issue_data + ) + + # Return the issue key and URL + return { + "key": result.get("key"), + "id": result.get("id"), + "self": result.get("self") + } + except JiraAuthError as e: + logger.error(f"Jira authentication error: {e}") + raise HTTPException(status_code=401, detail=str(e)) + except Exception as e: + logger.error(f"Failed to create Jira issue: {e}") + raise HTTPException(status_code=500, detail="Failed to create Jira issue") + +@router.post("/agent-config/{agent_id}") +async def save_agent_jira_config( + agent_id: str, + config: AgentJiraConfigModel, + organization: Organization = Depends(get_current_organization), + current_user: User = Depends(require_permissions("manage_organization")), + db: Session = Depends(get_db) +): + """ + Save Jira configuration for an agent. + """ + # The agent must belong to the caller's org — otherwise any tenant could + # flip another tenant's project_key / issue_type and hijack escalation. + _require_jira_agent_in_org(db, agent_id, organization.id) + + # Check if Jira is connected + token = db.query(JiraToken).filter( + JiraToken.organization_id == organization.id + ).first() + + if not token and config.enabled: + raise HTTPException(status_code=400, detail="Cannot enable Jira integration: Jira is not connected") + + # Check if config already exists + existing_config = db.query(AgentJiraConfig).filter( + AgentJiraConfig.agent_id == agent_id + ).first() + + if existing_config: + # Update existing config + existing_config.enabled = config.enabled + existing_config.project_key = config.projectKey + existing_config.issue_type_id = config.issueTypeId + else: + # Create new config + new_config = AgentJiraConfig( + agent_id=agent_id, + enabled=config.enabled, + project_key=config.projectKey, + issue_type_id=config.issueTypeId + ) + db.add(new_config) + + db.commit() + + return {"message": "Agent Jira configuration saved successfully"} + +@router.get("/agent-config/{agent_id}") +async def get_agent_jira_config( + agent_id: str, + organization: Organization = Depends(get_current_organization), + db: Session = Depends(get_db) +): + """ + Get Jira configuration for an agent. + """ + # The agent must belong to the caller's org before its config is exposed. + _require_jira_agent_in_org(db, agent_id, organization.id) + + config = db.query(AgentJiraConfig).filter( + AgentJiraConfig.agent_id == agent_id + ).first() + + if not config: + return { + "enabled": False, + "projectKey": None, + "issueTypeId": None + } + + return { + "enabled": config.enabled, + "projectKey": config.project_key, + "issueTypeId": config.issue_type_id + } \ No newline at end of file diff --git a/backend/app/api/mcp_tool.py b/backend/app/api/mcp_tool.py new file mode 100644 index 0000000..680c8dc --- /dev/null +++ b/backend/app/api/mcp_tool.py @@ -0,0 +1,538 @@ +""" +Copyright 2024-2026 ChatterMate + +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 fastapi import APIRouter, Depends, HTTPException, status +from typing import List +from app.core.logger import get_logger +from app.database import get_db +from app.models.user import User +from app.core.auth import get_current_user, require_permissions +from app.repositories.mcp_tool import MCPToolRepository +from app.repositories.agent import AgentRepository + +# Enterprise feature check +try: + from app.enterprise.repositories.plan import PlanRepository + from app.enterprise.services.feature_access import require_accessible_subscription + HAS_ENTERPRISE = True +except ImportError: + HAS_ENTERPRISE = False +from app.models.schemas.mcp_tool import ( + MCPToolCreate, MCPToolUpdate, MCPToolResponse, + MCPToolToAgentCreate, MCPToolToAgentResponse, + AgentMCPToolsResponse, MCPToolTestResponse, MCPToolReferencesResponse, + MCPTransportTypeEnum, unmask_secret_values +) +from app.core.outbound_policy import require_public_target +from app.core.params import DbIntId +from app.tools.mcp_manager import MCP_URL_LABEL, TEST_CONNECT_BUDGET_SECONDS, MCPToolsManager +from app.core.config import settings +from sqlalchemy.orm import Session +from uuid import UUID + +def require_stdio_enabled() -> None: + """Refuse a STDIO connector where running admin-typed commands is off.""" + if not settings.MCP_STDIO_ENABLED: + raise HTTPException( + status_code=400, + detail="Local (STDIO) MCP servers are not available here. Connect a remote server over HTTP or SSE.", + ) + + +router = APIRouter() +logger = get_logger(__name__) + + +def check_mcp_feature_access(current_user: User, db: Session): + """Check if user has access to MCP tools feature""" + if not HAS_ENTERPRISE: + return # Allow access in non-enterprise mode + + # Accessible = active/trial/past-due-in-period OR cancelled-but-still-in- + # paid-period; raises 403 when the org has no accessible plan. + subscription = require_accessible_subscription(db, current_user.organization_id) + plan_repo = PlanRepository(db) + + # Check if MCP tools feature is available in the plan + if not plan_repo.check_feature_availability(str(subscription.plan_id), 'mcp_tools'): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="MCP Tools feature is not available in your current plan. Please upgrade to access this feature." + ) + + +@router.post("", response_model=MCPToolResponse) +async def create_mcp_tool( + mcp_tool_data: MCPToolCreate, + current_user: User = Depends(require_permissions("manage_agents")), + db: Session = Depends(get_db) +): + """Create a new MCP tool""" + try: + # Check MCP tools feature access + check_mcp_feature_access(current_user, db) + if mcp_tool_data.transport_type == MCPTransportTypeEnum.STDIO: + require_stdio_enabled() + else: + require_public_target(mcp_tool_data.url, MCP_URL_LABEL) + + mcp_tool_repo = MCPToolRepository(db) + + # Set organization_id from current user + create_data = mcp_tool_data.model_dump() + create_data['organization_id'] = current_user.organization_id + + # Check if MCP tool with same name already exists + existing = mcp_tool_repo.get_by_name( + mcp_tool_data.name, + current_user.organization_id + ) + if existing: + raise HTTPException( + status_code=400, + detail="MCP tool with this name already exists" + ) + + mcp_tool = mcp_tool_repo.create_mcp_tool(**create_data) + return mcp_tool + + except HTTPException as e: + raise e + except Exception as e: + logger.error(f"MCP tool creation error: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("", response_model=List[MCPToolResponse]) +async def get_organization_mcp_tools( + enabled_only: bool = True, + current_user: User = Depends(require_permissions("view_all", "manage_agents")), + db: Session = Depends(get_db) +): + """Get all MCP tools for the current user's organization""" + try: + # Check MCP tools feature access + check_mcp_feature_access(current_user, db) + + mcp_tool_repo = MCPToolRepository(db) + mcp_tools = mcp_tool_repo.get_org_mcp_tools( + current_user.organization_id, + enabled_only=enabled_only + ) + return mcp_tools + + except Exception as e: + logger.error(f"Error fetching MCP tools: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/{mcp_tool_id}", response_model=MCPToolResponse) +async def get_mcp_tool( + mcp_tool_id: DbIntId, + current_user: User = Depends(require_permissions("view_all", "manage_agents")), + db: Session = Depends(get_db) +): + """Get a specific MCP tool""" + try: + check_mcp_feature_access(current_user, db) + + mcp_tool_repo = MCPToolRepository(db) + mcp_tool = mcp_tool_repo.get_mcp_tool(mcp_tool_id) + + if not mcp_tool: + raise HTTPException( + status_code=404, + detail="MCP tool not found" + ) + + # Check if user has access to this MCP tool's organization + if mcp_tool.organization_id != current_user.organization_id: + raise HTTPException( + status_code=403, + detail="Not authorized to access this MCP tool" + ) + + return mcp_tool + + except HTTPException as e: + raise e + except Exception as e: + logger.error(f"Error fetching MCP tool: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{mcp_tool_id}/test", response_model=MCPToolTestResponse) +async def test_mcp_tool( + mcp_tool_id: DbIntId, + current_user: User = Depends(require_permissions("manage_agents")), + db: Session = Depends(get_db) +): + """Connect to a configured MCP tool once and report whether it comes up + and which functions it exposes — surfaces environment problems (e.g. a + missing npx) that otherwise only show as a silent 0-tool run.""" + try: + check_mcp_feature_access(current_user, db) + + mcp_tool_repo = MCPToolRepository(db) + mcp_tool = mcp_tool_repo.get_mcp_tool(mcp_tool_id) + + if not mcp_tool: + raise HTTPException( + status_code=404, + detail="MCP tool not found" + ) + + if mcp_tool.organization_id != current_user.organization_id: + raise HTTPException( + status_code=403, + detail="Not authorized to access this MCP tool" + ) + + # A request is waiting on this, so cap it no matter how generous the + # tool's own timeout is — a hung server must not hold the worker and + # its DB session for minutes. + manager = MCPToolsManager(connect_budget=TEST_CONNECT_BUDGET_SECONDS) + return await manager.test_tool_config(mcp_tool) + + except HTTPException as e: + raise e + except Exception as e: + logger.error(f"MCP tool test error: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/{mcp_tool_id}/references", response_model=MCPToolReferencesResponse) +async def get_mcp_tool_references( + mcp_tool_id: DbIntId, + current_user: User = Depends(require_permissions("manage_agents")), + db: Session = Depends(get_db) +): + """What currently points at this connector, so a delete confirmation can + name what it is about to break. Deliberately ungated, like the delete it + precedes.""" + try: + mcp_tool_repo = MCPToolRepository(db) + mcp_tool = mcp_tool_repo.get_mcp_tool(mcp_tool_id) + + if not mcp_tool: + raise HTTPException( + status_code=404, + detail="MCP tool not found" + ) + + if mcp_tool.organization_id != current_user.organization_id: + raise HTTPException( + status_code=403, + detail="Not authorized to access this MCP tool" + ) + + return MCPToolReferencesResponse( + agents=mcp_tool_repo.get_mcp_tool_agent_names(mcp_tool_id), + used_in_investigations=mcp_tool_repo.is_used_in_investigations( + mcp_tool_id, current_user.organization_id + ), + ) + + except HTTPException as e: + raise e + except Exception as e: + logger.error(f"Error fetching MCP tool references: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/{mcp_tool_id}", response_model=MCPToolResponse) +async def update_mcp_tool( + mcp_tool_id: DbIntId, + update_data: MCPToolUpdate, + current_user: User = Depends(require_permissions("manage_agents")), + db: Session = Depends(get_db) +): + """Update an existing MCP tool""" + try: + check_mcp_feature_access(current_user, db) + + mcp_tool_repo = MCPToolRepository(db) + mcp_tool = mcp_tool_repo.get_mcp_tool(mcp_tool_id) + + if not mcp_tool: + raise HTTPException( + status_code=404, + detail="MCP tool not found" + ) + + # Check if user has access to this MCP tool's organization + if mcp_tool.organization_id != current_user.organization_id: + raise HTTPException( + status_code=403, + detail="Not authorized to update this MCP tool" + ) + + # Update MCP tool with provided fields + update_dict = update_data.model_dump(exclude_unset=True) + + # Secrets are masked on the way out, so a form that round-trips them + # untouched must leave the stored credential alone. + if 'env_vars' in update_dict: + update_dict['env_vars'] = unmask_secret_values( + update_dict['env_vars'], mcp_tool.env_vars + ) + if 'headers' in update_dict: + update_dict['headers'] = unmask_secret_values( + update_dict['headers'], mcp_tool.headers + ) + + # transport_type is editable, and MCPToolUpdate carries none of the + # create-time validators, so check the state the update would leave + # behind — otherwise a connector can be saved with a transport it has + # no command or URL for and fails only mid-investigation. + transport = update_dict.get('transport_type', mcp_tool.transport_type) + transport = getattr(transport, 'value', transport) + if transport == MCPTransportTypeEnum.STDIO.value: + require_stdio_enabled() + if not update_dict.get('command', mcp_tool.command): + raise HTTPException( + status_code=400, + detail="Command is required for STDIO transport" + ) + elif not update_dict.get('url', mcp_tool.url): + raise HTTPException( + status_code=400, + detail="URL is required for SSE/HTTP transport" + ) + else: + require_public_target(update_dict.get('url', mcp_tool.url), MCP_URL_LABEL) + + # Check for name conflicts if name is being updated + if 'name' in update_dict: + existing = mcp_tool_repo.get_by_name( + update_dict['name'], + current_user.organization_id + ) + if existing and existing.id != mcp_tool_id: + raise HTTPException( + status_code=400, + detail="MCP tool with this name already exists" + ) + + updated_mcp_tool = mcp_tool_repo.update_mcp_tool(mcp_tool_id, **update_dict) + return updated_mcp_tool + + except HTTPException as e: + raise e + except Exception as e: + logger.error(f"MCP tool update error: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/{mcp_tool_id}") +async def delete_mcp_tool( + mcp_tool_id: DbIntId, + current_user: User = Depends(require_permissions("manage_agents")), + db: Session = Depends(get_db) +): + """Delete an MCP tool. + + Deliberately not behind check_mcp_feature_access: an org whose plan no + longer carries mcp_tools must still be able to remove its connectors, + otherwise a downgrade would strand their credentials in env_vars for good. + """ + try: + mcp_tool_repo = MCPToolRepository(db) + mcp_tool = mcp_tool_repo.get_mcp_tool(mcp_tool_id) + + if not mcp_tool: + raise HTTPException( + status_code=404, + detail="MCP tool not found" + ) + + # Check if user has access to this MCP tool's organization + if mcp_tool.organization_id != current_user.organization_id: + raise HTTPException( + status_code=403, + detail="Not authorized to delete this MCP tool" + ) + + success = mcp_tool_repo.delete_mcp_tool(mcp_tool_id) + if not success: + raise HTTPException( + status_code=500, + detail="Failed to delete MCP tool" + ) + + return {"message": "MCP tool deleted successfully"} + + except HTTPException as e: + raise e + except Exception as e: + logger.error(f"MCP tool deletion error: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + +# Agent-MCPTool association endpoints +@router.post("/agent-association", response_model=MCPToolToAgentResponse) +async def add_mcp_tool_to_agent( + association_data: MCPToolToAgentCreate, + current_user: User = Depends(require_permissions("manage_agents")), + db: Session = Depends(get_db) +): + """Add MCP tool to agent""" + try: + # Check MCP tools feature access + check_mcp_feature_access(current_user, db) + + mcp_tool_repo = MCPToolRepository(db) + agent_repo = AgentRepository(db) + + # Verify MCP tool exists and belongs to user's organization + mcp_tool = mcp_tool_repo.get_mcp_tool(association_data.mcp_tool_id) + if not mcp_tool: + raise HTTPException( + status_code=404, + detail="MCP tool not found" + ) + + if mcp_tool.organization_id != current_user.organization_id: + raise HTTPException( + status_code=403, + detail="Not authorized to access this MCP tool" + ) + + # Verify agent exists and belongs to user's organization + agent = agent_repo.get_agent(association_data.agent_id) + if not agent: + raise HTTPException( + status_code=404, + detail="Agent not found" + ) + + if agent.organization_id != current_user.organization_id: + raise HTTPException( + status_code=403, + detail="Not authorized to access this agent" + ) + + # Create association + association = mcp_tool_repo.add_mcp_tool_to_agent( + association_data.mcp_tool_id, + association_data.agent_id + ) + + return association + + except HTTPException as e: + raise e + except Exception as e: + logger.error(f"Error adding MCP tool to agent: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/agent-association/{mcp_tool_id}/{agent_id}") +async def remove_mcp_tool_from_agent( + mcp_tool_id: DbIntId, + agent_id: UUID, + current_user: User = Depends(require_permissions("manage_agents")), + db: Session = Depends(get_db) +): + """Remove MCP tool from agent""" + try: + mcp_tool_repo = MCPToolRepository(db) + agent_repo = AgentRepository(db) + + # Verify MCP tool exists and belongs to user's organization + mcp_tool = mcp_tool_repo.get_mcp_tool(mcp_tool_id) + if not mcp_tool: + raise HTTPException( + status_code=404, + detail="MCP tool not found" + ) + + if mcp_tool.organization_id != current_user.organization_id: + raise HTTPException( + status_code=403, + detail="Not authorized to access this MCP tool" + ) + + # Verify agent exists and belongs to user's organization + agent = agent_repo.get_agent(agent_id) + if not agent: + raise HTTPException( + status_code=404, + detail="Agent not found" + ) + + if agent.organization_id != current_user.organization_id: + raise HTTPException( + status_code=403, + detail="Not authorized to access this agent" + ) + + # Remove association + success = mcp_tool_repo.remove_mcp_tool_from_agent(mcp_tool_id, agent_id) + if not success: + raise HTTPException( + status_code=404, + detail="Association not found" + ) + + return {"message": "MCP tool removed from agent successfully"} + + except HTTPException as e: + raise e + except Exception as e: + logger.error(f"Error removing MCP tool from agent: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/agent/{agent_id}", response_model=AgentMCPToolsResponse) +async def get_agent_mcp_tools( + agent_id: UUID, + current_user: User = Depends(require_permissions("view_all", "manage_agents")), + db: Session = Depends(get_db) +): + """Get all MCP tools associated with an agent""" + try: + mcp_tool_repo = MCPToolRepository(db) + agent_repo = AgentRepository(db) + + # Verify agent exists and belongs to user's organization + agent = agent_repo.get_agent(agent_id) + if not agent: + raise HTTPException( + status_code=404, + detail="Agent not found" + ) + + if agent.organization_id != current_user.organization_id: + raise HTTPException( + status_code=403, + detail="Not authorized to access this agent" + ) + + # Get agent's MCP tools + mcp_tools = mcp_tool_repo.get_agent_mcp_tools(agent_id) + + return AgentMCPToolsResponse( + id=agent.id, + name=agent.name, + mcp_tools=mcp_tools + ) + + except HTTPException as e: + raise e + except Exception as e: + logger.error(f"Error fetching agent MCP tools: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/backend/app/api/notification.py b/backend/app/api/notification.py new file mode 100644 index 0000000..d54b9ab --- /dev/null +++ b/backend/app/api/notification.py @@ -0,0 +1,279 @@ +""" +Copyright 2024-2026 ChatterMate + +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 fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from typing import List +from app.database import get_db +from app.models.user import User +from app.core.auth import get_current_user +from app.models.notification import Notification +from app.models.schemas.notification import ( + NotificationResponse, + NotificationSettingsOut, + NotificationSettingsUpdate, +) +from app.repositories.notification_settings import UserNotificationSettingsRepository +from app.core.logger import get_logger +from app.services.user import send_fcm_notification +from app.core.params import MAX_DB_INT, DbIntId + +router = APIRouter() +logger = get_logger(__name__) + + +@router.get("", response_model=List[NotificationResponse]) +async def list_notifications( + skip: int = Query(0, ge=0, le=MAX_DB_INT), + limit: int = Query(50, ge=1, le=500), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get user's notifications with pagination""" + try: + notifications = db.query(Notification)\ + .filter(Notification.user_id == current_user.id)\ + .order_by(Notification.created_at.desc())\ + .offset(skip)\ + .limit(limit)\ + .all() + + return notifications + except Exception as e: + logger.error(f"Error fetching notifications: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to fetch notifications" + ) + + +@router.patch("/{notification_id}/read") +async def mark_as_read( + notification_id: DbIntId, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Mark a notification as read""" + try: + notification = db.query(Notification)\ + .filter( + Notification.id == notification_id, + Notification.user_id == current_user.id + ).first() + + if not notification: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Notification not found" + ) + + notification.is_read = True + db.commit() + + return {"message": "Notification marked as read"} + except HTTPException: + raise + except Exception as e: + logger.error(f"Error marking notification as read: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update notification" + ) + + +@router.post("/read-all") +async def mark_all_as_read( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Mark every unread notification as read, in one statement. + + The client used to loop over the notifications it had fetched and PATCH each + one. That page is 50 rows, so anyone with more unread than that could never + clear the badge however often they tapped "Mark all read" — and it cost 50 + round trips to not fix it. + """ + try: + updated = db.query(Notification)\ + .filter( + Notification.user_id == current_user.id, + Notification.is_read == False + ).update({Notification.is_read: True}, synchronize_session=False) + db.commit() + + return {"message": "Notifications marked as read", "updated": updated} + except Exception as e: + db.rollback() + logger.error(f"Error marking all notifications as read: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update notifications" + ) + + +@router.delete("/{notification_id}") +async def delete_notification( + notification_id: DbIntId, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Delete one notification. + + Scoped to the caller: another user's id must 404 rather than delete. + """ + try: + notification = db.query(Notification)\ + .filter( + Notification.id == notification_id, + Notification.user_id == current_user.id + ).first() + + if not notification: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Notification not found" + ) + + db.delete(notification) + db.commit() + + return {"message": "Notification deleted"} + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"Error deleting notification: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to delete notification" + ) + + +@router.delete("") +async def clear_notifications( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Delete all of the caller's notifications.""" + try: + deleted = db.query(Notification)\ + .filter(Notification.user_id == current_user.id)\ + .delete(synchronize_session=False) + db.commit() + + return {"message": "Notifications cleared", "deleted": deleted} + except Exception as e: + db.rollback() + logger.error(f"Error clearing notifications: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to clear notifications" + ) + + +@router.get("/unread-count") +async def get_unread_count( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get count of unread notifications""" + try: + count = db.query(Notification)\ + .filter( + Notification.user_id == current_user.id, + Notification.is_read == False + ).count() + return {"count": count} + except Exception as e: + logger.error(f"Error getting unread count: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to get unread count" + ) + + +@router.get("/settings", response_model=NotificationSettingsOut) +async def get_notification_settings( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get the current user's chat notification preferences""" + try: + return UserNotificationSettingsRepository(db).get_or_create(current_user.id) + except Exception as e: + logger.error(f"Error fetching notification settings: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to fetch notification settings" + ) + + +@router.put("/settings", response_model=NotificationSettingsOut) +async def update_notification_settings( + payload: NotificationSettingsUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Update the current user's chat notification preferences. + + Always scoped to the caller — there is no way to address another user's + settings, so no permission check beyond authentication is needed. + """ + try: + settings = UserNotificationSettingsRepository(db).get_or_create(current_user.id) + + for key, value in payload.model_dump(exclude_unset=True).items(): + setattr(settings, key, value) + + db.commit() + db.refresh(settings) + return settings + except Exception as e: + logger.error(f"Error updating notification settings: {str(e)}") + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update notification settings" + ) + + +@router.post("/test") +async def send_test_notification( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Send a test notification to the current user""" + try: + # Create test notification + notification = Notification( + user_id=current_user.id, + type="CHAT", + title="Test Notification", + message="This is a test notification from ChatterMate", + notification_metadata={"test": True} + ) + db.add(notification) + db.commit() + # Send FCM notification + await send_fcm_notification(current_user.id, notification, db) + + return {"message": "Test notification sent successfully"} + except Exception as e: + logger.error(f"Error sending test notification: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to send test notification" + ) diff --git a/backend/app/api/shopify.py b/backend/app/api/shopify.py new file mode 100644 index 0000000..e3adb28 --- /dev/null +++ b/backend/app/api/shopify.py @@ -0,0 +1,1233 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import traceback +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response +from fastapi.responses import RedirectResponse, HTMLResponse +from sqlalchemy.orm import Session +from app.core.config import settings +from app.core.logger import get_logger +from app.database import get_db +from app.models.schemas.shopify import ShopifyShopCreate, ShopifyShop, ShopifyShopUpdate +from app.models.schemas.shopify import AgentShopifyConfigBase, AgentShopifyConfig, AgentShopifyConfigCreate, AgentShopifyConfigUpdate +from app.repositories.shopify_shop_repository import ShopifyShopRepository +from app.repositories.agent_shopify_config_repository import AgentShopifyConfigRepository +from typing import Optional +import requests +import hmac +import hashlib +import base64 +import json +from urllib.parse import urlencode, quote +from app.core.auth import get_current_user, require_permissions, get_current_organization, check_permissions +from app.models.user import User +from app.models.organization import Organization +from app.repositories.widget import WidgetRepository +from app.services.shopify import ShopifyService +from app.services.shopify_auth_service import ShopifyAuthService +from app.services.shopify_helper_service import ShopifyHelperService +from app.services.shopify_session import require_shopify_session +from app.services.shopify_urls import shopify_admin_url, shopify_oauth_token_url +from app.repositories.knowledge import KnowledgeRepository +from app.repositories.knowledge_queue import KnowledgeQueueRepository +from app.repositories.knowledge_to_agent import KnowledgeToAgentRepository +from app.models.knowledge import SourceType +from app.models.knowledge_queue import KnowledgeQueue, QueueStatus +from app.models.knowledge_to_agent import KnowledgeToAgent +from uuid import UUID +from app.core.cors import update_cors_middleware +from app.core.application import app +from app.core.params import MAX_DB_INT + +router = APIRouter() +logger = get_logger(__name__) + +# Define the scopes needed for your app +SCOPES = "read_products,read_themes,write_themes,write_script_tags,read_script_tags,read_orders,read_all_orders,read_customers" + + +def _require_shopify_agent_in_org(db, agent_id, org_id) -> None: + """The agent being configured must belong to the resolved org, else 404. + + agent_id and org_id both arrive as either strings or UUIDs depending on the + auth path; a malformed id is simply not found rather than a 500. + """ + from app.repositories.agent import AgentRepository + try: + agent_uuid = UUID(str(agent_id)) + org_uuid = UUID(str(org_id)) + except (ValueError, TypeError): + raise HTTPException(status_code=404, detail="Agent not found") + if not AgentRepository(db).get_agent_in_org(agent_uuid, org_uuid): + raise HTTPException(status_code=404, detail="Agent not found") + + +@router.post("/exchange-token") +async def exchange_session_token( + request: Request, + db: Session = Depends(get_db) +): + """ + Exchange Shopify session token for offline access token via Shopify token exchange API. + Called when app is first loaded in embedded context. + Stores offline access token in database for future Shopify API calls. + """ + try: + # 1. Get session token from Authorization header + from app.services.shopify_session import ShopifySessionService + + session_token = ShopifySessionService.get_session_token_from_request(request) + if not session_token: + raise HTTPException(status_code=401, detail="Session token required") + + logger.info("Exchanging session token for offline access token") + + # 2. Verify the session token's signature before trusting any claim. This request + # carries client_secret to the host named by `dest`, so an unverified token + # would let a caller steer our credentials to a host of their choosing. + decoded = ShopifySessionService.validate_session_token(session_token) + shop_domain = decoded.get('dest', '').replace('https://', '').replace('http://', '') + + # `dest` must be a Shopify shop domain, never a caller-chosen host. + if not shop_domain or not shop_domain.split('/')[0].endswith('.myshopify.com'): + raise HTTPException(status_code=400, detail="Invalid session token: bad shop domain") + + logger.info(f"Extracted shop domain from token: {shop_domain}") + + # 3. Call Shopify token exchange API + token_url = shopify_oauth_token_url(shop_domain) + payload = { + "client_id": settings.SHOPIFY_API_KEY, + "client_secret": settings.SHOPIFY_API_SECRET, + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token": session_token, + "subject_token_type": "urn:ietf:params:oauth:token-type:id_token", + "requested_token_type": "urn:shopify:params:oauth:token-type:offline-access-token" + } + + logger.info(f"Calling Shopify token exchange API for shop: {shop_domain}") + response = requests.post(token_url, json=payload) + + if response.status_code != 200: + logger.error(f"Token exchange failed: {response.status_code} - {response.text}") + raise HTTPException( + status_code=response.status_code, + detail=f"Token exchange failed: {response.text}" + ) + + token_data = response.json() + access_token = token_data.get('access_token') + + if not access_token: + logger.error(f"No access token in response: {token_data}") + raise HTTPException(status_code=500, detail="No access token received from Shopify") + + logger.info(f"Successfully received offline access token for shop: {shop_domain}") + + # 4. Create or update shop with offline access token + shop_repo = ShopifyShopRepository(db) + db_shop = shop_repo.get_shop_by_domain(shop_domain) + + if not db_shop: + logger.info(f"Creating new shop record for: {shop_domain}") + shop_data = ShopifyShopCreate( + shop_domain=shop_domain, + access_token=access_token, + is_installed=True + ) + db_shop = shop_repo.create_shop(shop_data) + else: + logger.info(f"Updating existing shop record for: {shop_domain}") + shop_update = ShopifyShopUpdate(access_token=access_token, is_installed=True) + db_shop = shop_repo.update_shop(db_shop.id, shop_update) + + db.commit() + db.refresh(db_shop) + + logger.info(f"Shop record saved successfully. Shop ID: {db_shop.id}, Org ID: {db_shop.organization_id}") + + return { + "shop_id": str(db_shop.id), + "shop_domain": db_shop.shop_domain, + "organization_id": str(db_shop.organization_id) if db_shop.organization_id else None, + "is_installed": True + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error exchanging session token: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Token exchange error: {str(e)}") + + +@router.post("/link-organization") +async def link_shop_to_org( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Link Shopify shop to authenticated user's organization after login. + Called after user logs in via popup window. + """ + try: + # Get shop_id from request body + body = await request.json() + shop_id = body.get('shop_id') + + if not shop_id: + raise HTTPException(status_code=400, detail="shop_id is required") + + logger.info(f"Linking shop {shop_id} to user {current_user.id}'s organization") + + # Verify user has manage_organization permission + if not check_permissions(current_user, ["manage_organization"]): + raise HTTPException(status_code=403, detail="Insufficient permissions") + + if not current_user.organization_id: + raise HTTPException(status_code=400, detail="User not associated with an organization") + + # Get shop from database + shop_repo = ShopifyShopRepository(db) + db_shop = shop_repo.get_shop(shop_id) + + if not db_shop: + raise HTTPException(status_code=404, detail="Shop not found") + + org_id_str = str(current_user.organization_id) + + # Claiming is only for shops nobody owns yet — the same guard + # /link-shop/{shop_id} has. Without it, any manage_organization user + # could re-point another tenant's installed shop (and its stored + # access token) at their own org, and drag that shop's domain onto + # their organization record in the process. + if db_shop.organization_id and str(db_shop.organization_id) != org_id_str: + raise HTTPException( + status_code=404, + detail="Shop not found" + ) + + # Update shop with organization_id + shop_update = ShopifyShopUpdate(organization_id=org_id_str) + shop_repo.update_shop(shop_id, shop_update) + + # Update organization domain with shop domain + try: + from app.repositories.organization import OrganizationRepository + org_repo = OrganizationRepository(db) + organization = org_repo.get_organization(org_id_str) + + if organization and db_shop.shop_domain: + # Only update if domain is different + if organization.domain != db_shop.shop_domain: + # Check if domain is already used by another organization + existing_org = org_repo.get_organization_by_domain(db_shop.shop_domain) + if not existing_org or str(existing_org.id) == str(organization.id): + # Update organization domain to shop domain + organization.domain = db_shop.shop_domain + db.add(organization) + logger.info(f"Updated organization {org_id_str} domain to {db_shop.shop_domain}") + else: + logger.warning(f"Domain {db_shop.shop_domain} is already used by organization {existing_org.id}, skipping update") + except Exception as domain_error: + logger.error(f"Error updating organization domain: {str(domain_error)}") + # Don't fail the linking if domain update fails + + db.commit() + + # Update CORS middleware to include new domain + try: + update_cors_middleware(app) + logger.info("Updated CORS middleware after linking shop to organization") + except Exception as cors_error: + logger.error(f"Error updating CORS middleware: {str(cors_error)}") + # Don't fail the linking if CORS update fails + + logger.info(f"Successfully linked shop {shop_id} to organization {org_id_str}") + + return { + "success": True, + "shop_id": shop_id, + "organization_id": org_id_str + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error linking shop to organization: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to link organization: {str(e)}") + + +@router.get("/shops", response_model=list[ShopifyShop]) +async def get_shops( + db: Session = Depends(get_db), + skip: int = Query(0, ge=0, le=MAX_DB_INT), + limit: int = Query(100, ge=1, le=1000), + organization: Organization = Depends(get_current_organization), + current_user: User = Depends(require_permissions("manage_organization")) +): + """ + Get all shops for the current organization + """ + # Ensure organization_id is a string, not a UUID object + org_id_str = str(organization.id) if organization and organization.id else None + shop_repository = ShopifyShopRepository(db) + shops = shop_repository.get_shops_by_organization(org_id_str, skip=skip, limit=limit) + + # Ensure organization_id is a string in each shop object + for shop in shops: + if shop.organization_id and not isinstance(shop.organization_id, str): + shop.organization_id = str(shop.organization_id) + + return shops + +@router.get("/shops/{shop_id}", response_model=ShopifyShop) +async def get_shop( + shop_id: str, + db: Session = Depends(get_db), + organization: Organization = Depends(get_current_organization), + current_user: User = Depends(require_permissions("manage_organization")) +): + """ + Get a shop by ID + """ + shop_repository = ShopifyShopRepository(db) + db_shop = shop_repository.get_shop(shop_id) + if not db_shop: + raise HTTPException(status_code=404, detail="Shop not found") + + # Convert organization IDs to strings for comparison + shop_org_id = str(db_shop.organization_id) if db_shop.organization_id else None + current_org_id = str(organization.id) if organization.id else None + + # Ensure organization_id is a string, not a UUID object + if db_shop.organization_id and not isinstance(db_shop.organization_id, str): + db_shop.organization_id = str(db_shop.organization_id) + + # Verify that the shop belongs to the current organization + if shop_org_id != current_org_id: + logger.warning(f"Unauthorized attempt to access shop {shop_id} from organization {current_org_id}") + raise HTTPException(status_code=403, detail="This shop does not belong to your organization") + + return db_shop + +@router.delete("/shops/{shop_id}") +async def delete_shop( + shop_id: str, + db: Session = Depends(get_db), + organization: Organization = Depends(get_current_organization), + current_user: User = Depends(require_permissions("manage_organization")) +): + """ + Delete a shop and disconnect from Shopify + """ + shop_repository = ShopifyShopRepository(db) + db_shop = shop_repository.get_shop(shop_id) + if not db_shop: + raise HTTPException(status_code=404, detail="Shop not found") + + # Convert organization IDs to strings for comparison + shop_org_id = str(db_shop.organization_id) if db_shop.organization_id else None + current_org_id = str(organization.id) if organization.id else None + + # Verify that the shop belongs to the current organization + if shop_org_id != current_org_id: + logger.warning(f"Unauthorized attempt to delete shop {shop_id} from organization {current_org_id}") + raise HTTPException(status_code=403, detail="This shop does not belong to your organization") + + # Send an uninstall request to Shopify if we have an access token + if db_shop.access_token: + try: + # Request to delete app data from Shopify + uninstall_url = shopify_admin_url(db_shop.shop_domain, "graphql.json") + headers = { + "X-Shopify-Access-Token": db_shop.access_token, + "Content-Type": "application/json" + } + + # GraphQL mutation to uninstall the app + mutation = """ + mutation { + appSubscriptionCancel( + id: "gid://shopify/AppSubscription/current" + ) { + appSubscription { + id + status + } + userErrors { + field + message + } + } + } + """ + + response = requests.post( + uninstall_url, + headers=headers, + json={"query": mutation} + ) + + logger.info(f"Shopify uninstall response: {response.status_code} - {response.text}") + except Exception as e: + logger.error(f"Error uninstalling app from Shopify: {str(e)}") + # Continue with deletion even if uninstall fails + + # Get all agent configs linked to this shop and delete them + try: + agent_config_repository = AgentShopifyConfigRepository(db) + agent_configs = agent_config_repository.get_configs_by_shop(shop_id) + if agent_configs: + logger.info(f"Deleting {len(agent_configs)} agent Shopify configs for shop {shop_id}") + for config in agent_configs: + # Update the config to disable and remove shop_id + agent_config_repository.update_agent_shopify_config( + config.agent_id, + AgentShopifyConfigUpdate(enabled=False, shop_id=None) + ) + logger.info(f"Successfully deleted agent Shopify configs for shop {shop_id}") + except Exception as e: + logger.error(f"Error deleting agent Shopify configs: {str(e)}") + # Continue with shop deletion even if config deletion fails + + # Delete the shop from our database + success = shop_repository.delete_shop(shop_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to delete shop") + + return {"status": "success", "message": "Shop successfully disconnected"} + +@router.post("/link-shop/{shop_id}") +async def link_shop_to_organization( + shop_id: str, + db: Session = Depends(get_db), + organization: Organization = Depends(get_current_organization), + current_user: User = Depends(require_permissions("manage_organization")) +): + """ + Link a Shopify shop to the current organization after user logs in. + This is called after successful login from the embedded app flow. + """ + try: + shop_repository = ShopifyShopRepository(db) + db_shop = shop_repository.get_shop(shop_id) + + if not db_shop: + raise HTTPException(status_code=404, detail="Shop not found") + + # Link shop to organization if not already linked + if not db_shop.organization_id: + org_id_str = str(organization.id) + shop_update = ShopifyShopUpdate(organization_id=org_id_str) + db_shop = shop_repository.update_shop(shop_id, shop_update) + db.commit() + logger.info(f"Linked shop {shop_id} to organization {org_id_str}") + + return { + "success": True, + "shop_id": str(db_shop.id), + "shop_domain": db_shop.shop_domain, + "organization_id": str(db_shop.organization_id) + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error linking shop to organization: {str(e)}") + raise HTTPException( + status_code=500, + detail="Error linking shop to organization" + ) + +@router.get("/status") +async def check_connection( + db: Session = Depends(get_db), + organization: Organization = Depends(get_current_organization), + current_user: User = Depends(require_permissions("manage_organization")) +): + """ + Check if Shopify is connected for the current organization + """ + try: + # Get shops for the current organization + org_id_str = str(organization.id) if organization.id else None + shop_repository = ShopifyShopRepository(db) + shops = shop_repository.get_shops_by_organization(org_id_str, limit=1) + is_connected = len(shops) > 0 and any(shop.is_installed for shop in shops) + + result = { + "connected": is_connected + } + + # If connected, include the shop domain + if is_connected: + result["shop_domain"] = shops[0].shop_domain + + return result + except Exception as e: + logger.error(f"Error checking Shopify connection: {str(e)}") + raise HTTPException( + status_code=500, + detail="Error checking Shopify connection status" + ) + +@router.get("/shop-config-status") +async def get_shop_config_status( + shopify_session: dict = Depends(require_shopify_session), + db: Session = Depends(get_db) +): + """ + Check configuration status for a shop (agents configured, widget ID, etc.) + Uses session token authentication for embedded apps. + """ + try: + # Shop info already validated by session token + db_shop = shopify_session['db_shop'] + + # Check if any agents are configured for this shop + agent_config_repository = AgentShopifyConfigRepository(db) + configs = agent_config_repository.get_configs_by_shop(str(db_shop.id), enabled_only=True) + agents_connected = len(configs) if configs else 0 + + # Get widget ID if agents are configured + widget_id = None + if configs and len(configs) > 0: + widget_repo = WidgetRepository(db) + widgets = widget_repo.get_widgets_by_agent(configs[0].agent_id) + if widgets and len(widgets) > 0: + widget_id = str(widgets[0].id) + + return { + "shop_id": str(db_shop.id), + "shop_domain": db_shop.shop_domain, + "is_installed": db_shop.is_installed, + "agents_connected": agents_connected, + "widget_id": widget_id, + "organization_id": str(db_shop.organization_id) if db_shop.organization_id else None + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting shop config status: {str(e)}") + raise HTTPException( + status_code=500, + detail="Error retrieving shop configuration status" + ) + +@router.get("/organization-domain") +async def get_organization_domain( + shopify_session: dict = Depends(require_shopify_session), + db: Session = Depends(get_db) +): + """ + Get organization domain from the organization table. + Uses session token authentication for embedded apps. + """ + try: + # Shop info already validated by session token + db_shop = shopify_session['db_shop'] + + if not db_shop.organization_id: + raise HTTPException(status_code=400, detail="Shop not linked to organization") + + # Get organization + from app.repositories.organization import OrganizationRepository + org_repo = OrganizationRepository(db) + organization = org_repo.get_organization(str(db_shop.organization_id)) + + if not organization: + raise HTTPException(status_code=404, detail="Organization not found") + + return { + "organization_id": str(organization.id), + "domain": organization.domain, + "name": organization.name + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting organization domain: {str(e)}") + raise HTTPException( + status_code=500, + detail="Error retrieving organization domain" + ) + +@router.put("/update-domain") +async def update_organization_domain( + request: Request, + shopify_session: dict = Depends(require_shopify_session), + db: Session = Depends(get_db) +): + """ + Update organization domain. + Uses session token authentication for embedded apps. + """ + try: + # Shop info already validated by session token + db_shop = shopify_session['db_shop'] + + # Get domain from request body + body = await request.json() + new_domain = body.get('domain') + + if not new_domain: + raise HTTPException(status_code=400, detail="domain is required") + + # Validate domain format (basic validation) + new_domain = new_domain.strip() + if not new_domain: + raise HTTPException(status_code=400, detail="domain cannot be empty") + + # Get organization + if not db_shop.organization_id: + raise HTTPException(status_code=400, detail="Shop not linked to organization") + + from app.repositories.organization import OrganizationRepository + org_repo = OrganizationRepository(db) + organization = org_repo.get_organization(str(db_shop.organization_id)) + + if not organization: + raise HTTPException(status_code=404, detail="Organization not found") + + # Update organization domain + organization.domain = new_domain + db.add(organization) + db.commit() + + logger.info(f"Updated organization {organization.id} domain to {new_domain}") + + # Update CORS middleware to include new domain + update_cors_middleware(app) + logger.info("Updated CORS middleware after domain update") + + return { + "success": True, + "domain": new_domain, + "organization_id": str(organization.id) + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error updating organization domain: {str(e)}") + # Check if it's a unique constraint violation + if "duplicate key" in str(e).lower() or "unique constraint" in str(e).lower(): + raise HTTPException( + status_code=400, + detail="Domain is already connected to another organization" + ) + raise HTTPException( + status_code=500, + detail="Error updating organization domain" + ) + +@router.get("/connected-agents") +async def get_connected_agents( + shop_id: str = Query(..., description="Shop ID"), + shopify_session: dict = Depends(require_shopify_session), + db: Session = Depends(get_db) +): + """ + Get all agents connected to a Shopify shop. + Uses session token authentication for embedded apps. + """ + try: + # Verify shop_id matches the session + if shop_id != shopify_session['shop_id']: + raise HTTPException(status_code=403, detail="Shop ID mismatch") + + # Get agent configurations for this shop + agent_config_repository = AgentShopifyConfigRepository(db) + configs = agent_config_repository.get_configs_by_shop(shop_id, enabled_only=True) + + if not configs: + return [] + + # Get full agent details for each config + from app.repositories.agent import AgentRepository + agent_repo = AgentRepository(db) + agents = [] + + for config in configs: + agent = agent_repo.get_agent(str(config.agent_id)) + if agent: + agents.append({ + "id": str(agent.id), + "name": agent.name, + "display_name": agent.display_name, + "description": agent.description, + "is_active": agent.is_active, + "organization_id": str(agent.organization_id) + }) + + return agents + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting connected agents: {str(e)}") + raise HTTPException( + status_code=500, + detail="Error retrieving connected agents" + ) + +@router.get("/agent-config/{agent_id}", response_model=AgentShopifyConfig) +async def get_agent_shopify_config( + agent_id: str, + request: Request, + db: Session = Depends(get_db) +): + """ + Get Shopify configuration for an agent. + Supports both session token auth (embedded) and JWT auth (dashboard). + """ + # Try session token first (for embedded apps) + org_id = None + try: + shopify_session = await require_shopify_session(request, db) + # Session token valid - verify agent belongs to shop's organization + if not shopify_session['organization_id']: + raise HTTPException(status_code=403, detail="Shop not linked to organization") + org_id = shopify_session['organization_id'] + logger.info(f"GET agent-config using session token for org: {org_id}") + except HTTPException: + # Fall back to JWT auth (for dashboard) + current_user = await get_current_user(request=request, db=db) + if not check_permissions(current_user, ["manage_organization"]): + raise HTTPException(status_code=403, detail="Insufficient permissions") + org_id = current_user.organization_id + logger.info(f"GET agent-config using JWT auth for user: {current_user.id}") + + # The comment above always promised this — the agent must be in the + # resolved org, else any tenant could read any agent's Shopify config. + _require_shopify_agent_in_org(db, agent_id, org_id) + + agent_config_repository = AgentShopifyConfigRepository(db) + config = agent_config_repository.get_agent_shopify_config(agent_id) + if not config: + # Return a default config if none exists + raise HTTPException(status_code=404, detail="Shopify configuration not found for this agent") + return config + +@router.post("/agent-config/{agent_id}", response_model=AgentShopifyConfig) +async def save_agent_shopify_config( + agent_id: str, + config: AgentShopifyConfigBase, + request: Request, + db: Session = Depends(get_db) +): + """ + Save Shopify configuration for an agent. + Automatically determines the shop ID for the organization. + Supports both session token auth (embedded) and JWT auth (dashboard). + """ + agent_config_repository = AgentShopifyConfigRepository(db) + shop_repository = ShopifyShopRepository(db) + + # Try session token first (for embedded apps) + org_id_str = None + user_id = None + try: + shopify_session = await require_shopify_session(request, db) + # Session token valid - verify shop is linked to organization + if not shopify_session['organization_id']: + raise HTTPException(status_code=403, detail="Shop not linked to organization") + # Ensure org_id_str is always a string + org_id_str = str(shopify_session['organization_id']) + # For session token, we don't have user_id - use None (optional for knowledge queue) + logger.info(f"POST agent-config using session token for org: {org_id_str}") + except HTTPException: + # Fall back to JWT auth (for dashboard) + current_user = await get_current_user(request=request, db=db) + if not check_permissions(current_user, ["manage_organization"]): + raise HTTPException(status_code=403, detail="Insufficient permissions") + if not current_user.organization_id: + raise HTTPException(status_code=403, detail="User not linked to organization") + org_id_str = str(current_user.organization_id) + user_id = current_user.id + logger.info(f"POST agent-config using JWT auth for user: {current_user.id}") + + # The agent being configured must belong to the resolved org. + _require_shopify_agent_in_org(db, agent_id, org_id_str) + + # Get the current config for the agent, if it exists + existing_config = agent_config_repository.get_agent_shopify_config(agent_id) + + # Automatically determine the shop ID for this organization + target_shop = None + + # Find the available installed shop for the organization + shops = shop_repository.get_shops_by_organization(org_id_str, limit=10) + installed_shops = [s for s in shops if s.is_installed] + + if installed_shops: + target_shop = installed_shops[0] + target_shop_id = target_shop.id + else: + target_shop_id = None + + if not installed_shops and config.enabled: + raise HTTPException( + status_code=400, + detail="Cannot enable Shopify integration: No installed Shopify shops found for this organization." + ) + + # --- Save Config to DB --- + try: + if existing_config: + # Update existing config + # Always use the automatically determined shop_id + update_config = AgentShopifyConfigUpdate( + enabled=config.enabled, + shop_id=target_shop_id if config.enabled else None + ) + updated_config = agent_config_repository.update_agent_shopify_config( + agent_id, update_config + ) + logger.info(f"Updated Shopify config for agent {agent_id}") + saved_config = updated_config + else: + # Create new config + # Always use the automatically determined shop_id + config_create_data = AgentShopifyConfigCreate( + agent_id=agent_id, + enabled=config.enabled, + shop_id=target_shop_id if config.enabled else None + ) + new_config = agent_config_repository.create_agent_shopify_config(config_create_data) + logger.info(f"Created Shopify config for agent {agent_id}") + saved_config = new_config + + # --- Link or Queue Knowledge Source --- + if config.enabled and target_shop: + try: + org_uuid = UUID(org_id_str) + agent_uuid = UUID(agent_id) + shop_domain = target_shop.shop_domain + + if shop_domain: + knowledge_repo = KnowledgeRepository(db) + # Check if knowledge source already exists for this shop domain and org + existing_knowledge_list = knowledge_repo.get_by_sources(org_uuid, [shop_domain]) + existing_knowledge = existing_knowledge_list[0] if existing_knowledge_list else None + + if existing_knowledge is None: + # Knowledge doesn't exist, queue it + logger.info(f"Shop domain {shop_domain} not found in knowledge base for org {org_uuid}. Queuing...") + queue_repo = KnowledgeQueueRepository(db) + queue_item = KnowledgeQueue( + organization_id=org_uuid, + agent_id=agent_uuid, # Link to current agent + user_id=user_id, # user_id from auth (JWT) or None (session token) + source_type=SourceType.WEBSITE, + source=shop_domain, + status=QueueStatus.PENDING, + queue_metadata={} + ) + queue_repo.create(queue_item) + logger.info(f"Shop domain {shop_domain} queued for knowledge processing for agent {agent_id}.") + else: + # Knowledge exists, check if linked to agent + logger.info(f"Shop domain {shop_domain} found in knowledge base (ID: {existing_knowledge.id}). Checking agent link...") + link_repo = KnowledgeToAgentRepository(db) + existing_link = link_repo.get_by_ids(existing_knowledge.id, agent_uuid) + if existing_link is None: + # Link doesn't exist, create it + logger.info(f"Linking knowledge source {existing_knowledge.id} to agent {agent_id}...") + link = KnowledgeToAgent(knowledge_id=existing_knowledge.id, agent_id=agent_uuid) + link_repo.create(link) + # Optional: Could add vector DB filter update here later if needed + logger.info(f"Successfully linked knowledge source {existing_knowledge.id} to agent {agent_id}.") + else: + logger.info(f"Knowledge source {existing_knowledge.id} already linked to agent {agent_id}.") + + except Exception as ke: + # Log the error but don't fail the main config saving operation + logger.error(f"Failed to link or queue knowledge for shop {target_shop.id if target_shop else 'N/A'} / agent {agent_id}: {str(ke)}", exc_info=True) + + return saved_config # Return the saved/updated config + except Exception as e: + logger.error(f"Failed to save agent Shopify config to DB: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail="Failed to save Shopify configuration to database.") + +@router.post("/webhooks/app-uninstalled") +async def shopify_app_uninstalled_webhook( + request: Request, + db: Session = Depends(get_db) +): + """ + Webhook handler for Shopify app uninstallation. + This endpoint is called by Shopify when a merchant uninstalls the app. + + It performs the following cleanup: + 1. Marks the shop as uninstalled (is_installed = False) + 2. Disables all agent Shopify configs for this shop + 3. Optionally: Can clear access tokens for security + + Documentation: https://shopify.dev/docs/apps/build/webhooks/subscribe/get-started + """ + try: + # Get the request body and headers + request_body = await request.body() + request_headers = request.headers + + # Verify the webhook signature + if not ShopifyHelperService.verify_shopify_webhook(request_headers, request_body): + logger.warning("Invalid webhook signature for app/uninstalled") + raise HTTPException(status_code=401, detail="Invalid webhook signature") + + # Parse the webhook payload + try: + payload = json.loads(request_body.decode('utf-8')) + except json.JSONDecodeError as e: + logger.error(f"Failed to parse webhook payload: {str(e)}") + raise HTTPException(status_code=400, detail="Invalid JSON payload") + + # Extract shop domain from payload + # Shopify sends the shop domain in the payload + shop_domain = payload.get('domain') or payload.get('myshopify_domain') + + if not shop_domain: + logger.error(f"No shop domain in webhook payload: {payload}") + raise HTTPException(status_code=400, detail="Missing shop domain in payload") + + logger.info(f"Processing app uninstall webhook for shop: {shop_domain}") + + # Initialize repositories + shop_repo = ShopifyShopRepository(db) + config_repo = AgentShopifyConfigRepository(db) + + # Find the shop in our database + db_shop = shop_repo.get_shop_by_domain(shop_domain) + + if not db_shop: + logger.warning(f"Shop not found in database: {shop_domain}") + # Return 200 anyway to acknowledge receipt + return {"success": True, "message": "Shop not found, no action needed"} + + logger.info(f"Found shop in database: {db_shop.id}") + + # Delete all agent configurations for this shop + configs = config_repo.get_configs_by_shop(str(db_shop.id)) + deleted_configs = 0 + + for config in configs: + config_repo.delete_agent_shopify_config(config.agent_id) + deleted_configs += 1 + logger.info(f"Deleted Shopify config for agent {config.agent_id}") + + logger.info(f"Deleted {deleted_configs} agent config(s) for shop {shop_domain}") + + # Delete the shop record from the database + shop_repo.delete_shop(str(db_shop.id)) + logger.info(f"Deleted shop record for {shop_domain}") + + # Commit the changes + db.commit() + + logger.info(f"Successfully processed uninstall for {shop_domain}. Deleted shop and {deleted_configs} agent config(s)") + + # Return 200 OK to acknowledge receipt + return { + "success": True, + "message": f"App uninstalled successfully for {shop_domain}", + "shop_domain": shop_domain, + "configs_deleted": deleted_configs + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error processing app uninstall webhook: {str(e)}") + logger.error(traceback.format_exc()) + # Return 200 to prevent Shopify from retrying + # Log the error for investigation + return { + "success": False, + "message": "An internal error occurred while processing the uninstall webhook." + } + +@router.post("/webhooks/customers/data_request") +async def shopify_customers_data_request_webhook( + request: Request, + db: Session = Depends(get_db) +): + """ + GDPR Compliance Webhook: Customer Data Request + + This webhook is called when a customer requests access to their data. + You must provide the customer's data within a specified timeframe. + + Shopify sends the following information: + - shop_id: The ID of the shop + - shop_domain: The shop's domain + - customer: Customer information including email, phone + - orders_requested: List of order IDs the customer has placed + + Documentation: https://shopify.dev/docs/apps/build/privacy-law-compliance + """ + try: + # Get the request body and headers + request_body = await request.body() + request_headers = request.headers + + # Verify the webhook signature + if not ShopifyHelperService.verify_shopify_webhook(request_headers, request_body): + logger.warning("Invalid webhook signature for customers/data_request") + raise HTTPException(status_code=401, detail="Invalid webhook signature") + + # Parse the webhook payload + try: + payload = json.loads(request_body.decode('utf-8')) + except json.JSONDecodeError as e: + logger.error(f"Failed to parse webhook payload: {str(e)}") + raise HTTPException(status_code=400, detail="Invalid JSON payload") + + shop_domain = payload.get('shop_domain') + customer = payload.get('customer', {}) + customer_email = customer.get('email') + customer_phone = customer.get('phone') + orders_requested = payload.get('orders_requested', []) + + logger.info(f"Received customer data request for shop: {shop_domain}, customer: {customer_email}") + + # TODO: Implement data collection based on your data storage + # For now, we'll log the request and acknowledge receipt + # You should: + # 1. Collect all customer data you've stored (chat history, customer info, etc.) + # 2. Prepare it in a readable format (JSON, CSV, etc.) + # 3. Send it to the customer or make it available for download + # 4. Keep a record of the request for compliance + + logger.info(f"Customer data request details:") + logger.info(f" - Shop: {shop_domain}") + logger.info(f" - Customer Email: {customer_email}") + logger.info(f" - Customer Phone: {customer_phone}") + logger.info(f" - Orders: {orders_requested}") + + # In a production system, you would: + # 1. Query your chat_history table for this customer + # 2. Query any customer-specific data + # 3. Format and send the data to the customer + + # Return 200 OK to acknowledge receipt + return { + "success": True, + "message": "Customer data request received and logged", + "shop_domain": shop_domain, + "customer_email": customer_email + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error processing customer data request webhook: {str(e)}") + logger.error(traceback.format_exc()) + return { + "success": False, + "message": "Error processing webhook" + } + +@router.post("/webhooks/customers/redact") +async def shopify_customers_redact_webhook( + request: Request, + db: Session = Depends(get_db) +): + """ + GDPR Compliance Webhook: Customer Data Erasure + + This webhook is called when a customer requests their data to be deleted. + You must delete all customer data within a specified timeframe. + + Shopify sends the following information: + - shop_id: The ID of the shop + - shop_domain: The shop's domain + - customer: Customer information including email, phone + - orders_to_redact: List of order IDs to redact + + Documentation: https://shopify.dev/docs/apps/build/privacy-law-compliance + """ + try: + # Get the request body and headers + request_body = await request.body() + request_headers = request.headers + + # Verify the webhook signature + if not ShopifyHelperService.verify_shopify_webhook(request_headers, request_body): + logger.warning("Invalid webhook signature for customers/redact") + raise HTTPException(status_code=401, detail="Invalid webhook signature") + + # Parse the webhook payload + try: + payload = json.loads(request_body.decode('utf-8')) + except json.JSONDecodeError as e: + logger.error(f"Failed to parse webhook payload: {str(e)}") + raise HTTPException(status_code=400, detail="Invalid JSON payload") + + shop_domain = payload.get('shop_domain') + customer = payload.get('customer', {}) + customer_email = customer.get('email') + customer_phone = customer.get('phone') + orders_to_redact = payload.get('orders_to_redact', []) + + logger.info(f"Received customer redact request for shop: {shop_domain}, customer: {customer_email}") + + # TODO: Implement data deletion based on your data storage + # For now, we'll log the request and acknowledge receipt + # You should: + # 1. Delete or anonymize all customer data (chat history, personal info, etc.) + # 2. Keep a minimal record for compliance (that the request was fulfilled) + # 3. Ensure the customer's data is no longer accessible + + logger.info(f"Customer redact request details:") + logger.info(f" - Shop: {shop_domain}") + logger.info(f" - Customer Email: {customer_email}") + logger.info(f" - Customer Phone: {customer_phone}") + logger.info(f" - Orders to redact: {orders_to_redact}") + + # In a production system, you would: + # 1. Find all chat_history records for this customer + # 2. Either delete them or anonymize the customer data + # 3. Remove any personally identifiable information (PII) + # 4. Keep a log that the request was fulfilled + + # Example pseudocode: + # chat_repo = ChatHistoryRepository(db) + # if customer_email: + # chat_repo.delete_by_customer_email(customer_email, shop_domain) + # if customer_phone: + # chat_repo.delete_by_customer_phone(customer_phone, shop_domain) + + # Return 200 OK to acknowledge receipt + return { + "success": True, + "message": "Customer data redaction request received and logged", + "shop_domain": shop_domain, + "customer_email": customer_email + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error processing customer redact webhook: {str(e)}") + logger.error(traceback.format_exc()) + return { + "success": False, + "message": "Error processing webhook" + } + +@router.post("/webhooks/shop/redact") +async def shopify_shop_redact_webhook( + request: Request, + db: Session = Depends(get_db) +): + """ + GDPR Compliance Webhook: Shop Data Erasure + + This webhook is called 48 hours after a shop owner uninstalls your app. + You must delete all shop-related data. + + Shopify sends the following information: + - shop_id: The ID of the shop + - shop_domain: The shop's domain + + Documentation: https://shopify.dev/docs/apps/build/privacy-law-compliance + """ + try: + # Get the request body and headers + request_body = await request.body() + request_headers = request.headers + + # Verify the webhook signature + if not ShopifyHelperService.verify_shopify_webhook(request_headers, request_body): + logger.warning("Invalid webhook signature for shop/redact") + raise HTTPException(status_code=401, detail="Invalid webhook signature") + + # Parse the webhook payload + try: + payload = json.loads(request_body.decode('utf-8')) + except json.JSONDecodeError as e: + logger.error(f"Failed to parse webhook payload: {str(e)}") + raise HTTPException(status_code=400, detail="Invalid JSON payload") + + shop_domain = payload.get('shop_domain') + shop_id = payload.get('shop_id') + + logger.info(f"Received shop redact request for shop: {shop_domain} (ID: {shop_id})") + + # Initialize repositories + shop_repo = ShopifyShopRepository(db) + config_repo = AgentShopifyConfigRepository(db) + + # Find the shop in our database + db_shop = shop_repo.get_shop_by_domain(shop_domain) + + if not db_shop: + logger.warning(f"Shop not found in database: {shop_domain}") + # Return 200 anyway to acknowledge receipt + return {"success": True, "message": "Shop not found, no action needed"} + + logger.info(f"Found shop in database: {db_shop.id}") + + # Delete all agent configurations for this shop + configs = config_repo.get_configs_by_shop(str(db_shop.id)) + deleted_configs = 0 + + for config in configs: + config_repo.delete_agent_shopify_config(config.agent_id) + deleted_configs += 1 + logger.info(f"Deleted Shopify config for agent {config.agent_id}") + + # TODO: Delete all shop-related data + # You should also: + # 1. Delete or anonymize all chat history related to this shop + # 2. Remove any customer data associated with this shop + # 3. Delete any cached product data + # 4. Remove any analytics data for this shop + + # Example pseudocode: + # chat_repo = ChatHistoryRepository(db) + # chat_repo.delete_by_shop(shop_domain) + # + # customer_repo = CustomerRepository(db) + # customer_repo.delete_by_shop(shop_domain) + + # Delete the shop record from the database + shop_repo.delete_shop(str(db_shop.id)) + logger.info(f"Deleted shop record for {shop_domain}") + + logger.info(f"Successfully redacted all data for shop {shop_domain}. Deleted {deleted_configs} config(s)") + + # Return 200 OK to acknowledge receipt + return { + "success": True, + "message": f"Shop data redacted successfully for {shop_domain}", + "shop_domain": shop_domain, + "configs_deleted": deleted_configs + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error processing shop redact webhook: {str(e)}") + logger.error(traceback.format_exc()) + return { + "success": False, + "message": "Error processing webhook" + } \ No newline at end of file diff --git a/backend/app/api/ticket_db_connectors.py b/backend/app/api/ticket_db_connectors.py new file mode 100644 index 0000000..a6760c6 --- /dev/null +++ b/backend/app/api/ticket_db_connectors.py @@ -0,0 +1,226 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +CRUD + connection testing for guardrailed ticket DB connectors. Credentials +are encrypted at rest and never returned by any endpoint. +""" + +import asyncio +from datetime import datetime, timezone +from typing import List +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.core.logger import get_logger +from app.core.outbound_policy import require_public_target +from app.core.security import encrypt_api_key +from app.database import get_db +from app.models.schemas.ticket_db_connector import ( + DBConnectorCreate, + DBConnectorDiscoverRequest, + DBConnectorDiscoverResponse, + DBConnectorOut, + DBConnectorUpdate, +) +from app.models.ticket_db_connector import TicketDBConnector +from app.models.user import User +from app.api.tickets import require_any_permission +from app.services.db_connector_service import DBConnectorConfig, first_hop, test_connection +from app.services.ticket_access import check_ticketing_access + +logger = get_logger(__name__) +router = APIRouter() + +manage_connectors = require_any_permission("manage_ticket_connectors", "manage_organization") + + +# Map API secret field -> encrypted model column. Omitting a secret on update +# keeps the stored value; the plaintext never round-trips to the client. +_SSH_SECRET_COLUMNS = { + "ssh_password": "encrypted_ssh_password", + "ssh_private_key": "encrypted_ssh_private_key", + "ssh_private_key_passphrase": "encrypted_ssh_key_passphrase", +} + + +def _config_from_discover(payload, organization_id) -> DBConnectorConfig: + return DBConnectorConfig( + id=None, + organization_id=organization_id, + name="draft", + engine=payload.engine, + host=payload.host, + port=payload.port, + database=payload.database, + username=payload.username, + password=payload.password, + ssh_enabled=payload.ssh_enabled, + ssh_host=payload.ssh_host, + ssh_port=payload.ssh_port, + ssh_username=payload.ssh_username, + ssh_password=payload.ssh_password, + ssh_private_key=payload.ssh_private_key, + ssh_private_key_passphrase=payload.ssh_private_key_passphrase, + ) + + +def _get_connector_or_404(db: Session, connector_id: UUID, user: User) -> TicketDBConnector: + connector = ( + db.query(TicketDBConnector) + .filter( + TicketDBConnector.id == connector_id, + TicketDBConnector.organization_id == user.organization_id, + ) + .first() + ) + if connector is None: + raise HTTPException(status_code=404, detail="Connector not found") + return connector + + +async def _discover(config: DBConnectorConfig) -> DBConnectorDiscoverResponse: + try: + result = await asyncio.to_thread(test_connection, config) + return DBConnectorDiscoverResponse(ok=True, tables=result["tables"]) + except Exception as e: + return DBConnectorDiscoverResponse(ok=False, error=str(e)[:500]) + + +@router.get("", response_model=List[DBConnectorOut]) +async def list_connectors( + current_user: User = Depends(manage_connectors), + db: Session = Depends(get_db), +): + check_ticketing_access(db, current_user.organization_id) + return ( + db.query(TicketDBConnector) + .filter(TicketDBConnector.organization_id == current_user.organization_id) + .order_by(TicketDBConnector.created_at) + .all() + ) + + +@router.post("/discover", response_model=DBConnectorDiscoverResponse) +async def discover_tables( + payload: DBConnectorDiscoverRequest, + current_user: User = Depends(manage_connectors), + db: Session = Depends(get_db), +): + """Pre-save connection test: connect with the given credentials and + return the discoverable tables/columns for the allowlist picker.""" + check_ticketing_access(db, current_user.organization_id) + require_public_target(*first_hop(payload.ssh_enabled, payload.ssh_host, payload.host)) + return await _discover(_config_from_discover(payload, current_user.organization_id)) + + +@router.post("", response_model=DBConnectorOut, status_code=201) +async def create_connector( + payload: DBConnectorCreate, + current_user: User = Depends(manage_connectors), + db: Session = Depends(get_db), +): + check_ticketing_access(db, current_user.organization_id) + require_public_target(*first_hop(payload.ssh_enabled, payload.ssh_host, payload.host)) + connector = TicketDBConnector( + organization_id=current_user.organization_id, + name=payload.name, + engine=payload.engine, + host=payload.host, + port=payload.port, + database=payload.database, + username=payload.username, + encrypted_password=encrypt_api_key(payload.password), + enabled=payload.enabled, + allowed_tables=payload.allowed_tables, + masked_columns=payload.masked_columns, + row_scope=payload.row_scope, + row_scope_key=payload.row_scope_key, + max_rows=payload.max_rows, + statement_timeout_ms=payload.statement_timeout_ms, + ssh_enabled=payload.ssh_enabled, + ssh_host=payload.ssh_host, + ssh_port=payload.ssh_port, + ssh_username=payload.ssh_username, + ) + for field_name, column in _SSH_SECRET_COLUMNS.items(): + value = getattr(payload, field_name, None) + if value: + setattr(connector, column, encrypt_api_key(value)) + db.add(connector) + db.commit() + db.refresh(connector) + return connector + + +@router.patch("/{connector_id}", response_model=DBConnectorOut) +async def update_connector( + connector_id: UUID, + payload: DBConnectorUpdate, + current_user: User = Depends(manage_connectors), + db: Session = Depends(get_db), +): + check_ticketing_access(db, current_user.organization_id) + connector = _get_connector_or_404(db, connector_id, current_user) + data = payload.model_dump(exclude_unset=True) + require_public_target(*first_hop( + data.get("ssh_enabled", connector.ssh_enabled), + data.get("ssh_host", connector.ssh_host), + data.get("host", connector.host), + )) + password = data.pop("password", None) + if password: + connector.encrypted_password = encrypt_api_key(password) + # SSH secrets map to encrypted_* columns; omitting one keeps the stored value. + for field_name, column in _SSH_SECRET_COLUMNS.items(): + if field_name in data: + value = data.pop(field_name) + if value: + setattr(connector, column, encrypt_api_key(value)) + for key, value in data.items(): + setattr(connector, key, value) + db.commit() + db.refresh(connector) + return connector + + +@router.delete("/{connector_id}", status_code=204) +async def delete_connector( + connector_id: UUID, + current_user: User = Depends(manage_connectors), + db: Session = Depends(get_db), +): + check_ticketing_access(db, current_user.organization_id) + connector = _get_connector_or_404(db, connector_id, current_user) + db.delete(connector) + db.commit() + + +@router.post("/{connector_id}/test", response_model=DBConnectorDiscoverResponse) +async def test_saved_connector( + connector_id: UUID, + current_user: User = Depends(manage_connectors), + db: Session = Depends(get_db), +): + """Test a saved connector with its stored credentials; refreshes the + discoverable table list and records the outcome.""" + check_ticketing_access(db, current_user.organization_id) + connector = _get_connector_or_404(db, connector_id, current_user) + result = await _discover(DBConnectorConfig.from_model(connector)) + connector.last_test_at = datetime.now(timezone.utc) + connector.last_test_ok = result.ok + db.commit() + return result diff --git a/backend/app/api/ticket_webhooks.py b/backend/app/api/ticket_webhooks.py new file mode 100644 index 0000000..a271333 --- /dev/null +++ b/backend/app/api/ticket_webhooks.py @@ -0,0 +1,141 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +Alert-webhook intake: Grafana/Datadog/CloudWatch (or any) alerts POST here +and become tickets (source=api) — investigated proactively, before a customer +reports the issue. Authenticated by a per-org URL secret; near-duplicate open +alerts are appended to the existing ticket instead of opening a new one. +""" + +import secrets as py_secrets +from typing import Optional, Tuple +from uuid import UUID + +from fastapi import APIRouter, Body, Depends, HTTPException +from sqlalchemy.orm import Session +from starlette.concurrency import run_in_threadpool + +from app.core.logger import get_logger +from app.database import get_db +from app.models.ticket import TicketPriority, TicketSource +from app.models.ticket_activity import TicketActivityType, TicketActorType +from app.repositories.ticket import TicketSettingsRepository +from app.services.ticket import TicketService, embed_ticket_text +from app.services.ticket_access import ticketing_allowed +from app.services.ticket_events import emit_ticket_update + +logger = get_logger(__name__) +router = APIRouter() + +# An open ticket at least this similar means the alert is a re-fire. +ALERT_DEDUP_SIMILARITY = 0.95 + +_SEVERITY_TO_PRIORITY = { + "critical": TicketPriority.URGENT, + "disaster": TicketPriority.URGENT, + "error": TicketPriority.HIGH, + "warning": TicketPriority.MEDIUM, + "info": TicketPriority.LOW, +} + + +def _parse_alert(payload: dict) -> Tuple[str, str, Optional[str]]: + """Best-effort extraction across alerting platforms (Grafana webhook, + Alertmanager, Datadog, generic {title, description, severity}).""" + title = ( + payload.get("title") + or payload.get("ruleName") + or payload.get("alert_name") + or payload.get("commonAnnotations", {}).get("summary") + or payload.get("commonLabels", {}).get("alertname") + or "Infrastructure alert" + ) + description = ( + payload.get("description") + or payload.get("message") + or payload.get("body") + or payload.get("commonAnnotations", {}).get("description") + or "" + ) + if not isinstance(description, str): + description = str(description) + severity = ( + payload.get("severity") + or payload.get("priority") + or payload.get("commonLabels", {}).get("severity") + ) + return str(title)[:500], description[:20000], (str(severity).lower() if severity else None) + + +@router.post("/alerts/{org_id}/{secret}", status_code=202) +async def alert_intake( + org_id: UUID, + secret: str, + payload: dict = Body(...), + db: Session = Depends(get_db), +): + settings_row = TicketSettingsRepository(db).get(org_id) + # Uniform 404 for unknown org / disabled intake / wrong secret — the + # endpoint must not confirm which part failed. + if ( + settings_row is None + or not settings_row.alert_webhook_enabled + or not settings_row.alert_webhook_secret + or not py_secrets.compare_digest(settings_row.alert_webhook_secret, secret) + or not ticketing_allowed(db, org_id) + ): + raise HTTPException(status_code=404, detail="Not found") + + title, description, severity = _parse_alert(payload) + priority = _SEVERITY_TO_PRIORITY.get(severity or "", TicketPriority.HIGH) + service = TicketService(db) + + # Re-fired alert → append to the open ticket instead of a duplicate. + embedding = await run_in_threadpool(embed_ticket_text, f"[ALERT] {title}", description) + if embedding is not None: + duplicates = service.repo.find_similar( + org_id, embedding, limit=1, only_open=True, + min_similarity=ALERT_DEDUP_SIMILARITY, + ) + if duplicates: + existing, similarity = duplicates[0] + service._add_activity( + existing, + TicketActivityType.COMMENT, + actor_type=TicketActorType.SYSTEM, + body=f"Alert fired again: {title}", + metadata={"source": "alert_webhook", "similarity": round(similarity, 3)}, + ) + db.commit() + await emit_ticket_update(org_id, existing.id, "comment") + return {"deduplicated": True, "ticket": existing.display_number} + + try: + ticket, _duplicates = await run_in_threadpool( + service.create_ticket, + organization_id=org_id, + title=f"[ALERT] {title}", + description=description or None, + priority=priority, + source=TicketSource.API, + ) + db.commit() + except Exception as e: + db.rollback() + logger.error(f"Alert intake failed for org {org_id}: {e}") + raise HTTPException(status_code=500, detail="Failed to create ticket") + + await emit_ticket_update(org_id, ticket.id, "created") + return {"deduplicated": False, "ticket": ticket.display_number} diff --git a/backend/app/api/tickets.py b/backend/app/api/tickets.py new file mode 100644 index 0000000..ccfab3e --- /dev/null +++ b/backend/app/api/tickets.py @@ -0,0 +1,746 @@ +""" +Copyright 2024-2026 ChatterMate + +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 datetime import datetime, timezone +from typing import List, Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from app.core.auth import check_permissions, get_current_user +from app.core.logger import get_logger +from app.database import get_db +from app.models.investigation import InvestigationRunType, InvestigationTrigger +from app.models.schemas.pagination import Pagination +from app.models.schemas.ticket import ( + HypothesisOut, + InvestigateRequest, + InvestigationDetailResponse, + InvestigationEventOut, + InvestigationRunOut, + ProposalOut, + ProposalRejectRequest, + RCADocumentOut, + RcaUpdate, + TicketCommentCreate, + TicketCreate, + TicketDetailResponse, + TicketListItem, + TicketListResponse, + TicketOut, + TicketReopen, + TicketResolve, + TicketSettingsOut, + TicketSettingsUpdate, + TicketStats, + TicketUpdate, + TicketActivityOut, +) +from app.models.ticket import Ticket, TicketSource, TicketStatus +from app.models.ticket_activity import TicketActorType +from app.models.user import User +from app.repositories.chat import ChatRepository +from app.repositories.session_to_agent import SessionToAgentRepository +from app.services.ticket import TicketService +from app.services.ticket_access import check_ticketing_access +from app.services.ticket_events import emit_ticket_update +from app.core.params import PageNumber + +logger = get_logger(__name__) +router = APIRouter() + +MAX_PAGE_SIZE = 100 + +# A ticket the team has already settled. It can be reopened, but nothing may +# push it forward — notably no new AI run, which would otherwise churn a closed +# ticket and re-notify the customer. Mirrored by isReopenable in +# TicketDetailView.vue, which disables the Investigate button on these. +SETTLED_STATUSES = ( + TicketStatus.RESOLVED.value, + TicketStatus.CLOSED.value, + TicketStatus.RESOLVED_PENDING_CONFIRMATION.value, +) + + +def require_any_permission(*permissions: str): + """Any-of permission dependency (require_permissions is all-of).""" + async def checker(current_user: User = Depends(get_current_user)) -> User: + for permission in permissions: + if check_permissions(current_user, [permission]): + return current_user + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions" + ) + return checker + + +view_tickets = require_any_permission("view_tickets", "manage_tickets") +manage_tickets = require_any_permission("manage_tickets") +# Approving/rejecting AI-proposed resolutions is a separate trust boundary +# from day-to-day ticket management. +approve_actions = require_any_permission("approve_ticket_actions") + + +def _service(db: Session) -> TicketService: + return TicketService(db) + + +def _list_item(service: TicketService, ticket: Ticket, has_active_run: bool, settings=None) -> TicketListItem: + return TicketListItem( + id=ticket.id, + ticket_number=ticket.ticket_number, + display_number=ticket.display_number, + title=ticket.title, + status=ticket.status, + priority=ticket.priority, + tags=ticket.tags, + assignee_user_id=ticket.assignee_user_id, + assignee_name=ticket.assignee.full_name if ticket.assignee else None, + ai_state=service.ai_state(ticket, has_active_run=has_active_run), + sla_due_at=service.sla_due_at(ticket, settings), + resolved_at=ticket.resolved_at, + created_at=ticket.created_at, + updated_at=ticket.updated_at, + ) + + +def _ticket_out(service: TicketService, ticket: Ticket) -> TicketOut: + out = TicketOut.model_validate(ticket) + out.display_number = ticket.display_number + out.ai_state = service.ai_state(ticket) + out.sla_due_at = service.sla_due_at(ticket) + return out + + +def _activity_out(activity) -> TicketActivityOut: + out = TicketActivityOut.model_validate(activity) + if activity.actor_user is not None: + out.actor_name = activity.actor_user.full_name + elif str(activity.actor_type) == TicketActorType.AI.value: + out.actor_name = "ChatterMate AI" + return out + + +def _get_ticket_or_404( + db: Session, ticket_id: UUID, user: User, for_update: bool = False +) -> Ticket: + from app.repositories.ticket import TicketRepository + ticket = TicketRepository(db).get_by_id( + ticket_id, user.organization_id, for_update=for_update + ) + if ticket is None: + raise HTTPException(status_code=404, detail="Ticket not found") + return ticket + + +@router.get("", response_model=TicketListResponse) +async def list_tickets( + status_filter: Optional[str] = Query(default=None, alias="status"), + priority: Optional[str] = None, + assignee_id: Optional[UUID] = None, + unassigned: bool = False, + ai_state: Optional[str] = None, + search: Optional[str] = None, + sort: str = "updated", + page: PageNumber = 1, + page_size: int = Query(default=25, ge=1, le=MAX_PAGE_SIZE), + current_user: User = Depends(view_tickets), + db: Session = Depends(get_db), +): + check_ticketing_access(db, current_user.organization_id) + service = _service(db) + statuses = [s.strip() for s in status_filter.split(",") if s.strip()] if status_filter else None + tickets, total = service.repo.list( + current_user.organization_id, + status=statuses, + priority=priority, + assignee_user_id=assignee_id, + unassigned=unassigned, + ai_state=ai_state, + search=search, + sort=sort, + page=page, + page_size=page_size, + ) + settings = service.settings_repo.get_or_create(current_user.organization_id) + active_ids = service.repo.active_run_ticket_ids([t.id for t in tickets]) + items = [_list_item(service, t, t.id in active_ids, settings) for t in tickets] + return TicketListResponse( + tickets=items, pagination=Pagination.build(total, page, page_size) + ) + + +@router.get("/stats", response_model=TicketStats) +async def ticket_stats( + current_user: User = Depends(view_tickets), + db: Session = Depends(get_db), +): + check_ticketing_access(db, current_user.organization_id) + return TicketStats(**_service(db).stats(current_user.organization_id)) + + +@router.get("/settings", response_model=TicketSettingsOut) +async def get_settings( + current_user: User = Depends(require_any_permission("manage_organization", "manage_tickets")), + db: Session = Depends(get_db), +): + check_ticketing_access(db, current_user.organization_id) + from app.repositories.ticket import TicketSettingsRepository + settings = TicketSettingsRepository(db).get_or_create(current_user.organization_id) + out = TicketSettingsOut.model_validate(settings) + # The alert-webhook secret is the sole credential for the intake endpoint — + # only settings managers (who can rotate it) may read it back. + if not check_permissions(current_user, ["manage_organization"]): + out.alert_webhook_secret = None + return out + + +@router.put("/settings", response_model=TicketSettingsOut) +async def update_settings( + payload: TicketSettingsUpdate, + current_user: User = Depends(require_any_permission("manage_organization")), + db: Session = Depends(get_db), +): + check_ticketing_access(db, current_user.organization_id) + from app.repositories.ticket import TicketSettingsRepository + settings = TicketSettingsRepository(db).get_or_create(current_user.organization_id) + for key, value in payload.model_dump(exclude_unset=True).items(): + setattr(settings, key, value.value if hasattr(value, "value") else value) + # First enable mints the intake secret (re-enabling keeps it stable). + if settings.alert_webhook_enabled and not settings.alert_webhook_secret: + import secrets as py_secrets + settings.alert_webhook_secret = py_secrets.token_urlsafe(24) + db.commit() + db.refresh(settings) + return settings + + +@router.get("/by-session/{session_id}", response_model=Optional[TicketOut]) +async def get_ticket_by_session( + session_id: UUID, + current_user: User = Depends(view_tickets), + db: Session = Depends(get_db), +): + check_ticketing_access(db, current_user.organization_id) + service = _service(db) + ticket = service.repo.get_by_session(session_id) + if ticket is None or ticket.organization_id != current_user.organization_id: + return None + return _ticket_out(service, ticket) + + +@router.get("/draft-from-session/{session_id}") +async def draft_from_session( + session_id: UUID, + current_user: User = Depends(view_tickets), + db: Session = Depends(get_db), +): + """Non-LLM prefill for the create-ticket modal: title from the customer's + last message, description from the recent transcript. AI triage rewrites + the title after creation.""" + check_ticketing_access(db, current_user.organization_id) + # Cross-org guard: the transcript is customer data — the session must + # belong to the caller's organization. + session_record = SessionToAgentRepository(db).get_session(session_id) + if session_record is None or str(session_record.organization_id) != str(current_user.organization_id): + raise HTTPException(status_code=404, detail="Session not found") + + service = _service(db) + messages = (await ChatRepository(db).get_session_history(session_id))[-20:] + customer_messages = [m for m in messages if m.message_type == "user"] + title = "" + if customer_messages: + title = (customer_messages[-1].message or "").strip().split("\n")[0][:200] + transcript = await service.build_session_transcript(session_id, max_messages=10, line_chars=300) + return { + "title": title or "Support issue from conversation", + "description": (transcript or "")[:5000], + } + + +def _validate_assignment_references(db: Session, org_id, assignee_user_id, group_id) -> None: + """A ticket may only be assigned to a user or group in the caller's org. + + Both create and update run this, so a ticket (and its assignment activity + log) can never point at a user or group from another tenant. + """ + if assignee_user_id is not None: + assignee = db.query(User).filter(User.id == assignee_user_id).first() + if assignee is None or str(assignee.organization_id) != str(org_id): + raise HTTPException(status_code=404, detail="Assignee not found") + if group_id is not None: + from app.models.user import UserGroup + group = db.query(UserGroup).filter(UserGroup.id == group_id).first() + if group is None or str(group.organization_id) != str(org_id): + raise HTTPException(status_code=404, detail="Group not found") + + +def _validate_create_references(db: Session, payload: TicketCreate, org_id) -> None: + """Every referenced entity must belong to the caller's organization.""" + if payload.customer_id is not None: + from app.models.customer import Customer + customer = db.query(Customer).filter(Customer.id == payload.customer_id).first() + if customer is None or str(customer.organization_id) != str(org_id): + raise HTTPException(status_code=404, detail="Customer not found") + _validate_assignment_references( + db, org_id, payload.assignee_user_id, payload.group_id) + + +@router.post("", response_model=TicketDetailResponse, status_code=201) +async def create_ticket( + payload: TicketCreate, + current_user: User = Depends(manage_tickets), + db: Session = Depends(get_db), +): + check_ticketing_access(db, current_user.organization_id) + _validate_create_references(db, payload, current_user.organization_id) + service = _service(db) + settings = service.settings_repo.get_or_create(current_user.organization_id) + try: + # Threadpool: create_ticket embeds the title/description with a local + # CPU-bound model — must not block the event loop. + from starlette.concurrency import run_in_threadpool + ticket, duplicates = await run_in_threadpool( + service.create_ticket, + organization_id=current_user.organization_id, + title=payload.title, + description=payload.description, + priority=payload.priority, + severity=payload.severity, + tags=payload.tags, + source=TicketSource.HUMAN_AGENT if payload.session_id else TicketSource.MANUAL, + customer_id=payload.customer_id, + customer_email=payload.customer_email, + customer_name=payload.customer_name, + session_id=payload.session_id, + assignee_user_id=payload.assignee_user_id, + group_id=payload.group_id, + created_by_user_id=current_user.id, + ) + db.commit() + except ValueError as e: + db.rollback() + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + db.rollback() + logger.error(f"Ticket creation failed: {e}") + raise HTTPException(status_code=500, detail="Failed to create ticket") + + db.refresh(ticket) + await service.notify_customer_created(ticket, settings) + db.commit() + await emit_ticket_update(current_user.organization_id, ticket.id, "created") + return TicketDetailResponse( + ticket=_ticket_out(service, ticket), + activities=[_activity_out(a) for a in service.activity_repo.list_for_ticket(ticket.id)], + runs=[InvestigationRunOut.model_validate(r) for r in service.run_repo.list_for_ticket(ticket.id)], + linked_session_ids=service.repo.get_session_ids(ticket.id), + possible_duplicates=[ + _list_item(service, t, False, settings) for t, _score in duplicates + ], + can_notify_customer=service.can_notify_customer(ticket), + ) + + +@router.get("/{ticket_id}", response_model=TicketDetailResponse) +async def get_ticket( + ticket_id: UUID, + current_user: User = Depends(view_tickets), + db: Session = Depends(get_db), +): + check_ticketing_access(db, current_user.organization_id) + service = _service(db) + ticket = _get_ticket_or_404(db, ticket_id, current_user) + return TicketDetailResponse( + ticket=_ticket_out(service, ticket), + activities=[_activity_out(a) for a in service.activity_repo.list_for_ticket(ticket.id)], + runs=[InvestigationRunOut.model_validate(r) for r in service.run_repo.list_for_ticket(ticket.id)], + linked_session_ids=service.repo.get_session_ids(ticket.id), + can_notify_customer=service.can_notify_customer(ticket), + ) + + +@router.patch("/{ticket_id}", response_model=TicketOut) +async def update_ticket( + ticket_id: UUID, + payload: TicketUpdate, + current_user: User = Depends(manage_tickets), + db: Session = Depends(get_db), +): + check_ticketing_access(db, current_user.organization_id) + service = _service(db) + ticket = _get_ticket_or_404(db, ticket_id, current_user) + data = payload.model_dump(exclude_unset=True) + try: + if "status" in data: + service.transition_status( + ticket, data.pop("status"), + actor_type=TicketActorType.USER, actor_user_id=current_user.id, + ) + if "priority" in data: + service.set_priority( + ticket, data.pop("priority"), + actor_type=TicketActorType.USER, actor_user_id=current_user.id, + ) + if "assignee_user_id" in data or "group_id" in data: + new_assignee = data.pop("assignee_user_id", ticket.assignee_user_id) + new_group = data.pop("group_id", ticket.group_id) + # Same org guard as create — the update path bypassed it entirely. + _validate_assignment_references( + db, current_user.organization_id, new_assignee, new_group) + service.assign( + ticket, + new_assignee, + new_group, + actor_user_id=current_user.id, + ) + customer_email = data.pop("customer_email", None) + customer_name = data.pop("customer_name", None) + if customer_email: + service.set_customer( + ticket, customer_email, customer_name, actor_user_id=current_user.id + ) + for key, value in data.items(): + setattr(ticket, key, value.value if hasattr(value, "value") else value) + from app.services.ticket_jira import maybe_escalate_to_jira + await maybe_escalate_to_jira( + db, service, ticket, service.settings_repo.get_or_create(current_user.organization_id) + ) + db.commit() + except ValueError as e: + db.rollback() + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + db.rollback() + logger.error(f"Ticket update failed: {e}") + raise HTTPException(status_code=500, detail="Failed to update ticket") + db.refresh(ticket) + await emit_ticket_update( + current_user.organization_id, ticket.id, "updated", + {"status": str(ticket.status), "priority": str(ticket.priority)}, + ) + return _ticket_out(service, ticket) + + +@router.post("/{ticket_id}/comments", response_model=TicketActivityOut, status_code=201) +async def add_comment( + ticket_id: UUID, + payload: TicketCommentCreate, + current_user: User = Depends(view_tickets), + db: Session = Depends(get_db), +): + check_ticketing_access(db, current_user.organization_id) + # Customer-visible replies are a customer-facing action, not a read: + # view-only roles may add internal notes but never message customers. + if not payload.is_internal and not check_permissions(current_user, ["manage_tickets"]): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Sending customer-visible replies requires manage_tickets", + ) + service = _service(db) + ticket = _get_ticket_or_404(db, ticket_id, current_user) + activity = service.add_comment( + ticket, + payload.body, + is_internal=payload.is_internal, + actor_type=TicketActorType.USER, + actor_user_id=current_user.id, + ) + if not payload.is_internal: + # The comment row above is the timeline entry; skip the extra + # CUSTOMER_NOTIFIED activity so the reply isn't duplicated. + await service.send_customer_message(ticket, payload.body, record_activity=False) + db.commit() + db.refresh(activity) + await emit_ticket_update(current_user.organization_id, ticket.id, "comment") + return _activity_out(activity) + + +@router.post("/{ticket_id}/resolve", response_model=TicketOut) +async def resolve_ticket( + ticket_id: UUID, + payload: TicketResolve, + current_user: User = Depends(manage_tickets), + db: Session = Depends(get_db), +): + check_ticketing_access(db, current_user.organization_id) + service = _service(db) + # Lock the row so a double-submitted resolve serializes: the second request + # blocks until the first commits, then sees RESOLVED_PENDING_CONFIRMATION + # and skips re-notifying the customer. + ticket = _get_ticket_or_404(db, ticket_id, current_user, for_update=True) + try: + await service.resolve( + ticket, + outcome=payload.outcome, + resolution_summary=payload.resolution_summary, + customer_message=payload.customer_message, + actor_type=TicketActorType.USER, + actor_user_id=current_user.id, + ) + db.commit() + except ValueError as e: + db.rollback() + raise HTTPException(status_code=400, detail=str(e)) + db.refresh(ticket) + await emit_ticket_update( + current_user.organization_id, ticket.id, "status", {"status": str(ticket.status)} + ) + return _ticket_out(service, ticket) + + +@router.post("/{ticket_id}/reopen", response_model=TicketOut) +async def reopen_ticket( + ticket_id: UUID, + payload: TicketReopen, + current_user: User = Depends(manage_tickets), + db: Session = Depends(get_db), +): + check_ticketing_access(db, current_user.organization_id) + service = _service(db) + ticket = _get_ticket_or_404(db, ticket_id, current_user) + if str(ticket.status) not in SETTLED_STATUSES: + raise HTTPException(status_code=400, detail="Only resolved or closed tickets can be reopened") + service.reopen( + ticket, reason=payload.reason, + actor_type=TicketActorType.USER, actor_user_id=current_user.id, + ) + db.commit() + db.refresh(ticket) + await emit_ticket_update( + current_user.organization_id, ticket.id, "status", {"status": str(ticket.status)} + ) + return _ticket_out(service, ticket) + + +@router.post("/{ticket_id}/investigate", response_model=InvestigationRunOut, status_code=201) +async def investigate_ticket( + ticket_id: UUID, + payload: Optional[InvestigateRequest] = None, + current_user: User = Depends(manage_tickets), + db: Session = Depends(get_db), +): + """Manually (re-)enqueue an AI run — a full investigation by default.""" + check_ticketing_access(db, current_user.organization_id) + service = _service(db) + ticket = _get_ticket_or_404(db, ticket_id, current_user) + if str(ticket.status) in SETTLED_STATUSES: + raise HTTPException( + status_code=409, + detail="This ticket is resolved — reopen it before running another investigation", + ) + payload = payload or InvestigateRequest() + # Distinguish "out of credits" (402, actionable — upgrade) from the generic + # 409 below, for a human clicking Investigate. + if payload.run_type == InvestigationRunType.INVESTIGATION and service._investigation_over_budget(ticket): + raise HTTPException( + status_code=402, + detail=( + "Not enough message credits left in your plan to run an AI " + "investigation. Upgrade your plan or switch to your own AI model key." + ), + ) + run = service.enqueue_run( + ticket, + run_type=payload.run_type, + trigger=InvestigationTrigger.MANUAL, + requested_by_user_id=current_user.id, + context_note=payload.context_note, + ) + if run is None: + raise HTTPException( + status_code=409, + detail="An AI run is already active for this ticket, or its run limit was reached", + ) + db.commit() + db.refresh(run) + await emit_ticket_update(current_user.organization_id, ticket.id, "run", {"status": "pending"}) + return InvestigationRunOut.model_validate(run) + + +@router.get("/{ticket_id}/investigation", response_model=InvestigationDetailResponse) +async def get_investigation( + ticket_id: UUID, + run_id: Optional[UUID] = None, + current_user: User = Depends(view_tickets), + db: Session = Depends(get_db), +): + """Glass-box payload: the latest (or requested) investigation run with its + hypotheses, evidence events and the ticket's current RCA document.""" + check_ticketing_access(db, current_user.organization_id) + service = _service(db) + ticket = _get_ticket_or_404(db, ticket_id, current_user) + + run = None + if run_id is not None: + run = service.run_repo.get_by_id(run_id) + # The run must belong to this ticket — run_id alone is not an + # authorization boundary. + if run is None or run.ticket_id != ticket.id: + raise HTTPException(status_code=404, detail="Run not found") + else: + run = service.run_repo.latest_run_of_type( + ticket.id, InvestigationRunType.INVESTIGATION.value + ) + + rca = service.run_repo.latest_rca(ticket.id) + return InvestigationDetailResponse( + run=InvestigationRunOut.model_validate(run) if run else None, + hypotheses=[ + HypothesisOut.model_validate(h) for h in service.run_repo.list_hypotheses(run.id) + ] if run else [], + events=[ + InvestigationEventOut.model_validate(e) for e in service.run_repo.list_events(run.id) + ] if run else [], + rca=_rca_out(rca), + proposal=_proposal_out(service.latest_proposal(ticket.id)), + ) + + +def _proposal_out(proposal) -> Optional[ProposalOut]: + if proposal is None: + return None + out = ProposalOut.model_validate(proposal) + if proposal.decided_by is not None: + out.decided_by_name = proposal.decided_by.full_name + return out + + +@router.post("/{ticket_id}/proposal/approve", response_model=ProposalOut) +async def approve_proposal( + ticket_id: UUID, + current_user: User = Depends(approve_actions), + db: Session = Depends(get_db), +): + """Approve the pending AI-proposed resolution: the ticket resolves and + the customer is notified with the proposal's message.""" + check_ticketing_access(db, current_user.organization_id) + service = _service(db) + ticket = _get_ticket_or_404(db, ticket_id, current_user) + proposal = service.pending_proposal(ticket.id) + if proposal is None: + raise HTTPException(status_code=404, detail="No pending proposal for this ticket") + try: + await service.approve_proposal(ticket, proposal, current_user.id) + db.commit() + except ValueError as e: + db.rollback() + raise HTTPException(status_code=400, detail=str(e)) + db.refresh(proposal) + await emit_ticket_update( + current_user.organization_id, ticket.id, "status", {"status": str(ticket.status)} + ) + return _proposal_out(proposal) + + +@router.post("/{ticket_id}/proposal/reject", response_model=ProposalOut) +async def reject_proposal( + ticket_id: UUID, + payload: ProposalRejectRequest, + current_user: User = Depends(approve_actions), + db: Session = Depends(get_db), +): + """Reject the pending proposal; optionally re-run the investigation with + the rejection reason as context.""" + check_ticketing_access(db, current_user.organization_id) + service = _service(db) + ticket = _get_ticket_or_404(db, ticket_id, current_user) + proposal = service.pending_proposal(ticket.id) + if proposal is None: + raise HTTPException(status_code=404, detail="No pending proposal for this ticket") + try: + service.reject_proposal( + ticket, proposal, current_user.id, payload.reason, + reinvestigate=payload.reinvestigate, + ) + db.commit() + except ValueError as e: + db.rollback() + raise HTTPException(status_code=400, detail=str(e)) + db.refresh(proposal) + await emit_ticket_update( + current_user.organization_id, ticket.id, "status", {"status": str(ticket.status)} + ) + return _proposal_out(proposal) + + +def _rca_out(rca) -> Optional[RCADocumentOut]: + if rca is None: + return None + out = RCADocumentOut.model_validate(rca) + if rca.reviewed_by is not None: + out.reviewed_by_name = rca.reviewed_by.full_name + return out + + +@router.patch("/{ticket_id}/rca", response_model=RCADocumentOut) +async def update_rca( + ticket_id: UUID, + payload: RcaUpdate, + current_user: User = Depends(manage_tickets), + db: Session = Depends(get_db), +): + """Edit the RCA's customer summary and/or stamp the reviewer byline.""" + check_ticketing_access(db, current_user.organization_id) + service = _service(db) + ticket = _get_ticket_or_404(db, ticket_id, current_user) + rca = service.run_repo.latest_rca(ticket.id) + if rca is None: + raise HTTPException(status_code=404, detail="No RCA document for this ticket") + if payload.customer_summary is not None: + rca.customer_summary = payload.customer_summary + if payload.mark_reviewed: + rca.reviewed_by_user_id = current_user.id + rca.reviewed_at = datetime.now(timezone.utc) + db.commit() + db.refresh(rca) + return _rca_out(rca) + + +@router.post("/{ticket_id}/rca/send-customer", response_model=TicketActivityOut) +async def send_rca_to_customer( + ticket_id: UUID, + current_user: User = Depends(manage_tickets), + db: Session = Depends(get_db), +): + """Send the RCA's customer summary to the customer (a manual human action + at autonomy L1/L2). Stamps the sender as reviewer.""" + check_ticketing_access(db, current_user.organization_id) + service = _service(db) + ticket = _get_ticket_or_404(db, ticket_id, current_user) + rca = service.run_repo.latest_rca(ticket.id) + if rca is None or not (rca.customer_summary or "").strip(): + raise HTTPException(status_code=404, detail="No customer summary to send") + if not service.can_notify_customer(ticket): + raise HTTPException( + status_code=400, + detail="No customer channel — link a conversation or set a customer email first", + ) + rca.reviewed_by_user_id = current_user.id + rca.reviewed_at = datetime.now(timezone.utc) + activity = service.add_comment( + ticket, + rca.customer_summary, + is_internal=False, + actor_type=TicketActorType.USER, + actor_user_id=current_user.id, + ) + await service.send_customer_message(ticket, rca.customer_summary, record_activity=False) + db.commit() + db.refresh(activity) + await emit_ticket_update(current_user.organization_id, ticket.id, "comment") + return _activity_out(activity) diff --git a/backend/app/api/webhooks/__init__.py b/backend/app/api/webhooks/__init__.py new file mode 100644 index 0000000..117b51e --- /dev/null +++ b/backend/app/api/webhooks/__init__.py @@ -0,0 +1,34 @@ +""" +Copyright 2024-2026 ChatterMate + +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 fastapi import APIRouter + +from app.api.webhooks.telegram import router as telegram_router +from app.api.webhooks.slack import router as slack_router +from app.api.webhooks.email import router as email_router +from app.api.webhooks.sms import router as sms_router +from app.api.webhooks.line import router as line_router +from app.api.webhooks.meta import router as meta_router + +# Aggregates every channel's webhook routes under /webhooks +router = APIRouter() +router.include_router(telegram_router, prefix="/telegram", tags=["webhooks"]) +router.include_router(slack_router, prefix="/slack", tags=["webhooks"]) +router.include_router(email_router, prefix="/email", tags=["webhooks"]) +router.include_router(sms_router, prefix="/sms", tags=["webhooks"]) +router.include_router(line_router, prefix="/line", tags=["webhooks"]) +# One endpoint for all three Meta products (WhatsApp / Messenger / Instagram) +router.include_router(meta_router, prefix="/meta", tags=["webhooks"]) diff --git a/backend/app/api/webhooks/common.py b/backend/app/api/webhooks/common.py new file mode 100644 index 0000000..fbd550f --- /dev/null +++ b/backend/app/api/webhooks/common.py @@ -0,0 +1,42 @@ +""" +Copyright 2024-2026 ChatterMate + +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.core.redis import get_redis +from app.core.logger import get_logger + +logger = get_logger(__name__) + +# Platforms redeliver webhooks on slow acks/timeouts; remember processed +# message ids long enough to absorb every retry cycle. +DEDUPE_TTL_SECONDS = 60 * 60 + + +def is_duplicate_message(channel_type: str, external_message_id: str) -> bool: + """Atomically claim an inbound message id (Redis SETNX). Returns True when + the message was already processed. Without Redis, dedupe is skipped — + single-process deployments rarely see retries fast enough to matter.""" + if not external_message_id: + return False + try: + redis_client = get_redis() + if redis_client is None: + return False + key = f"channel_dedupe:{channel_type}:{external_message_id}" + claimed = redis_client.set(key, "1", nx=True, ex=DEDUPE_TTL_SECONDS) + return not claimed + except Exception as e: + logger.error(f"Dedupe check failed (processing anyway): {e}") + return False diff --git a/backend/app/api/webhooks/email.py b/backend/app/api/webhooks/email.py new file mode 100644 index 0000000..6a8e1e3 --- /dev/null +++ b/backend/app/api/webhooks/email.py @@ -0,0 +1,78 @@ +""" +Copyright 2024-2026 ChatterMate + +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 uuid import UUID + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request +from sqlalchemy.orm import Session + +from app.api.webhooks.common import is_duplicate_message +from app.channels import get_adapter +from app.channels.email import verify_webhook_token +from app.core.logger import get_logger +from app.database import get_db +from app.models.channels import ChannelType +from app.repositories.channels import ChannelAccountRepository +from app.services.channel_chat import process_channel_message +from app.services.ticket_email_reply import find_ticket_for_reply, record_ticket_email_reply + +router = APIRouter() +logger = get_logger(__name__) + + +@router.post("/{account_id}") +async def email_webhook( + account_id: UUID, + request: Request, + background_tasks: BackgroundTasks, + token: str = Query(""), + db: Session = Depends(get_db), +): + """Inbound-parse webhook (point SendGrid/Brevo/your forwarder here with + the account's ?token=). Accepts JSON or multipart/urlencoded form.""" + account = ChannelAccountRepository(db).get_by_id(account_id) + if account is None or account.channel_type != ChannelType.EMAIL.value: + raise HTTPException(status_code=404, detail="Unknown account") + if not verify_webhook_token(account, token): + raise HTTPException(status_code=403, detail="Invalid token") + + content_type = request.headers.get("content-type", "") + if "application/json" in content_type: + payload = await request.json() + else: + form = await request.form() + payload = {key: str(value) for key, value in form.items()} + + adapter = get_adapter(ChannelType.EMAIL.value) + for inbound in adapter.parse_inbound(payload): + # A forward-all inbox can echo our own outbound reply back — never + # answer mail from the connected address itself. + if inbound.external_user_id == account.external_account_id: + logger.info(f"Ignoring self-addressed email on {account.external_account_id}") + continue + if is_duplicate_message(ChannelType.EMAIL.value, + f"{account.id}:{inbound.external_message_id}"): + continue + # A reply to a ticket notification belongs on the ticket, not in a + # chat session — the customer is answering the support team. + ticket = find_ticket_for_reply(db, account.organization_id, inbound.profile) + if ticket is not None: + background_tasks.add_task( + record_ticket_email_reply, account.organization_id, ticket.id, inbound) + continue + background_tasks.add_task(process_channel_message, account.id, inbound) + + return {"status": "ok"} diff --git a/backend/app/api/webhooks/line.py b/backend/app/api/webhooks/line.py new file mode 100644 index 0000000..d8f912b --- /dev/null +++ b/backend/app/api/webhooks/line.py @@ -0,0 +1,58 @@ +""" +Copyright 2024-2026 ChatterMate + +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 uuid import UUID + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request +from sqlalchemy.orm import Session + +from app.api.webhooks.common import is_duplicate_message +from app.channels import get_adapter +from app.core.logger import get_logger +from app.database import get_db +from app.models.channels import ChannelType +from app.repositories.channels import ChannelAccountRepository +from app.services.channel_chat import process_channel_message + +router = APIRouter() +logger = get_logger(__name__) + + +@router.post("/{account_id}") +async def line_webhook( + account_id: UUID, + request: Request, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), +): + """LINE Messaging API webhook, verified via X-Line-Signature.""" + account = ChannelAccountRepository(db).get_by_id(account_id) + if account is None or account.channel_type != ChannelType.LINE.value: + raise HTTPException(status_code=404, detail="Unknown account") + + adapter = get_adapter(ChannelType.LINE.value) + raw_body = await request.body() + if not await adapter.verify_webhook(dict(request.headers), raw_body, account): + raise HTTPException(status_code=403, detail="Invalid signature") + + payload = await request.json() + for inbound in adapter.parse_inbound(payload): + if is_duplicate_message(ChannelType.LINE.value, + f"{account.id}:{inbound.external_message_id}"): + continue + background_tasks.add_task(process_channel_message, account.id, inbound) + + return {"status": "ok"} diff --git a/backend/app/api/webhooks/meta.py b/backend/app/api/webhooks/meta.py new file mode 100644 index 0000000..f1e7a9d --- /dev/null +++ b/backend/app/api/webhooks/meta.py @@ -0,0 +1,187 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import hmac +from typing import Callable, Optional +from uuid import UUID + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response +from sqlalchemy.orm import Session + +from app.api.webhooks.common import is_duplicate_message +from app.channels import get_adapter +from app.channels.meta_base import signed_by_any, verify_meta_signature, verify_challenge +from app.services.meta_own_app import is_own_app, own_app_secret, same_app_accounts +from app.core.logger import get_logger +from app.database import get_db +from app.models.channels import ChannelAccount, ChannelType +from app.repositories.channels import ChannelAccountRepository +from app.services.channel_chat import process_channel_message +from app.services.channel_status import process_status_event + +router = APIRouter() +logger = get_logger(__name__) + +# One Meta app subscribes WhatsApp + Messenger + Instagram to this single +# callback; the payload's top-level `object` says which product it is. +_OBJECT_TO_CHANNEL = { + "whatsapp_business_account": ChannelType.WHATSAPP.value, + "page": ChannelType.MESSENGER.value, + "instagram": ChannelType.INSTAGRAM.value, +} + + +@router.get("") +async def meta_webhook_verify( + hub_mode: str = Query(None, alias="hub.mode"), + hub_verify_token: str = Query(None, alias="hub.verify_token"), + hub_challenge: str = Query(None, alias="hub.challenge"), +): + """Webhook verification handshake (App Dashboard → Webhooks → Verify).""" + challenge = verify_challenge(hub_mode, hub_verify_token, hub_challenge) + if challenge is None: + raise HTTPException(status_code=403, detail="Verification failed") + # Meta expects the raw challenge echoed as text/plain + return Response(content=challenge, media_type="text/plain") + + +@router.post("") +async def meta_webhook( + request: Request, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), +): + """Receive a Meta webhook for any of WhatsApp / Messenger / Instagram. + + Verifies the app-level signature, routes on the `object` field, resolves + the connected account per message, acks immediately, and processes in the + background. + """ + raw_body = await request.body() + if not verify_meta_signature(raw_body, request.headers.get("x-hub-signature-256", "")): + # Logged so a rejected delivery is distinguishable from one that never + # arrived; the body is untrusted and unlogged. + logger.warning(f"Meta webhook rejected: bad signature ({len(raw_body)} bytes)") + raise HTTPException(status_code=403, detail="Invalid signature") + + payload = await request.json() + channel_type = _OBJECT_TO_CHANNEL.get(payload.get("object")) + if channel_type is None: + # Unknown product subscription — ack so Meta doesn't retry forever + return {"status": "ignored"} + + account_repo = ChannelAccountRepository(db) + _queue_events(background_tasks, channel_type, payload, + lambda external_id: account_repo.get_by_external_id(channel_type, external_id)) + return {"status": "ok"} + + +@router.get("/{account_id}") +async def own_app_webhook_verify( + account_id: UUID, + hub_mode: str = Query(None, alias="hub.mode"), + hub_verify_token: str = Query(None, alias="hub.verify_token"), + hub_challenge: str = Query(None, alias="hub.challenge"), + db: Session = Depends(get_db), +): + """Verification handshake for an account connected from the customer's + own Meta app: its verify token is the account's webhook secret.""" + account = _own_app_account(db, account_id) + if (account is None or hub_mode != "subscribe" or not hub_verify_token + or not hmac.compare_digest(hub_verify_token.encode(), account.webhook_secret.encode())): + raise HTTPException(status_code=403, detail="Verification failed") + return Response(content=hub_challenge or "", media_type="text/plain") + + +@router.post("/{account_id}") +async def own_app_webhook( + account_id: UUID, + request: Request, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), +): + """A webhook for an account connected from the customer's own Meta app. + + Checked with that app's secret only, and only taken for this + organization's accounts from the same app: the secret is one the customer + chose, so it must never vouch for anyone else's. + """ + raw_body = await request.body() + account = _own_app_account(db, account_id) + secret = _readable_secret(account) + if secret is None: + # Disconnected, never ours, or its secret can't be read: ack, so Meta + # doesn't disable the customer's webhook over retries — nothing is acted on + return {"status": "ignored"} + if not signed_by_any(raw_body, request.headers.get("x-hub-signature-256", ""), (secret,)): + logger.warning(f"Meta webhook for account {account_id} rejected: bad signature ({len(raw_body)} bytes)") + raise HTTPException(status_code=403, detail="Invalid signature") + + payload = await request.json() + if _OBJECT_TO_CHANNEL.get(payload.get("object")) != account.channel_type: + return {"status": "ignored"} + _queue_events(background_tasks, account.channel_type, payload, same_app_accounts(db, account)) + return {"status": "ok"} + + +def _own_app_account(db: Session, account_id: UUID) -> Optional[ChannelAccount]: + account = ChannelAccountRepository(db).get_by_id(account_id) + return account if account is not None and account.is_active and is_own_app(account) else None + + +def _readable_secret(account: Optional[ChannelAccount]) -> Optional[str]: + if account is None: + return None + try: + return own_app_secret(account) + except Exception: + logger.exception(f"Could not read the app secret of Meta account {account.id}") + return None + + +def _queue_events(background_tasks: BackgroundTasks, channel_type: str, payload: dict, + resolve: Callable[[str], Optional[ChannelAccount]]) -> None: + """Hand each message and receipt in a webhook to its account's processor; + `resolve` names the account an external id belongs to, if any.""" + adapter = get_adapter(channel_type) + # Meta batches many events per number; resolve each account once + accounts = {} + + def account_for(external_id: str) -> Optional[ChannelAccount]: + if external_id not in accounts: + accounts[external_id] = resolve(external_id) + return accounts[external_id] + + for inbound in adapter.parse_inbound(payload): + account = account_for(inbound.external_account_id) + if account is None or not account.is_active: + logger.info(f"No active {channel_type} account for {inbound.external_account_id}") + continue + if is_duplicate_message(channel_type, + f"{account.id}:{inbound.external_message_id}"): + logger.info(f"Skipping duplicate {channel_type} message {inbound.external_message_id}") + continue + background_tasks.add_task(process_channel_message, account.id, inbound) + + for event in adapter.parse_status_events(payload): + account = account_for(event.external_account_id) + if account is None or not account.is_active: + continue + # Meta redelivers receipts; one per message and state is enough. + if is_duplicate_message(channel_type, f"{account.id}:{event.external_message_id}:" + f"{event.status.value}"): + continue + background_tasks.add_task(process_status_event, account.id, event) diff --git a/backend/app/api/webhooks/slack.py b/backend/app/api/webhooks/slack.py new file mode 100644 index 0000000..e973f9f --- /dev/null +++ b/backend/app/api/webhooks/slack.py @@ -0,0 +1,175 @@ +""" +Copyright 2024-2026 ChatterMate + +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 fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request +from sqlalchemy.orm import Session + +from app.api.webhooks.common import is_duplicate_message +from app.channels import get_adapter +from app.channels.slack import verify_slack_signature +from app.core.logger import get_logger +from app.database import get_db +from app.models.channels import ChannelType +from app.repositories.channels import ChannelAccountRepository +from app.services.channel_chat import process_channel_message +from app.services.slack_events import ( + deliver_home_welcome, + handle_assistant_thread_started, + publish_agent_home, +) + +router = APIRouter() +logger = get_logger(__name__) + +# Workspace-level events that end our access: the app was removed, or its +# tokens were revoked. Both mean the stored credentials are dead. +LIFECYCLE_EVENT_TYPES = frozenset({"app_uninstalled", "tokens_revoked"}) + +# App Home / Assistant surfaces — not messages, so parse_inbound drops them. +# Handled off the envelope and dispatched to background tasks. +APP_HOME_OPENED = "app_home_opened" +ASSISTANT_THREAD_STARTED = "assistant_thread_started" +ASSISTANT_CONTEXT_CHANGED = "assistant_thread_context_changed" +APP_SURFACE_EVENT_TYPES = frozenset( + {APP_HOME_OPENED, ASSISTANT_THREAD_STARTED, ASSISTANT_CONTEXT_CHANGED} +) + + +def _revokes_bot_access(event: dict) -> bool: + """Whether the event actually kills the credentials we hold. + + An uninstall always does. tokens_revoked also fires when a *user* token + is revoked, which leaves the bot working — we only ever store a bot + token, so acting on those would disconnect a healthy workspace. + """ + if event.get("type") == "app_uninstalled": + return True + return bool((event.get("tokens") or {}).get("bot")) + + +def _handle_lifecycle_event(payload: dict, account_repo: ChannelAccountRepository) -> None: + """Drop the workspace's stored credentials after an uninstall or revoke. + + Deleting the account closes any open sessions and cascades to its + conversations and agent config, so no agent is left holding a + conversation on a channel we can no longer reach. + """ + if not _revokes_bot_access(payload.get("event") or {}): + logger.info("Slack tokens_revoked without a bot token; keeping the account") + return + + team_id = payload.get("team_id", "") + if not team_id: + logger.warning("Slack lifecycle event without a team_id; ignoring") + return + + account = account_repo.get_by_external_id(ChannelType.SLACK.value, team_id) + if account is None: + # Already disconnected our side, or a workspace we never stored. + logger.info(f"Slack lifecycle event for unknown team {team_id}") + return + + # delete() reports failure by returning False rather than raising. + if account_repo.delete(account): + logger.info(f"Removed Slack account for team {team_id} after lifecycle event") + else: + logger.error(f"Failed to remove Slack account for team {team_id}") + + +def _dispatch_app_surface_event(event: dict, team_id: str, + account_repo: ChannelAccountRepository, + background_tasks: BackgroundTasks) -> None: + """Fan an App Home / Assistant event out to the right background handler. + + The Home tab publishes agent cards; the Messages tab and the Assistant + sidebar both send a welcome — this is what lets the app keep the Messages + tab enabled under the Marketplace guidelines. + """ + if not team_id: + return + account = account_repo.get_by_external_id(ChannelType.SLACK.value, team_id) + if account is None or not account.is_active: + logger.info(f"Slack app-surface event for inactive/unknown team {team_id}") + return + + event_type = event.get("type") + if event_type == APP_HOME_OPENED: + user = event.get("user", "") + if event.get("tab", "messages") == "home": + background_tasks.add_task(publish_agent_home, account.id, user) + else: # Messages tab (or unspecified) — welcome on first open. + background_tasks.add_task(deliver_home_welcome, account.id, user, event.get("channel", "")) + elif event_type == ASSISTANT_THREAD_STARTED: + thread = event.get("assistant_thread") or {} + background_tasks.add_task(handle_assistant_thread_started, account.id, + thread.get("channel_id", ""), thread.get("thread_ts", "")) + # assistant_thread_context_changed: nothing to do — prompts are set on start. + + +@router.post("") +async def slack_webhook( + request: Request, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), +): + """Slack Events API endpoint: URL verification handshake plus @mention and + DM events, verified against the app signing secret, acked immediately and + processed in the background.""" + raw_body = await request.body() + + # Verify before parsing — unsigned garbage gets a clean 403, not a 500. + if not verify_slack_signature(dict(request.headers), raw_body): + raise HTTPException(status_code=403, detail="Invalid signature") + + payload = await request.json() + + if payload.get("type") == "url_verification": + return {"challenge": payload.get("challenge")} + + if payload.get("type") != "event_callback": + return {"status": "ignored"} + + account_repo = ChannelAccountRepository(db) + + event = payload.get("event") or {} + event_type = event.get("type") + + # Lifecycle events carry no message, so parse_inbound drops them — resolve + # the workspace off the envelope instead, before the message loop. + if event_type in LIFECYCLE_EVENT_TYPES: + _handle_lifecycle_event(payload, account_repo) + return {"status": "ok"} + + # App Home / Assistant surfaces (welcome, home cards) — also not messages. + if event_type in APP_SURFACE_EVENT_TYPES: + _dispatch_app_surface_event(event, payload.get("team_id", ""), account_repo, background_tasks) + return {"status": "ok"} + + adapter = get_adapter(ChannelType.SLACK.value) + + for inbound in adapter.parse_inbound(payload): + account = account_repo.get_by_external_id(ChannelType.SLACK.value, inbound.external_account_id) + if account is None or not account.is_active: + logger.info(f"No active Slack account for team {inbound.external_account_id}") + continue + # event_id is globally unique; Slack redelivers with X-Slack-Retry-Num + if is_duplicate_message(ChannelType.SLACK.value, + f"{account.id}:{inbound.external_message_id}"): + logger.info(f"Skipping duplicate Slack event {inbound.external_message_id}") + continue + background_tasks.add_task(process_channel_message, account.id, inbound) + + return {"status": "ok"} diff --git a/backend/app/api/webhooks/sms.py b/backend/app/api/webhooks/sms.py new file mode 100644 index 0000000..f6561be --- /dev/null +++ b/backend/app/api/webhooks/sms.py @@ -0,0 +1,114 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import json as jsonlib +from uuid import UUID + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response +from sqlalchemy.orm import Session + +from app.api.webhooks.common import is_duplicate_message +from app.channels.email import verify_webhook_token +from app.channels.sms import get_provider, SmsWebhookRequest +from app.channels.sms.adapter import account_provider_name +from app.core.config import settings +from app.core.logger import get_logger +from app.database import get_db +from app.models.channels import ChannelType +from app.repositories.channels import ChannelAccountRepository +from app.services.channel_chat import process_channel_message + +router = APIRouter() +logger = get_logger(__name__) + +# Twilio expects TwiML back; empty means "no auto-reply" +EMPTY_TWIML = '' + + +def _public_url(provider: str, account_id: UUID) -> str: + return f"{settings.BACKEND_URL.rstrip('/')}{settings.API_V1_STR}/webhooks/sms/{provider}/{account_id}" + + +async def _build_request(request: Request, provider: str, account_id: UUID) -> SmsWebhookRequest: + raw_body = await request.body() + content_type = request.headers.get("content-type", "") + json_body = None + param_pairs = [] + params = {} + if "application/json" in content_type or (raw_body[:1] in (b"{", b"[")): + try: + json_body = jsonlib.loads(raw_body or b"{}") + except ValueError: + json_body = None + if json_body is None: + form = await request.form() + param_pairs = [(k, str(v)) for k, v in form.multi_items()] + params = dict(param_pairs) + return SmsWebhookRequest( + headers={k.lower(): v for k, v in request.headers.items()}, + params=params, param_pairs=param_pairs, json_body=json_body, + raw_body=raw_body, url=_public_url(provider, account_id), + ) + + +@router.post("/{provider}/{account_id}") +async def sms_webhook( + provider: str, + account_id: UUID, + request: Request, + background_tasks: BackgroundTasks, + token: str = Query(""), + db: Session = Depends(get_db), +): + """Inbound SMS for any provider. The provider in the path selects the + parser/verifier; it must match the account's configured provider.""" + account = ChannelAccountRepository(db).get_by_id(account_id) + if account is None or account.channel_type != ChannelType.SMS.value: + raise HTTPException(status_code=404, detail="Unknown account") + if account_provider_name(account) != provider: + raise HTTPException(status_code=404, detail="Provider mismatch") + + sms_provider = get_provider(provider) + if sms_provider is None: + raise HTTPException(status_code=404, detail="Unknown SMS provider") + + # Providers that do not sign their webhooks (Brevo, or Vonage with no signature + # secret) carry no proof of origin, so the route enforces the per-account URL + # token - the same protection the email webhook uses. The account id in the path + # is not a secret: it is part of the URL handed to the provider. + if not sms_provider.signs_webhook and not verify_webhook_token(account, token): + raise HTTPException(status_code=403, detail="Invalid token") + + sms_req = await _build_request(request, provider, account_id) + if not await sms_provider.verify_webhook(sms_req, account): + raise HTTPException(status_code=403, detail="Invalid signature") + + # Control callbacks (e.g. SNS subscription confirmation) are handled here + if await sms_provider.handle_control(sms_req, account): + return {"status": "ok"} + + for inbound in sms_provider.parse_inbound(sms_req, account): + # Only dedupe when the provider supplied a real message id — an empty + # id means "unknown", and deduping on text would drop repeat replies. + if inbound.external_message_id and is_duplicate_message( + ChannelType.SMS.value, f"{account.id}:{inbound.external_message_id}"): + continue + background_tasks.add_task(process_channel_message, account.id, inbound) + + # Twilio wants TwiML; others accept any 200 + if provider == "twilio": + return Response(content=EMPTY_TWIML, media_type="text/xml") + return {"status": "ok"} diff --git a/backend/app/api/webhooks/telegram.py b/backend/app/api/webhooks/telegram.py new file mode 100644 index 0000000..8602f07 --- /dev/null +++ b/backend/app/api/webhooks/telegram.py @@ -0,0 +1,81 @@ +""" +Copyright 2024-2026 ChatterMate + +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 uuid import UUID + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request +from sqlalchemy.orm import Session + +from app.api.webhooks.common import is_duplicate_message +from app.channels import get_adapter +from app.channels import telegram as telegram_channel +from app.core.logger import get_logger +from app.database import get_db +from app.models.channels import ChannelType +from app.repositories.channels import ChannelAccountRepository +from app.services.channel_chat import process_channel_message, process_channel_interaction + +router = APIRouter() +logger = get_logger(__name__) + + +@router.post("/{account_id}") +async def telegram_webhook( + account_id: UUID, + request: Request, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), +): + """Receive a Telegram Bot API update. Verifies the per-account secret + token, acks immediately, and processes the message in the background.""" + account = ChannelAccountRepository(db).get_by_id(account_id) + if account is None or account.channel_type != ChannelType.TELEGRAM.value: + raise HTTPException(status_code=404, detail="Unknown account") + + adapter = get_adapter(ChannelType.TELEGRAM.value) + raw_body = await request.body() + if not await adapter.verify_webhook(dict(request.headers), raw_body, account): + raise HTTPException(status_code=403, detail="Invalid webhook secret") + + payload = await request.json() + + # A shared contact (phone) is an interaction, not a customer text turn — + # dispatch it separately. + interaction = adapter.parse_interaction(payload) + if interaction is not None: + background_tasks.add_task(process_channel_interaction, account.id, interaction) + return {"ok": True} + + tap_taken = False + for inbound in adapter.parse_inbound(payload): + # Telegram message_id is only unique within a chat — key by conversation + # too. A tap is keyed by the keyboard it was on: only the first tap on + # a keyboard counts, so a double tap isn't answered twice. + if is_duplicate_message(ChannelType.TELEGRAM.value, + f"{account.id}:{inbound.external_conversation_id}:{telegram_channel.dedupe_id(inbound)}"): + logger.info(f"Skipping duplicate Telegram message {inbound.external_message_id}") + continue + background_tasks.add_task(process_channel_message, account.id, inbound) + if inbound.interactive is not None: + tap_taken = True + # Take the keyboard off the message: it has been answered. After + # the reply — background tasks run in turn, and a slow edit must + # not hold it up (a second tap meanwhile is refused as a duplicate). + background_tasks.add_task(adapter.clear_buttons, account, inbound.external_conversation_id, + inbound.reply_to_message_id) + + # A tap is answered in the reply, so its button stops spinning + return telegram_channel.callback_answer(payload, taken=tap_taken) or {"ok": True} diff --git a/backend/app/api/workflow.py b/backend/app/api/workflow.py new file mode 100644 index 0000000..a23e32c --- /dev/null +++ b/backend/app/api/workflow.py @@ -0,0 +1,256 @@ +""" +Copyright 2024-2026 ChatterMate + +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 fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import Optional +from uuid import UUID +from app.core.logger import get_logger +from app.database import get_db +from app.models.user import User +from app.core.auth import require_permissions +from app.services.workflow import WorkflowService +from app.models.schemas.workflow import WorkflowCreate, WorkflowResponse, WorkflowUpdate + +# Enterprise feature check +try: + from app.enterprise.repositories.plan import PlanRepository + from app.enterprise.services.feature_access import require_accessible_subscription + HAS_ENTERPRISE = True +except ImportError: + HAS_ENTERPRISE = False + +router = APIRouter() +logger = get_logger(__name__) + + +def check_workflow_feature_access(current_user: User, db: Session): + """Check if user has access to workflow feature""" + if not HAS_ENTERPRISE: + return # Allow access in non-enterprise mode + + # Accessible = active/trial/past-due-in-period OR cancelled-but-still-in- + # paid-period; raises 403 when the org has no accessible plan. + subscription = require_accessible_subscription(db, current_user.organization_id) + plan_repo = PlanRepository(db) + + # Check if workflow feature is available in the plan + if not plan_repo.check_feature_availability(str(subscription.plan_id), 'workflow'): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Workflow feature is not available in your current plan. Please upgrade to access this feature." + ) + + +@router.post("", response_model=WorkflowResponse, status_code=status.HTTP_201_CREATED) +async def create_workflow( + workflow_data: WorkflowCreate, + current_user: User = Depends(require_permissions("manage_agents")), + db: Session = Depends(get_db) +): + """Create a new workflow""" + try: + # Check workflow feature access + check_workflow_feature_access(current_user, db) + + workflow_service = WorkflowService(db) + + # Create workflow with organization ID from current user + workflow = workflow_service.create_workflow( + workflow_data=workflow_data, + created_by=current_user.id, + organization_id=current_user.organization_id + ) + + # Prepare response + response = WorkflowResponse( + id=workflow.id, + name=workflow.name, + description=workflow.description, + status=workflow.status, + version=workflow.version, + is_template=workflow.is_template, + default_language=workflow.default_language, + canvas_data=workflow.canvas_data, + settings=workflow.settings, + organization_id=workflow.organization_id, + agent_id=workflow.agent_id, + created_by=workflow.created_by, + created_at=workflow.created_at, + updated_at=workflow.updated_at + ) + + return response + + except ValueError as e: + logger.error(f"Validation error creating workflow: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) + except HTTPException: + # Let intended HTTP errors (e.g. 403 plan feature-gate, 400, 404) propagate + # instead of being masked as a generic 500. + raise + except Exception as e: + logger.error(f"Error creating workflow: {str(e)}") + db.rollback() + raise HTTPException(status_code=500, detail="Failed to create workflow") + + +@router.get("/agent/{agent_id}", response_model=Optional[WorkflowResponse]) +async def get_workflow_by_agent_id( + agent_id: UUID, + current_user: User = Depends(require_permissions("view_all", "manage_agents")), + db: Session = Depends(get_db) +): + """Get workflow by agent ID""" + try: + # Check workflow feature access + check_workflow_feature_access(current_user, db) + + workflow_service = WorkflowService(db) + + # Get workflow for the agent + workflow = workflow_service.get_workflow_by_agent_id( + agent_id=agent_id, + organization_id=current_user.organization_id + ) + + if not workflow: + return None + + # Prepare response + response = WorkflowResponse( + id=workflow.id, + name=workflow.name, + description=workflow.description, + status=workflow.status, + version=workflow.version, + is_template=workflow.is_template, + default_language=workflow.default_language, + canvas_data=workflow.canvas_data, + settings=workflow.settings, + organization_id=workflow.organization_id, + agent_id=workflow.agent_id, + created_by=workflow.created_by, + created_at=workflow.created_at, + updated_at=workflow.updated_at + ) + + return response + + except ValueError as e: + logger.error(f"Validation error getting workflow by agent ID: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) + except HTTPException: + # Let intended HTTP errors (e.g. 403 plan feature-gate, 400, 404) propagate + # instead of being masked as a generic 500. + raise + except Exception as e: + logger.error(f"Error getting workflow by agent ID: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to get workflow") + + +@router.put("/{workflow_id}", response_model=WorkflowResponse) +async def update_workflow( + workflow_id: UUID, + workflow_data: WorkflowUpdate, + current_user: User = Depends(require_permissions("manage_agents")), + db: Session = Depends(get_db) +): + """Update workflow""" + try: + # Check workflow feature access + check_workflow_feature_access(current_user, db) + + workflow_service = WorkflowService(db) + + # Update workflow + workflow = workflow_service.update_workflow( + workflow_id=workflow_id, + workflow_data=workflow_data.model_dump(exclude_unset=True), + organization_id=current_user.organization_id + ) + + if not workflow: + raise HTTPException(status_code=404, detail="Workflow not found") + + # Prepare response + response = WorkflowResponse( + id=workflow.id, + name=workflow.name, + description=workflow.description, + status=workflow.status, + version=workflow.version, + is_template=workflow.is_template, + default_language=workflow.default_language, + canvas_data=workflow.canvas_data, + settings=workflow.settings, + organization_id=workflow.organization_id, + agent_id=workflow.agent_id, + created_by=workflow.created_by, + created_at=workflow.created_at, + updated_at=workflow.updated_at + ) + + return response + + except ValueError as e: + logger.error(f"Validation error updating workflow: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) + except HTTPException: + # Let intended HTTP errors (e.g. 403 plan feature-gate, 400, 404) propagate + # instead of being masked as a generic 500. + raise + except Exception as e: + logger.error(f"Error updating workflow: {str(e)}") + db.rollback() + raise HTTPException(status_code=500, detail="Failed to update workflow") + + +@router.delete("/{workflow_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_workflow( + workflow_id: UUID, + current_user: User = Depends(require_permissions("manage_agents")), + db: Session = Depends(get_db) +): + """Delete workflow""" + try: + # Check workflow feature access + check_workflow_feature_access(current_user, db) + + workflow_service = WorkflowService(db) + + # Delete workflow + success = workflow_service.delete_workflow( + workflow_id=workflow_id, + organization_id=current_user.organization_id + ) + + if not success: + raise HTTPException(status_code=404, detail="Workflow not found") + + return None # 204 No Content + + except ValueError as e: + logger.error(f"Validation error deleting workflow: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) + except HTTPException: + # Let intended HTTP errors (e.g. 403 plan feature-gate, 400, 404) propagate + # instead of being masked as a generic 500. + raise + except Exception as e: + logger.error(f"Error deleting workflow: {str(e)}") + db.rollback() + raise HTTPException(status_code=500, detail="Failed to delete workflow") \ No newline at end of file diff --git a/backend/app/api/workflow_node.py b/backend/app/api/workflow_node.py new file mode 100644 index 0000000..c9a7bd2 --- /dev/null +++ b/backend/app/api/workflow_node.py @@ -0,0 +1,251 @@ +""" +Copyright 2024-2026 ChatterMate + +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 fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List, Dict, Any, Optional +from uuid import UUID +from pydantic import BaseModel +from app.core.logger import get_logger +from app.database import get_db +from app.models.user import User +from app.core.auth import require_permissions, get_current_user +from app.services.workflow_node import WorkflowNodeService +from app.models.schemas.workflow import WorkflowNodeResponse, WorkflowConnectionResponse, WorkflowNodeUpdate + + +router = APIRouter() +logger = get_logger(__name__) + + +class WorkflowNodesUpdateRequest(BaseModel): + nodes: List[Dict[str, Any]] + connections: List[Dict[str, Any]] + + +class WorkflowNodesResponse(BaseModel): + nodes: List[WorkflowNodeResponse] + connections: List[WorkflowConnectionResponse] + + +@router.put("/{workflow_id}/nodes", response_model=WorkflowNodesResponse) +async def replace_workflow_nodes( + workflow_id: UUID, + request_data: WorkflowNodesUpdateRequest, + current_user: User = Depends(require_permissions("manage_agents")), + db: Session = Depends(get_db) +): + """Replace all workflow nodes and connections with cached data""" + try: + workflow_node_service = WorkflowNodeService(db) + + # Replace all nodes and connections (delete existing, save new ones) + result = workflow_node_service.replace_workflow_nodes_and_connections( + workflow_id=workflow_id, + nodes_data=request_data.nodes, + connections_data=request_data.connections, + organization_id=current_user.organization_id + ) + + # Convert to response format + nodes_response = [ + WorkflowNodeResponse( + id=node.id, + workflow_id=node.workflow_id, + node_type=node.node_type, + name=node.name, + description=node.description, + position_x=node.position_x, + position_y=node.position_y, + config=node.config, + created_at=node.created_at, + updated_at=node.updated_at + ) + for node in result["nodes"] + ] + + connections_response = [ + WorkflowConnectionResponse( + id=conn.id, + workflow_id=conn.workflow_id, + source_node_id=conn.source_node_id, + target_node_id=conn.target_node_id, + label=conn.label, + condition=conn.condition, + priority=conn.priority, + connection_metadata=conn.connection_metadata, + created_at=conn.created_at, + updated_at=conn.updated_at + ) + for conn in result["connections"] + ] + + return WorkflowNodesResponse( + nodes=nodes_response, + connections=connections_response + ) + + except ValueError as e: + logger.error(f"Validation error updating workflow nodes: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) + except HTTPException: + # Let intended HTTP errors (e.g. 403 plan feature-gate, 400, 404) propagate + # instead of being masked as a generic 500. + raise + except Exception as e: + logger.error(f"Error replacing workflow nodes: {str(e)}") + db.rollback() + raise HTTPException(status_code=500, detail="Failed to replace workflow nodes") + + +@router.get("/{workflow_id}/nodes", response_model=WorkflowNodesResponse) +async def get_workflow_nodes( + workflow_id: UUID, + current_user: User = Depends(require_permissions("view_all", "manage_agents")), + db: Session = Depends(get_db) +): + """Get all nodes and connections for a workflow""" + try: + workflow_node_service = WorkflowNodeService(db) + + # Get nodes and connections + result = workflow_node_service.get_workflow_nodes_and_connections( + workflow_id=workflow_id, + organization_id=current_user.organization_id + ) + + # Convert to response format + nodes_response = [ + WorkflowNodeResponse( + id=node.id, + workflow_id=node.workflow_id, + node_type=node.node_type, + name=node.name, + description=node.description, + position_x=node.position_x, + position_y=node.position_y, + config=node.config, + created_at=node.created_at, + updated_at=node.updated_at + ) + for node in result["nodes"] + ] + + connections_response = [ + WorkflowConnectionResponse( + id=conn.id, + workflow_id=conn.workflow_id, + source_node_id=conn.source_node_id, + target_node_id=conn.target_node_id, + label=conn.label, + condition=conn.condition, + priority=conn.priority, + connection_metadata=conn.connection_metadata, + created_at=conn.created_at, + updated_at=conn.updated_at + ) + for conn in result["connections"] + ] + + return WorkflowNodesResponse( + nodes=nodes_response, + connections=connections_response + ) + + except ValueError as e: + logger.error(f"Validation error getting workflow nodes: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) + except HTTPException: + # Let intended HTTP errors (e.g. 403 plan feature-gate, 400, 404) propagate + # instead of being masked as a generic 500. + raise + except Exception as e: + logger.error(f"Error getting workflow nodes: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to get workflow nodes") + + +@router.put("/{workflow_id}/nodes/{node_id}", response_model=WorkflowNodeResponse) +async def update_workflow_node( + workflow_id: UUID, + node_id: UUID, + node_data: WorkflowNodeUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Update a single workflow node with its properties""" + try: + # Get the workflow node service + workflow_node_service = WorkflowNodeService(db) + logger.debug(f"Node data: {node_data}") + # Update the node + updated_node = workflow_node_service.update_single_node( + workflow_id=workflow_id, + node_id=node_id, + node_data=node_data.model_dump(), + organization_id=current_user.organization_id + ) + + if not updated_node: + raise HTTPException(status_code=400, detail="Node not found or could not be updated") + + return updated_node + + except ValueError as e: + # An unknown or another organization's workflow or node, as the routes above + raise HTTPException(status_code=400, detail=str(e)) + except HTTPException: + # Let intended HTTP errors (e.g. 403 plan feature-gate, 400, 404) propagate + # instead of being masked as a generic 500. + raise + except Exception as e: + logger.error(f"Error updating workflow node: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to update workflow node: {str(e)}") + + +@router.get("/{workflow_id}/nodes/{node_id}", response_model=WorkflowNodeResponse) +async def get_workflow_node( + workflow_id: UUID, + node_id: UUID, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get a single workflow node""" + try: + # Get the workflow node service + workflow_node_service = WorkflowNodeService(db) + + # Get the node + node = workflow_node_service.get_single_node( + workflow_id=workflow_id, + node_id=node_id, + organization_id=current_user.organization_id + ) + + if not node: + raise HTTPException(status_code=400, detail="Node not found") + + return node + + except ValueError as e: + # An unknown or another organization's workflow or node, as the routes above + raise HTTPException(status_code=400, detail=str(e)) + except HTTPException: + # Let intended HTTP errors (e.g. 403 plan feature-gate, 400, 404) propagate + # instead of being masked as a generic 500. + raise + except Exception as e: + logger.error(f"Error getting workflow node: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to get workflow node: {str(e)}") \ No newline at end of file diff --git a/backend/app/channels/__init__.py b/backend/app/channels/__init__.py new file mode 100644 index 0000000..993c983 --- /dev/null +++ b/backend/app/channels/__init__.py @@ -0,0 +1,30 @@ +""" +Copyright 2024-2026 ChatterMate + +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 ( + ChannelAdapter, InboundMessage, SendResult, WindowStatus, ChannelInteraction, +) +from app.channels.registry import get_adapter, register_adapter + +__all__ = [ + "ChannelAdapter", + "InboundMessage", + "SendResult", + "WindowStatus", + "ChannelInteraction", + "get_adapter", + "register_adapter", +] diff --git a/backend/app/channels/app_hooks.py b/backend/app/channels/app_hooks.py new file mode 100644 index 0000000..fb05db6 --- /dev/null +++ b/backend/app/channels/app_hooks.py @@ -0,0 +1,201 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +Extension points on the channel pipeline. + +An extension can see every channel message in and out and every delivery +receipt (event listeners), and can claim an inbound message before the AI +answers it (the inbound handler). Nothing here knows who the extension is: +load_extensions imports whatever is installed, and the extension registers +itself. Without one, every hook is a no-op. + +Hooks receive plain ids and values, never ORM objects or credentials. +""" + +import asyncio +import enum +import importlib +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Awaitable, Callable, List, Optional +from uuid import UUID, uuid4 + +from app.channels.base import InboundMessage, InteractiveMessage, StatusEvent +from app.channels.constants import MessageSource +from app.core.logger import get_logger + +logger = get_logger(__name__) + +# The installed extension's hook module, imported (if present) by +# load_extensions. Importing it registers its hooks. +EXTENSION_HOOKS_MODULE = "app.enterprise.hooks" +_EXTENSION_PACKAGE = EXTENSION_HOOKS_MODULE.rsplit(".", 1)[0] + +# Listeners run inline on the message, delivery and receipt paths, so each +# gets this long; they are meant to enqueue work, not do it. +LISTENER_TIMEOUT_SECONDS = 1.0 + + +class EventKind(str, enum.Enum): + INBOUND = "inbound" + STATUS = "status" + OUTBOUND = "outbound" + + +class HandledBy(str, enum.Enum): + APP = "app" + AI = "ai" + HUMAN = "human" + + +@dataclass +class InboundContext: + """An inbound message an extension may claim. `event_id` is the id of the + `inbound` event that follows for the same message, so a synchronous call + and that event can be told apart from two different messages.""" + event_id: str + organization_id: UUID + channel_account_id: UUID + channel_type: str + session_id: str + customer_id: str + inbound: InboundMessage + received_at: datetime + + +@dataclass +class OutboundRecord: + """A message the business sent on a channel.""" + message_id: Optional[int] + external_message_id: Optional[str] + source: MessageSource + text: str + interactive: Optional[InteractiveMessage] = None + + +@dataclass +class ChannelEvent: + """Something that happened on a channel conversation. `event_id` is + deterministic, so a retried webhook or send yields the same id.""" + kind: EventKind + event_id: str + organization_id: UUID + channel_account_id: UUID + occurred_at: datetime + session_id: Optional[str] = None + customer_id: Optional[str] = None + inbound: Optional[InboundMessage] = None + handled_by: Optional[HandledBy] = None + status: Optional[StatusEvent] = None + outbound: Optional[OutboundRecord] = None + + +InboundHandler = Callable[[InboundContext], Awaitable[bool]] +EventListener = Callable[[ChannelEvent], Awaitable[None]] + +_inbound_handler: Optional[InboundHandler] = None +_event_listeners: List[EventListener] = [] +_extensions_loaded = False + + +def register_inbound_handler(handler: InboundHandler) -> None: + """Let an extension claim inbound messages first. One slot: the handler + returns True (exactly) when it has dealt with the message and the AI + must not. + + The contract a handler has to live with: + - it is cancelled after app_first_routing.APP_HANDLER_TIMEOUT_SECONDS + and the AI answers instead, so its own outbound calls must time out + sooner — or the customer can get both answers; + - it must only do async I/O (blocking work can't be cancelled); + - it can see the same message twice (webhook redelivery when dedupe is + unavailable) — dedupe on InboundContext.event_id; + - it is not the durable record: every inbound message is also announced + as an `inbound` event (same event_id), whatever the handler did.""" + global _inbound_handler + _inbound_handler = handler + + +def register_event_listener(listener: EventListener) -> None: + if listener not in _event_listeners: + _event_listeners.append(listener) + + +def inbound_handler() -> Optional[InboundHandler]: + return _inbound_handler + + +async def emit_channel_event(event: ChannelEvent) -> None: + """Tell every listener. A failing or slow listener is logged and skipped — + an extension can never break or stall message handling.""" + for listener in list(_event_listeners): + try: + await asyncio.wait_for(listener(event), LISTENER_TIMEOUT_SECONDS) + except asyncio.TimeoutError: + logger.error(f"Channel event listener timed out on {event.kind.value} {event.event_id}") + except Exception: + logger.exception(f"Channel event listener failed on {event.kind.value} {event.event_id}") + + +def load_extensions() -> None: + """Import the installed extension's hooks, once. Called at startup by the + API and by every worker that sends on channels — explicitly, so a process + that never calls it (a migration, a script) never loads an extension.""" + global _extensions_loaded + if _extensions_loaded: + return + _extensions_loaded = True + try: + importlib.import_module(EXTENSION_HOOKS_MODULE) + except ModuleNotFoundError as e: + if e.name not in (EXTENSION_HOOKS_MODULE, _EXTENSION_PACKAGE): + logger.exception("Channel extension failed to load") # installed but broken + return + logger.debug("No channel extension installed") + except Exception: + logger.exception("Channel extension failed to load") + + +def reset() -> None: + """Forget every registration (tests).""" + global _inbound_handler, _extensions_loaded + _inbound_handler = None + _event_listeners.clear() + _extensions_loaded = False + + +def inbound_event_id(channel_account_id, external_message_id: str) -> str: + return f"{channel_account_id}:{external_message_id}" + + +def status_event_id(channel_account_id, external_message_id: str, status: str) -> str: + return f"{channel_account_id}:{external_message_id}:{status}" + + +def outbound_event(*, organization_id, channel_account_id, session_id: Optional[str], + customer_id: Optional[str], record: OutboundRecord) -> ChannelEvent: + # Keyed by the platform's id when there is one, else the stored row; a + # send with neither is unrepeatable anyway. + key = record.external_message_id or (f"row{record.message_id}" if record.message_id else None) + return ChannelEvent( + kind=EventKind.OUTBOUND, + event_id=f"{channel_account_id}:out:{key}" if key else str(uuid4()), + organization_id=organization_id, + channel_account_id=channel_account_id, + occurred_at=datetime.now(timezone.utc), + session_id=session_id, + customer_id=customer_id, + outbound=record, + ) diff --git a/backend/app/channels/base.py b/backend/app/channels/base.py new file mode 100644 index 0000000..0fa0ff9 --- /dev/null +++ b/backend/app/channels/base.py @@ -0,0 +1,267 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import enum +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import datetime +from typing import ClassVar, List, Optional + +from app.channels.interactive_limits import InteractiveLimits +from app.models.channels import ChannelAccount, ChannelConversation + + +class InteractiveReplyType(str, enum.Enum): + BUTTON_REPLY = "button_reply" # tap on a reply button we sent + LIST_REPLY = "list_reply" # pick from a list message we sent + TEMPLATE_BUTTON = "template_button" # tap on an approved template's quick-reply button + + +@dataclass +class InteractiveReply: + """The customer tapped a button or list row. `id` is the payload we set + when sending; `title` is the text they saw.""" + type: InteractiveReplyType + id: str + title: str + + +@dataclass +class SharedLocation: + latitude: float + longitude: float + name: Optional[str] = None + address: Optional[str] = None + + +@dataclass +class InboundMessage: + """A customer message normalized from a channel webhook payload.""" + external_account_id: str # which connected account received it + external_conversation_id: str # platform conversation id (chat_id / wa_id / PSID ...) + external_user_id: str # platform id of the customer + external_message_id: str # platform message id, used for dedupe + text: Optional[str] = None + attachments: List[dict] = field(default_factory=list) + profile: dict = field(default_factory=dict) # name / phone / username when available + timestamp: Optional[datetime] = None + interactive: Optional[InteractiveReply] = None + location: Optional[SharedLocation] = None + # The platform message this one replies to (WhatsApp context.id) — ties a + # button tap back to the message that offered the button. + reply_to_message_id: Optional[str] = None + + +class InteractiveKind(str, enum.Enum): + BUTTONS = "buttons" + LIST = "list" + CTA_URL = "cta_url" + + +@dataclass +class InteractiveButton: + id: str + title: str + # The choice's full text when the title had to be cut to fit (not sent) + description: Optional[str] = None + + +@dataclass +class InteractiveRow: + id: str + title: str + description: Optional[str] = None + + +@dataclass +class InteractiveSection: + rows: List[InteractiveRow] + title: Optional[str] = None + + +@dataclass +class InteractiveMessage: + """A channel-native choice prompt: reply buttons, a list, or a link button. + Limits are enforced by app.channels.interactive.validate_interactive.""" + kind: InteractiveKind + body: str + header: Optional[str] = None + footer: Optional[str] = None + buttons: List[InteractiveButton] = field(default_factory=list) + list_button_label: Optional[str] = None + sections: List[InteractiveSection] = field(default_factory=list) + url: Optional[str] = None + display_text: Optional[str] = None + + +class DeliveryState(str, enum.Enum): + SENT = "sent" + DELIVERED = "delivered" + READ = "read" + FAILED = "failed" + + +@dataclass +class StatusEvent: + """A delivery receipt for a message we sent.""" + external_account_id: str + external_message_id: str + recipient_id: str + status: DeliveryState + timestamp: Optional[datetime] = None + error: Optional[str] = None + error_code: Optional[str] = None # the platform's code for `error` + pricing_category: Optional[str] = None # e.g. marketing, utility, service + # Per-message pricing: whether this message is charged ("regular") or + # free ("free_customer_service", "free_entry_point"); `billable` is the + # older flag the platform is retiring in its favour + pricing_type: Optional[str] = None + pricing_model: Optional[str] = None + billable: Optional[bool] = None + + +@dataclass +class SendResult: + ok: bool + external_message_id: Optional[str] = None + error: Optional[str] = None + # True when the failure is a closed messaging window that an approved + # template message could reopen (WhatsApp) + can_template: bool = False + # True when the platform rejected the account's credentials (expired or + # revoked token): nothing will send until the account is reconnected + auth_failed: bool = False + + +@dataclass +class ChannelInteraction: + """A non-message interaction from a channel (a shared contact) — distinct + from a customer text turn, so it bypasses the AI pipeline and is handled + directly.""" + type: str # 'contact' + external_account_id: str + external_conversation_id: str + external_user_id: str + phone: Optional[str] = None # for type == 'contact' + profile: dict = field(default_factory=dict) + + +class WindowStatus(str, enum.Enum): + OK = "ok" + # Outside the customer-service window but a template message can reopen it + TEMPLATE_REQUIRED = "template_required" + # Outside the window with no recovery path + UNDELIVERABLE = "undeliverable" + + +class ChannelAdapter(ABC): + """Per-channel behavior behind a uniform interface. + + Adapters are stateless singletons: every method receives the + ChannelAccount (with credentials accessed via the account repository) + and, where relevant, the ChannelConversation. + """ + + channel_type: ClassVar[str] + # What send_interactive can carry on this channel; None when it can't + # send choices at all + interactive_limits: ClassVar[Optional[InteractiveLimits]] = None + # Whether the platform sends delivery receipts (parse_status_events) for + # the messages we send, so their ids are worth recording + reports_delivery_status: ClassVar[bool] = False + # The platform's cap on one text message, when it has one + max_text_length: ClassVar[Optional[int]] = None + + @property + def supports_interactive(self) -> bool: + return self.interactive_limits is not None + + def choice_limits_for(self, conversation: ChannelConversation) -> Optional[InteractiveLimits]: + """What choices can be offered in this conversation; None where a tap + couldn't come back to us. Default: the channel's limits.""" + return self.interactive_limits + + @abstractmethod + async def verify_webhook(self, headers: dict, raw_body: bytes, account: Optional[ChannelAccount]) -> bool: + """Authenticate an inbound webhook request before parsing it.""" + + @abstractmethod + def parse_inbound(self, payload: dict) -> List[InboundMessage]: + """Extract customer messages from a webhook payload. + Non-message events (delivery receipts, edits, joins) yield [].""" + + @abstractmethod + async def send_text(self, account: ChannelAccount, conversation: ChannelConversation, text: str) -> SendResult: + """Deliver a text reply to the customer.""" + + async def send_media(self, account: ChannelAccount, conversation: ChannelConversation, + url: str, content_type: str) -> SendResult: + """Deliver a media attachment. Default: unsupported.""" + return SendResult(ok=False, error=f"{self.channel_type} does not support media yet") + + async def send_interactive(self, account: ChannelAccount, conversation: ChannelConversation, + message: InteractiveMessage) -> SendResult: + """Deliver buttons / a list / a link button. Default: unsupported — + callers check supports_interactive and fall back to text.""" + return SendResult(ok=False, error=f"{self.channel_type} does not support interactive messages") + + def parse_status_events(self, payload: dict) -> List[StatusEvent]: + """Extract delivery receipts (sent/delivered/read/failed) from a + webhook payload. Default: the channel reports none.""" + return [] + + def check_delivery_window(self, conversation: ChannelConversation) -> WindowStatus: + """Whether the platform still accepts free-form outbound messages. + Default: no window restrictions (Telegram, Slack, email...).""" + return WindowStatus.OK + + def format_outbound(self, markdown: str) -> str: + """Convert the agent's markdown to the channel's markup and apply + length caps. Default: pass through unchanged.""" + return markdown + + def conversation_state(self, inbound: InboundMessage) -> dict: + """Channel-specific state to merge into the conversation's extra JSON + on each inbound message (e.g. email subject + Message-ID for reply + threading). Default: nothing.""" + return {} + + # --- Optional interactive capabilities (default: unsupported no-ops so + # channels without them are simply skipped) --- + + def parse_interaction(self, payload: dict) -> Optional["ChannelInteraction"]: + """Extract a button tap / shared contact from a webhook payload, or + None if the payload isn't such an interaction.""" + return None + + async def send_typing(self, account: ChannelAccount, conversation: ChannelConversation) -> None: + """Show a 'typing…' indicator while the agent composes a reply.""" + return None + + async def mark_read(self, account: ChannelAccount, external_message_id: str) -> None: + """Mark a customer's message read without a typing indicator — for a + message something other than the AI answers.""" + return None + + async def fetch_profile(self, account: ChannelAccount, external_user_id: str) -> dict: + """Look up the customer's real name/email from the platform API when the + webhook payload doesn't include it. Returns {} by default.""" + return {} + + async def request_phone(self, account: ChannelAccount, conversation: ChannelConversation, + text: str) -> SendResult: + """Ask the customer to share their phone number natively.""" + return SendResult(ok=False, error=f"{self.channel_type} does not support phone requests") diff --git a/backend/app/channels/constants.py b/backend/app/channels/constants.py new file mode 100644 index 0000000..d2f21df --- /dev/null +++ b/backend/app/channels/constants.py @@ -0,0 +1,67 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +Channel constants that describe the platforms rather than how we talk to them. + +Separate from meta_base because these are shared by layers that have no +business knowing about a transport — request schemas state one as a field +default. (It buys no import weight: app/channels/__init__ loads the registry +regardless. It is about where the value belongs, not what it drags in.) +""" + +import enum +from typing import Optional + +# The language assumed when a caller doesn't name one. Meta has no default — +# name and language together identify a template, so `order_update` in five +# languages is five templates — which makes this our choice, and one worth +# keeping in a single place: four copies of the literal would silently disagree +# the day it changes. +# +# The frontend's DEFAULT_LANGUAGE (utils/whatsappLanguages.ts) is the +# unavoidable second copy, on the other side of the wire. Keep the two equal. +DEFAULT_TEMPLATE_LANGUAGE = "en_US" + + +class MessageSource(str, enum.Enum): + """Who wrote a message the business sent (chat_history.attributes.source).""" + AI = "ai" + HUMAN = "human" + SYSTEM = "system" # canned notices: handover, limits, errors, tickets + APP = "app" # an external app, through a channel extension + + +# The most choices one reply can offer the customer: WhatsApp's list-message +# row cap, the largest interactive form any channel supports. +MAX_CHOICE_OPTIONS = 10 +# The longest one choice can be: a list row's description, which is where a +# choice too long for a button or row title is shown in full. +MAX_CHOICE_LENGTH = 72 + + +# Channels served by our own chat widget rather than an external messaging +# platform. They render our UI, so widget-only features (the rating prompt) work +# there; on Telegram or WhatsApp the same prompt is a dead end. +# +# 'shopify' is that widget embedded in a Shopify storefront — a distinct origin +# worth reporting on, but the same chat surface. Every "is this the widget?" +# test has to accept both, or Shopify conversations quietly lose widget +# features the moment they stop being labelled 'web'. +WIDGET_CHANNELS = frozenset({"web", "shopify"}) + + +def is_widget_channel(channel: Optional[str]) -> bool: + """True for our own widget surfaces. A missing channel means the widget.""" + return not channel or channel in WIDGET_CHANNELS diff --git a/backend/app/channels/email.py b/backend/app/channels/email.py new file mode 100644 index 0000000..02c6550 --- /dev/null +++ b/backend/app/channels/email.py @@ -0,0 +1,266 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import asyncio +import hashlib +import hmac +import json +import re +import smtplib +from email.message import EmailMessage +from email.utils import parseaddr, make_msgid +from typing import ClassVar, List, Optional + +from app.channels.base import ChannelAdapter, InboundMessage, SendResult +from app.channels.registry import register_adapter +from app.core.config import settings +from app.core.security import decrypt_api_key +from app.models.channels import ChannelAccount, ChannelConversation, ChannelType +from app.core.logger import get_logger + +logger = get_logger(__name__) + + +def smtp_config(account: ChannelAccount) -> dict: + """Resolve the outbound SMTP settings for an inbox. + + Uses the account's own encrypted SMTP credentials when present (so replies + go out from the inbox's own domain with correct SPF/DKIM), otherwise falls + back to the platform SMTP settings. + """ + creds = {} + if account.encrypted_credentials: + try: + creds = json.loads(decrypt_api_key(account.encrypted_credentials)) + except Exception as e: + logger.error(f"Failed to read email SMTP credentials for {account.id}: {e}") + + if creds.get("smtp_host"): + return inbox_smtp_config(creds, account.external_account_id) + return platform_smtp_config(account.external_account_id) + + +def _use_ssl(port: int, use_ssl: Optional[bool]) -> bool: + """Implicit TLS on 465 unless set explicitly; other ports try STARTTLS.""" + return port == 465 if use_ssl is None else bool(use_ssl) + + +def inbox_smtp_config(creds: dict, default_from: str) -> dict: + """Connection settings from an inbox's own SMTP credentials (the stored + smtp_* keys). `default_from` is used when no from_email was given.""" + port = int(creds.get("smtp_port") or 587) + return { + "host": creds["smtp_host"], + "port": port, + "username": creds.get("smtp_username"), + "password": creds.get("smtp_password"), + "from_email": creds.get("from_email") or default_from, + "use_ssl": _use_ssl(port, creds.get("smtp_use_ssl")), + } + + +def platform_smtp_config(from_email: str) -> dict: + """Connection settings for the platform's own account (SMTP_* settings). + With no SMTP_USERNAME there is no login, which is how local Mailpit runs.""" + port = int(settings.SMTP_PORT) + return { + "host": settings.SMTP_SERVER, + "port": port, + "username": settings.SMTP_USERNAME, + "password": settings.SMTP_PASSWORD, + "from_email": from_email, + "use_ssl": _use_ssl(port, None), + } + + +def open_smtp(cfg: dict) -> smtplib.SMTP: + """A connected, logged-in SMTP session for a config built above.""" + if cfg["use_ssl"]: + smtp = smtplib.SMTP_SSL(cfg["host"], cfg["port"], timeout=SMTP_TIMEOUT_SECONDS) + else: + smtp = smtplib.SMTP(cfg["host"], cfg["port"], timeout=SMTP_TIMEOUT_SECONDS) + try: + smtp.starttls() + except smtplib.SMTPNotSupportedError: + logger.warning("SMTP server does not support STARTTLS; sending unencrypted") + if cfg.get("username"): + smtp.login(cfg["username"], cfg["password"]) + return smtp + + +def validate_smtp(cfg: dict) -> None: + """Open + authenticate an SMTP connection to verify per-inbox credentials. + Raises on failure. Runs in a worker thread (smtplib is blocking).""" + with open_smtp(cfg) as smtp: + smtp.noop() + +SMTP_TIMEOUT_SECONDS = 20.0 +# Strip quoted history so the agent sees only the new content +_REPLY_MARKERS = ( + re.compile(r"^On .{5,80} wrote:\s*$", re.M), + re.compile(r"^-{2,}\s*Original Message\s*-{2,}", re.M | re.I), + re.compile(r"^_{10,}\s*$", re.M), +) + + +def strip_quoted_reply(text: str) -> str: + """Cut the email body at the first quoted-history marker.""" + cut = len(text) + for marker in _REPLY_MARKERS: + match = marker.search(text) + if match: + cut = min(cut, match.start()) + lines = [line for line in text[:cut].splitlines() if not line.startswith(">")] + stripped = "\n".join(lines).strip() + # Never lose a message to over-eager stripping — fall back to the raw body + return stripped or text.strip() + + +class EmailAdapter(ChannelAdapter): + """Email as a messaging channel. + + Inbound: an inbound-parse webhook (SendGrid/Brevo-style JSON or form post + forwarded to /webhooks/email/{account_id}?token=...). Conversations are + keyed by the customer's address — one continuous conversation per sender + per inbox. Outbound: platform SMTP, with In-Reply-To/References threading + from the last inbound Message-ID kept on the conversation.""" + + channel_type: ClassVar[str] = ChannelType.EMAIL.value + + async def verify_webhook(self, headers: dict, raw_body: bytes, account: Optional[ChannelAccount]) -> bool: + # The per-account secret rides on the URL (?token=), checked in the + # route because generic parse providers can't sign requests. + return account is not None + + def parse_inbound(self, payload: dict) -> List[InboundMessage]: + """Normalize a parsed-email payload. Accepts the common field names of + SendGrid Inbound Parse (from/to/subject/text/headers) and Brevo + (From/To/Subject/RawTextBody/MessageId). Machine-generated mail + (autoresponders, bounces) is dropped to prevent reply loops.""" + if self._is_auto_generated(payload): + return [] + sender = payload.get("from") or payload.get("From") or "" + if isinstance(sender, dict): # Brevo: {"Address": ..., "Name": ...} + sender_name, sender_email = sender.get("Name"), sender.get("Address", "") + else: + sender_name, sender_email = parseaddr(str(sender)) + sender_email = (sender_email or "").lower() + if not sender_email: + return [] + + text = payload.get("text") or payload.get("RawTextBody") or payload.get("body") or "" + text = strip_quoted_reply(str(text)) + if not text: + return [] + + message_id = str(payload.get("MessageId") or payload.get("message_id") + or self._header(payload, "Message-ID") or "") + subject = str(payload.get("subject") or payload.get("Subject") or "") + + return [InboundMessage( + external_account_id="", # resolved from the webhook path + external_conversation_id=sender_email, + external_user_id=sender_email, + external_message_id=message_id or + f"{sender_email}:{hashlib.sha256(text.encode()).hexdigest()[:32]}", + text=text, + profile={"name": sender_name or None, "email": sender_email, + "subject": subject, "inbound_message_id": message_id, + # Threading headers, kept raw — ticket reply matching + # reads the Message-IDs we minted back out of them. + "in_reply_to": self._threading_header(payload, "In-Reply-To"), + "references": self._threading_header(payload, "References")}, + )] + + def conversation_state(self, inbound: InboundMessage) -> dict: + state = {} + if inbound.profile.get("inbound_message_id"): + state["last_message_id"] = inbound.profile["inbound_message_id"] + if inbound.profile.get("subject"): + state["subject"] = inbound.profile["subject"] + return state + + async def send_text(self, account: ChannelAccount, conversation: ChannelConversation, text: str) -> SendResult: + extra = conversation.extra or {} + subject = extra.get("subject") or "Re: your message" + if subject and not subject.lower().startswith("re:"): + subject = f"Re: {subject}" + + cfg = smtp_config(account) + message = EmailMessage() + message["From"] = cfg["from_email"] + message["To"] = conversation.external_conversation_id + message["Subject"] = subject + message["Message-ID"] = make_msgid() + last_inbound_id = extra.get("last_message_id") + if last_inbound_id: + message["In-Reply-To"] = last_inbound_id + message["References"] = last_inbound_id + message.set_content(text) + + try: + await asyncio.to_thread(self._smtp_send, cfg, message) + return SendResult(ok=True, external_message_id=message["Message-ID"]) + except Exception as e: + logger.error(f"Email send failed: {e}") + return SendResult(ok=False, error=str(e)) + + @staticmethod + def _smtp_send(cfg: dict, message: EmailMessage) -> None: + with open_smtp(cfg) as smtp: + smtp.send_message(message) + + @staticmethod + def _is_auto_generated(payload: dict) -> bool: + """True for autoresponders/bounces (Auto-Submitted, Precedence bulk, + X-Auto-Response-Suppress) — answering those creates mail loops.""" + headers = payload.get("headers") + text = headers if isinstance(headers, str) else "" + if isinstance(headers, dict): + text = "\n".join(f"{k}: {v}" for k, v in headers.items()) + lowered = text.lower() + if re.search(r"^auto-submitted:\s*(?!no)", lowered, re.M): + return True + if re.search(r"^precedence:\s*(bulk|junk|auto_reply|list)", lowered, re.M): + return True + if "x-auto-response-suppress:" in lowered: + return True + return False + + @classmethod + def _threading_header(cls, payload: dict, name: str) -> str: + """In-Reply-To/References, read from the raw headers the provider + forwards (dict or string, both handled by _header). Also accepts a + top-level lowercased field for providers that flatten headers onto the + payload.""" + return payload.get(name.lower()) or cls._header(payload, name) or "" + + @staticmethod + def _header(payload: dict, name: str) -> Optional[str]: + headers = payload.get("headers") + if isinstance(headers, dict): + return headers.get(name) or headers.get(name.lower()) + if isinstance(headers, str): + match = re.search(rf"^{name}:\s*(.+)$", headers, re.M | re.I) + return match.group(1).strip() if match else None + return None + + +def verify_webhook_token(account: ChannelAccount, token: str) -> bool: + return bool(token) and hmac.compare_digest(token, account.webhook_secret or "") + + +register_adapter(EmailAdapter()) diff --git a/backend/app/channels/instagram.py b/backend/app/channels/instagram.py new file mode 100644 index 0000000..89c2231 --- /dev/null +++ b/backend/app/channels/instagram.py @@ -0,0 +1,66 @@ +""" +Copyright 2024-2026 ChatterMate + +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 typing import ClassVar, List, Optional + +from app.channels.interactive_limits import INSTAGRAM_LIMITS, InteractiveLimits +from app.channels.messenger import MessengerAdapter +from app.channels.meta_base import GRAPH_INSTAGRAM_BASE +from app.channels.registry import register_adapter +from app.models.channels import ChannelType + +# Instagram DM uses the same Messenger Platform envelope (entry[].messaging[], +# sender.id = IGSID) and the same `me/messages` send shape, so it subclasses +# Messenger. What differs is the host and the credential: accounts connect +# through Instagram Login, which yields an Instagram user token rather than a +# Facebook Page token. + + +class InstagramAdapter(MessengerAdapter): + channel_type: ClassVar[str] = ChannelType.INSTAGRAM.value + # An Instagram user token is only accepted by graph.instagram.com; the + # Facebook graph rejects it, so sends and profile lookups go here instead. + graph_base: ClassVar[str] = GRAPH_INSTAGRAM_BASE + # An IG user node exposes name/username, not the Messenger first/last name. + profile_fields: ClassVar[str] = "name,username" + # Instagram's send body is just {recipient, message} — messaging_type is a + # Messenger-only parameter and has no meaning here. + send_extras: ClassVar[dict] = {} + # Quick replies, but Instagram doesn't show them on desktop or the web, + # so the choices are written into the text too + interactive_limits: ClassVar[Optional[InteractiveLimits]] = INSTAGRAM_LIMITS + # Instagram's own cap on a message's text + max_text_length: ClassVar[Optional[int]] = INSTAGRAM_LIMITS.body_max + + def _events(self, entry: dict) -> List[dict]: + """Instagram delivers a DM either as messaging[] or, depending on the + API version, as changes[] with field=messages and the same event under + `value`. Reading only one shape drops every message on the other, and + silently — the payload parses to nothing and the webhook still acks. + """ + events = super()._events(entry) + if events: + return events + return [change.get("value") or {} + for change in entry.get("changes") or [] + if change.get("field") == "messages"] + + @staticmethod + def _display_name(data: dict) -> str: + return (data.get("name") or data.get("username") or "").strip() + + +register_adapter(InstagramAdapter()) diff --git a/backend/app/channels/interactive.py b/backend/app/channels/interactive.py new file mode 100644 index 0000000..5e1ec0f --- /dev/null +++ b/backend/app/channels/interactive.py @@ -0,0 +1,200 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +Interactive (choice) messages: validation against a channel's limits, and +building one from the agent's plain list of options. + +Checked before sending, so a bad message fails here with a readable reason +instead of as an opaque platform 400. Each channel's limits are in +channels/interactive_limits.py; the header, footer, section, list-button and +link-button limits below are WhatsApp's, the only channel with those parts. +""" + +from typing import List, Optional + +from app.channels.base import ( + InteractiveButton, InteractiveKind, InteractiveMessage, InteractiveRow, + InteractiveSection, +) +from app.channels.constants import MAX_CHOICE_OPTIONS +from app.channels.interactive_limits import WHATSAPP_LIMITS, InteractiveLimits + +SECTION_TITLE_MAX = 24 +LIST_BUTTON_LABEL_MAX = 20 +CTA_DISPLAY_TEXT_MAX = 20 +HEADER_MAX = 60 +FOOTER_MAX = 60 + +DEFAULT_LIST_BUTTON_LABEL = "Choose" +# The body of the choices when the reply itself was too long to carry them +DEFAULT_CHOICES_BODY = "Choose an option:" +ELLIPSIS = "…" + +# Ids we give the agent's options +_QUICK_REPLY_ID_PREFIX = "qr_" + + +def _quick_reply_id(index: int) -> str: + return f"{_QUICK_REPLY_ID_PREFIX}{index}" + + +def truncate(text: str, limit: int) -> str: + """Cut to `limit` characters, marking the cut with an ellipsis.""" + text = text.strip() + return text if len(text) <= limit else text[:limit - len(ELLIPSIS)].rstrip() + ELLIPSIS + + +def from_options(body: str, options: List[str], + limits: InteractiveLimits = WHATSAPP_LIMITS) -> InteractiveMessage: + """Build the message for the agent's options on a channel. + + Buttons when there are few enough options and every one fits a button + title, or when the channel has no lists (titles are cut to fit; the full + text is kept with each); otherwise a list, whose rows allow longer titles + and carry the full text in the description when the title had to be cut. + """ + options = options[:MAX_CHOICE_OPTIONS] + fits = len(options) <= limits.max_buttons and all(len(o) <= limits.button_title_max for o in options) + if fits or not limits.supports_list: + return InteractiveMessage( + kind=InteractiveKind.BUTTONS, + body=body, + buttons=[InteractiveButton(id=_quick_reply_id(i), title=truncate(o, limits.button_title_max), + description=o if len(o) > limits.button_title_max else None) + for i, o in enumerate(options[:limits.max_buttons])], + ) + rows = [ + InteractiveRow( + id=_quick_reply_id(i), + title=truncate(o, limits.row_title_max), + description=truncate(o, limits.row_description_max) if len(o) > limits.row_title_max else None, + ) + for i, o in enumerate(options[:limits.max_list_rows]) + ] + return InteractiveMessage( + kind=InteractiveKind.LIST, + body=body, + list_button_label=DEFAULT_LIST_BUTTON_LABEL, + sections=[InteractiveSection(rows=rows)], + ) + + +def validate_interactive(message: InteractiveMessage, + limits: InteractiveLimits = WHATSAPP_LIMITS) -> List[str]: + """Every reason the channel would reject this message; empty when valid.""" + errors: List[str] = [] + _check_text(errors, "body", message.body, limits.body_max, required=True) + _check_text(errors, "header", message.header, HEADER_MAX) + _check_text(errors, "footer", message.footer, FOOTER_MAX) + + if message.kind == InteractiveKind.BUTTONS: + _validate_buttons(errors, message.buttons, limits) + elif message.kind == InteractiveKind.LIST: + if limits.supports_list: + _validate_list(errors, message, limits) + else: + errors.append("this channel can't show a list") + elif message.kind == InteractiveKind.CTA_URL: + if limits.supports_cta: + _validate_cta(errors, message) + else: + errors.append("this channel can't show a link button") + return errors + + +def to_text_fallback(message: InteractiveMessage, max_length: Optional[int] = None) -> str: + """The same choices as plain text, for when the interactive form can't be + sent: the customer can still answer by typing the option. When the + channel caps message length, the body is shortened — never the choices.""" + if message.kind == InteractiveKind.CTA_URL: + tail = f"{message.display_text}: {message.url}" if message.display_text else message.url + suffix = f"\n{tail}" + else: + lines = [f"{i}. {_fallback_line(c)}" for i, c in enumerate(all_choices(message), start=1)] + suffix = "\n\n" + "\n".join(lines) if lines else "" + body = message.body + if max_length is not None and len(body) + len(suffix) > max_length: + body = truncate(body, max(max_length - len(suffix), len(ELLIPSIS))) + return body + suffix + + +def choice_text(choice) -> str: + """The full text of one choice: its title, or for a button or row whose + title had to be cut, its description (see from_options).""" + description = getattr(choice, "description", None) + if description and choice.title.endswith(ELLIPSIS): + return description + return choice.title + + +def all_choices(message: InteractiveMessage) -> list: + """Every button and list row, in order.""" + return list(message.buttons) + [r for s in message.sections for r in s.rows] + + +def _fallback_line(choice) -> str: + text = choice_text(choice) + description = getattr(choice, "description", None) + # A real description (not the cut title's full text) is worth showing too + return f"{text} — {description}" if description and description != text else text + + +def _check_text(errors: List[str], name: str, value: Optional[str], limit: int, + required: bool = False) -> None: + if not value or not value.strip(): + if required: + errors.append(f"{name} is required") + return + if len(value) > limit: + errors.append(f"{name} must be at most {limit} characters") + + +def _check_ids(errors: List[str], name: str, ids: List[str], limits: InteractiveLimits) -> None: + if any(not i or len(i.encode()) > limits.id_max_bytes for i in ids): + errors.append(f"every {name} id must be 1-{limits.id_max_bytes} bytes") + if len(set(ids)) != len(ids): + errors.append(f"{name} ids must be unique") + + +def _validate_buttons(errors: List[str], buttons: List[InteractiveButton], limits: InteractiveLimits) -> None: + if not 1 <= len(buttons) <= limits.max_buttons: + errors.append(f"buttons needs 1-{limits.max_buttons} buttons") + for button in buttons: + _check_text(errors, "button title", button.title, limits.button_title_max, required=True) + _check_ids(errors, "button", [b.id for b in buttons], limits) + + +def _validate_list(errors: List[str], message: InteractiveMessage, limits: InteractiveLimits) -> None: + _check_text(errors, "list button label", message.list_button_label, + LIST_BUTTON_LABEL_MAX, required=True) + rows = [r for s in message.sections for r in s.rows] + if not message.sections or any(not s.rows for s in message.sections): + errors.append("every list section needs at least one row") + if not 1 <= len(rows) <= limits.max_list_rows: + errors.append(f"a list needs 1-{limits.max_list_rows} rows in total") + if len(message.sections) > 1 and any(not s.title for s in message.sections): + errors.append("section titles are required when there is more than one section") + for section in message.sections: + _check_text(errors, "section title", section.title, SECTION_TITLE_MAX) + for row in rows: + _check_text(errors, "row title", row.title, limits.row_title_max, required=True) + _check_text(errors, "row description", row.description, limits.row_description_max) + _check_ids(errors, "row", [r.id for r in rows], limits) + + +def _validate_cta(errors: List[str], message: InteractiveMessage) -> None: + _check_text(errors, "display text", message.display_text, CTA_DISPLAY_TEXT_MAX, required=True) + if not (message.url or "").lower().startswith("https://"): + errors.append("url must be an https:// link") diff --git a/backend/app/channels/interactive_limits.py b/backend/app/channels/interactive_limits.py new file mode 100644 index 0000000..201faf9 --- /dev/null +++ b/backend/app/channels/interactive_limits.py @@ -0,0 +1,60 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +What each channel's tappable choices can hold. Dependency-free so adapters +(channels/base.py) and the builders (channels/interactive.py) can share it. +""" + +from dataclasses import dataclass + +from app.channels.constants import MAX_CHOICE_LENGTH, MAX_CHOICE_OPTIONS + + +@dataclass(frozen=True) +class InteractiveLimits: + max_buttons: int + button_title_max: int + body_max: int + # Choice ids are checked in bytes: Telegram counts bytes, and so does + # every other platform's limit once an id isn't ASCII + id_max_bytes: int + supports_list: bool = False + max_list_rows: int = 0 + row_title_max: int = 0 + row_description_max: int = 0 + supports_cta: bool = False + # Also write the choices into the text: where some of the platform's + # apps don't show the buttons (Instagram on desktop and the web) + options_in_text: bool = False + + +# https://developers.facebook.com/docs/whatsapp/cloud-api/messages/interactive-reply-buttons-messages +WHATSAPP_LIMITS = InteractiveLimits( + max_buttons=3, button_title_max=20, body_max=1024, id_max_bytes=256, + supports_list=True, max_list_rows=MAX_CHOICE_OPTIONS, row_title_max=24, + row_description_max=MAX_CHOICE_LENGTH, supports_cta=True, +) + +# Quick replies: https://developers.facebook.com/docs/messenger-platform/send-messages/quick-replies +# body_max is each channel's cap on a message's text, which the adapters use too +MESSENGER_LIMITS = InteractiveLimits(max_buttons=13, button_title_max=20, body_max=2000, id_max_bytes=1000) + +# Same quick replies, but Instagram doesn't show them on desktop or the web +INSTAGRAM_LIMITS = InteractiveLimits(max_buttons=13, button_title_max=20, body_max=1000, id_max_bytes=1000, + options_in_text=True) + +# Inline keyboard: https://core.telegram.org/bots/api#inlinekeyboardbutton +TELEGRAM_LIMITS = InteractiveLimits(max_buttons=MAX_CHOICE_OPTIONS, button_title_max=64, body_max=4096, + id_max_bytes=64) diff --git a/backend/app/channels/line.py b/backend/app/channels/line.py new file mode 100644 index 0000000..918a822 --- /dev/null +++ b/backend/app/channels/line.py @@ -0,0 +1,172 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import base64 +import hashlib +import hmac +import json +from datetime import datetime, timezone +from typing import ClassVar, List, Optional + +import httpx + +from app.channels.base import ChannelAdapter, InboundMessage, SendResult +from app.channels.registry import register_adapter +from app.core.security import decrypt_api_key +from app.models.channels import ChannelAccount, ChannelConversation, ChannelType +from app.core.logger import get_logger +from app.core.external_urls import external_base + +logger = get_logger(__name__) + +LINE_API_BASE = external_base("LINE_API_BASE", "https://api.line.me/v2/bot") +REQUEST_TIMEOUT_SECONDS = 15.0 +# https://developers.line.biz/en/reference/messaging-api/#text-message +MAX_MESSAGE_LENGTH = 5000 +# Loading-animation duration: 5–60, in multiples of 5 (auto-dismissed on reply) +LOADING_ANIMATION_SECONDS = 20 + +_http_client: Optional[httpx.AsyncClient] = None + + +def _get_http_client() -> httpx.AsyncClient: + global _http_client + if _http_client is None or _http_client.is_closed: + _http_client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS) + return _http_client + + +def credentials(account: ChannelAccount) -> dict: + return json.loads(decrypt_api_key(account.encrypted_credentials)) + + +async def get_bot_info(channel_access_token: str) -> Optional[dict]: + """Validate a channel access token; returns the bot info or None.""" + try: + response = await _get_http_client().get( + f"{LINE_API_BASE}/info", + headers={"Authorization": f"Bearer {channel_access_token}"}, + ) + return response.json() if response.status_code < 300 else None + except Exception as e: + logger.error(f"LINE bot info failed: {e}") + return None + + +async def set_webhook_endpoint(channel_access_token: str, url: str) -> bool: + """Point the LINE channel's webhook at us (best-effort; can also be set in + the LINE Developers console).""" + try: + response = await _get_http_client().put( + f"{LINE_API_BASE}/channel/webhook/endpoint", + headers={"Authorization": f"Bearer {channel_access_token}"}, + json={"endpoint": url}, + ) + return response.status_code < 300 + except Exception as e: + logger.error(f"LINE set webhook failed: {e}") + return False + + +class LineAdapter(ChannelAdapter): + """LINE Messaging API. Conversations are keyed by the LINE userId; replies + go out as push messages (reply tokens expire too quickly for AI latency).""" + + channel_type: ClassVar[str] = ChannelType.LINE.value + + async def verify_webhook(self, headers: dict, raw_body: bytes, account: Optional[ChannelAccount]) -> bool: + """X-Line-Signature: base64(HMAC-SHA256(channel_secret, body)).""" + if account is None: + return False + signature = headers.get("x-line-signature", "") + try: + secret = credentials(account).get("channel_secret", "") + except Exception as e: + logger.error(f"LINE credential decrypt failed for {account.id}: {e}") + return False + if not signature or not secret: + return False + expected = base64.b64encode( + hmac.new(secret.encode(), raw_body, hashlib.sha256).digest() + ).decode() + return hmac.compare_digest(expected, signature) + + def parse_inbound(self, payload: dict) -> List[InboundMessage]: + """Normalize webhook events; only one-on-one text messages become + InboundMessages.""" + messages: List[InboundMessage] = [] + for event in payload.get("events", []): + message = event.get("message") or {} + source = event.get("source") or {} + if event.get("type") != "message" or message.get("type") != "text": + continue + user_id = source.get("userId") + if not user_id: + continue + timestamp = None + if event.get("timestamp"): + timestamp = datetime.fromtimestamp(event["timestamp"] / 1000, tz=timezone.utc) + messages.append(InboundMessage( + external_account_id=str(payload.get("destination", "")), + external_conversation_id=str(user_id), + external_user_id=str(user_id), + external_message_id=str(message.get("id", "")), + text=message.get("text", ""), + timestamp=timestamp, + )) + return messages + + async def send_text(self, account: ChannelAccount, conversation: ChannelConversation, text: str) -> SendResult: + try: + response = await _get_http_client().post( + f"{LINE_API_BASE}/message/push", + headers={"Authorization": f"Bearer {credentials(account)['channel_access_token']}"}, + json={ + "to": conversation.external_conversation_id, + "messages": [{"type": "text", "text": text}], + }, + ) + if response.status_code >= 300: + try: + error = response.json().get("message", f"HTTP {response.status_code}") + except Exception: + error = f"HTTP {response.status_code}" + return SendResult(ok=False, error=str(error)) + return SendResult(ok=True) + except Exception as e: + logger.error(f"LINE send failed: {e}") + return SendResult(ok=False, error=str(e)) + + async def send_typing(self, account: ChannelAccount, conversation: ChannelConversation) -> None: + # LINE's loading animation (1:1 chats only); auto-dismisses when the + # reply is sent or after loadingSeconds. + try: + await _get_http_client().post( + f"{LINE_API_BASE}/chat/loading/start", + headers={"Authorization": f"Bearer {credentials(account)['channel_access_token']}"}, + json={ + "chatId": conversation.external_conversation_id, + "loadingSeconds": LOADING_ANIMATION_SECONDS, + }, + ) + except Exception as e: + logger.debug(f"LINE loading animation failed (non-critical): {e}") + + def format_outbound(self, markdown: str) -> str: + return markdown[:MAX_MESSAGE_LENGTH] + + +register_adapter(LineAdapter()) diff --git a/backend/app/channels/messenger.py b/backend/app/channels/messenger.py new file mode 100644 index 0000000..4fee889 --- /dev/null +++ b/backend/app/channels/messenger.py @@ -0,0 +1,186 @@ +""" +Copyright 2024-2026 ChatterMate + +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 typing import ClassVar, List, Optional + +from app.channels.base import ( + InboundMessage, InteractiveKind, InteractiveMessage, InteractiveReply, InteractiveReplyType, SendResult, +) +from app.channels.interactive import to_text_fallback +from app.channels.interactive_limits import MESSENGER_LIMITS, InteractiveLimits +from app.channels.meta_base import GRAPH_BASE, MetaBaseAdapter, graph_get, graph_post +from app.channels.registry import register_adapter +from app.models.channels import ChannelAccount, ChannelConversation, ChannelType +from app.core.logger import get_logger + +logger = get_logger(__name__) + +# Messenger send API limit +MAX_MESSAGE_LENGTH = MESSENGER_LIMITS.body_max + + +class MessengerAdapter(MetaBaseAdapter): + """Facebook Messenger. Instagram DM shares the same entry[].messaging[] + envelope and send shape, so InstagramAdapter subclasses this — overriding + only the host it talks to and the fields that name a sender.""" + + channel_type: ClassVar[str] = ChannelType.MESSENGER.value + # Instagram Login accounts hold an Instagram user token, which only + # graph.instagram.com accepts; Page tokens only work on the Facebook graph. + graph_base: ClassVar[str] = GRAPH_BASE + # Graph fields on the sender node that carry a display name. Instagram user + # nodes expose different ones, so it overrides this and _display_name. + profile_fields: ClassVar[str] = "first_name,last_name" + # Extra keys on a send. messaging_type is a Messenger Platform parameter and + # is not part of Instagram's documented send body, so Instagram sends none. + send_extras: ClassVar[dict] = {"messaging_type": "RESPONSE"} + # Choices go as quick replies (buttons only: no lists or link buttons) + interactive_limits: ClassVar[Optional[InteractiveLimits]] = MESSENGER_LIMITS + max_text_length: ClassVar[Optional[int]] = MAX_MESSAGE_LENGTH + + @staticmethod + def _event_seconds(raw) -> Optional[int]: + """Event time in seconds, or None if absent/unparseable. + + Messenger sends milliseconds as a number; Instagram's changes payload + sends seconds as a string, which the old `// 1000` raised a TypeError on + — crashing the whole webhook rather than dropping one timestamp. + """ + try: + value = int(raw) + except (TypeError, ValueError): + return None + # 13-digit values are milliseconds; 10-digit ones are already seconds. + return value // 1000 if value > 10_000_000_000 else value + + def _events(self, entry: dict) -> List[dict]: + """The messaging events on one entry. Messenger always uses messaging[]; + Instagram overrides this because it can deliver the same event under + changes[] instead.""" + return entry.get("messaging") or [] + + def parse_inbound(self, payload: dict) -> List[InboundMessage]: + """Normalize per-entry messaging events. Echoes, delivery/read receipts + and attachment-only messages yield nothing.""" + messages: List[InboundMessage] = [] + for entry in payload.get("entry", []): + account_id = str(entry.get("id", "")) + for event in self._events(entry): + message = event.get("message") + if not message or message.get("is_echo"): + continue + text = message.get("text") + if not text: + continue + sender_id = str((event.get("sender") or {}).get("id", "")) + if not sender_id: + continue + messages.append(InboundMessage( + external_account_id=account_id, + external_conversation_id=sender_id, + external_user_id=sender_id, + external_message_id=str(message.get("mid", "")), + text=text, + profile={}, + timestamp=self._timestamp(self._event_seconds(event.get("timestamp"))), + interactive=_quick_reply(message), + # Only a swipe-reply carries this; a quick-reply tap doesn't + reply_to_message_id=(message.get("reply_to") or {}).get("mid"), + )) + return messages + + async def send_text(self, account: ChannelAccount, conversation: ChannelConversation, text: str) -> SendResult: + return await self._send(account, conversation, {"text": text}) + + async def send_interactive(self, account: ChannelAccount, conversation: ChannelConversation, + message: InteractiveMessage) -> SendResult: + """The choices as quick replies under the text — and, where some of the + platform's apps don't show quick replies, written into it as well.""" + if message.kind != InteractiveKind.BUTTONS: + return SendResult(ok=False, error=f"{self.channel_type} only sends choices as quick replies") + limits = self.interactive_limits + text = to_text_fallback(message, limits.body_max) if limits.options_in_text else message.body + return await self._send(account, conversation, { + "text": text, + "quick_replies": [{"content_type": "text", "title": b.title, "payload": b.id} + for b in message.buttons], + }) + + async def _send(self, account: ChannelAccount, conversation: ChannelConversation, + message: dict) -> SendResult: + # `me/messages` resolves to the page/account owning the access token + return await graph_post( + "me/messages", + self.access_token(account), + { + "recipient": {"id": conversation.external_conversation_id}, + **self.send_extras, + "message": message, + }, + base=self.graph_base, + ) + + async def send_typing(self, account: ChannelAccount, conversation: ChannelConversation) -> None: + """Dismissed automatically when the reply is delivered, or after ~20s.""" + try: + result = await graph_post( + "me/messages", + self.access_token(account), + { + "recipient": {"id": conversation.external_conversation_id}, + "sender_action": "typing_on", + }, + base=self.graph_base, + ) + if not result.ok: + logger.debug(f"{self.channel_type} typing_on failed (non-critical): {result.error}") + except Exception as e: + logger.debug(f"{self.channel_type} typing_on failed (non-critical): {e}") + + def format_outbound(self, markdown: str) -> str: + return markdown[:self.max_text_length] + + async def fetch_profile(self, account: ChannelAccount, external_user_id: str) -> dict: + """The customer's name, which the webhook never carries — Messenger + sends only a PSID, so without this every customer shows as + "Messenger user 2742…" in People. + + Best-effort: a lookup failure must not cost us the message, so it + degrades to {} (the placeholder stays) rather than raising. Reading a + sender's name needs no permission beyond messaging them. + """ + ok, data = await graph_get(external_user_id, self.access_token(account), + params={"fields": self.profile_fields}, + base=self.graph_base) + if not ok: + return {} + name = self._display_name(data) + return {"name": name} if name else {} + + @staticmethod + def _display_name(data: dict) -> str: + return " ".join(p for p in (data.get("first_name"), data.get("last_name")) if p).strip() + + +def _quick_reply(message: dict) -> Optional[InteractiveReply]: + """A tapped quick reply: its payload is the choice's id, the text its title.""" + payload = (message.get("quick_reply") or {}).get("payload") + if not payload: + return None + return InteractiveReply(type=InteractiveReplyType.BUTTON_REPLY, id=str(payload), title=message.get("text") or "") + + +register_adapter(MessengerAdapter()) diff --git a/backend/app/channels/meta_base.py b/backend/app/channels/meta_base.py new file mode 100644 index 0000000..13782fe --- /dev/null +++ b/backend/app/channels/meta_base.py @@ -0,0 +1,457 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import asyncio +import hashlib +import hmac +from datetime import datetime, timezone +from typing import ClassVar, Iterable, Optional + +import httpx + +from app.channels.base import ChannelAdapter, SendResult, WindowStatus +from app.core.config import settings +from app.core.security import decrypt_credentials +from app.models.channels import ChannelAccount, ChannelConversation +from app.core.logger import get_logger +from app.core.external_urls import external_base + +logger = get_logger(__name__) + +GRAPH_BASE = external_base("META_GRAPH_BASE", "https://graph.facebook.com") +# Instagram API with Instagram Login speaks to its own host with an Instagram +# user token; the Facebook graph rejects those. Page-based channels keep using +# GRAPH_BASE, so every helper below takes the base rather than assuming one. +GRAPH_INSTAGRAM_BASE = external_base("META_GRAPH_INSTAGRAM_BASE", "https://graph.instagram.com") +INSTAGRAM_OAUTH_BASE = "https://api.instagram.com" +REQUEST_TIMEOUT_SECONDS = 15.0 +SEND_RETRIES = 2 +# Graph error codes meaning the access token is unusable: 190 expired or +# invalidated token, 102 expired session, 0 WhatsApp's "Authentication Error" +GRAPH_AUTH_ERROR_CODES = frozenset({0, 102, 190}) +# Meta customer-service window: free-form replies allowed for 24h after the +# customer's last inbound message (applies to WhatsApp, Messenger and Instagram). +WINDOW_HOURS = 24 + +# Shared keep-alive client to graph.facebook.com (process-lifetime, like the +# Telegram adapter's) — avoids a TCP+TLS handshake per outbound message. +_http_client: Optional[httpx.AsyncClient] = None + + +def _get_http_client() -> httpx.AsyncClient: + global _http_client + if _http_client is None or _http_client.is_closed: + _http_client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS) + return _http_client + + +def graph_url(path: str, base: str = GRAPH_BASE) -> str: + return f"{base}/{settings.META_GRAPH_VERSION}/{path}" + + +def verify_meta_signature(raw_body: bytes, signature_header: str) -> bool: + """Validate the X-Hub-Signature-256 header against our app secrets. + + The signature covers the raw request body; it must be checked before the + body is parsed. Uses a constant-time comparison. + + Instagram Login webhooks are signed with the Instagram app secret rather + than the Meta one, and the only thing that would say which product sent this + is inside the body we have not authenticated yet. So this tries each secret + we own instead of trusting unverified content to choose one; an attacker + still has to forge one of them. + """ + return signed_by_any(raw_body, signature_header, + (settings.META_APP_SECRET, settings.INSTAGRAM_APP_SECRET)) + + +def signed_by_any(raw_body: bytes, signature_header: str, app_secrets: Iterable[Optional[str]]) -> bool: + """Whether X-Hub-Signature-256 was made with one of these app secrets + (constant-time; empty secrets are skipped).""" + if not signature_header or not signature_header.startswith("sha256="): + return False + provided = signature_header[len("sha256="):] + for app_secret in app_secrets: + if not app_secret: + continue + expected = hmac.new(app_secret.encode(), raw_body, hashlib.sha256).hexdigest() + if hmac.compare_digest(expected, provided): + return True + return False + + +def verify_challenge(mode: Optional[str], token: Optional[str], challenge: Optional[str]) -> Optional[str]: + """Answer a webhook GET verification handshake. + + Returns the challenge string to echo when the subscribe token matches, else + None (caller should 403). + """ + if mode == "subscribe" and token and token == settings.META_WEBHOOK_VERIFY_TOKEN: + return challenge + return None + + +async def graph_post(path: str, access_token: str, payload: dict, + base: str = GRAPH_BASE) -> SendResult: + """POST to the Graph API with small exponential backoff on transient errors. + + Returns a SendResult; 4xx errors are terminal (no retry), network/5xx are + retried up to SEND_RETRIES times. `base` selects the host — Instagram Login + accounts send via graph.instagram.com, everything else via graph.facebook.com. + """ + url = graph_url(path, base) + headers = {"Authorization": f"Bearer {access_token}"} + last_error = "unknown error" + for attempt in range(SEND_RETRIES + 1): + try: + response = await _get_http_client().post(url, json=payload, headers=headers) + if response.status_code < 300: + data = response.json() + message_id = _extract_message_id(data) + return SendResult(ok=True, external_message_id=message_id) + # 4xx are our fault (bad token, closed window) — don't retry + if response.status_code < 500: + return SendResult(ok=False, error=_graph_error(response), + auth_failed=_is_auth_error(response)) + last_error = _graph_error(response) + except Exception as e: + last_error = str(e) + if attempt < SEND_RETRIES: + await asyncio.sleep(0.5 * (2 ** attempt)) + return SendResult(ok=False, error=last_error) + + +async def _graph_request(method: str, path: str, access_token: Optional[str], + params: Optional[dict] = None, + json_body: Optional[dict] = None, + base: str = GRAPH_BASE) -> tuple[bool, dict]: + """Issue a Graph call and return (ok, decoded-json). + + Unlike graph_post this keeps the whole response body, for calls where the + body is the result rather than just a message id. The token goes in the + Authorization header so it never appears in URLs or proxy logs; pass None + for the one endpoint that authenticates with app credentials instead. These + are management calls, not sends, so a failure surfaces to the caller + instead of being retried. + """ + try: + response = await _get_http_client().request( + method, + graph_url(path, base), + params=params or None, + json=json_body, + headers={"Authorization": f"Bearer {access_token}"} if access_token else {}, + ) + return (response.status_code < 300, response.json()) + except Exception as e: + logger.error(f"Graph {method} {path} failed: {e}") + return (False, {"error": {"message": str(e)}}) + + +async def graph_get(path: str, access_token: str, params: Optional[dict] = None, + base: str = GRAPH_BASE) -> tuple[bool, dict]: + """GET a Graph node (validating tokens during onboarding, listing templates).""" + return await _graph_request("GET", path, access_token, params=params, base=base) + + +def graph_detail(data: dict, fallback: str) -> str: + """Meta's own error text where it has one, so the UI can show the real + reason (e.g. a template that cannot be deleted) rather than a generic + failure. + + error_user_msg first: `message` is often a generic OAuth string that says + nothing actionable — Graph will pair a `message` of "Application does not + have permission for this action" with an error_user_msg that names the + actual asset and rule. Only one of those is a clue. + """ + error = data.get("error", {}) + if not isinstance(error, dict): + return fallback + return error.get("error_user_msg") or error.get("message") or fallback + + +async def graph_post_json(path: str, access_token: str, payload: dict) -> tuple[bool, dict]: + """POST to a Graph node, keeping the response body (e.g. creating a message + template returns its id, status and category).""" + return await _graph_request("POST", path, access_token, json_body=payload) + + +async def debug_token(input_token: str) -> tuple[bool, dict]: + """Inspect a token: what it is, who it belongs to, and whether it is ours. + + Returns Meta's `data` object, which for a Page token carries + `type: "PAGE"`, `profile_id` (the page id), `app_id`, `is_valid` and the + granted `scopes`. + + This authenticates with app credentials rather than the token being + inspected, so it needs no permission on the asset. Reading the Page node + directly (`me?fields=id,name`) would instead demand `pages_read_engagement` + — unrelated to messaging, and absent from a token that can nonetheless send + perfectly well. + """ + app_token = f"{settings.META_APP_ID}|{settings.META_APP_SECRET}" + ok, data = await _graph_request("GET", "debug_token", app_token, + params={"input_token": input_token}) + if not ok: + return False, data + return True, data.get("data", {}) + + +async def exchange_signup_code(code: str, redirect_uri: Optional[str] = None) -> tuple[bool, dict]: + """Trade a login code for the customer's access token. + + The app credentials authenticate this call, so it is the one Graph request + that carries no bearer token. + + redirect_uri must match whatever the login dialog used, or Graph rejects the + code as a mismatch: + - WhatsApp Embedded Signup issues its code through the JS SDK with no + redirect_uri, so it passes None and we omit the param. + - Facebook Login for Business runs as a first-party OAuth popup bound to + our own callback URL, so that flow passes that exact callback URL. + """ + params = { + "client_id": settings.META_APP_ID, + "client_secret": settings.META_APP_SECRET, + "code": code, + } + if redirect_uri is not None: + params["redirect_uri"] = redirect_uri + return await _graph_request("GET", "oauth/access_token", None, params=params) + + +async def exchange_for_long_lived_token(short_lived_token: str) -> tuple[bool, dict]: + """Trade a user token for a 60-day one. + + Page tokens inherit their lifetime from the user token they were read with: + from a short-lived one they die in about an hour, from a long-lived one they + never expire. We store the Page token and send with it for months, so this + runs before /me/accounts. An already-long-lived token comes back unchanged, + so it is safe to call unconditionally. App credentials authenticate it, so + like exchange_signup_code it carries no bearer token. + """ + return await _graph_request("GET", "oauth/access_token", None, params={ + "grant_type": "fb_exchange_token", + "client_id": settings.META_APP_ID, + "client_secret": settings.META_APP_SECRET, + "fb_exchange_token": short_lived_token, + }) + + +def instagram_token_payload(data: dict) -> dict: + """The token object out of an Instagram Login exchange response. + + Instagram has returned this both flat and wrapped in a single-element `data` + list depending on the API version, and the difference is invisible until it + silently yields no token — so accept either shape. + """ + entries = data.get("data") + if isinstance(entries, list) and entries and isinstance(entries[0], dict): + return entries[0] + return data + + +async def exchange_instagram_code(code: str, redirect_uri: str) -> tuple[bool, dict]: + """Trade an Instagram Business Login code for a short-lived user token. + + Instagram Login is a separate OAuth from the Facebook graph: its own host, + its own app credentials, form-encoded rather than query params, and it + returns an Instagram user token plus the account's user_id — no Page and no + Page token anywhere in the flow. + """ + try: + response = await _get_http_client().request( + "POST", + f"{INSTAGRAM_OAUTH_BASE}/oauth/access_token", + data={ + "client_id": settings.INSTAGRAM_APP_ID, + "client_secret": settings.INSTAGRAM_APP_SECRET, + "grant_type": "authorization_code", + "redirect_uri": redirect_uri, + "code": code, + }, + ) + return (response.status_code < 300, response.json()) + except Exception as e: + logger.error(f"Instagram code exchange failed: {e}") + return (False, {"error": {"message": str(e)}}) + + +async def exchange_instagram_long_lived(short_lived_token: str) -> tuple[bool, dict]: + """Trade a short-lived Instagram token (~1 hour) for a 60-day one. + + Without this the connection dies about an hour after onboarding, with no + signal beyond messages quietly failing. + """ + try: + response = await _get_http_client().request( + "GET", + f"{GRAPH_INSTAGRAM_BASE}/access_token", + params={ + "grant_type": "ig_exchange_token", + "client_secret": settings.INSTAGRAM_APP_SECRET, + "access_token": short_lived_token, + }, + ) + return (response.status_code < 300, response.json()) + except Exception as e: + logger.error(f"Instagram long-lived exchange failed: {e}") + return (False, {"error": {"message": str(e)}}) + + +async def subscribe_instagram_app(access_token: str) -> bool: + """Subscribe our app to this Instagram account's message webhooks. + + Best-effort, like subscribe_app: onboarding still succeeds if it fails, and + it can be retried by reconnecting. + """ + ok, _ = await _graph_request("POST", "me/subscribed_apps", access_token, + params={"subscribed_fields": "messages"}, + base=GRAPH_INSTAGRAM_BASE) + return ok + + +async def register_phone_number(phone_number_id: str, access_token: str, pin: str) -> tuple[bool, dict]: + """Enable a number for Cloud API sending. + + A number onboarded through Embedded Signup is attached to the WABA but not + yet usable; registering it with a two-step-verification PIN is what makes it + able to send. + """ + return await _graph_request("POST", f"{phone_number_id}/register", access_token, + json_body={"messaging_product": "whatsapp", "pin": pin}) + + +# Template fields worth reading back from Graph; components carries the body +# text and any {{n}} variables the UI has to prompt for. +TEMPLATE_FIELDS = "name,status,category,language,components" +TEMPLATE_PAGE_LIMIT = 100 +# Backstop against paging forever on a malformed cursor. Well above Meta's own +# per-WABA template ceiling, so a real account never reaches it. +TEMPLATE_MAX_PAGES = 10 + + +async def graph_list_all(path: str, access_token: str, params: dict, + max_pages: int = TEMPLATE_MAX_PAGES) -> tuple[bool, list | dict]: + """Every node on a Graph edge, following its cursor. + + A node that exists but isn't listed is an invisible failure — a Page the + customer administers but never sees offered, a template an agent cannot pick + — so this pages rather than stopping at the first page. Returns + (True, [node dicts]) or (False, graph error body), matching the other + helpers here. A `limit` in params lets a short final page end the walk one + round-trip early; without one it stops when Graph offers no `after` cursor. + """ + items: list = [] + limit = params.get("limit") + for _ in range(max_pages): + ok, data = await graph_get(path, access_token, params=params) + if not ok: + return False, data + page = data.get("data") + if not isinstance(page, list): + return False, data + items.extend(page) + + after = (data.get("paging") or {}).get("cursors", {}).get("after") + if not after or (limit is not None and len(page) < limit): + return True, items + params = {**params, "after": after} + + logger.warning(f"Stopped paging {path} at {max_pages} pages") + return True, items + + +async def fetch_message_templates(waba_id: str, access_token: str) -> tuple[bool, list | dict]: + """Every template on the WABA (see graph_list_all for the paging rationale).""" + return await graph_list_all( + f"{waba_id}/message_templates", access_token, + {"fields": TEMPLATE_FIELDS, "limit": TEMPLATE_PAGE_LIMIT}) + + +async def subscribe_app(node_id: str, access_token: str, subscribed_fields: Optional[str] = None) -> bool: + """Subscribe our app to a Page/WABA so its messages reach our webhook. + Best-effort: onboarding still succeeds if this fails (can be retried).""" + payload = {} + if subscribed_fields: + payload["subscribed_fields"] = subscribed_fields + result = await graph_post(f"{node_id}/subscribed_apps", access_token, payload) + return result.ok + + +def _extract_message_id(data: dict) -> Optional[str]: + # WhatsApp: {"messages":[{"id":...}]}; Messenger/IG: {"message_id":...} + if isinstance(data.get("messages"), list) and data["messages"]: + return str(data["messages"][0].get("id", "")) or None + return data.get("message_id") + + +def graph_auth_refused(body) -> bool: + """Whether a Graph error body says the access token itself is unusable + (expired, revoked, or never valid), as opposed to refusing this one call.""" + error = body.get("error") if isinstance(body, dict) else None + return isinstance(error, dict) and error.get("code") in GRAPH_AUTH_ERROR_CODES + + +def _is_auth_error(response: httpx.Response) -> bool: + if response.status_code == 401: + return True + try: + return graph_auth_refused(response.json()) + except Exception: + return False + + +def _graph_error(response: httpx.Response) -> str: + try: + body = response.json() + return str(body.get("error", {}).get("message") or body) + except Exception: + return f"HTTP {response.status_code}" + + +class MetaBaseAdapter(ChannelAdapter): + """Shared behavior for the Meta messaging channels (WhatsApp, Messenger, + Instagram): one webhook signature scheme, one Graph client, one 24h + customer-service window. Subclasses implement parse_inbound and send_text.""" + + # What an expired window means for this channel: WhatsApp can reopen with a + # template, Messenger/Instagram simply cannot deliver. + expired_status: ClassVar[WindowStatus] = WindowStatus.UNDELIVERABLE + + async def verify_webhook(self, headers: dict, raw_body: bytes, account: Optional[ChannelAccount]) -> bool: + return verify_meta_signature(raw_body, headers.get("x-hub-signature-256", "")) + + def check_delivery_window(self, conversation: ChannelConversation) -> WindowStatus: + last_inbound = conversation.last_inbound_at + if last_inbound is None: + return self.expired_status + if last_inbound.tzinfo is None: + last_inbound = last_inbound.replace(tzinfo=timezone.utc) + age_hours = (datetime.now(timezone.utc) - last_inbound).total_seconds() / 3600 + return WindowStatus.OK if age_hours < WINDOW_HOURS else self.expired_status + + @staticmethod + def access_token(account: ChannelAccount) -> str: + """Decrypt the account's Graph access token (page or WABA token).""" + return decrypt_credentials(account.encrypted_credentials)["access_token"] + + @staticmethod + def _timestamp(value) -> Optional[datetime]: + try: + return datetime.fromtimestamp(int(value), tz=timezone.utc) + except (TypeError, ValueError): + return None diff --git a/backend/app/channels/registry.py b/backend/app/channels/registry.py new file mode 100644 index 0000000..03f8d45 --- /dev/null +++ b/backend/app/channels/registry.py @@ -0,0 +1,53 @@ +""" +Copyright 2024-2026 ChatterMate + +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 typing import Dict, Optional + +from app.channels.base import ChannelAdapter + +_adapters: Dict[str, ChannelAdapter] = {} + + +def register_adapter(adapter: ChannelAdapter) -> ChannelAdapter: + """Register a stateless adapter singleton for its channel_type.""" + _adapters[adapter.channel_type] = adapter + return adapter + + +def get_adapter(channel_type: str) -> Optional[ChannelAdapter]: + """Adapter for a channel, or None for channels without one ('web').""" + _ensure_loaded() + return _adapters.get(channel_type) + + +_loaded = False + + +def _ensure_loaded() -> None: + """Import adapter modules on first use (each registers itself). + Lazy so importing app.channels never pulls every adapter's deps.""" + global _loaded + if _loaded: + return + _loaded = True + from app.channels import telegram # noqa: F401 + from app.channels import whatsapp # noqa: F401 + from app.channels import messenger # noqa: F401 + from app.channels import instagram # noqa: F401 + from app.channels import slack # noqa: F401 + from app.channels import email # noqa: F401 + from app.channels import sms # noqa: F401 + from app.channels import line # noqa: F401 diff --git a/backend/app/channels/sent_choices.py b/backend/app/channels/sent_choices.py new file mode 100644 index 0000000..2a1d27b --- /dev/null +++ b/backend/app/channels/sent_choices.py @@ -0,0 +1,66 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +The stored record of the choices a message actually went out with — what the +inbox shows under it, and what a later tap on one of them is read against. +Encrypted: the choices can echo what the customer said. +""" + +from typing import Optional + +from app.channels.base import InteractiveMessage +from app.channels.interactive import all_choices, choice_text +from app.core.encryption import decrypt_json, encrypt_json + +# chat_history.attributes key the record is stored under +SENT_CHOICES_ATTRIBUTE = "interactive_sent" + + +def encode_sent_choices(message: InteractiveMessage, as_text: bool, numbered: bool = False) -> dict: + """The record for one sent message; `as_text` when the choices had to go + as a numbered list instead of buttons; `numbered` when the customer saw + them numbered either way (so a bare "2" picks the second).""" + return encrypt_json({ + "kind": message.kind.value, + "as_text": as_text, + "numbered": as_text or numbered, + "choices": [{"id": c.id, "text": choice_text(c)} for c in all_choices(message)], + }) + + +def decode_sent_choices(value) -> Optional[dict]: + record = decrypt_json(value) + return record if isinstance(record, dict) else None + + +def sent_numbered_choice(record: Optional[dict], typed: str) -> Optional[str]: + """The full text of the choice the customer typed the number of (1-based), + when the record's choices were shown numbered. A number that is itself + one of the choices ("2" guests) is that choice, not a position.""" + choices = (record or {}).get("choices") or [] + if not (record or {}).get("numbered") or not typed.isdecimal(): + return None + if any((choice.get("text") or "").strip() == typed for choice in choices): + return None + number = int(typed) + return choices[number - 1].get("text") if 1 <= number <= len(choices) else None + + +def sent_choice_text(record: Optional[dict], choice_id: str) -> Optional[str]: + """The full text of the choice with this id in a sent-choices record.""" + for choice in (record or {}).get("choices") or []: + if choice.get("id") == choice_id: + return choice.get("text") + return None diff --git a/backend/app/channels/slack.py b/backend/app/channels/slack.py new file mode 100644 index 0000000..7ac2cbc --- /dev/null +++ b/backend/app/channels/slack.py @@ -0,0 +1,366 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import hashlib +import hmac +import json +import re +import time +from datetime import datetime, timezone +from typing import ClassVar, List, Optional + +import httpx + +from app.channels.base import ChannelAdapter, InboundMessage, SendResult +from app.channels.registry import register_adapter +from app.core.config import settings +from app.core.security import decrypt_api_key +from app.models.channels import ChannelAccount, ChannelConversation, ChannelType +from app.core.logger import get_logger +from app.core.external_urls import external_base + +logger = get_logger(__name__) + +SLACK_API_BASE = external_base("SLACK_API_BASE", "https://slack.com/api") +REQUEST_TIMEOUT_SECONDS = 15.0 +# Reject webhook timestamps older than this (replay protection, per Slack docs) +MAX_SIGNATURE_AGE_SECONDS = 60 * 5 +# Slack recommends keeping messages well under the 40k hard limit +MAX_MESSAGE_LENGTH = 12000 + +# App Home / Assistant (Agents & AI Apps) copy — kept in one place so the tone +# stays consistent across the Messages tab, the Home tab, and the sidebar. +MAX_SUGGESTED_PROMPTS = 4 # Slack caps setSuggestedPrompts at 4 +ASSISTANT_READY_STATUS = "is getting ready…" +WELCOME_WITH_AGENT = ( + "Hi! I'm {agent}. 👋\n\n" + "Ask me anything and I'll help right away — I answer from your team's " + "knowledge and can bring in a human when you need one." +) +WELCOME_NO_AGENT = ( + "Hi! I'm ChatterMate. 👋\n\n" + "No agent is connected to this workspace yet. Set one up in ChatterMate and " + "I'll start answering here." +) +SUGGESTED_PROMPTS_TITLE = "Try asking:" +STARTER_PROMPTS = [ + {"title": "What can you help with?", "message": "What can you help me with?"}, + {"title": "Talk to a human", "message": "I'd like to talk to a human."}, +] + +_MENTION_RE = re.compile(r"<@[A-Z0-9]+>") + +_http_client: Optional[httpx.AsyncClient] = None + + +def _get_http_client() -> httpx.AsyncClient: + global _http_client + if _http_client is None or _http_client.is_closed: + _http_client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS) + return _http_client + + +async def slack_api(method: str, access_token: str, payload: dict, form: bool = False) -> dict: + # Write methods (chat.postMessage/update) accept JSON; read/query methods + # like users.info only read args from form-encoded params. + kwargs = {"data": payload} if form else {"json": payload} + response = await _get_http_client().post( + f"{SLACK_API_BASE}/{method}", + headers={"Authorization": f"Bearer {access_token}"}, + **kwargs, + ) + return response.json() + + +# --- App Home / Assistant API wrappers (Agents & AI Apps) ------------------- +# All take JSON, so they go through slack_api directly. Status and prompts are +# cosmetic — failures are logged, never raised, so a hiccup can't break the flow. + +async def set_assistant_status(token: str, channel_id: str, thread_ts: str, status: str) -> dict: + """Show a transient status line (e.g. 'is thinking…') in an assistant thread.""" + data = await slack_api("assistant.threads.setStatus", token, + {"channel_id": channel_id, "thread_ts": thread_ts, "status": status}) + if not data.get("ok"): + logger.debug(f"Slack assistant.threads.setStatus: {data.get('error')}") + return data + + +async def set_suggested_prompts(token: str, channel_id: str, thread_ts: str, + prompts: List[dict], title: Optional[str] = None) -> dict: + """Offer starter prompts in an assistant thread. `prompts` is capped at 4.""" + payload = {"channel_id": channel_id, "thread_ts": thread_ts, + "prompts": prompts[:MAX_SUGGESTED_PROMPTS]} + if title: + payload["title"] = title + data = await slack_api("assistant.threads.setSuggestedPrompts", token, payload) + if not data.get("ok"): + logger.debug(f"Slack assistant.threads.setSuggestedPrompts: {data.get('error')}") + return data + + +async def publish_home_view(token: str, user_id: str, view: dict) -> dict: + """Publish the App Home (Home tab) view for one user.""" + data = await slack_api("views.publish", token, {"user_id": user_id, "view": view}) + if not data.get("ok"): + logger.error(f"Slack views.publish failed: {data.get('error')}") + return data + + +# Home-tab presentation. The card is pre-resolved by the caller (photo URL +# signed) so this stays a pure, testable Block Kit builder. Per Slack's App Home +# guidance: lead with what the app does, surface the most relevant thing (the +# connected agent), keep call-to-actions minimal, link out to settings. +HOME_INSTRUCTION_MAX = 250 +_STATUS_ONLINE = ":large_green_circle: Online" +_STATUS_OFFLINE = ":white_circle: Offline" +HOME_INTRO = ( + "AI customer support, right inside Slack. @mention me in a channel and I'll " + "answer in the thread, or send me a direct message. My answers come from your " + "team's own knowledge, and I hand off to a human when a conversation needs one." +) +HOME_HOW_TO = ( + "*How to use me*\n" + "• @mention *ChatterMate* in any channel — I reply in the thread\n" + "• Send me a *direct message* for a private conversation\n" + "• Open me from the *assistant* in the top bar for a side-by-side chat" +) +HOME_NO_AGENT = ( + "*No agent connected yet*\nConnect an agent to this workspace from the " + "ChatterMate dashboard and I'll start answering here." +) +DASHBOARD_LINK_LABEL = "Open the ChatterMate dashboard →" + + +def build_home_view(agent_card: Optional[dict], dashboard_url: str) -> dict: + """Assemble the App Home view: what the app does, the connected agent, how to + use it, and a link to the dashboard. + + `agent_card` (or None if no agent is assigned): {"name", "is_active", + "instruction", "photo_url" | None}. + """ + blocks: List[dict] = [ + {"type": "header", "text": {"type": "plain_text", "text": "ChatterMate", "emoji": True}}, + {"type": "section", "text": {"type": "mrkdwn", "text": HOME_INTRO}}, + {"type": "divider"}, + ] + + if agent_card: + status = _STATUS_ONLINE if agent_card.get("is_active") else _STATUS_OFFLINE + header: List[dict] = [] + if agent_card.get("photo_url"): + header.append({"type": "image", "image_url": agent_card["photo_url"], + "alt_text": agent_card["name"]}) + header.append({"type": "mrkdwn", "text": f"*{agent_card['name']}* · {status}"}) + blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": "*Connected agent*"}}) + blocks.append({"type": "context", "elements": header}) + + instruction = (agent_card.get("instruction") or "").strip() + if instruction: + if len(instruction) > HOME_INSTRUCTION_MAX: + instruction = instruction[:HOME_INSTRUCTION_MAX - 1].rstrip() + "…" + blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": instruction}}) + else: + blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": HOME_NO_AGENT}}) + + blocks.append({"type": "divider"}) + blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": HOME_HOW_TO}}) + # A markdown link, not a Block Kit button — link buttons still require + # Interactivity to be enabled, which this app intentionally leaves off. + blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": f"<{dashboard_url}|{DASHBOARD_LINK_LABEL}>"}}) + + return {"type": "home", "blocks": blocks} + + +# Placeholder "typing…" message ts per (account, conversation) — set by +# send_typing, consumed by send_text which edits it into the real reply. +# In-memory is fine: a message's send_typing + send_text run in the same +# background task / worker. Each entry is (ts, created_at); stale entries are +# pruned so a bypassed send_text (e.g. AI error, empty reply) can't leak them. +_typing_placeholders: dict = {} +# A placeholder is only useful for the seconds between typing and the reply; +# anything older is a leak from a path that never called send_text. +_PLACEHOLDER_TTL_SECONDS = 300 + + +def _prune_placeholders(now: float) -> None: + stale = [key for key, (_, created) in _typing_placeholders.items() + if now - created > _PLACEHOLDER_TTL_SECONDS] + for key in stale: + _typing_placeholders.pop(key, None) + + +def verify_slack_signature(headers: dict, raw_body: bytes) -> bool: + """Validate X-Slack-Signature (v0 HMAC-SHA256 over 'v0:{ts}:{body}') with + replay protection. Fails closed when SLACK_SIGNING_SECRET is unset.""" + signing_secret = settings.SLACK_SIGNING_SECRET + timestamp = headers.get("x-slack-request-timestamp", "") + signature = headers.get("x-slack-signature", "") + if not signing_secret or not timestamp or not signature: + return False + try: + if abs(time.time() - int(timestamp)) > MAX_SIGNATURE_AGE_SECONDS: + return False + except ValueError: + return False + base = f"v0:{timestamp}:".encode() + raw_body + expected = "v0=" + hmac.new(signing_secret.encode(), base, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, signature) + + +class SlackAdapter(ChannelAdapter): + """Slack as a customer messaging channel on the unified stack. + + Conversation keying: DMs to the bot use the IM channel id (one continuous + conversation, like Telegram); @mentions in channels use `{channel}:{thread_ts}` + so each thread is its own conversation and replies stay in-thread. + """ + + channel_type: ClassVar[str] = ChannelType.SLACK.value + + async def verify_webhook(self, headers: dict, raw_body: bytes, account: Optional[ChannelAccount]) -> bool: + return verify_slack_signature(headers, raw_body) + + def parse_inbound(self, payload: dict) -> List[InboundMessage]: + """Normalize an Events API callback. Handles app_mention and direct + messages; bot echoes, edits, and other subtypes yield nothing.""" + event = payload.get("event") or {} + event_type = event.get("type") + team_id = payload.get("team_id", "") + + is_mention = event_type == "app_mention" + is_dm = event_type == "message" and event.get("channel_type") == "im" + if not (is_mention or is_dm): + return [] + # Ignore bot messages and subtypes (edits, joins, message_changed...) + if event.get("bot_id") or event.get("subtype"): + return [] + + text = _MENTION_RE.sub("", event.get("text") or "").strip() + if not text: + return [] + + channel = event.get("channel", "") + ts = event.get("ts", "") + if is_mention: + thread_ts = event.get("thread_ts") or ts + conversation_id = f"{channel}:{thread_ts}" + elif event.get("thread_ts"): + # Assistant-sidebar messages are DMs that carry the assistant thread's + # ts; key by thread so the reply posts back into that thread. Plain + # DMs have no thread_ts and stay one continuous conversation. + conversation_id = f"{channel}:{event['thread_ts']}" + else: + conversation_id = channel + + timestamp = None + try: + timestamp = datetime.fromtimestamp(float(ts), tz=timezone.utc) + except (TypeError, ValueError): + pass + + return [InboundMessage( + external_account_id=team_id, + external_conversation_id=conversation_id, + external_user_id=str(event.get("user", "")), + external_message_id=payload.get("event_id") or ts, + text=text, + timestamp=timestamp, + )] + + async def fetch_profile(self, account: ChannelAccount, external_user_id: str) -> dict: + """Resolve the Slack user's real name (and email, if the scope allows) + via users.info so customers show as a name, not a raw U0… id.""" + try: + data = await slack_api("users.info", self._access_token(account), + {"user": external_user_id}, form=True) + if not data.get("ok"): + return {} + user = data.get("user") or {} + profile = user.get("profile") or {} + name = (user.get("real_name") or profile.get("display_name") + or profile.get("real_name")) + return {"name": name or None, "email": profile.get("email")} + except Exception as e: + logger.error(f"Slack users.info failed: {e}") + return {} + + async def send_typing(self, account: ChannelAccount, conversation: ChannelConversation) -> None: + # Slack has no bot typing indicator; post a placeholder we later edit + # into the real reply so the customer sees an immediate "typing…". + channel, thread_ts = self._split_conversation(conversation.external_conversation_id) + payload = {"channel": channel, "text": "_typing…_", "mrkdwn": True} + if thread_ts: + payload["thread_ts"] = thread_ts + try: + _prune_placeholders(time.time()) + data = await slack_api("chat.postMessage", self._access_token(account), payload) + if data.get("ok"): + _typing_placeholders[self._placeholder_key(account, conversation)] = (data["ts"], time.time()) + except Exception as e: + logger.debug(f"Slack typing placeholder failed (non-critical): {e}") + + async def send_text(self, account: ChannelAccount, conversation: ChannelConversation, text: str) -> SendResult: + channel, thread_ts = self._split_conversation(conversation.external_conversation_id) + token = self._access_token(account) + + # If we posted a "typing…" placeholder, edit it into the reply + entry = _typing_placeholders.pop(self._placeholder_key(account, conversation), None) + placeholder_ts = entry[0] if entry else None + if placeholder_ts: + try: + data = await slack_api("chat.update", token, + {"channel": channel, "ts": placeholder_ts, "text": text}) + if data.get("ok"): + return SendResult(ok=True, external_message_id=str(data.get("ts", ""))) + # Update failed (e.g. placeholder deleted) — remove the stale + # "typing…" so we don't leave it beside the fresh reply. + await slack_api("chat.delete", token, {"channel": channel, "ts": placeholder_ts}) + except Exception as e: + logger.debug(f"Slack chat.update failed, posting new message: {e}") + + payload = {"channel": channel, "text": text} + if thread_ts: + payload["thread_ts"] = thread_ts + try: + data = await slack_api("chat.postMessage", token, payload) + if not data.get("ok"): + return SendResult(ok=False, error=str(data.get("error") or "chat.postMessage failed")) + return SendResult(ok=True, external_message_id=str(data.get("ts", ""))) + except Exception as e: + logger.error(f"Slack send failed: {e}") + return SendResult(ok=False, error=str(e)) + + def format_outbound(self, markdown: str) -> str: + # Slack mrkdwn uses single markers: **bold** -> *bold* + return markdown.replace("**", "*")[:MAX_MESSAGE_LENGTH] + + @staticmethod + def _placeholder_key(account: ChannelAccount, conversation: ChannelConversation) -> tuple: + return (str(account.id), conversation.external_conversation_id) + + @staticmethod + def _split_conversation(conversation_id: str) -> tuple[str, Optional[str]]: + if ":" in conversation_id: + channel, thread_ts = conversation_id.split(":", 1) + return channel, thread_ts + return conversation_id, None + + @staticmethod + def _access_token(account: ChannelAccount) -> str: + return json.loads(decrypt_api_key(account.encrypted_credentials))["access_token"] + + +register_adapter(SlackAdapter()) diff --git a/backend/app/channels/sms/__init__.py b/backend/app/channels/sms/__init__.py new file mode 100644 index 0000000..4273dae --- /dev/null +++ b/backend/app/channels/sms/__init__.py @@ -0,0 +1,38 @@ +""" +Copyright 2024-2026 ChatterMate + +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.sms.base import ( + SmsProvider, SmsWebhookRequest, CredentialField, + get_provider, list_providers, register_provider, +) +# Import provider modules so they self-register +from app.channels.sms import twilio # noqa: F401 +from app.channels.sms import vonage # noqa: F401 +from app.channels.sms import messagebird # noqa: F401 +from app.channels.sms import plivo # noqa: F401 +from app.channels.sms import brevo # noqa: F401 +from app.channels.sms import sns # noqa: F401 +# The channel adapter (registers itself for channel_type 'sms') +from app.channels.sms import adapter # noqa: F401 + +__all__ = [ + "SmsProvider", + "SmsWebhookRequest", + "CredentialField", + "get_provider", + "list_providers", + "register_provider", +] diff --git a/backend/app/channels/sms/adapter.py b/backend/app/channels/sms/adapter.py new file mode 100644 index 0000000..0add7fd --- /dev/null +++ b/backend/app/channels/sms/adapter.py @@ -0,0 +1,60 @@ +""" +Copyright 2024-2026 ChatterMate + +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 typing import ClassVar, List, Optional + +from app.channels.base import ChannelAdapter, InboundMessage, SendResult +from app.channels.registry import register_adapter +from app.channels.sms.base import MAX_MESSAGE_LENGTH, get_provider +from app.models.channels import ChannelAccount, ChannelConversation, ChannelType +from app.core.logger import get_logger + +logger = get_logger(__name__) + + +def account_provider_name(account: ChannelAccount) -> str: + """The SMS provider chosen for this account (stored non-secret in settings; + defaults to twilio for accounts connected before providers existed).""" + return (account.settings or {}).get("provider", "twilio") + + +class SmsAdapter(ChannelAdapter): + """The 'sms' channel. Inbound parsing/verification is provider-specific and + handled by the /webhooks/sms route; this adapter only dispatches outbound + sends to the account's provider.""" + + channel_type: ClassVar[str] = ChannelType.SMS.value + + async def verify_webhook(self, headers: dict, raw_body: bytes, account: Optional[ChannelAccount]) -> bool: + # SMS inbound is verified per-provider inside the webhook route. + return account is not None + + def parse_inbound(self, payload: dict) -> List[InboundMessage]: + # Inbound is parsed per-provider in the webhook route, not here. + return [] + + async def send_text(self, account: ChannelAccount, conversation: ChannelConversation, text: str) -> SendResult: + provider = get_provider(account_provider_name(account)) + if provider is None: + return SendResult(ok=False, error=f"Unknown SMS provider '{account_provider_name(account)}'") + return await provider.send(account, conversation.external_conversation_id, text) + + def format_outbound(self, markdown: str) -> str: + plain = markdown.replace("**", "").replace("__", "") + return plain[:MAX_MESSAGE_LENGTH] + + +register_adapter(SmsAdapter()) diff --git a/backend/app/channels/sms/base.py b/backend/app/channels/sms/base.py new file mode 100644 index 0000000..9a880f7 --- /dev/null +++ b/backend/app/channels/sms/base.py @@ -0,0 +1,136 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import json +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import ClassVar, Dict, List, Optional + +import httpx + +from app.channels.base import InboundMessage, SendResult +from app.core.security import decrypt_api_key +from app.models.channels import ChannelAccount +from app.core.logger import get_logger + +logger = get_logger(__name__) + +REQUEST_TIMEOUT_SECONDS = 15.0 +# Carriers segment long messages; keep replies to a sane cap +MAX_MESSAGE_LENGTH = 1600 + +_http_client: Optional[httpx.AsyncClient] = None + + +def get_http_client() -> httpx.AsyncClient: + global _http_client + if _http_client is None or _http_client.is_closed: + _http_client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS) + return _http_client + + +@dataclass +class SmsWebhookRequest: + """Everything a provider needs to verify and parse an inbound webhook, + normalized by the /webhooks/sms route so providers stay transport-agnostic.""" + headers: dict + params: dict # decoded form params (repeated keys → last) + param_pairs: List[tuple] # form params preserving order/duplicates + json_body: Optional[dict] # parsed JSON body (JSON providers) + raw_body: bytes + url: str # the public webhook URL (for signatures) + + +@dataclass +class CredentialField: + key: str + label: str + secret: bool = False + optional: bool = False + + +class SmsProvider(ABC): + """A pluggable SMS backend (Twilio, Vonage, ...). One SmsProvider per + vendor; all share the single 'sms' channel so conversations, badges and + agent routing stay uniform.""" + + name: ClassVar[str] + label: ClassVar[str] + # Credential fields the connect UI collects (besides the phone number) + credential_fields: ClassVar[List[CredentialField]] = [] + # True when the provider signs inbound webhooks and we need the raw body + signs_webhook: ClassVar[bool] = False + + async def verify_webhook(self, req: SmsWebhookRequest, account: ChannelAccount) -> bool: + """Authenticate an inbound webhook. Default: accept - providers that leave + signs_webhook False have no signature to check, and the route authenticates + them with the per-account URL token instead.""" + return True + + @abstractmethod + def parse_inbound(self, req: SmsWebhookRequest, account: ChannelAccount) -> List[InboundMessage]: + """Normalize an inbound SMS into InboundMessage(s).""" + + async def handle_control(self, req: SmsWebhookRequest, account: ChannelAccount) -> bool: + """Handle non-message control callbacks (e.g. SNS subscription + confirmation). Return True if fully handled — the route then stops.""" + return False + + @abstractmethod + async def send(self, account: ChannelAccount, to: str, text: str) -> SendResult: + """Send an SMS from the account's number to `to`.""" + + async def validate_credentials(self, phone_number: str, credentials: dict) -> None: + """Optionally verify credentials at connect time. Raise ValueError with + a user-facing message on failure. Default: no check.""" + return None + + @staticmethod + def credentials(account: ChannelAccount) -> dict: + return json.loads(decrypt_api_key(account.encrypted_credentials)) + + +_providers: Dict[str, SmsProvider] = {} + + +def register_provider(provider: SmsProvider) -> SmsProvider: + _providers[provider.name] = provider + return provider + + +def get_provider(name: str) -> Optional[SmsProvider]: + return _providers.get(name) + + +def list_providers() -> List[SmsProvider]: + return list(_providers.values()) + + +def inbound(to: str, sender: str, text: str, message_id: str) -> InboundMessage: + """Build a normalized InboundMessage for an SMS (conversation keyed by the + customer's number). external_message_id is left empty when the provider + gives none — the webhook skips dedupe rather than risk collapsing two + legitimate identical replies (e.g. "Yes", "Yes") into one.""" + return InboundMessage( + external_account_id=to, + external_conversation_id=sender, + external_user_id=sender, + external_message_id=message_id or "", + text=text, + # The sender *is* the customer's number; providers vary on the leading + # '+' (Twilio sends it, Vonage doesn't) — normalize_phone accepts both. + profile={"phone": sender}, + ) diff --git a/backend/app/channels/sms/brevo.py b/backend/app/channels/sms/brevo.py new file mode 100644 index 0000000..ba60b8f --- /dev/null +++ b/backend/app/channels/sms/brevo.py @@ -0,0 +1,74 @@ +""" +Copyright 2024-2026 ChatterMate + +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 typing import ClassVar, List + +from app.channels.base import InboundMessage, SendResult +from app.channels.sms.base import ( + CredentialField, SmsProvider, SmsWebhookRequest, + get_http_client, inbound, register_provider, +) +from app.models.channels import ChannelAccount +from app.core.logger import get_logger + +logger = get_logger(__name__) + +BREVO_SMS_API = "https://api.brevo.com/v3/transactionalSMS/sms" + + +class BrevoProvider(SmsProvider): + """Brevo transactional SMS. Outbound only in most plans — inbound replies + depend on Brevo's inbound SMS support; when available they post to the + per-account webhook URL (authenticated by its token, not a signature).""" + + name: ClassVar[str] = "brevo" + label: ClassVar[str] = "Brevo" + credential_fields: ClassVar[List[CredentialField]] = [ + CredentialField("api_key", "API key", secret=True), + ] + + def parse_inbound(self, req: SmsWebhookRequest, account: ChannelAccount) -> List[InboundMessage]: + # Brevo inbound payloads vary; accept the common field names. + p = req.json_body or req.params + sender = p.get("from") or p.get("sender") or p.get("msisdn") or "" + text = (p.get("text") or p.get("message") or p.get("content") or "").strip() + if not sender or not text: + return [] + recipient = str(p.get("to") or account.external_account_id) + return [inbound(recipient, sender, text, str(p.get("id") or p.get("messageId") or ""))] + + async def send(self, account: ChannelAccount, to: str, text: str) -> SendResult: + creds = self.credentials(account) + try: + response = await get_http_client().post( + BREVO_SMS_API, + headers={"api-key": creds["api_key"], "content-type": "application/json"}, + json={"sender": account.external_account_id, "recipient": to, "content": text}, + ) + data = response.json() + if response.status_code >= 300: + return SendResult(ok=False, error=str(data.get("message") or f"HTTP {response.status_code}")) + return SendResult(ok=True, external_message_id=str(data.get("messageId", ""))) + except Exception as e: + logger.error(f"Brevo send failed: {e}") + return SendResult(ok=False, error=str(e)) + + async def validate_credentials(self, phone_number: str, credentials: dict) -> None: + if not credentials.get("api_key"): + raise ValueError("API key is required") + + +register_provider(BrevoProvider()) diff --git a/backend/app/channels/sms/messagebird.py b/backend/app/channels/sms/messagebird.py new file mode 100644 index 0000000..b87ad48 --- /dev/null +++ b/backend/app/channels/sms/messagebird.py @@ -0,0 +1,88 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import base64 +import hashlib +import hmac +from typing import ClassVar, List + +from app.channels.base import InboundMessage, SendResult +from app.channels.sms.base import ( + CredentialField, SmsProvider, SmsWebhookRequest, + get_http_client, inbound, register_provider, +) +from app.models.channels import ChannelAccount +from app.core.logger import get_logger + +logger = get_logger(__name__) + +MESSAGEBIRD_API = "https://rest.messagebird.com/messages" + + +class MessageBirdProvider(SmsProvider): + name: ClassVar[str] = "messagebird" + label: ClassVar[str] = "MessageBird (Bird)" + credential_fields: ClassVar[List[CredentialField]] = [ + CredentialField("access_key", "Access key", secret=True), + CredentialField("signing_key", "Signing key (optional)", secret=True, optional=True), + ] + + async def verify_webhook(self, req: SmsWebhookRequest, account: ChannelAccount) -> bool: + signing_key = self.credentials(account).get("signing_key") + if not signing_key: + return True + timestamp = req.headers.get("messagebird-request-timestamp", "") + signature = req.headers.get("messagebird-signature", "") + if not (timestamp and signature): + return False + # v1: base64(HMAC-SHA256(key, "{ts}\n{url}\n{sha256(body)}")) + body_hash = hashlib.sha256(req.raw_body).digest() + parts = timestamp.encode() + b"\n" + req.url.encode() + b"\n" + body_hash + expected = base64.b64encode(hmac.new(signing_key.encode(), parts, hashlib.sha256).digest()).decode() + return hmac.compare_digest(expected, signature) + + def parse_inbound(self, req: SmsWebhookRequest, account: ChannelAccount) -> List[InboundMessage]: + p = req.json_body or req.params + sender = p.get("originator") or p.get("sender") or "" + text = (p.get("payload") or p.get("body") or "").strip() + if not sender or not text: + return [] + recipient = str(p.get("recipient") or account.external_account_id) + return [inbound(recipient, sender, text, str(p.get("id", "")))] + + async def send(self, account: ChannelAccount, to: str, text: str) -> SendResult: + creds = self.credentials(account) + try: + response = await get_http_client().post( + MESSAGEBIRD_API, + headers={"Authorization": f"AccessKey {creds['access_key']}"}, + json={"originator": account.external_account_id, "recipients": [to], "body": text}, + ) + data = response.json() + if response.status_code >= 300: + errors = data.get("errors") or [{}] + return SendResult(ok=False, error=str(errors[0].get("description") or f"HTTP {response.status_code}")) + return SendResult(ok=True, external_message_id=str(data.get("id", ""))) + except Exception as e: + logger.error(f"MessageBird send failed: {e}") + return SendResult(ok=False, error=str(e)) + + async def validate_credentials(self, phone_number: str, credentials: dict) -> None: + if not credentials.get("access_key"): + raise ValueError("Access key is required") + + +register_provider(MessageBirdProvider()) diff --git a/backend/app/channels/sms/plivo.py b/backend/app/channels/sms/plivo.py new file mode 100644 index 0000000..2441c35 --- /dev/null +++ b/backend/app/channels/sms/plivo.py @@ -0,0 +1,95 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import base64 +import hashlib +import hmac +from typing import ClassVar, List + +from app.channels.base import InboundMessage, SendResult +from app.channels.sms.base import ( + CredentialField, SmsProvider, SmsWebhookRequest, + get_http_client, inbound, register_provider, +) +from app.models.channels import ChannelAccount +from app.core.logger import get_logger +from app.core.external_urls import external_base + +logger = get_logger(__name__) + +PLIVO_API_BASE = external_base("PLIVO_API_BASE", "https://api.plivo.com/v1") + + +class PlivoProvider(SmsProvider): + name: ClassVar[str] = "plivo" + label: ClassVar[str] = "Plivo" + signs_webhook: ClassVar[bool] = True + credential_fields: ClassVar[List[CredentialField]] = [ + CredentialField("auth_id", "Auth ID"), + CredentialField("auth_token", "Auth token", secret=True), + ] + + async def verify_webhook(self, req: SmsWebhookRequest, account: ChannelAccount) -> bool: + """X-Plivo-Signature-V3: base64(HMAC-SHA256(auth_token, url + nonce)).""" + auth_token = self.credentials(account).get("auth_token", "") + signature = req.headers.get("x-plivo-signature-v3", "") + nonce = req.headers.get("x-plivo-signature-v3-nonce", "") + if not (auth_token and signature and nonce): + return False + expected = base64.b64encode( + hmac.new(auth_token.encode(), (req.url + nonce).encode(), hashlib.sha256).digest() + ).decode() + # Plivo may send multiple comma-separated signatures + return any(hmac.compare_digest(expected, s) for s in signature.split(",")) + + def parse_inbound(self, req: SmsWebhookRequest, account: ChannelAccount) -> List[InboundMessage]: + p = req.params + sender, text = p.get("From", ""), (p.get("Text") or "").strip() + if not sender or not text: + return [] + return [inbound(p.get("To", ""), sender, text, p.get("MessageUUID", ""))] + + async def send(self, account: ChannelAccount, to: str, text: str) -> SendResult: + creds = self.credentials(account) + try: + response = await get_http_client().post( + f"{PLIVO_API_BASE}/Account/{creds['auth_id']}/Message/", + auth=(creds["auth_id"], creds["auth_token"]), + json={"src": account.external_account_id, "dst": to, "text": text}, + ) + data = response.json() + if response.status_code >= 300: + return SendResult(ok=False, error=str(data.get("error") or f"HTTP {response.status_code}")) + ids = data.get("message_uuid") or [] + return SendResult(ok=True, external_message_id=ids[0] if ids else None) + except Exception as e: + logger.error(f"Plivo send failed: {e}") + return SendResult(ok=False, error=str(e)) + + async def validate_credentials(self, phone_number: str, credentials: dict) -> None: + auth_id, token = credentials.get("auth_id"), credentials.get("auth_token") + if not (auth_id and token): + raise ValueError("Auth ID and auth token are required") + try: + response = await get_http_client().get( + f"{PLIVO_API_BASE}/Account/{auth_id}/", auth=(auth_id, token)) + except Exception as e: + raise ValueError(f"Could not reach Plivo: {e}") + if response.status_code >= 300: + raise ValueError("Invalid Plivo Auth ID or auth token") + + +register_provider(PlivoProvider()) diff --git a/backend/app/channels/sms/sns.py b/backend/app/channels/sms/sns.py new file mode 100644 index 0000000..b2a214f --- /dev/null +++ b/backend/app/channels/sms/sns.py @@ -0,0 +1,173 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import asyncio +import base64 +import json +import re +from typing import ClassVar, List, Optional +from urllib.parse import urlparse + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.x509 import load_pem_x509_certificate + +from app.channels.base import InboundMessage, SendResult +from app.channels.sms.base import ( + CredentialField, SmsProvider, SmsWebhookRequest, + get_http_client, inbound, register_provider, +) +from app.models.channels import ChannelAccount +from app.core.logger import get_logger + +logger = get_logger(__name__) + +# Signed-string field order per SNS message type +_SIGN_FIELDS = { + "Notification": ["Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type"], + "SubscriptionConfirmation": ["Message", "MessageId", "SubscribeURL", "Timestamp", "Token", "TopicArn", "Type"], + "UnsubscribeConfirmation": ["Message", "MessageId", "SubscribeURL", "Timestamp", "Token", "TopicArn", "Type"], +} +_cert_cache: dict = {} + + +def _string_to_sign(body: dict) -> Optional[str]: + fields = _SIGN_FIELDS.get(body.get("Type")) + if not fields: + return None + parts = [] + for key in fields: + if key in body and body[key] is not None: + parts.append(f"{key}\n{body[key]}\n") + return "".join(parts) + + +# Only genuine SNS endpoints — NOT any *.amazonaws.com (e.g. an attacker could +# host a spoofed cert on *.s3.amazonaws.com). AWS SNS uses sns..amazonaws.com. +_SNS_HOST_RE = re.compile(r"^sns\.[a-z0-9-]+\.amazonaws\.com$") + + +def _valid_sns_host(url: str) -> bool: + parsed = urlparse(url) + return parsed.scheme == "https" and parsed.hostname is not None and \ + bool(_SNS_HOST_RE.match(parsed.hostname)) + + +class SnsProvider(SmsProvider): + """AWS SNS / End User Messaging. Outbound via SNS Publish; inbound arrives + as SNS HTTPS-subscription notifications (two-way SMS → SNS topic).""" + + name: ClassVar[str] = "sns" + label: ClassVar[str] = "AWS SNS" + signs_webhook: ClassVar[bool] = True + credential_fields: ClassVar[List[CredentialField]] = [ + CredentialField("aws_access_key_id", "AWS access key ID"), + CredentialField("aws_secret_access_key", "AWS secret access key", secret=True), + CredentialField("region", "AWS region (e.g. us-east-1)"), + CredentialField("topic_arn", "SNS topic ARN (optional)", optional=True), + ] + + async def verify_webhook(self, req: SmsWebhookRequest, account: ChannelAccount) -> bool: + body = req.json_body or {} + configured_topic = self.credentials(account).get("topic_arn") + if configured_topic and body.get("TopicArn") != configured_topic: + return False + cert_url = body.get("SigningCertURL", "") + if not _valid_sns_host(cert_url): + return False + string_to_sign = _string_to_sign(body) + signature = body.get("Signature") + if not string_to_sign or not signature: + return False + try: + cert_pem = await self._fetch_cert(cert_url) + algorithm = hashes.SHA256() if body.get("SignatureVersion") == "2" else hashes.SHA1() + public_key = load_pem_x509_certificate(cert_pem).public_key() + public_key.verify(base64.b64decode(signature), string_to_sign.encode(), + padding.PKCS1v15(), algorithm) + return True + except Exception as e: + logger.error(f"SNS signature verification failed: {e}") + return False + + async def handle_control(self, req: SmsWebhookRequest, account: ChannelAccount) -> bool: + """Auto-confirm the SNS HTTPS subscription by visiting SubscribeURL.""" + body = req.json_body or {} + if body.get("Type") == "SubscriptionConfirmation" and body.get("SubscribeURL"): + # Only fetch AWS-hosted SubscribeURLs — verify_webhook already ran, + # but restrict here too to avoid any SSRF to an attacker URL. + if not _valid_sns_host(body["SubscribeURL"]): + logger.warning(f"Refusing non-SNS SubscribeURL for account {account.id}") + return True + try: + await get_http_client().get(body["SubscribeURL"]) + logger.info(f"Confirmed SNS subscription for account {account.id}") + except Exception as e: + logger.error(f"SNS subscription confirmation failed: {e}") + return True + return body.get("Type") == "UnsubscribeConfirmation" + + def parse_inbound(self, req: SmsWebhookRequest, account: ChannelAccount) -> List[InboundMessage]: + body = req.json_body or {} + if body.get("Type") != "Notification": + return [] + message = body.get("Message") + try: + sms = json.loads(message) if isinstance(message, str) else (message or {}) + except (ValueError, TypeError): + return [] + sender = sms.get("originationNumber", "") + text = (sms.get("messageBody") or "").strip() + if not sender or not text: + return [] + return [inbound(sms.get("destinationNumber", ""), sender, text, body.get("MessageId", ""))] + + async def send(self, account: ChannelAccount, to: str, text: str) -> SendResult: + creds = self.credentials(account) + try: + message_id = await asyncio.to_thread(self._publish, creds, to, text) + return SendResult(ok=True, external_message_id=message_id) + except Exception as e: + logger.error(f"SNS send failed: {e}") + return SendResult(ok=False, error=str(e)) + + @staticmethod + def _publish(creds: dict, to: str, text: str) -> str: + import boto3 + client = boto3.client( + "sns", + aws_access_key_id=creds["aws_access_key_id"], + aws_secret_access_key=creds["aws_secret_access_key"], + region_name=creds.get("region"), + ) + result = client.publish(PhoneNumber=to, Message=text) + return result.get("MessageId", "") + + async def _fetch_cert(self, url: str) -> bytes: + if url in _cert_cache: + return _cert_cache[url] + response = await get_http_client().get(url) + cert = response.content + _cert_cache[url] = cert + return cert + + async def validate_credentials(self, phone_number: str, credentials: dict) -> None: + for key in ("aws_access_key_id", "aws_secret_access_key", "region"): + if not credentials.get(key): + raise ValueError("AWS access key, secret, and region are required") + + +register_provider(SnsProvider()) diff --git a/backend/app/channels/sms/twilio.py b/backend/app/channels/sms/twilio.py new file mode 100644 index 0000000..943bdd0 --- /dev/null +++ b/backend/app/channels/sms/twilio.py @@ -0,0 +1,104 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import base64 +import hashlib +import hmac +from typing import ClassVar, List + +from app.channels.base import InboundMessage, SendResult +from app.channels.sms.base import ( + CredentialField, SmsProvider, SmsWebhookRequest, + get_http_client, inbound, register_provider, +) +from app.models.channels import ChannelAccount +from app.core.logger import get_logger +from app.core.external_urls import external_base + +logger = get_logger(__name__) + +TWILIO_API_BASE = external_base("TWILIO_API_BASE", "https://api.twilio.com/2010-04-01") + + +def _verify_signature(auth_token: str, url: str, param_pairs, signature: str) -> bool: + """X-Twilio-Signature: base64(HMAC-SHA1(auth_token, url + sorted concatenated + key/values)). param_pairs preserves repeated keys. + + HMAC-SHA1 is mandated by Twilio's signature spec (not a free choice); HMAC + with SHA1 remains secure for message authentication. + """ + if not auth_token or not signature: + return False + payload = url + "".join(f"{k}{v}" for k, v in sorted(param_pairs, key=lambda kv: kv[0])) + expected = base64.b64encode( + hmac.new(auth_token.encode(), payload.encode(), hashlib.sha1).digest() # noqa: S324 - Twilio protocol + ).decode() + return hmac.compare_digest(expected, signature) + + +class TwilioProvider(SmsProvider): + name: ClassVar[str] = "twilio" + label: ClassVar[str] = "Twilio" + signs_webhook: ClassVar[bool] = True + credential_fields: ClassVar[List[CredentialField]] = [ + CredentialField("account_sid", "Account SID"), + CredentialField("auth_token", "Auth token", secret=True), + ] + + async def verify_webhook(self, req: SmsWebhookRequest, account: ChannelAccount) -> bool: + return _verify_signature( + self.credentials(account).get("auth_token", ""), + req.url, req.param_pairs, + req.headers.get("x-twilio-signature", ""), + ) + + def parse_inbound(self, req: SmsWebhookRequest, account: ChannelAccount) -> List[InboundMessage]: + p = req.params + sender, body = p.get("From", ""), (p.get("Body") or "").strip() + if not sender or not body: + return [] + return [inbound(p.get("To", ""), sender, body, p.get("MessageSid", ""))] + + async def send(self, account: ChannelAccount, to: str, text: str) -> SendResult: + creds = self.credentials(account) + try: + response = await get_http_client().post( + f"{TWILIO_API_BASE}/Accounts/{creds['account_sid']}/Messages.json", + auth=(creds["account_sid"], creds["auth_token"]), + data={"From": account.external_account_id, "To": to, "Body": text}, + ) + data = response.json() + if response.status_code >= 300: + return SendResult(ok=False, error=str(data.get("message") or f"HTTP {response.status_code}")) + return SendResult(ok=True, external_message_id=data.get("sid")) + except Exception as e: + logger.error(f"Twilio send failed: {e}") + return SendResult(ok=False, error=str(e)) + + async def validate_credentials(self, phone_number: str, credentials: dict) -> None: + sid, token = credentials.get("account_sid"), credentials.get("auth_token") + if not (sid and token): + raise ValueError("Account SID and auth token are required") + try: + response = await get_http_client().get( + f"{TWILIO_API_BASE}/Accounts/{sid}.json", auth=(sid, token)) + except Exception as e: + raise ValueError(f"Could not reach Twilio: {e}") + if response.status_code >= 300: + raise ValueError("Invalid Twilio Account SID or auth token") + + +register_provider(TwilioProvider()) diff --git a/backend/app/channels/sms/vonage.py b/backend/app/channels/sms/vonage.py new file mode 100644 index 0000000..4a6b863 --- /dev/null +++ b/backend/app/channels/sms/vonage.py @@ -0,0 +1,104 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import hashlib +import hmac +from typing import ClassVar, List + +from app.channels.base import InboundMessage, SendResult +from app.channels.sms.base import ( + CredentialField, SmsProvider, SmsWebhookRequest, + get_http_client, inbound, register_provider, +) +from app.models.channels import ChannelAccount +from app.core.logger import get_logger + +logger = get_logger(__name__) + +VONAGE_SMS_URL = "https://rest.nexmo.com/sms/json" + + +def _verify_hmac_signature(params: dict, signature_secret: str) -> bool: + """Vonage HMAC-SHA256 signed-webhook check: HMAC-SHA256(secret) over the + sorted params (excluding `sig`, with `&`/`=` in values replaced by `_`). + + Configure the Vonage account's signature method as "SHA-256 HMAC". The + legacy MD5 method is intentionally not supported (weak hash). + """ + provided = params.get("sig", "") + if not provided: + return False + parts = [] + for key in sorted(k for k in params if k != "sig"): + value = str(params[key]).replace("&", "_").replace("=", "_") + parts.append(f"&{key}={value}") + digest = hmac.new( + signature_secret.encode(), "".join(parts).encode(), hashlib.sha256 + ).hexdigest().upper() + return hmac.compare_digest(digest, provided.upper()) + + +class VonageProvider(SmsProvider): + name: ClassVar[str] = "vonage" + label: ClassVar[str] = "Vonage (Nexmo)" + credential_fields: ClassVar[List[CredentialField]] = [ + CredentialField("api_key", "API key"), + CredentialField("api_secret", "API secret", secret=True), + CredentialField("signature_secret", "Signature secret (optional)", secret=True, optional=True), + ] + + async def verify_webhook(self, req: SmsWebhookRequest, account: ChannelAccount) -> bool: + secret = self.credentials(account).get("signature_secret") + if not secret: + # No signature configured - the route authenticates this account with the + # per-account webhook token instead (signs_webhook is False). + return True + source = req.json_body or req.params + return _verify_hmac_signature(source, secret) + + def parse_inbound(self, req: SmsWebhookRequest, account: ChannelAccount) -> List[InboundMessage]: + p = req.json_body or req.params + sender = p.get("msisdn", "") + text = (p.get("text") or "").strip() + if not sender or not text: + return [] + return [inbound(p.get("to", ""), sender, text, p.get("messageId", ""))] + + async def send(self, account: ChannelAccount, to: str, text: str) -> SendResult: + creds = self.credentials(account) + try: + response = await get_http_client().post(VONAGE_SMS_URL, data={ + "api_key": creds["api_key"], + "api_secret": creds["api_secret"], + "from": account.external_account_id, + "to": to, + "text": text, + }) + data = response.json() + message = (data.get("messages") or [{}])[0] + if message.get("status") != "0": + return SendResult(ok=False, error=str(message.get("error-text") or "Vonage send failed")) + return SendResult(ok=True, external_message_id=message.get("message-id")) + except Exception as e: + logger.error(f"Vonage send failed: {e}") + return SendResult(ok=False, error=str(e)) + + async def validate_credentials(self, phone_number: str, credentials: dict) -> None: + if not (credentials.get("api_key") and credentials.get("api_secret")): + raise ValueError("API key and API secret are required") + + +register_provider(VonageProvider()) diff --git a/backend/app/channels/telegram.py b/backend/app/channels/telegram.py new file mode 100644 index 0000000..11b88c8 --- /dev/null +++ b/backend/app/channels/telegram.py @@ -0,0 +1,309 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import hmac +import json +from datetime import datetime, timezone +from typing import ClassVar, List, Optional + +import httpx + +from app.channels.base import ( + ChannelAdapter, ChannelInteraction, InboundMessage, InteractiveKind, InteractiveMessage, + InteractiveReply, InteractiveReplyType, SendResult, +) +from app.channels.interactive_limits import TELEGRAM_LIMITS, InteractiveLimits +from app.channels.registry import register_adapter +from app.models.channels import ChannelAccount, ChannelConversation, ChannelType +from app.core.security import decrypt_api_key +from app.core.logger import get_logger +from app.core.external_urls import external_base + +logger = get_logger(__name__) + +TELEGRAM_API_BASE = external_base("TELEGRAM_API_BASE", "https://api.telegram.org") +# https://core.telegram.org/bots/api#sendmessage +MAX_MESSAGE_LENGTH = TELEGRAM_LIMITS.body_max +REQUEST_TIMEOUT_SECONDS = 15.0 + +# Shared client: keep-alive connection pooling to api.telegram.org instead of +# a TCP+TLS handshake per call (process-lifetime, like the DB engine). +_http_client: Optional[httpx.AsyncClient] = None + + +def _get_http_client() -> httpx.AsyncClient: + global _http_client + if _http_client is None or _http_client.is_closed: + _http_client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS) + return _http_client + + +async def _call_api(bot_token: str, method: str, payload: Optional[dict] = None) -> dict: + """Call a Telegram Bot API method; returns the decoded JSON envelope.""" + url = f"{TELEGRAM_API_BASE}/bot{bot_token}/{method}" + response = await _get_http_client().post(url, json=payload or {}) + return response.json() + + +async def get_me(bot_token: str) -> Optional[dict]: + """Validate a bot token; returns the bot's user object or None.""" + try: + data = await _call_api(bot_token, "getMe") + return data.get("result") if data.get("ok") else None + except Exception as e: + logger.error(f"Telegram getMe failed: {e}") + return None + + +async def set_webhook(bot_token: str, url: str, secret_token: str) -> tuple[bool, str]: + """Register our webhook with Telegram. Returns (ok, error_description) — + the description surfaces Telegram's reason (e.g. 'an HTTPS URL must be + provided for webhook' when BACKEND_URL points at localhost).""" + try: + data = await _call_api(bot_token, "setWebhook", { + "url": url, + "secret_token": secret_token, + # message covers customer texts and shared contacts; + # callback_query, taps on the choices we send + "allowed_updates": ["message", "callback_query"], + }) + if data.get("ok"): + return True, "" + description = str(data.get("description") or "setWebhook failed") + logger.error(f"Telegram setWebhook rejected for {url}: {description}") + return False, description + except Exception as e: + logger.error(f"Telegram setWebhook failed: {e}") + return False, str(e) + + +async def delete_webhook(bot_token: str) -> bool: + try: + data = await _call_api(bot_token, "deleteWebhook") + return bool(data.get("ok")) + except Exception as e: + logger.error(f"Telegram deleteWebhook failed: {e}") + return False + + +# A tap's message id, kept apart from message ids (which only count up per chat) +CALLBACK_ID_PREFIX = "cbq:" +PRIVATE_CHAT = "private" +TAP_NOT_TAKEN = "This choice is no longer available" + + +def callback_answer(payload: dict, taken: bool) -> Optional[dict]: + """The webhook response that answers a tap — Telegram keeps the button + spinning until a tap is answered, and accepts the answer as the reply to + its webhook request. A tap we didn't act on (a second tap, a group chat, + an old message) says so. None for any other update.""" + callback = payload.get("callback_query") or {} + if not callback.get("id"): + return None + answer = {"method": "answerCallbackQuery", "callback_query_id": callback["id"]} + if not taken: + answer["text"] = TAP_NOT_TAKEN + return answer + + +def dedupe_id(inbound: InboundMessage) -> str: + """What makes an update a repeat: a tap's keyboard (one answer per + keyboard), otherwise the message itself.""" + if inbound.interactive is not None and inbound.reply_to_message_id: + return f"keyboard:{inbound.reply_to_message_id}" + return inbound.external_message_id + + +class TelegramAdapter(ChannelAdapter): + channel_type: ClassVar[str] = ChannelType.TELEGRAM.value + # Choices go as an inline keyboard (buttons only) + interactive_limits: ClassVar[Optional[InteractiveLimits]] = TELEGRAM_LIMITS + max_text_length: ClassVar[Optional[int]] = MAX_MESSAGE_LENGTH + + async def verify_webhook(self, headers: dict, raw_body: bytes, account: Optional[ChannelAccount]) -> bool: + """Telegram echoes the secret_token given to setWebhook on every request.""" + if account is None: + return False + provided = headers.get("x-telegram-bot-api-secret-token", "") + return hmac.compare_digest(provided, account.webhook_secret or "") + + def parse_inbound(self, payload: dict) -> List[InboundMessage]: + """Normalize a Telegram Update. Plain user text messages and taps on + our buttons become InboundMessages; bot echoes, shared contacts + (handled as interactions) and service updates are ignored.""" + if payload.get("callback_query"): + tap = _tap(payload["callback_query"]) + return [tap] if tap is not None else [] + message = payload.get("message") or {} + sender = message.get("from") or {} + chat = message.get("chat") or {} + if not message or sender.get("is_bot") or "id" not in chat: + return [] + + text = message.get("text") or message.get("caption") + if not text: + return [] # media / contact-only messages aren't customer text turns + + name = " ".join(filter(None, [sender.get("first_name"), sender.get("last_name")])) or sender.get("username") + timestamp = None + if message.get("date"): + timestamp = datetime.fromtimestamp(message["date"], tz=timezone.utc) + + return [InboundMessage( + external_account_id="", # resolved from the webhook path, not the payload + external_conversation_id=str(chat["id"]), + external_user_id=str(sender.get("id", chat["id"])), + external_message_id=str(message.get("message_id", "")), + text=text, + profile={"name": name, "username": sender.get("username")}, + timestamp=timestamp, + )] + + def parse_interaction(self, payload: dict): + """Detect a shared contact (message.contact) — the share-phone flow. + Bypasses the AI pipeline.""" + message = payload.get("message") or {} + contact = message.get("contact") + chat = message.get("chat") or {} + if contact and "id" in chat: + sender = message.get("from") or {} + # Only accept the sender's OWN contact (the share-phone button flow); + # a forwarded third-party contact card has a different/absent user_id. + if contact.get("user_id") != sender.get("id"): + return None + return ChannelInteraction( + type="contact", + external_account_id="", + external_conversation_id=str(chat["id"]), + external_user_id=str(sender.get("id", chat["id"])), + phone=contact.get("phone_number"), + profile={"name": " ".join(filter(None, [contact.get("first_name"), contact.get("last_name")]))}, + ) + return None + + async def send_text(self, account: ChannelAccount, conversation: ChannelConversation, text: str) -> SendResult: + return await self._send_message(account, conversation.external_conversation_id, text) + + def choice_limits_for(self, conversation: ChannelConversation) -> Optional[InteractiveLimits]: + """Buttons only in a private chat with the bot: in a group anyone could + tap them, so taps there are ignored (Telegram's group ids are negative).""" + if str(conversation.external_conversation_id).startswith("-"): + return None + return self.interactive_limits + + async def send_interactive(self, account: ChannelAccount, conversation: ChannelConversation, + message: InteractiveMessage) -> SendResult: + """The choices as an inline keyboard under the text, one per row.""" + if message.kind != InteractiveKind.BUTTONS: + return SendResult(ok=False, error="Telegram only sends choices as buttons") + keyboard = {"inline_keyboard": [[{"text": b.title, "callback_data": b.id}] for b in message.buttons]} + return await self._send_message(account, conversation.external_conversation_id, message.body, + reply_markup=keyboard) + + async def clear_buttons(self, account: ChannelAccount, chat_id: str, message_id: Optional[str]) -> None: + """Take the keyboard off a message once one of its buttons was tapped. + Best effort: a keyboard left in place is only a stale one.""" + if not message_id: + return + try: + await _call_api(self._bot_token(account), "editMessageReplyMarkup", { + "chat_id": chat_id, "message_id": int(message_id), + "reply_markup": {"inline_keyboard": []}, + }) + except Exception as e: + logger.debug(f"Telegram editMessageReplyMarkup failed (non-critical): {e}") + + async def send_typing(self, account: ChannelAccount, conversation: ChannelConversation) -> None: + try: + await _call_api(self._bot_token(account), "sendChatAction", { + "chat_id": conversation.external_conversation_id, + "action": "typing", + }) + except Exception as e: + logger.debug(f"Telegram sendChatAction failed (non-critical): {e}") + + async def request_phone(self, account: ChannelAccount, conversation: ChannelConversation, + text: str) -> SendResult: + # One-time reply keyboard with a share-contact button + keyboard = { + "keyboard": [[{"text": "📱 Share my phone number", "request_contact": True}]], + "resize_keyboard": True, + "one_time_keyboard": True, + } + return await self._send_message(account, conversation.external_conversation_id, text, + reply_markup=keyboard) + + async def _send_message(self, account: ChannelAccount, chat_id: str, text: str, + reply_markup: dict = None) -> SendResult: + try: + payload = {"chat_id": chat_id, "text": text} + if reply_markup is not None: + payload["reply_markup"] = reply_markup + data = await _call_api(self._bot_token(account), "sendMessage", payload) + if not data.get("ok"): + return SendResult(ok=False, error=str(data.get("description") or "sendMessage failed")) + return SendResult(ok=True, external_message_id=str(data["result"].get("message_id", ""))) + except Exception as e: + logger.error(f"Telegram send failed: {e}") + return SendResult(ok=False, error=str(e)) + + def format_outbound(self, markdown: str) -> str: + """Telegram is sent as plain text (no parse_mode) — markdown renders + readably and escaping pitfalls are avoided. Enforce the length cap.""" + return markdown[:MAX_MESSAGE_LENGTH] + + @staticmethod + def _bot_token(account: ChannelAccount) -> str: + return json.loads(decrypt_api_key(account.encrypted_credentials))["bot_token"] + + +def _tap(callback: dict) -> Optional[InboundMessage]: + """A tap on one of our buttons, in a private chat with the bot (in a + group anyone could tap it). The tap carries only the button's id; its + title is read from the keyboard it was on. Dropped when that message is + gone or no longer accessible.""" + message = callback.get("message") or {} + chat = message.get("chat") or {} + sender = callback.get("from") or {} + data = callback.get("data") + if not data or not message.get("date") or chat.get("type") != PRIVATE_CHAT or "id" not in chat: + return None + title = _button_title(message.get("reply_markup") or {}, data) + if title is None: + return None + name = " ".join(filter(None, [sender.get("first_name"), sender.get("last_name")])) or sender.get("username") + return InboundMessage( + external_account_id="", # resolved from the webhook path, not the payload + external_conversation_id=str(chat["id"]), + external_user_id=str(sender.get("id", chat["id"])), + external_message_id=f"{CALLBACK_ID_PREFIX}{callback['id']}", + text=title, + profile={"name": name, "username": sender.get("username")}, + timestamp=datetime.now(timezone.utc), + interactive=InteractiveReply(type=InteractiveReplyType.BUTTON_REPLY, id=str(data), title=title), + reply_to_message_id=str(message.get("message_id", "")) or None, + ) + + +def _button_title(reply_markup: dict, data: str) -> Optional[str]: + for row in reply_markup.get("inline_keyboard") or []: + for button in row: + if button.get("callback_data") == data: + return button.get("text") + return None + + +register_adapter(TelegramAdapter()) diff --git a/backend/app/channels/whatsapp.py b/backend/app/channels/whatsapp.py new file mode 100644 index 0000000..eb4d9b7 --- /dev/null +++ b/backend/app/channels/whatsapp.py @@ -0,0 +1,308 @@ +""" +Copyright 2024-2026 ChatterMate + +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 typing import ClassVar, List, Optional + +from app.channels.base import ( + DeliveryState, InboundMessage, InteractiveKind, InteractiveMessage, + InteractiveReply, InteractiveReplyType, InteractiveSection, SendResult, SharedLocation, + StatusEvent, WindowStatus, +) +from app.channels.constants import DEFAULT_TEMPLATE_LANGUAGE +from app.channels.interactive_limits import WHATSAPP_LIMITS, InteractiveLimits +from app.channels.meta_base import MetaBaseAdapter, graph_post +from app.channels.registry import register_adapter +from app.models.channels import ChannelAccount, ChannelConversation, ChannelType +from app.core.logger import get_logger + +logger = get_logger(__name__) + +# https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages +MAX_MESSAGE_LENGTH = 4096 + + +class WhatsAppAdapter(MetaBaseAdapter): + channel_type: ClassVar[str] = ChannelType.WHATSAPP.value + # Outside the 24h window WhatsApp allows re-opening with an approved template + expired_status: ClassVar[WindowStatus] = WindowStatus.TEMPLATE_REQUIRED + interactive_limits: ClassVar[Optional[InteractiveLimits]] = WHATSAPP_LIMITS + reports_delivery_status: ClassVar[bool] = True + max_text_length: ClassVar[Optional[int]] = MAX_MESSAGE_LENGTH + + def parse_inbound(self, payload: dict) -> List[InboundMessage]: + """Normalize a WhatsApp Cloud API webhook. + + Shape: entry[].changes[].value.{metadata,contacts,messages}. Status + callbacks (delivered/read) carry no `messages` and yield nothing. + """ + messages: List[InboundMessage] = [] + for entry in payload.get("entry", []): + for change in entry.get("changes", []): + value = change.get("value", {}) + metadata = value.get("metadata", {}) + phone_number_id = metadata.get("phone_number_id") + if not phone_number_id or not value.get("messages"): + continue + name_by_wa_id = { + c.get("wa_id"): (c.get("profile") or {}).get("name") + for c in value.get("contacts", []) + } + for message in value["messages"]: + interactive = self._extract_interactive(message) + location = self._extract_location(message) + text = self._extract_text(message, interactive, location) + if text is None: + continue # media types until media support lands + wa_id = message.get("from") + messages.append(InboundMessage( + external_account_id=str(phone_number_id), + external_conversation_id=str(wa_id), + external_user_id=str(wa_id), + external_message_id=str(message.get("id", "")), + text=text, + # wa_id is the customer's number in E.164-without-plus, + # declared verbatim (like the SMS adapters) so the one + # normalize_msisdn boundary owns all '+' handling. + profile={"name": name_by_wa_id.get(wa_id), "phone": str(wa_id)}, + timestamp=self._timestamp(message.get("timestamp")), + interactive=interactive, + location=location, + reply_to_message_id=(message.get("context") or {}).get("id"), + )) + return messages + + @staticmethod + def _extract_text(message: dict, interactive: Optional[InteractiveReply], + location: Optional[SharedLocation]) -> Optional[str]: + if message.get("type") == "text": + return (message.get("text") or {}).get("body") + # A tap reads as the text the customer saw, so the agent needs no + # special handling; the id travels separately on `interactive`. A + # tap without a title (never expected) falls back to its id rather + # than running a turn on nothing. + if interactive is not None: + return interactive.title or interactive.id or None + if location is not None: + return _describe_location(location) + return None + + @staticmethod + def _extract_interactive(message: dict) -> Optional[InteractiveReply]: + if message.get("type") == "button": + # Template quick-reply button: `payload` is what the template set + button = message.get("button") or {} + return InteractiveReply(type=InteractiveReplyType.TEMPLATE_BUTTON, + id=str(button.get("payload") or ""), + title=str(button.get("text") or "")) + if message.get("type") == "interactive": + interactive = message.get("interactive") or {} + for reply_type in (InteractiveReplyType.BUTTON_REPLY, InteractiveReplyType.LIST_REPLY): + reply = interactive.get(reply_type.value) + if reply: + return InteractiveReply(type=reply_type, + id=str(reply.get("id") or ""), + title=str(reply.get("title") or "")) + return None + + @staticmethod + def _extract_location(message: dict) -> Optional[SharedLocation]: + if message.get("type") != "location": + return None + location = message.get("location") or {} + try: + return SharedLocation(latitude=float(location["latitude"]), + longitude=float(location["longitude"]), + name=location.get("name"), + address=location.get("address")) + except (KeyError, TypeError, ValueError): + return None + + def parse_status_events(self, payload: dict) -> List[StatusEvent]: + """Delivery receipts: entry[].changes[].value.statuses[].""" + events: List[StatusEvent] = [] + for entry in payload.get("entry", []): + for change in entry.get("changes", []): + value = change.get("value", {}) + phone_number_id = (value.get("metadata") or {}).get("phone_number_id") + if not phone_number_id: + continue + for status in value.get("statuses") or []: + event = self._status_event(str(phone_number_id), status) + if event is not None: + events.append(event) + return events + + def _status_event(self, phone_number_id: str, status: dict) -> Optional[StatusEvent]: + try: + state = DeliveryState(status.get("status")) + except ValueError: + return None # e.g. 'deleted' — not a delivery state we track + message_id = status.get("id") + if not message_id: + return None + pricing = status.get("pricing") or {} + errors = status.get("errors") or [] + return StatusEvent( + external_account_id=phone_number_id, + external_message_id=str(message_id), + recipient_id=str(status.get("recipient_id") or ""), + status=state, + timestamp=self._timestamp(status.get("timestamp")), + error=_status_error(errors[0]) if errors else None, + error_code=str(errors[0]["code"]) if errors and errors[0].get("code") is not None else None, + pricing_category=pricing.get("category"), + pricing_type=pricing.get("type"), + pricing_model=pricing.get("pricing_model"), + billable=pricing.get("billable"), + ) + + def conversation_state(self, inbound: InboundMessage) -> dict: + # send_typing marks a specific message as read, so it needs the wamid of + # the message it is replying to. + if not inbound.external_message_id: + return {} + return {"last_inbound_message_id": inbound.external_message_id} + + async def send_typing(self, account: ChannelAccount, conversation: ChannelConversation) -> None: + """Show a typing indicator. WhatsApp only offers this as part of marking + an inbound message read, so this also blue-ticks that message. Expires + after 25s or when the reply is delivered.""" + message_id = (conversation.extra or {}).get("last_inbound_message_id") + if message_id: + await self._read_receipt(account, message_id, typing=True) + + async def mark_read(self, account: ChannelAccount, external_message_id: str) -> None: + if external_message_id: + await self._read_receipt(account, external_message_id, typing=False) + + async def _read_receipt(self, account: ChannelAccount, message_id: str, typing: bool) -> None: + payload = {"messaging_product": "whatsapp", "status": "read", "message_id": message_id} + if typing: + payload["typing_indicator"] = {"type": "text"} + try: + result = await graph_post(f"{account.external_account_id}/messages", + self.access_token(account), payload) + if not result.ok: + logger.debug(f"WhatsApp read receipt failed (non-critical): {result.error}") + except Exception as e: + logger.debug(f"WhatsApp read receipt failed (non-critical): {e}") + + async def send_text(self, account: ChannelAccount, conversation: ChannelConversation, text: str) -> SendResult: + return await graph_post( + f"{account.external_account_id}/messages", + self.access_token(account), + { + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": conversation.external_conversation_id, + "type": "text", + "text": {"preview_url": False, "body": text}, + }, + ) + + async def send_template(self, account: ChannelAccount, conversation: ChannelConversation, + template_name: str, language: str = DEFAULT_TEMPLATE_LANGUAGE, + components: list = None) -> SendResult: + """Send an approved template message — the only way to reach a customer + outside the 24h window.""" + template: dict = {"name": template_name, "language": {"code": language}} + if components: + template["components"] = components + return await graph_post( + f"{account.external_account_id}/messages", + self.access_token(account), + { + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": conversation.external_conversation_id, + "type": "template", + "template": template, + }, + ) + + async def send_interactive(self, account: ChannelAccount, conversation: ChannelConversation, + message: InteractiveMessage) -> SendResult: + return await graph_post( + f"{account.external_account_id}/messages", + self.access_token(account), + { + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": conversation.external_conversation_id, + "type": "interactive", + "interactive": _interactive_payload(message), + }, + ) + + def format_outbound(self, markdown: str) -> str: + # WhatsApp renders *bold* / _italic_ but agent markdown (**bold**) doesn't + # map cleanly; send as-is with the length cap to avoid mangled markup. + return markdown[:MAX_MESSAGE_LENGTH] + + +def _describe_location(location: SharedLocation) -> str: + """A shared pin as text the agent (and the inbox) can read.""" + place = ", ".join(p for p in (location.name, location.address) if p) + coordinates = f"{location.latitude}, {location.longitude}" + return f"Shared location: {place} ({coordinates})" if place else f"Shared location: {coordinates}" + + +def _status_error(error: dict) -> str: + details = (error.get("error_data") or {}).get("details") + return str(details or error.get("message") or error.get("title") or error.get("code") or "failed") + + +def _interactive_payload(message: InteractiveMessage) -> dict: + """The Graph `interactive` object for a validated InteractiveMessage.""" + payload: dict = {"body": {"text": message.body}} + if message.header: + payload["header"] = {"type": "text", "text": message.header} + if message.footer: + payload["footer"] = {"text": message.footer} + + if message.kind == InteractiveKind.BUTTONS: + payload["type"] = "button" + payload["action"] = {"buttons": [ + {"type": "reply", "reply": {"id": b.id, "title": b.title}} for b in message.buttons + ]} + elif message.kind == InteractiveKind.LIST: + payload["type"] = "list" + payload["action"] = { + "button": message.list_button_label, + "sections": [_list_section(s) for s in message.sections], + } + else: + payload["type"] = "cta_url" + payload["action"] = {"name": "cta_url", "parameters": { + "display_text": message.display_text, "url": message.url, + }} + return payload + + +def _list_section(section: InteractiveSection) -> dict: + rows = [] + for row in section.rows: + item = {"id": row.id, "title": row.title} + if row.description: + item["description"] = row.description + rows.append(item) + out: dict = {"rows": rows} + if section.title: + out["title"] = section.title + return out + + +register_adapter(WhatsAppAdapter()) diff --git a/backend/app/services/channel_chat.py b/backend/app/services/channel_chat.py new file mode 100644 index 0000000..ddea7b9 --- /dev/null +++ b/backend/app/services/channel_chat.py @@ -0,0 +1,532 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import uuid +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy.orm import Session + +from app.agents.chat_agent import ChatAgent +from app.channels import InboundMessage, ChannelInteraction, get_adapter +from app.channels.app_hooks import HandledBy +from app.channels.base import InteractiveMessage +from app.channels.constants import MessageSource +from app.channels.interactive import from_options +from app.channels.interactive_limits import InteractiveLimits +from app.core.security import decrypt_api_key +from app.core.logger import get_logger +from app.database import SessionLocal +from app.models.ai_config import AIModelType +from app.models.channels import ChannelAccount +from app.models.session_to_agent import SessionStatus +from app.repositories.agent import AgentRepository +from app.repositories.ai_config import AIConfigRepository +from app.repositories.chat import ChatRepository +from app.repositories.customer import CustomerRepository +from app.utils.phone import normalize_msisdn +from app.repositories.channels import ( + ChannelAccountRepository, + ChannelConversationRepository, + AgentChannelConfigRepository, +) +from app.repositories.session_to_agent import SessionToAgentRepository +from app.services.app_first_routing import announce_inbound, app_claims +from app.services.chat_notifications import notify_new_channel_chat_once, team_not_yet_notified +from app.services.human_routing import HUMAN_ONLY_ACK, notify_customer +from app.services.lead_capture import record_lead_from_response +from app.services.conversation_token import VERIFIED_SCOPE +from app.services.agent_rooms import USER_MESSAGE, relay_stored_message +from app.services.business_message import business_message_context +from app.services.channel_replies import inbound_attributes, reply_text +from app.services.message_delivery import DeliveryResult, deliver_to_customer + +# Optional enterprise message-limit check (same seam as the widget path) +try: + from app.enterprise.services.message_limit import check_message_limit + HAS_ENTERPRISE = True +except ImportError: + HAS_ENTERPRISE = False + +logger = get_logger(__name__) + + +async def process_channel_message(account_id, inbound: InboundMessage) -> None: + """Process one inbound customer message from an external channel. + + Channel-agnostic core shared by every webhook route: resolves the + customer and session, stores the message, runs the AI agent (or relays + to the handling human), and delivers the reply through the channel's + adapter. Runs from a BackgroundTask with its own DB session. + """ + db = SessionLocal() + try: + account_repo = ChannelAccountRepository(db) + account = account_repo.get_by_id(account_id) + if account is None or not account.is_active: + logger.warning(f"Ignoring message for missing/inactive channel account {account_id}") + return + + adapter = get_adapter(account.channel_type) + + agent_id = AgentChannelConfigRepository(db).get_active_agent_id(account.id) + if agent_id is None: + logger.warning(f"No agent configured for {account.channel_type} account {account.id}") + return + + org_id = str(account.organization_id) + customer_id = _get_or_create_customer(db, account, inbound, org_id) + # If the customer still has a synthesized placeholder name, resolve the + # real one via the platform API — only then, not on every message. + if adapter is not None: + await _enrich_customer_name(db, adapter, account, inbound, customer_id) + session_record, conversation, is_new_session = _get_or_create_session( + db, account, inbound, agent_id, customer_id, org_id + ) + session_id = str(session_record.session_id) + + # Merge channel-specific per-conversation state (e.g. email threading + # headers) so outbound replies can use it. + if adapter is not None: + state = adapter.conversation_state(inbound) + if state: + ChannelConversationRepository(db).merge_extra(conversation, state) + + # A human-only agent answers nothing — the chat waits in the inbox for + # someone to pick it up, so it takes the same path as one a human + # already holds. + agent = AgentRepository(db).get_agent(agent_id) + ai_replies_enabled = agent.ai_replies_enabled if agent else True + # Buttons are the agent's choice (off unless turned on), where the + # channel can show them + choice_limits = (adapter.choice_limits_for(conversation) + if adapter is not None and agent is not None and agent.channel_quick_replies else None) + + # An installed extension (an external app) may answer first. Its reply + # can land while we wait, so the customer's message keeps the time it + # arrived and still sorts before it. + received_at = datetime.now(timezone.utc) + if await app_claims(db, account, session_record, customer_id, inbound, received_at): + stored = _store_customer_message(db, inbound, session_record, agent_id, + customer_id, org_id, account, created_at=received_at) + await relay_stored_message(session_record, stored, USER_MESSAGE) + if adapter is not None: + await adapter.mark_read(account, inbound.external_message_id) + await announce_inbound(account, session_record, customer_id, inbound, + HandledBy.APP, received_at) + return + + # From here the team (AI or inbox) is answering + await notify_new_channel_chat_once(db, session_record, conversation) + + # Human is handling (or a transfer is pending, or this agent never uses + # AI): store + relay to the agent dashboard, never run the bot. + if (session_record.user_id is not None + or session_record.status == SessionStatus.TRANSFERRED + or not ai_replies_enabled): + stored = _store_customer_message(db, inbound, session_record, agent_id, + customer_id, org_id, account) + await relay_stored_message(session_record, stored, USER_MESSAGE) + await announce_inbound(account, session_record, customer_id, inbound, + HandledBy.HUMAN, received_at) + + # Nothing will answer this on its own, so acknowledge once — on a + # channel an unanswered message reads as a dead number. Only on the + # opening message of a brand-new conversation. + if is_new_session and not ai_replies_enabled: + await notify_customer(db, session_record, HUMAN_ONLY_ACK, + channels_only=False) + return + + await announce_inbound(account, session_record, customer_id, inbound, + HandledBy.AI, received_at) + + ai_config = AIConfigRepository(db).get_active_config(account.organization_id) + if ai_config is None: + logger.error(f"No AI config for org {org_id}; dropping {account.channel_type} message") + return + + if not await _within_message_limit(db, org_id, ai_config): + await _send_via_adapter( + db, session_record, + "Sorry, this assistant has reached its message limit. Please try again later." + ) + return + + # Show a typing indicator while the agent composes its reply + if adapter is not None: + await adapter.send_typing(account, conversation) + + response, bot_message_id = await _run_chat_agent( + db, account, ai_config, inbound, + org_id=org_id, agent_id=str(agent_id), + customer_id=customer_id, session_id=session_id, + conversation=conversation, + choice_limits=choice_limits, + ) + if response is None: + # The agent hard-failed. Still send a reply so a typing indicator / + # placeholder (Slack, LINE) resolves instead of dangling. + await _deliver(db, session_record, { + 'message': "Sorry, I hit an error handling that — please try again.", + 'type': 'chat_response', + }, source=MessageSource.SYSTEM) + return + + record_lead_from_response( + db, response, + organization_id=account.organization_id, + agent_id=agent_id, + customer_id=customer_id, + session_id=session_id, + channel=account.channel_type, + # The platform vouches for who is messaging. + scope=VERIFIED_SCOPE, + ) + + delivery = await _deliver(db, session_record, { + 'message': response.message, + 'type': 'chat_response', + 'transfer_to_human': getattr(response, 'transfer_to_human', False), + 'end_chat': getattr(response, 'end_chat', False), + }, source=MessageSource.AI, message_id=bot_message_id, + interactive=_offered_choices(response, choice_limits)) + if not delivery.ok and bot_message_id is not None: + ChatRepository(db).mark_delivery_failed(bot_message_id, delivery.reason) + + await _send_channel_prompts(db, adapter, account, conversation, response) + except Exception as e: + logger.error(f"Error processing channel message: {e}", exc_info=True) + finally: + db.close() + + +async def _send_channel_prompts(db, adapter, account, conversation, response) -> None: + """After the reply, surface channel-native prompts. Rating is deliberately + NOT asked on external channels — it is a web-widget-only feature; here we + only request a phone number when the agent asks for contact.""" + if adapter is None: + return + try: + if getattr(response, 'request_contact', False): + await adapter.request_phone( + account, conversation, + "To help us follow up, tap below to share your phone number.") + except Exception as e: + logger.error(f"Failed sending channel prompt: {e}") + + +async def process_channel_interaction(account_id, interaction: ChannelInteraction) -> None: + """Handle a non-message interaction (a shared phone number) from a channel. + Runs from a BackgroundTask with its own DB session.""" + db = SessionLocal() + try: + account = ChannelAccountRepository(db).get_by_id(account_id) + if account is None or not account.is_active: + return + conversation = ChannelConversationRepository(db).get_latest( + account.id, interaction.external_conversation_id) + if conversation is None: + return + + if interaction.type == "contact": + _handle_contact(db, conversation, interaction) + except Exception as e: + logger.error(f"Error processing channel interaction: {e}", exc_info=True) + finally: + db.close() + + +def _handle_contact(db, conversation, interaction: ChannelInteraction) -> None: + """Store a shared phone number on the customer record.""" + if not interaction.phone or conversation.customer_id is None: + return + from app.models.customer import Customer + customer = db.query(Customer).filter(Customer.id == conversation.customer_id).first() + if customer is None: + return + meta = dict(customer.meta_data or {}) + meta["phone"] = interaction.phone + customer.meta_data = meta + # Also promote to the identity column (set-if-absent, conflict-guarded) so + # the person becomes phone-addressable; meta_data keeps the raw value for + # anything already reading it there. msisdn-lenient because the number is + # platform-relayed (Telegram sends it with or without '+'), and one commit + # for both writes — separate transactions left a crash window where the + # two stores disagreed. + phone = normalize_msisdn(interaction.phone) + if phone: + CustomerRepository(db).set_phone_if_absent(customer, phone) + db.commit() + logger.info(f"Stored shared phone for customer {conversation.customer_id}") + + +def _get_or_create_customer(db: Session, account: ChannelAccount, + inbound: InboundMessage, org_id: str) -> str: + """Resolve the platform user to a Customer. + + When the channel already gives us the customer's real email (e.g. the + email channel), use it verbatim so the same person is unified across + channels and the widget. Otherwise synthesize a stable per-channel address. + + Phone is the second identity key: adapters that truthfully know the + customer's number (WhatsApp, SMS) declare it in profile['phone'], and the + repository resolves by phone before email — which is what unifies one human + across phone-bearing channels and outbound sends. + """ + real_email = (inbound.profile or {}).get('email') + real_name = (inbound.profile or {}).get('name') + if real_email and '@' in real_email: + channel_email = real_email.lower() + else: + channel_email = f"{inbound.external_user_id}@{account.channel_type}.channel" + customer = CustomerRepository(db).get_or_create_customer( + email=channel_email, + organization_id=uuid.UUID(org_id), + full_name=real_name or placeholder_name(account.channel_type, + inbound.external_user_id), + # msisdn-lenient: adapters supply platform ids (wa_id, SMS sender), + # which are E.164-without-plus — trusted in a way typed digits aren't. + phone=normalize_msisdn((inbound.profile or {}).get('phone')), + ) + # Upgrade an existing placeholder name once we've resolved the real one + # (get_or_create doesn't update an existing customer's name). + if real_name and _is_placeholder_name(customer.full_name, account.channel_type): + try: + customer.full_name = real_name + db.commit() + except Exception as e: + db.rollback() + logger.error(f"Failed to update customer name: {e}") + return str(customer.id) + + +def placeholder_name(channel_type: str, external_user_id: str) -> str: + """The stand-in name for a channel person we don't know yet. + + Paired with _is_placeholder_name deliberately: one builds the string, the + other recognises it, and the two only work if they agree exactly. Spelling + either out a second time somewhere else (an outbound send, say) makes them + agree by coincidence — and the day the format changes, the detector + silently stops matching and every real name upgrade stops happening. + """ + return f"{channel_type.capitalize()} user {external_user_id[:8]}" + + +def _is_placeholder_name(name: str, channel_type: str) -> bool: + """True when the stored name is the synthesized ' user xxxx' or empty.""" + if not name: + return True + return name.startswith(f"{channel_type.capitalize()} user ") + + +async def _enrich_customer_name(db: Session, adapter, account: ChannelAccount, + inbound: InboundMessage, customer_id: str) -> None: + """Resolve the customer's real name via the platform API, but only while + the stored name is still a placeholder. + + Channels like Slack send just a user id in the webhook, so we look the name + up via e.g. users.info. Gating on the placeholder means we make that network + call once — for a new/unresolved customer — not on every inbound message. + """ + customer = CustomerRepository(db).get_by_id(uuid.UUID(customer_id)) + if customer is None or not _is_placeholder_name(customer.full_name, account.channel_type): + return + try: + enrichment = await adapter.fetch_profile(account, inbound.external_user_id) + except Exception as e: + logger.debug(f"Profile enrichment failed (non-critical): {e}") + return + name = (enrichment or {}).get('name') + if not name: + return + try: + customer.full_name = name + db.commit() + except Exception as e: + db.rollback() + logger.error(f"Failed to update customer name: {e}") + + +def _agent_changed(session_record, agent_id) -> bool: + """Whether this channel now answers with a different agent than the one the + open session was started with. + + A session is bound to one agent: its stored history, and the AI runtime's + own memory for that session id, belong to that agent. Carrying it over to a + newly configured agent makes the new agent answer out of the previous one's + retrieved knowledge, and splits the inbox list in two — the conversations + query groups by agent *and* session, while the detail view keys on session + alone, so the same thread appears twice and neither row opens the other. + + A missing session likewise needs a new one. A human-handled or + transferred chat is left alone: rotating it would drop the agent out of a + live conversation mid-reply. + """ + if session_record is None: + return True + if session_record.user_id is not None: + return False + if session_record.status == SessionStatus.TRANSFERRED: + return False + return str(session_record.agent_id) != str(agent_id) + + +def _get_or_create_session(db: Session, account: ChannelAccount, inbound: InboundMessage, + agent_id, customer_id: str, org_id: str): + """Find the open session for this platform conversation or start a new one. + + Returns (session, conversation, is_new) — callers use is_new to notify the + team only when a conversation actually starts. + """ + conv_repo = ChannelConversationRepository(db) + session_repo = SessionToAgentRepository(db) + + conversation = conv_repo.get_active(account.id, inbound.external_conversation_id) + if conversation is not None: + session_record = session_repo.get_session(conversation.session_id) + if not _agent_changed(session_record, agent_id): + conv_repo.touch_inbound(conversation) + return session_record, conversation, False + # Retire the thread so the newly configured agent starts clean. Closing + # the session is what makes get_active skip it, so the create below + # opens a fresh conversation for the same platform thread. + logger.info( + f"Agent changed for {account.channel_type} account {account.id}; " + f"starting a new session for conversation {inbound.external_conversation_id}") + session_repo.close_session(conversation.session_id) + + session_id = str(uuid.uuid4()) + session_record = session_repo.create_session( + session_id=session_id, + agent_id=str(agent_id), + customer_id=customer_id, + organization_id=org_id, + channel=account.channel_type, + ) + conversation = conv_repo.create( + channel_account_id=account.id, + channel_type=account.channel_type, + external_conversation_id=inbound.external_conversation_id, + external_user_id=inbound.external_user_id, + session_id=uuid.UUID(session_id), + organization_id=account.organization_id, + agent_id=agent_id, + customer_id=uuid.UUID(customer_id), + extra=team_not_yet_notified(), + ) + return session_record, conversation, True + + +def _store_customer_message(db: Session, inbound: InboundMessage, session_record, + agent_id, customer_id: str, org_id: str, + account: ChannelAccount, created_at: Optional[datetime] = None): + """Persist a customer message on the paths the AI doesn't answer (the AI + path persists inside ChatAgent.get_response). Returns the stored row for + the relay.""" + message = { + "message": reply_text(db, account.id, inbound), + "message_type": "user", + "session_id": str(session_record.session_id), + "organization_id": org_id, + "agent_id": str(agent_id), + "customer_id": customer_id, + "attributes": inbound_attributes(inbound, account.channel_type), + } + if created_at is not None: + message["created_at"] = created_at + return ChatRepository(db).create_message(message) + + +async def _within_message_limit(db: Session, org_id: str, ai_config) -> bool: + """Enterprise subscription limit; permissive on any failure so the + community edition and enterprise errors never block conversations.""" + if not (HAS_ENTERPRISE and ai_config.model_type == AIModelType.CHATTERMATE): + return True + try: + return await check_message_limit(db, org_id, None, None) + except Exception as e: + logger.error(f"Message limit check failed (allowing message): {e}") + return True + + +async def _run_chat_agent(db: Session, account: ChannelAccount, ai_config, + inbound: InboundMessage, org_id: str, agent_id: str, + customer_id: str, session_id: str, conversation=None, + choice_limits: Optional[InteractiveLimits] = None): + """Run the AI agent for one turn. Workflow-enabled agents fall back to the + plain agent on external channels (workflow forms are widget-only). + + Returns (response, id of the stored reply row); response is None when the + agent hard-failed.""" + chat_agent = await ChatAgent.create_async( + api_key=decrypt_api_key(ai_config.encrypted_api_key), + model_name=ai_config.model_name, + model_type=ai_config.model_type.value if hasattr(ai_config.model_type, 'value') else ai_config.model_type, + base_url=(ai_config.settings or {}).get('base_url'), + org_id=org_id, + agent_id=agent_id, + customer_id=customer_id, + session_id=session_id, + channel=account.channel_type, + extra_context=business_message_context(conversation), + choice_limits=choice_limits, + ) + try: + response = await chat_agent.get_response( + message=reply_text(db, account.id, inbound), + session_id=session_id, + org_id=org_id, + agent_id=agent_id, + customer_id=customer_id, + user_attributes=inbound_attributes(inbound, account.channel_type), + ) + return response, chat_agent.last_bot_message_id + except Exception as e: + logger.error(f"Chat agent error on {account.channel_type} session {session_id}: {e}") + return None, None + finally: + await chat_agent.safe_cleanup_mcp_tools() + + +def _offered_choices(response, limits: Optional[InteractiveLimits]) -> Optional[InteractiveMessage]: + """The agent's choices as the interactive message to send with its reply.""" + options = getattr(response, 'quick_replies', None) + return from_options(response.message, options, limits) if options and limits is not None else None + + +async def _deliver(db: Session, session_record, payload: dict, *, source: MessageSource, + message_id: Optional[int] = None, + interactive: Optional[InteractiveMessage] = None) -> DeliveryResult: + """Deliver a reply and log any failure. + + Unlike the widget path there is no agent socket to notify here, so a failed + send would otherwise be silent: the customer gets nothing and nothing says + so. Callers that have a stored message act further on the result. + """ + result = await deliver_to_customer(db, session_record, payload, message_id=message_id, + interactive=interactive, source=source) + if not result.ok: + logger.warning( + f"Undelivered reply on {getattr(session_record, 'channel', 'unknown')} " + f"session {session_record.session_id}: {result.reason}") + return result + + +async def _send_via_adapter(db: Session, session_record, text: str) -> None: + """Send a plain service message to the customer's channel.""" + await _deliver(db, session_record, {'message': text, 'type': 'chat_response'}, + source=MessageSource.SYSTEM) diff --git a/backend/app/services/channel_replies.py b/backend/app/services/channel_replies.py new file mode 100644 index 0000000..6621504 --- /dev/null +++ b/backend/app/services/channel_replies.py @@ -0,0 +1,103 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +What a channel customer's message carries beyond its text — a tapped +button, a shared location — and how a tap on one of the agent's own choices +reads back as the full option. +""" + +from typing import Optional +from uuid import UUID + +from sqlalchemy.orm import Session + +from app.channels.base import InboundMessage +from app.channels.sent_choices import ( + SENT_CHOICES_ATTRIBUTE, decode_sent_choices, sent_choice_text, sent_numbered_choice, +) +from app.repositories.chat import ChatRepository + +# chat_history.attributes key for a tap: only its type and id. The visible +# text is already the (encrypted) message, and a shared location's details +# live there too — nothing customer-identifying goes in plaintext attributes. +REPLY_ATTRIBUTE = "interactive_reply" +# How far back a tap without reply context is matched to the reply that offered it +RECENT_OFFERS = 10 + + +def inbound_attributes(inbound: InboundMessage, channel_type: str) -> dict: + """The chat_history attributes for a customer's channel message.""" + attributes = { + "channel": channel_type, + "external_message_id": inbound.external_message_id, + } + if inbound.interactive is not None: + attributes[REPLY_ATTRIBUTE] = { + "type": inbound.interactive.type.value, + "id": inbound.interactive.id, + } + if inbound.reply_to_message_id: + attributes["reply_to_message_id"] = inbound.reply_to_message_id + return attributes + + +def reply_text(db: Session, channel_account_id: UUID, inbound: InboundMessage) -> str: + """The text to store and answer for this message. + + A tap arrives as the button's title, which may have been cut to fit (or, + on Telegram, as just the button's id); a reply to choices shown as a + numbered list may be just the number. The full choice is in the record + of what the offering message sent, looked up within this customer's chat + on this account — by the tap's reply context where the platform gives + one, otherwise as the latest reply that offered the choice. + """ + choice = _tapped_choice(db, channel_account_id, inbound) or _numbered_choice(db, channel_account_id, inbound) + if choice is not None: + return choice + return inbound.text or (inbound.interactive.title if inbound.interactive else "") or "" + + +def _tapped_choice(db: Session, channel_account_id: UUID, inbound: InboundMessage) -> Optional[str]: + if inbound.interactive is None: + return None + repo = ChatRepository(db) + if inbound.reply_to_message_id: + offered = repo.in_channel_chat_by_external_message_id( + channel_account_id, inbound.external_conversation_id, inbound.reply_to_message_id) + candidates = [offered] if offered is not None else [] + else: + candidates = repo.latest_replies_in_channel_chat( + channel_account_id, inbound.external_conversation_id, RECENT_OFFERS) + for offered in candidates: + text = sent_choice_text(_record(offered), inbound.interactive.id) + if text is not None: + return text + return None + + +def _numbered_choice(db: Session, channel_account_id: UUID, inbound: InboundMessage) -> Optional[str]: + """A bare number answering our latest reply — the AI's or a person's — + when that reply showed its choices numbered (as text, or alongside + buttons a desktop app hides).""" + typed = (inbound.text or "").strip().rstrip(".") + if inbound.interactive is not None or not typed.isdecimal(): + return None + latest = ChatRepository(db).latest_replies_in_channel_chat( + channel_account_id, inbound.external_conversation_id, 1) + return sent_numbered_choice(_record(latest[0]), typed) if latest else None + + +def _record(message) -> Optional[dict]: + return decode_sent_choices((message.attributes or {}).get(SENT_CHOICES_ATTRIBUTE)) diff --git a/backend/app/services/channel_status.py b/backend/app/services/channel_status.py new file mode 100644 index 0000000..22e4bcc --- /dev/null +++ b/backend/app/services/channel_status.py @@ -0,0 +1,186 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +Channel delivery receipts (sent / delivered / read / failed) applied to the +stored messages they belong to. +""" + +import json +from datetime import datetime, timezone +from typing import List, Set +from uuid import UUID + +from sqlalchemy.orm import Session + +from app.channels.app_hooks import ChannelEvent, EventKind, emit_channel_event, status_event_id +from app.channels.base import DeliveryState, StatusEvent +from app.core.logger import get_logger +from app.core.redis import get_redis +from app.database import SessionLocal +from app.models.chat_history import ChatHistory +from app.repositories.channels import ChannelAccountRepository +from app.repositories.chat import ChatRepository +from app.repositories.session_to_agent import SessionToAgentRepository +from app.services.agent_rooms import emit_to_org_inbox + +logger = get_logger(__name__) + +# A receipt can arrive before the send that produced it has stored the +# message id (the Graph response and the webhook race). It waits here until +# record_sent picks it up; long enough to cover the race, short enough that a +# receipt for a message we never stored doesn't linger. +PENDING_KEY = "channel_status_pending:{account_id}:{message_id}" +PENDING_TTL_SECONDS = 600 + +# Receipts can arrive out of order and more than once, so a status only +# replaces one it outranks. FAILED replaces sent/delivered but never READ: +# a message the customer read was delivered, whatever arrives after. +# Keyed by the stored value (chat_history.channel_status). +_RANK = {DeliveryState.SENT.value: 0, DeliveryState.DELIVERED.value: 1, DeliveryState.READ.value: 2} + + +def replaceable_by(new: DeliveryState) -> Set[str]: + """The stored statuses receipt `new` may overwrite (no status at all is + always overwritten).""" + if new == DeliveryState.FAILED: + return {DeliveryState.SENT.value, DeliveryState.DELIVERED.value} + return {state for state, rank in _RANK.items() if rank < _RANK[new.value]} + + +async def process_status_event(account_id: UUID, event: StatusEvent) -> None: + """Apply one receipt from a channel webhook. Runs from a BackgroundTask + with its own DB session.""" + db = SessionLocal() + try: + account = ChannelAccountRepository(db).get_by_id(account_id) + if account is None: + return + # Announced before matching: extensions count every receipt, including + # ones for messages ChatterMate never stored. + await emit_channel_event(ChannelEvent( + kind=EventKind.STATUS, + event_id=status_event_id(account.id, event.external_message_id, event.status.value), + organization_id=account.organization_id, + channel_account_id=account.id, + occurred_at=event.timestamp or datetime.now(timezone.utc), + status=event, + )) + chat_repo = ChatRepository(db) + message = chat_repo.get_by_external_message_id( + account.organization_id, event.external_message_id) + if message is None: + _park(account_id, event) + # record_sent may have stored the id and drained the queue between + # the lookup and the park; look again so the receipt isn't stranded. + message = chat_repo.get_by_external_message_id( + account.organization_id, event.external_message_id) + if message is None: + return + for parked in _take_parked(account_id, event.external_message_id): + await _apply(db, message, parked) + return + await _apply(db, message, event) + except Exception as e: + logger.error(f"Error processing channel status event: {e}", exc_info=True) + finally: + db.close() + + +async def record_sent(db: Session, message_id: int, account_id: UUID, + external_message_id: str) -> None: + """Store the platform id of a message we just sent, then apply any + receipts that arrived before it.""" + message = ChatRepository(db).set_external_message_id(message_id, external_message_id) + if message is None: + return + for event in _take_parked(account_id, external_message_id): + await _apply(db, message, event) + + +async def _apply(db: Session, message: ChatHistory, event: StatusEvent) -> None: + chat_repo = ChatRepository(db) + advanced = chat_repo.advance_channel_status( + message.id, event.status.value, event.timestamp or datetime.now(timezone.utc), + replaceable_by(event.status)) + if not advanced: + return + db.refresh(message) + if event.status == DeliveryState.FAILED: + # Meta can accept a send and fail it later; this is the only place + # that failure becomes visible. + chat_repo.mark_delivery_failed(message.id, event.error) + session = SessionToAgentRepository(db).get_session(message.session_id) + await emit_to_org_inbox('message_status', { + 'session_id': str(message.session_id), + 'message_id': message.id, + 'external_message_id': message.external_message_id, + 'channel_status': message.channel_status, + 'delivery_status': (message.attributes or {}).get('delivery_status'), + }, message.organization_id, session.user_id if session else None) + + +def _pending_key(account_id: UUID, external_message_id: str) -> str: + return PENDING_KEY.format(account_id=account_id, message_id=external_message_id) + + +def _park(account_id: UUID, event: StatusEvent) -> None: + redis = get_redis() + if redis is None: + return + key = _pending_key(account_id, event.external_message_id) + try: + pipe = redis.pipeline() + pipe.rpush(key, _serialize(event)) + pipe.expire(key, PENDING_TTL_SECONDS) + pipe.execute() + except Exception as e: + logger.warning(f"Could not park channel status event: {e}") + + +def _take_parked(account_id: UUID, external_message_id: str) -> List[StatusEvent]: + redis = get_redis() + if redis is None: + return [] + key = _pending_key(account_id, external_message_id) + try: + pipe = redis.pipeline() + pipe.lrange(key, 0, -1) + pipe.delete(key) + raw_events, _ = pipe.execute() + except Exception as e: + logger.warning(f"Could not read parked channel status events: {e}") + return [] + return [_deserialize(raw, external_message_id) for raw in raw_events or []] + + +def _serialize(event: StatusEvent) -> str: + # Only what _apply uses — no recipient number or pricing sits in Redis + return json.dumps({ + 'status': event.status.value, + 'timestamp': event.timestamp.isoformat() if event.timestamp else None, + 'error': event.error, + }) + + +def _deserialize(raw: str, external_message_id: str) -> StatusEvent: + data = json.loads(raw) + return StatusEvent( + external_account_id="", + external_message_id=external_message_id, + recipient_id="", + status=DeliveryState(data['status']), + timestamp=datetime.fromisoformat(data['timestamp']) if data['timestamp'] else None, + error=data.get('error'), + ) diff --git a/backend/app/services/db_connector_service.py b/backend/app/services/db_connector_service.py new file mode 100644 index 0000000..077cc25 --- /dev/null +++ b/backend/app/services/db_connector_service.py @@ -0,0 +1,319 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +Execution layer for guardrailed ticket DB connectors. All functions here are +synchronous (drivers are sync) — callers run them via asyncio.to_thread. + +The read-only guarantee is enforced at the SESSION level, independently of +the SQL guardrails: default_transaction_read_only for Postgres, SESSION +TRANSACTION READ ONLY for MySQL, plus statement timeouts on both. +""" + +import io +from contextlib import contextmanager +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple +from uuid import UUID + +from app.core.logger import get_logger +from app.core.outbound_policy import pin_outbound_target +from app.core.security import decrypt_api_key + +logger = get_logger(__name__) + +CONNECT_TIMEOUT_SECONDS = 8 +SSH_CONNECT_TIMEOUT_SECONDS = 10 +# Schemas that are never discovered or queried. +SYSTEM_SCHEMAS = ( + "pg_catalog", "information_schema", "pg_toast", + "mysql", "performance_schema", "sys", +) + + +@dataclass +class DBConnectorConfig: + """Plain snapshot of a TicketDBConnector row (secrets decrypted) so the + executor never holds ORM objects across sessions/threads.""" + id: Optional[UUID] + organization_id: Optional[UUID] + name: str + engine: str + host: str + port: int + database: str + username: str + password: str + allowed_tables: List[str] = field(default_factory=list) + masked_columns: List[str] = field(default_factory=list) + # {"table": "customer_column"} — tables restricted to the ticket + # customer's own rows. See sql_guardrails._apply_row_scope. + row_scope: dict = field(default_factory=dict) + row_scope_key: str = "email" + max_rows: int = 100 + statement_timeout_ms: int = 5000 + # SSH tunnel (bastion/jump host) — the common production topology where + # the database isn't directly reachable. + ssh_enabled: bool = False + ssh_host: Optional[str] = None + ssh_port: int = 22 + ssh_username: Optional[str] = None + ssh_password: Optional[str] = None + ssh_private_key: Optional[str] = None + ssh_private_key_passphrase: Optional[str] = None + + @classmethod + def from_model(cls, connector) -> "DBConnectorConfig": + return cls( + id=connector.id, + organization_id=connector.organization_id, + name=connector.name, + engine=str(connector.engine), + host=connector.host, + port=connector.port, + database=connector.database, + username=connector.username, + password=decrypt_api_key(connector.encrypted_password), + allowed_tables=list(connector.allowed_tables or []), + masked_columns=list(connector.masked_columns or []), + row_scope=dict(connector.row_scope or {}), + row_scope_key=connector.row_scope_key or "email", + max_rows=connector.max_rows or 100, + statement_timeout_ms=connector.statement_timeout_ms or 5000, + ssh_enabled=bool(connector.ssh_enabled), + ssh_host=connector.ssh_host, + ssh_port=connector.ssh_port or 22, + ssh_username=connector.ssh_username, + ssh_password=( + decrypt_api_key(connector.encrypted_ssh_password) + if connector.encrypted_ssh_password else None + ), + ssh_private_key=( + decrypt_api_key(connector.encrypted_ssh_private_key) + if connector.encrypted_ssh_private_key else None + ), + ssh_private_key_passphrase=( + decrypt_api_key(connector.encrypted_ssh_key_passphrase) + if connector.encrypted_ssh_key_passphrase else None + ), + ) + + +def _load_ssh_key(key_text: str, passphrase: Optional[str]): + """Parse a PEM/OpenSSH private key of any common type from a string, + without writing it to disk.""" + import paramiko + + last_error = None + # DSA is obsolete — Ed25519/RSA/ECDSA cover every key type a modern host + # issues. + for key_class in (paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey): + try: + return key_class.from_private_key( + io.StringIO(key_text), password=passphrase or None + ) + except Exception as e: # wrong type or bad passphrase — try the next + last_error = e + raise ValueError(f"Unsupported or unreadable SSH private key: {last_error}") + + +@contextmanager +def _ssh_tunnel(config: DBConnectorConfig, ssh_address: Optional[str] = None): + """Open an SSH local-forward tunnel to the database and yield the local + (host, port) to connect through. ``ssh_address`` is the bastion's vetted + IP when the outbound policy pinned one. Always torn down on exit.""" + from sshtunnel import SSHTunnelForwarder + + kwargs = { + "ssh_username": config.ssh_username, + "remote_bind_address": (config.host, config.port), + # 127.0.0.1:0 — the OS assigns a free local port per tunnel. + "local_bind_address": ("127.0.0.1", 0), + "set_keepalive": 15.0, + } + if config.ssh_private_key: + kwargs["ssh_pkey"] = _load_ssh_key( + config.ssh_private_key, config.ssh_private_key_passphrase + ) + elif config.ssh_password: + kwargs["ssh_password"] = config.ssh_password + else: + raise ValueError("SSH tunnel needs a private key or password") + + tunnel = SSHTunnelForwarder((ssh_address or config.ssh_host, config.ssh_port), **kwargs) + tunnel.start() + try: + yield "127.0.0.1", tunnel.local_bind_port + finally: + try: + tunnel.stop() + except Exception as e: + logger.warning(f"SSH tunnel teardown warning (non-critical): {e}") + + +def _driver_connect(config: DBConnectorConfig, host: str, port: int, + hostaddr: Optional[str] = None): + """Open a read-only DB session with a statement timeout, to (host, port) + — which is the tunnel's local endpoint when tunneling. ``hostaddr`` is + the vetted IP to dial instead of resolving ``host`` again.""" + if config.engine == "mysql": + import pymysql + conn = pymysql.connect( + # pymysql has no separate address field; no TLS is configured + # here, so the name isn't needed for verification. + host=hostaddr or host, + port=port, + user=config.username, + password=config.password, + database=config.database, + connect_timeout=CONNECT_TIMEOUT_SECONDS, + read_timeout=max(1, config.statement_timeout_ms // 1000 + 1), + ) + with conn.cursor() as cursor: + cursor.execute("SET SESSION TRANSACTION READ ONLY") + # max_execution_time bounds SELECTs server-side (milliseconds). + cursor.execute(f"SET SESSION max_execution_time = {int(config.statement_timeout_ms)}") + return conn + import psycopg + # libpq dials hostaddr and keeps host for TLS verification. + pinned = {"hostaddr": hostaddr} if hostaddr else {} + return psycopg.connect( + host=host, + **pinned, + port=port, + dbname=config.database, + user=config.username, + password=config.password, + connect_timeout=CONNECT_TIMEOUT_SECONDS, + options=( + f"-c statement_timeout={int(config.statement_timeout_ms)} " + "-c default_transaction_read_only=on" + ), + ) + + +def first_hop(ssh_enabled: bool, ssh_host: Optional[str], host: Optional[str]) -> Tuple[Optional[str], str]: + """The host this server dials first, with its field label, for the + outbound policy: the SSH bastion when tunnelling, else the database. A + database behind a bastion is reached from the bastion, and may be private.""" + if ssh_enabled: + return ssh_host, "SSH host" + return host, "Database host" + + +@contextmanager +def _connection(config: DBConnectorConfig): + """Yield a read-only DB connection, opening an SSH tunnel first when the + connector is configured for one. Tunnel and connection are both closed on + exit — a fresh pair per call keeps the (short-lived) investigation queries + isolated.""" + # Vetted on every connect, not only on save, and the vetted address is + # the one dialled: DNS can change, even between two lookups. + pinned = pin_outbound_target(*first_hop(config.ssh_enabled, config.ssh_host, config.host)) + if config.ssh_enabled: + if not config.ssh_host or not config.ssh_username: + raise ValueError("SSH tunnel is enabled but host/username are missing") + with _ssh_tunnel(config, pinned) as (host, port): + conn = _driver_connect(config, host, port) + try: + yield conn + finally: + try: + conn.close() + except Exception: + pass + else: + conn = _driver_connect(config, config.host, config.port, pinned) + try: + yield conn + finally: + try: + conn.close() + except Exception: + pass + + +def run_readonly_query( + config: DBConnectorConfig, sql: str +) -> Tuple[List[str], List[tuple]]: + """Execute validated SQL in a read-only session; fetches at most + max_rows + 1 (the validator already forced a LIMIT — this is belt and + braces).""" + with _connection(config) as conn: + with conn.cursor() as cursor: + cursor.execute(sql) + columns = [d[0] for d in (cursor.description or [])] + rows = cursor.fetchmany(config.max_rows + 1) + return columns, list(rows) + + +def test_connection(config: DBConnectorConfig) -> Dict: + """Connect and discover user tables + columns (for the allowlist picker). + Returns {"tables": [{"schema", "table", "columns": [{"name", "type"}]}]}.""" + placeholders = ", ".join(["%s"] * len(SYSTEM_SCHEMAS)) + sql = ( + "SELECT table_schema, table_name, column_name, data_type " + "FROM information_schema.columns " + f"WHERE table_schema NOT IN ({placeholders}) " + "ORDER BY table_schema, table_name, ordinal_position" + ) + with _connection(config) as conn: + with conn.cursor() as cursor: + cursor.execute(sql, SYSTEM_SCHEMAS) + rows = cursor.fetchall() + + tables: Dict[Tuple[str, str], List[Dict]] = {} + for schema, table, column, data_type in rows: + tables.setdefault((schema, table), []).append({"name": column, "type": data_type}) + return { + "tables": [ + {"schema": schema, "table": table, "columns": columns} + for (schema, table), columns in tables.items() + ] + } + + +def describe_columns(config: DBConnectorConfig, schema: str, table: str) -> List[Dict]: + """Column names/types of one table via information_schema.""" + with _connection(config) as conn: + with conn.cursor() as cursor: + cursor.execute( + "SELECT column_name, data_type FROM information_schema.columns " + "WHERE table_schema = %s AND table_name = %s ORDER BY ordinal_position", + (schema, table), + ) + return [{"name": name, "type": data_type} for name, data_type in cursor.fetchall()] + + +def format_result_table( + columns: List[str], rows: List[tuple], max_rows: int, max_value_chars: int = 200 +) -> str: + """Compact text rendering of a result set for the model.""" + if not columns: + return "(no result)" + shown = rows[:max_rows] + lines = [" | ".join(columns)] + for row in shown: + lines.append( + " | ".join( + (str(value)[:max_value_chars] if value is not None else "NULL") + for value in row + ) + ) + suffix = f"({len(shown)} row{'s' if len(shown) != 1 else ''}" + if len(rows) > max_rows: + suffix += ", truncated" + suffix += ")" + lines.append(suffix) + return "\n".join(lines) diff --git a/backend/app/services/faq_article_import.py b/backend/app/services/faq_article_import.py new file mode 100644 index 0000000..6a655d3 --- /dev/null +++ b/backend/app/services/faq_article_import.py @@ -0,0 +1,581 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +Article-mode help-center import: crawl the linked article pages of an existing +help-center/FAQ index and import each one AS-IS — HTML converted straight to +Markdown (no LLM, no credits), images downloaded and re-hosted on our storage, +links absolutized. One draft FAQ per article: question = page title, answer = +the article body in Markdown. +""" + +import asyncio +import re +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple +from urllib.parse import urldefrag, urljoin, urlparse + +import httpx +from bs4 import BeautifulSoup, Tag +from markdownify import MarkdownConverter + +from app.core.config import settings +from app.core.logger import get_logger +from app.knowledge.main_content import select_main_node +from app.knowledge.sitemap_parser import _registrable_domain +from app.knowledge.url_safety import safe_get +from app.models.faq import FAQ, FAQ_SOURCE_URL_MAX_LENGTH, FAQStatus +from app.models.faq_generation_job import FAQGenerationJob, FAQJobStage +from app.models.schemas.faq import MAX_ANSWER_LENGTH, MAX_QUESTION_LENGTH +from app.repositories.faq import FAQRepository +from app.repositories.faq_generation_job import FAQGenerationJobRepository +from app.services.faq_generation import CategoryMerger, insert_draft_rows, normalize_question +from app.services.faq_import import _FETCH_HEADERS, MIN_STATIC_TEXT_CHARS +from app.services.help_center_images import ( + FAQ_IMAGE_TYPES, + MAX_FAQ_IMAGE_BYTES, + store_article_image, +) +from app.services.help_center_settings import assign_faq_url_paths + +logger = get_logger(__name__) + +DEFAULT_ARTICLE_CATEGORY = "Imported" + +# Link targets that can never be article pages. +_SKIP_EXTENSIONS = ( + ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".zip", ".mp4", + ".css", ".js", ".xml", ".ico", ".woff", ".woff2", ".txt", ".json", +) + +# In-article chrome that shouldn't survive into the imported Markdown. +# Word-boundary anchored: matches "docs-toc"/"breadcrumbs" but never innocent +# substrings like "stock-levels" or "autocomplete". +_TOC_RE = re.compile(r"\b(toc|table-of-contents|on-this-page|breadcrumbs?)\b", re.IGNORECASE) + +# Help-center chrome text (platform branding / article metadata) to drop. +# Platform-specific so it never strips legitimate prose like "Powered by our +# AI engine" — only footer branding for known help-center platforms and the +# "Last updated on …" article-metadata line. +_CHROME_TEXT_RE = re.compile( + r"^last updated on\b" + r"|^(made with|powered by)\b.*\b(chatwoot|intercom|zendesk|help ?scout|gitbook|freshdesk|document360)\b", + re.IGNORECASE, +) +_CHROME_HREFS = ("chatwoot.com", "chatwoot.help") + +# Path markers separating leaf article pages from category/section listings +# across Chatwoot / Zendesk / Intercom-style help centers. Only /articles/ +# pages hold real content; the listings are followed for their article links. +_ARTICLE_MARKER = "/articles/" +_CATEGORY_MARKERS = ("/categories/", "/sections/", "/collections/") +_HEADING_TAGS = ("h1", "h2", "h3", "h4") +_LEADING_SYMBOLS_RE = re.compile(r"^[^\w]+", re.UNICODE) + +# Homepage section headings that are curated cross-cuts, not real categories — +# their articles' true category comes from the category listing pages instead. +_GENERIC_SECTIONS = frozenset({ + "featured articles", "featured", "popular", "popular articles", "all articles", + "browse", "recent articles", "recently updated", "top articles", +}) + +# Internal scheme marking images collected during conversion, replaced with the +# re-hosted URL afterwards (conversion is sync; storage is async). The trailing +# ".img" delimits the index so "…//1.img" is never a prefix of "…//10.img". +_IMG_PLACEHOLDER = "cm-pending-image://{index}.img" + + +@dataclass +class Article: + url: str + title: str + markdown: str + category_hint: str + # index -> (bytes, content_type) awaiting re-host. + pending_images: Dict[int, Tuple[bytes, str]] = field(default_factory=dict) + + +class _ArticleConverter(MarkdownConverter): + """HTML→Markdown converter that absolutizes links and swaps images for + placeholders after downloading them (SSRF-guarded, size/type-capped).""" + + def __init__(self, base_url: str, client: httpx.Client, **options): + super().__init__(**options) + self._base_url = base_url + self._client = client + self.images: Dict[int, Tuple[bytes, str]] = {} + + def convert_a(self, el, text, parent_tags): + # Absolutize every link; same-site source links are remapped to the + # destination help center in a second pass (run_article_import_job), + # once every article's new FAQ slug is known. + href = el.get("href") + if href: + el["href"] = urljoin(self._base_url, href) + return super().convert_a(el, text, parent_tags) + + def convert_img(self, el, text, parent_tags): + alt = (el.get("alt") or "").strip() + src = (el.get("src") or "").strip() + if not src or src.startswith("data:"): + return alt # inline/base64 images are stripped by the renderer anyway + if len(self.images) >= settings.FAQ_ARTICLE_IMPORT_MAX_IMAGES: + return alt + image = self._download(urljoin(self._base_url, src)) + if not image: + return alt + index = len(self.images) + self.images[index] = image + return f"![{alt}]({_IMG_PLACEHOLDER.format(index=index)})" + + def _download(self, url: str) -> Optional[Tuple[bytes, str]]: + try: + response = safe_get(self._client, url) + response.raise_for_status() + content_type = (response.headers.get("content-type") or "").split(";")[0].strip() + if content_type not in FAQ_IMAGE_TYPES or len(response.content) > MAX_FAQ_IMAGE_BYTES: + return None + return response.content, content_type + except Exception as e: + logger.warning(f"Article image skipped ({url}): {e}") + return None + + +def _fetch_soup(client: httpx.Client, url: str) -> Tuple[Optional[BeautifulSoup], str]: + """Static SSRF-guarded fetch → (soup, final hop-validated URL).""" + try: + response = safe_get(client, url) + response.raise_for_status() + return BeautifulSoup(response.text, "html.parser"), str(response.url) + except httpx.HTTPError as e: + logger.warning(f"Article fetch failed for {url}: {e}") + return None, url + + +def _fetch_index_soup(client: httpx.Client, url: str) -> Tuple[Optional[BeautifulSoup], str]: + """Index fetch with the headless-browser fallback for JS-rendered indexes + (article pages themselves are static-only — one browser per crawl, not 30).""" + soup, final_url = _fetch_soup(client, url) + if soup and len(soup.get_text(" ", strip=True)) >= MIN_STATIC_TEXT_CHARS: + return soup, final_url + try: + from app.knowledge.crawl4ai_fallback import get_crawl4ai_fallback + + fallback = get_crawl4ai_fallback(timeout=settings.FAQ_IMPORT_FETCH_TIMEOUT) + if fallback.is_available: + html, browser_soup, _ = fallback.fetch_with_browser(final_url) + if browser_soup is not None: + return browser_soup, final_url + if isinstance(html, str) and html: + return BeautifulSoup(html, "html.parser"), final_url + except Exception as e: + logger.warning(f"Article index browser fetch failed for {url}: {e}") + return soup, final_url + + +def _clean_category(text: str) -> str: + """Strip a leading emoji/symbol (Chatwoot section headings are '📞 Help + and support') and cap length.""" + cleaned = _LEADING_SYMBOLS_RE.sub("", (text or "").strip()).strip() + return cleaned[:100] or DEFAULT_ARTICLE_CATEGORY + + +def _same_site_href(anchor: Tag, base_url: str, root_domain: str) -> Optional[str]: + """Absolute same-site content URL for an anchor, or None if it's off-site, + a fragment/query, a bare root, or a binary asset.""" + href = anchor.get("href") + if not href: + return None + url = urldefrag(urljoin(base_url, href))[0].rstrip("/") + parsed = urlparse(url) + if parsed.scheme not in ("http", "https") or parsed.query: + return None + if parsed.path in ("", "/"): + return None + if _registrable_domain(parsed.hostname or "") != root_domain: + return None + if parsed.path.lower().endswith(_SKIP_EXTENSIONS): + return None + return url + + +def _is_article_url(url: str) -> bool: + return _ARTICLE_MARKER in urlparse(url).path.lower() + + +def _is_category_url(url: str) -> bool: + path = urlparse(url).path.lower() + return any(marker in path for marker in _CATEGORY_MARKERS) + + +def _sectioned_article_links(node: Tag, base_url: str, root_domain: str) -> List[Tuple[str, Optional[str]]]: + """Article links in document order, each tagged with the nearest preceding + section heading (a help-center homepage's category cards). Headings nested + inside a link are article titles, not sections, so they don't reset it.""" + results: List[Tuple[str, Optional[str]]] = [] + current: Optional[str] = None + for el in node.find_all([*_HEADING_TAGS, "a"]): + if el.name in _HEADING_TAGS: + if not el.find_parent("a"): + cat = _clean_category(el.get_text(" ", strip=True)) + # Curated cross-cut sections ("Featured Articles") aren't real + # categories — leave those articles for the category pages. + current = None if cat.lower() in _GENERIC_SECTIONS else cat + continue + url = _same_site_href(el, base_url, root_domain) + if url and _is_article_url(url): + results.append((url, current)) + return results + + +def discover_article_links( + client: httpx.Client, index_url: str, limit: int +) -> List[Tuple[str, Optional[str]]]: + """Discover (article_url, category) pairs from a help-center index. + + Standard help centers (Chatwoot/Zendesk/Intercom) nest articles under + /articles/ and group them into /categories/ (or /sections//collections/). + We follow the category listing pages FIRST for the complete per-category + list (homepages truncate each section to a few articles), so hitting the + page cap yields whole categories rather than a thin slice of every one. + Homepage section links then fill any remainder (e.g. featured-only + articles). A non-standard/flat page with no /articles/ links falls back to + importing every same-site content link. + """ + soup, final_url = _fetch_index_soup(client, index_url) + if soup is None: + raise ValueError("Could not read that page.") + root_domain = _registrable_domain(urlparse(final_url).hostname or "") + index_clean = urldefrag(final_url)[0].rstrip("/") + main = select_main_node(soup) or soup + + ordered: List[str] = [] + category_of: Dict[str, Optional[str]] = {} + + def record(url: str, category: Optional[str]) -> None: + if url == index_clean: + return + if url not in category_of: + category_of[url] = category + ordered.append(url) + elif category and not category_of[url]: + # Upgrade a still-uncategorised article (seen first under a generic + # homepage section) with the category page's real name. + category_of[url] = category + + # 1. Follow category/section listing pages first — the complete per-category + # lists (a homepage shows only ~5 of a category's articles). + category_links: List[str] = [] + seen_cat = set() + for anchor in main.find_all("a", href=True): + url = _same_site_href(anchor, final_url, root_domain) + if url and _is_category_url(url) and url not in seen_cat: + seen_cat.add(url) + category_links.append(url) + for cat_url in category_links[: settings.FAQ_ARTICLE_IMPORT_MAX_CATEGORIES]: + if len(ordered) >= limit: + break + cat_soup, cat_final = _fetch_soup(client, cat_url) + if cat_soup is None: + continue + og_title, doc_title = _head_titles(cat_soup) + cat_node = select_main_node(cat_soup) or cat_soup + cat_name = _clean_category(_page_title(og_title, doc_title, cat_node, cat_soup)) + for anchor in cat_node.find_all("a", href=True): + url = _same_site_href(anchor, cat_final, root_domain) + if url and _is_article_url(url): + record(url, cat_name) + + # 2. Homepage section links — fills any remainder (featured-only articles, + # or the index itself being a single category/flat listing page). + for url, category in _sectioned_article_links(main, final_url, root_domain): + record(url, category) + + # 3. Fallback: no standard article URLs — import every same-site link, + # resolving each article's category from its own breadcrumb/URL. + if not ordered: + for anchor in main.find_all("a", href=True): + url = _same_site_href(anchor, final_url, root_domain) + if url: + record(url, None) + + ordered = ordered[:limit] + if not ordered: + raise ValueError("No linked article pages found on that page.") + return [(url, category_of[url]) for url in ordered] + + +def _head_titles(soup: BeautifulSoup) -> Tuple[str, str]: + """(og:title, ) — must run BEFORE select_main_node, which strips + <head> from the soup.""" + og = soup.find("meta", property="og:title") + og_title = og["content"].strip() if og and og.get("content", "").strip() else "" + doc_title = "" + if soup.title and soup.title.get_text(strip=True): + # Drop the " | Site Name" tail commonly appended to <title>. + doc_title = soup.title.get_text(strip=True).split("|")[0].strip() + return og_title, doc_title + + +def _page_title(og_title: str, doc_title: str, node: Tag, soup: BeautifulSoup) -> str: + if og_title: + return og_title + h1 = node.find("h1") or soup.find("h1") + if h1 and h1.get_text(strip=True): + return h1.get_text(" ", strip=True) + return doc_title + + +def _category_hint(soup: BeautifulSoup, url: str) -> str: + """Breadcrumb (second-to-last crumb = the article's section) or a URL path + segment, else the generic import bucket.""" + crumbs = soup.select('[aria-label*="readcrumb"] a, nav.breadcrumb a, .breadcrumbs a, .breadcrumb a') + texts = [c.get_text(" ", strip=True) for c in crumbs if c.get_text(strip=True)] + if len(texts) >= 2: + # Last crumb is the page itself; the one before is its section. + return texts[-2][:100] + segments = [s for s in urlparse(url).path.split("/") if s] + if len(segments) >= 2: + return segments[-2].replace("-", " ").replace("_", " ").title()[:100] + return DEFAULT_ARTICLE_CATEGORY + + +def _strip_chrome(node: Tag) -> None: + """Remove in-article chrome that shouldn't become FAQ content: nav bars, + TOCs, footers, help-center platform branding ('Made with Chatwoot') and + 'Last updated' metadata lines. Best-effort — a detached element mid-loop + just skips. (Breadcrumb "Home › Category" nav links are dropped later, in + _remap_source_links, since they often share a container with the title.)""" + for chrome in node.find_all("nav") + node.find_all("footer"): + chrome.decompose() + for chrome in node.find_all(class_=_TOC_RE) + node.find_all(id=_TOC_RE): + try: + chrome.decompose() + except Exception: + pass + # Platform-branding link ("Made with Chatwoot" logo): remove only a SMALL + # enclosing block (the footer badge), never a large content div that merely + # links out to the platform. + for anchor in list(node.find_all("a", href=True)): + try: + href = (anchor.get("href") or "").lower() + if any(brand in href for brand in _CHROME_HREFS): + container = anchor.find_parent(["div", "footer", "section"]) + target = container if container and len(container.get_text(" ", strip=True)) <= 40 else anchor + target.decompose() + except Exception: + pass + for el in list(node.find_all(["p", "div", "span", "small", "time"])): + try: + text = el.get_text(" ", strip=True) + if text and len(text) <= 60 and _CHROME_TEXT_RE.search(text): + el.decompose() + except Exception: + pass + + +def fetch_article( + client: httpx.Client, url: str, category_override: Optional[str] = None, +) -> Optional[Article]: + """One article page → title + Markdown body (+ images pending re-host). + category_override (from the category listing that linked here) wins over the + per-page breadcrumb/URL guess. None when the page can't be read or is empty.""" + soup, final_url = _fetch_soup(client, url) + if soup is None: + return None + og_title, doc_title = _head_titles(soup) # before <head> is stripped below + node = select_main_node(soup) + if node is None: + return None + title = _page_title(og_title, doc_title, node, soup) + if not title: + return None + category = category_override or _category_hint(soup, final_url) + # The page's own h1 would duplicate the FAQ question. + first_h1 = node.find("h1") + if first_h1: + first_h1.extract() + _strip_chrome(node) + converter = _ArticleConverter( + base_url=final_url, client=client, heading_style="ATX", bullets="-" + ) + markdown = converter.convert_soup(node).strip() + if not markdown: + return None + return Article( + url=final_url, + title=title[:MAX_QUESTION_LENGTH], + markdown=markdown, + category_hint=category, + pending_images=converter.images, + ) + + +async def _rehost_images(article: Article) -> str: + markdown = article.markdown + for index, (content, content_type) in article.pending_images.items(): + placeholder = _IMG_PLACEHOLDER.format(index=index) + try: + markdown = markdown.replace(placeholder, await store_article_image(content, content_type)) + except Exception as e: + logger.warning(f"Article image re-host failed ({article.url}): {e}") + markdown = markdown.replace(f"({placeholder})", "()") + return markdown + + +# Markdown links, excluding image embeds (`![alt](src)`). Group 1 = label, +# group 2 = the http(s) URL; an optional markdown title (`[x](url "title")`, which +# markdownify emits for anchors with a title attribute) is tolerated and dropped. +_MD_LINK_RE = re.compile(r'(?<!!)\[([^\]]*)\]\((https?://[^)\s]+)(?:\s+"[^"]*")?\)') + + +def _article_key(path: str) -> Optional[str]: + """Stable identifier for an article from its URL path — the numeric id in + `/articles/{id}-{slug}` (falling back to the whole segment). Lets a cross-link + resolve to the same article regardless of the slug suffix or a missing slug.""" + lower = path.lower() + idx = lower.find(_ARTICLE_MARKER) + if idx == -1: + return None + segment = path[idx + len(_ARTICLE_MARKER):].split("/")[0].split("?")[0] + numeric = re.match(r"\d+", segment) + return numeric.group(0) if numeric else (segment.lower() or None) + + +# Leading breadcrumb residue after "Home › Account" links are dropped: chevron / +# middot separators (never markdown-structural chars like - > | so hr/blockquote/ +# tables are safe). +_BREADCRUMB_RESIDUE_RE = re.compile(r"^[\s›»·]+") + + +def _remap_source_links(markdown: str, source_hosts: set, key_to_slug: dict) -> str: + """Rewrite links to the SOURCE HELP CENTER as ROOT-RELATIVE help-center paths: + - article link → /a/{new-slug} (or / when the article wasn't imported), + kept as a link; + - home/category link (non-article) → DROPPED — these are breadcrumb/nav + chrome ("Home › Account") that shouldn't appear in article bodies. + + Links are stored WITHOUT an origin or /help/{slug} prefix so they survive a + move to a custom domain or subdomain — the serving prefix is added at render + time (help_center_content.render_article_html). Matching is by EXACT host + against the set of hosts the articles were actually fetched from (handles + redirects and main-site→help-subdomain splits), so links to the main product + site and every other external link are left untouched.""" + def repl(match): + label, url = match.group(1), match.group(2) + parsed = urlparse(url) + if (parsed.hostname or "").lower() not in source_hosts: + return match.group(0) # off-site (incl. the main product site) — keep + key = _article_key(parsed.path) + if key is None: + return "" # home / category breadcrumb link — drop entirely + slug = key_to_slug.get(key) + return f"[{label}](/a/{slug})" if slug else f"[{label}](/)" + + result = _MD_LINK_RE.sub(repl, markdown) + # Tidy up after dropped breadcrumbs: strip leading separator residue and + # collapse the blank lines those drops leave behind. + result = _BREADCRUMB_RESIDUE_RE.sub("", result) + return re.sub(r"\n{3,}", "\n\n", result) + + +async def run_article_import_job(db, job: FAQGenerationJob) -> int: + """Execute an IMPORT_ARTICLES job. Returns FAQs created. No LLM involved — + articles are imported verbatim as Markdown drafts, then a second pass remaps + intra-help-center links to the org's new help center.""" + job_repo = FAQGenerationJobRepository(db) + job_repo.update_progress(job.id, stage=FAQJobStage.ANALYZING_SOURCES, progress_percentage=5.0) + + client = httpx.Client(timeout=settings.FAQ_IMPORT_FETCH_TIMEOUT, headers=_FETCH_HEADERS) + try: + links = await asyncio.to_thread( + discover_article_links, client, job.source_url, settings.FAQ_ARTICLE_IMPORT_MAX_PAGES + ) + job_repo.update_progress(job.id, stage=FAQJobStage.EXTRACTING, progress_percentage=10.0) + + existing = {normalize_question(q) for q in FAQRepository(db).get_existing_questions(job.organization_id)} + categories = CategoryMerger(db, job.organization_id) + source_label = f"Imported from {urlparse(job.source_url).netloc}" + + rows: List[FAQ] = [] + row_sources: List[str] = [] # each row's source article URL, for link remapping + for index, (link, category) in enumerate(links): + article = await asyncio.to_thread(fetch_article, client, link, category) + job_repo.update_progress( + job.id, progress_percentage=10.0 + 78.0 * (index + 1) / len(links) + ) + if not article: + continue + key = normalize_question(article.title) + if not key or key in existing: + continue + existing.add(key) + answer = (await _rehost_images(article))[:MAX_ANSWER_LENGTH] + rows.append( + FAQ( + organization_id=job.organization_id, + question=article.title, + answer=answer, + category=categories.merge(article.category_hint), + status=FAQStatus.DRAFT, + knowledge_id=None, + source_label=source_label, + # Provenance on every import, whether or not the original + # URLs are being preserved — so a later "apply preserved + # paths" pass is a pure data migration. + source_url=article.url[:FAQ_SOURCE_URL_MAX_LENGTH], + generation_job_id=job.id, + created_by=job.user_id, + ) + ) + row_sources.append(article.url) + finally: + client.close() + + if not rows: + return 0 + if job.preserve_source_urls: + # Before the insert, so the per-org unique index can never reject the + # batch. Rows that can't keep their path just fall back to /a/{slug} — + # one unusable URL must not cost the org the whole import. + applied = assign_faq_url_paths(db, rows, row_sources) + logger.info(f"Article import job {job.id}: preserved {applied}/{len(rows)} original URLs") + created = insert_draft_rows(db, job, rows) # assigns each row a unique slug in place + _remap_imported_links(db, rows, row_sources) + return created + + +def _remap_imported_links(db, rows, row_sources) -> None: + """Second pass: rewrite intra-help-center links in the just-imported answers to + root-relative help-center paths (article → /a/{slug}, else → home).""" + # Match against the hosts the articles were ACTUALLY fetched from (post-redirect, + # possibly a help subdomain) — NOT the typed index URL, which may redirect or be + # main-site-hosted. Using the real article hosts fixes redirect/subdomain splits + # without risking a match on main-site content links. + source_hosts = {(urlparse(src).hostname or "").lower() for src in row_sources} + source_hosts.discard("") + key_to_slug = {} + for src, faq in zip(row_sources, rows): + key = _article_key(urlparse(src).path) + if key: + key_to_slug[key] = faq.slug + + dirty = False + for faq in rows: + remapped = _remap_source_links(faq.answer, source_hosts, key_to_slug) + if remapped != faq.answer: + faq.answer = remapped[:MAX_ANSWER_LENGTH] + dirty = True + if dirty: + db.commit() diff --git a/backend/app/services/faq_generation.py b/backend/app/services/faq_generation.py new file mode 100644 index 0000000..7e39e11 --- /dev/null +++ b/backend/app/services/faq_generation.py @@ -0,0 +1,431 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +FAQ generation pipeline: read the org's stored knowledge text (no re-crawl), +extract grounded FAQs with the org's configured model, dedup against existing +questions and insert drafts for review. Additive only — regeneration never +edits or deletes existing FAQs. The drafting loop and dedup/category helpers +here are shared with the import service (faq_import.py). +""" + +import re +from collections import deque +from difflib import SequenceMatcher +from typing import Awaitable, Callable, List, Optional, Tuple, TypeVar + +from fastapi import HTTPException +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.agents.faq_generator import FAQGeneratorAgent, GeneratedFAQ +from app.core.config import settings +from app.core.logger import get_logger +from app.core.security import decrypt_api_key +from app.knowledge.page_editor import NATURAL_ID_ORDER, PAGE_ID_EXPR +from app.models.faq import FAQ, DEFAULT_FAQ_CATEGORY, FAQStatus +from app.models.faq_generation_job import FAQGenerationJob, FAQJobStage, FAQJobType +from app.models.knowledge import Knowledge +from app.repositories.ai_config import AIConfigRepository +from app.repositories.faq import FAQRepository +from app.repositories.faq_generation_job import FAQGenerationJobRepository + +logger = get_logger(__name__) + +# Dedup lists injected into prompts are a small recent window for intra-run +# steering only — the in-Python DedupState below (exact + fuzzy) is the real +# guard covering every existing question, so the prompt stays cheap. +MAX_PROMPT_QUESTIONS = 30 + +# Fuzzy-dedup tuning: token-set Jaccard on content words; borderline scores are +# confirmed with difflib on the normalized strings. Very short questions skip +# fuzzy matching entirely (too collision-prone). +FUZZY_JACCARD_DUP = 0.8 +FUZZY_JACCARD_MAYBE = 0.65 +FUZZY_SEQUENCE_DUP = 0.87 +FUZZY_MIN_CONTENT_TOKENS = 3 + +# Tiny inline stopword set — enough to keep filler words from inflating +# question similarity, without an NLP dependency. +_STOPWORDS = frozenset( + "a an and are can could do does for from how i in is it my of on or the " + "there to we what when where which who why will with you your".split() +) + + +_PUNCT_RE = re.compile(r"[^\w\s]", re.UNICODE) + +BatchT = TypeVar("BatchT") + + +def normalize_question(question: str) -> str: + """Casefold + strip punctuation/whitespace, so trivial rephrasings of the + same question dedup against each other.""" + return " ".join(_PUNCT_RE.sub(" ", question.casefold()).split()) + + +class NoAIConfigError(Exception): + """The org has no active AI configuration to generate with.""" + + +def build_generator(db: Session, organization_id, job: Optional[FAQGenerationJob] = None) -> FAQGeneratorAgent: + """The org's configured extraction agent. When a job is given, every actual + provider call (retries and the plain-JSON fallback's second call included) + increments the job's llm_calls — the metering source of truth.""" + config = AIConfigRepository(db).get_active_config(organization_id) + if not config: + raise NoAIConfigError( + "No active AI configuration. Set up an AI model in AI Configuration first." + ) + generator = FAQGeneratorAgent( + api_key=decrypt_api_key(config.encrypted_api_key), + model_name=config.model_name, + model_type=config.model_type.value if hasattr(config.model_type, "value") else str(config.model_type), + base_url=(config.settings or {}).get('base_url'), + ) + if job is not None: + job_repo = FAQGenerationJobRepository(db) + generator.on_llm_call = lambda: job_repo.increment_llm_calls(job.id) + return generator + + +def load_source_pages(db: Session, knowledge: Knowledge, max_chars: Optional[int] = None) -> List[str]: + """One string of aggregated text per crawled page/sub-page of a source, + read straight from the org's vector table (content is stored alongside the + embeddings — no re-crawl needed). Pages kept in crawl order when the cap + truncates; chunk text within a page is naturally ordered. max_chars caps + each page and defaults to the model-agnostic floor.""" + if not knowledge.schema or not knowledge.table_name: + # Source rows without a vector table (failed/partial ingestion). + return [] + # Chunks written by app.knowledge.chunking (`::n` ids) are verbatim slices + # and join back with nothing between them; legacy agno `_n` chunks were + # cut on whitespace they dropped, so those keep a newline. + query = text( + f"SELECT {PAGE_ID_EXPR} AS page_id, " + "string_agg(content, CASE WHEN id ~ '::[0-9]+$' THEN '' ELSE E'\n' END " + f"ORDER BY {NATURAL_ID_ORDER}) AS body " + f'FROM {knowledge.schema}."{knowledge.table_name}" ' + "WHERE name = :source GROUP BY 1 ORDER BY min(created_at) LIMIT :max_pages" + ) + rows = db.execute( + query, {"source": knowledge.source, "max_pages": settings.FAQ_MAX_PAGES_PER_SOURCE} + ).fetchall() + max_chars = max_chars or settings.FAQ_MAX_BATCH_CHARS + return [row.body[:max_chars] for row in rows if row.body and row.body.strip()] + + +def pack_batches(items: List[str], max_chars: Optional[int] = None, sep: str = "\n\n---\n\n") -> List[str]: + """Greedy-pack whole items (pages, lines) into batches of ~max_chars. + Oversized single items are truncated to max_chars, never split across + batches. Shared by generation (page items) and import (line items).""" + max_chars = max_chars or settings.FAQ_MAX_BATCH_CHARS + batches: List[str] = [] + current: List[str] = [] + size = 0 + for item in items: + item = item[:max_chars] + if current and size + len(item) + len(sep) > max_chars: + batches.append(sep.join(current)) + current, size = [], 0 + current.append(item) + size += len(item) + len(sep) + if current: + batches.append(sep.join(current)) + return batches + + +def _content_tokens(normalized: str) -> frozenset: + return frozenset(t for t in normalized.split() if t not in _STOPWORDS) + + +class DedupState: + """Tracks every question seen (existing + accepted this run). + + Two layers: an exact normalized-set match, plus fuzzy similarity (token-set + Jaccard via an inverted index, difflib confirmation on borderline scores) + so rephrasings are caught without shipping the whole question list to the + LLM. A bounded recent-questions window is still exposed for prompts.""" + + def __init__(self, existing_questions: List[str]): + self.seen: set = set() + self._token_index: dict = {} # content token -> set of seen keys + self._key_tokens: dict = {} # seen key -> its content-token frozenset + self._recent = deque(existing_questions, maxlen=MAX_PROMPT_QUESTIONS) + for q in existing_questions: + self._remember(normalize_question(q)) + + def _remember(self, key: str) -> None: + if not key or key in self.seen: + return + self.seen.add(key) + tokens = _content_tokens(key) + self._key_tokens[key] = tokens + for token in tokens: + self._token_index.setdefault(token, set()).add(key) + + def is_similar(self, key: str) -> bool: + """True when key fuzzily matches a seen question. Exact hits are the + caller's job (cheap set test); this covers rephrasings.""" + tokens = _content_tokens(key) + if len(tokens) < FUZZY_MIN_CONTENT_TOKENS: + return False + candidates: set = set() + for token in tokens: + candidates |= self._token_index.get(token, set()) + for candidate in candidates: + other = self._key_tokens[candidate] + union = len(tokens | other) + if not union: + continue + jaccard = len(tokens & other) / union + if jaccard >= FUZZY_JACCARD_DUP: + return True + if jaccard >= FUZZY_JACCARD_MAYBE and ( + SequenceMatcher(None, key, candidate).ratio() >= FUZZY_SEQUENCE_DUP + ): + return True + return False + + def accept_new(self, faqs: List[GeneratedFAQ]) -> List[GeneratedFAQ]: + accepted = [] + for faq in faqs: + key = normalize_question(faq.question) + if not key or key in self.seen or self.is_similar(key): + continue + self._remember(key) + self._recent.append(faq.question) + accepted.append(faq) + return accepted + + @property + def for_prompt(self) -> List[str]: + return list(self._recent) + + +class CategoryMerger: + """Case-insensitive merge of model-emitted categories into the org's + existing spellings.""" + + def __init__(self, db: Session, organization_id): + self._known = {c.casefold(): c for c in FAQRepository(db).get_categories(organization_id)} + + def merge(self, category: str) -> str: + cleaned = (category or "").strip() or DEFAULT_FAQ_CATEGORY + return self._known.setdefault(cleaned.casefold(), cleaned) + + @property + def names(self) -> List[str]: + return list(self._known.values()) + + +async def draft_batches( + db: Session, + job: FAQGenerationJob, + batches: List[BatchT], + extract: Callable[[BatchT, DedupState], Awaitable[List[GeneratedFAQ]]], + retries_per_batch: int = 1, +) -> List[Tuple[BatchT, GeneratedFAQ]]: + """Shared DRAFTING stage: run `extract` per batch with retry, dedup as we + go, report progress 30→90%. Raises only when every batch failed.""" + job_repo = FAQGenerationJobRepository(db) + job_repo.update_progress(job.id, stage=FAQJobStage.DRAFTING, progress_percentage=30.0) + dedup = DedupState(FAQRepository(db).get_existing_questions(job.organization_id)) + + accepted: List[Tuple[BatchT, GeneratedFAQ]] = [] + failures = 0 + last_error: Optional[Exception] = None + for index, batch in enumerate(batches): + faqs = None + for attempt in range(1 + retries_per_batch): + try: + faqs = await extract(batch, dedup) + break + except Exception as e: + last_error = e + logger.warning(f"FAQ batch {index + 1}/{len(batches)} attempt {attempt + 1} failed: {e}") + if faqs is None: + failures += 1 + else: + accepted.extend((batch, faq) for faq in dedup.accept_new(faqs)) + job_repo.update_progress( + job.id, progress_percentage=30.0 + 60.0 * (index + 1) / len(batches) + ) + if batches and failures == len(batches): + # Surface the underlying provider error (e.g. an unsupported/decommissioned + # model) instead of a generic message, so the UI can tell the user what to fix. + detail = f" — {last_error}" if last_error else "" + raise RuntimeError(f"The AI model could not generate FAQs from this content{detail}") + return accepted + + +def insert_draft_rows(db: Session, job: FAQGenerationJob, rows: List[FAQ]) -> int: + """Shared GROUPING stage tail: bulk-insert the drafted rows.""" + FAQGenerationJobRepository(db).update_progress( + job.id, stage=FAQJobStage.GROUPING, progress_percentage=92.0 + ) + if rows: + FAQRepository(db).bulk_create(rows) + return len(rows) + + +async def run_generation_job(db: Session, job: FAQGenerationJob) -> int: + """Execute a GENERATE_ALL / GENERATE_SOURCE job. Returns FAQs created. + Status transitions and notifications are the worker's responsibility.""" + job_repo = FAQGenerationJobRepository(db) + + # Stage 1: resolve sources + model config. + job_repo.update_progress(job.id, stage=FAQJobStage.ANALYZING_SOURCES, progress_percentage=5.0) + generator = build_generator(db, job.organization_id, job=job) + + knowledge_query = db.query(Knowledge).filter(Knowledge.organization_id == job.organization_id) + if job.job_type == FAQJobType.GENERATE_SOURCE.value: + knowledge_query = knowledge_query.filter(Knowledge.id == job.knowledge_id) + elif job.knowledge_ids: + # Explicit selection overrides the skip-already-generated default. + knowledge_query = knowledge_query.filter(Knowledge.id.in_(job.knowledge_ids)) + sources = knowledge_query.all() + if not sources: + raise ValueError("No knowledge sources to generate from. Add knowledge first.") + + if job.job_type == FAQJobType.GENERATE_ALL.value and not job.knowledge_ids: + # Regenerate touches only sources that haven't produced FAQs yet — + # deleting a source's FAQs makes it eligible again. + generated = FAQRepository(db).knowledge_ids_with_faqs(job.organization_id) + sources = [s for s in sources if s.id not in generated] + if not sources: + raise ValueError( + "All knowledge sources already have FAQs. " + "Delete a source's FAQs to regenerate it." + ) + + # Stage 2: read stored page text per source. A broken source (missing + # vector table etc.) is skipped, not fatal — unless nothing is readable. + job_repo.update_progress(job.id, stage=FAQJobStage.EXTRACTING, progress_percentage=10.0) + source_batches: List[Tuple[Knowledge, str]] = [] + for source in sources: + try: + # Batch size follows the org's model context window — big-context + # models get fewer, larger LLM calls. + pages = load_source_pages(db, source, max_chars=generator.batch_chars) + except Exception as e: + logger.warning(f"Skipping unreadable knowledge source {source.id} ({source.source}): {e}") + db.rollback() + continue + for batch in pack_batches(pages, max_chars=generator.batch_chars): + source_batches.append((source, batch)) + if not source_batches: + raise ValueError("The selected knowledge sources contain no readable content.") + + # Stage 3: draft per batch (shared loop). + categories = CategoryMerger(db, job.organization_id) + + async def extract(batch: Tuple[Knowledge, str], dedup: DedupState) -> List[GeneratedFAQ]: + return await generator.generate_from_text( + batch[1], + existing_questions=dedup.for_prompt, + existing_categories=categories.names, + ) + + accepted = await draft_batches(db, job, source_batches, extract) + + # Stage 4: group + insert drafts. + rows = [ + FAQ( + organization_id=job.organization_id, + question=faq.question, + answer=faq.answer, + category=categories.merge(faq.category), + status=FAQStatus.DRAFT, + knowledge_id=source.id, + source_label=source.source, + generation_job_id=job.id, + created_by=job.user_id, + ) + for (source, _batch_text), faq in accepted + ] + return insert_draft_rows(db, job, rows) + + +def maybe_enqueue_auto_faq_job(db: Session, queue_item) -> Optional[FAQGenerationJob]: + """Auto-generate draft FAQs when a new knowledge source finishes processing. + + Only fires for orgs that have adopted the feature (a help-center settings + row or any FAQ exists), have auto-generate on, are allowed by their plan + (cloud), and don't already have an active job for this source. Never raises + and always leaves the shared session usable — a hook failure must not fail + knowledge processing. + """ + try: + from app.repositories.help_center import HelpCenterRepository + from app.services.help_center_access import help_center_allowed + + org_id = queue_item.organization_id + settings_row = HelpCenterRepository(db).get_by_org(org_id) + faq_repo = FAQRepository(db) + job_repo_gate = FAQGenerationJobRepository(db) + # Adoption gate: auto-generation only after the org explicitly ran a + # generate/import (or added an FAQ). A settings row alone is NOT + # opt-in — merely opening the admin page creates one. + if not job_repo_gate.has_user_initiated_job(org_id) and not faq_repo.exists_for_org(org_id): + return None + if settings_row and not settings_row.auto_generate: + return None + if not help_center_allowed(db, org_id): + return None + knowledge = ( + db.query(Knowledge) + .filter(Knowledge.organization_id == org_id, Knowledge.source == queue_item.source) + .order_by(Knowledge.id.desc()) # deterministic: newest row wins on duplicate sources + .first() + ) + if not knowledge: + return None + job_repo = FAQGenerationJobRepository(db) + if job_repo.get_active_for_org(org_id, FAQJobType.GENERATE_SOURCE, knowledge_id=knowledge.id): + return None + from app.services.faq_usage import ( + count_source_pages, + ensure_generation_budget, + generation_is_metered, + PAGES_PER_CALL, + ) + from math import ceil + + try: + pages = count_source_pages(db, knowledge) + ensure_generation_budget(db, org_id, max(1, ceil((pages or 1) / PAGES_PER_CALL))) + except HTTPException: + logger.info(f"Auto FAQ generation skipped for org {org_id}: message budget exhausted") + return None + job = job_repo.create( + FAQGenerationJob( + organization_id=org_id, + user_id=queue_item.user_id, + job_type=FAQJobType.GENERATE_SOURCE.value, + knowledge_id=knowledge.id, + metered=generation_is_metered(db, org_id), + ) + ) + logger.info(f"Auto-enqueued FAQ generation job {job.id} for knowledge {knowledge.id}") + return job + except Exception as e: + logger.error(f"Auto FAQ enqueue failed (non-fatal): {e}") + # A failed flush would otherwise poison the caller's session and turn + # an already-successful knowledge run into a FAILED one. + try: + db.rollback() + except Exception: + pass + return None diff --git a/backend/app/services/faq_import.py b/backend/app/services/faq_import.py new file mode 100644 index 0000000..fdeeb98 --- /dev/null +++ b/backend/app/services/faq_import.py @@ -0,0 +1,243 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +FAQ page migration: fetch the customer's existing FAQ/help-center page +(SSRF-guarded) or read an uploaded PDF, extract Q&A pairs with the org's model +and insert them as drafts. URL mode is single page only — for linked article +pages use the article importer (faq_article_import.py). +""" + +import asyncio +from io import BytesIO +from typing import List, Optional +from urllib.parse import urlparse + +import httpx +from bs4 import BeautifulSoup +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.logger import get_logger +from app.knowledge.url_safety import guard_url, safe_get +from app.models.faq import FAQ, FAQStatus +from app.models.faq_generation_job import FAQGenerationJob, FAQJobStage +from app.repositories.faq_generation_job import FAQGenerationJobRepository +from app.services.faq_generation import ( + CategoryMerger, + DedupState, + build_generator, + draft_batches, + insert_draft_rows, + pack_batches, +) + +logger = get_logger(__name__) + +# Below this, the static HTML likely lacks the real content (JS-rendered page). +MIN_STATIC_TEXT_CHARS = 500 +# Block-level elements get blank-line separators so a question heading stays +# attached to its answer text when batches are split on blocks. +_BLOCK_TAGS = ( + "p", "div", "section", "article", "li", "h1", "h2", "h3", "h4", "h5", "h6", + "dt", "dd", "summary", "details", "tr", +) +_BOILERPLATE_TAGS = ("script", "style", "noscript", "nav", "footer", "header", "form") + +_FETCH_HEADERS = {"User-Agent": "ChatterMate-FAQ-Import/1.0 (+https://chattermate.chat)"} + + +def _soup_to_text(soup: BeautifulSoup) -> str: + """Clean text with one BLANK line between block elements, so downstream + blank-line batching keeps each Q&A block intact.""" + for tag in soup.find_all(_BOILERPLATE_TAGS): + tag.decompose() + for tag in soup.find_all(_BLOCK_TAGS): + tag.append("\n\n") + lines = [line.strip() for line in soup.get_text("\n").split("\n")] + text = "\n".join(lines) + # Collapse 3+ newlines to exactly one blank line. + while "\n\n\n" in text: + text = text.replace("\n\n\n", "\n\n") + return text.strip() + + +def fetch_page_text(url: str) -> str: + """Fetch the page and reduce it to clean text. Static fetch first (every + redirect hop SSRF-checked by safe_get); crawl4ai's headless browser as a + fallback for JS-rendered pages. Blocking — callers run it in a thread.""" + text = "" + final_url = url + try: + with httpx.Client(timeout=settings.FAQ_IMPORT_FETCH_TIMEOUT, headers=_FETCH_HEADERS) as client: + response = safe_get(client, url) + response.raise_for_status() + # The hop-validated final URL — the browser fallback fetches this, + # not the original, so a redirect chain can't smuggle it inward. + final_url = str(response.url) + text = _soup_to_text(BeautifulSoup(response.text, "html.parser")) + except httpx.HTTPError as e: + logger.warning(f"FAQ import static fetch failed for {url}: {e}") + + if len(text) < MIN_STATIC_TEXT_CHARS: + rendered = _fetch_rendered_text(final_url) + if rendered and len(rendered) > len(text): + text = rendered + + if not text.strip(): + raise ValueError("Could not read any text from that page.") + return text[: settings.FAQ_IMPORT_MAX_PAGE_CHARS] + + +def _fetch_rendered_text(url: str) -> Optional[str]: + """Headless-browser fallback for JS-rendered pages. The URL is re-checked + against the SSRF guard, but a browser follows redirects/JS navigation + unguarded — same residual exposure as the knowledge crawler's fallback.""" + try: + guard_url(url) + from app.knowledge.crawl4ai_fallback import get_crawl4ai_fallback + + fallback = get_crawl4ai_fallback(timeout=settings.FAQ_IMPORT_FETCH_TIMEOUT) + if not fallback.is_available: + return None + _, soup, _ = fallback.fetch_with_browser(url) + return _soup_to_text(soup) if soup else None + except Exception as e: + logger.warning(f"FAQ import browser fetch failed for {url}: {e}") + return None + + +def _split_blocks(text: str) -> List[str]: + """Blank-line-separated blocks (see _soup_to_text) — the batching unit.""" + return [block for block in text.split("\n\n") if block.strip()] + + +async def _extract_and_insert( + db: Session, job: FAQGenerationJob, generator, text: str, source_label: str, + mode: str = "extract", +) -> int: + """Shared LLM tail for URL/PDF imports: batch → extract → dedup → insert. + + mode="extract" migrates a page that already contains Q&A pairs (verbatim). + mode="generate" turns free-form documentation (a product guide, manual…) + into FAQs — an existing-FAQ extraction prompt would find almost nothing in + prose that isn't already a Q&A list.""" + batches = pack_batches(_split_blocks(text), max_chars=generator.batch_chars, sep="\n\n") + categories = CategoryMerger(db, job.organization_id) + + if mode == "generate": + async def extract(batch: str, dedup: DedupState): + return await generator.generate_from_text( + batch, existing_questions=dedup.for_prompt, existing_categories=categories.names + ) + else: + async def extract(batch: str, dedup: DedupState): + faqs = await generator.extract_from_faq_page(batch, existing_questions=dedup.for_prompt) + if not faqs: + # The page had no verbatim Q&A — an index/category listing or free-form + # prose. Rather than import nothing, draft FAQs from the same content. + faqs = await generator.generate_from_text( + batch, existing_questions=dedup.for_prompt, existing_categories=categories.names + ) + return faqs + + accepted = await draft_batches(db, job, batches, extract, retries_per_batch=0) + + rows = [ + FAQ( + organization_id=job.organization_id, + question=faq.question, + answer=faq.answer, + category=categories.merge(faq.category), + status=FAQStatus.DRAFT, + knowledge_id=None, + source_label=source_label, + generation_job_id=job.id, + created_by=job.user_id, + ) + for _batch, faq in accepted + ] + return insert_draft_rows(db, job, rows) + + +async def run_import_job(db: Session, job: FAQGenerationJob) -> int: + """Execute an IMPORT_URL job. Returns FAQs created. Status transitions and + notifications are the worker's responsibility.""" + job_repo = FAQGenerationJobRepository(db) + # ANALYZING = fail fast on a missing model config, before any external I/O. + job_repo.update_progress(job.id, stage=FAQJobStage.ANALYZING_SOURCES, progress_percentage=5.0) + generator = build_generator(db, job.organization_id, job=job) + + job_repo.update_progress(job.id, stage=FAQJobStage.EXTRACTING, progress_percentage=15.0) + # Re-validated at fetch time (not only at the API layer) so a job forged or + # replayed against the queue can't reach internal hosts; run in a thread so + # the slow fetch doesn't block the worker's event loop. + page_text = await asyncio.to_thread(fetch_page_text, job.source_url) + return await _extract_and_insert( + db, job, generator, page_text, + source_label=f"Imported from {urlparse(job.source_url).netloc}", + ) + + +def _pdf_to_text(content: bytes) -> str: + """Page-by-page text extraction with pypdf directly — agno's PDFReader + chunks/summarizes for the vector KB, which we don't want here. Blank-line + separators between pages keep block batching intact.""" + from pypdf import PdfReader + + reader = PdfReader(BytesIO(content)) + pages = [] + for page in reader.pages: + try: + text = page.extract_text() or "" + except Exception as e: # a single corrupt page shouldn't sink the file + logger.warning(f"PDF page extraction failed: {e}") + continue + if text.strip(): + pages.append(text.strip()) + return "\n\n".join(pages)[: settings.FAQ_IMPORT_MAX_PAGE_CHARS] + + +async def run_pdf_import_job(db: Session, job: FAQGenerationJob) -> int: + """Execute an IMPORT_PDF job: read the stored upload (job.source_url), + extract text and run the shared Q&A extraction. The stored file is + best-effort deleted afterwards — it's an import buffer, not a document + library.""" + from app.services.file_storage import delete_upload, load_upload + + job_repo = FAQGenerationJobRepository(db) + # Captured before the try: after a DB-originated failure the ORM instance + # is expired and reading job attributes from the finally would raise, + # masking the real error and leaking the buffer. + stored = job.source_url + source_label = f"Imported from {job.source_file_name or 'PDF'}" + # ANALYZING = fail fast on a missing model config, before file I/O. + job_repo.update_progress(job.id, stage=FAQJobStage.ANALYZING_SOURCES, progress_percentage=5.0) + + try: + generator = build_generator(db, job.organization_id, job=job) + content = await load_upload(stored) + job_repo.update_progress(job.id, stage=FAQJobStage.EXTRACTING, progress_percentage=15.0) + text = await asyncio.to_thread(_pdf_to_text, content) + if not text.strip(): + raise ValueError( + "Could not read any text from that PDF (it may be scanned images)." + ) + # A PDF is usually documentation/marketing prose, not an existing Q&A + # list — generate FAQs from it rather than extracting verbatim pairs. + return await _extract_and_insert( + db, job, generator, text, source_label=source_label, mode="generate" + ) + finally: + await delete_upload(stored) diff --git a/backend/app/services/faq_usage.py b/backend/app/services/faq_usage.py new file mode 100644 index 0000000..bf46b56 --- /dev/null +++ b/backend/app/services/faq_usage.py @@ -0,0 +1,180 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +FAQ generation usage/credits: estimate how many LLM calls a run will make, +decide whether those calls are metered (hosted model), and enforce the org's +message budget before enqueueing. OSS/self-hosted (no enterprise module) is +never metered and never blocked. +""" + +import math +from dataclasses import dataclass +from typing import List, Optional +from uuid import UUID + +from fastapi import HTTPException +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.logger import get_logger +from app.knowledge.page_editor import PAGE_ID_EXPR +from app.models.knowledge import Knowledge +from app.repositories.faq import FAQRepository +# Shared hosted-model metering helpers; re-exported here so existing FAQ +# callers keep importing them from faq_usage. +from app.services.usage_metering import ( # noqa: F401 + HOSTED_MODEL_TYPE, + is_hosted_model, + remaining_message_credits, +) + +logger = get_logger(__name__) + +# Rough pages-per-LLM-call heuristic for the confirm-dialog estimate (matches +# today's floor batch of ~3 average pages). An estimate, not billing. +PAGES_PER_CALL = 3 + +OVER_BUDGET_MESSAGE = ( + "Not enough message credits left in your plan for this generation " + "(~{estimated} needed, {remaining} remaining). Upgrade your plan or " + "switch to your own AI model key." +) + + +@dataclass +class SourceEstimate: + id: int + name: str + source_type: str + has_faqs: bool + pages: int + estimated_calls: int + + +@dataclass +class GenerationEstimate: + total_sources: int + new_sources: int + pages: int + estimated_calls: int + sources: List[SourceEstimate] + + +def generation_is_metered(db: Session, organization_id: UUID) -> bool: + """Generation costs credits only on the hosted model — own-key orgs pay + their provider directly. FAQ_METER_OWN_KEY flips to always-meter.""" + return settings.FAQ_METER_OWN_KEY or is_hosted_model(db, organization_id) + + +def count_source_pages(db: Session, knowledge: Knowledge) -> Optional[int]: + """Distinct stored pages for a source, capped like generation itself. + None when the vector table is missing/unreadable.""" + if not knowledge.schema or not knowledge.table_name: + return None + query = text( + f"SELECT COUNT(*) FROM (SELECT {PAGE_ID_EXPR} " + f'FROM {knowledge.schema}."{knowledge.table_name}" ' + "WHERE name = :source GROUP BY 1 LIMIT :max_pages) pages" + ) + try: + count = db.execute( + query, {"source": knowledge.source, "max_pages": settings.FAQ_MAX_PAGES_PER_SOURCE} + ).scalar() + return int(count or 0) + except Exception as e: + logger.warning(f"Page count failed for knowledge {knowledge.id}: {e}") + db.rollback() + return None + + +def estimate_generation_calls( + db: Session, + organization_id: UUID, + knowledge_ids: Optional[List[int]] = None, + count_pages: bool = True, +) -> GenerationEstimate: + """Confirm-dialog numbers for a GENERATE_ALL run: which sources it would + read (new-only unless explicitly targeted) and roughly how many LLM calls + that costs. Unreadable sources count as one call each. + + Always returns the FULL per-source list (for the generation source picker), + each with its own page/call estimate and whether it already has FAQs. The + aggregate counts (new_sources/pages/estimated_calls) cover the run's TARGET + sources: the explicit `knowledge_ids` selection, or — when none is given — + every source that doesn't yet have FAQs. + + count_pages=False skips the per-source page COUNT over the vector tables + (cheap — for surfaces that only need counts, like the Generate button + label); per-source estimated_calls is then a 1-per-source lower bound.""" + all_sources = ( + db.query(Knowledge).filter(Knowledge.organization_id == organization_id).all() + ) + generated = FAQRepository(db).knowledge_ids_with_faqs(organization_id) + existing_ids = {s.id for s in all_sources} + target_ids = ( + (set(knowledge_ids) & existing_ids) + if knowledge_ids + else {s.id for s in all_sources if s.id not in generated} + ) + + source_list: List[SourceEstimate] = [] + agg_pages = 0 + agg_calls = 0 + for source in all_sources: + source_pages = count_source_pages(db, source) if count_pages else None + pages = source_pages or 0 + if source_pages is None: + # Unreadable now, or page-count skipped (count_pages=False) — assume + # one call so the estimate isn't zero. + calls = 1 + else: + # A readable but empty source produces nothing → costs no call. + calls = max(1, math.ceil(pages / PAGES_PER_CALL)) if pages else 0 + source_list.append( + SourceEstimate( + id=source.id, + name=source.source, + source_type=source.source_type.value if hasattr(source.source_type, "value") else str(source.source_type), + has_faqs=source.id in generated, + pages=pages, + estimated_calls=calls, + ) + ) + if source.id in target_ids: + agg_pages += pages + agg_calls += calls + + return GenerationEstimate( + total_sources=len(all_sources), + new_sources=len(target_ids), + pages=agg_pages, + estimated_calls=agg_calls, + sources=source_list, + ) + + +def ensure_generation_budget(db: Session, organization_id: UUID, estimated_calls: int) -> None: + """402 when a metered run would exceed the org's remaining credits.""" + if estimated_calls <= 0 or not generation_is_metered(db, organization_id): + return + remaining = remaining_message_credits(db, organization_id) + if remaining is None: + return + if estimated_calls > remaining: + raise HTTPException( + status_code=402, + detail=OVER_BUDGET_MESSAGE.format(estimated=estimated_calls, remaining=remaining), + ) diff --git a/backend/app/services/firebase.py b/backend/app/services/firebase.py new file mode 100644 index 0000000..6635950 --- /dev/null +++ b/backend/app/services/firebase.py @@ -0,0 +1,63 @@ +""" +Copyright 2024-2026 ChatterMate + +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 firebase_admin import credentials, initialize_app, get_app +from app.core.config import settings +from app.core.logger import get_logger +import os + +logger = get_logger(__name__) + + +def initialize_firebase(): + """Initialize Firebase Admin SDK""" + try: + # Check if already initialized + try: + get_app() + logger.info("Firebase already initialized") + return + except ValueError: + logger.info("Initializing Firebase...") + + # Get credentials file path + cred_path = settings.FIREBASE_CREDENTIALS + + if not cred_path or not os.path.isfile(cred_path): + logger.warning(f"Firebase credentials not found at {cred_path}. Running in development mode.") + # Use a default configuration for development + cred = credentials.Certificate({ + "type": "service_account", + "project_id": "demo-project", + "private_key_id": "demo123456789", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDK/eDH0MDgBNHs\nYuXwHMnMYGz+86YJjYoXVbKdUGHQZXYVEVlc8rYA+VXbU1AqhD+c7yT9V9EaEjPO\nXGVWS6dWQh4Kh5g7y1O5SHIwQhDHK+AAqMgBfZhzEHJBtO8CXG8TDyxhfHXxjWpD\nGPz/RJ7GhW9cHYQOvP4yZAp3ZNGYQJwUlhNLGr80XEhGVlkZ5WPL0YFzwOxvqGQs\nQVVxGGj5h7YHJt1YmZwF6cBGGX8AUetJkMTgQEJSNsaK4Pl+MZUGQOhQEynK3qO2\nWf+7XZl4XPx9Qw+WPYEoHHlGgGQR4YUpL+qzVxYwNAXhVxd9k+YPp5JZHtGQAXeO\nZhxDo1QLAgMBAAECggEABBj/H/JGXuVGoCjR6J0XF+kQTOuTgZBvgapkpPTYwfXQ\nPRKzGWEpGUPVnVb3gMDqbqQe7EVJZ8Nj/zHmXf9c4qKxpXwNl7kYmPpuQOHmA8Nq\nXPE3QZH3YSRz1YXF/pA9RQqG5F4JBJjz5LcUR+ax9aSxPu26xwDHOpR4EIR9Aovx\nJfcg7MTAY4YuGBe0oLQbEWoUQqXgUIVEzWcqiDGXF0uuMZzpW6YVk5ZvWHXz7SVK\nE7e1XQN6sMTYqrmnFGkBhuvXiBkPZHsOEHBVxXVqytE8FJAqXwPwFqxWj4RhNS/w\nkMUv5ZyH6UOFuBXtSe1XGGEqktNVGPBhAkEo4BbhAQKBgQD1+fdC9zzDwA4KoNnk\nVxQbCpC7Zr5F8Mycer0SV/3Gg6okzo9vXl+vqVHXqYNGnAEFkLLhEzwpZB0cnqWk\nQswrGvZ5FvUSKd7rMfI0Uh5FxhYCuUwwZW6gVKEZDCXF6N7KBbMPaYVZuGwD7oQk\nUQKBgQDTKj+RKqFrwhZM4cVJVgqHgQmMvHXC7Hy3Sj8Yl9ZHxgsnrkoNXXXRDNwp\nEWVoTy4Xr1BXuBxFEgEBpuPGBlHVXLZRzaP1pCZVqftxiHDqGlAh4y3yZtKHSTGj\n1B6OZlYXxvD+9K5Ki1ZwM3JxpY4YE5zASL7cUrV+gUUE2VoengKBgQDxIrXXFR8l\nAgMBAAECggEABBj/H/JGXuVGoCjR6J0XF+kQTOuTgZBvgapkpPTYwfXQ\n-----END PRIVATE KEY-----\n", + "client_email": "firebase-adminsdk-demo@demo-project.iam.gserviceaccount.com", + "client_id": "123456789", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-demo%40demo-project.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" + }) + else: + logger.info("Loading Firebase credentials from file " + cred_path) + cred = credentials.Certificate(cred_path) + + initialize_app(cred) + logger.info("Firebase initialized successfully") + except Exception as e: + logger.error(f"Error initializing Firebase: {str(e)}") + logger.warning("Continuing without Firebase initialization") diff --git a/backend/app/services/help_center_access.py b/backend/app/services/help_center_access.py new file mode 100644 index 0000000..ff8a5e3 --- /dev/null +++ b/backend/app/services/help_center_access.py @@ -0,0 +1,42 @@ +""" +Copyright 2024-2026 ChatterMate + +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 plan gating: Pro-only on cloud, unrestricted for OSS/self-hosted. +Thin wrappers over the generic feature gate. +""" + +from uuid import UUID + +from sqlalchemy.orm import Session + +from app.services.feature_gate import check_feature_access, feature_allowed + +HELP_CENTER_FEATURE = "help_center" + +UPGRADE_MESSAGE = ( + "The Help Center is not available in your current plan. " + "Please upgrade to Pro to access this feature." +) + + +def help_center_allowed(db: Session, organization_id: UUID) -> bool: + """Non-raising check — used by the public site (which 404s instead of + 403ing) and the auto-generation hook. Fails closed on gating errors.""" + return feature_allowed(db, organization_id, HELP_CENTER_FEATURE) + + +def check_help_center_access(db: Session, organization_id: UUID) -> None: + """Raising check for admin endpoints (403 with the upgrade message).""" + check_feature_access(db, organization_id, HELP_CENTER_FEATURE, UPGRADE_MESSAGE) diff --git a/backend/app/services/help_center_content.py b/backend/app/services/help_center_content.py new file mode 100644 index 0000000..feffaa4 --- /dev/null +++ b/backend/app/services/help_center_content.py @@ -0,0 +1,131 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +FAQ answers are authored and stored as Markdown so a single Text column can hold +rich help-center articles (headings, images, links, ordered steps, callouts). +This module turns that Markdown into HTML for the public page, and into plain +text for previews, meta descriptions and FAQPage structured data. + +Security: the rendered HTML is dropped onto the *public* help center, so an org +admin's Markdown is untrusted input for visitors. Everything is run through an +nh3 (ammonia) allowlist clean — the template must mark this output `| safe`, so +this is the ONLY place org content is ever trusted as HTML. +""" + +import re +from typing import Mapping, Optional + +import markdown as _markdown +import nh3 +from bs4 import BeautifulSoup + +# `extra` enables tables/fenced code/etc; `sane_lists` fixes mixed list quirks; +# `nl2br` treats single newlines as <br> so plain-text FAQ answers (the common +# case) keep their line breaks without the author needing blank lines. +_MD_EXTENSIONS = ["extra", "sane_lists", "nl2br"] + +# Allowlist of tags a help-center article may contain. Anything else (script, +# style, iframe, form, event handlers, …) is stripped by nh3. +_ALLOWED_TAGS = { + "p", "br", "hr", "h1", "h2", "h3", "h4", "h5", "h6", + "strong", "b", "em", "i", "u", "s", "del", "code", "pre", "blockquote", + "ul", "ol", "li", "a", "img", + "table", "thead", "tbody", "tr", "th", "td", + "figure", "figcaption", "span", +} +_ALLOWED_ATTRS = { + "a": {"href", "title"}, + "img": {"src", "alt", "title"}, +} +# Block javascript:/data: URLs; images and links may only point at real hosts. +_URL_SCHEMES = {"http", "https", "mailto"} + +_WHITESPACE_RE = re.compile(r"\s+") + +# Intra-help-center links are stored ROOT-RELATIVE (/a/{slug}, or / for the home) +# so they survive a move between path mode and a subdomain/custom domain. The +# serving prefix is added here at render time. `/a/` (with the trailing slash) +# never matches an image path like /api/v1/uploads/... +_INTERNAL_HREF_RE = re.compile(r'(href=")(/a/[^"]*|/)(")') + + +def render_article_html( + text: str, base_path: str = "", link_paths: Optional[Mapping[str, str]] = None +) -> str: + """Markdown -> sanitized HTML for the public article body. Safe to render + with Jinja's `| safe` because nh3 strips every tag/attr/scheme off-allowlist + and rewrites link rel to neutralise tab-nabbing. + + `base_path` is the serving prefix ("" at a domain root, "/help/{slug}" in path + mode) prepended to root-relative intra-help-center links so cross-article links + resolve under whichever mode/domain serves the page. + + `link_paths` maps slug -> the path that article is actually served at, for + the articles whose original URL was preserved on a help-center migration. + Cross-article links stay STORED as /a/{slug} (stable when a path is later + edited, and the only form the regex above can prefix) and are resolved here, + so a link lands directly on the preserved URL instead of via its 301.""" + if not text or not text.strip(): + return "" + raw = _markdown.markdown(text, extensions=_MD_EXTENSIONS, output_format="html") + html = nh3.clean( + raw, + tags=_ALLOWED_TAGS, + attributes=_ALLOWED_ATTRS, + url_schemes=_URL_SCHEMES, + link_rel="noopener noreferrer nofollow", + ) + if base_path or link_paths: + html = _INTERNAL_HREF_RE.sub( + lambda m: f"{m.group(1)}{base_path}{_resolve_internal_href(m.group(2), link_paths)}{m.group(3)}", + html, + ) + return html + + +def _resolve_internal_href(href: str, link_paths: Optional[Mapping[str, str]]) -> str: + """A stored /a/{slug} link swapped for that article's preserved path, if it + has one. Unknown slugs (and the "/" home link) pass straight through.""" + if not link_paths or not href.startswith("/a/"): + return href + return link_paths.get(href[len("/a/"):], href) + + +def to_plain_text(text: str) -> str: + """Strip Markdown/HTML to a single line of readable text (previews, meta + descriptions, JSON-LD answer text).""" + if not text or not text.strip(): + return "" + html = _markdown.markdown(text, extensions=_MD_EXTENSIONS, output_format="html") + plain = BeautifulSoup(html, "html.parser").get_text(" ") + return _WHITESPACE_RE.sub(" ", plain).strip() + + +def excerpt(text: str, length: int = 150) -> str: + """A short one-line preview of an answer, cut on a word boundary.""" + plain = to_plain_text(text) + if len(plain) <= length: + return plain + return plain[:length].rsplit(" ", 1)[0].rstrip() + "…" + + +def read_time_minutes(text: str) -> int: + """Estimated read time in whole minutes (>= 1), ~200 words/min.""" + words = len(to_plain_text(text).split()) + return max(1, round(words / 200)) + + +def read_time_label(text: str) -> str: + return f"{read_time_minutes(text)} min read" diff --git a/backend/app/services/help_center_images.py b/backend/app/services/help_center_images.py new file mode 100644 index 0000000..cf68203 --- /dev/null +++ b/backend/app/services/help_center_images.py @@ -0,0 +1,124 @@ +""" +Copyright 2024-2026 ChatterMate + +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 article images. Images embedded in an article's Markdown must +resolve to a STABLE URL that is baked inline in the answer forever — so no +signed/expiring URLs, and no raw S3 URLs either now the bucket is private. +Both storage backends therefore return the same host-RELATIVE path, which +app/api/help_center_images.py resolves per request (redirect to a freshly +signed URL on S3, direct file on local). Host-relative so it resolves against +whichever origin serves the page: the main app in path mode, public_app on a +help-center subdomain / custom domain. Shared by the admin upload endpoint and +the article importer's re-hosting path. +""" + +import re +from uuid import uuid4 + +from app.core.config import settings +from app.services.file_storage import store_upload + +def strip_upload_host(text: str) -> str: + """Convert the app's OWN absolute upload URLs to relative /api/v1/uploads/... + paths, leaving everything else untouched. Only strips the configured + BACKEND_URL origin or a loopback host (localhost/127.0.0.1/0.0.0.0) — a + third-party URL that merely contains /api/v1/uploads/ (e.g. a link pasted + into an FAQ answer) is NOT rewritten. S3 and already-relative URLs never + match. Used by the one-time backfill migration.""" + if not text: + return text + backend = settings.BACKEND_URL.rstrip("/") + # Match our own backend origin OR any loopback host (with optional port), + # only when it sits directly in front of an /api/v1/uploads/ path. + pattern = re.compile( + r'(?:' + re.escape(backend) + + r'|https?://(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::\d+)?)' + + r'(?=/api/v1/uploads/)' + ) + return pattern.sub("", text) + +MAX_FAQ_IMAGE_BYTES = 5 * 1024 * 1024 +# Reject larger-than-this on a side up front (bomb guard); downscale the stored +# image to fit FAQ_IMAGE_FIT_DIM so article pages stay light. +FAQ_IMAGE_MAX_DIM = 6000 +FAQ_IMAGE_FIT_DIM = 1600 +FAQ_IMAGE_TYPES = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", +} +UPLOAD_FOLDER = "help_center" + +# store_article_image names files "<uuid4><ext>". The delivery route matches +# against this before interpolating the name into an S3 key or a filesystem +# path, so it is what stops a traversal ("../agents/x.png") being served. +_UUID4_PATTERN = ( + r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" +) +_EXTENSION_PATTERN = "|".join( + re.escape(ext.lstrip(".")) for ext in sorted(set(FAQ_IMAGE_TYPES.values())) +) +ARTICLE_IMAGE_NAME_RE = re.compile(rf"^{_UUID4_PATTERN}\.(?:{_EXTENSION_PATTERN})$") + +# Absolute S3 URLs baked into article Markdown before delivery moved behind +# article_image_path(). Both URL shapes (virtual-hosted and path-style) end in +# ".../help_center/<name>", so anchor on that. +_BAKED_S3_IMAGE_RE = re.compile( + r"https?://[^\s\"'()]*amazonaws\.com/[^\s\"'()]*" + + re.escape(UPLOAD_FOLDER) + + rf"/({_UUID4_PATTERN}\.(?:{_EXTENSION_PATTERN}))" +) + + +def article_image_path(file_name: str) -> str: + """The stable, host-relative URL baked into article Markdown. + + Identical for local and S3 storage — app/api/help_center_images.py resolves + it per request. Host-relative so it works both in path mode (main app) and + on a help-center subdomain / custom domain (public_app).""" + return f"{settings.API_V1_STR}/help-center/images/{file_name}" + + +def absolute_upload_url(stored: str) -> str: + """Make a stored upload path absolute. S3 returns an absolute http(s) URL + already; local mode returns a path under the /api/v1/uploads mount, which we + anchor to the backend's public origin so it loads cross-domain.""" + if stored.startswith("http"): + return stored + return f"{settings.BACKEND_URL.rstrip('/')}{stored}" + + +def rewrite_baked_s3_image_urls(text: str) -> str: + """Point previously-baked absolute S3 article-image URLs at article_image_path. + + Those URLs 403 now the bucket is private, and would have needed re-signing + anyway. Used by the one-time backfill migration.""" + if not text: + return text + return _BAKED_S3_IMAGE_RE.sub(lambda m: article_image_path(m.group(1)), text) + + +async def store_article_image(content: bytes, content_type: str) -> str: + """Persist validated image bytes and return the stable URL to bake into the + answer markdown. Caller is responsible for size/content-type validation + against MAX_FAQ_IMAGE_BYTES / FAQ_IMAGE_TYPES. + + The returned path is storage-agnostic and never expires. It must not be a + signed S3 URL (those expire) nor a raw S3 URL (the bucket is private, so + those 403) — the delivery route resolves it per request instead.""" + file_name = f"{uuid4()}{FAQ_IMAGE_TYPES[content_type]}" + await store_upload(content, folder=UPLOAD_FOLDER, file_name=file_name, content_type=content_type) + return article_image_path(file_name) diff --git a/backend/app/services/help_center_public.py b/backend/app/services/help_center_public.py new file mode 100644 index 0000000..46e729f --- /dev/null +++ b/backend/app/services/help_center_public.py @@ -0,0 +1,238 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +Public help-center domain logic: resolve the Host header to an org's +published help center, group its FAQs, and answer "Ask AI" questions grounded +in the mapped agent's knowledge. No admin auth here — everything must stay +read-only against published data. +""" + +import asyncio +from typing import List, Optional, Tuple + +from sqlalchemy.orm import Session + +from app.agents.guardrail_policy import ( + GuardrailContext, + INJECTION_CLAUSE, + visitor_data_block, +) +from app.core.help_center_host import normalize_host, slug_for_host # noqa: F401 — canonical host helpers re-exported for the public app +from app.core.logger import get_logger +from app.utils.guardrail_runtime import Surface, check_inbound +from app.database import SessionLocal +from app.models.faq import FAQ +from app.models.help_center import HelpCenterSettings +from app.repositories.faq import FAQRepository +from app.repositories.help_center import HelpCenterQueryRepository, HelpCenterRepository +from app.services.help_center_access import help_center_allowed + +logger = get_logger(__name__) + +MAX_QUESTION_CHARS = 500 +ANSWER_MAX_TOKENS = 600 +# Unauthenticated LLM calls are expensive: bound how many run at once per +# worker so /ask can't drain the DB pool or the provider budget. +_ask_concurrency = asyncio.Semaphore(4) + +_ASK_INSTRUCTIONS = """You are the support assistant on a company's public help center. A visitor has asked a question. + +You have tools to look up the answer: +- search_faqs: search the published help-center FAQs. +- search_knowledge_base: search the company's wider knowledge base (only available when the help center is mapped to an agent). + +ALWAYS use the tools before answering — start with search_faqs, and also try +search_knowledge_base for anything the FAQs don't cover. You may call a tool more +than once with different wording if the first result isn't relevant. + +Answer ONLY from what the tools return. When they surface a relevant answer, reply +in 1-4 short sentences of plain text (no markdown), in a friendly second-person +voice. If, after genuinely searching, you find nothing relevant, briefly say you +couldn't find that in the help center and suggest starting a chat or contacting +support. Never invent facts, prices, policies, or URLs. + +""" + INJECTION_CLAUSE + + +def resolve_help_center( + db: Session, host: Optional[str] = None, slug: Optional[str] = None +) -> Optional[HelpCenterSettings]: + """Enabled help center row, or None (renders as 404). Resolves by explicit + `slug` (path dispatch) when given, else by Host header (subdomain slug or a + verified custom domain). Custom domains only match once verified; plan + lapses hide the site.""" + repo = HelpCenterRepository(db) + if slug is not None: + row = repo.get_by_slug(slug) + else: + host_slug = slug_for_host(host or "") + if host_slug: + row = repo.get_by_slug(host_slug) + else: + row = repo.get_by_verified_domain(host or "") + if not row or not row.enabled: + return None + if not help_center_allowed(db, row.organization_id): + return None + return row + + +# Topic accent colors, applied by category display order (matches the index and +# article templates). Kept here so list cards, sidebar topics, the article tag +# and related cards all resolve the SAME color for a given category. +CATEGORY_PALETTE = [ + "#6d5bd0", "#0e8c8c", "#cf5b38", "#2a6fdb", "#1f8a5b", "#b0468a", "#c98a1e", "#3a6f8f", +] + + +def published_faq_groups( + db: Session, row: HelpCenterSettings, search: Optional[str] = None +) -> List[Tuple[str, List[FAQ]]]: + """Published FAQs grouped by category, in display order.""" + faqs = FAQRepository(db).get_published_for_org(row.organization_id, search=search) + groups: dict = {} + for faq in faqs: + groups.setdefault(faq.category, []).append(faq) + return list(groups.items()) + + +def category_colors(categories) -> dict: + """category name -> accent hex, by display order.""" + return {c: CATEGORY_PALETTE[i % len(CATEGORY_PALETTE)] for i, c in enumerate(categories)} + + +def get_published_article(db: Session, row: HelpCenterSettings, slug: str) -> Optional[FAQ]: + """A single published FAQ by slug for the /a/{slug} article page.""" + return FAQRepository(db).get_published_by_slug(row.organization_id, slug) + + +def get_published_article_by_path( + db: Session, row: HelpCenterSettings, url_path: str +) -> Optional[FAQ]: + """A single published FAQ by its preserved original path — the lookup for a + URL the org carried over from the help center it migrated from.""" + return FAQRepository(db).get_published_by_url_path(row.organization_id, url_path) + + +def related_articles(db: Session, row: HelpCenterSettings, faq: FAQ, limit: int = 4) -> List[FAQ]: + """Other published FAQs in the same category — the article's related list.""" + return FAQRepository(db).get_published_related( + row.organization_id, faq.category, faq.id, limit=limit + ) + + +def contrast_ink(hex_color: str) -> str: + """Dark or light text over the brand color (WCAG relative luminance).""" + try: + value = hex_color.lstrip("#") + if len(value) == 3: + value = "".join(ch * 2 for ch in value) + r, g, b = (int(value[i:i + 2], 16) / 255 for i in (0, 2, 4)) + + def linear(c: float) -> float: + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + luminance = 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b) + return "#12131A" if luminance > 0.45 else "#FFFFFF" + except (ValueError, IndexError): + return "#FFFFFF" + + +def widget_id_for(row: HelpCenterSettings) -> Optional[str]: + """The chat widget embedded on the page: the mapped agent's widget. Gated by + its own toggle, independent of the AI quick-summary in search.""" + if not (row.chat_widget_enabled and row.agent and row.agent.widgets): + return None + return row.agent.widgets[0].id + + +def ask_available(row: HelpCenterSettings) -> bool: + return bool(row.ai_search_enabled and row.agent_id) + + +async def answer_question(organization_id, agent_id, question: str) -> Optional[str]: + """Grounded answer for a public help-center question. The agent is given + FAQ + knowledge search tools and decides what to look up, so it can find and + combine answers instead of relying on a single pre-stuffed context. Returns + None when no confident answer could be produced. Writes the ask-log row + (metering/analytics); never touches chat history or the KB. + + The whole tool-using run happens in a worker thread: the tools do synchronous + DB work, and holding a pooled connection through a multi-second model call + would let this unauthenticated endpoint drain the pool for the backend. + """ + from app.core.security import decrypt_api_key + from app.repositories.ai_config import AIConfigRepository + + question = question.strip()[:MAX_QUESTION_CHARS] + + # Pre-LLM guardrail on the only unauthenticated LLM entry point: a + # blocked question never reaches the semaphore, the DB pool, or the model. + # None renders as the standard "couldn't find that" fallback. + guardrail_ctx = GuardrailContext( + org_id=str(organization_id), agent_id=str(agent_id) if agent_id else None + ) + if check_inbound(question, ctx=guardrail_ctx, surface=Surface.HELP_CENTER).block: + return None + + async with _ask_concurrency: + with SessionLocal() as db: + config = AIConfigRepository(db).get_active_config(organization_id) + if not config: + return None + model_type = config.model_type.value if hasattr(config.model_type, "value") else str(config.model_type) + model_name = config.model_name + api_key = decrypt_api_key(config.encrypted_api_key) + base_url = (config.settings or {}).get('base_url') + + def _run() -> Optional[str]: + from agno.agent import Agent + + from app.tools.faq_search import FAQSearchTool + from app.tools.knowledge_search_byagent import KnowledgeSearchByAgent + from app.utils.agno_utils import create_model + + tools = [FAQSearchTool(organization_id=organization_id)] + if agent_id: + tools.append(KnowledgeSearchByAgent(agent_id=str(agent_id), org_id=organization_id)) + model = create_model( + model_type=model_type, api_key=api_key, model_name=model_name, max_tokens=ANSWER_MAX_TOKENS, base_url=base_url + ) + agent = Agent( + name="Help Center Answers", + model=model, + tools=tools, + instructions=_ASK_INSTRUCTIONS, + markdown=False, + ) + response = agent.run( + message=visitor_data_block("VISITOR QUESTION", question), + stream=False, + ) + content = getattr(response, "content", None) + return content.strip() if isinstance(content, str) and content.strip() else None + + answer = None + try: + answer = await asyncio.to_thread(_run) + except Exception as e: + logger.error(f"Help center ask failed for org {organization_id}: {e}") + finally: + try: + with SessionLocal() as log_db: + HelpCenterQueryRepository(log_db).log(organization_id, question, answered=bool(answer)) + except Exception as log_err: + logger.error(f"Help center ask log failed: {log_err}") + return answer diff --git a/backend/app/services/help_center_seo.py b/backend/app/services/help_center_seo.py new file mode 100644 index 0000000..122b979 --- /dev/null +++ b/backend/app/services/help_center_seo.py @@ -0,0 +1,267 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +SEO surface of the public help center: the <head> values (title, description, +canonical, Open Graph) and the schema.org JSON-LD graph. Kept out of the route +module so every page derives its metadata by the same rules. + +Two URL spaces are in play and must not be mixed up: + - ABSOLUTE (live_url-based) for canonical, og:*, sitemap and every JSON-LD + @id/url — crawlers and social scrapers need a fetchable address. + - RELATIVE (base_path-prefixed) for links the page actually renders, so the + same markup works on a subdomain, a custom domain and /help/{slug}. +Breadcrumbs carry both, which is how the BreadcrumbList markup is guaranteed to +match the visible trail (a Google requirement). +""" + +from datetime import datetime +from typing import List, Optional, Sequence +from urllib.parse import quote, urlsplit + +from app.models.faq import FAQ +from app.models.help_center import HelpCenterSettings +from app.services.help_center_content import excerpt, to_plain_text +from app.services.help_center_settings import live_url + +HOME_CRUMB = "Home" +# Meta descriptions get truncated by search engines around here; the excerpt +# helper adds an ellipsis rather than cutting mid-word. +META_DESCRIPTION_CHARS = 200 + + +def site_url(row: HelpCenterSettings) -> str: + """The help center's public base URL, never trailing-slashed.""" + return live_url(row).rstrip("/") + + +def asset_origin(site: str) -> str: + """Scheme + host the site's uploads are served from. Same origin as the + site in every mode — but NOT the same prefix in path mode, where the site + is {backend}/help/{slug} while uploads stay at {backend}/api/v1/uploads/…""" + parts = urlsplit(site) + return f"{parts.scheme}://{parts.netloc}" + + +def absolute_asset_url(stored_url: Optional[str], site: str) -> Optional[str]: + """A stored logo/image URL made absolute for og:image and JSON-LD. S3 URLs + are already absolute and pass through; local uploads are relative and get + anchored to the serving origin.""" + if not stored_url: + return None + if stored_url.startswith("http://") or stored_url.startswith("https://"): + return stored_url + return f"{asset_origin(site)}{stored_url}" + + +def _isoformat(value: Optional[datetime]) -> Optional[str]: + return value.isoformat() if value else None + + +def article_title(row: HelpCenterSettings, faq: FAQ) -> str: + """The article's <title>: the org's override, else question + site name.""" + return faq.meta_title or f"{faq.question} · {row.organization.name} Help Center" + + +def article_description(faq: FAQ) -> str: + """The article's meta description: the org's override, else an excerpt of + the answer (falling back to the question for answers with no prose).""" + return faq.meta_description or excerpt(faq.answer, META_DESCRIPTION_CHARS) or faq.question + + +def index_title(row: HelpCenterSettings) -> str: + return row.title or f"{row.organization.name} Help Center" + + +def index_description(row: HelpCenterSettings) -> str: + return row.description or f"Answers to common questions about {row.organization.name}." + + +def category_url(site: str, category: str) -> str: + """Absolute URL of the index filtered to one category — the middle + breadcrumb. Mirrors the ?topic= link the templates render.""" + return f"{site}/?topic={quote(category)}" + + +def article_path(faq: FAQ) -> str: + """The root-relative path an article is served at, percent-DECODED. + + An article migrated in with "preserve original URLs" keeps the path it had + on the old help center; everything else lives at /a/{slug}. Exactly one of + the two is canonical — /a/{slug} 301s to the preserved path when there is + one — so this is the single answer to "where does this article live?" and + every URL the site emits derives from it.""" + return faq.url_path or f"/a/{faq.slug}" + + +def article_href(faq: FAQ, base_path: str = "") -> str: + """What a page renders in href=: the serving prefix plus the ENCODED path.""" + return f"{base_path}{quote(article_path(faq), safe='/')}" + + +def article_url(site: str, faq: FAQ) -> str: + """The article's ABSOLUTE URL, for canonical/og:url/sitemap/JSON-LD.""" + return f"{site}{quote(article_path(faq), safe='/')}" + + +def breadcrumbs( + row: HelpCenterSettings, faq: FAQ, base_path: str, site: str +) -> List[dict]: + """Home › Category › Article. Each crumb carries `href` (what the page + links to) and `url` (what BreadcrumbList advertises); the last crumb is the + current page and is rendered as text, so it has no href.""" + return [ + {"label": HOME_CRUMB, "href": f"{base_path}/", "url": f"{site}/"}, + { + "label": faq.category, + "href": f"{base_path}/?topic={quote(faq.category)}", + "url": category_url(site, faq.category), + }, + {"label": faq.question, "href": None, "url": article_url(site, faq)}, + ] + + +def _organization_node(row: HelpCenterSettings, site: str, logo_url: Optional[str]) -> dict: + node = { + "@type": "Organization", + "@id": f"{site}/#organization", + "name": row.organization.name, + "url": f"{site}/", + } + if logo_url: + node["logo"] = {"@type": "ImageObject", "url": logo_url} + return node + + +def _website_node(row: HelpCenterSettings, site: str) -> dict: + return { + "@type": "WebSite", + "@id": f"{site}/#website", + "url": f"{site}/", + "name": index_title(row), + "publisher": {"@id": f"{site}/#organization"}, + } + + +def _question_entity(faq: FAQ) -> dict: + """One schema.org Question + acceptedAnswer, from the plain-text form of the + (Markdown) answer.""" + return { + "@type": "Question", + "name": faq.question, + "acceptedAnswer": {"@type": "Answer", "text": to_plain_text(faq.answer)}, + } + + +def _breadcrumb_node(crumbs: Sequence[dict], page_url: str) -> dict: + return { + "@type": "BreadcrumbList", + "@id": f"{page_url}#breadcrumb", + "itemListElement": [ + {"@type": "ListItem", "position": i, "name": crumb["label"], "item": crumb["url"]} + for i, crumb in enumerate(crumbs, start=1) + ], + } + + +def index_json_ld( + row: HelpCenterSettings, faqs: Sequence[FAQ], site: str, logo_url: Optional[str] +) -> dict: + """The landing page graph: Organization + WebSite + a CollectionPage that is + also the FAQPage carrying every published Q&A. + + FAQPage - not QAPage - is the correct type for these owner-authored answers. + QAPage models community/forum pages where users submit competing answers and + requires answerCount/upvoteCount, which don't apply here. Google retired FAQ + rich results, but the markup still describes the page for other consumers. + """ + page_url = f"{site}/" + page: dict = { + "@type": ["CollectionPage", "FAQPage"], + "@id": f"{page_url}#webpage", + "url": page_url, + "name": index_title(row), + "description": index_description(row), + "isPartOf": {"@id": f"{site}/#website"}, + "about": {"@id": f"{site}/#organization"}, + } + if faqs: + page["mainEntity"] = [_question_entity(faq) for faq in faqs] + return { + "@context": "https://schema.org", + "@graph": [ + _organization_node(row, site, logo_url), + _website_node(row, site), + page, + ], + } + + +def article_json_ld( + row: HelpCenterSettings, + faq: FAQ, + site: str, + crumbs: Sequence[dict], + logo_url: Optional[str], + image_url: Optional[str], +) -> dict: + """The article page graph: Organization + WebSite + WebPage + BreadcrumbList + + TechArticle, wired together by @id. + + TechArticle (not FAQPage) is what a single how-to/help article is: Google + dropped FAQ rich results, while BreadcrumbList still renders in search. + """ + page_url = article_url(site, faq) + description = article_description(faq) + article: dict = { + "@type": "TechArticle", + "@id": f"{page_url}#article", + "headline": faq.question, + "description": description, + "articleSection": faq.category, + "inLanguage": "en", + "mainEntityOfPage": {"@id": f"{page_url}#webpage"}, + "isPartOf": {"@id": f"{page_url}#webpage"}, + "author": {"@id": f"{site}/#organization"}, + "publisher": {"@id": f"{site}/#organization"}, + } + published_at = _isoformat(faq.created_at) + # updated_at stays NULL until the first edit, so fall back to the creation + # date rather than claiming a modification date older than publication. + modified_at = _isoformat(faq.updated_at) or published_at + if published_at: + article["datePublished"] = published_at + if modified_at: + article["dateModified"] = modified_at + if image_url: + article["image"] = image_url + web_page = { + "@type": "WebPage", + "@id": f"{page_url}#webpage", + "url": page_url, + "name": article_title(row, faq), + "description": description, + "isPartOf": {"@id": f"{site}/#website"}, + "breadcrumb": {"@id": f"{page_url}#breadcrumb"}, + } + return { + "@context": "https://schema.org", + "@graph": [ + _organization_node(row, site, logo_url), + _website_node(row, site), + web_page, + _breadcrumb_node(crumbs, page_url), + article, + ], + } diff --git a/backend/app/services/help_center_settings.py b/backend/app/services/help_center_settings.py new file mode 100644 index 0000000..c814136 --- /dev/null +++ b/backend/app/services/help_center_settings.py @@ -0,0 +1,252 @@ +""" +Copyright 2024-2026 ChatterMate + +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}" diff --git a/backend/app/services/jira.py b/backend/app/services/jira.py new file mode 100644 index 0000000..9e9491b --- /dev/null +++ b/backend/app/services/jira.py @@ -0,0 +1,362 @@ +""" +Copyright 2024-2026 ChatterMate + +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 datetime import datetime, timedelta +from typing import Optional, Dict, Any, List +import requests +from urllib.parse import urlencode + +from app.models.jira import JiraToken +from app.core.config import settings +from app.core.exceptions import JiraAuthError +from app.core.external_urls import external_base +from app.core.logger import get_logger +logger = get_logger(__name__) + +# Atlassian hosts. Deployments never set these; the e2e suite points them at its +# recording fake. The authorize URL is browser-facing, the rest are server calls. +JIRA_AUTH_BASE = external_base("JIRA_AUTH_BASE", "https://auth.atlassian.com") +JIRA_API_BASE = external_base("JIRA_API_BASE", "https://api.atlassian.com") + + +def jira_authorize_url() -> str: + return f"{JIRA_AUTH_BASE}/authorize" + + +def jira_token_url() -> str: + return f"{JIRA_AUTH_BASE}/oauth/token" + + +def jira_accessible_resources_url() -> str: + return f"{JIRA_API_BASE}/oauth/token/accessible-resources" + + +def jira_rest_url(cloud_id: str, path: str) -> str: + """Jira Cloud REST v3 URL, e.g. `jira_rest_url(cloud_id, "issue")`.""" + return f"{JIRA_API_BASE}/ex/jira/{cloud_id}/rest/api/3/{path}" + + +class JiraService: + """Service for handling Jira OAuth 2.0 flow and API interactions.""" + + def __init__(self): + self.client_id = settings.JIRA_CLIENT_ID + self.client_secret = settings.JIRA_CLIENT_SECRET + self.redirect_uri = settings.JIRA_REDIRECT_URI + self.auth_url = jira_authorize_url() + self.token_url = jira_token_url() + self.scope = "read:jira-work write:jira-work read:jira-user offline_access" + + def get_authorization_url(self, state: str) -> str: + """Generate the authorization URL for Jira OAuth flow.""" + params = { + "audience": "api.atlassian.com", + "client_id": self.client_id, + "scope": self.scope, + "redirect_uri": self.redirect_uri, + "state": state, + "response_type": "code", + "prompt": "consent" + } + return f"{self.auth_url}?{urlencode(params)}" + + def validate_token(self, token: JiraToken) -> bool: + """Check if the token is valid and not expired.""" + if token is None: + return False + return token.expires_at > datetime.utcnow() + timedelta(minutes=5) + + async def exchange_code_for_token(self, code: str) -> Dict[str, Any]: + """Exchange authorization code for access token.""" + data = { + "grant_type": "authorization_code", + "client_id": self.client_id, + "client_secret": self.client_secret, + "code": code, + "redirect_uri": self.redirect_uri + } + + response = requests.post(self.token_url, data=data) + if response.status_code != 200: + raise JiraAuthError("Failed to exchange code for token") + + token_data = response.json() + return { + "access_token": token_data["access_token"], + "refresh_token": token_data["refresh_token"], + "token_type": token_data["token_type"], + "expires_at": datetime.utcnow() + timedelta(seconds=token_data["expires_in"]) + } + + async def refresh_token(self, refresh_token: str) -> Dict[str, Any]: + """Refresh an expired access token.""" + data = { + "grant_type": "refresh_token", + "client_id": self.client_id, + "client_secret": self.client_secret, + "refresh_token": refresh_token + } + + response = requests.post(self.token_url, data=data) + if response.status_code != 200: + raise JiraAuthError("Failed to refresh token") + + token_data = response.json() + return { + "access_token": token_data["access_token"], + "refresh_token": token_data.get("refresh_token", refresh_token), + "token_type": token_data["token_type"], + "expires_at": datetime.utcnow() + timedelta(seconds=token_data["expires_in"]) + } + + async def get_cloud_id(self, access_token: str) -> Dict[str, str]: + """Get Jira Cloud ID and site URL for the authenticated user.""" + headers = { + "Authorization": f"Bearer {access_token}", + "Accept": "application/json" + } + response = requests.get(jira_accessible_resources_url(), headers=headers) + + if response.status_code != 200: + raise JiraAuthError("Failed to get Jira Cloud ID") + + resources = response.json() + if not resources: + raise JiraAuthError("No Jira Cloud instances found") + + # Use the first available cloud instance + return { + "cloud_id": resources[0]["id"], + "site_url": resources[0]["url"] + } + + async def get_projects(self, access_token: str, cloud_id: str) -> List[Dict[str, Any]]: + """Get all projects from Jira.""" + headers = { + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + "Content-Type": "application/json" + } + + url = jira_rest_url(cloud_id, "project") + response = requests.get(url, headers=headers) + + if response.status_code != 200: + raise JiraAuthError(f"Failed to get Jira projects: {response.text}") + + projects = response.json() + return [ + { + "id": project["id"], + "key": project["key"], + "name": project["name"] + } + for project in projects + ] + + async def get_issue_types(self, access_token: str, cloud_id: str, project_key: str) -> List[Dict[str, Any]]: + """Get all issue types for a project from Jira.""" + headers = { + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + "Content-Type": "application/json" + } + + # First, get the project to find its ID + url = jira_rest_url(cloud_id, f"project/{project_key}") + response = requests.get(url, headers=headers) + + if response.status_code != 200: + raise JiraAuthError(f"Failed to get Jira project: {response.text}") + + project = response.json() + + # Get issue types for the project, filtering out sub-tasks + issue_types = [] + for issue_type in project.get("issueTypes", []): + # Skip sub-tasks as they require a parent issue + if issue_type.get("subtask", False): + logger.info(f"Skipping sub-task issue type: {issue_type['name']} (ID: {issue_type['id']})") + continue + + issue_types.append({ + "id": issue_type["id"], + "name": issue_type["name"], + "description": issue_type.get("description", ""), + "iconUrl": issue_type.get("iconUrl", "") + }) + + return issue_types + + async def create_issue(self, organization, db, issue_data): + """Wrapper method for create_issue that takes organization, db, and issue_data parameters.""" + from app.models.jira import JiraToken + + token = db.query(JiraToken).filter( + JiraToken.organization_id == organization.id + ).first() + + if not token: + raise JiraAuthError("No Jira connection found") + + # Check if token is valid and refresh if needed + is_valid = self.validate_token(token) + if not is_valid: + try: + token_data = await self.refresh_token(token.refresh_token) + + # Update token in database + for key, value in token_data.items(): + setattr(token, key, value) + + db.commit() + except Exception as e: + logger.error(f"Failed to refresh Jira token: {e}") + raise JiraAuthError("Jira token expired and could not be refreshed") + + # Create the issue + result = await self.create_issue_internal( + token.access_token, + token.cloud_id, + issue_data.projectKey, + issue_data.issueTypeId, + issue_data.summary, + issue_data.description, + issue_data.priority + ) + + return result + + async def create_issue_internal(self, access_token: str, cloud_id: str, project_key: str, issue_type_id: str, + summary: str, description: str, priority_id: Optional[str] = None) -> Dict[str, Any]: + """Create a new issue in Jira.""" + headers = { + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + "Content-Type": "application/json" + } + + url = jira_rest_url(cloud_id, "issue") + + data = { + "fields": { + "project": { + "key": project_key + }, + "issuetype": { + "id": issue_type_id + }, + "summary": summary, + "description": { + "type": "doc", + "version": 1, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": description + } + ] + } + ] + } + } + } + + # Add priority if provided and available + if priority_id: + # Check if priority field is available for this project/issue type + priority_available = await self.is_priority_available(access_token, cloud_id, project_key, issue_type_id) + if priority_available: + data["fields"]["priority"] = { + "id": priority_id + } + else: + logger.warning(f"Priority field not available for project {project_key} and issue type {issue_type_id}. Skipping priority.") + + response = requests.post(url, headers=headers, json=data) + + if response.status_code not in (200, 201): + raise JiraAuthError(f"Failed to create Jira issue: {response.text}") + + return response.json() + + async def get_priorities(self, access_token: str, cloud_id: str) -> List[Dict[str, Any]]: + """Get all priorities from Jira.""" + headers = { + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + "Content-Type": "application/json" + } + + url = jira_rest_url(cloud_id, "priority") + response = requests.get(url, headers=headers) + + if response.status_code != 200: + raise JiraAuthError(f"Failed to get Jira priorities: {response.text}") + + priorities = response.json() + return [ + { + "id": priority["id"], + "name": priority["name"], + "description": priority.get("description", ""), + "iconUrl": priority.get("iconUrl", "") + } + for priority in priorities + ] + + async def check_field_availability(self, access_token: str, cloud_id: str, project_key: str, issue_type_id: str, field_name: str) -> bool: + """Check if a field is available for a project/issue type combination.""" + headers = { + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + "Content-Type": "application/json" + } + + # Get the create metadata for the project/issue type + url = jira_rest_url(cloud_id, f"issue/createmeta?projectKeys={project_key}&issuetypeIds={issue_type_id}&expand=projects.issuetypes.fields") + response = requests.get(url, headers=headers) + + if response.status_code != 200: + logger.error(f"Failed to get create metadata: {response.text}") + return False + + metadata = response.json() + + # Navigate through the response structure to find the field + try: + projects = metadata.get("projects", []) + if not projects: + return False + + issue_types = projects[0].get("issuetypes", []) + if not issue_types: + return False + + fields = issue_types[0].get("fields", {}) + + # Check if the field exists + return field_name in fields + except Exception as e: + logger.error(f"Error checking field availability: {e}") + return False + + async def is_priority_available(self, access_token: str, cloud_id: str, project_key: str, issue_type_id: str) -> bool: + """Check if the priority field is available for a project/issue type combination.""" + return await self.check_field_availability(access_token, cloud_id, project_key, issue_type_id, "priority") \ No newline at end of file diff --git a/backend/app/services/meta_own_app.py b/backend/app/services/meta_own_app.py new file mode 100644 index 0000000..949fa97 --- /dev/null +++ b/backend/app/services/meta_own_app.py @@ -0,0 +1,189 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +WhatsApp numbers and Messenger Pages that live in the customer's own Meta +app (connected with manual credentials) rather than in ChatterMate's. + +Meta signs their webhooks with the customer's app secret, which the shared +/webhooks/meta endpoint can't check. So each such account gets its own +webhook address, checked with its app's secret only. That address takes +events for the account and for the same organization's other accounts from +the same app (one Meta app has one Messenger callback, and possibly several +numbers), never for anyone else's: a payload signed with a secret someone +chose themselves can't reach another organization. + +A WhatsApp number is pointed at its address with a per-number callback +override; Messenger has no override, so the customer pastes the address and +verify token into their app. +""" + +import hashlib +import hmac +from dataclasses import dataclass +from typing import Callable, Optional + +from sqlalchemy.orm import Session + +from app.channels.meta_base import graph_detail, graph_get, graph_post_json +from app.core.config import settings, webhooks_base_url +from app.core.logger import get_logger +from app.models.channels import ChannelAccount, ChannelType +from app.core.security import decrypt_credentials +from app.repositories.channels.accounts import ChannelAccountRepository + +logger = get_logger(__name__) + +# In the account's encrypted credentials +APP_SECRET_KEY = "app_secret" +# In its (non-secret) settings: which app it's from, and whether Meta +# accepted our per-number callback for it +APP_ID_SETTING = "meta_app_id" +AUTO_WEBHOOK_SETTING = "webhook_auto_configured" +OWN_APP_CHANNELS = (ChannelType.WHATSAPP.value, ChannelType.MESSENGER.value) + +# Meta's answer to an appsecret_proof made with another app's secret +_PROOF_ERROR = "appsecret_proof" + + +class AppSecretMismatch(Exception): + """The secret isn't the one of the app that issued the token.""" + + +class AppCheckUnavailable(Exception): + """Meta couldn't be asked (network, rate limit, an expired token…).""" + + +@dataclass +class OwnApp: + app_id: str + secret: str + + +def is_own_app(account: Optional[ChannelAccount]) -> bool: + return (account is not None and account.channel_type in OWN_APP_CHANNELS + and bool((account.settings or {}).get(APP_ID_SETTING))) + + +def own_app_secret(account: ChannelAccount) -> Optional[str]: + return decrypt_credentials(account.encrypted_credentials).get(APP_SECRET_KEY) if is_own_app(account) else None + + +def own_app_token(account: Optional[ChannelAccount]) -> Optional[str]: + """The customer's token, read before a reconnect replaces it: only it can + take back a callback override made with it.""" + return decrypt_credentials(account.encrypted_credentials).get("access_token") if is_own_app(account) else None + + +def own_app_webhook_url(account: ChannelAccount) -> str: + return f"{webhooks_base_url()}/meta/{account.id}" + + +async def resolve_own_app(access_token: str, entered_secret: Optional[str], + previous: Optional[ChannelAccount]) -> Optional[OwnApp]: + """The customer's own app behind this token, proven with its secret — + the one entered, or else the one kept from the last connect (checked + again: the new token may be from another app). None without a secret, or + for ChatterMate's own app, whose webhooks the shared address already gets. + + Raises AppSecretMismatch or AppCheckUnavailable.""" + secret = entered_secret or (own_app_secret(previous) if previous is not None else None) + if not secret: + return None + proof = hmac.new(secret.encode(), access_token.encode(), hashlib.sha256).hexdigest() + # /app (the token's own app) needs no permission on the Page or number + ok, data = await graph_get("app", access_token, params={"fields": "id", "appsecret_proof": proof}) + if not ok: + error = data.get("error") if isinstance(data.get("error"), dict) else {} + if _PROOF_ERROR in str(error.get("message", "")): + raise AppSecretMismatch() + # Meta's own errors carry a code; anything else is ours (a timeout, + # a socket error) and stays in the log + logger.warning(f"Could not check an app secret with Meta: {error.get('message')}") + raise AppCheckUnavailable(graph_detail(data, "") if "code" in error else "Meta did not answer") + app_id = str(data.get("id") or "") + if not app_id or app_id == settings.META_APP_ID: + return None + return OwnApp(app_id=app_id, secret=secret) + + +async def sync_own_app(db: Session, account: ChannelAccount, own: Optional[OwnApp], + access_token: str, previous_token: Optional[str]) -> None: + """After a connect: route the account's webhooks to its own address when + it's from the customer's app, or hand a number that used to be back.""" + repo = ChannelAccountRepository(db) + is_whatsapp = account.channel_type == ChannelType.WHATSAPP.value + if own is not None: + # Moved to another app: the old app's override is still the old app's + if is_whatsapp and previous_token and (account.settings or {}).get(APP_ID_SETTING) != own.app_id: + await _set_number_callback(account, previous_token, "", None) + # Saved first: Meta checks the address during the callback call, and + # the address only answers for an account marked as from its own app + repo.update_settings(account, {APP_ID_SETTING: own.app_id}) + if is_whatsapp: + pointed = await _set_number_callback( + account, access_token, own_app_webhook_url(account), account.webhook_secret) + repo.update_settings(account, {AUTO_WEBHOOK_SETTING: pointed}) + return + if previous_token is not None: + if is_whatsapp: + await _set_number_callback(account, previous_token, "", None) + repo.update_settings(account, {}, remove=(APP_ID_SETTING, AUTO_WEBHOOK_SETTING)) + + +async def leave_own_app(account: ChannelAccount) -> None: + """Before a disconnect: send the number's webhooks back to the customer's + app. Best effort — a disconnect must never fail over it.""" + if account.channel_type != ChannelType.WHATSAPP.value: + return + try: + token = own_app_token(account) + except Exception: + logger.exception(f"Could not read {account.id}'s credentials to release its callback") + return + if token: + await _set_number_callback(account, token, "", None) + + +def same_app_accounts(db: Session, account: ChannelAccount) -> Callable[[str], Optional[ChannelAccount]]: + """Resolve an external id named in a webhook to one of this organization's + accounts from the same app (and channel), or to nothing.""" + repo = ChannelAccountRepository(db) + app_id = account.settings[APP_ID_SETTING] + + def resolve(external_id: str) -> Optional[ChannelAccount]: + if external_id == account.external_account_id: + return account + other = repo.get_by_external_id(account.channel_type, external_id) + if (other is not None and other.organization_id == account.organization_id + and (other.settings or {}).get(APP_ID_SETTING) == app_id): + return other + return None + return resolve + + +async def _set_number_callback(account: ChannelAccount, access_token: str, url: str, + verify_token: Optional[str]) -> bool: + """Point a WhatsApp number's webhooks at `url` ("" sends them back to the + app's own callback). Meta checks the address during the call.""" + configuration = {"override_callback_uri": url} + if verify_token: + configuration["verify_token"] = verify_token + ok, data = await graph_post_json(account.external_account_id, access_token, + {"webhook_configuration": configuration}) + if not ok: + action = "point" if url else "release" + logger.warning(f"Could not {action} the webhook callback of {account.external_account_id}: " + f"{graph_detail(data, 'no reason given')}") + return ok diff --git a/backend/app/services/notifications.py b/backend/app/services/notifications.py new file mode 100644 index 0000000..b29044b --- /dev/null +++ b/backend/app/services/notifications.py @@ -0,0 +1,114 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import enum +from typing import Iterable, Optional + +from sqlalchemy.orm import Session + +from app.core.logger import get_logger +from app.models.notification import Notification, NotificationType +from app.models.notification_settings import ( + NOTIFY_CHAT_ASSIGNED, + NOTIFY_CHAT_TRANSFER, + NOTIFY_NEW_CHAT, +) +from app.repositories.notification_settings import UserNotificationSettingsRepository +from app.services.user import send_fcm_notification + +logger = get_logger(__name__) + + +class ChatNotificationEvent(str, enum.Enum): + """Chat events a user can mute. The value is both the preference column + and the `event` discriminator carried in the push metadata. + """ + NEW_CHAT = NOTIFY_NEW_CHAT + CHAT_TRANSFER = NOTIFY_CHAT_TRANSFER + CHAT_ASSIGNED = NOTIFY_CHAT_ASSIGNED + + +async def notify_user( + db: Session, + user_id, + type_: NotificationType, + title: str, + message: str, + metadata: Optional[dict] = None, +) -> None: + """Persist an in-app notification and send its FCM push. + + Never raises and no-ops without a user (background jobs may have none) — + a notification failure must not fail the work it reports on. + + NOTE: this commits (and, on error, rolls back) the caller's `db` session, + so call it only once any work you want persisted is already committed. + Every current caller either commits first or has nothing pending. + """ + if not user_id: + return + try: + notification = Notification( + user_id=user_id, + type=type_, + title=title, + message=message, + notification_metadata=metadata, + ) + db.add(notification) + db.commit() + await send_fcm_notification(user_id, notification, db) + except Exception as e: + logger.error(f"Error sending notification '{title}' to user {user_id}: {e}") + try: + db.rollback() + except Exception: + pass + + +async def notify_chat_event( + db: Session, + user_ids: Iterable, + event: ChatNotificationEvent, + title: str, + message: str, + metadata: Optional[dict] = None, +) -> None: + """Notify every user in `user_ids` who hasn't muted `event`. + + Recipients are resolved in a single query, so fanning out to a whole group + or org costs one lookup rather than one per user. Like notify_user, this + never raises — a notification must not fail the chat it reports on. + """ + try: + recipients = UserNotificationSettingsRepository(db).filter_enabled( + user_ids, event.value + ) + if not recipients: + return + + payload = {**(metadata or {}), "event": event.value} + for user_id in recipients: + await notify_user( + db=db, + user_id=user_id, + type_=NotificationType.CHAT, + title=title, + message=message, + metadata=payload, + ) + except Exception as e: + logger.error(f"Error sending '{event.value}' notification: {e}") diff --git a/backend/app/services/shopify.py b/backend/app/services/shopify.py new file mode 100644 index 0000000..ab57026 --- /dev/null +++ b/backend/app/services/shopify.py @@ -0,0 +1,1321 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import requests +from typing import Dict, Any, List, Optional +from app.core.logger import get_logger +from app.models.shopify import ShopifyShop +from sqlalchemy.orm import Session +from app.repositories.shopify_shop_repository import ShopifyShopRepository +from app.repositories.agent_shopify_config_repository import AgentShopifyConfigRepository +import json +import re # Import regex module +import aiohttp +from app.services.shopify_urls import shopify_admin_url + +logger = get_logger(__name__) + +class ShopifyService: + """Service for interacting with the Shopify API.""" + + def __init__(self, db: Session): + self.db = db + self.shopify_shop_repository = ShopifyShopRepository(db) + self.agent_shopify_config_repository = AgentShopifyConfigRepository(db) + + def _execute_graphql(self, shop: ShopifyShop, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """ + Execute a GraphQL query against the Shopify Admin API. + + Args: + shop: The ShopifyShop model containing credentials + query: The GraphQL query string + variables: Optional variables for the GraphQL query + + Returns: + Dict containing the GraphQL response or error information + """ + try: + if not shop or not shop.access_token or not shop.is_installed: + return { + "success": False, + "message": "Shop not connected or missing access token" + } + + url = shopify_admin_url(shop.shop_domain, "graphql.json") + headers = { + "X-Shopify-Access-Token": shop.access_token, + "Content-Type": "application/json" + } + + payload = { + "query": query + } + + if variables: + payload["variables"] = variables + + response = requests.post(url, headers=headers, json=payload) + + if response.status_code != 200: + logger.error(f"Failed to execute GraphQL query: {response.text}") + return { + "success": False, + "message": f"Failed to execute GraphQL query: {response.text}" + } + + result = response.json() + + # Check for GraphQL errors + if "errors" in result: + logger.error(f"GraphQL errors: {result['errors']}") + return { + "success": False, + "errors": result["errors"], + "message": result["errors"][0]["message"] if result["errors"] else "Unknown GraphQL error" + } + + return { + "success": True, + "data": result.get("data", {}), + "extensions": result.get("extensions", {}) + } + + except Exception as e: + logger.error(f"Error executing GraphQL query: {str(e)}") + return { + "success": False, + "message": f"Error executing GraphQL query: {str(e)}" + } + + async def _execute_graphql_async(self, shop: ShopifyShop, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """ + Execute a GraphQL query against the Shopify Admin API asynchronously. + + Args: + shop: The ShopifyShop model containing credentials + query: The GraphQL query string + variables: Optional variables for the GraphQL query + + Returns: + Dict containing the GraphQL response or error information + """ + try: + if not shop or not shop.access_token or not shop.is_installed: + return { + "success": False, + "message": "Shop not connected or missing access token" + } + + url = shopify_admin_url(shop.shop_domain, "graphql.json") + headers = { + "X-Shopify-Access-Token": shop.access_token, + "Content-Type": "application/json" + } + + payload = { + "query": query + } + + if variables: + payload["variables"] = variables + + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, json=payload) as response: + if response.status != 200: + error_text = await response.text() + logger.error(f"Failed to execute GraphQL query: {error_text}") + return { + "success": False, + "message": f"Failed to execute GraphQL query: {error_text}" + } + + result = await response.json() + + # Check for GraphQL errors + if "errors" in result: + logger.error(f"GraphQL errors: {result['errors']}") + return { + "success": False, + "errors": result["errors"], + "message": result["errors"][0]["message"] if result["errors"] else "Unknown GraphQL error" + } + + return { + "success": True, + "data": result.get("data", {}), + "extensions": result.get("extensions", {}) + } + + except Exception as e: + logger.error(f"Error executing GraphQL query: {str(e)}") + return { + "success": False, + "message": f"Error executing GraphQL query: {str(e)}" + } + + def get_products(self, shop: ShopifyShop, limit: int = 10) -> Dict[str, Any]: + """ + Get products from a Shopify store using GraphQL. + + Args: + shop: The ShopifyShop model containing credentials + limit: Maximum number of products to return + + Returns: + Dict containing products or error information + """ + query = """ + query GetProducts($limit: Int!) { + products(first: $limit, query: "status:active AND inventory_total:>0") { + edges { + node { + id + title + description + handle + productType + vendor + totalInventory + priceRangeV2 { + minVariantPrice { + amount + currencyCode + } + maxVariantPrice { + amount + currencyCode + } + } + images(first: 1) { + edges { + node { + id + url + altText + } + } + } + tags + createdAt + updatedAt + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + """ + + variables = { + "limit": limit + } + + result = self._execute_graphql(shop, query, variables) + + if not result.get("success", False): + return result + + # Transform the GraphQL data structure to match the existing format + transformed_products = [] + edges = result.get("data", {}).get("products", {}).get("edges", []) + + for edge in edges: + node = edge.get("node", {}) + + # Extract the first image URL if available + image_url = None + images = node.get("images", {}).get("edges", []) + if images and len(images) > 0: + image_url = images[0].get("node", {}).get("url") + + # Extract price information + price_range = node.get("priceRangeV2", {}) + min_price = price_range.get("minVariantPrice", {}).get("amount") + max_price = price_range.get("maxVariantPrice", {}).get("amount") + currency_code = price_range.get("minVariantPrice", {}).get("currencyCode") + + # Transform to previous structure + transformed_product = { + "id": node.get("id").split("/")[-1] if node.get("id") else None, # Extract numeric ID from gid + "title": node.get("title"), + "description": node.get("description"), + "handle": node.get("handle"), + "product_type": node.get("productType"), + "vendor": node.get("vendor"), + "total_inventory": node.get("totalInventory"), + "price": min_price, + "price_max": max_price, + "currency": currency_code, + "image": {"src": image_url} if image_url else None, + "tags": node.get("tags"), + "created_at": node.get("createdAt"), + "updated_at": node.get("updatedAt") + } + + transformed_products.append(transformed_product) + + return { + "success": True, + "products": transformed_products, + "count": len(transformed_products), + "has_next_page": result.get("data", {}).get("products", {}).get("pageInfo", {}).get("hasNextPage", False), + "end_cursor": result.get("data", {}).get("products", {}).get("pageInfo", {}).get("endCursor") + } + + def get_product(self, shop: ShopifyShop, product_id: str) -> Dict[str, Any]: + """ + Get a specific product from a Shopify store using GraphQL. + + Args: + shop: The ShopifyShop model containing credentials + product_id: The ID of the product to retrieve + + Returns: + Dict containing the product details or error information + """ + # In GraphQL, we need the full global ID which is formatted as gid://shopify/Product/{id} + gid = f"gid://shopify/Product/{product_id}" + + query = """ + query GetProduct($id: ID!) { + product(id: $id) { + id + title + description + descriptionHtml + handle + productType + vendor + totalInventory + priceRangeV2 { + minVariantPrice { + amount + currencyCode + } + maxVariantPrice { + amount + currencyCode + } + } + images(first: 10) { + edges { + node { + id + url + altText + width + height + } + } + } + variants(first: 20) { + edges { + node { + id + title + price + compareAtPrice + inventoryQuantity + sku + barcode + } + } + } + tags + collections(first: 5) { + edges { + node { + id + title + } + } + } + createdAt + updatedAt + } + } + """ + + variables = { + "id": gid + } + + result = self._execute_graphql(shop, query, variables) + + if not result.get("success", False): + return result + + product_data = result.get("data", {}).get("product") + + if not product_data: + return { + "success": False, + "message": f"Product with ID {product_id} not found" + } + + # Transform GraphQL data to match the expected format + + # Process images + images = [] + for edge in product_data.get("images", {}).get("edges", []): + node = edge.get("node", {}) + images.append({ + "id": node.get("id").split("/")[-1] if node.get("id") else None, + "src": node.get("url"), + "alt": node.get("altText"), + "width": node.get("width"), + "height": node.get("height") + }) + + # Process variants + variants = [] + for edge in product_data.get("variants", {}).get("edges", []): + node = edge.get("node", {}) + variants.append({ + "id": node.get("id").split("/")[-1] if node.get("id") else None, + "title": node.get("title"), + "price": node.get("price"), + "compare_at_price": node.get("compareAtPrice"), + "inventory_quantity": node.get("inventoryQuantity"), + "sku": node.get("sku"), + "barcode": node.get("barcode") + }) + + # Process collections + collections = [] + for edge in product_data.get("collections", {}).get("edges", []): + node = edge.get("node", {}) + collections.append({ + "id": node.get("id").split("/")[-1] if node.get("id") else None, + "title": node.get("title") + }) + + # Extract price information + price_range = product_data.get("priceRangeV2", {}) + min_price = price_range.get("minVariantPrice", {}).get("amount") + max_price = price_range.get("maxVariantPrice", {}).get("amount") + currency_code = price_range.get("minVariantPrice", {}).get("currencyCode") + + # Build transformed product + transformed_product = { + "id": product_data.get("id").split("/")[-1] if product_data.get("id") else None, + "title": product_data.get("title"), + "description": product_data.get("description"), + "body_html": product_data.get("descriptionHtml"), + "handle": product_data.get("handle"), + "product_type": product_data.get("productType"), + "vendor": product_data.get("vendor"), + "total_inventory": product_data.get("totalInventory"), + "price": min_price, + "price_max": max_price, + "currency": currency_code, + "images": images, + "image": images[0] if images else None, + "variants": variants, + "tags": product_data.get("tags"), + "collections": collections, + "created_at": product_data.get("createdAt"), + "updated_at": product_data.get("updatedAt") + } + + return { + "success": True, + "product": transformed_product + } + + def create_product(self, shop: ShopifyShop, product_data: Dict[str, Any]) -> Dict[str, Any]: + """ + Create a new product in a Shopify store. + + Args: + shop: The ShopifyShop model containing credentials + product_data: Dictionary containing the product details + + Returns: + Dict containing the created product or error information + """ + try: + if not shop or not shop.access_token or not shop.is_installed: + return { + "success": False, + "message": "Shop not connected or missing access token" + } + + url = shopify_admin_url(shop.shop_domain, "products.json") + headers = { + "X-Shopify-Access-Token": shop.access_token, + "Content-Type": "application/json" + } + + # Ensure product_data has the right structure + payload = {"product": product_data} + + response = requests.post(url, headers=headers, json=payload) + + if response.status_code not in (200, 201): + logger.error(f"Failed to create product: {response.text}") + return { + "success": False, + "message": f"Failed to create product: {response.text}" + } + + product = response.json().get("product", {}) + + return { + "success": True, + "product": product, + "message": "Product created successfully" + } + + except Exception as e: + logger.error(f"Error creating product: {str(e)}") + return { + "success": False, + "message": f"Error creating product: {str(e)}" + } + + def update_product(self, shop: ShopifyShop, product_id: str, product_data: Dict[str, Any]) -> Dict[str, Any]: + """ + Update an existing product in a Shopify store. + + Args: + shop: The ShopifyShop model containing credentials + product_id: The ID of the product to update + product_data: Dictionary containing the updated product details + + Returns: + Dict containing the updated product or error information + """ + try: + if not shop or not shop.access_token or not shop.is_installed: + return { + "success": False, + "message": "Shop not connected or missing access token" + } + + url = shopify_admin_url(shop.shop_domain, f"products/{product_id}.json") + headers = { + "X-Shopify-Access-Token": shop.access_token, + "Content-Type": "application/json" + } + + # Ensure product_data has the right structure + payload = {"product": product_data} + + response = requests.put(url, headers=headers, json=payload) + + if response.status_code != 200: + logger.error(f"Failed to update product: {response.text}") + return { + "success": False, + "message": f"Failed to update product: {response.text}" + } + + product = response.json().get("product", {}) + + return { + "success": True, + "product": product, + "message": "Product updated successfully" + } + + except Exception as e: + logger.error(f"Error updating product: {str(e)}") + return { + "success": False, + "message": f"Error updating product: {str(e)}" + } + + def delete_product(self, shop: ShopifyShop, product_id: str) -> Dict[str, Any]: + """ + Delete a product from a Shopify store. + + Args: + shop: The ShopifyShop model containing credentials + product_id: The ID of the product to delete + + Returns: + Dict containing success status or error information + """ + try: + if not shop or not shop.access_token or not shop.is_installed: + return { + "success": False, + "message": "Shop not connected or missing access token" + } + + url = shopify_admin_url(shop.shop_domain, f"products/{product_id}.json") + headers = { + "X-Shopify-Access-Token": shop.access_token, + "Content-Type": "application/json" + } + + response = requests.delete(url, headers=headers) + + if response.status_code not in (200, 204): + logger.error(f"Failed to delete product: {response.text}") + return { + "success": False, + "message": f"Failed to delete product: {response.text}" + } + + return { + "success": True, + "message": "Product deleted successfully" + } + + except Exception as e: + logger.error(f"Error deleting product: {str(e)}") + return { + "success": False, + "message": f"Error deleting product: {str(e)}" + } + + def search_products(self, shop: ShopifyShop, query: str, limit: int = 10) -> Dict[str, Any]: + """ + Search for products in a Shopify store using GraphQL. + + Args: + shop: The ShopifyShop model containing credentials + query: The search query + limit: Maximum number of products to return + + Returns: + Dict containing matching products or error information + """ + graphql_query = """ + query SearchProducts($query: String!, $limit: Int!) { + products(first: $limit, query: $query) { + edges { + node { + id + title + description + handle + productType + vendor + totalInventory + priceRangeV2 { + minVariantPrice { + amount + currencyCode + } + maxVariantPrice { + amount + currencyCode + } + } + images(first: 1) { + edges { + node { + id + url + altText + } + } + } + tags + createdAt + updatedAt + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + """ + + # Add status:active filter to only return active products (exclude archived and draft) + filtered_query = f"({query}) AND status:active" + + variables = { + "query": filtered_query, + "limit": limit + } + + result = self._execute_graphql(shop, graphql_query, variables) + + if not result.get("success", False): + return result + + # Transform the GraphQL data structure to match the existing format + transformed_products = [] + edges = result.get("data", {}).get("products", {}).get("edges", []) + + for edge in edges: + node = edge.get("node", {}) + + # Extract the first image URL if available + image_url = None + images = node.get("images", {}).get("edges", []) + if images and len(images) > 0: + image_url = images[0].get("node", {}).get("url") + + # Extract price information + price_range = node.get("priceRangeV2", {}) + min_price = price_range.get("minVariantPrice", {}).get("amount") + max_price = price_range.get("maxVariantPrice", {}).get("amount") + currency_code = price_range.get("minVariantPrice", {}).get("currencyCode") + + # Transform to previous structure + transformed_product = { + "id": node.get("id").split("/")[-1] if node.get("id") else None, # Extract numeric ID from gid + "title": node.get("title"), + "description": node.get("description"), + "handle": node.get("handle"), + "product_type": node.get("productType"), + "vendor": node.get("vendor"), + "total_inventory": node.get("totalInventory"), + "price": min_price, + "price_max": max_price, + "currency": currency_code, + "image": {"src": image_url} if image_url else None, + "tags": node.get("tags"), + "created_at": node.get("createdAt"), + "updated_at": node.get("updatedAt") + } + + transformed_products.append(transformed_product) + + return { + "success": True, + "products": transformed_products, + "count": len(transformed_products), + "has_next_page": result.get("data", {}).get("products", {}).get("pageInfo", {}).get("hasNextPage", False), + "end_cursor": result.get("data", {}).get("products", {}).get("pageInfo", {}).get("endCursor") + } + + def search_orders(self, shop: ShopifyShop, params: Dict[str, Any], limit: int = 10) -> Dict[str, Any]: + """ + Search for orders in a Shopify store using GraphQL. + + Args: + shop: The ShopifyShop model containing credentials + params: Dictionary containing search parameters + limit: Maximum number of orders to return + + Returns: + Dict containing matching orders or error information + """ + # Construct the query string based on the parameters + query_parts = [] + + if params.get("query"): + query_parts.append(params.get("query")) + + if params.get("email"): + query_parts.append(f"email:{params.get('email')}") + + if params.get("name"): + query_parts.append(f"name:{params.get('name')}") + + # Combine query parts or use a default query + query_string = " ".join(query_parts) if query_parts else "" + + graphql_query = """ + query SearchOrders($query: String!, $limit: Int!) { + orders(first: $limit, query: $query) { + edges { + node { + id + name + email + processedAt + createdAt + updatedAt + cancelledAt + cancelReason + displayFinancialStatus + displayFulfillmentStatus + customerJourneySummary { + customerOrderIndex + daysToConversion + } + customer { + id + firstName + lastName + email + phone + } + currentTotalPriceSet { + shopMoney { + amount + currencyCode + } + } + originalTotalPriceSet { + shopMoney { + amount + currencyCode + } + } + shippingAddress { + address1 + address2 + city + country + zip + phone + name + } + fulfillments { + status + trackingInfo { + number + url + } + } + lineItems(first: 10) { + edges { + node { + id + name + quantity + originalUnitPriceSet { + shopMoney { + amount + currencyCode + } + } + variant { + id + title + sku + } + } + } + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + """ + + variables = { + "query": query_string, + "limit": limit + } + + result = self._execute_graphql(shop, graphql_query, variables) + + if not result.get("success", False): + return result + + # Transform the GraphQL data structure to match the expected format + transformed_orders = [] + edges = result.get("data", {}).get("orders", {}).get("edges", []) + + for edge in edges: + node = edge.get("node", {}) + + # Process line items + line_items = [] + for li_edge in node.get("lineItems", {}).get("edges", []): + li_node = li_edge.get("node", {}) + variant = li_node.get("variant", {}) + + line_item = { + "id": li_node.get("id").split("/")[-1] if li_node.get("id") else None, + "name": li_node.get("name"), + "quantity": li_node.get("quantity"), + "price": li_node.get("originalUnitPriceSet", {}).get("shopMoney", {}).get("amount"), + "currency": li_node.get("originalUnitPriceSet", {}).get("shopMoney", {}).get("currencyCode"), + "variant_id": variant.get("id").split("/")[-1] if variant.get("id") else None, + "variant_title": variant.get("title"), + "sku": variant.get("sku") + } + + line_items.append(line_item) + + # Process fulfillments + fulfillments = [] + for fulfillment in node.get("fulfillments", []): + tracking_info = [] + + for tracking in fulfillment.get("trackingInfo", []): + tracking_info.append({ + "number": tracking.get("number"), + "url": tracking.get("url") + }) + + fulfillments.append({ + "status": fulfillment.get("status"), + "tracking_numbers": [t.get("number") for t in tracking_info if t.get("number")], + "tracking_urls": [t.get("url") for t in tracking_info if t.get("url")], + "tracking_info": tracking_info + }) + + # Extract customer info + customer = node.get("customer", {}) + customer_data = { + "id": customer.get("id").split("/")[-1] if customer.get("id") else None, + "first_name": customer.get("firstName"), + "last_name": customer.get("lastName"), + "email": customer.get("email"), + "phone": customer.get("phone") + } if customer else None + + # Process shipping address + shipping_address = node.get("shippingAddress", {}) + + # Build transformed order + transformed_order = { + "id": node.get("id").split("/")[-1] if node.get("id") else None, + "name": node.get("name"), + "email": node.get("email"), + "processed_at": node.get("processedAt"), + "created_at": node.get("createdAt"), + "updated_at": node.get("updatedAt"), + "cancelled_at": node.get("cancelledAt"), + "cancel_reason": node.get("cancelReason"), + "financial_status": node.get("displayFinancialStatus"), + "fulfillment_status": node.get("displayFulfillmentStatus"), + "customer": customer_data, + "total_price": node.get("currentTotalPriceSet", {}).get("shopMoney", {}).get("amount"), + "currency": node.get("currentTotalPriceSet", {}).get("shopMoney", {}).get("currencyCode"), + "original_total_price": node.get("originalTotalPriceSet", {}).get("shopMoney", {}).get("amount"), + "shipping_address": shipping_address, + "fulfillments": fulfillments, + "line_items": line_items + } + + transformed_orders.append(transformed_order) + + return { + "success": True, + "orders": transformed_orders, + "count": len(transformed_orders), + "has_next_page": result.get("data", {}).get("orders", {}).get("pageInfo", {}).get("hasNextPage", False), + "end_cursor": result.get("data", {}).get("orders", {}).get("pageInfo", {}).get("endCursor") + } + + def get_order(self, shop: ShopifyShop, order_id: str) -> Dict[str, Any]: + """ + Get a specific order from a Shopify store using GraphQL. + + Args: + shop: The ShopifyShop model containing credentials + order_id: The ID of the order to retrieve + + Returns: + Dict containing the order details or error information + """ + # In GraphQL, we need the full global ID which is formatted as gid://shopify/Order/{id} + gid = f"gid://shopify/Order/{order_id}" + + query = """ + query GetOrder($id: ID!) { + order(id: $id) { + id + name + email + processedAt + createdAt + updatedAt + cancelledAt + cancelReason + displayFinancialStatus + displayFulfillmentStatus + customerJourneySummary { + customerOrderIndex + daysToConversion + } + customer { + id + firstName + lastName + email + phone + } + currentTotalPriceSet { + shopMoney { + amount + currencyCode + } + } + originalTotalPriceSet { + shopMoney { + amount + currencyCode + } + } + shippingAddress { + address1 + address2 + city + country + zip + phone + name + } + fulfillments { + status + trackingInfo { + number + url + } + } + lineItems(first: 50) { + edges { + node { + id + name + quantity + originalUnitPriceSet { + shopMoney { + amount + currencyCode + } + } + variant { + id + title + sku + } + } + } + } + } + } + """ + + variables = { + "id": gid + } + + result = self._execute_graphql(shop, query, variables) + + if not result.get("success", False): + return result + + order_data = result.get("data", {}).get("order") + + if not order_data: + return { + "success": False, + "message": f"Order with ID {order_id} not found" + } + + # Process line items + line_items = [] + for li_edge in order_data.get("lineItems", {}).get("edges", []): + li_node = li_edge.get("node", {}) + variant = li_node.get("variant", {}) + + line_item = { + "id": li_node.get("id").split("/")[-1] if li_node.get("id") else None, + "name": li_node.get("name"), + "quantity": li_node.get("quantity"), + "price": li_node.get("originalUnitPriceSet", {}).get("shopMoney", {}).get("amount"), + "currency": li_node.get("originalUnitPriceSet", {}).get("shopMoney", {}).get("currencyCode"), + "variant_id": variant.get("id").split("/")[-1] if variant.get("id") else None, + "variant_title": variant.get("title"), + "sku": variant.get("sku") + } + + line_items.append(line_item) + + # Process fulfillments + fulfillments = [] + for fulfillment in order_data.get("fulfillments", []): + tracking_info = [] + + for tracking in fulfillment.get("trackingInfo", []): + tracking_info.append({ + "number": tracking.get("number"), + "url": tracking.get("url") + }) + + fulfillments.append({ + "status": fulfillment.get("status"), + "tracking_numbers": [t.get("number") for t in tracking_info if t.get("number")], + "tracking_urls": [t.get("url") for t in tracking_info if t.get("url")], + "tracking_info": tracking_info + }) + + # Extract customer info + customer = order_data.get("customer", {}) + customer_data = { + "id": customer.get("id").split("/")[-1] if customer.get("id") else None, + "first_name": customer.get("firstName"), + "last_name": customer.get("lastName"), + "email": customer.get("email"), + "phone": customer.get("phone") + } if customer else None + + # Build transformed order + transformed_order = { + "id": order_data.get("id").split("/")[-1] if order_data.get("id") else None, + "name": order_data.get("name"), + "email": order_data.get("email"), + "processed_at": order_data.get("processedAt"), + "created_at": order_data.get("createdAt"), + "updated_at": order_data.get("updatedAt"), + "cancelled_at": order_data.get("cancelledAt"), + "cancel_reason": order_data.get("cancelReason"), + "financial_status": order_data.get("displayFinancialStatus"), + "fulfillment_status": order_data.get("displayFulfillmentStatus"), + "customer": customer_data, + "total_price": order_data.get("currentTotalPriceSet", {}).get("shopMoney", {}).get("amount"), + "currency": order_data.get("currentTotalPriceSet", {}).get("shopMoney", {}).get("currencyCode"), + "original_total_price": order_data.get("originalTotalPriceSet", {}).get("shopMoney", {}).get("amount"), + "shipping_address": order_data.get("shippingAddress", {}), + "fulfillments": fulfillments, + "line_items": line_items + } + + return { + "success": True, + "order": transformed_order + } + + def _make_rest_request(self, shop: ShopifyShop, method: str, endpoint: str, payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """ + Make a REST request to the Shopify Admin API. + + Args: + shop: The ShopifyShop model containing credentials. + method: HTTP method (GET, POST, PUT, DELETE). + endpoint: API endpoint path (e.g., 'themes.json', 'themes/{theme_id}/assets.json'). + payload: Optional dictionary for request body (for POST/PUT). + + Returns: + Dict containing the API response or error information. + """ + try: + if not shop or not shop.access_token or not shop.is_installed: + return {"success": False, "message": "Shop not connected or missing access token"} + + url = shopify_admin_url(shop.shop_domain, endpoint) + headers = { + "X-Shopify-Access-Token": shop.access_token, + "Content-Type": "application/json", + "Accept": "application/json" + } + + response = requests.request(method, url, headers=headers, json=payload) + + # Handle rate limits (basic example) + if response.status_code == 429: + retry_after = response.headers.get("Retry-After", 1) + logger.warning(f"Shopify rate limit hit. Retrying after {retry_after} seconds.") + # Consider adding actual retry logic here if needed + return {"success": False, "message": f"Rate limit exceeded. Try again in {retry_after} seconds.", "status_code": 429} + + if not (200 <= response.status_code < 300): + logger.error(f"Shopify REST API error ({method} {url}): {response.status_code} - {response.text}") + return {"success": False, "message": f"API Error: {response.text}", "status_code": response.status_code} + + # DELETE might return 204 No Content + if response.status_code == 204: + return {"success": True, "data": {}} + + return {"success": True, "data": response.json()} + + except requests.exceptions.RequestException as e: + logger.error(f"Error making Shopify REST request: {str(e)}") + return {"success": False, "message": f"Request Exception: {str(e)}"} + except Exception as e: + logger.error(f"Unexpected error during Shopify REST request: {str(e)}", exc_info=True) + return {"success": False, "message": f"Unexpected Error: {str(e)}"} + + async def inject_widget_script(self, shop: ShopifyShop, script_tag: str) -> None: + """ + Creates a Shopify Script Tag to inject the ChatterMate widget using the scriptTagCreate GraphQL mutation. + + Args: + shop: The ShopifyShop object. + script_tag: The full HTML script tag string to inject (not used directly in this implementation). + + Note: + This implementation uses Shopify's scriptTagCreate mutation instead of modifying theme files. + It extracts the script URL from the provided script_tag string and creates a proper Shopify Script Tag. + """ + logger.info(f"Starting widget script injection for shop: {shop.shop_domain}") + + # 1. Extract the script URL from the script_tag string + # The script_tag is in format: + # <script>/* ChatterMate Start */window.chattermateId='widget_id';</script><script src="script_url" async defer></script> + # We need to extract the script_url + + import re + script_url_match = re.search(r'<script src=["\']([^"\']+)["\']', script_tag) + widget_id_match = re.search(r"window\.chattermateId=\'([^\']+)\'", script_tag) + + if not script_url_match: + raise Exception("Could not extract script URL from the provided script tag.") + + script_url = script_url_match.group(1) + widget_id = widget_id_match.group(1) if widget_id_match else None + + # 2. Check if this script is already installed + existing_script_query = """ + query { + scriptTags(first: 50) { + edges { + node { + id + src + } + } + } + } + """ + + script_response = await self._execute_graphql_async(shop, existing_script_query) + if not script_response.get("success"): + raise Exception(f"Failed to fetch existing scripts: {script_response.get('message')}") + + # Check if our script is already installed + script_edges = script_response["data"]["scriptTags"]["edges"] + for edge in script_edges: + if edge["node"]["src"] == script_url: + logger.info(f"ChatterMate script already installed at {script_url}. Skipping injection.") + return + + # 3. Create the script tag using scriptTagCreate mutation + create_mutation = """ + mutation scriptTagCreate($input: ScriptTagInput!) { + scriptTagCreate(input: $input) { + scriptTag { + id + src + displayScope + } + userErrors { + field + message + } + } + } + """ + + create_vars = { + "input": { + "src": script_url, + "displayScope": "ONLINE_STORE", # Apply to the online store + "cache": False # Don't cache the script to ensure updates are applied immediately + } + } + + create_response = await self._execute_graphql_async(shop, create_mutation, create_vars) + if not create_response.get("success"): + raise Exception(f"Failed to create script tag: {create_response.get('message')}") + + user_errors = create_response["data"]["scriptTagCreate"]["userErrors"] + if user_errors: + error_msg = user_errors[0]["message"] + logger.error(f"GraphQL error creating script tag: {error_msg}") + raise Exception(f"Failed to create script tag: {error_msg}") + + script_tag_id = create_response["data"]["scriptTagCreate"]["scriptTag"]["id"] + logger.info(f"Successfully created script tag with ID {script_tag_id} for shop {shop.shop_domain}") + + # 4. Store the script tag ID in the database for future reference + if widget_id: + # Optional: store the mapping between widget_id and script_tag_id + # This could be useful for removing the script tag later + # You'll need to implement this storage mechanism based on your database schema + logger.info(f"Associated widget {widget_id} with script tag {script_tag_id}") + + async def remove_widget_script(self, shop: ShopifyShop, widget_id: str) -> None: + """ + Removes the ChatterMate widget script tag using scriptTagDelete GraphQL mutation. + + Args: + shop: The ShopifyShop object. + widget_id: The ID of the widget to remove (used to identify the correct script tag). + """ + logger.info(f"Starting widget script removal for shop: {shop.shop_domain}") + + # 1. Get all script tags + script_query = """ + query { + scriptTags(first: 50) { + edges { + node { + id + src + } + } + } + } + """ + + script_response = await self._execute_graphql_async(shop, script_query) + if not script_response.get("success"): + raise Exception(f"Failed to fetch script tags: {script_response.get('message')}") + + # Extract the script edges + script_edges = script_response["data"]["scriptTags"]["edges"] + + # 2. Find scripts matching our widget + # The approach depends on how your scripts are identified + # Look for scripts containing your domain and potentially the widget ID + widget_scripts = [] + for edge in script_edges: + src = edge["node"]["src"] + # Match any scripts from your domain and potentially containing the widget ID + # Adjust this condition based on your script URL structure + if "chattermate" in src.lower(): + widget_scripts.append(edge["node"]["id"]) + + if not widget_scripts: + logger.info(f"No ChatterMate script tags found for shop {shop.shop_domain}. Nothing to remove.") + return + + # 3. Delete each matching script tag + delete_mutation = """ + mutation scriptTagDelete($id: ID!) { + scriptTagDelete(id: $id) { + deletedScriptTagId + userErrors { + field + message + } + } + } + """ + + for script_id in widget_scripts: + delete_vars = { + "id": script_id + } + + delete_response = await self._execute_graphql_async(shop, delete_mutation, delete_vars) + if not delete_response.get("success"): + logger.warning(f"Failed to delete script tag {script_id}: {delete_response.get('message')}") + continue + + user_errors = delete_response["data"]["scriptTagDelete"]["userErrors"] + if user_errors: + error_msg = user_errors[0]["message"] + logger.warning(f"GraphQL error deleting script tag {script_id}: {error_msg}") + continue + + logger.info(f"Successfully removed script tag with ID {script_id} from shop {shop.shop_domain}") + + logger.info(f"Completed widget script removal for shop {shop.shop_domain}") \ No newline at end of file diff --git a/backend/app/services/shopify_auth_service.py b/backend/app/services/shopify_auth_service.py new file mode 100644 index 0000000..f9637cd --- /dev/null +++ b/backend/app/services/shopify_auth_service.py @@ -0,0 +1,82 @@ +""" +Copyright 2024-2026 ChatterMate + +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 sqlalchemy.orm import Session +from typing import Optional, Tuple, List, Dict, Any +from app.core.logger import get_logger +from app.repositories.agent_shopify_config_repository import AgentShopifyConfigRepository +from app.repositories.widget import WidgetRepository +from app.services.shopify import ShopifyService +from app.repositories.agent import AgentRepository +from fastapi import Request +from fastapi.responses import RedirectResponse, HTMLResponse +from app.models.organization import Organization +from app.core.config import settings +from urllib.parse import quote +import hmac +import hashlib +import base64 + +logger = get_logger(__name__) + + +class ShopifyAuthService: + """Service for handling Shopify authentication and HTML generation""" + + @staticmethod + async def get_shop_success_data(db: Session, shop_id: str) -> Tuple[int, Optional[str]]: + """ + Get success page data for a shop (agent count and widget ID). + + Args: + db: Database session + shop_id: Shop ID + + Returns: + Tuple of (agents_connected, widget_id) + """ + agent_config_repository = AgentShopifyConfigRepository(db) + configs = agent_config_repository.get_configs_by_shop(shop_id, enabled_only=True) + agents_connected = len(configs) if configs else 0 + + # Get widget ID from the first connected agent + widget_id = None + if configs and len(configs) > 0: + widget_repo = WidgetRepository(db) + widgets = widget_repo.get_widgets_by_agent(configs[0].agent_id) + if widgets and len(widgets) > 0: + widget_id = str(widgets[0].id) + + return agents_connected, widget_id + + @staticmethod + async def verify_shop_token(db: Session, db_shop) -> bool: + """ + Verify if a shop's access token is valid. + + Args: + db: Database session + db_shop: Shop database record + + Returns: + True if token is valid, False otherwise + """ + shopify_service = ShopifyService(db) + validation_query = "query { shop { name } }" + validation_result = await shopify_service._execute_graphql_async(db_shop, validation_query) + return validation_result.get("success", False) + + diff --git a/backend/app/services/shopify_helper_service.py b/backend/app/services/shopify_helper_service.py new file mode 100644 index 0000000..59c6a3a --- /dev/null +++ b/backend/app/services/shopify_helper_service.py @@ -0,0 +1,209 @@ +""" +Copyright 2024-2026 ChatterMate + +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 sqlalchemy.orm import Session +from typing import Optional +from fastapi import Request +from fastapi.responses import RedirectResponse, HTMLResponse +from app.core.logger import get_logger +from app.core.config import settings +from app.models.organization import Organization +from app.repositories.widget import WidgetRepository +from app.services.shopify_auth_service import ShopifyAuthService +from app.services.shopify_urls import shopify_oauth_token_url +from urllib.parse import quote +import hmac +import hashlib +import base64 + +logger = get_logger(__name__) + + +class ShopifyHelperService: + """Service for Shopify helper functions and utilities""" + + @staticmethod + def get_shop_agents_list(db: Session, organization_id: str) -> list[dict]: + """ + Get all agents for a shop's organization and convert to dict format. + + Args: + db: Database session + organization_id: Organization ID (string UUID) + + Returns: + List of agent dictionaries with id, name, display_name, description, is_active + """ + from app.repositories.agent import AgentRepository + agent_repository = AgentRepository(db) + agents = agent_repository.get_active_agents(organization_id) + + # Convert agents to dict format + agents_list = [ + { + "id": str(agent.id), + "name": agent.name, + "display_name": agent.display_name, + "description": agent.description, + "is_active": agent.is_active + } + for agent in agents + ] + + return agents_list + + @staticmethod + def get_shop_widget_id(db: Session, configs: list, default: str = None) -> Optional[str]: + """ + Get the widget ID for the first configured agent. + + Args: + db: Database session + configs: List of agent Shopify configs + default: Default value if no widget found + + Returns: + Widget ID as string or None + """ + if configs and len(configs) > 0: + widget_repo = WidgetRepository(db) + widgets = widget_repo.get_widgets_by_agent(configs[0].agent_id) + if widgets and len(widgets) > 0: + return str(widgets[0].id) + return default + + @staticmethod + def exchange_oauth_code_for_token(shop: str, code: str) -> tuple[str, str]: + """ + Exchange OAuth authorization code for access token. + + Args: + shop: Shop domain + code: OAuth authorization code + + Returns: + Tuple of (access_token, scope) + + Raises: + HTTPException: If token exchange fails + """ + import requests + from fastapi import HTTPException + + token_url = shopify_oauth_token_url(shop) + payload = { + "client_id": settings.SHOPIFY_API_KEY, + "client_secret": settings.SHOPIFY_API_SECRET, + "code": code + } + + # Check if we should verify SSL certificates (default to True for production) + verify_ssl = settings.VERIFY_SSL_CERTIFICATES + logger.info(f"Exchanging OAuth code for token - Shop: {shop}") + logger.info(f"Verify SSL Certificates: {verify_ssl}") + logger.info(f"Token URL: {token_url}") + + try: + response = requests.post(token_url, json=payload, verify=verify_ssl) + response.raise_for_status() + token_data = response.json() + + access_token = token_data.get("access_token") + scope = token_data.get("scope") + + if not access_token: + logger.error(f"Failed to get access token for shop: {shop}") + raise HTTPException(status_code=400, detail="Failed to obtain access token") + + logger.info(f"Successfully obtained access token for shop: {shop}") + return access_token, scope + + except requests.exceptions.RequestException as e: + logger.error(f"Error exchanging OAuth code for token: {str(e)}") + if isinstance(e, requests.exceptions.SSLError): + logger.error("SSL Certificate verification failed. If this is a development environment, consider setting VERIFY_SSL_CERTIFICATES=False in settings.") + raise HTTPException(status_code=500, detail=f"Failed to exchange OAuth code: {str(e)}") + + @staticmethod + def verify_shopify_webhook(request_headers, request_body) -> bool: + """ + Validate Shopify HMAC signature for webhooks. + + Args: + request_headers: Request headers containing X-Shopify-Hmac-Sha256 + request_body: Raw request body bytes + + Returns: + True if signature is valid, False otherwise + """ + shopify_hmac = request_headers.get('X-Shopify-Hmac-Sha256') + if not shopify_hmac: + return False + + digest = hmac.new( + settings.SHOPIFY_API_SECRET.encode('utf-8'), + request_body, + hashlib.sha256 + ).digest() + + computed_hmac = base64.b64encode(digest).decode('utf-8') + return hmac.compare_digest(computed_hmac, shopify_hmac) + + @staticmethod + def validate_shop_request(request: Request, shop: str, hmac_param: str) -> bool: + """ + Validate the OAuth request from Shopify using HMAC validation. + + Args: + request: FastAPI Request object + shop: Shop domain + hmac_param: HMAC parameter from query string + + Returns: + True if request is valid, False otherwise + """ + # Check if the shop URL is a valid Shopify domain + if not shop.endswith('.myshopify.com'): + logger.warning(f"Invalid shop domain: {shop}") + return False + + # Get query parameters as dictionary + query_params = dict(request.query_params) + + # Remove hmac from params for validation + if 'hmac' in query_params: + query_params.pop('hmac') + + logger.info(f"Query params: {query_params}") + logger.info(f"HMAC param: {hmac_param}") + + # Sort and encode parameters + sorted_params = "&".join([f"{key}={quote(value)}" for key, value in sorted(query_params.items())]) + + # Generate HMAC + digest = hmac.new( + settings.SHOPIFY_API_SECRET.encode('utf-8'), + sorted_params.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + + # Compare with provided HMAC + is_valid = hmac.compare_digest(digest, hmac_param) + if not is_valid: + logger.warning(f"HMAC validation failed for shop: {shop}") + + return is_valid + diff --git a/backend/app/services/shopify_session.py b/backend/app/services/shopify_session.py new file mode 100644 index 0000000..c80354f --- /dev/null +++ b/backend/app/services/shopify_session.py @@ -0,0 +1,241 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import jwt +import time +from typing import Optional, Dict, Any +from fastapi import Request, HTTPException, Depends +from sqlalchemy.orm import Session +from app.core.config import settings +from app.core.logger import get_logger +from app.database import get_db + +logger = get_logger(__name__) + + +class ShopifySessionService: + """Service for handling Shopify session token validation""" + + @staticmethod + def get_session_token_from_request(request: Request) -> Optional[str]: + """ + Get session token from request header or URL parameter. + Per Shopify docs, session token can be in: + 1. Authorization header: Bearer <token> + 2. URL query parameter: id_token + """ + # Try to get from Authorization header first + auth_header = request.headers.get('authorization', '') + if auth_header.startswith('Bearer '): + token = auth_header.replace('Bearer ', '').strip() + if token: + return token + + # Try to get from URL query parameter + id_token = request.query_params.get('id_token') + if id_token: + return id_token + + return None + + @staticmethod + def validate_session_token(token: str) -> Dict[str, Any]: + """ + Validate and decode Shopify session token. + Returns decoded token payload if valid. + Raises HTTPException if invalid. + """ + try: + # Decode without verification first to get the shop + unverified = jwt.decode(token, options={"verify_signature": False}) + + # Verify the token with Shopify's public key + # Note: In production, you should fetch and cache Shopify's public keys + # For now, we'll do basic validation + decoded = jwt.decode( + token, + settings.SHOPIFY_API_SECRET, + algorithms=["HS256"], + audience=settings.SHOPIFY_API_KEY + ) + + # Check expiration + exp = decoded.get('exp') + if exp and exp < time.time(): + raise HTTPException(status_code=401, detail="Session token expired") + + # Check nbf (not before) + nbf = decoded.get('nbf') + if nbf and nbf > time.time(): + raise HTTPException(status_code=401, detail="Session token not yet valid") + + logger.info(f"Session token validated for shop: {decoded.get('dest')}") + return decoded + + except jwt.ExpiredSignatureError: + logger.warning("Session token expired") + raise HTTPException( + status_code=401, + detail="Session token expired", + headers={"X-Shopify-Retry-Invalid-Session-Request": "1"} + ) + except jwt.InvalidTokenError as e: + logger.warning(f"Invalid session token: {str(e)}") + raise HTTPException( + status_code=401, + detail="Invalid session token", + headers={"X-Shopify-Retry-Invalid-Session-Request": "1"} + ) + + @staticmethod + def is_document_request(request: Request) -> bool: + """ + Check if this is a document request (initial page load) vs API request. + Document requests typically don't have Authorization header. + """ + return not request.headers.get("authorization") + + +# FastAPI dependency for session token validation +async def require_shopify_session( + request: Request, + db: Session = Depends(get_db) +) -> Dict[str, Any]: + """ + FastAPI dependency to validate Shopify session token. + Extracts shop from token and looks up organization. + Returns decoded token with db_shop and organization_id. + + Usage: + @router.get("/some-endpoint") + async def some_endpoint( + shopify_session: dict = Depends(require_shopify_session), + db: Session = Depends(get_db) + ): + shop_id = shopify_session['shop_id'] + org_id = shopify_session['organization_id'] + db_shop = shopify_session['db_shop'] + # ... use the shop info + """ + from app.repositories.shopify_shop_repository import ShopifyShopRepository + + session_token = ShopifySessionService.get_session_token_from_request(request) + + if not session_token: + raise HTTPException( + status_code=401, + detail="Session token required for embedded app", + headers={"X-Shopify-Retry-Invalid-Session-Request": "1"} + ) + + # Validate and decode token + decoded = ShopifySessionService.validate_session_token(session_token) + + # Extract shop domain from token + shop_domain = decoded.get('dest', '').replace('https://', '').replace('http://', '') + + # Look up shop in database + shop_repository = ShopifyShopRepository(db) + db_shop = shop_repository.get_shop_by_domain(shop_domain) + + if not db_shop or not db_shop.is_installed: + raise HTTPException(status_code=403, detail="Shop not installed or not found") + + # Add shop info to decoded token for easy access + decoded['db_shop'] = db_shop + decoded['shop_id'] = str(db_shop.id) + decoded['shop_domain'] = shop_domain + decoded['organization_id'] = db_shop.organization_id if db_shop.organization_id else None + + logger.info(f"Session token validated for shop: {shop_domain}, org: {decoded['organization_id']}") + + return decoded + + +async def require_shopify_or_jwt_auth( + request: Request, + db: Session = Depends(get_db) +) -> Dict[str, Any]: + """ + Hybrid authentication dependency that tries Shopify session token first, + then falls back to JWT authentication. + Returns authentication info with type indicator. + + Usage: + @router.get("/some-endpoint") + async def some_endpoint( + auth_info: dict = Depends(require_shopify_or_jwt_auth), + db: Session = Depends(get_db) + ): + if auth_info['auth_type'] == 'shopify_session': + org_id = auth_info['organization_id'] + shop_id = auth_info['shop_id'] + user_id = None # No user for session token auth + else: # jwt + org_id = auth_info['organization_id'] + user_id = auth_info['user_id'] + current_user = auth_info['current_user'] + """ + from app.core.auth import get_current_user + from app.repositories.shopify_shop_repository import ShopifyShopRepository + + # First try Shopify session token + session_token = ShopifySessionService.get_session_token_from_request(request) + + if session_token: + try: + # Validate and decode the session token + decoded = ShopifySessionService.validate_session_token(session_token) + + # Extract shop domain from token + shop_domain = decoded.get('dest', '').replace('https://', '').replace('http://', '') + + if shop_domain: + # Look up shop in database + shop_repository = ShopifyShopRepository(db) + db_shop = shop_repository.get_shop_by_domain(shop_domain) + + if db_shop and db_shop.is_installed and db_shop.organization_id: + logger.info(f"Using Shopify session token auth for shop: {shop_domain}") + return { + 'auth_type': 'shopify_session', + 'shop_domain': shop_domain, + 'shop_id': str(db_shop.id), + 'organization_id': db_shop.organization_id, # Keep as UUID for consistency + 'db_shop': db_shop, + 'decoded_token': decoded, + 'user_id': None # No user_id for session token auth + } + except Exception as e: + logger.debug(f"Shopify session token validation failed, trying JWT: {str(e)}") + + # Fall back to JWT authentication + try: + current_user = await get_current_user(request=request, db=db) + logger.info(f"Using JWT auth for user: {current_user.id}") + return { + 'auth_type': 'jwt', + 'organization_id': current_user.organization_id, # Keep as UUID for consistency + 'user_id': current_user.id, + 'current_user': current_user + } + except Exception as e: + logger.error(f"Both Shopify session token and JWT authentication failed: {str(e)}") + raise HTTPException( + status_code=401, + detail="Authentication required" + ) + diff --git a/backend/app/services/shopify_urls.py b/backend/app/services/shopify_urls.py new file mode 100644 index 0000000..f6b3b36 --- /dev/null +++ b/backend/app/services/shopify_urls.py @@ -0,0 +1,42 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +# Shop-scoped Shopify URLs. Every Admin API and OAuth call goes to the merchant's +# own host (`{shop}.myshopify.com`), so there is no single base to swap. When +# SHOPIFY_ADMIN_BASE is set (only the e2e suite does) the shop domain becomes a +# path segment under it instead: `{base}/{shop_domain}/admin/...`. + +from app.core.external_urls import external_base + +SHOPIFY_API_VERSION = "2025-10" +SHOPIFY_ADMIN_BASE = external_base("SHOPIFY_ADMIN_BASE", "") + + +def shopify_shop_url(shop_domain: str, path: str) -> str: + """URL of `path` (no leading slash) on the shop's own host.""" + if SHOPIFY_ADMIN_BASE: + return f"{SHOPIFY_ADMIN_BASE}/{shop_domain}/{path}" + return f"https://{shop_domain}/{path}" + + +def shopify_admin_url(shop_domain: str, path: str) -> str: + """Admin API URL, e.g. `shopify_admin_url(shop, "graphql.json")`.""" + return shopify_shop_url(shop_domain, f"admin/api/{SHOPIFY_API_VERSION}/{path}") + + +def shopify_oauth_token_url(shop_domain: str) -> str: + """The shop's OAuth access-token endpoint (code and token exchange).""" + return shopify_shop_url(shop_domain, "admin/oauth/access_token") diff --git a/backend/app/services/slack_events.py b/backend/app/services/slack_events.py new file mode 100644 index 0000000..c8081a0 --- /dev/null +++ b/backend/app/services/slack_events.py @@ -0,0 +1,170 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +"""Slack App Home / Assistant lifecycle handlers. + +These run as FastAPI background tasks (the webhook acks within 3s and hands off +here), so each opens its own DB session. They cover the non-message surfaces: +the Messages-tab welcome, the Home tab view, and the Assistant sidebar — all of +which the Slack Marketplace requires an app with the Messages tab enabled to use. +""" + +from uuid import UUID + +from app.channels.slack import ( + SUGGESTED_PROMPTS_TITLE, + STARTER_PROMPTS, + WELCOME_NO_AGENT, + WELCOME_WITH_AGENT, + ASSISTANT_READY_STATUS, + SlackAdapter, + build_home_view, + publish_home_view, + set_assistant_status, + set_suggested_prompts, + slack_api, +) +from app.core.config import settings +from app.core.logger import get_logger +from app.core.redis import get_redis +from app.core.s3 import get_s3_signed_url +from app.database import SessionLocal +from app.repositories.agent import AgentRepository +from app.repositories.channels import ChannelAccountRepository +from app.repositories.channels.agent_config import AgentChannelConfigRepository + +logger = get_logger(__name__) + +# One welcome per user, forever — this flag is never meant to expire (a re-welcome +# on a Redis flush is harmless). SETNX; TTL only as a backstop against orphans. +_WELCOME_FLAG_TTL_SECONDS = 60 * 60 * 24 * 365 + + +def _assigned_agent(db, account): + """The single agent answering this Slack workspace, or None if unassigned.""" + agent_id = AgentChannelConfigRepository(db).get_active_agent_id(account.id) + if not agent_id: + return None + return AgentRepository(db).get_agent(agent_id) + + +def _welcome_text(agent) -> str: + if agent is None: + return WELCOME_NO_AGENT + return WELCOME_WITH_AGENT.format(agent=agent.display_name or agent.name) + + +def _already_welcomed(account_id, user_id: str) -> bool: + """Claim the first-welcome slot for (account, user). True if already welcomed. + + Without Redis we can't dedupe, so we welcome (once per process start is the + worst case) rather than stay silent — the review requires a welcome to be sent. + """ + redis_client = get_redis() + if redis_client is None: + return False + try: + key = f"slack_home_welcomed:{account_id}:{user_id}" + claimed = redis_client.set(key, "1", nx=True, ex=_WELCOME_FLAG_TTL_SECONDS) + return not claimed + except Exception as e: + logger.error(f"Slack welcome dedupe failed (welcoming anyway): {e}") + return False + + +async def deliver_home_welcome(account_id: UUID, user_id: str, channel: str) -> None: + """Send the one-time welcome DM when a user first opens the Messages tab.""" + if not user_id or not channel: + return + if _already_welcomed(account_id, user_id): + return + db = SessionLocal() + try: + account = ChannelAccountRepository(db).get_by_id(account_id) + if account is None or not account.is_active: + return + text = _welcome_text(_assigned_agent(db, account)) + await slack_api("chat.postMessage", SlackAdapter._access_token(account), + {"channel": channel, "text": text}) + except Exception as e: + logger.error(f"Slack Messages-tab welcome failed: {e}") + finally: + db.close() + + +async def handle_assistant_thread_started(account_id: UUID, channel_id: str, thread_ts: str) -> None: + """On assistant-sidebar open: show a status, offer starter prompts, welcome.""" + if not channel_id or not thread_ts: + return + db = SessionLocal() + try: + account = ChannelAccountRepository(db).get_by_id(account_id) + if account is None or not account.is_active: + return + token = SlackAdapter._access_token(account) + await set_assistant_status(token, channel_id, thread_ts, ASSISTANT_READY_STATUS) + await set_suggested_prompts(token, channel_id, thread_ts, STARTER_PROMPTS, SUGGESTED_PROMPTS_TITLE) + text = _welcome_text(_assigned_agent(db, account)) + await slack_api("chat.postMessage", token, + {"channel": channel_id, "text": text, "thread_ts": thread_ts}) + except Exception as e: + logger.error(f"Slack assistant welcome failed: {e}") + finally: + db.close() + + +async def _agent_card(agent) -> dict: + """Flatten an Agent into the resolved shape build_home_view expects.""" + photo_url = None + customization = getattr(agent, "customization", None) + if customization and customization.photo_url: + photo_url = customization.photo_url + if settings.S3_FILE_STORAGE: + try: + photo_url = await get_s3_signed_url(customization.photo_url) + except Exception as e: + logger.debug(f"Could not sign agent photo URL: {e}") + instructions = agent.instructions or [] + return { + "name": agent.display_name or agent.name, + "is_active": agent.is_active, + "instruction": instructions[0] if instructions else "", + "photo_url": photo_url, + } + + +def _dashboard_url() -> str: + return settings.FRONTEND_URL.rstrip("/") + + +async def publish_agent_home(account_id: UUID, user_id: str) -> None: + """Publish the Home tab: what ChatterMate does, the agent connected to this + workspace, how to use it, and a link to the dashboard.""" + if not user_id: + return + db = SessionLocal() + try: + account = ChannelAccountRepository(db).get_by_id(account_id) + if account is None or not account.is_active: + return + agent = _assigned_agent(db, account) + card = await _agent_card(agent) if agent is not None else None + view = build_home_view(card, _dashboard_url()) + await publish_home_view(SlackAdapter._access_token(account), user_id, view) + except Exception as e: + logger.error(f"Slack Home tab publish failed: {e}") + finally: + db.close() diff --git a/backend/app/services/sql_guardrails.py b/backend/app/services/sql_guardrails.py new file mode 100644 index 0000000..c1796ce --- /dev/null +++ b/backend/app/services/sql_guardrails.py @@ -0,0 +1,369 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +SQL guardrails for the AI ticket investigator's database connector. + +The model's SQL is untrusted input. Every guarantee here is enforced OUTSIDE +the model, on the parsed AST (sqlglot): + +- exactly one statement, and it must be a plain SELECT (no INTO, no locking) +- every referenced table — including inside CTEs, subqueries, joins and + unions — must be on the connector's explicit allowlist +- dangerous functions (sleep/file/network/admin) are denied +- a LIMIT is always enforced (added or clamped to the connector's max_rows) +- row-scoped tables are rewritten into a subquery pre-filtered to the ticket + customer's own rows, so the agent cannot read across customers +- the statement is re-rendered from the AST, so only canonical SQL executes + +The executor adds the second, independent barrier: a read-only transaction +with a statement timeout. Column masking happens on the result rows. +""" + +from dataclasses import dataclass +from typing import Iterable, List, Optional, Sequence + +import sqlglot +from sqlglot import exp + +MASK_VALUE = "***MASKED***" + +# Time-wasting, file, network and admin functions. The read-only transaction +# already blocks writes — this denylist is defense-in-depth against DoS and +# data exfiltration via server-side functions. +BLOCKED_FUNCTIONS = { + # postgres + "pg_sleep", "pg_sleep_for", "pg_sleep_until", + "pg_read_file", "pg_read_binary_file", "pg_ls_dir", "pg_stat_file", + "pg_terminate_backend", "pg_cancel_backend", "pg_reload_conf", + "pg_rotate_logfile", "set_config", "pg_promote", + "dblink", "dblink_exec", "dblink_connect", "dblink_connect_u", + "lo_import", "lo_export", + "query_to_xml", "database_to_xml", "table_to_xml", + "pg_logical_slot_get_changes", "pg_logical_slot_peek_changes", + "pg_create_logical_replication_slot", "pg_drop_replication_slot", + # mysql + "sleep", "benchmark", "load_file", "get_lock", "release_lock", + "master_pos_wait", "sys_eval", "sys_exec", +} + +# Statement/expression node types that must never appear anywhere in the tree. +BLOCKED_NODE_TYPES = ( + exp.Insert, exp.Update, exp.Delete, exp.Merge, + exp.Create, exp.Drop, exp.Alter, exp.TruncateTable, + exp.Grant, exp.Command, exp.Transaction, exp.Set, + exp.Into, exp.Lock, +) + +# Default schema names: a bare table reference resolves to these, so an +# allowlist entry "public.orders" also authorizes "orders". +DEFAULT_SCHEMAS = {"public", "dbo"} + +_DIALECTS = {"postgresql": "postgres", "postgres": "postgres", "mysql": "mysql"} + + +@dataclass +class SqlValidationResult: + ok: bool + sql: Optional[str] = None # canonical SQL, only when ok + reason: Optional[str] = None # block reason, only when not ok + + +def _normalize_allowlist(allowed_tables: Iterable[str]) -> set: + """Expand allowlist entries so both qualified and bare references match + when the schema is a default one.""" + allowed = set() + for entry in allowed_tables or []: + entry = (entry or "").strip().lower() + if not entry: + continue + allowed.add(entry) + if "." in entry: + schema, _, name = entry.rpartition(".") + if schema in DEFAULT_SCHEMAS: + allowed.add(name) + return allowed + + +def _table_reference(table: exp.Table) -> str: + name = (table.name or "").lower() + if table.db: + return f"{table.db.lower()}.{name}" + return name + + +def _cte_names(statement: exp.Expression) -> set: + return {cte.alias_or_name.lower() for cte in statement.find_all(exp.CTE)} + + +def _blocked_function(statement: exp.Expression) -> Optional[str]: + for func in statement.find_all(exp.Func): + name = (func.sql_name() or "").lower() + if name in BLOCKED_FUNCTIONS: + return name + if isinstance(func, exp.Anonymous): + anon = (func.name or "").lower() + if anon in BLOCKED_FUNCTIONS: + return anon + return None + + +def normalize_scope_map(row_scope: Optional[dict]) -> dict: + """Expand row-scope entries the same way as the allowlist, so a rule on + "public.orders" also binds a bare "orders" reference.""" + scoped = {} + for raw_table, raw_column in (row_scope or {}).items(): + table = (raw_table or "").strip().lower() + column = (raw_column or "").strip() + if not table or not column: + continue + scoped[table] = column + if "." in table: + schema, _, name = table.rpartition(".") + if schema in DEFAULT_SCHEMAS: + scoped.setdefault(name, column) + return scoped + + +def _apply_row_scope( + statement: exp.Select, + scoped: dict, + scope_value: Optional[str], + cte_names: set, + case_insensitive: bool = False, +) -> tuple: + """Rewrite every scoped base table into a pre-filtered subquery: + + FROM orders o -> FROM (SELECT * FROM orders WHERE email = 'x') AS o + + Filtering the *relation* rather than checking the model's WHERE clause is + what makes this unbypassable. Whatever the model writes outside — OR 1=1, + a UNION arm, a correlated subquery — it can only ever see rows the wrapper + already returned. Validating a model-supplied predicate instead would mean + proving that no OR, no join and no outer reference widens it, which is not + a thing you can check reliably. + + Returns (statement, error). Fails closed: a scoped table with no value to + scope by is refused, never silently unscoped. + """ + targets = [] + for table in statement.find_all(exp.Table): + reference = _table_reference(table) + if not reference: + continue + if not table.db and reference in cte_names: + continue + column = scoped.get(reference) + if column is None and table.name: + column = scoped.get(table.name.lower()) + if column: + targets.append((table, column)) + + if not targets: + return statement, None + + if not (scope_value or "").strip(): + return None, ( + "This table is restricted to the rows of the ticket's own customer, " + "and this ticket has no customer identity to scope by. Set the " + "ticket's customer, or query a table that is not customer-scoped." + ) + + value = scope_value.strip() + for table, column in targets: + alias_name = table.alias or table.name + inner_table = table.copy() + inner_table.set("alias", None) + column_ref = exp.Column(this=exp.to_identifier(column)) + # Emails are compared case-insensitively — the same person may be + # stored as Bob@x.com here and bob@x.com there, and an exact match + # would silently return nothing rather than their rows. Costs an index + # on the raw column unless one exists on LOWER(col). + predicate = exp.EQ( + this=exp.Lower(this=column_ref) if case_insensitive else column_ref, + # Literal.string escapes on render — never string-format a value + # into SQL. + expression=exp.Literal.string(value.lower() if case_insensitive else value), + ) + inner = ( + exp.Select().select(exp.Star()).from_(inner_table).where(predicate) + ) + table.replace( + exp.Subquery(this=inner, alias=exp.TableAlias(this=exp.to_identifier(alias_name))) + ) + return statement, None + + +def _force_limit(statement: exp.Select, max_rows: int) -> exp.Select: + """Add a LIMIT, or clamp an existing numeric one, to max_rows.""" + limit_node = statement.args.get("limit") + if limit_node is not None: + literal = limit_node.expression + if isinstance(literal, exp.Literal) and literal.is_int: + if int(literal.this) <= max_rows: + return statement + return statement.limit(max_rows, copy=False) + + +def validate_sql( + raw_sql: str, + allowed_tables: Sequence[str], + max_rows: int, + engine: str = "postgresql", + masked_columns: Optional[Iterable[str]] = None, + row_scope: Optional[dict] = None, + scope_value: Optional[str] = None, + scope_case_insensitive: bool = False, +) -> SqlValidationResult: + """Validate untrusted SQL against the connector policy. Returns canonical + SQL to execute, or the reason it was blocked. + + row_scope maps a table to the column identifying the customer who owns the + row ({"orders": "customer_email"}); scope_value is that customer's value + for the ticket being investigated. Listed tables are rewritten so only + their rows are visible.""" + dialect = _DIALECTS.get((engine or "").lower(), "postgres") + if not (raw_sql or "").strip(): + return SqlValidationResult(ok=False, reason="Empty query") + + try: + statements = sqlglot.parse(raw_sql, read=dialect) + except sqlglot.errors.ParseError as e: + return SqlValidationResult(ok=False, reason=f"SQL could not be parsed: {e}") + + statements = [s for s in statements if s is not None] + if len(statements) != 1: + return SqlValidationResult( + ok=False, reason="Exactly one SQL statement is allowed" + ) + statement = statements[0] + + if not isinstance(statement, exp.Select): + return SqlValidationResult( + ok=False, + reason="Only plain SELECT statements are allowed " + "(wrap set operations like UNION in a subquery)", + ) + + for node_type in BLOCKED_NODE_TYPES: + found = statement.find(node_type) + if found is not None: + return SqlValidationResult( + ok=False, reason=f"Disallowed SQL construct: {found.key.upper()}" + ) + + blocked_func = _blocked_function(statement) + if blocked_func is not None: + return SqlValidationResult(ok=False, reason=f"Disallowed function: {blocked_func}") + + allowed = _normalize_allowlist(allowed_tables) + if not allowed: + return SqlValidationResult(ok=False, reason="No tables are allowlisted for this connector") + cte_names = _cte_names(statement) + for table in statement.find_all(exp.Table): + reference = _table_reference(table) + if not reference: + continue + # References to CTE names defined in this query are fine — the CTE + # bodies themselves are walked by this same loop. + if not table.db and reference in cte_names: + continue + if reference not in allowed: + return SqlValidationResult( + ok=False, reason=f"Table not on the allowlist: {reference}" + ) + + # Masked columns may not be referenced at all — output-name masking alone + # is alias-bypassable (SELECT email AS x), and blocking references also + # prevents value probing via WHERE clauses. SELECT * and t.* stay allowed: + # they expand to real column names that mask_rows redacts by name. + masked = {c.strip().lower() for c in masked_columns or [] if c and c.strip()} + if masked: + # Names that denote a whole row when used as a bare value. + scope_names = {t.name.lower() for t in statement.find_all(exp.Table) if t.name} + scope_names |= { + t.alias.lower() for t in statement.find_all(exp.Table) if t.alias + } + scope_names |= cte_names + + for column in statement.find_all(exp.Column): + # A qualified/bare star (t.* or *) nested inside a function or cast + # collapses the row into one value (to_jsonb(c.*), (c.*)::text), + # defeating name-based masking — only a top-level projection star + # is safe. Same for a bare Star node. + if isinstance(column.this, exp.Star) and not isinstance(column.parent, exp.Select): + return SqlValidationResult( + ok=False, + reason="Row expansion (t.*) inside an expression is not allowed with masked columns", + ) + if (column.name or "").lower() in masked and not isinstance(column.this, exp.Star): + return SqlValidationResult( + ok=False, + reason=f"Column '{column.name}' is masked and cannot be referenced", + ) + # A bare (unqualified) reference to a table/alias/CTE name is a + # whole-row value — SELECT customers, to_jsonb(c), c::text, row(c). + # These serialize masked fields past name-based masking. + if not column.table and (column.name or "").lower() in scope_names: + return SqlValidationResult( + ok=False, + reason=( + f"Whole-row reference to '{column.name}' is not allowed with " + "masked columns — select individual columns instead" + ), + ) + for star in statement.find_all(exp.Star): + # Safe: top-level projection (SELECT *), the star of a t.* Column + # node (handled above), and count(*) — a cardinality marker that + # never exposes row values. + if not isinstance(star.parent, (exp.Select, exp.Column, exp.Count)): + return SqlValidationResult( + ok=False, + reason="Row expansion (*) inside an expression is not allowed with masked columns", + ) + + # Last, so the rewrite can't perturb the checks above: they inspect what + # the model actually wrote, not the wrappers added on its behalf. + scoped = normalize_scope_map(row_scope) + if scoped: + statement, scope_error = _apply_row_scope( + statement, scoped, scope_value, cte_names, + case_insensitive=scope_case_insensitive, + ) + if scope_error is not None: + return SqlValidationResult(ok=False, reason=scope_error) + + statement = _force_limit(statement, max_rows) + # comments=False: comments are dropped from the canonical SQL — MySQL + # executable comments (/*! ... */) must never reach the server. + return SqlValidationResult( + ok=True, sql=statement.sql(dialect=dialect, comments=False) + ) + + +def mask_rows( + columns: List[str], rows: List[tuple], masked_columns: Iterable[str] +) -> List[tuple]: + """Replace values of masked columns (matched by column name, case + insensitive) so PII never reaches the model.""" + masked = {c.strip().lower() for c in masked_columns or [] if c and c.strip()} + if not masked: + return rows + mask_indexes = {i for i, col in enumerate(columns) if col.lower() in masked} + if not mask_indexes: + return rows + return [ + tuple(MASK_VALUE if i in mask_indexes else value for i, value in enumerate(row)) + for row in rows + ] diff --git a/backend/app/services/telegram_webhooks.py b/backend/app/services/telegram_webhooks.py new file mode 100644 index 0000000..282bf95 --- /dev/null +++ b/backend/app/services/telegram_webhooks.py @@ -0,0 +1,83 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +Registering ChatterMate's webhook with a Telegram bot, with the updates it +must receive. Done on connect, and again for an agent's bots when its reply +buttons are turned on: a bot connected before buttons existed was registered +without button taps, and Telegram keeps a bot's last registration. +""" + +import asyncio +import json +from typing import Optional, Tuple +from uuid import UUID + +from sqlalchemy.orm import Session + +from app.channels import telegram as telegram_api +from app.core.config import settings +from app.core.security import decrypt_api_key +from app.core.logger import get_logger +from app.models.channels import ChannelAccount, ChannelType +from app.repositories.channels import AgentChannelConfigRepository, ChannelAccountRepository + +logger = get_logger(__name__) + + +def webhook_url(account_id) -> str: + return f"{settings.BACKEND_URL.rstrip('/')}{settings.API_V1_STR}/webhooks/telegram/{account_id}" + + +async def register(account: ChannelAccount, bot_token: str) -> Tuple[bool, str]: + return await telegram_api.set_webhook(bot_token, webhook_url(account.id), account.webhook_secret) + + +async def reregister_for_agent(db: Session, agent_id: UUID) -> None: + """Register again, at once, every active Telegram bot routed to this agent + — its buttons were just turned on.""" + try: + accounts = ChannelAccountRepository(db) + bots = [account for account in (accounts.get_by_id(config.channel_account_id) + for config in AgentChannelConfigRepository(db).list_by_agent(agent_id)) + if _is_active_bot(account)] + except Exception: + # The agent is already saved; only the re-registration is lost + logger.exception(f"Could not list agent {agent_id}'s Telegram bots to re-register") + return + await asyncio.gather(*(_reregister(account) for account in bots)) + + +async def reregister_if_buttons_on(account: ChannelAccount, agent) -> None: + """A bot just routed to an agent whose buttons are already on.""" + if _is_active_bot(account) and agent.channel_quick_replies: + await _reregister(account) + + +async def _reregister(account: ChannelAccount) -> None: + """Best effort: a bot that can't be registered keeps working — only its + button taps don't arrive until it is (turn the agent's buttons off and on + again, or reconnect the bot).""" + try: + bot_token = json.loads(decrypt_api_key(account.encrypted_credentials))["bot_token"] + ok, error = await register(account, bot_token) + except Exception as e: + ok, error = False, f"{type(e).__name__}: {e}" + if not ok: + logger.warning(f"Could not re-register Telegram bot {account.id} for button taps: {error}") + + +def _is_active_bot(account: Optional[ChannelAccount]) -> bool: + return (account is not None and account.is_active + and account.channel_type == ChannelType.TELEGRAM.value) diff --git a/backend/app/services/ticket.py b/backend/app/services/ticket.py new file mode 100644 index 0000000..a57633c --- /dev/null +++ b/backend/app/services/ticket.py @@ -0,0 +1,1078 @@ +""" +Copyright 2024-2026 ChatterMate + +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 datetime import datetime, timedelta, timezone +from typing import List, Optional, Tuple +from uuid import UUID + +from sqlalchemy.orm import Session + +from app.channels.constants import MessageSource +from app.core.logger import get_logger +from app.models.investigation import ( + InvestigationRun, + InvestigationRunType, + InvestigationTrigger, + ProposalStatus, + TicketProposal, +) +from app.models.session_to_agent import SessionToAgent +from app.models.ticket import ( + OPEN_TICKET_STATUSES, + TICKET_STATUS_TRANSITIONS, + ResolutionOutcome, + Ticket, + TicketPriority, + TicketSource, + TicketStatus, +) +from app.models.ticket_activity import TicketActivity, TicketActivityType, TicketActorType +from app.models.ticket_settings import ( + DEFAULT_CREATED_TEMPLATE, + DEFAULT_RESOLVED_TEMPLATE, + DEFAULT_SLA_TARGETS, + OrganizationTicketSettings, +) +from app.repositories.chat import ChatRepository +from app.repositories.session_to_agent import SessionToAgentRepository +from app.services.message_delivery import deliver_to_customer +from app.repositories.ticket import ( + InvestigationRepository, + TicketActivityRepository, + TicketRepository, + TicketSettingsRepository, +) + +logger = get_logger(__name__) + +# Widget sessions carry this channel; their replies reach the browser tab over +# Socket.IO only, so ticket updates to them are also emailed (see below). +WEB_CHANNEL = "web" + +# Open tickets whose title+description embed this close to an existing open +# ticket are flagged as possible duplicates. +DUPLICATE_SIMILARITY_THRESHOLD = 0.90 + +# Denormalized mirror value on session_to_agents.integration_type for native +# tickets (Jira sessions keep 'JIRA'). +NATIVE_INTEGRATION_TYPE = "NATIVE" + +# Trailing window for the workspace CSAT stat. Wider than the 7-day resolution +# stats because CSAT responses are sparse. +CSAT_WINDOW_DAYS = 30 + +_embedder = None + + +def _get_embedder(): + """Lazy module-level FastEmbed embedder (local model, no API cost).""" + global _embedder + if _embedder is None: + from app.knowledge.embedder import get_embedder + _embedder = get_embedder() + return _embedder + + +def embed_ticket_text(title: str, description: Optional[str]) -> Optional[List[float]]: + try: + content = title if not description else f"{title}\n\n{description}" + return _get_embedder().get_embedding(content[:4000]) + except Exception as e: + logger.error(f"Ticket embedding failed: {e}") + return None + + +def render_customer_template(template: str, customer_name: Optional[str], display_number: str) -> str: + return ( + template.replace("[customer]", customer_name or "there") + .replace("[ticket]", display_number) + ) + + +class TicketService: + """Ticket lifecycle. Every mutation writes a TicketActivity row — the + activity table is the audit trail, never bypass it.""" + + def __init__(self, db: Session): + self.db = db + self.repo = TicketRepository(db) + self.activity_repo = TicketActivityRepository(db) + self.settings_repo = TicketSettingsRepository(db) + self.run_repo = InvestigationRepository(db) + + # ---------- creation ---------- + + def create_ticket( + self, + organization_id: UUID, + title: str, + description: Optional[str] = None, + priority: str = TicketPriority.MEDIUM, + severity: Optional[int] = None, + tags: Optional[List[str]] = None, + source: str = TicketSource.MANUAL, + customer_id: Optional[UUID] = None, + customer_email: Optional[str] = None, + customer_name: Optional[str] = None, + session_id: Optional[UUID] = None, + agent_id: Optional[UUID] = None, + assignee_user_id: Optional[UUID] = None, + group_id: Optional[UUID] = None, + created_by_user_id: Optional[UUID] = None, + link_session_customer: bool = True, + ) -> Tuple[Ticket, List[Tuple[Ticket, float]]]: + """Create a ticket, link its session, mirror the legacy session + columns, and enqueue triage. Returns (ticket, possible_duplicates). + Caller commits. + + customer_email (manual tickets): resolved to an existing customer in + the org or a new record — the direct-email notification path. + + link_session_customer=False keeps the session's customer off the + ticket: a widget visitor bound to a customer by an email they only + typed must not hand the investigator that customer's identity.""" + settings = self.settings_repo.get_or_create(organization_id) + + if customer_id is None and customer_email: + customer_id = self._resolve_customer_by_email( + organization_id, customer_email, customer_name + ) + + session_record = None + if session_id is not None: + session_record = SessionToAgentRepository(self.db).get_session(session_id) + # Cross-org guard: linking mutates the session row and messages its + # customer, so a foreign session must be rejected, not ignored. + if session_record is None or str(session_record.organization_id) != str(organization_id): + raise ValueError("Session not found in this organization") + if customer_id is None and link_session_customer: + customer_id = session_record.customer_id + if agent_id is None: + agent_id = session_record.agent_id + + embedding = embed_ticket_text(title, description) + + ticket = Ticket( + organization_id=organization_id, + customer_id=customer_id, + title=title, + description=description, + priority=priority, + severity=severity, + tags=tags, + source=source, + status=TicketStatus.OPEN, + agent_id=agent_id, + assignee_user_id=assignee_user_id, + group_id=group_id, + created_by_user_id=created_by_user_id, + embedding=embedding, + ) + self.repo.create(ticket) + + possible_duplicates: List[Tuple[Ticket, float]] = [] + if embedding is not None: + possible_duplicates = self.repo.find_similar( + organization_id, + embedding, + limit=3, + exclude_ticket_id=ticket.id, + only_open=True, + min_similarity=DUPLICATE_SIMILARITY_THRESHOLD, + ) + + actor_type = TicketActorType.AI if source == TicketSource.CHAT_AI else ( + TicketActorType.USER if created_by_user_id else TicketActorType.SYSTEM + ) + self._add_activity( + ticket, + TicketActivityType.STATUS_CHANGE, + actor_type=actor_type, + actor_user_id=created_by_user_id, + body=( + f"Ticket created from conversation" if session_id else "Ticket created" + ), + metadata={"to": TicketStatus.OPEN.value, "source": source}, + ) + + if session_record is not None: + self.repo.link_session(ticket.id, session_record.session_id) + self._mirror_session_columns(session_record, ticket) + + if settings.auto_investigate_on_create: + self.enqueue_run( + ticket, + run_type=InvestigationRunType.TRIAGE, + trigger=InvestigationTrigger.AUTO_ON_CREATE, + settings=settings, + ) + + return ticket, possible_duplicates + + def _mirror_session_columns(self, session_record: SessionToAgent, ticket: Ticket) -> None: + """Keep the deprecated session_to_agents.ticket_* columns in sync so + existing session/widget consumers keep working. Never clobbers an + explicit Jira linkage — JiraTools reads ticket_id as a Jira issue key.""" + if session_record.integration_type not in (None, NATIVE_INTEGRATION_TYPE): + return + try: + session_record.ticket_id = ticket.display_number + session_record.ticket_status = str(ticket.status) + session_record.ticket_summary = ticket.title[:255] + session_record.ticket_description = (ticket.description or "")[:1000] + session_record.integration_type = NATIVE_INTEGRATION_TYPE + session_record.ticket_priority = str(ticket.priority) + self.db.flush() + except Exception as e: + logger.error(f"Failed to mirror ticket columns onto session: {e}") + + def _sync_linked_sessions(self, ticket: Ticket) -> None: + session_repo = SessionToAgentRepository(self.db) + for sid in self.repo.get_session_ids(ticket.id): + record = session_repo.get_session(sid) + if record is not None and record.integration_type == NATIVE_INTEGRATION_TYPE: + record.ticket_status = str(ticket.status) + record.ticket_priority = str(ticket.priority) + self.db.flush() + + async def build_session_transcript( + self, session_id, max_messages: int = 15, line_chars: int = 400 + ) -> Optional[str]: + """Plain-text transcript of a session's recent messages — shared by the + create-modal draft endpoint and the triage context builder.""" + messages = (await ChatRepository(self.db).get_session_history(session_id))[-max_messages:] + lines = [] + for m in messages: + who = "Customer" if m.message_type == "user" else "Agent" + text = (m.message or "").strip() + if text: + lines.append(f"{who}: {text[:line_chars]}") + return "\n".join(lines) or None + + # ---------- lifecycle ---------- + + def transition_status( + self, + ticket: Ticket, + new_status: TicketStatus, + actor_type: str = TicketActorType.USER, + actor_user_id: Optional[UUID] = None, + reason: Optional[str] = None, + ) -> Ticket: + current = TicketStatus(ticket.status) + new_status = TicketStatus(new_status) + if new_status == current: + return ticket + allowed = TICKET_STATUS_TRANSITIONS.get(current, set()) + if new_status not in allowed: + raise ValueError(f"Illegal status transition {current.value} -> {new_status.value}") + + ticket.status = new_status + now = datetime.now(timezone.utc) + if new_status == TicketStatus.RESOLVED and ticket.resolved_at is None: + ticket.resolved_at = now + if ticket.resolved_by_actor is None: + ticket.resolved_by_actor = "ai" if actor_type == TicketActorType.AI else "user" + if new_status == TicketStatus.RESOLVED_PENDING_CONFIRMATION: + ticket.confirmation_requested_at = now + if new_status == TicketStatus.CLOSED: + ticket.closed_at = now + if ticket.resolved_at is None: + ticket.resolved_at = now + if ticket.resolved_by_actor is None: + ticket.resolved_by_actor = "ai" if actor_type == TicketActorType.AI else "user" + + self._add_activity( + ticket, + TicketActivityType.STATUS_CHANGE, + actor_type=actor_type, + actor_user_id=actor_user_id, + body=reason or f"Status changed {current.value} → {new_status.value}", + metadata={"from": current.value, "to": new_status.value}, + ) + self._sync_linked_sessions(ticket) + return ticket + + def assign( + self, + ticket: Ticket, + assignee_user_id: Optional[UUID], + group_id: Optional[UUID], + actor_user_id: Optional[UUID] = None, + ) -> Ticket: + ticket.assignee_user_id = assignee_user_id + ticket.group_id = group_id + self._add_activity( + ticket, + TicketActivityType.ASSIGNMENT, + actor_type=TicketActorType.USER if actor_user_id else TicketActorType.SYSTEM, + actor_user_id=actor_user_id, + metadata={ + "assignee_user_id": str(assignee_user_id) if assignee_user_id else None, + "group_id": str(group_id) if group_id else None, + }, + ) + return ticket + + def set_priority( + self, + ticket: Ticket, + priority: TicketPriority, + actor_type: str = TicketActorType.USER, + actor_user_id: Optional[UUID] = None, + ) -> Ticket: + old = ticket.priority + if str(old) == str(priority): + return ticket + ticket.priority = priority + self._add_activity( + ticket, + TicketActivityType.PRIORITY_CHANGE, + actor_type=actor_type, + actor_user_id=actor_user_id, + metadata={"from": str(old), "to": str(priority)}, + ) + self._sync_linked_sessions(ticket) + return ticket + + def add_comment( + self, + ticket: Ticket, + body: str, + is_internal: bool = True, + actor_type: str = TicketActorType.USER, + actor_user_id: Optional[UUID] = None, + ) -> TicketActivity: + activity = self._add_activity( + ticket, + TicketActivityType.COMMENT, + actor_type=actor_type, + actor_user_id=actor_user_id, + body=body, + is_internal=is_internal, + ) + if ticket.first_response_at is None and actor_type in ( + TicketActorType.USER, TicketActorType.AI + ) and not is_internal: + ticket.first_response_at = datetime.now(timezone.utc) + return activity + + async def resolve( + self, + ticket: Ticket, + outcome: str, + resolution_summary: Optional[str] = None, + customer_message: Optional[str] = None, + actor_type: str = TicketActorType.USER, + actor_user_id: Optional[UUID] = None, + ) -> Ticket: + """Resolve pending customer confirmation. Notifies the customer with + the plain-language message; the lifecycle worker closes it after the + confirmation timeout.""" + # A repeat resolve (double-clicked button, client retry) must not + # re-send the resolution message. transition_status already no-ops when + # the status is unchanged, so key the notification off a real + # transition: only notify when the ticket wasn't already awaiting + # confirmation. Pair with a row lock in the endpoint so concurrent + # resolves serialize and the later ones observe the resolved status. + already_pending = ( + TicketStatus(ticket.status) == TicketStatus.RESOLVED_PENDING_CONFIRMATION + ) + ticket.resolution_outcome = outcome + if resolution_summary: + ticket.resolution_summary = resolution_summary + if customer_message: + ticket.customer_resolution_message = customer_message + ticket.resolved_by_actor = "ai" if actor_type == TicketActorType.AI else "user" + self.transition_status( + ticket, + TicketStatus.RESOLVED_PENDING_CONFIRMATION, + actor_type=actor_type, + actor_user_id=actor_user_id, + ) + if not already_pending: + await self.notify_customer_resolved(ticket) + return ticket + + async def close( + self, + ticket: Ticket, + actor_type: str = TicketActorType.SYSTEM, + actor_user_id: Optional[UUID] = None, + reason: Optional[str] = None, + ) -> Ticket: + self.transition_status( + ticket, TicketStatus.CLOSED, actor_type=actor_type, + actor_user_id=actor_user_id, reason=reason, + ) + settings = self.settings_repo.get_or_create(ticket.organization_id) + if settings.csat_enabled: + await self.request_csat(ticket) + return ticket + + def reopen( + self, + ticket: Ticket, + reason: Optional[str] = None, + actor_type: str = TicketActorType.USER, + actor_user_id: Optional[UUID] = None, + ) -> Ticket: + ticket.reopened_count = (ticket.reopened_count or 0) + 1 + ticket.resolved_at = None + ticket.closed_at = None + ticket.confirmation_requested_at = None + ticket.resolved_by_actor = None + ticket.status = TicketStatus.REOPENED + self._add_activity( + ticket, + TicketActivityType.REOPENED, + actor_type=actor_type, + actor_user_id=actor_user_id, + body=reason, + ) + self._sync_linked_sessions(ticket) + return ticket + + # ---------- AI runs ---------- + + def _investigation_over_budget(self, ticket: Ticket) -> bool: + """True when a metered (hosted-model) org has no message credits left. + Fails open (never blocks) for own-key orgs, unlimited plans, self-host + without the enterprise module, or on any metering error.""" + from app.services.usage_metering import ( + is_hosted_model, + remaining_message_credits, + ) + if not is_hosted_model(self.db, ticket.organization_id): + return False + remaining = remaining_message_credits(self.db, ticket.organization_id) + return remaining is not None and remaining <= 0 + + def enqueue_run( + self, + ticket: Ticket, + run_type: str = InvestigationRunType.TRIAGE, + trigger: str = InvestigationTrigger.MANUAL, + requested_by_user_id: Optional[UUID] = None, + context_note: Optional[str] = None, + settings: Optional[OrganizationTicketSettings] = None, + ) -> Optional[InvestigationRun]: + settings = settings or self.settings_repo.get_or_create(ticket.organization_id) + if run_type == InvestigationRunType.INVESTIGATION: + done = self.run_repo.count_for_ticket(ticket.id, InvestigationRunType.INVESTIGATION) + if done >= settings.max_runs_per_ticket: + logger.info(f"Ticket {ticket.display_number}: max investigation runs reached") + return None + # An investigation makes many metered LLM calls, so a hosted-model + # org that's out of message credits can't start one. Triage (cheap, + # one call) is never gated, so tickets still get classified. + if self._investigation_over_budget(ticket): + self._add_activity( + ticket, + TicketActivityType.STATUS_CHANGE, + actor_type=TicketActorType.SYSTEM, + body=( + "AI investigation paused — the organization is out of " + "message credits for this billing period." + ), + metadata={"reason": "message_limit_reached"}, + ) + logger.info(f"Ticket {ticket.display_number}: investigation blocked, out of credits") + return None + run = self.run_repo.enqueue( + InvestigationRun( + ticket_id=ticket.id, + organization_id=ticket.organization_id, + run_type=run_type, + trigger=trigger, + requested_by_user_id=requested_by_user_id, + context_note=context_note, + max_tool_calls=settings.max_tool_calls_per_run, + ) + ) + return run + + # ---------- autonomy L2: propose / approve ---------- + + def create_proposal( + self, + ticket: Ticket, + run_id: Optional[UUID], + summary: str, + customer_message: Optional[str], + confidence: Optional[float], + ) -> TicketProposal: + """Record an AI-proposed resolution and park the ticket in + awaiting_approval. Any earlier pending proposal is superseded.""" + ( + self.db.query(TicketProposal) + .filter( + TicketProposal.ticket_id == ticket.id, + TicketProposal.status == ProposalStatus.PENDING.value, + ) + .update({TicketProposal.status: ProposalStatus.SUPERSEDED.value}) + ) + proposal = TicketProposal( + ticket_id=ticket.id, + organization_id=ticket.organization_id, + run_id=run_id, + summary=summary, + customer_message=customer_message, + confidence=confidence, + ) + self.db.add(proposal) + self.db.flush() + self._add_activity( + ticket, + TicketActivityType.AI_RESOLUTION_PROPOSED, + actor_type=TicketActorType.AI, + body=summary, + metadata={"proposal_id": str(proposal.id), "confidence": confidence}, + ) + if TicketStatus(str(ticket.status)) in TICKET_STATUS_TRANSITIONS and ( + TicketStatus.AWAITING_APPROVAL + in TICKET_STATUS_TRANSITIONS[TicketStatus(str(ticket.status))] + ): + self.transition_status( + ticket, TicketStatus.AWAITING_APPROVAL, actor_type=TicketActorType.AI + ) + return proposal + + async def approve_proposal( + self, ticket: Ticket, proposal: TicketProposal, user_id: UUID + ) -> Ticket: + """Human approval: the proposed resolution is applied — the ticket + resolves (pending customer confirmation) and the customer gets the + proposal's message. Any infra change stays with humans/runbooks.""" + proposal.status = ProposalStatus.APPROVED + proposal.decided_by_user_id = user_id + proposal.decided_at = datetime.now(timezone.utc) + self._add_activity( + ticket, + TicketActivityType.AI_RESOLUTION_APPROVED, + actor_type=TicketActorType.USER, + actor_user_id=user_id, + body=proposal.summary, + metadata={"proposal_id": str(proposal.id)}, + ) + # The AI resolved it (a human signed off) — counts as AI-resolved. + await self.resolve( + ticket, + outcome=ResolutionOutcome.FIXED, + resolution_summary=proposal.summary, + customer_message=proposal.customer_message, + actor_type=TicketActorType.AI, + ) + return ticket + + def reject_proposal( + self, + ticket: Ticket, + proposal: TicketProposal, + user_id: UUID, + reason: Optional[str], + reinvestigate: bool = False, + ) -> Optional[InvestigationRun]: + """Human rejection. Optionally feeds the reason into a refined + investigation run (bounded by max_runs_per_ticket).""" + proposal.status = ProposalStatus.REJECTED + proposal.decided_by_user_id = user_id + proposal.decided_at = datetime.now(timezone.utc) + proposal.reject_reason = reason + self._add_activity( + ticket, + TicketActivityType.AI_RESOLUTION_REJECTED, + actor_type=TicketActorType.USER, + actor_user_id=user_id, + body=reason, + metadata={"proposal_id": str(proposal.id), "reinvestigate": reinvestigate}, + ) + if str(ticket.status) == TicketStatus.AWAITING_APPROVAL.value: + self.transition_status( + ticket, TicketStatus.OPEN, + actor_type=TicketActorType.USER, actor_user_id=user_id, + reason="Proposal rejected", + ) + if not reinvestigate: + return None + return self.enqueue_run( + ticket, + run_type=InvestigationRunType.INVESTIGATION, + trigger=InvestigationTrigger.REJECTION_FEEDBACK, + requested_by_user_id=user_id, + context_note=reason, + ) + + def pending_proposal(self, ticket_id: UUID) -> Optional[TicketProposal]: + return ( + self.db.query(TicketProposal) + .filter( + TicketProposal.ticket_id == ticket_id, + TicketProposal.status == ProposalStatus.PENDING.value, + ) + .order_by(TicketProposal.created_at.desc()) + .first() + ) + + def latest_proposal(self, ticket_id: UUID) -> Optional[TicketProposal]: + return ( + self.db.query(TicketProposal) + .filter(TicketProposal.ticket_id == ticket_id) + .order_by(TicketProposal.created_at.desc()) + .first() + ) + + # ---------- customer communications ---------- + + async def notify_customer_created( + self, ticket: Ticket, settings: Optional[OrganizationTicketSettings] = None + ) -> None: + settings = settings or self.settings_repo.get_or_create(ticket.organization_id) + template = settings.created_template or DEFAULT_CREATED_TEMPLATE + message = render_customer_template( + template, self._customer_name(ticket), ticket.display_number + ) + await self.send_customer_message(ticket, message) + + async def notify_customer_resolved( + self, ticket: Ticket, settings: Optional[OrganizationTicketSettings] = None + ) -> None: + settings = settings or self.settings_repo.get_or_create(ticket.organization_id) + template = settings.resolved_template or DEFAULT_RESOLVED_TEMPLATE + message = render_customer_template( + template, self._customer_name(ticket), ticket.display_number + ) + if ticket.customer_resolution_message: + message = f"{message}\n\n{ticket.customer_resolution_message}" + await self.send_customer_message(ticket, message) + + async def request_csat(self, ticket: Ticket) -> None: + """Ask the customer to rate the ticket, reusing the widget's existing + conversation rating prompt. + + Conversation-only by design: a rating row is keyed to a session + (ratings.session_id is NOT NULL), so a ticket with no linked + conversation — manual or email-only — has nothing to rate and no + capture path short of a public tokenised rating page. Those tickets + are simply never asked; csat_requested_at stays NULL and the UI shows + "not requested" rather than a CSAT that will never arrive. + """ + session_record = self._primary_session(ticket) + if session_record is None: + return + message = ( + f"How did we do on ticket {ticket.display_number}? " + "Your feedback helps us improve — you can rate this conversation." + ) + # The widget only shows its stars on a message that also ends the chat + # (as the AI's own wrap-up does); without these the customer was asked + # to rate with nothing to rate with. Stored with the same attributes so + # the thread and a reloaded widget show the ask too. + rating_ask = { + "request_rating": True, + "end_chat": True, + "end_chat_reason": "ISSUE_RESOLVED", + } + try: + stored = ChatRepository(self.db).create_message({ + "message": message, + "message_type": "bot", + "session_id": session_record.session_id, + "organization_id": ticket.organization_id, + "agent_id": session_record.agent_id, + "customer_id": session_record.customer_id, + "attributes": {"ticket_number": ticket.display_number, **rating_ask}, + }) + await deliver_to_customer(self.db, session_record, { + "message": message, + "type": "chat_response", + "session_id": str(session_record.session_id), + **rating_ask, + }, message_id=stored.id, source=MessageSource.SYSTEM) + ticket.csat_requested_at = datetime.now(timezone.utc) + self._add_activity( + ticket, TicketActivityType.CSAT_REQUESTED, + actor_type=TicketActorType.SYSTEM, + metadata={"session_id": str(session_record.session_id)}, + ) + except Exception as e: + logger.error(f"CSAT request failed for {ticket.display_number}: {e}") + + def record_csat( + self, ticket: Ticket, rating_id: UUID, score: int, feedback: Optional[str] = None + ) -> Ticket: + """Attach a submitted conversation rating to the ticket. The most + recent rating wins if a customer rates the same conversation twice. + + Re-submitting the same score is ignored: the customer controls how + often this fires, and every write here appends to the audit feed.""" + if ticket.csat_rating_id == rating_id and ticket.csat_score == score: + return ticket + ticket.csat_score = score + ticket.csat_rating_id = rating_id + ticket.csat_responded_at = datetime.now(timezone.utc) + body = f"Customer rated {score}/5" + if feedback: + body = f"{body} — {feedback}" + self._add_activity( + ticket, + TicketActivityType.CSAT_RECEIVED, + actor_type=TicketActorType.CUSTOMER, + body=body, + metadata={"rating_id": str(rating_id), "score": score}, + ) + return ticket + + def _resolve_customer_by_email( + self, organization_id: UUID, customer_email: str, customer_name: Optional[str] + ) -> UUID: + from app.repositories.customer import CustomerRepository + customer = CustomerRepository(self.db).get_or_create_customer( + email=customer_email.strip().lower(), + organization_id=organization_id, + full_name=(customer_name or "").strip() or None, + ) + return customer.id + + def set_customer( + self, + ticket: Ticket, + customer_email: str, + customer_name: Optional[str] = None, + actor_user_id: Optional[UUID] = None, + ) -> Ticket: + """Set/replace the ticket's customer by email (find-or-create).""" + customer_id = self._resolve_customer_by_email( + ticket.organization_id, customer_email, customer_name + ) + if ticket.customer_id == customer_id: + return ticket + ticket.customer_id = customer_id + self._add_activity( + ticket, + TicketActivityType.CUSTOMER_LINKED, + actor_type=TicketActorType.USER if actor_user_id else TicketActorType.SYSTEM, + actor_user_id=actor_user_id, + body=f"Customer set to {customer_email.strip().lower()}", + metadata={"customer_id": str(customer_id)}, + ) + return ticket + + def can_notify_customer(self, ticket: Ticket) -> bool: + """Whether any outbound path to the customer exists: a linked + conversation, or a customer with an email for direct sending.""" + if self.repo.get_session_ids(ticket.id): + return True + return bool(ticket.customer and ticket.customer.email) + + def _scrub_for_customer(self, ticket: Ticket, message: str) -> str: + """Redact identifiers that aren't the recipient's own before delivery.""" + from app.services.ticket_privacy import scrub_outbound + + customer = ticket.customer + owned = (customer.email, customer.phone) if customer else () + scrubbed = scrub_outbound(message, owned) + if scrubbed != message: + logger.warning( + f"Third-party identifiers redacted from an outbound message on " + f"{ticket.display_number}" + ) + return scrubbed or message + + async def send_customer_message( + self, ticket: Ticket, message: str, record_activity: bool = True + ) -> None: + """Deliver a ticket message to the customer: through the linked + conversation's channel (widget socket or email/WhatsApp/... adapter) + when one exists, by direct email otherwise (manual tickets). + record_activity=False when the caller already wrote its own activity + row (customer-visible comments). + + Every outbound path passes through here, so this is where third-party + identifiers are stripped: the investigator reads other customers' + tickets and unscoped database rows, and at autonomy L3 its summary + reaches the customer with no human in between.""" + message = self._scrub_for_customer(ticket, message) + session_record = self._primary_session(ticket) + if session_record is None: + await self._send_direct_email(ticket, message, record_activity) + return + channel = getattr(session_record, "channel", None) or WEB_CHANNEL + chat_repo = ChatRepository(self.db) + stored = delivery = None + try: + # Stored first so the channel's message id lands on this row + stored = chat_repo.create_message({ + "message": message, + "message_type": "bot", + "session_id": session_record.session_id, + "organization_id": ticket.organization_id, + "agent_id": session_record.agent_id, + "customer_id": session_record.customer_id, + "attributes": {"ticket_number": ticket.display_number}, + }) + delivery = await deliver_to_customer(self.db, session_record, { + "message": message, + "type": "chat_response", + "session_id": str(session_record.session_id), + }, message_id=stored.id, source=MessageSource.SYSTEM) + if not delivery.ok: + chat_repo.mark_delivery_failed(stored.id, delivery.reason) + if ticket.first_response_at is None: + ticket.first_response_at = datetime.now(timezone.utc) + if record_activity: + self._add_activity( + ticket, TicketActivityType.CUSTOMER_NOTIFIED, + actor_type=TicketActorType.SYSTEM, + body=message, + metadata={ + "session_id": str(session_record.session_id), + "channel": channel, + }, + ) + except Exception as e: + logger.error(f"Customer notification failed for {ticket.display_number}: {e}") + # Stored but the hand-over itself raised: don't let the thread + # show it as sent + if stored is not None and delivery is None: + chat_repo.mark_delivery_failed(stored.id, "send_failed") + + # Widget (web) sessions are delivered over Socket.IO to the browser tab + # only, but a ticket reply almost always arrives after the visitor has + # closed the chat — so it reaches nobody. When we have a real inbox for + # them, also email the reply through the org's connected email channel + # (their own domain) so async ticket updates actually land. External + # channels (WhatsApp/Telegram/email) already deliver asynchronously via + # their own adapter, so this supplement is web-only. + if channel == WEB_CHANNEL: + to_email = self._deliverable_email(ticket) + if to_email: + await self._email_customer_copy(ticket, message, to_email) + + async def _send_direct_email( + self, ticket: Ticket, message: str, record_activity: bool + ) -> None: + """Email fallback for tickets with no linked conversation.""" + to_email = self._deliverable_email(ticket) + if not to_email: + logger.info( + f"Ticket {ticket.display_number}: no linked session or mailable customer email to notify" + ) + return + if not await self._email_customer_copy(ticket, message, to_email): + return + if ticket.first_response_at is None: + ticket.first_response_at = datetime.now(timezone.utc) + if not record_activity: + return + self._add_activity( + ticket, TicketActivityType.CUSTOMER_NOTIFIED, + actor_type=TicketActorType.SYSTEM, + body=message, + metadata={"channel": "email", "to": to_email}, + ) + + def _deliverable_email(self, ticket: Ticket) -> Optional[str]: + """The customer's real, mailable address, or None for anonymous / + placeholder records: …@noemail.com widget visitors, …@<channel>.channel + identity keys, and generate-token …@<org>.local anonymous stand-ins — + none of which can receive mail.""" + from app.repositories.customer import CustomerRepository + email = ticket.customer.email if ticket.customer else None + if not email or CustomerRepository.is_placeholder_email(email) or email.endswith(".local"): + return None + return email + + async def _email_customer_copy(self, ticket: Ticket, message: str, to_email: str) -> bool: + """Email a ticket reply to a customer through the org's email channel. + The single send point for both the widget (web) supplement and the + no-session fallback. Returns whether the mail was sent. + + Best-effort — send_ticket_email swallows SMTP errors and returns False, + so a mail hiccup never fails the surrounding ticket mutation.""" + from app.services.ticket_email import send_ticket_email + return await send_ticket_email( + self.db, + ticket.organization_id, + to_email, + subject=f"[{ticket.display_number}] {ticket.title}", + body=message, + ticket_id=ticket.id, + in_reply_to=self.last_customer_email_message_id(ticket), + ) + + def last_customer_email_message_id(self, ticket: Ticket) -> Optional[str]: + """Message-ID of the customer's most recent emailed reply, so the next + notification threads under it in their client.""" + for activity in reversed(self.activity_repo.list_for_ticket(ticket.id)): + if activity.activity_type != TicketActivityType.CUSTOMER_REPLIED.value: + continue + message_id = (activity.activity_metadata or {}).get("email_message_id") + if message_id: + return message_id + return None + + def record_customer_email_reply( + self, ticket: Ticket, body: str, from_email: str, message_id: Optional[str] = None + ) -> TicketActivity: + """Append a customer's emailed reply to the ticket feed, reopening it + when the reply lands on a resolution the customer never confirmed. + + Customer-visible (is_internal=False): it is the customer's own words, + and the agent-facing feed is where the team reads them.""" + activity = self._add_activity( + ticket, + TicketActivityType.CUSTOMER_REPLIED, + actor_type=TicketActorType.CUSTOMER, + body=body, + is_internal=False, + metadata={ + "channel": "email", + "from": from_email, + "email_message_id": message_id, + }, + ) + current = TicketStatus(str(ticket.status)) + if current == TicketStatus.RESOLVED_PENDING_CONFIRMATION and ( + TicketStatus.REOPENED in TICKET_STATUS_TRANSITIONS.get(current, set()) + ): + self.reopen( + ticket, + reason="Customer replied by email before confirming the resolution", + actor_type=TicketActorType.CUSTOMER, + ) + return activity + + def _primary_session(self, ticket: Ticket) -> Optional[SessionToAgent]: + session_ids = self.repo.get_session_ids(ticket.id) + if not session_ids: + return None + return SessionToAgentRepository(self.db).get_session(session_ids[0]) + + def _customer_name(self, ticket: Ticket) -> Optional[str]: + try: + return ticket.customer.full_name if ticket.customer else None + except Exception: + return None + + # ---------- derived fields ---------- + + def ai_state(self, ticket: Ticket, has_active_run: Optional[bool] = None) -> str: + """Derived UI field: investigating / awaiting / human / resolved. + Resolution wins over a stale not-yet-processed run.""" + status = str(ticket.status) + resolved_states = ( + TicketStatus.RESOLVED, TicketStatus.CLOSED, + TicketStatus.RESOLVED_PENDING_CONFIRMATION, + ) + if status in resolved_states: + return "resolved" if ticket.resolved_by_actor == "ai" else "human" + if has_active_run is None: + has_active_run = self.run_repo.get_active_for_ticket(ticket.id) is not None + if status in (TicketStatus.TRIAGING, TicketStatus.INVESTIGATING) or has_active_run: + return "investigating" + if status == TicketStatus.AWAITING_APPROVAL: + return "awaiting" + return "human" + + @staticmethod + def _resolution_due( + priority: str, created_at, settings: OrganizationTicketSettings + ) -> Optional[datetime]: + targets = settings.sla_targets or DEFAULT_SLA_TARGETS + target = targets.get(str(priority)) + if not target or not created_at: + return None + if created_at.tzinfo is None: + created_at = created_at.replace(tzinfo=timezone.utc) + return created_at + timedelta(minutes=target["resolution_minutes"]) + + def sla_due_at( + self, ticket: Ticket, settings: Optional[OrganizationTicketSettings] = None + ) -> Optional[datetime]: + """Resolution SLA deadline from the org's per-priority targets.""" + if str(ticket.status) not in OPEN_TICKET_STATUSES: + return None + settings = settings or self.settings_repo.get_or_create(ticket.organization_id) + return self._resolution_due(ticket.priority, ticket.created_at, settings) + + def stats(self, organization_id: UUID) -> dict: + counts = self.repo.count_by_status(organization_id) + open_count = sum(counts.get(s, 0) for s in OPEN_TICKET_STATUSES) + awaiting = counts.get(TicketStatus.AWAITING_APPROVAL.value, 0) + + settings = self.settings_repo.get_or_create(organization_id) + now = datetime.now(timezone.utc) + breaching = 0 + for priority, created_at in self.repo.open_ticket_sla_fields(organization_id): + due = self._resolution_due(priority, created_at, settings) + if due is not None and due <= now + timedelta(minutes=30): + breaching += 1 + + ai_resolved, total_resolved = self.repo.resolved_counts_7d(organization_id) + pct = round(100.0 * ai_resolved / total_resolved, 1) if total_resolved else None + + return { + "open": open_count, + "awaiting_approval": awaiting, + "sla_breaching": breaching, + "ai_resolved_pct_7d": pct, + **self.csat_stats(organization_id), + } + + def csat_stats(self, organization_id: UUID, days: int = CSAT_WINDOW_DAYS) -> dict: + """Overall CSAT plus the AI-resolved vs human-resolved split. A wider + window than the resolution stats — CSAT responses are far sparser, and + a 7-day average would be noise for most orgs.""" + by_resolver = self.repo.csat_by_resolver(organization_id, days=days) + total = sum(count for count, _ in by_resolver.values()) + weighted = sum(count * avg for count, avg in by_resolver.values()) + + def side(actor: str) -> Optional[float]: + entry = by_resolver.get(actor) + return round(entry[1], 2) if entry else None + + return { + "csat_responses": total, + "csat_avg": round(weighted / total, 2) if total else None, + "csat_ai_avg": side("ai"), + "csat_human_avg": side("user"), + "csat_window_days": days, + } + + # ---------- internals ---------- + + def _add_activity( + self, + ticket: Ticket, + activity_type: str, + actor_type: str = TicketActorType.SYSTEM, + actor_user_id: Optional[UUID] = None, + body: Optional[str] = None, + is_internal: bool = True, + metadata: Optional[dict] = None, + ) -> TicketActivity: + return self.activity_repo.add( + TicketActivity( + ticket_id=ticket.id, + activity_type=activity_type, + actor_type=actor_type, + actor_user_id=actor_user_id, + body=body, + is_internal=is_internal, + activity_metadata=metadata, + ) + ) diff --git a/backend/app/services/ticket_access.py b/backend/app/services/ticket_access.py new file mode 100644 index 0000000..c5f9538 --- /dev/null +++ b/backend/app/services/ticket_access.py @@ -0,0 +1,43 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +AI-ticketing plan gating: paid-plan feature on cloud, unrestricted for +OSS/self-hosted. Thin wrappers over the generic feature gate (the +help-center pattern). +""" + +from uuid import UUID + +from sqlalchemy.orm import Session + +from app.services.feature_gate import check_feature_access, feature_allowed + +AI_TICKETING_FEATURE = "ai_ticketing" + +UPGRADE_MESSAGE = ( + "AI Ticketing is not available in your current plan. " + "Please upgrade to access this feature." +) + + +def ticketing_allowed(db: Session, organization_id: UUID) -> bool: + """Non-raising check — used by the chat-agent tool assembly and workers. + Fails closed on gating errors.""" + return feature_allowed(db, organization_id, AI_TICKETING_FEATURE) + + +def check_ticketing_access(db: Session, organization_id: UUID) -> None: + """Raising check for admin endpoints (403 with the upgrade message).""" + check_feature_access(db, organization_id, AI_TICKETING_FEATURE, UPGRADE_MESSAGE) diff --git a/backend/app/services/ticket_csat.py b/backend/app/services/ticket_csat.py new file mode 100644 index 0000000..d27fa0c --- /dev/null +++ b/backend/app/services/ticket_csat.py @@ -0,0 +1,67 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +Capture side of the ticket CSAT loop. + +TicketService.close() asks the customer for a rating through the widget's +existing conversation rating prompt. This module carries the answer back: every +path that creates a Rating calls record_conversation_rating(), which attaches +the score to the ticket linked to that session, if there is one. + +Deliberately conversation-only. ratings.session_id is NOT NULL, so a rating +cannot exist without a chat session — a manual or email-only ticket has nothing +to rate. Supporting those would mean a public tokenised rating page and a +session-less Rating; until that exists, such tickets are never asked and their +CSAT card reads "not requested". + +Best-effort throughout: a rating is a customer-facing success the moment it is +stored, so a failure to link it must never fail the submission. +""" + +from typing import Optional +from uuid import UUID + +from sqlalchemy.orm import Session + +from app.core.logger import get_logger +from app.models.rating import Rating +from app.services.ticket import TicketService + +logger = get_logger(__name__) + + +def record_conversation_rating(db: Session, rating: Rating) -> Optional[UUID]: + """Link a just-submitted conversation rating to its ticket. + + Returns the ticket id it was attached to, or None when the session has no + native ticket (the common case for plain chats) or the linkage failed. + """ + try: + service = TicketService(db) + ticket = service.repo.get_by_session(rating.session_id) + if ticket is None: + return None + service.record_csat(ticket, rating.id, rating.rating, rating.feedback) + db.commit() + return ticket.id + except Exception as e: + # The rating row itself is already committed by the caller, so rolling + # back here only discards the half-applied ticket update. + logger.error(f"Failed to link rating {rating.id} to its ticket: {e}") + try: + db.rollback() + except Exception: + pass + return None diff --git a/backend/app/services/ticket_email.py b/backend/app/services/ticket_email.py new file mode 100644 index 0000000..f813b79 --- /dev/null +++ b/backend/app/services/ticket_email.py @@ -0,0 +1,181 @@ +""" +Copyright 2024-2026 ChatterMate + +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 = "chattermate.chat" + + +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 diff --git a/backend/app/services/ticket_email_reply.py b/backend/app/services/ticket_email_reply.py new file mode 100644 index 0000000..f00aa3b --- /dev/null +++ b/backend/app/services/ticket_email_reply.py @@ -0,0 +1,114 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +Inbound side of ticket email threading: decide whether a mail arriving on the +email channel is a reply to one of our ticket notifications, and append it to +that ticket instead of feeding it to the chat agent. +""" + +from typing import Optional +from uuid import UUID + +from sqlalchemy.orm import Session + +from app.channels.base import InboundMessage +from app.core.logger import get_logger +from app.database import SessionLocal +from app.models.ticket import Ticket +from app.repositories.ticket import TicketRepository +from app.services.ticket_email import ( + ticket_ids_from_references, + ticket_number_from_subject, +) + +logger = get_logger(__name__) + + +def find_ticket_for_reply( + db: Session, organization_id: UUID, profile: Optional[dict] +) -> Optional[Ticket]: + """The ticket an inbound email is replying to, or None. + + Headers first: In-Reply-To/References carry a Message-ID we minted, and + the ticket id is inside it — unguessable, so a match is proof the mail + descends from a notification we sent. + + The [TKT-n] subject token is the fallback for clients that drop threading + headers. It is guessable, and a ticket's feed is read by agents and by the + investigator AI, so that path additionally requires the sender to be the + ticket's own customer; anything else falls through to the chat pipeline. + """ + profile = profile or {} + repo = TicketRepository(db) + + for ticket_id in ticket_ids_from_references( + profile.get("in_reply_to"), + profile.get("references"), + ): + ticket = repo.get_by_id(ticket_id, organization_id) + if ticket is not None: + return ticket + + number = ticket_number_from_subject(profile.get("subject")) + if number is None: + return None + ticket = repo.get_by_number(organization_id, number) + if ticket is None: + return None + sender = (profile.get("email") or "").strip().lower() + customer_email = (getattr(ticket.customer, "email", "") or "").strip().lower() + if sender and sender == customer_email: + return ticket + logger.info( + f"Subject token named {ticket.display_number} but {sender or 'an unknown sender'} " + "is not its customer — handling as a normal message" + ) + return None + + +async def record_ticket_email_reply( + organization_id: UUID, ticket_id: UUID, inbound: InboundMessage +) -> None: + """Append an inbound email to its ticket. Runs from a BackgroundTask with + its own DB session, mirroring process_channel_message.""" + db = SessionLocal() + try: + ticket = TicketRepository(db).get_by_id(ticket_id, organization_id) + if ticket is None: + logger.warning(f"Email reply for unknown ticket {ticket_id}") + return + + from app.services.ticket import TicketService + service = TicketService(db) + profile = inbound.profile or {} + service.record_customer_email_reply( + ticket, + body=inbound.text or "", + from_email=inbound.external_user_id, + message_id=profile.get("inbound_message_id") or None, + ) + db.commit() + db.refresh(ticket) + + from app.services.ticket_events import emit_ticket_update + await emit_ticket_update( + organization_id, ticket.id, "comment", {"status": str(ticket.status)} + ) + logger.info(f"Email reply appended to {ticket.display_number}") + except Exception as e: + db.rollback() + logger.error(f"Failed to record email reply on ticket {ticket_id}: {e}") + finally: + db.close() diff --git a/backend/app/services/ticket_events.py b/backend/app/services/ticket_events.py new file mode 100644 index 0000000..4c79399 --- /dev/null +++ b/backend/app/services/ticket_events.py @@ -0,0 +1,55 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +Live ticket updates to the dashboard. Emitted into the per-org ticket room on +the /agent namespace after a mutation commits; the frontend keeps a poll +fallback, so a missed frame is never fatal. +""" + +from typing import Optional +from uuid import UUID + +from app.core.logger import get_logger +from app.core.socketio import sio + +logger = get_logger(__name__) + +ORG_TICKET_ROOM_PREFIX = "org_tickets_" + + +def org_ticket_room(organization_id) -> str: + return f"{ORG_TICKET_ROOM_PREFIX}{organization_id}" + + +async def emit_ticket_update( + organization_id: UUID, + ticket_id: UUID, + kind: str, + payload: Optional[dict] = None, +) -> None: + """kind: 'created' | 'status' | 'triage' | 'comment' | 'run' | 'updated'.""" + try: + await sio.emit( + "ticket_update", + { + "ticket_id": str(ticket_id), + "kind": kind, + "payload": payload or {}, + }, + room=org_ticket_room(organization_id), + namespace="/agent", + ) + except Exception as e: + logger.error(f"ticket_update emit failed for {ticket_id}: {e}") diff --git a/backend/app/services/ticket_investigation.py b/backend/app/services/ticket_investigation.py new file mode 100644 index 0000000..ad50ec1 --- /dev/null +++ b/backend/app/services/ticket_investigation.py @@ -0,0 +1,349 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +Phase 3 investigation engine: hypothesis-driven root-cause analysis with every +tool call captured as a first-class InvestigationEvent row (the glass box). +Orchestrated here, executed by the ticket_investigator worker. +""" + +import asyncio +import json +import time +from typing import List, Optional, Tuple +from uuid import UUID + +from app.core.logger import get_logger +from app.database import SessionLocal +from app.models.investigation import ( + HypothesisStatus, + InvestigationEvent, + InvestigationEventType, + InvestigationHypothesis, + InvestigationRun, + InvestigationRunStatus, + RCADocument, +) +from app.models.ticket import Ticket +from app.utils.guardrails import GuardrailAction, PIIDetector + +logger = get_logger(__name__) + +# Per-hypothesis tool budget; the run-level max_tool_calls caps the total. +HYPOTHESIS_TOOL_CALL_LIMIT = 6 +# Truncation applied at capture time — raw payloads never land in the DB. +MAX_EVENT_INPUT_CHARS = 2000 +MAX_EVENT_RESULT_CHARS = 4000 +# Bounded time for the final RCA synthesis (outside the main wall clock so a +# timed-out investigation can still produce a partial RCA). +RCA_SYNTHESIS_TIMEOUT_SECONDS = 180 + +VALID_VERDICT_STATUSES = { + HypothesisStatus.VALIDATED.value, + HypothesisStatus.INVALIDATED.value, + HypothesisStatus.INCONCLUSIVE.value, +} + + +def redact_snippet(text: str, max_chars: int) -> str: + """PII-redact and truncate a tool payload before it is persisted.""" + if not text: + return "" + result = PIIDetector.detect(text[: max_chars * 2], action=GuardrailAction.REDACT) + redacted = result.redacted_text if result.redacted_text is not None else text + redacted = redacted[:max_chars] + return redacted + + +class EvidenceRecorder: + """Writes InvestigationEvent rows as the investigator works. + + Each write uses its own short-lived session so evidence is visible live + (the glass-box UI polls during active runs) and survives a failed run. + Also the run's tool-call meter: the budget check reads self.tool_calls. + """ + + def __init__(self, run_id: UUID, ticket_id: UUID): + self.run_id = run_id + self.ticket_id = ticket_id + self.seq = 0 + self.tool_calls = 0 + self.hypothesis_id: Optional[UUID] = None + # Per-hypothesis tool-call outcomes: {hypothesis_id: [total, errored]}. + # A verdict is only worth as much as the evidence under it, and a run + # where every query failed must not be able to assert a fact (#318). + self.calls_by_hypothesis: dict = {} + # Run-level totals, for the dashboard's connector banner. + self.failed_tool_calls = 0 + # tool function name -> configured connector (MCP server) name. + self.connector_by_function: dict = {} + # In-memory digest for RCA synthesis, mirroring the persisted rows. + self.evidence_lines: List[str] = [] + + def map_connectors(self, mcp_tools: list) -> None: + for tool in mcp_tools or []: + connector = getattr(tool, "_connector_name", None) or "MCP" + for function_name in getattr(tool, "functions", {}) or {}: + self.connector_by_function[function_name] = connector + + def _write(self, **fields) -> None: + self.seq += 1 + try: + with SessionLocal() as db: + db.add( + InvestigationEvent( + run_id=self.run_id, + ticket_id=self.ticket_id, + hypothesis_id=self.hypothesis_id, + seq=self.seq, + **fields, + ) + ) + db.commit() + except Exception as e: + logger.error(f"Failed to record investigation event: {e}") + + def record_phase(self, label: str) -> None: + """Phase marker — powers the live ticker line in the UI.""" + self._write(event_type=InvestigationEventType.PHASE, label=label[:300]) + + async def tool_hook(self, function_name: str, function, arguments: dict): + """agno tool hook: times the call and captures redacted input/result. + Registered on the hypothesis-testing agents only.""" + # The Groq structured-output capture tool is plumbing, not evidence. + if function_name == "json": + return await function(**arguments) + started = time.monotonic() + error: Optional[str] = None + result = None + try: + result = await function(**arguments) + return result + except Exception as e: + error = str(e) + raise + finally: + self.tool_calls += 1 + if error: + self.failed_tool_calls += 1 + if self.hypothesis_id is not None: + tally = self.calls_by_hypothesis.setdefault(self.hypothesis_id, [0, 0]) + tally[0] += 1 + if error: + tally[1] += 1 + duration_ms = int((time.monotonic() - started) * 1000) + try: + input_text = json.dumps(arguments, default=str) + except Exception: + input_text = str(arguments) + input_snippet = redact_snippet(input_text, MAX_EVENT_INPUT_CHARS) + result_snippet = redact_snippet(str(result), MAX_EVENT_RESULT_CHARS) if result is not None else None + self._write( + event_type=InvestigationEventType.TOOL_CALL, + tool_name=function_name[:200], + connector_name=self.connector_by_function.get(function_name, "MCP")[:200], + tool_input=input_snippet, + tool_result=result_snippet, + duration_ms=duration_ms, + error=error[:2000] if error else None, + ) + self.evidence_lines.append( + f"[E{self.seq}] {function_name}({input_snippet[:300]}) -> " + + (f"ERROR: {error[:200]}" if error else (result_snippet or "")[:500]) + ) + + + def tool_outcomes(self, hypothesis_id: UUID) -> tuple: + """(total, errored) tool calls made while testing this hypothesis.""" + total, errored = self.calls_by_hypothesis.get(hypothesis_id, [0, 0]) + return total, errored + + +def enforce_evidence_backed_verdict( + hypothesis: InvestigationHypothesis, recorder: "EvidenceRecorder" +) -> bool: + """Downgrade a definite verdict that no successful query supports. + + The model returns status and confidence as free text, and nothing else + checks them against what actually happened. A run whose every search + errored — a missing index, a rejected client version — can still come back + "the record does not exist, validated, 0.90", which a support agent would + reasonably act on. Tool errors are the absence of evidence, never evidence + of absence. + + Only fires when the phase made tool calls and every one of them failed; + a hypothesis reasoned from ticket context alone is left as the model + returned it. Returns True when it changed the verdict. + """ + total, errored = recorder.tool_outcomes(hypothesis.id) + if not total or errored < total: + return False + if str(hypothesis.status) == HypothesisStatus.INCONCLUSIVE.value: + return False + + original = str(hypothesis.status) + confidence = hypothesis.confidence + hypothesis.status = HypothesisStatus.INCONCLUSIVE + hypothesis.confidence = 0.0 + # The model's own conclusion is dropped rather than kept: it reads as a + # finding, and the RCA writer would quote it as one. + hypothesis.conclusion = ( + f"Not tested — all {total} tool call{'' if total == 1 else 's'} in this phase " + f"failed, so no evidence was gathered. The tester reported " + f"\"{original}\"{f' at {confidence:.2f} confidence' if confidence is not None else ''}, " + "which nothing supports. Check the connector errors in the evidence log." + ) + return True + + +def hypotheses_digest(hypotheses: List[InvestigationHypothesis]) -> str: + lines = [] + for h in hypotheses: + lines.append( + f"H{h.idx}: {h.title} — {h.status} (confidence {h.confidence if h.confidence is not None else 'n/a'})\n" + f" {h.conclusion or h.rationale or ''}" + ) + return "\n".join(lines) + + +async def run_investigation_phases( + db, + run: InvestigationRun, + ticket: Ticket, + agent, + context_message: str, + mcp_tools: list, + recorder: EvidenceRecorder, +) -> Tuple[List[InvestigationHypothesis], bool]: + """Generate hypotheses, then test each within the run's tool budget. + Mutates hypothesis rows (committed per step so the UI streams progress). + Returns (hypotheses, budget_exhausted).""" + recorder.record_phase("Generating hypotheses") + plan = await agent.generate_hypotheses(context_message) + if plan is None or not plan.hypotheses: + raise RuntimeError("Hypothesis generation produced no result") + + hypotheses: List[InvestigationHypothesis] = [] + for i, spec in enumerate(plan.hypotheses, start=1): + hypothesis = InvestigationHypothesis( + run_id=run.id, + ticket_id=ticket.id, + idx=i, + title=spec.title[:300], + rationale=spec.rationale, + status=HypothesisStatus.PENDING, + ) + db.add(hypothesis) + hypotheses.append(hypothesis) + db.commit() + + budget_exhausted = False + max_tool_calls = run.max_tool_calls if run.max_tool_calls is not None else 25 + for hypothesis in hypotheses: + remaining_budget = max(0, max_tool_calls - recorder.tool_calls) + if remaining_budget <= 0 and mcp_tools: + budget_exhausted = True + hypothesis.status = HypothesisStatus.INCONCLUSIVE + hypothesis.confidence = 0.0 + hypothesis.conclusion = "Not tested — the run's tool-call budget was exhausted." + db.commit() + continue + + recorder.hypothesis_id = hypothesis.id + recorder.record_phase(f"Testing H{hypothesis.idx}: {hypothesis.title}") + hypothesis.status = HypothesisStatus.TESTING + db.commit() + + verdict = await agent.test_hypothesis( + context_message, + hypothesis.title, + hypothesis.rationale, + mcp_tools=mcp_tools, + tool_call_limit=min(HYPOTHESIS_TOOL_CALL_LIMIT, remaining_budget) if mcp_tools else None, + tool_hooks=[recorder.tool_hook], + ) + if verdict is not None and verdict.status in VALID_VERDICT_STATUSES: + hypothesis.status = verdict.status + hypothesis.confidence = max(0.0, min(1.0, verdict.confidence)) + hypothesis.conclusion = verdict.conclusion + if enforce_evidence_backed_verdict(hypothesis, recorder): + logger.warning( + f"H{hypothesis.idx} claimed '{verdict.status}' with every tool " + f"call failing; forced to inconclusive" + ) + else: + hypothesis.status = HypothesisStatus.INCONCLUSIVE + hypothesis.confidence = 0.0 + # A provider that refused the request is a hard, deterministic + # failure — saying "no parseable verdict" for it reads as a model + # quality problem and hides a fixable connector issue (#303). + hypothesis.conclusion = ( + getattr(agent, "last_provider_error", None) + or "The tester produced no parseable verdict." + ) + db.commit() + recorder.hypothesis_id = None + return hypotheses, budget_exhausted + + +async def synthesize_and_store_rca( + db, + run: InvestigationRun, + ticket: Ticket, + agent, + context_message: str, + hypotheses: List[InvestigationHypothesis], + recorder: EvidenceRecorder, + partial: bool, +) -> Optional[RCADocument]: + """Write the versioned RCA document from whatever evidence exists.""" + from app.repositories.ticket import InvestigationRepository + + recorder.record_phase("Writing root-cause analysis") + try: + result = await asyncio.wait_for( + agent.synthesize_rca( + context_message, + hypotheses_digest(hypotheses), + "\n".join(recorder.evidence_lines[-60:]), + partial=partial, + ), + timeout=RCA_SYNTHESIS_TIMEOUT_SECONDS, + ) + except asyncio.TimeoutError: + logger.error(f"RCA synthesis timed out for {ticket.display_number}") + return None + if result is None: + return None + + rca = RCADocument( + ticket_id=ticket.id, + run_id=run.id, + version=InvestigationRepository(db).next_rca_version(ticket.id), + summary=result.summary, + impact=result.impact, + timeline=[entry.model_dump() for entry in result.timeline], + investigation_log=result.investigation_log, + contributing_factors=result.contributing_factors, + conclusion=result.conclusion, + remediation=result.remediation, + prevention=result.prevention, + customer_summary=result.customer_summary, + confidence=max(0.0, min(1.0, result.confidence)), + is_partial=partial, + generated_by="ai", + ) + db.add(rca) + db.commit() + return rca diff --git a/backend/app/services/ticket_jira.py b/backend/app/services/ticket_jira.py new file mode 100644 index 0000000..ea2b11d --- /dev/null +++ b/backend/app/services/ticket_jira.py @@ -0,0 +1,134 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +One-way Jira escalation for native tickets: when enabled, tickets at or +above the configured priority get a mirrored Jira issue (external_ref_*). +Native ChatterMate stays the source of truth — nothing syncs back. +""" + +from typing import Optional + +from app.core.logger import get_logger +from app.models.jira import AgentJiraConfig, JiraToken +from app.models.organization import Organization +from app.models.ticket import Ticket +from app.models.ticket_activity import TicketActivityType, TicketActorType + +logger = get_logger(__name__) + +_PRIORITY_ORDER = {"urgent": 0, "high": 1, "medium": 2, "low": 3} + + +def _find_project_config(db, ticket: Ticket) -> Optional[AgentJiraConfig]: + """Project/issue-type to escalate into: the ticket's agent's Jira config + when enabled, else any enabled agent config in the organization.""" + from app.models.agent import Agent + + if ticket.agent_id is not None: + config = ( + db.query(AgentJiraConfig) + .filter( + AgentJiraConfig.agent_id == str(ticket.agent_id), + AgentJiraConfig.enabled == True, # noqa: E712 + AgentJiraConfig.project_key.isnot(None), + ) + .first() + ) + if config is not None: + return config + return ( + db.query(AgentJiraConfig) + .join(Agent, Agent.id == AgentJiraConfig.agent_id.cast(Agent.id.type)) + .filter( + Agent.organization_id == ticket.organization_id, + AgentJiraConfig.enabled == True, # noqa: E712 + AgentJiraConfig.project_key.isnot(None), + ) + .first() + ) + + +def should_escalate(ticket: Ticket, settings_row) -> bool: + if not settings_row.jira_escalation_enabled: + return False + if ticket.external_ref_id: # already escalated — one-way, never re-synced + return False + minimum = str(settings_row.jira_escalation_priority or "urgent") + return _PRIORITY_ORDER.get(str(ticket.priority), 99) <= _PRIORITY_ORDER.get(minimum, 0) + + +async def maybe_escalate_to_jira(db, service, ticket: Ticket, settings_row) -> bool: + """Escalate when policy matches and the org has Jira connected. Failures + are logged, never raised — escalation must not break ticket flows.""" + if not should_escalate(ticket, settings_row): + return False + try: + token = ( + db.query(JiraToken) + .filter(JiraToken.organization_id == ticket.organization_id) + .first() + ) + if token is None: + return False + config = _find_project_config(db, ticket) + if config is None: + logger.info( + f"{ticket.display_number}: Jira escalation enabled but no agent " + "has a Jira project configured" + ) + return False + + from app.api.jira import CreateJiraIssueModel + from app.services.jira import JiraService + + description_parts = [ + f"Escalated from ChatterMate ticket {ticket.display_number} " + f"(priority: {ticket.priority}, severity: {ticket.severity or 'n/a'}).", + ] + if ticket.ai_summary: + description_parts.append(f"AI summary: {ticket.ai_summary}") + if ticket.description: + description_parts.append(ticket.description[:4000]) + + organization = db.query(Organization).filter( + Organization.id == ticket.organization_id + ).first() + result = await JiraService().create_issue( + organization, + db, + CreateJiraIssueModel( + projectKey=config.project_key, + issueTypeId=config.issue_type_id, + summary=f"[{ticket.display_number}] {ticket.title}"[:250], + description="\n\n".join(description_parts), + ), + ) + issue_key = result.get("key") + if not issue_key: + return False + ticket.external_ref_type = "jira" + ticket.external_ref_id = issue_key + ticket.external_ref_url = f"{token.site_url}/browse/{issue_key}" + service._add_activity( + ticket, + TicketActivityType.JIRA_ESCALATED, + actor_type=TicketActorType.SYSTEM, + body=f"Escalated to Jira as {issue_key}", + metadata={"issue_key": issue_key, "url": ticket.external_ref_url}, + ) + return True + except Exception as e: + logger.error(f"Jira escalation failed for {ticket.display_number}: {e}") + return False diff --git a/backend/app/services/ticket_privacy.py b/backend/app/services/ticket_privacy.py new file mode 100644 index 0000000..7430eeb --- /dev/null +++ b/backend/app/services/ticket_privacy.py @@ -0,0 +1,117 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +Cross-customer privacy guards for AI ticketing. + +The investigator reasons over material that belongs to people other than the +person it is writing to: similar past tickets from the same org, and rows from +a connected database that has no row-level scoping. The same LLM call that +reads all of that also writes `customer_summary`, which is delivered to the +customer — at autonomy L3 with no human in the loop. Prompt wording alone can't +be the control on that, so these are deterministic, applied at the boundary. +""" + +import re +from typing import Iterable, Optional, Set + +from app.core.logger import get_logger +from app.utils.guardrails import GuardrailAction, PIIDetector + +logger = get_logger(__name__) + +# Identifiers that must never appear in a message we send out, no matter whose +# they are — a support summary has no legitimate reason to restate them. +ALWAYS_REDACT = ("ssn", "credit_card", "date_of_birth") + +# Identifiers that are fine to echo back to their *owner* but not to anyone +# else. Kept when they match the recipient, redacted otherwise. +OWNER_ONLY = ("email", "phone") + +THIRD_PARTY_MARKER = "[redacted]" + + +def _digits(value: str) -> str: + return re.sub(r"\D", "", value or "") + + +def _owner_keys(identifiers: Iterable[Optional[str]]) -> Set[str]: + """Normalized forms of the recipient's own identifiers, so a match is not + defeated by case or phone formatting.""" + keys: Set[str] = set() + for raw in identifiers: + value = (raw or "").strip() + if not value: + continue + keys.add(value.lower()) + digits = _digits(value) + # 7 digits filters out short numerics (order counts, amounts) that + # would otherwise whitelist themselves as if they were the phone. + if len(digits) >= 7: + keys.add(digits) + return keys + + +def scrub_outbound(text: Optional[str], owner_identifiers: Iterable[Optional[str]] = ()) -> Optional[str]: + """Strip third-party identifiers from a message bound for the customer. + + The recipient's own email/phone survive — telling someone their own address + is not a leak, and blanking it makes legitimate summaries read as broken. + Everything else that looks like an identifier is redacted, because in a + ticket summary it can only have come from another customer's record. + """ + if not text: + return text + + owners = _owner_keys(owner_identifiers) + scrubbed = text + + for pii_type in ALWAYS_REDACT: + pattern = PIIDetector.PATTERNS.get(pii_type) + if pattern: + scrubbed = re.sub(pattern, THIRD_PARTY_MARKER, scrubbed) + + for pii_type in OWNER_ONLY: + pattern = PIIDetector.PATTERNS.get(pii_type) + if not pattern: + continue + + def _replace(match: re.Match) -> str: + value = match.group() + if value.lower() in owners or (_digits(value) and _digits(value) in owners): + return value + return THIRD_PARTY_MARKER + + scrubbed = re.sub(pattern, _replace, scrubbed) + + if scrubbed != text: + logger.warning( + "Redacted third-party identifiers from an outbound ticket message" + ) + return scrubbed + + +def redact_reference_text(text: Optional[str]) -> Optional[str]: + """Strip identifiers from another customer's material before it becomes + model context. + + Similar past tickets are included to convey *what broke and how it was + fixed*; the names, addresses and numbers in their titles carry none of that + signal and are the most likely thing to be echoed into a summary written + for a different person. + """ + if not text: + return text + result = PIIDetector.detect(text, action=GuardrailAction.REDACT) + return result.redacted_text or text diff --git a/backend/app/services/whatsapp_outbound.py b/backend/app/services/whatsapp_outbound.py new file mode 100644 index 0000000..4d79897 --- /dev/null +++ b/backend/app/services/whatsapp_outbound.py @@ -0,0 +1,319 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import re +import uuid +from dataclasses import dataclass +from typing import Optional +from uuid import UUID + +from sqlalchemy.orm import Session + +from app.channels import get_adapter +from app.channels.app_hooks import OutboundRecord, emit_channel_event, outbound_event +from app.channels.constants import DEFAULT_TEMPLATE_LANGUAGE, MessageSource +from app.services.channel_chat import placeholder_name +from app.services.business_message import remember_business_message +from app.services.channel_status import record_sent +from app.services.chat_notifications import team_not_yet_notified +from app.channels.meta_base import fetch_message_templates, graph_auth_refused, graph_detail +from app.core.logger import get_logger +from app.models.channels import ChannelAccount +from app.repositories.channels import ( + AgentChannelConfigRepository, + ChannelAccountRepository, + ChannelConversationRepository, +) +from app.repositories.chat import ChatRepository +from app.repositories.customer import CustomerRepository +from app.repositories.session_to_agent import SessionToAgentRepository +from app.utils.phone import normalize_phone, to_wa_id + +logger = get_logger(__name__) + +# Business-initiated conversations are Utility/Authentication only. Marketing +# is deliberately unsupported outbound: it carries opt-in obligations and +# per-user caps (Graph error 131049) that a support product should not walk +# its customers into. The reopen flow (an existing conversation) is unaffected. +OUTBOUND_CATEGORIES = {"UTILITY", "AUTHENTICATION"} + +_PLACEHOLDER = re.compile(r"\{\{(\d+)\}\}") + + +@dataclass +class OutboundSent: + """A started (or rejoined) conversation and the template that opened it.""" + session_id: UUID + message_id: int + external_message_id: Optional[str] + + +class OutboundError(Exception): + """A refused or failed outbound send. Carries the HTTP status the API + route should answer with, so the Phase-2 scheduler can reuse the service + without importing anything web-shaped.""" + + def __init__(self, status_code: int, detail: str): + super().__init__(detail) + self.status_code = status_code + self.detail = detail + + +def _render_body(template: dict, components: Optional[list]) -> str: + """The message text as the customer will read it. + + Graph matches body parameters to {{n}} placeholders positionally in + ascending order (the same rule the frontend's previewTemplate applies — + keep the two honest with each other). Falls back to the raw body when a + parameter is missing rather than failing the send: Meta is the authority + on completeness and will reject a genuinely short send itself. + """ + body = next((c.get("text", "") for c in template.get("components") or [] + if str(c.get("type", "")).upper() == "BODY"), "") + values = [ + str(parameter.get("text", "")) + for component in components or [] + if str(component.get("type", "")).lower() == "body" + for parameter in component.get("parameters") or [] + ] + return _PLACEHOLDER.sub( + lambda match: values[int(match.group(1)) - 1] + if 0 < int(match.group(1)) <= len(values) else match.group(0), + body, + ) + + +async def _approved_outbound_template(db: Session, account: ChannelAccount, + name: str, language: str, + *, starts_conversation: bool) -> dict: + """The template this send is allowed to use, or the reason it isn't. + + `starts_conversation` decides whether the Utility/Authentication rule + applies. It is a rule about business-INITIATED contact, so it belongs only + to a send that opens a thread with someone who never wrote to us. Reaching + an existing conversation is the reopen case: the customer did message us, + which is why Meta accepts Marketing there — and why the inbox picker has + never been category-gated. + """ + repo = ChannelAccountRepository(db) + credentials = repo.get_credentials(account) + waba_id = credentials.get("waba_id") + if not waba_id: + raise OutboundError(400, "Reconnect this number with its WhatsApp " + "Business Account ID to send templates") + + ok, data = await fetch_message_templates(waba_id, credentials["access_token"]) + if not ok: + if graph_auth_refused(data): + repo.record_auth_failure(account, graph_detail(data, "Authentication Error")) + raise OutboundError(502, "Could not read the account's templates from Meta") + + template = next( + (t for t in data if t.get("name") == name and t.get("language") == language), + None, + ) + if template is None: + raise OutboundError(404, f"No template named {name} in {language} on this account") + if str(template.get("status", "")).upper() != "APPROVED": + raise OutboundError(400, f"Template {name} is not approved yet") + if starts_conversation and \ + str(template.get("category", "")).upper() not in OUTBOUND_CATEGORIES: + raise OutboundError( + 400, + "Only Utility and Authentication templates can start a conversation. " + "Marketing templates need the customer to have messaged you first.", + ) + return template + + +def _resolve_customer(db: Session, account: ChannelAccount, phone: str, + customer_id: Optional[UUID], customer_name: Optional[str]): + """The person this conversation belongs to. + + A picked person must actually OWN the number being messaged — the two + arrive as independent fields, and binding a conversation to one person at + another person's number is unrecoverable: the reply resolves by phone to + somebody else, so history splits across two customers and the thread never + appears on the profile of the person actually in it. + + set_phone_if_absent's skip-and-log is right for the automatic inbound + paths, where there is nobody to tell. Here there is a human waiting on a + response, so a conflict is refused instead of silently ignored. + """ + repo = CustomerRepository(db) + if customer_id is not None: + customer = repo.get_by_id(customer_id) + if customer is None or customer.organization_id != account.organization_id: + raise OutboundError(404, "Customer not found") + if customer.phone and customer.phone != phone: + raise OutboundError( + 400, + f"{customer.full_name or 'That person'} has a different number on " + f"file. Send to their number, or update it on their profile first.", + ) + owner = repo.get_customer_by_phone(phone, account.organization_id) + if owner is not None and owner.id != customer.id: + raise OutboundError( + 400, + f"{phone} already belongs to {owner.full_name or 'another person'}. " + f"Pick them instead, or clear the number from their profile.", + ) + repo.set_phone_if_absent(customer, phone) + return customer + + wa_id = to_wa_id(phone) + customer = repo.get_or_create_customer( + email=f"{wa_id}@whatsapp.channel", + organization_id=account.organization_id, + full_name=customer_name or placeholder_name(account.channel_type, wa_id), + phone=phone, + ) + # First-touch source for People's Source column; setdefault semantics so a + # person who arrived some other way keeps their original story. + if customer.lead_source is None: + customer.lead_source = {"channel": "whatsapp", "via": "outbound"} + return customer + + +async def start_outbound_conversation( + db: Session, + account: ChannelAccount, + *, + to: str, + template_name: str, + language: str = DEFAULT_TEMPLATE_LANGUAGE, + components: Optional[list] = None, + customer_id: Optional[UUID] = None, + customer_name: Optional[str] = None, + source: MessageSource, +) -> OutboundSent: + """Start (or rejoin) a WhatsApp conversation by phone number. + + Returns the session — everything downstream (the inbox thread, template + resends, the AI answering the reply) already keys off one, which is why + this hands one back instead of growing a parallel outbound code path — + and the stored template message. `source` is who sent it (an inbox user, + or an external app). The team is told about the thread only once the + customer's reply needs them (notify_new_channel_chat_once), so a bulk + send doesn't page anyone. + + The conversation is created with last_inbound_at=None: no customer message + means no 24-hour window, and pretending otherwise would let the AI send + free-form text that Meta rejects. + """ + phone = normalize_phone(to) + if phone is None: + raise OutboundError(400, "Enter the number in international format, e.g. +91 12345 67890") + wa_id = to_wa_id(phone) + + agent_id = AgentChannelConfigRepository(db).get_active_agent_id(account.id) + if agent_id is None: + raise OutboundError(400, "Route an agent to this number first — " + "otherwise nobody would answer the reply") + + # Resolved BEFORE the template is validated: whether this opens a thread or + # rejoins one decides which rules the template must satisfy. + conv_repo = ChannelConversationRepository(db) + existing = conv_repo.get_active(account.id, wa_id) + + template = await _approved_outbound_template( + db, account, template_name, language, starts_conversation=existing is None) + + if existing is not None: + # An open conversation already carries this number; send there instead + # of splitting the thread. The window may even be open, but a template + # send inside the window is legal and simpler than branching. + conversation, session_id = existing, existing.session_id + customer = None + else: + customer = _resolve_customer(db, account, phone, customer_id, customer_name) + session_id = uuid.uuid4() + SessionToAgentRepository(db).create_session( + session_id=session_id, + agent_id=str(agent_id), + customer_id=str(customer.id), + organization_id=str(account.organization_id), + channel=account.channel_type, + ) + conversation = conv_repo.create( + channel_account_id=account.id, + channel_type=account.channel_type, + external_conversation_id=wa_id, + external_user_id=wa_id, + session_id=session_id, + organization_id=account.organization_id, + agent_id=agent_id, + customer_id=customer.id, + last_inbound_at=None, + extra=team_not_yet_notified(), + ) + + adapter = get_adapter("whatsapp") + result = await adapter.send_template(account, conversation, template_name, + language, components) + ChannelAccountRepository(db).track_send_result(account, result) + if not result.ok: + if existing is None: + # A failed send must not leave an empty thread in the inbox: drop + # the conversation, then the session it points at. The customer + # row stays — harmless, and reused on the next attempt. + # + # Guarded, because an exception escaping here would replace the 502 + # (which names Meta's actual reason) with an opaque 500 AND leave + # the empty thread this block exists to remove. Failing to clean up + # is worth a log; losing the reason the send failed is not. + from app.models.session_to_agent import SessionToAgent + try: + db.delete(conversation) + db.query(SessionToAgent).filter( + SessionToAgent.session_id == session_id).delete() + db.commit() + except Exception as e: + db.rollback() + logger.error(f"Could not roll back the empty outbound thread for " + f"session {session_id}: {e}") + raise OutboundError(502, result.error or "WhatsApp did not accept the message") + + rendered = _render_body(template, components) + stored = ChatRepository(db).create_message({ + "message": rendered, + "message_type": "bot", + "session_id": str(session_id), + "organization_id": str(account.organization_id), + "agent_id": str(agent_id), + # A reused conversation row predating this feature can lack a customer. + "customer_id": str(conversation.customer_id) if conversation.customer_id else None, + "attributes": { + "channel": account.channel_type, + "external_message_id": result.external_message_id, + "outbound_template": template_name, + "source": source.value, + }, + }) + if result.external_message_id: + await record_sent(db, stored.id, account.id, result.external_message_id) + # The AI reads this when the customer replies: a bare "yes" is meaningless + # without knowing what we asked. + remember_business_message(db, conversation, rendered, result.external_message_id) + await emit_channel_event(outbound_event( + organization_id=account.organization_id, channel_account_id=account.id, + session_id=str(session_id), + customer_id=str(conversation.customer_id) if conversation.customer_id else None, + record=OutboundRecord(message_id=stored.id, external_message_id=result.external_message_id, + source=source, text=rendered), + )) + return OutboundSent(session_id=session_id, message_id=stored.id, + external_message_id=result.external_message_id) diff --git a/backend/app/services/workflow.py b/backend/app/services/workflow.py new file mode 100644 index 0000000..8cba9e1 --- /dev/null +++ b/backend/app/services/workflow.py @@ -0,0 +1,185 @@ +""" +Copyright 2024-2026 ChatterMate + +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 typing import Optional +from uuid import UUID +from sqlalchemy.orm import Session +from app.repositories.workflow import WorkflowRepository +from app.repositories.agent import AgentRepository +from app.models.workflow import Workflow +from app.models.schemas.workflow import WorkflowCreate +from app.core.logger import get_logger + +logger = get_logger(__name__) + + +class WorkflowService: + def __init__(self, db: Session): + self.db = db + self.workflow_repo = WorkflowRepository(db) + self.agent_repo = AgentRepository(db) + + def create_workflow(self, workflow_data: WorkflowCreate, created_by: UUID, organization_id: UUID) -> Workflow: + """Create a new workflow with validation""" + + # Validate agent exists and belongs to organization + agent = self.agent_repo.get_by_id(workflow_data.agent_id) + if not agent: + raise ValueError("Agent not found") + + if agent.organization_id != organization_id: + raise ValueError("Agent does not belong to your organization") + + # Check if workflow already exists for this agent + existing_workflow = self.workflow_repo.get_workflows_by_agent(workflow_data.agent_id) + if existing_workflow: + raise ValueError("Agent already has a workflow. Only one workflow per agent is allowed.") + + # Check if workflow name already exists in organization + existing_workflow_name = self.workflow_repo.get_by_name( + workflow_data.name, + organization_id + ) + if existing_workflow_name: + raise ValueError(f"Workflow with name '{workflow_data.name}' already exists") + + # Create workflow data + workflow_dict = workflow_data.model_dump() + workflow_dict.update({ + "created_by": created_by, + "organization_id": organization_id + }) + + # Create workflow + workflow = self.workflow_repo.create_workflow(**workflow_dict) + + logger.info(f"Created workflow {workflow.id} for agent {workflow_data.agent_id}") + return workflow + + def get_workflow_by_agent_id(self, agent_id: UUID, organization_id: UUID) -> Optional[Workflow]: + """Get workflow by agent ID with organization validation""" + + # Validate agent exists and belongs to organization + agent = self.agent_repo.get_by_id(agent_id) + if not agent: + raise ValueError("Agent not found") + + if agent.organization_id != organization_id: + raise ValueError("Agent does not belong to your organization") + + # Get workflows for this agent + workflows = self.workflow_repo.get_workflows_by_agent(agent_id) + + # Return the first workflow (should be only one due to our constraint) + return workflows[0] if workflows else None + + def update_workflow(self, workflow_id: UUID, workflow_data: dict, organization_id: UUID) -> Optional[Workflow]: + """Update workflow with validation""" + + # Get existing workflow + workflow = self.workflow_repo.get_by_id(workflow_id) + if not workflow: + raise ValueError("Workflow not found") + + # Verify organization access + if workflow.organization_id != organization_id: + raise ValueError("Workflow does not belong to your organization") + + # If name is being updated, check for duplicates + if 'name' in workflow_data and workflow_data['name'] != workflow.name: + existing_workflow = self.workflow_repo.get_by_name( + workflow_data['name'], + organization_id + ) + if existing_workflow and existing_workflow.id != workflow_id: + raise ValueError(f"Workflow with name '{workflow_data['name']}' already exists") + + # If agent_id is being updated, validate the new agent and check constraint + if 'agent_id' in workflow_data and workflow_data['agent_id'] != workflow.agent_id: + # Validate new agent exists and belongs to organization + agent = self.agent_repo.get_by_id(workflow_data['agent_id']) + if not agent: + raise ValueError("Agent not found") + + if agent.organization_id != organization_id: + raise ValueError("Agent does not belong to your organization") + + # Check if new agent already has a workflow + existing_workflows = self.workflow_repo.get_workflows_by_agent(workflow_data['agent_id']) + if existing_workflows: + raise ValueError("Agent already has a workflow. Only one workflow per agent is allowed.") + + # Check if status is being updated to published + if 'status' in workflow_data and workflow_data['status'] == 'published': + # When publishing, update the agent's active_workflow_id and use_workflow + agent = self.agent_repo.get_by_id(workflow.agent_id) + if agent: + self.agent_repo.update_agent( + workflow.agent_id, + active_workflow_id=workflow_id, + use_workflow=True + ) + logger.info(f"Updated agent {workflow.agent_id} active_workflow_id to {workflow_id}") + + # Check if status is being updated to draft (unpublished) + if 'status' in workflow_data and workflow_data['status'] == 'draft': + # When unpublishing, clear the agent's active_workflow_id and disable workflow + agent = self.agent_repo.get_by_id(workflow.agent_id) + if agent and agent.active_workflow_id == workflow_id: + self.agent_repo.update_agent( + workflow.agent_id, + active_workflow_id=None, + use_workflow=False + ) + logger.info(f"Cleared agent {workflow.agent_id} active_workflow_id") + + # Update workflow + updated_workflow = self.workflow_repo.update_workflow(workflow_id, **workflow_data) + + logger.info(f"Updated workflow {workflow_id}") + return updated_workflow + + def delete_workflow(self, workflow_id: UUID, organization_id: UUID) -> bool: + """Delete workflow with validation""" + + # Get existing workflow + workflow = self.workflow_repo.get_by_id(workflow_id) + if not workflow: + raise ValueError("Workflow not found") + + # Verify organization access + if workflow.organization_id != organization_id: + raise ValueError("Workflow does not belong to your organization") + + # Clear agent's active_workflow_id if this workflow is active + agent = self.agent_repo.get_by_id(workflow.agent_id) + if agent and agent.active_workflow_id == workflow_id: + self.agent_repo.update_agent( + workflow.agent_id, + active_workflow_id=None, + use_workflow=False + ) + logger.info(f"Cleared agent {workflow.agent_id} active_workflow_id before deleting workflow") + + # Delete workflow + success = self.workflow_repo.delete_workflow(workflow_id) + + if success: + logger.info(f"Deleted workflow {workflow_id}") + + return success + + \ No newline at end of file diff --git a/backend/app/services/workflow_chat.py b/backend/app/services/workflow_chat.py new file mode 100644 index 0000000..de1c65a --- /dev/null +++ b/backend/app/services/workflow_chat.py @@ -0,0 +1,393 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import asyncio +from app.core.logger import get_logger +from app.agents.chat_agent import ChatAgent, ChatResponse +from app.repositories.chat import ChatRepository +from app.repositories.session_to_agent import SessionToAgentRepository +from app.services.workflow_execution import WorkflowExecutionService +from app.core.security import decrypt_api_key +from app.models.schemas.chat import TransferReasonType + +logger = get_logger(__name__) + + +class WorkflowChatService: + """Service for handling workflow-based chat processing""" + + def __init__(self, db): + self.db = db + self.chat_repo = ChatRepository(db) + self.session_repo = SessionToAgentRepository(db) + self.workflow_service = WorkflowExecutionService(db) + + async def handle_workflow_chat( + self, + active_session, + message: str, + session_id: str, + org_id: str, + customer_id: str, + session: dict, + sio, + namespace: str = '/widget', + source: str = None + ) -> ChatResponse: + """ + Handle chat processing for workflow-enabled agents + + Args: + active_session: The active session object + message: User message + session_id: Session ID + org_id: Organization ID + customer_id: Customer ID + session: Session data containing agent and AI config + sio: SocketIO instance for emitting events + namespace: SocketIO namespace + + Returns: + ChatResponse: The response from workflow execution + """ + + # Store user message in chat history + self.chat_repo.create_message({ + "message": message, + "message_type": "user", + "session_id": session_id, + "organization_id": org_id, + "agent_id": session['agent_id'], + "customer_id": customer_id, + }) + + # Execute workflow + workflow_result = await self.workflow_service.execute_workflow( + session_id=session_id, + user_message=message, + workflow_id=active_session.workflow_id, + current_node_id=active_session.current_node_id, + workflow_state=active_session.workflow_state, + api_key=decrypt_api_key(session['ai_config'].encrypted_api_key), + model_name=session['ai_config'].model_name, + model_type=session['ai_config'].model_type, + base_url=(session['ai_config'].settings or {}).get('base_url'), + org_id=org_id, + agent_id=session['agent_id'], + customer_id=customer_id, + source=source + ) + + if workflow_result.success: + return await self._handle_successful_workflow( + workflow_result, active_session, session_id, org_id, + customer_id, session, sio, namespace + ) + else: + return await self._handle_failed_workflow( + workflow_result, session_id, org_id, session, customer_id + ) + + async def _handle_successful_workflow( + self, + workflow_result, + active_session, + session_id: str, + org_id: str, + customer_id: str, + session: dict, + sio, + namespace: str + ) -> ChatResponse: + """Handle successful workflow execution""" + + + + # Handle intermediate messages from MESSAGE nodes during automatic execution + if workflow_result.intermediate_messages: + await self._handle_intermediate_messages( + workflow_result.intermediate_messages, session_id, org_id, + session, customer_id, active_session, sio, namespace + ) + + # Check if this is a form node that needs to display a form + if workflow_result.form_data: + await self._handle_form_display(workflow_result, session_id, sio, namespace) + return None # Don't send a regular chat response for form display + + # Create ChatResponse object from workflow result + response = ChatResponse( + message=workflow_result.message, + transfer_to_human=workflow_result.transfer_to_human, + transfer_reason=None, + transfer_description=None, + end_chat=workflow_result.end_chat, + request_rating=workflow_result.request_rating, + create_ticket=False + ) + + # Handle transfer to human if requested by workflow LLM + if workflow_result.transfer_to_human: + transfer_response = await self._handle_workflow_transfer( + workflow_result, session_id, org_id, session, customer_id, response + ) + if transfer_response: + return transfer_response + + # Handle end chat if requested by workflow + if workflow_result.end_chat: + response = await self._handle_workflow_end_chat( + response, session_id, org_id, session, customer_id + ) + + # Only store message if there's actual content + if workflow_result.message: + self._store_workflow_response( + response, session_id, org_id, session, customer_id, + active_session, workflow_result + ) + + return response + + async def _handle_intermediate_messages( + self, + intermediate_messages, + session_id: str, + org_id: str, + session: dict, + customer_id: str, + active_session, + sio, + namespace: str + ): + """Handle intermediate messages from MESSAGE nodes""" + + logger.info(f"Found {len(intermediate_messages)} intermediate messages from MESSAGE nodes") + + for intermediate_message in intermediate_messages: + logger.debug(f"Emitting intermediate message: {intermediate_message}") + + # Store intermediate message in chat history + self.chat_repo.create_message({ + "message": intermediate_message, + "message_type": "bot", + "session_id": session_id, + "organization_id": org_id, + "agent_id": session['agent_id'], + "customer_id": customer_id, + "attributes": { + "workflow_execution": True, + "workflow_id": str(active_session.workflow_id), + "intermediate_message": True, + "message_node": True + } + }) + + # Emit intermediate message to client immediately + await sio.emit('chat_response', { + 'message': intermediate_message, + 'type': 'chat_response', + 'transfer_to_human': False, + 'end_chat': False, + 'request_rating': False, + 'shopify_output': None + }, room=session_id, namespace=namespace) + + async def _handle_form_display(self, workflow_result, session_id: str, sio, namespace: str): + """Handle form display for form nodes""" + + logger.info(f"Form data detected: {workflow_result.form_data}") + logger.info("Emitting display_form event") + + # Emit form display to client + await sio.emit('display_form', { + 'form_data': workflow_result.form_data, + 'session_id': session_id + }, room=session_id, namespace=namespace) + + logger.info("Form display event emitted successfully") + + async def _handle_workflow_transfer( + self, + workflow_result, + session_id: str, + org_id: str, + session: dict, + customer_id: str, + response: ChatResponse + ) -> ChatResponse: + """Handle transfer to human requested by workflow""" + + logger.info(f"Workflow LLM requested transfer to human for session {session_id}") + + if not workflow_result.transfer_group_id: + logger.warning( + f"No transfer_group_id provided for workflow transfer in session {session_id}; " + "falling back to the agent's default group") + + chat_agent = await ChatAgent.create_async( + api_key=decrypt_api_key(session['ai_config'].encrypted_api_key), + model_name=session['ai_config'].model_name, + model_type=session['ai_config'].model_type, + base_url=(session['ai_config'].settings or {}).get('base_url'), + org_id=org_id, + agent_id=session['agent_id'], + customer_id=customer_id, + session_id=session_id + ) + + try: + # Create ChatResponse object with transfer details from workflow + llm_response = ChatResponse( + message=workflow_result.message, + transfer_to_human=True, + transfer_reason=TransferReasonType(workflow_result.transfer_reason) if workflow_result.transfer_reason else None, + transfer_description=workflow_result.transfer_description, + end_chat=False, + request_rating=False, + create_ticket=False + ) + + # A missing group id is passed through rather than short-circuited: + # the transfer path then falls back to the agent's own default + # group, as a plain agent transfer does. Setting the status alone + # left group_id NULL, and an inbox matches on user_id or group_id — + # so the handover was invisible to every agent it was meant for. + transfer_response = await chat_agent.handle_workflow_transfer( + session_id=session_id, + org_id=org_id, + agent_id=session['agent_id'], + customer_id=customer_id, + transfer_group_id=workflow_result.transfer_group_id, + db=self.db, + chat_repo=self.chat_repo, + llm_response=llm_response + ) + + logger.info(f"Transfer completed for session {session_id} to group {workflow_result.transfer_group_id}") + # Mark this as a transfer response so widget_chat.py can handle it properly + transfer_response._is_transfer_response = True + return transfer_response + except ValueError as e: + # The agent has no groups at all, so there is nobody to route to. + # Mark it transferred anyway — an admin can still see and claim it. + logger.error( + f"Workflow transfer for session {session_id} could not be routed to a group: {e}") + self.session_repo.update_session_status(session_id, "TRANSFERRED") + return response + finally: + await chat_agent.safe_cleanup_mcp_tools() + + async def _handle_workflow_end_chat( + self, + response: ChatResponse, + session_id: str, + org_id: str, + session: dict, + customer_id: str + ) -> ChatResponse: + """Handle end chat requested by workflow""" + + logger.info(f"Workflow requested end chat for session {session_id}") + + # Create a ChatAgent instance to use the _handle_end_chat method + chat_agent = await ChatAgent.create_async( + api_key=decrypt_api_key(session['ai_config'].encrypted_api_key), + model_name=session['ai_config'].model_name, + model_type=session['ai_config'].model_type, + base_url=(session['ai_config'].settings or {}).get('base_url'), + org_id=org_id, + agent_id=session['agent_id'], + customer_id=customer_id, + session_id=session_id + ) + + try: + # Handle end chat using the agent's method with rating from response + # The response.request_rating value comes from the workflow execution config + return await chat_agent._handle_end_chat(response, session_id, self.db, response.request_rating) + finally: + await chat_agent.safe_cleanup_mcp_tools() + + def _store_workflow_response( + self, + response: ChatResponse, + session_id: str, + org_id: str, + session: dict, + customer_id: str, + active_session, + workflow_result + ): + """Store workflow response in chat history""" + + self.chat_repo.create_message({ + "message": response.message, + "message_type": "bot", + "session_id": session_id, + "organization_id": org_id, + "agent_id": session['agent_id'], + "customer_id": customer_id, + "attributes": { + "workflow_execution": True, + "workflow_id": str(active_session.workflow_id), + "current_node_id": str(workflow_result.next_node_id) if workflow_result.next_node_id else None, + "transfer_to_human": response.transfer_to_human, + "end_chat": response.end_chat, + "request_rating": response.request_rating, + } + }) + + async def _handle_failed_workflow( + self, + workflow_result, + session_id: str, + org_id: str, + session: dict, + customer_id: str + ) -> ChatResponse: + """Handle failed workflow execution""" + + logger.error(f"Workflow execution failed: {workflow_result.error}") + + response = ChatResponse( + message=workflow_result.message or "I apologize, but I'm having trouble processing your request right now.", + transfer_to_human=False, + transfer_reason=None, + transfer_description=None, + end_chat=False, + request_rating=False, + create_ticket=False + ) + + # Store error response in chat history + self.chat_repo.create_message({ + "message": response.message, + "message_type": "bot", + "session_id": session_id, + "organization_id": org_id, + "agent_id": session['agent_id'], + "customer_id": customer_id, + "attributes": { + "workflow_execution": True, + "workflow_error": workflow_result.error, + "transfer_to_human": response.transfer_to_human, + "end_chat": response.end_chat, + "request_rating": response.request_rating, + } + }) + + return response \ No newline at end of file diff --git a/backend/app/services/workflow_conditions.py b/backend/app/services/workflow_conditions.py new file mode 100644 index 0000000..bd67826 --- /dev/null +++ b/backend/app/services/workflow_conditions.py @@ -0,0 +1,291 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +# Evaluates workflow condition nodes. +# +# The structure of a condition is fixed before any variable is looked up, and +# variable values are only ever compared, never parsed. A visitor's answer such +# as `x" OR "a" === "a` is therefore just text and cannot steer routing. + +import re +from typing import Any, Dict, List, Optional, Tuple + +from app.core.logger import get_logger + +logger = get_logger(__name__) + +# Returned by resolve_variable when no value is stored under the name. +MISSING = object() + +OPERATORS = frozenset({"===", "!==", ">", "<", ">=", "<=", "includes", "startsWith", "endsWith"}) +_ALIASES = {"==": "===", "!=": "!==", "contains": "includes"} +_NUMERIC = { + ">": lambda a, b: a > b, + "<": lambda a, b: a < b, + ">=": lambda a, b: a >= b, + "<=": lambda a, b: a <= b, +} +_NUMBER = re.compile(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?") + + +def resolve_variable(name: str, workflow_state: Optional[Dict[str, Any]]) -> Any: + """Look up a `{{name}}` value: `node_id.field`, a top-level variable such as + user_message, or a field from any node (latest wins). MISSING if absent.""" + variables = (workflow_state or {}).get("variables") or {} + node_variables = {k: v for k, v in variables.items() if isinstance(v, dict)} + name = name.strip() + + if "." in name: + node_id, field_name = (part.strip() for part in name.split(".", 1)) + scoped = node_variables.get(node_id, {}) + return scoped[field_name] if field_name in scoped else MISSING + + if name in variables and name not in node_variables: + return variables[name] + for node_id in reversed(list(node_variables)): + if name in node_variables[node_id]: + return node_variables[node_id][name] + return MISSING + + +def _as_text(value: Any) -> Optional[str]: + if value is MISSING or value is None: + return None + if isinstance(value, bool): + return "true" if value else "false" + return str(value).strip() + + +def _as_number(text: str) -> Optional[float]: + return float(text) if _NUMBER.fullmatch(text) else None + + +def compare(left: Any, operator: str, right: Any) -> bool: + """Apply one operator to two values. Case-sensitive; surrounding whitespace + is ignored. A missing value only satisfies `!==`.""" + operator = _ALIASES.get(operator, operator) + if operator not in OPERATORS: + logger.warning(f"Unknown condition operator '{operator}', treating as false") + return False + + left_text, right_text = _as_text(left), _as_text(right) + if operator == "!==": + return left_text != right_text + if left_text is None or right_text is None: + return False + if operator == "===": + return left_text == right_text + if operator == "includes": + return right_text in left_text + if operator == "startsWith": + return left_text.startswith(right_text) + if operator == "endsWith": + return left_text.endswith(right_text) + + left_num, right_num = _as_number(left_text), _as_number(right_text) + if left_num is None or right_num is None: + logger.debug(f"Non-numeric '{operator}' comparison is false: {left_text!r} vs {right_text!r}") + return False + return _NUMERIC[operator](left_num, right_num) + + +# --- Builder groups ------------------------------------------------------- + +def _rule_is_complete(rule: Any) -> bool: + # Mirrors the dashboard, which leaves half-filled rules out of the expression. + if not isinstance(rule, dict): + return False + value = rule.get("value") + return bool(rule.get("variable")) and bool(rule.get("operator")) and value is not None and str(value).strip() != "" + + +def _evaluate_rule(rule: Dict[str, Any], workflow_state: Dict[str, Any]) -> bool: + name = str(rule["variable"]).strip().removeprefix("{{").removesuffix("}}") + left = resolve_variable(name, workflow_state) + if left is MISSING: + logger.warning(f"Condition variable '{name}' has no value") + return compare(left, str(rule["operator"]), rule["value"]) + + +def evaluate_groups(groups: List[Any], workflow_state: Dict[str, Any]) -> Optional[bool]: + """Groups are OR'ed; rules within a group use its logicalOperator (AND by + default). None when no group holds a complete rule.""" + results = [] + for group in groups: + if not isinstance(group, dict): + continue + rules = [rule for rule in (group.get("rules") or []) if _rule_is_complete(rule)] + if not rules: + continue + outcomes = [_evaluate_rule(rule, workflow_state) for rule in rules] + any_rule = str(group.get("logicalOperator", "AND")).upper() == "OR" + results.append(any(outcomes) if any_rule else all(outcomes)) + return any(results) if results else None + + +# --- Legacy expression strings --------------------------------------------- + +class ConditionSyntaxError(ValueError): + pass + + +_TOKEN = re.compile(r""" + \s*(?: + (?P<var>\{\{[^{}]+\}\}) + | (?P<str>"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*') + | (?P<method>\.(?:includes|startsWith|endsWith)\() + | (?P<op>===|!==|==|!=|>=|<=|>|<|&&|\|\|) + | (?P<paren>[()]) + | (?P<word>[^\s(){}"'=!<>&|.]+(?:\.(?!(?:includes|startsWith|endsWith)\()[^\s(){}"'=!<>&|.]+)*) + )\s*""", re.VERBOSE) +_METHODS = frozenset({"includes", "startsWith", "endsWith"}) +_WORD_KEYWORDS = {"AND": ("and", None), "OR": ("or", None), "contains": ("op", "contains")} + +Token = Tuple[str, Optional[str]] + + +def _tokenize(expression: str) -> List[Token]: + tokens: List[Token] = [] + pos = 0 + while pos < len(expression): + match = _TOKEN.match(expression, pos) + if not match or match.end() == pos: + if expression[pos:].strip(): + raise ConditionSyntaxError(f"unexpected text at position {pos}") + break + pos = match.end() + kind = match.lastgroup + text = match.group(kind) + if kind == "var": + tokens.append(("var", text[2:-2].strip())) + elif kind == "str": + tokens.append(("lit", re.sub(r"\\(.)", r"\1", text[1:-1]))) + elif kind == "method": + tokens.append(("op", text[1:-1])) + elif kind == "op" and text in ("&&", "||"): + tokens.append(("and" if text == "&&" else "or", None)) + elif kind == "paren": + tokens.append((text, None)) + elif kind == "word": + tokens.append(_WORD_KEYWORDS.get(text, ("lit", text))) + else: + tokens.append((kind, text)) + return tokens + + +class _Parser: + """or := and (OR and)* ; and := primary (AND primary)* ; + primary := '(' or ')' | operand op operand | operand .method( operand )""" + + def __init__(self, tokens: List[Token]): + self.tokens = tokens + self.pos = 0 + + def parse(self): + if not self.tokens: + raise ConditionSyntaxError("empty condition") + node = self._or() + if self.pos != len(self.tokens): + raise ConditionSyntaxError("unexpected trailing tokens") + return node + + def _peek(self) -> Optional[str]: + return self.tokens[self.pos][0] if self.pos < len(self.tokens) else None + + def _take(self, kind: str) -> Token: + if self._peek() != kind: + raise ConditionSyntaxError(f"expected {kind}") + token = self.tokens[self.pos] + self.pos += 1 + return token + + def _or(self): + items = [self._and()] + while self._peek() == "or": + self.pos += 1 + items.append(self._and()) + return ("or", items) if len(items) > 1 else items[0] + + def _and(self): + items = [self._primary()] + while self._peek() == "and": + self.pos += 1 + items.append(self._primary()) + return ("and", items) if len(items) > 1 else items[0] + + def _primary(self): + if self._peek() == "(": + self.pos += 1 + node = self._or() + self._take(")") + return node + left = self._operand() + _, operator = self._take("op") + right = self._operand() + if operator in _METHODS: + self._take(")") # closes `.includes(` + return ("cmp", left, operator, right) + + def _operand(self) -> Token: + if self._peek() in ("var", "lit"): + token = self.tokens[self.pos] + self.pos += 1 + return token + raise ConditionSyntaxError("expected a value") + + +def _evaluate_node(node, workflow_state: Dict[str, Any]) -> bool: + kind = node[0] + if kind == "or": + return any(_evaluate_node(item, workflow_state) for item in node[1]) + if kind == "and": + return all(_evaluate_node(item, workflow_state) for item in node[1]) + _, left, operator, right = node + return compare(_operand_value(left, workflow_state), operator, _operand_value(right, workflow_state)) + + +def _operand_value(token: Token, workflow_state: Dict[str, Any]) -> Any: + kind, text = token + if kind == "lit": + return text + value = resolve_variable(text, workflow_state) + if value is MISSING: + logger.warning(f"Condition variable '{text}' has no value") + return value + + +def evaluate_expression(expression: str, workflow_state: Dict[str, Any]) -> bool: + """Evaluate a stored `condition_expression`. Unreadable conditions are false.""" + try: + tree = _Parser(_tokenize(expression)).parse() + except (ConditionSyntaxError, RecursionError) as e: + logger.warning(f"Unreadable condition {expression!r} ({e}), treating as false") + return False + return _evaluate_node(tree, workflow_state) + + +def evaluate_condition_config(config: Dict[str, Any], workflow_state: Dict[str, Any]) -> Optional[bool]: + """Evaluate a condition node's config, preferring the builder's groups over + the generated expression. None when neither holds a usable condition.""" + groups = config.get("condition_groups") + if isinstance(groups, list): + result = evaluate_groups(groups, workflow_state) + if result is not None: + return result + expression = config.get("condition_expression") + if isinstance(expression, str) and expression.strip(): + return evaluate_expression(expression, workflow_state) + return None diff --git a/backend/app/services/workflow_execution.py b/backend/app/services/workflow_execution.py new file mode 100644 index 0000000..d37b799 --- /dev/null +++ b/backend/app/services/workflow_execution.py @@ -0,0 +1,1439 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import traceback +import asyncio +from typing import Optional, Dict, Any, List, Tuple +from uuid import UUID +from sqlalchemy.orm import Session +from dataclasses import dataclass +import json +import re +from datetime import datetime, timedelta + +from app.models.workflow import Workflow, WorkflowStatus +from app.models.workflow_node import WorkflowNode, NodeType, ExitCondition +from app.models.workflow_connection import WorkflowConnection +from app.models.session_to_agent import SessionToAgent +from app.repositories.workflow import WorkflowRepository +from app.repositories.session_to_agent import SessionToAgentRepository +from app.repositories.chat import ChatRepository +from app.agents.chat_agent import ChatAgent, ChatResponse +from app.core.logger import get_logger +from app.services.workflow_conditions import ( + MISSING, + evaluate_condition_config, + evaluate_expression, + resolve_variable, +) + +logger = get_logger(__name__) + + +def _branch_label(value: Any) -> Optional[str]: + return value.strip().lower() if isinstance(value, str) else None + + +@dataclass +class WorkflowExecutionResult: + """Result of workflow execution""" + success: bool + message: str + next_node_id: Optional[UUID] = None + workflow_state: Optional[Dict[str, Any]] = None + should_continue: bool = True + transfer_to_human: bool = False + transfer_group_id: Optional[str] = None + transfer_reason: Optional[str] = None + transfer_description: Optional[str] = None + end_chat: bool = False + request_rating: bool = False + error: Optional[str] = None + form_data: Optional[Dict[str, Any]] = None + landing_page_data: Optional[Dict[str, Any]] = None + intermediate_messages: Optional[List[str]] = None # Collect messages from MESSAGE nodes during execution + + +class WorkflowExecutionService: + """Service for executing workflows in chat sessions""" + + def __init__(self, db: Session): + self.db = db + self.workflow_repo = WorkflowRepository(db) + self.session_repo = SessionToAgentRepository(db) + + async def execute_workflow( + self, + session_id: str, + user_message: Optional[str], + workflow_id: UUID, + current_node_id: Optional[UUID] = None, + workflow_state: Optional[Dict[str, Any]] = None, + api_key: str = None, + model_name: str = None, + model_type: str = None, + base_url: str = None, + org_id: str = None, + agent_id: str = None, + customer_id: str = None, + is_initial_execution: bool = False, + source: str = None + ) -> WorkflowExecutionResult: + """ + Execute workflow for a chat session + + Args: + session_id: Session ID + user_message: User's message + workflow_id: Workflow to execute + current_node_id: Current node (None to start from beginning) + workflow_state: Current workflow state + api_key: API key for AI model + model_name: AI model name + model_type: AI model type + org_id: Organization ID + agent_id: Agent ID + customer_id: Customer ID + + Returns: + WorkflowExecutionResult with execution details + """ + try: + logger.info(f"Executing workflow {workflow_id} for session {session_id}") + logger.debug(f"Current node ID: {current_node_id}") + logger.debug(f"Workflow state: {workflow_state}") + logger.debug(f"User message: {user_message}") + logger.debug(f"Is initial execution: {is_initial_execution}") + + # Get workflow with nodes and connections + workflow = self.workflow_repo.get_workflow_with_nodes_and_connections(workflow_id) + logger.debug(f"Workflow ID: {workflow.id}, Name: {workflow.name}, Status: {workflow.status}") + logger.debug(f"Nodes count: {len(workflow.nodes)}") + + if not workflow: + return WorkflowExecutionResult( + success=False, + message="Workflow not found", + error="Workflow not found" + ) + + # Check if workflow is published + if workflow.status != WorkflowStatus.PUBLISHED: + return WorkflowExecutionResult( + success=False, + message="Workflow is not published", + error="Workflow is not published" + ) + + # Initialize workflow state if not provided - keep it minimal + if workflow_state is None: + workflow_state = {} + + # Store user message in workflow state if provided + # This makes it available to all nodes via {{user_message}} variable + if user_message: + if "variables" not in workflow_state: + workflow_state["variables"] = {} + workflow_state["variables"]["user_message"] = user_message + logger.debug(f"Stored user message in workflow state: {user_message}") + + # Determine starting node + if current_node_id is None: + current_node = self._find_start_node(workflow) + logger.debug(f"Found starting node: {current_node.id}") + if not current_node: + return WorkflowExecutionResult( + success=False, + message="No start node found in workflow", + error="No start node found" + ) + else: + current_node = self._find_node_by_id(workflow, current_node_id) + logger.debug(f"Current node: {current_node}") + if not current_node: + return WorkflowExecutionResult( + success=False, + message="Current node not found", + error="Current node not found" + ) + + # Automatic execution loop - continue executing nodes until we reach one that requires user interaction + final_result = None + intermediate_messages = [] # Collect messages from MESSAGE nodes during execution + final_message_collected = False + + while current_node: + logger.debug(f"Executing node: {current_node.id} ({current_node.node_type})") + + # Execute current node + result = await self._execute_node( + current_node, + workflow, + workflow_state, + user_message, + api_key, + model_name, + model_type, + base_url, + org_id, + agent_id, + customer_id, + session_id, + source + ) + + collected_before = len(intermediate_messages) + + # Collect messages from MESSAGE nodes during automatic execution + if current_node.node_type == NodeType.MESSAGE and result.message: + intermediate_messages.append(result.message) + logger.debug(f"Collected intermediate message from MESSAGE node: {result.message}") + + # Also collect messages from LLM nodes that are not the final stopping node + # This handles the case where LLM node with single execution produces a response + # but then moves to the next node (like USER_INPUT) + if (current_node.node_type == NodeType.LLM and result.message and + result.should_continue and result.next_node_id is not None): + intermediate_messages.append(result.message) + logger.debug(f"Collected intermediate message from LLM node: {result.message}") + + # Also collect confirmation messages from USER_INPUT nodes that continue to next node + # This ensures confirmation messages are displayed to the user + if (current_node.node_type == NodeType.USER_INPUT and result.message and + result.should_continue and result.next_node_id is not None): + intermediate_messages.append(result.message) + logger.debug(f"Collected confirmation message from USER_INPUT node: {result.message}") + + final_result = result + final_message_collected = len(intermediate_messages) > collected_before + + # Check if we should continue to next node automatically + if not result.success: + logger.error(f"Node execution failed: {result.error}") + break + + # Check stopping conditions + if (result.form_data or + result.landing_page_data or + result.transfer_to_human or + result.end_chat or + not result.should_continue or + result.next_node_id is None or + (current_node.node_type == NodeType.USER_INPUT and not result.should_continue)): + logger.info(f"Stopping execution at node {current_node.id}: form_data={bool(result.form_data)}, landing_page={bool(result.landing_page_data)}, transfer={result.transfer_to_human}, end_chat={result.end_chat}, should_continue={result.should_continue}, next_node={result.next_node_id}, user_input_waiting={current_node.node_type == NodeType.USER_INPUT and not result.should_continue}") + break + + # For LLM nodes with continuous execution, don't auto-advance unless specific conditions are met + if (current_node.node_type == NodeType.LLM and + current_node.config and + current_node.config.get("exit_condition") == "continuous_execution"): + logger.info(f"LLM node {current_node.id} with continuous execution - stopping automatic advancement") + break + + # Move to next node + current_node = self._find_node_by_id(workflow, result.next_node_id) + if not current_node: + logger.info(f"Next node {result.next_node_id} not found, reached end of workflow") + break + + # Clear user message after first iteration (subsequent nodes shouldn't use the original user message) + user_message = None + + if not final_result: + return WorkflowExecutionResult( + success=False, + message="No execution result", + error="No execution result" + ) + + # The node we stopped on is returned as the final message, which every + # caller sends and stores on top of the intermediate ones. Leaving it + # in both lists sent the visitor the same text twice. + if final_message_collected: + intermediate_messages.pop() + + # Update session workflow state based on final result + if (final_result.landing_page_data or + final_result.form_data or + (current_node and current_node.node_type == NodeType.USER_INPUT and not final_result.should_continue) or + (current_node and current_node.node_type == NodeType.LLM and not final_result.should_continue and not final_result.transfer_to_human and not final_result.end_chat and final_result.next_node_id is not None)): + # We're displaying a landing page, form, waiting for user input, or staying on LLM node - stay on current node + # Note: LLM nodes only stay on current node if there's a next node (continuous execution) + self._update_session_workflow_state(session_id, current_node.id if current_node else None, workflow_state) + elif final_result.transfer_to_human: + # Transfer to human requested - stay on current node and let the chat handler manage the transfer + self._update_session_workflow_state(session_id, current_node.id if current_node else None, workflow_state) + logger.info(f"Transfer to human requested from node {current_node.id if current_node else 'unknown'}, staying on current node") + elif current_node and current_node.node_type == NodeType.LLM and final_result.next_node_id is None and not final_result.end_chat: + # Check if this is single execution - if so, reset workflow to restart from beginning + config = current_node.config or {} + exit_condition = config.get("exit_condition", ExitCondition.SINGLE_EXECUTION) + if isinstance(exit_condition, str): + try: + exit_condition = ExitCondition(exit_condition) + except ValueError: + exit_condition = ExitCondition.SINGLE_EXECUTION + + if exit_condition == ExitCondition.SINGLE_EXECUTION: + # Single execution LLM node that is the last node - reset workflow to restart from beginning + logger.info(f"LLM node {current_node.id} is last node with single execution - resetting workflow to restart") + self._update_session_workflow_state(session_id, None, {}) + else: + # Continuous execution - stay on current node + logger.info(f"LLM node {current_node.id} is last node with continuous execution - staying on current node") + self._update_session_workflow_state(session_id, current_node.id, workflow_state) + else: + # Normal flow - move to next node (or end workflow if end_chat) + self._update_session_workflow_state(session_id, final_result.next_node_id, workflow_state) + + return WorkflowExecutionResult( + success=final_result.success, + message=final_result.message, + next_node_id=final_result.next_node_id, + workflow_state=workflow_state, + should_continue=final_result.should_continue, + transfer_to_human=final_result.transfer_to_human, + transfer_group_id=final_result.transfer_group_id, + transfer_reason=final_result.transfer_reason, + transfer_description=final_result.transfer_description, + end_chat=final_result.end_chat, + request_rating=final_result.request_rating, + error=final_result.error, + form_data=final_result.form_data, # Pass through form_data + landing_page_data=final_result.landing_page_data, # Pass through landing_page_data + intermediate_messages=intermediate_messages # Include collected messages + ) + + except Exception as e: + traceback.print_exc() + logger.error(f"Error executing workflow: {str(e)}") + return WorkflowExecutionResult( + success=False, + message="An error occurred while executing the workflow", + error=str(e) + ) + + async def submit_form( + self, + session_id: str, + form_data: Dict[str, Any], + workflow_id: UUID, + org_id: str = None, + agent_id: str = None, + customer_id: str = None, + api_key: str = None, + model_name: str = None, + model_type: str = None, + base_url: str = None, + source: str = None + ) -> WorkflowExecutionResult: + """ + Handle form submission and continue workflow + + Args: + session_id: Session ID + form_data: Submitted form data + workflow_id: Workflow ID + org_id: Organization ID + agent_id: Agent ID + customer_id: Customer ID + api_key: API key for AI model + model_name: AI model name + model_type: AI model type + + Returns: + WorkflowExecutionResult with next step + """ + try: + logger.info(f"Submitting form for session {session_id}") + logger.debug(f"Form data: {form_data}") + logger.debug(f"Workflow ID: {workflow_id}") + logger.debug(f"Org ID: {org_id}") + logger.debug(f"Agent ID: {agent_id}") + logger.debug(f"Customer ID: {customer_id}") + logger.debug(f"API key: {api_key}") + # Get current session state + session = self.session_repo.get_session(session_id) + if not session: + return WorkflowExecutionResult( + success=False, + message="Session not found", + error="Session not found" + ) + + workflow_state = session.workflow_state or {} + current_node_id = session.current_node_id + + # Validate that we're in a form waiting state + if workflow_state.get("form_state") != "waiting": + return WorkflowExecutionResult( + success=False, + message="No form submission expected", + error="No form submission expected" + ) + + # Store form submission in workflow state + workflow_state["form_data"] = form_data + workflow_state["form_state"] = "submitted" + + logger.debug(f"Form submission received for session {session_id}") + logger.debug(f"Form data: {form_data}") + logger.debug(f"Updated workflow state: {workflow_state}") + + # Continue workflow execution with form submission + return await self.execute_workflow( + session_id=session_id, + user_message="", # No user message for form submission + workflow_id=workflow_id, + current_node_id=current_node_id, + workflow_state=workflow_state, + api_key=api_key, + model_name=model_name, + model_type=model_type, + base_url=base_url, + org_id=org_id, + agent_id=agent_id, + customer_id=customer_id, + source=source + ) + + except Exception as e: + logger.error(f"Error submitting form: {str(e)}") + return WorkflowExecutionResult( + success=False, + message="An error occurred while submitting the form", + error=str(e) + ) + + def _find_start_node(self, workflow: Workflow) -> Optional[WorkflowNode]: + """Find the starting node of a workflow""" + # Look for a node with no incoming connections + for node in workflow.nodes: + if not node.incoming_connections: + return node + + # If no node without incoming connections, return the first node + return workflow.nodes[0] if workflow.nodes else None + + def _find_node_by_id(self, workflow: Workflow, node_id: UUID) -> Optional[WorkflowNode]: + """Find a node by ID in the workflow""" + for node in workflow.nodes: + if node.id == node_id: + return node + return None + + async def _execute_node( + self, + node: WorkflowNode, + workflow: Workflow, + workflow_state: Dict[str, Any], + user_message: str, + api_key: str, + model_name: str, + model_type: str, + base_url: str, + org_id: str, + agent_id: str, + customer_id: str, + session_id: str, + source: str = None + ) -> WorkflowExecutionResult: + """Execute a specific node based on its type""" + + logger.info(f"Executing node {node.id} of type {node.node_type}") + + try: + if node.node_type == NodeType.MESSAGE: + return self._execute_message_node(node, workflow_state) + + elif node.node_type == NodeType.LLM: + return await self._execute_llm_node( + node, workflow, workflow_state, user_message, api_key, model_name, + model_type, base_url, org_id, agent_id, customer_id, session_id, source + ) + + elif node.node_type == NodeType.CONDITION: + return self._execute_condition_node(node, workflow, workflow_state) + + elif node.node_type == NodeType.FORM: + return self._execute_form_node(node, workflow_state, user_message, session_id) + + elif node.node_type == NodeType.LANDING_PAGE: + return self._execute_landing_page_node(node, workflow_state, user_message) + + elif node.node_type == NodeType.ACTION: + return self._execute_action_node(node, workflow_state) + + elif node.node_type == NodeType.HUMAN_TRANSFER: + return self._execute_human_transfer_node(node, workflow_state) + + elif node.node_type == NodeType.WAIT: + return self._execute_wait_node(node, workflow_state) + + elif node.node_type == NodeType.END: + return self._execute_end_node(node, workflow_state) + + elif node.node_type == NodeType.USER_INPUT: + return self._execute_user_input_node(node, workflow_state, user_message, session_id) + + elif node.node_type == NodeType.GUARDRAILS: + return self._execute_guardrails_node(node, workflow, workflow_state, user_message) + + else: + return WorkflowExecutionResult( + success=False, + message=f"Unknown node type: {node.node_type}", + error=f"Unknown node type: {node.node_type}" + ) + + except Exception as e: + logger.error(f"Error executing node {node.id}: {str(e)}") + return WorkflowExecutionResult( + success=False, + message=f"Error executing node: {str(e)}", + error=str(e) + ) + + def _execute_message_node(self, node: WorkflowNode, workflow_state: Dict[str, Any]) -> WorkflowExecutionResult: + """Execute a message node""" + config = node.config or {} + message = config.get("message_text", "No message configured") + + # Process variables in message + message = self._process_variables(message, workflow_state) + + # Find next node + next_node_id = self._find_next_node(node) + + return WorkflowExecutionResult( + success=True, + message=message, + next_node_id=next_node_id, + should_continue=next_node_id is not None + ) + + async def _execute_llm_node( + self, + node: WorkflowNode, + workflow: Workflow, + workflow_state: Dict[str, Any], + user_message: str, + api_key: str, + model_name: str, + model_type: str, + base_url: str, + org_id: str, + agent_id: str, + customer_id: str, + session_id: str, + source: str = None + ) -> WorkflowExecutionResult: + """Execute an LLM node""" + try: + # Check if both user message and workflow history are empty + if not user_message or user_message.strip() == "": + # Check if there's any meaningful chat history or workflow history + chat_repo = ChatRepository(self.db) + chat_history = await chat_repo.get_session_history(session_id) + workflow_history = self.session_repo.get_workflow_history(session_id) + + # If both message and history are empty, skip LLM execution + if (not chat_history or len(chat_history) == 0) and (not workflow_history or len(workflow_history) == 0): + logger.info(f"Skipping LLM node {node.id} execution - no user message and no history available") + + # Get exit condition to determine how to proceed + config = node.config or {} + exit_condition = config.get("exit_condition", ExitCondition.SINGLE_EXECUTION) + if isinstance(exit_condition, str): + try: + exit_condition = ExitCondition(exit_condition) + except ValueError: + exit_condition = ExitCondition.SINGLE_EXECUTION + + if exit_condition == ExitCondition.SINGLE_EXECUTION: + # Single execution: move to next node + next_node_id = self._find_next_node(node) + return WorkflowExecutionResult( + success=True, + message="", # Empty message since no execution occurred + next_node_id=next_node_id, + should_continue=next_node_id is not None + ) + else: + # Continuous execution: wait for user input + return WorkflowExecutionResult( + success=True, + message="", # Empty message since no execution occurred + next_node_id=None, + should_continue=False + ) + + # Get system prompt from config and process variables + config = node.config or {} + system_prompt = config.get("system_prompt", "You are a helpful assistant.") + system_prompt = self._process_variables(system_prompt, workflow_state) + + # Get exit condition configuration + exit_condition = config.get("exit_condition", ExitCondition.SINGLE_EXECUTION) + # Ensure it's an ExitCondition enum value + if isinstance(exit_condition, str): + try: + exit_condition = ExitCondition(exit_condition) + except ValueError: + exit_condition = ExitCondition.SINGLE_EXECUTION + + # Transfer to human setting only applies to continuous execution + auto_transfer = False + transfer_group_id = None + ask_for_rating_config = False + if exit_condition == ExitCondition.CONTINUOUS_EXECUTION: + auto_transfer = config.get("auto_transfer_enabled", False) + transfer_group_id = config.get("transfer_group_id") + ask_for_rating_config = config.get("ask_for_rating", True) # Default to True + + # Handle empty or null user message by creating structured context + processed_user_message = user_message + if not user_message or user_message.strip() == "": + processed_user_message = await self._build_context_message(session_id, workflow_state) + + # Create chat agent with custom system prompt + chat_agent = await ChatAgent.create_async( + api_key=api_key, + model_name=model_name, + model_type=model_type, + base_url=base_url, + org_id=org_id, + agent_id=agent_id, + customer_id=customer_id, + session_id=session_id, + custom_system_prompt=system_prompt, + transfer_to_human=auto_transfer, + source=source + ) + + try: + # Get response from LLM using the agent's internal method to avoid double message storage + response = await chat_agent._get_llm_response_only( + message=processed_user_message, + session_id=session_id, + org_id=org_id, + agent_id=agent_id, + customer_id=customer_id + ) + finally: + await chat_agent.safe_cleanup_mcp_tools() + + logger.debug(f"Response: {response}") + # Handle exit conditions + next_node_id = None + should_continue = False + transfer_to_human = response.transfer_to_human + end_chat = response.end_chat + + if exit_condition == ExitCondition.SINGLE_EXECUTION: + # Single execution: always move to next node after one response + next_node_id = self._find_next_node(node) + should_continue = next_node_id is not None + logger.info(f"LLM node {node.id} single execution - moving to next node {next_node_id}") + + elif exit_condition == ExitCondition.CONTINUOUS_EXECUTION: + # Continuous execution: stay on current node, only exit on explicit conditions + if response.transfer_to_human and auto_transfer: + # Auto transfer is enabled and LLM requested transfer + transfer_to_human = True + should_continue = False + logger.info(f"LLM node {node.id} auto transfer triggered") + elif response.end_chat: + # LLM requested end chat - move to next node or end workflow + next_node_id = self._find_next_node(node) + should_continue = next_node_id is not None + logger.info(f"LLM node {node.id} end chat requested - moving to next node {next_node_id}") + else: + # Stay on current node for continued conversation + should_continue = False + next_node_id = None + logger.info(f"LLM node {node.id} continuous execution - staying on current node") + + # Determine rating request based on config for workflow end chat handling + request_rating = response.request_rating + if end_chat and exit_condition == ExitCondition.CONTINUOUS_EXECUTION: + # For continuous execution, check the ask_for_rating config + if 'ask_for_rating' in config: + request_rating = config['ask_for_rating'] + else: + request_rating = True # Default to True if not specified + + # Update the response with the determined rating value + response.request_rating = request_rating + + return WorkflowExecutionResult( + success=True, + message=response.message, + next_node_id=next_node_id, + should_continue=should_continue, + transfer_to_human=transfer_to_human, + transfer_group_id=transfer_group_id if transfer_to_human else None, + transfer_reason=response.transfer_reason.value if response.transfer_reason else None, + transfer_description=response.transfer_description, + end_chat=end_chat, + request_rating=request_rating + ) + + except Exception as e: + logger.error(f"Error executing LLM node: {str(e)}") + return WorkflowExecutionResult( + success=False, + message="Sorry, I encountered an error processing your request.", + error=str(e) + ) + + def _execute_condition_node( + self, + node: WorkflowNode, + workflow: Workflow, + workflow_state: Dict[str, Any] + ) -> WorkflowExecutionResult: + """Execute a condition node""" + try: + logger.info(f"Executing condition node: {node.id}") + condition_result = evaluate_condition_config(node.config or {}, workflow_state) + if condition_result is None: + logger.error(f"No condition expression configured for condition node: {node.id}") + return WorkflowExecutionResult( + success=False, + message="No condition expression configured", + error="No condition expression configured" + ) + logger.info(f"Condition result: {condition_result}") + + # Find next node based on condition result + next_node_id = self._find_conditional_next_node(node, condition_result) + + return WorkflowExecutionResult( + success=True, + message="", # Condition nodes don't produce user-facing messages + next_node_id=next_node_id, + should_continue=next_node_id is not None + ) + + except Exception as e: + logger.error(f"Error executing condition node: {str(e)}") + return WorkflowExecutionResult( + success=False, + message="Error evaluating condition", + error=str(e) + ) + + def _execute_form_node( + self, + node: WorkflowNode, + workflow_state: Dict[str, Any], + user_message: str, + session_id: str = None + ) -> WorkflowExecutionResult: + """Execute a form node""" + logger.info(f"Executing form node: {node.id}") + config = node.config or {} + + # Get form fields from config (frontend stores them there) + form_fields = config.get("form_fields", []) + + if not form_fields: + return WorkflowExecutionResult( + success=False, + message="No form fields configured", + error="No form fields" + ) + + # Check if we're waiting for form submission + current_state = workflow_state.get("form_state", "display") + + if current_state == "display" or current_state == "waiting": + # First time hitting form node OR user refreshed while waiting - display the form + form_data = { + "title": config.get("form_title", ""), + "description": config.get("form_description", ""), + "submit_button_text": config.get("submit_button_text", "Submit"), + "fields": form_fields, + "form_full_screen": config.get("form_full_screen", False) + } + logger.debug(f"Form data: {form_data}") + # Mark that we're waiting for form submission + workflow_state["form_state"] = "waiting" + + result = WorkflowExecutionResult( + success=True, + message="", # No text message for form display + next_node_id=None, # Don't proceed yet + should_continue=False, # Wait for form submission + form_data=form_data # Include form data for display + ) + logger.debug(f"Form result: {result}") + return result + elif current_state == "submitted": + # Form has been submitted, process and continue + form_submission = workflow_state.get("form_data", {}) + logger.debug(f"Form submission: {form_submission}") + + # Store form variables for use in other nodes with node-based naming + node_based_variables = {} + node_name = node.name.lower().replace(' ', '_').replace('-', '_') if node.name else 'form' + # Remove non-alphanumeric characters except underscores + import re + node_name = re.sub(r'[^a-z0-9_]', '', node_name) + + for field_name, field_value in form_submission.items(): + variable_name = f"{node_name}_{field_name}" + node_based_variables[variable_name] = field_value + + self._store_form_variables(workflow_state, node.id, node_based_variables) + + # Clear form state after processing + workflow_state.pop("form_state", None) + workflow_state.pop("form_data", None) + # Store form submission in workflow history + self.session_repo.add_workflow_history_entry(session_id, node.id, "form_submission", form_submission) + + # Find next node + next_node_id = self._find_next_node(node) + logger.debug(f"Next node ID: {next_node_id}") + return WorkflowExecutionResult( + success=True, + message="", + next_node_id=next_node_id, + should_continue=next_node_id is not None + ) + else: + # Invalid state - but still try to display the form rather than error + logger.warning(f"Unknown form state '{current_state}', defaulting to display form") + form_data = { + "title": config.get("form_title", ""), + "description": config.get("form_description", ""), + "submit_button_text": config.get("submit_button_text", "Submit"), + "fields": form_fields, + "form_full_screen": config.get("form_full_screen", False) + } + # Reset to waiting state + workflow_state["form_state"] = "waiting" + + return WorkflowExecutionResult( + success=True, + message="", + next_node_id=None, + should_continue=False, + form_data=form_data + ) + + def _execute_landing_page_node(self, node: WorkflowNode, workflow_state: Dict[str, Any], user_message: Optional[str] = None) -> WorkflowExecutionResult: + """Execute a landing page node""" + logger.info(f"Executing landing page node: {node.id}, user_message present: {bool(user_message)}") + config = node.config or {} + heading = config.get("landing_page_heading", "Welcome") + content = config.get("landing_page_content", "Thank you for visiting!") + + # Process variables in heading and content + heading = self._process_variables(heading, workflow_state) + content = self._process_variables(content, workflow_state) + + # Create landing page data + landing_page_data = { + "heading": heading, + "content": content + } + + # If user message is present, proceed to next node + if user_message: + logger.info(f"User message received on landing page node, proceeding to next node") + next_node_id = self._find_next_node(node) + return WorkflowExecutionResult( + success=True, + message="", # No text message needed + next_node_id=next_node_id, + should_continue=True, # Continue execution + landing_page_data=None # Don't show landing page again + ) + + # Don't find next node immediately - wait for user to proceed + result = WorkflowExecutionResult( + success=True, + message="", # No text message for landing page display + next_node_id=None, # Don't proceed yet + should_continue=False, # Wait for user to proceed + landing_page_data=landing_page_data # Include landing page data for display + ) + logger.debug(f"Landing page result: {result}") + return result + + def _execute_action_node(self, node: WorkflowNode, workflow_state: Dict[str, Any]) -> WorkflowExecutionResult: + """Execute an action node""" + # This is a simplified implementation + # In a full implementation, you'd handle webhooks, database operations, etc. + + config = node.config or {} + action_type = config.get("action_type") + action_config = config.get("action_config", {}) + + logger.info(f"Executing action: {action_type}") + + # Don't store action execution in workflow state to keep it minimal + + # Find next node + next_node_id = self._find_next_node(node) + + return WorkflowExecutionResult( + success=True, + message="Action completed successfully.", + next_node_id=next_node_id, + should_continue=next_node_id is not None + ) + + def _execute_human_transfer_node( + self, + node: WorkflowNode, + workflow_state: Dict[str, Any] + ) -> WorkflowExecutionResult: + """Execute a human transfer node""" + config = node.config or {} + transfer_rules = config.get("transfer_rules", {}) + message = transfer_rules.get("message", "Transferring you to a human agent. Please wait...") + transfer_group_id = config.get("transfer_group_id") + + return WorkflowExecutionResult( + success=True, + message=message, + transfer_to_human=True, + transfer_group_id=transfer_group_id, + should_continue=False + ) + + def _execute_wait_node(self, node: WorkflowNode, workflow_state: Dict[str, Any]) -> WorkflowExecutionResult: + """Execute a wait node""" + # This is a simplified implementation + # In a full implementation, you'd handle timed waits and conditions + + config = node.config or {} + wait_duration = config.get("wait_duration", 0) + wait_unit = config.get("wait_unit", "seconds") + message = f"Please wait {wait_duration} {wait_unit}..." + + # Find next node + next_node_id = self._find_next_node(node) + + return WorkflowExecutionResult( + success=True, + message=message, + next_node_id=next_node_id, + should_continue=next_node_id is not None + ) + + def _execute_end_node(self, node: WorkflowNode, workflow_state: Dict[str, Any]) -> WorkflowExecutionResult: + """Execute an end node""" + config = node.config or {} + message = config.get("message_text", "Thank you for using our service!") + + # Check if we should request rating + request_rating = config.get("request_rating", False) + + return WorkflowExecutionResult( + success=True, + message=message, + end_chat=True, + request_rating=request_rating, + should_continue=False + ) + + def _execute_guardrails_node( + self, + node: WorkflowNode, + workflow: Workflow, + workflow_state: Dict[str, Any], + user_message: str + ) -> WorkflowExecutionResult: + """ + Execute a guardrails node to check content against configured guardrails + + Args: + node: Guardrails workflow node + workflow: Current workflow + workflow_state: Current workflow state + user_message: User's message to check + + Returns: + WorkflowExecutionResult with pass/fail status + """ + from app.utils.guardrails import check_guardrails + + config = node.config or {} + + # Get guardrail configuration + enabled_guardrails = config.get("enabled_guardrails", ["pii", "jailbreak"]) + pii_action = config.get("pii_action", "block") + jailbreak_sensitivity = config.get("jailbreak_sensitivity", 0.7) + + # Get text to check - either from user message or from a variable + text_source = config.get("text_source", "user_message") + + if text_source == "user_message": + # Try to get from parameter first, then from workflow state + if user_message: + text_to_check = user_message + elif "variables" in workflow_state and "user_message" in workflow_state["variables"]: + text_to_check = workflow_state["variables"]["user_message"] + logger.debug(f"Using user_message from workflow state: {text_to_check}") + else: + text_to_check = "" + else: + # Check if it's a variable reference + # If the text_source doesn't contain {{}} syntax, wrap it + if not (text_source.startswith("{{") and text_source.endswith("}}")): + text_source = f"{{{{{text_source}}}}}" + text_to_check = self._process_variables(text_source, workflow_state) + + if not text_to_check: + logger.warning(f"No text to check in guardrails node {node.id}") + # No text to check - pass through + next_node_id = self._find_next_node(node) + return WorkflowExecutionResult( + success=True, + message="", + next_node_id=next_node_id, + should_continue=True + ) + + # Run guardrail checks + passed, results, block_message = check_guardrails( + text=text_to_check, + guardrail_types=enabled_guardrails, + pii_action=pii_action, + jailbreak_sensitivity=jailbreak_sensitivity + ) + + # Store results in workflow state + if "guardrails" not in workflow_state: + workflow_state["guardrails"] = {} + + workflow_state["guardrails"][str(node.id)] = { + "passed": passed, + "results": results, + "checked_text": text_to_check[:100] + "..." if len(text_to_check) > 100 else text_to_check + } + + logger.info(f"Guardrails node {node.id} - Passed: {passed}, Results: {results}") + + # Find connections - guardrails nodes can have two outputs: "pass" and "fail" + pass_node_id = None + fail_node_id = None + + for connection in node.outgoing_connections: + condition_label = connection.label + if condition_label and condition_label.lower() == "fail": + fail_node_id = connection.target_node_id + else: + # Default or "pass" label goes to pass path + pass_node_id = connection.target_node_id + + if passed: + # Guardrails passed - continue to pass node or default next + next_node_id = pass_node_id or self._find_next_node(node) + + # Check if we should redact and update the message + redacted_text = None + for result in results: + if result.get("redacted_text"): + redacted_text = result["redacted_text"] + break + + if redacted_text and pii_action == "redact": + # Store redacted text in workflow state for use by subsequent nodes + workflow_state["last_redacted_message"] = redacted_text + + # Determine message and continuation based on whether there's a next node + message = "" + should_continue = True + + if not next_node_id: + # Guardrails passed but this is a terminal node - provide feedback + logger.info(f"Guardrails node {node.id} passed - this is a terminal node (no next node configured)") + should_continue = False + # Optionally provide a success message if this is the end of workflow + message = "Content check passed successfully." + + return WorkflowExecutionResult( + success=True, + message=message, + next_node_id=next_node_id, + should_continue=should_continue + ) + else: + # Guardrails failed - go to fail node or block + if fail_node_id: + # Continue to fail handling node + next_node_id = fail_node_id + return WorkflowExecutionResult( + success=True, + message="", + next_node_id=next_node_id, + should_continue=True + ) + else: + # No fail node - block with message + block_message_config = config.get("block_message", "") + final_message = block_message_config if block_message_config else block_message + + return WorkflowExecutionResult( + success=True, + message=final_message, + next_node_id=None, + should_continue=False + ) + + + def _execute_user_input_node( + self, + node: WorkflowNode, + workflow_state: Dict[str, Any], + user_message: str, + session_id: str = None + ) -> WorkflowExecutionResult: + """Execute a user input node""" + logger.info(f"Executing user input node: {node.id}") + config = node.config or {} + + # Check if we're waiting for user input + current_state = workflow_state.get("user_input_state", "waiting") + + if current_state == "waiting" and (not user_message or user_message.strip() == ""): + # Still waiting for user input + prompt_message = config.get("prompt_message", "") + + # Only process and display prompt if it exists and is not empty + if prompt_message and prompt_message.strip(): + prompt_message = self._process_variables(prompt_message, workflow_state) + message_to_display = prompt_message + else: + # No prompt message configured - return empty message + message_to_display = "" + + # Mark that we're waiting for user input + workflow_state["user_input_state"] = "waiting" + workflow_state["user_input_node_id"] = str(node.id) + + return WorkflowExecutionResult( + success=True, + message=message_to_display, + next_node_id=None, # Don't proceed yet + should_continue=False # Wait for user input + ) + + elif current_state == "waiting" and user_message and user_message.strip(): + # User has provided input - process and continue + user_input = user_message.strip() + logger.debug(f"User input received: {user_input}") + + # Store user input as a variable for use in other nodes with node-based naming + node_name = node.name.lower().replace(' ', '_').replace('-', '_') if node.name else 'user_input' + # Remove non-alphanumeric characters except underscores + node_name = re.sub(r'[^a-z0-9_]', '', node_name) + variable_name = f"{node_name}_input" + + user_input_data = {variable_name: user_input} + self._store_form_variables(workflow_state, node.id, user_input_data) + + # Store user input in workflow state temporarily + workflow_state["user_input_data"] = user_input + workflow_state["user_input_state"] = "received" + + # Store user input in workflow history + if session_id: + self.session_repo.add_workflow_history_entry( + session_id, + node.id, + "user_input", + {"input": user_input, "node_name": node.name} + ) + + # Clear the user input state after processing + workflow_state.pop("user_input_state", None) + workflow_state.pop("user_input_node_id", None) + workflow_state.pop("user_input_data", None) + + # Find next node + next_node_id = self._find_next_node(node) + logger.debug(f"Next node ID: {next_node_id}") + + # Get confirmation message or use default + confirmation_message = config.get("confirmation_message", "") + if confirmation_message: + confirmation_message = self._process_variables(confirmation_message, workflow_state) + return WorkflowExecutionResult( + success=True, + message=confirmation_message, + next_node_id=next_node_id, + should_continue=next_node_id is not None + ) + else: + # No confirmation message - proceed silently to next node + return WorkflowExecutionResult( + success=True, + message="", + next_node_id=next_node_id, + should_continue=next_node_id is not None + ) + else: + # Invalid state - reset to waiting + logger.warning(f"Unknown user input state '{current_state}', resetting to waiting") + prompt_message = config.get("prompt_message", "") + + # Only process and display prompt if it exists and is not empty + if prompt_message and prompt_message.strip(): + prompt_message = self._process_variables(prompt_message, workflow_state) + message_to_display = prompt_message + else: + # No prompt message configured - return empty message + message_to_display = "" + + workflow_state["user_input_state"] = "waiting" + workflow_state["user_input_node_id"] = str(node.id) + + return WorkflowExecutionResult( + success=True, + message=message_to_display, + next_node_id=None, + should_continue=False + ) + + def _find_next_node(self, node: WorkflowNode) -> Optional[UUID]: + """Find the next node in the workflow""" + if not node.outgoing_connections: + return None + + # Return the first outgoing connection's target + # In a more complex implementation, you'd handle multiple connections + return node.outgoing_connections[0].target_node_id + + + + def _find_conditional_next_node(self, node: WorkflowNode, condition_result: bool) -> Optional[UUID]: + """Find the connection labelled for the condition result. + + With no such connection the workflow stops here rather than taking + another branch: a false result must never run the "true" path. + """ + wanted = "true" if condition_result else "false" + for connection in node.outgoing_connections or []: + if wanted in (_branch_label(connection.label), _branch_label(connection.condition)): + return connection.target_node_id + + logger.warning( + f"Condition node '{node.name}' ({node.id}) has no '{wanted}' connection; " + f"stopping the workflow here. Connect its '{wanted}' branch to fix this." + ) + return None + + + + def _process_variables(self, text: str, workflow_state: Dict[str, Any]) -> str: + """Process variables in text using {{variable}} syntax""" + return self._interpolate_variables(text, workflow_state) + + def _evaluate_condition(self, condition: str, workflow_state: Dict[str, Any]) -> bool: + """Evaluate a condition expression string""" + return evaluate_expression(condition, workflow_state) + + def _update_session_workflow_state( + self, + session_id: str, + next_node_id: Optional[UUID], + workflow_state: Dict[str, Any] + ) -> None: + """Update session with new workflow state""" + try: + logger.info(f"Updating session {session_id} with next_node_id: {next_node_id}") + logger.info(f"Updating session {session_id} with workflow_state: {workflow_state}") + + success = self.session_repo.update_workflow_state(session_id, next_node_id, workflow_state) + if success: + logger.info(f"Successfully updated session {session_id} workflow state") + else: + logger.error(f"Failed to update session {session_id} workflow state") + + except Exception as e: + logger.error(f"Error updating session workflow state: {str(e)}") + + async def _build_context_message(self, session_id: str, workflow_state: Dict[str, Any]) -> str: + """ + Build a structured context message when user_message is empty or null. + Includes chat history and workflow history formatted as instructions for LLM. + """ + try: + # Initialize repositories + chat_repo = ChatRepository(self.db) + + # Get chat history for the session + chat_history = await chat_repo.get_session_history(session_id) + + # Get workflow history for the session + workflow_history = self.session_repo.get_workflow_history(session_id) + + # Build structured context message + context_parts = [] + context_parts.append("CONTEXT on previous workflow messages, you can use this along with instructions for understanding context") + # Add instruction header + + + # Add chat history section + if chat_history: + context_parts.append("CONVERSATION HISTORY:") + context_parts.append("-" * 25) + + for i, message in enumerate(chat_history, 1): + timestamp = message.created_at.strftime("%Y-%m-%d %H:%M:%S") if message.created_at else "Unknown" + message_type = message.message_type.upper() + + # Format message with context + context_parts.append(f"{i}. [{timestamp}] {message_type}: {message.message}") + + # Add attributes if they contain useful context + if message.attributes: + relevant_attrs = {} + for key, value in message.attributes.items(): + if key in ['workflow_execution', 'transfer_to_human', 'end_chat', 'form_submission', 'user_input']: + relevant_attrs[key] = value + + if relevant_attrs: + context_parts.append(f" Context: {relevant_attrs}") + + context_parts.append("") + else: + context_parts.append("CONVERSATION HISTORY: No previous messages") + context_parts.append("") + + # Add workflow history section + if workflow_history: + context_parts.append("WORKFLOW INTERACTION HISTORY:") + context_parts.append("-" * 35) + + for i, entry in enumerate(workflow_history, 1): + timestamp = entry.get('timestamp', 'Unknown') + entry_type = entry.get('type', 'Unknown') + node_id = entry.get('node_id', 'Unknown') + data = entry.get('data', {}) + + context_parts.append(f"{i}. [{timestamp}] {entry_type.upper()} (Node: {node_id})") + + # Format specific data types + if entry_type == 'form_submission' and data: + context_parts.append(f" Form Data: {json.dumps(data, indent=6)}") + elif entry_type == 'user_input' and data: + context_parts.append(f" User Input: {data}") + elif data: + context_parts.append(f" Data: {json.dumps(data, indent=6)}") + + context_parts.append("") + else: + context_parts.append("WORKFLOW INTERACTION HISTORY: No previous workflow interactions") + context_parts.append("") + + # Add current workflow state + if workflow_state: + context_parts.append("CURRENT WORKFLOW STATE:") + context_parts.append("-" * 27) + context_parts.append(json.dumps(workflow_state, indent=2)) + context_parts.append("") + + # Add analysis instructions + + + return "\n".join(context_parts) + + except Exception as e: + logger.error(f"Error building context message: {str(e)}") + # Fallback message + return ("Please analyze the current conversation context and provide an appropriate response. " + "The user has not provided a new message, so please review the conversation history " + "and workflow progress to determine the next appropriate action.") + + def _store_form_variables(self, workflow_state: Dict[str, Any], node_id: UUID, form_data: Dict[str, Any]) -> None: + """ + Store form variables in workflow state for use in other nodes + + Args: + workflow_state: Current workflow state + node_id: ID of the form node + form_data: Submitted form data + """ + try: + # Initialize variables structure if it doesn't exist + if "variables" not in workflow_state: + workflow_state["variables"] = {} + + # Store variables namespaced by node ID + workflow_state["variables"][str(node_id)] = form_data.copy() + + logger.info(f"Stored form variables from node {node_id}: {list(form_data.keys())}") + logger.debug(f"Variable values: {form_data}") + + except Exception as e: + logger.error(f"Error storing form variables: {str(e)}") + + def _interpolate_variables(self, text: str, workflow_state: Dict[str, Any]) -> str: + """ + Replace variable placeholders in text with actual values + + Supports syntax: + - {{user_message}} - A top-level variable such as the latest user message + - {{field_name}} - Search all form nodes for field (latest value wins) + - {{node_id.field_name}} - Specific node's field value + + Args: + text: Text containing variable placeholders + workflow_state: Current workflow state with variables + + Returns: + Text with variables interpolated + """ + if not text or not isinstance(text, str): + return text + + try: + variables = workflow_state.get("variables", {}) + if not variables: + return text + + # Pattern to match {{variable}} or {{node_id.field_name}} + pattern = r'\{\{([^}]+)\}\}' + + def replace_variable(match): + var_expression = match.group(1).strip() + value = resolve_variable(var_expression, workflow_state) + if value is MISSING: + logger.warning(f"Variable not found: {var_expression}") + return match.group(0) # Return original {{variable}} + return str(value) + + # Replace all variable placeholders + result = re.sub(pattern, replace_variable, text) + + if result != text: + logger.debug(f"Variable interpolation: '{text}' -> '{result}'") + + return result + + except Exception as e: + logger.error(f"Error interpolating variables: {str(e)}") + return text # Return original text on error + + \ No newline at end of file diff --git a/backend/app/services/workflow_node.py b/backend/app/services/workflow_node.py new file mode 100644 index 0000000..463f190 --- /dev/null +++ b/backend/app/services/workflow_node.py @@ -0,0 +1,463 @@ +""" +Copyright 2024-2026 ChatterMate + +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 typing import List, Dict, Any, Optional +from uuid import UUID +import uuid +import re +from sqlalchemy.orm import Session +from app.repositories.workflow_node import WorkflowNodeRepository +from app.repositories.workflow import WorkflowRepository + +from app.core.logger import get_logger + +logger = get_logger(__name__) + + +def sanitize_utf8_text(text: str) -> str: + """ + Sanitize text to remove invalid UTF-8 characters and surrogates + """ + if not text: + return text + + # Remove surrogate characters (like \udcac, \udd16) + text = re.sub(r'[\ud800-\udfff]', '', text) + + # Encode to UTF-8 and decode to handle any remaining issues + try: + text = text.encode('utf-8', errors='ignore').decode('utf-8') + except UnicodeError: + # If still failing, remove all non-ASCII characters + text = re.sub(r'[^\x00-\x7F]', '', text) + + return text.strip() + + +class WorkflowNodeService: + def __init__(self, db: Session): + self.db = db + self.node_repo = WorkflowNodeRepository(db) + self.workflow_repo = WorkflowRepository(db) + + def _generate_unique_node_name(self, workflow_id: UUID, base_name: str, exclude_node_id: UUID = None) -> str: + """ + Generate a unique node name by appending _001, _002, etc. if needed + + Args: + workflow_id: The workflow ID to check within + base_name: The base name to make unique + exclude_node_id: Node ID to exclude from uniqueness check (for updates) + + Returns: + Unique node name + """ + try: + # Get all existing nodes in the workflow + workflow = self.workflow_repo.get_by_id(workflow_id) + if not workflow: + return base_name + + existing_names = set() + for node in workflow.nodes: + if exclude_node_id and node.id == exclude_node_id: + continue # Skip the node being updated + if node.name: + existing_names.add(node.name.lower()) + + # Check if base name is already unique + if base_name.lower() not in existing_names: + return base_name + + # Generate unique name with suffix + counter = 1 + while True: + candidate_name = f"{base_name}_{counter:03d}" + if candidate_name.lower() not in existing_names: + return candidate_name + counter += 1 + + # Safety check to prevent infinite loop + if counter > 999: + # Fallback to UUID suffix + return f"{base_name}_{str(uuid.uuid4())[:8]}" + + except Exception as e: + logger.error(f"Error generating unique node name: {str(e)}") + # Fallback to original name with UUID suffix + return f"{base_name}_{str(uuid.uuid4())[:8]}" + + def _validate_node_name_uniqueness(self, workflow_id: UUID, node_name: str, exclude_node_id: UUID = None) -> bool: + """ + Validate that a node name is unique within the workflow + + Args: + workflow_id: The workflow ID to check within + node_name: The name to validate + exclude_node_id: Node ID to exclude from uniqueness check (for updates) + + Returns: + True if name is unique, False otherwise + """ + try: + workflow = self.workflow_repo.get_by_id(workflow_id) + if not workflow: + return True + + for node in workflow.nodes: + if exclude_node_id and node.id == exclude_node_id: + continue # Skip the node being updated + if node.name and node.name.lower() == node_name.lower(): + return False + + return True + + except Exception as e: + logger.error(f"Error validating node name uniqueness: {str(e)}") + return False + + def _ensure_unicode_support_in_config(self, config: dict) -> dict: + """ + Ensure Unicode characters are properly preserved in config data + by applying JSON serialization with ensure_ascii=False + """ + import json + import copy + + if not config: + return config + + try: + # Create a deep copy to avoid modifying the original + config_copy = copy.deepcopy(config) + + # Convert to JSON with ensure_ascii=False to preserve Unicode characters + # Then parse it back to ensure it's properly formatted for database storage + json_str = json.dumps(config_copy, ensure_ascii=False, separators=(',', ':')) + unicode_safe_config = json.loads(json_str) + + logger.debug(f"Applied Unicode-safe processing to config: {len(str(config_copy))} chars -> {len(str(unicode_safe_config))} chars") + return unicode_safe_config + + except Exception as e: + logger.error(f"Error applying Unicode-safe processing to config: {str(e)}") + return config + + def replace_workflow_nodes_and_connections( + self, + workflow_id: UUID, + nodes_data: List[Dict[str, Any]], + connections_data: List[Dict[str, Any]], + organization_id: UUID + ) -> Dict[str, List]: + """Replace all workflow nodes and connections with cached data (delete existing, create new)""" + + # Validate workflow exists and belongs to organization + workflow = self.workflow_repo.get_by_id(workflow_id) + if not workflow: + raise ValueError("Workflow not found") + + if workflow.organization_id != organization_id: + raise ValueError("Workflow does not belong to your organization") + + try: + # Delete all existing connections first (due to foreign key constraints) + existing_connections = self.node_repo.get_connections_by_workflow(workflow_id) + for connection in existing_connections: + self.db.delete(connection) + + # Delete all existing nodes + existing_nodes = self.node_repo.get_nodes_by_workflow(workflow_id) + for node in existing_nodes: + self.db.delete(node) + + # Commit deletions + self.db.commit() + + logger.info(f"Deleted {len(existing_nodes)} existing nodes and {len(existing_connections)} existing connections for workflow {workflow_id}") + + # Create ID mapping for text IDs to UUIDs + id_mapping = {} + + def is_text_id(node_id: str) -> bool: + """Check if ID is a text ID (like message-1, llm-2) instead of UUID""" + try: + UUID(node_id) + return False + except ValueError: + return True + + # First pass: Create ID mappings for text IDs + for node_data in nodes_data: + node_id = str(node_data.get('id', '')) + if node_id and is_text_id(node_id): + if node_id not in id_mapping: + id_mapping[node_id] = str(uuid.uuid4()) + logger.info(f"Mapped text ID '{node_id}' to UUID '{id_mapping[node_id]}'") + + # Also create mappings for connection IDs if they are text IDs + for conn_data in connections_data: + conn_id = str(conn_data.get('id', '')) + if conn_id and is_text_id(conn_id): + if conn_id not in id_mapping: + id_mapping[conn_id] = str(uuid.uuid4()) + logger.info(f"Mapped connection text ID '{conn_id}' to UUID '{id_mapping[conn_id]}'") + + # Create all new nodes + created_nodes = [] + for node_data in nodes_data: + node_data_copy = node_data.copy() + node_data_copy['workflow_id'] = workflow_id + + # Convert workflow_id to UUID if it's a string + if isinstance(node_data_copy['workflow_id'], str): + node_data_copy['workflow_id'] = UUID(node_data_copy['workflow_id']) + + # Extract and clean node-specific fields into config + config_fields = [ + 'message_text', 'system_prompt', 'temperature', 'model_id', + 'condition_expression', 'action_type', 'action_url', 'action_config', + 'transfer_department', 'transfer_message', 'transfer_rules', + 'wait_duration', 'wait_unit', 'wait_until_condition', + 'final_message', 'form_fields', 'form_title', 'form_description', + 'submit_button_text', 'form_full_screen', + 'landing_page_heading', 'landing_page_content', + 'enabled_guardrails', 'pii_action', 'jailbreak_sensitivity', + 'text_source', 'block_message' + ] + + # Initialize config if not present + if 'config' not in node_data_copy: + node_data_copy['config'] = {} + + # Move config fields from root to config object and filter out blank values + for field in config_fields: + if field in node_data_copy: + value = node_data_copy[field] + # Only add non-blank values to config + if value is not None and value != "" and value != []: + node_data_copy['config'][field] = value + # Remove from root level + del node_data_copy[field] + + # Apply Unicode-safe processing to the entire config to handle multi-language content + node_data_copy['config'] = self._ensure_unicode_support_in_config(node_data_copy['config']) + + # Sanitize text fields to prevent UTF-8 encoding errors + base_text_fields = ['name', 'description'] + for field in base_text_fields: + if field in node_data_copy and isinstance(node_data_copy[field], str): + node_data_copy[field] = sanitize_utf8_text(node_data_copy[field]) + + # Sanitize config text fields + config_text_fields = ['message_text', 'system_prompt', 'condition_expression', + 'action_type', 'action_url', 'transfer_department', 'transfer_message', + 'final_message', 'form_title', 'form_description', 'submit_button_text', + 'landing_page_heading', 'landing_page_content', 'text_source', 'block_message'] + for field in config_text_fields: + if field in node_data_copy['config'] and isinstance(node_data_copy['config'][field], str): + node_data_copy['config'][field] = sanitize_utf8_text(node_data_copy['config'][field]) + + # Replace text ID with UUID if mapped + original_id = str(node_data_copy.get('id', '')) + if original_id in id_mapping: + node_data_copy['id'] = id_mapping[original_id] + elif 'id' not in node_data_copy or not node_data_copy['id']: + # Generate new ID if not provided + node_data_copy['id'] = uuid.uuid4() + else: + # Convert string ID to UUID + node_data_copy['id'] = UUID(str(node_data_copy['id'])) + + # Filter out any remaining None or empty values from base fields + filtered_data = {} + for key, value in node_data_copy.items(): + if value is not None and value != "": + filtered_data[key] = value + + new_node = self.node_repo.create_node(**filtered_data) + created_nodes.append(new_node) + + # Commit node creations before processing connections + self.db.commit() + + # Create all new connections + created_connections = [] + for conn_data in connections_data: + conn_data_copy = conn_data.copy() + conn_data_copy['workflow_id'] = workflow_id + + # Convert workflow_id to UUID if it's a string + if isinstance(conn_data_copy['workflow_id'], str): + conn_data_copy['workflow_id'] = UUID(conn_data_copy['workflow_id']) + + # Sanitize text fields to prevent UTF-8 encoding errors + text_fields = ['label', 'condition'] + for field in text_fields: + if field in conn_data_copy and isinstance(conn_data_copy[field], str): + conn_data_copy[field] = sanitize_utf8_text(conn_data_copy[field]) + + # Replace text ID with UUID if mapped + original_id = str(conn_data_copy.get('id', '')) + if original_id in id_mapping: + conn_data_copy['id'] = id_mapping[original_id] + elif 'id' not in conn_data_copy or not conn_data_copy['id']: + # Generate new ID if not provided + conn_data_copy['id'] = uuid.uuid4() + else: + # Convert string ID to UUID + conn_data_copy['id'] = UUID(str(conn_data_copy['id'])) + + # Map source and target node IDs if they are text IDs + source_node_id = str(conn_data_copy.get('source_node_id', '')) + if source_node_id in id_mapping: + conn_data_copy['source_node_id'] = id_mapping[source_node_id] + + target_node_id = str(conn_data_copy.get('target_node_id', '')) + if target_node_id in id_mapping: + conn_data_copy['target_node_id'] = id_mapping[target_node_id] + + # Convert source and target node IDs to UUIDs + if 'source_node_id' in conn_data_copy: + conn_data_copy['source_node_id'] = UUID(str(conn_data_copy['source_node_id'])) + if 'target_node_id' in conn_data_copy: + conn_data_copy['target_node_id'] = UUID(str(conn_data_copy['target_node_id'])) + + new_conn = self.node_repo.create_connection(**conn_data_copy) + created_connections.append(new_conn) + + # Final commit for connections + self.db.commit() + + logger.info(f"Created {len(created_nodes)} new nodes and {len(created_connections)} new connections for workflow {workflow_id}") + + # Return all current nodes and connections + final_nodes = self.node_repo.get_nodes_by_workflow(workflow_id) + final_connections = self.node_repo.get_connections_by_workflow(workflow_id) + + return { + "nodes": final_nodes, + "connections": final_connections + } + + except Exception as e: + logger.error(f"Error replacing workflow nodes and connections: {str(e)}") + self.db.rollback() + raise + + def get_workflow_nodes_and_connections(self, workflow_id: UUID, organization_id: UUID) -> Dict[str, List]: + """Get all nodes and connections for a workflow""" + + # Validate workflow exists and belongs to organization + workflow = self.workflow_repo.get_by_id(workflow_id) + if not workflow: + raise ValueError("Workflow not found") + + if workflow.organization_id != organization_id: + raise ValueError("Workflow does not belong to your organization") + + nodes = self.node_repo.get_nodes_by_workflow(workflow_id) + connections = self.node_repo.get_connections_by_workflow(workflow_id) + + return { + "nodes": nodes, + "connections": connections + } + + def update_single_node( + self, + workflow_id: UUID, + node_id: UUID, + node_data: Dict[str, Any], + organization_id: UUID = None + ) -> Dict[str, Any]: + """Update a single workflow node with its properties""" + + # Validate workflow exists and belongs to organization + workflow = self.workflow_repo.get_by_id(workflow_id) + if not workflow: + raise ValueError("Workflow not found") + + if organization_id and workflow.organization_id != organization_id: + raise ValueError("Workflow does not belong to your organization") + + # Validate node exists and belongs to workflow + node = self.node_repo.get_node_by_id(node_id) + if not node: + raise ValueError("Node not found") + + if node.workflow_id != workflow_id: + raise ValueError("Node does not belong to this workflow") + + try: + logger.debug(f"Node data: {node_data}") + # Sanitize text fields to prevent UTF-8 encoding errors + node_data_copy = node_data.copy() + text_fields = ['name', 'description', 'message_text', 'system_prompt', + 'condition_expression', 'action_type'] + for field in text_fields: + if field in node_data_copy and isinstance(node_data_copy[field], str): + node_data_copy[field] = sanitize_utf8_text(node_data_copy[field]) + + # Validate and ensure unique node name + if 'name' in node_data_copy and node_data_copy['name']: + if not self._validate_node_name_uniqueness(workflow_id, node_data_copy['name'], node_id): + # Generate unique name if not unique + node_data_copy['name'] = self._generate_unique_node_name(workflow_id, node_data_copy['name'], node_id) + logger.info(f"Generated unique node name: {node_data_copy['name']}") + + # Apply Unicode-safe processing to config if present + if 'config' in node_data_copy and isinstance(node_data_copy['config'], dict): + node_data_copy['config'] = self._ensure_unicode_support_in_config(node_data_copy['config']) + + logger.debug(f"Node data copy: {node_data_copy}") + # Update node properties + updated_node = self.node_repo.update_node(node_id, **node_data_copy) + if not updated_node: + raise ValueError("Failed to update node") + + # Commit changes + self.db.commit() + + logger.info(f"Updated node {node_id}") + + return updated_node + + except Exception as e: + logger.error(f"Error updating node {node_id}: {str(e)}") + self.db.rollback() + raise + + def get_single_node(self, workflow_id: UUID, node_id: UUID, organization_id: UUID = None): + """Get a single node""" + + # Validate workflow exists and belongs to organization + workflow = self.workflow_repo.get_by_id(workflow_id) + if not workflow: + raise ValueError("Workflow not found") + + if organization_id and workflow.organization_id != organization_id: + raise ValueError("Workflow does not belong to your organization") + + # Validate node exists and belongs to workflow + node = self.node_repo.get_node_by_id(node_id) + if not node: + raise ValueError("Node not found") + + if node.workflow_id != workflow_id: + raise ValueError("Node does not belong to this workflow") + + return node \ No newline at end of file diff --git a/backend/scripts/prune_offsite_pages.py b/backend/scripts/prune_offsite_pages.py new file mode 100644 index 0000000..4110475 --- /dev/null +++ b/backend/scripts/prune_offsite_pages.py @@ -0,0 +1,222 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +Opt-in cleanup: drop stored pages that fall outside a website source's crawl scope. + +Crawls used to be bounded by the registrable domain, so a source seeded at +help.example.com also stored www.example.com, example.com/blog/* and every other +subdomain. New crawls stay on the seed's host (app/knowledge/crawl_scope.py); +this prunes what earlier crawls already stored. + +Unlike the mangled-mailto purge (alembic purge_mangled_crawl_pages_001, which runs +automatically because those rows are provably junk), these pages are REAL content +someone may be relying on. So this is a manual, per-source decision: it reports by +default and only deletes with --apply. + +Run it on the HOST, not inside the backend container — importing the `app` package +pulls in the FastAPI app and the ML stack, and has OOM'd production before. Nothing +here imports it; the scope rules mirror app/knowledge/crawl_scope.py in ~20 lines of +stdlib. + + DATABASE_URL=postgresql://... python backend/scripts/prune_offsite_pages.py + DATABASE_URL=postgresql://... python backend/scripts/prune_offsite_pages.py \ + --source https://help.example.com --scope path --apply + +Re-running is a no-op once a source is clean. Deleting a page removes it from the +agent's retrieval immediately; FAQs already generated from it are NOT removed — +generated FAQs record only their source, not the page they came from, so review +them in the dashboard afterwards. +""" + +import argparse +import os +import re +import sys +from collections import defaultdict +from urllib.parse import urlparse + +import psycopg +from psycopg import sql + +DESCRIPTION = "Report (and optionally delete) stored pages outside a website source's crawl scope" + +# Mirrors PAGE_ID_EXPR in app/knowledge/page_editor.py: a chunk id is the page id +# with an optional _<number> suffix. Only a numeric suffix is stripped, so a page +# named 'getting_started' stays whole. +_CHUNK_SUFFIX = re.compile(r"_[0-9]+$") + +SCOPE_HOST = "host" +SCOPE_PATH = "path" + + +def page_id_of(chunk_id: str) -> str: + return _CHUNK_SUFFIX.sub("", chunk_id) + + +def host_key(host: str) -> str: + """www-insensitive host identity, as the crawler compares hosts.""" + host = (host or "").lower().strip().rstrip(".") + return host[4:] if host.startswith("www.") else host + + +def in_scope(page_url: str, seed: str, scope: str) -> bool: + """True if ``page_url`` is inside the seed URL's scope. + + Anything that is not an http(s) URL — a text page added under a website + source, a PDF chunk keyed by UUID — is left alone: it is not a crawled page, + so it is not this script's business. + """ + try: + page = urlparse(page_url) + root = urlparse(seed) + except ValueError: + return True + if page.scheme not in ("http", "https"): + return True + # A URL with userinfo is a mangled mailto: link, not a page. The migration + # purge_mangled_crawl_pages_001 removes these; catch them here too so the + # script gives the same answer when it runs before that migration. + if page.username or page.password: + return False + if host_key(page.hostname or "") != host_key(root.hostname or ""): + return False + if scope == SCOPE_HOST: + return True + prefix = (root.path or "").rstrip("/") + path = (page.path or "").rstrip("/") + return path == prefix or path.startswith(f"{prefix}/") + + +def website_sources(conn, org=None, source=None): + query = ( + "SELECT id, organization_id, source, schema, table_name FROM knowledge " + "WHERE source_type = 'WEBSITE' AND schema IS NOT NULL AND table_name IS NOT NULL" + ) + params = [] + if org: + # organization_id is a uuid column; compare as text so a plain --org + # string doesn't trip 'operator does not exist: uuid = text'. + query += " AND organization_id::text = %s" + params.append(org) + if source: + query += " AND source LIKE %s" + params.append(f"%{source}%") + with conn.cursor() as cur: + cur.execute(query + " ORDER BY id", params) + return cur.fetchall() + + +def _table(schema, table): + """Quoted, injection-safe table reference. The names come from the knowledge + rows rather than the caller, but they still identify a table, not a value.""" + return sql.Identifier(schema, table) + + +def chunk_ids(conn, schema, table, source): + """Every chunk id stored for one source, or None if the table is gone.""" + with conn.cursor() as cur: + cur.execute("SELECT to_regclass(%s)", (_table(schema, table).as_string(conn),)) + if cur.fetchone()[0] is None: + return None + cur.execute( + sql.SQL("SELECT id FROM {} WHERE name = %s").format(_table(schema, table)), + (source,), + ) + return [row[0] for row in cur.fetchall()] + + +def delete_chunks(conn, schema, table, source, ids): + with conn.cursor() as cur: + cur.execute( + sql.SQL("DELETE FROM {} WHERE name = %s AND id = ANY(%s)").format( + _table(schema, table) + ), + (source, ids), + ) + return cur.rowcount + + +def prune(conn, scope, apply_changes, org, source_filter, samples): + sources = website_sources(conn, org, source_filter) + print(f"{len(sources)} website source(s) to inspect (scope: {scope})\n") + + total_pages = total_chunks = touched_sources = 0 + for _id, org_id, source, schema, table in sources: + ids = chunk_ids(conn, schema, table, source) + if ids is None: + print(f"! {source}: vector table {schema}.{table} is missing — skipped") + continue + + offsite = defaultdict(list) + for chunk in ids: + page = page_id_of(chunk) + if not in_scope(page, source, scope): + offsite[page].append(chunk) + if not offsite: + continue + + touched_sources += 1 + pages = sorted(offsite) + chunks = [c for page in pages for c in offsite[page]] + total_pages += len(pages) + total_chunks += len(chunks) + + kept = len({page_id_of(c) for c in ids}) - len(pages) + print(f"{source} (org {org_id})") + print(f" {len(pages)} off-scope page(s), {len(chunks)} chunk(s); {kept} page(s) kept") + for page in pages[:samples]: + print(f" - {page}") + if len(pages) > samples: + print(f" … and {len(pages) - samples} more") + + if apply_changes: + removed = delete_chunks(conn, schema, table, source, chunks) + conn.commit() + print(f" deleted {removed} chunk(s)") + print() + + verb = "Deleted" if apply_changes else "Would delete" + print(f"{verb} {total_pages} page(s) / {total_chunks} chunk(s) across {touched_sources} source(s)") + if not apply_changes and total_pages: + print("Dry run — re-run with --apply to delete.") + + +def main() -> int: + parser = argparse.ArgumentParser(description=DESCRIPTION) + parser.add_argument( + "--scope", + choices=(SCOPE_HOST, SCOPE_PATH), + default=SCOPE_HOST, + help="host: keep pages on the source URL's host (default). " + "path: also require the source URL's path prefix.", + ) + parser.add_argument("--apply", action="store_true", help="actually delete (default: report only)") + parser.add_argument("--org", help="limit to one organization id") + parser.add_argument("--source", help="limit to sources whose URL contains this string") + parser.add_argument("--samples", type=int, default=10, help="off-scope URLs to list per source") + args = parser.parse_args() + + database_url = os.environ.get("DATABASE_URL") + if not database_url: + print("DATABASE_URL is required", file=sys.stderr) + return 2 + + with psycopg.connect(database_url) as conn: + prune(conn, args.scope, args.apply, args.org, args.source, args.samples) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/tests/channels/__init__.py b/backend/tests/channels/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/channels/test_app_hooks.py b/backend/tests/channels/test_app_hooks.py new file mode 100644 index 0000000..e3a72f4 --- /dev/null +++ b/backend/tests/channels/test_app_hooks.py @@ -0,0 +1,131 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import asyncio +from datetime import datetime, timezone +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from app.channels import app_hooks +from app.channels.app_hooks import ( + ChannelEvent, EventKind, MessageSource, OutboundRecord, emit_channel_event, + inbound_event_id, outbound_event, register_event_listener, register_inbound_handler, +) + + +def _event() -> ChannelEvent: + return ChannelEvent(kind=EventKind.INBOUND, event_id="a:1", organization_id=uuid4(), + channel_account_id=uuid4(), occurred_at=datetime.now(timezone.utc)) + + +@pytest.mark.asyncio +async def test_no_extension_means_every_hook_is_a_no_op(): + assert app_hooks.inbound_handler() is None + await emit_channel_event(_event()) # nothing listening, nothing raised + + +@pytest.mark.asyncio +async def test_a_failing_listener_never_stops_the_others(): + calls = [] + + async def broken(event): + raise RuntimeError("extension bug") + + async def working(event): + calls.append(event.event_id) + + register_event_listener(broken) + register_event_listener(working) + register_event_listener(working) # registering twice is a no-op + + await emit_channel_event(_event()) + + assert calls == ["a:1"] + + +def test_load_extensions_without_one_installed_is_harmless(monkeypatch): + monkeypatch.setattr(app_hooks, "EXTENSION_HOOKS_MODULE", "app.no_such_extension_module") + app_hooks.load_extensions() + app_hooks.load_extensions() # idempotent + assert app_hooks.inbound_handler() is None + + +def test_load_extensions_imports_the_extension_once(monkeypatch): + imported = [] + monkeypatch.setattr(app_hooks.importlib, "import_module", lambda name: imported.append(name)) + app_hooks.load_extensions() + app_hooks.load_extensions() + assert imported == [app_hooks.EXTENSION_HOOKS_MODULE] + + +def test_reset_forgets_registrations(): + register_inbound_handler(AsyncMock(return_value=True)) + register_event_listener(AsyncMock()) + app_hooks.reset() + assert app_hooks.inbound_handler() is None + + +def test_event_ids_are_stable_for_the_same_message(): + account = uuid4() + assert inbound_event_id(account, "wamid.1") == inbound_event_id(account, "wamid.1") + record = OutboundRecord(message_id=7, external_message_id="wamid.OUT", + source=MessageSource.AI, text="hi") + first = outbound_event(organization_id=uuid4(), channel_account_id=account, + session_id="s", customer_id=None, record=record) + second = outbound_event(organization_id=uuid4(), channel_account_id=account, + session_id="s", customer_id=None, record=record) + assert first.event_id == second.event_id == f"{account}:out:wamid.OUT" + + +def test_outbound_without_a_platform_id_is_keyed_by_its_row(): + account = uuid4() + record = OutboundRecord(message_id=7, external_message_id=None, + source=MessageSource.SYSTEM, text="hi") + event = outbound_event(organization_id=uuid4(), channel_account_id=account, + session_id=None, customer_id=None, record=record) + assert event.event_id == f"{account}:out:row7" + + +def test_a_broken_extension_is_reported_not_silently_skipped(monkeypatch, caplog): + def import_module(name): + raise ModuleNotFoundError("No module named 'some_dependency'", name="some_dependency") + monkeypatch.setattr(app_hooks.importlib, "import_module", import_module) + + with caplog.at_level("ERROR"): + app_hooks.load_extensions() + + assert "Channel extension failed to load" in caplog.text + + +@pytest.mark.asyncio +async def test_a_slow_listener_is_cut_off(monkeypatch): + monkeypatch.setattr(app_hooks, "LISTENER_TIMEOUT_SECONDS", 0.01) + calls = [] + + async def slow(event): + await asyncio.sleep(5) + + async def fast(event): + calls.append(event.event_id) + + register_event_listener(slow) + register_event_listener(fast) + + await emit_channel_event(_event()) + + assert calls == ["a:1"] diff --git a/backend/tests/channels/test_channel_quick_replies.py b/backend/tests/channels/test_channel_quick_replies.py new file mode 100644 index 0000000..94a677e --- /dev/null +++ b/backend/tests/channels/test_channel_quick_replies.py @@ -0,0 +1,257 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +The AI's choices as tappable buttons beyond WhatsApp: Messenger and +Instagram quick replies, Telegram inline keyboards — sending them within +each channel's limits, and reading the taps back. +""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.channels import get_adapter, telegram +from app.channels.base import ( + InteractiveButton, InteractiveKind, InteractiveMessage, InteractiveReplyType, InteractiveRow, + InteractiveSection, +) +from app.channels.interactive import from_options, validate_interactive +from app.channels.interactive_limits import ( + INSTAGRAM_LIMITS, MESSENGER_LIMITS, TELEGRAM_LIMITS, WHATSAPP_LIMITS, +) +from tests.channels.test_meta_adapters import _FakeResponse, graph_client # noqa: F401 (fixture) + +LONG = "Reschedule to next Saturday morning" + + +def _conversation(external_id="PSID1"): + conversation = MagicMock() + conversation.external_conversation_id = external_id + return conversation + + +# --- building choices within each channel's limits --- + +def test_whatsapp_still_gets_a_list_for_long_options(): + assert from_options("Pick", ["Cancel", LONG], WHATSAPP_LIMITS).kind == InteractiveKind.LIST + + +@pytest.mark.parametrize("limits", [MESSENGER_LIMITS, TELEGRAM_LIMITS]) +def test_channels_without_lists_get_buttons(limits): + message = from_options("Pick", ["Cancel", LONG] + [f"Option {i}" for i in range(8)], limits) + assert message.kind == InteractiveKind.BUTTONS + assert len(message.buttons) == 10 + assert all(len(b.title) <= limits.button_title_max for b in message.buttons) + assert validate_interactive(message, limits) == [] + + +def test_a_cut_button_title_keeps_the_full_option(): + button = from_options("Pick", ["Cancel", LONG], MESSENGER_LIMITS).buttons[1] + assert button.title.endswith("…") and button.description == LONG + + +def test_a_list_or_link_button_is_refused_where_the_channel_has_none(): + list_message = InteractiveMessage(kind=InteractiveKind.LIST, body="Pick", list_button_label="Choose", + sections=[InteractiveSection(rows=[InteractiveRow(id="a", title="A")])]) + link = InteractiveMessage(kind=InteractiveKind.CTA_URL, body="Pay", display_text="Pay", url="https://x.io") + assert "this channel can't show a list" in validate_interactive(list_message, MESSENGER_LIMITS) + assert "this channel can't show a link button" in validate_interactive(link, TELEGRAM_LIMITS) + + +def test_telegram_ids_are_limited_in_bytes(): + message = InteractiveMessage(kind=InteractiveKind.BUTTONS, body="Pick", + buttons=[InteractiveButton(id="é" * 33, title="A")]) # 66 bytes, 33 chars + assert any("bytes" in e for e in validate_interactive(message, TELEGRAM_LIMITS)) + assert validate_interactive(message, MESSENGER_LIMITS) == [] + + +def test_messenger_allows_thirteen_quick_replies(): + buttons = [InteractiveButton(id=str(i), title=str(i)) for i in range(14)] + message = InteractiveMessage(kind=InteractiveKind.BUTTONS, body="Pick", buttons=buttons[:13]) + assert validate_interactive(message, MESSENGER_LIMITS) == [] + message.buttons = buttons + assert validate_interactive(message, MESSENGER_LIMITS) != [] + + +@pytest.mark.parametrize("channel,limits", [("whatsapp", WHATSAPP_LIMITS), ("messenger", MESSENGER_LIMITS), + ("instagram", INSTAGRAM_LIMITS), ("telegram", TELEGRAM_LIMITS), + ("slack", None), ("email", None)]) +def test_which_channels_can_show_buttons(channel, limits): + adapter = get_adapter(channel) + assert adapter.interactive_limits is limits + assert adapter.supports_interactive is (limits is not None) + + +# --- Messenger and Instagram --- + +@pytest.mark.asyncio +async def test_messenger_sends_quick_replies(graph_client, monkeypatch): # noqa: F811 + adapter = get_adapter("messenger") + monkeypatch.setattr(adapter, "access_token", lambda account: "TOKEN") + graph_client.response = _FakeResponse(200, {"message_id": "m.1"}) + + result = await adapter.send_interactive(MagicMock(), _conversation(), from_options("Coming?", ["Yes", "No"], + MESSENGER_LIMITS)) + + assert result.external_message_id == "m.1" + message = graph_client.calls[0]["json"]["message"] + assert message == {"text": "Coming?", "quick_replies": [ + {"content_type": "text", "title": "Yes", "payload": "qr_0"}, + {"content_type": "text", "title": "No", "payload": "qr_1"}]} + + +@pytest.mark.asyncio +async def test_instagram_also_writes_the_choices_into_the_text(graph_client, monkeypatch): # noqa: F811 + """Instagram doesn't show quick replies on desktop or the web.""" + adapter = get_adapter("instagram") + monkeypatch.setattr(adapter, "access_token", lambda account: "TOKEN") + graph_client.response = _FakeResponse(200, {"message_id": "m.1"}) + + await adapter.send_interactive(MagicMock(), _conversation(), from_options("Coming?", ["Yes", "No"], + INSTAGRAM_LIMITS)) + + message = graph_client.calls[0]["json"]["message"] + assert message["text"] == "Coming?\n\n1. Yes\n2. No" + assert [q["payload"] for q in message["quick_replies"]] == ["qr_0", "qr_1"] + + +@pytest.mark.asyncio +async def test_messenger_refuses_a_list(): + message = InteractiveMessage(kind=InteractiveKind.LIST, body="Pick", list_button_label="Choose", + sections=[InteractiveSection(rows=[InteractiveRow(id="a", title="A")])]) + assert (await get_adapter("messenger").send_interactive(MagicMock(), _conversation(), message)).ok is False + + +@pytest.mark.parametrize("channel", ["messenger", "instagram"]) +def test_a_quick_reply_tap_is_read(channel): + payload = {"entry": [{"id": "PAGE", "messaging": [{ + "sender": {"id": "PSID1"}, "timestamp": 1720000000000, + "message": {"mid": "m.2", "text": "No", "quick_reply": {"payload": "qr_1"}}}]}]} + [inbound] = get_adapter(channel).parse_inbound(payload) + assert (inbound.interactive.type, inbound.interactive.id, inbound.interactive.title) == ( + InteractiveReplyType.BUTTON_REPLY, "qr_1", "No") + assert inbound.reply_to_message_id is None + + +def test_a_swipe_reply_keeps_its_context_and_plain_text_has_no_tap(): + payload = {"entry": [{"id": "PAGE", "messaging": [{ + "sender": {"id": "PSID1"}, "timestamp": 1720000000000, + "message": {"mid": "m.3", "text": "That one", "reply_to": {"mid": "m.1"}}}]}]} + [inbound] = get_adapter("messenger").parse_inbound(payload) + assert (inbound.interactive, inbound.reply_to_message_id) == (None, "m.1") + + +# --- Telegram --- + +@pytest.fixture +def telegram_api(monkeypatch): + call = AsyncMock(return_value={"ok": True, "result": {"message_id": 55}}) + monkeypatch.setattr(telegram, "_call_api", call) + return call + + +@pytest.mark.asyncio +async def test_telegram_sends_an_inline_keyboard(telegram_api, monkeypatch): + adapter = get_adapter("telegram") + monkeypatch.setattr(adapter, "_bot_token", lambda account: "123:abc") + + result = await adapter.send_interactive(MagicMock(), _conversation("222"), + from_options("Coming?", ["Yes", "No"], TELEGRAM_LIMITS)) + + assert result.external_message_id == "55" + method, payload = telegram_api.await_args.args[1], telegram_api.await_args.args[2] + assert method == "sendMessage" + assert payload["reply_markup"] == {"inline_keyboard": [[{"text": "Yes", "callback_data": "qr_0"}], + [{"text": "No", "callback_data": "qr_1"}]]} + + +@pytest.mark.asyncio +async def test_the_webhook_is_registered_for_taps(telegram_api): + await telegram.set_webhook("123:abc", "https://x.io/hook", "secret") + assert telegram_api.await_args.args[2]["allowed_updates"] == ["message", "callback_query"] + + +def _callback(chat_type="private", data="qr_1", with_message=True, date=1720000000): + callback = {"id": "cb-9", "from": {"id": 111, "first_name": "Ada"}, "data": data} + if with_message: + callback["message"] = { + "message_id": 41, "date": date, "chat": {"id": 222, "type": chat_type}, + "reply_markup": {"inline_keyboard": [[{"text": "Yes", "callback_data": "qr_0"}], + [{"text": "No", "callback_data": "qr_1"}]]}} + return {"update_id": 5, "callback_query": callback} + + +def test_a_tap_is_read_with_its_title_from_the_keyboard(): + [inbound] = get_adapter("telegram").parse_inbound(_callback()) + assert (inbound.external_message_id, inbound.external_conversation_id, inbound.text) == ("cbq:cb-9", "222", "No") + assert (inbound.interactive.id, inbound.reply_to_message_id) == ("qr_1", "41") + + +@pytest.mark.parametrize("update", [ + _callback(chat_type="group"), # anyone in a group could tap + _callback(with_message=False), # the keyboard message is gone + _callback(date=0), # inaccessible message + _callback(data="qr_9"), # not a button on that keyboard + _callback(data=None), +]) +def test_taps_that_cant_be_trusted_are_dropped(update): + assert get_adapter("telegram").parse_inbound(update) == [] + + +def test_a_tap_is_answered_and_nothing_else_is(): + assert telegram.callback_answer(_callback(), taken=True) == {"method": "answerCallbackQuery", + "callback_query_id": "cb-9"} + assert telegram.callback_answer({"update_id": 1, "message": {"text": "hi"}}, taken=False) is None + + +def test_a_tap_we_didnt_act_on_says_so(): + assert telegram.callback_answer(_callback(), taken=False)["text"] == telegram.TAP_NOT_TAKEN + + +def test_one_tap_counts_per_keyboard(): + [first] = get_adapter("telegram").parse_inbound(_callback(data="qr_0")) + [second] = get_adapter("telegram").parse_inbound(_callback(data="qr_1")) + assert telegram.dedupe_id(first) == telegram.dedupe_id(second) == "keyboard:41" + message = get_adapter("telegram").parse_inbound({"update_id": 1, "message": { + "message_id": 7, "from": {"id": 1}, "chat": {"id": 222, "type": "private"}, "date": 1, "text": "hi"}})[0] + assert telegram.dedupe_id(message) == "7" + + +@pytest.mark.asyncio +async def test_the_keyboard_is_taken_off_a_tapped_message(telegram_api, monkeypatch): + adapter = get_adapter("telegram") + monkeypatch.setattr(adapter, "_bot_token", lambda account: "123:abc") + await adapter.clear_buttons(MagicMock(), "222", "41") + assert telegram_api.await_args.args[1:] == ("editMessageReplyMarkup", { + "chat_id": "222", "message_id": 41, "reply_markup": {"inline_keyboard": []}}) + telegram_api.side_effect = RuntimeError("message is not modified") + await adapter.clear_buttons(MagicMock(), "222", "41") # best effort: no raise + + +@pytest.mark.parametrize("chat_id,offered", [("222", True), ("-1001234", False)]) +def test_telegram_groups_get_no_buttons(chat_id, offered): + """Anyone in a group could tap them, so taps there are ignored.""" + assert (get_adapter("telegram").choice_limits_for(_conversation(chat_id)) is not None) is offered + + +def test_other_channels_offer_their_limits_in_every_conversation(): + assert get_adapter("messenger").choice_limits_for(_conversation()) is MESSENGER_LIMITS + + +@pytest.mark.parametrize("channel,cap", [("messenger", 2000), ("instagram", 1000), ("telegram", 4096)]) +def test_a_channels_text_cap_matches_its_limits(channel, cap): + adapter = get_adapter(channel) + assert adapter.max_text_length == adapter.interactive_limits.body_max == cap + assert len(adapter.format_outbound("x" * (cap + 50))) == cap diff --git a/backend/tests/channels/test_email_sms_line_adapters.py b/backend/tests/channels/test_email_sms_line_adapters.py new file mode 100644 index 0000000..ac6449c --- /dev/null +++ b/backend/tests/channels/test_email_sms_line_adapters.py @@ -0,0 +1,193 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import base64 +import hashlib +import hmac +import json + +import pytest +from unittest.mock import MagicMock + +from app.channels import get_adapter +from app.channels.email import EmailAdapter, strip_quoted_reply, verify_webhook_token +from app.channels.line import LineAdapter + + +class TestEmailAdapter: + def test_parse_sendgrid_style(self): + payload = { + "from": "Ada Lovelace <ada@example.com>", + "to": "support@acme.com", + "subject": "Order issue", + "text": "My order never arrived.\n\nOn Mon, Jul 7 2026 support wrote:\n> earlier reply", + "headers": "Message-ID: <abc123@mail.example.com>\nDate: Mon, 7 Jul 2026", + } + m = get_adapter("email").parse_inbound(payload)[0] + assert m.external_conversation_id == "ada@example.com" + assert m.text == "My order never arrived." + assert m.profile["subject"] == "Order issue" + assert m.profile["inbound_message_id"] == "<abc123@mail.example.com>" + + def test_threading_headers_from_raw_headers(self): + # Providers that forward the raw header block (dict or string) — the + # threading headers are pulled back out for ticket-reply matching. + string_headers = get_adapter("email").parse_inbound({ + "from": "ada@example.com", "text": "still broken", + "headers": ("Message-ID: <r@mail>\n" + "In-Reply-To: <ticket-1.a@acme.com>\n" + "References: <ticket-1.root@acme.com> <ticket-1.a@acme.com>"), + })[0] + assert string_headers.profile["in_reply_to"] == "<ticket-1.a@acme.com>" + assert string_headers.profile["references"] == ( + "<ticket-1.root@acme.com> <ticket-1.a@acme.com>") + + dict_headers = get_adapter("email").parse_inbound({ + "from": "ada@example.com", "text": "still broken", + "headers": {"In-Reply-To": "<ticket-2.a@acme.com>", "References": "<ticket-2.root@acme.com>"}, + })[0] + assert dict_headers.profile["in_reply_to"] == "<ticket-2.a@acme.com>" + assert dict_headers.profile["references"] == "<ticket-2.root@acme.com>" + + def test_threading_headers_from_flattened_payload(self): + # A provider that promotes headers to lowercased top-level fields. + m = get_adapter("email").parse_inbound({ + "from": "ada@example.com", "text": "still broken", + "in-reply-to": "<ticket-3.a@acme.com>", + "references": "<ticket-3.root@acme.com> <ticket-3.a@acme.com>", + })[0] + assert m.profile["in_reply_to"] == "<ticket-3.a@acme.com>" + assert m.profile["references"] == "<ticket-3.root@acme.com> <ticket-3.a@acme.com>" + + def test_threading_headers_absent_default_to_empty(self): + m = get_adapter("email").parse_inbound({ + "from": "ada@example.com", "text": "hi", "subject": "New question", + })[0] + assert m.profile["in_reply_to"] == "" + assert m.profile["references"] == "" + + def test_parse_brevo_style(self): + payload = { + "From": {"Address": "Ada@Example.com", "Name": "Ada"}, + "Subject": "Hi", + "RawTextBody": "hello there", + "MessageId": "<m1@brevo>", + } + m = get_adapter("email").parse_inbound(payload)[0] + assert m.external_conversation_id == "ada@example.com" + assert m.external_message_id == "<m1@brevo>" + + def test_parse_no_sender_or_body_yields_nothing(self): + assert get_adapter("email").parse_inbound({"text": "x"}) == [] + assert get_adapter("email").parse_inbound({"from": "a@b.co", "text": " "}) == [] + + def test_all_quoted_body_falls_back_to_raw(self): + # Over-eager stripping must never drop a message entirely + m = get_adapter("email").parse_inbound({"from": "a@b.co", "text": "> all quoted"})[0] + assert m.text == "> all quoted" + + def test_auto_generated_mail_dropped(self): + payload = {"from": "a@b.co", "text": "I am out of office", + "headers": "Auto-Submitted: auto-replied\nMessage-ID: <x@y>"} + assert get_adapter("email").parse_inbound(payload) == [] + + def test_strip_quoted_reply(self): + text = "New content\n> old line\nOn Tue, someone wrote:\nmore old" + assert strip_quoted_reply(text) == "New content" + + def test_smtp_config_per_inbox_and_fallback(self): + import json + from app.channels.email import smtp_config + from app.models.channels import ChannelAccount + from app.core.security import encrypt_api_key + from app.core.config import settings + + per = ChannelAccount(external_account_id="support@acme.com", + encrypted_credentials=encrypt_api_key(json.dumps({ + "smtp_host": "smtp.acme.com", "smtp_port": 465, + "smtp_username": "u", "smtp_password": "p", "from_email": "help@acme.com"}))) + cfg = smtp_config(per) + assert cfg["host"] == "smtp.acme.com" and cfg["port"] == 465 + assert cfg["use_ssl"] is True and cfg["from_email"] == "help@acme.com" + + fallback = ChannelAccount(external_account_id="x@y.com", + encrypted_credentials=encrypt_api_key(json.dumps({}))) + cfg2 = smtp_config(fallback) + assert cfg2["host"] == settings.SMTP_SERVER + assert cfg2["from_email"] == "x@y.com" # falls back to the inbox address + + def test_conversation_state(self): + adapter = EmailAdapter() + m = adapter.parse_inbound({"from": "a@b.co", "text": "hi", "subject": "S", + "headers": "Message-ID: <x@y>"})[0] + state = adapter.conversation_state(m) + assert state == {"last_message_id": "<x@y>", "subject": "S"} + + def test_webhook_token(self): + account = MagicMock(webhook_secret="tok") + assert verify_webhook_token(account, "tok") is True + assert verify_webhook_token(account, "nope") is False + assert verify_webhook_token(account, "") is False + + +class TestLineAdapter: + PAYLOAD = { + "destination": "Ubot1", + "events": [{ + "type": "message", + "timestamp": 1720000000000, + "source": {"type": "user", "userId": "Ucust9"}, + "message": {"type": "text", "id": "m100", "text": "konnichiwa"}, + }, { + "type": "follow", + "source": {"type": "user", "userId": "Ucust9"}, + }], + } + + def test_parse_inbound(self): + messages = get_adapter("line").parse_inbound(self.PAYLOAD) + assert len(messages) == 1 + m = messages[0] + assert m.external_account_id == "Ubot1" + assert m.external_conversation_id == "Ucust9" + assert m.text == "konnichiwa" + assert m.timestamp.tzinfo is not None + + @pytest.mark.asyncio + async def test_send_typing_calls_loading_animation(self, monkeypatch): + from unittest.mock import AsyncMock + adapter = LineAdapter() + account = MagicMock() + conv = MagicMock(external_conversation_id="Ucust9") + monkeypatch.setattr("app.channels.line.credentials", lambda a: {"channel_access_token": "tok"}) + post = AsyncMock() + monkeypatch.setattr("app.channels.line._get_http_client", + lambda: MagicMock(post=post)) + await adapter.send_typing(account, conv) + assert post.await_args.args[0].endswith("/chat/loading/start") + assert post.await_args.kwargs["json"]["chatId"] == "Ucust9" + + @pytest.mark.asyncio + async def test_verify_signature(self, monkeypatch): + adapter = LineAdapter() + body = json.dumps(self.PAYLOAD).encode() + secret = "line-secret" + signature = base64.b64encode(hmac.new(secret.encode(), body, hashlib.sha256).digest()).decode() + account = MagicMock() + monkeypatch.setattr("app.channels.line.credentials", lambda a: {"channel_secret": secret}) + assert await adapter.verify_webhook({"x-line-signature": signature}, body, account) is True + assert await adapter.verify_webhook({"x-line-signature": "bad"}, body, account) is False + assert await adapter.verify_webhook({}, body, None) is False diff --git a/backend/tests/channels/test_interactive.py b/backend/tests/channels/test_interactive.py new file mode 100644 index 0000000..962f273 --- /dev/null +++ b/backend/tests/channels/test_interactive.py @@ -0,0 +1,147 @@ +""" +Copyright 2024-2026 ChatterMate + +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 + "…" diff --git a/backend/tests/channels/test_meta_adapters.py b/backend/tests/channels/test_meta_adapters.py new file mode 100644 index 0000000..2244d56 --- /dev/null +++ b/backend/tests/channels/test_meta_adapters.py @@ -0,0 +1,769 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import hashlib +import hmac +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock +from urllib.parse import urlparse + +import pytest + +from app.channels import get_adapter +from app.channels.base import ( + DeliveryState, InteractiveButton, InteractiveKind, InteractiveMessage, + InteractiveReplyType, InteractiveRow, InteractiveSection, WindowStatus, +) +from app.channels.meta_base import ( + verify_meta_signature, + verify_challenge, + exchange_for_long_lived_token, + exchange_instagram_code, + exchange_instagram_long_lived, + graph_get, + graph_list_all, + graph_post, + graph_post_json, + instagram_token_payload, + GRAPH_BASE, + GRAPH_INSTAGRAM_BASE, + WINDOW_HOURS, +) +from app.core.config import settings + + +def _host(url: str) -> str: + """The host a request actually went to. + + Compared rather than prefix-matched: `startswith(GRAPH_BASE)` is also + satisfied by `https://graph.facebook.com.evil.example/...`, so it does not + really pin down where a token was sent. + """ + return urlparse(url).hostname or "" + + +WHATSAPP_PAYLOAD = { + "object": "whatsapp_business_account", + "entry": [{ + "id": "WABA1", + "changes": [{ + "field": "messages", + "value": { + "messaging_product": "whatsapp", + "metadata": {"display_phone_number": "15550001111", "phone_number_id": "PN123"}, + "contacts": [{"profile": {"name": "Ada Lovelace"}, "wa_id": "447000000001"}], + "messages": [{ + "from": "447000000001", + "id": "wamid.ABC", + "timestamp": "1720000000", + "type": "text", + "text": {"body": "hello whatsapp"}, + }], + }, + }], + }], +} + +WHATSAPP_STATUS_PAYLOAD = { + "object": "whatsapp_business_account", + "entry": [{ + "changes": [{ + "value": { + "metadata": {"phone_number_id": "PN123"}, + "statuses": [{"id": "wamid.ABC", "status": "delivered"}], + }, + }], + }], +} + +MESSENGER_PAYLOAD = { + "object": "page", + "entry": [{ + "id": "PAGE9", + "time": 1720000000000, + "messaging": [{ + "sender": {"id": "PSID5"}, + "recipient": {"id": "PAGE9"}, + "timestamp": 1720000000000, + "message": {"mid": "mid.1", "text": "hello messenger"}, + }], + }], +} + +INSTAGRAM_PAYLOAD = { + "object": "instagram", + "entry": [{ + "id": "IG7", + "messaging": [{ + "sender": {"id": "IGSID2"}, + "recipient": {"id": "IG7"}, + "timestamp": 1720000000000, + "message": {"mid": "mid.3", "text": "hello instagram"}, + }], + }], +} + + +class TestSignatureAndChallenge: + def test_valid_signature(self, monkeypatch): + monkeypatch.setattr(settings, "META_APP_SECRET", "s3cret") + body = b'{"object":"page"}' + sig = "sha256=" + hmac.new(b"s3cret", body, hashlib.sha256).hexdigest() + assert verify_meta_signature(body, sig) is True + + def test_invalid_or_missing_signature(self, monkeypatch): + monkeypatch.setattr(settings, "META_APP_SECRET", "s3cret") + assert verify_meta_signature(b"{}", "sha256=deadbeef") is False + assert verify_meta_signature(b"{}", "") is False + assert verify_meta_signature(b"{}", "sha1=abc") is False + + def test_signature_rejected_without_configured_secret(self, monkeypatch): + monkeypatch.setattr(settings, "META_APP_SECRET", "") + monkeypatch.setattr(settings, "INSTAGRAM_APP_SECRET", "") + assert verify_meta_signature(b"{}", "sha256=anything") is False + + def test_accepts_a_payload_signed_with_the_instagram_secret(self, monkeypatch): + """Instagram Login webhooks are signed with the Instagram app secret; + rejecting those would drop every Instagram DM at the door.""" + monkeypatch.setattr(settings, "META_APP_SECRET", "fb-secret") + monkeypatch.setattr(settings, "INSTAGRAM_APP_SECRET", "ig-secret") + body = b'{"object":"instagram"}' + sig = "sha256=" + hmac.new(b"ig-secret", body, hashlib.sha256).hexdigest() + assert verify_meta_signature(body, sig) is True + + def test_a_signature_from_neither_secret_is_rejected(self, monkeypatch): + monkeypatch.setattr(settings, "META_APP_SECRET", "fb-secret") + monkeypatch.setattr(settings, "INSTAGRAM_APP_SECRET", "ig-secret") + body = b'{"object":"instagram"}' + sig = "sha256=" + hmac.new(b"attacker", body, hashlib.sha256).hexdigest() + assert verify_meta_signature(body, sig) is False + + def test_challenge(self, monkeypatch): + monkeypatch.setattr(settings, "META_WEBHOOK_VERIFY_TOKEN", "vtok") + assert verify_challenge("subscribe", "vtok", "12345") == "12345" + assert verify_challenge("subscribe", "wrong", "12345") is None + assert verify_challenge(None, None, None) is None + + +class TestWhatsAppParse: + def test_parse_text_message(self): + messages = get_adapter("whatsapp").parse_inbound(WHATSAPP_PAYLOAD) + assert len(messages) == 1 + m = messages[0] + assert m.external_account_id == "PN123" + assert m.external_conversation_id == "447000000001" + assert m.external_message_id == "wamid.ABC" + assert m.text == "hello whatsapp" + assert m.profile["name"] == "Ada Lovelace" + # wa_id declared verbatim; the normalize_msisdn boundary adds the '+' + assert m.profile["phone"] == "447000000001" + assert m.timestamp.tzinfo is not None + + def test_status_callback_yields_nothing(self): + assert get_adapter("whatsapp").parse_inbound(WHATSAPP_STATUS_PAYLOAD) == [] + + def test_template_quick_reply_keeps_text_and_payload(self): + messages = get_adapter("whatsapp").parse_inbound(_wa_message({ + "type": "button", "button": {"text": "Yes please", "payload": "CONFIRM_42"}, + })) + assert messages[0].text == "Yes please" + assert messages[0].interactive.type == InteractiveReplyType.TEMPLATE_BUTTON + assert messages[0].interactive.id == "CONFIRM_42" + + def test_button_reply_keeps_id_and_reply_context(self): + messages = get_adapter("whatsapp").parse_inbound(_wa_message({ + "type": "interactive", + "context": {"from": "15550001111", "id": "wamid.OFFER"}, + "interactive": {"type": "button_reply", + "button_reply": {"id": "qr_1", "title": "Tomorrow"}}, + })) + m = messages[0] + assert m.text == "Tomorrow" + assert (m.interactive.type, m.interactive.id) == (InteractiveReplyType.BUTTON_REPLY, "qr_1") + assert m.reply_to_message_id == "wamid.OFFER" + + def test_list_reply_keeps_id_and_description(self): + messages = get_adapter("whatsapp").parse_inbound(_wa_message({ + "type": "interactive", + "interactive": {"type": "list_reply", "list_reply": { + "id": "attendance:present", "title": "Present", "description": "On time"}}, + })) + reply = messages[0].interactive + assert reply.type == InteractiveReplyType.LIST_REPLY + assert (reply.id, reply.title) == ("attendance:present", "Present") + + def test_location_becomes_readable_text(self): + messages = get_adapter("whatsapp").parse_inbound(_wa_message({ + "type": "location", + "location": {"latitude": 51.5, "longitude": -0.12, "name": "Pitch 3"}, + })) + m = messages[0] + assert m.location.latitude == 51.5 and m.location.name == "Pitch 3" + assert m.text == "Shared location: Pitch 3 (51.5, -0.12)" + + def test_an_untitled_tap_reads_as_its_id_never_empty(self): + messages = get_adapter("whatsapp").parse_inbound(_wa_message({ + "type": "interactive", + "interactive": {"type": "button_reply", "button_reply": {"id": "confirm"}}, + })) + assert messages[0].text == "confirm" + + def test_a_tap_with_neither_title_nor_id_is_skipped(self): + assert get_adapter("whatsapp").parse_inbound(_wa_message({ + "type": "interactive", "interactive": {"type": "button_reply", "button_reply": {}}, + })) == [] + + def test_media_is_still_skipped(self): + assert get_adapter("whatsapp").parse_inbound(_wa_message({ + "type": "image", "image": {"id": "MEDIA1"}, + })) == [] + + +def _wa_message(message: dict) -> dict: + """A one-message WhatsApp webhook payload.""" + return { + "object": "whatsapp_business_account", + "entry": [{"changes": [{"value": { + "metadata": {"phone_number_id": "PN123"}, + "messages": [{"from": "447", "id": "wamid.B", **message}], + }}]}], + } + + +def _wa_statuses(*statuses: dict) -> dict: + return { + "object": "whatsapp_business_account", + "entry": [{"changes": [{"value": { + "metadata": {"phone_number_id": "PN123"}, + "statuses": list(statuses), + }}]}], + } + + +class TestWhatsAppStatuses: + def test_parses_status_with_pricing(self): + events = get_adapter("whatsapp").parse_status_events(_wa_statuses({ + "id": "wamid.ABC", "status": "read", "timestamp": "1720000000", + "recipient_id": "447", + "pricing": {"billable": True, "pricing_model": "PMP", "category": "utility"}, + })) + assert len(events) == 1 + e = events[0] + assert (e.external_account_id, e.external_message_id) == ("PN123", "wamid.ABC") + assert e.status == DeliveryState.READ + assert (e.pricing_category, e.billable) == ("utility", True) + assert e.timestamp.tzinfo is not None + + def test_parses_per_message_pricing(self): + [e] = get_adapter("whatsapp").parse_status_events(_wa_statuses({ + "id": "wamid.ABC", "status": "delivered", "timestamp": "1720000000", "recipient_id": "447", + "pricing": {"billable": False, "pricing_model": "PMP", "category": "service", + "type": "free_customer_service"}, + })) + assert (e.pricing_category, e.pricing_type, e.pricing_model) == ("service", "free_customer_service", "PMP") + + def test_a_status_without_pricing_or_errors_has_none(self): + [e] = get_adapter("whatsapp").parse_status_events(_wa_statuses({ + "id": "wamid.ABC", "status": "sent", "timestamp": "1720000000", "recipient_id": "447"})) + assert (e.pricing_type, e.pricing_model, e.error_code) == (None, None, None) + + def test_failed_status_carries_the_reason(self): + events = get_adapter("whatsapp").parse_status_events(_wa_statuses({ + "id": "wamid.X", "status": "failed", + "errors": [{"code": 131047, "title": "Re-engagement message", + "error_data": {"details": "More than 24 hours have passed"}}], + })) + assert events[0].status == DeliveryState.FAILED + assert events[0].error == "More than 24 hours have passed" + assert events[0].error_code == "131047" + + def test_unknown_states_and_messages_are_ignored(self): + adapter = get_adapter("whatsapp") + assert adapter.parse_status_events(_wa_statuses({"id": "wamid.X", "status": "deleted"})) == [] + assert adapter.parse_status_events(WHATSAPP_PAYLOAD) == [] + + def test_other_channels_report_no_statuses(self): + assert get_adapter("messenger").parse_status_events(MESSENGER_PAYLOAD) == [] + + +class TestMessengerInstagramParse: + def test_parse_messenger(self): + messages = get_adapter("messenger").parse_inbound(MESSENGER_PAYLOAD) + assert len(messages) == 1 + m = messages[0] + assert m.external_account_id == "PAGE9" + assert m.external_conversation_id == "PSID5" + assert m.external_message_id == "mid.1" + assert m.text == "hello messenger" + + def test_echo_and_receipts_skipped(self): + payload = { + "object": "page", + "entry": [{"id": "PAGE9", "messaging": [ + {"sender": {"id": "PSID5"}, "message": {"mid": "m", "text": "x", "is_echo": True}}, + {"sender": {"id": "PSID5"}, "delivery": {"mids": ["m"]}}, + {"sender": {"id": "PSID5"}, "read": {"watermark": 1}}, + ]}], + } + assert get_adapter("messenger").parse_inbound(payload) == [] + + def test_parse_instagram(self): + messages = get_adapter("instagram").parse_inbound(INSTAGRAM_PAYLOAD) + assert len(messages) == 1 + assert messages[0].external_account_id == "IG7" + assert messages[0].external_conversation_id == "IGSID2" + + def test_parse_instagram_changes_shape(self): + """Instagram also delivers a DM under changes[] with field=messages + (this is the shape Meta's own webhook sample sends). Reading only + messaging[] drops the message silently — the webhook still acks.""" + payload = { + "object": "instagram", + "entry": [{ + "id": "IG7", + "changes": [{ + "field": "messages", + "value": { + "sender": {"id": "IGSID2"}, + "recipient": {"id": "IG7"}, + "timestamp": "1527459824", + "message": {"mid": "mid.9", "text": "hello from changes"}, + }, + }], + }], + } + messages = get_adapter("instagram").parse_inbound(payload) + assert len(messages) == 1 + assert messages[0].external_account_id == "IG7" + assert messages[0].external_conversation_id == "IGSID2" + assert messages[0].text == "hello from changes" + + def test_messenger_ignores_the_changes_shape(self): + """Only Instagram delivers messaging events that way.""" + payload = {"object": "page", "entry": [{"id": "PAGE9", "changes": [ + {"field": "messages", "value": {"sender": {"id": "PSID5"}, + "message": {"mid": "m", "text": "x"}}}]}]} + assert get_adapter("messenger").parse_inbound(payload) == [] + + +class TestInstagramTransport: + """Instagram Login accounts hold an Instagram user token, which the Facebook + graph rejects — every call for them must go to graph.instagram.com.""" + + @pytest.mark.asyncio + async def test_instagram_sends_via_graph_instagram(self, graph_client, monkeypatch): + adapter = get_adapter("instagram") + monkeypatch.setattr(adapter, "access_token", lambda account: "IGTOKEN") + graph_client.response = _FakeResponse(200, {"message_id": "m1"}) + conversation = MagicMock() + conversation.external_conversation_id = "IGSID1" + + await adapter.send_text(MagicMock(), conversation, "hi") + + call = graph_client.calls[0] + assert _host(call["url"]) == _host(GRAPH_INSTAGRAM_BASE) + # messaging_type is Messenger-only; Instagram's send body omits it. + assert "messaging_type" not in call["json"] + + @pytest.mark.asyncio + async def test_messenger_still_sends_via_graph_facebook(self, graph_client, monkeypatch): + adapter = get_adapter("messenger") + monkeypatch.setattr(adapter, "access_token", lambda account: "PAGETOKEN") + graph_client.response = _FakeResponse(200, {"message_id": "m1"}) + conversation = MagicMock() + conversation.external_conversation_id = "PSID1" + + await adapter.send_text(MagicMock(), conversation, "hi") + + call = graph_client.calls[0] + assert _host(call["url"]) == _host(GRAPH_BASE) + assert call["json"]["messaging_type"] == "RESPONSE" + + +class TestProfileEnrichment: + """The inbound payload carries only a sender id, so the customer's real name + is looked up on demand — otherwise everyone shows as 'Messenger user 2742…'.""" + + def _adapter(self, channel, monkeypatch, graph_return): + adapter = get_adapter(channel) + monkeypatch.setattr(adapter, "access_token", lambda account: "PAGE_TOKEN") + graph = AsyncMock(return_value=graph_return) + monkeypatch.setattr("app.channels.messenger.graph_get", graph) + return adapter, graph + + @pytest.mark.asyncio + async def test_messenger_resolves_the_senders_name(self, monkeypatch): + adapter, graph = self._adapter( + "messenger", monkeypatch, (True, {"first_name": "Ada", "last_name": "Lovelace"})) + assert await adapter.fetch_profile(MagicMock(), "PSID1") == {"name": "Ada Lovelace"} + graph.assert_awaited_once_with( + "PSID1", "PAGE_TOKEN", params={"fields": "first_name,last_name"}, + base="https://graph.facebook.com") + + @pytest.mark.asyncio + async def test_a_graph_failure_degrades_to_no_name(self, monkeypatch): + """A lookup failure must not lose the message — it keeps the placeholder.""" + adapter, _ = self._adapter( + "messenger", monkeypatch, (False, {"error": {"message": "rate limited"}})) + assert await adapter.fetch_profile(MagicMock(), "PSID1") == {} + + @pytest.mark.asyncio + async def test_instagram_uses_the_ig_name_fields(self, monkeypatch): + adapter, graph = self._adapter( + "instagram", monkeypatch, (True, {"name": "Ada", "username": "ada_l"})) + assert await adapter.fetch_profile(MagicMock(), "IGSID1") == {"name": "Ada"} + graph.assert_awaited_once_with( + "IGSID1", "PAGE_TOKEN", params={"fields": "name,username"}, + base="https://graph.instagram.com") + + @pytest.mark.asyncio + async def test_instagram_falls_back_to_username(self, monkeypatch): + adapter, _ = self._adapter( + "instagram", monkeypatch, (True, {"username": "ada_l"})) + assert await adapter.fetch_profile(MagicMock(), "IGSID1") == {"name": "ada_l"} + + +class TestDeliveryWindow: + def _conversation(self, hours_ago: float): + conv = MagicMock() + conv.last_inbound_at = datetime.now(timezone.utc) - timedelta(hours=hours_ago) + return conv + + def test_within_window_ok(self): + for channel in ("whatsapp", "messenger", "instagram"): + assert get_adapter(channel).check_delivery_window(self._conversation(1)) is WindowStatus.OK + + def test_expired_whatsapp_allows_template(self): + status = get_adapter("whatsapp").check_delivery_window(self._conversation(WINDOW_HOURS + 1)) + assert status is WindowStatus.TEMPLATE_REQUIRED + + def test_expired_messenger_instagram_undeliverable(self): + for channel in ("messenger", "instagram"): + status = get_adapter(channel).check_delivery_window(self._conversation(WINDOW_HOURS + 1)) + assert status is WindowStatus.UNDELIVERABLE + + def test_naive_timestamp_treated_as_utc(self): + conv = MagicMock() + conv.last_inbound_at = datetime.utcnow() - timedelta(hours=1) # naive + assert get_adapter("whatsapp").check_delivery_window(conv) is WindowStatus.OK + + def test_no_inbound_at_means_expired(self): + conv = MagicMock() + conv.last_inbound_at = None + assert get_adapter("whatsapp").check_delivery_window(conv) is WindowStatus.TEMPLATE_REQUIRED + + +class _FakeResponse: + def __init__(self, status_code=200, body=None): + self.status_code = status_code + self._body = {} if body is None else body + + def json(self): + return self._body + + +class _RecordingClient: + """Stands in for the shared Graph http client, capturing what was sent.""" + + def __init__(self): + self.response = _FakeResponse() + self.error = None + self.calls = [] + + async def request(self, method, url, params=None, json=None, headers=None, data=None): + self.calls.append({"method": method, "url": url, "params": params, + "json": json, "headers": headers, "data": data}) + if self.error: + raise self.error + return self.response + + async def post(self, url, json=None, headers=None): + """graph_post sends through .post rather than .request.""" + return await self.request("POST", url, json=json, headers=headers) + + +@pytest.fixture +def graph_client(monkeypatch): + client = _RecordingClient() + monkeypatch.setattr("app.channels.meta_base._get_http_client", lambda: client) + return client + + +class TestGraphHelpers: + """graph_post_json/graph_get keep the whole response body, unlike + graph_post which reduces it to a message id.""" + + @pytest.mark.asyncio + async def test_post_json_returns_full_body(self, graph_client): + created = {"id": "T1", "status": "PENDING", "category": "UTILITY"} + graph_client.response = _FakeResponse(200, created) + + ok, body = await graph_post_json("WABA1/message_templates", "tok", {"name": "hello"}) + + assert (ok, body) == (True, created) + assert graph_client.calls[0]["method"] == "POST" + assert graph_client.calls[0]["json"] == {"name": "hello"} + + @pytest.mark.asyncio + async def test_token_travels_in_header_not_url(self, graph_client): + await graph_post_json("WABA1/message_templates", "sekret", {}) + + call = graph_client.calls[0] + assert call["headers"]["Authorization"] == "Bearer sekret" + assert "sekret" not in call["url"] + + @pytest.mark.asyncio + async def test_params_are_passed_and_a_4xx_is_reported_not_raised(self, graph_client): + graph_client.response = _FakeResponse(400, {"error": {"message": "not found"}}) + + ok, body = await graph_get("WABA1/message_templates", "tok", {"fields": "name"}) + + assert ok is False + assert body["error"]["message"] == "not found" + assert graph_client.calls[0]["method"] == "GET" + assert graph_client.calls[0]["params"] == {"fields": "name"} + + @pytest.mark.asyncio + async def test_network_error_is_reported_not_raised(self, graph_client): + graph_client.error = RuntimeError("connection reset") + + ok, body = await graph_get("WABA1/message_templates", "tok") + + assert ok is False + assert "connection reset" in body["error"]["message"] + + @pytest.mark.asyncio + async def test_long_lived_exchange_uses_app_credentials_not_a_bearer(self, graph_client, monkeypatch): + """Like the signup-code exchange, this authenticates with the app secret + in the query, so it must carry no Authorization header.""" + monkeypatch.setattr(settings, "META_APP_ID", "APP1") + monkeypatch.setattr(settings, "META_APP_SECRET", "SEKRET") + graph_client.response = _FakeResponse(200, {"access_token": "LONGLIVED"}) + + ok, body = await exchange_for_long_lived_token("short-token") + + assert (ok, body["access_token"]) == (True, "LONGLIVED") + call = graph_client.calls[0] + assert call["headers"].get("Authorization") is None + assert call["params"]["grant_type"] == "fb_exchange_token" + assert call["params"]["fb_exchange_token"] == "short-token" + assert "SEKRET" not in call["url"] + + @pytest.mark.asyncio + async def test_instagram_code_exchange_uses_the_instagram_app_and_host( + self, graph_client, monkeypatch): + """Instagram Login is its own OAuth: Instagram app credentials, its own + host, form-encoded — the Facebook app id here would be rejected.""" + monkeypatch.setattr(settings, "INSTAGRAM_APP_ID", "IGAPP") + monkeypatch.setattr(settings, "INSTAGRAM_APP_SECRET", "IGSECRET") + monkeypatch.setattr(settings, "META_APP_ID", "FBAPP") + graph_client.response = _FakeResponse(200, {"access_token": "IGShort", "user_id": 178}) + + ok, body = await exchange_instagram_code("CODE", "https://app.test/cb.html") + + assert (ok, body["access_token"]) == (True, "IGShort") + call = graph_client.calls[0] + assert call["url"] == "https://api.instagram.com/oauth/access_token" + assert call["data"]["client_id"] == "IGAPP" + assert call["data"]["grant_type"] == "authorization_code" + assert call["data"]["redirect_uri"] == "https://app.test/cb.html" + + @pytest.mark.asyncio + async def test_instagram_long_lived_exchange_hits_graph_instagram(self, graph_client, monkeypatch): + monkeypatch.setattr(settings, "INSTAGRAM_APP_SECRET", "IGSECRET") + graph_client.response = _FakeResponse(200, {"access_token": "IGLong", "expires_in": 5183944}) + + ok, body = await exchange_instagram_long_lived("IGShort") + + assert (ok, body["access_token"]) == (True, "IGLong") + call = graph_client.calls[0] + assert call["url"] == f"{GRAPH_INSTAGRAM_BASE}/access_token" + assert call["params"]["grant_type"] == "ig_exchange_token" + + def test_token_payload_accepts_both_response_shapes(self): + """Instagram has returned the token flat and wrapped in `data`; reading + only one shape yields no token and looks like a login failure.""" + flat = {"access_token": "A", "user_id": 1} + wrapped = {"data": [{"access_token": "A", "user_id": 1}]} + assert instagram_token_payload(flat)["access_token"] == "A" + assert instagram_token_payload(wrapped)["access_token"] == "A" + # An empty/malformed `data` falls back rather than raising + assert instagram_token_payload({"data": []}) == {"data": []} + + @pytest.mark.asyncio + async def test_graph_list_all_follows_the_cursor(self, graph_client): + """Two full pages then a short one: it must return every node, not stop + at the first page.""" + pages = [ + _FakeResponse(200, {"data": [{"id": "1"}, {"id": "2"}], + "paging": {"cursors": {"after": "c1"}}}), + _FakeResponse(200, {"data": [{"id": "3"}]}), + ] + call_count = {"n": 0} + + async def request(method, url, params=None, json=None, headers=None): + graph_client.calls.append({"method": method, "url": url, "params": params}) + r = pages[call_count["n"]] + call_count["n"] += 1 + return r + + graph_client.request = request + ok, items = await graph_list_all("me/accounts", "tok", {"limit": 2}) + + assert ok is True + assert [i["id"] for i in items] == ["1", "2", "3"] + assert graph_client.calls[1]["params"]["after"] == "c1" + + +class TestWhatsAppInteractiveSend: + @pytest.fixture + def adapter(self, monkeypatch): + adapter = get_adapter("whatsapp") + monkeypatch.setattr(adapter, "access_token", lambda account: "WATOKEN") + return adapter + + @staticmethod + def _conversation(): + conversation = MagicMock() + conversation.external_conversation_id = "447000000001" + return conversation + + @pytest.mark.asyncio + async def test_reply_buttons_payload(self, graph_client, adapter): + graph_client.response = _FakeResponse(200, {"messages": [{"id": "wamid.OUT"}]}) + message = InteractiveMessage( + kind=InteractiveKind.BUTTONS, body="Mark attendance?", footer="CoachIQ", + buttons=[InteractiveButton(id="present", title="Present"), + InteractiveButton(id="absent", title="Absent")]) + + result = await adapter.send_interactive(MagicMock(), self._conversation(), message) + + assert result.ok and result.external_message_id == "wamid.OUT" + body = graph_client.calls[0]["json"] + assert body["type"] == "interactive" and body["to"] == "447000000001" + assert body["interactive"] == { + "type": "button", + "body": {"text": "Mark attendance?"}, + "footer": {"text": "CoachIQ"}, + "action": {"buttons": [ + {"type": "reply", "reply": {"id": "present", "title": "Present"}}, + {"type": "reply", "reply": {"id": "absent", "title": "Absent"}}, + ]}, + } + + @pytest.mark.asyncio + async def test_list_payload(self, graph_client, adapter): + graph_client.response = _FakeResponse(200, {"messages": [{"id": "wamid.OUT"}]}) + message = InteractiveMessage( + kind=InteractiveKind.LIST, body="Pick a session", header="Today", + list_button_label="Sessions", + sections=[InteractiveSection(title="Morning", rows=[ + InteractiveRow(id="s1", title="U12 09:00", description="Pitch 3"), + InteractiveRow(id="s2", title="U14 10:30"), + ])]) + + await adapter.send_interactive(MagicMock(), self._conversation(), message) + + interactive = graph_client.calls[0]["json"]["interactive"] + assert interactive["type"] == "list" + assert interactive["header"] == {"type": "text", "text": "Today"} + assert interactive["action"] == {"button": "Sessions", "sections": [{ + "title": "Morning", + "rows": [{"id": "s1", "title": "U12 09:00", "description": "Pitch 3"}, + {"id": "s2", "title": "U14 10:30"}], + }]} + + @pytest.mark.asyncio + async def test_cta_url_payload(self, graph_client, adapter): + message = InteractiveMessage(kind=InteractiveKind.CTA_URL, body="Your invoice", + display_text="Pay now", url="https://pay.example/1") + + await adapter.send_interactive(MagicMock(), self._conversation(), message) + + interactive = graph_client.calls[0]["json"]["interactive"] + assert interactive["type"] == "cta_url" + assert interactive["action"] == {"name": "cta_url", "parameters": { + "display_text": "Pay now", "url": "https://pay.example/1"}} + + @pytest.mark.asyncio + async def test_channels_without_support_refuse(self): + adapter = get_adapter("slack") + assert adapter.supports_interactive is False + result = await adapter.send_interactive( + MagicMock(), MagicMock(), InteractiveMessage(kind=InteractiveKind.BUTTONS, body="x")) + assert result.ok is False + + +class TestWhatsAppReadReceipts: + @pytest.mark.asyncio + async def test_mark_read_has_no_typing_indicator(self, graph_client, monkeypatch): + adapter = get_adapter("whatsapp") + monkeypatch.setattr(adapter, "access_token", lambda account: "WATOKEN") + graph_client.response = _FakeResponse(200, {"success": True}) + + await adapter.mark_read(MagicMock(), "wamid.IN") + + assert graph_client.calls[0]["json"] == { + "messaging_product": "whatsapp", "status": "read", "message_id": "wamid.IN"} + + @pytest.mark.asyncio + async def test_typing_marks_read_with_the_indicator(self, graph_client, monkeypatch): + adapter = get_adapter("whatsapp") + monkeypatch.setattr(adapter, "access_token", lambda account: "WATOKEN") + conversation = MagicMock() + conversation.extra = {"last_inbound_message_id": "wamid.IN"} + + await adapter.send_typing(MagicMock(), conversation) + + assert graph_client.calls[0]["json"]["typing_indicator"] == {"type": "text"} + + +class TestGraphPostAuthFailure: + """A refused token is flagged so the account can be marked for + reconnection; a refused message is not.""" + + @pytest.mark.asyncio + @pytest.mark.parametrize("status,body", [ + (401, {"error": {"message": "Authentication Error", "code": 0}}), + (400, {"error": {"message": "Authentication Error", "code": 0}}), + (400, {"error": {"message": "Session has expired", "code": 190, "error_subcode": 463}}), + (400, {"error": {"message": "Error validating access token", "code": 102}}), + (401, {}), + ]) + async def test_token_refusals_are_auth_failures(self, graph_client, status, body): + graph_client.response = _FakeResponse(status, body) + result = await graph_post("PN1/messages", "tok", {}) + assert result.ok is False + assert result.auth_failed is True + + @pytest.mark.asyncio + @pytest.mark.parametrize("body", [ + {"error": {"message": "Re-engagement message", "code": 131047}}, + {"error": {"message": "Invalid parameter", "code": 100}}, + {"error": "not a dict"}, + ]) + async def test_message_refusals_are_not(self, graph_client, body): + graph_client.response = _FakeResponse(400, body) + result = await graph_post("PN1/messages", "tok", {}) + assert result.ok is False + assert result.auth_failed is False + + @pytest.mark.asyncio + async def test_success_is_not(self, graph_client): + graph_client.response = _FakeResponse(200, {"messages": [{"id": "wamid.1"}]}) + result = await graph_post("PN1/messages", "tok", {}) + assert result.ok is True + assert result.auth_failed is False diff --git a/backend/tests/channels/test_slack_adapter.py b/backend/tests/channels/test_slack_adapter.py new file mode 100644 index 0000000..74b7f93 --- /dev/null +++ b/backend/tests/channels/test_slack_adapter.py @@ -0,0 +1,417 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import hashlib +import hmac +import time +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import app.api.webhooks.slack as slack_webhook +import app.services.slack_events as slack_events +from app.api.webhooks.slack import _handle_lifecycle_event +from app.channels import get_adapter +from app.channels.slack import SlackAdapter, build_home_view, verify_slack_signature +from app.core.config import settings + + +def make_event(event: dict, team_id="T111", event_id="Ev1"): + return {"type": "event_callback", "team_id": team_id, "event_id": event_id, "event": event} + + +MENTION_EVENT = make_event({ + "type": "app_mention", + "user": "U42", + "text": "<@UBOT> what are your opening hours?", + "channel": "C9", + "ts": "1720000000.000100", +}) + +DM_EVENT = make_event({ + "type": "message", + "channel_type": "im", + "user": "U42", + "text": "hello there", + "channel": "D7", + "ts": "1720000001.000200", +}) + + +@pytest.fixture +def adapter(): + return SlackAdapter() + + +def test_registry_resolves_slack(): + assert isinstance(get_adapter("slack"), SlackAdapter) + + +def test_parse_mention_threads_conversation(adapter): + messages = adapter.parse_inbound(MENTION_EVENT) + assert len(messages) == 1 + m = messages[0] + assert m.external_account_id == "T111" + assert m.external_conversation_id == "C9:1720000000.000100" + assert m.text == "what are your opening hours?" # mention stripped + assert m.external_message_id == "Ev1" + + +def test_parse_mention_reply_keeps_thread(adapter): + payload = make_event({**MENTION_EVENT["event"], "thread_ts": "1719990000.000001"}) + m = adapter.parse_inbound(payload)[0] + assert m.external_conversation_id == "C9:1719990000.000001" + + +def test_parse_dm_uses_channel_as_conversation(adapter): + m = adapter.parse_inbound(DM_EVENT)[0] + assert m.external_conversation_id == "D7" + assert m.text == "hello there" + + +def test_bot_and_subtype_events_ignored(adapter): + assert adapter.parse_inbound(make_event({**DM_EVENT["event"], "bot_id": "B1"})) == [] + assert adapter.parse_inbound(make_event({**DM_EVENT["event"], "subtype": "message_changed"})) == [] + assert adapter.parse_inbound(make_event({"type": "reaction_added"})) == [] + + +def test_split_conversation(): + assert SlackAdapter._split_conversation("C9:123.456") == ("C9", "123.456") + assert SlackAdapter._split_conversation("D7") == ("D7", None) + + +def test_format_outbound_mrkdwn(adapter): + assert adapter.format_outbound("**bold** text") == "*bold* text" + + +class TestLifecycleEvents: + """app_uninstalled / tokens_revoked must drop the workspace's credentials, + but only when it's really our bot token that died.""" + + @pytest.fixture + def repo(self): + repo = MagicMock() + repo.get_by_external_id.return_value = MagicMock(name="account") + repo.delete.return_value = True + return repo + + def test_uninstall_deletes_account(self, repo): + _handle_lifecycle_event(make_event({"type": "app_uninstalled"}), repo) + repo.get_by_external_id.assert_called_once_with("slack", "T111") + repo.delete.assert_called_once() + + def test_bot_token_revoked_deletes_account(self, repo): + payload = make_event({"type": "tokens_revoked", "tokens": {"bot": ["B123"]}}) + _handle_lifecycle_event(payload, repo) + repo.delete.assert_called_once() + + def test_user_token_revoke_keeps_account(self, repo): + """We only ever store a bot token — a user-token revoke leaves the + workspace working, so deleting it would disconnect a healthy install.""" + payload = make_event({"type": "tokens_revoked", "tokens": {"oauth": ["U123"]}}) + _handle_lifecycle_event(payload, repo) + repo.delete.assert_not_called() + + def test_unknown_team_is_noop(self, repo): + repo.get_by_external_id.return_value = None + _handle_lifecycle_event(make_event({"type": "app_uninstalled"}), repo) + repo.delete.assert_not_called() + + def test_missing_team_id_is_noop(self, repo): + payload = make_event({"type": "app_uninstalled"}, team_id="") + _handle_lifecycle_event(payload, repo) + repo.get_by_external_id.assert_not_called() + repo.delete.assert_not_called() + + def test_failed_delete_does_not_raise(self, repo): + """delete() reports failure by returning False; Slack still needs a 200.""" + repo.delete.return_value = False + _handle_lifecycle_event(make_event({"type": "app_uninstalled"}), repo) + repo.delete.assert_called_once() + + def test_lifecycle_events_are_not_messages(self, adapter): + """They must stay out of the message path — parse_inbound yields nothing, + which is why the route resolves the team off the envelope instead.""" + assert adapter.parse_inbound(make_event({"type": "app_uninstalled"})) == [] + assert adapter.parse_inbound(make_event({"type": "tokens_revoked"})) == [] + + +class TestSignature: + def _sign(self, body: bytes, secret: str, ts: str) -> str: + return "v0=" + hmac.new(secret.encode(), f"v0:{ts}:".encode() + body, hashlib.sha256).hexdigest() + + def test_valid(self, monkeypatch): + monkeypatch.setattr(settings, "SLACK_SIGNING_SECRET", "sek") + ts = str(int(time.time())) + body = b'{"type":"event_callback"}' + headers = {"x-slack-request-timestamp": ts, + "x-slack-signature": self._sign(body, "sek", ts)} + assert verify_slack_signature(headers, body) is True + + def test_wrong_secret_or_missing(self, monkeypatch): + monkeypatch.setattr(settings, "SLACK_SIGNING_SECRET", "sek") + ts = str(int(time.time())) + body = b"{}" + assert verify_slack_signature( + {"x-slack-request-timestamp": ts, + "x-slack-signature": self._sign(body, "other", ts)}, body) is False + assert verify_slack_signature({}, body) is False + + def test_replay_rejected(self, monkeypatch): + monkeypatch.setattr(settings, "SLACK_SIGNING_SECRET", "sek") + old_ts = str(int(time.time()) - 3600) + body = b"{}" + headers = {"x-slack-request-timestamp": old_ts, + "x-slack-signature": self._sign(body, "sek", old_ts)} + assert verify_slack_signature(headers, body) is False + + def test_unconfigured_secret_fails_closed(self, monkeypatch): + monkeypatch.setattr(settings, "SLACK_SIGNING_SECRET", "") + assert verify_slack_signature({"x-slack-request-timestamp": "1", + "x-slack-signature": "v0=x"}, b"{}") is False + + +class TestSlackProfileAndTyping: + @pytest.mark.asyncio + async def test_fetch_profile_resolves_real_name(self, adapter, monkeypatch): + from unittest.mock import AsyncMock, MagicMock + acc = MagicMock() + monkeypatch.setattr(adapter, "_access_token", lambda a: "tok") + monkeypatch.setattr("app.channels.slack.slack_api", AsyncMock(return_value={ + "ok": True, "user": {"real_name": "Ada Lovelace", + "profile": {"display_name": "ada", "email": "ada@acme.com"}}})) + prof = await adapter.fetch_profile(acc, "U09UPKP7") + assert prof["name"] == "Ada Lovelace" + assert prof["email"] == "ada@acme.com" + + @pytest.mark.asyncio + async def test_fetch_profile_handles_error(self, adapter, monkeypatch): + from unittest.mock import AsyncMock, MagicMock + monkeypatch.setattr(adapter, "_access_token", lambda a: "tok") + monkeypatch.setattr("app.channels.slack.slack_api", + AsyncMock(return_value={"ok": False, "error": "missing_scope"})) + assert await adapter.fetch_profile(MagicMock(), "U0") == {} + + @pytest.mark.asyncio + async def test_typing_placeholder_then_edit(self, adapter, monkeypatch): + from unittest.mock import AsyncMock, MagicMock + import app.channels.slack as slk + acc = MagicMock(); acc.id = "acc1" + conv = MagicMock(external_conversation_id="D7") + monkeypatch.setattr(adapter, "_access_token", lambda a: "tok") + calls = [] + async def fake_api(method, token, payload): + calls.append((method, payload)) + if method == "chat.postMessage": + return {"ok": True, "ts": "111.222"} + if method == "chat.update": + return {"ok": True, "ts": payload["ts"]} + return {"ok": False} + monkeypatch.setattr(slk, "slack_api", fake_api) + + await adapter.send_typing(acc, conv) # posts placeholder + result = await adapter.send_text(acc, conv, "Here is your answer") # edits it + assert result.ok and result.external_message_id == "111.222" + methods = [m for m, _ in calls] + assert methods == ["chat.postMessage", "chat.update"] # not a second post + # placeholder consumed + assert slk._typing_placeholders.get(("acc1", "D7")) is None + + @pytest.mark.asyncio + async def test_placeholder_deleted_when_update_fails(self, adapter, monkeypatch): + """When chat.update fails, the stale '_typing…_' is deleted and a fresh + message is posted — no dangling placeholder beside the reply.""" + from unittest.mock import MagicMock + import app.channels.slack as slk + acc = MagicMock(); acc.id = "acc9" + conv = MagicMock(external_conversation_id="D9") + monkeypatch.setattr(adapter, "_access_token", lambda a: "tok") + calls = [] + async def fake_api(method, token, payload): + calls.append((method, payload)) + if method == "chat.postMessage": + return {"ok": True, "ts": "555.666"} + if method == "chat.update": + return {"ok": False, "error": "cant_update_message"} + return {"ok": True} + monkeypatch.setattr(slk, "slack_api", fake_api) + + await adapter.send_typing(acc, conv) + result = await adapter.send_text(acc, conv, "The answer") + methods = [m for m, _ in calls] + assert methods == ["chat.postMessage", "chat.update", "chat.delete", "chat.postMessage"] + assert result.ok + assert slk._typing_placeholders.get(("acc9", "D9")) is None + + def test_prune_placeholders_drops_stale(self, monkeypatch): + import app.channels.slack as slk + slk._typing_placeholders.clear() + slk._typing_placeholders[("a", "c1")] = ("1.1", 1000.0) + slk._typing_placeholders[("a", "c2")] = ("2.2", 1000.0 + slk._PLACEHOLDER_TTL_SECONDS + 1) + slk._prune_placeholders(1000.0 + slk._PLACEHOLDER_TTL_SECONDS + 2) + assert ("a", "c1") not in slk._typing_placeholders # stale, pruned + assert ("a", "c2") in slk._typing_placeholders # fresh, kept + slk._typing_placeholders.clear() + + +def test_parse_dm_with_thread_keys_by_thread(adapter): + """Assistant-sidebar messages are DMs carrying the assistant thread ts — they + key by thread so the reply posts back into that thread.""" + ev = make_event({"type": "message", "channel_type": "im", "user": "U42", + "text": "hi", "channel": "D7", "ts": "1720000002.0003", + "thread_ts": "1719999999.0001"}) + m = adapter.parse_inbound(ev)[0] + assert m.external_conversation_id == "D7:1719999999.0001" + + +DASH = "https://app.chattermate.chat" + + +class TestHomeView: + def test_intro_and_dashboard_link_always_present(self): + import json + blob = json.dumps(build_home_view(None, DASH)) + assert "AI customer support" in blob # what it does, up top + assert DASH in blob # dashboard link + assert "How to use me" in blob + + def test_no_agent_shows_connect_prompt(self): + import json + v = build_home_view(None, DASH) + assert v["type"] == "home" + assert "No agent connected" in json.dumps(v) + + def test_connected_agent_card_and_truncation(self): + import json + v = build_home_view({"name": "Support", "is_active": True, + "instruction": "x" * 300, "photo_url": None}, DASH) + blob = json.dumps(v) + assert "Connected agent" in blob and "Support" in blob + section_texts = [b["text"]["text"] for b in v["blocks"] if b.get("type") == "section"] + long_text = next(t for t in section_texts if t.startswith("x")) + assert long_text.endswith("…") and len(long_text) <= 250 + + +class TestAppSurfaceDispatch: + """Webhook fan-out: the right event schedules the right background handler.""" + + def _repo(self, active=True): + account = MagicMock(); account.id = "acc1"; account.is_active = active + repo = MagicMock(); repo.get_by_external_id.return_value = account + return repo + + def test_messages_tab_open_schedules_welcome(self): + bg = MagicMock() + slack_webhook._dispatch_app_surface_event( + {"type": "app_home_opened", "user": "U1", "channel": "D1", "tab": "messages"}, + "T1", self._repo(), bg) + args = bg.add_task.call_args[0] + assert args[0] is slack_webhook.deliver_home_welcome + assert args[1:] == ("acc1", "U1", "D1") + + def test_missing_tab_defaults_to_welcome(self): + bg = MagicMock() + slack_webhook._dispatch_app_surface_event( + {"type": "app_home_opened", "user": "U1", "channel": "D1"}, "T1", self._repo(), bg) + assert bg.add_task.call_args[0][0] is slack_webhook.deliver_home_welcome + + def test_home_tab_open_schedules_home_publish(self): + bg = MagicMock() + slack_webhook._dispatch_app_surface_event( + {"type": "app_home_opened", "user": "U1", "tab": "home"}, "T1", self._repo(), bg) + args = bg.add_task.call_args[0] + assert args[0] is slack_webhook.publish_agent_home and args[1:] == ("acc1", "U1") + + def test_assistant_started_schedules_handler(self): + bg = MagicMock() + slack_webhook._dispatch_app_surface_event( + {"type": "assistant_thread_started", + "assistant_thread": {"channel_id": "D2", "thread_ts": "111.222"}}, + "T1", self._repo(), bg) + args = bg.add_task.call_args[0] + assert args[0] is slack_webhook.handle_assistant_thread_started + assert args[1:] == ("acc1", "D2", "111.222") + + def test_context_changed_is_noop(self): + bg = MagicMock() + slack_webhook._dispatch_app_surface_event( + {"type": "assistant_thread_context_changed"}, "T1", self._repo(), bg) + bg.add_task.assert_not_called() + + def test_inactive_team_is_noop(self): + bg = MagicMock() + slack_webhook._dispatch_app_surface_event( + {"type": "app_home_opened", "user": "U1", "tab": "messages"}, + "T1", self._repo(active=False), bg) + bg.add_task.assert_not_called() + + def test_missing_team_id_is_noop(self): + bg = MagicMock() + slack_webhook._dispatch_app_surface_event( + {"type": "app_home_opened", "user": "U1"}, "", self._repo(), bg) + bg.add_task.assert_not_called() + + +class TestHomeWelcomeDelivery: + def _patch_common(self, monkeypatch): + db = MagicMock() + monkeypatch.setattr(slack_events, "SessionLocal", lambda: db) + account = MagicMock(); account.is_active = True + repo = MagicMock(); repo.get_by_id.return_value = account + monkeypatch.setattr(slack_events, "ChannelAccountRepository", lambda d: repo) + monkeypatch.setattr(slack_events, "_assigned_agent", lambda d, a: None) + monkeypatch.setattr(slack_events.SlackAdapter, "_access_token", staticmethod(lambda a: "tok")) + api = AsyncMock() + monkeypatch.setattr(slack_events, "slack_api", api) + return api + + @pytest.mark.asyncio + async def test_welcome_sent_once(self, monkeypatch): + api = self._patch_common(monkeypatch) + redis = MagicMock() + redis.set.side_effect = [True, None] # first claims the slot, second is taken + monkeypatch.setattr(slack_events, "get_redis", lambda: redis) + + await slack_events.deliver_home_welcome("acc1", "U1", "D1") + await slack_events.deliver_home_welcome("acc1", "U1", "D1") + + assert api.await_count == 1 + assert api.await_args[0][0] == "chat.postMessage" + assert api.await_args[0][2]["channel"] == "D1" + + @pytest.mark.asyncio + async def test_no_channel_no_send(self, monkeypatch): + api = self._patch_common(monkeypatch) + monkeypatch.setattr(slack_events, "get_redis", lambda: None) + await slack_events.deliver_home_welcome("acc1", "U1", "") + api.assert_not_awaited() + + @pytest.mark.asyncio + async def test_assistant_started_sets_status_prompts_and_welcome(self, monkeypatch): + self._patch_common(monkeypatch) + status = AsyncMock(); prompts = AsyncMock() + monkeypatch.setattr(slack_events, "set_assistant_status", status) + monkeypatch.setattr(slack_events, "set_suggested_prompts", prompts) + api = AsyncMock() + monkeypatch.setattr(slack_events, "slack_api", api) + + await slack_events.handle_assistant_thread_started("acc1", "D2", "111.222") + + status.assert_awaited_once() + prompts.assert_awaited_once() + api.assert_awaited_once() + assert api.await_args[0][0] == "chat.postMessage" + assert api.await_args[0][2].get("thread_ts") == "111.222" diff --git a/backend/tests/channels/test_sms_providers.py b/backend/tests/channels/test_sms_providers.py new file mode 100644 index 0000000..9c87ab2 --- /dev/null +++ b/backend/tests/channels/test_sms_providers.py @@ -0,0 +1,172 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import base64 +import hashlib +import hmac +import json +from unittest.mock import MagicMock + +import pytest + +from app.channels.sms import get_provider, list_providers +from app.channels.sms.base import SmsWebhookRequest +from app.channels.sms.twilio import TwilioProvider + + +def make_req(params=None, json_body=None, headers=None, url="https://x/api/v1/webhooks/sms/p/abc", raw=b""): + pairs = list((params or {}).items()) + return SmsWebhookRequest( + headers={k.lower(): v for k, v in (headers or {}).items()}, + params=params or {}, param_pairs=pairs, json_body=json_body, + raw_body=raw or (json.dumps(json_body).encode() if json_body else b""), url=url) + + +def account_with(creds: dict): + from app.core.security import encrypt_api_key + acc = MagicMock() + acc.encrypted_credentials = encrypt_api_key(json.dumps(creds)) + acc.external_account_id = "+15550001111" + return acc + + +def test_all_providers_registered(): + names = {p.name for p in list_providers()} + assert names == {"twilio", "vonage", "messagebird", "plivo", "brevo", "sns"} + + +class TestTwilio: + def test_parse(self): + req = make_req(params={"From": "+44700", "To": "+1555", "Body": " hi ", "MessageSid": "SM1"}) + m = get_provider("twilio").parse_inbound(req, account_with({}))[0] + assert m.external_conversation_id == "+44700" and m.text == "hi" + # The sender IS the customer's number — declared so identity can use it + assert m.profile["phone"] == "+44700" + + @pytest.mark.asyncio + async def test_verify_signature(self): + url = "https://x/api/v1/webhooks/sms/twilio/abc" + params = {"From": "+44", "Body": "hi", "MessageSid": "SM1"} + payload = url + "".join(f"{k}{params[k]}" for k in sorted(params)) + sig = base64.b64encode(hmac.new(b"tok", payload.encode(), hashlib.sha1).digest()).decode() + req = make_req(params=params, url=url, headers={"X-Twilio-Signature": sig}) + assert await get_provider("twilio").verify_webhook(req, account_with({"auth_token": "tok"})) is True + bad = make_req(params=params, url=url, headers={"X-Twilio-Signature": "bad"}) + assert await get_provider("twilio").verify_webhook(bad, account_with({"auth_token": "tok"})) is False + + +class TestVonage: + def test_parse(self): + req = make_req(params={"msisdn": "+44700", "to": "+1555", "text": "hello", "messageId": "v1"}) + m = get_provider("vonage").parse_inbound(req, account_with({}))[0] + assert m.external_conversation_id == "+44700" and m.external_message_id == "v1" + + @pytest.mark.asyncio + async def test_no_signature_secret_accepts(self): + req = make_req(params={"msisdn": "+44", "text": "x"}) + assert await get_provider("vonage").verify_webhook(req, account_with({})) is True + + @pytest.mark.asyncio + async def test_hmac_signature(self): + secret = "sig-sek" + params = {"msisdn": "+44", "text": "hi", "to": "+1555"} + parts = "".join(f"&{k}={params[k]}" for k in sorted(params)) + sig = hmac.new(secret.encode(), parts.encode(), hashlib.sha256).hexdigest().upper() + req = make_req(params={**params, "sig": sig}) + assert await get_provider("vonage").verify_webhook(req, account_with({"signature_secret": secret})) is True + bad = make_req(params={**params, "sig": "deadbeef"}) + assert await get_provider("vonage").verify_webhook(bad, account_with({"signature_secret": secret})) is False + + +class TestMessageBird: + def test_parse_json(self): + req = make_req(json_body={"originator": "+44700", "recipient": "+1555", "payload": "hey", "id": "mb1"}) + m = get_provider("messagebird").parse_inbound(req, account_with({}))[0] + assert m.external_conversation_id == "+44700" and m.text == "hey" + + @pytest.mark.asyncio + async def test_signature(self): + key = "signkey" + body = b'{"originator":"+44","payload":"hi"}' + ts = "1720000000" + url = "https://x/api/v1/webhooks/sms/messagebird/abc" + body_hash = hashlib.sha256(body).digest() + parts = ts.encode() + b"\n" + url.encode() + b"\n" + body_hash + sig = base64.b64encode(hmac.new(key.encode(), parts, hashlib.sha256).digest()).decode() + req = make_req(json_body=json.loads(body), url=url, raw=body, + headers={"MessageBird-Request-Timestamp": ts, "MessageBird-Signature": sig}) + assert await get_provider("messagebird").verify_webhook(req, account_with({"signing_key": key})) is True + + +class TestPlivo: + def test_parse(self): + req = make_req(params={"From": "+44700", "To": "+1555", "Text": "yo", "MessageUUID": "pl1"}) + m = get_provider("plivo").parse_inbound(req, account_with({}))[0] + assert m.external_conversation_id == "+44700" and m.external_message_id == "pl1" + + @pytest.mark.asyncio + async def test_signature_v3(self): + token, nonce = "authtok", "n123" + url = "https://x/api/v1/webhooks/sms/plivo/abc" + sig = base64.b64encode(hmac.new(token.encode(), (url + nonce).encode(), hashlib.sha256).digest()).decode() + req = make_req(url=url, headers={"X-Plivo-Signature-V3": sig, "X-Plivo-Signature-V3-Nonce": nonce}) + assert await get_provider("plivo").verify_webhook(req, account_with({"auth_token": token})) is True + + +class TestBrevo: + def test_parse(self): + req = make_req(json_body={"from": "+44700", "to": "+1555", "text": "hi there"}) + m = get_provider("brevo").parse_inbound(req, account_with({}))[0] + assert m.text == "hi there" + + +class TestSns: + def test_parse_notification(self): + sms = {"originationNumber": "+44700", "destinationNumber": "+1555", "messageBody": "sns hi"} + req = make_req(json_body={"Type": "Notification", "MessageId": "sns1", "Message": json.dumps(sms)}) + m = get_provider("sns").parse_inbound(req, account_with({}))[0] + assert m.external_conversation_id == "+44700" and m.text == "sns hi" + + def test_subscription_confirmation_not_a_message(self): + req = make_req(json_body={"Type": "SubscriptionConfirmation", "SubscribeURL": "https://sns.x"}) + assert get_provider("sns").parse_inbound(req, account_with({})) == [] + + @pytest.mark.asyncio + async def test_non_sns_cert_host_rejected(self): + # An attacker-hosted cert on S3 (or anywhere off sns.<region>.amazonaws.com) is refused + for host in ("https://evil.com/c.pem", + "https://attacker.s3.amazonaws.com/c.pem", + "https://sns.amazonaws.com/c.pem"): # no region segment + req = make_req(json_body={"Type": "Notification", "SigningCertURL": host, + "Signature": "x", "Message": "{}", "MessageId": "1", + "Timestamp": "t", "TopicArn": "arn"}) + assert await get_provider("sns").verify_webhook(req, account_with({})) is False + + @pytest.mark.asyncio + async def test_valid_sns_host_bad_signature_rejected(self): + req = make_req(json_body={"Type": "Notification", + "SigningCertURL": "https://sns.us-east-1.amazonaws.com/c.pem", + "Signature": base64.b64encode(b"bad").decode(), + "Message": "{}", "MessageId": "1", "Timestamp": "t", "TopicArn": "arn"}) + # Valid host but signature verification fails (no real cert) + assert await get_provider("sns").verify_webhook(req, account_with({})) is False + + @pytest.mark.asyncio + async def test_topic_arn_mismatch_rejected(self): + req = make_req(json_body={"Type": "Notification", "TopicArn": "arn:other", + "SigningCertURL": "https://sns.us-east-1.amazonaws.com/c.pem", "Signature": "x"}) + assert await get_provider("sns").verify_webhook( + req, account_with({"topic_arn": "arn:mine"})) is False diff --git a/backend/tests/channels/test_smtp_config.py b/backend/tests/channels/test_smtp_config.py new file mode 100644 index 0000000..856496f --- /dev/null +++ b/backend/tests/channels/test_smtp_config.py @@ -0,0 +1,75 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +The SMTP connection settings every sender shares: an inbox's own credentials, +or the platform account (which local development points at Mailpit). +""" + +import smtplib + +import pytest + +from app.channels import email as email_channel +from app.channels.email import inbox_smtp_config, open_smtp, platform_smtp_config +from app.core.config import settings + + +@pytest.fixture +def platform(monkeypatch): + def configure(port, username="", password=""): + monkeypatch.setattr(settings, "SMTP_SERVER", "smtp.example.com") + monkeypatch.setattr(settings, "SMTP_PORT", port) + monkeypatch.setattr(settings, "SMTP_USERNAME", username) + monkeypatch.setattr(settings, "SMTP_PASSWORD", password) + return configure + + +@pytest.mark.parametrize("port,use_ssl", [(587, False), (465, True), ("1025", False)]) +def test_platform_config(platform, port, use_ssl): + platform(port, "relay-user", "relay-pass") + assert platform_smtp_config("noreply@example.com") == { + "host": "smtp.example.com", "port": int(port), "username": "relay-user", + "password": "relay-pass", "from_email": "noreply@example.com", "use_ssl": use_ssl, + } + + +def test_inbox_config_defaults(): + cfg = inbox_smtp_config({"smtp_host": "smtp.acme.com"}, "support@acme.com") + assert cfg["port"] == 587 and cfg["use_ssl"] is False + assert cfg["from_email"] == "support@acme.com" + + +@pytest.mark.parametrize("port,explicit,expected", [(465, False, False), (587, True, True), (465, None, True)]) +def test_inbox_explicit_ssl_wins(port, explicit, expected): + creds = {"smtp_host": "smtp.acme.com", "smtp_port": port, "smtp_use_ssl": explicit} + assert inbox_smtp_config(creds, "support@acme.com")["use_ssl"] is expected + + +class FakeSMTP: + def __init__(self, host, port, timeout=None): + self.calls = [] + + def starttls(self): + raise smtplib.SMTPNotSupportedError("STARTTLS extension not supported by server.") + + def login(self, username, password): + self.calls.append(("login", username)) + + +def test_platform_without_username_skips_login(platform, monkeypatch): + """Mailpit: no STARTTLS offered, no credentials configured.""" + platform(1025) + monkeypatch.setattr(email_channel.smtplib, "SMTP", FakeSMTP) + assert open_smtp(platform_smtp_config("noreply@example.com")).calls == [] diff --git a/backend/tests/channels/test_telegram_adapter.py b/backend/tests/channels/test_telegram_adapter.py new file mode 100644 index 0000000..892d88e --- /dev/null +++ b/backend/tests/channels/test_telegram_adapter.py @@ -0,0 +1,110 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +import pytest +from unittest.mock import MagicMock + +from app.channels import get_adapter +from app.channels.telegram import TelegramAdapter, MAX_MESSAGE_LENGTH + + +USER_MESSAGE_UPDATE = { + "update_id": 1, + "message": { + "message_id": 42, + "from": {"id": 111, "is_bot": False, "first_name": "Ada", "last_name": "Lovelace", "username": "ada"}, + "chat": {"id": 222, "type": "private"}, + "date": 1720000000, + "text": "hello there", + }, +} + +BOT_MESSAGE_UPDATE = { + "update_id": 2, + "message": { + "message_id": 43, + "from": {"id": 999, "is_bot": True, "first_name": "Bot"}, + "chat": {"id": 222, "type": "private"}, + "date": 1720000001, + "text": "echo", + }, +} + +MEDIA_ONLY_UPDATE = { + "update_id": 3, + "message": { + "message_id": 44, + "from": {"id": 111, "is_bot": False, "first_name": "Ada"}, + "chat": {"id": 222, "type": "private"}, + "date": 1720000002, + "photo": [{"file_id": "abc"}], + }, +} + + +@pytest.fixture +def adapter(): + return TelegramAdapter() + + +def test_registry_resolves_telegram(): + assert isinstance(get_adapter("telegram"), TelegramAdapter) + assert get_adapter("web") is None + + +def test_parse_user_message(adapter): + messages = adapter.parse_inbound(USER_MESSAGE_UPDATE) + assert len(messages) == 1 + msg = messages[0] + assert msg.external_conversation_id == "222" + assert msg.external_user_id == "111" + assert msg.external_message_id == "42" + assert msg.text == "hello there" + assert msg.profile["name"] == "Ada Lovelace" + + +def test_parse_ignores_bot_messages(adapter): + assert adapter.parse_inbound(BOT_MESSAGE_UPDATE) == [] + + +def test_parse_ignores_media_only_messages(adapter): + assert adapter.parse_inbound(MEDIA_ONLY_UPDATE) == [] + + +def test_parse_ignores_non_message_updates(adapter): + assert adapter.parse_inbound({"update_id": 4, "edited_message": {}}) == [] + + +@pytest.mark.asyncio +async def test_verify_webhook_accepts_matching_secret(adapter): + account = MagicMock(webhook_secret="s3cret") + assert await adapter.verify_webhook( + {"x-telegram-bot-api-secret-token": "s3cret"}, b"{}", account) is True + + +@pytest.mark.asyncio +async def test_verify_webhook_rejects_bad_or_missing_secret(adapter): + account = MagicMock(webhook_secret="s3cret") + assert await adapter.verify_webhook( + {"x-telegram-bot-api-secret-token": "wrong"}, b"{}", account) is False + assert await adapter.verify_webhook({}, b"{}", account) is False + assert await adapter.verify_webhook( + {"x-telegram-bot-api-secret-token": "s3cret"}, b"{}", None) is False + + +def test_format_outbound_enforces_length_cap(adapter): + assert adapter.format_outbound("x" * (MAX_MESSAGE_LENGTH + 100)) == "x" * MAX_MESSAGE_LENGTH + assert adapter.format_outbound("short **markdown**") == "short **markdown**" diff --git a/frontend/src/__tests__/components/faq/FaqCard.spec.ts b/frontend/src/__tests__/components/faq/FaqCard.spec.ts new file mode 100644 index 0000000..cf25c30 --- /dev/null +++ b/frontend/src/__tests__/components/faq/FaqCard.spec.ts @@ -0,0 +1,156 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' + +// FaqCard → MarkdownEditor → faqService → api → router → views → firebase, +// which cannot initialize in the test environment. +vi.mock('@/services/firebase', () => ({ + messaging: {}, + requestNotificationPermission: vi.fn(), +})) +vi.mock('@/services/faq', () => ({ + faqService: { uploadImage: vi.fn() }, +})) + +import FaqCard from '../../../components/faq/FaqCard.vue' +import MarkdownEditor from '../../../components/faq/MarkdownEditor.vue' +import type { FaqItem } from '../../../types/faq' + +const faq: FaqItem = { + id: 'f1', + question: 'How does billing work?', + answer: 'Plans are billed per seat, monthly or yearly.', + category: 'Billing', + status: 'published', + knowledge_id: 1, + source_label: 'Billing & refunds policy', +} + +const createWrapper = (props = {}) => + mount(FaqCard, { + props: { + faq, + editing: false, + draftQuestion: '', + draftAnswer: '', + ...props, + }, + }) + +describe('FaqCard display mode', () => { + it('renders question, answer and source label', () => { + const wrapper = createWrapper() + expect(wrapper.find('.faq-card__question').text()).toBe('How does billing work?') + expect(wrapper.find('.faq-card__answer').text()).toBe('Plans are billed per seat, monthly or yearly.') + expect(wrapper.find('.faq-card__source').text()).toContain('Billing & refunds policy') + }) + + it('falls back to Generated when there is no source label', () => { + const wrapper = createWrapper({ faq: { ...faq, source_label: null } }) + expect(wrapper.find('.faq-card__source').text()).toContain('自动生成') + }) + + it('shows the publish pill state', () => { + const published = createWrapper() + expect(published.find('.pill').classes()).toContain('pill--published') + expect(published.find('.pill').text()).toContain('已发布') + + const draft = createWrapper({ faq: { ...faq, status: 'draft' } }) + expect(draft.find('.pill').classes()).toContain('pill--draft') + expect(draft.find('.pill').text()).toContain('草稿') + }) + + it('emits toggle-status when the pill is clicked', async () => { + const wrapper = createWrapper() + await wrapper.find('.pill').trigger('click') + expect(wrapper.emitted('toggle-status')).toHaveLength(1) + }) + + it('emits edit and delete from the icon buttons', async () => { + const wrapper = createWrapper() + await wrapper.find('.icon-btn--edit').trigger('click') + await wrapper.find('.icon-btn--delete').trigger('click') + expect(wrapper.emitted('edit')).toHaveLength(1) + expect(wrapper.emitted('delete')).toHaveLength(1) + }) + + it('hides edit and delete and disables the pill when locked', () => { + const wrapper = createWrapper({ locked: true }) + expect(wrapper.find('.icon-btn--edit').exists()).toBe(false) + expect(wrapper.find('.icon-btn--delete').exists()).toBe(false) + expect(wrapper.find('.pill').attributes('disabled')).toBeDefined() + }) +}) + +describe('FaqCard edit mode', () => { + it('renders inputs bound to the drafts', () => { + const wrapper = createWrapper({ + editing: true, + draftQuestion: 'Draft question?', + draftAnswer: 'Draft answer.', + }) + expect((wrapper.find('.edit-question').element as HTMLInputElement).value).toBe('Draft question?') + // The answer is edited through the MarkdownEditor component. + expect(wrapper.findComponent(MarkdownEditor).props('modelValue')).toBe('Draft answer.') + expect(wrapper.find('.edit-label').text()).toBe('编辑中') + }) + + it('labels a new FAQ', () => { + const wrapper = createWrapper({ editing: true, isNew: true }) + expect(wrapper.find('.edit-label').text()).toBe('新建常见问题') + }) + + it('emits draft updates on input', async () => { + const wrapper = createWrapper({ editing: true }) + await wrapper.find('.edit-question').setValue('New question') + wrapper.findComponent(MarkdownEditor).vm.$emit('update:modelValue', 'New answer') + expect(wrapper.emitted('update:draftQuestion')?.[0]).toEqual(['New question']) + expect(wrapper.emitted('update:draftAnswer')?.[0]).toEqual(['New answer']) + }) + + it('renders the topic input with existing topics as autocomplete options', () => { + const wrapper = createWrapper({ + editing: true, + draftCategory: 'Billing', + categories: ['Billing', 'Getting started'], + }) + expect((wrapper.find('.edit-topic__input').element as HTMLInputElement).value).toBe('Billing') + const options = wrapper.findAll('#faq-topic-options option') + expect(options.map((o) => o.attributes('value'))).toEqual(['Billing', 'Getting started']) + }) + + it('emits topic draft updates on input', async () => { + const wrapper = createWrapper({ editing: true }) + await wrapper.find('.edit-topic__input').setValue('Refunds') + expect(wrapper.emitted('update:draftCategory')?.[0]).toEqual(['Refunds']) + }) + + it('emits save and cancel', async () => { + const wrapper = createWrapper({ editing: true }) + await wrapper.find('.btn-save').trigger('click') + await wrapper.find('.btn-cancel').trigger('click') + expect(wrapper.emitted('save')).toHaveLength(1) + expect(wrapper.emitted('cancel')).toHaveLength(1) + }) + + it('disables Save while saving', () => { + const wrapper = createWrapper({ editing: true, saving: true }) + expect(wrapper.find('.btn-save').attributes('disabled')).toBeDefined() + expect(wrapper.find('.btn-save').text()).toBe('保存中…') + }) +}) diff --git a/frontend/src/__tests__/components/faq/FaqGenerateBar.spec.ts b/frontend/src/__tests__/components/faq/FaqGenerateBar.spec.ts new file mode 100644 index 0000000..3b6bdcc --- /dev/null +++ b/frontend/src/__tests__/components/faq/FaqGenerateBar.spec.ts @@ -0,0 +1,77 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import FaqGenerateBar from '../../../components/faq/FaqGenerateBar.vue' + +const createWrapper = (props = {}) => + mount(FaqGenerateBar, { + props: { + phase: 'idle' as const, + sourceCount: 5, + pageCount: 9, + faqCount: 12, + publishedCount: 7, + ...props, + }, + }) + +describe('FaqGenerateBar', () => { + it('shows knowledge base counts and Generate when idle', () => { + const wrapper = createWrapper() + expect(wrapper.find('.generate-bar__title').text()).toBe('生成常见问题') + expect(wrapper.find('.generate-bar__sub').text()).toContain('读取 5 个来源 · 9 个页面') + expect(wrapper.find('.btn--generate').text()).toContain('生成') + expect(wrapper.find('.btn--generate').attributes('disabled')).toBeUndefined() + }) + + it('shows FAQ counts and Regenerate when ready', () => { + const wrapper = createWrapper({ phase: 'ready' }) + expect(wrapper.find('.generate-bar__title').text()).toBe('常见问题待发布') + expect(wrapper.find('.generate-bar__sub').text()).toContain('12 条常见问题 · 7 条已发布') + expect(wrapper.find('.btn--generate').text()).toContain('重新生成') + }) + + it('disables the generate button while generating', () => { + const wrapper = createWrapper({ phase: 'generating' }) + expect(wrapper.find('.generate-bar__title').text()).toBe('正在生成常见问题…') + expect(wrapper.find('.btn--generate').text()).toContain('生成中…') + expect(wrapper.find('.btn--generate').attributes('disabled')).toBeDefined() + }) + + it('respects the disabled prop', () => { + const wrapper = createWrapper({ disabled: true }) + expect(wrapper.find('.btn--generate').attributes('disabled')).toBeDefined() + }) + + it('emits generate, import and add', async () => { + const wrapper = createWrapper() + await wrapper.find('.btn--generate').trigger('click') + const ghostButtons = wrapper.findAll('.btn--ghost') + await ghostButtons[0].trigger('click') + await ghostButtons[1].trigger('click') + expect(wrapper.emitted('generate')).toHaveLength(1) + expect(wrapper.emitted('import')).toHaveLength(1) + expect(wrapper.emitted('add')).toHaveLength(1) + }) + + it('does not emit generate when disabled', async () => { + const wrapper = createWrapper({ phase: 'generating' }) + await wrapper.find('.btn--generate').trigger('click') + expect(wrapper.emitted('generate')).toBeUndefined() + }) +}) diff --git a/frontend/src/__tests__/components/faq/FaqGenerationProgress.spec.ts b/frontend/src/__tests__/components/faq/FaqGenerationProgress.spec.ts new file mode 100644 index 0000000..9c43d9f --- /dev/null +++ b/frontend/src/__tests__/components/faq/FaqGenerationProgress.spec.ts @@ -0,0 +1,74 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import FaqGenerationProgress from '../../../components/faq/FaqGenerationProgress.vue' +import type { FaqGenerationJob, FaqJobStage } from '../../../types/faq' + +const job = (stage: FaqJobStage): FaqGenerationJob => ({ + id: 1, + job_type: 'generate_all', + status: 'processing', + stage, + progress_percentage: 40, + faqs_created: 0, + source_url: null, + error: null, +}) + +const stepStates = (stage: FaqJobStage) => { + const wrapper = mount(FaqGenerationProgress, { props: { job: job(stage) } }) + return wrapper.findAll('.step').map((step) => { + if (step.classes().includes('done')) return 'done' + if (step.classes().includes('active')) return 'active' + return 'todo' + }) +} + +describe('FaqGenerationProgress', () => { + it('renders four steps with the expected labels', () => { + const wrapper = mount(FaqGenerationProgress, { props: { job: job('analyzing_sources') } }) + const labels = wrapper.findAll('.step__label').map((el) => el.text()) + expect(labels).toEqual([ + '分析你的来源', + '从内容中提取问题', + '撰写有依据的回答', + '按分类整理', + ]) + }) + + it('marks earlier stages done, the current stage active, later stages todo', () => { + expect(stepStates('drafting')).toEqual(['done', 'done', 'active', 'todo']) + }) + + it('starts with the first stage active', () => { + expect(stepStates('analyzing_sources')).toEqual(['active', 'todo', 'todo', 'todo']) + expect(stepStates('not_started')).toEqual(['active', 'todo', 'todo', 'todo']) + }) + + it('marks everything done when completed', () => { + expect(stepStates('completed')).toEqual(['done', 'done', 'done', 'done']) + }) + + it('shows pulsing dots only on the active step', () => { + const wrapper = mount(FaqGenerationProgress, { props: { job: job('drafting') } }) + const steps = wrapper.findAll('.step') + expect(steps[2].find('.step__pulse').exists()).toBe(true) + expect(steps[0].find('.step__pulse').exists()).toBe(false) + expect(steps[3].find('.step__pulse').exists()).toBe(false) + }) +}) diff --git a/frontend/src/__tests__/components/faq/FaqImportModal.spec.ts b/frontend/src/__tests__/components/faq/FaqImportModal.spec.ts new file mode 100644 index 0000000..76aef14 --- /dev/null +++ b/frontend/src/__tests__/components/faq/FaqImportModal.spec.ts @@ -0,0 +1,97 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' + +import FaqImportModal from '../../../components/faq/FaqImportModal.vue' + +vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) + +const createWrapper = () => + mount(FaqImportModal, { + props: { open: true }, + global: { + stubs: { + Modal: { template: '<div><slot name="title" /><slot name="content" /></div>' }, + FaqOrb: true, + }, + }, + }) + +/** Pick an import mode by its radio value. */ +const chooseMode = (wrapper: ReturnType<typeof createWrapper>, mode: string) => + wrapper.find(`input[value="${mode}"]`).setValue(true) + +describe('FaqImportModal', () => { + it('offers the preserve-URLs option only for an article migration', async () => { + const wrapper = createWrapper() + // Q&A and PDF modes extract answers from one document — there are no + // per-article source URLs to preserve. + expect(wrapper.find('.preserve').exists()).toBe(false) + + await chooseMode(wrapper, 'articles') + expect(wrapper.find('.preserve').exists()).toBe(true) + + await chooseMode(wrapper, 'pdf') + expect(wrapper.find('.preserve').exists()).toBe(false) + }) + + it('preserves URLs by default, since that is what a migration wants', async () => { + const wrapper = createWrapper() + await chooseMode(wrapper, 'articles') + await wrapper.find('#faq-import-url').setValue('support.acme.com/help') + + await wrapper.find('.btn-import').trigger('click') + + expect(wrapper.emitted('submit')?.[0]).toEqual([ + 'https://support.acme.com/help', + 'articles', + true, + ]) + }) + + it('submits the opt-out when the box is unticked', async () => { + const wrapper = createWrapper() + await chooseMode(wrapper, 'articles') + await wrapper.find('#faq-import-url').setValue('support.acme.com/help') + await wrapper.find('.preserve__box').setValue(false) + + await wrapper.find('.btn-import').trigger('click') + + expect(wrapper.emitted('submit')?.[0]?.[2]).toBe(false) + }) + + it('never claims to preserve URLs in a mode that has none', async () => { + const wrapper = createWrapper() + await wrapper.find('#faq-import-url').setValue('support.acme.com/faq') + + await wrapper.find('.btn-import').trigger('click') + + expect(wrapper.emitted('submit')?.[0]).toEqual(['https://support.acme.com/faq', 'qa', false]) + }) + + it('resets the choice when the mode changes, so it cannot go stale', async () => { + const wrapper = createWrapper() + await chooseMode(wrapper, 'articles') + await wrapper.find('.preserve__box').setValue(false) + + await chooseMode(wrapper, 'qa') + await chooseMode(wrapper, 'articles') + + expect((wrapper.find('.preserve__box').element as HTMLInputElement).checked).toBe(true) + }) +}) diff --git a/frontend/src/__tests__/components/faq/FaqSeoFields.spec.ts b/frontend/src/__tests__/components/faq/FaqSeoFields.spec.ts new file mode 100644 index 0000000..9c5348e --- /dev/null +++ b/frontend/src/__tests__/components/faq/FaqSeoFields.spec.ts @@ -0,0 +1,90 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' + +import FaqSeoFields from '../../../components/faq/FaqSeoFields.vue' + +const createWrapper = (props = {}) => mount(FaqSeoFields, { props }) + +// The meta-title input is the only plain .seo__input without a modifier class. +const titleInput = (wrapper: ReturnType<typeof createWrapper>) => + wrapper.findAll('input.seo__input').filter((i) => !i.classes().some((c) => c.includes('--')))[0] + +describe('FaqSeoFields', () => { + it('stays collapsed until opened when nothing is customised', async () => { + const wrapper = createWrapper() + expect(wrapper.find('.seo__fields').exists()).toBe(false) + + await wrapper.find('.seo__toggle').trigger('click') + expect(wrapper.find('.seo__fields').exists()).toBe(true) + }) + + it('opens itself when the article already has overrides', () => { + // A customised value must never be hidden behind a collapsed panel. + const wrapper = createWrapper({ metaTitle: 'Custom title' }) + expect(wrapper.find('.seo__fields').exists()).toBe(true) + expect(wrapper.find('.seo__badge').exists()).toBe(true) + }) + + it('can still be collapsed after opening itself', async () => { + const wrapper = createWrapper({ metaTitle: 'Custom title' }) + await wrapper.find('.seo__toggle').trigger('click') + expect(wrapper.find('.seo__fields').exists()).toBe(false) + }) + + it('does not treat a slug alone as customised', () => { + // Every article gets a generated slug, so its presence says nothing about + // whether the org edited anything. + const wrapper = createWrapper({ slug: 'auto-generated-slug' }) + expect(wrapper.find('.seo__badge').exists()).toBe(false) + }) + + it('emits updates for each field', async () => { + const wrapper = createWrapper({ metaTitle: 'x' }) + + await wrapper.find('.seo__input--slug').setValue('custom-slug') + await wrapper.find('.seo__input--path').setValue('/hc/articles/1') + await titleInput(wrapper).setValue('New title') + await wrapper.find('textarea').setValue('New description') + + expect(wrapper.emitted('update:slug')?.[0]).toEqual(['custom-slug']) + expect(wrapper.emitted('update:urlPath')?.[0]).toEqual(['/hc/articles/1']) + expect(wrapper.emitted('update:metaTitle')?.at(-1)).toEqual(['New title']) + expect(wrapper.emitted('update:metaDescription')?.[0]).toEqual(['New description']) + }) + + it('treats a preserved URL path as a customisation', () => { + // A migrated article's URL must not be hidden behind a collapsed panel. + const wrapper = createWrapper({ urlPath: '/hc/en-us/articles/360012-reset' }) + expect(wrapper.find('.seo__fields').exists()).toBe(true) + expect(wrapper.find('.seo__badge').exists()).toBe(true) + expect((wrapper.find('.seo__input--path').element as HTMLInputElement).value).toBe( + '/hc/en-us/articles/360012-reset', + ) + }) + + it('flags a title past the length search engines truncate at', () => { + const wrapper = createWrapper({ metaTitle: 'a'.repeat(61) }) + expect(wrapper.find('.seo__count--over').exists()).toBe(true) + }) + + it('shows the question as the title placeholder so the default is visible', () => { + const wrapper = createWrapper({ metaTitle: 'x', question: 'How do I sign up?' }) + expect(titleInput(wrapper).attributes('placeholder')).toBe('How do I sign up?') + }) +}) diff --git a/frontend/src/__tests__/components/integrations/CopyField.spec.ts b/frontend/src/__tests__/components/integrations/CopyField.spec.ts new file mode 100644 index 0000000..ff85634 --- /dev/null +++ b/frontend/src/__tests__/components/integrations/CopyField.spec.ts @@ -0,0 +1,53 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { toast } from 'vue-sonner' + +import CopyField from '../../../components/integrations/CopyField.vue' + +vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) + +const mountField = () => mount(CopyField, { + props: { fieldId: 'verify-token', label: 'Verify token', name: 'Verify token', value: 'tok-123' }, +}) + +describe('CopyField', () => { + beforeEach(() => vi.clearAllMocks()) + + it('labels its input', () => { + const wrapper = mountField() + expect(wrapper.find('label').attributes('for')).toBe('verify-token') + expect(wrapper.find('input').attributes('id')).toBe('verify-token') + }) + + it('copies the value', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.assign(navigator, { clipboard: { writeText } }) + await mountField().find('button').trigger('click') + await flushPromises() + expect(writeText).toHaveBeenCalledWith('tok-123') + expect(toast.success).toHaveBeenCalledWith('Verify token已复制') + }) + + it('says so when the browser will not copy', async () => { + Object.assign(navigator, { clipboard: { writeText: vi.fn().mockRejectedValue(new Error('insecure')) } }) + await mountField().find('button').trigger('click') + await flushPromises() + expect(toast.error).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/__tests__/components/integrations/MessengerPagePicker.spec.ts b/frontend/src/__tests__/components/integrations/MessengerPagePicker.spec.ts new file mode 100644 index 0000000..4086ef7 --- /dev/null +++ b/frontend/src/__tests__/components/integrations/MessengerPagePicker.spec.ts @@ -0,0 +1,52 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' + +import MessengerPagePicker from '../../../components/integrations/MessengerPagePicker.vue' + +const PAGES = [ + { id: 'PAGE1', name: 'Acme Support' }, + { id: 'PAGE2', name: 'Acme Sales' }, +] + +const mountPicker = (connecting = false) => + mount(MessengerPagePicker, { + props: { pages: PAGES, connecting }, + global: { stubs: { 'font-awesome-icon': true } }, + }) + +describe('MessengerPagePicker', () => { + it('renders one row per Page', () => { + const wrapper = mountPicker() + expect(wrapper.findAll('.picker-row')).toHaveLength(2) + expect(wrapper.text()).toContain('Acme Support') + expect(wrapper.text()).toContain('Acme Sales') + }) + + it('emits select with the picked Page id', async () => { + const wrapper = mountPicker() + await wrapper.findAll('.picker-row')[1].trigger('click') + expect(wrapper.emitted('select')).toEqual([['PAGE2']]) + }) + + it('locks every row while a connect is in flight', () => { + const wrapper = mountPicker(true) + const rows = wrapper.findAll('.picker-row') + expect(rows.every((row) => row.attributes('disabled') !== undefined)).toBe(true) + }) +}) diff --git a/frontend/src/__tests__/components/integrations/MetaChannelConnect.appSecret.spec.ts b/frontend/src/__tests__/components/integrations/MetaChannelConnect.appSecret.spec.ts new file mode 100644 index 0000000..5ffca9a --- /dev/null +++ b/frontend/src/__tests__/components/integrations/MetaChannelConnect.appSecret.spec.ts @@ -0,0 +1,78 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ref } from 'vue' +import { mount, flushPromises } from '@vue/test-utils' + +import MetaChannelConnect from '../../../components/integrations/MetaChannelConnect.vue' +import channelsService from '../../../services/channels' + +vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) +vi.mock('../../../services/channels', () => ({ + default: { connectWhatsApp: vi.fn(), connectMessenger: vi.fn(), connectInstagram: vi.fn() }, +})) +vi.mock('../../../services/agent', () => ({ agentService: { getOrganizationAgents: vi.fn().mockResolvedValue([]) } })) +// The manual form, as when Meta's one-click signup isn't offered +vi.mock('../../../composables/useMetaSignup', () => ({ + useMetaSignup: () => ({ + signupEnabled: ref(false), signingUp: ref(false), showManualForm: ref(true), signupPages: ref([]), + connectingPage: ref(false), copy: { intro: '', cta: '' }, startSignup: vi.fn(), onPageSelected: vi.fn(), + }), +})) + +const OWN_APP_ACCOUNT = { + id: 'acc-1', channel_type: 'whatsapp', external_account_id: 'PN9', is_active: true, display_name: 'Acme', + webhook_url: 'https://api.example.com/api/v1/webhooks/meta/acc-1', webhook_verify_token: 'tok', webhook_auto_configured: false, +} + +const fill = async (wrapper: ReturnType<typeof mount>, values: Record<string, string>) => { + for (const [key, value] of Object.entries(values)) { + await wrapper.find(`#meta-${key}`).setValue(value) + } + await wrapper.findAll('button').find(b => b.text() === '连接')!.trigger('click') + await flushPromises() +} + +describe('MetaChannelConnect app secret', () => { + beforeEach(() => vi.clearAllMocks()) + + it('connects without a secret and sends none', async () => { + vi.mocked(channelsService.connectWhatsApp).mockResolvedValue({ ...OWN_APP_ACCOUNT, webhook_url: undefined } as any) + const wrapper = mount(MetaChannelConnect, { props: { channel: 'whatsapp' }, global: { stubs: { 'font-awesome-icon': true } } }) + await fill(wrapper, { phone_number_id: 'PN9', access_token: 'EAAG-x' }) + expect(channelsService.connectWhatsApp).toHaveBeenCalledWith({ phone_number_id: 'PN9', access_token: 'EAAG-x' }) + expect(wrapper.text()).not.toContain('回调 URL') + }) + + it('sends the secret and shows where webhooks go', async () => { + vi.mocked(channelsService.connectWhatsApp).mockResolvedValue(OWN_APP_ACCOUNT as any) + const wrapper = mount(MetaChannelConnect, { props: { channel: 'whatsapp' }, global: { stubs: { 'font-awesome-icon': true } } }) + await fill(wrapper, { phone_number_id: 'PN9', access_token: 'EAAG-x', app_secret: ' s3cret ' }) + expect(channelsService.connectWhatsApp).toHaveBeenCalledWith( + { phone_number_id: 'PN9', access_token: 'EAAG-x', app_secret: 's3cret' }) + expect(wrapper.text()).toContain('回调 URL') + expect(wrapper.text()).toContain('WhatsApp → 配置') + }) + + it('offers the secret on Messenger too, not Instagram', () => { + const has = (channel: 'messenger' | 'instagram') => + mount(MetaChannelConnect, { props: { channel }, global: { stubs: { 'font-awesome-icon': true } } }) + .find('#meta-app_secret').exists() + expect(has('messenger')).toBe(true) + expect(has('instagram')).toBe(false) + }) +}) diff --git a/frontend/src/__tests__/components/integrations/MetaChannelConnect.spec.ts b/frontend/src/__tests__/components/integrations/MetaChannelConnect.spec.ts new file mode 100644 index 0000000..03c2720 --- /dev/null +++ b/frontend/src/__tests__/components/integrations/MetaChannelConnect.spec.ts @@ -0,0 +1,80 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' + +vi.mock('vue-sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) +vi.mock('@/services/agent', () => ({ + agentService: { getOrganizationAgents: vi.fn().mockResolvedValue([{ id: 'ag1', name: 'Bot' }]) }, +})) +vi.mock('@/services/channels', () => ({ + default: { + getEmbeddedSignupConfig: vi.fn().mockResolvedValue({ enabled: false }), + connectWhatsApp: vi.fn(), + }, +})) + +import channelsService from '@/services/channels' +import MetaChannelConnect from '../../../components/integrations/MetaChannelConnect.vue' +import type { ChannelAccount } from '@/services/channels' + +const ACCOUNT: ChannelAccount = { + id: 'acc1', channel_type: 'whatsapp', external_account_id: 'PN1', + display_name: 'Test Number', is_active: true, agent_id: 'ag1', +} + +const mountModal = (existingAccount: ChannelAccount) => + mount(MetaChannelConnect, { + props: { channel: 'whatsapp', existingAccount }, + global: { stubs: { 'font-awesome-icon': true } }, + }) + +describe('MetaChannelConnect reconnect prompt', () => { + beforeEach(() => vi.clearAllMocks()) + + it('does not offer a reconnect for a healthy account', async () => { + const wrapper = mountModal(ACCOUNT) + await flushPromises() + expect(wrapper.find('.meta-reconnect').exists()).toBe(false) + expect(wrapper.text()).toContain('已连接') + }) + + it('offers a reconnect when Meta stopped accepting the account', async () => { + const wrapper = mountModal({ ...ACCOUNT, needs_reconnect: true }) + await flushPromises() + expect(wrapper.find('.meta-reconnect').text()).toContain('请重新连接以恢复回复') + }) + + it('Reconnect opens the credentials form, and connecting again clears the prompt', async () => { + vi.mocked(channelsService.connectWhatsApp).mockResolvedValue({ ...ACCOUNT, needs_reconnect: false }) + const wrapper = mountModal({ ...ACCOUNT, needs_reconnect: true }) + await flushPromises() + + await wrapper.find('.meta-reconnect button').trigger('click') + expect(wrapper.find('#meta-phone_number_id').exists()).toBe(true) + + await wrapper.find('#meta-phone_number_id').setValue('PN1') + await wrapper.find('#meta-access_token').setValue('new-token') + await wrapper.findAll('.meta-actions .meta-btn-primary').at(-1)!.trigger('click') + await flushPromises() + + expect(channelsService.connectWhatsApp).toHaveBeenCalledWith( + expect.objectContaining({ phone_number_id: 'PN1', access_token: 'new-token' })) + expect(wrapper.find('.meta-reconnect').exists()).toBe(false) + expect(wrapper.text()).toContain('已连接') + }) +}) diff --git a/frontend/src/__tests__/components/integrations/MetaOwnAppWebhook.spec.ts b/frontend/src/__tests__/components/integrations/MetaOwnAppWebhook.spec.ts new file mode 100644 index 0000000..ea6960e --- /dev/null +++ b/frontend/src/__tests__/components/integrations/MetaOwnAppWebhook.spec.ts @@ -0,0 +1,52 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' + +import MetaOwnAppWebhook from '../../../components/integrations/MetaOwnAppWebhook.vue' +import type { ChannelAccount } from '../../../services/channels' + +vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) + +const account = (channel_type: 'whatsapp' | 'messenger', webhook_auto_configured?: boolean): ChannelAccount => ({ + id: 'acc-1', + channel_type, + external_account_id: 'PN1', + is_active: true, + webhook_url: 'https://api.example.com/api/v1/webhooks/meta/acc-1', + webhook_verify_token: 'tok-123', + webhook_auto_configured, +}) + +const text = (a: ChannelAccount) => mount(MetaOwnAppWebhook, { props: { account: a } }).text() + +describe('MetaOwnAppWebhook', () => { + it('shows the callback URL and verify token', () => { + const inputs = mount(MetaOwnAppWebhook, { props: { account: account('whatsapp', true) } }).findAll('input') + expect(inputs.map(i => (i.element as HTMLInputElement).value)).toEqual([ + 'https://api.example.com/api/v1/webhooks/meta/acc-1', 'tok-123']) + }) + + it('says a WhatsApp number is set up only when Meta took the callback', () => { + expect(text(account('whatsapp', true))).toContain('无需额外配置') + expect(text(account('whatsapp', false))).toContain('WhatsApp → 配置') + }) + + it('tells Messenger where the values go', () => { + expect(text(account('messenger'))).toContain('Messenger → 设置 → Webhooks') + }) +}) diff --git a/frontend/src/__tests__/components/layout/BottomNav.spec.ts b/frontend/src/__tests__/components/layout/BottomNav.spec.ts new file mode 100644 index 0000000..8a9d505 --- /dev/null +++ b/frontend/src/__tests__/components/layout/BottomNav.spec.ts @@ -0,0 +1,89 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createRouter, createWebHistory } from 'vue-router' +import BottomNav from '@/components/layout/BottomNav.vue' + +// Nav visibility comes from the real permission checks reading the cached +// user, so give this spec a user who holds everything. +vi.mock('@/services/user', async () => { + const { userWithPermissions } = await import('../../fixtures/permissions') + const user = userWithPermissions([ + 'manage_agents', 'manage_users', 'view_all_chats', 'manage_all_chats', 'view_people', + 'manage_knowledge', 'view_analytics', 'view_tickets', 'manage_organization', + 'manage_ai_config', 'manage_subscription', + ]) + return { userService: { getCurrentUser: () => user } } +}) + +vi.mock('@/composables/useEnterpriseFeatures', () => ({ + useEnterpriseFeatures: () => ({ hasEnterpriseModule: false }), +})) + +const routes = ['/conversations', '/people', '/ai-agents', '/analytics', '/knowledge'].map( + (path) => ({ path, component: { template: '<div />' } }) +) +routes.push({ path: '/', component: { template: '<div />' } }) + +const makeRouter = () => createRouter({ history: createWebHistory(), routes }) + +const mountNav = async (props = {}, path = '/conversations') => { + const router = makeRouter() + await router.push(path) + await router.isReady() + return mount(BottomNav, { props, global: { plugins: [router] } }) +} + +describe('BottomNav', () => { + it('renders the four primary items plus More', async () => { + const wrapper = await mountNav() + const labels = wrapper.findAll('.bottom-nav-label').map((n) => n.text()) + expect(labels).toEqual(['会话', '联系人', 'AI 客服', '数据分析', '更多']) + }) + + it('marks the current route active', async () => { + const wrapper = await mountNav({}, '/analytics') + const active = wrapper.findAll('.bottom-nav-item.active') + expect(active).toHaveLength(1) + expect(active[0].text()).toContain('数据分析') + }) + + it('marks More active when on an overflow route', async () => { + const wrapper = await mountNav({}, '/knowledge') + const active = wrapper.findAll('.bottom-nav-item.active') + expect(active).toHaveLength(1) + expect(active[0].text()).toContain('更多') + }) + + it('shows an unread badge on More and caps it at 99+', async () => { + const wrapper = await mountNav({ unreadCount: 3 }) + expect(wrapper.find('.bottom-nav-badge').text()).toBe('3') + + await wrapper.setProps({ unreadCount: 120 }) + expect(wrapper.find('.bottom-nav-badge').text()).toBe('99+') + + await wrapper.setProps({ unreadCount: 0 }) + expect(wrapper.find('.bottom-nav-badge').exists()).toBe(false) + }) + + it('emits more when the More button is tapped', async () => { + const wrapper = await mountNav() + await wrapper.find('button.bottom-nav-item').trigger('click') + expect(wrapper.emitted('more')).toHaveLength(1) + }) +}) diff --git a/frontend/src/__tests__/components/layout/navItems.spec.ts b/frontend/src/__tests__/components/layout/navItems.spec.ts new file mode 100644 index 0000000..2b123de --- /dev/null +++ b/frontend/src/__tests__/components/layout/navItems.spec.ts @@ -0,0 +1,177 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Drive the REAL permission checks from a cached user, rather than mocking +// permissionChecks. Nav visibility and the router now read the same map, and a +// mock of the checks cannot catch the two disagreeing — which is exactly the +// bug this suite missed: People was listed for anyone with a chat grant while +// the route required view_people, so the link bounced. +const currentUser = await vi.hoisted(async () => ({ value: null as unknown })) + +vi.mock('@/services/user', () => ({ + userService: { getCurrentUser: () => currentUser.value }, +})) + +vi.mock('@/composables/useEnterpriseFeatures', () => ({ + useEnterpriseFeatures: () => ({ hasEnterpriseModule: false }), +})) + +import { useNavItems, PRIMARY_NAV_PATHS } from '@/components/layout/navItems' +import { userWithPermissions, HUMAN_AGENT_PERMISSIONS } from '../../fixtures/permissions' + +const ALL_PERMISSIONS = [ + 'manage_agents', 'view_agents', 'manage_users', 'view_all_chats', 'manage_all_chats', + 'view_assigned_chats', 'manage_assigned_chats', 'view_unassigned_chats', 'view_people', + 'manage_knowledge', 'view_knowledge', 'view_analytics', 'view_tickets', 'manage_tickets', + 'manage_organization', 'view_organization', 'manage_ai_config', 'view_ai_config', + 'manage_subscription', 'view_subscription', +] + +const asUser = (permissions: string[]) => { + currentUser.value = userWithPermissions(permissions) +} + +describe('useNavItems', () => { + beforeEach(() => asUser(ALL_PERMISSIONS)) + + it('splits primary (bottom-nav) and overflow (More sheet) items', () => { + const { primaryNavItems, moreNavItems } = useNavItems() + + expect(primaryNavItems.value.map((i) => i.to)).toEqual([ + '/conversations', + '/people', + '/ai-agents', + '/analytics', + ]) + const morePaths = moreNavItems.value.map((i) => i.to) + expect(morePaths).toContain('/human-agents') + expect(morePaths).toContain('/knowledge') + expect(morePaths).toContain('/settings/user') + morePaths.forEach((p) => expect(PRIMARY_NAV_PATHS).not.toContain(p)) + moreNavItems.value.forEach((i) => expect(i.section).toBeUndefined()) + }) + + it('hides permission-gated items for restricted users', () => { + asUser(['manage_agents']) + + const { primaryNavItems, moreNavItems, navItems } = useNavItems() + + const primaryPaths = primaryNavItems.value.map((i) => i.to) + expect(primaryPaths).not.toContain('/conversations') + expect(primaryPaths).not.toContain('/people') + expect(primaryPaths).not.toContain('/analytics') + expect(primaryPaths).toContain('/ai-agents') + + expect(moreNavItems.value.map((i) => i.to)).not.toContain('/human-agents') + // User settings is always available + expect(navItems.value.map((i) => i.to)).toContain('/settings/user') + }) + + // The product requirement, as a test: a Human Agent sees the inbox, the + // people they talk to, and their own settings. Nothing else. + it('shows a Human Agent exactly Inbox, People and User Settings', () => { + asUser(HUMAN_AGENT_PERMISSIONS) + + const { navItems } = useNavItems() + + expect(navItems.value.filter((i) => i.to).map((i) => i.to)).toEqual([ + '/conversations', + '/people', + '/settings/user', + ]) + }) + + // A role with only view_people gets the People link; one with only a chat + // grant does not. The nav used to gate People on chat permissions while the + // route required view_people, so both cases were wrong in opposite ways. + it('gates People on the same permissions as its route', () => { + asUser(['view_people']) + expect(useNavItems().navItems.value.map((i) => i.to)).toContain('/people') + + asUser(['view_assigned_chats']) + expect(useNavItems().navItems.value.map((i) => i.to)).toContain('/people') + + asUser(['manage_knowledge']) + expect(useNavItems().navItems.value.map((i) => i.to)).not.toContain('/people') + }) + + // super_admin bypasses every check on the backend; a frontend check that + // does not bypass gives that role an almost-empty sidebar. + it('shows everything to a super_admin', () => { + asUser(['super_admin']) + + const paths = useNavItems().navItems.value.filter((i) => i.to).map((i) => i.to) + + expect(paths).toContain('/ai-agents') + expect(paths).toContain('/human-agents') + expect(paths).toContain('/knowledge') + expect(paths).toContain('/settings/organization') + }) + + it('excludes enterprise subscription when enterprise module is absent', () => { + const { moreNavItems } = useNavItems() + expect(moreNavItems.value.map((i) => i.to)).not.toContain('/settings/subscription') + }) + + // Guards the whole point of the More sheet: mobile must expose exactly the + // same destinations as the desktop sidebar, so adding a nav item can never + // silently leave phones without it. + it('surfaces every sidebar destination across the bottom nav and More sheet', () => { + const { navItems, primaryNavItems, moreNavGroups } = useNavItems() + + const sidebarPaths = navItems.value.filter((i) => i.to).map((i) => i.to) + const mobilePaths = [ + ...primaryNavItems.value.map((i) => i.to), + ...moreNavGroups.value.flatMap((g) => g.items.map((i) => i.to)), + ] + + expect(mobilePaths.slice().sort()).toEqual(sidebarPaths.slice().sort()) + }) + + it('keeps the sidebar section headings in the More sheet', () => { + const { moreNavGroups } = useNavItems() + expect(moreNavGroups.value.map((g) => g.section)).toEqual(['主菜单', '设置']) + // No empty groups — a section fully covered by the bottom nav is dropped + moreNavGroups.value.forEach((g) => expect(g.items.length).toBeGreaterThan(0)) + }) + + // User Settings is always visible while the Settings heading used to be + // permission-gated separately, which orphaned the item into Main Menu. + it('keeps always-visible settings items under Settings for restricted users', () => { + // Knowledge, so Main Menu still has a non-primary item to group; no + // settings grants, so Settings holds only the always-visible entry. + asUser(['manage_knowledge']) + + const { moreNavGroups } = useNavItems() + const settings = moreNavGroups.value.find((g) => g.section === '设置') + + expect(settings?.items.map((i) => i.to)).toEqual(['/settings/user']) + expect(moreNavGroups.value.find((g) => g.section === '主菜单')?.items.map((i) => i.to)) + .not.toContain('/settings/user') + }) + + it('drops a section whose every item is permission-hidden', () => { + asUser([]) + + const { moreNavGroups, navItems } = useNavItems() + + // Only User Settings survives, so Main Menu disappears entirely + expect(moreNavGroups.value.map((g) => g.section)).toEqual(['设置']) + expect(navItems.value.filter((i) => i.section).map((i) => i.section)).toEqual(['设置']) + }) +}) diff --git a/frontend/src/__tests__/components/tickets/InvestigationNoteForm.spec.ts b/frontend/src/__tests__/components/tickets/InvestigationNoteForm.spec.ts new file mode 100644 index 0000000..bdcceba --- /dev/null +++ b/frontend/src/__tests__/components/tickets/InvestigationNoteForm.spec.ts @@ -0,0 +1,98 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { afterEach, describe, expect, it } from 'vitest' +import { mount, type VueWrapper } from '@vue/test-utils' +import InvestigationNoteForm from '@/components/tickets/InvestigationNoteForm.vue' + +// attachTo puts the component in the real document so focus can be asserted, +// and VTU only takes it back out on unmount — without this the forms pile up +// and whichever mounted last owns document.activeElement. +const mounted: VueWrapper[] = [] +afterEach(() => { + while (mounted.length) mounted.pop()!.unmount() +}) + +const mountForm = (props: Record<string, unknown> = {}, slots: Record<string, string> = {}) => { + const wrapper = mount(InvestigationNoteForm, { + props: { placeholder: 'Why?', submitLabel: 'Go', ...props }, + slots, + attachTo: document.body, + }) + mounted.push(wrapper) + return wrapper +} + +describe('InvestigationNoteForm', () => { + it('trims the note before emitting', async () => { + const wrapper = mountForm() + await wrapper.find('textarea').setValue(' use fields.order_ref ') + await wrapper.find('.submit-btn').trigger('click') + + expect(wrapper.emitted('submit')?.[0]).toEqual(['use fields.order_ref']) + }) + + it('allows an empty note', async () => { + // Re-running unchanged is legitimate, and the reject flow has always + // permitted a reason-less rejection. + const wrapper = mountForm() + await wrapper.find('.submit-btn').trigger('click') + + expect(wrapper.emitted('submit')?.[0]).toEqual(['']) + }) + + it('submits on Cmd+Enter and Ctrl+Enter', async () => { + const meta = mountForm() + await meta.find('textarea').setValue('a') + await meta.find('textarea').trigger('keydown', { key: 'Enter', metaKey: true }) + expect(meta.emitted('submit')?.[0]).toEqual(['a']) + + const ctrl = mountForm() + await ctrl.find('textarea').setValue('b') + await ctrl.find('textarea').trigger('keydown', { key: 'Enter', ctrlKey: true }) + expect(ctrl.emitted('submit')?.[0]).toEqual(['b']) + }) + + it('cancels on Escape', async () => { + const wrapper = mountForm() + await wrapper.find('textarea').trigger('keydown', { key: 'Escape' }) + + expect(wrapper.emitted('cancel')).toHaveLength(1) + expect(wrapper.emitted('submit')).toBeUndefined() + }) + + it('focuses the textarea so the operator can just type', () => { + // Mounted alone in this test, so activeElement can only be this one. + const wrapper = mountForm() + expect(document.activeElement).toBe(wrapper.find('textarea').element) + }) + + it('blocks a second submit while one is in flight', async () => { + const wrapper = mountForm({ busy: true }) + expect(wrapper.find('.submit-btn').attributes('disabled')).toBeDefined() + }) + + it('colours the action by tone', () => { + expect(mountForm({ tone: 'danger' }).find('.submit-btn').classes()).toContain('danger') + // Accent is the default, so the panel's re-run needs no tone prop. + expect(mountForm().find('.submit-btn').classes()).toContain('accent') + }) + + it('renders extra controls passed by the caller', () => { + const wrapper = mountForm({}, { default: '<label class="extra">Re-run too</label>' }) + expect(wrapper.find('.extra').exists()).toBe(true) + }) +}) diff --git a/frontend/src/__tests__/components/tickets/TicketConnectorsSection.spec.ts b/frontend/src/__tests__/components/tickets/TicketConnectorsSection.spec.ts new file mode 100644 index 0000000..5f06312 --- /dev/null +++ b/frontend/src/__tests__/components/tickets/TicketConnectorsSection.spec.ts @@ -0,0 +1,120 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import TicketConnectorsSection from '@/components/tickets/TicketConnectorsSection.vue' +import { mcpService } from '@/services/mcp' +import type { MCPTool } from '@/types/mcp' + +vi.mock('@/services/mcp', () => ({ + mcpService: { + getOrganizationMCPTools: vi.fn(), + createMCPTool: vi.fn(), + updateMCPTool: vi.fn(), + deleteMCPTool: vi.fn(), + getMCPToolReferences: vi.fn(), + }, +})) +vi.mock('vue-sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +const CONNECTOR: MCPTool = { + id: 7, + name: 'Elastic prod', + transport_type: 'http', + enabled: true, + url: 'https://kibana/api/agent_builder/mcp', + usage_guidance: 'Order id is fields.order_ref.', + organization_id: 'org-1', + created_at: '2026-09-01T00:00:00Z', + updated_at: '2026-09-01T00:00:00Z', +} + +function mountIt(connectors: MCPTool[] = []) { + vi.mocked(mcpService.getOrganizationMCPTools).mockResolvedValue(connectors) + return mount(TicketConnectorsSection, { + props: { selectedIds: [] }, + global: { stubs: { 'font-awesome-icon': true } }, + }) +} + +describe('TicketConnectorsSection guidance', () => { + beforeEach(() => vi.clearAllMocks()) + + it('sends the guidance the operator wrote when creating a connector', async () => { + vi.mocked(mcpService.createMCPTool).mockResolvedValue({ ...CONNECTOR, id: 9 }) + const wrapper = mountIt() + await flushPromises() + + await wrapper.findAll('.preset-tile').at(-1)!.trigger('click') + await wrapper.find('.field-input').setValue('Elastic prod') + await wrapper.find('.guidance-input').setValue('Order id is fields.order_ref.') + await wrapper.find('.connect-btn').trigger('click') + await flushPromises() + + expect(vi.mocked(mcpService.createMCPTool).mock.calls[0][0]).toMatchObject({ + usage_guidance: 'Order id is fields.order_ref.', + }) + }) + + it('sends null rather than an empty string when no guidance is written', async () => { + vi.mocked(mcpService.createMCPTool).mockResolvedValue({ ...CONNECTOR, id: 9 }) + const wrapper = mountIt() + await flushPromises() + + await wrapper.findAll('.preset-tile').at(-1)!.trigger('click') + await wrapper.find('.field-input').setValue('Bare connector') + await wrapper.find('.connect-btn').trigger('click') + await flushPromises() + + expect(vi.mocked(mcpService.createMCPTool).mock.calls[0][0].usage_guidance).toBeNull() + }) + + it('never seeds guidance from a preset description', async () => { + /** + * A preset's `desc` is a one-line marketing subtitle ("Errors, issues and + * traces"). Letting it become prompt text is exactly the failure this + * field exists to fix, so it must stay empty even though `description` + * is filled. + */ + const wrapper = mountIt() + await flushPromises() + + await wrapper.findAll('.preset-tile')[0].trigger('click') + + expect((wrapper.find('.guidance-input').element as HTMLTextAreaElement).value).toBe('') + expect((wrapper.find('.field-input').element as HTMLInputElement).value).toBe('Grafana') + }) + + it('loads existing guidance into the edit form and marks the row as guided', async () => { + const wrapper = mountIt([CONNECTOR]) + await flushPromises() + + expect(wrapper.find('.guided-tag').exists()).toBe(true) + + await wrapper.find('.row-btn').trigger('click') + expect((wrapper.find('.guidance-input').element as HTMLTextAreaElement).value).toBe( + 'Order id is fields.order_ref.', + ) + }) + + it('shows no guided chip for an undocumented connector', async () => { + const wrapper = mountIt([{ ...CONNECTOR, usage_guidance: null }]) + await flushPromises() + + expect(wrapper.find('.guided-tag').exists()).toBe(false) + }) +}) diff --git a/frontend/src/__tests__/components/tickets/TicketInvestigationPanel.spec.ts b/frontend/src/__tests__/components/tickets/TicketInvestigationPanel.spec.ts new file mode 100644 index 0000000..a1c3c12 --- /dev/null +++ b/frontend/src/__tests__/components/tickets/TicketInvestigationPanel.spec.ts @@ -0,0 +1,214 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' + +import TicketInvestigationPanel from '../../../components/tickets/TicketInvestigationPanel.vue' +import type { InvestigationDetail, InvestigationRun } from '../../../types/ticket' + +const baseRun: InvestigationRun = { + id: 'r1', + run_type: 'investigation', + status: 'completed', + trigger: 'manual', + tool_calls_used: 0, +} + +const mountPanel = (run: Partial<InvestigationRun>, props: Record<string, unknown> = {}) => + mount(TicketInvestigationPanel, { + props: { + investigation: { + run: { ...baseRun, ...run }, + hypotheses: [], + events: [], + } as InvestigationDetail, + ...props, + }, + global: { + stubs: { + 'font-awesome-icon': true, + 'router-link': { template: '<a><slot /></a>' }, + }, + }, + }) + +describe('TicketInvestigationPanel connector warning', () => { + it('warns when the run loaded fewer connectors than configured', () => { + const wrapper = mountPanel({ + connector_status: { + configured: 1, + loaded: 0, + failed: [{ name: 'Elasticsearch', error: "[Errno 2] No such file or directory: 'npx'" }], + }, + }) + const warning = wrapper.find('.connector-warning') + expect(warning.exists()).toBe(true) + expect(warning.text()).toContain('0 / 1 个已配置连接器') + expect(warning.text()).toContain('Elasticsearch') + expect(warning.text()).toContain('npx') + }) + + it('stays silent when every configured connector loaded', () => { + const wrapper = mountPanel({ + connector_status: { configured: 2, loaded: 2, failed: [] }, + }) + expect(wrapper.find('.connector-warning').exists()).toBe(false) + }) + + it('warns when every connector loaded but the provider refused their tools', () => { + // The #303 case: nothing "failed", so the loaded-vs-configured check alone + // reports a clean run that gathered no evidence at all. + const wrapper = mountPanel({ + connector_status: { + configured: 1, + loaded: 1, + failed: [], + provider_errors: ["The model provider rejected a connected tool's schema."], + }, + }) + const warning = wrapper.find('.connector-warning') + expect(warning.exists()).toBe(true) + expect(warning.text()).toContain("无法使用") + expect(warning.text()).toContain('rejected a connected tool') + }) + + it('stays silent when the run has no connector status (older runs)', () => { + const wrapper = mountPanel({}) + expect(wrapper.find('.connector-warning').exists()).toBe(false) + }) +}) + +describe('TicketInvestigationPanel errored tool calls', () => { + /** + * A connector can come up, load its tools, and then error on every query. + * That reports loaded === configured with no provider errors, so before + * #318 the run rendered completely clean with a confident wrong answer. + */ + it('warns when every tool call errored, even with all connectors loaded', () => { + const wrapper = mountPanel({ + connector_status: { + configured: 1, + loaded: 1, + failed: [], + tool_calls: 3, + tool_calls_failed: 3, + }, + }) + + const text = wrapper.find('.connector-warning').text() + expect(text).toContain('3 / 3 次工具调用出错') + expect(text).toContain('此处结论均无证据支撑') + }) + + it('reports a partial failure without claiming nothing was gathered', () => { + const wrapper = mountPanel({ + connector_status: { + configured: 1, + loaded: 1, + failed: [], + tool_calls: 4, + tool_calls_failed: 1, + }, + }) + + const text = wrapper.find('.connector-warning').text() + expect(text).toContain('1 / 4 次工具调用出错') + expect(text).not.toContain('evidence-backed') + }) + + it('stays silent on a clean run', () => { + const wrapper = mountPanel({ + connector_status: { + configured: 2, + loaded: 2, + failed: [], + tool_calls: 5, + tool_calls_failed: 0, + }, + }) + + expect(wrapper.find('.connector-warning').exists()).toBe(false) + }) + + it('handles a run recorded before these counts existed', () => { + const wrapper = mountPanel({ + connector_status: { configured: 1, loaded: 1, failed: [] }, + }) + + expect(wrapper.find('.connector-warning').exists()).toBe(false) + }) +}) + +describe('TicketInvestigationPanel guided re-run', () => { + /** + * Below autonomy 2 no proposal is created, so the L2 approval banner never + * renders — and it held the only reject-with-feedback path. An L1 operator + * had no way to correct a run short of editing the ticket description. + */ + it('offers a guided re-run when the user can start one', () => { + const wrapper = mountPanel({}, { canReinvestigate: true }) + expect(wrapper.find('.rerun-btn').exists()).toBe(true) + }) + + it('is absent for a user who cannot manage tickets', () => { + expect(mountPanel({}, { canReinvestigate: false }).find('.panel-foot').exists()).toBe(false) + expect(mountPanel({}).find('.panel-foot').exists()).toBe(false) + }) + + it('disables the button with the reason a run is unavailable', () => { + const wrapper = mountPanel({}, { + canReinvestigate: true, + reinvestigateBlockedReason: 'This ticket is resolved — reopen it to investigate again', + }) + + const button = wrapper.find('.rerun-btn') + expect(button.attributes('disabled')).toBeDefined() + expect(button.attributes('title')).toContain('reopen it') + }) + + it('emits the trimmed note and closes the form', async () => { + const wrapper = mountPanel({}, { canReinvestigate: true }) + await wrapper.find('.rerun-btn').trigger('click') + + await wrapper.find('.note-input').setValue(' Use fields.order_ref, not order_id. ') + await wrapper.find('.submit-btn').trigger('click') + + expect(wrapper.emitted('reinvestigate')?.[0]).toEqual(['Use fields.order_ref, not order_id.']) + // Back to the button, so a second run needs a deliberate click. + expect(wrapper.find('.note-input').exists()).toBe(false) + }) + + it('lets the operator back out without starting a run', async () => { + const wrapper = mountPanel({}, { canReinvestigate: true }) + await wrapper.find('.rerun-btn').trigger('click') + await wrapper.find('.cancel-btn').trigger('click') + + expect(wrapper.emitted('reinvestigate')).toBeUndefined() + expect(wrapper.find('.rerun-btn').exists()).toBe(true) + }) + + it('links to settings only for someone who can edit them', async () => { + const allowed = mountPanel({}, { canReinvestigate: true, canEditSettings: true }) + await allowed.find('.rerun-btn').trigger('click') + expect(allowed.find('.rerun-scope a').exists()).toBe(true) + + const denied = mountPanel({}, { canReinvestigate: true, canEditSettings: false }) + await denied.find('.rerun-btn').trigger('click') + expect(denied.find('.rerun-scope a').exists()).toBe(false) + expect(denied.find('.rerun-scope').text()).toContain('Ticketing settings') + }) +}) diff --git a/frontend/src/__tests__/components/tickets/TicketSidePanel.spec.ts b/frontend/src/__tests__/components/tickets/TicketSidePanel.spec.ts new file mode 100644 index 0000000..7ae12c8 --- /dev/null +++ b/frontend/src/__tests__/components/tickets/TicketSidePanel.spec.ts @@ -0,0 +1,95 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' + +vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) +vi.mock('@/composables/useUsers', () => ({ + useUsers: () => ({ users: { value: [] }, fetchUsers: vi.fn().mockResolvedValue([]) }), +})) + +import TicketSidePanel from '../../../components/tickets/TicketSidePanel.vue' +import type { Ticket } from '../../../types/ticket' + +const baseTicket = { + id: 't1', + ticket_number: 12, + display_number: 'TKT-12', + organization_id: 'org1', + title: 'Refund never arrived', + status: 'closed', + priority: 'medium', + source: 'chat_ai', + reopened_count: 0, +} as Ticket + +const createWrapper = (ticket: Partial<Ticket> = {}, linkedSessionIds: string[] = ['s1']) => + mount(TicketSidePanel, { + props: { + ticket: { ...baseTicket, ...ticket } as Ticket, + linkedSessionIds, + canManage: false, + }, + global: { stubs: { 'font-awesome-icon': true } }, + }) + +const csatCard = (wrapper: ReturnType<typeof createWrapper>) => + wrapper.findAll('.panel-card').find((card) => card.text().startsWith('CSAT'))! + +describe('TicketSidePanel CSAT card', () => { + it('shows the score once the customer has rated', () => { + const card = csatCard( + createWrapper({ + csat_requested_at: '2026-07-20T10:00:00Z', + csat_responded_at: '2026-07-20T11:00:00Z', + csat_score: 4, + }), + ) + expect(card.text()).toContain('4/5') + expect(card.findAll('.csat-stars font-awesome-icon-stub')).toHaveLength(5) + expect(card.text()).toContain('Rated by the customer') + }) + + it('shows pending while the ask is out', () => { + const card = csatCard(createWrapper({ csat_requested_at: '2026-07-20T10:00:00Z' })) + expect(card.text()).toContain('待评价') + expect(card.find('.csat-stars').exists()).toBe(false) + }) + + it('goes back to pending when a reopened ticket is asked again', () => { + const card = csatCard( + createWrapper({ + csat_score: 2, + csat_responded_at: '2026-07-20T11:00:00Z', + csat_requested_at: '2026-07-21T09:00:00Z', + }), + ) + expect(card.text()).toContain('待评价') + expect(card.text()).not.toContain('2/5') + }) + + it('shows not requested before close', () => { + const card = csatCard(createWrapper()) + expect(card.text()).toContain('未邀请评价') + expect(card.text()).toContain('工单关闭时会自动邀请评价') + }) + + it('explains why a ticket with no conversation is never asked', () => { + const card = csatCard(createWrapper({}, [])) + expect(card.text()).toContain('没有可评价的关联会话') + }) +}) diff --git a/frontend/src/__tests__/utils/channelAccounts.spec.ts b/frontend/src/__tests__/utils/channelAccounts.spec.ts new file mode 100644 index 0000000..9852f63 --- /dev/null +++ b/frontend/src/__tests__/utils/channelAccounts.spec.ts @@ -0,0 +1,72 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { describe, it, expect } from 'vitest' +import { accountToManage, reconnectWarning } from '@/utils/channelAccounts' +import type { ChannelAccount } from '@/services/channels' + +const account = (overrides: Partial<ChannelAccount> = {}): ChannelAccount => ({ + id: 'a1', channel_type: 'whatsapp', external_account_id: 'PN1', + display_name: 'Support (+1 555)', is_active: true, ...overrides, +}) + +describe('reconnectWarning', () => { + it('is silent when no account needs reconnecting', () => { + expect(reconnectWarning([])).toBeUndefined() + expect(reconnectWarning([account(), account({ needs_reconnect: false })])).toBeUndefined() + }) + + it('names the account that needs reconnecting', () => { + expect(reconnectWarning([account({ needs_reconnect: true })])) + .toBe('以下账号的访问权限已过期:Support (+1 555),请重新连接以恢复回复。') + }) + + it('names only the expired ones when several are connected', () => { + const warning = reconnectWarning([ + account({ id: 'a1', display_name: 'Sales', needs_reconnect: true }), + account({ id: 'a2', display_name: 'Healthy' }), + account({ id: 'a3', display_name: 'Billing', needs_reconnect: true }), + ]) + expect(warning).toBe('以下账号的访问权限已过期:Sales, Billing,请重新连接以恢复回复。') + }) + + it('falls back to the platform id when the account has no name', () => { + expect(reconnectWarning([account({ display_name: undefined, needs_reconnect: true })])) + .toContain('以下账号的访问权限已过期:PN1') + }) +}) + +describe('accountToManage', () => { + it('opens the account that needs reconnecting, even when it is not first', () => { + const healthy = account({ id: 'a1' }) + const expired = account({ id: 'a2', needs_reconnect: true }) + expect(accountToManage([healthy, expired])).toBe(expired) + }) + + it('opens the first expired one when several need reconnecting', () => { + const first = account({ id: 'a2', needs_reconnect: true }) + expect(accountToManage([account({ id: 'a1' }), first, account({ id: 'a3', needs_reconnect: true })])).toBe(first) + }) + + it('opens the first account when every one is healthy', () => { + const first = account({ id: 'a1' }) + expect(accountToManage([first, account({ id: 'a2' })])).toBe(first) + }) + + it('opens nothing when no account is connected', () => { + expect(accountToManage([])).toBeNull() + }) +}) diff --git a/frontend/src/__tests__/utils/metaSdk.spec.ts b/frontend/src/__tests__/utils/metaSdk.spec.ts new file mode 100644 index 0000000..094c22b --- /dev/null +++ b/frontend/src/__tests__/utils/metaSdk.spec.ts @@ -0,0 +1,144 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { describe, it, expect, vi, afterEach } from 'vitest' +import { + parseSignupMessage, + signupLoginOptions, + runInstagramLogin, + runBusinessLogin, +} from '../../utils/metaSdk' + +const message = (data: unknown, origin = 'https://www.facebook.com') => + ({ origin, data: typeof data === 'string' ? data : JSON.stringify(data) }) as MessageEvent + +const FINISH = { + type: 'WA_EMBEDDED_SIGNUP', + event: 'FINISH', + data: { waba_id: 'WABA9', phone_number_id: 'PN555' }, +} + +describe('parseSignupMessage', () => { + it('reads the WABA and phone number from a finished signup', () => { + expect(parseSignupMessage(message(FINISH))).toEqual({ + waba_id: 'WABA9', + phone_number_id: 'PN555', + }) + }) + + it('accepts the other Facebook origins the flow can post from', () => { + for (const origin of ['https://web.facebook.com', 'https://business.facebook.com']) { + expect(parseSignupMessage(message(FINISH, origin))).not.toBeNull() + } + }) + + it('rejects a lookalike origin', () => { + // Meta's own sample uses origin.endsWith('facebook.com'), which these pass. + for (const origin of ['https://evil-facebook.com', 'https://facebook.com.attacker.io']) { + expect(parseSignupMessage(message(FINISH, origin))).toBeNull() + } + }) + + it('rejects a plain http facebook origin', () => { + expect(parseSignupMessage(message(FINISH, 'http://www.facebook.com'))).toBeNull() + }) + + it('ignores unrelated messages on a trusted origin', () => { + expect(parseSignupMessage(message({ type: 'SOMETHING_ELSE' }))).toBeNull() + expect(parseSignupMessage(message('not json at all'))).toBeNull() + }) + + it('ignores a signup that was abandoned rather than finished', () => { + expect(parseSignupMessage(message({ ...FINISH, event: 'CANCEL' }))).toBeNull() + }) + + it('ignores a finish missing either id', () => { + expect(parseSignupMessage(message({ ...FINISH, data: { waba_id: 'WABA9' } }))).toBeNull() + expect(parseSignupMessage(message({ ...FINISH, data: {} }))).toBeNull() + }) +}) + +describe('signupLoginOptions', () => { + it('asks for a code rather than the default token response', () => { + expect(signupLoginOptions('CFG1')).toEqual({ + config_id: 'CFG1', + response_type: 'code', + override_default_response_type: true, + extras: { setup: {} }, + }) + }) + + it('sends the WhatsApp setup extra for WhatsApp', () => { + expect(signupLoginOptions('CFG1', 'whatsapp').extras).toEqual({ setup: {} }) + }) + + it('omits the setup extra for Facebook Login for Business', () => { + // extras.setup opens Embedded Signup; Messenger/Instagram must not send it. + expect(signupLoginOptions('CFG1', 'messenger')).not.toHaveProperty('extras') + expect(signupLoginOptions('CFG1', 'instagram')).not.toHaveProperty('extras') + }) +}) + +describe('OAuth login popups', () => { + afterEach(() => vi.unstubAllGlobals()) + + /** Capture the URL the popup is opened with, without completing the login. */ + const capturePopupUrl = (run: () => Promise<unknown>) => { + let opened = '' + vi.stubGlobal('open', (url: string) => { + opened = url + return { closed: false, close: () => {} } + }) + // The promise stays pending (no callback message); we only want the URL. + void run().catch(() => {}) + return new URL(opened) + } + + it('sends Instagram to its own authorize endpoint, not a Facebook dialog', () => { + const url = capturePopupUrl(() => + runInstagramLogin({ appId: 'IGAPP', redirectUri: 'https://app.test/cb.html' })) + + expect(url.origin + url.pathname).toBe('https://www.instagram.com/oauth/authorize') + expect(url.searchParams.get('client_id')).toBe('IGAPP') + expect(url.searchParams.get('response_type')).toBe('code') + expect(url.searchParams.get('redirect_uri')).toBe('https://app.test/cb.html') + expect(url.searchParams.get('force_reauth')).toBe('true') + expect(url.searchParams.get('scope')).toContain('instagram_business_manage_messages') + // Least privilege: we only read the account and handle DMs, so no + // comments/publishing/insights scopes to enlarge App Review. + expect(url.searchParams.get('scope')).not.toContain('content_publish') + expect(url.searchParams.get('state')).toBeTruthy() + }) + + it('sends Messenger to the Facebook dialog with its configuration', () => { + const url = capturePopupUrl(() => + runBusinessLogin({ + appId: 'FBAPP', configId: 'CFG1', graphVersion: 'v21.0', + redirectUri: 'https://app.test/cb.html', + })) + + expect(url.hostname).toBe('www.facebook.com') + expect(url.searchParams.get('config_id')).toBe('CFG1') + expect(url.searchParams.get('client_id')).toBe('FBAPP') + }) + + it('rejects when the browser blocks the popup', async () => { + vi.stubGlobal('open', () => null) + await expect( + runInstagramLogin({ appId: 'IGAPP', redirectUri: 'https://app.test/cb.html' }), + ).rejects.toThrow(/弹窗被拦截/) + }) +}) diff --git a/frontend/src/components/faq/FaqCard.vue b/frontend/src/components/faq/FaqCard.vue new file mode 100644 index 0000000..98c6044 --- /dev/null +++ b/frontend/src/components/faq/FaqCard.vue @@ -0,0 +1,528 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import type { FaqItem } from '@/types/faq' +import FaqSeoFields from './FaqSeoFields.vue' +import MarkdownEditor from './MarkdownEditor.vue' + +const props = defineProps<{ + faq: FaqItem + editing: boolean + isNew?: boolean + saving?: boolean + draftQuestion: string + draftAnswer: string + /** Per-article SEO drafts. Optional so callers that don't expose the SEO + * section (and tests) can leave them out — they default to empty. */ + draftSlug?: string + draftUrlPath?: string + draftMetaTitle?: string + draftMetaDescription?: string + /** Topic (category) draft plus the org's existing topics for autocomplete. + * Optional for the same reason as the SEO drafts. */ + draftCategory?: string + categories?: string[] + locked?: boolean + /** Selection mode is on somewhere in the list (keeps checkboxes visible). */ + selectable?: boolean + selected?: boolean +}>() + +const emit = defineEmits<{ + 'toggle-status': [] + edit: [] + delete: [] + save: [] + cancel: [] + 'toggle-select': [] + 'update:draftQuestion': [value: string] + 'update:draftAnswer': [value: string] + 'update:draftCategory': [value: string] + 'update:draftSlug': [value: string] + 'update:draftUrlPath': [value: string] + 'update:draftMetaTitle': [value: string] + 'update:draftMetaDescription': [value: string] +}>() + +function onQuestionInput(event: Event) { + emit('update:draftQuestion', (event.target as HTMLInputElement).value) +} + +function onCategoryInput(event: Event) { + emit('update:draftCategory', (event.target as HTMLInputElement).value) +} + +const sourceLabel = () => props.faq.source_label || '自动生成' + +// Mirrors the category column width in app/models/faq.py — the API 422s +// anything longer, so this only stops the user typing past the limit. +const MAX_TOPIC = 100 + +// Answers are stored as Markdown; strip the syntax and cap the length for the +// compact, line-clamped card preview (the public article page and the editor +// show the full formatted content). +const PREVIEW_MAX_CHARS = 240 +function answerPreview(md: string): string { + const text = (md || '') + .replace(/!\[[^\]]*\]\([^)]*\)/g, '') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/[#>*_`~]/g, '') + .replace(/\s+/g, ' ') + .trim() + return text.length > PREVIEW_MAX_CHARS ? `${text.slice(0, PREVIEW_MAX_CHARS).trimEnd()}…` : text +} +</script> + +<template> + <div class="faq-card" :class="{ 'faq-card--editing': editing, 'faq-card--selecting': selectable, 'faq-card--selected': selected }"> + <!-- Edit mode --> + <div v-if="editing"> + <div class="edit-label">{{ isNew ? '新建常见问题' : '编辑中' }}</div> + <input + class="edit-question" + type="text" + placeholder="问题" + :value="draftQuestion" + @input="onQuestionInput" + /> + <MarkdownEditor + class="edit-answer" + :model-value="draftAnswer" + @update:model-value="$emit('update:draftAnswer', $event)" + /> + <p class="edit-hint">支持 Markdown——标题、<strong>加粗</strong>、列表、链接和图片。</p> + <label class="edit-topic"> + <span class="edit-topic__label">分类</span> + <input + type="text" + class="edit-topic__input" + list="faq-topic-options" + :maxlength="MAX_TOPIC" + :placeholder="isNew ? 'General' : '留空则保留当前分类'" + :value="draftCategory" + @input="onCategoryInput" + /> + <datalist id="faq-topic-options"> + <option v-for="c in categories" :key="c" :value="c" /> + </datalist> + <span class="edit-topic__hint">选择已有分类,或输入新分类以创建。</span> + </label> + <FaqSeoFields + :slug="draftSlug" + :url-path="draftUrlPath" + :meta-title="draftMetaTitle" + :meta-description="draftMetaDescription" + :question="draftQuestion" + @update:slug="$emit('update:draftSlug', $event)" + @update:url-path="$emit('update:draftUrlPath', $event)" + @update:meta-title="$emit('update:draftMetaTitle', $event)" + @update:meta-description="$emit('update:draftMetaDescription', $event)" + /> + <div class="edit-actions"> + <button class="btn-save" type="button" :disabled="saving" @click="$emit('save')"> + {{ saving ? '保存中…' : '保存' }} + </button> + <button class="btn-cancel" type="button" :disabled="saving" @click="$emit('cancel')">取消</button> + </div> + </div> + + <!-- Display mode --> + <div v-else class="faq-card__row"> + <input + v-if="!locked" + class="faq-card__check" + type="checkbox" + :checked="selected" + :aria-label="`选择 ${faq.question}`" + @change="$emit('toggle-select')" + /> + <div class="faq-card__body"> + <div class="faq-card__question">{{ faq.question }}</div> + <div class="faq-card__answer">{{ answerPreview(faq.answer) }}</div> + <div class="faq-card__source"> + <span class="faq-card__source-dot"></span> + {{ sourceLabel() }} + <span + v-if="(faq.helpful_yes || 0) + (faq.helpful_no || 0) > 0" + class="faq-card__fb" + :title="`${faq.helpful_yes || 0} 人觉得有帮助,${faq.helpful_no || 0} 人觉得没帮助`" + > + <span class="faq-card__fb-item faq-card__fb-item--up"> + <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M7 11v9H4a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1z" /><path d="M7 11l4-8a2 2 0 0 1 3 1.8V9h4.5a2 2 0 0 1 2 2.4l-1.4 7A2 2 0 0 1 17 20H7" /></svg> + {{ faq.helpful_yes || 0 }} + </span> + <span class="faq-card__fb-item faq-card__fb-item--down"> + <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M17 13V4h3a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1z" /><path d="M17 13l-4 8a2 2 0 0 1-3-1.8V15H5.5a2 2 0 0 1-2-2.4l1.4-7A2 2 0 0 1 7 4h10" /></svg> + {{ faq.helpful_no || 0 }} + </span> + </span> + </div> + </div> + <div class="faq-card__controls"> + <button + class="pill" + :class="faq.status === 'published' ? 'pill--published' : 'pill--draft'" + type="button" + :disabled="locked" + @click="$emit('toggle-status')" + > + <span class="pill__dot"></span> + {{ faq.status === 'published' ? '已发布' : '草稿' }} + </button> + <button v-if="!locked" class="icon-btn icon-btn--edit" type="button" title="编辑" @click="$emit('edit')"> + <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z" /></svg> + </button> + <button v-if="!locked" class="icon-btn icon-btn--delete" type="button" title="删除" @click="$emit('delete')"> + <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16" /><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" /><path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" /></svg> + </button> + </div> + </div> + </div> +</template> + +<style scoped> +.faq-card { + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 14px; + padding: 16px 18px; +} + +.faq-card--editing { + border-color: var(--purple-border); +} + +.faq-card--selected { + border-color: var(--teal-border); + background: var(--teal-bg); +} + +.faq-card__check { + width: 16px; + height: 16px; + margin: 3px 0 0; + flex-shrink: 0; + accent-color: var(--c-teal); + cursor: pointer; + opacity: 0; + transition: opacity var(--transition-fast); +} + +.faq-card:hover .faq-card__check, +.faq-card--selecting .faq-card__check, +.faq-card__check:checked, +.faq-card__check:focus-visible { + opacity: 1; +} + +.faq-card__row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.faq-card__body { + flex: 1; + min-width: 0; +} + +.faq-card__question { + font-size: 15px; + font-weight: 600; + color: var(--text2); + line-height: 1.4; + margin-bottom: 6px; +} + +.faq-card__answer { + font-size: 13.5px; + color: var(--muted); + line-height: 1.55; + /* Keep every card compact and uniform — imported articles can be very long; + full content is on the public page and in the editor. */ + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.faq-card__source { + display: inline-flex; + align-items: center; + gap: 7px; + margin-top: 11px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--faint); +} + +.faq-card__source-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--faint); +} + +.faq-card__fb { + display: inline-flex; + align-items: center; + gap: 9px; + padding-left: 9px; + margin-left: 2px; + border-left: 1px solid var(--o10); +} + +.faq-card__fb-item { + display: inline-flex; + align-items: center; + gap: 3px; +} + +.faq-card__fb-item--up { + color: var(--c-teal, #1d9e75); +} + +.faq-card__fb-item--down { + color: var(--c-coral, #d85a30); +} + +.faq-card__controls { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.pill { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 7px 13px; + border-radius: 9px; + font-family: var(--font-sans); + font-size: 12.5px; + font-weight: 600; + cursor: pointer; +} + +.pill:disabled { + cursor: default; +} + +.pill__dot { + width: 6px; + height: 6px; + border-radius: 50%; +} + +.pill--published { + background: var(--teal-bg); + border: 1px solid var(--teal-border); + color: var(--c-teal); +} + +.pill--published .pill__dot { + background: var(--c-teal); +} + +.pill--draft { + background: var(--pill-idle-bg); + border: 1px solid var(--o12); + color: var(--pill-idle-fg); +} + +.pill--draft .pill__dot { + background: var(--faint); +} + +.icon-btn { + width: 32px; + height: 32px; + border-radius: 9px; + background: var(--o05); + border: 1px solid var(--o12); + color: var(--muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background-color var(--transition-fast), color var(--transition-fast); +} + +.icon-btn--edit:hover { + background: var(--o08); + color: var(--text2); +} + +.icon-btn--delete:hover { + background: var(--coral-bg); + color: var(--c-coral); + border-color: var(--coral-border); +} + +.edit-label { + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: 0.08em; + color: var(--c-purple); + margin-bottom: 10px; +} + +.edit-question { + width: 100%; + background: var(--bg); + border: 1px solid var(--o12); + border-radius: 10px; + padding: 11px 13px; + color: var(--text); + outline: none; + box-sizing: border-box; + font-size: 14.5px; + font-weight: 600; + margin-bottom: 9px; +} + +.edit-topic { + display: block; + margin-top: 12px; +} + +.edit-topic__label { + display: block; + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted2); + margin-bottom: 6px; +} + +.edit-topic__input { + width: 100%; + max-width: 320px; + display: block; + background: var(--bg); + border: 1px solid var(--o12); + border-radius: 10px; + padding: 9px 12px; + color: var(--text); + outline: none; + box-sizing: border-box; + font-family: var(--font-sans); + font-size: 13.5px; +} + +.edit-topic__hint { + display: block; + margin-top: 6px; + font-size: 11.5px; + color: var(--muted2); +} + +/* .edit-answer wraps the MarkdownEditor, which supplies its own chrome. */ +.edit-hint { + margin: 8px 0 0; + font-size: 11.5px; + color: var(--muted2); +} + +.edit-hint strong { + font-weight: 600; +} + +.edit-actions { + display: flex; + align-items: center; + gap: 10px; + margin-top: 12px; +} + +.btn-save { + padding: 9px 18px; + background: var(--accent-solid); + border: none; + border-radius: 9px; + color: var(--on-accent-solid); + font-family: var(--font-sans); + font-size: 13.5px; + font-weight: 600; + cursor: pointer; +} + +.btn-save:disabled { + opacity: 0.6; + cursor: default; +} + +.btn-cancel { + padding: 9px 16px; + background: transparent; + border: 1px solid var(--o12); + border-radius: 9px; + color: var(--muted); + font-family: var(--font-sans); + font-size: 13.5px; + font-weight: 500; + cursor: pointer; +} + +/* ── Mobile ────────────────────────────────────────────────────────────── + The status pill plus two icon buttons took ~200px of a 375px row, leaving + the question a column so narrow it wrapped a word per line. Below 768px + the controls drop to their own line and the question gets the full width. */ +@media (max-width: 768px) { + .faq-card__row { + flex-wrap: wrap; + gap: 10px; + } + + .faq-card__body { + flex: 1 1 auto; + } + + .faq-card__controls { + flex: 1 0 100%; + justify-content: flex-end; + } + + .faq-card__question { + font-size: 15px; + } + + /* 44px minimum touch target (Apple HIG / WCAG 2.5.8) */ + .icon-btn { + width: 44px; + height: 44px; + } + + .pill { + min-height: 44px; + } + + .edit-actions { + flex-wrap: wrap; + } + + .btn-save, + .btn-cancel { + flex: 1 1 auto; + min-height: 44px; + } +} +</style> diff --git a/frontend/src/components/faq/FaqCategoryGroup.vue b/frontend/src/components/faq/FaqCategoryGroup.vue new file mode 100644 index 0000000..1b34649 --- /dev/null +++ b/frontend/src/components/faq/FaqCategoryGroup.vue @@ -0,0 +1,186 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +const props = defineProps<{ + category: string + count: number + /** Published FAQs in this group (for the header stat). */ + publishedCount?: number + /** Cards in this group currently selected; undefined hides the select-all. */ + selectedCount?: number + /** Whether the group body is expanded. */ + open?: boolean +}>() + +defineEmits<{ + 'toggle-all': [on: boolean] + toggle: [] +}>() + +const allSelected = () => props.selectedCount === props.count && props.count > 0 +</script> + +<template> + <div class="category-group"> + <div class="category-group__header"> + <input + v-if="selectedCount !== undefined" + class="category-group__check" + type="checkbox" + :checked="allSelected()" + :indeterminate.prop="!!selectedCount && !allSelected()" + :aria-label="`全选 ${category}`" + @click.stop + @change="$emit('toggle-all', !allSelected())" + /> + <button + class="category-group__toggle" + type="button" + :aria-expanded="open" + @click="$emit('toggle')" + > + <svg + class="category-group__chevron" + :class="{ 'category-group__chevron--open': open }" + viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" + stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" + ><path d="M9 6l6 6-6 6" /></svg> + <span class="category-group__name">{{ category.toUpperCase() }}</span> + <span class="category-group__count"> + {{ count }} 条回答<template v-if="publishedCount !== undefined"> · {{ publishedCount }} 条已发布</template> + </span> + </button> + <div class="category-group__rule"></div> + </div> + <div v-show="open" class="category-group__cards"> + <slot /> + </div> + </div> +</template> + +<style scoped> +.category-group { + margin-bottom: 18px; +} + +.category-group__header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; + padding: 0 2px; +} + +.category-group__check { + width: 14px; + height: 14px; + margin: 0; + accent-color: var(--c-teal); + cursor: pointer; + opacity: 0.55; + transition: opacity var(--transition-fast); +} + +.category-group__header:hover .category-group__check, +.category-group__check:checked, +.category-group__check:indeterminate { + opacity: 1; +} + +.category-group__toggle { + display: inline-flex; + align-items: center; + gap: 8px; + background: transparent; + border: none; + padding: 2px 0; + cursor: pointer; + color: inherit; +} + +.category-group__chevron { + color: var(--muted2); + flex-shrink: 0; + transition: transform var(--transition-fast); +} + +.category-group__chevron--open { + transform: rotate(90deg); +} + +.category-group__name { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.08em; + color: var(--accent-ink); +} + +.category-group__count { + font-family: var(--font-mono); + font-size: 11px; + color: var(--muted2); +} + +.category-group__rule { + flex: 1; + height: 1px; + background: var(--o07); +} + +.category-group__cards { + display: flex; + flex-direction: column; + gap: 10px; +} + +/* ── Mobile ───────────────────────────────────────────────────────────── + The decorative rule took flex:1 of the header, squeezing the toggle until + the name broke mid-phrase ("ACCOUNT &" / "SECURITY"). + + Grid rather than flex-wrap: the chevron owns column 1 and the text owns + column 2, so the count sits under the name and the chevron can never be + pushed onto a line of its own however narrow the screen or long the + category name. */ +@media (max-width: 768px) { + .category-group__rule { + display: none; + } + + .category-group__toggle { + flex: 1; + min-width: 0; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + column-gap: 8px; + row-gap: 2px; + text-align: left; + /* 44px minimum touch target (Apple HIG / WCAG 2.5.8) */ + min-height: 44px; + } + + .category-group__chevron { + grid-row: 1 / span 2; + } + + .category-group__name, + .category-group__count { + grid-column: 2; + min-width: 0; + } +} +</style> diff --git a/frontend/src/components/faq/FaqEmptyState.vue b/frontend/src/components/faq/FaqEmptyState.vue new file mode 100644 index 0000000..b1916ef --- /dev/null +++ b/frontend/src/components/faq/FaqEmptyState.vue @@ -0,0 +1,159 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import FaqOrb from './FaqOrb.vue' + +defineProps<{ + sourceCount: number + pageCount: number +}>() + +defineEmits<{ + generate: [] + import: [] + add: [] +}>() +</script> + +<template> + <div class="empty-state"> + <FaqOrb class="empty-state__orb" :size="64" /> + <h3 class="empty-state__title">还没有常见问题</h3> + <p class="empty-state__copy"> + 让 ChatterMate 阅读你的知识库,起草清晰、可直接发布的常见问题——按分类整理,并随文档更新保持同步。 + </p> + <div class="empty-state__pill"> + <span class="empty-state__dot"></span> + 读取 {{ sourceCount }} 个来源 · {{ pageCount }} 个页面 + </div> + <div class="empty-state__actions"> + <button class="btn btn--primary" type="button" @click="$emit('generate')"> + <svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3v4M3 5h4M6 17v4M4 19h4M13 3l2.5 6.5L22 12l-6.5 2.5L13 21l-2.5-6.5L4 12l6.5-2.5z" /></svg> + 从知识库生成 + </button> + <button class="btn btn--ghost" type="button" @click="$emit('import')">导入已有常见问题</button> + <button class="btn btn--text" type="button" @click="$emit('add')">手动添加</button> + </div> + </div> +</template> + +<style scoped> +.empty-state { + background: var(--surface); + border: 1px dashed var(--o14); + border-radius: 16px; + padding: 56px 32px; + text-align: center; +} + +.empty-state__orb { + margin: 0 auto 22px; +} + +.empty-state__title { + font-family: var(--font-display); + font-weight: 700; + font-size: 22px; + letter-spacing: -0.01em; + color: var(--text); + margin: 0 0 10px; +} + +.empty-state__copy { + font-size: 14.5px; + color: var(--muted); + line-height: 1.6; + margin: 0 auto 26px; + max-width: 440px; +} + +.empty-state__pill { + display: inline-flex; + align-items: center; + gap: 9px; + padding: 8px 14px; + border-radius: var(--radius-pill); + background: var(--o04); + border: 1px solid var(--o10); + font-family: var(--font-mono); + font-size: 12px; + color: var(--text3); + margin-bottom: 26px; +} + +.empty-state__dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--c-teal); + box-shadow: 0 0 6px var(--c-teal); +} + +.empty-state__actions { + display: flex; + gap: 11px; + justify-content: center; + flex-wrap: wrap; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 9px; + border-radius: 12px; + font-family: var(--font-sans); + font-size: 15px; + cursor: pointer; + transition: filter var(--transition-fast), background-color var(--transition-fast); +} + +.btn--primary { + padding: 14px 24px; + background: var(--grad-generate); + border: none; + color: var(--on-dark); + font-weight: 600; +} + +.btn--primary:hover { + filter: brightness(1.1); +} + +.btn--ghost { + padding: 14px 20px; + background: var(--o05); + border: 1px solid var(--o14); + color: var(--text2); + font-weight: 600; +} + +.btn--ghost:hover { + background: var(--o08); +} + +.btn--text { + padding: 14px 20px; + background: transparent; + border: 1px solid var(--o12); + color: var(--muted); + font-weight: 500; +} + +.btn--text:hover { + background: var(--o04); +} +</style> diff --git a/frontend/src/components/faq/FaqGenerateBar.vue b/frontend/src/components/faq/FaqGenerateBar.vue new file mode 100644 index 0000000..8c0744f --- /dev/null +++ b/frontend/src/components/faq/FaqGenerateBar.vue @@ -0,0 +1,210 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed } from 'vue' +import FaqOrb from './FaqOrb.vue' + +const props = defineProps<{ + phase: 'idle' | 'generating' | 'ready' + sourceCount: number + pageCount: number + faqCount: number + publishedCount: number + /** Sources without FAQs yet (from the estimate); null = unknown. */ + newSourceCount?: number | null + disabled?: boolean +}>() + +defineEmits<{ + generate: [] + import: [] + add: [] +}>() + +const title = computed(() => { + if (props.phase === 'generating') return '正在生成常见问题…' + if (props.phase === 'ready') return '常见问题待发布' + return '生成常见问题' +}) + +const subtitle = computed(() => + props.phase === 'ready' + ? `${props.faqCount} 条常见问题 · ${props.publishedCount} 条已发布` + : `读取 ${props.sourceCount} 个来源 · ${props.pageCount} 个页面`, +) + +const generateLabel = computed(() => { + if (props.phase === 'generating') return '生成中…' + if (props.phase === 'ready') { + // Regenerate only reads new (ungenerated) sources — say so. + if (typeof props.newSourceCount === 'number' && props.newSourceCount > 0) { + return `生成 ${props.newSourceCount} 个新来源` + } + return '重新生成' + } + return '生成' +}) + +const noNewSources = computed(() => props.phase === 'ready' && props.newSourceCount === 0) + +const generateDisabled = computed(() => props.phase === 'generating' || props.disabled || noNewSources.value) + +const generateTitle = computed(() => + noNewSources.value ? '所有知识来源都已生成常见问题' : undefined, +) +</script> + +<template> + <div class="generate-bar"> + <div class="generate-bar__lead"> + <FaqOrb :size="40" /> + <div> + <div class="generate-bar__title">{{ title }}</div> + <div class="generate-bar__sub"> + <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5.5A2 2 0 0 1 6 4h5v16H6a2 2 0 0 0-2 1.5z" /><path d="M20 5.5A2 2 0 0 0 18 4h-5v16h5a2 2 0 0 1 2 1.5z" /></svg> + {{ subtitle }} + </div> + </div> + </div> + <div class="generate-bar__actions"> + <button class="btn btn--ghost" type="button" @click="$emit('import')"> + <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12" /><path d="M8 11l4 4 4-4" /><path d="M5 21h14" /></svg> + 导入 + </button> + <button class="btn btn--ghost" type="button" @click="$emit('add')"> + <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14" /></svg> + 添加常见问题 + </button> + <button class="btn btn--generate" type="button" :disabled="generateDisabled" :title="generateTitle" @click="$emit('generate')"> + <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3v4M3 5h4M6 17v4M4 19h4M13 3l2.5 6.5L22 12l-6.5 2.5L13 21l-2.5-6.5L4 12l6.5-2.5z" /></svg> + {{ generateLabel }} + </button> + </div> + </div> +</template> + +<style scoped> +.generate-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 22px; + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 16px; + flex-wrap: wrap; +} + +.generate-bar__lead { + display: flex; + align-items: center; + gap: 14px; +} + +.generate-bar__title { + font-size: 14.5px; + font-weight: 600; + color: var(--text2); +} + +.generate-bar__sub { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 13px; + color: var(--muted); + margin-top: 2px; +} + +.generate-bar__actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + border-radius: 11px; + font-family: var(--font-sans); + font-size: 14px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: background-color var(--transition-fast); +} + +.btn--ghost { + background: var(--o05); + border: 1px solid var(--o14); + color: var(--text2); +} + +.btn--ghost:hover { + background: var(--o08); +} + +.btn--generate { + padding: 12px 20px; + background: var(--purple-bg); + border: 1px solid var(--purple-border); + color: var(--c-purple); +} + +.btn--generate:disabled { + opacity: 0.6; + cursor: default; +} + +/* ── Mobile ───────────────────────────────────────────────────────────── + Three actions wrapped 2 + 1, leaving a stranded Regenerate button. Below + 768px they share one row as equal thirds. */ +@media (max-width: 768px) { + .generate-bar { + padding: 14px 16px; + gap: 14px; + } + + .generate-bar__actions { + width: 100%; + gap: 8px; + flex-wrap: nowrap; + } + + .btn { + flex: 1 1 0; + min-width: 0; + justify-content: center; + padding: 12px 8px; + /* 44px minimum touch target (Apple HIG / WCAG 2.5.8) */ + min-height: 44px; + white-space: nowrap; + } +} + +/* Labels alone would overflow three-up on the narrowest phones */ +@media (max-width: 400px) { + .btn { + font-size: 12.5px; + gap: 5px; + } +} +</style> diff --git a/frontend/src/components/faq/FaqGenerationProgress.vue b/frontend/src/components/faq/FaqGenerationProgress.vue new file mode 100644 index 0000000..dc2c258 --- /dev/null +++ b/frontend/src/components/faq/FaqGenerationProgress.vue @@ -0,0 +1,205 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed } from 'vue' +import type { FaqGenerationJob, FaqJobStage } from '@/types/faq' +import FaqOrb from './FaqOrb.vue' + +const props = defineProps<{ job: FaqGenerationJob }>() + +const STAGE_ORDER: FaqJobStage[] = ['analyzing_sources', 'extracting', 'drafting', 'grouping'] +const STAGE_LABELS = [ + '分析你的来源', + '从内容中提取问题', + '撰写有依据的回答', + '按分类整理', +] + +const steps = computed(() => { + const stage = props.job.stage + // Before the first stage report, the first step is active; when the job + // finishes, everything is done. + const activeIndex = stage === 'completed' ? STAGE_ORDER.length : Math.max(0, STAGE_ORDER.indexOf(stage)) + return STAGE_LABELS.map((label, index) => ({ + label, + state: index < activeIndex ? 'done' : index === activeIndex ? 'active' : 'todo', + })) +}) +</script> + +<template> + <div class="progress-card"> + <div class="progress-card__head"> + <FaqOrb :size="48" /> + <div> + <div class="progress-card__title">正在读取你的知识库…</div> + <div class="progress-card__note"> + 通常不到一分钟即可完成。你可以离开此页面,我们会在后台继续处理。 + </div> + </div> + </div> + <div class="progress-track"> + <div class="progress-track__sweep"></div> + </div> + <div class="steps"> + <div v-for="step in steps" :key="step.label" class="step" :class="step.state"> + <span class="step__icon"> + <svg v-if="step.state === 'done'" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--on-accent-solid)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l4 4 10-10" /></svg> + <span v-else-if="step.state === 'active'" class="step__spinner"></span> + <span v-else class="step__dot"></span> + </span> + <span class="step__label">{{ step.label }}</span> + <span v-if="step.state === 'active'" class="step__pulse"> + <span></span><span></span><span></span> + </span> + </div> + </div> + </div> +</template> + +<style scoped> +.progress-card { + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 16px; + padding: 40px 32px; +} + +.progress-card__head { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 26px; +} + +.progress-card__title { + font-family: var(--font-display); + font-weight: 700; + font-size: 19px; + letter-spacing: -0.01em; + color: var(--text); +} + +.progress-card__note { + font-size: 13.5px; + color: var(--muted); + margin-top: 3px; +} + +.progress-track { + height: 6px; + border-radius: var(--radius-pill); + background: var(--o08); + overflow: hidden; + margin-bottom: 26px; + position: relative; +} + +.progress-track__sweep { + position: absolute; + top: 0; + bottom: 0; + width: 38%; + border-radius: var(--radius-pill); + background: var(--grad-generate); + animation: faq-progress 1.5s ease-in-out infinite; +} + +@keyframes faq-progress { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(320%); } +} + +.steps { + display: flex; + flex-direction: column; + gap: 14px; +} + +.step { + display: flex; + align-items: center; + gap: 13px; +} + +.step__icon { + width: 24px; + height: 24px; + flex-shrink: 0; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.step.done .step__icon { + background: var(--accent-solid); +} + +.step__spinner { + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid var(--c-purple); + border-top-color: transparent; + animation: faq-spin 0.8s linear infinite; +} + +@keyframes faq-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.step__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--faint); +} + +.step__label { + font-size: 14px; + font-weight: 500; + color: var(--text2); +} + +.step.todo .step__label { + color: var(--muted2); +} + +.step__pulse { + display: inline-flex; + gap: 3px; + margin-left: 2px; +} + +.step__pulse span { + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--c-purple); + animation: faq-pulse 1s ease-in-out infinite; +} + +.step__pulse span:nth-child(2) { animation-delay: 0.2s; } +.step__pulse span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes faq-pulse { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 1; } +} +</style> diff --git a/frontend/src/components/faq/FaqImportModal.vue b/frontend/src/components/faq/FaqImportModal.vue new file mode 100644 index 0000000..9dc7ea0 --- /dev/null +++ b/frontend/src/components/faq/FaqImportModal.vue @@ -0,0 +1,471 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed, ref, watch } from 'vue' +import { toast } from 'vue-sonner' +import Modal from '@/components/common/Modal.vue' +import FaqOrb from './FaqOrb.vue' +import type { FaqImportMode } from '@/types/faq' + +const props = defineProps<{ + open: boolean + submitting?: boolean +}>() + +const emit = defineEmits<{ + close: [] + submit: [url: string, mode: FaqImportMode, preserveUrls: boolean] + 'submit-pdf': [file: File] +}>() + +const url = ref('') +const mode = ref<FaqImportMode>('qa') +const pdfFile = ref<File | null>(null) +// Article mode only: keep every imported article at the URL it already has. +const preserveUrls = ref(true) + +const MODES: { value: FaqImportMode; title: string; description: string }[] = [ + { + value: 'qa', + title: '问答页面', + description: 'AI 读取单个页面并提取每一组问答,会消耗 AI 额度。', + }, + { + value: 'articles', + title: '文章页面', + description: '按原样导入该页面链接到的所有文章——文本、图片和链接,不消耗 AI。', + }, + { + value: 'pdf', + title: 'PDF 文档', + description: 'AI 从上传的 PDF 中提取问答,会消耗 AI 额度。', + }, +] + +watch( + () => props.open, + (open) => { + if (!open) { + url.value = '' + mode.value = 'qa' + pdfFile.value = null + preserveUrls.value = true + } + }, +) + +// The option only exists for article mode; reset it on every mode change so +// switching away and back can't carry a stale choice into the next import. +watch(mode, () => { + preserveUrls.value = true +}) + +const canSubmit = computed(() => { + if (props.submitting) return false + if (mode.value === 'pdf') return pdfFile.value !== null + return url.value.trim().length > 0 +}) + +const hint = computed(() => { + if (mode.value === 'articles') + return 'ChatterMate 会跟随该页面上的文章链接,按原文将每篇文章导入为草稿——包含格式和图片。' + if (mode.value === 'pdf') + return 'ChatterMate 会读取 PDF,提取每组问答并作为草稿添加到这里,供你审核后发布。' + return 'ChatterMate 会抓取该页面,提取每组问答并作为草稿添加到这里,供你审核后发布。' +}) + +const fileSize = computed(() => { + const bytes = pdfFile.value?.size ?? 0 + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +}) + +const dragOver = ref(false) + +function setPdf(file: File | null | undefined) { + if (file && file.type === 'application/pdf') pdfFile.value = file + else if (file) toast.error('请选择 PDF 文件') +} + +function onPdfChange(event: Event) { + setPdf((event.target as HTMLInputElement).files?.[0] ?? null) +} + +function onDrop(event: DragEvent) { + dragOver.value = false + setPdf(event.dataTransfer?.files?.[0]) +} + +function submit() { + if (!canSubmit.value) return + if (mode.value === 'pdf') { + if (pdfFile.value) emit('submit-pdf', pdfFile.value) + return + } + const cleaned = url.value.trim().replace(/^https?:\/\//i, '') + emit('submit', `https://${cleaned}`, mode.value, mode.value === 'articles' && preserveUrls.value) +} +</script> + +<template> + <Modal v-if="open" @close="$emit('close')"> + <template #title>迁移现有帮助中心</template> + <template #content> + <p class="import-sub">粘贴帮助中心或常见问题页面的 URL,并选择导入方式。</p> + + <div class="mode-cards" role="radiogroup" aria-label="导入方式"> + <label + v-for="option in MODES" + :key="option.value" + class="mode-card" + :class="{ 'mode-card--active': mode === option.value }" + > + <input v-model="mode" class="mode-card__radio" type="radio" name="faq-import-mode" :value="option.value" /> + <span class="mode-card__title">{{ option.title }}</span> + <span class="mode-card__desc">{{ option.description }}</span> + </label> + </div> + + <template v-if="mode !== 'pdf'"> + <label class="import-label" for="faq-import-url">{{ mode === 'articles' ? '帮助中心索引 URL' : '常见问题页面 URL' }}</label> + <div class="import-input"> + <span class="import-input__prefix">https://</span> + <input + id="faq-import-url" + v-model="url" + type="text" + placeholder="support.yourcompany.com/faq" + @keydown.enter="submit" + /> + </div> + </template> + <template v-else> + <label class="import-label" for="faq-import-pdf">PDF 文件(最大 25MB)</label> + <label + class="pdf-drop" + :class="{ 'pdf-drop--filled': pdfFile, 'pdf-drop--drag': dragOver }" + @dragover.prevent="dragOver = true" + @dragleave.prevent="dragOver = false" + @drop.prevent="onDrop" + > + <input id="faq-import-pdf" class="pdf-drop__input" type="file" accept="application/pdf" @change="onPdfChange" /> + <span class="pdf-drop__icon"> + <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3v4a1 1 0 0 0 1 1h4" /><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z" /><path d="M9 13h6M9 17h4" /></svg> + </span> + <span class="pdf-drop__text"> + <template v-if="pdfFile"> + <span class="pdf-drop__name">{{ pdfFile.name }}</span> + <span class="pdf-drop__meta">{{ fileSize }} · 重新选择文件</span> + </template> + <template v-else> + <span class="pdf-drop__name">选择 PDF 或拖放到此处</span> + <span class="pdf-drop__meta">仅支持 PDF,最大 25 MB</span> + </template> + </span> + </label> + </template> + <label v-if="mode === 'articles'" class="preserve"> + <input v-model="preserveUrls" class="preserve__box" type="checkbox" /> + <span class="preserve__text"> + <span class="preserve__title">保留文章原始 URL</span> + <span class="preserve__desc"> + 每篇文章仍保留现有的 URL 路径,因此搜索排名和已有链接在迁移后依然有效。将旧域名指向这里,访客不会察觉任何变化。 + </span> + </span> + </label> + + <div class="import-hint"> + <FaqOrb :size="34" /> + <div>{{ hint }}</div> + </div> + <div class="import-actions"> + <button class="btn-cancel" type="button" @click="$emit('close')">取消</button> + <button class="btn-import" type="button" :disabled="!canSubmit" @click="submit"> + <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12" /><path d="M8 11l4 4 4-4" /><path d="M5 21h14" /></svg> + {{ submitting ? '导入中…' : mode === 'articles' ? '导入文章' : mode === 'pdf' ? '导入 PDF' : '导入常见问题' }} + </button> + </div> + </template> + </Modal> +</template> + +<style scoped> +.import-sub { + font-size: 13px; + color: var(--muted); + margin: -12px 0 18px; + line-height: 1.5; +} + +.mode-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + margin-bottom: 18px; +} + +@media (max-width: 560px) { + .mode-cards { + grid-template-columns: 1fr; + } +} + +.mode-card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 13px 14px; + background: var(--o03); + border: 1px solid var(--o12); + border-radius: 12px; + cursor: pointer; + transition: border-color var(--transition-fast), background-color var(--transition-fast); +} + +.mode-card--active { + background: var(--purple-bg); + border-color: var(--purple-border); +} + +.mode-card__radio { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.mode-card__title { + font-size: 13.5px; + font-weight: 600; + color: var(--text2); +} + +.mode-card--active .mode-card__title { + color: var(--c-purple); +} + +.mode-card__desc { + font-size: 12px; + color: var(--muted); + line-height: 1.45; +} + +.import-label { + display: block; + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.06em; + color: var(--muted2); + margin-bottom: 8px; +} + +.import-input { + display: flex; + align-items: center; + gap: 10px; + background: var(--bg); + border: 1px solid var(--o12); + border-radius: 11px; + padding: 0 14px; + margin-bottom: 16px; + font-family: var(--font-mono); +} + +.import-input__prefix { + color: var(--faint); + font-size: 13.5px; +} + +.import-input input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--text); + font-size: 13.5px; + padding: 13px 4px; + font-family: var(--font-mono); +} + +.pdf-drop { + display: flex; + align-items: center; + gap: 13px; + padding: 16px 18px; + margin-bottom: 16px; + background: var(--bg); + border: 1.5px dashed var(--o14); + border-radius: 12px; + cursor: pointer; + transition: border-color var(--transition-fast), background-color var(--transition-fast); +} + +.pdf-drop:hover { + border-color: var(--c-purple); + background: var(--purple-bg); +} + +.pdf-drop--filled { + border-style: solid; + border-color: var(--purple-border); + background: var(--purple-bg); +} + +.pdf-drop--drag { + border-style: solid; + border-color: var(--c-purple); + background: var(--purple-bg); +} + +.pdf-drop__input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +.pdf-drop__icon { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + flex-shrink: 0; + border-radius: 10px; + background: var(--o05); + color: var(--muted); +} + +.pdf-drop--filled .pdf-drop__icon { + background: var(--surface); + color: var(--c-purple); +} + +.pdf-drop__text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.pdf-drop__name { + font-size: 14px; + font-weight: 600; + color: var(--text2); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pdf-drop__meta { + font-size: 12px; + color: var(--muted); +} + +.preserve { + display: flex; + align-items: flex-start; + gap: 11px; + padding: 13px 14px; + margin-bottom: 16px; + background: var(--o03); + border: 1px solid var(--o12); + border-radius: 12px; + cursor: pointer; +} + +.preserve__box { + flex-shrink: 0; + width: 15px; + height: 15px; + margin-top: 1px; + accent-color: var(--c-purple); + cursor: pointer; +} + +.preserve__text { + display: flex; + flex-direction: column; + gap: 3px; +} + +.preserve__title { + font-size: 13.5px; + font-weight: 600; + color: var(--text2); +} + +.preserve__desc { + font-size: 12px; + color: var(--muted); + line-height: 1.45; +} + +.import-hint { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + background: var(--o03); + border: 1px solid var(--o07); + border-radius: 12px; + margin-bottom: 20px; + font-size: 13px; + color: var(--muted); + line-height: 1.5; +} + +.import-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + +.btn-cancel { + padding: 11px 18px; + background: transparent; + border: 1px solid var(--o12); + border-radius: 10px; + color: var(--muted); + font-family: var(--font-sans); + font-size: 14px; + font-weight: 500; + cursor: pointer; +} + +.btn-import { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 11px 20px; + background: var(--c-purple); + border: none; + border-radius: 10px; + color: var(--on-accent); + font-family: var(--font-sans); + font-size: 14px; + font-weight: 600; + cursor: pointer; +} + +.btn-import:disabled { + opacity: 0.55; + cursor: default; +} +</style> diff --git a/frontend/src/components/faq/FaqOrb.vue b/frontend/src/components/faq/FaqOrb.vue new file mode 100644 index 0000000..8a4f7e8 --- /dev/null +++ b/frontend/src/components/faq/FaqOrb.vue @@ -0,0 +1,86 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed } from 'vue' + +const props = withDefaults(defineProps<{ size?: number }>(), { size: 40 }) + +const style = computed(() => ({ + width: `${props.size}px`, + height: `${props.size}px`, + '--orb-blur': `${props.size * 0.05}px`, + '--orb-inset': `${props.size * 0.21}px`, + '--orb-shadow': `${props.size * 0.22}px`, +})) +</script> + +<template> + <div class="orb" :style="style" aria-hidden="true"> + <div class="orb__gradient"></div> + <div class="orb__core"></div> + <div class="orb__ring"></div> + </div> +</template> + +<style scoped> +.orb { + position: relative; + flex-shrink: 0; +} + +.orb__gradient { + position: absolute; + inset: 0; + border-radius: 50%; + background: conic-gradient( + from 0deg, + var(--c-purple), + var(--c-teal), + var(--accent-ink), + var(--c-coral), + var(--c-purple) + ); + filter: blur(var(--orb-blur)); + animation: orb-spin 6s linear infinite; +} + +.orb__core { + position: absolute; + inset: var(--orb-inset); + border-radius: 50%; + background: radial-gradient(circle at 40% 35%, rgba(255, 255, 255, 0.92), var(--o12) 55%, transparent 72%); + animation: orb-pulse 2.6s ease-in-out infinite; +} + +.orb__ring { + position: absolute; + inset: 0; + border-radius: 50%; + box-shadow: inset 0 0 var(--orb-shadow) rgba(0, 0, 0, 0.4); +} + +@keyframes orb-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +@keyframes orb-pulse { + 0% { opacity: 0.4; } + 50% { opacity: 1; } + 100% { opacity: 0.4; } +} +</style> diff --git a/frontend/src/components/faq/FaqProLockOverlay.vue b/frontend/src/components/faq/FaqProLockOverlay.vue new file mode 100644 index 0000000..b0b9866 --- /dev/null +++ b/frontend/src/components/faq/FaqProLockOverlay.vue @@ -0,0 +1,177 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed } from 'vue' +import { useRouter } from 'vue-router' +import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures' +import { useSubscriptionStorage } from '@/utils/storage' + +const props = defineProps<{ + /** Server-side plan verdict (settings.plan_allowed). false always locks. */ + planAllowed?: boolean | null +}>() + +const PRO_PRICE_NOTE = '包含在 Pro 套餐中 · 每位智能体 $39/月' + +const router = useRouter() +const { hasEnterpriseModule } = useEnterpriseFeatures() +const subscriptionStorage = useSubscriptionStorage() + +const isLocked = computed(() => { + if (props.planAllowed === false) return true + return ( + hasEnterpriseModule && + (!subscriptionStorage.hasFeature('help_center') || !subscriptionStorage.isSubscriptionActive()) + ) +}) + +function goToSubscription() { + router.push('/settings/subscription') +} +</script> + +<template> + <div class="lock-wrap"> + <div class="lock-wrap__content" :class="{ 'lock-wrap__content--locked': isLocked }"> + <slot /> + </div> + <div v-if="isLocked" class="lock-overlay"> + <div class="lock-card"> + <div class="lock-card__icon"> + <svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="var(--c-purple)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="11" width="14" height="10" rx="2.2" /><path d="M8 11V8a4 4 0 0 1 8 0v3" /></svg> + </div> + <h3 class="lock-card__title">用 Pro 自动生成常见问题</h3> + <p class="lock-card__copy"> + ChatterMate 会读取每个知识来源,起草清晰、可直接发布的常见问题——按分类整理,并随文档更新保持同步。 + </p> + <div class="lock-card__actions"> + <button class="btn-upgrade" type="button" @click="goToSubscription"> + <svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" stroke="none"><path d="M13 2 4 14h6l-1 8 9-12h-6z" /></svg> + 升级到 Pro + </button> + <button class="btn-compare" type="button" @click="goToSubscription">比较套餐</button> + </div> + <div class="lock-card__note">{{ PRO_PRICE_NOTE }}</div> + </div> + </div> + </div> +</template> + +<style scoped> +.lock-wrap { + position: relative; +} + +.lock-wrap__content--locked { + filter: grayscale(0.9) saturate(0.5); + opacity: 0.55; + pointer-events: none; + user-select: none; +} + +.lock-overlay { + position: absolute; + inset: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: var(--scrim); + backdrop-filter: blur(6px); + border-radius: 18px; +} + +.lock-card { + max-width: 440px; + text-align: center; + background: var(--surface); + border: 1px solid var(--purple-border); + border-radius: 20px; + padding: 34px 32px; + box-shadow: var(--shadow-lg); +} + +.lock-card__icon { + width: 52px; + height: 52px; + margin: 0 auto 18px; + border-radius: 14px; + background: var(--purple-bg); + border: 1px solid var(--purple-border); + display: flex; + align-items: center; + justify-content: center; +} + +.lock-card__title { + font-family: var(--font-display); + font-weight: 700; + font-size: 21px; + letter-spacing: -0.01em; + color: var(--text); + margin: 0 0 10px; +} + +.lock-card__copy { + font-size: 14.5px; + color: var(--muted); + line-height: 1.6; + margin: 0 0 22px; +} + +.lock-card__actions { + display: flex; + gap: 10px; + justify-content: center; + flex-wrap: wrap; +} + +.btn-upgrade { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 13px 22px; + background: var(--c-purple); + border: 1px solid var(--c-purple); + border-radius: 11px; + color: var(--on-accent); + font-family: var(--font-sans); + font-size: 14.5px; + font-weight: 600; + cursor: pointer; +} + +.btn-compare { + padding: 13px 20px; + background: var(--o05); + border: 1px solid var(--o14); + border-radius: 11px; + color: var(--text2); + font-family: var(--font-sans); + font-size: 14.5px; + font-weight: 500; + cursor: pointer; +} + +.lock-card__note { + font-family: var(--font-mono); + font-size: 11px; + color: var(--muted2); + margin-top: 18px; +} +</style> diff --git a/frontend/src/components/faq/FaqSeoFields.vue b/frontend/src/components/faq/FaqSeoFields.vue new file mode 100644 index 0000000..b821aaa --- /dev/null +++ b/frontend/src/components/faq/FaqSeoFields.vue @@ -0,0 +1,301 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. + +Per-article SEO overrides inside the FAQ editor: URL slug, the preserved URL +path for a migrated article, meta title and meta description. Collapsed by +default — every field is optional, and leaving one empty keeps the value the +public page derives from the question and answer. +--> + +<script setup lang="ts"> +import { computed, ref } from 'vue' + +const props = withDefaults( + defineProps<{ + slug?: string + urlPath?: string + metaTitle?: string + metaDescription?: string + /** Shown as the placeholder for the title, so the derived default is visible. */ + question?: string + }>(), + { slug: '', urlPath: '', metaTitle: '', metaDescription: '', question: '' }, +) + +const emit = defineEmits<{ + 'update:slug': [value: string] + 'update:urlPath': [value: string] + 'update:metaTitle': [value: string] + 'update:metaDescription': [value: string] +}>() + +// Hard caps mirror the backend column widths in app/models/faq.py, which is the +// source of truth — the API rejects anything longer with a 422, so these only +// stop the user typing past the limit. The "recommended" numbers are softer: +// where search engines start truncating the tag in results. +const MAX_SLUG = 80 +const MAX_URL_PATH = 400 +const MAX_TITLE = 120 +const MAX_DESCRIPTION = 300 +const RECOMMENDED_TITLE = 60 +const RECOMMENDED_DESCRIPTION = 160 + +// A customised article opens the section on mount, so an override is never +// hidden behind a collapsed panel — but it stays a plain toggle afterwards, so +// the user can still fold it away. The editor mounts fresh per article, which +// is what makes the initial value correct. +const hasValues = computed( + () => Boolean(props.urlPath.trim() || props.metaTitle.trim() || props.metaDescription.trim()), +) +const expanded = ref(hasValues.value) + +const titleCount = computed(() => props.metaTitle.trim().length) +const descriptionCount = computed(() => props.metaDescription.trim().length) + +function onInput(event: Event): string { + return (event.target as HTMLInputElement | HTMLTextAreaElement).value +} +</script> + +<template> + <div class="seo"> + <button class="seo__toggle" type="button" :aria-expanded="expanded" @click="expanded = !expanded"> + <svg + class="seo__chevron" + :class="{ 'seo__chevron--open': expanded }" + viewBox="0 0 24 24" + width="13" + height="13" + fill="none" + stroke="currentColor" + stroke-width="2.2" + stroke-linecap="round" + stroke-linejoin="round" + > + <path d="M9 6l6 6-6 6" /> + </svg> + SEO 与 URL + <span v-if="hasValues" class="seo__badge">已自定义</span> + </button> + + <div v-if="expanded" class="seo__fields"> + <label class="seo__field"> + <span class="seo__label">URL 别名</span> + <span class="seo__slug"> + <span class="seo__prefix">/a/</span> + <input + type="text" + class="seo__input seo__input--slug" + :maxlength="MAX_SLUG" + placeholder="根据问题自动生成" + :value="slug" + @input="emit('update:slug', onInput($event))" + /> + </span> + <span class="seo__hint"> + 修改后,指向该文章的现有链接将失效。空格和符号会转换为连字符。 + </span> + </label> + + <label class="seo__field"> + <span class="seo__label">自定义 URL 路径</span> + <input + type="text" + class="seo__input seo__input--path" + :maxlength="MAX_URL_PATH" + placeholder="/hc/en-us/articles/360012-reset-password" + :value="urlPath" + @input="emit('update:urlPath', onInput($event))" + /> + <span class="seo__hint"> + 将这篇文章发布到它在其他帮助中心已有的 URL 上,以保留搜索排名和外部链接。粘贴旧 URL 或路径即可,留空则使用上方的别名。 + </span> + </label> + + <label class="seo__field"> + <span class="seo__label"> + Meta 标题 + <span class="seo__count" :class="{ 'seo__count--over': titleCount > RECOMMENDED_TITLE }"> + {{ titleCount }}/{{ RECOMMENDED_TITLE }} + </span> + </span> + <input + type="text" + class="seo__input" + :maxlength="MAX_TITLE" + :placeholder="question || '默认为问题内容'" + :value="metaTitle" + @input="emit('update:metaTitle', onInput($event))" + /> + </label> + + <label class="seo__field"> + <span class="seo__label"> + Meta 描述 + <span + class="seo__count" + :class="{ 'seo__count--over': descriptionCount > RECOMMENDED_DESCRIPTION }" + > + {{ descriptionCount }}/{{ RECOMMENDED_DESCRIPTION }} + </span> + </span> + <textarea + class="seo__input seo__textarea" + rows="2" + :maxlength="MAX_DESCRIPTION" + placeholder="默认为回答的开头部分" + :value="metaDescription" + @input="emit('update:metaDescription', onInput($event))" + ></textarea> + </label> + </div> + </div> +</template> + +<style scoped> +.seo { + margin-top: 10px; + border-top: 1px solid var(--o08); + padding-top: 10px; +} + +.seo__toggle { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 0; + background: none; + border: none; + cursor: pointer; + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: 0.08em; + color: var(--muted); +} + +.seo__toggle:hover { + color: var(--text); +} + +.seo__chevron { + transition: transform 0.15s ease; +} + +.seo__chevron--open { + transform: rotate(90deg); +} + +.seo__badge { + padding: 2px 7px; + border-radius: 999px; + background: var(--o08); + font-size: 9.5px; + letter-spacing: 0.06em; + color: var(--muted2); +} + +.seo__fields { + display: grid; + gap: 12px; + margin-top: 12px; +} + +.seo__field { + display: block; +} + +.seo__label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 5px; + font-size: 12px; + font-weight: 600; + color: var(--muted); +} + +.seo__count { + font-family: var(--font-mono); + font-size: 10.5px; + font-weight: 400; + color: var(--muted2); +} + +.seo__count--over { + color: var(--c-coral); +} + +.seo__slug { + display: flex; + align-items: center; + background: var(--bg); + border: 1px solid var(--o12); + border-radius: 9px; + overflow: hidden; +} + +.seo__prefix { + padding: 0 2px 0 11px; + font-family: var(--font-mono); + font-size: 12.5px; + color: var(--muted2); +} + +.seo__input { + width: 100%; + box-sizing: border-box; + background: var(--bg); + border: 1px solid var(--o12); + border-radius: 9px; + padding: 9px 11px; + color: var(--text); + font-family: var(--font-sans); + font-size: 13px; + outline: none; +} + +.seo__input:focus { + border-color: var(--o20); +} + +/* The slug input sits inside the prefixed shell, which draws the border. */ +.seo__input--slug { + border: none; + border-radius: 0; + padding-left: 0; + font-family: var(--font-mono); + font-size: 12.5px; +} + +/* A URL, so it reads in the same monospace as the slug — but it keeps the + standard input shell, since it has no "/a/" prefix to sit behind. */ +.seo__input--path { + font-family: var(--font-mono); + font-size: 12.5px; +} + +.seo__textarea { + resize: vertical; + line-height: 1.5; +} + +.seo__hint { + display: block; + margin-top: 5px; + font-size: 11px; + color: var(--muted2); +} +</style> diff --git a/frontend/src/components/faq/FaqSourcePicker.vue b/frontend/src/components/faq/FaqSourcePicker.vue new file mode 100644 index 0000000..005e717 --- /dev/null +++ b/frontend/src/components/faq/FaqSourcePicker.vue @@ -0,0 +1,293 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed, ref, watch } from 'vue' +import Modal from '@/components/common/Modal.vue' +import type { GenerateEstimate, GenerationSource } from '@/types/faq' + +const props = defineProps<{ + open: boolean + estimate: GenerateEstimate | null + submitting?: boolean +}>() + +const emit = defineEmits<{ + close: [] + generate: [knowledgeIds: number[]] +}>() + +const sources = computed<GenerationSource[]>(() => props.estimate?.sources ?? []) +const selected = ref<Set<number>>(new Set()) + +// Default selection = sources without FAQs (the "new" ones). Seed only when the +// picker OPENS (not on later estimate changes) so a background refetch can't +// wipe the user's manual selection mid-interaction. +watch( + () => props.open, + (open) => { + if (!open) return + const next = new Set<number>() + for (const s of sources.value) if (!s.has_faqs) next.add(s.id) + // If everything already has FAQs, start from all so the user can regenerate. + if (next.size === 0) for (const s of sources.value) next.add(s.id) + selected.value = next + }, + { immediate: true }, +) + +function toggle(id: number) { + const next = new Set(selected.value) + if (next.has(id)) next.delete(id) + else next.add(id) + selected.value = next +} + +const allSelected = computed( + () => sources.value.length > 0 && selected.value.size === sources.value.length, +) + +function toggleAll() { + selected.value = allSelected.value ? new Set() : new Set(sources.value.map((s) => s.id)) +} + +const metered = computed(() => props.estimate?.metered ?? false) +const remaining = computed(() => props.estimate?.remaining_credits ?? null) + +const selectedCredits = computed(() => + sources.value.reduce((n, s) => (selected.value.has(s.id) ? n + s.estimated_calls : n), 0), +) + +const overBudget = computed( + () => metered.value && remaining.value !== null && selectedCredits.value > remaining.value, +) + +const canGenerate = computed(() => selected.value.size > 0 && !props.submitting && !overBudget.value) + +function labelFor(s: GenerationSource): string { + const pages = s.pages ? `${s.pages} 个页面` : '' + return pages +} + +function submit() { + if (!canGenerate.value) return + emit('generate', [...selected.value]) +} +</script> + +<template> + <Modal v-if="open" @close="$emit('close')"> + <template #title>生成常见问题</template> + <template #content> + <p class="picker-sub">选择要从哪些知识来源生成常见问题。</p> + + <div class="picker-toolbar"> + <button class="link-btn" type="button" @click="toggleAll"> + {{ allSelected ? '取消全选' : '全选' }} + </button> + <span class="picker-count">已选 {{ selected.size }} / {{ sources.length }}</span> + </div> + + <div class="picker-list"> + <label + v-for="s in sources" + :key="s.id" + class="src" + :class="{ 'src--on': selected.has(s.id) }" + > + <input class="src__check" type="checkbox" :checked="selected.has(s.id)" @change="toggle(s.id)" /> + <span class="src__body"> + <span class="src__name" :title="s.name">{{ s.name }}</span> + <span class="src__meta"> + <span class="src__type">{{ s.source_type }}</span> + <template v-if="labelFor(s)"> · {{ labelFor(s) }}</template> + <span v-if="s.has_faqs" class="src__badge">已生成</span> + </span> + </span> + </label> + </div> + + <p v-if="metered" class="picker-credits" :class="{ 'picker-credits--over': overBudget }"> + 预计消耗约 {{ selectedCredits }} 条额度<template v-if="remaining !== null"> · 剩余 {{ remaining }} 条</template> + <template v-if="overBudget">——额度不足,请升级套餐或改用你自己的 AI 模型。</template> + </p> + + <div class="picker-actions"> + <button class="btn-cancel" type="button" @click="$emit('close')">取消</button> + <button class="btn-generate" type="button" :disabled="!canGenerate" @click="submit"> + {{ submitting ? '正在启动…' : `从 ${selected.size} 个来源生成` }} + </button> + </div> + </template> + </Modal> +</template> + +<style scoped> +.picker-sub { + font-size: 13.5px; + color: var(--muted); + margin: -12px 0 16px; + line-height: 1.5; +} + +.picker-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} + +.link-btn { + background: transparent; + border: none; + color: var(--c-purple); + font-family: var(--font-sans); + font-size: 13px; + font-weight: 600; + cursor: pointer; + padding: 0; +} + +.picker-count { + font-family: var(--font-mono); + font-size: 11.5px; + color: var(--muted2); +} + +.picker-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 42vh; + overflow-y: auto; + margin-bottom: 16px; +} + +.src { + display: flex; + align-items: flex-start; + gap: 11px; + padding: 12px 13px; + background: var(--bg); + border: 1px solid var(--o10); + border-radius: 11px; + cursor: pointer; + transition: border-color var(--transition-fast), background-color var(--transition-fast); +} + +.src--on { + border-color: var(--purple-border); + background: var(--purple-bg); +} + +.src__check { + width: 16px; + height: 16px; + margin: 1px 0 0; + flex-shrink: 0; + accent-color: var(--c-purple); + cursor: pointer; +} + +.src__body { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.src__name { + font-size: 13.5px; + font-weight: 600; + color: var(--text2); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.src__meta { + display: inline-flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + font-size: 12px; + color: var(--muted); +} + +.src__type { + font-family: var(--font-mono); + font-size: 11px; + color: var(--muted2); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.src__badge { + padding: 1px 7px; + border-radius: 6px; + background: var(--o05); + border: 1px solid var(--o12); + font-size: 10.5px; + color: var(--muted); +} + +.picker-credits { + font-size: 12.5px; + color: var(--muted); + margin: 0 0 16px; + line-height: 1.5; +} + +.picker-credits--over { + color: var(--c-coral); +} + +.picker-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + +.btn-cancel { + padding: 11px 18px; + background: transparent; + border: 1px solid var(--o12); + border-radius: 10px; + color: var(--muted); + font-family: var(--font-sans); + font-size: 14px; + font-weight: 500; + cursor: pointer; +} + +.btn-generate { + padding: 11px 20px; + background: var(--purple-bg); + border: 1px solid var(--purple-border); + border-radius: 10px; + color: var(--c-purple); + font-family: var(--font-sans); + font-size: 14px; + font-weight: 600; + cursor: pointer; +} + +.btn-generate:disabled { + opacity: 0.55; + cursor: default; +} +</style> diff --git a/frontend/src/components/faq/FaqWorkspace.vue b/frontend/src/components/faq/FaqWorkspace.vue new file mode 100644 index 0000000..e0f1ae3 --- /dev/null +++ b/frontend/src/components/faq/FaqWorkspace.vue @@ -0,0 +1,857 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed, onMounted, onUnmounted, ref, watch } from 'vue' +import { toast } from 'vue-sonner' +import type { FaqImportMode, FaqItem, FaqStatus } from '@/types/faq' +import { useFaqWorkspace } from '@/composables/useFaqWorkspace' +import { useHelpCenterSettings } from '@/composables/useHelpCenterSettings' +import FaqGenerateBar from './FaqGenerateBar.vue' +import FaqEmptyState from './FaqEmptyState.vue' +import FaqGenerationProgress from './FaqGenerationProgress.vue' +import FaqCategoryGroup from './FaqCategoryGroup.vue' +import FaqCard from './FaqCard.vue' +import FaqImportModal from './FaqImportModal.vue' +import FaqSourcePicker from './FaqSourcePicker.vue' +import HelpCenterAppearance from './HelpCenterAppearance.vue' +import HelpCenterPublic from './HelpCenterPublic.vue' + +const props = defineProps<{ organizationId?: string }>() + +const emit = defineEmits<{ + 'plan-allowed': [allowed: boolean] +}>() + +const { + faqs, + job, + settings, + estimate, + fetchEstimate, + sourceCount, + pageCount, + phase, + barPhase, + publishedCount, + groupedFaqs, + searchQuery, + categoryFilter, + statusFilter, + categories, + hasActiveFilters, + filteredCount, + resetFilters, + toggleCategory, + isCategoryOpen, + editingId, + isNewFaq, + draftQuestion, + draftAnswer, + draftCategory, + draftSlug, + draftUrlPath, + draftMetaTitle, + draftMetaDescription, + isSaving, + selectedIds, + selectionActive, + toggleSelect, + setSelected, + clearSelection, + bulkSetStatus, + bulkDelete, + refresh, + startPolling, + stopPolling, + startGeneration, + submitImport, + submitPdfImport, + togglePublish, + startEdit, + startAdd, + cancelEdit, + saveEdit, + deleteFaq, +} = useFaqWorkspace(() => props.organizationId) + +const { + saveState, + queueSave, + saveNow, + uploadLogo, + removeLogo, + uploadFavicon, + removeFavicon, + domainBusy, + setDomain, + verifyDomain, + removeDomain, +} = useHelpCenterSettings(settings) + +// Surface the server-side plan verdict to the view's lock overlay. +watch( + () => settings.value?.plan_allowed, + (allowed) => { + if (typeof allowed === 'boolean') emit('plan-allowed', allowed) + }, +) + +defineExpose({ settings }) + +// Tabs keep the (potentially long) FAQ list and the settings on separate +// panes so neither forces the other into a long scroll. +const tab = ref<'faqs' | 'settings'>('faqs') + +// Import modal state. +const importOpen = ref(false) +const importSubmitting = ref(false) + +// Knowledge-source picker (shown when there are multiple sources). +const sourcePickerOpen = ref(false) +const generating = ref(false) + +async function onPickSources(knowledgeIds: number[]) { + generating.value = true + try { + // Keep the picker open if the job didn't start (409/budget) — the error + // toast explains why and the user can adjust their selection. + if (await startGeneration(knowledgeIds)) sourcePickerOpen.value = false + } finally { + generating.value = false + } +} + +async function onImportSubmit(url: string, mode: FaqImportMode, preserveUrls: boolean) { + importSubmitting.value = true + try { + const ok = await submitImport(url, mode, preserveUrls) + if (ok) importOpen.value = false + } finally { + importSubmitting.value = false + } +} + +async function onPdfImportSubmit(file: File) { + importSubmitting.value = true + try { + const ok = await submitPdfImport(file) + if (ok) importOpen.value = false + } finally { + importSubmitting.value = false + } +} + +// A single reusable confirm dialog for deletes and generation. +interface ConfirmState { + title: string + message: string + actionLabel: string + busyLabel: string + intent?: 'danger' | 'primary' + disabledReason?: string + action: () => Promise<void> +} +const confirmState = ref<ConfirmState | null>(null) +const confirmBusy = ref(false) + +function askDelete(faq: FaqItem) { + confirmState.value = { + title: '删除常见问题', + message: `删除“${faq.question}”?此操作无法撤销。`, + actionLabel: '删除', + busyLabel: '正在删除…', + intent: 'danger', + action: () => deleteFaq(faq), + } +} + +async function askGenerate() { + await fetchEstimate(true) // full estimate incl. page counts for the dialog + const e = estimate.value + if (!e) { + // Estimate unavailable (e.g. plan check failed) — let the backend decide. + await startGeneration() + return + } + if (e.total_sources === 0) { + toast.error('没有可用于生成的知识来源,请先添加知识。') + return + } + // Multiple sources → let the user pick which to generate from. + if (e.sources.length > 1) { + sourcePickerOpen.value = true + return + } + if (e.new_sources === 0) { + toast.info('所有知识来源都已生成常见问题,没有新内容可生成。') + return + } + const sources = `${e.new_sources} 个新来源` + const pages = e.pages ? `(约 ${e.pages} 个页面)` : '' + let message = `根据 ${sources}${pages}生成常见问题?` + let disabledReason: string | undefined + if (e.metered) { + message += ` 预计消耗约 ${e.estimated_calls} 条消息额度。` + if (e.remaining_credits !== null && e.estimated_calls > e.remaining_credits) { + disabledReason = `本周期仅剩 ${e.remaining_credits} 条额度——请升级套餐或改用你自己的 AI 模型。` + } + } + confirmState.value = { + title: '生成常见问题', + message, + actionLabel: '生成', + busyLabel: '正在启动…', + intent: 'primary', + disabledReason, + action: async () => { + await startGeneration() + }, + } +} + +function askBulkDelete() { + const n = selectedIds.value.size + confirmState.value = { + title: `删除 ${n} 条常见问题`, + message: '已发布的文章将从帮助中心移除,此操作无法撤销。', + actionLabel: '删除', + busyLabel: '正在删除…', + intent: 'danger', + action: async () => { + try { + await bulkDelete() + } catch (error: any) { + toast.error(error.message) + } + }, + } +} + +function selectedInCategory(items: FaqItem[]): number { + return items.reduce((n, item) => n + (selectedIds.value.has(item.id) ? 1 : 0), 0) +} + +function publishedInCategory(items: FaqItem[]): number { + return items.reduce((n, item) => n + (item.status === 'published' ? 1 : 0), 0) +} + +const STATUS_FILTERS: { v: 'all' | FaqStatus; l: string }[] = [ + { v: 'all', l: '全部' }, + { v: 'published', l: '已发布' }, + { v: 'draft', l: '草稿' }, +] + +async function runConfirm() { + const state = confirmState.value + if (!state) return + confirmBusy.value = true + try { + await state.action() + } finally { + confirmBusy.value = false + confirmState.value = null + } +} + +// Placeholder card for a manually added FAQ. The category mirrors the server +// default applied when the topic field is left blank. +const newFaqStub = computed<FaqItem>(() => ({ + id: 'new', + question: '', + answer: '', + category: 'General', + status: 'draft', + knowledge_id: null, + source_label: '手动添加', +})) + +onMounted(() => { + refresh() + startPolling() +}) + +onUnmounted(stopPolling) +</script> + +<template> + <div class="faq-workspace"> + <div v-if="phase === 'loading'" class="loading">正在加载常见问题…</div> + + <template v-else> + <nav class="tabs" role="tablist"> + <button class="tab" :class="{ 'tab--active': tab === 'faqs' }" type="button" role="tab" :aria-selected="tab === 'faqs'" @click="tab = 'faqs'"> + 常见问题 + <span v-if="faqs.length" class="tab__count">{{ faqs.length }}</span> + </button> + <button class="tab" :class="{ 'tab--active': tab === 'settings' }" type="button" role="tab" :aria-selected="tab === 'settings'" @click="tab = 'settings'"> + 自定义 + </button> + </nav> + + <div v-show="tab === 'faqs'" class="tab-panel"> + <FaqGenerateBar + class="workspace-bar" + :phase="barPhase" + :source-count="sourceCount" + :page-count="pageCount" + :faq-count="faqs.length" + :published-count="publishedCount" + :new-source-count="estimate?.new_sources ?? null" + @generate="askGenerate" + @import="importOpen = true" + @add="startAdd" + /> + + <FaqEmptyState + v-if="phase === 'empty' && !isNewFaq" + :source-count="sourceCount" + :page-count="pageCount" + @generate="askGenerate" + @import="importOpen = true" + @add="startAdd" + /> + + <FaqGenerationProgress v-else-if="phase === 'generating' && job" :job="job" /> + + <template v-else> + <div v-if="isNewFaq" class="faq-list faq-list--new"> + <FaqCard + :faq="newFaqStub" + :editing="true" + :is-new="true" + :saving="isSaving" + :categories="categories" + v-model:draft-question="draftQuestion" + v-model:draft-answer="draftAnswer" + v-model:draft-category="draftCategory" + v-model:draft-slug="draftSlug" + v-model:draft-url-path="draftUrlPath" + v-model:draft-meta-title="draftMetaTitle" + v-model:draft-meta-description="draftMetaDescription" + @save="saveEdit" + @cancel="cancelEdit" + /> + </div> + + <!-- Search + filter toolbar (only once there are FAQs to filter) --> + <template v-if="faqs.length"> + <div class="faq-toolbar"> + <div class="faq-search"> + <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.3-4.3" /></svg> + <input v-model="searchQuery" type="search" placeholder="搜索问题与答案…" aria-label="搜索常见问题" /> + </div> + <select v-model="categoryFilter" class="faq-select" aria-label="按分类筛选"> + <option :value="null">全部分类</option> + <option v-for="c in categories" :key="c" :value="c">{{ c }}</option> + </select> + <div class="faq-status-filter" role="group" aria-label="按状态筛选"> + <button + v-for="opt in STATUS_FILTERS" + :key="opt.v" + class="faq-status-filter__btn" + :class="{ 'faq-status-filter__btn--active': statusFilter === opt.v }" + type="button" + @click="statusFilter = opt.v" + >{{ opt.l }}</button> + </div> + </div> + + <div v-if="hasActiveFilters" class="faq-resultcount">显示 {{ filteredCount }} / {{ faqs.length }} 条</div> + </template> + + <div v-if="faqs.length && groupedFaqs.size === 0" class="faq-noresults"> + <p>没有符合筛选条件的常见问题。</p> + <button v-if="hasActiveFilters" class="faq-noresults__clear" type="button" @click="resetFilters">清除筛选</button> + </div> + + <div v-else-if="groupedFaqs.size" class="faq-list"> + <FaqCategoryGroup + v-for="[category, items] in groupedFaqs" + :key="category" + :category="category" + :count="items.length" + :published-count="publishedInCategory(items)" + :selected-count="selectedInCategory(items)" + :open="isCategoryOpen(category)" + @toggle="toggleCategory(category)" + @toggle-all="setSelected(items, $event)" + > + <FaqCard + v-for="faq in items" + :key="faq.id" + :faq="faq" + :editing="editingId === faq.id" + :saving="isSaving" + :selectable="selectionActive" + :selected="selectedIds.has(faq.id)" + :categories="categories" + :draft-question="draftQuestion" + :draft-answer="draftAnswer" + :draft-category="draftCategory" + :draft-slug="draftSlug" + :draft-url-path="draftUrlPath" + :draft-meta-title="draftMetaTitle" + :draft-meta-description="draftMetaDescription" + @update:draft-question="draftQuestion = $event" + @update:draft-answer="draftAnswer = $event" + @update:draft-category="draftCategory = $event" + @update:draft-slug="draftSlug = $event" + @update:draft-url-path="draftUrlPath = $event" + @update:draft-meta-title="draftMetaTitle = $event" + @update:draft-meta-description="draftMetaDescription = $event" + @toggle-select="toggleSelect(faq.id)" + @toggle-status="togglePublish(faq)" + @edit="startEdit(faq)" + @delete="askDelete(faq)" + @save="saveEdit" + @cancel="cancelEdit" + /> + </FaqCategoryGroup> + </div> + </template> + </div> + + <div v-show="tab === 'settings'" class="tab-panel"> + <template v-if="settings"> + <HelpCenterAppearance + class="settings-section" + :settings="settings" + :save-state="saveState" + @update="queueSave" + @save-now="saveNow" + @upload-logo="uploadLogo" + @remove-logo="removeLogo" + @upload-favicon="uploadFavicon" + @remove-favicon="removeFavicon" + /> + <HelpCenterPublic + class="settings-section" + :settings="settings" + :domain-busy="domainBusy" + @toggle-enabled="saveNow({ enabled: $event })" + @update-ai="saveNow($event)" + @set-domain="setDomain" + @verify-domain="verifyDomain" + @remove-domain="removeDomain" + /> + </template> + <div v-else class="loading">正在加载设置…</div> + </div> + </template> + + <FaqImportModal :open="importOpen" :submitting="importSubmitting" @close="importOpen = false" @submit="onImportSubmit" @submit-pdf="onPdfImportSubmit" /> + + <FaqSourcePicker + :open="sourcePickerOpen" + :estimate="estimate" + :submitting="generating" + @close="sourcePickerOpen = false" + @generate="onPickSources" + /> + + <div v-if="selectionActive" class="bulkbar" role="toolbar" aria-label="批量操作"> + <span class="bulkbar__count">已选 {{ selectedIds.size }} 条</span> + <button class="bulkbar__btn" type="button" @click="bulkSetStatus('published')">发布</button> + <button class="bulkbar__btn" type="button" @click="bulkSetStatus('draft')">取消发布</button> + <button class="bulkbar__btn bulkbar__btn--danger" type="button" @click="askBulkDelete">删除</button> + <button class="bulkbar__clear" type="button" aria-label="清除选择" @click="clearSelection">✕</button> + </div> + + <div v-if="confirmState" class="confirm" role="dialog" aria-modal="true"> + <div class="confirm__card"> + <h3 class="confirm__title">{{ confirmState.title }}</h3> + <p class="confirm__msg">{{ confirmState.message }}</p> + <p v-if="confirmState.disabledReason" class="confirm__blocked">{{ confirmState.disabledReason }}</p> + <div class="confirm__actions"> + <button class="confirm__cancel" type="button" :disabled="confirmBusy" @click="confirmState = null">取消</button> + <button + class="confirm__go" + :class="confirmState.intent === 'primary' ? 'confirm__go--primary' : 'confirm__go--danger'" + type="button" + :disabled="confirmBusy || !!confirmState.disabledReason" + @click="runConfirm" + > + {{ confirmBusy ? confirmState.busyLabel : confirmState.actionLabel }} + </button> + </div> + </div> + </div> + + </div> +</template> + +<style scoped> +.faq-workspace { + display: flex; + flex-direction: column; +} + +.loading { + padding: 48px 0; + text-align: center; + font-size: 14px; + color: var(--muted); +} + +.tabs { + display: flex; + gap: 4px; + border-bottom: 1px solid var(--o08); + margin-bottom: 20px; +} + +.tab { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + color: var(--muted); + font-family: var(--font-sans); + font-size: 14px; + font-weight: 600; + cursor: pointer; +} + +.tab:hover { + color: var(--text); +} + +.tab--active { + color: var(--text); + border-bottom-color: var(--c-teal); +} + +.tab__count { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + padding: 1px 7px; + border-radius: var(--radius-pill); + background: var(--o08); + color: var(--muted2); +} + +.tab--active .tab__count { + background: var(--teal-bg); + color: var(--c-teal); +} + +.workspace-bar { + margin-bottom: 16px; +} + +.faq-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.faq-list--new { + margin-bottom: 16px; +} + +.faq-toolbar { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 18px; + flex-wrap: wrap; +} + +.faq-search { + display: flex; + align-items: center; + gap: 9px; + flex: 1; + min-width: 200px; + padding: 0 12px; + background: var(--surface); + border: 1px solid var(--o12); + border-radius: 11px; + color: var(--muted); +} + +.faq-search input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--text); + font-family: var(--font-sans); + font-size: 13.5px; + padding: 10px 0; +} + +.faq-select { + padding: 10px 12px; + background: var(--surface); + border: 1px solid var(--o12); + border-radius: 11px; + color: var(--text2); + font-family: var(--font-sans); + font-size: 13.5px; + font-weight: 500; + cursor: pointer; +} + +.faq-status-filter { + display: inline-flex; + padding: 3px; + background: var(--o05); + border: 1px solid var(--o12); + border-radius: 11px; +} + +.faq-status-filter__btn { + padding: 7px 13px; + background: transparent; + border: none; + border-radius: 8px; + color: var(--muted); + font-family: var(--font-sans); + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.faq-status-filter__btn--active { + background: var(--surface); + color: var(--text2); + box-shadow: 0 1px 3px var(--scrim); +} + +.faq-resultcount { + margin: -8px 0 14px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--muted2); +} + +.faq-noresults { + padding: 40px 0; + text-align: center; + color: var(--muted); + font-size: 14px; +} + +.faq-noresults__clear { + margin-top: 12px; + padding: 8px 16px; + background: var(--o05); + border: 1px solid var(--o12); + border-radius: 10px; + color: var(--text2); + font-family: var(--font-sans); + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.settings-section:not(:first-child) { + margin-top: 36px; +} + +.bulkbar { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + z-index: 90; + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + background: var(--surface); + border: 1px solid var(--o14); + border-radius: var(--radius-pill); + box-shadow: 0 8px 30px var(--scrim); +} + +.bulkbar__count { + font-family: var(--font-mono); + font-size: 12px; + color: var(--muted); + padding: 0 6px; +} + +.bulkbar__btn { + padding: 8px 14px; + background: var(--o05); + border: 1px solid var(--o12); + border-radius: var(--radius-pill); + color: var(--text2); + font-family: var(--font-sans); + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.bulkbar__btn:hover { + background: var(--o08); +} + +.bulkbar__btn--danger { + background: var(--coral-bg); + border-color: var(--coral-border); + color: var(--c-coral); +} + +.bulkbar__clear { + width: 30px; + height: 30px; + background: transparent; + border: none; + border-radius: 50%; + color: var(--muted); + font-size: 13px; + cursor: pointer; +} + +.bulkbar__clear:hover { + background: var(--o08); + color: var(--text); +} + +.confirm { + position: fixed; + inset: 0; + z-index: 100; + background: var(--scrim); + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +.confirm__card { + background: var(--surface); + border: 1px solid var(--o12); + border-radius: 16px; + padding: 24px; + max-width: 400px; + width: 100%; +} + +.confirm__title { + font-family: var(--font-display); + font-weight: 700; + font-size: 18px; + color: var(--text); + margin: 0 0 8px; +} + +.confirm__msg { + font-size: 14px; + color: var(--muted); + line-height: 1.55; + margin: 0 0 20px; +} + +.confirm__actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.confirm__cancel { + padding: 10px 16px; + background: transparent; + border: 1px solid var(--o12); + border-radius: 10px; + color: var(--muted); + font-family: var(--font-sans); + font-size: 13.5px; + font-weight: 500; + cursor: pointer; +} + +.confirm__go { + padding: 10px 18px; + border-radius: 10px; + font-family: var(--font-sans); + font-size: 13.5px; + font-weight: 600; + cursor: pointer; +} + +.confirm__go:disabled { + opacity: 0.55; + cursor: default; +} + +.confirm__go--danger { + background: var(--coral-bg); + border: 1px solid var(--coral-border); + color: var(--c-coral); +} + +.confirm__go--primary { + background: var(--purple-bg); + border: 1px solid var(--purple-border); + color: var(--c-purple); +} + +.confirm__blocked { + font-size: 13px; + color: var(--c-coral); + background: var(--coral-bg); + border: 1px solid var(--coral-border); + border-radius: 10px; + padding: 10px 12px; + margin: -8px 0 20px; +} + +/* ── Mobile ───────────────────────────────────────────────────────────── + The toolbar wrapped into a ragged column: the search box stretched but the + select and the status group sat at content width, so the select's arrow + landed mid-row instead of at the edge. Each control takes the full row. */ +@media (max-width: 768px) { + .faq-toolbar { + gap: 8px; + } + + .faq-search, + .faq-select, + .faq-status-filter { + flex: 1 0 100%; + min-width: 0; + /* 44px minimum touch target (Apple HIG / WCAG 2.5.8) */ + min-height: 44px; + } + + /* 16px keeps iOS from zooming the page when either field takes focus */ + .faq-search input, + .faq-select { + font-size: 16px; + } + + .faq-select { + width: 100%; + padding-right: 12px; + } + + .faq-status-filter__btn { + flex: 1; + min-height: 38px; + font-size: 14px; + } +} +</style> diff --git a/frontend/src/components/faq/HelpCenterAppearance.vue b/frontend/src/components/faq/HelpCenterAppearance.vue new file mode 100644 index 0000000..141c351 --- /dev/null +++ b/frontend/src/components/faq/HelpCenterAppearance.vue @@ -0,0 +1,592 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed, ref, watch } from 'vue' +import { toast } from 'vue-sonner' +import type { HelpCenterSettings, HelpCenterSettingsUpdate } from '@/types/faq' +import type { SaveState } from '@/composables/useHelpCenterSettings' +import HelpCenterLogoField from './HelpCenterLogoField.vue' +import HelpCenterPreview from './HelpCenterPreview.vue' + +const props = defineProps<{ + settings: HelpCenterSettings + saveState: SaveState +}>() + +const emit = defineEmits<{ + update: [payload: HelpCenterSettingsUpdate] + 'save-now': [payload: HelpCenterSettingsUpdate] + 'upload-logo': [file: File] + 'remove-logo': [] + 'upload-favicon': [file: File] + 'remove-favicon': [] +}>() + +const BRAND_SWATCHES = ['#4338CA', '#0E8C8C', '#CF5B38', '#6D5BD0', '#1F8A5B', '#2A6FDB'] + +// Custom brand color: native picker + hex field, both saving immediately. +const isPreset = computed(() => + BRAND_SWATCHES.some((c) => c.toLowerCase() === (props.settings.brand_color || '').toLowerCase()), +) + +const hexDraft = ref(props.settings.brand_color) +watch(() => props.settings.brand_color, (v) => { hexDraft.value = v }) + +// The native <input type=color> only accepts #rrggbb — expand 3-digit and drop +// alpha from 8-digit so opening the picker starts on the real color, never +// silently resetting to black. +const pickerValue = computed(() => { + const body = (props.settings.brand_color || '').replace(/^#/, '') + if (body.length === 3) return `#${[...body].map((c) => c + c).join('')}` + if (body.length >= 6) return `#${body.slice(0, 6)}` + return '#000000' +}) + +function saveColor(value: string) { + if (value.toLowerCase() !== (props.settings.brand_color || '').toLowerCase()) { + emit('save-now', { brand_color: value }) + } +} + +function onPicker(event: Event) { + saveColor((event.target as HTMLInputElement).value.toUpperCase()) +} + +function commitHex() { + const raw = hexDraft.value.trim() + const body = raw.replace(/^#/, '') + // Accept 3, 6 or 8 hex digits (matches the backend validator). + if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$|^[0-9a-fA-F]{8}$/.test(body)) { + toast.error('请输入类似 #4338CA 的十六进制颜色') + hexDraft.value = props.settings.brand_color + return + } + const hex = `#${body.toUpperCase()}` + hexDraft.value = hex + saveColor(hex) +} + +const saveStateLabel = computed(() => { + if (props.saveState === 'saving') return '保存中…' + if (props.saveState === 'saved') return '已保存' + if (props.saveState === 'error') return '保存失败,再次编辑可重试' + return '' +}) + +/** Text fields: mutate in place for instant feedback, emit for debounced save. */ +function onText(field: 'cta_text' | 'cta_url', event: Event) { + const value = (event.target as HTMLInputElement).value + props.settings[field] = value + emit('update', { [field]: value }) +} + +// Header link rows are mutated in place — the settings engine deep-watches +// header_links and debounce-saves. +function addLink() { + props.settings.header_links.push({ label: '新链接', url: '' }) +} + +function removeLink(index: number) { + props.settings.header_links.splice(index, 1) +} +</script> + +<template> + <section class="appearance"> + <div class="section-head"> + <h2 class="section-head__title">帮助中心外观</h2> + <span class="section-head__sub">Logo、颜色与页头链接</span> + <span v-if="saveStateLabel" class="section-head__save" :class="`is-${saveState}`">{{ saveStateLabel }}</span> + </div> + + <div class="appearance__card"> + <!-- controls --> + <div class="controls"> + <div> + <label class="mono-label">Logo</label> + <HelpCenterLogoField + :image-url="settings.logo_url" + kind="logo" + @upload="$emit('upload-logo', $event)" + @remove="$emit('remove-logo')" + /> + </div> + + <div> + <label class="mono-label">Favicon</label> + <HelpCenterLogoField + :image-url="settings.favicon_url" + kind="favicon" + :max-bytes="1048576" + :output-max="128" + square + @upload="$emit('upload-favicon', $event)" + @remove="$emit('remove-favicon')" + /> + </div> + + <div> + <label class="mono-label">品牌色</label> + <div class="swatches"> + <button + v-for="color in BRAND_SWATCHES" + :key="color" + class="swatch" + :class="{ 'swatch--selected': settings.brand_color.toLowerCase() === color.toLowerCase() }" + :style="{ background: color, boxShadow: settings.brand_color.toLowerCase() === color.toLowerCase() ? `0 0 0 2px ${color}` : 'none' }" + type="button" + :title="color" + @click="$emit('save-now', { brand_color: color })" + ></button> + + <!-- Custom color: swatch opens the OS picker; shows current color when off-preset. --> + <label + class="swatch swatch--custom" + :class="{ 'swatch--selected': !isPreset }" + :style="!isPreset ? { background: settings.brand_color, boxShadow: `0 0 0 2px ${settings.brand_color}` } : {}" + title="自定义颜色" + > + <input type="color" class="swatch__picker" :value="pickerValue" @change="onPicker" /> + <svg v-if="isPreset" viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14" /></svg> + </label> + </div> + + <div class="hex-field"> + <span class="hex-field__hash">#</span> + <input + :value="hexDraft.replace(/^#/, '')" + maxlength="8" + spellcheck="false" + placeholder="4338CA" + aria-label="品牌色十六进制色值" + @input="hexDraft = ($event.target as HTMLInputElement).value" + @keydown.enter="commitHex" + @blur="commitHex" + /> + </div> + + <p class="hint">用于重新着色帮助中心的按钮、链接、搜索框和高亮元素。</p> + </div> + + <div> + <label class="mono-label">页头链接</label> + <p class="hint hint--tight">显示在帮助中心页头的链接——可指向你的官网。</p> + <div class="link-rows"> + <div v-for="(link, index) in settings.header_links" :key="index" class="link-row"> + <input v-model="link.label" class="text-input text-input--label" type="text" placeholder="名称" /> + <div class="url-input"> + <span class="url-input__prefix">https://</span> + <input v-model="link.url" type="text" placeholder="yoursite.com" /> + </div> + <button class="remove-btn" type="button" title="移除" @click="removeLink(index)"> + <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12" /></svg> + </button> + </div> + </div> + <button class="add-link" type="button" @click="addLink"> + <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14" /></svg> + 添加链接 + </button> + + <div class="divider"></div> + + <div class="row-head"> + <label class="mono-label mono-label--inline">主按钮</label> + <label class="switch switch--sm"> + <input type="checkbox" :checked="settings.cta_enabled" @change="$emit('save-now', { cta_enabled: ($event.target as HTMLInputElement).checked })" /> + <span class="switch__track"><span class="switch__knob"></span></span> + </label> + </div> + <div class="link-row" :class="{ 'link-row--off': !settings.cta_enabled }"> + <input class="text-input text-input--label" type="text" placeholder="名称" :disabled="!settings.cta_enabled" :value="settings.cta_text || ''" @input="onText('cta_text', $event)" /> + <div class="url-input"> + <span class="url-input__prefix">https://</span> + <input type="text" placeholder="app.yoursite.com" :disabled="!settings.cta_enabled" :value="settings.cta_url || ''" @input="onText('cta_url', $event)" /> + </div> + </div> + </div> + </div> + + <!-- live preview --> + <div class="preview-col"> + <label class="mono-label">实时预览</label> + <HelpCenterPreview :settings="settings" /> + <p class="preview-note"> + <span class="preview-note__url">{{ settings.live_url }}</span> + 上的修改会立即应用到已发布的帮助中心。 + </p> + </div> + </div> + </section> +</template> + +<style scoped> +.section-head { + display: flex; + align-items: baseline; + gap: 12px; + margin-bottom: 16px; +} + +.section-head__title { + font-family: var(--font-display); + font-weight: 600; + font-size: 19px; + letter-spacing: -0.01em; + color: var(--text); + margin: 0; +} + +.section-head__sub, +.section-head__save { + font-family: var(--font-mono); + font-size: 12px; + color: var(--muted2); +} + +.section-head__save { + margin-left: auto; +} + +.section-head__save.is-saved { + color: var(--c-teal); +} + +.section-head__save.is-error { + color: var(--c-coral); +} + +.appearance__card { + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 18px; + padding: 22px; + display: grid; + grid-template-columns: 1fr 1.05fr; + gap: 26px; + align-items: start; +} + +@media (max-width: 900px) { + .appearance__card { + grid-template-columns: 1fr; + } +} + +.controls { + display: flex; + flex-direction: column; + gap: 24px; + min-width: 0; +} + +.mono-label { + display: block; + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.06em; + color: var(--muted2); + margin-bottom: 10px; +} + +.swatches { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.swatch { + width: 36px; + height: 36px; + border-radius: 10px; + cursor: pointer; + padding: 0; + border: 2px solid transparent; +} + +.swatch--selected { + border-color: var(--text); +} + +.swatch--custom { + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; + background: var(--o05); + border: 2px dashed var(--o25); + color: var(--muted); +} + +.swatch--custom.swatch--selected { + border-style: solid; + color: #fff; +} + +/* The native color input fills the swatch but stays invisible — the label is + the visible control and clicking anywhere opens the OS picker. */ +.swatch__picker { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + border: none; + padding: 0; + cursor: pointer; +} + +.hex-field { + display: inline-flex; + align-items: center; + margin-top: 12px; + background: var(--bg); + border: 1px solid var(--o10); + border-radius: 9px; + padding: 0 11px; + font-family: var(--font-mono); +} + +.hex-field__hash { + color: var(--faint); + font-size: 13px; +} + +.hex-field input { + width: 84px; + background: transparent; + border: none; + outline: none; + color: var(--text); + font-family: var(--font-mono); + font-size: 13px; + text-transform: uppercase; + padding: 9px 2px 9px 4px; +} + +.hint { + font-size: 12.5px; + color: var(--muted); + margin: 11px 0 0; + line-height: 1.5; +} + +.hint--tight { + margin: 0 0 12px; +} + +.link-rows { + display: flex; + flex-direction: column; + gap: 9px; + margin-bottom: 12px; +} + +.link-row { + display: flex; + align-items: center; + gap: 8px; +} + +.text-input { + background: var(--bg); + border: 1px solid var(--o10); + border-radius: 9px; + padding: 9px 11px; + font-size: 13px; + font-family: var(--font-sans); + color: var(--text); + outline: none; +} + +.text-input--label { + width: 112px; + flex-shrink: 0; +} + +.url-input { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + background: var(--bg); + border: 1px solid var(--o10); + border-radius: 9px; + padding: 0 11px; + font-family: var(--font-mono); +} + +.url-input__prefix { + color: var(--faint); + font-size: 12.5px; +} + +.url-input input { + flex: 1; + min-width: 0; + background: transparent; + border: none; + outline: none; + color: var(--text); + font-size: 12.5px; + padding: 9px 3px; + font-family: var(--font-mono); +} + +.remove-btn { + width: 32px; + height: 32px; + flex-shrink: 0; + border-radius: 8px; + background: var(--o05); + border: 1px solid var(--o10); + color: var(--muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.remove-btn:hover { + background: var(--coral-bg); + color: var(--c-coral); + border-color: var(--coral-border); +} + +.add-link { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 9px 14px; + background: var(--o05); + border: 1px dashed var(--o16); + border-radius: 9px; + color: var(--text2); + font-family: var(--font-sans); + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.add-link:hover { + background: var(--o08); + border-color: var(--o25); +} + +.divider { + height: 1px; + background: var(--o07); + margin: 18px 0; +} + +.row-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.mono-label--inline { + margin-bottom: 0; +} + +.link-row--off { + opacity: 0.5; +} + +.link-row--off .text-input, +.link-row--off .url-input { + cursor: not-allowed; +} + +/* Toggle switch (scoped copy of the shared switch styling). */ +.switch { + position: relative; + display: inline-flex; + align-items: center; + cursor: pointer; +} + +.switch input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.switch__track { + position: relative; + flex-shrink: 0; + width: 38px; + height: 22px; + border-radius: var(--radius-pill); + background: var(--o12); + transition: background 0.18s ease; +} + +.switch__knob { + position: absolute; + top: 3px; + left: 3px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; + transition: transform 0.18s ease; +} + +.switch input:checked + .switch__track { + background: var(--c-teal); +} + +.switch input:checked + .switch__track .switch__knob { + transform: translateX(16px); +} + +.switch--sm .switch__track { + width: 34px; + height: 20px; +} + +.switch--sm .switch__knob { + width: 14px; + height: 14px; +} + +.switch--sm input:checked + .switch__track .switch__knob { + transform: translateX(14px); +} + +.preview-col { + min-width: 0; +} + +.preview-note { + font-size: 12px; + color: var(--muted2); + margin: 11px 0 0; + line-height: 1.5; +} + +.preview-note__url { + font-family: var(--font-mono); + color: var(--text3); +} +</style> diff --git a/frontend/src/components/faq/HelpCenterDomainPanel.vue b/frontend/src/components/faq/HelpCenterDomainPanel.vue new file mode 100644 index 0000000..8b98490 --- /dev/null +++ b/frontend/src/components/faq/HelpCenterDomainPanel.vue @@ -0,0 +1,503 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed, ref, watch } from 'vue' +import { toast } from 'vue-sonner' +import type { HelpCenterDomain } from '@/types/faq' + +const props = defineProps<{ + domain: HelpCenterDomain | null + busy: boolean +}>() + +const emit = defineEmits<{ + 'set-domain': [domain: string] + 'verify-domain': [] + 'remove-domain': [] +}>() + +const domainInput = ref(props.domain?.custom_domain || '') + +watch( + () => props.domain?.custom_domain, + (value) => { + domainInput.value = value || '' + }, +) + +const currentDomain = computed(() => props.domain?.custom_domain || '') +const normalizedInput = computed(() => domainInput.value.trim().replace(/^https?:\/\//i, '')) +const isVerified = computed(() => currentDomain.value !== '' && props.domain?.domain_status === 'verified') + +// empty → enter a domain · pending → add DNS records + verify · connected → live +const phase = computed<'empty' | 'pending' | 'connected'>(() => { + if (!currentDomain.value) return 'empty' + return isVerified.value ? 'connected' : 'pending' +}) + +const statusPill = computed(() => { + if (phase.value === 'connected') return { label: '已连接', cls: 'status-pill--teal' } + if (phase.value === 'pending') return { label: '待验证', cls: 'status-pill--warn' } + return { label: '未配置', cls: 'status-pill--idle' } +}) + +const sslPill = computed(() => { + const status = props.domain?.ssl_status + if (status === 'active') return { label: '已启用', cls: 'record-pill--teal' } + if (status === 'failed') return { label: '失败', cls: 'record-pill--coral' } + return { label: '签发中', cls: 'record-pill--idle' } +}) + +const liveUrl = computed(() => `https://${currentDomain.value}`) + +function addDomain() { + if (!normalizedInput.value || props.busy) return + emit('set-domain', normalizedInput.value) +} + +async function copy(text: string) { + try { + await navigator.clipboard.writeText(text) + toast.success('已复制') + } catch { + toast.error('无法复制,请手动选择并复制') + } +} +</script> + +<template> + <div> + <div class="domain-head"> + <h3 class="domain-head__title">自定义域名</h3> + <span class="status-pill" :class="statusPill.cls"> + <span class="status-pill__dot"></span> + {{ statusPill.label }} + </span> + </div> + + <!-- Step 1: enter a domain --> + <template v-if="phase === 'empty'"> + <p class="domain-copy"> + 用你自己的子域名提供帮助中心,替代 ChatterMate 的 URL。在下方输入域名,我们会显示需要添加的两条 DNS 记录。 + </p> + <div class="domain-form"> + <div class="domain-input"> + <span class="domain-input__prefix">https://</span> + <input + v-model="domainInput" + type="text" + placeholder="help.yourcompany.com" + @keydown.enter="addDomain" + /> + </div> + <button class="btn-primary" type="button" :disabled="busy || !normalizedInput" @click="addDomain"> + {{ busy ? '处理中…' : '添加域名' }} + </button> + </div> + </template> + + <!-- Step 2: add the DNS records, then verify --> + <template v-else> + <div class="domain-current"> + <div class="domain-current__lead"> + <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="M3 12h18" /><path d="M12 3a15 15 0 0 1 0 18a15 15 0 0 1 0-18z" /></svg> + <a v-if="phase === 'connected'" :href="liveUrl" target="_blank" rel="noopener" class="domain-current__name domain-current__name--link">{{ currentDomain }}</a> + <span v-else class="domain-current__name">{{ currentDomain }}</span> + </div> + <button class="link-btn" type="button" :disabled="busy" @click="$emit('remove-domain')">移除</button> + </div> + + <template v-if="phase === 'pending'"> + <ol class="steps"> + <li>在你的 DNS 服务商处添加这两条记录。</li> + <li>然后返回并点击 <strong>验证域名</strong>——DNS 变更可能需要几分钟生效。</li> + </ol> + + <div class="records"> + <div v-for="record in domain?.records || []" :key="`${record.type}-${record.host}`" class="record" :class="{ 'record--ok': record.verified }"> + <div class="record__top"> + <span class="record__type">{{ record.type }}</span> + <span class="record-pill" :class="record.verified ? 'record-pill--teal' : 'record-pill--warn'"> + {{ record.verified ? '已检测到' : '等待中' }} + </span> + </div> + <div class="record__field"> + <span class="record__label">名称 / 主机</span> + <div class="record__value"> + <code>{{ record.host }}</code> + <button class="copy-btn" type="button" title="复制" @click="copy(record.host)"> + <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" /></svg> + </button> + </div> + </div> + <div class="record__field"> + <span class="record__label">值</span> + <div class="record__value"> + <code>{{ record.value }}</code> + <button class="copy-btn" type="button" title="复制" @click="copy(record.value)"> + <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" /></svg> + </button> + </div> + </div> + </div> + </div> + + <div class="verify-row"> + <button class="btn-primary" type="button" :disabled="busy" @click="$emit('verify-domain')"> + {{ busy ? '检查中…' : '验证域名' }} + </button> + <span class="ssl-note"> + <span class="record-pill" :class="sslPill.cls">SSL {{ sslPill.label }}</span> + 验证通过后自动签发 + </span> + </div> + </template> + + <template v-else> + <p class="domain-copy domain-copy--ok"> + 你的帮助中心已上线:<a :href="liveUrl" target="_blank" rel="noopener">{{ currentDomain }}</a>。 + <span class="record-pill" :class="sslPill.cls">SSL {{ sslPill.label }}</span> + </p> + </template> + </template> + </div> +</template> + +<style scoped> +.domain-head { + display: flex; + align-items: center; + gap: 10px; +} + +.domain-head__title { + font-family: var(--font-display); + font-weight: 600; + font-size: 16px; + color: var(--text); + margin: 0; +} + +.domain-copy { + font-size: 13.5px; + color: var(--muted); + max-width: 560px; + line-height: 1.55; + margin: 6px 0 16px; +} + +.domain-copy--ok { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.domain-copy a { + color: var(--c-teal); + font-weight: 600; +} + +.status-pill { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 6px 12px; + border-radius: var(--radius-pill); + font-size: 12.5px; + font-weight: 600; +} + +.status-pill__dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; +} + +.status-pill--teal { + background: var(--teal-bg); + border: 1px solid var(--teal-border); + color: var(--c-teal); +} + +.status-pill--warn { + background: var(--warning-bg); + border: 1px solid color-mix(in srgb, var(--c-warn) 30%, transparent); + color: var(--c-warn); +} + +.status-pill--idle { + background: var(--pill-idle-bg); + border: 1px solid var(--o12); + color: var(--pill-idle-fg); +} + +.domain-form { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.domain-input { + flex: 1; + min-width: 240px; + display: flex; + align-items: center; + background: var(--bg); + border: 1px solid var(--o10); + border-radius: 11px; + padding: 0 14px; + font-family: var(--font-mono); +} + +.domain-input__prefix { + color: var(--faint); + font-size: 13.5px; +} + +.domain-input input { + flex: 1; + min-width: 0; + background: transparent; + border: none; + outline: none; + color: var(--text); + font-size: 13.5px; + padding: 13px 4px; + font-family: var(--font-mono); +} + +.btn-primary { + padding: 0 20px; + min-height: 44px; + background: var(--accent-solid); + border: none; + border-radius: 11px; + color: var(--on-accent-solid); + font-family: var(--font-sans); + font-size: 14px; + font-weight: 600; + cursor: pointer; +} + +.btn-primary:disabled { + opacity: 0.65; + cursor: default; +} + +/* current domain header */ +.domain-current { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + margin: 6px 0 16px; + background: var(--bg); + border: 1px solid var(--o08); + border-radius: 11px; +} + +.domain-current__lead { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + color: var(--muted); +} + +.domain-current__name { + font-family: var(--font-mono); + font-size: 14px; + font-weight: 600; + color: var(--text2); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.domain-current__name--link { + color: var(--c-teal); + text-decoration: none; +} + +.link-btn { + flex-shrink: 0; + background: transparent; + border: none; + color: var(--muted); + font-family: var(--font-sans); + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.link-btn:hover { + color: var(--c-coral); +} + +.link-btn:disabled { + opacity: 0.5; + cursor: default; +} + +/* steps + records */ +.steps { + margin: 0 0 14px; + padding-left: 20px; + font-size: 13.5px; + color: var(--muted); + line-height: 1.7; +} + +.steps strong { + color: var(--text2); +} + +.records { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 10px; + margin-bottom: 18px; +} + +.record { + background: var(--bg); + border: 1px solid var(--o08); + border-radius: 12px; + padding: 13px 14px; +} + +.record--ok { + border-color: var(--teal-border); +} + +.record__top { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} + +.record__type { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--c-purple); +} + +.record__field { + margin-top: 8px; +} + +.record__label { + display: block; + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.06em; + color: var(--muted2); + margin-bottom: 4px; +} + +.record__value { + display: flex; + align-items: center; + gap: 8px; + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 8px; + padding: 7px 9px; +} + +.record__value code { + flex: 1; + min-width: 0; + font-family: var(--font-mono); + font-size: 12.5px; + color: var(--text3); + overflow-x: auto; + white-space: nowrap; +} + +.copy-btn { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + background: var(--o05); + border: 1px solid var(--o12); + border-radius: 7px; + color: var(--muted); + cursor: pointer; +} + +.copy-btn:hover { + background: var(--o08); + color: var(--text2); +} + +.verify-row { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} + +.ssl-note { + font-size: 12.5px; + color: var(--muted); + display: inline-flex; + align-items: center; + gap: 8px; +} + +.record-pill { + display: inline-block; + padding: 3px 9px; + border-radius: 7px; + font-size: 11px; + font-weight: 600; +} + +.record-pill--teal { + background: var(--teal-bg); + border: 1px solid var(--teal-border); + color: var(--c-teal); +} + +.record-pill--warn { + background: var(--warning-bg); + border: 1px solid color-mix(in srgb, var(--c-warn) 30%, transparent); + color: var(--c-warn); +} + +.record-pill--coral { + background: var(--coral-bg); + border: 1px solid var(--coral-border); + color: var(--c-coral); +} + +.record-pill--idle { + background: var(--o05); + border: 1px solid var(--o12); + color: var(--muted); +} +</style> diff --git a/frontend/src/components/faq/HelpCenterLogoField.vue b/frontend/src/components/faq/HelpCenterLogoField.vue new file mode 100644 index 0000000..7296643 --- /dev/null +++ b/frontend/src/components/faq/HelpCenterLogoField.vue @@ -0,0 +1,362 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed, ref } from 'vue' +import { Cropper } from 'vue-advanced-cropper' +import 'vue-advanced-cropper/dist/style.css' + +// Generic image field (logo or favicon): file pick → square-optional crop → +// PNG export. Configurable size caps and crop aspect so one component serves both. +const props = withDefaults( + defineProps<{ + imageUrl: string | null + kind?: 'logo' | 'favicon' + maxBytes?: number + outputMax?: number + square?: boolean + }>(), + { kind: 'logo', maxBytes: 2 * 1024 * 1024, outputMax: 512, square: false }, +) + +const emit = defineEmits<{ + upload: [file: File] + remove: [] +}>() + +// Raster only — SVG is rejected (it would be active markup on the public site). +const ACCEPT = 'image/png,image/jpeg,image/webp' + +const maxMbLabel = computed(() => `${Math.round(props.maxBytes / (1024 * 1024))} MB`) +const stencilProps = computed(() => (props.square ? { aspectRatio: 1 } : {})) + +const fileInput = ref<HTMLInputElement | null>(null) +const error = ref('') + +const showCropper = ref(false) +const cropperImage = ref('') +const cropper = ref<any>(null) +const saving = ref(false) + +const imageName = computed(() => { + if (!props.imageUrl) return '' + return props.imageUrl.split('/').pop()?.split('?')[0] || props.kind +}) + +function pick() { + error.value = '' + fileInput.value?.click() +} + +function onFileChange(event: Event) { + const input = event.target as HTMLInputElement + const file = input.files?.[0] + input.value = '' + if (!file) return + if (!/^image\/(png|jpe?g|webp)$/.test(file.type)) { + error.value = '请使用 PNG、JPG 或 WebP 图片。' + return + } + if (file.size > props.maxBytes) { + error.value = `图片不能超过 ${maxMbLabel.value}。` + return + } + error.value = '' + cropperImage.value = URL.createObjectURL(file) + showCropper.value = true +} + +async function confirmCrop() { + if (!cropper.value || saving.value) return + saving.value = true + try { + const { canvas } = cropper.value.getResult() + if (!canvas) throw new Error('no canvas') + const blob: Blob = await new Promise((resolve, reject) => { + canvas.toBlob((b: Blob | null) => (b ? resolve(b) : reject(new Error('blob'))), 'image/png') + }) + emit('upload', new File([blob], `${props.kind}.png`, { type: 'image/png' })) + closeCropper() + } catch { + error.value = '无法处理该图片,请换一张试试。' + } finally { + saving.value = false + } +} + +function closeCropper() { + showCropper.value = false + if (cropperImage.value) URL.revokeObjectURL(cropperImage.value) + cropperImage.value = '' +} +</script> + +<template> + <div> + <input ref="fileInput" class="file-input" type="file" :accept="ACCEPT" @change="onFileChange" /> + <div v-if="imageUrl" class="logo-row"> + <div class="logo-chip"> + <img class="logo-chip__img" :src="imageUrl" :alt="kind" /> + <div class="logo-chip__name">{{ imageName }}</div> + </div> + <button class="btn-sm" type="button" @click="pick">替换</button> + <button class="icon-btn" type="button" :title="`移除 ${kind === 'logo' ? 'Logo' : 'Favicon'}`" @click="$emit('remove')"> + <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16" /><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" /><path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" /></svg> + </button> + </div> + <button v-else class="upload-btn" type="button" @click="pick"> + <span class="upload-btn__icon"> + <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4" /><path d="M8 8l4-4 4 4" /><path d="M4 20h16" /></svg> + </span> + <span> + <span class="upload-btn__title">上传 {{ kind === 'logo' ? 'Logo' : 'Favicon' }}</span> + <span class="upload-btn__sub">PNG、JPG 或 WebP,最大 {{ maxMbLabel }} · 可裁剪适配</span> + </span> + </button> + + <p v-if="error" class="logo-error">{{ error }}</p> + + <!-- Crop modal: the cropped canvas is exported as a fresh PNG, so nothing + from the original file (metadata, oversized pixels) reaches the server. --> + <div v-if="showCropper" class="crop-modal" @click.self="closeCropper"> + <div class="crop-panel"> + <div class="crop-head">裁剪 {{ kind === 'logo' ? 'Logo' : 'Favicon' }}</div> + <div class="crop-stage"> + <Cropper + ref="cropper" + :src="cropperImage" + :canvas="{ maxWidth: outputMax, maxHeight: outputMax }" + :stencil-props="stencilProps" + image-restriction="fit-area" + background="var(--o05)" + /> + </div> + <div class="crop-actions"> + <button class="btn-cancel" type="button" @click="closeCropper">取消</button> + <button class="btn-save" type="button" :disabled="saving" @click="confirmCrop"> + {{ saving ? '保存中…' : `保存 ${kind === 'logo' ? 'Logo' : 'Favicon'}` }} + </button> + </div> + </div> + </div> + </div> +</template> + +<style scoped> +.file-input { + display: none; +} + +.logo-row { + display: flex; + align-items: center; + gap: 8px; +} + +.logo-chip { + display: flex; + align-items: center; + gap: 9px; + padding: 10px 14px; + background: var(--bg); + border: 1px solid var(--o10); + border-radius: 11px; + margin-right: 6px; + min-width: 0; +} + +.logo-chip__img { + width: 28px; + height: 28px; + border-radius: 8px; + object-fit: contain; + flex-shrink: 0; +} + +.logo-chip__name { + font-size: 13px; + font-weight: 600; + color: var(--text2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.btn-sm { + padding: 9px 13px; + background: var(--o05); + border: 1px solid var(--o12); + border-radius: 9px; + color: var(--text2); + font-family: var(--font-sans); + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.btn-sm:hover { + background: var(--o08); +} + +.icon-btn { + width: 34px; + height: 34px; + flex-shrink: 0; + border-radius: 9px; + background: var(--o05); + border: 1px solid var(--o12); + color: var(--muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.icon-btn:hover { + background: var(--coral-bg); + color: var(--c-coral); + border-color: var(--coral-border); +} + +.upload-btn { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 16px; + background: var(--bg); + border: 1.5px dashed var(--o16); + border-radius: 12px; + cursor: pointer; + text-align: left; + font-family: var(--font-sans); +} + +.upload-btn:hover { + border-color: var(--o30); +} + +.upload-btn__icon { + width: 38px; + height: 38px; + flex-shrink: 0; + border-radius: 10px; + background: var(--o05); + border: 1px solid var(--o10); + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); +} + +.upload-btn__title { + display: block; + font-size: 13.5px; + font-weight: 600; + color: var(--text2); +} + +.upload-btn__sub { + display: block; + font-size: 12px; + color: var(--muted); + margin-top: 2px; +} + +.logo-error { + margin: 8px 0 0; + font-size: 12.5px; + color: var(--c-coral); +} + +/* ---- crop modal ---- */ +.crop-modal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(10, 12, 16, 0.55); +} + +.crop-panel { + width: 100%; + max-width: 460px; + background: var(--surface, var(--bg)); + border: 1px solid var(--o12); + border-radius: 16px; + box-shadow: 0 24px 60px rgba(10, 12, 16, 0.35); + overflow: hidden; +} + +.crop-head { + padding: 16px 18px; + font-family: var(--font-sans); + font-size: 15px; + font-weight: 600; + color: var(--text2); + border-bottom: 1px solid var(--o10); +} + +.crop-stage { + height: 320px; + background: var(--o05); +} + +.crop-stage :deep(.vue-advanced-cropper) { + height: 100%; +} + +.crop-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + padding: 14px 18px; + border-top: 1px solid var(--o10); +} + +.btn-cancel { + padding: 10px 16px; + background: transparent; + border: 1px solid var(--o12); + border-radius: 10px; + color: var(--muted); + font-family: var(--font-sans); + font-size: 14px; + font-weight: 500; + cursor: pointer; +} + +.btn-save { + padding: 10px 18px; + background: var(--purple-bg); + border: 1px solid var(--purple-border); + border-radius: 10px; + color: var(--c-purple); + font-family: var(--font-sans); + font-size: 14px; + font-weight: 600; + cursor: pointer; +} + +.btn-save:disabled { + opacity: 0.55; + cursor: default; +} +</style> diff --git a/frontend/src/components/faq/HelpCenterPreview.vue b/frontend/src/components/faq/HelpCenterPreview.vue new file mode 100644 index 0000000..8c0c03f --- /dev/null +++ b/frontend/src/components/faq/HelpCenterPreview.vue @@ -0,0 +1,246 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed } from 'vue' +import type { HelpCenterSettings } from '@/types/faq' +import { contrastInk } from '@/utils/color' + +const props = defineProps<{ settings: HelpCenterSettings }>() + +const brand = computed(() => props.settings.brand_color || '#4338CA') +const ink = computed(() => contrastInk(brand.value)) + +const address = computed(() => { + const domain = props.settings.domain + if (domain?.domain_status === 'verified' && domain.custom_domain) return domain.custom_domain + // live_url is mode-aware (path {origin}/help/{slug} or {slug}.{base}); show it + // without the scheme so the preview matches wherever the site is actually served. + if (props.settings.live_url) return props.settings.live_url.replace(/^https?:\/\//, '') + return props.settings.slug || 'your-company' +}) + +const rootStyle = computed(() => ({ '--hc-brand': brand.value, '--hc-ink': ink.value })) + +// Hero tint from the brand color at low alpha (1F = ~12%). +const heroStyle = computed(() => ({ + background: `linear-gradient(180deg, ${brand.value}1F, #FFFFFF 92%)`, +})) +</script> + +<template> + <div class="preview" :style="rootStyle"> + <!-- browser chrome --> + <div class="preview__chrome"> + <span class="preview__dot"></span><span class="preview__dot"></span><span class="preview__dot"></span> + <div class="preview__address">{{ address }}</div> + </div> + <!-- site header --> + <div class="preview__header"> + <div class="preview__brand"> + <img v-if="settings.logo_url" class="preview__logo-img" :src="settings.logo_url" alt="Logo" /> + <div v-else class="preview__logo-mark"><div class="preview__logo-diamond"></div></div> + <span class="preview__brand-name">{{ settings.title || '你的公司' }}</span> + </div> + <div class="preview__nav"> + <span v-for="link in settings.header_links" :key="link.label" class="preview__nav-link">{{ link.label }}</span> + <span v-if="settings.cta_enabled && settings.cta_text" class="preview__cta">{{ settings.cta_text }}</span> + </div> + </div> + <!-- hero --> + <div class="preview__hero" :style="heroStyle"> + <div class="preview__hero-title">有什么可以帮你?</div> + <div class="preview__search"> + <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="#9AA0AD" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></svg> + <span class="preview__search-hint">搜索答案…</span> + <span class="preview__search-btn">搜索</span> + </div> + </div> + </div> +</template> + +<style scoped> +/* This component simulates the visitor-facing help center, an external + light-mode website — the fixed light palette below is deliberate and must + NOT follow the admin app's theme tokens. */ +.preview { + border-radius: 14px; + overflow: hidden; + border: 1px solid var(--o10); + box-shadow: 0 18px 44px rgba(0, 0, 0, 0.28); +} + +.preview__chrome { + display: flex; + align-items: center; + gap: 6px; + padding: 9px 13px; + background: #ebedf1; + border-bottom: 1px solid #dde0e7; +} + +.preview__dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: #c6cad3; +} + +.preview__address { + margin-left: 9px; + flex: 1; + height: 20px; + border-radius: 6px; + background: #ffffff; + display: flex; + align-items: center; + padding: 0 11px; + font-family: var(--font-mono); + font-size: 10.5px; + color: #9aa0ad; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.preview__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 12px 14px; + background: #ffffff; + border-bottom: 1px solid #ecedf2; +} + +.preview__brand { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + overflow: hidden; +} + +.preview__logo-img { + width: 28px; + height: 28px; + border-radius: 8px; + object-fit: contain; + flex-shrink: 0; +} + +.preview__logo-mark { + width: 28px; + height: 28px; + border-radius: 8px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--hc-brand); +} + +.preview__logo-diamond { + width: 11px; + height: 11px; + border-radius: 3px; + background: var(--hc-ink); + transform: rotate(45deg); +} + +.preview__brand-name { + font-family: var(--font-display); + font-weight: 700; + font-size: 14px; + color: #1a1b25; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.preview__nav { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.preview__nav-link { + font-size: 12.5px; + color: #5a6172; + white-space: nowrap; +} + +.preview__cta { + display: inline-flex; + align-items: center; + padding: 8px 15px; + border-radius: 9px; + font-size: 12.5px; + font-weight: 600; + white-space: nowrap; + background: var(--hc-brand); + color: var(--hc-ink); +} + +.preview__hero { + padding: 22px 20px 20px; +} + +.preview__hero-title { + font-family: var(--font-display); + font-weight: 700; + font-size: 19px; + color: #1a1b25; + text-align: center; + margin-bottom: 13px; +} + +.preview__search { + display: flex; + align-items: center; + gap: 8px; + background: #ffffff; + border: 1px solid #e4e6ec; + border-radius: 11px; + padding: 5px 5px 5px 13px; + max-width: 340px; + margin: 0 auto; + box-shadow: 0 8px 20px rgba(40, 36, 90, 0.08); +} + +.preview__search svg { + flex-shrink: 0; +} + +.preview__search-hint { + flex: 1; + font-size: 12.5px; + color: #9aa0ad; +} + +.preview__search-btn { + display: inline-flex; + align-items: center; + padding: 9px 16px; + border-radius: 9px; + font-size: 12.5px; + font-weight: 600; + white-space: nowrap; + background: var(--hc-brand); + color: var(--hc-ink); +} +</style> diff --git a/frontend/src/components/faq/HelpCenterPublic.vue b/frontend/src/components/faq/HelpCenterPublic.vue new file mode 100644 index 0000000..43b5540 --- /dev/null +++ b/frontend/src/components/faq/HelpCenterPublic.vue @@ -0,0 +1,316 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import type { HelpCenterSettings } from '@/types/faq' +import HelpCenterDomainPanel from './HelpCenterDomainPanel.vue' + +defineProps<{ + settings: HelpCenterSettings + domainBusy: boolean +}>() + +const emit = defineEmits<{ + 'toggle-enabled': [enabled: boolean] + 'update-ai': [payload: { agent_id?: string | null; ai_search_enabled?: boolean; chat_widget_enabled?: boolean }] + 'set-domain': [domain: string] + 'verify-domain': [] + 'remove-domain': [] +}>() + +function onAgentChange(event: Event) { + const value = (event.target as HTMLSelectElement).value + emit('update-ai', { agent_id: value || null }) +} +</script> + +<template> + <section class="public"> + <h2 class="public__heading">公开帮助中心</h2> + + <div class="public__card"> + <!-- publish row --> + <div class="publish-row"> + <div class="publish-row__lead"> + <label class="switch"> + <input type="checkbox" :checked="settings.enabled" @change="emit('toggle-enabled', ($event.target as HTMLInputElement).checked)" /> + <span class="switch__track"><span class="switch__knob"></span></span> + </label> + <span class="status-pill" :class="settings.enabled ? 'status-pill--teal' : 'status-pill--idle'"> + <span class="status-pill__dot" :class="{ 'status-pill__dot--glow': settings.enabled }"></span> + {{ settings.enabled ? '已发布' : '草稿' }} + </span> + <span class="publish-row__text">已发布 {{ settings.published_count }} 条常见问题,地址:</span> + <a v-if="settings.live_url" class="publish-row__url" :href="settings.live_url" target="_blank" rel="noopener">{{ settings.live_url }}</a> + </div> + <a v-if="settings.live_url" class="btn-ghost" :href="settings.live_url" target="_blank" rel="noopener">打开帮助中心 →</a> + </div> + + <!-- AI agent + surfaces --> + <div class="block"> + <label class="mono-label">AI 智能体</label> + <div class="ai-row"> + <select class="agent-select" :value="settings.agent_id || ''" @change="onAgentChange"> + <option value="">选择智能体…</option> + <option v-for="agent in settings.agents" :key="agent.id" :value="agent.id">{{ agent.name }}</option> + </select> + </div> + <div class="toggle-list"> + <label class="switch switch--labelled"> + <input type="checkbox" :checked="settings.ai_search_enabled" @change="emit('update-ai', { ai_search_enabled: ($event.target as HTMLInputElement).checked })" /> + <span class="switch__track"><span class="switch__knob"></span></span> + <span class="switch__label"> + 搜索中的 AI 快速摘要 + <span class="switch__sub">在搜索结果上方即时给出 AI 回答,基于该智能体的知识。</span> + </span> + </label> + <label class="switch switch--labelled"> + <input type="checkbox" :checked="settings.chat_widget_enabled" @change="emit('update-ai', { chat_widget_enabled: ($event.target as HTMLInputElement).checked })" /> + <span class="switch__track"><span class="switch__knob"></span></span> + <span class="switch__label"> + 聊天挂件 + <span class="switch__sub">嵌入该智能体的聊天挂件,让访客可以直接发起对话。</span> + </span> + </label> + </div> + <p class="hint">两种展示都使用上方选择的智能体——可以只选一个、两个都选,或都不选。</p> + </div> + + <!-- custom domain --> + <div class="block"> + <HelpCenterDomainPanel + :domain="settings.domain" + :busy="domainBusy" + @set-domain="emit('set-domain', $event)" + @verify-domain="emit('verify-domain')" + @remove-domain="emit('remove-domain')" + /> + </div> + </div> + </section> +</template> + +<style scoped> +.public__heading { + font-family: var(--font-display); + font-weight: 600; + font-size: 19px; + letter-spacing: -0.01em; + color: var(--text); + margin: 0 0 16px; +} + +.public__card { + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 18px; + overflow: hidden; +} + +.publish-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 20px 22px; + border-bottom: 1px solid var(--o07); + flex-wrap: wrap; +} + +.publish-row__lead { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} + +.publish-row__text { + font-size: 14px; + color: var(--muted); +} + +.publish-row__url { + font-family: var(--font-mono); + font-size: 13px; + color: var(--accent-ink); + text-decoration: none; +} + +.btn-ghost { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: var(--o05); + border: 1px solid var(--o14); + border-radius: 10px; + color: var(--text2); + text-decoration: none; + font-size: 13.5px; + font-weight: 600; +} + +.btn-ghost:hover { + background: var(--o08); +} + +.switch { + display: inline-flex; + align-items: center; + gap: 9px; + cursor: pointer; +} + +.switch input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.switch__track { + flex-shrink: 0; + width: 38px; + height: 22px; + border-radius: var(--radius-pill); + background: var(--toggle-track-off); + padding: 2px; + display: inline-flex; + transition: background-color var(--transition-fast); + box-sizing: border-box; +} + +.switch input:checked + .switch__track { + background: var(--toggle-on-teal); +} + +.switch__knob { + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--toggle-knob); + transition: transform var(--transition-fast); +} + +.switch input:checked + .switch__track .switch__knob { + transform: translateX(16px); +} + +.switch__label { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + font-size: 13.5px; + font-weight: 500; + color: var(--text2); +} + +.switch__sub { + font-size: 12px; + font-weight: 400; + color: var(--text3, var(--text2)); + opacity: 0.85; +} + +.toggle-list { + display: flex; + flex-direction: column; + gap: 14px; + margin-top: 14px; +} + +.switch--labelled { + align-items: flex-start; +} + +.status-pill { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 6px 12px; + border-radius: var(--radius-pill); + font-size: 12.5px; + font-weight: 600; +} + +.status-pill__dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; +} + +.status-pill__dot--glow { + box-shadow: 0 0 6px var(--c-teal); +} + +.status-pill--teal { + background: var(--teal-bg); + border: 1px solid var(--teal-border); + color: var(--c-teal); +} + +.status-pill--idle { + background: var(--pill-idle-bg); + border: 1px solid var(--o12); + color: var(--pill-idle-fg); +} + +.block { + padding: 22px; + border-bottom: 1px solid var(--o07); +} + +.block:last-child { + border-bottom: none; +} + +.mono-label { + display: block; + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.06em; + color: var(--muted2); + margin-bottom: 12px; +} + +.ai-row { + display: flex; + align-items: center; + gap: 18px; + flex-wrap: wrap; +} + +.agent-select { + min-width: 220px; + background: var(--bg); + border: 1px solid var(--o12); + border-radius: 10px; + padding: 10px 12px; + font-family: var(--font-sans); + font-size: 13.5px; + color: var(--text); + outline: none; +} + +.hint { + font-size: 12.5px; + color: var(--muted); + margin: 12px 0 0; + line-height: 1.55; +} +</style> diff --git a/frontend/src/components/faq/MarkdownEditor.vue b/frontend/src/components/faq/MarkdownEditor.vue new file mode 100644 index 0000000..70c5f9a --- /dev/null +++ b/frontend/src/components/faq/MarkdownEditor.vue @@ -0,0 +1,266 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed, nextTick, ref } from 'vue' +import { marked } from 'marked' +import { toast } from 'vue-sonner' +import { faqService } from '@/services/faq' + +const props = withDefaults( + defineProps<{ modelValue: string; placeholder?: string }>(), + { placeholder: '回答——支持 Markdown(标题、**加粗**、列表、链接、图片)' }, +) +const emit = defineEmits<{ 'update:modelValue': [value: string] }>() + +const textarea = ref<HTMLTextAreaElement | null>(null) +const fileInput = ref<HTMLInputElement | null>(null) +const mode = ref<'write' | 'preview'>('write') +const uploading = ref(false) + +// Preview renders in a sandboxed iframe (no allow-scripts): it safely shows +// images/links from the author's Markdown without executing anything, and +// sidesteps the app-wide DOMPurify hook that strips <img> elsewhere. +const previewDoc = computed(() => { + const body = props.modelValue.trim() + ? (marked.parse(props.modelValue, { breaks: true, async: false }) as string) + : '<p style="color:#9aa0ad">暂无可预览的内容。</p>' + return `<!doctype html><html><head><meta charset="utf-8"><style> + body{font-family:system-ui,-apple-system,sans-serif;font-size:14px;line-height:1.7;color:#2a303e;margin:14px;} + h1,h2,h3,h4{font-weight:700;line-height:1.3;margin:20px 0 10px;color:#1a1b25;} + h1{font-size:20px}h2{font-size:17px}h3{font-size:15px} + a{color:#4338ca;font-weight:600} + img{max-width:100%;height:auto;border-radius:10px;border:1px solid #ececf2;margin:6px 0} + ul,ol{padding-left:22px}li{margin:4px 0} + blockquote{margin:6px 0;padding:10px 14px;background:#f4f2fd;border-left:3px solid #6d5bd0;border-radius:8px} + code{font-family:ui-monospace,monospace;background:#f1f2f6;border-radius:4px;padding:1px 5px} + pre{background:#f1f2f6;border-radius:10px;padding:12px;overflow-x:auto} + pre code{background:none;padding:0} + table{border-collapse:collapse;width:100%}th,td{border:1px solid #ececf2;padding:6px 9px} + </style></head><body>${body}</body></html>` +}) + +/** Replace the current selection (or insert at caret), then restore focus/caret. */ +function replaceSelection(transform: (selected: string) => { text: string; caretOffset?: number }) { + const el = textarea.value + if (!el) return + const start = el.selectionStart + const end = el.selectionEnd + const value = props.modelValue + const selected = value.slice(start, end) + const { text, caretOffset } = transform(selected) + const next = value.slice(0, start) + text + value.slice(end) + emit('update:modelValue', next) + const caret = caretOffset === undefined ? start + text.length : start + caretOffset + nextTick(() => { + el.focus() + el.setSelectionRange(caret, caret) + }) +} + +function wrap(before: string, after: string, placeholder: string) { + replaceSelection((sel) => { + const inner = sel || placeholder + return { text: `${before}${inner}${after}`, caretOffset: before.length + inner.length } + }) +} + +function prefixLine(prefix: string, placeholder: string) { + replaceSelection((sel) => { + const inner = sel || placeholder + return { text: `${prefix}${inner}`, caretOffset: prefix.length + inner.length } + }) +} + +function insertLink() { + replaceSelection((sel) => { + const label = sel || '链接文字' + return { text: `[${label}](https://)`, caretOffset: label.length + 3 } + }) +} + +async function onFile(event: Event) { + const input = event.target as HTMLInputElement + const file = input.files?.[0] + input.value = '' + if (!file) return + uploading.value = true + try { + const url = await faqService.uploadImage(file) + const alt = file.name.replace(/\.[^.]+$/, '') + replaceSelection(() => ({ text: `\n![${alt}](${url})\n` })) + } catch (error: any) { + toast.error(error?.message || '图片上传失败') + } finally { + uploading.value = false + } +} +</script> + +<template> + <div class="md-editor"> + <div class="md-toolbar"> + <div class="md-tools"> + <button type="button" class="md-btn" title="标题" @click="prefixLine('## ', '标题')">H</button> + <button type="button" class="md-btn md-btn--bold" title="加粗" @click="wrap('**', '**', '加粗')">B</button> + <button type="button" class="md-btn" title="无序列表" @click="prefixLine('- ', '列表项')"> + <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="20" y2="6"/><line x1="8" y1="12" x2="20" y2="12"/><line x1="8" y1="18" x2="20" y2="18"/><circle cx="3.5" cy="6" r="1"/><circle cx="3.5" cy="12" r="1"/><circle cx="3.5" cy="18" r="1"/></svg> + </button> + <button type="button" class="md-btn" title="链接" @click="insertLink"> + <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1"/></svg> + </button> + <button type="button" class="md-btn" title="插入图片" :disabled="uploading" @click="fileInput?.click()"> + <svg v-if="!uploading" viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg> + <span v-else class="md-spin"></span> + </button> + <input ref="fileInput" type="file" accept="image/png,image/jpeg,image/gif,image/webp" hidden @change="onFile" /> + </div> + <div class="md-modes"> + <button type="button" class="md-mode" :class="{ 'md-mode--on': mode === 'write' }" @click="mode = 'write'">编写</button> + <button type="button" class="md-mode" :class="{ 'md-mode--on': mode === 'preview' }" @click="mode = 'preview'">预览</button> + </div> + </div> + + <textarea + v-show="mode === 'write'" + ref="textarea" + class="md-textarea" + rows="8" + :placeholder="placeholder" + :value="modelValue" + @input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)" + ></textarea> + + <iframe v-show="mode === 'preview'" class="md-preview" :srcdoc="previewDoc" sandbox="" title="回答预览"></iframe> + </div> +</template> + +<style scoped> +.md-editor { + border: 1px solid var(--o12); + border-radius: 10px; + overflow: hidden; + background: var(--bg); +} + +.md-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 6px 8px; + border-bottom: 1px solid var(--o08); + background: var(--o03); +} + +.md-tools { + display: flex; + align-items: center; + gap: 4px; +} + +.md-btn { + min-width: 30px; + height: 30px; + padding: 0 7px; + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid transparent; + border-radius: 8px; + color: var(--muted); + font-family: var(--font-sans); + font-size: 13.5px; + cursor: pointer; +} + +.md-btn:hover { + background: var(--o08); + color: var(--text2); +} + +.md-btn:disabled { + opacity: 0.6; + cursor: default; +} + +.md-btn--bold { + font-weight: 700; +} + +.md-modes { + display: flex; + gap: 2px; + background: var(--o05); + border-radius: 8px; + padding: 2px; +} + +.md-mode { + padding: 5px 11px; + background: transparent; + border: none; + border-radius: 6px; + color: var(--muted); + font-family: var(--font-sans); + font-size: 12.5px; + font-weight: 600; + cursor: pointer; +} + +.md-mode--on { + background: var(--surface); + color: var(--text); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.md-textarea { + width: 100%; + display: block; + background: var(--bg); + border: none; + padding: 12px 13px; + color: var(--text); + font-family: var(--font-sans); + font-size: 13.5px; + line-height: 1.6; + outline: none; + resize: vertical; + box-sizing: border-box; +} + +.md-preview { + width: 100%; + height: 240px; + border: none; + background: #fff; + display: block; +} + +.md-spin { + width: 13px; + height: 13px; + border: 2px solid var(--o16); + border-top-color: var(--c-purple); + border-radius: 50%; + animation: md-spin 0.7s linear infinite; +} + +@keyframes md-spin { + to { transform: rotate(360deg); } +} +</style> diff --git a/frontend/src/components/integrations/ChannelConnectModal.vue b/frontend/src/components/integrations/ChannelConnectModal.vue new file mode 100644 index 0000000..bef65a1 --- /dev/null +++ b/frontend/src/components/integrations/ChannelConnectModal.vue @@ -0,0 +1,342 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { ref, computed, onMounted } from 'vue' +import { toast } from 'vue-sonner' +import channelsService, { type ChannelAccount, type SmsProviderInfo } from '@/services/channels' +import { agentService } from '@/services/agent' +import type { Agent } from '@/types/agent' +import CopyField from './CopyField.vue' + +const props = defineProps<{ + channel: 'email' | 'sms' | 'line' | 'slack' + // When set, the modal opens in "manage" mode for an already-connected + // account: it skips credential entry and shows the webhook URL + agent. + // Slack always uses this (it connects via OAuth, so only the agent step). + existingAccount?: ChannelAccount | null +}>() + +const emit = defineEmits<{ + (e: 'close'): void + (e: 'connected', account: ChannelAccount): void +}>() + +const FORMS = { + email: { + title: '连接 Email', + intro: '填写客户来信的客服邮箱地址,然后将服务商的入站解析 / 转发 Webhook 指向下方显示的 URL。可选:配置自己的发信 SMTP,以便使用自有域名发送回复并正确设置 SPF/DKIM(留空则使用平台邮件服务器)。', + fields: [ + { key: 'inbound_address', label: '客服邮箱地址', placeholder: 'support@yourcompany.com', secret: false }, + { key: 'smtp_host', label: 'SMTP 主机(可选)', placeholder: 'smtp.yourprovider.com', secret: false, optional: true }, + { key: 'smtp_port', label: 'SMTP 端口(可选)', placeholder: '587', secret: false, optional: true }, + { key: 'smtp_username', label: 'SMTP 用户名(可选)', placeholder: 'apikey / user', secret: false, optional: true }, + { key: 'smtp_password', label: 'SMTP 密码(可选)', placeholder: '••••••••', secret: true, optional: true }, + { key: 'from_email', label: '发件地址(可选)', placeholder: '默认使用客服邮箱地址', secret: false, optional: true }, + ], + connect: (v: Record<string, string>) => { + const payload: any = { inbound_address: v.inbound_address } + if (v.smtp_host?.trim()) { + payload.smtp_host = v.smtp_host.trim() + if (v.smtp_port?.trim()) payload.smtp_port = Number(v.smtp_port) + payload.smtp_username = v.smtp_username + payload.smtp_password = v.smtp_password + if (v.from_email?.trim()) payload.from_email = v.from_email.trim() + } + return channelsService.connectEmail(payload) + }, + }, + sms: { + title: '连接 SMS', + intro: '选择短信服务商,填写发信号码及其 API 凭据。连接完成后,将显示的 URL 设置为该号码的入站消息 Webhook(SNS 使用 HTTPS 主题订阅)。', + // Fields are provider-specific and resolved dynamically (see activeFields) + fields: [], + connect: (v: Record<string, string>) => { + const info = smsProviders.value.find(p => p.name === selectedProvider.value) + const credentials: Record<string, string> = {} + for (const f of info?.fields || []) { + if (v[f.key]?.trim()) credentials[f.key] = v[f.key].trim() + } + return channelsService.connectSms({ + provider: selectedProvider.value, + phone_number: v.phone_number, + credentials, + }) + }, + }, + line: { + title: '连接 LINE', + intro: '在 LINE Developers 控制台(Messaging API 渠道)中复制 Channel secret 并签发 Channel access token。Webhook 会自动配置。', + fields: [ + { key: 'channel_secret', label: 'Channel secret', placeholder: '••••••••', secret: true }, + { key: 'channel_access_token', label: 'Channel access token', placeholder: '长期有效 Token', secret: true }, + ], + connect: (v: Record<string, string>) => channelsService.connectLine({ + channel_secret: v.channel_secret, channel_access_token: v.channel_access_token }), + }, + slack: { + // Slack connects via OAuth (no credential form); this modal is only used + // in manage mode to pick the answering agent. + title: 'Slack', + intro: '选择由哪个 AI 智能体应答 Slack 中的提及和私信。', + fields: [], + connect: async () => { throw new Error('Slack connects via OAuth') }, + }, +} as const + +const form = computed(() => FORMS[props.channel]) +const values = ref<Record<string, string>>({}) +const connecting = ref(false) +// In manage mode we start at the second step with the existing account +const account = ref<ChannelAccount | null>(props.existingAccount ?? null) +const isManage = computed(() => !!props.existingAccount) + +// SMS providers (dynamic credential fields per provider) +const smsProviders = ref<SmsProviderInfo[]>([]) +const selectedProvider = ref('twilio') + +// The credential fields to render: dynamic for SMS, static otherwise +const activeFields = computed(() => { + if (props.channel !== 'sms') return form.value.fields + const info = smsProviders.value.find(p => p.name === selectedProvider.value) + return [ + { key: 'phone_number', label: '电话号码 / 发送方 ID', placeholder: '+15551234567', secret: false }, + ...(info?.fields || []).map(f => ({ + key: f.key, label: f.label, placeholder: f.secret ? '••••••••' : '', + secret: f.secret, optional: f.optional, + })), + ] +}) + +const agents = ref<Agent[]>([]) +const selectedAgentId = ref('') +const savingAgent = ref(false) + +onMounted(async () => { + try { + if (props.channel === 'sms') { + smsProviders.value = await channelsService.listSmsProviders() + } + agents.value = await agentService.getOrganizationAgents() + // Default the agent selector to the account's current agent, else the first + selectedAgentId.value = String( + props.existingAccount?.agent_id || agents.value[0]?.id || '') + } catch (error) { + console.error('Error loading modal data:', error) + } +}) + +const connect = async () => { + const missing = activeFields.value.filter(f => !(f as any).optional && !values.value[f.key]?.trim()) + if (missing.length > 0) { + toast.error(`请填写:${missing.map(f => f.label).join('、')}`) + return + } + try { + connecting.value = true + const trimmed = Object.fromEntries( + Object.entries(values.value).map(([k, v]) => [k, v.trim()])) + account.value = await form.value.connect(trimmed) + toast.success(`已连接 ${account.value.display_name || form.value.title.replace('连接 ', '')}`) + } catch (error: any) { + toast.error(error?.response?.data?.detail || `连接 ${props.channel} 失败`) + } finally { + connecting.value = false + } +} + +const saveAgent = async () => { + if (!account.value || !selectedAgentId.value) return + try { + savingAgent.value = true + const updated = await channelsService.setAccountAgent(account.value.id, selectedAgentId.value) + toast.success('智能体已分配 — 该渠道已生效!') + emit('connected', updated) + } catch (error: any) { + toast.error(error?.response?.data?.detail || '分配智能体失败') + } finally { + savingAgent.value = false + } +} +</script> + +<template> + <!-- Intentionally no backdrop-click dismiss: these forms hold credentials + the user is mid-entry on, so only the × / Cancel buttons close it. --> + <div class="cc-modal"> + <div class="cc-modal-content"> + <div class="cc-modal-header"> + <h3>{{ isManage ? form.title.replace('连接', '管理') : form.title }}</h3> + <button class="cc-close-btn" @click="emit('close')">×</button> + </div> + + <!-- Step 1: credentials --> + <div v-if="!account" class="cc-modal-body"> + <p class="cc-intro">{{ form.intro }}</p> + + <!-- SMS provider picker --> + <div v-if="channel === 'sms'" class="cc-field"> + <label class="cc-label" for="cc-provider">短信服务商</label> + <select id="cc-provider" v-model="selectedProvider" class="cc-input"> + <option v-for="p in smsProviders" :key="p.name" :value="p.name">{{ p.label }}</option> + </select> + </div> + + <div v-for="field in activeFields" :key="field.key" class="cc-field"> + <label class="cc-label" :for="`cc-${field.key}`">{{ field.label }}</label> + <input + :id="`cc-${field.key}`" + v-model="values[field.key]" + :type="field.secret ? 'password' : 'text'" + class="cc-input" + :placeholder="(field as any).placeholder" + :name="`cc-${channel}-${field.key}`" + :autocomplete="field.secret ? 'new-password' : 'off'" + /> + </div> + <div class="cc-actions"> + <button class="cc-btn cc-btn-secondary" @click="emit('close')">取消</button> + <button class="cc-btn cc-btn-primary" :disabled="connecting" @click="connect"> + {{ connecting ? '连接中…' : '连接' }} + </button> + </div> + </div> + + <!-- Step 2: webhook URL (if applicable) + agent routing --> + <div v-else class="cc-modal-body"> + <p class="cc-intro"> + <strong>{{ account.display_name }}</strong> 已连接。 + </p> + <CopyField v-if="account.webhook_url" class="cc-field" field-id="cc-webhook-url" + label="Webhook URL — 在你的服务商处设置" name="Webhook URL" + :value="account.webhook_url" /> + <label class="cc-label" for="cc-agent">负责应答该渠道的 AI 智能体</label> + <select id="cc-agent" v-model="selectedAgentId" class="cc-input"> + <option v-for="agent in agents" :key="String(agent.id)" :value="String(agent.id)"> + {{ agent.display_name || agent.name }} + </option> + </select> + <div class="cc-actions"> + <button v-if="isManage && channel !== 'slack'" class="cc-btn cc-btn-secondary" @click="account = null"> + 重新配置凭据 + </button> + <button v-else class="cc-btn cc-btn-secondary" @click="emit('connected', account)">暂时跳过</button> + <button class="cc-btn cc-btn-primary" :disabled="savingAgent || !selectedAgentId" @click="saveAgent"> + {{ savingAgent ? '保存中…' : '分配智能体' }} + </button> + </div> + </div> + </div> + </div> +</template> + +<style scoped> +.cc-modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.cc-modal-content { + background: var(--background-color); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg, 12px); + width: min(480px, calc(100vw - 32px)); + padding: 24px; +} + +.cc-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.cc-modal-header h3 { + margin: 0; + font-family: var(--font-display); +} + +.cc-close-btn { + background: none; + border: none; + font-size: 22px; + cursor: pointer; + color: var(--muted); +} + +.cc-intro { + margin: 0 0 16px; + color: var(--muted); + font-size: 14px; + line-height: 1.6; +} + +.cc-field { + margin-bottom: 12px; +} + +.cc-label { + display: block; + font-size: 13px; + font-weight: 600; + margin-bottom: 6px; +} + +.cc-input { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border-color); + border-radius: var(--radius-btn, 8px); + background: var(--background-soft); + color: inherit; + font-size: 14px; +} + +.cc-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 20px; +} + +.cc-btn { + padding: 9px 16px; + border-radius: var(--radius-btn, 8px); + font-size: 14px; + font-weight: 600; + cursor: pointer; + border: 1px solid var(--border-color); + background: var(--background-soft); + color: inherit; + white-space: nowrap; +} + +.cc-btn-primary { + background: var(--accent-solid); + color: var(--on-accent-solid); + border-color: transparent; +} + +.cc-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} +</style> diff --git a/frontend/src/components/integrations/CopyField.vue b/frontend/src/components/integrations/CopyField.vue new file mode 100644 index 0000000..1d83bbe --- /dev/null +++ b/frontend/src/components/integrations/CopyField.vue @@ -0,0 +1,99 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<!-- A read-only value the customer copies into another product's settings + (a webhook URL, a verify token). --> +<script setup lang="ts"> +import { toast } from 'vue-sonner' + +const props = defineProps<{ + fieldId: string + label: string + /** What the toast calls it ("Webhook URL copied") */ + name: string + value: string +}>() + +const copy = async () => { + try { + await navigator.clipboard.writeText(props.value) + toast.success(`${props.name}已复制`) + } catch { + // No clipboard on plain-http origins: the field stays selectable + toast.error('复制失败,请选中文本后手动复制。') + } +} +</script> + +<template> + <div class="copy-field"> + <label class="copy-label" :for="fieldId">{{ label }}</label> + <div class="copy-row"> + <input :id="fieldId" class="copy-input" :value="value" readonly /> + <button class="copy-btn" type="button" @click="copy">复制</button> + </div> + </div> +</template> + +<style scoped> +.copy-field + .copy-field { + margin-top: 10px; +} + +.copy-label { + display: block; + font-size: 13px; + font-weight: 600; + margin-bottom: 6px; +} + +.copy-row { + display: flex; + gap: 8px; +} + +.copy-input { + flex: 1; + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: var(--radius-btn); + background: var(--background-soft); + color: inherit; + font-size: 13px; +} + +.copy-btn { + padding: 8px 12px; + border: 1px solid var(--border-color); + border-radius: var(--radius-btn); + background: var(--background-soft); + color: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.copy-btn:hover { + border-color: var(--muted); +} + +.copy-btn:focus-visible, +.copy-input:focus-visible { + outline: 2px solid var(--accent-ink); + outline-offset: 1px; +} +</style> diff --git a/frontend/src/components/integrations/MessengerPagePicker.vue b/frontend/src/components/integrations/MessengerPagePicker.vue new file mode 100644 index 0000000..58f267f --- /dev/null +++ b/frontend/src/components/integrations/MessengerPagePicker.vue @@ -0,0 +1,126 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<!-- + Facebook Login for Business can grant several Pages at once, so the customer + chooses which one this channel answers. WhatsApp Embedded Signup never needs + this — it creates the single number itself. +--> +<script setup lang="ts"> +import { ref } from 'vue' +import type { MessengerSignupPage } from '@/services/channels' + +defineProps<{ + pages: MessengerSignupPage[] + connecting: boolean +}>() + +const emit = defineEmits<{ + (e: 'select', pageId: string): void +}>() + +// Which row is being connected, so only it shows a spinner (and the rest lock). +const pendingId = ref('') + +const choose = (pageId: string) => { + pendingId.value = pageId + emit('select', pageId) +} +</script> + +<template> + <div class="picker"> + <p class="picker-intro">选择要连接的账号:</p> + <ul class="picker-list"> + <li v-for="page in pages" :key="page.id"> + <button + type="button" + class="picker-row" + :disabled="connecting" + @click="choose(page.id)" + > + <span class="picker-name">{{ page.name }}</span> + <font-awesome-icon + v-if="connecting && pendingId === page.id" + icon="fa-solid fa-spinner" + spin + /> + <font-awesome-icon v-else icon="fa-solid fa-chevron-right" class="picker-chevron" /> + </button> + </li> + </ul> + </div> +</template> + +<style scoped> +.picker { + display: flex; + flex-direction: column; + gap: 12px; +} + +.picker-intro { + margin: 0; + color: var(--muted); + font-size: 14px; +} + +.picker-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.picker-row { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border: 1px solid var(--border-color); + border-radius: var(--radius-btn, 8px); + background: var(--background-soft); + color: inherit; + font-size: 14px; + font-weight: 600; + cursor: pointer; + text-align: left; +} + +.picker-row:hover:not(:disabled) { + border-color: var(--accent-solid); +} + +.picker-row:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.picker-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.picker-chevron { + color: var(--muted); + flex-shrink: 0; +} +</style> diff --git a/frontend/src/components/integrations/MetaChannelConnect.vue b/frontend/src/components/integrations/MetaChannelConnect.vue new file mode 100644 index 0000000..89f3bd9 --- /dev/null +++ b/frontend/src/components/integrations/MetaChannelConnect.vue @@ -0,0 +1,402 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { ref, computed, onMounted } from 'vue' +import { toast } from 'vue-sonner' +import channelsService, { type ChannelAccount } from '@/services/channels' +import { agentService } from '@/services/agent' +import type { Agent } from '@/types/agent' +import MessengerPagePicker from './MessengerPagePicker.vue' +import MetaOwnAppWebhook from './MetaOwnAppWebhook.vue' +import { useMetaSignup } from '@/composables/useMetaSignup' + +const props = defineProps<{ + channel: 'whatsapp' | 'messenger' | 'instagram' + existingAccount?: ChannelAccount | null +}>() + +const emit = defineEmits<{ + (e: 'close'): void + (e: 'connected', account: ChannelAccount): void +}>() + +// Where the credentials are copied from — linked so it's one click, not a +// hostname to retype. +const META_APPS_URL = 'https://developers.facebook.com/apps/' + +// Meta signs your own app's webhooks with its secret: without it a number or +// Page from your app can send but never receives. +const APP_SECRET_FIELD = { + key: 'app_secret', label: 'App Secret(可选)', placeholder: 'App settings → Basic', secret: true, +} as const + +// Per-channel copy + credential fields. The intro is split around the link +// rather than carrying markup, so it renders as escaped text. +const META_FORMS = { + whatsapp: { + title: '连接 WhatsApp', + introBefore: '在你的 Meta 应用中(', + introAfter: ' → WhatsApp → API Setup)复制电话号码 ID 和永久访问 Token。添加 App Secret 后才能接收消息。', + fields: [ + { key: 'phone_number_id', label: '电话号码 ID', placeholder: '1234567890', secret: false }, + { key: 'access_token', label: '访问 Token', placeholder: 'EAAG…', secret: true }, + { key: 'waba_id', label: 'WhatsApp Business 账号 ID(可选)', placeholder: '用于自动订阅 Webhook', secret: false }, + APP_SECRET_FIELD, + ], + }, + messenger: { + title: '连接 Messenger', + introBefore: '在你的 Meta 应用中(', + introAfter: ' → Messenger → 设置)为要连接的 Facebook 公共主页生成公共主页访问 Token。添加 App Secret 后才能接收消息。', + fields: [ + { key: 'page_id', label: 'Facebook 公共主页 ID', placeholder: '1234567890', secret: false }, + { key: 'page_access_token', label: '公共主页访问 Token', placeholder: 'EAAG…', secret: true }, + APP_SECRET_FIELD, + ], + }, + instagram: { + title: '连接 Instagram', + introBefore: '你的 Instagram 账号必须是已关联 Facebook 公共主页的专业账号。请使用所关联公共主页的访问 Token,来源:', + introAfter: ' → Instagram。', + fields: [ + { key: 'ig_id', label: 'Instagram 账号 ID', placeholder: '17841400000000000', secret: false }, + { key: 'page_access_token', label: '关联公共主页访问 Token', placeholder: 'EAAG…', secret: true }, + ], + }, +} as const + +const form = computed(() => META_FORMS[props.channel]) +const values = ref<Record<string, string>>({}) +const connecting = ref(false) +const account = ref<ChannelAccount | null>(props.existingAccount ?? null) + +const agents = ref<Agent[]>([]) +const selectedAgentId = ref('') +const savingAgent = ref(false) + +// The three one-click logins live in their own composable; this component owns +// the manual credentials form and agent assignment. +const { + signupEnabled, + signingUp, + showManualForm, + signupPages, + connectingPage, + copy: signupCopy, + startSignup, + onPageSelected, +} = useMetaSignup({ + channel: props.channel, + existingAccount: props.existingAccount, + onConnected: (connected) => { account.value = connected }, +}) + +onMounted(async () => { + try { + agents.value = await agentService.getOrganizationAgents() + selectedAgentId.value = String( + props.existingAccount?.agent_id || agents.value[0]?.id || '') + } catch (error) { + console.error('Error loading agents:', error) + } +}) + +const connect = async () => { + const missing = form.value.fields.filter(f => !f.label.includes('可选') && !values.value[f.key]?.trim()) + if (missing.length > 0) { + toast.error(`请填写:${missing.map(f => f.label).join('、')}`) + return + } + try { + connecting.value = true + const payload: any = Object.fromEntries( + form.value.fields + .map(f => [f.key, values.value[f.key]?.trim()]) + .filter(([, v]) => v) + ) + if (props.channel === 'whatsapp') { + account.value = await channelsService.connectWhatsApp(payload) + } else if (props.channel === 'messenger') { + account.value = await channelsService.connectMessenger(payload) + } else { + account.value = await channelsService.connectInstagram(payload) + } + toast.success(`已连接 ${account.value.display_name || form.value.title.replace('连接 ', '')}`) + } catch (error: any) { + toast.error(error?.response?.data?.detail || `连接 ${props.channel} 失败`) + } finally { + connecting.value = false + } +} + +const saveAgent = async () => { + if (!account.value || !selectedAgentId.value) return + try { + savingAgent.value = true + const updated = await channelsService.setAccountAgent(account.value.id, selectedAgentId.value) + toast.success('智能体已分配 — 该渠道已生效!') + emit('connected', updated) + } catch (error: any) { + toast.error(error?.response?.data?.detail || '分配智能体失败') + } finally { + savingAgent.value = false + } +} +</script> + +<template> + <div class="meta-modal" @click.self="emit('close')"> + <div class="meta-modal-content"> + <div class="meta-modal-header"> + <h3>{{ form.title }}</h3> + <button class="meta-close-btn" @click="emit('close')">×</button> + </div> + + <!-- Step 1: credentials --> + <div v-if="!account" class="meta-modal-body"> + <!-- Login for Business can grant several accounts at once; the customer + picks which one this channel answers. --> + <MessengerPagePicker + v-if="signupPages.length" + :pages="signupPages" + :connecting="connectingPage" + @select="onPageSelected" + /> + + <!-- One-click signup under ChatterMate's Meta app; the manual form + stays available for anyone who already has their own credentials. --> + <div v-else-if="signupEnabled && !showManualForm" class="meta-signup"> + <p class="meta-intro">{{ signupCopy.intro }}</p> + <button class="meta-btn meta-btn-primary meta-signup-btn" :disabled="signingUp" @click="startSignup"> + <font-awesome-icon v-if="signingUp" icon="fa-solid fa-spinner" spin /> + {{ signingUp ? '正在等待 Meta…' : signupCopy.cta }} + </button> + <button class="meta-link-btn" @click="showManualForm = true"> + 改为手动输入凭据 + </button> + </div> + + <template v-else> + <p class="meta-intro"> + {{ form.introBefore }}<a + :href="META_APPS_URL" + target="_blank" + rel="noopener noreferrer" + class="meta-intro-link" + >developers.facebook.com</a>{{ form.introAfter }} + </p> + <div v-for="field in form.fields" :key="field.key" class="meta-field"> + <label class="meta-label" :for="`meta-${field.key}`">{{ field.label }}</label> + <input + :id="`meta-${field.key}`" + v-model="values[field.key]" + :type="field.secret ? 'password' : 'text'" + class="meta-input" + :placeholder="field.placeholder" + :name="`meta-${channel}-${field.key}`" + :autocomplete="field.secret ? 'new-password' : 'off'" + /> + </div> + <div class="meta-actions"> + <button class="meta-btn meta-btn-secondary" @click="emit('close')">取消</button> + <button class="meta-btn meta-btn-primary" :disabled="connecting" @click="connect"> + {{ connecting ? '连接中…' : '连接' }} + </button> + </div> + </template> + </div> + + <!-- Step 2: route to an agent --> + <div v-else class="meta-modal-body"> + <div v-if="account.needs_reconnect" class="meta-reconnect"> + <p class="meta-reconnect-text">Meta 已停止接受该账号的访问权限,请重新连接以恢复回复。</p> + <button class="meta-btn meta-btn-primary" @click="account = null">重新连接</button> + </div> + <p class="meta-intro"> + <strong>{{ account.display_name }}</strong> 已连接。 + </p> + <MetaOwnAppWebhook v-if="account.webhook_url && account.webhook_verify_token" :account="account" /> + <p class="meta-intro">选择由哪个 AI 智能体应答其消息:</p> + <label class="meta-label" for="meta-agent">AI 智能体</label> + <select id="meta-agent" v-model="selectedAgentId" class="meta-input"> + <option v-for="agent in agents" :key="String(agent.id)" :value="String(agent.id)"> + {{ agent.display_name || agent.name }} + </option> + </select> + <div class="meta-actions"> + <button class="meta-btn meta-btn-secondary" @click="emit('connected', account)">暂时跳过</button> + <button class="meta-btn meta-btn-primary" :disabled="savingAgent || !selectedAgentId" @click="saveAgent"> + {{ savingAgent ? '保存中…' : '分配智能体' }} + </button> + </div> + </div> + </div> + </div> +</template> + +<style scoped> +.meta-modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.meta-modal-content { + background: var(--background-color); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg, 12px); + width: min(460px, calc(100vw - 32px)); + padding: 24px; +} + +.meta-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.meta-modal-header h3 { + margin: 0; + font-family: var(--font-display); +} + +.meta-close-btn { + background: none; + border: none; + font-size: 22px; + cursor: pointer; + color: var(--muted); +} + +.meta-reconnect { + display: flex; + align-items: center; + gap: 12px; + margin: 0 0 16px; + padding: 12px; + border-radius: var(--radius-md, 8px); + background: var(--error-bg); +} + +.meta-reconnect-text { + margin: 0; + flex: 1; + color: var(--error-color); + font-size: 14px; + line-height: 1.5; +} + +.meta-intro { + margin: 0 0 16px; + color: var(--muted); + font-size: 14px; + line-height: 1.6; +} + +.meta-intro-link { + /* --accent-ink, not --accent-solid: the latter is the lime fill and stays + lime in both themes, which is unreadable as text on a light background. */ + color: var(--accent-ink); + text-decoration: underline; + font-weight: 600; +} + +.meta-intro-link:hover { + text-decoration: none; +} + +.meta-field { + margin-bottom: 12px; +} + +.meta-label { + display: block; + font-size: 13px; + font-weight: 600; + margin-bottom: 6px; +} + +.meta-input { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border-color); + border-radius: var(--radius-btn, 8px); + background: var(--background-soft); + color: inherit; + font-size: 14px; +} + +.meta-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 20px; +} + +.meta-btn { + padding: 9px 16px; + border-radius: var(--radius-btn, 8px); + font-size: 14px; + font-weight: 600; + cursor: pointer; + border: 1px solid var(--border-color); + background: var(--background-soft); + color: inherit; +} + +.meta-btn-primary { + background: var(--accent-solid); + color: var(--on-accent-solid); + border-color: transparent; +} + +.meta-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.meta-signup { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 12px; +} + +.meta-signup-btn { + width: 100%; + padding: 12px; +} + +.meta-link-btn { + background: none; + border: none; + color: var(--muted); + font-size: 13px; + cursor: pointer; + text-decoration: underline; + padding: 4px; +} + +.meta-link-btn:hover { + color: inherit; +} +</style> diff --git a/frontend/src/components/integrations/MetaOwnAppWebhook.vue b/frontend/src/components/integrations/MetaOwnAppWebhook.vue new file mode 100644 index 0000000..96f9ee6 --- /dev/null +++ b/frontend/src/components/integrations/MetaOwnAppWebhook.vue @@ -0,0 +1,60 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<!-- The webhook address and verify token of a WhatsApp number or Messenger + Page connected from the customer's own Meta app. --> +<script setup lang="ts"> +import { computed } from 'vue' +import type { ChannelAccount } from '@/services/channels' +import CopyField from './CopyField.vue' + +const props = defineProps<{ account: ChannelAccount }>() + +const hint = computed(() => { + if (props.account.channel_type === 'messenger') { + return '在你的 Meta 应用中,将以下信息添加到 Messenger → 设置 → Webhooks,然后将公共主页订阅消息事件。' + } + return props.account.webhook_auto_configured + ? '该号码的消息会直接发送到 ChatterMate,无需额外配置。' + : '在你的 Meta 应用中,将以下信息添加到 WhatsApp → 配置,以便消息送达 ChatterMate。' +}) +</script> + +<template> + <div class="own-app-webhook"> + <p class="own-app-hint">{{ hint }}</p> + <CopyField field-id="own-app-callback-url" label="回调 URL" name="回调 URL" + :value="account.webhook_url || ''" /> + <CopyField field-id="own-app-verify-token" label="验证 Token" name="验证 Token" + :value="account.webhook_verify_token || ''" /> + </div> +</template> + +<style scoped> +.own-app-webhook { + margin-bottom: 16px; + padding: 12px; + border: 1px solid var(--border-color); + border-radius: var(--radius-btn); +} + +.own-app-hint { + margin: 0 0 12px; + color: var(--muted); + font-size: 13px; + line-height: 1.5; +} +</style> diff --git a/frontend/src/components/integrations/TelegramConnectModal.vue b/frontend/src/components/integrations/TelegramConnectModal.vue new file mode 100644 index 0000000..47991ad --- /dev/null +++ b/frontend/src/components/integrations/TelegramConnectModal.vue @@ -0,0 +1,245 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { ref, onMounted } from 'vue' +import { toast } from 'vue-sonner' +import channelsService, { type ChannelAccount } from '@/services/channels' +import { agentService } from '@/services/agent' +import type { Agent } from '@/types/agent' + +const props = defineProps<{ + existingAccount?: ChannelAccount | null +}>() + +const emit = defineEmits<{ + (e: 'close'): void + (e: 'connected', account: ChannelAccount): void +}>() + +const botToken = ref('') +const connecting = ref(false) +const account = ref<ChannelAccount | null>(props.existingAccount ?? null) + +const agents = ref<Agent[]>([]) +const selectedAgentId = ref('') +const savingAgent = ref(false) + +onMounted(async () => { + try { + agents.value = await agentService.getOrganizationAgents() + selectedAgentId.value = String( + props.existingAccount?.agent_id || agents.value[0]?.id || '') + } catch (error) { + console.error('Error loading agents:', error) + } +}) + +const connect = async () => { + if (!botToken.value.trim()) { + toast.error('请粘贴来自 @BotFather 的机器人 Token') + return + } + try { + connecting.value = true + account.value = await channelsService.connectTelegram(botToken.value.trim()) + toast.success(`已连接 ${account.value.display_name || 'Telegram 机器人'}`) + } catch (error: any) { + const detail = error?.response?.data?.detail || '连接 Telegram 机器人失败' + toast.error(detail) + } finally { + connecting.value = false + } +} + +const saveAgent = async () => { + if (!account.value || !selectedAgentId.value) return + try { + savingAgent.value = true + const updated = await channelsService.setAccountAgent(account.value.id, selectedAgentId.value) + toast.success('智能体已分配 — 你的机器人已生效!') + emit('connected', updated) + } catch (error: any) { + toast.error(error?.response?.data?.detail || '分配智能体失败') + } finally { + savingAgent.value = false + } +} +</script> + +<template> + <div class="tg-modal" @click.self="emit('close')"> + <div class="tg-modal-content"> + <div class="tg-modal-header"> + <h3>连接 Telegram</h3> + <button class="tg-close-btn" @click="emit('close')">×</button> + </div> + + <!-- Step 1: bot token --> + <div v-if="!account" class="tg-modal-body"> + <ol class="tg-steps"> + <li>在 Telegram 中打开 <strong>@BotFather</strong>,发送 <code>/newbot</code></li> + <li>按提示为你的机器人命名</li> + <li>复制机器人 Token 并粘贴到下方</li> + </ol> + <label class="tg-label" for="tg-token">机器人 Token</label> + <input + id="tg-token" + v-model="botToken" + type="password" + class="tg-input" + placeholder="123456789:AAF…" + name="tg-bot-token" + autocomplete="new-password" + @keyup.enter="connect" + /> + <div class="tg-actions"> + <button class="tg-btn tg-btn-secondary" @click="emit('close')">取消</button> + <button class="tg-btn tg-btn-primary" :disabled="connecting" @click="connect"> + {{ connecting ? '连接中…' : '连接' }} + </button> + </div> + </div> + + <!-- Step 2: route to an agent --> + <div v-else class="tg-modal-body"> + <p class="tg-connected-note"> + <strong>{{ account.display_name }}</strong> 已连接。 + 选择由哪个 AI 智能体应答其消息: + </p> + <label class="tg-label" for="tg-agent">AI 智能体</label> + <select id="tg-agent" v-model="selectedAgentId" class="tg-input"> + <option v-for="agent in agents" :key="String(agent.id)" :value="String(agent.id)"> + {{ agent.display_name || agent.name }} + </option> + </select> + <div class="tg-actions"> + <button class="tg-btn tg-btn-secondary" @click="emit('connected', account)">暂时跳过</button> + <button class="tg-btn tg-btn-primary" :disabled="savingAgent || !selectedAgentId" @click="saveAgent"> + {{ savingAgent ? '保存中…' : '分配智能体' }} + </button> + </div> + </div> + </div> + </div> +</template> + +<style scoped> +.tg-modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.tg-modal-content { + background: var(--background-color); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg, 12px); + width: min(440px, calc(100vw - 32px)); + padding: 24px; +} + +.tg-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.tg-modal-header h3 { + margin: 0; + font-family: var(--font-display); +} + +.tg-close-btn { + background: none; + border: none; + font-size: 22px; + cursor: pointer; + color: var(--muted); +} + +.tg-steps { + margin: 0 0 16px; + padding-left: 20px; + color: var(--muted); + font-size: 14px; + line-height: 1.7; +} + +.tg-steps code { + font-family: var(--font-mono); + background: var(--background-mute); + padding: 1px 5px; + border-radius: 4px; +} + +.tg-label { + display: block; + font-size: 13px; + font-weight: 600; + margin-bottom: 6px; +} + +.tg-input { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border-color); + border-radius: var(--radius-btn, 8px); + background: var(--background-soft); + color: inherit; + font-size: 14px; +} + +.tg-connected-note { + margin: 0 0 16px; + color: var(--muted); + font-size: 14px; +} + +.tg-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 20px; +} + +.tg-btn { + padding: 9px 16px; + border-radius: var(--radius-btn, 8px); + font-size: 14px; + font-weight: 600; + cursor: pointer; + border: 1px solid var(--border-color); + background: var(--background-soft); + color: inherit; +} + +.tg-btn-primary { + background: var(--accent-solid); + color: var(--on-accent-solid); + border-color: transparent; +} + +.tg-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} +</style> diff --git a/frontend/src/components/integrations/WhatsAppTemplateManager.vue b/frontend/src/components/integrations/WhatsAppTemplateManager.vue new file mode 100644 index 0000000..22c1b9e --- /dev/null +++ b/frontend/src/components/integrations/WhatsAppTemplateManager.vue @@ -0,0 +1,415 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { ref, watch, onMounted } from 'vue' +import channelsService, { + type ChannelAccount, + type WhatsAppTemplate, +} from '@/services/channels' +import BaseModal from '@/components/common/BaseModal.vue' +import { templatePreviewText, templateKey } from '@/utils/whatsappTemplates' +import { languageLabel } from '@/utils/whatsappLanguages' + +const props = defineProps<{ + accounts: ChannelAccount[] +}>() + +const emit = defineEmits<{ (e: 'close'): void }>() + +const accountId = ref(props.accounts[0]?.id ?? '') +const templates = ref<WhatsAppTemplate[]>([]) +const loading = ref(true) +const loadError = ref('') + +/** + * Templates are written in Meta's Template Library, not here — it has ~150 + * pre-written, pre-localised ones built to pass its own review. + * + * The link is fetched rather than built: it is scoped to this number's Business + * Account, and only the server can resolve which that is. It is fetched up + * front so the anchor is a real href by the time it is clicked — resolving it + * on click, after an await, would hand the browser a popup to block. + */ +const libraryUrl = ref('') + +// Guards against a slow response for a previously selected number landing after +// a newer one and rendering its templates under the wrong account. +let loadToken = 0 +// The link's own token: it is keyed to the account, not the list, so the two +// reload on different schedules and cannot invalidate each other. +let libraryToken = 0 + +const load = async () => { + const token = ++loadToken + loadError.value = '' + if (!accountId.value) { + templates.value = [] + loading.value = false + return + } + loading.value = true + try { + const result = await channelsService.listWhatsAppTemplates(accountId.value) + if (token !== loadToken) return + templates.value = result + } catch (error: any) { + if (token !== loadToken) return + loadError.value = error?.response?.data?.detail || '无法加载模板' + templates.value = [] + } finally { + if (token === loadToken) loading.value = false + } +} + +/** + * Fetched alongside the list but deliberately not awaited with it: this costs a + * Graph call, and failing it must not take the list down with it. Without a URL + * the link simply doesn't render. + * + * Only ever called for a change of account. The link is derived from the + * account's Business Account, so re-resolving it when the list reloads would + * spend a Graph round trip to learn what we already know — and the list + * reloads on every "refresh" click. + */ +const loadLibraryUrl = async () => { + const token = ++libraryToken + libraryUrl.value = '' + if (!accountId.value) return + try { + const url = await channelsService.getWhatsAppTemplateLibraryUrl(accountId.value) + if (token === libraryToken) libraryUrl.value = url + } catch { + // Left empty: the list is still useful, and there is nothing to act on. + } +} + +onMounted(() => { + load() + loadLibraryUrl() +}) + +watch(accountId, () => { + load() + loadLibraryUrl() +}) + +</script> + +<template> + <BaseModal title="WhatsApp 模板" width="620px" @close="emit('close')"> + <p class="wtm-intro"> + 客户 24 小时会话窗口关闭后,可通过模板重新发起会话。模板需在 WhatsApp Manager 中编写; + 经 Meta 审核通过后会显示在此处,客服即可发送。 + </p> + + <label v-if="accounts.length > 1" class="wtm-field"> + <span class="wtm-label">号码</span> + <select v-model="accountId" class="wtm-input"> + <option v-for="account in accounts" :key="account.id" :value="account.id"> + {{ account.display_name || account.external_account_id }} + </option> + </select> + </label> + + <div class="wtm-body"> + <div v-if="loading" class="wtm-skeleton" aria-live="polite" aria-busy="true"> + <div v-for="n in 3" :key="n" class="wtm-skeleton-row"></div> + </div> + + <div v-else-if="loadError" class="wtm-empty"> + <font-awesome-icon icon="fa-solid fa-circle-exclamation" /> + <p>{{ loadError }}</p> + </div> + + <div v-else-if="templates.length === 0" class="wtm-empty"> + <p>暂无模板。</p> + </div> + + <ul v-else class="wtm-list"> + <!-- Keyed on name+language: one name can have a row per language, and + Vue needs them distinct. --> + <li v-for="template in templates" :key="templateKey(template)" class="wtm-row"> + <div class="wtm-row-main"> + <div class="wtm-row-head"> + <span class="wtm-name">{{ template.name }}</span> + <span v-if="template.status" class="wtm-pill" :class="template.status.toLowerCase()"> + {{ template.status }} + </span> + </div> + <div class="wtm-row-meta"> + <span v-if="template.category">{{ template.category }}</span> + <span v-if="template.language">· {{ languageLabel(template.language) }}</span> + </div> + <p class="wtm-row-body">{{ templatePreviewText(template) }}</p> + </div> + </li> + </ul> + </div> + + <!-- Meta's Template Library is where these get written: ~150 pre-written, + pre-localised templates, already shaped to pass its review. --> + <div v-if="accountId && !loading" class="wtm-create-guide"> + <h4 class="wtm-guide-title">添加模板</h4> + <ol class="wtm-steps"> + <li>打开 WhatsApp Manager,进入<strong>模板库</strong>。</li> + <li>选择现成模板,或选择<strong>创建模板</strong>自行编写。</li> + <li> + 待 Meta 审核通过后,返回此处并点击<button type="button" class="wtm-link" @click="load">刷新</button>。 + </li> + </ol> + <a + v-if="libraryUrl" + class="wtm-btn wtm-btn-primary wtm-guide-link" + :href="libraryUrl" + target="_blank" + rel="noopener noreferrer" + > + 打开 WhatsApp Manager + <font-awesome-icon icon="fa-solid fa-arrow-up-right-from-square" /> + </a> + </div> + </BaseModal> +</template> + +<style scoped> + + + + + + +.wtm-intro { + margin: 0 0 16px; + color: var(--muted); + font-size: 14px; + line-height: 1.6; +} + +.wtm-body { + flex: 1; +} + +.wtm-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.wtm-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 12px; + border: 1px solid var(--border-color); + border-radius: var(--radius-btn, 8px); + background: var(--background-soft); +} + +.wtm-row-main { + min-width: 0; +} + +.wtm-row-head { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.wtm-name { + font-weight: 600; + font-size: 14px; +} + +/* Neutral by default: Meta adds statuses over time (PENDING_DELETION, IN_APPEAL, + FLAGGED, ...) and an unrecognised one must still read as a pill, not vanish. */ +.wtm-pill { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + padding: 2px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--c-neutral) 16%, transparent); + color: var(--c-neutral); + border: 1px solid color-mix(in srgb, var(--c-neutral) 35%, transparent); +} + +.wtm-pill.approved { + background: color-mix(in srgb, var(--c-positive) 16%, transparent); + color: var(--c-positive); + border-color: color-mix(in srgb, var(--c-positive) 35%, transparent); +} + +.wtm-pill.pending { + background: color-mix(in srgb, var(--c-warn) 16%, transparent); + color: var(--c-warn); + border-color: color-mix(in srgb, var(--c-warn) 35%, transparent); +} + +/* Rejected, paused and disabled all mean "cannot send" */ +.wtm-pill.rejected, +.wtm-pill.paused, +.wtm-pill.disabled { + background: color-mix(in srgb, var(--c-danger) 16%, transparent); + color: var(--c-danger); + border-color: color-mix(in srgb, var(--c-danger) 35%, transparent); +} + +.wtm-row-meta { + font-size: 12px; + color: var(--muted); + margin-top: 4px; +} + +.wtm-row-body { + margin: 6px 0 0; + font-size: 13px; + color: var(--muted); + line-height: 1.5; + word-break: break-word; +} + + + +.wtm-field { + display: block; + margin-bottom: 12px; +} + +.wtm-label { + display: block; + font-size: 13px; + font-weight: 600; + margin-bottom: 6px; +} + +.wtm-input { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border-color); + border-radius: var(--radius-btn, 8px); + background: var(--background-soft); + color: inherit; + font-size: 14px; + font-family: inherit; +} + +.wtm-skeleton { + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Mirrors .wtm-row, the row it stands in for: a real row is legible because of + its border, not its fill (--background-soft is #0E0F14 against a #0B0C10 + modal — a contrast ratio of 1.02). Without the border this was rendering + perfectly and showing nothing, so the modal just looked blank until the + fetch returned. */ +.wtm-skeleton-row { + height: 64px; + border-radius: var(--radius-btn, 8px); + border: 1px solid var(--border-color); + background: var(--o08); + animation: wtm-pulse 1.4s ease-in-out infinite; +} + +/* Only down to 0.6: at 0.5 the trough fell back below the threshold where the + row reads as being there at all. */ +@keyframes wtm-pulse { + 50% { + opacity: 0.6; + } +} + +@media (prefers-reduced-motion: reduce) { + .wtm-skeleton-row { + animation: none; + } +} + +.wtm-empty { + text-align: center; + padding: 24px 8px; + color: var(--muted); + font-size: 14px; +} + +.wtm-empty p { + margin: 0 0 12px; +} + +.wtm-create-guide { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border-color); +} + +.wtm-guide-title { + margin: 0 0 8px; + font-size: 14px; + font-weight: 600; +} + +.wtm-steps { + margin: 0 0 12px; + padding-left: 20px; + color: var(--muted); + font-size: 13px; + line-height: 1.7; +} + +/* A button, because it acts on this page — styled as the inline link it reads as */ +.wtm-link { + background: none; + border: none; + padding: 0; + font: inherit; + color: var(--c-info); + cursor: pointer; + text-decoration: underline; +} + +.wtm-guide-link { + display: inline-flex; + align-items: center; + gap: 8px; + text-decoration: none; +} + +.wtm-btn { + padding: 8px 14px; + border-radius: var(--radius-btn, 8px); + font-size: 13px; + font-weight: 600; + cursor: pointer; + border: 1px solid var(--border-color); + background: var(--background-soft); + color: inherit; +} + +.wtm-btn-primary { + background: var(--accent-solid); + color: var(--on-accent-solid); + border-color: transparent; +} + + +</style> diff --git a/frontend/src/components/jira/JiraTicketModal.vue b/frontend/src/components/jira/JiraTicketModal.vue new file mode 100644 index 0000000..0fdc7e5 --- /dev/null +++ b/frontend/src/components/jira/JiraTicketModal.vue @@ -0,0 +1,438 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { useJiraTicket } from '@/composables/useJiraTicket' + +const props = defineProps<{ + chatId: string + initialSummary?: string +}>() + +const emit = defineEmits<{ + (e: 'close'): void + (e: 'ticketCreated', ticketKey: string): void +}>() + +const { + jiraConnected, + jiraLoading, + showTicketModal, + jiraProjects, + jiraIssueTypes, + jiraPriorities, + selectedProject, + selectedIssueType, + selectedPriority, + ticketSummary, + ticketDescription, + loadingProjects, + loadingIssueTypes, + loadingPriorities, + creatingTicket, + createdTicketKey, + priorityAvailable, + checkingPriorityAvailability, + isFormValid, + checkJiraStatus, + handleProjectChange, + handleIssueTypeChange, + openTicketModal, + closeTicketModal, + submitTicket +} = useJiraTicket() + +// Initialize the modal +const initModal = async () => { + await openTicketModal(props.initialSummary) +} + +// Handle close +const handleClose = () => { + closeTicketModal() + emit('close') +} + +// Handle submit +const handleSubmit = async () => { + const ticketKey = await submitTicket(props.chatId) + if (ticketKey) { + emit('ticketCreated', ticketKey) + handleClose() + } +} + +// Initialize on mount +initModal() +</script> + +<template> + <div class="ticket-modal"> + <div class="ticket-modal-content"> + <div class="ticket-modal-header"> + <h3>创建 Jira 问题</h3> + <button class="close-modal-btn" @click="handleClose"> + <font-awesome-icon icon="fa-solid fa-times" /> + </button> + </div> + + <div v-if="jiraLoading" class="ticket-loading"> + <font-awesome-icon icon="fa-solid fa-spinner" spin /> + 正在检查 Jira 连接… + </div> + + <div v-else-if="!jiraConnected" class="ticket-not-connected"> + <font-awesome-icon icon="fa-solid fa-exclamation-triangle" /> + <p>Jira 尚未连接,请在设置中连接 Jira。</p> + <router-link to="/settings/integrations" class="connect-jira-btn"> + 连接 Jira + </router-link> + </div> + + <div v-else class="ticket-form"> + <div class="form-group"> + <label for="ticket-project">项目 *</label> + <div v-if="loadingProjects" class="loading-indicator"> + <font-awesome-icon icon="fa-solid fa-spinner" spin /> 加载项目中… + </div> + <select + v-else + id="ticket-project" + v-model="selectedProject" + @change="handleProjectChange(selectedProject)" + > + <option value="">选择项目</option> + <option + v-for="project in jiraProjects" + :key="project.id" + :value="project.key" + > + {{ project.name }} + </option> + </select> + </div> + + <div class="form-group"> + <label for="ticket-issue-type">问题类型 *</label> + <div v-if="loadingIssueTypes" class="loading-indicator"> + <font-awesome-icon icon="fa-solid fa-spinner" spin /> 加载问题类型中… + </div> + <select + v-else + id="ticket-issue-type" + v-model="selectedIssueType" + :disabled="!selectedProject" + @change="handleIssueTypeChange(selectedIssueType)" + > + <option value="">选择问题类型</option> + <option + v-for="issueType in jiraIssueTypes" + :key="issueType.id" + :value="issueType.id" + > + {{ issueType.name }} + </option> + </select> + </div> + + <div v-if="selectedProject && selectedIssueType" class="form-group"> + <div v-if="checkingPriorityAvailability" class="loading-indicator"> + <font-awesome-icon icon="fa-solid fa-spinner" spin /> 正在检查优先级字段… + </div> + + <template v-else-if="priorityAvailable"> + <label for="ticket-priority">优先级</label> + <div v-if="loadingPriorities" class="loading-indicator"> + <font-awesome-icon icon="fa-solid fa-spinner" spin /> 加载优先级中… + </div> + <select + v-else + id="ticket-priority" + v-model="selectedPriority" + > + <option value="">选择优先级</option> + <option + v-for="priority in jiraPriorities" + :key="priority.id" + :value="priority.id" + > + {{ priority.name }} + </option> + </select> + </template> + + <div v-else class="priority-unavailable"> + <font-awesome-icon icon="fa-solid fa-info-circle" /> + 此项目 / 问题类型不支持优先级字段 + </div> + </div> + + <div class="form-group"> + <label for="ticket-summary">主题 *</label> + <input + type="text" + id="ticket-summary" + v-model="ticketSummary" + placeholder="输入问题主题" + > + </div> + + <div class="form-group"> + <label for="ticket-description">描述</label> + <textarea + id="ticket-description" + v-model="ticketDescription" + placeholder="输入问题描述" + rows="4" + ></textarea> + </div> + + <div class="ticket-actions"> + <button class="cancel-btn" @click="handleClose">取消</button> + <button + class="create-btn" + @click="handleSubmit" + :disabled="!isFormValid || creatingTicket" + > + <font-awesome-icon v-if="creatingTicket" icon="fa-solid fa-spinner" spin /> + {{ creatingTicket ? '创建中…' : '创建问题' }} + </button> + </div> + + <div v-if="createdTicketKey" class="ticket-created"> + <font-awesome-icon icon="fa-solid fa-check-circle" /> + 问题已创建:<strong>{{ createdTicketKey }}</strong> + </div> + </div> + </div> + </div> +</template> + +<style scoped> +.ticket-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.ticket-modal-content { + background: var(--background-color); + border-radius: 8px; + width: 500px; + max-width: 90%; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.ticket-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 24px; + border-bottom: 1px solid var(--border-color); +} + +.ticket-modal-header h3 { + margin: 0; + color: var(--text-primary); + font-size: 18px; +} + +.close-modal-btn { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 18px; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + width: 32px; + height: 32px; +} + +.close-modal-btn:hover { + background: var(--background-mute); + color: var(--text-primary); +} + +.ticket-loading, +.ticket-not-connected { + padding: 24px; + text-align: center; + color: var(--text-muted); +} + +.ticket-loading svg, +.ticket-not-connected svg { + font-size: 24px; + margin-bottom: 16px; + display: block; +} + +.ticket-not-connected svg { + color: var(--warning); +} + +.connect-jira-btn { + display: inline-block; + margin-top: 16px; + padding: 8px 16px; + background: var(--accent-solid); + color: var(--on-accent-solid); + border-radius: 4px; + text-decoration: none; + font-weight: 500; + transition: all 0.2s; +} + +.connect-jira-btn:hover { + filter: brightness(1.1); + transform: translateY(-1px); +} + +.ticket-form { + padding: 24px; +} + +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + margin-bottom: 8px; + font-weight: 500; + color: var(--text-primary); +} + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--background-color); + color: var(--text-primary); + font-size: 14px; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + border-color: var(--primary-color); + outline: none; +} + +.form-group select:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.loading-indicator { + padding: 10px 0; + color: var(--text-muted); + font-size: 14px; +} + +.loading-indicator svg { + margin-right: 8px; +} + +.ticket-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 24px; +} + +.cancel-btn { + background: var(--background-mute); + color: var(--text-primary); + border: none; + border-radius: 4px; + padding: 8px 16px; + cursor: pointer; + font-size: 14px; +} + +.create-btn { + background: var(--accent-solid); + color: var(--on-accent-solid); + border: none; + border-radius: 4px; + padding: 8px 16px; + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + gap: 8px; +} + +.create-btn:hover { + filter: brightness(1.1); + transform: translateY(-1px); +} + +.create-btn:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; + filter: none; +} + +.ticket-created { + margin-top: 16px; + padding: 12px; + background: var(--success-light); + color: var(--success); + border-radius: 4px; + display: flex; + align-items: center; + gap: 8px; +} + +.ticket-created svg { + font-size: 18px; +} + +.priority-unavailable { + padding: 10px; + background-color: var(--background-mute); + border-radius: 4px; + color: var(--text-muted); + font-size: 14px; + display: flex; + align-items: center; + gap: 8px; +} + +.priority-unavailable svg { + color: var(--warning); + font-size: 16px; +} +</style> \ No newline at end of file diff --git a/frontend/src/components/notifications/EnablePushPrompt.vue b/frontend/src/components/notifications/EnablePushPrompt.vue new file mode 100644 index 0000000..4c9adb5 --- /dev/null +++ b/frontend/src/components/notifications/EnablePushPrompt.vue @@ -0,0 +1,205 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts" name="EnablePushPrompt"> +import { ref, onMounted } from 'vue' +import { isShopifyEmbedded } from '@/pwa/register' +import { usePWAInstall } from '@/composables/usePWAInstall' +import { navIconSvg } from '@/components/layout/navIcons' + +const emit = defineEmits<{ + // Parent (DashboardLayout) owns the notifications composable instance and + // performs the actual permission request from this user gesture. + (e: 'enable'): void +}>() + +const SNOOZE_KEY = 'cm-push-prompt-snoozed-at' +const SNOOZE_MS = 14 * 24 * 60 * 60 * 1000 + +const visible = ref(false) +const needsInstallFirst = ref(false) +const { isIOS, isStandalone } = usePWAInstall() + +const isSnoozed = () => { + const at = Number(localStorage.getItem(SNOOZE_KEY) || 0) + return at > 0 && Date.now() - at < SNOOZE_MS +} + +onMounted(() => { + if (isShopifyEmbedded() || isSnoozed()) return + + if (isIOS && !isStandalone.value) { + // Safari tab on iOS: the Push API only exists once installed to Home Screen + needsInstallFirst.value = true + visible.value = true + return + } + + if ('Notification' in window && Notification.permission === 'default') { + visible.value = true + } +}) + +const snooze = () => { + localStorage.setItem(SNOOZE_KEY, String(Date.now())) + visible.value = false +} + +const enable = () => { + emit('enable') + // The browser permission dialog takes over from here + visible.value = false +} +</script> + +<template> + <Transition name="push-prompt"> + <div v-if="visible" class="push-prompt" role="dialog" aria-label="开启通知"> + <div class="bell-tile" aria-hidden="true" v-html="navIconSvg('bell', 28)"></div> + + <template v-if="needsInstallFirst"> + <div class="prompt-title">安装后接收通知</div> + <div class="prompt-body"> + 请先把 ChatterMate 添加到主屏幕 — 在 iPhone 上,推送通知仅在已安装的应用中生效。 + 点按分享图标,然后选择“添加到主屏幕”。 + </div> + <button type="button" class="secondary-btn" @click="snooze">知道了</button> + </template> + + <template v-else> + <div class="prompt-title">客户需要你时及时获知</div> + <div class="prompt-body"> + AI 客服转接会话时,我们会立即通知你 — 不让客户久等。 + </div> + <button type="button" class="primary-btn" @click="enable"> + 开启通知 + </button> + <button type="button" class="secondary-btn" @click="snooze">暂不</button> + </template> + </div> + </Transition> +</template> + +<style scoped> +.push-prompt { + position: fixed; + right: 20px; + bottom: 20px; + z-index: var(--z-prompt); + width: 340px; + max-width: calc(100vw - 40px); + background: var(--bg2); + border: 1px solid var(--o10); + border-radius: var(--radius-card); + box-shadow: var(--shadow-lg); + padding: var(--space-lg); + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.bell-tile { + width: 60px; + height: 60px; + border-radius: 18px; + background: var(--teal-bg); + border: 1px solid var(--teal-border); + color: var(--c-teal); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: var(--space-md); +} + +.prompt-title { + font-family: var(--font-display); + font-size: 19px; + font-weight: var(--font-weight-bold); + letter-spacing: var(--tracking-display); + line-height: 1.2; + color: var(--text); +} + +.prompt-body { + font-size: 13.5px; + color: var(--muted); + line-height: 1.5; + margin-top: 10px; +} + +.primary-btn { + width: 100%; + height: 48px; + margin-top: var(--space-lg); + border: none; + border-radius: var(--radius-btn); + background: var(--accent-solid); + color: var(--on-accent-solid); + font-family: var(--font-sans); + font-size: 15px; + font-weight: var(--font-weight-bold); + cursor: pointer; + transition: filter var(--transition-fast); +} + +.primary-btn:hover:not(:disabled) { + filter: brightness(1.05); +} + +.secondary-btn { + margin-top: 12px; + background: none; + border: none; + color: var(--muted); + font-family: var(--font-sans); + font-size: 14px; + font-weight: var(--font-weight-medium); + cursor: pointer; + padding: 8px; +} + +.secondary-btn:hover { + color: var(--text); +} + +/* Mobile: bottom sheet over the nav, safe-area aware */ +@media (max-width: 768px) { + .push-prompt { + left: 0; + right: 0; + bottom: 0; + width: 100%; + max-width: 100%; + border-radius: 24px 24px 0 0; + border-left: none; + border-right: none; + border-bottom: none; + padding-bottom: calc(var(--space-lg) + var(--safe-bottom)); + } +} + +.push-prompt-enter-active, +.push-prompt-leave-active { + transition: opacity var(--transition-normal), transform var(--transition-normal); +} + +.push-prompt-enter-from, +.push-prompt-leave-to { + opacity: 0; + transform: translateY(16px); +} +</style> diff --git a/frontend/src/components/notifications/NotificationList.vue b/frontend/src/components/notifications/NotificationList.vue new file mode 100644 index 0000000..7d6010c --- /dev/null +++ b/frontend/src/components/notifications/NotificationList.vue @@ -0,0 +1,580 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { ref, computed, onMounted } from 'vue' +import { useRouter } from 'vue-router' +import { notificationService, type Notification } from '@/services/notification' +import { getNotificationIcon } from './notificationIcons' +import { conversationSessionUrl } from '@/pwa/pushContract' + +const router = useRouter() + +const props = defineProps<{ + isOpen: boolean +}>() + +const emit = defineEmits<{ + close: [], + 'notification-read': [] +}>() + +const notifications = ref<Notification[]>([]) +const isLoading = ref(false) +const error = ref('') + +const fetchNotifications = async () => { + try { + isLoading.value = true + notifications.value = await notificationService.getNotifications() + } catch (err) { + error.value = '通知加载失败' + console.error('Error fetching notifications:', err) + } finally { + isLoading.value = false + } +} + +const markAsRead = async (id: number) => { + try { + await notificationService.markAsRead(id) + const notification = notifications.value.find(n => n.id === id) + if (notification) { + notification.is_read = true + emit('notification-read') + } + } catch (err) { + console.error('Error marking notification as read:', err) + } +} + +// Chat notifications (new chat, transfer, assignment) carry the session id in +// their metadata; clicking one marks it read and deep-links to that +// conversation, closing the drawer. +const sessionIdOf = (notification: Notification): string | undefined => { + const id = notification.notification_metadata?.session_id + return id ? String(id) : undefined +} + +const handleNotificationClick = async (notification: Notification) => { + if (!notification.is_read) { + await markAsRead(notification.id) + } + const sessionId = sessionIdOf(notification) + if (sessionId) { + emit('close') + router.push(conversationSessionUrl(sessionId)) + } +} + +const activeFilter = ref<'all' | 'unread'>('all') + +const filteredNotifications = computed(() => + activeFilter.value === 'unread' + ? notifications.value.filter(n => !n.is_read) + : notifications.value +) + +const hasUnread = computed(() => notifications.value.some(n => !n.is_read)) + +// One request that clears everything, not one per loaded row. The list is a +// 50-row page, so looping it could never clear a badge showing 99+ however many +// times you tapped. +const markAllRead = async () => { + try { + await notificationService.markAllAsRead() + notifications.value.forEach(n => { n.is_read = true }) + emit('notification-read') + } catch (err) { + console.error('Error marking all notifications as read:', err) + error.value = '无法全部标为已读' + } +} + +const deleteNotification = async (id: number) => { + const previous = notifications.value + // Optimistic: the row disappears on tap, and comes back if the call fails. + notifications.value = notifications.value.filter(n => n.id !== id) + try { + await notificationService.deleteNotification(id) + emit('notification-read') + } catch (err) { + console.error('Error deleting notification:', err) + notifications.value = previous + error.value = '无法删除该通知' + } +} + +const clearAll = async () => { + const previous = notifications.value + notifications.value = [] + try { + await notificationService.clearAll() + emit('notification-read') + } catch (err) { + console.error('Error clearing notifications:', err) + notifications.value = previous + error.value = '无法清空通知' + } +} + +const formatTime = (timestamp: string): string => { + const date = new Date(timestamp) + const now = new Date() + const diff = now.getTime() - date.getTime() + + // Less than a minute + if (diff < 60000) { + return '刚刚' + } + + // Less than an hour + if (diff < 3600000) { + const mins = Math.floor(diff / 60000) + return `${mins} 分钟前` + } + + // Less than a day + if (diff < 86400000) { + const hours = Math.floor(diff / 3600000) + return `${hours} 小时前` + } + + // Show date + return date.toLocaleDateString() +} + +onMounted(fetchNotifications) +</script> + +<template> + <div class="notification-drawer" :class="{ open: isOpen }"> + <div class="drawer-header"> + <h3>通知</h3> + <div class="header-actions"> + <button + class="refresh-button" + @click="fetchNotifications" + :disabled="isLoading" + :class="{ 'loading': isLoading }" + title="刷新" + > + <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.3"/> + </svg> + </button> + <button class="close-button" @click="emit('close')" title="关闭">×</button> + </div> + </div> + + <div class="drawer-filter"> + <div class="filter-tabs"> + <button class="filter-tab" :class="{ active: activeFilter === 'all' }" @click="activeFilter = 'all'">全部</button> + <button class="filter-tab" :class="{ active: activeFilter === 'unread' }" @click="activeFilter = 'unread'">未读</button> + </div> + <div class="filter-actions"> + <button class="mark-all" :disabled="!hasUnread" @click="markAllRead">全部已读</button> + <button class="mark-all" :disabled="!notifications.length" @click="clearAll">清空</button> + </div> + </div> + + <div class="drawer-content"> + <div v-if="isLoading" class="state-message"> + 正在加载通知… + </div> + + <div v-else-if="error" class="state-message"> + {{ error }} + </div> + + <div v-else-if="!filteredNotifications.length" class="empty-state"> + <div class="empty-title">全部看完了</div> + <div class="empty-sub">暂无{{ activeFilter === 'unread' ? '未读' : '' }}通知。</div> + </div> + + <div v-else class="notifications"> + <div v-for="notification in filteredNotifications" :key="notification.id" class="notification-item" + :class="{ unread: !notification.is_read, linkable: sessionIdOf(notification) }" + @click="handleNotificationClick(notification)"> + <span class="notification-icon-wrap"> + <img v-if="getNotificationIcon(notification.type.toLowerCase())" + :src="getNotificationIcon(notification.type.toLowerCase())" class="notification-type-icon" + :alt="notification.type" /> + </span> + <div class="notification-body"> + <div class="notification-top"> + <span class="notification-title">{{ notification.title }}</span> + <span class="notification-time">{{ formatTime(notification.created_at) }}</span> + </div> + <div class="notification-message">{{ notification.message }}</div> + </div> + <span class="unread-dot" :class="{ on: !notification.is_read }"></span> + <!-- .stop: the row itself deep-links to the conversation --> + <button + class="delete-notification" + :aria-label="`删除通知:${notification.title}`" + title="删除" + @click.stop="deleteNotification(notification.id)" + >×</button> + </div> + </div> + </div> + </div> +</template> + +<style scoped> +.notification-drawer { + position: fixed; + top: 0; + right: -380px; + width: 380px; + max-width: 90vw; + height: 100vh; + background: var(--bg2); + border-left: 1px solid var(--o08); + transition: right 0.3s ease; + z-index: var(--z-drawer); + display: flex; + flex-direction: column; +} + +.notification-drawer.open { + right: 0; + /* Only while open. Parked at right:-380px the panel is off-screen, but a + shadow cast 20px leftward with a 50px blur still reaches ~45px back onto + the page, above the content — and this drawer is mounted by + DashboardLayout on every screen, so the band followed you everywhere. + Invisible on the dark theme, a grey edge on the light one. */ + box-shadow: -20px 0 50px rgba(0, 0, 0, 0.4); +} + +.drawer-header { + padding: 20px 20px 16px; + border-bottom: 1px solid var(--o07); + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg2); +} + +.drawer-header h3 { + margin: 0; + font-family: var(--font-display); + font-size: 18px; + font-weight: var(--font-weight-bold); + color: var(--text); +} + +.header-actions { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.refresh-button { + background: transparent; + border: 1px solid var(--o12); + border-radius: 8px; + cursor: pointer; + width: 30px; + height: 30px; + color: var(--muted); + transition: background-color 0.2s ease, color 0.2s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.refresh-button:hover { + background: var(--o06); + color: var(--text); +} + +.refresh-button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.refresh-button.loading svg { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.close-button { + background: transparent; + border: 1px solid var(--o12); + font-size: 15px; + cursor: pointer; + color: var(--muted); + transition: background-color 0.2s ease, color 0.2s ease; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; +} + +.close-button:hover { + background: var(--o06); + color: var(--text); +} + +/* Filter row */ +.drawer-filter { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 18px; + border-bottom: 1px solid var(--o07); +} + +.filter-tabs { + display: flex; + gap: 4px; + padding: 3px; + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 9px; +} + +.filter-tab { + background: transparent; + border: none; + cursor: pointer; + padding: 5px 13px; + border-radius: 6px; + color: var(--muted); + font-family: var(--font-sans); + font-size: 13px; + font-weight: var(--font-weight-medium); + transition: background-color 0.15s ease, color 0.15s ease; +} + +.filter-tab.active { + background: var(--accent-bg-12); + color: var(--accent-ink); +} + +.filter-actions { + display: flex; + align-items: center; + gap: 14px; +} + +/* Sits over the row; the row itself is the deep-link, so the button stops + propagation. 32px keeps it thumb-reachable without crowding the text. */ +.delete-notification { + flex-shrink: 0; + width: 32px; + height: 32px; + border: none; + background: none; + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 20px; + line-height: 1; + cursor: pointer; + opacity: 0.55; + transition: opacity 0.15s ease, background-color 0.15s ease; +} + +.delete-notification:hover, +.delete-notification:focus-visible { + opacity: 1; + background: var(--o07); +} + +.mark-all { + background: none; + border: none; + cursor: pointer; + color: var(--accent-ink); + font-family: var(--font-sans); + font-size: 13px; + font-weight: var(--font-weight-medium); + transition: filter 0.15s ease, opacity 0.15s ease; +} + +.mark-all:hover:not(:disabled) { + filter: brightness(1.1); +} + +.mark-all:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.drawer-content { + flex: 1; + overflow-y: auto; + min-height: 0; +} + +.notification-item { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 18px; + border-bottom: 1px solid var(--o05); + cursor: pointer; + transition: background-color 0.2s ease; +} + +.notification-item:hover { + background: var(--o03); +} + +/* Chat notifications deep-link to their conversation — hint it on hover. */ +.notification-item.linkable { + position: relative; +} + +.notification-item.linkable::after { + content: '打开 ›'; + position: absolute; + right: 18px; + bottom: 10px; + font-size: 11px; + font-weight: var(--font-weight-medium); + color: var(--accent-ink); + opacity: 0; + transition: opacity 0.15s ease; +} + +.notification-item.linkable:hover::after { + opacity: 1; +} + +.notification-item.unread { + background: var(--accent-bg-06); +} + +.notification-icon-wrap { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; +} + +.notification-type-icon { + width: 18px; + height: 18px; + flex-shrink: 0; + filter: var(--icon-filter, brightness(0) invert(1)); + opacity: var(--icon-opacity, 0.55); +} + +.notification-body { + flex: 1; + min-width: 0; +} + +.notification-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.notification-title { + font-size: 14px; + font-weight: var(--font-weight-semibold); + color: var(--text2); + word-break: break-word; +} + +.notification-time { + font-size: 11px; + color: var(--muted2); + flex-shrink: 0; + white-space: nowrap; +} + +.notification-message { + font-size: 12.5px; + color: var(--muted); + line-height: 1.5; + margin-top: 3px; + word-break: break-word; +} + +.unread-dot { + flex-shrink: 0; + width: 7px; + height: 7px; + border-radius: 50%; + background: transparent; + margin-top: 6px; +} + +.unread-dot.on { + background: var(--accent-solid); +} + +.state-message { + padding: var(--space-md); + text-align: center; + color: var(--muted2); +} + +.empty-state { + padding: 60px 24px; + text-align: center; +} + +.empty-title { + font-size: 14px; + color: var(--muted2); + margin-bottom: 4px; +} + +.empty-sub { + font-size: 12.5px; + color: var(--faint); +} + +/* Mobile: full-screen panel per design */ +@media (max-width: 768px) { + .notification-drawer { + width: 100%; + max-width: 100vw; + right: -100vw; + height: 100vh; + height: 100dvh; + border-left: none; + } + + .notification-drawer.open { + right: 0; + } + + .drawer-header { + padding-top: calc(20px + var(--safe-top)); + } + + .drawer-content { + padding-bottom: var(--safe-bottom); + } +} +</style> \ No newline at end of file diff --git a/frontend/src/components/notifications/notificationIcons.ts b/frontend/src/components/notifications/notificationIcons.ts new file mode 100644 index 0000000..92aa9ad --- /dev/null +++ b/frontend/src/components/notifications/notificationIcons.ts @@ -0,0 +1,30 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import chatIcon from '@/assets/chat.svg' +import knowledgeIcon from '@/assets/knowledge.svg' + +export const getNotificationIcon = (type: string): string => { + switch (type) { + case 'knowledge_processed': + case 'knowledge_failed': + return knowledgeIcon + case 'chat': + return chatIcon + default: + return '' + } +} diff --git a/frontend/src/components/tickets/EvidenceCitationRow.vue b/frontend/src/components/tickets/EvidenceCitationRow.vue new file mode 100644 index 0000000..4fb702a --- /dev/null +++ b/frontend/src/components/tickets/EvidenceCitationRow.vue @@ -0,0 +1,182 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { ref } from 'vue' +import { toast } from 'vue-sonner' +import type { InvestigationEvent } from '@/types/ticket' + +const props = defineProps<{ event: InvestigationEvent }>() + +const isExpanded = ref(false) + +function copyQuery() { + if (!props.event.tool_input) return + navigator.clipboard + .writeText(props.event.tool_input) + .then(() => toast.success('查询已复制')) + .catch(() => {}) +} + +function formatTime(iso?: string | null): string { + if (!iso) return '' + return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) +} +</script> + +<template> + <div class="evidence-row" :class="{ failed: !!event.error }"> + <button class="evidence-head" @click="isExpanded = !isExpanded"> + <span class="tool-badge">{{ event.connector_name || 'MCP' }}</span> + <span class="tool-name">{{ event.tool_name }}</span> + <span v-if="event.error" class="error-tag">错误</span> + <span class="evidence-meta"> + <span v-if="event.duration_ms != null" class="mono">{{ event.duration_ms }}ms</span> + <span class="mono">{{ formatTime(event.created_at) }}</span> + <span class="chevron">{{ isExpanded ? '▾' : '▸' }}</span> + </span> + </button> + <div v-if="isExpanded" class="evidence-body"> + <template v-if="event.tool_input"> + <div class="block-label-row"> + <span class="block-label">查询</span> + <button class="copy-btn" @click="copyQuery">复制</button> + </div> + <pre class="code-block">{{ event.tool_input }}</pre> + </template> + <template v-if="event.error"> + <div class="block-label">错误</div> + <pre class="code-block error">{{ event.error }}</pre> + </template> + <template v-else-if="event.tool_result"> + <div class="block-label">结果</div> + <pre class="code-block">{{ event.tool_result }}</pre> + </template> + </div> + </div> +</template> + +<style scoped> +.evidence-row { + border: 1px solid var(--o07); + border-radius: 10px; + background: var(--bg2); + overflow: hidden; +} +.evidence-row.failed { + border-color: color-mix(in srgb, var(--c-danger) 35%, transparent); +} +.evidence-head { + width: 100%; + display: flex; + align-items: center; + gap: 9px; + padding: 8px 11px; + background: transparent; + border: none; + cursor: pointer; + text-align: left; +} +.tool-badge { + font-family: var(--font-mono); + font-size: 9.5px; + font-weight: var(--font-weight-semibold); + letter-spacing: 0.05em; + color: var(--c-teal); + background: var(--teal-bg-10); + padding: 2px 7px; + border-radius: 6px; + flex-shrink: 0; + max-width: 140px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tool-name { + font-family: var(--font-mono); + font-size: 11.5px; + color: var(--text3); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.error-tag { + font-family: var(--font-mono); + font-size: 9.5px; + color: var(--c-danger); + background: color-mix(in srgb, var(--c-danger) 12%, transparent); + padding: 2px 7px; + border-radius: 6px; +} +.evidence-meta { + margin-left: auto; + display: flex; + align-items: center; + gap: 9px; + color: var(--faint); + font-size: 10.5px; + flex-shrink: 0; +} +.mono { + font-family: var(--font-mono); +} +.chevron { + color: var(--muted2); +} +.evidence-body { + padding: 0 11px 11px; +} +.block-label-row { + display: flex; + align-items: center; + justify-content: space-between; +} +.block-label { + font-family: var(--font-mono); + font-size: 9.5px; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--faint); + margin: 8px 0 5px; +} +.copy-btn { + font-size: 10.5px; + color: var(--accent-ink); + background: var(--accent-bg-08); + border: none; + padding: 2px 8px; + border-radius: 6px; + cursor: pointer; +} +.code-block { + margin: 0; + padding: 9px 11px; + background: var(--surface); + border: 1px solid var(--o07); + border-radius: 8px; + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.55; + color: var(--text3); + white-space: pre-wrap; + word-break: break-word; + max-height: 260px; + overflow-y: auto; +} +.code-block.error { + color: var(--c-danger); +} +</style> diff --git a/frontend/src/components/tickets/HypothesisCard.vue b/frontend/src/components/tickets/HypothesisCard.vue new file mode 100644 index 0000000..eb4d512 --- /dev/null +++ b/frontend/src/components/tickets/HypothesisCard.vue @@ -0,0 +1,200 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed, ref } from 'vue' +import type { InvestigationEvent, InvestigationHypothesis } from '@/types/ticket' +import { hypothesisMeta } from './ticketMeta' +import EvidenceCitationRow from './EvidenceCitationRow.vue' + +const props = defineProps<{ + hypothesis: InvestigationHypothesis + events: InvestigationEvent[] +}>() + +const isExpanded = ref(false) +const meta = computed(() => hypothesisMeta(props.hypothesis.status)) +const isTesting = computed(() => props.hypothesis.status === 'testing') +const evidence = computed(() => + props.events.filter((e) => e.hypothesis_id === props.hypothesis.id && e.event_type === 'tool_call'), +) +const confidencePct = computed(() => + props.hypothesis.confidence != null ? Math.round(props.hypothesis.confidence * 100) : null, +) +</script> + +<template> + <div class="hypothesis-card" :id="`hypothesis-${hypothesis.idx}`"> + <button class="hypo-head" @click="isExpanded = !isExpanded"> + <span class="hypo-id mono">H{{ hypothesis.idx }}</span> + <span class="hypo-title">{{ hypothesis.title }}</span> + <span class="hypo-chip" :style="{ color: meta.color, borderColor: meta.color }"> + <span v-if="isTesting" class="pulse-dot" :style="{ background: meta.color }"></span> + {{ meta.label }} + </span> + <span class="chevron">{{ isExpanded ? '▾' : '▸' }}</span> + </button> + + <div v-if="confidencePct != null" class="confidence-row"> + <div class="confidence-track"> + <div + class="confidence-fill" + :style="{ width: `${confidencePct}%`, background: meta.color }" + ></div> + </div> + <span class="confidence-value mono" :style="{ color: meta.color }">{{ (hypothesis.confidence ?? 0).toFixed(2) }}</span> + </div> + + <div v-if="isExpanded" class="hypo-body"> + <p v-if="hypothesis.rationale" class="hypo-text"> + <span class="text-label">理由 · </span>{{ hypothesis.rationale }} + </p> + <p v-if="hypothesis.conclusion" class="hypo-text"> + <span class="text-label">结论 · </span>{{ hypothesis.conclusion }} + </p> + <template v-if="evidence.length"> + <div class="evidence-label">证据——{{ evidence.length }} 次工具调用</div> + <div class="evidence-list"> + <EvidenceCitationRow v-for="event in evidence" :key="event.id" :event="event" /> + </div> + </template> + <p v-else class="no-evidence">无工具证据——基于工单上下文的推理验证。</p> + </div> + </div> +</template> + +<style scoped> +.hypothesis-card { + border: 1px solid var(--o08); + border-radius: 12px; + background: var(--surface); + padding: 11px 13px; +} +.hypo-head { + width: 100%; + display: flex; + align-items: center; + gap: 10px; + background: transparent; + border: none; + cursor: pointer; + text-align: left; + padding: 0; +} +.hypo-id { + font-size: 11px; + font-weight: var(--font-weight-bold); + color: var(--muted2); + background: var(--o05); + border: 1px solid var(--o08); + padding: 2px 7px; + border-radius: 6px; + flex-shrink: 0; +} +.mono { + font-family: var(--font-mono); +} +.hypo-title { + font-size: 13px; + font-weight: var(--font-weight-semibold); + color: var(--text); + flex: 1; + min-width: 0; +} +.hypo-chip { + display: inline-flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: 10px; + font-weight: var(--font-weight-semibold); + letter-spacing: 0.04em; + border: 1px solid; + padding: 2px 8px; + border-radius: 20px; + flex-shrink: 0; +} +.pulse-dot { + width: 6px; + height: 6px; + border-radius: 50%; + animation: hypo-pulse 1.3s ease-in-out infinite; +} +@keyframes hypo-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} +.chevron { + color: var(--muted2); + font-size: 11px; + flex-shrink: 0; +} +.confidence-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 9px; +} +.confidence-track { + flex: 1; + height: 4px; + border-radius: 3px; + background: var(--o07); + overflow: hidden; +} +.confidence-fill { + height: 100%; + border-radius: 3px; + transition: width 0.4s ease; +} +.confidence-value { + font-size: 10.5px; + font-weight: var(--font-weight-semibold); +} +.hypo-body { + margin-top: 11px; + border-top: 1px solid var(--o06); + padding-top: 11px; +} +.hypo-text { + margin: 0 0 9px; + font-size: 12.5px; + line-height: 1.55; + color: var(--text3); +} +.text-label { + color: var(--faint); + font-size: 11px; +} +.evidence-label { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--faint); + margin: 11px 0 7px; +} +.evidence-list { + display: flex; + flex-direction: column; + gap: 7px; +} +.no-evidence { + margin: 0; + font-size: 12px; + color: var(--faint); +} +</style> diff --git a/frontend/src/components/tickets/InvestigationNoteForm.vue b/frontend/src/components/tickets/InvestigationNoteForm.vue new file mode 100644 index 0000000..ed46b39 --- /dev/null +++ b/frontend/src/components/tickets/InvestigationNoteForm.vue @@ -0,0 +1,133 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +/** + * A note that steers the next investigation. Used by the L2 approval banner + * when rejecting a proposal, and by the investigation panel for a plain + * guided re-run — the two differ only in wording and button colour, so the + * autofocus, the keyboard shortcuts and the trim live here rather than twice. + */ +import { onMounted, ref } from 'vue' + +withDefaults( + defineProps<{ + placeholder: string + submitLabel: string + tone?: 'danger' | 'accent' + busy?: boolean + }>(), + { tone: 'accent', busy: false }, +) + +const emit = defineEmits<{ + (e: 'submit', note: string): void + (e: 'cancel'): void +}>() + +const note = ref('') +const input = ref<HTMLTextAreaElement | null>(null) + +onMounted(() => input.value?.focus()) + +// An empty note is allowed: re-running unchanged is a legitimate thing to +// want, and the reject flow has always permitted a reason-less rejection. +const submit = () => emit('submit', note.value.trim()) +</script> + +<template> + <div class="note-form"> + <textarea + ref="input" + v-model="note" + class="note-input" + :placeholder="placeholder" + @keydown.esc.stop="emit('cancel')" + @keydown.enter.meta.stop.prevent="submit" + @keydown.enter.ctrl.stop.prevent="submit" + ></textarea> + <slot /> + <div class="note-actions"> + <button type="button" class="cancel-btn" @click="emit('cancel')">取消</button> + <button + type="button" + class="submit-btn" + :class="tone" + :disabled="busy" + @click="submit" + > + {{ submitLabel }} + </button> + </div> + </div> +</template> + +<style scoped> +.note-form { + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 11px; + padding: 12px; +} +.note-input { + width: 100%; + min-height: 68px; + resize: vertical; + background: var(--bg2); + border: 1px solid var(--o10); + border-radius: 9px; + padding: 9px 11px; + color: var(--text); + font-size: 12.5px; + line-height: 1.5; + outline: none; +} +.note-actions { + display: flex; + justify-content: flex-end; + gap: 9px; + margin-top: 10px; +} +.cancel-btn { + padding: 7px 13px; + background: var(--o05); + border: 1px solid var(--o10); + color: var(--muted); + border-radius: 9px; + font-size: 12.5px; + cursor: pointer; +} +.submit-btn { + padding: 7px 15px; + border: none; + border-radius: 9px; + font-size: 12.5px; + font-weight: var(--font-weight-semibold); + cursor: pointer; +} +.submit-btn.danger { + background: var(--c-danger); + color: var(--on-light, #fff); +} +.submit-btn.accent { + background: var(--accent-solid); + color: var(--on-accent-solid); +} +.submit-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +</style> diff --git a/frontend/src/components/tickets/LinkedTicketCard.vue b/frontend/src/components/tickets/LinkedTicketCard.vue new file mode 100644 index 0000000..445df36 --- /dev/null +++ b/frontend/src/components/tickets/LinkedTicketCard.vue @@ -0,0 +1,134 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { onBeforeUnmount, onMounted, ref, watch } from 'vue' +import { useRouter } from 'vue-router' +import { ticketService } from '@/services/tickets' +import { useTicketSocket } from '@/composables/useTicketSocket' +import TicketStatusBadge from './TicketStatusBadge.vue' +import TicketAiStateChip from './TicketAiStateChip.vue' +import type { Ticket } from '@/types/ticket' + +const props = defineProps<{ sessionId: string }>() +const router = useRouter() +const ticket = ref<Ticket | null>(null) + +async function load() { + ticket.value = props.sessionId + ? await ticketService.getTicketBySession(props.sessionId) + : null +} + +useTicketSocket((event) => { + if (ticket.value && event.ticket_id === ticket.value.id) load() +}) + +// Gentle refresh keeps the card honest if the ticket was just created by the +// chat AI mid-conversation. +let timer: ReturnType<typeof setInterval> | null = null +onMounted(() => { + load() + timer = setInterval(load, 20000) +}) +onBeforeUnmount(() => { + if (timer) clearInterval(timer) +}) +watch(() => props.sessionId, load) +</script> + +<template> + <div v-if="ticket" class="linked-ticket-card"> + <div class="card-header"> + <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--accent-ink)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> + <path d="M4 9a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2 2 2 0 0 0 0 4 2 2 0 0 1-2 2H6a2 2 0 0 1-2-2 2 2 0 0 0 0-4z"></path> + </svg> + <span class="card-title">关联工单</span> + </div> + <div class="card-body"> + <div class="chip-row"> + <span class="ticket-number">{{ ticket.display_number }}</span> + <TicketStatusBadge :status="ticket.status" /> + </div> + <div class="ticket-title">{{ ticket.title }}</div> + <TicketAiStateChip :state="ticket.ai_state" class="ai-line" /> + <button class="view-btn" @click="router.push(`/tickets/${ticket.id}`)"> + 查看工单 → + </button> + </div> + </div> +</template> + +<style scoped> +.linked-ticket-card { + background: var(--surface); + border: 1px solid var(--accent-border); + border-radius: 13px; + overflow: hidden; +} +.card-header { + display: flex; + align-items: center; + gap: 7px; + padding: 11px 15px; + background: var(--accent-bg-08); + border-bottom: 1px solid var(--o07); +} +.card-title { + font-family: var(--font-display); + font-weight: var(--font-weight-semibold); + font-size: 12.5px; + color: var(--accent-ink); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.card-body { + padding: 15px; +} +.chip-row { + display: flex; + align-items: center; + gap: 9px; + margin-bottom: 9px; +} +.ticket-number { + font-family: var(--font-mono); + font-size: 12px; + color: var(--muted2); +} +.ticket-title { + font-size: 13.5px; + font-weight: var(--font-weight-semibold); + line-height: 1.4; + margin-bottom: 11px; + color: var(--text); +} +.ai-line { + margin-bottom: 14px; + display: inline-flex; +} +.view-btn { + width: 100%; + padding: 9px; + background: var(--accent-solid); + color: var(--on-accent-solid); + border: none; + border-radius: 9px; + font-size: 12.5px; + font-weight: var(--font-weight-bold); + cursor: pointer; +} +</style> diff --git a/frontend/src/components/tickets/TicketActivityFeed.vue b/frontend/src/components/tickets/TicketActivityFeed.vue new file mode 100644 index 0000000..d3b8222 --- /dev/null +++ b/frontend/src/components/tickets/TicketActivityFeed.vue @@ -0,0 +1,274 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { ref } from 'vue' +import { formatDistanceToNow } from 'date-fns' +import { zhCN } from 'date-fns/locale' +import type { TicketActivity } from '@/types/ticket' +import { ticketInitials } from './ticketMeta' + +defineProps<{ + activities: TicketActivity[] + canComment: boolean + isSaving: boolean + /** Delivery path to the customer exists — shows the "Send to customer" toggle. */ + canMessageCustomer?: boolean +}>() +const emit = defineEmits<{ (e: 'comment', body: string, isInternal: boolean): void }>() + +const draft = ref('') +const customerVisible = ref(false) + +function submit() { + if (!draft.value.trim()) return + emit('comment', draft.value, !customerVisible.value) + draft.value = '' + customerVisible.value = false +} + +const timeAgo = (iso?: string | null) => + iso ? formatDistanceToNow(new Date(iso), { addSuffix: true, locale: zhCN }) : '' + +function actorName(activity: TicketActivity): string { + if (activity.actor_name) return activity.actor_name + if (activity.actor_type === 'ai') return 'ChatterMate AI' + if (activity.actor_type === 'customer') return '客户' + return '系统' +} + +function avatarText(activity: TicketActivity): string { + if (activity.actor_type === 'ai') return 'AI' + if (activity.actor_type === 'system') return '◇' + return ticketInitials(actorName(activity)) +} +</script> + +<template> + <div class="activity-feed"> + <div class="feed-header">动态</div> + <div class="feed-body"> + <div v-if="!activities.length" class="feed-empty">暂无动态。</div> + <div v-for="activity in activities" :key="activity.id" class="feed-item"> + <span + class="avatar" + :class="{ ai: activity.actor_type === 'ai', system: activity.actor_type === 'system' }" + > + {{ avatarText(activity) }} + </span> + <div class="item-main"> + <div class="item-header"> + <span class="author">{{ actorName(activity) }}</span> + <span v-if="activity.actor_type === 'ai'" class="ai-tag">AI</span> + <span + v-if="activity.activity_type === 'comment' && !activity.is_internal" + class="visible-tag" + > + 已发送给客户 + </span> + <span + v-if="activity.activity_type === 'customer_replied' + && activity.activity_metadata?.channel === 'email'" + class="visible-tag" + > + 通过邮件回复 + </span> + <span class="time">{{ timeAgo(activity.created_at) }}</span> + </div> + <div class="item-text" :class="{ muted: activity.actor_type === 'system' }"> + {{ activity.body || activity.activity_type.replace(/_/g, ' ') }} + </div> + </div> + </div> + </div> + <div v-if="canComment" class="composer"> + <textarea + v-model="draft" + class="composer-input" + :placeholder="customerVisible ? '回复客户…' : '添加内部备注…'" + @keydown.meta.enter="submit" + @keydown.ctrl.enter="submit" + ></textarea> + <div class="composer-actions"> + <label v-if="canMessageCustomer" class="visibility-toggle"> + <input v-model="customerVisible" type="checkbox" /> + 发送给客户 + </label> + <span v-else class="no-customer-note">内部备注——未关联客户</span> + <button class="post-btn" :disabled="!draft.trim() || isSaving" @click="submit"> + {{ isSaving ? '发布中…' : '评论' }} + </button> + </div> + </div> + </div> +</template> + +<style scoped> +.activity-feed { + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 15px; + overflow: hidden; +} +.feed-header { + padding: 14px 20px; + border-bottom: 1px solid var(--o07); + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--faint); +} +.feed-body { + padding: 6px 20px; + max-height: 480px; + overflow-y: auto; +} +.feed-empty { + padding: 20px 0; + font-size: 13px; + color: var(--muted); +} +.feed-item { + display: flex; + gap: 12px; + padding: 13px 0; + border-bottom: 1px solid var(--o05); +} +.feed-item:last-child { + border-bottom: none; +} +.avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--c-coral); + color: var(--on-accent); + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: var(--font-weight-bold); + flex-shrink: 0; +} +.avatar.ai { + border-radius: 8px; + background: var(--accent-solid); + color: var(--on-accent-solid); +} +.avatar.system { + border-radius: 8px; + background: var(--o08); + color: var(--muted); +} +.item-main { + flex: 1; + min-width: 0; +} +.item-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 3px; +} +.author { + font-size: 13px; + font-weight: var(--font-weight-semibold); + color: var(--text2); +} +.ai-tag { + font-family: var(--font-mono); + font-size: 9.5px; + font-weight: var(--font-weight-semibold); + color: var(--c-info); + background: color-mix(in srgb, var(--c-info) 14%, transparent); + padding: 1px 6px; + border-radius: 5px; +} +.visible-tag { + font-size: 10px; + color: var(--c-teal); + background: var(--teal-bg-10); + padding: 1px 7px; + border-radius: 5px; +} +.time { + margin-left: auto; + font-family: var(--font-mono); + font-size: 10.5px; + color: var(--faint); + white-space: nowrap; +} +.item-text { + font-size: 13px; + color: var(--text3); + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} +.item-text.muted { + color: var(--muted); +} +.composer { + padding: 14px 20px; + border-top: 1px solid var(--o07); +} +.composer-input { + width: 100%; + min-height: 58px; + resize: vertical; + background: var(--bg2); + border: 1px solid var(--o08); + border-radius: 10px; + padding: 9px 12px; + color: var(--text); + font-size: 13px; + line-height: 1.5; + outline: none; +} +.composer-actions { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 9px; +} +.visibility-toggle { + display: flex; + align-items: center; + gap: 7px; + font-size: 12px; + color: var(--muted); + cursor: pointer; +} +.no-customer-note { + font-size: 12px; + color: var(--faint); +} +.post-btn { + padding: 8px 16px; + background: var(--accent-solid); + color: var(--on-accent-solid); + border: none; + border-radius: 9px; + font-size: 13px; + font-weight: var(--font-weight-semibold); + cursor: pointer; +} +.post-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +</style> diff --git a/frontend/src/components/tickets/TicketAiStateChip.vue b/frontend/src/components/tickets/TicketAiStateChip.vue new file mode 100644 index 0000000..50f5e92 --- /dev/null +++ b/frontend/src/components/tickets/TicketAiStateChip.vue @@ -0,0 +1,55 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed } from 'vue' +import type { TicketAiState } from '@/types/ticket' +import { aiStateMeta } from './ticketMeta' + +const props = defineProps<{ state?: TicketAiState | null }>() +const meta = computed(() => aiStateMeta(props.state)) +</script> + +<template> + <span class="ai-state" :style="{ '--chip-color': meta.color }"> + <span class="dot" :class="{ pulse: meta.pulse }"></span>{{ meta.label }} + </span> +</template> + +<style scoped> +.ai-state { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: var(--font-weight-medium); + color: var(--chip-color); + white-space: nowrap; +} +.dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--chip-color); +} +.dot.pulse { + animation: ticket-pulse 1.4s ease-in-out infinite; +} +@keyframes ticket-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} +</style> diff --git a/frontend/src/components/tickets/TicketApprovalBanner.vue b/frontend/src/components/tickets/TicketApprovalBanner.vue new file mode 100644 index 0000000..7fdc421 --- /dev/null +++ b/frontend/src/components/tickets/TicketApprovalBanner.vue @@ -0,0 +1,296 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed, ref } from 'vue' +import type { InvestigationHypothesis, TicketProposal } from '@/types/ticket' +import InvestigationNoteForm from './InvestigationNoteForm.vue' + +const props = defineProps<{ + proposal: TicketProposal + hypotheses: InvestigationHypothesis[] + canApprove: boolean +}>() + +const emit = defineEmits<{ + (e: 'approve'): void + (e: 'reject', reason: string, reinvestigate: boolean): void +}>() + +const isRejecting = ref(false) +const reinvestigate = ref(true) +const isSubmitting = ref(false) + +const bestHypothesis = computed(() => { + const validated = props.hypotheses.filter((h) => h.status === 'validated') + if (!validated.length) return null + return validated.reduce((a, b) => ((a.confidence ?? 0) >= (b.confidence ?? 0) ? a : b)) +}) + +function jumpToHypothesis() { + if (!bestHypothesis.value) return + document + .getElementById(`hypothesis-${bestHypothesis.value.idx}`) + ?.scrollIntoView({ behavior: 'smooth', block: 'center' }) +} + +function approve() { + isSubmitting.value = true + emit('approve') +} + +function submitReject(reason: string) { + isSubmitting.value = true + emit('reject', reason, reinvestigate.value) +} +</script> + +<template> + <div class="approval-banner" :class="proposal.status"> + <!-- PENDING --> + <template v-if="proposal.status === 'pending'"> + <div class="banner-head"> + <font-awesome-icon :icon="['fas', 'hourglass-half']" class="banner-icon pending" /> + <div> + <div class="banner-title">AI 建议的解决方案——等待你审批</div> + <div class="banner-sub"> + 批准后将解决工单,并把下方消息发送给客户。ChatterMate 不会执行任何基础设施变更,修复仍由你的团队完成。 + </div> + </div> + <span v-if="proposal.confidence != null" class="confidence-chip mono"> + {{ proposal.confidence.toFixed(2) }} + </span> + </div> + + <div class="proposal-body"> + <div class="block-label">建议解决方案</div> + <p class="proposal-text">{{ proposal.summary }}</p> + <template v-if="proposal.customer_message"> + <div class="block-label">给客户的消息</div> + <p class="proposal-text customer">{{ proposal.customer_message }}</p> + </template> + <button v-if="bestHypothesis" class="reasoning-link" @click="jumpToHypothesis"> + 推理:H{{ bestHypothesis.idx }} · 已验证 + <template v-if="bestHypothesis.confidence != null"> + · 置信度 {{ bestHypothesis.confidence.toFixed(2) }} + </template> + <font-awesome-icon :icon="['fas', 'arrow-down']" /> + </button> + </div> + + <div v-if="canApprove" class="banner-actions"> + <template v-if="!isRejecting"> + <button class="reject-btn" :disabled="isSubmitting" @click="isRejecting = true"> + 拒绝… + </button> + <button class="approve-btn" :disabled="isSubmitting" @click="approve"> + <font-awesome-icon :icon="['fas', 'check']" /> + 批准并解决 + </button> + </template> + <InvestigationNoteForm + v-else + placeholder="哪里不对?你的备注会指导下一轮调查…" + submit-label="拒绝方案" + tone="danger" + :busy="isSubmitting" + @cancel="isRejecting = false" + @submit="submitReject" + > + <label class="reinvestigate-row"> + <input v-model="reinvestigate" type="checkbox" /> + 根据此反馈重新调查 + </label> + </InvestigationNoteForm> + </div> + <div v-else class="banner-sub"> + 你没有审批工单上 AI 操作的权限。 + </div> + </template> + + <!-- DECIDED --> + <template v-else-if="proposal.status === 'approved'"> + <div class="banner-head"> + <font-awesome-icon :icon="['fas', 'circle-check']" class="banner-icon approved" /> + <div> + <div class="banner-title">方案已批准</div> + <div class="banner-sub"> + <template v-if="proposal.decided_by_name">由 {{ proposal.decided_by_name }} 批准——</template> + 工单已解决并已通知客户。任何基础设施变更都由你的团队执行,而非 ChatterMate。 + </div> + </div> + </div> + </template> + + <template v-else-if="proposal.status === 'rejected'"> + <div class="banner-head"> + <font-awesome-icon :icon="['fas', 'circle-xmark']" class="banner-icon rejected" /> + <div> + <div class="banner-title">方案已拒绝</div> + <div class="banner-sub"> + <template v-if="proposal.decided_by_name">由 {{ proposal.decided_by_name }} 拒绝。</template> + <template v-if="proposal.reject_reason">“{{ proposal.reject_reason }}”</template> + </div> + </div> + </div> + </template> + </div> +</template> + +<style scoped> +.approval-banner { + border-radius: 14px; + padding: 16px 18px; + border: 1.5px solid var(--c-warn); + background: color-mix(in srgb, var(--c-warn) 8%, transparent); +} +.approval-banner.approved { + border-color: var(--c-positive); + background: color-mix(in srgb, var(--c-positive) 8%, transparent); +} +.approval-banner.rejected { + border-color: var(--o10); + background: var(--surface); +} +.banner-head { + display: flex; + align-items: flex-start; + gap: 11px; +} +.banner-icon { + font-size: 17px; + line-height: 1.3; + flex-shrink: 0; + margin-top: 1px; +} +/* Status colour comes from the design tokens so both themes stay in step — + an emoji glyph can't follow the palette. */ +.banner-icon.pending { + color: var(--c-warn); +} +.banner-icon.approved { + color: var(--c-positive); +} +.banner-icon.rejected { + color: var(--c-danger); +} +.banner-title { + font-family: var(--font-display); + font-weight: var(--font-weight-bold); + font-size: 14.5px; + color: var(--text); +} +.banner-sub { + margin-top: 3px; + font-size: 12px; + color: var(--muted); + line-height: 1.5; +} +.confidence-chip { + margin-left: auto; + font-size: 11px; + font-weight: var(--font-weight-semibold); + color: var(--c-warn); + background: color-mix(in srgb, var(--c-warn) 14%, transparent); + padding: 3px 9px; + border-radius: 20px; + flex-shrink: 0; +} +.mono { + font-family: var(--font-mono); +} +.proposal-body { + margin-top: 13px; +} +.block-label { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--faint); + margin: 10px 0 5px; +} +.proposal-text { + margin: 0; + font-size: 13px; + line-height: 1.6; + color: var(--text); + white-space: pre-wrap; + word-break: break-word; +} +.proposal-text.customer { + color: var(--text3); + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 10px; + padding: 10px 12px; +} +.reasoning-link { + margin-top: 11px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--c-info); + background: color-mix(in srgb, var(--c-info) 10%, transparent); + border: none; + padding: 5px 10px; + border-radius: 8px; + cursor: pointer; +} +.banner-actions { + margin-top: 14px; +} +.banner-actions > .approve-btn, +.banner-actions > .reject-btn { + margin-right: 0; +} +.approve-btn { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 9px 17px; + background: var(--c-positive); + color: var(--on-light, #04140d); + border: none; + border-radius: 10px; + font-size: 13px; + font-weight: var(--font-weight-bold); + cursor: pointer; +} +.reject-btn { + padding: 9px 15px; + background: transparent; + border: 1px solid var(--o12); + color: var(--text3); + border-radius: 10px; + font-size: 13px; + cursor: pointer; + margin-right: 9px; +} +.approve-btn:disabled, +.reject-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.reinvestigate-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 9px; + font-size: 12px; + color: var(--text3); + cursor: pointer; +} +</style> diff --git a/frontend/src/components/tickets/TicketConnectorsSection.vue b/frontend/src/components/tickets/TicketConnectorsSection.vue new file mode 100644 index 0000000..7c920f1 --- /dev/null +++ b/frontend/src/components/tickets/TicketConnectorsSection.vue @@ -0,0 +1,718 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { onMounted, reactive, ref } from 'vue' +import { toast } from 'vue-sonner' +import { mcpService } from '@/services/mcp' +import type { MCPTool, MCPToolReferences, MCPTransportType } from '@/types/mcp' +import { + CONNECTOR_GUIDANCE_EXAMPLE, + CONNECTOR_GUIDANCE_HINT, + CONNECTOR_GUIDANCE_LABEL, + DEFAULT_MCP_TIMEOUT, + SECRET_MASK, + clampMCPTimeout, + linesToRecord, + recordToLines, +} from '@/utils/mcp' +import GuidanceTextarea from '@/components/common/GuidanceTextarea.vue' +import grafanaLogo from '@/assets/grafana-logo.svg' +import elasticsearchLogo from '@/assets/elasticsearch-logo.svg' +import sentryLogo from '@/assets/sentry-logo.svg' +import cloudwatchLogo from '@/assets/cloudwatch-logo.svg' + +const props = defineProps<{ selectedIds: number[] }>() +const emit = defineEmits<{ (e: 'update:selected-ids', ids: number[]): void }>() + +const connectors = ref<MCPTool[]>([]) +const isLoading = ref(true) +const loadError = ref<string | null>(null) +const isSaving = ref(false) +const showForm = ref(false) +// Which connector the form is editing, or null when it is creating one. +const editingId = ref<number | null>(null) +const deleteTarget = ref<MCPTool | null>(null) +const deleteRefs = ref<MCPToolReferences | null>(null) +const isDeleting = ref(false) + +// One-click starting points for the common observability platforms. All +// values are placeholders the user edits — any other platform with an MCP +// server works through the Custom tile. +const PRESETS = [ + { + key: 'grafana', + name: 'Grafana', + logo: grafanaLogo, + desc: '通过 Grafana 数据源查询 Loki、Prometheus、Elastic 与 CloudWatch', + transport: 'http' as MCPTransportType, + url: 'http://your-mcp-grafana-host:8000/mcp', + envLines: '', + headerLines: 'Authorization=Bearer <grafana-service-account-token>', + command: '', + args: '', + }, + { + // Elastic's own MCP endpoint, served by Kibana. The old + // @elastic/mcp-server-elasticsearch npm package is deprecated and its last + // published version is the deprecated one, so npx would install a dead + // package. The API key needs the feature_agentBuilder.read privilege or + // Kibana answers 403. Self-hosters below 9.2 can run Elastic's Docker + // image in http mode and point this at their own host instead. + key: 'elasticsearch', + name: 'Elasticsearch', + logo: elasticsearchLogo, + desc: '通过 Agent Builder 查询索引、映射与日志(Elastic 9.2+ 或 Serverless)', + transport: 'http' as MCPTransportType, + url: 'https://your-kibana-host/api/agent_builder/mcp', + headerLines: 'Authorization=ApiKey <api-key>', + command: '', + args: '', + envLines: '', + }, + { + key: 'sentry', + name: 'Sentry', + logo: sentryLogo, + desc: '错误、问题与链路追踪', + transport: 'http' as MCPTransportType, + url: 'https://mcp.sentry.dev/mcp', + headerLines: 'Authorization=Bearer <sentry-token>', + command: '', + args: '', + envLines: '', + }, + { + key: 'cloudwatch', + name: 'CloudWatch', + logo: cloudwatchLogo, + desc: 'AWS 日志、指标与告警', + transport: 'stdio' as MCPTransportType, + url: '', + headerLines: '', + command: 'uvx', + args: 'awslabs.cloudwatch-mcp-server@latest', + envLines: 'AWS_ACCESS_KEY_ID=<key>\nAWS_SECRET_ACCESS_KEY=<secret>\nAWS_REGION=us-east-1', + }, +] + +const form = reactive({ + name: '', + description: '', + transport: 'http' as MCPTransportType, + url: '', + headerLines: '', + command: '', + args: '', + envLines: '', + timeout: DEFAULT_MCP_TIMEOUT, + usageGuidance: '', +}) + +function applyPreset(preset: (typeof PRESETS)[0] | null) { + Object.assign(form, { + name: preset?.name || '', + description: preset?.desc || '', + transport: preset?.transport || 'http', + url: preset?.url || '', + headerLines: preset?.headerLines || '', + command: preset?.command || '', + args: preset?.args || '', + envLines: preset?.envLines || '', + timeout: DEFAULT_MCP_TIMEOUT, + // Never seeded from the preset: its `desc` is a one-line marketing + // subtitle, and shipping that as prompt text is the exact failure this + // field exists to fix. + usageGuidance: '', + }) + editingId.value = null + showForm.value = true +} + +/** Load an existing connector into the same form. Secret values arrive + * masked; leaving them alone keeps the stored credential. */ +function startEdit(connector: MCPTool) { + Object.assign(form, { + name: connector.name, + description: connector.description || '', + transport: connector.transport_type, + url: connector.url || '', + headerLines: recordToLines(connector.headers), + command: connector.command || '', + args: (connector.args || []).join(' '), + envLines: recordToLines(connector.env_vars), + timeout: connector.timeout ?? DEFAULT_MCP_TIMEOUT, + usageGuidance: connector.usage_guidance || '', + }) + editingId.value = connector.id + showForm.value = true +} + +async function fetchConnectors() { + isLoading.value = true + try { + connectors.value = await mcpService.getOrganizationMCPTools(false) + loadError.value = null + } catch (err: any) { + loadError.value = + err.response?.data?.detail || '加载连接器失败——当前套餐可能不支持 MCP 工具。' + } finally { + isLoading.value = false + } +} + +/** The form's fields as an API payload. Fields belonging to the other + * transport are cleared so a switched connector carries no stale command or + * URL alongside its new one. */ +function formPayload() { + const isRemote = form.transport !== 'stdio' + return { + name: form.name.trim(), + // Sent even when empty: an omitted key cannot clear a stored description. + description: form.description.trim(), + transport_type: form.transport, + enabled: true, + url: isRemote ? form.url.trim() : '', + headers: isRemote ? linesToRecord(form.headerLines) : {}, + command: isRemote ? '' : form.command.trim(), + args: isRemote ? [] : form.args.trim().split(/\s+/).filter(Boolean), + env_vars: isRemote ? {} : linesToRecord(form.envLines), + timeout: clampMCPTimeout(form.timeout), + usage_guidance: form.usageGuidance.trim() || null, + } +} + +async function saveConnector() { + if (!form.name.trim()) return + isSaving.value = true + try { + if (editingId.value !== null) { + const updated = await mcpService.updateMCPTool(editingId.value, formPayload()) + await fetchConnectors() + showForm.value = false + editingId.value = null + toast.success(`${updated.name} 已更新`) + return + } + const created = await mcpService.createMCPTool(formPayload()) + await fetchConnectors() + // New investigation connectors are opted in immediately. + emit('update:selected-ids', [...props.selectedIds, created.id]) + showForm.value = false + toast.success(`${created.name} 已连接`) + } catch (err: any) { + const fallback = editingId.value !== null + ? '更新连接器失败' + : '创建连接器失败' + toast.error(err.response?.data?.detail || fallback) + } finally { + isSaving.value = false + } +} + +function cancelForm() { + showForm.value = false + editingId.value = null +} + +/** Ask what points at the connector before offering to delete it, so the + * confirmation can name what it is about to break. */ +async function requestDelete(connector: MCPTool) { + deleteTarget.value = connector + deleteRefs.value = null + try { + deleteRefs.value = await mcpService.getMCPToolReferences(connector.id) + } catch { + // Non-fatal — confirm without the reference list rather than blocking. + } +} + +async function deleteConnector() { + const target = deleteTarget.value + if (!target) return + isDeleting.value = true + try { + await mcpService.deleteMCPTool(target.id) + if (editingId.value === target.id) cancelForm() + await fetchConnectors() + // The API drops the id from the org's selection itself; mirror that + // locally only when this connector was actually selected, so deleting an + // unselected one costs no settings write. + if (props.selectedIds.includes(target.id)) { + emit('update:selected-ids', props.selectedIds.filter((id) => id !== target.id)) + } + deleteTarget.value = null + toast.success(`${target.name} 已删除`) + } catch (err: any) { + toast.error(err.response?.data?.detail || '删除连接器失败') + } finally { + isDeleting.value = false + } +} + +function toggleSelected(id: number, checked: boolean) { + const next = checked + ? [...props.selectedIds, id] + : props.selectedIds.filter((existing) => existing !== id) + emit('update:selected-ids', next) +} + +onMounted(fetchConnectors) +</script> + +<template> + <div class="connectors"> + <div class="preset-grid"> + <button + v-for="preset in PRESETS" + :key="preset.key" + class="preset-tile" + @click="applyPreset(preset)" + > + <img :src="preset.logo" :alt="`${preset.name} 标志`" class="preset-logo" /> + <span class="preset-name">{{ preset.name }}</span> + <span class="preset-desc">{{ preset.desc }}</span> + </button> + <button class="preset-tile custom" @click="applyPreset(null)"> + <span class="preset-icon"><font-awesome-icon :icon="['fas', 'plus']" /></span> + <span class="preset-name">添加 MCP 连接器</span> + <span class="preset-desc">任何提供 MCP 服务器的平台——Splunk、Datadog、New Relic 或自建服务</span> + </button> + </div> + + <div v-if="showForm" class="create-form"> + <div v-if="editingId !== null" class="form-title">正在编辑 {{ form.name || '连接器' }}</div> + <div class="form-grid"> + <label class="form-field"> + <span class="field-label">名称</span> + <input v-model="form.name" class="field-input" placeholder="Grafana 生产环境" /> + </label> + <label class="form-field"> + <span class="field-label">传输方式</span> + <select v-model="form.transport" class="field-input"> + <option value="http">HTTP(流式)</option> + <option value="sse">SSE</option> + <option value="stdio">STDIO(本地命令)</option> + </select> + </label> + <template v-if="form.transport !== 'stdio'"> + <label class="form-field wide"> + <span class="field-label">服务器 URL</span> + <input v-model="form.url" class="field-input mono" placeholder="https://host/mcp" /> + </label> + <label class="form-field wide"> + <span class="field-label">请求头(每行一条,KEY=value)</span> + <textarea v-model="form.headerLines" class="field-input mono" rows="2"></textarea> + <span v-if="editingId !== null" class="field-note"> + 已保存的值显示为 {{ SECRET_MASK }}——保持不变即可沿用现有凭据。 + </span> + </label> + </template> + <template v-else> + <label class="form-field"> + <span class="field-label">命令</span> + <input v-model="form.command" class="field-input mono" placeholder="npx" /> + </label> + <label class="form-field"> + <span class="field-label">参数</span> + <input v-model="form.args" class="field-input mono" placeholder="-y @elastic/mcp-server-elasticsearch" /> + </label> + <label class="form-field wide"> + <span class="field-label">环境变量(每行一条,KEY=value)</span> + <textarea v-model="form.envLines" class="field-input mono" rows="3"></textarea> + <span v-if="editingId !== null" class="field-note"> + 已保存的值显示为 {{ SECRET_MASK }}——保持不变即可沿用现有凭据。 + </span> + </label> + </template> + <label class="form-field"> + <span class="field-label">超时(秒)</span> + <input + v-model.number="form.timeout" + type="number" + min="1" + max="300" + class="field-input" + /> + <span class="field-note">握手与单次调用上限。首次启动较慢时可调大。</span> + </label> + <div class="form-field wide"> + <GuidanceTextarea + v-model="form.usageGuidance" + :label="CONNECTOR_GUIDANCE_LABEL" + :hint="CONNECTOR_GUIDANCE_HINT" + :example="CONNECTOR_GUIDANCE_EXAMPLE" + :rows="5" + /> + </div> + </div> + <div class="form-actions"> + <button class="cancel-btn" @click="cancelForm">取消</button> + <button class="connect-btn" :disabled="isSaving || !form.name.trim()" @click="saveConnector"> + <template v-if="editingId !== null">{{ isSaving ? '保存中…' : '保存更改' }}</template> + <template v-else>{{ isSaving ? '连接中…' : '连接' }}</template> + </button> + </div> + </div> + + <div v-if="isLoading" class="state-note">加载连接器…</div> + <div v-else-if="loadError" class="state-note">{{ loadError }}</div> + <div v-else-if="connectors.length" class="connector-list"> + <div v-for="connector in connectors" :key="connector.id" class="connector-row"> + <label class="connector-select"> + <input + type="checkbox" + :checked="selectedIds.includes(connector.id)" + :disabled="!connector.enabled" + @change="toggleSelected(connector.id, ($event.target as HTMLInputElement).checked)" + /> + <div class="connector-info"> + <div class="connector-name"> + {{ connector.name }} + <span class="transport-tag mono">{{ connector.transport_type }}</span> + <span v-if="!connector.enabled" class="disabled-tag mono">已禁用</span> + <span v-if="connector.usage_guidance" class="guided-tag mono" title="AI 已为此数据源配置指导">已配置指导</span> + </div> + <div class="connector-sub mono"> + {{ connector.url || [connector.command, ...(connector.args || [])].join(' ') }} + </div> + </div> + <span class="use-label">用于调查</span> + </label> + <div class="row-actions"> + <button class="row-btn" @click="startEdit(connector)">编辑</button> + <button class="row-btn danger" @click="requestDelete(connector)">删除</button> + </div> + </div> + </div> + <div v-else class="state-note"> + 暂无连接器——请在上方连接一个,AI 才能从你的日志和指标中收集证据。 + </div> + + <div v-if="deleteTarget" class="confirm-overlay" @click.self="deleteTarget = null"> + <div class="confirm-box"> + <h4 class="confirm-title">删除 {{ deleteTarget.name }}?</h4> + <p class="confirm-text"> + 其保存的凭据会一并删除,且无法恢复。 + </p> + <ul v-if="deleteRefs && (deleteRefs.agents.length || deleteRefs.used_in_investigations)" class="confirm-refs"> + <li v-if="deleteRefs.used_in_investigations"> + 它已被选用于调查——删除后将取消选择。 + </li> + <li v-if="deleteRefs.agents.length"> + 已关联到 {{ deleteRefs.agents.join(', ') }}——该关联将被移除。 + </li> + </ul> + <div class="form-actions"> + <button class="cancel-btn" @click="deleteTarget = null">取消</button> + <button class="danger-btn" :disabled="isDeleting" @click="deleteConnector"> + {{ isDeleting ? '删除中…' : '删除连接器' }} + </button> + </div> + </div> + </div> + + <div class="lock-note"> + <font-awesome-icon :icon="['fas', 'lock']" /> + 只读——连接器仅挂载到<strong>调查智能体</strong>,绝不用在面向客户的聊天智能体上。 + </div> + </div> +</template> + +<style scoped> +.connectors { + display: flex; + flex-direction: column; + gap: 14px; +} +.preset-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); + gap: 10px; +} +.preset-tile { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 5px; + padding: 13px 14px; + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 12px; + cursor: pointer; + text-align: left; + transition: border-color 0.15s; +} +.preset-tile:hover { + border-color: var(--accent-ink); +} +.preset-tile.custom { + border-style: dashed; +} +.preset-logo { + width: 22px; + height: 22px; + object-fit: contain; +} +.preset-icon { + width: 22px; + height: 22px; + display: flex; + align-items: center; + font-size: 16px; + color: var(--muted); +} +.preset-name { + font-family: var(--font-display); + font-weight: var(--font-weight-semibold); + font-size: 13px; + color: var(--text); +} +.preset-desc { + font-size: 10.5px; + line-height: 1.4; + color: var(--faint); +} +.create-form { + background: var(--surface); + border: 1px solid var(--o10); + border-radius: 13px; + padding: 15px 16px; +} +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 11px; +} +.form-field { + display: flex; + flex-direction: column; + gap: 5px; +} +.form-field.wide { + grid-column: 1 / -1; +} +.field-label { + font-size: 11px; + color: var(--faint); +} +.field-note { + font-size: 11px; + color: var(--muted2); + line-height: 1.45; +} +.field-input { + padding: 8px 11px; + background: var(--bg2); + border: 1px solid var(--o10); + border-radius: 9px; + color: var(--text); + font-size: 12.5px; + outline: none; + resize: vertical; +} +.mono { + font-family: var(--font-mono); + font-size: 11.5px; +} +.form-actions { + display: flex; + justify-content: flex-end; + gap: 9px; + margin-top: 12px; +} +.cancel-btn { + padding: 7px 13px; + background: var(--o05); + border: 1px solid var(--o10); + color: var(--muted); + border-radius: 9px; + font-size: 12.5px; + cursor: pointer; +} +.connect-btn { + padding: 7px 16px; + background: var(--accent-solid); + color: var(--on-accent-solid); + border: none; + border-radius: 9px; + font-size: 12.5px; + font-weight: var(--font-weight-semibold); + cursor: pointer; +} +.connect-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.state-note { + font-size: 12.5px; + color: var(--muted); + padding: 12px 14px; + background: var(--surface); + border: 1px solid var(--o07); + border-radius: 11px; +} +.connector-list { + display: flex; + flex-direction: column; + gap: 8px; +} +.connector-row { + display: flex; + align-items: center; + gap: 12px; + padding: 11px 14px; + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 11px; +} +.connector-select { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + min-width: 0; + cursor: pointer; +} +.row-actions { + display: flex; + gap: 6px; + flex-shrink: 0; +} +.row-btn { + padding: 5px 11px; + background: var(--o05); + border: 1px solid var(--o10); + border-radius: 8px; + color: var(--muted); + font-size: 11.5px; + cursor: pointer; +} +.row-btn:hover { + color: var(--text); +} +.row-btn.danger:hover { + color: var(--c-danger); + border-color: var(--c-danger); +} +.form-title { + font-size: 12px; + color: var(--muted); + margin-bottom: 11px; +} +.confirm-overlay { + position: fixed; + inset: 0; + z-index: var(--z-modal); + background: var(--scrim); + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} +.confirm-box { + background: var(--bg2); + border: 1px solid var(--o10); + border-radius: 13px; + padding: 18px 20px; + max-width: 420px; + width: 100%; +} +.confirm-title { + font-family: var(--font-display); + font-size: 15px; + color: var(--text); + margin: 0 0 8px; +} +.confirm-text { + font-size: 12.5px; + color: var(--muted); + line-height: 1.5; + margin: 0; +} +.confirm-refs { + margin: 10px 0 0; + padding-left: 18px; + font-size: 12px; + color: var(--muted2); + line-height: 1.6; +} +.danger-btn { + padding: 7px 16px; + background: var(--c-danger); + color: var(--on-dark); + border: none; + border-radius: 9px; + font-size: 12.5px; + font-weight: var(--font-weight-semibold); + cursor: pointer; +} +.danger-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.connector-info { + flex: 1; + min-width: 0; +} +.connector-name { + font-size: 13px; + font-weight: var(--font-weight-semibold); + color: var(--text); + display: flex; + align-items: center; + gap: 8px; +} +.transport-tag { + font-size: 9.5px; + color: var(--c-teal); + background: var(--teal-bg-10); + padding: 1px 7px; + border-radius: 6px; + text-transform: uppercase; +} +.disabled-tag, +.guided-tag { + font-size: 9.5px; + background: var(--o05); + padding: 1px 7px; + border-radius: 6px; +} +.disabled-tag { + color: var(--faint); +} +.guided-tag { + color: var(--muted); +} +.connector-sub { + font-size: 10.5px; + color: var(--faint); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-top: 2px; +} +.use-label { + font-size: 11px; + color: var(--muted2); + flex-shrink: 0; +} +.lock-note { + font-size: 11.5px; + color: var(--muted); + line-height: 1.5; +} +</style> diff --git a/frontend/src/components/tickets/TicketCreateModal.vue b/frontend/src/components/tickets/TicketCreateModal.vue new file mode 100644 index 0000000..954daca --- /dev/null +++ b/frontend/src/components/tickets/TicketCreateModal.vue @@ -0,0 +1,425 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { onBeforeUnmount, ref, watch } from 'vue' +import { toast } from 'vue-sonner' +import { ticketService } from '@/services/tickets' +import { peopleService } from '@/services/people' +import type { PersonListItem } from '@/types/people' +import type { TicketDetail, TicketPriority } from '@/types/ticket' +import { PRIORITIES, priorityMeta } from './ticketMeta' + +const props = defineProps<{ + open: boolean + // Present when creating from a conversation: enables the AI-drafted prefill. + sessionId?: string | null + sessionLabel?: string | null +}>() + +const emit = defineEmits<{ + (e: 'close'): void + (e: 'created', detail: TicketDetail): void +}>() + +const title = ref('') +const description = ref('') +const priority = ref<TicketPriority>('medium') +const fromConversation = ref(false) +const isDrafting = ref(false) +const isSubmitting = ref(false) + +// Manual tickets have no conversation to deliver through — collecting an +// email gives notifications a direct-email path. Suggestions come from the +// org's identified People. +const customerEmail = ref('') +const customerName = ref('') +const customerSuggestions = ref<PersonListItem[]>([]) +let suggestTimer: ReturnType<typeof setTimeout> | null = null + +watch(customerEmail, (query) => { + if (suggestTimer) clearTimeout(suggestTimer) + const term = query.trim() + if (term.length < 2) { + customerSuggestions.value = [] + return + } + const matched = customerSuggestions.value.find((p) => p.email === term) + if (matched && !customerName.value) customerName.value = matched.name || '' + suggestTimer = setTimeout(async () => { + try { + const response = await peopleService.listPeople({ search: term, page_size: 8 }) + customerSuggestions.value = response.items.filter((p) => p.email) + } catch { + customerSuggestions.value = [] + } + }, 300) +}) + +onBeforeUnmount(() => { + if (suggestTimer) clearTimeout(suggestTimer) +}) + +watch( + () => props.open, + (open) => { + if (open) { + title.value = '' + description.value = '' + priority.value = 'medium' + fromConversation.value = false + customerEmail.value = '' + customerName.value = '' + customerSuggestions.value = [] + if (props.sessionId) useConversationDraft() + } + }, +) + +async function useConversationDraft() { + if (!props.sessionId) return + fromConversation.value = true + isDrafting.value = true + try { + const draft = await ticketService.draftFromSession(props.sessionId) + if (!title.value) title.value = draft.title + if (!description.value) description.value = draft.description + } catch { + // Draft is best-effort; the form still works blank. + } finally { + isDrafting.value = false + } +} + +async function submit() { + if (!title.value.trim() || isSubmitting.value) return + isSubmitting.value = true + try { + const linkSession = fromConversation.value && props.sessionId + const detail = await ticketService.createTicket({ + title: title.value.trim(), + description: description.value.trim() || undefined, + priority: priority.value, + session_id: linkSession ? props.sessionId! : undefined, + customer_email: !linkSession ? customerEmail.value.trim() || undefined : undefined, + customer_name: !linkSession ? customerName.value.trim() || undefined : undefined, + }) + toast.success(`工单 ${detail.ticket.display_number} 已创建`) + emit('created', detail) + emit('close') + } catch (e: any) { + toast.error(e?.message || '创建工单失败') + } finally { + isSubmitting.value = false + } +} +</script> + +<template> + <Teleport to="body"> + <div v-if="open" class="modal-overlay" @click="emit('close')"> + <div class="modal" @click.stop> + <div class="modal-header"> + <div class="modal-title">新建工单</div> + <button class="close-btn" @click="emit('close')">×</button> + </div> + <div class="modal-body"> + <div v-if="sessionId" class="source-toggle"> + <button + class="source-option" + :class="{ active: !fromConversation }" + @click="fromConversation = false" + > + 空白 + </button> + <button + class="source-option" + :class="{ active: fromConversation }" + @click="useConversationDraft" + > + 来自会话 + </button> + </div> + + <div v-if="fromConversation" class="draft-note"> + <span class="draft-glyph">✎</span> + <span v-if="isDrafting">正在根据会话生成草稿…</span> + <span v-else> + 已根据 + <span class="mono">{{ sessionLabel || '该会话' }}</span> + 生成草稿——可随意修改。 + </span> + </div> + + <label class="field-label">主题</label> + <input + v-model="title" + class="field-input" + placeholder="简要描述问题…" + maxlength="500" + /> + + <label class="field-label">描述</label> + <textarea + v-model="description" + class="field-textarea" + placeholder="发生了什么?请说明报错、对客户的影响和复现步骤…" + ></textarea> + + <template v-if="!fromConversation"> + <label class="field-label">客户邮箱 <span class="optional">(选填,用于通知客户)</span></label> + <input + v-model="customerEmail" + class="field-input" + type="email" + list="ticket-customer-suggestions" + placeholder="customer@company.com" + maxlength="320" + /> + <datalist id="ticket-customer-suggestions"> + <option + v-for="person in customerSuggestions" + :key="person.id" + :value="person.email || ''" + > + {{ person.name || person.email }} + </option> + </datalist> + + <template v-if="customerEmail.trim()"> + <label class="field-label">客户名称 <span class="optional">(选填)</span></label> + <input + v-model="customerName" + class="field-input" + placeholder="Jane Doe" + maxlength="200" + /> + </template> + </template> + + <label class="field-label">优先级</label> + <div class="priority-pills"> + <button + v-for="p in PRIORITIES" + :key="p" + class="priority-pill" + :class="{ active: priority === p }" + :style="{ '--pill-color': priorityMeta(p).color }" + @click="priority = p" + > + <span class="dot"></span>{{ priorityMeta(p).label }} + </button> + </div> + </div> + <div class="modal-footer"> + <button class="btn-secondary" @click="emit('close')">取消</button> + <button class="btn-primary" :disabled="!title.trim() || isSubmitting" @click="submit"> + {{ isSubmitting ? '创建中…' : '创建工单' }} + </button> + </div> + </div> + </div> + </Teleport> +</template> + +<style scoped> +.modal-overlay { + position: fixed; + inset: 0; + z-index: 100; + background: var(--scrim, rgba(4, 5, 8, 0.62)); + backdrop-filter: blur(6px); + display: flex; + align-items: flex-start; + justify-content: center; + padding: 8vh 20px 20px; +} +.modal { + width: 560px; + max-width: 100%; + background: var(--bg2); + border: 1px solid var(--o12); + border-radius: 18px; + overflow: hidden; +} +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 22px; + border-bottom: 1px solid var(--o07); +} +.modal-title { + font-family: var(--font-display); + font-weight: var(--font-weight-bold); + font-size: 17px; + color: var(--text); +} +.close-btn { + width: 30px; + height: 30px; + border-radius: 8px; + background: var(--o05); + border: 1px solid var(--o10); + color: var(--muted); + cursor: pointer; + font-size: 16px; +} +.modal-body { + padding: 22px; +} +.source-toggle { + display: flex; + gap: 5px; + padding: 3px; + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 10px; + margin-bottom: 18px; +} +.source-option { + flex: 1; + padding: 8px; + border: none; + border-radius: 7px; + font-size: 12.5px; + cursor: pointer; + background: transparent; + color: var(--muted); +} +.source-option.active { + background: var(--o08); + color: var(--text); + font-weight: var(--font-weight-semibold); +} +.draft-note { + display: flex; + align-items: center; + gap: 10px; + padding: 11px 13px; + background: var(--accent-bg-08); + border: 1px solid var(--accent-border); + border-radius: 11px; + margin-bottom: 16px; + font-size: 12px; + color: var(--text3); + line-height: 1.4; +} +.draft-glyph { + width: 22px; + height: 22px; + flex-shrink: 0; + border-radius: 6px; + background: var(--accent-solid); + color: var(--on-accent-solid); + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; +} +.mono { + font-family: var(--font-mono); + color: var(--text); +} +.field-label { + display: block; + font-size: 11.5px; + color: var(--faint); + margin-bottom: 6px; +} +.optional { + color: var(--muted2); + font-weight: var(--font-weight-normal); +} +.field-input, +.field-textarea { + width: 100%; + padding: 10px 12px; + background: var(--surface); + border: 1px solid var(--o10); + border-radius: 10px; + color: var(--text); + font-size: 13.5px; + outline: none; + margin-bottom: 16px; +} +.field-textarea { + min-height: 96px; + resize: vertical; + line-height: 1.5; +} +.priority-pills { + display: flex; + gap: 8px; +} +.priority-pill { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px; + border-radius: 9px; + font-size: 12.5px; + font-weight: var(--font-weight-semibold); + cursor: pointer; + border: 1px solid var(--o10); + background: var(--surface); + color: var(--muted); +} +.priority-pill .dot { + width: 6px; + height: 6px; + border-radius: 2px; + background: var(--pill-color); +} +.priority-pill.active { + border-color: var(--pill-color); + color: var(--pill-color); + background: var(--o05); +} +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 16px 22px; + border-top: 1px solid var(--o07); + background: var(--surface); +} +.btn-secondary { + padding: 9px 17px; + background: var(--o05); + border: 1px solid var(--o10); + color: var(--text); + border-radius: 9px; + font-size: 13px; + cursor: pointer; +} +.btn-primary { + padding: 9px 20px; + background: var(--accent-solid); + color: var(--on-accent-solid); + border: none; + border-radius: 9px; + font-size: 13px; + font-weight: var(--font-weight-semibold); + cursor: pointer; +} +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} +</style> diff --git a/frontend/src/components/tickets/TicketDbConnectorsSection.vue b/frontend/src/components/tickets/TicketDbConnectorsSection.vue new file mode 100644 index 0000000..2add1b7 --- /dev/null +++ b/frontend/src/components/tickets/TicketDbConnectorsSection.vue @@ -0,0 +1,889 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed, onMounted, reactive, ref } from 'vue' +import { toast } from 'vue-sonner' +import { dbConnectorService } from '@/services/tickets' +import type { DbConnector, DbConnectorTable } from '@/types/ticket' + +const connectors = ref<DbConnector[]>([]) +const isLoading = ref(true) +const loadError = ref<string | null>(null) + +const showForm = ref(false) +// When set, the picker edits this saved connector instead of the draft form. +const editingConnector = ref<DbConnector | null>(null) + +const form = reactive({ + name: '', + engine: 'postgresql' as 'postgresql' | 'mysql', + host: '', + port: 5432, + database: '', + username: '', + password: '', + max_rows: 100, + // SSH tunnel — production databases are usually behind a bastion. + ssh_enabled: false, + ssh_host: '', + ssh_port: 22, + ssh_username: '', + ssh_auth: 'key' as 'key' | 'password', + ssh_password: '', + ssh_private_key: '', + ssh_private_key_passphrase: '', +}) + +// Only the tunnel fields the API accepts, with the chosen auth method. +function sshPayload() { + if (!form.ssh_enabled) return { ssh_enabled: false } + const base = { + ssh_enabled: true, + ssh_host: form.ssh_host.trim(), + ssh_port: form.ssh_port, + ssh_username: form.ssh_username.trim(), + } + return form.ssh_auth === 'password' + ? { ...base, ssh_password: form.ssh_password || undefined } + : { + ...base, + ssh_private_key: form.ssh_private_key || undefined, + ssh_private_key_passphrase: form.ssh_private_key_passphrase || undefined, + } +} + +const isDiscovering = ref(false) +const discoveredTables = ref<DbConnectorTable[] | null>(null) +const discoverError = ref<string | null>(null) +const selectedTables = ref<Set<string>>(new Set()) +const maskedColumns = ref<Set<string>>(new Set()) +// { "schema.table": "customer_column" } — a scoped table only ever returns the +// ticket customer's own rows. Absent = readable in full. +const rowScope = ref<Record<string, string>>({}) +const rowScopeKey = ref<'email' | 'phone'>('email') +const expandedTable = ref<string | null>(null) +const isSaving = ref(false) + +const scopedTableCount = computed(() => Object.keys(rowScope.value).length) + +function setRowScope(table: DbConnectorTable, column: string) { + const next = { ...rowScope.value } + if (column) next[tableKey(table)] = column + else delete next[tableKey(table)] + rowScope.value = next +} + +const tablesBySchema = computed(() => { + const groups: Record<string, DbConnectorTable[]> = {} + for (const table of discoveredTables.value || []) { + ;(groups[table.schema] ||= []).push(table) + } + return groups +}) + +function tableKey(table: DbConnectorTable): string { + return `${table.schema}.${table.table}`.toLowerCase() +} + +function resetPicker() { + discoveredTables.value = null + discoverError.value = null + selectedTables.value = new Set() + maskedColumns.value = new Set() + rowScope.value = {} + rowScopeKey.value = 'email' + expandedTable.value = null +} + +function openCreateForm() { + editingConnector.value = null + Object.assign(form, { + name: '', engine: 'postgresql', host: '', port: 5432, + database: '', username: '', password: '', max_rows: 100, + ssh_enabled: false, ssh_host: '', ssh_port: 22, ssh_username: '', + ssh_auth: 'key', ssh_password: '', ssh_private_key: '', ssh_private_key_passphrase: '', + }) + resetPicker() + showForm.value = true +} + +async function fetchConnectors() { + isLoading.value = true + try { + connectors.value = await dbConnectorService.list() + loadError.value = null + } catch (err: any) { + loadError.value = err.response?.data?.detail || '加载数据库连接器失败' + } finally { + isLoading.value = false + } +} + +async function discover() { + isDiscovering.value = true + discoverError.value = null + try { + const result = editingConnector.value + ? await dbConnectorService.test(editingConnector.value.id) + : await dbConnectorService.discover({ + name: form.name, engine: form.engine, host: form.host, port: form.port, + database: form.database, username: form.username, password: form.password, + ...sshPayload(), + }) + if (!result.ok) { + discoverError.value = result.error || '连接失败' + discoveredTables.value = null + } else { + discoveredTables.value = result.tables + toast.success(`已连接——发现 ${result.tables.length} 张表`) + } + } catch (err: any) { + discoverError.value = err.response?.data?.detail || '连接失败' + } finally { + isDiscovering.value = false + } +} + +function toggleTable(table: DbConnectorTable, checked: boolean) { + const next = new Set(selectedTables.value) + if (checked) { + next.add(tableKey(table)) + } else { + next.delete(tableKey(table)) + // Don't leave a scope rule behind on a table that is no longer allowed — + // re-adding it later would silently restore a filter nobody chose. + setRowScope(table, '') + } + selectedTables.value = next +} + +function toggleMask(columnName: string, masked: boolean) { + const next = new Set(maskedColumns.value) + if (masked) next.add(columnName.toLowerCase()) + else next.delete(columnName.toLowerCase()) + maskedColumns.value = next +} + +async function saveConnector() { + if (!selectedTables.value.size) { + toast.error('请至少选择一张表——否则无法查询') + return + } + isSaving.value = true + try { + const policy = { + allowed_tables: [...selectedTables.value], + masked_columns: [...maskedColumns.value], + row_scope: rowScope.value, + row_scope_key: rowScopeKey.value, + max_rows: form.max_rows, + } + if (editingConnector.value) { + await dbConnectorService.update(editingConnector.value.id, policy) + toast.success('连接器已更新') + } else { + await dbConnectorService.create({ + name: form.name, engine: form.engine, host: form.host, port: form.port, + database: form.database, username: form.username, password: form.password, + enabled: true, ...sshPayload(), ...policy, + }) + toast.success('数据库连接器已创建') + } + showForm.value = false + editingConnector.value = null + resetPicker() + await fetchConnectors() + } catch (err: any) { + toast.error(err.response?.data?.detail || '保存连接器失败') + } finally { + isSaving.value = false + } +} + +async function editTables(connector: DbConnector) { + editingConnector.value = connector + form.max_rows = connector.max_rows + resetPicker() + selectedTables.value = new Set((connector.allowed_tables || []).map((t) => t.toLowerCase())) + maskedColumns.value = new Set((connector.masked_columns || []).map((c) => c.toLowerCase())) + rowScope.value = { ...(connector.row_scope || {}) } + rowScopeKey.value = connector.row_scope_key || 'email' + showForm.value = true + await discover() +} + +async function toggleEnabled(connector: DbConnector, enabled: boolean) { + try { + await dbConnectorService.update(connector.id, { enabled }) + await fetchConnectors() + } catch (err: any) { + toast.error(err.response?.data?.detail || '更新连接器失败') + } +} + +async function removeConnector(connector: DbConnector) { + if (!confirm(`删除连接器“${connector.name}”?AI 将失去此数据库的访问权限。`)) return + try { + await dbConnectorService.remove(connector.id) + await fetchConnectors() + toast.success('连接器已删除') + } catch (err: any) { + toast.error(err.response?.data?.detail || '删除连接器失败') + } +} + +onMounted(fetchConnectors) +</script> + +<template> + <div class="db-connectors"> + <div v-if="isLoading" class="state-note">加载中…</div> + <div v-else-if="loadError" class="state-note">{{ loadError }}</div> + + <template v-else> + <div v-if="connectors.length" class="connector-list"> + <div v-for="connector in connectors" :key="connector.id" class="connector-card"> + <div class="connector-main"> + <div class="connector-name"> + {{ connector.name }} + <span class="engine-tag mono">{{ connector.engine }}</span> + <span + v-if="connector.last_test_ok != null" + class="test-tag mono" + :class="{ ok: connector.last_test_ok }" + > + {{ connector.last_test_ok ? '已连接' : '连接失败' }} + </span> + </div> + <div class="connector-sub mono"> + {{ connector.username }}@{{ connector.host }}:{{ connector.port }}/{{ connector.database }} + · {{ (connector.allowed_tables || []).length }} 张表可用 + · {{ (connector.masked_columns || []).length }} 列已脱敏 + · {{ Object.keys(connector.row_scope || {}).length }} 张按客户限定 + </div> + </div> + <label class="enable-toggle"> + <input + type="checkbox" + :checked="connector.enabled" + @change="toggleEnabled(connector, ($event.target as HTMLInputElement).checked)" + /> + 启用 + </label> + <button class="small-btn" @click="editTables(connector)">编辑数据表</button> + <button class="small-btn danger" @click="removeConnector(connector)">删除</button> + </div> + </div> + + <button v-if="!showForm" class="add-connector" @click="openCreateForm"> + + 连接数据库 + </button> + + <div v-if="showForm" class="create-form"> + <template v-if="!editingConnector"> + <div class="form-grid"> + <label class="form-field"> + <span class="field-label">名称</span> + <input v-model="form.name" class="field-input" placeholder="生产只读副本" /> + </label> + <label class="form-field"> + <span class="field-label">引擎</span> + <select + v-model="form.engine" + class="field-input" + @change="form.port = form.engine === 'mysql' ? 3306 : 5432" + > + <option value="postgresql">PostgreSQL</option> + <option value="mysql">MySQL</option> + </select> + </label> + <label class="form-field"> + <span class="field-label">主机</span> + <input v-model="form.host" class="field-input mono" placeholder="db-replica.internal" /> + </label> + <label class="form-field"> + <span class="field-label">端口</span> + <input v-model.number="form.port" type="number" class="field-input mono" /> + </label> + <label class="form-field"> + <span class="field-label">数据库</span> + <input v-model="form.database" class="field-input mono" /> + </label> + <label class="form-field"> + <span class="field-label">用户名</span> + <input v-model="form.username" class="field-input mono" placeholder="readonly_user" /> + </label> + <label class="form-field"> + <span class="field-label">密码</span> + <input v-model="form.password" type="password" class="field-input mono" /> + </label> + <label class="form-field"> + <span class="field-label">单次查询最大行数</span> + <input v-model.number="form.max_rows" type="number" min="1" max="1000" class="field-input mono" /> + </label> + </div> + <!-- SSH TUNNEL --> + <div class="ssh-block"> + <label class="ssh-toggle"> + <input v-model="form.ssh_enabled" type="checkbox" /> + <span> + 通过 SSH 隧道连接 + <span class="ssh-hint">——适用于位于堡垒机 / 跳板机之后的数据库(生产环境常见)</span> + </span> + </label> + <div v-if="form.ssh_enabled" class="ssh-fields"> + <p class="ssh-note"> + 上方的主机和端口将从 SSH 主机的网络中解析。 + </p> + <div class="form-grid"> + <label class="form-field"> + <span class="field-label">SSH 主机</span> + <input v-model="form.ssh_host" class="field-input mono" placeholder="bastion.example.com" /> + </label> + <label class="form-field"> + <span class="field-label">SSH 端口</span> + <input v-model.number="form.ssh_port" type="number" class="field-input mono" /> + </label> + <label class="form-field"> + <span class="field-label">SSH 用户名</span> + <input v-model="form.ssh_username" class="field-input mono" placeholder="ec2-user" /> + </label> + <label class="form-field"> + <span class="field-label">认证方式</span> + <select v-model="form.ssh_auth" class="field-input"> + <option value="key">私钥</option> + <option value="password">密码</option> + </select> + </label> + <label v-if="form.ssh_auth === 'password'" class="form-field wide"> + <span class="field-label">SSH 密码</span> + <input v-model="form.ssh_password" type="password" class="field-input mono" /> + </label> + <template v-else> + <label class="form-field wide"> + <span class="field-label">私钥(PEM / OpenSSH)</span> + <textarea + v-model="form.ssh_private_key" + class="field-input mono key-input" + rows="4" + placeholder="-----BEGIN OPENSSH PRIVATE KEY----- …" + ></textarea> + </label> + <label class="form-field"> + <span class="field-label">私钥口令(选填)</span> + <input v-model="form.ssh_private_key_passphrase" type="password" class="field-input mono" /> + </label> + </template> + </div> + </div> + </div> + + <p class="tip-note"> + 提示:使用只读副本,并只为数据库用户授予 SELECT 权限——在 ChatterMate 自身的防护之外再加一道保险。 + </p> + </template> + <div v-else class="editing-note"> + 正在编辑 <strong>{{ editingConnector.name }}</strong> 的表访问权限 + </div> + + <div class="discover-row"> + <button + class="small-btn" + :disabled="isDiscovering" + @click="discover" + > + {{ isDiscovering ? '连接中…' : discoveredTables ? '重新测试连接' : '测试连接' }} + </button> + <span v-if="discoveredTables" class="discover-result"> + <font-awesome-icon :icon="['fas', 'check']" /> + 发现 {{ discoveredTables.length }} 张表——请选择 AI 可查询的表 + </span> + <span v-else-if="discoverError" class="discover-error">{{ discoverError }}</span> + </div> + + <div v-if="discoveredTables && scopedTableCount" class="scope-key-row"> + <label class="scope-label"> + 客户匹配依据 + <select v-model="rowScopeKey" class="scope-select"> + <option value="email">邮箱地址</option> + <option value="phone">手机号</option> + </select> + </label> + <span class="scope-key-hint"> + <template v-if="rowScopeKey === 'email'"> + 工单客户的邮箱会与你为各表指定的列进行匹配,忽略大小写。 + </template> + <template v-else> + 工单客户的手机号会与你为各表指定的列精确匹配,请确保两边存储格式一致。 + </template> + </span> + </div> + + <!-- ALLOWLIST TREE --> + <div v-if="discoveredTables" class="table-tree"> + <div v-for="(tables, schema) in tablesBySchema" :key="schema" class="schema-group"> + <div class="schema-label mono">{{ schema }}</div> + <div v-for="table in tables" :key="tableKey(table)" class="table-row-wrap"> + <div class="table-row"> + <label class="table-check"> + <input + type="checkbox" + :checked="selectedTables.has(tableKey(table))" + @change="toggleTable(table, ($event.target as HTMLInputElement).checked)" + /> + <span class="table-name mono">{{ table.table }}</span> + <span class="col-count">{{ table.columns.length }} 列</span> + <span v-if="rowScope[tableKey(table)]" class="scope-chip mono"> + <font-awesome-icon :icon="['fas', 'user-shield']" /> + {{ rowScope[tableKey(table)] }} + </span> + </label> + <button + v-if="selectedTables.has(tableKey(table))" + class="mask-toggle" + @click="expandedTable = expandedTable === tableKey(table) ? null : tableKey(table)" + > + {{ expandedTable === tableKey(table) ? '隐藏列' : '列脱敏' }} + </button> + </div> + <div v-if="expandedTable === tableKey(table)" class="column-list"> + <div v-for="column in table.columns" :key="column.name" class="column-row"> + <span class="column-name mono">{{ column.name }}</span> + <span class="column-type">{{ column.type }}</span> + <button + class="mask-pill" + :class="{ masked: maskedColumns.has(column.name.toLowerCase()) }" + @click="toggleMask(column.name, !maskedColumns.has(column.name.toLowerCase()))" + > + {{ maskedColumns.has(column.name.toLowerCase()) ? '已脱敏' : '可见' }} + </button> + </div> + <p class="mask-hint"> + 脱敏列在 AI 看到之前就会被隐藏——引用这些列的查询会被直接拒绝。脱敏按列名在此连接器内全局生效。 + </p> + + <div class="scope-row"> + <label class="scope-label"> + 限定为该工单客户的数据 + <select + class="scope-select mono" + :value="rowScope[tableKey(table)] || ''" + @change="setRowScope(table, ($event.target as HTMLSelectElement).value)" + > + <option value="">不限定——可读取全部行</option> + <option v-for="column in table.columns" :key="column.name" :value="column.name"> + {{ column.name }} + </option> + </select> + </label> + <p class="mask-hint"> + 选择存放客户标识的列。AI 的查询会被改写为仅读取该客户的行——无法扩大范围,且未关联客户的工单完全无法查询此表。与具体客户无关的参考数据可不限定。 + </p> + </div> + </div> + </div> + </div> + </div> + + <div class="form-actions"> + <button class="cancel-btn" @click="showForm = false; editingConnector = null">取消</button> + <button + v-if="discoveredTables" + class="save-btn" + :disabled="isSaving" + @click="saveConnector" + > + {{ isSaving ? '保存中…' : editingConnector ? '保存更改' : '保存连接器' }} + </button> + </div> + </div> + </template> + </div> +</template> + +<style scoped> +.db-connectors { + display: flex; + flex-direction: column; + gap: 12px; +} +.state-note { + font-size: 12.5px; + color: var(--muted); + padding: 12px 14px; + background: var(--surface); + border: 1px solid var(--o07); + border-radius: 11px; +} +.connector-list { + display: flex; + flex-direction: column; + gap: 8px; +} +.connector-card { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 15px; + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 12px; +} +.connector-main { + flex: 1; + min-width: 0; +} +.connector-name { + font-size: 13.5px; + font-weight: var(--font-weight-semibold); + color: var(--text); + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.engine-tag { + font-size: 9.5px; + color: var(--c-teal); + background: var(--teal-bg-10); + padding: 1px 7px; + border-radius: 6px; + text-transform: uppercase; +} +.test-tag { + font-size: 9.5px; + color: var(--c-danger); + background: color-mix(in srgb, var(--c-danger) 10%, transparent); + padding: 1px 7px; + border-radius: 6px; +} +.test-tag.ok { + color: var(--c-positive); + background: color-mix(in srgb, var(--c-positive) 10%, transparent); +} +.connector-sub { + margin-top: 3px; + font-size: 10.5px; + color: var(--faint); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mono { + font-family: var(--font-mono); +} +.enable-toggle { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text3); + cursor: pointer; + flex-shrink: 0; +} +.small-btn { + padding: 6px 12px; + background: var(--o05); + border: 1px solid var(--o10); + color: var(--text); + border-radius: 8px; + font-size: 12px; + cursor: pointer; + flex-shrink: 0; +} +.small-btn.danger { + color: var(--c-danger); + border-color: color-mix(in srgb, var(--c-danger) 35%, transparent); +} +.small-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.add-connector { + align-self: flex-start; + padding: 9px 16px; + background: transparent; + border: 1.5px dashed var(--o14); + color: var(--muted); + border-radius: 11px; + font-size: 13px; + cursor: pointer; +} +.add-connector:hover { + border-color: var(--accent-ink); + color: var(--text); +} +.create-form { + background: var(--surface); + border: 1px solid var(--o10); + border-radius: 13px; + padding: 16px; +} +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 11px; +} +.form-field { + display: flex; + flex-direction: column; + gap: 5px; +} +.field-label { + font-size: 11px; + color: var(--faint); +} +.field-input { + padding: 8px 11px; + background: var(--bg2); + border: 1px solid var(--o10); + border-radius: 9px; + color: var(--text); + font-size: 12.5px; + outline: none; +} +.tip-note { + margin: 12px 0 0; + font-size: 11.5px; + color: var(--muted); + line-height: 1.5; +} +.ssh-block { + margin-top: 14px; + border-top: 1px solid var(--o07); + padding-top: 13px; +} +.ssh-toggle { + display: flex; + align-items: flex-start; + gap: 9px; + font-size: 13px; + color: var(--text3); + cursor: pointer; +} +.ssh-hint { + color: var(--faint); + font-size: 12px; +} +.ssh-fields { + margin-top: 12px; +} +.ssh-note { + margin: 0 0 10px; + font-size: 11.5px; + color: var(--faint); +} +.key-input { + resize: vertical; + line-height: 1.4; +} +.editing-note { + font-size: 13px; + color: var(--text3); +} +.discover-row { + display: flex; + align-items: center; + gap: 12px; + margin-top: 13px; + flex-wrap: wrap; +} +.discover-result { + font-size: 12px; + color: var(--c-positive); +} +.discover-error { + font-size: 12px; + color: var(--c-danger); +} +.table-tree { + margin-top: 13px; + border: 1px solid var(--o07); + border-radius: 11px; + max-height: 420px; + overflow-y: auto; +} +.schema-group { + padding: 6px 0; +} +.schema-label { + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--faint); + padding: 6px 14px 4px; +} +.table-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 14px; +} +.table-row:hover { + background: var(--o03); +} +.table-check { + display: flex; + align-items: center; + gap: 9px; + cursor: pointer; + min-width: 0; +} +.table-name { + font-size: 12px; + color: var(--text); +} +.col-count { + font-size: 10.5px; + color: var(--faint); +} +.mask-toggle { + font-size: 11px; + color: var(--accent-ink); + background: var(--accent-bg-08); + border: none; + padding: 3px 9px; + border-radius: 7px; + cursor: pointer; + flex-shrink: 0; +} +.column-list { + margin: 4px 14px 10px 34px; + border-left: 2px solid var(--o07); + padding-left: 12px; +} +.column-row { + display: flex; + align-items: center; + gap: 10px; + padding: 3px 0; +} +.column-name { + font-size: 11.5px; + color: var(--text3); + min-width: 140px; +} +.column-type { + font-size: 10.5px; + color: var(--faint); + flex: 1; +} +.mask-pill { + font-family: var(--font-mono); + font-size: 10px; + padding: 2px 9px; + border-radius: 20px; + border: 1px solid var(--o10); + background: transparent; + color: var(--muted2); + cursor: pointer; +} +.mask-pill.masked { + color: var(--c-warn); + border-color: var(--c-warn); + background: color-mix(in srgb, var(--c-warn) 10%, transparent); +} +.mask-hint { + margin: 8px 0 0; + font-size: 10.5px; + color: var(--faint); + line-height: 1.5; +} +.scope-row { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--o08); +} +.scope-label { + display: flex; + align-items: center; + gap: 9px; + font-size: 11.5px; + color: var(--muted2); +} +.scope-select { + flex: 1; + min-width: 0; + padding: 6px 9px; + background: var(--surface); + border: 1px solid var(--o10); + color: var(--text); + border-radius: 8px; + font-size: 11.5px; +} +.scope-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 7px; + border-radius: 20px; + font-size: 10px; + color: var(--c-teal); + background: color-mix(in srgb, var(--c-teal) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--c-teal) 30%, transparent); +} +.scope-key-row { + margin: 14px 0 4px; + padding: 11px 13px; + background: var(--o05); + border: 1px solid var(--o08); + border-radius: 10px; +} +.scope-key-hint { + display: block; + margin-top: 7px; + font-size: 10.5px; + color: var(--faint); + line-height: 1.5; +} +.form-actions { + display: flex; + justify-content: flex-end; + gap: 9px; + margin-top: 14px; +} +.cancel-btn { + padding: 8px 14px; + background: var(--o05); + border: 1px solid var(--o10); + color: var(--muted); + border-radius: 9px; + font-size: 12.5px; + cursor: pointer; +} +.save-btn { + padding: 8px 17px; + background: var(--accent-solid); + color: var(--on-accent-solid); + border: none; + border-radius: 9px; + font-size: 12.5px; + font-weight: var(--font-weight-semibold); + cursor: pointer; +} +.save-btn:disabled { + opacity: 0.5; +} +@media (max-width: 700px) { + .form-grid { + grid-template-columns: 1fr; + } + .connector-card { + flex-wrap: wrap; + } +} +</style> diff --git a/frontend/src/components/tickets/TicketEmptyState.vue b/frontend/src/components/tickets/TicketEmptyState.vue new file mode 100644 index 0000000..a1c4b12 --- /dev/null +++ b/frontend/src/components/tickets/TicketEmptyState.vue @@ -0,0 +1,83 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +defineProps<{ filtered: boolean }>() +const emit = defineEmits<{ (e: 'clear'): void; (e: 'create'): void }>() +</script> + +<template> + <div class="empty-state"> + <div class="glyph">∅</div> + <template v-if="filtered"> + <div class="title">没有符合条件的工单</div> + <div class="hint">试试清除筛选条件,或新建一个工单。</div> + <button class="action" @click="emit('clear')">清除筛选</button> + </template> + <template v-else> + <div class="title">暂无工单</div> + <div class="hint"> + 聊天 AI 升级问题时,工单会出现在这里——你也可以自己创建。 + </div> + <button class="action" @click="emit('create')">新建工单</button> + </template> + </div> +</template> + +<style scoped> +.empty-state { + padding: 66px 20px; + text-align: center; +} +.glyph { + width: 46px; + height: 46px; + margin: 0 auto 16px; + border-radius: 14px; + border: 1.5px dashed var(--o16); + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-mono); + color: var(--faint); + font-size: 18px; +} +.title { + font-family: var(--font-display); + font-weight: var(--font-weight-semibold); + font-size: 17px; + margin-bottom: 6px; + color: var(--text); +} +.hint { + font-size: 13px; + color: var(--muted); + margin-bottom: 18px; +} +.action { + padding: 8px 16px; + background: var(--o05); + border: 1px solid var(--o12); + color: var(--text); + border-radius: 9px; + font-size: 13px; + font-weight: var(--font-weight-semibold); + cursor: pointer; +} +.action:hover { + background: var(--o08); +} +</style> diff --git a/frontend/src/components/tickets/TicketFilterBar.vue b/frontend/src/components/tickets/TicketFilterBar.vue new file mode 100644 index 0000000..8493218 --- /dev/null +++ b/frontend/src/components/tickets/TicketFilterBar.vue @@ -0,0 +1,176 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { onMounted } from 'vue' +import type { TicketListFilters } from '@/types/ticket' +import { useUsers } from '@/composables/useUsers' + +defineProps<{ filters: TicketListFilters }>() + +const STATUS_PILLS = [ + { value: 'all', label: '全部' }, + { value: 'open', label: '待处理' }, + { value: 'awaiting', label: '等待中' }, + { value: 'breaching', label: 'SLA 超时' }, + { value: 'resolved', label: '已解决' }, +] + +const AI_PILLS = [ + { value: 'all', label: '全部 AI', color: 'var(--muted)' }, + { value: 'investigating', label: '调查中', color: 'var(--c-info)' }, + { value: 'awaiting', label: '等待人工', color: 'var(--c-warn)' }, + { value: 'human', label: '人工处理', color: 'var(--c-neutral)' }, + { value: 'resolved', label: 'AI 已解决', color: 'var(--c-positive)' }, +] + +const { users, fetchUsers } = useUsers() +onMounted(() => { + fetchUsers().catch(() => {}) +}) +</script> + +<template> + <div class="filter-bar"> + <div class="pill-group"> + <button + v-for="pill in STATUS_PILLS" + :key="pill.value" + class="pill" + :class="{ active: filters.status === pill.value }" + @click="filters.status = pill.value" + > + {{ pill.label }} + </button> + </div> + + <select v-model="filters.priority" class="filter-select" aria-label="按优先级筛选"> + <option value="all">优先级 · 全部</option> + <option value="urgent">紧急</option> + <option value="high">高</option> + <option value="medium">中</option> + <option value="low">低</option> + </select> + + <select v-model="filters.assignee" class="filter-select" aria-label="按负责人筛选"> + <option value="all">负责人 · 全部</option> + <option value="unassigned">未分配 / AI</option> + <option v-for="user in users" :key="user.id" :value="user.id"> + {{ user.full_name || user.email }} + </option> + </select> + + <div class="pill-group"> + <button + v-for="pill in AI_PILLS" + :key="pill.value" + class="pill with-dot" + :class="{ active: filters.ai === pill.value }" + @click="filters.ai = pill.value" + > + <span class="dot" :style="{ background: pill.color }"></span>{{ pill.label }} + </button> + </div> + + <div class="search-wrap"> + <span class="search-glyph"></span> + <input v-model="filters.search" class="search-input" placeholder="搜索工单…" /> + </div> + + <select v-model="filters.sort" class="filter-select" aria-label="工单排序"> + <option value="updated">排序 · 更新时间</option> + <option value="created">最新创建</option> + <option value="priority">优先级</option> + </select> + </div> +</template> + +<style scoped> +.filter-bar { + display: flex; + align-items: center; + gap: 9px; + flex-wrap: wrap; + margin-bottom: 16px; +} +.pill-group { + display: flex; + gap: 4px; + padding: 3px; + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 10px; +} +.pill { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border: none; + border-radius: 7px; + font-size: 12.5px; + font-weight: var(--font-weight-medium); + cursor: pointer; + background: transparent; + color: var(--muted); +} +.pill.active { + background: var(--o10); + color: var(--text); + font-weight: var(--font-weight-semibold); +} +.dot { + width: 6px; + height: 6px; + border-radius: 50%; +} +.filter-select { + padding: 8px 11px; + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 9px; + color: var(--text); + font-size: 12.5px; + cursor: pointer; +} +.search-wrap { + position: relative; + margin-left: auto; +} +.search-glyph { + position: absolute; + left: 11px; + top: 50%; + transform: translateY(-50%); + width: 11px; + height: 11px; + border: 1.5px solid var(--faint); + border-radius: 50%; +} +.search-input { + width: 190px; + padding: 8px 12px 8px 30px; + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 9px; + color: var(--text); + font-size: 12.5px; + outline: none; +} +.search-input:focus { + border-color: var(--o16); +} +</style> diff --git a/frontend/src/components/tickets/TicketInvestigationPanel.vue b/frontend/src/components/tickets/TicketInvestigationPanel.vue new file mode 100644 index 0000000..6323810 --- /dev/null +++ b/frontend/src/components/tickets/TicketInvestigationPanel.vue @@ -0,0 +1,340 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed, ref } from 'vue' +import type { InvestigationDetail } from '@/types/ticket' +import HypothesisCard from './HypothesisCard.vue' +import InvestigationNoteForm from './InvestigationNoteForm.vue' + +const props = defineProps<{ + investigation: InvestigationDetail + /** Whether this user can start another run right now. */ + canReinvestigate?: boolean + /** Why they can't, shown as a tooltip on the disabled button. */ + reinvestigateBlockedReason?: string | null + /** Gates the deep link to settings, which needs manage_organization. */ + canEditSettings?: boolean +}>() + +const emit = defineEmits<{ (e: 'reinvestigate', note: string): void }>() + +// A guided re-run belongs here rather than only in the L2 approval banner: +// below autonomy 2 no proposal is ever created, so that banner never renders +// and an L1 operator had no way to correct a run short of editing the ticket +// description (#318). +const isNoting = ref(false) + +function submitRerun(note: string) { + isNoting.value = false + emit('reinvestigate', note) +} + +const run = computed(() => props.investigation.run) +const isActive = computed(() => ['pending', 'running'].includes(run.value?.status || '')) + +const RUN_STATUS_META: Record<string, { label: string; color: string }> = { + pending: { label: '排队中', color: 'var(--c-neutral)' }, + running: { label: '调查中', color: 'var(--c-info)' }, + completed: { label: '已完成', color: 'var(--c-positive)' }, + failed: { label: '失败', color: 'var(--c-danger)' }, + cancelled: { label: '已取消', color: 'var(--c-neutral)' }, + budget_exceeded: { label: '部分完成 · 预算', color: 'var(--c-warn)' }, +} +const statusMeta = computed( + () => RUN_STATUS_META[run.value?.status || ''] || RUN_STATUS_META.pending, +) + +// "What the AI is doing now": the newest event, phrased for humans. +const tickerLine = computed(() => { + if (!isActive.value) return null + const events = props.investigation.events + const latest = events[events.length - 1] + if (!latest) return '正在启动调查…' + if (latest.event_type === 'phase') return latest.label + return `正在查询 ${latest.connector_name || '连接器'} · ${latest.tool_name}` +}) + +const toolCallCount = computed( + () => props.investigation.events.filter((e) => e.event_type === 'tool_call').length, +) + +// Compact token total for the run header, e.g. "1.2k tokens". Only shown once +// the run has recorded usage. +const tokenLabel = computed(() => { + const total = (run.value?.input_tokens || 0) + (run.value?.output_tokens || 0) + if (!total) return '' + return total >= 1000 ? `${(total / 1000).toFixed(1)}k tokens` : `${total} tokens` +}) + +// How many of the run's tool calls failed. Surfaced because a verdict resting +// on failed queries is worth nothing, however confident it reads (#318). +const erroredCalls = computed(() => run.value?.connector_status?.tool_calls_failed || 0) +const totalCalls = computed(() => run.value?.connector_status?.tool_calls || 0) +const allCallsFailed = computed(() => totalCalls.value > 0 && erroredCalls.value === totalCalls.value) + +// A run that finished with fewer MCP connectors than configured produced its +// answer without the evidence those tools were meant to gather. So did a run +// whose connectors all came up but whose tools the provider refused — that one +// reports every connector loaded, so it needs its own check (#303). +const connectorWarning = computed(() => { + const status = run.value?.connector_status + if (!status) return null + const missingConnectors = status.loaded < status.configured + const refusedTools = !!status.provider_errors?.length + // A connector can come up, load its tools, and then error on every query. + // That reports loaded === configured with no provider errors, so it needs + // its own check or the run renders as healthy (#318). + if (!missingConnectors && !refusedTools && !erroredCalls.value) return null + return status +}) +</script> + +<template> + <div v-if="run" class="investigation-panel"> + <div class="panel-head"> + <div class="head-left"> + <span class="panel-title">AI 调查</span> + <span class="run-chip" :style="{ color: statusMeta.color, borderColor: statusMeta.color }"> + <span v-if="isActive" class="pulse-dot" :style="{ background: statusMeta.color }"></span> + {{ statusMeta.label }} + </span> + </div> + <div class="head-meta mono"> + <span>{{ investigation.hypotheses.length }} 个假设</span> + <span>·</span> + <span>{{ toolCallCount }}/{{ run.max_tool_calls || '—' }} 次工具调用</span> + <span v-if="run.llm_calls">· {{ run.llm_calls }} 次 LLM 调用</span> + <span v-if="tokenLabel">· {{ tokenLabel }}</span> + <span v-if="run.model_name">· {{ run.model_name }}</span> + </div> + </div> + + <div v-if="tickerLine" class="ticker"> + <span class="ticker-dot"></span> + {{ tickerLine }} + </div> + + <div v-if="run.status === 'failed' && run.error" class="run-error"> + {{ run.error }} + </div> + + <div v-if="connectorWarning" class="connector-warning"> + <div v-if="connectorWarning.loaded < connectorWarning.configured"> + 仅使用了 {{ connectorWarning.loaded }} / {{ connectorWarning.configured }} 个已配置连接器——结论可能缺少证据。 + </div> + <div v-else> + 连接器已就绪,但其工具无法使用——结论可能缺少证据。 + </div> + <ul v-if="connectorWarning.failed.length" class="connector-warning__list"> + <li v-for="f in connectorWarning.failed" :key="f.name"> + <strong>{{ f.name }}</strong>:{{ f.error }} + </li> + </ul> + <ul v-if="connectorWarning.provider_errors?.length" class="connector-warning__list"> + <li v-for="reason in connectorWarning.provider_errors" :key="reason">{{ reason }}</li> + </ul> + <div v-if="erroredCalls" class="connector-warning__calls"> + {{ erroredCalls }} / {{ totalCalls }} 次工具调用出错<template + v-if="allCallsFailed" + >——未采集到任何数据,此处结论均无证据支撑</template>。 + </div> + </div> + + <div v-if="investigation.hypotheses.length" class="hypothesis-list"> + <HypothesisCard + v-for="hypothesis in investigation.hypotheses" + :key="hypothesis.id" + :hypothesis="hypothesis" + :events="investigation.events" + /> + </div> + <p v-else-if="!isActive" class="empty-note">本次调查未生成假设。</p> + + <div v-if="canReinvestigate" class="panel-foot"> + <button + v-if="!isNoting" + class="rerun-btn" + :disabled="!!reinvestigateBlockedReason" + :title="reinvestigateBlockedReason || '重新调查,并备注告诉 AI 这次要怎么做'" + @click="isNoting = true" + > + <font-awesome-icon :icon="['fas', 'rotate-right']" /> + 带指导重新调查… + </button> + <template v-else> + <InvestigationNoteForm + placeholder="这次希望 AI 怎么做?例如“订单号字段是 fields.order_ref,不是 order_id——请从 3 日 14:00 UTC 起搜索 app-logs-*。”" + submit-label="重新调查" + @cancel="isNoting = false" + @submit="submitRerun" + /> + <p class="rerun-scope"> + 仅对本次调查生效。若希望每次都告知 AI,请在连接器的 + <router-link v-if="canEditSettings" to="/settings/ticketing">工单设置</router-link> + <span v-else>工单设置</span> 中配置。 + </p> + </template> + </div> + </div> +</template> + +<style scoped> +.panel-foot { + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid var(--o07); +} +.rerun-btn { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 8px 14px; + background: transparent; + border: 1px solid var(--o12); + color: var(--text3); + border-radius: 10px; + font-size: 12.5px; + cursor: pointer; +} +.rerun-btn:hover:not(:disabled) { + color: var(--text); +} +.rerun-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.rerun-scope { + margin: 8px 0 0; + font-size: 11.5px; + color: var(--muted); + line-height: 1.5; +} +.connector-warning__calls { + margin-top: 6px; + font-weight: var(--font-weight-semibold); +} +.investigation-panel { + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 15px; + padding: 16px 18px; +} +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} +.head-left { + display: flex; + align-items: center; + gap: 10px; +} +.panel-title { + font-family: var(--font-display); + font-weight: var(--font-weight-bold); + font-size: 14.5px; + color: var(--text); +} +.run-chip { + display: inline-flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: 10px; + font-weight: var(--font-weight-semibold); + letter-spacing: 0.04em; + border: 1px solid; + padding: 2px 9px; + border-radius: 20px; +} +.pulse-dot, +.ticker-dot { + width: 6px; + height: 6px; + border-radius: 50%; + animation: inv-pulse 1.3s ease-in-out infinite; +} +.ticker-dot { + background: var(--c-info); + flex-shrink: 0; +} +@keyframes inv-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} +.head-meta { + display: flex; + align-items: center; + gap: 6px; + font-size: 10.5px; + color: var(--faint); +} +.mono { + font-family: var(--font-mono); +} +.ticker { + margin-top: 12px; + display: flex; + align-items: center; + gap: 9px; + padding: 9px 12px; + background: color-mix(in srgb, var(--c-info) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--c-info) 30%, transparent); + border-radius: 10px; + font-size: 12px; + color: var(--text3); + font-family: var(--font-mono); +} +.run-error { + margin-top: 12px; + padding: 9px 12px; + background: color-mix(in srgb, var(--c-danger) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--c-danger) 35%, transparent); + border-radius: 10px; + font-size: 12px; + color: var(--c-danger); +} +.connector-warning { + margin-top: 12px; + padding: 9px 12px; + background: color-mix(in srgb, var(--c-warn) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--c-warn) 35%, transparent); + border-radius: 10px; + font-size: 12px; + color: var(--c-warn); +} +.connector-warning__list { + margin: 6px 0 0; + padding-left: 18px; +} +.connector-warning__list li { + word-break: break-word; +} +.hypothesis-list { + margin-top: 13px; + display: flex; + flex-direction: column; + gap: 9px; +} +.empty-note { + margin: 12px 0 0; + font-size: 12.5px; + color: var(--faint); +} +</style> diff --git a/frontend/src/components/tickets/TicketPriorityBadge.vue b/frontend/src/components/tickets/TicketPriorityBadge.vue new file mode 100644 index 0000000..bac3a99 --- /dev/null +++ b/frontend/src/components/tickets/TicketPriorityBadge.vue @@ -0,0 +1,48 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed } from 'vue' +import type { TicketPriority } from '@/types/ticket' +import { priorityMeta } from './ticketMeta' + +const props = defineProps<{ priority: TicketPriority }>() +const meta = computed(() => priorityMeta(props.priority)) +</script> + +<template> + <span class="ticket-priority" :style="{ '--chip-color': meta.color }"> + <span class="dot"></span>{{ meta.label }} + </span> +</template> + +<style scoped> +.ticket-priority { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 12px; + font-weight: var(--font-weight-semibold); + color: var(--chip-color); + white-space: nowrap; +} +.dot { + width: 6px; + height: 6px; + border-radius: 2px; + background: var(--chip-color); +} +</style> diff --git a/frontend/src/components/tickets/TicketRcaDoc.vue b/frontend/src/components/tickets/TicketRcaDoc.vue new file mode 100644 index 0000000..a6d25b7 --- /dev/null +++ b/frontend/src/components/tickets/TicketRcaDoc.vue @@ -0,0 +1,381 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed, ref, watch } from 'vue' +import type { RcaDocument } from '@/types/ticket' + +const props = withDefaults( + defineProps<{ + rca: RcaDocument + canManage: boolean + canNotify: boolean + /** A save/send is in flight — both actions lock so nothing sends twice. */ + isBusy?: boolean + }>(), + { isBusy: false }, +) + +const emit = defineEmits<{ + (e: 'save-draft', customerSummary: string): void + (e: 'send-customer', customerSummary: string): void +}>() + +const customerDraft = ref(props.rca.customer_summary || '') +watch( + () => props.rca.customer_summary, + (next) => { + customerDraft.value = next || '' + }, +) + +interface Section { + id: string + label: string + present: boolean +} + +const sections = computed<Section[]>(() => [ + { id: 'rca-summary', label: '摘要', present: !!props.rca.summary }, + { id: 'rca-impact', label: '影响', present: !!props.rca.impact }, + { id: 'rca-timeline', label: '时间线', present: !!props.rca.timeline?.length }, + { id: 'rca-log', label: '调查日志', present: !!props.rca.investigation_log }, + { id: 'rca-factors', label: '促成因素', present: !!props.rca.contributing_factors?.length }, + { id: 'rca-conclusion', label: '结论', present: !!props.rca.conclusion }, + { id: 'rca-remediation', label: '修复措施', present: !!props.rca.remediation }, + { id: 'rca-prevention', label: '预防措施', present: !!props.rca.prevention }, + { id: 'rca-customer', label: '客户摘要', present: true }, +]) + +function jumpTo(id: string) { + document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }) +} +</script> + +<template> + <div class="rca-doc"> + <div class="rca-head"> + <div> + <div class="rca-title">根因分析</div> + <div class="rca-byline"> + 由 ChatterMate AI 生成 + <template v-if="rca.reviewed_by_name"> · 审核人 {{ rca.reviewed_by_name }}</template> + <template v-else> · 尚未审核</template> + <span class="version mono">v{{ rca.version }}</span> + <span v-if="rca.is_partial" class="partial-tag">部分完成——已达预算上限</span> + </div> + </div> + <div v-if="rca.confidence != null" class="rca-confidence mono"> + 置信度 {{ rca.confidence.toFixed(2) }} + </div> + </div> + + <div class="rca-grid"> + <nav class="rca-toc"> + <button + v-for="section in sections.filter((s) => s.present)" + :key="section.id" + class="toc-link" + @click="jumpTo(section.id)" + > + {{ section.label }} + </button> + </nav> + + <div class="rca-body"> + <section v-if="rca.summary" id="rca-summary"> + <h4 class="section-label">摘要</h4> + <p class="section-text">{{ rca.summary }}</p> + </section> + + <section v-if="rca.impact" id="rca-impact"> + <h4 class="section-label">影响</h4> + <p class="section-text">{{ rca.impact }}</p> + </section> + + <section v-if="rca.timeline?.length" id="rca-timeline"> + <h4 class="section-label">时间线</h4> + <div class="timeline"> + <div v-for="(entry, i) in rca.timeline" :key="i" class="timeline-row"> + <span class="timeline-time mono">{{ entry.time }}</span> + <span class="timeline-event">{{ entry.event }}</span> + </div> + </div> + </section> + + <section v-if="rca.investigation_log" id="rca-log"> + <h4 class="section-label">调查日志</h4> + <p class="section-text">{{ rca.investigation_log }}</p> + </section> + + <section v-if="rca.contributing_factors?.length" id="rca-factors"> + <h4 class="section-label">促成因素</h4> + <ul class="factor-list"> + <li v-for="(factor, i) in rca.contributing_factors" :key="i">{{ factor }}</li> + </ul> + </section> + + <section v-if="rca.conclusion" id="rca-conclusion"> + <h4 class="section-label">结论</h4> + <p class="section-text conclusion">{{ rca.conclusion }}</p> + </section> + + <section v-if="rca.remediation" id="rca-remediation"> + <h4 class="section-label">修复措施</h4> + <p class="section-text">{{ rca.remediation }}</p> + </section> + + <section v-if="rca.prevention" id="rca-prevention"> + <h4 class="section-label">预防措施</h4> + <p class="section-text">{{ rca.prevention }}</p> + </section> + + <section id="rca-customer"> + <h4 class="section-label">客户摘要</h4> + <div class="customer-card"> + <textarea + v-if="canManage" + v-model="customerDraft" + class="customer-editor" + placeholder="给客户的通俗摘要…" + ></textarea> + <p v-else class="section-text">{{ rca.customer_summary || '—' }}</p> + <div v-if="canManage" class="customer-actions"> + <span v-if="!canNotify" class="no-channel-note"> + 没有客户联系渠道——请关联会话或添加客户邮箱后再发送。 + </span> + <button + class="draft-btn" + :disabled="props.isBusy" + @click="emit('save-draft', customerDraft)" + > + 保存草稿 + </button> + <button + class="send-btn" + :disabled="props.isBusy || !canNotify || !customerDraft.trim()" + @click="emit('send-customer', customerDraft)" + > + {{ props.isBusy ? '发送中…' : '发送给客户' }} + </button> + </div> + </div> + </section> + </div> + </div> + </div> +</template> + +<style scoped> +.rca-doc { + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 15px; + padding: 18px 20px; +} +.rca-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 15px; +} +.rca-title { + font-family: var(--font-display); + font-weight: var(--font-weight-bold); + font-size: 15px; + color: var(--text); +} +.rca-byline { + margin-top: 3px; + font-size: 11.5px; + color: var(--faint); + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.version { + color: var(--muted2); + background: var(--o05); + border: 1px solid var(--o08); + padding: 1px 6px; + border-radius: 5px; + font-size: 10px; +} +.partial-tag { + font-family: var(--font-mono); + font-size: 9.5px; + color: var(--c-warn); + background: color-mix(in srgb, var(--c-warn) 12%, transparent); + padding: 2px 7px; + border-radius: 6px; +} +.rca-confidence { + font-size: 11px; + color: var(--muted2); + flex-shrink: 0; +} +.mono { + font-family: var(--font-mono); +} +.rca-grid { + display: grid; + grid-template-columns: 148px minmax(0, 1fr); + gap: 18px; + align-items: start; +} +.rca-toc { + position: sticky; + top: 16px; + display: flex; + flex-direction: column; + gap: 2px; +} +.toc-link { + text-align: left; + padding: 5px 9px; + background: transparent; + border: none; + border-radius: 7px; + color: var(--muted); + font-size: 11.5px; + cursor: pointer; +} +.toc-link:hover { + background: var(--o05); + color: var(--text); +} +.rca-body { + display: flex; + flex-direction: column; + gap: 17px; + min-width: 0; +} +.section-label { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--faint); + margin: 0 0 7px; +} +.section-text { + margin: 0; + font-size: 13px; + line-height: 1.6; + color: var(--text3); + white-space: pre-wrap; + word-break: break-word; +} +.section-text.conclusion { + color: var(--text); +} +.timeline { + display: flex; + flex-direction: column; + gap: 6px; +} +.timeline-row { + display: flex; + gap: 12px; + font-size: 12.5px; +} +.timeline-time { + color: var(--muted2); + font-size: 11px; + flex-shrink: 0; + min-width: 92px; + padding-top: 1px; +} +.timeline-event { + color: var(--text3); + line-height: 1.5; +} +.factor-list { + margin: 0; + padding-left: 18px; + font-size: 13px; + line-height: 1.7; + color: var(--text3); +} +.customer-card { + border: 1.5px solid var(--c-teal); + background: var(--teal-bg-10, color-mix(in srgb, var(--c-teal) 8%, transparent)); + border-radius: 12px; + padding: 13px 14px; +} +.customer-editor { + width: 100%; + min-height: 88px; + resize: vertical; + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 9px; + padding: 10px 12px; + color: var(--text); + font-size: 13px; + line-height: 1.55; + outline: none; +} +.customer-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 9px; + margin-top: 10px; + flex-wrap: wrap; +} +.no-channel-note { + margin-right: auto; + font-size: 11px; + color: var(--faint); +} +.draft-btn { + padding: 7px 13px; + background: var(--o05); + border: 1px solid var(--o10); + color: var(--text); + border-radius: 9px; + font-size: 12px; + cursor: pointer; +} +.send-btn { + padding: 7px 15px; + background: var(--c-teal); + color: var(--on-light, #04140d); + border: none; + border-radius: 9px; + font-size: 12px; + font-weight: var(--font-weight-bold); + cursor: pointer; +} +.send-btn:disabled, +.draft-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} +@media (max-width: 900px) { + .rca-grid { + grid-template-columns: 1fr; + } + .rca-toc { + position: static; + flex-direction: row; + flex-wrap: wrap; + } +} +</style> diff --git a/frontend/src/components/tickets/TicketSidePanel.vue b/frontend/src/components/tickets/TicketSidePanel.vue new file mode 100644 index 0000000..ca2ecfd --- /dev/null +++ b/frontend/src/components/tickets/TicketSidePanel.vue @@ -0,0 +1,466 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed, onMounted, ref } from 'vue' +import { useRouter } from 'vue-router' +import type { Ticket } from '@/types/ticket' +import { formatSlaCountdown, ticketInitials } from './ticketMeta' +import { useUsers } from '@/composables/useUsers' + +const props = defineProps<{ + ticket: Ticket + linkedSessionIds: string[] + canManage: boolean +}>() + +const emit = defineEmits<{ + (e: 'assign', userId: string | null): void + (e: 'set-customer', email: string, name?: string): void +}>() + +const router = useRouter() +const { users, fetchUsers } = useUsers() +const isPickingAssignee = ref(false) + +const isEditingCustomer = ref(false) +const customerEmailDraft = ref('') +const customerNameDraft = ref('') + +function startCustomerEdit() { + customerEmailDraft.value = props.ticket.customer?.email || '' + customerNameDraft.value = props.ticket.customer?.full_name || '' + isEditingCustomer.value = true +} + +function saveCustomer() { + if (!customerEmailDraft.value.trim()) return + emit('set-customer', customerEmailDraft.value, customerNameDraft.value) + isEditingCustomer.value = false +} + +onMounted(() => { + if (props.canManage) fetchUsers().catch(() => {}) +}) + +const sla = computed(() => formatSlaCountdown(props.ticket.sla_due_at, props.ticket.resolved_at)) +const firstResponseMet = computed(() => !!props.ticket.first_response_at) + +const assigneeName = computed( + () => props.ticket.assignee?.full_name || props.ticket.assignee_name || null, +) + +// CSAT is asked on close, through the linked conversation's rating prompt, so +// a ticket with no conversation is never asked at all. A reopened-then-closed +// ticket is asked again, hence the timestamp comparison rather than a plain +// "has a score" check — the newest ask is the one being waited on. +const csat = computed(() => { + const { csat_score: score, csat_requested_at: asked, csat_responded_at: answered } = props.ticket + const awaitingAnswer = !!asked && (!answered || new Date(asked) > new Date(answered)) + + if (score != null && !awaitingAnswer) { + return { + state: 'scored' as const, + score, + label: `${score}/5`, + color: score >= 4 ? 'var(--c-positive)' : score >= 3 ? 'var(--c-warn)' : 'var(--c-danger)', + } + } + if (awaitingAnswer) { + return { state: 'pending' as const, score: 0, label: '待评价', color: 'var(--muted2)' } + } + return { state: 'not-requested' as const, score: 0, label: '未邀请评价', color: 'var(--faint)' } +}) + +function openConversation() { + if (props.linkedSessionIds.length) { + router.push({ path: '/conversations', query: { session: props.linkedSessionIds[0] } }) + } +} + +function pickAssignee(userId: string | null) { + isPickingAssignee.value = false + emit('assign', userId) +} +</script> + +<template> + <aside class="side-panel"> + <div class="panel-card"> + <div class="card-label">负责人</div> + <div class="assignee-row"> + <span class="avatar" :class="{ ai: !assigneeName }"> + {{ ticketInitials(assigneeName) }} + </span> + <div class="assignee-info"> + <div class="assignee-name">{{ assigneeName || 'ChatterMate AI' }}</div> + <div class="assignee-sub">{{ assigneeName ? '人工坐席' : '未分配——由 AI 处理' }}</div> + </div> + <button v-if="canManage" class="change-btn" @click="isPickingAssignee = !isPickingAssignee"> + 更改 + </button> + </div> + <div v-if="isPickingAssignee" class="assignee-picker"> + <button class="picker-option" @click="pickAssignee(null)">ChatterMate AI(取消分配)</button> + <button + v-for="user in users" + :key="user.id" + class="picker-option" + @click="pickAssignee(user.id)" + > + {{ user.full_name || user.email }} + </button> + </div> + </div> + + <div class="panel-card"> + <div class="card-label-row"> + <div class="card-label">客户</div> + <button + v-if="canManage && !isEditingCustomer" + class="change-btn" + @click="startCustomerEdit" + > + {{ ticket.customer ? '编辑' : '添加' }} + </button> + </div> + <template v-if="isEditingCustomer"> + <input + v-model="customerEmailDraft" + type="email" + class="customer-input" + placeholder="customer@company.com" + maxlength="320" + /> + <input + v-model="customerNameDraft" + class="customer-input" + placeholder="姓名(选填)" + maxlength="200" + /> + <div class="customer-edit-actions"> + <button class="customer-cancel" @click="isEditingCustomer = false">取消</button> + <button + class="customer-save" + :disabled="!customerEmailDraft.trim()" + @click="saveCustomer" + > + 保存 + </button> + </div> + </template> + <div v-else-if="ticket.customer" class="customer-row"> + <span class="customer-avatar"> + {{ (ticket.customer.full_name || ticket.customer.email || '?')[0]?.toUpperCase() }} + </span> + <div> + <div class="assignee-name">{{ ticket.customer.full_name || '客户' }}</div> + <div class="assignee-sub">{{ ticket.customer.email }}</div> + </div> + </div> + <div v-else class="no-customer"> + 未关联客户——此工单无法发送通知。 + </div> + </div> + + <div v-if="linkedSessionIds.length" class="panel-card"> + <div class="card-label">关联会话</div> + <button class="open-conversation" @click="openConversation">打开会话 →</button> + </div> + + <div class="panel-card"> + <div class="card-label">SLA</div> + <div class="sla-row"> + <span class="sla-label">首次响应</span> + <span class="sla-value" :style="{ color: firstResponseMet ? 'var(--c-positive)' : 'var(--muted2)' }"> + <template v-if="firstResponseMet"> + <font-awesome-icon :icon="['fas', 'check']" /> 已达成 + </template> + <template v-else>等待中</template> + </span> + </div> + <div class="sla-row"> + <span class="sla-label">解决时间</span> + <span class="sla-value mono" :style="{ color: sla.color }">{{ sla.label }}</span> + </div> + </div> + + <div class="panel-card"> + <div class="card-label">CSAT</div> + <div class="csat-row"> + <span v-if="csat.state === 'scored'" class="csat-stars" :style="{ color: csat.color }"> + <font-awesome-icon + v-for="star in 5" + :key="star" + :icon="[star <= csat.score ? 'fas' : 'far', 'star']" + /> + </span> + <span class="sla-value" :style="{ color: csat.color }">{{ csat.label }}</span> + </div> + <p class="csat-note"> + <template v-if="csat.state === 'scored'">客户已在关联会话中评分。</template> + <template v-else-if="csat.state === 'pending'">关闭时已邀请评价——等待客户评分。</template> + <template v-else-if="linkedSessionIds.length">工单关闭时会自动邀请评价。</template> + <template v-else>没有可评价的关联会话,因此此工单不收集 CSAT。</template> + </p> + </div> + + <div v-if="ticket.intent || ticket.ai_summary" class="panel-card"> + <div class="card-label">AI 分诊</div> + <div v-if="ticket.intent" class="triage-row"> + <span class="sla-label">意图</span> + <span class="sla-value mono">{{ ticket.intent }}</span> + </div> + <div v-if="ticket.triage_confidence != null" class="triage-row"> + <span class="sla-label">置信度</span> + <span class="sla-value mono">{{ ticket.triage_confidence.toFixed(2) }}</span> + </div> + <p v-if="ticket.ai_summary" class="triage-summary">{{ ticket.ai_summary }}</p> + </div> + + <div v-if="ticket.external_ref_id" class="panel-card"> + <div class="card-label">外部引用</div> + <a + :href="ticket.external_ref_url || '#'" + target="_blank" + rel="noopener" + class="external-ref" + > + {{ ticket.external_ref_type }} · {{ ticket.external_ref_id }} + </a> + </div> + </aside> +</template> + +<style scoped> +.side-panel { + display: flex; + flex-direction: column; + gap: 14px; +} +.panel-card { + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 13px; + padding: 15px; +} +.card-label { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--faint); + margin-bottom: 11px; +} +.assignee-row, +.customer-row { + display: flex; + align-items: center; + gap: 10px; +} +.avatar { + width: 30px; + height: 30px; + border-radius: 50%; + background: var(--c-coral); + color: var(--on-accent); + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: var(--font-weight-bold); + flex-shrink: 0; +} +.avatar.ai { + border-radius: 9px; + background: var(--accent-solid); + color: var(--on-accent-solid); +} +.no-customer { + font-size: 12px; + color: var(--faint); + line-height: 1.5; +} +.card-label-row { + display: flex; + align-items: center; + justify-content: space-between; +} +.customer-input { + width: 100%; + padding: 8px 11px; + background: var(--bg2); + border: 1px solid var(--o10); + border-radius: 9px; + color: var(--text); + font-size: 12.5px; + outline: none; + margin-bottom: 8px; +} +.customer-edit-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} +.customer-cancel { + padding: 6px 12px; + background: var(--o05); + border: 1px solid var(--o10); + color: var(--muted); + border-radius: 8px; + font-size: 12px; + cursor: pointer; +} +.customer-save { + padding: 6px 14px; + background: var(--accent-solid); + color: var(--on-accent-solid); + border: none; + border-radius: 8px; + font-size: 12px; + font-weight: var(--font-weight-semibold); + cursor: pointer; +} +.customer-save:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.customer-avatar { + width: 34px; + height: 34px; + border-radius: 10px; + background: var(--grad-purple-teal); + color: var(--on-accent); + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: var(--font-weight-bold); + font-family: var(--font-display); + flex-shrink: 0; +} +.assignee-info { + flex: 1; + min-width: 0; +} +.assignee-name { + font-size: 13px; + font-weight: var(--font-weight-semibold); + color: var(--text); +} +.assignee-sub { + font-size: 11px; + color: var(--faint); + overflow: hidden; + text-overflow: ellipsis; +} +.change-btn { + font-size: 11.5px; + color: var(--accent-ink); + background: var(--accent-bg-08); + border: none; + padding: 4px 9px; + border-radius: 7px; + cursor: pointer; +} +.assignee-picker { + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 2px; + max-height: 200px; + overflow-y: auto; +} +.picker-option { + text-align: left; + padding: 7px 10px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text3); + font-size: 12.5px; + cursor: pointer; +} +.picker-option:hover { + background: var(--o05); + color: var(--text); +} +.open-conversation { + width: 100%; + padding: 9px; + background: var(--o05); + border: 1px solid var(--o10); + color: var(--text); + border-radius: 9px; + font-size: 12.5px; + font-weight: var(--font-weight-semibold); + cursor: pointer; +} +.open-conversation:hover { + background: var(--o08); +} +.sla-row, +.triage-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 9px; +} +.sla-row:last-child, +.triage-row:last-child { + margin-bottom: 0; +} +.sla-label { + font-size: 12.5px; + color: var(--text3); +} +.sla-value { + font-size: 11.5px; + font-weight: var(--font-weight-semibold); +} +.mono { + font-family: var(--font-mono); + font-size: 12px; +} +.csat-row { + display: flex; + align-items: center; + gap: 9px; +} +.csat-stars { + display: inline-flex; + gap: 3px; + font-size: 12px; +} +.csat-note { + margin: 9px 0 0; + font-size: 12px; + line-height: 1.5; + color: var(--faint); +} +.triage-summary { + margin: 10px 0 0; + font-size: 12.5px; + line-height: 1.55; + color: var(--text3); +} +.external-ref { + font-family: var(--font-mono); + font-size: 12px; + color: var(--c-teal); + text-decoration: none; +} +</style> diff --git a/frontend/src/components/tickets/TicketStatusBadge.vue b/frontend/src/components/tickets/TicketStatusBadge.vue new file mode 100644 index 0000000..6d85eaa --- /dev/null +++ b/frontend/src/components/tickets/TicketStatusBadge.vue @@ -0,0 +1,44 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed } from 'vue' +import type { TicketStatus } from '@/types/ticket' +import { statusMeta } from './ticketMeta' + +const props = defineProps<{ status: TicketStatus }>() +const meta = computed(() => statusMeta(props.status)) +</script> + +<template> + <span class="ticket-status-badge" :style="{ '--chip-color': meta.color }"> + {{ meta.label }} + </span> +</template> + +<style scoped> +.ticket-status-badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: var(--radius-full); + font-size: 11.5px; + font-weight: var(--font-weight-semibold); + color: var(--chip-color); + background: color-mix(in srgb, var(--chip-color) 14%, transparent); + white-space: nowrap; +} +</style> diff --git a/frontend/src/components/tickets/ticketMeta.ts b/frontend/src/components/tickets/ticketMeta.ts new file mode 100644 index 0000000..c91abc5 --- /dev/null +++ b/frontend/src/components/tickets/ticketMeta.ts @@ -0,0 +1,114 @@ +/* +Copyright 2024-2026 ChatterMate + +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. + +Shared display metadata for ticket statuses / priorities / AI states. +Colors come from the design tokens so both themes work. +*/ + +import type { HypothesisStatus, TicketAiState, TicketPriority, TicketStatus } from '@/types/ticket' + +export interface ChipMeta { + label: string + color: string +} + +export const STATUS_META: Record<TicketStatus, ChipMeta> = { + open: { label: '待处理', color: 'var(--c-info)' }, + triaging: { label: '待处理 · AI 分诊', color: 'var(--c-info)' }, + investigating: { label: '待处理 · AI 处理中', color: 'var(--c-info)' }, + awaiting_approval: { label: '待审批', color: 'var(--c-warn)' }, + in_progress: { label: '处理中', color: 'var(--c-teal)' }, + resolved_pending_confirmation: { label: '已解决 · 待确认', color: 'var(--c-positive)' }, + resolved: { label: '已解决', color: 'var(--c-positive)' }, + closed: { label: '已关闭', color: 'var(--c-neutral)' }, + reopened: { label: '重新打开', color: 'var(--c-coral)' }, +} + +export const PRIORITY_META: Record<TicketPriority, ChipMeta> = { + urgent: { label: '紧急', color: 'var(--c-danger)' }, + high: { label: '高', color: 'var(--c-coral)' }, + medium: { label: '中', color: 'var(--c-warn)' }, + low: { label: '低', color: 'var(--c-neutral)' }, +} + +export const AI_STATE_META: Record<TicketAiState, ChipMeta & { pulse: boolean }> = { + investigating: { label: 'AI 调查中', color: 'var(--c-info)', pulse: true }, + awaiting: { label: '等待人工', color: 'var(--c-warn)', pulse: false }, + human: { label: '人工处理', color: 'var(--c-neutral)', pulse: false }, + resolved: { label: 'AI 已解决', color: 'var(--c-positive)', pulse: false }, +} + +// Human-selectable statuses for the detail dropdown, in lifecycle order. +// Triaging/investigating are AI-driven and not directly selectable. +export const SELECTABLE_STATUSES: TicketStatus[] = [ + 'open', + 'in_progress', + 'awaiting_approval', + 'resolved_pending_confirmation', + 'resolved', + 'closed', + 'reopened', +] + +export const PRIORITIES: TicketPriority[] = ['urgent', 'high', 'medium', 'low'] + +export function statusMeta(status: TicketStatus): ChipMeta { + return STATUS_META[status] || { label: status, color: 'var(--c-neutral)' } +} + +export function priorityMeta(priority: TicketPriority): ChipMeta { + return PRIORITY_META[priority] || { label: priority, color: 'var(--c-neutral)' } +} + +export function aiStateMeta(state?: TicketAiState | null) { + return (state && AI_STATE_META[state]) || AI_STATE_META.human +} + +export const HYPOTHESIS_STATUS_META: Record<HypothesisStatus, ChipMeta> = { + pending: { label: '待验证', color: 'var(--c-neutral)' }, + testing: { label: '验证中', color: 'var(--c-info)' }, + validated: { label: '已验证', color: 'var(--c-positive)' }, + invalidated: { label: '已推翻', color: 'var(--c-danger)' }, + inconclusive: { label: '无定论', color: 'var(--c-warn)' }, +} + +export function hypothesisMeta(status: HypothesisStatus): ChipMeta { + return HYPOTHESIS_STATUS_META[status] || { label: status, color: 'var(--c-neutral)' } +} + +export function ticketInitials(name?: string | null): string { + if (!name) return 'AI' + return name + .split(/\s+/) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join('') +} + +export function formatSlaCountdown(slaDueAt?: string | null, resolvedAt?: string | null): ChipMeta { + if (resolvedAt || !slaDueAt) return { label: '—', color: 'var(--faint)' } + const remainingMs = new Date(slaDueAt).getTime() - Date.now() + if (remainingMs <= 0) return { label: '已超时', color: 'var(--c-danger)' } + const minutes = Math.floor(remainingMs / 60000) + const label = + minutes >= 60 * 24 + ? `${Math.floor(minutes / (60 * 24))}天 ${Math.floor((minutes % (60 * 24)) / 60)}小时` + : minutes >= 60 + ? `${Math.floor(minutes / 60)}小时 ${minutes % 60}分` + : `${minutes}分钟` + const color = + minutes < 10 ? 'var(--c-danger)' : minutes < 30 ? 'var(--c-warn)' : 'var(--muted2)' + return { label, color } +} diff --git a/frontend/src/components/workflow/NodeConfigPanel.vue b/frontend/src/components/workflow/NodeConfigPanel.vue new file mode 100644 index 0000000..2f82d9a --- /dev/null +++ b/frontend/src/components/workflow/NodeConfigPanel.vue @@ -0,0 +1,668 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { ref, computed, onMounted } from 'vue' +import type { Node } from '@vue-flow/core' + +const props = defineProps<{ + node: Node + workflowId: string +}>() + +const emit = defineEmits<{ + (e: 'save', config: any): void + (e: 'cancel'): void + (e: 'delete', nodeId: string): void +}>() + +// Form state +const formData = ref({ + label: '', + description: '', + config: {} as Record<string, any> +}) + +// Initialize form data +onMounted(() => { + formData.value = { + label: props.node.data.label || '', + description: props.node.data.description || '', + config: { ...(props.node.data.config || {}) } + } +}) + +// Field configuration type +interface FieldConfig { + key: string + label: string + type: string + required?: boolean + placeholder?: string + options?: string[] + min?: number + max?: number + step?: number + default?: any + arrayType?: string +} + +// Node type configurations +const nodeConfigs: Record<string, { fields: FieldConfig[] }> = { + message: { + fields: [ + { key: 'message', label: '消息内容', type: 'textarea', required: true } + ] + }, + llm: { + fields: [ + { key: 'model', label: 'AI 模型', type: 'select', options: ['gpt-4', 'gpt-3.5-turbo', 'claude-3'], required: true }, + { key: 'systemPrompt', label: '系统提示词', type: 'textarea', required: true }, + { key: 'temperature', label: '温度', type: 'number', min: 0, max: 2, step: 0.1, default: 0.7 }, + { key: 'maxTokens', label: '最大 Token 数', type: 'number', min: 1, max: 4000, default: 1000 } + ] + }, + condition: { + fields: [ + { key: 'condition', label: '条件表达式', type: 'textarea', required: true }, + { key: 'conditionType', label: '条件类型', type: 'select', options: ['javascript', 'simple'], required: true } + ] + }, + form: { + fields: [ + { key: 'fields', label: '表单字段', type: 'array', arrayType: 'object', required: true } + ] + }, + action: { + fields: [ + { key: 'actionType', label: '动作类型', type: 'select', options: ['webhook', 'email', 'api'], required: true }, + { key: 'url', label: 'URL', type: 'text', required: true }, + { key: 'method', label: 'HTTP 方法', type: 'select', options: ['GET', 'POST', 'PUT', 'DELETE'], default: 'POST' }, + { key: 'headers', label: '请求头', type: 'object' }, + { key: 'body', label: '请求体', type: 'textarea' } + ] + }, + humanTransfer: { + fields: [ + { key: 'department', label: '部门', type: 'select', options: ['general', 'sales', 'support', 'technical'], required: true }, + { key: 'message', label: '转接消息', type: 'textarea' }, + { key: 'priority', label: '优先级', type: 'select', options: ['low', 'medium', 'high'], default: 'medium' } + ] + }, + wait: { + fields: [ + { key: 'duration', label: '时长', type: 'number', min: 1, required: true }, + { key: 'unit', label: '时间单位', type: 'select', options: ['seconds', 'minutes', 'hours', 'days'], default: 'seconds' }, + { key: 'waitType', label: '等待类型', type: 'select', options: ['fixed', 'user_input'], default: 'fixed' } + ] + }, + end: { + fields: [ + { key: 'endType', label: '结束类型', type: 'select', options: ['success', 'failure', 'timeout'], default: 'success' }, + { key: 'finalMessage', label: '最终消息', type: 'textarea' } + ] + } +} + +// Get configuration for current node type +const currentConfig = computed(() => { + return nodeConfigs[props.node.type as keyof typeof nodeConfigs] || { fields: [] } +}) + +// Handle form submission +const handleSave = () => { + emit('save', formData.value) +} + +// Handle cancel +const handleCancel = () => { + emit('cancel') +} + +// Handle delete +const handleDelete = () => { + if (confirm('确定要删除此节点吗?')) { + emit('delete', props.node.id) + } +} + +// Handle array field changes +const addArrayItem = (fieldKey: string) => { + if (!formData.value.config[fieldKey]) { + formData.value.config[fieldKey] = [] + } + formData.value.config[fieldKey].push({}) +} + +const removeArrayItem = (fieldKey: string, index: number) => { + if (formData.value.config[fieldKey]) { + formData.value.config[fieldKey].splice(index, 1) + } +} + +// Get node type display name +const getNodeTypeName = (type: string | undefined) => { + if (!type) return '未知' + const names = { + message: '消息', + llm: 'LLM', + condition: '条件', + form: '表单', + action: '动作', + humanTransfer: '转人工', + wait: '等待', + end: '结束' + } + return names[type as keyof typeof names] || type +} + +// Handle object field changes +const handleObjectChange = (fieldKey: string, value: string) => { + try { + formData.value.config[fieldKey] = JSON.parse(value) + } catch (e) { + // Keep as string if not valid JSON + formData.value.config[fieldKey] = value + } +} +</script> + +<template> + <div class="node-config-overlay" @click="handleCancel"> + <div class="node-config-panel" @click.stop> + <div class="panel-header"> + <div class="header-left"> + <h3>配置{{ getNodeTypeName(node.type) }}节点</h3> + <span class="node-id">ID:{{ node.id }}</span> + </div> + <button class="close-button" @click="handleCancel"> + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <line x1="18" y1="6" x2="6" y2="18"></line> + <line x1="6" y1="6" x2="18" y2="18"></line> + </svg> + </button> + </div> + + <div class="panel-content"> + <form @submit.prevent="handleSave"> + <!-- Basic Information --> + <div class="form-section"> + <h4>基本信息</h4> + + <div class="form-group"> + <label for="node-label">节点名称 *</label> + <input + id="node-label" + v-model="formData.label" + type="text" + class="form-input" + placeholder="输入节点名称" + required + /> + </div> + + <div class="form-group"> + <label for="node-description">描述</label> + <textarea + id="node-description" + v-model="formData.description" + class="form-textarea" + placeholder="输入节点描述(可选)" + rows="3" + ></textarea> + </div> + </div> + + <!-- Node-specific Configuration --> + <div v-if="currentConfig.fields.length > 0" class="form-section"> + <h4>{{ getNodeTypeName(node.type) }}配置</h4> + + <div + v-for="field in currentConfig.fields" + :key="field.key" + class="form-group" + > + <label :for="`config-${field.key}`"> + {{ field.label }} + <span v-if="field.required" class="required">*</span> + </label> + + <!-- Text Input --> + <input + v-if="field.type === 'text'" + :id="`config-${field.key}`" + v-model="formData.config[field.key]" + type="text" + class="form-input" + :placeholder="field.placeholder || `输入${field.label?.toLowerCase() || '值'}`" + :required="field.required" + /> + + <!-- Number Input --> + <input + v-else-if="field.type === 'number'" + :id="`config-${field.key}`" + v-model.number="formData.config[field.key]" + type="number" + class="form-input" + :min="field.min" + :max="field.max" + :step="field.step" + :placeholder="field.default?.toString() || ''" + :required="field.required" + /> + + <!-- Textarea --> + <textarea + v-else-if="field.type === 'textarea'" + :id="`config-${field.key}`" + v-model="formData.config[field.key]" + class="form-textarea" + :placeholder="field.placeholder || `输入${field.label?.toLowerCase() || '值'}`" + rows="4" + :required="field.required" + ></textarea> + + <!-- Select --> + <select + v-else-if="field.type === 'select'" + :id="`config-${field.key}`" + v-model="formData.config[field.key]" + class="form-select" + :required="field.required" + > + <option value="">选择{{ field.label.toLowerCase() }}</option> + <option + v-for="option in field.options" + :key="option" + :value="option" + > + {{ option }} + </option> + </select> + + <!-- Checkbox --> + <label + v-else-if="field.type === 'checkbox'" + class="checkbox-label" + > + <input + :id="`config-${field.key}`" + v-model="formData.config[field.key]" + type="checkbox" + class="form-checkbox" + /> + <span class="checkbox-text">{{ field.label }}</span> + </label> + + <!-- Object (JSON) --> + <textarea + v-else-if="field.type === 'object'" + :id="`config-${field.key}`" + :value="JSON.stringify(formData.config[field.key] || {}, null, 2)" + @input="handleObjectChange(field.key, ($event.target as HTMLTextAreaElement).value)" + class="form-textarea" + placeholder="输入 JSON 对象" + rows="4" + ></textarea> + + <!-- Array --> + <div v-else-if="field.type === 'array'" class="array-field"> + <div + v-for="(item, index) in formData.config[field.key] || []" + :key="index" + class="array-item" + > + <input + v-if="field.arrayType === 'string'" + v-model="formData.config[field.key][index]" + type="text" + class="form-input" + :placeholder="`${field.label} ${index + 1}`" + /> + <div v-else-if="field.arrayType === 'object'" class="object-item"> + <input + v-model="formData.config[field.key][index].name" + type="text" + class="form-input" + placeholder="字段名" + /> + <select + v-model="formData.config[field.key][index].type" + class="form-select" + > + <option value="text">文本</option> + <option value="email">邮箱</option> + <option value="number">数字</option> + <option value="select">下拉选择</option> + <option value="checkbox">复选框</option> + </select> + </div> + <button + type="button" + class="remove-item-btn" + @click="removeArrayItem(field.key, index)" + > + × + </button> + </div> + <button + type="button" + class="add-item-btn" + @click="addArrayItem(field.key)" + > + 添加{{ field.label }} + </button> + </div> + </div> + </div> + </form> + </div> + + <div class="panel-footer"> + <button type="button" class="btn btn-danger" @click="handleDelete"> + 删除节点 + </button> + <div class="footer-actions"> + <button type="button" class="btn btn-secondary" @click="handleCancel"> + 取消 + </button> + <button type="submit" class="btn btn-primary" @click="handleSave"> + 保存修改 + </button> + </div> + </div> + </div> + </div> +</template> + +<style scoped> +.node-config-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1001; +} + +.node-config-panel { + background: var(--background-color); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + width: 90%; + max-width: 600px; + max-height: 80vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-lg); + border-bottom: 1px solid var(--border-color); + background: var(--background-soft); +} + +.header-left h3 { + margin: 0; + font-size: 1.2rem; + font-weight: 600; + color: var(--text-color); +} + +.node-id { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 2px; +} + +.close-button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: transparent; + border: none; + border-radius: var(--radius-md); + color: var(--text-muted); + cursor: pointer; + transition: all 0.2s ease; +} + +.close-button:hover { + background: var(--background-muted); + color: var(--text-color); +} + +.close-button svg { + width: 16px; + height: 16px; +} + +.panel-content { + flex: 1; + overflow-y: auto; + padding: var(--space-lg); +} + +.form-section { + margin-bottom: var(--space-xl); +} + +.form-section h4 { + margin: 0 0 var(--space-md) 0; + font-size: 1rem; + font-weight: 600; + color: var(--text-color); + border-bottom: 1px solid var(--border-color); + padding-bottom: var(--space-sm); +} + +.form-group { + margin-bottom: var(--space-md); +} + +.form-group label { + display: block; + font-size: var(--text-sm); + font-weight: 500; + color: var(--text-color); + margin-bottom: var(--space-xs); +} + +.required { + color: var(--error-color); +} + +.form-input, +.form-textarea, +.form-select { + width: 100%; + padding: var(--space-sm); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--background-color); + color: var(--text-color); + font-size: var(--text-sm); + transition: border-color 0.2s ease; +} + +.form-input:focus, +.form-textarea:focus, +.form-select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15); +} + +.form-textarea { + resize: vertical; + min-height: 80px; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: var(--space-sm); + cursor: pointer; + font-weight: normal; +} + +.form-checkbox { + width: auto; + margin: 0; +} + +.checkbox-text { + font-size: var(--text-sm); + color: var(--text-color); +} + +.array-field { + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: var(--space-sm); + background: var(--background-soft); +} + +.array-item { + display: flex; + gap: var(--space-sm); + margin-bottom: var(--space-sm); + align-items: center; +} + +.object-item { + display: flex; + gap: var(--space-sm); + flex: 1; +} + +.remove-item-btn { + background: var(--error-color); + color: white; + border: none; + border-radius: var(--radius-sm); + width: 28px; + height: 28px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + line-height: 1; + flex-shrink: 0; +} + +.add-item-btn { + background: var(--accent-solid); + color: var(--on-accent-solid); + border: none; + border-radius: var(--radius-md); + padding: var(--space-sm); + cursor: pointer; + font-size: var(--text-sm); + transition: background-color 0.2s ease; +} + +.add-item-btn:hover { + background: var(--primary-dark); +} + +.panel-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-lg); + border-top: 1px solid var(--border-color); + background: var(--background-soft); +} + +.footer-actions { + display: flex; + gap: var(--space-sm); +} + +.btn { + padding: var(--space-sm) var(--space-md); + border: none; + border-radius: var(--radius-md); + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + font-size: var(--text-sm); +} + +.btn-primary { + background: var(--accent-solid); + color: var(--on-accent-solid); +} + +.btn-primary:hover { + background: var(--primary-dark); +} + +.btn-secondary { + background: var(--background-muted); + color: var(--text-muted); + border: 1px solid var(--border-color); +} + +.btn-secondary:hover { + background: var(--background-alt); + color: var(--text-color); +} + +.btn-danger { + background: var(--error-color); + color: white; +} + +.btn-danger:hover { + background: #DC2626; +} + +/* Responsive */ +@media (max-width: 768px) { + .node-config-panel { + width: 95%; + max-height: 90vh; + } + + .panel-footer { + flex-direction: column; + gap: var(--space-md); + } + + .footer-actions { + width: 100%; + justify-content: stretch; + } + + .btn { + flex: 1; + } +} +</style> \ No newline at end of file diff --git a/frontend/src/components/workflow/PropertiesPanel.vue b/frontend/src/components/workflow/PropertiesPanel.vue new file mode 100644 index 0000000..6cdca28 --- /dev/null +++ b/frontend/src/components/workflow/PropertiesPanel.vue @@ -0,0 +1,2176 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { ref, watch, computed, type Ref } from 'vue' +import type { Node, Edge } from '@vue-flow/core' +import { workflowNodeService } from '@/services/workflowNode' +import { workflowCacheStorage } from '@/utils/storage' +import LLMNodeConfig from '@/components/workflow/nodes/LLMNodeConfig.vue' +import FormNodeConfig from '@/components/workflow/nodes/FormNodeConfig.vue' +import WaitNodeConfig from '@/components/workflow/nodes/WaitNodeConfig.vue' +import HumanTransferNodeConfig from '@/components/workflow/nodes/HumanTransferNodeConfig.vue' +import LandingPageNodeConfig from '@/components/workflow/nodes/LandingPageNodeConfig.vue' +import EndNodeConfig from '@/components/workflow/nodes/EndNodeConfig.vue' +import ActionNodeConfig from '@/components/workflow/nodes/ActionNodeConfig.vue' +import MessageNodeConfig from '@/components/workflow/nodes/MessageNodeConfig.vue' +import ConditionNodeConfig from '@/components/workflow/nodes/ConditionNodeConfig.vue' +import UserInputNodeConfig from '@/components/workflow/nodes/UserInputNodeConfig.vue' +import GuardrailsNodeConfig from '@/components/workflow/nodes/GuardrailsNodeConfig.vue' +import { ExitCondition } from '@/types/workflow' +import { listGroups } from '@/services/groups' +import type { UserGroup } from '@/types/user' +import { toast } from 'vue-sonner' + +// Collapsible sections state +const collapsedSections = ref({ + basic: false, + nodeSettings: false, + advanced: true, // Advanced settings collapsed by default + knowledge: true, // Knowledge sources collapsed by default + variables: false // Variables expanded by default +}) + +// Define form field interface +interface FormField { + name: string + label: string + type: string + required: boolean + placeholder: string + options: string | string[] + minLength: number + maxLength: number +} + +const props = defineProps<{ + selectedNode: Node + availableNodeTypes: Array<{ + type: string + label: string + icon: string + description: string + color: string + }> + workflowId: string + agentId: string + organizationId: string + currentEdges: Edge[] | Ref<Edge[]> + currentNodes: Node[] | Ref<Node[]> + availableVariables: Array<{ + nodeId: string + nodeName: string + fieldName: string + fieldType: string + fieldLabel: string + }> +}>() + +const emit = defineEmits<{ + (e: 'save', properties: any): void + (e: 'close'): void + (e: 'delete'): void +}>() + + + +const nodeForm = ref({ + name: '', + description: '', + // Message node + message_text: '', + show_typing: false, + // LLM node + system_prompt: '', + temperature: 0.7, + exit_condition: ExitCondition.SINGLE_EXECUTION, + auto_transfer_enabled: false, + transfer_group_id: '', + ask_for_rating: false, + // Condition node + condition_expression: '', + condition_groups: [] as any[], + + // Form node + form_fields: [] as FormField[], + form_title: '', + form_description: '', + submit_button_text: '提交', + form_full_screen: false, + // Action node + action_type: '', + action_url: '', + // Human transfer node + transfer_department: '', + transfer_message: '', + // Wait node + wait_duration: 5, + wait_unit: 'seconds', + // End node + final_message: '', + // Landing Page node + landing_page_heading: '', + landing_page_content: '', + // User Input node + prompt_message: '', + confirmation_message: '', + // Guardrails node + enabled_guardrails: ['pii', 'jailbreak'] as string[], + pii_action: 'block', + jailbreak_sensitivity: 0.7, + text_source: 'user_message', + block_message: '' +}) + +const saving = ref(false) + +// Validation errors state +const validationErrors = ref<Record<string, string>>({}) + +// User groups state +const userGroups = ref<UserGroup[]>([]) +const loadingGroups = ref(false) + +// Auto-save functionality +const autoSaveTimeout = ref<number | null>(null) + +// Simple debounce implementation +const debounce = (func: Function, delay: number) => { + return (...args: any[]) => { + if (autoSaveTimeout.value) { + clearTimeout(autoSaveTimeout.value) + } + autoSaveTimeout.value = setTimeout(() => func(...args), delay) + } +} + + + +// Toggle collapsible section +const toggleSection = (section: keyof typeof collapsedSections.value | string) => { + if (section in collapsedSections.value) { + collapsedSections.value[section as keyof typeof collapsedSections.value] = !collapsedSections.value[section as keyof typeof collapsedSections.value] + } +} + +// Variables functionality +const shouldShowVariables = computed(() => { + // Show variables for nodes that can use them (message, llm, landingPage, userInput, condition) + // Also show for userInput nodes to display what variables they will create + const nodeType = props.selectedNode.data.nodeType + return ['message', 'llm', 'landingPage', 'userInput', 'condition'].includes(nodeType) && props.availableVariables.length > 0 +}) + +const getVariableSyntax = (fieldName: string) => { + return `{{${fieldName}}}` +} + +const copyVariableToClipboard = async (variable: any) => { + try { + const variableSyntax = getVariableSyntax(variable.fieldName) + await navigator.clipboard.writeText(variableSyntax) + + // Show success toast + toast.success(`已复制 ${variableSyntax} 到剪贴板`, { + position: 'top-center', + duration: 2000 + }) + } catch (error) { + console.error('Failed to copy to clipboard:', error) + toast.error('复制到剪贴板失败', { + position: 'top-center' + }) + } +} + +// Update LLM form data from child component +const updateLLMFormData = (data: any) => { + Object.assign(nodeForm.value, data) + // Trigger auto-save after updating + autoSaveToCache() +} + +// Update Form form data from child component +const updateFormFormData = (data: any) => { + Object.assign(nodeForm.value, data) + // Trigger auto-save after updating + autoSaveToCache() +} + +// Update Wait form data from child component +const updateWaitFormData = (data: any) => { + Object.assign(nodeForm.value, data) + // Trigger auto-save after updating + autoSaveToCache() +} + +// Update Human Transfer form data from child component +const updateHumanTransferFormData = (data: any) => { + Object.assign(nodeForm.value, data) + // Trigger auto-save after updating + autoSaveToCache() +} + +// Update Landing Page form data from child component +const updateLandingPageFormData = (data: any) => { + Object.assign(nodeForm.value, data) + // Trigger auto-save after updating + autoSaveToCache() +} + +// Update End form data from child component +const updateEndFormData = (data: any) => { + Object.assign(nodeForm.value, data) + // Trigger auto-save after updating + autoSaveToCache() +} + +// Update Action form data from child component +const updateActionFormData = (data: any) => { + Object.assign(nodeForm.value, data) + // Trigger auto-save after updating + autoSaveToCache() +} + +// Update Message form data from child component +const updateMessageFormData = (data: any) => { + Object.assign(nodeForm.value, data) + // Trigger auto-save after updating + autoSaveToCache() +} + +// Update Condition form data from child component +const updateConditionFormData = (data: any) => { + Object.assign(nodeForm.value, data) + // Trigger auto-save after updating + autoSaveToCache() +} + +// Update User Input form data from child component +const updateUserInputFormData = (data: any) => { + Object.assign(nodeForm.value, data) + // Trigger auto-save after updating + autoSaveToCache() +} + +// Update Guardrails form data from child component +const updateGuardrailsFormData = (data: any) => { + Object.assign(nodeForm.value, data) + // Trigger auto-save after updating + autoSaveToCache() +} + +// Helper function to check if node name is unique +const isNodeNameUnique = (nodeName: string): boolean => { + const currentNodes = Array.isArray(props.currentNodes) + ? props.currentNodes + : (props.currentNodes as Ref<Node[]>).value + const currentNodeId = props.selectedNode.id + + return !currentNodes.some((node: any) => + node.id !== currentNodeId && + (node.data.cleanName?.toLowerCase() === nodeName.toLowerCase() || + node.data.label?.toLowerCase() === nodeName.toLowerCase()) + ) +} + +// Helper function to validate condition node connections +const validateConditionConnections = (): string | null => { + if (props.selectedNode.data.nodeType !== 'condition') return null + + const edges = Array.isArray(props.currentEdges) + ? props.currentEdges + : (props.currentEdges as Ref<Edge[]>).value + const outgoingConnections = edges.filter((edge: any) => edge.source === props.selectedNode.id) + + if (outgoingConnections.length === 0) { + return '条件节点必须同时具备“是”和“否”两条连接' + } + + if (outgoingConnections.length === 1) { + const existingLabel = outgoingConnections[0].label + const missingLabel = existingLabel === 'true' ? 'false' : 'true' + return `条件节点缺少“${missingLabel === 'true' ? '是' : '否'}”连接` + } + + if (outgoingConnections.length === 2) { + const labels = outgoingConnections.map((edge: any) => edge.label).filter(Boolean) + const hasTrue = labels.includes('true') + const hasFalse = labels.includes('false') + + if (!hasTrue && !hasFalse) { + return '条件连接必须分别标记为“是”和“否”' + } else if (!hasTrue) { + return '条件节点缺少“是”连接' + } else if (!hasFalse) { + return '条件节点缺少“否”连接' + } + } + + return null +} + +// Validation functions +const validateField = (field: string, value: any, nodeType: string): string | null => { + switch (field) { + case 'name': + if (!value || value.trim() === '') { + return '请输入节点名称' + } + if (value.length > 100) { + return '节点名称不能超过 100 个字符' + } + // Check for unique node name + if (!isNodeNameUnique(value.trim())) { + return '节点名称在工作流内必须唯一' + } + break + + case 'message_text': + if (nodeType === 'message' && (!value || value.trim() === '')) { + return '请输入消息内容' + } + break + + case 'system_prompt': + if (nodeType === 'llm' && (!value || value.trim() === '')) { + return '请输入系统提示词' + } + break + + case 'temperature': + if (nodeType === 'llm' && (value < 0 || value > 2)) { + return '温度必须在 0 到 2 之间' + } + break + + case 'exit_condition': + if (nodeType === 'llm' && (!value || value.trim() === '')) { + return '请选择退出条件' + } + break + + case 'condition_expression': + if (nodeType === 'condition' && (!value || value.trim() === '')) { + return '请输入条件表达式' + } + break + + case 'condition_groups': + if (nodeType === 'condition') { + if (!value || !Array.isArray(value) || value.length === 0) { + return '至少需要一条条件规则' + } + + let hasValidRule = false + for (let groupIndex = 0; groupIndex < value.length; groupIndex++) { + const group = value[groupIndex] + if (group.rules && Array.isArray(group.rules)) { + for (let ruleIndex = 0; ruleIndex < group.rules.length; ruleIndex++) { + const rule = group.rules[ruleIndex] + + // Check if rule has all required fields + if (rule.variable && rule.operator && rule.value) { + hasValidRule = true + } else { + // Only show error for incomplete rules if they have any field filled + if (rule.variable || rule.operator || rule.value) { + const missingFields = [] + if (!rule.variable) missingFields.push('变量') + if (!rule.operator) missingFields.push('运算符') + if (!rule.value) missingFields.push('值') + + return `第 ${groupIndex + 1} 组第 ${ruleIndex + 1} 条规则:缺少${missingFields.join('、')}` + } + } + } + } + } + + if (!hasValidRule) { + return '至少需要一条完整的条件规则(变量、运算符和值)' + } + } + break + + case 'condition_connections': + if (nodeType === 'condition') { + return validateConditionConnections() + } + break + + + case 'action_type': + if (nodeType === 'action' && (!value || value.trim() === '')) { + return '请选择动作类型' + } + break + + case 'action_url': + if (nodeType === 'action' && (!value || value.trim() === '')) { + return '请输入动作 URL' + } + if (nodeType === 'action' && value && !isValidUrl(value)) { + return '请输入有效的 URL' + } + break + + case 'transfer_department': + if (nodeType === 'humanTransfer' && (!value || value.trim() === '')) { + return '请选择部门' + } + break + + case 'wait_duration': + if (nodeType === 'wait' && (!value || value < 1)) { + return '等待时长至少为 1' + } + break + + case 'wait_unit': + if (nodeType === 'wait' && (!value || value.trim() === '')) { + return '请选择时间单位' + } + break + + case 'form_fields': + if (nodeType === 'form' && (!value || value.length === 0)) { + return '至少需要一个表单字段' + } + if (nodeType === 'form' && value && value.length > 0) { + for (let i = 0; i < value.length; i++) { + const field = value[i] + if (!field.name || field.name.trim() === '') { + return `表单字段 ${i + 1}:请输入字段名` + } + if (!field.label || field.label.trim() === '') { + return `表单字段 ${i + 1}:请输入显示名称` + } + if (field.name && !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(field.name)) { + return `表单字段 ${i + 1}:字段名必须以字母或下划线开头,且只能包含字母、数字和下划线` + } + } + } + break + + case 'landing_page_heading': + if (nodeType === 'landingPage' && (!value || value.trim() === '')) { + return '请输入标题' + } + break + + case 'landing_page_content': + if (nodeType === 'landingPage' && (!value || value.trim() === '')) { + return '请输入内容' + } + break + + // Removed prompt_message validation case - it's now optional + } + return null +} + +// Helper function to validate URLs +const isValidUrl = (url: string): boolean => { + try { + new URL(url) + return true + } catch { + return false + } +} + +// Validate all fields +const validateForm = (): boolean => { + const errors: Record<string, string> = {} + const nodeType = props.selectedNode.data.nodeType + + // Validate each field + const fieldsToValidate = [ + 'name', 'message_text', 'system_prompt', 'temperature', 'exit_condition', + 'condition_expression', 'condition_groups', 'condition_connections', 'action_type', 'action_url', + 'transfer_department', 'wait_duration', 'wait_unit', 'form_fields', + 'landing_page_heading', 'landing_page_content', 'prompt_message', 'confirmation_message' + ] + + fieldsToValidate.forEach(field => { + const value = nodeForm.value[field as keyof typeof nodeForm.value] + const error = validateField(field, value, nodeType) + if (error) { + errors[field] = error + } + }) + + validationErrors.value = errors + return Object.keys(errors).length === 0 +} + +// Real-time validation on field change +const validateFieldOnChange = (field: string) => { + const value = nodeForm.value[field as keyof typeof nodeForm.value] + const error = validateField(field, value, props.selectedNode.data.nodeType) + + if (error) { + validationErrors.value[field] = error + } else { + delete validationErrors.value[field] + } + + // Trigger auto-save after validation + autoSaveToCache() +} + + + +// Load user groups for transfer +const loadUserGroups = async () => { + try { + loadingGroups.value = true + userGroups.value = await listGroups() + } catch (error) { + console.error('Failed to load user groups:', error) + userGroups.value = [] + } finally { + loadingGroups.value = false + } +} + +// Get node data with latest cache values for PropertiesPanel +const getNodeDataForForm = (node: Node) => { + // Get cached workflow data + const workflowCache = workflowCacheStorage.getWorkflowCache(props.workflowId) + + // Start with node data + let nodeData = { ...node.data } + + if (workflowCache && workflowCache.nodes) { + // Find this node in the cache + const cachedNode = workflowCache.nodes.find((n: any) => n.id === node.id) + if (cachedNode) { + console.log('Found cached node:', cachedNode) + // Update name from cache + if (cachedNode.name) nodeData.cleanName = cachedNode.name + if (cachedNode.description) nodeData.description = cachedNode.description + + // Update config values from cache + if (cachedNode.config) { + Object.assign(nodeData, cachedNode.config) + nodeData.config = cachedNode.config + } + } else { + console.log('No cached node found for:', node.id) + } + } else { + console.log('No workflow cache found') + } + + return nodeData +} + +// Watch for node selection changes +// Load user groups on component mount +loadUserGroups() + +// Watch for edge changes to validate condition connections +watch(() => props.currentEdges, () => { + if (props.selectedNode && props.selectedNode.data.nodeType === 'condition') { + // Validate condition connections when edges change + const error = validateConditionConnections() + if (error) { + validationErrors.value.condition_connections = error + } else { + delete validationErrors.value.condition_connections + } + } +}, { deep: true }) + +watch(() => props.selectedNode, (newNode) => { + if (newNode) { + console.log('Node changed, loading form data for:', newNode.id) + + // Get node data with cache values + const nodeData = getNodeDataForForm(newNode) + console.log('Node data for form:', nodeData) + + // Helper function to extract clean name from label by removing emoji icons + const extractCleanName = (label: string) => { + if (!label) return '' + // Remove common emoji icons and trim whitespace + return label + .replace(/[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu, '') // Unicode emojis + .replace(/[💬🤖🔀📝⚡👤⏱️🏁📄]/g, '') // Specific icons we use + .replace(/^\s+|\s+$/g, '') // Trim whitespace + .replace(/\s+/g, ' ') // Replace multiple spaces with single space + } + + nodeForm.value = { + name: nodeData.cleanName || + extractCleanName(nodeData.label) || + '', + description: nodeData.description || '', + // Message node - check cache config first, then fallback + message_text: nodeData.config?.message_text || + nodeData.message_text || + '', + show_typing: nodeData.config?.show_typing !== undefined ? + nodeData.config?.show_typing : + (nodeData.show_typing || false), + // LLM node + system_prompt: nodeData.config?.system_prompt || + nodeData.system_prompt || + '', + temperature: nodeData.config?.temperature !== undefined ? + nodeData.config?.temperature : + (nodeData.temperature || 0.7), + exit_condition: nodeData.config?.exit_condition || + nodeData.exit_condition || + ExitCondition.SINGLE_EXECUTION, + auto_transfer_enabled: nodeData.config?.auto_transfer_enabled !== undefined ? + nodeData.config?.auto_transfer_enabled : + (nodeData.auto_transfer_enabled || false), + transfer_group_id: nodeData.config?.transfer_group_id || + nodeData.transfer_group_id || + '', + ask_for_rating: nodeData.config?.ask_for_rating !== undefined ? + nodeData.config?.ask_for_rating : + (nodeData.ask_for_rating !== undefined ? nodeData.ask_for_rating : true), + // Condition node + condition_expression: nodeData.config?.condition_expression || + nodeData.condition_expression || + '', + condition_groups: nodeData.config?.condition_groups || + nodeData.condition_groups || + [], + // Form node - prioritize config over outer fields + form_fields: (() => { + const fields = nodeData.config?.form_fields || nodeData.form_fields || [] + // Convert array options back to newline-separated string for textarea display + return fields.map((field: any) => ({ + ...field, + options: Array.isArray(field.options) ? field.options.join('\n') : (field.options || '') + })) + })(), + form_title: nodeData.config?.form_title || + nodeData.form_title || + '', + form_description: nodeData.config?.form_description || + nodeData.form_description || + '', + submit_button_text: nodeData.config?.submit_button_text || + nodeData.submit_button_text || + '提交', + form_full_screen: nodeData.config?.form_full_screen !== undefined ? + nodeData.config?.form_full_screen : + (nodeData.form_full_screen || false), + // Action node + action_type: nodeData.config?.action_type || + nodeData.action_type || + '', + action_url: nodeData.config?.action_url || + nodeData.action_url || + '', + // Human transfer node + transfer_department: nodeData.config?.transfer_department || + nodeData.transfer_department || + '', + transfer_message: nodeData.config?.transfer_message || + nodeData.transfer_message || + '', + // Wait node + wait_duration: nodeData.config?.wait_duration !== undefined ? + nodeData.config?.wait_duration : + (nodeData.wait_duration || 5), + wait_unit: nodeData.config?.wait_unit || + nodeData.wait_unit || + 'seconds', + // End node + final_message: nodeData.config?.final_message || + nodeData.final_message || + '', + // Landing Page node + landing_page_heading: nodeData.config?.landing_page_heading || + nodeData.landing_page_heading || + '', + landing_page_content: nodeData.config?.landing_page_content || + nodeData.landing_page_content || + '', + // User Input node + prompt_message: nodeData.config?.prompt_message || + nodeData.prompt_message || + '', + confirmation_message: nodeData.config?.confirmation_message || + nodeData.confirmation_message || + '', + // Guardrails node + enabled_guardrails: nodeData.config?.enabled_guardrails || + nodeData.enabled_guardrails || + ['pii', 'jailbreak'], + pii_action: nodeData.config?.pii_action || + nodeData.pii_action || + 'block', + jailbreak_sensitivity: nodeData.config?.jailbreak_sensitivity !== undefined ? + nodeData.config?.jailbreak_sensitivity : + (nodeData.jailbreak_sensitivity !== undefined ? nodeData.jailbreak_sensitivity : 0.7), + text_source: nodeData.config?.text_source || + nodeData.text_source || + 'user_message', + block_message: nodeData.config?.block_message || + nodeData.block_message || + '' + } + + console.log('Form loaded with values:', nodeForm.value) + } +}, { immediate: true }) + +// Get node type display name +const getNodeTypeName = (type: string) => { + const names = { + landingPage: '落地页', + message: '消息', + llm: 'LLM', + condition: '条件', + form: '表单', + action: '动作', + humanTransfer: '转人工', + wait: '等待', + end: '结束', + userInput: '用户输入', + guardrails: '护栏' + } + return names[type as keyof typeof names] || type +} + + + + + +// Filter out blank/empty values from config +const filterBlankValues = (obj: any): any => { + const filtered: any = {} + + for (const [key, value] of Object.entries(obj)) { + // Skip null, undefined, empty strings, and empty arrays + if (value !== null && value !== undefined && value !== '' && + !(Array.isArray(value) && value.length === 0)) { + + // For objects, recursively filter + if (typeof value === 'object' && !Array.isArray(value)) { + const filteredObj = filterBlankValues(value) + // Only include if the filtered object has properties + if (Object.keys(filteredObj).length > 0) { + filtered[key] = filteredObj + } + } else { + filtered[key] = value + } + } + } + + return filtered +} + +// Get node-specific config based on node type +const getNodeSpecificConfig = (nodeType: string) => { + const config: any = {} + + switch (nodeType) { + case 'message': + if (nodeForm.value.message_text) config.message_text = nodeForm.value.message_text + if (nodeForm.value.show_typing !== undefined) config.show_typing = nodeForm.value.show_typing + break + + case 'llm': + if (nodeForm.value.system_prompt) config.system_prompt = nodeForm.value.system_prompt + if (nodeForm.value.temperature !== undefined) config.temperature = nodeForm.value.temperature + if (nodeForm.value.exit_condition) config.exit_condition = nodeForm.value.exit_condition + if (nodeForm.value.auto_transfer_enabled !== undefined) config.auto_transfer_enabled = nodeForm.value.auto_transfer_enabled + if (nodeForm.value.transfer_group_id) config.transfer_group_id = nodeForm.value.transfer_group_id + if (nodeForm.value.ask_for_rating !== undefined) config.ask_for_rating = nodeForm.value.ask_for_rating + break + + case 'condition': + if (nodeForm.value.condition_expression) config.condition_expression = nodeForm.value.condition_expression + if (nodeForm.value.condition_groups) config.condition_groups = nodeForm.value.condition_groups + break + + case 'form': + if (nodeForm.value.form_fields && nodeForm.value.form_fields.length > 0) { + // Ensure all form field properties are saved, including required, placeholder, etc. + config.form_fields = nodeForm.value.form_fields.map(field => ({ + name: field.name || '', + label: field.label || '', + type: field.type || 'text', + required: field.required || false, + placeholder: field.placeholder || '', + // Convert newline-separated string to array for select/radio options + options: (field.type === 'select' || field.type === 'radio') && field.options + ? (typeof field.options === 'string' + ? field.options.split('\n').map(opt => opt.trim()).filter(opt => opt.length > 0) + : field.options) + : (field.options || ''), + minLength: field.minLength || 0, + maxLength: field.maxLength || 255 + })) + } + if (nodeForm.value.form_title) config.form_title = nodeForm.value.form_title + if (nodeForm.value.form_description) config.form_description = nodeForm.value.form_description + if (nodeForm.value.submit_button_text) config.submit_button_text = nodeForm.value.submit_button_text + if (nodeForm.value.form_full_screen !== undefined) config.form_full_screen = nodeForm.value.form_full_screen + break + + case 'action': + if (nodeForm.value.action_type) config.action_type = nodeForm.value.action_type + if (nodeForm.value.action_url) config.action_url = nodeForm.value.action_url + break + + case 'humanTransfer': + if (nodeForm.value.transfer_department) config.transfer_department = nodeForm.value.transfer_department + if (nodeForm.value.transfer_message) config.transfer_message = nodeForm.value.transfer_message + break + + case 'wait': + if (nodeForm.value.wait_duration) config.wait_duration = nodeForm.value.wait_duration + if (nodeForm.value.wait_unit) config.wait_unit = nodeForm.value.wait_unit + break + + case 'end': + if (nodeForm.value.final_message) config.final_message = nodeForm.value.final_message + break + + case 'landingPage': + if (nodeForm.value.landing_page_heading) config.landing_page_heading = nodeForm.value.landing_page_heading + if (nodeForm.value.landing_page_content) config.landing_page_content = nodeForm.value.landing_page_content + break + + case 'userInput': + if (nodeForm.value.prompt_message) config.prompt_message = nodeForm.value.prompt_message + if (nodeForm.value.confirmation_message) config.confirmation_message = nodeForm.value.confirmation_message + break + + case 'guardrails': + if (nodeForm.value.enabled_guardrails) config.enabled_guardrails = nodeForm.value.enabled_guardrails + if (nodeForm.value.pii_action) config.pii_action = nodeForm.value.pii_action + if (nodeForm.value.jailbreak_sensitivity !== undefined) config.jailbreak_sensitivity = nodeForm.value.jailbreak_sensitivity + if (nodeForm.value.text_source) config.text_source = nodeForm.value.text_source + if (nodeForm.value.block_message) config.block_message = nodeForm.value.block_message + break + } + + // Apply additional filtering to remove any blank values + return filterBlankValues(config) +} + +// Auto-save node data to cache +const autoSaveToCache = debounce(async () => { + console.log('autoSaveToCache called') + // For form fields, we want to save even if there are validation errors on other fields + // as long as the form field structure is valid + const nodeType = props.selectedNode.data.nodeType || 'message' + + + // If it's a form node, validate form fields separately + if (nodeType === 'form') { + + // Check if form fields have basic structure (name and label) + const hasValidFormFields = nodeForm.value.form_fields.every(field => + field.name && field.name.trim() !== '' && + field.label && field.label.trim() !== '' + ) + + if (!hasValidFormFields) { + console.log('Form fields missing required name/label, skipping auto-save') + return + } + + } else { + // For other node types, use full validation + if (!validateForm()) { + console.log('Form validation failed, skipping auto-save') + return + } + } + + // Update node in cache with current form data + const config = getNodeSpecificConfig(nodeType) + + + const nodeData = { + id: props.selectedNode.id, + node_type: mapNodeTypeToBackend(nodeType), + name: nodeForm.value.name, + description: nodeForm.value.description, + position_x: props.selectedNode.position.x, + position_y: props.selectedNode.position.y, + config: config + } + + + workflowNodeService.updateNodeInCache(props.workflowId, nodeData) + + // Emit the updated data to parent + emit('save', { + ...nodeForm.value, + needsWorkflowSave: false, // Don't trigger full workflow save + cacheUpdated: true + }) +}, 500) // Auto-save after 500ms of inactivity + +// Map frontend node types to backend enum values +const mapNodeTypeToBackend = (frontendType: string) => { + const mapping = { + 'landingPage': 'landing_page', + 'message': 'message', + 'llm': 'llm', + 'condition': 'condition', + 'form': 'form', + 'action': 'action', + 'humanTransfer': 'human_transfer', + 'wait': 'wait', + 'end': 'end', + 'userInput': 'user_input', + 'guardrails': 'guardrails' + } + return mapping[frontendType as keyof typeof mapping] || 'message' +} + + + + + +// Handle close +const handleClose = () => { + emit('close') +} + +// Handle delete +const handleDelete = () => { + if (confirm('确定要删除此节点吗?')) { + emit('delete') + } +} + +// Add global event listeners when component mounts (removed for now) +// onMounted(() => { +// document.addEventListener('click', handleGlobalModalClick, true) +// document.addEventListener('keydown', handleGlobalModalClick, true) +// }) + +// Clean up event listeners when component unmounts (removed for now) +// onUnmounted(() => { +// document.removeEventListener('click', handleGlobalModalClick, true) +// document.removeEventListener('keydown', handleGlobalModalClick, true) +// }) +</script> + +<template> + <div class="properties-panel"> + <div class="properties-header"> + <div class="header-left"> + <h3>节点属性</h3> + <span class="node-type-badge" :style="{ backgroundColor: selectedNode.data.color }"> + {{ selectedNode.data.icon }} {{ getNodeTypeName(selectedNode.data.nodeType) }} + </span> + </div> + <div class="header-actions"> + <button class="header-icon-btn delete-btn" @click="handleDelete" title="删除节点"> + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <polyline points="3,6 5,6 21,6"></polyline> + <path d="M19,6v14a2,2 0 0,1 -2,2H7a2,2 0 0,1 -2,-2V6m3,0V4a2,2 0 0,1 2,-2h4a2,2 0 0,1 2,2v2"></path> + <line x1="10" y1="11" x2="10" y2="17"></line> + <line x1="14" y1="11" x2="14" y2="17"></line> + </svg> + </button> + <button class="header-icon-btn close-btn" @click="handleClose" title="关闭"> + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <line x1="18" y1="6" x2="6" y2="18"></line> + <line x1="6" y1="6" x2="18" y2="18"></line> + </svg> + </button> + </div> + </div> + + <div class="properties-content"> + <form @submit.prevent> + <!-- Basic Properties --> + <div class="collapsible-section"> + <div class="section-header" @click="toggleSection('basic')"> + <div class="section-title"> + <svg class="section-icon" :class="{ 'rotated': collapsedSections.basic }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <polyline points="6,9 12,15 18,9"></polyline> + </svg> + <span>基本信息</span> + </div> + </div> + + <div class="section-content" :class="{ 'collapsed': collapsedSections.basic }"> + <div class="form-group"> + <label for="node-name">节点名称 *</label> + <input + id="node-name" + v-model="nodeForm.name" + type="text" + class="form-input" + :class="{ 'error': validationErrors.name }" + placeholder="输入节点名称" + required + @blur="validateFieldOnChange('name')" + @input="validateFieldOnChange('name')" + /> + <div v-if="validationErrors.name" class="error-message"> + {{ validationErrors.name }} + </div> + </div> + + <div class="form-group"> + <label for="node-description">描述</label> + <textarea + id="node-description" + v-model="nodeForm.description" + class="form-textarea" + placeholder="输入节点描述(可选)" + rows="3" + @blur="autoSaveToCache" + @input="autoSaveToCache" + ></textarea> + </div> + </div> + </div> + + <!-- Node-specific Properties --> + <div class="collapsible-section"> + <div class="section-header" @click="toggleSection('nodeSettings')"> + <div class="section-title"> + <svg class="section-icon" :class="{ 'rotated': collapsedSections.nodeSettings }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <polyline points="6,9 12,15 18,9"></polyline> + </svg> + <span>{{ getNodeTypeName(selectedNode.data.nodeType) }}设置</span> + </div> + </div> + + <div class="section-content" :class="{ 'collapsed': collapsedSections.nodeSettings }"> + + <!-- Message Node --> + <template v-if="selectedNode.data.nodeType === 'message'"> + <MessageNodeConfig + :model-value="{ + message_text: nodeForm.message_text, + show_typing: nodeForm.show_typing + }" + @update:model-value="updateMessageFormData" + :validation-errors="validationErrors" + @validate-field="validateFieldOnChange" + /> + </template> + + <!-- LLM Node --> + <template v-if="selectedNode.data.nodeType === 'llm'"> + <LLMNodeConfig + :model-value="{ + system_prompt: nodeForm.system_prompt, + temperature: nodeForm.temperature, + exit_condition: nodeForm.exit_condition, + auto_transfer_enabled: nodeForm.auto_transfer_enabled, + transfer_group_id: nodeForm.transfer_group_id, + ask_for_rating: nodeForm.ask_for_rating + }" + @update:model-value="updateLLMFormData" + :validation-errors="validationErrors" + :user-groups="userGroups" + :loading-groups="loadingGroups" + :agent-id="agentId" + :organization-id="organizationId" + :collapsed-sections="collapsedSections" + @validate-field="validateFieldOnChange" + @toggle-section="toggleSection" + /> + </template> + + <!-- Condition Node --> + <template v-if="selectedNode.data.nodeType === 'condition'"> + <!-- Connection Status --> + <div class="form-group"> + <label>连接状态</label> + <div class="connection-status"> + <div class="connection-info" :class="{ 'error': validationErrors.condition_connections }"> + <div class="connection-item"> + <span class="connection-label true">“是”路径:</span> + <span class="connection-value"> + {{ (() => { + const edgesVal = Array.isArray(currentEdges) ? currentEdges : (currentEdges as any).value + const trueConnection = edgesVal.find((edge: any) => edge.source === selectedNode.id && edge.label === 'true') + return trueConnection ? '✓ 已连接' : '✗ 未连接' + })() }} + </span> + </div> + <div class="connection-item"> + <span class="connection-label false">“否”路径:</span> + <span class="connection-value"> + {{ (() => { + const edgesVal = Array.isArray(currentEdges) ? currentEdges : (currentEdges as any).value + const falseConnection = edgesVal.find((edge: any) => edge.source === selectedNode.id && edge.label === 'false') + return falseConnection ? '✓ 已连接' : '✗ 未连接' + })() }} + </span> + </div> + </div> + <div v-if="validationErrors.condition_connections" class="error-message"> + {{ validationErrors.condition_connections }} + </div> + <div class="help-text"> + 从此节点拖拽连接到其他节点。第一条连接标记为“是”,第二条标记为“否”。 + </div> + </div> + </div> + + <ConditionNodeConfig + :key="selectedNode.id" + :model-value="{ + condition_expression: nodeForm.condition_expression, + condition_groups: nodeForm.condition_groups + }" + @update:model-value="updateConditionFormData" + :validation-errors="validationErrors" + :available-variables="availableVariables" + @validate-field="validateFieldOnChange" + /> + </template> + + <!-- Form Node --> + <template v-if="selectedNode.data.nodeType === 'form'"> + <FormNodeConfig + :model-value="{ + form_title: nodeForm.form_title, + form_description: nodeForm.form_description, + submit_button_text: nodeForm.submit_button_text, + form_full_screen: nodeForm.form_full_screen, + form_fields: nodeForm.form_fields + }" + @update:model-value="updateFormFormData" + :validation-errors="validationErrors" + @validate-field="validateFieldOnChange" + /> + </template> + + <!-- Action Node --> + <template v-if="selectedNode.data.nodeType === 'action'"> + <ActionNodeConfig + :model-value="{ + action_type: nodeForm.action_type, + action_url: nodeForm.action_url + }" + @update:model-value="updateActionFormData" + :validation-errors="validationErrors" + @validate-field="validateFieldOnChange" + /> + </template> + + <!-- Human Transfer Node --> + <template v-if="selectedNode.data.nodeType === 'humanTransfer'"> + <HumanTransferNodeConfig + :model-value="{ + transfer_department: nodeForm.transfer_department, + transfer_message: nodeForm.transfer_message + }" + @update:model-value="updateHumanTransferFormData" + :validation-errors="validationErrors" + @validate-field="validateFieldOnChange" + /> + </template> + + <!-- Wait Node --> + <template v-if="selectedNode.data.nodeType === 'wait'"> + <WaitNodeConfig + :model-value="{ + wait_duration: nodeForm.wait_duration, + wait_unit: nodeForm.wait_unit + }" + @update:model-value="updateWaitFormData" + :validation-errors="validationErrors" + @validate-field="validateFieldOnChange" + /> + </template> + + <!-- End Node --> + <template v-if="selectedNode.data.nodeType === 'end'"> + <EndNodeConfig + :model-value="{ + final_message: nodeForm.final_message + }" + @update:model-value="updateEndFormData" + :validation-errors="validationErrors" + @validate-field="validateFieldOnChange" + /> + </template> + + <!-- Landing Page Node --> + <template v-if="selectedNode.data.nodeType === 'landingPage'"> + <LandingPageNodeConfig + :model-value="{ + landing_page_heading: nodeForm.landing_page_heading, + landing_page_content: nodeForm.landing_page_content + }" + @update:model-value="updateLandingPageFormData" + :validation-errors="validationErrors" + @validate-field="validateFieldOnChange" + /> + </template> + + <!-- User Input Node --> + <template v-if="selectedNode.data.nodeType === 'userInput'"> + <UserInputNodeConfig + :model-value="{ + prompt_message: nodeForm.prompt_message, + confirmation_message: nodeForm.confirmation_message + }" + @update:model-value="updateUserInputFormData" + :validation-errors="validationErrors" + @validate-field="validateFieldOnChange" + /> + </template> + + <!-- Guardrails Node --> + <template v-if="selectedNode.data.nodeType === 'guardrails'"> + <GuardrailsNodeConfig + :model-value="{ + enabled_guardrails: nodeForm.enabled_guardrails, + pii_action: nodeForm.pii_action, + jailbreak_sensitivity: nodeForm.jailbreak_sensitivity, + text_source: nodeForm.text_source, + block_message: nodeForm.block_message + }" + @update:model-value="updateGuardrailsFormData" + :validation-errors="validationErrors" + :available-variables="availableVariables" + @validate-field="validateFieldOnChange" + /> + </template> + </div> + + <!-- Form Variables Section --> + <div v-if="shouldShowVariables" class="collapsible-section"> + <div class="section-header" @click="toggleSection('variables')"> + <div class="section-title"> + <svg class="section-icon" :class="{ 'rotated': collapsedSections.variables }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <polyline points="6,9 12,15 18,9"></polyline> + </svg> + <span>可用变量</span> + <span v-if="availableVariables.length > 0" class="variables-count">{{ availableVariables.length }}</span> + </div> + </div> + + <div class="section-content" :class="{ 'collapsed': collapsedSections.variables }"> + <div v-if="availableVariables.length === 0" class="no-variables"> + <p>暂无可用的变量</p> + <small>添加表单或用户输入节点以创建变量</small> + </div> + + <div v-else class="variables-list"> + <div + v-for="variable in availableVariables" + :key="`${variable.nodeId}-${variable.fieldName}`" + class="variable-item" + @click="copyVariableToClipboard(variable)" + :title="`点击复制 {{${variable.fieldName}}} 到剪贴板`" + > + <div class="variable-info"> + <div class="variable-name">{{ variable.fieldName }}</div> + <div class="variable-source">来自 {{ variable.nodeName }}</div> + <div class="variable-type">{{ variable.fieldType }}</div> + </div> + <div class="variable-syntax"> + <code>{{ getVariableSyntax(variable.fieldName) }}</code> + </div> + </div> + </div> + + <div v-if="availableVariables.length > 0" class="variables-help"> + <small> + <strong>用法:</strong><br> + • <code>{{ getVariableSyntax('field_name') }}</code> - 最新值<br> + • <code>{{ getVariableSyntax('node_id.field_name') }}</code> - 指定节点 + </small> + </div> + </div> + </div> + </div> + </form> + </div> + + <div class="properties-footer"> + <div class="footer-info"> + <span class="auto-save-indicator"> + <svg class="auto-save-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path> + <polyline points="17,21 17,13 7,13 7,21"></polyline> + <polyline points="7,3 7,8 15,8"></polyline> + </svg> + 自动保存更改 + </span> + </div> + </div> + </div> +</template> + +<style scoped> +/* Ensure toast positioning is centered and doesn't cover buttons */ +.properties-panel :deep([data-sonner-toaster]) { + top: 80px !important; +} + +.properties-panel :deep([data-sonner-toaster][data-theme]) { + top: 80px !important; +} + +.properties-panel :deep(.sonner-toaster) { + top: 80px !important; +} +/* Properties Panel Styles */ +.properties-panel { + width: 350px; + background: var(--background-soft); + border-left: 1px solid var(--border-color); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.properties-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-md) var(--space-lg); + border-bottom: 1px solid var(--border-color); + background: var(--background-color); +} + +.properties-header .header-left { + flex: 1; +} + +.properties-header h3 { + font-size: 1.05rem; + font-weight: 600; + margin: 0 0 4px 0; + color: var(--text-color); +} + +.node-type-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: var(--radius-full); + font-size: 0.7rem; + font-weight: 500; + color: white; +} + +.header-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.header-icon-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: transparent; + border: none; + border-radius: var(--radius-md); + color: var(--text-muted); + cursor: pointer; + transition: all 0.2s ease; +} + +.header-icon-btn:hover { + background: var(--background-muted); + color: var(--text-color); +} + +.header-icon-btn.delete-btn:hover { + background: var(--error-color); + color: white; +} + +.header-icon-btn svg { + width: 16px; + height: 16px; +} + +.properties-content { + flex: 1; + overflow-y: auto; + padding: var(--space-md); +} + +/* Collapsible Section Styles */ +.collapsible-section { + margin-bottom: var(--space-md); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--background-color); + overflow: hidden; +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-sm) var(--space-md); + background: var(--background-muted); + cursor: pointer; + transition: all 0.2s ease; + border-bottom: 1px solid var(--border-color); +} + +.section-header:hover { + background: var(--background-alt); +} + +.section-title { + display: flex; + align-items: center; + gap: var(--space-xs); + font-size: 0.9rem; + font-weight: 600; + color: var(--text-color); +} + +.section-icon { + width: 16px; + height: 16px; + color: var(--text-muted); + transition: transform 0.2s ease; +} + +.section-icon.rotated { + transform: rotate(-90deg); +} + +.section-content { + padding: var(--space-md); + transition: all 0.3s ease; + max-height: none; + overflow: visible; +} + +.section-content.collapsed { + max-height: 0; + padding-top: 0; + padding-bottom: 0; + opacity: 0; + overflow: hidden; +} + +.form-section { + margin-bottom: var(--space-lg); +} + +.form-section h4 { + margin: 0 0 var(--space-sm) 0; + font-size: 0.95rem; + font-weight: 600; + color: var(--text-color); + border-bottom: 1px solid var(--border-color); + padding-bottom: var(--space-xs); +} + +.form-group { + margin-bottom: var(--space-sm); +} + +.form-group label { + display: block; + font-size: 0.8rem; + font-weight: 500; + color: var(--text-color); + margin-bottom: 4px; +} + +.form-input, +.form-textarea, +.form-select { + width: 100%; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--background-color); + color: var(--text-color); + font-size: 0.85rem; + transition: border-color 0.2s ease; +} + +.form-input:focus, +.form-textarea:focus, +.form-select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15); +} + +.form-input.error, +.form-textarea.error, +.form-select.error { + border-color: var(--error-color); + background-color: rgba(239, 68, 68, 0.05); +} + +.form-input.error:focus, +.form-textarea.error:focus, +.form-select.error:focus { + border-color: var(--error-color); + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1); +} + +.error-message { + color: var(--error-color); + font-size: 0.75rem; + margin-top: 4px; + display: flex; + align-items: center; + gap: 4px; +} + +.error-message::before { + content: "⚠"; + font-size: 0.8rem; +} + +.form-textarea { + resize: vertical; + min-height: 70px; +} + +.checkbox-group { + margin-top: 8px; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-weight: normal; + margin-bottom: 0; + padding: 4px 0; +} + +.form-checkbox { + width: 16px; + height: 16px; + margin: 0; + cursor: pointer; + flex-shrink: 0; +} + +.checkbox-label span { + font-size: 0.85rem; + color: var(--text-color); + line-height: 1.4; + user-select: none; +} + +.properties-footer { + display: flex; + justify-content: center; + align-items: center; + padding: var(--space-sm) var(--space-md); + border-top: 1px solid var(--border-color); + background: var(--background-soft); + flex-shrink: 0; +} + +.footer-info { + display: flex; + align-items: center; + justify-content: center; + width: 100%; +} + +.auto-save-indicator { + display: flex; + align-items: center; + gap: var(--space-xs); + color: var(--text-muted); + font-size: 0.75rem; + font-weight: 500; +} + +.auto-save-icon { + width: 14px; + height: 14px; + color: var(--success-color); +} + +.btn { + padding: 8px 16px; + border: none; + border-radius: var(--radius-md); + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.85rem; + min-width: 80px; + white-space: nowrap; +} + +.btn-primary { + background: var(--accent-solid); + color: var(--on-accent-solid); +} + +.btn-primary:hover { + background: var(--primary-dark); +} + +.btn-secondary { + background: var(--background-muted); + color: var(--text-muted); + border: 1px solid var(--border-color); +} + +.btn-secondary:hover { + background: var(--background-alt); + color: var(--text-color); +} + +.btn-danger { + background: var(--error-color); + color: white; +} + +.btn-danger:hover { + background: #DC2626; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-spinner { + width: 16px; + height: 16px; + border: 2px solid transparent; + border-top: 2px solid currentColor; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + + + + + +/* Knowledge Section Styles */ +.knowledge-section { + margin-top: var(--space-md); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--background-color); + overflow: hidden; +} + +.knowledge-header .section-header { + background: var(--background-muted); + border-bottom: 1px solid var(--border-color); +} + +.knowledge-header .section-title { + color: var(--text-color); +} + +.knowledge-content { + padding: 0; /* Remove default padding since KnowledgeGrid has its own */ + max-height: none; + overflow: visible; + transition: all 0.3s ease; +} + +.knowledge-content.collapsed { + max-height: 0; + padding-top: 0; + padding-bottom: 0; + opacity: 0; + overflow: hidden; +} + +.knowledge-wrapper { + position: relative; + width: 100%; + height: 100%; +} + +/* Override KnowledgeGrid styles within PropertiesPanel */ +.knowledge-content :deep(.knowledge-grid-container) { + padding: var(--space-sm); + background: transparent; + border-top: none; +} + +.knowledge-content :deep(.knowledge-header) { + margin-bottom: var(--space-sm); + flex-direction: column; + gap: var(--space-xs); + align-items: stretch; +} + +.knowledge-content :deep(.header-left) { + flex-direction: column; + gap: var(--space-xs); + align-items: stretch; +} + +.knowledge-content :deep(.header-left h3) { + font-size: 1rem; + margin-bottom: 0; +} + +.knowledge-content :deep(.header-actions) { + display: flex; + gap: var(--space-xs); + justify-content: center; +} + +.knowledge-content :deep(.action-button) { + font-size: 0.7rem; + padding: 4px 8px; + flex: 1; + text-align: center; + white-space: nowrap; +} + +.knowledge-content :deep(.knowledge-grid) { + font-size: 0.8rem; +} + +.knowledge-content :deep(.knowledge-grid-header), +.knowledge-content :deep(.knowledge-grid-row) { + grid-template-columns: 1fr 60px; + min-height: auto; +} + +.knowledge-content :deep(.header-cell), +.knowledge-content :deep(.grid-cell) { + padding: var(--space-xs) var(--space-sm); + font-size: 0.75rem; +} + +.knowledge-content :deep(.header-cell:nth-child(2)), +.knowledge-content :deep(.header-cell:nth-child(3)), +.knowledge-content :deep(.header-cell:nth-child(4)), +.knowledge-content :deep(.grid-cell:nth-child(2)), +.knowledge-content :deep(.grid-cell:nth-child(3)), +.knowledge-content :deep(.grid-cell:nth-child(4)) { + display: none; +} + +.knowledge-content :deep(.actions-cell) { + width: 60px; + text-align: center; +} + +.knowledge-content :deep(.delete-button) { + padding: 2px; +} + +.knowledge-content :deep(.delete-icon) { + width: 16px; + height: 16px; +} + +.knowledge-content :deep(.knowledge-empty) { + padding: var(--space-md); + text-align: center; +} + +.knowledge-content :deep(.warning-message) { + font-size: 0.8rem; + margin-bottom: var(--space-xs); +} + +.knowledge-content :deep(.warning-description) { + font-size: 0.75rem; + line-height: 1.4; +} + +.knowledge-content :deep(.modal-content) { + max-width: 30vw; + max-height: 75vh; + width: 65%; +} + +/* Ensure knowledge modals have proper z-index and don't interfere */ +.knowledge-content :deep(.modal-overlay) { + z-index: 1001 !important; /* Higher than properties panel */ + position: fixed !important; + isolation: isolate; + background: rgba(0, 0, 0, 0.4) !important; /* Slightly more transparent for workflow context */ +} + +/* Prevent modal interactions from bubbling to properties panel */ +.knowledge-content :deep(.modal-overlay), +.knowledge-content :deep(.modal-content) { + pointer-events: auto; +} + +.knowledge-content :deep(.modal-overlay) { + backdrop-filter: blur(4px); +} + +.knowledge-content :deep(.pagination) { + flex-direction: column; + gap: var(--space-xs); + margin-top: var(--space-sm); +} + +.knowledge-content :deep(.pagination-button) { + width: 100%; + font-size: 0.7rem; + padding: var(--space-xs); +} + +.knowledge-content :deep(.page-info) { + font-size: 0.7rem; + text-align: center; +} + +/* Link modal overrides for PropertiesPanel */ +.knowledge-content :deep(.link-modal) { + max-width: 65vw; + width: 60%; +} + +.knowledge-content :deep(.org-knowledge-grid .knowledge-grid-header), +.knowledge-content :deep(.org-knowledge-grid .knowledge-grid-row) { + grid-template-columns: 2fr 0fr 100px; /* Keep 3 columns but make type column 0 width */ + align-items: center; +} + +.knowledge-content :deep(.org-knowledge-grid .type-cell) { + display: none; /* Hide type column */ +} + +.knowledge-content :deep(.source-cell) { + padding-left: var(--space-sm); + white-space: normal; + word-break: break-word; + font-size: 0.75rem; +} + +.knowledge-content :deep(.action-cell) { + padding-right: var(--space-sm); + text-align: right; + width: 100px; +} + +.knowledge-content :deep(.link-button), +.knowledge-content :deep(.unlink-button) { + min-width: 70px; + padding: 4px 8px; + font-size: 0.7rem; + display: inline-block; +} + +/* Force proper button display regardless of responsive breakpoints */ +.knowledge-content :deep(.org-knowledge-grid) .action-cell { + display: block !important; + visibility: visible !important; +} + +.knowledge-content :deep(.org-knowledge-grid) .link-button, +.knowledge-content :deep(.org-knowledge-grid) .unlink-button { + display: inline-block !important; + visibility: visible !important; + opacity: 1 !important; +} + +/* Additional form styles */ +.checkbox-group { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 8px 0; +} + +.checkbox-group input[type="checkbox"] { + margin-top: 2px; + flex-shrink: 0; +} + +.checkbox-label { + font-size: 0.85rem; + color: var(--text-color); + line-height: 1.4; +} + +.help-text { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 4px; + line-height: 1.3; +} + +/* Exit condition help text styling */ +.help-text template { + display: block; + padding: 6px 8px; + background: var(--background-soft); + border-radius: var(--radius-sm); + border-left: 3px solid var(--primary-color); + margin-top: 4px; +} + + + +/* Override any responsive hiding that might affect buttons */ +@media (max-width: 768px) { + .knowledge-content :deep(.knowledge-grid-container) { + padding: 4px; + } + + .knowledge-content :deep(.header-actions) { + flex-direction: column; + gap: 4px; + } + + .knowledge-content :deep(.action-button) { + font-size: 0.65rem; + padding: 3px 6px; + } + + .knowledge-content :deep(.org-knowledge-grid .knowledge-grid-header), + .knowledge-content :deep(.org-knowledge-grid .knowledge-grid-row) { + grid-template-columns: 2fr 0fr 100px !important; /* Force 3-column layout */ + } + + .knowledge-content :deep(.org-knowledge-grid) .action-cell { + display: block !important; + grid-column: 3 !important; /* Ensure it's in the third column */ + } +} + +/* Responsive adjustments */ +@media (max-width: 1024px) { + .properties-panel { + width: 320px; + } +} + +@media (max-width: 768px) { + .properties-panel { + width: 100%; + position: fixed; + top: 0; + right: 0; + bottom: 0; + z-index: 1002; + box-shadow: var(--shadow-lg); + } + + .properties-footer { + padding: var(--space-md); + } + + .footer-actions { + width: 100%; + justify-content: space-between; + } + + .btn { + flex: 1; + margin: 0 4px; + } +} + +/* Variables Section Styles */ +.variables-count { + background: var(--accent-solid); + color: var(--on-accent-solid); + border-radius: var(--radius-full); + padding: 2px 8px; + font-size: 0.7rem; + font-weight: 500; + margin-left: var(--space-xs); +} + +.no-variables { + text-align: center; + padding: var(--space-lg); + color: var(--text-muted); +} + +.no-variables p { + margin: 0 0 var(--space-xs) 0; + font-size: var(--text-sm); +} + +.no-variables small { + font-size: 0.75rem; + opacity: 0.7; +} + +.variables-list { + display: flex; + flex-direction: column; + gap: var(--space-xs); + margin-bottom: var(--space-md); +} + +.variable-item { + padding: var(--space-sm); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s ease; + background: var(--background-color); +} + +.variable-item:hover { + background: var(--background-alt); + border-color: var(--primary-color); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +.variable-info { + margin-bottom: var(--space-xs); +} + +.variable-name { + font-weight: 600; + font-size: var(--text-sm); + color: var(--text-color); +} + +.variable-source { + font-size: 0.75rem; + color: var(--text-muted); + margin: 2px 0; +} + +.variable-type { + font-size: 0.75rem; + color: var(--primary-color); + background: var(--primary-soft); + padding: 1px 6px; + border-radius: var(--radius-xs); + display: inline-block; +} + +.variable-syntax { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; +} + +.variable-syntax code { + background: var(--background-soft); + padding: 2px 6px; + border-radius: var(--radius-xs); + font-size: 0.75rem; + color: var(--text-color); + border: 1px solid var(--border-color); +} + +.variables-help { + padding: var(--space-sm); + background: var(--background-soft); + border-radius: var(--radius-md); + border: 1px solid var(--border-color); + margin-top: var(--space-sm); +} + +.variables-help small { + color: var(--text-muted); + line-height: 1.4; +} + +.variables-help code { + background: var(--background-color); + padding: 1px 4px; + border-radius: var(--radius-xs); + font-size: 0.7rem; + color: var(--primary-color); + border: 1px solid var(--border-color); +} + +/* Connection Status Styles */ +.connection-status { + margin-top: var(--space-xs); +} + +.connection-info { + padding: var(--space-sm); + background: var(--background-soft); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + margin-bottom: var(--space-xs); +} + +.connection-info.error { + border-color: var(--error-color); + background-color: rgba(239, 68, 68, 0.05); +} + +.connection-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-xs) 0; +} + +.connection-item:not(:last-child) { + border-bottom: 1px solid var(--border-color); + margin-bottom: var(--space-xs); + padding-bottom: var(--space-xs); +} + +.connection-label { + font-weight: 600; + font-size: 0.85rem; +} + +.connection-label.true { + color: var(--success-color, #10B981); +} + +.connection-label.false { + color: var(--error-color, #EF4444); +} + +.connection-value { + font-size: 0.8rem; + color: var(--text-muted); +} + +.connection-value:contains('✓') { + color: var(--success-color, #10B981); +} + +.connection-value:contains('✗') { + color: var(--error-color, #EF4444); +} + +</style> \ No newline at end of file diff --git a/frontend/src/components/workflow/WorkflowBuilder.vue b/frontend/src/components/workflow/WorkflowBuilder.vue new file mode 100644 index 0000000..520175a --- /dev/null +++ b/frontend/src/components/workflow/WorkflowBuilder.vue @@ -0,0 +1,1276 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { ref, onMounted, onUnmounted, computed, watch } from 'vue' +import { VueFlow, useVueFlow, type Node, type Edge, type NodeMouseEvent } from '@vue-flow/core' +import { Background } from '@vue-flow/background' +import { Controls } from '@vue-flow/controls' +import { MiniMap } from '@vue-flow/minimap' +import { toast } from 'vue-sonner' +import { workflowNodeService } from '../../services/workflowNode' +import { workflowService } from '../../services/workflow' +import type { WorkflowResponse } from '@/types/workflow' +import { WorkflowStatus, ExitCondition } from '@/types/workflow' +import PropertiesPanel from './PropertiesPanel.vue' +import { workflowCacheStorage } from '@/utils/storage' +import { useWorkflowValidation, type NodeTypeInfo } from '@/composables/useWorkflowValidation' +import { useWorkflowManagement } from '@/composables/useWorkflowManagement' + +// Import Vue Flow styles +import '@vue-flow/core/dist/style.css' +import '@vue-flow/core/dist/theme-default.css' + +const props = defineProps<{ + workflow: WorkflowResponse +}>() + +const emit = defineEmits<{ + (e: 'close'): void +}>() + +// Vue Flow setup +const { onConnect, addNodes, removeNodes, addEdges, getNodes, getEdges, vueFlowRef } = useVueFlow({ + id: 'workflow-builder', + nodes: [], + edges: [], + snapToGrid: true, + snapGrid: [20, 20], + defaultEdgeOptions: { + style: { strokeWidth: 2 } + } +}) + +// Node types configuration - using default Vue Flow nodes for now +const nodeTypes = {} + +// Initialize workflow validation composable +const { + validateAllNodes, + highlightNodesWithErrors, + resetNodeValidationStyle, + resetAllNodesValidationStyle, + getNodeDataWithCache +} = useWorkflowValidation() + +// Helper function to check if a node is an LLM with continuous execution +const isLLMNodeWithContinuousExecution = (node: Node): boolean => { + if (node.data.nodeType !== 'llm') return false + + // First check the cache for the most up-to-date configuration + const exitCondition = getLLMNodeExitCondition(node.id) + return exitCondition === ExitCondition.CONTINUOUS_EXECUTION +} + +// Helper function to get LLM node exit condition from cache or node data +const getLLMNodeExitCondition = (nodeId: string): string => { + const workflowCache = workflowCacheStorage.getWorkflowCache(props.workflow.id) + + if (workflowCache && workflowCache.nodes) { + const cachedNode = workflowCache.nodes.find((n: any) => n.id === nodeId) + if (cachedNode && cachedNode.config?.exit_condition) { + return cachedNode.config.exit_condition + } + } + + // Fallback to node data + const nodes = getNodes.value + const node = nodes.find(n => n.id === nodeId) + return node?.data.config?.exit_condition || node?.data.exit_condition || ExitCondition.SINGLE_EXECUTION +} + + + +// Available node types for sidebar +const availableNodeTypes: NodeTypeInfo[] = [ + { + type: 'landingPage', + label: '落地页', + icon: '🏠', + description: '显示可自定义标题和内容的欢迎页面', + color: '#6366F1' + }, + { + type: 'message', + label: '消息', + icon: '💬', + description: '向用户发送预设消息', + color: '#3B82F6' + }, + { + type: 'llm', + label: 'LLM', + icon: '🤖', + description: '使用可配置提示词的 AI 模型处理', + color: '#8B5CF6' + }, + { + type: 'condition', + label: '条件', + icon: '🔀', + description: '基于变量条件分流对话', + color: '#F59E0B' + }, + { + type: 'form', + label: '表单', + icon: '📝', + description: '收集用户填写的结构化数据', + color: '#10B981' + }, + { + type: 'userInput', + label: '用户输入', + icon: '✏️', + description: '通过自定义提示等待用户输入', + color: '#06B6D4' + }, + { + type: 'guardrails', + label: '护栏', + icon: '🛡️', + description: '检查内容中的 PII、越狱尝试及其他违规内容', + color: '#EC4899' + }, + // { + // type: 'action', + // label: 'Action', + // icon: '⚡', + // description: 'Trigger external actions or API calls', + // color: '#EF4444' + // }, + { + type: 'humanTransfer', + label: '转人工', + icon: '👤', + description: '由人工客服接管对话', + color: '#F97316' + }, + // { + // type: 'wait', + // label: 'Wait', + // icon: '⏱️', + // description: 'Pause execution for time-based triggers', + // color: '#6B7280' + // }, + { + type: 'end', + label: '结束', + icon: '🏁', + description: '终止对话流程', + color: '#DC2626' + } +] + +// State management +const loading = ref(false) +const draggedType = ref<string | null>(null) +const nodeIdCounter = ref(1) +const showPropertiesPanel = ref(false) +const selectedNode = ref<Node | null>(null) +const workflowStatus = ref<WorkflowStatus>(props.workflow.status) +const publishLoading = ref(false) + +// Variables state for PropertiesPanel +const availableVariables = ref<Array<{ + nodeId: string + nodeName: string + fieldName: string + fieldType: string + fieldLabel: string +}>>([]) + +// Watch for node changes to update available variables +watch(() => getNodes.value, (newNodes) => { + updateAvailableVariables(newNodes) +}, { deep: true }) + +// Initialize workflow management composable +const { + loadWorkflowData, + saveNodeProperties, + saveWorkflow, + publishWorkflow, + unpublishWorkflow, + handleNodesDelete, + handleNodesChange, + handleDeleteEvent, + handleEdgesChange, + mapNodeTypeToFrontend, + mapNodeTypeToBackend +} = useWorkflowManagement({ + workflowId: props.workflow.id, + selectedNode, + showPropertiesPanel, + loading, + publishLoading, + workflowStatus, + nodeIdCounter, + availableNodeTypes, + validateAllNodes, + highlightNodesWithErrors, + resetAllNodesValidationStyle, + getNodes: () => getNodes.value, + getEdges: () => getEdges.value, + addNodes, + addEdges, + removeNodes, + closePropertiesPanel: () => { + showPropertiesPanel.value = false + selectedNode.value = null + document.body.classList.remove('workflow-properties-panel-open') + } +}) + +// Helper function to generate UUID-like IDs +const generateNodeId = (type: string) => { + return `${type}-${nodeIdCounter.value++}` +} + +// Helper function to generate unique node names +const generateUniqueNodeName = (baseName: string) => { + const nodes = getNodes.value + const existingNames = new Set( + nodes.map(node => node.data.cleanName?.toLowerCase() || node.data.label?.toLowerCase()).filter(Boolean) + ) + + // Check if base name is already unique + if (!existingNames.has(baseName.toLowerCase())) { + return baseName + } + + // Generate unique name with suffix + let counter = 1 + while (true) { + const candidateName = `${baseName}_${counter.toString().padStart(3, '0')}` + if (!existingNames.has(candidateName.toLowerCase())) { + return candidateName + } + counter++ + + // Safety check to prevent infinite loop + if (counter > 999) { + return `${baseName}_${Date.now()}` + } + } +} + +// Handle node deletion +const deleteSelectedNode = () => { + if (selectedNode.value) { + const nodeId = selectedNode.value.id + console.log('Deleting selected node:', nodeId) + + // Remove from Vue Flow first + removeNodes([nodeId]) + + // Remove from cache + workflowNodeService.deleteNodeFromCache(props.workflow.id, nodeId) + + // Close properties panel and show success message + showPropertiesPanel.value = false + selectedNode.value = null + document.body.classList.remove('workflow-properties-panel-open') + + toast.success('节点已删除', { + position: 'top-center' + }) + } +} + +// Handle properties panel close +const closePropertiesPanel = () => { + showPropertiesPanel.value = false + selectedNode.value = null + + // Remove class from body to reset toast positioning + document.body.classList.remove('workflow-properties-panel-open') +} + +// Get node type display name + +// Computed properties +const hasNodes = computed(() => getNodes.value.length > 0) +const isPublished = computed(() => workflowStatus.value === WorkflowStatus.PUBLISHED) +const isDraft = computed(() => workflowStatus.value === WorkflowStatus.DRAFT) +const hasValidConnections = computed(() => { + const nodes = getNodes.value + const edges = getEdges.value + + // Single node is always valid + if (nodes.length <= 1) return true + + // Multiple nodes must have connections + if (edges.length === 0) return false + + // Check for isolated nodes + const connectedNodeIds = new Set<string>() + edges.forEach(edge => { + connectedNodeIds.add(edge.source) + connectedNodeIds.add(edge.target) + }) + + // All nodes must be connected + const allNodesConnected = nodes.every(node => connectedNodeIds.has(node.id)) + if (!allNodesConnected) return false + + // Check condition nodes have both true and false connections + const conditionNodes = nodes.filter(node => node.data.nodeType === 'condition') + for (const conditionNode of conditionNodes) { + const outgoingConnections = edges.filter(edge => edge.source === conditionNode.id) + + // Condition nodes must have exactly 2 outgoing connections + if (outgoingConnections.length !== 2) return false + + // Must have one 'true' and one 'false' connection + const labels = outgoingConnections.map(edge => edge.label).filter(Boolean) + const hasTrue = labels.includes('true') + const hasFalse = labels.includes('false') + + if (!hasTrue || !hasFalse) return false + } + + return true +}) +const canPublish = computed(() => hasNodes.value && isDraft.value && hasValidConnections.value) + + + + + +// Handle node drag start +const handleDragStart = (event: DragEvent, nodeType: string) => { + if (event.dataTransfer) { + event.dataTransfer.setData('application/vueflow', nodeType) + event.dataTransfer.effectAllowed = 'move' + } + draggedType.value = nodeType +} + +// Handle drop on canvas +const handleDrop = (event: DragEvent) => { + event.preventDefault() + + const type = event.dataTransfer?.getData('application/vueflow') + if (!type) return + + const vueFlowBounds = vueFlowRef.value?.getBoundingClientRect() + if (!vueFlowBounds) return + + const position = { + x: event.clientX - vueFlowBounds.left, + y: event.clientY - vueFlowBounds.top + } + + const nodeType = availableNodeTypes.find(t => t.type === type) + const uniqueName = generateUniqueNodeName(nodeType?.label || 'Node') + + // Get default config for node type + const getDefaultConfig = (nodeType: string) => { + switch (nodeType) { + case 'guardrails': + return { + enabled_guardrails: ['pii', 'jailbreak'], + pii_action: 'block', + jailbreak_sensitivity: 0.7, + text_source: 'user_message', + block_message: '' + } + default: + return {} + } + } + + const defaultConfig = getDefaultConfig(type) + + const newNode: Node = { + id: generateNodeId(type), + type: 'default', // Use default Vue Flow node type + position, + data: { + label: `${nodeType?.icon || '📄'} ${uniqueName}`, // Display with icon and unique name + cleanName: uniqueName, // Store unique clean name for backend + description: '', + config: defaultConfig, + nodeType: type, // Store the original type for later use + icon: nodeType?.icon || '📄', + color: nodeType?.color || '#6B7280' + }, + style: { + backgroundColor: nodeType?.color || '#6B7280', + color: 'white', + borderColor: nodeType?.color || '#6B7280' + } + } + + addNodes([newNode]) + + // Add node to cache + const cacheNodeData = { + id: newNode.id, + node_type: mapNodeTypeToBackend(type), + name: uniqueName, + description: '', + position_x: newNode.position.x, + position_y: newNode.position.y, + config: defaultConfig + } + workflowNodeService.createNodeInCache(props.workflow.id, cacheNodeData) + + // Auto-connect to the last node if it exists + autoConnectToLastNode(newNode) + + draggedType.value = null +} + +// Auto-connect functionality +const autoConnectToLastNode = (newNode: Node) => { + const nodes = getNodes.value + const edges = getEdges.value + + if (nodes.length <= 1) return + + // Find the last node (excluding the new one) by looking for nodes that don't have outgoing connections + // This indicates they are "leaf" nodes and likely the most recently added + const otherNodes = nodes.filter(n => n.id !== newNode.id) + + // Find nodes that don't have any outgoing edges (no edges where they are the source) + // For condition and guardrails nodes, check if they have less than 2 outgoing connections + const nodesWithoutOutgoing = otherNodes.filter(node => { + const outgoingConnections = edges.filter(edge => edge.source === node.id) + if (node.data.nodeType === 'condition') { + return outgoingConnections.length < 2 // Condition nodes can have up to 2 connections + } + return outgoingConnections.length === 0 // Other nodes can have only 1 connection + }) + + // If we have nodes without outgoing connections, use the first one found + // If all nodes have outgoing connections, fall back to the last node in the array + const lastNode = nodesWithoutOutgoing.length > 0 + ? nodesWithoutOutgoing[0] + : otherNodes[otherNodes.length - 1] + + if (lastNode) { + // Check if the new node already has an incoming connection + const existingIncomingConnection = edges.find(edge => edge.target === newNode.id) + if (existingIncomingConnection) { + // Skip auto-connect if the new node already has an incoming connection + return + } + + // Check if the last node is an LLM with continuous execution + if (isLLMNodeWithContinuousExecution(lastNode)) { + toast.error('无法在“持续执行”的 LLM 节点之后连接其他节点。此类 LLM 节点必须是终止节点。', { + position: 'top-center', + duration: 5000, + closeButton: true + }) + return + } + + // Determine connection label for condition nodes in auto-connect + let autoConnectionLabel = undefined + if (lastNode.data.nodeType === 'condition') { + const existingOutgoingConnections = edges.filter(edge => edge.source === lastNode.id) + const existingLabels = existingOutgoingConnections.map(edge => edge.label).filter(Boolean) + + // Assign true/false labels based on what's already used + if (!existingLabels.includes('true') && !existingLabels.includes('false')) { + // First connection - default to 'true' + autoConnectionLabel = 'true' + } else if (existingLabels.includes('true') && !existingLabels.includes('false')) { + // Second connection - must be 'false' + autoConnectionLabel = 'false' + } else if (!existingLabels.includes('true') && existingLabels.includes('false')) { + // Second connection - must be 'true' + autoConnectionLabel = 'true' + } + } + + const newEdge: Edge = { + id: `${lastNode.id}-${newNode.id}`, + source: lastNode.id, + target: newNode.id, + animated: true, + label: autoConnectionLabel, + style: autoConnectionLabel ? { + stroke: autoConnectionLabel === 'true' ? '#10B981' : '#EF4444', + strokeWidth: 2 + } : undefined + } + + addEdges([newEdge]) + + // Add connection to cache + const cacheConnectionData = { + id: newEdge.id, + source_node_id: newEdge.source, + target_node_id: newEdge.target, + source_handle: newEdge.sourceHandle || undefined, + target_handle: newEdge.targetHandle || undefined, + label: autoConnectionLabel, + condition: newEdge.data?.condition, + priority: newEdge.data?.priority || 1, + connection_metadata: newEdge.data?.metadata || {} + } + workflowNodeService.addConnectionToCache(props.workflow.id, cacheConnectionData) + } +} + +// Handle node click for editing +const handleNodeClick = (event: NodeMouseEvent) => { + selectedNode.value = event.node + showPropertiesPanel.value = true + console.log('Node clicked:', event.node.id) + + // Add class to body to adjust toast positioning + document.body.classList.add('workflow-properties-panel-open') +} + + +// Handle connection creation +onConnect((params) => { + // Get the source and target nodes + const nodes = getNodes.value + const edges = getEdges.value + const sourceNode = nodes.find(n => n.id === params.source) + const targetNode = nodes.find(n => n.id === params.target) + + if (!sourceNode || !targetNode) return + + // Check if target node already has an incoming connection + const existingIncomingConnection = edges.find(edge => edge.target === params.target) + if (existingIncomingConnection) { + toast.error('每个节点只能有一条输入连接。请先移除已有连接。', { + position: 'top-center', + duration: 5000, + closeButton: true + }) + return + } + + // Check if source is an LLM node with continuous execution + if (isLLMNodeWithContinuousExecution(sourceNode)) { + toast.error('无法在“持续执行”的 LLM 节点之后连接其他节点。此类 LLM 节点必须是终止节点。', { + position: 'top-center', + duration: 5000, + closeButton: true + }) + return + } + + // Check if source node already has outgoing connections (except for condition nodes which can have multiple) + if (sourceNode.data.nodeType !== 'condition') { + const existingOutgoingConnection = edges.find(edge => edge.source === params.source) + if (existingOutgoingConnection) { + toast.error('除条件节点可分支到多条路径外,每个节点只能有一条输出连接。', { + position: 'top-center', + duration: 5000, + closeButton: true + }) + return + } + } else { + // For condition nodes, limit to maximum 2 outgoing connections (true/false branches) + const existingOutgoingConnections = edges.filter(edge => edge.source === params.source) + if (existingOutgoingConnections.length >= 2) { + toast.error('条件节点最多只能有 2 条输出连接(是 / 否分支)。', { + position: 'top-center', + duration: 5000, + closeButton: true + }) + return + } + } + + // Determine connection label for condition nodes + let connectionLabel = undefined + if (sourceNode.data.nodeType === 'condition') { + const existingOutgoingConnections = edges.filter(edge => edge.source === params.source) + const existingLabels = existingOutgoingConnections.map(edge => edge.label).filter(Boolean) + + // Assign true/false labels based on what's already used + if (!existingLabels.includes('true') && !existingLabels.includes('false')) { + // First connection - default to 'true' + connectionLabel = 'true' + } else if (existingLabels.includes('true') && !existingLabels.includes('false')) { + // Second connection - must be 'false' + connectionLabel = 'false' + } else if (!existingLabels.includes('true') && existingLabels.includes('false')) { + // Second connection - must be 'true' + connectionLabel = 'true' + } + } + + const newEdge: Edge = { + ...params, + id: `${params.source}-${params.target}`, + animated: true, + label: connectionLabel, + style: connectionLabel ? { + stroke: connectionLabel === 'true' ? '#10B981' : '#EF4444', + strokeWidth: 2 + } : undefined + } + addEdges([newEdge]) + + // Add connection to cache + const cacheConnectionData = { + id: newEdge.id, + source_node_id: newEdge.source, + target_node_id: newEdge.target, + source_handle: newEdge.sourceHandle || undefined, + target_handle: newEdge.targetHandle || undefined, + label: connectionLabel, + condition: newEdge.data?.condition, + priority: newEdge.data?.priority || 1, + connection_metadata: newEdge.data?.metadata || {} + } + workflowNodeService.addConnectionToCache(props.workflow.id, cacheConnectionData) +}) + +// Close workflow builder +const closeBuilder = () => { + // Clean up body class when closing + document.body.classList.remove('workflow-properties-panel-open') + emit('close') +} + +// Variables methods +const updateAvailableVariables = (nodes: Node[]) => { + const variables: Array<{ + nodeId: string + nodeName: string + fieldName: string + fieldType: string + fieldLabel: string + }> = [] + + // Find all form nodes and extract their field definitions + nodes.forEach(node => { + if (node.data.nodeType === 'form') { + // Get form fields from cache or node data + const nodeData = getNodeDataWithCache(node, props.workflow.id) + const formFields = nodeData?.config?.form_fields || node.data.config?.form_fields || [] + + formFields.forEach((field: any) => { + if (field.name) { + const nodeName = node.data.cleanName || node.data.label || 'Form' + variables.push({ + nodeId: node.id, + nodeName: nodeName, + fieldName: `${nodeName.toLowerCase().replace(/[^a-z0-9]/g, '_')}_${field.name}`, + fieldType: field.type || 'text', + fieldLabel: `${nodeName}:${field.label || field.name}` + }) + } + }) + } + + // Find all user input nodes and add their input as a variable + if (node.data.nodeType === 'userInput') { + const nodeName = node.data.cleanName || node.data.label || 'User Input' + variables.push({ + nodeId: node.id, + nodeName: nodeName, + fieldName: `${nodeName.toLowerCase().replace(/[^a-z0-9]/g, '_')}_input`, + fieldType: 'text', + fieldLabel: `${nodeName}:用户输入` + }) + } + }) + + availableVariables.value = variables +} + + + +// Load data on mount +onMounted(() => { + loadWorkflowData() +}) + +// Cleanup on unmount +onUnmounted(() => { + // Clean up body class when component unmounts + document.body.classList.remove('workflow-properties-panel-open') +}) + + + + + + +</script> + +<template> + <div class="workflow-builder" :class="{ 'properties-panel-open': showPropertiesPanel }" @dragover.prevent @drop="handleDrop"> + <!-- Header --> + <div class="builder-header"> + <div class="header-left"> + <h2 class="header-title">{{ workflow.name }}</h2> + <span class="header-status" :class="{ + 'status-published': isPublished, + 'status-draft': isDraft + }"> + {{ isPublished ? '已发布' : '草稿' }} + </span> + </div> + <div class="header-actions"> + <button class="action-btn secondary" @click="closeBuilder"> + <svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <line x1="18" y1="6" x2="6" y2="18"></line> + <line x1="6" y1="6" x2="18" y2="18"></line> + </svg> + 关闭 + </button> + + <!-- Publish/Unpublish button --> + <button + v-if="isPublished" + class="action-btn warning" + @click="unpublishWorkflow" + :disabled="publishLoading" + > + <div v-if="publishLoading" class="btn-spinner"></div> + <svg v-else class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <circle cx="12" cy="12" r="10"></circle> + <line x1="15" y1="9" x2="9" y2="15"></line> + <line x1="9" y1="9" x2="15" y2="15"></line> + </svg> + 取消发布 + </button> + + <button + v-else + class="action-btn success" + @click="publishWorkflow" + :disabled="!canPublish || publishLoading" + :title="!hasNodes ? '请先添加节点再发布' : + !hasValidConnections ? '请连接所有节点,并确保条件节点同时具备“是”和“否”两条路径后再发布' : + '发布工作流后将正式生效'" + > + <div v-if="publishLoading" class="btn-spinner"></div> + <svg v-else class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"></path> + </svg> + 发布 + </button> + + <button class="action-btn primary" @click="saveWorkflow" :disabled="loading"> + <div v-if="loading" class="btn-spinner"></div> + <svg v-else class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path> + <polyline points="17,21 17,13 7,13 7,21"></polyline> + <polyline points="7,3 7,8 15,8"></polyline> + </svg> + 保存 + </button> + </div> + </div> + + <div class="builder-content"> + <!-- Sidebar --> + <div class="sidebar"> + <div class="sidebar-header"> + <h3>节点类型</h3> + <p>拖拽节点到画布</p> + </div> + + <div class="node-types"> + <div + v-for="nodeType in availableNodeTypes" + :key="nodeType.type" + class="node-type-item" + draggable="true" + @dragstart="handleDragStart($event, nodeType.type)" + > + <div class="node-type-icon" :style="{ backgroundColor: nodeType.color }"> + {{ nodeType.icon }} + </div> + <div class="node-type-content"> + <h4>{{ nodeType.label }}</h4> + <p>{{ nodeType.description }}</p> + </div> + </div> + </div> + + + </div> + + <!-- Canvas --> + <div class="canvas-container"> + <div v-if="loading" class="canvas-loading"> + <div class="loading-spinner"></div> + <p>正在加载工作流……</p> + </div> + + <VueFlow + v-else + :node-types="nodeTypes" + class="workflow-canvas" + :default-viewport="{ zoom: 1 }" + :min-zoom="0.2" + :max-zoom="4" + @node-click="handleNodeClick" + @nodes-delete="handleNodesDelete" + @nodes-change="handleNodesChange" + @delete="handleDeleteEvent" + @nodes-remove="handleNodesDelete" + @elements-remove="handleDeleteEvent" + @edges-change="handleEdgesChange" + > + <Background pattern-color="#aaa" :gap="16" /> + <Controls /> + <MiniMap /> + + <!-- Empty state --> + <div v-if="!hasNodes" class="empty-state"> + <div class="empty-icon">🎯</div> + <h3>开始搭建你的工作流</h3> + <p>从侧边栏拖拽节点,构建对话流程</p> + <div v-if="isPublished" class="empty-state-warning"> + <svg class="warning-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path> + <line x1="12" y1="9" x2="12" y2="13"></line> + <line x1="12" y1="17" x2="12.01" y2="17"></line> + </svg> + <p>此工作流已发布但还没有节点。添加节点后即可使用。</p> + </div> + </div> + </VueFlow> + </div> + + <!-- Properties Panel --> + <PropertiesPanel + v-if="showPropertiesPanel && selectedNode" + :selected-node="selectedNode" + :available-node-types="availableNodeTypes" + :workflow-id="workflow.id" + :agent-id="workflow.agent_id" + :organization-id="workflow.organization_id" + :current-edges="getEdges" + :current-nodes="getNodes" + :available-variables="availableVariables" + @save="saveNodeProperties" + @close="closePropertiesPanel" + @delete="deleteSelectedNode" + /> + </div> + + + </div> +</template> + +<style scoped> +.workflow-builder { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--background-color); + z-index: 1000; + display: flex; + flex-direction: column; +} + +/* Ensure toast positioning is centered and doesn't cover buttons */ +.workflow-builder :deep([data-sonner-toaster]) { + top: 80px !important; +} + +.workflow-builder :deep([data-sonner-toaster][data-theme]) { + top: 80px !important; +} + +.workflow-builder :deep(.sonner-toaster) { + top: 80px !important; +} + +.builder-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-md) var(--space-lg); + border-bottom: 1px solid var(--border-color); + background: var(--background-soft); +} + +.header-left { + display: flex; + align-items: center; + gap: var(--space-md); +} + +.header-title { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-color); + margin: 0; +} + +.header-status { + padding: var(--space-xs) var(--space-sm); + background: var(--accent-solid); + color: var(--on-accent-solid); + border-radius: var(--radius-full); + font-size: 0.75rem; + font-weight: 500; +} + +.header-status.status-published { + background: var(--success-color, #10B981); +} + +.header-status.status-draft { + background: var(--warning-color, #F59E0B); +} + +.header-actions { + display: flex; + gap: var(--space-sm); +} + +.action-btn { + display: flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-sm) var(--space-md); + border: none; + border-radius: var(--radius-md); + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + font-size: var(--text-sm); +} + +.action-btn.primary { + background: var(--accent-solid); + color: var(--on-accent-solid); +} + +.action-btn.primary:hover:not(:disabled) { + background: var(--primary-dark); +} + +.action-btn.secondary { + background: var(--background-muted); + color: var(--text-muted); + border: 1px solid var(--border-color); +} + +.action-btn.secondary:hover { + background: var(--background-alt); + color: var(--text-color); +} + +.action-btn.success { + background: var(--success-color, #10B981); + color: white; +} + +.action-btn.success:hover:not(:disabled) { + background: var(--success-dark, #059669); +} + +.action-btn.warning { + background: var(--warning-color, #F59E0B); + color: white; +} + +.action-btn.warning:hover:not(:disabled) { + background: var(--warning-dark, #D97706); +} + +.action-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-icon { + width: 16px; + height: 16px; +} + +.btn-spinner { + width: 16px; + height: 16px; + border: 2px solid transparent; + border-top: 2px solid currentColor; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.builder-content { + flex: 1; + display: flex; + overflow: hidden; +} + +.sidebar { + width: 300px; + background: var(--background-soft); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.sidebar-header { + padding: var(--space-lg); + border-bottom: 1px solid var(--border-color); +} + +.sidebar-header h3 { + font-size: 1.1rem; + font-weight: 600; + margin: 0 0 var(--space-xs) 0; + color: var(--text-color); +} + +.sidebar-header p { + color: var(--text-muted); + font-size: var(--text-sm); + margin: 0; +} + +.node-types { + flex: 1; + overflow-y: auto; + padding: var(--space-md); +} + +.node-type-item { + display: flex; + align-items: center; + gap: var(--space-md); + padding: var(--space-md); + border-radius: var(--radius-lg); + cursor: grab; + transition: all 0.2s ease; + margin-bottom: var(--space-sm); + background: var(--background-color); + border: 1px solid var(--border-color); +} + +.node-type-item:hover { + background: var(--background-alt); + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +.node-type-item:active { + cursor: grabbing; +} + +.node-type-icon { + width: 40px; + height: 40px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + color: white; + flex-shrink: 0; +} + +.node-type-content { + flex: 1; + min-width: 0; +} + +.node-type-content h4 { + font-size: var(--text-sm); + font-weight: 600; + margin: 0 0 var(--space-xs) 0; + color: var(--text-color); +} + +.node-type-content p { + font-size: 0.75rem; + color: var(--text-muted); + margin: 0; + line-height: 1.3; +} + +.canvas-container { + flex: 1; + position: relative; + overflow: hidden; +} + +.canvas-loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; + z-index: 10; +} + +.loading-spinner { + width: 40px; + height: 40px; + border: 3px solid var(--border-color); + border-radius: 50%; + border-top-color: var(--primary-color); + animation: spin 1s linear infinite; + margin: 0 auto var(--space-md); +} + +.workflow-canvas { + width: 100%; + height: 100%; +} + +.empty-state { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; + z-index: 10; +} + +.empty-icon { + font-size: 4rem; + margin-bottom: var(--space-md); +} + +.empty-state h3 { + font-size: 1.5rem; + font-weight: 600; + color: var(--text-color); + margin: 0 0 var(--space-sm) 0; +} + +.empty-state p { + color: var(--text-muted); + font-size: var(--text-sm); + margin: 0; +} + +.empty-state-warning { + margin-top: var(--space-lg); + padding: var(--space-md); + background: var(--warning-soft, #FEF3C7); + border: 1px solid var(--warning-color, #F59E0B); + border-radius: var(--radius-md); + display: flex; + align-items: center; + gap: var(--space-sm); + max-width: 400px; +} + +.warning-icon { + width: 20px; + height: 20px; + color: var(--warning-color, #F59E0B); + flex-shrink: 0; +} + +.empty-state-warning p { + color: var(--warning-dark, #92400E); + font-size: var(--text-sm); + margin: 0; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Vue Flow Styles */ +:deep(.vue-flow__node) { + border-radius: var(--radius-lg); + border: 2px solid var(--border-color); + background: var(--background-color); + box-shadow: var(--shadow-md); + transition: all 0.2s ease; + min-width: 120px; +} + +:deep(.vue-flow__node:hover) { + box-shadow: var(--shadow-lg); + transform: translateY(-2px); +} + +:deep(.vue-flow__node.selected) { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px var(--primary-soft); +} + +:deep(.vue-flow__node-default) { + padding: var(--space-md); + border-radius: var(--radius-lg); + min-width: 120px; + text-align: center; +} + +:deep(.vue-flow__node-default .vue-flow__node-label) { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-xs); +} + +:deep(.vue-flow__node-default .node-icon) { + font-size: 1.2rem; + margin-bottom: var(--space-xs); +} + +:deep(.vue-flow__node-default .node-label) { + font-size: var(--text-sm); + font-weight: 500; + color: var(--text-color); +} + +:deep(.vue-flow__edge) { + stroke: var(--border-color); + stroke-width: 2px; +} + +:deep(.vue-flow__edge.selected) { + stroke: var(--primary-color); +} + +:deep(.vue-flow__edge.animated) { + stroke-dasharray: 5; + animation: dashdraw 0.5s linear infinite; +} + +@keyframes dashdraw { + to { + stroke-dashoffset: -10; + } +} + +:deep(.vue-flow__controls) { + bottom: var(--space-md); + left: var(--space-md); +} + +:deep(.vue-flow__minimap) { + bottom: var(--space-md); + right: var(--space-md); +} + + + +</style> \ No newline at end of file diff --git a/frontend/src/components/workflow/nodes/ActionNode.vue b/frontend/src/components/workflow/nodes/ActionNode.vue new file mode 100644 index 0000000..cf61181 --- /dev/null +++ b/frontend/src/components/workflow/nodes/ActionNode.vue @@ -0,0 +1,145 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { Handle, Position } from '@vue-flow/core' +import type { Node } from '@vue-flow/core' + +defineProps<{ + data: Node['data'] +}>() +</script> + +<template> + <div class="action-node"> + <Handle + id="input" + type="target" + :position="Position.Left" + class="node-handle" + /> + + <div class="node-content"> + <div class="node-header"> + <div class="node-icon">⚡</div> + <div class="node-title">{{ data.label || '动作' }}</div> + </div> + + <div v-if="data.description" class="node-description"> + {{ data.description }} + </div> + + <div v-if="data.config?.actionType" class="node-preview"> + <div class="preview-label">动作类型:</div> + <div class="preview-content">{{ data.config.actionType }}</div> + </div> + + <div v-if="data.config?.url" class="node-preview"> + <div class="preview-label">URL:</div> + <div class="preview-content"> + {{ data.config.url.substring(0, 80) }}{{ data.config.url.length > 80 ? '...' : '' }} + </div> + </div> + </div> + + <Handle + id="output" + type="source" + :position="Position.Right" + class="node-handle" + /> + </div> +</template> + +<style scoped> +.action-node { + position: relative; + background: var(--background-color); + border: 2px solid #EF4444; + border-radius: var(--radius-lg); + padding: var(--space-md); + min-width: 200px; + max-width: 300px; + box-shadow: var(--shadow-md); + transition: all 0.2s ease; +} + +.action-node:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.node-content { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.node-header { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.node-icon { + font-size: 1.2rem; + flex-shrink: 0; +} + +.node-title { + font-weight: 600; + color: var(--text-color); + font-size: var(--text-sm); +} + +.node-description { + color: var(--text-muted); + font-size: 0.75rem; + line-height: 1.3; +} + +.node-preview { + background: var(--background-soft); + border-radius: var(--radius-sm); + padding: var(--space-sm); + border: 1px solid var(--border-color); +} + +.preview-label { + font-size: 0.7rem; + color: var(--text-muted); + font-weight: 500; + margin-bottom: 2px; +} + +.preview-content { + font-size: 0.7rem; + color: var(--text-color); + line-height: 1.3; +} + +.node-handle { + background: #EF4444; + border: 2px solid var(--background-color); + width: 12px; + height: 12px; +} + +.node-handle:hover { + background: #DC2626; + transform: scale(1.2); +} +</style> \ No newline at end of file diff --git a/frontend/src/components/workflow/nodes/ActionNodeConfig.vue b/frontend/src/components/workflow/nodes/ActionNodeConfig.vue new file mode 100644 index 0000000..f266251 --- /dev/null +++ b/frontend/src/components/workflow/nodes/ActionNodeConfig.vue @@ -0,0 +1,155 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed } from 'vue' + +interface ActionNodeData { + action_type: string + action_url: string +} + +const props = defineProps<{ + modelValue: ActionNodeData + validationErrors: Record<string, string> +}>() + +const emit = defineEmits<{ + (e: 'update:model-value', value: ActionNodeData): void + (e: 'validate-field', field: string): void +}>() + +const formData = computed({ + get: () => props.modelValue, + set: (value: ActionNodeData) => emit('update:model-value', value) +}) + +// Update form data +const updateFormData = (field: keyof ActionNodeData, value: any) => { + formData.value = { + ...formData.value, + [field]: value + } + + emit('validate-field', field) +} +</script> + +<template> + <div class="action-node-config"> + <div class="form-group"> + <label for="action-type">动作类型 *</label> + <select + id="action-type" + :value="formData.action_type" + @change="updateFormData('action_type', ($event.target as HTMLSelectElement).value)" + @blur="$emit('validate-field', 'action_type')" + class="form-select" + :class="{ 'error': validationErrors.action_type }" + required + > + <option value="">选择动作类型</option> + <option value="webhook">Webhook</option> + <option value="email">发送邮件</option> + <option value="api">API 调用</option> + </select> + <div v-if="validationErrors.action_type" class="error-message"> + {{ validationErrors.action_type }} + </div> + </div> + + <div class="form-group"> + <label for="action-url">URL *</label> + <input + id="action-url" + :value="formData.action_url" + @input="updateFormData('action_url', ($event.target as HTMLInputElement).value)" + @blur="$emit('validate-field', 'action_url')" + type="url" + class="form-input" + :class="{ 'error': validationErrors.action_url }" + placeholder="https://example.com/webhook" + required + /> + <div v-if="validationErrors.action_url" class="error-message"> + {{ validationErrors.action_url }} + </div> + </div> + </div> +</template> + +<style scoped> +.action-node-config { + width: 100%; +} + +.form-group { + margin-bottom: var(--space-sm); +} + +.form-group label { + display: block; + font-size: 0.8rem; + font-weight: 500; + color: var(--text-color); + margin-bottom: 4px; +} + +.form-input, +.form-select { + width: 100%; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--background-color); + color: var(--text-color); + font-size: 0.85rem; + transition: border-color 0.2s ease; +} + +.form-input:focus, +.form-select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15); +} + +.form-input.error, +.form-select.error { + border-color: var(--error-color); + background-color: rgba(239, 68, 68, 0.05); +} + +.form-input.error:focus, +.form-select.error:focus { + border-color: var(--error-color); + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1); +} + +.error-message { + color: var(--error-color); + font-size: 0.75rem; + margin-top: 4px; + display: flex; + align-items: center; + gap: 4px; +} + +.error-message::before { + content: "⚠"; + font-size: 0.8rem; +} +</style> \ No newline at end of file diff --git a/frontend/src/components/workflow/nodes/ConditionNode.vue b/frontend/src/components/workflow/nodes/ConditionNode.vue new file mode 100644 index 0000000..2805f22 --- /dev/null +++ b/frontend/src/components/workflow/nodes/ConditionNode.vue @@ -0,0 +1,177 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { Handle, Position } from '@vue-flow/core' +import type { Node } from '@vue-flow/core' + +defineProps<{ + data: Node['data'] +}>() +</script> + +<template> + <div class="condition-node"> + <Handle + id="input" + type="target" + :position="Position.Left" + class="node-handle" + /> + + <div class="node-content"> + <div class="node-header"> + <div class="node-icon">🔀</div> + <div class="node-title">{{ data.label || '条件' }}</div> + </div> + + <div v-if="data.description" class="node-description"> + {{ data.description }} + </div> + + <div v-if="data.config?.condition" class="node-preview"> + <div class="preview-label">条件:</div> + <div class="preview-content"> + {{ data.config.condition.substring(0, 80) }}{{ data.config.condition.length > 80 ? '...' : '' }} + </div> + </div> + </div> + + <Handle + id="true" + type="source" + :position="Position.Right" + :style="{ top: '30%' }" + class="node-handle true-handle" + /> + <div class="handle-label true-label">是</div> + + <Handle + id="false" + type="source" + :position="Position.Right" + :style="{ top: '70%' }" + class="node-handle false-handle" + /> + <div class="handle-label false-label">否</div> + </div> +</template> + +<style scoped> +.condition-node { + position: relative; + background: var(--background-color); + border: 2px solid #F59E0B; + border-radius: var(--radius-lg); + padding: var(--space-md); + min-width: 200px; + max-width: 300px; + box-shadow: var(--shadow-md); + transition: all 0.2s ease; +} + +.condition-node:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.node-content { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.node-header { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.node-icon { + font-size: 1.2rem; + flex-shrink: 0; +} + +.node-title { + font-weight: 600; + color: var(--text-color); + font-size: var(--text-sm); +} + +.node-description { + color: var(--text-muted); + font-size: 0.75rem; + line-height: 1.3; +} + +.node-preview { + background: var(--background-soft); + border-radius: var(--radius-sm); + padding: var(--space-sm); + border: 1px solid var(--border-color); +} + +.preview-label { + font-size: 0.7rem; + color: var(--text-muted); + font-weight: 500; + margin-bottom: 2px; +} + +.preview-content { + font-size: 0.7rem; + color: var(--text-color); + line-height: 1.3; +} + +.node-handle { + background: #F59E0B; + border: 2px solid var(--background-color); + width: 12px; + height: 12px; +} + +.node-handle.true-handle { + background: #10B981; +} + +.node-handle.false-handle { + background: #EF4444; +} + +.node-handle:hover { + transform: scale(1.2); +} + +.handle-label { + position: absolute; + right: -35px; + font-size: 0.6rem; + color: var(--text-muted); + font-weight: 500; + pointer-events: none; +} + +.true-label { + top: 25%; + color: #10B981; +} + +.false-label { + top: 65%; + color: #EF4444; +} +</style> \ No newline at end of file diff --git a/frontend/src/components/workflow/nodes/ConditionNodeConfig.vue b/frontend/src/components/workflow/nodes/ConditionNodeConfig.vue new file mode 100644 index 0000000..cef1491 --- /dev/null +++ b/frontend/src/components/workflow/nodes/ConditionNodeConfig.vue @@ -0,0 +1,592 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed, ref, watch } from 'vue' +import { toast } from 'vue-sonner' + +interface ConditionRule { + id: string + variable: string + operator: string + value: string +} + +interface ConditionGroup { + id: string + rules: ConditionRule[] + logicalOperator: 'AND' | 'OR' +} + +interface ConditionNodeData { + condition_expression: string + condition_groups?: ConditionGroup[] +} + +interface Variable { + nodeId: string + nodeName: string + fieldName: string + fieldType: string + fieldLabel: string +} + +const props = defineProps<{ + modelValue: ConditionNodeData + validationErrors: Record<string, string> + availableVariables?: Variable[] +}>() + +const emit = defineEmits<{ + (e: 'update:model-value', value: ConditionNodeData): void + (e: 'validate-field', field: string): void +}>() + +// Available operators +const operators = [ + { value: '===', label: '等于' }, + { value: '!==', label: '不等于' }, + { value: '>', label: '大于' }, + { value: '<', label: '小于' }, + { value: '>=', label: '大于或等于' }, + { value: '<=', label: '小于或等于' }, + { value: 'includes', label: '包含' }, + { value: 'startsWith', label: '开头是' }, + { value: 'endsWith', label: '结尾是' } +] + +// Initialize condition groups from existing data or create default +const conditionGroups = ref<ConditionGroup[]>( + props.modelValue.condition_groups || [ + { + id: generateId(), + rules: [ + { + id: generateId(), + variable: '', + operator: '===', + value: '' + } + ], + logicalOperator: 'AND' + } + ] +) + +// Generate unique ID +function generateId(): string { + return Math.random().toString(36).substr(2, 9) +} + +// Factory for default condition group structure +function createDefaultConditionGroups(): ConditionGroup[] { + return [ + { + id: generateId(), + rules: [ + { id: generateId(), variable: '', operator: '===', value: '' } + ], + logicalOperator: 'AND' + } + ] +} + +// Keep local state in sync when switching nodes or external updates occur +watch( + () => props.modelValue, + (newValue) => { + if (newValue && Array.isArray(newValue.condition_groups)) { + conditionGroups.value = newValue.condition_groups.length > 0 + ? newValue.condition_groups + : createDefaultConditionGroups() + } else { + conditionGroups.value = createDefaultConditionGroups() + } + }, + { deep: true, immediate: true } +) + +// Update condition groups and generate expression +const updateConditions = () => { + const expression = generateConditionExpression() + const updatedData: ConditionNodeData = { + condition_expression: expression, + condition_groups: conditionGroups.value + } + + emit('update:model-value', updatedData) + emit('validate-field', 'condition_expression') + emit('validate-field', 'condition_groups') +} + +// Generate JavaScript expression from condition groups +const generateConditionExpression = (): string => { + if (conditionGroups.value.length === 0) return '' + + const groupExpressions = conditionGroups.value.map(group => { + if (group.rules.length === 0) return '' + + const ruleExpressions = group.rules + .filter(rule => rule.variable && rule.operator && rule.value) + .map(rule => { + const variable = `{{${rule.variable}}}` + const value = isNaN(Number(rule.value)) ? `"${rule.value}"` : rule.value + + if (rule.operator === 'includes') { + return `${variable}.includes(${value})` + } else if (rule.operator === 'startsWith') { + return `${variable}.startsWith(${value})` + } else if (rule.operator === 'endsWith') { + return `${variable}.endsWith(${value})` + } else { + return `${variable} ${rule.operator} ${value}` + } + }) + + if (ruleExpressions.length === 0) return '' + if (ruleExpressions.length === 1) return ruleExpressions[0] + + return `(${ruleExpressions.join(` ${group.logicalOperator} `)})` + }).filter(expr => expr) + + if (groupExpressions.length === 0) return '' + if (groupExpressions.length === 1) return groupExpressions[0] + + return groupExpressions.join(' OR ') +} + +// Add new rule to a group +const addRule = (groupId: string) => { + const group = conditionGroups.value.find(g => g.id === groupId) + if (group) { + group.rules.push({ + id: generateId(), + variable: '', + operator: '===', + value: '' + }) + updateConditions() + } +} + +// Remove rule from a group +const removeRule = (groupId: string, ruleId: string) => { + const group = conditionGroups.value.find(g => g.id === groupId) + if (group && group.rules.length > 1) { + group.rules = group.rules.filter(r => r.id !== ruleId) + updateConditions() + } +} + +// Add new condition group +const addGroup = () => { + conditionGroups.value.push({ + id: generateId(), + rules: [ + { + id: generateId(), + variable: '', + operator: '===', + value: '' + } + ], + logicalOperator: 'AND' + }) + updateConditions() +} + +// Remove condition group +const removeGroup = (groupId: string) => { + if (conditionGroups.value.length > 1) { + conditionGroups.value = conditionGroups.value.filter(g => g.id !== groupId) + updateConditions() + } +} + +// Update rule +const updateRule = (groupId: string, ruleId: string, field: keyof ConditionRule, value: string) => { + const group = conditionGroups.value.find(g => g.id === groupId) + if (group) { + const rule = group.rules.find(r => r.id === ruleId) + if (rule) { + rule[field] = value + updateConditions() + } + } +} + +// Update group logical operator +const updateGroupOperator = (groupId: string, operator: 'AND' | 'OR') => { + const group = conditionGroups.value.find(g => g.id === groupId) + if (group) { + group.logicalOperator = operator + updateConditions() + } +} +</script> + +<template> + <div class="condition-node-config"> + <!-- Condition Builder --> + <div class="form-group"> + <label>条件规则 *</label> + + <div v-if="!availableVariables || availableVariables.length === 0" class="no-variables"> + <p>暂无可用的变量。请在此条件节点之前添加表单或用户输入节点,以便在条件中使用它们的值。</p> + </div> + + <div v-else class="condition-builder"> + <div + v-for="(group, groupIndex) in conditionGroups" + :key="group.id" + class="condition-group" + > + <!-- Group Header --> + <div v-if="groupIndex > 0" class="group-separator"> + <span class="separator-text">或</span> + </div> + + <div class="group-content"> + <!-- Rules within the group --> + <div + v-for="(rule, ruleIndex) in group.rules" + :key="rule.id" + class="condition-rule" + > + <!-- Rule logical operator (AND/OR) --> + <div v-if="ruleIndex > 0" class="rule-operator"> + <select + :value="group.logicalOperator" + @change="updateGroupOperator(group.id, ($event.target as HTMLSelectElement).value as 'AND' | 'OR')" + class="operator-select" + > + <option value="AND">AND</option> + <option value="OR">OR</option> + </select> + </div> + + <!-- Rule configuration --> + <div class="rule-config"> + <!-- Variable selection --> + <select + :value="rule.variable" + @change="updateRule(group.id, rule.id, 'variable', ($event.target as HTMLSelectElement).value)" + class="variable-select" + > + <option value="">选择变量</option> + <option + v-for="variable in availableVariables" + :key="variable.fieldName" + :value="variable.fieldName" + > + {{ variable.fieldLabel }} + </option> + </select> + + <!-- Operator selection --> + <select + :value="rule.operator" + @change="updateRule(group.id, rule.id, 'operator', ($event.target as HTMLSelectElement).value)" + class="operator-select" + > + <option + v-for="op in operators" + :key="op.value" + :value="op.value" + > + {{ op.label }} + </option> + </select> + + <!-- Value input --> + <input + type="text" + :value="rule.value" + @input="updateRule(group.id, rule.id, 'value', ($event.target as HTMLInputElement).value)" + placeholder="输入值" + class="value-input" + /> + + <!-- Remove rule button --> + <button + v-if="group.rules.length > 1" + type="button" + @click="removeRule(group.id, rule.id)" + class="remove-btn rule-remove" + title="删除规则" + > + × + </button> + </div> + </div> + + <!-- Add rule button --> + <button + type="button" + @click="addRule(group.id)" + class="add-btn add-rule" + > + + 添加规则 + </button> + </div> + + <!-- Remove group button --> + <button + v-if="conditionGroups.length > 1" + type="button" + @click="removeGroup(group.id)" + class="remove-btn group-remove" + title="删除分组" + > + × + </button> + </div> + + <!-- Add group button --> + <button + type="button" + @click="addGroup" + class="add-btn add-group" + > + + 添加分组(或) + </button> + </div> + + <!-- Generated expression preview --> + <div v-if="generateConditionExpression()" class="expression-preview"> + <label>生成的表达式:</label> + <code class="expression-code">{{ generateConditionExpression() }}</code> + </div> + + <div v-if="validationErrors.condition_expression" class="error-message"> + {{ validationErrors.condition_expression }} + </div> + </div> + </div> +</template> + +<style scoped> +.condition-node-config { + width: 100%; +} + +.form-group { + margin-bottom: var(--space-sm); +} + +.form-group label { + display: block; + font-size: 0.8rem; + font-weight: 500; + color: var(--text-color); + margin-bottom: 8px; +} + +.no-variables { + font-size: 0.8rem; + color: var(--text-muted); + font-style: italic; + margin: 8px 0; + padding: 12px; + background: var(--background-soft); + border-radius: var(--radius-md); + border: 1px dashed var(--border-color); +} + +.condition-builder { + display: flex; + flex-direction: column; + gap: 16px; +} + +.condition-group { + position: relative; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 12px; + background: var(--background-soft); +} + +.group-separator { + display: flex; + justify-content: center; + margin: 8px 0; +} + +.separator-text { + background: var(--background-color); + padding: 4px 12px; + border: 1px solid var(--border-color); + border-radius: var(--radius-full); + font-size: 0.75rem; + font-weight: 600; + color: var(--text-muted); +} + +.group-content { + display: flex; + flex-direction: column; + gap: 8px; +} + +.condition-rule { + display: flex; + flex-direction: column; + gap: 8px; +} + +.rule-operator { + display: flex; + justify-content: center; +} + +.rule-config { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.variable-select, +.operator-select { + flex: 1; + min-width: 120px; + padding: 6px 8px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--background-color); + color: var(--text-color); + font-size: 0.8rem; +} + +.value-input { + flex: 1; + min-width: 100px; + padding: 6px 8px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--background-color); + color: var(--text-color); + font-size: 0.8rem; +} + +.variable-select:focus, +.operator-select:focus, +.value-input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15); +} + +.add-btn { + padding: 6px 12px; + border: 1px dashed var(--border-color); + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + font-size: 0.75rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.add-btn:hover { + border-color: var(--primary-color); + color: var(--primary-color); + background: rgba(201, 242, 78, 0.05); +} + +.add-rule { + align-self: flex-start; + margin-top: 4px; +} + +.add-group { + align-self: center; +} + +.remove-btn { + width: 24px; + height: 24px; + border: none; + border-radius: 50%; + background: var(--error-color); + color: white; + font-size: 14px; + font-weight: bold; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.remove-btn:hover { + background: var(--error-dark, #dc2626); + transform: scale(1.1); +} + +.group-remove { + position: absolute; + top: -8px; + right: -8px; +} + +.rule-remove { + margin-left: 4px; +} + +.expression-preview { + margin-top: 12px; + padding: 8px; + background: var(--background-alt); + border-radius: var(--radius-sm); + border: 1px solid var(--border-color); +} + +.expression-preview label { + font-size: 0.75rem; + color: var(--text-muted); + margin-bottom: 4px; +} + +.expression-code { + display: block; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.75rem; + color: var(--text-color); + background: var(--background-color); + padding: 8px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-color); + word-break: break-all; + white-space: pre-wrap; +} + +.error-message { + color: var(--error-color); + font-size: 0.75rem; + margin-top: 8px; + display: flex; + align-items: center; + gap: 4px; +} + +.error-message::before { + content: "⚠"; + font-size: 0.8rem; +} +</style> \ No newline at end of file diff --git a/frontend/src/components/workflow/nodes/EndNode.vue b/frontend/src/components/workflow/nodes/EndNode.vue new file mode 100644 index 0000000..c2ed30f --- /dev/null +++ b/frontend/src/components/workflow/nodes/EndNode.vue @@ -0,0 +1,140 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { Handle, Position } from '@vue-flow/core' +import type { Node } from '@vue-flow/core' + +defineProps<{ + data: Node['data'] +}>() +</script> + +<template> + <div class="end-node"> + <Handle + id="input" + type="target" + :position="Position.Left" + class="node-handle" + /> + + <div class="node-content"> + <div class="node-header"> + <div class="node-icon">🏁</div> + <div class="node-title">{{ data.label || '结束' }}</div> + </div> + + <div v-if="data.description" class="node-description"> + {{ data.description }} + </div> + + <div v-if="data.config?.endType" class="node-preview"> + <div class="preview-label">结束类型:</div> + <div class="preview-content">{{ data.config.endType }}</div> + </div> + + <div v-if="data.config?.finalMessage" class="node-preview"> + <div class="preview-label">最终消息:</div> + <div class="preview-content"> + {{ data.config.finalMessage.substring(0, 80) }}{{ data.config.finalMessage.length > 80 ? '...' : '' }} + </div> + </div> + </div> + + <!-- End node doesn't have output handle --> + </div> +</template> + +<style scoped> +.end-node { + position: relative; + background: var(--background-color); + border: 2px solid #DC2626; + border-radius: var(--radius-lg); + padding: var(--space-md); + min-width: 200px; + max-width: 300px; + box-shadow: var(--shadow-md); + transition: all 0.2s ease; +} + +.end-node:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.node-content { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.node-header { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.node-icon { + font-size: 1.2rem; + flex-shrink: 0; +} + +.node-title { + font-weight: 600; + color: var(--text-color); + font-size: var(--text-sm); +} + +.node-description { + color: var(--text-muted); + font-size: 0.75rem; + line-height: 1.3; +} + +.node-preview { + background: var(--background-soft); + border-radius: var(--radius-sm); + padding: var(--space-sm); + border: 1px solid var(--border-color); +} + +.preview-label { + font-size: 0.7rem; + color: var(--text-muted); + font-weight: 500; + margin-bottom: 2px; +} + +.preview-content { + font-size: 0.7rem; + color: var(--text-color); + line-height: 1.3; +} + +.node-handle { + background: #DC2626; + border: 2px solid var(--background-color); + width: 12px; + height: 12px; +} + +.node-handle:hover { + background: #B91C1C; + transform: scale(1.2); +} +</style> \ No newline at end of file diff --git a/frontend/src/components/workflow/nodes/EndNodeConfig.vue b/frontend/src/components/workflow/nodes/EndNodeConfig.vue new file mode 100644 index 0000000..7aa855f --- /dev/null +++ b/frontend/src/components/workflow/nodes/EndNodeConfig.vue @@ -0,0 +1,123 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed } from 'vue' + +interface EndNodeData { + final_message: string +} + +const props = defineProps<{ + modelValue: EndNodeData + validationErrors: Record<string, string> +}>() + +const emit = defineEmits<{ + (e: 'update:model-value', value: EndNodeData): void + (e: 'validate-field', field: string): void +}>() + +const formData = computed({ + get: () => props.modelValue, + set: (value: EndNodeData) => emit('update:model-value', value) +}) + +// Update form data +const updateFormData = (field: keyof EndNodeData, value: any) => { + formData.value = { + ...formData.value, + [field]: value + } +} +</script> + +<template> + <div class="end-node-config"> + <div class="form-group"> + <label for="end-message">最终消息</label> + <textarea + id="end-message" + :value="formData.final_message" + @input="updateFormData('final_message', ($event.target as HTMLTextAreaElement).value)" + class="form-textarea" + placeholder="结束会话时显示的最终消息" + rows="3" + ></textarea> + </div> + </div> +</template> + +<style scoped> +.end-node-config { + width: 100%; +} + +.form-group { + margin-bottom: var(--space-sm); +} + +.form-group label { + display: block; + font-size: 0.8rem; + font-weight: 500; + color: var(--text-color); + margin-bottom: 4px; +} + +.form-textarea { + width: 100%; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--background-color); + color: var(--text-color); + font-size: 0.85rem; + transition: border-color 0.2s ease; + resize: vertical; + min-height: 70px; +} + +.form-textarea:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15); +} + +.form-textarea.error { + border-color: var(--error-color); + background-color: rgba(239, 68, 68, 0.05); +} + +.form-textarea.error:focus { + border-color: var(--error-color); + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1); +} + +.error-message { + color: var(--error-color); + font-size: 0.75rem; + margin-top: 4px; + display: flex; + align-items: center; + gap: 4px; +} + +.error-message::before { + content: "⚠"; + font-size: 0.8rem; +} +</style> \ No newline at end of file diff --git a/frontend/src/components/workflow/nodes/FormNode.vue b/frontend/src/components/workflow/nodes/FormNode.vue new file mode 100644 index 0000000..a835a1d --- /dev/null +++ b/frontend/src/components/workflow/nodes/FormNode.vue @@ -0,0 +1,163 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { Handle, Position } from '@vue-flow/core' +import type { Node } from '@vue-flow/core' + +defineProps<{ + data: Node['data'] +}>() +</script> + +<template> + <div class="form-node"> + <Handle + id="input" + type="target" + :position="Position.Left" + class="node-handle" + /> + + <div class="node-content"> + <div class="node-header"> + <div class="node-icon">📝</div> + <div class="node-title">{{ data.label || '表单' }}</div> + </div> + + <div v-if="data.description" class="node-description"> + {{ data.description }} + </div> + + <div v-if="data.config?.fields?.length" class="node-preview"> + <div class="preview-label">字段({{ data.config.fields.length }}):</div> + <div class="field-list"> + <div + v-for="field in data.config.fields.slice(0, 3)" + :key="field.name" + class="field-item" + > + {{ field.name }} ({{ field.type }}) + </div> + <div v-if="data.config.fields.length > 3" class="field-more"> + +{{ data.config.fields.length - 3 }} 项 + </div> + </div> + </div> + </div> + + <Handle + id="output" + type="source" + :position="Position.Right" + class="node-handle" + /> + </div> +</template> + +<style scoped> +.form-node { + position: relative; + background: var(--background-color); + border: 2px solid #10B981; + border-radius: var(--radius-lg); + padding: var(--space-md); + min-width: 200px; + max-width: 300px; + box-shadow: var(--shadow-md); + transition: all 0.2s ease; +} + +.form-node:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.node-content { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.node-header { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.node-icon { + font-size: 1.2rem; + flex-shrink: 0; +} + +.node-title { + font-weight: 600; + color: var(--text-color); + font-size: var(--text-sm); +} + +.node-description { + color: var(--text-muted); + font-size: 0.75rem; + line-height: 1.3; +} + +.node-preview { + background: var(--background-soft); + border-radius: var(--radius-sm); + padding: var(--space-sm); + border: 1px solid var(--border-color); +} + +.preview-label { + font-size: 0.7rem; + color: var(--text-muted); + font-weight: 500; + margin-bottom: 4px; +} + +.field-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.field-item { + font-size: 0.65rem; + color: var(--text-color); + padding: 2px 4px; + background: rgba(16, 185, 129, 0.1); + border-radius: 3px; +} + +.field-more { + font-size: 0.65rem; + color: var(--text-muted); + font-style: italic; +} + +.node-handle { + background: #10B981; + border: 2px solid var(--background-color); + width: 12px; + height: 12px; +} + +.node-handle:hover { + background: #059669; + transform: scale(1.2); +} +</style> \ No newline at end of file diff --git a/frontend/src/components/workflow/nodes/FormNodeConfig.vue b/frontend/src/components/workflow/nodes/FormNodeConfig.vue new file mode 100644 index 0000000..05a96f7 --- /dev/null +++ b/frontend/src/components/workflow/nodes/FormNodeConfig.vue @@ -0,0 +1,604 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed } from 'vue' + +// Define form field interface +interface FormField { + name: string + label: string + type: string + required: boolean + placeholder: string + options: string | string[] // Can be string (for textarea display) or array (when saved) + minLength: number + maxLength: number +} + +interface FormNodeData { + form_title: string + form_description: string + submit_button_text: string + form_full_screen: boolean + form_fields: FormField[] +} + +const props = defineProps<{ + modelValue: FormNodeData + validationErrors: Record<string, string> +}>() + +const emit = defineEmits<{ + (e: 'update:model-value', value: FormNodeData): void + (e: 'validate-field', field: string): void +}>() + +const formData = computed({ + get: () => props.modelValue, + set: (value: FormNodeData) => emit('update:model-value', value) +}) + +// Get field type icon +const getFieldTypeIcon = (type: string) => { + const icons = { + text: '📝', + email: '📧', + number: '🔢', + tel: '📞', + textarea: '📄', + select: '📋', + checkbox: '☑️', + radio: '🔘' + } + return icons[type as keyof typeof icons] || '📝' +} + +// Add form field +const addFormField = () => { + const updatedFields = [...formData.value.form_fields, { + name: '', + label: '', + type: 'text', + required: false, + placeholder: '', + options: '', + minLength: 0, + maxLength: 255 + }] + + formData.value = { + ...formData.value, + form_fields: updatedFields + } + + // Trigger validation for form fields + emit('validate-field', 'form_fields') +} + +// Remove form field +const removeFormField = (index: number) => { + const updatedFields = formData.value.form_fields.filter((_, i) => i !== index) + + formData.value = { + ...formData.value, + form_fields: updatedFields + } + + // Trigger validation for form fields + emit('validate-field', 'form_fields') +} + +// Update individual field +const updateField = (index: number, field: string, value: any) => { + const updatedFields = [...formData.value.form_fields] + updatedFields[index] = { + ...updatedFields[index], + [field]: value + } + + formData.value = { + ...formData.value, + form_fields: updatedFields + } + + emit('validate-field', 'form_fields') +} + +// Update form data +const updateFormData = (field: keyof FormNodeData, value: any) => { + formData.value = { + ...formData.value, + [field]: value + } + + if (field !== 'form_fields') { + emit('validate-field', field) + } +} +</script> + +<template> + <div class="form-node-config"> + <div class="form-group"> + <label for="form-title">表单标题</label> + <input + id="form-title" + :value="formData.form_title" + @input="updateFormData('form_title', ($event.target as HTMLInputElement).value)" + type="text" + class="form-input" + placeholder="输入表单标题" + /> + </div> + + <div class="form-group"> + <label for="form-description">表单描述</label> + <textarea + id="form-description" + :value="formData.form_description" + @input="updateFormData('form_description', ($event.target as HTMLTextAreaElement).value)" + class="form-textarea" + placeholder="输入表单描述(可选)" + rows="3" + ></textarea> + </div> + + <div class="form-group"> + <label for="submit-button-text">提交按钮文字</label> + <input + id="submit-button-text" + :value="formData.submit_button_text" + @input="updateFormData('submit_button_text', ($event.target as HTMLInputElement).value)" + type="text" + class="form-input" + placeholder="提交" + /> + </div> + + <div class="form-group"> + <label class="checkbox-group"> + <input + :checked="formData.form_full_screen" + @change="updateFormData('form_full_screen', ($event.target as HTMLInputElement).checked)" + type="checkbox" + class="form-checkbox" + /> + <span class="checkbox-label">全屏显示表单</span> + </label> + <p class="help-text">启用后,表单将以全屏浮层显示,而不是显示在聊天界面内</p> + </div> + + <div class="form-group"> + <label>表单字段</label> + <div class="form-fields-container" :class="{ 'error': validationErrors.form_fields }"> + <div + v-for="(field, index) in formData.form_fields" + :key="index" + class="form-field-item" + > + <div class="form-field-header"> + <span class="field-type-badge" :class="`field-type-${field.type}`"> + {{ getFieldTypeIcon(field.type) }} {{ field.type }} + </span> + <button + type="button" + class="remove-field-btn" + @click="removeFormField(index)" + title="删除字段" + > + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <line x1="18" y1="6" x2="6" y2="18"></line> + <line x1="6" y1="6" x2="18" y2="18"></line> + </svg> + </button> + </div> + + <div class="form-field-config"> + <div class="field-row"> + <div class="field-col"> + <label class="field-label">字段名 *</label> + <input + :value="field.name" + @input="updateField(index, 'name', ($event.target as HTMLInputElement).value)" + @blur="$emit('validate-field', 'form_fields')" + type="text" + class="field-input" + placeholder="field_name" + required + /> + </div> + <div class="field-col"> + <label class="field-label">显示名称 *</label> + <input + :value="field.label" + @input="updateField(index, 'label', ($event.target as HTMLInputElement).value)" + @blur="$emit('validate-field', 'form_fields')" + type="text" + class="field-input" + placeholder="字段名称" + required + /> + </div> + </div> + + <div class="field-row"> + <div class="field-col"> + <label class="field-label">字段类型</label> + <select + :value="field.type" + @change="updateField(index, 'type', ($event.target as HTMLSelectElement).value)" + class="field-select" + > + <option value="text">文本</option> + <option value="email">邮箱</option> + <option value="number">数字</option> + <option value="tel">电话</option> + <option value="textarea">多行文本</option> + <option value="select">下拉选择</option> + <option value="checkbox">复选框</option> + <option value="radio">单选框</option> + </select> + </div> + <div class="field-col"> + <label class="field-label">必填</label> + <label class="checkbox-label"> + <input + :checked="field.required" + @change="updateField(index, 'required', ($event.target as HTMLInputElement).checked)" + type="checkbox" + class="form-checkbox" + /> + <span>必填字段</span> + </label> + </div> + </div> + + <div class="field-row"> + <div class="field-col-full"> + <label class="field-label">占位提示</label> + <input + :value="field.placeholder" + @input="updateField(index, 'placeholder', ($event.target as HTMLInputElement).value)" + @blur="$emit('validate-field', 'form_fields')" + type="text" + class="field-input" + placeholder="输入占位提示文字" + /> + </div> + </div> + + <!-- Options for select/radio fields --> + <div v-if="field.type === 'select' || field.type === 'radio'" class="field-row"> + <div class="field-col-full"> + <label class="field-label">选项(每行一个)</label> + <textarea + :value="Array.isArray(field.options) ? field.options.join('\n') : field.options" + @input="updateField(index, 'options', ($event.target as HTMLTextAreaElement).value)" + @blur="$emit('validate-field', 'form_fields')" + class="field-textarea" + placeholder="选项 1 选项 2 选项 3" + rows="3" + ></textarea> + </div> + </div> + + <!-- Validation for text fields --> + <div v-if="field.type === 'text' || field.type === 'textarea'" class="field-row"> + <div class="field-col"> + <label class="field-label">最小长度</label> + <input + :value="field.minLength" + @input="updateField(index, 'minLength', parseInt(($event.target as HTMLInputElement).value) || 0)" + @blur="$emit('validate-field', 'form_fields')" + type="number" + class="field-input" + min="0" + placeholder="0" + /> + </div> + <div class="field-col"> + <label class="field-label">最大长度</label> + <input + :value="field.maxLength" + @input="updateField(index, 'maxLength', parseInt(($event.target as HTMLInputElement).value) || 255)" + @blur="$emit('validate-field', 'form_fields')" + type="number" + class="field-input" + min="1" + placeholder="255" + /> + </div> + </div> + </div> + </div> + + <button + type="button" + class="add-field-btn" + @click="addFormField" + > + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <line x1="12" y1="5" x2="12" y2="19"></line> + <line x1="5" y1="12" x2="19" y2="12"></line> + </svg> + 添加表单字段 + </button> + </div> + <div v-if="validationErrors.form_fields" class="error-message"> + {{ validationErrors.form_fields }} + </div> + </div> + </div> +</template> + +<style scoped> +.form-node-config { + width: 100%; +} + +.form-group { + margin-bottom: var(--space-sm); +} + +.form-group label { + display: block; + font-size: 0.8rem; + font-weight: 500; + color: var(--text-color); + margin-bottom: 4px; +} + +.form-input, +.form-textarea, +.form-select { + width: 100%; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--background-color); + color: var(--text-color); + font-size: 0.85rem; + transition: border-color 0.2s ease; +} + +.form-input:focus, +.form-textarea:focus, +.form-select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15); +} + +.form-textarea { + resize: vertical; + min-height: 70px; +} + +.checkbox-group { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 8px 0; +} + +.checkbox-group input[type="checkbox"] { + margin-top: 2px; + flex-shrink: 0; +} + +.checkbox-label { + font-size: 0.85rem; + color: var(--text-color); + line-height: 1.4; + cursor: pointer; + user-select: none; +} + +.form-checkbox { + width: 16px; + height: 16px; + margin: 0; + cursor: pointer; + flex-shrink: 0; +} + +.help-text { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 4px; + line-height: 1.3; +} + +.error-message { + color: var(--error-color); + font-size: 0.75rem; + margin-top: 4px; + display: flex; + align-items: center; + gap: 4px; +} + +.error-message::before { + content: "⚠"; + font-size: 0.8rem; +} + +/* Form Fields Styles */ +.form-fields-container { + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--background-color); + padding: var(--space-sm); + max-height: none; + overflow: visible; +} + +.form-fields-container.error { + border-color: var(--error-color); + background-color: rgba(239, 68, 68, 0.05); +} + +.form-field-item { + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--background-soft); + margin-bottom: var(--space-sm); + overflow: hidden; +} + +.form-field-item:last-child { + margin-bottom: 0; +} + +.form-field-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-sm) var(--space-md); + background: var(--background-muted); + border-bottom: 1px solid var(--border-color); +} + +.field-type-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + border-radius: var(--radius-sm); + font-size: 0.7rem; + font-weight: 500; + text-transform: capitalize; + background: var(--primary-soft); + color: var(--primary-color); +} + +.remove-field-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: transparent; + border: none; + border-radius: var(--radius-sm); + color: var(--text-muted); + cursor: pointer; + transition: all 0.2s ease; +} + +.remove-field-btn:hover { + background: var(--error-color); + color: white; +} + +.remove-field-btn svg { + width: 12px; + height: 12px; +} + +.form-field-config { + padding: var(--space-md); +} + +.field-row { + display: flex; + gap: var(--space-sm); + margin-bottom: var(--space-sm); +} + +.field-row:last-child { + margin-bottom: 0; +} + +.field-col { + flex: 1; +} + +.field-col-full { + flex: 1; + width: 100%; +} + +.field-label { + display: block; + font-size: 0.7rem; + font-weight: 500; + color: var(--text-muted); + margin-bottom: 4px; +} + +.field-input, +.field-textarea, +.field-select { + width: 100%; + padding: 6px 8px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--background-color); + color: var(--text-color); + font-size: 0.75rem; + transition: border-color 0.2s ease; +} + +.field-input:focus, +.field-textarea:focus, +.field-select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 1px rgba(201, 242, 78, 0.15); +} + +.field-input.error, +.field-textarea.error, +.field-select.error { + border-color: var(--error-color); + background-color: rgba(239, 68, 68, 0.05); +} + +.field-textarea { + resize: vertical; + min-height: 60px; +} + +.add-field-btn { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-xs); + width: 100%; + padding: var(--space-sm); + background: var(--background-muted); + border: 1px dashed var(--border-color); + border-radius: var(--radius-md); + color: var(--text-muted); + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.8rem; + font-weight: 500; +} + +.add-field-btn:hover { + background: var(--primary-soft); + border-color: var(--primary-color); + color: var(--primary-color); +} + +.add-field-btn svg { + width: 14px; + height: 14px; +} +</style> \ No newline at end of file diff --git a/frontend/src/components/workflow/nodes/GuardrailsNodeConfig.vue b/frontend/src/components/workflow/nodes/GuardrailsNodeConfig.vue new file mode 100644 index 0000000..9b4c7e7 --- /dev/null +++ b/frontend/src/components/workflow/nodes/GuardrailsNodeConfig.vue @@ -0,0 +1,534 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed } from 'vue' + +interface GuardrailsNodeData { + enabled_guardrails: string[] + pii_action: string + jailbreak_sensitivity: number + text_source: string + block_message: string +} + +interface Variable { + nodeId: string + nodeName: string + fieldName: string + fieldType: string + fieldLabel: string +} + +const props = defineProps<{ + modelValue: GuardrailsNodeData + validationErrors: Record<string, string> + availableVariables?: Variable[] +}>() + +const emit = defineEmits<{ + (e: 'update:model-value', value: GuardrailsNodeData): void + (e: 'validate-field', field: string): void +}>() + +const formData = computed({ + get: () => props.modelValue, + set: (value: GuardrailsNodeData) => emit('update:model-value', value) +}) + +// Update form data +const updateFormData = (field: keyof GuardrailsNodeData, value: any) => { + formData.value = { + ...formData.value, + [field]: value + } + + emit('validate-field', field) +} + +// Toggle guardrail type +const toggleGuardrail = (type: string) => { + const current = formData.value.enabled_guardrails || [] + const index = current.indexOf(type) + + let updated: string[] + if (index > -1) { + // Remove if exists + updated = current.filter(g => g !== type) + } else { + // Add if doesn't exist + updated = [...current, type] + } + + updateFormData('enabled_guardrails', updated) +} + +const isGuardrailEnabled = (type: string) => { + return (formData.value.enabled_guardrails || []).includes(type) +} + +// Helper to get variable syntax +const getVariableSyntax = (fieldName: string) => { + return `{{${fieldName}}}` +} + +// Copy variable to clipboard +const copyVariableToClipboard = async (variable: Variable) => { + const syntax = getVariableSyntax(variable.fieldName) + try { + await navigator.clipboard.writeText(syntax) + // Could add a toast notification here if needed + } catch (err) { + console.error('Failed to copy variable:', err) + } +} +</script> + +<template> + <div class="guardrails-node-config"> + <div class="info-box"> + <p>配置内容护栏,检测并拦截不当内容。</p> + </div> + + <div class="form-group"> + <label>启用的护栏 *</label> + <div class="guardrail-options"> + <label class="guardrail-option"> + <input + type="checkbox" + :checked="isGuardrailEnabled('pii')" + @change="toggleGuardrail('pii')" + class="form-checkbox" + /> + <div class="guardrail-info"> + <span class="guardrail-icon">🔒</span> + <div> + <strong>PII 检测</strong> + <p>检测个人身份信息(邮箱、电话号码、身份证号等)</p> + </div> + </div> + </label> + + <label class="guardrail-option"> + <input + type="checkbox" + :checked="isGuardrailEnabled('jailbreak')" + @change="toggleGuardrail('jailbreak')" + class="form-checkbox" + /> + <div class="guardrail-info"> + <span class="guardrail-icon">⚠️</span> + <div> + <strong>越狱检测</strong> + <p>检测绕过 AI 安全准则或操纵系统提示词的尝试</p> + </div> + </div> + </label> + </div> + <div v-if="validationErrors.enabled_guardrails" class="error-message"> + {{ validationErrors.enabled_guardrails }} + </div> + </div> + + <!-- PII Action --> + <div v-if="isGuardrailEnabled('pii')" class="form-group"> + <label for="pii-action">PII 处理方式</label> + <select + id="pii-action" + :value="formData.pii_action" + @change="updateFormData('pii_action', ($event.target as HTMLSelectElement).value)" + class="form-select" + > + <option value="block">阻止:检测到 PII 时停止处理</option> + <option value="redact">脱敏:将 PII 替换为占位符</option> + <option value="warning">警告:记录日志但允许继续处理</option> + <option value="log">记录:仅记录检测结果</option> + </select> + </div> + + <!-- Jailbreak Sensitivity --> + <div v-if="isGuardrailEnabled('jailbreak')" class="form-group"> + <label for="jailbreak-sensitivity"> + 越狱检测灵敏度:{{ formData.jailbreak_sensitivity?.toFixed(1) || '0.7' }} + </label> + <input + id="jailbreak-sensitivity" + type="range" + :value="formData.jailbreak_sensitivity || 0.7" + @input="updateFormData('jailbreak_sensitivity', parseFloat(($event.target as HTMLInputElement).value))" + min="0.1" + max="1.0" + step="0.1" + class="form-range" + /> + <div class="range-labels"> + <span>较低灵敏度(0.1)</span> + <span>较高灵敏度(1.0)</span> + </div> + <small class="help-text"> + 灵敏度越高,越容易检测到潜在的越狱尝试 + </small> + </div> + + <div class="form-group"> + <label for="text-source">文本来源</label> + <input + id="text-source" + type="text" + :value="formData.text_source" + @input="updateFormData('text_source', ($event.target as HTMLInputElement).value)" + class="form-input" + placeholder="user_message or user_input_input" + /> + <small class="help-text"> + 指定要检测的文本。使用“user_message”表示最新用户输入,也可引用“user_input_input”这样的变量,或使用双花括号语法 + </small> + </div> + + <!-- Available Variables Section --> + <div v-if="availableVariables && availableVariables.length > 0" class="variables-section"> + <div class="variables-header"> + <span class="variables-title">可用变量</span> + <span class="variables-count">{{ availableVariables.length }}</span> + </div> + + <div class="variables-list"> + <div + v-for="variable in availableVariables" + :key="`${variable.nodeId}-${variable.fieldName}`" + class="variable-item" + @click="copyVariableToClipboard(variable)" + :title="`点击复制 ${getVariableSyntax(variable.fieldName)} 到剪贴板`" + > + <div class="variable-info"> + <div class="variable-name">{{ variable.fieldName }}</div> + <div class="variable-source">来自 {{ variable.nodeName }}</div> + </div> + <div class="variable-syntax"> + <code>{{ getVariableSyntax(variable.fieldName) }}</code> + </div> + </div> + </div> + + <div class="variables-help"> + <small>点击任意变量即可复制到剪贴板</small> + </div> + </div> + + <div class="form-group"> + <label for="block-message">自定义拦截消息</label> + <textarea + id="block-message" + :value="formData.block_message" + @input="updateFormData('block_message', ($event.target as HTMLTextAreaElement).value)" + class="form-textarea" + placeholder="可选:内容被拦截时显示的自定义消息" + rows="3" + ></textarea> + <small class="help-text"> + 留空则使用默认拦截消息 + </small> + </div> + + + </div> +</template> + +<style scoped> +.guardrails-node-config { + width: 100%; +} + +.info-box { + background: var(--color-bg-tertiary, #f8f9fa); + border-left: 3px solid var(--color-primary, #3b82f6); + padding: var(--space-xs); + margin-bottom: var(--space-sm); + border-radius: 4px; +} + +.info-box p { + margin: 0; + font-size: 0.85rem; + color: var(--color-text-secondary, #6b7280); +} + +.info-box ul { + margin: var(--space-xs) 0 0 0; + padding-left: 1.5rem; +} + +.info-box li { + margin: 0.25rem 0; + font-size: 0.85rem; +} + +.routing-info { + border-left-color: var(--color-accent, #ec4899); +} + +.form-group { + margin-bottom: var(--space-sm); +} + +.form-group label { + display: block; + font-size: 0.8rem; + font-weight: 600; + color: var(--color-text-primary, #1f2937); + margin-bottom: var(--space-xs); +} + +.guardrail-options { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.guardrail-option { + display: flex; + align-items: flex-start; + gap: var(--space-xs); + padding: var(--space-xs); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; +} + +.guardrail-option:hover { + background: var(--color-bg-secondary, #f9fafb); + border-color: var(--color-primary, #3b82f6); +} + +.guardrail-option input[type="checkbox"] { + margin-top: 0.2rem; +} + +.guardrail-info { + display: flex; + align-items: flex-start; + gap: var(--space-xs); + flex: 1; +} + +.guardrail-icon { + font-size: 1.5rem; + line-height: 1; +} + +.guardrail-info strong { + display: block; + font-size: 0.9rem; + margin-bottom: 0.25rem; +} + +.guardrail-info p { + margin: 0; + font-size: 0.8rem; + color: var(--color-text-secondary, #6b7280); + font-weight: normal; +} + +.form-input, +.form-textarea, +.form-select { + width: 100%; + padding: var(--space-xs); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 4px; + font-size: 0.85rem; + transition: border-color 0.2s; +} + +.form-input:focus, +.form-textarea:focus, +.form-select:focus { + outline: none; + border-color: var(--color-primary, #3b82f6); +} + +.form-range { + width: 100%; + height: 6px; + border-radius: 3px; + background: var(--color-bg-tertiary, #e5e7eb); + outline: none; + -webkit-appearance: none; +} + +.form-range::-webkit-slider-thumb { + appearance: none; + -webkit-appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--color-primary, #3b82f6); + cursor: pointer; +} + +.form-range::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--color-primary, #3b82f6); + cursor: pointer; + border: none; +} + +.range-labels { + display: flex; + justify-content: space-between; + margin-top: 0.25rem; +} + +.range-labels span { + font-size: 0.75rem; + color: var(--color-text-secondary, #6b7280); +} + +.help-text { + display: block; + font-size: 0.75rem; + color: var(--color-text-secondary, #6b7280); + margin-top: 0.25rem; +} + +.error-message { + color: var(--color-error, #ef4444); + font-size: 0.75rem; + margin-top: 0.25rem; +} + +.form-checkbox { + cursor: pointer; +} + +/* Variables Section */ +.variables-section { + margin-top: var(--space-sm); + padding: var(--space-sm); + background: var(--background-soft); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); +} + +.variables-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-xs); +} + +.variables-title { + font-size: 0.85rem; + font-weight: 600; + color: var(--text-color); +} + +.variables-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + background: var(--accent-solid); + color: var(--on-accent-solid); + border-radius: 10px; + font-size: 0.7rem; + font-weight: 600; +} + +.variables-list { + display: flex; + flex-direction: column; + gap: var(--space-xs); + margin-bottom: var(--space-xs); +} + +.variable-item { + display: flex; + align-items: flex-start; + gap: var(--space-xs); + padding: var(--space-sm); + background: var(--background-color); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s ease; +} + +.variable-item:hover { + background: var(--background-alt); + border-color: var(--primary-color); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +.variable-info { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-width: 0; +} + +.variable-name { + font-weight: 600; + font-size: var(--text-sm); + color: var(--text-color); + word-break: break-word; +} + +.variable-source { + font-size: 0.75rem; + color: var(--text-muted); + margin: 2px 0; +} + +.variable-syntax { + display: flex; + align-items: center; + flex-shrink: 0; + margin-top: 2px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; +} + +.variable-syntax code { + background: var(--background-soft); + padding: 2px 6px; + border-radius: var(--radius-xs); + font-size: 0.75rem; + color: var(--text-color); + border: 1px solid var(--border-color); + white-space: nowrap; +} + +.variables-help { + padding: var(--space-sm); + background: var(--background-soft); + border-radius: var(--radius-md); + border: 1px solid var(--border-color); + margin-top: var(--space-xs); +} + +.variables-help small { + font-size: 0.75rem; + color: var(--text-muted); + line-height: 1.3; +} +</style> diff --git a/frontend/src/components/workflow/nodes/HumanTransferNode.vue b/frontend/src/components/workflow/nodes/HumanTransferNode.vue new file mode 100644 index 0000000..f7a26ff --- /dev/null +++ b/frontend/src/components/workflow/nodes/HumanTransferNode.vue @@ -0,0 +1,145 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { Handle, Position } from '@vue-flow/core' +import type { Node } from '@vue-flow/core' + +defineProps<{ + data: Node['data'] +}>() +</script> + +<template> + <div class="human-transfer-node"> + <Handle + id="input" + type="target" + :position="Position.Left" + class="node-handle" + /> + + <div class="node-content"> + <div class="node-header"> + <div class="node-icon">👤</div> + <div class="node-title">{{ data.label || '转人工' }}</div> + </div> + + <div v-if="data.description" class="node-description"> + {{ data.description }} + </div> + + <div v-if="data.config?.department" class="node-preview"> + <div class="preview-label">部门:</div> + <div class="preview-content">{{ data.config.department }}</div> + </div> + + <div v-if="data.config?.message" class="node-preview"> + <div class="preview-label">转接消息:</div> + <div class="preview-content"> + {{ data.config.message.substring(0, 80) }}{{ data.config.message.length > 80 ? '...' : '' }} + </div> + </div> + </div> + + <Handle + id="output" + type="source" + :position="Position.Right" + class="node-handle" + /> + </div> +</template> + +<style scoped> +.human-transfer-node { + position: relative; + background: var(--background-color); + border: 2px solid #F97316; + border-radius: var(--radius-lg); + padding: var(--space-md); + min-width: 200px; + max-width: 300px; + box-shadow: var(--shadow-md); + transition: all 0.2s ease; +} + +.human-transfer-node:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.node-content { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.node-header { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.node-icon { + font-size: 1.2rem; + flex-shrink: 0; +} + +.node-title { + font-weight: 600; + color: var(--text-color); + font-size: var(--text-sm); +} + +.node-description { + color: var(--text-muted); + font-size: 0.75rem; + line-height: 1.3; +} + +.node-preview { + background: var(--background-soft); + border-radius: var(--radius-sm); + padding: var(--space-sm); + border: 1px solid var(--border-color); +} + +.preview-label { + font-size: 0.7rem; + color: var(--text-muted); + font-weight: 500; + margin-bottom: 2px; +} + +.preview-content { + font-size: 0.7rem; + color: var(--text-color); + line-height: 1.3; +} + +.node-handle { + background: #F97316; + border: 2px solid var(--background-color); + width: 12px; + height: 12px; +} + +.node-handle:hover { + background: #EA580C; + transform: scale(1.2); +} +</style> \ No newline at end of file diff --git a/frontend/src/components/workflow/nodes/HumanTransferNodeConfig.vue b/frontend/src/components/workflow/nodes/HumanTransferNodeConfig.vue new file mode 100644 index 0000000..f10f392 --- /dev/null +++ b/frontend/src/components/workflow/nodes/HumanTransferNodeConfig.vue @@ -0,0 +1,154 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed } from 'vue' + +interface HumanTransferNodeData { + transfer_department: string + transfer_message: string +} + +const props = defineProps<{ + modelValue: HumanTransferNodeData + validationErrors: Record<string, string> +}>() + +const emit = defineEmits<{ + (e: 'update:model-value', value: HumanTransferNodeData): void + (e: 'validate-field', field: string): void +}>() + +const formData = computed({ + get: () => props.modelValue, + set: (value: HumanTransferNodeData) => emit('update:model-value', value) +}) + +// Update form data +const updateFormData = (field: keyof HumanTransferNodeData, value: any) => { + formData.value = { + ...formData.value, + [field]: value + } + + emit('validate-field', field) +} +</script> + +<template> + <div class="human-transfer-node-config"> + <div class="form-group"> + <label for="transfer-department">部门 *</label> + <select + id="transfer-department" + :value="formData.transfer_department" + @change="updateFormData('transfer_department', ($event.target as HTMLSelectElement).value)" + @blur="$emit('validate-field', 'transfer_department')" + class="form-select" + :class="{ 'error': validationErrors.transfer_department }" + required + > + <option value="">选择部门</option> + <option value="general">常规支持</option> + <option value="sales">销售</option> + <option value="technical">技术支持</option> + </select> + <div v-if="validationErrors.transfer_department" class="error-message"> + {{ validationErrors.transfer_department }} + </div> + </div> + + <div class="form-group"> + <label for="transfer-message">转接消息</label> + <textarea + id="transfer-message" + :value="formData.transfer_message" + @input="updateFormData('transfer_message', ($event.target as HTMLTextAreaElement).value)" + class="form-textarea" + placeholder="转接至人工客服时显示的消息" + rows="3" + ></textarea> + </div> + </div> +</template> + +<style scoped> +.human-transfer-node-config { + width: 100%; +} + +.form-group { + margin-bottom: var(--space-sm); +} + +.form-group label { + display: block; + font-size: 0.8rem; + font-weight: 500; + color: var(--text-color); + margin-bottom: 4px; +} + +.form-select, +.form-textarea { + width: 100%; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--background-color); + color: var(--text-color); + font-size: 0.85rem; + transition: border-color 0.2s ease; +} + +.form-select:focus, +.form-textarea:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15); +} + +.form-select.error, +.form-textarea.error { + border-color: var(--error-color); + background-color: rgba(239, 68, 68, 0.05); +} + +.form-select.error:focus, +.form-textarea.error:focus { + border-color: var(--error-color); + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1); +} + +.form-textarea { + resize: vertical; + min-height: 70px; +} + +.error-message { + color: var(--error-color); + font-size: 0.75rem; + margin-top: 4px; + display: flex; + align-items: center; + gap: 4px; +} + +.error-message::before { + content: "⚠"; + font-size: 0.8rem; +} +</style> \ No newline at end of file diff --git a/frontend/src/components/workflow/nodes/LLMNode.vue b/frontend/src/components/workflow/nodes/LLMNode.vue new file mode 100644 index 0000000..538ca75 --- /dev/null +++ b/frontend/src/components/workflow/nodes/LLMNode.vue @@ -0,0 +1,145 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { Handle, Position } from '@vue-flow/core' +import type { Node } from '@vue-flow/core' + +defineProps<{ + data: Node['data'] +}>() +</script> + +<template> + <div class="llm-node"> + <Handle + id="input" + type="target" + :position="Position.Left" + class="node-handle" + /> + + <div class="node-content"> + <div class="node-header"> + <div class="node-icon">🤖</div> + <div class="node-title">{{ data.label || 'LLM' }}</div> + </div> + + <div v-if="data.description" class="node-description"> + {{ data.description }} + </div> + + <div v-if="data.config?.model" class="node-preview"> + <div class="preview-label">模型:</div> + <div class="preview-content">{{ data.config.model }}</div> + </div> + + <div v-if="data.config?.systemPrompt" class="node-preview"> + <div class="preview-label">系统提示词:</div> + <div class="preview-content"> + {{ data.config.systemPrompt.substring(0, 80) }}{{ data.config.systemPrompt.length > 80 ? '...' : '' }} + </div> + </div> + </div> + + <Handle + id="output" + type="source" + :position="Position.Right" + class="node-handle" + /> + </div> +</template> + +<style scoped> +.llm-node { + position: relative; + background: var(--background-color); + border: 2px solid #8B5CF6; + border-radius: var(--radius-lg); + padding: var(--space-md); + min-width: 200px; + max-width: 300px; + box-shadow: var(--shadow-md); + transition: all 0.2s ease; +} + +.llm-node:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.node-content { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.node-header { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.node-icon { + font-size: 1.2rem; + flex-shrink: 0; +} + +.node-title { + font-weight: 600; + color: var(--text-color); + font-size: var(--text-sm); +} + +.node-description { + color: var(--text-muted); + font-size: 0.75rem; + line-height: 1.3; +} + +.node-preview { + background: var(--background-soft); + border-radius: var(--radius-sm); + padding: var(--space-sm); + border: 1px solid var(--border-color); +} + +.preview-label { + font-size: 0.7rem; + color: var(--text-muted); + font-weight: 500; + margin-bottom: 2px; +} + +.preview-content { + font-size: 0.7rem; + color: var(--text-color); + line-height: 1.3; +} + +.node-handle { + background: #8B5CF6; + border: 2px solid var(--background-color); + width: 12px; + height: 12px; +} + +.node-handle:hover { + background: #7C3AED; + transform: scale(1.2); +} +</style> \ No newline at end of file diff --git a/frontend/src/components/workflow/nodes/LLMNodeConfig.vue b/frontend/src/components/workflow/nodes/LLMNodeConfig.vue new file mode 100644 index 0000000..c6d31fb --- /dev/null +++ b/frontend/src/components/workflow/nodes/LLMNodeConfig.vue @@ -0,0 +1,859 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { ref, computed } from 'vue' +import { useAgentEdit } from '@/composables/useAgentEdit' +import KnowledgeGrid from '@/components/agent/KnowledgeGrid.vue' +import { ExitCondition } from '@/types/workflow' +import type { UserGroup } from '@/types/user' + +interface FormData { + system_prompt: string + temperature: number + exit_condition: string + auto_transfer_enabled: boolean + transfer_group_id: string + ask_for_rating: boolean +} + +const props = defineProps<{ + modelValue: FormData + validationErrors: Record<string, string> + userGroups: UserGroup[] + loadingGroups: boolean + agentId: string + organizationId: string + collapsedSections: Record<string, boolean> +}>() + +const emit = defineEmits<{ + (e: 'update:modelValue', value: FormData): void + (e: 'validateField', field: string): void + (e: 'toggleSection', section: string): void +}>() + +// Local reactive copy of the form data +const localFormData = computed({ + get: () => props.modelValue, + set: (value) => emit('update:modelValue', value) +}) + +// AI generation state +const showAIPrompt = ref(false) +const aiPrompt = ref('') + +// Initialize agent edit composable (using a mock agent object for AI generation) +const mockAgent = { + id: 'temp-agent', + name: 'Workflow Node Agent', + display_name: 'Workflow Node Agent', + description: 'Temporary agent for AI generation', + agent_type: 'general', + instructions: '', + transfer_to_human: false, + ask_for_rating: false, + organization_id: 'temp', + created_by: 'temp', + created_at: new Date(), + updated_at: new Date(), + is_active: true, + enable_rate_limiting: false, + overall_limit_per_ip: 0, + requests_per_sec: 0, + conversation_starters: [], + knowledge_base_ids: [], + canvas_data: {} +} as any + +const { generateInstructions, isLoading: aiLoading, error: aiError } = useAgentEdit(mockAgent) + +// Handle field updates +const updateField = (field: keyof FormData, value: any) => { + const updated = { ...localFormData.value, [field]: value } + emit('update:modelValue', updated) + emit('validateField', field) +} + +// Handle AI generation for system prompt +const handleGenerateWithAI = async () => { + if (!aiPrompt.value.trim()) return + + try { + const generatedInstructions = await generateInstructions(aiPrompt.value) + if (generatedInstructions.length > 0) { + // Join the generated instructions with newlines + updateField('system_prompt', generatedInstructions.join('\n')) + showAIPrompt.value = false + aiPrompt.value = '' + } + } catch (err) { + console.error('Failed to generate instructions:', err) + } +} + +const validateFieldOnChange = (field: string) => { + emit('validateField', field) +} + +const toggleSection = (section: string) => { + emit('toggleSection', section) +} +</script> + +<template> + <div class="llm-node-config"> + <div class="form-group"> + <div class="instructions-header"> + <label for="system-prompt">指令 *</label> + <button + class="ai-generate-button" + @click="showAIPrompt = true" + :disabled="aiLoading" + type="button" + > + <span class="ai-icon">✨</span> + AI 生成 + </button> + </div> + <textarea + id="system-prompt" + :value="localFormData.system_prompt" + @input="updateField('system_prompt', ($event.target as HTMLInputElement).value)" + class="form-textarea" + :class="{ 'error': validationErrors.system_prompt }" + placeholder="输入给 AI 的系统提示词" + rows="4" + required + @blur="validateFieldOnChange('system_prompt')" + ></textarea> + <div v-if="validationErrors.system_prompt" class="error-message"> + {{ validationErrors.system_prompt }} + </div> + </div> + + <div class="form-group"> + <label for="temperature">温度</label> + <input + id="temperature" + :value="localFormData.temperature" + @input="updateField('temperature', parseFloat(($event.target as HTMLInputElement).value))" + type="number" + class="form-input" + :class="{ 'error': validationErrors.temperature }" + min="0" + max="2" + step="0.1" + placeholder="0.7" + @blur="validateFieldOnChange('temperature')" + /> + <div v-if="validationErrors.temperature" class="error-message"> + {{ validationErrors.temperature }} + </div> + <p class="help-text"> + 控制回复的随机性。数值越低(0.1)越聚焦,数值越高(1.5)越有创造性。 + </p> + </div> + + <div class="form-group"> + <label for="exit-condition">退出条件 *</label> + <select + id="exit-condition" + :value="localFormData.exit_condition" + @change="updateField('exit_condition', ($event.target as HTMLSelectElement).value)" + class="form-select" + :class="{ 'error': validationErrors.exit_condition }" + required + @blur="validateFieldOnChange('exit_condition')" + > + <option :value="ExitCondition.SINGLE_EXECUTION">单次执行</option> + <option :value="ExitCondition.CONTINUOUS_EXECUTION">持续执行</option> + </select> + <div v-if="validationErrors.exit_condition" class="error-message"> + {{ validationErrors.exit_condition }} + </div> + <p class="help-text"> + <template v-if="localFormData.exit_condition === ExitCondition.SINGLE_EXECUTION"> + AI 回复一次后总是进入下一个节点(默认)。 + </template> + <template v-else-if="localFormData.exit_condition === ExitCondition.CONTINUOUS_EXECUTION"> + 停留在当前节点持续对话,直到请求结束会话。 + </template> + </p> + </div> + + <!-- Transfer to Human Setting - Only for Continuous Execution --> + <div v-if="localFormData.exit_condition === ExitCondition.CONTINUOUS_EXECUTION" class="form-group"> + <label class="checkbox-group"> + <input + :checked="localFormData.auto_transfer_enabled" + @change="updateField('auto_transfer_enabled', ($event.target as HTMLInputElement).checked)" + type="checkbox" + class="form-checkbox" + /> + <span class="checkbox-label">启用自动转人工</span> + </label> + <p class="help-text"> + 启用后,AI 判断需要人工协助时可自动将会话转接给人工客服,且不会结束会话。 + </p> + + <!-- Group selection - Only when transfer is enabled --> + <div v-if="localFormData.auto_transfer_enabled" class="transfer-groups"> + <label for="transfer-group">转接分组</label> + <div v-if="!loadingGroups"> + <select + id="transfer-group" + :value="localFormData.transfer_group_id" + @change="updateField('transfer_group_id', ($event.target as HTMLSelectElement).value)" + class="form-select" + > + <option value="">选择分组</option> + <option v-for="group in userGroups" :key="group.id" :value="group.id"> + {{ group.name }} + </option> + </select> + <p v-if="userGroups.length === 0" class="help-text no-groups-message"> + 暂无可用分组。 + <router-link to="/human-agents" class="create-group-link"> + 创建分组 + </router-link> + </p> + <p v-else class="help-text"> + 选择负责处理转接会话的分组。 + </p> + </div> + <div v-else class="loading-groups"> + 正在加载分组…… + </div> + </div> + </div> + + <!-- Request Rating Setting - Only for Continuous Execution --> + <div v-if="localFormData.exit_condition === ExitCondition.CONTINUOUS_EXECUTION" class="form-group"> + <label class="checkbox-group"> + <input + :checked="localFormData.ask_for_rating" + @change="updateField('ask_for_rating', ($event.target as HTMLInputElement).checked)" + type="checkbox" + class="form-checkbox" + /> + <span class="checkbox-label">会话结束后邀请评分</span> + </label> + <p class="help-text"> + 启用后,会话结束时将邀请客户对体验进行评分。 + </p> + </div> + + <!-- Knowledge Section --> + <div class="form-group"> + <div class="knowledge-section"> + <div class="section-header knowledge-header" @click="toggleSection('knowledge')"> + <div class="section-title"> + <svg class="section-icon" :class="{ 'rotated': collapsedSections.knowledge }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <polyline points="6,9 12,15 18,9"></polyline> + </svg> + <span>知识来源</span> + </div> + </div> + + <div class="section-content knowledge-content" :class="{ 'collapsed': collapsedSections.knowledge }"> + <div class="knowledge-wrapper"> + <KnowledgeGrid + v-if="!collapsedSections.knowledge" + :agent-id="agentId" + :organization-id="organizationId" + /> + </div> + </div> + </div> + </div> + + <!-- AI Prompt Modal --> + <div v-if="showAIPrompt" class="ai-prompt-modal"> + <div class="ai-prompt-content"> + <h5>用 AI 生成指令</h5> + <textarea + v-model="aiPrompt" + placeholder="描述你希望这个 AI 节点做什么。例如:“为一个帮助查询订单的客服助手生成指令”" + rows="4" + class="ai-prompt-textarea" + ></textarea> + <div v-if="aiError" class="error-message">{{ aiError }}</div> + <div class="ai-prompt-actions"> + <button + class="cancel-ai-button" + @click="showAIPrompt = false" + :disabled="aiLoading" + type="button" + > + 取消 + </button> + <button + class="generate-ai-button" + @click="handleGenerateWithAI" + :disabled="aiLoading || !aiPrompt.trim()" + type="button" + > + {{ aiLoading ? '生成中……' : '生成' }} + </button> + </div> + </div> + </div> + </div> +</template> + +<style scoped> +/* Import shared form styles */ +.form-group { + margin-bottom: var(--space-sm); +} + +.form-group label { + display: block; + font-size: 0.8rem; + font-weight: 500; + color: var(--text-color); + margin-bottom: 4px; +} + +.form-input, +.form-textarea, +.form-select { + width: 100%; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--background-color); + color: var(--text-color); + font-size: 0.85rem; + transition: border-color 0.2s ease; +} + +.form-input:focus, +.form-textarea:focus, +.form-select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15); +} + +.form-input.error, +.form-textarea.error, +.form-select.error { + border-color: var(--error-color); + background-color: rgba(239, 68, 68, 0.05); +} + +.form-input.error:focus, +.form-textarea.error:focus, +.form-select.error:focus { + border-color: var(--error-color); + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1); +} + +.error-message { + color: var(--error-color); + font-size: 0.75rem; + margin-top: 4px; + display: flex; + align-items: center; + gap: 4px; +} + +.error-message::before { + content: "⚠"; + font-size: 0.8rem; +} + +.form-textarea { + resize: vertical; + min-height: 70px; +} + +.checkbox-group { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 8px 0; +} + +.checkbox-group input[type="checkbox"] { + margin-top: 2px; + flex-shrink: 0; +} + +.checkbox-label { + font-size: 0.85rem; + color: var(--text-color); + line-height: 1.4; +} + +.help-text { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 4px; + line-height: 1.3; +} + +.form-checkbox { + width: 16px; + height: 16px; + margin: 0; + cursor: pointer; + flex-shrink: 0; +} + +/* Instructions Header Styles */ +.instructions-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +} + +.ai-generate-button { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + border-radius: var(--radius-sm); + font-size: 0.7rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.ai-generate-button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3); +} + +.ai-generate-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.ai-icon { + font-size: 0.8rem; +} + +/* AI Prompt Modal Styles */ +.ai-prompt-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.ai-prompt-content { + background: var(--background-color); + border-radius: var(--radius-lg); + padding: var(--space-lg); + width: 90%; + max-width: 500px; + box-shadow: var(--shadow-xl); + position: relative; + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.ai-prompt-content h5 { + margin: 0 0 var(--space-sm) 0; + font-size: 1.1rem; + font-weight: 600; + color: var(--text-color); +} + +.ai-prompt-textarea { + width: 100%; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--background-soft); + color: var(--text-color); + font-size: 0.85rem; + resize: vertical; + min-height: 100px; + box-sizing: border-box; +} + +.ai-prompt-actions { + display: flex; + justify-content: flex-end; + gap: var(--space-sm); +} + +.cancel-ai-button, +.generate-ai-button { + padding: 8px 16px; + border: none; + border-radius: var(--radius-md); + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.85rem; + min-width: 80px; + white-space: nowrap; +} + +.cancel-ai-button { + background: var(--background-muted); + color: var(--text-muted); + border: 1px solid var(--border-color); +} + +.cancel-ai-button:hover { + background: var(--background-alt); + color: var(--text-color); +} + +.generate-ai-button { + background: var(--accent-solid); + color: var(--on-accent-solid); +} + +.generate-ai-button:hover { + background: var(--primary-dark); +} + +.generate-ai-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Transfer groups styling */ +.transfer-groups { + margin-top: var(--space-md); + padding: var(--space-md); + background: var(--background-alt); + border-radius: var(--radius-md); + border: 1px solid var(--border-color); +} + +.transfer-groups label { + display: block; + font-size: 0.8rem; + font-weight: 500; + color: var(--text-color); + margin-bottom: 4px; +} + +.no-groups-message { + color: var(--text-muted); + font-size: 0.75rem; + margin-top: 4px; +} + +.create-group-link { + color: var(--primary-color); + text-decoration: none; + font-weight: 500; +} + +.create-group-link:hover { + text-decoration: underline; +} + +.loading-groups { + text-align: center; + padding: var(--space-sm); + color: var(--text-muted); + font-size: 0.8rem; +} + +/* Knowledge Section Styles */ +.knowledge-section { + margin-top: var(--space-md); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--background-color); + overflow: hidden; +} + +.knowledge-header .section-header { + background: var(--background-muted); + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-sm) var(--space-md); + cursor: pointer; + transition: all 0.2s ease; +} + +.knowledge-header .section-header:hover { + background: var(--background-alt); +} + +.section-title { + display: flex; + align-items: center; + gap: var(--space-xs); + font-size: 0.9rem; + font-weight: 600; + color: var(--text-color); +} + +.section-icon { + width: 16px; + height: 16px; + color: var(--text-muted); + transition: transform 0.2s ease; +} + +.section-icon.rotated { + transform: rotate(-90deg); +} + +.knowledge-content { + padding: 0; + max-height: none; + overflow: visible; + transition: all 0.3s ease; +} + +.knowledge-content.collapsed { + max-height: 0; + padding-top: 0; + padding-bottom: 0; + opacity: 0; + overflow: hidden; +} + +.knowledge-wrapper { + position: relative; + width: 100%; + height: 100%; +} + +/* Override KnowledgeGrid styles within LLMNodeConfig */ +.knowledge-content :deep(.knowledge-grid-container) { + padding: var(--space-sm); + background: transparent; + border-top: none; +} + +.knowledge-content :deep(.knowledge-header) { + margin-bottom: var(--space-sm); + flex-direction: column; + gap: var(--space-xs); + align-items: stretch; +} + +.knowledge-content :deep(.header-left) { + flex-direction: column; + gap: var(--space-xs); + align-items: stretch; +} + +.knowledge-content :deep(.header-left h3) { + font-size: 1rem; + margin-bottom: 0; +} + +.knowledge-content :deep(.header-actions) { + display: flex; + gap: var(--space-xs); + justify-content: center; +} + +.knowledge-content :deep(.action-button) { + font-size: 0.7rem; + padding: 4px 8px; + flex: 1; + text-align: center; + white-space: nowrap; +} + +.knowledge-content :deep(.knowledge-grid) { + font-size: 0.8rem; +} + +.knowledge-content :deep(.knowledge-grid-header), +.knowledge-content :deep(.knowledge-grid-row) { + grid-template-columns: 1fr 60px; + min-height: auto; +} + +.knowledge-content :deep(.header-cell), +.knowledge-content :deep(.grid-cell) { + padding: var(--space-xs) var(--space-sm); + font-size: 0.75rem; +} + +.knowledge-content :deep(.header-cell:nth-child(2)), +.knowledge-content :deep(.header-cell:nth-child(3)), +.knowledge-content :deep(.header-cell:nth-child(4)), +.knowledge-content :deep(.grid-cell:nth-child(2)), +.knowledge-content :deep(.grid-cell:nth-child(3)), +.knowledge-content :deep(.grid-cell:nth-child(4)) { + display: none; +} + +.knowledge-content :deep(.actions-cell) { + width: 60px; + text-align: center; +} + +.knowledge-content :deep(.delete-button) { + padding: 2px; +} + +.knowledge-content :deep(.delete-icon) { + width: 16px; + height: 16px; +} + +.knowledge-content :deep(.knowledge-empty) { + padding: var(--space-md); + text-align: center; +} + +.knowledge-content :deep(.warning-message) { + font-size: 0.8rem; + margin-bottom: var(--space-xs); +} + +.knowledge-content :deep(.warning-description) { + font-size: 0.75rem; + line-height: 1.4; +} + +.knowledge-content :deep(.modal-content) { + max-width: 30vw; + max-height: 75vh; + width: 65%; +} + +/* Ensure knowledge modals have proper z-index */ +.knowledge-content :deep(.modal-overlay) { + z-index: 1001 !important; + position: fixed !important; + isolation: isolate; + background: rgba(0, 0, 0, 0.4) !important; +} + +.knowledge-content :deep(.modal-overlay), +.knowledge-content :deep(.modal-content) { + pointer-events: auto; +} + +.knowledge-content :deep(.modal-overlay) { + backdrop-filter: blur(4px); +} + +.knowledge-content :deep(.pagination) { + flex-direction: column; + gap: var(--space-xs); + margin-top: var(--space-sm); +} + +.knowledge-content :deep(.pagination-button) { + width: 100%; + font-size: 0.7rem; + padding: var(--space-xs); +} + +.knowledge-content :deep(.page-info) { + font-size: 0.7rem; + text-align: center; +} + +/* Link modal overrides */ +.knowledge-content :deep(.link-modal) { + max-width: 65vw; + width: 60%; +} + +.knowledge-content :deep(.org-knowledge-grid .knowledge-grid-header), +.knowledge-content :deep(.org-knowledge-grid .knowledge-grid-row) { + grid-template-columns: 2fr 0fr 100px; + align-items: center; +} + +.knowledge-content :deep(.org-knowledge-grid .type-cell) { + display: none; +} + +.knowledge-content :deep(.source-cell) { + padding-left: var(--space-sm); + white-space: normal; + word-break: break-word; + font-size: 0.75rem; +} + +.knowledge-content :deep(.action-cell) { + padding-right: var(--space-sm); + text-align: right; + width: 100px; +} + +.knowledge-content :deep(.link-button), +.knowledge-content :deep(.unlink-button) { + min-width: 70px; + padding: 4px 8px; + font-size: 0.7rem; + display: inline-block; +} + +.knowledge-content :deep(.org-knowledge-grid) .action-cell { + display: block !important; + visibility: visible !important; +} + +.knowledge-content :deep(.org-knowledge-grid) .link-button, +.knowledge-content :deep(.org-knowledge-grid) .unlink-button { + display: inline-block !important; + visibility: visible !important; + opacity: 1 !important; +} + +@media (max-width: 768px) { + .knowledge-content :deep(.knowledge-grid-container) { + padding: 4px; + } + + .knowledge-content :deep(.header-actions) { + flex-direction: column; + gap: 4px; + } + + .knowledge-content :deep(.action-button) { + font-size: 0.65rem; + padding: 3px 6px; + } + + .knowledge-content :deep(.org-knowledge-grid .knowledge-grid-header), + .knowledge-content :deep(.org-knowledge-grid .knowledge-grid-row) { + grid-template-columns: 2fr 0fr 100px !important; + } + + .knowledge-content :deep(.org-knowledge-grid) .action-cell { + display: block !important; + grid-column: 3 !important; + } +} +</style> \ No newline at end of file diff --git a/frontend/src/components/workflow/nodes/LandingPageNodeConfig.vue b/frontend/src/components/workflow/nodes/LandingPageNodeConfig.vue new file mode 100644 index 0000000..589c988 --- /dev/null +++ b/frontend/src/components/workflow/nodes/LandingPageNodeConfig.vue @@ -0,0 +1,156 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed } from 'vue' + +interface LandingPageNodeData { + landing_page_heading: string + landing_page_content: string +} + +const props = defineProps<{ + modelValue: LandingPageNodeData + validationErrors: Record<string, string> +}>() + +const emit = defineEmits<{ + (e: 'update:model-value', value: LandingPageNodeData): void + (e: 'validate-field', field: string): void +}>() + +const formData = computed({ + get: () => props.modelValue, + set: (value: LandingPageNodeData) => emit('update:model-value', value) +}) + +// Update form data +const updateFormData = (field: keyof LandingPageNodeData, value: any) => { + formData.value = { + ...formData.value, + [field]: value + } + + emit('validate-field', field) +} +</script> + +<template> + <div class="landing-page-node-config"> + <div class="form-group"> + <label for="landing-page-heading">标题 *</label> + <input + id="landing-page-heading" + :value="formData.landing_page_heading" + @input="updateFormData('landing_page_heading', ($event.target as HTMLInputElement).value)" + @blur="$emit('validate-field', 'landing_page_heading')" + class="form-input" + :class="{ 'error': validationErrors.landing_page_heading }" + placeholder="输入落地页的欢迎标题" + required + /> + <div v-if="validationErrors.landing_page_heading" class="error-message"> + {{ validationErrors.landing_page_heading }} + </div> + </div> + + <div class="form-group"> + <label for="landing-page-content">内容 *</label> + <textarea + id="landing-page-content" + :value="formData.landing_page_content" + @input="updateFormData('landing_page_content', ($event.target as HTMLTextAreaElement).value)" + @blur="$emit('validate-field', 'landing_page_content')" + class="form-textarea" + :class="{ 'error': validationErrors.landing_page_content }" + placeholder="输入落地页内容,将显示在标题下方。" + rows="4" + required + ></textarea> + <div v-if="validationErrors.landing_page_content" class="error-message"> + {{ validationErrors.landing_page_content }} + </div> + </div> + </div> +</template> + +<style scoped> +.landing-page-node-config { + width: 100%; +} + +.form-group { + margin-bottom: var(--space-sm); +} + +.form-group label { + display: block; + font-size: 0.8rem; + font-weight: 500; + color: var(--text-color); + margin-bottom: 4px; +} + +.form-input, +.form-textarea { + width: 100%; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--background-color); + color: var(--text-color); + font-size: 0.85rem; + transition: border-color 0.2s ease; +} + +.form-input:focus, +.form-textarea:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15); +} + +.form-input.error, +.form-textarea.error { + border-color: var(--error-color); + background-color: rgba(239, 68, 68, 0.05); +} + +.form-input.error:focus, +.form-textarea.error:focus { + border-color: var(--error-color); + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1); +} + +.form-textarea { + resize: vertical; + min-height: 70px; +} + +.error-message { + color: var(--error-color); + font-size: 0.75rem; + margin-top: 4px; + display: flex; + align-items: center; + gap: 4px; +} + +.error-message::before { + content: "⚠"; + font-size: 0.8rem; +} +</style> \ No newline at end of file diff --git a/frontend/src/components/workflow/nodes/MessageNode.vue b/frontend/src/components/workflow/nodes/MessageNode.vue new file mode 100644 index 0000000..d0216be --- /dev/null +++ b/frontend/src/components/workflow/nodes/MessageNode.vue @@ -0,0 +1,140 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { Handle, Position } from '@vue-flow/core' +import type { Node } from '@vue-flow/core' + +defineProps<{ + data: Node['data'] +}>() +</script> + +<template> + <div class="message-node"> + <Handle + id="input" + type="target" + :position="Position.Left" + class="node-handle" + /> + + <div class="node-content"> + <div class="node-header"> + <div class="node-icon">💬</div> + <div class="node-title">{{ data.label || '消息' }}</div> + </div> + + <div v-if="data.description" class="node-description"> + {{ data.description }} + </div> + + <div v-if="data.config?.message" class="node-preview"> + <div class="preview-label">消息:</div> + <div class="preview-content"> + {{ data.config.message.substring(0, 100) }}{{ data.config.message.length > 100 ? '...' : '' }} + </div> + </div> + </div> + + <Handle + id="output" + type="source" + :position="Position.Right" + class="node-handle" + /> + </div> +</template> + +<style scoped> +.message-node { + position: relative; + background: var(--background-color); + border: 2px solid #3B82F6; + border-radius: var(--radius-lg); + padding: var(--space-md); + min-width: 200px; + max-width: 300px; + box-shadow: var(--shadow-md); + transition: all 0.2s ease; +} + +.message-node:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.node-content { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.node-header { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.node-icon { + font-size: 1.2rem; + flex-shrink: 0; +} + +.node-title { + font-weight: 600; + color: var(--text-color); + font-size: var(--text-sm); +} + +.node-description { + color: var(--text-muted); + font-size: 0.75rem; + line-height: 1.3; +} + +.node-preview { + background: var(--background-soft); + border-radius: var(--radius-sm); + padding: var(--space-sm); + border: 1px solid var(--border-color); +} + +.preview-label { + font-size: 0.7rem; + color: var(--text-muted); + font-weight: 500; + margin-bottom: 2px; +} + +.preview-content { + font-size: 0.7rem; + color: var(--text-color); + line-height: 1.3; +} + +.node-handle { + background: #3B82F6; + border: 2px solid var(--background-color); + width: 12px; + height: 12px; +} + +.node-handle:hover { + background: #2563EB; + transform: scale(1.2); +} +</style> \ No newline at end of file diff --git a/frontend/src/components/workflow/nodes/MessageNodeConfig.vue b/frontend/src/components/workflow/nodes/MessageNodeConfig.vue new file mode 100644 index 0000000..7a2dfd2 --- /dev/null +++ b/frontend/src/components/workflow/nodes/MessageNodeConfig.vue @@ -0,0 +1,175 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed } from 'vue' + +interface MessageNodeData { + message_text: string + show_typing: boolean +} + +const props = defineProps<{ + modelValue: MessageNodeData + validationErrors: Record<string, string> +}>() + +const emit = defineEmits<{ + (e: 'update:model-value', value: MessageNodeData): void + (e: 'validate-field', field: string): void +}>() + +const formData = computed({ + get: () => props.modelValue, + set: (value: MessageNodeData) => emit('update:model-value', value) +}) + +// Update form data +const updateFormData = (field: keyof MessageNodeData, value: any) => { + formData.value = { + ...formData.value, + [field]: value + } + + emit('validate-field', field) +} +</script> + +<template> + <div class="message-node-config"> + <div class="form-group"> + <label for="message-text">消息内容 *</label> + <textarea + id="message-text" + :value="formData.message_text" + @input="updateFormData('message_text', ($event.target as HTMLTextAreaElement).value)" + @blur="$emit('validate-field', 'message_text')" + class="form-textarea" + :class="{ 'error': validationErrors.message_text }" + placeholder="输入要发送给用户的消息" + rows="4" + required + ></textarea> + <div v-if="validationErrors.message_text" class="error-message"> + {{ validationErrors.message_text }} + </div> + </div> + + <div class="form-group"> + <div class="checkbox-group"> + <label class="checkbox-label"> + <input + type="checkbox" + :checked="formData.show_typing" + @change="updateFormData('show_typing', ($event.target as HTMLInputElement).checked)" + class="form-checkbox" + /> + <span>显示输入中提示</span> + </label> + </div> + </div> + </div> +</template> + +<style scoped> +.message-node-config { + width: 100%; +} + +.form-group { + margin-bottom: var(--space-sm); +} + +.form-group label { + display: block; + font-size: 0.8rem; + font-weight: 500; + color: var(--text-color); + margin-bottom: 4px; +} + +.form-textarea { + width: 100%; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--background-color); + color: var(--text-color); + font-size: 0.85rem; + transition: border-color 0.2s ease; + resize: vertical; + min-height: 70px; +} + +.form-textarea:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15); +} + +.form-textarea.error { + border-color: var(--error-color); + background-color: rgba(239, 68, 68, 0.05); +} + +.form-textarea.error:focus { + border-color: var(--error-color); + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1); +} + +.checkbox-group { + margin-top: 8px; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-weight: normal; + margin-bottom: 0; + padding: 4px 0; +} + +.form-checkbox { + width: 16px; + height: 16px; + margin: 0; + cursor: pointer; + flex-shrink: 0; +} + +.checkbox-label span { + font-size: 0.85rem; + color: var(--text-color); + line-height: 1.4; + user-select: none; +} + +.error-message { + color: var(--error-color); + font-size: 0.75rem; + margin-top: 4px; + display: flex; + align-items: center; + gap: 4px; +} + +.error-message::before { + content: "⚠"; + font-size: 0.8rem; +} +</style> \ No newline at end of file diff --git a/frontend/src/components/workflow/nodes/UserInputNodeConfig.vue b/frontend/src/components/workflow/nodes/UserInputNodeConfig.vue new file mode 100644 index 0000000..3f8cae7 --- /dev/null +++ b/frontend/src/components/workflow/nodes/UserInputNodeConfig.vue @@ -0,0 +1,223 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed } from 'vue' + +interface UserInputNodeData { + prompt_message: string + confirmation_message: string +} + +const props = defineProps<{ + modelValue: UserInputNodeData + validationErrors: Record<string, string> +}>() + +const emit = defineEmits<{ + (e: 'update:modelValue', value: UserInputNodeData): void + (e: 'validateField', field: string): void +}>() + +// Computed properties for two-way binding +const promptMessage = computed({ + get: () => props.modelValue.prompt_message, + set: (value: string) => { + emit('update:modelValue', { ...props.modelValue, prompt_message: value }) + emit('validateField', 'prompt_message') + } +}) + +const confirmationMessage = computed({ + get: () => props.modelValue.confirmation_message, + set: (value: string) => { + emit('update:modelValue', { ...props.modelValue, confirmation_message: value }) + emit('validateField', 'confirmation_message') + } +}) +</script> + +<template> + <div class="user-input-config"> + <div class="form-group"> + <label for="prompt-message">提示消息</label> + <textarea + id="prompt-message" + v-model="promptMessage" + class="form-textarea" + :class="{ 'error': validationErrors.prompt_message }" + placeholder="输入提示用户填写内容的消息(可选)" + rows="3" + @blur="$emit('validateField', 'prompt_message')" + @input="$emit('validateField', 'prompt_message')" + ></textarea> + <div v-if="validationErrors.prompt_message" class="error-message"> + {{ validationErrors.prompt_message }} + </div> + <div class="help-text"> + 用于提示用户填写内容的消息,可选。留空则不显示消息。 + </div> + </div> + + <div class="form-group"> + <label for="confirmation-message">确认消息</label> + <textarea + id="confirmation-message" + v-model="confirmationMessage" + class="form-textarea" + placeholder="用户提交内容后显示的消息(可选,例如“感谢您的填写!”)" + rows="2" + ></textarea> + <div class="help-text"> + 用户提交内容后显示的消息,可选。留空则直接进入下一个节点。 + </div> + </div> + + <div class="info-section"> + <div class="info-header"> + <svg class="info-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <circle cx="12" cy="12" r="10"></circle> + <path d="M9,9h0a3,3,0,0,1,6,0c0,2-3,3-3,3"></path> + <path d="M12,17h0"></path> + </svg> + <span>用户输入节点行为</span> + </div> + <div class="info-content"> + <ul> + <li>工作流会在此节点暂停,等待用户输入</li> + <li>提示消息会展示给用户</li> + <li>用户输入后,工作流继续执行下一个节点</li> + <li>后续节点可通过变量引用用户输入</li> + </ul> + </div> + </div> + </div> +</template> + +<style scoped> +.user-input-config { + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.form-group { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.form-group label { + font-size: 0.8rem; + font-weight: 500; + color: var(--text-color); +} + +.form-textarea { + width: 100%; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--background-color); + color: var(--text-color); + font-size: 0.85rem; + transition: border-color 0.2s ease; + resize: vertical; + min-height: 60px; + font-family: inherit; +} + +.form-textarea:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15); +} + +.form-textarea.error { + border-color: var(--error-color); + background-color: rgba(239, 68, 68, 0.05); +} + +.form-textarea.error:focus { + border-color: var(--error-color); + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1); +} + +.error-message { + color: var(--error-color); + font-size: 0.75rem; + margin-top: 4px; + display: flex; + align-items: center; + gap: 4px; +} + +.error-message::before { + content: "⚠"; + font-size: 0.8rem; +} + +.help-text { + font-size: 0.75rem; + color: var(--text-muted); + line-height: 1.3; +} + +.info-section { + margin-top: var(--space-sm); + padding: var(--space-sm); + background: var(--background-soft); + border-radius: var(--radius-md); + border: 1px solid var(--border-color); +} + +.info-header { + display: flex; + align-items: center; + gap: var(--space-xs); + margin-bottom: var(--space-xs); + font-size: 0.8rem; + font-weight: 500; + color: var(--text-color); +} + +.info-icon { + width: 16px; + height: 16px; + color: var(--primary-color); + flex-shrink: 0; +} + +.info-content { + font-size: 0.75rem; + color: var(--text-muted); + line-height: 1.4; +} + +.info-content ul { + margin: 0; + padding-left: var(--space-md); + list-style-type: disc; +} + +.info-content li { + margin-bottom: var(--space-xs); +} + +.info-content li:last-child { + margin-bottom: 0; +} +</style> \ No newline at end of file diff --git a/frontend/src/components/workflow/nodes/WaitNode.vue b/frontend/src/components/workflow/nodes/WaitNode.vue new file mode 100644 index 0000000..c507fe8 --- /dev/null +++ b/frontend/src/components/workflow/nodes/WaitNode.vue @@ -0,0 +1,143 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { Handle, Position } from '@vue-flow/core' +import type { Node } from '@vue-flow/core' + +defineProps<{ + data: Node['data'] +}>() +</script> + +<template> + <div class="wait-node"> + <Handle + id="input" + type="target" + :position="Position.Left" + class="node-handle" + /> + + <div class="node-content"> + <div class="node-header"> + <div class="node-icon">⏱️</div> + <div class="node-title">{{ data.label || '等待' }}</div> + </div> + + <div v-if="data.description" class="node-description"> + {{ data.description }} + </div> + + <div v-if="data.config?.duration" class="node-preview"> + <div class="preview-label">时长:</div> + <div class="preview-content">{{ data.config.duration }} {{ data.config.unit || '秒' }}</div> + </div> + + <div v-if="data.config?.waitType" class="node-preview"> + <div class="preview-label">等待类型:</div> + <div class="preview-content">{{ data.config.waitType }}</div> + </div> + </div> + + <Handle + id="output" + type="source" + :position="Position.Right" + class="node-handle" + /> + </div> +</template> + +<style scoped> +.wait-node { + position: relative; + background: var(--background-color); + border: 2px solid #6B7280; + border-radius: var(--radius-lg); + padding: var(--space-md); + min-width: 200px; + max-width: 300px; + box-shadow: var(--shadow-md); + transition: all 0.2s ease; +} + +.wait-node:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.node-content { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.node-header { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.node-icon { + font-size: 1.2rem; + flex-shrink: 0; +} + +.node-title { + font-weight: 600; + color: var(--text-color); + font-size: var(--text-sm); +} + +.node-description { + color: var(--text-muted); + font-size: 0.75rem; + line-height: 1.3; +} + +.node-preview { + background: var(--background-soft); + border-radius: var(--radius-sm); + padding: var(--space-sm); + border: 1px solid var(--border-color); +} + +.preview-label { + font-size: 0.7rem; + color: var(--text-muted); + font-weight: 500; + margin-bottom: 2px; +} + +.preview-content { + font-size: 0.7rem; + color: var(--text-color); + line-height: 1.3; +} + +.node-handle { + background: #6B7280; + border: 2px solid var(--background-color); + width: 12px; + height: 12px; +} + +.node-handle:hover { + background: #4B5563; + transform: scale(1.2); +} +</style> \ No newline at end of file diff --git a/frontend/src/components/workflow/nodes/WaitNodeConfig.vue b/frontend/src/components/workflow/nodes/WaitNodeConfig.vue new file mode 100644 index 0000000..690c00b --- /dev/null +++ b/frontend/src/components/workflow/nodes/WaitNodeConfig.vue @@ -0,0 +1,155 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed } from 'vue' + +interface WaitNodeData { + wait_duration: number + wait_unit: string +} + +const props = defineProps<{ + modelValue: WaitNodeData + validationErrors: Record<string, string> +}>() + +const emit = defineEmits<{ + (e: 'update:model-value', value: WaitNodeData): void + (e: 'validate-field', field: string): void +}>() + +const formData = computed({ + get: () => props.modelValue, + set: (value: WaitNodeData) => emit('update:model-value', value) +}) + +// Update form data +const updateFormData = (field: keyof WaitNodeData, value: any) => { + formData.value = { + ...formData.value, + [field]: value + } + + emit('validate-field', field) +} +</script> + +<template> + <div class="wait-node-config"> + <div class="form-group"> + <label for="wait-duration">时长 *</label> + <input + id="wait-duration" + :value="formData.wait_duration" + @input="updateFormData('wait_duration', parseInt(($event.target as HTMLInputElement).value) || 1)" + @blur="$emit('validate-field', 'wait_duration')" + type="number" + class="form-input" + :class="{ 'error': validationErrors.wait_duration }" + min="1" + placeholder="5" + required + /> + <div v-if="validationErrors.wait_duration" class="error-message"> + {{ validationErrors.wait_duration }} + </div> + </div> + + <div class="form-group"> + <label for="wait-unit">时间单位 *</label> + <select + id="wait-unit" + :value="formData.wait_unit" + @change="updateFormData('wait_unit', ($event.target as HTMLSelectElement).value)" + @blur="$emit('validate-field', 'wait_unit')" + class="form-select" + :class="{ 'error': validationErrors.wait_unit }" + required + > + <option value="seconds">秒</option> + <option value="minutes">分钟</option> + <option value="hours">小时</option> + </select> + <div v-if="validationErrors.wait_unit" class="error-message"> + {{ validationErrors.wait_unit }} + </div> + </div> + </div> +</template> + +<style scoped> +.wait-node-config { + width: 100%; +} + +.form-group { + margin-bottom: var(--space-sm); +} + +.form-group label { + display: block; + font-size: 0.8rem; + font-weight: 500; + color: var(--text-color); + margin-bottom: 4px; +} + +.form-input, +.form-select { + width: 100%; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--background-color); + color: var(--text-color); + font-size: 0.85rem; + transition: border-color 0.2s ease; +} + +.form-input:focus, +.form-select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15); +} + +.form-input.error, +.form-select.error { + border-color: var(--error-color); + background-color: rgba(239, 68, 68, 0.05); +} + +.form-input.error:focus, +.form-select.error:focus { + border-color: var(--error-color); + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1); +} + +.error-message { + color: var(--error-color); + font-size: 0.75rem; + margin-top: 4px; + display: flex; + align-items: center; + gap: 4px; +} + +.error-message::before { + content: "⚠"; + font-size: 0.8rem; +} +</style> \ No newline at end of file diff --git a/frontend/src/components/workflow/nodes/index.ts b/frontend/src/components/workflow/nodes/index.ts new file mode 100644 index 0000000..21e500e --- /dev/null +++ b/frontend/src/components/workflow/nodes/index.ts @@ -0,0 +1,28 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import type { App } from 'vue' +// TODO: Import node editors when they are created +// import MessageNodeEditor from './MessageNodeEditor.vue' +// import LlmNodeEditor from './LlmNodeEditor.vue' + +export default { + install(app: App) { + // TODO: Register node editors when they are created + // app.component('message-node-editor', MessageNodeEditor) + // app.component('llm-node-editor', LlmNodeEditor) + } +} \ No newline at end of file diff --git a/frontend/src/composables/useFaqWorkspace.ts b/frontend/src/composables/useFaqWorkspace.ts new file mode 100644 index 0000000..8a0ba23 --- /dev/null +++ b/frontend/src/composables/useFaqWorkspace.ts @@ -0,0 +1,496 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { computed, ref } from 'vue' +import { toast } from 'vue-sonner' + +import { faqService } from '@/services/faq' +import { knowledgeService } from '@/services/knowledge' +import type { FaqGenerationJob, FaqImportMode, FaqItem, FaqStatus, GenerateEstimate, HelpCenterSettings } from '@/types/faq' + +export type WorkspacePhase = 'loading' | 'empty' | 'generating' | 'populated' + +// Fast tick while a job is running; relaxed tick while a domain/SSL state is +// pending. Idle = no requests at all. +const ACTIVE_POLL_MS = 3000 +const IDLE_POLL_MS = 10000 + +export function useFaqWorkspace(organizationId: () => string | undefined) { + const faqs = ref<FaqItem[]>([]) + const job = ref<FaqGenerationJob | null>(null) + const settings = ref<HelpCenterSettings | null>(null) + const estimate = ref<GenerateEstimate | null>(null) + const sourceCount = ref(0) + const pageCount = ref(0) + const isLoading = ref(false) + const loadedOnce = ref(false) + + // Multi-select state for bulk publish/unpublish/delete. + const selectedIds = ref<Set<string>>(new Set()) + const selectionActive = computed(() => selectedIds.value.size > 0) + + function toggleSelect(id: string): void { + const next = new Set(selectedIds.value) + if (next.has(id)) next.delete(id) + else next.add(id) + selectedIds.value = next + } + + function setSelected(items: FaqItem[], on: boolean): void { + const next = new Set(selectedIds.value) + for (const item of items) { + if (on) next.add(item.id) + else next.delete(item.id) + } + selectedIds.value = next + } + + function clearSelection(): void { + selectedIds.value = new Set() + } + + async function bulkSetStatus(status: FaqStatus): Promise<void> { + const ids = [...selectedIds.value] + if (!ids.length) return + try { + const updated = await faqService.setStatus(ids, status) + faqs.value = faqs.value.map((f) => (selectedIds.value.has(f.id) ? { ...f, status } : f)) + clearSelection() + toast.success(`${updated} 条 FAQ ${status === 'published' ? '已发布' : '已移至草稿'}`) + } catch (error: any) { + toast.error(error.message) + // A batch may have partially applied server-side (>200 selection splits + // into requests) — refetch so the list matches the backend. + await refresh() + } + } + + async function bulkDelete(): Promise<void> { + const ids = [...selectedIds.value] + if (!ids.length) return + try { + const deleted = await faqService.bulkDelete(ids) + faqs.value = faqs.value.filter((f) => !selectedIds.value.has(f.id)) + clearSelection() + toast.success(`${deleted} 条 FAQ 已删除`) + // Deleting a source's FAQs makes it eligible for generation again — the + // Generate button's new-source count must not stay stale/disabled. + void fetchEstimate() + } catch (error: any) { + toast.error(error.message) + await refresh() // partial batch may have deleted server-side; resync + } + } + + // Inline edit state (one card at a time). + const editingId = ref<string | null>(null) + const isNewFaq = ref(false) + const draftQuestion = ref('') + const draftAnswer = ref('') + // Topic (stored as the FAQ's free-form category). Empty on create means the + // server default; empty on edit means "keep the current topic". + const draftCategory = ref('') + // Per-article SEO overrides. Empty means "derive it" — the server normalizes + // a hand-typed slug and treats blanks as cleared, so no client-side slugify. + const draftSlug = ref('') + const draftUrlPath = ref('') + const draftMetaTitle = ref('') + const draftMetaDescription = ref('') + const isSaving = ref(false) + + let pollTimer: ReturnType<typeof setInterval> | null = null + let lastPollAt = 0 + + const isJobActive = computed( + () => job.value?.status === 'pending' || job.value?.status === 'processing', + ) + + const phase = computed<WorkspacePhase>(() => { + if (isLoading.value && !loadedOnce.value) return 'loading' + // Regenerating with existing FAQs keeps the list visible — only the + // generate bar flips; the full progress card is for first-run only. + if (isJobActive.value && faqs.value.length === 0) return 'generating' + return faqs.value.length > 0 ? 'populated' : 'empty' + }) + + const barPhase = computed<'idle' | 'generating' | 'ready'>(() => { + if (isJobActive.value) return 'generating' + return faqs.value.length > 0 ? 'ready' : 'idle' + }) + + const publishedCount = computed(() => faqs.value.filter((f) => f.status === 'published').length) + + // ---- search + filter + collapse (all client-side over the loaded set) ---- + const searchQuery = ref('') + const categoryFilter = ref<string | null>(null) + const statusFilter = ref<'all' | FaqStatus>('all') + const collapsedCategories = ref<Set<string>>(new Set()) + + const categories = computed(() => + [...new Set(faqs.value.map((f) => f.category))].sort((a, b) => a.localeCompare(b)), + ) + + const hasActiveFilters = computed( + () => + searchQuery.value.trim() !== '' || + categoryFilter.value !== null || + statusFilter.value !== 'all', + ) + + const filteredFaqs = computed(() => { + // Tokenized: every term must appear somewhere (question/answer/category), + // so "close my" matches "How to close my account". + const terms = searchQuery.value.trim().toLowerCase().split(/\s+/).filter(Boolean) + return faqs.value.filter((f) => { + if (categoryFilter.value && f.category !== categoryFilter.value) return false + if (statusFilter.value !== 'all' && f.status !== statusFilter.value) return false + if (terms.length) { + const haystack = `${f.question} ${f.answer} ${f.category}`.toLowerCase() + if (!terms.every((t) => haystack.includes(t))) return false + } + return true + }) + }) + + const filteredCount = computed(() => filteredFaqs.value.length) + + const groupedFaqs = computed(() => { + const groups = new Map<string, FaqItem[]>() + for (const faq of filteredFaqs.value) { + const list = groups.get(faq.category) || [] + list.push(faq) + groups.set(faq.category, list) + } + return groups + }) + + function resetFilters(): void { + searchQuery.value = '' + categoryFilter.value = null + statusFilter.value = 'all' + } + + function toggleCategory(category: string): void { + const next = new Set(collapsedCategories.value) + if (next.has(category)) next.delete(category) + else next.add(category) + collapsedCategories.value = next + } + + // While filtering/searching, always show matches regardless of collapse state. + function isCategoryOpen(category: string): boolean { + return hasActiveFilters.value || !collapsedCategories.value.has(category) + } + + const domainPending = computed(() => { + const domain = settings.value?.domain + if (!domain?.custom_domain) return false + return domain.domain_status !== 'verified' || domain.ssl_status === 'pending' + }) + + // Bounded safety cap; a curated FAQ list should never approach this. + const MAX_FAQ_PAGES = 10 + + async function fetchFaqs(): Promise<void> { + const all: FaqItem[] = [] + let page = 1 + for (;;) { + const response = await faqService.getFaqs({ page }) + all.push(...response.faqs) + if (page >= response.pagination.total_pages || page >= MAX_FAQ_PAGES) break + page += 1 + } + faqs.value = all + } + + async function fetchJob(): Promise<void> { + job.value = await faqService.getJob(true) + } + + async function fetchSettings(): Promise<void> { + settings.value = await faqService.getSettings() + } + + async function fetchEstimate(includePages = false): Promise<void> { + // Background fetches (page load, deletes, job completion) skip the + // per-source page scan — the button label only needs new_sources. The + // confirm dialog passes includePages=true for the full call estimate. + // Non-fatal: locked plans 403 here; the generate button then just says + // "Generate" without the new-source count. + try { + estimate.value = await faqService.getGenerateEstimate(includePages) + } catch { + estimate.value = null + } + } + + async function fetchCounts(): Promise<void> { + const orgId = organizationId() + if (!orgId) return + const response = await knowledgeService.getKnowledgeByOrganization(orgId, 1, 100) + const items = response.knowledge || [] + sourceCount.value = response.pagination?.total ?? response.pagination?.total_count ?? items.length + pageCount.value = items.reduce( + (sum: number, item: { pages?: unknown[] }) => sum + (item.pages?.length || 1), + 0, + ) + } + + async function refresh(): Promise<void> { + isLoading.value = true + clearSelection() + try { + await Promise.all([fetchFaqs(), fetchJob(), fetchSettings(), fetchCounts(), fetchEstimate()]) + loadedOnce.value = true + } catch (error: any) { + toast.error(error.message) + } finally { + isLoading.value = false + } + } + + async function pollTick(): Promise<void> { + const interval = isJobActive.value ? ACTIVE_POLL_MS : IDLE_POLL_MS + if (!isJobActive.value && !domainPending.value) return + const now = Date.now() + if (now - lastPollAt < interval - 100) return + lastPollAt = now + try { + if (isJobActive.value) { + const wasActive = job.value?.id + await fetchJob() + const stillActive = job.value?.status === 'pending' || job.value?.status === 'processing' + if (wasActive && !stillActive) { + await Promise.all([fetchFaqs(), fetchSettings(), fetchEstimate()]) + const finished = await faqService.getJob(false) + if (finished?.status === 'failed') { + toast.error(finished.error || 'FAQ 生成失败') + } else if (finished) { + toast.success( + finished.faqs_created + ? `${finished.faqs_created} 条草稿 FAQ 待审核` + : '没有发现新的 FAQ,现有 FAQ 已覆盖这些内容', + ) + } + } + } else if (domainPending.value) { + await fetchSettings() + } + } catch { + // Transient polling errors are silent; the next tick retries. + } + } + + function startPolling(): void { + stopPolling() + pollTimer = setInterval(pollTick, ACTIVE_POLL_MS) + } + + function stopPolling(): void { + if (pollTimer) { + clearInterval(pollTimer) + pollTimer = null + } + } + + async function startGeneration(knowledgeIds?: number[]): Promise<boolean> { + try { + job.value = await faqService.startGeneration(knowledgeIds) + return true + } catch (error: any) { + toast.error(error.message) + return false + } + } + + async function submitImport( + url: string, + mode: FaqImportMode = 'qa', + preserveUrls = false, + ): Promise<boolean> { + try { + job.value = await faqService.importFaq(url, mode, preserveUrls) + toast.success('导入已开始,完成后会生成草稿') + return true + } catch (error: any) { + toast.error(error.message) + return false + } + } + + async function submitPdfImport(file: File): Promise<boolean> { + try { + job.value = await faqService.importPdf(file) + toast.success('导入已开始,完成后会生成草稿') + return true + } catch (error: any) { + toast.error(error.message) + return false + } + } + + async function togglePublish(faq: FaqItem): Promise<void> { + const nextStatus = faq.status === 'published' ? 'draft' : 'published' + const previous = faq.status + faq.status = nextStatus // optimistic + try { + await faqService.setStatus([faq.id], nextStatus) + } catch (error: any) { + faq.status = previous + toast.error(error.message) + } + } + + function setSeoDraft(faq: FaqItem | null): void { + draftSlug.value = faq?.slug || '' + draftUrlPath.value = faq?.url_path || '' + draftMetaTitle.value = faq?.meta_title || '' + draftMetaDescription.value = faq?.meta_description || '' + } + + function startEdit(faq: FaqItem): void { + editingId.value = faq.id + isNewFaq.value = false + draftQuestion.value = faq.question + draftAnswer.value = faq.answer + draftCategory.value = faq.category + setSeoDraft(faq) + } + + function startAdd(): void { + editingId.value = 'new' + isNewFaq.value = true + draftQuestion.value = '' + draftAnswer.value = '' + draftCategory.value = '' + setSeoDraft(null) + } + + function cancelEdit(): void { + editingId.value = null + isNewFaq.value = false + draftQuestion.value = '' + draftAnswer.value = '' + draftCategory.value = '' + setSeoDraft(null) + } + + async function saveEdit(): Promise<void> { + const question = draftQuestion.value.trim() + const answer = draftAnswer.value.trim() + if (!question || !answer) { + toast.error('问题和答案均为必填') + return + } + // Blank SEO fields are sent through so clearing one in the UI clears it + // server-side, restoring the derived title/description (and, for the slug, + // keeping whatever is already assigned). + const seo = { + slug: draftSlug.value.trim(), + url_path: draftUrlPath.value.trim(), + meta_title: draftMetaTitle.value.trim(), + meta_description: draftMetaDescription.value.trim(), + } + // The server rejects a blank category, so only send one when set: on + // create a blank falls back to the server default, on edit it keeps the + // FAQ's current topic. + const category = draftCategory.value.trim() + const withCategory = category ? { category } : {} + isSaving.value = true + try { + if (isNewFaq.value) { + const created = await faqService.createFaq({ question, answer, ...withCategory, ...seo }) + faqs.value = [...faqs.value, created] + } else if (editingId.value) { + const updated = await faqService.updateFaq(editingId.value, { question, answer, ...withCategory, ...seo }) + faqs.value = faqs.value.map((f) => (f.id === updated.id ? updated : f)) + } + cancelEdit() + } catch (error: any) { + toast.error(error.message) + } finally { + isSaving.value = false + } + } + + async function deleteFaq(faq: FaqItem): Promise<void> { + try { + await faqService.deleteFaq(faq.id) + faqs.value = faqs.value.filter((f) => f.id !== faq.id) + if (editingId.value === faq.id) cancelEdit() + // Keep the Generate button's new-source count fresh (see bulkDelete). + void fetchEstimate() + } catch (error: any) { + toast.error(error.message) + } + } + + return { + faqs, + job, + settings, + estimate, + fetchEstimate, + sourceCount, + pageCount, + isLoading, + phase, + barPhase, + publishedCount, + groupedFaqs, + searchQuery, + categoryFilter, + statusFilter, + categories, + hasActiveFilters, + filteredCount, + resetFilters, + toggleCategory, + isCategoryOpen, + isJobActive, + selectedIds, + selectionActive, + toggleSelect, + setSelected, + clearSelection, + bulkSetStatus, + bulkDelete, + editingId, + isNewFaq, + draftQuestion, + draftAnswer, + draftCategory, + draftSlug, + draftUrlPath, + draftMetaTitle, + draftMetaDescription, + isSaving, + refresh, + fetchSettings, + startPolling, + stopPolling, + pollTick, + startGeneration, + submitImport, + submitPdfImport, + togglePublish, + startEdit, + startAdd, + cancelEdit, + saveEdit, + deleteFaq, + } +} diff --git a/frontend/src/composables/useHelpCenterSettings.ts b/frontend/src/composables/useHelpCenterSettings.ts new file mode 100644 index 0000000..280a111 --- /dev/null +++ b/frontend/src/composables/useHelpCenterSettings.ts @@ -0,0 +1,205 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { computed, ref, watch, type Ref } from 'vue' +import { toast } from 'vue-sonner' + +import { faqService } from '@/services/faq' +import type { HelpCenterSettings, HelpCenterSettingsUpdate } from '@/types/faq' + +export type SaveState = 'idle' | 'saving' | 'saved' | 'error' + +const AUTOSAVE_DEBOUNCE_MS = 800 +const MAX_LOGO_BYTES = 2 * 1024 * 1024 +const MAX_FAVICON_BYTES = 1 * 1024 * 1024 + +/** Appearance/settings editing with debounced autosave — the design has no + * Save button ("changes apply instantly to your published help center"). */ +export function useHelpCenterSettings(settings: Ref<HelpCenterSettings | null>) { + const saveState = ref<SaveState>('idle') + + let debounceTimer: ReturnType<typeof setTimeout> | null = null + let saveInFlight = false + let trailingSave: HelpCenterSettingsUpdate | null = null + let suppressWatch = false + + const brandColor = computed(() => settings.value?.brand_color || '#4338CA') + + function applyResponse(updated: HelpCenterSettings): void { + suppressWatch = true + settings.value = updated + // Release after the watcher has seen (and ignored) this assignment. + setTimeout(() => { + suppressWatch = false + }, 0) + } + + async function save(payload: HelpCenterSettingsUpdate): Promise<void> { + if (saveInFlight) { + trailingSave = { ...(trailingSave || {}), ...payload } + return + } + saveInFlight = true + saveState.value = 'saving' + try { + applyResponse(await faqService.updateSettings(payload)) + saveState.value = 'saved' + } catch (error: any) { + saveState.value = 'error' + toast.error(error.message) + } finally { + saveInFlight = false + if (trailingSave) { + const queued = trailingSave + trailingSave = null + void save(queued) + } + } + } + + /** Debounced path for text inputs (links, CTA, labels). */ + function queueSave(payload: HelpCenterSettingsUpdate): void { + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => void save(payload), AUTOSAVE_DEBOUNCE_MS) + } + + /** Immediate path for discrete actions (swatch click, toggles, selects). */ + async function saveNow(payload: HelpCenterSettingsUpdate): Promise<void> { + if (debounceTimer) clearTimeout(debounceTimer) + await save(payload) + } + + // Deep-watch branding fields the inputs mutate in place (header_links rows). + watch( + () => settings.value?.header_links, + (links, previous) => { + if (suppressWatch || links === undefined || previous === undefined) return + queueSave({ header_links: links }) + }, + { deep: true }, + ) + + async function uploadLogo(file: File): Promise<void> { + if (file.size > MAX_LOGO_BYTES) { + toast.error('Logo 不能超过 2 MB') + return + } + saveState.value = 'saving' + try { + applyResponse(await faqService.uploadLogo(file)) + saveState.value = 'saved' + } catch (error: any) { + saveState.value = 'error' + toast.error(error.message) + } + } + + async function removeLogo(): Promise<void> { + saveState.value = 'saving' + try { + applyResponse(await faqService.removeLogo()) + saveState.value = 'saved' + } catch (error: any) { + saveState.value = 'error' + toast.error(error.message) + } + } + + async function uploadFavicon(file: File): Promise<void> { + if (file.size > MAX_FAVICON_BYTES) { + toast.error('网站图标不能超过 1 MB') + return + } + saveState.value = 'saving' + try { + applyResponse(await faqService.uploadFavicon(file)) + saveState.value = 'saved' + } catch (error: any) { + saveState.value = 'error' + toast.error(error.message) + } + } + + async function removeFavicon(): Promise<void> { + saveState.value = 'saving' + try { + applyResponse(await faqService.removeFavicon()) + saveState.value = 'saved' + } catch (error: any) { + saveState.value = 'error' + toast.error(error.message) + } + } + + // Domain lifecycle (explicit Verify button — never autosaved). + const domainBusy = ref(false) + + async function setDomain(domain: string): Promise<void> { + domainBusy.value = true + try { + const result = await faqService.setDomain(domain) + if (settings.value) settings.value = { ...settings.value, domain: result } + } catch (error: any) { + toast.error(error.message) + } finally { + domainBusy.value = false + } + } + + async function verifyDomain(): Promise<void> { + domainBusy.value = true + try { + const result = await faqService.verifyDomain() + if (settings.value) settings.value = { ...settings.value, domain: result } + if (result.domain_status === 'verified') { + toast.success('域名已验证') + } else { + toast.info('记录尚未生效,DNS 变更可能需要几分钟才能传播') + } + } catch (error: any) { + toast.error(error.message) + } finally { + domainBusy.value = false + } + } + + async function removeDomain(): Promise<void> { + domainBusy.value = true + try { + const result = await faqService.removeDomain() + if (settings.value) settings.value = { ...settings.value, domain: result } + } catch (error: any) { + toast.error(error.message) + } finally { + domainBusy.value = false + } + } + + return { + saveState, + brandColor, + queueSave, + saveNow, + uploadLogo, + removeLogo, + uploadFavicon, + removeFavicon, + domainBusy, + setDomain, + verifyDomain, + removeDomain, + } +} diff --git a/frontend/src/composables/useJiraIntegration.ts b/frontend/src/composables/useJiraIntegration.ts new file mode 100644 index 0000000..721f7f0 --- /dev/null +++ b/frontend/src/composables/useJiraIntegration.ts @@ -0,0 +1,260 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { ref } from 'vue' +import { toast } from 'vue-sonner' +import { + checkJiraConnection, + getJiraProjects, + getJiraIssueTypes, + saveAgentJiraConfig, + getAgentJiraConfig +} from '@/services/jira' + +export function useJiraIntegration(agentId: string) { + // Jira integration state + const jiraConnected = ref(false) + const jiraLoading = ref(false) + const createTicketEnabled = ref(false) + const jiraProjects = ref<any[]>([]) + const jiraIssueTypes = ref<any[]>([]) + const selectedProject = ref<string>('') + const selectedIssueType = ref<string>('') + const loadingProjects = ref(false) + const loadingIssueTypes = ref(false) + + /** + * Check if Jira is connected + */ + const checkJiraStatus = async () => { + try { + jiraLoading.value = true + const status = await checkJiraConnection() + jiraConnected.value = status.connected + + // If Jira is connected, fetch projects + if (jiraConnected.value && createTicketEnabled.value) { + await fetchJiraProjects() + } + } catch (error) { + console.error('Failed to check Jira status:', error) + } finally { + jiraLoading.value = false + } + } + + /** + * Fetch Jira projects + */ + const fetchJiraProjects = async () => { + if (!jiraConnected.value) return + + try { + loadingProjects.value = true + const projects = await getJiraProjects() + jiraProjects.value = projects + } catch (error) { + console.error('Failed to fetch Jira projects:', error) + toast.error('加载 Jira 项目失败') + } finally { + loadingProjects.value = false + } + } + + /** + * Fetch Jira issue types for a project + */ + const fetchJiraIssueTypes = async (projectKey: string) => { + if (!projectKey) { + jiraIssueTypes.value = [] + return + } + + try { + loadingIssueTypes.value = true + const issueTypes = await getJiraIssueTypes(projectKey) + jiraIssueTypes.value = issueTypes + } catch (error) { + console.error('Failed to fetch Jira issue types:', error) + toast.error('加载 Jira 问题类型失败') + } finally { + loadingIssueTypes.value = false + } + } + + /** + * Toggle Jira ticket creation + */ + const toggleCreateTicket = async () => { + // If enabling and Jira is not connected, show error + if (!createTicketEnabled.value && !jiraConnected.value) { + toast.error('无法开启工单创建:Jira 未连接', { + description: '请先在集成设置中连接 Jira。', + duration: 5000 + }) + return + } + + try { + // Toggle the create ticket setting + const newValue = !createTicketEnabled.value + + // If disabling, just save the config with enabled=false + if (!newValue) { + await saveAgentJiraConfig(agentId, { enabled: false }) + createTicketEnabled.value = false + toast.success('工单创建已关闭') + return + } + + // If enabling but no project/issue type selected yet, just update the UI state + // The actual save will happen when the user selects a project and issue type + if (newValue && (!selectedProject.value || !selectedIssueType.value)) { + createTicketEnabled.value = true + // Fetch projects if not already loaded + if (jiraProjects.value.length === 0) { + await fetchJiraProjects() + } + return + } + + // If enabling and project/issue type are selected, save the config + await saveAgentJiraConfig(agentId, { + enabled: true, + projectKey: selectedProject.value, + issueTypeId: selectedIssueType.value + }) + + createTicketEnabled.value = true + toast.success('工单创建已开启') + } catch (error) { + console.error('Failed to toggle ticket creation:', error) + toast.error('更新工单创建设置失败') + } + } + + /** + * Save Jira configuration + */ + const saveJiraConfig = async (projectKey?: string, issueTypeId?: string) => { + // Use provided values or fall back to the state values + const projectToUse = projectKey || selectedProject.value + const issueTypeToUse = issueTypeId || selectedIssueType.value + + if (!projectToUse || !issueTypeToUse) { + toast.error('请选择项目和问题类型') + return + } + + try { + await saveAgentJiraConfig(agentId, { + enabled: true, + projectKey: projectToUse, + issueTypeId: issueTypeToUse + }) + + // Update the local state to match what was saved + selectedProject.value = projectToUse + selectedIssueType.value = issueTypeToUse + + toast.success('Jira 配置已保存') + } catch (error) { + console.error('Failed to save Jira config:', error) + toast.error('保存 Jira 配置失败') + } + } + + /** + * Fetch agent's Jira configuration + */ + const fetchAgentJiraConfig = async () => { + try { + const config = await getAgentJiraConfig(agentId) + createTicketEnabled.value = config.enabled + selectedProject.value = config.projectKey || '' + selectedIssueType.value = config.issueTypeId || '' + + // If there's an existing configuration, fetch the projects and issue types + if (config.enabled && config.projectKey) { + // First check if Jira is connected + const status = await checkJiraConnection() + jiraConnected.value = status.connected + + if (jiraConnected.value) { + // Fetch projects + loadingProjects.value = true + try { + const projects = await getJiraProjects() + jiraProjects.value = projects + + // If project is selected, fetch issue types + if (selectedProject.value) { + await fetchJiraIssueTypes(selectedProject.value) + } + } catch (error) { + console.error('Failed to fetch Jira projects:', error) + toast.error('加载 Jira 项目失败') + } finally { + loadingProjects.value = false + } + } + } + } catch (error) { + console.error('Failed to fetch agent Jira config:', error) + } + } + + /** + * Handle project change + */ + const handleProjectChange = async (projectKey: string) => { + if (projectKey !== selectedProject.value) { + selectedProject.value = projectKey + selectedIssueType.value = '' // Reset issue type when project changes + await fetchJiraIssueTypes(projectKey) + } + } + + /** + * Handle issue type change + */ + const handleIssueTypeChange = (issueTypeId: string) => { + selectedIssueType.value = issueTypeId + } + + return { + // State + jiraConnected, + jiraLoading, + createTicketEnabled, + jiraProjects, + jiraIssueTypes, + selectedProject, + selectedIssueType, + loadingProjects, + loadingIssueTypes, + + // Methods + checkJiraStatus, + fetchJiraProjects, + fetchJiraIssueTypes, + toggleCreateTicket, + saveJiraConfig, + fetchAgentJiraConfig, + handleProjectChange, + handleIssueTypeChange + } +} \ No newline at end of file diff --git a/frontend/src/composables/useJiraTicket.ts b/frontend/src/composables/useJiraTicket.ts new file mode 100644 index 0000000..f0affb7 --- /dev/null +++ b/frontend/src/composables/useJiraTicket.ts @@ -0,0 +1,254 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { ref, computed } from 'vue' +import { toast } from 'vue-sonner' +import { + checkJiraConnection, + getJiraProjects, + getJiraIssueTypes, + getJiraPriorities, + createJiraTicket, + checkPriorityAvailability +} from '@/services/jira' + +export function useJiraTicket() { + // State + const jiraConnected = ref(false) + const jiraLoading = ref(false) + const showTicketModal = ref(false) + const jiraProjects = ref<any[]>([]) + const jiraIssueTypes = ref<any[]>([]) + const jiraPriorities = ref<any[]>([]) + const selectedProject = ref<string>('') + const selectedIssueType = ref<string>('') + const selectedPriority = ref<string>('') + const ticketSummary = ref<string>('') + const ticketDescription = ref<string>('') + const loadingProjects = ref(false) + const loadingIssueTypes = ref(false) + const loadingPriorities = ref(false) + const creatingTicket = ref(false) + const createdTicketKey = ref<string | null>(null) + const priorityAvailable = ref(false) + const checkingPriorityAvailability = ref(false) + + // Check if Jira is connected + const checkJiraStatus = async () => { + jiraLoading.value = true + try { + const response = await checkJiraConnection() + jiraConnected.value = response.connected + if (jiraConnected.value) { + await fetchJiraProjects() + await fetchJiraPriorities() + } + } catch (error) { + console.error('Error checking Jira status:', error) + jiraConnected.value = false + } finally { + jiraLoading.value = false + } + } + + // Fetch Jira projects + const fetchJiraProjects = async () => { + if (!jiraConnected.value) return + + loadingProjects.value = true + try { + const projects = await getJiraProjects() + jiraProjects.value = projects + } catch (error) { + console.error('Error fetching Jira projects:', error) + toast.error('加载 Jira 项目失败') + } finally { + loadingProjects.value = false + } + } + + // Fetch Jira issue types for a project + const fetchJiraIssueTypes = async (projectKey: string) => { + if (!projectKey) return + + loadingIssueTypes.value = true + try { + const issueTypes = await getJiraIssueTypes(projectKey) + jiraIssueTypes.value = issueTypes + } catch (error) { + console.error('Error fetching Jira issue types:', error) + toast.error('加载问题类型失败') + } finally { + loadingIssueTypes.value = false + } + } + + // Fetch Jira priorities + const fetchJiraPriorities = async () => { + if (!jiraConnected.value) return + + loadingPriorities.value = true + try { + const priorities = await getJiraPriorities() + jiraPriorities.value = priorities + } catch (error) { + console.error('Error fetching Jira priorities:', error) + toast.error('加载优先级失败') + } finally { + loadingPriorities.value = false + } + } + + // Handle project change + const handleProjectChange = async (projectKey: string) => { + selectedProject.value = projectKey + selectedIssueType.value = '' + selectedPriority.value = '' + priorityAvailable.value = false + + if (projectKey) { + await fetchJiraIssueTypes(projectKey) + } else { + jiraIssueTypes.value = [] + } + } + + // Handle issue type change + const handleIssueTypeChange = async (issueTypeId: string) => { + selectedIssueType.value = issueTypeId + selectedPriority.value = '' + + if (selectedProject.value && issueTypeId) { + await checkPriorityField(selectedProject.value, issueTypeId) + } else { + priorityAvailable.value = false + } + } + + // Check if priority field is available + const checkPriorityField = async (projectKey: string, issueTypeId: string) => { + checkingPriorityAvailability.value = true + try { + priorityAvailable.value = await checkPriorityAvailability(projectKey, issueTypeId) + + // If priority is available, fetch priorities + if (priorityAvailable.value) { + await fetchJiraPriorities() + } + } catch (error) { + console.error('Error checking priority availability:', error) + priorityAvailable.value = false + } finally { + checkingPriorityAvailability.value = false + } + } + + // Open ticket modal + const openTicketModal = async (chatSummary?: string) => { + // Reset form + selectedProject.value = '' + selectedIssueType.value = '' + selectedPriority.value = '' + ticketDescription.value = '' + createdTicketKey.value = null + + // Set summary from chat if provided + ticketSummary.value = chatSummary || '' + + // Check Jira status and load data + await checkJiraStatus() + + // Show modal + showTicketModal.value = true + } + + // Close ticket modal + const closeTicketModal = () => { + showTicketModal.value = false + } + + // Create Jira ticket + const submitTicket = async (chatId?: string) => { + if (!selectedProject.value || !selectedIssueType.value || !ticketSummary.value) { + toast.error('请填写所有必填项') + return + } + + creatingTicket.value = true + try { + const response = await createJiraTicket({ + projectKey: selectedProject.value, + issueTypeId: selectedIssueType.value, + summary: ticketSummary.value, + description: ticketDescription.value, + priority: selectedPriority.value, + chatId + }) + + createdTicketKey.value = response.key + toast.success(`工单 ${response.key} 已创建`) + return response.key + } catch (error) { + console.error('Error creating ticket:', error) + toast.error('创建工单失败') + return null + } finally { + creatingTicket.value = false + } + } + + // Check if form is valid + const isFormValid = computed(() => { + return selectedProject.value && + selectedIssueType.value && + ticketSummary.value.trim().length > 0 + }) + + return { + // State + jiraConnected, + jiraLoading, + showTicketModal, + jiraProjects, + jiraIssueTypes, + jiraPriorities, + selectedProject, + selectedIssueType, + selectedPriority, + ticketSummary, + ticketDescription, + loadingProjects, + loadingIssueTypes, + loadingPriorities, + creatingTicket, + createdTicketKey, + priorityAvailable, + checkingPriorityAvailability, + isFormValid, + + // Methods + checkJiraStatus, + fetchJiraProjects, + fetchJiraIssueTypes, + fetchJiraPriorities, + handleProjectChange, + handleIssueTypeChange, + checkPriorityField, + openTicketModal, + closeTicketModal, + submitTicket + } +} \ No newline at end of file diff --git a/frontend/src/composables/useMCPTools.ts b/frontend/src/composables/useMCPTools.ts new file mode 100644 index 0000000..f0db998 --- /dev/null +++ b/frontend/src/composables/useMCPTools.ts @@ -0,0 +1,382 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { ref, reactive } from 'vue' +import { mcpService } from '@/services/mcp' +import type { MCPTool, MCPToolCreate, MCPToolReferences, MCPToolTestResult, MCPTransportType } from '@/types/mcp' +import { DEFAULT_MCP_TIMEOUT, clampMCPTimeout } from '@/utils/mcp' +import { toast } from 'vue-sonner' + +export function useMCPTools(agentId: string) { + // State + const agentMCPTools = ref<MCPTool[]>([]) + const availableMCPTools = ref<MCPTool[]>([]) + const isLoading = ref(false) + const isLoadingAvailable = ref(false) + const error = ref<string | null>(null) + const showCreateModal = ref(false) + const showLinkModal = ref(false) + const showDeleteConfirm = ref(false) + const deleteTargetId = ref<number | null>(null) + const deleteReferences = ref<MCPToolReferences | null>(null) + // Which tool the form is editing, or null when it is creating one. + const editingToolId = ref<number | null>(null) + + // Form state for creating MCP tools + const createForm = reactive<MCPToolCreate>({ + name: '', + description: '', + usage_guidance: '', + transport_type: 'stdio' as MCPTransportType, + enabled: true, + command: '', + args: [], + env_vars: {}, + url: '', + headers: {}, + timeout: DEFAULT_MCP_TIMEOUT, + sse_read_timeout: 60, + terminate_on_close: true + }) + + // Transport type options + const transportTypes = [ + { value: 'stdio', label: 'STDIO', description: '标准输入/输出通信' }, + { value: 'sse', label: 'Server-Sent Events', description: 'HTTP 流式通信' }, + { value: 'http', label: 'HTTP', description: '请求/响应通信' } + ] + + // Common MCP tool presets + const mcpPresets = [ + { + name: 'File System', + description: 'Access and manage files and directories', + transport_type: 'stdio' as MCPTransportType, + command: 'npx', + args: ['-y','@modelcontextprotocol/server-filesystem'], + env_vars: { ALLOWED_DIRECTORIES: '/path/to/allowed/directory' } + }, + { + name: 'Weather', + description: 'Get weather information', + transport_type: 'stdio' as MCPTransportType, + command: 'uvx', + args: ["--from", "git+https://github.com/adhikasp/mcp-weather.git", "mcp-weather"], + env_vars: { ACCUWEATHER_API_KEY: 'your-api-key' } + } + ] + + // Fetch agent's MCP tools + const fetchAgentMCPTools = async () => { + isLoading.value = true + error.value = null + + try { + const response = await mcpService.getAgentMCPTools(agentId) + agentMCPTools.value = response.mcp_tools + } catch (err: any) { + error.value = err.response?.data?.detail || '加载 MCP 工具失败' + console.error('Error fetching agent MCP tools:', err) + } finally { + isLoading.value = false + } + } + + // Fetch available MCP tools for linking + const fetchAvailableMCPTools = async () => { + isLoadingAvailable.value = true + + try { + const tools = await mcpService.getOrganizationMCPTools(true) + availableMCPTools.value = tools + } catch (err: any) { + console.error('Error fetching available MCP tools:', err) + toast.error('加载可用 MCP 工具失败') + } finally { + isLoadingAvailable.value = false + } + } + + // Load an existing tool into the shared form. Secret values arrive masked; + // leaving them alone keeps the stored credential. + const startEdit = (tool: MCPTool) => { + Object.assign(createForm, { + name: tool.name, + description: tool.description || '', + usage_guidance: tool.usage_guidance || '', + transport_type: tool.transport_type, + enabled: tool.enabled, + command: tool.command || '', + args: [...(tool.args || [])], + env_vars: { ...(tool.env_vars || {}) }, + url: tool.url || '', + headers: { ...(tool.headers || {}) }, + timeout: tool.timeout ?? DEFAULT_MCP_TIMEOUT, + sse_read_timeout: tool.sse_read_timeout ?? 60, + terminate_on_close: tool.terminate_on_close ?? true + }) + editingToolId.value = tool.id + showCreateModal.value = true + } + + // Create a new MCP tool, or save the one being edited + const saveMCPTool = async () => { + const isEditing = editingToolId.value !== null + try { + const payload = { ...createForm, timeout: clampMCPTimeout(createForm.timeout) } + + if (isEditing) { + await mcpService.updateMCPTool(editingToolId.value as number, payload) + } else { + const newTool = await mcpService.createMCPTool(payload) + // Add to agent immediately + await mcpService.addMCPToolToAgent(newTool.id, agentId) + } + + // Refresh agent tools + await fetchAgentMCPTools() + + // Reset form and close modal + resetCreateForm() + showCreateModal.value = false + editingToolId.value = null + + toast.success(isEditing ? 'MCP 工具已更新' : 'MCP 工具已创建并关联') + } catch (err: any) { + const errorMessage = err.response?.data?.detail + || (isEditing ? '更新 MCP 工具失败' : '创建 MCP 工具失败') + toast.error(errorMessage) + throw err + } + } + + // Link existing MCP tool to agent + const linkMCPTool = async (toolId: number) => { + try { + await mcpService.addMCPToolToAgent(toolId, agentId) + await fetchAgentMCPTools() + toast.success('MCP 工具已关联') + } catch (err: any) { + const errorMessage = err.response?.data?.detail || '关联 MCP 工具失败' + toast.error(errorMessage) + } + } + + // Unlink MCP tool from agent + const unlinkMCPTool = async (toolId: number) => { + try { + await mcpService.removeMCPToolFromAgent(toolId, agentId) + await fetchAgentMCPTools() + toast.success('MCP 工具已取消关联') + } catch (err: any) { + const errorMessage = err.response?.data?.detail || '取消关联 MCP 工具失败' + toast.error(errorMessage) + } + } + + // Delete MCP tool + const deleteMCPTool = async () => { + if (!deleteTargetId.value) return + + try { + await mcpService.deleteMCPTool(deleteTargetId.value) + await fetchAgentMCPTools() + cancelDelete() + toast.success('MCP 工具已删除') + } catch (err: any) { + const errorMessage = err.response?.data?.detail || '删除 MCP 工具失败' + toast.error(errorMessage) + } + } + + // Connection test results, per tool id. A tool that saves fine can still be + // dead at runtime (missing npx, bad key, unreachable server) — the Test + // button surfaces that instead of a silent 0-tool run. + const testResults = ref<Record<number, MCPToolTestResult>>({}) + const testingToolId = ref<number | null>(null) + + const testMCPTool = async (toolId: number) => { + testingToolId.value = toolId + try { + testResults.value = { ...testResults.value, [toolId]: await mcpService.testMCPTool(toolId) } + } catch (err: any) { + const errorMessage = err.response?.data?.detail || '测试 MCP 工具失败' + testResults.value = { ...testResults.value, [toolId]: { success: false, functions: [], error: errorMessage } } + } finally { + testingToolId.value = null + } + } + + // Apply preset to form + const applyPreset = (preset: typeof mcpPresets[0]) => { + Object.assign(createForm, { + ...preset, + // Presets describe themselves for humans; that copy must never become + // prompt text the AI treats as a description of the real source. + usage_guidance: '', + enabled: true + }) + } + + // Reset create form + const resetCreateForm = () => { + editingToolId.value = null + Object.assign(createForm, { + name: '', + description: '', + usage_guidance: '', + transport_type: 'stdio' as MCPTransportType, + enabled: true, + command: '', + args: [], + env_vars: {}, + url: '', + headers: {}, + timeout: DEFAULT_MCP_TIMEOUT, + sse_read_timeout: 60, + terminate_on_close: true + }) + } + + // Confirm delete. This destroys the connector for the whole organization, + // so the dialog first asks what else points at it. + const confirmDelete = async (toolId: number) => { + deleteTargetId.value = toolId + deleteReferences.value = null + showDeleteConfirm.value = true + try { + deleteReferences.value = await mcpService.getMCPToolReferences(toolId) + } catch { + // Non-fatal — confirm without the reference list rather than blocking. + } + } + + // Cancel delete + const cancelDelete = () => { + deleteTargetId.value = null + deleteReferences.value = null + showDeleteConfirm.value = false + } + + // Add argument to args array + const addArg = (arg: string) => { + if (arg.trim()) { + createForm.args = [...(createForm.args || []), arg.trim()] + } + } + + // Remove argument from args array + const removeArg = (index: number) => { + createForm.args = createForm.args?.filter((_, i) => i !== index) || [] + } + + // Add environment variable + const addEnvVar = (key: string, value: string) => { + if (key.trim() && value.trim()) { + createForm.env_vars = { + ...createForm.env_vars, + [key.trim()]: value.trim() + } + } + } + + // Remove environment variable + const removeEnvVar = (key: string) => { + const newEnvVars = { ...createForm.env_vars } + delete newEnvVars[key] + createForm.env_vars = newEnvVars + } + + // Add header + const addHeader = (key: string, value: string) => { + if (key.trim() && value.trim()) { + createForm.headers = { + ...createForm.headers, + [key.trim()]: value.trim() + } + } + } + + // Remove header + const removeHeader = (key: string) => { + const newHeaders = { ...createForm.headers } + delete newHeaders[key] + createForm.headers = newHeaders + } + + // Check if tool is linked to agent + const isToolLinked = (toolId: number): boolean => { + return agentMCPTools.value.some(tool => tool.id === toolId) + } + + // Get transport type display info + const getTransportTypeInfo = (type: MCPTransportType) => { + return transportTypes.find(t => t.value === type) || transportTypes[0] + } + + // Format date + const formatDate = (dateString: string): string => { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + } + + return { + // State + agentMCPTools, + availableMCPTools, + isLoading, + isLoadingAvailable, + error, + showCreateModal, + showLinkModal, + showDeleteConfirm, + deleteReferences, + editingToolId, + createForm, + transportTypes, + mcpPresets, + + // Methods + fetchAgentMCPTools, + fetchAvailableMCPTools, + saveMCPTool, + startEdit, + linkMCPTool, + unlinkMCPTool, + deleteMCPTool, + testMCPTool, + testResults, + testingToolId, + applyPreset, + resetCreateForm, + confirmDelete, + cancelDelete, + addArg, + removeArg, + addEnvVar, + removeEnvVar, + addHeader, + removeHeader, + isToolLinked, + getTransportTypeInfo, + formatDate + } +} \ No newline at end of file diff --git a/frontend/src/composables/useMetaSignup.ts b/frontend/src/composables/useMetaSignup.ts new file mode 100644 index 0000000..3924bd6 --- /dev/null +++ b/frontend/src/composables/useMetaSignup.ts @@ -0,0 +1,256 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +/** + * One-click connect for the Meta channels. + * + * Three different logins hide behind one button: WhatsApp uses Meta's Embedded + * Signup SDK, Messenger uses Facebook Login for Business and then picks a Page, + * and Instagram uses Instagram Login, which involves no Facebook Page at all. + * Kept out of the connect modal so that component stays presentation plus the + * manual credentials form. + */ + +import { onBeforeUnmount, onMounted, ref } from 'vue' +import { toast } from 'vue-sonner' +import channelsService, { + type ChannelAccount, + type MessengerSignupPage, +} from '@/services/channels' +import { + loadMetaSdk, + parseSignupMessage, + runBusinessLogin, + runInstagramLogin, + signupLoginOptions, + META_OAUTH_CALLBACK_PATH, + type SignupChannel, + type SignupSession, +} from '@/utils/metaSdk' + +/** Copy for the one-click pane, per channel. */ +const SIGNUP_COPY: Record<SignupChannel, { intro: string; cta: string }> = { + whatsapp: { + intro: '使用 Facebook 登录并连接你的 WhatsApp Business 号码。', + cta: '使用 Facebook 继续', + }, + messenger: { + intro: '使用 Facebook 登录并连接你的主页。', + cta: '使用 Facebook 继续', + }, + instagram: { + intro: '使用 Instagram 登录并连接你的专业账号。', + cta: '使用 Instagram 继续', + }, +} + +export interface MetaSignupOptions { + channel: SignupChannel + /** Manage mode: an already-connected account has nothing to sign up for. */ + existingAccount?: ChannelAccount | null + /** Called with the account once a login completes. */ + onConnected: (account: ChannelAccount) => void +} + +export function useMetaSignup({ channel, existingAccount, onConnected }: MetaSignupOptions) { + // Offered only when the server says this deployment has a Meta app to onboard + // under. Everyone else keeps the manual credentials form. + const signupEnabled = ref(false) + const signingUp = ref(false) + const showManualForm = ref(false) + + const configId = ref('') + const appId = ref('') + const graphVersion = ref('') + + /** WhatsApp only: its two halves arrive separately and are joined once both land. */ + const signupSession = ref<SignupSession | null>(null) + + // Facebook Login for Business can grant several Pages; the customer picks one. + const signupPages = ref<MessengerSignupPage[]>([]) + const signupToken = ref('') + const connectingPage = ref(false) + + const copy = SIGNUP_COPY[channel] + + const handleSignupMessage = (event: MessageEvent) => { + const session = parseSignupMessage(event) + if (session) signupSession.value = session + } + + onMounted(async () => { + if (existingAccount) return + try { + const config = await channelsService.getEmbeddedSignupConfig(channel) + if (!config.enabled || !config.app_id) return + // The Facebook logins are driven by a Login-for-Business configuration; + // Instagram Login has none, so requiring one here would hide its button. + if (channel !== 'instagram' && !config.config_id) return + // Empty for Instagram Login, which authorizes with the app id alone. + configId.value = config.config_id ?? '' + appId.value = config.app_id + graphVersion.value = config.graph_version + signupEnabled.value = true + // WhatsApp uses the JS SDK (Embedded Signup, which also reports its + // result out of band via postMessage). The other two drive their own + // OAuth popup, so they need neither the SDK nor the message listener. + if (channel === 'whatsapp') { + await loadMetaSdk(config.app_id, config.graph_version) + window.addEventListener('message', handleSignupMessage) + } + } catch (error) { + // Falling back to the manual form is a working path, not an error worth + // interrupting the user for. + console.error('One-click signup unavailable:', error) + } + }) + + onBeforeUnmount(() => window.removeEventListener('message', handleSignupMessage)) + + const finishWhatsAppSignup = async (code: string) => { + const session = signupSession.value + if (!session) { + // Signed in, but Meta never reported which number was set up, so there + // is nothing to connect. Say so rather than appearing to do nothing. + toast.error('WhatsApp 注册未完成', { + description: '未设置号码。请重试,或手动输入凭据。', + }) + return + } + const account = await channelsService.connectWhatsAppEmbeddedSignup({ + code, + waba_id: session.waba_id, + phone_number_id: session.phone_number_id, + }) + onConnected(account) + toast.success(`已连接 ${account.display_name || 'WhatsApp'}`) + } + + const connectPickedPage = async (pageId: string) => { + const account = await channelsService.connectMessengerSignup({ + signup_token: signupToken.value, + page_id: pageId, + }) + onConnected(account) + toast.success(`已连接 ${account.display_name || 'Messenger'}`) + } + + const finishMessengerSignup = async (code: string, redirectUri: string) => { + const { pages, signup_token } = await channelsService.listMessengerSignupPages(code, redirectUri) + signupToken.value = signup_token + // One Page needs no picker — connect it and go straight to agent assignment. + if (pages.length === 1) { + await connectPickedPage(pages[0].id) + } else { + signupPages.value = pages + } + } + + /** The picker's choice — its own loading state, since the login popup is long gone. */ + const onPageSelected = async (pageId: string) => { + try { + connectingPage.value = true + await connectPickedPage(pageId) + } catch (error: any) { + toast.error(error?.response?.data?.detail || '无法连接该账号') + } finally { + connectingPage.value = false + } + } + + // WhatsApp: the JS SDK's login callback. + const completeWhatsAppSignup = async (response: { authResponse?: { code?: string } }) => { + const code = response.authResponse?.code + if (!code) { + // The popup was dismissed — not an error worth a toast. + signingUp.value = false + return + } + try { + await finishWhatsAppSignup(code) + } catch (error: any) { + toast.error(error?.response?.data?.detail || '无法完成连接') + } finally { + signingUp.value = false + } + } + + /** Shared shape of the two popup logins: run it, connect, surface failures. */ + const runPopupConnect = async (connect: (redirectUri: string) => Promise<void>) => { + signingUp.value = true + const redirectUri = window.location.origin + META_OAUTH_CALLBACK_PATH + try { + await connect(redirectUri) + } catch (error: any) { + // The user closing the popup is not an error worth a toast. + if (error?.message !== 'cancelled') { + toast.error(error?.response?.data?.detail || error?.message || '无法完成连接') + } + } finally { + signingUp.value = false + } + } + + // Messenger: Facebook Login for Business, then pick a Page. + const startMessengerLogin = () => runPopupConnect(async (redirectUri) => { + const code = await runBusinessLogin({ + appId: appId.value, + configId: configId.value, + graphVersion: graphVersion.value, + redirectUri, + }) + await finishMessengerSignup(code, redirectUri) + }) + + // Instagram: Instagram Login — one account, so it connects in a single step. + const startInstagramLogin = () => runPopupConnect(async (redirectUri) => { + const code = await runInstagramLogin({ appId: appId.value, redirectUri }) + const account = await channelsService.connectInstagramLogin(code, redirectUri) + onConnected(account) + toast.success(`已连接 ${account.display_name || 'Instagram'}`) + }) + + const startSignup = () => { + if (signingUp.value) return + + // Both popups must open synchronously in the click handler or the browser + // blocks them. + if (channel === 'messenger') { void startMessengerLogin(); return } + if (channel === 'instagram') { void startInstagramLogin(); return } + + if (!window.FB) return + signupSession.value = null + signingUp.value = true + // The SDK type-checks its callback and rejects an AsyncFunction outright + // ("Expression is of type asyncfunction, not function"), so hand the async + // work off from a plain function rather than passing `async` here. + window.FB.login( + (response) => { void completeWhatsAppSignup(response) }, + signupLoginOptions(configId.value, channel), + ) + } + + return { + signupEnabled, + signingUp, + showManualForm, + signupPages, + connectingPage, + copy, + startSignup, + onPageSelected, + } +} diff --git a/frontend/src/composables/useNotificationSettings.ts b/frontend/src/composables/useNotificationSettings.ts new file mode 100644 index 0000000..371ebe9 --- /dev/null +++ b/frontend/src/composables/useNotificationSettings.ts @@ -0,0 +1,59 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { onMounted, ref } from 'vue' +import { toast } from 'vue-sonner' +import { notificationService, type NotificationSettings } from '@/services/notification' + +export function useNotificationSettings() { + const settings = ref<NotificationSettings | null>(null) + const isLoading = ref(true) + const isSaving = ref(false) + const error = ref<string | null>(null) + + async function load() { + isLoading.value = true + try { + settings.value = await notificationService.getSettings() + error.value = null + } catch (e: any) { + error.value = e?.message || '加载通知设置失败' + } finally { + isLoading.value = false + } + } + + // Optimistic: the toggle flips immediately and reverts if the save fails, + // so a slow round-trip never makes the switch feel unresponsive. + async function save(patch: Partial<NotificationSettings>) { + if (!settings.value || isSaving.value) return + isSaving.value = true + const previous = { ...settings.value } + Object.assign(settings.value, patch) + try { + settings.value = await notificationService.updateSettings(patch) + } catch (e: any) { + settings.value = previous + toast.error(e?.message || '保存通知设置失败') + } finally { + isSaving.value = false + } + } + + onMounted(load) + + return { settings, isLoading, isSaving, error, load, save } +} diff --git a/frontend/src/composables/useNotifications.ts b/frontend/src/composables/useNotifications.ts new file mode 100644 index 0000000..b73c13a --- /dev/null +++ b/frontend/src/composables/useNotifications.ts @@ -0,0 +1,188 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { onMounted, ref } from 'vue' +import { useRouter } from 'vue-router' +import { onMessage, type MessagePayload } from 'firebase/messaging' +import { toast } from 'vue-sonner' +import { + requestNotificationPermission, + refreshFCMToken, + getMessagingIfSupported +} from '@/services/firebase' +import { SW_MESSAGE, CONVERSATIONS_PATH, conversationSessionUrl } from '@/pwa/pushContract' +import mitt from '@/utils/emitter' + +// Match backend NotificationType enum +enum NotificationType { + KNOWLEDGE_PROCESSED = 'knowledge_processed', + KNOWLEDGE_FAILED = 'knowledge_failed', + SYSTEM = 'system', + CHAT = 'chat' +} + +// Module-level guards: every view mounts its own DashboardLayout (and thus +// this composable), so per-mount attachment would stack a new SW message +// listener + FCM onMessage subscription on every route change — one push +// would then toast once per navigation. +let listenersAttached = false +let tokenRefreshed = false + +export function useNotifications() { + const hasPermission = ref( + typeof Notification !== 'undefined' && Notification.permission === 'granted' + ) + const router = useRouter() + + // A payload that references a conversation gets an "Open" action on its toast + const sessionAction = (payload: MessagePayload) => { + const sessionId = payload.data?.session_id + if (!sessionId) return undefined + return { + label: '打开', + onClick: () => router.push(conversationSessionUrl(sessionId)) + } + } + + const handleNewNotification = (payload: MessagePayload) => { + const notifType = payload.data?.type?.toLowerCase() || 'default' + // Backend sends data-only messages (title/body inside data) so the SW SDK + // doesn't auto-display duplicates; keep notification?. as a fallback. + const title = payload.data?.title || payload.notification?.title + const body = payload.data?.body || payload.notification?.body + + // Handle different notification types + switch (notifType) { + case NotificationType.KNOWLEDGE_PROCESSED: + toast.success(title || '知识库处理完成', { + description: body, + duration: 5000, + position: 'top-right' + }) + // Emit event to refresh knowledge grid + mitt.emit('knowledge-updated') + break + + case NotificationType.KNOWLEDGE_FAILED: + toast.error(title || '知识库处理失败', { + description: body, + duration: 8000, + position: 'top-right' + }) + break + + case NotificationType.SYSTEM: + toast(title || '系统通知', { + description: body, + duration: 5000, + position: 'top-right', + action: sessionAction(payload) + }) + break + + case NotificationType.CHAT: + toast(title || '新会话消息', { + description: body, + duration: 5000, + position: 'top-right', + action: sessionAction(payload) + }) + break + + default: + toast(title || '新通知', { + description: body, + duration: 5000, + position: 'top-right' + }) + } + } + + const listenForBgNotification = () => { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.addEventListener('message', (event) => { + if (event.data?.eventType === SW_MESSAGE.BACKGROUND_NOTIFICATION) { + handleNewNotification(event.data) + } + // SW notificationclick fallback for tabs it can't navigate() itself. + // Pin the target to the inbox — never route to an arbitrary string + // from a message event. + if ( + event.data?.eventType === SW_MESSAGE.OPEN_CONVERSATION && + typeof event.data.url === 'string' && + event.data.url.startsWith(CONVERSATIONS_PATH) + ) { + router.push(event.data.url) + } + }) + } + } + + const setFcmMessagingListener = async () => { + const messaging = await getMessagingIfSupported() + if (!messaging) return + onMessage(messaging, (payload) => { + handleNewNotification(payload) + }) + } + + // Attach listeners — safe on mount, guarded to run once per app load. Does + // NOT ask for permission: on iOS a permission request outside a user gesture + // silently fails, so the request lives behind an explicit button + // (EnablePushPrompt → enableNotifications). + const startListening = async () => { + try { + if (!hasPermission.value || listenersAttached) return + listenersAttached = true + listenForBgNotification() + await setFcmMessagingListener() + + // Already-granted users never see the enable prompt again, so this is + // the only path that re-binds their FCM token to the current service + // worker (the old firebase-messaging-sw.js one dies on upgrade). + if (!tokenRefreshed) { + tokenRefreshed = true + refreshFCMToken() + } + } catch (err) { + console.error('Error initializing notification listeners:', err) + } + } + + // Gesture-driven: prompt for permission, register the FCM token, then listen + const enableNotifications = async (): Promise<boolean> => { + const granted = await requestNotificationPermission() + hasPermission.value = granted + if (granted) { + // Token was just minted — don't mint again from startListening + tokenRefreshed = true + await startListening() + } else if (typeof Notification !== 'undefined' && Notification.permission === 'granted') { + // Permission granted but token registration failed — surface it + toast.error('无法开启通知。请刷新后重试。') + } + return granted + } + + onMounted(() => { + startListening() + }) + + return { + hasPermission, + enableNotifications, + } +} diff --git a/frontend/src/composables/useTicketDetail.ts b/frontend/src/composables/useTicketDetail.ts new file mode 100644 index 0000000..18f1524 --- /dev/null +++ b/frontend/src/composables/useTicketDetail.ts @@ -0,0 +1,265 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue' +import { toast } from 'vue-sonner' +import { ticketService } from '@/services/tickets' +import { useTicketSocket } from '@/composables/useTicketSocket' +import type { + InvestigationDetail, + Ticket, + TicketActivity, + TicketDetail, + TicketPriority, + TicketStatus, + TicketUpdatePayload, +} from '@/types/ticket' + +// Poll fallback cadence: brisk while an AI run is active, relaxed otherwise. +const ACTIVE_POLL_MS = 3000 +const IDLE_POLL_MS = 15000 + +export function useTicketDetail(ticketId: Ref<string>) { + const detail = ref<TicketDetail | null>(null) + const investigation = ref<InvestigationDetail | null>(null) + const isLoading = ref(true) + const error = ref<string | null>(null) + const isSavingComment = ref(false) + // Covers both customer-summary actions: they write the same field, and + // sending twice delivers the message to the customer twice. + const isRcaBusy = ref(false) + + const ticket = computed<Ticket | null>(() => detail.value?.ticket ?? null) + const activities = computed<TicketActivity[]>(() => detail.value?.activities ?? []) + const hasActiveRun = computed(() => + (detail.value?.runs ?? []).some((r) => r.status === 'pending' || r.status === 'running'), + ) + + async function refresh(silent = false) { + if (!ticketId.value) return + if (!silent) isLoading.value = true + try { + // The glass box loads alongside the ticket; its failure (e.g. plan + // gate) must never take the whole page down. + const [detailData, investigationData] = await Promise.all([ + ticketService.getTicket(ticketId.value), + ticketService.getInvestigation(ticketId.value).catch(() => null), + ]) + detail.value = detailData + investigation.value = investigationData + error.value = null + } catch (e: any) { + if (!silent) error.value = e?.message || '加载工单失败' + } finally { + isLoading.value = false + } + } + + async function patch(patchPayload: TicketUpdatePayload, failureMessage: string) { + if (!detail.value) return + const previous = { ...detail.value.ticket } + Object.assign(detail.value.ticket, patchPayload) + try { + await ticketService.updateTicket(previous.id, patchPayload) + await refresh(true) + } catch (e: any) { + detail.value.ticket = previous + toast.error(e?.message || failureMessage) + } + } + + const setStatus = (status: TicketStatus) => patch({ status }, '修改状态失败') + const setPriority = (priority: TicketPriority) => patch({ priority }, '修改优先级失败') + const setSeverity = (severity: number) => patch({ severity }, '修改严重程度失败') + const setTitle = (title: string) => patch({ title }, '重命名工单失败') + const setDescription = (description: string) => + patch({ description }, '更新描述失败') + const setAssignee = (assignee_user_id: string | null) => + patch({ assignee_user_id }, '分配工单失败') + + async function setCustomer(email: string, name?: string) { + if (!detail.value || !email.trim()) return + try { + await ticketService.updateTicket(detail.value.ticket.id, { + customer_email: email.trim(), + customer_name: name?.trim() || undefined, + }) + await refresh(true) + toast.success('客户已关联') + } catch (e: any) { + toast.error(e?.message || '关联客户失败') + } + } + + async function addComment(body: string, isInternal = true) { + if (!detail.value || !body.trim()) return + isSavingComment.value = true + try { + await ticketService.addComment(detail.value.ticket.id, body.trim(), isInternal) + await refresh(true) + } catch (e: any) { + toast.error(e?.message || '发表评论失败') + } finally { + isSavingComment.value = false + } + } + + async function resolve(payload: { outcome?: string; resolution_summary?: string; customer_message?: string }) { + if (!detail.value) return + try { + await ticketService.resolveTicket(detail.value.ticket.id, payload) + await refresh(true) + toast.success('工单已解决,已通知客户') + } catch (e: any) { + toast.error(e?.message || '解决工单失败') + } + } + + async function reopen(reason?: string) { + if (!detail.value) return + try { + await ticketService.reopenTicket(detail.value.ticket.id, reason) + await refresh(true) + } catch (e: any) { + toast.error(e?.message || '重新打开工单失败') + } + } + + async function investigate(contextNote?: string) { + if (!detail.value) return + try { + await ticketService.investigate(detail.value.ticket.id, { + run_type: 'investigation', + context_note: contextNote, + }) + await refresh(true) + toast.success('AI 调查已加入队列') + } catch (e: any) { + toast.error(e?.message || '启动 AI 运行失败') + } + } + + async function saveRcaDraft(customerSummary: string) { + if (!detail.value || isRcaBusy.value) return + isRcaBusy.value = true + try { + await ticketService.updateRca(detail.value.ticket.id, { + customer_summary: customerSummary, + mark_reviewed: true, + }) + await refresh(true) + toast.success('草稿已保存') + } catch (e: any) { + toast.error(e?.message || '保存草稿失败') + } finally { + isRcaBusy.value = false + } + } + + async function sendRcaToCustomer(customerSummary?: string) { + // Re-entry guard, not just a disabled button: this delivers a message to + // a real customer, so a double-click must not send it twice. + if (!detail.value || isRcaBusy.value) return + isRcaBusy.value = true + try { + // Persist any unsaved edit first so what's sent is what's shown. + if (customerSummary !== undefined) { + await ticketService.updateRca(detail.value.ticket.id, { + customer_summary: customerSummary, + mark_reviewed: true, + }) + } + await ticketService.sendRcaToCustomer(detail.value.ticket.id) + await refresh(true) + toast.success('摘要已发送给客户') + } catch (e: any) { + toast.error(e?.message || '发送摘要失败') + } finally { + isRcaBusy.value = false + } + } + + async function approveProposal() { + if (!detail.value) return + try { + await ticketService.approveProposal(detail.value.ticket.id) + await refresh(true) + toast.success('方案已批准,工单已解决并通知客户') + } catch (e: any) { + toast.error(e?.message || '批准方案失败') + } + } + + async function rejectProposal(reason?: string, reinvestigate = false) { + if (!detail.value) return + try { + await ticketService.rejectProposal(detail.value.ticket.id, reason, reinvestigate) + await refresh(true) + toast.success(reinvestigate ? '已拒绝,已排入新的深入调查' : '方案已拒绝') + } catch (e: any) { + toast.error(e?.message || '拒绝方案失败') + } + } + + useTicketSocket((event) => { + if (event.ticket_id === ticketId.value) refresh(true) + }) + + let pollTimer: ReturnType<typeof setTimeout> | null = null + function scheduleNextPoll() { + pollTimer = setTimeout(async () => { + await refresh(true) + scheduleNextPoll() + }, hasActiveRun.value ? ACTIVE_POLL_MS : IDLE_POLL_MS) + } + + onMounted(async () => { + await refresh() + scheduleNextPoll() + }) + onBeforeUnmount(() => { + if (pollTimer) clearTimeout(pollTimer) + }) + watch(ticketId, () => refresh()) + + return { + detail, + investigation, + ticket, + activities, + hasActiveRun, + isLoading, + isSavingComment, + isRcaBusy, + error, + refresh, + setStatus, + setPriority, + setSeverity, + setTitle, + setDescription, + setAssignee, + setCustomer, + addComment, + resolve, + reopen, + investigate, + saveRcaDraft, + sendRcaToCustomer, + approveProposal, + rejectProposal, + } +} diff --git a/frontend/src/composables/useTicketSocket.ts b/frontend/src/composables/useTicketSocket.ts new file mode 100644 index 0000000..44d11d5 --- /dev/null +++ b/frontend/src/composables/useTicketSocket.ts @@ -0,0 +1,59 @@ +/* +Copyright 2024-2026 ChatterMate + +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. + +Live ticket updates: joins the per-org ticket room on the /agent namespace +and forwards `ticket_update` frames. Callers keep a poll fallback — a missed +frame is never fatal. +*/ + +import { onBeforeUnmount, onMounted } from 'vue' +import { socketService } from '@/services/socket' +import { userService } from '@/services/user' +import type { TicketUpdateEvent } from '@/types/ticket' + +export function useTicketSocket(onUpdate: (event: TicketUpdateEvent) => void) { + const orgId = userService.getCurrentUser()?.organization_id + const room = orgId ? `org_tickets_${orgId}` : null + + const handleUpdate = (data: unknown) => { + const event = data as TicketUpdateEvent + if (event && event.ticket_id) onUpdate(event) + } + + const joinRoom = () => { + if (room) socketService.emit('join_room', { session_id: room }) + } + + const handleReconnect = () => { + socketService.off('ticket_update', handleUpdate) + socketService.on('ticket_update', handleUpdate) + joinRoom() + } + + onMounted(() => { + if (!room) return + socketService.connect() + socketService.on('ticket_update', handleUpdate) + socketService.onReconnect(handleReconnect) + joinRoom() + }) + + onBeforeUnmount(() => { + if (!room) return + socketService.emit('leave_room', { session_id: room }) + socketService.off('ticket_update', handleUpdate) + socketService.offReconnect(handleReconnect) + }) +} diff --git a/frontend/src/composables/useTicketingSettings.ts b/frontend/src/composables/useTicketingSettings.ts new file mode 100644 index 0000000..ccb7463 --- /dev/null +++ b/frontend/src/composables/useTicketingSettings.ts @@ -0,0 +1,62 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { onMounted, ref } from 'vue' +import { toast } from 'vue-sonner' +import { ticketService } from '@/services/tickets' +import type { TicketSettings } from '@/types/ticket' + +export function useTicketingSettings() { + const settings = ref<TicketSettings | null>(null) + const isLoading = ref(true) + const isSaving = ref(false) + const error = ref<string | null>(null) + const planGated = ref(false) + + async function load() { + isLoading.value = true + try { + settings.value = await ticketService.getSettings() + error.value = null + planGated.value = false + } catch (e: any) { + planGated.value = /plan/i.test(e?.message || '') + error.value = e?.message || '加载工单设置失败' + } finally { + isLoading.value = false + } + } + + async function save(patch: Partial<TicketSettings>) { + if (!settings.value || isSaving.value) return + isSaving.value = true + const previous = { ...settings.value } + Object.assign(settings.value, patch) + try { + settings.value = await ticketService.updateSettings(patch) + toast.success('工单设置已保存') + } catch (e: any) { + settings.value = previous + toast.error(e?.message || '保存工单设置失败') + } finally { + isSaving.value = false + } + } + + onMounted(load) + + return { settings, isLoading, isSaving, error, planGated, load, save } +} diff --git a/frontend/src/composables/useTicketsWorkspace.ts b/frontend/src/composables/useTicketsWorkspace.ts new file mode 100644 index 0000000..395bc9a --- /dev/null +++ b/frontend/src/composables/useTicketsWorkspace.ts @@ -0,0 +1,179 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' +import { useRoute, useRouter } from 'vue-router' +import { ticketService } from '@/services/tickets' +import { useTicketSocket } from '@/composables/useTicketSocket' +import type { Pagination, TicketListFilters, TicketListItem, TicketStats } from '@/types/ticket' + +// Server-side filter mapping for the status pills. "breaching" is a virtual +// filter (SLA), approximated client-side over the loaded page. +const OPEN_STATUSES = 'open,triaging,investigating,awaiting_approval,in_progress,reopened,resolved_pending_confirmation' +const SEARCH_DEBOUNCE_MS = 350 +// Poll fallback so a missed socket frame never strands the list. +const LIST_REFRESH_MS = 30000 + +const DEFAULT_FILTERS: TicketListFilters = { + status: 'all', + priority: 'all', + assignee: 'all', + ai: 'all', + search: '', + sort: 'updated', +} + +export function useTicketsWorkspace() { + const route = useRoute() + const router = useRouter() + + const tickets = ref<TicketListItem[]>([]) + const stats = ref<TicketStats | null>(null) + const pagination = ref<Pagination | null>(null) + const page = ref(1) + const isLoading = ref(true) + const error = ref<string | null>(null) + const planGated = ref(false) + + const filters = reactive<TicketListFilters>({ + ...DEFAULT_FILTERS, + ...pickFiltersFromQuery(route.query), + }) + + const phase = computed(() => { + if (isLoading.value && tickets.value.length === 0) return 'loading' + if (tickets.value.length === 0) return 'empty' + return 'populated' + }) + + const hasActiveFilters = computed(() => + Object.keys(DEFAULT_FILTERS).some( + (key) => filters[key as keyof TicketListFilters] !== DEFAULT_FILTERS[key as keyof TicketListFilters], + ), + ) + + function pickFiltersFromQuery(query: Record<string, any>): Partial<TicketListFilters> { + const picked: Partial<TicketListFilters> = {} + for (const key of Object.keys(DEFAULT_FILTERS) as (keyof TicketListFilters)[]) { + if (typeof query[key] === 'string' && query[key]) picked[key] = query[key] + } + return picked + } + + function filtersToQuery(): Record<string, string> { + const query: Record<string, string> = {} + for (const key of Object.keys(DEFAULT_FILTERS) as (keyof TicketListFilters)[]) { + if (filters[key] !== DEFAULT_FILTERS[key]) query[key] = filters[key] + } + return query + } + + function buildParams() { + const params: Record<string, any> = { page: page.value, sort: filters.sort } + if (filters.status === 'open') params.status = OPEN_STATUSES + else if (filters.status === 'awaiting') params.status = 'awaiting_approval' + else if (filters.status === 'resolved') params.status = 'resolved,closed' + else if (filters.status === 'breaching') params.status = OPEN_STATUSES + if (filters.priority !== 'all') params.priority = filters.priority + if (filters.assignee === 'unassigned') params.unassigned = true + else if (filters.assignee !== 'all') params.assignee_id = filters.assignee + if (filters.ai !== 'all') params.ai_state = filters.ai + if (filters.search.trim()) params.search = filters.search.trim() + return params + } + + const visibleTickets = computed(() => { + if (filters.status !== 'breaching') return tickets.value + const soon = Date.now() + 30 * 60000 + return tickets.value.filter( + (t) => t.sla_due_at && !t.resolved_at && new Date(t.sla_due_at).getTime() <= soon, + ) + }) + + async function refresh(silent = false) { + if (!silent) isLoading.value = true + try { + const [listResponse, statsResponse] = await Promise.all([ + ticketService.listTickets(buildParams()), + ticketService.getStats(), + ]) + tickets.value = listResponse.tickets + pagination.value = listResponse.pagination + stats.value = statsResponse + error.value = null + planGated.value = false + } catch (e: any) { + if (!silent) { + planGated.value = /plan/i.test(e?.message || '') + error.value = e?.message || '加载工单失败' + } + } finally { + isLoading.value = false + } + } + + function clearFilters() { + Object.assign(filters, DEFAULT_FILTERS) + page.value = 1 + } + + // Filters -> URL (shareable) + reload. Search is debounced. + let searchTimer: ReturnType<typeof setTimeout> | null = null + watch( + () => ({ ...filters }), + (next, prev) => { + router.replace({ query: filtersToQuery() }) + if (next.search !== prev?.search) { + if (searchTimer) clearTimeout(searchTimer) + searchTimer = setTimeout(() => { + page.value = 1 + refresh() + }, SEARCH_DEBOUNCE_MS) + return + } + page.value = 1 + refresh() + }, + ) + watch(page, () => refresh()) + + useTicketSocket(() => refresh(true)) + + let refreshTimer: ReturnType<typeof setInterval> | null = null + onMounted(() => { + refresh() + refreshTimer = setInterval(() => refresh(true), LIST_REFRESH_MS) + }) + onBeforeUnmount(() => { + if (refreshTimer) clearInterval(refreshTimer) + if (searchTimer) clearTimeout(searchTimer) + }) + + return { + tickets: visibleTickets, + stats, + pagination, + page, + filters, + phase, + isLoading, + error, + planGated, + hasActiveFilters, + refresh, + clearFilters, + } +} diff --git a/frontend/src/composables/useWorkflowManagement.ts b/frontend/src/composables/useWorkflowManagement.ts new file mode 100644 index 0000000..43e043c --- /dev/null +++ b/frontend/src/composables/useWorkflowManagement.ts @@ -0,0 +1,569 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import { ref, type Ref } from 'vue' +import type { Node, Edge } from '@vue-flow/core' +import { toast } from 'vue-sonner' +import { workflowNodeService } from '@/services/workflowNode' +import { workflowService } from '@/services/workflow' +import { workflowCacheStorage } from '@/utils/storage' +import { WorkflowStatus } from '@/types/workflow' +import type { NodeTypeInfo, ValidationResult } from './useWorkflowValidation' + +export interface WorkflowManagementOptions { + workflowId: string + selectedNode: Ref<Node | null> + showPropertiesPanel: Ref<boolean> + loading: Ref<boolean> + publishLoading: Ref<boolean> + workflowStatus: Ref<WorkflowStatus> + nodeIdCounter: Ref<number> + availableNodeTypes: NodeTypeInfo[] + validateAllNodes: (nodes: Node[], workflowId: string, edges?: any[]) => ValidationResult + highlightNodesWithErrors: (nodes: Node[], workflowId: string, availableNodeTypes: NodeTypeInfo[], edges?: any[]) => void + resetAllNodesValidationStyle: (nodes: Node[], availableNodeTypes: NodeTypeInfo[]) => void + getNodes: () => Node[] + getEdges: () => Edge[] + addNodes: (nodes: Node[]) => void + addEdges: (edges: Edge[]) => void + removeNodes: (nodeIds: string[]) => void + closePropertiesPanel: () => void +} + +export function useWorkflowManagement(options: WorkflowManagementOptions) { + const { + workflowId, + selectedNode, + showPropertiesPanel, + loading, + publishLoading, + workflowStatus, + nodeIdCounter, + availableNodeTypes, + validateAllNodes, + highlightNodesWithErrors, + resetAllNodesValidationStyle, + getNodes, + getEdges, + addNodes, + addEdges, + removeNodes, + closePropertiesPanel + } = options + + // Map backend node types to frontend enum values + const mapNodeTypeToFrontend = (backendType: string) => { + const mapping = { + 'landing_page': 'landingPage', + 'message': 'message', + 'llm': 'llm', + 'condition': 'condition', + 'form': 'form', + 'action': 'action', + 'human_transfer': 'humanTransfer', + 'wait': 'wait', + 'end': 'end', + 'user_input': 'userInput', + 'guardrails': 'guardrails' + } + return mapping[backendType as keyof typeof mapping] || 'message' + } + + // Map frontend node types to backend enum values + const mapNodeTypeToBackend = (frontendType: string) => { + const mapping = { + 'landingPage': 'landing_page', + 'message': 'message', + 'llm': 'llm', + 'condition': 'condition', + 'form': 'form', + 'action': 'action', + 'humanTransfer': 'human_transfer', + 'wait': 'wait', + 'end': 'end', + 'userInput': 'user_input', + 'guardrails': 'guardrails' + } + return mapping[frontendType as keyof typeof mapping] || 'message' + } + + // Load workflow data + const loadWorkflowData = async () => { + try { + loading.value = true + + // Clear any existing cache and load fresh from API + workflowCacheStorage.clearWorkflowCache(workflowId) + const data = await workflowNodeService.getWorkflowNodes(workflowId, false) + + // Convert backend nodes to Vue Flow nodes + const nodes: Node[] = data.nodes.map((node: any) => { + const frontendNodeType = mapNodeTypeToFrontend(node.node_type) + const nodeType = availableNodeTypes.find(t => t.type === frontendNodeType) + return { + id: node.id, + type: 'default', + position: { x: node.position_x, y: node.position_y }, + data: { + label: `${nodeType?.icon || '📄'} ${node.name}`, + cleanName: node.name, + description: node.description, + config: node.config, + nodeType: frontendNodeType, + icon: nodeType?.icon || '📄', + color: nodeType?.color || '#6B7280', + // Store all backend properties directly in data for easy access + message_text: node.config?.message_text, + system_prompt: node.system_prompt, + temperature: node.temperature, + model_id: node.model_id, + form_fields: node.form_fields, + form_title: node.form_title, + form_description: node.form_description, + submit_button_text: node.submit_button_text, + form_full_screen: node.form_full_screen, + condition_expression: node.condition_expression, + action_type: node.action_type, + action_config: node.action_config, + transfer_rules: node.transfer_rules, + wait_duration: node.wait_duration, + wait_until_condition: node.wait_until_condition, + landing_page_heading: node.landing_page_heading, + landing_page_content: node.landing_page_content, + // Also spread config for backward compatibility + ...node.config + }, + style: { + backgroundColor: nodeType?.color || '#6B7280', + color: 'white', + borderColor: nodeType?.color || '#6B7280' + } + } + }) + + // Convert backend connections to Vue Flow edges + const edges: Edge[] = data.connections.map((conn: any) => ({ + id: conn.id, + source: conn.source_node_id, + target: conn.target_node_id, + sourceHandle: conn.source_handle, + targetHandle: conn.target_handle, + label: conn.label, + data: { + condition: conn.condition, + priority: conn.priority, + metadata: conn.connection_metadata + } + })) + + addNodes(nodes) + addEdges(edges) + + // Update counter for new nodes + const maxCounter = nodes.reduce((max, node) => { + const match = node.id.match(/-(\d+)$/) + if (match) { + return Math.max(max, parseInt(match[1])) + } + return max + }, 0) + nodeIdCounter.value = maxCounter + 1 + } catch (error: any) { + console.error('Error loading workflow data:', error) + + // Don't show error toast for 404 - it means workflow exists but has no nodes yet + if (error.response?.status !== 404) { + toast.error('加载工作流数据失败', { + position: 'top-center' + }) + } + + if (error.response?.status === 404) { + console.log('No nodes found for workflow - starting with empty canvas') + } + } finally { + loading.value = false + } + } + + // Save node properties + const saveNodeProperties = (properties: any) => { + if (selectedNode.value) { + // Update the node visual data (label and styling) + const nodeType = availableNodeTypes.find(t => t.type === selectedNode.value?.data.nodeType) + selectedNode.value.data.label = `${nodeType?.icon || '📄'} ${properties.name || selectedNode.value.data.cleanName}` + selectedNode.value.data.cleanName = properties.name || selectedNode.value.data.cleanName + selectedNode.value.data.description = properties.description || selectedNode.value.data.description + } + + // Reset all validation styling to refresh the validation state based on latest cache data + resetAllNodesValidationStyle(getNodes(), availableNodeTypes) + + // Re-validate to ensure errors are cleared if the node is now valid + setTimeout(() => { + const validation = validateAllNodes(getNodes(), workflowId, getEdges()) + if (!validation.isValid) { + highlightNodesWithErrors(getNodes(), workflowId, availableNodeTypes, getEdges()) + } + }, 100) + } + + // Save workflow + const saveWorkflow = async () => { + const nodes = getNodes() + const edges = getEdges() + + // Check if there are no nodes to save + if (nodes.length === 0) { + toast.error('无法保存空工作流,请至少添加一个节点。', { + duration: 4000, + closeButton: true, + position: 'top-center' + }) + return + } + + // Check for at least one connection if multiple nodes exist + if (nodes.length > 1 && edges.length === 0) { + toast.error('无法保存未连接的工作流,请先连接节点再保存。', { + duration: 5000, + closeButton: true, + position: 'top-center' + }) + return + } + + // For single node workflows, ensure it's a valid starting node type + if (nodes.length === 1) { + const singleNode = nodes[0] + const validStartingNodeTypes = ['landingPage', 'message', 'llm', 'form'] + if (!validStartingNodeTypes.includes(singleNode.data.nodeType)) { + toast.error(`工作流仅包含一个 ${singleNode.data.nodeType} 节点,无法保存。工作流必须以落地页、消息、LLM 或表单节点开始。`, { + duration: 6000, + closeButton: true, + position: 'top-center' + }) + return + } + } + + // Advanced connection validation: Check for isolated nodes + if (nodes.length > 1) { + const connectedNodeIds = new Set<string>() + + edges.forEach(edge => { + connectedNodeIds.add(edge.source) + connectedNodeIds.add(edge.target) + }) + + const isolatedNodes = nodes.filter(node => !connectedNodeIds.has(node.id)) + + if (isolatedNodes.length > 0) { + const isolatedNodeNames = isolatedNodes.map(node => + node.data.cleanName || node.data.label || '未命名节点' + ).join(', ') + + toast.error(`存在孤立节点,无法保存工作流:${isolatedNodeNames}。请先连接所有节点再保存。`, { + duration: 6000, + closeButton: true, + position: 'top-center' + }) + return + } + } + + // Validate all nodes have required configuration + const validation = validateAllNodes(nodes, workflowId, edges) + if (!validation.isValid) { + highlightNodesWithErrors(nodes, workflowId, availableNodeTypes, edges) + toast.error('工作流存在校验错误,无法保存:\n' + validation.errors.join('\n'), { + duration: 8000, + closeButton: true, + position: 'top-center' + }) + return + } + + try { + loading.value = true + + // Save entire cache to backend and get the updated data with real UUIDs + const savedData = await workflowNodeService.saveWorkflowCache(workflowId) + + console.log('Workflow saved, reloading with new UUIDs:', savedData) + + // Close properties panel if open to avoid ID mismatch issues + if (showPropertiesPanel.value) { + closePropertiesPanel() + } + + // Clear current nodes and edges + removeNodes(getNodes().map(n => n.id)) + + // Reload workflow data with new UUIDs from backend + await loadWorkflowData() + + toast.success('工作流已保存', { + position: 'top-center' + }) + } catch (error) { + console.error('Error saving workflow:', error) + toast.error('保存工作流失败', { + position: 'top-center' + }) + } finally { + loading.value = false + } + } + + // Publish workflow + const publishWorkflow = async () => { + try { + publishLoading.value = true + + const nodes = getNodes() + const edges = getEdges() + + // Validate connections before publishing + if (nodes.length > 1 && edges.length === 0) { + toast.error('无法发布节点之间没有连接的工作流,请先连接节点再发布。', { + duration: 5000, + closeButton: true, + position: 'top-center' + }) + return + } + + // For single node workflows, ensure it's a valid starting node type + if (nodes.length === 1) { + const singleNode = nodes[0] + const validStartingNodeTypes = ['landingPage', 'message', 'llm', 'form'] + if (!validStartingNodeTypes.includes(singleNode.data.nodeType)) { + toast.error(`工作流仅包含一个 ${singleNode.data.nodeType} 节点,无法发布。工作流必须以落地页、消息、LLM 或表单节点开始。`, { + duration: 6000, + closeButton: true, + position: 'top-center' + }) + return + } + } + + // Advanced connection validation: Check for isolated nodes + if (nodes.length > 1) { + const connectedNodeIds = new Set<string>() + + edges.forEach(edge => { + connectedNodeIds.add(edge.source) + connectedNodeIds.add(edge.target) + }) + + const isolatedNodes = nodes.filter(node => !connectedNodeIds.has(node.id)) + + if (isolatedNodes.length > 0) { + const isolatedNodeNames = isolatedNodes.map(node => + node.data.cleanName || node.data.label || '未命名节点' + ).join(', ') + + toast.error(`存在孤立节点,无法发布工作流:${isolatedNodeNames}。请先连接所有节点再发布。`, { + duration: 6000, + closeButton: true, + position: 'top-center' + }) + return + } + } + + // Validate before publishing + const validation = validateAllNodes(nodes, workflowId, edges) + if (!validation.isValid) { + highlightNodesWithErrors(nodes, workflowId, availableNodeTypes, edges) + toast.error('发布前请先修正校验错误:\n' + validation.errors.join('\n'), { + duration: 8000, + closeButton: true, + position: 'top-center' + }) + return + } + + // First save the workflow to ensure all changes are persisted + await saveWorkflow() + + // Then publish it + const updatedWorkflow = await workflowService.publishWorkflow(workflowId) + workflowStatus.value = updatedWorkflow.status + + toast.success('工作流已发布!现在已上线,可以处理会话。', { + position: 'top-center' + }) + } catch (error) { + console.error('Error publishing workflow:', error) + toast.error('发布工作流失败', { + position: 'top-center' + }) + } finally { + publishLoading.value = false + } + } + + // Unpublish workflow + const unpublishWorkflow = async () => { + try { + publishLoading.value = true + + const updatedWorkflow = await workflowService.unpublishWorkflow(workflowId) + workflowStatus.value = updatedWorkflow.status + + toast.success('工作流已取消发布!现在处于草稿模式。', { + position: 'top-center' + }) + } catch (error) { + console.error('Error unpublishing workflow:', error) + toast.error('取消发布工作流失败', { + position: 'top-center' + }) + } finally { + publishLoading.value = false + } + } + + // Handle nodes deletion + const handleNodesDelete = (deletedNodes: Node[]) => { + console.log('handleNodesDelete called with:', deletedNodes) + + // Remove deleted nodes from cache + deletedNodes.forEach(node => { + console.log('Deleting node from cache (handleNodesDelete):', node.id) + workflowNodeService.deleteNodeFromCache(workflowId, node.id) + }) + + // Check if the currently selected node was deleted + if (selectedNode.value && deletedNodes.some(node => node.id === selectedNode.value?.id)) { + console.log('Selected node was deleted, closing properties panel') + closePropertiesPanel() + toast.success('节点已删除', { + position: 'top-center' + }) + } + } + + // Handle nodes change (e.g., node added, moved, deleted) + const handleNodesChange = (changes: any) => { + console.log('Nodes changed:', changes) + + changes.forEach((change: any) => { + switch (change.type) { + case 'remove': + console.log('Node removal detected:', change) + workflowNodeService.deleteNodeFromCache(workflowId, change.id) + + if (selectedNode.value && selectedNode.value.id === change.id) { + console.log('Selected node was deleted, closing properties panel') + closePropertiesPanel() + toast.success('节点已删除', { + position: 'top-center' + }) + } + break + + case 'position': + if (change.position) { + const nodeIndex = getNodes().findIndex(n => n.id === change.id) + if (nodeIndex !== -1) { + const node = getNodes()[nodeIndex] + const cacheNodeData = { + id: node.id, + node_type: mapNodeTypeToBackend(node.data.nodeType), + name: node.data.cleanName || node.data.label || 'Node', + description: node.data.description || '', + position_x: change.position.x, + position_y: change.position.y, + config: node.data.config || {} + } + workflowNodeService.updateNodeInCache(workflowId, cacheNodeData) + } + } + break + } + }) + } + + // Handle delete event from Vue Flow + const handleDeleteEvent = (event: any) => { + console.log('Vue Flow delete event:', event) + + const handleSingleNodeDeletion = (nodeId: string) => { + console.log('Deleting node from cache (handleDeleteEvent):', nodeId) + workflowNodeService.deleteNodeFromCache(workflowId, nodeId) + + if (selectedNode.value && selectedNode.value.id === nodeId) { + closePropertiesPanel() + toast.success('节点已删除', { + position: 'top-center' + }) + } + } + + if (Array.isArray(event)) { + const deletedNodes = event.filter((item: any) => item.type === 'node' || !item.type) + deletedNodes.forEach((node: any) => handleSingleNodeDeletion(node.id)) + } else if (event.nodes) { + event.nodes.forEach((node: any) => handleSingleNodeDeletion(node.id)) + } else if (event.id) { + handleSingleNodeDeletion(event.id) + } + } + + // Handle edge changes (including deletion) + const handleEdgesChange = (changes: any[]) => { + console.log('Edges changed:', changes) + + changes.forEach((change: any) => { + switch (change.type) { + case 'remove': + console.log('Edge removal detected:', change) + workflowNodeService.removeConnectionFromCache(workflowId, change.id) + + toast.success('连接已删除', { + position: 'top-center' + }) + break + + // Handle other edge change types if needed in the future + case 'select': + // Edge selection - no cache update needed + break + + case 'add': + // Edge addition - already handled in onConnect + break + } + }) + } + + return { + loadWorkflowData, + saveNodeProperties, + saveWorkflow, + publishWorkflow, + unpublishWorkflow, + handleNodesDelete, + handleNodesChange, + handleDeleteEvent, + handleEdgesChange, + mapNodeTypeToFrontend, + mapNodeTypeToBackend + } +} \ No newline at end of file diff --git a/frontend/src/composables/useWorkflowValidation.ts b/frontend/src/composables/useWorkflowValidation.ts new file mode 100644 index 0000000..ce44585 --- /dev/null +++ b/frontend/src/composables/useWorkflowValidation.ts @@ -0,0 +1,397 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import type { Node } from '@vue-flow/core' +import { workflowCacheStorage } from '@/utils/storage' +import { ExitCondition } from '@/types/workflow' + +export interface ValidationResult { + isValid: boolean + errors: string[] +} + +export interface NodeTypeInfo { + type: string + label: string + icon: string + color: string + description: string +} + +export function useWorkflowValidation() { + + // Get node data with latest cache values + const getNodeDataWithCache = (node: Node, workflowId: string) => { + // Get cached workflow data + const workflowCache = workflowCacheStorage.getWorkflowCache(workflowId) + + // Merge node data with cache data, prioritizing cache + const nodeData = { ...node.data } + + if (workflowCache && workflowCache.nodes) { + // Find this node in the cache + const cachedNode = workflowCache.nodes.find((n: any) => n.id === node.id) + if (cachedNode) { + // Update name from cache + if (cachedNode.name) nodeData.cleanName = cachedNode.name + + // Update config values from cache + if (cachedNode.config) { + Object.assign(nodeData, cachedNode.config) + nodeData.config = cachedNode.config + } + } + } + + return nodeData + } + + // Validate a single node based on its type + const validateNode = (nodeType: string, nodeData: any): string[] => { + const errors: string[] = [] + + switch (nodeType) { + case 'message': + if (!nodeData.cleanName || nodeData.cleanName.trim() === '') { + errors.push(`${nodeType} 节点:名称必填`) + } + const messageText = nodeData.message_text || nodeData.config?.message_text + if (!messageText || messageText.trim() === '') { + errors.push(`${nodeType} 节点“${nodeData.cleanName || '未命名'}”:消息内容必填`) + } + break + + case 'llm': + if (!nodeData.cleanName || nodeData.cleanName.trim() === '') { + errors.push(`${nodeType} 节点:名称必填`) + } + const systemPrompt = nodeData.system_prompt || nodeData.config?.system_prompt + if (!systemPrompt || systemPrompt.trim() === '') { + errors.push(`${nodeType} 节点“${nodeData.cleanName || '未命名'}”:系统提示词必填`) + } + break + + case 'condition': + if (!nodeData.cleanName || nodeData.cleanName.trim() === '') { + errors.push(`${nodeType} 节点:名称必填`) + } + + // Check for condition expression + const conditionExpression = nodeData.condition_expression || nodeData.config?.condition_expression + if (!conditionExpression || conditionExpression.trim() === '') { + errors.push(`${nodeType} 节点“${nodeData.cleanName || '未命名'}”:条件表达式必填`) + } + + // Check for valid condition groups and rules + const conditionGroups = nodeData.condition_groups || nodeData.config?.condition_groups + if (conditionGroups && Array.isArray(conditionGroups)) { + let hasValidRule = false + + for (let groupIndex = 0; groupIndex < conditionGroups.length; groupIndex++) { + const group = conditionGroups[groupIndex] + if (group.rules && Array.isArray(group.rules)) { + for (let ruleIndex = 0; ruleIndex < group.rules.length; ruleIndex++) { + const rule = group.rules[ruleIndex] + + // Check if rule has all required fields + if (rule.variable && rule.operator && rule.value) { + hasValidRule = true + } else { + // Only show error for incomplete rules if they have any field filled + if (rule.variable || rule.operator || rule.value) { + const missingFields = [] + if (!rule.variable) missingFields.push('variable') + if (!rule.operator) missingFields.push('operator') + if (!rule.value) missingFields.push('value') + + errors.push(`${nodeType} 节点“${nodeData.cleanName || '未命名'}”第 ${groupIndex + 1} 组、第 ${ruleIndex + 1} 条规则:缺少 ${missingFields.join(', ')}`) + } + } + } + } + } + + // If no valid rules found, add error + if (!hasValidRule) { + errors.push(`${nodeType} 节点“${nodeData.cleanName || '未命名'}”:至少需要一条完整的条件规则(variable、operator 和 value)`) + } + } else { + // No condition groups found, this is an error for the new structured format + errors.push(`${nodeType} 节点“${nodeData.cleanName || '未命名'}”:至少需要一条条件规则`) + } + break + + case 'form': + if (!nodeData.cleanName || nodeData.cleanName.trim() === '') { + errors.push(`${nodeType} 节点:名称必填`) + } + const formFields = nodeData.form_fields || nodeData.config?.form_fields || [] + if (!formFields || formFields.length === 0) { + errors.push(`${nodeType} 节点“${nodeData.cleanName || '未命名'}”:至少需要一个表单字段`) + } else { + for (let i = 0; i < formFields.length; i++) { + const field = formFields[i] + if (!field.name || field.name.trim() === '') { + errors.push(`${nodeType} 节点“${nodeData.cleanName || '未命名'}”第 ${i + 1} 个字段:字段名必填`) + } + if (!field.label || field.label.trim() === '') { + errors.push(`${nodeType} 节点“${nodeData.cleanName || '未命名'}”第 ${i + 1} 个字段:显示名称必填`) + } + } + } + break + + case 'action': + if (!nodeData.cleanName || nodeData.cleanName.trim() === '') { + errors.push(`${nodeType} 节点:名称必填`) + } + const actionType = nodeData.action_type || nodeData.config?.action_type + if (!actionType || actionType.trim() === '') { + errors.push(`${nodeType} 节点“${nodeData.cleanName || '未命名'}”:操作类型必填`) + } + const actionUrl = nodeData.action_url || nodeData.config?.action_url + if (!actionUrl || actionUrl.trim() === '') { + errors.push(`${nodeType} 节点“${nodeData.cleanName || '未命名'}”:操作 URL 必填`) + } + break + + case 'humanTransfer': + if (!nodeData.cleanName || nodeData.cleanName.trim() === '') { + errors.push(`${nodeType} 节点:名称必填`) + } + const transferDepartment = nodeData.transfer_department || nodeData.config?.transfer_department + if (!transferDepartment || transferDepartment.trim() === '') { + errors.push(`${nodeType} 节点“${nodeData.cleanName || '未命名'}”:部门必填`) + } + break + + case 'wait': + if (!nodeData.cleanName || nodeData.cleanName.trim() === '') { + errors.push(`${nodeType} 节点:名称必填`) + } + const waitDuration = nodeData.wait_duration || nodeData.config?.wait_duration + if (!waitDuration || waitDuration < 1) { + errors.push(`${nodeType} 节点“${nodeData.cleanName || '未命名'}”:等待时长至少为 1`) + } + const waitUnit = nodeData.wait_unit || nodeData.config?.wait_unit + if (!waitUnit || waitUnit.trim() === '') { + errors.push(`${nodeType} 节点“${nodeData.cleanName || '未命名'}”:时间单位必填`) + } + break + + case 'landingPage': + if (!nodeData.cleanName || nodeData.cleanName.trim() === '') { + errors.push(`${nodeType} 节点:名称必填`) + } + const landingPageHeading = nodeData.landing_page_heading || nodeData.config?.landing_page_heading + if (!landingPageHeading || landingPageHeading.trim() === '') { + errors.push(`${nodeType} 节点“${nodeData.cleanName || '未命名'}”:标题必填`) + } + const landingPageContent = nodeData.landing_page_content || nodeData.config?.landing_page_content + if (!landingPageContent || landingPageContent.trim() === '') { + errors.push(`${nodeType} 节点“${nodeData.cleanName || '未命名'}”:内容必填`) + } + break + + case 'userInput': + if (!nodeData.cleanName || nodeData.cleanName.trim() === '') { + errors.push(`${nodeType} 节点:名称必填`) + } + // Removed prompt_message validation - it's now optional + break + + case 'guardrails': + if (!nodeData.cleanName || nodeData.cleanName.trim() === '') { + errors.push(`${nodeType} 节点:名称必填`) + } + const enabledGuardrails = nodeData.enabled_guardrails || nodeData.config?.enabled_guardrails || [] + if (!enabledGuardrails || enabledGuardrails.length === 0) { + errors.push(`${nodeType} 节点“${nodeData.cleanName || '未命名'}”:至少需要启用一项护栏`) + } + break + + default: + if (!nodeData.cleanName || nodeData.cleanName.trim() === '') { + errors.push(`${nodeType} 节点:名称必填`) + } + break + } + + return errors + } + + // Check if a node has validation errors (simplified to use validateNode) + const hasNodeValidationErrors = (nodeType: string, nodeData: any): boolean => { + return validateNode(nodeType, nodeData).length > 0 + } + + // Validate all nodes in a workflow and return detailed error messages + const validateAllNodes = (nodes: Node[], workflowId: string, edges?: any[]): ValidationResult => { + const errors: string[] = [] + + for (const node of nodes) { + const nodeType = node.data.nodeType + const nodeData = getNodeDataWithCache(node, workflowId) + const nodeErrors = validateNode(nodeType, nodeData) + errors.push(...nodeErrors) + } + + // Workflow-level validation: Check for LLM nodes with continuous execution that have outgoing connections + if (edges) { + for (const node of nodes) { + if (node.data.nodeType === 'llm') { + const nodeData = getNodeDataWithCache(node, workflowId) + const exitCondition = nodeData.config?.exit_condition || nodeData.exit_condition + + if (exitCondition === ExitCondition.CONTINUOUS_EXECUTION) { + // Check if this LLM node has any outgoing edges + const hasOutgoingConnections = edges.some(edge => edge.source === node.id) + + if (hasOutgoingConnections) { + const nodeName = nodeData.cleanName || nodeData.label || '未命名 LLM 节点' + errors.push(`LLM 节点“${nodeName}”启用了持续执行,但存在出边连接。持续执行的 LLM 节点必须是终端节点。`) + } + } + } + + // Validate condition node connections + if (node.data.nodeType === 'condition') { + const nodeData = getNodeDataWithCache(node, workflowId) + const nodeName = nodeData.cleanName || nodeData.label || '未命名条件节点' + const outgoingConnections = edges.filter(edge => edge.source === node.id) + + // Condition nodes must have exactly 2 outgoing connections + if (outgoingConnections.length === 0) { + errors.push(`条件节点“${nodeName}”没有出边连接,必须同时具有 true 和 false 路径。`) + } else if (outgoingConnections.length === 1) { + const existingLabel = outgoingConnections[0].label + const missingLabel = existingLabel === 'true' ? 'false' : 'true' + errors.push(`条件节点“${nodeName}”缺少 ${missingLabel} 路径,必须同时具有 true 和 false 路径。`) + } else if (outgoingConnections.length > 2) { + errors.push(`条件节点“${nodeName}”出边连接过多(${outgoingConnections.length}),只能有 true 和 false 两条路径。`) + } else { + // Check that we have both true and false labels + const labels = outgoingConnections.map(edge => edge.label).filter(Boolean) + const hasTrue = labels.includes('true') + const hasFalse = labels.includes('false') + + if (!hasTrue && !hasFalse) { + errors.push(`条件节点“${nodeName}”的连接缺少 true 和 false 标签,必须同时具有这两条路径。`) + } else if (!hasTrue) { + errors.push(`条件节点“${nodeName}”缺少 true 路径,必须同时具有 true 和 false 路径。`) + } else if (!hasFalse) { + errors.push(`条件节点“${nodeName}”缺少 false 路径,必须同时具有 true 和 false 路径。`) + } + } + } + } + } + + return { isValid: errors.length === 0, errors } + } + + // Check if a specific node has connection-related validation errors + const hasNodeConnectionErrors = (node: Node, workflowId: string, edges: any[]): boolean => { + if (node.data.nodeType !== 'condition') { + return false + } + + const outgoingConnections = edges.filter(edge => edge.source === node.id) + + // Check for connection errors + if (outgoingConnections.length !== 2) { + return true + } + + const labels = outgoingConnections.map(edge => edge.label).filter(Boolean) + const hasTrue = labels.includes('true') + const hasFalse = labels.includes('false') + + return !hasTrue || !hasFalse + } + + // Quick check if workflow has any validation errors (without detailed messages) + const hasWorkflowValidationErrors = (nodes: Node[], workflowId: string): boolean => { + for (const node of nodes) { + const nodeType = node.data.nodeType + const nodeData = getNodeDataWithCache(node, workflowId) + if (hasNodeValidationErrors(nodeType, nodeData)) { + return true + } + } + return false + } + + // Apply error styling to nodes with validation errors + const highlightNodesWithErrors = (nodes: Node[], workflowId: string, availableNodeTypes: NodeTypeInfo[], edges: any[] = []) => { + // Update node styles to show validation errors + nodes.forEach(node => { + const nodeType = node.data.nodeType + const nodeData = getNodeDataWithCache(node, workflowId) + const hasPropertyError = hasNodeValidationErrors(nodeType, nodeData) + const hasConnectionError = hasNodeConnectionErrors(node, workflowId, edges) + const hasError = hasPropertyError || hasConnectionError + + // Update node style based on validation + if (hasError) { + node.style = { + ...node.style, + borderColor: '#EF4444', + borderWidth: '2px', + borderStyle: 'solid', + boxShadow: '0 0 0 2px rgba(239, 68, 68, 0.2)' + } + } else { + // Reset to original style + const nodeTypeInfo = availableNodeTypes.find(t => t.type === node.data.nodeType) + node.style = { + backgroundColor: nodeTypeInfo?.color || '#6B7280', + color: 'white', + borderColor: nodeTypeInfo?.color || '#6B7280' + } + } + }) + } + + // Reset a specific node's validation styling + const resetNodeValidationStyle = (node: Node, availableNodeTypes: NodeTypeInfo[]) => { + const nodeTypeInfo = availableNodeTypes.find(t => t.type === node.data.nodeType) + node.style = { + backgroundColor: nodeTypeInfo?.color || '#6B7280', + color: 'white', + borderColor: nodeTypeInfo?.color || '#6B7280' + } + } + + // Reset all nodes validation styling + const resetAllNodesValidationStyle = (nodes: Node[], availableNodeTypes: NodeTypeInfo[]) => { + nodes.forEach(node => { + resetNodeValidationStyle(node, availableNodeTypes) + }) + } + + return { + validateAllNodes, + validateNode, + hasNodeValidationErrors, + hasNodeConnectionErrors, + hasWorkflowValidationErrors, + highlightNodesWithErrors, + resetNodeValidationStyle, + resetAllNodesValidationStyle, + getNodeDataWithCache + } +} \ No newline at end of file diff --git a/frontend/src/utils/channelAccounts.ts b/frontend/src/utils/channelAccounts.ts new file mode 100644 index 0000000..d7fa56c --- /dev/null +++ b/frontend/src/utils/channelAccounts.ts @@ -0,0 +1,31 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import type { ChannelAccount } from '@/services/channels' + +/** The integration-card warning for accounts whose platform stopped accepting + * their credentials, or undefined when every account is healthy. */ +export const reconnectWarning = (accounts: ChannelAccount[]): string | undefined => { + const expired = accounts.filter(a => a.needs_reconnect) + if (!expired.length) return undefined + const names = expired.map(a => a.display_name || a.external_account_id).join(', ') + return `以下账号的访问权限已过期:${names},请重新连接以恢复回复。` +} + +/** The account a card's Manage opens: one that needs reconnecting first, so + * the card's warning leads to its fix, else the first connected. */ +export const accountToManage = (accounts: ChannelAccount[]): ChannelAccount | null => + accounts.find(a => a.needs_reconnect) ?? accounts[0] ?? null diff --git a/frontend/src/utils/mcp.ts b/frontend/src/utils/mcp.ts new file mode 100644 index 0000000..a4906a9 --- /dev/null +++ b/frontend/src/utils/mcp.ts @@ -0,0 +1,99 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +/** + * Connector timeout, in seconds. Bounds the MCP startup handshake and each + * later tool call. Cold `npx` launches resolve and download the package + * first, so the default is generous. + */ +export const DEFAULT_MCP_TIMEOUT = 30 +export const MIN_MCP_TIMEOUT = 1 +export const MAX_MCP_TIMEOUT = 300 + +/** + * Keep the timeout inside the range the API accepts. A number input's + * min/max don't block submission, and the resulting 422 carries a validation + * payload rather than a message a toast can show. + */ +export function clampMCPTimeout(value: number | undefined | null): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return DEFAULT_MCP_TIMEOUT + } + return Math.min(Math.max(Math.round(value), MIN_MCP_TIMEOUT), MAX_MCP_TIMEOUT) +} + +/** + * What the API sends instead of a stored environment/header value. Sending it + * back unchanged keeps the stored credential, so editing an unrelated field + * never forces the operator to mint a new one — providers typically show an + * API key exactly once. Must match SECRET_MASK in the backend schema. + */ +export const SECRET_MASK = '********' + +/** Record -> "KEY=value" lines, for the textarea editors. */ +export function recordToLines(record: Record<string, string> | undefined | null): string { + return Object.entries(record || {}) + .map(([key, value]) => `${key}=${value}`) + .join('\n') +} + +/** "KEY=value" lines -> record. Lines without '=' are ignored. */ +export function linesToRecord(lines: string): Record<string, string> { + const result: Record<string, string> = {} + for (const line of lines.split('\n')) { + const idx = line.indexOf('=') + if (idx > 0) result[line.slice(0, idx).trim()] = line.slice(idx + 1).trim() + } + return result +} + +/** + * Ceiling on a connector's usage guidance. Must match MAX_USAGE_GUIDANCE_CHARS + * in the backend schema — if it drifts, the operator hits a bare 422 toast + * instead of the textarea stopping them. + */ +export const MAX_GUIDANCE_CHARS = 2000 + +/** + * Copy for the connector guidance field. Lives here because the field appears + * in two structurally unrelated forms — the investigation connector list and + * the agent MCP tools modal — and the wording must not drift between them. + */ +export const CONNECTOR_GUIDANCE_LABEL = '如何查询此数据源' + +export const CONNECTOR_GUIDANCE_HINT = + 'AI 使用此连接器时会收到这段说明。请写清应查询的索引、项目或数据表,' + + '以及用于识别客户或订单的字段。选填——留空则由 AI 自行探索。' + +/** + * Deliberately written as instructions, not as a description. Tested against a + * real MCP server and a real model: naming the field ("Order id is + * fields.order_ref") got the right index but a bare keyword query and a false + * "no such order". Telling it what to *do* ("query app-logs-* with + * fields.order_ref:<ref> — always name that field") found the record in one + * call. Operators copy this example, so it has to model the phrasing that + * works. + * + * The last line matters just as much: a query against a field that does not + * exist returns zero hits rather than an error, so without the warning the AI + * reads a clean "no results" and concludes the record is absent. + */ +export const CONNECTOR_GUIDANCE_EXAMPLE = `索引:app-logs-*(每次请求一条文档)、payment-logs-*(Stripe Webhook)。 +要查找订单,请用 \`fields.order_ref:<订单号>\` 查询 app-logs-*—— +务必显式指定该字段;只搜关键词不会命中。 +时间戳字段为 @timestamp,UTC,保留 30 天。 +不存在 order_id 字段;用它匹配只会返回 0 条而不是报错, +所以结果为空并不能证明订单不存在。` diff --git a/frontend/src/utils/metaSdk.ts b/frontend/src/utils/metaSdk.ts new file mode 100644 index 0000000..c552a0b --- /dev/null +++ b/frontend/src/utils/metaSdk.ts @@ -0,0 +1,253 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +const SDK_SRC = 'https://connect.facebook.net/en_US/sdk.js' +const SDK_SCRIPT_ID = 'facebook-jssdk' + +/** Where each provider's OAuth dialog lives. Instagram Login is a different + * provider, not a Facebook dialog, so it has its own endpoint. */ +const FACEBOOK_DIALOG_BASE = 'https://www.facebook.com' +const INSTAGRAM_AUTHORIZE_URL = 'https://www.instagram.com/oauth/authorize' + +/** + * Static page Meta redirects the Login-for-Business popup to (see + * public/meta-oauth-callback.html). Its full URL — origin + this path — is the + * redirect_uri, so it must be registered as a Valid OAuth Redirect URI and is + * sent to the server to exchange the code against. + */ +export const META_OAUTH_CALLBACK_PATH = '/meta-oauth-callback.html' + +/** + * Origins Embedded Signup posts its result from. + * + * Meta's own sample checks `origin.endsWith('facebook.com')`, which any + * attacker can satisfy by registering evil-facebook.com. The signup result + * names the WhatsApp number we then connect, so this is an exact allowlist. + */ +const TRUSTED_ORIGINS = [ + 'https://www.facebook.com', + 'https://web.facebook.com', + 'https://business.facebook.com', +] + +/** Channels connectable through a Meta login popup. */ +export type SignupChannel = 'whatsapp' | 'messenger' | 'instagram' + +export interface SignupSession { + waba_id: string + phone_number_id: string +} + +interface FacebookSdk { + init(options: Record<string, unknown>): void + login( + callback: (response: { authResponse?: { code?: string } }) => void, + options: Record<string, unknown>, + ): void +} + +declare global { + interface Window { + FB?: FacebookSdk + fbAsyncInit?: () => void + } +} + +let sdkPromise: Promise<FacebookSdk> | null = null + +/** Load and initialise Meta's JS SDK once per page, for a given app. */ +export const loadMetaSdk = (appId: string, graphVersion: string): Promise<FacebookSdk> => { + if (sdkPromise) return sdkPromise + + sdkPromise = new Promise<FacebookSdk>((resolve, reject) => { + if (window.FB) { + resolve(window.FB) + return + } + + window.fbAsyncInit = () => { + window.FB?.init({ + appId, + autoLogAppEvents: true, + xfbml: false, + version: graphVersion, + }) + if (window.FB) resolve(window.FB) + else reject(new Error('Meta SDK 已加载但未初始化')) + } + + if (document.getElementById(SDK_SCRIPT_ID)) return + + const script = document.createElement('script') + script.id = SDK_SCRIPT_ID + script.src = SDK_SRC + script.async = true + script.defer = true + script.crossOrigin = 'anonymous' + script.onerror = () => { + // Let a later attempt retry rather than caching the failure forever — + // and take the dead tag with it. Left in place, the retry's `already + // present?` check above would find it and return from inside the promise + // executor without resolving or rejecting, so `await loadMetaSdk()` would + // hang forever: the Embedded Signup button simply never appears, and no + // catch ever runs to say why. + script.remove() + sdkPromise = null + reject(new Error('无法加载 Meta SDK')) + } + document.body.appendChild(script) + }) + + return sdkPromise +} + +/** + * Read a signup result out of a postMessage, or null if it isn't one. + * + * WhatsApp Embedded Signup only: it reports the created WABA and phone number + * through postMessage while the authorization code comes back via the FB.login + * callback, so the two halves must be correlated by the caller. Facebook Login + * for Business (Messenger/Instagram) has no postMessage — its code alone is + * enough — so this is not part of that flow. + */ +export const parseSignupMessage = (event: MessageEvent): SignupSession | null => { + if (!TRUSTED_ORIGINS.includes(event.origin)) return null + if (typeof event.data !== 'string') return null + try { + const payload = JSON.parse(event.data) + if (payload?.type !== 'WA_EMBEDDED_SIGNUP' || payload.event !== 'FINISH') return null + const { waba_id: wabaId, phone_number_id: phoneNumberId } = payload.data ?? {} + if (!wabaId || !phoneNumberId) return null + return { waba_id: String(wabaId), phone_number_id: String(phoneNumberId) } + } catch { + // Facebook posts unrelated non-JSON messages on these origins too. + return null + } +} + +/** + * Options for the Meta login popup that onboards a channel. + * + * `extras.setup` is WhatsApp Embedded Signup's own parameter — Facebook Login + * for Business is a plain permissions grant and takes none, so passing it would + * open the wrong flow. + */ +export const signupLoginOptions = (configId: string, channel: SignupChannel = 'whatsapp') => ({ + config_id: configId, + response_type: 'code', + override_default_response_type: true, + ...(channel === 'whatsapp' ? { extras: { setup: {} } } : {}), +}) + +export interface BusinessLoginConfig { + appId: string + configId: string + graphVersion: string + /** window.location.origin + META_OAUTH_CALLBACK_PATH */ + redirectUri: string +} + +export interface InstagramLoginConfig { + /** The Instagram app id — not the Meta one; Instagram Login is its own app. */ + appId: string + /** window.location.origin + META_OAUTH_CALLBACK_PATH */ + redirectUri: string +} + +/** What Instagram Login must grant us: read the account, and handle its DMs. */ +const INSTAGRAM_SCOPES = 'instagram_business_basic,instagram_business_manage_messages' + +/** + * Run an OAuth authorization in a popup and resolve the code it returns. + * + * Why we drive the dialog ourselves rather than use FB.login: the JS SDK binds + * the code to an internal xd_arbiter redirect that regenerates every login, so + * it can never be a registered Valid OAuth Redirect URI — fatal once the app + * enforces Strict Mode (which it does, non-negotiably). Owning the callback URL + * means the code is bound to a value the server can reproduce at exchange time. + * + * `buildUrl` receives the CSRF state to embed; the provider echoes it back on + * the redirect and we accept only a match. Resolves with the code, rejects on + * error, popup-block, or the user closing the window ('cancelled'). + */ +const runOAuthPopup = (buildUrl: (state: string) => string): Promise<string> => + new Promise((resolve, reject) => { + const state = crypto.randomUUID() + const popup = window.open(buildUrl(state), 'meta-login', 'width=600,height=720') + if (!popup) { + reject(new Error('弹窗被拦截,请允许此站点弹出窗口后重试')) + return + } + + const cleanup = () => { + window.removeEventListener('message', onMessage) + window.clearInterval(closedTimer) + } + const onMessage = (event: MessageEvent) => { + // The callback page is same-origin, so anything else is not our reply. + if (event.origin !== window.location.origin) return + const data = event.data + if (!data || data.source !== 'meta-oauth') return + cleanup() + try { popup.close() } catch { /* already closed */ } + if (data.state !== state) reject(new Error('无法验证登录')) + else if (data.error) reject(new Error(data.error)) + else if (!data.code) reject(new Error('登录未返回授权码')) + else resolve(data.code) + } + const closedTimer = window.setInterval(() => { + if (popup.closed) { cleanup(); reject(new Error('cancelled')) } + }, 500) + window.addEventListener('message', onMessage) + }) + +/** + * Facebook Login for Business — used by Messenger, which connects a Page. + * + * The same redirectUri must go to the token exchange, so the caller passes it + * on to the server. + */ +export const runBusinessLogin = (config: BusinessLoginConfig): Promise<string> => + runOAuthPopup((state) => + `${FACEBOOK_DIALOG_BASE}/${config.graphVersion}/dialog/oauth?` + + new URLSearchParams({ + client_id: config.appId, + config_id: config.configId, + response_type: 'code', + override_default_response_type: 'true', + redirect_uri: config.redirectUri, + state, + display: 'popup', + }).toString()) + +/** + * Instagram Business Login — a different provider entirely, not a Facebook + * dialog: the business signs in with Instagram, so no Facebook Page is involved + * and the resulting token is an Instagram user token. + */ +export const runInstagramLogin = (config: InstagramLoginConfig): Promise<string> => + runOAuthPopup((state) => + `${INSTAGRAM_AUTHORIZE_URL}?` + + new URLSearchParams({ + client_id: config.appId, + redirect_uri: config.redirectUri, + response_type: 'code', + scope: INSTAGRAM_SCOPES, + state, + // Make the business pick the account deliberately rather than silently + // reusing whoever is already signed in on this browser. + force_reauth: 'true', + }).toString()) diff --git a/frontend/src/utils/whatsappLanguages.ts b/frontend/src/utils/whatsappLanguages.ts new file mode 100644 index 0000000..869b930 --- /dev/null +++ b/frontend/src/utils/whatsappLanguages.ts @@ -0,0 +1,159 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +/** + * The languages Meta accepts for a WhatsApp message template, transcribed from + * https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/supported-languages + * + * This list exists only to stop typos reaching Graph — Meta remains the + * authority on what it accepts, and the server passes the code through + * untouched. A language Meta adds after this was written is simply missing from + * the picker until we update it; it is never rejected by us, and a template + * already using one still displays (see languageLabel). + * + * Codes are not uniformly `xx` or `xx_YY`: `fil` and `prs_AF` have three-letter + * languages, `es_CRI` a three-letter region. Do not "tidy" them. + */ +export interface WhatsAppLanguage { + code: string + label: string +} + +/** Meta's own names, kept verbatim so they can be cross-referenced against the + * Business Manager UI. Sorted by label here rather than at runtime — a sort on + * import hides the ordering from review and re-runs on every module load. */ +export const WHATSAPP_LANGUAGES: readonly WhatsAppLanguage[] = [ + { code: 'af', label: 'Afrikaans' }, + { code: 'sq', label: 'Albanian' }, + { code: 'ar', label: 'Arabic' }, + { code: 'ar_EG', label: 'Arabic (EGY)' }, + { code: 'ar_LB', label: 'Arabic (LBN)' }, + { code: 'ar_MA', label: 'Arabic (MAR)' }, + { code: 'ar_QA', label: 'Arabic (QAT)' }, + { code: 'ar_AE', label: 'Arabic (UAE)' }, + { code: 'az', label: 'Azerbaijani' }, + { code: 'be_BY', label: 'Belarusian' }, + { code: 'bn', label: 'Bengali' }, + { code: 'bn_IN', label: 'Bengali (IND)' }, + { code: 'bg', label: 'Bulgarian' }, + { code: 'ca', label: 'Catalan' }, + { code: 'zh_CN', label: 'Chinese (CHN)' }, + { code: 'zh_HK', label: 'Chinese (HKG)' }, + { code: 'zh_TW', label: 'Chinese (TAI)' }, + { code: 'hr', label: 'Croatian' }, + { code: 'cs', label: 'Czech' }, + { code: 'da', label: 'Danish' }, + { code: 'prs_AF', label: 'Dari' }, + { code: 'nl', label: 'Dutch' }, + { code: 'nl_BE', label: 'Dutch (BEL)' }, + { code: 'en', label: 'English' }, + { code: 'en_AU', label: 'English (AUS)' }, + { code: 'en_CA', label: 'English (CAN)' }, + { code: 'en_GH', label: 'English (GHA)' }, + { code: 'en_IN', label: 'English (IND)' }, + { code: 'en_IE', label: 'English (IRL)' }, + { code: 'en_JM', label: 'English (JAM)' }, + { code: 'en_MY', label: 'English (MYS)' }, + { code: 'en_NZ', label: 'English (NZL)' }, + { code: 'en_QA', label: 'English (QAT)' }, + { code: 'en_SG', label: 'English (SGP)' }, + { code: 'en_AE', label: 'English (UAE)' }, + { code: 'en_UG', label: 'English (UGA)' }, + { code: 'en_GB', label: 'English (UK)' }, + { code: 'en_US', label: 'English (US)' }, + { code: 'en_ZA', label: 'English (ZAF)' }, + { code: 'et', label: 'Estonian' }, + { code: 'fil', label: 'Filipino' }, + { code: 'fi', label: 'Finnish' }, + { code: 'fr', label: 'French' }, + { code: 'fr_BE', label: 'French (BEL)' }, + { code: 'fr_CA', label: 'French (CAN)' }, + { code: 'fr_CH', label: 'French (CHE)' }, + { code: 'fr_CI', label: 'French (CIV)' }, + { code: 'fr_MA', label: 'French (MAR)' }, + { code: 'ka', label: 'Georgian' }, + { code: 'de', label: 'German' }, + { code: 'de_AT', label: 'German (AUT)' }, + { code: 'de_CH', label: 'German (CHE)' }, + { code: 'el', label: 'Greek' }, + { code: 'gu', label: 'Gujarati' }, + { code: 'ha', label: 'Hausa' }, + { code: 'he', label: 'Hebrew' }, + { code: 'hi', label: 'Hindi' }, + { code: 'hu', label: 'Hungarian' }, + { code: 'id', label: 'Indonesian' }, + { code: 'ga', label: 'Irish' }, + { code: 'it', label: 'Italian' }, + { code: 'ja', label: 'Japanese' }, + { code: 'kn', label: 'Kannada' }, + { code: 'kk', label: 'Kazakh' }, + { code: 'rw_RW', label: 'Kinyarwanda' }, + { code: 'ko', label: 'Korean' }, + { code: 'ky_KG', label: 'Kyrgyz (Kyrgyzstan)' }, + { code: 'lo', label: 'Lao' }, + { code: 'lv', label: 'Latvian' }, + { code: 'lt', label: 'Lithuanian' }, + { code: 'mk', label: 'Macedonian' }, + { code: 'ms', label: 'Malay' }, + { code: 'ml', label: 'Malayalam' }, + { code: 'mr', label: 'Marathi' }, + { code: 'nb', label: 'Norwegian' }, + { code: 'ps_AF', label: 'Pashto' }, + { code: 'fa', label: 'Persian' }, + { code: 'pl', label: 'Polish' }, + { code: 'pt_BR', label: 'Portuguese (BR)' }, + { code: 'pt_PT', label: 'Portuguese (POR)' }, + { code: 'pa', label: 'Punjabi' }, + { code: 'ro', label: 'Romanian' }, + { code: 'ru', label: 'Russian' }, + { code: 'sr', label: 'Serbian' }, + { code: 'si_LK', label: 'Sinhala' }, + { code: 'sk', label: 'Slovak' }, + { code: 'sl', label: 'Slovenian' }, + { code: 'es', label: 'Spanish' }, + { code: 'es_AR', label: 'Spanish (ARG)' }, + { code: 'es_CL', label: 'Spanish (CHL)' }, + { code: 'es_CO', label: 'Spanish (COL)' }, + { code: 'es_CRI', label: 'Spanish (CRI)' }, + { code: 'es_DO', label: 'Spanish (DOM)' }, + { code: 'es_EC', label: 'Spanish (ECU)' }, + { code: 'es_HN', label: 'Spanish (HND)' }, + { code: 'es_MX', label: 'Spanish (MEX)' }, + { code: 'es_PA', label: 'Spanish (PAN)' }, + { code: 'es_PE', label: 'Spanish (PER)' }, + { code: 'es_ES', label: 'Spanish (SPA)' }, + { code: 'es_UY', label: 'Spanish (URY)' }, + { code: 'sw', label: 'Swahili' }, + { code: 'sv', label: 'Swedish' }, + { code: 'ta', label: 'Tamil' }, + { code: 'te', label: 'Telugu' }, + { code: 'th', label: 'Thai' }, + { code: 'tr', label: 'Turkish' }, + { code: 'uk', label: 'Ukrainian' }, + { code: 'ur', label: 'Urdu' }, + { code: 'uz', label: 'Uzbek' }, + { code: 'vi', label: 'Vietnamese' }, + { code: 'zu', label: 'Zulu' }, +] as const + +export const DEFAULT_LANGUAGE = 'en_US' + +const BY_CODE = new Map(WHATSAPP_LANGUAGES.map((language) => [language.code, language.label])) + +/** The display name for a code, falling back to the code itself — a template + * may use a language Meta added after this list was written, and showing the + * raw code beats showing nothing. */ +export const languageLabel = (code: string): string => BY_CODE.get(code) ?? code diff --git a/frontend/src/utils/whatsappTemplates.ts b/frontend/src/utils/whatsappTemplates.ts new file mode 100644 index 0000000..4cc33bd --- /dev/null +++ b/frontend/src/utils/whatsappTemplates.ts @@ -0,0 +1,200 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +import type { TemplateComponent, WhatsAppTemplate } from '@/services/channels' + +const PLACEHOLDER = /\{\{(\d+)\}\}/g + +/** + * Meta supplies an authentication template's verification code as the single + * body parameter. There is always exactly one, and it never appears as {{n}} in + * stored text — so it is modelled as variable 1 to reuse the same fill/validate + * path as an authored template. + */ +const AUTH_CODE_INDEX = 1 + +/** + * Authentication copy belongs to Meta and is localised per language — Spanish + * puts the code mid-sentence, and the button label changes with it — so it is + * described here, never reproduced. Anything written out in English would be + * wrong for most templates. The create form fetches the real rendering from + * Meta's template-previews API. + */ +export const AUTH_DESCRIPTION = 'WhatsApp 自带的验证码消息' + +export const authSendSummary = (code: string): string => + code.trim() + ? `WhatsApp 将发送包含验证码 ${code.trim()} 的验证码消息。` + : 'WhatsApp 将发送自带的验证码消息。' + +const placeholdersIn = (text: string): number[] => { + const found = new Set<number>() + for (const match of text.matchAll(PLACEHOLDER)) found.add(Number(match[1])) + return [...found].sort((a, b) => a - b) +} + +const componentOfType = (template: WhatsAppTemplate, type: string): TemplateComponent | undefined => + template.components?.find((c) => c.type?.toUpperCase() === type) + +/** The template's message text, which is what the customer actually receives. */ +export const templateBody = (template: WhatsAppTemplate): string => + componentOfType(template, 'BODY')?.text ?? '' + +/** + * What identifies one template. WhatsApp has no multilingual template: name and + * language together identify one, so `order_update` in five languages is five + * templates and Graph returns a row for each. + * + * Keying on the name alone therefore treats siblings as the same thing — the + * list renders duplicate keys, every language of a name highlights as selected + * at once, and picking a different language is a no-op that silently sends the + * one already chosen. Meta doesn't return `id` on the list edge (see + * TEMPLATE_FIELDS in meta_base.py), so the pair is the identity we have. + */ +export const templateKey = (template: WhatsAppTemplate): string => + `${template.name}|${template.language ?? ''}` + +/** Whether two rows are the same template, not merely same-named. */ +export const isSameTemplate = ( + a: WhatsAppTemplate | null | undefined, + b: WhatsAppTemplate | null | undefined, +): boolean => !!a && !!b && templateKey(a) === templateKey(b) + +/** + * True for an authentication template — a one-time passcode. + * + * Meta owns these entirely: the body is its own fixed copy, assembled from + * add_security_recommendation / code_expiration_minutes, and the verification + * code is supplied at send time rather than written into the template. So they + * are read and sent differently from a template we author. + */ +export const isAuthentication = (template: WhatsAppTemplate): boolean => + template.category === 'AUTHENTICATION' + +/** A placeholder in a non-body text component — a header or footer. */ +const hasTextPlaceholderOutsideBody = (component: TemplateComponent): boolean => + component.type?.toUpperCase() !== 'BODY' && + typeof component.text === 'string' && + placeholdersIn(component.text).length > 0 + +/** A dynamic URL button, e.g. https://example.com/orders/{{1}}. The placeholder + * lives in the button's `url`, not in any component `text`. */ +const hasUrlPlaceholder = (component: TemplateComponent): boolean => + ((component.buttons as { url?: string }[] | undefined) ?? []).some( + (button) => typeof button.url === 'string' && placeholdersIn(button.url).length > 0, + ) + +/** A media header takes an image/video/document parameter at send time. It has + * no `text` to scan, so it would otherwise look parameterless. */ +const needsMediaParameter = (component: TemplateComponent): boolean => + component.type?.toUpperCase() === 'HEADER' && + typeof component.format === 'string' && + component.format.toUpperCase() !== 'TEXT' + +/** + * True when sending the template would need a parameter we cannot supply. + * + * Only body parameters are built here, and Meta rejects a send that doesn't + * cover every one the template declares — so offering such a template would + * guarantee a failed send, and it is filtered out rather than shown. + * + * Authentication templates are exempt: their one parameter is Meta's own, and + * buildAuthComponents supplies it, so the rule would wrongly hide every one. + */ +export const hasUnsupportedParameters = (template: WhatsAppTemplate): boolean => + !isAuthentication(template) && + (template.components ?? []).some( + (component) => + hasTextPlaceholderOutsideBody(component) || + hasUrlPlaceholder(component) || + needsMediaParameter(component), + ) + +/** Meta only lets an APPROVED template be sent; the rest are informational. */ +export const isSendable = (template: WhatsAppTemplate): boolean => + template.status === 'APPROVED' && !hasUnsupportedParameters(template) + +/** + * The {{n}} placeholders in the body, ascending and de-duplicated. + * + * Meta numbers placeholders rather than naming them, and a template may repeat + * one ({{1}} twice), so the positions are read from the body itself. + */ +export const templateVariables = (template: WhatsAppTemplate): number[] => + isAuthentication(template) ? [AUTH_CODE_INDEX] : placeholdersIn(templateBody(template)) + +/** What to show for a template in a list. An authentication template stores no + * body text, and its real copy is Meta's and language-specific, so it is + * named rather than quoted. */ +export const templatePreviewText = (template: WhatsAppTemplate): string => + isAuthentication(template) ? AUTH_DESCRIPTION : templateBody(template) + +/** Body text with the agent's values substituted, for previewing before sending. */ +export const previewTemplate = ( + template: WhatsAppTemplate, + values: Record<number, string>, +): string => + isAuthentication(template) + ? authSendSummary(values[AUTH_CODE_INDEX] ?? '') + : templateBody(template).replace(/\{\{(\d+)\}\}/g, (placeholder, index) => + values[Number(index)]?.trim() || placeholder, + ) + +/** True once every placeholder has a value — Meta rejects a partial fill. */ +export const isTemplateComplete = ( + template: WhatsAppTemplate, + values: Record<number, string>, +): boolean => templateVariables(template).every((index) => !!values[index]?.trim()) + +/** + * Components for sending an authentication template. + * + * The code goes in the body *and* in the copy-code button, and Meta requires + * both: body-only produces a button that copies nothing. The button is + * addressed as sub_type 'url' at index '0' — that naming is historical, not a + * mistake; there is no 'otp' sub_type on the send side. + */ +export const buildAuthComponents = (code: string): TemplateComponent[] => { + // Built twice rather than sharing one array: two components holding the same + // mutable reference is a trap for whoever edits this next. + const parameter = () => [{ type: 'text', text: code.trim() }] + return [ + { type: 'body', parameters: parameter() }, + { type: 'button', sub_type: 'url', index: '0', parameters: parameter() }, + ] +} + +/** + * The `components` payload for a send. Graph matches body parameters strictly by + * position, so they go in ascending placeholder order; an authored template with + * no placeholders sends none. + */ +export const buildTemplateComponents = ( + template: WhatsAppTemplate, + values: Record<number, string>, +): TemplateComponent[] | undefined => { + if (isAuthentication(template)) { + return buildAuthComponents(values[AUTH_CODE_INDEX] ?? '') + } + const variables = templateVariables(template) + if (variables.length === 0) return undefined + return [ + { + type: 'body', + parameters: variables.map((index) => ({ type: 'text', text: values[index]?.trim() ?? '' })), + }, + ] +} diff --git a/frontend/src/views/FaqView.vue b/frontend/src/views/FaqView.vue new file mode 100644 index 0000000..669b719 --- /dev/null +++ b/frontend/src/views/FaqView.vue @@ -0,0 +1,110 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> +<script setup lang="ts"> +import { computed, ref } from 'vue' +import DashboardLayout from '@/layouts/DashboardLayout.vue' +import FaqProLockOverlay from '@/components/faq/FaqProLockOverlay.vue' +import FaqWorkspace from '@/components/faq/FaqWorkspace.vue' +import { userService } from '@/services/user' + +const organizationId = computed(() => userService.getCurrentUser()?.organization_id ?? '') + +const planAllowed = ref<boolean | null>(null) +const workspaceRef = ref<InstanceType<typeof FaqWorkspace> | null>(null) + +const liveUrl = computed(() => workspaceRef.value?.settings?.live_url || null) +</script> + +<template> + <DashboardLayout> + <div class="faq-view"> + <div class="page-header"> + <div> + <h1 class="page-header__title">帮助中心</h1> + <p class="page-header__subtitle"> + 把知识库变成公开的帮助中心:自动生成常见问题,审核后即可发布。 + </p> + </div> + <a v-if="liveUrl" class="page-header__link" :href="liveUrl" target="_blank" rel="noopener"> + <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /><path d="M15 3h6v6" /><path d="M10 14 21 3" /></svg> + 查看公开帮助中心 + </a> + </div> + + <FaqProLockOverlay :plan-allowed="planAllowed"> + <FaqWorkspace + v-if="organizationId" + ref="workspaceRef" + :organization-id="organizationId" + @plan-allowed="planAllowed = $event" + /> + </FaqProLockOverlay> + </div> + </DashboardLayout> +</template> + +<style scoped> +.faq-view { + max-width: 1000px; + margin: 0 auto; + padding: 32px 24px 60px; +} + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + margin-bottom: 28px; + flex-wrap: wrap; +} + +.page-header__title { + font-family: var(--font-display); + font-weight: 700; + font-size: 30px; + letter-spacing: -0.02em; + color: var(--text); + margin: 0 0 8px; +} + +.page-header__subtitle { + font-size: 15px; + color: var(--muted); + margin: 0; + max-width: 560px; + line-height: 1.55; +} + +.page-header__link { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 11px 17px; + background: var(--o05); + border: 1px solid var(--o14); + border-radius: 11px; + color: var(--text2); + text-decoration: none; + font-size: 14px; + font-weight: 600; + white-space: nowrap; +} + +.page-header__link:hover { + background: var(--o08); +} +</style> diff --git a/frontend/src/views/TicketDetailView.vue b/frontend/src/views/TicketDetailView.vue new file mode 100644 index 0000000..3f4af92 --- /dev/null +++ b/frontend/src/views/TicketDetailView.vue @@ -0,0 +1,675 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed, ref } from 'vue' +import { useRoute, useRouter } from 'vue-router' +import { toast } from 'vue-sonner' +import DashboardLayout from '@/layouts/DashboardLayout.vue' +import { useTicketDetail } from '@/composables/useTicketDetail' +import { SELECTABLE_STATUSES, statusMeta, PRIORITIES, priorityMeta } from '@/components/tickets/ticketMeta' +import TicketStatusBadge from '@/components/tickets/TicketStatusBadge.vue' +import TicketAiStateChip from '@/components/tickets/TicketAiStateChip.vue' +import TicketActivityFeed from '@/components/tickets/TicketActivityFeed.vue' +import TicketSidePanel from '@/components/tickets/TicketSidePanel.vue' +import TicketInvestigationPanel from '@/components/tickets/TicketInvestigationPanel.vue' +import TicketRcaDoc from '@/components/tickets/TicketRcaDoc.vue' +import TicketApprovalBanner from '@/components/tickets/TicketApprovalBanner.vue' +import { permissionChecks } from '@/utils/permissions' +import type { TicketPriority, TicketStatus } from '@/types/ticket' + +const route = useRoute() +const router = useRouter() +const ticketId = computed(() => String(route.params.id)) + +const { + detail, investigation, ticket, activities, hasActiveRun, isLoading, isSavingComment, + error, setStatus, setPriority, setSeverity, setTitle, setDescription, + setAssignee, setCustomer, addComment, resolve, reopen, investigate, + saveRcaDraft, sendRcaToCustomer, isRcaBusy, approveProposal, rejectProposal, +} = useTicketDetail(ticketId) + +const canManage = permissionChecks.canManageTickets() +const canApprove = permissionChecks.canApproveTicketActions() +// Only gates the deep link to Ticketing settings, which that role owns. +const canEditSettings = permissionChecks.canManageOrganization() + +// Shared by the header's Investigate button and the panel's guided re-run, so +// the two can't drift into disagreeing about why a run is unavailable. +const RESOLVED_TOOLTIP = '工单已解决——重新打开后才能再次调查' + +// The banner shows a pending proposal always; a decided one only while the +// ticket still reflects that decision (approved → resolved states). +const bannerProposal = computed(() => { + const proposal = investigation.value?.proposal + if (!proposal) return null + if (proposal.status === 'pending') return proposal + if (proposal.status === 'approved' && + ['resolved', 'resolved_pending_confirmation', 'closed'].includes(ticket.value?.status || '')) { + return proposal + } + if (proposal.status === 'rejected' && !hasActiveRun.value) return proposal + return null +}) +const titleDraft = ref<string | null>(null) +const showResolvePanel = ref(false) +const resolveOutcome = ref('fixed') +const resolveCustomerMessage = ref('') + +const RESOLVE_OUTCOMES = [ + ['fixed', '已修复'], + ['workaround', '已提供临时方案'], + ['not_a_bug', '非缺陷'], + ['duplicate', '重复工单'], + ['cannot_reproduce', '无法复现'], +] as const + +const isReopenable = computed(() => + ['resolved', 'closed', 'resolved_pending_confirmation'].includes(ticket.value?.status || ''), +) + +// No linked conversation and no customer email → nothing to deliver through, +// so hide every "send to customer" affordance instead of faking it. +const canNotifyCustomer = computed(() => detail.value?.can_notify_customer ?? false) + +const isEditingDescription = ref(false) +const descriptionDraft = ref('') + +function startDescriptionEdit() { + descriptionDraft.value = ticket.value?.description || '' + isEditingDescription.value = true +} + +async function saveDescription() { + await setDescription(descriptionDraft.value.trim()) + isEditingDescription.value = false +} + +function commitTitle() { + if (titleDraft.value !== null && ticket.value && titleDraft.value.trim() && titleDraft.value !== ticket.value.title) { + setTitle(titleDraft.value.trim()) + } + titleDraft.value = null +} + +function copyLink() { + navigator.clipboard + .writeText(window.location.href) + .then(() => toast.success('链接已复制')) + .catch(() => {}) +} + +const isResolving = ref(false) +async function submitResolve() { + // Guard against a double-clicked resolve firing multiple /resolve calls, + // which would send the customer the resolution message more than once. + if (isResolving.value) return + isResolving.value = true + try { + await resolve({ + outcome: resolveOutcome.value, + customer_message: resolveCustomerMessage.value.trim() || undefined, + }) + showResolvePanel.value = false + resolveCustomerMessage.value = '' + } finally { + isResolving.value = false + } +} +</script> + +<template> + <DashboardLayout> + <div class="ticket-detail-view"> + <div v-if="isLoading && !ticket" class="loading-state">加载工单…</div> + <div v-else-if="error && !ticket" class="error-state"> + {{ error }} + <button class="back-btn" @click="router.push('/tickets')">← 返回工单列表</button> + </div> + + <div v-else-if="ticket" class="detail-grid"> + <!-- MAIN COLUMN --> + <div class="main-col"> + <div class="detail-header"> + <div class="header-chips"> + <button class="back-chip" @click="router.push('/tickets')">← 工单</button> + <span class="ticket-number">{{ ticket.display_number }}</span> + <TicketStatusBadge :status="ticket.status" /> + <TicketAiStateChip :state="ticket.ai_state" /> + <button class="copy-link" @click="copyLink">复制链接</button> + </div> + + <input + class="title-input" + :value="titleDraft ?? ticket.title" + :readonly="!canManage" + :title="canManage ? '点击编辑主题' : undefined" + @input="titleDraft = ($event.target as HTMLInputElement).value" + @blur="commitTitle" + @keydown.enter="($event.target as HTMLInputElement).blur()" + /> + <div v-if="ticket.original_title && ticket.original_title !== ticket.title" class="original-title"> + 原标题:“{{ ticket.original_title }}” + </div> + + <div class="control-row"> + <div class="control"> + <span class="control-label">状态</span> + <select + aria-label="状态" + :value="ticket.status" + :disabled="!canManage" + class="control-select" + @change="setStatus(($event.target as HTMLSelectElement).value as TicketStatus)" + > + <option v-if="!SELECTABLE_STATUSES.includes(ticket.status)" :value="ticket.status"> + {{ statusMeta(ticket.status).label }} + </option> + <option v-for="status in SELECTABLE_STATUSES" :key="status" :value="status"> + {{ statusMeta(status).label }} + </option> + </select> + </div> + <div class="control"> + <span class="control-label">优先级</span> + <select + aria-label="优先级" + :value="ticket.priority" + :disabled="!canManage" + class="control-select" + :style="{ color: priorityMeta(ticket.priority).color }" + @change="setPriority(($event.target as HTMLSelectElement).value as TicketPriority)" + > + <option v-for="p in PRIORITIES" :key="p" :value="p">{{ priorityMeta(p).label }}</option> + </select> + </div> + <div class="control"> + <span class="control-label">严重程度</span> + <select + aria-label="严重程度" + :value="ticket.severity ?? ''" + :disabled="!canManage" + class="control-select" + @change="setSeverity(Number(($event.target as HTMLSelectElement).value))" + > + <option value="" disabled>—</option> + <option :value="1">SEV-1</option> + <option :value="2">SEV-2</option> + <option :value="3">SEV-3</option> + </select> + </div> + + <div class="header-actions"> + <button + v-if="canManage && !hasActiveRun" + class="action-btn" + :disabled="isReopenable" + :title=" + isReopenable + ? RESOLVED_TOOLTIP + : '运行 AI 调查(假设 + 证据 + 根因分析)' + " + @click="investigate()" + > + <font-awesome-icon :icon="['fas', 'bolt']" /> + 发起调查 + </button> + <button + v-if="canManage && isReopenable" + class="action-btn" + @click="reopen()" + > + 重新打开 + </button> + <button + v-if="canManage && !isReopenable" + class="action-btn resolve" + @click="showResolvePanel = !showResolvePanel" + > + 解决… + </button> + </div> + </div> + </div> + + <!-- L2 APPROVAL BANNER — pinned above everything else --> + <TicketApprovalBanner + v-if="bannerProposal" + :proposal="bannerProposal" + :hypotheses="investigation?.hypotheses || []" + :can-approve="canApprove" + @approve="approveProposal" + @reject="(reason, reinvestigate) => rejectProposal(reason, reinvestigate)" + /> + + <div v-if="hasActiveRun" class="run-banner"> + <span class="run-dot"></span> + AI 正在处理此工单——分诊与分类结果实时更新。 + </div> + + <div v-if="showResolvePanel" class="resolve-panel"> + <div class="resolve-title">解决 {{ ticket.display_number }}</div> + <label class="field-label">处理结果</label> + <select v-model="resolveOutcome" class="control-select outcome-select"> + <option v-for="[value, label] in RESOLVE_OUTCOMES" :key="value" :value="value"> + {{ label }} + </option> + </select> + <template v-if="canNotifyCustomer"> + <label class="field-label">给客户的消息(通俗表述)</label> + <textarea + v-model="resolveCustomerMessage" + class="resolve-textarea" + placeholder="发生了什么、做了哪些处理…" + ></textarea> + </template> + <div class="resolve-actions"> + <button class="action-btn" @click="showResolvePanel = false">取消</button> + <button class="resolve-submit" :disabled="isResolving" @click="submitResolve"> + {{ isResolving ? '正在解决…' : (canNotifyCustomer ? '解决并通知客户' : '解决') }} + </button> + </div> + <div class="resolve-hint"> + <template v-if="canNotifyCustomer"> + 工单会等待客户确认,并在超过配置的等待时间后自动关闭。 + </template> + <template v-else> + 此工单未关联客户,因此不会发送任何通知。 + </template> + </div> + </div> + + <div v-if="ticket.description || canManage" class="description-card"> + <div class="card-header-row"> + <div class="card-label">描述</div> + <button + v-if="canManage && !isEditingDescription" + class="edit-btn" + @click="startDescriptionEdit" + > + {{ ticket.description ? '编辑' : '添加' }} + </button> + </div> + <template v-if="isEditingDescription"> + <textarea + v-model="descriptionDraft" + class="description-editor" + placeholder="发生了什么?请说明报错、对客户的影响和复现步骤…" + ></textarea> + <div class="edit-actions"> + <button class="action-btn" @click="isEditingDescription = false">取消</button> + <button class="edit-save" @click="saveDescription">保存</button> + </div> + </template> + <p v-else-if="ticket.description" class="description-text">{{ ticket.description }}</p> + <p v-else class="description-empty">暂无描述。</p> + </div> + + <div v-if="ticket.ai_summary" class="summary-card"> + <div class="card-label">AI 摘要</div> + <p class="description-text">{{ ticket.ai_summary }}</p> + </div> + + <!-- GLASS BOX: hypotheses + evidence of the latest investigation run --> + <TicketInvestigationPanel + v-if="investigation?.run" + :investigation="investigation" + :can-reinvestigate="canManage && !hasActiveRun" + :reinvestigate-blocked-reason="isReopenable ? RESOLVED_TOOLTIP : null" + :can-edit-settings="canEditSettings" + @reinvestigate="(note: string) => investigate(note)" + /> + + <TicketRcaDoc + v-if="investigation?.rca" + :rca="investigation.rca" + :can-manage="canManage" + :can-notify="canNotifyCustomer" + :is-busy="isRcaBusy" + @save-draft="saveRcaDraft" + @send-customer="sendRcaToCustomer" + /> + + <TicketActivityFeed + :activities="activities" + :can-comment="true" + :can-message-customer="canNotifyCustomer" + :is-saving="isSavingComment" + @comment="addComment" + /> + </div> + + <!-- SIDE PANEL --> + <TicketSidePanel + :ticket="ticket" + :linked-session-ids="detail?.linked_session_ids || []" + :can-manage="canManage" + @assign="setAssignee" + @set-customer="setCustomer" + /> + </div> + </div> + </DashboardLayout> +</template> + +<style scoped> +.ticket-detail-view { + padding: 24px 28px 70px; +} +.loading-state, +.error-state { + padding: 60px 20px; + text-align: center; + color: var(--muted); + font-size: 13.5px; +} +.back-btn { + display: block; + margin: 16px auto 0; + padding: 8px 14px; + background: var(--o05); + border: 1px solid var(--o10); + color: var(--text); + border-radius: 9px; + cursor: pointer; +} +.detail-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 336px; + gap: 24px; + align-items: start; +} +.main-col { + min-width: 0; + display: flex; + flex-direction: column; + gap: 20px; +} +.header-chips { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 10px; + flex-wrap: wrap; +} +.back-chip { + padding: 5px 10px; + border-radius: 8px; + background: var(--o04); + border: 1px solid var(--o10); + color: var(--muted); + cursor: pointer; + font-size: 12.5px; +} +.ticket-number { + font-family: var(--font-mono); + font-size: 13px; + color: var(--muted2); + background: var(--o05); + border: 1px solid var(--o08); + padding: 3px 9px; + border-radius: 7px; +} +.copy-link { + margin-left: auto; + padding: 6px 11px; + border-radius: 9px; + background: var(--o04); + border: 1px solid var(--o10); + color: var(--muted); + cursor: pointer; + font-size: 12.5px; +} +.title-input { + width: 100%; + font-family: var(--font-display); + font-weight: var(--font-weight-bold); + font-size: 24px; + letter-spacing: var(--tracking-display); + color: var(--text); + background: transparent; + border: none; + border-bottom: 1px dashed transparent; + outline: none; + padding: 2px 0; + margin: 0 0 4px; +} +.title-input:not([readonly]):hover, +.title-input:not([readonly]):focus { + border-bottom-color: var(--o16); +} +.original-title { + font-size: 11.5px; + color: var(--faint); + margin-bottom: 10px; +} +.control-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-top: 10px; +} +.control { + display: flex; + align-items: center; + gap: 7px; + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 9px; + padding: 6px 10px; +} +.control-label { + font-size: 11px; + color: var(--faint); +} +.control-select { + background: transparent; + border: none; + color: var(--text); + font-size: 12.5px; + font-weight: var(--font-weight-semibold); + cursor: pointer; + outline: none; +} +.header-actions { + margin-left: auto; + display: flex; + gap: 8px; +} +.action-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 13px; + background: var(--o05); + border: 1px solid var(--o10); + color: var(--text); + border-radius: 9px; + font-size: 12.5px; + cursor: pointer; +} +.action-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.action-btn.resolve { + border-color: var(--c-positive); + color: var(--c-positive); +} +.run-banner { + display: flex; + align-items: center; + gap: 9px; + padding: 11px 15px; + background: color-mix(in srgb, var(--c-info) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--c-info) 40%, transparent); + border-radius: 12px; + font-size: 12.5px; + color: var(--text3); +} +.run-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--c-info); + animation: ticket-pulse 1.3s ease-in-out infinite; +} +@keyframes ticket-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} +.resolve-panel { + border: 1px solid var(--c-positive); + background: color-mix(in srgb, var(--c-positive) 8%, transparent); + border-radius: 14px; + padding: 16px 18px; +} +.resolve-title { + font-family: var(--font-display); + font-weight: var(--font-weight-bold); + font-size: 15px; + color: var(--text); + margin-bottom: 12px; +} +.field-label { + display: block; + font-size: 11.5px; + color: var(--faint); + margin: 10px 0 6px; +} +.outcome-select { + background: var(--surface); + border: 1px solid var(--o10); + border-radius: 9px; + padding: 8px 10px; +} +.resolve-textarea { + width: 100%; + min-height: 76px; + resize: vertical; + background: var(--surface); + border: 1px solid var(--o10); + border-radius: 10px; + padding: 10px 12px; + color: var(--text); + font-size: 13px; + line-height: 1.5; + outline: none; +} +.resolve-actions { + display: flex; + justify-content: flex-end; + gap: 9px; + margin-top: 12px; +} +.resolve-submit { + padding: 9px 16px; + background: var(--c-positive); + color: var(--on-light, #04140d); + border: none; + border-radius: 9px; + font-size: 13px; + font-weight: var(--font-weight-bold); + cursor: pointer; +} +.resolve-submit:disabled { + opacity: 0.6; + cursor: not-allowed; +} +.resolve-hint { + margin-top: 10px; + font-size: 11.5px; + color: var(--muted); +} +.description-card, +.summary-card { + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 15px; + padding: 18px 20px; +} +.card-label { + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--faint); + margin-bottom: 10px; +} +.description-text { + margin: 0; + font-size: 14px; + line-height: 1.6; + color: var(--text3); + white-space: pre-wrap; + word-break: break-word; +} +.description-empty { + margin: 0; + font-size: 13px; + color: var(--faint); +} +.card-header-row { + display: flex; + align-items: center; + justify-content: space-between; +} +.edit-btn { + font-size: 11.5px; + color: var(--accent-ink); + background: var(--accent-bg-08); + border: none; + padding: 4px 10px; + border-radius: 7px; + cursor: pointer; + margin-bottom: 10px; +} +.description-editor { + width: 100%; + min-height: 96px; + resize: vertical; + background: var(--bg2); + border: 1px solid var(--o10); + border-radius: 10px; + padding: 10px 12px; + color: var(--text); + font-size: 13.5px; + line-height: 1.55; + outline: none; +} +.edit-actions { + display: flex; + justify-content: flex-end; + gap: 9px; + margin-top: 10px; +} +.edit-save { + padding: 7px 15px; + background: var(--accent-solid); + color: var(--on-accent-solid); + border: none; + border-radius: 9px; + font-size: 12.5px; + font-weight: var(--font-weight-semibold); + cursor: pointer; +} +@media (max-width: 1100px) { + .detail-grid { + grid-template-columns: 1fr; + } +} +</style> diff --git a/frontend/src/views/TicketsView.vue b/frontend/src/views/TicketsView.vue new file mode 100644 index 0000000..1107ac5 --- /dev/null +++ b/frontend/src/views/TicketsView.vue @@ -0,0 +1,424 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed, ref } from 'vue' +import { useRouter } from 'vue-router' +import { formatDistanceToNow } from 'date-fns' +import { zhCN } from 'date-fns/locale' +import DashboardLayout from '@/layouts/DashboardLayout.vue' +import { useTicketsWorkspace } from '@/composables/useTicketsWorkspace' +import { formatSlaCountdown, ticketInitials } from '@/components/tickets/ticketMeta' +import TicketStatusBadge from '@/components/tickets/TicketStatusBadge.vue' +import TicketPriorityBadge from '@/components/tickets/TicketPriorityBadge.vue' +import TicketAiStateChip from '@/components/tickets/TicketAiStateChip.vue' +import TicketFilterBar from '@/components/tickets/TicketFilterBar.vue' +import TicketEmptyState from '@/components/tickets/TicketEmptyState.vue' +import TicketCreateModal from '@/components/tickets/TicketCreateModal.vue' +import { permissionChecks } from '@/utils/permissions' + +const router = useRouter() +const { + tickets, stats, pagination, page, filters, phase, + error, planGated, hasActiveFilters, refresh, clearFilters, +} = useTicketsWorkspace() + +const showCreateModal = ref(false) +const canManage = permissionChecks.canManageTickets() + +// "AI 4.6 · human 4.2" when both sides have responses — the split is the point +// of the chip now that L3 auto-resolve is live. +const csatSub = computed(() => { + const s = stats.value + if (!s) return '' + const window = `近 ${s.csat_window_days} 天` + if (!s.csat_responses) return window + const parts: string[] = [] + if (s.csat_ai_avg != null) parts.push(`AI ${s.csat_ai_avg.toFixed(1)}`) + if (s.csat_human_avg != null) parts.push(`人工 ${s.csat_human_avg.toFixed(1)}`) + return parts.length ? parts.join(' · ') : `${s.csat_responses} 人评分 · ${window}` +}) + +const csatColor = computed(() => { + const avg = stats.value?.csat_avg + if (avg == null) return 'var(--c-info)' + return avg >= 4 ? 'var(--c-positive)' : avg >= 3 ? 'var(--c-warn)' : 'var(--c-danger)' +}) + +const statChips = computed(() => [ + { label: '待处理', value: stats.value?.open ?? '—', color: 'var(--c-info)', alert: false }, + { label: '待审批', value: stats.value?.awaiting_approval ?? '—', color: 'var(--c-warn)', alert: false }, + { + label: 'SLA 超时', + value: stats.value?.sla_breaching ?? '—', + color: 'var(--c-danger)', + alert: (stats.value?.sla_breaching ?? 0) > 0, + }, + { + label: 'AI 已解决', + value: stats.value?.ai_resolved_pct_7d != null ? `${stats.value.ai_resolved_pct_7d}%` : '—', + color: 'var(--c-positive)', + alert: false, + sub: '近 7 天', + }, + { + label: 'CSAT', + value: stats.value?.csat_avg != null ? `${stats.value.csat_avg.toFixed(1)}/5` : '—', + color: csatColor.value, + alert: false, + sub: csatSub.value, + }, +]) + +const timeAgo = (iso?: string | null) => + iso ? formatDistanceToNow(new Date(iso), { addSuffix: false, locale: zhCN }) : '' + +function openTicket(id: string) { + router.push(`/tickets/${id}`) +} +</script> + +<template> + <DashboardLayout> + <div class="tickets-view"> + <div class="page-header"> + <div> + <h1 class="page-title">工单</h1> + <p class="page-subtitle"> + AI 全程公开调查——每个结论都能追溯到证据。 + </p> + </div> + <button v-if="canManage" class="new-ticket-btn" @click="showCreateModal = true"> + <span class="plus">+</span> 新建工单 + </button> + </div> + + <div v-if="planGated" class="plan-gate"> + {{ error }} + </div> + + <template v-else> + <div class="stat-grid"> + <div v-for="chip in statChips" :key="chip.label" class="stat-card"> + <div class="stat-label"> + <span class="stat-dot" :style="{ background: chip.color }"></span> + {{ chip.label }} + </div> + <div class="stat-value" :style="chip.alert ? { color: chip.color } : {}"> + {{ chip.value }} + <span v-if="chip.sub" class="stat-sub">{{ chip.sub }}</span> + </div> + </div> + </div> + + <TicketFilterBar :filters="filters" /> + + <div class="ticket-table"> + <div class="table-scroll" tabindex="0" aria-label="工单列表"> + <div class="table-head"> + <span>工单</span><span>主题</span><span>状态</span><span>优先级</span> + <span>AI 状态</span><span>负责人 / SLA</span><span class="right">更新时间</span> + </div> + + <div v-if="phase === 'loading'" class="table-loading">加载工单…</div> + + <template v-else-if="phase === 'populated'"> + <div + v-for="ticket in tickets" + :key="ticket.id" + class="table-row" + @click="openTicket(ticket.id)" + > + <span class="ticket-number">{{ ticket.display_number }}</span> + <div class="title-cell"> + <div class="ticket-title">{{ ticket.title }}</div> + <div v-if="ticket.tags?.length" class="ticket-tags"> + {{ ticket.tags.join(' · ') }} + </div> + </div> + <span><TicketStatusBadge :status="ticket.status" /></span> + <span><TicketPriorityBadge :priority="ticket.priority" /></span> + <span><TicketAiStateChip :state="ticket.ai_state" /></span> + <div class="assignee-cell"> + <span + class="avatar" + :class="{ 'avatar-ai': !ticket.assignee_name }" + :title="ticket.assignee_name || 'ChatterMate AI'" + > + {{ ticketInitials(ticket.assignee_name) }} + </span> + <span + class="sla" + :style="{ color: formatSlaCountdown(ticket.sla_due_at, ticket.resolved_at).color }" + > + {{ formatSlaCountdown(ticket.sla_due_at, ticket.resolved_at).label }} + </span> + </div> + <span class="updated">{{ timeAgo(ticket.updated_at) }}</span> + </div> + </template> + + <TicketEmptyState + v-else + :filtered="hasActiveFilters" + @clear="clearFilters" + @create="showCreateModal = true" + /> + </div> + + <div v-if="pagination && pagination.total_pages > 1" class="pager"> + <button class="pager-btn" :disabled="page <= 1" @click="page--">← 上一页</button> + <span class="pager-info">第 {{ pagination.page }} / {{ pagination.total_pages }} 页</span> + <button class="pager-btn" :disabled="page >= pagination.total_pages" @click="page++"> + 下一页 → + </button> + </div> + </div> + </template> + + <TicketCreateModal + :open="showCreateModal" + @close="showCreateModal = false" + @created="refresh()" + /> + </div> + </DashboardLayout> +</template> + +<style scoped> +.tickets-view { + padding: 26px 30px 36px; +} +.page-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 20px; + margin-bottom: 20px; +} +.page-title { + font-family: var(--font-display); + font-weight: var(--font-weight-bold); + font-size: 25px; + letter-spacing: var(--tracking-display); + margin: 0 0 4px; + color: var(--text); +} +.page-subtitle { + margin: 0; + font-size: 13px; + color: var(--muted); +} +.new-ticket-btn { + display: flex; + align-items: center; + gap: 7px; + padding: 9px 16px; + background: var(--accent-solid); + color: var(--on-accent-solid); + border: none; + border-radius: 10px; + font-size: 13.5px; + font-weight: var(--font-weight-semibold); + cursor: pointer; +} +.plus { + font-size: 15px; + line-height: 0; +} +.plan-gate { + padding: 40px; + text-align: center; + color: var(--muted); + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 15px; +} +.stat-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-bottom: 20px; +} +.stat-card { + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 14px; + padding: 15px 17px; +} +.stat-label { + display: flex; + align-items: center; + gap: 7px; + margin-bottom: 10px; + font-size: 12px; + color: var(--muted); +} +.stat-dot { + width: 7px; + height: 7px; + border-radius: 2px; +} +.stat-value { + font-family: var(--font-display); + font-weight: var(--font-weight-bold); + font-size: 28px; + letter-spacing: var(--tracking-display); + line-height: 1; + color: var(--text); + display: flex; + align-items: baseline; + gap: 7px; +} +.stat-sub { + font-size: 11.5px; + font-family: var(--font-sans); + font-weight: var(--font-weight-normal); + color: var(--faint); +} +.ticket-table { + border: 1px solid var(--o08); + border-radius: 15px; + background: var(--surface); + overflow: hidden; +} +.table-scroll { + overflow-x: auto; +} +.table-head, +.table-row { + display: grid; + grid-template-columns: 98px minmax(200px, 1fr) 150px 96px 158px 132px 92px; + gap: 14px; + align-items: center; + padding: 11px 20px; + min-width: 900px; +} +.table-head { + border-bottom: 1px solid var(--o07); + background: var(--o03); + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--faint); +} +.table-row { + padding: 14px 20px; + border-bottom: 1px solid var(--o06); + cursor: pointer; +} +.table-row:hover { + background: var(--o03); +} +.table-row:last-child { + border-bottom: none; +} +.table-loading { + padding: 46px 20px; + text-align: center; + color: var(--muted); + font-size: 13px; +} +.ticket-number { + font-family: var(--font-mono); + font-size: 12px; + color: var(--muted); +} +.title-cell { + min-width: 0; +} +.ticket-title { + font-size: 13.5px; + font-weight: var(--font-weight-medium); + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ticket-tags { + font-family: var(--font-mono); + font-size: 10.5px; + color: var(--faint); + margin-top: 3px; +} +.assignee-cell { + display: flex; + align-items: center; + gap: 8px; +} +.avatar { + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--c-purple); + color: var(--on-accent); + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: var(--font-weight-bold); + flex-shrink: 0; +} +.avatar-ai { + background: var(--accent-solid); + color: var(--on-accent-solid); + border-radius: 8px; +} +.sla { + font-family: var(--font-mono); + font-size: 11.5px; + font-weight: var(--font-weight-semibold); +} +.updated { + text-align: right; + font-size: 11.5px; + color: var(--faint); +} +.right { + text-align: right; +} +.pager { + display: flex; + align-items: center; + justify-content: center; + gap: 14px; + padding: 12px; + border-top: 1px solid var(--o07); +} +.pager-btn { + padding: 6px 12px; + background: var(--o05); + border: 1px solid var(--o10); + color: var(--text); + border-radius: 8px; + font-size: 12px; + cursor: pointer; +} +.pager-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.pager-info { + font-size: 12px; + color: var(--muted); +} +@media (max-width: 900px) { + .stat-grid { + grid-template-columns: repeat(2, 1fr); + } +} +</style> diff --git a/frontend/src/views/settings/AIConfigSettings.vue b/frontend/src/views/settings/AIConfigSettings.vue new file mode 100644 index 0000000..38a1157 --- /dev/null +++ b/frontend/src/views/settings/AIConfigSettings.vue @@ -0,0 +1,62 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<template> + <DashboardLayout> + <div class="dashboard-container"> + <div class="settings-section"> + <AISetup @ai-setup-complete="handleSetupComplete" /> + </div> + </div> + </DashboardLayout> +</template> + +<script setup lang="ts"> +import DashboardLayout from '@/layouts/DashboardLayout.vue' +import AISetup from '@/components/ai/AISetup.vue' + +const handleSetupComplete = () => { + // Optionally handle completion +} +</script> + +<style scoped> +.dashboard-container { + display: flex; + height: 100%; + position: relative; +} + +.settings-section { + flex: 1; + padding: var(--space-lg); +} + +.page-header { + margin-bottom: var(--space-xl); +} + +.page-header h1 { + font-size: var(--text-2xl); + color: var(--text-color); + margin-bottom: var(--space-sm); +} + +.description { + color: var(--text-color); + opacity: 0.7; +} +</style> \ No newline at end of file diff --git a/frontend/src/views/settings/IntegrationsSettings.vue b/frontend/src/views/settings/IntegrationsSettings.vue new file mode 100644 index 0000000..b546ba1 --- /dev/null +++ b/frontend/src/views/settings/IntegrationsSettings.vue @@ -0,0 +1,1615 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { ref, onMounted, computed, watch } from 'vue' +import { useRoute, useRouter } from 'vue-router' +import { toast } from 'vue-sonner' +import DashboardLayout from '@/layouts/DashboardLayout.vue' +import { checkJiraConnection, getJiraAuthUrl, disconnectJira } from '@/services/jira' +import { checkShopifyConnection, getShopifyShops } from '@/services/shopify' +import channelsService, { type ChannelAccount } from '@/services/channels' +import { accountToManage, reconnectWarning } from '@/utils/channelAccounts' +import crmService, { type CrmConnection, type CrmProvider } from '@/services/crm' +import TelegramConnectModal from '@/components/integrations/TelegramConnectModal.vue' +import MetaChannelConnect from '@/components/integrations/MetaChannelConnect.vue' +import WhatsAppTemplateManager from '@/components/integrations/WhatsAppTemplateManager.vue' +import ChannelConnectModal from '@/components/integrations/ChannelConnectModal.vue' + +// Import logos +import jiraLogo from '@/assets/jira-logo.svg' +import chattermateLogo from '@/assets/logo.svg' +import slackLogo from '@/assets/slack-logo.svg' +import zendeskLogo from '@/assets/zendesk-logo.svg' +import shopifyLogo from '@/assets/shopify-logo.svg' +import telegramLogo from '@/assets/telegram-logo.svg' +import whatsappLogo from '@/assets/whatsapp-logo.svg' +import messengerLogo from '@/assets/messenger-logo.svg' +import instagramLogo from '@/assets/instagram-logo.svg' +import emailLogo from '@/assets/email-logo.svg' +import smsLogo from '@/assets/sms-logo.svg' +import lineLogo from '@/assets/line-logo.svg' +import hubspotLogo from '@/assets/hubspot-logo.svg' +import pipedriveLogo from '@/assets/pipedrive-logo.svg' + +// Define interface for Shopify shop +interface ShopifyShop { + id: string + shop_domain: string + is_installed: boolean + [key: string]: any +} + +// Shopify state variables +const shopifyConnected = ref(false) +const shopifyShopDomain = ref('') +const shopifyLoading = ref(true) + +// Messaging channel state (Telegram + Meta channels share one accounts list) +const channelAccounts = ref<ChannelAccount[]>([]) +const channelsLoading = ref(true) +const showTelegramModal = ref(false) +// Which Meta connect modal is open (null = none) +const metaModalChannel = ref<'whatsapp' | 'messenger' | 'instagram' | null>(null) +const showTemplateManager = ref(false) +// Which credential connect modal is open (null = none) +const credentialModalChannel = ref<'email' | 'sms' | 'line' | 'slack' | null>(null) +// Account being managed (null = fresh connect). Also used for Telegram/Meta manage. +const credentialModalAccount = ref<ChannelAccount | null>(null) +const telegramModalAccount = ref<ChannelAccount | null>(null) +const metaModalAccount = ref<ChannelAccount | null>(null) + +const CREDENTIAL_CHANNELS = ['email', 'sms', 'line'] +const META_CHANNELS = ['whatsapp', 'messenger', 'instagram'] +const CRM_PROVIDERS: CrmProvider[] = ['hubspot', 'pipedrive'] + +// CRM lead-push connections +const crmConnections = ref<CrmConnection[]>([]) +const crmLoading = ref(true) +// 403 from the connections endpoint = plan doesn't include crm_sync +const crmUpgradeMessage = ref<string | null>(null) + +const crmFor = (provider: CrmProvider) => + crmConnections.value.find(c => c.provider === provider) ?? null + +const fetchCrmConnections = async () => { + try { + crmLoading.value = true + crmConnections.value = await crmService.listConnections() + crmUpgradeMessage.value = null + } catch (error: any) { + if (error?.response?.status === 403) { + crmUpgradeMessage.value = error.response?.data?.detail || '当前套餐不支持 CRM 同步。' + } else { + console.error('Error loading CRM connections:', error) + } + } finally { + crmLoading.value = false + } +} + +const connectCrm = (provider: CrmProvider) => { + if (crmUpgradeMessage.value) { + toast.error(crmUpgradeMessage.value) + return + } + window.location.href = crmService.getInstallUrl(provider) +} + +const testCrm = async (provider: CrmProvider) => { + try { + const result = await crmService.testConnection(provider) + if (result.ok) { + toast.success(`已连接到 ${result.account_name || provider}`) + } else { + toast.error(result.error || `${provider} 连接检查失败`) + } + } catch (error: any) { + toast.error(error?.response?.data?.detail || `${provider} 连接检查失败`) + } +} + +const handleDisconnectCrm = async (provider: CrmProvider) => { + try { + crmLoading.value = true + await crmService.disconnect(provider) + crmConnections.value = crmConnections.value.filter(c => c.provider !== provider) + toast.success(`已断开 ${provider === 'hubspot' ? 'HubSpot' : 'Pipedrive'}`) + } catch (error: any) { + toast.error(error?.response?.data?.detail || `断开 ${provider} 时出错`) + } finally { + crmLoading.value = false + showDisconnectConfirm.value = false + disconnectingIntegration.value = null + } +} + +const crmCardWarning = (connection: CrmConnection | null): string | undefined => { + if (!connection) return undefined + if (connection.status !== 'active') { + return '连接已过期 — 重新连接以恢复线索同步。' + } + if (connection.recent_failures > 0) { + return `最近 7 天有 ${connection.recent_failures} 条线索同步失败。` + } + return undefined +} + +// "Manage" on a connected card: open the right modal for the connected account +const manageIntegration = (integration: IntegrationCard) => { + const id = integration.id + const acc = accountToManage(accountsFor(id)) + if (CREDENTIAL_CHANNELS.includes(id)) { + credentialModalAccount.value = acc + credentialModalChannel.value = id as 'email' | 'sms' | 'line' + } else if (id === 'telegram') { + telegramModalAccount.value = acc + showTelegramModal.value = true + } else if (META_CHANNELS.includes(id)) { + metaModalAccount.value = acc + metaModalChannel.value = id as 'whatsapp' | 'messenger' | 'instagram' + } else if (id === 'slack') { + // Slack connects via OAuth; Manage just picks the answering agent + credentialModalAccount.value = acc + credentialModalChannel.value = 'slack' + } else if (CRM_PROVIDERS.includes(id as CrmProvider)) { + // CRM Manage = read-only connectivity check, surfaced as a toast + testCrm(id as CrmProvider) + } else { + // Jira/Shopify: re-run their connect/OAuth flow + if (id === 'shopify') openShopifyInstallation() + else integration.connectAction?.() + } +} + +const accountsFor = (channelType: string) => + channelAccounts.value.filter(a => a.channel_type === channelType) + +const telegramAccounts = computed(() => accountsFor('telegram')) + +const fetchChannelAccounts = async () => { + try { + channelsLoading.value = true + channelAccounts.value = await channelsService.listAccounts() + } catch (error) { + console.error('Error loading channel accounts:', error) + } finally { + channelsLoading.value = false + } +} + +const onChannelConnected = async () => { + showTelegramModal.value = false + metaModalChannel.value = null + credentialModalChannel.value = null + credentialModalAccount.value = null + telegramModalAccount.value = null + metaModalAccount.value = null + await fetchChannelAccounts() +} + +// Shared disconnect for messaging channels; Telegram also removes its webhook +const disconnectChannelAccounts = async (channelType: string, label: string) => { + try { + channelsLoading.value = true + for (const account of accountsFor(channelType)) { + if (channelType === 'telegram') { + await channelsService.disconnectTelegram(account.id) + } else if (channelType === 'slack') { + await channelsService.disconnectSlack(account.id) + } else if (channelType === 'email') { + await channelsService.disconnectEmail(account.id) + } else if (channelType === 'sms') { + await channelsService.disconnectSms(account.id) + } else if (channelType === 'line') { + await channelsService.disconnectLine(account.id) + } else { + await channelsService.disconnectMeta(account.id) + } + } + channelAccounts.value = channelAccounts.value.filter(a => a.channel_type !== channelType) + toast.success(`已断开 ${label}`) + } catch (error: any) { + toast.error(error?.response?.data?.detail || `断开 ${label} 时出错`) + } finally { + channelsLoading.value = false + showDisconnectConfirm.value = false + disconnectingIntegration.value = null + } +} + +const handleDisconnectTelegram = () => disconnectChannelAccounts('telegram', 'Telegram') +const handleDisconnectSlack = () => disconnectChannelAccounts('slack', 'Slack') +const connectSlack = () => { window.location.href = channelsService.getSlackInstallUrl() } +const handleDisconnectWhatsApp = () => disconnectChannelAccounts('whatsapp', 'WhatsApp') +const handleDisconnectMessenger = () => disconnectChannelAccounts('messenger', 'Messenger') +const handleDisconnectInstagram = () => disconnectChannelAccounts('instagram', 'Instagram') +const handleDisconnectEmail = () => disconnectChannelAccounts('email', 'Email') +const handleDisconnectSms = () => disconnectChannelAccounts('sms', 'SMS') +const handleDisconnectLine = () => disconnectChannelAccounts('line', 'LINE') + + +const route = useRoute() +const router = useRouter() + +const jiraConnected = ref(false) +const jiraSiteUrl = ref('') +const isLoading = ref(true) +const showDisconnectConfirm = ref(false) +const disconnectingIntegration = ref<string | null>(null) +// OAuth connect error, tagged with the integration it belongs to so the banner +// renders on that card rather than always on Jira's. +const connectionError = ref<{ integration: string; message: string } | null>(null) + +// Check if Jira is connected +const fetchJiraStatus = async () => { + try { + isLoading.value = true + const data = await checkJiraConnection() + jiraConnected.value = data.connected + jiraSiteUrl.value = data.site_url || '' + } catch (error) { + console.error('Error checking Jira connection:', error) + jiraConnected.value = false + } finally { + isLoading.value = false + } +} + +// Connect to Jira +const connectJira = () => { + try { + // Clear any previous error messages + connectionError.value = null + window.location.href = getJiraAuthUrl() + } catch (error) { + console.error('Error connecting to Jira:', error) + toast.error('连接 Jira 时出错') + } +} + +// Show disconnect confirmation +const showDisconnectConfirmation = (integrationId: string) => { + disconnectingIntegration.value = integrationId + showDisconnectConfirm.value = true +} + +// Cancel disconnect +const cancelDisconnect = () => { + showDisconnectConfirm.value = false + disconnectingIntegration.value = null +} + +// Disconnect from Jira +const handleDisconnectJira = async () => { + try { + isLoading.value = true + await disconnectJira() + jiraConnected.value = false + jiraSiteUrl.value = '' + toast.success('Jira 已断开连接') + } catch (error: any) { + console.error('Error disconnecting from Jira:', error) + let errorMessage = '断开 Jira 时出错' + + // Try to extract a more detailed error message if available + if (error.response && error.response.data && error.response.data.detail) { + errorMessage = error.response.data.detail + } + + toast.error(errorMessage) + } finally { + isLoading.value = false + showDisconnectConfirm.value = false + disconnectingIntegration.value = null + } +} + +// Check if Shopify is connected +const fetchShopifyStatus = async () => { + try { + shopifyLoading.value = true + const data = await checkShopifyConnection() + shopifyConnected.value = data.connected + shopifyShopDomain.value = data.shop_domain || '' + } catch (error) { + console.error('Error checking Shopify connection:', error) + shopifyConnected.value = false + } finally { + shopifyLoading.value = false + } +} + + +// Open Shopify installation page +const openShopifyInstallation = () => { + try { + // Direct installation URL provided by Shopify + const installUrl = 'https://admin.shopify.com/?organization_id=162380510&no_redirect=true&redirect=/oauth/redirect_from_developer_dashboard?client_id%3D280379be88b01dbdde1bcf06c027b1d4' + + // Open in new tab + window.open(installUrl, '_blank') + + // Show helpful message + toast.info('已跳转至 Shopify 安装应用。安装完成后请刷新本页查看连接状态。') + } catch (error: any) { + console.error('Error opening Shopify installation:', error) + toast.error('打开 Shopify 安装页面时出错') + } +} + +// Disconnect from Shopify - redirect to Shopify admin to uninstall +const handleDisconnectShopify = () => { + try { + // Close the modal + showDisconnectConfirm.value = false + disconnectingIntegration.value = null + + // Open Shopify admin apps page in a new tab where user can uninstall the app + const shopifyAdminUrl = `https://${shopifyShopDomain.value}/admin/apps` + window.open(shopifyAdminUrl, '_blank') + + // Show a helpful toast message + toast.info('请在 Shopify 后台卸载 ChatterMate 应用以完成断开连接。') + } catch (error: any) { + console.error('Error opening Shopify admin:', error) + toast.error('打开 Shopify 后台时出错') + showDisconnectConfirm.value = false + disconnectingIntegration.value = null + } +} + +// Define interface for IntegrationCard +interface IntegrationCard { + id: string; + name: string; + description: string; + logo: string; + connected: boolean; + isLoading?: boolean; + siteUrl?: string; + shopDomain?: string; + teamName?: string; + comingSoon?: boolean; + category?: string; + color?: string; + /** Inline warning under the description (expired connection, sync failures). */ + warning?: string; + connectAction?: () => void; + disconnectAction?: () => void; + /** An extra action on the connected card, alongside Manage/Disconnect. */ + extraActionLabel?: string; + extraAction?: () => void; +} + +// List of available integrations +const availableIntegrations = computed<IntegrationCard[]>(() => [ + { + id: 'jira', + name: 'Jira', + description: '连接 Jira,即可直接从 ChatterMate 创建事务。', + logo: jiraLogo, + category: '项目管理', + color: 'purple', + connected: jiraConnected.value, + siteUrl: jiraSiteUrl.value, + isLoading: isLoading.value, + connectAction: connectJira, + disconnectAction: handleDisconnectJira + }, + { + id: 'shopify', + name: 'Shopify', + description: '从 Shopify 应用商店安装,将你的店铺与 ChatterMate 集成。', + logo: shopifyLogo, + category: '电子商务', + color: 'teal', + connected: shopifyConnected.value, + shopDomain: shopifyShopDomain.value, + isLoading: shopifyLoading.value, + disconnectAction: handleDisconnectShopify + }, + { + id: 'slack', + name: 'Slack', + description: '连接 Slack 工作区,用户即可通过 @提及 和私信与你的 AI 智能体对话。', + logo: slackLogo, + category: '消息渠道', + color: 'accent', + connected: accountsFor('slack').length > 0, + teamName: accountsFor('slack').map(a => a.display_name).filter(Boolean).join(', '), + warning: reconnectWarning(accountsFor('slack')), + isLoading: channelsLoading.value, + connectAction: connectSlack, + disconnectAction: handleDisconnectSlack + }, + { + id: 'telegram', + name: 'Telegram', + description: '连接 Telegram 机器人,客户即可在 Telegram 上与你的 AI 智能体对话。', + logo: telegramLogo, + category: '消息渠道', + color: 'accent', + connected: telegramAccounts.value.length > 0, + teamName: telegramAccounts.value.map(a => a.display_name).filter(Boolean).join(', '), + warning: reconnectWarning(telegramAccounts.value), + isLoading: channelsLoading.value, + connectAction: () => { showTelegramModal.value = true }, + disconnectAction: handleDisconnectTelegram + }, + ...(['whatsapp', 'messenger', 'instagram'] as const).map(channel => { + const meta = { + whatsapp: { name: 'WhatsApp', logo: whatsappLogo, color: 'teal', + description: '让客户在 WhatsApp Business 上向你的 AI 智能体发消息。', + disconnect: handleDisconnectWhatsApp }, + messenger: { name: 'Messenger', logo: messengerLogo, color: 'accent', + description: '让客户在 Facebook Messenger 上与你的 AI 智能体对话。', + disconnect: handleDisconnectMessenger }, + instagram: { name: 'Instagram', logo: instagramLogo, color: 'purple', + description: '让客户在 Instagram 上向你的 AI 智能体发送私信。', + disconnect: handleDisconnectInstagram }, + }[channel] + const accounts = accountsFor(channel) + return { + id: channel, + name: meta.name, + description: meta.description, + logo: meta.logo, + category: '消息渠道', + color: meta.color, + connected: accounts.length > 0, + teamName: accounts.map(a => a.display_name).filter(Boolean).join(', '), + warning: reconnectWarning(accounts), + isLoading: channelsLoading.value, + connectAction: () => { metaModalChannel.value = channel }, + disconnectAction: meta.disconnect, + // Templates are WhatsApp-only — the other Meta channels have no equivalent. + ...(channel === 'whatsapp' && accounts.length > 0 + ? { extraActionLabel: '模板', extraAction: () => { showTemplateManager.value = true } } + : {}) + } + }), + ...(['email', 'sms', 'line'] as const).map(channel => { + const meta = { + email: { name: 'Email', logo: emailLogo, color: 'purple', + description: '连接客服邮箱,邮件会话将由你的 AI 智能体答复。', + disconnect: handleDisconnectEmail }, + sms: { name: 'SMS', logo: smsLogo, color: 'coral', + description: '连接 Twilio 号码,客户即可向你的 AI 智能体发送短信。', + disconnect: handleDisconnectSms }, + line: { name: 'LINE', logo: lineLogo, color: 'teal', + description: '连接 LINE 官方账号,客户即可在 LINE 上与你的 AI 智能体对话。', + disconnect: handleDisconnectLine }, + }[channel] + const accounts = accountsFor(channel) + return { + id: channel, + name: meta.name, + description: meta.description, + logo: meta.logo, + category: '消息渠道', + color: meta.color, + connected: accounts.length > 0, + teamName: accounts.map(a => a.display_name).filter(Boolean).join(', '), + warning: reconnectWarning(accounts), + isLoading: channelsLoading.value, + connectAction: () => { credentialModalChannel.value = channel }, + disconnectAction: meta.disconnect + } + }), + ...CRM_PROVIDERS.map(provider => { + const meta = { + hubspot: { name: 'HubSpot', logo: hubspotLogo, color: 'coral', + description: '将捕获的线索以联系人形式推送到 HubSpot,按邮箱去重,并附带 AI 摘要。' }, + pipedrive: { name: 'Pipedrive', logo: pipedriveLogo, color: 'teal', + description: '将捕获的线索以联系人和线索形式推送到 Pipedrive,按邮箱去重,并附带 AI 摘要。' }, + }[provider] + const connection = crmFor(provider) + return { + id: provider, + name: meta.name, + description: meta.description, + logo: meta.logo, + category: 'CRM', + color: meta.color, + connected: connection?.status === 'active', + teamName: connection?.display_name || undefined, + warning: crmCardWarning(connection), + isLoading: crmLoading.value, + connectAction: () => connectCrm(provider), + disconnectAction: () => handleDisconnectCrm(provider) + } + }), + // Native AI ticketing (built-in) — the card links to its settings page. + { + id: 'ai-ticketing', + name: 'AI Ticketing', + description: '由 AI 分诊与调查的原生工单 — 无需外部工单系统。', + logo: chattermateLogo, + category: '客户支持', + color: 'lime', + connected: true, + connectAction: () => router.push('/settings/ticketing'), + disconnectAction: () => router.push('/settings/ticketing') + }, + // Future integrations + { + id: 'zendesk', + name: 'Zendesk', + description: '连接 Zendesk 以管理客户支持工单。', + logo: zendeskLogo, + category: '客户支持', + color: 'coral', + connected: false, + comingSoon: true + } +]) + +// Search + summary +const intQuery = ref('') + +const filteredIntegrations = computed(() => { + const q = intQuery.value.trim().toLowerCase() + if (!q) return availableIntegrations.value + return availableIntegrations.value.filter(it => + it.name.toLowerCase().includes(q) || + it.description.toLowerCase().includes(q) || + (it.category || '').toLowerCase().includes(q) + ) +}) + +const intEmpty = computed(() => filteredIntegrations.value.length === 0) + +const intSummary = computed(() => { + const total = availableIntegrations.value.length + const connected = availableIntegrations.value.filter(it => it.connected).length + return `${connected}/${total} 已连接` +}) + +// Display name for an ?integration= id, taken from the card list so a new +// integration never has to be named twice. Jira's callback predates the param, +// so a missing id means Jira. +const integrationName = (id?: string) => { + const integrationId = id || 'jira' + return availableIntegrations.value.find(it => it.id === integrationId)?.name || integrationId +} + +onMounted(async () => { + await Promise.all([ + fetchJiraStatus(), + fetchShopifyStatus(), + fetchChannelAccounts(), + fetchCrmConnections() + ]) + + // Check if we're returning from an OAuth flow + if (route.query.status) { + if (route.query.status === 'success') { + if (route.query.integration === 'shopify') { + toast.success('Shopify 连接成功!') + } + else if (route.query.integration === 'slack') { + toast.success('Slack 已连接 — 请选择负责应答的智能体。') + // Open the agent picker for the just-connected Slack workspace + const slackAcc = accountsFor('slack')[0] + if (slackAcc) { + credentialModalAccount.value = slackAcc + credentialModalChannel.value = 'slack' + } + } + else { + toast.success(`${integrationName(route.query.integration as string)} 连接成功!`) + } + connectionError.value = null + } else if (route.query.status === 'failure') { + // Jira's callback omits the integration param, so a missing value means Jira. + const failedIntegration = (route.query.integration as string) || 'jira' + const name = integrationName(failedIntegration) + const reason = route.query.reason as string || 'unknown' + + let errorMessage = `连接 ${name} 失败` + + // Map common error reasons to user-friendly messages + if (reason === 'cancelled') { + errorMessage = `${name} 连接已取消` + } else if (reason === 'invalid_state') { + errorMessage = '登录会话已过期或无效' + } else if (reason.includes('unauthorized')) { + errorMessage = '授权失败,请检查你的权限' + } else if (reason) { + errorMessage = `连接 ${name} 失败:${reason.replace(/_/g, ' ')}` + } + + toast.error(errorMessage) + connectionError.value = { integration: failedIntegration, message: errorMessage } + } + + // Remove the query parameters to avoid showing the toast on refresh + window.history.replaceState({}, document.title, window.location.pathname) + } + + // Landing-page install: arriving with ?connect=slack starts the OAuth flow + // immediately. The router guard has already ensured the user is signed in + // (redirecting through login if needed), so this same-domain redirect carries + // the session cookie the install endpoint requires. + if (route.query.connect === 'slack') { + window.history.replaceState({}, document.title, window.location.pathname) + if (accountsFor('slack').length === 0) { + connectSlack() + } + } +}) +</script> + +<template> + <DashboardLayout> + <div class="integrations-settings"> + <!-- Page header + search --> + <div class="int-page-header"> + <div class="int-page-titles"> + <h1 class="int-title">集成</h1> + <p class="int-subtitle">将 ChatterMate 与团队常用工具连接起来。</p> + </div> + <div class="int-search"> + <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"> + <circle cx="11" cy="11" r="7"></circle> + <path d="M21 21l-4-4"></path> + </svg> + <input + v-model="intQuery" + type="search" + name="integration-search" + autocomplete="off" + aria-label="搜索集成" + placeholder="搜索集成…" + /> + </div> + </div> + + <!-- Summary bar --> + <div class="int-summary-bar"> + <span class="int-summary">{{ intSummary }}</span> + </div> + + <!-- Cards grid --> + <div class="integration-cards"> + <div + v-for="integration in filteredIntegrations" + :key="integration.id" + class="integration-card" + > + <div class="integration-header"> + <div class="integration-tile" :class="`tile-${integration.color}`"> + <img + v-if="integration.logo" + :src="integration.logo" + :alt="`${integration.name} 标志`" + class="integration-logo" + /> + <span v-else>{{ integration.name.charAt(0) }}</span> + </div> + <div class="integration-info"> + <div class="integration-name">{{ integration.name }}</div> + <div class="integration-cat">{{ integration.category }}</div> + </div> + <span + v-if="integration.connected" + class="status-badge connected" + > + <span class="status-dot"></span> + 已连接 + </span> + <span + v-else-if="!integration.comingSoon" + class="status-badge not-connected" + > + <span class="status-dot"></span> + 未连接 + </span> + <span + v-else + class="status-badge soon" + > + 即将推出 + </span> + </div> + + <p class="integration-desc">{{ integration.description }}</p> + + <div v-if="integration.connected && integration.siteUrl" class="integration-meta"> + <a :href="integration.siteUrl" target="_blank" class="meta-link">↗ 访问 {{ integration.name }} 官网</a> + </div> + <div v-else-if="integration.connected && integration.shopDomain" class="integration-meta"> + <span class="meta-text">{{ integration.shopDomain }}</span> + <a :href="`https://${integration.shopDomain}/admin`" target="_blank" class="meta-link">↗ 访问 Shopify 后台</a> + </div> + <div v-else-if="integration.connected && integration.teamName" class="integration-meta"> + <span class="meta-text">{{ integration.teamName }}</span> + </div> + <div v-else-if="!integration.connected && connectionError && connectionError.integration === integration.id" class="integration-meta"> + <span class="meta-error">⚠️ {{ connectionError.message }}</span> + </div> + <div v-if="integration.warning" class="integration-meta"> + <span class="meta-error">⚠️ {{ integration.warning }}</span> + </div> + + <!-- Loading state --> + <button + v-if="integration.isLoading" + class="int-btn int-btn-loading" + disabled + > + <span class="loading-spinner"></span> + 加载中… + </button> + + <!-- Connected: Manage + Disconnect --> + <div v-else-if="integration.connected" class="int-actions"> + <button class="int-btn int-btn-manage" @click="manageIntegration(integration)">管理</button> + <button + v-if="integration.extraAction" + class="int-btn int-btn-manage" + @click="integration.extraAction()" + > + {{ integration.extraActionLabel }} + </button> + <button class="int-btn int-btn-disconnect" @click="showDisconnectConfirmation(integration.id)"> + 断开连接 + </button> + </div> + + <!-- Coming soon --> + <button + v-else-if="integration.comingSoon" + class="int-btn int-btn-soon" + disabled + > + 即将推出 + </button> + + <!-- Not connected: Connect / Install --> + <button + v-else + class="int-btn int-btn-connect" + @click="integration.id === 'shopify' ? openShopifyInstallation() : integration.connectAction?.()" + > + <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"> + <path d="M12 5v14M5 12h14"></path> + </svg> + {{ integration.id === 'shopify' ? '安装' : '连接' }} + </button> + </div> + </div> + + <!-- Empty state --> + <div v-if="intEmpty" class="int-empty"> + 没有匹配的集成。 + </div> + </div> + </DashboardLayout> + + <!-- Disconnect Confirmation Modal --> + <div v-if="showDisconnectConfirm" class="disconnect-modal"> + <div class="disconnect-modal-content"> + <div class="disconnect-modal-header"> + <h3>断开集成</h3> + <button class="close-modal-btn" @click="cancelDisconnect"> + <span>×</span> + </button> + </div> + <div class="disconnect-modal-body"> + <div class="warning-icon">⚠️</div> + <p>确定要断开此集成吗?</p> + <p class="warning-text">此操作将移除该集成的所有连接与配置。</p> + + <div v-if="disconnectingIntegration === 'jira'" class="integration-specific-warning"> + <p>断开 Jira 将:</p> + <ul> + <li>移除所有智能体上的 Jira 配置</li> + <li>停用工单创建功能</li> + <li>如需再次使用,你需要重新连接并配置 Jira</li> + </ul> + </div> + + <div v-if="disconnectingIntegration === 'shopify'" class="integration-specific-warning"> + <p>断开 Shopify 的步骤:</p> + <ul> + <li>你将被跳转到 Shopify 后台</li> + <li>从 Shopify 店铺中卸载 ChatterMate 应用</li> + <li>这样可确保两端同步断开</li> + <li>如需再次使用,你需要重新安装应用</li> + </ul> + </div> + + <div v-if="disconnectingIntegration === 'slack'" class="integration-specific-warning"> + <p>断开 Slack 将:</p> + <ul> + <li>移除所有 Slack 渠道配置</li> + <li>停用 Slack 内的聊天功能</li> + <li>按 GDPR 要求删除已存储的会话数据</li> + <li>如需再次使用,你需要重新连接并配置</li> + </ul> + </div> + + <div v-if="disconnectingIntegration === 'telegram'" class="integration-specific-warning"> + <p>断开 Telegram 将:</p> + <ul> + <li>移除机器人 Webhook,使其不再接收消息</li> + <li>移除该机器人的智能体路由</li> + <li>如需再次使用,你需要重新连接机器人 Token</li> + </ul> + </div> + + <div v-if="disconnectingIntegration === 'whatsapp' || disconnectingIntegration === 'messenger' || disconnectingIntegration === 'instagram'" class="integration-specific-warning"> + <p>断开该渠道将:</p> + <ul> + <li>AI 智能体将不再接收和回复该渠道的消息</li> + <li>移除已连接账号的智能体路由</li> + <li>如需再次使用,需要重新输入凭据</li> + </ul> + </div> + + <div v-if="disconnectingIntegration === 'hubspot' || disconnectingIntegration === 'pipedrive'" class="integration-specific-warning"> + <p>断开该 CRM 将:</p> + <ul> + <li>停止向其推送捕获的线索(排队中的推送将被取消)</li> + <li>吊销 ChatterMate 的访问令牌</li> + <li>保留智能体的「同步到 CRM」设置 — 在你重新连接前保持不生效</li> + </ul> + </div> + </div> + <div class="disconnect-modal-actions"> + <button class="btn-cancel" @click="cancelDisconnect">取消</button> + <button + v-if="disconnectingIntegration === 'jira'" + class="btn-disconnect" + @click="handleDisconnectJira" + :disabled="isLoading" + > + <span v-if="isLoading" class="loading-spinner"></span> + <span v-else>断开 Jira</span> + </button> + <button + v-if="disconnectingIntegration === 'shopify'" + class="btn-disconnect" + @click="handleDisconnectShopify" + > + <span class="btn-icon">↗</span> + <span>打开 Shopify 后台</span> + </button> + <button + v-if="disconnectingIntegration === 'slack'" + class="btn-disconnect" + @click="handleDisconnectSlack" + :disabled="channelsLoading" + > + <span v-if="channelsLoading" class="loading-spinner"></span> + <span v-else>断开 Slack</span> + </button> + <button + v-if="disconnectingIntegration === 'telegram'" + class="btn-disconnect" + @click="handleDisconnectTelegram" + :disabled="channelsLoading" + > + <span v-if="channelsLoading" class="loading-spinner"></span> + <span v-else>断开 Telegram</span> + </button> + <button + v-if="disconnectingIntegration === 'email' || disconnectingIntegration === 'sms' || disconnectingIntegration === 'line'" + class="btn-disconnect" + @click="disconnectingIntegration === 'email' ? handleDisconnectEmail() : disconnectingIntegration === 'sms' ? handleDisconnectSms() : handleDisconnectLine()" + :disabled="channelsLoading" + > + <span v-if="channelsLoading" class="loading-spinner"></span> + <span v-else>断开 {{ disconnectingIntegration === 'email' ? 'Email' : disconnectingIntegration === 'sms' ? 'SMS' : 'LINE' }}</span> + </button> + <button + v-if="disconnectingIntegration === 'whatsapp' || disconnectingIntegration === 'messenger' || disconnectingIntegration === 'instagram'" + class="btn-disconnect" + @click="disconnectingIntegration === 'whatsapp' ? handleDisconnectWhatsApp() : disconnectingIntegration === 'messenger' ? handleDisconnectMessenger() : handleDisconnectInstagram()" + :disabled="channelsLoading" + > + <span v-if="channelsLoading" class="loading-spinner"></span> + <span v-else>断开 {{ disconnectingIntegration === 'whatsapp' ? 'WhatsApp' : disconnectingIntegration === 'messenger' ? 'Messenger' : 'Instagram' }}</span> + </button> + <button + v-if="disconnectingIntegration === 'hubspot' || disconnectingIntegration === 'pipedrive'" + class="btn-disconnect" + @click="handleDisconnectCrm(disconnectingIntegration as CrmProvider)" + :disabled="crmLoading" + > + <span v-if="crmLoading" class="loading-spinner"></span> + <span v-else>断开 {{ disconnectingIntegration === 'hubspot' ? 'HubSpot' : 'Pipedrive' }}</span> + </button> + </div> + </div> + </div> + + <!-- Telegram Connect Modal --> + <TelegramConnectModal + v-if="showTelegramModal" + :existing-account="telegramModalAccount" + @close="showTelegramModal = false; telegramModalAccount = null" + @connected="onChannelConnected" + /> + + <!-- Meta Channel Connect Modal (WhatsApp / Messenger / Instagram) --> + <MetaChannelConnect + v-if="metaModalChannel" + :channel="metaModalChannel" + :existing-account="metaModalAccount" + @close="metaModalChannel = null; metaModalAccount = null; fetchChannelAccounts()" + @connected="onChannelConnected" + /> + + <!-- WhatsApp template management --> + <WhatsAppTemplateManager + v-if="showTemplateManager" + :accounts="accountsFor('whatsapp')" + @close="showTemplateManager = false" + /> + + <!-- Credential Connect Modal (Email / SMS / LINE) --> + <ChannelConnectModal + v-if="credentialModalChannel" + :channel="credentialModalChannel" + :existing-account="credentialModalAccount" + @close="credentialModalChannel = null; credentialModalAccount = null" + @connected="onChannelConnected" + /> + +</template> + +<style scoped> +:root { + --primary-color-rgb: 59, 130, 246; /* This is a typical blue color in RGB format */ + --error-color-rgb: 220, 38, 38; /* Red color in RGB format */ +} + +.integrations-settings { + padding: var(--space-lg); + max-width: 1180px; + margin: 0 auto; +} + +/* Page header + search */ +.int-page-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + flex-wrap: wrap; + margin-bottom: 22px; +} + +.int-title { + font-family: var(--font-display); + font-weight: 700; + font-size: 30px; + letter-spacing: -0.02em; + color: var(--text); + margin: 0 0 6px; +} + +.int-subtitle { + font-size: 15px; + color: var(--muted); + margin: 0; +} + +.int-search { + display: flex; + align-items: center; + gap: 9px; + padding: 10px 14px; + background: var(--surface); + border: 1px solid var(--o10); + border-radius: var(--radius-btn); + min-width: 230px; + color: var(--muted2); +} + +.int-search input { + flex: 1; + min-width: 0; + background: none; + border: none; + outline: none; + color: var(--text); + font-size: 14px; + font-family: inherit; +} + +.int-search input::placeholder { + color: var(--muted2); +} + +/* Summary bar */ +.int-summary-bar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 22px; +} + +.int-summary { + font-family: var(--font-mono); + font-size: 12px; + color: var(--muted2); + flex-shrink: 0; +} + +/* Cards grid */ +.integration-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); + gap: 18px; +} + +.integration-card { + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 18px; + padding: 22px; + display: flex; + flex-direction: column; +} + +.integration-header { + display: flex; + align-items: flex-start; + gap: 14px; + margin-bottom: 14px; +} + +/* Color-coded icon tile */ +.integration-tile { + width: 44px; + height: 44px; + border-radius: 11px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-family: var(--font-display); + font-weight: 700; + font-size: 18px; +} + +.integration-tile .integration-logo { + width: 26px; + height: 26px; + object-fit: contain; +} + +.tile-purple { + background: var(--purple-bg); + color: var(--c-purple); +} + +.tile-teal { + background: var(--teal-bg); + color: var(--c-teal); +} + +.tile-accent { + background: var(--accent-bg-12); + color: var(--accent-ink); +} + +.tile-coral { + background: var(--coral-bg); + color: var(--c-coral); +} + +.integration-info { + flex: 1; + min-width: 0; +} + +.integration-name { + font-family: var(--font-display); + font-weight: 600; + font-size: 16.5px; + color: var(--text2); +} + +.integration-cat { + font-family: var(--font-mono); + font-size: 11px; + color: var(--muted2); + margin-top: 3px; +} + +/* Status badge */ +.status-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + border-radius: var(--radius-full); + font-size: 11px; + font-weight: 600; + flex-shrink: 0; +} + +.status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +.status-badge.connected { + background: var(--teal-bg); + color: var(--c-teal); +} + +.status-badge.not-connected { + background: var(--o05); + color: var(--muted2); +} + +.status-badge.soon { + background: var(--o05); + color: var(--faint); +} + +/* Description */ +.integration-desc { + font-size: 13.5px; + color: var(--muted); + line-height: 1.55; + margin: 0 0 18px; + flex: 1; +} + +/* Connected meta info */ +.integration-meta { + display: flex; + flex-direction: column; + gap: 6px; + margin: -8px 0 16px; +} + +.meta-text { + font-size: 13px; + color: var(--text2); + font-weight: 500; +} + +.meta-link { + font-size: 12.5px; + color: var(--c-teal); + text-decoration: none; + width: fit-content; +} + +.meta-link:hover { + text-decoration: underline; +} + +.meta-error { + font-size: 12px; + color: var(--c-coral); + background: var(--coral-bg); + border: 1px solid var(--coral-border); + padding: 6px 10px; + border-radius: var(--radius-md); + word-break: break-word; +} + +/* Buttons */ +.int-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + font-family: inherit; + font-size: 13.5px; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast); +} + +.int-actions { + display: flex; + gap: 9px; +} + +.int-btn-connect { + width: 100%; + padding: 12px; + background: var(--accent-solid); + color: var(--on-accent-solid); + border: none; + border-radius: var(--radius-chip); + font-size: 14px; + font-weight: 600; +} + +.int-btn-connect:hover { + filter: brightness(1.05); +} + +.int-btn-manage { + flex: 1; + padding: 11px; + background: var(--o05); + border: 1px solid var(--o14); + border-radius: var(--radius-chip); + color: var(--text); +} + +.int-btn-manage:hover { + background: var(--o10); +} + +.int-btn-disconnect { + flex-shrink: 0; + padding: 11px 16px; + background: transparent; + border: 1px solid var(--coral-border); + border-radius: var(--radius-chip); + color: var(--c-coral); +} + +.int-btn-disconnect:hover { + background: var(--coral-bg); +} + +.int-btn-soon { + width: 100%; + padding: 12px; + background: var(--o03); + border: 1px solid var(--o08); + border-radius: var(--radius-chip); + color: var(--faint); + font-size: 14px; + cursor: not-allowed; +} + +.int-btn-loading { + width: 100%; + padding: 12px; + background: var(--o03); + border: 1px solid var(--o08); + border-radius: var(--radius-chip); + color: var(--muted); + font-size: 14px; + cursor: default; +} + +.loading-spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid var(--o10); + border-top-color: var(--accent-ink); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Empty state */ +.int-empty { + padding: 60px 20px; + text-align: center; + color: var(--muted2); + font-size: 14px; +} + +@media (max-width: 768px) { + .integration-cards { + grid-template-columns: 1fr; + } + + .integrations-settings { + padding: var(--space-md); + } +} + +/* Disconnect Modal Styles */ +.disconnect-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.disconnect-modal-content { + background: var(--background-color); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg, 12px); + width: 440px; + max-width: calc(100vw - 32px); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); + overflow: hidden; +} + +.disconnect-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 18px 22px; + border-bottom: 1px solid var(--border-color); +} + +.disconnect-modal-header h3 { + margin: 0; + color: var(--text-primary, var(--text)); + font-size: 16px; + font-weight: 600; + font-family: var(--font-display, inherit); +} + +.close-modal-btn { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 24px; + line-height: 1; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +.close-modal-btn:hover { + color: var(--text-primary); +} + +.disconnect-modal-body { + padding: 24px; +} + +.warning-icon { + width: 56px; + height: 56px; + margin: 4px auto 16px; + display: flex; + align-items: center; + justify-content: center; + font-size: 26px; + border-radius: 50%; + background: var(--coral-bg, rgba(220, 38, 38, 0.12)); +} + +.disconnect-modal-body p { + margin: 0 0 10px; + text-align: center; + color: var(--text-primary, var(--text)); +} + +.warning-text { + color: var(--error-color); + font-weight: 500; + font-size: 13px; +} + +.integration-specific-warning { + margin-top: 20px; + padding: 14px 16px; + background: var(--background-soft); + border-radius: var(--radius-md, 8px); + border-left: 3px solid var(--warning, #f5a623); +} + +.integration-specific-warning p { + text-align: left; + margin: 0 0 8px; + font-weight: 600; + font-size: 13px; + color: var(--text-primary, var(--text)); +} + +.integration-specific-warning ul { + margin: 0; + padding-left: 20px; +} + +.integration-specific-warning li { + margin-bottom: 6px; + color: var(--text-secondary, var(--muted)); + font-size: 13px; +} + +.disconnect-modal-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 16px 22px; + border-top: 1px solid var(--border-color); + background: var(--background-soft); +} + +.btn-cancel { + background: var(--background-mute); + color: var(--text-primary, var(--text)); + border: 1px solid var(--border-color); + border-radius: var(--radius-btn, 8px); + padding: 9px 16px; + cursor: pointer; + font-size: 14px; + font-weight: 600; +} + +.btn-cancel:hover { + background: var(--background-alt); +} + +.btn-disconnect { + background: var(--error-color); + color: #fff; + border: none; + border-radius: var(--radius-btn, 8px); + padding: 9px 16px; + cursor: pointer; + font-size: 14px; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 8px; +} + +.btn-disconnect:hover { + filter: brightness(1.08); +} + +.btn-disconnect:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.loading-spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid var(--o30); + border-radius: 50%; + border-top-color: var(--text); + animation: spin 1s linear infinite; +} + +.not-connected-info { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.connection-error { + font-size: var(--text-xs); + color: var(--error-color); + background-color: rgba(var(--error-color-rgb), 0.1); + padding: var(--space-xs) var(--space-sm); + border-radius: var(--radius-md); + margin-top: var(--space-xs); + display: flex; + align-items: center; + gap: var(--space-xs); + max-width: 100%; + word-break: break-word; +} + +.error-icon { + font-size: 12px; + flex-shrink: 0; +} + +.shop-info { + display: flex; + flex-direction: column; + gap: var(--space-xs); + margin-top: var(--space-xs); +} + +.shop-domain { + font-size: var(--text-sm); + color: var(--text-secondary); + font-weight: 500; +} + +.team-info { + display: flex; + flex-direction: column; + gap: var(--space-xs); + margin-top: var(--space-xs); +} + +.team-name { + font-size: var(--text-sm); + color: var(--text-secondary); + font-weight: 500; +} + +.integration-form { + margin-top: var(--space-sm); + border-top: 1px solid var(--border-color); + padding-top: var(--space-sm); +} + +.form-group { + margin-bottom: var(--space-md); +} + +.form-group label { + display: block; + font-size: var(--text-sm); + font-weight: 500; + margin-bottom: var(--space-xs); + color: var(--text-primary); +} + +.input-with-label { + display: flex; + align-items: center; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background-color: var(--background-color); + overflow: hidden; +} + +.input-with-label input { + flex: 1; + padding: var(--space-sm); + border: none; + font-size: var(--text-sm); + background: transparent; + min-width: 0; +} + +.input-with-label input:focus { + outline: none; +} + +.input-suffix { + padding: var(--space-sm) var(--space-sm) var(--space-sm) 0; + font-size: var(--text-sm); + color: var(--text-muted); + white-space: nowrap; +} + +.form-help { + display: block; + font-size: var(--text-xs); + color: var(--text-muted); + margin-top: var(--space-xs); +} + +.form-error { + display: block; + font-size: var(--text-xs); + color: var(--error-color); + margin-top: var(--space-xs); +} +</style> \ No newline at end of file diff --git a/frontend/src/views/settings/OrganizationSettings.vue b/frontend/src/views/settings/OrganizationSettings.vue new file mode 100644 index 0000000..0a22fae --- /dev/null +++ b/frontend/src/views/settings/OrganizationSettings.vue @@ -0,0 +1,43 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import DashboardLayout from '@/layouts/DashboardLayout.vue' +import OrganizationSettingsSetup from '@/components/organization/Organizations.vue' +</script> + +<template> + <DashboardLayout> + <div class="dashboard-container"> + <div class="settings-section"> + <OrganizationSettingsSetup /> + </div> + </div> + </DashboardLayout> +</template> + +<style scoped> +.dashboard-container { + display: flex; + height: 100%; + position: relative; +} + +.settings-section { + flex: 1; + padding: var(--space-lg); +} +</style> \ No newline at end of file diff --git a/frontend/src/views/settings/TicketingSettings.vue b/frontend/src/views/settings/TicketingSettings.vue new file mode 100644 index 0000000..f9a8986 --- /dev/null +++ b/frontend/src/views/settings/TicketingSettings.vue @@ -0,0 +1,749 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import { computed, ref, watch } from 'vue' +import { toast } from 'vue-sonner' +import DashboardLayout from '@/layouts/DashboardLayout.vue' +import { useTicketingSettings } from '@/composables/useTicketingSettings' +import { PRIORITIES, priorityMeta } from '@/components/tickets/ticketMeta' +import TicketConnectorsSection from '@/components/tickets/TicketConnectorsSection.vue' +import TicketDbConnectorsSection from '@/components/tickets/TicketDbConnectorsSection.vue' +import { apiPath } from '@/config/api' +import { userService } from '@/services/user' +import type { SlaTarget, TicketPriority } from '@/types/ticket' + +const { settings, isLoading, isSaving, error, planGated, save } = useTicketingSettings() + +// Autonomy cards: each enumerates exactly what the AI may do at that level. +const AUTONOMY_CARDS = [ + { + level: 1, + tier: 'L1', + title: '仅调查', + caps: [ + { label: '分诊与调查', ok: true }, + { label: '提出解决方案', ok: false }, + { label: '联系客户', ok: false }, + { label: '关闭工单', ok: false }, + ], + warn: false, + disabled: false, + }, + { + level: 2, + tier: 'L2', + title: '提出方案,人工审批', + caps: [ + { label: '分诊与调查', ok: true }, + { label: '提出解决方案', ok: true }, + { label: '联系客户', ok: false }, + { label: '关闭工单', ok: false }, + ], + warn: false, + disabled: false, + }, + { + level: 3, + tier: 'L3', + title: '自动解决并通知', + caps: [ + { label: '分诊与调查', ok: true }, + { label: '提出解决方案', ok: true }, + { label: '联系客户', ok: true }, + { label: '关闭工单', ok: true }, + ], + warn: true, + disabled: false, + }, +] + +const webhookUrl = computed(() => { + if (!settings.value?.alert_webhook_enabled || !settings.value?.alert_webhook_secret) return null + const orgId = userService.getCurrentUser()?.organization_id + if (!orgId) return null + return apiPath(`/tickets/webhooks/alerts/${orgId}/${settings.value.alert_webhook_secret}`) +}) + +function copyWebhookUrl() { + if (!webhookUrl.value) return + navigator.clipboard + .writeText(webhookUrl.value) + .then(() => toast.success('Webhook URL 已复制')) + .catch(() => {}) +} + +const DEFAULT_SLA: Record<TicketPriority, SlaTarget> = { + urgent: { first_response_minutes: 15, resolution_minutes: 120 }, + high: { first_response_minutes: 30, resolution_minutes: 240 }, + medium: { first_response_minutes: 120, resolution_minutes: 1440 }, + low: { first_response_minutes: 480, resolution_minutes: 4320 }, +} + +// Deep copy per priority: a shallow spread would alias the nested SlaTarget +// objects, so edits would mutate the baseline and isDirty could never fire. +const cloneSla = (targets?: Record<TicketPriority, SlaTarget> | null) => + Object.fromEntries( + PRIORITIES.map((p) => [p, { ...(targets?.[p] || DEFAULT_SLA[p]) }]), + ) as Record<TicketPriority, SlaTarget> + +const slaDraft = ref<Record<TicketPriority, SlaTarget>>(cloneSla()) +const createdTemplate = ref('') +const resolvedTemplate = ref('') +const confirmationTimeout = ref(72) + +watch(settings, (next) => { + if (!next) return + slaDraft.value = cloneSla(next.sla_targets) + createdTemplate.value = next.created_template || defaultCreated + resolvedTemplate.value = next.resolved_template || defaultResolved + confirmationTimeout.value = next.confirmation_timeout_hours +}) + +const defaultCreated = + "您好 [customer] — 我们已就您反馈的问题创建工单 [ticket],团队正在处理,进展会及时同步给您。" +const defaultResolved = + "好消息 [customer] — 工单 [ticket] 已解决。以下是问题原因与我们采取的修复措施,如仍有异常请直接回复。" + +const preview = (template: string) => + template.replace(/\[customer\]/g, 'Northwind').replace(/\[ticket\]/g, 'TKT-2038') + +const isDirty = computed(() => { + if (!settings.value) return false + return ( + JSON.stringify(slaDraft.value) !== JSON.stringify(cloneSla(settings.value.sla_targets)) || + createdTemplate.value !== (settings.value.created_template || defaultCreated) || + resolvedTemplate.value !== (settings.value.resolved_template || defaultResolved) || + confirmationTimeout.value !== settings.value.confirmation_timeout_hours + ) +}) + +function saveAll() { + save({ + sla_targets: slaDraft.value, + created_template: createdTemplate.value, + resolved_template: resolvedTemplate.value, + confirmation_timeout_hours: confirmationTimeout.value, + }) +} +</script> + +<template> + <DashboardLayout> + <div class="ticketing-settings"> + <div class="page-header"> + <h1 class="page-title">工单设置</h1> + <p class="page-subtitle"> + 精确设定 AI 可以执行的操作 — 每项操作都受你所选自主级别的约束。 + </p> + </div> + + <div v-if="isLoading" class="state-msg">加载中…</div> + <div v-else-if="planGated || error" class="state-msg">{{ error }}</div> + + <template v-else-if="settings"> + <!-- AUTONOMY --> + <section class="section"> + <h2 class="section-title">自主级别</h2> + <p class="section-hint"> + AI 不会超出该级别。更高级别下,所有操作仍会记录日志以供审计。 + </p> + <div class="autonomy-grid"> + <div + v-for="card in AUTONOMY_CARDS" + :key="card.level" + class="autonomy-card" + :class="{ + selected: settings.autonomy_level === card.level, + disabled: card.disabled, + }" + @click="!card.disabled && save({ autonomy_level: card.level })" + > + <div class="card-head"> + <span class="radio" :class="{ on: settings.autonomy_level === card.level }"> + <span class="radio-dot"></span> + </span> + <span class="tier">{{ card.tier }}</span> + <span v-if="card.disabled" class="soon-tag">即将推出</span> + </div> + <div class="card-title">{{ card.title }}</div> + <div class="caps"> + <div v-for="cap in card.caps" :key="cap.label" class="cap" :class="{ off: !cap.ok }"> + <span class="cap-icon" :class="cap.ok ? 'yes' : 'no'"> + <font-awesome-icon :icon="['fas', cap.ok ? 'check' : 'xmark']" /> + </span> + {{ cap.label }} + </div> + </div> + <div + v-if="card.warn && settings.autonomy_level === card.level" + class="warn-note" + > + ⚠ AI 可在无人审核的情况下联系客户并关闭工单。 + 请仅用于范围明确、风险较低的队列。 + </div> + </div> + </div> + <label class="toggle-row"> + <input + type="checkbox" + :checked="settings.auto_investigate_on_create" + @change="save({ auto_investigate_on_create: ($event.target as HTMLInputElement).checked })" + /> + 创建工单时自动运行 AI 分诊 + </label> + </section> + + <!-- SLA --> + <section class="section"> + <h2 class="section-title">SLA 目标</h2> + <div class="sla-table"> + <div class="sla-head"> + <span>优先级</span><span>首次响应(分钟)</span><span>解决(分钟)</span> + </div> + <div v-for="p in PRIORITIES" :key="p" class="sla-row"> + <span class="sla-priority" :style="{ color: priorityMeta(p).color }"> + <span class="dot" :style="{ background: priorityMeta(p).color }"></span> + {{ priorityMeta(p).label }} + </span> + <input v-model.number="slaDraft[p].first_response_minutes" type="number" min="1" class="sla-input" /> + <input v-model.number="slaDraft[p].resolution_minutes" type="number" min="1" class="sla-input" /> + </div> + </div> + </section> + + <!-- CUSTOMER COMMS --> + <section class="section"> + <div class="section-head-row"> + <h2 class="section-title">客户沟通</h2> + <label class="toggle-row inline"> + <input + type="checkbox" + :checked="settings.csat_enabled" + @change="save({ csat_enabled: ($event.target as HTMLInputElement).checked })" + /> + 解决后收集 CSAT + </label> + </div> + <p class="section-hint"> + 客户邮件通过你已连接的邮箱发送 — + <router-link to="/settings/integrations" class="hint-link">在「集成」中连接邮箱</router-link> + 即可使用自有域名发送,否则将使用 ChatterMate 的地址。 + </p> + <div class="template-grid"> + <div class="template-card"> + <div class="card-label">模板 · 工单已创建</div> + <textarea v-model="createdTemplate" class="template-input"></textarea> + <div class="card-label">预览</div> + <div class="template-preview">{{ preview(createdTemplate) }}</div> + </div> + <div class="template-card"> + <div class="card-label">模板 · 工单已解决</div> + <textarea v-model="resolvedTemplate" class="template-input"></textarea> + <div class="card-label">预览</div> + <div class="template-preview">{{ preview(resolvedTemplate) }}</div> + </div> + </div> + <div class="timeout-row"> + <span class="timeout-label"> + 已解决工单在客户无回复 + </span> + <input v-model.number="confirmationTimeout" type="number" min="1" max="720" class="sla-input" /> + <span class="timeout-label">小时后自动关闭</span> + </div> + </section> + + <!-- CONNECTORS --> + <section class="section"> + <div class="section-head-row"> + <h2 class="section-title">调查连接器</h2> + <span class="mcp-tag">通过 MCP</span> + </div> + <p class="section-hint"> + 以只读方式访问日志、指标与错误信息,让 AI 在调查期间收集证据。 + 所选连接器会附加到每一次调查中。 + </p> + <TicketConnectorsSection + :selected-ids="settings.investigation_mcp_tool_ids || []" + @update:selected-ids="(ids: number[]) => save({ investigation_mcp_tool_ids: ids })" + /> + </section> + + <!-- GUARDRAILED DATABASE ACCESS --> + <section class="section"> + <div class="section-head-row"> + <h2 class="section-title">数据库连接器</h2> + <span class="lock-chip"> + <font-awesome-icon :icon="['fas', 'lock']" /> + 只读 — AI 无法写入 + </span> + </div> + <p class="section-hint"> + 让调查器直接在你的数据库中核实事实。仅对你明确加入白名单的表执行 SELECT; + 敏感字段会在 AI 读取前脱敏;每条查询都会校验、限制行数并留痕审计。 + </p> + <TicketDbConnectorsSection /> + </section> + + <!-- ALERT WEBHOOK --> + <section class="section"> + <div class="section-head-row"> + <h2 class="section-title">告警 Webhook 接入</h2> + <label class="toggle-row inline"> + <input + type="checkbox" + :checked="settings.alert_webhook_enabled" + @change="save({ alert_webhook_enabled: ($event.target as HTMLInputElement).checked })" + /> + 已启用 + </label> + </div> + <p class="section-hint"> + 将 Grafana、Datadog 或 CloudWatch 的告警 Webhook 指向此处 — 告警会自动创建工单并 + 主动触发调查,无需等客户反馈。重复触发的告警会附加到已有的未关闭工单。 + </p> + <div v-if="webhookUrl" class="webhook-row"> + <code class="webhook-url">{{ webhookUrl }}</code> + <button class="copy-webhook" @click="copyWebhookUrl">复制</button> + </div> + </section> + + <!-- JIRA ESCALATION --> + <section class="section"> + <div class="section-head-row"> + <h2 class="section-title">Jira 升级</h2> + <label class="toggle-row inline"> + <input + type="checkbox" + :checked="settings.jira_escalation_enabled" + @change="save({ jira_escalation_enabled: ($event.target as HTMLInputElement).checked })" + /> + 已启用 + </label> + </div> + <p class="section-hint"> + 单向同步:达到或高于所选优先级的工单会同步创建 Jira 事务(使用已连接的 Jira 与该智能体的项目)。 + ChatterMate 始终是数据来源。 + </p> + <div v-if="settings.jira_escalation_enabled" class="jira-row"> + <span class="timeout-label">升级以下优先级的工单</span> + <select + class="sla-input jira-select" + :value="settings.jira_escalation_priority || 'urgent'" + @change="save({ jira_escalation_priority: ($event.target as HTMLSelectElement).value as TicketPriority })" + > + <option v-for="p in PRIORITIES" :key="p" :value="p">{{ priorityMeta(p).label }} 及以上</option> + </select> + </div> + </section> + + <div class="save-bar" v-if="isDirty"> + <button class="save-btn" :disabled="isSaving" @click="saveAll"> + {{ isSaving ? '保存中…' : '保存更改' }} + </button> + </div> + </template> + </div> + </DashboardLayout> +</template> + +<style scoped> +.ticketing-settings { + max-width: 960px; + margin: 0 auto; + padding: 28px 30px 70px; +} +.page-header { + margin-bottom: 26px; +} +.page-title { + font-family: var(--font-display); + font-weight: var(--font-weight-bold); + font-size: 25px; + letter-spacing: var(--tracking-display); + margin: 0 0 4px; + color: var(--text); +} +.page-subtitle { + margin: 0; + font-size: 13px; + color: var(--muted); +} +.state-msg { + padding: 40px; + text-align: center; + color: var(--muted); + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 15px; +} +.section { + margin-bottom: 34px; +} +.section-title { + font-family: var(--font-display); + font-weight: var(--font-weight-semibold); + font-size: 16px; + margin: 0 0 4px; + color: var(--text); +} +.section-hint { + margin: 0 0 16px; + font-size: 12.5px; + color: var(--muted); + line-height: 1.5; +} +.hint-link { + color: var(--accent-ink); + text-decoration: none; +} +.hint-link:hover { + text-decoration: underline; +} +.section-head-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} +.autonomy-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; + margin-bottom: 16px; +} +.autonomy-card { + border: 1.5px solid var(--o08); + background: var(--surface); + border-radius: 15px; + padding: 17px; + cursor: pointer; + transition: border-color 0.15s; +} +.autonomy-card.selected { + border-color: var(--accent-ink); + background: var(--accent-bg-06); +} +.autonomy-card.disabled { + opacity: 0.55; + cursor: not-allowed; +} +.card-head { + display: flex; + align-items: center; + gap: 9px; + margin-bottom: 12px; +} +.radio { + width: 18px; + height: 18px; + border-radius: 50%; + border: 2px solid var(--o20); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.radio.on { + border-color: var(--accent-ink); +} +.radio-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: transparent; +} +.radio.on .radio-dot { + background: var(--accent-ink); +} +.tier { + font-family: var(--font-display); + font-weight: var(--font-weight-bold); + font-size: 13px; + color: var(--muted2); +} +.autonomy-card.selected .tier { + color: var(--accent-ink); +} +.soon-tag { + margin-left: auto; + font-family: var(--font-mono); + font-size: 9.5px; + color: var(--faint); + background: var(--o05); + border: 1px solid var(--o08); + padding: 2px 7px; + border-radius: 20px; +} +.card-title { + font-family: var(--font-display); + font-weight: var(--font-weight-semibold); + font-size: 15px; + margin-bottom: 12px; + color: var(--text); +} +.caps { + display: flex; + flex-direction: column; + gap: 8px; +} +.cap { + display: flex; + align-items: center; + gap: 8px; + font-size: 12.5px; + color: var(--text3); +} +.cap.off { + color: var(--faint); +} +.cap-icon { + width: 15px; + height: 15px; + border-radius: 5px; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: var(--font-weight-bold); + flex-shrink: 0; +} +.cap-icon.yes { + background: color-mix(in srgb, var(--c-positive) 15%, transparent); + color: var(--c-positive); +} +.cap-icon.no { + background: color-mix(in srgb, var(--c-danger) 12%, transparent); + color: var(--c-danger); +} +.warn-note { + margin-top: 13px; + display: flex; + gap: 8px; + padding: 9px 11px; + background: color-mix(in srgb, var(--c-warn) 12%, transparent); + border: 1px solid var(--c-warn); + border-radius: 10px; + font-size: 11.5px; + color: var(--c-warn); + line-height: 1.45; +} +.toggle-row { + display: flex; + align-items: center; + gap: 9px; + font-size: 13px; + color: var(--text3); + cursor: pointer; +} +.toggle-row.inline { + font-size: 12.5px; + color: var(--muted); +} +.sla-table { + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 14px; + overflow: hidden; +} +.sla-head, +.sla-row { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 14px; + padding: 11px 18px; + align-items: center; +} +.sla-head { + border-bottom: 1px solid var(--o07); + background: var(--o03); + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--faint); +} +.sla-row { + border-bottom: 1px solid var(--o06); +} +.sla-row:last-child { + border-bottom: none; +} +.sla-priority { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 13px; + font-weight: var(--font-weight-semibold); +} +.dot { + width: 7px; + height: 7px; + border-radius: 2px; +} +.sla-input { + width: 100px; + padding: 7px 10px; + background: var(--bg2); + border: 1px solid var(--o08); + border-radius: 8px; + color: var(--text); + font-family: var(--font-mono); + font-size: 12.5px; + outline: none; +} +.template-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + margin-bottom: 16px; +} +.template-card { + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 14px; + padding: 16px; +} +.card-label { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--faint); + margin-bottom: 10px; +} +.template-input { + width: 100%; + min-height: 76px; + resize: vertical; + background: var(--bg2); + border: 1px solid var(--o08); + border-radius: 10px; + padding: 10px 12px; + color: var(--text); + font-size: 12.5px; + line-height: 1.5; + outline: none; + margin-bottom: 12px; +} +.template-preview { + background: var(--bubble-ai-bg, var(--bg2)); + border: 1px solid var(--o07); + border-radius: 10px 10px 10px 3px; + padding: 11px 13px; + font-size: 12.5px; + color: var(--text3); + line-height: 1.5; +} +.timeout-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.timeout-label { + font-size: 13px; + color: var(--text3); +} +.mcp-tag { + font-family: var(--font-mono); + font-size: 10px; + font-weight: var(--font-weight-semibold); + letter-spacing: 0.06em; + color: var(--c-teal); + background: var(--teal-bg-10); + padding: 2px 8px; + border-radius: 20px; +} +.lock-chip { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--muted2); + background: var(--o05); + border: 1px solid var(--o08); + padding: 3px 10px; + border-radius: 20px; + white-space: nowrap; +} +.webhook-row { + display: flex; + align-items: center; + gap: 10px; +} +.webhook-url { + flex: 1; + min-width: 0; + font-family: var(--font-mono); + font-size: 11px; + color: var(--text3); + background: var(--surface); + border: 1px solid var(--o08); + border-radius: 9px; + padding: 9px 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.copy-webhook { + padding: 8px 14px; + background: var(--accent-bg-08); + border: none; + color: var(--accent-ink); + border-radius: 9px; + font-size: 12px; + cursor: pointer; + flex-shrink: 0; +} +.jira-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.jira-select { + width: auto; + font-family: inherit; +} +.save-bar { + position: sticky; + bottom: 20px; + display: flex; + justify-content: flex-end; +} +.save-btn { + padding: 11px 22px; + background: var(--accent-solid); + color: var(--on-accent-solid); + border: none; + border-radius: 11px; + font-size: 13.5px; + font-weight: var(--font-weight-bold); + cursor: pointer; + box-shadow: 0 8px 30px -8px rgba(0, 0, 0, 0.4); +} +.save-btn:disabled { + opacity: 0.5; +} +@media (max-width: 800px) { + .autonomy-grid, + .template-grid { + grid-template-columns: 1fr; + } +} +</style> diff --git a/frontend/src/views/settings/WidgetAppsSettings.vue b/frontend/src/views/settings/WidgetAppsSettings.vue new file mode 100644 index 0000000..ba76f66 --- /dev/null +++ b/frontend/src/views/settings/WidgetAppsSettings.vue @@ -0,0 +1,43 @@ +<!-- +Copyright 2024-2026 ChatterMate + +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. +--> + +<script setup lang="ts"> +import DashboardLayout from '@/layouts/DashboardLayout.vue' +import WidgetAppList from '@/components/widget-app/WidgetAppList.vue' +</script> + +<template> + <DashboardLayout> + <div class="dashboard-container"> + <div class="settings-section"> + <WidgetAppList /> + </div> + </div> + </DashboardLayout> +</template> + +<style scoped> +.dashboard-container { + display: flex; + height: 100%; + position: relative; +} + +.settings-section { + flex: 1; + padding: var(--space-lg); +} +</style>