@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/README.md +121 -40
- package/dist/lodestar-setup.js +12 -0
- package/dist/lodestar.js +147 -0
- package/package.json +15 -6
- package/scripts/postinstall.cjs +97 -0
- package/daemon.ts +0 -353
- package/src/cardkit.ts +0 -349
- package/src/cards.ts +0 -798
- package/src/claude-process.ts +0 -395
- package/src/config.ts +0 -83
- package/src/feishu.ts +0 -531
- package/src/instructions.ts +0 -13
- package/src/log.ts +0 -11
- package/src/paths.ts +0 -57
- package/src/session.ts +0 -1606
- package/src/usage.ts +0 -327
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
|
-
}
|
package/src/instructions.ts
DELETED
|
@@ -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')
|