@miphamai/cli 0.53.0 → 0.55.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.53.0",
3
+ "version": "0.55.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -10,9 +10,12 @@ interface FrontmatterResult {
10
10
  }
11
11
 
12
12
  function parseFrontmatter(raw: string): FrontmatterResult {
13
- const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/)
13
+ // Strip a leading UTF-8 BOM — otherwise `^---` never matches and a
14
+ // BOM-prefixed file is silently treated as body text (effectively ignored).
15
+ const src = raw.replace(/^\uFEFF/, '')
16
+ const match = src.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/)
14
17
  if (!match) {
15
- return { data: {}, content: raw }
18
+ return { data: {}, content: src }
16
19
  }
17
20
  return {
18
21
  data: parseYaml(match[1] || '') as Record<string, unknown>,
@@ -9,7 +9,7 @@ import {
9
9
  } from 'node:fs'
10
10
  import { join, basename } from 'node:path'
11
11
  import { homedir, hostname } from 'node:os'
12
- import type { SessionInfo } from '../../shared/types'
12
+ import type { SessionInfo, CrossSessionInbound } from '../../shared/types'
13
13
 
14
14
  const MIPHAM_HOME = join(homedir(), '.mipham')
15
15
  const ACTIVE_SESSIONS_DIR = join(MIPHAM_HOME, '.active-sessions')
@@ -97,6 +97,7 @@ export function createSessionInfo(
97
97
  cwd?: string,
98
98
  provider?: string,
99
99
  model?: string,
100
+ crossSessionInbound?: CrossSessionInbound,
100
101
  ): SessionInfo {
101
102
  return {
102
103
  id: sessionId,
@@ -107,6 +108,7 @@ export function createSessionInfo(
107
108
  cwd,
108
109
  provider,
109
110
  model,
111
+ crossSessionInbound,
110
112
  }
111
113
  }
112
114
 
@@ -90,6 +90,16 @@ export class MessageRouter {
90
90
  }
91
91
  const targetSession = resolution.session!
92
92
 
93
+ // Recipient refuses inbound messages — report "refused" instead of a silent
94
+ // success so the sender knows the message was not delivered.
95
+ if (targetSession.crossSessionInbound === 'deny') {
96
+ return {
97
+ success: false,
98
+ routedTo: 'inbox',
99
+ error: `Session "${targetSession.name}" refuses inbound messages.`,
100
+ }
101
+ }
102
+
93
103
  // Get sender info — resolve the human-readable name when the sender is a
94
104
  // registered session, else fall back to the raw id (e.g. non-session senders).
95
105
  const senderName = discoverSessions().find((s) => s.id === from)?.name ?? from
@@ -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
+ }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type { CommandHandler, CommandContext, CommandResult } from '../ui/commands.js'
9
9
  import { getWorkspaceTrust } from '../core/workspace-trust'
10
+ import { atomicWriteFileSync } from '../shared/atomic-write'
10
11
 
11
12
  export {
12
13
  initCmd,
@@ -20,7 +21,7 @@ export {
20
21
  }
21
22
 
22
23
  const initCmd: CommandHandler = async (ctx) => {
23
- const { existsSync, mkdirSync, writeFileSync } = await import('node:fs')
24
+ const { existsSync, mkdirSync } = await import('node:fs')
24
25
  const { join } = await import('node:path')
25
26
  const { homedir } = await import('node:os')
26
27
 
@@ -78,7 +79,7 @@ providers:
78
79
  ${providerYaml}
79
80
  `
80
81
 
81
- writeFileSync(userConfigPath, configContent, 'utf-8')
82
+ atomicWriteFileSync(userConfigPath, configContent)
82
83
  return {
83
84
  content: `✅ Mipham Code initialized!
84
85
 
@@ -434,7 +435,7 @@ permission: ask
434
435
  providers:
435
436
  ${providerYaml}
436
437
  `
437
- writeFileSync(configPath, defaultConfig, 'utf-8')
438
+ atomicWriteFileSync(configPath, defaultConfig)
438
439
  created.push('.mipham/config.yml')
439
440
  } else {
440
441
  skipped.push('.mipham/config.yml (already exists)')
@@ -6,11 +6,11 @@ import {
6
6
  readdirSync,
7
7
  unlinkSync,
8
8
  chmodSync,
9
- writeFileSync,
10
9
  } from 'node:fs'
11
10
  import { join } from 'node:path'
12
11
  import { homedir } from 'node:os'
13
12
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
13
+ import { atomicWriteFileSync } from '../shared/atomic-write'
14
14
  import type {
15
15
  MiphamConfig,
16
16
  ProviderConfig,
@@ -533,7 +533,7 @@ export function saveProviderApiKey(providerId: string, apiKey: string): boolean
533
533
  doc.providers = providers
534
534
 
535
535
  // Write back
536
- writeFileSync(configPath, stringifyYaml(doc), { encoding: 'utf-8', mode: 0o600 })
536
+ atomicWriteFileSync(configPath, stringifyYaml(doc), { mode: 0o600 })
537
537
  return true
538
538
  } catch (err: unknown) {
539
539
  const msg = err instanceof Error ? err.message : String(err)
@@ -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
+ }
@@ -10,9 +10,12 @@ interface FrontmatterResult {
10
10
  }
11
11
 
12
12
  function parseFrontmatter(raw: string): FrontmatterResult {
13
- const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
13
+ // Strip a leading UTF-8 BOM — otherwise `^---` never matches and a
14
+ // BOM-prefixed file is silently treated as body text (effectively ignored).
15
+ const src = raw.replace(/^\uFEFF/, '')
16
+ const match = src.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
14
17
  if (!match) {
15
- return { data: {}, content: raw }
18
+ return { data: {}, content: src }
16
19
  }
17
20
  return {
18
21
  data: parseYaml(match[1] || '') as Record<string, unknown>,
@@ -101,6 +104,16 @@ export class InstructionsLoader {
101
104
  parts.push(this.buildPermissionContext(permissionMode))
102
105
  }
103
106
 
107
+ // 开场克制:寒暄只回一句短问候,不上能力清单(避免把「你好」当「你是谁」处理)
108
+ parts.push(`## Greeting Restraint
109
+
110
+ When the user's message is only a greeting or small talk ("hello", "hi",
111
+ "你好", "您好", "在吗", "早上好"), reply with ONE brief, warm line —
112
+ e.g. "你好,我是 Mipham,有什么可以帮你的?" Do NOT introduce yourself,
113
+ list your capabilities/tools/MCP servers, or enumerate what you can do.
114
+ Only describe your capabilities when the user explicitly asks ("你是谁",
115
+ "你能做什么", "what can you do", "introduce yourself").`)
116
+
104
117
  // Inject critical thinking self-check layer (for analysis/comparison tasks)
105
118
  parts.push(`## Critical Thinking Self-Check
106
119
 
@@ -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
+ }
@@ -3,7 +3,7 @@ import type { SessionWorker } from './session-worker'
3
3
  import type { RateLimiter } from './rate-limiter'
4
4
 
5
5
  export interface ChannelMessageOptions {
6
- channel: string // 'feishu' | 'telegram' | 'wecom'
6
+ channel: string // 'feishu' | 'telegram' | 'wecom' | 'dingtalk'
7
7
  externalId: string // openId / chatId / userId
8
8
  text: string
9
9
  allowed: Set<string>
@@ -14,11 +14,11 @@ export interface ChannelMessageOptions {
14
14
  provider: string
15
15
  model: string
16
16
  sendText: (externalId: string, text: string) => Promise<void>
17
- maxLen: number // 飞书 4000 / Telegram 4096 / 企微 2048
17
+ maxLen: number // 飞书 4000 / Telegram 4096 / 企微 2048 / 钉钉 2000
18
18
  logPrefix: string // '[feishu]' / '[telegram]' / '[wecom]'
19
19
  }
20
20
 
21
- /** 三频道共享的消息处理骨架:白名单→限流→会话→processPrompt→回发。 */
21
+ /** 四频道共享的消息处理骨架:白名单→限流→会话→processPrompt→回发。 */
22
22
  export async function handleChannelMessage(opts: ChannelMessageOptions): Promise<void> {
23
23
  const {
24
24
  channel,
@@ -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
+ }