Files
2026-10-04 00:39:20 +08:00

294 lines
10 KiB
TypeScript

import { adapterMigrationLifecycle, registerAdapterShutdown } from '../common/migration-lifecycle.js'
/**
* Slack Adapter for Claude Code Desktop
*
* Runs the app in Socket Mode, so the desktop needs no public request URL —
* the same property that makes the Feishu / WeCom / QQ adapters work from a
* laptop. Credentials come from Desktop Settings → IM, or from
* SLACK_BOT_TOKEN (`xoxb-…`) and SLACK_APP_TOKEN (`xapp-…`).
*
* 启动:SLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... bun run slack/index.ts
*/
import { WsBridge, type AttachmentRef } from '../common/ws-bridge.js'
import { MessageDedup } from '../common/message-dedup.js'
import { loadConfig } from '../common/config.js'
import { splitMessage } from '../common/format.js'
import { SessionStore } from '../common/session-store.js'
import { createAdapterClient } from '../common/adapter-client.js'
import { AttachmentStore } from '../common/attachment/attachment-store.js'
import { checkAttachmentLimit } from '../common/attachment/attachment-limits.js'
import {
attachmentKindForMime,
imageExtensionForMime,
inferMimeFromFileName,
} from '../common/attachment/mime.js'
import {
ImChatRuntime,
type ChatPort,
type OutboundImage,
type ResponseStream,
} from '../common/chat-runtime.js'
import { SlackApiClient } from './api.js'
import { SlackSocketMode } from './socket-mode.js'
import { extractSlackPayload, type SlackFile } from './extract-payload.js'
const SLACK_TEXT_LIMIT = 3_500
/** Slack rate-limits `chat.update` to roughly one call per second per channel. */
const SLACK_EDIT_INTERVAL_MS = 1_200
const config = loadConfig()
if (!config.slack.botToken || !config.slack.appToken) {
console.error('[Slack] Missing SLACK_BOT_TOKEN / SLACK_APP_TOKEN. Configure Slack in Desktop Settings > IM.')
process.exit(1)
}
const api = new SlackApiClient(config.slack.botToken)
const bridge = new WsBridge(config.serverUrl, 'slack')
const dedup = new MessageDedup()
const sessionStore = new SessionStore()
const { httpClient, defaultWorkDir } = createAdapterClient(config, config.slack)
const attachmentStore = new AttachmentStore()
attachmentStore.gc().catch((err) => {
console.warn('[Slack] AttachmentStore.gc failed:', err instanceof Error ? err.message : err)
})
let botUserId: string | undefined
/** Bounded: `resolveDisplayName` runs for unpaired senders too, so an unbounded
* map would grow with every stranger who messages the bot. */
const displayNames = new Map<string, string>()
const DISPLAY_NAME_CACHE_LIMIT = 500
/** The thread the user last wrote in, so answers land where they asked. */
const replyThreads = new Map<string, string | undefined>()
async function sendChunkedText(chatId: string, text: string): Promise<void> {
const threadTs = replyThreads.get(chatId)
for (const chunk of splitMessage(text, SLACK_TEXT_LIMIT)) {
await api.postMessage(chatId, chunk, threadTs)
}
}
/**
* One assistant turn rendered as a Slack message that edits in place.
*
* The first flush posts a message; later flushes edit it, throttled so a fast
* stream cannot burn the per-channel `chat.update` budget. Text beyond Slack's
* practical message length continues in follow-up messages, and the editing
* target moves to the newest one so the tail keeps streaming.
*/
class SlackResponse implements ResponseStream {
private messageTs: string | null = null
private current = ''
private pending = ''
private lastEditAt = 0
constructor(private readonly chatId: string) {}
async append(delta: string): Promise<void> {
if (!delta) return
this.pending += delta
const now = Date.now()
if (this.messageTs && now - this.lastEditAt < SLACK_EDIT_INTERVAL_MS) return
await this.render(now)
}
async finish(): Promise<void> {
// Everything already rendered is on screen; only unflushed text is owed.
if (!this.pending) return
await this.render(Date.now())
}
private async render(now: number): Promise<void> {
// What the open message currently shows, so a failure can resume from it
// instead of re-posting text the user is already looking at.
const displayed = this.messageTs ? this.current : ''
const next = this.current + this.pending
if (!next.trim()) {
this.pending = ''
return
}
this.pending = ''
try {
const chunks = splitMessage(next, SLACK_TEXT_LIMIT)
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i]!
const isLast = i === chunks.length - 1
if (i === 0 && this.messageTs) {
await api.updateMessage(this.chatId, this.messageTs, chunk)
} else {
this.messageTs = await api.postMessage(this.chatId, chunk, replyThreads.get(this.chatId))
}
// A chunk that is not the last one is sealed at its length limit; the
// remainder continues in a fresh message rather than editing this one.
if (!isLast) this.messageTs = null
}
this.current = chunks[chunks.length - 1]!
this.lastEditAt = now
} catch (err) {
console.error('[Slack] Failed to render response:', err instanceof Error ? err.message : err)
// Drop the edit target — a stale ts would fail every later update too —
// and keep only the text that never made it onto the screen, so the
// retry continues the answer instead of repeating its visible prefix.
this.messageTs = null
this.current = ''
this.pending = next.slice(displayed.length)
}
}
}
const port: ChatPort = {
platform: 'slack',
logPrefix: '[Slack]',
async sendNotice(chatId, text) {
await sendChunkedText(chatId, text)
},
createResponse(chatId) {
return new SlackResponse(chatId)
},
async sendImage(chatId, image: OutboundImage) {
const check = checkAttachmentLimit('image', image.buffer.length, image.mime)
if (!check.ok) {
console.warn('[Slack] Outbound image rejected:', check.hint)
return
}
await api.uploadFile({
channel: chatId,
buffer: image.buffer,
filename: `claude-${Date.now()}.${imageExtensionForMime(image.mime)}`,
title: image.alt,
threadTs: replyThreads.get(chatId),
})
},
clearChat(chatId) {
replyThreads.delete(chatId)
},
}
const runtime = new ImChatRuntime({
port,
config,
platformConfig: config.slack,
bridge,
sessionStore,
httpClient,
defaultWorkDir,
dedup,
flushIntervalMs: 900,
flushCharThreshold: 240,
})
async function resolveDisplayName(userId: string): Promise<string> {
const cached = displayNames.get(userId)
if (cached) return cached
try {
const info = await api.get<{ user?: { real_name?: string; name?: string } }>('users.info', {
user: userId,
})
const name = info.user?.real_name?.trim() || info.user?.name?.trim() || userId
if (displayNames.size >= DISPLAY_NAME_CACHE_LIMIT) {
const oldest = displayNames.keys().next().value
if (oldest !== undefined) displayNames.delete(oldest)
}
displayNames.set(userId, name)
return name
} catch {
// users:read may be missing on an app installed from an older manifest.
return userId
}
}
async function collectAttachments(chatId: string, files: SlackFile[]): Promise<AttachmentRef[]> {
if (files.length === 0) return []
const sessionId = sessionStore.get(chatId)?.sessionId ?? chatId
const refs: AttachmentRef[] = []
let failures = 0
for (const file of files) {
try {
const buffer = await api.downloadFile(file.url_private_download ?? file.url_private)
const name = file.name?.trim() || file.title?.trim() || `slack-file-${file.id ?? Date.now()}`
const mimeType = file.mimetype?.split(';')[0]?.trim()
|| inferMimeFromFileName(name)
|| 'application/octet-stream'
const kind = attachmentKindForMime(mimeType)
const check = checkAttachmentLimit(kind, buffer.length, mimeType)
if (!check.ok) {
await port.sendNotice(chatId, check.hint)
continue
}
if (kind === 'image') {
refs.push({ type: 'image', name, data: buffer.toString('base64'), mimeType })
continue
}
const target = attachmentStore.resolvePath('slack', sessionId, name)
const path = await attachmentStore.write(target, buffer)
refs.push({ type: 'file', name, path, mimeType })
} catch (err) {
failures += 1
console.error('[Slack] file download failed:', err instanceof Error ? err.message : err)
}
}
if (failures > 0) {
await port.sendNotice(
chatId,
failures === files.length ? '附件下载失败,请稍后重试。' : `${failures} 个附件下载失败,已跳过。`,
)
}
return refs
}
const socket = new SlackSocketMode({
openConnection: () => api.openSocketConnection(config.slack.appToken),
logPrefix: '[Slack]',
onEnvelope: (envelope) => {
if (envelope.type !== 'events_api') return
const payload = extractSlackPayload(envelope.payload?.event, { botUserId })
if (!payload) return
replyThreads.set(payload.chatId, payload.threadTs)
if (adapterMigrationLifecycle.isQuiescing) return
void adapterMigrationLifecycle.track((async () => {
try {
await runtime.handleInbound({
chatId: payload.chatId,
userId: payload.userId,
displayName: await resolveDisplayName(payload.userId),
dedupKey: payload.dedupKey,
text: payload.text,
hasAttachments: payload.files.length > 0,
loadAttachments: () => collectAttachments(payload.chatId, payload.files),
})
} catch (err) {
console.error('[Slack] Failed to prepare inbound message:', err)
}
})())
},
})
console.log('[Slack] Starting adapter...')
console.log(`[Slack] Server: ${config.serverUrl}`)
void adapterMigrationLifecycle.track((async () => {
try {
const identity = await api.authTest()
botUserId = identity.userId || undefined
console.log(`[Slack] Authenticated as ${botUserId ?? 'unknown bot user'}`)
} catch (err) {
console.error('[Slack] auth.test failed:', err instanceof Error ? err.message : err)
process.exit(1)
}
if (!adapterMigrationLifecycle.isQuiescing) await socket.start()
})())
registerAdapterShutdown(async () => {
console.log('[Slack] Shutting down...')
await socket.stop()
bridge.destroy()
dedup.destroy()
})