quickcall-integrations 0.1.3__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.
@@ -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()