slackChannelSuggestions.ts
1 import { z } from 'zod' 2 import type { SuggestionItem } from '../../components/PromptInput/PromptInputFooterSuggestions.js' 3 import type { MCPServerConnection } from '../../services/mcp/types.js' 4 import { logForDebugging } from '../debug.js' 5 import { lazySchema } from '../lazySchema.js' 6 import { createSignal } from '../signal.js' 7 import { jsonParse } from '../slowOperations.js' 8 9 const SLACK_SEARCH_TOOL = 'slack_search_channels' 10 11 // Plain Map (not LRUCache) — findReusableCacheEntry needs to iterate all 12 // entries for prefix matching, which LRUCache doesn't expose cleanly. 13 const cache = new Map<string, string[]>() 14 // Flat set of every channel name ever returned by MCP — used to gate 15 // highlighting so only confirmed-real channels turn blue in the prompt. 16 const knownChannels = new Set<string>() 17 let knownChannelsVersion = 0 18 const knownChannelsChanged = createSignal() 19 export const subscribeKnownChannels = knownChannelsChanged.subscribe 20 let inflightQuery: string | null = null 21 let inflightPromise: Promise<string[]> | null = null 22 23 function findSlackClient( 24 clients: MCPServerConnection[], 25 ): MCPServerConnection | undefined { 26 return clients.find(c => c.type === 'connected' && c.name.includes('slack')) 27 } 28 29 async function fetchChannels( 30 clients: MCPServerConnection[], 31 query: string, 32 ): Promise<string[]> { 33 const slackClient = findSlackClient(clients) 34 if (!slackClient || slackClient.type !== 'connected') { 35 return [] 36 } 37 38 try { 39 const result = await slackClient.client.callTool( 40 { 41 name: SLACK_SEARCH_TOOL, 42 arguments: { 43 query, 44 limit: 20, 45 channel_types: 'public_channel,private_channel', 46 }, 47 }, 48 undefined, 49 { timeout: 5000 }, 50 ) 51 52 const content = result.content 53 if (!Array.isArray(content)) return [] 54 55 const rawText = content 56 .filter((c): c is { type: 'text'; text: string } => c.type === 'text') 57 .map(c => c.text) 58 .join('\n') 59 60 return parseChannels(unwrapResults(rawText)) 61 } catch (error) { 62 logForDebugging(`Failed to fetch Slack channels: ${error}`) 63 return [] 64 } 65 } 66 67 // The Slack MCP server wraps its markdown in a JSON envelope: 68 // {"results":"# Search Results...\nName: #chan\n..."} 69 const resultsEnvelopeSchema = lazySchema(() => 70 z.object({ results: z.string() }), 71 ) 72 73 function unwrapResults(text: string): string { 74 const trimmed = text.trim() 75 if (!trimmed.startsWith('{')) return text 76 try { 77 const parsed = resultsEnvelopeSchema().safeParse(jsonParse(trimmed)) 78 if (parsed.success) return parsed.data.results 79 } catch { 80 // jsonParse threw — fall through 81 } 82 return text 83 } 84 85 // Parse channel names from slack_search_channels text output. 86 // The Slack MCP server returns markdown with "Name: #channel-name" lines. 87 function parseChannels(text: string): string[] { 88 const channels: string[] = [] 89 const seen = new Set<string>() 90 91 for (const line of text.split('\n')) { 92 const m = line.match(/^Name:\s*#?([a-z0-9][a-z0-9_-]{0,79})\s*$/) 93 if (m && !seen.has(m[1]!)) { 94 seen.add(m[1]!) 95 channels.push(m[1]!) 96 } 97 } 98 99 return channels 100 } 101 102 export function hasSlackMcpServer(clients: MCPServerConnection[]): boolean { 103 return findSlackClient(clients) !== undefined 104 } 105 106 export function getKnownChannelsVersion(): number { 107 return knownChannelsVersion 108 } 109 110 export function findSlackChannelPositions( 111 text: string, 112 ): Array<{ start: number; end: number }> { 113 const positions: Array<{ start: number; end: number }> = [] 114 const re = /(^|\s)#([a-z0-9][a-z0-9_-]{0,79})(?=\s|$)/g 115 let m: RegExpExecArray | null 116 while ((m = re.exec(text)) !== null) { 117 if (!knownChannels.has(m[2]!)) continue 118 const start = m.index + m[1]!.length 119 positions.push({ start, end: start + 1 + m[2]!.length }) 120 } 121 return positions 122 } 123 124 // Slack's search tokenizes on hyphens and requires whole-word matches, so 125 // "claude-code-team-en" returns 0 results. Strip the trailing partial segment 126 // so the MCP query is "claude-code-team" (complete words only), then filter 127 // locally. This keeps the query maximally specific (avoiding the 20-result 128 // cap) while never sending a partial word that kills the search. 129 function mcpQueryFor(searchToken: string): string { 130 const lastSep = Math.max( 131 searchToken.lastIndexOf('-'), 132 searchToken.lastIndexOf('_'), 133 ) 134 return lastSep > 0 ? searchToken.slice(0, lastSep) : searchToken 135 } 136 137 // Find a cached entry whose key is a prefix of mcpQuery and still has 138 // matches for searchToken. Lets typing "c"→"cl"→"cla" reuse the "c" cache 139 // instead of issuing a new MCP call per keystroke. 140 function findReusableCacheEntry( 141 mcpQuery: string, 142 searchToken: string, 143 ): string[] | undefined { 144 let best: string[] | undefined 145 let bestLen = 0 146 for (const [key, channels] of cache) { 147 if ( 148 mcpQuery.startsWith(key) && 149 key.length > bestLen && 150 channels.some(c => c.startsWith(searchToken)) 151 ) { 152 best = channels 153 bestLen = key.length 154 } 155 } 156 return best 157 } 158 159 export async function getSlackChannelSuggestions( 160 clients: MCPServerConnection[], 161 searchToken: string, 162 ): Promise<SuggestionItem[]> { 163 if (!searchToken) return [] 164 165 const mcpQuery = mcpQueryFor(searchToken) 166 const lower = searchToken.toLowerCase() 167 168 let channels = cache.get(mcpQuery) ?? findReusableCacheEntry(mcpQuery, lower) 169 if (!channels) { 170 if (inflightQuery === mcpQuery && inflightPromise) { 171 channels = await inflightPromise 172 } else { 173 inflightQuery = mcpQuery 174 inflightPromise = fetchChannels(clients, mcpQuery) 175 channels = await inflightPromise 176 cache.set(mcpQuery, channels) 177 const before = knownChannels.size 178 for (const c of channels) knownChannels.add(c) 179 if (knownChannels.size !== before) { 180 knownChannelsVersion++ 181 knownChannelsChanged.emit() 182 } 183 if (cache.size > 50) { 184 cache.delete(cache.keys().next().value!) 185 } 186 if (inflightQuery === mcpQuery) { 187 inflightQuery = null 188 inflightPromise = null 189 } 190 } 191 } 192 193 return channels 194 .filter(c => c.startsWith(lower)) 195 .sort() 196 .slice(0, 10) 197 .map(c => ({ 198 id: `slack-channel-${c}`, 199 displayText: `#${c}`, 200 })) 201 } 202 203 export function clearSlackChannelCache(): void { 204 cache.clear() 205 knownChannels.clear() 206 knownChannelsVersion = 0 207 inflightQuery = null 208 inflightPromise = null 209 }