quickcall-integrations 0.1.2__py3-none-any.whl → 0.1.4__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.
- mcp_server/api_clients/__init__.py +6 -0
- mcp_server/api_clients/github_client.py +440 -0
- mcp_server/api_clients/slack_client.py +359 -0
- mcp_server/auth/__init__.py +24 -0
- mcp_server/auth/credentials.py +278 -0
- mcp_server/auth/device_flow.py +253 -0
- mcp_server/server.py +54 -2
- mcp_server/tools/__init__.py +12 -0
- mcp_server/tools/auth_tools.py +411 -0
- mcp_server/tools/git_tools.py +58 -20
- mcp_server/tools/github_tools.py +338 -0
- mcp_server/tools/slack_tools.py +203 -0
- quickcall_integrations-0.1.4.dist-info/METADATA +138 -0
- quickcall_integrations-0.1.4.dist-info/RECORD +18 -0
- mcp_server/config.py +0 -10
- quickcall_integrations-0.1.2.dist-info/METADATA +0 -81
- quickcall_integrations-0.1.2.dist-info/RECORD +0 -10
- {quickcall_integrations-0.1.2.dist-info → quickcall_integrations-0.1.4.dist-info}/WHEEL +0 -0
- {quickcall_integrations-0.1.2.dist-info → quickcall_integrations-0.1.4.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Slack API client for MCP server.
|
|
3
|
+
|
|
4
|
+
Provides Slack API operations using httpx.
|
|
5
|
+
Focuses on messaging and channel operations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import List, Optional, Dict, Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ============================================================================
|
|
18
|
+
# Pydantic models for Slack data
|
|
19
|
+
# ============================================================================
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SlackChannel(BaseModel):
|
|
23
|
+
"""Represents a Slack channel."""
|
|
24
|
+
|
|
25
|
+
id: str
|
|
26
|
+
name: str
|
|
27
|
+
is_private: bool = False
|
|
28
|
+
is_member: bool = False
|
|
29
|
+
topic: str = ""
|
|
30
|
+
purpose: str = ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SlackUser(BaseModel):
|
|
34
|
+
"""Represents a Slack user."""
|
|
35
|
+
|
|
36
|
+
id: str
|
|
37
|
+
name: str
|
|
38
|
+
real_name: str = ""
|
|
39
|
+
display_name: str = ""
|
|
40
|
+
email: Optional[str] = None
|
|
41
|
+
is_admin: bool = False
|
|
42
|
+
is_bot: bool = False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SlackMessage(BaseModel):
|
|
46
|
+
"""Represents a sent Slack message."""
|
|
47
|
+
|
|
48
|
+
ok: bool
|
|
49
|
+
channel: str
|
|
50
|
+
ts: str # Message timestamp (used as ID)
|
|
51
|
+
message: Optional[Dict[str, Any]] = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ============================================================================
|
|
55
|
+
# Slack Client
|
|
56
|
+
# ============================================================================
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SlackClient:
|
|
60
|
+
"""
|
|
61
|
+
Slack API client using httpx.
|
|
62
|
+
|
|
63
|
+
Provides simplified interface for Slack operations.
|
|
64
|
+
Uses bot token authentication.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
BASE_URL = "https://slack.com/api"
|
|
68
|
+
|
|
69
|
+
def __init__(self, bot_token: str, default_channel: Optional[str] = None):
|
|
70
|
+
"""
|
|
71
|
+
Initialize Slack API client.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
bot_token: Slack bot OAuth token (xoxb-...)
|
|
75
|
+
default_channel: Default channel name or ID for sending messages
|
|
76
|
+
"""
|
|
77
|
+
self.bot_token = bot_token
|
|
78
|
+
self.default_channel = default_channel
|
|
79
|
+
self._headers = {
|
|
80
|
+
"Authorization": f"Bearer {bot_token}",
|
|
81
|
+
"Content-Type": "application/json",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async def _request(
|
|
85
|
+
self,
|
|
86
|
+
method: str,
|
|
87
|
+
endpoint: str,
|
|
88
|
+
params: Optional[Dict] = None,
|
|
89
|
+
json: Optional[Dict] = None,
|
|
90
|
+
) -> Dict[str, Any]:
|
|
91
|
+
"""Make an async request to Slack API."""
|
|
92
|
+
url = f"{self.BASE_URL}/{endpoint}"
|
|
93
|
+
|
|
94
|
+
async with httpx.AsyncClient() as client:
|
|
95
|
+
if method == "GET":
|
|
96
|
+
response = await client.get(url, headers=self._headers, params=params)
|
|
97
|
+
else:
|
|
98
|
+
response = await client.post(url, headers=self._headers, json=json)
|
|
99
|
+
|
|
100
|
+
data = response.json()
|
|
101
|
+
|
|
102
|
+
if not data.get("ok"):
|
|
103
|
+
error = data.get("error", "unknown_error")
|
|
104
|
+
raise SlackAPIError(f"Slack API error: {error}")
|
|
105
|
+
|
|
106
|
+
return data
|
|
107
|
+
|
|
108
|
+
def _request_sync(
|
|
109
|
+
self,
|
|
110
|
+
method: str,
|
|
111
|
+
endpoint: str,
|
|
112
|
+
params: Optional[Dict] = None,
|
|
113
|
+
json: Optional[Dict] = None,
|
|
114
|
+
) -> Dict[str, Any]:
|
|
115
|
+
"""Make a sync request to Slack API."""
|
|
116
|
+
url = f"{self.BASE_URL}/{endpoint}"
|
|
117
|
+
|
|
118
|
+
with httpx.Client() as client:
|
|
119
|
+
if method == "GET":
|
|
120
|
+
response = client.get(url, headers=self._headers, params=params)
|
|
121
|
+
else:
|
|
122
|
+
response = client.post(url, headers=self._headers, json=json)
|
|
123
|
+
|
|
124
|
+
data = response.json()
|
|
125
|
+
|
|
126
|
+
if not data.get("ok"):
|
|
127
|
+
error = data.get("error", "unknown_error")
|
|
128
|
+
raise SlackAPIError(f"Slack API error: {error}")
|
|
129
|
+
|
|
130
|
+
return data
|
|
131
|
+
|
|
132
|
+
# ========================================================================
|
|
133
|
+
# Connection / Auth
|
|
134
|
+
# ========================================================================
|
|
135
|
+
|
|
136
|
+
def health_check(self) -> Dict[str, Any]:
|
|
137
|
+
"""
|
|
138
|
+
Check if Slack connection is working.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Dict with connection status and workspace info
|
|
142
|
+
"""
|
|
143
|
+
try:
|
|
144
|
+
data = self._request_sync("POST", "auth.test")
|
|
145
|
+
return {
|
|
146
|
+
"connected": True,
|
|
147
|
+
"team": data.get("team"),
|
|
148
|
+
"team_id": data.get("team_id"),
|
|
149
|
+
"user": data.get("user"),
|
|
150
|
+
"user_id": data.get("user_id"),
|
|
151
|
+
"bot_id": data.get("bot_id"),
|
|
152
|
+
}
|
|
153
|
+
except Exception as e:
|
|
154
|
+
return {
|
|
155
|
+
"connected": False,
|
|
156
|
+
"error": str(e),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# ========================================================================
|
|
160
|
+
# Channel Operations
|
|
161
|
+
# ========================================================================
|
|
162
|
+
|
|
163
|
+
def list_channels(
|
|
164
|
+
self, include_private: bool = True, limit: int = 200
|
|
165
|
+
) -> List[SlackChannel]:
|
|
166
|
+
"""
|
|
167
|
+
List Slack channels the bot has access to.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
include_private: Whether to include private channels
|
|
171
|
+
limit: Maximum channels to return
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
List of channels
|
|
175
|
+
"""
|
|
176
|
+
types = (
|
|
177
|
+
"public_channel,private_channel" if include_private else "public_channel"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
data = self._request_sync(
|
|
181
|
+
"GET",
|
|
182
|
+
"conversations.list",
|
|
183
|
+
params={"types": types, "limit": limit, "exclude_archived": True},
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
channels = []
|
|
187
|
+
for ch in data.get("channels", []):
|
|
188
|
+
channels.append(
|
|
189
|
+
SlackChannel(
|
|
190
|
+
id=ch["id"],
|
|
191
|
+
name=ch["name"],
|
|
192
|
+
is_private=ch.get("is_private", False),
|
|
193
|
+
is_member=ch.get("is_member", False),
|
|
194
|
+
topic=ch.get("topic", {}).get("value", ""),
|
|
195
|
+
purpose=ch.get("purpose", {}).get("value", ""),
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return channels
|
|
200
|
+
|
|
201
|
+
def _resolve_channel(self, channel: Optional[str] = None) -> str:
|
|
202
|
+
"""
|
|
203
|
+
Resolve channel name to channel ID.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
channel: Channel name (with or without #) or channel ID
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
Channel ID
|
|
210
|
+
"""
|
|
211
|
+
channel = channel or self.default_channel
|
|
212
|
+
|
|
213
|
+
if not channel:
|
|
214
|
+
raise ValueError("No channel specified and no default channel configured")
|
|
215
|
+
|
|
216
|
+
# If it's already an ID (starts with C), return as-is
|
|
217
|
+
if channel.startswith("C"):
|
|
218
|
+
return channel
|
|
219
|
+
|
|
220
|
+
# Strip # prefix if present
|
|
221
|
+
channel_name = channel.lstrip("#").lower()
|
|
222
|
+
|
|
223
|
+
# Look up channel by name
|
|
224
|
+
channels = self.list_channels()
|
|
225
|
+
for ch in channels:
|
|
226
|
+
if ch.name.lower() == channel_name:
|
|
227
|
+
return ch.id
|
|
228
|
+
|
|
229
|
+
raise ValueError(f"Channel '{channel}' not found or bot is not a member")
|
|
230
|
+
|
|
231
|
+
# ========================================================================
|
|
232
|
+
# Messaging
|
|
233
|
+
# ========================================================================
|
|
234
|
+
|
|
235
|
+
def send_message(
|
|
236
|
+
self,
|
|
237
|
+
text: str,
|
|
238
|
+
channel: Optional[str] = None,
|
|
239
|
+
thread_ts: Optional[str] = None,
|
|
240
|
+
) -> SlackMessage:
|
|
241
|
+
"""
|
|
242
|
+
Send a message to a Slack channel.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
text: Message text (supports mrkdwn formatting)
|
|
246
|
+
channel: Channel name or ID (uses default if not specified)
|
|
247
|
+
thread_ts: Thread timestamp to reply to (optional)
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
SlackMessage with sent message details
|
|
251
|
+
"""
|
|
252
|
+
channel_id = self._resolve_channel(channel)
|
|
253
|
+
|
|
254
|
+
payload = {
|
|
255
|
+
"channel": channel_id,
|
|
256
|
+
"text": text,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if thread_ts:
|
|
260
|
+
payload["thread_ts"] = thread_ts
|
|
261
|
+
|
|
262
|
+
data = self._request_sync("POST", "chat.postMessage", json=payload)
|
|
263
|
+
|
|
264
|
+
return SlackMessage(
|
|
265
|
+
ok=data.get("ok", False),
|
|
266
|
+
channel=data.get("channel", channel_id),
|
|
267
|
+
ts=data.get("ts", ""),
|
|
268
|
+
message=data.get("message"),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
async def send_message_async(
|
|
272
|
+
self,
|
|
273
|
+
text: str,
|
|
274
|
+
channel: Optional[str] = None,
|
|
275
|
+
thread_ts: Optional[str] = None,
|
|
276
|
+
) -> SlackMessage:
|
|
277
|
+
"""
|
|
278
|
+
Send a message to a Slack channel (async version).
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
text: Message text (supports mrkdwn formatting)
|
|
282
|
+
channel: Channel name or ID (uses default if not specified)
|
|
283
|
+
thread_ts: Thread timestamp to reply to (optional)
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
SlackMessage with sent message details
|
|
287
|
+
"""
|
|
288
|
+
channel_id = self._resolve_channel(channel)
|
|
289
|
+
|
|
290
|
+
payload = {
|
|
291
|
+
"channel": channel_id,
|
|
292
|
+
"text": text,
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if thread_ts:
|
|
296
|
+
payload["thread_ts"] = thread_ts
|
|
297
|
+
|
|
298
|
+
data = await self._request("POST", "chat.postMessage", json=payload)
|
|
299
|
+
|
|
300
|
+
return SlackMessage(
|
|
301
|
+
ok=data.get("ok", False),
|
|
302
|
+
channel=data.get("channel", channel_id),
|
|
303
|
+
ts=data.get("ts", ""),
|
|
304
|
+
message=data.get("message"),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# ========================================================================
|
|
308
|
+
# User Operations
|
|
309
|
+
# ========================================================================
|
|
310
|
+
|
|
311
|
+
def list_users(
|
|
312
|
+
self, limit: int = 200, include_bots: bool = False
|
|
313
|
+
) -> List[SlackUser]:
|
|
314
|
+
"""
|
|
315
|
+
List users in the Slack workspace.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
limit: Maximum users to return
|
|
319
|
+
include_bots: Whether to include bot users
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
List of users
|
|
323
|
+
"""
|
|
324
|
+
data = self._request_sync("GET", "users.list", params={"limit": limit})
|
|
325
|
+
|
|
326
|
+
users = []
|
|
327
|
+
for member in data.get("members", []):
|
|
328
|
+
# Skip deleted users
|
|
329
|
+
if member.get("deleted"):
|
|
330
|
+
continue
|
|
331
|
+
|
|
332
|
+
# Skip bots unless requested
|
|
333
|
+
if not include_bots and member.get("is_bot"):
|
|
334
|
+
continue
|
|
335
|
+
|
|
336
|
+
# Skip Slackbot
|
|
337
|
+
if member.get("id") == "USLACKBOT":
|
|
338
|
+
continue
|
|
339
|
+
|
|
340
|
+
profile = member.get("profile", {})
|
|
341
|
+
users.append(
|
|
342
|
+
SlackUser(
|
|
343
|
+
id=member["id"],
|
|
344
|
+
name=member.get("name", ""),
|
|
345
|
+
real_name=member.get("real_name", ""),
|
|
346
|
+
display_name=profile.get("display_name", ""),
|
|
347
|
+
email=profile.get("email"),
|
|
348
|
+
is_admin=member.get("is_admin", False),
|
|
349
|
+
is_bot=member.get("is_bot", False),
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
return users
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class SlackAPIError(Exception):
|
|
357
|
+
"""Exception raised for Slack API errors."""
|
|
358
|
+
|
|
359
|
+
pass
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
QuickCall Authentication Module
|
|
3
|
+
|
|
4
|
+
Handles OAuth device flow authentication for CLI/MCP clients.
|
|
5
|
+
Stores credentials locally and fetches fresh tokens from quickcall.dev API.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from mcp_server.auth.credentials import (
|
|
9
|
+
CredentialStore,
|
|
10
|
+
get_credential_store,
|
|
11
|
+
is_authenticated,
|
|
12
|
+
get_credentials,
|
|
13
|
+
clear_credentials,
|
|
14
|
+
)
|
|
15
|
+
from mcp_server.auth.device_flow import DeviceFlowAuth
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"CredentialStore",
|
|
19
|
+
"get_credential_store",
|
|
20
|
+
"is_authenticated",
|
|
21
|
+
"get_credentials",
|
|
22
|
+
"clear_credentials",
|
|
23
|
+
"DeviceFlowAuth",
|
|
24
|
+
]
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Credential storage and management for QuickCall MCP.
|
|
3
|
+
|
|
4
|
+
Stores device tokens locally in ~/.quickcall/credentials.json
|
|
5
|
+
Fetches fresh API credentials from quickcall.dev on demand.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional, Dict, Any
|
|
13
|
+
from dataclasses import dataclass, asdict
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# Local storage
|
|
21
|
+
QUICKCALL_DIR = Path.home() / ".quickcall"
|
|
22
|
+
CREDENTIALS_FILE = QUICKCALL_DIR / "credentials.json"
|
|
23
|
+
|
|
24
|
+
# QuickCall API - configurable via environment for local testing
|
|
25
|
+
# Set QUICKCALL_API_URL=http://localhost:8000 for local development
|
|
26
|
+
QUICKCALL_API_URL = os.getenv("QUICKCALL_API_URL", "https://api.quickcall.dev")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class StoredCredentials:
|
|
31
|
+
"""Credentials stored locally after device flow authentication."""
|
|
32
|
+
|
|
33
|
+
device_token: str # qt_xxxxx - for API authentication
|
|
34
|
+
user_id: str
|
|
35
|
+
email: Optional[str] = None
|
|
36
|
+
username: Optional[str] = None
|
|
37
|
+
authenticated_at: Optional[str] = None
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
40
|
+
return asdict(self)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_dict(cls, data: Dict[str, Any]) -> "StoredCredentials":
|
|
44
|
+
return cls(
|
|
45
|
+
device_token=data["device_token"],
|
|
46
|
+
user_id=data["user_id"],
|
|
47
|
+
email=data.get("email"),
|
|
48
|
+
username=data.get("username"),
|
|
49
|
+
authenticated_at=data.get("authenticated_at"),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class APICredentials:
|
|
55
|
+
"""Fresh credentials fetched from QuickCall API."""
|
|
56
|
+
|
|
57
|
+
# User info
|
|
58
|
+
user_id: str
|
|
59
|
+
email: Optional[str] = None
|
|
60
|
+
username: Optional[str] = None
|
|
61
|
+
|
|
62
|
+
# GitHub
|
|
63
|
+
github_connected: bool = False
|
|
64
|
+
github_token: Optional[str] = None # Installation token (1 hour validity)
|
|
65
|
+
github_username: Optional[str] = None
|
|
66
|
+
github_installation_id: Optional[int] = None
|
|
67
|
+
|
|
68
|
+
# Slack
|
|
69
|
+
slack_connected: bool = False
|
|
70
|
+
slack_bot_token: Optional[str] = None
|
|
71
|
+
slack_team_name: Optional[str] = None
|
|
72
|
+
slack_team_id: Optional[str] = None
|
|
73
|
+
slack_user_id: Optional[str] = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class CredentialStore:
|
|
77
|
+
"""
|
|
78
|
+
Manages credential storage and retrieval.
|
|
79
|
+
|
|
80
|
+
Usage:
|
|
81
|
+
store = CredentialStore()
|
|
82
|
+
|
|
83
|
+
# Check if authenticated
|
|
84
|
+
if store.is_authenticated():
|
|
85
|
+
creds = store.get_api_credentials()
|
|
86
|
+
if creds.github_connected:
|
|
87
|
+
# Use GitHub token
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
# Save after device flow
|
|
91
|
+
store.save(StoredCredentials(device_token="qt_xxx", user_id="user_xxx"))
|
|
92
|
+
|
|
93
|
+
# Clear on logout
|
|
94
|
+
store.clear()
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self, api_url: Optional[str] = None):
|
|
98
|
+
"""
|
|
99
|
+
Initialize credential store.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
api_url: QuickCall API URL (defaults to production)
|
|
103
|
+
"""
|
|
104
|
+
self.api_url = api_url or QUICKCALL_API_URL
|
|
105
|
+
self._stored: Optional[StoredCredentials] = None
|
|
106
|
+
self._api_creds: Optional[APICredentials] = None
|
|
107
|
+
self._load()
|
|
108
|
+
|
|
109
|
+
def _load(self):
|
|
110
|
+
"""Load stored credentials from disk."""
|
|
111
|
+
if not CREDENTIALS_FILE.exists():
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
with open(CREDENTIALS_FILE) as f:
|
|
116
|
+
data = json.load(f)
|
|
117
|
+
self._stored = StoredCredentials.from_dict(data)
|
|
118
|
+
logger.debug(f"Loaded credentials for user {self._stored.user_id}")
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.warning(f"Failed to load credentials: {e}")
|
|
121
|
+
|
|
122
|
+
def save(self, credentials: StoredCredentials):
|
|
123
|
+
"""Save credentials to disk."""
|
|
124
|
+
QUICKCALL_DIR.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
with open(CREDENTIALS_FILE, "w") as f:
|
|
128
|
+
json.dump(credentials.to_dict(), f, indent=2)
|
|
129
|
+
CREDENTIALS_FILE.chmod(0o600) # Restrict permissions
|
|
130
|
+
self._stored = credentials
|
|
131
|
+
self._api_creds = None # Clear cached API creds
|
|
132
|
+
logger.info(f"Saved credentials for user {credentials.user_id}")
|
|
133
|
+
except Exception as e:
|
|
134
|
+
logger.error(f"Failed to save credentials: {e}")
|
|
135
|
+
raise
|
|
136
|
+
|
|
137
|
+
def clear(self):
|
|
138
|
+
"""Clear stored credentials."""
|
|
139
|
+
if CREDENTIALS_FILE.exists():
|
|
140
|
+
try:
|
|
141
|
+
CREDENTIALS_FILE.unlink()
|
|
142
|
+
logger.info("Cleared stored credentials")
|
|
143
|
+
except Exception as e:
|
|
144
|
+
logger.error(f"Failed to clear credentials: {e}")
|
|
145
|
+
raise
|
|
146
|
+
|
|
147
|
+
self._stored = None
|
|
148
|
+
self._api_creds = None
|
|
149
|
+
|
|
150
|
+
def is_authenticated(self) -> bool:
|
|
151
|
+
"""Check if we have stored credentials."""
|
|
152
|
+
return self._stored is not None
|
|
153
|
+
|
|
154
|
+
def get_stored_credentials(self) -> Optional[StoredCredentials]:
|
|
155
|
+
"""Get locally stored credentials (device token, etc)."""
|
|
156
|
+
return self._stored
|
|
157
|
+
|
|
158
|
+
def get_api_credentials(
|
|
159
|
+
self, force_refresh: bool = False
|
|
160
|
+
) -> Optional[APICredentials]:
|
|
161
|
+
"""
|
|
162
|
+
Fetch fresh API credentials from QuickCall.
|
|
163
|
+
|
|
164
|
+
This calls the /api/cli/credentials endpoint to get:
|
|
165
|
+
- GitHub installation token (fresh, 1 hour validity)
|
|
166
|
+
- Slack bot token (decrypted)
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
force_refresh: Force fetch even if cached
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
APICredentials with fresh tokens, or None if not authenticated
|
|
173
|
+
"""
|
|
174
|
+
if not self._stored:
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
# Always fetch fresh credentials - don't cache
|
|
178
|
+
# Integration status can change at any time (user connects/disconnects via web)
|
|
179
|
+
# Caching causes stale data issues where connected integrations show as disconnected
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
with httpx.Client(timeout=30.0) as client:
|
|
183
|
+
response = client.get(
|
|
184
|
+
f"{self.api_url}/api/cli/credentials",
|
|
185
|
+
headers={"Authorization": f"Bearer {self._stored.device_token}"},
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if response.status_code == 401:
|
|
189
|
+
logger.warning("Device token invalid or revoked")
|
|
190
|
+
self.clear()
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
response.raise_for_status()
|
|
194
|
+
data = response.json()
|
|
195
|
+
|
|
196
|
+
self._api_creds = APICredentials(
|
|
197
|
+
user_id=data["user"]["user_id"],
|
|
198
|
+
email=data["user"].get("email"),
|
|
199
|
+
username=data["user"].get("username"),
|
|
200
|
+
github_connected=data["github"]["connected"],
|
|
201
|
+
github_token=data["github"].get("token"),
|
|
202
|
+
github_username=data["github"].get("username"),
|
|
203
|
+
github_installation_id=data["github"].get("installation_id"),
|
|
204
|
+
slack_connected=data["slack"]["connected"],
|
|
205
|
+
slack_bot_token=data["slack"].get("bot_token"),
|
|
206
|
+
slack_team_name=data["slack"].get("team_name"),
|
|
207
|
+
slack_team_id=data["slack"].get("team_id"),
|
|
208
|
+
slack_user_id=data["slack"].get("user_id"),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
logger.debug(
|
|
212
|
+
f"Fetched API credentials: GitHub={self._api_creds.github_connected}, "
|
|
213
|
+
f"Slack={self._api_creds.slack_connected}"
|
|
214
|
+
)
|
|
215
|
+
return self._api_creds
|
|
216
|
+
|
|
217
|
+
except httpx.HTTPStatusError as e:
|
|
218
|
+
logger.error(f"API error fetching credentials: {e.response.status_code}")
|
|
219
|
+
return None
|
|
220
|
+
except Exception as e:
|
|
221
|
+
logger.error(f"Failed to fetch API credentials: {e}")
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
def get_status(self) -> Dict[str, Any]:
|
|
225
|
+
"""Get authentication status for diagnostics."""
|
|
226
|
+
if not self._stored:
|
|
227
|
+
return {
|
|
228
|
+
"authenticated": False,
|
|
229
|
+
"credentials_file": str(CREDENTIALS_FILE),
|
|
230
|
+
"file_exists": CREDENTIALS_FILE.exists(),
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
# Always fetch fresh status (force refresh to get latest connection states)
|
|
234
|
+
api_creds = self.get_api_credentials(force_refresh=True)
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
"authenticated": True,
|
|
238
|
+
"credentials_file": str(CREDENTIALS_FILE),
|
|
239
|
+
"user_id": self._stored.user_id,
|
|
240
|
+
"email": self._stored.email,
|
|
241
|
+
"username": self._stored.username,
|
|
242
|
+
"authenticated_at": self._stored.authenticated_at,
|
|
243
|
+
"github": {
|
|
244
|
+
"connected": api_creds.github_connected if api_creds else False,
|
|
245
|
+
"username": api_creds.github_username if api_creds else None,
|
|
246
|
+
},
|
|
247
|
+
"slack": {
|
|
248
|
+
"connected": api_creds.slack_connected if api_creds else False,
|
|
249
|
+
"team_name": api_creds.slack_team_name if api_creds else None,
|
|
250
|
+
},
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# Global credential store instance
|
|
255
|
+
_credential_store: Optional[CredentialStore] = None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def get_credential_store() -> CredentialStore:
|
|
259
|
+
"""Get the global credential store instance."""
|
|
260
|
+
global _credential_store
|
|
261
|
+
if _credential_store is None:
|
|
262
|
+
_credential_store = CredentialStore()
|
|
263
|
+
return _credential_store
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def is_authenticated() -> bool:
|
|
267
|
+
"""Check if the user is authenticated."""
|
|
268
|
+
return get_credential_store().is_authenticated()
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def get_credentials() -> Optional[APICredentials]:
|
|
272
|
+
"""Get fresh API credentials (GitHub token, Slack token, etc)."""
|
|
273
|
+
return get_credential_store().get_api_credentials()
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def clear_credentials():
|
|
277
|
+
"""Clear stored credentials (logout)."""
|
|
278
|
+
get_credential_store().clear()
|