我把 Claude Code 搬进了 Slack,从此蹲坑也能 Vibe Coding
我把 Claude Code 搬进了 Slack,从此蹲坑也能 Vibe Coding
周末两天肝出一个工具,让 AI 帮我干活,我在 Slack 里指挥就行
🤔 起因:一次深刻的厕所沉思
那是一个普通的下午,我正在电脑前和 Claude Code 愉快地 vibe coding。需求聊着聊着,代码写着写着,突然,肚子一阵翻涌 —— 该去解决人生大事了。
坐在马桶上,我习惯性地掏出手机刷了会儿。刷着刷着突然想起来:卧槽,刚才让 Claude 改的那个函数,我还没确认呢,它现在在干嘛?
更要命的是,我还想继续和它聊,让它把剩下的逻辑也写了。但是... 我在厕所啊!
我盯着手机屏幕陷入了沉思:
- 回去继续?—— 但这坨还没解决完
- 用手机 SSH?—— 上次试过,vim 在手机上简直是酷刑
- 干等着?—— 这也太浪费时间了
就在这个充满哲学意味的时刻,一个想法击中了我:
为什么我不能在手机上继续 vibe coding?
现在 AI 编程这么火,Claude Code 那么好用,凭什么非得坐在电脑前?我就不能蹲着坑,发条消息,让 Claude 继续帮我干活?
想到这里,我感觉这坨💩都拉得更顺畅了。
说干就干。
💡 想法:让 Claude Code 成为我的远程员工
思路其实很简单:
我 (Slack) --> 消息 --> 服务器 --> Claude Code --> 代码修改 --> 结果返回 --> Slack
把 Claude Code CLI 包装成一个服务,跑在我的开发服务器上,然后通过 Slack 这类 IM 工具来和它对话。这样我就可以:
- 🚽 蹲坑的时候继续 vibe coding
- 📱 躺床上用手机改代码
- 🚇 地铁上处理紧急 bug
- 🍜 吃饭的时候让 AI 跑着任务
我给它起名叫 Heimerdinger —— 英雄联盟里那个小矮子发明家。因为这工具就像有个小机器人帮你干活一样。
🏗️ 架构设计:踩过的第一个坑
最初的设想
一开始我想得很简单:
- 监听 Slack 消息
- 调用 Claude CLI
- 把结果发回去
三步走,完事儿。
现实的暴击
实际做的时候才发现,事情没那么简单:
问题一:Claude Code 的输出是流式的
Claude 不是一下子吐出所有结果,而是一个字一个字往外蹦的。如果我等它全部输出完再发 Slack,用户体验会很差 —— 要等好久才能看到结果。
问题二:一个 Slack workspace 可能有多个项目
不同的频道可能在聊不同的项目,我需要记住"这个频道正在操作哪个项目"。
问题三:会话连续性
Claude Code 有会话概念,同一个会话里它能记住上下文。如果每次都开新会话,那用户说"把刚才那个函数改一下",Claude 根本不知道"刚才"是啥。
最终架构
flowchart TB
subgraph daemon["hmdg daemon"]
subgraph adapters["IM Adapters"]
slack["Slack Adapter"]
feishu["Feishu Adapter"]
discord["Discord...<br/>(Future)"]
end
subgraph processor["Message Processor"]
state["Session State<br/>Project State"]
end
claude["Claude Code CLI<br/>(streaming JSON)"]
slack --> processor
feishu --> processor
discord --> processor
processor --> claude
end
user["📱 你 (手机/电脑)"] <--> slack
user <--> feishu
我设计了一个适配器模式,把不同 IM 平台的差异封装起来。这样以后想支持飞书、Discord 什么的,只需要写个新 Adapter 就行。
🔧 技术实现:细节里全是魔鬼
1. 流式输出的处理
Claude Code 支持 --output-format stream-json 参数,会输出 JSONL 格式的流式数据:
{"type":"assistant","message":{"content":[{"type":"text","text":"让我来"}]}}
{"type":"assistant","message":{"content":[{"type":"text","text":"让我来看看"}]}}
{"type":"assistant","message":{"content":[{"type":"text","text":"让我来看看这个bug"}]}}
我需要实时解析这些 JSON,然后更新 Slack 消息。
但这里有个坑:Slack 有 API 频率限制。
如果每收到一个 JSON 就更新一次消息,很快就会被限流。所以我做了个节流处理:
// 至少间隔 1 秒才更新一次消息
const MIN_UPDATE_INTERVAL = 1000;
let lastUpdateTime = 0;
function throttledUpdate(content: string) {
const now = Date.now();
if (now - lastUpdateTime >= MIN_UPDATE_INTERVAL) {
updateMessage(content);
lastUpdateTime = now;
}
}
2. 会话状态管理
这是整个项目最复杂的部分。我需要维护三层状态:
// 每个频道当前选择的项目
const userStates = new Map<string, ChannelState>();
// 每个项目最后使用的会话 ID
const projectSessions = new Map<string, string>();
// 正在执行的任务(用于支持 /stop 命令)
const activeExecutions = new Map<string, ExecutionInfo>();
而且这些状态要持久化,不然服务重启就全丢了:
// 状态持久化到 ~/.heimerdinger/sessions-state.json
function saveState() {
fs.writeFileSync(STATE_FILE, JSON.stringify({
userStates: Object.fromEntries(userStates),
projectSessions: Object.fromEntries(projectSessions)
}));
}
3. 项目发现机制
Claude Code 会把项目信息存在 ~/.claude/projects/ 目录下,目录名是项目路径的编码形式:
~/.claude/projects/
├── home-dev-project-a/
├── home-dev-my-project/
└── Users-test-my-app/
编码规则很简单:把 / 替换成 -。但解码就头疼了。
比如 home-dev-my-project,它可能是:
-
/home/dev/my/project—— 4 层目录 -
/home/dev/my-project—— 3 层目录,最后一层本身带连字符
没法直接区分哪个 - 是原来的 /,哪个是路径本身就有的。
一开始我想简单处理:
// 简单粗暴,但是错的
function decodePath(encoded: string): string {
return '/' + encoded.replace(/-/g, '/');
}
// home-dev-my-project -> /home/dev/my/project ❌ 错!
后来想到一个办法:穷举所有可能的组合,看哪个路径在文件系统里真实存在。
function decodeProjectPath(encodedPath: string): string {
const parts = encodedPath.split('-'); // ['home', 'dev', 'my', 'project']
const result = findValidPath('', parts, 0);
return result || `/${encodedPath.replace(/-/g, '/')}`; // fallback
}
// 递归 + 回溯,尝试所有可能的路径组合
function findValidPath(current: string, parts: string[], index: number): string | null {
if (index >= parts.length) {
return existsSync(current) ? current : null;
}
// 从 index 开始,尝试把连续的 parts 拼成一个目录名
for (let i = index; i < parts.length; i++) {
const segment = parts.slice(index, i + 1).join('-'); // 'my' 或 'my-project'
const newPath = `${current}/${segment}`;
if (i === parts.length - 1) {
// 最后一段了,检查路径是否存在
if (existsSync(newPath)) return newPath;
} else {
// 继续递归
const result = findValidPath(newPath, parts, i + 1);
if (result) return result;
}
}
return null;
}
举个例子,对于 home-dev-my-project:
尝试 /home/dev/my/project → existsSync() = false ❌
尝试 /home/dev/my-project → existsSync() = true ✅ 找到了!
本质就是暴力搜索,但因为目录层级不会太深,性能完全可以接受。
有时候最笨的办法就是最好的办法
4. 权限系统
Claude Code 有权限控制,执行某些操作需要用户确认。在 CLI 里是交互式的,但在 Slack 里怎么办?
我做了个交互式卡片:
// 当 Claude 需要权限时,发送一个带按钮的卡片
async function sendPermissionCard(channel: string, tool: string, input: any) {
await slack.chat.postMessage({
channel,
blocks: [
{
type: 'section',
text: { type: 'mrkdwn', text: `🔐 *需要权限确认*\n工具: ${tool}` }
},
{
type: 'actions',
elements: [
{ type: 'button', text: { type: 'plain_text', text: '✅ 允许' }, action_id: 'approve' },
{ type: 'button', text: { type: 'plain_text', text: '❌ 拒绝' }, action_id: 'deny' }
]
}
]
});
}
用户点击按钮后,我再用更高权限重新执行请求。
5. Slack 斜杠命令
光发消息还不够,我还想要一些快捷操作。Slack 的斜杠命令(Slash Commands)正好派上用场:
-
/project—— 切换项目 -
/stop—— 停止当前正在执行的任务 -
/clear—— 清除会话,重新开始
实现分两层:
第一层:Slack Adapter 注册命令
// 注册 /project 命令
this.app.command('/project', async ({ command, ack }) => {
await ack(); // 必须在 3 秒内响应,否则 Slack 会报错
const context = {
channelId: command.channel_id,
userId: command.user_id,
};
// 触发交互处理
for (const handler of this.interactionHandlers) {
await handler('show_project_selector', '', context);
}
});
第二层:Message Processor 处理交互
async handleInteraction(action: string, value: string, context: MessageContext) {
if (action === 'show_project_selector') {
// 展示项目选择卡片
await this.showProjectSelector(adapter, context);
} else if (action === 'stop_execution') {
// 停止当前任务
await this.handleStopExecution(adapter, context);
} else if (action === 'clear_session') {
// 清除会话
await this.handleClearSession(adapter, context);
}
}
/stop 的实现有点意思 —— 需要能够中断正在运行的 Claude 进程:
private async handleStopExecution(adapter: IMAdapter, context: MessageContext) {
const execution = this.activeExecutions.get(context.channelId);
if (!execution) {
await adapter.sendMessage(context.channelId, '没有正在运行的任务。');
return;
}
// 标记为已中止,防止后续消息更新
execution.aborted = true;
// 触发 AbortController
execution.abort();
await adapter.updateMessage(context.channelId, execution.messageTs, '🛑 已停止');
}
这里用了 AbortController,它是 Node.js 原生支持的中止信号机制。在启动 Claude 进程时传入 abortSignal,调用 abort() 就能优雅地终止进程。
一个小细节:Slack 要求斜杠命令必须在 3 秒内响应(ack()),否则会显示错误。所以我先 ack(),再异步处理实际逻辑。这样用户体验会好很多。
😱 踩坑实录:那些让我头秃的问题
坑 1:Bun + Slack WebSocket = 💥
我一开始用 Bun 来开发,build 也用 Bun。结果发现一个诡异的问题:Slack 的 Socket Mode 在 Bun 运行时下经常断连。
排查了半天,发现是 Bun 的 WebSocket 实现和 @slack/bolt 不太兼容。
解决方案:开发时用 tsx(Node.js 运行时),生产构建用 Bun 打包但改成 Node.js 的 shebang:
bun build ./src/cli.ts --outdir ./dist --target node && \
sed -i '1s|#!/usr/bin/env bun|#!/usr/bin/env node|' ./dist/cli.js
是的,有点丑陋,但它能用。
坑 2:Slack 消息长度限制
Slack 单条消息最大 40KB(实际建议 38KB 以内)。但 Claude 有时候会输出很长的内容,特别是它在解释代码的时候。
直接截断?不行,可能截到一半把 markdown 格式搞坏了。
解决方案:按字节长度截断,并确保截断点不在多字节字符中间:
function truncateToByteLength(str: string, maxBytes: number): string {
const encoder = new TextEncoder();
const encoded = encoder.encode(str);
if (encoded.length <= maxBytes) return str;
// 找到合适的截断点
let truncated = encoded.slice(0, maxBytes);
// 确保不截断 UTF-8 多字节字符
while (truncated.length > 0 && (truncated[truncated.length - 1] & 0xc0) === 0x80) {
truncated = truncated.slice(0, -1);
}
return new TextDecoder().decode(truncated) + '\n\n... (内容过长,已截断)';
}
坑 3:Markdown 格式转换
Claude 输出的是标准 Markdown,但 Slack 用的是自己的 mrkdwn 格式。两者语法不一样:
| Markdown | Slack mrkdwn |
|---|---|
**bold** |
*bold* |
*italic* |
_italic_ |
[text](url) |
<url|text> |
`code` |
`code` |
我写了个转换函数,处理这些差异。但最坑的是代码块 —— Slack 对代码块的渲染很奇怪,超过一定长度就会出问题。
最终方案:长代码不放在消息里,而是上传成代码片段(Snippet):
if (codeBlock.length > 2000) {
await slack.files.uploadV2({
channel_id: channel,
content: codeBlock,
filename: 'code.txt',
title: 'Code Output'
});
}
坑 4:语音消息支持
既然是在手机上用,那支持语音消息岂不是更方便?说一句话就能让 Claude 干活。
我集成了 whisper.cpp 做语音转文字:
async function transcribe(audioPath: string): Promise<string> {
// 先用 ffmpeg 转换成 whisper 需要的格式
await exec(`ffmpeg -i ${audioPath} -ar 16000 -ac 1 -f wav ${wavPath}`);
// 调用 whisper
const { stdout } = await exec(`whisper-cli -m ${modelPath} -f ${wavPath}`);
return stdout.trim();
}
但这里又有坑:Slack 发来的语音是 .mp4 格式,需要先下载再转换。而且 whisper 模型挺大的,第一次运行要下载...
坑 5:进程管理
作为一个后台服务,进程管理是必须的:
- 如何优雅地启动/停止?
- 如何检测服务是否在运行?
- 如何处理僵尸进程?
我用 PID 文件 + 信号量来管理:
async function stop() {
const pid = readPidFile();
if (!pid) return;
// 先尝试优雅退出
process.kill(pid, 'SIGTERM');
// 等待 5 秒
await sleep(5000);
// 如果还在运行,强制杀掉
if (isRunning(pid)) {
process.kill(pid, 'SIGKILL');
}
}
✨ 最终效果
折腾了两天,终于能用了:
# 安装
npm install -g chat-heimerdinger
# 初始化配置
hmdg init
# 启动服务
hmdg start
然后在 Slack 里:
![]()
或者直接发语音
![]()
蹲在马桶上,动动手指,代码就改好了。Vibe coding,永不断档!
如果不确定Claude code的改动是否正确,每次对话的thread里还有本次修改的diff
![]()
写在最后
工具的价值在于节省时间。 虽然开发这个工具花了我两个整天,但以后每次蹲坑时继续 vibe coding 省下的时间,迟早会赚回来的(大概)。
slack目前支持已经基本完善,但飞书我是盲调的(公司内网有防火墙,连不到飞书的socket,所以飞书的不保证可用,后续我还会继续调整)。后续我还会考虑支持上钉钉、企微、微信,让更多的IM工具都能实现马桶vibe coding。
项目地址:GitHub - chat-heimerdinger
如果觉得有用,欢迎 Star ⭐
有问题欢迎评论区交流,我会尽量回复(如果我不是在厕所里指挥 Claude 干活的话)。