larkway 0.3.55 → 0.3.57
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 +1 -1
- package/README.zh.md +1 -1
- package/dist/cli/index.js +168 -39
- package/dist/main.js +262 -36
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
You @ the bot in a Feishu thread. It runs on your machine — reading your real codebase, executing commands, opening MRs — and posts the result back. You define what the agent knows and what it can do. Larkway just carries the messages.
|
|
10
10
|
|
|
11
|
-
**Current release: v0.3.
|
|
11
|
+
**Current release: v0.3.57**
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
package/README.zh.md
CHANGED
package/dist/cli/index.js
CHANGED
|
@@ -10515,12 +10515,12 @@ var require_form_data = __commonJS({
|
|
|
10515
10515
|
if (value.end != void 0 && value.end != Infinity && value.start != void 0) {
|
|
10516
10516
|
callback(null, value.end + 1 - (value.start ? value.start : 0));
|
|
10517
10517
|
} else {
|
|
10518
|
-
fs8.stat(value.path, function(err2,
|
|
10518
|
+
fs8.stat(value.path, function(err2, stat7) {
|
|
10519
10519
|
if (err2) {
|
|
10520
10520
|
callback(err2);
|
|
10521
10521
|
return;
|
|
10522
10522
|
}
|
|
10523
|
-
var fileSize =
|
|
10523
|
+
var fileSize = stat7.size - (value.start ? value.start : 0);
|
|
10524
10524
|
callback(null, fileSize);
|
|
10525
10525
|
});
|
|
10526
10526
|
}
|
|
@@ -108202,6 +108202,7 @@ __export(channelClient_exports, {
|
|
|
108202
108202
|
ChannelClient: () => ChannelClient,
|
|
108203
108203
|
DEFAULT_OPEN_CHAT_DISCOVERY_MS: () => DEFAULT_OPEN_CHAT_DISCOVERY_MS,
|
|
108204
108204
|
channelMsgToLarkEvent: () => channelMsgToLarkEvent,
|
|
108205
|
+
isBotSentMessage: () => isBotSentMessage,
|
|
108205
108206
|
resolveOpenChatDiscoveryMs: () => resolveOpenChatDiscoveryMs,
|
|
108206
108207
|
resolveRecoveredThreadId: () => resolveRecoveredThreadId,
|
|
108207
108208
|
synthesizeCardActionEvent: () => synthesizeCardActionEvent
|
|
@@ -108210,6 +108211,17 @@ import { execFile as execFileCallback } from "node:child_process";
|
|
|
108210
108211
|
import { mkdir as mkdir3, readFile as readFile5, writeFile as writeFile4, rename as rename3, unlink as unlink4 } from "node:fs/promises";
|
|
108211
108212
|
import path9 from "node:path";
|
|
108212
108213
|
import { promisify as promisify4 } from "node:util";
|
|
108214
|
+
function isChatAccessGoneError(e) {
|
|
108215
|
+
const parts = [];
|
|
108216
|
+
if (e instanceof Error) parts.push(e.message);
|
|
108217
|
+
if (e && typeof e === "object") {
|
|
108218
|
+
for (const key of ["stderr", "stdout"]) {
|
|
108219
|
+
const v = e[key];
|
|
108220
|
+
if (typeof v === "string") parts.push(v);
|
|
108221
|
+
}
|
|
108222
|
+
}
|
|
108223
|
+
return parts.some((s) => s.includes("230002"));
|
|
108224
|
+
}
|
|
108213
108225
|
function stripAtMarkup(s) {
|
|
108214
108226
|
return s.replace(/<at\b[^>]*>.*?<\/at>/gi, "").replace(/<at\b[^>]*\/>/gi, "").trim();
|
|
108215
108227
|
}
|
|
@@ -108297,6 +108309,16 @@ function resolveRecoveredThreadId(m) {
|
|
|
108297
108309
|
}
|
|
108298
108310
|
return null;
|
|
108299
108311
|
}
|
|
108312
|
+
function isBotSentMessage(m, botOpenId) {
|
|
108313
|
+
const sender = m["sender"];
|
|
108314
|
+
if (typeof sender === "string") return sender === botOpenId;
|
|
108315
|
+
if (sender && typeof sender === "object") {
|
|
108316
|
+
const s = sender;
|
|
108317
|
+
if (s["sender_type"] === "app") return true;
|
|
108318
|
+
if (typeof s["id"] === "string" && s["id"] === botOpenId) return true;
|
|
108319
|
+
}
|
|
108320
|
+
return false;
|
|
108321
|
+
}
|
|
108300
108322
|
function cardActionChoice(value) {
|
|
108301
108323
|
if (value && typeof value === "object") {
|
|
108302
108324
|
const choice = value["larkway_choice"];
|
|
@@ -108348,7 +108370,7 @@ function channelMsgToLarkEvent(msg, fallbackChatType) {
|
|
|
108348
108370
|
create_time: m?.["create_time"] ?? String(msg.createTime ?? Date.now())
|
|
108349
108371
|
};
|
|
108350
108372
|
}
|
|
108351
|
-
var import_node_sdk, execFile4, LEARNED_CHATS_LIMIT, SEEN_MESSAGES_LIMIT, MAX_MESSAGE_ATTEMPTS, OPEN_CHAT_DISCOVERY_LOOKBACK_BUFFER_MS, OPEN_CHAT_DISCOVERY_BOOTSTRAP_LOOKBACK_MS, PROCESSING_REACTION_EMOJI, DEFAULT_OPEN_CHAT_DISCOVERY_MS, OPEN_CHAT_DISCOVERY_JITTER_CAP_MS, OPEN_CHAT_DISCOVERY_MAX_BACKOFF_CYCLES, GAP_FILL_MAX_ATTEMPTS, GAP_FILL_BACKOFF_BASE_MS, UNRESOLVED_WINDOW_MAX_CHATS, UNRESOLVED_WINDOW_MAX_AGE_MS, DEFAULT_CONNECT_GRACE_MS, ChannelClient;
|
|
108373
|
+
var import_node_sdk, execFile4, LEARNED_CHATS_LIMIT, SEEN_MESSAGES_LIMIT, MAX_MESSAGE_ATTEMPTS, OPEN_CHAT_DISCOVERY_LOOKBACK_BUFFER_MS, OPEN_CHAT_DISCOVERY_BOOTSTRAP_LOOKBACK_MS, PROCESSING_REACTION_EMOJI, DEFAULT_OPEN_CHAT_DISCOVERY_MS, OPEN_CHAT_DISCOVERY_JITTER_CAP_MS, OPEN_CHAT_DISCOVERY_MAX_BACKOFF_CYCLES, GAP_FILL_MAX_ATTEMPTS, GAP_FILL_BACKOFF_BASE_MS, UNRESOLVED_WINDOW_MAX_CHATS, UNRESOLVED_WINDOW_MAX_AGE_MS, CHAT_ACCESS_GONE_UNTRACK_AFTER, DEFAULT_CONNECT_GRACE_MS, ChannelClient;
|
|
108352
108374
|
var init_channelClient = __esm({
|
|
108353
108375
|
"src/lark/channelClient.ts"() {
|
|
108354
108376
|
"use strict";
|
|
@@ -108373,6 +108395,7 @@ var init_channelClient = __esm({
|
|
|
108373
108395
|
GAP_FILL_BACKOFF_BASE_MS = 1e3;
|
|
108374
108396
|
UNRESOLVED_WINDOW_MAX_CHATS = 50;
|
|
108375
108397
|
UNRESOLVED_WINDOW_MAX_AGE_MS = 30 * 60 * 1e3;
|
|
108398
|
+
CHAT_ACCESS_GONE_UNTRACK_AFTER = 3;
|
|
108376
108399
|
DEFAULT_CONNECT_GRACE_MS = 3e3;
|
|
108377
108400
|
ChannelClient = class {
|
|
108378
108401
|
opts;
|
|
@@ -108471,6 +108494,16 @@ var init_channelClient = __esm({
|
|
|
108471
108494
|
* capped at UNRESOLVED_WINDOW_MAX_CHATS tracked chats.
|
|
108472
108495
|
*/
|
|
108473
108496
|
unresolvedGapWindowByChat = /* @__PURE__ */ new Map();
|
|
108497
|
+
/**
|
|
108498
|
+
* chatId → count of CONSECUTIVE gap-fill cycles whose pull failed with
|
|
108499
|
+
* 230002 (bot removed from the chat). At {@link CHAT_ACCESS_GONE_UNTRACK_AFTER}
|
|
108500
|
+
* the chat is dropped from gap-fill tracking entirely (see
|
|
108501
|
+
* {@link noteChatAccessGone}) — the fix for the 2026-07-17 amplifier where a
|
|
108502
|
+
* stale chat's unresolved window was replayed (3 retries each) every cycle,
|
|
108503
|
+
* forever, burning quota against a deterministic error. Reset by any
|
|
108504
|
+
* successful pull of the chat or any live sighting (noteSeenChat).
|
|
108505
|
+
*/
|
|
108506
|
+
chatAccessGoneCountByChat = /* @__PURE__ */ new Map();
|
|
108474
108507
|
/**
|
|
108475
108508
|
* Backoff sleep used by the per-chat history-pull retry. Indirected through a
|
|
108476
108509
|
* field purely so tests can observe/await the backoff deterministically; in
|
|
@@ -108558,6 +108591,44 @@ var init_channelClient = __esm({
|
|
|
108558
108591
|
unresolvedGapWindowsForTest() {
|
|
108559
108592
|
return new Map(this.unresolvedGapWindowByChat);
|
|
108560
108593
|
}
|
|
108594
|
+
/** TEST-ONLY read of the tracked (gap-fillable) chat id set. */
|
|
108595
|
+
trackedChatIdsForTest() {
|
|
108596
|
+
return new Set(this.recentlySeenChatIds);
|
|
108597
|
+
}
|
|
108598
|
+
/**
|
|
108599
|
+
* Seed gap-fill's tracked-chat set from durable session history
|
|
108600
|
+
* (sessions.json), BEFORE connect. Fix for the 2026-07-17 p2p message-loss
|
|
108601
|
+
* incident: p2p chats are invisible to bot-side chat-list discovery (the API
|
|
108602
|
+
* returns groups only), so once the in-memory tracking state was gone —
|
|
108603
|
+
* restart plus a missing/stale runtime cache — a p2p chat could never
|
|
108604
|
+
* re-enter the gap-fill list and its messages dropped during a WS outage
|
|
108605
|
+
* were lost forever. sessions.json persists the chatId (and, going forward,
|
|
108606
|
+
* chatType) of every thread the bot has served, so seeding from it makes a
|
|
108607
|
+
* p2p chat permanently gap-fillable once it has ever been seen.
|
|
108608
|
+
*
|
|
108609
|
+
* Deliberately does NOT persist to the runtime channel-seen-chats cache:
|
|
108610
|
+
* this runs before {@link loadRecentlySeenChatIds} merges the cache in, and
|
|
108611
|
+
* persisting the (possibly smaller) seed set here would overwrite cached
|
|
108612
|
+
* chats learned in previous runs. The union is persisted by the next
|
|
108613
|
+
* ordinary noteSeenChat.
|
|
108614
|
+
*/
|
|
108615
|
+
seedTrackedChats(entries) {
|
|
108616
|
+
let added = 0;
|
|
108617
|
+
for (const { chatId, chatType } of entries) {
|
|
108618
|
+
if (!chatId.startsWith("oc_")) continue;
|
|
108619
|
+
const before = this.recentlySeenChatIds.size;
|
|
108620
|
+
this.recentlySeenChatIds.add(chatId);
|
|
108621
|
+
if (this.recentlySeenChatIds.size > before) added++;
|
|
108622
|
+
if (chatType && !this.chatTypesById.has(chatId)) {
|
|
108623
|
+
this.chatTypesById.set(chatId, chatType);
|
|
108624
|
+
}
|
|
108625
|
+
}
|
|
108626
|
+
if (added > 0) {
|
|
108627
|
+
console.log(
|
|
108628
|
+
`[channel.client] seeded ${added} chat(s) from session history for gap-fill`
|
|
108629
|
+
);
|
|
108630
|
+
}
|
|
108631
|
+
}
|
|
108561
108632
|
/**
|
|
108562
108633
|
* TEST-ONLY: run exactly ONE open-chat discovery cycle (same code path the
|
|
108563
108634
|
* interval timer invokes), awaited to completion. Lets tests assert the
|
|
@@ -108693,10 +108764,10 @@ var init_channelClient = __esm({
|
|
|
108693
108764
|
log(`dropped (unmappable raw): ${JSON.stringify(msg.messageId ?? "?")}`);
|
|
108694
108765
|
return;
|
|
108695
108766
|
}
|
|
108696
|
-
this.noteSeenChat(
|
|
108697
|
-
|
|
108698
|
-
|
|
108699
|
-
|
|
108767
|
+
this.noteSeenChat(
|
|
108768
|
+
ev.chat_id,
|
|
108769
|
+
typeof ev.chat_type === "string" && ev.chat_type.length > 0 ? ev.chat_type : void 0
|
|
108770
|
+
);
|
|
108700
108771
|
if (this.seenMessageIds.has(ev.message_id) || this.inFlightMessageIds.has(ev.message_id)) {
|
|
108701
108772
|
return;
|
|
108702
108773
|
}
|
|
@@ -108769,10 +108840,10 @@ var init_channelClient = __esm({
|
|
|
108769
108840
|
ingestLocalEvent(ev, sourceTag) {
|
|
108770
108841
|
if (this.closed) return false;
|
|
108771
108842
|
const log = (s) => console.log(`[channel.client] ${s}`);
|
|
108772
|
-
this.noteSeenChat(
|
|
108773
|
-
|
|
108774
|
-
|
|
108775
|
-
|
|
108843
|
+
this.noteSeenChat(
|
|
108844
|
+
ev.chat_id,
|
|
108845
|
+
typeof ev.chat_type === "string" && ev.chat_type.length > 0 ? ev.chat_type : void 0
|
|
108846
|
+
);
|
|
108776
108847
|
if (this.seenMessageIds.has(ev.message_id) || this.inFlightMessageIds.has(ev.message_id)) {
|
|
108777
108848
|
log(`local-dispatch deduped (${sourceTag}): message_id=${ev.message_id}`);
|
|
108778
108849
|
return false;
|
|
@@ -108979,14 +109050,19 @@ var init_channelClient = __esm({
|
|
|
108979
109050
|
const messageId = m["message_id"];
|
|
108980
109051
|
if (!messageId) continue;
|
|
108981
109052
|
if (this.seenMessageIds.has(messageId) || this.inFlightMessageIds.has(messageId)) continue;
|
|
108982
|
-
const
|
|
108983
|
-
|
|
108984
|
-
(
|
|
108985
|
-
|
|
108986
|
-
|
|
108987
|
-
|
|
108988
|
-
|
|
108989
|
-
|
|
109053
|
+
const isP2pChat = this.chatTypesById.get(chatId) === "p2p";
|
|
109054
|
+
if (isP2pChat) {
|
|
109055
|
+
if (isBotSentMessage(m, botOpenId)) continue;
|
|
109056
|
+
} else {
|
|
109057
|
+
const mentions = m["mentions"];
|
|
109058
|
+
const mentionsBot = Array.isArray(mentions) && mentions.some(
|
|
109059
|
+
(mn) => {
|
|
109060
|
+
if (typeof mn?.id === "string") return mn.id === botOpenId;
|
|
109061
|
+
return mn?.id?.open_id === botOpenId;
|
|
109062
|
+
}
|
|
109063
|
+
);
|
|
109064
|
+
if (!mentionsBot) continue;
|
|
109065
|
+
}
|
|
108990
109066
|
const recoveredThread = resolveRecoveredThreadId(m);
|
|
108991
109067
|
const isThreadReply = recoveredThread !== null && recoveredThread !== messageId;
|
|
108992
109068
|
if (isThreadReply && !nonEmptyStringField(m, "root_id")) {
|
|
@@ -109040,7 +109116,12 @@ var init_channelClient = __esm({
|
|
|
109040
109116
|
totalDispatched++;
|
|
109041
109117
|
}
|
|
109042
109118
|
this.resolveUnresolvedGapWindow(chatId, windowStart);
|
|
109119
|
+
this.chatAccessGoneCountByChat.delete(chatId);
|
|
109043
109120
|
} catch (e) {
|
|
109121
|
+
if (isChatAccessGoneError(e)) {
|
|
109122
|
+
this.noteChatAccessGone(chatId, log);
|
|
109123
|
+
continue;
|
|
109124
|
+
}
|
|
109044
109125
|
anyChatFailed = true;
|
|
109045
109126
|
this.recordUnresolvedGapWindow(chatId, windowStart, log);
|
|
109046
109127
|
log(
|
|
@@ -109068,6 +109149,7 @@ var init_channelClient = __esm({
|
|
|
109068
109149
|
return await execFile4(larkCli, args);
|
|
109069
109150
|
} catch (e) {
|
|
109070
109151
|
lastErr = e;
|
|
109152
|
+
if (isChatAccessGoneError(e)) throw e;
|
|
109071
109153
|
if (attempt < GAP_FILL_MAX_ATTEMPTS) {
|
|
109072
109154
|
const backoffMs = GAP_FILL_BACKOFF_BASE_MS * 2 ** (attempt - 1);
|
|
109073
109155
|
log(
|
|
@@ -109124,6 +109206,33 @@ var init_channelClient = __esm({
|
|
|
109124
109206
|
if (tracked === void 0) return;
|
|
109125
109207
|
if (coveredFrom <= tracked) this.unresolvedGapWindowByChat.delete(chatId);
|
|
109126
109208
|
}
|
|
109209
|
+
/**
|
|
109210
|
+
* A gap-fill cycle hit 230002 for this chat (bot removed from it). Drop any
|
|
109211
|
+
* queued unresolved window IMMEDIATELY — replaying it burns quota against a
|
|
109212
|
+
* deterministic error (the 2026-07-17 amplifier) — and count a strike; at
|
|
109213
|
+
* {@link CHAT_ACCESS_GONE_UNTRACK_AFTER} consecutive strikes, untrack the
|
|
109214
|
+
* chat entirely so no future cycle pulls it. Self-healing: if the bot is
|
|
109215
|
+
* re-invited, the next live message (or discovery listing) re-tracks the
|
|
109216
|
+
* chat via {@link noteSeenChat}, which also resets the strikes.
|
|
109217
|
+
*/
|
|
109218
|
+
noteChatAccessGone(chatId, log) {
|
|
109219
|
+
this.unresolvedGapWindowByChat.delete(chatId);
|
|
109220
|
+
const strikes = (this.chatAccessGoneCountByChat.get(chatId) ?? 0) + 1;
|
|
109221
|
+
if (strikes < CHAT_ACCESS_GONE_UNTRACK_AFTER) {
|
|
109222
|
+
this.chatAccessGoneCountByChat.set(chatId, strikes);
|
|
109223
|
+
log(
|
|
109224
|
+
`gap-fill: chat ${chatId} inaccessible (230002 \u2014 bot not in chat), strike ${strikes}/${CHAT_ACCESS_GONE_UNTRACK_AFTER}`
|
|
109225
|
+
);
|
|
109226
|
+
return;
|
|
109227
|
+
}
|
|
109228
|
+
this.chatAccessGoneCountByChat.delete(chatId);
|
|
109229
|
+
const wasTracked = this.recentlySeenChatIds.delete(chatId);
|
|
109230
|
+
this.chatTypesById.delete(chatId);
|
|
109231
|
+
if (wasTracked) void this.persistRecentlySeenChatIds();
|
|
109232
|
+
log(
|
|
109233
|
+
`gap-fill: chat ${chatId} untracked after ${CHAT_ACCESS_GONE_UNTRACK_AFTER} consecutive inaccessible cycles (bot removed from chat?) \u2014 re-tracks automatically on the next live message`
|
|
109234
|
+
);
|
|
109235
|
+
}
|
|
109127
109236
|
/**
|
|
109128
109237
|
* Chats-mode replay loop (see {@link unresolvedReplayTimer}): every discovery
|
|
109129
109238
|
* interval, IF any unresolved gap window is pending, gapFill exactly those
|
|
@@ -109284,24 +109393,35 @@ var init_channelClient = __esm({
|
|
|
109284
109393
|
try {
|
|
109285
109394
|
const raw = await readFile5(file, "utf8");
|
|
109286
109395
|
const parsed = JSON.parse(raw);
|
|
109287
|
-
|
|
109396
|
+
const ids = Array.isArray(parsed) ? parsed : arrayField(parsed, "chats") ?? [];
|
|
109288
109397
|
let count = 0;
|
|
109289
|
-
for (const chatId of
|
|
109398
|
+
for (const chatId of ids) {
|
|
109290
109399
|
if (typeof chatId !== "string" || !chatId.startsWith("oc_")) continue;
|
|
109291
109400
|
this.recentlySeenChatIds.add(chatId);
|
|
109292
109401
|
count++;
|
|
109293
109402
|
}
|
|
109403
|
+
const types2 = !Array.isArray(parsed) && parsed && typeof parsed === "object" ? parsed["chatTypes"] : void 0;
|
|
109404
|
+
if (types2 && typeof types2 === "object") {
|
|
109405
|
+
for (const [chatId, t] of Object.entries(types2)) {
|
|
109406
|
+
if (typeof t !== "string" || t.length === 0) continue;
|
|
109407
|
+
if (!this.recentlySeenChatIds.has(chatId)) continue;
|
|
109408
|
+
this.chatTypesById.set(chatId, t);
|
|
109409
|
+
}
|
|
109410
|
+
}
|
|
109294
109411
|
if (count > 0) log(`loaded ${count} learned chat(s) for gap-fill`);
|
|
109295
109412
|
} catch (err2) {
|
|
109296
109413
|
if (err2.code === "ENOENT") return;
|
|
109297
109414
|
log(`learned chats load failed: ${err2.message}`);
|
|
109298
109415
|
}
|
|
109299
109416
|
}
|
|
109300
|
-
noteSeenChat(chatId) {
|
|
109417
|
+
noteSeenChat(chatId, chatType) {
|
|
109418
|
+
const typeChanged = chatType !== void 0 && this.chatTypesById.get(chatId) !== chatType;
|
|
109419
|
+
if (typeChanged) this.chatTypesById.set(chatId, chatType);
|
|
109301
109420
|
if (!chatId.startsWith("oc_")) return;
|
|
109421
|
+
this.chatAccessGoneCountByChat.delete(chatId);
|
|
109302
109422
|
const before = this.recentlySeenChatIds.size;
|
|
109303
109423
|
this.recentlySeenChatIds.add(chatId);
|
|
109304
|
-
if (this.recentlySeenChatIds.size === before) return;
|
|
109424
|
+
if (this.recentlySeenChatIds.size === before && !typeChanged) return;
|
|
109305
109425
|
void this.persistRecentlySeenChatIds();
|
|
109306
109426
|
}
|
|
109307
109427
|
async loadSeenMessageIds(log) {
|
|
@@ -109366,7 +109486,12 @@ var init_channelClient = __esm({
|
|
|
109366
109486
|
const file = this.learnedChatsPath();
|
|
109367
109487
|
if (!file) return;
|
|
109368
109488
|
const chats = [...this.recentlySeenChatIds].sort().slice(-LEARNED_CHATS_LIMIT);
|
|
109369
|
-
|
|
109489
|
+
const chatTypes = {};
|
|
109490
|
+
for (const chatId of chats) {
|
|
109491
|
+
const t = this.chatTypesById.get(chatId);
|
|
109492
|
+
if (t) chatTypes[chatId] = t;
|
|
109493
|
+
}
|
|
109494
|
+
await this.atomicWriteJson(file, { chats, chatTypes });
|
|
109370
109495
|
}
|
|
109371
109496
|
async addProcessingReaction(messageId) {
|
|
109372
109497
|
if (this.processingReactions.has(messageId)) return;
|
|
@@ -121335,7 +121460,7 @@ var jsYaml = {
|
|
|
121335
121460
|
};
|
|
121336
121461
|
|
|
121337
121462
|
// src/config/botLoader.ts
|
|
121338
|
-
import { readdir, readFile } from "node:fs/promises";
|
|
121463
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
121339
121464
|
import path from "node:path";
|
|
121340
121465
|
|
|
121341
121466
|
// src/responseSurface.ts
|
|
@@ -122026,6 +122151,10 @@ ${issues}`);
|
|
|
122026
122151
|
async function loadBots(botsDir) {
|
|
122027
122152
|
return (await loadBotsDetailed(botsDir)).bots;
|
|
122028
122153
|
}
|
|
122154
|
+
var ScheduleSliceSchema = BotConfigSchema.pick({
|
|
122155
|
+
schedules: true,
|
|
122156
|
+
schedule_chat_id: true
|
|
122157
|
+
}).strip();
|
|
122029
122158
|
|
|
122030
122159
|
// src/cli/botsStore.ts
|
|
122031
122160
|
init_paths();
|
|
@@ -122183,7 +122312,7 @@ __export(hostConfig_exports, {
|
|
|
122183
122312
|
writeHostConfig: () => writeHostConfig,
|
|
122184
122313
|
writeSecret: () => writeSecret
|
|
122185
122314
|
});
|
|
122186
|
-
import { mkdir as mkdir2, readFile as readFile4, writeFile as writeFile2, rename as rename2, chmod, access as access2, stat, unlink as unlink2 } from "node:fs/promises";
|
|
122315
|
+
import { mkdir as mkdir2, readFile as readFile4, writeFile as writeFile2, rename as rename2, chmod, access as access2, stat as stat2, unlink as unlink2 } from "node:fs/promises";
|
|
122187
122316
|
import path4 from "node:path";
|
|
122188
122317
|
|
|
122189
122318
|
// src/config.ts
|
|
@@ -122360,7 +122489,7 @@ async function removeSecret(envName) {
|
|
|
122360
122489
|
const body = [...map2.entries()].map(([k, v]) => `${k}=${quoteIfNeeded(v)}`).join("\n") + "\n";
|
|
122361
122490
|
await atomicWrite2(file, body);
|
|
122362
122491
|
try {
|
|
122363
|
-
const s = await
|
|
122492
|
+
const s = await stat2(file);
|
|
122364
122493
|
if ((s.mode & 511) !== 384) await chmod(file, 384);
|
|
122365
122494
|
} catch {
|
|
122366
122495
|
await chmod(file, 384);
|
|
@@ -122376,7 +122505,7 @@ async function envFileExists() {
|
|
|
122376
122505
|
}
|
|
122377
122506
|
|
|
122378
122507
|
// src/cli/commands/init.ts
|
|
122379
|
-
import { access as access5, stat as
|
|
122508
|
+
import { access as access5, stat as stat3 } from "node:fs/promises";
|
|
122380
122509
|
import { execFile as execFile3 } from "node:child_process";
|
|
122381
122510
|
import path8 from "node:path";
|
|
122382
122511
|
import "node:os";
|
|
@@ -122555,8 +122684,8 @@ async function writeAlways(filePath, content) {
|
|
|
122555
122684
|
}
|
|
122556
122685
|
async function ensureRelativeSymlink(linkPath, target) {
|
|
122557
122686
|
try {
|
|
122558
|
-
const
|
|
122559
|
-
if (
|
|
122687
|
+
const stat7 = await fs.lstat(linkPath);
|
|
122688
|
+
if (stat7.isSymbolicLink()) {
|
|
122560
122689
|
const currentTarget = await fs.readlink(linkPath);
|
|
122561
122690
|
if (currentTarget === target) return;
|
|
122562
122691
|
}
|
|
@@ -123170,7 +123299,7 @@ async function detectLocalRepos(cwd) {
|
|
|
123170
123299
|
for (const name of siblings.slice(0, 20)) {
|
|
123171
123300
|
const p = path8.join(parent, name);
|
|
123172
123301
|
try {
|
|
123173
|
-
const s = await
|
|
123302
|
+
const s = await stat3(p);
|
|
123174
123303
|
if (s.isDirectory()) candidates.push(p);
|
|
123175
123304
|
} catch {
|
|
123176
123305
|
}
|
|
@@ -123930,8 +124059,8 @@ async function checkWorktrees(ctx) {
|
|
|
123930
124059
|
const gitPath = path10.join(worktreeDir, ".git");
|
|
123931
124060
|
let gitContent = null;
|
|
123932
124061
|
try {
|
|
123933
|
-
const
|
|
123934
|
-
gitContent =
|
|
124062
|
+
const stat7 = await readFile7(gitPath, "utf-8").catch(() => null);
|
|
124063
|
+
gitContent = stat7;
|
|
123935
124064
|
} catch {
|
|
123936
124065
|
}
|
|
123937
124066
|
if (gitContent === null) {
|
|
@@ -125759,7 +125888,7 @@ import { readFile as readFile10, writeFile as writeFile8, unlink as unlink5, mkd
|
|
|
125759
125888
|
import { promisify as promisify6 } from "node:util";
|
|
125760
125889
|
import path15 from "node:path";
|
|
125761
125890
|
import process3 from "node:process";
|
|
125762
|
-
import { stat as
|
|
125891
|
+
import { stat as stat4 } from "node:fs/promises";
|
|
125763
125892
|
|
|
125764
125893
|
// src/bridge/statusFile.ts
|
|
125765
125894
|
init_paths();
|
|
@@ -126087,7 +126216,7 @@ async function tailBridgeLog(larkwayDir, n = 80) {
|
|
|
126087
126216
|
const logPath = bridgeLogPath(larkwayDir);
|
|
126088
126217
|
let content;
|
|
126089
126218
|
try {
|
|
126090
|
-
const s = await
|
|
126219
|
+
const s = await stat4(logPath);
|
|
126091
126220
|
const MAX_READ = 256 * 1024;
|
|
126092
126221
|
if (s.size > MAX_READ) {
|
|
126093
126222
|
const { open } = await import("node:fs/promises");
|
|
@@ -126681,12 +126810,12 @@ async function run7(ctx, args) {
|
|
|
126681
126810
|
import http from "node:http";
|
|
126682
126811
|
import { randomBytes } from "node:crypto";
|
|
126683
126812
|
import { existsSync } from "node:fs";
|
|
126684
|
-
import { readFile as readFile14, stat as
|
|
126813
|
+
import { readFile as readFile14, stat as stat6 } from "node:fs/promises";
|
|
126685
126814
|
import path25 from "node:path";
|
|
126686
126815
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
126687
126816
|
|
|
126688
126817
|
// src/web/api.ts
|
|
126689
|
-
import { readdir as readdir6, readFile as readFile13, stat as
|
|
126818
|
+
import { readdir as readdir6, readFile as readFile13, stat as stat5 } from "node:fs/promises";
|
|
126690
126819
|
import { execFile as execFileCallback2 } from "node:child_process";
|
|
126691
126820
|
import path24 from "node:path";
|
|
126692
126821
|
import { promisify as promisify8 } from "node:util";
|
|
@@ -128282,7 +128411,7 @@ var getStatus = async (req) => {
|
|
|
128282
128411
|
let logExists = false;
|
|
128283
128412
|
let logSizeKb = 0;
|
|
128284
128413
|
try {
|
|
128285
|
-
const s = await
|
|
128414
|
+
const s = await stat5(logPath);
|
|
128286
128415
|
logExists = true;
|
|
128287
128416
|
logSizeKb = Math.round(s.size / 1024);
|
|
128288
128417
|
} catch {
|
|
@@ -128744,7 +128873,7 @@ async function handleStatic(res, pathname, token) {
|
|
|
128744
128873
|
let target = resolved;
|
|
128745
128874
|
let isIndexFallback = false;
|
|
128746
128875
|
try {
|
|
128747
|
-
const s = await
|
|
128876
|
+
const s = await stat6(target);
|
|
128748
128877
|
if (s.isDirectory()) {
|
|
128749
128878
|
target = path25.join(target, "index.html");
|
|
128750
128879
|
}
|
package/dist/main.js
CHANGED
|
@@ -10067,12 +10067,12 @@ var require_form_data = __commonJS({
|
|
|
10067
10067
|
if (value.end != void 0 && value.end != Infinity && value.start != void 0) {
|
|
10068
10068
|
callback(null, value.end + 1 - (value.start ? value.start : 0));
|
|
10069
10069
|
} else {
|
|
10070
|
-
fs17.stat(value.path, function(err,
|
|
10070
|
+
fs17.stat(value.path, function(err, stat5) {
|
|
10071
10071
|
if (err) {
|
|
10072
10072
|
callback(err);
|
|
10073
10073
|
return;
|
|
10074
10074
|
}
|
|
10075
|
-
var fileSize =
|
|
10075
|
+
var fileSize = stat5.size - (value.start ? value.start : 0);
|
|
10076
10076
|
callback(null, fileSize);
|
|
10077
10077
|
});
|
|
10078
10078
|
}
|
|
@@ -112365,6 +112365,18 @@ var GAP_FILL_MAX_ATTEMPTS = 3;
|
|
|
112365
112365
|
var GAP_FILL_BACKOFF_BASE_MS = 1e3;
|
|
112366
112366
|
var UNRESOLVED_WINDOW_MAX_CHATS = 50;
|
|
112367
112367
|
var UNRESOLVED_WINDOW_MAX_AGE_MS = 30 * 60 * 1e3;
|
|
112368
|
+
var CHAT_ACCESS_GONE_UNTRACK_AFTER = 3;
|
|
112369
|
+
function isChatAccessGoneError(e) {
|
|
112370
|
+
const parts = [];
|
|
112371
|
+
if (e instanceof Error) parts.push(e.message);
|
|
112372
|
+
if (e && typeof e === "object") {
|
|
112373
|
+
for (const key of ["stderr", "stdout"]) {
|
|
112374
|
+
const v = e[key];
|
|
112375
|
+
if (typeof v === "string") parts.push(v);
|
|
112376
|
+
}
|
|
112377
|
+
}
|
|
112378
|
+
return parts.some((s) => s.includes("230002"));
|
|
112379
|
+
}
|
|
112368
112380
|
function stripAtMarkup(s) {
|
|
112369
112381
|
return s.replace(/<at\b[^>]*>.*?<\/at>/gi, "").replace(/<at\b[^>]*\/>/gi, "").trim();
|
|
112370
112382
|
}
|
|
@@ -112453,6 +112465,16 @@ function resolveRecoveredThreadId(m) {
|
|
|
112453
112465
|
}
|
|
112454
112466
|
return null;
|
|
112455
112467
|
}
|
|
112468
|
+
function isBotSentMessage(m, botOpenId) {
|
|
112469
|
+
const sender = m["sender"];
|
|
112470
|
+
if (typeof sender === "string") return sender === botOpenId;
|
|
112471
|
+
if (sender && typeof sender === "object") {
|
|
112472
|
+
const s = sender;
|
|
112473
|
+
if (s["sender_type"] === "app") return true;
|
|
112474
|
+
if (typeof s["id"] === "string" && s["id"] === botOpenId) return true;
|
|
112475
|
+
}
|
|
112476
|
+
return false;
|
|
112477
|
+
}
|
|
112456
112478
|
function cardActionChoice(value) {
|
|
112457
112479
|
if (value && typeof value === "object") {
|
|
112458
112480
|
const choice = value["larkway_choice"];
|
|
@@ -112601,6 +112623,16 @@ var ChannelClient = class {
|
|
|
112601
112623
|
* capped at UNRESOLVED_WINDOW_MAX_CHATS tracked chats.
|
|
112602
112624
|
*/
|
|
112603
112625
|
unresolvedGapWindowByChat = /* @__PURE__ */ new Map();
|
|
112626
|
+
/**
|
|
112627
|
+
* chatId → count of CONSECUTIVE gap-fill cycles whose pull failed with
|
|
112628
|
+
* 230002 (bot removed from the chat). At {@link CHAT_ACCESS_GONE_UNTRACK_AFTER}
|
|
112629
|
+
* the chat is dropped from gap-fill tracking entirely (see
|
|
112630
|
+
* {@link noteChatAccessGone}) — the fix for the 2026-07-17 amplifier where a
|
|
112631
|
+
* stale chat's unresolved window was replayed (3 retries each) every cycle,
|
|
112632
|
+
* forever, burning quota against a deterministic error. Reset by any
|
|
112633
|
+
* successful pull of the chat or any live sighting (noteSeenChat).
|
|
112634
|
+
*/
|
|
112635
|
+
chatAccessGoneCountByChat = /* @__PURE__ */ new Map();
|
|
112604
112636
|
/**
|
|
112605
112637
|
* Backoff sleep used by the per-chat history-pull retry. Indirected through a
|
|
112606
112638
|
* field purely so tests can observe/await the backoff deterministically; in
|
|
@@ -112688,6 +112720,44 @@ var ChannelClient = class {
|
|
|
112688
112720
|
unresolvedGapWindowsForTest() {
|
|
112689
112721
|
return new Map(this.unresolvedGapWindowByChat);
|
|
112690
112722
|
}
|
|
112723
|
+
/** TEST-ONLY read of the tracked (gap-fillable) chat id set. */
|
|
112724
|
+
trackedChatIdsForTest() {
|
|
112725
|
+
return new Set(this.recentlySeenChatIds);
|
|
112726
|
+
}
|
|
112727
|
+
/**
|
|
112728
|
+
* Seed gap-fill's tracked-chat set from durable session history
|
|
112729
|
+
* (sessions.json), BEFORE connect. Fix for the 2026-07-17 p2p message-loss
|
|
112730
|
+
* incident: p2p chats are invisible to bot-side chat-list discovery (the API
|
|
112731
|
+
* returns groups only), so once the in-memory tracking state was gone —
|
|
112732
|
+
* restart plus a missing/stale runtime cache — a p2p chat could never
|
|
112733
|
+
* re-enter the gap-fill list and its messages dropped during a WS outage
|
|
112734
|
+
* were lost forever. sessions.json persists the chatId (and, going forward,
|
|
112735
|
+
* chatType) of every thread the bot has served, so seeding from it makes a
|
|
112736
|
+
* p2p chat permanently gap-fillable once it has ever been seen.
|
|
112737
|
+
*
|
|
112738
|
+
* Deliberately does NOT persist to the runtime channel-seen-chats cache:
|
|
112739
|
+
* this runs before {@link loadRecentlySeenChatIds} merges the cache in, and
|
|
112740
|
+
* persisting the (possibly smaller) seed set here would overwrite cached
|
|
112741
|
+
* chats learned in previous runs. The union is persisted by the next
|
|
112742
|
+
* ordinary noteSeenChat.
|
|
112743
|
+
*/
|
|
112744
|
+
seedTrackedChats(entries) {
|
|
112745
|
+
let added = 0;
|
|
112746
|
+
for (const { chatId, chatType } of entries) {
|
|
112747
|
+
if (!chatId.startsWith("oc_")) continue;
|
|
112748
|
+
const before = this.recentlySeenChatIds.size;
|
|
112749
|
+
this.recentlySeenChatIds.add(chatId);
|
|
112750
|
+
if (this.recentlySeenChatIds.size > before) added++;
|
|
112751
|
+
if (chatType && !this.chatTypesById.has(chatId)) {
|
|
112752
|
+
this.chatTypesById.set(chatId, chatType);
|
|
112753
|
+
}
|
|
112754
|
+
}
|
|
112755
|
+
if (added > 0) {
|
|
112756
|
+
console.log(
|
|
112757
|
+
`[channel.client] seeded ${added} chat(s) from session history for gap-fill`
|
|
112758
|
+
);
|
|
112759
|
+
}
|
|
112760
|
+
}
|
|
112691
112761
|
/**
|
|
112692
112762
|
* TEST-ONLY: run exactly ONE open-chat discovery cycle (same code path the
|
|
112693
112763
|
* interval timer invokes), awaited to completion. Lets tests assert the
|
|
@@ -112823,10 +112893,10 @@ var ChannelClient = class {
|
|
|
112823
112893
|
log(`dropped (unmappable raw): ${JSON.stringify(msg.messageId ?? "?")}`);
|
|
112824
112894
|
return;
|
|
112825
112895
|
}
|
|
112826
|
-
this.noteSeenChat(
|
|
112827
|
-
|
|
112828
|
-
|
|
112829
|
-
|
|
112896
|
+
this.noteSeenChat(
|
|
112897
|
+
ev.chat_id,
|
|
112898
|
+
typeof ev.chat_type === "string" && ev.chat_type.length > 0 ? ev.chat_type : void 0
|
|
112899
|
+
);
|
|
112830
112900
|
if (this.seenMessageIds.has(ev.message_id) || this.inFlightMessageIds.has(ev.message_id)) {
|
|
112831
112901
|
return;
|
|
112832
112902
|
}
|
|
@@ -112899,10 +112969,10 @@ var ChannelClient = class {
|
|
|
112899
112969
|
ingestLocalEvent(ev, sourceTag) {
|
|
112900
112970
|
if (this.closed) return false;
|
|
112901
112971
|
const log = (s) => console.log(`[channel.client] ${s}`);
|
|
112902
|
-
this.noteSeenChat(
|
|
112903
|
-
|
|
112904
|
-
|
|
112905
|
-
|
|
112972
|
+
this.noteSeenChat(
|
|
112973
|
+
ev.chat_id,
|
|
112974
|
+
typeof ev.chat_type === "string" && ev.chat_type.length > 0 ? ev.chat_type : void 0
|
|
112975
|
+
);
|
|
112906
112976
|
if (this.seenMessageIds.has(ev.message_id) || this.inFlightMessageIds.has(ev.message_id)) {
|
|
112907
112977
|
log(`local-dispatch deduped (${sourceTag}): message_id=${ev.message_id}`);
|
|
112908
112978
|
return false;
|
|
@@ -113109,14 +113179,19 @@ var ChannelClient = class {
|
|
|
113109
113179
|
const messageId = m["message_id"];
|
|
113110
113180
|
if (!messageId) continue;
|
|
113111
113181
|
if (this.seenMessageIds.has(messageId) || this.inFlightMessageIds.has(messageId)) continue;
|
|
113112
|
-
const
|
|
113113
|
-
|
|
113114
|
-
(
|
|
113115
|
-
|
|
113116
|
-
|
|
113117
|
-
|
|
113118
|
-
|
|
113119
|
-
|
|
113182
|
+
const isP2pChat = this.chatTypesById.get(chatId) === "p2p";
|
|
113183
|
+
if (isP2pChat) {
|
|
113184
|
+
if (isBotSentMessage(m, botOpenId)) continue;
|
|
113185
|
+
} else {
|
|
113186
|
+
const mentions = m["mentions"];
|
|
113187
|
+
const mentionsBot = Array.isArray(mentions) && mentions.some(
|
|
113188
|
+
(mn) => {
|
|
113189
|
+
if (typeof mn?.id === "string") return mn.id === botOpenId;
|
|
113190
|
+
return mn?.id?.open_id === botOpenId;
|
|
113191
|
+
}
|
|
113192
|
+
);
|
|
113193
|
+
if (!mentionsBot) continue;
|
|
113194
|
+
}
|
|
113120
113195
|
const recoveredThread = resolveRecoveredThreadId(m);
|
|
113121
113196
|
const isThreadReply = recoveredThread !== null && recoveredThread !== messageId;
|
|
113122
113197
|
if (isThreadReply && !nonEmptyStringField(m, "root_id")) {
|
|
@@ -113170,7 +113245,12 @@ var ChannelClient = class {
|
|
|
113170
113245
|
totalDispatched++;
|
|
113171
113246
|
}
|
|
113172
113247
|
this.resolveUnresolvedGapWindow(chatId, windowStart);
|
|
113248
|
+
this.chatAccessGoneCountByChat.delete(chatId);
|
|
113173
113249
|
} catch (e) {
|
|
113250
|
+
if (isChatAccessGoneError(e)) {
|
|
113251
|
+
this.noteChatAccessGone(chatId, log);
|
|
113252
|
+
continue;
|
|
113253
|
+
}
|
|
113174
113254
|
anyChatFailed = true;
|
|
113175
113255
|
this.recordUnresolvedGapWindow(chatId, windowStart, log);
|
|
113176
113256
|
log(
|
|
@@ -113198,6 +113278,7 @@ var ChannelClient = class {
|
|
|
113198
113278
|
return await execFile(larkCli, args);
|
|
113199
113279
|
} catch (e) {
|
|
113200
113280
|
lastErr = e;
|
|
113281
|
+
if (isChatAccessGoneError(e)) throw e;
|
|
113201
113282
|
if (attempt < GAP_FILL_MAX_ATTEMPTS) {
|
|
113202
113283
|
const backoffMs = GAP_FILL_BACKOFF_BASE_MS * 2 ** (attempt - 1);
|
|
113203
113284
|
log(
|
|
@@ -113254,6 +113335,33 @@ var ChannelClient = class {
|
|
|
113254
113335
|
if (tracked === void 0) return;
|
|
113255
113336
|
if (coveredFrom <= tracked) this.unresolvedGapWindowByChat.delete(chatId);
|
|
113256
113337
|
}
|
|
113338
|
+
/**
|
|
113339
|
+
* A gap-fill cycle hit 230002 for this chat (bot removed from it). Drop any
|
|
113340
|
+
* queued unresolved window IMMEDIATELY — replaying it burns quota against a
|
|
113341
|
+
* deterministic error (the 2026-07-17 amplifier) — and count a strike; at
|
|
113342
|
+
* {@link CHAT_ACCESS_GONE_UNTRACK_AFTER} consecutive strikes, untrack the
|
|
113343
|
+
* chat entirely so no future cycle pulls it. Self-healing: if the bot is
|
|
113344
|
+
* re-invited, the next live message (or discovery listing) re-tracks the
|
|
113345
|
+
* chat via {@link noteSeenChat}, which also resets the strikes.
|
|
113346
|
+
*/
|
|
113347
|
+
noteChatAccessGone(chatId, log) {
|
|
113348
|
+
this.unresolvedGapWindowByChat.delete(chatId);
|
|
113349
|
+
const strikes = (this.chatAccessGoneCountByChat.get(chatId) ?? 0) + 1;
|
|
113350
|
+
if (strikes < CHAT_ACCESS_GONE_UNTRACK_AFTER) {
|
|
113351
|
+
this.chatAccessGoneCountByChat.set(chatId, strikes);
|
|
113352
|
+
log(
|
|
113353
|
+
`gap-fill: chat ${chatId} inaccessible (230002 \u2014 bot not in chat), strike ${strikes}/${CHAT_ACCESS_GONE_UNTRACK_AFTER}`
|
|
113354
|
+
);
|
|
113355
|
+
return;
|
|
113356
|
+
}
|
|
113357
|
+
this.chatAccessGoneCountByChat.delete(chatId);
|
|
113358
|
+
const wasTracked = this.recentlySeenChatIds.delete(chatId);
|
|
113359
|
+
this.chatTypesById.delete(chatId);
|
|
113360
|
+
if (wasTracked) void this.persistRecentlySeenChatIds();
|
|
113361
|
+
log(
|
|
113362
|
+
`gap-fill: chat ${chatId} untracked after ${CHAT_ACCESS_GONE_UNTRACK_AFTER} consecutive inaccessible cycles (bot removed from chat?) \u2014 re-tracks automatically on the next live message`
|
|
113363
|
+
);
|
|
113364
|
+
}
|
|
113257
113365
|
/**
|
|
113258
113366
|
* Chats-mode replay loop (see {@link unresolvedReplayTimer}): every discovery
|
|
113259
113367
|
* interval, IF any unresolved gap window is pending, gapFill exactly those
|
|
@@ -113414,24 +113522,35 @@ var ChannelClient = class {
|
|
|
113414
113522
|
try {
|
|
113415
113523
|
const raw = await readFile2(file, "utf8");
|
|
113416
113524
|
const parsed = JSON.parse(raw);
|
|
113417
|
-
|
|
113525
|
+
const ids = Array.isArray(parsed) ? parsed : arrayField(parsed, "chats") ?? [];
|
|
113418
113526
|
let count = 0;
|
|
113419
|
-
for (const chatId of
|
|
113527
|
+
for (const chatId of ids) {
|
|
113420
113528
|
if (typeof chatId !== "string" || !chatId.startsWith("oc_")) continue;
|
|
113421
113529
|
this.recentlySeenChatIds.add(chatId);
|
|
113422
113530
|
count++;
|
|
113423
113531
|
}
|
|
113532
|
+
const types2 = !Array.isArray(parsed) && parsed && typeof parsed === "object" ? parsed["chatTypes"] : void 0;
|
|
113533
|
+
if (types2 && typeof types2 === "object") {
|
|
113534
|
+
for (const [chatId, t] of Object.entries(types2)) {
|
|
113535
|
+
if (typeof t !== "string" || t.length === 0) continue;
|
|
113536
|
+
if (!this.recentlySeenChatIds.has(chatId)) continue;
|
|
113537
|
+
this.chatTypesById.set(chatId, t);
|
|
113538
|
+
}
|
|
113539
|
+
}
|
|
113424
113540
|
if (count > 0) log(`loaded ${count} learned chat(s) for gap-fill`);
|
|
113425
113541
|
} catch (err) {
|
|
113426
113542
|
if (err.code === "ENOENT") return;
|
|
113427
113543
|
log(`learned chats load failed: ${err.message}`);
|
|
113428
113544
|
}
|
|
113429
113545
|
}
|
|
113430
|
-
noteSeenChat(chatId) {
|
|
113546
|
+
noteSeenChat(chatId, chatType) {
|
|
113547
|
+
const typeChanged = chatType !== void 0 && this.chatTypesById.get(chatId) !== chatType;
|
|
113548
|
+
if (typeChanged) this.chatTypesById.set(chatId, chatType);
|
|
113431
113549
|
if (!chatId.startsWith("oc_")) return;
|
|
113550
|
+
this.chatAccessGoneCountByChat.delete(chatId);
|
|
113432
113551
|
const before = this.recentlySeenChatIds.size;
|
|
113433
113552
|
this.recentlySeenChatIds.add(chatId);
|
|
113434
|
-
if (this.recentlySeenChatIds.size === before) return;
|
|
113553
|
+
if (this.recentlySeenChatIds.size === before && !typeChanged) return;
|
|
113435
113554
|
void this.persistRecentlySeenChatIds();
|
|
113436
113555
|
}
|
|
113437
113556
|
async loadSeenMessageIds(log) {
|
|
@@ -113496,7 +113615,12 @@ var ChannelClient = class {
|
|
|
113496
113615
|
const file = this.learnedChatsPath();
|
|
113497
113616
|
if (!file) return;
|
|
113498
113617
|
const chats = [...this.recentlySeenChatIds].sort().slice(-LEARNED_CHATS_LIMIT);
|
|
113499
|
-
|
|
113618
|
+
const chatTypes = {};
|
|
113619
|
+
for (const chatId of chats) {
|
|
113620
|
+
const t = this.chatTypesById.get(chatId);
|
|
113621
|
+
if (t) chatTypes[chatId] = t;
|
|
113622
|
+
}
|
|
113623
|
+
await this.atomicWriteJson(file, { chats, chatTypes });
|
|
113500
113624
|
}
|
|
113501
113625
|
async addProcessingReaction(messageId) {
|
|
113502
113626
|
if (this.processingReactions.has(messageId)) return;
|
|
@@ -114300,6 +114424,7 @@ var SessionStore = class _SessionStore {
|
|
|
114300
114424
|
...record.senderOpenId !== void 0 ? { senderOpenId: record.senderOpenId } : {},
|
|
114301
114425
|
...record.rootText !== void 0 ? { rootText: record.rootText } : {},
|
|
114302
114426
|
...record.chatId !== void 0 ? { chatId: record.chatId } : {},
|
|
114427
|
+
...record.chatType !== void 0 ? { chatType: record.chatType } : {},
|
|
114303
114428
|
// BL-38: only persist when > 0 — a 0/undefined counter is a clean thread,
|
|
114304
114429
|
// so passing consecutiveStuckCount: 0 naturally clears the field on reset.
|
|
114305
114430
|
...record.consecutiveStuckCount ? { consecutiveStuckCount: record.consecutiveStuckCount } : {},
|
|
@@ -114439,7 +114564,7 @@ var SessionStore = class _SessionStore {
|
|
|
114439
114564
|
function isStoredRecord(value) {
|
|
114440
114565
|
if (typeof value !== "object" || value === null) return false;
|
|
114441
114566
|
const v = value;
|
|
114442
|
-
return typeof v["threadId"] === "string" && typeof v["sessionId"] === "string" && typeof v["botId"] === "string" && typeof v["createdTs"] === "number" && typeof v["lastActiveTs"] === "number" && (v["senderOpenId"] === void 0 || typeof v["senderOpenId"] === "string") && (v["rootText"] === void 0 || typeof v["rootText"] === "string") && (v["chatId"] === void 0 || typeof v["chatId"] === "string") && (v["turnCount"] === void 0 || typeof v["turnCount"] === "number") && (v["harvestedAt"] === void 0 || typeof v["harvestedAt"] === "number") && (v["approxChars"] === void 0 || typeof v["approxChars"] === "number") && (v["needsFreshStart"] === void 0 || isFreshStartMarker(v["needsFreshStart"]));
|
|
114567
|
+
return typeof v["threadId"] === "string" && typeof v["sessionId"] === "string" && typeof v["botId"] === "string" && typeof v["createdTs"] === "number" && typeof v["lastActiveTs"] === "number" && (v["senderOpenId"] === void 0 || typeof v["senderOpenId"] === "string") && (v["rootText"] === void 0 || typeof v["rootText"] === "string") && (v["chatId"] === void 0 || typeof v["chatId"] === "string") && (v["chatType"] === void 0 || typeof v["chatType"] === "string") && (v["turnCount"] === void 0 || typeof v["turnCount"] === "number") && (v["harvestedAt"] === void 0 || typeof v["harvestedAt"] === "number") && (v["approxChars"] === void 0 || typeof v["approxChars"] === "number") && (v["needsFreshStart"] === void 0 || isFreshStartMarker(v["needsFreshStart"]));
|
|
114443
114568
|
}
|
|
114444
114569
|
function isFreshStartMarker(value) {
|
|
114445
114570
|
if (typeof value !== "object" || value === null) return false;
|
|
@@ -116052,8 +116177,8 @@ async function writeAlways(filePath, content) {
|
|
|
116052
116177
|
}
|
|
116053
116178
|
async function ensureRelativeSymlink(linkPath, target) {
|
|
116054
116179
|
try {
|
|
116055
|
-
const
|
|
116056
|
-
if (
|
|
116180
|
+
const stat5 = await fs4.lstat(linkPath);
|
|
116181
|
+
if (stat5.isSymbolicLink()) {
|
|
116057
116182
|
const currentTarget = await fs4.readlink(linkPath);
|
|
116058
116183
|
if (currentTarget === target) return;
|
|
116059
116184
|
}
|
|
@@ -120116,7 +120241,15 @@ var BridgeHandler = class {
|
|
|
120116
120241
|
turnCount: 1,
|
|
120117
120242
|
// 批H (H2): volume accounting starts with this turn's own contribution.
|
|
120118
120243
|
approxChars: turnToolResultChars + trustedAnswerText.length,
|
|
120119
|
-
...isTopLevel ? {
|
|
120244
|
+
...isTopLevel ? {
|
|
120245
|
+
rootText: parsed.text.slice(0, 200),
|
|
120246
|
+
chatId: parsed.chatId,
|
|
120247
|
+
// 2026-07-17 p2p message-loss fix: persist the chat's type
|
|
120248
|
+
// alongside chatId so ChannelClient.seedTrackedChats can
|
|
120249
|
+
// mark a p2p chat gap-fillable across restarts (p2p is
|
|
120250
|
+
// invisible to bot chat-list discovery).
|
|
120251
|
+
...typeof parsed.raw.chat_type === "string" && parsed.raw.chat_type ? { chatType: parsed.raw.chat_type } : {}
|
|
120252
|
+
} : {}
|
|
120120
120253
|
});
|
|
120121
120254
|
} else if (sessionId !== void 0 && currentExisting !== void 0) {
|
|
120122
120255
|
await this.deps.sessionStore.put({
|
|
@@ -120128,6 +120261,7 @@ var BridgeHandler = class {
|
|
|
120128
120261
|
senderOpenId,
|
|
120129
120262
|
rootText: currentExisting.rootText,
|
|
120130
120263
|
chatId: currentExisting.chatId,
|
|
120264
|
+
chatType: currentExisting.chatType,
|
|
120131
120265
|
// BL-38: +1 on an idle-stuck turn, 0 (cleared) on any clean turn.
|
|
120132
120266
|
consecutiveStuckCount: nextStuckCount,
|
|
120133
120267
|
// 批F (F2): a reseed turn restarts the count at 1 (this turn ran
|
|
@@ -121175,7 +121309,7 @@ async function cleanupAgentSession(threadId, agentId, dryRun) {
|
|
|
121175
121309
|
}
|
|
121176
121310
|
|
|
121177
121311
|
// src/config/botLoader.ts
|
|
121178
|
-
import { readdir as readdir2, readFile as readFile5 } from "node:fs/promises";
|
|
121312
|
+
import { readdir as readdir2, readFile as readFile5, stat as stat2 } from "node:fs/promises";
|
|
121179
121313
|
import path17 from "node:path";
|
|
121180
121314
|
|
|
121181
121315
|
// node_modules/.pnpm/js-yaml@4.1.1/node_modules/js-yaml/dist/js-yaml.mjs
|
|
@@ -124363,9 +124497,56 @@ ${issues}`);
|
|
|
124363
124497
|
}
|
|
124364
124498
|
return { bots: deduped, skipped, strippedPeers };
|
|
124365
124499
|
}
|
|
124500
|
+
var ScheduleSliceSchema = BotConfigSchema.pick({
|
|
124501
|
+
schedules: true,
|
|
124502
|
+
schedule_chat_id: true
|
|
124503
|
+
}).strip();
|
|
124504
|
+
function createScheduleConfigReloader(botsDir, botId) {
|
|
124505
|
+
let filePath;
|
|
124506
|
+
let lastMtimeMs;
|
|
124507
|
+
return async () => {
|
|
124508
|
+
try {
|
|
124509
|
+
if (filePath === void 0) {
|
|
124510
|
+
const entries = (await readdir2(botsDir)).filter(
|
|
124511
|
+
(f) => f.endsWith(".yaml") || f.endsWith(".yml")
|
|
124512
|
+
);
|
|
124513
|
+
for (const f of entries.sort()) {
|
|
124514
|
+
const p = path17.join(botsDir, f);
|
|
124515
|
+
try {
|
|
124516
|
+
const parsed2 = jsYaml.load(await readFile5(p, "utf-8"));
|
|
124517
|
+
if (parsed2 && parsed2["id"] === botId) {
|
|
124518
|
+
filePath = p;
|
|
124519
|
+
break;
|
|
124520
|
+
}
|
|
124521
|
+
} catch {
|
|
124522
|
+
}
|
|
124523
|
+
}
|
|
124524
|
+
if (filePath === void 0) return null;
|
|
124525
|
+
}
|
|
124526
|
+
const st = await stat2(filePath);
|
|
124527
|
+
if (lastMtimeMs !== void 0 && st.mtimeMs === lastMtimeMs) return null;
|
|
124528
|
+
const parsed = jsYaml.load(await readFile5(filePath, "utf-8"));
|
|
124529
|
+
const result = ScheduleSliceSchema.safeParse(parsed);
|
|
124530
|
+
if (!result.success) return null;
|
|
124531
|
+
lastMtimeMs = st.mtimeMs;
|
|
124532
|
+
return {
|
|
124533
|
+
schedules: result.data.schedules,
|
|
124534
|
+
schedule_chat_id: result.data.schedule_chat_id
|
|
124535
|
+
};
|
|
124536
|
+
} catch {
|
|
124537
|
+
try {
|
|
124538
|
+
if (filePath !== void 0) await stat2(filePath);
|
|
124539
|
+
} catch {
|
|
124540
|
+
filePath = void 0;
|
|
124541
|
+
lastMtimeMs = void 0;
|
|
124542
|
+
}
|
|
124543
|
+
return null;
|
|
124544
|
+
}
|
|
124545
|
+
};
|
|
124546
|
+
}
|
|
124366
124547
|
|
|
124367
124548
|
// src/bridge/reconcile.ts
|
|
124368
|
-
import { readdir as readdir3, stat as
|
|
124549
|
+
import { readdir as readdir3, stat as stat3 } from "node:fs/promises";
|
|
124369
124550
|
import { join as pathJoin2 } from "node:path";
|
|
124370
124551
|
|
|
124371
124552
|
// src/bridge/postFile.ts
|
|
@@ -124989,7 +125170,7 @@ async function reconcileOrphanedCards(deps) {
|
|
|
124989
125170
|
let ageMs = 0;
|
|
124990
125171
|
try {
|
|
124991
125172
|
const target = state ? stateFilePathOf(wtPath) : wtPath;
|
|
124992
|
-
ageMs = now - (await
|
|
125173
|
+
ageMs = now - (await stat3(target)).mtimeMs;
|
|
124993
125174
|
} catch {
|
|
124994
125175
|
ageMs = 0;
|
|
124995
125176
|
}
|
|
@@ -125509,7 +125690,7 @@ var ClaudeRunner = class {
|
|
|
125509
125690
|
|
|
125510
125691
|
// src/claude/pool.ts
|
|
125511
125692
|
import { execFile as execFile4, spawn as spawn3 } from "node:child_process";
|
|
125512
|
-
import { mkdir as mkdir4, readFile as readFile6, rename as rename3, stat as
|
|
125693
|
+
import { mkdir as mkdir4, readFile as readFile6, rename as rename3, stat as stat4, unlink as unlink4, writeFile as writeFile4 } from "node:fs/promises";
|
|
125513
125694
|
import path20 from "node:path";
|
|
125514
125695
|
import { createInterface as createInterface2 } from "node:readline";
|
|
125515
125696
|
import { randomUUID } from "node:crypto";
|
|
@@ -126282,7 +126463,7 @@ async function reapOrphanedWarmClaudeProcesses(pidListFilePath) {
|
|
|
126282
126463
|
return;
|
|
126283
126464
|
}
|
|
126284
126465
|
try {
|
|
126285
|
-
const st = await
|
|
126466
|
+
const st = await stat4(pidListFilePath);
|
|
126286
126467
|
const ageMs = Date.now() - st.mtimeMs;
|
|
126287
126468
|
if (ageMs < REAP_FRESHNESS_GUARD_MS) {
|
|
126288
126469
|
console.warn(
|
|
@@ -129534,6 +129715,9 @@ function decideCronDue(nextFireAt, now, graceMinutes) {
|
|
|
129534
129715
|
if (overdueMinutes > graceMinutes) return { kind: "misfire_skip", overdueMinutes };
|
|
129535
129716
|
return { kind: "fire" };
|
|
129536
129717
|
}
|
|
129718
|
+
function fingerprintConfig(schedules, defaultChatId) {
|
|
129719
|
+
return JSON.stringify([schedules, defaultChatId ?? null]);
|
|
129720
|
+
}
|
|
129537
129721
|
function decideOneShotDue(wake, now) {
|
|
129538
129722
|
const at = Date.parse(wake.at);
|
|
129539
129723
|
if (Number.isNaN(at)) return { kind: "expired" };
|
|
@@ -129550,6 +129734,9 @@ var BotScheduler = class {
|
|
|
129550
129734
|
#statePath;
|
|
129551
129735
|
#wakesDir;
|
|
129552
129736
|
#cron = [];
|
|
129737
|
+
#defaultChatId;
|
|
129738
|
+
/** Fingerprint of the applied config — reload no-ops (and stays silent) when unchanged. */
|
|
129739
|
+
#configFingerprint;
|
|
129553
129740
|
#timer;
|
|
129554
129741
|
#running = false;
|
|
129555
129742
|
/** Awaitable current cycle — same M1 shutdown-drain shape as CommentPoller. */
|
|
@@ -129559,14 +129746,22 @@ var BotScheduler = class {
|
|
|
129559
129746
|
this.#tickMs = opts?.tickMs ?? DEFAULT_TICK_MS;
|
|
129560
129747
|
this.#statePath = path23.join(deps.botDir, "schedule-state.json");
|
|
129561
129748
|
this.#wakesDir = path23.join(deps.botDir, "wakes");
|
|
129562
|
-
|
|
129749
|
+
this.#defaultChatId = deps.defaultChatId;
|
|
129750
|
+
this.#configFingerprint = fingerprintConfig(deps.schedules, deps.defaultChatId);
|
|
129751
|
+
this.#applyCronConfig(deps.schedules);
|
|
129752
|
+
}
|
|
129753
|
+
/** (Re)build the parsed cron entry list from a config slice. */
|
|
129754
|
+
#applyCronConfig(schedules) {
|
|
129755
|
+
const next = [];
|
|
129756
|
+
for (const [i, entry] of schedules.entries()) {
|
|
129563
129757
|
if (entry.enabled === false) continue;
|
|
129564
129758
|
try {
|
|
129565
|
-
|
|
129759
|
+
next.push({ key: cronEntryKey(i, entry), entry, spec: parseCron(entry.cron) });
|
|
129566
129760
|
} catch (err) {
|
|
129567
129761
|
this.#log(`schedule entry ${i} skipped (bad cron "${entry.cron}"): ${String(err)}`);
|
|
129568
129762
|
}
|
|
129569
129763
|
}
|
|
129764
|
+
this.#cron = next;
|
|
129570
129765
|
}
|
|
129571
129766
|
#log(line) {
|
|
129572
129767
|
(this.#deps.log ?? ((s) => console.log(`[schedule] ${s}`)))(
|
|
@@ -129631,6 +129826,11 @@ var BotScheduler = class {
|
|
|
129631
129826
|
// -- tick ------------------------------------------------------------------
|
|
129632
129827
|
async #tick() {
|
|
129633
129828
|
if (!this.#running) return;
|
|
129829
|
+
try {
|
|
129830
|
+
await this.#maybeReloadConfig();
|
|
129831
|
+
} catch (err) {
|
|
129832
|
+
this.#log(`config reload failed (keeping current schedules): ${String(err)}`);
|
|
129833
|
+
}
|
|
129634
129834
|
try {
|
|
129635
129835
|
await this.#tickCron();
|
|
129636
129836
|
} catch (err) {
|
|
@@ -129642,6 +129842,20 @@ var BotScheduler = class {
|
|
|
129642
129842
|
this.#log(`one-shot tick failed (next tick retries): ${String(err)}`);
|
|
129643
129843
|
}
|
|
129644
129844
|
}
|
|
129845
|
+
/** Hot-reload: apply a changed yaml schedules slice mid-flight (docs/schedule.md). */
|
|
129846
|
+
async #maybeReloadConfig() {
|
|
129847
|
+
if (!this.#deps.reloadConfig) return;
|
|
129848
|
+
const next = await this.#deps.reloadConfig();
|
|
129849
|
+
if (!next) return;
|
|
129850
|
+
const fingerprint = fingerprintConfig(next.schedules, next.schedule_chat_id);
|
|
129851
|
+
if (fingerprint === this.#configFingerprint) return;
|
|
129852
|
+
this.#configFingerprint = fingerprint;
|
|
129853
|
+
this.#defaultChatId = next.schedule_chat_id;
|
|
129854
|
+
this.#applyCronConfig(next.schedules);
|
|
129855
|
+
this.#log(
|
|
129856
|
+
`schedules hot-reloaded from yaml: ${this.#cron.length} active cron entr${this.#cron.length === 1 ? "y" : "ies"} (no restart needed)`
|
|
129857
|
+
);
|
|
129858
|
+
}
|
|
129645
129859
|
async #tickCron() {
|
|
129646
129860
|
if (this.#cron.length === 0) return;
|
|
129647
129861
|
const now = this.#now();
|
|
@@ -129669,7 +129883,7 @@ var BotScheduler = class {
|
|
|
129669
129883
|
`cron "${entry.cron}" missed by ${decision.overdueMinutes.toFixed(1)}min (> grace ${grace}min) \u2014 skipped, next at ${advance?.toISOString() ?? "never"}`
|
|
129670
129884
|
);
|
|
129671
129885
|
} else {
|
|
129672
|
-
const chatId = entry.chat_id ?? this.#
|
|
129886
|
+
const chatId = entry.chat_id ?? this.#defaultChatId;
|
|
129673
129887
|
if (!chatId) {
|
|
129674
129888
|
this.#log(`cron "${entry.cron}" due but no chat_id/schedule_chat_id \u2014 skipped`);
|
|
129675
129889
|
} else {
|
|
@@ -129733,7 +129947,7 @@ var BotScheduler = class {
|
|
|
129733
129947
|
await unlink6(full).catch(() => void 0);
|
|
129734
129948
|
continue;
|
|
129735
129949
|
}
|
|
129736
|
-
const chatId = wake.chat_id ?? this.#
|
|
129950
|
+
const chatId = wake.chat_id ?? this.#defaultChatId;
|
|
129737
129951
|
if (!chatId) {
|
|
129738
129952
|
this.#log(`wake ${file} due but no chat_id/schedule_chat_id \u2014 dropped`);
|
|
129739
129953
|
await unlink6(full).catch(() => void 0);
|
|
@@ -129886,6 +130100,11 @@ async function runV2Mode({
|
|
|
129886
130100
|
larkCliProfile,
|
|
129887
130101
|
larkwayDir: larkwayHome()
|
|
129888
130102
|
});
|
|
130103
|
+
client.seedTrackedChats(
|
|
130104
|
+
sessionStore.list().flatMap(
|
|
130105
|
+
(r) => r.chatId ? [{ chatId: r.chatId, ...r.chatType ? { chatType: r.chatType } : {} }] : []
|
|
130106
|
+
)
|
|
130107
|
+
);
|
|
129889
130108
|
console.log(`[larkway] bot "${bot.id}" inbound transport = Channel SDK (WS in-process)`);
|
|
129890
130109
|
localHandoffRegistry.register(
|
|
129891
130110
|
{ botId: bot.id, name: bot.name, botOpenId: bot.bot_open_id },
|
|
@@ -130255,6 +130474,13 @@ async function runV2Mode({
|
|
|
130255
130474
|
botDir,
|
|
130256
130475
|
schedules: bot.schedules,
|
|
130257
130476
|
defaultChatId: bot.schedule_chat_id,
|
|
130477
|
+
// Hot-reload: edits to the bot yaml's schedules:/schedule_chat_id go
|
|
130478
|
+
// live within one tick (≤30s) — no bridge restart. Every other yaml
|
|
130479
|
+
// field still needs a restart (loaded once at boot).
|
|
130480
|
+
reloadConfig: createScheduleConfigReloader(
|
|
130481
|
+
process.env["LARKWAY_BOTS_DIR"] ? path24.resolve(process.env["LARKWAY_BOTS_DIR"]) : path24.join(larkwayHome(), "bots"),
|
|
130482
|
+
bot.id
|
|
130483
|
+
),
|
|
130258
130484
|
log: (line) => console.log(`[schedule] ${line}`),
|
|
130259
130485
|
fire: async ({ prompt, note, chatId, source, id, occurrence }) => {
|
|
130260
130486
|
let mirrorId;
|