feat: 日历分享改为按手机号+好友关系校验
This commit is contained in:
@@ -11,7 +11,6 @@ from core.agentscope.tools.utils.calendar_domain import (
|
||||
map_calendar_exception,
|
||||
merge_schedule_metadata_for_update,
|
||||
parse_iso_datetime,
|
||||
resolve_share_target_phone_map,
|
||||
schedule_event_to_dict,
|
||||
)
|
||||
from core.agentscope.tools.utils.calendar_ui import (
|
||||
@@ -31,9 +30,12 @@ from v1.schedule_items.schemas import (
|
||||
class CalendarShareInvitee(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
user_id: str = Field(
|
||||
alias="userId",
|
||||
description="Target invitee user id as UUID string.",
|
||||
phone: str = Field(
|
||||
alias="phone",
|
||||
description=(
|
||||
"Target invitee phone. Accepts +8613xxxxxxxxx / 8613xxxxxxxxx "
|
||||
"/ 13xxxxxxxxx and normalizes to E.164 (+86...)."
|
||||
),
|
||||
)
|
||||
permission_view: bool = Field(
|
||||
default=True,
|
||||
@@ -497,8 +499,8 @@ async def calendar_share(
|
||||
list[CalendarShareInvitee],
|
||||
Field(
|
||||
description=(
|
||||
"Invitee list with userId and per-user permissions. "
|
||||
"Prefer composing with user_lookup tool to get userId first."
|
||||
"Invitee list with phone and per-user permissions. "
|
||||
"Prefer composing with user_lookup tool to pick a friend phone first."
|
||||
),
|
||||
min_length=1,
|
||||
),
|
||||
@@ -506,11 +508,25 @@ async def calendar_share(
|
||||
session: Any = None,
|
||||
owner_id: Any = None,
|
||||
) -> ToolResponse:
|
||||
"""Share a calendar event with invitee user ids.
|
||||
"""Share a calendar event with invitee phones.
|
||||
|
||||
Input contract:
|
||||
- invitees use `phone` (not `userId`)
|
||||
- phone accepts local/86/E.164 forms and is normalized before share
|
||||
|
||||
Orchestration contract:
|
||||
- prefer `user_lookup` first to get friend candidates
|
||||
- choose matched friend phone(s)
|
||||
- call `calendar_share`
|
||||
|
||||
Output contract:
|
||||
- status can be success / partial / failure
|
||||
- result includes per-item outcomes in `items=[{phone,status,code}]`
|
||||
- first failure is exposed in `error` when any item fails
|
||||
|
||||
Args:
|
||||
event_id: Target event id as UUID string.
|
||||
invitees: Invitee list with user id and per-user permissions.
|
||||
invitees: Invitee list with phone and per-user permissions.
|
||||
|
||||
Returns:
|
||||
ToolResponse with serialized ToolAgentOutput payload.
|
||||
@@ -537,56 +553,116 @@ async def calendar_share(
|
||||
)
|
||||
target_uuid = UUID(event_id)
|
||||
|
||||
phone_map = resolve_share_target_phone_map(
|
||||
[invitee.user_id for invitee in invitees]
|
||||
)
|
||||
|
||||
if not phone_map:
|
||||
return calendar_error_output(
|
||||
tool_name=tool_name,
|
||||
tool_call_args=tool_call_args,
|
||||
code="NOT_FOUND",
|
||||
message="未找到任何有效的邀请目标",
|
||||
retryable=False,
|
||||
)
|
||||
|
||||
invited: list[str] = []
|
||||
result_items: list[dict[str, str]] = []
|
||||
for invitee in invitees:
|
||||
try:
|
||||
normalized_user_id = str(UUID(invitee.user_id.strip()))
|
||||
except ValueError:
|
||||
continue
|
||||
phone = phone_map.get(normalized_user_id)
|
||||
if phone is None:
|
||||
raw_phone = invitee.phone.strip()
|
||||
normalized_phone = raw_phone
|
||||
for separator in (" ", "-", "(", ")"):
|
||||
normalized_phone = normalized_phone.replace(separator, "")
|
||||
if normalized_phone.startswith("0086"):
|
||||
normalized_phone = f"+86{normalized_phone[4:]}"
|
||||
elif normalized_phone.startswith("86") and normalized_phone[2:].isdigit():
|
||||
normalized_phone = f"+{normalized_phone}"
|
||||
elif normalized_phone.startswith("1") and normalized_phone.isdigit():
|
||||
normalized_phone = f"+86{normalized_phone}"
|
||||
if (
|
||||
len(normalized_phone) != 14
|
||||
or not normalized_phone.startswith("+861")
|
||||
or not normalized_phone[1:].isdigit()
|
||||
or normalized_phone[4] not in {"3", "4", "5", "6", "7", "8", "9"}
|
||||
):
|
||||
result_items.append(
|
||||
{
|
||||
"phone": raw_phone,
|
||||
"status": "failure",
|
||||
"code": "INVALID_ARGUMENT",
|
||||
"message": "无效手机号格式",
|
||||
}
|
||||
)
|
||||
continue
|
||||
permission = {
|
||||
"permission_view": invitee.permission_view,
|
||||
"permission_edit": invitee.permission_edit,
|
||||
"permission_invite": invitee.permission_invite,
|
||||
}
|
||||
await service.share(
|
||||
target_uuid, ScheduleItemShareRequest(phone=phone, **permission)
|
||||
try:
|
||||
await service.share(
|
||||
target_uuid,
|
||||
ScheduleItemShareRequest(phone=normalized_phone, **permission),
|
||||
)
|
||||
invited.append(normalized_phone)
|
||||
result_items.append(
|
||||
{
|
||||
"phone": normalized_phone,
|
||||
"status": "success",
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
code, message, _ = map_calendar_exception(exc)
|
||||
result_items.append(
|
||||
{
|
||||
"phone": normalized_phone,
|
||||
"status": "failure",
|
||||
"code": code,
|
||||
"message": message,
|
||||
}
|
||||
)
|
||||
|
||||
failure_count = len(
|
||||
[item for item in result_items if item["status"] == "failure"]
|
||||
)
|
||||
success_count = len(invited)
|
||||
if success_count and failure_count:
|
||||
final_status = ToolStatus.PARTIAL
|
||||
elif success_count:
|
||||
final_status = ToolStatus.SUCCESS
|
||||
else:
|
||||
final_status = ToolStatus.FAILURE
|
||||
|
||||
compact_items = ",".join(
|
||||
[
|
||||
"{"
|
||||
f"phone={item.get('phone')},status={item.get('status')},"
|
||||
f"code={item.get('code', '')}"
|
||||
"}"
|
||||
for item in result_items
|
||||
]
|
||||
)
|
||||
summary = (
|
||||
f"status={final_status.value} success={success_count} "
|
||||
f"failed={failure_count}"
|
||||
)
|
||||
if compact_items:
|
||||
summary = f"{summary} items=[{compact_items}]"
|
||||
|
||||
error_info: ErrorInfo | None = None
|
||||
if failure_count:
|
||||
first_failure = next(
|
||||
(item for item in result_items if item.get("status") == "failure"),
|
||||
None,
|
||||
)
|
||||
invited.append(phone)
|
||||
if not invited:
|
||||
return calendar_error_output(
|
||||
tool_name=tool_name,
|
||||
tool_call_args=tool_call_args,
|
||||
code="NOT_FOUND",
|
||||
message="邀请目标均无有效手机号",
|
||||
error_info = ErrorInfo(
|
||||
code=str(
|
||||
first_failure.get("code") if first_failure else "INTERNAL_ERROR"
|
||||
),
|
||||
message=str(
|
||||
first_failure.get("message")
|
||||
if first_failure and first_failure.get("message")
|
||||
else "日历分享失败"
|
||||
),
|
||||
retryable=False,
|
||||
details={"results": result_items},
|
||||
)
|
||||
|
||||
summary = (
|
||||
f"status=success invited_count={len(invited)} invited=[{','.join(invited)}]"
|
||||
)
|
||||
return dump_tool_output(
|
||||
ToolAgentOutput(
|
||||
tool_name=tool_name,
|
||||
tool_call_id=get_current_tool_call_id(tool_name=tool_name),
|
||||
tool_call_args=tool_call_args,
|
||||
status=ToolStatus.SUCCESS,
|
||||
status=final_status,
|
||||
result=summary,
|
||||
error=error_info,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
from typing import Annotated, Any, cast
|
||||
from typing import Any, cast
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from agentscope.tool import ToolResponse
|
||||
from core.agentscope.tools.tool_call_context import get_current_tool_call_id
|
||||
from core.agentscope.tools.utils import (
|
||||
find_auth_phone_by_user_id,
|
||||
list_auth_users,
|
||||
)
|
||||
from core.agentscope.tools.utils.tool_response_builder import (
|
||||
build_error_output,
|
||||
build_tool_response,
|
||||
)
|
||||
from models.friendships import Friendship
|
||||
from models.profile import Profile
|
||||
from schemas.enums import FriendshipStatus
|
||||
from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus
|
||||
from v1.auth.gateway import SupabaseAuthGateway
|
||||
from v1.users.contact_resolver import resolve_contacts_by_user_ids
|
||||
|
||||
|
||||
def _dump_tool_output(output: ToolAgentOutput) -> ToolResponse:
|
||||
@@ -43,85 +40,99 @@ def _lookup_error_output(
|
||||
return _dump_tool_output(output)
|
||||
|
||||
|
||||
async def _resolve_identity(
|
||||
async def _list_friend_contacts(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
user_phone: str | None,
|
||||
user_name: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve user identity by phone or username."""
|
||||
phone = user_phone.strip() if isinstance(user_phone, str) else ""
|
||||
name = user_name.strip() if isinstance(user_name, str) else ""
|
||||
owner_id: UUID,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Load accepted friends and return contact tuples.
|
||||
|
||||
if bool(phone) == bool(name):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="请提供 phone 或 username 其中之一",
|
||||
Returns items shaped as:
|
||||
- userId: friend user UUID string
|
||||
- username: friend username
|
||||
- phone: friend phone in E.164 format
|
||||
"""
|
||||
friendships_stmt = (
|
||||
select(Friendship)
|
||||
.where(
|
||||
or_(
|
||||
Friendship.user_low_id == owner_id,
|
||||
Friendship.user_high_id == owner_id,
|
||||
)
|
||||
)
|
||||
|
||||
if phone:
|
||||
auth_gateway = SupabaseAuthGateway()
|
||||
user = await auth_gateway.get_user_by_phone(phone)
|
||||
user_id = UUID(user.id)
|
||||
|
||||
stmt = (
|
||||
select(Profile.username)
|
||||
.where(Profile.id == user_id)
|
||||
.where(Profile.deleted_at.is_(None))
|
||||
.where(Friendship.status == FriendshipStatus.ACCEPTED)
|
||||
.where(Friendship.deleted_at.is_(None))
|
||||
)
|
||||
friendships = (await session.execute(friendships_stmt)).scalars().all()
|
||||
friend_ids: list[UUID] = []
|
||||
for friendship in friendships:
|
||||
friend_id = (
|
||||
friendship.user_high_id
|
||||
if friendship.user_low_id == owner_id
|
||||
else friendship.user_low_id
|
||||
)
|
||||
username = (await session.execute(stmt)).scalar_one_or_none()
|
||||
friend_ids.append(friend_id)
|
||||
|
||||
return {
|
||||
"userId": str(user_id),
|
||||
"phone": user.phone,
|
||||
"username": username,
|
||||
"matchedBy": "phone",
|
||||
}
|
||||
if not friend_ids:
|
||||
return []
|
||||
|
||||
stmt = (
|
||||
profiles_stmt = (
|
||||
select(Profile)
|
||||
.where(Profile.username == name)
|
||||
.where(Profile.id.in_(friend_ids))
|
||||
.where(Profile.deleted_at.is_(None))
|
||||
)
|
||||
profile = await session.execute(stmt)
|
||||
profile = profile.scalar_one_or_none()
|
||||
profiles = (await session.execute(profiles_stmt)).scalars().all()
|
||||
profiles_by_id = {profile.id: profile for profile in profiles}
|
||||
auth_gateway = SupabaseAuthGateway()
|
||||
resolved_contacts = await resolve_contacts_by_user_ids(
|
||||
user_ids=friend_ids,
|
||||
profiles_by_id=profiles_by_id,
|
||||
auth_gateway=auth_gateway,
|
||||
)
|
||||
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
contacts: list[dict[str, str]] = []
|
||||
for friend_id in friend_ids:
|
||||
contact = resolved_contacts.get(friend_id)
|
||||
if contact is None:
|
||||
continue
|
||||
phone = contact.phone
|
||||
if not phone:
|
||||
continue
|
||||
contacts.append(
|
||||
{
|
||||
"userId": str(friend_id),
|
||||
"username": str(contact.username or ""),
|
||||
"phone": phone,
|
||||
}
|
||||
)
|
||||
|
||||
users = list_auth_users()
|
||||
phone_value = find_auth_phone_by_user_id(users=users, user_id=profile.id)
|
||||
|
||||
return {
|
||||
"userId": str(profile.id),
|
||||
"phone": phone_value,
|
||||
"username": profile.username,
|
||||
"matchedBy": "username",
|
||||
}
|
||||
contacts.sort(key=lambda item: (item["username"], item["phone"]))
|
||||
return contacts
|
||||
|
||||
|
||||
async def user_lookup(
|
||||
user_phone: Annotated[
|
||||
str | None,
|
||||
Field(description="User phone to look up."),
|
||||
] = None,
|
||||
user_name: Annotated[
|
||||
str | None,
|
||||
Field(description="Username to look up."),
|
||||
] = None,
|
||||
session: Any = None,
|
||||
owner_id: Any = None,
|
||||
) -> ToolResponse:
|
||||
"""Look up user identity by phone or username.
|
||||
"""List current user's accepted friend contacts.
|
||||
|
||||
Args:
|
||||
user_phone: User phone for lookup.
|
||||
user_name: Username for lookup.
|
||||
This tool is intentionally argument-free for business inputs. Runtime
|
||||
context (`session`, `owner_id`) is injected by toolkit preset kwargs.
|
||||
|
||||
Intended composition:
|
||||
1) call `user_lookup` to obtain friend username/phone candidates
|
||||
2) resolve target friend from user utterance
|
||||
3) call `calendar_share` with selected phone(s)
|
||||
|
||||
Result format (in ToolAgentOutput.result):
|
||||
- status=success
|
||||
- friends_count=<n>
|
||||
- friends=[{userId=...,username=...,phone=...}, ...]
|
||||
|
||||
Returns:
|
||||
ToolResponse with serialized ToolAgentOutput payload.
|
||||
"""
|
||||
tool_call_args = {"user_phone": user_phone, "user_name": user_name}
|
||||
tool_call_args: dict[str, Any] = {}
|
||||
|
||||
if session is None or owner_id is None:
|
||||
return _lookup_error_output(
|
||||
@@ -132,20 +143,23 @@ async def user_lookup(
|
||||
)
|
||||
|
||||
try:
|
||||
resolved = await _resolve_identity(
|
||||
contacts = await _list_friend_contacts(
|
||||
session=cast(AsyncSession, session),
|
||||
user_phone=user_phone,
|
||||
user_name=user_name,
|
||||
owner_id=cast(UUID, owner_id),
|
||||
)
|
||||
|
||||
username = str(resolved.get("username") or "")
|
||||
phone = str(resolved.get("phone") or "")
|
||||
user_id = str(resolved.get("userId") or "")
|
||||
matched_by = str(resolved.get("matchedBy") or "")
|
||||
summary = (
|
||||
f"status=success matched_by={matched_by} user_id={user_id} "
|
||||
f"username={username} has_phone={str(bool(phone)).lower()}"
|
||||
compact_items = ",".join(
|
||||
[
|
||||
"{"
|
||||
f"userId={item.get('userId')},"
|
||||
f"username={item.get('username')},"
|
||||
f"phone={item.get('phone')}"
|
||||
"}"
|
||||
for item in contacts
|
||||
]
|
||||
)
|
||||
summary = f"status=success friends_count={len(contacts)}"
|
||||
if compact_items:
|
||||
summary = f"{summary} friends=[{compact_items}]"
|
||||
return _dump_tool_output(
|
||||
ToolAgentOutput(
|
||||
tool_name="user_lookup",
|
||||
@@ -155,24 +169,10 @@ async def user_lookup(
|
||||
result=summary,
|
||||
)
|
||||
)
|
||||
except HTTPException as exc:
|
||||
if exc.status_code == 404:
|
||||
return _lookup_error_output(
|
||||
tool_call_args=tool_call_args,
|
||||
code="NOT_FOUND",
|
||||
message=exc.detail or "用户不存在",
|
||||
retryable=False,
|
||||
)
|
||||
return _lookup_error_output(
|
||||
tool_call_args=tool_call_args,
|
||||
code="LOOKUP_FAILED",
|
||||
message=exc.detail or "用户查找失败",
|
||||
retryable=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
return _lookup_error_output(
|
||||
tool_call_args=tool_call_args,
|
||||
code="INTERNAL_ERROR",
|
||||
message=f"用户查找失败: {str(exc)}",
|
||||
message=f"好友查找失败: {str(exc)}",
|
||||
retryable=True,
|
||||
)
|
||||
|
||||
@@ -8,11 +8,8 @@ from uuid import UUID
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.agentscope.tools.utils.auth_helpers import (
|
||||
find_auth_phone_by_user_id,
|
||||
list_auth_users,
|
||||
)
|
||||
from core.auth.models import CurrentUser
|
||||
from core.http.errors import ApiProblemError
|
||||
from v1.inbox_messages.repository import SQLAlchemyInboxMessageRepository
|
||||
from v1.schedule_items.repository import SQLAlchemyScheduleItemRepository
|
||||
from v1.schedule_items.schemas import ScheduleItemMetadata
|
||||
@@ -22,6 +19,8 @@ _HEX_COLOR_PATTERN = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
||||
|
||||
|
||||
def map_calendar_exception(exc: Exception) -> tuple[str, str, bool]:
|
||||
if isinstance(exc, ApiProblemError):
|
||||
return exc.code, exc.detail, False
|
||||
if isinstance(exc, HTTPException):
|
||||
detail = exc.detail
|
||||
if isinstance(detail, str) and detail.strip():
|
||||
@@ -127,22 +126,3 @@ def parse_iso_datetime(value: str | None) -> datetime | None:
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("时间必须包含时区信息")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def resolve_share_target_phone_map(invitee_user_ids: list[str]) -> dict[str, str]:
|
||||
users = list_auth_users()
|
||||
resolved: dict[str, str] = {}
|
||||
for raw_user_id in invitee_user_ids:
|
||||
if not isinstance(raw_user_id, str):
|
||||
continue
|
||||
normalized_user_id = raw_user_id.strip()
|
||||
if not normalized_user_id:
|
||||
continue
|
||||
try:
|
||||
user_uuid = UUID(normalized_user_id)
|
||||
except ValueError:
|
||||
continue
|
||||
phone = find_auth_phone_by_user_id(users=users, user_id=user_uuid)
|
||||
if phone:
|
||||
resolved[str(user_uuid)] = phone
|
||||
return resolved
|
||||
|
||||
@@ -41,6 +41,7 @@ class SupabaseAuthGateway(AuthServiceGateway):
|
||||
self._user_lookup_cache_ttl_seconds: int = 60
|
||||
self._user_lookup_cache_expires_at: float = 0.0
|
||||
self._users_by_phone: dict[str, Any] = {}
|
||||
self._users_by_id: dict[str, Any] = {}
|
||||
|
||||
def _get_client(self) -> Any:
|
||||
return supabase_service.get_client()
|
||||
@@ -207,17 +208,30 @@ class SupabaseAuthGateway(AuthServiceGateway):
|
||||
)
|
||||
|
||||
async def get_user_by_id(self, user_id: str) -> UserByIdResponse:
|
||||
try:
|
||||
admin_client = self._get_admin_client()
|
||||
user = await asyncio.to_thread(admin_client.auth.get_user_by_id, user_id)
|
||||
users = await self.get_users_by_ids([user_id])
|
||||
resolved = users.get(user_id)
|
||||
if resolved is None:
|
||||
raise _auth_error(
|
||||
status_code=404,
|
||||
code="AUTH_USER_NOT_FOUND",
|
||||
detail="User not found",
|
||||
)
|
||||
return resolved
|
||||
|
||||
async def get_users_by_ids(
|
||||
self, user_ids: list[str]
|
||||
) -> dict[str, UserByIdResponse]:
|
||||
await self._refresh_user_lookup_cache_if_needed()
|
||||
resolved: dict[str, UserByIdResponse] = {}
|
||||
for raw_user_id in user_ids:
|
||||
normalized_user_id = raw_user_id.strip()
|
||||
if not normalized_user_id:
|
||||
continue
|
||||
user = self._users_by_id.get(normalized_user_id)
|
||||
if user is None:
|
||||
raise _auth_error(
|
||||
status_code=404,
|
||||
code="AUTH_USER_NOT_FOUND",
|
||||
detail="User not found",
|
||||
)
|
||||
continue
|
||||
user_attrs = getattr(user, "user", user)
|
||||
return UserByIdResponse(
|
||||
resolved[normalized_user_id] = UserByIdResponse(
|
||||
id=str(getattr(user_attrs, "id", "")),
|
||||
phone=getattr(user_attrs, "phone", None),
|
||||
created_at=str(getattr(user_attrs, "created_at", "")),
|
||||
@@ -227,19 +241,7 @@ class SupabaseAuthGateway(AuthServiceGateway):
|
||||
else None
|
||||
),
|
||||
)
|
||||
except AuthError as exc:
|
||||
logger.warning("Get user by id failed", error_type=type(exc).__name__)
|
||||
if _is_auth_upstream_unavailable(exc):
|
||||
raise _auth_error(
|
||||
status_code=503,
|
||||
code="AUTH_SERVICE_UNAVAILABLE",
|
||||
detail=AUTH_UNAVAILABLE_DETAIL,
|
||||
) from exc
|
||||
raise _auth_error(
|
||||
status_code=404,
|
||||
code="AUTH_USER_NOT_FOUND",
|
||||
detail="User not found",
|
||||
) from exc
|
||||
return resolved
|
||||
|
||||
async def search_user_ids_by_phone(self, query: str, limit: int = 20) -> list[str]:
|
||||
normalized_query = _normalize_phone_search_query(query)
|
||||
@@ -287,10 +289,15 @@ class SupabaseAuthGateway(AuthServiceGateway):
|
||||
admin_client = self._get_admin_client()
|
||||
users = await asyncio.to_thread(_list_auth_users, admin_client)
|
||||
users_by_phone: dict[str, Any] = {}
|
||||
users_by_id: dict[str, Any] = {}
|
||||
for candidate in users:
|
||||
candidate_id = str(getattr(candidate, "id", "")).strip()
|
||||
if candidate_id:
|
||||
users_by_id[candidate_id] = candidate
|
||||
candidate_phone = _normalize_phone(getattr(candidate, "phone", ""))
|
||||
if candidate_phone:
|
||||
users_by_phone[candidate_phone] = candidate
|
||||
self._users_by_id = users_by_id
|
||||
self._users_by_phone = users_by_phone
|
||||
self._user_lookup_cache_expires_at = now + self._user_lookup_cache_ttl_seconds
|
||||
|
||||
@@ -386,10 +393,15 @@ def _list_auth_users(client: Any) -> list[Any]:
|
||||
return users
|
||||
|
||||
|
||||
def _normalize_phone(raw_phone: object) -> str | None:
|
||||
phone = str(raw_phone).strip()
|
||||
def _sanitize_phone_token(raw: object) -> str:
|
||||
token = str(raw).strip()
|
||||
for separator in (" ", "-", "(", ")"):
|
||||
phone = phone.replace(separator, "")
|
||||
token = token.replace(separator, "")
|
||||
return token
|
||||
|
||||
|
||||
def _normalize_phone(raw_phone: object) -> str | None:
|
||||
phone = _sanitize_phone_token(raw_phone)
|
||||
if not phone:
|
||||
return None
|
||||
if phone.startswith("00") and len(phone) > 2:
|
||||
@@ -402,9 +414,7 @@ def _normalize_phone(raw_phone: object) -> str | None:
|
||||
|
||||
|
||||
def _normalize_phone_search_query(raw_query: str) -> str | None:
|
||||
query = raw_query.strip()
|
||||
for separator in (" ", "-", "(", ")"):
|
||||
query = query.replace(separator, "")
|
||||
query = _sanitize_phone_token(raw_query)
|
||||
if not query:
|
||||
return None
|
||||
if query.startswith("00") and len(query) > 2:
|
||||
|
||||
@@ -14,12 +14,14 @@ from models.inbox_messages import InboxMessage
|
||||
from models.profile import Profile
|
||||
from models.schedule_items import ScheduleItem
|
||||
from schemas.enums import (
|
||||
FriendshipStatus,
|
||||
InboxMessageStatus,
|
||||
InboxMessageType,
|
||||
SubscriptionPermission,
|
||||
SubscriptionStatus,
|
||||
)
|
||||
from v1.auth.gateway import SupabaseAuthGateway
|
||||
from v1.friendships.repository import SQLAlchemyFriendshipRepository
|
||||
from v1.inbox_messages.repository import InboxMessageRepository
|
||||
from v1.schedule_items.repository import ScheduleItemRepository
|
||||
from v1.schedule_items.schemas import (
|
||||
@@ -34,11 +36,13 @@ from v1.schedule_items.schemas import (
|
||||
ScheduleItemStatus,
|
||||
SubscriberInfo,
|
||||
)
|
||||
from v1.users.contact_resolver import resolve_contacts_by_user_ids
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from v1.auth.schemas import UserByIdResponse, UserByPhoneResponse
|
||||
from v1.friendships.repository import FriendshipRepository
|
||||
from v1.users.repository import UserRepository
|
||||
|
||||
logger = get_logger("v1.schedule_items.service")
|
||||
@@ -56,6 +60,7 @@ class ScheduleItemService(BaseService):
|
||||
_session: AsyncSession
|
||||
_auth_gateway: AuthByPhoneGateway
|
||||
_inbox_repository: InboxMessageRepository
|
||||
_friendship_repository: FriendshipRepository
|
||||
_user_repository: UserRepository | None
|
||||
|
||||
def __init__(
|
||||
@@ -65,6 +70,7 @@ class ScheduleItemService(BaseService):
|
||||
current_user: CurrentUser | None,
|
||||
auth_gateway: AuthByPhoneGateway | None = None,
|
||||
inbox_repository: InboxMessageRepository | None = None,
|
||||
friendship_repository: FriendshipRepository | None = None,
|
||||
user_repository: UserRepository | None = None,
|
||||
) -> None:
|
||||
super().__init__(current_user=current_user)
|
||||
@@ -74,6 +80,9 @@ class ScheduleItemService(BaseService):
|
||||
if inbox_repository is None:
|
||||
raise ValueError("inbox_repository is required")
|
||||
self._inbox_repository = inbox_repository
|
||||
self._friendship_repository = friendship_repository or (
|
||||
SQLAlchemyFriendshipRepository(session)
|
||||
)
|
||||
self._user_repository = user_repository
|
||||
|
||||
async def create(self, request: ScheduleItemCreateRequest) -> ScheduleItemResponse:
|
||||
@@ -188,26 +197,20 @@ class ScheduleItemService(BaseService):
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
logger.exception("Failed to get subscriber profiles")
|
||||
resolved_contacts = await resolve_contacts_by_user_ids(
|
||||
user_ids=subscriber_ids,
|
||||
profiles_by_id=profiles,
|
||||
auth_gateway=self._auth_gateway,
|
||||
)
|
||||
for sub in subscriptions:
|
||||
if sub.status == SubscriptionStatus.ACTIVE:
|
||||
profile = profiles.get(sub.subscriber_id)
|
||||
phone = None
|
||||
try:
|
||||
user_info = await self._auth_gateway.get_user_by_id(
|
||||
str(sub.subscriber_id)
|
||||
)
|
||||
phone = user_info.phone
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to get phone for subscriber",
|
||||
subscriber_id=str(sub.subscriber_id),
|
||||
)
|
||||
contact = resolved_contacts.get(sub.subscriber_id)
|
||||
subscribers.append(
|
||||
SubscriberInfo(
|
||||
user_id=sub.subscriber_id,
|
||||
username=profile.username if profile else None,
|
||||
avatar_url=profile.avatar_url if profile else None,
|
||||
phone=phone,
|
||||
username=contact.username if contact else None,
|
||||
avatar_url=contact.avatar_url if contact else None,
|
||||
phone=contact.phone if contact else None,
|
||||
permission=sub.permission,
|
||||
status=sub.status.value
|
||||
if hasattr(sub.status, "value")
|
||||
@@ -518,6 +521,18 @@ class ScheduleItemService(BaseService):
|
||||
target_user = await self._auth_gateway.get_user_by_phone(request.phone)
|
||||
recipient_id = UUID(target_user.id)
|
||||
|
||||
friendship = await self._friendship_repository.get_friendship_between_users(
|
||||
user_id, recipient_id
|
||||
)
|
||||
if friendship is None or friendship.status != FriendshipStatus.ACCEPTED:
|
||||
raise ApiProblemError(
|
||||
status_code=403,
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_SHARE_TARGET_NOT_FRIEND",
|
||||
detail="You can only share calendar with accepted friends",
|
||||
),
|
||||
)
|
||||
|
||||
existing = await self._repository.get_subscription(item_id, recipient_id)
|
||||
if existing:
|
||||
if existing.status == SubscriptionStatus.PENDING:
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
from uuid import UUID
|
||||
|
||||
from core.logging import get_logger
|
||||
from models.profile import Profile
|
||||
from v1.auth.schemas import UserByIdResponse
|
||||
|
||||
logger = get_logger("v1.users.contact_resolver")
|
||||
|
||||
|
||||
class AuthContactGateway(Protocol):
|
||||
async def get_user_by_id(self, user_id: str) -> UserByIdResponse:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ContactInfo:
|
||||
user_id: UUID
|
||||
username: str | None
|
||||
avatar_url: str | None
|
||||
phone: str | None
|
||||
|
||||
|
||||
async def resolve_contacts_by_user_ids(
|
||||
*,
|
||||
user_ids: list[UUID],
|
||||
profiles_by_id: dict[UUID, Profile],
|
||||
auth_gateway: AuthContactGateway,
|
||||
) -> dict[UUID, ContactInfo]:
|
||||
resolved: dict[UUID, ContactInfo] = {}
|
||||
for user_id in user_ids:
|
||||
profile = profiles_by_id.get(user_id)
|
||||
phone: str | None = None
|
||||
try:
|
||||
user_info = await auth_gateway.get_user_by_id(str(user_id))
|
||||
phone = user_info.phone
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to resolve auth phone",
|
||||
user_id=str(user_id),
|
||||
)
|
||||
|
||||
resolved[user_id] = ContactInfo(
|
||||
user_id=user_id,
|
||||
username=profile.username if profile else None,
|
||||
avatar_url=profile.avatar_url if profile else None,
|
||||
phone=phone,
|
||||
)
|
||||
return resolved
|
||||
Reference in New Issue
Block a user