Files
replypier/backend/app/services/widget_identity.py
WangShen007 0cc6299d1c feat(widget): Widget 配置、loader 与右下角悬浮入口
生成可嵌入站点的 loader 与悬浮按钮,支持展开收起和移动端;附独立宿主测试页。
2026-09-25 20:50:00 +08:00

96 lines
3.9 KiB
Python

"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
What a widget conversation token may do to the customer it names.
A widget visitor can be bound to a customer by an email they only typed, so the
token naming a customer is not proof of being them. Conversations are already
fenced by the token's scope (see conversation_token.conversation_scope); this
module fences the customer row itself - its contact details, merges, and whether
an unverified token may stand for a person the embedding app has signed in.
"""
from typing import Optional
from uuid import UUID
from sqlalchemy.orm import Session
from app.models.customer import Customer
from app.models.session_to_agent import SessionToAgent
from app.repositories.customer import CustomerRepository
from app.repositories.session_to_agent import SessionToAgentRepository
from app.services.conversation_token import (
LEGACY_SCOPE_PREFIX,
is_verified_scope,
scope_for_session_key,
)
def can_act_as(db: Session, customer: Optional[Customer], scope: Optional[str]) -> bool:
"""True when a token with `scope` may change `customer` as if it were them.
A verified token always may. An unverified one only for a customer its own
scope created - "no conversations elsewhere" is not ownership: a customer
made by a ticket or an import has none - and only while every conversation
of that customer is still its own.
"""
if customer is None or not scope:
return False
if is_verified_scope(scope):
return True
if customer.is_authenticated or not _created_by(customer, scope):
return False
return not SessionToAgentRepository(db).customer_has_sessions_outside_scope(customer.id, scope)
def _created_by(customer: Customer, scope: str) -> bool:
if customer.conversation_key:
return customer.conversation_key == scope
# Customers from before conversation keys: the anonymous row a legacy
# widget token created for itself is the only kind such a token can own.
return scope.startswith(LEGACY_SCOPE_PREFIX) and CustomerRepository.is_placeholder_email(customer.email)
def may_stand_for(customer: Optional[Customer], scope: Optional[str]) -> bool:
"""True when an unverified token may keep naming `customer` at all.
Once the embedding app has signed a person in, only a token our side vouched
for may name them - otherwise whoever claimed the address first would keep a
token for the signed-in user's row.
"""
if customer is None or not scope:
return False
return is_verified_scope(scope) or not customer.is_authenticated
def trusts_identity_in_conversation(
db: Session, customer: Optional[Customer], conversation: Optional[SessionToAgent]
) -> bool:
"""True when the visitor in `conversation` may be told, or change, what we
know of `customer`.
Reciting a stored name or email back is how a visitor who only typed someone
else's address would learn who they are. Unknown conversation: nothing.
"""
if conversation is None:
return False
return can_act_as(db, customer, scope_for_session_key(conversation.conversation_key))
def trusts_identity_in_session(db: Session, customer: Optional[Customer], session_id: UUID | str | None) -> bool:
"""trusts_identity_in_conversation, for a caller holding only the session id."""
conversation = SessionToAgentRepository(db).get_session(session_id) if session_id else None
return trusts_identity_in_conversation(db, customer, conversation)