Files
dify/api/controllers/common/human_input_v2_contracts.py
T
8cbb6c0130 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>
2026-10-30 02:43:31 +00:00

706 lines
26 KiB
Python

"""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",
]