@leviyuan/lodestar 0.2.8 → 0.3.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/src/feishu.ts DELETED
@@ -1,531 +0,0 @@
1
- /**
2
- * Feishu (Lark) primitives: Lark client, tenant token cache, chat
3
- * directory, sendText/sendCard, reactions, attachment download, project
4
- * provisioning, and Anthropic-auth check.
5
- *
6
- * Higher layers (cardkit / session / daemon) build on this.
7
- */
8
-
9
- import * as lark from '@larksuiteoapi/node-sdk'
10
- import { execSync } from 'node:child_process'
11
- import { randomUUID } from 'node:crypto'
12
- import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
13
- import { homedir } from 'node:os'
14
- import { basename, extname, join } from 'node:path'
15
- import { config } from './config'
16
- import { ALIVE_MARKER_FILE, DATA_DIR, INBOX_DIR, SESSION_CHAT_MAP_FILE, SESSION_RESUME_MAP_FILE } from './paths'
17
- import { log } from './log'
18
-
19
- const APP_ID = config.feishu.app_id
20
- const APP_SECRET = config.feishu.app_secret
21
- export const PROJECTS_ROOT = config.runtime.projects_root
22
-
23
- export const client = new lark.Client({
24
- appId: APP_ID, appSecret: APP_SECRET, disableTokenCache: false,
25
- })
26
-
27
- // ── Tenant token (cached, used by raw fetch wrappers) ──────────────────
28
- let cachedToken = ''
29
- let tokenExpiry = 0
30
- export async function getTenantToken(): Promise<string> {
31
- if (cachedToken && Date.now() < tokenExpiry) return cachedToken
32
- const res = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
33
- method: 'POST', headers: { 'Content-Type': 'application/json' },
34
- body: JSON.stringify({ app_id: APP_ID, app_secret: APP_SECRET }),
35
- })
36
- const data = await res.json() as { tenant_access_token?: string; expire?: number }
37
- if (!data.tenant_access_token) throw new Error('feishu: failed to obtain tenant token')
38
- cachedToken = data.tenant_access_token
39
- tokenExpiry = Date.now() + ((data.expire ?? 7200) - 60) * 1000
40
- return cachedToken
41
- }
42
-
43
- // ── Chat directory ─────────────────────────────────────────────────────
44
- export const chatNameCache = new Map<string, string>()
45
- export const preferredChatForSession = new Map<string, string>()
46
-
47
- export function loadSessionChatMap(): void {
48
- try {
49
- const obj = JSON.parse(readFileSync(SESSION_CHAT_MAP_FILE, 'utf8'))
50
- for (const [name, id] of Object.entries(obj)) {
51
- if (typeof id === 'string') preferredChatForSession.set(name, id)
52
- }
53
- log(`feishu: loaded ${preferredChatForSession.size} session→chat bindings`)
54
- } catch {}
55
- }
56
-
57
- function saveSessionChatMap(): void {
58
- try {
59
- const obj: Record<string, string> = {}
60
- for (const [k, v] of preferredChatForSession) obj[k] = v
61
- mkdirSync(DATA_DIR, { recursive: true })
62
- writeFileSync(SESSION_CHAT_MAP_FILE, JSON.stringify(obj, null, 2))
63
- } catch (e) { log(`feishu: save session-chat-map failed: ${e}`) }
64
- }
65
-
66
- export function bindSessionToChat(sessionName: string, chatId: string): void {
67
- if (preferredChatForSession.get(sessionName) === chatId) return
68
- const prev = preferredChatForSession.get(sessionName)
69
- preferredChatForSession.set(sessionName, chatId)
70
- saveSessionChatMap()
71
- log(`feishu: bound session "${sessionName}" → ${chatId}${prev ? ` (was ${prev})` : ''}`)
72
- }
73
-
74
- // ── Session resume map ────────────────────────────────────────────────
75
- // `sessionName → last-known claude session_id`. Persisted so a daemon
76
- // restart (systemctl, crash, watchdog) doesn't strand the user with a
77
- // fresh conversation when they next type `restart`. Updated on every
78
- // `system/init` from a claude subprocess.
79
- const lastSessionIdByName = new Map<string, string>()
80
-
81
- export function loadSessionResumeMap(): void {
82
- try {
83
- const obj = JSON.parse(readFileSync(SESSION_RESUME_MAP_FILE, 'utf8'))
84
- for (const [name, id] of Object.entries(obj)) {
85
- if (typeof id === 'string') lastSessionIdByName.set(name, id)
86
- }
87
- log(`feishu: loaded ${lastSessionIdByName.size} session→resume bindings`)
88
- } catch {}
89
- }
90
-
91
- function saveSessionResumeMap(): void {
92
- try {
93
- const obj: Record<string, string> = {}
94
- for (const [k, v] of lastSessionIdByName) obj[k] = v
95
- mkdirSync(DATA_DIR, { recursive: true })
96
- writeFileSync(SESSION_RESUME_MAP_FILE, JSON.stringify(obj, null, 2))
97
- } catch (e) { log(`feishu: save session-resume-map failed: ${e}`) }
98
- }
99
-
100
- export function bindSessionResume(sessionName: string, sessionId: string): void {
101
- if (lastSessionIdByName.get(sessionName) === sessionId) return
102
- lastSessionIdByName.set(sessionName, sessionId)
103
- saveSessionResumeMap()
104
- }
105
-
106
- export function getSessionResume(sessionName: string): string | null {
107
- return lastSessionIdByName.get(sessionName) ?? null
108
- }
109
-
110
- // ── Alive-on-shutdown marker ──────────────────────────────────────────
111
- // Persists the list of session names that were still running when the
112
- // daemon went down. Next boot reads + unlinks the file and auto-spawns
113
- // (via session.restart(true)) only those — sessions that were already
114
- // `stop`ped before shutdown are deliberately NOT in this list, so they
115
- // stay stopped after restart.
116
-
117
- export function writeAliveMarker(sessionNames: string[]): void {
118
- try {
119
- writeFileSync(ALIVE_MARKER_FILE, JSON.stringify(sessionNames, null, 2))
120
- } catch (e) { log(`feishu: write alive marker failed: ${e}`) }
121
- }
122
-
123
- /** Read + unlink in one shot — marker is single-use: revival should
124
- * happen exactly once per boot, not re-run on every subsequent crash
125
- * loop where systemd might keep re-launching us. */
126
- export function readAndConsumeAliveMarker(): string[] {
127
- if (!existsSync(ALIVE_MARKER_FILE)) return []
128
- try {
129
- const raw = readFileSync(ALIVE_MARKER_FILE, 'utf8')
130
- try { unlinkSync(ALIVE_MARKER_FILE) } catch {}
131
- const data = JSON.parse(raw)
132
- return Array.isArray(data) ? data.filter((x: unknown): x is string => typeof x === 'string') : []
133
- } catch (e) {
134
- log(`feishu: read alive marker failed: ${e}`)
135
- try { unlinkSync(ALIVE_MARKER_FILE) } catch {}
136
- return []
137
- }
138
- }
139
-
140
- export function chatIdForSession(sessionName: string): string | null {
141
- const preferred = preferredChatForSession.get(sessionName)
142
- if (preferred && chatNameCache.get(preferred) === sessionName) return preferred
143
- const matches: string[] = []
144
- for (const [id, name] of chatNameCache) if (name === sessionName) matches.push(id)
145
- if (matches.length === 1) return matches[0]
146
- if (matches.length > 1) {
147
- log(`feishu: chatIdForSession("${sessionName}"): ${matches.length} candidates with no binding — [${matches.join(', ')}]`)
148
- }
149
- return null
150
- }
151
-
152
- export async function refreshChatList(): Promise<void> {
153
- try {
154
- let pageToken: string | undefined
155
- do {
156
- const res = await client.im.chat.list({
157
- params: { page_size: 100, ...(pageToken ? { page_token: pageToken } : {}) },
158
- })
159
- for (const chat of res.data?.items ?? []) {
160
- if (chat.chat_id && chat.name) chatNameCache.set(chat.chat_id, chat.name)
161
- }
162
- pageToken = res.data?.page_token
163
- } while (pageToken)
164
- log(`feishu: refreshed chat list — ${chatNameCache.size} groups`)
165
- } catch (e) { log(`feishu: refresh chat list failed: ${e}`) }
166
- }
167
-
168
- // ── Outbound: text + card ──────────────────────────────────────────────
169
- /** Delay schedule for sendText/sendCard SDK retries. Three attempts total
170
- * (the leading 0 is the eager first try). Tuned for the bun+axios+lark-SDK
171
- * ECONNREFUSED transient we've been seeing — by ~5s the socket pool
172
- * usually recovers. Business errors (Feishu code != 0) are NOT retried;
173
- * only thrown network errors are. */
174
- const SEND_RETRY_DELAYS_MS = [0, 1000, 4000]
175
-
176
- async function sendViaSdkWithRetry(
177
- what: 'text' | 'card',
178
- chatId: string,
179
- msgType: 'text' | 'interactive',
180
- content: string,
181
- ): Promise<string | null> {
182
- // Same uuid across retries → Feishu dedupes on its side so a successful-
183
- // but-response-lost first attempt doesn't produce a duplicate message.
184
- const uuid = randomUUID()
185
- let lastErr: unknown = null
186
- for (let i = 0; i < SEND_RETRY_DELAYS_MS.length; i++) {
187
- if (SEND_RETRY_DELAYS_MS[i] > 0) {
188
- await new Promise(r => setTimeout(r, SEND_RETRY_DELAYS_MS[i]))
189
- }
190
- try {
191
- const res: any = await client.im.message.create({
192
- params: { receive_id_type: 'chat_id' },
193
- data: { receive_id: chatId, msg_type: msgType, content, uuid },
194
- })
195
- if (res?.code && res.code !== 0) {
196
- log(`feishu: send${what === 'text' ? 'Text' : 'Card'} rejected chat=${chatId} code=${res.code} msg=${res.msg}`)
197
- return null
198
- }
199
- return res?.data?.message_id ?? null
200
- } catch (e) {
201
- lastErr = e
202
- log(`feishu: send${what === 'text' ? 'Text' : 'Card'} attempt ${i + 1}/${SEND_RETRY_DELAYS_MS.length} chat=${chatId} failed: ${e}`)
203
- }
204
- }
205
- log(`feishu: send${what === 'text' ? 'Text' : 'Card'} chat=${chatId} EXHAUSTED ${SEND_RETRY_DELAYS_MS.length} retries: ${lastErr}`)
206
- return null
207
- }
208
-
209
- export async function sendText(chatId: string, text: string): Promise<string | null> {
210
- return sendViaSdkWithRetry('text', chatId, 'text', JSON.stringify({ text }))
211
- }
212
-
213
- export async function sendCard(chatId: string, card: object): Promise<string | null> {
214
- return sendViaSdkWithRetry('card', chatId, 'interactive', JSON.stringify(card))
215
- }
216
-
217
- /** Last-resort text send that bypasses the lark SDK and uses raw fetch
218
- * (which is what cardkit.ts uses and has never had stability issues on
219
- * this runtime). Used by callers that need to *surface a failure when
220
- * the SDK send path itself is the broken thing* — e.g. `openTurnCard`'s
221
- * `sendCard` exhausted retries on ECONNREFUSED and we still owe the
222
- * user a visible "your message was lost, please retry" notice. Do not
223
- * use this as a general-purpose send; it's the failure-surfacing
224
- * channel, not a silent fallback. */
225
- export async function sendTextRaw(chatId: string, text: string): Promise<string | null> {
226
- try {
227
- const token = await getTenantToken()
228
- const res = await fetch('https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id', {
229
- method: 'POST',
230
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
231
- body: JSON.stringify({
232
- receive_id: chatId,
233
- msg_type: 'text',
234
- content: JSON.stringify({ text }),
235
- }),
236
- })
237
- const json = await res.json() as any
238
- if (json?.code !== 0) {
239
- log(`feishu: sendTextRaw rejected chat=${chatId} code=${json?.code} msg=${json?.msg}`)
240
- return null
241
- }
242
- return json.data?.message_id ?? null
243
- } catch (e) {
244
- log(`feishu: sendTextRaw chat=${chatId} failed: ${e}`)
245
- return null
246
- }
247
- }
248
-
249
- // ── Reactions ──────────────────────────────────────────────────────────
250
- /** Add an emoji reaction. Returns the new reaction_id on success (needed
251
- * to delete the reaction later via {@link deleteReaction}) or null on
252
- * failure. Failures are logged and swallowed — reactions are non-load-
253
- * bearing UX, not worth bubbling errors. */
254
- export async function addReaction(messageId: string, emojiType: string): Promise<string | null> {
255
- if (!messageId) return null
256
- try {
257
- const res: any = await client.im.messageReaction.create({
258
- path: { message_id: messageId },
259
- data: { reaction_type: { emoji_type: emojiType } },
260
- })
261
- return res?.data?.reaction_id ?? null
262
- } catch (e) { log(`feishu: addReaction ${emojiType} on ${messageId} failed: ${e}`); return null }
263
- }
264
-
265
- /** Remove a previously-added reaction by its reaction_id (returned from
266
- * {@link addReaction}). Used for the "queued → released" lifecycle: the
267
- * OneSecond placed on arrival is *removed* when the daemon hands the
268
- * message off to the SDK's batch / system-reminder pipeline, instead of
269
- * stacking a second CheckMark on top — keeps the message's reaction row
270
- * uncluttered. Quiet on failure. */
271
- export async function deleteReaction(messageId: string, reactionId: string): Promise<void> {
272
- if (!messageId || !reactionId) return
273
- try {
274
- await client.im.messageReaction.delete({
275
- path: { message_id: messageId, reaction_id: reactionId },
276
- })
277
- } catch (e) { log(`feishu: deleteReaction ${reactionId} on ${messageId} failed: ${e}`) }
278
- }
279
-
280
- // ── Urgent push ───────────────────────────────────────────────────────
281
- /** Fire Feishu's "加急 — 应用内" push for an already-sent message.
282
- * Bypasses chat-level mute and pops a full-screen prompt on the
283
- * recipient's phone. Bot must be the original sender of the message
284
- * AND must still be a member of the chat.
285
- *
286
- * Endpoint:
287
- * PATCH /open-apis/im/v1/messages/{message_id}/urgent_app
288
- * ?user_id_type=open_id
289
- * body: { user_id_list: ["ou_..."] }
290
- *
291
- * Required app scope (either one):
292
- * - `im:message.urgent` (「发送应用内加急消息」)
293
- * - `im:message.urgent:app_send` (「…(历史版本)」)
294
- *
295
- * Limits: 50 QPS app-wide; per-recipient cap is 200 unread urgent
296
- * messages (230023). No daily quota.
297
- *
298
- * Common error codes:
299
- * 230012 — message not sent by this bot
300
- * 230023 — recipient has 200 unread urgent already
301
- * 230052 — missing scope / chat restricts urgent */
302
- export async function urgentApp(messageId: string, openIds: string[]): Promise<void> {
303
- if (!messageId) { log(`feishu: urgentApp skip — missing messageId`); return }
304
- if (openIds.length === 0) { log(`feishu: urgentApp skip — empty openIds (msg=${messageId})`); return }
305
- const token = await getTenantToken()
306
- const url = `https://open.feishu.cn/open-apis/im/v1/messages/${messageId}/urgent_app?user_id_type=open_id`
307
- try {
308
- const res = await fetch(url, {
309
- method: 'PATCH',
310
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
311
- body: JSON.stringify({ user_id_list: openIds }),
312
- })
313
- const json = await res.json() as any
314
- if (json?.code !== 0) {
315
- log(`feishu: urgentApp ${messageId} code=${json?.code} msg=${json?.msg}`)
316
- return
317
- }
318
- const invalid = json.data?.invalid_user_id_list ?? []
319
- const delivered = openIds.length - invalid.length
320
- log(`feishu: urgentApp ${messageId} ok — delivered=${delivered}${invalid.length ? ` invalid=${invalid.length}` : ''}`)
321
- } catch (e) { log(`feishu: urgentApp ${messageId} failed: ${e}`) }
322
- }
323
-
324
- // ── Attachment download (image/file) ───────────────────────────────────
325
- export async function downloadAttachment(
326
- messageId: string, key: string, type: 'image' | 'file', name?: string,
327
- ): Promise<string | undefined> {
328
- try {
329
- const token = await getTenantToken()
330
- const url = `https://open.feishu.cn/open-apis/im/v1/messages/${messageId}/resources/${key}?type=${type}`
331
- const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
332
- if (!res.ok) {
333
- log(`feishu: download ${type} HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`)
334
- return undefined
335
- }
336
- const buf = Buffer.from(await res.arrayBuffer())
337
- mkdirSync(INBOX_DIR, { recursive: true })
338
- const safeName = name
339
- ? name.replace(/[^a-zA-Z0-9._-]/g, '_')
340
- : `${key.replace(/[^a-zA-Z0-9_-]/g, '_')}.png`
341
- const path = join(INBOX_DIR, `${Date.now()}-${safeName}`)
342
- writeFileSync(path, buf)
343
- log(`feishu: downloaded ${type} ${path} (${buf.length}B)`)
344
- return path
345
- } catch (e) {
346
- log(`feishu: download ${type} failed: ${e instanceof Error ? e.message : e}`)
347
- return undefined
348
- }
349
- }
350
-
351
- // ── Outbound: upload + send file/image ────────────────────────────────
352
- // Lark caps message images at ~30 MB; files vary by tenant (default 30 MB).
353
- // We refuse anything above 30 MB up front rather than chasing per-tenant
354
- // limits and surfacing opaque API errors mid-upload.
355
- const MAX_UPLOAD_BYTES = 30 * 1024 * 1024
356
- const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'])
357
-
358
- function looksLikeImage(filePath: string): boolean {
359
- return IMAGE_EXTS.has(extname(filePath).toLowerCase())
360
- }
361
-
362
- /** Defense against prompt-injection-driven exfiltration via the
363
- * [[send: /path]] marker. The resolved (symlink-followed) real path
364
- * must sit inside one of the explicitly-trusted roots. Anything
365
- * outside — `/etc/*`, `~/.ssh`, `~/.config/lodestar/config.toml`,
366
- * `~/.claude.json`, the user's home dotfiles — is rejected, even if a
367
- * symlink in an allowed root points to it.
368
- *
369
- * Returns [realPath, ''] on accept, [null, reason] on reject. */
370
- function gateOutboundPath(rawPath: string, allowedRoots: string[]): [string | null, string] {
371
- let real: string
372
- try { real = realpathSync(rawPath) }
373
- catch (e) { return [null, `realpath failed: ${e instanceof Error ? e.message : e}`] }
374
- for (const root of allowedRoots) {
375
- if (!root) continue
376
- if (real === root) return [real, '']
377
- const prefix = root.endsWith('/') ? root : root + '/'
378
- if (real.startsWith(prefix)) return [real, '']
379
- // Special-case the `/tmp/lodestar-` namespace: caller can pass
380
- // the bare prefix without a trailing slash to mean "any tmp file
381
- // whose basename starts with lodestar-".
382
- if (root.endsWith('-') && real.startsWith(root)) return [real, '']
383
- }
384
- return [null, `not inside any allowed root (real=${real})`]
385
- }
386
-
387
- async function uploadImageMultipart(filePath: string): Promise<string | null> {
388
- const token = await getTenantToken()
389
- const file = Bun.file(filePath)
390
- const form = new FormData()
391
- form.append('image_type', 'message')
392
- form.append('image', file, basename(filePath))
393
- const res = await fetch('https://open.feishu.cn/open-apis/im/v1/images', {
394
- method: 'POST',
395
- headers: { Authorization: `Bearer ${token}` },
396
- body: form,
397
- })
398
- const data = await res.json() as any
399
- if (data?.code !== 0) {
400
- log(`feishu: uploadImage ${filePath} code=${data.code} msg=${data.msg}`)
401
- return null
402
- }
403
- return data.data?.image_key ?? null
404
- }
405
-
406
- async function uploadFileMultipart(filePath: string): Promise<string | null> {
407
- const token = await getTenantToken()
408
- const file = Bun.file(filePath)
409
- const form = new FormData()
410
- // 'stream' is the catch-all type and works for arbitrary binaries.
411
- form.append('file_type', 'stream')
412
- form.append('file_name', basename(filePath))
413
- form.append('file', file, basename(filePath))
414
- const res = await fetch('https://open.feishu.cn/open-apis/im/v1/files', {
415
- method: 'POST',
416
- headers: { Authorization: `Bearer ${token}` },
417
- body: form,
418
- })
419
- const data = await res.json() as any
420
- if (data?.code !== 0) {
421
- log(`feishu: uploadFile ${filePath} code=${data.code} msg=${data.msg}`)
422
- return null
423
- }
424
- return data.data?.file_key ?? null
425
- }
426
-
427
- export async function sendImage(chatId: string, imageKey: string): Promise<string | null> {
428
- try {
429
- const res: any = await client.im.message.create({
430
- params: { receive_id_type: 'chat_id' },
431
- data: { receive_id: chatId, msg_type: 'image', content: JSON.stringify({ image_key: imageKey }) },
432
- })
433
- if (res?.code && res.code !== 0) {
434
- log(`feishu: sendImage rejected chat=${chatId} code=${res.code} msg=${res.msg}`)
435
- return null
436
- }
437
- return res?.data?.message_id ?? null
438
- } catch (e) { log(`feishu: sendImage failed chat=${chatId}: ${e}`); return null }
439
- }
440
-
441
- export async function sendFile(chatId: string, fileKey: string): Promise<string | null> {
442
- try {
443
- const res: any = await client.im.message.create({
444
- params: { receive_id_type: 'chat_id' },
445
- data: { receive_id: chatId, msg_type: 'file', content: JSON.stringify({ file_key: fileKey }) },
446
- })
447
- if (res?.code && res.code !== 0) {
448
- log(`feishu: sendFile rejected chat=${chatId} code=${res.code} msg=${res.msg}`)
449
- return null
450
- }
451
- return res?.data?.message_id ?? null
452
- } catch (e) { log(`feishu: sendFile failed chat=${chatId}: ${e}`); return null }
453
- }
454
-
455
- /** Upload a local file and post it as an image or file message in the
456
- * chat. Type is inferred from extension. The path MUST resolve inside
457
- * one of `allowedRoots` (defense against prompt-injection-driven
458
- * exfiltration via the `[[send: /path]]` marker — see isPathAllowed).
459
- * Returns true on success. All failures (missing file, oversize,
460
- * outside allowed roots, upload reject, send reject) log and surface
461
- * an inline error message in the chat so the user knows. */
462
- export async function uploadAndSend(chatId: string, filePath: string, allowedRoots: string[]): Promise<boolean> {
463
- const [realPath, reason] = gateOutboundPath(filePath, allowedRoots)
464
- if (!realPath) {
465
- log(`feishu: uploadAndSend REJECTED ${filePath}: ${reason}`)
466
- await sendText(chatId, `❌ 出站文件被拒绝(路径不在允许范围): ${filePath}`)
467
- return false
468
- }
469
- try {
470
- const stats = statSync(realPath)
471
- if (!stats.isFile()) {
472
- await sendText(chatId, `❌ 出站文件: 路径不是文件 — ${filePath}`)
473
- return false
474
- }
475
- if (stats.size > MAX_UPLOAD_BYTES) {
476
- await sendText(chatId, `❌ 出站文件: ${basename(realPath)} 超过 30 MB (${(stats.size / 1024 / 1024).toFixed(1)} MB)`)
477
- return false
478
- }
479
- } catch (e) {
480
- await sendText(chatId, `❌ 出站文件: 无法读取 ${filePath} (${e})`)
481
- return false
482
- }
483
- const isImage = looksLikeImage(realPath)
484
- try {
485
- if (isImage) {
486
- const key = await uploadImageMultipart(realPath)
487
- if (!key) { await sendText(chatId, `❌ 出站图片上传失败: ${basename(realPath)}`); return false }
488
- const msgId = await sendImage(chatId, key)
489
- return msgId != null
490
- } else {
491
- const key = await uploadFileMultipart(realPath)
492
- if (!key) { await sendText(chatId, `❌ 出站文件上传失败: ${basename(realPath)}`); return false }
493
- const msgId = await sendFile(chatId, key)
494
- return msgId != null
495
- }
496
- } catch (e) {
497
- log(`feishu: uploadAndSend ${filePath} failed: ${e}`)
498
- await sendText(chatId, `❌ 出站文件异常: ${basename(realPath)} — ${e}`)
499
- return false
500
- }
501
- }
502
-
503
- // ── Project provisioning ──────────────────────────────────────────────
504
- // Bootstrap ~/{name}: create dir, mark as trusted in ~/.claude.json so
505
- // Claude skips the trust dialog, and `git init` so the project starts as
506
- // a real repo.
507
- export function provisionProject(workDir: string): void {
508
- mkdirSync(workDir, { recursive: true })
509
- log(`feishu: provisioned ${workDir}`)
510
- const claudeJsonPath = join(homedir(), '.claude.json')
511
- try {
512
- let config: any = {}
513
- try { config = JSON.parse(readFileSync(claudeJsonPath, 'utf8')) } catch { config = {} }
514
- if (!config.projects || typeof config.projects !== 'object') config.projects = {}
515
- config.projects[workDir] = { ...(config.projects[workDir] ?? {}), hasTrustDialogAccepted: true }
516
- writeFileSync(claudeJsonPath, JSON.stringify(config, null, 2))
517
- } catch (e) { log(`feishu: trust write failed for ${workDir}: ${e}`) }
518
- try { execSync('git init -q', { cwd: workDir, stdio: 'ignore' }) } catch {}
519
- }
520
-
521
- export function isAnthropicAuthenticated(): boolean {
522
- try {
523
- const out = execSync(`${join(homedir(), '.local', 'bin', 'claude')} auth status 2>&1`, { timeout: 10_000 }).toString()
524
- const status = JSON.parse(out)
525
- return status.loggedIn === true && status.apiProvider === 'firstParty'
526
- } catch { return false }
527
- }
528
-
529
- export function sanitizeSessionName(raw: string): string {
530
- return raw.replace(/[^\w一-鿿\-]/g, '_').slice(0, 64)
531
- }
@@ -1,13 +0,0 @@
1
- /**
2
- * Daemon ↔ model I/O contracts. Appended to claude's system prompt on
3
- * every headless launch via `--append-system-prompt`. Three rules:
4
- * inbound file marker, multi-content boundary marker, outbound file
5
- * marker. Anything beyond pure I/O semantics (environment description,
6
- * UX conventions, identity binding) was stripped 2026-05-16 — the
7
- * model handles conversational flow natively, doesn't need to be told.
8
- */
9
- export const CHANNEL_INSTRUCTIONS = [
10
- '- Text prefixed with `[file: /abs/path]` means a file is attached at that path; read it when relevant.',
11
- '- A content block wrapped in `<u>...</u>` is an independent message — treat each `<u>` element in a multi-content turn as a separate input, even when their texts concatenate visually (e.g. `<u>1</u><u>45</u>` is two messages, not the number `145`).',
12
- '- Write `[[send: /abs/path]]` anywhere in your reply (preferably on its own line) to deliver that file as a separate message. The marker is stripped from the displayed text. Emit only when the user asked for a file or you are delivering a generated artifact.',
13
- ].join('\n')
package/src/log.ts DELETED
@@ -1,11 +0,0 @@
1
- import { appendFileSync, mkdirSync } from 'node:fs'
2
- import { dirname } from 'node:path'
3
- import { LOG_FILE } from './paths'
4
-
5
- try { mkdirSync(dirname(LOG_FILE), { recursive: true }) } catch {}
6
-
7
- export function log(msg: string): void {
8
- const line = `[${new Date().toISOString()}] ${msg}\n`
9
- process.stderr.write(line)
10
- try { appendFileSync(LOG_FILE, line) } catch {}
11
- }
package/src/paths.ts DELETED
@@ -1,57 +0,0 @@
1
- /**
2
- * Filesystem layout — XDG Base Directory spec, with env-var overrides.
3
- *
4
- * Config: $LODESTAR_CONFIG_DIR | $XDG_CONFIG_HOME/lodestar | ~/.config/lodestar
5
- * Data: $LODESTAR_DATA_DIR | $XDG_DATA_HOME/lodestar | ~/.local/share/lodestar
6
- *
7
- * config.toml — credentials + preferences (in CONFIG_DIR)
8
- * daemon.pid — single-instance lock (in DATA_DIR)
9
- * daemon.log — append-only run log (in DATA_DIR)
10
- * session-chat-map.json — duplicate-name routing (in DATA_DIR)
11
- * session-resume-map.json — last-known claude session_id (in DATA_DIR)
12
- * inbox/ — downloaded attachments (in DATA_DIR)
13
- */
14
-
15
- import { homedir } from 'node:os'
16
- import { join } from 'node:path'
17
-
18
- const HOME = homedir()
19
-
20
- function pickDir(envOverride: string | undefined, xdgVar: string | undefined, fallback: string): string {
21
- if (envOverride) return envOverride
22
- if (xdgVar) return join(xdgVar, 'lodestar')
23
- return fallback
24
- }
25
-
26
- export const CONFIG_DIR = pickDir(
27
- process.env.LODESTAR_CONFIG_DIR,
28
- process.env.XDG_CONFIG_HOME,
29
- join(HOME, '.config', 'lodestar'),
30
- )
31
-
32
- export const DATA_DIR = pickDir(
33
- process.env.LODESTAR_DATA_DIR,
34
- process.env.XDG_DATA_HOME,
35
- join(HOME, '.local', 'share', 'lodestar'),
36
- )
37
-
38
- export const CONFIG_FILE = process.env.LODESTAR_CONFIG ?? join(CONFIG_DIR, 'config.toml')
39
- export const PID_FILE = join(DATA_DIR, 'daemon.pid')
40
- export const LOG_FILE = join(DATA_DIR, 'daemon.log')
41
- export const SESSION_CHAT_MAP_FILE = join(DATA_DIR, 'session-chat-map.json')
42
- export const SESSION_RESUME_MAP_FILE = join(DATA_DIR, 'session-resume-map.json')
43
- /** Marker file written at shutdown listing the session names that
44
- * were still alive. The next daemon boot reads it (and unlinks it)
45
- * to auto-revive those sessions via `restart(true)` — bridges the
46
- * gap between systemctl-restart killing every child Claude and
47
- * Lodestar's "you have to send a message to re-spawn it" default. */
48
- export const ALIVE_MARKER_FILE = join(DATA_DIR, 'alive-on-shutdown.json')
49
- export const INBOX_DIR = join(DATA_DIR, 'inbox')
50
- /** Unix-socket endpoint the daemon listens on for debug message
51
- * injection (see scripts/test-inject.ts). A real Feishu user sends
52
- * a `[DEBUG]…` text once to seed `DEBUG_CTX_FILE` with chat_id +
53
- * sender_open_id; the injector then POSTs `{text}` here and the
54
- * daemon replays it through `handleMessage` as if WS had delivered
55
- * it. File mode 0600 — daemon-private, never network-exposed. */
56
- export const DEBUG_SOCK_FILE = join(DATA_DIR, 'debug.sock')
57
- export const DEBUG_CTX_FILE = join(DATA_DIR, 'debug-context.json')