阅读视图

发现新文章,点击刷新页面。

我把 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 —— 英雄联盟里那个小矮子发明家。因为这工具就像有个小机器人帮你干活一样。

🏗️ 架构设计:踩过的第一个坑

最初的设想

一开始我想得很简单:

  1. 监听 Slack 消息
  2. 调用 Claude CLI
  3. 把结果发回去

三步走,完事儿。

现实的暴击

实际做的时候才发现,事情没那么简单:

问题一: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 里:

2db91133029cfc157e7dbbcea5630576.jpg

或者直接发语音

cae84620508677ae00533e3fc5d68573.jpg

蹲在马桶上,动动手指,代码就改好了。Vibe coding,永不断档!

如果不确定Claude code的改动是否正确,每次对话的thread里还有本次修改的diff

image.png

写在最后

工具的价值在于节省时间。 虽然开发这个工具花了我两个整天,但以后每次蹲坑时继续 vibe coding 省下的时间,迟早会赚回来的(大概)。

slack目前支持已经基本完善,但飞书我是盲调的(公司内网有防火墙,连不到飞书的socket,所以飞书的不保证可用,后续我还会继续调整)。后续我还会考虑支持上钉钉、企微、微信,让更多的IM工具都能实现马桶vibe coding


项目地址:GitHub - chat-heimerdinger

如果觉得有用,欢迎 Star ⭐

有问题欢迎评论区交流,我会尽量回复(如果我不是在厕所里指挥 Claude 干活的话)。

❌