lfx-confluence 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.
@@ -0,0 +1 @@
1
+ """Connection-backed Confluence Cloud integration bundle."""
lfx_confluence/base.py ADDED
@@ -0,0 +1,44 @@
1
+ """Shared Confluence connection and trigger declarations."""
2
+
3
+ from typing import ClassVar
4
+
5
+ from lfx.base.triggers.base import BaseTriggerComponent
6
+ from lfx.custom.custom_component.component import Component
7
+ from lfx.integrations.telemetry import integration_action
8
+
9
+ from lfx_confluence.client import ConfluenceClient
10
+ from lfx_confluence.schema import READ, WRITE
11
+
12
+
13
+ class ConfluenceActionComponent(Component):
14
+ icon = "Confluence"
15
+ capability_id: ClassVar[str] = ""
16
+
17
+ def client(self):
18
+ return ConfluenceClient(self.resolve_connection("connection"))
19
+
20
+ async def perform(self, method, path, *, body=None, params=None, write=False):
21
+ client = self.client()
22
+ try:
23
+ credential = await client.lease.get_credential()
24
+ async with integration_action(
25
+ self, provider="confluence", capability=self.capability_id, owner_kind=credential.owner_kind
26
+ ):
27
+ return await client.request(
28
+ method, path, body=body, params=params, scopes=frozenset({READ, WRITE} if write else {READ})
29
+ )
30
+ finally:
31
+ await client.aclose()
32
+
33
+
34
+ class ConfluencePageTriggerComponent(BaseTriggerComponent):
35
+ provider = "confluence"
36
+ needs_connection = True
37
+ icon = "Confluence"
38
+
39
+ def trigger_config(self):
40
+ return {
41
+ "connection": getattr(self, "connection", None),
42
+ "space_id": getattr(self, "space_id", ""),
43
+ "delivery_mode": "poll",
44
+ }
@@ -0,0 +1,154 @@
1
+ """Fixed-origin Confluence requests with credential checks and bounded responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import re
8
+ from typing import TYPE_CHECKING
9
+
10
+ import httpx
11
+ from lfx.integrations.errors import (
12
+ AuthExpiredError,
13
+ ConnectionNotAuthorizedError,
14
+ IntegrationNetworkError,
15
+ ProviderUnavailableError,
16
+ RateLimitedError,
17
+ ResourceNotFoundError,
18
+ ScopeMissingError,
19
+ )
20
+ from lfx.utils.ssrf_httpx import ssrf_protected_strict_httpx_client_kwargs_for_url
21
+ from lfx.utils.ssrf_protection import SSRFProtectionError
22
+ from lfx.utils.url_redaction import suppress_sensitive_http_logs
23
+
24
+ from lfx_confluence.schema import API_ORIGIN, READ, cloud_id, invalid
25
+
26
+ if TYPE_CHECKING:
27
+ from lfx.integrations.models import CredentialLease
28
+
29
+ _PATH = re.compile(r"/wiki/api/v2/pages(?:/[1-9][0-9]{0,18})?\Z")
30
+ MAX_REQUEST_BYTES = 1048576
31
+ MAX_RESPONSE_BYTES = 2 * 1024 * 1024
32
+
33
+
34
+ def http_client(transport=None) -> httpx.AsyncClient:
35
+ try:
36
+ kwargs = (
37
+ ssrf_protected_strict_httpx_client_kwargs_for_url(API_ORIGIN)[1]
38
+ if transport is None
39
+ else {"transport": transport}
40
+ )
41
+ except SSRFProtectionError:
42
+ raise IntegrationNetworkError(provider="confluence", reason="connection", host="api.atlassian.com") from None
43
+ kwargs["follow_redirects"] = False
44
+ return httpx.AsyncClient(timeout=20, trust_env=False, **kwargs)
45
+
46
+
47
+ async def send(http: httpx.AsyncClient, method: str, path: str, token: str, *, body=None, params=None):
48
+ if not path.startswith("/") or "?" in path or "#" in path or ".." in path or "\\" in path or "%" in path:
49
+ msg = "Unsupported Confluence API resource."
50
+ raise invalid(msg)
51
+ content = json.dumps(body, ensure_ascii=False).encode() if body is not None else None
52
+ if content is not None and len(content) > MAX_REQUEST_BYTES:
53
+ msg = "The Confluence request exceeds 1 MiB."
54
+ raise invalid(msg)
55
+ try:
56
+ async with (
57
+ suppress_sensitive_http_logs(),
58
+ http.stream(
59
+ method,
60
+ API_ORIGIN + path,
61
+ content=content,
62
+ params=params,
63
+ headers={
64
+ "Authorization": f"Bearer {token}",
65
+ "Accept": "application/json",
66
+ "Content-Type": "application/json",
67
+ },
68
+ ) as response,
69
+ ):
70
+ raw = bytearray()
71
+ async for part in response.aiter_bytes():
72
+ raw.extend(part)
73
+ if len(raw) > MAX_RESPONSE_BYTES:
74
+ raise ProviderUnavailableError(provider="confluence")
75
+ status = response.status_code
76
+ if status == httpx.codes.UNAUTHORIZED:
77
+ raise AuthExpiredError(provider="confluence")
78
+ if status == httpx.codes.FORBIDDEN:
79
+ raise ConnectionNotAuthorizedError(provider="confluence", reason="provider")
80
+ if status == httpx.codes.NOT_FOUND:
81
+ raise ResourceNotFoundError(provider="confluence")
82
+ if status == httpx.codes.TOO_MANY_REQUESTS:
83
+ try:
84
+ delay = float(response.headers.get("Retry-After", "60"))
85
+ except (ValueError, OverflowError):
86
+ delay = 60.0
87
+ raise RateLimitedError(
88
+ provider="confluence", retry_after=max(0, min(delay, 86400)) if math.isfinite(delay) else 60.0
89
+ )
90
+ if (
91
+ status >= httpx.codes.INTERNAL_SERVER_ERROR
92
+ or httpx.codes.MULTIPLE_CHOICES <= status < httpx.codes.BAD_REQUEST
93
+ ):
94
+ raise ProviderUnavailableError(provider="confluence", http_status=status)
95
+ if status >= httpx.codes.BAD_REQUEST:
96
+ msg = "Confluence rejected this operation. Check the page fields and account permissions."
97
+ raise invalid(msg)
98
+ if status not in {200, 201, 204}:
99
+ raise ProviderUnavailableError(provider="confluence")
100
+ if not raw and status == httpx.codes.NO_CONTENT:
101
+ return {}
102
+ try:
103
+ value = json.loads(raw)
104
+ except (ValueError, UnicodeDecodeError):
105
+ raise ProviderUnavailableError(provider="confluence") from None
106
+ if not isinstance(value, (dict, list)):
107
+ raise ProviderUnavailableError(provider="confluence")
108
+ return value
109
+ except httpx.RequestError:
110
+ raise IntegrationNetworkError(provider="confluence", reason="connection", host="api.atlassian.com") from None
111
+
112
+
113
+ class ConfluenceClient:
114
+ def __init__(self, lease: CredentialLease, *, transport=None):
115
+ self.lease = lease
116
+ self.site = cloud_id()
117
+ self.http = http_client(transport)
118
+
119
+ async def aclose(self):
120
+ await self.http.aclose()
121
+
122
+ async def _check(self, scopes):
123
+ credential = await self.lease.get_credential()
124
+ if (
125
+ credential.provider != "confluence"
126
+ or credential.identity != "user_delegated"
127
+ or credential.account is None
128
+ or credential.account.tenant_id != self.site
129
+ ):
130
+ raise ConnectionNotAuthorizedError(provider="confluence", reason="provider")
131
+ if not credential.scopes_verified or not scopes <= credential.granted_scopes:
132
+ raise ScopeMissingError(
133
+ frozenset(scopes), provider="confluence", scopes_verified=credential.scopes_verified
134
+ )
135
+
136
+ async def request(self, method, path, *, body=None, params=None, scopes=frozenset({READ})):
137
+ if not _PATH.fullmatch(path):
138
+ msg = "Unsupported Confluence API resource."
139
+ raise invalid(msg)
140
+ await self._check(scopes)
141
+ token = await self.lease.get_token()
142
+ for attempt in range(2):
143
+ try:
144
+ return await send(
145
+ self.http, method, f"/ex/confluence/{self.site}" + path, token, body=body, params=params
146
+ )
147
+ except AuthExpiredError:
148
+ if attempt:
149
+ raise
150
+ token = await self.lease.get_token_after_auth_error(
151
+ AuthExpiredError(provider="confluence"), rejected_token=token
152
+ )
153
+ await self._check(scopes)
154
+ raise AuthExpiredError(provider="confluence")
@@ -0,0 +1 @@
1
+ """Confluence integration components."""
@@ -0,0 +1,18 @@
1
+ """Stable Confluence integration component identities."""
2
+
3
+ from .actions import (
4
+ ConfluenceCreatePageComponent,
5
+ ConfluenceReadPageComponent,
6
+ ConfluenceSearchPagesComponent,
7
+ ConfluenceUpdatePageComponent,
8
+ )
9
+ from .triggers import ConfluenceOnPageCreatedTriggerComponent, ConfluenceOnPageUpdatedTriggerComponent
10
+
11
+ __all__ = [
12
+ "ConfluenceCreatePageComponent",
13
+ "ConfluenceOnPageCreatedTriggerComponent",
14
+ "ConfluenceOnPageUpdatedTriggerComponent",
15
+ "ConfluenceReadPageComponent",
16
+ "ConfluenceSearchPagesComponent",
17
+ "ConfluenceUpdatePageComponent",
18
+ ]
@@ -0,0 +1,156 @@
1
+ """Explicit page actions with approved spaces and optimistic content versions."""
2
+
3
+ from lfx.io import IntInput, MessageTextInput, MultilineInput, Output
4
+ from lfx.schema.data import Data
5
+
6
+ from lfx_confluence.base import ConfluenceActionComponent
7
+ from lfx_confluence.manifest import connection_input
8
+ from lfx_confluence.schema import (
9
+ MAX_VERSION,
10
+ approved_space,
11
+ invalid,
12
+ list_params,
13
+ numeric_id,
14
+ page_data,
15
+ page_list,
16
+ text,
17
+ )
18
+
19
+
20
+ def space_input():
21
+ return MessageTextInput(
22
+ name="space_id",
23
+ display_name="Space ID",
24
+ required=True,
25
+ info="An operator-approved numeric Confluence space ID.",
26
+ )
27
+
28
+
29
+ class ConfluenceSearchPagesComponent(ConfluenceActionComponent):
30
+ display_name = "Confluence: Search Pages"
31
+ description = "List published pages in one approved space, optionally matching an exact title."
32
+ name = "ConfluenceSearchPages"
33
+ capability_id = "confluence.page.search"
34
+ inputs = [
35
+ connection_input(capability_id),
36
+ space_input(),
37
+ MessageTextInput(name="title", display_name="Exact Title"),
38
+ MessageTextInput(name="cursor", display_name="Next Page Cursor", advanced=True),
39
+ ]
40
+ outputs = [Output(display_name="Pages", name="pages", method="search_pages")]
41
+
42
+ async def search_pages(self) -> Data:
43
+ space = approved_space(self.space_id)
44
+ title = getattr(self, "title", "") or ""
45
+ result = await self.perform(
46
+ "GET",
47
+ "/wiki/api/v2/pages",
48
+ params=list_params(space, title=title, cursor=getattr(self, "cursor", "") or ""),
49
+ )
50
+ pages, cursor = page_list(result, space=space)
51
+ if title and any(page["title"] != title for page in pages):
52
+ msg = "Confluence returned a page outside the exact title filter."
53
+ raise invalid(msg)
54
+ return Data(data={"pages": pages, "next_cursor": cursor})
55
+
56
+
57
+ class ConfluenceReadPageComponent(ConfluenceActionComponent):
58
+ display_name = "Confluence: Read Page"
59
+ description = "Read one current page and its storage body from an approved space."
60
+ name = "ConfluenceReadPage"
61
+ capability_id = "confluence.page.read"
62
+ inputs = [
63
+ connection_input(capability_id),
64
+ space_input(),
65
+ MessageTextInput(name="page_id", display_name="Page ID", required=True),
66
+ ]
67
+ outputs = [Output(display_name="Page", name="page", method="read_page")]
68
+
69
+ async def read_page(self) -> Data:
70
+ space, identifier = approved_space(self.space_id), numeric_id(self.page_id)
71
+ result = await self.perform(
72
+ "GET", f"/wiki/api/v2/pages/{identifier}", params={"body-format": "storage", "status": "current"}
73
+ )
74
+ return Data(data=page_data(result, space=space, identifier=identifier, include_body=True))
75
+
76
+
77
+ class ConfluenceCreatePageComponent(ConfluenceActionComponent):
78
+ display_name = "Confluence: Create Page"
79
+ description = "Publish one page with a storage-format body in an approved space."
80
+ name = "ConfluenceCreatePage"
81
+ capability_id = "confluence.page.create"
82
+ inputs = [
83
+ connection_input(capability_id),
84
+ space_input(),
85
+ MessageTextInput(name="title", display_name="Title", required=True),
86
+ MultilineInput(name="body_storage", display_name="Storage Body", required=True),
87
+ MessageTextInput(name="parent_id", display_name="Parent Page ID", advanced=True),
88
+ ]
89
+ outputs = [Output(display_name="Page", name="page", method="create_page")]
90
+
91
+ async def create_page(self) -> Data:
92
+ space = approved_space(self.space_id)
93
+ body = {
94
+ "spaceId": space,
95
+ "status": "current",
96
+ "title": text(self.title, maximum=255),
97
+ "body": {"representation": "storage", "value": text(self.body_storage, maximum=524288, required=False)},
98
+ }
99
+ parent = getattr(self, "parent_id", "")
100
+ if parent:
101
+ parent = numeric_id(parent)
102
+ existing = await self.perform("GET", f"/wiki/api/v2/pages/{parent}", params={"status": "current"})
103
+ page_data(existing, space=space, identifier=parent)
104
+ body["parentId"] = parent
105
+ result = await self.perform("POST", "/wiki/api/v2/pages", body=body, write=True)
106
+ return Data(data=page_data(result, space=space))
107
+
108
+
109
+ class ConfluenceUpdatePageComponent(ConfluenceActionComponent):
110
+ display_name = "Confluence: Update Page"
111
+ description = "Replace a published page only when its current version matches the expected version."
112
+ name = "ConfluenceUpdatePage"
113
+ capability_id = "confluence.page.update"
114
+ inputs = [
115
+ connection_input(capability_id),
116
+ space_input(),
117
+ MessageTextInput(name="page_id", display_name="Page ID", required=True),
118
+ IntInput(name="expected_version", display_name="Expected Current Version", required=True, value=1),
119
+ MessageTextInput(name="title", display_name="Title", required=True),
120
+ MultilineInput(name="body_storage", display_name="Storage Body", required=True),
121
+ ]
122
+ outputs = [Output(display_name="Page", name="page", method="update_page")]
123
+
124
+ async def update_page(self) -> Data:
125
+ space, identifier = approved_space(self.space_id), numeric_id(self.page_id)
126
+ expected = self.expected_version
127
+ if type(expected) is not int or not 1 <= expected < MAX_VERSION:
128
+ msg = "Provide the expected positive current page version."
129
+ raise invalid(msg)
130
+ title = text(self.title, maximum=255)
131
+ storage = text(self.body_storage, maximum=524288, required=False)
132
+ current = page_data(
133
+ await self.perform("GET", f"/wiki/api/v2/pages/{identifier}", params={"status": "current"}),
134
+ space=space,
135
+ identifier=identifier,
136
+ )
137
+ if current["version"] != expected:
138
+ msg = "The page changed. Read its current version before submitting another update."
139
+ raise invalid(msg)
140
+ result = await self.perform(
141
+ "PUT",
142
+ f"/wiki/api/v2/pages/{identifier}",
143
+ body={
144
+ "id": identifier,
145
+ "status": "current",
146
+ "title": title,
147
+ "body": {"representation": "storage", "value": storage},
148
+ "version": {"number": expected + 1},
149
+ },
150
+ write=True,
151
+ )
152
+ updated = page_data(result, space=space, identifier=identifier)
153
+ if updated["version"] != expected + 1:
154
+ msg = "Confluence returned an unexpected updated page version."
155
+ raise invalid(msg)
156
+ return Data(data=updated)
@@ -0,0 +1,160 @@
1
+ {
2
+ "schema_version": 1,
3
+ "provider_id": "confluence",
4
+ "display_name": "Confluence Cloud",
5
+ "icon": "Confluence",
6
+ "docs_url": "https://docs.langflow.org/next/bundles-confluence",
7
+ "auth_profiles": [
8
+ {
9
+ "id": "user",
10
+ "kind": "oauth2_authorization_code",
11
+ "identity": "user_delegated",
12
+ "supports_pkce": false,
13
+ "supports_refresh": true,
14
+ "default_scopes": [
15
+ "offline_access",
16
+ "read:confluence-user",
17
+ "read:page:confluence",
18
+ "write:page:confluence"
19
+ ],
20
+ "client_type_by_context": {
21
+ "hosted": "confidential",
22
+ "self_managed": "confidential"
23
+ },
24
+ "owner_by_context": {
25
+ "hosted": "either",
26
+ "self_managed": "customer"
27
+ }
28
+ }
29
+ ],
30
+ "capabilities": [
31
+ {
32
+ "id": "confluence.page.search",
33
+ "display_name": "Confluence: Search Pages",
34
+ "auth_profile_id": "user",
35
+ "identity": "user_delegated",
36
+ "required_scopes": [
37
+ "read:page:confluence",
38
+ "read:confluence-user"
39
+ ],
40
+ "policy_keys": [
41
+ "integrations.confluence.page.search"
42
+ ],
43
+ "substrate": "rest",
44
+ "maturity": "preview",
45
+ "deployment_contexts": [
46
+ "hosted",
47
+ "self_managed"
48
+ ],
49
+ "risk": "read",
50
+ "component_ref": "ConfluenceSearchPagesComponent"
51
+ },
52
+ {
53
+ "id": "confluence.page.read",
54
+ "display_name": "Confluence: Read Page",
55
+ "auth_profile_id": "user",
56
+ "identity": "user_delegated",
57
+ "required_scopes": [
58
+ "read:page:confluence",
59
+ "read:confluence-user"
60
+ ],
61
+ "policy_keys": [
62
+ "integrations.confluence.page.read"
63
+ ],
64
+ "substrate": "rest",
65
+ "maturity": "preview",
66
+ "deployment_contexts": [
67
+ "hosted",
68
+ "self_managed"
69
+ ],
70
+ "risk": "read",
71
+ "component_ref": "ConfluenceReadPageComponent"
72
+ },
73
+ {
74
+ "id": "confluence.page.create",
75
+ "display_name": "Confluence: Create Page",
76
+ "auth_profile_id": "user",
77
+ "identity": "user_delegated",
78
+ "required_scopes": [
79
+ "read:page:confluence",
80
+ "write:page:confluence",
81
+ "read:confluence-user"
82
+ ],
83
+ "policy_keys": [
84
+ "integrations.confluence.page.create"
85
+ ],
86
+ "substrate": "rest",
87
+ "maturity": "preview",
88
+ "deployment_contexts": [
89
+ "hosted",
90
+ "self_managed"
91
+ ],
92
+ "risk": "write",
93
+ "component_ref": "ConfluenceCreatePageComponent"
94
+ },
95
+ {
96
+ "id": "confluence.page.update",
97
+ "display_name": "Confluence: Update Page",
98
+ "auth_profile_id": "user",
99
+ "identity": "user_delegated",
100
+ "required_scopes": [
101
+ "read:page:confluence",
102
+ "write:page:confluence",
103
+ "read:confluence-user"
104
+ ],
105
+ "policy_keys": [
106
+ "integrations.confluence.page.update"
107
+ ],
108
+ "substrate": "rest",
109
+ "maturity": "preview",
110
+ "deployment_contexts": [
111
+ "hosted",
112
+ "self_managed"
113
+ ],
114
+ "risk": "write",
115
+ "component_ref": "ConfluenceUpdatePageComponent"
116
+ },
117
+ {
118
+ "id": "confluence.trigger.created",
119
+ "display_name": "Confluence: On Page Created",
120
+ "auth_profile_id": "user",
121
+ "identity": "user_delegated",
122
+ "required_scopes": [
123
+ "read:page:confluence",
124
+ "offline_access",
125
+ "read:confluence-user"
126
+ ],
127
+ "policy_keys": [
128
+ "integrations.confluence.trigger.created"
129
+ ],
130
+ "substrate": "rest",
131
+ "maturity": "preview",
132
+ "deployment_contexts": [
133
+ "self_managed"
134
+ ],
135
+ "risk": "read",
136
+ "component_ref": "ConfluenceOnPageCreatedTriggerComponent"
137
+ },
138
+ {
139
+ "id": "confluence.trigger.updated",
140
+ "display_name": "Confluence: On Page Updated",
141
+ "auth_profile_id": "user",
142
+ "identity": "user_delegated",
143
+ "required_scopes": [
144
+ "read:page:confluence",
145
+ "offline_access",
146
+ "read:confluence-user"
147
+ ],
148
+ "policy_keys": [
149
+ "integrations.confluence.trigger.updated"
150
+ ],
151
+ "substrate": "rest",
152
+ "maturity": "preview",
153
+ "deployment_contexts": [
154
+ "self_managed"
155
+ ],
156
+ "risk": "read",
157
+ "component_ref": "ConfluenceOnPageUpdatedTriggerComponent"
158
+ }
159
+ ]
160
+ }
@@ -0,0 +1,41 @@
1
+ """Declarations for authenticated latest-state page polling."""
2
+
3
+ from lfx.io import MessageTextInput
4
+
5
+ from lfx_confluence.base import ConfluencePageTriggerComponent
6
+ from lfx_confluence.manifest import connection_input
7
+
8
+
9
+ class ConfluenceOnPageCreatedTriggerComponent(ConfluencePageTriggerComponent):
10
+ display_name = "Confluence: On Page Created"
11
+ description = (
12
+ "Observe newly created pages after an initial approved-space baseline. "
13
+ "Moved old pages do not count as creation."
14
+ )
15
+ name = "ConfluenceOnPageCreatedTrigger"
16
+ trigger_kind = "confluence.page_created"
17
+ inputs = [
18
+ connection_input("confluence.trigger.created", required=False),
19
+ MessageTextInput(
20
+ name="space_id",
21
+ display_name="Space ID",
22
+ required=True,
23
+ info="An approved numeric space ID with at most 5000 observed pages.",
24
+ ),
25
+ ]
26
+
27
+
28
+ class ConfluenceOnPageUpdatedTriggerComponent(ConfluencePageTriggerComponent):
29
+ display_name = "Confluence: On Page Updated"
30
+ description = "Observe version changes of previously seen published pages. Intermediate edits can be missed."
31
+ name = "ConfluenceOnPageUpdatedTrigger"
32
+ trigger_kind = "confluence.page_updated"
33
+ inputs = [
34
+ connection_input("confluence.trigger.updated", required=False),
35
+ MessageTextInput(
36
+ name="space_id",
37
+ display_name="Space ID",
38
+ required=True,
39
+ info="An approved numeric space ID with at most 5000 observed pages.",
40
+ ),
41
+ ]
@@ -0,0 +1,25 @@
1
+ {
2
+ "$schema": "https://schemas.langflow.org/extension/v1.json",
3
+ "id": "lfx-confluence",
4
+ "version": "0.1.0",
5
+ "name": "Confluence Integrations",
6
+ "description": "Confluence Cloud page actions and latest-state polling triggers.",
7
+ "lfx": {
8
+ "compat": [
9
+ "1"
10
+ ]
11
+ },
12
+ "bundles": [
13
+ {
14
+ "name": "confluence_integrations",
15
+ "path": "components/confluence_integrations"
16
+ }
17
+ ],
18
+ "integrations": [
19
+ {
20
+ "provider_id": "confluence",
21
+ "bundle": "confluence_integrations",
22
+ "path": "capabilities.v1.json"
23
+ }
24
+ ]
25
+ }
@@ -0,0 +1,31 @@
1
+ """Bundle capability metadata and connection requirements."""
2
+
3
+ from functools import lru_cache
4
+ from pathlib import Path
5
+
6
+ from lfx.custom.custom_component.component import Component # noqa: F401
7
+ from lfx.integrations.capabilities import IntegrationCapabilityManifest
8
+ from lfx.io import ConnectionRefInput
9
+
10
+
11
+ @lru_cache(maxsize=1)
12
+ def load_manifest():
13
+ return IntegrationCapabilityManifest.model_validate_json(
14
+ (Path(__file__).parent / "components/confluence_integrations/capabilities.v1.json").read_text()
15
+ )
16
+
17
+
18
+ def connection_input(capability, *, required=True):
19
+ declared = next(item for item in load_manifest().capabilities if item.id == capability)
20
+ return ConnectionRefInput(
21
+ name="connection",
22
+ display_name="Confluence Connection",
23
+ provider="confluence",
24
+ auth_profile_id="user",
25
+ identity_kind="user",
26
+ ownership_mode="user",
27
+ required_scopes=list(declared.required_scopes),
28
+ capabilities=[capability],
29
+ required=required,
30
+ info="Use an owned Confluence Cloud OAuth connection for the operator-configured site.",
31
+ )
@@ -0,0 +1,172 @@
1
+ """Fixed deployment identity and bounded Confluence page targets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from datetime import datetime, timezone
8
+ from urllib.parse import parse_qsl, urlsplit
9
+ from uuid import UUID
10
+
11
+ from lfx.integrations.errors import InvalidRequestError
12
+
13
+ API_ORIGIN = "https://api.atlassian.com"
14
+ READ = "read:page:confluence"
15
+ WRITE = "write:page:confluence"
16
+ USER = "read:confluence-user"
17
+ SCOPES = frozenset({READ, WRITE, USER, "offline_access"})
18
+ PAGE_SIZE = 100
19
+ MAX_PAGES = 50
20
+ MAX_ITEMS = 5000
21
+ MAX_CURSOR = 8192
22
+ MAX_VERSION = 2147483647
23
+ MAX_SPACES = 100
24
+
25
+
26
+ def invalid(message):
27
+ return InvalidRequestError(message, provider="confluence")
28
+
29
+
30
+ def cloud_id():
31
+ value = os.environ.get("LANGFLOW_CONFLUENCE_CLOUD_ID", "")
32
+ try:
33
+ if str(UUID(value)) != value:
34
+ raise ValueError
35
+ except (ValueError, AttributeError):
36
+ msg = "Configure one canonical lowercase Confluence Cloud site UUID on the server."
37
+ raise invalid(msg) from None
38
+ return value
39
+
40
+
41
+ def numeric_id(value):
42
+ if not isinstance(value, str) or not re.fullmatch(r"[1-9][0-9]{0,18}", value):
43
+ msg = "Use a positive Confluence numeric ID within 19 digits."
44
+ raise invalid(msg)
45
+ return value
46
+
47
+
48
+ def approved_space(value):
49
+ value = numeric_id(value)
50
+ configured = os.environ.get("LANGFLOW_CONFLUENCE_SPACE_IDS", "").split(",")
51
+ if not 1 <= len(configured) <= MAX_SPACES or any(
52
+ not re.fullmatch(r"[1-9][0-9]{0,18}", item) for item in configured
53
+ ):
54
+ msg = "Configure one to 100 approved Confluence space IDs as a comma-separated server setting."
55
+ raise invalid(msg)
56
+ if value not in configured:
57
+ msg = "This Confluence space is not approved by the deployment operator."
58
+ raise invalid(msg)
59
+ return value
60
+
61
+
62
+ def text(value, *, maximum=32768, required=True):
63
+ try:
64
+ valid = (
65
+ isinstance(value, str) and len(value.encode("utf-8")) <= maximum and (not required or bool(value.strip()))
66
+ )
67
+ except UnicodeEncodeError:
68
+ valid = False
69
+ if not valid:
70
+ msg = "Provide text within the documented size limit."
71
+ raise invalid(msg)
72
+ return value
73
+
74
+
75
+ def timestamp(value):
76
+ try:
77
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
78
+ if parsed.tzinfo is None:
79
+ raise ValueError
80
+ return parsed.astimezone(timezone.utc)
81
+ except (AttributeError, ValueError, TypeError, OverflowError):
82
+ msg = "Confluence returned an invalid page timestamp."
83
+ raise invalid(msg) from None
84
+
85
+
86
+ def page_data(value, *, space, identifier=None, include_body=False):
87
+ if not isinstance(value, dict) or not isinstance(value.get("version"), dict):
88
+ msg = "Confluence returned an invalid page."
89
+ raise invalid(msg)
90
+ page_id = numeric_id(value.get("id"))
91
+ if value.get("spaceId") != approved_space(space) or (identifier is not None and page_id != identifier):
92
+ msg = "Confluence returned a page outside the selected target."
93
+ raise invalid(msg)
94
+ version = value["version"].get("number")
95
+ if type(version) is not int or not 1 <= version <= MAX_VERSION or value.get("status") != "current":
96
+ msg = "Confluence returned an invalid current page version."
97
+ raise invalid(msg)
98
+ result = {
99
+ "id": page_id,
100
+ "space_id": space,
101
+ "title": text(value.get("title"), maximum=4096),
102
+ "created_at": timestamp(value.get("createdAt")).isoformat(),
103
+ "updated_at": timestamp(value["version"].get("createdAt")).isoformat(),
104
+ "version": version,
105
+ }
106
+ if include_body:
107
+ body = value.get("body", {}).get("storage") if isinstance(value.get("body"), dict) else None
108
+ if not isinstance(body, dict):
109
+ msg = "Confluence returned no storage page body."
110
+ raise invalid(msg)
111
+ result["body_storage"] = text(body.get("value"), maximum=1048576, required=False)
112
+ return result
113
+
114
+
115
+ def list_params(space, *, title="", cursor=""):
116
+ result = {
117
+ "space-id": approved_space(space),
118
+ "status": "current",
119
+ "sort": "id",
120
+ "subtype": "page",
121
+ "limit": PAGE_SIZE,
122
+ }
123
+ if title:
124
+ result["title"] = text(title, maximum=255)
125
+ if cursor:
126
+ result["cursor"] = text(cursor, maximum=MAX_CURSOR)
127
+ return result
128
+
129
+
130
+ def next_cursor(result):
131
+ links = result.get("_links", {}) if isinstance(result, dict) else None
132
+ if not isinstance(links, dict):
133
+ msg = "Confluence returned malformed page links."
134
+ raise invalid(msg)
135
+ link = links.get("next")
136
+ if link is None or link == "":
137
+ return ""
138
+ link = text(link, maximum=MAX_CURSOR + 4096)
139
+ parts = urlsplit(link)
140
+ allowed = {"/wiki/api/v2/pages", f"/ex/confluence/{cloud_id()}/wiki/api/v2/pages"}
141
+ if (parts.scheme or parts.netloc) and f"{parts.scheme}://{parts.netloc}" != API_ORIGIN:
142
+ msg = "Confluence returned an unsafe pagination origin."
143
+ raise invalid(msg)
144
+ if parts.path not in allowed or parts.fragment or parts.username or parts.password:
145
+ msg = "Confluence returned an unrelated pagination resource."
146
+ raise invalid(msg)
147
+ try:
148
+ pairs = parse_qsl(parts.query, strict_parsing=True, max_num_fields=12)
149
+ except ValueError:
150
+ msg = "Confluence returned malformed pagination parameters."
151
+ raise invalid(msg) from None
152
+ cursors = [value for key, value in pairs if key == "cursor"]
153
+ if len(cursors) != 1:
154
+ msg = "Confluence returned no unique pagination cursor."
155
+ raise invalid(msg)
156
+ # Rebuild every request from approved space and fixed filters. Never follow the provider URL.
157
+ return text(cursors[0], maximum=MAX_CURSOR)
158
+
159
+
160
+ def page_list(result, *, space):
161
+ if (
162
+ not isinstance(result, dict)
163
+ or not isinstance(result.get("results"), list)
164
+ or len(result["results"]) > PAGE_SIZE
165
+ ):
166
+ msg = "Confluence returned an invalid page list."
167
+ raise invalid(msg)
168
+ pages = [page_data(value, space=space) for value in result["results"]]
169
+ if len({value["id"] for value in pages}) != len(pages):
170
+ msg = "Confluence returned duplicate pages. Retry the complete snapshot."
171
+ raise invalid(msg)
172
+ return pages, next_cursor(result)
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: lfx-confluence
3
+ Version: 0.1.0
4
+ Summary: Confluence Cloud page actions and bounded latest-state polling triggers.
5
+ License: MIT
6
+ Requires-Python: <3.16,>=3.10
7
+ Requires-Dist: httpx<1,>=0.27
8
+ Requires-Dist: lfx<2.0.0,>=1.14.0.dev0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # Confluence Cloud integrations
12
+
13
+ This preview bundle provides four page actions and two polling triggers for an approved Confluence Cloud site. The Confluence Integrations category is separate from legacy Confluence components, whose saved-flow identities are unchanged.
14
+
15
+ Follow the [Confluence Cloud setup guide](https://docs.langflow.org/next/bundles-confluence) for Atlassian application registration, deployment configuration, connections, background runs, event identity, limits, and recovery. The maintained [guide source](../../../docs/docs/Components/bundles-confluence.mdx) ships with the next-version documentation.
16
+
17
+ The [provider contract](../../../design/provider-integrations/contracts/confluence.v1.json) records capabilities and acceptance evidence. Live tenant authorization and production delivery acceptance remain unverified.
@@ -0,0 +1,15 @@
1
+ lfx_confluence/__init__.py,sha256=Kimsdx_uLzf22x4ZomvC6IHRj3eut2rjfa1oHd2-XK0,61
2
+ lfx_confluence/base.py,sha256=ETEzH2F-mTtwTAi7LCmxCxvFIQDNSEPJ50uX-olR56E,1501
3
+ lfx_confluence/client.py,sha256=o_E2TQNQUSqRnkvU9MEvBFwyYaCNfeyP_XPnuH27dkw,6398
4
+ lfx_confluence/extension.json,sha256=p75xs6SakTmek5v67-433RNsyhBYrRc09Te443xhIBk,560
5
+ lfx_confluence/manifest.py,sha256=vVVWvLTdaEXirt539rQW1mCK3Dt9Y27V6G0xZ5k7Gi8,1117
6
+ lfx_confluence/schema.py,sha256=S0OurMsIE36JILNZ6g7qwbKe42ty8B6WdCPcKhEr7XA,6285
7
+ lfx_confluence/components/__init__.py,sha256=xMJwKXJ_-DRq3wc-KogCLYqgvdpizDsRYqftP4EYXas,41
8
+ lfx_confluence/components/confluence_integrations/__init__.py,sha256=ZSCuI5xVCmVH-w5h4lY1mGuOqieo6cPgyqw8Gzoutho,582
9
+ lfx_confluence/components/confluence_integrations/actions.py,sha256=b65s2dMZForPTDkv3diG0QDNUWe9bZBq3bwrmCfWLGo,6624
10
+ lfx_confluence/components/confluence_integrations/capabilities.v1.json,sha256=0w1iE89FTGBoQokbW8vRJ21er-_PPFP_9OUb-upiRpc,4263
11
+ lfx_confluence/components/confluence_integrations/triggers.py,sha256=TjBeSWb-nz_Il6ZLKsiPicQbonyuixTvqiVlifCVVCg,1541
12
+ lfx_confluence-0.1.0.dist-info/METADATA,sha256=GcnyOoXJJT5qoU0CD64wv-4NJZFrVj3rdxX1Y-jWvWs,1138
13
+ lfx_confluence-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
14
+ lfx_confluence-0.1.0.dist-info/entry_points.txt,sha256=dzE4uxeVnPrX4nPvK2AfAOzf9s2OAi8TI0bQxoW83fE,154
15
+ lfx_confluence-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [langflow.extensions]
2
+ lfx-confluence = lfx_confluence
3
+
4
+ [langflow.trigger_providers]
5
+ confluence = langflow.services.triggers.providers.confluence:PROVIDER