•
18 min read
dsh-superagent 接入 Slack 和 Linear:官方没有的 Source 怎么按 Cookbook 自己加(Socket Mode、app_mention、Linear GraphQL 与 Webhook 签名,附未测试代码骨架)

dsh-superagent 部署指南 发出来以后,问得最多的是:能不能在 Slack 里 @ 它?能不能在 Linear 的 Issue 里 @ 它干活?

先说结论:目前官方不支持。 dsh-superagent 只自带两个 Source:GitHub 和飞书(Lark 国际版也走飞书 Source)。仓库里没有 Slack 或 Linear 的代码。想接,只能按仓库的 docs/008 Cookbook 自己写一个 Source。这篇就讲怎么写:Source 接口长什么样、Slack 和 Linear 各该用哪些官方 API、代码骨架怎么搭,以及安全上要注意什么。

说明:dsh-superagent 的 README 第一句是 “Research prototype for reference only, not an official DeepSeek product.”,也就是研究原型,不是 DeepSeek 的官方产品。下文的接口和字段来自该仓库的 docs/006、docs/008 和 src/sources/ 源码,Slack 和 Linear 的 API 名、scope 和参数来自 docs.slack.dev 和 linear.app/developers(2026-11-03 查阅,dsh-superagent 版本 0.1.0)。文中代码是按 Cookbook 接口写的示例骨架,没有编译和运行过,我也没有端到端跑过这两个集成。

一、先确认:官方到底支持哪些 Source

我把仓库 src/sources/ 目录和文档逐个看了一遍:

来源 官方状态 依据
GitHub 内置 src/sources/github*.ts,README
飞书 内置(可选插件 dsh-superagent-feishu) src/sources/feishu*.ts,docs/006 “Feishu bot (optional)”
Lark 国际版 内置,走飞书 Source,lark-cli 用 --brand lark docs/006 “a Feishu or Lark app”,src/deployment.ts 读取 brand
Slack 没有 src/sources/ 里没有相关文件,全仓库搜不到 slack
Linear 没有 同上

有的英文转述把它写成了“集成 Slack”,但仓库里没有这部分代码。官方给的扩展方式是 docs/008:用一个虚构的消息队列 CLI hookq,示范怎么从零加一个 Source。

社区里有没有现成的?

我在 GitHub、X 和网上搜了一圈,没有找到实现成 dsh-superagent Source 的 Slack 或 Linear 集成。找到的都是 DeepSeek Harness(DSH)本身的工具插件,和 superagent 的 Source 是两回事:

  • STARDUSTLC666/dsh-slack:社区 DSH 插件,MIT 协议,给 DSH 的模型提供 slack_notify、slack_inbox、slack_reply 等工具,用 Socket Mode 收消息。它让你自己用的 DSH 会话能读写 Slack,但不会把 Slack 消息变成 superagent 的 Trigger,也不走 Policy 白名单。
  • 也有文章示范用第三方 CLI 给 DSH 写一个能发 Slack、建 Linear Issue 的工具插件,同样是“工具”,不是“Source”。

两者的区别在于:工具是 agent 主动去调用的,Source 是外部消息进来以后先过准入规则、再决定要不要开 Ticket。 想要“团队在 Slack 里 @ 它,只有白名单里的人能派活”,只能写 Source。

二、Source 接口:Cookbook 要你实现什么

docs/008 把一个 Source 拆成四件事:翻译事件、定义接收和授权规则、提供回复和上下文、维护轮询循环。对应 SourceKind 接口(src/sources/kind.ts)里的这些部分:

部分 作用 Slack 怎么对应 Linear 怎么对应
kind / schema / fields Source 的 id、配置校验、面板表单 slack,配置接收的频道 linear,配置接收的 team
Trigger 翻译 一条外部消息 → 一个 Trigger,id 必须稳定 一条 app_mention,id 用 频道:ts 一条提到 agent 的评论
scope 这条消息在不在接收范围 频道是否在列表里 team 是否在列表里
channel.send 回复,必须幂等 chat.postMessage + thread_ts commentCreate + parentId
threadContext 给评估读上下文 conversations.replies 读根评论和它的回复
policy 谁能派活(白名单) Slack user ID(U…/W…) Linear user ID
environment 给 prepare 命令的变量 DSH_SUPERAGENT_SLACK_CHANNEL DSH_SUPERAGENT_LINEAR_ISSUE
插件入口 + 循环 等 setup 完成、跟随开关、处理错误和 abort Socket Mode 长连接 定时轮询

自定义 Source 在 dsh-superagent 里的位置:Slack 通过 Socket Mode 订阅 app_mention,Linear 走专用成员轮询 notifications 或 Agent 加公网中转验签 Webhook;消息进入你写的 Source(Trigger、scope、policy、threadContext、channel.send),再由 superagent 按白名单开 Ticket、在 GitHub 开 PR,结果用 chat.postMessage 或 commentCreate 回到原线程

几条 Cookbook 原则,写 Slack 和 Linear 时都要守住:

  • Trigger id 必须稳定。 Engine 对重复 id 返回 duplicate,所以重复读到同一条消息是安全的。
  • 永远不要给 agent 自己的消息生成 Trigger。
  • meta 和 text 都是不可信数据,不是授权(docs/008 原话:“Both meta and text are untrusted data, not authorization.”)。
  • send 要幂等。 发送可能在对方已经收到后才失败,重试不能发两遍。只有永久发不出去时才返回 gone,其他失败一律 throw,让 Outbox 重试(5 秒起,翻倍到 300 秒)。
  • 默认什么都不接收。 新实例按 schema 默认值创建,默认值应该是空列表。

还有两个限制要先知道:

  1. setup 只检查 GitHub 和飞书(docs/008:“Setup currently checks only GitHub and Feishu fields listed in SETUP_FIELDS”)。自己加的 Source,凭据和身份要自己校验。
  2. 新 Source 没有内置 skill。 文档写明,新 kind 不会自动获得内置 skill、读取原始记录的资源,也不会绑定 PR。worker 只能通过 orchestrator_read trigger/<id> 和 thread/<key> 读消息内容。干完活仍然是在 GitHub 上开 PR,再由 channel.send 把结果回到 Slack 或 Linear。

三、dsh-superagent Slack 接入:用 Socket Mode 收 app_mention

为什么选 Socket Mode

dsh-superagent 的面板只监听 127.0.0.1,飞书也是用长连接收消息,不需要公网地址。Slack 里对应的做法是 Socket Mode:先用 app-level token 调 apps.connections.open 拿到一个 WebSocket 地址,事件就从这个连接推过来,不需要公网 Request URL。官方文档还写了,Socket Mode 收到的事件“no need to verify or validate”,因为连接本身已经认证过。

代价有两条,都写在 Slack 文档里:Socket Mode 应用不能上架 Slack Marketplace,而且连接每隔几个小时就会刷新,要能自动重连。团队内部自用,这两条都不是问题。

1. 创建 Slack App(官方步骤)

  1. 在 api.slack.com/apps 创建应用,选你的工作区。
  2. 打开 Socket Mode,生成一个 app-level token(xapp- 开头),scope 选 connections:write。
  3. 在 Event Subscriptions 里订阅 bot 事件 app_mention。
  4. 在 OAuth & Permissions 里加 bot token scopes(见下表),安装到工作区,拿到 Bot User OAuth Token(xoxb- 开头)。
  5. 在要用的频道里把机器人邀请进来。按 Slack 文档,app 不在频道里时,被 @ 也收不到事件。
Scope 类型 用途 来源
connections:write App-level 调 apps.connections.open 建立 Socket Mode 连接 scope 文档
app_mentions:read Bot 接收 app_mention 事件 app_mention 事件文档
chat:write Bot chat.postMessage 在线程里回复 chat.postMessage 文档
channels:history Bot conversations.replies 读公开频道的线程 conversations.replies 文档
groups:history Bot 同上,私有频道(不用就别加) conversations.replies 文档

私聊不会触发 app_mention。要收私聊,得另外订阅 message.im 并加 im:history,下面的骨架没有做。

2. 给 Source 一个只用机器人令牌的 CLI

dsh-superagent 的惯例是把凭据放在 CLI 包装脚本里,比如 gh-agent、lark-bot,而不是放进 dsh 的设置。Slack 官方 CLI 从 v4.1.0 起有 slack api <method> 命令,可以直接调任意 Web API 方法,令牌按优先级从 --token、--app、环境变量 SLACK_BOT_TOKEN 依次读取。v4.12.0 修复了 GET 请求的参数传递,所以建议装 4.12.0 以上:

mkdir -p ~/.config/slack-bot && chmod 700 ~/.config/slack-bot
# 把 xoxb- 令牌写进 ~/.config/slack-bot/bot-token,xapp- 令牌写进 ~/.config/slack-bot/app-token,两个文件都 chmod 600
cat > ~/.local/bin/slack-bot <<'EOF'
#!/bin/sh
SLACK_BOT_TOKEN="$(cat "$HOME/.config/slack-bot/bot-token")" exec slack api "$@"
EOF
chmod +x ~/.local/bin/slack-bot
slack-bot auth.test

auth.test 返回的 user_id 就是机器人自己的 ID,下面用它过滤掉 agent 自己发的消息。

注:slack api 在项目目录外只靠 SLACK_BOT_TOKEN 能否正常工作、输出是不是原样的 JSON 响应,是按官方命令文档推断的,没有实际跑过。

3. 代码骨架:src/sources/slack.ts(未测试)

下面按 docs/008 的接口写。导入、health、sourceContext 和插件入口都照 Cookbook 的写法;Socket Mode 部分用的是 Node 22 内置的 WebSocket(Node 22.4 起是稳定 API,dsh-superagent 要求 Node 22.19+)。

// 未经测试的示例代码:按 dsh-superagent docs/008-cookbook.md 的 Source 接口改写
import type { Context } from '@deepseek-ai/cordis'
import { readFileSync } from 'node:fs'
import { setTimeout as delay } from 'node:timers/promises'
import { z } from 'zod'
import { key, type OutboundMessage, type Trigger } from '../core/index.ts'
import { health, sourceContext, type SourceContext } from './host.ts'
import type { SourceKind } from './kind.ts'

const ConfigSchema = z.object({
  // 默认不接收任何频道(Cookbook:Make defaults receive nothing)
  channels: z.array(z.object({ id: z.string().regex(/^[CG][A-Z0-9]+$/) }).strict()).default([]),
  appTokenFile: z.string().default(''), // xapp- 令牌文件的绝对路径;文件里是令牌,配置里只放路径
}).strict()
export type SlackConfig = z.infer<typeof ConfigSchema>

class SlackError extends Error { constructor(readonly code: string) { super(`Slack API error: ${code}`) } }
/** 通过 slack-bot 包装脚本调用 Slack Web API;Slack 失败时 HTTP 也可能是 200,所以要看 ok 字段。 */
async function slackApi<T>(host: Pick<SourceContext, 'commands'>, method: string, params: Record<string, unknown>, get = false) {
  const args = get
    ? [method, '--method', 'GET', ...Object.entries(params).map(([k, v]) => `${k}=${String(v)}`)]
    : [method, '--json', JSON.stringify(params)]
  const out = JSON.parse(await host.commands.run('slack-bot', args)) as { ok: boolean; error?: string } & T
  if (!out.ok) throw new SlackError(out.error ?? 'unknown_error')
  return out
}

const MentionSchema = z.looseObject({
  type: z.literal('app_mention'), user: z.string().optional(), bot_id: z.string().optional(),
  text: z.string(), ts: z.string(), thread_ts: z.string().optional(), channel: z.string(),
})
type SlackMention = z.infer<typeof MentionSchema>

/** 一条 app_mention 对应一个 Trigger;线程 = 频道 + 根消息 ts;agent 自己的消息不生成 Trigger。 */
export function slackTrigger(e: SlackMention, me: string): Trigger | undefined {
  if (e.user === me) return undefined
  const root = e.thread_ts ?? e.ts
  const thread = `${e.channel}:${root}`
  return {
    id: key('slack', `${e.channel}:${e.ts}`),
    source: 'slack',
    threads: [{ source: 'slack', id: thread }],
    actor: { source: 'slack', id: e.user ?? '' },
    human: !e.bot_id,
    addressed: 'mention',
    at: new Date(Number(e.ts) * 1000).toISOString(),
    text: e.text,
    channel: { source: 'slack', thread: `slack:${thread}`, address: { channel: e.channel, threadTs: root } },
    meta: { channel: e.channel, ts: e.ts, root },
  }
}

/** 在原线程里回复。chat.postMessage 没有文档化的幂等参数,这里用 message metadata 标记 key,发之前先查一遍。 */
export async function sendSlack(host: Pick<SourceContext, 'commands'>, message: OutboundMessage): Promise<'sent' | 'gone'> {
  const { channel, threadTs } = message.channel.address
  if (!channel || !threadTs) throw new Error('Missing Slack reply target')
  try {
    const thread = await slackApi<{ messages: { metadata?: { event_payload?: { key?: string } } }[] }>(host,
      'conversations.replies', { channel, ts: threadTs, include_all_metadata: true, limit: 200 }, true)
    if (thread.messages.some(m => m.metadata?.event_payload?.key === message.id)) return 'sent'
    await slackApi(host, 'chat.postMessage', { channel, thread_ts: threadTs, text: message.text,
      metadata: { event_type: 'dsh_superagent_reply', event_payload: { key: message.id } } })
    return 'sent'
  } catch (error) {
    // 频道不存在或已归档:永久发不出去,返回 gone;其他错误 throw,交给 Outbox 重试
    if (error instanceof SlackError && ['channel_not_found', 'is_archived'].includes(error.code)) return 'gone'
    throw error
  }
}

export const slackKind: SourceKind<SlackConfig> = {
  kind: 'slack',
  title: { zh: 'Slack', en: 'Slack' },
  description: { zh: '接收下列频道里 @ 机器人的消息。', en: 'Receives mentions of the bot in these channels.' },
  schema: ConfigSchema,
  fields: [
    { key: 'channels', type: 'rows', label: { zh: '接收的频道', en: 'Channels' }, columns: [
      { key: 'id', type: 'text', label: { zh: '频道 ID', en: 'Channel ID' }, pattern: '[CG][A-Z0-9]+' }] },
    { key: 'appTokenFile', type: 'text', advanced: true, label: { zh: 'App-level token 文件', en: 'App-level token file' } },
  ],
  health: ['slack'],
  scope({ meta }, config) {
    const listed = config.channels.some(c => c.id === meta.channel)
    return listed ? { listened: true, observed: false, row: String(meta.channel) } : { listened: false, reason: 'channel_not_listed' }
  },
  channel: {
    allowed: (address, config) => config.channels.some(c => c.id === address.channel),
    send: sendSlack,
    link: address => ({ url: '', label: { zh: `Slack 频道 ${address.channel}`, en: `Slack channel ${address.channel}` } }),
  },
  threadContext: {
    messageId: trigger => String(trigger.meta.ts ?? ''),
    read: async (commands, trigger, _part, cursor) => {
      const page = await slackApi<{ messages: { ts: string; user?: string; bot_id?: string; text: string }[];
        response_metadata?: { next_cursor?: string } }>({ commands }, 'conversations.replies',
        { channel: String(trigger.meta.channel), ts: String(trigger.meta.root), limit: 50, ...(cursor ? { cursor } : {}) }, true)
      const messages = page.messages.map(m => ({ id: m.ts, from: m.user ?? m.bot_id ?? '', at: new Date(Number(m.ts) * 1000).toISOString(),
        text: m.text, ...(m.bot_id ? { bot: true as const } : {}) }))
      const next = page.response_metadata?.next_cursor
      return { messages, ...(next ? { next } : {}), body: { messages } }
    },
  },
  policy: {
    schema: z.object({ allowed: z.array(z.object({ id: z.string().regex(/^[UW][A-Z0-9]+$/),
      name: z.string().trim().max(100).optional() }).strict()).default([]) }).strict(),
    fields: [{ key: 'allowed', type: 'rows', label: { zh: '能发起工作的 Slack 用户', en: 'Slack users who may originate work' }, columns: [
      { key: 'id', type: 'text', label: { zh: 'Slack 用户 ID', en: 'Slack user ID' } },
      { key: 'name', type: 'text', label: { zh: '名字', en: 'Name' }, optional: true }] }],
    explicit: { zh: '名单里的人在频道里 @ 机器人。', en: 'Someone on the list mentions the bot in a channel.' },
    // 只订阅了 app_mention,收不到未 @ 的消息,所以“未 @”的格子都不可用
    rows: stored => ConfigSchema.parse(stored).channels.map(c => ({ id: c.id, label: c.id, note: { zh: '仅 @', en: '@ only' },
      cells: { listedUnaddressed: false, unlistedAddressed: true, unlistedUnaddressed: false } })),
  },
  environment: {
    variables: [{ key: 'DSH_SUPERAGENT_SLACK_CHANNEL', description: {
      zh: '接下工单的 Slack 频道;工单不是从 Slack 来的时为空', en: 'The Slack channel that started the ticket; empty otherwise' } }],
    values: ({ trigger }) => ({ DSH_SUPERAGENT_SLACK_CHANNEL: trigger?.source === 'slack' ? String(trigger.meta.channel ?? '') : '' }),
  },
}

/** 一次 Socket Mode 连接:apps.connections.open 拿 wss 地址,收 events_api,先存 Trigger 再用 envelope_id 确认。 */
async function connectOnce(ctx: SourceContext, config: SlackConfig, me: string, signal: AbortSignal): Promise<void> {
  const appToken = readFileSync(config.appTokenFile, 'utf8').trim()
  const res = await fetch('https://slack.com/api/apps.connections.open', { method: 'POST', signal,
    headers: { authorization: `Bearer ${appToken}`, 'content-type': 'application/x-www-form-urlencoded' } })
  const open = await res.json() as { ok: boolean; url?: string; error?: string }
  if (!open.ok || !open.url) throw new SlackError(open.error ?? 'no_url')
  const ws = new WebSocket(open.url)
  await new Promise<void>((resolve, reject) => {
    const stop = () => ws.close()
    signal.addEventListener('abort', stop, { once: true })
    ws.onclose = () => { signal.removeEventListener('abort', stop); resolve() } // Slack 定期刷新连接,外层循环会重连
    ws.onerror = () => reject(new Error('Slack Socket Mode connection failed'))
    let chain = Promise.resolve()
    ws.onmessage = ({ data }) => {
      chain = chain.then(async () => {
        const frame = JSON.parse(String(data)) as { type: string; envelope_id?: string; payload?: { event?: unknown } }
        if (frame.type === 'hello') ctx.report('slack', { state: 'ready', detail: 'Socket Mode connected', at: new Date().toISOString() })
        if (frame.type === 'disconnect') { ws.close(); return }
        if (frame.type !== 'events_api' || !frame.envelope_id) return
        const event = MentionSchema.safeParse(frame.payload?.event)
        // 已关掉的 Source 不再收新消息
        if (event.success && ctx.config<SlackConfig>('slack')) {
          const trigger = slackTrigger(event.data, me)
          if (trigger) await ctx.receive(trigger)
        }
        ws.send(JSON.stringify({ envelope_id: frame.envelope_id }))
      }).catch((error: unknown) => { ctx.report('slack', { state: 'error', detail: String(error) }) })
    }
  })
}

async function watch(ctx: SourceContext, signal: AbortSignal): Promise<void> {
  await ctx.ready
  if (!ctx.configured()) { ctx.report('slack', health('paused', 'setupPending')); return }
  let failures = 0
  while (!signal.aborted) {
    const config = ctx.config<SlackConfig>('slack')
    if (!config) { ctx.report('slack', health('paused', 'sourceOff')); await delay(5000, undefined, { signal }).catch(() => {}); continue }
    try {
      const me = (await slackApi<{ user_id: string }>(ctx, 'auth.test', {})).user_id // 自检:令牌有效,并拿到机器人自己的 ID
      await connectOnce(ctx, config, me, signal)
      failures = 0
    } catch (error) {
      failures += 1
      if (!signal.aborted && failures >= 3) {
        ctx.report('slack', health('error', 'pollFailures', { count: String(failures), error: String(error) }))
      }
      await delay(Math.min(60_000, 1000 * 2 ** failures), undefined, { signal }).catch(() => {})
    }
  }
}

export const name = 'dsh-superagent-slack'
export const inject = ['orchestrator']
export function apply(ctx: Context): void {
  ctx.effect(() => ctx.orchestrator.sources.register(slackKind), 'slack: source kind')
  ctx.effect(() => {
    const source = sourceContext(ctx.orchestrator, slackKind)
    const controller = new AbortController()
    const running = watch(source, controller.signal)
      .catch((error: unknown) => { source.report('slack', { state: 'error', detail: String(error) }) })
    return async () => { controller.abort(); await running }
  })
}

几处设计说明:

  • 先存再确认。 Slack 文档要求每个事件都用 envelope_id 确认,好让 Slack 知道要不要重发。这里先 ctx.receive 再确认,相当于 Cookbook 里“先存 Trigger 再移动游标”。重发的事件 id 一样,Engine 会判成 duplicate。
  • 幂等发送。 chat.postMessage 没有文档化的幂等参数,所以借用 message metadata 存 message.id,发之前用 conversations.replies(include_all_metadata)查一遍。注意 Slack 文档的提醒:metadata 对工作区里所有成员和应用可见,不要往里放敏感信息。
  • 断线期间的消息。 Socket Mode 断开期间发生的 @ 会不会全部补发,我没在文档里找到明确说法。要更稳,可以像飞书 Source 那样,再加一个用 conversations.history 补收的循环(骨架里没做)。按 Slack 2025 年的变更,未上架 Marketplace 的商业分发应用调用 conversations.history 会被限到每分钟 1 次;内部自建应用不受影响,仍是 Tier 3。

4. 注册插件

按 docs/008 第 10 步:

  1. 在 tsdown.config.ts 的 entry 里加 'slack-watch': 'src/sources/slack.ts';
  2. package.json 的 exports 加 "./slack-watch": "./lib/slack-watch.js";
  3. bundle 的 cordis.patch.yml 的 insert 列表里加一项,默认禁用:
    - id: dsh-superagent-slack
      name: dsh-superagent/slack-watch
      disabled: true
  1. 在 profile 的 cordis.patch.yml 里写上 id: dsh-superagent-slack 和 disabled: false,然后 build 并重启。新 Source 会出现在 Sources 设置和 Eval → On each Source 里。
  2. 把模块和测试加进 docs/007 的表格,tests/docs.spec.ts 会检查索引是否完整。测试按 tests/sources.spec.ts 的写法 mock Commands.run。

四、dsh-superagent Linear 接入:两条路线

Linear 比 Slack 麻烦:它没有 Socket Mode 这样的长连接,官方推荐的推送方式是 Webhook,而 Webhook 要求“publicly accessible HTTPS, non-localhost URL”,也就是公网可访问的 HTTPS 地址。dsh-superagent 的面板只听 127.0.0.1,不要为了接 Linear 把面板暴露到公网。所以有两条路:

路线 怎么做 优点 代价
A:专用成员 + 轮询 建一个 Linear 成员给 agent,用它的 personal API key 定时查 notifications 和 GitHub Source 思路一致(GitHub 也是轮询 agent 账号的 notifications),不需要公网 Linear 文档明确“discourage”轮询;普通成员占一个席位
B:Linear Agent + Webhook 中转 OAuth 应用用 actor=app 安装,申请 app:mentionable / app:assignable,收 AgentSessionEvent Linear 原生的 agent 体验,官方说 agent “do not count as billable users” 需要一个公网 HTTPS 中转服务;created 事件后 10 秒内要回一个 activity

小团队先试,路线 A 更简单;准备长期用,再考虑路线 B。下面的骨架用路线 A,路线 B 给出 Webhook 校验的关键代码。

路线 A:专用成员 + GraphQL 轮询

1. 准备身份和 CLI。 给 agent 建一个 Linear 成员,在它的 Security & access 设置里创建 personal API key。Linear 的认证方式是请求头 Authorization: <API_KEY>(personal key 不带 Bearer)。照 gh-agent 的思路包一个 CLI,把 key 放在文件里,让 curl 用 -H @文件 读请求头,key 就不会出现在进程参数里:

mkdir -p ~/.config/linear-agent && chmod 700 ~/.config/linear-agent
# ~/.config/linear-agent/auth-header 里只有一行:authorization: <API_KEY>,chmod 600
cat > ~/.local/bin/linear-agent <<'EOF'
#!/bin/sh
# 用法:linear-agent '{"query": "...", "variables": {...}}'
exec curl -sS --fail-with-body https://api.linear.app/graphql \
  -H "content-type: application/json" -H @"$HOME/.config/linear-agent/auth-header" --data "$1"
EOF
chmod +x ~/.local/bin/linear-agent
linear-agent '{"query": "query Me { viewer { id name } }"}'

viewer.id 就是 agent 自己的 Linear user ID,用来过滤它自己的评论。

2. 限流。 Linear 对 API key 的限制是每个用户每小时 2,500 次请求,复杂度上限每小时 300 万点,单个查询最多 1 万点。每 60 秒轮询一次,一小时也就 60 次,余量很大。但 Linear 文档的原话是 “One thing that we especially discourage is polling”,所以间隔别设太短。

3. 骨架(未测试,只列和 Slack 不同的部分):

// 未经测试的示例代码:Linear Source 的关键部分,其余结构同上面的 Slack 骨架
import { createHash } from 'node:crypto'
import { cursorAt } from './poll.ts'
import { KV } from '../kv.ts' // 需在 KV 里登记:linear: { cursor: record<string>('linear-cursor') }

const ConfigSchema = z.object({
  teams: z.array(z.object({ key: z.string().min(1) }).strict()).default([]),   // 接收的 team key,如 ENG
  // 哪些通知类型算“@ 了 agent”。先手动 @ 一次 agent 账号,再用 notifications 查询看实际的 type 值填进来
  mentionTypes: z.array(z.string()).default([]),
  pollSeconds: z.number().int().min(60).max(3600).default(60),
}).strict()
type LinearConfig = z.infer<typeof ConfigSchema>
type LinearNotification = { id: string; type: string; createdAt: string
  comment?: { id: string; body: string; url: string; createdAt: string; user?: { id: string } | null; parent?: { id: string } | null } | null
  issue?: { id: string; identifier: string; url: string; team: { key: string } } }

async function linear<T>(host: Pick<SourceContext, 'commands'>, query: string, variables: Record<string, unknown> = {}): Promise<T> {
  const out = JSON.parse(await host.commands.run('linear-agent', [JSON.stringify({ query, variables })])) as
    { data?: T; errors?: { message: string }[] }
  // GraphQL 可能部分成功但带 errors,按官方建议先检查 errors
  if (out.errors?.length || !out.data) throw new Error(`Linear: ${out.errors?.map(e => e.message).join('; ') ?? 'no data'}`)
  return out.data
}

const NOTIFICATIONS = `query($after: DateTimeOrDuration!) {
  notifications(filter: { createdAt: { gt: $after } }, orderBy: createdAt, first: 50) {
    nodes { id type createdAt
      ... on IssueNotification {
        comment { id body url createdAt user { id } parent { id } }
        issue { id identifier url team { key } } } } } }`

/** 轮询一轮:读游标之后的通知,逐条 receive,全部存完再移动游标(Cookbook 第 9 步)。 */
async function pollLinear(ctx: SourceContext, config: LinearConfig, me: string, signal: AbortSignal) {
  const after = ctx.state.get(KV.linear.cursor) ?? new Date(Date.now() - 60_000).toISOString() // KV.linear 需在 src/kv.ts 里登记
  const started = new Date().toISOString()
  const data = await linear<{ notifications: { nodes: LinearNotification[] } }>({ commands: ctx.commands.withSignal(signal) }, NOTIFICATIONS, { after })
  for (const n of data.notifications.nodes) {
    if (signal.aborted) throw new Error('The Linear Source stopped during the read')
    const c = n.comment
    if (!c || !n.issue || c.user?.id === me) continue                         // 只处理评论;跳过 agent 自己
    const root = c.parent?.id ?? c.id
    await ctx.receive({
      id: key('linear', c.id),
      source: 'linear',
      threads: [{ source: 'linear', id: `${n.issue.id}:${root}` }],
      actor: { source: 'linear', id: c.user?.id ?? '' },
      human: Boolean(c.user),                                                   // 没有 user 的评论(如集成写入)不算人
      ...(config.mentionTypes.includes(n.type) ? { addressed: 'mention' as const } : {}),
      at: c.createdAt, text: c.body, url: c.url,
      channel: { source: 'linear', thread: `linear:${n.issue.id}:${root}`, address: { issueId: n.issue.id, parentId: root, url: c.url } },
      meta: { issueId: n.issue.id, identifier: n.issue.identifier, team: n.issue.team.key, root, commentId: c.id },
    })
  }
  ctx.state.set(KV.linear.cursor, cursorAt(started))                           // src/sources/poll.ts:往前留 5 秒余量
}

/** 由 message.id 推出一个固定的 UUID v4 格式 id;commentCreate 接受客户端指定 id,用它做幂等。 */
const uuidFor = (s: string): string => {
  const h = createHash('sha256').update(s).digest('hex')
  return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${(8 + (parseInt(h[16]!, 16) & 3)).toString(16)}${h.slice(17, 20)}-${h.slice(20, 32)}`
}

export async function sendLinear(host: Pick<SourceContext, 'commands'>, message: OutboundMessage): Promise<'sent' | 'gone'> {
  const { issueId, parentId } = message.channel.address
  if (!issueId) throw new Error('Missing Linear reply target')
  const id = uuidFor(message.id)
  // 先查这条评论是否已经发过(上次可能在对方收到后才失败)
  const seen = await linear<{ comment: { id: string } | null }>(host, 'query($id: String!) { comment(id: $id) { id } }', { id })
    .catch(() => ({ comment: null }))
  if (seen.comment) return 'sent'
  await linear(host, `mutation($input: CommentCreateInput!) { commentCreate(input: $input) { success } }`,
    { input: { id, issueId, ...(parentId ? { parentId } : {}), body: message.text } })
  return 'sent'
  // 未处理:Issue 被删除时应返回 'gone',需要先确认 Linear 对应的错误形态
}

scope 按 meta.team 是否在 teams 里判断;threadContext 可以用 comment(id: root) { body user { id } createdAt children(first: 50, after: $cursor) { nodes { ... } pageInfo { hasNextPage endCursor } } } 读根评论和回复;policy.allowed 填 Linear user ID(可以用 users 查询,或在 Linear 里 Cmd/Ctrl+K → “Copy model UUID”)。这些写法和 Slack 骨架一样,就不重复了。

注:notifications 的 type 是字符串,schema 里没有枚举,官方文档也没有列出取值,所以骨架里把它做成了配置项 mentionTypes,由使用者自己查。commentCreate 的 id 参数在 schema 里写的是 “The identifier in UUID v4 format. If none is provided, the backend will generate one.”,用它做幂等是我的设计,没有验证过重复 id 时的报错。

路线 B:Linear Agent + Webhook 中转(要点)

Linear 官方给 agent 的接入方式(Linear Developers → Agents):

  1. 创建一个 OAuth Application,打开 webhooks,勾选 Agent session events。
  2. 安装时在 OAuth 授权地址里加 actor=app,scope 加 app:mentionable(可以被 @)、app:assignable(可以被委派 Issue)。用 actor=app 安装需要工作区管理员完成。
  3. 有人 @ 它或把 Issue 委派给它时,Linear 发 AgentSessionEvent 的 created 事件,里面有 agentSession、promptContext 等字段;用户追加消息时发 prompted。
  4. 回复用 agentActivityCreate,content.type 用 thought、response、error 等。

和 dsh-superagent 的结合方式,可以照 Cookbook 的 hookq 例子:一个公网 HTTPS 中转服务负责验签,把事件写进队列;dsh-superagent 的 Source 从队列里拉。 中转服务有两个硬要求:

  • 5 秒内返回 200,否则 Linear 会重试(1 分钟、1 小时、6 小时后各一次),持续失败可能被禁用。
  • 收到 created 后 10 秒内要发一个 activity(或设置 external URL),否则会话会被标成 unresponsive。dsh-superagent 从评估到接单可能远不止 10 秒,所以这个“已收到”的 thought 最好由中转服务直接发。这意味着中转服务也要持有 OAuth token。

Webhook 验签按 Linear 官方示例:对原始请求体做 HMAC-SHA256(密钥是 webhook 的 signing secret),和 Linear-Signature 头比对,再检查 webhookTimestamp 在 60 秒以内:

// 摘自 Linear 官方 Webhooks 文档的校验逻辑(略作精简)
import crypto from 'node:crypto'
function verifyLinear(rawBody: Buffer, headerSignature: string | undefined, secret: string): boolean {
  if (typeof headerSignature !== 'string') return false
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest()
  const given = Buffer.from(headerSignature, 'hex')
  return given.length === expected.length && crypto.timingSafeEqual(expected, given)
}
// 验签通过后:if (Math.abs(Date.now() - body.webhookTimestamp) > 60_000) 拒绝,防重放

Linear 文档还建议校验来源 IP(官方 Security 页面列了出站地址)。Slack 这边如果你不用 Socket Mode、改用 HTTP Events API,也要做同样的事:Slack 用 signing secret 对 v0:<X-Slack-Request-Timestamp>:<原始请求体> 做 HMAC-SHA256,和 X-Slack-Signature 比对,时间戳超过 5 分钟的请求直接丢弃。

五、安全:白名单、签名和提示词注入

自己写 Slack / Linear Source 的安全清单:白名单只填稳定 ID,机器人消息不能派活,Webhook 一定验签,防提示词注入,令牌不交给 worker,单独 OS 用户或 VM 隔离并最小化权限

  1. 白名单用平台的稳定 ID,不用名字。 Slack 填 user ID(U…/W…),Linear 填 user UUID。显示名可以改,ID 不会变。白名单为空时任何请求都不会变成 Ticket,这是 dsh-superagent 的默认行为,先只放你自己。
  2. 机器人发的消息不能派活。 Slack 消息带 bot_id 时设 human: false;Linear 评论没有 user 时同样处理。docs/008 写明,被列出的机器人最多成为“推断候选”,不能发起明确请求。
  3. 消息内容只是输入。 docs/008:“Both meta and text are untrusted data, not authorization.” Slack 线程和 Linear 评论里任何人都能写字,worker 读上下文时都可能读到“忽略之前的指令”之类的话。这和 AGENTS.md 投毒 是同一类风险。收紧白名单,保持主动工作的默认 observe,auto-merge 先关。
  4. Webhook 一定要验签。 Linear 用 Linear-Signature 加 60 秒时间窗,Slack HTTP 模式用 X-Slack-Signature 加 5 分钟时间窗,都要用原始请求体计算,比较用常量时间。Socket Mode 不需要验签。
  5. 凭据别给 worker。 令牌放在 chmod 600 的文件里,通过包装脚本使用,不要写进 dsh 的设置或 DSH_SUPERAGENT_* 变量(docs/008:“never include credentials”)。setup 会去掉名字里带 KEY、PASSWORD、SECRET、TOKEN 的环境变量,但 docs/006 也提醒了:worker 以 danger-full-access 运行,能读你的 home 目录。真正的隔离靠单独的 OS 用户或 VM。另外,把 slack-bot、slack、linear-agent 加进 Execution 的 blocked commands,免得 worker 自己去发消息。
  6. 权限最小化。 Slack 不用私有频道就别加 groups:history,不需要往没加入的频道发消息就别加 chat:write.public;Linear 路线 B 不要申请 admin(actor=app 本来也不允许)。Slack Connect 这类有外部成员的频道,不要加进接收列表。

六、怎么选

情况 建议
团队主要在 GitHub 协作 只用内置 GitHub Source,不用写代码
团队用飞书或 Lark 用内置飞书 Source(Lark 加 --brand lark)
团队在 Slack,想让大家 @ 它派活 按本文写 Slack Source,用 Socket Mode,不需要公网
只想让自己的 DSH 会话能发 Slack 用社区 DSH 工具插件就够了,不需要 superagent
团队用 Linear,先小范围试 路线 A:专用成员 + 60 秒以上轮询
团队用 Linear,准备长期用 路线 B:Linear Agent + 公网中转 + 验签
没有 TypeScript 维护能力 先别接,它是研究原型,接口还会变

最后

Slack 和 Linear 都不是 dsh-superagent 官方支持的 Source。好在 docs/008 把扩展接口写得很完整,Slack 有 Socket Mode,接起来和飞书差不多;Linear 没有长连接,要么接受轮询,要么搭一个公网中转。不管走哪条路,Cookbook 里几条规矩都要守住:id 要稳定、发送要幂等、默认什么都不接收、消息内容不算授权。

一句话:dsh-superagent 官方没有 Slack 和 Linear Source,要按 docs/008 自己写。Slack 用 Socket Mode 加 app_mention,Linear 用专用成员轮询或 Agent 加 Webhook 中转;白名单只填稳定 ID,Webhook 必须验签,令牌放在包装脚本里,不交给 worker。

还没部署 dsh-superagent 的话,先看 GitHub 部署指南;团队用 Lark,可以看 Lark 接入教程;用飞书的话,看 飞书接入教程(官方步骤在 docs/006 的 Feishu bot 一节),Lark 教程里也列了两者的差异。DSH 配 DeepSeek 模型的实际体验,见 DeepSeek V4.1 Flash 接进 OMP 和 DSH;模型账单相关,见 DeepSeek V4.1 Flash 1017 接入 Claude Code:配置与价格。

参考来源

  1. dsh-superagent-prototype 仓库与 README:https://github.com/deepseek-ai/dsh-superagent-prototype
  2. docs/008-cookbook.md(Add a Source):https://github.com/deepseek-ai/dsh-superagent-prototype/blob/main/docs/008-cookbook.md
  3. docs/006-deployment.md(权限与隔离、setup checks):https://github.com/deepseek-ai/dsh-superagent-prototype/blob/main/docs/006-deployment.md
  4. Source 接口 src/sources/kind.ts 与 host.ts:https://github.com/deepseek-ai/dsh-superagent-prototype/tree/main/src/sources
  5. Slack · Using Socket Mode:https://docs.slack.dev/apis/events-api/using-socket-mode
  6. Slack · apps.connections.open:https://docs.slack.dev/reference/methods/apps.connections.open
  7. Slack · connections:write scope:https://docs.slack.dev/reference/scopes/connections.write
  8. Slack · app_mention 事件:https://docs.slack.dev/reference/events/app_mention
  9. Slack · chat.postMessage:https://docs.slack.dev/reference/methods/chat.postMessage
  10. Slack · conversations.replies:https://docs.slack.dev/reference/methods/conversations.replies
  11. Slack · auth.test:https://docs.slack.dev/reference/methods/auth.test
  12. Slack CLI · slack api 命令:https://docs.slack.dev/tools/slack-cli/reference/commands/slack_api
  13. Slack CLI 发布记录(v4.1.0 新增 slack api,v4.12.0 修复 GET 参数):https://github.com/slackapi/slack-cli/releases
  14. Slack · Verifying requests from Slack:https://docs.slack.dev/authentication/verifying-requests-from-slack
  15. Slack · 非 Marketplace 应用限流变更(2025-05-29)与澄清(2025-06-03):https://docs.slack.dev/changelog/2025/05/29/rate-limit-changes-for-non-marketplace-apps 、https://docs.slack.dev/changelog/2025/06/03/rate-limits-clarity
  16. Linear · GraphQL API 入门(认证方式):https://linear.app/developers/graphql
  17. Linear · Rate limiting:https://linear.app/developers/rate-limiting
  18. Linear · Webhooks(签名校验):https://linear.app/developers/webhooks
  19. Linear · Agents(actor=app、app:mentionable、app:assignable):https://linear.app/developers/agents
  20. Linear · Agent interaction(AgentSessionEvent、agentActivityCreate):https://linear.app/developers/agent-interaction
  21. Linear GraphQL schema(CommentCreateInput、notifications):https://raw.githubusercontent.com/linear/linear/refs/heads/master/packages/sdk/src/schema.graphql
  22. Node.js 22 全局 WebSocket:https://nodejs.org/docs/latest-v22.x/api/globals.html
  23. 社区 DSH 插件 dsh-slack(工具插件,非 superagent Source):https://github.com/STARDUSTLC666/dsh-slack

相关阅读:

Comments

  • Loading comments…

Comments are posted immediately and emailed to the site owner. No account needed.