Files
程序员阿江(Relakkes) 814c02a786 feat(im): add scan-to-create Feishu bots plus WeCom, QQ and Slack adapters
Connecting Feishu meant creating a bot by hand on the open platform and
pasting an App ID and App Secret back. Feishu also exposes an RFC 8628
device-authorization flow, so the desktop can now render a QR code, and
confirming it in the app creates the bot and stores its credentials
directly. `adapters/feishu/registration.ts` implements that protocol
rather than importing `registerApp` from `@larksuiteoapi/node-sdk@1.73`:
the repository pins 1.60 for the chat client, and the SDK runs the whole
poll inside one un-cancellable promise where the desktop needs the
stateless begin/poll pair the DingTalk registration already uses. The
scan is create-only, so it can never rewrite the configuration of a bot
the user already runs, and it pre-fills exactly the scopes, events and
callbacks this adapter calls. International tenants finish on Lark's
domain, which is now persisted and honoured by the client.

WeCom, QQ and Slack join the same session model. WeCom and QQ bind by
scanning; Slack has no scan flow, so it uses an app manifest that
pre-fills the scopes and Socket Mode. All three run over long
connections, so no public callback URL is needed, and all three accept
private chats only — pairing authorizes one person, and answering in a
group would extend that authorization to everyone else in the room.

They are built on a new `adapters/common/chat-runtime.ts` instead of a
fourth copy of the loop the five existing adapters each carry. A platform
supplies a `ChatPort` — how to say something, how to open a streaming
reply, optionally how to send an image — and the runtime owns pairing,
command routing, session restore, permission bookkeeping and the
translation of the server's stream. The existing five are deliberately
left on their own copies; migrating them is a separate change with its
own regression surface.

Attachments are downloaded through a deferred loader that runs after the
pairing gate and inside the per-chat queue. Resolving them eagerly would
let an unpaired stranger make the adapter fetch bytes and write them
under ~/.claude/im-downloads — on Slack with the bot token attached —
and would let a slow attachment overtake a text message sent after it.

The sidecar launcher's per-adapter branches become one table. It is
declared above the mode dispatch on purpose: `runAdapters` is hoisted and
runs at module top level, so a table declared below it is still in its
temporal dead zone when the adapters mode reads it — which type checks,
lints and unit tests all miss, and only the compiled binary reveals.

Verified with the checks `check:impact` selects: adapters, server,
desktop, electron, policy, chat-contract, agent-flow, docs, native
(sidecar compile, packaging and an adapters-mode smoke against the real
binary) and coverage. The scan flows themselves are not verified against
live platforms — that needs real WeCom, QQ and Slack accounts and would
create real bots.

Claude-Session: https://claude.ai/code/session_01CCGoP316AK7wdQG3Ms6Uwq
2026-09-05 23:46:50 +08:00

160 lines
4.7 KiB
TypeScript

/**
* Slack Socket Mode connection.
*
* Socket Mode replaces the public HTTPS request URL a Slack app would normally
* need: the app opens an outbound WebSocket and Slack pushes envelopes down it.
* That is what makes a desktop-local bot possible at all, the same role the
* long connection plays for Feishu, WeCom and QQ.
*
* The transport rules that matter here: every envelope must be acknowledged by
* its `envelope_id` or Slack retries it, and Slack periodically asks the client
* to reconnect (`type: "disconnect"`) so it can drain a server.
*/
import WebSocket from 'ws'
export type SocketEnvelope = {
type?: string
envelope_id?: string
payload?: {
event?: Record<string, any>
[key: string]: unknown
}
reason?: string
[key: string]: unknown
}
export type SocketModeOptions = {
/** Resolves a fresh `wss://` URL. Called again for every reconnect. */
openConnection: () => Promise<string>
onEnvelope: (envelope: SocketEnvelope) => void
createWebSocket?: (url: string) => WebSocket
logPrefix?: string
}
const RECONNECT_BASE_MS = 1_000
const RECONNECT_MAX_MS = 30_000
/**
* Keeps exactly one Socket Mode WebSocket alive.
*
* Reconnects are unconditional and unbounded on purpose: unlike a chat session,
* there is nothing to fall back to — if this socket stays down, the bot is
* simply deaf until the process restarts.
*/
export class SlackSocketMode {
private socket: WebSocket | null = null
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private attempts = 0
private stopped = false
private readonly logPrefix: string
constructor(private readonly options: SocketModeOptions) {
this.logPrefix = options.logPrefix ?? '[Slack]'
}
async start(): Promise<void> {
this.stopped = false
await this.connect()
}
stop(): void {
this.stopped = true
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
const socket = this.socket
this.socket = null
if (!socket) return
socket.removeAllListeners()
// Swallow a teardown-time transport error: without a listener attached,
// `ws` re-raises it as an unhandled 'error' event and kills the process.
socket.on('error', () => {})
try {
socket.close(1000, 'adapter stopped')
} catch {
// Already closing.
}
}
private async connect(): Promise<void> {
if (this.stopped) return
let url: string
try {
url = await this.options.openConnection()
} catch (err) {
console.error(
`${this.logPrefix} Failed to open Socket Mode connection:`,
err instanceof Error ? err.message : err,
)
this.scheduleReconnect()
return
}
if (this.stopped) return
const socket = (this.options.createWebSocket ?? ((target) => new WebSocket(target)))(url)
this.socket = socket
socket.on('open', () => {
this.attempts = 0
console.log(`${this.logPrefix} Socket Mode connected`)
})
socket.on('message', (raw) => {
let envelope: SocketEnvelope
try {
envelope = JSON.parse(raw.toString())
} catch (err) {
console.error(`${this.logPrefix} Socket parse error:`, err)
return
}
// Acknowledge before handling: Slack redelivers anything unacked within
// three seconds, and handling can take much longer than that.
if (envelope.envelope_id && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ envelope_id: envelope.envelope_id }))
}
if (envelope.type === 'disconnect') {
console.log(`${this.logPrefix} Slack asked to reconnect (${envelope.reason ?? 'unknown'})`)
try {
socket.close(1000, 'slack requested reconnect')
} catch {
// The close handler below still schedules the reconnect.
}
return
}
if (envelope.type === 'hello') return
try {
this.options.onEnvelope(envelope)
} catch (err) {
console.error(`${this.logPrefix} Envelope handler error:`, err)
}
})
socket.on('close', () => {
if (this.socket !== socket) return
this.socket = null
if (this.stopped) return
this.scheduleReconnect()
})
socket.on('error', (err: Error) => {
console.error(`${this.logPrefix} Socket error:`, err.message)
})
}
private scheduleReconnect(): void {
if (this.stopped || this.reconnectTimer) return
this.attempts += 1
const delay = Math.min(RECONNECT_BASE_MS * 2 ** (this.attempts - 1), RECONNECT_MAX_MS)
console.log(`${this.logPrefix} Reconnecting Socket Mode in ${delay}ms (attempt ${this.attempts})`)
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
void this.connect()
}, delay)
}
}