@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 +1 -1
- package/skills/mipham/save-to-wiki.mipham-skill.md +92 -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/core/claude-md-audit.ts +64 -0
- package/src/daemon/backoff.ts +4 -0
- package/src/daemon/channel-message.ts +59 -0
- 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/feishu/adapter.ts +16 -27
- package/src/daemon/index.ts +34 -0
- package/src/daemon/server.ts +36 -0
- package/src/daemon/telegram/adapter.ts +16 -27
- package/src/daemon/telegram/poller.ts +1 -5
- package/src/daemon/wecom/adapter.ts +61 -0
- package/src/daemon/wecom/api.ts +58 -0
- package/src/daemon/wecom/env.ts +16 -0
- package/src/daemon/wecom/types.ts +12 -0
- package/src/daemon/wecom/ws-client.ts +73 -0
- package/src/i18n-core/locales/en-US.json +8 -1
- package/src/i18n-core/locales/zh-CN.json +8 -1
- 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/package-info.ts +1 -1
- package/src/shared/types.ts +2 -0
- package/src/skills/bundled-skills.ts +1 -0
- package/src/ui/commands.ts +56 -1
- package/src/ui/input.tsx +4 -2
- package/src/ui/vim-motions.ts +17 -0
package/package.json
CHANGED
|
@@ -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
|
|
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
|
+
}
|
|
@@ -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,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
|
+
}
|