@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/README.md +74 -41
- 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 -355
- package/src/cardkit.ts +0 -349
- package/src/cards/console.ts +0 -352
- package/src/cards/elements.ts +0 -22
- package/src/cards/turn.ts +0 -530
- package/src/cards.ts +0 -32
- package/src/claude-process.ts +0 -417
- package/src/config.ts +0 -98
- package/src/feishu.ts +0 -498
- package/src/instructions.ts +0 -13
- package/src/log.ts +0 -11
- package/src/notify.ts +0 -132
- package/src/paths.ts +0 -57
- package/src/session-ask.ts +0 -165
- package/src/session-permission.ts +0 -136
- package/src/session-tools.ts +0 -233
- package/src/session-types.ts +0 -91
- package/src/session.ts +0 -1137
- package/src/sysinfo.ts +0 -273
- package/src/usage.ts +0 -327
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
|
-
}
|
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/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
|
-
}
|