larkin 0.4.20 → 0.4.21

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.
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.DEFAULT_MISSED_OUTBOUND_REPEAT = exports.DEFAULT_MISSED_OUTBOUND_TITLE = void 0;
7
+ exports.parseOmMessageId = parseOmMessageId;
8
+ exports.parseScanDeliveryTarget = parseScanDeliveryTarget;
9
+ exports.isDefaultMissedOutboundReminder = isDefaultMissedOutboundReminder;
10
+ exports.ensureDefaultMissedOutboundScanReminder = ensureDefaultMissedOutboundScanReminder;
11
+ exports.persistInboundScanTarget = persistInboundScanTarget;
12
+ const node_path_1 = __importDefault(require("node:path"));
13
+ const reminder_store_js_1 = require("./reminder-store.cjs");
14
+ exports.DEFAULT_MISSED_OUTBOUND_TITLE = "Read scoped history from this reminder envelope's persisted deliveryTarget (chat: +chat-messages-list; thread: +threads-messages-list). Judge unanswered asks, undelivered follow-ups, and stalled work. On a hit, post a short status in the same conversation using that persisted deliveryTarget/anchor; otherwise stay silent. Never infer recipients from the title.";
15
+ exports.DEFAULT_MISSED_OUTBOUND_REPEAT = "every:15m";
16
+ const CHAT_TARGET = /^chat:(oc_[A-Za-z0-9]+)$/;
17
+ const THREAD_TARGET = /^thread:(oc_[A-Za-z0-9]+):(omt_[A-Za-z0-9]+)$/;
18
+ const OM_ID = /^om_[A-Za-z0-9]+$/;
19
+ function parseOmMessageId(value, label = "message-id") {
20
+ const id = String(value || "").trim();
21
+ if (!OM_ID.test(id))
22
+ throw new Error(`${label} 必须是严格 om_ 语法`);
23
+ return id;
24
+ }
25
+ function parseScanDeliveryTarget(target, anchor) {
26
+ const deliveryTarget = String(target || "").trim();
27
+ if (!deliveryTarget) {
28
+ throw new Error("user-facing reminder 必须显式指定 delivery target,不得从标题推断收件人");
29
+ }
30
+ const chat = deliveryTarget.match(CHAT_TARGET);
31
+ if (chat) {
32
+ return {
33
+ deliveryTarget,
34
+ scope: "chat",
35
+ chatId: chat[1],
36
+ threadId: null,
37
+ deliveryAnchor: anchor ? parseOmMessageId(anchor, "delivery anchor") : null,
38
+ };
39
+ }
40
+ const thread = deliveryTarget.match(THREAD_TARGET);
41
+ if (thread) {
42
+ return {
43
+ deliveryTarget,
44
+ scope: "thread",
45
+ chatId: thread[1],
46
+ threadId: thread[2],
47
+ deliveryAnchor: parseOmMessageId(anchor, "delivery anchor"),
48
+ };
49
+ }
50
+ throw new Error("delivery target 必须是 chat:oc_… 或 thread:oc_…:omt_…,禁止推断 DM");
51
+ }
52
+ function isDefaultMissedOutboundReminder(reminder) {
53
+ return reminder?.title === exports.DEFAULT_MISSED_OUTBOUND_TITLE
54
+ && reminder.repeat === exports.DEFAULT_MISSED_OUTBOUND_REPEAT
55
+ && reminder.deliveryMode === "user"
56
+ && reminder.status === "scheduled";
57
+ }
58
+ function ensureDefaultMissedOutboundScanReminder(input) {
59
+ const parsed = parseScanDeliveryTarget(input.deliveryTarget, input.deliveryAnchor);
60
+ const recurrence = (0, reminder_store_js_1.parseRepeat)(exports.DEFAULT_MISSED_OUTBOUND_REPEAT);
61
+ if ("error" in recurrence)
62
+ throw new Error(recurrence.error);
63
+ const now = input.nowMs ?? Date.now();
64
+ const wantedAnchor = parsed.deliveryAnchor;
65
+ return (0, reminder_store_js_1.mutate)(input.storeFile, (store) => {
66
+ const sameIdentity = (reminder) => reminder.ownerAgentId === input.agentId
67
+ && reminder.title === exports.DEFAULT_MISSED_OUTBOUND_TITLE
68
+ && reminder.repeat === exports.DEFAULT_MISSED_OUTBOUND_REPEAT
69
+ && reminder.deliveryMode === "user"
70
+ && reminder.deliveryTarget === parsed.deliveryTarget;
71
+ const scheduled = store.reminders.find((reminder) => sameIdentity(reminder) && reminder.status === "scheduled");
72
+ if (scheduled) {
73
+ if ((scheduled.deliveryAnchor || null) !== wantedAnchor)
74
+ scheduled.deliveryAnchor = wantedAnchor;
75
+ return { created: false, reminderId: scheduled.reminderId, rebuilt: false };
76
+ }
77
+ const canceled = store.reminders.find((reminder) => sameIdentity(reminder) && reminder.status === "canceled");
78
+ if (canceled)
79
+ return { created: false, reminderId: canceled.reminderId, rebuilt: false };
80
+ const hadFired = store.reminders.some((reminder) => sameIdentity(reminder) && reminder.status === "fired");
81
+ store.reminders = store.reminders.filter((reminder) => !(sameIdentity(reminder) && reminder.status === "fired"));
82
+ const reminderId = (0, reminder_store_js_1.newId)();
83
+ store.reminders.push({
84
+ reminderId,
85
+ ownerAgentId: input.agentId,
86
+ title: exports.DEFAULT_MISSED_OUTBOUND_TITLE,
87
+ fireAt: (0, reminder_store_js_1.nowIso)(now + 15 * 60_000),
88
+ firedAt: null,
89
+ createdAt: (0, reminder_store_js_1.nowIso)(now),
90
+ status: "scheduled",
91
+ version: 1,
92
+ events: [],
93
+ deliveryTarget: parsed.deliveryTarget,
94
+ deliveryAnchor: wantedAnchor,
95
+ deliveryMode: "user",
96
+ repeat: exports.DEFAULT_MISSED_OUTBOUND_REPEAT,
97
+ });
98
+ return { created: true, reminderId, rebuilt: hadFired };
99
+ });
100
+ }
101
+ function persistInboundScanTarget(stateDir, event, agentId, storeFile = node_path_1.default.join(stateDir, "reminders.json")) {
102
+ if (event._sender_is_bot)
103
+ throw new Error("scan reminder 只接受 human inbound");
104
+ if (event._scan_authority !== true)
105
+ throw new Error("scan reminder 需要权威 chatType/senderIsBot");
106
+ if (event.chat_type !== "group")
107
+ throw new Error("scan reminder 只接受 group/thread,禁止 DM");
108
+ const chatId = String(event.chat_id || "");
109
+ if (!/^oc_[A-Za-z0-9]+$/.test(chatId)) {
110
+ throw new Error("user-facing reminder 必须显式指定 delivery target,不得从标题推断收件人");
111
+ }
112
+ const threadId = event.thread_id ? String(event.thread_id) : "";
113
+ const parsed = threadId
114
+ ? parseScanDeliveryTarget(`thread:${chatId}:${threadId}`, event.message_id)
115
+ : parseScanDeliveryTarget(`chat:${chatId}`, event.message_id || null);
116
+ ensureDefaultMissedOutboundScanReminder({
117
+ storeFile,
118
+ agentId,
119
+ deliveryTarget: parsed.deliveryTarget,
120
+ deliveryAnchor: parsed.deliveryAnchor,
121
+ });
122
+ return parsed;
123
+ }
@@ -0,0 +1,112 @@
1
+ import path from "node:path";
2
+ import { mutate, newId, nowIso, parseRepeat } from "./reminder-store.mjs";
3
+ export const DEFAULT_MISSED_OUTBOUND_TITLE = "Read scoped history from this reminder envelope's persisted deliveryTarget (chat: +chat-messages-list; thread: +threads-messages-list). Judge unanswered asks, undelivered follow-ups, and stalled work. On a hit, post a short status in the same conversation using that persisted deliveryTarget/anchor; otherwise stay silent. Never infer recipients from the title.";
4
+ export const DEFAULT_MISSED_OUTBOUND_REPEAT = "every:15m";
5
+ const CHAT_TARGET = /^chat:(oc_[A-Za-z0-9]+)$/;
6
+ const THREAD_TARGET = /^thread:(oc_[A-Za-z0-9]+):(omt_[A-Za-z0-9]+)$/;
7
+ const OM_ID = /^om_[A-Za-z0-9]+$/;
8
+ export function parseOmMessageId(value, label = "message-id") {
9
+ const id = String(value || "").trim();
10
+ if (!OM_ID.test(id))
11
+ throw new Error(`${label} 必须是严格 om_ 语法`);
12
+ return id;
13
+ }
14
+ export function parseScanDeliveryTarget(target, anchor) {
15
+ const deliveryTarget = String(target || "").trim();
16
+ if (!deliveryTarget) {
17
+ throw new Error("user-facing reminder 必须显式指定 delivery target,不得从标题推断收件人");
18
+ }
19
+ const chat = deliveryTarget.match(CHAT_TARGET);
20
+ if (chat) {
21
+ return {
22
+ deliveryTarget,
23
+ scope: "chat",
24
+ chatId: chat[1],
25
+ threadId: null,
26
+ deliveryAnchor: anchor ? parseOmMessageId(anchor, "delivery anchor") : null,
27
+ };
28
+ }
29
+ const thread = deliveryTarget.match(THREAD_TARGET);
30
+ if (thread) {
31
+ return {
32
+ deliveryTarget,
33
+ scope: "thread",
34
+ chatId: thread[1],
35
+ threadId: thread[2],
36
+ deliveryAnchor: parseOmMessageId(anchor, "delivery anchor"),
37
+ };
38
+ }
39
+ throw new Error("delivery target 必须是 chat:oc_… 或 thread:oc_…:omt_…,禁止推断 DM");
40
+ }
41
+ export function isDefaultMissedOutboundReminder(reminder) {
42
+ return reminder?.title === DEFAULT_MISSED_OUTBOUND_TITLE
43
+ && reminder.repeat === DEFAULT_MISSED_OUTBOUND_REPEAT
44
+ && reminder.deliveryMode === "user"
45
+ && reminder.status === "scheduled";
46
+ }
47
+ export function ensureDefaultMissedOutboundScanReminder(input) {
48
+ const parsed = parseScanDeliveryTarget(input.deliveryTarget, input.deliveryAnchor);
49
+ const recurrence = parseRepeat(DEFAULT_MISSED_OUTBOUND_REPEAT);
50
+ if ("error" in recurrence)
51
+ throw new Error(recurrence.error);
52
+ const now = input.nowMs ?? Date.now();
53
+ const wantedAnchor = parsed.deliveryAnchor;
54
+ return mutate(input.storeFile, (store) => {
55
+ const sameIdentity = (reminder) => reminder.ownerAgentId === input.agentId
56
+ && reminder.title === DEFAULT_MISSED_OUTBOUND_TITLE
57
+ && reminder.repeat === DEFAULT_MISSED_OUTBOUND_REPEAT
58
+ && reminder.deliveryMode === "user"
59
+ && reminder.deliveryTarget === parsed.deliveryTarget;
60
+ const scheduled = store.reminders.find((reminder) => sameIdentity(reminder) && reminder.status === "scheduled");
61
+ if (scheduled) {
62
+ if ((scheduled.deliveryAnchor || null) !== wantedAnchor)
63
+ scheduled.deliveryAnchor = wantedAnchor;
64
+ return { created: false, reminderId: scheduled.reminderId, rebuilt: false };
65
+ }
66
+ const canceled = store.reminders.find((reminder) => sameIdentity(reminder) && reminder.status === "canceled");
67
+ if (canceled)
68
+ return { created: false, reminderId: canceled.reminderId, rebuilt: false };
69
+ const hadFired = store.reminders.some((reminder) => sameIdentity(reminder) && reminder.status === "fired");
70
+ store.reminders = store.reminders.filter((reminder) => !(sameIdentity(reminder) && reminder.status === "fired"));
71
+ const reminderId = newId();
72
+ store.reminders.push({
73
+ reminderId,
74
+ ownerAgentId: input.agentId,
75
+ title: DEFAULT_MISSED_OUTBOUND_TITLE,
76
+ fireAt: nowIso(now + 15 * 60_000),
77
+ firedAt: null,
78
+ createdAt: nowIso(now),
79
+ status: "scheduled",
80
+ version: 1,
81
+ events: [],
82
+ deliveryTarget: parsed.deliveryTarget,
83
+ deliveryAnchor: wantedAnchor,
84
+ deliveryMode: "user",
85
+ repeat: DEFAULT_MISSED_OUTBOUND_REPEAT,
86
+ });
87
+ return { created: true, reminderId, rebuilt: hadFired };
88
+ });
89
+ }
90
+ export function persistInboundScanTarget(stateDir, event, agentId, storeFile = path.join(stateDir, "reminders.json")) {
91
+ if (event._sender_is_bot)
92
+ throw new Error("scan reminder 只接受 human inbound");
93
+ if (event._scan_authority !== true)
94
+ throw new Error("scan reminder 需要权威 chatType/senderIsBot");
95
+ if (event.chat_type !== "group")
96
+ throw new Error("scan reminder 只接受 group/thread,禁止 DM");
97
+ const chatId = String(event.chat_id || "");
98
+ if (!/^oc_[A-Za-z0-9]+$/.test(chatId)) {
99
+ throw new Error("user-facing reminder 必须显式指定 delivery target,不得从标题推断收件人");
100
+ }
101
+ const threadId = event.thread_id ? String(event.thread_id) : "";
102
+ const parsed = threadId
103
+ ? parseScanDeliveryTarget(`thread:${chatId}:${threadId}`, event.message_id)
104
+ : parseScanDeliveryTarget(`chat:${chatId}`, event.message_id || null);
105
+ ensureDefaultMissedOutboundScanReminder({
106
+ storeFile,
107
+ agentId,
108
+ deliveryTarget: parsed.deliveryTarget,
109
+ deliveryAnchor: parsed.deliveryAnchor,
110
+ });
111
+ return parsed;
112
+ }
@@ -51,6 +51,7 @@ const host_business_state_js_1 = require("./host-business-state.cjs");
51
51
  const host_processing_eye_js_1 = require("./host-processing-eye.cjs");
52
52
  const inbox_projection_js_1 = require("../agent/inbox-projection.cjs");
53
53
  const host_reminder_orchestrator_js_1 = require("../agent/host-reminder-orchestrator.cjs");
54
+ const missed_outbound_scan_js_1 = require("../agent/missed-outbound-scan.cjs");
54
55
  const host_channel_business_js_1 = require("./host-channel-business.cjs");
55
56
  const interaction_orchestrator_js_1 = require("./interaction-orchestrator.cjs");
56
57
  const message_policy_js_1 = require("./message-policy.cjs");
@@ -385,6 +386,14 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
385
386
  // append/dedupe decision is durable. Agent model-seen state is untouched.
386
387
  if (event.event_id)
387
388
  seenEventIds.add(eventKey);
389
+ if (!event._sender_is_bot && event._scan_authority) {
390
+ try {
391
+ (0, missed_outbound_scan_js_1.persistInboundScanTarget)(agent.stateDir, event, agent.agentId);
392
+ }
393
+ catch (error) {
394
+ log(`inbound scan target 未持久化: ${error.message}`);
395
+ }
396
+ }
388
397
  if (append.status === "duplicate_consumed")
389
398
  return null;
390
399
  const inboxEnvelope = append.envelope;
@@ -11,6 +11,7 @@ import { HostEnvelopeProjector, HostStateProjection, SenderIdentityCache, projec
11
11
  import { ProcessingEyeOrchestrator } from "./host-processing-eye.mjs";
12
12
  import { projectInboxEnvelope, targetKeyOfInboxEnvelope } from "../agent/inbox-projection.mjs";
13
13
  import { HostReminderOrchestrator } from "../agent/host-reminder-orchestrator.mjs";
14
+ import { persistInboundScanTarget } from "../agent/missed-outbound-scan.mjs";
14
15
  import { HostChannelBusiness } from "./host-channel-business.mjs";
15
16
  import { HostInteractionOrchestrator } from "./interaction-orchestrator.mjs";
16
17
  import { targetFor } from "./message-policy.mjs";
@@ -345,6 +346,14 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
345
346
  // append/dedupe decision is durable. Agent model-seen state is untouched.
346
347
  if (event.event_id)
347
348
  seenEventIds.add(eventKey);
349
+ if (!event._sender_is_bot && event._scan_authority) {
350
+ try {
351
+ persistInboundScanTarget(agent.stateDir, event, agent.agentId);
352
+ }
353
+ catch (error) {
354
+ log(`inbound scan target 未持久化: ${error.message}`);
355
+ }
356
+ }
348
357
  if (append.status === "duplicate_consumed")
349
358
  return null;
350
359
  const inboxEnvelope = append.envelope;
@@ -39,6 +39,8 @@ function normalizeChannelMessage(message) {
39
39
  _mentioned_bot: Boolean(message.mentionedBot || message.mentionAll),
40
40
  _mention_all: Boolean(message.mentionAll),
41
41
  _sender_is_bot: message.senderIsBot === true,
42
+ _scan_authority: typeof message.senderIsBot === "boolean"
43
+ && (message.chatType === "group" || message.chatMode === "topic"),
42
44
  };
43
45
  }
44
46
  function slug10(value) {
@@ -26,6 +26,8 @@ export function normalizeChannelMessage(message) {
26
26
  _mentioned_bot: Boolean(message.mentionedBot || message.mentionAll),
27
27
  _mention_all: Boolean(message.mentionAll),
28
28
  _sender_is_bot: message.senderIsBot === true,
29
+ _scan_authority: typeof message.senderIsBot === "boolean"
30
+ && (message.chatType === "group" || message.chatMode === "topic"),
29
31
  };
30
32
  }
31
33
  export function slug10(value) {
@@ -19,6 +19,7 @@ import { beginBuiltinPiCredentialTransaction, createOfficialPiCredentialRuntime,
19
19
  import { documentCommentSubscriptionCapability, markDocumentCommentSubscriptionVerified, } from "../platform/callback-capability.mjs";
20
20
  import { openPlatformHost, parseLarkinTenant, registerAppAccountsHost, } from "../feishu/platform-hosts.mjs";
21
21
  import { authorizationUrlFailureMessage, presentAuthorizationUrl } from "./setup-authorization-url.mjs";
22
+ import { missingGrantedTenantScopes } from "./tenant-scope-grant.mjs";
22
23
  // qrcode-terminal does not publish TypeScript declarations.
23
24
  // @ts-expect-error bundled CommonJS dependency
24
25
  import qrcodePackage from "qrcode-terminal";
@@ -28,6 +29,7 @@ const testFixture = process.env.LARKIN_TEST_BOT_REGISTER_MODULE
28
29
  const registerApp = testFixture?.registerApp ?? channelRegisterApp;
29
30
  const qrcode = testFixture?.qrcode ?? qrcodePackage;
30
31
  const spawnSync = testFixture?.spawnSync ?? systemSpawnSync;
32
+ const spawnChild = testFixture?.spawn ?? systemSpawn;
31
33
  const synchronizeAgentProfile = testFixture?.syncAgentProfile ?? syncAgentProfile;
32
34
  const resolveOfficialCli = testFixture?.resolveOfficialLarkCli ?? resolveOfficialLarkCli;
33
35
  const wait = testFixture?.wait ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
@@ -306,7 +308,7 @@ async function runBindProcess(command, args) {
306
308
  return spawnSync(command, [...args], { env: process.env, stdio: "inherit" }).status;
307
309
  }
308
310
  return await new Promise((resolve, reject) => {
309
- const child = systemSpawn(command, [...args], { env: process.env, stdio: "inherit" });
311
+ const child = spawnChild(command, [...args], { env: process.env, stdio: "inherit" });
310
312
  trackPendingChild(child);
311
313
  let killTimer = null;
312
314
  const abort = () => {
@@ -339,7 +341,7 @@ async function runBoundedCliProcess(command, args, env, label) {
339
341
  return { status: result.status, stdout: result.stdout || "", stderr: result.stderr || "" };
340
342
  }
341
343
  return await new Promise((resolve, reject) => {
342
- const child = systemSpawn(command, [...args], { env, stdio: ["ignore", "pipe", "pipe"] });
344
+ const child = spawnChild(command, [...args], { env, stdio: ["ignore", "pipe", "pipe"] });
343
345
  trackPendingChild(child);
344
346
  let stdout = "";
345
347
  let stderr = "";
@@ -641,12 +643,13 @@ Lark 必须用 --tenant lark:授权二维码是官方 /page/cli(begin 仍走
641
643
  : commentSubscription === "none" || !priorSubscription
642
644
  ? { mode: "none", status: "safe-default", source: "setup-default", updatedAt: requestedAt }
643
645
  : priorSubscription;
644
- if (!flag("--runtime") && (!testFixture || process.env.LARKIN_TEST_ENABLE_AGENT_CHOICE === "1")) {
646
+ const injectedAgentChoice = testFixture?.collectSetupAgentChoice;
647
+ if (!flag("--runtime") && (!testFixture || injectedAgentChoice || process.env.LARKIN_TEST_ENABLE_AGENT_CHOICE === "1")) {
645
648
  const existing = larkinConfig.loadConfig(process.env).config.agents[id];
646
649
  // The official resolver performs a synchronous login-shell probe. Resolve it
647
650
  // exactly once before the credential heartbeat lock becomes active.
648
651
  resolvedSetupOfficialCli = resolveOfficialCli({ env: process.env });
649
- const questioner = terminalSetupQuestioner();
652
+ const questioner = injectedAgentChoice ? { ask: async () => "", secret: async () => "" } : terminalSetupQuestioner();
650
653
  // One setup-owned transaction starts before status/logout and remains active
651
654
  // through selection, login, bind, readiness, and the final setup commit.
652
655
  pendingPiAuthTransaction = beginBuiltinPiCredentialTransaction(CFG_DIR, id);
@@ -657,37 +660,43 @@ Lark 必须用 --tenant lark:授权二维码是官方 /page/cli(begin 仍走
657
660
  report: (message) => say(message),
658
661
  };
659
662
  try {
660
- const requested = await collectSetupAgentChoice(questioner, existing, authServices);
661
- const choice = await recoverUnavailableExternalPi(requested, questioner, () => probeNativeRuntimeReadiness({
662
- runtime: "pi", agentId: id, cwd: path.join(CFG_DIR, "agents", id), env: process.env,
663
- }), (message) => say(`! ${message}`), authServices);
663
+ const requested = injectedAgentChoice
664
+ ? await injectedAgentChoice()
665
+ : await collectSetupAgentChoice(questioner, existing, authServices);
666
+ const choice = injectedAgentChoice
667
+ ? requested
668
+ : await recoverUnavailableExternalPi(requested, questioner, () => probeNativeRuntimeReadiness({
669
+ runtime: "pi", agentId: id, cwd: path.join(CFG_DIR, "agents", id), env: process.env,
670
+ }), (message) => say(`! ${message}`), authServices);
664
671
  if (choice) {
665
672
  let serializedChoice = choice;
666
673
  if (choice.runtime === "pi" && choice.distribution === "builtin") {
667
- const official = choice.preset === "official" ? choice : null;
668
- const configured = official ? null : configureBuiltinPiProviderModel(CFG_DIR, id, choice);
669
- const providerId = official?.providerId || configured.provider;
670
- const authType = official?.authType || "api_key";
671
- let piRuntime;
672
- try {
673
- say(`正在通过捆绑官方 Pi 登录 ${providerId}(${authType})…`);
674
- piRuntime = await createOfficialPiModelRuntime(CFG_DIR, id);
675
- await runOfficialPiLogin(piRuntime, providerId, authType, createOfficialPiAuthInteraction({ questioner, report: (message) => say(message), openUrl: openBrowser }));
676
- }
677
- catch {
678
- pendingPiAuthTransaction.rollback();
679
- pendingPiAuthTransaction = null;
680
- throw new Error(`官方 Pi ${providerId} 登录失败或已取消;credential/config 未修改`);
681
- }
682
- if (process.env.LARKIN_TEST_SKIP_BUILTIN_PI_PROVIDER_TURN !== "1") {
683
- say("正在验证内置 Pi provider(受控单轮,不发送飞书(Lark)消息)…");
674
+ if (!injectedAgentChoice) {
675
+ const official = choice.preset === "official" ? choice : null;
676
+ const configured = official ? null : configureBuiltinPiProviderModel(CFG_DIR, id, choice);
677
+ const providerId = official?.providerId || configured.provider;
678
+ const authType = official?.authType || "api_key";
679
+ let piRuntime;
684
680
  try {
685
- await verifyOfficialPiProviderTurn(piRuntime, choice.model);
681
+ say(`正在通过捆绑官方 Pi 登录 ${providerId}(${authType})…`);
682
+ piRuntime = await createOfficialPiModelRuntime(CFG_DIR, id);
683
+ await runOfficialPiLogin(piRuntime, providerId, authType, createOfficialPiAuthInteraction({ questioner, report: (message) => say(message), openUrl: openBrowser }));
686
684
  }
687
685
  catch {
688
686
  pendingPiAuthTransaction.rollback();
689
687
  pendingPiAuthTransaction = null;
690
- throw new Error(`官方 Pi ${providerId} readiness 失败;credential/config 未修改`);
688
+ throw new Error(`官方 Pi ${providerId} 登录失败或已取消;credential/config 未修改`);
689
+ }
690
+ if (process.env.LARKIN_TEST_SKIP_BUILTIN_PI_PROVIDER_TURN !== "1") {
691
+ say("正在验证内置 Pi provider(受控单轮,不发送飞书(Lark)消息)…");
692
+ try {
693
+ await verifyOfficialPiProviderTurn(piRuntime, choice.model);
694
+ }
695
+ catch {
696
+ pendingPiAuthTransaction.rollback();
697
+ pendingPiAuthTransaction = null;
698
+ throw new Error(`官方 Pi ${providerId} readiness 失败;credential/config 未修改`);
699
+ }
691
700
  }
692
701
  }
693
702
  serializedChoice = { ...choice, authCompleted: true, readinessCompleted: true };
@@ -850,6 +859,21 @@ Lark 必须用 --tenant lark:授权二维码是官方 /page/cli(begin 仍走
850
859
  }
851
860
  if (!verified)
852
861
  throw new Error("Bot identity verification failed");
862
+ const scopeResult = await runIdentityProcess(official.command, [...official.argsPrefix, "api", "GET", "/open-apis/application/v6/scopes"], cliEnv);
863
+ let scopePayload;
864
+ try {
865
+ scopePayload = JSON.parse(scopeResult.stdout || "");
866
+ }
867
+ catch {
868
+ scopePayload = null;
869
+ }
870
+ const missing = missingGrantedTenantScopes(scopePayload);
871
+ if (scopeResult.status !== 0 || missing.length > 0) {
872
+ const reason = scopeResult.status !== 0
873
+ ? `scopes API 失败 status=${scopeResult.status}`
874
+ : `缺 ${missing.join(", ")}`;
875
+ throw new Error(`Bot tenant scope 未就绪,${reason};+chat-list 成功不能当作可发群消息。请在开发者后台开通后重跑 setup,不要重建 Agent。`);
876
+ }
853
877
  if (commentSubscription !== "preserve") {
854
878
  const reconciled = await applyDocumentCommentSubscription({
855
879
  mode: commentSubscription,
@@ -901,6 +925,8 @@ Lark 必须用 --tenant lark:授权二维码是官方 /page/cli(begin 仍走
901
925
  }
902
926
  catch (error) {
903
927
  const diagnostic = errorMessage(error);
928
+ if (diagnostic.startsWith("Bot tenant scope 未就绪"))
929
+ die(diagnostic);
904
930
  if (diagnostic.startsWith("document comment application subscription")
905
931
  || diagnostic.startsWith("document comment local verified-state persistence"))
906
932
  say(`! ${diagnostic}`);
@@ -10,6 +10,7 @@ import * as larkinConfig from "../platform/config.mjs";
10
10
  import { managedOfficialLarkCli } from "../app/agent-lark-cli-workspace.mjs";
11
11
  import { loadValidatedBotCredential } from "./run-credential-preflight.mjs";
12
12
  import { parseLarkinTenant, registerAppAccountsHost } from "../feishu/platform-hosts.mjs";
13
+ import { presentAuthorizationUrl } from "./setup-authorization-url.mjs";
13
14
  // qrcode-terminal does not publish TypeScript declarations.
14
15
  // @ts-expect-error bundled CommonJS dependency
15
16
  import qrcodePackage from "qrcode-terminal";
@@ -108,18 +109,26 @@ export async function main() {
108
109
  },
109
110
  onQRCodeReady: (info) => {
110
111
  const minutes = Math.max(1, Math.round(info.expireIn / 60));
112
+ const official = resolveManagedOfficialCli(selected, process.env);
113
+ const presented = presentAuthorizationUrl(info.url, {
114
+ tenant: TENANT,
115
+ larkCliVersion: official.command.version,
116
+ });
117
+ if (TENANT === "lark" && /\/page\/launcher(?:\?|$)/.test(presented)) {
118
+ throw new Error("Lark 租户拒绝把 /page/launcher 交给浏览器");
119
+ }
111
120
  log("\n请用飞书(Lark)扫码或打开链接,确认给应用增补权限:\n");
112
- qrcode.generate(info.url, { small: true });
113
- log(`\n链接: ${info.url}\n有效期约 ${minutes} 分钟(⚠️ 需本进程保持轮询,否则 user_code 立即失效)\n`);
121
+ qrcode.generate(presented, { small: true });
122
+ log(`\n链接: ${presented}\n有效期约 ${minutes} 分钟(⚠️ 需本进程保持轮询,否则 user_code 立即失效)\n`);
114
123
  if (URL_FILE) {
115
124
  try {
116
- fs.writeFileSync(URL_FILE, info.url);
125
+ fs.writeFileSync(URL_FILE, presented);
117
126
  }
118
127
  catch (error) {
119
128
  log(`[grant] 写 url-file 失败: ${error instanceof Error ? error.message : String(error)}`);
120
129
  }
121
130
  }
122
- sendUrlToChat(info.url, minutes);
131
+ sendUrlToChat(presented, minutes);
123
132
  },
124
133
  onStatusChange: (info) => log(`[grant] 状态: ${info.status}`),
125
134
  domain: registerAppAccountsHost(TENANT),
@@ -0,0 +1,16 @@
1
+ /** Tenant scopes the freshness gate needs on the Bot application. */
2
+ export const FRESHNESS_REQUIRED_TENANT_SCOPES = ["im:message.group_msg"];
3
+ function asRows(payload) {
4
+ if (!payload || typeof payload !== "object")
5
+ return [];
6
+ const root = payload;
7
+ const raw = Array.isArray(root.data?.scopes) ? root.data.scopes
8
+ : Array.isArray(root.scopes) ? root.scopes
9
+ : [];
10
+ return raw.filter((row) => !!row && typeof row === "object");
11
+ }
12
+ /** grant_status 1 is granted on GET /open-apis/application/v6/scopes. */
13
+ export function missingGrantedTenantScopes(payload, required = FRESHNESS_REQUIRED_TENANT_SCOPES) {
14
+ const rows = asRows(payload);
15
+ return required.filter((name) => !rows.some((row) => row.scope_name === name && row.grant_status === 1));
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkin",
3
- "version": "0.4.20",
3
+ "version": "0.4.21",
4
4
  "private": false,
5
5
  "larkinPackageRole": "npm-published",
6
6
  "files": [