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
This commit is contained in:
程序员阿江(Relakkes)
2026-08-24 00:41:47 +08:00
parent 90a4a11d28
commit 814c02a786
74 changed files with 9261 additions and 237 deletions
+2 -1
View File
@@ -2,7 +2,8 @@
These rules apply to `adapters/` changes in addition to the root instructions.
- Reuse shared adapter utilities and keep platform-specific behavior within the relevant Telegram, Feishu, WeChat, or DingTalk package.
- Reuse shared adapter utilities and keep platform-specific behavior within the relevant platform package.
- New text-chat platforms build on `common/chat-runtime.ts` and supply a `ChatPort`; do not re-implement pairing, command routing, or the server-stream translation per platform.
- Install adapter dependencies in `adapters/` on a fresh checkout; do not change root dependencies for an adapter-only task without a verified need.
- Add focused tests for the affected adapter, then follow `bun run check:impact`; adapter changes normally select `bun run check:adapters`.
- Required tests must not call real messaging platforms or use saved accounts/tokens. Use fixtures, mocked transports, and temporary session/config paths.
+30 -11
View File
@@ -10,6 +10,9 @@
- `docs/im/whatsapp.md`
- `docs/im/telegram.md`
- `docs/im/feishu.md`
- `docs/im/wecom.md`
- `docs/im/qq.md`
- `docs/im/slack.md`
## 当前方案摘要
@@ -27,7 +30,7 @@ Desktop Webapp Settings
注意两点:
- IM 配置和配对都在 Desktop Webapp 的 `Settings -> IM 接入`
- Webapp 不会自动启动 Adapter 进程,仍需手动运行 `bun run wechat`、`bun run dingtalk`、`bun run telegram` 或 `bun run feishu`
- 从源码运行时 Webapp 不会自动启动 Adapter 进程,仍需手动运行 `bun run <platform>`;发布版桌面端会把它们作为 sidecar 拉起
## 快速启动
@@ -35,14 +38,7 @@ Desktop Webapp Settings
cd adapters
bun install
bun run telegram
# 或
bun run feishu
# 或
bun run wechat
# 或
bun run dingtalk
# 或
bun run whatsapp
# 或 feishu / wechat / dingtalk / whatsapp / wecom / qq / slack
```
## 开发
@@ -58,6 +54,9 @@ bun test feishu/
bun test wechat/
bun test dingtalk/
bun test whatsapp/
bun test wecom/
bun test qq/
bun test slack/
```
### 目录结构
@@ -65,7 +64,8 @@ bun test whatsapp/
```text
adapters/
├── common/
│ └── attachment/ # 跨平台附件工具(types / limits / store / image-watcher)
│ ├── chat-runtime.ts # 跨平台会话循环(配对 / 命令 / 服务端流 -> ChatPort)
│ └── attachment/ # 跨平台附件工具(types / limits / store / image-watcher / mime)
├── telegram/
│ └── media.ts # TelegramMediaService(grammy Bot API 封装)
├── feishu/
@@ -81,6 +81,19 @@ adapters/
│ ├── session.ts # Baileys socket / auth state 封装
│ ├── protocol.ts # WhatsApp QR 登录 / 解绑协议封装
│ └── index.ts # WhatsApp Web 私聊 Adapter
├── wecom/
│ ├── qr-auth.ts # 企业微信智能机器人扫码创建
│ ├── extract-payload.ts # 入站消息解析(text / voice / mixed / quote)
│ └── index.ts # 企业微信长连接 Adapter(流式消息)
├── qq/
│ ├── qr-auth.ts # QQ 开放平台扫码授权
│ ├── extract-payload.ts # 入站 C2C 消息解析
│ └── index.ts # QQ WebSocket 网关 Adapter(stream_messages)
├── slack/
│ ├── api.ts # Slack Web API 封装(消息 / 文件 / Socket 连接)
│ ├── socket-mode.ts # Socket Mode 长连接
│ ├── manifest.ts # 应用清单与一键创建链接
│ └── index.ts # Slack 私信 Adapter(原地编辑)
├── package.json
├── tsconfig.json
└── README.md
@@ -88,13 +101,16 @@ adapters/
## 附件收发
两个 Adapter 都支持双向图片/文件,和 Desktop 端走同一套 `AttachmentRef` 协议透传给主进程。
各 Adapter 支持双向图片/文件,和 Desktop 端走同一套 `AttachmentRef` 协议透传给主进程。
**入站(用户 → Claude):**
- 飞书: 图片(jpg/png/gif/webp/heic)、文档(doc/xls/ppt/pdf 等)、post 富文本里的 img/file 元素
- Telegram: photo、document、video、audio、voice
- WhatsApp: image、document、video、audio、sticker
- 企业微信: image、file、mixed 里的 image(下载后按 aeskey 解密);voice 由平台转写成文本
- QQ: 事件里的 attachments;voice 用平台的 ASR 文本,不下载音频
- Slack: 私信里的 files(仅 files.slack.com,带 Bot Token 下载)
下载落地到 `~/.claude/im-downloads/{platform}/{sessionId}/`,24 小时后自动 GC(`.part` 孤文件 10 分钟超时)。大小限制:单张图 ≤10 MB、单个文件 ≤30 MB,超限直接拒收并在 IM 里提示。
@@ -105,6 +121,9 @@ Agent 流式文本里的 markdown 图片引用 `![alt](path|url|data:)` 会被 `
- 飞书: `im.message.create(msg_type='image')` 单发(card 内嵌是后续优化)
- Telegram: `bot.api.sendPhoto(InputFile)` 单发
- WhatsApp: Baileys `sendMessage({ image })` 单发
- 企业微信: `uploadMedia` + `sendMediaMessage` 单发
- QQ: `sendMedia(MediaFileType.IMAGE)` 单发
- Slack: `files.getUploadURLExternal` 三步上传后发进私信
非图片类出站(Agent 产的 pdf/zip 等)暂不支持。
+13
View File
@@ -6,6 +6,9 @@
"name": "claude-code-im-adapters",
"dependencies": {
"@larksuiteoapi/node-sdk": "^1.60.0",
"@tencent-connect/qqbot-connector": "1.2.0",
"@tencent-connect/qqbot-nodejs": "1.0.4",
"@wecom/aibot-node-sdk": "1.0.7",
"@whiskeysockets/baileys": "7.0.0-rc.9",
"dingtalk-stream": "2.1.4",
"grammy": "^1.42.0",
@@ -117,6 +120,10 @@
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "https://registry.npmmirror.com/@protobufjs/utf8/-/utf8-1.1.1.tgz", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="],
"@tencent-connect/qqbot-connector": ["@tencent-connect/qqbot-connector@1.2.0", "https://registry.npmmirror.com/@tencent-connect/qqbot-connector/-/qqbot-connector-1.2.0.tgz", { "dependencies": { "qrcode-terminal": "^0.12" } }, "sha512-FAOUCvgxP4M9UiYbHrtVgJ7BavSlh1CHJPjeEQuTAkP9MZ91lX4yATqv6I/lB9yp15nVq+G2DaGSSJwQTSoSsA=="],
"@tencent-connect/qqbot-nodejs": ["@tencent-connect/qqbot-nodejs@1.0.4", "https://registry.npmmirror.com/@tencent-connect/qqbot-nodejs/-/qqbot-nodejs-1.0.4.tgz", { "dependencies": { "ws": "^8.21.0" }, "peerDependencies": { "mpg123-decoder": "^1.0.3", "silk-wasm": "^3.7.1" }, "optionalPeers": ["mpg123-decoder", "silk-wasm"] }, "sha512-gU5HySLplczZXMUjM7NtiUACY7YfX9YlI/R9PKzCLMgLmHvwsX9L2sitsrYPMentGUr9b8NLfSaSTsndF77NBA=="],
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "https://registry.npmmirror.com/@tokenizer/inflate/-/inflate-0.4.1.tgz", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "https://registry.npmmirror.com/@tokenizer/token/-/token-0.3.0.tgz", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
@@ -125,6 +132,8 @@
"@types/ws": ["@types/ws@8.18.1", "https://registry.npmmirror.com/@types/ws/-/ws-8.18.1.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@wecom/aibot-node-sdk": ["@wecom/aibot-node-sdk@1.0.7", "https://registry.npmmirror.com/@wecom/aibot-node-sdk/-/aibot-node-sdk-1.0.7.tgz", { "dependencies": { "axios": "^1.6.7", "eventemitter3": "^5.0.1", "ws": "^8.16.0" } }, "sha512-51w+sTqunry6GD3HFvmuh0gArMSJDFE418vyvR1wMJHj1N6DaFuGD3HuaY2fazZs3mb9FWeQFX3+vU5t0Qhwmw=="],
"@whiskeysockets/baileys": ["@whiskeysockets/baileys@7.0.0-rc.9", "https://registry.npmmirror.com/@whiskeysockets/baileys/-/baileys-7.0.0-rc.9.tgz", { "dependencies": { "@cacheable/node-cache": "^1.4.0", "@hapi/boom": "^9.1.3", "async-mutex": "^0.5.0", "libsignal": "git+https://github.com/whiskeysockets/libsignal-node.git", "lru-cache": "^11.1.0", "music-metadata": "^11.7.0", "p-queue": "^9.0.0", "pino": "^9.6", "protobufjs": "^7.2.4", "ws": "^8.13.0" }, "peerDependencies": { "audio-decode": "^2.1.3", "jimp": "^1.6.0", "link-preview-js": "^3.0.0", "sharp": "*" }, "optionalPeers": ["audio-decode", "jimp", "link-preview-js"] }, "sha512-YFm5gKXfDP9byCXCW3OPHKXLzrAKzolzgVUlRosHHgwbnf2YOO3XknkMm6J7+F0ns8OA0uuSBhgkRHTDtqkacw=="],
"abort-controller": ["abort-controller@3.0.0", "https://registry.npmmirror.com/abort-controller/-/abort-controller-3.0.0.tgz", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
@@ -251,6 +260,8 @@
"qified": ["qified@0.10.1", "https://registry.npmmirror.com/qified/-/qified-0.10.1.tgz", { "dependencies": { "hookified": "^2.1.1" } }, "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA=="],
"qrcode-terminal": ["qrcode-terminal@0.12.0", "https://registry.npmmirror.com/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ=="],
"qs": ["qs@6.15.0", "https://registry.npmmirror.com/qs/-/qs-6.15.0.tgz", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "https://registry.npmmirror.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
@@ -301,6 +312,8 @@
"@protobufjs/fetch/@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "https://registry.npmmirror.com/@protobufjs/inquire/-/inquire-1.1.0.tgz", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
"@tencent-connect/qqbot-nodejs/ws": ["ws@8.21.3", "https://registry.npmmirror.com/ws/-/ws-8.21.3.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
"qified/hookified": ["hookified@2.2.0", "https://registry.npmmirror.com/hookified/-/hookified-2.2.0.tgz", {}, "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA=="],
}
}
@@ -0,0 +1,611 @@
/**
* Shared IM chat runtime — round-trip tests.
*
* The runtime is the piece every new platform depends on, so these tests drive
* the real transitions (an inbound message, a server stream, a permission
* reply) through fake *transports* rather than asserting hand-written state.
* Only the WebSocket bridge and the HTTP client are faked: those are the
* process boundaries, and everything between them is the code under test.
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { ImChatRuntime, type ChatPort, type ResponseStream } from '../chat-runtime.js'
import { loadConfig } from '../config.js'
import { MessageDedup } from '../message-dedup.js'
import { SessionStore } from '../session-store.js'
import type { AdapterHttpClient } from '../http-client.js'
import type { AttachmentRef, ServerMessage, WsBridge } from '../ws-bridge.js'
const CHAT_ID = 'chat-1'
const USER_ID = 'user-1'
let tmpDir: string
let originalConfigDir: string | undefined
type SentMessage = { content: string; attachments?: unknown[] }
class FakeBridge {
sessions = new Map<string, string>()
handlers = new Map<string, (msg: ServerMessage) => void | Promise<void>>()
sent: SentMessage[] = []
permissionResponses: Array<{ requestId: string; allowed: boolean; rule?: string }> = []
stopped: string[] = []
resets: string[] = []
sendResult = true
connectSession(chatId: string, sessionId: string): boolean {
this.sessions.set(chatId, sessionId)
return true
}
onServerMessage(chatId: string, handler: (msg: ServerMessage) => void | Promise<void>): void {
this.handlers.set(chatId, handler)
}
getSessionId(chatId: string): string | null {
return this.sessions.get(chatId) ?? null
}
hasSession(chatId: string): boolean {
return this.sessions.has(chatId)
}
isSessionOpen(chatId: string, sessionId?: string): boolean {
const current = this.sessions.get(chatId)
if (!current) return false
return sessionId ? current === sessionId : true
}
resetSession(chatId: string): void {
this.resets.push(chatId)
this.sessions.delete(chatId)
this.handlers.delete(chatId)
}
waitForOpen(): Promise<boolean> {
return Promise.resolve(true)
}
sendUserMessage(_chatId: string, content: string, attachments?: unknown[]): boolean {
if (!this.sendResult) return false
this.sent.push({ content, attachments })
return true
}
sendPermissionResponse(_chatId: string, requestId: string, allowed: boolean, rule?: string): boolean {
this.permissionResponses.push({ requestId, allowed, rule })
return true
}
sendStopGeneration(chatId: string): boolean {
this.stopped.push(chatId)
return true
}
}
class FakeHttpClient {
createdSessions: string[] = []
existingSessions = new Set<string>()
projects: Array<{ projectName: string; realPath: string; branch?: string }> = []
nextSessionId = 'session-1'
async createSession(workDir: string): Promise<string> {
this.createdSessions.push(workDir)
const id = `${this.nextSessionId}-${this.createdSessions.length}`
this.existingSessions.add(id)
return id
}
async sessionExists(sessionId: string): Promise<boolean> {
return this.existingSessions.has(sessionId)
}
async listRecentProjects() {
return this.projects
}
async matchProject(query: string) {
const project = this.projects.find((item) => item.projectName === query)
return project ? { project } : {}
}
async getGitInfo() {
return { repoName: 'demo', workDir: '/tmp/demo', branch: 'main' }
}
async getTasksForSession() {
return []
}
}
class RecordingResponse implements ResponseStream {
appended: string[] = []
finished = 0
async append(delta: string): Promise<void> {
this.appended.push(delta)
}
async finish(): Promise<void> {
this.finished += 1
}
}
function createPort() {
const notices: string[] = []
const responses: RecordingResponse[] = []
const busy: boolean[] = []
const images: Array<{ mime: string; alt?: string }> = []
const port: ChatPort = {
platform: 'wecom',
logPrefix: '[Test]',
async sendNotice(_chatId, text) {
notices.push(text)
},
createResponse() {
const response = new RecordingResponse()
responses.push(response)
return response
},
async sendImage(_chatId, image) {
images.push({ mime: image.mime, alt: image.alt })
},
setBusy(_chatId, value) {
busy.push(value)
},
}
return { port, notices, responses, busy, images }
}
function writeAdapterConfig(config: Record<string, unknown>): void {
fs.writeFileSync(path.join(tmpDir, 'adapters.json'), JSON.stringify(config, null, 2))
}
function createRuntime(overrides: { workDir?: string } = {}) {
const { port, notices, responses, busy, images } = createPort()
const bridge = new FakeBridge()
const httpClient = new FakeHttpClient()
const sessionStore = new SessionStore(path.join(tmpDir, 'adapter-sessions.json'))
const dedup = new MessageDedup()
const config = loadConfig()
const runtime = new ImChatRuntime({
port,
config,
platformConfig: config.wecom,
bridge: bridge as unknown as WsBridge,
sessionStore,
httpClient: httpClient as unknown as AdapterHttpClient,
defaultWorkDir: overrides.workDir ?? tmpDir,
dedup,
// Flush immediately so a test does not have to wait on a timer.
flushIntervalMs: 1,
flushCharThreshold: 1,
})
return { runtime, port, notices, responses, busy, images, bridge, httpClient, sessionStore, dedup }
}
async function inbound(
runtime: ImChatRuntime,
text: string,
overrides: Partial<{
dedupKey: string
userId: string
loadAttachments: () => Promise<AttachmentRef[]>
hasAttachments: boolean
}> = {},
): Promise<void> {
await runtime.handleInbound({
chatId: CHAT_ID,
userId: overrides.userId ?? USER_ID,
displayName: 'Tester',
dedupKey: overrides.dedupKey ?? `msg-${Math.random()}`,
text,
loadAttachments: overrides.loadAttachments,
hasAttachments: overrides.hasAttachments,
})
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chat-runtime-test-'))
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = tmpDir
writeAdapterConfig({ wecom: { pairedUsers: [{ userId: USER_ID, displayName: 'Tester', pairedAt: 1 }] } })
})
afterEach(() => {
if (originalConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = originalConfigDir
else delete process.env.CLAUDE_CONFIG_DIR
fs.rmSync(tmpDir, { recursive: true, force: true })
})
describe('ImChatRuntime authorization', () => {
it('refuses an unpaired sender and forwards nothing to the session', async () => {
writeAdapterConfig({ wecom: { pairedUsers: [], allowedUsers: [] } })
const { runtime, notices, bridge } = createRuntime()
await inbound(runtime, 'hello')
expect(bridge.sent).toHaveLength(0)
expect(notices[0]).toContain('未授权')
})
it('pairs on a valid code and then forwards the next message', async () => {
writeAdapterConfig({
wecom: { pairedUsers: [], allowedUsers: [] },
pairing: { code: 'ABC234', expiresAt: Date.now() + 60_000, createdAt: Date.now() },
})
const { runtime, notices, bridge } = createRuntime()
await inbound(runtime, 'ABC234')
expect(notices[0]).toContain('配对成功')
await inbound(runtime, 'hello there')
expect(bridge.sent.map((item) => item.content)).toEqual(['hello there'])
})
// Both directions of the dedup rule: a replay is dropped, a genuine repeat
// with its own id is not.
it('drops a replayed message id but keeps a repeated message with a new id', async () => {
const { runtime, bridge } = createRuntime()
await inbound(runtime, 'same text', { dedupKey: 'm1' })
await inbound(runtime, 'same text', { dedupKey: 'm1' })
expect(bridge.sent).toHaveLength(1)
await inbound(runtime, 'same text', { dedupKey: 'm2' })
expect(bridge.sent).toHaveLength(2)
})
})
describe('ImChatRuntime session lifecycle', () => {
it('creates a session on the first message and reuses it for the second', async () => {
const { runtime, httpClient, bridge } = createRuntime()
await inbound(runtime, 'first')
await inbound(runtime, 'second')
expect(httpClient.createdSessions).toEqual([tmpDir])
expect(bridge.sent.map((item) => item.content)).toEqual(['first', 'second'])
})
it('starts a fresh session for /new and drops the previous binding', async () => {
const { runtime, httpClient, bridge, sessionStore } = createRuntime()
await inbound(runtime, 'first')
const firstSessionId = sessionStore.get(CHAT_ID)?.sessionId
await inbound(runtime, '/new')
const secondSessionId = sessionStore.get(CHAT_ID)?.sessionId
expect(httpClient.createdSessions).toHaveLength(2)
expect(secondSessionId).not.toBe(firstSessionId)
expect(bridge.resets).toContain(CHAT_ID)
})
it('answers /status with the no-session text before any message', async () => {
const { runtime, notices } = createRuntime()
await inbound(runtime, '/status')
expect(notices[0]).toContain('当前没有活动会话')
})
it('sends a stop signal for /stop once a session exists', async () => {
const { runtime, bridge } = createRuntime()
await inbound(runtime, 'first')
await inbound(runtime, '/stop')
expect(bridge.stopped).toEqual([CHAT_ID])
})
it('forwards /clear into the live session instead of recreating it', async () => {
const { runtime, bridge, httpClient } = createRuntime()
await inbound(runtime, 'first')
await inbound(runtime, '/clear')
expect(httpClient.createdSessions).toHaveLength(1)
expect(bridge.sent.map((item) => item.content)).toEqual(['first', '/clear'])
})
})
describe('ImChatRuntime server stream', () => {
it('streams deltas into one response and finishes it exactly once', async () => {
const { runtime, responses } = createRuntime()
await runtime.handleServerMessage(CHAT_ID, { type: 'content_start', blockType: 'text' })
await runtime.handleServerMessage(CHAT_ID, { type: 'content_delta', text: 'Hello ' })
await runtime.handleServerMessage(CHAT_ID, { type: 'content_delta', text: 'world' })
await runtime.handleServerMessage(CHAT_ID, { type: 'message_complete' })
expect(responses).toHaveLength(1)
expect(responses[0]!.appended.join('')).toBe('Hello world')
expect(responses[0]!.finished).toBe(1)
})
it('opens a new response for the next turn', async () => {
const { runtime, responses } = createRuntime()
await runtime.handleServerMessage(CHAT_ID, { type: 'content_delta', text: 'one' })
await runtime.handleServerMessage(CHAT_ID, { type: 'message_complete' })
await runtime.handleServerMessage(CHAT_ID, { type: 'content_delta', text: 'two' })
await runtime.handleServerMessage(CHAT_ID, { type: 'message_complete' })
expect(responses).toHaveLength(2)
expect(responses[1]!.appended.join('')).toBe('two')
})
it('closes the in-flight reply before asking for permission', async () => {
const { runtime, responses, notices } = createRuntime()
await runtime.handleServerMessage(CHAT_ID, { type: 'content_delta', text: 'about to run' })
await runtime.handleServerMessage(CHAT_ID, {
type: 'permission_request',
requestId: 'req-1',
toolName: 'Bash',
input: { command: 'ls' },
})
expect(responses[0]!.finished).toBe(1)
expect(notices.join('\n')).toContain('req-1')
})
it('routes a numeric reply to the single pending permission request', async () => {
const { runtime, bridge } = createRuntime()
await inbound(runtime, 'run something')
await runtime.handleServerMessage(CHAT_ID, {
type: 'permission_request',
requestId: 'req-1',
toolName: 'Bash',
input: { command: 'ls' },
})
await inbound(runtime, '1')
expect(bridge.permissionResponses).toEqual([{ requestId: 'req-1', allowed: true, rule: undefined }])
})
it('records a permanent allow separately from a one-shot allow', async () => {
const { runtime, bridge } = createRuntime()
await inbound(runtime, 'run something')
await runtime.handleServerMessage(CHAT_ID, {
type: 'permission_request',
requestId: 'req-2',
toolName: 'Bash',
input: {},
})
await inbound(runtime, '2')
expect(bridge.permissionResponses).toEqual([{ requestId: 'req-2', allowed: true, rule: 'always' }])
})
it('rejects a decision for a request that is not pending', async () => {
const { runtime, bridge, notices } = createRuntime()
await inbound(runtime, 'hello')
await inbound(runtime, '/allow req-missing')
expect(bridge.permissionResponses).toHaveLength(0)
expect(notices.join('\n')).toContain('req-missing')
})
it('rebuilds the session when the server reports a stale thinking signature', async () => {
const { runtime, httpClient, notices } = createRuntime()
await inbound(runtime, 'hello')
expect(httpClient.createdSessions).toHaveLength(1)
await runtime.handleServerMessage(CHAT_ID, {
type: 'error',
message: 'Invalid `signature` field for `thinking` block',
})
expect(httpClient.createdSessions).toHaveLength(2)
expect(notices.join('\n')).toContain('已重建会话')
})
it('reports an ordinary error without recreating the session', async () => {
const { runtime, httpClient, notices } = createRuntime()
await inbound(runtime, 'hello')
await runtime.handleServerMessage(CHAT_ID, { type: 'error', message: 'boom' })
expect(httpClient.createdSessions).toHaveLength(1)
expect(notices.join('\n')).toContain('boom')
})
it('drives the busy indicator in both directions', async () => {
const { runtime, busy } = createRuntime()
await runtime.handleServerMessage(CHAT_ID, { type: 'status', state: 'thinking' })
await runtime.handleServerMessage(CHAT_ID, { type: 'message_complete' })
expect(busy).toContain(true)
expect(busy[busy.length - 1]).toBe(false)
})
it('uploads an image referenced in the stream and skips one outside the work dir', async () => {
const workDir = fs.realpathSync(tmpDir)
const insidePath = path.join(workDir, 'inside.png')
// 1×1 transparent PNG.
fs.writeFileSync(
insidePath,
Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64',
),
)
const { runtime, images } = createRuntime({ workDir })
await inbound(runtime, 'draw me something')
await runtime.handleServerMessage(CHAT_ID, {
type: 'content_delta',
text: `![inside](${insidePath}) ![outside](/etc/hosts)`,
})
await runtime.handleServerMessage(CHAT_ID, { type: 'message_complete' })
// The upload is dispatched without blocking the stream.
await new Promise((resolve) => setTimeout(resolve, 20))
expect(images).toEqual([{ mime: 'image/png', alt: 'inside' }])
})
})
describe('ImChatRuntime project selection', () => {
it('opens the picker when no default work dir is configured, then binds the reply', async () => {
const { runtime, httpClient, notices } = createRuntime({ workDir: '' })
httpClient.projects = [{ projectName: 'demo', realPath: tmpDir, branch: 'main' }]
await inbound(runtime, 'hello')
expect(notices.join('\n')).toContain('选择项目')
expect(httpClient.createdSessions).toHaveLength(0)
await inbound(runtime, 'demo')
expect(httpClient.createdSessions).toEqual([tmpDir])
})
})
describe('ImChatRuntime abandoning a live turn', () => {
// `finish()` is the only way a platform closes its presentation: a WeCom
// bubble stays "generating" forever without it, and a QQ StreamSession keeps
// a live timer that can fire into a stream nobody is reading.
it.each([
['/new', '/new'],
['/clear', '/clear'],
])('closes the in-flight response when %s arrives mid-stream', async (_label, command) => {
const { runtime, responses } = createRuntime()
await inbound(runtime, 'do something slow')
await runtime.handleServerMessage(CHAT_ID, { type: 'content_delta', text: 'working on it' })
expect(responses).toHaveLength(1)
expect(responses[0]!.finished).toBe(0)
await inbound(runtime, command)
expect(responses[0]!.finished).toBe(1)
})
it('closes the in-flight response when a stale session is rebuilt', async () => {
const { runtime, responses } = createRuntime()
await inbound(runtime, 'hello')
await runtime.handleServerMessage(CHAT_ID, { type: 'content_delta', text: 'partial' })
await runtime.handleServerMessage(CHAT_ID, {
type: 'error',
message: 'Invalid `signature` field for `thinking` block',
})
expect(responses[0]!.finished).toBe(1)
})
it('never finishes the same response twice', async () => {
const { runtime, responses } = createRuntime()
await inbound(runtime, 'hello')
await runtime.handleServerMessage(CHAT_ID, { type: 'content_delta', text: 'done' })
await runtime.handleServerMessage(CHAT_ID, { type: 'message_complete' })
await inbound(runtime, '/new')
expect(responses).toHaveLength(1)
expect(responses[0]!.finished).toBe(1)
})
})
describe('ImChatRuntime attachment authorization', () => {
// Downloading before the pairing gate lets a stranger make the adapter fetch
// bytes and write them under ~/.claude/im-downloads — and on Slack that
// fetch carries the bot token.
it('does not download attachments for an unpaired sender', async () => {
writeAdapterConfig({ wecom: { pairedUsers: [], allowedUsers: [] } })
const { runtime, notices } = createRuntime()
let loads = 0
await inbound(runtime, 'look at this', {
hasAttachments: true,
loadAttachments: async () => {
loads += 1
return []
},
})
expect(loads).toBe(0)
expect(notices[0]).toContain('未授权')
})
it('does not download attachments for a replayed message', async () => {
const { runtime } = createRuntime()
let loads = 0
const loadAttachments = async () => {
loads += 1
return []
}
await inbound(runtime, 'first', { dedupKey: 'm1', hasAttachments: true, loadAttachments })
await inbound(runtime, 'first', { dedupKey: 'm1', hasAttachments: true, loadAttachments })
expect(loads).toBe(1)
})
it('downloads once the sender is authorized and forwards the result', async () => {
const { runtime, bridge } = createRuntime()
await inbound(runtime, 'look at this', {
hasAttachments: true,
loadAttachments: async () => [
{ type: 'image', name: 'a.png', data: 'AAAA', mimeType: 'image/png' },
],
})
expect(bridge.sent).toHaveLength(1)
expect(bridge.sent[0]!.attachments).toEqual([
{ type: 'image', name: 'a.png', data: 'AAAA', mimeType: 'image/png' },
])
})
// Command routing only applies to attachment-free messages. Both directions
// matter: a bare command must not reach the agent, and text that merely
// looks like a command must not swallow the file sent with it.
it('routes a bare command without touching the attachment path', async () => {
const { runtime, bridge, notices } = createRuntime()
await inbound(runtime, '/status')
expect(bridge.sent).toHaveLength(0)
expect(notices[0]).toContain('当前没有活动会话')
})
it('forwards a command-looking caption sent with a file instead of running it', async () => {
const { runtime, bridge, notices } = createRuntime()
await inbound(runtime, '/status', {
hasAttachments: true,
loadAttachments: async () => [
{ type: 'image', name: 'a.png', data: 'AAAA', mimeType: 'image/png' },
],
})
expect(bridge.sent.map((item) => item.content)).toEqual(['/status'])
expect(notices.join('\n')).not.toContain('当前没有活动会话')
})
it('runs the download inside the per-chat queue, so order is preserved', async () => {
const { runtime, bridge } = createRuntime()
const slow = inbound(runtime, 'with attachment', {
dedupKey: 'slow',
hasAttachments: true,
loadAttachments: async () => {
await new Promise((resolve) => setTimeout(resolve, 30))
return []
},
})
const fast = inbound(runtime, 'plain text', { dedupKey: 'fast' })
await Promise.all([slow, fast])
expect(bridge.sent.map((item) => item.content)).toEqual(['with attachment', 'plain text'])
})
})
@@ -0,0 +1,155 @@
/**
* Config loading for the platforms added alongside the QR bot flows.
*
* `loadConfig` is the single place where env vars, the config file and the
* defaults meet, and every adapter's credential gate reads its result — so the
* precedence rule is worth pinning per platform rather than assuming the new
* blocks copied the old ones correctly.
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { loadConfig, resolveAllowedProjectRoots } from '../config.js'
import { isPaired, type ImPlatform } from '../pairing.js'
let tmpDir: string
let originalConfigDir: string | undefined
const touchedEnv: string[] = []
function writeConfig(config: Record<string, unknown>): void {
fs.writeFileSync(path.join(tmpDir, 'adapters.json'), JSON.stringify(config, null, 2))
}
function setEnv(key: string, value: string): void {
touchedEnv.push(key)
process.env[key] = value
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapters-config-test-'))
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = tmpDir
})
afterEach(() => {
if (originalConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = originalConfigDir
else delete process.env.CLAUDE_CONFIG_DIR
for (const key of touchedEnv.splice(0)) delete process.env[key]
fs.rmSync(tmpDir, { recursive: true, force: true })
})
describe('loadConfig for WeCom / QQ / Slack', () => {
it('reads credentials from the config file', () => {
writeConfig({
wecom: { botId: 'bot-1', secret: 'secret-1' },
qq: { appId: 'app-1', appSecret: 'secret-2' },
slack: { botToken: 'xoxb-1', appToken: 'xapp-1' },
})
const config = loadConfig()
expect(config.wecom).toMatchObject({ botId: 'bot-1', secret: 'secret-1' })
expect(config.qq).toMatchObject({ appId: 'app-1', appSecret: 'secret-2' })
expect(config.slack).toMatchObject({ botToken: 'xoxb-1', appToken: 'xapp-1' })
})
// Standalone runs configure the adapter purely through env; the file is the
// desktop's channel. Env must win, exactly as it does for the older
// platforms.
it.each([
['WECOM_BOT_ID', 'wecom', 'botId'],
['WECOM_BOT_SECRET', 'wecom', 'secret'],
['QQ_APP_ID', 'qq', 'appId'],
['QQ_APP_SECRET', 'qq', 'appSecret'],
['SLACK_BOT_TOKEN', 'slack', 'botToken'],
['SLACK_APP_TOKEN', 'slack', 'appToken'],
] as const)('lets %s override the file value', (envKey, platform, field) => {
writeConfig({ [platform]: { [field]: 'from-file' } })
setEnv(envKey, 'from-env')
expect((loadConfig()[platform] as unknown as Record<string, string>)[field]).toBe('from-env')
})
it('defaults every new platform to empty credentials and closed access', () => {
const config = loadConfig()
for (const platform of ['wecom', 'qq', 'slack'] as const) {
expect(config[platform].allowedUsers).toEqual([])
expect(config[platform].pairedUsers).toEqual([])
expect(config[platform].defaultWorkDir).toBeTruthy()
}
expect(config.wecom.botId).toBe('')
expect(config.qq.appId).toBe('')
expect(config.slack.botToken).toBe('')
})
it('honours a per-platform project boundary that replaces the global one', () => {
const scoped = fs.mkdtempSync(path.join(os.tmpdir(), 'scoped-'))
try {
writeConfig({
allowedProjectRoots: [tmpDir],
slack: { allowedProjectRoots: [scoped] },
})
const config = loadConfig()
expect(resolveAllowedProjectRoots(config, config.slack)).toEqual([fs.realpathSync(scoped)])
expect(resolveAllowedProjectRoots(config, config.qq)).toEqual([fs.realpathSync(tmpDir)])
} finally {
fs.rmSync(scoped, { recursive: true, force: true })
}
})
})
describe('pairing for WeCom / QQ / Slack', () => {
// Deny-by-default is the whole authorization model: an unconfigured platform
// must not be open, and a paired user must be recognised.
it.each(['wecom', 'qq', 'slack'] as const)('is closed by default for %s', (platform: ImPlatform) => {
expect(isPaired(platform, 'someone', { [platform]: { pairedUsers: [], allowedUsers: [] } }))
.toBe(false)
})
it.each(['wecom', 'qq', 'slack'] as const)('recognises a paired %s user', (platform: ImPlatform) => {
expect(
isPaired(platform, 'someone', {
[platform]: {
pairedUsers: [{ userId: 'someone', displayName: 'Someone', pairedAt: 1 }],
allowedUsers: [],
},
}),
).toBe(true)
})
it.each(['wecom', 'qq', 'slack'] as const)('recognises an allowlisted %s user', (platform: ImPlatform) => {
expect(isPaired(platform, 'allowed', { [platform]: { pairedUsers: [], allowedUsers: ['allowed'] } }))
.toBe(true)
})
})
describe('loadConfig Feishu tenant domain', () => {
// The scan flow can provision a bot on either brand, and the adapter picks
// its API origin from this value — a wrong default cannot authenticate.
it('defaults to feishu when nothing is configured', () => {
expect(loadConfig().feishu.domain).toBe('feishu')
})
it('reads lark from the config file', () => {
writeConfig({ feishu: { domain: 'lark' } })
expect(loadConfig().feishu.domain).toBe('lark')
})
it('lets FEISHU_DOMAIN override the file', () => {
writeConfig({ feishu: { domain: 'lark' } })
setEnv('FEISHU_DOMAIN', 'feishu')
expect(loadConfig().feishu.domain).toBe('feishu')
})
it('falls back to feishu for an unrecognised value rather than passing it through', () => {
writeConfig({ feishu: { domain: 'example.com' } })
expect(loadConfig().feishu.domain).toBe('feishu')
})
})
+54
View File
@@ -4,6 +4,8 @@ import {
formatImHelp,
formatImStatus,
splitMessage,
splitMessageByBytes,
utf8Length,
formatToolUse,
formatPermissionRequest,
truncateInput,
@@ -220,3 +222,55 @@ describe('formatImStatus', () => {
expect(text).toContain('/projects')
})
})
describe('splitMessageByBytes', () => {
it('returns a single chunk when the text already fits', () => {
expect(splitMessageByBytes('hello', 100)).toEqual(['hello'])
})
// The reason this function exists: WeCom caps on UTF-8 bytes, and a
// character-based split silently overflows the moment the text is Chinese.
it('keeps every chunk inside the byte budget for CJK text', () => {
const text = Array.from({ length: 200 }, (_, i) => `第 ${i} 段中文内容。`).join('\n')
const chunks = splitMessageByBytes(text, 300)
expect(chunks.length).toBeGreaterThan(1)
for (const chunk of chunks) {
expect(utf8Length(chunk)).toBeLessThanOrEqual(300)
}
expect(chunks.join('\n').replace(/\s+/g, '')).toBe(text.replace(/\s+/g, ''))
})
it('prefers a line boundary over cutting mid-line', () => {
const chunks = splitMessageByBytes('first line\nsecond line', 12)
expect(chunks[0]).toBe('first line')
})
it('never splits a surrogate pair into invalid UTF-8', () => {
const text = '🙂'.repeat(50)
const chunks = splitMessageByBytes(text, 9)
for (const chunk of chunks) {
expect(chunk).not.toContain('\uFFFD')
expect(Buffer.from(chunk, 'utf8').toString('utf8')).toBe(chunk)
}
expect(chunks.join('')).toBe(text)
})
it('makes progress even when a single token exceeds the budget', () => {
const chunks = splitMessageByBytes('这是一个没有空格的超长中文串', 4)
expect(chunks.length).toBeGreaterThan(1)
expect(chunks.join('')).toBe('这是一个没有空格的超长中文串')
})
})
describe('utf8Length', () => {
it('counts bytes, not characters', () => {
expect(utf8Length('abc')).toBe(3)
expect(utf8Length('中文')).toBe(6)
})
})
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'bun:test'
import {
attachmentKindForMime,
imageExtensionForMime,
inferMimeFromFileName,
} from '../attachment/mime.js'
describe('inferMimeFromFileName', () => {
it('maps a known extension case-insensitively', () => {
expect(inferMimeFromFileName('report.PDF')).toBe('application/pdf')
expect(inferMimeFromFileName('photo.jpeg')).toBe('image/jpeg')
})
it('returns undefined rather than guessing for unknown or extensionless names', () => {
expect(inferMimeFromFileName('archive.unknownext')).toBeUndefined()
expect(inferMimeFromFileName('Makefile')).toBeUndefined()
expect(inferMimeFromFileName(undefined)).toBeUndefined()
})
})
describe('imageExtensionForMime', () => {
it('maps image MIMEs to the extension the platform expects', () => {
expect(imageExtensionForMime('image/jpeg')).toBe('jpg')
expect(imageExtensionForMime('image/webp')).toBe('webp')
})
it('ignores charset parameters', () => {
expect(imageExtensionForMime('image/png; charset=binary')).toBe('png')
})
it('falls back to png for anything unrecognised', () => {
expect(imageExtensionForMime('application/pdf')).toBe('png')
expect(imageExtensionForMime(undefined)).toBe('png')
})
})
describe('attachmentKindForMime', () => {
// The size limits and the wire representation differ between the two, so a
// wrong answer here means either a rejected image or an oversized upload.
it('classifies images and everything else', () => {
expect(attachmentKindForMime('image/png')).toBe('image')
expect(attachmentKindForMime('IMAGE/PNG')).toBe('image')
expect(attachmentKindForMime('application/pdf')).toBe('file')
expect(attachmentKindForMime(undefined)).toBe('file')
})
})
@@ -6,7 +6,15 @@ import type { AttachmentRef } from '../ws-bridge.js'
export type { AttachmentRef }
/** Platform tag — used for local staging subdir and telemetry. */
export type ImPlatform = 'feishu' | 'telegram' | 'wechat' | 'dingtalk' | 'whatsapp'
export type ImPlatform =
| 'feishu'
| 'telegram'
| 'wechat'
| 'dingtalk'
| 'whatsapp'
| 'wecom'
| 'qq'
| 'slack'
/** Result of downloading an IM resource into the local stage dir. */
export interface LocalAttachment {
+71
View File
@@ -0,0 +1,71 @@
/**
* Filename ↔ MIME helpers shared by IM adapters.
*
* Platforms differ in what they tell us about an attachment: WeCom hands back
* only a download URL, QQ sends a `content_type`, Slack sends a `mimetype` and
* a filename. The pieces that must agree — the extension we stage the file
* under and the MIME we forward to the server — are derived here so the three
* adapters cannot drift apart.
*/
const EXTENSION_TO_MIME: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
bmp: 'image/bmp',
svg: 'image/svg+xml',
heic: 'image/heic',
pdf: 'application/pdf',
txt: 'text/plain',
md: 'text/markdown',
csv: 'text/csv',
json: 'application/json',
xml: 'application/xml',
yaml: 'application/yaml',
yml: 'application/yaml',
zip: 'application/zip',
gz: 'application/gzip',
tar: 'application/x-tar',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
mp3: 'audio/mpeg',
m4a: 'audio/mp4',
wav: 'audio/wav',
ogg: 'audio/ogg',
mp4: 'video/mp4',
mov: 'video/quicktime',
}
const MIME_TO_EXTENSION: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/gif': 'gif',
'image/webp': 'webp',
'image/bmp': 'bmp',
'image/svg+xml': 'svg',
'image/heic': 'heic',
}
/** Best-effort MIME from a filename. Returns undefined when unknown. */
export function inferMimeFromFileName(fileName?: string | null): string | undefined {
const extension = fileName?.split('.').pop()?.toLowerCase()
if (!extension || extension === fileName?.toLowerCase()) return undefined
return EXTENSION_TO_MIME[extension]
}
/** File extension for an image MIME, without the dot. Defaults to `png`. */
export function imageExtensionForMime(mime?: string | null): string {
if (!mime) return 'png'
return MIME_TO_EXTENSION[mime.split(';')[0]!.trim().toLowerCase()] ?? 'png'
}
/** `image` when the MIME is an image type, otherwise `file`. */
export function attachmentKindForMime(mime?: string | null): 'image' | 'file' {
return mime?.toLowerCase().startsWith('image/') ? 'image' : 'file'
}
+756
View File
@@ -0,0 +1,756 @@
/**
* Shared IM chat runtime.
*
* Telegram / Feishu / WeChat / DingTalk / WhatsApp each grew their own copy of
* the same loop: pair the sender, route slash commands, restore or create the
* session, forward the message, then translate the server's stream back into
* platform messages. Every copy has independently regressed at least once.
*
* This module owns that loop once. A platform supplies a {@link ChatPort} —
* how to say something, how to open a streaming reply, optionally how to send
* an image — and keeps its transport, auth and media details to itself. The
* runtime never imports a platform SDK, so it is testable with a fake port.
*
* The port, not the runtime, remembers per-chat reply context (a WeCom frame's
* `req_id`, a QQ inbound `msgId`, a Slack `thread_ts`): those are platform
* concepts with platform lifetimes.
*/
import * as path from 'node:path'
import { enqueue } from './chat-queue.js'
import {
getConfiguredWorkDir,
type AdapterConfig,
type AdapterPlatformConfig,
} from './config.js'
import { formatImHelp, formatImStatus, formatPermissionRequest } from './format.js'
import type { AdapterHttpClient } from './http-client.js'
import { MessageBuffer } from './message-buffer.js'
import type { MessageDedup } from './message-dedup.js'
import { isAllowedUser, tryPair, type ImPlatform } from './pairing.js'
import {
formatPermissionDecisionStatus,
formatPermissionInstructions,
parsePermissionCommand,
type PermissionDecision,
} from './permission.js'
import { restoreStoredSessionBinding } from './session-recovery.js'
import type { SessionStore } from './session-store.js'
import type { AttachmentRef, ServerMessage, WsBridge } from './ws-bridge.js'
import { ImageBlockWatcher } from './attachment/image-block-watcher.js'
import { loadSafeOutboundImage } from './attachment/outbound-image.js'
import type { PendingUpload } from './attachment/attachment-types.js'
export type ChatRuntimeState = {
state: 'idle' | 'thinking' | 'streaming' | 'tool_executing' | 'permission_pending'
verb?: string
model?: string
pendingPermissionCount: number
}
/** One assistant turn's outbound presentation. */
export interface ResponseStream {
/** Incremental text. Called in order; the runtime batches deltas first. */
append(delta: string): Promise<void>
/** Last call for this turn. Always called exactly once per stream. */
finish(): Promise<void>
}
export type OutboundImage = {
buffer: Buffer
mime: string
alt?: string
}
export interface ChatPort {
platform: ImPlatform
/** Log tag, e.g. `[WeCom]`. */
logPrefix: string
/** Human label used in pairing / authorization copy. */
displayName?: string
/** Out-of-band message: command output, permission prompt, error. */
sendNotice(chatId: string, text: string): Promise<void>
/** Open the presentation for one assistant turn. */
createResponse(chatId: string): ResponseStream
/** Optional: outbound image found in the Agent's markdown output. */
sendImage?(chatId: string, image: OutboundImage): Promise<void>
/** Optional: typing indicator / busy state. */
setBusy?(chatId: string, busy: boolean): void
/** Optional: platform teardown for a chat (e.g. drop cached reply context). */
clearChat?(chatId: string): void
}
/** One inbound user message, already normalized by the platform adapter. */
export type InboundChatMessage = {
chatId: string
userId: string | number
displayName: string
/** Stable per-message identity used for replay dedup. */
dedupKey: string
text: string
attachments?: AttachmentRef[]
/**
* Deferred attachment download.
*
* Called only after dedup and the pairing gate have passed, and from inside
* the per-chat queue. Downloading before that lets an unpaired stranger make
* the adapter fetch bytes and write them under `~/.claude/im-downloads/`, and
* lets a slow attachment overtake a text message sent after it.
*/
loadAttachments?: () => Promise<AttachmentRef[]>
/** Whether attachments exist, known before they are downloaded. */
hasAttachments?: boolean
}
export type ImChatRuntimeOptions = {
port: ChatPort
config: AdapterConfig
platformConfig: AdapterPlatformConfig
bridge: WsBridge
sessionStore: SessionStore
httpClient: AdapterHttpClient
defaultWorkDir: string
dedup: MessageDedup
/** Buffer window for streamed text. Platforms with edit-in-place replies
* want a short window; platforms that post discrete messages want a long
* one so a turn does not arrive as twenty separate chat bubbles. */
flushIntervalMs?: number
flushCharThreshold?: number
}
const HELP_ALIASES = new Set(['/help', '帮助'])
const STATUS_ALIASES = new Set(['/status', '状态'])
const PROJECTS_ALIASES = new Set(['/projects', '项目列表'])
const NEW_ALIASES = new Set(['/new', '新会话'])
const STOP_ALIASES = new Set(['/stop', '停止'])
const CLEAR_ALIASES = new Set(['/clear', '清空'])
/**
* Drives one IM platform's chats against the desktop server.
*
* Instances are long-lived: one per adapter process, shared by every chat.
*/
export class ImChatRuntime {
private readonly port: ChatPort
private readonly config: AdapterConfig
private readonly platformConfig: AdapterPlatformConfig
private readonly bridge: WsBridge
private readonly sessionStore: SessionStore
private readonly httpClient: AdapterHttpClient
private readonly defaultWorkDir: string
private readonly dedup: MessageDedup
private readonly flushIntervalMs: number
private readonly flushCharThreshold: number
private readonly runtimeStates = new Map<string, ChatRuntimeState>()
private readonly buffers = new Map<string, MessageBuffer>()
private readonly responses = new Map<string, ResponseStream>()
private readonly pendingPermissions = new Map<string, Set<string>>()
private readonly pendingProjectSelection = new Set<string>()
private readonly imageWatchers = new Map<string, ImageBlockWatcher>()
constructor(options: ImChatRuntimeOptions) {
this.port = options.port
this.config = options.config
this.platformConfig = options.platformConfig
this.bridge = options.bridge
this.sessionStore = options.sessionStore
this.httpClient = options.httpClient
this.defaultWorkDir = options.defaultWorkDir
this.dedup = options.dedup
this.flushIntervalMs = options.flushIntervalMs ?? 800
this.flushCharThreshold = options.flushCharThreshold ?? 320
}
/** The work dir a `/new` with no argument would use. Exposed for adapters
* that print it at startup. */
get configuredWorkDir(): string {
return this.defaultWorkDir || getConfiguredWorkDir(this.config, this.platformConfig)
}
// ---------- inbound ----------
/**
* Full inbound pipeline for one user message.
*
* Work runs on the per-chat FIFO so two fast messages cannot interleave
* their session mutations. The returned promise settles when this message
* has been handled; adapters normally ignore it, tests await it.
*/
handleInbound(message: InboundChatMessage): Promise<void> {
const { chatId, dedupKey } = message
if (!chatId) return Promise.resolve()
if (dedupKey && !this.dedup.tryRecord(`${this.port.platform}:${dedupKey}`)) {
return Promise.resolve()
}
const text = message.text.trim()
const hasAttachments = message.hasAttachments
?? ((message.attachments?.length ?? 0) > 0 || Boolean(message.loadAttachments))
if (!text && !hasAttachments) return Promise.resolve()
return enqueue(chatId, async () => {
try {
await this.routeInbound({ ...message, text }, hasAttachments)
} catch (err) {
this.port.setBusy?.(chatId, false)
const detail = err instanceof Error ? err.message : String(err)
console.error(`${this.port.logPrefix} Failed to handle message for ${chatId}:`, err)
await this.notifySafely(chatId, `处理消息失败:${detail}`)
}
})
}
private async routeInbound(
message: InboundChatMessage,
hasAttachments: boolean,
): Promise<void> {
const { chatId, userId, displayName, text } = message
if (!isAllowedUser(this.port.platform, userId)) {
const paired = text
? tryPair(text, { userId, displayName }, this.port.platform)
: false
await this.port.sendNotice(
chatId,
paired
? '配对成功!现在可以开始聊天了。\n\n发送消息即可与 Claude 对话,发送 /help 查看可用命令。'
: '未授权。请在 Claude Code 桌面端生成配对码后发送给我。',
)
return
}
if (!hasAttachments && (await this.tryHandleCommand(chatId, text))) return
const decision = hasAttachments
? null
: parsePermissionCommand(text, this.pendingPermissions.get(chatId))
if (decision) {
await this.applyPermissionDecision(chatId, decision)
return
}
if (!hasAttachments && this.pendingProjectSelection.has(chatId)) {
if (text) await this.startNewSession(chatId, text)
return
}
const ready = await this.ensureSession(chatId)
if (!ready) return
// Only now — sender authorized, session bound, still inside this chat's
// queue — is it safe to pull the bytes down.
const attachments = message.attachments
?? (message.loadAttachments ? await message.loadAttachments() : [])
const effective = text || (attachments.length > 0 ? '(用户发送了附件)' : '')
if (!effective && attachments.length === 0) return
this.port.setBusy?.(chatId, true)
const sent = this.bridge.sendUserMessage(
chatId,
effective,
attachments.length > 0 ? attachments : undefined,
)
if (!sent) {
this.port.setBusy?.(chatId, false)
await this.port.sendNotice(chatId, '消息发送失败,连接可能已断开。请发送 /new 重新开始。')
}
}
/** Returns true when `text` was a command and has been fully handled. */
private async tryHandleCommand(chatId: string, text: string): Promise<boolean> {
if (HELP_ALIASES.has(text)) {
await this.port.sendNotice(chatId, formatImHelp())
return true
}
if (STATUS_ALIASES.has(text)) {
await this.port.sendNotice(chatId, await this.buildStatusText(chatId))
return true
}
if (PROJECTS_ALIASES.has(text)) {
await this.showProjectPicker(chatId)
return true
}
if (NEW_ALIASES.has(text) || text.startsWith('/new ') || text.startsWith('新会话 ')) {
const arg = text.startsWith('/new ')
? text.slice('/new '.length).trim()
: text.startsWith('新会话 ')
? text.slice('新会话 '.length).trim()
: ''
await this.startNewSession(chatId, arg || undefined)
return true
}
if (STOP_ALIASES.has(text)) {
const stored = await this.ensureExistingSession(chatId)
if (!stored) {
await this.port.sendNotice(chatId, formatImStatus(null))
return true
}
this.bridge.sendStopGeneration(chatId)
await this.port.sendNotice(chatId, '已发送停止信号。')
return true
}
if (CLEAR_ALIASES.has(text)) {
const stored = await this.ensureExistingSession(chatId)
if (!stored) {
await this.port.sendNotice(chatId, formatImStatus(null))
return true
}
this.clearTransientChatState(chatId)
const sent = this.bridge.sendUserMessage(chatId, '/clear')
await this.port.sendNotice(
chatId,
sent ? '已清空当前会话上下文。' : '无法发送 /clear,请先发送 /new 重新连接会话。',
)
return true
}
return false
}
private async applyPermissionDecision(
chatId: string,
decision: PermissionDecision,
): Promise<void> {
const pending = this.pendingPermissions.get(chatId)
if (!pending?.has(decision.requestId)) {
await this.port.sendNotice(chatId, `未找到待确认的权限请求:${decision.requestId}`)
return
}
const sent = this.bridge.sendPermissionResponse(
chatId,
decision.requestId,
decision.allowed,
decision.rule,
)
if (sent) {
pending.delete(decision.requestId)
const runtime = this.getRuntimeState(chatId)
runtime.pendingPermissionCount = Math.max(0, runtime.pendingPermissionCount - 1)
}
await this.port.sendNotice(
chatId,
sent ? `${formatPermissionDecisionStatus(decision)}。` : '权限响应发送失败,请检查会话状态。',
)
}
// ---------- server stream ----------
async handleServerMessage(chatId: string, msg: ServerMessage): Promise<void> {
const runtime = this.getRuntimeState(chatId)
switch (msg.type) {
case 'connected':
break
case 'status':
runtime.state = msg.state
runtime.verb = typeof msg.verb === 'string' ? msg.verb : undefined
if (msg.state === 'thinking' || msg.state === 'tool_executing') {
this.port.setBusy?.(chatId, true)
} else if (msg.state === 'idle') {
this.port.setBusy?.(chatId, false)
}
break
case 'content_start':
if (msg.blockType === 'text') {
runtime.state = 'streaming'
} else if (msg.blockType === 'tool_use') {
runtime.state = 'tool_executing'
runtime.verb = typeof msg.toolName === 'string' ? msg.toolName : runtime.verb
this.port.setBusy?.(chatId, true)
}
break
case 'content_delta':
if (typeof msg.text === 'string' && msg.text) {
this.getBuffer(chatId).append(msg.text)
if (this.port.sendImage) {
for (const pending of this.getImageWatcher(chatId).feed(msg.text)) {
void this.dispatchOutboundImage(chatId, pending)
}
}
}
break
case 'tool_use_complete':
runtime.state = 'tool_executing'
runtime.verb = typeof msg.toolName === 'string' ? msg.toolName : runtime.verb
this.port.setBusy?.(chatId, true)
break
case 'tool_result':
runtime.state = 'thinking'
runtime.verb = undefined
this.port.setBusy?.(chatId, true)
break
case 'permission_request': {
runtime.pendingPermissionCount += 1
runtime.state = 'permission_pending'
let pending = this.pendingPermissions.get(chatId)
if (!pending) {
pending = new Set()
this.pendingPermissions.set(chatId, pending)
}
pending.add(msg.requestId)
this.port.setBusy?.(chatId, false)
// Close the in-flight reply first: the approval prompt is a separate
// message and must not be swallowed by an editing stream.
await this.finishResponse(chatId)
await this.port.sendNotice(
chatId,
`${formatPermissionRequest(msg.toolName, msg.input, msg.requestId)}\n\n${formatPermissionInstructions(msg.requestId)}`,
)
break
}
case 'message_complete':
runtime.state = 'idle'
runtime.verb = undefined
this.port.setBusy?.(chatId, false)
await this.finishResponse(chatId)
break
case 'error': {
runtime.state = 'idle'
runtime.verb = undefined
this.port.setBusy?.(chatId, false)
this.buffers.get(chatId)?.reset()
this.buffers.delete(chatId)
await this.finishResponse(chatId, { discardBuffer: true })
await this.handleServerError(chatId, msg)
break
}
case 'system_notification':
if (msg.subtype === 'init' && msg.data && typeof msg.data === 'object') {
const model = (msg.data as Record<string, unknown>).model
if (typeof model === 'string' && model.trim()) runtime.model = model
}
break
}
}
private async handleServerError(chatId: string, msg: ServerMessage): Promise<void> {
// A session created under a different provider carries thinking blocks the
// current one cannot verify. Rebuilding is the only recovery, and doing it
// silently is better than telling the user to run /new themselves.
if (typeof msg.message === 'string' && /Invalid.*signature.*thinking/i.test(msg.message)) {
const stored = this.sessionStore.get(chatId)
const workDir = stored?.workDir || this.defaultWorkDir
if (workDir) {
await this.port.sendNotice(chatId, '会话上下文已失效,正在自动重建...')
this.clearTransientChatState(chatId)
this.bridge.resetSession(chatId)
this.sessionStore.delete(chatId)
const ok = await this.createSessionForChat(chatId, workDir)
await this.port.sendNotice(
chatId,
ok ? '已重建会话,请重新发送消息。' : '重建会话失败,请发送 /new 手动新建。',
)
return
}
await this.port.sendNotice(chatId, '会话上下文已失效,请发送 /new 新建会话。')
return
}
await this.port.sendNotice(chatId, `错误: ${msg.message}`)
}
private async dispatchOutboundImage(chatId: string, pending: PendingUpload): Promise<void> {
const send = this.port.sendImage
if (!send) return
try {
const loaded = await loadSafeOutboundImage(pending, this.sessionStore.get(chatId)?.workDir)
if (!loaded.ok) {
console.warn(`${this.port.logPrefix} Outbound image rejected:`, loaded.reason)
return
}
await send.call(this.port, chatId, {
buffer: loaded.buffer,
mime: loaded.mime,
alt: pending.alt,
})
} catch (err) {
console.error(
`${this.port.logPrefix} dispatchOutboundImage failed:`,
err instanceof Error ? err.message : err,
)
}
}
// ---------- sessions ----------
async ensureExistingSession(chatId: string): Promise<{ sessionId: string; workDir: string } | null> {
return await restoreStoredSessionBinding({
chatId,
bridge: this.bridge,
sessionStore: this.sessionStore,
httpClient: this.httpClient,
onServerMessage: (msg) => this.handleServerMessage(chatId, msg),
logPrefix: this.port.logPrefix,
clearTransientState: () => this.clearTransientChatState(chatId),
})
}
private async ensureSession(chatId: string): Promise<boolean> {
const stored = await this.ensureExistingSession(chatId)
if (stored) return true
if (this.defaultWorkDir) {
return await this.createSessionForChat(chatId, this.defaultWorkDir)
}
await this.showProjectPicker(chatId)
return false
}
private async createSessionForChat(chatId: string, workDir: string): Promise<boolean> {
try {
// Always tear the old socket down first: connectSession() short-circuits
// on an OPEN connection, which would leave messages on the stale session.
this.bridge.resetSession(chatId)
this.clearTransientChatState(chatId)
const sessionId = await this.httpClient.createSession(workDir)
this.sessionStore.set(chatId, sessionId, workDir)
this.bridge.connectSession(chatId, sessionId)
this.bridge.onServerMessage(chatId, (msg) => this.handleServerMessage(chatId, msg))
const opened = await this.bridge.waitForOpen(chatId)
if (!opened) {
await this.port.sendNotice(chatId, '连接服务器超时,请重试。')
return false
}
return true
} catch (err) {
await this.port.sendNotice(
chatId,
`无法创建会话: ${err instanceof Error ? err.message : String(err)}`,
)
return false
}
}
async showProjectPicker(chatId: string): Promise<void> {
try {
const projects = await this.httpClient.listRecentProjects()
if (projects.length === 0) {
await this.port.sendNotice(
chatId,
`没有找到最近的项目。发送 /new 会使用默认工作目录:${this.defaultWorkDir}\n也可以发送 /new /path/to/project 指定项目。`,
)
return
}
const lines = projects.slice(0, 10).map((project, index) =>
`${index + 1}. ${project.projectName}${project.branch ? ` (${project.branch})` : ''}\n ${project.realPath}`,
)
this.pendingProjectSelection.add(chatId)
await this.port.sendNotice(
chatId,
`选择项目(回复编号):\n\n${lines.join('\n\n')}\n\n下次可直接 /new <编号、名称或绝对路径> 快速新建会话`,
)
} catch (err) {
await this.port.sendNotice(
chatId,
`无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`,
)
}
}
async startNewSession(chatId: string, query?: string): Promise<void> {
this.bridge.resetSession(chatId)
this.sessionStore.delete(chatId)
this.clearTransientChatState(chatId)
this.pendingProjectSelection.delete(chatId)
if (!query) {
if (this.defaultWorkDir) {
const ok = await this.createSessionForChat(chatId, this.defaultWorkDir)
if (ok) await this.port.sendNotice(chatId, '已新建会话,可以开始对话了。')
} else {
await this.showProjectPicker(chatId)
}
return
}
try {
const { project, ambiguous } = await this.httpClient.matchProject(query)
if (project) {
const ok = await this.createSessionForChat(chatId, project.realPath)
if (ok) {
await this.port.sendNotice(
chatId,
`已新建会话:${project.projectName}${project.branch ? ` (${project.branch})` : ''}`,
)
}
return
}
if (ambiguous) {
const list = ambiguous.map((p, i) => `${i + 1}. ${p.projectName} - ${p.realPath}`).join('\n')
await this.port.sendNotice(chatId, `匹配到多个项目,请更精确:\n\n${list}`)
return
}
await this.port.sendNotice(
chatId,
`未找到匹配 "${query}" 的项目。发送 /projects 查看完整列表。`,
)
} catch (err) {
await this.port.sendNotice(chatId, err instanceof Error ? err.message : String(err))
}
}
async buildStatusText(chatId: string): Promise<string> {
const stored = await this.ensureExistingSession(chatId)
if (!stored) return formatImStatus(null)
const runtime = this.getRuntimeState(chatId)
let projectName = path.basename(stored.workDir) || stored.workDir
let branch: string | null = null
try {
const gitInfo = await this.httpClient.getGitInfo(stored.sessionId)
projectName = gitInfo.repoName || path.basename(gitInfo.workDir) || projectName
branch = gitInfo.branch
} catch {
// Status stays best-effort; a git lookup failure must not hide the rest.
}
let taskCounts:
| { total: number; pending: number; inProgress: number; completed: number }
| undefined
try {
const tasks = await this.httpClient.getTasksForSession(stored.sessionId)
if (tasks.length > 0) {
taskCounts = {
total: tasks.length,
pending: tasks.filter((task) => task.status === 'pending').length,
inProgress: tasks.filter((task) => task.status === 'in_progress').length,
completed: tasks.filter((task) => task.status === 'completed').length,
}
}
} catch {
// Same: task counts are a nicety, not a precondition.
}
return formatImStatus({
sessionId: stored.sessionId,
projectName,
branch,
model: runtime.model,
state: runtime.state,
verb: runtime.verb,
pendingPermissionCount: runtime.pendingPermissionCount,
taskCounts,
})
}
// ---------- per-chat state ----------
getRuntimeState(chatId: string): ChatRuntimeState {
let state = this.runtimeStates.get(chatId)
if (!state) {
state = { state: 'idle', pendingPermissionCount: 0 }
this.runtimeStates.set(chatId, state)
}
return state
}
clearTransientChatState(chatId: string): void {
this.buffers.get(chatId)?.reset()
this.buffers.delete(chatId)
// Abandoning a turn still has to close its presentation. Dropping the
// reference instead leaves a WeCom bubble stuck mid-generation forever and
// a QQ StreamSession holding a live timer that can fire into a dead stream.
// Unflushed buffered text is deliberately not sent — this is an abandon,
// not a completion — so `finish()` only seals what the user already saw.
this.closeResponse(chatId)
this.pendingPermissions.delete(chatId)
this.imageWatchers.delete(chatId)
this.port.setBusy?.(chatId, false)
this.port.clearChat?.(chatId)
const runtime = this.getRuntimeState(chatId)
runtime.state = 'idle'
runtime.verb = undefined
runtime.pendingPermissionCount = 0
}
private getBuffer(chatId: string): MessageBuffer {
let buffer = this.buffers.get(chatId)
if (!buffer) {
buffer = new MessageBuffer(
async (text) => {
if (!text) return
await this.getResponse(chatId).append(text)
},
this.flushIntervalMs,
this.flushCharThreshold,
)
this.buffers.set(chatId, buffer)
}
return buffer
}
private getResponse(chatId: string): ResponseStream {
let response = this.responses.get(chatId)
if (!response) {
response = this.port.createResponse(chatId)
this.responses.set(chatId, response)
}
return response
}
private getImageWatcher(chatId: string): ImageBlockWatcher {
let watcher = this.imageWatchers.get(chatId)
if (!watcher) {
watcher = new ImageBlockWatcher()
this.imageWatchers.set(chatId, watcher)
}
return watcher
}
/** Flush what is buffered and close the turn's presentation, if any. */
private async finishResponse(
chatId: string,
options: { discardBuffer?: boolean } = {},
): Promise<void> {
if (options.discardBuffer) {
this.buffers.get(chatId)?.reset()
} else {
await this.buffers.get(chatId)?.complete()
}
this.buffers.delete(chatId)
await this.closeResponse(chatId)
}
/**
* Close the turn's presentation exactly once, if one is open.
*
* Returns a promise so `finishResponse` can await it, but callers on the
* synchronous teardown path may ignore it: the failure mode we care about is
* a stream that is never closed at all, not one closed a tick late.
*/
private closeResponse(chatId: string): Promise<void> {
const response = this.responses.get(chatId)
if (!response) return Promise.resolve()
this.responses.delete(chatId)
return Promise.resolve()
.then(() => response.finish())
.catch((err) => {
console.error(
`${this.port.logPrefix} Failed to finish response:`,
err instanceof Error ? err.message : err,
)
})
}
private async notifySafely(chatId: string, text: string): Promise<void> {
try {
await this.port.sendNotice(chatId, text)
} catch (err) {
console.error(
`${this.port.logPrefix} Failed to deliver notice:`,
err instanceof Error ? err.message : err,
)
}
}
}
+67
View File
@@ -33,6 +33,8 @@ export type FeishuConfig = {
appSecret: string
encryptKey: string
verificationToken: string
/** `lark` for international tenants, whose APIs live on open.larksuite.com. */
domain: 'feishu' | 'lark'
allowedUsers: string[]
pairedUsers: PairedUser[]
defaultWorkDir: string
@@ -71,6 +73,37 @@ export type WhatsAppConfig = {
allowedProjectRoots: string[]
}
/** Enterprise WeChat (企业微信) AI bot — QR-provisioned botId/secret over the
* 官方 WebSocket 长连接 (`@wecom/aibot-node-sdk`). */
export type WecomConfig = {
botId: string
secret: string
allowedUsers: string[]
pairedUsers: PairedUser[]
defaultWorkDir: string
allowedProjectRoots: string[]
}
/** QQ Open Platform bot — QR-provisioned appId/appSecret over the WS gateway. */
export type QQConfig = {
appId: string
appSecret: string
allowedUsers: string[]
pairedUsers: PairedUser[]
defaultWorkDir: string
allowedProjectRoots: string[]
}
/** Slack app in Socket Mode — `xoxb-` bot token plus `xapp-` app-level token. */
export type SlackConfig = {
botToken: string
appToken: string
allowedUsers: string[]
pairedUsers: PairedUser[]
defaultWorkDir: string
allowedProjectRoots: string[]
}
export type AdapterConfig = {
serverUrl: string
defaultProjectDir: string
@@ -81,6 +114,9 @@ export type AdapterConfig = {
wechat: WechatConfig
dingtalk: DingtalkConfig
whatsapp: WhatsAppConfig
wecom: WecomConfig
qq: QQConfig
slack: SlackConfig
}
export type AdapterPlatformConfig =
@@ -89,6 +125,9 @@ export type AdapterPlatformConfig =
| WechatConfig
| DingtalkConfig
| WhatsAppConfig
| WecomConfig
| QQConfig
| SlackConfig
function getConfigPath(): string {
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
@@ -113,6 +152,9 @@ export function loadConfig(): AdapterConfig {
const wc = file.wechat ?? {}
const dt = file.dingtalk ?? {}
const wa = file.whatsapp ?? {}
const wecom = file.wecom ?? {}
const qq = file.qq ?? {}
const slack = file.slack ?? {}
const pairing = file.pairing ?? {}
const fallbackWorkDir = resolveUserDefaultWorkDir()
const whatsappAuthDir = resolveConfiguredPath(
@@ -142,6 +184,7 @@ export function loadConfig(): AdapterConfig {
appSecret: process.env.FEISHU_APP_SECRET || fs_.appSecret || '',
encryptKey: process.env.FEISHU_ENCRYPT_KEY || fs_.encryptKey || '',
verificationToken: process.env.FEISHU_VERIFICATION_TOKEN || fs_.verificationToken || '',
domain: (process.env.FEISHU_DOMAIN || fs_.domain) === 'lark' ? 'lark' : 'feishu',
allowedUsers: fs_.allowedUsers ?? [],
pairedUsers: fs_.pairedUsers ?? [],
defaultWorkDir: fs_.defaultWorkDir || fallbackWorkDir,
@@ -176,6 +219,30 @@ export function loadConfig(): AdapterConfig {
defaultWorkDir: wa.defaultWorkDir || fallbackWorkDir,
allowedProjectRoots: readProjectRoots(wa.allowedProjectRoots),
},
wecom: {
botId: process.env.WECOM_BOT_ID || wecom.botId || '',
secret: process.env.WECOM_BOT_SECRET || wecom.secret || '',
allowedUsers: wecom.allowedUsers ?? [],
pairedUsers: wecom.pairedUsers ?? [],
defaultWorkDir: wecom.defaultWorkDir || fallbackWorkDir,
allowedProjectRoots: readProjectRoots(wecom.allowedProjectRoots),
},
qq: {
appId: process.env.QQ_APP_ID || qq.appId || '',
appSecret: process.env.QQ_APP_SECRET || qq.appSecret || '',
allowedUsers: qq.allowedUsers ?? [],
pairedUsers: qq.pairedUsers ?? [],
defaultWorkDir: qq.defaultWorkDir || fallbackWorkDir,
allowedProjectRoots: readProjectRoots(qq.allowedProjectRoots),
},
slack: {
botToken: process.env.SLACK_BOT_TOKEN || slack.botToken || '',
appToken: process.env.SLACK_APP_TOKEN || slack.appToken || '',
allowedUsers: slack.allowedUsers ?? [],
pairedUsers: slack.pairedUsers ?? [],
defaultWorkDir: slack.defaultWorkDir || fallbackWorkDir,
allowedProjectRoots: readProjectRoots(slack.allowedProjectRoots),
},
}
}
+58
View File
@@ -64,6 +64,64 @@ export function splitMessage(text: string, limit: number): string[] {
return chunks
}
const utf8Encoder = new TextEncoder()
/** UTF-8 byte length — WeCom and several other platforms cap on bytes, not characters. */
export function utf8Length(text: string): number {
return utf8Encoder.encode(text).length
}
/**
* Split text into chunks that each fit within a UTF-8 **byte** limit.
*
* `splitMessage` counts characters, which silently overflows a byte cap the
* moment the text is Chinese (3 bytes per character). Splitting still prefers
* paragraph, then line, then word boundaries; a single oversized token is cut
* at a code-point boundary rather than mid-sequence.
*/
export function splitMessageByBytes(text: string, maxBytes: number): string[] {
if (maxBytes <= 0) return [text]
if (utf8Length(text) <= maxBytes) return [text]
const chunks: string[] = []
let remaining = text
while (remaining.length > 0) {
if (utf8Length(remaining) <= maxBytes) {
chunks.push(remaining)
break
}
// Walk back from an upper-bound character index until the slice fits.
let end = Math.min(remaining.length, maxBytes)
while (end > 0 && utf8Length(remaining.slice(0, end)) > maxBytes) {
end -= 1
}
if (end <= 0) end = 1
const window = remaining.slice(0, end)
let splitAt = window.lastIndexOf('\n\n')
if (splitAt <= 0) splitAt = window.lastIndexOf('\n')
if (splitAt <= 0) splitAt = window.lastIndexOf(' ')
if (splitAt <= 0) splitAt = end
// Never split inside a surrogate pair — that produces invalid UTF-8.
if (splitAt < remaining.length && isLowSurrogate(remaining.charCodeAt(splitAt))) {
splitAt -= 1
}
if (splitAt <= 0) splitAt = end
chunks.push(remaining.slice(0, splitAt).trimEnd())
remaining = remaining.slice(splitAt).trimStart()
}
return chunks.filter((chunk) => chunk.length > 0)
}
function isLowSurrogate(code: number): boolean {
return code >= 0xdc00 && code <= 0xdfff
}
type MarkdownTable = {
headers: string[]
rows: string[][]
+9 -1
View File
@@ -13,7 +13,15 @@ import * as crypto from 'node:crypto'
import type { PairedUser, PairingState } from './config.js'
const SAFE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789' // 排除 0/O/1/I/L
export type ImPlatform = 'telegram' | 'feishu' | 'wechat' | 'dingtalk' | 'whatsapp'
export type ImPlatform =
| 'telegram'
| 'feishu'
| 'wechat'
| 'dingtalk'
| 'whatsapp'
| 'wecom'
| 'qq'
| 'slack'
// 速率限制:每个 userId 在 RATE_LIMIT_WINDOW_MS 内最多 RATE_LIMIT_MAX_ATTEMPTS 次失败尝试
const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000 // 5 minutes
@@ -0,0 +1,230 @@
/**
* Feishu scan-to-create provisioning — protocol round-trip.
*
* These tests pin the wire contract we implement against Feishu's device
* authorization endpoint: what `begin` must send, what the QR URL must and must
* not carry, and how every RFC 8628 poll outcome maps onto our status. The
* fetch implementation is injected, so nothing here touches the network.
*/
import { afterEach, describe, expect, it } from 'bun:test'
import { gunzipSync } from 'node:zlib'
import {
assertFeishuVerificationUrl,
beginFeishuRegistration,
encodeFeishuAddons,
FEISHU_REQUIRED_CALLBACKS,
FEISHU_REQUIRED_EVENTS,
FEISHU_REQUIRED_SCOPES,
pollFeishuRegistration,
resetFeishuRegistrationsForTest,
} from '../registration.js'
type Call = { url: string; params: Record<string, string> }
function fakeFetch(responses: Array<Record<string, unknown>>, calls: Call[] = []) {
let index = 0
const impl = (async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString()
const params = Object.fromEntries(new URLSearchParams(String(init?.body ?? '')))
calls.push({ url, params })
const body = responses[Math.min(index, responses.length - 1)]
index += 1
return new Response(JSON.stringify(body), {
status: (body as { error?: string }).error ? 400 : 200,
headers: { 'content-type': 'application/json' },
})
}) as unknown as typeof fetch
return { impl, calls }
}
const BEGIN_OK = {
device_code: 'device-123',
verification_uri_complete: 'https://open.feishu.cn/page/launcher?code=abc',
expires_in: 600,
interval: 5,
}
afterEach(() => {
resetFeishuRegistrationsForTest()
})
describe('beginFeishuRegistration', () => {
it('asks for a personal-agent app with a client secret', async () => {
const { impl, calls } = fakeFetch([BEGIN_OK])
await beginFeishuRegistration({ fetchImpl: impl })
expect(calls[0]!.url).toBe('https://accounts.feishu.cn/oauth/v1/app/registration')
expect(calls[0]!.params).toEqual({
action: 'begin',
archetype: 'PersonalAgent',
auth_method: 'client_secret',
request_user_info: 'open_id',
})
})
it('pins the QR URL to create-only and carries the scopes this adapter calls', async () => {
const { impl } = fakeFetch([BEGIN_OK])
const begun = await beginFeishuRegistration({
fetchImpl: impl,
appName: 'Claude Code Haha',
appDescription: 'desc',
})
const url = new URL(begun.verificationUri)
// createOnly is the guard that stops this flow from silently rewriting the
// configuration of a bot the user already runs.
expect(url.searchParams.get('createOnly')).toBe('true')
expect(url.searchParams.get('name')).toBe('Claude Code Haha')
expect(url.searchParams.get('desc')).toBe('desc')
expect(url.searchParams.has('clientID')).toBe(false)
const addons = JSON.parse(decodeAddons(url.searchParams.get('addons')!))
expect(addons.preset).toBe(false)
expect(addons.scopes.tenant).toEqual([...FEISHU_REQUIRED_SCOPES])
expect(addons.events.items.tenant).toEqual([...FEISHU_REQUIRED_EVENTS])
expect(addons.callbacks.items).toEqual([...FEISHU_REQUIRED_CALLBACKS])
})
it('falls back to platform defaults when expiry and interval are missing', async () => {
const { impl } = fakeFetch([{ device_code: 'd', verification_uri_complete: BEGIN_OK.verification_uri_complete }])
const begun = await beginFeishuRegistration({ fetchImpl: impl })
expect(begun.expiresInSeconds).toBe(600)
expect(begun.intervalSeconds).toBe(5)
})
it('starts on the Lark account domain when asked', async () => {
const { impl, calls } = fakeFetch([BEGIN_OK])
await beginFeishuRegistration({ fetchImpl: impl, domain: 'lark' })
expect(calls[0]!.url.startsWith('https://accounts.larksuite.com/')).toBe(true)
})
it('rejects a begin response with no device code', async () => {
const { impl } = fakeFetch([{ verification_uri_complete: BEGIN_OK.verification_uri_complete }])
await expect(beginFeishuRegistration({ fetchImpl: impl })).rejects.toThrow(/device code/i)
})
})
describe('assertFeishuVerificationUrl', () => {
it('accepts the platform launcher hosts', () => {
expect(assertFeishuVerificationUrl('https://open.feishu.cn/page/launcher?x=1').hostname)
.toBe('open.feishu.cn')
expect(assertFeishuVerificationUrl('https://accounts.larksuite.com/x').hostname)
.toBe('accounts.larksuite.com')
})
// The URL becomes a QR code we tell the user to scan, so an off-platform or
// credentialed host would be a phishing vector wearing our UI.
it.each([
['http://open.feishu.cn/page/launcher', 'plain http'],
['https://evil.example.com/page/launcher', 'foreign host'],
['https://user:pass@open.feishu.cn/x', 'embedded credentials'],
['https://open.feishu.cn:8443/x', 'non-default port'],
['not a url', 'unparseable'],
])('rejects %s (%s)', (value) => {
expect(() => assertFeishuVerificationUrl(value)).toThrow()
})
})
describe('pollFeishuRegistration', () => {
async function begin(responses: Array<Record<string, unknown>>) {
const { impl, calls } = fakeFetch([BEGIN_OK, ...responses])
const begun = await beginFeishuRegistration({ fetchImpl: impl })
return { sessionKey: begun.sessionKey, impl, calls }
}
it('returns the credentials once the platform issues them', async () => {
const { sessionKey, impl } = await begin([
{ client_id: 'cli_abc', client_secret: 'secret-1', user_info: { open_id: 'ou_1' } },
])
const result = await pollFeishuRegistration({ sessionKey, fetchImpl: impl })
expect(result).toEqual({
status: 'success',
appId: 'cli_abc',
appSecret: 'secret-1',
domain: 'feishu',
openId: 'ou_1',
})
})
it('keeps waiting while the user has not confirmed', async () => {
const { sessionKey, impl } = await begin([{ error: 'authorization_pending' }])
const result = await pollFeishuRegistration({ sessionKey, fetchImpl: impl })
expect(result.status).toBe('waiting')
})
it('honours slow_down by widening the poll interval', async () => {
const { sessionKey, impl } = await begin([{ error: 'slow_down' }])
const result = await pollFeishuRegistration({ sessionKey, fetchImpl: impl })
expect(result).toMatchObject({ status: 'waiting', intervalSeconds: 10 })
})
it.each([
['expired_token', 'expired'],
['access_denied', 'denied'],
['something_else', 'failed'],
])('maps %s to %s and ends the attempt', async (error, expected) => {
const { sessionKey, impl } = await begin([{ error }])
expect((await pollFeishuRegistration({ sessionKey, fetchImpl: impl })).status).toBe(expected)
// A terminal outcome must clear the session rather than let the UI keep
// polling a device code the platform has already retired.
expect((await pollFeishuRegistration({ sessionKey, fetchImpl: impl })).status).toBe('not_started')
})
it('reports an unknown session key instead of throwing', async () => {
const { impl } = fakeFetch([{}])
expect((await pollFeishuRegistration({ sessionKey: 'nope', fetchImpl: impl })).status)
.toBe('not_started')
})
it('switches to the Lark domain once for an international tenant', async () => {
const calls: Call[] = []
const { impl } = fakeFetch(
[
BEGIN_OK,
{ user_info: { tenant_brand: 'lark' }, error: 'authorization_pending' },
{ client_id: 'cli_lark', client_secret: 'secret-lark', user_info: { tenant_brand: 'lark' } },
],
calls,
)
const begun = await beginFeishuRegistration({ fetchImpl: impl })
const result = await pollFeishuRegistration({ sessionKey: begun.sessionKey, fetchImpl: impl })
expect(result).toMatchObject({ status: 'success', appId: 'cli_lark', domain: 'lark' })
expect(calls[1]!.url.startsWith('https://accounts.feishu.cn/')).toBe(true)
expect(calls[2]!.url.startsWith('https://accounts.larksuite.com/')).toBe(true)
})
})
describe('encodeFeishuAddons', () => {
it('produces URL-safe unpadded base64 that gunzips back to the payload', () => {
const payload = { preset: false, scopes: { tenant: ['im:message:send_as_bot'] } }
const encoded = encodeFeishuAddons(payload)
expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/)
expect(JSON.parse(decodeAddons(encoded))).toEqual(payload)
})
})
function decodeAddons(encoded: string): string {
const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/')
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4)
return gunzipSync(Buffer.from(padded, 'base64')).toString('utf8')
}
+6 -2
View File
@@ -51,11 +51,15 @@ if (!config.feishu.appId || !config.feishu.appSecret) {
process.exit(1)
}
/** International (Lark) tenants serve the same APIs from a different origin;
* a bot provisioned there cannot authenticate against open.feishu.cn. */
const larkDomain = config.feishu.domain === 'lark' ? Lark.Domain.Lark : Lark.Domain.Feishu
const larkClient = new Lark.Client({
appId: config.feishu.appId,
appSecret: config.feishu.appSecret,
appType: Lark.AppType.SelfBuild,
domain: Lark.Domain.Feishu,
domain: larkDomain,
})
const bridge = new WsBridge(config.serverUrl, 'feishu')
@@ -1242,7 +1246,7 @@ async function start(): Promise<void> {
wsClient = new Lark.WSClient({
appId: config.feishu.appId,
appSecret: config.feishu.appSecret,
domain: Lark.Domain.Feishu,
domain: larkDomain,
loggerLevel: Lark.LoggerLevel.info,
})
+337
View File
@@ -0,0 +1,337 @@
/**
* Feishu / Lark scan-to-create bot provisioning.
*
* Until now, connecting Feishu meant sending the user to the open platform to
* create an app by hand and paste an App ID and App Secret back. Feishu also
* exposes an RFC 8628 device-authorization flow at
* `accounts.feishu.cn/oauth/v1/app/registration`: the desktop asks for a device
* code, renders the returned URL as a QR code, and the user scans it in Feishu,
* confirms a pre-filled app (name, description, scopes, events, callbacks) and
* the poll returns `client_id` / `client_secret` directly.
*
* The protocol is implemented here rather than taken from
* `@larksuiteoapi/node-sdk` on purpose: `registerApp` only landed in 1.73 (this
* repo pins 1.60 for the chat client), it runs the whole poll inside one
* un-cancellable promise, and the desktop needs the stateless begin/poll pair
* that the DingTalk registration already uses. What we borrow from the SDK is
* the wire format, which is why the details below are spelled out.
*/
import * as crypto from 'node:crypto'
import { gzipSync } from 'node:zlib'
const FEISHU_ACCOUNTS_DOMAIN = 'accounts.feishu.cn'
const LARK_ACCOUNTS_DOMAIN = 'accounts.larksuite.com'
const REGISTRATION_PATH = '/oauth/v1/app/registration'
const REQUEST_TIMEOUT_MS = 15_000
const SESSION_TTL_MS = 30 * 60_000
/** Hosts allowed to appear in a verification URL we turn into a QR image. */
const ALLOWED_VERIFICATION_HOSTS = new Set([
'accounts.feishu.cn',
'open.feishu.cn',
'accounts.larksuite.com',
'open.larksuite.com',
])
/**
* Scopes this repository's Feishu adapter actually calls.
*
* `im.message.receive_v1` needs the p2p read scope; `im.message.create` /
* `reply` / `patch` need send-as-bot; `im.messageResource.get`,
* `im.image.create` and `im.file.create` need `im:resource`; the streaming card
* needs the CardKit write scope. Nothing here is speculative — adding a scope
* without a call site means asking the user to grant access we never use.
*/
export const FEISHU_REQUIRED_SCOPES = [
'im:message.p2p_msg:readonly',
'im:message:readonly',
'im:message:send_as_bot',
'im:resource',
'cardkit:card:write',
] as const
export const FEISHU_REQUIRED_EVENTS = ['im.message.receive_v1'] as const
export const FEISHU_REQUIRED_CALLBACKS = ['card.action.trigger'] as const
export type FeishuRegistrationBegin = {
sessionKey: string
verificationUri: string
expiresInSeconds: number
intervalSeconds: number
message: string
}
export type FeishuRegistrationPoll =
| { status: 'success'; appId: string; appSecret: string; domain: 'feishu' | 'lark'; openId?: string }
| { status: 'waiting'; intervalSeconds: number; message: string }
| { status: 'expired' | 'denied' | 'failed' | 'not_started'; message: string }
type RegistrationSession = {
sessionKey: string
deviceCode: string
accountsDomain: string
domain: 'feishu' | 'lark'
intervalSeconds: number
domainSwitched: boolean
startedAt: number
expiresAt: number
}
type RegistrationResponse = {
device_code?: string
verification_uri_complete?: string
expires_in?: number
interval?: number
client_id?: string
client_secret?: string
user_info?: { open_id?: string; tenant_brand?: string }
error?: string
error_description?: string
}
const sessions = new Map<string, RegistrationSession>()
/**
* Incremental app configuration pre-filled into the confirmation page.
*
* The payload travels on the URL as gzip → base64 → URL-safe → unpadded, a
* shape fixed by the platform. `preset: false` drops Feishu's default template
* so the user is asked for exactly the five scopes above and nothing more.
*/
export function encodeFeishuAddons(addons: Record<string, unknown>): string {
return gzipSync(Buffer.from(JSON.stringify(addons), 'utf8'))
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
function defaultAddons(): Record<string, unknown> {
return {
preset: false,
scopes: { tenant: [...FEISHU_REQUIRED_SCOPES] },
events: { items: { tenant: [...FEISHU_REQUIRED_EVENTS] } },
callbacks: { items: [...FEISHU_REQUIRED_CALLBACKS] },
}
}
/**
* Reject a verification URL that is not a plain Feishu/Lark HTTPS page.
*
* The URL is rendered as a QR code the user is told to scan, so a redirected
* or credentialed URL would be a phishing vector wearing our UI.
*/
export function assertFeishuVerificationUrl(value: string | undefined): URL {
if (!value) throw new Error('Feishu registration returned no verification URL')
let url: URL
try {
url = new URL(value)
} catch {
throw new Error('Feishu registration returned an invalid verification URL')
}
const safePort = !url.port || url.port === '443'
if (
url.protocol !== 'https:'
|| !ALLOWED_VERIFICATION_HOSTS.has(url.hostname)
|| !safePort
|| url.username
|| url.password
) {
throw new Error('Feishu registration returned an unsafe verification URL')
}
return url
}
async function requestRegistration(
accountsDomain: string,
params: Record<string, string>,
fetchImpl: typeof fetch,
): Promise<RegistrationResponse> {
const response = await fetchImpl(`https://${accountsDomain}${REGISTRATION_PATH}`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(params).toString(),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
// RFC 8628 delivers authorization_pending / slow_down with HTTP 400, so the
// body has to be read on failure too — only a missing body is a transport
// error.
const data = (await response.json().catch(() => null)) as RegistrationResponse | null
if (!data) {
throw new Error(`Feishu registration returned HTTP ${response.status}`)
}
return data
}
function purgeExpiredSessions(): void {
const now = Date.now()
for (const [key, session] of sessions) {
if (now - session.startedAt > SESSION_TTL_MS || now > session.expiresAt) {
sessions.delete(key)
}
}
}
export type BeginFeishuRegistrationOptions = {
sessionKey?: string
/** Identifies this client on the confirmation page. */
source?: string
/** Pre-filled app name / description; the user can still edit them. */
appName?: string
appDescription?: string
/** Start on Lark's account domain for international tenants. */
domain?: 'feishu' | 'lark'
fetchImpl?: typeof fetch
}
export async function beginFeishuRegistration(
options: BeginFeishuRegistrationOptions = {},
): Promise<FeishuRegistrationBegin> {
purgeExpiredSessions()
const fetchImpl = options.fetchImpl ?? fetch
const domain = options.domain === 'lark' ? 'lark' : 'feishu'
const accountsDomain = domain === 'lark' ? LARK_ACCOUNTS_DOMAIN : FEISHU_ACCOUNTS_DOMAIN
const begun = await requestRegistration(
accountsDomain,
{
action: 'begin',
archetype: 'PersonalAgent',
auth_method: 'client_secret',
request_user_info: 'open_id',
},
fetchImpl,
)
if (begun.error) {
throw new Error(`Feishu registration failed: ${begun.error_description || begun.error}`)
}
const deviceCode = begun.device_code?.trim()
if (!deviceCode) throw new Error('Feishu registration returned no device code')
const verificationUrl = assertFeishuVerificationUrl(begun.verification_uri_complete)
verificationUrl.searchParams.set('from', 'sdk')
verificationUrl.searchParams.set('source', options.source || 'claude-code-haha')
verificationUrl.searchParams.set('tp', 'sdk')
// Only ever create a new app: binding an existing one would let this flow
// overwrite the callback configuration of a bot the user already runs.
verificationUrl.searchParams.set('createOnly', 'true')
if (options.appName) verificationUrl.searchParams.set('name', options.appName)
if (options.appDescription) verificationUrl.searchParams.set('desc', options.appDescription)
verificationUrl.searchParams.set('addons', encodeFeishuAddons(defaultAddons()))
const expiresInSeconds = Number.isFinite(Number(begun.expires_in)) && Number(begun.expires_in) > 0
? Number(begun.expires_in)
: 600
const intervalSeconds = Number.isFinite(Number(begun.interval)) && Number(begun.interval) > 0
? Number(begun.interval)
: 5
const sessionKey = options.sessionKey || crypto.randomUUID()
const startedAt = Date.now()
sessions.set(sessionKey, {
sessionKey,
deviceCode,
accountsDomain,
domain,
intervalSeconds,
domainSwitched: false,
startedAt,
expiresAt: startedAt + expiresInSeconds * 1000,
})
return {
sessionKey,
verificationUri: verificationUrl.toString(),
expiresInSeconds,
intervalSeconds,
message: '使用飞书扫描二维码,确认后会自动创建机器人。',
}
}
export async function pollFeishuRegistration(options: {
sessionKey: string
fetchImpl?: typeof fetch
}): Promise<FeishuRegistrationPoll> {
purgeExpiredSessions()
const session = sessions.get(options.sessionKey)
if (!session) {
return {
status: 'not_started',
message: '当前没有进行中的飞书扫码,请重新生成二维码。',
}
}
const fetchImpl = options.fetchImpl ?? fetch
let data = await requestRegistration(
session.accountsDomain,
{ action: 'poll', device_code: session.deviceCode },
fetchImpl,
)
// An international tenant finishes the flow on Lark's domain. The switch is
// one-way and happens once, mirroring the official SDK.
if (data.user_info?.tenant_brand === 'lark' && !session.domainSwitched) {
session.domainSwitched = true
session.domain = 'lark'
session.accountsDomain = LARK_ACCOUNTS_DOMAIN
data = await requestRegistration(
session.accountsDomain,
{ action: 'poll', device_code: session.deviceCode },
fetchImpl,
)
}
if (data.client_id && data.client_secret) {
sessions.delete(options.sessionKey)
return {
status: 'success',
appId: data.client_id,
appSecret: data.client_secret,
domain: session.domain,
openId: data.user_info?.open_id,
}
}
switch (data.error) {
case undefined:
case '':
case 'authorization_pending':
return {
status: 'waiting',
intervalSeconds: session.intervalSeconds,
message: '等待飞书扫码确认...',
}
case 'slow_down':
// The server asks for a longer gap; honour it for the rest of this run.
session.intervalSeconds += 5
return {
status: 'waiting',
intervalSeconds: session.intervalSeconds,
message: '等待飞书扫码确认...',
}
case 'expired_token':
sessions.delete(options.sessionKey)
return { status: 'expired', message: '二维码已过期,请重新生成。' }
case 'access_denied':
sessions.delete(options.sessionKey)
return { status: 'denied', message: '飞书授权被拒绝。' }
default:
sessions.delete(options.sessionKey)
// The description can echo server-side detail; keep it out of the API
// surface and log nothing that could carry a credential.
return { status: 'failed', message: '飞书扫码授权失败,请重试。' }
}
}
export function cancelFeishuRegistration(sessionKey: string): void {
sessions.delete(sessionKey)
}
/** Test seam: the module-level session map must not leak across test cases. */
export function resetFeishuRegistrationsForTest(): void {
sessions.clear()
}
+10 -1
View File
@@ -9,16 +9,25 @@
"wechat": "bun run wechat/index.ts",
"dingtalk": "bun run dingtalk/index.ts",
"whatsapp": "bun run whatsapp/index.ts",
"wecom": "bun run wecom/index.ts",
"qq": "bun run qq/index.ts",
"slack": "bun run slack/index.ts",
"test": "bun test",
"test:common": "bun test ./common/",
"test:telegram": "bun test ./telegram/",
"test:feishu": "bun test ./feishu/",
"test:wechat": "bun test ./wechat/",
"test:dingtalk": "bun test ./dingtalk/",
"test:whatsapp": "bun test ./whatsapp/"
"test:whatsapp": "bun test ./whatsapp/",
"test:wecom": "bun test ./wecom/",
"test:qq": "bun test ./qq/",
"test:slack": "bun test ./slack/"
},
"dependencies": {
"@larksuiteoapi/node-sdk": "^1.60.0",
"@tencent-connect/qqbot-connector": "1.2.0",
"@tencent-connect/qqbot-nodejs": "1.0.4",
"@wecom/aibot-node-sdk": "1.0.7",
"@whiskeysockets/baileys": "7.0.0-rc.9",
"dingtalk-stream": "2.1.4",
"grammy": "^1.42.0",
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'bun:test'
import { extractQqPayload } from '../extract-payload.js'
const c2c = {
kind: 'c2c',
senderId: 'openid-1',
senderName: 'Tester',
content: 'hello',
messageId: 'msg-1',
timestamp: '2026-08-23T00:00:00Z',
}
describe('extractQqPayload', () => {
it('flattens a private message', () => {
expect(extractQqPayload(c2c)).toEqual({
chatId: 'openid-1',
userId: 'openid-1',
displayName: 'Tester',
text: 'hello',
dedupKey: 'msg-1',
attachments: [],
})
})
// Pairing authorizes one person; a group message would extend that
// authorization to every other member.
it.each([['group'], ['guild'], ['dm']])('drops a %s message', (kind) => {
expect(extractQqPayload({ ...c2c, kind })).toBeNull()
})
it('drops a message the bot itself sent', () => {
expect(extractQqPayload({ ...c2c, senderIsBot: true })).toBeNull()
})
it('drops a message with no sender identity', () => {
expect(extractQqPayload({ ...c2c, senderId: ' ' })).toBeNull()
expect(extractQqPayload(undefined)).toBeNull()
})
it('keeps downloadable attachments and their order', () => {
const payload = extractQqPayload({
...c2c,
attachments: [
{ content_type: 'image/png', url: 'multimedia.example/a.png' },
{ content_type: 'application/pdf', url: 'multimedia.example/b.pdf' },
],
})
expect(payload!.attachments.map((item) => item.url)).toEqual([
'multimedia.example/a.png',
'multimedia.example/b.pdf',
])
})
it('drops an attachment with no URL to fetch', () => {
const payload = extractQqPayload({ ...c2c, attachments: [{ content_type: 'image/png' }] })
expect(payload!.attachments).toEqual([])
})
// A voice note is fully represented by QQ's own transcript; downloading the
// audio would attach something the Agent cannot read.
it('folds a voice transcript into the text and does not download the audio', () => {
const payload = extractQqPayload({
...c2c,
content: '',
attachments: [
{ content_type: 'audio/silk', url: 'multimedia.example/v.silk', asr_refer_text: 'run the tests' },
],
})
expect(payload!.text).toBe('run the tests')
expect(payload!.attachments).toEqual([])
})
it('falls back to a synthesized dedup key when the message id is absent', () => {
const payload = extractQqPayload({ ...c2c, messageId: undefined })
expect(payload!.dedupKey).toBe('openid-1:2026-08-23T00:00:00Z:hello')
})
it('falls back to a generic display name', () => {
expect(extractQqPayload({ ...c2c, senderName: undefined })!.displayName).toBe('QQ User')
})
})
+107
View File
@@ -0,0 +1,107 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { AttachmentStore } from '../../common/attachment/attachment-store.js'
import { normalizeQqAttachmentUrl, QqMediaService } from '../media.js'
let tmpDir: string
let store: AttachmentStore
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qq-media-test-'))
store = new AttachmentStore({ root: tmpDir })
})
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true })
})
describe('normalizeQqAttachmentUrl', () => {
it('adds the scheme QQ omits', () => {
expect(normalizeQqAttachmentUrl('multimedia.nt.qq.com.cn/a.png')?.toString())
.toBe('https://multimedia.nt.qq.com.cn/a.png')
})
it('upgrades an http reference to https', () => {
expect(normalizeQqAttachmentUrl('http://multimedia.nt.qq.com.cn/a.png')?.protocol).toBe('https:')
})
// The URL comes from remote input and is fetched by this process, so
// anything that is not a plain web URL must fail closed.
it.each([
['file:///etc/passwd', 'file scheme'],
['https://user:pw@host/a.png', 'embedded credentials'],
[' ', 'blank'],
[undefined, 'missing'],
])('rejects %s (%s)', (value) => {
expect(normalizeQqAttachmentUrl(value as string | undefined)).toBeNull()
})
})
describe('QqMediaService', () => {
function fetchReturning(body: string, headers: Record<string, string> = {}) {
return (async () =>
new Response(body, { status: 200, headers })) as unknown as typeof fetch
}
it('stages an image and reports it as an image attachment', async () => {
const service = new QqMediaService(store, fetchReturning('png-bytes'))
const local = await service.downloadAttachment(
{ content_type: 'image/png', url: 'multimedia.example/a.png' },
'session-1',
'seed-0',
)
expect(local.kind).toBe('image')
expect(local.mimeType).toBe('image/png')
expect(local.name).toBe('qq-image-seed-0.png')
expect(fs.readFileSync(local.path, 'utf8')).toBe('png-bytes')
})
it('keeps the filename QQ supplied', async () => {
const service = new QqMediaService(store, fetchReturning('doc'))
const local = await service.downloadAttachment(
{ content_type: 'application/pdf', url: 'multimedia.example/x', filename: 'spec.pdf' },
'session-1',
'seed-1',
)
expect(local.name).toBe('spec.pdf')
expect(local.kind).toBe('file')
})
it('falls back to the response content type when the event omits one', async () => {
const service = new QqMediaService(store, fetchReturning('gif', { 'content-type': 'image/gif; charset=binary' }))
const local = await service.downloadAttachment(
{ url: 'multimedia.example/x' },
'session-1',
'seed-2',
)
expect(local.mimeType).toBe('image/gif')
expect(local.kind).toBe('image')
})
it('surfaces a failed download instead of staging an empty file', async () => {
const service = new QqMediaService(
store,
(async () => new Response('nope', { status: 403 })) as unknown as typeof fetch,
)
await expect(
service.downloadAttachment({ url: 'multimedia.example/x' }, 'session-1', 'seed-3'),
).rejects.toThrow(/403/)
})
it('refuses an attachment whose URL cannot be used', async () => {
const service = new QqMediaService(store, fetchReturning('x'))
await expect(
service.downloadAttachment({ url: 'file:///etc/passwd' }, 'session-1', 'seed-4'),
).rejects.toThrow(/download URL/i)
})
})
+202
View File
@@ -0,0 +1,202 @@
import { afterEach, describe, expect, it } from 'bun:test'
import {
cancelQqQrLogin,
pollQqQrLogin,
resetQqQrLoginsForTest,
setQqQrConnector,
startQqQrLogin,
type StartQrConnect,
} from '../qr-auth.js'
type Controls = {
displayQr: (url: string) => void
succeed: (credentials: Array<{ appId: string; appSecret: string; userOpenid?: string }>) => void
fail: (error: Error) => void
disposed: () => number
options: () => { displayQrCodeToConsole?: boolean; source?: string } | undefined
}
/**
* A stand-in for the connector SDK that lets a test drive its callbacks.
*
* `startQqQrLogin` resolves the connector asynchronously, so a test that drives
* a callback on the very next line would otherwise race the registration.
* Actions queued before the connector starts are replayed the moment it does.
*/
function installFakeConnector(): Controls {
let callbacks: Parameters<StartQrConnect>[0] | null = null
let options: Parameters<StartQrConnect>[1]
let disposeCount = 0
const queued: Array<(cb: Parameters<StartQrConnect>[0]) => void> = []
const run = (action: (cb: Parameters<StartQrConnect>[0]) => void) => {
if (callbacks) action(callbacks)
else queued.push(action)
}
const impl: StartQrConnect = (cb, opts) => {
callbacks = cb
options = opts
for (const action of queued.splice(0)) action(cb)
return () => {
disposeCount += 1
}
}
setQqQrConnector(impl)
return {
displayQr: (url) => run((cb) => cb.onQrDisplayed?.(url)),
succeed: (credentials) => run((cb) => cb.onSuccess(credentials)),
fail: (error) => run((cb) => cb.onFailure(error)),
disposed: () => disposeCount,
options: () => options,
}
}
afterEach(() => {
resetQqQrLoginsForTest()
setQqQrConnector(null)
})
describe('startQqQrLogin', () => {
it('resolves with the QR URL the connector reports and never prints it', async () => {
const controls = installFakeConnector()
const pending = startQqQrLogin({ sessionKey: 'k1' })
controls.displayQr('https://qq.example/scan?token=1')
const started = await pending
expect(started).toMatchObject({
sessionKey: 'k1',
verificationUrl: 'https://qq.example/scan?token=1',
})
// A desktop app renders the QR itself; console output would be noise and
// would leak the code into the sidecar log.
expect(controls.options()?.displayQrCodeToConsole).toBe(false)
expect(controls.options()?.source).toBe('claude-code-haha')
})
it('rejects and disposes the connector when it fails before showing a code', async () => {
const controls = installFakeConnector()
const pending = startQqQrLogin({ sessionKey: 'k2' })
controls.fail(new Error('connector exploded'))
await expect(pending).rejects.toThrow('connector exploded')
expect(controls.disposed()).toBe(1)
})
// Each start mints a fresh key, so without superseding, an abandoned scan
// keeps a connector polling the vendor until its TTL expires — and every
// retry stacks another one on top.
it('disposes an abandoned attempt when a new scan starts', async () => {
const first = installFakeConnector()
const startedFirst = startQqQrLogin({ sessionKey: 'abandoned' })
first.displayQr('https://qq.example/one')
await startedFirst
const second = installFakeConnector()
const startedSecond = startQqQrLogin({ sessionKey: 'fresh' })
second.displayQr('https://qq.example/two')
await startedSecond
expect(first.disposed()).toBe(1)
expect(pollQqQrLogin({ sessionKey: 'abandoned' })).toMatchObject({ status: 'not_started' })
expect(pollQqQrLogin({ sessionKey: 'fresh' })).toMatchObject({ status: 'waiting' })
})
it('supersedes a previous attempt on the same session key', async () => {
const first = installFakeConnector()
const startedFirst = startQqQrLogin({ sessionKey: 'same' })
first.displayQr('https://qq.example/one')
await startedFirst
const second = installFakeConnector()
const startedSecond = startQqQrLogin({ sessionKey: 'same' })
second.displayQr('https://qq.example/two')
await startedSecond
expect(first.disposed()).toBe(1)
expect(pollQqQrLogin({ sessionKey: 'same' })).toMatchObject({ status: 'waiting' })
})
})
describe('pollQqQrLogin', () => {
async function started(sessionKey = 'poll') {
const controls = installFakeConnector()
const pending = startQqQrLogin({ sessionKey })
controls.displayQr('https://qq.example/scan')
await pending
return controls
}
it('waits until the connector reports credentials', async () => {
const controls = await started()
expect(pollQqQrLogin({ sessionKey: 'poll' })).toMatchObject({ connected: false, status: 'waiting' })
controls.succeed([{ appId: 'app-1', appSecret: 'secret-1', userOpenid: 'ou-1' }])
expect(pollQqQrLogin({ sessionKey: 'poll' })).toEqual({
connected: true,
appId: 'app-1',
appSecret: 'secret-1',
userOpenid: 'ou-1',
})
})
it('latches success so a later poll cannot resurrect the attempt', async () => {
const controls = await started()
controls.succeed([{ appId: 'app-1', appSecret: 'secret-1' }])
expect(pollQqQrLogin({ sessionKey: 'poll' })).toMatchObject({ connected: true })
expect(pollQqQrLogin({ sessionKey: 'poll' })).toMatchObject({ status: 'not_started' })
})
it('treats incomplete credentials as a failure rather than binding a broken bot', async () => {
const controls = await started()
controls.succeed([{ appId: 'app-1', appSecret: '' }])
expect(pollQqQrLogin({ sessionKey: 'poll' })).toMatchObject({ connected: false, status: 'failed' })
})
it('maps an expiry failure to expired and any other failure to failed', async () => {
const expiring = await started('expiry')
expiring.fail(new Error('device code expired'))
expect(pollQqQrLogin({ sessionKey: 'expiry' })).toMatchObject({ status: 'expired' })
const failing = await started('other')
failing.fail(new Error('network down'))
expect(pollQqQrLogin({ sessionKey: 'other' })).toMatchObject({ status: 'failed' })
})
it('reports a rotated QR exactly once so the UI redraws without flicker', async () => {
const controls = await started()
controls.displayQr('https://qq.example/refreshed')
const afterRotation = pollQqQrLogin({ sessionKey: 'poll' })
expect(afterRotation).toMatchObject({
status: 'waiting',
verificationUrl: 'https://qq.example/refreshed',
})
// A repeat poll must not keep re-delivering the same URL, or the settings
// page would regenerate the image on every tick.
const settled = pollQqQrLogin({ sessionKey: 'poll' })
expect(settled.connected).toBe(false)
expect((settled as { verificationUrl?: string }).verificationUrl).toBeUndefined()
})
it('reports an unknown session key instead of throwing', () => {
expect(pollQqQrLogin({ sessionKey: 'missing' })).toMatchObject({ status: 'not_started' })
})
it('disposes the connector when the attempt is cancelled', async () => {
const controls = await started()
cancelQqQrLogin('poll')
expect(controls.disposed()).toBe(1)
expect(pollQqQrLogin({ sessionKey: 'poll' })).toMatchObject({ status: 'not_started' })
})
})
+72
View File
@@ -0,0 +1,72 @@
/**
* Normalize one inbound QQ Open Platform message.
*
* The SDK already flattens C2C / group / guild events into `InboundMessage`,
* but three things still need deciding before the shared runtime sees it: which
* conversations we accept, what counts as the message text (a voice note only
* carries an ASR transcript), and which attachments are worth downloading.
*/
export type QqInboundAttachment = {
content_type?: string
url?: string
filename?: string
size?: number
asr_refer_text?: string
}
export type QqInboundMessage = {
kind?: string
senderId?: string
senderName?: string
senderIsBot?: boolean
content?: string
messageId?: string
timestamp?: string
groupOpenid?: string
attachments?: QqInboundAttachment[]
}
export type QqInboundPayload = {
chatId: string
userId: string
displayName: string
text: string
dedupKey: string
attachments: QqInboundAttachment[]
}
/**
* Flatten an inbound message, or return null when it must not be routed.
*
* Only C2C (private) chats are accepted. Pairing authorizes a *person*, and a
* group message would hand that person's authorization to every other member
* of the group.
*/
export function extractQqPayload(message: QqInboundMessage | undefined): QqInboundPayload | null {
if (!message) return null
if (message.kind !== 'c2c') return null
if (message.senderIsBot) return null
const userId = message.senderId?.trim()
if (!userId) return null
const attachments = (message.attachments ?? []).filter((item) => Boolean(item?.url))
const transcripts = attachments
.map((item) => item.asr_refer_text?.trim())
.filter((value): value is string => Boolean(value))
const text = [message.content?.trim(), ...transcripts].filter(Boolean).join('\n').trim()
const messageId = message.messageId?.trim()
return {
chatId: userId,
userId,
displayName: message.senderName?.trim() || 'QQ User',
text,
dedupKey: messageId || `${userId}:${message.timestamp ?? ''}:${text.slice(0, 32)}`,
// A voice note is fully represented by its transcript; downloading the
// audio would add an attachment the Agent cannot read.
attachments: attachments.filter((item) => !item.asr_refer_text),
}
}
+277
View File
@@ -0,0 +1,277 @@
/**
* QQ Adapter for Claude Code Desktop
*
* Uses the official QQ Open Platform SDK (`@tencent-connect/qqbot-nodejs`) over
* the WebSocket gateway, so no public callback URL is needed. Credentials
* (`appId` / `appSecret`) come from the QR binding in Desktop Settings → IM,
* or from QQ_APP_ID / QQ_APP_SECRET.
*
* 启动:QQ_APP_ID=xxx QQ_APP_SECRET=yyy bun run qq/index.ts
*/
import { MediaFileType, QQBot, type ReplyTarget, type StreamSession } from '@tencent-connect/qqbot-nodejs'
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 {
ImChatRuntime,
type ChatPort,
type OutboundImage,
type ResponseStream,
} from '../common/chat-runtime.js'
import { extractQqPayload, type QqInboundAttachment } from './extract-payload.js'
import { QqMediaService } from './media.js'
const QQ_TEXT_LIMIT = 1_500
/** QQ throttles stream frames; the SDK enforces a 300 ms floor of its own. */
const QQ_STREAM_THROTTLE_MS = 600
const TYPING_SECONDS = 30
const config = loadConfig()
if (!config.qq.appId || !config.qq.appSecret) {
console.error('[QQ] Missing QQ_APP_ID / QQ_APP_SECRET. Bind QQ in Desktop Settings > IM.')
process.exit(1)
}
const bot = new QQBot({
appId: config.qq.appId,
appSecret: config.qq.appSecret,
accountId: config.qq.appId,
transport: 'websocket',
tokenPrefetch: 'sync',
logger: {
debug: () => {},
info: () => {},
warn: (...args: unknown[]) => console.warn('[QQ][sdk]', ...args),
error: (...args: unknown[]) => console.error('[QQ][sdk]', ...args),
},
})
const bridge = new WsBridge(config.serverUrl, 'qq')
const dedup = new MessageDedup()
const sessionStore = new SessionStore()
const { httpClient, defaultWorkDir } = createAdapterClient(config, config.qq)
const attachmentStore = new AttachmentStore()
const media = new QqMediaService(attachmentStore)
attachmentStore.gc().catch((err) => {
console.warn('[QQ] AttachmentStore.gc failed:', err instanceof Error ? err.message : err)
})
/** Outbound QQ messages must name a peer, and a reply must also name the
* inbound message it answers. Both live in the last target we saw per chat. */
const replyTargets = new Map<string, ReplyTarget>()
function targetFor(chatId: string): ReplyTarget {
return replyTargets.get(chatId) ?? { scope: 'c2c', targetId: chatId }
}
async function sendPlainText(chatId: string, text: string): Promise<void> {
const target = targetFor(chatId)
for (const chunk of splitMessage(text, QQ_TEXT_LIMIT)) {
await bot.sendText(target, chunk)
}
}
/**
* One assistant turn rendered as a QQ C2C stream message.
*
* `stream_messages` is C2C-only and anchored to the inbound message id, so a
* turn with no known reply anchor degrades to ordinary messages instead of
* failing. `update()` takes the *full* text, so this class accumulates.
*/
class QqResponse implements ResponseStream {
private session: StreamSession | null = null
private accumulated = ''
/** How much of `accumulated` the stream bubble already shows. */
private streamed = 0
private failed = false
constructor(private readonly chatId: string) {
const target = targetFor(chatId)
if (target.scope !== 'c2c' || !target.msgId) return
try {
this.session = bot.openStream({ target, throttleMs: QQ_STREAM_THROTTLE_MS })
} catch (err) {
console.warn('[QQ] openStream failed:', err instanceof Error ? err.message : err)
}
}
async append(delta: string): Promise<void> {
if (!delta) return
this.accumulated += delta
if (!this.session || this.failed) return
try {
await this.session.update(this.accumulated)
this.streamed = this.accumulated.length
} catch (err) {
// Fall back to discrete messages for the rest of the turn: retrying a
// broken stream would drop the remaining text entirely.
this.failed = true
console.warn('[QQ] stream update failed:', err instanceof Error ? err.message : err)
}
}
async finish(): Promise<void> {
const session = this.session
this.session = null
if (session && !this.failed) {
try {
await session.complete()
this.accumulated = ''
return
} catch (err) {
console.warn('[QQ] stream complete failed:', err instanceof Error ? err.message : err)
}
}
session?.cancel()
// Only the tail the bubble never showed: re-sending the whole turn would
// repeat everything the user already read above it.
const remainder = this.accumulated.slice(this.streamed).trim()
this.accumulated = ''
if (remainder) await sendPlainText(this.chatId, remainder)
}
}
const port: ChatPort = {
platform: 'qq',
logPrefix: '[QQ]',
async sendNotice(chatId, text) {
await sendPlainText(chatId, text)
},
createResponse(chatId) {
return new QqResponse(chatId)
},
async sendImage(chatId, image: OutboundImage) {
const check = checkAttachmentLimit('image', image.buffer.length, image.mime)
if (!check.ok) {
console.warn('[QQ] Outbound image rejected:', check.hint)
return
}
await bot.sendMedia({
target: targetFor(chatId),
fileType: MediaFileType.IMAGE,
buffer: image.buffer,
content: image.alt,
})
},
setBusy(chatId, busy) {
if (!busy) return
const target = targetFor(chatId)
if (target.scope !== 'c2c') return
// Typing is a courtesy signal; a failure must never surface to the user.
void bot.sendTyping(target, TYPING_SECONDS).catch(() => {})
},
clearChat(chatId) {
replyTargets.delete(chatId)
},
}
const runtime = new ImChatRuntime({
port,
config,
platformConfig: config.qq,
bridge,
sessionStore,
httpClient,
defaultWorkDir,
dedup,
flushIntervalMs: 600,
flushCharThreshold: 200,
})
async function collectAttachments(
chatId: string,
attachments: QqInboundAttachment[],
seed: string,
): Promise<AttachmentRef[]> {
if (attachments.length === 0) return []
const sessionId = sessionStore.get(chatId)?.sessionId ?? chatId
const settled = await Promise.allSettled(
attachments.map((attachment, index) =>
media.downloadAttachment(attachment, sessionId, `${seed}-${index}`),
),
)
const refs: AttachmentRef[] = []
let failures = 0
for (const result of settled) {
if (result.status === 'rejected') {
failures += 1
console.error('[QQ] attachment download failed:', result.reason)
continue
}
const local = result.value
const check = checkAttachmentLimit(local.kind, local.size, local.mimeType)
if (!check.ok) {
await port.sendNotice(chatId, check.hint)
continue
}
refs.push(
local.kind === 'image'
? { type: 'image', name: local.name, data: local.buffer.toString('base64'), mimeType: local.mimeType }
: { type: 'file', name: local.name, path: local.path, mimeType: local.mimeType },
)
}
if (failures > 0) {
await port.sendNotice(
chatId,
failures === attachments.length ? '附件下载失败,请稍后重试。' : `${failures} 个附件下载失败,已跳过。`,
)
}
return refs
}
bot.on('message', async (_ctx, message) => {
const payload = extractQqPayload(message as never)
if (!payload) return
replyTargets.set(payload.chatId, {
scope: 'c2c',
targetId: payload.chatId,
msgId: message.messageId,
})
await runtime.handleInbound({
chatId: payload.chatId,
userId: payload.userId,
displayName: payload.displayName,
dedupKey: payload.dedupKey,
text: payload.text,
hasAttachments: payload.attachments.length > 0,
loadAttachments: () => collectAttachments(
payload.chatId,
payload.attachments,
payload.dedupKey,
),
})
})
bot.on('ready', () => console.log('[QQ] Gateway ready, bot is running!'))
bot.on('resumed', () => console.log('[QQ] Gateway session resumed'))
bot.on('error', (err: Error) => console.error('[QQ] Connection error:', err.message))
console.log('[QQ] Starting adapter...')
console.log(`[QQ] Server: ${config.serverUrl}`)
console.log(`[QQ] App: ${config.qq.appId}`)
void bot.start().catch((err) => {
console.error('[QQ] Failed to start:', err instanceof Error ? err.message : err)
process.exit(1)
})
process.on('SIGINT', () => {
console.log('[QQ] Shutting down...')
try {
bot.stop()
} catch {
// Best-effort: the process is exiting either way.
}
bridge.destroy()
dedup.destroy()
process.exit(0)
})
+94
View File
@@ -0,0 +1,94 @@
/**
* QQ inbound media download.
*
* QQ delivers attachments as CDN URLs that are already signed, and often with
* the scheme omitted (`multimedia.nt.qq.com.cn/...`). This module normalizes
* those URLs, refuses anything that is not a plain HTTPS fetch, and stages the
* bytes under the shared attachment store.
*/
import type { AttachmentStore } from '../common/attachment/attachment-store.js'
import type { LocalAttachment } from '../common/attachment/attachment-types.js'
import {
attachmentKindForMime,
imageExtensionForMime,
inferMimeFromFileName,
} from '../common/attachment/mime.js'
import type { QqInboundAttachment } from './extract-payload.js'
const DOWNLOAD_TIMEOUT_MS = 30_000
/**
* Force a QQ attachment reference onto HTTPS.
*
* Returns null for anything that is not a host/path URL — QQ has no reason to
* hand us a `file:` or credentialed URL, and fetching one would be an SSRF
* primitive driven by remote input.
*/
export function normalizeQqAttachmentUrl(raw: string | undefined): URL | null {
const value = raw?.trim()
if (!value) return null
const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `https://${value}`
let url: URL
try {
url = new URL(candidate)
} catch {
return null
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
if (url.username || url.password) return null
// QQ CDN serves plain HTTP for some resources; upgrade rather than refuse.
url.protocol = 'https:'
return url
}
function fileNameFor(attachment: QqInboundAttachment, mime: string | undefined, seed: string): string {
const provided = attachment.filename?.trim()
if (provided) return provided
if (mime?.startsWith('image/')) return `qq-image-${seed}.${imageExtensionForMime(mime)}`
return `qq-file-${seed}`
}
export class QqMediaService {
constructor(
private readonly store: AttachmentStore,
private readonly fetchImpl: typeof fetch = fetch,
) {}
async downloadAttachment(
attachment: QqInboundAttachment,
sessionId: string,
seed: string,
): Promise<LocalAttachment> {
const url = normalizeQqAttachmentUrl(attachment.url)
if (!url) throw new Error('QQ attachment has no usable download URL')
const response = await this.fetchImpl(url, {
redirect: 'follow',
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
})
if (!response.ok) {
throw new Error(`QQ attachment download failed: ${response.status} ${response.statusText}`)
}
const buffer = Buffer.from(await response.arrayBuffer())
const headerMime = response.headers.get('content-type')?.split(';')[0]?.trim()
const mimeType = attachment.content_type?.split(';')[0]?.trim()
|| headerMime
|| inferMimeFromFileName(attachment.filename)
|| 'application/octet-stream'
const name = fileNameFor(attachment, mimeType, seed)
const target = this.store.resolvePath('qq', sessionId, name)
const path = await this.store.write(target, buffer)
return {
kind: attachmentKindForMime(mimeType),
name,
path,
buffer,
size: buffer.length,
mimeType,
}
}
}
+253
View File
@@ -0,0 +1,253 @@
/**
* QQ Open Platform QR provisioning.
*
* `@tencent-connect/qqbot-connector` drives the scan flow with callbacks and a
* dispose function; the desktop settings page needs a start/poll pair it can
* drive over HTTP. This module owns that adaptation: one attempt per session
* key, terminal states latched so a slow poll still observes the result, and
* QR refreshes surfaced so the UI can redraw the image.
*/
import * as crypto from 'node:crypto'
export type QqQrCredentials = { appId: string; appSecret: string; userOpenid?: string }
type QrConnectCallbacks = {
onSuccess: (credentials: QqQrCredentials[]) => void
onFailure: (error: Error) => void
onQrDisplayed?: (url: string) => void
onQrExpired?: () => void
}
type QrConnectOptions = {
displayQrCodeToConsole?: boolean
signal?: AbortSignal
source?: string
}
export type StartQrConnect = (
callbacks: QrConnectCallbacks,
options?: QrConnectOptions,
) => () => void
export type QqQrStartResult = {
sessionKey: string
verificationUrl: string
pollIntervalMs: number
message: string
}
export type QqQrPollResult =
| { connected: true; appId: string; appSecret: string; userOpenid?: string }
| {
connected: false
status: 'waiting' | 'expired' | 'failed' | 'not_started'
message: string
/** Present when the connector rotated the code and the UI must redraw. */
verificationUrl?: string
}
const DEFAULT_SOURCE = 'claude-code-haha'
const QR_READY_TIMEOUT_MS = 20_000
const SESSION_TTL_MS = 10 * 60_000
export const QQ_POLL_INTERVAL_MS = 2_000
type QqLoginSession = {
sessionKey: string
verificationUrl: string
/** Bumped on every QR rotation so a poll can tell the UI to redraw. */
urlVersion: number
deliveredUrlVersion: number
status: 'waiting' | 'success' | 'expired' | 'failed'
credentials?: QqQrCredentials
failureMessage?: string
dispose: () => void
startedAt: number
}
const activeLogins = new Map<string, QqLoginSession>()
let startQrConnectImpl: StartQrConnect | null = null
/** Test seam: inject a fake connector instead of loading the real SDK. */
export function setQqQrConnector(impl: StartQrConnect | null): void {
startQrConnectImpl = impl
}
async function resolveConnector(): Promise<StartQrConnect> {
if (startQrConnectImpl) return startQrConnectImpl
const mod = await import('@tencent-connect/qqbot-connector')
return mod.startQrConnect as unknown as StartQrConnect
}
function purgeExpiredLogins(): void {
const now = Date.now()
for (const [key, session] of activeLogins) {
if (now - session.startedAt <= SESSION_TTL_MS) continue
activeLogins.delete(key)
safeDispose(session)
}
}
function safeDispose(session: QqLoginSession): void {
try {
session.dispose()
} catch {
// The connector is already finished in the common case.
}
}
export async function startQqQrLogin(
opts: { sessionKey?: string; source?: string } = {},
): Promise<QqQrStartResult> {
purgeExpiredLogins()
const sessionKey = opts.sessionKey || crypto.randomUUID()
// Only one scan can be in flight: the settings page shows one QR at a time,
// and every start mints a fresh key. Without this, closing the page without
// cancelling leaves a connector polling the vendor until its TTL, and each
// retry stacks another one on top.
for (const [key, session] of activeLogins) {
activeLogins.delete(key)
safeDispose(session)
}
const startQrConnect = await resolveConnector()
// The session record exists before the connector runs: a user who scans
// faster than the first `await` still lands their result somewhere the poll
// can read it, instead of into a `null` that silently drops the credentials.
const session: QqLoginSession = {
sessionKey,
verificationUrl: '',
urlVersion: 0,
deliveredUrlVersion: 0,
status: 'waiting',
dispose: () => {},
startedAt: Date.now(),
}
let resolveReady: ((url: string) => void) | null = null
let rejectReady: ((error: Error) => void) | null = null
const ready = new Promise<string>((resolve, reject) => {
resolveReady = resolve
rejectReady = reject
})
const dispose = startQrConnect(
{
onQrDisplayed(url) {
session.verificationUrl = url
session.urlVersion += 1
resolveReady?.(url)
},
onSuccess(credentials) {
const first = credentials?.[0]
if (!first?.appId || !first?.appSecret) {
session.status = 'failed'
session.failureMessage = 'QQ 返回的机器人凭据不完整,请重新扫码。'
return
}
session.status = 'success'
session.credentials = first
},
onFailure(error) {
const message = error?.message ?? 'QQ 扫码授权失败。'
// The connector reports expiry through onFailure once it stops retrying.
session.status = /expire|timeout|过期/i.test(message) ? 'expired' : 'failed'
session.failureMessage = message
rejectReady?.(error instanceof Error ? error : new Error(message))
},
},
{ displayQrCodeToConsole: false, source: opts.source || DEFAULT_SOURCE },
)
session.dispose = dispose
let verificationUrl: string
let readyTimer: ReturnType<typeof setTimeout> | undefined
try {
verificationUrl = await Promise.race([
ready,
new Promise<string>((_, reject) => {
readyTimer = setTimeout(
() => reject(new Error('QQ 二维码获取超时,请重试。')),
QR_READY_TIMEOUT_MS,
)
}),
])
} catch (err) {
safeDispose(session)
throw err
} finally {
// Without this the timer holds the event loop for its full duration after
// every successful start.
if (readyTimer) clearTimeout(readyTimer)
}
session.deliveredUrlVersion = session.urlVersion
activeLogins.set(sessionKey, session)
return {
sessionKey,
verificationUrl,
pollIntervalMs: QQ_POLL_INTERVAL_MS,
message: '使用 QQ 扫描二维码,创建并授权机器人。',
}
}
export function pollQqQrLogin(opts: { sessionKey: string }): QqQrPollResult {
purgeExpiredLogins()
const session = activeLogins.get(opts.sessionKey)
if (!session) {
return {
connected: false,
status: 'not_started',
message: '当前没有进行中的 QQ 绑定,请重新生成二维码。',
}
}
if (session.status === 'success' && session.credentials) {
activeLogins.delete(opts.sessionKey)
safeDispose(session)
return {
connected: true,
appId: session.credentials.appId,
appSecret: session.credentials.appSecret,
userOpenid: session.credentials.userOpenid,
}
}
if (session.status === 'expired' || session.status === 'failed') {
activeLogins.delete(opts.sessionKey)
safeDispose(session)
return {
connected: false,
status: session.status,
message: session.failureMessage
?? (session.status === 'expired' ? '二维码已过期,请重新生成。' : 'QQ 授权失败,请重新扫码。'),
}
}
const rotated = session.urlVersion !== session.deliveredUrlVersion
session.deliveredUrlVersion = session.urlVersion
return {
connected: false,
status: 'waiting',
message: '等待 QQ 扫码确认...',
verificationUrl: rotated ? session.verificationUrl : undefined,
}
}
export function cancelQqQrLogin(sessionKey: string): void {
const session = activeLogins.get(sessionKey)
if (!session) return
activeLogins.delete(sessionKey)
safeDispose(session)
}
/** Test seam: the module-level session map must not leak across test cases. */
export function resetQqQrLoginsForTest(): void {
for (const session of activeLogins.values()) safeDispose(session)
activeLogins.clear()
}
+198
View File
@@ -0,0 +1,198 @@
import { describe, expect, it } from 'bun:test'
import {
assertSlackFileUrl,
assertSlackUploadUrl,
SlackApiClient,
SlackApiError,
} from '../api.js'
type Recorded = {
url: string
method: string
authorization?: string
body?: string
}
function fakeFetch(responses: Array<{ status?: number; body: unknown }>, calls: Recorded[] = []) {
let index = 0
const impl = (async (input: string | URL | Request, init?: RequestInit) => {
const headers = new Headers(init?.headers as HeadersInit | undefined)
calls.push({
url: String(input),
method: init?.method ?? 'GET',
authorization: headers.get('authorization') ?? undefined,
body: typeof init?.body === 'string' ? init.body : undefined,
})
const response = responses[Math.min(index, responses.length - 1)]!
index += 1
const payload = response.body
return new Response(
typeof payload === 'string' ? payload : JSON.stringify(payload),
{ status: response.status ?? 200, headers: { 'content-type': 'application/json' } },
)
}) as unknown as typeof fetch
return { impl, calls }
}
describe('SlackApiClient.call', () => {
it('sends the bot token and returns the parsed body', async () => {
const { impl, calls } = fakeFetch([{ body: { ok: true, ts: '1.1' } }])
const client = new SlackApiClient('xoxb-test', impl)
const ts = await client.postMessage('D1', 'hi')
expect(ts).toBe('1.1')
expect(calls[0]!.url).toBe('https://slack.com/api/chat.postMessage')
expect(calls[0]!.authorization).toBe('Bearer xoxb-test')
expect(JSON.parse(calls[0]!.body!)).toEqual({ channel: 'D1', text: 'hi' })
})
// Slack answers HTTP 200 with `ok: false`; treating that as success is the
// classic way an integration silently stops delivering.
it('throws on an ok:false body even though the HTTP status is 200', async () => {
const { impl } = fakeFetch([{ body: { ok: false, error: 'not_in_channel' } }])
const client = new SlackApiClient('xoxb-test', impl)
await expect(client.postMessage('D1', 'hi')).rejects.toThrow(SlackApiError)
await expect(client.postMessage('D1', 'hi')).rejects.toThrow(/not_in_channel/)
})
it('throws when the response is not JSON at all', async () => {
const { impl } = fakeFetch([{ status: 502, body: 'gateway down' }])
const client = new SlackApiClient('xoxb-test', impl)
await expect(client.postMessage('D1', 'hi')).rejects.toThrow(/http_502/)
})
it('reports a post that returns no timestamp instead of pretending it worked', async () => {
const { impl } = fakeFetch([{ body: { ok: true } }])
const client = new SlackApiClient('xoxb-test', impl)
await expect(client.postMessage('D1', 'hi')).rejects.toThrow(/missing_ts/)
})
it('threads a reply when a thread timestamp is supplied', async () => {
const { impl, calls } = fakeFetch([{ body: { ok: true, ts: '2.2' } }])
const client = new SlackApiClient('xoxb-test', impl)
await client.postMessage('D1', 'hi', '1.0')
expect(JSON.parse(calls[0]!.body!)).toMatchObject({ thread_ts: '1.0' })
})
})
describe('SlackApiClient.openSocketConnection', () => {
it('uses the app-level token, not the bot token', async () => {
const { impl, calls } = fakeFetch([{ body: { ok: true, url: 'wss://wss.slack.com/link/1' } }])
const client = new SlackApiClient('xoxb-test', impl)
const url = await client.openSocketConnection('xapp-test')
expect(url).toBe('wss://wss.slack.com/link/1')
expect(calls[0]!.authorization).toBe('Bearer xapp-test')
})
it('refuses a socket URL that is not wss', async () => {
const { impl } = fakeFetch([{ body: { ok: true, url: 'ws://wss.slack.com/link/1' } }])
const client = new SlackApiClient('xoxb-test', impl)
await expect(client.openSocketConnection('xapp-test')).rejects.toThrow(/insecure/)
})
})
describe('Slack file URL pinning', () => {
// The bot token travels on the download, and the URL arrives inside an event
// payload — so an unpinned host is a credentialed request anyone in the
// workspace could steer.
it.each([
['http://files.slack.com/files-pri/a', 'plain http'],
['https://evil.example.com/files-pri/a', 'foreign host'],
['https://user:pw@files.slack.com/files-pri/a', 'embedded credentials'],
['https://files.slack.com:8443/files-pri/a', 'non-default port'],
])('rejects a download URL: %s (%s)', (value) => {
expect(() => assertSlackFileUrl(value)).toThrow()
})
it('accepts a genuine Slack file URL and drops its fragment', () => {
expect(assertSlackFileUrl('https://files.slack.com/files-pri/T1-F1/a.png#frag').toString())
.toBe('https://files.slack.com/files-pri/T1-F1/a.png')
})
it('rejects a missing download URL', () => {
expect(() => assertSlackFileUrl(undefined)).toThrow(/no download URL/)
})
it('pins the upload URL to the same host', () => {
expect(() => assertSlackUploadUrl('https://evil.example.com/upload/x')).toThrow(/unsafe/)
expect(assertSlackUploadUrl('https://files.slack.com/upload/x').hostname).toBe('files.slack.com')
})
})
describe('SlackApiClient.uploadFile', () => {
it('reserves a URL, PUTs the bytes and then publishes the file', async () => {
const { impl, calls } = fakeFetch(
[
{ body: { ok: true, upload_url: 'https://files.slack.com/upload/abc', file_id: 'F1' } },
{ body: { ok: true } },
{ body: { ok: true } },
],
)
const client = new SlackApiClient('xoxb-test', impl)
await client.uploadFile({
channel: 'D1',
buffer: Buffer.from('bytes'),
filename: 'a.png',
title: 'alt text',
})
expect(calls[0]!.url).toContain('files.getUploadURLExternal')
expect(calls[0]!.url).toContain('length=5')
expect(calls[1]!.url).toBe('https://files.slack.com/upload/abc')
expect(calls[2]!.url).toContain('files.completeUploadExternal')
expect(JSON.parse(calls[2]!.body!)).toMatchObject({
channel_id: 'D1',
files: [{ id: 'F1', title: 'alt text' }],
})
})
it('refuses to PUT bytes to an upload URL Slack did not vouch for', async () => {
const { impl } = fakeFetch([
{ body: { ok: true, upload_url: 'https://evil.example.com/upload/abc', file_id: 'F1' } },
])
const client = new SlackApiClient('xoxb-test', impl)
await expect(
client.uploadFile({ channel: 'D1', buffer: Buffer.from('x'), filename: 'a.png' }),
).rejects.toThrow(/unsafe/)
})
})
describe('SlackApiClient.downloadFile', () => {
it('attaches the bot token to the file request', async () => {
const calls: Recorded[] = []
const impl = (async (input: string | URL | Request, init?: RequestInit) => {
const headers = new Headers(init?.headers as HeadersInit | undefined)
calls.push({
url: String(input),
method: init?.method ?? 'GET',
authorization: headers.get('authorization') ?? undefined,
})
return new Response('file-bytes', { status: 200 })
}) as unknown as typeof fetch
const client = new SlackApiClient('xoxb-test', impl)
const buffer = await client.downloadFile('https://files.slack.com/files-pri/T1-F1/a.png')
expect(buffer.toString('utf8')).toBe('file-bytes')
expect(calls[0]!.authorization).toBe('Bearer xoxb-test')
})
it('surfaces a failed download instead of returning empty bytes', async () => {
const impl = (async () => new Response('nope', { status: 404 })) as unknown as typeof fetch
const client = new SlackApiClient('xoxb-test', impl)
await expect(client.downloadFile('https://files.slack.com/files-pri/T1-F1/a.png'))
.rejects.toThrow(/404/)
})
})
@@ -0,0 +1,128 @@
import { describe, expect, it } from 'bun:test'
import { extractSlackPayload } from '../extract-payload.js'
const dm = {
type: 'message',
channel: 'D123',
channel_type: 'im',
user: 'U123',
text: 'hello',
ts: '1700000000.000100',
client_msg_id: 'cmid-1',
}
describe('extractSlackPayload', () => {
it('flattens a direct message', () => {
expect(extractSlackPayload(dm)).toEqual({
chatId: 'D123',
userId: 'U123',
text: 'hello',
dedupKey: 'cmid-1',
threadTs: undefined,
files: [],
})
})
// Slack pushes edits, deletions, joins and the bot's own replies down the
// same socket; answering any of them is at best noise and at worst a loop.
// `file_share` is deliberately absent here — it carries a real user message.
it.each([
[{ subtype: 'message_changed' }, 'an edit'],
[{ subtype: 'message_deleted' }, 'a deletion'],
[{ subtype: 'channel_join' }, 'a join'],
[{ hidden: true }, 'a hidden event'],
[{ bot_id: 'B999' }, 'a bot message'],
[{ type: 'app_mention' }, 'a non-message event'],
])('drops %o (%s)', (overrides) => {
expect(extractSlackPayload({ ...dm, ...overrides })).toBeNull()
})
it('drops its own user id even when Slack omits bot_id', () => {
expect(extractSlackPayload(dm, { botUserId: 'U123' })).toBeNull()
expect(extractSlackPayload(dm, { botUserId: 'U999' })).not.toBeNull()
})
// Pairing authorizes one person; a channel would extend that authorization
// to everyone else in it.
it.each([['channel'], ['group'], ['mpim']])('drops a %s message', (channelType) => {
expect(extractSlackPayload({ ...dm, channel_type: channelType })).toBeNull()
})
// Slack escapes these three characters in every message. Leaving them encoded
// corrupts shell commands, comparisons, JSX and generics for a coding agent.
it('decodes the HTML entities Slack escapes', () => {
const payload = extractSlackPayload({
...dm,
text: 'run `a &amp;&amp; b` when x &lt; y &gt; z',
})
expect(payload!.text).toBe('run `a && b` when x < y > z')
})
it('does not double-decode an escaped entity', () => {
expect(extractSlackPayload({ ...dm, text: '&amp;lt;not-a-tag&amp;gt;' })!.text)
.toBe('&lt;not-a-tag&gt;')
})
it('strips Slack markup so the Agent sees plain text', () => {
const payload = extractSlackPayload({
...dm,
text: '<@U999> check <https://example.com|the docs> in <#C1|general>',
})
expect(payload!.text).toBe('check the docs in #general')
})
it('keeps a bare link readable', () => {
expect(extractSlackPayload({ ...dm, text: 'see <https://example.com/x>' })!.text)
.toBe('see https://example.com/x')
})
// Slack delivers a DM with an attachment as subtype `file_share`. Treating
// every subtype as noise dropped the file *and* the caption typed with it,
// so the bot never answered a message containing a screenshot at all.
it('accepts a file upload, which Slack sends as subtype file_share', () => {
const payload = extractSlackPayload({
...dm,
subtype: 'file_share',
text: 'have a look',
files: [{ id: 'F1', name: 'a.png', url_private_download: 'https://files.slack.com/x' }],
})
expect(payload).not.toBeNull()
expect(payload!.text).toBe('have a look')
expect(payload!.files).toHaveLength(1)
})
it('accepts a file-only message with no caption', () => {
const payload = extractSlackPayload({
...dm,
subtype: 'file_share',
text: '',
files: [{ id: 'F1', name: 'a.png', url_private_download: 'https://files.slack.com/x' }],
})
expect(payload!.text).toBe('')
expect(payload!.files).toHaveLength(1)
})
it('drops a file with no downloadable URL', () => {
const payload = extractSlackPayload({ ...dm, files: [{ id: 'F1', name: 'a.png' }] })
expect(payload!.files).toEqual([])
})
it('drops a message that carries neither text nor a usable file', () => {
expect(extractSlackPayload({ ...dm, text: ' ', files: [{ id: 'F1' }] })).toBeNull()
})
it('falls back to channel and timestamp when client_msg_id is absent', () => {
expect(extractSlackPayload({ ...dm, client_msg_id: undefined })!.dedupKey)
.toBe('D123:1700000000.000100')
})
it('carries the thread timestamp through', () => {
expect(extractSlackPayload({ ...dm, thread_ts: '1699999999.000100' })!.threadTs)
.toBe('1699999999.000100')
})
})
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'bun:test'
import {
buildSlackAppManifest,
buildSlackCreateAppUrl,
buildSlackManifestJson,
SLACK_BOT_SCOPES,
} from '../manifest.js'
describe('buildSlackAppManifest', () => {
// Socket Mode is what removes the need for a public request URL; without it
// a desktop-local bot cannot receive anything at all.
it('enables Socket Mode and subscribes to direct messages', () => {
const manifest = buildSlackAppManifest() as any
expect(manifest.settings.socket_mode_enabled).toBe(true)
expect(manifest.settings.event_subscriptions.bot_events).toEqual(['message.im'])
})
it('requests exactly the scopes the adapter calls', () => {
const manifest = buildSlackAppManifest() as any
expect(manifest.oauth_config.scopes.bot).toEqual([...SLACK_BOT_SCOPES])
expect(manifest.oauth_config.scopes.bot).toEqual([
'chat:write',
'im:history',
'files:write',
'files:read',
'users:read',
])
})
it('enables the messages tab so a user can DM the bot at all', () => {
const manifest = buildSlackAppManifest() as any
expect(manifest.features.app_home.messages_tab_enabled).toBe(true)
expect(manifest.features.app_home.messages_tab_read_only_enabled).toBe(false)
})
it('uses the caller-supplied app name in both places Slack shows it', () => {
const manifest = buildSlackAppManifest('My Bot') as any
expect(manifest.display_information.name).toBe('My Bot')
expect(manifest.features.bot_user.display_name).toBe('My Bot')
})
})
describe('buildSlackCreateAppUrl', () => {
it('opens the create-app dialog with the manifest pre-filled', () => {
const url = new URL(buildSlackCreateAppUrl())
expect(url.origin + url.pathname).toBe('https://api.slack.com/apps')
expect(url.searchParams.get('new_app')).toBe('1')
expect(JSON.parse(url.searchParams.get('manifest_json')!)).toEqual(buildSlackAppManifest())
})
})
describe('buildSlackManifestJson', () => {
it('is valid JSON a user can paste into Slack by hand', () => {
expect(JSON.parse(buildSlackManifestJson())).toEqual(buildSlackAppManifest())
})
})
@@ -0,0 +1,163 @@
import { afterEach, describe, expect, it } from 'bun:test'
import { EventEmitter } from 'node:events'
import type WebSocket from 'ws'
import { SlackSocketMode, type SocketEnvelope } from '../socket-mode.js'
class FakeSocket extends EventEmitter {
readyState = 1 // WebSocket.OPEN
sent: string[] = []
closedWith: Array<[number, string]> = []
send(payload: string): void {
this.sent.push(payload)
}
close(code: number, reason: string): void {
this.closedWith.push([code, reason])
this.readyState = 3
this.emit('close')
}
deliver(envelope: SocketEnvelope): void {
this.emit('message', Buffer.from(JSON.stringify(envelope)))
}
}
const sockets: FakeSocket[] = []
let started: SlackSocketMode | null = null
function createSocketMode(options: {
onEnvelope?: (envelope: SocketEnvelope) => void
openConnection?: () => Promise<string>
} = {}) {
const received: SocketEnvelope[] = []
const opened: string[] = []
const mode = new SlackSocketMode({
openConnection: options.openConnection
?? (async () => {
opened.push('wss://wss.slack.com/link')
return 'wss://wss.slack.com/link'
}),
onEnvelope: options.onEnvelope ?? ((envelope) => received.push(envelope)),
createWebSocket: () => {
const socket = new FakeSocket()
sockets.push(socket)
return socket as unknown as WebSocket
},
logPrefix: '[Test]',
})
started = mode
return { mode, received, opened }
}
afterEach(() => {
started?.stop()
started = null
sockets.length = 0
})
describe('SlackSocketMode', () => {
it('acknowledges an envelope before handing it to the handler', async () => {
const order: string[] = []
const { mode } = createSocketMode({
onEnvelope: () => order.push('handled'),
})
await mode.start()
const socket = sockets[0]!
socket.send = (payload: string) => {
order.push('acked')
socket.sent.push(payload)
}
socket.deliver({ type: 'events_api', envelope_id: 'env-1', payload: {} })
// Slack redelivers anything unacked within three seconds, and handling can
// take far longer than that — so the ack must not wait on the handler.
expect(order).toEqual(['acked', 'handled'])
expect(JSON.parse(socket.sent[0]!)).toEqual({ envelope_id: 'env-1' })
})
it('passes an events_api envelope to the handler', async () => {
const { mode, received } = createSocketMode()
await mode.start()
sockets[0]!.deliver({
type: 'events_api',
envelope_id: 'env-1',
payload: { event: { type: 'message', text: 'hi' } },
})
expect(received).toHaveLength(1)
expect(received[0]!.payload?.event).toMatchObject({ text: 'hi' })
})
it('swallows the hello frame instead of routing it as an event', async () => {
const { mode, received } = createSocketMode()
await mode.start()
sockets[0]!.deliver({ type: 'hello' })
expect(received).toHaveLength(0)
})
it('reconnects with a freshly opened URL when Slack asks it to', async () => {
let opens = 0
const { mode } = createSocketMode({
openConnection: async () => {
opens += 1
return 'wss://wss.slack.com/link'
},
})
await mode.start()
expect(opens).toBe(1)
sockets[0]!.deliver({ type: 'disconnect', envelope_id: 'env-2', reason: 'refresh_requested' })
await new Promise((resolve) => setTimeout(resolve, 1_200))
expect(sockets[0]!.closedWith[0]![1]).toBe('slack requested reconnect')
expect(opens).toBe(2)
})
it('does not reconnect after stop()', async () => {
let opens = 0
const { mode } = createSocketMode({
openConnection: async () => {
opens += 1
return 'wss://wss.slack.com/link'
},
})
await mode.start()
mode.stop()
await new Promise((resolve) => setTimeout(resolve, 1_200))
expect(opens).toBe(1)
})
it('survives a malformed frame without dropping the connection', async () => {
const { mode, received } = createSocketMode()
await mode.start()
sockets[0]!.emit('message', Buffer.from('not json'))
sockets[0]!.deliver({ type: 'events_api', envelope_id: 'env-3', payload: {} })
expect(received).toHaveLength(1)
})
it('schedules a reconnect when opening the connection fails', async () => {
let attempts = 0
const { mode } = createSocketMode({
openConnection: async () => {
attempts += 1
if (attempts === 1) throw new Error('rate limited')
return 'wss://wss.slack.com/link'
},
})
await mode.start()
expect(sockets).toHaveLength(0)
await new Promise((resolve) => setTimeout(resolve, 1_200))
expect(attempts).toBe(2)
expect(sockets).toHaveLength(1)
})
})
+203
View File
@@ -0,0 +1,203 @@
/**
* Minimal Slack Web API client.
*
* Only the calls this adapter needs: posting and editing messages, opening a
* Socket Mode connection, and the three-step external file upload. Slack's
* official SDK would pull in a large dependency tree for that surface, and the
* error handling we care about (`ok: false` plus an `error` code) is uniform
* across every endpoint.
*/
const SLACK_API_BASE = 'https://slack.com/api/'
const SLACK_FILE_HOST = 'files.slack.com'
const REQUEST_TIMEOUT_MS = 30_000
const UPLOAD_TIMEOUT_MS = 120_000
export class SlackApiError extends Error {
constructor(
readonly method: string,
readonly code: string,
readonly status?: number,
) {
super(`Slack ${method} failed: ${code}`)
this.name = 'SlackApiError'
}
}
type SlackResponse = { ok?: boolean; error?: string; [key: string]: unknown }
/**
* Slack serves private files from `files.slack.com` only.
*
* The URL arrives inside an event payload, so treating it as a fetchable
* address without pinning the host would let anyone in the workspace steer an
* authenticated request — the bot token travels on this call.
*/
export function assertSlackFileUrl(value: string | undefined): URL {
if (!value) throw new Error('Slack file has no download URL')
const url = new URL(value)
const safePort = !url.port || url.port === '443'
if (
url.protocol !== 'https:'
|| url.hostname !== SLACK_FILE_HOST
|| !safePort
|| url.username
|| url.password
) {
throw new Error('Slack file URL is not hosted by Slack')
}
url.hash = ''
return url
}
/** Slack returns the external upload target on its own file host. */
export function assertSlackUploadUrl(value: string | undefined): URL {
if (!value) throw new Error('Slack returned no upload URL')
const url = new URL(value)
const safePort = !url.port || url.port === '443'
if (
url.protocol !== 'https:'
|| url.hostname !== SLACK_FILE_HOST
|| !safePort
|| url.username
|| url.password
) {
throw new Error('Slack returned an unsafe upload URL')
}
url.hash = ''
return url
}
export class SlackApiClient {
constructor(
private readonly botToken: string,
private readonly fetchImpl: typeof fetch = fetch,
private readonly baseUrl: string = SLACK_API_BASE,
) {}
/** POST a JSON body to a Web API method. */
async call<T extends SlackResponse>(
method: string,
body: Record<string, unknown>,
token = this.botToken,
): Promise<T> {
const response = await this.fetchImpl(new URL(method, this.baseUrl), {
method: 'POST',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json; charset=utf-8',
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
const data = (await response.json().catch(() => null)) as T | null
if (!data || data.ok !== true) {
throw new SlackApiError(method, data?.error ?? `http_${response.status}`, response.status)
}
return data
}
/** GET a Web API method with query parameters. */
async get<T extends SlackResponse>(
method: string,
query: Record<string, string | number>,
): Promise<T> {
const url = new URL(method, this.baseUrl)
for (const [key, value] of Object.entries(query)) {
url.searchParams.set(key, String(value))
}
const response = await this.fetchImpl(url, {
method: 'GET',
headers: { authorization: `Bearer ${this.botToken}` },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
const data = (await response.json().catch(() => null)) as T | null
if (!data || data.ok !== true) {
throw new SlackApiError(method, data?.error ?? `http_${response.status}`, response.status)
}
return data
}
async postMessage(channel: string, text: string, threadTs?: string): Promise<string> {
const body: Record<string, unknown> = { channel, text }
if (threadTs) body.thread_ts = threadTs
const data = await this.call<{ ts?: string }>('chat.postMessage', body)
if (!data.ts) throw new SlackApiError('chat.postMessage', 'missing_ts')
return data.ts
}
async updateMessage(channel: string, ts: string, text: string): Promise<void> {
await this.call('chat.update', { channel, ts, text })
}
/** Identity of the bot user, used to ignore the bot's own messages. */
async authTest(): Promise<{ userId: string; teamId?: string; botId?: string }> {
const data = await this.call<{ user_id?: string; team_id?: string; bot_id?: string }>(
'auth.test',
{},
)
return { userId: data.user_id ?? '', teamId: data.team_id, botId: data.bot_id }
}
/** Open a Socket Mode WebSocket URL using the app-level token. */
async openSocketConnection(appToken: string): Promise<string> {
const data = await this.call<{ url?: string }>('apps.connections.open', {}, appToken)
const raw = data.url
if (!raw) throw new SlackApiError('apps.connections.open', 'missing_url')
const url = new URL(raw)
if (url.protocol !== 'wss:') {
throw new SlackApiError('apps.connections.open', 'insecure_socket_url')
}
return url.toString()
}
/** Download a private file with the bot token attached. */
async downloadFile(urlPrivateDownload: string | undefined): Promise<Buffer> {
const url = assertSlackFileUrl(urlPrivateDownload)
const response = await this.fetchImpl(url, {
headers: { authorization: `Bearer ${this.botToken}` },
redirect: 'follow',
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
})
if (!response.ok) {
throw new Error(`Slack file download failed: ${response.status} ${response.statusText}`)
}
return Buffer.from(await response.arrayBuffer())
}
/**
* Slack's three-step external upload: reserve a URL, PUT the bytes, then
* publish the file into a channel.
*/
async uploadFile(options: {
channel: string
buffer: Buffer
filename: string
title?: string
threadTs?: string
}): Promise<void> {
const reserved = await this.get<{ upload_url?: string; file_id?: string }>(
'files.getUploadURLExternal',
{ filename: options.filename, length: options.buffer.length },
)
const uploadUrl = assertSlackUploadUrl(reserved.upload_url)
const fileId = reserved.file_id
if (!fileId) throw new SlackApiError('files.getUploadURLExternal', 'missing_file_id')
const upload = await this.fetchImpl(uploadUrl, {
method: 'POST',
body: new Uint8Array(options.buffer),
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
})
if (!upload.ok) {
throw new Error(`Slack file upload failed: ${upload.status} ${upload.statusText}`)
}
const complete: Record<string, unknown> = {
files: [{ id: fileId, title: options.title ?? options.filename }],
channel_id: options.channel,
}
if (options.threadTs) complete.thread_ts = options.threadTs
await this.call('files.completeUploadExternal', complete)
}
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Normalize one inbound Slack event.
*
* Slack pushes far more than user messages down the same socket: edits,
* deletions, joins, the bot's own replies, and channel traffic. Everything the
* adapter must *not* answer is filtered here, in one testable place.
*/
export type SlackFile = {
id?: string
name?: string
title?: string
mimetype?: string
size?: number
url_private_download?: string
url_private?: string
}
export type SlackEvent = {
type?: string
subtype?: string
channel?: string
channel_type?: string
user?: string
bot_id?: string
app_id?: string
text?: string
ts?: string
thread_ts?: string
client_msg_id?: string
files?: SlackFile[]
hidden?: boolean
}
export type SlackInboundPayload = {
chatId: string
userId: string
text: string
dedupKey: string
threadTs?: string
files: SlackFile[]
}
/**
* Slack escapes exactly three characters in message text, and nothing else.
*
* Skipping this corrupts every message containing them — for a coding agent
* that means `a && b` arriving as `a &amp;&amp; b`, and any `<`/`>` in a shell
* redirect, comparison, JSX tag or generic silently mangled.
*/
function decodeSlackEntities(text: string): string {
return text
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
// `&amp;` last: decoding it first would turn `&amp;lt;` into `<`.
.replaceAll('&amp;', '&')
}
/** `<@U123>` and `<#C123|name>` are Slack's wire format for mentions. */
function stripSlackMarkup(text: string): string {
return decodeSlackEntities(
text
.replace(/<@([A-Z0-9]+)(\|[^>]*)?>/g, '')
.replace(/<#([A-Z0-9]+)\|([^>]*)>/g, '#$2')
.replace(/<(https?:\/\/[^|>]+)\|([^>]*)>/g, '$2')
.replace(/<(https?:\/\/[^|>]+)>/g, '$1'),
).trim()
}
/**
* Subtypes that still carry a real user message.
*
* Every other subtype is an edit, a deletion, a join or another non-message
* event. `file_share` is the exception that matters: Slack delivers a DM with
* an attachment as a subtyped message, so treating all subtypes as noise drops
* the attachment *and* the caption the user typed with it.
*/
const USER_MESSAGE_SUBTYPES = new Set(['file_share'])
export type ExtractSlackOptions = {
/** The bot's own user id, so its replies are never treated as input. */
botUserId?: string
}
export function extractSlackPayload(
event: SlackEvent | undefined,
options: ExtractSlackOptions = {},
): SlackInboundPayload | null {
if (!event) return null
if (event.type !== 'message') return null
if (event.subtype && !USER_MESSAGE_SUBTYPES.has(event.subtype)) return null
if (event.hidden) return null
// Bot messages include our own replies; answering them is an infinite loop.
if (event.bot_id) return null
// Direct messages only: pairing authorizes one person, and a channel would
// extend that person's authorization to everyone else in it.
if (event.channel_type !== 'im') return null
const chatId = event.channel?.trim()
const userId = event.user?.trim()
if (!chatId || !userId) return null
if (options.botUserId && userId === options.botUserId) return null
const files = (event.files ?? []).filter((file) => Boolean(file?.url_private_download || file?.url_private))
const text = stripSlackMarkup(event.text ?? '')
if (!text && files.length === 0) return null
return {
chatId,
userId,
text,
dedupKey: event.client_msg_id?.trim() || `${chatId}:${event.ts ?? ''}`,
threadTs: event.thread_ts,
files,
}
}
+292
View File
@@ -0,0 +1,292 @@
/**
* 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)
void (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 (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)
}
await socket.start()
})()
process.on('SIGINT', () => {
console.log('[Slack] Shutting down...')
socket.stop()
bridge.destroy()
dedup.destroy()
process.exit(0)
})
+86
View File
@@ -0,0 +1,86 @@
/**
* Slack app manifest for Claude Code Desktop.
*
* Slack has no scan-to-create flow, so its closest equivalent is a manifest:
* the user opens Slack's "create an app" page with the whole configuration
* pre-filled — Socket Mode on, DM events subscribed, exactly the scopes this
* adapter calls — and only has to approve and install it.
*
* Keeping the manifest here rather than in the docs means the scopes the app
* requests and the API methods the adapter calls are edited together.
*/
const CREATE_APP_URL = 'https://api.slack.com/apps'
/** Scopes map 1:1 to the Web API calls in `api.ts`. Nothing speculative. */
export const SLACK_BOT_SCOPES = [
// chat.postMessage / chat.update
'chat:write',
// message.im events
'im:history',
// files.getUploadURLExternal + files.completeUploadExternal
'files:write',
// downloading an inbound file with the bot token
'files:read',
// resolving a display name for the paired-users list
'users:read',
] as const
export type SlackManifest = Record<string, unknown>
export function buildSlackAppManifest(appName = 'Claude Code Haha'): SlackManifest {
return {
display_information: {
name: appName,
description: 'Drive a local Claude Code session from Slack direct messages.',
background_color: '#1a1a1a',
},
features: {
bot_user: {
display_name: appName,
always_online: false,
},
app_home: {
home_tab_enabled: false,
messages_tab_enabled: true,
messages_tab_read_only_enabled: false,
},
},
oauth_config: {
scopes: {
bot: [...SLACK_BOT_SCOPES],
},
},
settings: {
event_subscriptions: {
bot_events: ['message.im'],
},
interactivity: {
is_enabled: false,
},
org_deploy_enabled: false,
// Socket Mode is what removes the need for a public request URL.
socket_mode_enabled: true,
token_rotation_enabled: false,
},
}
}
/** Pretty-printed manifest for the "paste a manifest" path. */
export function buildSlackManifestJson(appName?: string): string {
return JSON.stringify(buildSlackAppManifest(appName), null, 2)
}
/**
* Deep link that opens Slack's create-app dialog with the manifest pre-filled.
*
* Slack ignores the parameter if it ever stops supporting it, in which case the
* page still opens on the normal create flow — which is why the settings UI
* also shows the manifest itself.
*/
export function buildSlackCreateAppUrl(appName?: string): string {
const url = new URL(CREATE_APP_URL)
url.searchParams.set('new_app', '1')
url.searchParams.set('manifest_json', buildSlackManifestJson(appName))
return url.toString()
}
+159
View File
@@ -0,0 +1,159 @@
/**
* 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)
}
}
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'bun:test'
import {
extractWecomPayload,
extractWecomQuoteText,
extractWecomText,
} from '../extract-payload.js'
describe('extractWecomText', () => {
it('reads a plain text message', () => {
expect(extractWecomText({ msgtype: 'text', text: { content: ' hello ' } })).toBe('hello')
})
// WeCom transcribes voice server-side, so the text path — not the attachment
// path — is where a voice note belongs.
it('reads the transcript of a voice message', () => {
expect(extractWecomText({ msgtype: 'voice', voice: { content: 'read the readme' } }))
.toBe('read the readme')
})
it('joins the text items of a mixed message and ignores the images', () => {
expect(
extractWecomText({
msgtype: 'mixed',
mixed: {
msg_item: [
{ msgtype: 'text', text: { content: 'look at this' } },
{ msgtype: 'image', text: undefined },
{ msgtype: 'text', text: { content: 'and this' } },
],
},
}),
).toBe('look at this\nand this')
})
it('returns an empty string for a body with no text at all', () => {
expect(extractWecomText({ msgtype: 'image' })).toBe('')
expect(extractWecomText(undefined)).toBe('')
})
})
describe('extractWecomQuoteText', () => {
it('reads the quoted message so the Agent sees what the user replied to', () => {
expect(
extractWecomQuoteText({ quote: { msgtype: 'text', text: { content: 'earlier answer' } } }),
).toBe('earlier answer')
})
it('returns an empty string when nothing was quoted', () => {
expect(extractWecomQuoteText({ msgtype: 'text', text: { content: 'x' } })).toBe('')
})
})
describe('extractWecomPayload', () => {
const single = {
msgid: 'msg-1',
chattype: 'single' as const,
from: { userid: 'zhangsan' },
msgtype: 'text',
text: { content: 'hello' },
}
it('routes a 1:1 chat by the sender userid', () => {
expect(extractWecomPayload(single)).toEqual({
chatId: 'zhangsan',
userId: 'zhangsan',
text: 'hello',
dedupKey: 'msg-1',
})
})
// Pairing authorizes a person. Answering in a group would hand that person's
// authorization to everyone else in the room.
it('drops a group message, whatever else it carries', () => {
expect(extractWecomPayload({ ...single, chattype: 'group', chatid: 'room-1' })).toBeNull()
expect(extractWecomPayload({ ...single, chattype: 'group' })).toBeNull()
})
it('drops a message with no sender identity to authorize', () => {
expect(extractWecomPayload({ ...single, from: {} })).toBeNull()
expect(extractWecomPayload(undefined)).toBeNull()
})
it('prefixes the quoted text so the reply keeps its context', () => {
const payload = extractWecomPayload({
...single,
quote: { msgtype: 'text', text: { content: 'line one\nline two' } },
})
expect(payload!.text).toBe('> line one\n> line two\n\nhello')
})
it('falls back to a synthesized dedup key when msgid is absent', () => {
const payload = extractWecomPayload({ ...single, msgid: undefined, create_time: 1700 })
expect(payload!.dedupKey).toBe('zhangsan:1700:hello')
})
})
+122
View File
@@ -0,0 +1,122 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { AttachmentStore } from '../../common/attachment/attachment-store.js'
import { collectWecomMediaCandidates, WecomMediaService } from '../media.js'
let tmpDir: string
let store: AttachmentStore
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wecom-media-test-'))
store = new AttachmentStore({ root: tmpDir })
})
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true })
})
describe('collectWecomMediaCandidates', () => {
it('picks up an image with its per-download AES key', () => {
const candidates = collectWecomMediaCandidates({
msgid: 'm1',
msgtype: 'image',
image: { url: 'https://cdn.example/img', aeskey: 'key-1' },
})
expect(candidates).toEqual([
{ kind: 'image', name: 'wecom-image-m1.jpg', url: 'https://cdn.example/img', aesKey: 'key-1', mimeType: 'image/jpeg' },
])
})
it('picks up a file message', () => {
const candidates = collectWecomMediaCandidates({
msgid: 'm2',
msgtype: 'file',
file: { url: 'https://cdn.example/doc', aeskey: 'key-2' },
})
expect(candidates).toMatchObject([{ kind: 'file', url: 'https://cdn.example/doc', aesKey: 'key-2' }])
})
it('picks up every image inside a mixed message', () => {
const candidates = collectWecomMediaCandidates({
msgid: 'm3',
msgtype: 'mixed',
mixed: {
msg_item: [
{ msgtype: 'text' },
{ msgtype: 'image', image: { url: 'https://cdn.example/a' } },
{ msgtype: 'image', image: { url: 'https://cdn.example/b' } },
],
},
})
expect(candidates.map((item) => item.url)).toEqual(['https://cdn.example/a', 'https://cdn.example/b'])
})
// Voice arrives already transcribed, so downloading the audio would attach a
// file the Agent cannot read.
it('ignores a voice message', () => {
expect(collectWecomMediaCandidates({ msgid: 'm4', msgtype: 'voice', voice: { content: 'hi' } }))
.toEqual([])
})
it('ignores media entries with no download URL', () => {
expect(collectWecomMediaCandidates({ msgid: 'm5', msgtype: 'image', image: { aeskey: 'k' } }))
.toEqual([])
expect(collectWecomMediaCandidates(undefined)).toEqual([])
})
})
describe('WecomMediaService', () => {
it('stages the decrypted bytes and prefers the name the download reports', async () => {
const service = new WecomMediaService(store, async () => ({
buffer: Buffer.from('file body'),
filename: 'report.pdf',
}))
const local = await service.downloadCandidate(
{ kind: 'file', name: 'wecom-file-m1', url: 'https://cdn.example/doc', aesKey: 'k' },
'session-1',
)
expect(local.name).toBe('report.pdf')
expect(local.kind).toBe('file')
expect(local.mimeType).toBe('application/pdf')
expect(fs.readFileSync(local.path, 'utf8')).toBe('file body')
})
it('passes the AES key through to the download', async () => {
const seen: Array<[string, string | undefined]> = []
const service = new WecomMediaService(store, async (url, aesKey) => {
seen.push([url, aesKey])
return { buffer: Buffer.from('x') }
})
await service.downloadCandidate(
{ kind: 'image', name: 'a.jpg', url: 'https://cdn.example/img', aesKey: 'secret-key' },
'session-1',
)
expect(seen).toEqual([['https://cdn.example/img', 'secret-key']])
})
// A `file` message carrying a PNG is an image to the Agent, and the size
// limits differ, so the resolved MIME wins over the message's own shape.
it('reclassifies a file message that turns out to be an image', async () => {
const service = new WecomMediaService(store, async () => ({
buffer: Buffer.from('png bytes'),
filename: 'diagram.png',
}))
const local = await service.downloadCandidate(
{ kind: 'file', name: 'wecom-file-m1', url: 'https://cdn.example/doc' },
'session-1',
)
expect(local.kind).toBe('image')
expect(local.mimeType).toBe('image/png')
})
})
+149
View File
@@ -0,0 +1,149 @@
import { afterEach, describe, expect, it } from 'bun:test'
import {
cancelWecomQrLogin,
pollWecomQrLogin,
resetWecomQrLoginsForTest,
startWecomQrLogin,
} from '../qr-auth.js'
type Call = { url: URL }
function fakeFetch(bodies: Array<Record<string, unknown>>, calls: Call[] = []) {
let index = 0
const impl = (async (input: string | URL | Request) => {
calls.push({ url: new URL(String(input)) })
const body = bodies[Math.min(index, bodies.length - 1)]
index += 1
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}) as unknown as typeof fetch
return { impl, calls }
}
const GENERATED = {
data: { scode: 'scode-1', auth_url: 'https://work.weixin.qq.com/ai/qc/confirm?scode=scode-1' },
}
afterEach(() => {
resetWecomQrLoginsForTest()
})
describe('startWecomQrLogin', () => {
it('requests a code for this platform and returns the scannable URL', async () => {
const { impl, calls } = fakeFetch([GENERATED])
const started = await startWecomQrLogin({ fetchImpl: impl, sessionKey: 'k1' })
expect(calls[0]!.url.pathname).toBe('/ai/qc/generate')
expect(calls[0]!.url.searchParams.get('source')).toBe('claude-code-haha')
expect(['1', '2', '3']).toContain(calls[0]!.url.searchParams.get('plat'))
expect(started.verificationUrl).toBe(GENERATED.data.auth_url)
expect(started.expiresAt).toBeGreaterThan(Date.now())
})
it('reuses a fresh code instead of burning a new one', async () => {
const { impl, calls } = fakeFetch([GENERATED])
await startWecomQrLogin({ fetchImpl: impl, sessionKey: 'k1' })
await startWecomQrLogin({ fetchImpl: impl, sessionKey: 'k1' })
expect(calls).toHaveLength(1)
})
it('forces a new code when the caller asks to rebind', async () => {
const { impl, calls } = fakeFetch([GENERATED])
await startWecomQrLogin({ fetchImpl: impl, sessionKey: 'k1' })
await startWecomQrLogin({ fetchImpl: impl, sessionKey: 'k1', force: true })
expect(calls).toHaveLength(2)
})
// The URL is rendered as a QR code, so anything off the console's own origin
// must fail closed rather than be shown to the user.
it.each([
['http://work.weixin.qq.com/ai/qc/confirm', 'plain http'],
['https://evil.example.com/ai/qc/confirm', 'foreign host'],
['https://user:pw@work.weixin.qq.com/x', 'embedded credentials'],
])('rejects an unsafe auth_url: %s (%s)', async (authUrl) => {
const { impl } = fakeFetch([{ data: { scode: 's', auth_url: authUrl } }])
await expect(startWecomQrLogin({ fetchImpl: impl })).rejects.toThrow(/invalid data/i)
})
it('rejects a response with no scan code', async () => {
const { impl } = fakeFetch([{ data: { auth_url: GENERATED.data.auth_url } }])
await expect(startWecomQrLogin({ fetchImpl: impl })).rejects.toThrow(/invalid data/i)
})
})
describe('pollWecomQrLogin', () => {
async function started(pollBodies: Array<Record<string, unknown>>) {
const { impl } = fakeFetch([GENERATED, ...pollBodies])
const start = await startWecomQrLogin({ fetchImpl: impl, sessionKey: 'poll-key' })
return { sessionKey: start.sessionKey, impl }
}
it('returns the bot credentials on success', async () => {
const { sessionKey, impl } = await started([
{ data: { status: 'success', bot_info: { botid: 'bot-1', secret: 'secret-1' } } },
])
expect(await pollWecomQrLogin({ sessionKey, fetchImpl: impl })).toEqual({
connected: true,
botId: 'bot-1',
secret: 'secret-1',
})
})
it('treats a success with missing credentials as a failure', async () => {
const { sessionKey, impl } = await started([{ data: { status: 'success', bot_info: {} } }])
expect(await pollWecomQrLogin({ sessionKey, fetchImpl: impl })).toMatchObject({
connected: false,
status: 'failed',
})
})
it('keeps waiting on an unknown intermediate status', async () => {
const { sessionKey, impl } = await started([{ data: { status: 'scanned' } }])
expect(await pollWecomQrLogin({ sessionKey, fetchImpl: impl })).toMatchObject({
connected: false,
status: 'waiting',
})
})
it.each([
['expired', 'expired'],
['timeout', 'expired'],
['fail', 'failed'],
['error', 'failed'],
])('maps %s to %s and ends the attempt', async (remote, expected) => {
const { sessionKey, impl } = await started([{ data: { status: remote } }])
expect(await pollWecomQrLogin({ sessionKey, fetchImpl: impl })).toMatchObject({ status: expected })
expect(await pollWecomQrLogin({ sessionKey, fetchImpl: impl })).toMatchObject({ status: 'not_started' })
})
it('reports an unknown session key instead of throwing', async () => {
const { impl } = fakeFetch([{}])
expect(await pollWecomQrLogin({ sessionKey: 'missing', fetchImpl: impl })).toMatchObject({
status: 'not_started',
})
})
it('stops polling after the attempt is cancelled', async () => {
const { sessionKey, impl } = await started([{ data: { status: 'scanned' } }])
cancelWecomQrLogin(sessionKey)
expect(await pollWecomQrLogin({ sessionKey, fetchImpl: impl })).toMatchObject({
status: 'not_started',
})
})
})
+103
View File
@@ -0,0 +1,103 @@
/**
* Normalize one inbound Enterprise WeChat message body.
*
* The WebSocket channel delivers six message shapes (text / image / mixed /
* voice / file / video) that all carry their text in a different place. This
* module flattens them into the fields the shared chat runtime needs, and is
* the only place that knows those shapes.
*/
export type WecomInboundBody = {
msgid?: string
aibotid?: string
chatid?: string
chattype?: 'single' | 'group' | string
msgtype?: string
from?: { userid?: string }
create_time?: number
text?: { content?: string }
voice?: { content?: string }
mixed?: { msg_item?: Array<{ msgtype?: string; text?: { content?: string } }> }
quote?: {
msgtype?: string
text?: { content?: string }
voice?: { content?: string }
mixed?: { msg_item?: Array<{ msgtype?: string; text?: { content?: string } }> }
}
}
export type WecomInboundPayload = {
/** Conversation key: the sender's userid, since only 1:1 chats are routed. */
chatId: string
userId: string
text: string
dedupKey: string
}
function cleanText(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function mixedText(
mixed: { msg_item?: Array<{ msgtype?: string; text?: { content?: string } }> } | undefined,
): string {
return (mixed?.msg_item ?? [])
.filter((item) => item.msgtype === 'text')
.map((item) => cleanText(item.text?.content))
.filter(Boolean)
.join('\n')
}
/** The user-visible text of a message, whatever shape carried it. */
export function extractWecomText(body: WecomInboundBody | undefined): string {
if (!body) return ''
const parts = [
cleanText(body.text?.content),
// WeCom transcribes voice server-side, so a voice note is plain text here.
cleanText(body.voice?.content),
mixedText(body.mixed),
].filter(Boolean)
return parts.join('\n').trim()
}
/** The text of a quoted message, used as context for the Agent. */
export function extractWecomQuoteText(body: WecomInboundBody | undefined): string {
const quote = body?.quote
if (!quote) return ''
const parts = [
cleanText(quote.text?.content),
cleanText(quote.voice?.content),
mixedText(quote.mixed),
].filter(Boolean)
return parts.join('\n').trim()
}
/**
* Flatten an inbound body, or return null when it cannot be routed.
*
* A message with no sender userid has no identity to authorize, and a group
* message would answer in a room where pairing authorized only one member —
* both are dropped here rather than downstream, so the runtime only ever sees
* routable private messages.
*/
export function extractWecomPayload(
body: WecomInboundBody | undefined,
): WecomInboundPayload | null {
if (!body) return null
const userId = cleanText(body.from?.userid)
if (!userId) return null
if (body.chattype === 'group') return null
const chatId = userId
const quoted = extractWecomQuoteText(body)
const body_text = extractWecomText(body)
const text = quoted ? `> ${quoted.replace(/\n/g, '\n> ')}\n\n${body_text}`.trim() : body_text
return {
chatId,
userId,
text,
dedupKey: cleanText(body.msgid) || `${chatId}:${body.create_time ?? ''}:${body_text.slice(0, 32)}`,
}
}
+268
View File
@@ -0,0 +1,268 @@
/**
* 企业微信 (Enterprise WeChat / WeCom) Adapter for Claude Code Desktop
*
* Uses the official 智能机器人 WebSocket channel (`@wecom/aibot-node-sdk`), so
* no public callback URL is needed — the same shape as the Feishu adapter.
* Credentials (`botId` / `secret`) come from the QR binding in Desktop
* Settings → IM, or from WECOM_BOT_ID / WECOM_BOT_SECRET.
*
* 启动:WECOM_BOT_ID=xxx WECOM_BOT_SECRET=yyy bun run wecom/index.ts
*/
import * as crypto from 'node:crypto'
import { WSClient } from '@wecom/aibot-node-sdk'
import { WsBridge, type AttachmentRef } from '../common/ws-bridge.js'
import { MessageDedup } from '../common/message-dedup.js'
import { loadConfig } from '../common/config.js'
import { splitMessageByBytes, utf8Length } 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 { imageExtensionForMime } from '../common/attachment/mime.js'
import {
ImChatRuntime,
type ChatPort,
type OutboundImage,
type ResponseStream,
} from '../common/chat-runtime.js'
import { extractWecomPayload } from './extract-payload.js'
import { collectWecomMediaCandidates, WecomMediaService } from './media.js'
/** Platform cap is 20480 bytes per stream frame; leave room for the tail marker. */
const WECOM_STREAM_BYTE_LIMIT = 19_000
const WECOM_TEXT_BYTE_LIMIT = 4_000
const config = loadConfig()
if (!config.wecom.botId || !config.wecom.secret) {
console.error('[WeCom] Missing WECOM_BOT_ID / WECOM_BOT_SECRET. Bind 企业微信 in Desktop Settings > IM.')
process.exit(1)
}
const client = new WSClient({
botId: config.wecom.botId,
secret: config.wecom.secret,
logger: {
debug: () => {},
info: () => {},
warn: (message: string, ...args: unknown[]) => console.warn('[WeCom][sdk]', message, ...args),
error: (message: string, ...args: unknown[]) => console.error('[WeCom][sdk]', message, ...args),
},
})
const bridge = new WsBridge(config.serverUrl, 'wecom')
const dedup = new MessageDedup()
const sessionStore = new SessionStore()
const { httpClient, defaultWorkDir } = createAdapterClient(config, config.wecom)
const attachmentStore = new AttachmentStore()
const media = new WecomMediaService(attachmentStore, (url, aesKey) => client.downloadFile(url, aesKey))
attachmentStore.gc().catch((err) => {
console.warn('[WeCom] AttachmentStore.gc failed:', err instanceof Error ? err.message : err)
})
/** Frames are the only way to reply *into* the message the user just sent.
* One per chat: a newer message supersedes the previous reply target. */
type ReplyFrame = { headers: { req_id: string; [key: string]: unknown } }
const replyFrames = new Map<string, ReplyFrame>()
async function sendMarkdown(chatId: string, text: string): Promise<void> {
for (const chunk of splitMessageByBytes(text, WECOM_TEXT_BYTE_LIMIT)) {
await client.sendMessage(chatId, { msgtype: 'markdown', markdown: { content: chunk } })
}
}
/**
* One assistant turn rendered as a WeCom stream message.
*
* The platform updates a single bubble as long as every frame reuses the same
* `streamId`, which is the closest thing WeCom has to Telegram's edit-in-place.
* Two constraints shape this class: the reply must reference the inbound frame,
* and one stream cannot exceed the per-frame byte cap — overflow continues as
* ordinary proactive messages instead of being silently truncated.
*/
class WecomResponse implements ResponseStream {
private readonly streamId = crypto.randomUUID().replace(/-/g, '')
private readonly frame: ReplyFrame | undefined
private streamed = ''
private overflow = ''
private streamStarted = false
private streamClosed = false
constructor(private readonly chatId: string) {
this.frame = replyFrames.get(chatId)
}
async append(delta: string): Promise<void> {
if (!delta) return
if (this.streamClosed) {
this.overflow += delta
return
}
const next = this.streamed + delta
if (utf8Length(next) > WECOM_STREAM_BYTE_LIMIT) {
// Close the bubble at the last frame that fit, then keep going below it.
await this.flushStream(true)
this.streamClosed = true
this.overflow += delta
return
}
this.streamed = next
// Skip an intermediate frame while the previous one is still unacked —
// the SDK queues them, and a backlog turns streaming into a slideshow.
if (this.frame && client.hasPendingReplyAck(this.frame)) return
await this.flushStream(false)
}
async finish(): Promise<void> {
if (!this.streamClosed) {
await this.flushStream(true)
this.streamClosed = true
}
const tail = this.overflow.trim()
this.overflow = ''
if (tail) await sendMarkdown(this.chatId, tail)
}
private async flushStream(finished: boolean): Promise<void> {
// An empty turn (tool-only) has nothing to show and no bubble to close.
if (!this.streamStarted && !this.streamed.trim()) return
if (!this.frame) {
// No inbound frame to attach to (e.g. a reconnect replayed the stream).
// Fall back to a proactive message at the end rather than dropping text.
if (finished && this.streamed.trim()) await sendMarkdown(this.chatId, this.streamed)
return
}
try {
await client.replyStream(this.frame, this.streamId, this.streamed, finished)
this.streamStarted = true
} catch (err) {
console.warn('[WeCom] replyStream failed:', err instanceof Error ? err.message : err)
if (finished && this.streamed.trim()) {
await sendMarkdown(this.chatId, this.streamed).catch((sendErr) => {
console.error('[WeCom] stream fallback send failed:', sendErr)
})
}
}
}
}
const port: ChatPort = {
platform: 'wecom',
logPrefix: '[WeCom]',
async sendNotice(chatId, text) {
await sendMarkdown(chatId, text)
},
createResponse(chatId) {
return new WecomResponse(chatId)
},
async sendImage(chatId, image: OutboundImage) {
const check = checkAttachmentLimit('image', image.buffer.length, image.mime)
if (!check.ok) {
console.warn('[WeCom] Outbound image rejected:', check.hint)
return
}
const filename = `claude-${Date.now()}.${imageExtensionForMime(image.mime)}`
const uploaded = await client.uploadMedia(image.buffer, { type: 'image', filename })
const mediaId = (uploaded as { media_id?: string })?.media_id
if (!mediaId) throw new Error('Enterprise WeChat upload returned no media_id')
await client.sendMediaMessage(chatId, 'image', mediaId)
},
clearChat(chatId) {
replyFrames.delete(chatId)
},
}
const runtime = new ImChatRuntime({
port,
config,
platformConfig: config.wecom,
bridge,
sessionStore,
httpClient,
defaultWorkDir,
dedup,
// The bubble updates in place, so flushing often is cheap and looks live.
flushIntervalMs: 500,
flushCharThreshold: 160,
})
async function collectAttachments(
chatId: string,
candidates: ReturnType<typeof collectWecomMediaCandidates>,
): Promise<AttachmentRef[]> {
if (candidates.length === 0) return []
const sessionId = sessionStore.get(chatId)?.sessionId ?? chatId
const settled = await Promise.allSettled(
candidates.map((candidate) => media.downloadCandidate(candidate, sessionId)),
)
const attachments: AttachmentRef[] = []
let failures = 0
for (const result of settled) {
if (result.status === 'rejected') {
failures += 1
console.error('[WeCom] media download failed:', result.reason)
continue
}
const local = result.value
const check = checkAttachmentLimit(local.kind, local.size, local.mimeType)
if (!check.ok) {
await port.sendNotice(chatId, check.hint)
continue
}
attachments.push(
local.kind === 'image'
? { type: 'image', name: local.name, data: local.buffer.toString('base64'), mimeType: local.mimeType }
: { type: 'file', name: local.name, path: local.path, mimeType: local.mimeType },
)
}
if (failures > 0) {
await port.sendNotice(
chatId,
failures === candidates.length ? '附件下载失败,请稍后重试。' : `${failures} 个附件下载失败,已跳过。`,
)
}
return attachments
}
client.on('message', (frame) => {
const payload = extractWecomPayload(frame?.body)
if (!payload) return
replyFrames.set(payload.chatId, frame as ReplyFrame)
const candidates = collectWecomMediaCandidates(frame?.body)
void runtime.handleInbound({
chatId: payload.chatId,
userId: payload.userId,
displayName: payload.userId,
dedupKey: payload.dedupKey,
text: payload.text,
hasAttachments: candidates.length > 0,
loadAttachments: () => collectAttachments(payload.chatId, candidates),
})
})
client.on('authenticated', () => console.log('[WeCom] Authenticated, bot is running!'))
client.on('disconnected', (reason: string) => console.warn(`[WeCom] Disconnected: ${reason}`))
client.on('reconnecting', (attempt: number) => console.log(`[WeCom] Reconnecting (attempt ${attempt})`))
client.on('error', (err: Error) => console.error('[WeCom] Connection error:', err.message))
console.log('[WeCom] Starting adapter...')
console.log(`[WeCom] Server: ${config.serverUrl}`)
console.log(`[WeCom] Bot: ${config.wecom.botId}`)
client.connect()
process.on('SIGINT', () => {
console.log('[WeCom] Shutting down...')
try {
client.disconnect()
} catch {
// Best-effort: the process is exiting either way.
}
bridge.destroy()
dedup.destroy()
process.exit(0)
})
+113
View File
@@ -0,0 +1,113 @@
/**
* Enterprise WeChat inbound media.
*
* WeCom hands the bot an encrypted CDN URL plus a per-download AES key; the
* SDK's `downloadFile` does the fetch and the decryption. This module only
* decides *what* to download (from a message body) and where to stage it, so
* the extraction rules can be tested without a live WebSocket client.
*/
import type { AttachmentStore } from '../common/attachment/attachment-store.js'
import type { LocalAttachment } from '../common/attachment/attachment-types.js'
import { attachmentKindForMime, inferMimeFromFileName } from '../common/attachment/mime.js'
export type WecomMediaCandidate = {
kind: 'image' | 'file'
/** Fallback name; the real one may arrive with the download. */
name: string
url: string
aesKey?: string
mimeType?: string
}
type WecomMediaBody = {
msgid?: string
msgtype?: string
image?: { url?: string; aeskey?: string }
file?: { url?: string; aeskey?: string }
mixed?: { msg_item?: Array<{ msgtype?: string; image?: { url?: string; aeskey?: string } }> }
/** Listed so the deliberate omission is visible: WeCom delivers voice notes
* already transcribed, so they belong in the text path, not here. */
voice?: { content?: string }
}
export type WecomDownload = (
url: string,
aesKey?: string,
) => Promise<{ buffer: Buffer; filename?: string }>
function imageCandidate(
media: { url?: string; aeskey?: string } | undefined,
seed: string,
): WecomMediaCandidate | null {
if (!media?.url) return null
return {
kind: 'image',
name: `wecom-image-${seed}.jpg`,
url: media.url,
aesKey: media.aeskey,
mimeType: 'image/jpeg',
}
}
/**
* Downloadable attachments carried by one inbound message.
*
* Voice messages are deliberately absent: WeCom delivers them already
* transcribed as `voice.content`, so they belong in the text path, not here.
*/
export function collectWecomMediaCandidates(body: WecomMediaBody | undefined): WecomMediaCandidate[] {
if (!body) return []
const seed = body.msgid || String(Date.now())
const candidates: WecomMediaCandidate[] = []
const image = imageCandidate(body.image, seed)
if (image) candidates.push(image)
if (body.file?.url) {
candidates.push({
kind: 'file',
name: `wecom-file-${seed}`,
url: body.file.url,
aesKey: body.file.aeskey,
})
}
body.mixed?.msg_item?.forEach((item, index) => {
if (item.msgtype !== 'image') return
const mixedImage = imageCandidate(item.image, `${seed}-${index}`)
if (mixedImage) candidates.push(mixedImage)
})
return candidates
}
export class WecomMediaService {
constructor(
private readonly store: AttachmentStore,
private readonly download: WecomDownload,
) {}
async downloadCandidate(
candidate: WecomMediaCandidate,
sessionId: string,
): Promise<LocalAttachment> {
const { buffer, filename } = await this.download(candidate.url, candidate.aesKey)
const name = filename?.trim() || candidate.name
const mimeType = candidate.mimeType
?? inferMimeFromFileName(name)
?? (candidate.kind === 'image' ? 'image/jpeg' : 'application/octet-stream')
const target = this.store.resolvePath('wecom', sessionId, name)
const path = await this.store.write(target, buffer)
return {
// Trust the resolved MIME over the candidate's guess: a `file` message
// carrying a .png is an image to the Agent, and the limits differ.
kind: attachmentKindForMime(mimeType),
name,
path,
buffer,
size: buffer.length,
mimeType,
}
}
}
+194
View File
@@ -0,0 +1,194 @@
/**
* Enterprise WeChat (企业微信) AI-bot QR provisioning.
*
* The 企业微信 console exposes a scan-to-create flow for 智能机器人: a GET
* returns a short code plus an authorization URL, the admin scans it in the
* WeCom app, and a second GET hands back the bot's `botid` / `secret`. Those
* two values are everything the WebSocket runtime needs, so binding never
* requires the user to open the admin console or copy a secret by hand.
*
* Shaped like `adapters/wechat/protocol.ts`: the module owns the in-flight
* login sessions so the HTTP API stays a thin start/poll pair.
*/
import * as crypto from 'node:crypto'
const GENERATE_URL = 'https://work.weixin.qq.com/ai/qc/generate'
const POLL_URL = 'https://work.weixin.qq.com/ai/qc/query_result'
const REQUEST_TIMEOUT_MS = 10_000
/** The console invalidates a scan code after five minutes. */
const QR_TTL_MS = 5 * 60_000
export const WECOM_POLL_INTERVAL_MS = 3_000
const DEFAULT_SOURCE = 'claude-code-haha'
type WecomLoginSession = {
sessionKey: string
scode: string
verificationUrl: string
startedAt: number
}
export type WecomQrStartResult = {
sessionKey: string
verificationUrl: string
expiresAt: number
pollIntervalMs: number
message: string
}
export type WecomQrPollResult =
| { connected: true; botId: string; secret: string }
| { connected: false; status: 'waiting' | 'expired' | 'failed' | 'not_started'; message: string }
const activeLogins = new Map<string, WecomLoginSession>()
function isFresh(session: WecomLoginSession): boolean {
return Date.now() - session.startedAt < QR_TTL_MS
}
function purgeExpiredLogins(): void {
for (const [key, session] of activeLogins) {
if (!isFresh(session)) activeLogins.delete(key)
}
}
function cleanString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null
}
/** Only the console's own HTTPS origin may be turned into a QR image. */
function safeVerificationUrl(value: unknown): string | null {
const raw = cleanString(value)
if (!raw) return null
try {
const url = new URL(raw)
const safeHost = url.hostname === 'work.weixin.qq.com'
const safePort = !url.port || url.port === '443'
if (url.protocol !== 'https:' || !safeHost || !safePort) return null
if (url.username || url.password) return null
return url.href
} catch {
return null
}
}
function currentPlatformCode(platform: NodeJS.Platform = process.platform): 1 | 2 | 3 {
if (platform === 'win32') return 2
if (platform === 'linux') return 3
return 1
}
async function requestJson(url: URL, fetchImpl: typeof fetch): Promise<Record<string, any>> {
const response = await fetchImpl(url, {
method: 'GET',
redirect: 'error',
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
headers: { accept: 'application/json' },
})
if (!response.ok) {
throw new Error(`Enterprise WeChat QR service returned HTTP ${response.status}`)
}
const body = await response.json().catch(() => null)
if (!body || typeof body !== 'object' || Array.isArray(body)) {
throw new Error('Enterprise WeChat QR service returned a non-JSON response')
}
return body as Record<string, any>
}
export async function startWecomQrLogin(
opts: { force?: boolean; sessionKey?: string; source?: string; fetchImpl?: typeof fetch } = {},
): Promise<WecomQrStartResult> {
purgeExpiredLogins()
const sessionKey = opts.sessionKey || crypto.randomUUID()
const existing = activeLogins.get(sessionKey)
if (!opts.force && existing && isFresh(existing)) {
return {
sessionKey,
verificationUrl: existing.verificationUrl,
expiresAt: existing.startedAt + QR_TTL_MS,
pollIntervalMs: WECOM_POLL_INTERVAL_MS,
message: '二维码已就绪,请使用企业微信扫描。',
}
}
const url = new URL(GENERATE_URL)
url.searchParams.set('source', opts.source || DEFAULT_SOURCE)
url.searchParams.set('plat', String(currentPlatformCode()))
const body = await requestJson(url, opts.fetchImpl ?? fetch)
const scode = cleanString(body?.data?.scode)
const verificationUrl = safeVerificationUrl(body?.data?.auth_url)
if (!scode || !verificationUrl) {
throw new Error('Enterprise WeChat QR service returned invalid data')
}
const startedAt = Date.now()
activeLogins.set(sessionKey, { sessionKey, scode, verificationUrl, startedAt })
return {
sessionKey,
verificationUrl,
expiresAt: startedAt + QR_TTL_MS,
pollIntervalMs: WECOM_POLL_INTERVAL_MS,
message: '使用企业微信扫描二维码,创建并授权智能机器人。',
}
}
export async function pollWecomQrLogin(
opts: { sessionKey: string; fetchImpl?: typeof fetch },
): Promise<WecomQrPollResult> {
purgeExpiredLogins()
const session = activeLogins.get(opts.sessionKey)
if (!session) {
return {
connected: false,
status: 'not_started',
message: '当前没有进行中的企业微信绑定,请重新生成二维码。',
}
}
const url = new URL(POLL_URL)
url.searchParams.set('scode', session.scode)
const body = await requestJson(url, opts.fetchImpl ?? fetch)
const status = cleanString(body?.data?.status)?.toLowerCase()
if (status === 'success') {
const botId = cleanString(body?.data?.bot_info?.botid)
const secret = cleanString(body?.data?.bot_info?.secret)
if (!botId || !secret) {
activeLogins.delete(opts.sessionKey)
return {
connected: false,
status: 'failed',
message: '企业微信返回的机器人凭据不完整,请重新扫码。',
}
}
activeLogins.delete(opts.sessionKey)
return { connected: true, botId, secret }
}
if (status === 'expired' || status === 'timeout') {
activeLogins.delete(opts.sessionKey)
return { connected: false, status: 'expired', message: '二维码已过期,请重新生成。' }
}
if (status === 'fail' || status === 'failed' || status === 'error') {
activeLogins.delete(opts.sessionKey)
return { connected: false, status: 'failed', message: '企业微信授权失败,请重新扫码。' }
}
return { connected: false, status: 'waiting', message: '等待企业微信扫码确认...' }
}
/** Drop an in-flight attempt (user cancelled or rebound). */
export function cancelWecomQrLogin(sessionKey: string): void {
activeLogins.delete(sessionKey)
}
/** Test seam: the module-level session map must not leak across test cases. */
export function resetWecomQrLoginsForTest(): void {
activeLogins.clear()
}