Files
replypier/backend/app/services/channel_status.py
T
WangShen007 20fef2b9f3 chore: settle product naming across app, widget and deployments
One word everywhere: ReplyPier in UI copy, package and image names, the
replypier.site domains (app/api/docs/help), REPLYPIER_* settings and the
--rp-* design tokens.

- public widget API is window.ReplyPier with replypierId / replypierBaseUrl /
  replypierConfig / replypierToken, loader at webclient/replypier.min.js;
  both bundles rebuilt from source
- NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and the demo runtime
  config rewritten; the demo config ships no third-party keys
- migration adds the ReplyPier managed model value to the AI model enum
- local compose project, database, role, volume and Docker network carry the
  same name
2026-10-26 00:57:22 +08:00

187 lines
7.4 KiB
Python

"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
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 ReplyPier 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'),
)