feat(modules): 帮助中心、工单、工作流与多渠道接入

补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
WangShen007 committed 2026-10-17 22:05:00 +08:00
1 parent ba91abc4b2
commit 83a16ed07d
225 files changed
+59337

No files matched your search

+34
View File
@@ -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"])
+42
View File
@@ -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
+78
View File
@@ -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"}
+58
View File
@@ -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"}
+187
View File
@@ -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)
+175
View File
@@ -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"}
+114
View File
@@ -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"}
+81
View File
@@ -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}