agentgraph-connector-slack 0.5.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.
- agentgraph_connector_slack/__init__.py +608 -0
- agentgraph_connector_slack/auth.py +673 -0
- agentgraph_connector_slack/slack-app-manifest.json +37 -0
- agentgraph_connector_slack-0.5.0.dist-info/METADATA +7 -0
- agentgraph_connector_slack-0.5.0.dist-info/RECORD +8 -0
- agentgraph_connector_slack-0.5.0.dist-info/WHEEL +5 -0
- agentgraph_connector_slack-0.5.0.dist-info/entry_points.txt +2 -0
- agentgraph_connector_slack-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
"""Slack connector with OAuth and browser-session authentication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from datetime import UTC, datetime, timedelta
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from agentgraph.connectors.base import (
|
|
13
|
+
BaseConnector,
|
|
14
|
+
ConnectorAccount,
|
|
15
|
+
EdgeRecord,
|
|
16
|
+
EntityBatch,
|
|
17
|
+
EntityRecord,
|
|
18
|
+
FetchPolicy,
|
|
19
|
+
PersonRecord,
|
|
20
|
+
ResourceType,
|
|
21
|
+
SourceReference,
|
|
22
|
+
get_known_channel_syncs,
|
|
23
|
+
)
|
|
24
|
+
from agentgraph.graph.upsert import upsert_batch
|
|
25
|
+
from agentgraph_connector_slack.auth import (
|
|
26
|
+
SlackBrowserCredentials,
|
|
27
|
+
account_id_for_team,
|
|
28
|
+
list_slack_accounts,
|
|
29
|
+
load_slack_creds,
|
|
30
|
+
slack_headers,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
SLACK_API = "https://slack.com/api"
|
|
36
|
+
_STALE_AFTER = 5 * 60 # 5 minutes
|
|
37
|
+
_PADDING_MESSAGES = 20 # messages to fetch on first visit as immediate context
|
|
38
|
+
_SLACK_CHANNEL_URL_RE = re.compile(
|
|
39
|
+
r"https://app\.slack\.com/client/(?P<workspace_id>[A-Z0-9]+)/(?P<channel_id>[A-Z0-9]+)"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _team_id_from_token(account_id: str | None = None) -> str | None:
|
|
44
|
+
"""Extract the Slack team ID from the stored credentials."""
|
|
45
|
+
try:
|
|
46
|
+
creds = load_slack_creds(account_id)
|
|
47
|
+
except RuntimeError:
|
|
48
|
+
return None
|
|
49
|
+
if creds.team_id:
|
|
50
|
+
return creds.team_id
|
|
51
|
+
if not isinstance(creds, SlackBrowserCredentials):
|
|
52
|
+
return None
|
|
53
|
+
parts = creds.xoxc_token.split("-")
|
|
54
|
+
return parts[1] if len(parts) >= 2 else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _channel_ref(team_id: str, channel_id: str) -> str:
|
|
58
|
+
return f"{team_id}/{channel_id}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _message_ref(team_id: str, channel_id: str, ts: str) -> str:
|
|
62
|
+
return f"{team_id}/{channel_id}:{ts}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _user_ref(team_id: str, user_id: str) -> str:
|
|
66
|
+
return f"{team_id}/{user_id}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _split_channel_ref(resource_id: str) -> tuple[str, str]:
|
|
70
|
+
team_id, _, channel_id = resource_id.partition("/")
|
|
71
|
+
if not team_id or not channel_id:
|
|
72
|
+
raise ValueError(f"Slack resource must be workspace-qualified: {resource_id}")
|
|
73
|
+
return team_id, channel_id
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _normalise_channel_ref(resource_id: str, account_id: str | None = None) -> str:
|
|
77
|
+
if "/" in resource_id:
|
|
78
|
+
return resource_id
|
|
79
|
+
team_id = _team_id_from_token(account_id)
|
|
80
|
+
if not team_id:
|
|
81
|
+
raise ValueError(f"Slack resource must be workspace-qualified: {resource_id}")
|
|
82
|
+
return _channel_ref(team_id, resource_id)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def _api_get(
|
|
86
|
+
client: httpx.AsyncClient,
|
|
87
|
+
method: str,
|
|
88
|
+
account_id: str | None = None,
|
|
89
|
+
**params: Any,
|
|
90
|
+
) -> dict[str, Any]:
|
|
91
|
+
for attempt in range(2):
|
|
92
|
+
resp = await client.get(
|
|
93
|
+
f"{SLACK_API}/{method}",
|
|
94
|
+
headers=await slack_headers(
|
|
95
|
+
account_id,
|
|
96
|
+
force_refresh=attempt == 1,
|
|
97
|
+
client=client,
|
|
98
|
+
),
|
|
99
|
+
params=params,
|
|
100
|
+
)
|
|
101
|
+
resp.raise_for_status()
|
|
102
|
+
data: dict[str, Any] = resp.json()
|
|
103
|
+
if data.get("ok"):
|
|
104
|
+
return data
|
|
105
|
+
if data.get("error") != "token_expired" or attempt == 1:
|
|
106
|
+
raise RuntimeError(f"Slack API error on {method}: {data.get('error', 'unknown')}")
|
|
107
|
+
raise RuntimeError(f"Slack API error on {method}: token_expired")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def _fetch_channel_info(client: httpx.AsyncClient, channel_id: str, account_id: str | None = None) -> dict[str, Any]:
|
|
111
|
+
data = await _api_get(client, "conversations.info", account_id=account_id, channel=channel_id)
|
|
112
|
+
return data.get("channel", {}) # type: ignore[return-value]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def _fetch_user(client: httpx.AsyncClient, user_id: str, account_id: str | None = None) -> dict[str, Any]:
|
|
116
|
+
data = await _api_get(client, "users.info", account_id=account_id, user=user_id)
|
|
117
|
+
return data.get("user", {}) # type: ignore[return-value]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _ts_to_dt(ts: str) -> datetime:
|
|
121
|
+
return datetime.fromtimestamp(float(ts), tz=UTC)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _parse_mentions(text: str) -> list[str]:
|
|
125
|
+
"""Extract <@UXXXXXXX> user IDs from message text."""
|
|
126
|
+
import re
|
|
127
|
+
return re.findall(r"<@([A-Z0-9]+)>", text)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parse_channel_mentions(text: str) -> list[str]:
|
|
131
|
+
"""Extract <#CXXXXXXX> channel IDs from message text."""
|
|
132
|
+
import re
|
|
133
|
+
return re.findall(r"<#([A-Z0-9]+)(?:\|[^>]*)?>", text)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class SlackConnector(BaseConnector):
|
|
137
|
+
source = "slack"
|
|
138
|
+
fetch_policy = FetchPolicy(stale_after_seconds=_STALE_AFTER)
|
|
139
|
+
poll_interval: timedelta | None = timedelta(minutes=5) # type: ignore[assignment]
|
|
140
|
+
url_patterns = ["https://app.slack.com/*"]
|
|
141
|
+
auth_label = "slack"
|
|
142
|
+
auth_description = "Slack workspace channels and DMs: Channel and Message entities with thread replies, authors, and user/channel mentions."
|
|
143
|
+
onboard_prompt = "Set up Slack?"
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def run_auth_flow(
|
|
147
|
+
cls,
|
|
148
|
+
account_id: str | None = None,
|
|
149
|
+
add: bool = False,
|
|
150
|
+
args: list[str] | None = None,
|
|
151
|
+
) -> None:
|
|
152
|
+
from agentgraph_connector_slack.auth import run_interactive_auth_flow
|
|
153
|
+
|
|
154
|
+
if args:
|
|
155
|
+
cls.run_auth_flow_with_args(args, account_id=account_id, add=add)
|
|
156
|
+
return
|
|
157
|
+
run_interactive_auth_flow(account_id=account_id, add=add)
|
|
158
|
+
|
|
159
|
+
@classmethod
|
|
160
|
+
def run_auth_flow_with_args(
|
|
161
|
+
cls,
|
|
162
|
+
args: list[str],
|
|
163
|
+
account_id: str | None = None,
|
|
164
|
+
add: bool = False,
|
|
165
|
+
) -> None:
|
|
166
|
+
from agentgraph_connector_slack.auth import (
|
|
167
|
+
run_cookie_flow,
|
|
168
|
+
run_guided_oauth_flow,
|
|
169
|
+
run_interactive_auth_flow,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
method: str | None = None
|
|
173
|
+
client_id: str | None = None
|
|
174
|
+
xoxc_token: str | None = None
|
|
175
|
+
d_cookie: str | None = None
|
|
176
|
+
index = 0
|
|
177
|
+
while index < len(args):
|
|
178
|
+
arg = args[index]
|
|
179
|
+
if arg in {"--method", "--client-id", "--xoxc-token", "--d-cookie"}:
|
|
180
|
+
if index + 1 >= len(args):
|
|
181
|
+
raise ValueError(f"{arg} requires a value")
|
|
182
|
+
value = args[index + 1]
|
|
183
|
+
index += 1
|
|
184
|
+
elif any(arg.startswith(f"{option}=") for option in (
|
|
185
|
+
"--method", "--client-id", "--xoxc-token", "--d-cookie"
|
|
186
|
+
)):
|
|
187
|
+
option, value = arg.split("=", 1)
|
|
188
|
+
arg = option
|
|
189
|
+
else:
|
|
190
|
+
raise ValueError(f"Unknown Slack authentication option: {arg}")
|
|
191
|
+
if arg == "--method":
|
|
192
|
+
method = value
|
|
193
|
+
elif arg == "--client-id":
|
|
194
|
+
client_id = value.strip()
|
|
195
|
+
if not client_id:
|
|
196
|
+
raise ValueError("--client-id requires a non-empty value")
|
|
197
|
+
elif arg == "--xoxc-token":
|
|
198
|
+
xoxc_token = value
|
|
199
|
+
else:
|
|
200
|
+
d_cookie = value
|
|
201
|
+
index += 1
|
|
202
|
+
|
|
203
|
+
if method not in {None, "oauth", "browser"}:
|
|
204
|
+
raise ValueError("Slack auth method must be 'oauth' or 'browser'")
|
|
205
|
+
browser_options = xoxc_token is not None or d_cookie is not None
|
|
206
|
+
if method == "oauth" and browser_options:
|
|
207
|
+
raise ValueError("--xoxc-token and --d-cookie cannot be used with --method oauth")
|
|
208
|
+
if method == "browser" and client_id is not None:
|
|
209
|
+
raise ValueError("--client-id cannot be used with --method browser")
|
|
210
|
+
selected_method = method or (
|
|
211
|
+
"browser" if browser_options else "oauth" if client_id is not None else None
|
|
212
|
+
)
|
|
213
|
+
if selected_method is None:
|
|
214
|
+
run_interactive_auth_flow(account_id=account_id, add=add)
|
|
215
|
+
return
|
|
216
|
+
if selected_method == "browser":
|
|
217
|
+
run_cookie_flow(
|
|
218
|
+
account_id=account_id,
|
|
219
|
+
add=add,
|
|
220
|
+
xoxc_token=xoxc_token,
|
|
221
|
+
d_cookie=d_cookie,
|
|
222
|
+
)
|
|
223
|
+
return
|
|
224
|
+
run_guided_oauth_flow(account_id=account_id, add=add, client_id=client_id)
|
|
225
|
+
|
|
226
|
+
@classmethod
|
|
227
|
+
def get_authenticated_user(cls) -> str | None:
|
|
228
|
+
try:
|
|
229
|
+
creds = load_slack_creds()
|
|
230
|
+
if creds.team_name and creds.user_id:
|
|
231
|
+
return f"{creds.team_name} / {creds.user_id}"
|
|
232
|
+
return creds.user_id
|
|
233
|
+
except Exception:
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
@classmethod
|
|
237
|
+
def list_accounts(cls) -> list[ConnectorAccount]:
|
|
238
|
+
return [
|
|
239
|
+
ConnectorAccount(
|
|
240
|
+
account_id=str(account["account_id"]),
|
|
241
|
+
label=str(account["label"]),
|
|
242
|
+
auth_group=cls.auth_label or cls.source,
|
|
243
|
+
source=cls.source,
|
|
244
|
+
user_id=account.get("user_id"),
|
|
245
|
+
workspace_id=account.get("team_id"),
|
|
246
|
+
email=account.get("email"),
|
|
247
|
+
auth_method=account.get("auth_method"),
|
|
248
|
+
)
|
|
249
|
+
for account in list_slack_accounts()
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
@classmethod
|
|
253
|
+
async def verify_auth(cls, account_id: str | None = None) -> tuple[str, str | None]:
|
|
254
|
+
try:
|
|
255
|
+
credentials = load_slack_creds(account_id)
|
|
256
|
+
except RuntimeError:
|
|
257
|
+
return ("missing", None)
|
|
258
|
+
except Exception as exc:
|
|
259
|
+
return ("invalid", str(exc))
|
|
260
|
+
try:
|
|
261
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
262
|
+
data = await _api_get(client, "auth.test", account_id=account_id)
|
|
263
|
+
except Exception as exc:
|
|
264
|
+
return ("invalid", str(exc))
|
|
265
|
+
team_name = data.get("team") or credentials.team_name or credentials.team_id
|
|
266
|
+
user_id = data.get("user_id") or credentials.user_id
|
|
267
|
+
detail = f"{team_name} / {user_id}" if team_name and user_id else user_id or team_name
|
|
268
|
+
return ("ok", str(detail) if detail else "authenticated")
|
|
269
|
+
|
|
270
|
+
@classmethod
|
|
271
|
+
def current_user_ids(cls) -> list[str]:
|
|
272
|
+
ids: list[str] = []
|
|
273
|
+
for account in list_slack_accounts():
|
|
274
|
+
team_id = account.get("team_id")
|
|
275
|
+
user_id = account.get("user_id")
|
|
276
|
+
if team_id and user_id:
|
|
277
|
+
ids.append(f"slack:{team_id}/{user_id}")
|
|
278
|
+
return ids
|
|
279
|
+
|
|
280
|
+
def can_handle(self, url: str) -> bool:
|
|
281
|
+
return self.resolve_url(url) is not None
|
|
282
|
+
|
|
283
|
+
def resolve_url(self, url: str) -> SourceReference | None:
|
|
284
|
+
match = _SLACK_CHANNEL_URL_RE.match(url)
|
|
285
|
+
if match is None:
|
|
286
|
+
return None
|
|
287
|
+
return SourceReference(
|
|
288
|
+
source=self.source,
|
|
289
|
+
resource_type="channel",
|
|
290
|
+
resource_id=f"{match.group('workspace_id')}/{match.group('channel_id')}",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
async def fetch(
|
|
294
|
+
self,
|
|
295
|
+
resource_type: ResourceType,
|
|
296
|
+
resource_id: str,
|
|
297
|
+
meta: dict[str, str] | None = None,
|
|
298
|
+
account_id: str | None = None,
|
|
299
|
+
) -> EntityBatch:
|
|
300
|
+
last_sync = await self.last_synced_at(resource_id)
|
|
301
|
+
decision = self.fetch_policy.decide(last_sync)
|
|
302
|
+
|
|
303
|
+
if decision == FetchPolicy.FRESH:
|
|
304
|
+
logger.debug("slack/%s is fresh — updating last_accessed only", resource_id)
|
|
305
|
+
await _touch_last_accessed(resource_id)
|
|
306
|
+
return EntityBatch()
|
|
307
|
+
|
|
308
|
+
oldest: str | None = None
|
|
309
|
+
if decision == FetchPolicy.INCREMENTAL and last_sync:
|
|
310
|
+
oldest = str(last_sync.timestamp())
|
|
311
|
+
|
|
312
|
+
resource_id = _normalise_channel_ref(resource_id, account_id)
|
|
313
|
+
team_id, _ = _split_channel_ref(resource_id)
|
|
314
|
+
selected_account_id = account_id or account_id_for_team(team_id)
|
|
315
|
+
logger.info("Fetching Slack channel %s (policy=%s)", resource_id, decision)
|
|
316
|
+
batch = await _fetch_channel(resource_id, oldest=oldest, account_id=selected_account_id)
|
|
317
|
+
await upsert_batch(batch)
|
|
318
|
+
return batch
|
|
319
|
+
|
|
320
|
+
async def poll(
|
|
321
|
+
self,
|
|
322
|
+
cursor: dict[str, Any],
|
|
323
|
+
account_id: str | None = None,
|
|
324
|
+
) -> tuple[EntityBatch, dict[str, Any]]:
|
|
325
|
+
channel_rows = await get_known_channel_syncs("slack", account_id=account_id)
|
|
326
|
+
combined = EntityBatch()
|
|
327
|
+
for channel_id, synced_at in channel_rows:
|
|
328
|
+
oldest = str(synced_at.timestamp()) if synced_at else None
|
|
329
|
+
try:
|
|
330
|
+
batch = await _fetch_channel(channel_id, oldest=oldest, account_id=account_id)
|
|
331
|
+
combined.entities.extend(batch.entities)
|
|
332
|
+
combined.persons.extend(batch.persons)
|
|
333
|
+
combined.edges.extend(batch.edges)
|
|
334
|
+
except Exception:
|
|
335
|
+
logger.exception("slack poll: failed to fetch channel %s", channel_id)
|
|
336
|
+
return combined, cursor
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
async def _touch_last_accessed(channel_id: str) -> None:
|
|
340
|
+
from agentgraph.core.context import get_backend
|
|
341
|
+
|
|
342
|
+
backend = get_backend()
|
|
343
|
+
entity = await backend.get_entity_by_platform("slack", channel_id)
|
|
344
|
+
if entity:
|
|
345
|
+
# Re-upsert the same entity to bump last_accessed via the normal path.
|
|
346
|
+
# The cheapest way is just a stub upsert which only updates last_accessed.
|
|
347
|
+
await backend.upsert_stub_entity("Channel", "slack", channel_id)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
async def _fetch_thread_replies(
|
|
351
|
+
client: httpx.AsyncClient,
|
|
352
|
+
channel_ref: str,
|
|
353
|
+
thread_ts: str,
|
|
354
|
+
entities: list[EntityRecord],
|
|
355
|
+
persons: list[PersonRecord],
|
|
356
|
+
edges: list[EdgeRecord],
|
|
357
|
+
seen_users: set[str],
|
|
358
|
+
team_id: str | None = None,
|
|
359
|
+
account_id: str | None = None,
|
|
360
|
+
) -> None:
|
|
361
|
+
"""Fetch replies to a threaded message and add them to the batch in-place."""
|
|
362
|
+
if team_id is None:
|
|
363
|
+
return
|
|
364
|
+
_, channel_id = _split_channel_ref(channel_ref)
|
|
365
|
+
try:
|
|
366
|
+
data = await _api_get(
|
|
367
|
+
client, "conversations.replies", account_id=account_id, channel=channel_id, ts=thread_ts
|
|
368
|
+
)
|
|
369
|
+
except Exception as exc:
|
|
370
|
+
logger.warning("Could not fetch replies for %s:%s: %s", channel_id, thread_ts, exc)
|
|
371
|
+
return
|
|
372
|
+
|
|
373
|
+
reply_messages: list[dict[str, Any]] = data.get("messages", [])
|
|
374
|
+
|
|
375
|
+
for reply in reply_messages:
|
|
376
|
+
ts: str = reply.get("ts", "")
|
|
377
|
+
if not ts or ts == thread_ts:
|
|
378
|
+
continue # skip the parent message Slack echoes back
|
|
379
|
+
|
|
380
|
+
user_id: str = reply.get("user", "")
|
|
381
|
+
text: str = reply.get("text", "")
|
|
382
|
+
|
|
383
|
+
reply_meta: dict[str, Any] = {"team_id": team_id, "channel_id": channel_id, "ts": ts, "thread_ts": thread_ts}
|
|
384
|
+
if team_id:
|
|
385
|
+
ts_compact = ts.replace(".", "")
|
|
386
|
+
reply_meta["web_url"] = f"https://app.slack.com/client/{team_id}/{channel_id}/p{ts_compact}"
|
|
387
|
+
if account_id:
|
|
388
|
+
reply_meta["account_id"] = account_id
|
|
389
|
+
entities.append(EntityRecord(
|
|
390
|
+
entity_type="Message",
|
|
391
|
+
platform="slack",
|
|
392
|
+
platform_entity_id=_message_ref(team_id, channel_id, ts),
|
|
393
|
+
content=text,
|
|
394
|
+
created_at=_ts_to_dt(ts),
|
|
395
|
+
updated_at=_ts_to_dt(ts),
|
|
396
|
+
metadata=reply_meta,
|
|
397
|
+
))
|
|
398
|
+
|
|
399
|
+
edges.append(EdgeRecord(
|
|
400
|
+
edge_type="replied_to",
|
|
401
|
+
source_platform_entity_id=_message_ref(team_id, channel_id, ts),
|
|
402
|
+
target_platform_entity_id=_message_ref(team_id, channel_id, thread_ts),
|
|
403
|
+
platform="slack",
|
|
404
|
+
))
|
|
405
|
+
edges.append(EdgeRecord(
|
|
406
|
+
edge_type="posted_in",
|
|
407
|
+
source_platform_entity_id=_message_ref(team_id, channel_id, ts),
|
|
408
|
+
target_platform_entity_id=channel_ref,
|
|
409
|
+
platform="slack",
|
|
410
|
+
))
|
|
411
|
+
|
|
412
|
+
if user_id and user_id not in seen_users:
|
|
413
|
+
seen_users.add(user_id)
|
|
414
|
+
try:
|
|
415
|
+
user_info = await _fetch_user(client, user_id, account_id=account_id)
|
|
416
|
+
profile = user_info.get("profile", {})
|
|
417
|
+
persons.append(PersonRecord(
|
|
418
|
+
platform="slack",
|
|
419
|
+
platform_user_id=_user_ref(team_id, user_id),
|
|
420
|
+
platform_username=user_info.get("name"),
|
|
421
|
+
canonical_email=profile.get("email") or None,
|
|
422
|
+
display_name=profile.get("real_name") or None,
|
|
423
|
+
))
|
|
424
|
+
except Exception:
|
|
425
|
+
logger.debug("Could not fetch user %s", user_id)
|
|
426
|
+
|
|
427
|
+
if user_id:
|
|
428
|
+
edges.append(EdgeRecord(
|
|
429
|
+
edge_type="authored",
|
|
430
|
+
source_platform_user_id=_user_ref(team_id, user_id),
|
|
431
|
+
target_platform_entity_id=_message_ref(team_id, channel_id, ts),
|
|
432
|
+
platform="slack",
|
|
433
|
+
))
|
|
434
|
+
|
|
435
|
+
for mentioned_id in _parse_mentions(text):
|
|
436
|
+
if mentioned_id not in seen_users:
|
|
437
|
+
seen_users.add(mentioned_id)
|
|
438
|
+
try:
|
|
439
|
+
user_info = await _fetch_user(client, mentioned_id, account_id=account_id)
|
|
440
|
+
profile = user_info.get("profile", {})
|
|
441
|
+
persons.append(PersonRecord(
|
|
442
|
+
platform="slack",
|
|
443
|
+
platform_user_id=_user_ref(team_id, mentioned_id),
|
|
444
|
+
platform_username=user_info.get("name"),
|
|
445
|
+
canonical_email=profile.get("email") or None,
|
|
446
|
+
display_name=profile.get("real_name") or None,
|
|
447
|
+
))
|
|
448
|
+
except Exception:
|
|
449
|
+
logger.debug("Could not fetch user %s", mentioned_id)
|
|
450
|
+
edges.append(EdgeRecord(
|
|
451
|
+
edge_type="mentions",
|
|
452
|
+
source_platform_entity_id=_message_ref(team_id, channel_id, ts),
|
|
453
|
+
target_platform_user_id=_user_ref(team_id, mentioned_id),
|
|
454
|
+
platform="slack",
|
|
455
|
+
))
|
|
456
|
+
|
|
457
|
+
for mentioned_channel_id in _parse_channel_mentions(text):
|
|
458
|
+
edges.append(EdgeRecord(
|
|
459
|
+
edge_type="mentions",
|
|
460
|
+
source_platform_entity_id=_message_ref(team_id, channel_id, ts),
|
|
461
|
+
target_platform_entity_id=_channel_ref(team_id, mentioned_channel_id),
|
|
462
|
+
platform="slack",
|
|
463
|
+
))
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
async def _fetch_channel(channel_ref: str, oldest: str | None = None, account_id: str | None = None) -> EntityBatch:
|
|
467
|
+
entities: list[EntityRecord] = []
|
|
468
|
+
persons: list[PersonRecord] = []
|
|
469
|
+
edges: list[EdgeRecord] = []
|
|
470
|
+
seen_users: set[str] = set()
|
|
471
|
+
|
|
472
|
+
team_id, channel_id = _split_channel_ref(channel_ref)
|
|
473
|
+
|
|
474
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
475
|
+
# Channel entity
|
|
476
|
+
channel_info = await _fetch_channel_info(client, channel_id, account_id=account_id)
|
|
477
|
+
channel_name = channel_info.get("name", channel_id)
|
|
478
|
+
|
|
479
|
+
channel_meta: dict[str, Any] = {"team_id": team_id, "channel_id": channel_id}
|
|
480
|
+
if team_id:
|
|
481
|
+
channel_meta["web_url"] = f"https://app.slack.com/client/{team_id}/{channel_id}"
|
|
482
|
+
if account_id:
|
|
483
|
+
channel_meta["account_id"] = account_id
|
|
484
|
+
channel_entity = EntityRecord(
|
|
485
|
+
entity_type="Channel",
|
|
486
|
+
platform="slack",
|
|
487
|
+
platform_entity_id=channel_ref,
|
|
488
|
+
title=f"#{channel_name}",
|
|
489
|
+
updated_at=datetime.now(UTC),
|
|
490
|
+
metadata=channel_meta,
|
|
491
|
+
)
|
|
492
|
+
entities.append(channel_entity)
|
|
493
|
+
|
|
494
|
+
# Fetch messages
|
|
495
|
+
params: dict[str, Any] = {"channel": channel_id, "limit": 100}
|
|
496
|
+
if oldest:
|
|
497
|
+
params["oldest"] = oldest
|
|
498
|
+
|
|
499
|
+
data = await _api_get(client, "conversations.history", account_id=account_id, **params)
|
|
500
|
+
messages: list[dict[str, Any]] = data.get("messages", [])
|
|
501
|
+
|
|
502
|
+
for msg in messages:
|
|
503
|
+
user_id: str = msg.get("user", "")
|
|
504
|
+
ts: str = msg.get("ts", "")
|
|
505
|
+
text: str = msg.get("text", "")
|
|
506
|
+
thread_ts: str | None = msg.get("thread_ts")
|
|
507
|
+
reply_count: int = msg.get("reply_count", 0)
|
|
508
|
+
|
|
509
|
+
if not ts:
|
|
510
|
+
continue
|
|
511
|
+
|
|
512
|
+
msg_meta: dict[str, Any] = {"team_id": team_id, "channel_id": channel_id, "ts": ts}
|
|
513
|
+
if team_id:
|
|
514
|
+
ts_compact = ts.replace(".", "")
|
|
515
|
+
msg_meta["web_url"] = f"https://app.slack.com/client/{team_id}/{channel_id}/p{ts_compact}"
|
|
516
|
+
if account_id:
|
|
517
|
+
msg_meta["account_id"] = account_id
|
|
518
|
+
msg_entity = EntityRecord(
|
|
519
|
+
entity_type="Message",
|
|
520
|
+
platform="slack",
|
|
521
|
+
platform_entity_id=_message_ref(team_id, channel_id, ts),
|
|
522
|
+
content=text,
|
|
523
|
+
created_at=_ts_to_dt(ts),
|
|
524
|
+
updated_at=_ts_to_dt(ts),
|
|
525
|
+
metadata=msg_meta,
|
|
526
|
+
)
|
|
527
|
+
entities.append(msg_entity)
|
|
528
|
+
|
|
529
|
+
# posted_in edge: message → channel
|
|
530
|
+
edges.append(EdgeRecord(
|
|
531
|
+
edge_type="posted_in",
|
|
532
|
+
source_platform_entity_id=_message_ref(team_id, channel_id, ts),
|
|
533
|
+
target_platform_entity_id=channel_ref,
|
|
534
|
+
platform="slack",
|
|
535
|
+
))
|
|
536
|
+
|
|
537
|
+
# Thread reply edge (this message is itself a reply)
|
|
538
|
+
if thread_ts and thread_ts != ts:
|
|
539
|
+
edges.append(EdgeRecord(
|
|
540
|
+
edge_type="replied_to",
|
|
541
|
+
source_platform_entity_id=_message_ref(team_id, channel_id, ts),
|
|
542
|
+
target_platform_entity_id=_message_ref(team_id, channel_id, thread_ts),
|
|
543
|
+
platform="slack",
|
|
544
|
+
))
|
|
545
|
+
|
|
546
|
+
# Author
|
|
547
|
+
if user_id and user_id not in seen_users:
|
|
548
|
+
seen_users.add(user_id)
|
|
549
|
+
user_info = await _fetch_user(client, user_id, account_id=account_id)
|
|
550
|
+
profile = user_info.get("profile", {})
|
|
551
|
+
persons.append(PersonRecord(
|
|
552
|
+
platform="slack",
|
|
553
|
+
platform_user_id=_user_ref(team_id, user_id),
|
|
554
|
+
platform_username=user_info.get("name"),
|
|
555
|
+
canonical_email=profile.get("email") or None,
|
|
556
|
+
display_name=profile.get("real_name") or None,
|
|
557
|
+
))
|
|
558
|
+
|
|
559
|
+
if user_id:
|
|
560
|
+
edges.append(EdgeRecord(
|
|
561
|
+
edge_type="authored",
|
|
562
|
+
source_platform_user_id=_user_ref(team_id, user_id),
|
|
563
|
+
target_platform_entity_id=_message_ref(team_id, channel_id, ts),
|
|
564
|
+
platform="slack",
|
|
565
|
+
))
|
|
566
|
+
|
|
567
|
+
# User mention edges
|
|
568
|
+
for mentioned_id in _parse_mentions(text):
|
|
569
|
+
if mentioned_id not in seen_users:
|
|
570
|
+
seen_users.add(mentioned_id)
|
|
571
|
+
try:
|
|
572
|
+
user_info = await _fetch_user(client, mentioned_id, account_id=account_id)
|
|
573
|
+
profile = user_info.get("profile", {})
|
|
574
|
+
persons.append(PersonRecord(
|
|
575
|
+
platform="slack",
|
|
576
|
+
platform_user_id=_user_ref(team_id, mentioned_id),
|
|
577
|
+
platform_username=user_info.get("name"),
|
|
578
|
+
canonical_email=profile.get("email") or None,
|
|
579
|
+
display_name=profile.get("real_name") or None,
|
|
580
|
+
))
|
|
581
|
+
except Exception:
|
|
582
|
+
logger.debug("Could not fetch user %s", mentioned_id)
|
|
583
|
+
edges.append(EdgeRecord(
|
|
584
|
+
edge_type="mentions",
|
|
585
|
+
source_platform_entity_id=_message_ref(team_id, channel_id, ts),
|
|
586
|
+
target_platform_user_id=_user_ref(team_id, mentioned_id),
|
|
587
|
+
platform="slack",
|
|
588
|
+
))
|
|
589
|
+
|
|
590
|
+
# Channel mention edges
|
|
591
|
+
for mentioned_channel_id in _parse_channel_mentions(text):
|
|
592
|
+
edges.append(EdgeRecord(
|
|
593
|
+
edge_type="mentions",
|
|
594
|
+
source_platform_entity_id=_message_ref(team_id, channel_id, ts),
|
|
595
|
+
target_platform_entity_id=_channel_ref(team_id, mentioned_channel_id),
|
|
596
|
+
platform="slack",
|
|
597
|
+
))
|
|
598
|
+
|
|
599
|
+
# Fetch thread replies for messages that have them
|
|
600
|
+
if reply_count > 0 and not (thread_ts and thread_ts != ts):
|
|
601
|
+
await _fetch_thread_replies(
|
|
602
|
+
client, channel_ref, ts, entities, persons, edges, seen_users, team_id=team_id, account_id=account_id
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
batch = EntityBatch(entities=entities, persons=persons, edges=edges)
|
|
606
|
+
for entity in batch.entities[:]:
|
|
607
|
+
batch.add_stubs_from(entity)
|
|
608
|
+
return batch
|
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
"""Slack OAuth and browser-session credentials."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import hashlib
|
|
8
|
+
import os
|
|
9
|
+
import secrets
|
|
10
|
+
import time
|
|
11
|
+
import webbrowser
|
|
12
|
+
from datetime import UTC, datetime, timedelta
|
|
13
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Annotated, Any, Literal, cast
|
|
16
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
17
|
+
from weakref import WeakKeyDictionary
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
from pydantic import BaseModel, Field, TypeAdapter
|
|
21
|
+
|
|
22
|
+
SLACK_TOKEN_URL = "https://slack.com/api/oauth.v2.user.access"
|
|
23
|
+
SLACK_AUTHORIZE_URL = "https://slack.com/oauth/v2_user/authorize"
|
|
24
|
+
DEFAULT_REDIRECT_URI = "http://localhost:8766/slack/oauth/callback"
|
|
25
|
+
REQUIRED_SCOPES: frozenset[str] = frozenset({
|
|
26
|
+
"channels:history",
|
|
27
|
+
"channels:read",
|
|
28
|
+
"groups:history",
|
|
29
|
+
"groups:read",
|
|
30
|
+
"im:history",
|
|
31
|
+
"im:read",
|
|
32
|
+
"mpim:history",
|
|
33
|
+
"mpim:read",
|
|
34
|
+
"users:read",
|
|
35
|
+
})
|
|
36
|
+
OPTIONAL_SCOPES: frozenset[str] = frozenset({"users:read.email"})
|
|
37
|
+
REFRESH_WINDOW = timedelta(minutes=5)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SlackBrowserCredentials(BaseModel):
|
|
41
|
+
auth_method: Literal["browser"] = "browser"
|
|
42
|
+
xoxc_token: str
|
|
43
|
+
d_cookie: str
|
|
44
|
+
user_id: str | None = None
|
|
45
|
+
team_id: str | None = None
|
|
46
|
+
team_name: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SlackOAuthCredentials(BaseModel):
|
|
50
|
+
auth_method: Literal["oauth"] = "oauth"
|
|
51
|
+
access_token: str
|
|
52
|
+
refresh_token: str
|
|
53
|
+
expires_at: datetime
|
|
54
|
+
client_id: str
|
|
55
|
+
scopes: list[str]
|
|
56
|
+
user_id: str
|
|
57
|
+
team_id: str
|
|
58
|
+
team_name: str | None = None
|
|
59
|
+
email: str | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
SlackCredential = Annotated[
|
|
63
|
+
SlackBrowserCredentials | SlackOAuthCredentials,
|
|
64
|
+
Field(discriminator="auth_method"),
|
|
65
|
+
]
|
|
66
|
+
_CREDENTIAL_ADAPTER: TypeAdapter[SlackCredential] = TypeAdapter(SlackCredential)
|
|
67
|
+
|
|
68
|
+
# Backwards-compatible public name for callers that used the old model.
|
|
69
|
+
SlackCredentials = SlackBrowserCredentials
|
|
70
|
+
|
|
71
|
+
_refresh_locks: WeakKeyDictionary[
|
|
72
|
+
asyncio.AbstractEventLoop, dict[str, asyncio.Lock]
|
|
73
|
+
] = WeakKeyDictionary()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SlackOAuthCallback(BaseModel):
|
|
77
|
+
code: str | None = None
|
|
78
|
+
state: str | None = None
|
|
79
|
+
error: str | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parse_slack_credentials(raw: dict[str, Any]) -> SlackCredential:
|
|
83
|
+
"""Parse stored credentials, treating pre-discriminator records as browser auth."""
|
|
84
|
+
data = dict(raw)
|
|
85
|
+
data.setdefault("auth_method", "browser")
|
|
86
|
+
return _CREDENTIAL_ADAPTER.validate_python(data)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_slack_creds(account_id: str | None = None) -> SlackCredential:
|
|
90
|
+
from agentgraph.auth.credentials import load_platform_account
|
|
91
|
+
|
|
92
|
+
data = load_platform_account("slack", account_id)
|
|
93
|
+
if data is None:
|
|
94
|
+
raise RuntimeError("Slack credentials not configured. Run: agentgraph auth slack")
|
|
95
|
+
return parse_slack_credentials(data)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def list_slack_accounts() -> list[dict[str, str | None]]:
|
|
99
|
+
from agentgraph.auth.credentials import load_platform_accounts
|
|
100
|
+
|
|
101
|
+
results: list[dict[str, str | None]] = []
|
|
102
|
+
for raw in load_platform_accounts("slack"):
|
|
103
|
+
try:
|
|
104
|
+
creds = parse_slack_credentials(raw)
|
|
105
|
+
except Exception:
|
|
106
|
+
continue
|
|
107
|
+
team_id = creds.team_id
|
|
108
|
+
user_id = creds.user_id
|
|
109
|
+
account_id = str(
|
|
110
|
+
raw.get("account_id")
|
|
111
|
+
or (f"slack:{team_id}:{user_id}" if team_id and user_id else "slack")
|
|
112
|
+
)
|
|
113
|
+
label = creds.team_name or team_id or account_id
|
|
114
|
+
results.append({
|
|
115
|
+
"account_id": account_id,
|
|
116
|
+
"team_id": team_id,
|
|
117
|
+
"user_id": user_id,
|
|
118
|
+
"label": label,
|
|
119
|
+
"auth_method": creds.auth_method,
|
|
120
|
+
"email": creds.email if isinstance(creds, SlackOAuthCredentials) else None,
|
|
121
|
+
})
|
|
122
|
+
return results
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def account_id_for_team(team_id: str) -> str | None:
|
|
126
|
+
for account in list_slack_accounts():
|
|
127
|
+
if account.get("team_id") == team_id:
|
|
128
|
+
return str(account["account_id"])
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def validate_required_scopes(scopes: list[str] | str) -> list[str]:
|
|
133
|
+
granted = set(scopes.split(",") if isinstance(scopes, str) else scopes)
|
|
134
|
+
return sorted(REQUIRED_SCOPES - granted)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _oauth_values(data: dict[str, Any]) -> tuple[str, str, int, list[str]]:
|
|
138
|
+
authed_user = data.get("authed_user")
|
|
139
|
+
user_data = cast(dict[str, Any], authed_user) if isinstance(authed_user, dict) else {}
|
|
140
|
+
access_token = data.get("access_token") or user_data.get("access_token")
|
|
141
|
+
refresh_token = data.get("refresh_token") or user_data.get("refresh_token")
|
|
142
|
+
expires_in = data.get("expires_in") or user_data.get("expires_in")
|
|
143
|
+
raw_scopes = data.get("scope") or user_data.get("scope") or ""
|
|
144
|
+
if not isinstance(access_token, str) or not isinstance(refresh_token, str):
|
|
145
|
+
raise RuntimeError("Slack OAuth response did not include rotating user tokens")
|
|
146
|
+
if not isinstance(expires_in, int | float):
|
|
147
|
+
raise RuntimeError("Slack OAuth response did not include token expiry")
|
|
148
|
+
scopes = [scope for scope in str(raw_scopes).split(",") if scope]
|
|
149
|
+
return access_token, refresh_token, int(expires_in), scopes
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _pkce_pair() -> tuple[str, str]:
|
|
153
|
+
verifier = secrets.token_urlsafe(64)
|
|
154
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
155
|
+
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
156
|
+
return verifier, challenge
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _wait_for_oauth_callback(
|
|
160
|
+
redirect_uri: str,
|
|
161
|
+
*,
|
|
162
|
+
timeout_seconds: float = 300,
|
|
163
|
+
) -> SlackOAuthCallback:
|
|
164
|
+
parsed = urlparse(redirect_uri)
|
|
165
|
+
if parsed.scheme != "http" or parsed.hostname not in {"localhost", "127.0.0.1"}:
|
|
166
|
+
raise ValueError("Slack OAuth redirect must use http://localhost or http://127.0.0.1")
|
|
167
|
+
if parsed.port is None:
|
|
168
|
+
raise ValueError("Slack OAuth redirect must include a local port")
|
|
169
|
+
|
|
170
|
+
result: SlackOAuthCallback | None = None
|
|
171
|
+
expected_path = parsed.path or "/"
|
|
172
|
+
|
|
173
|
+
class CallbackHandler(BaseHTTPRequestHandler):
|
|
174
|
+
def do_GET(self) -> None: # noqa: N802
|
|
175
|
+
nonlocal result
|
|
176
|
+
request_url = urlparse(self.path)
|
|
177
|
+
if request_url.path != expected_path:
|
|
178
|
+
self.send_error(404)
|
|
179
|
+
return
|
|
180
|
+
query = parse_qs(request_url.query)
|
|
181
|
+
result = SlackOAuthCallback(
|
|
182
|
+
code=query.get("code", [None])[0],
|
|
183
|
+
state=query.get("state", [None])[0],
|
|
184
|
+
error=query.get("error", [None])[0],
|
|
185
|
+
)
|
|
186
|
+
body = b"Slack authorization received. You can close this window."
|
|
187
|
+
self.send_response(200)
|
|
188
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
189
|
+
self.send_header("Content-Length", str(len(body)))
|
|
190
|
+
self.end_headers()
|
|
191
|
+
self.wfile.write(body)
|
|
192
|
+
|
|
193
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
194
|
+
_ = (format, args)
|
|
195
|
+
|
|
196
|
+
deadline = time.monotonic() + timeout_seconds
|
|
197
|
+
with HTTPServer((parsed.hostname, parsed.port), CallbackHandler) as server:
|
|
198
|
+
while result is None:
|
|
199
|
+
remaining = deadline - time.monotonic()
|
|
200
|
+
if remaining <= 0:
|
|
201
|
+
raise TimeoutError(
|
|
202
|
+
"Timed out waiting five minutes for Slack authorization. "
|
|
203
|
+
"If you requested app approval, rerun this command after Slackbot "
|
|
204
|
+
"confirms approval."
|
|
205
|
+
)
|
|
206
|
+
server.timeout = remaining
|
|
207
|
+
server.handle_request()
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _exchange_oauth_code(
|
|
212
|
+
*,
|
|
213
|
+
code: str,
|
|
214
|
+
verifier: str,
|
|
215
|
+
client_id: str,
|
|
216
|
+
redirect_uri: str,
|
|
217
|
+
) -> dict[str, Any]:
|
|
218
|
+
response = httpx.post(
|
|
219
|
+
SLACK_TOKEN_URL,
|
|
220
|
+
data={
|
|
221
|
+
"grant_type": "authorization_code",
|
|
222
|
+
"code": code,
|
|
223
|
+
"code_verifier": verifier,
|
|
224
|
+
"client_id": client_id,
|
|
225
|
+
"redirect_uri": redirect_uri,
|
|
226
|
+
},
|
|
227
|
+
timeout=30,
|
|
228
|
+
)
|
|
229
|
+
response.raise_for_status()
|
|
230
|
+
data: dict[str, Any] = response.json()
|
|
231
|
+
if not data.get("ok"):
|
|
232
|
+
raise RuntimeError(f"Slack OAuth exchange failed: {data.get('error', 'unknown')}")
|
|
233
|
+
return data
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _manifest_text() -> str:
|
|
237
|
+
manifest_path = Path(__file__).with_name("slack-app-manifest.json")
|
|
238
|
+
return manifest_path.read_text().rstrip()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _manifest_block() -> str:
|
|
242
|
+
return f"AgentGraph Slack app manifest (JSON):\n\n{_manifest_text()}\n"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _admin_setup_instructions() -> str:
|
|
246
|
+
return (
|
|
247
|
+
"\nCreate the AgentGraph Slack app:\n\n"
|
|
248
|
+
" 1. Open https://api.slack.com/apps\n"
|
|
249
|
+
" 2. Choose Create New App > From an app manifest.\n"
|
|
250
|
+
" 3. Select the workspace you want AgentGraph to connect to.\n"
|
|
251
|
+
" 4. Select the JSON tab and paste the manifest printed below.\n"
|
|
252
|
+
" 5. Create and approve the app. Email access is optional.\n"
|
|
253
|
+
" 6. Open Basic Information > App Credentials and copy the Client ID.\n\n"
|
|
254
|
+
f"{_manifest_block()}"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _member_creation_instructions() -> str:
|
|
259
|
+
return (
|
|
260
|
+
"\nCheck whether you can create the AgentGraph app:\n\n"
|
|
261
|
+
" 1. Open https://api.slack.com/apps\n"
|
|
262
|
+
" 2. Choose Create New App > From an app manifest.\n"
|
|
263
|
+
" 3. At Pick a workspace, look for the workspace you want AgentGraph "
|
|
264
|
+
"to connect to.\n"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _member_visible_workspace_instructions() -> str:
|
|
269
|
+
return (
|
|
270
|
+
"\nCreate the AgentGraph Slack app:\n\n"
|
|
271
|
+
" 1. Select that workspace and continue.\n"
|
|
272
|
+
" 2. Select the JSON tab and paste the manifest printed below.\n"
|
|
273
|
+
" 3. Review the configuration and choose Create.\n"
|
|
274
|
+
" 4. Open Basic Information > App Credentials and copy the Client ID.\n\n"
|
|
275
|
+
f"{_manifest_block()}"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _member_missing_workspace_instructions() -> str:
|
|
280
|
+
return (
|
|
281
|
+
"\nThe workspace is not available in Slack's app creation list.\n\n"
|
|
282
|
+
"Ask a Workspace Owner or app manager either to give you permission to\n"
|
|
283
|
+
"create internal apps, or to create and approve AgentGraph using the\n"
|
|
284
|
+
"JSON manifest below.\n\n"
|
|
285
|
+
"Example request:\n\n"
|
|
286
|
+
"Hi, I'd like to connect AgentGraph to Slack. AgentGraph indexes Slack "
|
|
287
|
+
"conversations that I can access into a local knowledge graph. Could you "
|
|
288
|
+
"either give me permission to create an internal Slack app, or create and "
|
|
289
|
+
"approve AgentGraph using the JSON manifest below? The required user scopes "
|
|
290
|
+
"read public channels, private channels, DMs, group DMs, and member profiles. "
|
|
291
|
+
"Email access is optional. Once the app is approved, please send me its "
|
|
292
|
+
"Client ID. No client secret is needed. More information: "
|
|
293
|
+
"https://simonexmachina.github.io/agent-graph/\n\n"
|
|
294
|
+
f"{_manifest_block()}\n"
|
|
295
|
+
"Slack cannot offer app approval until the app exists in the target\n"
|
|
296
|
+
"workspace. When the admin sends the Client ID, rerun:\n\n"
|
|
297
|
+
" agentgraph auth slack --add --client-id <client-id>\n"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _authorization_instructions() -> str:
|
|
302
|
+
return (
|
|
303
|
+
"\nSlack may show one of these actions:\n\n"
|
|
304
|
+
" Allow\n"
|
|
305
|
+
" Continue authorization now.\n\n"
|
|
306
|
+
" Request approval\n"
|
|
307
|
+
" Submit the request and wait for a Workspace Owner or app manager.\n"
|
|
308
|
+
" For Message for your Slack Admin, you can use:\n\n"
|
|
309
|
+
" Hi, I'd like approval to connect AgentGraph to Slack. AgentGraph "
|
|
310
|
+
"creates a local, queryable graph of conversations I can already access "
|
|
311
|
+
"so my AI tools can search my work context. It requests read access to "
|
|
312
|
+
"public channels, private channels, DMs, group DMs, and member profiles; "
|
|
313
|
+
"email access is optional. More information: "
|
|
314
|
+
"https://simonexmachina.github.io/agent-graph/\n\n"
|
|
315
|
+
" Rerun this command after Slackbot confirms approval, then enter the\n"
|
|
316
|
+
" same Client ID.\n\n"
|
|
317
|
+
" Installation blocked\n"
|
|
318
|
+
" Contact a Workspace Owner or app manager. Workspace policy does not\n"
|
|
319
|
+
" allow members to request this app.\n"
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _available_oauth_client_id(account_id: str | None, *, add: bool) -> str | None:
|
|
324
|
+
if not add:
|
|
325
|
+
try:
|
|
326
|
+
existing = load_slack_creds(account_id)
|
|
327
|
+
except Exception:
|
|
328
|
+
existing = None
|
|
329
|
+
if isinstance(existing, SlackOAuthCredentials):
|
|
330
|
+
return existing.client_id
|
|
331
|
+
return os.environ.get("AGENTGRAPH_SLACK_CLIENT_ID", "").strip() or None
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _prompt_oauth_client_id() -> str:
|
|
335
|
+
import typer
|
|
336
|
+
|
|
337
|
+
while True:
|
|
338
|
+
client_id = typer.prompt("Slack app Client ID").strip()
|
|
339
|
+
if client_id:
|
|
340
|
+
return client_id
|
|
341
|
+
typer.echo("Slack app Client ID cannot be empty.")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def run_interactive_auth_flow(account_id: str | None = None, add: bool = False) -> None:
|
|
345
|
+
"""Prompt for a Slack authentication method and run the selected flow."""
|
|
346
|
+
import click
|
|
347
|
+
import typer
|
|
348
|
+
|
|
349
|
+
typer.echo(
|
|
350
|
+
"\nChoose how to connect Slack:\n"
|
|
351
|
+
" 1. Slack user OAuth with PKCE [recommended]\n"
|
|
352
|
+
" 2. Browser session credentials (xoxc token + d cookie) [fallback]\n"
|
|
353
|
+
)
|
|
354
|
+
choice = typer.prompt(
|
|
355
|
+
"Authentication method",
|
|
356
|
+
type=click.Choice(["1", "2"]),
|
|
357
|
+
default="1",
|
|
358
|
+
show_default=False,
|
|
359
|
+
)
|
|
360
|
+
if choice == "2":
|
|
361
|
+
run_cookie_flow(account_id=account_id, add=add)
|
|
362
|
+
return
|
|
363
|
+
run_guided_oauth_flow(account_id=account_id, add=add)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def run_guided_oauth_flow(
|
|
367
|
+
account_id: str | None = None,
|
|
368
|
+
add: bool = False,
|
|
369
|
+
*,
|
|
370
|
+
client_id: str | None = None,
|
|
371
|
+
) -> None:
|
|
372
|
+
"""Resolve an existing client or guide the user through Slack app setup."""
|
|
373
|
+
import click
|
|
374
|
+
import typer
|
|
375
|
+
|
|
376
|
+
resolved_client_id = client_id or _available_oauth_client_id(account_id, add=add)
|
|
377
|
+
if resolved_client_id is not None:
|
|
378
|
+
run_oauth_flow(account_id=account_id, add=add, client_id=resolved_client_id)
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
workspace_admin = typer.confirm(
|
|
382
|
+
"Do you have admin permission in the Slack workspace you want to connect?",
|
|
383
|
+
default=False,
|
|
384
|
+
)
|
|
385
|
+
if workspace_admin:
|
|
386
|
+
typer.echo(_admin_setup_instructions())
|
|
387
|
+
run_oauth_flow(
|
|
388
|
+
account_id=account_id,
|
|
389
|
+
add=add,
|
|
390
|
+
client_id=_prompt_oauth_client_id(),
|
|
391
|
+
)
|
|
392
|
+
return
|
|
393
|
+
|
|
394
|
+
typer.echo(
|
|
395
|
+
"\nHow would you like to continue?\n\n"
|
|
396
|
+
" 1. Enter a Client ID provided by a Slack admin\n"
|
|
397
|
+
" 2. Set up the AgentGraph Slack App\n"
|
|
398
|
+
)
|
|
399
|
+
choice = typer.prompt(
|
|
400
|
+
"Choice",
|
|
401
|
+
type=click.Choice(["1", "2"]),
|
|
402
|
+
default="1",
|
|
403
|
+
show_default=False,
|
|
404
|
+
)
|
|
405
|
+
if choice == "1":
|
|
406
|
+
run_oauth_flow(
|
|
407
|
+
account_id=account_id,
|
|
408
|
+
add=add,
|
|
409
|
+
client_id=_prompt_oauth_client_id(),
|
|
410
|
+
)
|
|
411
|
+
return
|
|
412
|
+
|
|
413
|
+
typer.echo(_member_creation_instructions())
|
|
414
|
+
workspace_visible = typer.confirm(
|
|
415
|
+
"Can you see the workspace you want in Slack's Pick a workspace list?",
|
|
416
|
+
default=False,
|
|
417
|
+
)
|
|
418
|
+
if not workspace_visible:
|
|
419
|
+
typer.echo(_member_missing_workspace_instructions())
|
|
420
|
+
return
|
|
421
|
+
typer.echo(_member_visible_workspace_instructions())
|
|
422
|
+
run_oauth_flow(
|
|
423
|
+
account_id=account_id,
|
|
424
|
+
add=add,
|
|
425
|
+
client_id=_prompt_oauth_client_id(),
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def run_oauth_flow(
|
|
430
|
+
account_id: str | None = None,
|
|
431
|
+
add: bool = False,
|
|
432
|
+
*,
|
|
433
|
+
client_id: str | None = None,
|
|
434
|
+
) -> None:
|
|
435
|
+
"""Authorize a Slack user with PKCE and store rotating credentials."""
|
|
436
|
+
import typer
|
|
437
|
+
|
|
438
|
+
resolved_client_id = client_id or _available_oauth_client_id(account_id, add=add)
|
|
439
|
+
if resolved_client_id is None:
|
|
440
|
+
resolved_client_id = _prompt_oauth_client_id()
|
|
441
|
+
verifier, challenge = _pkce_pair()
|
|
442
|
+
state = secrets.token_urlsafe(32)
|
|
443
|
+
authorize_url = f"{SLACK_AUTHORIZE_URL}?{urlencode({
|
|
444
|
+
'client_id': resolved_client_id,
|
|
445
|
+
'scope': ','.join(sorted(REQUIRED_SCOPES | OPTIONAL_SCOPES)),
|
|
446
|
+
'redirect_uri': DEFAULT_REDIRECT_URI,
|
|
447
|
+
'state': state,
|
|
448
|
+
'code_challenge': challenge,
|
|
449
|
+
'code_challenge_method': 'S256',
|
|
450
|
+
})}"
|
|
451
|
+
typer.echo(_authorization_instructions())
|
|
452
|
+
typer.echo(f"Opening Slack authorization in your browser:\n{authorize_url}")
|
|
453
|
+
webbrowser.open(authorize_url)
|
|
454
|
+
callback = _wait_for_oauth_callback(DEFAULT_REDIRECT_URI)
|
|
455
|
+
if not callback.state or not secrets.compare_digest(callback.state, state):
|
|
456
|
+
raise RuntimeError("Slack OAuth callback state did not match")
|
|
457
|
+
if callback.error:
|
|
458
|
+
raise RuntimeError(f"Slack OAuth authorization denied: {callback.error}")
|
|
459
|
+
if not callback.code:
|
|
460
|
+
raise RuntimeError("Slack OAuth callback did not include an authorization code")
|
|
461
|
+
|
|
462
|
+
data = _exchange_oauth_code(
|
|
463
|
+
code=callback.code,
|
|
464
|
+
verifier=verifier,
|
|
465
|
+
client_id=resolved_client_id,
|
|
466
|
+
redirect_uri=DEFAULT_REDIRECT_URI,
|
|
467
|
+
)
|
|
468
|
+
access_token, refresh_token, expires_in, scopes = _oauth_values(data)
|
|
469
|
+
missing_scopes = validate_required_scopes(scopes)
|
|
470
|
+
if missing_scopes:
|
|
471
|
+
raise RuntimeError(
|
|
472
|
+
"Slack authorization is missing required scopes: " + ", ".join(missing_scopes)
|
|
473
|
+
)
|
|
474
|
+
team = data.get("team")
|
|
475
|
+
team_data = cast(dict[str, Any], team) if isinstance(team, dict) else {}
|
|
476
|
+
authed_user = data.get("authed_user")
|
|
477
|
+
user_data = cast(dict[str, Any], authed_user) if isinstance(authed_user, dict) else {}
|
|
478
|
+
team_id = team_data.get("id")
|
|
479
|
+
user_id = user_data.get("id") or data.get("user_id")
|
|
480
|
+
if not isinstance(team_id, str) or not isinstance(user_id, str):
|
|
481
|
+
raise RuntimeError("Slack OAuth response did not identify the workspace and user")
|
|
482
|
+
|
|
483
|
+
email: str | None = None
|
|
484
|
+
if "users:read.email" in scopes:
|
|
485
|
+
try:
|
|
486
|
+
profile_response = httpx.get(
|
|
487
|
+
"https://slack.com/api/users.info",
|
|
488
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
489
|
+
params={"user": user_id},
|
|
490
|
+
timeout=10,
|
|
491
|
+
)
|
|
492
|
+
profile_data: dict[str, Any] = profile_response.json()
|
|
493
|
+
raw_user = profile_data.get("user")
|
|
494
|
+
user_profile = cast(dict[str, Any], raw_user) if isinstance(raw_user, dict) else {}
|
|
495
|
+
raw_profile = user_profile.get("profile")
|
|
496
|
+
profile = cast(dict[str, Any], raw_profile) if isinstance(raw_profile, dict) else {}
|
|
497
|
+
raw_email = profile.get("email")
|
|
498
|
+
email = raw_email if isinstance(raw_email, str) else None
|
|
499
|
+
except Exception:
|
|
500
|
+
email = None
|
|
501
|
+
|
|
502
|
+
credentials = SlackOAuthCredentials(
|
|
503
|
+
access_token=access_token,
|
|
504
|
+
refresh_token=refresh_token,
|
|
505
|
+
expires_at=datetime.now(UTC) + timedelta(seconds=expires_in),
|
|
506
|
+
client_id=resolved_client_id,
|
|
507
|
+
scopes=scopes,
|
|
508
|
+
user_id=user_id,
|
|
509
|
+
team_id=team_id,
|
|
510
|
+
team_name=team_data.get("name") if isinstance(team_data.get("name"), str) else None,
|
|
511
|
+
email=email,
|
|
512
|
+
)
|
|
513
|
+
resolved_account_id = account_id or f"slack:{team_id}:{user_id}"
|
|
514
|
+
if account_id is not None and account_id != f"slack:{team_id}:{user_id}":
|
|
515
|
+
raise RuntimeError(
|
|
516
|
+
f"Slack identity {team_id}:{user_id} does not match requested account {account_id}"
|
|
517
|
+
)
|
|
518
|
+
from agentgraph.auth.credentials import upsert_platform_account
|
|
519
|
+
|
|
520
|
+
upsert_platform_account("slack", resolved_account_id, credentials, make_default=True)
|
|
521
|
+
from agentgraph.config import CREDENTIALS_FILE
|
|
522
|
+
|
|
523
|
+
typer.echo(f"Slack OAuth credentials saved to {CREDENTIALS_FILE} ({resolved_account_id})")
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
async def refresh_oauth_credentials(
|
|
527
|
+
account_id: str | None = None,
|
|
528
|
+
*,
|
|
529
|
+
force: bool = False,
|
|
530
|
+
client: httpx.AsyncClient | None = None,
|
|
531
|
+
) -> SlackOAuthCredentials:
|
|
532
|
+
"""Refresh a rotating token once per account and persist the replacement atomically."""
|
|
533
|
+
initial = load_slack_creds(account_id)
|
|
534
|
+
if not isinstance(initial, SlackOAuthCredentials):
|
|
535
|
+
raise RuntimeError("Slack browser credentials cannot be refreshed")
|
|
536
|
+
resolved_id = account_id or f"slack:{initial.team_id}:{initial.user_id}"
|
|
537
|
+
locks = _refresh_locks.setdefault(asyncio.get_running_loop(), {})
|
|
538
|
+
lock = locks.setdefault(resolved_id, asyncio.Lock())
|
|
539
|
+
async with lock:
|
|
540
|
+
current = load_slack_creds(resolved_id)
|
|
541
|
+
if not isinstance(current, SlackOAuthCredentials):
|
|
542
|
+
raise RuntimeError("Slack OAuth credentials changed during refresh")
|
|
543
|
+
now = datetime.now(UTC)
|
|
544
|
+
expires_at = current.expires_at
|
|
545
|
+
if expires_at.tzinfo is None:
|
|
546
|
+
expires_at = expires_at.replace(tzinfo=UTC)
|
|
547
|
+
if force and current.access_token != initial.access_token:
|
|
548
|
+
return current
|
|
549
|
+
if not force and expires_at > now + REFRESH_WINDOW:
|
|
550
|
+
return current
|
|
551
|
+
|
|
552
|
+
owns_client = client is None
|
|
553
|
+
http = client or httpx.AsyncClient(timeout=30)
|
|
554
|
+
try:
|
|
555
|
+
response = await http.post(
|
|
556
|
+
SLACK_TOKEN_URL,
|
|
557
|
+
data={
|
|
558
|
+
"grant_type": "refresh_token",
|
|
559
|
+
"refresh_token": current.refresh_token,
|
|
560
|
+
"client_id": current.client_id,
|
|
561
|
+
},
|
|
562
|
+
)
|
|
563
|
+
response.raise_for_status()
|
|
564
|
+
data: dict[str, Any] = response.json()
|
|
565
|
+
finally:
|
|
566
|
+
if owns_client:
|
|
567
|
+
await http.aclose()
|
|
568
|
+
if not data.get("ok"):
|
|
569
|
+
raise RuntimeError(f"Slack OAuth refresh failed: {data.get('error', 'unknown')}")
|
|
570
|
+
|
|
571
|
+
access_token, refresh_token, expires_in, returned_scopes = _oauth_values(data)
|
|
572
|
+
refreshed = current.model_copy(update={
|
|
573
|
+
"access_token": access_token,
|
|
574
|
+
"refresh_token": refresh_token,
|
|
575
|
+
"expires_at": now + timedelta(seconds=expires_in),
|
|
576
|
+
"scopes": returned_scopes or current.scopes,
|
|
577
|
+
})
|
|
578
|
+
from agentgraph.auth.credentials import upsert_platform_account
|
|
579
|
+
|
|
580
|
+
upsert_platform_account("slack", resolved_id, refreshed, make_default=False)
|
|
581
|
+
return refreshed
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
async def slack_headers(
|
|
585
|
+
account_id: str | None = None,
|
|
586
|
+
*,
|
|
587
|
+
force_refresh: bool = False,
|
|
588
|
+
client: httpx.AsyncClient | None = None,
|
|
589
|
+
) -> dict[str, str]:
|
|
590
|
+
creds = load_slack_creds(account_id)
|
|
591
|
+
if isinstance(creds, SlackOAuthCredentials):
|
|
592
|
+
expires_at = creds.expires_at
|
|
593
|
+
if expires_at.tzinfo is None:
|
|
594
|
+
expires_at = expires_at.replace(tzinfo=UTC)
|
|
595
|
+
if force_refresh or expires_at <= datetime.now(UTC) + REFRESH_WINDOW:
|
|
596
|
+
creds = await refresh_oauth_credentials(account_id, force=force_refresh, client=client)
|
|
597
|
+
return {
|
|
598
|
+
"Authorization": f"Bearer {creds.access_token}",
|
|
599
|
+
"Content-Type": "application/json",
|
|
600
|
+
}
|
|
601
|
+
return {
|
|
602
|
+
"Authorization": f"Bearer {creds.xoxc_token}",
|
|
603
|
+
"Cookie": f"d={creds.d_cookie}",
|
|
604
|
+
"Content-Type": "application/json",
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def run_cookie_flow(
|
|
609
|
+
account_id: str | None = None,
|
|
610
|
+
add: bool = False,
|
|
611
|
+
xoxc_token: str | None = None,
|
|
612
|
+
d_cookie: str | None = None,
|
|
613
|
+
) -> None:
|
|
614
|
+
"""Guide the user through extracting Slack browser-session credentials."""
|
|
615
|
+
import typer
|
|
616
|
+
|
|
617
|
+
from agentgraph.auth.credentials import save_platform, upsert_platform_account
|
|
618
|
+
|
|
619
|
+
if xoxc_token is None:
|
|
620
|
+
typer.echo(
|
|
621
|
+
"\nBrowser-session fallback selected. Open Slack in a browser, inspect a "
|
|
622
|
+
"slack.com/api request, and copy its xoxc token and d cookie.\n"
|
|
623
|
+
)
|
|
624
|
+
xoxc_token_val: str = typer.prompt("xoxc- token").strip()
|
|
625
|
+
else:
|
|
626
|
+
xoxc_token_val = xoxc_token
|
|
627
|
+
if not xoxc_token_val.startswith("xoxc-"):
|
|
628
|
+
typer.echo("Warning: token doesn't start with 'xoxc-' - double-check the value.")
|
|
629
|
+
|
|
630
|
+
d_cookie_val: str = typer.prompt("d cookie value").strip() if d_cookie is None else d_cookie
|
|
631
|
+
user_id: str | None = None
|
|
632
|
+
team_id: str | None = None
|
|
633
|
+
team_name: str | None = None
|
|
634
|
+
try:
|
|
635
|
+
response = httpx.get(
|
|
636
|
+
"https://slack.com/api/auth.test",
|
|
637
|
+
headers={
|
|
638
|
+
"Authorization": f"Bearer {xoxc_token_val}",
|
|
639
|
+
"Cookie": f"d={d_cookie_val}",
|
|
640
|
+
},
|
|
641
|
+
timeout=10,
|
|
642
|
+
)
|
|
643
|
+
data = response.json()
|
|
644
|
+
if data.get("ok"):
|
|
645
|
+
user_id = data.get("user_id")
|
|
646
|
+
team_id = data.get("team_id")
|
|
647
|
+
team_name = data.get("team")
|
|
648
|
+
except Exception:
|
|
649
|
+
pass
|
|
650
|
+
|
|
651
|
+
creds = SlackBrowserCredentials(
|
|
652
|
+
xoxc_token=xoxc_token_val,
|
|
653
|
+
d_cookie=d_cookie_val,
|
|
654
|
+
user_id=user_id,
|
|
655
|
+
team_id=team_id,
|
|
656
|
+
team_name=team_name,
|
|
657
|
+
)
|
|
658
|
+
resolved_account_id = account_id or (
|
|
659
|
+
f"slack:{team_id}:{user_id}" if team_id and user_id else "slack"
|
|
660
|
+
)
|
|
661
|
+
if not add and account_id is None and not list_slack_accounts():
|
|
662
|
+
save_platform(
|
|
663
|
+
"slack", {**creds.model_dump(mode="json"), "account_id": resolved_account_id}
|
|
664
|
+
)
|
|
665
|
+
else:
|
|
666
|
+
upsert_platform_account("slack", resolved_account_id, creds, make_default=True)
|
|
667
|
+
from agentgraph.config import CREDENTIALS_FILE
|
|
668
|
+
|
|
669
|
+
msg = f"\nSlack credentials saved to {CREDENTIALS_FILE}"
|
|
670
|
+
if user_id:
|
|
671
|
+
label = f"{team_name} / {user_id}" if team_name else user_id
|
|
672
|
+
msg += f" (authenticated as {label})"
|
|
673
|
+
typer.echo(msg)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"display_information": {
|
|
3
|
+
"name": "AgentGraph",
|
|
4
|
+
"description": "Local Slack indexing for AgentGraph",
|
|
5
|
+
"background_color": "#1f2937"
|
|
6
|
+
},
|
|
7
|
+
"oauth_config": {
|
|
8
|
+
"redirect_urls": [
|
|
9
|
+
"http://localhost:8766/slack/oauth/callback"
|
|
10
|
+
],
|
|
11
|
+
"pkce_enabled": true,
|
|
12
|
+
"token_management_enabled": true,
|
|
13
|
+
"scopes": {
|
|
14
|
+
"user": [
|
|
15
|
+
"channels:history",
|
|
16
|
+
"channels:read",
|
|
17
|
+
"groups:history",
|
|
18
|
+
"groups:read",
|
|
19
|
+
"im:history",
|
|
20
|
+
"im:read",
|
|
21
|
+
"mpim:history",
|
|
22
|
+
"mpim:read",
|
|
23
|
+
"users:read",
|
|
24
|
+
"users:read.email"
|
|
25
|
+
],
|
|
26
|
+
"user_optional": [
|
|
27
|
+
"users:read",
|
|
28
|
+
"users:read.email"
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"settings": {
|
|
33
|
+
"org_deploy_enabled": false,
|
|
34
|
+
"socket_mode_enabled": false,
|
|
35
|
+
"token_rotation_enabled": true
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
agentgraph_connector_slack/__init__.py,sha256=efYHYC7tocl-iIWxffelYhRzEoCqLZlTmUC7X60R0wE,23688
|
|
2
|
+
agentgraph_connector_slack/auth.py,sha256=mw7J11C2C2kz9I73nPeTvFh9cxBXeiRF8jwQPhylAi4,24955
|
|
3
|
+
agentgraph_connector_slack/slack-app-manifest.json,sha256=-LX4xtVCK775-hkTZOICLZOL8mFC9ZBziQlk2OmJwO0,812
|
|
4
|
+
agentgraph_connector_slack-0.5.0.dist-info/METADATA,sha256=ObbQfw9-Lwo_Z9JDjnaGT3tTDH8161EIP_oF--LlLiQ,208
|
|
5
|
+
agentgraph_connector_slack-0.5.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
6
|
+
agentgraph_connector_slack-0.5.0.dist-info/entry_points.txt,sha256=HPakTuFBG2DKPJt9dQdMRpL94H-DBJYn4sUGqu1wvG4,74
|
|
7
|
+
agentgraph_connector_slack-0.5.0.dist-info/top_level.txt,sha256=Ed5lxRW5zacXrCMqGSu_bFyz8dkNbNDAuoTIdRXGoTQ,27
|
|
8
|
+
agentgraph_connector_slack-0.5.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agentgraph_connector_slack
|