@leviyuan/lodestar 0.1.0 → 2.0.14
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/README.md +76 -67
- package/cli.ts +176 -0
- package/config.ts +135 -0
- package/daemon.ts +1080 -144
- package/email-worker.ts +534 -0
- package/env-bootstrap.ts +7 -0
- package/feishu-mcp.ts +482 -0
- package/package.json +36 -37
- package/runtime-api.ts +569 -0
- package/scripts/runtime-thread.sh +91 -0
- package/status-dashboard.ts +733 -0
- package/src/cardkit.ts +0 -215
- package/src/cards.ts +0 -304
- package/src/claude-process.ts +0 -301
- package/src/config.ts +0 -83
- package/src/feishu.ts +0 -365
- package/src/instructions.ts +0 -22
- package/src/log.ts +0 -11
- package/src/paths.ts +0 -41
- package/src/session.ts +0 -447
package/daemon.ts
CHANGED
|
@@ -1,203 +1,1139 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Feishu daemon for DeepSeek TUI — standalone process that watches all
|
|
4
|
+
* Feishu groups and routes messages to/from DeepSeek via Runtime API.
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* "hi" → create or check a Runtime API thread
|
|
7
|
+
* "restart" → interrupt + fork (fresh context)
|
|
8
|
+
* "kill" → archive thread
|
|
9
|
+
* "clear" → compact thread
|
|
10
|
+
* "ls" → render a tree of the project directory
|
|
11
|
+
* other → inject as a turn into the thread
|
|
8
12
|
*
|
|
9
|
-
* Run:
|
|
10
|
-
*
|
|
13
|
+
* Run: bun daemon.ts
|
|
14
|
+
*
|
|
15
|
+
* Design doc: LODESTAR_DEEPSEEK_DESIGN.md
|
|
11
16
|
*/
|
|
12
17
|
|
|
18
|
+
import './env-bootstrap' // MUST be first
|
|
19
|
+
|
|
13
20
|
import * as lark from '@larksuiteoapi/node-sdk'
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
import { execSync } from 'child_process'
|
|
22
|
+
import { createHash } from 'crypto'
|
|
23
|
+
import {
|
|
24
|
+
readFileSync, writeFileSync, unlinkSync, mkdirSync, existsSync,
|
|
25
|
+
appendFileSync, readdirSync, statSync, copyFileSync,
|
|
26
|
+
} from 'fs'
|
|
27
|
+
import { homedir } from 'os'
|
|
28
|
+
import { join, resolve as resolvePath, sep as pathSep, basename, dirname } from 'path'
|
|
29
|
+
import {
|
|
30
|
+
RuntimeApiClient,
|
|
31
|
+
MessageQueue,
|
|
32
|
+
SseEventHandler,
|
|
33
|
+
ThreadMap,
|
|
34
|
+
injectFeishuMcpConfig,
|
|
35
|
+
buildSystemPrompt,
|
|
36
|
+
} from './runtime-api'
|
|
37
|
+
import type { QueuedMessage, SseDelta, TurnInfo } from './runtime-api'
|
|
38
|
+
import { startEmailWorker, stopEmailWorker } from './email-worker'
|
|
39
|
+
import { startStatusDashboard } from './status-dashboard'
|
|
40
|
+
|
|
41
|
+
// ── State paths ──────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
const STATE_DIR = join(homedir(), '.deepseek', 'lodestar')
|
|
44
|
+
const PID_FILE = join(STATE_DIR, 'daemon.pid')
|
|
45
|
+
const LOG_FILE = join(STATE_DIR, 'daemon.log')
|
|
46
|
+
const INBOX_DIR = join(STATE_DIR, 'inbox')
|
|
47
|
+
const PROJECTS_ROOT = process.env.FEISHU_PROJECTS_ROOT ?? homedir()
|
|
48
|
+
|
|
49
|
+
// Runtime API config
|
|
50
|
+
const API_URL = process.env.DEEPSEEK_API_URL ?? 'http://localhost:7878'
|
|
51
|
+
const API_TOKEN = process.env.DEEPSEEK_API_TOKEN ?? ''
|
|
52
|
+
|
|
53
|
+
function sanitizeSessionName(raw: string): string {
|
|
54
|
+
return raw.replace(/[^\w一-鿿\-]/g, '_').slice(0, 64)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const APP_ID = process.env.FEISHU_APP_ID
|
|
58
|
+
const APP_SECRET = process.env.FEISHU_APP_SECRET
|
|
59
|
+
|
|
60
|
+
if (!APP_ID || !APP_SECRET) {
|
|
61
|
+
console.error('feishu-daemon: FEISHU_APP_ID and FEISHU_APP_SECRET required')
|
|
62
|
+
console.error(' run: lodestar configure')
|
|
63
|
+
console.error(' or edit: ~/.deepseek/lodestar.toml')
|
|
64
|
+
process.exit(1)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Lark client ──────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
const client = new lark.Client({
|
|
70
|
+
appId: APP_ID,
|
|
71
|
+
appSecret: APP_SECRET,
|
|
72
|
+
disableTokenCache: false,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// ── Runtime API client ───────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
const api = new RuntimeApiClient({ baseUrl: API_URL, authToken: API_TOKEN })
|
|
78
|
+
const msgQueue = new MessageQueue()
|
|
79
|
+
const threadMap = new ThreadMap()
|
|
80
|
+
|
|
81
|
+
// ── Group name cache: chat_id → name ─────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
const chatNameCache = new Map<string, string>()
|
|
84
|
+
|
|
85
|
+
// Session name → preferred chat_id (same as original — handles duplicate group names)
|
|
86
|
+
const SESSION_CHAT_MAP_FILE = join(STATE_DIR, 'session-chat-map.json')
|
|
87
|
+
const preferredChatForSession = new Map<string, string>()
|
|
88
|
+
|
|
89
|
+
function loadSessionChatMap(): void {
|
|
25
90
|
try {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
91
|
+
const raw = readFileSync(SESSION_CHAT_MAP_FILE, 'utf-8')
|
|
92
|
+
const obj = JSON.parse(raw)
|
|
93
|
+
for (const [name, id] of Object.entries(obj)) {
|
|
94
|
+
if (typeof id === 'string') preferredChatForSession.set(name, id)
|
|
95
|
+
}
|
|
96
|
+
log(`loaded session→chat map: ${preferredChatForSession.size} bindings`)
|
|
29
97
|
} catch {}
|
|
30
|
-
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function saveSessionChatMap(): void {
|
|
101
|
+
try {
|
|
102
|
+
const obj: Record<string, string> = {}
|
|
103
|
+
for (const [name, id] of preferredChatForSession.entries()) obj[name] = id
|
|
104
|
+
writeFileSync(SESSION_CHAT_MAP_FILE, JSON.stringify(obj, null, 2))
|
|
105
|
+
} catch (err) {
|
|
106
|
+
log(`save session→chat map failed: ${err}`)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function bindSessionToChat(sessionName: string, chatId: string): void {
|
|
111
|
+
if (preferredChatForSession.get(sessionName) === chatId) return
|
|
112
|
+
const prev = preferredChatForSession.get(sessionName)
|
|
113
|
+
preferredChatForSession.set(sessionName, chatId)
|
|
114
|
+
saveSessionChatMap()
|
|
115
|
+
log(`bound session "${sessionName}" → chat ${chatId}${prev ? ` (was ${prev})` : ''}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function refreshChatList(): Promise<void> {
|
|
119
|
+
try {
|
|
120
|
+
let pageToken: string | undefined
|
|
121
|
+
do {
|
|
122
|
+
const res = await client.im.chat.list({
|
|
123
|
+
params: { page_size: 100, ...(pageToken ? { page_token: pageToken } : {}) },
|
|
124
|
+
})
|
|
125
|
+
for (const chat of res.data?.items ?? []) {
|
|
126
|
+
if (chat.chat_id && chat.name) {
|
|
127
|
+
chatNameCache.set(chat.chat_id, chat.name)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
pageToken = res.data?.page_token
|
|
131
|
+
} while (pageToken)
|
|
132
|
+
log(`refreshed chat list: ${chatNameCache.size} groups`)
|
|
133
|
+
} catch (err) {
|
|
134
|
+
log(`refresh chat list failed: ${err}`)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
function log(msg: string): void {
|
|
141
|
+
const line = `[${new Date().toISOString()}] ${msg}`
|
|
142
|
+
try {
|
|
143
|
+
mkdirSync(STATE_DIR, { recursive: true })
|
|
144
|
+
appendFileSync(LOG_FILE, line + '\n')
|
|
145
|
+
} catch {}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Feishu send helpers ─────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
async function getToken(): Promise<string> {
|
|
151
|
+
const res = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
headers: { 'Content-Type': 'application/json' },
|
|
154
|
+
body: JSON.stringify({ app_id: APP_ID, app_secret: APP_SECRET }),
|
|
155
|
+
})
|
|
156
|
+
const data = await res.json() as any
|
|
157
|
+
return data.tenant_access_token
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function addReaction(messageId: string, emojiType: string): Promise<string | undefined> {
|
|
161
|
+
if (!messageId) return
|
|
162
|
+
try {
|
|
163
|
+
const res: any = await client.im.messageReaction.create({
|
|
164
|
+
path: { message_id: messageId },
|
|
165
|
+
data: { reaction_type: { emoji_type: emojiType } },
|
|
166
|
+
})
|
|
167
|
+
return res?.data?.reaction_id
|
|
168
|
+
} catch (err) {
|
|
169
|
+
log(`react ${emojiType} failed on ${messageId}: ${err}`)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function sendTextMessage(chatId: string, text: string): Promise<string | null> {
|
|
174
|
+
try {
|
|
175
|
+
const res: any = await client.im.message.create({
|
|
176
|
+
params: { receive_id_type: 'chat_id' },
|
|
177
|
+
data: {
|
|
178
|
+
receive_id: chatId,
|
|
179
|
+
msg_type: 'text',
|
|
180
|
+
content: JSON.stringify({ text }),
|
|
181
|
+
},
|
|
182
|
+
})
|
|
183
|
+
if (res && typeof res.code === 'number' && res.code !== 0) {
|
|
184
|
+
log(`send message rejected by Lark: chat=${chatId} code=${res.code} msg=${res.msg}`)
|
|
185
|
+
return null
|
|
186
|
+
}
|
|
187
|
+
return res?.data?.message_id ?? null
|
|
188
|
+
} catch (e) {
|
|
189
|
+
log(`send message failed: chat=${chatId} err=${e}`)
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function sendCardMessage(chatId: string, card: object): Promise<boolean> {
|
|
195
|
+
try {
|
|
196
|
+
const res: any = await client.im.message.create({
|
|
197
|
+
params: { receive_id_type: 'chat_id' },
|
|
198
|
+
data: {
|
|
199
|
+
receive_id: chatId,
|
|
200
|
+
msg_type: 'interactive',
|
|
201
|
+
content: JSON.stringify(card),
|
|
202
|
+
},
|
|
203
|
+
})
|
|
204
|
+
if (res && typeof res.code === 'number' && res.code !== 0) {
|
|
205
|
+
log(`send card rejected by Lark: chat=${chatId} code=${res.code} msg=${res.msg}`)
|
|
206
|
+
return false
|
|
207
|
+
}
|
|
208
|
+
return true
|
|
209
|
+
} catch (e) {
|
|
210
|
+
log(`send card failed: chat=${chatId} err=${e}`)
|
|
211
|
+
return false
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── Image/file download ─────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
async function downloadAttachment(
|
|
218
|
+
messageId: string,
|
|
219
|
+
key: string,
|
|
220
|
+
type: 'image' | 'file',
|
|
221
|
+
name?: string,
|
|
222
|
+
): Promise<string | undefined> {
|
|
223
|
+
try {
|
|
224
|
+
const token = await getToken()
|
|
225
|
+
const url = `https://open.feishu.cn/open-apis/im/v1/messages/${messageId}/resources/${key}?type=${type}`
|
|
226
|
+
log(`downloading ${type}: ${url}`)
|
|
227
|
+
const res = await fetch(url, {
|
|
228
|
+
headers: { 'Authorization': `Bearer ${token}` },
|
|
229
|
+
})
|
|
230
|
+
if (!res.ok) {
|
|
231
|
+
const body = await res.text()
|
|
232
|
+
log(`${type} download HTTP ${res.status}: ${body.slice(0, 200)}`)
|
|
233
|
+
return undefined
|
|
234
|
+
}
|
|
235
|
+
const buf = Buffer.from(await res.arrayBuffer())
|
|
236
|
+
mkdirSync(INBOX_DIR, { recursive: true })
|
|
237
|
+
const safeName = name
|
|
238
|
+
? name.replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
239
|
+
: `${key.replace(/[^a-zA-Z0-9_-]/g, '_')}.png`
|
|
240
|
+
const path = join(INBOX_DIR, `${Date.now()}-${safeName}`)
|
|
241
|
+
writeFileSync(path, buf)
|
|
242
|
+
log(`downloaded ${type} to ${path} (${buf.length} bytes)`)
|
|
243
|
+
return path
|
|
244
|
+
} catch (err) {
|
|
245
|
+
log(`${type} download failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
246
|
+
}
|
|
247
|
+
return undefined
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── Slash alias expansion ───────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
const SLASH_ALIASES: Record<string, string> = {
|
|
253
|
+
ap: '/oh-my-claudecode:autopilot',
|
|
254
|
+
ulw: '/oh-my-claudecode:ultrawork',
|
|
255
|
+
cl: '/oh-my-claudecode:cancel',
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function expandSlashAlias(text: string): string {
|
|
259
|
+
if (!text) return text
|
|
260
|
+
const tokens = text.split(/\s+/).filter(Boolean)
|
|
261
|
+
if (tokens.length === 0) return text
|
|
262
|
+
|
|
263
|
+
const lookup = (tok: string): string | null => {
|
|
264
|
+
if (!tok.startsWith('/')) return null
|
|
265
|
+
return SLASH_ALIASES[tok.slice(1).toLowerCase()] ?? null
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const firstHit = lookup(tokens[0])
|
|
269
|
+
if (firstHit) return [firstHit, ...tokens.slice(1)].join(' ').trim()
|
|
270
|
+
|
|
271
|
+
if (tokens.length > 1) {
|
|
272
|
+
const lastHit = lookup(tokens[tokens.length - 1])
|
|
273
|
+
if (lastHit) return [lastHit, ...tokens.slice(0, -1)].join(' ').trim()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return text
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── Project tree (ls command) ────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
const TREE_IGNORE = new Set([
|
|
282
|
+
'.git', '.claude', '.omc',
|
|
283
|
+
'node_modules', '__pycache__', '.venv', 'venv',
|
|
284
|
+
'dist', 'build', 'target', '.next', '.turbo', '.cache',
|
|
285
|
+
'.bun', '.pytest_cache', '.mypy_cache', '.ruff_cache',
|
|
286
|
+
])
|
|
287
|
+
|
|
288
|
+
const TREE_MAX_LINES = 100
|
|
289
|
+
const TREE_FOLDER_THRESHOLD = 12
|
|
290
|
+
|
|
291
|
+
function renderProjectTreeBody(rootDir: string): string {
|
|
292
|
+
const lines: string[] = []
|
|
293
|
+
let truncated = false
|
|
294
|
+
|
|
295
|
+
function walk(dir: string, prefix: string): void {
|
|
296
|
+
if (lines.length >= TREE_MAX_LINES) { truncated = true; return }
|
|
297
|
+
let entries: { name: string; isDir: boolean }[]
|
|
298
|
+
try {
|
|
299
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
300
|
+
.filter(e => !TREE_IGNORE.has(e.name))
|
|
301
|
+
.map(e => ({ name: e.name, isDir: e.isDirectory() }))
|
|
302
|
+
.sort((a, b) => {
|
|
303
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
|
|
304
|
+
return a.name.localeCompare(b.name)
|
|
305
|
+
})
|
|
306
|
+
} catch { return }
|
|
307
|
+
|
|
308
|
+
let visible = entries
|
|
309
|
+
let collapsedCount = 0
|
|
310
|
+
if (entries.length > TREE_FOLDER_THRESHOLD) {
|
|
311
|
+
visible = entries.slice(0, TREE_FOLDER_THRESHOLD)
|
|
312
|
+
collapsedCount = entries.length - TREE_FOLDER_THRESHOLD
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
for (let i = 0; i < visible.length; i++) {
|
|
316
|
+
if (lines.length >= TREE_MAX_LINES) { truncated = true; return }
|
|
317
|
+
const e = visible[i]
|
|
318
|
+
const isLastVisible = i === visible.length - 1 && collapsedCount === 0
|
|
319
|
+
const branch = isLastVisible ? '└── ' : '├── '
|
|
320
|
+
lines.push(prefix + branch + e.name + (e.isDir ? '/' : ''))
|
|
321
|
+
if (e.isDir) {
|
|
322
|
+
walk(join(dir, e.name), prefix + (isLastVisible ? ' ' : '│ '))
|
|
323
|
+
if (lines.length >= TREE_MAX_LINES) { truncated = true; return }
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (collapsedCount > 0) {
|
|
327
|
+
if (lines.length >= TREE_MAX_LINES) { truncated = true; return }
|
|
328
|
+
lines.push(prefix + `└── … more ${collapsedCount} 个`)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
walk(rootDir, '')
|
|
333
|
+
|
|
334
|
+
if (truncated) {
|
|
335
|
+
if (lines.length >= TREE_MAX_LINES) lines.length = TREE_MAX_LINES - 1
|
|
336
|
+
lines.push('… (已截断到 100 行)')
|
|
337
|
+
}
|
|
338
|
+
return lines.join('\n')
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── Provision new project ───────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
function provisionProject(workDir: string): void {
|
|
344
|
+
mkdirSync(workDir, { recursive: true })
|
|
345
|
+
log(`provisioned directory ${workDir}`)
|
|
346
|
+
|
|
347
|
+
// Run git init so the new project has version control from the start
|
|
348
|
+
try {
|
|
349
|
+
execSync('git init', { cwd: workDir, timeout: 10_000 })
|
|
350
|
+
log(`git init in ${workDir}`)
|
|
351
|
+
} catch (err) {
|
|
352
|
+
log(`git init in ${workDir} failed: ${err}`)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ── Resolve chat_id for a session name ──────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
function chatIdForSession(sessionName: string): string | null {
|
|
359
|
+
const preferred = preferredChatForSession.get(sessionName)
|
|
360
|
+
if (preferred && chatNameCache.get(preferred) === sessionName) return preferred
|
|
361
|
+
|
|
362
|
+
const matches: string[] = []
|
|
363
|
+
for (const [id, name] of chatNameCache.entries()) {
|
|
364
|
+
if (name === sessionName) matches.push(id)
|
|
365
|
+
}
|
|
366
|
+
if (matches.length === 1) return matches[0]
|
|
367
|
+
if (matches.length > 1) {
|
|
368
|
+
log(`chatIdForSession("${sessionName}"): ${matches.length} chats share this name and no binding — [${matches.join(', ')}]`)
|
|
369
|
+
return null
|
|
370
|
+
}
|
|
371
|
+
return null
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ── SSE event handling (stream output to Feishu) ────────────────────────────
|
|
375
|
+
|
|
376
|
+
// Accumulates agent_message text and sends single complete reply on turn.completed.
|
|
377
|
+
// Feishu PATCH endpoint doesn't support text message updates, so we accumulate
|
|
378
|
+
// all deltas and send once. During processing, a 👌 reaction on the user's
|
|
379
|
+
// message indicates the bot is working.
|
|
380
|
+
const streamBuffers = new Map<string, {
|
|
381
|
+
text: string
|
|
382
|
+
chatId: string
|
|
383
|
+
userMessageId?: string // original feishu message to react to
|
|
384
|
+
reasoningText: string
|
|
385
|
+
}>()
|
|
386
|
+
|
|
387
|
+
const sseHandler = new SseEventHandler(api)
|
|
388
|
+
|
|
389
|
+
sseHandler.onDelta = (threadId: string, delta: SseDelta) => {
|
|
390
|
+
if (delta.kind === 'agent_reasoning') {
|
|
391
|
+
const buf = streamBuffers.get(threadId)
|
|
392
|
+
if (buf) buf.reasoningText += delta.delta
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (delta.kind === 'agent_message') {
|
|
397
|
+
const buf = streamBuffers.get(threadId)
|
|
398
|
+
if (buf) buf.text += delta.delta
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
sseHandler.onTurnCompleted = (threadId: string, _turn: TurnInfo) => {
|
|
404
|
+
const buf = streamBuffers.get(threadId)
|
|
405
|
+
if (!buf) return
|
|
406
|
+
|
|
407
|
+
const text = buf.text.trim()
|
|
408
|
+
buf.text = ''
|
|
409
|
+
buf.reasoningText = ''
|
|
410
|
+
|
|
411
|
+
if (text) {
|
|
412
|
+
// Send complete reply — use card format if markdown detected
|
|
413
|
+
sendCardIfMarkdown(buf.chatId, text)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Clear active turn
|
|
417
|
+
msgQueue.clearActiveTurn(threadId)
|
|
418
|
+
|
|
419
|
+
// Inject next queued message if any
|
|
420
|
+
const next = msgQueue.dequeue(threadId)
|
|
421
|
+
if (next) {
|
|
422
|
+
injectMessage(threadId, next)
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function initStreamBuffer(threadId: string, chatId: string, userMessageId?: string): void {
|
|
427
|
+
streamBuffers.set(threadId, {
|
|
428
|
+
text: '',
|
|
429
|
+
chatId,
|
|
430
|
+
userMessageId,
|
|
431
|
+
reasoningText: '',
|
|
432
|
+
})
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Send as interactive card if text has markdown, else plain text. */
|
|
436
|
+
async function sendCardIfMarkdown(chatId: string, text: string): Promise<void> {
|
|
437
|
+
// Simple markdown detection — if present, send as card for better mobile rendering
|
|
438
|
+
const hasMd = /(?:^#{1,6}\s|^[-*]\s|```|\*\*|`[^`]+`)/m.test(text)
|
|
439
|
+
if (hasMd) {
|
|
440
|
+
try {
|
|
441
|
+
const card = markdownToCard(text)
|
|
442
|
+
await sendCardMessageRaw(chatId, card)
|
|
443
|
+
return
|
|
444
|
+
} catch (err) {
|
|
445
|
+
log(`send card failed, falling back to text: ${err}`)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
await sendTextMessage(chatId, text)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Simple markdown → Feishu card (v2 schema). */
|
|
452
|
+
function markdownToCard(text: string): string {
|
|
453
|
+
const MAX_LEN = 28000
|
|
454
|
+
const parts: string[] = []
|
|
455
|
+
let rest = text
|
|
456
|
+
while (rest.length > MAX_LEN) {
|
|
457
|
+
const cut = rest.lastIndexOf('\n\n', MAX_LEN)
|
|
458
|
+
parts.push(rest.slice(0, cut > MAX_LEN / 2 ? cut : MAX_LEN))
|
|
459
|
+
rest = rest.slice(cut > MAX_LEN / 2 ? cut : MAX_LEN)
|
|
460
|
+
}
|
|
461
|
+
if (rest) parts.push(rest)
|
|
462
|
+
const elements = parts.map(p => ({ tag: 'markdown', content: p }))
|
|
463
|
+
return JSON.stringify({ schema: '2.0', config: { wide_screen_mode: true }, body: { elements } })
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Send a raw card JSON to feishu. */
|
|
467
|
+
async function sendCardMessageRaw(chatId: string, cardJson: string): Promise<void> {
|
|
468
|
+
try {
|
|
469
|
+
const res: any = await client.im.message.create({
|
|
470
|
+
params: { receive_id_type: 'chat_id' },
|
|
471
|
+
data: { receive_id: chatId, msg_type: 'interactive', content: cardJson },
|
|
472
|
+
})
|
|
473
|
+
if (res && typeof res.code === 'number' && res.code !== 0) {
|
|
474
|
+
log(`send card rejected: chat=${chatId} code=${res.code} msg=${res.msg}`)
|
|
475
|
+
}
|
|
476
|
+
} catch (e) {
|
|
477
|
+
log(`send card failed: chat=${chatId} err=${e}`)
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ── Message injection ───────────────────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
async function injectMessage(threadId: string, msg: QueuedMessage): Promise<void> {
|
|
484
|
+
const sessionName = threadMap.getSessionName(threadId)
|
|
485
|
+
if (!sessionName) {
|
|
486
|
+
log(`injectMessage: no session name for thread ${threadId}`)
|
|
487
|
+
return
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Initialize stream buffer for this thread's chat
|
|
491
|
+
const chatId = msg.chatId
|
|
492
|
+
initStreamBuffer(threadId, chatId)
|
|
493
|
+
|
|
494
|
+
let prompt = `[飞书] ${msg.sender} (${msg.messageId}): ${msg.text}`
|
|
495
|
+
if (msg.filePath) {
|
|
496
|
+
prompt += `\n\n附件已下载到: ${msg.filePath}`
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Build the full prompt with file context
|
|
500
|
+
// For images, tell the model to read the file
|
|
501
|
+
if (msg.filePath && /\.(png|jpe?g|gif|webp|bmp)$/i.test(msg.filePath)) {
|
|
502
|
+
prompt += `\n\n(这是一张图片,请使用 read 工具查看: ${msg.filePath})`
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
try {
|
|
506
|
+
if (msgQueue.hasActiveTurn(threadId)) {
|
|
507
|
+
// Interrupt current turn and inject new message
|
|
508
|
+
const activeTurnId = msgQueue.getActiveTurnId(threadId)
|
|
509
|
+
if (activeTurnId) {
|
|
510
|
+
await api.interruptTurn(threadId, activeTurnId).catch(err =>
|
|
511
|
+
log(`interrupt failed for ${threadId}/${activeTurnId}: ${err}`))
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const turnRes = await api.createTurn(threadId, {
|
|
516
|
+
prompt,
|
|
517
|
+
auto_approve: true,
|
|
518
|
+
mode: 'yolo',
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
msgQueue.setActiveTurn(threadId, turnRes.turn.id)
|
|
522
|
+
log(`injected turn ${turnRes.turn.id} for thread "${sessionName}" (${threadId})`)
|
|
523
|
+
} catch (err) {
|
|
524
|
+
log(`injectMessage failed for thread ${threadId}: ${err}`)
|
|
525
|
+
msgQueue.clearActiveTurn(threadId)
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ── Control command handlers ────────────────────────────────────────────────
|
|
530
|
+
|
|
531
|
+
async function handleHi(sessionName: string, chatId: string, workDir: string): Promise<void> {
|
|
532
|
+
// Check if we already have a thread for this session
|
|
533
|
+
let threadId = threadMap.getThreadId(sessionName)
|
|
534
|
+
|
|
535
|
+
if (threadId) {
|
|
536
|
+
// Thread exists — show status
|
|
537
|
+
const threadInfo = await api.getThreadInfo(threadId)
|
|
538
|
+
if (threadInfo) {
|
|
539
|
+
const lines = [
|
|
540
|
+
`🟢 DeepSeek 已在运行`,
|
|
541
|
+
`session: ${sessionName}`,
|
|
542
|
+
`thread: ${threadInfo.id}`,
|
|
543
|
+
`workspace: ${threadInfo.workspace}`,
|
|
544
|
+
`status: ${threadInfo.archived ? '已归档 (发送 hi 创建新会话)' : '活跃'}`,
|
|
545
|
+
]
|
|
546
|
+
if (threadInfo.archived) {
|
|
547
|
+
lines.push('')
|
|
548
|
+
lines.push('该会话已归档。发送 "hi" 将创建新的会话。')
|
|
549
|
+
// Clear the mapping for archived threads so next hi creates a new one
|
|
550
|
+
threadMap.removeThreadId(sessionName)
|
|
551
|
+
threadId = undefined
|
|
552
|
+
}
|
|
553
|
+
await sendTextMessage(chatId, lines.join('\n'))
|
|
554
|
+
} else {
|
|
555
|
+
// Thread may have been deleted — clear mapping
|
|
556
|
+
threadMap.removeThreadId(sessionName)
|
|
557
|
+
threadId = undefined
|
|
558
|
+
}
|
|
559
|
+
if (threadId) return
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// No thread — create a new one
|
|
563
|
+
const isNewProject = !existsSync(workDir)
|
|
564
|
+
if (isNewProject) {
|
|
565
|
+
await sendTextMessage(chatId, `🆕 目录 ~/${sessionName} 不存在,正在创建新项目...`)
|
|
566
|
+
try {
|
|
567
|
+
provisionProject(workDir)
|
|
568
|
+
} catch (e) {
|
|
569
|
+
await sendTextMessage(chatId, `❌ 创建项目失败: ${e}`)
|
|
570
|
+
log(`provisionProject failed for ${workDir}: ${e}`)
|
|
571
|
+
return
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const groupName = chatNameCache.get(chatId) ?? sessionName
|
|
576
|
+
const systemPrompt = buildSystemPrompt(groupName, chatId, workDir)
|
|
577
|
+
|
|
578
|
+
try {
|
|
579
|
+
const thread = await api.createThread({
|
|
580
|
+
title: sessionName,
|
|
581
|
+
workspace: workDir,
|
|
582
|
+
mode: 'yolo',
|
|
583
|
+
auto_approve: true,
|
|
584
|
+
system_prompt: systemPrompt,
|
|
585
|
+
model: '', // use default from config.toml
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
threadMap.setThreadId(sessionName, thread.id)
|
|
589
|
+
log(`created thread ${thread.id} for session "${sessionName}"`)
|
|
590
|
+
|
|
591
|
+
// Initialize SSE stream buffer so model output flows to feishu
|
|
592
|
+
initStreamBuffer(thread.id, chatId)
|
|
593
|
+
|
|
594
|
+
// Start SSE stream for this thread
|
|
595
|
+
sseHandler.connect(thread.id).catch(err =>
|
|
596
|
+
log(`SSE connect failed for ${thread.id}: ${err}`))
|
|
597
|
+
|
|
598
|
+
const sent = await sendTextMessage(chatId, [
|
|
599
|
+
`✅ DeepSeek 已就绪,可以开始对话`,
|
|
600
|
+
`session: ${sessionName}`,
|
|
601
|
+
`workspace: ${workDir}`,
|
|
602
|
+
isNewProject ? `已初始化 git 仓库` : '',
|
|
603
|
+
'',
|
|
604
|
+
`指令: hi 状态 | restart 重启 | kill 关闭 | clear 清上下文`,
|
|
605
|
+
].filter(Boolean).join('\n'))
|
|
606
|
+
log(`sent greeting to ${chatId}: ${sent ? 'OK' : 'FAIL'}`)
|
|
607
|
+
} catch (err) {
|
|
608
|
+
log(`createThread failed for "${sessionName}": ${err}`)
|
|
609
|
+
await sendTextMessage(chatId, `❌ 启动失败: ${err}`)
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function handleKill(sessionName: string, chatId: string): Promise<void> {
|
|
614
|
+
const threadId = threadMap.getThreadId(sessionName)
|
|
615
|
+
if (!threadId) {
|
|
616
|
+
await sendTextMessage(chatId, `⚠️ 没有运行中的会话: ${sessionName}`)
|
|
617
|
+
return
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
try {
|
|
621
|
+
await api.archiveThread(threadId)
|
|
622
|
+
threadMap.removeThreadId(sessionName)
|
|
623
|
+
sseHandler.disconnect(threadId)
|
|
624
|
+
msgQueue.clearThread(threadId)
|
|
625
|
+
streamBuffers.delete(threadId)
|
|
626
|
+
await sendTextMessage(chatId, `🔴 会话已关闭 (session: ${sessionName})`)
|
|
627
|
+
log(`archived thread ${threadId} for session "${sessionName}"`)
|
|
628
|
+
} catch (err) {
|
|
629
|
+
log(`archiveThread failed for ${threadId}: ${err}`)
|
|
630
|
+
await sendTextMessage(chatId, `⚠️ 关闭失败: ${err}`)
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function handleRestart(sessionName: string, chatId: string, workDir: string): Promise<void> {
|
|
635
|
+
const threadId = threadMap.getThreadId(sessionName)
|
|
636
|
+
if (!threadId) {
|
|
637
|
+
// No thread — treat like hi (fresh start)
|
|
638
|
+
await handleHi(sessionName, chatId, workDir)
|
|
639
|
+
return
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
try {
|
|
643
|
+
// Interrupt any active turn first
|
|
644
|
+
const activeTurnId = msgQueue.getActiveTurnId(threadId)
|
|
645
|
+
if (activeTurnId) {
|
|
646
|
+
await api.interruptTurn(threadId, activeTurnId).catch(() => {})
|
|
647
|
+
msgQueue.clearActiveTurn(threadId)
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Fork: creates a new thread preserving the session context
|
|
651
|
+
const newThread = await api.forkThread(threadId)
|
|
652
|
+
|
|
653
|
+
// Archive the old thread
|
|
654
|
+
await api.archiveThread(threadId).catch(err =>
|
|
655
|
+
log(`archive old thread failed: ${err}`))
|
|
656
|
+
|
|
657
|
+
// Clean up old
|
|
658
|
+
sseHandler.disconnect(threadId)
|
|
659
|
+
msgQueue.clearThread(threadId)
|
|
660
|
+
streamBuffers.delete(threadId)
|
|
661
|
+
|
|
662
|
+
// Set up new
|
|
663
|
+
threadMap.setThreadId(sessionName, newThread.id)
|
|
664
|
+
initStreamBuffer(newThread.id, chatId)
|
|
665
|
+
sseHandler.connect(newThread.id).catch(err =>
|
|
666
|
+
log(`SSE connect failed for ${newThread.id}: ${err}`))
|
|
667
|
+
|
|
668
|
+
await sendTextMessage(chatId, `✅ 会话已重启 (session: ${sessionName})`)
|
|
669
|
+
log(`restarted session "${sessionName}": ${threadId} → ${newThread.id}`)
|
|
670
|
+
} catch (err) {
|
|
671
|
+
log(`handleRestart failed for "${sessionName}": ${err}`)
|
|
672
|
+
await sendTextMessage(chatId, `❌ 重启失败: ${err}`)
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async function handleClear(sessionName: string, chatId: string, workDir: string): Promise<void> {
|
|
677
|
+
const oldThreadId = threadMap.getThreadId(sessionName)
|
|
678
|
+
if (!oldThreadId) {
|
|
679
|
+
await sendTextMessage(chatId, `⚠️ 没有运行中的会话: ${sessionName}`)
|
|
680
|
+
return
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
try {
|
|
684
|
+
// Archive old thread
|
|
685
|
+
await api.archiveThread(oldThreadId).catch(err => log(`archive old thread failed: ${err}`))
|
|
686
|
+
sseHandler.disconnect(oldThreadId)
|
|
687
|
+
msgQueue.clearThread(oldThreadId)
|
|
688
|
+
streamBuffers.delete(oldThreadId)
|
|
31
689
|
|
|
32
|
-
|
|
33
|
-
|
|
690
|
+
// Create fresh thread with same workspace + system_prompt
|
|
691
|
+
const groupName = chatNameCache.get(chatId) ?? sessionName
|
|
692
|
+
const systemPrompt = buildSystemPrompt(groupName, chatId, workDir)
|
|
34
693
|
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
694
|
+
const newThread = await api.createThread({
|
|
695
|
+
title: sessionName,
|
|
696
|
+
workspace: workDir,
|
|
697
|
+
mode: 'yolo',
|
|
698
|
+
auto_approve: true,
|
|
699
|
+
system_prompt: systemPrompt,
|
|
700
|
+
model: '',
|
|
701
|
+
})
|
|
41
702
|
|
|
42
|
-
|
|
43
|
-
|
|
703
|
+
threadMap.setThreadId(sessionName, newThread.id)
|
|
704
|
+
initStreamBuffer(newThread.id, chatId)
|
|
705
|
+
sseHandler.connect(newThread.id).catch(err =>
|
|
706
|
+
log(`SSE connect failed for ${newThread.id}: ${err}`))
|
|
44
707
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
708
|
+
await sendTextMessage(chatId, `🧹 上下文已清空,全新会话开始 (session: ${sessionName})`)
|
|
709
|
+
log(`cleared session "${sessionName}": ${oldThreadId} → ${newThread.id}`)
|
|
710
|
+
} catch (err) {
|
|
711
|
+
log(`handleClear failed for "${sessionName}": ${err}`)
|
|
712
|
+
await sendTextMessage(chatId, `⚠️ 清理失败: ${err}`)
|
|
50
713
|
}
|
|
51
|
-
return s
|
|
52
714
|
}
|
|
53
715
|
|
|
54
|
-
// ──
|
|
716
|
+
// ── Card action handler (permission approve/deny) ───────────────────────────
|
|
717
|
+
|
|
718
|
+
const processedActions = new Set<string>()
|
|
719
|
+
|
|
720
|
+
async function handleCardAction(data: any): Promise<any> {
|
|
721
|
+
const action = data?.action
|
|
722
|
+
const value = action?.value
|
|
723
|
+
if (!value?.action || !value?.request_id) return
|
|
724
|
+
|
|
725
|
+
const requestId = value.request_id as string
|
|
726
|
+
const behavior = value.action as string // 'allow' or 'deny'
|
|
727
|
+
|
|
728
|
+
const dedup = `${requestId}:${behavior}`
|
|
729
|
+
if (processedActions.has(dedup)) return
|
|
730
|
+
processedActions.add(dedup)
|
|
731
|
+
if (processedActions.size > 100) {
|
|
732
|
+
const arr = [...processedActions]
|
|
733
|
+
for (let i = 0; i < 50; i++) processedActions.delete(arr[i])
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
log(`card action: ${behavior} ${requestId}`)
|
|
737
|
+
|
|
738
|
+
try {
|
|
739
|
+
if (behavior === 'allow' || behavior === 'deny') {
|
|
740
|
+
await api.handleApproval(requestId, behavior)
|
|
741
|
+
}
|
|
742
|
+
} catch (err) {
|
|
743
|
+
log(`handleApproval failed for ${requestId}: ${err}`)
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Build a response card (simplified — toast confirmation)
|
|
747
|
+
const resultText = behavior === 'allow' ? '✅ 已批准' : '❌ 已拒绝'
|
|
748
|
+
return {
|
|
749
|
+
toast: {
|
|
750
|
+
type: behavior === 'allow' ? 'success' : 'error',
|
|
751
|
+
content: resultText,
|
|
752
|
+
},
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// ── Event handler ────────────────────────────────────────────────────────────
|
|
757
|
+
|
|
758
|
+
const processedMessages = new Set<string>()
|
|
55
759
|
const STALE_THRESHOLD_MS = 10_000
|
|
56
|
-
const seenMessageIds = new Set<string>()
|
|
57
760
|
|
|
58
761
|
async function handleMessage(data: any): Promise<void> {
|
|
59
762
|
const message = data?.message
|
|
60
763
|
if (!message) return
|
|
61
764
|
|
|
62
|
-
const msgId = message.message_id
|
|
63
|
-
if (msgId &&
|
|
765
|
+
const msgId = message.message_id
|
|
766
|
+
if (msgId && processedMessages.has(msgId)) return
|
|
64
767
|
if (msgId) {
|
|
65
|
-
|
|
66
|
-
if (
|
|
67
|
-
const arr = [...
|
|
68
|
-
|
|
69
|
-
for (const id of arr.slice(-100)) seenMessageIds.add(id)
|
|
768
|
+
processedMessages.add(msgId)
|
|
769
|
+
if (processedMessages.size > 100) {
|
|
770
|
+
const arr = [...processedMessages]
|
|
771
|
+
for (let i = 0; i < 50; i++) processedMessages.delete(arr[i])
|
|
70
772
|
}
|
|
71
773
|
}
|
|
72
774
|
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
775
|
+
const chatId = message.chat_id
|
|
776
|
+
const msgType = message.message_type
|
|
777
|
+
|
|
778
|
+
// Drop stale messages
|
|
779
|
+
const createTimeMs = Number(message.create_time ?? 0)
|
|
780
|
+
if (createTimeMs > 0) {
|
|
781
|
+
const ageMs = Date.now() - createTimeMs
|
|
782
|
+
if (ageMs > STALE_THRESHOLD_MS) {
|
|
783
|
+
const ageSec = Math.round(ageMs / 1000)
|
|
784
|
+
let preview = `(${msgType})`
|
|
785
|
+
try {
|
|
786
|
+
const obj = JSON.parse(message.content ?? '{}')
|
|
787
|
+
const raw = (msgType === 'text' ? obj.text ?? '' : '').trim()
|
|
788
|
+
if (raw) preview = raw.length > 10 ? raw.slice(0, 10) + '…' : raw
|
|
789
|
+
} catch {}
|
|
790
|
+
log(`dropping stale message ${msgId} from ${chatId}, age=${ageSec}s, preview="${preview}"`)
|
|
791
|
+
void addReaction(msgId, 'CrossMark')
|
|
792
|
+
return
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Immediate seen signal
|
|
797
|
+
void addReaction(msgId, 'OK')
|
|
798
|
+
|
|
799
|
+
let contentObj: any = {}
|
|
800
|
+
try { contentObj = JSON.parse(message.content ?? '{}') } catch {}
|
|
801
|
+
const text = (msgType === 'text' ? contentObj.text ?? '' : '').trim()
|
|
802
|
+
const cmd = text.toLowerCase()
|
|
803
|
+
|
|
804
|
+
const isOpt = /^opt[1-5]$/.test(cmd)
|
|
805
|
+
const lsMatch = cmd.match(/^ls(?:\s+(.+))?$/)
|
|
806
|
+
const isLs = !!lsMatch
|
|
807
|
+
const isControl = cmd === 'hi' || cmd === 'restart' || cmd === 'kill' || cmd === 'clear' || isOpt || isLs
|
|
808
|
+
|
|
809
|
+
// Non-control messages: inject as turn
|
|
810
|
+
if (!isControl) {
|
|
811
|
+
const groupName = chatNameCache.get(chatId)
|
|
812
|
+
if (groupName) bindSessionToChat(groupName, chatId)
|
|
813
|
+
|
|
814
|
+
const sessionName = groupName ? sanitizeSessionName(groupName) : null
|
|
815
|
+
const threadId = sessionName ? threadMap.getThreadId(sessionName) : undefined
|
|
816
|
+
|
|
817
|
+
if (!threadId || !sessionName) {
|
|
818
|
+
log(`no thread for message from chat ${chatId} (group: ${groupName ?? 'unknown'})`)
|
|
819
|
+
return
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Download attachments
|
|
823
|
+
let filePath: string | undefined
|
|
824
|
+
if (msgType === 'image' && contentObj.image_key) {
|
|
825
|
+
filePath = await downloadAttachment(message.message_id, contentObj.image_key, 'image')
|
|
826
|
+
} else if (msgType === 'file' && contentObj.file_key) {
|
|
827
|
+
filePath = await downloadAttachment(message.message_id, contentObj.file_key, 'file', contentObj.file_name)
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const expandedText = expandSlashAlias(text)
|
|
831
|
+
if (expandedText !== text) log(`expanded slash alias: "${text}" → "${expandedText}"`)
|
|
832
|
+
|
|
833
|
+
const msg: QueuedMessage = {
|
|
834
|
+
chatId,
|
|
835
|
+
messageId: message.message_id,
|
|
836
|
+
sender: data.sender?.sender_id?.open_id ?? 'unknown',
|
|
837
|
+
text: expandedText || `(${msgType})`,
|
|
838
|
+
filePath,
|
|
839
|
+
timestamp: Date.now(),
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Inject immediately if no active turn, otherwise interrupt + inject
|
|
843
|
+
injectMessage(threadId, msg)
|
|
844
|
+
|
|
845
|
+
// Stamp last user message time for dashboard
|
|
846
|
+
try { writeFileSync(join(STATE_DIR, 'last_user_message'), String(Math.floor(Date.now() / 1000))) } catch {}
|
|
78
847
|
return
|
|
79
848
|
}
|
|
80
|
-
if (msgId) void feishu.addReaction(msgId, 'OK')
|
|
81
849
|
|
|
82
|
-
|
|
83
|
-
|
|
850
|
+
// Control commands below: only text messages
|
|
851
|
+
if (msgType !== 'text') return
|
|
852
|
+
|
|
853
|
+
// Resolve group name
|
|
854
|
+
let groupName = chatNameCache.get(chatId)
|
|
84
855
|
if (!groupName) {
|
|
85
|
-
await
|
|
86
|
-
groupName =
|
|
856
|
+
await refreshChatList()
|
|
857
|
+
groupName = chatNameCache.get(chatId)
|
|
87
858
|
}
|
|
88
859
|
if (!groupName) {
|
|
89
|
-
|
|
90
|
-
await
|
|
860
|
+
await new Promise(r => setTimeout(r, 2000))
|
|
861
|
+
await refreshChatList()
|
|
862
|
+
groupName = chatNameCache.get(chatId)
|
|
863
|
+
}
|
|
864
|
+
if (!groupName) {
|
|
865
|
+
try {
|
|
866
|
+
const res = await client.im.chat.get({ path: { chat_id: chatId } })
|
|
867
|
+
groupName = res.data?.name ?? undefined
|
|
868
|
+
if (groupName) chatNameCache.set(chatId, groupName)
|
|
869
|
+
} catch {}
|
|
870
|
+
}
|
|
871
|
+
if (!groupName) {
|
|
872
|
+
log(`received "${cmd}" from unknown group ${chatId}, cannot resolve name`)
|
|
873
|
+
await sendTextMessage(chatId, '❌ 无法识别群名,请确认机器人已加入群聊')
|
|
91
874
|
return
|
|
92
875
|
}
|
|
93
|
-
const sessionName = feishu.sanitizeSessionName(groupName)
|
|
94
|
-
feishu.bindSessionToChat(sessionName, chatId)
|
|
95
|
-
const session = sessionFor(chatId, sessionName)
|
|
96
876
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
877
|
+
const sessionName = sanitizeSessionName(groupName)
|
|
878
|
+
const workDir = join(PROJECTS_ROOT, sessionName)
|
|
879
|
+
|
|
880
|
+
bindSessionToChat(sessionName, chatId)
|
|
881
|
+
log(`received "${cmd}" in group "${groupName}" (${chatId})`)
|
|
101
882
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
883
|
+
if (cmd === 'hi') {
|
|
884
|
+
await handleHi(sessionName, chatId, workDir)
|
|
885
|
+
} else if (cmd === 'kill') {
|
|
886
|
+
await handleKill(sessionName, chatId)
|
|
887
|
+
} else if (cmd === 'restart') {
|
|
888
|
+
await handleRestart(sessionName, chatId, workDir)
|
|
889
|
+
} else if (cmd === 'clear') {
|
|
890
|
+
await handleClear(sessionName, chatId, workDir)
|
|
891
|
+
} else if (isOpt) {
|
|
892
|
+
// opt1~5 — steer to active turn
|
|
893
|
+
const threadId = threadMap.getThreadId(sessionName)
|
|
894
|
+
if (!threadId || !msgQueue.hasActiveTurn(threadId)) {
|
|
895
|
+
await sendTextMessage(chatId, `⚠️ 没有活跃的会话可以追加选项`)
|
|
896
|
+
return
|
|
897
|
+
}
|
|
898
|
+
const turnId = msgQueue.getActiveTurnId(threadId)!
|
|
899
|
+
try {
|
|
900
|
+
await api.steerTurn(threadId, turnId, text)
|
|
901
|
+
await sendTextMessage(chatId, `✅ 已追加选项: ${text}`)
|
|
902
|
+
} catch (err) {
|
|
903
|
+
log(`steerTurn failed: ${err}`)
|
|
904
|
+
}
|
|
905
|
+
} else if (isLs) {
|
|
906
|
+
const subPath = lsMatch?.[1] ?? ''
|
|
907
|
+
const target = subPath ? join(workDir, subPath) : workDir
|
|
908
|
+
if (!existsSync(target)) {
|
|
909
|
+
await sendTextMessage(chatId, `❌ 路径不存在: ${subPath || '~/' + sessionName}`)
|
|
910
|
+
return
|
|
911
|
+
}
|
|
912
|
+
const tree = renderProjectTreeBody(target)
|
|
913
|
+
const header = subPath
|
|
914
|
+
? `📁 ~/${sessionName}/${subPath}`
|
|
915
|
+
: `📁 ~/${sessionName}`
|
|
916
|
+
await sendTextMessage(chatId, `${header}\n\`\`\`\n${tree}\n\`\`\``)
|
|
109
917
|
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// ── PID management ───────────────────────────────────────────────────────────
|
|
921
|
+
|
|
922
|
+
function writePid(): void {
|
|
923
|
+
mkdirSync(STATE_DIR, { recursive: true })
|
|
924
|
+
writeFileSync(PID_FILE, String(process.pid))
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function cleanPid(): void {
|
|
928
|
+
try { unlinkSync(PID_FILE) } catch {}
|
|
929
|
+
}
|
|
110
930
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
931
|
+
try {
|
|
932
|
+
const existingPid = readFileSync(PID_FILE, 'utf8').trim()
|
|
933
|
+
try {
|
|
934
|
+
process.kill(Number(existingPid), 0)
|
|
935
|
+
console.error(`feishu-daemon: already running (pid ${existingPid}). Use "kill ${existingPid}" to stop it first.`)
|
|
936
|
+
process.exit(1)
|
|
937
|
+
} catch {}
|
|
938
|
+
} catch {}
|
|
939
|
+
|
|
940
|
+
// ── Boot ─────────────────────────────────────────────────────────────────────
|
|
941
|
+
|
|
942
|
+
process.on('unhandledRejection', err => log(`unhandled rejection: ${err}`))
|
|
943
|
+
process.on('uncaughtException', err => log(`uncaught exception: ${err}`))
|
|
944
|
+
|
|
945
|
+
process.on('SIGTERM', () => {
|
|
946
|
+
log('received SIGTERM, shutting down')
|
|
947
|
+
sseHandler.disconnectAll()
|
|
948
|
+
stopEmailWorker()
|
|
949
|
+
cleanPid()
|
|
950
|
+
process.exit(0)
|
|
951
|
+
})
|
|
952
|
+
|
|
953
|
+
process.on('SIGINT', () => {
|
|
954
|
+
log('received SIGINT, shutting down')
|
|
955
|
+
sseHandler.disconnectAll()
|
|
956
|
+
stopEmailWorker()
|
|
957
|
+
cleanPid()
|
|
958
|
+
process.exit(0)
|
|
959
|
+
})
|
|
960
|
+
|
|
961
|
+
function ensureStateDir(): void {
|
|
962
|
+
mkdirSync(STATE_DIR, { recursive: true })
|
|
963
|
+
mkdirSync(INBOX_DIR, { recursive: true })
|
|
964
|
+
mkdirSync(join(STATE_DIR, 'messages'), { recursive: true }) // for backward compat
|
|
965
|
+
|
|
966
|
+
// Install feishu-mcp.ts to state dir if not present
|
|
967
|
+
const mcpDest = join(STATE_DIR, 'feishu-mcp.ts')
|
|
968
|
+
const mcpSrc = join(dirname(new URL(import.meta.url).pathname), 'feishu-mcp.ts')
|
|
969
|
+
if (!existsSync(mcpDest) && existsSync(mcpSrc)) {
|
|
970
|
+
try {
|
|
971
|
+
copyFileSync(mcpSrc, mcpDest)
|
|
972
|
+
log('installed feishu-mcp.ts to state dir')
|
|
973
|
+
} catch (err) {
|
|
974
|
+
log(`install feishu-mcp.ts failed: ${err}`)
|
|
975
|
+
}
|
|
117
976
|
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// ── WS reconnect with exponential backoff ───────────────────────────────────
|
|
118
980
|
|
|
119
|
-
|
|
120
|
-
|
|
981
|
+
// Module-level pong tracker (shared across WS reconnects, single watchdog timer)
|
|
982
|
+
let lastPongAt = Date.now()
|
|
983
|
+
let watchdogTimer: ReturnType<typeof setInterval> | null = null
|
|
984
|
+
|
|
985
|
+
function startWatchdog(): void {
|
|
986
|
+
if (watchdogTimer) return // only one timer
|
|
987
|
+
watchdogTimer = setInterval(() => {
|
|
988
|
+
const idle = Date.now() - lastPongAt
|
|
989
|
+
if (idle > 180 * 1000) {
|
|
990
|
+
log(`[watchdog] no WS pong for ${Math.round(idle / 1000)}s, triggering reconnect`)
|
|
991
|
+
reconnectWs()
|
|
992
|
+
}
|
|
993
|
+
}, 30 * 1000)
|
|
121
994
|
}
|
|
122
995
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (!value?.kind) return
|
|
128
|
-
const chatId = data?.context?.open_chat_id ?? ''
|
|
129
|
-
const userId = data?.operator?.open_id ?? ''
|
|
130
|
-
const session = sessions.get(chatId)
|
|
131
|
-
if (!session) return { toast: { type: 'error', content: '会话不存在,请先发消息启动' } }
|
|
132
|
-
|
|
133
|
-
switch (value.kind) {
|
|
134
|
-
case 'permission':
|
|
135
|
-
await session.onPermissionDecision(value.request_id, value.decision, userId)
|
|
136
|
-
return { toast: { type: value.decision === 'deny' ? 'error' : 'success', content: '已处理' } }
|
|
137
|
-
case 'console':
|
|
138
|
-
await session.onConsoleAction(value.action)
|
|
139
|
-
return { toast: { type: 'info', content: value.action } }
|
|
140
|
-
case 'menu':
|
|
141
|
-
await session.onUserMessage(`(menu choice ${value.choice + 1})`)
|
|
142
|
-
return { toast: { type: 'success', content: 'OK' } }
|
|
143
|
-
}
|
|
144
|
-
return { toast: { type: 'info', content: 'unknown action' } }
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// ── WebSocket boot ─────────────────────────────────────────────────────
|
|
148
|
-
function fmt(m: any[]): string {
|
|
149
|
-
return m.map(x => typeof x === 'string' ? x : JSON.stringify(x)).join(' ')
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
async function boot(): Promise<void> {
|
|
153
|
-
log(`lodestar-daemon: pid ${process.pid} starting`)
|
|
154
|
-
feishu.loadSessionChatMap()
|
|
155
|
-
await feishu.refreshChatList()
|
|
156
|
-
setInterval(() => { void feishu.refreshChatList() }, 5 * 60 * 1000)
|
|
157
|
-
|
|
158
|
-
// Lark WSClient sends pings every ~120s but doesn't verify pongs. On a
|
|
159
|
-
// half-open TCP (NAT idle-kill, network blip) the socket stays OPEN and
|
|
160
|
-
// 'close' never fires — we'd go silently deaf. Stamp every inbound pong
|
|
161
|
-
// and exit(1) after 180s of silence so systemd reconnects us.
|
|
162
|
-
let lastPongAt = Date.now()
|
|
996
|
+
function createWsClient(): { wsClient: lark.WSClient; dispatcher: lark.EventDispatcher } {
|
|
997
|
+
const fmt = (msg: any[]) =>
|
|
998
|
+
msg.map(m => typeof m === 'string' ? m : JSON.stringify(m)).join(' ')
|
|
999
|
+
|
|
163
1000
|
const wsLogger = {
|
|
164
|
-
error: (
|
|
165
|
-
warn:
|
|
166
|
-
info:
|
|
167
|
-
debug: (
|
|
168
|
-
trace: (
|
|
169
|
-
if (Array.isArray(
|
|
1001
|
+
error: (msg: any[]) => log(`[ws-sdk error] ${fmt(msg)}`),
|
|
1002
|
+
warn: (msg: any[]) => log(`[ws-sdk warn] ${fmt(msg)}`),
|
|
1003
|
+
info: (msg: any[]) => log(`[ws-sdk] ${fmt(msg)}`),
|
|
1004
|
+
debug: (_msg: any[]) => {},
|
|
1005
|
+
trace: (msg: any[]) => {
|
|
1006
|
+
if (Array.isArray(msg) && msg[0] === '[ws]' && msg[1] === 'receive pong') {
|
|
170
1007
|
lastPongAt = Date.now()
|
|
171
1008
|
}
|
|
172
1009
|
},
|
|
173
1010
|
}
|
|
174
|
-
setInterval(() => {
|
|
175
|
-
const idle = Date.now() - lastPongAt
|
|
176
|
-
if (idle > 180_000) {
|
|
177
|
-
log(`[watchdog] no WS pong for ${Math.round(idle / 1000)}s — exit for systemd restart`)
|
|
178
|
-
process.exit(1)
|
|
179
|
-
}
|
|
180
|
-
}, 30_000)
|
|
181
1011
|
|
|
182
|
-
const
|
|
183
|
-
appId:
|
|
184
|
-
appSecret:
|
|
185
|
-
loggerLevel: lark.LoggerLevel.
|
|
1012
|
+
const wsClient = new lark.WSClient({
|
|
1013
|
+
appId: APP_ID!,
|
|
1014
|
+
appSecret: APP_SECRET!,
|
|
1015
|
+
loggerLevel: lark.LoggerLevel.info,
|
|
186
1016
|
logger: wsLogger,
|
|
187
1017
|
})
|
|
1018
|
+
|
|
188
1019
|
const dispatcher = new lark.EventDispatcher({})
|
|
1020
|
+
|
|
189
1021
|
dispatcher.register({
|
|
190
|
-
'im.message.receive_v1': async (
|
|
191
|
-
try { await handleMessage(
|
|
1022
|
+
'im.message.receive_v1': async (data: any) => {
|
|
1023
|
+
try { await handleMessage(data) } catch (err) {
|
|
1024
|
+
log(`handler error: ${err}`)
|
|
1025
|
+
}
|
|
192
1026
|
},
|
|
193
1027
|
})
|
|
1028
|
+
|
|
194
1029
|
dispatcher.register({
|
|
195
|
-
'card.action.trigger': async (
|
|
196
|
-
try { return await handleCardAction(
|
|
1030
|
+
'card.action.trigger': async (data: any) => {
|
|
1031
|
+
try { return await handleCardAction(data) } catch (err) {
|
|
1032
|
+
log(`card action error: ${err}`)
|
|
1033
|
+
}
|
|
197
1034
|
},
|
|
198
1035
|
})
|
|
199
|
-
|
|
200
|
-
|
|
1036
|
+
|
|
1037
|
+
return { wsClient, dispatcher }
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
let wsClientInstance: lark.WSClient | null = null
|
|
1041
|
+
let wsReconnectAttempt = 0
|
|
1042
|
+
let wsReconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
1043
|
+
|
|
1044
|
+
function startWs(): void {
|
|
1045
|
+
startWatchdog()
|
|
1046
|
+
const { wsClient, dispatcher } = createWsClient()
|
|
1047
|
+
wsClientInstance = wsClient
|
|
1048
|
+
wsClient.start({ eventDispatcher: dispatcher })
|
|
1049
|
+
wsReconnectAttempt = 0
|
|
1050
|
+
lastPongAt = Date.now()
|
|
1051
|
+
log(`WS connected, watching ${chatNameCache.size} groups`)
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function reconnectWs(): void {
|
|
1055
|
+
if (wsReconnectTimer) return // already scheduled
|
|
1056
|
+
|
|
1057
|
+
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, 60s (cap)
|
|
1058
|
+
wsReconnectAttempt++
|
|
1059
|
+
const delay = Math.min(1000 * Math.pow(2, wsReconnectAttempt - 1), 60_000)
|
|
1060
|
+
|
|
1061
|
+
log(`WS reconnect attempt ${wsReconnectAttempt} in ${delay}ms`)
|
|
1062
|
+
|
|
1063
|
+
// After 5 minutes of failed reconnects, send Feishu notification
|
|
1064
|
+
if (wsReconnectAttempt >= 8) {
|
|
1065
|
+
for (const [chatId] of chatNameCache) {
|
|
1066
|
+
sendTextMessage(chatId, '⚠️ 飞书连接不稳定,正在重连...').catch(() => {})
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
wsReconnectTimer = setTimeout(() => {
|
|
1071
|
+
wsReconnectTimer = null
|
|
1072
|
+
try {
|
|
1073
|
+
if (wsClientInstance) {
|
|
1074
|
+
try { (wsClientInstance as any).stop?.() } catch {}
|
|
1075
|
+
}
|
|
1076
|
+
startWs()
|
|
1077
|
+
} catch (err) {
|
|
1078
|
+
log(`WS reconnect failed: ${err}`)
|
|
1079
|
+
// Schedule next attempt
|
|
1080
|
+
reconnectWs()
|
|
1081
|
+
}
|
|
1082
|
+
}, delay)
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
async function boot() {
|
|
1086
|
+
writePid()
|
|
1087
|
+
ensureStateDir()
|
|
1088
|
+
log('starting...')
|
|
1089
|
+
|
|
1090
|
+
// Write runtime token for utility scripts
|
|
1091
|
+
try {
|
|
1092
|
+
const tokenFile = join(STATE_DIR, '.runtime_token')
|
|
1093
|
+
if (API_TOKEN) writeFileSync(tokenFile, API_TOKEN)
|
|
1094
|
+
} catch {}
|
|
1095
|
+
|
|
1096
|
+
// Inject feishu MCP config into ~/.deepseek/mcp.json
|
|
1097
|
+
try {
|
|
1098
|
+
injectFeishuMcpConfig()
|
|
1099
|
+
} catch (err) {
|
|
1100
|
+
log(`mcp config inject failed: ${err}`)
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
loadSessionChatMap()
|
|
1104
|
+
|
|
1105
|
+
// Check Runtime API health
|
|
1106
|
+
const apiReady = await api.healthCheck()
|
|
1107
|
+
if (!apiReady) {
|
|
1108
|
+
log('WARNING: deepseek serve --http not reachable at ' + API_URL)
|
|
1109
|
+
log(' daemon will start but threads cannot be created until serve is up')
|
|
1110
|
+
log(' start with: deepseek serve --http --port 7878')
|
|
1111
|
+
} else {
|
|
1112
|
+
log(`deepseek serve --http ready at ${API_URL}`)
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
await refreshChatList()
|
|
1116
|
+
setInterval(() => {
|
|
1117
|
+
void refreshChatList()
|
|
1118
|
+
}, 5 * 60 * 1000)
|
|
1119
|
+
|
|
1120
|
+
// Reconnect SSE for existing threads
|
|
1121
|
+
for (const [sessionName, threadId] of threadMap.entries()) {
|
|
1122
|
+
sseHandler.connect(threadId).catch(err =>
|
|
1123
|
+
log(`SSE reconnect failed for ${threadId}: ${err}`))
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// Start email worker
|
|
1127
|
+
startEmailWorker(log, sendTextMessage, chatNameCache).catch(err =>
|
|
1128
|
+
log(`email-worker boot failed: ${err}`))
|
|
1129
|
+
|
|
1130
|
+
// Start status dashboard
|
|
1131
|
+
startStatusDashboard(log)
|
|
1132
|
+
|
|
1133
|
+
// Start WS connection
|
|
1134
|
+
startWs()
|
|
1135
|
+
|
|
1136
|
+
log(`daemon running (pid ${process.pid}), watching for messages in ${chatNameCache.size} groups...`)
|
|
201
1137
|
}
|
|
202
1138
|
|
|
203
|
-
boot().catch(
|
|
1139
|
+
boot().catch(err => { log(`fatal: ${err}`); process.exit(1) })
|