@jtalk22/slack-mcp 1.0.3 → 1.0.6

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.
package/lib/handlers.js CHANGED
@@ -4,11 +4,107 @@
4
4
  * Implementation of all MCP tool handlers.
5
5
  */
6
6
 
7
- import { writeFileSync } from "fs";
8
- import { homedir } from "os";
7
+ import { writeFileSync, readFileSync, existsSync, renameSync, unlinkSync } from "fs";
8
+ import { homedir, platform } from "os";
9
9
  import { join } from "path";
10
- import { loadTokens, saveTokens, extractFromChrome } from "./token-store.js";
11
- import { slackAPI, resolveUser, formatTimestamp, sleep } from "./slack-client.js";
10
+ import { loadTokens, saveTokens, extractFromChrome, isAutoRefreshAvailable } from "./token-store.js";
11
+ import { slackAPI, resolveUser, formatTimestamp, sleep, checkTokenHealth, getUserCacheStats } from "./slack-client.js";
12
+
13
+ // ============ Utilities ============
14
+
15
+ /**
16
+ * Robust boolean parser for LLM input unpredictability
17
+ * Handles: true, "true", "True", "1", 1, "yes", etc.
18
+ */
19
+ function parseBool(val) {
20
+ if (typeof val === 'boolean') return val;
21
+ if (typeof val === 'number') return val !== 0;
22
+ if (typeof val === 'string') {
23
+ return ['true', '1', 'yes', 'on'].includes(val.toLowerCase());
24
+ }
25
+ return false;
26
+ }
27
+
28
+ /**
29
+ * Atomic write to prevent file corruption from concurrent writes
30
+ */
31
+ function atomicWriteSync(filePath, content) {
32
+ const tempPath = `${filePath}.${process.pid}.tmp`;
33
+ try {
34
+ writeFileSync(tempPath, content);
35
+ if (platform() === 'darwin' || platform() === 'linux') {
36
+ try { require('child_process').execSync(`chmod 600 "${tempPath}"`); } catch {}
37
+ }
38
+ renameSync(tempPath, filePath);
39
+ } catch (e) {
40
+ try { unlinkSync(tempPath); } catch {}
41
+ throw e;
42
+ }
43
+ }
44
+
45
+ // ============ DM Cache ============
46
+ const DM_CACHE_FILE = join(homedir(), ".slack-mcp-dm-cache.json");
47
+ const DM_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
48
+
49
+ function loadDMCache() {
50
+ if (!existsSync(DM_CACHE_FILE)) return { dms: {}, updated: 0 };
51
+ try {
52
+ const data = JSON.parse(readFileSync(DM_CACHE_FILE, "utf-8"));
53
+ // Check if cache is stale
54
+ if (Date.now() - (data.updated || 0) > DM_CACHE_TTL) {
55
+ return { dms: {}, updated: 0 };
56
+ }
57
+ return data;
58
+ } catch {
59
+ return { dms: {}, updated: 0 };
60
+ }
61
+ }
62
+
63
+ function saveDMCache(dms) {
64
+ try {
65
+ const data = { dms, updated: Date.now() };
66
+ atomicWriteSync(DM_CACHE_FILE, JSON.stringify(data, null, 2));
67
+ } catch {
68
+ // Ignore write errors - cache is optional
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Token status handler - detailed token health info
74
+ */
75
+ export async function handleTokenStatus() {
76
+ const health = await checkTokenHealth({ error: () => {} });
77
+ const cacheStats = getUserCacheStats();
78
+ const dmCache = loadDMCache();
79
+
80
+ return {
81
+ content: [{
82
+ type: "text",
83
+ text: JSON.stringify({
84
+ token: {
85
+ status: health.healthy ? "healthy" : health.reason === 'no_tokens' ? "missing" : "warning",
86
+ age_hours: health.age_hours,
87
+ source: health.source,
88
+ updated_at: health.updated_at,
89
+ message: health.message
90
+ },
91
+ auto_refresh: {
92
+ enabled: true,
93
+ interval: "4 hours",
94
+ last_attempt: health.refreshed ? "just now" : "on-demand",
95
+ requires: "Slack tab open in Chrome"
96
+ },
97
+ cache: {
98
+ users: cacheStats,
99
+ dms: {
100
+ count: Object.keys(dmCache.dms || {}).length,
101
+ age_hours: dmCache.updated ? Math.round((Date.now() - dmCache.updated) / (60 * 60 * 1000) * 10) / 10 : null
102
+ }
103
+ }
104
+ }, null, 2)
105
+ }]
106
+ };
107
+ }
12
108
 
13
109
  /**
14
110
  * Health check handler
@@ -53,6 +149,17 @@ export async function handleHealthCheck() {
53
149
  * Refresh tokens handler
54
150
  */
55
151
  export async function handleRefreshTokens() {
152
+ // Check platform support
153
+ if (!isAutoRefreshAvailable()) {
154
+ return {
155
+ content: [{
156
+ type: "text",
157
+ text: "Auto-refresh is only available on macOS.\n\nOn other platforms, manually update ~/.slack-mcp-tokens.json with:\n{\n \"SLACK_TOKEN\": \"xoxc-...\",\n \"SLACK_COOKIE\": \"xoxd-...\"\n}"
158
+ }],
159
+ isError: true
160
+ };
161
+ }
162
+
56
163
  const chromeTokens = extractFromChrome();
57
164
  if (chromeTokens) {
58
165
  saveTokens(chromeTokens.token, chromeTokens.cookie);
@@ -86,11 +193,12 @@ export async function handleRefreshTokens() {
86
193
  }
87
194
 
88
195
  /**
89
- * List conversations handler
196
+ * List conversations handler (with lazy DM discovery)
90
197
  */
91
198
  export async function handleListConversations(args) {
92
199
  const types = args.types || "im,mpim";
93
200
  const wantsDMs = types.includes("im") || types.includes("mpim");
201
+ const discoverDMs = parseBool(args.discover_dms); // Robust boolean parsing
94
202
 
95
203
  const result = await slackAPI("conversations.list", {
96
204
  types: types,
@@ -111,29 +219,48 @@ export async function handleListConversations(args) {
111
219
  };
112
220
  }));
113
221
 
114
- // xoxc tokens often don't return IMs via conversations.list
115
- // So we manually add DMs by opening them with known users
116
- if (wantsDMs) {
222
+ // Load cached DMs first (fast path)
223
+ const dmCache = loadDMCache();
224
+ for (const [channelId, data] of Object.entries(dmCache.dms || {})) {
225
+ if (!conversations.find(c => c.id === channelId)) {
226
+ conversations.push(data);
227
+ }
228
+ }
229
+
230
+ // Only do expensive full DM discovery if explicitly requested
231
+ // This avoids hitting rate limits on large workspaces
232
+ if (wantsDMs && discoverDMs) {
233
+ const newDMs = { ...dmCache.dms };
234
+ let discoveredCount = 0;
235
+
117
236
  try {
118
- const usersResult = await slackAPI("users.list", { limit: 100 });
237
+ const usersResult = await slackAPI("users.list", { limit: 200 });
119
238
  for (const user of (usersResult.members || [])) {
120
239
  if (user.is_bot || user.id === "USLACKBOT" || user.deleted) continue;
121
240
 
122
- // Try to open DM with each user to get channel ID
241
+ // Skip if we already have this user's DM
242
+ const existingDM = conversations.find(c => c.user_id === user.id && c.type === "dm");
243
+ if (existingDM) continue;
244
+
245
+ // Try to open DM with this user
123
246
  try {
124
247
  const dmResult = await slackAPI("conversations.open", { users: user.id });
125
- if (dmResult.channel && dmResult.channel.id) {
248
+ if (dmResult.channel?.id) {
126
249
  const channelId = dmResult.channel.id;
127
- // Only add if not already in list
128
250
  if (!conversations.find(c => c.id === channelId)) {
129
- conversations.push({
251
+ const dmData = {
130
252
  id: channelId,
131
253
  name: user.real_name || user.name,
132
254
  type: "dm",
133
255
  user_id: user.id
134
- });
256
+ };
257
+ conversations.push(dmData);
258
+ newDMs[channelId] = dmData;
259
+ discoveredCount++;
135
260
  }
136
261
  }
262
+ // Rate limit protection
263
+ await sleep(50);
137
264
  } catch (e) {
138
265
  // Skip users we can't DM
139
266
  }
@@ -141,12 +268,22 @@ export async function handleListConversations(args) {
141
268
  } catch (e) {
142
269
  // If users.list fails, continue with what we have
143
270
  }
271
+
272
+ // Save updated cache
273
+ if (discoveredCount > 0) {
274
+ saveDMCache(newDMs);
275
+ }
144
276
  }
145
277
 
146
278
  return {
147
279
  content: [{
148
280
  type: "text",
149
- text: JSON.stringify({ count: conversations.length, conversations }, null, 2)
281
+ text: JSON.stringify({
282
+ count: conversations.length,
283
+ conversations,
284
+ cached_dms: Object.keys(dmCache.dms || {}).length,
285
+ hint: !discoverDMs && wantsDMs ? "Use discover_dms:true for full DM discovery (slower)" : undefined
286
+ }, null, 2)
150
287
  }]
151
288
  };
152
289
  }
@@ -394,28 +531,43 @@ export async function handleGetThread(args) {
394
531
  }
395
532
 
396
533
  /**
397
- * List users handler
534
+ * List users handler (with pagination support)
398
535
  */
399
536
  export async function handleListUsers(args) {
400
- const result = await slackAPI("users.list", {
401
- limit: args.limit || 100
402
- });
537
+ const maxUsers = args.limit || 500;
538
+ const allUsers = [];
539
+ let cursor;
540
+
541
+ do {
542
+ const result = await slackAPI("users.list", {
543
+ limit: Math.min(200, maxUsers - allUsers.length),
544
+ cursor
545
+ });
403
546
 
404
- const users = (result.members || [])
405
- .filter(u => !u.deleted && !u.is_bot)
406
- .map(u => ({
407
- id: u.id,
408
- name: u.name,
409
- real_name: u.real_name,
410
- display_name: u.profile?.display_name,
411
- email: u.profile?.email,
412
- is_admin: u.is_admin
413
- }));
547
+ const users = (result.members || [])
548
+ .filter(u => !u.deleted && !u.is_bot)
549
+ .map(u => ({
550
+ id: u.id,
551
+ name: u.name,
552
+ real_name: u.real_name,
553
+ display_name: u.profile?.display_name,
554
+ email: u.profile?.email,
555
+ is_admin: u.is_admin
556
+ }));
557
+
558
+ allUsers.push(...users);
559
+ cursor = result.response_metadata?.next_cursor;
560
+
561
+ // Small delay between pagination requests
562
+ if (cursor && allUsers.length < maxUsers) {
563
+ await sleep(100);
564
+ }
565
+ } while (cursor && allUsers.length < maxUsers);
414
566
 
415
567
  return {
416
568
  content: [{
417
569
  type: "text",
418
- text: JSON.stringify({ count: users.length, users }, null, 2)
570
+ text: JSON.stringify({ count: allUsers.length, users: allUsers }, null, 2)
419
571
  }]
420
572
  };
421
573
  }
@@ -3,17 +3,138 @@
3
3
  *
4
4
  * Handles all Slack API communication with:
5
5
  * - Automatic token refresh on auth failure
6
- * - User name caching
6
+ * - User name caching with LRU + TTL
7
7
  * - Rate limiting
8
+ * - Network error retry with exponential backoff
9
+ * - Proactive token health checking
8
10
  */
9
11
 
10
12
  import { loadTokens, saveTokens, extractFromChrome } from "./token-store.js";
11
13
 
12
- // User cache to avoid repeated API calls
13
- const userCache = new Map();
14
+ // ============ Configuration ============
15
+
16
+ const TOKEN_WARNING_AGE = 6 * 60 * 60 * 1000; // 6 hours
17
+ const TOKEN_CRITICAL_AGE = 10 * 60 * 60 * 1000; // 10 hours
18
+ const REFRESH_COOLDOWN = 60 * 60 * 1000; // 1 hour between refresh attempts
19
+ const USER_CACHE_MAX_SIZE = 500;
20
+ const USER_CACHE_TTL = 60 * 60 * 1000; // 1 hour
21
+
22
+ const RETRYABLE_NETWORK_ERRORS = [
23
+ 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN',
24
+ 'ECONNREFUSED', 'EPIPE', 'UND_ERR_CONNECT_TIMEOUT'
25
+ ];
26
+
27
+ let lastRefreshAttempt = 0;
28
+
29
+ // ============ LRU Cache with TTL ============
30
+
31
+ class LRUCache {
32
+ constructor(maxSize = 500, ttlMs = 60 * 60 * 1000) {
33
+ this.maxSize = maxSize;
34
+ this.ttlMs = ttlMs;
35
+ this.cache = new Map();
36
+ }
37
+
38
+ get(key) {
39
+ const entry = this.cache.get(key);
40
+ if (!entry) return null;
41
+ if (Date.now() > entry.expiry) {
42
+ this.cache.delete(key);
43
+ return null;
44
+ }
45
+ // Move to end (most recently used)
46
+ this.cache.delete(key);
47
+ this.cache.set(key, entry);
48
+ return entry.value;
49
+ }
50
+
51
+ set(key, value) {
52
+ // Delete if exists (to update position)
53
+ this.cache.delete(key);
54
+ // Evict oldest if at capacity
55
+ if (this.cache.size >= this.maxSize) {
56
+ const firstKey = this.cache.keys().next().value;
57
+ this.cache.delete(firstKey);
58
+ }
59
+ this.cache.set(key, {
60
+ value,
61
+ expiry: Date.now() + this.ttlMs
62
+ });
63
+ }
64
+
65
+ has(key) {
66
+ return this.get(key) !== null;
67
+ }
68
+
69
+ get size() { return this.cache.size; }
70
+
71
+ clear() { this.cache.clear(); }
72
+
73
+ stats() {
74
+ return { size: this.cache.size, maxSize: this.maxSize, ttlMs: this.ttlMs };
75
+ }
76
+ }
77
+
78
+ // User cache with LRU + TTL
79
+ const userCache = new LRUCache(USER_CACHE_MAX_SIZE, USER_CACHE_TTL);
80
+
81
+ // ============ Token Health ============
82
+
83
+ /**
84
+ * Check token health and attempt proactive refresh if needed
85
+ */
86
+ export async function checkTokenHealth(logger = console) {
87
+ const silentLogger = { error: () => {}, warn: () => {}, log: () => {} };
88
+ const creds = loadTokens(false, silentLogger);
89
+
90
+ if (!creds) {
91
+ return { healthy: false, reason: 'no_tokens', message: 'No credentials found' };
92
+ }
93
+
94
+ const tokenAge = creds.updatedAt
95
+ ? Date.now() - new Date(creds.updatedAt).getTime()
96
+ : Infinity;
97
+ const ageHours = Math.round(tokenAge / (60 * 60 * 1000) * 10) / 10;
98
+
99
+ // Attempt proactive refresh if token is getting old
100
+ if (tokenAge > TOKEN_WARNING_AGE && Date.now() - lastRefreshAttempt > REFRESH_COOLDOWN) {
101
+ lastRefreshAttempt = Date.now();
102
+ logger.error?.(`Token is ${ageHours}h old, attempting proactive refresh...`);
103
+
104
+ const newTokens = extractFromChrome();
105
+ if (newTokens) {
106
+ saveTokens(newTokens.token, newTokens.cookie);
107
+ logger.error?.('Proactively refreshed tokens from Chrome');
108
+ return {
109
+ healthy: true,
110
+ refreshed: true,
111
+ age_hours: 0,
112
+ source: 'chrome-auto',
113
+ message: 'Tokens refreshed successfully'
114
+ };
115
+ } else {
116
+ logger.error?.('Could not refresh from Chrome (is Slack tab open?)');
117
+ }
118
+ }
119
+
120
+ return {
121
+ healthy: tokenAge < TOKEN_CRITICAL_AGE,
122
+ age_hours: ageHours,
123
+ warning: tokenAge > TOKEN_WARNING_AGE,
124
+ critical: tokenAge > TOKEN_CRITICAL_AGE,
125
+ source: creds.source,
126
+ updated_at: creds.updatedAt,
127
+ message: tokenAge > TOKEN_CRITICAL_AGE
128
+ ? 'Token may expire soon - open Slack in Chrome'
129
+ : tokenAge > TOKEN_WARNING_AGE
130
+ ? 'Token is getting old - will auto-refresh if Slack tab is open'
131
+ : 'Token is healthy'
132
+ };
133
+ }
14
134
 
15
135
  /**
16
136
  * Make an authenticated Slack API call
137
+ * Features: auth retry, rate limit handling, network error retry
17
138
  */
18
139
  export async function slackAPI(method, params = {}, options = {}) {
19
140
  const { retryOnAuthFail = true, retryCount = 0, maxRetries = 3, logger = console } = options;
@@ -23,15 +144,40 @@ export async function slackAPI(method, params = {}, options = {}) {
23
144
  throw new Error("No credentials available. Run refresh-tokens.sh or open Slack in Chrome.");
24
145
  }
25
146
 
26
- const response = await fetch(`https://slack.com/api/${method}`, {
27
- method: "POST",
28
- headers: {
29
- "Authorization": `Bearer ${creds.token}`,
30
- "Cookie": `d=${creds.cookie}`,
31
- "Content-Type": "application/json; charset=utf-8",
32
- },
33
- body: JSON.stringify(params),
34
- });
147
+ // Proactive token health check (non-blocking, only on first attempt)
148
+ if (retryCount === 0) {
149
+ checkTokenHealth({ error: () => {} }).catch(() => {});
150
+ }
151
+
152
+ let response;
153
+ try {
154
+ response = await fetch(`https://slack.com/api/${method}`, {
155
+ method: "POST",
156
+ headers: {
157
+ "Authorization": `Bearer ${creds.token}`,
158
+ "Cookie": `d=${creds.cookie}`,
159
+ "Content-Type": "application/json; charset=utf-8",
160
+ },
161
+ body: JSON.stringify(params),
162
+ });
163
+ } catch (networkError) {
164
+ // Retry on network errors with exponential backoff + jitter
165
+ if (retryCount < maxRetries) {
166
+ const isRetryable = RETRYABLE_NETWORK_ERRORS.some(e =>
167
+ networkError.message?.includes(e) ||
168
+ networkError.code === e ||
169
+ networkError.cause?.code === e
170
+ );
171
+ if (isRetryable || networkError.message?.includes('fetch')) {
172
+ const backoff = Math.min(1000 * Math.pow(2, retryCount), 10000);
173
+ const jitter = Math.random() * 1000;
174
+ logger.error?.(`Network error on ${method}: ${networkError.message}, retry ${retryCount + 1}/${maxRetries} in ${Math.round(backoff + jitter)}ms`);
175
+ await sleep(backoff + jitter);
176
+ return slackAPI(method, params, { ...options, retryCount: retryCount + 1 });
177
+ }
178
+ }
179
+ throw networkError;
180
+ }
35
181
 
36
182
  const data = await response.json();
37
183
 
@@ -40,14 +186,15 @@ export async function slackAPI(method, params = {}, options = {}) {
40
186
  if (data.error === "ratelimited" && retryCount < maxRetries) {
41
187
  const retryAfter = parseInt(response.headers.get("Retry-After") || "5", 10);
42
188
  const backoff = Math.min(retryAfter * 1000, 30000) * (retryCount + 1);
43
- logger.error(`Rate limited on ${method}, waiting ${backoff}ms before retry ${retryCount + 1}/${maxRetries}`);
44
- await sleep(backoff);
189
+ const jitter = Math.random() * 1000;
190
+ logger.error?.(`Rate limited on ${method}, waiting ${Math.round(backoff + jitter)}ms before retry ${retryCount + 1}/${maxRetries}`);
191
+ await sleep(backoff + jitter);
45
192
  return slackAPI(method, params, { ...options, retryCount: retryCount + 1 });
46
193
  }
47
194
 
48
195
  // Handle auth errors with auto-retry
49
196
  if ((data.error === "invalid_auth" || data.error === "token_expired") && retryOnAuthFail) {
50
- logger.error("Token expired, attempting Chrome auto-extraction...");
197
+ logger.error?.("Token expired, attempting Chrome auto-extraction...");
51
198
  const chromeTokens = extractFromChrome();
52
199
  if (chromeTokens) {
53
200
  saveTokens(chromeTokens.token, chromeTokens.cookie);
@@ -63,11 +210,13 @@ export async function slackAPI(method, params = {}, options = {}) {
63
210
  }
64
211
 
65
212
  /**
66
- * Resolve user ID to real name (with caching)
213
+ * Resolve user ID to real name (with LRU caching)
67
214
  */
68
215
  export async function resolveUser(userId, options = {}) {
69
216
  if (!userId) return "unknown";
70
- if (userCache.has(userId)) return userCache.get(userId);
217
+
218
+ const cached = userCache.get(userId);
219
+ if (cached) return cached;
71
220
 
72
221
  try {
73
222
  const result = await slackAPI("users.info", { user: userId }, options);
@@ -75,6 +224,7 @@ export async function resolveUser(userId, options = {}) {
75
224
  userCache.set(userId, name);
76
225
  return name;
77
226
  } catch (e) {
227
+ // Cache the ID itself to avoid repeated failed lookups
78
228
  userCache.set(userId, userId);
79
229
  return userId;
80
230
  }
@@ -91,10 +241,7 @@ export function clearUserCache() {
91
241
  * Get user cache stats
92
242
  */
93
243
  export function getUserCacheStats() {
94
- return {
95
- size: userCache.size,
96
- entries: Array.from(userCache.entries())
97
- };
244
+ return userCache.stats();
98
245
  }
99
246
 
100
247
  /**
@@ -8,17 +8,24 @@
8
8
  * 4. Chrome auto-extraction (fallback)
9
9
  */
10
10
 
11
- import { readFileSync, writeFileSync, existsSync } from "fs";
12
- import { homedir } from "os";
11
+ import { readFileSync, writeFileSync, existsSync, renameSync, unlinkSync } from "fs";
12
+ import { homedir, platform } from "os";
13
13
  import { join } from "path";
14
14
  import { execSync } from "child_process";
15
15
 
16
16
  const TOKEN_FILE = join(homedir(), ".slack-mcp-tokens.json");
17
17
  const KEYCHAIN_SERVICE = "slack-mcp-server";
18
18
 
19
- // ============ Keychain Storage ============
19
+ // Platform detection
20
+ const IS_MACOS = platform() === 'darwin';
21
+
22
+ // Refresh lock to prevent concurrent extraction attempts
23
+ let refreshInProgress = null;
24
+
25
+ // ============ Keychain Storage (macOS only) ============
20
26
 
21
27
  export function getFromKeychain(key) {
28
+ if (!IS_MACOS) return null; // Keychain is macOS-only
22
29
  try {
23
30
  const result = execSync(
24
31
  `security find-generic-password -s "${KEYCHAIN_SERVICE}" -a "${key}" -w 2>/dev/null`,
@@ -31,6 +38,7 @@ export function getFromKeychain(key) {
31
38
  }
32
39
 
33
40
  export function saveToKeychain(key, value) {
41
+ if (!IS_MACOS) return false; // Keychain is macOS-only
34
42
  try {
35
43
  // Delete existing entry
36
44
  try {
@@ -61,21 +69,57 @@ export function getFromFile() {
61
69
  }
62
70
  }
63
71
 
72
+ /**
73
+ * Atomic write to prevent file corruption from concurrent writes
74
+ */
75
+ function atomicWriteSync(filePath, content) {
76
+ const tempPath = `${filePath}.${process.pid}.tmp`;
77
+ try {
78
+ writeFileSync(tempPath, content);
79
+ if (IS_MACOS || platform() === 'linux') {
80
+ try { execSync(`chmod 600 "${tempPath}"`); } catch {}
81
+ }
82
+ renameSync(tempPath, filePath); // Atomic on POSIX systems
83
+ } catch (e) {
84
+ // Clean up temp file on error
85
+ try { unlinkSync(tempPath); } catch {}
86
+ throw e;
87
+ }
88
+ }
89
+
64
90
  export function saveToFile(token, cookie) {
65
91
  const data = {
66
92
  SLACK_TOKEN: token,
67
93
  SLACK_COOKIE: cookie,
68
94
  updated_at: new Date().toISOString()
69
95
  };
70
- writeFileSync(TOKEN_FILE, JSON.stringify(data, null, 2));
71
- try {
72
- execSync(`chmod 600 "${TOKEN_FILE}"`);
73
- } catch (e) { /* ignore on non-unix */ }
96
+ atomicWriteSync(TOKEN_FILE, JSON.stringify(data, null, 2));
74
97
  }
75
98
 
76
99
  // ============ Chrome Extraction ============
77
100
 
78
- export function extractFromChrome() {
101
+ // Multiple localStorage paths Slack might use (for robustness)
102
+ const SLACK_TOKEN_PATHS = [
103
+ // Current known path
104
+ `JSON.parse(localStorage.localConfig_v2).teams[Object.keys(JSON.parse(localStorage.localConfig_v2).teams)[0]].token`,
105
+ // Potential future paths
106
+ `JSON.parse(localStorage.localConfig_v3).teams[Object.keys(JSON.parse(localStorage.localConfig_v3).teams)[0]].token`,
107
+ // Redux store path (older Slack)
108
+ `JSON.parse(localStorage.getItem('reduxPersist:localConfig'))?.teams?.[Object.keys(JSON.parse(localStorage.getItem('reduxPersist:localConfig'))?.teams || {})[0]]?.token`,
109
+ // Direct boot data
110
+ `window.boot_data?.api_token`,
111
+ ];
112
+
113
+ /**
114
+ * Extract tokens from Chrome (macOS only, uses AppleScript)
115
+ * Returns null on non-macOS platforms
116
+ */
117
+ function extractFromChromeInternal() {
118
+ if (!IS_MACOS) {
119
+ // AppleScript/osascript is macOS-only
120
+ return null;
121
+ }
122
+
79
123
  try {
80
124
  // Extract cookie
81
125
  const cookieScript = `
@@ -96,13 +140,17 @@ export function extractFromChrome() {
96
140
 
97
141
  if (!cookie || !cookie.startsWith('xoxd-')) return null;
98
142
 
99
- // Extract token
143
+ // Try multiple token extraction paths
144
+ const tokenPathsJS = SLACK_TOKEN_PATHS.map((path, i) =>
145
+ `try { var t${i} = ${path}; if (t${i}?.startsWith('xoxc-')) return t${i}; } catch(e) {}`
146
+ ).join(' ');
147
+
100
148
  const tokenScript = `
101
149
  tell application "Google Chrome"
102
150
  repeat with w in windows
103
151
  repeat with t in tabs of w
104
152
  if URL of t contains "slack.com" then
105
- return execute t javascript "try{JSON.parse(localStorage.localConfig_v2).teams[Object.keys(JSON.parse(localStorage.localConfig_v2).teams)[0]].token}catch(e){''}"
153
+ return execute t javascript "(function() { ${tokenPathsJS} return ''; })()"
106
154
  end if
107
155
  end repeat
108
156
  end repeat
@@ -121,6 +169,32 @@ export function extractFromChrome() {
121
169
  }
122
170
  }
123
171
 
172
+ /**
173
+ * Extract tokens from Chrome with mutex lock
174
+ * Prevents concurrent extraction attempts (race condition fix)
175
+ */
176
+ export function extractFromChrome() {
177
+ // Simple mutex: if another extraction is running, skip this one
178
+ // This prevents race conditions from background + foreground refresh
179
+ if (refreshInProgress) {
180
+ return null; // Let the in-progress extraction complete
181
+ }
182
+
183
+ try {
184
+ refreshInProgress = true;
185
+ return extractFromChromeInternal();
186
+ } finally {
187
+ refreshInProgress = false;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Check if auto-refresh is available on this platform
193
+ */
194
+ export function isAutoRefreshAvailable() {
195
+ return IS_MACOS;
196
+ }
197
+
124
198
  // ============ Main Token Loader ============
125
199
 
126
200
  export function loadTokens(forceRefresh = false, logger = console) {