mirror of
https://github.com/langgenius/dify.git
synced 2026-11-10 13:26:58 +08:00
feat: HITL IM integration (#44864)
Co-authored-by: zxhlyh <jasonapring2015@outlook.com> Co-authored-by: JzoNg <jzongcode@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
868 files changed
+145761
-6399
No files matched your search
@@ -83,7 +83,6 @@ jobs:
|
||||
- 'api/services/knowledge_fs_capability.py'
|
||||
- 'api/tests/unit_tests/dev/test_generate_knowledge_fs_contract.py'
|
||||
- 'api/tests/unit_tests/extensions/test_ext_celery.py'
|
||||
- 'api/tests/unit_tests/migrations/versions/test_2026_10_21_1500-b9d3f7a5c102_add_upgrade_reconciliation_lease.py'
|
||||
- 'api/tests/unit_tests/models/test_agent_config_entities.py'
|
||||
- 'api/tests/unit_tests/controllers/console/workspace/rbac/test_roles.py'
|
||||
- 'api/tests/unit_tests/core/agent/test_base_agent_runner.py'
|
||||
@@ -257,7 +256,6 @@ jobs:
|
||||
|
||||
test_touchpoints=(
|
||||
api/tests/unit_tests/extensions/test_ext_celery.py
|
||||
api/tests/unit_tests/migrations/versions/test_2026_10_21_1500-b9d3f7a5c102_add_upgrade_reconciliation_lease.py
|
||||
api/tests/unit_tests/models/test_agent_config_entities.py
|
||||
api/tests/unit_tests/controllers/console/datasets/test_datasets.py
|
||||
api/tests/unit_tests/controllers/console/test_wraps.py
|
||||
@@ -286,7 +284,6 @@ jobs:
|
||||
/core/workflow/
|
||||
/dev/
|
||||
/extensions/
|
||||
/migrations/
|
||||
/models/
|
||||
/repositories/
|
||||
/services/
|
||||
|
||||
@@ -13,7 +13,8 @@ jobs:
|
||||
openapi-sdk-test:
|
||||
name: OpenAPI SDK and Next.js adapters
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
timeout-minutes: 15
|
||||
# Leave time for contract checks after a cold toolchain installation.
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -21,6 +22,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup web environment
|
||||
timeout-minutes: 15
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Setup Python for in-memory Console schema export
|
||||
|
||||
@@ -43,6 +43,9 @@ INTERNAL_FILES_URL=http://host.docker.internal:5001
|
||||
# TRIGGER URL
|
||||
TRIGGER_URL=http://localhost:5001
|
||||
|
||||
# Required deployment-wide transport for Human Input IM provider events
|
||||
HUMAN_INPUT_IM_EVENT_TRANSPORT_MODE=WEBHOOK
|
||||
|
||||
# The time in seconds after the signature is rejected
|
||||
FILES_ACCESS_TIMEOUT=300
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ layers =
|
||||
# error makes this list shrink whenever a legacy dependency is removed.
|
||||
unmatched_ignore_imports_alerting = error
|
||||
ignore_imports =
|
||||
# The Dify node factory composes the Human Input runtime for workflow execution.
|
||||
core.workflow.node_factory -> services.human_input_v2.composition
|
||||
# Legacy generators delegate configured file defaults to the application owner during core migration.
|
||||
core.app.apps.base_app_generator -> services.app.file_input_defaults
|
||||
core.app.apps.advanced_chat.app_generator -> services.app.file_input_defaults
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
from enums import DeploymentEdition
|
||||
|
||||
|
||||
class IMEventTransportMode(StrEnum):
|
||||
"""Deployment-wide transport used for inbound IM provider events."""
|
||||
|
||||
WEBHOOK = "WEBHOOK"
|
||||
STREAM = "STREAM"
|
||||
|
||||
|
||||
class DeploymentConfig(BaseSettings):
|
||||
"""
|
||||
Configuration settings for application deployment
|
||||
@@ -30,6 +39,11 @@ class DeploymentConfig(BaseSettings):
|
||||
default=DeploymentEdition.COMMUNITY,
|
||||
)
|
||||
|
||||
HUMAN_INPUT_IM_EVENT_TRANSPORT_MODE: IMEventTransportMode = Field(
|
||||
IMEventTransportMode.WEBHOOK,
|
||||
description="Required deployment-wide transport for Human Input IM provider events.",
|
||||
)
|
||||
|
||||
INIT_PASSWORD: str = Field(
|
||||
description="Password required before initializing a self-hosted deployment",
|
||||
default="",
|
||||
|
||||
@@ -25,6 +25,8 @@ HUMAN_INPUT_FORM_INPUT_EXAMPLE = {
|
||||
|
||||
|
||||
class HumanInputFormSubmitPayload(BaseModel):
|
||||
"""Legacy Human Input v1 submit payload shared by existing runtime surfaces."""
|
||||
|
||||
inputs: dict[str, JsonValue] = Field(
|
||||
description=(
|
||||
"Submitted human input values keyed by output variable name. "
|
||||
|
||||
@@ -0,0 +1,705 @@
|
||||
"""Shared Human Input v2 transport contracts.
|
||||
|
||||
Request DTOs use normal Pydantic coercion and forbid unknown fields. Migration
|
||||
input is the sole compatibility exception: it ignores unknown legacy fields,
|
||||
defaults a missing version to ``"1"``, rejects any other explicit version, and
|
||||
rejects duplicate node IDs. Its transport shape mirrors the frontend migration
|
||||
adapter so the generated client can replace the temporary mock without changing
|
||||
frontend orchestration.
|
||||
Public v2 keeps its email-proof DTO. Service API uses the existing form DTO for
|
||||
both node versions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from typing import Annotated, Literal, Self
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Discriminator, Field, JsonValue, model_validator
|
||||
|
||||
from controllers.common.human_input_v2_migration import LegacyHITLv1NodeData
|
||||
from core.human_input_v2.entities import (
|
||||
ContactId,
|
||||
DebugChannel,
|
||||
HumanInputContactType,
|
||||
IMBindingId,
|
||||
IMBindingScope,
|
||||
IMIdentityBindingStatus,
|
||||
IMIdentityId,
|
||||
IMProvider,
|
||||
IMSyncRemovalReason,
|
||||
IMSyncResultType,
|
||||
IMSyncRunId,
|
||||
IMSyncRunStatus,
|
||||
OrganizationCandidateId,
|
||||
)
|
||||
from core.workflow.nodes.human_input.entities import FormInputConfig, UserActionConfig
|
||||
from core.workflow.nodes.human_input_v2.entities import HumanInputNodeData as HITLv2NodeData
|
||||
from fields.base import ResponseModel
|
||||
from fields.pagination import PaginationParamsMixin, PaginationResultMixin
|
||||
from fields.timestamp import Timestamp
|
||||
from libs.helper import EmailStr
|
||||
|
||||
|
||||
class _NoExtraModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class _RequestModel(BaseModel):
|
||||
"""Base request model that forbids unknown fields while accepting JSON-native values."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class _MigrationInputModel(BaseModel):
|
||||
"""Forward-compatible migration input that ignores fields unknown to this backend version."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
class ContactListQuery(PaginationParamsMixin, _NoExtraModel):
|
||||
"""Query params for listing contacts in the workspace directory."""
|
||||
|
||||
group: HumanInputContactType | None = Field(
|
||||
default=None,
|
||||
description="Optional contact type filter. None means all contacts.",
|
||||
)
|
||||
keyword: str | None = Field(default=None, description="Free-text search against contact name or email.")
|
||||
|
||||
|
||||
class ContactOptionsQuery(PaginationParamsMixin, _NoExtraModel):
|
||||
"""Query params for selecting contacts in workflow editors."""
|
||||
|
||||
keyword: str | None = Field(default=None, description="Free-text search against selectable contact names.")
|
||||
|
||||
|
||||
class OrganizationCandidatesQuery(PaginationParamsMixin, _NoExtraModel):
|
||||
"""Query params for searching organization member candidates."""
|
||||
|
||||
keyword: str | None = Field(default=None, description="Free-text search against candidate name or email.")
|
||||
|
||||
|
||||
ExternalContactName = Annotated[
|
||||
str,
|
||||
Field(
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
description="Display name shown in the contact directory.",
|
||||
),
|
||||
]
|
||||
|
||||
ExternalContactEmail = Annotated[
|
||||
EmailStr,
|
||||
Field(
|
||||
description="Primary email used for delivery and identity verification.",
|
||||
),
|
||||
]
|
||||
|
||||
ExternalContactAvatar = Annotated[
|
||||
str,
|
||||
Field(
|
||||
description=(
|
||||
"Optional avatar file ID. Upload the avatar image first via "
|
||||
"`POST /console/api/files/upload`, then use the returned file id here."
|
||||
" Set to empty string for resetting to default avatar."
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class ExternalContactCreateRequest(_RequestModel):
|
||||
"""Request body for creating or updating one external contact."""
|
||||
|
||||
name: ExternalContactName
|
||||
email: ExternalContactEmail
|
||||
avatar: ExternalContactAvatar | None = None
|
||||
|
||||
|
||||
class ExternalContactUpdateRequest(_RequestModel):
|
||||
"""Request body for creating or updating one external contact."""
|
||||
|
||||
name: ExternalContactName | None = None
|
||||
email: ExternalContactEmail | None = None
|
||||
avatar: ExternalContactAvatar | None = None
|
||||
|
||||
|
||||
class IMBinding(BaseModel):
|
||||
id: IMBindingId = Field(description="Unique IM binding identifier.")
|
||||
provider: IMProvider = Field(description="Provider of the IM binding.")
|
||||
scope: IMBindingScope = Field(description="Scope of the IM binding.")
|
||||
|
||||
|
||||
class HumanInputContactSummary(BaseModel):
|
||||
"""A trimmed version of `HumanInputContact` that only includes the fields needed for workflow orchestration."""
|
||||
|
||||
id: ContactId = Field(description="Unique contact identifier.")
|
||||
name: str = Field(description="Display name shown in the contact directory.")
|
||||
avatar_url: str = Field(default="", description="URL of the contact's avatar.")
|
||||
created_at: Timestamp = Field(description="Timestamp when the contact was created.")
|
||||
|
||||
|
||||
class HumanInputContact(BaseModel):
|
||||
"""One contact entity returned by contact-related APIs."""
|
||||
|
||||
id: ContactId = Field(description="Unique contact identifier.")
|
||||
type: HumanInputContactType = Field(description="Resolved contact type in the current workspace scope.")
|
||||
name: str = Field(description="Display name shown in the contact directory.")
|
||||
email: str | None = Field(default=None, description="Primary contact email if one exists.")
|
||||
avatar_url: str = Field(default="", description="URL of the contact's avatar.")
|
||||
# the `im_bindings` field is always empty for EXTERNAL contacts
|
||||
im_bindings: list[IMBinding] = Field(
|
||||
default_factory=list[IMBinding],
|
||||
description=(
|
||||
"IM bindings that are bound to this contact. "
|
||||
"Currently, only one IM binding is supported. "
|
||||
"There is at most one IM binding per IM provider."
|
||||
),
|
||||
)
|
||||
|
||||
created_at: Timestamp = Field(description="Timestamp when the contact was created.")
|
||||
|
||||
|
||||
class ContactOption(ResponseModel):
|
||||
"""Least-privilege contact projection returned to workflow editors."""
|
||||
|
||||
id: ContactId = Field(description="Unique contact identifier persisted in workflow recipient configuration.")
|
||||
type: HumanInputContactType = Field(description="Resolved contact type in the current workspace scope.")
|
||||
name: str = Field(description="Display name shown in the contact picker.")
|
||||
avatar_url: str | None = Field(default=None, description="Signed avatar URL if one is available.")
|
||||
email: str | None = Field(default=None, description="Current deliverable Email, when available.")
|
||||
|
||||
|
||||
class ExternalContactCreateResponse(ResponseModel):
|
||||
contact: HumanInputContact = Field(description="The created external contact.")
|
||||
|
||||
|
||||
class ExternalContactUpdateResponse(ResponseModel):
|
||||
contact: HumanInputContact = Field(description="The updated external contact. Fields are values after updating.")
|
||||
|
||||
|
||||
class OrganizationCandidate(ResponseModel):
|
||||
"""One organization member candidate that may become a platform contact."""
|
||||
|
||||
id: OrganizationCandidateId = Field(description="Organization candidate identifier.")
|
||||
name: str = Field(description="Display name shown in the candidate list.")
|
||||
email: str = Field(description="Primary organization email used for matching.")
|
||||
avatar_url: str | None = Field(default=None, description="Signed avatar URL if one is available.")
|
||||
|
||||
|
||||
class ListContactsResponse(PaginationResultMixin, ResponseModel):
|
||||
"""Paginated response body for contact list APIs."""
|
||||
|
||||
data: list[HumanInputContact] = Field(description="Contacts returned for the current page.")
|
||||
|
||||
|
||||
class ListContactOptionsResponse(PaginationResultMixin, ResponseModel):
|
||||
"""Paginated editor-safe contact picker response."""
|
||||
|
||||
data: list[ContactOption] = Field(description="Selectable contacts returned for the current page.")
|
||||
|
||||
|
||||
class ContactIMIdentityDetail(BaseModel):
|
||||
"""Display fields for the identity selected by an effective contact binding."""
|
||||
|
||||
id: IMIdentityId
|
||||
provider_user_id: str
|
||||
display_name: str | None
|
||||
email: str | None
|
||||
|
||||
|
||||
class ContactIMBindingDetail(BaseModel):
|
||||
id: IMBindingId
|
||||
provider: IMProvider
|
||||
scope: IMBindingScope
|
||||
identity: ContactIMIdentityDetail
|
||||
|
||||
|
||||
class GetContactResponse(ResponseModel):
|
||||
"""Response body for one contact resolved in the current workspace scope."""
|
||||
|
||||
contact: HumanInputContact = Field(description="Contact resolved as workspace, platform, or external.")
|
||||
im_binding_details: list[ContactIMBindingDetail] = Field(
|
||||
description="Effective IM bindings and their identities in the current workspace, for the contact detail view."
|
||||
)
|
||||
|
||||
|
||||
class ListOrganizationCandidatesResponse(PaginationResultMixin, ResponseModel):
|
||||
"""Paginated response body for organization candidate search."""
|
||||
|
||||
data: list[OrganizationCandidate] = Field(
|
||||
description="Organization member candidates returned for the current page."
|
||||
)
|
||||
|
||||
|
||||
class AddPlatformContactsRequest(_RequestModel):
|
||||
"""Request body for adding one or more organization members as platform contacts."""
|
||||
|
||||
candidate_ids: list[OrganizationCandidateId] = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Organization candidate identifiers to project into the current workspace as platform contacts.",
|
||||
)
|
||||
|
||||
|
||||
class AddPlatformContactsResponse(ResponseModel):
|
||||
"""Response body for adding platform contacts."""
|
||||
|
||||
data: list[HumanInputContact] = Field(description="Contacts created by the current add operation.")
|
||||
|
||||
|
||||
class RemoveContactsRequest(_RequestModel):
|
||||
"""Request body for batch-removing platform or external contacts."""
|
||||
|
||||
contact_ids: list[ContactId] = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Contact identifiers selected for removal from the contact directory surface.",
|
||||
)
|
||||
|
||||
|
||||
class RemoveContactsResponse(ResponseModel):
|
||||
"""Response body returned after batch-removing contacts."""
|
||||
|
||||
removed_contact_ids: list[ContactId] = Field(description="Contact identifiers removed by the current operation.")
|
||||
|
||||
|
||||
class IMSyncRunResultCounts(ResponseModel):
|
||||
"""Aggregate result counts for one IM sync run."""
|
||||
|
||||
added: int = Field(description="Number of entries newly matched and bound.")
|
||||
not_matched: int = Field(description="Number of entries that could not be matched.")
|
||||
failed: int = Field(description="Number of entries that failed to reconcile.")
|
||||
removed: int = Field(description="Number of entries whose prior binding was removed.")
|
||||
skipped: int = Field(description="Number of entries intentionally skipped.")
|
||||
|
||||
|
||||
class IMSyncRun(ResponseModel):
|
||||
"""One IM sync run snapshot.
|
||||
|
||||
The latest-only UI displays ``finished_at`` as the explicit sync time. The
|
||||
transport contract intentionally does not expose a ``started_by`` actor.
|
||||
"""
|
||||
|
||||
id: IMSyncRunId = Field(description="Unique sync run identifier.")
|
||||
status: IMSyncRunStatus = Field(description="Current lifecycle state of the sync run.")
|
||||
started_at: Timestamp | None = Field(
|
||||
default=None, description="Unix timestamp in seconds when the sync run started."
|
||||
)
|
||||
finished_at: Timestamp | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Unix timestamp in seconds when the sync run finished. "
|
||||
"This is the sync time displayed by the latest-only UI and is None while the run is unfinished."
|
||||
),
|
||||
)
|
||||
error_message: str | None = Field(
|
||||
default=None,
|
||||
description="Terminal error message. Present only when the sync run status is `failed`.",
|
||||
)
|
||||
result_counts: IMSyncRunResultCounts = Field(
|
||||
description="Aggregate reconciliation counts for the current run snapshot.",
|
||||
)
|
||||
provider: IMProvider = Field(description="IM provider associated with the sync run.")
|
||||
channel_id: str = Field(description="Channel identifier captured when the sync run was created.")
|
||||
channel_config_version: int = Field(
|
||||
ge=1,
|
||||
description="Channel configuration revision captured when the sync run was created.",
|
||||
)
|
||||
|
||||
|
||||
class CreateIMSyncRunResponse(ResponseModel):
|
||||
"""Response body returned after creating one sync run."""
|
||||
|
||||
run: IMSyncRun = Field(description="Newly created sync run snapshot.")
|
||||
|
||||
|
||||
class IMContactSyncErrorResponse(ResponseModel):
|
||||
"""Stable expected application failure returned by IM sync and binding routes."""
|
||||
|
||||
code: str = Field(description="Machine-readable application error code.")
|
||||
message: str = Field(description="Operator-safe error message.")
|
||||
status: int = Field(description="HTTP status code associated with this failure.")
|
||||
|
||||
|
||||
class IMDirectoryEntry(_RequestModel):
|
||||
"""Normalized provider-side account observed during an IM sync run.
|
||||
|
||||
The entry is run-scoped input to identity and binding reconciliation. It does
|
||||
not represent a stable Dify identity and must not be referenced by bindings
|
||||
or runtime authorization. Sync results may retain a snapshot for display,
|
||||
diagnostics, and audit; durable references use IMIdentity or IMBinding IDs.
|
||||
"""
|
||||
|
||||
provider_user_id: str
|
||||
display_name: str | None = None
|
||||
email: str | None = None
|
||||
|
||||
|
||||
class IMIdentitySnapshot(_RequestModel):
|
||||
"""Last known persistent IM identity state retained by a sync result."""
|
||||
|
||||
identity_id: IMIdentityId
|
||||
provider_user_id: str
|
||||
display_name: str | None = None
|
||||
email: str | None = None
|
||||
|
||||
|
||||
class IMSyncResultAdded(BaseModel):
|
||||
type: Literal[IMSyncResultType.ADDED] = IMSyncResultType.ADDED
|
||||
|
||||
contact: HumanInputContactSummary = Field(description="The contact that associated with this sync result.")
|
||||
entry: IMDirectoryEntry = Field(description="Provider directory entry observed during the current sync run.")
|
||||
|
||||
|
||||
class IMSyncResultRemoved(BaseModel):
|
||||
type: Literal[IMSyncResultType.REMOVED] = IMSyncResultType.REMOVED
|
||||
|
||||
contact: HumanInputContactSummary = Field(description="The contact that associated with this sync result.")
|
||||
last_known_identity: IMIdentitySnapshot = Field(
|
||||
description="Last known persistent IM identity state before its binding was removed."
|
||||
)
|
||||
reason: IMSyncRemovalReason = Field(description="Reason the existing IM binding was removed.")
|
||||
|
||||
|
||||
class IMSyncResultFailed(BaseModel):
|
||||
type: Literal[IMSyncResultType.FAILED] = IMSyncResultType.FAILED
|
||||
|
||||
entry: IMDirectoryEntry | None = Field(
|
||||
None, description="Provider directory entry observed before this reconciliation failure, if available."
|
||||
)
|
||||
reason: str = Field(description="Reason the binding failed to sync.")
|
||||
|
||||
|
||||
class IMSyncResultSkipped(BaseModel):
|
||||
type: Literal[IMSyncResultType.SKIPPED] = IMSyncResultType.SKIPPED
|
||||
|
||||
entry: IMDirectoryEntry | None = Field(
|
||||
None, description="Provider directory entry observed before reconciliation was skipped, if available."
|
||||
)
|
||||
contact: HumanInputContactSummary = Field(description="The contact that associated with this sync result.")
|
||||
|
||||
|
||||
class IMSyncResultNotMatched(BaseModel):
|
||||
type: Literal[IMSyncResultType.NOT_MATCHED] = IMSyncResultType.NOT_MATCHED
|
||||
|
||||
entry: IMDirectoryEntry | None = Field(
|
||||
None, description="Provider directory entry that could not be matched, if available."
|
||||
)
|
||||
|
||||
|
||||
IMSyncResult = Annotated[
|
||||
IMSyncResultAdded | IMSyncResultRemoved | IMSyncResultFailed | IMSyncResultNotMatched | IMSyncResultSkipped,
|
||||
Discriminator("type"),
|
||||
]
|
||||
|
||||
|
||||
class IMSyncResultItem(ResponseModel):
|
||||
"""One paginated reconciliation result entry for the latest sync run."""
|
||||
|
||||
# The current implementation does not return IM binding status for other IM providers.
|
||||
# According to the design, we should return IM binding status for all configured IM providers.
|
||||
# However, this version allows only one configured IM provider, so this model excludes
|
||||
# the IM binding status for other IM providers.
|
||||
|
||||
id: str = Field(description="Unique synchronization result identifier.")
|
||||
result: IMSyncResult = Field(description="Result bucket this entry belongs to.")
|
||||
|
||||
|
||||
class GetLatestIMSyncRunResponse(ResponseModel):
|
||||
"""Response body for reading the latest IM sync run summary."""
|
||||
|
||||
run: IMSyncRun = Field(description="Latest sync run summary.")
|
||||
|
||||
|
||||
class ListLatestIMSyncRunResultsQuery(PaginationParamsMixin, _NoExtraModel):
|
||||
"""Query params for reading paginated latest-run results."""
|
||||
|
||||
result: IMSyncResultType = Field(
|
||||
...,
|
||||
description=(
|
||||
"Required result bucket to paginate from the latest sync run. "
|
||||
"There is no `all` bucket or unfiltered results mode."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ListLatestIMSyncRunResultsResponse(PaginationResultMixin, ResponseModel):
|
||||
"""Page-based latest-run results without cursor state or a repeated run summary."""
|
||||
|
||||
data: list[IMSyncResultItem] = Field(
|
||||
description="Result entries returned with page, limit, and total metadata for the selected bucket."
|
||||
)
|
||||
|
||||
|
||||
class ListIMIdentitiesQuery(PaginationParamsMixin, _NoExtraModel):
|
||||
"""Query params for searching synced IM identities."""
|
||||
|
||||
keyword: str | None = Field(
|
||||
default=None,
|
||||
description="Free-text search against identity display name, email, or provider user ID.",
|
||||
)
|
||||
|
||||
|
||||
class IMIdentity(ResponseModel):
|
||||
"""One synced IM identity that may be bound or overridden."""
|
||||
|
||||
id: IMIdentityId = Field(description="Internal IM identity record identifier.")
|
||||
provider: IMProvider = Field(description="IM provider that owns this identity.")
|
||||
provider_user_id: str = Field(description="Provider-side user identifier.")
|
||||
display_name: str | None = Field(default=None, description="Display name returned by the provider.")
|
||||
email: str | None = Field(default=None, description="Email returned by the provider, if any.")
|
||||
binding_status: IMIdentityBindingStatus = Field(
|
||||
description="Whether this IM identity is currently bound to a contact."
|
||||
)
|
||||
|
||||
|
||||
class ListIMIdentitiesResponse(PaginationResultMixin, ResponseModel):
|
||||
"""Paginated response body for synced IM identity search."""
|
||||
|
||||
data: list[IMIdentity] = Field(description="IM identities returned for the current page.")
|
||||
|
||||
|
||||
class SetContactIMOverrideRequest(_RequestModel):
|
||||
"""Request body for setting one workspace-scoped IM override."""
|
||||
|
||||
identity_id: IMIdentityId = Field(description="Synced IM identity identifier selected as the workspace override.")
|
||||
|
||||
|
||||
class SetContactIMOverrideResponse(ResponseModel):
|
||||
"""Response body returned after setting one contact IM override."""
|
||||
|
||||
contact: HumanInputContact = Field(description="Contact snapshot after the override is applied.")
|
||||
|
||||
|
||||
class ResetContactIMOverrideResponse(ResponseModel):
|
||||
"""Response body returned after resetting one contact IM override."""
|
||||
|
||||
contact: HumanInputContact = Field(description="Contact snapshot after the override is cleared.")
|
||||
|
||||
|
||||
class CreateIMBindingRequest(_RequestModel):
|
||||
"""Request body for setting one workspace-scoped IM override."""
|
||||
|
||||
identity_id: IMIdentityId = Field(description="Synced IM identity identifier selected as the workspace override.")
|
||||
|
||||
|
||||
class CreateIMBindingResponse(ResponseModel):
|
||||
"""Response body returned after binding one IM identity to the workspace."""
|
||||
|
||||
contact: HumanInputContact = Field(description="Contact snapshot after the IM identity is bound.")
|
||||
|
||||
|
||||
class DeleteIMBindingResponse(ResponseModel):
|
||||
pass
|
||||
|
||||
|
||||
class MessageTemplateTestRequest(_RequestModel):
|
||||
"""Request body for sending one message-template test notification."""
|
||||
|
||||
channel: DebugChannel = Field(description="Target debug delivery channel used for the test send.")
|
||||
inputs: dict[str, JsonValue] = Field(
|
||||
default_factory=dict,
|
||||
description="Variable values used when rendering the message template preview.",
|
||||
)
|
||||
|
||||
|
||||
class MessageTemplateTestResponse(ResponseModel):
|
||||
"""Response body returned after one message-template test send."""
|
||||
|
||||
|
||||
class FormAccessRequestResponse(ResponseModel):
|
||||
"""Response body returned after creating one OTP challenge."""
|
||||
|
||||
expires_in_seconds: int = Field(description="Seconds until the current OTP challenge expires.")
|
||||
resend_after_seconds: int = Field(description="Seconds until another OTP challenge may be requested.")
|
||||
challenge_token: str = Field(description="The token used to complete the OTP challenge.")
|
||||
|
||||
|
||||
class FormDefinitionResponse(ResponseModel):
|
||||
"""Response body containing a resolved human-input form definition."""
|
||||
|
||||
form_content: str | None = Field(default=None, description="Rendered form body shown to the approver.")
|
||||
inputs: list[FormInputConfig] = Field(default_factory=list, description="Resolved form input definitions.")
|
||||
resolved_default_values: dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description="Default values after variable resolution and stringification.",
|
||||
)
|
||||
user_actions: list[UserActionConfig] = Field(
|
||||
default_factory=list,
|
||||
description="Action buttons that can complete the form.",
|
||||
)
|
||||
expiration_time: int = Field(description="Unix timestamp when the current form expires.")
|
||||
|
||||
|
||||
class BatchGetContactsQuery(_NoExtraModel):
|
||||
contact_ids: list[ContactId] = Field(..., description="List of contact IDs to retrieve.")
|
||||
|
||||
|
||||
class BatchGetContactsResponse(ResponseModel):
|
||||
data: list[HumanInputContactSummary] = Field(..., description="List of retrieved human input contacts.")
|
||||
|
||||
|
||||
class BatchGetContactOptionsQuery(_NoExtraModel):
|
||||
contact_ids: list[ContactId] = Field(..., description="Contact IDs persisted in workflow recipient configuration.")
|
||||
|
||||
|
||||
class BatchGetContactOptionsResponse(ResponseModel):
|
||||
data: list[ContactOption] = Field(..., description="Selectable contacts resolved in request order.")
|
||||
|
||||
|
||||
class HumanInputV2FormSubmitRequest(_RequestModel):
|
||||
"""Public Human Input v2 submit payload, independent from the v1 form contract."""
|
||||
|
||||
inputs: dict[str, JsonValue] = Field(description="Submitted form values keyed by output variable name.")
|
||||
action: str = Field(description="Identifier of the selected Human Input v2 action.")
|
||||
challenge_token: str | None = Field(
|
||||
default=None,
|
||||
description="OTP challenge token returned by the Human Input v2 access-request endpoint.",
|
||||
)
|
||||
otp_code: str | None = Field(
|
||||
default=None,
|
||||
description="OTP code required when the current Human Input v2 approver uses email proof.",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_complete_email_proof(self) -> Self:
|
||||
has_challenge_token = self.challenge_token is not None
|
||||
has_otp_code = self.otp_code is not None
|
||||
if has_challenge_token != has_otp_code:
|
||||
raise ValueError("challenge_token and otp_code must be provided together")
|
||||
return self
|
||||
|
||||
|
||||
class FormUploadTokenResponse(ResponseModel):
|
||||
"""Response body returned when issuing a Human Input v2 upload token."""
|
||||
|
||||
upload_token: str
|
||||
expires_at: int
|
||||
|
||||
|
||||
class FormSubmitResponse(ResponseModel):
|
||||
"""Empty response body returned after a Human Input v2 form submission."""
|
||||
|
||||
|
||||
# =================== Node migration related entities ===================
|
||||
|
||||
|
||||
class NodeDataMigrationInput(_MigrationInputModel):
|
||||
"""One legacy node submitted through the frontend migration adapter boundary."""
|
||||
|
||||
node_id: str = Field(
|
||||
..., description="The identifier of node to migrate. Used to associate between request and response"
|
||||
)
|
||||
node_data: LegacyHITLv1NodeData = Field(..., description="The legacy Human Input node data to migrate.")
|
||||
|
||||
|
||||
class NodeDataMigrationPayload(_MigrationInputModel):
|
||||
"""Complete legacy-node batch submitted for one migration attempt."""
|
||||
|
||||
nodes: list[NodeDataMigrationInput] = Field(min_length=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_unique_node_ids(self) -> Self:
|
||||
node_ids = [node.node_id for node in self.nodes]
|
||||
if len(node_ids) != len(set(node_ids)):
|
||||
raise ValueError("node_id must be unique within one migration request")
|
||||
return self
|
||||
|
||||
|
||||
class NodeDataMigrationResult(ResponseModel):
|
||||
"""One converted node returned with its frontend correlation identifier."""
|
||||
|
||||
node_id: str = Field(description="The identifier of the migrated node.")
|
||||
node_data: HITLv2NodeData = Field(description="The complete converted Human Input v2 node data.")
|
||||
|
||||
|
||||
class NodeDataMigrationResponse(ResponseModel):
|
||||
"""Successful all-node conversion response."""
|
||||
|
||||
data: list[NodeDataMigrationResult]
|
||||
|
||||
|
||||
NodeDataMigrationBlockerCode = Literal[
|
||||
"unsupported-version",
|
||||
"invalid-default-value",
|
||||
"configured-disabled-method",
|
||||
"unsupported-delivery-method",
|
||||
"invalid-email-configuration",
|
||||
"invalid-email",
|
||||
"unresolved-member",
|
||||
"conflicting-email-templates",
|
||||
"missing-recipients",
|
||||
]
|
||||
|
||||
|
||||
class NodeDataMigrationBlocker(ResponseModel):
|
||||
"""Stable node-scoped reason why the backend cannot produce lossless v2 data."""
|
||||
|
||||
node_id: str = Field(description="The identifier of the node that failed migration.")
|
||||
node_title: str = Field(description="The node title used for actionable frontend feedback.")
|
||||
code: NodeDataMigrationBlockerCode = Field(description="Machine-readable migration blocker code.")
|
||||
method_id: str | None = Field(default=None, description="Legacy delivery method related to the blocker.")
|
||||
value: str | None = Field(default=None, description="Safe legacy value related to the blocker.")
|
||||
|
||||
|
||||
class NodeDataMigrationFailureResponse(ResponseModel):
|
||||
"""Whole-batch failure response without partial converted node data."""
|
||||
|
||||
code: Literal["hitl_node_data_migration_failure"] = "hitl_node_data_migration_failure"
|
||||
message: str = Field(..., description="overall error messages")
|
||||
status: Literal[HTTPStatus.BAD_REQUEST] = HTTPStatus.BAD_REQUEST
|
||||
blockers: list[NodeDataMigrationBlocker] = Field(
|
||||
..., description="Node-scoped blockers that caused the whole batch to fail."
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AddPlatformContactsRequest",
|
||||
"AddPlatformContactsResponse",
|
||||
"BatchGetContactOptionsQuery",
|
||||
"BatchGetContactOptionsResponse",
|
||||
"ContactListQuery",
|
||||
"ContactOption",
|
||||
"ContactOptionsQuery",
|
||||
"CreateIMSyncRunResponse",
|
||||
"ExternalContactCreateRequest",
|
||||
"ExternalContactUpdateRequest",
|
||||
"FormAccessRequestResponse",
|
||||
"FormDefinitionResponse",
|
||||
"FormSubmitResponse",
|
||||
"FormUploadTokenResponse",
|
||||
"GetContactResponse",
|
||||
"GetLatestIMSyncRunResponse",
|
||||
"HumanInputContact",
|
||||
"HumanInputContactType",
|
||||
"HumanInputV2FormSubmitRequest",
|
||||
"IMContactSyncErrorResponse",
|
||||
"IMIdentity",
|
||||
"IMIdentityBindingStatus",
|
||||
"IMProvider",
|
||||
"IMSyncRemovalReason",
|
||||
"IMSyncResultItem",
|
||||
"IMSyncResultType",
|
||||
"IMSyncRun",
|
||||
"IMSyncRunResultCounts",
|
||||
"IMSyncRunStatus",
|
||||
"ListContactOptionsResponse",
|
||||
"ListContactsResponse",
|
||||
"ListIMIdentitiesQuery",
|
||||
"ListIMIdentitiesResponse",
|
||||
"ListLatestIMSyncRunResultsQuery",
|
||||
"ListLatestIMSyncRunResultsResponse",
|
||||
"ListOrganizationCandidatesResponse",
|
||||
"MessageTemplateTestRequest",
|
||||
"MessageTemplateTestResponse",
|
||||
"NodeDataMigrationFailureResponse",
|
||||
"NodeDataMigrationPayload",
|
||||
"NodeDataMigrationResponse",
|
||||
"OrganizationCandidate",
|
||||
"OrganizationCandidatesQuery",
|
||||
"RemoveContactsRequest",
|
||||
"RemoveContactsResponse",
|
||||
"ResetContactIMOverrideResponse",
|
||||
"SetContactIMOverrideRequest",
|
||||
"SetContactIMOverrideResponse",
|
||||
]
|
||||
@@ -0,0 +1,122 @@
|
||||
"""HTTP errors shared by the Human Input v2 approval transports."""
|
||||
|
||||
from typing import override
|
||||
|
||||
from controllers.common.errors import NotFoundError
|
||||
from libs.exception import BaseHTTPException
|
||||
from services.human_input_v2.email_otp_service import EmailOtpError, OtpFailure
|
||||
from services.human_input_v2.submission_service import HumanInputFormError, HumanInputFormErrorCode
|
||||
|
||||
|
||||
class HumanInputV2AlreadySubmittedError(BaseHTTPException):
|
||||
error_code = "already_submitted"
|
||||
code = 409
|
||||
|
||||
|
||||
class HumanInputV2ExpiredError(BaseHTTPException):
|
||||
error_code = "expired"
|
||||
code = 410
|
||||
|
||||
|
||||
class HumanInputV2WorkflowNotActiveError(BaseHTTPException):
|
||||
error_code = "workflow_not_active"
|
||||
code = 409
|
||||
|
||||
|
||||
class HumanInputV2InvalidInputError(BaseHTTPException):
|
||||
error_code = "invalid_input"
|
||||
code = 400
|
||||
|
||||
|
||||
class PublicHumanInputV2NotFoundError(BaseHTTPException):
|
||||
error_code = "human_input_form_not_found"
|
||||
code = 404
|
||||
|
||||
|
||||
class PublicHumanInputV2ExpiredError(BaseHTTPException):
|
||||
error_code = "human_input_form_expired"
|
||||
code = 412
|
||||
|
||||
|
||||
class PublicHumanInputV2SubmittedError(BaseHTTPException):
|
||||
error_code = "human_input_form_submitted"
|
||||
code = 412
|
||||
|
||||
|
||||
class HumanInputAccessRateLimitError(BaseHTTPException):
|
||||
error_code = "human_input_access_rate_limit_exceeded"
|
||||
code = 429
|
||||
|
||||
def __init__(self, *, retry_after: int | None) -> None:
|
||||
super().__init__(description="Unable to complete email verification.")
|
||||
self._retry_after = retry_after
|
||||
|
||||
@override
|
||||
def get_headers(self, environ=None, scope=None):
|
||||
headers = super().get_headers(environ, scope)
|
||||
if self._retry_after is not None:
|
||||
headers.append(("Retry-After", str(self._retry_after)))
|
||||
return headers
|
||||
|
||||
|
||||
class HumanInputAccessDeliveryError(BaseHTTPException):
|
||||
error_code = "human_input_access_delivery_failed"
|
||||
code = 503
|
||||
|
||||
|
||||
class HumanInputV2UnavailableError(BaseHTTPException):
|
||||
error_code = "human_input_v2_unavailable"
|
||||
code = 503
|
||||
|
||||
|
||||
class HumanInputInvalidOtpError(BaseHTTPException):
|
||||
error_code = "human_input_invalid_otp"
|
||||
code = 400
|
||||
|
||||
|
||||
class HumanInputStaleChallengeError(BaseHTTPException):
|
||||
error_code = "human_input_challenge_stale"
|
||||
code = 400
|
||||
|
||||
|
||||
class HumanInputExpiredChallengeError(BaseHTTPException):
|
||||
error_code = "human_input_challenge_expired"
|
||||
code = 400
|
||||
|
||||
|
||||
def form_http_error(error: HumanInputFormError, *, message: str) -> BaseHTTPException:
|
||||
error_type = {
|
||||
HumanInputFormErrorCode.NOT_FOUND: NotFoundError,
|
||||
HumanInputFormErrorCode.ALREADY_SUBMITTED: HumanInputV2AlreadySubmittedError,
|
||||
HumanInputFormErrorCode.EXPIRED: HumanInputV2ExpiredError,
|
||||
HumanInputFormErrorCode.WORKFLOW_NOT_ACTIVE: HumanInputV2WorkflowNotActiveError,
|
||||
HumanInputFormErrorCode.INVALID_INPUT: HumanInputV2InvalidInputError,
|
||||
}[error.code]
|
||||
return error_type(description=message)
|
||||
|
||||
|
||||
def public_form_http_error(error: HumanInputFormError) -> BaseHTTPException:
|
||||
error_type = {
|
||||
HumanInputFormErrorCode.NOT_FOUND: PublicHumanInputV2NotFoundError,
|
||||
HumanInputFormErrorCode.ALREADY_SUBMITTED: PublicHumanInputV2SubmittedError,
|
||||
HumanInputFormErrorCode.EXPIRED: PublicHumanInputV2ExpiredError,
|
||||
HumanInputFormErrorCode.WORKFLOW_NOT_ACTIVE: HumanInputV2WorkflowNotActiveError,
|
||||
HumanInputFormErrorCode.INVALID_INPUT: HumanInputV2InvalidInputError,
|
||||
}[error.code]
|
||||
return error_type(description="The form is unavailable or the submission could not be accepted.")
|
||||
|
||||
|
||||
def email_otp_http_error(error: EmailOtpError) -> BaseHTTPException:
|
||||
if error.reason == OtpFailure.RATE_LIMIT:
|
||||
return HumanInputAccessRateLimitError(retry_after=error.retry_after)
|
||||
error_type = {
|
||||
OtpFailure.NOT_FOUND: PublicHumanInputV2NotFoundError,
|
||||
OtpFailure.EXPIRED: PublicHumanInputV2ExpiredError,
|
||||
OtpFailure.SUBMITTED: PublicHumanInputV2SubmittedError,
|
||||
OtpFailure.DELIVERY: HumanInputAccessDeliveryError,
|
||||
OtpFailure.UNAVAILABLE: HumanInputV2UnavailableError,
|
||||
OtpFailure.INVALID: HumanInputInvalidOtpError,
|
||||
OtpFailure.STALE: HumanInputStaleChallengeError,
|
||||
OtpFailure.CHALLENGE_EXPIRED: HumanInputExpiredChallengeError,
|
||||
}[error.reason]
|
||||
return error_type(description="Unable to complete email verification.")
|
||||
@@ -0,0 +1,431 @@
|
||||
"""Transport compatibility and preflight parsing for HITL v1 node migration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import override
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
GetJsonSchemaHandler,
|
||||
JsonValue,
|
||||
RootModel,
|
||||
TypeAdapter,
|
||||
ValidationError,
|
||||
model_validator,
|
||||
)
|
||||
from pydantic.json_schema import JsonSchemaValue
|
||||
from pydantic_core import CoreSchema, PydanticCustomError
|
||||
|
||||
from core.workflow.human_input_adapter import (
|
||||
EmailDeliveryMethod,
|
||||
WebAppDeliveryMethod,
|
||||
adapt_human_input_node_data_for_graph,
|
||||
)
|
||||
from core.workflow.nodes.human_input.entities import HumanInputNodeDataFull
|
||||
from core.workflow.nodes.human_input_v2.migration import (
|
||||
LegacyDeliveryParseIssue,
|
||||
LegacyNodeDataPreflight,
|
||||
MigrationBlockerCode,
|
||||
)
|
||||
from graphon.entities.base_node_data import DefaultValue
|
||||
from graphon.entities.exc import DefaultValueTypeError
|
||||
|
||||
|
||||
class LegacyHITLv1NodeData(RootModel[LegacyNodeDataPreflight]):
|
||||
"""Refine historical wire aliases into the formal V1 model at HTTP ingress.
|
||||
|
||||
Only delivery/default preflight is migration-specific. All authoring fields
|
||||
and constraints come from the same model that executes V1 workflows.
|
||||
"""
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def parse_v1(cls, value: object) -> LegacyNodeDataPreflight:
|
||||
return _parse_node_data(value)
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def __get_pydantic_json_schema__(
|
||||
cls,
|
||||
_core_schema: CoreSchema,
|
||||
handler: GetJsonSchemaHandler,
|
||||
) -> JsonSchemaValue:
|
||||
return handler(TypeAdapter(HumanInputNodeDataFull).core_schema)
|
||||
|
||||
|
||||
class _TransportDeliveryMethod(BaseModel):
|
||||
"""Permissive envelope used only to classify compatibility input."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
__pydantic_extra__: dict[str, JsonValue] = Field(init=False) # pyrefly: ignore[bad-override-mutable-attribute]
|
||||
|
||||
id: str | None = None
|
||||
type: str
|
||||
enabled: bool = True
|
||||
config: JsonValue = Field(default_factory=dict)
|
||||
|
||||
|
||||
def _json_value_is_material(value: JsonValue) -> bool:
|
||||
if value is None or value is False or value == "":
|
||||
return False
|
||||
if isinstance(value, list | dict):
|
||||
return bool(value)
|
||||
return True
|
||||
|
||||
|
||||
def _disabled_email_config_is_historical_default(config: JsonValue) -> bool:
|
||||
if not isinstance(config, Mapping):
|
||||
return False
|
||||
if not set(config).issubset({"recipients", "subject", "body", "debug_mode"}):
|
||||
return False
|
||||
if "subject" in config and config["subject"] != "":
|
||||
return False
|
||||
if "body" in config and config["body"] != "":
|
||||
return False
|
||||
if "debug_mode" in config and config["debug_mode"] is not False:
|
||||
return False
|
||||
|
||||
if "recipients" not in config:
|
||||
return True
|
||||
recipients = config["recipients"]
|
||||
if not isinstance(recipients, Mapping):
|
||||
return False
|
||||
if not set(recipients).issubset({"items", "whole_workspace", "include_bound_group"}):
|
||||
return False
|
||||
if "items" in recipients and recipients["items"] != []:
|
||||
return False
|
||||
if "whole_workspace" in recipients and recipients["whole_workspace"] is not False:
|
||||
return False
|
||||
return "include_bound_group" not in recipients or recipients["include_bound_group"] is False
|
||||
|
||||
|
||||
def _disabled_method_is_configured(method: _TransportDeliveryMethod) -> bool:
|
||||
if method.__pydantic_extra__:
|
||||
return True
|
||||
if "config" not in method.model_fields_set:
|
||||
return False
|
||||
if method.type == "email":
|
||||
return not _disabled_email_config_is_historical_default(method.config)
|
||||
if method.type == "webapp":
|
||||
return not isinstance(method.config, Mapping) or bool(method.config)
|
||||
return _json_value_is_material(method.config)
|
||||
|
||||
|
||||
def _transport_method_value(method: _TransportDeliveryMethod) -> dict[str, JsonValue]:
|
||||
method_value = method.model_dump(mode="python")
|
||||
if "id" not in method.model_fields_set:
|
||||
method_value.pop("id", None)
|
||||
return method_value
|
||||
|
||||
|
||||
def _parse_default_values(
|
||||
raw_default_values: JsonValue,
|
||||
) -> tuple[list[DefaultValue] | None, tuple[LegacyDeliveryParseIssue, ...]]:
|
||||
if raw_default_values is None:
|
||||
return None, ()
|
||||
if isinstance(raw_default_values, list):
|
||||
try:
|
||||
return [DefaultValue.model_validate(raw_default) for raw_default in raw_default_values], ()
|
||||
except (ValidationError, DefaultValueTypeError):
|
||||
pass
|
||||
return (
|
||||
None,
|
||||
(
|
||||
LegacyDeliveryParseIssue(
|
||||
code=MigrationBlockerCode.INVALID_DEFAULT_VALUE,
|
||||
value="default_value",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_email_compatibility_aliases(method: _TransportDeliveryMethod) -> dict[str, JsonValue]:
|
||||
# Reuse the runtime's historical user_id/whole_workspace normalization.
|
||||
normalized = adapt_human_input_node_data_for_graph({"delivery_methods": [_transport_method_value(method)]})
|
||||
return TypeAdapter(list[dict[str, JsonValue]]).validate_python(normalized["delivery_methods"])[0]
|
||||
|
||||
|
||||
def _email_configuration_error_field(method: _TransportDeliveryMethod) -> str:
|
||||
config = method.config
|
||||
if not isinstance(config, Mapping) or not isinstance(config.get("recipients"), Mapping):
|
||||
return "recipients"
|
||||
if not isinstance(config.get("subject"), str):
|
||||
return "subject"
|
||||
if not isinstance(config.get("body"), str):
|
||||
return "body"
|
||||
return "recipients"
|
||||
|
||||
|
||||
def _parse_method(
|
||||
raw_method: JsonValue,
|
||||
method_position: int,
|
||||
) -> tuple[
|
||||
EmailDeliveryMethod | WebAppDeliveryMethod | None,
|
||||
tuple[LegacyDeliveryParseIssue, ...],
|
||||
tuple[int, ...],
|
||||
]:
|
||||
try:
|
||||
transport_method = _TransportDeliveryMethod.model_validate(raw_method)
|
||||
except ValidationError:
|
||||
return (
|
||||
None,
|
||||
(
|
||||
LegacyDeliveryParseIssue(
|
||||
code=MigrationBlockerCode.UNSUPPORTED_DELIVERY_METHOD,
|
||||
method_position=method_position,
|
||||
),
|
||||
),
|
||||
(),
|
||||
)
|
||||
|
||||
if "id" in transport_method.model_fields_set and transport_method.id is None:
|
||||
return (
|
||||
None,
|
||||
(
|
||||
LegacyDeliveryParseIssue(
|
||||
code=MigrationBlockerCode.UNSUPPORTED_DELIVERY_METHOD,
|
||||
method_position=method_position,
|
||||
value=transport_method.type,
|
||||
),
|
||||
),
|
||||
(),
|
||||
)
|
||||
|
||||
if transport_method.id is not None:
|
||||
try:
|
||||
UUID(transport_method.id)
|
||||
except ValueError:
|
||||
return (
|
||||
None,
|
||||
(
|
||||
LegacyDeliveryParseIssue(
|
||||
code=MigrationBlockerCode.UNSUPPORTED_DELIVERY_METHOD,
|
||||
method_position=method_position,
|
||||
method_id=transport_method.id,
|
||||
value=transport_method.type,
|
||||
),
|
||||
),
|
||||
(),
|
||||
)
|
||||
|
||||
if transport_method.type not in {"email", "webapp"}:
|
||||
return (
|
||||
None,
|
||||
(
|
||||
LegacyDeliveryParseIssue(
|
||||
code=MigrationBlockerCode.UNSUPPORTED_DELIVERY_METHOD,
|
||||
method_position=method_position,
|
||||
method_id=transport_method.id,
|
||||
value=transport_method.type,
|
||||
),
|
||||
),
|
||||
(),
|
||||
)
|
||||
|
||||
if not transport_method.enabled:
|
||||
if _disabled_method_is_configured(transport_method):
|
||||
return (
|
||||
None,
|
||||
(
|
||||
LegacyDeliveryParseIssue(
|
||||
code=MigrationBlockerCode.CONFIGURED_DISABLED_METHOD,
|
||||
method_position=method_position,
|
||||
method_id=transport_method.id,
|
||||
value=transport_method.type,
|
||||
),
|
||||
),
|
||||
(),
|
||||
)
|
||||
return None, (), ()
|
||||
|
||||
if transport_method.type == "webapp":
|
||||
try:
|
||||
return WebAppDeliveryMethod.model_validate(_transport_method_value(transport_method)), (), ()
|
||||
except ValidationError:
|
||||
return (
|
||||
None,
|
||||
(
|
||||
LegacyDeliveryParseIssue(
|
||||
code=MigrationBlockerCode.UNSUPPORTED_DELIVERY_METHOD,
|
||||
method_position=method_position,
|
||||
method_id=transport_method.id,
|
||||
value=transport_method.type,
|
||||
),
|
||||
),
|
||||
(),
|
||||
)
|
||||
|
||||
method_value = _normalize_email_compatibility_aliases(transport_method)
|
||||
config = method_value.get("config")
|
||||
if not isinstance(config, Mapping):
|
||||
return (
|
||||
None,
|
||||
(
|
||||
LegacyDeliveryParseIssue(
|
||||
code=MigrationBlockerCode.INVALID_EMAIL_CONFIGURATION,
|
||||
method_position=method_position,
|
||||
method_id=transport_method.id,
|
||||
value="recipients",
|
||||
),
|
||||
),
|
||||
(),
|
||||
)
|
||||
recipients = config.get("recipients")
|
||||
if not isinstance(recipients, Mapping):
|
||||
return (
|
||||
None,
|
||||
(
|
||||
LegacyDeliveryParseIssue(
|
||||
code=MigrationBlockerCode.INVALID_EMAIL_CONFIGURATION,
|
||||
method_position=method_position,
|
||||
method_id=transport_method.id,
|
||||
value="recipients",
|
||||
),
|
||||
),
|
||||
(),
|
||||
)
|
||||
raw_items = recipients.get("items", [])
|
||||
if not isinstance(raw_items, list):
|
||||
return (
|
||||
None,
|
||||
(
|
||||
LegacyDeliveryParseIssue(
|
||||
code=MigrationBlockerCode.INVALID_EMAIL_CONFIGURATION,
|
||||
method_position=method_position,
|
||||
method_id=transport_method.id,
|
||||
value="recipients",
|
||||
),
|
||||
),
|
||||
(),
|
||||
)
|
||||
|
||||
valid_items: list[JsonValue] = []
|
||||
valid_item_positions: list[int] = []
|
||||
issues: list[LegacyDeliveryParseIssue] = []
|
||||
for recipient_position, raw_recipient in enumerate(raw_items):
|
||||
if not isinstance(raw_recipient, Mapping):
|
||||
issues.append(
|
||||
LegacyDeliveryParseIssue(
|
||||
code=MigrationBlockerCode.INVALID_EMAIL_CONFIGURATION,
|
||||
method_position=method_position,
|
||||
method_id=transport_method.id,
|
||||
value="recipients",
|
||||
recipient_position=recipient_position,
|
||||
)
|
||||
)
|
||||
continue
|
||||
recipient_type = raw_recipient.get("type")
|
||||
if recipient_type == "external":
|
||||
if not isinstance(raw_recipient.get("email"), str):
|
||||
issues.append(
|
||||
LegacyDeliveryParseIssue(
|
||||
code=MigrationBlockerCode.INVALID_EMAIL,
|
||||
method_position=method_position,
|
||||
method_id=transport_method.id,
|
||||
recipient_position=recipient_position,
|
||||
)
|
||||
)
|
||||
continue
|
||||
elif recipient_type == "member":
|
||||
if not isinstance(raw_recipient.get("reference_id"), str):
|
||||
issues.append(
|
||||
LegacyDeliveryParseIssue(
|
||||
code=MigrationBlockerCode.UNRESOLVED_MEMBER,
|
||||
method_position=method_position,
|
||||
method_id=transport_method.id,
|
||||
recipient_position=recipient_position,
|
||||
)
|
||||
)
|
||||
continue
|
||||
else:
|
||||
issues.append(
|
||||
LegacyDeliveryParseIssue(
|
||||
code=MigrationBlockerCode.INVALID_EMAIL_CONFIGURATION,
|
||||
method_position=method_position,
|
||||
method_id=transport_method.id,
|
||||
value="recipients",
|
||||
recipient_position=recipient_position,
|
||||
)
|
||||
)
|
||||
continue
|
||||
valid_items.append(dict(raw_recipient))
|
||||
valid_item_positions.append(recipient_position)
|
||||
|
||||
normalized_method_value = {
|
||||
**method_value,
|
||||
"config": {
|
||||
**config,
|
||||
"recipients": {
|
||||
**recipients,
|
||||
"items": valid_items,
|
||||
},
|
||||
},
|
||||
}
|
||||
try:
|
||||
return (
|
||||
EmailDeliveryMethod.model_validate(normalized_method_value),
|
||||
tuple(issues),
|
||||
tuple(valid_item_positions),
|
||||
)
|
||||
except ValidationError:
|
||||
issues.append(
|
||||
LegacyDeliveryParseIssue(
|
||||
code=MigrationBlockerCode.INVALID_EMAIL_CONFIGURATION,
|
||||
method_position=method_position,
|
||||
method_id=transport_method.id,
|
||||
value=_email_configuration_error_field(transport_method),
|
||||
)
|
||||
)
|
||||
return None, tuple(issues), ()
|
||||
|
||||
|
||||
def _parse_node_data(raw_node_data: object) -> LegacyNodeDataPreflight:
|
||||
node_value = TypeAdapter(dict[str, JsonValue]).validate_python(raw_node_data)
|
||||
if node_value.get("version", "1") != "1":
|
||||
raise PydanticCustomError("unsupported_version", 'version must be "1"')
|
||||
default_value, default_value_issues = _parse_default_values(node_value.get("default_value"))
|
||||
raw_methods = TypeAdapter(list[JsonValue]).validate_python(node_value.get("delivery_methods", []))
|
||||
delivery_methods: list[EmailDeliveryMethod | WebAppDeliveryMethod] = []
|
||||
method_positions: list[int] = []
|
||||
recipient_positions: list[tuple[int, ...]] = []
|
||||
issues = list(default_value_issues)
|
||||
for method_position, raw_method in enumerate(raw_methods):
|
||||
method, method_issues, method_recipient_positions = _parse_method(raw_method, method_position)
|
||||
if method is not None:
|
||||
delivery_methods.append(method)
|
||||
method_positions.append(method_position)
|
||||
recipient_positions.append(method_recipient_positions)
|
||||
issues.extend(method_issues)
|
||||
|
||||
# text_input predates the shared paragraph model. Normalize only this alias;
|
||||
# the formal discriminated union owns every current input and field constraint.
|
||||
raw_inputs = node_value.get("inputs")
|
||||
if isinstance(raw_inputs, list):
|
||||
node_value["inputs"] = [
|
||||
{**input_value, "type": "paragraph"}
|
||||
if isinstance(input_value, dict) and input_value.get("type") == "text_input"
|
||||
else input_value
|
||||
for input_value in raw_inputs
|
||||
]
|
||||
canonical_node_data = HumanInputNodeDataFull.model_validate(
|
||||
{
|
||||
**node_value,
|
||||
"default_value": default_value,
|
||||
"delivery_methods": delivery_methods,
|
||||
}
|
||||
)
|
||||
return LegacyNodeDataPreflight(
|
||||
node_data=canonical_node_data,
|
||||
method_positions=tuple(method_positions),
|
||||
recipient_positions=tuple(recipient_positions),
|
||||
issues=tuple(issues),
|
||||
)
|
||||
|
||||
|
||||
def preflight_legacy_human_input_node_data(
|
||||
transport_node_data: LegacyHITLv1NodeData,
|
||||
) -> LegacyNodeDataPreflight:
|
||||
return transport_node_data.root
|
||||
@@ -75,7 +75,8 @@ SCHEMA_NAME_OVERRIDES: Mapping[str, str] = {
|
||||
"core.app.entities.task_entities.WorkflowAppBlockingResponse.Data": "WorkflowAppBlockingResponseData",
|
||||
}
|
||||
|
||||
_REF_ID_SUFFIX = re.compile(r":\d+(?=$|[\[\],])")
|
||||
# Pydantic can append metadata such as "_Strict(strict=False)" after a process-local type ID.
|
||||
_REF_ID_SUFFIX = re.compile(r":\d+(?=$|[\[\],_])")
|
||||
_MODULE_PREFIX = re.compile(r"(?:[^.[\],]+\.)+([^.[\],]+)")
|
||||
|
||||
# Normalized component name -> (component name, fully qualified source type, schema), per namespace.
|
||||
|
||||
@@ -181,6 +181,7 @@ def _register_controllers() -> tuple[ModuleType, ...]:
|
||||
workflow_app_log,
|
||||
workflow_comment,
|
||||
workflow_draft_variable,
|
||||
workflow_human_input_v2,
|
||||
workflow_node_output_inspector,
|
||||
workflow_run,
|
||||
workflow_statistic,
|
||||
@@ -232,6 +233,9 @@ def _register_controllers() -> tuple[ModuleType, ...]:
|
||||
saved_message,
|
||||
trial,
|
||||
)
|
||||
from .human_input_v2 import channel as human_input_channel
|
||||
from .human_input_v2 import contact as human_input_contact
|
||||
from .human_input_v2 import form as human_input_v2_form
|
||||
from .knowledge_fs import features as knowledge_fs_features
|
||||
from .knowledge_fs import resources as knowledge_fs_resources
|
||||
from .snippets import snippet_workflow, snippet_workflow_draft_variable
|
||||
@@ -264,6 +268,8 @@ def _register_controllers() -> tuple[ModuleType, ...]:
|
||||
extension,
|
||||
feature,
|
||||
human_input_form,
|
||||
human_input_channel,
|
||||
human_input_v2_form,
|
||||
init_validate,
|
||||
notification,
|
||||
onboarding,
|
||||
@@ -299,6 +305,7 @@ def _register_controllers() -> tuple[ModuleType, ...]:
|
||||
workflow_app_log,
|
||||
workflow_comment,
|
||||
workflow_draft_variable,
|
||||
workflow_human_input_v2,
|
||||
workflow_node_output_inspector,
|
||||
workflow_run,
|
||||
workflow_statistic,
|
||||
@@ -343,6 +350,7 @@ def _register_controllers() -> tuple[ModuleType, ...]:
|
||||
account,
|
||||
agent_providers,
|
||||
endpoint,
|
||||
human_input_contact,
|
||||
load_balancing_config,
|
||||
members,
|
||||
model_providers,
|
||||
@@ -363,6 +371,8 @@ def _register_controllers() -> tuple[ModuleType, ...]:
|
||||
extension,
|
||||
feature,
|
||||
human_input_form,
|
||||
human_input_channel,
|
||||
human_input_v2_form,
|
||||
init_validate,
|
||||
notification,
|
||||
onboarding,
|
||||
@@ -398,6 +408,7 @@ def _register_controllers() -> tuple[ModuleType, ...]:
|
||||
workflow_app_log,
|
||||
workflow_comment,
|
||||
workflow_draft_variable,
|
||||
workflow_human_input_v2,
|
||||
workflow_node_output_inspector,
|
||||
workflow_run,
|
||||
workflow_statistic,
|
||||
@@ -442,6 +453,7 @@ def _register_controllers() -> tuple[ModuleType, ...]:
|
||||
account,
|
||||
agent_providers,
|
||||
endpoint,
|
||||
human_input_contact,
|
||||
load_balancing_config,
|
||||
members,
|
||||
model_providers,
|
||||
@@ -501,7 +513,10 @@ __all__ = [
|
||||
"forgot_password",
|
||||
"generator",
|
||||
"hit_testing",
|
||||
"human_input_channel",
|
||||
"human_input_contact",
|
||||
"human_input_form",
|
||||
"human_input_v2_form",
|
||||
"init_validate",
|
||||
"installed_app",
|
||||
"knowledge_fs_features",
|
||||
@@ -551,6 +566,7 @@ __all__ = [
|
||||
"workflow_app_log",
|
||||
"workflow_comment",
|
||||
"workflow_draft_variable",
|
||||
"workflow_human_input_v2",
|
||||
"workflow_node_output_inspector",
|
||||
"workflow_run",
|
||||
"workflow_run_archive",
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Draft Human Input v2 message-template tests for the authenticated editor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
|
||||
from flask_restx import Resource
|
||||
|
||||
from controllers.common.human_input_v2_contracts import (
|
||||
MessageTemplateTestRequest,
|
||||
MessageTemplateTestResponse,
|
||||
)
|
||||
from controllers.common.rbac import PlainApp, RBACCheck
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_user,
|
||||
)
|
||||
from libs.exception import BaseHTTPException
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.model import App, AppMode
|
||||
from services.human_input_v2.message_template_test_composition import build_message_template_test_service
|
||||
from services.human_input_v2.message_template_test_service import MessageTemplateSendError, MessageTemplateTestError
|
||||
|
||||
from .wraps import get_app_model
|
||||
|
||||
register_schema_models(console_ns, MessageTemplateTestRequest)
|
||||
register_response_schema_models(console_ns, MessageTemplateTestResponse)
|
||||
|
||||
|
||||
class MessageTemplateTestHttpError(BaseHTTPException):
|
||||
code = HTTPStatus.BAD_REQUEST
|
||||
error_code = "message_template_test_invalid"
|
||||
|
||||
|
||||
class MessageTemplateSendHttpError(BaseHTTPException):
|
||||
code = HTTPStatus.BAD_GATEWAY
|
||||
error_code = "message_template_test_send_failed"
|
||||
|
||||
|
||||
def _send_test(current_user: Account, app_model: App, node_id: str) -> dict[str, object]:
|
||||
payload = MessageTemplateTestRequest.model_validate(console_ns.payload or {})
|
||||
service = build_message_template_test_service(
|
||||
tenant_id=app_model.tenant_id, account_id=current_user.id, account_email=current_user.email
|
||||
)
|
||||
try:
|
||||
service.send_test(app_id=app_model.id, node_id=node_id, channel=payload.channel, inputs=payload.inputs)
|
||||
except MessageTemplateSendError as error:
|
||||
raise MessageTemplateSendHttpError(str(error)) from None
|
||||
except MessageTemplateTestError as error:
|
||||
raise MessageTemplateTestHttpError(str(error)) from None
|
||||
return MessageTemplateTestResponse().model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/human-input/nodes/<string:node_id>/message-template/test")
|
||||
class WorkflowDraftMessageTemplateTestApi(Resource):
|
||||
@console_ns.expect(console_ns.models[MessageTemplateTestRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[MessageTemplateTestResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp()))
|
||||
@get_app_model(mode=[AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App, node_id: str):
|
||||
return _send_test(current_user, app_model, node_id)
|
||||
|
||||
|
||||
@console_ns.route(
|
||||
"/apps/<uuid:app_id>/advanced-chat/workflows/draft/human-input/nodes/<string:node_id>/message-template/test"
|
||||
)
|
||||
class AdvancedChatDraftMessageTemplateTestApi(Resource):
|
||||
@console_ns.expect(console_ns.models[MessageTemplateTestRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[MessageTemplateTestResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp()))
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App, node_id: str):
|
||||
return _send_test(current_user, app_model, node_id)
|
||||
@@ -104,6 +104,7 @@ def _triggered_from(value: str | None) -> WorkflowRunTriggeredFrom:
|
||||
class HumanInputPauseTypeResponse(ResponseModel):
|
||||
type: Literal["human_input"]
|
||||
form_id: str
|
||||
form_version: Literal["1", "2"] = "1"
|
||||
backstage_input_url: str | None = None
|
||||
|
||||
|
||||
@@ -408,6 +409,7 @@ class ConsoleWorkflowPauseDetailsApi(Resource):
|
||||
"pause_type": {
|
||||
"type": "human_input",
|
||||
"form_id": node.form_id,
|
||||
"form_version": node.form_version,
|
||||
"backstage_input_url": _build_backstage_input_url(node.form_token),
|
||||
},
|
||||
}
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,7 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class StrictModel(BaseModel):
|
||||
"""Base request model that forbids unknown fields while accepting JSON-native values."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", strict=True, hide_input_in_errors=True)
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from http import HTTPStatus
|
||||
|
||||
from flask import Response, abort
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
setup_required,
|
||||
)
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.account import TenantAccountRole
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
|
||||
def workspace_channel_api_admission[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
"""Reject Workspace Channel transport when IM is centrally managed before authentication."""
|
||||
|
||||
@wraps(view)
|
||||
def admitted(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
if FeatureService.is_im_centrally_managed():
|
||||
abort(HTTPStatus.NOT_IMPLEMENTED)
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return admitted
|
||||
|
||||
|
||||
def require_admin_or_owner[**P, R](view: Callable[P, R]) -> Callable[P, R | Response]:
|
||||
"""Apply the complete Console workspace owner/administrator guard stack."""
|
||||
|
||||
@wraps(view)
|
||||
def admin_or_owner_required(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
from libs.login import current_user
|
||||
|
||||
user = current_user._get_current_object()
|
||||
# Keep this check synchronized with the non-RBAC branch in
|
||||
# controllers.console.wraps.is_admin_or_owner_required.
|
||||
# TODO(QuantumGhost): Introduce RBAC authorization for Human Input channel management.
|
||||
if not isinstance(user, Account) or not TenantAccountRole.is_privileged_role(user.role):
|
||||
raise Forbidden()
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return setup_required(
|
||||
login_required(
|
||||
account_initialization_required(
|
||||
admin_or_owner_required,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def require_editor[**P, R](view: Callable[P, R]) -> Callable[P, R | Response]:
|
||||
"""Require an editing workspace role independently of RBAC configuration."""
|
||||
|
||||
@wraps(view)
|
||||
def editor_required(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
from libs.login import current_user
|
||||
|
||||
user = current_user._get_current_object()
|
||||
# Contact selection follows workspace roles even when RBAC is enabled.
|
||||
if not isinstance(user, Account) or not TenantAccountRole.is_editing_role(user.role):
|
||||
raise Forbidden()
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return setup_required(login_required(account_initialization_required(editor_required)))
|
||||
|
||||
|
||||
__all__ = ["require_admin_or_owner", "require_editor", "workspace_channel_api_admission"]
|
||||
@@ -0,0 +1,696 @@
|
||||
"""Canonical Console transport for Human Input Email and IM Channels."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from enum import StrEnum
|
||||
from http import HTTPStatus
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from flask import request
|
||||
from flask.typing import ResponseReturnValue
|
||||
from flask_restx import Resource
|
||||
from pydantic import Field, StringConstraints
|
||||
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
register_response_schema_models,
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import with_current_tenant_id, with_current_user
|
||||
from core.human_input_v2.entities import (
|
||||
ChannelKind,
|
||||
EmailProviderType,
|
||||
IMProvider,
|
||||
)
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
EmailProviderId,
|
||||
NormalizedEmail,
|
||||
TenantId,
|
||||
WorkspaceScope,
|
||||
)
|
||||
from fields.base import ResponseModel
|
||||
from fields.timestamp import Timestamp
|
||||
from libs.helper import EmailStr
|
||||
from models.account import Account
|
||||
from repositories.human_input_v2.email_channel import EmailChannelView, EmailConfigurationSnapshot
|
||||
from repositories.human_input_v2.email_channel.entities import ResendCandidate
|
||||
from repositories.human_input_v2.im_channel_repository import IMChannelId, IMChannelStatus
|
||||
from services.human_input_v2.credential_update import ResendChannelUpdate, Unchanged
|
||||
from services.human_input_v2.email_channel_management_composition import (
|
||||
build_human_input_email_channel_management_service,
|
||||
)
|
||||
from services.human_input_v2.errors import (
|
||||
ChannelAlreadyConfiguredError,
|
||||
ChannelNotFoundError,
|
||||
ChannelProviderError,
|
||||
ProviderConfigurationUpdatedError,
|
||||
ReplacementRequiredError,
|
||||
)
|
||||
from services.human_input_v2.im_channel_service import IMChannelView, WorkspaceIMChannelService
|
||||
from services.human_input_v2.im_channel_service_composition import (
|
||||
build_workspace_im_channel_service,
|
||||
)
|
||||
|
||||
from ._common import StrictModel
|
||||
from ._decorators import require_admin_or_owner, workspace_channel_api_admission
|
||||
from .config_version import (
|
||||
InvalidConfigVersionError,
|
||||
decode_email_config_version,
|
||||
decode_im_config_version,
|
||||
encode_email_config_version,
|
||||
encode_im_config_version,
|
||||
)
|
||||
from .errors import (
|
||||
ChannelAlreadyConfiguredHttpError,
|
||||
ChannelNotFoundHttpError,
|
||||
ChannelProviderBadRequestHttpError,
|
||||
ChannelProviderConfigurationUpdatedHttpError,
|
||||
ChannelReplacementRequiredHttpError,
|
||||
)
|
||||
from .provider_updates import EmailProviderCredentialsUpdateInput, IMProviderCredentialsUpdateInput, UnchangedInput
|
||||
from .provider_views import (
|
||||
IMProviderCredentialsResponse,
|
||||
Redacted,
|
||||
ResendCredentialsResponse,
|
||||
im_credentials_response,
|
||||
)
|
||||
from .providers import EmailProviderCredentials, IMProviderCredentialsInput
|
||||
|
||||
type ChannelId = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
||||
type ConfigVersion = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
||||
|
||||
|
||||
class WorkspaceChannelResource(Resource):
|
||||
"""Apply the edition gate outside every canonical handler decorator stack."""
|
||||
|
||||
method_decorators = [workspace_channel_api_admission]
|
||||
|
||||
|
||||
class ChannelStatus(StrEnum):
|
||||
CONFIGURED = "configured"
|
||||
CONNECTED = "connected"
|
||||
INVALID_CREDENTIALS = "invalid_credentials"
|
||||
CONNECTION_FAILURE = "connection_failure"
|
||||
|
||||
|
||||
class ConnectionMode(StrEnum):
|
||||
CUSTOM_APP = "custom_app"
|
||||
|
||||
|
||||
class ChannelTestResponse(ResponseModel):
|
||||
status: ChannelStatus
|
||||
status_description: str
|
||||
|
||||
|
||||
class ChannelDeleteResponse(ResponseModel):
|
||||
channel_id: ChannelId
|
||||
|
||||
|
||||
class ChannelConflictResponse(ResponseModel):
|
||||
code: Literal["replacement_required", "provider_configuration_updated"]
|
||||
message: str
|
||||
status: Literal[HTTPStatus.CONFLICT] = HTTPStatus.CONFLICT
|
||||
|
||||
|
||||
class ChannelProvider(ResponseModel):
|
||||
provider: EmailProviderType | IMProvider
|
||||
connection_mode: Literal[ConnectionMode.CUSTOM_APP] = ConnectionMode.CUSTOM_APP
|
||||
|
||||
|
||||
class ListChannelProvidersResponse(ResponseModel):
|
||||
email_providers: Sequence[ChannelProvider]
|
||||
im_providers: Sequence[ChannelProvider]
|
||||
|
||||
|
||||
class ChannelSummary(ResponseModel):
|
||||
id: ChannelId
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
kind: Literal[ChannelKind.EMAIL, ChannelKind.IM]
|
||||
provider: EmailProviderType | IMProvider = Field(
|
||||
description="The provider of the Channel. The concrete provider type depends on the `kind` field."
|
||||
)
|
||||
status: ChannelStatus
|
||||
status_description: str = Field(
|
||||
description="Human-readable status description. Empty when the status is `configured` or `connected`."
|
||||
)
|
||||
display_identifier: str = Field(description="The display identifier of the Channel.")
|
||||
webhook_url: str | None = Field(
|
||||
description=(
|
||||
"Webhook URL to configure on the provider side. None when webhook configuration is not required "
|
||||
"or the provider does not support webhooks."
|
||||
)
|
||||
)
|
||||
config_version: ConfigVersion = Field(
|
||||
description="The current opaque configuration version used for optimistic concurrency control."
|
||||
)
|
||||
|
||||
|
||||
class ListChannelsResponse(ResponseModel):
|
||||
channels: Sequence[ChannelSummary]
|
||||
|
||||
|
||||
type _SenderName = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=255)]
|
||||
|
||||
|
||||
class _EmailChannelCandidatePayload(StrictModel):
|
||||
sender_email: EmailStr
|
||||
sender_name: _SenderName
|
||||
credentials: EmailProviderCredentials
|
||||
|
||||
def to_owner_candidate(self) -> ResendCandidate:
|
||||
return ResendCandidate(
|
||||
sender_email=NormalizedEmail(self.sender_email),
|
||||
sender_name=self.sender_name,
|
||||
credentials=self.credentials.to_owner_credentials(),
|
||||
)
|
||||
|
||||
|
||||
class EmailChannelTestPayload(_EmailChannelCandidatePayload):
|
||||
pass
|
||||
|
||||
|
||||
class EmailChannelCreatePayload(_EmailChannelCandidatePayload):
|
||||
pass
|
||||
|
||||
|
||||
class EmailChannelMutationResponse(ResponseModel):
|
||||
summary: ChannelSummary
|
||||
|
||||
|
||||
class EmailChannelDetailResponse(ResponseModel):
|
||||
summary: ChannelSummary
|
||||
sender_name: str
|
||||
sender_email: str
|
||||
credentials: ResendCredentialsResponse
|
||||
|
||||
|
||||
class EmailChannelUpdatePayload(StrictModel):
|
||||
sender_email: EmailStr | UnchangedInput
|
||||
sender_name: _SenderName | UnchangedInput
|
||||
credentials: EmailProviderCredentialsUpdateInput
|
||||
expected_config_version: ConfigVersion
|
||||
|
||||
def to_owner_update(self) -> ResendChannelUpdate:
|
||||
return ResendChannelUpdate(
|
||||
sender_email=(
|
||||
Unchanged.VALUE if isinstance(self.sender_email, UnchangedInput) else NormalizedEmail(self.sender_email)
|
||||
),
|
||||
sender_name=Unchanged.VALUE if isinstance(self.sender_name, UnchangedInput) else self.sender_name,
|
||||
credentials=self.credentials.to_owner_update(),
|
||||
)
|
||||
|
||||
|
||||
class ChannelDeleteQuery(StrictModel):
|
||||
expected_config_version: ConfigVersion
|
||||
|
||||
|
||||
class IMChannelTestPayload(StrictModel):
|
||||
credentials: IMProviderCredentialsInput
|
||||
|
||||
|
||||
class IMChannelCreatePayload(StrictModel):
|
||||
credentials: IMProviderCredentialsInput
|
||||
|
||||
|
||||
class IMChannelMutationResponse(ResponseModel):
|
||||
summary: ChannelSummary
|
||||
|
||||
|
||||
class IMChannelDetailResponse(ResponseModel):
|
||||
summary: ChannelSummary
|
||||
credentials: IMProviderCredentialsResponse
|
||||
|
||||
|
||||
class IMChannelUpdatePayload(StrictModel):
|
||||
credentials: IMProviderCredentialsUpdateInput
|
||||
expected_config_version: ConfigVersion
|
||||
|
||||
|
||||
class IMChannelReplacementPayload(StrictModel):
|
||||
credentials: IMProviderCredentialsInput
|
||||
expected_config_version: ConfigVersion
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
ChannelDeleteQuery,
|
||||
EmailChannelTestPayload,
|
||||
EmailChannelCreatePayload,
|
||||
EmailChannelUpdatePayload,
|
||||
IMChannelTestPayload,
|
||||
IMChannelCreatePayload,
|
||||
IMChannelUpdatePayload,
|
||||
IMChannelReplacementPayload,
|
||||
)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
ChannelTestResponse,
|
||||
ChannelDeleteResponse,
|
||||
ChannelConflictResponse,
|
||||
ChannelProvider,
|
||||
ListChannelProvidersResponse,
|
||||
ChannelSummary,
|
||||
ListChannelsResponse,
|
||||
EmailChannelMutationResponse,
|
||||
EmailChannelDetailResponse,
|
||||
IMChannelMutationResponse,
|
||||
IMChannelDetailResponse,
|
||||
)
|
||||
|
||||
|
||||
def _workspace_scope(tenant_id: str) -> WorkspaceScope:
|
||||
return WorkspaceScope(id=TenantId(tenant_id))
|
||||
|
||||
|
||||
def _actor_id(account: Account) -> AccountId:
|
||||
return AccountId(account.id)
|
||||
|
||||
|
||||
def _channel_provider_response(provider: EmailProviderType | IMProvider) -> ChannelProvider:
|
||||
return ChannelProvider(provider=provider)
|
||||
|
||||
|
||||
def _email_channel_summary_response(view: EmailChannelView) -> ChannelSummary:
|
||||
return ChannelSummary(
|
||||
id=view.id,
|
||||
created_at=view.created_at,
|
||||
updated_at=view.updated_at,
|
||||
kind=ChannelKind.EMAIL,
|
||||
provider=view.provider,
|
||||
status=ChannelStatus.CONFIGURED,
|
||||
status_description="",
|
||||
display_identifier=" ".join(part for part in (view.sender_name, view.sender_email) if part),
|
||||
webhook_url=None,
|
||||
config_version=encode_email_config_version(view.revision),
|
||||
)
|
||||
|
||||
|
||||
def _im_channel_summary_response(view: IMChannelView) -> ChannelSummary:
|
||||
status, status_description = _im_channel_status(view)
|
||||
return ChannelSummary(
|
||||
id=view.id,
|
||||
created_at=view.created_at,
|
||||
updated_at=view.updated_at,
|
||||
kind=ChannelKind.IM,
|
||||
provider=view.provider,
|
||||
status=status,
|
||||
status_description=status_description,
|
||||
display_identifier=view.app_identifier,
|
||||
webhook_url=view.webhook_url,
|
||||
config_version=encode_im_config_version(view.id, view.config_version),
|
||||
)
|
||||
|
||||
|
||||
def _im_channel_status(view: IMChannelView) -> tuple[ChannelStatus, str]:
|
||||
if view.status is IMChannelStatus.CONNECTED:
|
||||
return ChannelStatus.CONNECTED, ""
|
||||
if view.status is IMChannelStatus.INVALID_CREDENTIALS:
|
||||
return (
|
||||
ChannelStatus.INVALID_CREDENTIALS,
|
||||
view.status_reason or "The configured credentials are no longer accepted.",
|
||||
)
|
||||
return (
|
||||
ChannelStatus.CONNECTION_FAILURE,
|
||||
view.status_reason or "The configured provider connection is unavailable.",
|
||||
)
|
||||
|
||||
|
||||
def _email_revision(value: str, channel_id: EmailProviderId) -> EmailConfigurationSnapshot:
|
||||
try:
|
||||
return decode_email_config_version(value, channel_id)
|
||||
except InvalidConfigVersionError as error:
|
||||
raise ChannelProviderConfigurationUpdatedHttpError() from error
|
||||
|
||||
|
||||
def _im_config_version(value: str, channel_id: IMChannelId) -> int:
|
||||
try:
|
||||
return decode_im_config_version(value, channel_id)
|
||||
except InvalidConfigVersionError as error:
|
||||
raise ChannelProviderConfigurationUpdatedHttpError() from error
|
||||
|
||||
|
||||
def _workspace_im_channel_service(tenant_id: str, account: Account) -> WorkspaceIMChannelService:
|
||||
return build_workspace_im_channel_service(TenantId(tenant_id), _actor_id(account))
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/channel-providers")
|
||||
class ListChannelProvidersApi(WorkspaceChannelResource):
|
||||
@console_ns.response(200, "Success", console_ns.models[ListChannelProvidersResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account) -> ResponseReturnValue:
|
||||
email_providers = build_human_input_email_channel_management_service().available_providers()
|
||||
im_providers = _workspace_im_channel_service(tenant_id, current_user).available_providers()
|
||||
return ListChannelProvidersResponse(
|
||||
email_providers=[_channel_provider_response(provider) for provider in email_providers],
|
||||
im_providers=[_channel_provider_response(provider) for provider in im_providers],
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/channels")
|
||||
class ListChannelsApi(WorkspaceChannelResource):
|
||||
@console_ns.response(200, "Success", console_ns.models[ListChannelsResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account) -> ResponseReturnValue:
|
||||
workspace_scope = _workspace_scope(tenant_id)
|
||||
email = build_human_input_email_channel_management_service().get_current(workspace_scope)
|
||||
im = _workspace_im_channel_service(tenant_id, current_user).get_current()
|
||||
channels: list[ChannelSummary] = []
|
||||
if email is not None:
|
||||
channels.append(_email_channel_summary_response(email))
|
||||
if im is not None:
|
||||
channels.append(_im_channel_summary_response(im))
|
||||
return ListChannelsResponse(channels=channels).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/channels/email/test")
|
||||
class EmailChannelTestApi(WorkspaceChannelResource):
|
||||
@console_ns.expect(console_ns.models[EmailChannelTestPayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[ChannelTestResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str) -> ResponseReturnValue:
|
||||
request_body = EmailChannelTestPayload.model_validate(console_ns.payload or {})
|
||||
candidate = request_body.to_owner_candidate()
|
||||
try:
|
||||
build_human_input_email_channel_management_service().test(
|
||||
_workspace_scope(tenant_id),
|
||||
candidate,
|
||||
)
|
||||
except ChannelProviderError as error:
|
||||
return ChannelTestResponse(
|
||||
status=ChannelStatus(error.kind.value),
|
||||
status_description=error.status_description,
|
||||
).model_dump(mode="json")
|
||||
return ChannelTestResponse(
|
||||
status=ChannelStatus.CONNECTED,
|
||||
status_description="",
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/channels/email/<uuid:channel_id>/test")
|
||||
class EmailChannelSavedTestApi(WorkspaceChannelResource):
|
||||
@console_ns.expect(console_ns.models[EmailChannelUpdatePayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[ChannelTestResponse.__name__])
|
||||
@console_ns.response(409, "Configuration conflict", console_ns.models[ChannelConflictResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, channel_id: UUID) -> ResponseReturnValue:
|
||||
request_body = EmailChannelUpdatePayload.model_validate(console_ns.payload or {})
|
||||
addressed_id = EmailProviderId(str(channel_id))
|
||||
try:
|
||||
build_human_input_email_channel_management_service().test_saved(
|
||||
_workspace_scope(tenant_id),
|
||||
addressed_id,
|
||||
_email_revision(request_body.expected_config_version, addressed_id),
|
||||
request_body.to_owner_update(),
|
||||
)
|
||||
except ChannelNotFoundError as error:
|
||||
raise ChannelNotFoundHttpError() from error
|
||||
except ProviderConfigurationUpdatedError as error:
|
||||
raise ChannelProviderConfigurationUpdatedHttpError() from error
|
||||
except ChannelProviderError as error:
|
||||
return ChannelTestResponse(
|
||||
status=ChannelStatus(error.kind.value),
|
||||
status_description=error.status_description,
|
||||
).model_dump(mode="json")
|
||||
return ChannelTestResponse(status=ChannelStatus.CONNECTED, status_description="").model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/channels/email")
|
||||
class EmailChannelCreateApi(WorkspaceChannelResource):
|
||||
@console_ns.expect(console_ns.models[EmailChannelCreatePayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[EmailChannelMutationResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account) -> ResponseReturnValue:
|
||||
request_body = EmailChannelCreatePayload.model_validate(console_ns.payload or {})
|
||||
try:
|
||||
snapshot = build_human_input_email_channel_management_service().create(
|
||||
_workspace_scope(tenant_id),
|
||||
_actor_id(current_user),
|
||||
request_body.to_owner_candidate(),
|
||||
)
|
||||
except ChannelAlreadyConfiguredError as error:
|
||||
raise ChannelAlreadyConfiguredHttpError() from error
|
||||
except ChannelProviderError as error:
|
||||
raise ChannelProviderBadRequestHttpError(error.status_description) from error
|
||||
return EmailChannelMutationResponse(summary=_email_channel_summary_response(snapshot)).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/channels/email/<uuid:channel_id>")
|
||||
class EmailChannelApi(WorkspaceChannelResource):
|
||||
@console_ns.response(200, "Success", console_ns.models[EmailChannelDetailResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, channel_id: UUID) -> ResponseReturnValue:
|
||||
try:
|
||||
snapshot = build_human_input_email_channel_management_service().get(
|
||||
_workspace_scope(tenant_id),
|
||||
EmailProviderId(str(channel_id)),
|
||||
)
|
||||
except ChannelNotFoundError as error:
|
||||
raise ChannelNotFoundHttpError() from error
|
||||
return EmailChannelDetailResponse(
|
||||
summary=_email_channel_summary_response(snapshot),
|
||||
sender_name=snapshot.sender_name,
|
||||
sender_email=snapshot.sender_email,
|
||||
credentials=ResendCredentialsResponse(provider=EmailProviderType.RESEND, api_key=Redacted()),
|
||||
).model_dump(mode="json")
|
||||
|
||||
@console_ns.expect(console_ns.models[EmailChannelUpdatePayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[EmailChannelMutationResponse.__name__])
|
||||
@console_ns.response(409, "Configuration conflict", console_ns.models[ChannelConflictResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def put(self, tenant_id: str, current_user: Account, channel_id: UUID) -> ResponseReturnValue:
|
||||
request_body = EmailChannelUpdatePayload.model_validate(console_ns.payload or {})
|
||||
addressed_id = EmailProviderId(str(channel_id))
|
||||
try:
|
||||
snapshot = build_human_input_email_channel_management_service().update(
|
||||
_workspace_scope(tenant_id),
|
||||
addressed_id,
|
||||
_email_revision(request_body.expected_config_version, addressed_id),
|
||||
_actor_id(current_user),
|
||||
request_body.to_owner_update(),
|
||||
)
|
||||
except ChannelNotFoundError as error:
|
||||
raise ChannelNotFoundHttpError() from error
|
||||
except ProviderConfigurationUpdatedError as error:
|
||||
raise ChannelProviderConfigurationUpdatedHttpError() from error
|
||||
except ChannelProviderError as error:
|
||||
raise ChannelProviderBadRequestHttpError(error.status_description) from error
|
||||
return EmailChannelMutationResponse(summary=_email_channel_summary_response(snapshot)).model_dump(mode="json")
|
||||
|
||||
@console_ns.doc(params=query_params_from_model(ChannelDeleteQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[ChannelDeleteResponse.__name__])
|
||||
@console_ns.response(409, "Configuration conflict", console_ns.models[ChannelConflictResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@with_current_tenant_id
|
||||
def delete(self, tenant_id: str, channel_id: UUID) -> ResponseReturnValue:
|
||||
query = ChannelDeleteQuery.model_validate(request.args.to_dict(flat=True))
|
||||
addressed_id = EmailProviderId(str(channel_id))
|
||||
try:
|
||||
deleted_id = build_human_input_email_channel_management_service().delete(
|
||||
_workspace_scope(tenant_id),
|
||||
addressed_id,
|
||||
_email_revision(query.expected_config_version, addressed_id),
|
||||
)
|
||||
except ChannelNotFoundError as error:
|
||||
raise ChannelNotFoundHttpError() from error
|
||||
except ProviderConfigurationUpdatedError as error:
|
||||
raise ChannelProviderConfigurationUpdatedHttpError() from error
|
||||
return ChannelDeleteResponse(channel_id=deleted_id).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/channels/im/test")
|
||||
class IMChannelTestApi(WorkspaceChannelResource):
|
||||
@console_ns.expect(console_ns.models[IMChannelTestPayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[ChannelTestResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account) -> ResponseReturnValue:
|
||||
request_body = IMChannelTestPayload.model_validate(console_ns.payload or {})
|
||||
try:
|
||||
_workspace_im_channel_service(tenant_id, current_user).test(request_body.credentials.to_owner_credentials())
|
||||
except ChannelProviderError as error:
|
||||
return ChannelTestResponse(
|
||||
status=ChannelStatus(error.kind.value),
|
||||
status_description=error.status_description,
|
||||
).model_dump(mode="json")
|
||||
return ChannelTestResponse(
|
||||
status=ChannelStatus.CONNECTED,
|
||||
status_description="",
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/channels/im/<uuid:channel_id>/test")
|
||||
class IMChannelSavedTestApi(WorkspaceChannelResource):
|
||||
@console_ns.expect(console_ns.models[IMChannelUpdatePayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[ChannelTestResponse.__name__])
|
||||
@console_ns.response(400, "The test provider must match the saved IM Channel.")
|
||||
@console_ns.response(409, "Configuration conflict", console_ns.models[ChannelConflictResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, channel_id: UUID) -> ResponseReturnValue:
|
||||
request_body = IMChannelUpdatePayload.model_validate(console_ns.payload or {})
|
||||
addressed_id = IMChannelId(str(channel_id))
|
||||
try:
|
||||
_workspace_im_channel_service(tenant_id, current_user).test_saved(
|
||||
addressed_id,
|
||||
_im_config_version(request_body.expected_config_version, addressed_id),
|
||||
request_body.credentials.to_owner_update(),
|
||||
)
|
||||
except ChannelNotFoundError as error:
|
||||
raise ChannelNotFoundHttpError() from error
|
||||
except ProviderConfigurationUpdatedError as error:
|
||||
raise ChannelProviderConfigurationUpdatedHttpError() from error
|
||||
except ReplacementRequiredError as error:
|
||||
raise ChannelProviderBadRequestHttpError("The test provider must match the saved IM Channel.") from error
|
||||
except ChannelProviderError as error:
|
||||
return ChannelTestResponse(
|
||||
status=ChannelStatus(error.kind.value),
|
||||
status_description=error.status_description,
|
||||
).model_dump(mode="json")
|
||||
return ChannelTestResponse(status=ChannelStatus.CONNECTED, status_description="").model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/channels/im")
|
||||
class IMChannelCreateApi(WorkspaceChannelResource):
|
||||
@console_ns.expect(console_ns.models[IMChannelCreatePayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[IMChannelMutationResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account) -> ResponseReturnValue:
|
||||
request_body = IMChannelCreatePayload.model_validate(console_ns.payload or {})
|
||||
try:
|
||||
snapshot = _workspace_im_channel_service(tenant_id, current_user).create(
|
||||
request_body.credentials.to_owner_credentials()
|
||||
)
|
||||
except ChannelAlreadyConfiguredError as error:
|
||||
raise ChannelAlreadyConfiguredHttpError() from error
|
||||
except ChannelProviderError as error:
|
||||
raise ChannelProviderBadRequestHttpError(error.status_description) from error
|
||||
return IMChannelMutationResponse(summary=_im_channel_summary_response(snapshot)).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/channels/im/<uuid:channel_id>")
|
||||
class IMChannelApi(WorkspaceChannelResource):
|
||||
@console_ns.response(200, "Success", console_ns.models[IMChannelDetailResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, channel_id: UUID) -> ResponseReturnValue:
|
||||
try:
|
||||
detail = _workspace_im_channel_service(tenant_id, current_user).get_detail(IMChannelId(str(channel_id)))
|
||||
except ChannelNotFoundError as error:
|
||||
raise ChannelNotFoundHttpError() from error
|
||||
return IMChannelDetailResponse(
|
||||
summary=_im_channel_summary_response(detail.summary),
|
||||
credentials=im_credentials_response(detail.credentials),
|
||||
).model_dump(mode="json")
|
||||
|
||||
@console_ns.expect(console_ns.models[IMChannelUpdatePayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[IMChannelMutationResponse.__name__])
|
||||
@console_ns.response(409, "Configuration conflict", console_ns.models[ChannelConflictResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def put(self, tenant_id: str, current_user: Account, channel_id: UUID) -> ResponseReturnValue:
|
||||
request_body = IMChannelUpdatePayload.model_validate(console_ns.payload or {})
|
||||
addressed_id = IMChannelId(str(channel_id))
|
||||
try:
|
||||
snapshot = _workspace_im_channel_service(tenant_id, current_user).update(
|
||||
addressed_id,
|
||||
_im_config_version(request_body.expected_config_version, addressed_id),
|
||||
request_body.credentials.to_owner_update(),
|
||||
)
|
||||
except ChannelNotFoundError as error:
|
||||
raise ChannelNotFoundHttpError() from error
|
||||
except ReplacementRequiredError as error:
|
||||
raise ChannelReplacementRequiredHttpError() from error
|
||||
except ProviderConfigurationUpdatedError as error:
|
||||
raise ChannelProviderConfigurationUpdatedHttpError() from error
|
||||
except ChannelProviderError as error:
|
||||
raise ChannelProviderBadRequestHttpError(error.status_description) from error
|
||||
return IMChannelMutationResponse(summary=_im_channel_summary_response(snapshot)).model_dump(mode="json")
|
||||
|
||||
@console_ns.doc(params=query_params_from_model(ChannelDeleteQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[ChannelDeleteResponse.__name__])
|
||||
@console_ns.response(409, "Configuration conflict", console_ns.models[ChannelConflictResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def delete(self, tenant_id: str, current_user: Account, channel_id: UUID) -> ResponseReturnValue:
|
||||
query = ChannelDeleteQuery.model_validate(request.args.to_dict(flat=True))
|
||||
addressed_id = IMChannelId(str(channel_id))
|
||||
try:
|
||||
deleted_id = _workspace_im_channel_service(tenant_id, current_user).delete(
|
||||
addressed_id,
|
||||
_im_config_version(query.expected_config_version, addressed_id),
|
||||
)
|
||||
except ChannelNotFoundError as error:
|
||||
raise ChannelNotFoundHttpError() from error
|
||||
except ProviderConfigurationUpdatedError as error:
|
||||
raise ChannelProviderConfigurationUpdatedHttpError() from error
|
||||
return ChannelDeleteResponse(channel_id=deleted_id).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/channels/im/<uuid:channel_id>/replacement")
|
||||
class IMChannelReplacementApi(WorkspaceChannelResource):
|
||||
@console_ns.expect(console_ns.models[IMChannelReplacementPayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[IMChannelMutationResponse.__name__])
|
||||
@console_ns.response(409, "Configuration conflict", console_ns.models[ChannelConflictResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, channel_id: UUID) -> ResponseReturnValue:
|
||||
request_body = IMChannelReplacementPayload.model_validate(console_ns.payload or {})
|
||||
addressed_id = IMChannelId(str(channel_id))
|
||||
try:
|
||||
snapshot = _workspace_im_channel_service(tenant_id, current_user).replace(
|
||||
addressed_id,
|
||||
_im_config_version(request_body.expected_config_version, addressed_id),
|
||||
request_body.credentials.to_owner_credentials(),
|
||||
)
|
||||
except ChannelNotFoundError as error:
|
||||
raise ChannelNotFoundHttpError() from error
|
||||
except ProviderConfigurationUpdatedError as error:
|
||||
raise ChannelProviderConfigurationUpdatedHttpError() from error
|
||||
except ChannelProviderError as error:
|
||||
raise ChannelProviderBadRequestHttpError(error.status_description) from error
|
||||
return IMChannelMutationResponse(summary=_im_channel_summary_response(snapshot)).model_dump(mode="json")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ChannelConflictResponse",
|
||||
"ChannelDeleteQuery",
|
||||
"ChannelDeleteResponse",
|
||||
"ChannelProvider",
|
||||
"ChannelSummary",
|
||||
"ChannelTestResponse",
|
||||
"EmailChannelCreatePayload",
|
||||
"EmailChannelDetailResponse",
|
||||
"EmailChannelTestPayload",
|
||||
"EmailChannelUpdatePayload",
|
||||
"IMChannelCreatePayload",
|
||||
"IMChannelDetailResponse",
|
||||
"IMChannelReplacementPayload",
|
||||
"IMChannelTestPayload",
|
||||
"IMChannelUpdatePayload",
|
||||
"ListChannelProvidersResponse",
|
||||
"ListChannelsResponse",
|
||||
]
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Transport-only encoding for owner-native Channel revision tokens."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from typing import Literal
|
||||
|
||||
from core.human_input_v2.entities import ChannelKind
|
||||
from core.human_input_v2.shared import EmailProviderId
|
||||
from repositories.human_input_v2.email_channel import EmailConfigurationSnapshot
|
||||
from repositories.human_input_v2.im_channel_repository import IMChannelId
|
||||
|
||||
_FORMAT_VERSION = 1
|
||||
type _ConfigVersionChannel = Literal[
|
||||
ChannelKind.EMAIL,
|
||||
ChannelKind.IM,
|
||||
]
|
||||
|
||||
|
||||
class InvalidConfigVersionError(ValueError):
|
||||
"""The opaque transport value does not name the addressed owner revision."""
|
||||
|
||||
|
||||
def encode_email_config_version(snapshot: EmailConfigurationSnapshot) -> str:
|
||||
return _encode(ChannelKind.EMAIL, snapshot.configuration_id, snapshot.config_version)
|
||||
|
||||
|
||||
def decode_email_config_version(
|
||||
value: str,
|
||||
channel_id: EmailProviderId,
|
||||
) -> EmailConfigurationSnapshot:
|
||||
revision = _decode(value, ChannelKind.EMAIL, channel_id)
|
||||
return EmailConfigurationSnapshot(channel_id, revision)
|
||||
|
||||
|
||||
def encode_im_config_version(channel_id: IMChannelId, config_version: int) -> str:
|
||||
return _encode(ChannelKind.IM, channel_id, config_version)
|
||||
|
||||
|
||||
def decode_im_config_version(
|
||||
value: str,
|
||||
channel_id: IMChannelId,
|
||||
) -> int:
|
||||
return _decode(value, ChannelKind.IM, channel_id)
|
||||
|
||||
|
||||
def _encode(
|
||||
kind: _ConfigVersionChannel,
|
||||
channel_id: EmailProviderId | IMChannelId,
|
||||
config_version: int,
|
||||
) -> str:
|
||||
serialized = json.dumps(
|
||||
(_FORMAT_VERSION, kind.value, str(channel_id), config_version),
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
return base64.urlsafe_b64encode(serialized).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _decode(
|
||||
value: str,
|
||||
expected_kind: _ConfigVersionChannel,
|
||||
expected_channel_id: EmailProviderId | IMChannelId,
|
||||
) -> int:
|
||||
try:
|
||||
padding = "=" * (-len(value) % 4)
|
||||
serialized = base64.b64decode(value + padding, altchars=b"-_", validate=True)
|
||||
decoded = json.loads(serialized)
|
||||
except (UnicodeEncodeError, ValueError, json.JSONDecodeError):
|
||||
raise InvalidConfigVersionError("invalid configuration version") from None
|
||||
|
||||
if not isinstance(decoded, list) or len(decoded) != 4:
|
||||
raise InvalidConfigVersionError("invalid configuration version")
|
||||
format_version, kind, channel_id, config_version = decoded
|
||||
if (
|
||||
format_version != _FORMAT_VERSION
|
||||
or kind != expected_kind.value
|
||||
or channel_id != str(expected_channel_id)
|
||||
or isinstance(config_version, bool)
|
||||
or not isinstance(config_version, int)
|
||||
or config_version < 1
|
||||
):
|
||||
raise InvalidConfigVersionError("invalid configuration version")
|
||||
return config_version
|
||||
|
||||
|
||||
__all__ = [
|
||||
"InvalidConfigVersionError",
|
||||
"decode_email_config_version",
|
||||
"decode_im_config_version",
|
||||
"encode_email_config_version",
|
||||
"encode_im_config_version",
|
||||
]
|
||||
@@ -0,0 +1,984 @@
|
||||
"""Workspace Human Input v2 Contact management and editor APIs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from typing import Never
|
||||
from uuid import UUID
|
||||
|
||||
from flask import abort, request
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.human_input_v2_contracts import (
|
||||
AddPlatformContactsRequest,
|
||||
AddPlatformContactsResponse,
|
||||
BatchGetContactOptionsQuery,
|
||||
BatchGetContactOptionsResponse,
|
||||
BatchGetContactsQuery,
|
||||
BatchGetContactsResponse,
|
||||
ContactListQuery,
|
||||
ContactOption,
|
||||
ContactOptionsQuery,
|
||||
CreateIMBindingRequest,
|
||||
CreateIMBindingResponse,
|
||||
CreateIMSyncRunResponse,
|
||||
DeleteIMBindingResponse,
|
||||
ExternalContactCreateRequest,
|
||||
ExternalContactCreateResponse,
|
||||
ExternalContactUpdateRequest,
|
||||
ExternalContactUpdateResponse,
|
||||
GetContactResponse,
|
||||
GetLatestIMSyncRunResponse,
|
||||
HumanInputContact,
|
||||
HumanInputContactType,
|
||||
IMContactSyncErrorResponse,
|
||||
IMProvider,
|
||||
IMSyncResultType,
|
||||
IMSyncRunStatus,
|
||||
ListContactOptionsResponse,
|
||||
ListContactsResponse,
|
||||
ListIMIdentitiesQuery,
|
||||
ListIMIdentitiesResponse,
|
||||
ListLatestIMSyncRunResultsQuery,
|
||||
ListLatestIMSyncRunResultsResponse,
|
||||
ListOrganizationCandidatesResponse,
|
||||
NodeDataMigrationFailureResponse,
|
||||
NodeDataMigrationPayload,
|
||||
NodeDataMigrationResponse,
|
||||
OrganizationCandidatesQuery,
|
||||
RemoveContactsRequest,
|
||||
RemoveContactsResponse,
|
||||
ResetContactIMOverrideResponse,
|
||||
SetContactIMOverrideRequest,
|
||||
SetContactIMOverrideResponse,
|
||||
)
|
||||
from controllers.common.human_input_v2_migration import preflight_legacy_human_input_node_data
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
query_params_from_request,
|
||||
register_enum_models,
|
||||
register_response_schema_models,
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.workspace.error import IMBindingReadOnlyError, WorkspaceIMOverrideNotAllowedError
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
only_edition_enterprise,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from core.human_input_v2.im_integration import (
|
||||
ContactIMBindingView,
|
||||
IMBindingCommandError,
|
||||
IMBindingCommandErrorCode,
|
||||
IMSyncRun,
|
||||
SyncContactSnapshot,
|
||||
SyncResultFact,
|
||||
)
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
DeploymentScope,
|
||||
DirectoryScope,
|
||||
IMIdentityId,
|
||||
TenantId,
|
||||
WorkspaceScope,
|
||||
)
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.helper import build_avatar_url, dump_response
|
||||
from libs.login import login_required
|
||||
from libs.uuid_utils import uuidv7
|
||||
from models.account import Account
|
||||
from repositories.human_input_v2.contact import (
|
||||
CandidateId,
|
||||
Contact,
|
||||
ContactError,
|
||||
ContactErrorCode,
|
||||
ContactQuery,
|
||||
ContactType,
|
||||
ExternalContact,
|
||||
)
|
||||
from repositories.human_input_v2.sqlalchemy_contact_repository import (
|
||||
SQLAlchemyContactIMBindingRepository,
|
||||
SQLAlchemyContactRepository,
|
||||
)
|
||||
from repositories.human_input_v2.sqlalchemy_im_channel_repository import (
|
||||
DeploymentIMChannelReader,
|
||||
WorkspaceIMChannelReader,
|
||||
)
|
||||
from repositories.human_input_v2.sqlalchemy_im_identity_repository import SQLAlchemyIMIdentityRepository
|
||||
from services.enterprise.human_input_contact_composition import build_enterprise_contact_management_service
|
||||
from services.feature_service import FeatureService
|
||||
from services.human_input_v2.composition import build_human_input_node_data_migration_service
|
||||
from services.human_input_v2.contact_service import ContactManagementService, ContactWithIMBindings
|
||||
from services.human_input_v2.im_contact_sync.composition import build_im_contact_sync_application
|
||||
from services.human_input_v2.im_contact_sync.errors import IMWriteUnavailableError
|
||||
from services.human_input_v2.im_contact_sync.service import (
|
||||
IMChannelNotConfiguredError,
|
||||
IMSyncDispatchUnavailableError,
|
||||
IMSyncRevisionChangedError,
|
||||
IMSyncRunNotFoundError,
|
||||
)
|
||||
from services.human_input_v2.node_data_migration import MigrationNode, NodeDataMigrationFailure
|
||||
|
||||
from ._decorators import require_admin_or_owner, require_editor
|
||||
|
||||
register_enum_models(
|
||||
console_ns,
|
||||
HumanInputContactType,
|
||||
IMSyncRunStatus,
|
||||
IMSyncResultType,
|
||||
IMProvider,
|
||||
)
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
ContactListQuery,
|
||||
ContactOptionsQuery,
|
||||
BatchGetContactOptionsQuery,
|
||||
OrganizationCandidatesQuery,
|
||||
AddPlatformContactsRequest,
|
||||
ExternalContactCreateRequest,
|
||||
ExternalContactUpdateRequest,
|
||||
RemoveContactsRequest,
|
||||
ListIMIdentitiesQuery,
|
||||
ListLatestIMSyncRunResultsQuery,
|
||||
SetContactIMOverrideRequest,
|
||||
CreateIMBindingRequest,
|
||||
NodeDataMigrationPayload,
|
||||
)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
HumanInputContact,
|
||||
ContactOption,
|
||||
GetContactResponse,
|
||||
ExternalContactCreateResponse,
|
||||
ExternalContactUpdateResponse,
|
||||
AddPlatformContactsResponse,
|
||||
ListContactsResponse,
|
||||
ListContactOptionsResponse,
|
||||
BatchGetContactOptionsResponse,
|
||||
RemoveContactsResponse,
|
||||
ListIMIdentitiesResponse,
|
||||
CreateIMSyncRunResponse,
|
||||
IMContactSyncErrorResponse,
|
||||
GetLatestIMSyncRunResponse,
|
||||
ListLatestIMSyncRunResultsResponse,
|
||||
ListOrganizationCandidatesResponse,
|
||||
ResetContactIMOverrideResponse,
|
||||
SetContactIMOverrideResponse,
|
||||
CreateIMBindingResponse,
|
||||
DeleteIMBindingResponse,
|
||||
BatchGetContactsResponse,
|
||||
NodeDataMigrationResponse,
|
||||
NodeDataMigrationFailureResponse,
|
||||
)
|
||||
|
||||
|
||||
_IM_BINDING_ERROR_STATUS = {
|
||||
IMBindingCommandErrorCode.INTEGRATION_NOT_CONFIGURED: HTTPStatus.NOT_FOUND,
|
||||
IMBindingCommandErrorCode.CONTACT_NOT_FOUND: HTTPStatus.NOT_FOUND,
|
||||
IMBindingCommandErrorCode.IDENTITY_NOT_FOUND: HTTPStatus.NOT_FOUND,
|
||||
IMBindingCommandErrorCode.BINDING_NOT_FOUND: HTTPStatus.NOT_FOUND,
|
||||
IMBindingCommandErrorCode.BINDING_CONFLICT: HTTPStatus.CONFLICT,
|
||||
IMBindingCommandErrorCode.INVALID_SCOPE: HTTPStatus.UNPROCESSABLE_ENTITY,
|
||||
}
|
||||
|
||||
|
||||
def _workspace_scope(tenant_id: str) -> WorkspaceScope:
|
||||
return WorkspaceScope(id=TenantId(tenant_id))
|
||||
|
||||
|
||||
def _im_binding_scope(tenant_id: str) -> DirectoryScope:
|
||||
if FeatureService.is_im_centrally_managed():
|
||||
return DeploymentScope()
|
||||
return _workspace_scope(tenant_id)
|
||||
|
||||
|
||||
type _IMApplicationError = (
|
||||
IMBindingCommandError
|
||||
| IMChannelNotConfiguredError
|
||||
| IMSyncDispatchUnavailableError
|
||||
| IMSyncRevisionChangedError
|
||||
| IMSyncRunNotFoundError
|
||||
| IMWriteUnavailableError
|
||||
)
|
||||
|
||||
|
||||
def _im_application_error_response(error: _IMApplicationError) -> tuple[dict[str, object], HTTPStatus]:
|
||||
if isinstance(error, IMBindingCommandError):
|
||||
status = _IM_BINDING_ERROR_STATUS[error.code]
|
||||
code = error.code.value
|
||||
elif isinstance(error, IMChannelNotConfiguredError):
|
||||
status = HTTPStatus.NOT_FOUND
|
||||
code = "im_integration_not_configured"
|
||||
elif isinstance(error, IMSyncRunNotFoundError):
|
||||
status = HTTPStatus.NOT_FOUND
|
||||
code = "im_sync_run_not_found"
|
||||
elif isinstance(error, IMSyncDispatchUnavailableError):
|
||||
status = HTTPStatus.SERVICE_UNAVAILABLE
|
||||
code = "im_sync_dispatch_unavailable"
|
||||
elif isinstance(error, IMWriteUnavailableError):
|
||||
status = HTTPStatus.SERVICE_UNAVAILABLE
|
||||
code = "im_write_unavailable"
|
||||
else:
|
||||
status = HTTPStatus.CONFLICT
|
||||
code = "im_sync_revision_changed"
|
||||
return (
|
||||
dump_response(
|
||||
IMContactSyncErrorResponse,
|
||||
{"code": code, "message": str(error), "status": status},
|
||||
),
|
||||
status,
|
||||
)
|
||||
|
||||
|
||||
def _sync_run_payload(run: IMSyncRun) -> dict[str, object]:
|
||||
return {
|
||||
"id": run.id,
|
||||
"status": run.status,
|
||||
"started_at": run.started_at,
|
||||
"finished_at": run.finished_at,
|
||||
"error_message": run.error_message,
|
||||
"result_counts": {
|
||||
"added": run.added_count,
|
||||
"not_matched": run.not_matched_count,
|
||||
"failed": run.failed_count,
|
||||
"removed": run.removed_count,
|
||||
"skipped": run.skipped_count,
|
||||
},
|
||||
"provider": run.provider,
|
||||
"channel_id": run.channel_revision.channel_id,
|
||||
"channel_config_version": run.channel_revision.config_version,
|
||||
}
|
||||
|
||||
|
||||
def _directory_entry_payload(result: SyncResultFact) -> dict[str, object] | None:
|
||||
if result.provider_user_id is None:
|
||||
return None
|
||||
return {
|
||||
"provider_user_id": result.provider_user_id,
|
||||
"display_name": result.display_name,
|
||||
"email": result.email,
|
||||
}
|
||||
|
||||
|
||||
def _avatar_url(avatar_file_id: str | None) -> str:
|
||||
return build_avatar_url(avatar_file_id) or ""
|
||||
|
||||
|
||||
def _sync_contact_payload(contact: SyncContactSnapshot, fallback_created_at) -> dict[str, object]:
|
||||
# Historical result snapshots predate the Contact creation-time field.
|
||||
created_at = contact.created_at if contact.created_at is not None else fallback_created_at
|
||||
return {
|
||||
"id": contact.contact_id,
|
||||
"name": contact.name,
|
||||
"avatar_url": _avatar_url(contact.avatar_file_id),
|
||||
"created_at": created_at,
|
||||
}
|
||||
|
||||
|
||||
def _sync_result_payload(result: SyncResultFact) -> dict[str, object]:
|
||||
entry = _directory_entry_payload(result)
|
||||
if result.result_type is IMSyncResultType.NOT_MATCHED:
|
||||
return {"type": result.result_type, "entry": entry}
|
||||
if result.result_type is IMSyncResultType.FAILED:
|
||||
return {
|
||||
"type": result.result_type,
|
||||
"entry": entry,
|
||||
"reason": result.reason_message or result.reason_code or "sync_failed",
|
||||
}
|
||||
if result.result_type is IMSyncResultType.ADDED:
|
||||
if result.contact_snapshot is None or entry is None:
|
||||
raise RuntimeError("added sync result is missing its persisted display snapshot")
|
||||
return {
|
||||
"type": result.result_type,
|
||||
"contact": _sync_contact_payload(result.contact_snapshot, result.created_at),
|
||||
"entry": entry,
|
||||
}
|
||||
if result.result_type is IMSyncResultType.SKIPPED:
|
||||
if result.contact_snapshot is None:
|
||||
raise RuntimeError("skipped sync result is missing its persisted Contact snapshot")
|
||||
return {
|
||||
"type": result.result_type,
|
||||
"entry": entry,
|
||||
"contact": _sync_contact_payload(result.contact_snapshot, result.created_at),
|
||||
}
|
||||
if result.contact_snapshot is None or result.identity_snapshot is None or result.removal_reason is None:
|
||||
raise RuntimeError("removed sync result is missing its persisted display snapshot")
|
||||
return {
|
||||
"type": result.result_type,
|
||||
"contact": _sync_contact_payload(result.contact_snapshot, result.created_at),
|
||||
"last_known_identity": {
|
||||
"identity_id": result.identity_snapshot.identity_id,
|
||||
"provider_user_id": result.identity_snapshot.provider_user_id,
|
||||
"display_name": result.identity_snapshot.display_name,
|
||||
"email": result.identity_snapshot.email,
|
||||
},
|
||||
"reason": result.removal_reason,
|
||||
}
|
||||
|
||||
|
||||
def _contact_binding_payload(contact: ContactIMBindingView) -> dict[str, object]:
|
||||
return {
|
||||
"id": contact.id,
|
||||
"type": contact.type,
|
||||
"name": contact.name,
|
||||
"email": contact.email,
|
||||
"avatar_url": _avatar_url(contact.avatar_file_id),
|
||||
"im_bindings": [
|
||||
{"id": binding.id, "provider": binding.provider, "scope": binding.scope} for binding in contact.im_bindings
|
||||
],
|
||||
"created_at": contact.created_at,
|
||||
}
|
||||
|
||||
|
||||
def _contact_avatar_url(contact: Contact) -> str:
|
||||
return _avatar_url(contact.avatar_file_id)
|
||||
|
||||
|
||||
def _contact_payload(view: ContactWithIMBindings) -> dict[str, object]:
|
||||
contact = view.contact
|
||||
return {
|
||||
"id": contact.id,
|
||||
"type": contact.type,
|
||||
"name": contact.name,
|
||||
"email": contact.email,
|
||||
"avatar_url": _contact_avatar_url(contact),
|
||||
"im_bindings": [
|
||||
{"id": binding.id, "provider": binding.provider, "scope": binding.scope} for binding in view.im_bindings
|
||||
],
|
||||
"created_at": contact.created_at,
|
||||
}
|
||||
|
||||
|
||||
def _contact_option_payload(contact: Contact) -> dict[str, object]:
|
||||
return {
|
||||
"id": contact.id,
|
||||
"type": contact.type,
|
||||
"name": contact.name,
|
||||
"avatar_url": _contact_avatar_url(contact) or None,
|
||||
"email": contact.email,
|
||||
}
|
||||
|
||||
|
||||
def _contact_summary_payload(view: ContactWithIMBindings) -> dict[str, object]:
|
||||
contact = view.contact
|
||||
return {
|
||||
"id": contact.id,
|
||||
"name": contact.name,
|
||||
"avatar_url": _contact_avatar_url(contact),
|
||||
"created_at": contact.created_at,
|
||||
}
|
||||
|
||||
|
||||
def _contact_service(session: Session, tenant_id: TenantId) -> ContactManagementService:
|
||||
repository = SQLAlchemyContactRepository(session)
|
||||
if FeatureService.is_im_centrally_managed():
|
||||
channel = DeploymentIMChannelReader(session).get()
|
||||
else:
|
||||
channel = WorkspaceIMChannelReader(session, tenant_id).get()
|
||||
return ContactManagementService(
|
||||
repository,
|
||||
SQLAlchemyContactIMBindingRepository(session, channel),
|
||||
SQLAlchemyIMIdentityRepository(session, channel.id) if channel is not None else None,
|
||||
)
|
||||
|
||||
|
||||
def _raise_contact_error(error: ContactError) -> Never:
|
||||
if error.code in {ContactErrorCode.NOT_FOUND, ContactErrorCode.ACCOUNT_NOT_FOUND}:
|
||||
abort(HTTPStatus.NOT_FOUND, str(error))
|
||||
if error.code is ContactErrorCode.CONFLICT:
|
||||
abort(HTTPStatus.CONFLICT, str(error))
|
||||
abort(HTTPStatus.UNPROCESSABLE_ENTITY, str(error))
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/contacts")
|
||||
class WorkspaceContactsApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(ContactListQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[ListContactsResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str):
|
||||
query = ContactListQuery.model_validate(request.args.to_dict(flat=True))
|
||||
contact_query = ContactQuery(
|
||||
keyword=query.keyword or "",
|
||||
contact_type=ContactType(query.group.value) if query.group is not None else None,
|
||||
)
|
||||
service = _contact_service(session, TenantId(tenant_id))
|
||||
page, views = service.list_contacts(
|
||||
TenantId(tenant_id),
|
||||
page=query.page,
|
||||
limit=query.limit,
|
||||
query=contact_query,
|
||||
)
|
||||
return dump_response(
|
||||
ListContactsResponse,
|
||||
{
|
||||
"data": [_contact_payload(view) for view in views],
|
||||
"page": page.page,
|
||||
"limit": page.limit,
|
||||
"total": service.count_contacts(TenantId(tenant_id), contact_query),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/contacts/<uuid:contact_id>")
|
||||
class WorkspaceContactApi(Resource):
|
||||
"""Read one contact only when it resolves in the current workspace scope."""
|
||||
|
||||
@console_ns.response(200, "Success", console_ns.models[GetContactResponse.__name__])
|
||||
@console_ns.response(404, "Contact not found or absent in the current workspace")
|
||||
@require_admin_or_owner
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, contact_id: str):
|
||||
details = _contact_service(session, TenantId(tenant_id)).get_contact_details(
|
||||
TenantId(tenant_id), ContactId(contact_id)
|
||||
)
|
||||
if details is None:
|
||||
abort(HTTPStatus.NOT_FOUND, "Contact not found")
|
||||
return dump_response(
|
||||
GetContactResponse,
|
||||
{
|
||||
"contact": _contact_payload(details.contact),
|
||||
"im_binding_details": [
|
||||
{
|
||||
"id": detail.binding.id,
|
||||
"provider": detail.binding.provider,
|
||||
"scope": detail.binding.scope,
|
||||
"identity": detail.identity,
|
||||
}
|
||||
for detail in details.im_binding_details
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/contact-options")
|
||||
class WorkspaceContactOptionsApi(Resource):
|
||||
"""Search the current workspace's selectable Contact projection for workflow editors."""
|
||||
|
||||
@console_ns.doc(
|
||||
params=query_params_from_model(ContactOptionsQuery),
|
||||
description=(
|
||||
"List editor-safe Contact options for static recipient selection. "
|
||||
"The projection omits IM bindings and management metadata; unavailable Contacts are omitted."
|
||||
),
|
||||
)
|
||||
@console_ns.response(200, "Success", console_ns.models[ListContactOptionsResponse.__name__])
|
||||
@require_editor
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str):
|
||||
query = ContactOptionsQuery.model_validate(request.args.to_dict(flat=True))
|
||||
service = _contact_service(session, TenantId(tenant_id))
|
||||
page = service.list_contact_options(
|
||||
TenantId(tenant_id),
|
||||
page=query.page,
|
||||
limit=query.limit,
|
||||
keyword=query.keyword or "",
|
||||
)
|
||||
return dump_response(
|
||||
ListContactOptionsResponse,
|
||||
{
|
||||
"data": [_contact_option_payload(contact) for contact in page.items],
|
||||
"page": page.page,
|
||||
"limit": page.limit,
|
||||
"total": service.count_contacts(TenantId(tenant_id), ContactQuery(keyword=query.keyword or "")),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/organization-candidates")
|
||||
class WorkspaceOrganizationCandidatesApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(OrganizationCandidatesQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[ListOrganizationCandidatesResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@only_edition_enterprise
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str):
|
||||
query = OrganizationCandidatesQuery.model_validate(request.args.to_dict(flat=True))
|
||||
service = build_enterprise_contact_management_service(session)
|
||||
candidates = service.list_organization_candidates(
|
||||
page=query.page,
|
||||
limit=query.limit,
|
||||
keyword=query.keyword or "",
|
||||
)
|
||||
return dump_response(
|
||||
ListOrganizationCandidatesResponse,
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": candidate.id,
|
||||
"name": candidate.name,
|
||||
"email": candidate.email,
|
||||
"avatar_url": _avatar_url(candidate.avatar_file_id) or None,
|
||||
}
|
||||
for candidate in candidates
|
||||
],
|
||||
"page": query.page,
|
||||
"limit": query.limit,
|
||||
"total": service.count_organization_candidates(query.keyword or ""),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/contacts/platform")
|
||||
class WorkspacePlatformContactsApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AddPlatformContactsRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[AddPlatformContactsResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@only_edition_enterprise
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def post(self, session: Session, tenant_id: str, current_user: Account):
|
||||
request_body = AddPlatformContactsRequest.model_validate(console_ns.payload or {})
|
||||
try:
|
||||
contacts = build_enterprise_contact_management_service(session).add_platform_contacts(
|
||||
TenantId(tenant_id),
|
||||
[CandidateId(candidate_id) for candidate_id in request_body.candidate_ids],
|
||||
AccountId(current_user.id),
|
||||
)
|
||||
except ContactError as error:
|
||||
_raise_contact_error(error)
|
||||
return dump_response(AddPlatformContactsResponse, {"data": [_contact_payload(contact) for contact in contacts]})
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/contacts/external")
|
||||
class WorkspaceExternalContactsApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ExternalContactCreateRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[ExternalContactCreateResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def post(self, session: Session, tenant_id: str):
|
||||
request_body = ExternalContactCreateRequest.model_validate(console_ns.payload or {})
|
||||
try:
|
||||
contact = _contact_service(session, TenantId(tenant_id)).create_external_contact(
|
||||
TenantId(tenant_id),
|
||||
contact_id=ContactId(str(uuidv7())),
|
||||
name=request_body.name,
|
||||
email=str(request_body.email),
|
||||
avatar_file_id=request_body.avatar or None,
|
||||
now=naive_utc_now(),
|
||||
)
|
||||
except ContactError as error:
|
||||
_raise_contact_error(error)
|
||||
return dump_response(ExternalContactCreateResponse, {"contact": _contact_payload(contact)})
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/contacts/external/<uuid:contact_id>")
|
||||
class WorkspaceExternalContactApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ExternalContactUpdateRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[ExternalContactUpdateResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def patch(self, session: Session, tenant_id: str, contact_id: str):
|
||||
request_body = ExternalContactUpdateRequest.model_validate(console_ns.payload or {})
|
||||
service = _contact_service(session, TenantId(tenant_id))
|
||||
try:
|
||||
current = service.get_contact(TenantId(tenant_id), ContactId(contact_id))
|
||||
if current is None or current.contact.type is not ContactType.EXTERNAL:
|
||||
abort(HTTPStatus.NOT_FOUND, "External Contact not found")
|
||||
assert current is not None
|
||||
current_contact = current.contact
|
||||
if current_contact.email is None:
|
||||
raise RuntimeError("External Contact is missing its required Email")
|
||||
avatar_file_id = current_contact.avatar_file_id
|
||||
if "avatar" in request_body.model_fields_set:
|
||||
avatar_file_id = request_body.avatar or None
|
||||
contact = service.update_external_contact(
|
||||
TenantId(tenant_id),
|
||||
external_contact=ExternalContact(
|
||||
id=current_contact.id,
|
||||
name=request_body.name or current_contact.name,
|
||||
email=str(request_body.email) if request_body.email is not None else current_contact.email,
|
||||
avatar_file_id=avatar_file_id,
|
||||
created_at=current_contact.created_at,
|
||||
),
|
||||
)
|
||||
except ContactError as error:
|
||||
_raise_contact_error(error)
|
||||
return dump_response(ExternalContactUpdateResponse, {"contact": _contact_payload(contact)})
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/contacts/remove")
|
||||
class WorkspaceContactsRemoveApi(Resource):
|
||||
@console_ns.expect(console_ns.models[RemoveContactsRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[RemoveContactsResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def post(self, session: Session, tenant_id: str):
|
||||
request_body = RemoveContactsRequest.model_validate(console_ns.payload or {})
|
||||
try:
|
||||
contact_ids = [ContactId(contact_id) for contact_id in request_body.contact_ids]
|
||||
if FeatureService.is_im_centrally_managed():
|
||||
removed_ids = build_enterprise_contact_management_service(session).remove_contacts(
|
||||
TenantId(tenant_id),
|
||||
contact_ids,
|
||||
)
|
||||
else:
|
||||
removed_ids = _contact_service(session, TenantId(tenant_id)).remove_contacts(
|
||||
TenantId(tenant_id), contact_ids
|
||||
)
|
||||
except ContactError as error:
|
||||
_raise_contact_error(error)
|
||||
except ValueError as error:
|
||||
abort(HTTPStatus.UNPROCESSABLE_ENTITY, str(error))
|
||||
return dump_response(RemoveContactsResponse, {"removed_contact_ids": removed_ids})
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/im-sync-runs")
|
||||
class WorkspaceIMSyncRunsApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[CreateIMSyncRunResponse.__name__])
|
||||
@console_ns.response(404, "IM Integration not configured", console_ns.models[IMContactSyncErrorResponse.__name__])
|
||||
@console_ns.response(409, "IM Integration revision changed", console_ns.models[IMContactSyncErrorResponse.__name__])
|
||||
@console_ns.response(
|
||||
503,
|
||||
"IM synchronization temporarily unavailable",
|
||||
console_ns.models[IMContactSyncErrorResponse.__name__],
|
||||
)
|
||||
@require_admin_or_owner
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account):
|
||||
try:
|
||||
run = build_im_contact_sync_application().sync_service.create_or_get_active_run(
|
||||
_workspace_scope(tenant_id),
|
||||
AccountId(current_user.id),
|
||||
)
|
||||
except (
|
||||
IMChannelNotConfiguredError,
|
||||
IMSyncDispatchUnavailableError,
|
||||
IMSyncRevisionChangedError,
|
||||
IMWriteUnavailableError,
|
||||
) as error:
|
||||
return _im_application_error_response(error)
|
||||
return dump_response(CreateIMSyncRunResponse, {"run": _sync_run_payload(run)})
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/im-sync-runs/latest")
|
||||
class WorkspaceLatestIMSyncRunApi(Resource):
|
||||
@console_ns.doc(
|
||||
description=(
|
||||
"Return the latest IM sync run summary. The UI uses finished_at as the explicit sync time; "
|
||||
"the response does not include started_by."
|
||||
)
|
||||
)
|
||||
@console_ns.response(200, "Success", console_ns.models[GetLatestIMSyncRunResponse.__name__])
|
||||
@console_ns.response(404, "Latest IM sync run not found", console_ns.models[IMContactSyncErrorResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
try:
|
||||
run = build_im_contact_sync_application().sync_service.get_latest_run(_workspace_scope(tenant_id))
|
||||
except (IMChannelNotConfiguredError, IMSyncRunNotFoundError) as error:
|
||||
return _im_application_error_response(error)
|
||||
return dump_response(GetLatestIMSyncRunResponse, {"run": _sync_run_payload(run)})
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/im-sync-runs/latest/results")
|
||||
class WorkspaceLatestIMSyncRunResultsApi(Resource):
|
||||
@console_ns.doc(
|
||||
params=query_params_from_model(ListLatestIMSyncRunResultsQuery),
|
||||
description=(
|
||||
"Return one required result bucket from the latest IM sync run using page and limit pagination. "
|
||||
"There is no all filter; the response contains page, limit, and total metadata without a run summary."
|
||||
),
|
||||
)
|
||||
@console_ns.response(200, "Success", console_ns.models[ListLatestIMSyncRunResultsResponse.__name__])
|
||||
@console_ns.response(404, "Latest IM sync run not found", console_ns.models[IMContactSyncErrorResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
query = ListLatestIMSyncRunResultsQuery.model_validate(request.args.to_dict(flat=True))
|
||||
try:
|
||||
result_page = build_im_contact_sync_application().sync_service.list_latest_results(
|
||||
_workspace_scope(tenant_id),
|
||||
query.result,
|
||||
page=query.page,
|
||||
limit=query.limit,
|
||||
)
|
||||
except (IMChannelNotConfiguredError, IMSyncRunNotFoundError) as error:
|
||||
return _im_application_error_response(error)
|
||||
return dump_response(
|
||||
ListLatestIMSyncRunResultsResponse,
|
||||
{
|
||||
"data": [{"id": result.id, "result": _sync_result_payload(result)} for result in result_page.items],
|
||||
"page": result_page.page,
|
||||
"limit": result_page.limit,
|
||||
"total": result_page.total,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/im-identities")
|
||||
class WorkspaceIMIdentitiesApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(ListIMIdentitiesQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[ListIMIdentitiesResponse.__name__])
|
||||
@console_ns.response(404, "IM Integration not configured", console_ns.models[IMContactSyncErrorResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
query = ListIMIdentitiesQuery.model_validate(request.args.to_dict(flat=True))
|
||||
try:
|
||||
identity_page = build_im_contact_sync_application().sync_service.search_identities(
|
||||
_im_binding_scope(tenant_id),
|
||||
keyword=query.keyword,
|
||||
page=query.page,
|
||||
limit=query.limit,
|
||||
)
|
||||
except IMChannelNotConfiguredError as error:
|
||||
return _im_application_error_response(error)
|
||||
return dump_response(
|
||||
ListIMIdentitiesResponse,
|
||||
{
|
||||
"data": identity_page.items,
|
||||
"page": identity_page.page,
|
||||
"limit": identity_page.limit,
|
||||
"total": identity_page.total,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/contacts/<uuid:contact_id>/im-override")
|
||||
class WorkspaceContactIMOverrideApi(Resource):
|
||||
@console_ns.doc(
|
||||
description=(
|
||||
"Set or reset the IM override for a contact. "
|
||||
"This endpoint is used to override the IM identity for a contact in the workspace."
|
||||
),
|
||||
)
|
||||
@console_ns.expect(console_ns.models[SetContactIMOverrideRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[SetContactIMOverrideResponse.__name__])
|
||||
@console_ns.response(403, "Workspace overrides require centrally managed IM bindings")
|
||||
@console_ns.response(
|
||||
404,
|
||||
"Contact or IM identity not found",
|
||||
console_ns.models[IMContactSyncErrorResponse.__name__],
|
||||
)
|
||||
@console_ns.response(409, "IM binding conflict", console_ns.models[IMContactSyncErrorResponse.__name__])
|
||||
@console_ns.response(422, "Invalid binding scope", console_ns.models[IMContactSyncErrorResponse.__name__])
|
||||
@console_ns.response(503, "IM write unavailable", console_ns.models[IMContactSyncErrorResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def put(self, tenant_id: str, current_user: Account, contact_id: str):
|
||||
if not FeatureService.is_im_centrally_managed():
|
||||
raise WorkspaceIMOverrideNotAllowedError()
|
||||
request_body = SetContactIMOverrideRequest.model_validate(console_ns.payload or {})
|
||||
tenant_id = TenantId(tenant_id)
|
||||
try:
|
||||
contact = build_im_contact_sync_application().binding_service.set_workspace_override(
|
||||
organization_scope=_im_binding_scope(tenant_id),
|
||||
tenant_id=tenant_id,
|
||||
contact_id=ContactId(contact_id),
|
||||
identity_id=IMIdentityId(request_body.identity_id),
|
||||
bound_by_account_id=AccountId(current_user.id),
|
||||
)
|
||||
except (IMBindingCommandError, IMWriteUnavailableError) as error:
|
||||
return _im_application_error_response(error)
|
||||
return dump_response(SetContactIMOverrideResponse, {"contact": _contact_binding_payload(contact)})
|
||||
|
||||
@console_ns.doc(
|
||||
description=(
|
||||
"Reset the IM override for a contact. "
|
||||
"This endpoint is used to clear the IM identity override for a contact in the workspace."
|
||||
),
|
||||
)
|
||||
@console_ns.response(200, "Success", console_ns.models[ResetContactIMOverrideResponse.__name__])
|
||||
@console_ns.response(403, "Workspace overrides require centrally managed IM bindings")
|
||||
@console_ns.response(404, "Contact not found", console_ns.models[IMContactSyncErrorResponse.__name__])
|
||||
@console_ns.response(422, "Invalid binding scope", console_ns.models[IMContactSyncErrorResponse.__name__])
|
||||
@console_ns.response(503, "IM write unavailable", console_ns.models[IMContactSyncErrorResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@with_current_tenant_id
|
||||
def delete(self, tenant_id: str, contact_id: str):
|
||||
if not FeatureService.is_im_centrally_managed():
|
||||
raise WorkspaceIMOverrideNotAllowedError()
|
||||
tenant_id = TenantId(tenant_id)
|
||||
try:
|
||||
contact = build_im_contact_sync_application().binding_service.reset_workspace_override(
|
||||
organization_scope=_im_binding_scope(tenant_id),
|
||||
tenant_id=tenant_id,
|
||||
contact_id=ContactId(contact_id),
|
||||
)
|
||||
except (IMBindingCommandError, IMWriteUnavailableError) as error:
|
||||
return _im_application_error_response(error)
|
||||
return dump_response(ResetContactIMOverrideResponse, {"contact": _contact_binding_payload(contact)})
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/contacts/<uuid:contact_id>/im-bindings")
|
||||
class WorkspaceContactIMBindingsApi(Resource):
|
||||
@console_ns.doc(
|
||||
description=(
|
||||
"Set a default IM binding for a contact, atomically replacing any default binding for either "
|
||||
"the contact or the selected identity. Identical assignments preserve their binding ID; "
|
||||
"changed assignments receive a new binding ID. "
|
||||
"This endpoint is not used for creating workspace IM override. "
|
||||
"For that purpose, use WorkspaceContactIMOverrideApi.put instead."
|
||||
),
|
||||
)
|
||||
@console_ns.expect(console_ns.models[CreateIMBindingRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[CreateIMBindingResponse.__name__])
|
||||
@console_ns.response(403, "Centrally managed IM bindings cannot be edited from a workspace")
|
||||
@console_ns.response(
|
||||
404,
|
||||
"Contact or IM identity not found",
|
||||
console_ns.models[IMContactSyncErrorResponse.__name__],
|
||||
)
|
||||
@console_ns.response(409, "IM binding conflict", console_ns.models[IMContactSyncErrorResponse.__name__])
|
||||
@console_ns.response(503, "IM write unavailable", console_ns.models[IMContactSyncErrorResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def put(self, tenant_id: str, current_user: Account, contact_id: str):
|
||||
if FeatureService.is_im_centrally_managed():
|
||||
raise IMBindingReadOnlyError()
|
||||
request_body = CreateIMBindingRequest.model_validate(console_ns.payload or {})
|
||||
tenant_id = TenantId(tenant_id)
|
||||
try:
|
||||
contact = build_im_contact_sync_application().binding_service.set_organization_binding(
|
||||
organization_scope=WorkspaceScope(id=tenant_id),
|
||||
tenant_id=tenant_id,
|
||||
contact_id=ContactId(contact_id),
|
||||
identity_id=IMIdentityId(request_body.identity_id),
|
||||
bound_by_account_id=AccountId(current_user.id),
|
||||
)
|
||||
except (IMBindingCommandError, IMWriteUnavailableError) as error:
|
||||
return _im_application_error_response(error)
|
||||
return dump_response(CreateIMBindingResponse, {"contact": _contact_binding_payload(contact)})
|
||||
|
||||
@console_ns.response(200, "Success", console_ns.models[DeleteIMBindingResponse.__name__])
|
||||
@console_ns.response(403, "Centrally managed IM bindings cannot be edited from a workspace")
|
||||
@console_ns.response(404, "IM binding not found", console_ns.models[IMContactSyncErrorResponse.__name__])
|
||||
@console_ns.response(503, "IM write unavailable", console_ns.models[IMContactSyncErrorResponse.__name__])
|
||||
@console_ns.doc(
|
||||
description=(
|
||||
"Delete the current default IM binding for the contact identified by the path. "
|
||||
"Workspace IM overrides are not modified. "
|
||||
"This endpoint is not used for resetting workspace IM override. For that purpose, use "
|
||||
"WorkspaceContactIMOverrideApi.delete instead."
|
||||
),
|
||||
)
|
||||
@require_admin_or_owner
|
||||
@with_current_tenant_id
|
||||
def delete(self, tenant_id: str, contact_id: UUID):
|
||||
if FeatureService.is_im_centrally_managed():
|
||||
raise IMBindingReadOnlyError()
|
||||
try:
|
||||
build_im_contact_sync_application().binding_service.delete_organization_binding(
|
||||
organization_scope=_workspace_scope(tenant_id),
|
||||
contact_id=ContactId(str(contact_id)),
|
||||
)
|
||||
except (IMBindingCommandError, IMWriteUnavailableError) as error:
|
||||
return _im_application_error_response(error)
|
||||
return dump_response(DeleteIMBindingResponse, {})
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/contacts/batch")
|
||||
class BatchGetContactsAPI(Resource):
|
||||
@console_ns.doc(
|
||||
params=query_params_from_model(BatchGetContactsQuery),
|
||||
description=(
|
||||
"Admin-only batch lookup for Contact management clients. "
|
||||
"Workflow editors must use the editor-safe contact-options/batch projection."
|
||||
),
|
||||
)
|
||||
@console_ns.response(200, "Success", console_ns.models[BatchGetContactsResponse.__name__])
|
||||
@require_admin_or_owner
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str):
|
||||
query = query_params_from_request(BatchGetContactsQuery, list_fields=("contact_ids",))
|
||||
contacts = _contact_service(session, TenantId(tenant_id)).get_contacts(
|
||||
TenantId(tenant_id),
|
||||
[ContactId(contact_id) for contact_id in query.contact_ids],
|
||||
)
|
||||
return dump_response(
|
||||
BatchGetContactsResponse,
|
||||
{"data": [_contact_summary_payload(contact) for contact in contacts]},
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/contact-options/batch")
|
||||
class BatchGetContactOptionsAPI(Resource):
|
||||
"""Resolve persisted Contact IDs through the same editor-safe selection projection."""
|
||||
|
||||
@console_ns.doc(
|
||||
params=query_params_from_model(BatchGetContactOptionsQuery),
|
||||
description=(
|
||||
"Resolve Contact IDs persisted in workflow recipient configuration. "
|
||||
"Contacts unavailable in the current workspace are omitted."
|
||||
),
|
||||
)
|
||||
@console_ns.response(200, "Success", console_ns.models[BatchGetContactOptionsResponse.__name__])
|
||||
@require_editor
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str):
|
||||
query = query_params_from_request(BatchGetContactOptionsQuery, list_fields=("contact_ids",))
|
||||
contacts = _contact_service(session, TenantId(tenant_id)).get_contact_options(
|
||||
TenantId(tenant_id),
|
||||
[ContactId(contact_id) for contact_id in query.contact_ids],
|
||||
)
|
||||
return dump_response(
|
||||
BatchGetContactOptionsResponse,
|
||||
{"data": [_contact_option_payload(contact) for contact in contacts]},
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/v2/node-data-migration")
|
||||
class NodeDataMigrationAPI(Resource):
|
||||
@console_ns.doc(
|
||||
description=(
|
||||
"Migrate node data from HITLv1 to HITLv2. "
|
||||
'A missing legacy version defaults to "1"; any other explicit version is rejected. '
|
||||
"Every legacy Email recipient becomes onetime_email, while whole_workspace becomes "
|
||||
"all_workspace_contacts. This side-effect-free endpoint only returns migrated node data; "
|
||||
"the caller owns applying it to the Draft workflow."
|
||||
),
|
||||
)
|
||||
@console_ns.expect(console_ns.models[NodeDataMigrationPayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[NodeDataMigrationResponse.__name__])
|
||||
@console_ns.response(400, "Migration failed", console_ns.models[NodeDataMigrationFailureResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
request_body = NodeDataMigrationPayload.model_validate(console_ns.payload or {})
|
||||
outcome = build_human_input_node_data_migration_service().migrate(
|
||||
tenant_id=TenantId(tenant_id),
|
||||
nodes=tuple(
|
||||
MigrationNode.from_preflight(
|
||||
node.node_id,
|
||||
preflight_legacy_human_input_node_data(node.node_data),
|
||||
)
|
||||
for node in request_body.nodes
|
||||
),
|
||||
)
|
||||
if isinstance(outcome, NodeDataMigrationFailure):
|
||||
return (
|
||||
dump_response(
|
||||
NodeDataMigrationFailureResponse,
|
||||
{
|
||||
"message": "Human Input node-data migration failed.",
|
||||
"blockers": outcome.blockers,
|
||||
},
|
||||
),
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
return dump_response(NodeDataMigrationResponse, outcome)
|
||||
Whitespace-only changes.
@@ -0,0 +1,32 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
from libs.exception import BaseHTTPException
|
||||
|
||||
|
||||
class ChannelAlreadyConfiguredHttpError(BaseHTTPException):
|
||||
error_code = "conflict"
|
||||
description = "A Channel is already configured for this kind."
|
||||
code = HTTPStatus.CONFLICT
|
||||
|
||||
|
||||
class ChannelNotFoundHttpError(BaseHTTPException):
|
||||
error_code = "not_found"
|
||||
description = "Channel not found."
|
||||
code = HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
class ChannelProviderConfigurationUpdatedHttpError(BaseHTTPException):
|
||||
error_code = "provider_configuration_updated"
|
||||
description = "The Channel configuration was updated."
|
||||
code = HTTPStatus.CONFLICT
|
||||
|
||||
|
||||
class ChannelProviderBadRequestHttpError(BaseHTTPException):
|
||||
error_code = "bad_request"
|
||||
code = HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
class ChannelReplacementRequiredHttpError(BaseHTTPException):
|
||||
error_code = "replacement_required"
|
||||
description = "Explicit IM Channel replacement is required."
|
||||
code = HTTPStatus.CONFLICT
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Account-authenticated Human Input v2 approval transport."""
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ConfigDict, JsonValue
|
||||
|
||||
from controllers.common.human_input_v2_errors import (
|
||||
HumanInputV2AlreadySubmittedError,
|
||||
HumanInputV2ExpiredError,
|
||||
HumanInputV2InvalidInputError,
|
||||
HumanInputV2WorkflowNotActiveError,
|
||||
form_http_error,
|
||||
)
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import account_initialization_required, setup_required, with_current_user
|
||||
from core.db.session_factory import session_factory
|
||||
from core.human_input_v2.resolved_form import ResolvedForm
|
||||
from core.human_input_v2.shared.values import AccountId
|
||||
from fields.base import ResponseModel
|
||||
from libs.datetime_utils import to_utc_timestamp
|
||||
from libs.external_api import error_responses
|
||||
from libs.helper import dump_response, extract_remote_ip
|
||||
from libs.login import login_required
|
||||
from models.account import Account
|
||||
from services.human_input_v2.submission_service import (
|
||||
HumanInputFormError,
|
||||
HumanInputSubmissionService,
|
||||
)
|
||||
|
||||
|
||||
class ConsoleHumanInputV2SubmitPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", strict=True)
|
||||
|
||||
inputs: dict[str, JsonValue]
|
||||
action: str
|
||||
|
||||
|
||||
class ConsoleHumanInputV2SubmitResponse(ResponseModel):
|
||||
pass
|
||||
|
||||
|
||||
class ConsoleHumanInputV2FormDefinitionResponse(ResponseModel):
|
||||
"""Frozen form definition and effective expiration in UTC epoch seconds."""
|
||||
|
||||
resolved_form: ResolvedForm
|
||||
expiration_time: int
|
||||
|
||||
|
||||
register_schema_models(console_ns, ConsoleHumanInputV2SubmitPayload)
|
||||
register_response_schema_models(
|
||||
console_ns, ConsoleHumanInputV2SubmitResponse, ConsoleHumanInputV2FormDefinitionResponse
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/form/human-input/v2/<string:form_token>")
|
||||
class ConsoleHumanInputV2FormApi(Resource):
|
||||
@error_responses(HumanInputV2AlreadySubmittedError, HumanInputV2ExpiredError)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@console_ns.doc(
|
||||
"get_console_human_input_v2_form",
|
||||
description="Read a frozen v2 form using the current Account's Contact authorization.",
|
||||
responses={401: "Login required", 404: "Form not found", 409: "Already submitted", 410: "Expired"},
|
||||
)
|
||||
@console_ns.response(200, "Form retrieved", console_ns.models[ConsoleHumanInputV2FormDefinitionResponse.__name__])
|
||||
def get(self, current_user: Account, form_token: str):
|
||||
service = HumanInputSubmissionService(session_factory=session_factory.get_session_maker())
|
||||
try:
|
||||
form = service.get_console_form(
|
||||
account_id=AccountId(current_user.id),
|
||||
form_token=form_token,
|
||||
ip_address=extract_remote_ip(request) or None,
|
||||
)
|
||||
except HumanInputFormError as exc:
|
||||
raise form_http_error(exc, message="The form could not be retrieved.") from exc
|
||||
return dump_response(
|
||||
ConsoleHumanInputV2FormDefinitionResponse,
|
||||
{
|
||||
"resolved_form": form.resolved_form,
|
||||
"expiration_time": to_utc_timestamp(min(form.expiration_time, form.global_timeout_deadline)),
|
||||
},
|
||||
)
|
||||
|
||||
@error_responses(
|
||||
HumanInputV2AlreadySubmittedError,
|
||||
HumanInputV2ExpiredError,
|
||||
HumanInputV2WorkflowNotActiveError,
|
||||
HumanInputV2InvalidInputError,
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@console_ns.expect(console_ns.models[ConsoleHumanInputV2SubmitPayload.__name__])
|
||||
@console_ns.response(200, "Approval accepted", console_ns.models[ConsoleHumanInputV2SubmitResponse.__name__])
|
||||
def post(self, current_user: Account, form_token: str):
|
||||
payload = ConsoleHumanInputV2SubmitPayload.model_validate(console_ns.payload or {})
|
||||
service = HumanInputSubmissionService(session_factory=session_factory.get_session_maker())
|
||||
try:
|
||||
service.submit_console_form(
|
||||
account_id=AccountId(current_user.id),
|
||||
form_token=form_token,
|
||||
action=payload.action,
|
||||
inputs=payload.inputs,
|
||||
)
|
||||
except HumanInputFormError as exc:
|
||||
raise form_http_error(exc, message="The approval could not be accepted.") from exc
|
||||
return ConsoleHumanInputV2SubmitResponse().model_dump(mode="json")
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Console credential edit DTOs; omission is never a retention instruction."""
|
||||
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import Field, SecretStr, ValidationInfo, field_validator
|
||||
|
||||
from core.human_input_v2.entities import EmailProviderType, IMProvider
|
||||
from services.human_input_v2.credential_update import (
|
||||
DingTalkCredentialsUpdate,
|
||||
FeishuCredentialsUpdate,
|
||||
LarkCredentialsUpdate,
|
||||
MSTeamsCredentialsUpdate,
|
||||
ResendCredentialsUpdate,
|
||||
SlackCredentialsUpdate,
|
||||
Unchanged,
|
||||
WeComCredentialsUpdate,
|
||||
)
|
||||
|
||||
from ._common import StrictModel
|
||||
from .providers import (
|
||||
_SLACK_APP_TOKEN_PREFIX,
|
||||
_SLACK_BOT_TOKEN_PREFIX,
|
||||
_OptionalSecret,
|
||||
_RequiredSecret,
|
||||
_SecretValidationMixin,
|
||||
)
|
||||
|
||||
type _Identifier = Annotated[str, Field(min_length=1, pattern=r"\S")]
|
||||
type _UUIDString = Annotated[
|
||||
str, Field(pattern=r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
|
||||
]
|
||||
|
||||
|
||||
class UnchangedInput(StrictModel):
|
||||
"""Retain the addressed saved field; valid only for updates and saved tests."""
|
||||
|
||||
type: Literal["unchanged"]
|
||||
|
||||
|
||||
def _updated_value[T](edit: T | UnchangedInput) -> T | Unchanged:
|
||||
return Unchanged.VALUE if isinstance(edit, UnchangedInput) else edit
|
||||
|
||||
|
||||
def _secret_update(edit: SecretStr | UnchangedInput) -> str | Unchanged:
|
||||
return Unchanged.VALUE if isinstance(edit, UnchangedInput) else edit.get_secret_value()
|
||||
|
||||
|
||||
def _optional_secret_update(edit: SecretStr | None | UnchangedInput) -> str | None | Unchanged:
|
||||
return None if edit is None else _secret_update(edit)
|
||||
|
||||
|
||||
class FeishuCredentialsUpdateInput(_SecretValidationMixin, StrictModel):
|
||||
provider: Literal[IMProvider.FEISHU]
|
||||
app_id: _Identifier | UnchangedInput
|
||||
app_secret: _RequiredSecret | UnchangedInput
|
||||
verification_token: _OptionalSecret | UnchangedInput
|
||||
encrypt_key: _OptionalSecret | UnchangedInput
|
||||
|
||||
def to_owner_update(self) -> FeishuCredentialsUpdate:
|
||||
return FeishuCredentialsUpdate(
|
||||
app_id=_updated_value(self.app_id),
|
||||
app_secret=_secret_update(self.app_secret),
|
||||
verification_token=_optional_secret_update(self.verification_token),
|
||||
encrypt_key=_optional_secret_update(self.encrypt_key),
|
||||
)
|
||||
|
||||
|
||||
class LarkCredentialsUpdateInput(_SecretValidationMixin, StrictModel):
|
||||
provider: Literal[IMProvider.LARK]
|
||||
app_id: _Identifier | UnchangedInput
|
||||
app_secret: _RequiredSecret | UnchangedInput
|
||||
verification_token: _OptionalSecret | UnchangedInput
|
||||
encrypt_key: _OptionalSecret | UnchangedInput
|
||||
|
||||
def to_owner_update(self) -> LarkCredentialsUpdate:
|
||||
return LarkCredentialsUpdate(
|
||||
app_id=_updated_value(self.app_id),
|
||||
app_secret=_secret_update(self.app_secret),
|
||||
verification_token=_optional_secret_update(self.verification_token),
|
||||
encrypt_key=_optional_secret_update(self.encrypt_key),
|
||||
)
|
||||
|
||||
|
||||
class SlackCredentialsUpdateInput(_SecretValidationMixin, StrictModel):
|
||||
provider: Literal[IMProvider.SLACK]
|
||||
client_id: _Identifier | UnchangedInput
|
||||
client_secret: _RequiredSecret | UnchangedInput
|
||||
signing_secret: _RequiredSecret | UnchangedInput
|
||||
bot_token: _RequiredSecret | UnchangedInput
|
||||
app_token: _OptionalSecret | UnchangedInput
|
||||
|
||||
@field_validator("bot_token", "app_token")
|
||||
@classmethod
|
||||
def require_token_prefix(
|
||||
cls, token: SecretStr | None | UnchangedInput, info: ValidationInfo
|
||||
) -> SecretStr | None | UnchangedInput:
|
||||
if isinstance(token, SecretStr):
|
||||
prefix = _SLACK_BOT_TOKEN_PREFIX if info.field_name == "bot_token" else _SLACK_APP_TOKEN_PREFIX
|
||||
if not token.get_secret_value().startswith(prefix):
|
||||
raise ValueError(f"Slack token must start with {prefix}")
|
||||
return token
|
||||
|
||||
def to_owner_update(self) -> SlackCredentialsUpdate:
|
||||
return SlackCredentialsUpdate(
|
||||
client_id=_updated_value(self.client_id),
|
||||
client_secret=_secret_update(self.client_secret),
|
||||
signing_secret=_secret_update(self.signing_secret),
|
||||
bot_token=_secret_update(self.bot_token),
|
||||
app_token=_optional_secret_update(self.app_token),
|
||||
)
|
||||
|
||||
|
||||
class DingTalkCredentialsUpdateInput(_SecretValidationMixin, StrictModel):
|
||||
provider: Literal[IMProvider.DING_TALK]
|
||||
corp_id: _Identifier | UnchangedInput
|
||||
client_id: _Identifier | UnchangedInput
|
||||
client_secret: _RequiredSecret | UnchangedInput
|
||||
|
||||
def to_owner_update(self) -> DingTalkCredentialsUpdate:
|
||||
return DingTalkCredentialsUpdate(
|
||||
corp_id=_updated_value(self.corp_id),
|
||||
client_id=_updated_value(self.client_id),
|
||||
client_secret=_secret_update(self.client_secret),
|
||||
)
|
||||
|
||||
|
||||
class MSTeamsCredentialsUpdateInput(_SecretValidationMixin, StrictModel):
|
||||
provider: Literal[IMProvider.MS_TEAMS]
|
||||
tenant_id: _UUIDString | UnchangedInput
|
||||
client_id: _UUIDString | UnchangedInput
|
||||
client_secret: _RequiredSecret | UnchangedInput
|
||||
|
||||
def to_owner_update(self) -> MSTeamsCredentialsUpdate:
|
||||
return MSTeamsCredentialsUpdate(
|
||||
tenant_id=_updated_value(self.tenant_id),
|
||||
client_id=_updated_value(self.client_id),
|
||||
client_secret=_secret_update(self.client_secret),
|
||||
)
|
||||
|
||||
|
||||
class WeComCredentialsUpdateInput(_SecretValidationMixin, StrictModel):
|
||||
provider: Literal[IMProvider.WE_COM]
|
||||
corp_id: _Identifier | UnchangedInput
|
||||
agent_id: str | UnchangedInput
|
||||
secret: _RequiredSecret | UnchangedInput
|
||||
|
||||
def to_owner_update(self) -> WeComCredentialsUpdate:
|
||||
return WeComCredentialsUpdate(
|
||||
corp_id=_updated_value(self.corp_id),
|
||||
agent_id=_updated_value(self.agent_id),
|
||||
secret=_secret_update(self.secret),
|
||||
)
|
||||
|
||||
|
||||
type IMProviderCredentialsUpdateInput = Annotated[
|
||||
FeishuCredentialsUpdateInput
|
||||
| LarkCredentialsUpdateInput
|
||||
| SlackCredentialsUpdateInput
|
||||
| DingTalkCredentialsUpdateInput
|
||||
| MSTeamsCredentialsUpdateInput
|
||||
| WeComCredentialsUpdateInput,
|
||||
Field(discriminator="provider"),
|
||||
]
|
||||
|
||||
|
||||
class ResendCredentialsUpdateInput(_SecretValidationMixin, StrictModel):
|
||||
provider: Literal[EmailProviderType.RESEND]
|
||||
api_key: _RequiredSecret | UnchangedInput
|
||||
|
||||
def to_owner_update(self) -> ResendCredentialsUpdate:
|
||||
return ResendCredentialsUpdate(
|
||||
api_key=_secret_update(self.api_key),
|
||||
)
|
||||
|
||||
|
||||
type EmailProviderCredentialsUpdateInput = Annotated[ResendCredentialsUpdateInput, Field(discriminator="provider")]
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Credential projections exposing identifiers while keeping secrets redacted."""
|
||||
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from core.human_input_v2.entities import EmailProviderType, IMProvider
|
||||
from core.human_input_v2.im_integration.adapters.credentials import (
|
||||
DingTalkCredentials,
|
||||
FeishuCredentials,
|
||||
IMProviderCredentials,
|
||||
LarkCredentials,
|
||||
MSTeamsCredentials,
|
||||
SlackCredentials,
|
||||
)
|
||||
from fields.base import ResponseModel
|
||||
|
||||
|
||||
class Redacted(ResponseModel):
|
||||
"""A configured credential whose stored value is omitted."""
|
||||
|
||||
type: Literal["redacted"] = "redacted"
|
||||
|
||||
|
||||
class FeishuLarkCredentialsResponse(ResponseModel):
|
||||
provider: Literal[IMProvider.FEISHU, IMProvider.LARK]
|
||||
app_id: str
|
||||
app_secret: Redacted
|
||||
verification_token: Redacted | None
|
||||
encrypt_key: Redacted | None
|
||||
|
||||
|
||||
class SlackCredentialsResponse(ResponseModel):
|
||||
provider: Literal[IMProvider.SLACK]
|
||||
client_id: str
|
||||
client_secret: Redacted
|
||||
signing_secret: Redacted
|
||||
bot_token: Redacted
|
||||
app_token: Redacted | None
|
||||
|
||||
|
||||
class DingTalkCredentialsResponse(ResponseModel):
|
||||
provider: Literal[IMProvider.DING_TALK]
|
||||
corp_id: str
|
||||
client_id: str
|
||||
client_secret: Redacted
|
||||
|
||||
|
||||
class MSTeamsCredentialsResponse(ResponseModel):
|
||||
provider: Literal[IMProvider.MS_TEAMS]
|
||||
tenant_id: str
|
||||
client_id: str
|
||||
client_secret: Redacted
|
||||
|
||||
|
||||
class WeComCredentialsResponse(ResponseModel):
|
||||
provider: Literal[IMProvider.WE_COM]
|
||||
corp_id: str
|
||||
agent_id: str
|
||||
secret: Redacted
|
||||
|
||||
|
||||
type IMProviderCredentialsResponse = Annotated[
|
||||
FeishuLarkCredentialsResponse
|
||||
| SlackCredentialsResponse
|
||||
| DingTalkCredentialsResponse
|
||||
| MSTeamsCredentialsResponse
|
||||
| WeComCredentialsResponse,
|
||||
Field(discriminator="provider"),
|
||||
]
|
||||
|
||||
|
||||
class ResendCredentialsResponse(ResponseModel):
|
||||
provider: Literal[EmailProviderType.RESEND]
|
||||
api_key: Redacted
|
||||
|
||||
|
||||
def im_credentials_response(credentials: IMProviderCredentials) -> IMProviderCredentialsResponse:
|
||||
if isinstance(credentials, (FeishuCredentials, LarkCredentials)):
|
||||
return FeishuLarkCredentialsResponse(
|
||||
provider=credentials.provider,
|
||||
app_id=credentials.app_id,
|
||||
app_secret=Redacted(),
|
||||
verification_token=Redacted() if credentials.verification_token is not None else None,
|
||||
encrypt_key=Redacted() if credentials.encrypt_key is not None else None,
|
||||
)
|
||||
if isinstance(credentials, SlackCredentials):
|
||||
return SlackCredentialsResponse(
|
||||
provider=credentials.provider,
|
||||
client_id=credentials.client_id,
|
||||
client_secret=Redacted(),
|
||||
signing_secret=Redacted(),
|
||||
bot_token=Redacted(),
|
||||
app_token=Redacted() if credentials.app_token is not None else None,
|
||||
)
|
||||
if isinstance(credentials, DingTalkCredentials):
|
||||
return DingTalkCredentialsResponse(
|
||||
provider=credentials.provider,
|
||||
corp_id=credentials.corp_id,
|
||||
client_id=credentials.client_id,
|
||||
client_secret=Redacted(),
|
||||
)
|
||||
if isinstance(credentials, MSTeamsCredentials):
|
||||
return MSTeamsCredentialsResponse(
|
||||
provider=credentials.provider,
|
||||
tenant_id=credentials.tenant_id,
|
||||
client_id=credentials.client_id,
|
||||
client_secret=Redacted(),
|
||||
)
|
||||
return WeComCredentialsResponse(
|
||||
provider=credentials.provider,
|
||||
corp_id=credentials.corp_id,
|
||||
agent_id=credentials.agent_id,
|
||||
secret=Redacted(),
|
||||
)
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Canonical Console v2 provider credential DTOs and owner mappings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import Field, SecretStr, field_validator
|
||||
|
||||
from core.human_input_v2.entities import EmailProviderType, IMProvider
|
||||
from core.human_input_v2.im_integration.adapters.credentials import (
|
||||
DingTalkCredentials,
|
||||
FeishuCredentials,
|
||||
LarkCredentials,
|
||||
MSTeamsCredentials,
|
||||
SlackCredentials,
|
||||
WeComCredentials,
|
||||
)
|
||||
from repositories.human_input_v2.email_channel.entities import ResendCredentials as _ResendCredentials
|
||||
|
||||
from ._common import StrictModel
|
||||
|
||||
type _RequiredSecret = Annotated[SecretStr, Field(min_length=1)]
|
||||
type _OptionalSecret = Annotated[SecretStr, Field(min_length=1)] | None
|
||||
|
||||
_SLACK_BOT_TOKEN_PREFIX = "xoxb-"
|
||||
_SLACK_APP_TOKEN_PREFIX = "xapp-"
|
||||
|
||||
|
||||
def _secret_value(secret: SecretStr) -> str:
|
||||
return secret.get_secret_value()
|
||||
|
||||
|
||||
def _optional_secret_value(secret: SecretStr | None) -> str | None:
|
||||
return secret.get_secret_value() if secret is not None else None
|
||||
|
||||
|
||||
class _SecretValidationMixin:
|
||||
@field_validator("*", mode="after", check_fields=False)
|
||||
@classmethod
|
||||
def reject_blank_secrets(cls, value: object) -> object:
|
||||
if isinstance(value, SecretStr) and not value.get_secret_value().strip():
|
||||
raise ValueError("secret must not be blank")
|
||||
return value
|
||||
|
||||
|
||||
class _FeishuLarkCredentialsInputBase(_SecretValidationMixin, StrictModel):
|
||||
app_id: str = Field(min_length=1, description="Feishu or Lark application identifier.")
|
||||
app_secret: _RequiredSecret = Field(description="Feishu or Lark application secret.")
|
||||
verification_token: _OptionalSecret = Field(default=None, description="Optional callback verification token.")
|
||||
encrypt_key: _OptionalSecret = Field(default=None, description="Optional callback encryption key.")
|
||||
|
||||
|
||||
class FeishuCredentialsInput(_FeishuLarkCredentialsInputBase):
|
||||
provider: Literal[IMProvider.FEISHU] = Field(description="Feishu credential discriminator.")
|
||||
|
||||
def to_owner_credentials(self) -> FeishuCredentials:
|
||||
return FeishuCredentials(
|
||||
provider=self.provider,
|
||||
app_id=self.app_id,
|
||||
app_secret=_secret_value(self.app_secret),
|
||||
verification_token=_optional_secret_value(self.verification_token),
|
||||
encrypt_key=_optional_secret_value(self.encrypt_key),
|
||||
)
|
||||
|
||||
|
||||
class LarkCredentialsInput(_FeishuLarkCredentialsInputBase):
|
||||
provider: Literal[IMProvider.LARK] = Field(description="Lark credential discriminator.")
|
||||
|
||||
def to_owner_credentials(self) -> LarkCredentials:
|
||||
return LarkCredentials(
|
||||
provider=self.provider,
|
||||
app_id=self.app_id,
|
||||
app_secret=_secret_value(self.app_secret),
|
||||
verification_token=_optional_secret_value(self.verification_token),
|
||||
encrypt_key=_optional_secret_value(self.encrypt_key),
|
||||
)
|
||||
|
||||
|
||||
class SlackCredentialsInput(_SecretValidationMixin, StrictModel):
|
||||
provider: Literal[IMProvider.SLACK] = Field(description="Slack credential discriminator.")
|
||||
client_id: str = Field(min_length=1, description="Slack OAuth client identifier.")
|
||||
client_secret: _RequiredSecret = Field(description="Slack OAuth client secret.")
|
||||
signing_secret: _RequiredSecret = Field(description="Slack callback signing secret.")
|
||||
bot_token: _RequiredSecret = Field(description="Slack bot API token.")
|
||||
app_token: _OptionalSecret = Field(
|
||||
default=None,
|
||||
description="Optional Slack app-level token required only for Socket Mode.",
|
||||
)
|
||||
|
||||
@field_validator("bot_token")
|
||||
@classmethod
|
||||
def require_bot_token_prefix(cls, token: SecretStr) -> SecretStr:
|
||||
if not token.get_secret_value().startswith(_SLACK_BOT_TOKEN_PREFIX):
|
||||
raise ValueError("Slack bot token must start with xoxb-")
|
||||
return token
|
||||
|
||||
@field_validator("app_token")
|
||||
@classmethod
|
||||
def require_app_token_prefix(cls, token: SecretStr | None) -> SecretStr | None:
|
||||
if token is not None and not token.get_secret_value().startswith(_SLACK_APP_TOKEN_PREFIX):
|
||||
raise ValueError("Slack app token must start with xapp-")
|
||||
return token
|
||||
|
||||
def to_owner_credentials(self) -> SlackCredentials:
|
||||
return SlackCredentials(
|
||||
provider=self.provider,
|
||||
client_id=self.client_id,
|
||||
client_secret=_secret_value(self.client_secret),
|
||||
signing_secret=_secret_value(self.signing_secret),
|
||||
bot_token=_secret_value(self.bot_token),
|
||||
app_token=_optional_secret_value(self.app_token),
|
||||
)
|
||||
|
||||
|
||||
class DingTalkCredentialsInput(_SecretValidationMixin, StrictModel):
|
||||
provider: Literal[IMProvider.DING_TALK] = Field(description="DingTalk credential discriminator.")
|
||||
corp_id: str = Field(min_length=1, description="DingTalk corporation identifier.")
|
||||
client_id: str = Field(min_length=1, description="DingTalk application client identifier.")
|
||||
client_secret: _RequiredSecret = Field(description="DingTalk application client secret.")
|
||||
|
||||
def to_owner_credentials(self) -> DingTalkCredentials:
|
||||
return DingTalkCredentials(
|
||||
provider=self.provider,
|
||||
corp_id=self.corp_id,
|
||||
client_id=self.client_id,
|
||||
client_secret=_secret_value(self.client_secret),
|
||||
)
|
||||
|
||||
|
||||
class MSTeamsCredentialsInput(_SecretValidationMixin, StrictModel):
|
||||
provider: Literal[IMProvider.MS_TEAMS] = Field(description="Microsoft Teams credential discriminator.")
|
||||
tenant_id: str = Field(min_length=1, description="Microsoft Entra tenant identifier.")
|
||||
client_id: str = Field(min_length=1, description="Microsoft Teams application client identifier.")
|
||||
client_secret: _RequiredSecret = Field(description="Microsoft Teams application client secret.")
|
||||
|
||||
def to_owner_credentials(self) -> MSTeamsCredentials:
|
||||
return MSTeamsCredentials(
|
||||
provider=self.provider,
|
||||
tenant_id=self.tenant_id,
|
||||
client_id=self.client_id,
|
||||
client_secret=_secret_value(self.client_secret),
|
||||
)
|
||||
|
||||
|
||||
class WeComCredentialsInput(_SecretValidationMixin, StrictModel):
|
||||
provider: Literal[IMProvider.WE_COM] = Field(description="WeCom credential discriminator.")
|
||||
corp_id: str = Field(min_length=1, description="WeCom corporation identifier.")
|
||||
agent_id: str = Field(min_length=1, description="WeCom application agent identifier.")
|
||||
secret: _RequiredSecret = Field(description="WeCom application secret.")
|
||||
|
||||
def to_owner_credentials(self) -> WeComCredentials:
|
||||
return WeComCredentials(
|
||||
provider=self.provider,
|
||||
corp_id=self.corp_id,
|
||||
agent_id=self.agent_id,
|
||||
secret=_secret_value(self.secret),
|
||||
)
|
||||
|
||||
|
||||
type IMProviderCredentialsInput = Annotated[
|
||||
FeishuCredentialsInput
|
||||
| LarkCredentialsInput
|
||||
| SlackCredentialsInput
|
||||
| DingTalkCredentialsInput
|
||||
| MSTeamsCredentialsInput
|
||||
| WeComCredentialsInput,
|
||||
Field(discriminator="provider"),
|
||||
]
|
||||
|
||||
|
||||
class ResendCredentials(_SecretValidationMixin, StrictModel):
|
||||
provider: Literal[EmailProviderType.RESEND] = EmailProviderType.RESEND
|
||||
api_key: _RequiredSecret = Field(description="Resend API key.")
|
||||
|
||||
def to_owner_credentials(self) -> _ResendCredentials:
|
||||
return _ResendCredentials.model_construct(api_key=_secret_value(self.api_key))
|
||||
|
||||
|
||||
type EmailProviderCredentials = Annotated[ResendCredentials, Field(discriminator="provider")]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DingTalkCredentialsInput",
|
||||
"EmailProviderCredentials",
|
||||
"FeishuCredentialsInput",
|
||||
"IMProviderCredentialsInput",
|
||||
"LarkCredentialsInput",
|
||||
"MSTeamsCredentialsInput",
|
||||
"ResendCredentials",
|
||||
"SlackCredentialsInput",
|
||||
"WeComCredentialsInput",
|
||||
]
|
||||
@@ -1,3 +1,5 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
from libs.exception import BaseHTTPException
|
||||
|
||||
|
||||
@@ -55,6 +57,18 @@ class InvalidMemberRoleError(BaseHTTPException):
|
||||
code = 400
|
||||
|
||||
|
||||
class IMBindingReadOnlyError(BaseHTTPException):
|
||||
error_code = "im_binding_read_only"
|
||||
description = "Centrally managed IM bindings cannot be edited from a workspace."
|
||||
code = HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
class WorkspaceIMOverrideNotAllowedError(BaseHTTPException):
|
||||
error_code = "workspace_im_override_not_allowed"
|
||||
description = "Workspace IM overrides require centrally managed bindings."
|
||||
code = HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
class PluginOperationError(BaseHTTPException):
|
||||
error_code = "plugin_error"
|
||||
description = "Plugin operation failed."
|
||||
|
||||
@@ -8,9 +8,9 @@ paused human input forms in workflow/chatflow runs.
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from typing import Annotated
|
||||
from typing import Annotated, NoReturn
|
||||
|
||||
from flask import Response
|
||||
from flask import Response, request
|
||||
from flask_restx import Resource
|
||||
from pydantic import ConfigDict, WithJsonSchema
|
||||
from werkzeug.exceptions import BadRequest, NotFound
|
||||
@@ -21,20 +21,30 @@ from controllers.console.wraps import model_validate
|
||||
from controllers.service_api import service_api_ns
|
||||
from controllers.service_api.schema import expect_with_user
|
||||
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
|
||||
from core.db.session_factory import session_factory
|
||||
from core.human_input_v2.shared.values import TenantId
|
||||
from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface
|
||||
from core.workflow.nodes.human_input.entities import FormInputConfig, UserActionConfig
|
||||
from extensions.ext_database import db
|
||||
from factories.human_input_v2_form_presentation import build_form_presentation
|
||||
from fields.base import ResponseModel
|
||||
from libs.external_api import error_responses
|
||||
from libs.helper import to_timestamp
|
||||
from libs.helper import extract_remote_ip, to_timestamp
|
||||
from models.model import App, EndUser
|
||||
from repositories.human_input_v2.form_repository import Form as V2Form
|
||||
from services.human_input_service import (
|
||||
Form,
|
||||
FormExpiredError,
|
||||
FormNotFoundError,
|
||||
FormSubmittedError,
|
||||
HumanInputService,
|
||||
InvalidFormDataError,
|
||||
ServiceAPIHumanInputService,
|
||||
)
|
||||
from services.human_input_v2.submission_service import (
|
||||
HumanInputFormError,
|
||||
HumanInputFormErrorCode,
|
||||
HumanInputSubmissionService,
|
||||
ServiceAPIFormActor,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -56,7 +66,11 @@ class HumanInputFormSubmitResponse(ResponseModel):
|
||||
|
||||
|
||||
register_schema_models(service_api_ns, HumanInputFormSubmitPayload)
|
||||
register_response_schema_models(service_api_ns, HumanInputFormDefinitionResponse, HumanInputFormSubmitResponse)
|
||||
register_response_schema_models(
|
||||
service_api_ns,
|
||||
HumanInputFormDefinitionResponse,
|
||||
HumanInputFormSubmitResponse,
|
||||
)
|
||||
|
||||
|
||||
def _jsonify_form_definition(form: Form, *, inputs: Sequence[FormInputConfig] = ()) -> Response:
|
||||
@@ -83,6 +97,37 @@ def _ensure_form_is_allowed_for_service_api(form: Form) -> None:
|
||||
raise NotFound("Form not found")
|
||||
|
||||
|
||||
def _jsonify_v2_form_definition(form: V2Form) -> Response:
|
||||
presentation = build_form_presentation(form.resolved_form)
|
||||
response = HumanInputFormDefinitionResponse(
|
||||
form_content=presentation.form_content,
|
||||
inputs=presentation.inputs,
|
||||
resolved_default_values=presentation.resolved_default_values,
|
||||
user_actions=presentation.user_actions,
|
||||
expiration_time=to_timestamp(min(form.expiration_time, form.global_timeout_deadline)),
|
||||
)
|
||||
return Response(response.model_dump_json(), mimetype="application/json")
|
||||
|
||||
|
||||
def _raise_form_error(error: HumanInputFormError) -> NoReturn:
|
||||
# Keep the existing Service API errors instead of leaking Web App v2's
|
||||
# 409/410 statuses or its transport-specific error codes.
|
||||
match error.code:
|
||||
case HumanInputFormErrorCode.NOT_FOUND:
|
||||
raise NotFound("Form not found") from error
|
||||
case HumanInputFormErrorCode.ALREADY_SUBMITTED:
|
||||
assert error.form_id is not None
|
||||
raise FormSubmittedError(error.form_id) from error
|
||||
case HumanInputFormErrorCode.EXPIRED:
|
||||
assert error.form_id is not None
|
||||
raise FormExpiredError(error.form_id) from error
|
||||
case HumanInputFormErrorCode.INVALID_INPUT:
|
||||
description = str(error.__cause__) if error.__cause__ is not None else "Invalid form data"
|
||||
raise InvalidFormDataError(description) from error
|
||||
case HumanInputFormErrorCode.WORKFLOW_NOT_ACTIVE:
|
||||
raise BadRequest("The workflow is not paused with an active checkpoint.") from error
|
||||
|
||||
|
||||
@service_api_ns.route("/form/human_input/<string:form_token>")
|
||||
class WorkflowHumanInputFormApi(Resource):
|
||||
@error_responses(FormExpiredError, FormSubmittedError)
|
||||
@@ -121,10 +166,19 @@ class WorkflowHumanInputFormApi(Resource):
|
||||
)
|
||||
@validate_app_token
|
||||
def get(self, app_model: App, form_token: str):
|
||||
service = HumanInputService(db.engine)
|
||||
service = ServiceAPIHumanInputService(db.engine)
|
||||
form = service.get_form_by_token(form_token)
|
||||
if form is None:
|
||||
raise NotFound("Form not found")
|
||||
v2_service = HumanInputSubmissionService(session_factory=session_factory.get_session_maker())
|
||||
try:
|
||||
v2_form = v2_service.get_service_api_form(
|
||||
actor=ServiceAPIFormActor(tenant_id=TenantId(app_model.tenant_id), app_id=app_model.id),
|
||||
form_token=form_token,
|
||||
ip_address=extract_remote_ip(request) or None,
|
||||
)
|
||||
except HumanInputFormError as error:
|
||||
_raise_form_error(error)
|
||||
return _jsonify_v2_form_definition(v2_form)
|
||||
|
||||
_ensure_form_belongs_to_app(form, app_model)
|
||||
_ensure_form_is_allowed_for_service_api(form)
|
||||
@@ -176,10 +230,21 @@ class WorkflowHumanInputFormApi(Resource):
|
||||
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON, required=True))
|
||||
@model_validate(HumanInputFormSubmitPayload)
|
||||
def post(self, payload: HumanInputFormSubmitPayload, app_model: App, end_user: EndUser, form_token: str):
|
||||
service = HumanInputService(db.engine)
|
||||
service = ServiceAPIHumanInputService(db.engine)
|
||||
form = service.get_form_by_token(form_token)
|
||||
if form is None:
|
||||
raise NotFound("Form not found")
|
||||
v2_service = HumanInputSubmissionService(session_factory=session_factory.get_session_maker())
|
||||
try:
|
||||
v2_service.submit_service_api_form(
|
||||
actor=ServiceAPIFormActor(tenant_id=TenantId(app_model.tenant_id), app_id=app_model.id),
|
||||
end_user_id=end_user.id,
|
||||
form_token=form_token,
|
||||
action=payload.action,
|
||||
inputs=payload.inputs,
|
||||
)
|
||||
except HumanInputFormError as error:
|
||||
_raise_form_error(error)
|
||||
return {}, 200
|
||||
|
||||
_ensure_form_belongs_to_app(form, app_model)
|
||||
_ensure_form_is_allowed_for_service_api(form)
|
||||
|
||||
@@ -56,6 +56,9 @@ def _register_controllers() -> tuple[ModuleType, ...]:
|
||||
forgot_password,
|
||||
human_input_file_upload,
|
||||
human_input_form,
|
||||
human_input_form_access_request,
|
||||
human_input_v2,
|
||||
human_input_v2_file_upload,
|
||||
login,
|
||||
message,
|
||||
passport,
|
||||
@@ -76,6 +79,9 @@ def _register_controllers() -> tuple[ModuleType, ...]:
|
||||
forgot_password,
|
||||
human_input_file_upload,
|
||||
human_input_form,
|
||||
human_input_form_access_request,
|
||||
human_input_v2,
|
||||
human_input_v2_file_upload,
|
||||
login,
|
||||
message,
|
||||
passport,
|
||||
@@ -97,6 +103,9 @@ def _register_controllers() -> tuple[ModuleType, ...]:
|
||||
forgot_password,
|
||||
human_input_file_upload,
|
||||
human_input_form,
|
||||
human_input_form_access_request,
|
||||
human_input_v2,
|
||||
human_input_v2_file_upload,
|
||||
login,
|
||||
message,
|
||||
passport,
|
||||
@@ -121,6 +130,9 @@ __all__ = [
|
||||
"forgot_password",
|
||||
"human_input_file_upload",
|
||||
"human_input_form",
|
||||
"human_input_form_access_request",
|
||||
"human_input_v2",
|
||||
"human_input_v2_file_upload",
|
||||
"login",
|
||||
"message",
|
||||
"passport",
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Public web Human Input v2 form transport.
|
||||
|
||||
The versioned public routes are separate from legacy v1 forms and Web App
|
||||
passport-authenticated forms. Each runtime path must reject tokens owned by the other
|
||||
version when the service implementation is added.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import NoReturn
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
|
||||
from controllers.common.human_input_v2_contracts import (
|
||||
FormAccessRequestResponse,
|
||||
FormDefinitionResponse,
|
||||
FormSubmitResponse,
|
||||
FormUploadTokenResponse,
|
||||
HumanInputV2FormSubmitRequest,
|
||||
)
|
||||
from controllers.common.human_input_v2_errors import (
|
||||
HumanInputAccessDeliveryError,
|
||||
HumanInputAccessRateLimitError,
|
||||
HumanInputExpiredChallengeError,
|
||||
HumanInputInvalidOtpError,
|
||||
HumanInputStaleChallengeError,
|
||||
HumanInputV2InvalidInputError,
|
||||
HumanInputV2UnavailableError,
|
||||
HumanInputV2WorkflowNotActiveError,
|
||||
PublicHumanInputV2ExpiredError,
|
||||
PublicHumanInputV2NotFoundError,
|
||||
PublicHumanInputV2SubmittedError,
|
||||
email_otp_http_error,
|
||||
public_form_http_error,
|
||||
)
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console.wraps import model_validate
|
||||
from controllers.web import web_ns
|
||||
from controllers.web.human_input_file_upload import InvalidUploadTokenForbiddenError
|
||||
from core.db.session_factory import session_factory
|
||||
from factories.human_input_v2_form_presentation import build_form_presentation
|
||||
from libs.datetime_utils import to_utc_timestamp
|
||||
from libs.external_api import error_responses
|
||||
from libs.helper import dump_response, extract_remote_ip
|
||||
from services.human_input_file_upload_service import InvalidUploadTokenError
|
||||
from services.human_input_v2.email_otp_composition import build_email_otp_service
|
||||
from services.human_input_v2.email_otp_service import EmailOtpError
|
||||
from services.human_input_v2.file_upload_service import build_file_upload_service
|
||||
from services.human_input_v2.submission_service import (
|
||||
HumanInputFormError,
|
||||
HumanInputSubmissionService,
|
||||
)
|
||||
|
||||
register_schema_models(web_ns, HumanInputV2FormSubmitRequest)
|
||||
register_response_schema_models(
|
||||
web_ns,
|
||||
FormAccessRequestResponse,
|
||||
FormDefinitionResponse,
|
||||
FormSubmitResponse,
|
||||
FormUploadTokenResponse,
|
||||
)
|
||||
|
||||
|
||||
def _raise_public_form_error(error: HumanInputFormError) -> NoReturn:
|
||||
raise public_form_http_error(error) from error
|
||||
|
||||
|
||||
@web_ns.route("/form/human-input/v2/public/<string:form_token>")
|
||||
class HumanInputV2FormApi(Resource):
|
||||
"""Read or submit a Human Input v2 form without sharing v1 submission logic."""
|
||||
|
||||
@error_responses(PublicHumanInputV2NotFoundError, PublicHumanInputV2SubmittedError, PublicHumanInputV2ExpiredError)
|
||||
@web_ns.doc(
|
||||
description="Read a public v2 form definition by delivery token without an OTP or login session.",
|
||||
security=[],
|
||||
responses={404: "Form not found", 412: "Form already submitted or expired"},
|
||||
)
|
||||
@web_ns.response(200, "Form definition", web_ns.models[FormDefinitionResponse.__name__])
|
||||
def get(self, form_token: str):
|
||||
service = HumanInputSubmissionService(session_factory=session_factory.get_session_maker())
|
||||
try:
|
||||
form = service.get_public_form(form_token=form_token, ip_address=extract_remote_ip(request) or None)
|
||||
except HumanInputFormError as error:
|
||||
_raise_public_form_error(error)
|
||||
presentation = build_form_presentation(form.resolved_form)
|
||||
return dump_response(
|
||||
FormDefinitionResponse,
|
||||
{
|
||||
"form_content": presentation.form_content,
|
||||
"inputs": presentation.inputs,
|
||||
"resolved_default_values": presentation.resolved_default_values,
|
||||
"user_actions": presentation.user_actions,
|
||||
"expiration_time": to_utc_timestamp(min(form.expiration_time, form.global_timeout_deadline)),
|
||||
},
|
||||
)
|
||||
|
||||
@error_responses(
|
||||
PublicHumanInputV2NotFoundError,
|
||||
PublicHumanInputV2SubmittedError,
|
||||
PublicHumanInputV2ExpiredError,
|
||||
HumanInputV2WorkflowNotActiveError,
|
||||
HumanInputV2InvalidInputError,
|
||||
HumanInputExpiredChallengeError,
|
||||
HumanInputInvalidOtpError,
|
||||
HumanInputStaleChallengeError,
|
||||
HumanInputV2UnavailableError,
|
||||
)
|
||||
@web_ns.expect(web_ns.models[HumanInputV2FormSubmitRequest.__name__])
|
||||
@web_ns.doc(
|
||||
description="Submit a form with inputs, an action, and email verification proof.",
|
||||
security=[],
|
||||
responses={
|
||||
400: "Invalid form or email proof",
|
||||
404: "Form not found",
|
||||
409: "Workflow not active",
|
||||
412: "Already submitted or expired",
|
||||
422: "Invalid request body",
|
||||
503: "Email verification unavailable",
|
||||
},
|
||||
)
|
||||
@web_ns.response(200, "Form submitted", web_ns.models[FormSubmitResponse.__name__])
|
||||
@model_validate(HumanInputV2FormSubmitRequest)
|
||||
def post(self, payload: HumanInputV2FormSubmitRequest, form_token: str):
|
||||
service = HumanInputSubmissionService(session_factory=session_factory.get_session_maker())
|
||||
try:
|
||||
service.submit_public_form(
|
||||
email_otp_service=build_email_otp_service(),
|
||||
form_token=form_token,
|
||||
action=payload.action,
|
||||
inputs=payload.inputs,
|
||||
challenge_token=payload.challenge_token,
|
||||
otp_code=payload.otp_code,
|
||||
ip_address=extract_remote_ip(request) or None,
|
||||
)
|
||||
except EmailOtpError as error:
|
||||
raise email_otp_http_error(error) from error
|
||||
except HumanInputFormError as error:
|
||||
_raise_public_form_error(error)
|
||||
return FormSubmitResponse().model_dump(mode="json")
|
||||
|
||||
|
||||
@web_ns.route("/form/human-input/v2/public/<string:form_token>/upload-token")
|
||||
class HumanInputV2FormUploadTokenApi(Resource):
|
||||
"""Issue an upload token for an active Human Input v2 form."""
|
||||
|
||||
@error_responses(
|
||||
PublicHumanInputV2NotFoundError,
|
||||
PublicHumanInputV2SubmittedError,
|
||||
PublicHumanInputV2ExpiredError,
|
||||
InvalidUploadTokenForbiddenError,
|
||||
)
|
||||
@web_ns.response(200, "Success", web_ns.models[FormUploadTokenResponse.__name__])
|
||||
@web_ns.doc(security=[], responses={404: "Form not found", 412: "Already submitted or expired"})
|
||||
def post(self, form_token: str):
|
||||
try:
|
||||
grant = build_file_upload_service().issue_public_upload_token(form_token)
|
||||
except HumanInputFormError as error:
|
||||
_raise_public_form_error(error)
|
||||
except InvalidUploadTokenError as error:
|
||||
raise InvalidUploadTokenForbiddenError() from error
|
||||
return FormUploadTokenResponse(
|
||||
upload_token=grant.upload_token, expires_at=to_utc_timestamp(grant.expires_at)
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
@web_ns.route("/form/human-input/v2/public/<string:form_token>/access-request")
|
||||
class FormAccessRequestApi(Resource):
|
||||
@error_responses(
|
||||
PublicHumanInputV2NotFoundError,
|
||||
PublicHumanInputV2SubmittedError,
|
||||
PublicHumanInputV2ExpiredError,
|
||||
HumanInputAccessRateLimitError,
|
||||
HumanInputAccessDeliveryError,
|
||||
HumanInputV2UnavailableError,
|
||||
)
|
||||
@web_ns.response(200, "Success", web_ns.models[FormAccessRequestResponse.__name__])
|
||||
def post(self, form_token: str):
|
||||
try:
|
||||
challenge = build_email_otp_service().request_access(
|
||||
form_token, ip_address=extract_remote_ip(request) or None
|
||||
)
|
||||
except EmailOtpError as error:
|
||||
raise email_otp_http_error(error) from error
|
||||
return FormAccessRequestResponse(
|
||||
challenge_token=challenge.challenge_token,
|
||||
expires_in_seconds=challenge.expires_in_seconds,
|
||||
resend_after_seconds=challenge.resend_after_seconds,
|
||||
).model_dump(mode="json")
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Passport-authenticated Human Input v2 forms for the original Web App EndUser.
|
||||
|
||||
These routes are separate from the public Email-proof form surface. A delivery
|
||||
token locates the form; the app passport establishes who may read or submit it.
|
||||
"""
|
||||
|
||||
from flask import request
|
||||
from pydantic import BaseModel, ConfigDict, JsonValue
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.common.human_input_v2_errors import (
|
||||
HumanInputV2AlreadySubmittedError,
|
||||
HumanInputV2ExpiredError,
|
||||
HumanInputV2InvalidInputError,
|
||||
HumanInputV2WorkflowNotActiveError,
|
||||
form_http_error,
|
||||
)
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console.wraps import model_validate
|
||||
from controllers.web import web_ns
|
||||
from controllers.web.wraps import WebApiResource
|
||||
from core.db.session_factory import session_factory
|
||||
from core.human_input_v2.resolved_form import ResolvedForm
|
||||
from core.human_input_v2.shared.values import TenantId
|
||||
from fields.base import ResponseModel
|
||||
from libs.external_api import error_responses
|
||||
from libs.helper import dump_response, extract_remote_ip, to_timestamp
|
||||
from models.model import App, EndUser
|
||||
from services.human_input_v2.submission_service import (
|
||||
HumanInputFormError,
|
||||
HumanInputSubmissionService,
|
||||
WebAppFormActor,
|
||||
)
|
||||
|
||||
|
||||
class WebAppHumanInputV2SubmitPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", strict=True)
|
||||
|
||||
inputs: dict[str, JsonValue]
|
||||
action: str
|
||||
|
||||
|
||||
class WebAppHumanInputV2FormDefinitionResponse(ResponseModel):
|
||||
resolved_form: ResolvedForm
|
||||
expiration_time: int
|
||||
|
||||
|
||||
class WebAppHumanInputV2SubmitResponse(ResponseModel):
|
||||
pass
|
||||
|
||||
|
||||
register_schema_models(web_ns, WebAppHumanInputV2SubmitPayload)
|
||||
register_response_schema_models(web_ns, WebAppHumanInputV2FormDefinitionResponse, WebAppHumanInputV2SubmitResponse)
|
||||
|
||||
|
||||
def _web_app_actor(app_model: App, end_user: EndUser) -> WebAppFormActor:
|
||||
# The passport decoder loads App and EndUser independently. Establish their
|
||||
# ownership relationship before treating the session as an approval actor.
|
||||
if end_user.tenant_id != app_model.tenant_id or end_user.app_id != app_model.id:
|
||||
raise NotFound("Form not found")
|
||||
return WebAppFormActor(tenant_id=TenantId(app_model.tenant_id), app_id=app_model.id, end_user_id=end_user.id)
|
||||
|
||||
|
||||
@web_ns.route("/form/human-input/v2/<string:form_token>")
|
||||
class WebAppHumanInputV2FormApi(WebApiResource):
|
||||
@error_responses(HumanInputV2AlreadySubmittedError, HumanInputV2ExpiredError)
|
||||
@web_ns.doc(
|
||||
"get_web_app_human_input_v2_form",
|
||||
description="Read a v2 form using the original EndUser's Web App passport.",
|
||||
responses={401: "Invalid passport", 404: "Form not found", 409: "Already submitted", 410: "Expired"},
|
||||
)
|
||||
@web_ns.response(200, "Form retrieved", web_ns.models[WebAppHumanInputV2FormDefinitionResponse.__name__])
|
||||
def get(self, app_model: App, end_user: EndUser, form_token: str):
|
||||
actor = _web_app_actor(app_model, end_user)
|
||||
service = HumanInputSubmissionService(session_factory=session_factory.get_session_maker())
|
||||
try:
|
||||
form = service.get_web_app_form(
|
||||
actor=actor, form_token=form_token, ip_address=extract_remote_ip(request) or None
|
||||
)
|
||||
except HumanInputFormError as exc:
|
||||
raise form_http_error(exc, message="The approval could not be accepted.") from exc
|
||||
return dump_response(
|
||||
WebAppHumanInputV2FormDefinitionResponse,
|
||||
{
|
||||
"resolved_form": form.resolved_form,
|
||||
"expiration_time": to_timestamp(min(form.expiration_time, form.global_timeout_deadline)),
|
||||
},
|
||||
)
|
||||
|
||||
@error_responses(
|
||||
HumanInputV2AlreadySubmittedError,
|
||||
HumanInputV2ExpiredError,
|
||||
HumanInputV2WorkflowNotActiveError,
|
||||
HumanInputV2InvalidInputError,
|
||||
)
|
||||
@web_ns.doc(
|
||||
"submit_web_app_human_input_v2_form",
|
||||
description="Submit a v2 form using the original EndUser's Web App passport.",
|
||||
responses={
|
||||
400: "Invalid inputs",
|
||||
401: "Invalid passport",
|
||||
404: "Form not found",
|
||||
409: "Already submitted or workflow not active",
|
||||
410: "Expired",
|
||||
422: "Invalid request body",
|
||||
},
|
||||
)
|
||||
@web_ns.expect(web_ns.models[WebAppHumanInputV2SubmitPayload.__name__])
|
||||
@web_ns.response(200, "Approval accepted", web_ns.models[WebAppHumanInputV2SubmitResponse.__name__])
|
||||
@model_validate(WebAppHumanInputV2SubmitPayload)
|
||||
def post(self, payload: WebAppHumanInputV2SubmitPayload, app_model: App, end_user: EndUser, form_token: str):
|
||||
actor = _web_app_actor(app_model, end_user)
|
||||
service = HumanInputSubmissionService(session_factory=session_factory.get_session_maker())
|
||||
try:
|
||||
service.submit_web_app_form(
|
||||
actor=actor, form_token=form_token, action=payload.action, inputs=payload.inputs
|
||||
)
|
||||
except HumanInputFormError as exc:
|
||||
raise form_http_error(exc, message="The approval could not be accepted.") from exc
|
||||
return WebAppHumanInputV2SubmitResponse().model_dump(mode="json")
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Public bearer-only file upload transport for v2 approval grants."""
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ConfigDict, HttpUrl, ValidationError
|
||||
from werkzeug.exceptions import BadRequest
|
||||
|
||||
from controllers.common.errors import (
|
||||
BlockedFileExtensionError,
|
||||
FilenameNotExistsError,
|
||||
FileTooLargeError,
|
||||
NoFileUploadedError,
|
||||
RemoteFileAccessDeniedError,
|
||||
RemoteFileInvalidResponseError,
|
||||
RemoteFileInvalidUrlError,
|
||||
RemoteFileNotFoundError,
|
||||
RemoteFileUnavailableError,
|
||||
RemoteFileUrlBlockedError,
|
||||
TooManyFilesError,
|
||||
UnsupportedFileTypeError,
|
||||
)
|
||||
from controllers.common.human_input_v2_errors import (
|
||||
PublicHumanInputV2ExpiredError,
|
||||
PublicHumanInputV2NotFoundError,
|
||||
PublicHumanInputV2SubmittedError,
|
||||
)
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.web import web_ns
|
||||
from controllers.web.human_input_file_upload import (
|
||||
HumanInputFileUploadFormPayload,
|
||||
InvalidUploadTokenBadRequestError,
|
||||
InvalidUploadTokenForbiddenError,
|
||||
InvalidUploadTokenUnauthorizedError,
|
||||
)
|
||||
from controllers.web.human_input_form_access_request import _raise_public_form_error
|
||||
from fields.file_fields import FileWithSignedUrl
|
||||
from libs.external_api import error_responses
|
||||
from libs.helper import dump_response
|
||||
from services import remote_file_service as remote_errors
|
||||
from services.errors import file as file_errors
|
||||
from services.human_input_file_upload_service import InvalidUploadTokenError
|
||||
from services.human_input_v2.file_upload_service import V2_UPLOAD_TOKEN_PREFIX, build_file_upload_service
|
||||
from services.human_input_v2.submission_service import HumanInputFormError
|
||||
|
||||
register_response_schema_models(web_ns, FileWithSignedUrl)
|
||||
|
||||
|
||||
@web_ns.route("/human-input/v2/files")
|
||||
class HumanInputV2FileUploadApi(Resource):
|
||||
@error_responses(
|
||||
InvalidUploadTokenUnauthorizedError,
|
||||
InvalidUploadTokenBadRequestError,
|
||||
InvalidUploadTokenForbiddenError,
|
||||
PublicHumanInputV2NotFoundError,
|
||||
PublicHumanInputV2SubmittedError,
|
||||
PublicHumanInputV2ExpiredError,
|
||||
NoFileUploadedError,
|
||||
FilenameNotExistsError,
|
||||
TooManyFilesError,
|
||||
FileTooLargeError,
|
||||
UnsupportedFileTypeError,
|
||||
BlockedFileExtensionError,
|
||||
RemoteFileAccessDeniedError,
|
||||
RemoteFileInvalidResponseError,
|
||||
RemoteFileInvalidUrlError,
|
||||
RemoteFileNotFoundError,
|
||||
RemoteFileUnavailableError,
|
||||
RemoteFileUrlBlockedError,
|
||||
)
|
||||
@web_ns.doc(
|
||||
description="Upload a file or a remote URL using a v2 upload grant.",
|
||||
consumes=["multipart/form-data"],
|
||||
security=[],
|
||||
params={
|
||||
"Authorization": {
|
||||
"in": "header",
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"description": "Bearer v2 upload grant",
|
||||
},
|
||||
"file": {"in": "formData", "type": "file", "required": False},
|
||||
"url": {"in": "formData", "type": "string", "required": False, "description": "Remote HTTP(S) file URL"},
|
||||
},
|
||||
responses={
|
||||
400: "Invalid upload",
|
||||
401: "Grant required",
|
||||
403: "Invalid grant",
|
||||
404: "Form not found",
|
||||
412: "Form inactive",
|
||||
},
|
||||
)
|
||||
@web_ns.response(201, "File uploaded", web_ns.models[FileWithSignedUrl.__name__])
|
||||
def post(self):
|
||||
return _upload_file(remote=False)
|
||||
|
||||
|
||||
class HumanInputV2RemoteUploadPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
url: HttpUrl
|
||||
|
||||
|
||||
register_schema_models(web_ns, HumanInputV2RemoteUploadPayload)
|
||||
|
||||
|
||||
@web_ns.route("/human-input/v2/files/remote")
|
||||
class HumanInputV2RemoteFileUploadApi(Resource):
|
||||
@error_responses(
|
||||
InvalidUploadTokenUnauthorizedError,
|
||||
InvalidUploadTokenBadRequestError,
|
||||
InvalidUploadTokenForbiddenError,
|
||||
PublicHumanInputV2NotFoundError,
|
||||
PublicHumanInputV2SubmittedError,
|
||||
PublicHumanInputV2ExpiredError,
|
||||
NoFileUploadedError,
|
||||
FilenameNotExistsError,
|
||||
TooManyFilesError,
|
||||
FileTooLargeError,
|
||||
UnsupportedFileTypeError,
|
||||
BlockedFileExtensionError,
|
||||
RemoteFileAccessDeniedError,
|
||||
RemoteFileInvalidResponseError,
|
||||
RemoteFileInvalidUrlError,
|
||||
RemoteFileNotFoundError,
|
||||
RemoteFileUnavailableError,
|
||||
RemoteFileUrlBlockedError,
|
||||
)
|
||||
@web_ns.doc(
|
||||
description="Fetch a remote file using a v2 upload grant.",
|
||||
security=[],
|
||||
params={"Authorization": {"in": "header", "type": "string", "required": True}},
|
||||
responses={
|
||||
400: "Invalid upload",
|
||||
401: "Grant required",
|
||||
403: "Invalid grant",
|
||||
404: "Form not found",
|
||||
412: "Form inactive",
|
||||
},
|
||||
)
|
||||
@web_ns.expect(web_ns.models[HumanInputV2RemoteUploadPayload.__name__])
|
||||
@web_ns.response(201, "File uploaded", web_ns.models[FileWithSignedUrl.__name__])
|
||||
def post(self):
|
||||
return _upload_file(remote=True)
|
||||
|
||||
|
||||
def _upload_file(*, remote: bool):
|
||||
authorization = request.headers.get("Authorization", "").split()
|
||||
if len(authorization) != 2:
|
||||
raise InvalidUploadTokenUnauthorizedError()
|
||||
scheme, token = authorization
|
||||
if scheme.lower() != "bearer" or not token.startswith(V2_UPLOAD_TOKEN_PREFIX):
|
||||
raise InvalidUploadTokenBadRequestError()
|
||||
service = build_file_upload_service()
|
||||
try:
|
||||
context = service.validate_upload_token(token)
|
||||
except InvalidUploadTokenError as error:
|
||||
raise InvalidUploadTokenForbiddenError() from error
|
||||
except HumanInputFormError as error:
|
||||
_raise_public_form_error(error)
|
||||
try:
|
||||
if remote:
|
||||
payload = HumanInputV2RemoteUploadPayload.model_validate(web_ns.payload or {})
|
||||
form = HumanInputFileUploadFormPayload(url=payload.url)
|
||||
else:
|
||||
form = HumanInputFileUploadFormPayload.model_validate(request.form.to_dict(flat=True))
|
||||
except ValidationError as error:
|
||||
raise RemoteFileInvalidUrlError() from error
|
||||
if len(request.files.getlist("file")) > 1 or len(request.files) > 1:
|
||||
raise TooManyFilesError()
|
||||
if form.url is not None and request.files:
|
||||
raise BadRequest("Provide either a file or a URL.")
|
||||
try:
|
||||
if form.url is not None:
|
||||
uploaded = service.upload_remote_file(context=context, url=str(form.url))
|
||||
else:
|
||||
file = request.files.get("file")
|
||||
if file is None:
|
||||
raise NoFileUploadedError()
|
||||
if not file.filename:
|
||||
raise FilenameNotExistsError()
|
||||
uploaded = service.upload_local_file(
|
||||
context=context, filename=file.filename, content=file.read(), mimetype=file.mimetype
|
||||
)
|
||||
except file_errors.FileTooLargeError as error:
|
||||
raise FileTooLargeError(error.description or "File size exceeded.") from error
|
||||
except file_errors.UnsupportedFileTypeError as error:
|
||||
raise UnsupportedFileTypeError() from error
|
||||
except file_errors.BlockedFileExtensionError as error:
|
||||
raise BlockedFileExtensionError() from error
|
||||
except remote_errors.RemoteFileInvalidUrlError as error:
|
||||
raise RemoteFileInvalidUrlError() from error
|
||||
except remote_errors.RemoteFileUrlBlockedError as error:
|
||||
raise RemoteFileUrlBlockedError() from error
|
||||
except remote_errors.RemoteFileNotFoundError as error:
|
||||
raise RemoteFileNotFoundError() from error
|
||||
except remote_errors.RemoteFileAccessDeniedError as error:
|
||||
raise RemoteFileAccessDeniedError() from error
|
||||
except remote_errors.RemoteFileUnavailableError as error:
|
||||
raise RemoteFileUnavailableError() from error
|
||||
except remote_errors.RemoteFileInvalidResponseError as error:
|
||||
raise RemoteFileInvalidResponseError() from error
|
||||
return dump_response(FileWithSignedUrl, uploaded), 201
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Bounded, unauthenticated HTTP transport for Provider-authenticated callbacks."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from flask import Blueprint, Response, request
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from configs import dify_config
|
||||
from core.human_input_v2.im_integration.adapters.entities import WebhookRequest
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.helper import extract_remote_ip
|
||||
from repositories.human_input_v2.im_channel_repository import WebhookId
|
||||
from services.human_input_v2.im_webhook_runtime import build_im_webhook_ingress_service
|
||||
|
||||
bp = Blueprint("im_provider_webhook", __name__, url_prefix="/callbacks/human-input/v2/im")
|
||||
|
||||
|
||||
class _WebhookRoute(BaseModel):
|
||||
webhook_id: Annotated[str, Field(min_length=32, max_length=32, pattern=r"^[A-Za-z0-9_-]+$")]
|
||||
|
||||
|
||||
@bp.post("/<webhook_id>")
|
||||
def handle_im_provider_webhook(webhook_id: str) -> Response:
|
||||
received_at = naive_utc_now()
|
||||
try:
|
||||
route = _WebhookRoute(webhook_id=webhook_id)
|
||||
except ValidationError:
|
||||
return Response(status=404)
|
||||
maximum_bytes = dify_config.WEBHOOK_REQUEST_BODY_MAX_SIZE
|
||||
if request.content_length is not None and request.content_length > maximum_bytes:
|
||||
return Response(status=413)
|
||||
body = request.stream.read(maximum_bytes + 1)
|
||||
if len(body) > maximum_bytes:
|
||||
return Response(status=413)
|
||||
provider_response = build_im_webhook_ingress_service().handle(
|
||||
WebhookId(route.webhook_id),
|
||||
WebhookRequest(
|
||||
method=request.method,
|
||||
headers=tuple(request.headers.items()),
|
||||
body=body,
|
||||
received_at=received_at,
|
||||
request_ip_address=extract_remote_ip(request) or None,
|
||||
),
|
||||
)
|
||||
return Response(provider_response.body, status=provider_response.status_code, headers=provider_response.headers)
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, NewType, TypedDict
|
||||
|
||||
@@ -55,12 +56,13 @@ from core.workflow.human_input_forms import (
|
||||
load_form_dispositions_by_form_id,
|
||||
)
|
||||
from core.workflow.human_input_policy import (
|
||||
FormDisposition,
|
||||
HumanInputSurface,
|
||||
enrich_human_input_pause_reasons,
|
||||
resolve_human_input_pause_reason_inputs,
|
||||
resolve_variable_select_input_options,
|
||||
)
|
||||
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
|
||||
from core.workflow.nodes.human_input_v2.presentation import build_human_input_v2_pause_reason
|
||||
from core.workflow.nodes.human_input_v2.runtime import PreparedForm
|
||||
from core.workflow.system_variables import SystemVariableKey, system_variables_to_mapping
|
||||
from core.workflow.workflow_entry import WorkflowEntry
|
||||
from extensions.ext_database import db
|
||||
@@ -73,6 +75,7 @@ from graphon.enums import (
|
||||
)
|
||||
from graphon.file import FILE_MODEL_IDENTITY, File
|
||||
from graphon.runtime import RuntimeState
|
||||
from graphon.runtime.runtime_state_protocol import ReadOnlyVariablePool
|
||||
from graphon.variables.segments import ArrayFileSegment, FileSegment, Segment
|
||||
from graphon.variables.variables import Variable
|
||||
from graphon.workflow_type_encoder import WorkflowRuntimeTypeConverter
|
||||
@@ -108,7 +111,39 @@ class EndUserCreatedByDict(TypedDict):
|
||||
CreatedByDict = AccountCreatedByDict | EndUserCreatedByDict
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _HumanInputPauseOutput:
|
||||
"""Keep a form event paired with its entry in the workflow pause response."""
|
||||
|
||||
response: HumanInputRequiredResponse
|
||||
pause_reason: dict[str, Any]
|
||||
|
||||
|
||||
class WorkflowResponseConverter:
|
||||
@staticmethod
|
||||
def human_input_v2_required_data(prepared: PreparedForm, *, node_id: str) -> HumanInputRequiredResponse.Data:
|
||||
"""Project Dify-owned form data into the existing HITL wire contract.
|
||||
|
||||
Live execution supplies the node's snapshot; reconnect loads the form
|
||||
from its run-owned v2 records. Neither path resolves a v1 token.
|
||||
"""
|
||||
reason = build_human_input_v2_pause_reason(
|
||||
prepared.form.resolved_form, form_id=prepared.form.id, node_id=node_id
|
||||
)
|
||||
return HumanInputRequiredResponse.Data(
|
||||
form_version=reason.form_version,
|
||||
form_id=reason.form_id,
|
||||
node_id=node_id,
|
||||
node_title=reason.node_title,
|
||||
form_content=reason.form_content,
|
||||
inputs=reason.inputs,
|
||||
actions=reason.actions,
|
||||
resolved_default_values=reason.resolved_default_values,
|
||||
expiration_time=to_utc_timestamp(prepared.form.expiration_time),
|
||||
display_in_ui=prepared.form_token is not None,
|
||||
form_token=prepared.form_token.get_secret_value() if prepared.form_token is not None else None,
|
||||
)
|
||||
|
||||
_truncator: BaseTruncator
|
||||
|
||||
def __init__(
|
||||
@@ -332,75 +367,45 @@ class WorkflowResponseConverter:
|
||||
encoded_outputs = self._encode_outputs(event.outputs) or {}
|
||||
if self._application_generate_entity.invoke_from == InvokeFrom.SERVICE_API:
|
||||
encoded_outputs = {}
|
||||
variable_pool = graph_runtime_state.variable_pool
|
||||
resolved_reasons = resolve_human_input_pause_reason_inputs(
|
||||
event.reasons,
|
||||
variable_pool=variable_pool,
|
||||
v1_outputs = iter(
|
||||
self._human_input_v1_pause_responses(
|
||||
reasons=[
|
||||
reason
|
||||
for reason in event.reasons
|
||||
if isinstance(reason, HumanInputRequired) and reason.form_version == "1"
|
||||
],
|
||||
task_id=task_id,
|
||||
variable_pool=graph_runtime_state.variable_pool,
|
||||
)
|
||||
)
|
||||
pause_reasons = [reason.model_dump(mode="json") for reason in resolved_reasons]
|
||||
human_input_form_ids = [reason.form_id for reason in resolved_reasons if isinstance(reason, HumanInputRequired)]
|
||||
expiration_times_by_form_id: dict[str, datetime] = {}
|
||||
display_in_ui_by_form_id: dict[str, bool] = {}
|
||||
dispositions_by_form_id: dict[str, FormDisposition] = {}
|
||||
if human_input_form_ids:
|
||||
stmt = select(
|
||||
HumanInputForm.id,
|
||||
HumanInputForm.expiration_time,
|
||||
HumanInputForm.form_definition,
|
||||
).where(HumanInputForm.id.in_(human_input_form_ids))
|
||||
hitl_surface = _INVOKE_FROM_TO_HITL_SURFACE.get(self._application_generate_entity.invoke_from)
|
||||
with Session(bind=db.engine) as session:
|
||||
for form_id, expiration_time, form_definition in session.execute(stmt):
|
||||
expiration_times_by_form_id[str(form_id)] = expiration_time
|
||||
try:
|
||||
definition_payload = json.loads(form_definition) if form_definition else {}
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
definition_payload = {}
|
||||
display_in_ui_by_form_id[str(form_id)] = bool(definition_payload.get("display_in_ui"))
|
||||
dispositions_by_form_id = load_form_dispositions_by_form_id(
|
||||
human_input_form_ids,
|
||||
session=session,
|
||||
surface=hitl_surface,
|
||||
)
|
||||
|
||||
# Reconnect paths must preserve the same pause-reason contract as live streams;
|
||||
# otherwise clients see schema drift after resume.
|
||||
pause_reasons = enrich_human_input_pause_reasons(
|
||||
pause_reasons,
|
||||
dispositions_by_form_id=dispositions_by_form_id,
|
||||
expiration_times_by_form_id={
|
||||
form_id: to_utc_timestamp(expiration_time)
|
||||
for form_id, expiration_time in expiration_times_by_form_id.items()
|
||||
},
|
||||
v2_outputs = iter(
|
||||
self._human_input_v2_pause_responses(
|
||||
reasons=[
|
||||
reason
|
||||
for reason in event.reasons
|
||||
if isinstance(reason, HumanInputRequired) and reason.form_version == "2"
|
||||
],
|
||||
forms=event.human_input_v2_forms,
|
||||
task_id=task_id,
|
||||
)
|
||||
)
|
||||
|
||||
responses: list[StreamResponse] = []
|
||||
|
||||
for reason in resolved_reasons:
|
||||
if isinstance(reason, HumanInputRequired):
|
||||
expiration_time = expiration_times_by_form_id.get(reason.form_id)
|
||||
if expiration_time is None:
|
||||
raise ValueError(f"HumanInputForm not found for pause reason, form_id={reason.form_id}")
|
||||
disposition = dispositions_by_form_id.get(reason.form_id)
|
||||
responses.append(
|
||||
HumanInputRequiredResponse(
|
||||
task_id=task_id,
|
||||
workflow_run_id=run_id,
|
||||
data=HumanInputRequiredResponse.Data(
|
||||
form_id=reason.form_id,
|
||||
node_id=reason.node_id,
|
||||
node_title=reason.node_title,
|
||||
form_content=reason.form_content,
|
||||
inputs=reason.inputs,
|
||||
actions=reason.actions,
|
||||
display_in_ui=display_in_ui_by_form_id.get(reason.form_id, False),
|
||||
form_token=disposition.form_token if disposition else None,
|
||||
approval_channels=list(disposition.approval_channels) if disposition else [],
|
||||
resolved_default_values=reason.resolved_default_values,
|
||||
expiration_time=to_utc_timestamp(expiration_time),
|
||||
),
|
||||
)
|
||||
)
|
||||
pause_reasons: list[dict[str, Any]] = []
|
||||
# Each version owns its complete conversion; merge in the original event
|
||||
# order, including scheduling reasons interleaved with approval forms.
|
||||
for reason in event.reasons:
|
||||
if not isinstance(reason, HumanInputRequired):
|
||||
pause_reasons.append(reason.model_dump(mode="json"))
|
||||
continue
|
||||
match reason.form_version:
|
||||
case "1":
|
||||
converted = next(v1_outputs)
|
||||
case "2":
|
||||
converted = next(v2_outputs)
|
||||
case _:
|
||||
raise ValueError(f"Unsupported Human Input form version: {reason.form_version}")
|
||||
responses.append(converted.response)
|
||||
pause_reasons.append(converted.pause_reason)
|
||||
|
||||
responses.append(
|
||||
WorkflowPauseStreamResponse(
|
||||
@@ -930,3 +935,97 @@ class WorkflowResponseConverter:
|
||||
node_id=event.node_id,
|
||||
),
|
||||
)
|
||||
|
||||
def _human_input_v1_pause_responses(
|
||||
self,
|
||||
*,
|
||||
reasons: Sequence[HumanInputRequired],
|
||||
task_id: str,
|
||||
variable_pool: ReadOnlyVariablePool,
|
||||
) -> list[_HumanInputPauseOutput]:
|
||||
"""Resolve legacy inputs, access channels, and both V1 response payloads."""
|
||||
if not reasons:
|
||||
return []
|
||||
run_id = self._ensure_workflow_run_id()
|
||||
resolved_reasons = [
|
||||
reason.model_copy(
|
||||
update={
|
||||
"inputs": resolve_variable_select_input_options(reason.inputs, variable_pool=variable_pool),
|
||||
}
|
||||
)
|
||||
for reason in reasons
|
||||
]
|
||||
form_ids = [reason.form_id for reason in resolved_reasons]
|
||||
expiration_times: dict[str, datetime] = {}
|
||||
display_in_ui: dict[str, bool] = {}
|
||||
stmt = select(HumanInputForm.id, HumanInputForm.expiration_time, HumanInputForm.form_definition).where(
|
||||
HumanInputForm.id.in_(form_ids)
|
||||
)
|
||||
surface = _INVOKE_FROM_TO_HITL_SURFACE.get(self._application_generate_entity.invoke_from)
|
||||
with Session(bind=db.engine) as session:
|
||||
for form_id, expiration_time, form_definition in session.execute(stmt):
|
||||
expiration_times[str(form_id)] = expiration_time
|
||||
try:
|
||||
definition_payload = json.loads(form_definition) if form_definition else {}
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
definition_payload = {}
|
||||
display_in_ui[str(form_id)] = bool(definition_payload.get("display_in_ui"))
|
||||
dispositions = load_form_dispositions_by_form_id(form_ids, session=session, surface=surface)
|
||||
# Preserve the legacy pause-reason fields, which differ from the
|
||||
# human_input_required data payload (notably display_in_ui).
|
||||
pause_reasons = enrich_human_input_pause_reasons(
|
||||
[reason.model_dump(mode="json") for reason in resolved_reasons],
|
||||
dispositions_by_form_id=dispositions,
|
||||
expiration_times_by_form_id={
|
||||
form_id: to_utc_timestamp(value) for form_id, value in expiration_times.items()
|
||||
},
|
||||
)
|
||||
outputs: list[_HumanInputPauseOutput] = []
|
||||
for reason, payload in zip(resolved_reasons, pause_reasons, strict=True):
|
||||
expiration_time = expiration_times.get(reason.form_id)
|
||||
if expiration_time is None:
|
||||
raise ValueError(f"HumanInputForm not found for pause reason, form_id={reason.form_id}")
|
||||
disposition = dispositions.get(reason.form_id)
|
||||
response = HumanInputRequiredResponse(
|
||||
task_id=task_id,
|
||||
workflow_run_id=run_id,
|
||||
data=HumanInputRequiredResponse.Data(
|
||||
form_version=reason.form_version,
|
||||
form_id=reason.form_id,
|
||||
node_id=reason.node_id,
|
||||
node_title=reason.node_title,
|
||||
form_content=reason.form_content,
|
||||
inputs=reason.inputs,
|
||||
actions=reason.actions,
|
||||
display_in_ui=display_in_ui.get(reason.form_id, False),
|
||||
form_token=disposition.form_token if disposition else None,
|
||||
approval_channels=list(disposition.approval_channels) if disposition else [],
|
||||
resolved_default_values=reason.resolved_default_values,
|
||||
expiration_time=to_utc_timestamp(expiration_time),
|
||||
),
|
||||
)
|
||||
outputs.append(_HumanInputPauseOutput(response=response, pause_reason=payload))
|
||||
return outputs
|
||||
|
||||
def _human_input_v2_pause_responses(
|
||||
self,
|
||||
*,
|
||||
reasons: Sequence[HumanInputRequired],
|
||||
forms: Mapping[str, PreparedForm],
|
||||
task_id: str,
|
||||
) -> list[_HumanInputPauseOutput]:
|
||||
"""Build both V2 response payloads from the snapshots emitted by nodes."""
|
||||
if not reasons:
|
||||
return []
|
||||
run_id = self._ensure_workflow_run_id()
|
||||
outputs: list[_HumanInputPauseOutput] = []
|
||||
for reason in reasons:
|
||||
prepared = forms.get(reason.form_id)
|
||||
if prepared is None:
|
||||
raise ValueError(f"Human Input v2 node did not supply its form snapshot: {reason.form_id}")
|
||||
data = self.human_input_v2_required_data(prepared, node_id=reason.node_id)
|
||||
response = HumanInputRequiredResponse(task_id=task_id, workflow_run_id=run_id, data=data)
|
||||
payload = reason.model_dump(mode="json")
|
||||
payload.update(data.model_dump(mode="json"))
|
||||
outputs.append(_HumanInputPauseOutput(response=response, pause_reason=payload))
|
||||
return outputs
|
||||
@@ -58,6 +58,11 @@ from core.workflow.nodes.human_input.boundary import resolve_human_input_node_id
|
||||
from core.workflow.nodes.human_input.callback import DifyHITLCallback
|
||||
from core.workflow.nodes.human_input.enums import HumanInputFormStatus
|
||||
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired, PauseReason
|
||||
from core.workflow.nodes.human_input_v2.events import (
|
||||
NodeRunHumanInputV2FormFilledEvent,
|
||||
NodeRunHumanInputV2FormTimeoutEvent,
|
||||
)
|
||||
from core.workflow.nodes.human_input_v2.runtime import PreparedForm
|
||||
from core.workflow.system_variables import (
|
||||
build_bootstrap_variables,
|
||||
default_system_variables,
|
||||
@@ -108,7 +113,7 @@ from graphon.variable_loader import DUMMY_VARIABLE_LOADER, VariableLoader, load_
|
||||
from graphon.variables.factory import build_segment
|
||||
from models.workflow import Workflow
|
||||
from services.workflow_node_variables import load_additional_node_variables
|
||||
from tasks.mail_human_input_delivery_task import dispatch_human_input_email_task
|
||||
from tasks.mail_human_input_delivery_task import dispatch_human_input_form_delivery_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -587,6 +592,7 @@ class WorkflowBasedAppRunner:
|
||||
node_run_index: int = 1,
|
||||
node_execution_snapshots: tuple[NodeExecutionSnapshot, ...] = (),
|
||||
pause_reasons: Sequence[PauseReason] = (),
|
||||
human_input_v2_forms: Mapping[str, PreparedForm] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Handle event
|
||||
@@ -602,12 +608,13 @@ class WorkflowBasedAppRunner:
|
||||
loop_id = node_metadata.get(WorkflowNodeExecutionMetadataKey.LOOP_ID.value)
|
||||
match event:
|
||||
case NodeRunHumanInputFormFilledEvent():
|
||||
form_id = event.form_id if isinstance(event, NodeRunHumanInputV2FormFilledEvent) else event.id
|
||||
self._publish_event(
|
||||
QueueHumanInputFormFilledEvent(
|
||||
form_id=event.id,
|
||||
form_id=form_id,
|
||||
node_id=resolve_human_input_node_id(
|
||||
node_id=event.node_id,
|
||||
form_id=event.id,
|
||||
form_id=form_id,
|
||||
variable_pool=workflow_entry.graph_engine.runtime_state.variable_pool,
|
||||
),
|
||||
node_type=event.node_type,
|
||||
@@ -619,12 +626,13 @@ class WorkflowBasedAppRunner:
|
||||
)
|
||||
)
|
||||
case NodeRunHumanInputFormTimeoutEvent():
|
||||
form_id = event.form_id if isinstance(event, NodeRunHumanInputV2FormTimeoutEvent) else event.id
|
||||
self._publish_event(
|
||||
QueueHumanInputFormTimeoutEvent(
|
||||
form_id=event.id,
|
||||
form_id=form_id,
|
||||
node_id=resolve_human_input_node_id(
|
||||
node_id=event.node_id,
|
||||
form_id=event.id,
|
||||
form_id=form_id,
|
||||
variable_pool=workflow_entry.graph_engine.runtime_state.variable_pool,
|
||||
),
|
||||
node_type=event.node_type,
|
||||
@@ -657,12 +665,24 @@ class WorkflowBasedAppRunner:
|
||||
paused_nodes = list(
|
||||
dict.fromkeys(reason.node_id for reason in pause_reasons if isinstance(reason, HumanInputRequired))
|
||||
)
|
||||
self._enqueue_human_input_notifications(pause_reasons)
|
||||
forms = {
|
||||
reason.form_id: (human_input_v2_forms or {})[reason.form_id]
|
||||
for reason in pause_reasons
|
||||
if isinstance(reason, HumanInputRequired) and reason.form_version == "2"
|
||||
}
|
||||
self._enqueue_human_input_v1_notifications(
|
||||
[
|
||||
reason
|
||||
for reason in pause_reasons
|
||||
if isinstance(reason, HumanInputRequired) and reason.form_version == "1"
|
||||
]
|
||||
)
|
||||
self._publish_event(
|
||||
QueueWorkflowPausedEvent(
|
||||
reasons=list(pause_reasons),
|
||||
outputs=event.outputs,
|
||||
paused_nodes=paused_nodes,
|
||||
human_input_v2_forms=forms,
|
||||
)
|
||||
)
|
||||
case NodeRunRetryEvent():
|
||||
@@ -910,19 +930,17 @@ class WorkflowBasedAppRunner:
|
||||
)
|
||||
)
|
||||
|
||||
def _enqueue_human_input_notifications(self, reasons: Sequence[object]) -> None:
|
||||
def _enqueue_human_input_v1_notifications(self, reasons: Sequence[HumanInputRequired]) -> None:
|
||||
for reason in reasons:
|
||||
if not isinstance(reason, HumanInputRequired):
|
||||
continue
|
||||
if not reason.form_id:
|
||||
continue
|
||||
try:
|
||||
dispatch_human_input_email_task.apply_async(
|
||||
dispatch_human_input_form_delivery_task.apply_async(
|
||||
kwargs={"form_id": reason.form_id, "node_title": reason.node_title},
|
||||
queue="mail",
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive logging
|
||||
logger.exception("Failed to enqueue human input email task for form %s", reason.form_id)
|
||||
logger.exception("Failed to enqueue human input form delivery task for form %s", reason.form_id)
|
||||
|
||||
def _publish_event(self, event: AppQueueEvent):
|
||||
self._queue_manager.publish(event, PublishFrom.APPLICATION_MANAGER)
|
||||
@@ -8,6 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
from core.app.entities.agent_strategy import AgentStrategyInfo
|
||||
from core.rag.entities import RetrievalSourceMetadata
|
||||
from core.workflow.nodes.human_input.pause_reason import PauseReason
|
||||
from core.workflow.nodes.human_input_v2.runtime import PreparedForm
|
||||
from graphon.entities import WorkflowStartReason
|
||||
from graphon.enums import NodeType, WorkflowNodeExecutionMetadataKey
|
||||
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk
|
||||
@@ -595,3 +596,6 @@ class QueueWorkflowPausedEvent(AppQueueEvent):
|
||||
reasons: Sequence[PauseReason] = Field(default_factory=list)
|
||||
outputs: Mapping[str, object] = Field(default_factory=dict)
|
||||
paused_nodes: Sequence[str] = Field(default_factory=list)
|
||||
# Live node snapshots consumed by Dify's response converter, never included
|
||||
# in the public pause reason or the persisted graph resumption state.
|
||||
human_input_v2_forms: Mapping[str, PreparedForm] = Field(default_factory=dict, repr=False)
|
||||
@@ -285,6 +285,7 @@ class HumanInputRequiredResponse(StreamResponse):
|
||||
Data entity
|
||||
"""
|
||||
|
||||
form_version: Literal["1", "2"] = "1"
|
||||
form_id: str
|
||||
node_id: str
|
||||
node_title: str
|
||||
@@ -309,6 +310,7 @@ class HumanInputRequiredPauseReasonPayload(BaseModel):
|
||||
"""
|
||||
|
||||
TYPE: Literal[DifyHITLEventType.HUMAN_INPUT_REQUIRED] = DifyHITLEventType.HUMAN_INPUT_REQUIRED
|
||||
form_version: Literal["1", "2"] = "1"
|
||||
form_id: str
|
||||
node_id: str
|
||||
node_title: str
|
||||
@@ -324,6 +326,7 @@ class HumanInputRequiredPauseReasonPayload(BaseModel):
|
||||
@classmethod
|
||||
def from_response_data(cls, data: HumanInputRequiredResponse.Data) -> "HumanInputRequiredPauseReasonPayload":
|
||||
return cls(
|
||||
form_version=data.form_version,
|
||||
form_id=data.form_id,
|
||||
node_id=data.node_id,
|
||||
node_title=data.node_title,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any, TypeAlias
|
||||
from typing import Any, Literal, TypeAlias
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
||||
|
||||
@@ -13,6 +13,7 @@ class HumanInputFormDefinition(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
form_id: str
|
||||
form_version: Literal["1", "2"] = "1"
|
||||
node_id: str
|
||||
node_title: str
|
||||
form_content: str
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Channel-neutral Human Input values shared across runtime versions."""
|
||||
|
||||
from .enums import ButtonStyle
|
||||
|
||||
__all__ = ["ButtonStyle"]
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Channel-neutral Human Input enumerations."""
|
||||
|
||||
import enum
|
||||
|
||||
|
||||
class ButtonStyle(enum.StrEnum):
|
||||
"""Button styles for user actions."""
|
||||
|
||||
PRIMARY = enum.auto()
|
||||
DEFAULT = enum.auto()
|
||||
ACCENT = enum.auto()
|
||||
GHOST = enum.auto()
|
||||
@@ -0,0 +1 @@
|
||||
"""Channel-neutral Human Input v2 domain values."""
|
||||
@@ -0,0 +1,140 @@
|
||||
from enum import StrEnum
|
||||
from typing import NewType
|
||||
|
||||
from pydantic import NaiveDatetime
|
||||
|
||||
from core.human_input_v2.shared import AccountId, ContactId, NormalizedEmail, TenantId
|
||||
|
||||
|
||||
class HumanInputContactType(StrEnum):
|
||||
"""Concrete contact classification resolved in one workspace."""
|
||||
|
||||
WORKSPACE = "workspace"
|
||||
PLATFORM = "platform"
|
||||
EXTERNAL = "external"
|
||||
|
||||
|
||||
class ChannelKind(StrEnum):
|
||||
"""Human Input channel kind used by management contracts."""
|
||||
|
||||
EMAIL = "email"
|
||||
IM = "im"
|
||||
WEB = "web"
|
||||
CONSOLE = "console"
|
||||
|
||||
|
||||
class IMProvider(StrEnum):
|
||||
"""IM provider supported by Human Input contact and delivery flows."""
|
||||
|
||||
FEISHU = "feishu"
|
||||
SLACK = "slack"
|
||||
DING_TALK = "ding_talk"
|
||||
MS_TEAMS = "ms_teams"
|
||||
WE_COM = "we_com"
|
||||
LARK = "lark"
|
||||
|
||||
def supports_webhook(self) -> bool:
|
||||
"""Return whether the provider has a static inbound Webhook capability."""
|
||||
|
||||
return self in {
|
||||
IMProvider.SLACK,
|
||||
IMProvider.FEISHU,
|
||||
IMProvider.LARK,
|
||||
IMProvider.MS_TEAMS,
|
||||
}
|
||||
|
||||
|
||||
class DebugChannel(StrEnum):
|
||||
"""Delivery selection for editor debugging, resolved to current channel configuration."""
|
||||
|
||||
EMAIL = "email"
|
||||
FEISHU = IMProvider.FEISHU.value
|
||||
SLACK = IMProvider.SLACK.value
|
||||
DING_TALK = IMProvider.DING_TALK.value
|
||||
MS_TEAMS = IMProvider.MS_TEAMS.value
|
||||
WE_COM = IMProvider.WE_COM.value
|
||||
LARK = IMProvider.LARK.value
|
||||
|
||||
|
||||
class IMBindingScope(StrEnum):
|
||||
"""Resolution scope of a contact-to-IM-identity binding."""
|
||||
|
||||
WORKSPACE = "workspace"
|
||||
ORGANIZATION = "organization"
|
||||
|
||||
|
||||
class IMIdentityBindingStatus(StrEnum):
|
||||
"""Whether a synchronized IM identity is currently bound."""
|
||||
|
||||
UNBOUND = "unbound"
|
||||
BOUND = "bound"
|
||||
|
||||
|
||||
class IMSyncRunStatus(StrEnum):
|
||||
"""Lifecycle state of a manual IM directory synchronization."""
|
||||
|
||||
QUEUED = "queued"
|
||||
RUNNING = "running"
|
||||
SUCCEEDED = "succeeded"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class IMSyncResultType(StrEnum):
|
||||
"""Stable reconciliation bucket for one synchronized directory entry."""
|
||||
|
||||
ADDED = "added"
|
||||
NOT_MATCHED = "not_matched"
|
||||
FAILED = "failed"
|
||||
REMOVED = "removed"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class IMSyncRemovalReason(StrEnum):
|
||||
"""Stable reason for removing or replacing a current IM binding."""
|
||||
|
||||
NOT_PRESENT_IN_DIRECTORY = "not_present_in_directory"
|
||||
BINDING_INVALIDATED = "binding_invalidated"
|
||||
BINDING_REPLACED = "binding_replaced"
|
||||
|
||||
|
||||
class EmailProviderType(StrEnum):
|
||||
"""Email provider supported by organization-level Human Input delivery."""
|
||||
|
||||
RESEND = "resend"
|
||||
|
||||
|
||||
# Organization candidate identifiers round-trip as stable Contact IDs.
|
||||
OrganizationCandidateId = ContactId
|
||||
|
||||
# Identifiers for synced IM identiies. This is not the same as user_id or account_id
|
||||
# on the IM provier side. It is the identifier for the synced IM user record in Dify.
|
||||
IMIdentityId = NewType("IMIdentityId", str)
|
||||
|
||||
# Identifiers for a full IM user synchorization.
|
||||
IMSyncRunId = NewType("IMSyncRunId", str)
|
||||
|
||||
# Identifier for an IM binding, an association between an IM identity and a Dify contact.
|
||||
IMBindingId = NewType("IMBindingId", str)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AccountId",
|
||||
"ChannelKind",
|
||||
"ContactId",
|
||||
"DebugChannel",
|
||||
"EmailProviderType",
|
||||
"HumanInputContactType",
|
||||
"IMBindingId",
|
||||
"IMBindingScope",
|
||||
"IMIdentityBindingStatus",
|
||||
"IMIdentityId",
|
||||
"IMProvider",
|
||||
"IMSyncRemovalReason",
|
||||
"IMSyncResultType",
|
||||
"IMSyncRunId",
|
||||
"IMSyncRunStatus",
|
||||
"NaiveDatetime",
|
||||
"NormalizedEmail",
|
||||
"OrganizationCandidateId",
|
||||
"TenantId",
|
||||
]
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Infrastructure-free historical synchronization and reconciliation boundary."""
|
||||
|
||||
from .binding_commands import ContactIMBindingView, IMBindingCommandError, IMBindingCommandErrorCode
|
||||
from .change_log import (
|
||||
IMBindingChangeSnapshot,
|
||||
IMIdentityChangeSnapshot,
|
||||
IMReconciliationChange,
|
||||
IMReconciliationOperation,
|
||||
IMReconciliationSnapshot,
|
||||
IMReconciliationSubjectKind,
|
||||
)
|
||||
from .ports import (
|
||||
ActiveRunDecision,
|
||||
ActiveRunDecisionKind,
|
||||
ApplyReconciliationResult,
|
||||
ApplyReconciliationStatus,
|
||||
IMSyncRepository,
|
||||
ResolvedReconciliationWarning,
|
||||
)
|
||||
from .records import OpaqueProviderPayload
|
||||
from .sync_reconciliation import (
|
||||
BlockedReconciliation,
|
||||
ContactEmailMatchState,
|
||||
CreateIMBinding,
|
||||
CurrentIMBindingState,
|
||||
CurrentIMIdentityState,
|
||||
DeleteIMBinding,
|
||||
ExistingIMIdentityRef,
|
||||
IMBindingMutation,
|
||||
IMIdentityDeletion,
|
||||
IMIdentityRef,
|
||||
IMIdentityUpsert,
|
||||
IMIdentityUpsertKind,
|
||||
NewIMIdentityRef,
|
||||
PlanGenerationResult,
|
||||
PlannedReconciliationWarning,
|
||||
PlannedSyncResult,
|
||||
ReconciliationBlock,
|
||||
ReconciliationBlockCode,
|
||||
ReconciliationInput,
|
||||
ReconciliationPlan,
|
||||
ReconciliationReasonCode,
|
||||
ReconciliationRunRef,
|
||||
ReplaceIMBinding,
|
||||
SyncReconciler,
|
||||
)
|
||||
from .sync_records import (
|
||||
IMChannelRevision,
|
||||
IMSyncRun,
|
||||
StaleRevision,
|
||||
SyncContactSnapshot,
|
||||
SynchronizedIMIdentity,
|
||||
SynchronizedIMIdentityPage,
|
||||
SyncIdentitySnapshot,
|
||||
SyncResultFact,
|
||||
SyncResultPage,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ActiveRunDecision",
|
||||
"ActiveRunDecisionKind",
|
||||
"ApplyReconciliationResult",
|
||||
"ApplyReconciliationStatus",
|
||||
"BlockedReconciliation",
|
||||
"ContactEmailMatchState",
|
||||
"ContactIMBindingView",
|
||||
"CreateIMBinding",
|
||||
"CurrentIMBindingState",
|
||||
"CurrentIMIdentityState",
|
||||
"DeleteIMBinding",
|
||||
"ExistingIMIdentityRef",
|
||||
"IMBindingChangeSnapshot",
|
||||
"IMBindingCommandError",
|
||||
"IMBindingCommandErrorCode",
|
||||
"IMBindingMutation",
|
||||
"IMChannelRevision",
|
||||
"IMIdentityChangeSnapshot",
|
||||
"IMIdentityDeletion",
|
||||
"IMIdentityRef",
|
||||
"IMIdentityUpsert",
|
||||
"IMIdentityUpsertKind",
|
||||
"IMReconciliationChange",
|
||||
"IMReconciliationOperation",
|
||||
"IMReconciliationSnapshot",
|
||||
"IMReconciliationSubjectKind",
|
||||
"IMSyncRepository",
|
||||
"IMSyncRun",
|
||||
"NewIMIdentityRef",
|
||||
"OpaqueProviderPayload",
|
||||
"PlanGenerationResult",
|
||||
"PlannedReconciliationWarning",
|
||||
"PlannedSyncResult",
|
||||
"ReconciliationBlock",
|
||||
"ReconciliationBlockCode",
|
||||
"ReconciliationInput",
|
||||
"ReconciliationPlan",
|
||||
"ReconciliationReasonCode",
|
||||
"ReconciliationRunRef",
|
||||
"ReplaceIMBinding",
|
||||
"ResolvedReconciliationWarning",
|
||||
"StaleRevision",
|
||||
"SyncContactSnapshot",
|
||||
"SyncIdentitySnapshot",
|
||||
"SyncReconciler",
|
||||
"SyncResultFact",
|
||||
"SyncResultPage",
|
||||
"SynchronizedIMIdentity",
|
||||
"SynchronizedIMIdentityPage",
|
||||
]
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Stable types owned by the IM provider adapter boundary."""
|
||||
|
||||
from .credentials import (
|
||||
DingTalkCredentials,
|
||||
FeishuCredentials,
|
||||
IMProviderCredentials,
|
||||
IMProviderCredentialsAdapter,
|
||||
LarkCredentials,
|
||||
MSTeamsCredentials,
|
||||
SlackCredentials,
|
||||
WeComCredentials,
|
||||
)
|
||||
from .entities import (
|
||||
AuthenticatedIMEvent,
|
||||
CardAssessment,
|
||||
CorrelationToken,
|
||||
CredentialTestFailure,
|
||||
CredentialTestFailureKind,
|
||||
CredentialTestSuccess,
|
||||
Directory,
|
||||
DirectoryEntry,
|
||||
DirectoryReadFailure,
|
||||
DynamicCardMessagingError,
|
||||
EventAcceptance,
|
||||
IMCardEvent,
|
||||
IMCardEventDecodeResult,
|
||||
IMCardEventDecodingError,
|
||||
IMEventIngressKind,
|
||||
IMStreamStartError,
|
||||
IMStreamStopError,
|
||||
MessageAccepted,
|
||||
MessageSendingError,
|
||||
MessageSendingResult,
|
||||
ProviderUserId,
|
||||
ReplacementError,
|
||||
ReplacementErrorKind,
|
||||
StaticCardIntent,
|
||||
UnrecognizedIMEvent,
|
||||
WebhookRequest,
|
||||
WebhookResponse,
|
||||
)
|
||||
from .message_locator import MessageLocator
|
||||
from .protocols import (
|
||||
IMCardEventDecoder,
|
||||
IMDirectory,
|
||||
IMDynamicCardMessaging,
|
||||
IMEventConsumer,
|
||||
IMEventStream,
|
||||
IMMessaging,
|
||||
IMProviderAdapter,
|
||||
IMWebhookHandler,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AuthenticatedIMEvent",
|
||||
"CardAssessment",
|
||||
"CorrelationToken",
|
||||
"CredentialTestFailure",
|
||||
"CredentialTestFailureKind",
|
||||
"CredentialTestSuccess",
|
||||
"DingTalkCredentials",
|
||||
"Directory",
|
||||
"DirectoryEntry",
|
||||
"DirectoryReadFailure",
|
||||
"DynamicCardMessagingError",
|
||||
"EventAcceptance",
|
||||
"FeishuCredentials",
|
||||
"IMCardEvent",
|
||||
"IMCardEventDecodeResult",
|
||||
"IMCardEventDecoder",
|
||||
"IMCardEventDecodingError",
|
||||
"IMDirectory",
|
||||
"IMDynamicCardMessaging",
|
||||
"IMEventConsumer",
|
||||
"IMEventIngressKind",
|
||||
"IMEventStream",
|
||||
"IMMessaging",
|
||||
"IMProviderAdapter",
|
||||
"IMProviderCredentials",
|
||||
"IMProviderCredentialsAdapter",
|
||||
"IMStreamStartError",
|
||||
"IMStreamStopError",
|
||||
"IMWebhookHandler",
|
||||
"LarkCredentials",
|
||||
"MSTeamsCredentials",
|
||||
"MessageAccepted",
|
||||
"MessageLocator",
|
||||
"MessageSendingError",
|
||||
"MessageSendingResult",
|
||||
"ProviderUserId",
|
||||
"ReplacementError",
|
||||
"ReplacementErrorKind",
|
||||
"SlackCredentials",
|
||||
"StaticCardIntent",
|
||||
"UnrecognizedIMEvent",
|
||||
"WeComCredentials",
|
||||
"WebhookRequest",
|
||||
"WebhookResponse",
|
||||
]
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Download provider avatars without exposing transport details in directory data."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from email.message import Message
|
||||
from http import HTTPStatus
|
||||
from itertools import batched
|
||||
|
||||
import httpx
|
||||
|
||||
from core.file import remote_fetcher
|
||||
from core.human_input_v2.im_integration.adapters.entities import Avatar
|
||||
from core.tools.errors import ToolSSRFError
|
||||
|
||||
_DOWNLOAD_TIMEOUT_SECONDS = 10.0
|
||||
_MAX_CONCURRENT_DOWNLOADS = 8
|
||||
|
||||
|
||||
class AvatarReadError(Exception):
|
||||
"""An avatar could not be read, so the directory snapshot must fail."""
|
||||
|
||||
|
||||
def read_avatars(
|
||||
urls: Sequence[str | None],
|
||||
*,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> tuple[Avatar | None, ...]:
|
||||
"""Download unique URLs in batches of at most eight, preserving input order.
|
||||
|
||||
Missing URLs and HTTP 404 map to None. Other download errors propagate.
|
||||
"""
|
||||
unique_urls = tuple(dict.fromkeys(url for url in urls if url is not None))
|
||||
if not unique_urls:
|
||||
return (None,) * len(urls)
|
||||
|
||||
def download(url: str) -> Avatar | None:
|
||||
return _read_avatar(url, headers=headers)
|
||||
|
||||
by_url: dict[str, Avatar | None] = {}
|
||||
with ThreadPoolExecutor(max_workers=_MAX_CONCURRENT_DOWNLOADS) as executor:
|
||||
for batch in batched(unique_urls, _MAX_CONCURRENT_DOWNLOADS, strict=False):
|
||||
by_url.update(zip(batch, executor.map(download, batch), strict=True))
|
||||
return tuple(by_url[url] if url is not None else None for url in urls)
|
||||
|
||||
|
||||
def _read_avatar(url: str, *, headers: dict[str, str] | None) -> Avatar | None:
|
||||
"""Read image bytes through the remote-file boundary; HTTP 404 is None."""
|
||||
|
||||
response: httpx.Response | None = None
|
||||
try:
|
||||
response = remote_fetcher.make_request(
|
||||
"GET",
|
||||
url,
|
||||
headers=headers.copy() if headers is not None else None,
|
||||
timeout=_DOWNLOAD_TIMEOUT_SECONDS,
|
||||
max_retries=0,
|
||||
follow_redirects=True,
|
||||
)
|
||||
if response.status_code == HTTPStatus.NOT_FOUND:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
metadata = Message()
|
||||
metadata["Content-Type"] = response.headers.get("Content-Type", "application/octet-stream")
|
||||
if metadata.get_content_maintype() != "image" or not response.content:
|
||||
raise AvatarReadError("Provider avatar response is not a nonempty image")
|
||||
return Avatar(mime_type=metadata.get_content_type(), data=response.content)
|
||||
except (httpx.HTTPError, httpx.InvalidURL, ToolSSRFError) as error:
|
||||
raise AvatarReadError("Provider avatar could not be downloaded") from error
|
||||
finally:
|
||||
if response is not None:
|
||||
response.close()
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Canonical credentials accepted by IM provider adapters."""
|
||||
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
|
||||
|
||||
from core.human_input_v2.entities import IMProvider
|
||||
|
||||
|
||||
class _ProviderCredentials(BaseModel):
|
||||
"""Strict immutable credentials bound to one provider adapter lifetime."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
extra="forbid",
|
||||
frozen=True,
|
||||
strict=True,
|
||||
hide_input_in_errors=True,
|
||||
)
|
||||
|
||||
|
||||
class _FeishuLarkCredentials(_ProviderCredentials):
|
||||
"""Shared resolved credentials for Feishu and Lark adapters."""
|
||||
|
||||
app_id: str = Field(min_length=1, description="Provider application identifier.")
|
||||
app_secret: str = Field(min_length=1, repr=False, description="Resolved application secret.")
|
||||
verification_token: str | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
repr=False,
|
||||
description="Resolved callback verification token.",
|
||||
)
|
||||
encrypt_key: str | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
repr=False,
|
||||
description="Resolved callback encryption key.",
|
||||
)
|
||||
|
||||
|
||||
class FeishuCredentials(_FeishuLarkCredentials):
|
||||
"""Resolved Feishu credentials bound for one adapter lifetime."""
|
||||
|
||||
provider: Literal[IMProvider.FEISHU] = Field(description="Feishu credential discriminator.")
|
||||
|
||||
|
||||
class LarkCredentials(_FeishuLarkCredentials):
|
||||
"""Resolved Lark credentials bound for one adapter lifetime."""
|
||||
|
||||
provider: Literal[IMProvider.LARK] = Field(description="Lark credential discriminator.")
|
||||
|
||||
|
||||
class SlackCredentials(_ProviderCredentials):
|
||||
"""Resolved Slack credentials bound for one adapter lifetime."""
|
||||
|
||||
provider: Literal[IMProvider.SLACK] = Field(description="Slack credential discriminator.")
|
||||
client_id: str = Field(min_length=1, description="Slack OAuth client identifier.")
|
||||
client_secret: str = Field(min_length=1, repr=False, description="Resolved Slack OAuth client secret.")
|
||||
signing_secret: str = Field(min_length=1, repr=False, description="Resolved Slack callback signing secret.")
|
||||
bot_token: str = Field(
|
||||
min_length=1,
|
||||
pattern=r"^xoxb-",
|
||||
repr=False,
|
||||
description="Resolved Slack bot API token.",
|
||||
)
|
||||
app_token: str | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
pattern=r"^xapp-",
|
||||
repr=False,
|
||||
description="Optional resolved Slack app-level token required only for Socket Mode.",
|
||||
)
|
||||
|
||||
|
||||
class DingTalkCredentials(_ProviderCredentials):
|
||||
"""Resolved DingTalk credentials bound for one adapter lifetime."""
|
||||
|
||||
provider: Literal[IMProvider.DING_TALK] = Field(description="DingTalk credential discriminator.")
|
||||
corp_id: str = Field(min_length=1, pattern=r"\S", description="DingTalk corporation identifier.")
|
||||
client_id: str = Field(min_length=1, pattern=r"\S", description="DingTalk application client identifier.")
|
||||
client_secret: str = Field(
|
||||
min_length=1,
|
||||
pattern=r"\S",
|
||||
repr=False,
|
||||
description="Resolved DingTalk application client secret.",
|
||||
)
|
||||
|
||||
|
||||
class MSTeamsCredentials(_ProviderCredentials):
|
||||
"""Resolved Microsoft Teams credentials bound for one adapter lifetime."""
|
||||
|
||||
provider: Literal[IMProvider.MS_TEAMS] = Field(description="Microsoft Teams credential discriminator.")
|
||||
tenant_id: str = Field(
|
||||
min_length=1,
|
||||
pattern=r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
|
||||
description="Microsoft Entra tenant identifier.",
|
||||
)
|
||||
client_id: str = Field(
|
||||
min_length=1,
|
||||
pattern=r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
|
||||
description="Microsoft Teams bot application client identifier.",
|
||||
)
|
||||
client_secret: str = Field(
|
||||
min_length=1,
|
||||
repr=False,
|
||||
description="Resolved Microsoft Teams application client secret.",
|
||||
)
|
||||
|
||||
|
||||
class WeComCredentials(_ProviderCredentials):
|
||||
"""Resolved WeCom credentials bound for one adapter lifetime."""
|
||||
|
||||
provider: Literal[IMProvider.WE_COM] = Field(description="WeCom credential discriminator.")
|
||||
corp_id: str = Field(min_length=1, pattern=r"\S", description="WeCom corporation identifier.")
|
||||
agent_id: str = Field(
|
||||
min_length=1,
|
||||
pattern=r"^[1-9][0-9]*$",
|
||||
description="WeCom application agent identifier.",
|
||||
)
|
||||
secret: str = Field(
|
||||
min_length=1,
|
||||
pattern=r"\S",
|
||||
repr=False,
|
||||
description="Resolved WeCom application secret.",
|
||||
)
|
||||
|
||||
|
||||
type IMProviderCredentials = Annotated[
|
||||
(
|
||||
FeishuCredentials
|
||||
| LarkCredentials
|
||||
| SlackCredentials
|
||||
| DingTalkCredentials
|
||||
| MSTeamsCredentials
|
||||
| WeComCredentials
|
||||
),
|
||||
Field(discriminator="provider"),
|
||||
]
|
||||
|
||||
IMProviderCredentialsAdapter: TypeAdapter[IMProviderCredentials] = TypeAdapter(IMProviderCredentials)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DingTalkCredentials",
|
||||
"FeishuCredentials",
|
||||
"IMProviderCredentials",
|
||||
"IMProviderCredentialsAdapter",
|
||||
"LarkCredentials",
|
||||
"MSTeamsCredentials",
|
||||
"SlackCredentials",
|
||||
"WeComCredentials",
|
||||
]
|
||||
@@ -0,0 +1,595 @@
|
||||
"""Concrete DingTalk adapter for Provider-neutral Human Input IM contracts.
|
||||
|
||||
The root and its ordinary capabilities rely on the contract's external
|
||||
serialization rule and intentionally contain no synchronization or token
|
||||
cache. Credential testing bypasses the replaceable capability token provider
|
||||
so future caching cannot validate stale credentials.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import deque
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Protocol, override
|
||||
from urllib import error as urllib_error
|
||||
from urllib import parse as urllib_parse
|
||||
from urllib import request as urllib_request
|
||||
|
||||
from alibabacloud_dingtalk.oauth2_1_0.client import Client as OAuthClient
|
||||
from alibabacloud_dingtalk.oauth2_1_0.models import GetTokenRequest
|
||||
from alibabacloud_dingtalk.robot_1_0.client import Client as RobotClient
|
||||
from alibabacloud_dingtalk.robot_1_0.models import BatchSendOTOHeaders, BatchSendOTORequest
|
||||
from alibabacloud_tea_openapi.models import Config
|
||||
from alibabacloud_tea_util.models import RuntimeOptions
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
|
||||
from requests.exceptions import Timeout as RequestsTimeout
|
||||
from Tea.exceptions import TeaException
|
||||
|
||||
from core.human_input_v2.entities import IMProvider
|
||||
from core.human_input_v2.im_integration.adapters.avatar import read_avatars
|
||||
from core.human_input_v2.im_integration.adapters.credentials import DingTalkCredentials
|
||||
from core.human_input_v2.im_integration.adapters.entities import (
|
||||
CredentialTestFailure,
|
||||
CredentialTestFailureKind,
|
||||
CredentialTestSuccess,
|
||||
CredentialTestTimeoutError,
|
||||
Directory,
|
||||
DirectoryEntry,
|
||||
DirectoryReadFailure,
|
||||
IMProviderError,
|
||||
MessageAccepted,
|
||||
MessageSendingError,
|
||||
MessageSendingResult,
|
||||
ProviderUserId,
|
||||
)
|
||||
from core.human_input_v2.im_integration.adapters.message_locator import MessageLocator, _Base64JSONLocatorPayload
|
||||
from core.human_input_v2.im_integration.adapters.protocols import (
|
||||
IMCardEventDecoder,
|
||||
IMDirectory,
|
||||
IMDynamicCardMessaging,
|
||||
IMEventConsumer,
|
||||
IMEventStream,
|
||||
IMMessaging,
|
||||
IMWebhookHandler,
|
||||
)
|
||||
|
||||
_DEPARTMENT_LIST_URL = "https://oapi.dingtalk.com/topapi/v2/department/listsub"
|
||||
_USER_LIST_URL = "https://oapi.dingtalk.com/topapi/v2/user/list"
|
||||
_AUTHORIZATION_SCOPE_URL = "https://oapi.dingtalk.com/auth/scopes"
|
||||
_ROOT_DEPARTMENT_ID = 1
|
||||
_DIRECTORY_PAGE_SIZE = 100
|
||||
_HTTP_TIMEOUT_SECONDS = 5.0
|
||||
_SDK_TIMEOUT_MILLISECONDS = 5000
|
||||
_HTTP_SUCCESS_STATUS_MIN = 200
|
||||
_HTTP_SUCCESS_STATUS_MAX_EXCLUSIVE = 300
|
||||
|
||||
_CREDENTIAL_REJECTED = "DingTalk rejected the credential test."
|
||||
_AUTHORIZATION_SCOPE_UNVERIFIED = "DingTalk could not verify complete member authorization."
|
||||
_TENANT_BOUNDARY_UNAVAILABLE = "DingTalk could not verify the configured corporation directory boundary."
|
||||
_DIRECTORY_READ_FAILED = "DingTalk directory could not be read completely."
|
||||
_MESSAGE_ACCEPTANCE_UNCONFIRMED = "DingTalk message acceptance could not be confirmed."
|
||||
|
||||
|
||||
class AccessTokenProvider(Protocol):
|
||||
"""DingTalk-private seam for a currently usable application token."""
|
||||
|
||||
def get(self) -> str:
|
||||
"""Return a currently usable access token."""
|
||||
...
|
||||
|
||||
|
||||
class _OAuthClient(Protocol):
|
||||
def get_token(self, corp_id: str, request: GetTokenRequest) -> object: ...
|
||||
|
||||
|
||||
class _RobotClient(Protocol):
|
||||
def batch_send_otowith_options(
|
||||
self,
|
||||
request: BatchSendOTORequest,
|
||||
headers: BatchSendOTOHeaders,
|
||||
runtime: RuntimeOptions,
|
||||
) -> object: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _DirectoryHTTPResponse:
|
||||
body: bytes
|
||||
http_status: int
|
||||
|
||||
|
||||
class _DirectoryHTTPClient(Protocol):
|
||||
def get(self, url: str, *, access_token: str) -> _DirectoryHTTPResponse: ...
|
||||
|
||||
def post(self, url: str, *, access_token: str, body: dict[str, int]) -> _DirectoryHTTPResponse: ...
|
||||
|
||||
def close(self) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _AccessToken:
|
||||
value: str
|
||||
expires_in: int
|
||||
|
||||
|
||||
class _AccessTokenResponseBody(BaseModel):
|
||||
model_config = ConfigDict(strict=True, from_attributes=True)
|
||||
|
||||
access_token: str = Field(min_length=1)
|
||||
expires_in: int = Field(gt=0)
|
||||
|
||||
|
||||
class _AccessTokenResponse(BaseModel):
|
||||
model_config = ConfigDict(strict=True, from_attributes=True)
|
||||
|
||||
body: _AccessTokenResponseBody
|
||||
|
||||
|
||||
class _MessageSendResponseBody(BaseModel):
|
||||
model_config = ConfigDict(strict=True, from_attributes=True)
|
||||
|
||||
process_query_key: str = Field(pattern=r"\S")
|
||||
# The SDK may omit rejection lists when no recipients were rejected.
|
||||
filtered_staff_id_list: Sequence[object] | None = Field(default=None, max_length=0)
|
||||
flow_controlled_staff_id_list: Sequence[object] | None = Field(default=None, max_length=0)
|
||||
invalid_staff_id_list: Sequence[object] | None = Field(default=None, max_length=0)
|
||||
|
||||
|
||||
class _MessageSendResponse(BaseModel):
|
||||
model_config = ConfigDict(strict=True, from_attributes=True)
|
||||
|
||||
status_code: int = Field(ge=_HTTP_SUCCESS_STATUS_MIN, lt=_HTTP_SUCCESS_STATUS_MAX_EXCLUSIVE)
|
||||
body: _MessageSendResponseBody
|
||||
|
||||
|
||||
class _DirectoryHTTPError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class _DirectoryProviderRejectedError(_DirectoryHTTPError):
|
||||
def __init__(self, provider_error_code: int | None, *, http_status: int) -> None:
|
||||
super().__init__()
|
||||
self.provider_error_code = str(provider_error_code) if provider_error_code is not None else None
|
||||
self.http_status = http_status
|
||||
|
||||
|
||||
class _OAuthErrorData(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", strict=True)
|
||||
|
||||
status_code: int | None = Field(default=None, alias="statusCode")
|
||||
|
||||
|
||||
class _OAuthErrorResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", strict=True, hide_input_in_errors=True)
|
||||
|
||||
code: str | None = None
|
||||
data: _OAuthErrorData | None = None
|
||||
|
||||
|
||||
class _ProviderResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", strict=True)
|
||||
|
||||
errcode: int | None = None
|
||||
errmsg: str | None = None
|
||||
|
||||
|
||||
class _Department(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", strict=True)
|
||||
|
||||
dept_id: int = Field(ge=1)
|
||||
|
||||
|
||||
class _DepartmentListResponse(_ProviderResponse):
|
||||
result: tuple[_Department, ...] | None = None
|
||||
|
||||
|
||||
class _AuthorizationOrganizationScope(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", strict=True)
|
||||
|
||||
authed_dept: tuple[int, ...]
|
||||
authed_user: tuple[str, ...]
|
||||
|
||||
|
||||
class _AuthorizationScopeResponse(_ProviderResponse):
|
||||
auth_org_scopes: _AuthorizationOrganizationScope
|
||||
|
||||
|
||||
class _DirectoryUser(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", strict=True)
|
||||
|
||||
userid: str
|
||||
name: str | None = None
|
||||
email: str | None = None
|
||||
avatar_url: str | None = Field(default=None, alias="avatar")
|
||||
|
||||
@field_validator("avatar_url")
|
||||
@classmethod
|
||||
def _normalize_avatar_url(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.strip() or None
|
||||
|
||||
|
||||
class _UserPage(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", strict=True)
|
||||
|
||||
has_more: bool
|
||||
next_cursor: int | None = None
|
||||
users: tuple[_DirectoryUser, ...] = Field(alias="list")
|
||||
|
||||
|
||||
class _UserListResponse(_ProviderResponse):
|
||||
result: _UserPage | None = None
|
||||
|
||||
|
||||
class _DingTalkLocatorPayload(_Base64JSONLocatorPayload):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
|
||||
|
||||
# version of the locator
|
||||
v: Literal[1]
|
||||
# provider of the locator
|
||||
p: Literal[IMProvider.DING_TALK]
|
||||
# DingTalk process query key returned for the sent message:
|
||||
# https://open.dingtalk.com/document/orgapp/chatbots-send-one-on-one-chat-messages-in-batches.md
|
||||
process_query_key: str = Field(min_length=1, pattern=r"\S")
|
||||
|
||||
|
||||
class _SDKAccessTokenProvider(AccessTokenProvider):
|
||||
def __init__(self, credentials: DingTalkCredentials, client: _OAuthClient) -> None:
|
||||
self._credentials = credentials
|
||||
self._client = client
|
||||
|
||||
@override
|
||||
def get(self) -> str:
|
||||
return _get_access_token(self._client, self._credentials)
|
||||
|
||||
|
||||
class _UrllibDirectoryHTTPClient(_DirectoryHTTPClient):
|
||||
@override
|
||||
def get(self, url: str, *, access_token: str) -> _DirectoryHTTPResponse:
|
||||
target = f"{url}?{urllib_parse.urlencode({'access_token': access_token})}"
|
||||
request = urllib_request.Request(target, method="GET")
|
||||
with urllib_request.urlopen(request, timeout=_HTTP_TIMEOUT_SECONDS) as response:
|
||||
return _DirectoryHTTPResponse(body=response.read(), http_status=response.status)
|
||||
|
||||
@override
|
||||
def post(self, url: str, *, access_token: str, body: dict[str, int]) -> _DirectoryHTTPResponse:
|
||||
target = f"{url}?{urllib_parse.urlencode({'access_token': access_token})}"
|
||||
request = urllib_request.Request(
|
||||
target,
|
||||
data=json.dumps(body, separators=(",", ":")).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib_request.urlopen(request, timeout=_HTTP_TIMEOUT_SECONDS) as response:
|
||||
return _DirectoryHTTPResponse(body=response.read(), http_status=response.status)
|
||||
|
||||
@override
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _DingTalkDirectory(IMDirectory):
|
||||
def __init__(self, token_provider: AccessTokenProvider, http_client: _DirectoryHTTPClient) -> None:
|
||||
self._token_provider = token_provider
|
||||
self._http_client = http_client
|
||||
|
||||
@override
|
||||
def read_directory(self) -> Directory | DirectoryReadFailure:
|
||||
try:
|
||||
access_token = self._token_provider.get()
|
||||
return self._read_complete_directory(access_token)
|
||||
except Exception:
|
||||
return DirectoryReadFailure(_DIRECTORY_READ_FAILED)
|
||||
|
||||
def _read_complete_directory(self, access_token: str) -> Directory:
|
||||
users: list[_DirectoryUser] = []
|
||||
seen_user_ids: set[str] = set()
|
||||
pending_departments: deque[int] = deque((_ROOT_DEPARTMENT_ID,))
|
||||
discovered_departments = {_ROOT_DEPARTMENT_ID}
|
||||
|
||||
while pending_departments:
|
||||
department_id = pending_departments.popleft()
|
||||
for child in _department_children(self._http_client, access_token, department_id):
|
||||
if child.dept_id in discovered_departments:
|
||||
continue
|
||||
discovered_departments.add(child.dept_id)
|
||||
pending_departments.append(child.dept_id)
|
||||
|
||||
cursor = 0
|
||||
seen_cursors = {cursor}
|
||||
while True:
|
||||
page = _user_page(self._http_client, access_token, department_id, cursor)
|
||||
for user in page.users:
|
||||
if not user.userid.strip():
|
||||
raise _DirectoryHTTPError
|
||||
if user.userid in seen_user_ids:
|
||||
continue
|
||||
seen_user_ids.add(user.userid)
|
||||
users.append(user)
|
||||
if not page.has_more:
|
||||
break
|
||||
next_cursor = page.next_cursor
|
||||
if next_cursor is None or next_cursor in seen_cursors:
|
||||
raise _DirectoryHTTPError
|
||||
seen_cursors.add(next_cursor)
|
||||
cursor = next_cursor
|
||||
|
||||
avatars = read_avatars([user.avatar_url for user in users])
|
||||
return Directory(
|
||||
tuple(
|
||||
DirectoryEntry(
|
||||
ProviderUserId(user.userid),
|
||||
_optional_non_blank(user.name),
|
||||
_optional_non_blank(user.email),
|
||||
avatar=avatar,
|
||||
)
|
||||
for user, avatar in zip(users, avatars, strict=True)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class _DingTalkMessaging(IMMessaging):
|
||||
def __init__(
|
||||
self,
|
||||
robot_code: str,
|
||||
token_provider: AccessTokenProvider,
|
||||
client: _RobotClient,
|
||||
) -> None:
|
||||
self._robot_code = robot_code
|
||||
self._token_provider = token_provider
|
||||
self._client = client
|
||||
|
||||
@override
|
||||
def send_text(
|
||||
self, provider_user_id: ProviderUserId, body: str, subject: str | None = None
|
||||
) -> MessageSendingResult:
|
||||
if subject is not None:
|
||||
body = f"{subject}\n\n{body}"
|
||||
try:
|
||||
access_token = self._token_provider.get()
|
||||
response = self._client.batch_send_otowith_options(
|
||||
BatchSendOTORequest(
|
||||
msg_key="sampleText",
|
||||
msg_param=json.dumps({"content": body}, ensure_ascii=False, separators=(",", ":")),
|
||||
robot_code=self._robot_code,
|
||||
user_ids=[str(provider_user_id)],
|
||||
),
|
||||
BatchSendOTOHeaders(x_acs_dingtalk_access_token=access_token),
|
||||
RuntimeOptions(
|
||||
autoretry=False,
|
||||
max_attempts=1,
|
||||
connect_timeout=_SDK_TIMEOUT_MILLISECONDS,
|
||||
read_timeout=_SDK_TIMEOUT_MILLISECONDS,
|
||||
),
|
||||
)
|
||||
accepted_response = _MessageSendResponse.model_validate(response)
|
||||
return MessageAccepted(
|
||||
MessageLocator(
|
||||
_DingTalkLocatorPayload(
|
||||
v=1,
|
||||
p=IMProvider.DING_TALK,
|
||||
process_query_key=accepted_response.body.process_query_key,
|
||||
).encode()
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
return MessageSendingError(_MESSAGE_ACCEPTANCE_UNCONFIRMED)
|
||||
|
||||
|
||||
class DingTalkIMProviderAdapter:
|
||||
"""Externally serialized DingTalk capability composition root."""
|
||||
|
||||
@classmethod
|
||||
def card_event_decoder(cls) -> IMCardEventDecoder | None:
|
||||
return None
|
||||
|
||||
def __init__(self, credentials: DingTalkCredentials) -> None:
|
||||
if not isinstance(credentials, DingTalkCredentials):
|
||||
raise TypeError("DingTalk adapter requires resolved DingTalk credentials")
|
||||
self._credentials = credentials
|
||||
self._credential_test_client = _new_oauth_client()
|
||||
token_provider = _SDKAccessTokenProvider(credentials, _new_oauth_client())
|
||||
self._http_client = _new_http_client()
|
||||
self._directory = _DingTalkDirectory(token_provider, self._http_client)
|
||||
self._messaging = _DingTalkMessaging(credentials.client_id, token_provider, _new_robot_client())
|
||||
self._closed = False
|
||||
|
||||
@property
|
||||
def provider(self) -> IMProvider:
|
||||
return IMProvider.DING_TALK
|
||||
|
||||
def test_credentials(self) -> CredentialTestSuccess | CredentialTestFailure:
|
||||
try:
|
||||
return self._test_credentials()
|
||||
except (TimeoutError, RequestsTimeout) as error:
|
||||
raise CredentialTestTimeoutError() from error
|
||||
except urllib_error.HTTPError as error:
|
||||
try:
|
||||
rejection = _ProviderResponse.model_validate_json(error.read())
|
||||
except ValidationError:
|
||||
provider_error_code = None
|
||||
else:
|
||||
provider_error_code = str(rejection.errcode) if rejection.errcode is not None else None
|
||||
return CredentialTestFailure(
|
||||
CredentialTestFailureKind.UNKNOWN, _CREDENTIAL_REJECTED, provider_error_code, http_status=error.code
|
||||
)
|
||||
except urllib_error.URLError as error:
|
||||
if isinstance(error.reason, TimeoutError):
|
||||
raise CredentialTestTimeoutError() from error
|
||||
raise
|
||||
|
||||
def _test_credentials(self) -> CredentialTestSuccess | CredentialTestFailure:
|
||||
try:
|
||||
access_token = _get_access_token(self._credential_test_client, self._credentials)
|
||||
except TeaException as error:
|
||||
rejection = _OAuthErrorResponse.model_validate({"code": error.code, "data": error.data})
|
||||
status_code = rejection.data.status_code if rejection.data is not None else None
|
||||
if rejection.code is None and status_code is None:
|
||||
raise
|
||||
kind = (
|
||||
CredentialTestFailureKind.AUTHENTICATION_REJECTED
|
||||
if status_code in {400, 401, 403}
|
||||
else CredentialTestFailureKind.UNKNOWN
|
||||
)
|
||||
return CredentialTestFailure(kind, _CREDENTIAL_REJECTED, rejection.code, http_status=status_code)
|
||||
|
||||
try:
|
||||
complete_authorization = _has_complete_member_authorization(self._http_client, access_token)
|
||||
except _DirectoryProviderRejectedError as error:
|
||||
return CredentialTestFailure(
|
||||
CredentialTestFailureKind.UNKNOWN,
|
||||
_AUTHORIZATION_SCOPE_UNVERIFIED,
|
||||
error.provider_error_code,
|
||||
http_status=error.http_status,
|
||||
)
|
||||
if not complete_authorization:
|
||||
return CredentialTestFailure(
|
||||
CredentialTestFailureKind.PERMISSION_DENIED,
|
||||
_AUTHORIZATION_SCOPE_UNVERIFIED,
|
||||
)
|
||||
baseline_failure = _verify_root_boundary(self._http_client, access_token)
|
||||
if baseline_failure is not None:
|
||||
return baseline_failure
|
||||
return CredentialTestSuccess(IMProvider.DING_TALK, self._credentials.corp_id)
|
||||
|
||||
@property
|
||||
def directory(self) -> IMDirectory:
|
||||
return self._directory
|
||||
|
||||
@property
|
||||
def messaging(self) -> IMMessaging:
|
||||
return self._messaging
|
||||
|
||||
@property
|
||||
def dynamic_card_messaging(self) -> IMDynamicCardMessaging | None:
|
||||
return None
|
||||
|
||||
def create_webhook_handler(self, consumer: IMEventConsumer) -> IMWebhookHandler | None:
|
||||
del consumer
|
||||
return None
|
||||
|
||||
def create_stream_handler(self, consumer: IMEventConsumer) -> IMEventStream | None:
|
||||
del consumer
|
||||
return None
|
||||
|
||||
def close(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
self._http_client.close()
|
||||
|
||||
|
||||
def _new_oauth_client() -> OAuthClient:
|
||||
return OAuthClient(_sdk_config())
|
||||
|
||||
|
||||
def _new_robot_client() -> RobotClient:
|
||||
return RobotClient(_sdk_config())
|
||||
|
||||
|
||||
def _new_http_client() -> _DirectoryHTTPClient:
|
||||
return _UrllibDirectoryHTTPClient()
|
||||
|
||||
|
||||
def _sdk_config() -> Config:
|
||||
return Config(
|
||||
protocol="https",
|
||||
connect_timeout=_SDK_TIMEOUT_MILLISECONDS,
|
||||
read_timeout=_SDK_TIMEOUT_MILLISECONDS,
|
||||
)
|
||||
|
||||
|
||||
def _get_access_token(client: _OAuthClient, credentials: DingTalkCredentials) -> str:
|
||||
return _request_access_token(client, credentials).value
|
||||
|
||||
|
||||
def _request_access_token(client: _OAuthClient, credentials: DingTalkCredentials) -> _AccessToken:
|
||||
response = client.get_token(
|
||||
credentials.corp_id,
|
||||
GetTokenRequest(
|
||||
client_id=credentials.client_id,
|
||||
client_secret=credentials.client_secret,
|
||||
grant_type="client_credentials",
|
||||
),
|
||||
)
|
||||
try:
|
||||
response_body = _AccessTokenResponse.model_validate(response).body
|
||||
except ValidationError as error:
|
||||
raise IMProviderError(f"DingTalk returned an invalid access token response: {error}") from error
|
||||
return _AccessToken(response_body.access_token, response_body.expires_in)
|
||||
|
||||
|
||||
def _verify_root_boundary(http_client: _DirectoryHTTPClient, access_token: str) -> CredentialTestFailure | None:
|
||||
try:
|
||||
_department_children(http_client, access_token, _ROOT_DEPARTMENT_ID)
|
||||
except _DirectoryProviderRejectedError as error:
|
||||
return CredentialTestFailure(
|
||||
CredentialTestFailureKind.TENANT_ID_UNAVAILABLE,
|
||||
_TENANT_BOUNDARY_UNAVAILABLE,
|
||||
error.provider_error_code,
|
||||
http_status=error.http_status,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _has_complete_member_authorization(
|
||||
http_client: _DirectoryHTTPClient,
|
||||
access_token: str,
|
||||
) -> bool:
|
||||
response_body = http_client.get(_AUTHORIZATION_SCOPE_URL, access_token=access_token)
|
||||
scope = _parse_response(response_body, _AuthorizationScopeResponse).auth_org_scopes
|
||||
return _ROOT_DEPARTMENT_ID in scope.authed_dept
|
||||
|
||||
|
||||
def _department_children(
|
||||
http_client: _DirectoryHTTPClient,
|
||||
access_token: str,
|
||||
department_id: int,
|
||||
) -> tuple[_Department, ...]:
|
||||
response_body = http_client.post(
|
||||
_DEPARTMENT_LIST_URL,
|
||||
access_token=access_token,
|
||||
body={"dept_id": department_id},
|
||||
)
|
||||
payload = _parse_response(response_body, _DepartmentListResponse)
|
||||
if payload.result is None:
|
||||
raise IMProviderError("DingTalk did not provide department results.")
|
||||
return payload.result
|
||||
|
||||
|
||||
def _user_page(
|
||||
http_client: _DirectoryHTTPClient,
|
||||
access_token: str,
|
||||
department_id: int,
|
||||
cursor: int,
|
||||
) -> _UserPage:
|
||||
response_body = http_client.post(
|
||||
_USER_LIST_URL,
|
||||
access_token=access_token,
|
||||
body={"dept_id": department_id, "cursor": cursor, "size": _DIRECTORY_PAGE_SIZE},
|
||||
)
|
||||
payload = _parse_response(response_body, _UserListResponse)
|
||||
if payload.result is None:
|
||||
raise IMProviderError("DingTalk did not provide user results.")
|
||||
return payload.result
|
||||
|
||||
|
||||
def _parse_response[ResponseT: _ProviderResponse](
|
||||
response_body: _DirectoryHTTPResponse,
|
||||
response_type: type[ResponseT],
|
||||
) -> ResponseT:
|
||||
rejection = _ProviderResponse.model_validate_json(response_body.body)
|
||||
if rejection.errcode != 0:
|
||||
raise _DirectoryProviderRejectedError(rejection.errcode, http_status=response_body.http_status)
|
||||
return response_type.model_validate_json(response_body.body)
|
||||
|
||||
|
||||
def _optional_non_blank(value: str | None) -> str | None:
|
||||
if value is None or not value.strip():
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
__all__ = ["DingTalkIMProviderAdapter"]
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Redis-backed access-token caching for the concrete DingTalk adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Protocol
|
||||
|
||||
from redis import RedisError
|
||||
|
||||
from core.human_input_v2.im_integration.adapters.credentials import DingTalkCredentials
|
||||
from core.human_input_v2.im_integration.adapters.dingtalk import (
|
||||
_new_oauth_client,
|
||||
_OAuthClient,
|
||||
_request_access_token,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CACHE_KEY_PREFIX = "dify:human-input-v2:dingtalk:access-token:"
|
||||
_EXPIRY_SAFETY_MARGIN_SECONDS = 60
|
||||
|
||||
|
||||
class _RedisCacheClient(Protocol):
|
||||
def get(self, name: str) -> object: ...
|
||||
|
||||
def ttl(self, name: str) -> object: ...
|
||||
|
||||
def set(self, name: str, value: str, *, ex: int) -> object: ...
|
||||
|
||||
|
||||
class RedisCacheAccessTokenProvider:
|
||||
"""Provide DingTalk access tokens with a shared Redis optimization."""
|
||||
|
||||
def __init__(self, credentials: DingTalkCredentials, cache: _RedisCacheClient) -> None:
|
||||
if not isinstance(credentials, DingTalkCredentials):
|
||||
raise TypeError("DingTalk token provider requires resolved DingTalk credentials")
|
||||
self._credentials = credentials
|
||||
self._cache = cache
|
||||
self._client: _OAuthClient = _new_oauth_client()
|
||||
self._cache_key = _cache_key(credentials.client_id)
|
||||
|
||||
def get(self) -> str:
|
||||
cached_token = self._read_cached_token()
|
||||
if cached_token is not None:
|
||||
return cached_token
|
||||
|
||||
fresh_token = _request_access_token(self._client, self._credentials)
|
||||
cache_ttl = fresh_token.expires_in - _EXPIRY_SAFETY_MARGIN_SECONDS
|
||||
if cache_ttl > 0:
|
||||
try:
|
||||
self._cache.set(self._cache_key, fresh_token.value, ex=cache_ttl)
|
||||
except RedisError:
|
||||
logger.warning("Failed to write DingTalk access-token cache; returning the fresh token.")
|
||||
return fresh_token.value
|
||||
|
||||
def _read_cached_token(self) -> str | None:
|
||||
try:
|
||||
cached_value = self._cache.get(self._cache_key)
|
||||
cached_token = _decode_cached_token(cached_value)
|
||||
if cached_token is None:
|
||||
return None
|
||||
ttl = self._cache.ttl(self._cache_key)
|
||||
except UnicodeDecodeError:
|
||||
logger.warning("Ignored a malformed DingTalk access-token cache entry.")
|
||||
return None
|
||||
except RedisError:
|
||||
logger.warning("Failed to read DingTalk access-token cache; requesting a fresh token.")
|
||||
return None
|
||||
|
||||
if isinstance(ttl, int) and not isinstance(ttl, bool) and ttl > 0:
|
||||
return cached_token
|
||||
return None
|
||||
|
||||
|
||||
def _cache_key(client_id: str) -> str:
|
||||
digest = hashlib.sha256(client_id.encode()).hexdigest()
|
||||
return f"{_CACHE_KEY_PREFIX}{digest}"
|
||||
|
||||
|
||||
def _decode_cached_token(value: object) -> str | None:
|
||||
if isinstance(value, bytes):
|
||||
token = value.decode("utf-8")
|
||||
elif isinstance(value, str):
|
||||
token = value
|
||||
else:
|
||||
return None
|
||||
return token if token.strip() else None
|
||||
|
||||
|
||||
__all__ = ["RedisCacheAccessTokenProvider"]
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Stable values, results, and errors shared by IM provider adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from types import MappingProxyType
|
||||
from typing import NewType
|
||||
|
||||
from pydantic import ConfigDict, JsonValue, NaiveDatetime, TypeAdapter, ValidationError
|
||||
|
||||
from core.human_input_v2.entities import IMProvider
|
||||
|
||||
from .message_locator import MessageLocator
|
||||
|
||||
ProviderUserId = NewType("ProviderUserId", str)
|
||||
CorrelationToken = NewType("CorrelationToken", str)
|
||||
|
||||
|
||||
class CredentialTestFailureKind(StrEnum):
|
||||
"""Provider failures; UNKNOWN is an unclassified provider error response."""
|
||||
|
||||
AUTHENTICATION_REJECTED = "authentication_rejected"
|
||||
PERMISSION_DENIED = "permission_denied"
|
||||
TENANT_ID_UNAVAILABLE = "tenant_id_unavailable"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CredentialTestSuccess:
|
||||
"""Confirmed provider authentication and stable tenant identity."""
|
||||
|
||||
provider: IMProvider
|
||||
provider_tenant_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CredentialTestFailure:
|
||||
"""Provider rejection or confirmed credential limitation, not an execution error."""
|
||||
|
||||
kind: CredentialTestFailureKind
|
||||
reason: str
|
||||
provider_error_code: str | None = None
|
||||
missing_permissions: tuple[str, ...] = ()
|
||||
http_status: int | None = None
|
||||
|
||||
|
||||
class IMProviderError(Exception):
|
||||
"""A provider operation failed with a message suitable for configuration callers."""
|
||||
|
||||
|
||||
class CredentialTestTimeoutError(IMProviderError):
|
||||
"""A credential-test request timed out; the original error is its cause."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("The provider credential test request timed out.")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Avatar:
|
||||
"""Downloaded provider image, independent of transport and credentials."""
|
||||
|
||||
mime_type: str
|
||||
data: bytes
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DirectoryEntry:
|
||||
"""Provider identity and downloaded avatar from one directory snapshot."""
|
||||
|
||||
provider_user_id: ProviderUserId
|
||||
display_name: str | None
|
||||
email: str | None
|
||||
avatar: Avatar | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Directory:
|
||||
"""Immutable ordered complete provider directory snapshot."""
|
||||
|
||||
entries: tuple[DirectoryEntry, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DirectoryReadFailure:
|
||||
"""Safe whole-snapshot failure without partial entries."""
|
||||
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StaticCardIntent:
|
||||
"""Caller-rendered non-interactive replacement presentation."""
|
||||
|
||||
rendered_content: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CardAssessment:
|
||||
"""Side-effect-free whole-intent representability decision."""
|
||||
|
||||
representable: bool
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MessageAccepted:
|
||||
"""Confirmed provider acceptance, not end-user delivery proof."""
|
||||
|
||||
locator: MessageLocator
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MessageSendingError:
|
||||
"""Message creation without confirmed provider acceptance."""
|
||||
|
||||
reason: str
|
||||
|
||||
|
||||
type MessageSendingResult = MessageAccepted | MessageSendingError
|
||||
|
||||
|
||||
class ReplacementErrorKind(StrEnum):
|
||||
"""Stable exact-message replacement failure categories."""
|
||||
|
||||
INVALID_REFERENCE = "invalid_reference"
|
||||
STALE_REFERENCE = "stale_reference"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReplacementError:
|
||||
"""Static replacement failure without accepted mutation content."""
|
||||
|
||||
kind: ReplacementErrorKind
|
||||
reason: str
|
||||
|
||||
|
||||
class DynamicCardMessagingError(Exception):
|
||||
"""Complete dynamic card intent cannot be represented by the provider."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WebhookRequest:
|
||||
"""Framework-neutral request preserving verification-critical values."""
|
||||
|
||||
method: str
|
||||
headers: tuple[tuple[str, str], ...]
|
||||
body: bytes
|
||||
received_at: NaiveDatetime
|
||||
# None when the transport cannot determine the request IP.
|
||||
request_ip_address: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WebhookResponse:
|
||||
"""Framework-neutral response containing exact provider ACK facts."""
|
||||
|
||||
status_code: int
|
||||
headers: tuple[tuple[str, str], ...]
|
||||
body: bytes
|
||||
|
||||
|
||||
class IMEventIngressKind(StrEnum):
|
||||
"""Ingress contract used to construct the provider payload snapshot."""
|
||||
|
||||
WEBHOOK = "webhook"
|
||||
STREAM = "stream"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthenticatedIMEvent:
|
||||
"""Authenticated provider evidence before consumer-specific decoding."""
|
||||
|
||||
provider: IMProvider
|
||||
provider_tenant_id: str
|
||||
event_id: str | None
|
||||
event_type: str | None
|
||||
occurred_at: NaiveDatetime | None
|
||||
received_at: NaiveDatetime
|
||||
ingress_kind: IMEventIngressKind
|
||||
payload: str
|
||||
|
||||
|
||||
_CARD_EVENT_INPUTS_ADAPTER = TypeAdapter(
|
||||
dict[str, JsonValue],
|
||||
config=ConfigDict(strict=True, allow_inf_nan=False),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IMCardEvent:
|
||||
"""Provider-neutral normalized card interaction."""
|
||||
|
||||
provider_user_id: ProviderUserId
|
||||
action_id: str
|
||||
inputs: Mapping[str, JsonValue]
|
||||
correlation_token: CorrelationToken
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.action_id, str) or not self.action_id:
|
||||
raise ValueError("card event action identifier must not be empty")
|
||||
if not isinstance(self.inputs, Mapping):
|
||||
raise TypeError("card event inputs must be a mapping")
|
||||
try:
|
||||
copied_inputs = _CARD_EVENT_INPUTS_ADAPTER.validate_python(dict(self.inputs), strict=True)
|
||||
except ValidationError:
|
||||
raise TypeError("card event inputs must contain only JSON values") from None
|
||||
object.__setattr__(self, "inputs", MappingProxyType(copied_inputs))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UnrecognizedIMEvent:
|
||||
"""Authenticated event outside one decoder's supported card protocol."""
|
||||
|
||||
|
||||
type IMCardEventDecodeResult = IMCardEvent | UnrecognizedIMEvent
|
||||
|
||||
|
||||
class IMCardEventDecodingError(ValueError):
|
||||
"""Recognized card event cannot be decoded using the expected schema."""
|
||||
|
||||
|
||||
class EventAcceptance(StrEnum):
|
||||
"""Consumer result controlling provider acknowledgement."""
|
||||
|
||||
ACCEPTED = "accepted"
|
||||
NOT_ACCEPTED = "not_accepted"
|
||||
|
||||
|
||||
class IMStreamStartError(Exception):
|
||||
"""Operator-safe synchronous stream startup failure."""
|
||||
|
||||
|
||||
class IMStreamStopError(Exception):
|
||||
"""Operator-safe failure to establish the graceful-stop guarantees."""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AuthenticatedIMEvent",
|
||||
"Avatar",
|
||||
"CardAssessment",
|
||||
"CorrelationToken",
|
||||
"CredentialTestFailure",
|
||||
"CredentialTestFailureKind",
|
||||
"CredentialTestSuccess",
|
||||
"CredentialTestTimeoutError",
|
||||
"Directory",
|
||||
"DirectoryEntry",
|
||||
"DirectoryReadFailure",
|
||||
"DynamicCardMessagingError",
|
||||
"EventAcceptance",
|
||||
"IMCardEvent",
|
||||
"IMCardEventDecodeResult",
|
||||
"IMCardEventDecodingError",
|
||||
"IMEventIngressKind",
|
||||
"IMProviderError",
|
||||
"IMStreamStartError",
|
||||
"IMStreamStopError",
|
||||
"MessageAccepted",
|
||||
"MessageSendingError",
|
||||
"MessageSendingResult",
|
||||
"ProviderUserId",
|
||||
"ReplacementError",
|
||||
"ReplacementErrorKind",
|
||||
"StaticCardIntent",
|
||||
"UnrecognizedIMEvent",
|
||||
"WebhookRequest",
|
||||
"WebhookResponse",
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Construction of complete IM provider adapters from canonical credentials."""
|
||||
|
||||
from .credentials import (
|
||||
DingTalkCredentials,
|
||||
FeishuCredentials,
|
||||
IMProviderCredentials,
|
||||
LarkCredentials,
|
||||
MSTeamsCredentials,
|
||||
SlackCredentials,
|
||||
WeComCredentials,
|
||||
)
|
||||
from .dingtalk import DingTalkIMProviderAdapter
|
||||
from .feishu_lark import FeishuIMProviderAdapter, LarkIMProviderAdapter
|
||||
from .ms_teams import MSTeamsIMProviderAdapter
|
||||
from .protocols import IMProviderAdapter
|
||||
from .slack import SlackIMProviderAdapter
|
||||
from .wecom import WeComIMProviderAdapter
|
||||
|
||||
|
||||
def build_im_provider_adapter(credentials: IMProviderCredentials) -> IMProviderAdapter:
|
||||
match credentials:
|
||||
case SlackCredentials():
|
||||
return SlackIMProviderAdapter(credentials)
|
||||
case FeishuCredentials():
|
||||
return FeishuIMProviderAdapter(credentials)
|
||||
case LarkCredentials():
|
||||
return LarkIMProviderAdapter(credentials)
|
||||
case DingTalkCredentials():
|
||||
return DingTalkIMProviderAdapter(credentials)
|
||||
case MSTeamsCredentials():
|
||||
return MSTeamsIMProviderAdapter(credentials)
|
||||
case WeComCredentials():
|
||||
return WeComIMProviderAdapter(credentials)
|
||||
case _:
|
||||
raise TypeError("unsupported IM provider credentials")
|
||||
|
||||
|
||||
__all__ = ["build_im_provider_adapter"]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,50 @@
|
||||
"""Canonical type and codec for provider-owned message locators."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import re
|
||||
from typing import NewType, Self
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Opaque, persistable locator for one exact provider message.
|
||||
#
|
||||
# Callers may store, compare, and return this value to a compatible adapter,
|
||||
# but must not parse, alter, or synthesize it.
|
||||
#
|
||||
# The value is a plain, versioned serialization of provider-private locator
|
||||
# facts. It may cross process boundaries and survive adapter recreation.
|
||||
# Keep this value within a trusted application boundary; it must not cross
|
||||
# a security boundary.
|
||||
# "Opaque" constrains caller behavior; it does not imply encryption, signing,
|
||||
# cryptographic authenticity, or authorization.
|
||||
MessageLocator = NewType("MessageLocator", str)
|
||||
|
||||
_URLSAFE_BASE64_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
|
||||
|
||||
class _Base64JSONLocatorPayload(BaseModel):
|
||||
"""Base model giving each private payload one canonical string codec."""
|
||||
|
||||
def encode(self) -> MessageLocator:
|
||||
serialized_payload = self.model_dump_json().encode("utf-8")
|
||||
encoded_payload = base64.urlsafe_b64encode(serialized_payload).rstrip(b"=").decode("ascii")
|
||||
return MessageLocator(encoded_payload)
|
||||
|
||||
@classmethod
|
||||
def decode(cls, value: MessageLocator | str) -> Self:
|
||||
if not _URLSAFE_BASE64_PATTERN.fullmatch(value):
|
||||
raise ValueError("locator must be unpadded URL-safe Base64")
|
||||
if len(value) % 4 == 1:
|
||||
raise ValueError("locator has invalid Base64 length")
|
||||
padding = "=" * (-len(value) % 4)
|
||||
serialized_payload = base64.b64decode(
|
||||
value + padding,
|
||||
altchars=b"-_",
|
||||
validate=True,
|
||||
)
|
||||
return cls.model_validate_json(serialized_payload)
|
||||
|
||||
|
||||
__all__ = ["MessageLocator", "_Base64JSONLocatorPayload"]
|
||||
@@ -0,0 +1,991 @@
|
||||
"""Concrete Microsoft Teams adapter for Provider-neutral IM contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import binascii
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from contextlib import closing
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from email.message import Message
|
||||
from http import HTTPStatus
|
||||
from string import punctuation
|
||||
from typing import ClassVar, Literal, override
|
||||
from urllib.parse import quote, urlsplit
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from azure.core.exceptions import ClientAuthenticationError, ServiceRequestTimeoutError, ServiceResponseTimeoutError
|
||||
from azure.identity import ClientSecretCredential
|
||||
from botbuilder.schema import (
|
||||
Activity,
|
||||
Attachment,
|
||||
ChannelAccount,
|
||||
ConversationParameters,
|
||||
ConversationResourceResponse,
|
||||
ResourceResponse,
|
||||
)
|
||||
from botframework.connector import ConnectorClient
|
||||
from botframework.connector.auth import (
|
||||
JwtTokenValidation,
|
||||
MicrosoftAppCredentials,
|
||||
SimpleCredentialProvider,
|
||||
)
|
||||
from msrest.exceptions import HttpOperationError
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, JsonValue, ValidationError, field_validator
|
||||
from requests.exceptions import Timeout as RequestsTimeout
|
||||
|
||||
from core.human_input import ButtonStyle
|
||||
from core.human_input_v2.entities import IMProvider
|
||||
from core.human_input_v2.im_integration.adapters.avatar import AvatarReadError, read_avatars
|
||||
from core.human_input_v2.im_integration.adapters.credentials import MSTeamsCredentials
|
||||
from core.human_input_v2.im_integration.adapters.entities import (
|
||||
AuthenticatedIMEvent,
|
||||
CardAssessment,
|
||||
CorrelationToken,
|
||||
CredentialTestFailure,
|
||||
CredentialTestFailureKind,
|
||||
CredentialTestSuccess,
|
||||
CredentialTestTimeoutError,
|
||||
Directory,
|
||||
DirectoryEntry,
|
||||
DirectoryReadFailure,
|
||||
DynamicCardMessagingError,
|
||||
EventAcceptance,
|
||||
IMCardEvent,
|
||||
IMCardEventDecodeResult,
|
||||
IMCardEventDecodingError,
|
||||
IMEventIngressKind,
|
||||
IMProviderError,
|
||||
MessageAccepted,
|
||||
MessageSendingError,
|
||||
MessageSendingResult,
|
||||
ProviderUserId,
|
||||
ReplacementError,
|
||||
ReplacementErrorKind,
|
||||
StaticCardIntent,
|
||||
UnrecognizedIMEvent,
|
||||
WebhookRequest,
|
||||
WebhookResponse,
|
||||
)
|
||||
from core.human_input_v2.im_integration.adapters.message_locator import MessageLocator, _Base64JSONLocatorPayload
|
||||
from core.human_input_v2.im_integration.adapters.protocols import (
|
||||
IMCardEventDecoder,
|
||||
IMDirectory,
|
||||
IMDynamicCardMessaging,
|
||||
IMEventConsumer,
|
||||
IMEventStream,
|
||||
IMMessaging,
|
||||
IMWebhookHandler,
|
||||
)
|
||||
from core.human_input_v2.resolved_form import (
|
||||
FileInput,
|
||||
FileListInput,
|
||||
MarkdownFragment,
|
||||
ParagraphInput,
|
||||
ResolvedForm,
|
||||
SelectInput,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_HTTP_TIMEOUT_SECONDS = 10.0
|
||||
_GRAPH_SCOPE = "https://graph.microsoft.com/.default"
|
||||
_GRAPH_USERS_URL = "https://graph.microsoft.com/v1.0/users"
|
||||
_GRAPH_DIRECTORY_PAGE_SIZE = 999
|
||||
_GRAPH_CREDENTIAL_TEST_PAGE_SIZE = 1
|
||||
_PUBLIC_TEAMS_SERVICE_URL = "https://smba.trafficmanager.net/teams/"
|
||||
_JSON_CONTENT_TYPE = "application/json"
|
||||
_EXTENDED_MARKDOWN_TEXT_FORMAT = "extendedmarkdown"
|
||||
_TEAMS_BOT_CHANNEL_ID_PREFIX = "28:"
|
||||
_BOT_TOKEN_REJECTION_PREFIX = "Failed to get access token with error: "
|
||||
_OAUTH_CREDENTIAL_REJECTION_CODES = frozenset(("invalid_client", "invalid_grant", "unauthorized_client"))
|
||||
|
||||
|
||||
class _GraphError(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", strict=True)
|
||||
|
||||
code: str | None = None
|
||||
|
||||
|
||||
class _GraphErrorResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", strict=True)
|
||||
|
||||
error: _GraphError | None = None
|
||||
|
||||
|
||||
class _GraphUser(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", frozen=True)
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
display_name: str | None = Field(default=None, alias="displayName")
|
||||
mail: str | None = None
|
||||
|
||||
@field_validator("display_name", "mail", mode="before")
|
||||
@classmethod
|
||||
def normalize_optional_text(cls, value: object) -> object:
|
||||
if isinstance(value, str) and not value.strip():
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
class _GraphUsersPage(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", frozen=True)
|
||||
|
||||
users: tuple[_GraphUser, ...] = Field(alias="value")
|
||||
next_link: str | None = Field(default=None, alias="@odata.nextLink")
|
||||
|
||||
|
||||
class _TeamsConversation(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", frozen=True)
|
||||
|
||||
tenant_id: str | None = Field(default=None, alias="tenantId")
|
||||
|
||||
|
||||
class _TeamsTenant(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", frozen=True)
|
||||
|
||||
id: str
|
||||
|
||||
|
||||
class _TeamsChannelData(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", frozen=True)
|
||||
|
||||
tenant: _TeamsTenant | None = None
|
||||
|
||||
|
||||
class _TeamsInboundActivity(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", frozen=True)
|
||||
|
||||
type: str = Field(min_length=1)
|
||||
id: str | None = None
|
||||
timestamp: AwareDatetime | None = None
|
||||
service_url: str = Field(alias="serviceUrl", min_length=1)
|
||||
channel_id: Literal["msteams"] = Field(alias="channelId")
|
||||
conversation: _TeamsConversation
|
||||
channel_data: _TeamsChannelData = Field(alias="channelData")
|
||||
|
||||
@field_validator("id", mode="before")
|
||||
@classmethod
|
||||
def normalize_optional_identifier(cls, value: object) -> object:
|
||||
if isinstance(value, str) and not value.strip():
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
class _MSTeamsLocatorPayload(_Base64JSONLocatorPayload):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
|
||||
|
||||
# version of the locator
|
||||
v: Literal[1]
|
||||
# provider of the locator
|
||||
p: Literal[IMProvider.MS_TEAMS]
|
||||
# Bot Framework service endpoint used for subsequent message operations:
|
||||
# https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference?view=azure-bot-service-4.0
|
||||
service_url: str = Field(min_length=1)
|
||||
# Bot Framework conversation containing the activity:
|
||||
# https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference?view=azure-bot-service-4.0
|
||||
conversation_id: str = Field(min_length=1, pattern=r"\S")
|
||||
# Bot Framework activity identifier of the exact message:
|
||||
# https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference?view=azure-bot-service-4.0
|
||||
activity_id: str = Field(min_length=1, pattern=r"\S")
|
||||
|
||||
@field_validator("service_url")
|
||||
@classmethod
|
||||
def _require_trusted_service_url(cls, value: str) -> str:
|
||||
if not _trusted_teams_service_url(value):
|
||||
raise ValueError("service_url must be a trusted Teams service endpoint")
|
||||
return value
|
||||
|
||||
|
||||
class _MSTeamsBotCredentials(MicrosoftAppCredentials):
|
||||
"""Concrete credentials wrapper kept private to this adapter module."""
|
||||
|
||||
|
||||
def _reject_non_standard_json_constant(value: str) -> object:
|
||||
del value
|
||||
raise ValueError("non-standard JSON constant")
|
||||
|
||||
|
||||
def _canonical_guid(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return str(UUID(value))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _permission_error_is_credential_rejection(error: PermissionError) -> bool:
|
||||
error_text = str(error).strip().casefold()
|
||||
sdk_error_marker = "error:"
|
||||
if sdk_error_marker in error_text:
|
||||
error_text = error_text.split(sdk_error_marker, maxsplit=1)[1].lstrip()
|
||||
error_code = error_text.split(",", maxsplit=1)[0].split(":", maxsplit=1)[0].strip()
|
||||
return error_code in _OAUTH_CREDENTIAL_REJECTION_CODES
|
||||
|
||||
|
||||
def _microsoft_error_code(error: ClientAuthenticationError | PermissionError) -> str | None:
|
||||
# Azure Identity and Bot Framework expose OAuth failures as text. Extract
|
||||
# only the provider's identifier; descriptions may echo submitted secrets.
|
||||
error_text = str(error)
|
||||
aadsts_code = re.search(r"\bAADSTS[0-9]+\b", error_text)
|
||||
if aadsts_code is not None:
|
||||
return aadsts_code.group()
|
||||
if error_text.startswith(_BOT_TOKEN_REJECTION_PREFIX):
|
||||
oauth_code = error_text.removeprefix(_BOT_TOKEN_REJECTION_PREFIX).split(", error_description:", maxsplit=1)[0]
|
||||
return oauth_code if oauth_code != "Unknown error" else None
|
||||
return None
|
||||
|
||||
|
||||
def _log_safe_error(message: str) -> None:
|
||||
logger.error(message)
|
||||
|
||||
|
||||
def _trusted_graph_users_url(url: str) -> bool:
|
||||
parsed = urlsplit(url)
|
||||
return parsed.scheme == "https" and parsed.hostname == "graph.microsoft.com" and parsed.path == "/v1.0/users"
|
||||
|
||||
|
||||
def _trusted_teams_service_url(url: object) -> bool:
|
||||
if not isinstance(url, str):
|
||||
return False
|
||||
parsed = urlsplit(url)
|
||||
return parsed.scheme == "https" and parsed.hostname == "smba.trafficmanager.net"
|
||||
|
||||
|
||||
def _non_empty_string(value: object) -> bool:
|
||||
return isinstance(value, str) and bool(value.strip())
|
||||
|
||||
|
||||
def _header_values(headers: tuple[tuple[str, str], ...], target_name: str) -> tuple[str, ...]:
|
||||
normalized_target = target_name.casefold()
|
||||
return tuple(value for name, value in headers if name.casefold() == normalized_target)
|
||||
|
||||
|
||||
def _content_type(headers: tuple[tuple[str, str], ...]) -> str | None:
|
||||
values = _header_values(headers, "content-type")
|
||||
if len(values) != 1:
|
||||
return None
|
||||
parsed = Message()
|
||||
parsed["Content-Type"] = values[0]
|
||||
return parsed.get_content_type()
|
||||
|
||||
|
||||
def _webhook_response(status_code: int, body: bytes) -> WebhookResponse:
|
||||
return WebhookResponse(
|
||||
status_code=status_code,
|
||||
headers=(("Content-Type", "text/plain; charset=utf-8"),),
|
||||
body=body,
|
||||
)
|
||||
|
||||
|
||||
def _mutation_connector(credentials: MicrosoftAppCredentials, base_url: str) -> ConnectorClient:
|
||||
client = ConnectorClient(credentials, base_url=base_url)
|
||||
# The SDK retries every HTTP verb by default. Message mutations must retain
|
||||
# their contract's at-most-once boundary even when acceptance is uncertain.
|
||||
client.config.retry_policy.retries = 0
|
||||
return client
|
||||
|
||||
|
||||
def _card_summary(intent: ResolvedForm) -> str:
|
||||
if intent.title:
|
||||
return intent.title
|
||||
for block in intent.parts:
|
||||
if isinstance(block, MarkdownFragment) and block.text.strip():
|
||||
return block.text
|
||||
return "Human input form"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _MSTeamsCardCodec(IMCardEventDecoder):
|
||||
"""Own the credential-free Microsoft Teams card wire contract."""
|
||||
|
||||
_CONTENT_TYPE = "application/vnd.microsoft.card.adaptive"
|
||||
_SCHEMA_URL = "http://adaptivecards.io/schemas/adaptive-card.json"
|
||||
_CARD_VERSION = "1.5"
|
||||
_MAX_CARD_SIZE_BYTES = 28 * 1024
|
||||
_SUPPORTED_ACTION_STYLES = frozenset((ButtonStyle.DEFAULT, ButtonStyle.PRIMARY, ButtonStyle.ACCENT))
|
||||
_METADATA_MEMBER = "__dify.human_input"
|
||||
_CALLBACK_SCHEMA_VERSION: ClassVar[Literal[1]] = 1
|
||||
_APPLICABLE_ACTIVITY_TYPES = frozenset(("invoke", "message"))
|
||||
|
||||
class _CallbackModel(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
allow_inf_nan=False,
|
||||
extra="ignore",
|
||||
frozen=True,
|
||||
strict=True,
|
||||
)
|
||||
|
||||
class _CallbackActor(_CallbackModel):
|
||||
# Graph directory users use the Entra object ID, not the Bot Framework ID.
|
||||
aad_object_id: UUID = Field(alias="aadObjectId", strict=False)
|
||||
|
||||
class _ButtonMetadata(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
|
||||
|
||||
version: Literal[1]
|
||||
action_id: str = Field(min_length=1)
|
||||
correlation_token: str
|
||||
|
||||
class _SubmissionActivity(_CallbackModel):
|
||||
type: Literal["invoke", "message"]
|
||||
actor: _MSTeamsCardCodec._CallbackActor = Field(alias="from")
|
||||
value: dict[str, JsonValue]
|
||||
|
||||
def assess(self, intent: ResolvedForm) -> CardAssessment:
|
||||
reason = self._unrepresentable_reason(intent)
|
||||
if reason is not None:
|
||||
return CardAssessment(False, reason)
|
||||
return CardAssessment(True)
|
||||
|
||||
def encode(
|
||||
self,
|
||||
intent: ResolvedForm,
|
||||
correlation_token: CorrelationToken,
|
||||
) -> Mapping[str, JsonValue]:
|
||||
reason = self._unrepresentable_reason(intent)
|
||||
if reason is not None:
|
||||
raise DynamicCardMessagingError(reason)
|
||||
card = self._render_card(intent, correlation_token)
|
||||
if self._serialized_card_size(card) > self._MAX_CARD_SIZE_BYTES:
|
||||
raise DynamicCardMessagingError("Microsoft Teams cannot preserve a card beyond the Provider payload limit.")
|
||||
return card
|
||||
|
||||
@override
|
||||
def decode(self, event: AuthenticatedIMEvent) -> IMCardEventDecodeResult:
|
||||
if event.provider is not IMProvider.MS_TEAMS or event.event_type not in self._APPLICABLE_ACTIVITY_TYPES:
|
||||
return UnrecognizedIMEvent()
|
||||
callback = self._decode_json_object(event.payload)
|
||||
if callback is None:
|
||||
raise IMCardEventDecodingError("Microsoft Teams card event payload is invalid.")
|
||||
|
||||
callback_value = callback.get("value")
|
||||
if not isinstance(callback_value, dict):
|
||||
return UnrecognizedIMEvent()
|
||||
if self._METADATA_MEMBER not in callback_value:
|
||||
return UnrecognizedIMEvent()
|
||||
|
||||
validated_submission = self._validate_submission(callback)
|
||||
if validated_submission is None:
|
||||
raise IMCardEventDecodingError("Microsoft Teams card event schema is invalid.")
|
||||
submission, metadata = validated_submission
|
||||
if submission.type != event.event_type:
|
||||
raise IMCardEventDecodingError("Microsoft Teams card event schema is invalid.")
|
||||
inputs = dict(submission.value)
|
||||
inputs.pop(self._METADATA_MEMBER)
|
||||
return IMCardEvent(
|
||||
provider_user_id=ProviderUserId(str(submission.actor.aad_object_id)),
|
||||
action_id=metadata.action_id,
|
||||
inputs=inputs,
|
||||
correlation_token=CorrelationToken(metadata.correlation_token),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _decode_json_object(cls, serialized_callback: str) -> dict[str, object] | None:
|
||||
try:
|
||||
decoded_callback = json.loads(
|
||||
serialized_callback,
|
||||
parse_constant=_reject_non_standard_json_constant,
|
||||
)
|
||||
except (json.JSONDecodeError, ValueError, RecursionError):
|
||||
return None
|
||||
if not isinstance(decoded_callback, dict) or any(not isinstance(key, str) for key in decoded_callback):
|
||||
return None
|
||||
return decoded_callback
|
||||
|
||||
@classmethod
|
||||
def _validate_submission(
|
||||
cls,
|
||||
callback: dict[str, object],
|
||||
) -> tuple[_SubmissionActivity, _ButtonMetadata] | None:
|
||||
try:
|
||||
submission = cls._SubmissionActivity.model_validate(callback)
|
||||
metadata = cls._ButtonMetadata.model_validate(submission.value[cls._METADATA_MEMBER])
|
||||
except (KeyError, ValidationError):
|
||||
return None
|
||||
return submission, metadata
|
||||
|
||||
@classmethod
|
||||
def _unrepresentable_reason(cls, intent: ResolvedForm) -> str | None:
|
||||
if not intent.parts and not intent.actions:
|
||||
return "Microsoft Teams cannot preserve an empty card."
|
||||
|
||||
input_names: set[str] = set()
|
||||
for block in intent.parts:
|
||||
match block:
|
||||
case MarkdownFragment(text=text):
|
||||
if not text:
|
||||
return "Microsoft Teams cannot preserve an empty Markdown block."
|
||||
continue
|
||||
case FileInput() | FileListInput():
|
||||
return "Microsoft Teams cards cannot represent file inputs."
|
||||
case ParagraphInput(output_variable_name=input_name):
|
||||
pass
|
||||
case SelectInput(output_variable_name=input_name, options=options, default_value=default_value):
|
||||
if not options or any(not option for option in options):
|
||||
return "Microsoft Teams cannot preserve one select input's options."
|
||||
if len(options) != len(set(options)):
|
||||
return "Microsoft Teams cannot preserve duplicate select options."
|
||||
if default_value is not None and default_value not in options:
|
||||
return "Microsoft Teams cannot preserve one select input default."
|
||||
if input_name in input_names:
|
||||
return "Microsoft Teams cannot preserve duplicate card input identifiers."
|
||||
if input_name == cls._METADATA_MEMBER:
|
||||
return "Microsoft Teams card input identifier is reserved."
|
||||
input_names.add(input_name)
|
||||
|
||||
if any(action.button_style not in cls._SUPPORTED_ACTION_STYLES for action in intent.actions):
|
||||
return "Microsoft Teams cannot preserve one card action style."
|
||||
assessment_card = cls._render_card(intent, CorrelationToken(""))
|
||||
if cls._serialized_card_size(assessment_card) > cls._MAX_CARD_SIZE_BYTES:
|
||||
return "Microsoft Teams cannot preserve a card beyond the Provider payload limit."
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _render_card(
|
||||
cls,
|
||||
intent: ResolvedForm,
|
||||
correlation_token: CorrelationToken,
|
||||
) -> dict[str, JsonValue]:
|
||||
body: list[JsonValue] = []
|
||||
if intent.title:
|
||||
body.append(
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": intent.title,
|
||||
"wrap": True,
|
||||
"size": "Medium",
|
||||
"weight": "Bolder",
|
||||
}
|
||||
)
|
||||
for block in intent.parts:
|
||||
if isinstance(block, MarkdownFragment):
|
||||
body.append({"type": "TextBlock", "text": block.text, "wrap": True})
|
||||
continue
|
||||
if isinstance(block, ParagraphInput):
|
||||
text_input: dict[str, JsonValue] = {
|
||||
"type": "Input.Text",
|
||||
"id": block.output_variable_name,
|
||||
"label": block.output_variable_name,
|
||||
"isMultiline": True,
|
||||
}
|
||||
if block.default_value is not None:
|
||||
text_input["value"] = block.default_value
|
||||
body.append(text_input)
|
||||
continue
|
||||
if isinstance(block, SelectInput):
|
||||
choice_input: dict[str, JsonValue] = {
|
||||
"type": "Input.ChoiceSet",
|
||||
"id": block.output_variable_name,
|
||||
"label": block.output_variable_name,
|
||||
"choices": [{"title": option, "value": option} for option in block.options],
|
||||
"style": "compact",
|
||||
}
|
||||
if block.default_value is not None:
|
||||
choice_input["value"] = block.default_value
|
||||
body.append(choice_input)
|
||||
continue
|
||||
raise DynamicCardMessagingError("Microsoft Teams cards cannot represent file inputs.")
|
||||
|
||||
actions: list[JsonValue] = []
|
||||
for action in intent.actions:
|
||||
metadata = cls._ButtonMetadata(
|
||||
version=cls._CALLBACK_SCHEMA_VERSION,
|
||||
action_id=action.id,
|
||||
correlation_token=str(correlation_token),
|
||||
)
|
||||
actions.append(
|
||||
{
|
||||
"type": "Action.Submit",
|
||||
"title": action.title,
|
||||
"data": {cls._METADATA_MEMBER: metadata.model_dump(mode="json")},
|
||||
}
|
||||
)
|
||||
return {
|
||||
"$schema": cls._SCHEMA_URL,
|
||||
"type": "AdaptiveCard",
|
||||
"version": cls._CARD_VERSION,
|
||||
"body": body,
|
||||
"actions": actions,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialized_card_size(card: Mapping[str, JsonValue]) -> int:
|
||||
return len(
|
||||
json.dumps(
|
||||
card,
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
)
|
||||
|
||||
|
||||
class _MSTeamsDirectory(IMDirectory):
|
||||
def __init__(self, credential: ClientSecretCredential, client: httpx.Client) -> None:
|
||||
self._credential = credential
|
||||
self._client = client
|
||||
|
||||
@override
|
||||
def read_directory(self) -> Directory | DirectoryReadFailure:
|
||||
try:
|
||||
access_token = self._credential.get_token(_GRAPH_SCOPE).token
|
||||
return self._read_all_pages(access_token)
|
||||
except (AvatarReadError, ClientAuthenticationError, httpx.HTTPError, ValidationError, ValueError):
|
||||
return DirectoryReadFailure("Microsoft Teams directory could not be read completely.")
|
||||
except Exception:
|
||||
_log_safe_error("Unexpected Microsoft Teams directory failure")
|
||||
return DirectoryReadFailure("Microsoft Teams directory could not be read completely.")
|
||||
|
||||
def _read_all_pages(self, access_token: str) -> Directory:
|
||||
entries: list[DirectoryEntry] = []
|
||||
next_url: str | None = None
|
||||
seen_urls: set[str] = set()
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
while True:
|
||||
if next_url is None:
|
||||
response = self._client.get(
|
||||
_GRAPH_USERS_URL,
|
||||
headers=headers,
|
||||
params={
|
||||
"$select": "id,displayName,mail",
|
||||
"$top": _GRAPH_DIRECTORY_PAGE_SIZE,
|
||||
},
|
||||
)
|
||||
else:
|
||||
response = self._client.get(next_url, headers=headers)
|
||||
response.raise_for_status()
|
||||
page = _GraphUsersPage.model_validate(response.json())
|
||||
avatars = read_avatars(
|
||||
[f"{_GRAPH_USERS_URL}/{quote(user.id, safe='')}/photo/$value" for user in page.users],
|
||||
headers=headers,
|
||||
)
|
||||
entries.extend(
|
||||
DirectoryEntry(
|
||||
provider_user_id=ProviderUserId(user.id),
|
||||
display_name=user.display_name,
|
||||
email=user.mail,
|
||||
avatar=avatar,
|
||||
)
|
||||
for user, avatar in zip(page.users, avatars, strict=True)
|
||||
)
|
||||
if page.next_link is None:
|
||||
return Directory(tuple(entries))
|
||||
if not _trusted_graph_users_url(page.next_link) or page.next_link in seen_urls:
|
||||
raise ValueError("invalid Microsoft Graph pagination")
|
||||
seen_urls.add(page.next_link)
|
||||
next_url = page.next_link
|
||||
|
||||
|
||||
class _MSTeamsMessaging(IMMessaging):
|
||||
def __init__(self, credentials: MicrosoftAppCredentials, tenant_id: str, client_id: str) -> None:
|
||||
self._credentials = credentials
|
||||
self._tenant_id = tenant_id
|
||||
self._client_id = client_id
|
||||
|
||||
@override
|
||||
def send_text(
|
||||
self, provider_user_id: ProviderUserId, body: str, subject: str | None = None
|
||||
) -> MessageSendingResult:
|
||||
if subject:
|
||||
# Keep subject content literal inside the Markdown heading.
|
||||
escaped_subject = "".join(
|
||||
f"\\{character}" if character in punctuation else character for character in subject
|
||||
)
|
||||
body = f"# {escaped_subject}\n\n{body}"
|
||||
# Teams' default Markdown renderer does not support headings.
|
||||
return self._send_activity(
|
||||
provider_user_id,
|
||||
Activity(type="message", text=body, text_format=_EXTENDED_MARKDOWN_TEXT_FORMAT),
|
||||
)
|
||||
|
||||
def _send_activity(
|
||||
self,
|
||||
provider_user_id: ProviderUserId,
|
||||
activity: Activity,
|
||||
) -> MessageSendingResult:
|
||||
try:
|
||||
conversation_client = _mutation_connector(self._credentials, _PUBLIC_TEAMS_SERVICE_URL)
|
||||
conversation = conversation_client.conversations.create_conversation(
|
||||
ConversationParameters(
|
||||
is_group=False,
|
||||
bot=ChannelAccount(id=f"{_TEAMS_BOT_CHANNEL_ID_PREFIX}{self._client_id}"),
|
||||
members=[ChannelAccount(id=str(provider_user_id))],
|
||||
tenant_id=self._tenant_id,
|
||||
)
|
||||
)
|
||||
if not isinstance(conversation, ConversationResourceResponse):
|
||||
return MessageSendingError("Microsoft Teams returned no usable personal conversation reference.")
|
||||
conversation_id = conversation.id
|
||||
service_url = conversation.service_url or _PUBLIC_TEAMS_SERVICE_URL
|
||||
if not _non_empty_string(conversation_id) or not _trusted_teams_service_url(service_url):
|
||||
return MessageSendingError("Microsoft Teams returned no usable personal conversation reference.")
|
||||
message_client = _mutation_connector(self._credentials, service_url)
|
||||
response = message_client.conversations.send_to_conversation(conversation_id, activity)
|
||||
if not isinstance(response, ResourceResponse):
|
||||
return MessageSendingError("Microsoft Teams returned no exact message reference.")
|
||||
activity_id = response.id
|
||||
if not _non_empty_string(activity_id):
|
||||
return MessageSendingError("Microsoft Teams returned no exact message reference.")
|
||||
return MessageAccepted(
|
||||
MessageLocator(
|
||||
_MSTeamsLocatorPayload(
|
||||
v=1,
|
||||
p=IMProvider.MS_TEAMS,
|
||||
service_url=service_url,
|
||||
conversation_id=conversation_id,
|
||||
activity_id=activity_id,
|
||||
).encode()
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
_log_safe_error("Unexpected Microsoft Teams message creation failure")
|
||||
return MessageSendingError("Microsoft Teams message acceptance could not be confirmed.")
|
||||
|
||||
|
||||
class _MSTeamsDynamicCardMessaging(_MSTeamsMessaging, IMDynamicCardMessaging):
|
||||
def __init__(
|
||||
self,
|
||||
credentials: MicrosoftAppCredentials,
|
||||
tenant_id: str,
|
||||
client_id: str,
|
||||
codec: _MSTeamsCardCodec,
|
||||
) -> None:
|
||||
super().__init__(credentials, tenant_id, client_id)
|
||||
self._codec = codec
|
||||
|
||||
@override
|
||||
def assess(self, intent: ResolvedForm) -> CardAssessment:
|
||||
return self._codec.assess(intent)
|
||||
|
||||
@override
|
||||
def send_card(
|
||||
self,
|
||||
provider_user_id: ProviderUserId,
|
||||
intent: ResolvedForm,
|
||||
correlation_token: CorrelationToken,
|
||||
) -> MessageSendingResult:
|
||||
content = self._codec.encode(intent, correlation_token)
|
||||
activity = Activity(
|
||||
type="message",
|
||||
summary=_card_summary(intent),
|
||||
attachments=[Attachment(content_type=self._codec._CONTENT_TYPE, content=dict(content))],
|
||||
)
|
||||
return self._send_activity(provider_user_id, activity)
|
||||
|
||||
@override
|
||||
def replace_with_static(
|
||||
self,
|
||||
locator: MessageLocator,
|
||||
intent: StaticCardIntent,
|
||||
) -> ReplacementError | None:
|
||||
decoded_locator = self._compatible_card_locator(locator)
|
||||
if decoded_locator is None:
|
||||
return ReplacementError(
|
||||
ReplacementErrorKind.INVALID_REFERENCE,
|
||||
"The Microsoft Teams message reference is invalid.",
|
||||
)
|
||||
try:
|
||||
client = _mutation_connector(self._credentials, decoded_locator.service_url)
|
||||
client.conversations.update_activity(
|
||||
decoded_locator.conversation_id,
|
||||
decoded_locator.activity_id,
|
||||
Activity(type="message", text=intent.rendered_content),
|
||||
)
|
||||
except HttpOperationError as error:
|
||||
if error.response.status_code in {404, 410}:
|
||||
return ReplacementError(
|
||||
ReplacementErrorKind.STALE_REFERENCE,
|
||||
"The referenced Microsoft Teams card is no longer replaceable.",
|
||||
)
|
||||
return ReplacementError(
|
||||
ReplacementErrorKind.UNKNOWN,
|
||||
"Microsoft Teams replacement acceptance is unknown.",
|
||||
)
|
||||
except Exception:
|
||||
_log_safe_error("Unexpected Microsoft Teams card replacement failure")
|
||||
return ReplacementError(
|
||||
ReplacementErrorKind.UNKNOWN,
|
||||
"Microsoft Teams replacement acceptance is unknown.",
|
||||
)
|
||||
return None
|
||||
|
||||
def _compatible_card_locator(self, locator: MessageLocator) -> _MSTeamsLocatorPayload | None:
|
||||
try:
|
||||
return _MSTeamsLocatorPayload.decode(str(locator))
|
||||
except (binascii.Error, UnicodeDecodeError, ValueError, ValidationError):
|
||||
return None
|
||||
|
||||
|
||||
class _MSTeamsWebhookHandler(IMWebhookHandler):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
consumer: IMEventConsumer,
|
||||
) -> None:
|
||||
self._tenant_id = tenant_id
|
||||
self._credential_provider = SimpleCredentialProvider(client_id, client_secret)
|
||||
self._consumer = consumer
|
||||
|
||||
@override
|
||||
def handle(self, request: WebhookRequest) -> WebhookResponse:
|
||||
if request.method != "POST":
|
||||
return _webhook_response(405, b"method not allowed")
|
||||
if _content_type(request.headers) != _JSON_CONTENT_TYPE:
|
||||
return _webhook_response(415, b"unsupported content type")
|
||||
authorization_values = _header_values(request.headers, "authorization")
|
||||
if len(authorization_values) != 1:
|
||||
return _webhook_response(401, b"request authentication failed")
|
||||
|
||||
decoded_body = self._decoded_body(request.body)
|
||||
if decoded_body is None:
|
||||
return _webhook_response(400, b"invalid Microsoft Teams activity")
|
||||
try:
|
||||
activity = _TeamsInboundActivity.model_validate(decoded_body)
|
||||
except ValidationError:
|
||||
return _webhook_response(400, b"invalid Microsoft Teams activity")
|
||||
if not _trusted_teams_service_url(activity.service_url):
|
||||
return _webhook_response(400, b"invalid Microsoft Teams activity")
|
||||
if not self._authenticated(activity, authorization_values[0]):
|
||||
return _webhook_response(401, b"request authentication failed")
|
||||
if not self._configured_tenant(activity):
|
||||
return _webhook_response(403, b"Microsoft Teams tenant mismatch")
|
||||
|
||||
event = AuthenticatedIMEvent(
|
||||
provider=IMProvider.MS_TEAMS,
|
||||
provider_tenant_id=self._tenant_id,
|
||||
event_id=activity.id,
|
||||
event_type=activity.type,
|
||||
occurred_at=self._occurred_at(activity),
|
||||
received_at=request.received_at,
|
||||
ingress_kind=IMEventIngressKind.WEBHOOK,
|
||||
payload=json.dumps(decoded_body, ensure_ascii=False, allow_nan=False, separators=(",", ":")),
|
||||
)
|
||||
try:
|
||||
acceptance = self._consumer.accept(event, request_ip_address=request.request_ip_address)
|
||||
except Exception:
|
||||
_log_safe_error("Unexpected Microsoft Teams Webhook consumer failure")
|
||||
return _webhook_response(503, b"event processing failed")
|
||||
if acceptance is EventAcceptance.ACCEPTED:
|
||||
return _webhook_response(200, b"")
|
||||
return _webhook_response(503, b"event not accepted")
|
||||
|
||||
@staticmethod
|
||||
def _decoded_body(body: bytes) -> dict[str, object] | None:
|
||||
try:
|
||||
decoded = json.loads(body, parse_constant=_reject_non_standard_json_constant)
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return None
|
||||
if not isinstance(decoded, dict) or any(not isinstance(key, str) for key in decoded):
|
||||
return None
|
||||
return decoded
|
||||
|
||||
def _authenticated(self, activity: _TeamsInboundActivity, authorization: str) -> bool:
|
||||
sdk_activity = Activity(
|
||||
type=activity.type,
|
||||
channel_id=activity.channel_id,
|
||||
service_url=activity.service_url,
|
||||
)
|
||||
try:
|
||||
identity = asyncio.run(
|
||||
JwtTokenValidation.authenticate_request(
|
||||
sdk_activity,
|
||||
authorization,
|
||||
self._credential_provider,
|
||||
"",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
_log_safe_error("Microsoft Teams Webhook authentication failed")
|
||||
return False
|
||||
return identity.is_authenticated
|
||||
|
||||
def _configured_tenant(self, activity: _TeamsInboundActivity) -> bool:
|
||||
tenant_ids = {
|
||||
_canonical_guid(tenant_id)
|
||||
for tenant_id in (
|
||||
activity.conversation.tenant_id,
|
||||
activity.channel_data.tenant.id if activity.channel_data.tenant is not None else None,
|
||||
)
|
||||
if tenant_id is not None
|
||||
}
|
||||
configured_tenant_id = _canonical_guid(self._tenant_id)
|
||||
return configured_tenant_id is not None and None not in tenant_ids and tenant_ids == {configured_tenant_id}
|
||||
|
||||
@staticmethod
|
||||
def _occurred_at(activity: _TeamsInboundActivity) -> datetime | None:
|
||||
if activity.timestamp is None:
|
||||
return None
|
||||
return activity.timestamp.astimezone(UTC).replace(tzinfo=None)
|
||||
|
||||
|
||||
class MSTeamsIMProviderAdapter:
|
||||
"""Externally serialized Microsoft Teams capability composition root."""
|
||||
|
||||
@classmethod
|
||||
def card_event_decoder(cls) -> IMCardEventDecoder:
|
||||
"""Return a credential-free decoder independent from root adapter instances."""
|
||||
return _MSTeamsCardCodec()
|
||||
|
||||
def __init__(self, credentials: MSTeamsCredentials) -> None:
|
||||
if not isinstance(credentials, MSTeamsCredentials):
|
||||
raise TypeError("Microsoft Teams adapter requires resolved Microsoft Teams credentials")
|
||||
self._credentials = credentials
|
||||
self._graph_credential = ClientSecretCredential(
|
||||
credentials.tenant_id,
|
||||
credentials.client_id,
|
||||
credentials.client_secret,
|
||||
)
|
||||
self._graph_client = httpx.Client(timeout=_HTTP_TIMEOUT_SECONDS)
|
||||
self._bot_credentials = _MSTeamsBotCredentials(
|
||||
credentials.client_id,
|
||||
credentials.client_secret,
|
||||
channel_auth_tenant=credentials.tenant_id,
|
||||
)
|
||||
self._directory = _MSTeamsDirectory(self._graph_credential, self._graph_client)
|
||||
self._messaging = _MSTeamsMessaging(
|
||||
self._bot_credentials,
|
||||
credentials.tenant_id,
|
||||
credentials.client_id,
|
||||
)
|
||||
self._dynamic_card_messaging = _MSTeamsDynamicCardMessaging(
|
||||
self._bot_credentials,
|
||||
credentials.tenant_id,
|
||||
credentials.client_id,
|
||||
_MSTeamsCardCodec(),
|
||||
)
|
||||
|
||||
@property
|
||||
def provider(self) -> IMProvider:
|
||||
return IMProvider.MS_TEAMS
|
||||
|
||||
def test_credentials(self) -> CredentialTestSuccess | CredentialTestFailure:
|
||||
try:
|
||||
return self._test_credentials()
|
||||
except (
|
||||
TimeoutError,
|
||||
httpx.TimeoutException,
|
||||
RequestsTimeout,
|
||||
ServiceRequestTimeoutError,
|
||||
ServiceResponseTimeoutError,
|
||||
) as error:
|
||||
raise CredentialTestTimeoutError() from error
|
||||
|
||||
def _test_credentials(self) -> CredentialTestSuccess | CredentialTestFailure:
|
||||
# Azure Identity and Bot Framework cache tokens within their credential
|
||||
# instances. Each test must authenticate again, even on the same adapter.
|
||||
with closing(
|
||||
ClientSecretCredential(
|
||||
self._credentials.tenant_id,
|
||||
self._credentials.client_id,
|
||||
self._credentials.client_secret,
|
||||
)
|
||||
) as graph_credential:
|
||||
return self._verify_credentials(graph_credential)
|
||||
|
||||
def _verify_credentials(
|
||||
self, graph_credential: ClientSecretCredential
|
||||
) -> CredentialTestSuccess | CredentialTestFailure:
|
||||
try:
|
||||
graph_token = graph_credential.get_token(_GRAPH_SCOPE).token
|
||||
except ClientAuthenticationError as error:
|
||||
provider_error_code = _microsoft_error_code(error)
|
||||
if provider_error_code is None and error.response is None:
|
||||
raise
|
||||
return CredentialTestFailure(
|
||||
CredentialTestFailureKind.AUTHENTICATION_REJECTED,
|
||||
"Microsoft rejected the Graph credentials.",
|
||||
provider_error_code=provider_error_code,
|
||||
http_status=error.status_code,
|
||||
)
|
||||
|
||||
graph_response = self._graph_client.get(
|
||||
_GRAPH_USERS_URL,
|
||||
headers={"Authorization": f"Bearer {graph_token}"},
|
||||
params={"$select": "id,displayName,mail", "$top": _GRAPH_CREDENTIAL_TEST_PAGE_SIZE},
|
||||
)
|
||||
if not graph_response.is_success:
|
||||
try:
|
||||
rejection = _GraphErrorResponse.model_validate(graph_response.json())
|
||||
except (json.JSONDecodeError, ValidationError):
|
||||
provider_error_code = None
|
||||
else:
|
||||
provider_error_code = rejection.error.code if rejection.error is not None else None
|
||||
kind = CredentialTestFailureKind.UNKNOWN
|
||||
if graph_response.status_code == HTTPStatus.UNAUTHORIZED:
|
||||
kind = CredentialTestFailureKind.AUTHENTICATION_REJECTED
|
||||
elif graph_response.status_code == HTTPStatus.FORBIDDEN:
|
||||
kind = CredentialTestFailureKind.PERMISSION_DENIED
|
||||
return CredentialTestFailure(
|
||||
kind,
|
||||
"Microsoft Graph rejected the directory access test.",
|
||||
provider_error_code=provider_error_code,
|
||||
http_status=graph_response.status_code,
|
||||
)
|
||||
_GraphUsersPage.model_validate(graph_response.json())
|
||||
# Teams has no conversation-free Connector read probe. Test its tenant-scoped
|
||||
# Bot Framework credentials through the SDK without sending a message.
|
||||
try:
|
||||
bot_credentials = _MSTeamsBotCredentials(
|
||||
self._credentials.client_id,
|
||||
self._credentials.client_secret,
|
||||
channel_auth_tenant=self._credentials.tenant_id,
|
||||
)
|
||||
bot_token = bot_credentials.get_access_token()
|
||||
except PermissionError as error:
|
||||
if not str(error).startswith(_BOT_TOKEN_REJECTION_PREFIX):
|
||||
raise
|
||||
kind = (
|
||||
CredentialTestFailureKind.AUTHENTICATION_REJECTED
|
||||
if _permission_error_is_credential_rejection(error)
|
||||
else CredentialTestFailureKind.UNKNOWN
|
||||
)
|
||||
return CredentialTestFailure(
|
||||
kind,
|
||||
"Microsoft rejected the Bot Framework credentials.",
|
||||
provider_error_code=_microsoft_error_code(error),
|
||||
)
|
||||
|
||||
if not bot_token:
|
||||
raise IMProviderError("Microsoft Bot Framework did not provide an access token.")
|
||||
return CredentialTestSuccess(IMProvider.MS_TEAMS, str(UUID(self._credentials.tenant_id)))
|
||||
|
||||
@property
|
||||
def directory(self) -> IMDirectory:
|
||||
return self._directory
|
||||
|
||||
@property
|
||||
def messaging(self) -> IMMessaging:
|
||||
return self._messaging
|
||||
|
||||
@property
|
||||
def dynamic_card_messaging(self) -> IMDynamicCardMessaging:
|
||||
return self._dynamic_card_messaging
|
||||
|
||||
def create_webhook_handler(self, consumer: IMEventConsumer) -> IMWebhookHandler:
|
||||
return _MSTeamsWebhookHandler(
|
||||
tenant_id=self._credentials.tenant_id,
|
||||
client_id=self._credentials.client_id,
|
||||
client_secret=self._credentials.client_secret,
|
||||
consumer=consumer,
|
||||
)
|
||||
|
||||
def create_stream_handler(self, consumer: IMEventConsumer) -> IMEventStream | None:
|
||||
del consumer
|
||||
return None
|
||||
|
||||
def close(self) -> None:
|
||||
self._graph_client.close()
|
||||
self._graph_credential.close()
|
||||
|
||||
|
||||
__all__ = ["MSTeamsIMProviderAdapter"]
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Capability protocols implemented by IM provider adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from core.human_input_v2.entities import IMProvider
|
||||
from core.human_input_v2.resolved_form import ResolvedForm
|
||||
|
||||
from .entities import (
|
||||
AuthenticatedIMEvent,
|
||||
CardAssessment,
|
||||
CorrelationToken,
|
||||
CredentialTestFailure,
|
||||
CredentialTestSuccess,
|
||||
Directory,
|
||||
DirectoryReadFailure,
|
||||
EventAcceptance,
|
||||
IMCardEventDecodeResult,
|
||||
MessageSendingResult,
|
||||
ProviderUserId,
|
||||
ReplacementError,
|
||||
StaticCardIntent,
|
||||
WebhookRequest,
|
||||
WebhookResponse,
|
||||
)
|
||||
from .message_locator import MessageLocator
|
||||
|
||||
|
||||
class IMDirectory(Protocol):
|
||||
"""Adapter-bound directory capability."""
|
||||
|
||||
def read_directory(self) -> Directory | DirectoryReadFailure:
|
||||
"""Return identities with downloaded avatars, or fail without partial entries.
|
||||
|
||||
Provider authentication and image retrieval finish before returning.
|
||||
Missing avatars and HTTP 404 are None; other avatar errors fail the snapshot.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class IMMessaging(Protocol):
|
||||
"""Adapter-bound personal text messaging capability."""
|
||||
|
||||
def send_text(
|
||||
self, provider_user_id: ProviderUserId, body: str, subject: str | None = None
|
||||
) -> MessageSendingResult:
|
||||
"""Send a CommonMark body with an optional plain-text subject, without replay.
|
||||
|
||||
Each adapter owns subject presentation and provider-specific formatting.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class IMDynamicCardMessaging(Protocol):
|
||||
"""Adapter-bound complete dynamic card capability."""
|
||||
|
||||
def assess(self, intent: ResolvedForm) -> CardAssessment:
|
||||
"""Assess every intent fact without provider I/O or side effects."""
|
||||
...
|
||||
|
||||
def send_card(
|
||||
self,
|
||||
provider_user_id: ProviderUserId,
|
||||
intent: ResolvedForm,
|
||||
correlation_token: CorrelationToken,
|
||||
) -> MessageSendingResult:
|
||||
"""Attempt one complete card creation and preserve callback identity."""
|
||||
...
|
||||
|
||||
def replace_with_static(
|
||||
self,
|
||||
locator: MessageLocator,
|
||||
intent: StaticCardIntent,
|
||||
) -> ReplacementError | None:
|
||||
"""Replace only the exact compatible referenced card once."""
|
||||
...
|
||||
|
||||
|
||||
class IMCardEventDecoder(Protocol):
|
||||
"""Credential-free and thread-safe provider card callback decoder."""
|
||||
|
||||
def decode(self, event: AuthenticatedIMEvent) -> IMCardEventDecodeResult:
|
||||
"""Normalize one authenticated event without provider or persistence I/O."""
|
||||
...
|
||||
|
||||
|
||||
class IMEventConsumer(Protocol):
|
||||
"""Thread-safe consumer of authenticated provider events."""
|
||||
|
||||
def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance:
|
||||
"""Accept only after processing or taking responsibility for processing."""
|
||||
...
|
||||
|
||||
|
||||
class IMWebhookHandler(Protocol):
|
||||
"""Thread-safe framework-neutral provider webhook handler."""
|
||||
|
||||
def handle(self, request: WebhookRequest) -> WebhookResponse:
|
||||
"""Handle provider URL verification or authenticate and consume an event.
|
||||
|
||||
URL verification returns the provider response without invoking the consumer.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class IMEventStream(Protocol):
|
||||
"""Owner-managed inbound event resource with a one-shot lifecycle."""
|
||||
|
||||
def start(self) -> None:
|
||||
"""Synchronously initialize and start receiving events."""
|
||||
...
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Synchronously drain accepted events and release owned resources."""
|
||||
...
|
||||
|
||||
|
||||
class IMProviderAdapter(Protocol):
|
||||
"""Externally serialized provider-bound capability composition root."""
|
||||
|
||||
@classmethod
|
||||
def card_event_decoder(cls) -> IMCardEventDecoder | None:
|
||||
"""Return a stateless decoder independent from credentials and root lifecycles."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def provider(self) -> IMProvider: ...
|
||||
|
||||
def test_credentials(self) -> CredentialTestSuccess | CredentialTestFailure:
|
||||
"""Authenticate submitted credentials without reusing previously acquired tokens.
|
||||
|
||||
Execution errors propagate, with timeouts classified separately.
|
||||
"""
|
||||
...
|
||||
|
||||
@property
|
||||
def directory(self) -> IMDirectory: ...
|
||||
|
||||
@property
|
||||
def messaging(self) -> IMMessaging: ...
|
||||
|
||||
@property
|
||||
def dynamic_card_messaging(self) -> IMDynamicCardMessaging | None: ...
|
||||
|
||||
def create_webhook_handler(self, consumer: IMEventConsumer) -> IMWebhookHandler | None: ...
|
||||
|
||||
def create_stream_handler(self, consumer: IMEventConsumer) -> IMEventStream | None: ...
|
||||
|
||||
def close(self) -> None:
|
||||
"""Idempotently end the root lifecycle without invalidating transports."""
|
||||
...
|
||||
|
||||
|
||||
__all__ = [
|
||||
"IMCardEventDecoder",
|
||||
"IMDirectory",
|
||||
"IMDynamicCardMessaging",
|
||||
"IMEventConsumer",
|
||||
"IMEventStream",
|
||||
"IMMessaging",
|
||||
"IMProviderAdapter",
|
||||
"IMWebhookHandler",
|
||||
]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,617 @@
|
||||
"""Concrete WeCom adapter for Provider-neutral Human Input IM contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from email.headerregistry import Address
|
||||
from json import JSONDecodeError
|
||||
from typing import Annotated, Literal, Protocol, override
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict, Field, RootModel, StrictInt, StrictStr, ValidationError, field_validator
|
||||
from requests.exceptions import Timeout as RequestsTimeout
|
||||
from wechatpy.enterprise import WeChatClient
|
||||
from wechatpy.exceptions import WeChatClientException
|
||||
|
||||
from core.human_input_v2.entities import IMProvider
|
||||
from core.human_input_v2.im_integration.adapters.avatar import read_avatars
|
||||
from core.human_input_v2.im_integration.adapters.credentials import WeComCredentials
|
||||
from core.human_input_v2.im_integration.adapters.entities import (
|
||||
CredentialTestFailure,
|
||||
CredentialTestFailureKind,
|
||||
CredentialTestSuccess,
|
||||
CredentialTestTimeoutError,
|
||||
Directory,
|
||||
DirectoryEntry,
|
||||
DirectoryReadFailure,
|
||||
IMProviderError,
|
||||
MessageAccepted,
|
||||
MessageSendingError,
|
||||
MessageSendingResult,
|
||||
ProviderUserId,
|
||||
)
|
||||
from core.human_input_v2.im_integration.adapters.message_locator import MessageLocator, _Base64JSONLocatorPayload
|
||||
from core.human_input_v2.im_integration.adapters.protocols import (
|
||||
IMCardEventDecoder,
|
||||
IMDirectory,
|
||||
IMDynamicCardMessaging,
|
||||
IMEventConsumer,
|
||||
IMEventStream,
|
||||
IMMessaging,
|
||||
IMWebhookHandler,
|
||||
)
|
||||
|
||||
_SDK_TIMEOUT_SECONDS = 5.0
|
||||
_ACCESS_TOKEN_URL = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
|
||||
|
||||
_AUTHENTICATION_REJECTION_CODES = frozenset({40001, 40013})
|
||||
_TENANT_BOUNDARY_REJECTION_CODES = frozenset({40056, 48002, 60011})
|
||||
|
||||
_CREDENTIAL_TEST_FAILED = "WeCom credential testing could not be completed."
|
||||
_CREDENTIAL_REJECTED = "WeCom rejected the credential test."
|
||||
_TENANT_BOUNDARY_UNAVAILABLE = "WeCom could not verify the configured corporation directory boundary."
|
||||
_DIRECTORY_READ_FAILED = "WeCom directory could not be read completely."
|
||||
_MESSAGE_ACCEPTANCE_UNCONFIRMED = "WeCom message acceptance could not be confirmed."
|
||||
|
||||
|
||||
class AccessTokenProvider(Protocol):
|
||||
def get(self) -> str:
|
||||
"""Return a currently usable access token."""
|
||||
...
|
||||
|
||||
|
||||
class _AgentAPI(Protocol):
|
||||
def get(self, agent_id: int) -> object: ...
|
||||
|
||||
|
||||
class _DepartmentAPI(Protocol):
|
||||
def get(self, id: int | None = None) -> object: ...
|
||||
|
||||
|
||||
class _UserAPI(Protocol):
|
||||
def get(self, user_id: str) -> object: ...
|
||||
|
||||
def list(
|
||||
self,
|
||||
department_id: int,
|
||||
fetch_child: bool = False,
|
||||
status: int = 0,
|
||||
simple: bool = False,
|
||||
) -> object: ...
|
||||
|
||||
|
||||
class _TagAPI(Protocol):
|
||||
def get_users(self, tag_id: int) -> object: ...
|
||||
|
||||
|
||||
class _MessageAPI(Protocol):
|
||||
def send_text(
|
||||
self,
|
||||
agent_id: int,
|
||||
user_ids: list[str],
|
||||
content: str,
|
||||
party_ids: str = "",
|
||||
tag_ids: str = "",
|
||||
safe: int = 0,
|
||||
) -> object: ...
|
||||
|
||||
|
||||
class _WeComClient(Protocol):
|
||||
@property
|
||||
def agent(self) -> _AgentAPI: ...
|
||||
|
||||
@property
|
||||
def department(self) -> _DepartmentAPI: ...
|
||||
|
||||
@property
|
||||
def user(self) -> _UserAPI: ...
|
||||
|
||||
@property
|
||||
def tag(self) -> _TagAPI: ...
|
||||
|
||||
@property
|
||||
def message(self) -> _MessageAPI: ...
|
||||
|
||||
def fetch_access_token(self) -> object: ...
|
||||
|
||||
|
||||
class _AccessTokenResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", strict=True)
|
||||
|
||||
errcode: Literal[0]
|
||||
access_token: str = Field(min_length=1, pattern=r"\S")
|
||||
expires_in: int = Field(gt=0)
|
||||
|
||||
|
||||
class _AccessTokenStatus(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", strict=True)
|
||||
|
||||
errcode: StrictInt
|
||||
|
||||
|
||||
class _HTTPResponseMetadata(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", strict=True, from_attributes=True)
|
||||
|
||||
status_code: int | None = None
|
||||
|
||||
|
||||
class _WeComClientWithBoundedTokenFetch(WeChatClient):
|
||||
@override
|
||||
def fetch_access_token(self) -> object:
|
||||
response = httpx.get(
|
||||
_ACCESS_TOKEN_URL,
|
||||
params={
|
||||
"corpid": self.corp_id,
|
||||
"corpsecret": self.secret,
|
||||
},
|
||||
timeout=_SDK_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
status = _AccessTokenStatus.model_validate(payload)
|
||||
if status.errcode != 0:
|
||||
raise WeChatClientException(status.errcode, None, response=response)
|
||||
|
||||
token = _AccessTokenResponse.model_validate(payload)
|
||||
self.session.set(self.access_token_key, token.access_token, token.expires_in)
|
||||
self.expires_at = int(time.time()) + token.expires_in
|
||||
return token.model_dump()
|
||||
|
||||
|
||||
class _ScopedUser(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", frozen=True)
|
||||
|
||||
userid: StrictStr = Field(pattern=r"\S")
|
||||
|
||||
|
||||
class _AllowedDepartments(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", frozen=True)
|
||||
|
||||
partyid: tuple[Annotated[StrictInt, Field(gt=0)], ...] = ()
|
||||
|
||||
|
||||
class _AllowedUsers(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", frozen=True)
|
||||
|
||||
user: tuple[_ScopedUser, ...] = ()
|
||||
|
||||
|
||||
class _AllowedTags(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", frozen=True)
|
||||
|
||||
tagid: tuple[Annotated[StrictInt, Field(gt=0)], ...] = ()
|
||||
|
||||
|
||||
class _AgentResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", strict=True)
|
||||
|
||||
errcode: Literal[0]
|
||||
agentid: int = Field(gt=0)
|
||||
allow_partys: _AllowedDepartments = Field(default_factory=_AllowedDepartments)
|
||||
allow_userinfos: _AllowedUsers = Field(default_factory=_AllowedUsers)
|
||||
allow_tags: _AllowedTags = Field(default_factory=_AllowedTags)
|
||||
|
||||
|
||||
class _Department(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", frozen=True)
|
||||
|
||||
id: StrictInt = Field(gt=0)
|
||||
parentid: StrictInt = Field(ge=0)
|
||||
|
||||
|
||||
class _DepartmentList(RootModel[tuple[_Department, ...]]):
|
||||
pass
|
||||
|
||||
|
||||
class _DirectoryUser(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", frozen=True)
|
||||
|
||||
userid: StrictStr
|
||||
name: StrictStr | None = None
|
||||
email: StrictStr | None = None
|
||||
avatar_url: str | None = Field(default=None, alias="avatar")
|
||||
|
||||
@field_validator("avatar_url", mode="before")
|
||||
@classmethod
|
||||
def _normalize_avatar_url(cls, value: object) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
return value.strip() or None
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def _validate_email(cls, value: str | None) -> str | None:
|
||||
if value is None or not value.strip():
|
||||
return value
|
||||
if Address(addr_spec=value).addr_spec != value:
|
||||
raise ValueError("email must be an exact addr-spec")
|
||||
return value
|
||||
|
||||
|
||||
class _DirectoryUserList(RootModel[tuple[_DirectoryUser, ...]]):
|
||||
pass
|
||||
|
||||
|
||||
class _TagResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", frozen=True)
|
||||
|
||||
errcode: Literal[0]
|
||||
userlist: tuple[_ScopedUser, ...] = ()
|
||||
partylist: tuple[StrictInt, ...] = ()
|
||||
|
||||
|
||||
class _MessageSendResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", frozen=True)
|
||||
|
||||
errcode: Literal[0]
|
||||
invaliduser: StrictStr | None = None
|
||||
invalidparty: StrictStr | None = None
|
||||
invalidtag: StrictStr | None = None
|
||||
msgid: StrictStr
|
||||
|
||||
|
||||
class _DirectoryBoundaryError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class _WeComLocatorPayload(_Base64JSONLocatorPayload):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
|
||||
|
||||
# version of the locator
|
||||
v: Literal[1]
|
||||
# provider of the locator
|
||||
p: Literal[IMProvider.WE_COM]
|
||||
# WeCom application message identifier returned by the send API:
|
||||
# https://developer.work.weixin.qq.com/document/path/90236
|
||||
message_id: StrictStr = Field(pattern=r"\S")
|
||||
|
||||
|
||||
class _SDKAccessTokenProvider(AccessTokenProvider):
|
||||
def __init__(self, credentials: WeComCredentials) -> None:
|
||||
self._credentials = credentials
|
||||
|
||||
@override
|
||||
def get(self) -> str:
|
||||
return _fetch_access_token(_new_client(self._credentials))
|
||||
|
||||
|
||||
class _WeComDirectory(IMDirectory):
|
||||
def __init__(
|
||||
self,
|
||||
credentials: WeComCredentials,
|
||||
token_provider: AccessTokenProvider,
|
||||
) -> None:
|
||||
self._credentials = credentials
|
||||
self._token_provider = token_provider
|
||||
|
||||
@override
|
||||
def read_directory(self) -> Directory | DirectoryReadFailure:
|
||||
try:
|
||||
return self._read_complete_directory()
|
||||
# Provider SDKs may surface transport and decoding failures through
|
||||
# different exception classes. Directory never exposes partial state.
|
||||
except Exception:
|
||||
return DirectoryReadFailure(_DIRECTORY_READ_FAILED)
|
||||
|
||||
def _read_complete_directory(self) -> Directory:
|
||||
access_token = self._token_provider.get()
|
||||
client = _new_client(self._credentials, access_token=access_token)
|
||||
scope = _AgentResponse.model_validate(client.agent.get(int(self._credentials.agent_id)))
|
||||
if scope.agentid != int(self._credentials.agent_id):
|
||||
raise _DirectoryBoundaryError
|
||||
|
||||
entries: list[_DirectoryUser] = []
|
||||
seen_user_ids: set[str] = set()
|
||||
seen_department_ids: set[int] = set()
|
||||
|
||||
for department_id in scope.allow_partys.partyid:
|
||||
_read_department_scope(
|
||||
client,
|
||||
department_id,
|
||||
entries,
|
||||
seen_user_ids,
|
||||
seen_department_ids,
|
||||
)
|
||||
|
||||
for scoped_user in scope.allow_userinfos.user:
|
||||
_read_user_details(client, scoped_user.userid, entries, seen_user_ids)
|
||||
|
||||
for tag_id in scope.allow_tags.tagid:
|
||||
if tag_id <= 0:
|
||||
raise _DirectoryBoundaryError
|
||||
tag = _TagResponse.model_validate(client.tag.get_users(tag_id))
|
||||
for scoped_user in tag.userlist:
|
||||
_read_user_details(client, scoped_user.userid, entries, seen_user_ids)
|
||||
for department_id in tag.partylist:
|
||||
_read_department_scope(
|
||||
client,
|
||||
department_id,
|
||||
entries,
|
||||
seen_user_ids,
|
||||
seen_department_ids,
|
||||
)
|
||||
|
||||
avatars = read_avatars([user.avatar_url for user in entries])
|
||||
return Directory(
|
||||
tuple(
|
||||
DirectoryEntry(
|
||||
ProviderUserId(user.userid),
|
||||
_optional_non_blank(user.name),
|
||||
_optional_non_blank(user.email),
|
||||
avatar=avatar,
|
||||
)
|
||||
for user, avatar in zip(entries, avatars, strict=True)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class _WeComMessaging(IMMessaging):
|
||||
def __init__(
|
||||
self,
|
||||
credentials: WeComCredentials,
|
||||
token_provider: AccessTokenProvider,
|
||||
) -> None:
|
||||
self._credentials = credentials
|
||||
self._token_provider = token_provider
|
||||
|
||||
@override
|
||||
def send_text(
|
||||
self, provider_user_id: ProviderUserId, body: str, subject: str | None = None
|
||||
) -> MessageSendingResult:
|
||||
if subject is not None:
|
||||
body = f"{subject}\n\n{body}"
|
||||
try:
|
||||
access_token = self._token_provider.get()
|
||||
client = _new_client(self._credentials, access_token=access_token)
|
||||
response = _MessageSendResponse.model_validate(
|
||||
client.message.send_text(
|
||||
int(self._credentials.agent_id),
|
||||
[str(provider_user_id)],
|
||||
body,
|
||||
)
|
||||
)
|
||||
if any(
|
||||
_optional_non_blank(invalid_target) is not None
|
||||
for invalid_target in (response.invaliduser, response.invalidparty, response.invalidtag)
|
||||
):
|
||||
return MessageSendingError(_MESSAGE_ACCEPTANCE_UNCONFIRMED)
|
||||
if not response.msgid.strip():
|
||||
return MessageSendingError(_MESSAGE_ACCEPTANCE_UNCONFIRMED)
|
||||
return MessageAccepted(
|
||||
MessageLocator(
|
||||
_WeComLocatorPayload(
|
||||
v=1,
|
||||
p=IMProvider.WE_COM,
|
||||
message_id=response.msgid,
|
||||
).encode()
|
||||
)
|
||||
)
|
||||
# One mutation attempt is the maximum; every SDK/transport/response
|
||||
# failure is normalized without replay or Provider material.
|
||||
except Exception:
|
||||
return MessageSendingError(_MESSAGE_ACCEPTANCE_UNCONFIRMED)
|
||||
|
||||
|
||||
class WeComIMProviderAdapter:
|
||||
"""Externally serialized WeCom capability composition root."""
|
||||
|
||||
@classmethod
|
||||
def card_event_decoder(cls) -> IMCardEventDecoder | None:
|
||||
return None
|
||||
|
||||
def __init__(self, credentials: WeComCredentials) -> None:
|
||||
if not isinstance(credentials, WeComCredentials):
|
||||
raise TypeError("WeCom adapter requires resolved WeCom credentials")
|
||||
self._credentials = credentials
|
||||
token_provider = _SDKAccessTokenProvider(credentials)
|
||||
self._directory = _WeComDirectory(credentials, token_provider)
|
||||
self._messaging = _WeComMessaging(credentials, token_provider)
|
||||
self._closed = False
|
||||
|
||||
@property
|
||||
def provider(self) -> IMProvider:
|
||||
return IMProvider.WE_COM
|
||||
|
||||
def test_credentials(self) -> CredentialTestSuccess | CredentialTestFailure:
|
||||
try:
|
||||
return self._test_credentials()
|
||||
except (TimeoutError, httpx.TimeoutException, RequestsTimeout) as error:
|
||||
raise CredentialTestTimeoutError() from error
|
||||
except httpx.HTTPStatusError as error:
|
||||
try:
|
||||
rejection = _AccessTokenStatus.model_validate(error.response.json())
|
||||
except (JSONDecodeError, ValidationError):
|
||||
provider_error_code = None
|
||||
else:
|
||||
provider_error_code = str(rejection.errcode)
|
||||
return CredentialTestFailure(
|
||||
CredentialTestFailureKind.UNKNOWN,
|
||||
_CREDENTIAL_TEST_FAILED,
|
||||
provider_error_code=provider_error_code,
|
||||
http_status=error.response.status_code,
|
||||
)
|
||||
|
||||
def _test_credentials(self) -> CredentialTestSuccess | CredentialTestFailure:
|
||||
client = _new_client(self._credentials)
|
||||
try:
|
||||
_fetch_access_token(client)
|
||||
except WeChatClientException as error:
|
||||
if error.errcode is None and error.response is None:
|
||||
raise
|
||||
kind = (
|
||||
CredentialTestFailureKind.AUTHENTICATION_REJECTED
|
||||
if error.errcode in _AUTHENTICATION_REJECTION_CODES
|
||||
else CredentialTestFailureKind.UNKNOWN
|
||||
)
|
||||
return CredentialTestFailure(
|
||||
kind,
|
||||
_CREDENTIAL_REJECTED,
|
||||
provider_error_code=str(error.errcode) if error.errcode is not None else None,
|
||||
http_status=_HTTPResponseMetadata.model_validate(error.response).status_code
|
||||
if error.response is not None
|
||||
else None,
|
||||
)
|
||||
try:
|
||||
agent = _AgentResponse.model_validate(client.agent.get(int(self._credentials.agent_id)))
|
||||
except WeChatClientException as error:
|
||||
if error.errcode is None and error.response is None:
|
||||
raise
|
||||
kind = CredentialTestFailureKind.UNKNOWN
|
||||
if error.errcode in _TENANT_BOUNDARY_REJECTION_CODES:
|
||||
kind = CredentialTestFailureKind.PERMISSION_DENIED
|
||||
elif error.errcode in _AUTHENTICATION_REJECTION_CODES:
|
||||
kind = CredentialTestFailureKind.AUTHENTICATION_REJECTED
|
||||
return CredentialTestFailure(
|
||||
kind,
|
||||
_TENANT_BOUNDARY_UNAVAILABLE,
|
||||
provider_error_code=str(error.errcode) if error.errcode is not None else None,
|
||||
http_status=_HTTPResponseMetadata.model_validate(error.response).status_code
|
||||
if error.response is not None
|
||||
else None,
|
||||
)
|
||||
if agent.agentid != int(self._credentials.agent_id):
|
||||
raise IMProviderError("WeCom returned a different application identity.")
|
||||
return CredentialTestSuccess(IMProvider.WE_COM, self._credentials.corp_id)
|
||||
|
||||
@property
|
||||
def directory(self) -> IMDirectory:
|
||||
return self._directory
|
||||
|
||||
@property
|
||||
def messaging(self) -> IMMessaging:
|
||||
return self._messaging
|
||||
|
||||
@property
|
||||
def dynamic_card_messaging(self) -> IMDynamicCardMessaging | None:
|
||||
return None
|
||||
|
||||
def create_webhook_handler(self, consumer: IMEventConsumer) -> IMWebhookHandler | None:
|
||||
del consumer
|
||||
return None
|
||||
|
||||
def create_stream_handler(self, consumer: IMEventConsumer) -> IMEventStream | None:
|
||||
del consumer
|
||||
return None
|
||||
|
||||
def close(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
|
||||
|
||||
def _new_client(
|
||||
credentials: WeComCredentials,
|
||||
*,
|
||||
access_token: str | None = None,
|
||||
) -> _WeComClient:
|
||||
return _WeComClientWithBoundedTokenFetch(
|
||||
credentials.corp_id,
|
||||
credentials.secret,
|
||||
access_token=access_token,
|
||||
timeout=_SDK_TIMEOUT_SECONDS,
|
||||
auto_retry=False,
|
||||
)
|
||||
|
||||
|
||||
def _fetch_access_token(client: _WeComClient) -> str:
|
||||
response = _AccessTokenResponse.model_validate(client.fetch_access_token())
|
||||
return response.access_token
|
||||
|
||||
|
||||
def _read_department_scope(
|
||||
client: _WeComClient,
|
||||
root_department_id: int,
|
||||
entries: list[_DirectoryUser],
|
||||
seen_user_ids: set[str],
|
||||
seen_department_ids: set[int],
|
||||
) -> None:
|
||||
if root_department_id <= 0:
|
||||
raise _DirectoryBoundaryError
|
||||
if root_department_id in seen_department_ids:
|
||||
return
|
||||
|
||||
departments = _DepartmentList.model_validate(client.department.get(root_department_id)).root
|
||||
_validate_department_topology(root_department_id, departments)
|
||||
for department in departments:
|
||||
if department.id in seen_department_ids:
|
||||
continue
|
||||
seen_department_ids.add(department.id)
|
||||
users = _DirectoryUserList.model_validate(
|
||||
client.user.list(
|
||||
department.id,
|
||||
fetch_child=False,
|
||||
status=0,
|
||||
simple=False,
|
||||
)
|
||||
).root
|
||||
for user in users:
|
||||
_append_directory_user(user, entries, seen_user_ids)
|
||||
|
||||
|
||||
def _validate_department_topology(
|
||||
root_department_id: int,
|
||||
departments: tuple[_Department, ...],
|
||||
) -> None:
|
||||
parent_by_department: dict[int, int] = {}
|
||||
for department in departments:
|
||||
if department.id in parent_by_department:
|
||||
raise _DirectoryBoundaryError
|
||||
parent_by_department[department.id] = department.parentid
|
||||
|
||||
if root_department_id not in parent_by_department:
|
||||
raise _DirectoryBoundaryError
|
||||
if parent_by_department[root_department_id] in parent_by_department:
|
||||
raise _DirectoryBoundaryError
|
||||
|
||||
for department_id in parent_by_department:
|
||||
current_department_id = department_id
|
||||
visited: set[int] = set()
|
||||
while current_department_id != root_department_id:
|
||||
if current_department_id in visited:
|
||||
raise _DirectoryBoundaryError
|
||||
visited.add(current_department_id)
|
||||
parent_id = parent_by_department.get(current_department_id)
|
||||
if parent_id is None:
|
||||
raise _DirectoryBoundaryError
|
||||
current_department_id = parent_id
|
||||
|
||||
|
||||
def _read_user_details(
|
||||
client: _WeComClient,
|
||||
user_id: str,
|
||||
entries: list[_DirectoryUser],
|
||||
seen_user_ids: set[str],
|
||||
) -> None:
|
||||
_require_valid_user_id(user_id)
|
||||
if user_id in seen_user_ids:
|
||||
return
|
||||
user = _DirectoryUser.model_validate(client.user.get(user_id))
|
||||
if user.userid != user_id:
|
||||
raise _DirectoryBoundaryError
|
||||
_append_directory_user(user, entries, seen_user_ids)
|
||||
|
||||
|
||||
def _append_directory_user(
|
||||
user: _DirectoryUser,
|
||||
entries: list[_DirectoryUser],
|
||||
seen_user_ids: set[str],
|
||||
) -> None:
|
||||
_require_valid_user_id(user.userid)
|
||||
if user.userid in seen_user_ids:
|
||||
return
|
||||
seen_user_ids.add(user.userid)
|
||||
entries.append(user)
|
||||
|
||||
|
||||
def _require_valid_user_id(user_id: str) -> None:
|
||||
if not user_id.strip():
|
||||
raise _DirectoryBoundaryError
|
||||
|
||||
|
||||
def _optional_non_blank(value: str | None) -> str | None:
|
||||
if value is None or not value.strip():
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
__all__ = ["WeComIMProviderAdapter"]
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Transport-neutral results and failures for manual IM binding commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import NaiveDatetime
|
||||
|
||||
from core.human_input_v2.entities import HumanInputContactType
|
||||
from core.human_input_v2.shared import ContactId
|
||||
from repositories.human_input_v2.contact import IMBinding
|
||||
|
||||
|
||||
class IMBindingCommandErrorCode(StrEnum):
|
||||
"""Stable application failures suitable for transport-specific mapping."""
|
||||
|
||||
INTEGRATION_NOT_CONFIGURED = "im_integration_not_configured"
|
||||
CONTACT_NOT_FOUND = "contact_not_found"
|
||||
IDENTITY_NOT_FOUND = "im_identity_not_found"
|
||||
BINDING_NOT_FOUND = "im_binding_not_found"
|
||||
BINDING_CONFLICT = "im_binding_conflict"
|
||||
INVALID_SCOPE = "invalid_im_binding_scope"
|
||||
|
||||
|
||||
class IMBindingCommandError(RuntimeError):
|
||||
"""Expected manual-binding rejection without persistence implementation detail."""
|
||||
|
||||
def __init__(self, code: IMBindingCommandErrorCode, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactIMBindingView:
|
||||
"""Current workspace Contact projection after one binding mutation."""
|
||||
|
||||
id: ContactId
|
||||
type: HumanInputContactType
|
||||
name: str
|
||||
email: str | None
|
||||
avatar_file_id: str | None
|
||||
im_bindings: tuple[IMBinding, ...]
|
||||
created_at: NaiveDatetime
|
||||
|
||||
|
||||
__all__ = ["ContactIMBindingView", "IMBindingCommandError", "IMBindingCommandErrorCode"]
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Immutable append-only reconciliation change-log values."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import NaiveDatetime
|
||||
|
||||
from core.human_input_v2.entities import IMProvider
|
||||
from core.human_input_v2.im_integration.adapters.entities import ProviderUserId
|
||||
from core.human_input_v2.shared import (
|
||||
ContactId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IMReconciliationChangeId,
|
||||
IMSyncRunId,
|
||||
NormalizedEmail,
|
||||
)
|
||||
from repositories.human_input_v2.im_channel_repository import IMChannelId
|
||||
|
||||
|
||||
class IMReconciliationSubjectKind(StrEnum):
|
||||
IDENTITY = "identity"
|
||||
BINDING = "binding"
|
||||
|
||||
|
||||
class IMReconciliationOperation(StrEnum):
|
||||
CREATE = "create"
|
||||
UPDATE = "update"
|
||||
REFRESH = "refresh"
|
||||
REPLACE = "replace"
|
||||
DELETE = "delete"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IMIdentityChangeSnapshot:
|
||||
identity_id: IMIdentityId
|
||||
provider: IMProvider
|
||||
provider_user_id: ProviderUserId
|
||||
display_name: str | None
|
||||
email: str | None
|
||||
normalized_email: NormalizedEmail | None
|
||||
last_seen_sync_run_id: IMSyncRunId | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IMBindingChangeSnapshot:
|
||||
binding_id: IMBindingId
|
||||
identity_id: IMIdentityId
|
||||
contact_id: ContactId
|
||||
|
||||
|
||||
type IMReconciliationSnapshot = IMIdentityChangeSnapshot | IMBindingChangeSnapshot
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IMReconciliationChange:
|
||||
"""One append-only identity or IM binding mutation committed by a sync run."""
|
||||
|
||||
id: IMReconciliationChangeId
|
||||
channel_id: IMChannelId
|
||||
sync_run_id: IMSyncRunId
|
||||
operation_key: str
|
||||
subject_kind: IMReconciliationSubjectKind
|
||||
operation: IMReconciliationOperation
|
||||
reason_code: str
|
||||
identity_id: IMIdentityId
|
||||
binding_id: IMBindingId | None
|
||||
contact_id: ContactId | None
|
||||
before: IMReconciliationSnapshot | None
|
||||
after: IMReconciliationSnapshot | None
|
||||
committed_at: NaiveDatetime
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.operation_key:
|
||||
raise ValueError("operation key must not be empty")
|
||||
expected_snapshot_type = (
|
||||
IMIdentityChangeSnapshot
|
||||
if self.subject_kind is IMReconciliationSubjectKind.IDENTITY
|
||||
else IMBindingChangeSnapshot
|
||||
)
|
||||
if self.before is not None and not isinstance(self.before, expected_snapshot_type):
|
||||
raise ValueError("before snapshot does not match subject kind")
|
||||
if self.after is not None and not isinstance(self.after, expected_snapshot_type):
|
||||
raise ValueError("after snapshot does not match subject kind")
|
||||
if self.operation is IMReconciliationOperation.CREATE and (self.before is not None or self.after is None):
|
||||
raise ValueError("create requires only an after snapshot")
|
||||
if self.operation is IMReconciliationOperation.DELETE and (self.before is None or self.after is not None):
|
||||
raise ValueError("delete requires only a before snapshot")
|
||||
if self.operation not in (IMReconciliationOperation.CREATE, IMReconciliationOperation.DELETE) and (
|
||||
self.before is None or self.after is None
|
||||
):
|
||||
raise ValueError("mutation requires before and after snapshots")
|
||||
if self.subject_kind is IMReconciliationSubjectKind.IDENTITY:
|
||||
if self.operation is IMReconciliationOperation.REPLACE or self.binding_id is not None:
|
||||
raise ValueError("identity change cannot represent an IM binding replacement")
|
||||
elif self.operation in (IMReconciliationOperation.UPDATE, IMReconciliationOperation.REFRESH):
|
||||
raise ValueError("IM binding change cannot use an identity-only operation")
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Transaction-oriented persistence ports for IM Control Plane invariants.
|
||||
|
||||
Implementations own CAS predicates, Channel row locks, eager loading,
|
||||
revision-guarded apply, rollback, and append-only result persistence. Generic
|
||||
table CRUD is intentionally absent.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Protocol
|
||||
|
||||
from pydantic import NaiveDatetime
|
||||
|
||||
from core.human_input_v2.entities import IMProvider, IMSyncResultType
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
DirectoryScope,
|
||||
IMIdentityId,
|
||||
IMSyncRunId,
|
||||
)
|
||||
from repositories.human_input_v2.im_channel_repository import IMChannelId
|
||||
|
||||
from .sync_reconciliation import ReconciliationReasonCode
|
||||
from .sync_records import (
|
||||
IMChannelRevision,
|
||||
IMSyncRun,
|
||||
StaleRevision,
|
||||
SynchronizedIMIdentityPage,
|
||||
SyncResultFact,
|
||||
SyncResultPage,
|
||||
)
|
||||
|
||||
|
||||
class ActiveRunDecisionKind(StrEnum):
|
||||
"""Outcome of Channel-locked sync run creation."""
|
||||
|
||||
# No active run existed and a new revision-bound synchronization run was committed.
|
||||
CREATED = "created"
|
||||
|
||||
# An active run already exists, so the repository returns it instead of creating a duplicate.
|
||||
EXISTING_ACTIVE = "existing_active"
|
||||
|
||||
# The captured Channel revision is no longer current and no new run may be created from it.
|
||||
STALE_REVISION = "stale_revision"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ActiveRunDecision:
|
||||
"""New or existing active run, or a stable stale-revision rejection."""
|
||||
|
||||
kind: ActiveRunDecisionKind
|
||||
run: IMSyncRun | None
|
||||
stale_revision: StaleRevision | None = None
|
||||
|
||||
|
||||
class ApplyReconciliationStatus(StrEnum):
|
||||
"""Stable outcome of one idempotent revision-guarded apply."""
|
||||
|
||||
# The plan was applied to current state and its append-only result facts were committed.
|
||||
APPLIED = "applied"
|
||||
|
||||
# This completed run was applied earlier; persisted facts are returned for idempotent replay.
|
||||
ALREADY_APPLIED = "already_applied"
|
||||
|
||||
# Channel configuration changed after capture, so the plan cannot mutate current state.
|
||||
STALE_REVISION = "stale_revision"
|
||||
|
||||
LOCK_UNAVAILABLE = "lock_unavailable"
|
||||
LOCK_LOST = "lock_lost"
|
||||
PRECONDITION_FAILED = "precondition_failed"
|
||||
DIRECTORY_READ_FAILED = "directory_read_failed"
|
||||
PLAN_BLOCKED = "plan_blocked"
|
||||
UNEXPECTED_APPLY_FAILURE = "unexpected_apply_failure"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResolvedReconciliationWarning:
|
||||
warning_key: str
|
||||
reason: ReconciliationReasonCode
|
||||
identity_ids: tuple[IMIdentityId, ...]
|
||||
contact_ids: tuple[ContactId, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplyReconciliationResult:
|
||||
"""Transport-neutral persisted outcome of one conditional plan apply."""
|
||||
|
||||
status: ApplyReconciliationStatus
|
||||
sync_run_id: IMSyncRunId
|
||||
committed_at: NaiveDatetime | None
|
||||
result_count: int
|
||||
change_count: int
|
||||
warnings: tuple[ResolvedReconciliationWarning, ...]
|
||||
|
||||
|
||||
class IMSyncRepository(Protocol):
|
||||
"""Transport-neutral command and query persistence required by IM sync."""
|
||||
|
||||
def create_or_get_active_run(
|
||||
self,
|
||||
channel_revision: IMChannelRevision,
|
||||
*,
|
||||
organization_scope: DirectoryScope,
|
||||
sync_run_id: IMSyncRunId,
|
||||
started_by_account_id: AccountId | None,
|
||||
now: NaiveDatetime,
|
||||
) -> ActiveRunDecision:
|
||||
"""Lock Channel and return at most one active run."""
|
||||
...
|
||||
|
||||
def load_sync_run(self, sync_run_id: IMSyncRunId) -> IMSyncRun | None:
|
||||
"""Load one persisted run without acquiring the Organization write lock."""
|
||||
...
|
||||
|
||||
def load_latest_sync_run(self, channel_id: IMChannelId) -> IMSyncRun | None:
|
||||
"""Load the latest run for one current Channel."""
|
||||
...
|
||||
|
||||
def page_sync_results(
|
||||
self,
|
||||
sync_run_id: IMSyncRunId,
|
||||
result_type: IMSyncResultType,
|
||||
*,
|
||||
page: int,
|
||||
limit: int,
|
||||
) -> SyncResultPage:
|
||||
"""Page one required product-result bucket for one exact run."""
|
||||
...
|
||||
|
||||
def search_identities(
|
||||
self,
|
||||
channel_id: IMChannelId,
|
||||
provider: IMProvider,
|
||||
*,
|
||||
keyword: str | None,
|
||||
page: int,
|
||||
limit: int,
|
||||
) -> SynchronizedIMIdentityPage:
|
||||
"""Search current identities without exposing raw payload or ORM state."""
|
||||
...
|
||||
|
||||
def append_sync_results(self, results: tuple[SyncResultFact, ...]) -> None:
|
||||
"""Append diagnostic result facts without changing current state."""
|
||||
...
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ActiveRunDecision",
|
||||
"ActiveRunDecisionKind",
|
||||
"ApplyReconciliationResult",
|
||||
"ApplyReconciliationStatus",
|
||||
"IMSyncRepository",
|
||||
"ResolvedReconciliationWarning",
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Historical synchronization payload values."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from pydantic import ConfigDict, JsonValue, RootModel
|
||||
|
||||
|
||||
class OpaqueProviderPayload(RootModel[dict[str, JsonValue]]):
|
||||
"""Opaque JSON retained by historical synchronization facts."""
|
||||
|
||||
model_config = ConfigDict(frozen=True, strict=True, validate_default=True)
|
||||
|
||||
# TODO(QuantumGhost): Remove from_mapping and to_mapping.
|
||||
@classmethod
|
||||
def from_mapping(cls, values: Mapping[str, JsonValue]) -> OpaqueProviderPayload:
|
||||
return cls(dict(values))
|
||||
|
||||
def to_mapping(self) -> dict[str, JsonValue]:
|
||||
return dict(self.root)
|
||||
|
||||
|
||||
__all__ = ["OpaqueProviderPayload"]
|
||||
@@ -0,0 +1,626 @@
|
||||
"""Pure provider directory matching and immutable reconciliation plans."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from collections import Counter, defaultdict
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from core.human_input_v2.entities import IMProvider, IMSyncRemovalReason, IMSyncResultType
|
||||
from core.human_input_v2.im_integration.adapters.entities import DirectoryEntry, ProviderUserId
|
||||
from core.human_input_v2.shared import (
|
||||
ContactId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IMSyncRunId,
|
||||
NormalizedEmail,
|
||||
)
|
||||
|
||||
from .sync_records import IMChannelRevision
|
||||
|
||||
|
||||
class IMIdentityUpsertKind(StrEnum):
|
||||
"""Semantic identity outcome selected by the pure planner."""
|
||||
|
||||
CREATE = "create"
|
||||
UPDATE = "update"
|
||||
REFRESH = "refresh"
|
||||
|
||||
|
||||
class ReconciliationBlockCode(StrEnum):
|
||||
"""Whole-plan input or current-state invariants that prevent safe apply."""
|
||||
|
||||
DUPLICATE_PROVIDER_USER_ID = "duplicate_provider_user_id"
|
||||
DUPLICATE_CURRENT_IDENTITY = "duplicate_current_identity"
|
||||
INVALID_CURRENT_BINDING = "invalid_current_binding"
|
||||
INVALID_RECONCILED_BINDING_SET = "invalid_reconciled_binding_set"
|
||||
|
||||
|
||||
class ReconciliationReasonCode(StrEnum):
|
||||
"""Stable explanations for reconciliation changes and non-matches."""
|
||||
|
||||
PROVIDER_USER_ID_MATCH = "provider_user_id_match"
|
||||
NORMALIZED_EMAIL_MATCH = "normalized_email_match"
|
||||
MISSING_EMAIL = "missing_email"
|
||||
NO_CONTACT_MATCH = "no_contact_match"
|
||||
AMBIGUOUS_CONTACT_EMAIL = "ambiguous_contact_email"
|
||||
AMBIGUOUS_PROVIDER_EMAIL = "ambiguous_provider_email"
|
||||
CONTACT_ALREADY_BOUND = "contact_already_bound"
|
||||
IDENTITY_ABSENT_FROM_DIRECTORY = "identity_absent_from_directory"
|
||||
BINDING_REPLACED = "binding_replaced"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReconciliationRunRef:
|
||||
"""Immutable reconciliation namespace captured when a sync run starts."""
|
||||
|
||||
sync_run_id: IMSyncRunId
|
||||
channel_revision: IMChannelRevision
|
||||
provider: IMProvider
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CurrentIMIdentityState:
|
||||
"""Current persisted identity facts required by plan generation."""
|
||||
|
||||
identity_id: IMIdentityId
|
||||
provider_user_id: ProviderUserId
|
||||
display_name: str | None
|
||||
email: str | None
|
||||
normalized_email: NormalizedEmail | None
|
||||
last_seen_sync_run_id: IMSyncRunId | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactEmailMatchState:
|
||||
"""A scope-resolved Contact available for automatic email matching."""
|
||||
|
||||
contact_id: ContactId
|
||||
display_name: str
|
||||
email: str | None
|
||||
normalized_email: NormalizedEmail
|
||||
avatar_file_id: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CurrentIMBindingState:
|
||||
"""Current Contact-to-IM-identity binding without persistence scope."""
|
||||
|
||||
binding_id: IMBindingId
|
||||
identity_id: IMIdentityId
|
||||
contact_id: ContactId
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReconciliationInput:
|
||||
"""Complete immutable facts loaded before the pure planner is invoked."""
|
||||
|
||||
run: ReconciliationRunRef
|
||||
directory_entries: tuple[DirectoryEntry, ...]
|
||||
current_identities: tuple[CurrentIMIdentityState, ...]
|
||||
current_bindings: tuple[CurrentIMBindingState, ...]
|
||||
reconciled_binding_ids: frozenset[IMBindingId]
|
||||
contacts_for_email_matching: tuple[ContactEmailMatchState, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExistingIMIdentityRef:
|
||||
identity_id: IMIdentityId
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NewIMIdentityRef:
|
||||
provider_user_id: ProviderUserId
|
||||
|
||||
|
||||
type IMIdentityRef = ExistingIMIdentityRef | NewIMIdentityRef
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IMIdentityUpsert:
|
||||
operation_key: str
|
||||
kind: IMIdentityUpsertKind
|
||||
identity_ref: IMIdentityRef
|
||||
entry: DirectoryEntry
|
||||
normalized_email: NormalizedEmail | None
|
||||
before: CurrentIMIdentityState | None
|
||||
changed_fields: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CreateIMBinding:
|
||||
operation_key: str
|
||||
identity_ref: IMIdentityRef
|
||||
contact_id: ContactId
|
||||
contact_precondition: ContactEmailMatchState
|
||||
reason: ReconciliationReasonCode
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.contact_precondition.contact_id != self.contact_id:
|
||||
raise ValueError("Contact precondition must describe the binding target")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReplaceIMBinding:
|
||||
operation_key: str
|
||||
before: CurrentIMBindingState
|
||||
next_identity_ref: IMIdentityRef
|
||||
contact_precondition: ContactEmailMatchState
|
||||
reason: ReconciliationReasonCode
|
||||
removal_reason: IMSyncRemovalReason
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.contact_precondition.contact_id != self.before.contact_id:
|
||||
raise ValueError("Contact precondition must describe the binding target")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeleteIMBinding:
|
||||
operation_key: str
|
||||
before: CurrentIMBindingState
|
||||
reason: ReconciliationReasonCode
|
||||
removal_reason: IMSyncRemovalReason
|
||||
|
||||
|
||||
type IMBindingMutation = CreateIMBinding | ReplaceIMBinding | DeleteIMBinding
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IMIdentityDeletion:
|
||||
operation_key: str
|
||||
before: CurrentIMIdentityState
|
||||
reason: ReconciliationReasonCode
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PlannedSyncResult:
|
||||
operation_key: str
|
||||
result_type: IMSyncResultType
|
||||
provider_user_id: ProviderUserId | None
|
||||
identity_ref: IMIdentityRef | None
|
||||
binding_id: IMBindingId | None
|
||||
contact_id: ContactId | None
|
||||
reason_code: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PlannedReconciliationWarning:
|
||||
warning_key: str
|
||||
reason: ReconciliationReasonCode
|
||||
identity_refs: tuple[IMIdentityRef, ...]
|
||||
contact_ids: tuple[ContactId, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReconciliationPlan:
|
||||
"""Complete deterministic plan applied in dependency order."""
|
||||
|
||||
run: ReconciliationRunRef
|
||||
identity_upserts: tuple[IMIdentityUpsert, ...]
|
||||
binding_mutations: tuple[IMBindingMutation, ...]
|
||||
identity_deletions: tuple[IMIdentityDeletion, ...]
|
||||
sync_results: tuple[PlannedSyncResult, ...]
|
||||
warnings: tuple[PlannedReconciliationWarning, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReconciliationBlock:
|
||||
code: ReconciliationBlockCode
|
||||
subject_key: str | None
|
||||
message: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BlockedReconciliation:
|
||||
run: ReconciliationRunRef
|
||||
blockers: tuple[ReconciliationBlock, ...]
|
||||
|
||||
|
||||
type PlanGenerationResult = ReconciliationPlan | BlockedReconciliation
|
||||
|
||||
|
||||
class SyncReconciler:
|
||||
"""Stateless composite planner with no transport or infrastructure dependencies."""
|
||||
|
||||
@staticmethod
|
||||
def generate_plan(reconciliation_input: ReconciliationInput) -> PlanGenerationResult:
|
||||
blockers = _validate_input(reconciliation_input)
|
||||
if blockers:
|
||||
return BlockedReconciliation(reconciliation_input.run, blockers)
|
||||
|
||||
entries = tuple(sorted(reconciliation_input.directory_entries, key=lambda entry: str(entry.provider_user_id)))
|
||||
identities = tuple(
|
||||
sorted(reconciliation_input.current_identities, key=lambda identity: str(identity.identity_id))
|
||||
)
|
||||
current_identity_by_provider_id = {identity.provider_user_id: identity for identity in identities}
|
||||
directory_provider_ids = {entry.provider_user_id for entry in entries}
|
||||
|
||||
identity_upserts: list[IMIdentityUpsert] = []
|
||||
identity_ref_by_provider_id: dict[ProviderUserId, IMIdentityRef] = {}
|
||||
normalized_email_by_provider_id: dict[ProviderUserId, NormalizedEmail | None] = {}
|
||||
for entry in entries:
|
||||
current_identity = current_identity_by_provider_id.get(entry.provider_user_id)
|
||||
normalized_email = _normalize_email(entry.email)
|
||||
identity_ref: IMIdentityRef
|
||||
if current_identity is None:
|
||||
kind = IMIdentityUpsertKind.CREATE
|
||||
identity_ref = NewIMIdentityRef(entry.provider_user_id)
|
||||
changed_fields: tuple[str, ...] = ()
|
||||
else:
|
||||
identity_ref = ExistingIMIdentityRef(current_identity.identity_id)
|
||||
changed_fields = _changed_identity_fields(current_identity, entry, normalized_email)
|
||||
kind = IMIdentityUpsertKind.UPDATE if changed_fields else IMIdentityUpsertKind.REFRESH
|
||||
identity_ref_by_provider_id[entry.provider_user_id] = identity_ref
|
||||
normalized_email_by_provider_id[entry.provider_user_id] = normalized_email
|
||||
identity_upserts.append(
|
||||
IMIdentityUpsert(
|
||||
operation_key=_stable_key("identity", kind.value, str(entry.provider_user_id)),
|
||||
kind=kind,
|
||||
identity_ref=identity_ref,
|
||||
entry=entry,
|
||||
normalized_email=normalized_email,
|
||||
before=current_identity,
|
||||
changed_fields=changed_fields,
|
||||
)
|
||||
)
|
||||
|
||||
current_identity_by_id = {identity.identity_id: identity for identity in identities}
|
||||
current_binding_by_id = {
|
||||
binding.binding_id: binding
|
||||
for binding in sorted(reconciliation_input.current_bindings, key=lambda binding: str(binding.binding_id))
|
||||
}
|
||||
reconciled_bindings = tuple(
|
||||
current_binding_by_id[binding_id]
|
||||
for binding_id in sorted(reconciliation_input.reconciled_binding_ids, key=str)
|
||||
)
|
||||
reconciled_binding_by_identity = {binding.identity_id: binding for binding in reconciled_bindings}
|
||||
reconciled_binding_by_contact = {binding.contact_id: binding for binding in reconciled_bindings}
|
||||
|
||||
preserved_binding_by_provider_id: dict[ProviderUserId, CurrentIMBindingState] = {}
|
||||
for entry in entries:
|
||||
identity = current_identity_by_provider_id.get(entry.provider_user_id)
|
||||
if identity is None:
|
||||
continue
|
||||
binding = reconciled_binding_by_identity.get(identity.identity_id)
|
||||
if binding is not None:
|
||||
preserved_binding_by_provider_id[entry.provider_user_id] = binding
|
||||
|
||||
unbound_entries_by_email: dict[NormalizedEmail, list[DirectoryEntry]] = defaultdict(list)
|
||||
for entry in entries:
|
||||
normalized_email = normalized_email_by_provider_id[entry.provider_user_id]
|
||||
if entry.provider_user_id not in preserved_binding_by_provider_id and normalized_email is not None:
|
||||
unbound_entries_by_email[normalized_email].append(entry)
|
||||
|
||||
contacts_by_email: dict[NormalizedEmail, list[ContactEmailMatchState]] = defaultdict(list)
|
||||
for contact in sorted(
|
||||
reconciliation_input.contacts_for_email_matching, key=lambda contact: str(contact.contact_id)
|
||||
):
|
||||
contacts_by_email[contact.normalized_email].append(contact)
|
||||
|
||||
binding_mutations: list[IMBindingMutation] = []
|
||||
sync_results: list[PlannedSyncResult] = []
|
||||
warnings: list[PlannedReconciliationWarning] = []
|
||||
replaced_binding_ids: set[IMBindingId] = set()
|
||||
warned_contact_emails: set[NormalizedEmail] = set()
|
||||
|
||||
for entry in entries:
|
||||
identity_ref = identity_ref_by_provider_id[entry.provider_user_id]
|
||||
preserved_binding = preserved_binding_by_provider_id.get(entry.provider_user_id)
|
||||
if preserved_binding is not None:
|
||||
sync_results.append(
|
||||
_planned_result(
|
||||
IMSyncResultType.SKIPPED,
|
||||
entry.provider_user_id,
|
||||
identity_ref,
|
||||
binding_id=preserved_binding.binding_id,
|
||||
contact_id=preserved_binding.contact_id,
|
||||
reason=ReconciliationReasonCode.PROVIDER_USER_ID_MATCH,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
normalized_email = normalized_email_by_provider_id[entry.provider_user_id]
|
||||
if normalized_email is None:
|
||||
sync_results.append(
|
||||
_not_matched_result(entry.provider_user_id, identity_ref, ReconciliationReasonCode.MISSING_EMAIL)
|
||||
)
|
||||
continue
|
||||
|
||||
matching_contacts = contacts_by_email.get(normalized_email, [])
|
||||
if len(matching_contacts) > 1:
|
||||
sync_results.append(
|
||||
_not_matched_result(
|
||||
entry.provider_user_id, identity_ref, ReconciliationReasonCode.AMBIGUOUS_CONTACT_EMAIL
|
||||
)
|
||||
)
|
||||
if normalized_email not in warned_contact_emails:
|
||||
affected_entries = unbound_entries_by_email[normalized_email]
|
||||
warnings.append(
|
||||
PlannedReconciliationWarning(
|
||||
warning_key=_stable_key(
|
||||
"warning",
|
||||
ReconciliationReasonCode.AMBIGUOUS_CONTACT_EMAIL.value,
|
||||
str(normalized_email),
|
||||
),
|
||||
reason=ReconciliationReasonCode.AMBIGUOUS_CONTACT_EMAIL,
|
||||
identity_refs=tuple(
|
||||
identity_ref_by_provider_id[item.provider_user_id]
|
||||
for item in sorted(affected_entries, key=lambda item: str(item.provider_user_id))
|
||||
),
|
||||
contact_ids=tuple(contact.contact_id for contact in matching_contacts),
|
||||
)
|
||||
)
|
||||
warned_contact_emails.add(normalized_email)
|
||||
continue
|
||||
|
||||
if not matching_contacts:
|
||||
sync_results.append(
|
||||
_not_matched_result(entry.provider_user_id, identity_ref, ReconciliationReasonCode.NO_CONTACT_MATCH)
|
||||
)
|
||||
continue
|
||||
|
||||
if len(unbound_entries_by_email[normalized_email]) > 1:
|
||||
sync_results.append(
|
||||
_not_matched_result(
|
||||
entry.provider_user_id, identity_ref, ReconciliationReasonCode.AMBIGUOUS_PROVIDER_EMAIL
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
contact = matching_contacts[0]
|
||||
occupying_binding = reconciled_binding_by_contact.get(contact.contact_id)
|
||||
if occupying_binding is not None:
|
||||
occupying_identity = current_identity_by_id[occupying_binding.identity_id]
|
||||
if occupying_identity.provider_user_id in directory_provider_ids:
|
||||
sync_results.append(
|
||||
_not_matched_result(
|
||||
entry.provider_user_id, identity_ref, ReconciliationReasonCode.CONTACT_ALREADY_BOUND
|
||||
)
|
||||
)
|
||||
continue
|
||||
binding_mutations.append(
|
||||
ReplaceIMBinding(
|
||||
operation_key=_stable_key(
|
||||
"binding", "replace", str(occupying_binding.binding_id), str(entry.provider_user_id)
|
||||
),
|
||||
before=occupying_binding,
|
||||
next_identity_ref=identity_ref,
|
||||
contact_precondition=contact,
|
||||
reason=ReconciliationReasonCode.NORMALIZED_EMAIL_MATCH,
|
||||
removal_reason=IMSyncRemovalReason.BINDING_REPLACED,
|
||||
)
|
||||
)
|
||||
replaced_binding_ids.add(occupying_binding.binding_id)
|
||||
else:
|
||||
binding_mutations.append(
|
||||
CreateIMBinding(
|
||||
operation_key=_stable_key(
|
||||
"binding", "create", str(entry.provider_user_id), str(contact.contact_id)
|
||||
),
|
||||
identity_ref=identity_ref,
|
||||
contact_id=contact.contact_id,
|
||||
contact_precondition=contact,
|
||||
reason=ReconciliationReasonCode.NORMALIZED_EMAIL_MATCH,
|
||||
)
|
||||
)
|
||||
sync_results.append(
|
||||
_planned_result(
|
||||
IMSyncResultType.ADDED,
|
||||
entry.provider_user_id,
|
||||
identity_ref,
|
||||
binding_id=None,
|
||||
contact_id=contact.contact_id,
|
||||
reason=ReconciliationReasonCode.NORMALIZED_EMAIL_MATCH,
|
||||
)
|
||||
)
|
||||
|
||||
absent_identities = tuple(
|
||||
identity for identity in identities if identity.provider_user_id not in directory_provider_ids
|
||||
)
|
||||
absent_identity_ids = {identity.identity_id for identity in absent_identities}
|
||||
for binding in current_binding_by_id.values():
|
||||
if binding.identity_id not in absent_identity_ids:
|
||||
continue
|
||||
previous_identity = current_identity_by_id[binding.identity_id]
|
||||
if binding.binding_id in replaced_binding_ids:
|
||||
removal_reason = IMSyncRemovalReason.BINDING_REPLACED
|
||||
reason = ReconciliationReasonCode.BINDING_REPLACED
|
||||
else:
|
||||
removal_reason = IMSyncRemovalReason.NOT_PRESENT_IN_DIRECTORY
|
||||
reason = ReconciliationReasonCode.IDENTITY_ABSENT_FROM_DIRECTORY
|
||||
binding_mutations.append(
|
||||
DeleteIMBinding(
|
||||
operation_key=_stable_key("binding", "delete", str(binding.binding_id)),
|
||||
before=binding,
|
||||
reason=reason,
|
||||
removal_reason=removal_reason,
|
||||
)
|
||||
)
|
||||
sync_results.append(
|
||||
_planned_result(
|
||||
IMSyncResultType.REMOVED,
|
||||
previous_identity.provider_user_id,
|
||||
ExistingIMIdentityRef(previous_identity.identity_id),
|
||||
binding_id=binding.binding_id,
|
||||
contact_id=binding.contact_id,
|
||||
reason=reason,
|
||||
)
|
||||
)
|
||||
|
||||
identity_deletions = tuple(
|
||||
IMIdentityDeletion(
|
||||
operation_key=_stable_key("identity", "delete", str(identity.identity_id)),
|
||||
before=identity,
|
||||
reason=ReconciliationReasonCode.IDENTITY_ABSENT_FROM_DIRECTORY,
|
||||
)
|
||||
for identity in absent_identities
|
||||
)
|
||||
return ReconciliationPlan(
|
||||
run=reconciliation_input.run,
|
||||
identity_upserts=tuple(identity_upserts),
|
||||
binding_mutations=tuple(binding_mutations),
|
||||
identity_deletions=identity_deletions,
|
||||
sync_results=tuple(sync_results),
|
||||
warnings=tuple(warnings),
|
||||
)
|
||||
|
||||
|
||||
def _validate_input(reconciliation_input: ReconciliationInput) -> tuple[ReconciliationBlock, ...]:
|
||||
blockers: list[ReconciliationBlock] = []
|
||||
directory_provider_ids = [entry.provider_user_id for entry in reconciliation_input.directory_entries]
|
||||
for provider_user_id, count in Counter(directory_provider_ids).items():
|
||||
if count > 1:
|
||||
blockers.append(
|
||||
ReconciliationBlock(
|
||||
ReconciliationBlockCode.DUPLICATE_PROVIDER_USER_ID,
|
||||
str(provider_user_id),
|
||||
"Directory contains a duplicate Provider user ID.",
|
||||
)
|
||||
)
|
||||
|
||||
current_identity_ids = [identity.identity_id for identity in reconciliation_input.current_identities]
|
||||
current_provider_ids = [identity.provider_user_id for identity in reconciliation_input.current_identities]
|
||||
for identity_id, count in Counter(current_identity_ids).items():
|
||||
if count > 1:
|
||||
blockers.append(
|
||||
ReconciliationBlock(
|
||||
ReconciliationBlockCode.DUPLICATE_CURRENT_IDENTITY,
|
||||
f"identity_id:{identity_id}",
|
||||
"Current identity ID is not unique.",
|
||||
)
|
||||
)
|
||||
for provider_user_id, count in Counter(current_provider_ids).items():
|
||||
if count > 1:
|
||||
blockers.append(
|
||||
ReconciliationBlock(
|
||||
ReconciliationBlockCode.DUPLICATE_CURRENT_IDENTITY,
|
||||
f"provider_user_id:{provider_user_id}",
|
||||
"Current Provider user ID is not unique.",
|
||||
)
|
||||
)
|
||||
|
||||
current_identity_id_set = set(current_identity_ids)
|
||||
binding_ids = [binding.binding_id for binding in reconciliation_input.current_bindings]
|
||||
for binding_id, count in Counter(binding_ids).items():
|
||||
if count > 1:
|
||||
blockers.append(
|
||||
ReconciliationBlock(
|
||||
ReconciliationBlockCode.INVALID_CURRENT_BINDING,
|
||||
str(binding_id),
|
||||
"Current IM binding ID is not unique.",
|
||||
)
|
||||
)
|
||||
for binding in reconciliation_input.current_bindings:
|
||||
if binding.identity_id not in current_identity_id_set:
|
||||
blockers.append(
|
||||
ReconciliationBlock(
|
||||
ReconciliationBlockCode.INVALID_CURRENT_BINDING,
|
||||
str(binding.binding_id),
|
||||
"Current IM binding references an identity outside current identities.",
|
||||
)
|
||||
)
|
||||
|
||||
current_binding_id_set = set(binding_ids)
|
||||
for binding_id in reconciliation_input.reconciled_binding_ids - current_binding_id_set:
|
||||
blockers.append(
|
||||
ReconciliationBlock(
|
||||
ReconciliationBlockCode.INVALID_RECONCILED_BINDING_SET,
|
||||
str(binding_id),
|
||||
"Reconciled IM binding ID is not present in current bindings.",
|
||||
)
|
||||
)
|
||||
reconciled_bindings = [
|
||||
binding
|
||||
for binding in reconciliation_input.current_bindings
|
||||
if binding.binding_id in reconciliation_input.reconciled_binding_ids
|
||||
]
|
||||
for identity_id, count in Counter(binding.identity_id for binding in reconciled_bindings).items():
|
||||
if count > 1:
|
||||
blockers.append(
|
||||
ReconciliationBlock(
|
||||
ReconciliationBlockCode.INVALID_RECONCILED_BINDING_SET,
|
||||
f"identity_id:{identity_id}",
|
||||
"Reconciled IM bindings are not one-to-one by identity.",
|
||||
)
|
||||
)
|
||||
for contact_id, count in Counter(binding.contact_id for binding in reconciled_bindings).items():
|
||||
if count > 1:
|
||||
blockers.append(
|
||||
ReconciliationBlock(
|
||||
ReconciliationBlockCode.INVALID_RECONCILED_BINDING_SET,
|
||||
f"contact_id:{contact_id}",
|
||||
"Reconciled IM bindings are not one-to-one by Contact.",
|
||||
)
|
||||
)
|
||||
return tuple(sorted(blockers, key=lambda block: (block.code.value, block.subject_key or "", block.message)))
|
||||
|
||||
|
||||
def _normalize_email(email: str | None) -> NormalizedEmail | None:
|
||||
if email is None or not email.strip():
|
||||
return None
|
||||
try:
|
||||
return NormalizedEmail(email)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _changed_identity_fields(
|
||||
current_identity: CurrentIMIdentityState,
|
||||
entry: DirectoryEntry,
|
||||
normalized_email: NormalizedEmail | None,
|
||||
) -> tuple[str, ...]:
|
||||
desired_values = (
|
||||
("display_name", current_identity.display_name, entry.display_name),
|
||||
("email", current_identity.email, entry.email),
|
||||
("normalized_email", current_identity.normalized_email, normalized_email),
|
||||
)
|
||||
return tuple(
|
||||
field_name for field_name, current_value, desired_value in desired_values if current_value != desired_value
|
||||
)
|
||||
|
||||
|
||||
def _stable_key(kind: str, operation: str, *subjects: str) -> str:
|
||||
semantic_key = "\x1f".join((kind, operation, *subjects)).encode()
|
||||
return f"{kind}:{operation}:{hashlib.sha256(semantic_key).hexdigest()}"
|
||||
|
||||
|
||||
def _not_matched_result(
|
||||
provider_user_id: ProviderUserId,
|
||||
identity_ref: IMIdentityRef,
|
||||
reason: ReconciliationReasonCode,
|
||||
) -> PlannedSyncResult:
|
||||
return _planned_result(
|
||||
IMSyncResultType.NOT_MATCHED,
|
||||
provider_user_id,
|
||||
identity_ref,
|
||||
binding_id=None,
|
||||
contact_id=None,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
def _planned_result(
|
||||
result_type: IMSyncResultType,
|
||||
provider_user_id: ProviderUserId,
|
||||
identity_ref: IMIdentityRef,
|
||||
*,
|
||||
binding_id: IMBindingId | None,
|
||||
contact_id: ContactId | None,
|
||||
reason: ReconciliationReasonCode,
|
||||
) -> PlannedSyncResult:
|
||||
subjects = (
|
||||
str(provider_user_id),
|
||||
str(binding_id) if binding_id is not None else "",
|
||||
str(contact_id) if contact_id is not None else "",
|
||||
reason.value,
|
||||
)
|
||||
return PlannedSyncResult(
|
||||
operation_key=_stable_key("result", result_type.value, *subjects),
|
||||
result_type=result_type,
|
||||
provider_user_id=provider_user_id,
|
||||
identity_ref=identity_ref,
|
||||
binding_id=binding_id,
|
||||
contact_id=contact_id,
|
||||
reason_code=reason.value,
|
||||
)
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Immutable synchronization run and product-result persistence values."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
from pydantic import NaiveDatetime
|
||||
|
||||
from core.human_input_v2.entities import (
|
||||
IMIdentityBindingStatus,
|
||||
IMProvider,
|
||||
IMSyncRemovalReason,
|
||||
IMSyncResultType,
|
||||
IMSyncRunStatus,
|
||||
)
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IMSyncResultId,
|
||||
IMSyncRunId,
|
||||
NormalizedEmail,
|
||||
)
|
||||
from repositories.human_input_v2.im_channel_repository import IMChannelId
|
||||
|
||||
from .records import OpaqueProviderPayload
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IMChannelRevision:
|
||||
"""Historical configuration token captured by one synchronization run."""
|
||||
|
||||
channel_id: str
|
||||
config_version: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.config_version < 1:
|
||||
raise ValueError("config version must be positive")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StaleRevision:
|
||||
"""Stable rejection for a synchronization token that is no longer current."""
|
||||
|
||||
expected: IMChannelRevision
|
||||
actual: IMChannelRevision | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SyncContactSnapshot:
|
||||
"""Immutable Contact display values retained by a historical result."""
|
||||
|
||||
contact_id: ContactId
|
||||
name: str
|
||||
email: str | None
|
||||
avatar_file_id: str | None
|
||||
created_at: NaiveDatetime | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SyncIdentitySnapshot:
|
||||
"""Immutable last-known provider identity retained after current deletion."""
|
||||
|
||||
identity_id: IMIdentityId
|
||||
provider: IMProvider
|
||||
provider_user_id: str
|
||||
display_name: str | None
|
||||
email: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SyncResultFact:
|
||||
"""Append-only product outcome for one binding decision or diagnostic."""
|
||||
|
||||
id: IMSyncResultId
|
||||
channel_id: IMChannelId
|
||||
sync_run_id: IMSyncRunId
|
||||
operation_key: str | None
|
||||
result_type: IMSyncResultType
|
||||
provider_user_id: str | None
|
||||
display_name: str | None
|
||||
email: str | None
|
||||
normalized_email: NormalizedEmail | None
|
||||
contact_id: ContactId | None
|
||||
identity_id: IMIdentityId | None
|
||||
binding_id: IMBindingId | None
|
||||
removal_reason: IMSyncRemovalReason | None
|
||||
reason_code: str | None
|
||||
reason_message: str | None
|
||||
directory_entry_payload: OpaqueProviderPayload | None
|
||||
contact_snapshot: SyncContactSnapshot | None
|
||||
identity_snapshot: SyncIdentitySnapshot | None
|
||||
created_at: NaiveDatetime
|
||||
updated_at: NaiveDatetime
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SyncResultPage:
|
||||
"""Stable one-bucket page of product-facing synchronization facts."""
|
||||
|
||||
items: tuple[SyncResultFact, ...]
|
||||
page: int
|
||||
limit: int
|
||||
total: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SynchronizedIMIdentity:
|
||||
"""Search-safe current identity projection without Provider raw payload."""
|
||||
|
||||
id: IMIdentityId
|
||||
provider: IMProvider
|
||||
provider_user_id: str
|
||||
display_name: str | None
|
||||
email: str | None
|
||||
binding_status: IMIdentityBindingStatus
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SynchronizedIMIdentityPage:
|
||||
"""Stable page of current synchronized identities."""
|
||||
|
||||
items: tuple[SynchronizedIMIdentity, ...]
|
||||
page: int
|
||||
limit: int
|
||||
total: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IMSyncRun:
|
||||
"""Independent sync aggregate that captures one complete Channel revision."""
|
||||
|
||||
id: IMSyncRunId
|
||||
channel_revision: IMChannelRevision
|
||||
provider: IMProvider
|
||||
status: IMSyncRunStatus
|
||||
added_count: int
|
||||
not_matched_count: int
|
||||
failed_count: int
|
||||
removed_count: int
|
||||
skipped_count: int
|
||||
started_by_account_id: AccountId | None
|
||||
started_at: NaiveDatetime | None
|
||||
finished_at: NaiveDatetime | None
|
||||
error_code: str | None
|
||||
error_message: str | None
|
||||
created_at: NaiveDatetime
|
||||
updated_at: NaiveDatetime
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
sync_run_id: IMSyncRunId,
|
||||
channel_revision: IMChannelRevision,
|
||||
provider: IMProvider,
|
||||
started_by_account_id: AccountId | None,
|
||||
now: NaiveDatetime,
|
||||
) -> IMSyncRun:
|
||||
return cls(
|
||||
id=sync_run_id,
|
||||
channel_revision=channel_revision,
|
||||
provider=provider,
|
||||
status=IMSyncRunStatus.QUEUED,
|
||||
added_count=0,
|
||||
not_matched_count=0,
|
||||
failed_count=0,
|
||||
removed_count=0,
|
||||
skipped_count=0,
|
||||
started_by_account_id=started_by_account_id,
|
||||
started_at=None,
|
||||
finished_at=None,
|
||||
error_code=None,
|
||||
error_message=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self.status in (IMSyncRunStatus.QUEUED, IMSyncRunStatus.RUNNING)
|
||||
|
||||
def start(self, now: NaiveDatetime) -> IMSyncRun:
|
||||
if self.status is not IMSyncRunStatus.QUEUED:
|
||||
return self
|
||||
return replace(self, status=IMSyncRunStatus.RUNNING, started_at=now, updated_at=now)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Durable IM callback record contracts."""
|
||||
|
||||
from .contracts import (
|
||||
IM_INBOX_PROVIDER_METADATA_MAX_LENGTH,
|
||||
AcceptanceKind,
|
||||
IMInboxConsumer,
|
||||
IMInboxRecord,
|
||||
IMInboxRecordId,
|
||||
IMMessageInboxRepository,
|
||||
InboxAcceptance,
|
||||
InboxEventValidationError,
|
||||
InboxPersistenceError,
|
||||
canonicalize_inbox_event,
|
||||
validate_inbox_event,
|
||||
validate_inbox_provider_tenant_id,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"IM_INBOX_PROVIDER_METADATA_MAX_LENGTH",
|
||||
"AcceptanceKind",
|
||||
"IMInboxConsumer",
|
||||
"IMInboxRecord",
|
||||
"IMInboxRecordId",
|
||||
"IMMessageInboxRepository",
|
||||
"InboxAcceptance",
|
||||
"InboxEventValidationError",
|
||||
"InboxPersistenceError",
|
||||
"canonicalize_inbox_event",
|
||||
"validate_inbox_event",
|
||||
"validate_inbox_provider_tenant_id",
|
||||
]
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Domain contracts for durable IM callback records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import NewType, Protocol
|
||||
|
||||
from core.human_input_v2.im_integration.adapters.entities import AuthenticatedIMEvent
|
||||
from repositories.human_input_v2.im_channel_repository import IMChannelId
|
||||
|
||||
IM_INBOX_PROVIDER_METADATA_MAX_LENGTH = 128
|
||||
|
||||
|
||||
class InboxEventValidationError(ValueError):
|
||||
"""Provider metadata cannot be represented by the durable inbox contract."""
|
||||
|
||||
field_name: str
|
||||
maximum_length: int
|
||||
|
||||
def __init__(self, field_name: str, maximum_length: int) -> None:
|
||||
self.field_name = field_name
|
||||
self.maximum_length = maximum_length
|
||||
super().__init__(f"{field_name} must not exceed {maximum_length} characters")
|
||||
|
||||
|
||||
def validate_inbox_provider_tenant_id(provider_tenant_id: str) -> None:
|
||||
"""Reject a Provider tenant identity that cannot fit one inbox record."""
|
||||
|
||||
if len(provider_tenant_id) > IM_INBOX_PROVIDER_METADATA_MAX_LENGTH:
|
||||
raise InboxEventValidationError("provider tenant id", IM_INBOX_PROVIDER_METADATA_MAX_LENGTH)
|
||||
|
||||
|
||||
def canonicalize_inbox_event(event: AuthenticatedIMEvent) -> AuthenticatedIMEvent:
|
||||
"""Represent a blank Provider event ID as an absent deduplication identity."""
|
||||
|
||||
if event.event_id is None or event.event_id.strip():
|
||||
return event
|
||||
return replace(event, event_id=None)
|
||||
|
||||
|
||||
def validate_inbox_event(event: AuthenticatedIMEvent) -> None:
|
||||
"""Reject Provider metadata that cannot fit one inbox record without truncation."""
|
||||
|
||||
validate_inbox_provider_tenant_id(event.provider_tenant_id)
|
||||
if event.event_id is not None and len(event.event_id) > IM_INBOX_PROVIDER_METADATA_MAX_LENGTH:
|
||||
raise InboxEventValidationError("provider event id", IM_INBOX_PROVIDER_METADATA_MAX_LENGTH)
|
||||
if event.event_type is not None and len(event.event_type) > IM_INBOX_PROVIDER_METADATA_MAX_LENGTH:
|
||||
raise InboxEventValidationError("provider event type", IM_INBOX_PROVIDER_METADATA_MAX_LENGTH)
|
||||
|
||||
|
||||
IMInboxRecordId = NewType("IMInboxRecordId", str)
|
||||
|
||||
|
||||
class AcceptanceKind(StrEnum):
|
||||
"""Whether intake inserted a record or resolved an identified callback."""
|
||||
|
||||
NEW = "new"
|
||||
DUPLICATE = "duplicate"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InboxAcceptance:
|
||||
"""Durably committed callback record."""
|
||||
|
||||
record_id: IMInboxRecordId
|
||||
kind: AcceptanceKind
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IMInboxRecord:
|
||||
"""Persisted callback facts reconstructed for Celery processing."""
|
||||
|
||||
record_id: IMInboxRecordId
|
||||
channel_id: IMChannelId
|
||||
event: AuthenticatedIMEvent
|
||||
processed_at: datetime | None
|
||||
# Absent for stream events and callbacks saved before IP capture was added.
|
||||
request_ip_address: str | None = None
|
||||
|
||||
|
||||
class InboxPersistenceError(Exception):
|
||||
"""Expected database failure while storing or updating an inbox record."""
|
||||
|
||||
|
||||
class IMInboxConsumer(Protocol):
|
||||
"""Application callback consumer invoked by the Celery task."""
|
||||
|
||||
def consume(self, record: IMInboxRecord) -> None:
|
||||
"""Process one callback or raise so Celery can retry the task."""
|
||||
|
||||
|
||||
class IMMessageInboxRepository(Protocol):
|
||||
"""Persistence operations for durable callback facts."""
|
||||
|
||||
def insert_or_resolve(
|
||||
self,
|
||||
channel_id: IMChannelId,
|
||||
event: AuthenticatedIMEvent,
|
||||
*,
|
||||
now: datetime,
|
||||
request_ip_address: str | None = None,
|
||||
) -> InboxAcceptance:
|
||||
"""Commit a callback or resolve its identified duplicate."""
|
||||
|
||||
def get(self, record_id: IMInboxRecordId) -> IMInboxRecord | None:
|
||||
"""Load one durable callback record."""
|
||||
|
||||
def mark_processed(self, record_id: IMInboxRecordId, *, processed_at: datetime) -> None:
|
||||
"""Record that callback processing completed successfully."""
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Channel-neutral resolved presentation values for Human Input v2 forms."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
from enum import StrEnum
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from core.human_input import ButtonStyle
|
||||
from graphon.file.enums import FileTransferMethod, FileType
|
||||
|
||||
|
||||
class FormPartType(StrEnum):
|
||||
MARKDOWN_FRAGMENT = "markdown_fragment"
|
||||
PARAGRAPH_INPUT = "paragraph_input"
|
||||
SELECT_INPUT = "select_input"
|
||||
FILE_INPUT = "file_input"
|
||||
FILE_LIST_INPUT = "file_list_input"
|
||||
|
||||
|
||||
class MarkdownFragment(BaseModel):
|
||||
"""One source-ordered Markdown fragment in a resolved form."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True)
|
||||
|
||||
type: Literal[FormPartType.MARKDOWN_FRAGMENT] = FormPartType.MARKDOWN_FRAGMENT
|
||||
text: str
|
||||
|
||||
|
||||
class _BasicInput(BaseModel, abc.ABC):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True)
|
||||
|
||||
output_variable_name: str
|
||||
|
||||
|
||||
class ParagraphInput(_BasicInput):
|
||||
"""Resolved multiline text input."""
|
||||
|
||||
type: Literal[FormPartType.PARAGRAPH_INPUT] = FormPartType.PARAGRAPH_INPUT
|
||||
# Populated only when a default value is configured; otherwise None.
|
||||
default_value: str | None
|
||||
|
||||
|
||||
class SelectInput(_BasicInput):
|
||||
"""Resolved single-choice input with immutable concrete options."""
|
||||
|
||||
type: Literal[FormPartType.SELECT_INPUT] = FormPartType.SELECT_INPUT
|
||||
options: tuple[str, ...]
|
||||
# Populated only when a default value is configured; otherwise None.
|
||||
default_value: str | None
|
||||
|
||||
|
||||
class FileInput(_BasicInput):
|
||||
"""Resolved single-file input constraints."""
|
||||
|
||||
type: Literal[FormPartType.FILE_INPUT] = FormPartType.FILE_INPUT
|
||||
allowed_file_types: tuple[FileType, ...]
|
||||
allowed_file_extensions: tuple[str, ...]
|
||||
allowed_file_upload_methods: tuple[FileTransferMethod, ...]
|
||||
|
||||
|
||||
class FileListInput(_BasicInput):
|
||||
"""Resolved multi-file input constraints and effective number limit."""
|
||||
|
||||
type: Literal[FormPartType.FILE_LIST_INPUT] = FormPartType.FILE_LIST_INPUT
|
||||
allowed_file_types: tuple[FileType, ...]
|
||||
allowed_file_extensions: tuple[str, ...]
|
||||
allowed_file_upload_methods: tuple[FileTransferMethod, ...]
|
||||
number_limits: int
|
||||
|
||||
|
||||
class UserAction(BaseModel):
|
||||
"""Resolved action values required for presentation and submission validation."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True)
|
||||
|
||||
id: str
|
||||
title: str
|
||||
button_style: ButtonStyle
|
||||
|
||||
|
||||
type FormPart = Annotated[
|
||||
MarkdownFragment | ParagraphInput | SelectInput | FileInput | FileListInput,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
|
||||
class ResolvedForm(BaseModel):
|
||||
"""Immutable authoritative presentation snapshot for one v2 form."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True)
|
||||
|
||||
# An empty string represents a form without a title.
|
||||
title: str
|
||||
parts: tuple[FormPart, ...]
|
||||
actions: tuple[UserAction, ...]
|
||||
# All non-output variables are resolved; {{#$output.<name>#}} slots remain.
|
||||
legacy_form_content: str
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Stable Human Input v2 values shared across domain contexts.
|
||||
|
||||
This package contains infrastructure-free values only. Feature-specific ownership
|
||||
and lifecycle rules belong to their bounded context rather than this package.
|
||||
"""
|
||||
|
||||
from .values import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
DeploymentScope,
|
||||
DirectoryScope,
|
||||
EmailProviderId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IMReconciliationChangeId,
|
||||
IMSyncResultId,
|
||||
IMSyncRunId,
|
||||
NormalizedEmail,
|
||||
PlatformEntryId,
|
||||
TenantId,
|
||||
WorkspaceScope,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AccountId",
|
||||
"ContactId",
|
||||
"DeploymentScope",
|
||||
"DirectoryScope",
|
||||
"EmailProviderId",
|
||||
"IMBindingId",
|
||||
"IMIdentityId",
|
||||
"IMReconciliationChangeId",
|
||||
"IMSyncResultId",
|
||||
"IMSyncRunId",
|
||||
"NormalizedEmail",
|
||||
"PlatformEntryId",
|
||||
"TenantId",
|
||||
"WorkspaceScope",
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Primitive-independent identifiers, scopes, email, and time values."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import NewType, override
|
||||
|
||||
AccountId = NewType("AccountId", str)
|
||||
ContactId = NewType("ContactId", str)
|
||||
RecipientId = NewType("RecipientId", str)
|
||||
PlatformEntryId = NewType("PlatformEntryId", str)
|
||||
TenantId = NewType("TenantId", str)
|
||||
EmailProviderId = NewType("EmailProviderId", str)
|
||||
IMIdentityId = NewType("IMIdentityId", str)
|
||||
IMBindingId = NewType("IMBindingId", str)
|
||||
IMReconciliationChangeId = NewType("IMReconciliationChangeId", str)
|
||||
IMSyncRunId = NewType("IMSyncRunId", str)
|
||||
IMSyncResultId = NewType("IMSyncResultId", str)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NormalizedEmail:
|
||||
"""Case-insensitive canonical email used for identity comparisons."""
|
||||
|
||||
value: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.value, str):
|
||||
raise ValueError("value must be a valid email")
|
||||
normalized = self.value.strip().casefold()
|
||||
local, separator, domain = normalized.partition("@")
|
||||
if not separator or not local or not domain or " " in normalized or "@" in domain:
|
||||
raise ValueError("value must be a valid email")
|
||||
object.__setattr__(self, "value", normalized)
|
||||
|
||||
@override
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
def to_primitive(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeploymentScope:
|
||||
"""Deployment-wide owner scope used by EE Organization contacts."""
|
||||
|
||||
def to_primitive(self) -> dict[str, str]:
|
||||
return {"kind": "deployment"}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkspaceScope:
|
||||
"""Workspace scope whose id is the corresponding Dify Tenant.id."""
|
||||
|
||||
id: TenantId
|
||||
|
||||
def to_primitive(self) -> dict[str, str]:
|
||||
return {"kind": "workspace", "id": self.id}
|
||||
|
||||
|
||||
type DirectoryScope = DeploymentScope | WorkspaceScope
|
||||
@@ -14,6 +14,10 @@ from core.workflow.human_input_adapter import (
|
||||
EmailDeliveryMethod,
|
||||
EmailRecipients,
|
||||
ExternalRecipient,
|
||||
InstantMessageChannelRecipient,
|
||||
InstantMessageDeliveryConfig,
|
||||
InstantMessageDeliveryMethod,
|
||||
InstantMessageUserRecipient,
|
||||
InteractiveSurfaceDeliveryMethod,
|
||||
is_human_input_webapp_enabled,
|
||||
)
|
||||
@@ -32,6 +36,7 @@ from models.human_input import (
|
||||
HumanInputDelivery,
|
||||
HumanInputForm,
|
||||
HumanInputFormRecipient,
|
||||
InstantMessageRecipientPayload,
|
||||
RecipientType,
|
||||
StandaloneWebAppRecipientPayload,
|
||||
)
|
||||
@@ -326,9 +331,48 @@ class HumanInputFormRepositoryImpl:
|
||||
recipients_config=email_recipients_config,
|
||||
)
|
||||
)
|
||||
case InstantMessageDeliveryMethod():
|
||||
recipients.extend(
|
||||
self._build_instant_message_recipients(
|
||||
form_id=form_id,
|
||||
delivery_id=delivery_id,
|
||||
config=delivery_method.config,
|
||||
)
|
||||
)
|
||||
|
||||
return _DeliveryAndRecipients(delivery=delivery_model, recipients=recipients)
|
||||
|
||||
@staticmethod
|
||||
def _build_instant_message_recipients(
|
||||
*,
|
||||
form_id: str,
|
||||
delivery_id: str,
|
||||
config: InstantMessageDeliveryConfig,
|
||||
) -> list[HumanInputFormRecipient]:
|
||||
recipient_models: list[HumanInputFormRecipient] = []
|
||||
for recipient in config.recipients.items:
|
||||
match recipient:
|
||||
case InstantMessageChannelRecipient():
|
||||
payload = InstantMessageRecipientPayload(
|
||||
provider=config.provider,
|
||||
recipient_kind=recipient.type,
|
||||
channel_id=recipient.channel_id,
|
||||
)
|
||||
case InstantMessageUserRecipient():
|
||||
payload = InstantMessageRecipientPayload(
|
||||
provider=config.provider,
|
||||
recipient_kind=recipient.type,
|
||||
user_id=recipient.user_id,
|
||||
)
|
||||
recipient_models.append(
|
||||
HumanInputFormRecipient.new(
|
||||
form_id=form_id,
|
||||
delivery_id=delivery_id,
|
||||
payload=payload,
|
||||
)
|
||||
)
|
||||
return recipient_models
|
||||
|
||||
def _build_email_recipients(
|
||||
self,
|
||||
session: Session,
|
||||
|
||||
@@ -261,7 +261,6 @@ class ToolInvokeMessage(BaseModel):
|
||||
@field_validator("message", mode="before")
|
||||
@classmethod
|
||||
def decode_blob_message(cls, v, info: ValidationInfo):
|
||||
# 处理 blob 解码
|
||||
if isinstance(v, dict) and "blob" in v:
|
||||
with contextlib.suppress(Exception):
|
||||
v["blob"] = base64.b64decode(v["blob"])
|
||||
|
||||
@@ -5,6 +5,8 @@ Dify-owned field spellings and value shapes. Adapt them here before handing the
|
||||
payload to Graphon so Graphon-owned models only see current contracts.
|
||||
"""
|
||||
|
||||
# TODO(QuantumGhost): This should be migrated back to human_input.HumanInputNodeData.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
@@ -27,6 +29,7 @@ from graphon.variables.template_resolution import convert_template
|
||||
class DeliveryMethodType(enum.StrEnum):
|
||||
WEBAPP = enum.auto()
|
||||
EMAIL = enum.auto()
|
||||
IM = enum.auto()
|
||||
|
||||
|
||||
class EmailRecipientType(enum.StrEnum):
|
||||
@@ -35,6 +38,17 @@ class EmailRecipientType(enum.StrEnum):
|
||||
EXTERNAL = "external"
|
||||
|
||||
|
||||
class InstantMessageProvider(enum.StrEnum):
|
||||
SLACK = "slack"
|
||||
TEAMS = "teams"
|
||||
DISCORD = "discord"
|
||||
|
||||
|
||||
class InstantMessageRecipientType(enum.StrEnum):
|
||||
CHANNEL = "channel"
|
||||
USER = "user"
|
||||
|
||||
|
||||
class _InteractiveSurfaceDeliveryConfig(BaseModel):
|
||||
pass
|
||||
|
||||
@@ -57,6 +71,25 @@ MemberRecipient = BoundRecipient
|
||||
EmailRecipient = Annotated[BoundRecipient | ExternalRecipient, Field(discriminator="type")]
|
||||
|
||||
|
||||
class InstantMessageChannelRecipient(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
type: Literal[InstantMessageRecipientType.CHANNEL] = InstantMessageRecipientType.CHANNEL
|
||||
channel_id: str
|
||||
|
||||
|
||||
class InstantMessageUserRecipient(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
type: Literal[InstantMessageRecipientType.USER] = InstantMessageRecipientType.USER
|
||||
user_id: str
|
||||
|
||||
|
||||
InstantMessageRecipient = Annotated[
|
||||
InstantMessageChannelRecipient | InstantMessageUserRecipient, Field(discriminator="type")
|
||||
]
|
||||
|
||||
|
||||
class EmailRecipients(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -67,6 +100,12 @@ class EmailRecipients(BaseModel):
|
||||
items: list[EmailRecipient] = Field(default_factory=list)
|
||||
|
||||
|
||||
class InstantMessageRecipients(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
items: list[InstantMessageRecipient] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EmailDeliveryConfig(BaseModel):
|
||||
URL_PLACEHOLDER: ClassVar[str] = "{{#url#}}"
|
||||
_ALLOWED_HTML_TAGS: ClassVar[list[str]] = [
|
||||
@@ -74,6 +113,12 @@ class EmailDeliveryConfig(BaseModel):
|
||||
"br",
|
||||
"code",
|
||||
"em",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"li",
|
||||
"ol",
|
||||
"p",
|
||||
@@ -142,6 +187,14 @@ class EmailDeliveryConfig(BaseModel):
|
||||
return " ".join(sanitized.split())
|
||||
|
||||
|
||||
class InstantMessageDeliveryConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
provider: InstantMessageProvider
|
||||
recipients: InstantMessageRecipients = Field(default_factory=InstantMessageRecipients)
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class _DeliveryMethodBase(BaseModel):
|
||||
enabled: bool = True
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4)
|
||||
@@ -171,10 +224,31 @@ class EmailDeliveryMethod(_DeliveryMethodBase):
|
||||
return selectors
|
||||
|
||||
|
||||
class InstantMessageDeliveryMethod(_DeliveryMethodBase):
|
||||
type: Literal[DeliveryMethodType.IM] = DeliveryMethodType.IM
|
||||
config: InstantMessageDeliveryConfig
|
||||
|
||||
@override
|
||||
def extract_variable_selectors(self) -> Sequence[Sequence[str]]:
|
||||
if not self.config.message:
|
||||
return ()
|
||||
variable_template_parser = VariableTemplateParser(template=self.config.message)
|
||||
selectors: list[Sequence[str]] = []
|
||||
for variable_selector in variable_template_parser.extract_variable_selectors():
|
||||
value_selector = list(variable_selector.value_selector)
|
||||
if len(value_selector) < SELECTORS_LENGTH:
|
||||
continue
|
||||
selectors.append(value_selector[:SELECTORS_LENGTH])
|
||||
return selectors
|
||||
|
||||
|
||||
WebAppDeliveryMethod = InteractiveSurfaceDeliveryMethod
|
||||
_WebAppDeliveryConfig = _InteractiveSurfaceDeliveryConfig
|
||||
|
||||
DeliveryChannelConfig = Annotated[InteractiveSurfaceDeliveryMethod | EmailDeliveryMethod, Field(discriminator="type")]
|
||||
DeliveryChannelConfig = Annotated[
|
||||
InteractiveSurfaceDeliveryMethod | EmailDeliveryMethod | InstantMessageDeliveryMethod,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
_DELIVERY_METHODS_ADAPTER = TypeAdapter(list[DeliveryChannelConfig])
|
||||
|
||||
@@ -205,9 +279,10 @@ def adapt_human_input_node_data_for_graph(node_data: Mapping[str, Any] | BaseMod
|
||||
|
||||
config_mapping = _copy_mapping(method_mapping.get("config"))
|
||||
if config_mapping is not None:
|
||||
recipients_mapping = _copy_mapping(config_mapping.get("recipients"))
|
||||
if recipients_mapping is not None:
|
||||
config_mapping["recipients"] = _normalize_email_recipients(recipients_mapping)
|
||||
if method_mapping.get("type") == DeliveryMethodType.EMAIL:
|
||||
recipients_mapping = _copy_mapping(config_mapping.get("recipients"))
|
||||
if recipients_mapping is not None:
|
||||
config_mapping["recipients"] = _normalize_email_recipients(recipients_mapping)
|
||||
method_mapping["config"] = config_mapping
|
||||
|
||||
normalized_methods.append(method_mapping)
|
||||
@@ -380,6 +455,13 @@ __all__ = [
|
||||
"EmailRecipientType",
|
||||
"EmailRecipients",
|
||||
"ExternalRecipient",
|
||||
"InstantMessageChannelRecipient",
|
||||
"InstantMessageDeliveryConfig",
|
||||
"InstantMessageDeliveryMethod",
|
||||
"InstantMessageProvider",
|
||||
"InstantMessageRecipientType",
|
||||
"InstantMessageRecipients",
|
||||
"InstantMessageUserRecipient",
|
||||
"MemberRecipient",
|
||||
"WebAppDeliveryMethod",
|
||||
"_WebAppDeliveryConfig",
|
||||
|
||||
@@ -55,6 +55,7 @@ from core.workflow.nodes.agent_v2.output_adapter import WorkflowAgentOutputAdapt
|
||||
from core.workflow.nodes.agent_v2.runtime_request_builder import WorkflowAgentRuntimeRequestBuilder
|
||||
from core.workflow.nodes.human_input.callback import DifyHITLCallback
|
||||
from core.workflow.nodes.human_input.entities import HumanInputNodeData as DifyHumanInputNodeData
|
||||
from core.workflow.nodes.human_input_v2.node import HumanInputNode as HumanInputV2Node
|
||||
from core.workflow.system_variables import SystemVariableKey, get_system_text, system_variable_selector
|
||||
from core.workflow.template_rendering import CodeExecutorJinja2TemplateRenderer
|
||||
from core.workflow.workflow_tool_node import DifyWorkflowToolNode
|
||||
@@ -78,6 +79,7 @@ from graphon.nodes.question_classifier.entities import QuestionClassifierNodeDat
|
||||
from graphon.nodes.tool.entities import ToolProviderType
|
||||
from graphon.variables.segments import ArrayObjectSegment, ObjectSegment
|
||||
from models.model import Conversation
|
||||
from services.human_input_v2.composition import WorkflowHumanInputRuntime
|
||||
from services.knowledge_fs.knowledge_retrieval_v2.automatic_metadata_filter import (
|
||||
KnowledgeFSAutomaticMetadataFilterExtractor,
|
||||
)
|
||||
@@ -497,12 +499,18 @@ class DifyNodeFactory(NodeFactory):
|
||||
"file_manager": self._http_request_file_manager,
|
||||
"file_reference_factory": self._file_reference_factory,
|
||||
},
|
||||
BuiltinNodeTypes.HUMAN_INPUT: lambda: {
|
||||
"hitl_callback": self._build_human_input_callback(
|
||||
node_data=DifyHumanInputNodeData.model_validate(adapted_node_config["data"]),
|
||||
execution_id_getter=lambda: node.execution_id if node is not None else None,
|
||||
),
|
||||
},
|
||||
BuiltinNodeTypes.HUMAN_INPUT: lambda: (
|
||||
{
|
||||
"human_input_runtime": WorkflowHumanInputRuntime(self._dify_context),
|
||||
}
|
||||
if node_class is HumanInputV2Node
|
||||
else {
|
||||
"hitl_callback": self._build_human_input_callback(
|
||||
node_data=DifyHumanInputNodeData.model_validate(adapted_node_config["data"]),
|
||||
execution_id_getter=lambda: node.execution_id if node is not None else None,
|
||||
),
|
||||
}
|
||||
),
|
||||
BuiltinNodeTypes.LLM: lambda: self._build_llm_compatible_node_init_kwargs(
|
||||
node_class=node_class,
|
||||
node_data=resolved_node_data,
|
||||
|
||||
@@ -20,14 +20,20 @@ from graphon.variables.segments import StringSegment
|
||||
|
||||
from .constants import OUTPUT_FIELD_ACTION_ID, OUTPUT_FIELD_ACTION_VALUE, OUTPUT_FIELD_RENDERED_CONTENT, TIMEOUT_HANDLE
|
||||
from .pause_reason import HumanInputRequired
|
||||
from .session_binding import default_session_binding
|
||||
|
||||
|
||||
class HumanInputPauseReasonResolutionError(LookupError):
|
||||
"""Raised when a graph pause reason cannot be resolved into Dify-owned form state."""
|
||||
|
||||
|
||||
class HumanInputFormEventFilter:
|
||||
"""Adapt HITL callback results into Dify's form lifecycle event stream."""
|
||||
"""Adapt V1 callback results; V2 nodes emit their own form lifecycle events."""
|
||||
|
||||
def __init__(self, *, form_repository: HumanInputFormSubmissionRepository) -> None:
|
||||
self._form_repository = form_repository
|
||||
self._node_titles: dict[str, str] = {}
|
||||
self._v1_node_ids: set[str] = set()
|
||||
self._app_id: str | None = None
|
||||
|
||||
@property
|
||||
@@ -36,13 +42,21 @@ class HumanInputFormEventFilter:
|
||||
|
||||
def initialize(self, context: EngineEventFilterContext) -> None:
|
||||
self._node_titles.clear()
|
||||
# Graphon completion events can retain the default version. Route by
|
||||
# the concrete node in the graph instead of the event's version field.
|
||||
self._v1_node_ids = {
|
||||
node.id
|
||||
for node in (context.graph.root_node, *context.graph.nodes.values())
|
||||
if node.node_type == BuiltinNodeTypes.HUMAN_INPUT and node.version() == "1"
|
||||
}
|
||||
self._app_id = get_system_text(context.runtime_state.variable_pool, SystemVariableKey.APP_ID)
|
||||
|
||||
def on_event(self, event: EngineEvent) -> Iterable[EngineEvent]:
|
||||
if isinstance(event, NodeRunStartedEvent) and event.node_type == BuiltinNodeTypes.HUMAN_INPUT:
|
||||
self._node_titles[event.id] = event.node_title
|
||||
elif isinstance(event, NodeRunSucceededEvent) and event.node_type == BuiltinNodeTypes.HUMAN_INPUT:
|
||||
yield self._completion_event(event)
|
||||
if isinstance(event, NodeRunStartedEvent | NodeRunSucceededEvent) and event.node_id in self._v1_node_ids:
|
||||
if isinstance(event, NodeRunStartedEvent):
|
||||
self._node_titles[event.id] = event.node_title
|
||||
else:
|
||||
yield self._completion_event(event)
|
||||
|
||||
yield event
|
||||
|
||||
@@ -88,6 +102,25 @@ class HumanInputFormEventFilter:
|
||||
)
|
||||
|
||||
|
||||
def resolve_human_input_v1_pause_reason(
|
||||
*,
|
||||
reason: HitlRequired,
|
||||
form_repository: HumanInputFormSubmissionRepository,
|
||||
variable_pool: ReadOnlyVariablePool | None,
|
||||
) -> HumanInputRequired:
|
||||
"""Load the legacy form from the V1 submission repository."""
|
||||
node_version, form_id = default_session_binding.resolve_form_id_from_session_id(session_id=reason.session_id)
|
||||
if node_version != "1":
|
||||
raise ValueError("Expected a Human Input v1 session")
|
||||
record = form_repository.get_by_form_id(form_id)
|
||||
if record is None:
|
||||
raise HumanInputPauseReasonResolutionError(
|
||||
f"missing human input form while enriching pause reason: form_id={form_id}, session_id={reason.session_id}"
|
||||
)
|
||||
|
||||
return build_human_input_pause_reason(reason=reason, record=record, variable_pool=variable_pool)
|
||||
|
||||
|
||||
def human_input_container_selector(form_id: str) -> tuple[str, str]:
|
||||
return ("__dify_workflow_tool_hitl__", form_id)
|
||||
|
||||
|
||||
@@ -98,7 +98,9 @@ class DifyHITLCallback:
|
||||
)
|
||||
if form is None:
|
||||
created = self._create_form(ctx, form_id=form_id)
|
||||
return PauseRequested(session_id=self._session_binding.issue_session_id_for_form(form_id=created.id))
|
||||
return PauseRequested(
|
||||
session_id=self._session_binding.issue_session_id_for_form(node_version="1", form_id=created.id)
|
||||
)
|
||||
|
||||
status = self._normalize_status(form.status)
|
||||
if status == HumanInputFormStatus.WAITING.value and not form.submitted:
|
||||
@@ -124,7 +126,9 @@ class DifyHITLCallback:
|
||||
raise AssertionError(msg)
|
||||
|
||||
if not form.submitted:
|
||||
return PauseRequested(session_id=self._session_binding.issue_session_id_for_form(form_id=form.id))
|
||||
return PauseRequested(
|
||||
session_id=self._session_binding.issue_session_id_for_form(node_version="1", form_id=form.id)
|
||||
)
|
||||
|
||||
selected_action_id = form.selected_action_id
|
||||
if selected_action_id is None:
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Annotated, Any, Literal, Self, assert_never, override
|
||||
|
||||
from pydantic import BaseModel, Field, NonNegativeInt, field_validator, model_validator
|
||||
|
||||
from core.workflow.human_input_adapter import DeliveryChannelConfig
|
||||
from graphon.entities.base_node_data import BaseNodeData
|
||||
from graphon.enums import BuiltinNodeTypes, NodeType
|
||||
from graphon.file.enums import FileTransferMethod, FileType
|
||||
@@ -23,11 +24,17 @@ from graphon.variables.segments import Segment
|
||||
from . import _exc as exc
|
||||
from .enums import ButtonStyle, FormInputType, TimeoutUnit, ValueSourceType
|
||||
|
||||
_OUTPUT_VARIABLE_PATTERN = re.compile(
|
||||
OUTPUT_VARIABLE_PATTERN = re.compile(
|
||||
r"\{\{#\$output\.(?P<field_name>[a-zA-Z_][a-zA-Z0-9_]{0,29})#\}\}",
|
||||
)
|
||||
|
||||
|
||||
def extract_output_variable_names(form_content: str) -> tuple[str, ...]:
|
||||
"""Return source-ordered output slots using the authoring DSL grammar."""
|
||||
|
||||
return tuple(match.group("field_name") for match in OUTPUT_VARIABLE_PATTERN.finditer(form_content))
|
||||
|
||||
|
||||
class StringSource(BaseModel):
|
||||
"""Default configuration for form inputs."""
|
||||
|
||||
@@ -228,7 +235,7 @@ class UserActionConfig(BaseModel):
|
||||
# It also serves as the identifiers of output handle.
|
||||
#
|
||||
# The id must be a valid identifier (satisfy the _IDENTIFIER_PATTERN above.)
|
||||
id: str = Field(max_length=20, pattern=_IDENTIFIER_PATTERN.pattern)
|
||||
id: str = Field(max_length=20, pattern=_IDENTIFIER_PATTERN)
|
||||
title: str = Field(max_length=100)
|
||||
button_style: ButtonStyle = ButtonStyle.DEFAULT
|
||||
|
||||
@@ -245,10 +252,29 @@ class UserActionConfig(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
def validate_unique_input_names(inputs: Sequence[FormInputConfig]) -> None:
|
||||
seen_names: set[str] = set()
|
||||
for form_input in inputs:
|
||||
name = form_input.output_variable_name
|
||||
if name in seen_names:
|
||||
raise ValueError(f"duplicated output_variable_name '{name}' in inputs")
|
||||
seen_names.add(name)
|
||||
|
||||
|
||||
def validate_unique_action_ids(user_actions: Sequence[UserActionConfig]) -> None:
|
||||
seen_ids: set[str] = set()
|
||||
for action in user_actions:
|
||||
action_id = action.id
|
||||
if action_id in seen_ids:
|
||||
raise ValueError(f"duplicated user action id '{action_id}'")
|
||||
seen_ids.add(action_id)
|
||||
|
||||
|
||||
class HumanInputNodeData(BaseNodeData):
|
||||
"""Human Input node data."""
|
||||
|
||||
type: NodeType = BuiltinNodeTypes.HUMAN_INPUT
|
||||
title: str = ""
|
||||
form_content: str = ""
|
||||
inputs: list[FormInputConfig] = Field(default_factory=list[FormInputConfig])
|
||||
user_actions: list[UserActionConfig] = Field(default_factory=list[UserActionConfig])
|
||||
@@ -258,25 +284,13 @@ class HumanInputNodeData(BaseNodeData):
|
||||
@field_validator("inputs")
|
||||
@classmethod
|
||||
def _validate_inputs(cls, inputs: list[FormInputConfig]) -> list[FormInputConfig]:
|
||||
seen_names: set[str] = set()
|
||||
for form_input in inputs:
|
||||
name = form_input.output_variable_name
|
||||
if name in seen_names:
|
||||
msg = f"duplicated output_variable_name '{name}' in inputs"
|
||||
raise ValueError(msg)
|
||||
seen_names.add(name)
|
||||
validate_unique_input_names(inputs)
|
||||
return inputs
|
||||
|
||||
@field_validator("user_actions")
|
||||
@classmethod
|
||||
def _validate_user_actions(cls, user_actions: list[UserActionConfig]) -> list[UserActionConfig]:
|
||||
seen_ids: set[str] = set()
|
||||
for action in user_actions:
|
||||
action_id = action.id
|
||||
if action_id in seen_ids:
|
||||
msg = f"duplicated user action id '{action_id}'"
|
||||
raise ValueError(msg)
|
||||
seen_ids.add(action_id)
|
||||
validate_unique_action_ids(user_actions)
|
||||
return user_actions
|
||||
|
||||
def expiration_time(self, start_time: datetime) -> datetime:
|
||||
@@ -289,7 +303,7 @@ class HumanInputNodeData(BaseNodeData):
|
||||
assert_never(self.timeout_unit)
|
||||
|
||||
def outputs_field_names(self) -> Sequence[str]:
|
||||
return [match.group("field_name") for match in _OUTPUT_VARIABLE_PATTERN.finditer(self.form_content)]
|
||||
return list(extract_output_variable_names(self.form_content))
|
||||
|
||||
def find_action_text(self, action_id: str) -> str:
|
||||
"""Resolve action display text by id."""
|
||||
@@ -361,3 +375,10 @@ def validate_human_input_submission(
|
||||
missing_list = ", ".join(missing_inputs)
|
||||
msg = f"Missing required inputs: {missing_list}"
|
||||
raise HumanInputSubmissionValidationError(msg)
|
||||
|
||||
|
||||
class HumanInputNodeDataFull(HumanInputNodeData):
|
||||
# This model is the full definition of HumanInputNodeData.
|
||||
#
|
||||
# The model above lacks some fields due to migration between Graphon and Dify. This model add them back.
|
||||
delivery_methods: list[DeliveryChannelConfig] = Field(default_factory=list[DeliveryChannelConfig])
|
||||
@@ -1,5 +1,7 @@
|
||||
import enum
|
||||
|
||||
from core.human_input import ButtonStyle
|
||||
|
||||
|
||||
class HumanInputFormStatus(enum.StrEnum):
|
||||
"""Status of a human input form."""
|
||||
@@ -25,15 +27,6 @@ class HumanInputFormKind(enum.StrEnum):
|
||||
DELIVERY_TEST = enum.auto() # Form created for delivery tests.
|
||||
|
||||
|
||||
class ButtonStyle(enum.StrEnum):
|
||||
"""Button styles for user actions."""
|
||||
|
||||
PRIMARY = enum.auto()
|
||||
DEFAULT = enum.auto()
|
||||
ACCENT = enum.auto()
|
||||
GHOST = enum.auto()
|
||||
|
||||
|
||||
class TimeoutUnit(enum.StrEnum):
|
||||
"""Timeout unit for form expiration."""
|
||||
|
||||
@@ -74,3 +67,13 @@ class ValueSourceType(enum.StrEnum):
|
||||
VARIABLE = enum.auto()
|
||||
# `CONSTANT` means that the value comes from a static setting in form definition.
|
||||
CONSTANT = enum.auto()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ButtonStyle",
|
||||
"FormInputType",
|
||||
"HumanInputFormKind",
|
||||
"HumanInputFormStatus",
|
||||
"TimeoutUnit",
|
||||
"ValueSourceType",
|
||||
]
|
||||
@@ -22,6 +22,9 @@ class DifyHITLEventType(StrEnum):
|
||||
|
||||
|
||||
class HumanInputRequired(BaseModel):
|
||||
# The referenced form determines its storage owner; this is not an entity version.
|
||||
# Clients use it to choose the matching submission API.
|
||||
form_version: Literal["1", "2"] = "1"
|
||||
TYPE: Literal[DifyHITLEventType.HUMAN_INPUT_REQUIRED] = DifyHITLEventType.HUMAN_INPUT_REQUIRED
|
||||
form_id: str
|
||||
form_content: str
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
class SessionBinding:
|
||||
"""Translate between graphon session ids and Dify form ids.
|
||||
|
||||
Phase 1 keeps the public graphon `session_id` contract while Dify continues
|
||||
to persist and query `form_id`. The identity mapping lives here so later
|
||||
phases can change the translation without scattering equality assumptions.
|
||||
Existing v1 session ids remain unchanged. V2 ids carry their form version
|
||||
so consumers do not have to infer ownership from node state or database lookups.
|
||||
"""
|
||||
|
||||
def issue_session_id_for_form(self, *, form_id: str) -> str:
|
||||
return form_id
|
||||
_V2_PREFIX = "hitlv2:"
|
||||
|
||||
def resolve_form_id_from_session_id(self, *, session_id: str) -> str:
|
||||
return session_id
|
||||
def issue_session_id_for_form(self, node_version: str, form_id: str) -> str:
|
||||
match node_version:
|
||||
case "1":
|
||||
return form_id
|
||||
case "2":
|
||||
return f"{self._V2_PREFIX}{form_id}"
|
||||
case _:
|
||||
raise ValueError(f"Unsupported Human Input node version: {node_version}")
|
||||
|
||||
def resolve_form_id_from_session_id(self, session_id: str) -> tuple[str, str]:
|
||||
"""Return the Human Input node version and the unprefixed form id."""
|
||||
if session_id.startswith(self._V2_PREFIX):
|
||||
return "2", session_id.removeprefix(self._V2_PREFIX)
|
||||
return "1", session_id
|
||||
|
||||
|
||||
default_session_binding = SessionBinding()
|
||||
@@ -0,0 +1,123 @@
|
||||
import enum
|
||||
import typing
|
||||
from collections.abc import Sequence
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import AfterValidator, BaseModel, ConfigDict, Discriminator, Field, field_validator
|
||||
|
||||
from core.human_input_v2.entities import DebugChannel
|
||||
from core.workflow.nodes.human_input.entities import (
|
||||
FormInputConfig,
|
||||
TimeoutUnit,
|
||||
UserActionConfig,
|
||||
extract_output_variable_names,
|
||||
validate_unique_action_ids,
|
||||
validate_unique_input_names,
|
||||
)
|
||||
from graphon.entities.base_node_data import BaseNodeData
|
||||
|
||||
|
||||
class RecipientType(enum.StrEnum):
|
||||
CONTACT = enum.auto()
|
||||
DYNAMIC_EMAIL = enum.auto()
|
||||
ONETIME_EMAIL = enum.auto()
|
||||
INITIATOR = enum.auto()
|
||||
ALL_WORKSPACE_CONTACTS = enum.auto()
|
||||
|
||||
|
||||
class Contact(BaseModel):
|
||||
type: Literal[RecipientType.CONTACT] = RecipientType.CONTACT
|
||||
|
||||
contact_id: str
|
||||
|
||||
|
||||
class DynamicEmail(BaseModel):
|
||||
type: Literal[RecipientType.DYNAMIC_EMAIL] = RecipientType.DYNAMIC_EMAIL
|
||||
|
||||
selector: Sequence[str]
|
||||
|
||||
|
||||
class OnetimeEmail(BaseModel):
|
||||
type: Literal[RecipientType.ONETIME_EMAIL] = RecipientType.ONETIME_EMAIL
|
||||
|
||||
email: str
|
||||
|
||||
|
||||
class Initiator(BaseModel):
|
||||
type: Literal[RecipientType.INITIATOR] = RecipientType.INITIATOR
|
||||
|
||||
|
||||
class AllWorkspaceContacts(BaseModel):
|
||||
"""Migration marker preserving legacy whole-workspace recipient intent.
|
||||
|
||||
Runtime expansion is deliberately owned by the recipient resolver rather
|
||||
than the node-data migration helper.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
type: Literal[RecipientType.ALL_WORKSPACE_CONTACTS] = RecipientType.ALL_WORKSPACE_CONTACTS
|
||||
|
||||
|
||||
RecipientConfig = Annotated[
|
||||
Contact | DynamicEmail | OnetimeEmail | Initiator | AllWorkspaceContacts,
|
||||
Discriminator("type"),
|
||||
]
|
||||
|
||||
|
||||
class MessageTemplateConfig(BaseModel):
|
||||
subject: str
|
||||
body: str
|
||||
|
||||
|
||||
class DebugModeConfig(BaseModel):
|
||||
enabled: bool = False
|
||||
channels: Sequence[DebugChannel]
|
||||
|
||||
|
||||
HUMAN_INPUT_NODE_TYPE: typing.Final[Literal["human-input"]] = "human-input"
|
||||
HUMAN_INPUT_V2_VERSION: typing.Final = "2"
|
||||
|
||||
|
||||
def _version_validator(version: str) -> str:
|
||||
if version != HUMAN_INPUT_V2_VERSION:
|
||||
raise ValueError(f"Human Input v2 requires version='{HUMAN_INPUT_V2_VERSION}'")
|
||||
return version
|
||||
|
||||
|
||||
class HumanInputNodeData(BaseNodeData):
|
||||
"""Human Input node data."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore", frozen=True, strict=True, validate_default=True)
|
||||
|
||||
# BaseNodeData exposes the discriminator as a class variable, while the
|
||||
# concrete Pydantic model must validate and serialize an exact wire value.
|
||||
type: Literal["human-input"] = HUMAN_INPUT_NODE_TYPE # pyrefly: ignore[bad-override]
|
||||
title: str = "" # pyrefly: ignore[bad-override]
|
||||
version: Annotated[str, AfterValidator(_version_validator)] = HUMAN_INPUT_V2_VERSION # pyrefly: ignore[bad-override]
|
||||
|
||||
recipients_spec: list[RecipientConfig]
|
||||
|
||||
message_template: MessageTemplateConfig
|
||||
debug_mode: DebugModeConfig
|
||||
|
||||
form_content: str = ""
|
||||
inputs: list[FormInputConfig] = Field(default_factory=list[FormInputConfig])
|
||||
user_actions: list[UserActionConfig] = Field(default_factory=list[UserActionConfig])
|
||||
timeout: int = 36
|
||||
timeout_unit: TimeoutUnit = Field(default=TimeoutUnit.HOUR, strict=False)
|
||||
|
||||
@field_validator("inputs")
|
||||
@classmethod
|
||||
def _validate_inputs(cls, inputs: list[FormInputConfig]) -> list[FormInputConfig]:
|
||||
validate_unique_input_names(inputs)
|
||||
return inputs
|
||||
|
||||
@field_validator("user_actions")
|
||||
@classmethod
|
||||
def _validate_user_actions(cls, user_actions: list[UserActionConfig]) -> list[UserActionConfig]:
|
||||
validate_unique_action_ids(user_actions)
|
||||
return user_actions
|
||||
|
||||
def output_variable_names(self) -> tuple[str, ...]:
|
||||
return extract_output_variable_names(self.form_content)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Dify presentation events emitted alongside graphon's pause control events."""
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from graphon.engine_events import NodeEvent, NodeRunHumanInputFormFilledEvent, NodeRunHumanInputFormTimeoutEvent
|
||||
|
||||
from .runtime import PreparedForm
|
||||
|
||||
|
||||
class NodeRunHumanInputV2FormRequiredEvent(NodeEvent):
|
||||
"""Carry the committed form snapshot to Dify without another database read.
|
||||
|
||||
The initiator token is for the live response only. Pause persistence stores
|
||||
the form reference; reconnect resolves current persisted form state itself.
|
||||
"""
|
||||
|
||||
prepared_form: PreparedForm = Field(repr=False)
|
||||
|
||||
|
||||
class NodeRunHumanInputV2FormFilledEvent(NodeRunHumanInputFormFilledEvent):
|
||||
"""Keep Dify's form identity distinct from the engine's execution identity."""
|
||||
|
||||
form_id: str
|
||||
|
||||
|
||||
class NodeRunHumanInputV2FormTimeoutEvent(NodeRunHumanInputFormTimeoutEvent):
|
||||
"""Identify the persisted form whose timeout completed this execution."""
|
||||
|
||||
form_id: str
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Compile Human Input v2 authoring values into an authoritative resolved form."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import assert_never
|
||||
|
||||
from constants import DEFAULT_FILE_NUMBER_LIMITS
|
||||
from core.human_input_v2.resolved_form import (
|
||||
FileInput,
|
||||
FileListInput,
|
||||
FormPart,
|
||||
MarkdownFragment,
|
||||
ParagraphInput,
|
||||
ResolvedForm,
|
||||
SelectInput,
|
||||
UserAction,
|
||||
)
|
||||
from core.workflow.nodes.human_input.entities import (
|
||||
OUTPUT_VARIABLE_PATTERN,
|
||||
FileInputConfig,
|
||||
FileListInputConfig,
|
||||
FormInputConfig,
|
||||
ParagraphInputConfig,
|
||||
SelectInputConfig,
|
||||
StringListSource,
|
||||
StringSource,
|
||||
)
|
||||
from core.workflow.nodes.human_input.enums import ValueSourceType
|
||||
from graphon.runtime.runtime_state_protocol import ReadOnlyVariablePool
|
||||
from graphon.variables.segments import Segment
|
||||
from graphon.variables.template_resolution import convert_template
|
||||
|
||||
from .entities import HumanInputNodeData
|
||||
|
||||
|
||||
class MissingResolvedInputError(ValueError):
|
||||
"""An output slot has no corresponding authoring input configuration."""
|
||||
|
||||
|
||||
def compile_resolved_form(
|
||||
node_data: HumanInputNodeData,
|
||||
variable_pool: ReadOnlyVariablePool,
|
||||
*,
|
||||
resolved_default_values: Mapping[str, object] | None = None,
|
||||
) -> ResolvedForm:
|
||||
"""Resolve workflow-owned presentation data exactly once at form creation."""
|
||||
|
||||
legacy_form_content = _resolve_legacy_form_content(node_data.form_content, variable_pool)
|
||||
inputs_by_name = {input_config.output_variable_name: input_config for input_config in node_data.inputs}
|
||||
default_values = resolved_default_values or {}
|
||||
blocks: list[FormPart] = []
|
||||
cursor = 0
|
||||
for match in OUTPUT_VARIABLE_PATTERN.finditer(legacy_form_content):
|
||||
fragment = legacy_form_content[cursor : match.start()]
|
||||
if fragment != "":
|
||||
blocks.append(MarkdownFragment(text=fragment))
|
||||
output_variable_name = match.group("field_name")
|
||||
input_config = inputs_by_name.get(output_variable_name)
|
||||
if input_config is None:
|
||||
raise MissingResolvedInputError(f"resolved form input is missing for output slot '{output_variable_name}'")
|
||||
blocks.append(
|
||||
_resolve_input(
|
||||
input_config,
|
||||
variable_pool=variable_pool,
|
||||
resolved_default=default_values.get(output_variable_name),
|
||||
)
|
||||
)
|
||||
cursor = match.end()
|
||||
trailing_fragment = legacy_form_content[cursor:]
|
||||
if trailing_fragment != "":
|
||||
blocks.append(MarkdownFragment(text=trailing_fragment))
|
||||
|
||||
return ResolvedForm(
|
||||
title=node_data.title,
|
||||
parts=tuple(blocks),
|
||||
actions=tuple(
|
||||
UserAction(
|
||||
id=action.id,
|
||||
title=action.title,
|
||||
button_style=action.button_style,
|
||||
)
|
||||
for action in node_data.user_actions
|
||||
),
|
||||
legacy_form_content=legacy_form_content,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_legacy_form_content(form_content: str, variable_pool: ReadOnlyVariablePool) -> str:
|
||||
return convert_template(variable_pool, form_content).markdown
|
||||
|
||||
|
||||
def _resolve_input(
|
||||
input_config: FormInputConfig,
|
||||
*,
|
||||
variable_pool: ReadOnlyVariablePool,
|
||||
resolved_default: object,
|
||||
) -> ParagraphInput | SelectInput | FileInput | FileListInput:
|
||||
match input_config:
|
||||
case ParagraphInputConfig():
|
||||
default_value = _resolve_paragraph_default(input_config.default, variable_pool)
|
||||
if default_value is None and resolved_default is not None:
|
||||
default_value = _resolved_text(resolved_default, field_name=input_config.output_variable_name)
|
||||
return ParagraphInput(output_variable_name=input_config.output_variable_name, default_value=default_value)
|
||||
case SelectInputConfig():
|
||||
options = _resolve_select_options(input_config.option_source, variable_pool)
|
||||
default_value = (
|
||||
_resolved_text(resolved_default, field_name=input_config.output_variable_name)
|
||||
if resolved_default is not None
|
||||
else None
|
||||
)
|
||||
return SelectInput(
|
||||
output_variable_name=input_config.output_variable_name, options=options, default_value=default_value
|
||||
)
|
||||
case FileInputConfig():
|
||||
return FileInput(
|
||||
output_variable_name=input_config.output_variable_name,
|
||||
allowed_file_types=tuple(input_config.allowed_file_types),
|
||||
allowed_file_extensions=tuple(input_config.allowed_file_extensions),
|
||||
allowed_file_upload_methods=tuple(input_config.allowed_file_upload_methods),
|
||||
)
|
||||
case FileListInputConfig():
|
||||
return FileListInput(
|
||||
output_variable_name=input_config.output_variable_name,
|
||||
allowed_file_types=tuple(input_config.allowed_file_types),
|
||||
allowed_file_extensions=tuple(input_config.allowed_file_extensions),
|
||||
allowed_file_upload_methods=tuple(input_config.allowed_file_upload_methods),
|
||||
number_limits=input_config.number_limits or DEFAULT_FILE_NUMBER_LIMITS,
|
||||
)
|
||||
case _:
|
||||
assert_never(input_config)
|
||||
|
||||
|
||||
def _resolve_paragraph_default(source: StringSource | None, variable_pool: ReadOnlyVariablePool) -> str | None:
|
||||
if source is None:
|
||||
return None
|
||||
if source.type is ValueSourceType.CONSTANT:
|
||||
return source.value
|
||||
segment = variable_pool.get(source.selector)
|
||||
if segment is None:
|
||||
return None
|
||||
return _resolved_text(segment, field_name="paragraph default")
|
||||
|
||||
|
||||
def _resolve_select_options(source: StringListSource, variable_pool: ReadOnlyVariablePool) -> tuple[str, ...]:
|
||||
if source.type is ValueSourceType.CONSTANT:
|
||||
return tuple(source.value)
|
||||
segment = variable_pool.get(source.selector)
|
||||
if segment is None:
|
||||
raise ValueError("select option source variable is unavailable")
|
||||
resolved = segment.to_object()
|
||||
if not isinstance(resolved, list) or any(not isinstance(option, str) for option in resolved):
|
||||
raise TypeError("select option source must resolve to a string list")
|
||||
return tuple(resolved)
|
||||
|
||||
|
||||
def _resolved_text(resolved: object, *, field_name: str) -> str | None:
|
||||
if isinstance(resolved, Segment):
|
||||
resolved = resolved.to_object()
|
||||
if resolved is None:
|
||||
return None
|
||||
if isinstance(resolved, str):
|
||||
return resolved
|
||||
if isinstance(resolved, bool | int | float):
|
||||
return str(resolved)
|
||||
raise TypeError(f"{field_name} must resolve to a scalar text value")
|
||||
|
||||
|
||||
__all__ = ["MissingResolvedInputError", "compile_resolved_form"]
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Pure legacy Human Input node-data conversion.
|
||||
|
||||
This module owns only deterministic value conversion. Database access,
|
||||
workspace scoping, Contact state, and transport response mapping belong to the
|
||||
application and adapter layers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from dataclasses import dataclass
|
||||
|
||||
from core.human_input_v2.entities import DebugChannel
|
||||
from core.human_input_v2.shared.values import NormalizedEmail
|
||||
from core.workflow.human_input_adapter import (
|
||||
DeliveryChannelConfig,
|
||||
EmailDeliveryConfig,
|
||||
EmailDeliveryMethod,
|
||||
ExternalRecipient,
|
||||
WebAppDeliveryMethod,
|
||||
)
|
||||
from core.workflow.nodes.human_input.entities import HumanInputNodeDataFull
|
||||
|
||||
from .entities import (
|
||||
HUMAN_INPUT_NODE_TYPE,
|
||||
AllWorkspaceContacts,
|
||||
DebugModeConfig,
|
||||
HumanInputNodeData,
|
||||
Initiator,
|
||||
MessageTemplateConfig,
|
||||
OnetimeEmail,
|
||||
RecipientConfig,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResolvedMemberEmail:
|
||||
member_id: str
|
||||
email: NormalizedEmail
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MemberEmailSnapshot:
|
||||
"""Immutable request-local workspace member Email snapshot."""
|
||||
|
||||
entries: tuple[ResolvedMemberEmail, ...] = ()
|
||||
|
||||
def email_for(self, member_id: str) -> NormalizedEmail | None:
|
||||
for entry in self.entries:
|
||||
if entry.member_id == member_id:
|
||||
return entry.email
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NodeMigrationBlocker:
|
||||
code: MigrationBlockerCode
|
||||
method_id: str | None = None
|
||||
value: str | None = None
|
||||
method_index: int | None = None
|
||||
recipient_index: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NodeMigrationConversion:
|
||||
node_data: HumanInputNodeData | None
|
||||
blockers: tuple[NodeMigrationBlocker, ...]
|
||||
|
||||
|
||||
class MigrationBlockerCode(enum.StrEnum):
|
||||
INVALID_DEFAULT_VALUE = "invalid-default-value"
|
||||
CONFIGURED_DISABLED_METHOD = "configured-disabled-method"
|
||||
UNSUPPORTED_DELIVERY_METHOD = "unsupported-delivery-method"
|
||||
INVALID_EMAIL_CONFIGURATION = "invalid-email-configuration"
|
||||
INVALID_EMAIL = "invalid-email"
|
||||
UNRESOLVED_MEMBER = "unresolved-member"
|
||||
CONFLICTING_EMAIL_TEMPLATES = "conflicting-email-templates"
|
||||
MISSING_RECIPIENTS = "missing-recipients"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LegacyDeliveryParseIssue:
|
||||
"""One transport/preflight issue that did not become canonical v1 data."""
|
||||
|
||||
code: MigrationBlockerCode
|
||||
method_position: int | None = None
|
||||
method_id: str | None = None
|
||||
value: str | None = None
|
||||
recipient_position: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LegacyNodeDataPreflight:
|
||||
"""Canonical v1 value plus typed issues from the transport boundary."""
|
||||
|
||||
node_data: HumanInputNodeDataFull
|
||||
method_positions: tuple[int, ...]
|
||||
recipient_positions: tuple[tuple[int, ...], ...]
|
||||
issues: tuple[LegacyDeliveryParseIssue, ...]
|
||||
|
||||
|
||||
def _disabled_method_has_material_configuration(method: DeliveryChannelConfig) -> bool:
|
||||
if isinstance(method, WebAppDeliveryMethod):
|
||||
return False
|
||||
if not isinstance(method, EmailDeliveryMethod):
|
||||
return True
|
||||
config = method.config
|
||||
return bool(
|
||||
config.recipients.items
|
||||
or config.recipients.include_bound_group
|
||||
or config.subject
|
||||
or config.body
|
||||
or config.debug_mode
|
||||
)
|
||||
|
||||
|
||||
def _invalid_template_field(config: EmailDeliveryConfig) -> str | None:
|
||||
if not config.subject.strip():
|
||||
return "subject"
|
||||
if not config.body.strip():
|
||||
return "body"
|
||||
return None
|
||||
|
||||
|
||||
def _validated_email_template(config: EmailDeliveryConfig) -> tuple[str, str] | None:
|
||||
if not config.subject.strip():
|
||||
return None
|
||||
if not config.body.strip():
|
||||
return None
|
||||
return config.subject, config.body
|
||||
|
||||
|
||||
def convert_legacy_human_input_node_data(
|
||||
legacy_node_data: HumanInputNodeDataFull,
|
||||
member_emails: MemberEmailSnapshot,
|
||||
) -> NodeMigrationConversion:
|
||||
"""Convert one legacy node against a caller-owned immutable Email snapshot."""
|
||||
|
||||
recipients: list[RecipientConfig] = []
|
||||
blockers: list[NodeMigrationBlocker] = []
|
||||
seen_emails: set[str] = set()
|
||||
has_initiator = False
|
||||
has_workspace_marker = False
|
||||
email_debug_enabled = False
|
||||
email_template: tuple[str, str] | None = None
|
||||
conflicting_template: tuple[str, int] | None = None
|
||||
for method_index, method in enumerate(legacy_node_data.delivery_methods):
|
||||
method_id = str(method.id)
|
||||
if not method.enabled:
|
||||
if _disabled_method_has_material_configuration(method):
|
||||
blockers.append(
|
||||
NodeMigrationBlocker(
|
||||
MigrationBlockerCode.CONFIGURED_DISABLED_METHOD,
|
||||
method_id,
|
||||
method.type.value,
|
||||
method_index=method_index,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if isinstance(method, WebAppDeliveryMethod):
|
||||
if not has_initiator:
|
||||
recipients.append(Initiator())
|
||||
has_initiator = True
|
||||
continue
|
||||
if not isinstance(method, EmailDeliveryMethod):
|
||||
blockers.append(
|
||||
NodeMigrationBlocker(
|
||||
MigrationBlockerCode.UNSUPPORTED_DELIVERY_METHOD,
|
||||
method_id,
|
||||
method.type.value,
|
||||
method_index=method_index,
|
||||
)
|
||||
)
|
||||
continue
|
||||
current_template = _validated_email_template(method.config)
|
||||
if current_template is None:
|
||||
blockers.append(
|
||||
NodeMigrationBlocker(
|
||||
MigrationBlockerCode.INVALID_EMAIL_CONFIGURATION,
|
||||
method_id,
|
||||
_invalid_template_field(method.config),
|
||||
method_index=method_index,
|
||||
)
|
||||
)
|
||||
else:
|
||||
if email_template is None:
|
||||
email_template = current_template
|
||||
elif current_template != email_template and conflicting_template is None:
|
||||
conflicting_template = method_id, method_index
|
||||
email_debug_enabled = email_debug_enabled or method.config.debug_mode
|
||||
email_recipients = method.config.recipients
|
||||
for recipient_index, source in enumerate(email_recipients.items):
|
||||
if isinstance(source, ExternalRecipient):
|
||||
try:
|
||||
normalized_email = str(NormalizedEmail(source.email))
|
||||
except ValueError:
|
||||
blockers.append(
|
||||
NodeMigrationBlocker(
|
||||
MigrationBlockerCode.INVALID_EMAIL,
|
||||
method_id,
|
||||
source.email,
|
||||
method_index=method_index,
|
||||
recipient_index=recipient_index,
|
||||
)
|
||||
)
|
||||
continue
|
||||
else:
|
||||
member_id = source.reference_id
|
||||
member_email = member_emails.email_for(member_id)
|
||||
if member_email is None:
|
||||
blockers.append(
|
||||
NodeMigrationBlocker(
|
||||
MigrationBlockerCode.UNRESOLVED_MEMBER,
|
||||
method_id,
|
||||
member_id,
|
||||
method_index=method_index,
|
||||
recipient_index=recipient_index,
|
||||
)
|
||||
)
|
||||
continue
|
||||
normalized_email = str(member_email)
|
||||
if normalized_email in seen_emails:
|
||||
continue
|
||||
recipients.append(OnetimeEmail(email=normalized_email))
|
||||
seen_emails.add(normalized_email)
|
||||
if email_recipients.include_bound_group and not has_workspace_marker:
|
||||
recipients.append(AllWorkspaceContacts())
|
||||
has_workspace_marker = True
|
||||
|
||||
if conflicting_template is not None:
|
||||
conflicting_template_method_id, conflicting_template_method_index = conflicting_template
|
||||
blockers.append(
|
||||
NodeMigrationBlocker(
|
||||
MigrationBlockerCode.CONFLICTING_EMAIL_TEMPLATES,
|
||||
conflicting_template_method_id,
|
||||
method_index=conflicting_template_method_index,
|
||||
)
|
||||
)
|
||||
if not recipients:
|
||||
blockers.append(NodeMigrationBlocker(MigrationBlockerCode.MISSING_RECIPIENTS))
|
||||
if blockers:
|
||||
return NodeMigrationConversion(node_data=None, blockers=tuple(blockers))
|
||||
|
||||
if email_template is None:
|
||||
# Runtime requires a Request URL even for initiator-only forms.
|
||||
subject = "Action required"
|
||||
body = (
|
||||
"A request is waiting for your review. Please review the details and submit your decision to continue. "
|
||||
"Review and respond: {{#url#}}"
|
||||
)
|
||||
else:
|
||||
subject, body = email_template
|
||||
|
||||
node_data = HumanInputNodeData(
|
||||
type=HUMAN_INPUT_NODE_TYPE,
|
||||
title=legacy_node_data.title,
|
||||
desc=legacy_node_data.desc,
|
||||
error_strategy=legacy_node_data.error_strategy,
|
||||
default_value=legacy_node_data.default_value,
|
||||
retry_config=legacy_node_data.retry_config,
|
||||
recipients_spec=recipients,
|
||||
message_template=MessageTemplateConfig(subject=subject, body=body),
|
||||
debug_mode=DebugModeConfig(
|
||||
enabled=email_debug_enabled,
|
||||
channels=(DebugChannel.EMAIL,) if email_debug_enabled else (),
|
||||
),
|
||||
form_content=legacy_node_data.form_content,
|
||||
inputs=legacy_node_data.inputs,
|
||||
user_actions=legacy_node_data.user_actions,
|
||||
timeout=legacy_node_data.timeout,
|
||||
timeout_unit=legacy_node_data.timeout_unit,
|
||||
)
|
||||
return NodeMigrationConversion(node_data=node_data, blockers=())
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Dify-owned Human Input v2 node; services are supplied through its runtime port.
|
||||
|
||||
Only the generic graphon node/event contracts are used. Dify form semantics do
|
||||
not pass through HITLCallback. Keep v1 registrations and published runs intact.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator, Mapping, Sequence
|
||||
from typing import assert_never, override
|
||||
|
||||
from core.workflow.nodes.human_input.enums import HumanInputFormStatus
|
||||
from core.workflow.nodes.human_input.session_binding import default_session_binding
|
||||
from factories.human_input_v2_form_presentation import render_filled_content
|
||||
from graphon.engine_events import NodeEvent
|
||||
from graphon.entities.pause_reason import HitlRequired
|
||||
from graphon.enums import BuiltinNodeTypes, NodeExecutionType, WorkflowNodeExecutionStatus
|
||||
from graphon.node_events import NodeEventPayload, NodeRunResult, StreamCompletedEvent
|
||||
from graphon.node_events.node import PauseRequestedEvent
|
||||
from graphon.nodes.base.node import Node
|
||||
from graphon.nodes.base.variable_template_parser import VariableTemplateParser
|
||||
from graphon.runtime.init_params import InitParams
|
||||
from graphon.runtime.runtime_state import RuntimeState
|
||||
from graphon.variables.segments import Segment, StringSegment
|
||||
|
||||
from .entities import HUMAN_INPUT_V2_VERSION, DynamicEmail, HumanInputNodeData
|
||||
from .events import (
|
||||
NodeRunHumanInputV2FormFilledEvent,
|
||||
NodeRunHumanInputV2FormRequiredEvent,
|
||||
NodeRunHumanInputV2FormTimeoutEvent,
|
||||
)
|
||||
from .runtime import HumanInputDeliveryError, HumanInputRuntime
|
||||
|
||||
|
||||
class HumanInputNode(Node[HumanInputNodeData]):
|
||||
node_type = BuiltinNodeTypes.HUMAN_INPUT
|
||||
execution_type = NodeExecutionType.BRANCH
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
node_id: str,
|
||||
data: HumanInputNodeData,
|
||||
*,
|
||||
init_params: InitParams,
|
||||
runtime_state: RuntimeState,
|
||||
human_input_runtime: HumanInputRuntime,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
node_id=node_id,
|
||||
data=data,
|
||||
init_params=init_params,
|
||||
runtime_state=runtime_state,
|
||||
)
|
||||
self._human_input_runtime = human_input_runtime
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def version(cls) -> str:
|
||||
return HUMAN_INPUT_V2_VERSION
|
||||
|
||||
@override
|
||||
def _run(self) -> Generator[NodeEventPayload | NodeEvent]:
|
||||
"""Consume persisted form outcomes; only completion events select edges."""
|
||||
try:
|
||||
prepared = self._human_input_runtime.prepare_form(
|
||||
node_execution_id=self.execution_id,
|
||||
node_data=self.node_data,
|
||||
variable_pool=self.runtime_state.variable_pool,
|
||||
)
|
||||
except HumanInputDeliveryError as error:
|
||||
yield StreamCompletedEvent(
|
||||
node_run_result=NodeRunResult(
|
||||
status=WorkflowNodeExecutionStatus.FAILED,
|
||||
error=str(error),
|
||||
error_type=type(error).__name__,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
form = prepared.form
|
||||
resolved = form.resolved_form
|
||||
match form.status:
|
||||
case HumanInputFormStatus.WAITING:
|
||||
# Keep the live Dify presentation separate from graphon's persisted
|
||||
# pause reason, which carries only the reference needed to resume.
|
||||
yield NodeRunHumanInputV2FormRequiredEvent(
|
||||
id=self.execution_id,
|
||||
node_id=self.id,
|
||||
node_type=self.node_type,
|
||||
node_version=self.version(),
|
||||
prepared_form=prepared,
|
||||
)
|
||||
yield PauseRequestedEvent(
|
||||
reason=HitlRequired(
|
||||
session_id=default_session_binding.issue_session_id_for_form(
|
||||
node_version=self.version(), form_id=form.id
|
||||
),
|
||||
node_id=self.id,
|
||||
node_title=resolved.title,
|
||||
)
|
||||
)
|
||||
case HumanInputFormStatus.SUBMITTED:
|
||||
submission = form.submission
|
||||
assert submission is not None, "Submitted form must contain its accepted submission"
|
||||
action = next(action for action in resolved.actions if action.id == submission.selected_action_id)
|
||||
inputs = submission.normalized_submission_data
|
||||
content = render_filled_content(resolved, inputs)
|
||||
yield NodeRunHumanInputV2FormFilledEvent(
|
||||
id=self.execution_id,
|
||||
node_id=self.id,
|
||||
node_type=self.node_type,
|
||||
node_version=self.version(),
|
||||
form_id=form.id,
|
||||
node_title=resolved.title,
|
||||
action_id=action.id,
|
||||
action_text=action.title,
|
||||
submitted_data=inputs,
|
||||
rendered_content=content,
|
||||
)
|
||||
outputs = dict(inputs)
|
||||
outputs.update(_special_outputs(action.id, action.title, content))
|
||||
yield StreamCompletedEvent(
|
||||
node_run_result=NodeRunResult(
|
||||
status=WorkflowNodeExecutionStatus.SUCCEEDED,
|
||||
inputs=inputs,
|
||||
outputs=outputs,
|
||||
edge_source_handle=action.id,
|
||||
)
|
||||
)
|
||||
case HumanInputFormStatus.TIMEOUT:
|
||||
yield NodeRunHumanInputV2FormTimeoutEvent(
|
||||
id=self.execution_id,
|
||||
node_id=self.id,
|
||||
node_type=self.node_type,
|
||||
node_version=self.version(),
|
||||
form_id=form.id,
|
||||
node_title=resolved.title,
|
||||
expiration_time=form.expiration_time,
|
||||
)
|
||||
yield StreamCompletedEvent(
|
||||
node_run_result=NodeRunResult(
|
||||
status=WorkflowNodeExecutionStatus.SUCCEEDED,
|
||||
outputs=_special_outputs("", "", resolved.legacy_form_content),
|
||||
edge_source_handle="__timeout",
|
||||
)
|
||||
)
|
||||
case HumanInputFormStatus.EXPIRED:
|
||||
# A node failure can take an error branch. Global expiration must
|
||||
# stop the graph itself, without dispatching a completion event.
|
||||
self.runtime_state.graph_execution.abort(f"Human input form globally expired: {form.id}")
|
||||
case _:
|
||||
assert_never(form.status)
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def _extract_variable_selector_to_variable_mapping(
|
||||
cls,
|
||||
*,
|
||||
graph_config: Mapping[str, object],
|
||||
node_id: str,
|
||||
node_data: HumanInputNodeData,
|
||||
) -> Mapping[str, Sequence[str]]:
|
||||
"""Expose all authoring dependencies to single-node debug execution.
|
||||
|
||||
Include Form Content, input defaults/select options, Dynamic Email,
|
||||
and Message template subject/body selectors. Output slots and Request
|
||||
URL are not upstream variables. Preserve complete selectors, including
|
||||
nested paths. Recipient resolution and rendering still belong to the
|
||||
runtime; extracting dependencies must not invoke either operation.
|
||||
"""
|
||||
selectors: list[Sequence[str]] = []
|
||||
for template in (node_data.form_content, node_data.message_template.subject, node_data.message_template.body):
|
||||
selectors.extend(
|
||||
item.value_selector for item in VariableTemplateParser(template).extract_variable_selectors()
|
||||
)
|
||||
for form_input in node_data.inputs:
|
||||
selectors.extend(form_input.extract_variable_selectors())
|
||||
for recipient in node_data.recipients_spec:
|
||||
if isinstance(recipient, DynamicEmail):
|
||||
selectors.append(recipient.selector)
|
||||
return {f"{node_id}.#{'.'.join(selector)}#": tuple(selector) for selector in selectors}
|
||||
|
||||
|
||||
def _special_outputs(action_id: str, action_value: str, rendered_content: str) -> dict[str, Segment]:
|
||||
return {
|
||||
"__action_id": StringSegment(value=action_id),
|
||||
"__action_value": StringSegment(value=action_value),
|
||||
"__rendered_content": StringSegment(value=rendered_content),
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Bridge frozen v2 form presentation to graph pause reasons."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from core.human_input_v2.resolved_form import ResolvedForm
|
||||
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
|
||||
from core.workflow.nodes.human_input.session_binding import default_session_binding
|
||||
from factories.human_input_v2_form_presentation import build_form_presentation
|
||||
from graphon.entities.pause_reason import HitlRequired
|
||||
|
||||
from .runtime import PreparedForm
|
||||
|
||||
|
||||
def resolve_human_input_v2_pause_reason(
|
||||
*, reason: HitlRequired, forms: Mapping[str, PreparedForm]
|
||||
) -> HumanInputRequired:
|
||||
"""Resolve a v2 pause exclusively from its preceding node event."""
|
||||
node_version, form_id = default_session_binding.resolve_form_id_from_session_id(session_id=reason.session_id)
|
||||
if node_version != "2":
|
||||
raise ValueError("Expected a Human Input v2 session")
|
||||
prepared = forms.get(form_id)
|
||||
if prepared is None:
|
||||
raise ValueError(f"Human Input v2 form snapshot is missing: node_id={reason.node_id}, form_id={form_id}")
|
||||
return build_human_input_v2_pause_reason(prepared.form.resolved_form, form_id=form_id, node_id=reason.node_id)
|
||||
|
||||
|
||||
def build_human_input_v2_pause_reason(form: ResolvedForm, *, form_id: str, node_id: str) -> HumanInputRequired:
|
||||
presentation = build_form_presentation(form)
|
||||
return HumanInputRequired(
|
||||
form_version="2",
|
||||
form_id=form_id,
|
||||
node_id=node_id,
|
||||
node_title=form.title,
|
||||
form_content=presentation.form_content,
|
||||
inputs=presentation.inputs,
|
||||
actions=presentation.user_actions,
|
||||
resolved_default_values=presentation.resolved_default_values,
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""The Human Input operation needed by a workflow node."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol
|
||||
|
||||
from pydantic import SecretStr
|
||||
|
||||
from graphon.runtime.runtime_state_protocol import ReadOnlyVariablePool
|
||||
from repositories.human_input_v2.form_repository import Form
|
||||
|
||||
from .entities import HumanInputNodeData
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PreparedForm:
|
||||
"""An existing or newly delivered form and its initiator stream token.
|
||||
|
||||
form_token belongs only to the Current Initiator delivery selected by this
|
||||
node's recipient configuration. It is None without that configuration or
|
||||
without a usable, verified initiator. Never substitute another recipient's
|
||||
token. The token locates the form; submission still requires authentication.
|
||||
"""
|
||||
|
||||
form: Form
|
||||
form_token: SecretStr | None = field(repr=False)
|
||||
|
||||
|
||||
class HumanInputDeliveryError(Exception):
|
||||
"""No delivery succeeded; the node must emit failed execution, not pause."""
|
||||
|
||||
|
||||
class HumanInputRuntime(Protocol):
|
||||
"""Run-bound approval lifecycle composing transaction-owning services.
|
||||
|
||||
The composition boundary binds tenant, app, workflow run, authenticated
|
||||
initiator, and debugging identity. The node supplies only execution-local
|
||||
facts. Recipient resolution, channels, transactions, notification execution,
|
||||
and delivery-result aggregation remain behind this interface.
|
||||
Its implementation owns no transaction; each service commits its own work
|
||||
before the runtime invokes the next service.
|
||||
|
||||
Form is the existing detached snapshot, not a repository capability. Reuse
|
||||
its persisted status and typed submission. PreparedForm adds only the stream
|
||||
token that Form does not contain; it does not define another state machine.
|
||||
"""
|
||||
|
||||
def prepare_form(
|
||||
self,
|
||||
*,
|
||||
node_execution_id: str,
|
||||
node_data: HumanInputNodeData,
|
||||
variable_pool: ReadOnlyVariablePool,
|
||||
) -> PreparedForm:
|
||||
"""Initialize or revisit the approval for this exact node execution.
|
||||
|
||||
Look up the form first. If it exists, return its frozen content, status,
|
||||
submission, and initiator token without changing its content or recipients.
|
||||
Recover unconfirmed deliveries from the frozen notification plan; never
|
||||
resend a confirmed success. Duplicate notifications are allowed when a
|
||||
provider accepted a send but its result was not committed locally.
|
||||
|
||||
Otherwise create Form and resolved/merged Recipients in one transaction.
|
||||
After commit, deliver independently to each Recipient; shared endpoints
|
||||
across different recipients do not suppress a delivery. Determine and
|
||||
persist each recipient's deliveries before provider I/O. Perform
|
||||
delivery and record each actual result within this node invocation;
|
||||
do not return success merely because deliveries were created or queued.
|
||||
|
||||
Raise HumanInputDeliveryError if every delivery failed or no approval
|
||||
path exists. A usable Current Initiator interaction surface counts as
|
||||
a delivery path without an external provider call. Partial failures are
|
||||
warnings when another delivery succeeded. Return after aggregating the
|
||||
results. Delivery success leaves the form waiting for human input.
|
||||
|
||||
Only Current Initiator configuration permits a stream form_token; Email
|
||||
and IM deliveries never supply the token for Workflow/Chatflow streams.
|
||||
Preserve this rule on reentry using the original recipient grant.
|
||||
|
||||
Expiration handling is separate from this lookup/create operation.
|
||||
Persisted TIMEOUT selects the timeout branch; EXPIRED belongs to
|
||||
whole-workflow termination. Do not recompute terminal outcomes here.
|
||||
"""
|
||||
...
|
||||
@@ -226,7 +226,7 @@ class WorkflowToolContainerHandler:
|
||||
frame.state.graph_execution.exceptions_count -= 1
|
||||
if isinstance(event, NodeRunPauseRequestedEvent) and isinstance(event.reason, HitlRequired):
|
||||
# Keep source identity in Graphon; persist form ownership for Dify's response boundary.
|
||||
form_id = default_session_binding.resolve_form_id_from_session_id(session_id=event.reason.session_id)
|
||||
_, form_id = default_session_binding.resolve_form_id_from_session_id(session_id=event.reason.session_id)
|
||||
self._root_runtime_state().variable_pool.add(human_input_container_selector(form_id), frame.container_id)
|
||||
# Preserve source ownership before outer containers add their own metadata.
|
||||
event.node_run_result.process_data = {
|
||||
|
||||
@@ -276,6 +276,8 @@ def collect_changed_glue_lines(
|
||||
"--no-ext-diff",
|
||||
"--no-color",
|
||||
"--unified=0",
|
||||
"--src-prefix=a/",
|
||||
"--dst-prefix=b/",
|
||||
base,
|
||||
"--",
|
||||
*glue_paths,
|
||||
|
||||
@@ -35,10 +35,10 @@ if [[ "${MODE}" == "worker" ]]; then
|
||||
if [[ -z "${CELERY_QUEUES}" ]]; then
|
||||
if [[ "${DEPLOYMENT_EDITION:-COMMUNITY}" == "CLOUD" ]]; then
|
||||
# Cloud edition: separate queues for dataset and trigger tasks
|
||||
DEFAULT_QUEUES="api_token,dataset,dataset_summary,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,app_rbac,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_publisher,trigger_refresh_executor,retention,workflow_based_app_execution,knowledge_fs_lifecycle"
|
||||
DEFAULT_QUEUES="api_token,dataset,dataset_summary,priority_dataset,priority_pipeline,pipeline,mail,human_input_delivery,human_input_contact_sync,ops_trace,app_deletion,app_rbac,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_publisher,trigger_refresh_executor,retention,workflow_based_app_execution,knowledge_fs_lifecycle"
|
||||
else
|
||||
# Self-hosted editions: dataset, pipeline and workflow have separate queues
|
||||
DEFAULT_QUEUES="api_token,dataset,dataset_summary,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,app_rbac,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_publisher,trigger_refresh_executor,retention,workflow_based_app_execution,knowledge_fs_lifecycle"
|
||||
DEFAULT_QUEUES="api_token,dataset,dataset_summary,priority_dataset,priority_pipeline,pipeline,mail,human_input_delivery,human_input_contact_sync,ops_trace,app_deletion,app_rbac,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_publisher,trigger_refresh_executor,retention,workflow_based_app_execution,knowledge_fs_lifecycle"
|
||||
fi
|
||||
else
|
||||
DEFAULT_QUEUES="${CELERY_QUEUES}"
|
||||
|
||||
@@ -755,7 +755,8 @@ def build_application_services(
|
||||
messages=messages,
|
||||
files=message_files,
|
||||
extra_contents=ExecutionExtraContentReader(
|
||||
repository=SQLAlchemyExecutionExtraContentRepository(session_maker=database_client)
|
||||
repository=SQLAlchemyExecutionExtraContentRepository(session_maker=database_client),
|
||||
session_factory=database_client,
|
||||
),
|
||||
),
|
||||
message_file_previews=MessageFilePreviewService(
|
||||
@@ -829,6 +830,7 @@ def build_application_services(
|
||||
workflow_runs=WorkflowRunService(
|
||||
workflow_runs=workflow_run_repository,
|
||||
node_executions=workflow_node_execution_repository,
|
||||
session_factory=database_client,
|
||||
),
|
||||
workspaces=workspace_services,
|
||||
workflow_app_logs=WorkflowAppLogQueryService(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Register API blueprints with their browser-facing CORS policies."""
|
||||
|
||||
from configs import dify_config
|
||||
from configs.deploy import IMEventTransportMode
|
||||
from constants import (
|
||||
HEADER_NAME_APP_CODE,
|
||||
HEADER_NAME_CATALOG,
|
||||
@@ -42,6 +43,14 @@ def _apply_cors_once(bp, /, **cors_kwargs):
|
||||
def init_app(app: DifyApp):
|
||||
# register blueprint routers
|
||||
|
||||
from services.human_input_v2.im_webhook_runtime import configure_im_webhook_runtime
|
||||
|
||||
configure_im_webhook_runtime(app)
|
||||
if dify_config.HUMAN_INPUT_IM_EVENT_TRANSPORT_MODE is IMEventTransportMode.WEBHOOK:
|
||||
from controllers.web.human_input_v2_im_webhook import bp as im_provider_webhook_bp
|
||||
|
||||
app.register_blueprint(im_provider_webhook_bp)
|
||||
|
||||
from controllers.console import bp as console_app_bp
|
||||
from controllers.files import bp as files_bp
|
||||
from controllers.inner_api import bp as inner_api_bp
|
||||
|
||||
@@ -200,6 +200,9 @@ def init_app(app: DifyApp) -> Celery:
|
||||
"tasks.install_default_plugins_task", # tenant default plugin installation
|
||||
"tasks.refresh_billing_vector_space_task", # billing vector-space cache refresh
|
||||
"tasks.workflow_run_archive_download_tasks", # workflow-run archive download preparation
|
||||
"tasks.im_message_inbox_tasks", # durable IM callback processing
|
||||
"tasks.human_input_im_card_tasks", # post-submission IM card updates
|
||||
"tasks.im_contact_sync_tasks", # durable IM Contact directory synchronization
|
||||
"tasks.knowledge_fs_initial_source_preview_tasks", # datasource previews use the standard dataset queue
|
||||
"tasks.knowledge_fs_agent_investigation_tasks",
|
||||
"tasks.knowledge_fs_failed_retrieval_tasks", # best-effort Workflow quality capture uses dataset workers
|
||||
|
||||
@@ -46,6 +46,17 @@ class _ScriptRegistrar(Protocol):
|
||||
def register_script(self, script: str) -> Callable[..., Any]: ...
|
||||
|
||||
|
||||
_INCR_IF_EXISTS_LUA = """
|
||||
-- Exactly 1 key and no arguments.
|
||||
-- KEYS[1]: Existing integer counter key, including the deployment prefix.
|
||||
-- Returns the incremented integer, or nil (Lua false) if the key is missing.
|
||||
-- INCR preserves TTL; checking existence atomically avoids recreating an
|
||||
-- expired/deleted counter without an expiry.
|
||||
if redis.call('EXISTS', KEYS[1]) == 0 then return false end
|
||||
return redis.call('INCR', KEYS[1])
|
||||
"""
|
||||
|
||||
|
||||
class RedisClientWrapper:
|
||||
"""
|
||||
A wrapper class for the Redis client that addresses the issue where the global
|
||||
@@ -176,6 +187,13 @@ class RedisClientWrapper:
|
||||
def hset(self, name: str | bytes, *args: Any, **kwargs: Any) -> Any:
|
||||
return self._require_client().hset(_serialize_redis_name_arg(name, self._get_prefix()), *args, **kwargs)
|
||||
|
||||
def incr_if_exists(self, name: str) -> int | None:
|
||||
"""Increment an existing integer counter, preserving TTL; return None if absent."""
|
||||
count = self.eval(_INCR_IF_EXISTS_LUA, 1, _serialize_redis_name(name, self._get_prefix()))
|
||||
if count is not None and not isinstance(count, int):
|
||||
raise ValueError("Invalid Redis counter response")
|
||||
return count
|
||||
|
||||
def hgetall(self, name: str | bytes) -> Any:
|
||||
return self._require_client().hgetall(_serialize_redis_name_arg(name, self._get_prefix()))
|
||||
|
||||
@@ -225,6 +243,11 @@ class RedisClientWrapper:
|
||||
def pubsub(self) -> PubSub:
|
||||
return self._require_client().pubsub()
|
||||
|
||||
def eval(self, script: str, numkeys: int, *args: str | int) -> Any:
|
||||
# Lua callers supply physical names, matching the historically delegated API.
|
||||
# Redis script responses are dynamically shaped at this SDK boundary.
|
||||
return self._require_client().execute_command("EVAL", script, numkeys, *args)
|
||||
|
||||
def pipeline(self, transaction: bool = True, shard_hint: str | None = None) -> Any:
|
||||
return self._require_client().pipeline(transaction=transaction, shard_hint=shard_hint)
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Refresh hosted file links in messages and frozen form display content."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
from urllib.parse import parse_qs, parse_qsl, quote, unquote_plus, urlsplit
|
||||
|
||||
from configs import dify_config
|
||||
from core.tools.signature import sign_tool_file, sign_upload_file_preview_url
|
||||
from graphon.file import helpers as file_helpers
|
||||
|
||||
# Agent answers contain Markdown links, backticked links, and bare URLs. Keep
|
||||
# relative /files/ links too; skipping those formats previously left internal
|
||||
# file hosts in browser-facing answers. Refs #40788.
|
||||
_CONTENT_URL = re.compile(r"""https?://[^\s<>"'`()\[\]]+|(?<![\w/])/files/[^\s<>"'`()\[\]]+""")
|
||||
_FILE_PREVIEW_PATH = re.compile(
|
||||
r"/files/(?:(?P<upload_id>[\w-]+)/(?P<preview_kind>file|image)-preview"
|
||||
r"|(?P<generated_kind>tools|datasources)/(?P<generated_id>[\w-]+)(?P<extension>\.[^/]+)?)$"
|
||||
)
|
||||
|
||||
|
||||
def re_sign_file_urls(
|
||||
content: str,
|
||||
*,
|
||||
preserve_url: bool = False,
|
||||
replacements: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Refresh display links, reusing replacements across related content.
|
||||
|
||||
Messages retain their existing external-host and legacy-preview conversion.
|
||||
Frozen forms preserve the original URL and renew only authentic signatures.
|
||||
"""
|
||||
if not content:
|
||||
return content
|
||||
if replacements is None:
|
||||
replacements = {}
|
||||
|
||||
def replace_url(match: re.Match[str]) -> str:
|
||||
candidate = match.group()
|
||||
url = candidate.rstrip(".,;")
|
||||
if url not in replacements:
|
||||
replacements[url] = _re_sign_file_url(url, preserve_url=preserve_url)
|
||||
return replacements[url] + candidate[len(url) :]
|
||||
|
||||
return _CONTENT_URL.sub(replace_url, content)
|
||||
|
||||
|
||||
def _re_sign_file_url(url: str, *, preserve_url: bool) -> str:
|
||||
parsed = urlsplit(url)
|
||||
path = _FILE_PREVIEW_PATH.search(parsed.path)
|
||||
if path is None:
|
||||
return url
|
||||
query = parse_qs(parsed.query, keep_blank_values=True)
|
||||
if any(len(query.get(key, [])) != 1 for key in ("timestamp", "nonce", "sign")):
|
||||
return url
|
||||
file_id = path.group("upload_id") or path.group("generated_id")
|
||||
preview_kind = path.group("preview_kind") or "file"
|
||||
if preserve_url:
|
||||
if not any(
|
||||
url.startswith(f"{base_url}/files/")
|
||||
for base_url in (dify_config.FILES_URL, dify_config.INTERNAL_FILES_URL, "")
|
||||
):
|
||||
return url
|
||||
# Ignore expiry, but do not grant access to arbitrary paths embedded in
|
||||
# user-authored content merely because they resemble hosted file URLs.
|
||||
payload = f"{preview_kind}-preview|{file_id}|{query['timestamp'][0]}|{query['nonce'][0]}"
|
||||
expected = base64.urlsafe_b64encode(
|
||||
hmac.new(dify_config.SECRET_KEY.encode(), payload.encode(), hashlib.sha256).digest()
|
||||
)
|
||||
if not hmac.compare_digest(query["sign"][0].encode(), expected):
|
||||
return url
|
||||
|
||||
if path.group("generated_id"):
|
||||
if not preserve_url and path.group("generated_kind") != "tools":
|
||||
return url
|
||||
extension = path.group("extension") or ""
|
||||
if not preserve_url:
|
||||
extension = f".{extension.rsplit('.', 1)[-1]}" if extension else ".bin"
|
||||
if len(extension) > 10:
|
||||
extension = ".bin"
|
||||
signed_url = sign_tool_file(tool_file_id=file_id, extension=extension)
|
||||
elif preserve_url and preview_kind == "image":
|
||||
signed_url = sign_upload_file_preview_url(upload_file_id=file_id, extension="")
|
||||
else:
|
||||
# Messages keep converting deprecated image-preview links to file-preview.
|
||||
signed_url = file_helpers.get_signed_file_url(file_id)
|
||||
|
||||
if not preserve_url:
|
||||
if "as_attachment" in url:
|
||||
signed_url += "&as_attachment=true"
|
||||
return signed_url
|
||||
|
||||
signature_query = dict(parse_qsl(urlsplit(signed_url).query))
|
||||
# Preserve download options and unrelated query encoding in frozen content.
|
||||
parameters: list[str] = []
|
||||
for parameter in parsed.query.split("&"):
|
||||
key = unquote_plus(parameter.partition("=")[0])
|
||||
if key in signature_query:
|
||||
parameter = f"{key}={quote(signature_query[key], safe='')}"
|
||||
parameters.append(parameter)
|
||||
return parsed._replace(query="&".join(parameters)).geturl()
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Project frozen forms into the shared Dify form-rendering contract."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import assert_never
|
||||
|
||||
from core.human_input_v2.resolved_form import (
|
||||
FileInput,
|
||||
FileListInput,
|
||||
MarkdownFragment,
|
||||
ParagraphInput,
|
||||
ResolvedForm,
|
||||
SelectInput,
|
||||
)
|
||||
from core.workflow.nodes.human_input.entities import (
|
||||
FileInputConfig,
|
||||
FileListInputConfig,
|
||||
FormInputConfig,
|
||||
ParagraphInputConfig,
|
||||
SelectInputConfig,
|
||||
StringListSource,
|
||||
StringSource,
|
||||
UserActionConfig,
|
||||
)
|
||||
from core.workflow.nodes.human_input.enums import ValueSourceType
|
||||
from factories.file_url_factory import re_sign_file_urls
|
||||
from graphon.variables.segments import ArrayFileSegment, Segment
|
||||
|
||||
|
||||
def refresh_form_file_urls(form: ResolvedForm) -> ResolvedForm:
|
||||
"""Copy display content with fresh file URLs without resolving variables again."""
|
||||
refreshed_urls: dict[str, str] = {}
|
||||
|
||||
# Reuse each replacement across fragments and the legacy rendering so both
|
||||
# representations expose exactly the same signature in a single response.
|
||||
return form.model_copy(
|
||||
update={
|
||||
"parts": tuple(
|
||||
part.model_copy(
|
||||
update={"text": re_sign_file_urls(part.text, preserve_url=True, replacements=refreshed_urls)}
|
||||
)
|
||||
if isinstance(part, MarkdownFragment)
|
||||
else part
|
||||
for part in form.parts
|
||||
),
|
||||
"legacy_form_content": re_sign_file_urls(
|
||||
form.legacy_form_content, preserve_url=True, replacements=refreshed_urls
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FormPresentation:
|
||||
form_content: str
|
||||
inputs: list[FormInputConfig]
|
||||
user_actions: list[UserActionConfig]
|
||||
resolved_default_values: dict[str, str]
|
||||
|
||||
|
||||
def build_form_presentation(form: ResolvedForm) -> FormPresentation:
|
||||
"""Use the immutable snapshot without reloading workflow configuration."""
|
||||
inputs: list[FormInputConfig] = []
|
||||
defaults: dict[str, str] = {}
|
||||
seen: set[str] = set()
|
||||
for part in form.parts:
|
||||
if isinstance(part, MarkdownFragment) or part.output_variable_name in seen:
|
||||
continue
|
||||
name = part.output_variable_name
|
||||
seen.add(name)
|
||||
match part:
|
||||
case ParagraphInput():
|
||||
# Shared form controls require a concrete paragraph default,
|
||||
# including when the frozen form has no configured default.
|
||||
default = StringSource(
|
||||
type=ValueSourceType.CONSTANT,
|
||||
value=part.default_value if part.default_value is not None else "",
|
||||
)
|
||||
if part.default_value is not None:
|
||||
defaults[name] = part.default_value
|
||||
inputs.append(ParagraphInputConfig(output_variable_name=name, default=default))
|
||||
case SelectInput():
|
||||
inputs.append(
|
||||
SelectInputConfig(
|
||||
output_variable_name=name,
|
||||
option_source=StringListSource(
|
||||
type=ValueSourceType.CONSTANT,
|
||||
value=list(part.options),
|
||||
),
|
||||
)
|
||||
)
|
||||
if part.default_value is not None:
|
||||
defaults[name] = part.default_value
|
||||
case FileInput():
|
||||
inputs.append(
|
||||
FileInputConfig(
|
||||
output_variable_name=name,
|
||||
allowed_file_types=part.allowed_file_types,
|
||||
allowed_file_extensions=part.allowed_file_extensions,
|
||||
allowed_file_upload_methods=part.allowed_file_upload_methods,
|
||||
)
|
||||
)
|
||||
case FileListInput():
|
||||
inputs.append(
|
||||
FileListInputConfig(
|
||||
output_variable_name=name,
|
||||
allowed_file_types=part.allowed_file_types,
|
||||
allowed_file_extensions=part.allowed_file_extensions,
|
||||
allowed_file_upload_methods=part.allowed_file_upload_methods,
|
||||
number_limits=part.number_limits,
|
||||
)
|
||||
)
|
||||
return FormPresentation(
|
||||
form_content=form.legacy_form_content,
|
||||
inputs=inputs,
|
||||
user_actions=[
|
||||
UserActionConfig(id=action.id, title=action.title, button_style=action.button_style)
|
||||
for action in form.actions
|
||||
],
|
||||
resolved_default_values=defaults,
|
||||
)
|
||||
|
||||
|
||||
def render_filled_content(form: ResolvedForm, inputs: Mapping[str, Segment]) -> str:
|
||||
"""Render accepted inputs consistently across workflow events and delivery channels."""
|
||||
# Render source-ordered parts once: submitted text containing output markers
|
||||
# must not be interpreted as another template substitution.
|
||||
fragments: list[str] = []
|
||||
for part in form.parts:
|
||||
if isinstance(part, MarkdownFragment):
|
||||
fragments.append(part.text)
|
||||
continue
|
||||
segment = inputs.get(part.output_variable_name)
|
||||
if segment is None or segment.to_object() is None:
|
||||
continue
|
||||
match part:
|
||||
case ParagraphInput() | SelectInput():
|
||||
fragments.append(segment.text)
|
||||
case FileInput():
|
||||
fragments.append("[file]")
|
||||
case FileListInput():
|
||||
assert isinstance(segment, ArrayFileSegment), "Accepted file-list input must retain its Segment type"
|
||||
fragments.append(f"[{len(segment.value)} files]")
|
||||
case _:
|
||||
assert_never(part)
|
||||
return "".join(fragments)
|
||||
@@ -0,0 +1,12 @@
|
||||
from pydantic import Field, PositiveInt
|
||||
|
||||
|
||||
class PaginationParamsMixin:
|
||||
page: PositiveInt = Field(1, description="1-based page number.")
|
||||
limit: PositiveInt = Field(default=20, le=100, description="Maximum number of records returned per page.")
|
||||
|
||||
|
||||
class PaginationResultMixin:
|
||||
limit: int = Field(description="Page size used for the current query.")
|
||||
total: int = Field(description="Total number of candidates matching the current query.")
|
||||
page: int = Field(description="Current 1-based page number.")
|
||||
@@ -0,0 +1,24 @@
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BeforeValidator, Field, PlainSerializer
|
||||
|
||||
from libs.datetime_utils import to_utc_timestamp
|
||||
|
||||
|
||||
def _require_datetime(value: object) -> datetime:
|
||||
if not isinstance(value, datetime):
|
||||
raise ValueError("Timestamp source must be a datetime")
|
||||
return value
|
||||
|
||||
|
||||
def _datetime_to_unix_seconds(value: datetime) -> int:
|
||||
return to_utc_timestamp(value)
|
||||
|
||||
|
||||
Timestamp = Annotated[
|
||||
datetime,
|
||||
BeforeValidator(_require_datetime),
|
||||
PlainSerializer(_datetime_to_unix_seconds, return_type=int, when_used="json"),
|
||||
Field(..., description="Unix timestamp in seconds"),
|
||||
]
|
||||
+78
-1
@@ -24,6 +24,7 @@ from typing_extensions import TypedDict
|
||||
from configs import dify_config
|
||||
from core.app.features.rate_limiting.rate_limit import RateLimitGenerator
|
||||
from extensions.ext_redis import redis_client
|
||||
from extensions.redis_names import serialize_redis_name
|
||||
from graphon.file import helpers as file_helpers
|
||||
from graphon.model_runtime.utils.encoders import jsonable_encoder
|
||||
|
||||
@@ -561,6 +562,7 @@ class TokenManager:
|
||||
|
||||
|
||||
class _RateLimiterRedisClient(Protocol):
|
||||
def eval(self, script: str, numkeys: int, *args: str | int) -> object: ...
|
||||
def register_script(self, script: str) -> Callable[..., Any]: ...
|
||||
|
||||
def zadd(self, name: str | bytes, mapping: dict[str | bytes | int | float, float | int | str | bytes]) -> int: ...
|
||||
@@ -579,6 +581,44 @@ def _default_rate_limit_member_factory() -> str:
|
||||
return f"{current_time}:{secrets.token_urlsafe(nbytes=8)}"
|
||||
|
||||
|
||||
_ACQUIRE_RATE_LIMIT_LUA = """
|
||||
-- EVAL contract: exactly 1 key and 4 arguments.
|
||||
-- KEYS[1]: Physical Redis ZSET key for one rate-limit subject. Members are
|
||||
-- unique request IDs; scores are Redis Unix timestamps in seconds.
|
||||
-- ARGV[1]: Positive rolling-window duration in seconds.
|
||||
-- ARGV[2]: Positive maximum number of accepted requests within that window.
|
||||
-- ARGV[3]: Minimum seconds between accepted requests, from 0 to ARGV[1].
|
||||
-- ARGV[4]: Unique, nonempty request ID to consume a slot if allowed; an empty
|
||||
-- string only queries the delay without consuming a slot.
|
||||
-- Returns: 0 when allowed; otherwise the positive number of seconds to wait,
|
||||
-- rounded up and accounting for both the window limit and cooldown.
|
||||
-- Every call removes scores <= now - window, retaining (now - window, now].
|
||||
-- Only an allowed, nonempty request ID is inserted and refreshes the key TTL.
|
||||
-- Rejected requests and delay queries do not consume quota or refresh TTL.
|
||||
-- Redis TIME and atomic execution keep these decisions consistent across nodes.
|
||||
local clock = redis.call('TIME')
|
||||
local now = tonumber(clock[1]) + tonumber(clock[2]) / 1000000
|
||||
local window = tonumber(ARGV[1])
|
||||
local maximum = tonumber(ARGV[2])
|
||||
local cooldown = tonumber(ARGV[3])
|
||||
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now - window)
|
||||
local count = redis.call('ZCARD', KEYS[1])
|
||||
local wait = 0
|
||||
if count >= maximum then
|
||||
local oldest = redis.call('ZRANGE', KEYS[1], count - maximum, count - maximum, 'WITHSCORES')
|
||||
wait = math.max(wait, tonumber(oldest[2]) + window - now)
|
||||
end
|
||||
local latest = redis.call('ZREVRANGE', KEYS[1], 0, 0, 'WITHSCORES')
|
||||
if #latest > 0 then wait = math.max(wait, tonumber(latest[2]) + cooldown - now) end
|
||||
if wait > 0 then return math.ceil(wait) end
|
||||
if ARGV[4] ~= '' then
|
||||
redis.call('ZADD', KEYS[1], now, ARGV[4])
|
||||
redis.call('EXPIRE', KEYS[1], math.max(window, cooldown))
|
||||
end
|
||||
return 0
|
||||
"""
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -622,6 +662,12 @@ class RateLimiter:
|
||||
raise
|
||||
|
||||
def is_rate_limited(self, email: str) -> bool:
|
||||
"""Legacy non-consuming limit query, retained for existing admission policies.
|
||||
|
||||
New callers should use acquire() for atomic admission or retry_after()
|
||||
for a delay query. Migrating existing callers requires auditing when
|
||||
their events should consume quota.
|
||||
"""
|
||||
key = self._get_key(email)
|
||||
current_time = int(time.time())
|
||||
window_start_time = current_time - self.time_window
|
||||
@@ -632,6 +678,11 @@ class RateLimiter:
|
||||
return bool(attempts and int(attempts) >= self.max_attempts)
|
||||
|
||||
def increment_rate_limit(self, email: str):
|
||||
"""Record an event unconditionally, without enforcing admission limits.
|
||||
|
||||
For atomic check-and-consume admission, use acquire() instead of calling
|
||||
is_rate_limited() and this method separately.
|
||||
"""
|
||||
key = self._get_key(email)
|
||||
member = self._member_factory()
|
||||
current_time = int(time.time())
|
||||
@@ -639,11 +690,37 @@ class RateLimiter:
|
||||
self._redis_client.zadd(key, {member: current_time})
|
||||
self._redis_client.expire(key, self.time_window * 2)
|
||||
|
||||
def acquire(self, subject: str, *, cooldown_seconds: int = 0) -> int:
|
||||
"""Atomically consume a slot, or return seconds to wait without consuming it."""
|
||||
return self._atomic_wait(subject, cooldown_seconds, self._member_factory())
|
||||
|
||||
def retry_after(self, subject: str, *, cooldown_seconds: int = 0) -> int:
|
||||
"""Read the combined cooldown/window delay without consuming a slot."""
|
||||
return self._atomic_wait(subject, cooldown_seconds, "")
|
||||
|
||||
def _atomic_wait(self, subject: str, cooldown_seconds: int, member: str) -> int:
|
||||
if self.max_attempts <= 0 or self.time_window <= 0 or not 0 <= cooldown_seconds <= self.time_window:
|
||||
raise ValueError("Atomic rate limits require positive limits and a cooldown within the window")
|
||||
# eval bypasses the wrapper's key prefix; Redis time prevents replica clock skew.
|
||||
remaining = self._redis_client.eval(
|
||||
_ACQUIRE_RATE_LIMIT_LUA,
|
||||
1,
|
||||
serialize_redis_name(self._get_key(subject)),
|
||||
self.time_window,
|
||||
self.max_attempts,
|
||||
cooldown_seconds,
|
||||
member,
|
||||
)
|
||||
if not isinstance(remaining, int):
|
||||
raise ValueError("Invalid Redis rate limit response")
|
||||
return remaining
|
||||
|
||||
def seconds_until_available(self, email: str) -> int:
|
||||
"""Seconds until the oldest in-window entry expires, freeing a slot.
|
||||
|
||||
Defensive floor of 1 second. Caller should only invoke this after
|
||||
is_rate_limited() returned True.
|
||||
is_rate_limited() returned True. New callers should use retry_after(),
|
||||
which returns 0 when no delay is required.
|
||||
"""
|
||||
key = self._get_key(email)
|
||||
oldest = cast(Any, self._redis_client).zrange(key, 0, 0, withscores=True)
|
||||
|
||||
@@ -0,0 +1,792 @@
|
||||
"""Create the Human Input v2 schema.
|
||||
|
||||
Revision ID: e8b4a1c6d920
|
||||
Revises: d7e9a1c3f502
|
||||
Create Date: 2026-10-26 16:46:14
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from models.types import LongText, StringUUID
|
||||
|
||||
revision = "e8b4a1c6d920"
|
||||
down_revision = "d7e9a1c3f502"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_PROVIDER_METADATA_MAX_LENGTH = 128
|
||||
|
||||
|
||||
def _default_fields(table_name: str) -> tuple[sa.Column, sa.Column, sa.Column, sa.PrimaryKeyConstraint]:
|
||||
return (
|
||||
sa.Column("id", StringUUID(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=f"{table_name}_pkey"),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"human_input_contact_identities",
|
||||
sa.Column(
|
||||
"subject_type",
|
||||
sa.String(length=20),
|
||||
nullable=False,
|
||||
comment="Immutable Account or External subject discriminator.",
|
||||
),
|
||||
sa.Column(
|
||||
"account_id",
|
||||
StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical accounts.id reference for Account subjects only.",
|
||||
),
|
||||
sa.Column("id", StringUUID(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name="human_input_contact_identities_pkey"),
|
||||
sa.UniqueConstraint("account_id", name="human_input_contact_identities_account_id_uq"),
|
||||
sa.CheckConstraint(
|
||||
"(subject_type = 'account' AND account_id IS NOT NULL) OR "
|
||||
"(subject_type = 'external' AND account_id IS NULL)",
|
||||
name="human_input_contact_identities_subject_type_ck",
|
||||
),
|
||||
comment=(
|
||||
"Immutable Human Input Contact identities. Mutable Account and External Contact profile facts live "
|
||||
"with their source owners."
|
||||
),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_external_contact_profiles",
|
||||
sa.Column(
|
||||
"contact_id",
|
||||
StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_contact_identities.id reference for one External subject.",
|
||||
),
|
||||
sa.Column(
|
||||
"tenant_id",
|
||||
StringUUID(),
|
||||
nullable=False,
|
||||
comment="Owning tenants.id used by every current External Contact lookup.",
|
||||
),
|
||||
sa.Column("name", sa.String(length=255), nullable=False, comment="Workspace-managed display name."),
|
||||
sa.Column(
|
||||
"normalized_name",
|
||||
sa.String(length=255),
|
||||
nullable=False,
|
||||
comment="Canonical search value maintained by External Contact writes.",
|
||||
),
|
||||
sa.Column(
|
||||
"email",
|
||||
sa.String(length=320),
|
||||
nullable=False,
|
||||
comment="Workspace-managed deliverable Email address.",
|
||||
),
|
||||
sa.Column(
|
||||
"normalized_email",
|
||||
sa.String(length=320),
|
||||
nullable=False,
|
||||
comment="Canonical Email equality value maintained by External Contact writes.",
|
||||
),
|
||||
sa.Column(
|
||||
"avatar_file_id",
|
||||
StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical upload_files.id reference owned by the same workspace.",
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("contact_id", name="human_input_external_contact_profiles_pkey"),
|
||||
sa.UniqueConstraint("tenant_id", "normalized_email", name="hiecp_tenant_normalized_email_uq"),
|
||||
comment=(
|
||||
"Current workspace-owned External Contact profiles. Deletion removes both this profile and its "
|
||||
"Contact identity."
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"hiecp_tenant_normalized_name_idx",
|
||||
"human_input_external_contact_profiles",
|
||||
["tenant_id", "normalized_name"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_platform_contact_workspace_entries",
|
||||
sa.Column("tenant_id", StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
|
||||
sa.Column(
|
||||
"contact_id",
|
||||
StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical foreign key to human_input_contact_identities.id.",
|
||||
),
|
||||
sa.Column(
|
||||
"added_by_account_id",
|
||||
StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical foreign key to accounts.id for the administrator who added this directory entry.",
|
||||
),
|
||||
sa.Column("id", StringUUID(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name="human_input_platform_contact_workspace_entries_pkey"),
|
||||
sa.UniqueConstraint("tenant_id", "contact_id", name="hipcwe_tenant_contact_uq"),
|
||||
comment=(
|
||||
"EE-only workspace allow-list for Organization Account contacts. Workspace membership and External "
|
||||
"contact ownership must not create rows in this table."
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"hipcwe_tenant_created_at_id_idx",
|
||||
"human_input_platform_contact_workspace_entries",
|
||||
["tenant_id", "created_at", "id"],
|
||||
)
|
||||
op.create_index(
|
||||
"hipcwe_contact_id_idx",
|
||||
"human_input_platform_contact_workspace_entries",
|
||||
["contact_id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_im_channels",
|
||||
sa.Column("id", StringUUID(), nullable=False),
|
||||
sa.Column(
|
||||
"owner_key",
|
||||
sa.String(length=50),
|
||||
nullable=False,
|
||||
comment="Canonical owner slot: workspace:<tenant_id> or deployment.",
|
||||
),
|
||||
sa.Column(
|
||||
"provider",
|
||||
sa.String(length=20),
|
||||
nullable=False,
|
||||
comment="Configured IM provider discriminator.",
|
||||
),
|
||||
sa.Column(
|
||||
"provider_tenant_id",
|
||||
sa.String(length=255),
|
||||
nullable=False,
|
||||
comment="Confirmed provider-side organization, tenant, or workspace identifier.",
|
||||
),
|
||||
sa.Column(
|
||||
"encrypted_credentials",
|
||||
LongText(),
|
||||
nullable=False,
|
||||
comment="Versioned opaque encrypted IM Channel credential envelope.",
|
||||
),
|
||||
sa.Column(
|
||||
"app_identifier",
|
||||
sa.String(length=255),
|
||||
nullable=False,
|
||||
comment="Safe provider application identifier used by credential-free projections.",
|
||||
),
|
||||
sa.Column(
|
||||
"webhook_id",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
comment="Server-generated globally unique route ID used to derive webhook URLs.",
|
||||
),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.String(length=20),
|
||||
nullable=False,
|
||||
comment="Stored credential-safe Channel status snapshot.",
|
||||
),
|
||||
sa.Column(
|
||||
"config_version",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
comment="Monotonic numeric version paired with the Channel ID for CAS.",
|
||||
),
|
||||
sa.Column(
|
||||
"configured_by_account_id",
|
||||
StringUUID(),
|
||||
nullable=True,
|
||||
comment="Latest configuring Dify Account; null for deployment-owned writes.",
|
||||
),
|
||||
sa.Column(
|
||||
"status_reason",
|
||||
LongText(),
|
||||
nullable=True,
|
||||
comment="Operator-safe status explanation without provider payload or credentials.",
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
server_default=sa.func.current_timestamp(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
server_default=sa.func.current_timestamp(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name="human_input_im_channels_pkey"),
|
||||
sa.UniqueConstraint("owner_key", name="human_input_im_channels_owner_key_uq"),
|
||||
sa.UniqueConstraint("webhook_id", name="human_input_im_channels_webhook_id_uq"),
|
||||
sa.CheckConstraint(
|
||||
"config_version > 0",
|
||||
name="human_input_im_channels_config_version_positive_ck",
|
||||
),
|
||||
comment=(
|
||||
"Current owner-scoped Human Input IM Channel configuration. "
|
||||
"Directory, Binding, Sync, and inbox records remain separately owned."
|
||||
),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_im_identities",
|
||||
sa.Column(
|
||||
"channel_id",
|
||||
StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_im_channels.id reference.",
|
||||
),
|
||||
sa.Column(
|
||||
"provider_user_id",
|
||||
sa.String(length=255),
|
||||
nullable=False,
|
||||
comment="Provider-native user identifier within the owning Channel.",
|
||||
),
|
||||
sa.Column(
|
||||
"raw_payload",
|
||||
LongText(),
|
||||
nullable=False,
|
||||
comment="Latest opaque Provider payload retained for diagnostics.",
|
||||
),
|
||||
sa.Column(
|
||||
"last_seen_sync_run_id",
|
||||
StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_im_sync_runs.id reference for the latest observation.",
|
||||
),
|
||||
sa.Column(
|
||||
"last_seen_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
comment="Timestamp of the latest successful Provider observation.",
|
||||
),
|
||||
sa.Column(
|
||||
"display_name",
|
||||
sa.String(length=255),
|
||||
nullable=True,
|
||||
comment="Latest canonical non-blank Provider display name.",
|
||||
),
|
||||
sa.Column(
|
||||
"normalized_name",
|
||||
sa.String(length=255),
|
||||
nullable=True,
|
||||
comment="Canonical display name used by persistence queries.",
|
||||
),
|
||||
sa.Column(
|
||||
"email",
|
||||
sa.String(length=320),
|
||||
nullable=True,
|
||||
comment="Latest canonical non-blank Provider email.",
|
||||
),
|
||||
sa.Column(
|
||||
"normalized_email",
|
||||
sa.String(length=320),
|
||||
nullable=True,
|
||||
comment="Canonical email used by matching and persistence queries.",
|
||||
),
|
||||
sa.Column(
|
||||
"avatar_file_id",
|
||||
StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical upload_files.id reference for the current IM avatar.",
|
||||
),
|
||||
*_default_fields("human_input_im_identities"),
|
||||
sa.UniqueConstraint(
|
||||
"channel_id",
|
||||
"provider_user_id",
|
||||
name="human_input_im_identities_channel_provider_user_uq",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"length(trim(provider_user_id)) > 0",
|
||||
name="human_input_im_identities_provider_user_nonblank",
|
||||
),
|
||||
comment="Current Provider users synchronized through one IM Channel.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiimi_channel_email_idx",
|
||||
"human_input_im_identities",
|
||||
["channel_id", "normalized_email"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiimi_channel_name_idx",
|
||||
"human_input_im_identities",
|
||||
["channel_id", "normalized_name"],
|
||||
)
|
||||
op.create_table(
|
||||
"human_input_im_bindings",
|
||||
sa.Column(
|
||||
"channel_id",
|
||||
StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_im_channels.id reference.",
|
||||
),
|
||||
sa.Column(
|
||||
"contact_id",
|
||||
StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_contact_identities.id reference.",
|
||||
),
|
||||
sa.Column(
|
||||
"im_identity_id",
|
||||
StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_im_identities.id reference.",
|
||||
),
|
||||
sa.Column(
|
||||
"bound_by_account_id",
|
||||
StringUUID(),
|
||||
nullable=True,
|
||||
comment="Latest Dify Account that manually selected this Binding, when available.",
|
||||
),
|
||||
*_default_fields("human_input_im_bindings"),
|
||||
sa.UniqueConstraint(
|
||||
"channel_id",
|
||||
"contact_id",
|
||||
name="human_input_im_bindings_channel_contact_uq",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"channel_id",
|
||||
"im_identity_id",
|
||||
name="human_input_im_bindings_channel_identity_uq",
|
||||
),
|
||||
comment="Default Contact-to-IM-identity Bindings for one IM Channel.",
|
||||
)
|
||||
op.create_table(
|
||||
"human_input_im_workspace_binding_overrides",
|
||||
sa.Column(
|
||||
"channel_id",
|
||||
StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_im_channels.id reference.",
|
||||
),
|
||||
sa.Column(
|
||||
"tenant_id",
|
||||
StringUUID(),
|
||||
nullable=False,
|
||||
comment="Target tenants.id whose effective Binding is overridden.",
|
||||
),
|
||||
sa.Column(
|
||||
"contact_id",
|
||||
StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_contact_identities.id reference.",
|
||||
),
|
||||
sa.Column(
|
||||
"im_identity_id",
|
||||
StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_im_identities.id reference.",
|
||||
),
|
||||
sa.Column(
|
||||
"bound_by_account_id",
|
||||
StringUUID(),
|
||||
nullable=True,
|
||||
comment="Dify Account that selected this workspace override.",
|
||||
),
|
||||
*_default_fields("human_input_im_workspace_binding_overrides"),
|
||||
sa.UniqueConstraint(
|
||||
"channel_id",
|
||||
"tenant_id",
|
||||
"contact_id",
|
||||
name="hiimwbo_channel_tenant_contact_uq",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"channel_id",
|
||||
"tenant_id",
|
||||
"im_identity_id",
|
||||
name="hiimwbo_channel_tenant_identity_uq",
|
||||
),
|
||||
comment="Workspace-specific Binding overrides for one IM Channel.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiimwbo_channel_identity_idx",
|
||||
"human_input_im_workspace_binding_overrides",
|
||||
["channel_id", "im_identity_id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_im_sync_runs",
|
||||
sa.Column(
|
||||
"integration_id",
|
||||
StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_im_channels.id owner.",
|
||||
),
|
||||
sa.Column(
|
||||
"integration_config_version",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
comment="Captured integration configuration revision.",
|
||||
),
|
||||
sa.Column("provider", sa.String(length=20), nullable=False, comment="Captured provider discriminator."),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, comment="Synchronization lifecycle state."),
|
||||
sa.Column("added_count", sa.Integer(), nullable=False, comment="Newly matched and bound entries."),
|
||||
sa.Column("not_matched_count", sa.Integer(), nullable=False, comment="Unmatched entries."),
|
||||
sa.Column("failed_count", sa.Integer(), nullable=False, comment="Failed entries."),
|
||||
sa.Column(
|
||||
"removed_count",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
comment="Removed binding facts, including one unbound-identity fact when applicable.",
|
||||
),
|
||||
sa.Column("skipped_count", sa.Integer(), nullable=False, comment="Intentionally skipped entries."),
|
||||
sa.Column(
|
||||
"started_by_account_id",
|
||||
StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical accounts.id for the trigger actor.",
|
||||
),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=True, comment="Worker start timestamp."),
|
||||
sa.Column("finished_at", sa.DateTime(), nullable=True, comment="Terminal timestamp."),
|
||||
sa.Column("error_code", sa.String(length=100), nullable=True, comment="Machine-readable terminal error."),
|
||||
sa.Column("error_message", LongText(), nullable=True, comment="Operator-safe terminal error."),
|
||||
*_default_fields("human_input_im_sync_runs"),
|
||||
sa.CheckConstraint("integration_config_version > 0", name="captured_version_positive"),
|
||||
sa.CheckConstraint(
|
||||
"added_count >= 0 AND not_matched_count >= 0 AND failed_count >= 0 AND removed_count >= 0 "
|
||||
"AND skipped_count >= 0",
|
||||
name="result_counts_nonnegative",
|
||||
),
|
||||
comment="Manual IM directory synchronization lifecycle and counts.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiimsr_integration_created_idx",
|
||||
"human_input_im_sync_runs",
|
||||
["integration_id", "created_at", "id"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiimsr_integration_status_created_idx",
|
||||
"human_input_im_sync_runs",
|
||||
["integration_id", "status", "created_at"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_im_sync_results",
|
||||
sa.Column(
|
||||
"integration_id",
|
||||
StringUUID(),
|
||||
nullable=False,
|
||||
comment="Denormalized logical human_input_im_channels.id.",
|
||||
),
|
||||
sa.Column(
|
||||
"sync_run_id",
|
||||
StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_im_sync_runs.id.",
|
||||
),
|
||||
sa.Column("result_type", sa.String(length=20), nullable=False, comment="Stable result bucket."),
|
||||
sa.Column("provider_user_id", sa.String(length=255), nullable=True, comment="Observed provider user ID."),
|
||||
sa.Column("display_name", sa.String(length=255), nullable=True, comment="Observed provider display name."),
|
||||
sa.Column("email", sa.String(length=320), nullable=True, comment="Observed provider email."),
|
||||
sa.Column("normalized_email", sa.String(length=320), nullable=True, comment="Normalized matching email."),
|
||||
sa.Column("contact_id", StringUUID(), nullable=True, comment="Historical logical Contact identity."),
|
||||
sa.Column(
|
||||
"im_identity_id",
|
||||
StringUUID(),
|
||||
nullable=True,
|
||||
comment="Historical logical IM identity.",
|
||||
),
|
||||
sa.Column("im_binding_id", StringUUID(), nullable=True, comment="Historical logical IM binding."),
|
||||
sa.Column("removal_reason", sa.String(length=32), nullable=True, comment="Stable removal reason."),
|
||||
sa.Column("reason_code", sa.String(length=100), nullable=True, comment="Machine-readable diagnostic reason."),
|
||||
sa.Column("reason_message", LongText(), nullable=True, comment="Operator-safe diagnostic."),
|
||||
sa.Column(
|
||||
"directory_entry_payload",
|
||||
LongText(),
|
||||
nullable=True,
|
||||
comment="Immutable provider entry JSON observed by this run.",
|
||||
),
|
||||
sa.Column(
|
||||
"contact_snapshot",
|
||||
LongText(),
|
||||
nullable=True,
|
||||
comment="Immutable Contact display snapshot JSON.",
|
||||
),
|
||||
sa.Column(
|
||||
"identity_snapshot",
|
||||
LongText(),
|
||||
nullable=True,
|
||||
comment="Immutable removed identity snapshot JSON.",
|
||||
),
|
||||
sa.Column(
|
||||
"operation_key",
|
||||
sa.String(length=255),
|
||||
nullable=True,
|
||||
comment="Deterministic run-local idempotency key; null only for historical results.",
|
||||
),
|
||||
*_default_fields("human_input_im_sync_results"),
|
||||
sa.UniqueConstraint(
|
||||
"sync_run_id",
|
||||
"operation_key",
|
||||
name="human_input_im_sync_results_run_operation_uq",
|
||||
),
|
||||
comment="Append-only per-entry, removed-binding, and diagnostic IM synchronization outcomes.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiimsres_run_type_created_idx",
|
||||
"human_input_im_sync_results",
|
||||
["sync_run_id", "result_type", "created_at", "id"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiimsres_integration_contact_created_idx",
|
||||
"human_input_im_sync_results",
|
||||
["integration_id", "contact_id", "created_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiimsres_integration_identity_created_idx",
|
||||
"human_input_im_sync_results",
|
||||
["integration_id", "im_identity_id", "created_at"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_email_providers",
|
||||
sa.Column("provider", sa.String(length=20), nullable=False, comment="Configured email provider discriminator."),
|
||||
sa.Column("sender_email", sa.String(length=320), nullable=False, comment="Configured sender email address."),
|
||||
sa.Column(
|
||||
"encrypted_credentials",
|
||||
LongText(),
|
||||
nullable=False,
|
||||
comment="Encrypted Resend credential Pydantic model serialized as JSON text.",
|
||||
),
|
||||
sa.Column("tenant_id", StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
|
||||
sa.Column("sender_name", sa.String(length=255), nullable=False, comment="Optional sender display name."),
|
||||
sa.Column(
|
||||
"configured_by_account_id",
|
||||
StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical foreign key to accounts.id for the latest configuration write.",
|
||||
),
|
||||
sa.Column(
|
||||
"config_version",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("1"),
|
||||
comment="Monotonic Email configuration revision used for compare-and-swap.",
|
||||
),
|
||||
*_default_fields("human_input_email_providers"),
|
||||
sa.UniqueConstraint("tenant_id", name="human_input_email_providers_tenant_uq"),
|
||||
comment="Workspace-level Human Input email delivery configuration.",
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"im_message_event",
|
||||
sa.Column("integration_id", StringUUID(), nullable=False),
|
||||
sa.Column("provider", sa.String(length=20), nullable=False),
|
||||
sa.Column(
|
||||
"tenant_id",
|
||||
sa.String(length=_PROVIDER_METADATA_MAX_LENGTH),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"event_id",
|
||||
sa.String(length=_PROVIDER_METADATA_MAX_LENGTH),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("provider_event_time", sa.DateTime(), nullable=True),
|
||||
sa.Column("received_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("provider_event_type", sa.String(length=_PROVIDER_METADATA_MAX_LENGTH), nullable=True),
|
||||
sa.Column("ingress_kind", sa.String(length=20), nullable=False),
|
||||
sa.Column("payload", LongText(), nullable=False),
|
||||
sa.Column(
|
||||
"request_ip_address",
|
||||
sa.Text(),
|
||||
nullable=True,
|
||||
comment="HTTP request IP when available; absent for stream and historical events.",
|
||||
),
|
||||
sa.Column("processed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("id", StringUUID(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name="im_message_inbox_pkey"),
|
||||
sa.UniqueConstraint("provider", "event_id", name="im_message_event_provider_event_uq"),
|
||||
comment="Durable authenticated IM callback records.",
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_im_reconciliation_changes",
|
||||
sa.Column(
|
||||
"integration_id",
|
||||
StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical foreign key to human_input_im_channels.id.",
|
||||
),
|
||||
sa.Column(
|
||||
"sync_run_id",
|
||||
StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical foreign key to human_input_im_sync_runs.id.",
|
||||
),
|
||||
sa.Column(
|
||||
"operation_key",
|
||||
sa.String(length=255),
|
||||
nullable=False,
|
||||
comment="Deterministic run-local idempotency key.",
|
||||
),
|
||||
sa.Column("subject_kind", sa.String(length=20), nullable=False),
|
||||
sa.Column("operation", sa.String(length=20), nullable=False),
|
||||
sa.Column("reason_code", sa.String(length=100), nullable=False),
|
||||
sa.Column("im_identity_id", StringUUID(), nullable=False),
|
||||
sa.Column("committed_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("im_binding_id", StringUUID(), nullable=True),
|
||||
sa.Column("contact_id", StringUUID(), nullable=True),
|
||||
sa.Column("before_snapshot", sa.Text(), nullable=True),
|
||||
sa.Column("after_snapshot", sa.Text(), nullable=True),
|
||||
sa.Column("id", StringUUID(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name="human_input_im_reconciliation_changes_pkey"),
|
||||
sa.UniqueConstraint(
|
||||
"sync_run_id",
|
||||
"operation_key",
|
||||
name="human_input_im_reconciliation_changes_run_operation_uq",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"before_snapshot IS NOT NULL OR after_snapshot IS NOT NULL",
|
||||
name="snapshot_present",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(operation = 'create' AND before_snapshot IS NULL AND after_snapshot IS NOT NULL) OR "
|
||||
"(operation = 'delete' AND before_snapshot IS NOT NULL AND after_snapshot IS NULL) OR "
|
||||
"(operation NOT IN ('create', 'delete') AND before_snapshot IS NOT NULL AND after_snapshot IS NOT NULL)",
|
||||
name="snapshot_operation_shape",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(subject_kind = 'identity' AND im_binding_id IS NULL) OR "
|
||||
"(subject_kind = 'binding' AND im_binding_id IS NOT NULL)",
|
||||
name="subject_identifier_shape",
|
||||
),
|
||||
comment="Append-only IM identity and IM binding reconciliation mutation history.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiimrc_run_subject_committed_idx",
|
||||
"human_input_im_reconciliation_changes",
|
||||
["sync_run_id", "subject_kind", "committed_at", "id"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiimrc_integration_committed_idx",
|
||||
"human_input_im_reconciliation_changes",
|
||||
["integration_id", "committed_at", "id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"hitlv2_forms",
|
||||
sa.Column("id", StringUUID(), nullable=False),
|
||||
sa.Column("tenant_id", StringUUID(), nullable=False),
|
||||
sa.Column("app_id", StringUUID(), nullable=False),
|
||||
sa.Column("workflow_run_id", StringUUID(), nullable=True),
|
||||
sa.Column("node_execution_id", StringUUID(), nullable=True),
|
||||
sa.Column("form_kind", sa.String(20), nullable=False),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.String(20),
|
||||
nullable=False,
|
||||
server_default="waiting",
|
||||
comment="Persisted form outcome; terminal states are not recomputed from deadlines.",
|
||||
),
|
||||
sa.Column("expiration_time", sa.DateTime(), nullable=False),
|
||||
sa.Column("global_timeout_deadline", sa.DateTime(), nullable=False),
|
||||
sa.Column("resolved_form", LongText(), nullable=False),
|
||||
sa.Column("submitted_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("submitted_by_recipient_id", StringUUID(), nullable=True),
|
||||
sa.Column("submitted_by_delivery_id", StringUUID(), nullable=True),
|
||||
sa.Column("selected_action_id", sa.String(200), nullable=True),
|
||||
sa.Column("raw_submission_inputs", sa.Text(), nullable=True),
|
||||
sa.Column("normalized_submission_data", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name="hitlv2_forms_pkey"),
|
||||
sa.UniqueConstraint(
|
||||
"workflow_run_id",
|
||||
"node_execution_id",
|
||||
name="hitlv2_forms_execution_uq",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"hitlv2_forms_expiration_scan_idx",
|
||||
"hitlv2_forms",
|
||||
["tenant_id", "app_id", "form_kind", "status", "id"],
|
||||
)
|
||||
op.create_index("hitlv2_forms_node_deadline_idx", "hitlv2_forms", ["form_kind", "status", "expiration_time", "id"])
|
||||
op.create_index(
|
||||
"hitlv2_forms_global_deadline_idx", "hitlv2_forms", ["form_kind", "status", "global_timeout_deadline", "id"]
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"hitlv2_recipients",
|
||||
sa.Column("id", StringUUID(), nullable=False),
|
||||
sa.Column("tenant_id", StringUUID(), nullable=False),
|
||||
sa.Column("form_id", StringUUID(), nullable=False),
|
||||
sa.Column("subject_type", sa.String(20), nullable=False),
|
||||
sa.Column("subject_value", sa.String(255), nullable=False),
|
||||
sa.Column("sources", LongText(), nullable=False),
|
||||
sa.Column("snapshot", LongText(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name="hitlv2_recipients_pkey"),
|
||||
sa.UniqueConstraint("form_id", "subject_type", "subject_value", name="hitlv2_recipients_subject_uq"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"hitlv2_deliveries",
|
||||
sa.Column("id", StringUUID(), primary_key=True),
|
||||
sa.Column("tenant_id", StringUUID(), nullable=False),
|
||||
sa.Column("form_id", StringUUID(), nullable=False),
|
||||
sa.Column("recipient_id", StringUUID(), nullable=False),
|
||||
sa.Column("token_hash", sa.String(64), nullable=False),
|
||||
sa.Column("auth_type", sa.Text(), nullable=False),
|
||||
sa.Column("target_type", sa.String(20), nullable=False),
|
||||
sa.Column("target_snapshot", LongText(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||
)
|
||||
op.create_index("hitlv2_deliveries_token_idx", "hitlv2_deliveries", ["token_hash"])
|
||||
op.create_index("hitlv2_deliveries_form_target_idx", "hitlv2_deliveries", ["form_id", "target_type"])
|
||||
op.create_table(
|
||||
"hitlv2_delivery_attempts",
|
||||
sa.Column("id", StringUUID(), primary_key=True),
|
||||
sa.Column("tenant_id", StringUUID(), nullable=False),
|
||||
sa.Column("form_id", StringUUID(), nullable=False),
|
||||
sa.Column("delivery_id", StringUUID(), nullable=False),
|
||||
sa.Column("status", sa.Text(), nullable=False),
|
||||
sa.Column("error_message", sa.String(200), nullable=True),
|
||||
sa.Column("response", LongText(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"hitlv2_audit_events",
|
||||
sa.Column("id", StringUUID(), primary_key=True),
|
||||
sa.Column("tenant_id", StringUUID(), nullable=False),
|
||||
sa.Column("form_id", StringUUID(), nullable=False),
|
||||
sa.Column("payload", LongText(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("hitlv2_audit_events")
|
||||
op.drop_table("hitlv2_delivery_attempts")
|
||||
op.drop_table("hitlv2_deliveries")
|
||||
op.drop_table("hitlv2_recipients")
|
||||
op.drop_table("hitlv2_forms")
|
||||
op.drop_table("human_input_im_reconciliation_changes")
|
||||
op.drop_table("im_message_event")
|
||||
op.drop_table("human_input_email_providers")
|
||||
op.drop_table("human_input_im_sync_results")
|
||||
op.drop_table("human_input_im_sync_runs")
|
||||
op.drop_table("human_input_im_workspace_binding_overrides")
|
||||
op.drop_table("human_input_im_bindings")
|
||||
op.drop_table("human_input_im_identities")
|
||||
op.drop_table("human_input_im_channels")
|
||||
op.drop_table("human_input_platform_contact_workspace_entries")
|
||||
op.drop_table("human_input_external_contact_profiles")
|
||||
op.drop_table("human_input_contact_identities")
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
"""Rename HITLv2 integration references to channel
|
||||
|
||||
Revision ID: ad1816d009b8
|
||||
Revises: e8b4a1c6d920
|
||||
Create Date: 2026-10-27 17:18:24.294086+08:00
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from models.types import StringUUID
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "ad1816d009b8"
|
||||
down_revision = "e8b4a1c6d920"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.alter_column(
|
||||
"im_message_event",
|
||||
"integration_id",
|
||||
new_column_name="channel_id",
|
||||
existing_type=StringUUID(),
|
||||
existing_nullable=False,
|
||||
comment="Logical local Channel routing identifier without physical ownership.",
|
||||
)
|
||||
|
||||
with op.batch_alter_table("human_input_im_sync_runs") as batch_op:
|
||||
batch_op.drop_index("hiimsr_integration_created_idx")
|
||||
batch_op.drop_index("hiimsr_integration_status_created_idx")
|
||||
batch_op.drop_constraint(op.f("human_input_im_sync_runs_captured_version_positive_check"), type_="check")
|
||||
batch_op.alter_column(
|
||||
"integration_id",
|
||||
new_column_name="channel_id",
|
||||
existing_type=StringUUID(),
|
||||
existing_nullable=False,
|
||||
comment="Logical foreign key to human_input_im_channels.id.",
|
||||
)
|
||||
batch_op.alter_column(
|
||||
"integration_config_version",
|
||||
new_column_name="channel_config_version",
|
||||
existing_type=sa.Integer(),
|
||||
existing_nullable=False,
|
||||
comment="Channel configuration revision captured when this synchronization was created.",
|
||||
)
|
||||
batch_op.create_check_constraint(
|
||||
op.f("human_input_im_sync_runs_captured_version_positive_check"), "channel_config_version > 0"
|
||||
)
|
||||
batch_op.create_index("hiimsr_channel_created_idx", ["channel_id", "created_at", "id"])
|
||||
batch_op.create_index("hiimsr_channel_status_created_idx", ["channel_id", "status", "created_at"])
|
||||
|
||||
with op.batch_alter_table("human_input_im_sync_results") as batch_op:
|
||||
batch_op.drop_index("hiimsres_integration_contact_created_idx")
|
||||
batch_op.drop_index("hiimsres_integration_identity_created_idx")
|
||||
batch_op.alter_column(
|
||||
"integration_id",
|
||||
new_column_name="channel_id",
|
||||
existing_type=StringUUID(),
|
||||
existing_nullable=False,
|
||||
comment=(
|
||||
"Denormalized logical foreign key to human_input_im_channels.id; "
|
||||
"must match the channel referenced by sync_run_id."
|
||||
),
|
||||
)
|
||||
batch_op.create_index("hiimsres_channel_contact_created_idx", ["channel_id", "contact_id", "created_at"])
|
||||
batch_op.create_index("hiimsres_channel_identity_created_idx", ["channel_id", "im_identity_id", "created_at"])
|
||||
|
||||
with op.batch_alter_table("human_input_im_reconciliation_changes") as batch_op:
|
||||
batch_op.drop_index("hiimrc_integration_committed_idx")
|
||||
batch_op.alter_column(
|
||||
"integration_id",
|
||||
new_column_name="channel_id",
|
||||
existing_type=StringUUID(),
|
||||
existing_nullable=False,
|
||||
comment="Logical foreign key to human_input_im_channels.id.",
|
||||
)
|
||||
batch_op.create_index("hiimrc_channel_committed_idx", ["channel_id", "committed_at", "id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("human_input_im_reconciliation_changes") as batch_op:
|
||||
batch_op.drop_index("hiimrc_channel_committed_idx")
|
||||
batch_op.alter_column(
|
||||
"channel_id",
|
||||
new_column_name="integration_id",
|
||||
existing_type=StringUUID(),
|
||||
existing_nullable=False,
|
||||
comment="Logical foreign key to human_input_im_channels.id.",
|
||||
)
|
||||
batch_op.create_index("hiimrc_integration_committed_idx", ["integration_id", "committed_at", "id"])
|
||||
|
||||
with op.batch_alter_table("human_input_im_sync_results") as batch_op:
|
||||
batch_op.drop_index("hiimsres_channel_contact_created_idx")
|
||||
batch_op.drop_index("hiimsres_channel_identity_created_idx")
|
||||
batch_op.alter_column(
|
||||
"channel_id",
|
||||
new_column_name="integration_id",
|
||||
existing_type=StringUUID(),
|
||||
existing_nullable=False,
|
||||
comment="Denormalized logical human_input_im_channels.id.",
|
||||
)
|
||||
batch_op.create_index(
|
||||
"hiimsres_integration_contact_created_idx", ["integration_id", "contact_id", "created_at"]
|
||||
)
|
||||
batch_op.create_index(
|
||||
"hiimsres_integration_identity_created_idx", ["integration_id", "im_identity_id", "created_at"]
|
||||
)
|
||||
|
||||
with op.batch_alter_table("human_input_im_sync_runs") as batch_op:
|
||||
batch_op.drop_index("hiimsr_channel_created_idx")
|
||||
batch_op.drop_index("hiimsr_channel_status_created_idx")
|
||||
batch_op.drop_constraint(op.f("human_input_im_sync_runs_captured_version_positive_check"), type_="check")
|
||||
batch_op.alter_column(
|
||||
"channel_config_version",
|
||||
new_column_name="integration_config_version",
|
||||
existing_type=sa.Integer(),
|
||||
existing_nullable=False,
|
||||
comment="Captured integration configuration revision.",
|
||||
)
|
||||
batch_op.alter_column(
|
||||
"channel_id",
|
||||
new_column_name="integration_id",
|
||||
existing_type=StringUUID(),
|
||||
existing_nullable=False,
|
||||
comment="Logical human_input_im_channels.id owner.",
|
||||
)
|
||||
batch_op.create_check_constraint(
|
||||
op.f("human_input_im_sync_runs_captured_version_positive_check"), "integration_config_version > 0"
|
||||
)
|
||||
batch_op.create_index("hiimsr_integration_created_idx", ["integration_id", "created_at", "id"])
|
||||
batch_op.create_index("hiimsr_integration_status_created_idx", ["integration_id", "status", "created_at"])
|
||||
|
||||
op.alter_column(
|
||||
"im_message_event",
|
||||
"channel_id",
|
||||
new_column_name="integration_id",
|
||||
existing_type=StringUUID(),
|
||||
existing_nullable=False,
|
||||
comment=None,
|
||||
)
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
"""Backfill Account Contact identities
|
||||
|
||||
Revision ID: 5f44dd69330f
|
||||
Revises: ad1816d009b8
|
||||
Create Date: 2026-10-27 23:55:43.514434
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "5f44dd69330f"
|
||||
down_revision = "ad1816d009b8"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
match op.get_context().dialect.name:
|
||||
case "postgresql":
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO human_input_contact_identities (id, subject_type, account_id)
|
||||
SELECT uuidv7(), 'account', id FROM accounts ORDER BY id
|
||||
ON CONFLICT (account_id) DO NOTHING
|
||||
"""
|
||||
)
|
||||
case "mysql" | "mariadb":
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO human_input_contact_identities (id, subject_type, account_id)
|
||||
SELECT UUID(), 'account', id FROM accounts ORDER BY id
|
||||
ON DUPLICATE KEY UPDATE account_id = human_input_contact_identities.account_id
|
||||
"""
|
||||
)
|
||||
case dialect_name:
|
||||
raise NotImplementedError(f"Unsupported database dialect for Account Contact backfill: {dialect_name}")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Stable Contact IDs may already be referenced by bindings and historical records.
|
||||
pass
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
"""Add HITLv2 ordered lookup indexes
|
||||
|
||||
Revision ID: 0cc3158b50b0
|
||||
Revises: 5f44dd69330f
|
||||
Create Date: 2026-10-28 10:39:22.447779+08:00
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0cc3158b50b0"
|
||||
down_revision = "5f44dd69330f"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_index("hitlv2_forms_run_id_idx", "hitlv2_forms", ["workflow_run_id", "id"])
|
||||
op.create_index("hitlv2_deliveries_form_id_idx", "hitlv2_deliveries", ["form_id", "id"])
|
||||
op.create_index("hiimi_channel_id_idx", "human_input_im_identities", ["channel_id", "id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("hiimi_channel_id_idx", table_name="human_input_im_identities")
|
||||
op.drop_index("hitlv2_deliveries_form_id_idx", table_name="hitlv2_deliveries")
|
||||
op.drop_index("hitlv2_forms_run_id_idx", table_name="hitlv2_forms")
|
||||
@@ -67,6 +67,26 @@ from .enums import (
|
||||
)
|
||||
from .execution_extra_content import ExecutionExtraContent, HumanInputContent
|
||||
from .human_input import HumanInputForm, HumanInputFormUploadFile, HumanInputFormUploadToken
|
||||
from .human_input_v2 import (
|
||||
ContactSubjectType,
|
||||
HumanInputContactIdentity,
|
||||
HumanInputEmailProvider,
|
||||
HumanInputExternalContactProfile,
|
||||
HumanInputIMBinding,
|
||||
HumanInputIMChannel,
|
||||
HumanInputIMIdentity,
|
||||
HumanInputIMReconciliationChange,
|
||||
HumanInputIMSyncResult,
|
||||
HumanInputIMSyncRun,
|
||||
HumanInputPlatformContactWorkspaceEntry,
|
||||
IMBindingReconciliationSnapshot,
|
||||
IMIdentityRawPayload,
|
||||
IMIdentityReconciliationSnapshot,
|
||||
IMMessageEvent,
|
||||
IMSyncContactSnapshot,
|
||||
IMSyncDirectoryEntryPayload,
|
||||
IMSyncIdentitySnapshot,
|
||||
)
|
||||
from .knowledge_fs import (
|
||||
AppKnowledgeFSSpaceJoin,
|
||||
KnowledgeFSApiCredential,
|
||||
@@ -259,6 +279,7 @@ __all__ = [
|
||||
"BuiltinToolProvider",
|
||||
"CeleryTask",
|
||||
"CeleryTaskSet",
|
||||
"ContactSubjectType",
|
||||
"Conversation",
|
||||
"ConversationVariable",
|
||||
"CreatorUserRole",
|
||||
@@ -287,10 +308,27 @@ __all__ = [
|
||||
"ExporleBanner",
|
||||
"ExternalKnowledgeApis",
|
||||
"ExternalKnowledgeBindings",
|
||||
"HumanInputContactIdentity",
|
||||
"HumanInputContent",
|
||||
"HumanInputEmailProvider",
|
||||
"HumanInputExternalContactProfile",
|
||||
"HumanInputForm",
|
||||
"HumanInputFormUploadFile",
|
||||
"HumanInputFormUploadToken",
|
||||
"HumanInputIMBinding",
|
||||
"HumanInputIMChannel",
|
||||
"HumanInputIMIdentity",
|
||||
"HumanInputIMReconciliationChange",
|
||||
"HumanInputIMSyncResult",
|
||||
"HumanInputIMSyncRun",
|
||||
"HumanInputPlatformContactWorkspaceEntry",
|
||||
"IMBindingReconciliationSnapshot",
|
||||
"IMIdentityRawPayload",
|
||||
"IMIdentityReconciliationSnapshot",
|
||||
"IMMessageEvent",
|
||||
"IMSyncContactSnapshot",
|
||||
"IMSyncDirectoryEntryPayload",
|
||||
"IMSyncIdentitySnapshot",
|
||||
"IconType",
|
||||
"InstalledApp",
|
||||
"InvitationCode",
|
||||
|
||||
Loaded 100 of 868 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user