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:
authored and GitHub committed 2026-10-30 02:43:31 +00:00
1 parent ad6ed864a1
commit 8cbb6c0130
868 files changed
+145761 -6399

No files matched your search

-3
View File
@@ -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/
+3 -1
View File
@@ -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
+3
View File
@@ -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
+2
View File
@@ -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
+14
View File
@@ -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="",
+2
View File
@@ -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
+2 -1
View File
@@ -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.
+16
View File
@@ -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)
+12
View File
@@ -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")
+120
View File
@@ -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
+29 -11
View File
@@ -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)
+4
View File
@@ -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)
+3
View File
@@ -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,
+2 -1
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
"""Channel-neutral Human Input values shared across runtime versions."""
from .enums import ButtonStyle
__all__ = ["ButtonStyle"]
+12
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
"""Channel-neutral Human Input v2 domain values."""
+140
View File
@@ -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."""
+100
View File
@@ -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",
]
+62
View File
@@ -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,
-1
View File
@@ -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"])
+86 -4
View File
@@ -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",
+14 -6
View File
@@ -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:
+38 -17
View File
@@ -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])
+12 -9
View File
@@ -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 = {
+2
View File
@@ -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,
+2 -2
View File
@@ -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}"
+3 -1
View File
@@ -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(
+9
View File
@@ -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
+3
View File
@@ -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
+23
View File
@@ -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)
+102
View File
@@ -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)
+12
View File
@@ -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.")
+24
View File
@@ -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
View File
@@ -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")
@@ -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,
)
@@ -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
@@ -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")
+38
View File
@@ -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