@leviyuan/lodestar 0.2.9 → 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,498 +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, 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
- async function uploadImageMultipart(filePath: string): Promise<string | null> {
363
- const token = await getTenantToken()
364
- const file = Bun.file(filePath)
365
- const form = new FormData()
366
- form.append('image_type', 'message')
367
- form.append('image', file, basename(filePath))
368
- const res = await fetch('https://open.feishu.cn/open-apis/im/v1/images', {
369
- method: 'POST',
370
- headers: { Authorization: `Bearer ${token}` },
371
- body: form,
372
- })
373
- const data = await res.json() as any
374
- if (data?.code !== 0) {
375
- log(`feishu: uploadImage ${filePath} code=${data.code} msg=${data.msg}`)
376
- return null
377
- }
378
- return data.data?.image_key ?? null
379
- }
380
-
381
- async function uploadFileMultipart(filePath: string): Promise<string | null> {
382
- const token = await getTenantToken()
383
- const file = Bun.file(filePath)
384
- const form = new FormData()
385
- // 'stream' is the catch-all type and works for arbitrary binaries.
386
- form.append('file_type', 'stream')
387
- form.append('file_name', basename(filePath))
388
- form.append('file', file, basename(filePath))
389
- const res = await fetch('https://open.feishu.cn/open-apis/im/v1/files', {
390
- method: 'POST',
391
- headers: { Authorization: `Bearer ${token}` },
392
- body: form,
393
- })
394
- const data = await res.json() as any
395
- if (data?.code !== 0) {
396
- log(`feishu: uploadFile ${filePath} code=${data.code} msg=${data.msg}`)
397
- return null
398
- }
399
- return data.data?.file_key ?? null
400
- }
401
-
402
- export async function sendImage(chatId: string, imageKey: string): Promise<string | null> {
403
- try {
404
- const res: any = await client.im.message.create({
405
- params: { receive_id_type: 'chat_id' },
406
- data: { receive_id: chatId, msg_type: 'image', content: JSON.stringify({ image_key: imageKey }) },
407
- })
408
- if (res?.code && res.code !== 0) {
409
- log(`feishu: sendImage rejected chat=${chatId} code=${res.code} msg=${res.msg}`)
410
- return null
411
- }
412
- return res?.data?.message_id ?? null
413
- } catch (e) { log(`feishu: sendImage failed chat=${chatId}: ${e}`); return null }
414
- }
415
-
416
- export async function sendFile(chatId: string, fileKey: string): Promise<string | null> {
417
- try {
418
- const res: any = await client.im.message.create({
419
- params: { receive_id_type: 'chat_id' },
420
- data: { receive_id: chatId, msg_type: 'file', content: JSON.stringify({ file_key: fileKey }) },
421
- })
422
- if (res?.code && res.code !== 0) {
423
- log(`feishu: sendFile rejected chat=${chatId} code=${res.code} msg=${res.msg}`)
424
- return null
425
- }
426
- return res?.data?.message_id ?? null
427
- } catch (e) { log(`feishu: sendFile failed chat=${chatId}: ${e}`); return null }
428
- }
429
-
430
- /** Upload a local file and post it as an image or file message in the
431
- * chat. Type is inferred from extension. Returns true on success.
432
- * All failures (missing file, oversize, upload reject, send reject)
433
- * log and surface an inline error message in the chat so the user
434
- * knows. */
435
- export async function uploadAndSend(chatId: string, filePath: string): Promise<boolean> {
436
- try {
437
- const stats = statSync(filePath)
438
- if (!stats.isFile()) {
439
- await sendText(chatId, `❌ 出站文件: 路径不是文件 — ${filePath}`)
440
- return false
441
- }
442
- if (stats.size > MAX_UPLOAD_BYTES) {
443
- await sendText(chatId, `❌ 出站文件: ${basename(filePath)} 超过 30 MB (${(stats.size / 1024 / 1024).toFixed(1)} MB)`)
444
- return false
445
- }
446
- } catch (e) {
447
- await sendText(chatId, `❌ 出站文件: 无法读取 ${filePath} (${e})`)
448
- return false
449
- }
450
- const isImage = looksLikeImage(filePath)
451
- try {
452
- if (isImage) {
453
- const key = await uploadImageMultipart(filePath)
454
- if (!key) { await sendText(chatId, `❌ 出站图片上传失败: ${basename(filePath)}`); return false }
455
- const msgId = await sendImage(chatId, key)
456
- return msgId != null
457
- } else {
458
- const key = await uploadFileMultipart(filePath)
459
- if (!key) { await sendText(chatId, `❌ 出站文件上传失败: ${basename(filePath)}`); return false }
460
- const msgId = await sendFile(chatId, key)
461
- return msgId != null
462
- }
463
- } catch (e) {
464
- log(`feishu: uploadAndSend ${filePath} failed: ${e}`)
465
- await sendText(chatId, `❌ 出站文件异常: ${basename(filePath)} — ${e}`)
466
- return false
467
- }
468
- }
469
-
470
- // ── Project provisioning ──────────────────────────────────────────────
471
- // Bootstrap ~/{name}: create dir, mark as trusted in ~/.claude.json so
472
- // Claude skips the trust dialog, and `git init` so the project starts as
473
- // a real repo.
474
- export function provisionProject(workDir: string): void {
475
- mkdirSync(workDir, { recursive: true })
476
- log(`feishu: provisioned ${workDir}`)
477
- const claudeJsonPath = join(homedir(), '.claude.json')
478
- try {
479
- let config: any = {}
480
- try { config = JSON.parse(readFileSync(claudeJsonPath, 'utf8')) } catch { config = {} }
481
- if (!config.projects || typeof config.projects !== 'object') config.projects = {}
482
- config.projects[workDir] = { ...(config.projects[workDir] ?? {}), hasTrustDialogAccepted: true }
483
- writeFileSync(claudeJsonPath, JSON.stringify(config, null, 2))
484
- } catch (e) { log(`feishu: trust write failed for ${workDir}: ${e}`) }
485
- try { execSync('git init -q', { cwd: workDir, stdio: 'ignore' }) } catch {}
486
- }
487
-
488
- export function isAnthropicAuthenticated(): boolean {
489
- try {
490
- const out = execSync(`${join(homedir(), '.local', 'bin', 'claude')} auth status 2>&1`, { timeout: 10_000 }).toString()
491
- const status = JSON.parse(out)
492
- return status.loggedIn === true && status.apiProvider === 'firstParty'
493
- } catch { return false }
494
- }
495
-
496
- export function sanitizeSessionName(raw: string): string {
497
- return raw.replace(/[^\w一-鿿\-]/g, '_').slice(0, 64)
498
- }
@@ -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/notify.ts DELETED
@@ -1,132 +0,0 @@
1
- /**
2
- * Outbound notification HTTP endpoint — any local process can POST a
3
- * markdown message + project name and it lands in the matching Feishu
4
- * group as a one-shot interactive card (non-streaming).
5
- *
6
- * Default bind: 127.0.0.1:9876. No auth — the daemon trusts loopback
7
- * on the assumption that anything able to hit this port is already
8
- * owner-equivalent (same security tier as the bot's stdin and the
9
- * debug.sock). Do NOT bind 0.0.0.0 without adding your own front-end
10
- * auth.
11
- *
12
- * POST /notify
13
- * Content-Type: application/json
14
- * { "project": "feishu",
15
- * "text": "**build done** 12 files",
16
- * "title": "build", // optional, default = project
17
- * "level": "info" | "warn" | "error" // optional, default "info"
18
- * }
19
- * → 200 { ok: true, chat_id, message_id }
20
- * → 400 bad/empty json or missing field
21
- * → 404 project not bound to any Feishu group
22
- * → 502 feishu sendCard failed (network / API rejection)
23
- *
24
- * GET / → plain-text help (so a `curl` from the shell reports a
25
- * live server instead of 404).
26
- *
27
- * "project" must match a Feishu group name that the daemon already
28
- * has a chat_id binding for — usually established by sending any
29
- * message in that group at least once after the daemon started. Run
30
- * `bun scripts/test-inject.ts "hi"` from the project root if you
31
- * need to seed the binding without leaving the keyboard.
32
- */
33
-
34
- import { log } from './log'
35
- import * as feishu from './feishu'
36
-
37
- type Level = 'info' | 'warn' | 'error'
38
- const VALID_LEVELS: ReadonlySet<Level> = new Set(['info', 'warn', 'error'])
39
-
40
- function notifyCard(opts: { title: string; text: string; level: Level }): object {
41
- const template = opts.level === 'error' ? 'red'
42
- : opts.level === 'warn' ? 'yellow'
43
- : 'blue'
44
- const emoji = opts.level === 'error' ? '❌'
45
- : opts.level === 'warn' ? '⚠️'
46
- : '🔔'
47
- const d = new Date()
48
- const hhmm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
49
- return {
50
- schema: '2.0',
51
- config: {},
52
- header: {
53
- title: { tag: 'plain_text', content: `${emoji} ${opts.title}` },
54
- template,
55
- },
56
- body: {
57
- elements: [
58
- { tag: 'markdown', content: opts.text || '_(空消息)_' },
59
- { tag: 'hr' },
60
- { tag: 'markdown', content: `<font color='grey'>via notify · ${hhmm}</font>` },
61
- ],
62
- },
63
- }
64
- }
65
-
66
- export interface NotifyOptions {
67
- bind: string
68
- port: number
69
- }
70
-
71
- export function startNotifyServer(opts: NotifyOptions): void {
72
- try {
73
- Bun.serve({
74
- hostname: opts.bind,
75
- port: opts.port,
76
- fetch: async (req: Request) => {
77
- const url = new URL(req.url)
78
- if (req.method === 'GET' && url.pathname === '/') {
79
- return new Response(
80
- 'lodestar notify\n' +
81
- 'POST /notify body={project,text,title?,level?}\n' +
82
- 'levels: info|warn|error (default info)\n',
83
- { headers: { 'content-type': 'text/plain; charset=utf-8' } },
84
- )
85
- }
86
- if (req.method !== 'POST' || url.pathname !== '/notify') {
87
- return new Response('use POST /notify', { status: 405 })
88
- }
89
-
90
- let body: any = {}
91
- try { body = await req.json() } catch {
92
- return new Response('bad json', { status: 400 })
93
- }
94
- const project = String(body.project ?? '').trim()
95
- const text = String(body.text ?? '')
96
- const titleRaw = String(body.title ?? '').trim()
97
- const levelRaw = String(body.level ?? 'info').toLowerCase()
98
- if (!project) return new Response('missing "project"', { status: 400 })
99
- if (!text) return new Response('missing "text"', { status: 400 })
100
-
101
- const level: Level = (VALID_LEVELS.has(levelRaw as Level) ? levelRaw : 'info') as Level
102
- const title = titleRaw || project
103
-
104
- const sessionName = feishu.sanitizeSessionName(project)
105
- const chatId = feishu.chatIdForSession(sessionName)
106
- if (!chatId) {
107
- log(`notify: project "${project}" (sanitized "${sessionName}") has no chat binding → 404`)
108
- return new Response(
109
- `project "${project}" not bound — send any message in that Feishu group at least once after the daemon started, then retry`,
110
- { status: 404 },
111
- )
112
- }
113
-
114
- const card = notifyCard({ title, text, level })
115
- const messageId = await feishu.sendCard(chatId, card)
116
- if (!messageId) {
117
- log(`notify: sendCard failed → 502 (project="${project}" chat=${chatId.slice(0, 8)}…)`)
118
- return new Response('feishu sendCard failed (see daemon log)', { status: 502 })
119
- }
120
- log(`notify: → ${project} (${chatId.slice(0, 8)}…) level=${level} bytes=${text.length} msg=${messageId}`)
121
- return Response.json({ ok: true, chat_id: chatId, message_id: messageId })
122
- },
123
- error: (err: Error) => {
124
- log(`notify: handler crash: ${err.message}`)
125
- return new Response('internal error', { status: 500 })
126
- },
127
- })
128
- log(`notify: HTTP listening at http://${opts.bind}:${opts.port}/notify`)
129
- } catch (e) {
130
- log(`notify: server bind failed (${opts.bind}:${opts.port}): ${e}`)
131
- }
132
- }