patchr 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. apps/__init__.py +2 -0
  2. apps/api/__init__.py +2 -0
  3. apps/api/main.py +652 -0
  4. apps/benchmarks/__init__.py +1 -0
  5. apps/benchmarks/main.py +20 -0
  6. apps/sandbox/__init__.py +1 -0
  7. apps/sandbox/main.py +20 -0
  8. apps/worker/__init__.py +2 -0
  9. apps/worker/main.py +15 -0
  10. apps/worker/verify.py +14 -0
  11. patchr/__init__.py +12 -0
  12. patchr/sdk/__init__.py +20 -0
  13. patchr/sdk/client.py +12 -0
  14. patchr-0.1.0.dist-info/METADATA +137 -0
  15. patchr-0.1.0.dist-info/RECORD +116 -0
  16. patchr-0.1.0.dist-info/WHEEL +5 -0
  17. patchr-0.1.0.dist-info/entry_points.txt +5 -0
  18. patchr-0.1.0.dist-info/licenses/LICENSE +17 -0
  19. patchr-0.1.0.dist-info/top_level.txt +3 -0
  20. picux/__init__.py +6 -0
  21. picux/agents/__init__.py +5 -0
  22. picux/agents/registry.py +204 -0
  23. picux/api/__init__.py +5 -0
  24. picux/api/service.py +5075 -0
  25. picux/audit/__init__.py +31 -0
  26. picux/audit/activity.py +97 -0
  27. picux/audit/observability.py +55 -0
  28. picux/audit/verification/__init__.py +21 -0
  29. picux/audit/verification/ledger.py +633 -0
  30. picux/benchmarks/__init__.py +5 -0
  31. picux/benchmarks/local.py +286 -0
  32. picux/config.py +140 -0
  33. picux/contracts/__init__.py +22 -0
  34. picux/contracts/handshake.py +122 -0
  35. picux/contracts/integration.py +385 -0
  36. picux/contracts/openapi.py +187 -0
  37. picux/contracts/protocol_map.py +152 -0
  38. picux/contracts/routes.py +980 -0
  39. picux/contracts/schema_catalog.py +125 -0
  40. picux/core/__init__.py +17 -0
  41. picux/core/models.py +148 -0
  42. picux/core/router.py +131 -0
  43. picux/core/runtime.py +42 -0
  44. picux/core/state_machine.py +38 -0
  45. picux/domains/__init__.py +2 -0
  46. picux/domains/bridge/HostRun.py +1104 -0
  47. picux/domains/bridge/__init__.py +6 -0
  48. picux/domains/bridge/engine.py +345 -0
  49. picux/domains/hunt/__init__.py +6 -0
  50. picux/domains/hunt/engine.py +307 -0
  51. picux/domains/hunt/models.py +88 -0
  52. picux/domains/pay/__init__.py +16 -0
  53. picux/domains/pay/adapters.py +607 -0
  54. picux/domains/pay/engine.py +950 -0
  55. picux/domains/pay/models.py +95 -0
  56. picux/domains/proxy/__init__.py +5 -0
  57. picux/domains/proxy/engine.py +466 -0
  58. picux/domains/resolve/__init__.py +5 -0
  59. picux/domains/resolve/engine.py +546 -0
  60. picux/orchestrator/__init__.py +3 -0
  61. picux/orchestrator/engine.py +2840 -0
  62. picux/portals/__init__.py +17 -0
  63. picux/portals/templates.py +272 -0
  64. picux/protocols/__init__.py +1 -0
  65. picux/protocols/a2a/__init__.py +6 -0
  66. picux/protocols/a2a/client.py +51 -0
  67. picux/protocols/a2a/envelope.py +132 -0
  68. picux/protocols/mcp/__init__.py +7 -0
  69. picux/protocols/mcp/client.py +69 -0
  70. picux/protocols/mcp/contract.py +67 -0
  71. picux/protocols/mcp/server.py +76 -0
  72. picux/sandbox/__init__.py +6 -0
  73. picux/sandbox/midnight_arbitrage.py +215 -0
  74. picux/sandbox/models.py +90 -0
  75. picux/sdk/__init__.py +13 -0
  76. picux/sdk/client.py +768 -0
  77. picux/sdk/external.py +245 -0
  78. picux/security/__init__.py +18 -0
  79. picux/security/auth.py +86 -0
  80. picux/security/config_validator.py +58 -0
  81. picux/security/policy.py +158 -0
  82. picux/security/secrets.py +144 -0
  83. picux/signals/__init__.py +1 -0
  84. picux/signals/community/__init__.py +24 -0
  85. picux/signals/community/adapters/__init__.py +7 -0
  86. picux/signals/community/adapters/reddit.py +37 -0
  87. picux/signals/community/adapters/shopify.py +23 -0
  88. picux/signals/community/adapters/web.py +23 -0
  89. picux/signals/community/disambiguation.py +51 -0
  90. picux/signals/community/intake.py +227 -0
  91. picux/signals/community/models.py +102 -0
  92. picux/signals/community/rules.py +91 -0
  93. picux/signals/community/scoring.py +64 -0
  94. picux/storage/__init__.py +41 -0
  95. picux/storage/agents.py +50 -0
  96. picux/storage/cases.py +440 -0
  97. picux/storage/channels.py +476 -0
  98. picux/storage/connectors.py +411 -0
  99. picux/storage/envelopes.py +137 -0
  100. picux/storage/escrows.py +168 -0
  101. picux/storage/events.py +989 -0
  102. picux/storage/keyspace.py +60 -0
  103. picux/storage/mandates.py +107 -0
  104. picux/storage/portals.py +222 -0
  105. picux/storage/postgres.py +2049 -0
  106. picux/storage/providers.py +148 -0
  107. picux/storage/proxy.py +231 -0
  108. picux/storage/receipts.py +131 -0
  109. picux/storage/signals.py +147 -0
  110. picux/storage/tasks.py +179 -0
  111. picux/tools/__init__.py +11 -0
  112. picux/tools/shared.py +2048 -0
  113. picux/verification/__init__.py +5 -0
  114. picux/verification/rollout.py +183 -0
  115. picux/workflows/__init__.py +5 -0
  116. picux/workflows/templates.py +74 -0
@@ -0,0 +1,1104 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import smtplib
7
+ import ssl
8
+ from datetime import datetime, timezone
9
+ from email.message import EmailMessage
10
+ from html import escape
11
+ from typing import Any
12
+ from urllib import error, parse, request
13
+
14
+ from picux.security import SecretResolver
15
+
16
+
17
+ class HostedBridgeAdapters:
18
+ """Execute Picux-hosted Bridge adapters with client-registered credentials."""
19
+
20
+ def __init__(self, *, secrets: SecretResolver | None = None, timeoutSeconds: int = 15) -> None:
21
+ self.secrets = secrets or SecretResolver()
22
+ self.timeoutSeconds = timeoutSeconds
23
+
24
+ def run(
25
+ self,
26
+ action: dict[str, Any],
27
+ connector: dict[str, Any],
28
+ session: dict[str, Any],
29
+ payload: dict[str, Any],
30
+ ) -> dict[str, Any]:
31
+ kind = self._kind(action, connector, session, payload)
32
+ if kind == "gmail":
33
+ return self._gmail(action, connector, session, payload)
34
+ if kind == "googleDocs":
35
+ return self._googleDocs(action, connector, session, payload)
36
+ if kind == "notion":
37
+ return self._notion(action, connector, session, payload)
38
+ if kind == "jira":
39
+ return self._jira(action, connector, session, payload)
40
+ if kind == "salesforce":
41
+ return self._salesforce(action, connector, session, payload)
42
+ if kind == "hubspot":
43
+ return self._hubspot(action, connector, session, payload)
44
+ if kind == "zendesk":
45
+ return self._zendesk(action, connector, session, payload)
46
+ if kind == "sap":
47
+ return self._sap(action, connector, session, payload)
48
+ if kind == "twilioVoice":
49
+ return self._twilioVoice(action, connector, session, payload)
50
+ if kind == "whatsapp":
51
+ return self._whatsapp(action, connector, session, payload)
52
+ if kind == "slack":
53
+ return self._slack(action, connector, session, payload)
54
+ if kind == "smtp":
55
+ return self._smtp(action, connector, session, payload)
56
+ if kind == "httpJson":
57
+ return self._httpJson(action, connector, session, payload)
58
+ return {}
59
+
60
+ def _kind(self, action: dict[str, Any], connector: dict[str, Any], session: dict[str, Any], payload: dict[str, Any]) -> str:
61
+ meta = connector.get("meta", {}) if isinstance(connector.get("meta"), dict) else {}
62
+ endpoint = str(connector.get("endpoint", "") or "")
63
+ caps = " ".join(str(item or "") for item in connector.get("caps", []) if str(item))
64
+ parts = [
65
+ action.get("connectorId", ""),
66
+ action.get("action", ""),
67
+ action.get("resource", ""),
68
+ action.get("channel", ""),
69
+ action.get("provider", ""),
70
+ connector.get("connectorId", ""),
71
+ connector.get("kind", ""),
72
+ connector.get("name", ""),
73
+ endpoint,
74
+ caps,
75
+ meta.get("provider", ""),
76
+ payload.get("adapter", ""),
77
+ payload.get("tool", ""),
78
+ ]
79
+ needle = " ".join(str(item or "") for item in parts).lower()
80
+ if "notion" in needle and ("docs.write" in needle or "page" in needle or "casefile" in needle or "knowledge" in needle):
81
+ return "notion"
82
+ if "jira" in needle or "atlassian" in needle:
83
+ return "jira"
84
+ if "salesforce" in needle or "crm.cases" in needle or "case.write" in needle:
85
+ return "salesforce"
86
+ if "hubspot" in needle:
87
+ return "hubspot"
88
+ if "zendesk" in needle:
89
+ return "zendesk"
90
+ if "bridge://sap" in needle or " sap " in f" {needle} " or "sap." in needle or "erp" in needle:
91
+ return "sap"
92
+ if "gmail" in needle and ("email.send" in needle or "messages.send" in needle or action.get("channel") == "email"):
93
+ return "gmail"
94
+ if "googledocs" in needle or "google-docs" in needle or (
95
+ ("google" in needle or "gdocs" in needle) and ("docs.write" in needle or "documents.write" in needle)
96
+ ):
97
+ return "googleDocs"
98
+ if "twilio" in needle or "voice.call" in needle or "call.create" in needle:
99
+ return "twilioVoice"
100
+ if "whatsapp" in needle:
101
+ return "whatsapp"
102
+ if "slack" in needle:
103
+ return "slack"
104
+ if ("email.send" in needle or action.get("channel") == "email") and self._env(("PICUX_SMTP_HOST", "SMTP_HOST"))[0]:
105
+ return "smtp"
106
+ if endpoint.startswith(("http://", "https://")) and self._shouldRunHttp(connector, session, payload):
107
+ return "httpJson"
108
+ return ""
109
+
110
+ def _shouldRunHttp(self, connector: dict[str, Any], session: dict[str, Any], payload: dict[str, Any]) -> bool:
111
+ return self._hostedRequired(connector, session, payload)
112
+
113
+ @staticmethod
114
+ def _hostedRequired(connector: dict[str, Any], session: dict[str, Any], payload: dict[str, Any]) -> bool:
115
+ meta = connector.get("meta", {}) if isinstance(connector.get("meta"), dict) else {}
116
+ mode = str(session.get("executionMode", "") or meta.get("executionMode", "") or "")
117
+ return bool(
118
+ mode in {"picuxHostedAdapter", "hostedAdapter", "picuxHosted", "hosted"}
119
+ or meta.get("hostedAdapter")
120
+ or meta.get("picuxHostedAdapter")
121
+ or payload.get("executeHosted")
122
+ or payload.get("runHosted")
123
+ or payload.get("hostedAdapter")
124
+ )
125
+
126
+ def _httpJson(self, action: dict[str, Any], connector: dict[str, Any], session: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
127
+ endpoint = str(connector.get("endpoint", "") or "")
128
+ if not endpoint.startswith(("http://", "https://")):
129
+ return {}
130
+ body = {
131
+ "action": self._actionRef(action),
132
+ "connector": self._connectorRef(connector),
133
+ "session": self._sessionRef(session),
134
+ "payload": payload,
135
+ "createdAt": self._now(),
136
+ }
137
+ headers = {"X-Picux-Bridge-Action": str(action.get("actionId", "") or "")}
138
+ token = self._credentialToken(connector, session)
139
+ if token:
140
+ headers["Authorization"] = f"Bearer {token}"
141
+ status, responseBody, errText = self._postJson(endpoint, body, headers=headers)
142
+ providerRef = str(responseBody.get("id", responseBody.get("messageId", responseBody.get("providerRef", ""))) or "")
143
+ ok = 200 <= status < 300
144
+ result = {
145
+ "ok": ok,
146
+ "status": "sent" if ok else "ioError",
147
+ "providerStatus": status,
148
+ "providerRef": providerRef or self._stableId("bridgeHttp", {"actionId": action.get("actionId", ""), "status": status, "body": responseBody}),
149
+ "response": responseBody,
150
+ "reason": "" if ok else errText or f"httpStatus:{status}",
151
+ }
152
+ return self._adapterResult("httpJson", action, connector, result, endpoint=endpoint)
153
+
154
+ def _gmail(self, action: dict[str, Any], connector: dict[str, Any], session: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
155
+ token, tokenKey = self._bearerToken(connector, session, ("GMAIL_ACCESS_TOKEN", "GOOGLE_GMAIL_ACCESS_TOKEN", "GOOGLE_ACCESS_TOKEN"))
156
+ toEmail = self._email(payload)
157
+ if not token:
158
+ if not self._hostedRequired(connector, session, payload):
159
+ return {}
160
+ return self._blocked("gmail", action, connector, "missingBridgeCredentials", missingEnv=["GMAIL_ACCESS_TOKEN"])
161
+ if not toEmail:
162
+ return self._blocked("gmail", action, connector, "missingDestinationEmail", missingEnv=[])
163
+ userId = str(payload.get("userId", payload.get("gmailUserId", "me")) or "me")
164
+ baseUrl = self._providerUrl(connector, session, payload, "gmailBaseUrl", "https://gmail.googleapis.com").rstrip("/")
165
+ endpoint = f"{baseUrl}/gmail/v1/users/{parse.quote(userId)}/messages/send"
166
+ raw = self._gmailRaw(payload, toEmail)
167
+ status, responseBody, errText = self._postJson(endpoint, {"raw": raw}, headers={"Authorization": f"Bearer {token}"})
168
+ ok = 200 <= status < 300
169
+ result = {
170
+ "ok": ok,
171
+ "status": "sent" if ok else "ioError",
172
+ "providerStatus": status,
173
+ "providerRef": str(responseBody.get("id", responseBody.get("messageId", "")) or self._stableId("gmailMsg", {"actionId": action.get("actionId", ""), "to": toEmail})),
174
+ "channel": "email",
175
+ "toRef": self._redactEmail(toEmail),
176
+ "credentialRefs": [tokenKey],
177
+ "response": responseBody,
178
+ "reason": "" if ok else errText or f"httpStatus:{status}",
179
+ }
180
+ return self._adapterResult("gmail", action, connector, result, endpoint=endpoint)
181
+
182
+ def _googleDocs(self, action: dict[str, Any], connector: dict[str, Any], session: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
183
+ token, tokenKey = self._bearerToken(connector, session, ("GOOGLE_DOCS_ACCESS_TOKEN", "GOOGLE_ACCESS_TOKEN"))
184
+ if not token:
185
+ if not self._hostedRequired(connector, session, payload):
186
+ return {}
187
+ return self._blocked("googleDocs", action, connector, "missingBridgeCredentials", missingEnv=["GOOGLE_DOCS_ACCESS_TOKEN"])
188
+ baseUrl = self._providerUrl(connector, session, payload, "googleDocsBaseUrl", "https://docs.googleapis.com").rstrip("/")
189
+ title = str(payload.get("title", payload.get("subject", "Picux case document")) or "Picux case document")
190
+ text = self._message(payload, fallback=str(payload.get("content", "") or ""))
191
+ createEndpoint = f"{baseUrl}/v1/documents"
192
+ status, responseBody, errText = self._postJson(createEndpoint, {"title": title}, headers={"Authorization": f"Bearer {token}"})
193
+ docId = str(responseBody.get("documentId", responseBody.get("id", "")) or "")
194
+ batchStatus = 0
195
+ batchResponse: dict[str, Any] = {}
196
+ batchError = ""
197
+ if 200 <= status < 300 and docId and text:
198
+ batchEndpoint = f"{baseUrl}/v1/documents/{parse.quote(docId)}:batchUpdate"
199
+ batchStatus, batchResponse, batchError = self._postJson(
200
+ batchEndpoint,
201
+ {"requests": [{"insertText": {"location": {"index": 1}, "text": text}}]},
202
+ headers={"Authorization": f"Bearer {token}"},
203
+ )
204
+ ok = 200 <= status < 300 and (not text or 200 <= batchStatus < 300)
205
+ reason = "" if ok else batchError or errText or f"httpStatus:{batchStatus or status}"
206
+ result = {
207
+ "ok": ok,
208
+ "status": "created" if ok else "ioError",
209
+ "providerStatus": batchStatus or status,
210
+ "providerRef": docId or self._stableId("googleDoc", {"actionId": action.get("actionId", ""), "title": title}),
211
+ "channel": "document",
212
+ "documentId": docId,
213
+ "credentialRefs": [tokenKey],
214
+ "response": {"create": responseBody, "batchUpdate": batchResponse},
215
+ "reason": reason,
216
+ }
217
+ source = f"https://docs.google.com/document/d/{docId}" if docId else createEndpoint
218
+ return self._adapterResult("googleDocs", action, connector, result, endpoint=source)
219
+
220
+ def _notion(self, action: dict[str, Any], connector: dict[str, Any], session: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
221
+ token, tokenKey = self._bearerToken(connector, session, ("NOTION_TOKEN", "PICUX_NOTION_TOKEN"))
222
+ databaseId, databaseKey = self._fieldValue(
223
+ connector,
224
+ session,
225
+ payload,
226
+ ("databaseId", "notionDatabaseId", "NOTION_DATABASE_ID", "PICUX_NOTION_DATABASE_ID"),
227
+ ("NOTION_DATABASE_ID", "PICUX_NOTION_DATABASE_ID"),
228
+ )
229
+ if not token:
230
+ if not self._hostedRequired(connector, session, payload):
231
+ return {}
232
+ return self._blocked("notion", action, connector, "missingBridgeCredentials", missingEnv=["NOTION_TOKEN"])
233
+ if not databaseId:
234
+ if not self._hostedRequired(connector, session, payload):
235
+ return {}
236
+ return self._blocked("notion", action, connector, "missingNotionDatabaseId", missingEnv=["NOTION_DATABASE_ID"])
237
+ baseUrl = self._providerUrl(connector, session, payload, "notionBaseUrl", "https://api.notion.com").rstrip("/")
238
+ endpoint = f"{baseUrl}/v1/pages"
239
+ title = str(payload.get("title", payload.get("subject", "Picux case file")) or "Picux case file")
240
+ body = self._message(payload, fallback=str(payload.get("content", "") or ""))
241
+ statusName = str(payload.get("statusName", payload.get("status", "Open")) or "Open")
242
+ requestBody = {
243
+ "parent": {"database_id": databaseId},
244
+ "properties": {
245
+ "Name": {"title": [{"text": {"content": title[:2000]}}]},
246
+ "Status": {"select": {"name": statusName[:100]}},
247
+ },
248
+ "children": [{"object": "block", "type": "paragraph", "paragraph": {"rich_text": [{"type": "text", "text": {"content": body[:2000]}}]}}],
249
+ }
250
+ status, responseBody, errText = self._postJson(
251
+ endpoint,
252
+ requestBody,
253
+ headers={"Authorization": f"Bearer {token}", "Notion-Version": str(payload.get("notionVersion", "2022-06-28") or "2022-06-28")},
254
+ )
255
+ ok = 200 <= status < 300
256
+ pageId = str(responseBody.get("id", "") or "")
257
+ result = {
258
+ "ok": ok,
259
+ "status": "created" if ok else "ioError",
260
+ "providerStatus": status,
261
+ "providerRef": pageId or self._stableId("notionPage", {"actionId": action.get("actionId", ""), "title": title}),
262
+ "channel": "workspace",
263
+ "credentialRefs": [tokenKey, databaseKey],
264
+ "response": responseBody,
265
+ "reason": "" if ok else errText or f"httpStatus:{status}",
266
+ }
267
+ source = str(responseBody.get("url", endpoint) or endpoint)
268
+ return self._adapterResult("notion", action, connector, result, endpoint=source)
269
+
270
+ def _jira(self, action: dict[str, Any], connector: dict[str, Any], session: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
271
+ baseUrl = self._providerUrl(connector, session, payload, "jiraBaseUrl", "").rstrip("/") or self._env(("JIRA_BASE_URL", "ATLASSIAN_BASE_URL"))[0].rstrip("/")
272
+ authHeader, authRef = self._jiraAuth(connector, session)
273
+ projectKey, projectKeyRef = self._fieldValue(
274
+ connector,
275
+ session,
276
+ payload,
277
+ ("projectKey", "jiraProjectKey", "JIRA_PROJECT_KEY", "PICUX_JIRA_PROJECT_KEY"),
278
+ ("JIRA_PROJECT_KEY", "PICUX_JIRA_PROJECT_KEY"),
279
+ )
280
+ if not baseUrl:
281
+ if not self._hostedRequired(connector, session, payload):
282
+ return {}
283
+ return self._blocked("jira", action, connector, "missingJiraBaseUrl", missingEnv=["JIRA_BASE_URL"])
284
+ if not authHeader:
285
+ if not self._hostedRequired(connector, session, payload):
286
+ return {}
287
+ return self._blocked("jira", action, connector, "missingBridgeCredentials", missingEnv=["JIRA_ACCESS_TOKEN"])
288
+ if not projectKey:
289
+ return self._blocked("jira", action, connector, "missingJiraProjectKey", missingEnv=["JIRA_PROJECT_KEY"])
290
+ endpoint = f"{baseUrl}/rest/api/3/issue"
291
+ summary = str(payload.get("summary", payload.get("title", payload.get("subject", "Picux case"))) or "Picux case")
292
+ description = self._message(payload, fallback=str(payload.get("description", "") or ""))
293
+ issueType = str(payload.get("issueType", payload.get("jiraIssueType", "Task")) or "Task")
294
+ body = {
295
+ "fields": {
296
+ "project": {"key": projectKey},
297
+ "summary": summary[:255],
298
+ "issuetype": {"name": issueType},
299
+ "description": self._jiraDoc(description),
300
+ }
301
+ }
302
+ status, responseBody, errText = self._postJson(endpoint, body, headers={"Authorization": authHeader})
303
+ ok = 200 <= status < 300
304
+ key = str(responseBody.get("key", responseBody.get("id", "")) or "")
305
+ result = {
306
+ "ok": ok,
307
+ "status": "created" if ok else "ioError",
308
+ "providerStatus": status,
309
+ "providerRef": key or self._stableId("jiraIssue", {"actionId": action.get("actionId", ""), "summary": summary}),
310
+ "channel": "ticketing",
311
+ "credentialRefs": [authRef, projectKeyRef],
312
+ "response": responseBody,
313
+ "reason": "" if ok else errText or f"httpStatus:{status}",
314
+ }
315
+ source = f"{baseUrl}/browse/{key}" if key else endpoint
316
+ return self._adapterResult("jira", action, connector, result, endpoint=source)
317
+
318
+ def _salesforce(self, action: dict[str, Any], connector: dict[str, Any], session: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
319
+ token, tokenKey = self._bearerToken(connector, session, ("SALESFORCE_ACCESS_TOKEN", "PICUX_SALESFORCE_ACCESS_TOKEN"))
320
+ instanceUrl = self._providerUrl(connector, session, payload, "salesforceInstanceUrl", "").rstrip("/") or self._env(("SALESFORCE_INSTANCE_URL", "PICUX_SALESFORCE_INSTANCE_URL"))[0].rstrip("/")
321
+ if not token:
322
+ if not self._hostedRequired(connector, session, payload):
323
+ return {}
324
+ return self._blocked("salesforce", action, connector, "missingBridgeCredentials", missingEnv=["SALESFORCE_ACCESS_TOKEN"])
325
+ if not instanceUrl:
326
+ return self._blocked("salesforce", action, connector, "missingSalesforceInstanceUrl", missingEnv=["SALESFORCE_INSTANCE_URL"])
327
+ version = str(payload.get("salesforceApiVersion", "60.0") or "60.0")
328
+ endpoint = f"{instanceUrl}/services/data/v{version}/sobjects/Case"
329
+ subject = str(payload.get("subject", payload.get("title", "Picux case")) or "Picux case")
330
+ description = self._message(payload, fallback=str(payload.get("description", "") or ""))
331
+ body = {
332
+ "Subject": subject[:255],
333
+ "Description": description[:32000],
334
+ "Origin": str(payload.get("origin", "Picux") or "Picux"),
335
+ "Status": str(payload.get("caseStatus", payload.get("status", "New")) or "New"),
336
+ }
337
+ email = self._email(payload)
338
+ if email:
339
+ body["SuppliedEmail"] = email
340
+ status, responseBody, errText = self._postJson(endpoint, body, headers={"Authorization": f"Bearer {token}"})
341
+ ok = 200 <= status < 300 and responseBody.get("success", True) is not False
342
+ caseId = str(responseBody.get("id", responseBody.get("caseId", "")) or "")
343
+ result = {
344
+ "ok": ok,
345
+ "status": "created" if ok else "ioError",
346
+ "providerStatus": status,
347
+ "providerRef": caseId or self._stableId("salesforceCase", {"actionId": action.get("actionId", ""), "subject": subject}),
348
+ "channel": "crm",
349
+ "credentialRefs": [tokenKey],
350
+ "response": responseBody,
351
+ "reason": "" if ok else errText or str(responseBody.get("errors", f"httpStatus:{status}") or ""),
352
+ }
353
+ return self._adapterResult("salesforce", action, connector, result, endpoint=f"{instanceUrl}/lightning/r/Case/{caseId}/view" if caseId else endpoint)
354
+
355
+ def _hubspot(self, action: dict[str, Any], connector: dict[str, Any], session: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
356
+ token, tokenKey = self._bearerToken(connector, session, ("HUBSPOT_ACCESS_TOKEN", "PICUX_HUBSPOT_ACCESS_TOKEN"))
357
+ baseUrl = self._providerUrl(connector, session, payload, "hubspotBaseUrl", "").rstrip("/") or self._env(("HUBSPOT_BASE_URL", "PICUX_HUBSPOT_BASE_URL"))[0].rstrip("/") or "https://api.hubapi.com"
358
+ if not token:
359
+ if not self._hostedRequired(connector, session, payload):
360
+ return {}
361
+ return self._blocked("hubspot", action, connector, "missingBridgeCredentials", missingEnv=["HUBSPOT_ACCESS_TOKEN"])
362
+ endpoint = f"{baseUrl}/crm/v3/objects/tickets"
363
+ subject = str(payload.get("subject", payload.get("title", "Picux case")) or "Picux case")
364
+ body = self._message(payload, fallback=str(payload.get("description", "") or "Picux case created by Bridge."))
365
+ requestBody = {
366
+ "properties": {
367
+ "subject": subject[:255],
368
+ "content": body[:64000],
369
+ "source_type": str(payload.get("sourceType", "PICUX") or "PICUX"),
370
+ }
371
+ }
372
+ email = self._email(payload)
373
+ if email:
374
+ requestBody["properties"]["source_ref"] = email
375
+ status, responseBody, errText = self._postJson(endpoint, requestBody, headers={"Authorization": f"Bearer {token}"})
376
+ ok = 200 <= status < 300
377
+ ticketId = str(responseBody.get("id", responseBody.get("ticketId", "")) or "")
378
+ result = {
379
+ "ok": ok,
380
+ "status": "created" if ok else "ioError",
381
+ "providerStatus": status,
382
+ "providerRef": ticketId or self._stableId("hubspotTicket", {"actionId": action.get("actionId", ""), "subject": subject}),
383
+ "channel": "crm",
384
+ "credentialRefs": [tokenKey],
385
+ "response": responseBody,
386
+ "reason": "" if ok else errText or f"httpStatus:{status}",
387
+ }
388
+ return self._adapterResult("hubspot", action, connector, result, endpoint=f"{baseUrl}/contacts/{ticketId}" if ticketId else endpoint)
389
+
390
+ def _zendesk(self, action: dict[str, Any], connector: dict[str, Any], session: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
391
+ token, tokenKey = self._bearerToken(connector, session, ("ZENDESK_ACCESS_TOKEN", "PICUX_ZENDESK_ACCESS_TOKEN", "ZENDESK_API_TOKEN"))
392
+ baseUrl = self._providerUrl(connector, session, payload, "zendeskBaseUrl", "").rstrip("/") or self._env(("ZENDESK_BASE_URL", "PICUX_ZENDESK_BASE_URL"))[0].rstrip("/")
393
+ if not token:
394
+ if not self._hostedRequired(connector, session, payload):
395
+ return {}
396
+ return self._blocked("zendesk", action, connector, "missingBridgeCredentials", missingEnv=["ZENDESK_ACCESS_TOKEN"])
397
+ if not baseUrl:
398
+ if not self._hostedRequired(connector, session, payload):
399
+ return {}
400
+ return self._blocked("zendesk", action, connector, "missingZendeskBaseUrl", missingEnv=["ZENDESK_BASE_URL"])
401
+ endpoint = f"{baseUrl}/api/v2/tickets.json"
402
+ subject = str(payload.get("subject", payload.get("title", "Picux case")) or "Picux case")
403
+ body = self._message(payload, fallback=str(payload.get("description", "") or "Picux case created by Bridge."))
404
+ requestBody: dict[str, Any] = {
405
+ "ticket": {
406
+ "subject": subject[:255],
407
+ "comment": {"body": body[:64000]},
408
+ "tags": ["picux"],
409
+ }
410
+ }
411
+ email = self._email(payload)
412
+ if email:
413
+ requestBody["ticket"]["requester"] = {"email": email}
414
+ headers = {"Authorization": self._zendeskAuth(token, tokenKey, connector, session)}
415
+ status, responseBody, errText = self._postJson(endpoint, requestBody, headers=headers)
416
+ ticket = responseBody.get("ticket", {}) if isinstance(responseBody.get("ticket"), dict) else responseBody
417
+ ok = 200 <= status < 300
418
+ ticketId = str(ticket.get("id", ticket.get("ticketId", "")) or "")
419
+ result = {
420
+ "ok": ok,
421
+ "status": "created" if ok else "ioError",
422
+ "providerStatus": status,
423
+ "providerRef": ticketId or self._stableId("zendeskTicket", {"actionId": action.get("actionId", ""), "subject": subject}),
424
+ "channel": "support",
425
+ "credentialRefs": [tokenKey],
426
+ "response": responseBody,
427
+ "reason": "" if ok else errText or f"httpStatus:{status}",
428
+ }
429
+ return self._adapterResult("zendesk", action, connector, result, endpoint=f"{baseUrl}/agent/tickets/{ticketId}" if ticketId else endpoint)
430
+
431
+ def _sap(self, action: dict[str, Any], connector: dict[str, Any], session: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
432
+ token, tokenKey = self._bearerToken(connector, session, ("SAP_ACCESS_TOKEN", "PICUX_SAP_ACCESS_TOKEN"))
433
+ baseUrl = self._providerUrl(connector, session, payload, "sapBaseUrl", "").rstrip("/") or self._env(("SAP_BASE_URL", "PICUX_SAP_BASE_URL"))[0].rstrip("/")
434
+ if not token:
435
+ if not self._hostedRequired(connector, session, payload):
436
+ return {}
437
+ return self._blocked("sap", action, connector, "missingBridgeCredentials", missingEnv=["SAP_ACCESS_TOKEN"])
438
+ if not baseUrl:
439
+ if not self._hostedRequired(connector, session, payload):
440
+ return {}
441
+ return self._blocked("sap", action, connector, "missingSapBaseUrl", missingEnv=["SAP_BASE_URL"])
442
+ path = str(payload.get("sapPath", payload.get("path", "/picux/bridge/actions")) or "/picux/bridge/actions")
443
+ if not path.startswith("/"):
444
+ path = f"/{path}"
445
+ endpoint = f"{baseUrl}{path}"
446
+ requestBody = {
447
+ "action": self._actionRef(action),
448
+ "connector": self._connectorRef(connector),
449
+ "case": {
450
+ "caseId": str(payload.get("caseId", action.get("caseId", "")) or ""),
451
+ "conversationId": str(payload.get("conversationId", action.get("conversationId", "")) or ""),
452
+ "subject": str(payload.get("subject", payload.get("title", "")) or ""),
453
+ "message": self._message(payload, fallback=str(payload.get("description", "") or "")),
454
+ },
455
+ "payload": payload,
456
+ "createdAt": self._now(),
457
+ }
458
+ status, responseBody, errText = self._postJson(endpoint, requestBody, headers={"Authorization": f"Bearer {token}"})
459
+ ok = 200 <= status < 300
460
+ result = {
461
+ "ok": ok,
462
+ "status": "synced" if ok else "ioError",
463
+ "providerStatus": status,
464
+ "providerRef": str(responseBody.get("id", responseBody.get("documentId", responseBody.get("providerRef", ""))) or self._stableId("sapSync", {"actionId": action.get("actionId", ""), "status": status})),
465
+ "channel": "erp",
466
+ "credentialRefs": [tokenKey],
467
+ "response": responseBody,
468
+ "reason": "" if ok else errText or f"httpStatus:{status}",
469
+ }
470
+ return self._adapterResult("sap", action, connector, result, endpoint=endpoint)
471
+
472
+ def _twilioVoice(self, action: dict[str, Any], connector: dict[str, Any], session: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
473
+ sid, sidKey = self._fieldValue(
474
+ connector,
475
+ session,
476
+ payload,
477
+ ("twilioAccountSid", "accountSid", "sid", "PICUX_TWILIO_ACCOUNT_SID", "TWILIO_ACCOUNT_SID"),
478
+ ("PICUX_TWILIO_ACCOUNT_SID", "TWILIO_ACCOUNT_SID"),
479
+ )
480
+ token, tokenKey = self._secretValue(
481
+ connector,
482
+ session,
483
+ ("twilioAuthToken", "authToken", "PICUX_TWILIO_AUTH_TOKEN", "TWILIO_AUTH_TOKEN"),
484
+ ("PICUX_TWILIO_AUTH_TOKEN", "TWILIO_AUTH_TOKEN"),
485
+ )
486
+ fromNumber, fromKey = self._fieldValue(
487
+ connector,
488
+ session,
489
+ payload,
490
+ (
491
+ "twilioFromNumber",
492
+ "fromNumber",
493
+ "from",
494
+ "PICUX_TWILIO_VOICE_FROM",
495
+ "TWILIO_PHONE_NUMBER",
496
+ "PICUX_TWILIO_VERIFIED_CALLER_ID",
497
+ ),
498
+ ("PICUX_TWILIO_VOICE_FROM", "TWILIO_PHONE_NUMBER", "PICUX_TWILIO_VERIFIED_CALLER_ID"),
499
+ )
500
+ missing = [name for value, name in ((sid, "PICUX_TWILIO_ACCOUNT_SID"), (token, "PICUX_TWILIO_AUTH_TOKEN"), (fromNumber, "PICUX_TWILIO_VOICE_FROM")) if not value]
501
+ phone = self._phone(payload)
502
+ if not phone:
503
+ if not self._hostedRequired(connector, session, payload):
504
+ return {}
505
+ return self._blocked("twilioVoice", action, connector, "missingDestinationPhone", missingEnv=[])
506
+ if missing:
507
+ if not self._hostedRequired(connector, session, payload):
508
+ return {}
509
+ return self._blocked("twilioVoice", action, connector, "missingBridgeCredentials", missingEnv=missing)
510
+ baseUrl = self._providerUrl(connector, session, payload, "twilioBaseUrl", "https://api.twilio.com").rstrip("/")
511
+ endpoint = f"{baseUrl}/2010-04-01/Accounts/{parse.quote(sid)}/Calls.json"
512
+ twiml = f"<Response><Say>{escape(self._message(payload, fallback='Picux Proxy is calling to confirm availability.'))}</Say></Response>"
513
+ form = {"To": phone, "From": fromNumber, "Twiml": twiml}
514
+ auth = base64.b64encode(f"{sid}:{token}".encode("utf-8")).decode("ascii")
515
+ status, responseBody, errText = self._postForm(endpoint, form, headers={"Authorization": f"Basic {auth}"})
516
+ ok = 200 <= status < 300
517
+ result = {
518
+ "ok": ok,
519
+ "status": "sent" if ok else "ioError",
520
+ "providerStatus": status,
521
+ "providerRef": str(responseBody.get("sid", responseBody.get("Sid", responseBody.get("providerRef", ""))) or self._stableId("twilioCall", {"phone": phone, "actionId": action.get("actionId", "")})),
522
+ "channel": "voice",
523
+ "toRef": self._redactPhone(phone),
524
+ "credentialRefs": [sidKey, tokenKey, fromKey],
525
+ "response": responseBody,
526
+ "reason": "" if ok else errText or f"httpStatus:{status}",
527
+ }
528
+ return self._adapterResult("twilioVoice", action, connector, result, endpoint=endpoint)
529
+
530
+ def _whatsapp(self, action: dict[str, Any], connector: dict[str, Any], session: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
531
+ token, tokenKey = self._secretValue(
532
+ connector,
533
+ session,
534
+ ("whatsappAccessToken", "accessToken", "WHATSAPP_ACCESS_TOKEN", "META_WHATSAPP_TOKEN"),
535
+ ("WHATSAPP_ACCESS_TOKEN", "META_WHATSAPP_TOKEN"),
536
+ )
537
+ phoneNumberId, phoneKey = self._fieldValue(
538
+ connector,
539
+ session,
540
+ payload,
541
+ ("whatsappPhoneNumberId", "phoneNumberId", "WHATSAPP_PHONE_NUMBER_ID", "META_WHATSAPP_PHONE_NUMBER_ID"),
542
+ ("WHATSAPP_PHONE_NUMBER_ID", "META_WHATSAPP_PHONE_NUMBER_ID"),
543
+ )
544
+ toPhone = self._phone(payload)
545
+ missing = [name for value, name in ((token, "WHATSAPP_ACCESS_TOKEN"), (phoneNumberId, "WHATSAPP_PHONE_NUMBER_ID")) if not value]
546
+ if not toPhone:
547
+ if not self._hostedRequired(connector, session, payload):
548
+ return {}
549
+ return self._blocked("whatsapp", action, connector, "missingDestinationPhone", missingEnv=[])
550
+ if missing:
551
+ if not self._hostedRequired(connector, session, payload):
552
+ return {}
553
+ return self._blocked("whatsapp", action, connector, "missingBridgeCredentials", missingEnv=missing)
554
+ baseUrl = self._providerUrl(connector, session, payload, "whatsappBaseUrl", "https://graph.facebook.com/v19.0").rstrip("/")
555
+ endpoint = f"{baseUrl}/{parse.quote(phoneNumberId)}/messages"
556
+ body = {
557
+ "messaging_product": "whatsapp",
558
+ "to": toPhone.lstrip("+"),
559
+ "type": "text",
560
+ "text": {"body": self._message(payload, fallback="Picux Proxy needs your confirmation.")},
561
+ }
562
+ status, responseBody, errText = self._postJson(endpoint, body, headers={"Authorization": f"Bearer {token}"})
563
+ messages = responseBody.get("messages", []) if isinstance(responseBody.get("messages"), list) else []
564
+ first = messages[0] if messages and isinstance(messages[0], dict) else {}
565
+ ok = 200 <= status < 300
566
+ result = {
567
+ "ok": ok,
568
+ "status": "sent" if ok else "ioError",
569
+ "providerStatus": status,
570
+ "providerRef": str(first.get("id", responseBody.get("messageId", "")) or self._stableId("whatsappMsg", {"phone": toPhone, "actionId": action.get("actionId", "")})),
571
+ "channel": "whatsapp",
572
+ "toRef": self._redactPhone(toPhone),
573
+ "credentialRefs": [tokenKey, phoneKey],
574
+ "response": responseBody,
575
+ "reason": "" if ok else errText or f"httpStatus:{status}",
576
+ }
577
+ return self._adapterResult("whatsapp", action, connector, result, endpoint=endpoint)
578
+
579
+ def _slack(self, action: dict[str, Any], connector: dict[str, Any], session: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
580
+ token, tokenKey = self._bearerToken(connector, session, ("SLACK_BOT_TOKEN", "PICUX_SLACK_BOT_TOKEN"))
581
+ channel = str(payload.get("channelId", payload.get("channel", payload.get("to", ""))) or "")
582
+ if token:
583
+ if not channel:
584
+ if not self._hostedRequired(connector, session, payload):
585
+ return {}
586
+ return self._blocked("slack", action, connector, "missingSlackChannel", missingEnv=[])
587
+ baseUrl = self._providerUrl(connector, session, payload, "slackApiBaseUrl", "https://slack.com/api").rstrip("/")
588
+ endpoint = f"{baseUrl}/chat.postMessage"
589
+ status, responseBody, errText = self._postJson(
590
+ endpoint,
591
+ {"channel": channel, "text": self._message(payload, fallback="Picux Bridge notification.")},
592
+ headers={"Authorization": f"Bearer {token}"},
593
+ )
594
+ ok = 200 <= status < 300 and responseBody.get("ok", True) is not False
595
+ result = {
596
+ "ok": ok,
597
+ "status": "sent" if ok else "ioError",
598
+ "providerStatus": status,
599
+ "providerRef": str(responseBody.get("ts", responseBody.get("messageId", "")) or self._stableId("slackMsg", {"actionId": action.get("actionId", ""), "channel": channel})),
600
+ "channel": "slack",
601
+ "credentialRefs": [tokenKey],
602
+ "response": responseBody,
603
+ "reason": "" if ok else str(responseBody.get("error", errText or f"httpStatus:{status}") or ""),
604
+ }
605
+ return self._adapterResult("slack", action, connector, result, endpoint=endpoint)
606
+
607
+ endpoint = str(connector.get("endpoint", "") or "")
608
+ if not endpoint.startswith(("http://", "https://")):
609
+ endpoint = self._env(("SLACK_WEBHOOK_URL", "PICUX_SLACK_WEBHOOK_URL"))[0]
610
+ if not endpoint:
611
+ if not self._hostedRequired(connector, session, payload):
612
+ return {}
613
+ return self._blocked("slack", action, connector, "missingBridgeCredentials", missingEnv=["SLACK_WEBHOOK_URL", "SLACK_BOT_TOKEN"])
614
+ status, responseBody, errText = self._postJson(endpoint, {"text": self._message(payload, fallback="Picux Bridge notification.")})
615
+ ok = 200 <= status < 300
616
+ result = {
617
+ "ok": ok,
618
+ "status": "sent" if ok else "ioError",
619
+ "providerStatus": status,
620
+ "providerRef": self._stableId("slackMsg", {"actionId": action.get("actionId", ""), "status": status}),
621
+ "channel": "slack",
622
+ "response": responseBody,
623
+ "reason": "" if ok else errText or f"httpStatus:{status}",
624
+ }
625
+ return self._adapterResult("slack", action, connector, result, endpoint=endpoint)
626
+
627
+ def _smtp(self, action: dict[str, Any], connector: dict[str, Any], session: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
628
+ host, hostKey = self._env(("PICUX_SMTP_HOST", "SMTP_HOST"))
629
+ portRaw, portKey = self._env(("PICUX_SMTP_PORT", "SMTP_PORT"))
630
+ user, userKey = self._env(("PICUX_SMTP_USER", "SMTP_USER"))
631
+ password, passwordKey = self._env(("PICUX_SMTP_PASSWORD", "SMTP_PASSWORD"))
632
+ fromEmail, fromKey = self._env(("PICUX_SMTP_FROM", "SMTP_FROM", "PICUX_BRIDGE_FROM_EMAIL"))
633
+ toEmail = self._email(payload)
634
+ missing = [name for value, name in ((host, "PICUX_SMTP_HOST"), (fromEmail, "PICUX_SMTP_FROM")) if not value]
635
+ if not toEmail:
636
+ if not self._hostedRequired(connector, session, payload):
637
+ return {}
638
+ return self._blocked("smtp", action, connector, "missingDestinationEmail", missingEnv=[])
639
+ if missing:
640
+ if not self._hostedRequired(connector, session, payload):
641
+ return {}
642
+ return self._blocked("smtp", action, connector, "missingBridgeCredentials", missingEnv=missing)
643
+ msg = EmailMessage()
644
+ msg["From"] = fromEmail
645
+ msg["To"] = toEmail
646
+ msg["Subject"] = str(payload.get("subject", payload.get("title", "Picux Resolve message")) or "Picux Resolve message")
647
+ msg.set_content(self._message(payload, fallback=str(payload.get("body", "") or "Picux Resolve message.")))
648
+ try:
649
+ port = int(portRaw or 587)
650
+ with smtplib.SMTP(host, port, timeout=self.timeoutSeconds) as smtp:
651
+ if str(self._env(("PICUX_SMTP_TLS", "SMTP_TLS"))[0] or "true").lower() not in {"0", "false", "no"}:
652
+ smtp.starttls(context=ssl.create_default_context())
653
+ if user and password:
654
+ smtp.login(user, password)
655
+ smtp.send_message(msg)
656
+ result = {
657
+ "ok": True,
658
+ "status": "sent",
659
+ "providerStatus": 250,
660
+ "providerRef": self._stableId("smtpMsg", {"actionId": action.get("actionId", ""), "to": toEmail, "subject": msg["Subject"]}),
661
+ "channel": "email",
662
+ "toRef": self._redactEmail(toEmail),
663
+ "credentialRefs": [hostKey, portKey, userKey, passwordKey, fromKey],
664
+ "response": {"accepted": [self._redactEmail(toEmail)]},
665
+ }
666
+ except Exception as exc:
667
+ result = {
668
+ "ok": False,
669
+ "status": "ioError",
670
+ "providerStatus": 0,
671
+ "providerRef": "",
672
+ "channel": "email",
673
+ "toRef": self._redactEmail(toEmail),
674
+ "credentialRefs": [hostKey, portKey, userKey, passwordKey, fromKey],
675
+ "reason": str(exc),
676
+ }
677
+ return self._adapterResult("smtp", action, connector, result, endpoint=f"smtp://{host}:{portRaw or 587}")
678
+
679
+ def _adapterResult(self, kind: str, action: dict[str, Any], connector: dict[str, Any], result: dict[str, Any], *, endpoint: str = "") -> dict[str, Any]:
680
+ proof = self._proof(kind, action, connector, result, endpoint=endpoint)
681
+ return {
682
+ "adapter": {
683
+ "kind": kind,
684
+ "mode": "picuxHostedAdapter",
685
+ "provider": str(connector.get("meta", {}).get("provider", connector.get("provider", connector.get("connectorId", ""))) if isinstance(connector.get("meta", {}), dict) else connector.get("connectorId", "")),
686
+ },
687
+ "result": result,
688
+ "proof": proof,
689
+ }
690
+
691
+ def _blocked(self, kind: str, action: dict[str, Any], connector: dict[str, Any], reason: str, *, missingEnv: list[str]) -> dict[str, Any]:
692
+ result = {
693
+ "ok": False,
694
+ "status": "blocked",
695
+ "reason": reason,
696
+ "missingEnv": missingEnv,
697
+ "providerRef": "",
698
+ }
699
+ return self._adapterResult(kind, action, connector, result)
700
+
701
+ def _postJson(self, url: str, body: dict[str, Any], *, headers: dict[str, str] | None = None) -> tuple[int, dict[str, Any], str]:
702
+ data = json.dumps(body, ensure_ascii=True, sort_keys=True).encode("utf-8")
703
+ req = request.Request(
704
+ url,
705
+ data=data,
706
+ method="POST",
707
+ headers={"Accept": "application/json", "Content-Type": "application/json", **(headers or {})},
708
+ )
709
+ return self._open(req)
710
+
711
+ def _postForm(self, url: str, form: dict[str, Any], *, headers: dict[str, str] | None = None) -> tuple[int, dict[str, Any], str]:
712
+ data = parse.urlencode({key: str(value) for key, value in form.items()}).encode("utf-8")
713
+ req = request.Request(
714
+ url,
715
+ data=data,
716
+ method="POST",
717
+ headers={"Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded", **(headers or {})},
718
+ )
719
+ return self._open(req)
720
+
721
+ def _open(self, req: request.Request) -> tuple[int, dict[str, Any], str]:
722
+ try:
723
+ with request.urlopen(req, timeout=self.timeoutSeconds) as response:
724
+ raw = response.read()
725
+ return int(response.status), self._body(raw), ""
726
+ except error.HTTPError as exc:
727
+ raw = exc.read()
728
+ return int(exc.code), self._body(raw), raw.decode("utf-8", errors="replace")[:1000]
729
+ except Exception as exc:
730
+ return 0, {}, str(exc)
731
+
732
+ @staticmethod
733
+ def _body(raw: bytes) -> dict[str, Any]:
734
+ text = raw.decode("utf-8", errors="replace")
735
+ try:
736
+ decoded = json.loads(text or "{}")
737
+ except Exception:
738
+ return {"text": text[:1000]} if text else {}
739
+ body = decoded if isinstance(decoded, dict) else {"data": decoded}
740
+ return HostedBridgeAdapters._safeProviderBody(body)
741
+
742
+ def _env(self, names: tuple[str, ...]) -> tuple[str, str]:
743
+ for name in names:
744
+ resolved = self.secrets.resolve(f"env:{name}")
745
+ if resolved.available:
746
+ return resolved.value, name
747
+ return "", ""
748
+
749
+ def _credentialToken(self, connector: dict[str, Any], session: dict[str, Any]) -> str:
750
+ credential = connector.get("credential", {}) if isinstance(connector.get("credential"), dict) else {}
751
+ resolved = self.secrets.resolve(
752
+ str(credential.get("tokenRef", credential.get("secretRef", "")) or ""),
753
+ envName=str(credential.get("env", "") or ""),
754
+ source="connector.credential",
755
+ )
756
+ if resolved.available:
757
+ return resolved.value
758
+ value, _source = self._credentialValue(
759
+ connector,
760
+ session,
761
+ ("token", "accessToken", "apiKey", "bearer", "authorization", "authToken"),
762
+ allowSingleFallback=True,
763
+ )
764
+ if value:
765
+ return value
766
+ return ""
767
+
768
+ def _bearerToken(self, connector: dict[str, Any], session: dict[str, Any], envNames: tuple[str, ...]) -> tuple[str, str]:
769
+ aliases = (*envNames, "token", "accessToken", "apiKey", "bearer", "authorization")
770
+ return self._secretValue(connector, session, aliases, envNames, allowSingleFallback=True)
771
+
772
+ def _secretValue(
773
+ self,
774
+ connector: dict[str, Any],
775
+ session: dict[str, Any],
776
+ aliases: tuple[str, ...],
777
+ envNames: tuple[str, ...],
778
+ *,
779
+ allowSingleFallback: bool = False,
780
+ ) -> tuple[str, str]:
781
+ value, source = self._credentialValue(connector, session, aliases, allowSingleFallback=allowSingleFallback)
782
+ if value:
783
+ return value, source
784
+ return self._env(envNames)
785
+
786
+ def _fieldValue(
787
+ self,
788
+ connector: dict[str, Any],
789
+ session: dict[str, Any],
790
+ payload: dict[str, Any],
791
+ aliases: tuple[str, ...],
792
+ envNames: tuple[str, ...] = (),
793
+ *,
794
+ fallback: str = "",
795
+ ) -> tuple[str, str]:
796
+ for source, record in (
797
+ ("payload", payload),
798
+ ("session.providerMeta", session.get("providerMeta", {}) if isinstance(session.get("providerMeta"), dict) else {}),
799
+ ("connector.meta", connector.get("meta", {}) if isinstance(connector.get("meta"), dict) else {}),
800
+ ):
801
+ if not isinstance(record, dict):
802
+ continue
803
+ for key in aliases:
804
+ if key in record and str(record.get(key, "") or "").strip():
805
+ value = str(record.get(key, "") or "").strip()
806
+ resolved = self._maybeResolve(value, f"{source}.{key}")
807
+ if resolved[0]:
808
+ return resolved
809
+ return value, f"{source}.{key}"
810
+ value, source = self._credentialValue(connector, session, aliases)
811
+ if value:
812
+ return value, source
813
+ if envNames:
814
+ return self._env(envNames)
815
+ return fallback, ""
816
+
817
+ def _credentialValue(
818
+ self,
819
+ connector: dict[str, Any],
820
+ session: dict[str, Any],
821
+ aliases: tuple[str, ...],
822
+ *,
823
+ allowSingleFallback: bool = False,
824
+ ) -> tuple[str, str]:
825
+ wanted = {self._tokenKey(item) for item in aliases if str(item or "").strip()}
826
+ refs = self._credentialRefs(connector, session)
827
+ matches = []
828
+ for item in refs:
829
+ keys = {
830
+ self._tokenKey(item.get("resource", "")),
831
+ self._tokenKey(item.get("kind", "")),
832
+ self._tokenKey(item.get("ref", "")),
833
+ self._tokenKey(str(item.get("ref", "")).split(":", 1)[-1]),
834
+ }
835
+ if wanted and not (wanted & keys):
836
+ continue
837
+ matches.append(item)
838
+ if not matches and allowSingleFallback and len(refs) == 1:
839
+ matches = refs
840
+ for item in matches:
841
+ ref = str(item.get("ref", "") or "")
842
+ resolved = self.secrets.resolve(ref, source=f"session.credentialRefs.{item.get('resource', item.get('kind', 'credential'))}")
843
+ if resolved.available:
844
+ return resolved.value, ref
845
+ return "", ""
846
+
847
+ @staticmethod
848
+ def _credentialRefs(connector: dict[str, Any], session: dict[str, Any]) -> list[dict[str, Any]]:
849
+ refs: list[dict[str, Any]] = []
850
+ for item in session.get("credentialRefs", []) if isinstance(session.get("credentialRefs"), list) else []:
851
+ if isinstance(item, dict) and str(item.get("ref", item.get("tokenRef", item.get("secretRef", item.get("env", "")))) or "").strip():
852
+ if HostedBridgeAdapters._expired(str(item.get("expiresAt", "") or "")):
853
+ continue
854
+ refs.append(
855
+ {
856
+ "ref": str(item.get("ref", item.get("tokenRef", item.get("secretRef", item.get("env", "")))) or ""),
857
+ "kind": str(item.get("kind", item.get("type", "")) or ""),
858
+ "resource": str(item.get("resource", "") or ""),
859
+ "expiresAt": str(item.get("expiresAt", "") or ""),
860
+ }
861
+ )
862
+ meta = connector.get("meta", {}) if isinstance(connector.get("meta"), dict) else {}
863
+ for item in meta.get("credentialRefs", []) if isinstance(meta.get("credentialRefs"), list) else []:
864
+ if isinstance(item, dict) and str(item.get("ref", item.get("tokenRef", item.get("secretRef", item.get("env", "")))) or "").strip():
865
+ if HostedBridgeAdapters._expired(str(item.get("expiresAt", "") or "")):
866
+ continue
867
+ refs.append(
868
+ {
869
+ "ref": str(item.get("ref", item.get("tokenRef", item.get("secretRef", item.get("env", "")))) or ""),
870
+ "kind": str(item.get("kind", item.get("type", "")) or ""),
871
+ "resource": str(item.get("resource", "") or ""),
872
+ "expiresAt": str(item.get("expiresAt", "") or ""),
873
+ }
874
+ )
875
+ return refs
876
+
877
+ @staticmethod
878
+ def _expired(value: str) -> bool:
879
+ raw = str(value or "").strip()
880
+ if not raw:
881
+ return False
882
+ if raw.endswith("Z"):
883
+ raw = raw[:-1] + "+00:00"
884
+ try:
885
+ parsed = datetime.fromisoformat(raw)
886
+ except Exception:
887
+ return True
888
+ if parsed.tzinfo is None:
889
+ parsed = parsed.replace(tzinfo=timezone.utc)
890
+ return parsed.astimezone(timezone.utc) <= datetime.now(timezone.utc)
891
+
892
+ @staticmethod
893
+ def _safeProviderBody(value: Any) -> Any:
894
+ secretKeys = {
895
+ "authorization",
896
+ "bearer",
897
+ "cookie",
898
+ "set-cookie",
899
+ "setcookie",
900
+ "token",
901
+ "apikey",
902
+ "api_key",
903
+ "secret",
904
+ "password",
905
+ "privatekey",
906
+ "private_key",
907
+ "clientsecret",
908
+ "client_secret",
909
+ "accesstoken",
910
+ "access_token",
911
+ "refreshtoken",
912
+ "refresh_token",
913
+ "paymenttoken",
914
+ "payment_token",
915
+ }
916
+ if isinstance(value, list):
917
+ return [HostedBridgeAdapters._safeProviderBody(item) for item in value]
918
+ if not isinstance(value, dict):
919
+ return value
920
+ out: dict[str, Any] = {}
921
+ for key, item in value.items():
922
+ normalized = "".join(ch for ch in str(key).lower() if ch.isalnum() or ch in {"_", "-"})
923
+ compact = "".join(ch for ch in str(key).lower() if ch.isalnum())
924
+ if normalized in secretKeys or compact in secretKeys:
925
+ out[key] = "[redacted]"
926
+ else:
927
+ out[key] = HostedBridgeAdapters._safeProviderBody(item)
928
+ return out
929
+
930
+ def _maybeResolve(self, value: str, source: str) -> tuple[str, str]:
931
+ if value.startswith(("env:", "vault:")) or (value.isupper() and "_" in value):
932
+ resolved = self.secrets.resolve(value, source=source)
933
+ if resolved.available:
934
+ return resolved.value, value
935
+ return "", ""
936
+
937
+ @staticmethod
938
+ def _tokenKey(value: Any) -> str:
939
+ return "".join(ch for ch in str(value or "").lower() if ch.isalnum())
940
+
941
+ def _jiraAuth(self, connector: dict[str, Any], session: dict[str, Any]) -> tuple[str, str]:
942
+ token, tokenRef = self._bearerToken(connector, session, ("JIRA_ACCESS_TOKEN", "ATLASSIAN_ACCESS_TOKEN", "JIRA_API_TOKEN", "ATLASSIAN_API_TOKEN"))
943
+ if not token:
944
+ return "", ""
945
+ email, emailRef = self._fieldValue(connector, session, {}, ("jiraEmail", "atlassianEmail", "JIRA_EMAIL", "ATLASSIAN_EMAIL"), ("JIRA_EMAIL", "ATLASSIAN_EMAIL"))
946
+ if email:
947
+ basic = base64.b64encode(f"{email}:{token}".encode("utf-8")).decode("ascii")
948
+ return f"Basic {basic}", f"{emailRef}+{tokenRef}"
949
+ return f"Bearer {token}", tokenRef
950
+
951
+ def _zendeskAuth(self, token: str, tokenRef: str, connector: dict[str, Any], session: dict[str, Any]) -> str:
952
+ email, _emailRef = self._fieldValue(connector, session, {}, ("zendeskEmail", "ZENDESK_EMAIL", "PICUX_ZENDESK_EMAIL"), ("ZENDESK_EMAIL", "PICUX_ZENDESK_EMAIL"))
953
+ if email:
954
+ basic = base64.b64encode(f"{email}/token:{token}".encode("utf-8")).decode("ascii")
955
+ return f"Basic {basic}"
956
+ return f"Bearer {token}"
957
+
958
+ @staticmethod
959
+ def _jiraDoc(text: str) -> dict[str, Any]:
960
+ return {
961
+ "type": "doc",
962
+ "version": 1,
963
+ "content": [
964
+ {
965
+ "type": "paragraph",
966
+ "content": [{"type": "text", "text": str(text or "Picux case created by Bridge.")[:32000]}],
967
+ }
968
+ ],
969
+ }
970
+
971
+ @staticmethod
972
+ def _providerUrl(connector: dict[str, Any], session: dict[str, Any], payload: dict[str, Any], key: str, fallback: str) -> str:
973
+ meta = connector.get("meta", {}) if isinstance(connector.get("meta"), dict) else {}
974
+ providerMeta = session.get("providerMeta", {}) if isinstance(session.get("providerMeta"), dict) else {}
975
+ return str(providerMeta.get(key, meta.get(key, fallback)) or fallback)
976
+
977
+ @staticmethod
978
+ def _message(payload: dict[str, Any], *, fallback: str) -> str:
979
+ voiceCall = payload.get("voiceCall", {}) if isinstance(payload.get("voiceCall"), dict) else {}
980
+ candidates = (
981
+ payload.get("text"),
982
+ payload.get("message"),
983
+ payload.get("body"),
984
+ payload.get("script"),
985
+ voiceCall.get("prompt"),
986
+ voiceCall.get("script"),
987
+ fallback,
988
+ )
989
+ return str(next((item for item in candidates if str(item or "").strip()), fallback) or fallback)
990
+
991
+ @staticmethod
992
+ def _phone(payload: dict[str, Any]) -> str:
993
+ voiceCall = payload.get("voiceCall", {}) if isinstance(payload.get("voiceCall"), dict) else {}
994
+ candidates = (
995
+ payload.get("to"),
996
+ payload.get("phone"),
997
+ payload.get("number"),
998
+ payload.get("phoneNumber"),
999
+ voiceCall.get("to"),
1000
+ voiceCall.get("phone"),
1001
+ voiceCall.get("number"),
1002
+ )
1003
+ return str(next((item for item in candidates if str(item or "").strip()), "") or "")
1004
+
1005
+ @staticmethod
1006
+ def _email(payload: dict[str, Any]) -> str:
1007
+ value = payload.get("to", payload.get("email", ""))
1008
+ if isinstance(value, list):
1009
+ value = next((item for item in value if str(item or "").strip()), "")
1010
+ return str(value or "")
1011
+
1012
+ @staticmethod
1013
+ def _gmailRaw(payload: dict[str, Any], toEmail: str) -> str:
1014
+ msg = EmailMessage()
1015
+ fromEmail = str(payload.get("from", payload.get("sender", "")) or "")
1016
+ if fromEmail:
1017
+ msg["From"] = fromEmail
1018
+ msg["To"] = toEmail
1019
+ cc = payload.get("cc", [])
1020
+ if isinstance(cc, list):
1021
+ cc = ", ".join(str(item) for item in cc if str(item))
1022
+ if str(cc or "").strip():
1023
+ msg["Cc"] = str(cc)
1024
+ msg["Subject"] = str(payload.get("subject", payload.get("title", "Picux Resolve message")) or "Picux Resolve message")
1025
+ msg.set_content(str(payload.get("body", payload.get("text", payload.get("message", ""))) or ""))
1026
+ return base64.urlsafe_b64encode(bytes(msg)).decode("ascii").rstrip("=")
1027
+
1028
+ @staticmethod
1029
+ def _redactPhone(value: str) -> str:
1030
+ clean = str(value or "")
1031
+ return f"{clean[:3]}***{clean[-3:]}" if len(clean) > 7 else "***"
1032
+
1033
+ @staticmethod
1034
+ def _redactEmail(value: str) -> str:
1035
+ text = str(value or "")
1036
+ if "@" not in text:
1037
+ return "***"
1038
+ left, right = text.split("@", 1)
1039
+ return f"{left[:2]}***@{right}"
1040
+
1041
+ @staticmethod
1042
+ def _actionRef(action: dict[str, Any]) -> dict[str, Any]:
1043
+ return {
1044
+ "actionId": str(action.get("actionId", "") or ""),
1045
+ "action": str(action.get("action", "") or ""),
1046
+ "resource": str(action.get("resource", "") or ""),
1047
+ "caseId": str(action.get("caseId", "") or ""),
1048
+ "conversationId": str(action.get("conversationId", "") or ""),
1049
+ }
1050
+
1051
+ @staticmethod
1052
+ def _connectorRef(connector: dict[str, Any]) -> dict[str, Any]:
1053
+ return {
1054
+ "connectorId": str(connector.get("connectorId", "") or ""),
1055
+ "kind": str(connector.get("kind", "") or ""),
1056
+ "endpoint": str(connector.get("endpoint", "") or ""),
1057
+ "caps": connector.get("caps", []) if isinstance(connector.get("caps"), list) else [],
1058
+ }
1059
+
1060
+ @staticmethod
1061
+ def _sessionRef(session: dict[str, Any]) -> dict[str, Any]:
1062
+ return {
1063
+ "sessionId": str(session.get("sessionId", "") or ""),
1064
+ "connectorId": str(session.get("connectorId", "") or ""),
1065
+ "executionMode": str(session.get("executionMode", "") or ""),
1066
+ "subjectId": str(session.get("subjectId", "") or ""),
1067
+ }
1068
+
1069
+ def _proof(self, kind: str, action: dict[str, Any], connector: dict[str, Any], result: dict[str, Any], *, endpoint: str = "") -> dict[str, Any]:
1070
+ proofPayload = {
1071
+ "kind": kind,
1072
+ "actionId": action.get("actionId", ""),
1073
+ "connectorId": connector.get("connectorId", ""),
1074
+ "providerRef": result.get("providerRef", ""),
1075
+ "status": result.get("status", ""),
1076
+ "providerStatus": result.get("providerStatus", ""),
1077
+ }
1078
+ artifactRef = self._stableId("bridgeProof", proofPayload)
1079
+ return {
1080
+ "proofId": artifactRef,
1081
+ "artifactRef": artifactRef,
1082
+ "kind": f"{kind}Proof",
1083
+ "status": "sourceBound" if result.get("ok") else str(result.get("status", "blocked") or "blocked"),
1084
+ "label": f"{kind} Bridge execution proof",
1085
+ "sourceUrl": self._safeEndpoint(endpoint),
1086
+ "hash": hashlib.sha256(json.dumps(proofPayload, ensure_ascii=True, sort_keys=True).encode("utf-8")).hexdigest(),
1087
+ "meta": proofPayload,
1088
+ }
1089
+
1090
+ @staticmethod
1091
+ def _safeEndpoint(endpoint: str) -> str:
1092
+ parsed = parse.urlparse(str(endpoint or ""))
1093
+ if not parsed.scheme:
1094
+ return ""
1095
+ return f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
1096
+
1097
+ @staticmethod
1098
+ def _stableId(prefix: str, payload: dict[str, Any]) -> str:
1099
+ raw = json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")
1100
+ return f"{prefix}_" + hashlib.sha256(raw).hexdigest()[:24]
1101
+
1102
+ @staticmethod
1103
+ def _now() -> str:
1104
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")