@miphamai/cli 0.52.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.52.0",
3
+ "version": "0.54.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -0,0 +1,92 @@
1
+ ---
2
+ name: save-to-wiki
3
+ description: Save the current conversation, an insight, or a decision into the Obsidian wiki vault (~/MiphamAI) as a structured note. Analyzes the chat, picks a note type (synthesis/concept/source/decision/session), writes it via the Obsidian MCP, and leaves a memory pointer back. Use when the user types /save, says "save this to the wiki", "file this", "keep this insight", or wants a decision/concept archived.
4
+ version: 1.0.0
5
+ ---
6
+
7
+ # Save to Wiki
8
+
9
+ Good answers and insights shouldn't disappear into chat history. This skill files the most valuable content from the current conversation into the user's Obsidian wiki as a permanent, searchable note.
10
+
11
+ The wiki compounds. Save often.
12
+
13
+ ## Transport
14
+
15
+ Writes go through the Obsidian MCP server (`obsidian` in `~/.mipham/mcp.json`), exposed as tools prefixed `mcp__obsidian__`:
16
+
17
+ - `mcp__obsidian__create_note` — create a new note (target path + markdown body)
18
+ - `mcp__obsidian__append_note` — append to an existing note
19
+ - `mcp__obsidian__get_file` / `mcp__obsidian__list_files` — check whether a note already exists
20
+ - `mcp__obsidian__set_property` — update frontmatter properties
21
+
22
+ If a tool name is unfamiliar, run `/mcp` to list the connected Obsidian tools and use the exact names. Avoid `get_vault_info` — it has a known upstream bug (`Command "vault" not found`) and is not needed for writing.
23
+
24
+ ## Note Type Decision
25
+
26
+ Pick the best type from the conversation content. If the user specifies a type, use it.
27
+
28
+ | Type | Folder (`wiki/`) | Use when |
29
+ | --------- | ---------------- | -------------------------------------------------------- |
30
+ | synthesis | `questions/` | Multi-step analysis, comparison, or answer to a question |
31
+ | concept | `concepts/` | Explaining or defining an idea, pattern, or framework |
32
+ | source | `sources/` | Summary of external material discussed in the session |
33
+ | decision | `meta/` | Architectural, project, or strategic decision made |
34
+ | session | `sessions/` | Full session summary — captures everything discussed |
35
+
36
+ When in doubt, use `synthesis`.
37
+
38
+ ## Frontmatter
39
+
40
+ All note types share this base frontmatter (aligns with the vault's `_templates/`):
41
+
42
+ ```yaml
43
+ ---
44
+ type: <synthesis|concept|source|decision|session>
45
+ title: 'Note Title'
46
+ created: YYYY-MM-DD
47
+ updated: YYYY-MM-DD
48
+ tags:
49
+ - <relevant-tag>
50
+ status: developing
51
+ related:
52
+ - '[[Any Wiki Page Mentioned]]'
53
+ sources: []
54
+ saved_from: Mipham Code
55
+ mipham_memory: <memory-slug>
56
+ ---
57
+ ```
58
+
59
+ - `synthesis` adds: `question: "<original query>"`, `answer_quality: solid`
60
+ - `decision` adds: `decision_date: YYYY-MM-DD`
61
+ - `saved_from` and `mipham_memory` implement the light two-way bridge (see below).
62
+
63
+ ## Workflow
64
+
65
+ 1. **Scan** the conversation and identify the single most valuable content to preserve — an insight, a decision with rationale, or a synthesis. If the conversation is trivial (mechanical Q&A, setup steps already documented, temp debugging), say so and skip.
66
+ 2. **Determine** the note type using the table. Respect an explicit type/title from the user.
67
+ 3. **Name** the note — short and descriptive; ask the user if not already named.
68
+ 4. **Check existence** — use `list_files`/`get_file` to see whether `wiki/<folder>/<title>.md` already exists. If it does, offer to update (`append_note` or rewrite) instead of duplicating.
69
+ 5. **Write** the note via `create_note` (path `wiki/<folder>/<title>.md`) with full frontmatter and a declarative, present-tense body.
70
+ 6. **Leave a memory pointer** — write a `reference` memory via the Memory tool (`action=write`, `name=wiki-<title-slug>`) whose body records the wiki note path and a one-line summary. This lets `/memory` and recall surface the wiki note.
71
+ 7. **Update** `wiki/index.md` (add the note to the relevant section) and `wiki/log.md` (prepend `## [YYYY-MM-DD] save | Note Title`). Refresh `wiki/hot.md` if it tracks recent additions.
72
+
73
+ ## Light Two-Way Bridge
74
+
75
+ - **memory → wiki**: the pointer memory (step 6) stores the wiki path, so memory recall can link back to the note.
76
+ - **wiki → memory**: the note's frontmatter carries `saved_from: Mipham Code` and `mipham_memory: <slug>` — plain strings, not wikilinks, so they don't create broken links in Obsidian.
77
+
78
+ This is a one-way pointer plus a provenance back-reference, not a sync layer. Do not attempt bidirectional synchronization.
79
+
80
+ ## Writing Style
81
+
82
+ - Declarative, present tense. Write the knowledge, not the conversation.
83
+ - Not: "The user asked about X and Claude explained..."
84
+ - Yes: "X works by doing Y. The key insight is Z."
85
+ - Link mentioned concepts/entities/wiki pages with `[[wikilinks]]`.
86
+ - Cite sources where applicable: `(Source: [[Page]])`.
87
+
88
+ ## What to Save vs. Skip
89
+
90
+ **Save**: non-obvious insights, decisions with rationale, analyses that took real effort, comparisons likely to be referenced again, research findings.
91
+
92
+ **Skip**: mechanical Q&A, setup steps already documented, temporary debugging with no lasting insight, anything already in the wiki (update instead of duplicating).
@@ -250,17 +250,26 @@ export class AgentViewManager {
250
250
  return true
251
251
  }
252
252
 
253
+ /**
254
+ * Permanently remove a single session (any status). Returns true if found.
255
+ */
256
+ remove(id: string): boolean {
257
+ if (!this.sessions.has(id)) return false
258
+ this.sessions.delete(id)
259
+ this.sessionOrder = this.sessionOrder.filter((oid) => oid !== id)
260
+ return true
261
+ }
262
+
253
263
  /**
254
264
  * Remove all completed and failed sessions (cleanup).
255
265
  */
256
266
  prune(): number {
267
+ const terminalIds = Array.from(this.sessions.entries())
268
+ .filter(([, session]) => session.status === 'completed' || session.status === 'failed')
269
+ .map(([id]) => id)
257
270
  let removed = 0
258
- for (const [id, session] of this.sessions) {
259
- if (session.status === 'completed' || session.status === 'failed') {
260
- this.sessions.delete(id)
261
- this.sessionOrder = this.sessionOrder.filter((oid) => oid !== id)
262
- removed++
263
- }
271
+ for (const id of terminalIds) {
272
+ if (this.remove(id)) removed++
264
273
  }
265
274
  return removed
266
275
  }
@@ -32,6 +32,8 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
32
32
  const [peekingSessionId, setPeekingSessionId] = useState<string | null>(null)
33
33
  const [groupBy, setGroupBy] = useState<'status' | 'directory'>('status')
34
34
  const [feedback, setFeedback] = useState<string | null>(null)
35
+ // Bump to force flatList recompute after a session is removed (list membership change).
36
+ const [version, setVersion] = useState(0)
35
37
 
36
38
  // Flash a brief feedback message that auto-clears
37
39
  const showFeedback = useCallback((msg: string) => {
@@ -87,7 +89,7 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
87
89
  }
88
90
 
89
91
  return result
90
- }, [manager, groupBy])
92
+ }, [manager, groupBy, version])
91
93
 
92
94
  // Flatten sessions only for navigation (skip headers)
93
95
  const sessionsOnly = useMemo(
@@ -140,6 +142,22 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
140
142
  return
141
143
  }
142
144
 
145
+ // Ctrl+X — permanently remove the selected session
146
+ if (key.ctrl && input === 'x') {
147
+ if (sessionsOnly.length === 0) {
148
+ showFeedback('No sessions to remove')
149
+ return
150
+ }
151
+ const current = sessionsOnly[selectedIndex]
152
+ if (!current) return
153
+ manager.remove(current.session.id)
154
+ setPeekingSessionId(null)
155
+ setSelectedIndex((prev) => Math.max(0, Math.min(prev, sessionsOnly.length - 2)))
156
+ setVersion((v) => v + 1)
157
+ showFeedback(`Removed ${current.session.title || current.session.id}`)
158
+ return
159
+ }
160
+
143
161
  if (input === 'j') {
144
162
  if (sessionsOnly.length === 0) {
145
163
  showFeedback('No sessions to navigate — spawn a background agent first')
@@ -223,7 +241,8 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
223
241
  </Box>
224
242
  <Box>
225
243
  <Text dimColor>
226
- j/k navigate · Space peek · Enter attach · Ctrl+T group · Ctrl+R rename · Esc back
244
+ j/k navigate · Space peek · Enter attach · Ctrl+T group · Ctrl+R rename · Ctrl+X remove
245
+ · Esc back
227
246
  </Text>
228
247
  </Box>
229
248
  </Box>
@@ -0,0 +1,35 @@
1
+ import { existsSync, readdirSync } from 'node:fs'
2
+ import { dirname, basename, join } from 'node:path'
3
+
4
+ /**
5
+ * Suggest existing directories that match a partial `/cd` target.
6
+ *
7
+ * `path` must already be tilde-expanded and resolved by the caller (`/cd`
8
+ * passes the `resolve()`d target). Given a path that does not (fully) exist,
9
+ * walk up to its nearest existing ancestor and return the subdirectories whose
10
+ * names prefix-match the trailing segment. Files and non-matching entries are
11
+ * excluded. Returns `[]` when the path already exists or nothing matches.
12
+ */
13
+ export function suggestDirectories(path: string): string[] {
14
+ if (existsSync(path)) return []
15
+
16
+ let ancestor = dirname(path)
17
+ while (!existsSync(ancestor)) {
18
+ const parent = dirname(ancestor)
19
+ if (parent === ancestor) return [] // hit filesystem root
20
+ ancestor = parent
21
+ }
22
+
23
+ const prefix = basename(path)
24
+ let entries
25
+ try {
26
+ entries = readdirSync(ancestor, { withFileTypes: true })
27
+ } catch {
28
+ return []
29
+ }
30
+
31
+ return entries
32
+ .filter((e) => e.isDirectory() && e.name.startsWith(prefix))
33
+ .map((e) => join(ancestor, e.name))
34
+ .sort()
35
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * CLAUDE.md audit — find sections whose content the model can infer from the
3
+ * codebase itself (directory structure, tech stack, dependencies, commit
4
+ * history, project/submodule catalogs, test counts). Such sections burn
5
+ * context tokens every session and go stale as the code changes; they are
6
+ * candidates for the `prompt-exclude` frontmatter.
7
+ */
8
+
9
+ export type DerivableReason =
10
+ 'structure' | 'tech-stack' | 'dependencies' | 'commits' | 'catalog' | 'tests'
11
+
12
+ export interface DerivableSection {
13
+ heading: string
14
+ reason: DerivableReason
15
+ }
16
+
17
+ /** Human-readable hint for each derivable category (diagnostic output). */
18
+ export const DERIVABLE_HINTS: Record<DerivableReason, string> = {
19
+ structure: 'directory/file structure — inferable from the repo itself',
20
+ 'tech-stack': 'tech stack — inferable from package.json / config',
21
+ dependencies: 'dependencies — inferable from package.json',
22
+ commits: 'commit/revision history — inferable from git log',
23
+ catalog: 'project/submodule catalog — inferable from .gitmodules / directories',
24
+ tests: 'test counts — inferable from running the suite',
25
+ }
26
+
27
+ const PATTERNS: Array<{ pattern: RegExp; reason: DerivableReason }> = [
28
+ {
29
+ pattern:
30
+ /目录结构|项目结构|文件结构|directory structure|project structure|file structure|repo structure|monorepo/i,
31
+ reason: 'structure',
32
+ },
33
+ { pattern: /技术栈|tech\s*stack|technology\s*stack/i, reason: 'tech-stack' },
34
+ { pattern: /依赖关系|依赖清单|dependencies/i, reason: 'dependencies' },
35
+ {
36
+ pattern: /最近提交|recent\s*commit|修订历史|revision\s*history|changelog|变更记录|变更历史/i,
37
+ reason: 'commits',
38
+ },
39
+ {
40
+ pattern: /项目一览|项目清单|项目列表|子模块清单|submodule\s*(list|catalog)|catalog/i,
41
+ reason: 'catalog',
42
+ },
43
+ { pattern: /测试矩阵|test\s*matrix|测试覆盖|coverage/i, reason: 'tests' },
44
+ ]
45
+
46
+ /**
47
+ * Return `##`/`###` headings whose title matches a derivable-content pattern,
48
+ * in document order. Headings without a match are ignored.
49
+ */
50
+ export function findDerivableSections(content: string): DerivableSection[] {
51
+ const found: DerivableSection[] = []
52
+ for (const line of content.split('\n')) {
53
+ const m = line.match(/^(#{2,3})\s+(.+?)\s*$/)
54
+ if (!m) continue
55
+ const heading = m[2]!.trim()
56
+ for (const { pattern, reason } of PATTERNS) {
57
+ if (pattern.test(heading)) {
58
+ found.push({ heading, reason })
59
+ break
60
+ }
61
+ }
62
+ }
63
+ return found
64
+ }
@@ -0,0 +1,4 @@
1
+ /** 指数退避,封顶 30s。三频道(telegram/wecom/dingtalk)重连共享。 */
2
+ export function nextBackoff(currentMs: number): number {
3
+ return Math.min(currentMs * 2, 30_000)
4
+ }
@@ -0,0 +1,59 @@
1
+ import type { SessionManager } from './session-manager'
2
+ import type { SessionWorker } from './session-worker'
3
+ import type { RateLimiter } from './rate-limiter'
4
+
5
+ export interface ChannelMessageOptions {
6
+ channel: string // 'feishu' | 'telegram' | 'wecom' | 'dingtalk'
7
+ externalId: string // openId / chatId / userId
8
+ text: string
9
+ allowed: Set<string>
10
+ rateLimiter: RateLimiter
11
+ sm: SessionManager
12
+ getOrCreateWorker: (sessionId: string) => SessionWorker | null
13
+ cwd: string
14
+ provider: string
15
+ model: string
16
+ sendText: (externalId: string, text: string) => Promise<void>
17
+ maxLen: number // 飞书 4000 / Telegram 4096 / 企微 2048 / 钉钉 2000
18
+ logPrefix: string // '[feishu]' / '[telegram]' / '[wecom]'
19
+ }
20
+
21
+ /** 四频道共享的消息处理骨架:白名单→限流→会话→processPrompt→回发。 */
22
+ export async function handleChannelMessage(opts: ChannelMessageOptions): Promise<void> {
23
+ const {
24
+ channel,
25
+ externalId,
26
+ text,
27
+ allowed,
28
+ rateLimiter,
29
+ sm,
30
+ getOrCreateWorker,
31
+ cwd,
32
+ provider,
33
+ model,
34
+ sendText,
35
+ maxLen,
36
+ logPrefix,
37
+ } = opts
38
+ try {
39
+ if (!allowed.has(externalId)) return
40
+ if (!rateLimiter.check(`${channel}:${externalId}`).allowed) return
41
+
42
+ const session = sm.getOrCreateByExternalUser(channel, externalId, cwd, provider, model)
43
+ const worker = getOrCreateWorker(session.id)
44
+ if (!worker) {
45
+ await sendText(externalId, '(会话初始化失败,请稍后重试)')
46
+ return
47
+ }
48
+ await worker.processPrompt(text)
49
+ const result = worker.getLastAssistantContent()
50
+ await sendText(externalId, result ? result.slice(0, maxLen) : '(无回复)')
51
+ } catch (err) {
52
+ console.error(`${logPrefix} message handling failed:`, err)
53
+ try {
54
+ await sendText(externalId, '(处理失败,请稍后重试)')
55
+ } catch {
56
+ /* 忽略回送失败,不 rethrow */
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,62 @@
1
+ import type { DingtalkApi } from './api.js'
2
+ import type { DingtalkConfig, DingtalkMessage } from './types.js'
3
+ import type { SessionManager } from '../session-manager'
4
+ import type { SessionWorker } from '../session-worker'
5
+ import type { RateLimiter } from '../rate-limiter'
6
+ import { startDingtalkWs } from './ws-client.js'
7
+ import { handleChannelMessage } from '../channel-message.js'
8
+
9
+ export interface DingtalkAdapterDeps {
10
+ sm: SessionManager
11
+ getOrCreateWorker: (sessionId: string) => SessionWorker | null
12
+ rateLimiter: RateLimiter
13
+ cwd: string
14
+ provider: string
15
+ model: string
16
+ }
17
+
18
+ export interface DingtalkAdapter {
19
+ start(): () => void
20
+ handleMessage(msg: DingtalkMessage): Promise<void>
21
+ isAllowed(staffId: string): boolean
22
+ }
23
+
24
+ export function createDingtalkAdapter(
25
+ config: DingtalkConfig,
26
+ api: DingtalkApi,
27
+ deps: DingtalkAdapterDeps,
28
+ ): DingtalkAdapter {
29
+ const allowed = new Set(config.allowedStaffIds)
30
+ let stopWs: (() => void) | null = null
31
+
32
+ async function handleMessage(msg: DingtalkMessage): Promise<void> {
33
+ await handleChannelMessage({
34
+ channel: 'dingtalk',
35
+ externalId: msg.staffId,
36
+ text: msg.text,
37
+ allowed,
38
+ rateLimiter: deps.rateLimiter,
39
+ sm: deps.sm,
40
+ getOrCreateWorker: deps.getOrCreateWorker,
41
+ cwd: deps.cwd,
42
+ provider: deps.provider,
43
+ model: deps.model,
44
+ // 钉钉回发走每条消息自带的 sessionWebhook(非持久 userId 路由)
45
+ sendText: async (_staffId, text) => {
46
+ await api.reply(msg.sessionWebhook, text)
47
+ },
48
+ maxLen: 2000,
49
+ logPrefix: '[dingtalk]',
50
+ })
51
+ }
52
+
53
+ return {
54
+ handleMessage,
55
+ isAllowed: (staffId) => allowed.has(staffId),
56
+ start() {
57
+ if (stopWs) return stopWs
58
+ stopWs = startDingtalkWs(api, handleMessage)
59
+ return stopWs
60
+ },
61
+ }
62
+ }
@@ -0,0 +1,120 @@
1
+ import type { DingtalkConfig, DingtalkMessage } from './types.js'
2
+
3
+ const GATEWAY_URL = 'https://api.dingtalk.com/v1.0/gateway/connections/open'
4
+
5
+ export interface DingtalkApi {
6
+ register(): Promise<{ endpoint: string; ticket: string }>
7
+ open(endpoint: string, ticket: string): WebSocket
8
+ reply(sessionWebhook: string, text: string): Promise<void>
9
+ parseMessage(frame: unknown): DingtalkMessage | null
10
+ isPing(frame: unknown): boolean
11
+ ack(ws: WebSocket, frame: unknown): void
12
+ pong(ws: WebSocket, frame: unknown): void
13
+ }
14
+
15
+ /** 回帧信封:code 200 + 回显 headers.messageId。ack 与 pong 共用。 */
16
+ function sendResponse(ws: WebSocket, frame: unknown, data: unknown): void {
17
+ const f = frame as { headers?: { messageId?: string } }
18
+ ws.send(
19
+ JSON.stringify({
20
+ code: 200,
21
+ headers: { contentType: 'application/json', messageId: f?.headers?.messageId ?? '' },
22
+ message: 'OK',
23
+ data,
24
+ }),
25
+ )
26
+ }
27
+
28
+ /**
29
+ * 钉钉 Stream Mode 协议 codec。零依赖:HTTP 用裸 fetch(register + 回发),
30
+ * WebSocket 用 globalThis.WebSocket(Node 22+ / Bun 原生)。ticket 一次性且
31
+ * 90s 过期,由 ws-client 每次重连前重新 register。
32
+ */
33
+ export function createDingtalkApi(
34
+ config: DingtalkConfig,
35
+ fetchImpl: typeof fetch = fetch,
36
+ ): DingtalkApi {
37
+ return {
38
+ async register() {
39
+ const res = await fetchImpl(GATEWAY_URL, {
40
+ method: 'POST',
41
+ headers: { 'Content-Type': 'application/json' },
42
+ body: JSON.stringify({
43
+ clientId: config.clientId,
44
+ clientSecret: config.clientSecret,
45
+ subscriptions: [{ type: 'CALLBACK', topic: '/v1.0/im/bot/messages/get' }],
46
+ ua: 'mipham-code',
47
+ }),
48
+ })
49
+ if (!res.ok) throw new Error(`dingtalk gateway register ${res.status}`)
50
+ const data = (await res.json()) as { endpoint?: string; ticket?: string }
51
+ if (!data.endpoint || !data.ticket) {
52
+ throw new Error('dingtalk gateway: missing endpoint/ticket')
53
+ }
54
+ return { endpoint: data.endpoint, ticket: data.ticket }
55
+ },
56
+
57
+ open(endpoint, ticket) {
58
+ return new WebSocket(`${endpoint}?ticket=${encodeURIComponent(ticket)}`)
59
+ },
60
+
61
+ async reply(sessionWebhook, text) {
62
+ if (!sessionWebhook) return
63
+ const res = await fetchImpl(sessionWebhook, {
64
+ method: 'POST',
65
+ headers: { 'Content-Type': 'application/json' },
66
+ body: JSON.stringify({ msgtype: 'text', text: { content: text } }),
67
+ })
68
+ if (!res.ok) throw new Error(`dingtalk reply ${res.status}`)
69
+ const data = (await res.json()) as { errcode?: number; errmsg?: string }
70
+ if (data.errcode != null && data.errcode !== 0) {
71
+ throw new Error(`dingtalk reply errcode=${data.errcode}: ${data.errmsg ?? 'unknown'}`)
72
+ }
73
+ },
74
+
75
+ parseMessage(frame) {
76
+ if (!frame || typeof frame !== 'object') return null
77
+ const f = frame as { type?: string; data?: unknown }
78
+ if (f.type !== 'CALLBACK' || typeof f.data !== 'string') return null
79
+ let payload: {
80
+ msgtype?: string
81
+ text?: { content?: string }
82
+ senderStaffId?: string
83
+ senderId?: string
84
+ conversationId?: string
85
+ msgId?: string
86
+ sessionWebhook?: string
87
+ }
88
+ try {
89
+ payload = JSON.parse(f.data)
90
+ } catch {
91
+ return null
92
+ }
93
+ if (payload?.msgtype !== 'text') return null
94
+ const text = payload?.text?.content?.trim()
95
+ if (!text) return null
96
+ const staffId = payload?.senderStaffId ?? payload?.senderId
97
+ if (!staffId) return null
98
+ return {
99
+ staffId,
100
+ conversationId: payload?.conversationId ?? '',
101
+ msgId: payload?.msgId ?? '',
102
+ text,
103
+ sessionWebhook: payload?.sessionWebhook ?? '',
104
+ }
105
+ },
106
+
107
+ isPing(frame) {
108
+ const f = frame as { type?: string; headers?: { topic?: string } }
109
+ return f?.type === 'SYSTEM' && f?.headers?.topic === 'ping'
110
+ },
111
+
112
+ ack(ws, frame) {
113
+ sendResponse(ws, frame, '{"response": null}')
114
+ },
115
+
116
+ pong(ws, frame) {
117
+ sendResponse(ws, frame, (frame as { data?: unknown }).data)
118
+ },
119
+ }
120
+ }
@@ -0,0 +1,16 @@
1
+ import type { DingtalkConfig } from './types.js'
2
+
3
+ /** fail-closed:缺 clientId 或 clientSecret → null(daemon 不启用钉钉)。 */
4
+ export function parseDingtalkEnv(): DingtalkConfig | null {
5
+ const clientId = process.env.DINGTALK_CLIENT_ID
6
+ const clientSecret = process.env.DINGTALK_CLIENT_SECRET
7
+ if (!clientId || !clientSecret) return null
8
+ return {
9
+ clientId,
10
+ clientSecret,
11
+ allowedStaffIds: (process.env.DINGTALK_ALLOWED_STAFF_IDS || '')
12
+ .split(',')
13
+ .map((s) => s.trim())
14
+ .filter(Boolean),
15
+ }
16
+ }
@@ -0,0 +1,13 @@
1
+ export interface DingtalkConfig {
2
+ clientId: string
3
+ clientSecret: string
4
+ allowedStaffIds: string[] // 白名单(钉钉 senderStaffId / senderId)
5
+ }
6
+
7
+ export interface DingtalkMessage {
8
+ staffId: string // 发消息用户(senderStaffId,回退 senderId)
9
+ conversationId: string // 会话 id
10
+ msgId: string // 消息 id(业务侧)
11
+ text: string // 文本内容
12
+ sessionWebhook: string // 回发路由(机器人 sessionWebhook URL)
13
+ }
@@ -0,0 +1,81 @@
1
+ import type { DingtalkApi } from './api.js'
2
+ import type { DingtalkMessage } from './types.js'
3
+ import { nextBackoff } from '../backoff.js'
4
+
5
+ /**
6
+ * 钉钉 Stream 长连接生命周期:register(HTTP 拿 endpoint+ticket)→ 建连 →
7
+ * 服务端 ping/pong → 消息回调(parse→ack→onMessage)→ 断开重连。ticket 一次性
8
+ * 且 90s 过期,故每次重连前必须重新 register。返回 stop。
9
+ */
10
+ export function startDingtalkWs(
11
+ api: DingtalkApi,
12
+ onMessage: (msg: DingtalkMessage) => Promise<void>,
13
+ ): () => void {
14
+ let stopped = false
15
+ let ws: WebSocket | null = null
16
+ let backoffMs = 1000
17
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null
18
+
19
+ function clearReconnect() {
20
+ if (reconnectTimer) clearTimeout(reconnectTimer)
21
+ reconnectTimer = null
22
+ }
23
+
24
+ function scheduleReconnect() {
25
+ if (stopped) return
26
+ reconnectTimer = setTimeout(() => void connect(), backoffMs)
27
+ backoffMs = nextBackoff(backoffMs)
28
+ ;(reconnectTimer as unknown as { unref?: () => void }).unref?.()
29
+ }
30
+
31
+ async function connect() {
32
+ if (stopped) return
33
+ let endpoint: string
34
+ let ticket: string
35
+ try {
36
+ ;({ endpoint, ticket } = await api.register())
37
+ } catch {
38
+ scheduleReconnect()
39
+ return
40
+ }
41
+ if (stopped) return
42
+ ws = api.open(endpoint, ticket)
43
+ ws.onopen = () => {
44
+ backoffMs = 1000
45
+ }
46
+ ws.onmessage = (ev) => {
47
+ let frame: unknown
48
+ try {
49
+ frame = JSON.parse(ev.data as string)
50
+ } catch {
51
+ return
52
+ }
53
+ if (api.isPing(frame)) {
54
+ if (ws) api.pong(ws, frame)
55
+ return
56
+ }
57
+ const msg = api.parseMessage(frame)
58
+ if (msg) {
59
+ if (ws) api.ack(ws, frame)
60
+ void onMessage(msg).catch(() => {})
61
+ }
62
+ }
63
+ ws.onclose = () => {
64
+ ws = null
65
+ scheduleReconnect()
66
+ }
67
+ }
68
+
69
+ void connect()
70
+ return () => {
71
+ stopped = true
72
+ clearReconnect()
73
+ if (ws) {
74
+ try {
75
+ ws.close()
76
+ } catch {
77
+ /* 连接尚未建立时 close 可能抛错,忽略 */
78
+ }
79
+ }
80
+ }
81
+ }