feat(modules): 帮助中心、工单、工作流与多渠道接入
补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
1 parent
ba91abc4b2
commit
83a16ed07d
225 files changed
+59337
No files matched your search
@@ -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"])
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
@@ -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 = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>'
|
||||
|
||||
|
||||
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"}
|
||||
@@ -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}
|
||||
Reference in new issue
Block a user