@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 +1 -1
- package/src/agent/agent-registry.ts +5 -2
- package/src/agent/cross-session/discovery.ts +3 -1
- package/src/agent/message-router.ts +10 -0
- package/src/agent-view/agent-view-manager.ts +15 -6
- package/src/agent-view/dashboard.tsx +21 -2
- package/src/commands/cd-suggest.ts +35 -0
- package/src/commands/project.ts +4 -3
- package/src/config/loader.ts +2 -2
- package/src/core/claude-md-audit.ts +64 -0
- package/src/core/instructions.ts +15 -2
- package/src/daemon/backoff.ts +4 -0
- package/src/daemon/channel-message.ts +3 -3
- package/src/daemon/dingtalk/adapter.ts +62 -0
- package/src/daemon/dingtalk/api.ts +120 -0
- package/src/daemon/dingtalk/env.ts +16 -0
- package/src/daemon/dingtalk/types.ts +13 -0
- package/src/daemon/dingtalk/ws-client.ts +81 -0
- package/src/daemon/index.ts +17 -0
- package/src/daemon/server.ts +18 -0
- package/src/daemon/telegram/poller.ts +1 -5
- package/src/daemon/wecom/ws-client.ts +1 -5
- package/src/i18n-core/locales/en-US.json +5 -1
- package/src/i18n-core/locales/zh-CN.json +5 -1
- package/src/index.tsx +45 -32
- package/src/mcp/client.ts +2 -2
- package/src/mcp/http-transport.ts +11 -4
- package/src/mcp/oauth.ts +17 -9
- package/src/mcp/transport.ts +9 -3
- package/src/shared/atomic-write.ts +17 -0
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +3 -0
- package/src/skills/loader.ts +5 -2
- package/src/ui/commands.ts +37 -1
- package/src/ui/config-wizard.tsx +3 -2
- package/src/core/output-styles.ts +0 -95
package/package.json
CHANGED
|
@@ -10,9 +10,12 @@ interface FrontmatterResult {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
function parseFrontmatter(raw: string): FrontmatterResult {
|
|
13
|
-
|
|
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:
|
|
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
|
|
259
|
-
if (
|
|
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 ·
|
|
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
|
+
}
|
package/src/commands/project.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
438
|
+
atomicWriteFileSync(configPath, defaultConfig)
|
|
438
439
|
created.push('.mipham/config.yml')
|
|
439
440
|
} else {
|
|
440
441
|
skipped.push('.mipham/config.yml (already exists)')
|
package/src/config/loader.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/core/instructions.ts
CHANGED
|
@@ -10,9 +10,12 @@ interface FrontmatterResult {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
function parseFrontmatter(raw: string): FrontmatterResult {
|
|
13
|
-
|
|
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:
|
|
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
|
|
|
@@ -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
|
-
/**
|
|
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
|
+
}
|