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.
- lfx_confluence/__init__.py +1 -0
- lfx_confluence/base.py +44 -0
- lfx_confluence/client.py +154 -0
- lfx_confluence/components/__init__.py +1 -0
- lfx_confluence/components/confluence_integrations/__init__.py +18 -0
- lfx_confluence/components/confluence_integrations/actions.py +156 -0
- lfx_confluence/components/confluence_integrations/capabilities.v1.json +160 -0
- lfx_confluence/components/confluence_integrations/triggers.py +41 -0
- lfx_confluence/extension.json +25 -0
- lfx_confluence/manifest.py +31 -0
- lfx_confluence/schema.py +172 -0
- lfx_confluence-0.1.0.dist-info/METADATA +17 -0
- lfx_confluence-0.1.0.dist-info/RECORD +15 -0
- lfx_confluence-0.1.0.dist-info/WHEEL +4 -0
- lfx_confluence-0.1.0.dist-info/entry_points.txt +5 -0
|
@@ -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
|
+
}
|
lfx_confluence/client.py
ADDED
|
@@ -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
|
+
)
|
lfx_confluence/schema.py
ADDED
|
@@ -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,,
|