/ src / utils / suggestions / slackChannelSuggestions.ts
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  }