SLOPSHOPPER

feishu-notify

Feishu DMs via lark-cli when a long turn ends or Claude waits on a permission prompt or question

newguardcommandtoastprocesstimer
★ 1v0.1.0no licenseupdated 2026-10-08Jenwein/claude-mods/feishu-notify
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · feishu-notify
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /feishu-test ⎿ feishu-notify: 发送失败:读不到飞书登录信息,请先运行 lark-cli auth login ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

自己写的 Claude Code Mod(函数钩子插件),整个仓库是一个插件市场。终端和桌面 App 的 Code 标签页都能用。

Mod作用
git-band在输入框上方显示当前 git 分支、未提交文件数和 ↑↓ 提交数,每 3 秒刷新
wt-panel/wt 打开当前仓库的 worktree 总览面板:状态、改动、复制路径、VS Code 打开、带风险提醒的删除
feishu-notify长任务结束或出错、权限弹窗或提问等你超过一定时间时,用 lark-cli 的机器人给你发飞书私信;/feishu-test 发测试消息。需要先 lark-cli auth login

安装

在 Claude Code 的终端会话里,每个 Mod 一行(<mod> 换成上表里的名字),第一次会问是否添加这个插件市场,回答 y,再选安装范围:

/plugin install <mod> --marketplace Jenwein/claude-mods

或者在命令行里:

claude plugin install <mod> --marketplace Jenwein/claude-mods

按用户范围安装后,之后的每个会话都会自动加载。

修改

想边改边用,就把本地克隆加成插件市场再安装,插件会直接从这个文件夹读取:

claude plugin marketplace add <克隆所在的路径>
claude plugin install <mod>@claude-mods

改完文件后,在会话里运行 /reload-plugins 就会生效,不用重装。

检查和测试:

claude plugin validate <mod>
claude plugin test <mod>
Source 2 files
hooks/register.tsx 200 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4  describeQuestions,
5  describeTool,
6  doneMessage,
7  failMessage,
8  larkExeFromWhere,
9  parseOpenId,
10  permissionMessage,
11  questionMessage,
12  stableKey,
13} from './notify'
14
15// How often waits are checked and queued messages sent.
16const TICK_MS = 5_000
17
18type Build = (where: string) => string
19
20// A dialog waiting on the person, from when it showed.
21type Wait = { startedAt: number; build: (where: string, waitedMs: number) => string }
22
23type Lark = { argv: string[]; openId: string }
24
25// Waits and queued messages live here and are sent by the session's own timer:
26// work that outlives a hook's dispatch belongs to session.start.
27const waits = new Map<string, Wait>()
28const outbox: Build[] = []
29let lark: Lark | null = null
30let isTicking = false
31
32const firstLine = (text: string) => text.trim().split(/\r?\n/)[0] ?? ''
33
34const minutes = (value: unknown, fallback: number) => (typeof value === 'number' && value > 0 ? value : fallback) * 60_000
35
36// The lark-cli to run and the person to message, found once per session.
37async function larkTarget($: EngineInterface): Promise<Lark | string> {
38  if (lark !== null) {
39    return lark
40  }
41
42  const found = await $.process.run(['where', 'lark-cli']).catch(() => null)
43  const exe = found !== null && found.exitCode === 0 ? larkExeFromWhere(found.stdout) : null
44  const argv = exe !== null && (await $.fs.exists(exe)) ? [exe] : ['lark-cli']
45  const status = await $.process.run([...argv, 'auth', 'status'], { timeoutMs: 30_000 })
46  const openId = parseOpenId(status.stdout)
47
48  if (openId === null) {
49    return '读不到飞书登录信息,请先运行 lark-cli auth login'
50  }
51
52  lark = { argv, openId }
53
54  return lark
55}
56
57// The session's folder, and its branch when it is in a git repository.
58async function place($: EngineInterface) {
59  const cwd = await $.session.cwd()
60  const folder = cwd.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? cwd
61  const branch = await $.process.run(['git', '-C', cwd, 'branch', '--show-current']).catch(() => null)
62  const name = branch !== null && branch.exitCode === 0 ? branch.stdout.trim() : ''
63
64  return name === '' ? folder : `${folder} (${name})`
65}
66
67// Sends one message as the bot to the logged-in user; resolves why it failed,
68// or null.
69async function send($: EngineInterface, build: Build): Promise<string | null> {
70  try {
71    const target = await larkTarget($)
72
73    if (typeof target === 'string') {
74      return target
75    }
76
77    const text = build(await place($))
78    const sent = await $.process.run([...target.argv, 'im', '+messages-send', '--as', 'bot', '--user-id', target.openId, '--markdown', text], {
79      timeoutMs: 30_000,
80    })
81
82    return sent.exitCode === 0 ? null : firstLine(sent.stderr) || firstLine(sent.stdout) || `lark-cli 退出码 ${sent.exitCode}`
83  } catch (error) {
84    return error instanceof Error ? error.message : String(error)
85  }
86}
87
88async function tick($: EngineInterface, waitMs: number) {
89  if (isTicking) {
90    return
91  }
92
93  isTicking = true
94
95  try {
96    const now = await $.clock.now()
97
98    for (const [key, wait] of waits) {
99      if (now - wait.startedAt >= waitMs) {
100        waits.delete(key)
101        outbox.push(where => wait.build(where, now - wait.startedAt))
102      }
103    }
104
105    for (let build = outbox.shift(); build !== undefined; build = outbox.shift()) {
106      const error = await send($, build)
107
108      if (error !== null) {
109        $.ui.toast(`飞书提醒发送失败:${error}`)
110      }
111    }
112  } finally {
113    isTicking = false
114  }
115}
116
117export const register: Register = (on, options) => {
118  const waitMs = minutes(options.waitMinutes, 2)
119  const longMs = minutes(options.longTurnMinutes, 3)
120
121  on('session.start', async ($, e, next) => {
122    const started = await next(e)
123    waits.clear()
124    outbox.length = 0
125    lark = null
126    await $.command.register({ name: 'feishu-test', description: '发一条飞书测试消息,确认提醒能送达' })
127    $.clock.every(TICK_MS, () => void tick($, waitMs))
128
129    return started
130  })
131
132  on('command.run', { command: 'feishu-test' }, async $ => {
133    const error = await send($, where => `🔔 **${where}** 飞书提醒测试:能收到这条,说明链路正常`)
134
135    return { text: error === null ? '已发送测试消息到飞书。' : `发送失败:${error}` }
136  })
137
138  // A permission dialog is about to show, unless a settings hook decided.
139  on('classic.PermissionRequest', async ($, e, next) => {
140    const decided = await next(e)
141
142    if (decided.decision === undefined && decided.block === undefined && e.tool_name !== 'AskUserQuestion') {
143      const tool = e.tool_name
144      const detail = describeTool(tool, e.tool_input)
145      waits.set(stableKey(tool, e.tool_input), {
146        startedAt: await $.clock.now(),
147        build: (where, waited) => permissionMessage(where, waited, tool, detail),
148      })
149    }
150
151    return decided
152  })
153
154  // A call settling ends its dialog: the person answered the question, or
155  // allowed or refused the tool.
156  on('tool.call', async ($, e, next) => {
157    const { tool, tool_use_id, agentId, ...input } = e
158    void agentId
159    const questionKey = `question:${tool_use_id}`
160
161    if (e.tool === 'AskUserQuestion') {
162      const detail = describeQuestions(e.questions)
163      waits.set(questionKey, { startedAt: await $.clock.now(), build: (where, waited) => questionMessage(where, waited, detail) })
164    }
165
166    try {
167      return await next(e)
168    } finally {
169      waits.delete(questionKey)
170      waits.delete(stableKey(tool, input))
171    }
172  })
173
174  on('turn.complete', async ($, e, next) => {
175    const completed = await next(e)
176
177    if (e.agentId !== undefined) {
178      return completed
179    }
180
181    // No dialog outlives the turn that raised it.
182    waits.clear()
183
184    if (e.reason === 'aborted' || e.durationMs < longMs) {
185      return completed
186    }
187
188    if (e.reason === 'answer') {
189      outbox.push(where => doneMessage(where, e.durationMs, e.answer))
190    } else if (e.reason === 'error') {
191      outbox.push(where => failMessage(where, e.durationMs, '本轮出错结束', e.answer))
192    } else if (e.reason === 'refusal') {
193      const explanation = e.refusal.explanation ?? ''
194      outbox.push(where => failMessage(where, e.durationMs, 'Claude 拒绝继续', explanation))
195    }
196
197    return completed
198  })
199}
200
hooks/notify.ts 105 lines
1// What the Feishu messages say, and reading what lark-cli prints. No `$` here.
2
3const ANSWER_CHARS = 300
4const DETAIL_CHARS = 200
5
6export const formatDuration = (ms: number) => {
7  const seconds = Math.round(ms / 1000)
8
9  if (seconds < 60) {
10    return `${seconds} 秒`
11  }
12
13  if (seconds < 3600) {
14    const rest = seconds % 60
15
16    return rest === 0 ? `${Math.floor(seconds / 60)} 分` : `${Math.floor(seconds / 60)} 分 ${rest} 秒`
17  }
18
19  return `${Math.floor(seconds / 3600)} 小时 ${Math.floor((seconds % 3600) / 60)} 分`
20}
21
22const stable = (value: unknown): string => {
23  if (Array.isArray(value)) {
24    return `[${value.map(stable).join(',')}]`
25  }
26
27  if (value !== null && typeof value === 'object') {
28    const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
29
30    return `{${entries.map(([key, inner]) => `${JSON.stringify(key)}:${stable(inner)}`).join(',')}}`
31  }
32
33  return JSON.stringify(value) ?? 'undefined'
34}
35
36// Names one tool call by what it would do: a permission prompt carries no id,
37// so the call it belongs to is found by its tool and input.
38export const stableKey = (tool: string, input: unknown) => `${tool}:${stable(input)}`
39
40export const clip = (text: string, chars: number) => (text.length > chars ? `${text.slice(0, chars)}…` : text)
41
42const field = (input: unknown, name: string) => {
43  const value = input !== null && typeof input === 'object' ? (input as Record<string, unknown>)[name] : undefined
44
45  return typeof value === 'string' ? value : null
46}
47
48export const describeTool = (tool: string, input: unknown) => {
49  const command = field(input, 'command')
50  const file = field(input, 'file_path') ?? field(input, 'notebook_path')
51  const url = field(input, 'url')
52
53  if (command !== null) return clip(`命令:${command}`, DETAIL_CHARS)
54  if (file !== null) return clip(`文件:${file}`, DETAIL_CHARS)
55  if (url !== null) return clip(`网址:${url}`, DETAIL_CHARS)
56
57  return clip(`参数:${JSON.stringify(input)}`, DETAIL_CHARS)
58}
59
60export type Question = { question: string; options: readonly { label: string }[] }
61
62export const describeQuestions = (questions: readonly Question[]) =>
63  questions.map(one => `${one.question}\n选项:${one.options.map(option => option.label).join(' / ')}`).join('\n\n')
64
65const withBody = (head: string, body: string) => (body === '' ? head : `${head}\n\n${body}`)
66
67export const doneMessage = (where: string, durationMs: number, answer: string) =>
68  withBody(`✅ **${where}** 任务完成,用时 ${formatDuration(durationMs)}`, clip(answer.trim(), ANSWER_CHARS))
69
70export const failMessage = (where: string, durationMs: number, how: string, detail: string) =>
71  withBody(`❌ **${where}** ${how},用时 ${formatDuration(durationMs)}`, clip(detail.trim(), ANSWER_CHARS))
72
73export const permissionMessage = (where: string, waitedMs: number, tool: string, detail: string) =>
74  withBody(`⏳ **${where}** Claude 等你批准 ${tool},已等 ${formatDuration(waitedMs)}`, detail)
75
76export const questionMessage = (where: string, waitedMs: number, detail: string) =>
77  withBody(`❓ **${where}** Claude 有问题等你回答,已等 ${formatDuration(waitedMs)}`, detail)
78
79// `lark-cli auth status` prints JSON naming the logged-in user's open_id.
80export const parseOpenId = (stdout: string): string | null => {
81  try {
82    const status = JSON.parse(stdout) as { identities?: { user?: { openId?: unknown } } }
83    const openId = status.identities?.user?.openId
84
85    return typeof openId === 'string' && openId !== '' ? openId : null
86  } catch {
87    return null
88  }
89}
90
91// npm installs lark-cli as shims beside node; the native binary they launch
92// sits in the package. Running it directly keeps cmd.exe from mangling the
93// message's newlines and quotes.
94export const larkExeFromWhere = (stdout: string): string | null => {
95  const shim = stdout.split(/\r?\n/).find(line => line.trim() !== '')
96
97  if (shim === undefined) {
98    return null
99  }
100
101  const dir = shim.trim().replace(/[\\/][^\\/]*$/, '')
102
103  return `${dir}\\node_modules\\@larksuite\\cli\\bin\\lark-cli.exe`
104}
105