larkin 0.4.20 → 0.4.22
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/dist/agent/agent-cli-capabilities.cjs +4 -2
- package/dist/agent/agent-cli-capabilities.mjs +4 -2
- package/dist/agent/inbox-audit-heartbeat.cjs +63 -0
- package/dist/agent/inbox-audit-heartbeat.mjs +59 -0
- package/dist/agent/missed-outbound-scan.cjs +133 -0
- package/dist/agent/missed-outbound-scan.mjs +94 -0
- package/dist/app/agent-cli.mjs +10 -2
- package/dist/feishu/host-shell.cjs +17 -0
- package/dist/feishu/host-shell.mjs +17 -0
- package/dist/feishu/message-policy.cjs +2 -0
- package/dist/feishu/message-policy.mjs +2 -0
- package/dist/setup/bot-register.mjs +53 -27
- package/dist/setup/grant-scopes.mjs +13 -4
- package/dist/setup/tenant-scope-grant.mjs +16 -0
- package/package.json +1 -1
|
@@ -12,7 +12,7 @@ const internal_command_js_1 = require("../app/internal-command.cjs");
|
|
|
12
12
|
exports.AGENT_CLI_CAPABILITIES = Object.freeze({
|
|
13
13
|
version: 1,
|
|
14
14
|
commands: Object.freeze({
|
|
15
|
-
inbox: Object.freeze(["check", "poll"]),
|
|
15
|
+
inbox: Object.freeze(["check", "poll", "audit"]),
|
|
16
16
|
comment: Object.freeze(["reply"]),
|
|
17
17
|
reminder: Object.freeze(["schedule", "list", "snooze", "update", "cancel", "log"]),
|
|
18
18
|
interaction: Object.freeze(["callback-status", "callback-probe", "create", "get", "resolve"]),
|
|
@@ -31,7 +31,9 @@ function resolveAgentCliExecutable(cliPath, runtimeExecutable = node_process_1.d
|
|
|
31
31
|
function agentCliPromptCapabilities(executable = "larkin") {
|
|
32
32
|
const commands = Object.entries(exports.AGENT_CLI_CAPABILITIES.commands).flatMap(([group, operations]) => operations.map((operation) => ({
|
|
33
33
|
command: `${group} ${operation}`,
|
|
34
|
-
purpose: group === "inbox" ? (operation === "check" ? "Read pending target summaries without consuming messages."
|
|
34
|
+
purpose: group === "inbox" ? (operation === "check" ? "Read pending target summaries without consuming messages."
|
|
35
|
+
: operation === "audit" ? "Read bounded observed group/topic audit targets and instructions."
|
|
36
|
+
: "Poll full messages and direct-ack the returned batch.") :
|
|
35
37
|
group === "comment" ? "Reply once to the exact cloud-document comment bound by a polled Inbox message id." :
|
|
36
38
|
group === "reminder" ? "Manage this Agent's durable reminders." :
|
|
37
39
|
group === "interaction" ? "Create, inspect, or resolve a durable interactive card run." :
|
|
@@ -4,7 +4,7 @@ import { INTERNAL_AGENT_CLI, internalCommandShell } from "../app/internal-comman
|
|
|
4
4
|
export const AGENT_CLI_CAPABILITIES = Object.freeze({
|
|
5
5
|
version: 1,
|
|
6
6
|
commands: Object.freeze({
|
|
7
|
-
inbox: Object.freeze(["check", "poll"]),
|
|
7
|
+
inbox: Object.freeze(["check", "poll", "audit"]),
|
|
8
8
|
comment: Object.freeze(["reply"]),
|
|
9
9
|
reminder: Object.freeze(["schedule", "list", "snooze", "update", "cancel", "log"]),
|
|
10
10
|
interaction: Object.freeze(["callback-status", "callback-probe", "create", "get", "resolve"]),
|
|
@@ -23,7 +23,9 @@ export function resolveAgentCliExecutable(cliPath, runtimeExecutable = process.e
|
|
|
23
23
|
export function agentCliPromptCapabilities(executable = "larkin") {
|
|
24
24
|
const commands = Object.entries(AGENT_CLI_CAPABILITIES.commands).flatMap(([group, operations]) => operations.map((operation) => ({
|
|
25
25
|
command: `${group} ${operation}`,
|
|
26
|
-
purpose: group === "inbox" ? (operation === "check" ? "Read pending target summaries without consuming messages."
|
|
26
|
+
purpose: group === "inbox" ? (operation === "check" ? "Read pending target summaries without consuming messages."
|
|
27
|
+
: operation === "audit" ? "Read bounded observed group/topic audit targets and instructions."
|
|
28
|
+
: "Poll full messages and direct-ack the returned batch.") :
|
|
27
29
|
group === "comment" ? "Reply once to the exact cloud-document comment bound by a polled Inbox message id." :
|
|
28
30
|
group === "reminder" ? "Manage this Agent's durable reminders." :
|
|
29
31
|
group === "interaction" ? "Create, inspect, or resolve a durable interactive card run." :
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.InboxAuditHeartbeat = exports.INBOX_AUDIT_CADENCE_MS = void 0;
|
|
4
|
+
const inbox_projection_js_1 = require("./inbox-projection.cjs");
|
|
5
|
+
exports.INBOX_AUDIT_CADENCE_MS = 15 * 60_000;
|
|
6
|
+
/** One Host-owned timer fans a targetless internal wake to each active Agent. */
|
|
7
|
+
class InboxAuditHeartbeat {
|
|
8
|
+
options;
|
|
9
|
+
timer = null;
|
|
10
|
+
running = false;
|
|
11
|
+
sequence = 0;
|
|
12
|
+
cadenceMs;
|
|
13
|
+
constructor(options) {
|
|
14
|
+
this.options = options;
|
|
15
|
+
this.cadenceMs = options.cadenceMs ?? exports.INBOX_AUDIT_CADENCE_MS;
|
|
16
|
+
if (!Number.isSafeInteger(this.cadenceMs) || this.cadenceMs < 1)
|
|
17
|
+
throw new Error("inbox audit cadence must be a positive integer");
|
|
18
|
+
}
|
|
19
|
+
start() {
|
|
20
|
+
if (this.running)
|
|
21
|
+
return;
|
|
22
|
+
this.running = true;
|
|
23
|
+
this.schedule();
|
|
24
|
+
}
|
|
25
|
+
stop() {
|
|
26
|
+
this.running = false;
|
|
27
|
+
if (this.timer)
|
|
28
|
+
(this.options.clearTimer ?? clearTimeout)(this.timer);
|
|
29
|
+
this.timer = null;
|
|
30
|
+
}
|
|
31
|
+
schedule() {
|
|
32
|
+
if (!this.running || this.timer)
|
|
33
|
+
return;
|
|
34
|
+
this.timer = (this.options.setTimer ?? setTimeout)(() => {
|
|
35
|
+
this.timer = null;
|
|
36
|
+
return this.fire().finally(() => { if (this.running)
|
|
37
|
+
this.schedule(); });
|
|
38
|
+
}, this.cadenceMs);
|
|
39
|
+
this.timer.unref?.();
|
|
40
|
+
}
|
|
41
|
+
async fire() {
|
|
42
|
+
const now = this.options.now ?? Date.now;
|
|
43
|
+
for (const agent of this.options.agents) {
|
|
44
|
+
const message_id = `rem_inbox_audit_${now()}_${++this.sequence}_${agent.agentId}`;
|
|
45
|
+
const envelope = {
|
|
46
|
+
kind: "reminder",
|
|
47
|
+
message_id,
|
|
48
|
+
target: inbox_projection_js_1.RUNTIME_REMINDER_TARGET,
|
|
49
|
+
wake: true,
|
|
50
|
+
content: "Internal inbox audit wake. Poll runtime:reminder, then run larkin inbox audit --json. No finding stays silent.",
|
|
51
|
+
};
|
|
52
|
+
try {
|
|
53
|
+
const appended = this.options.stateStore(agent).appendCanonicalInboxOnce(envelope);
|
|
54
|
+
if (appended.status === "appended")
|
|
55
|
+
await this.options.runtimeHost.deliver(agent.agentId, appended.envelope);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
this.options.log?.(`inbox audit heartbeat failed agent=${agent.agentId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
exports.InboxAuditHeartbeat = InboxAuditHeartbeat;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { RUNTIME_REMINDER_TARGET } from "./inbox-projection.mjs";
|
|
2
|
+
export const INBOX_AUDIT_CADENCE_MS = 15 * 60_000;
|
|
3
|
+
/** One Host-owned timer fans a targetless internal wake to each active Agent. */
|
|
4
|
+
export class InboxAuditHeartbeat {
|
|
5
|
+
options;
|
|
6
|
+
timer = null;
|
|
7
|
+
running = false;
|
|
8
|
+
sequence = 0;
|
|
9
|
+
cadenceMs;
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.options = options;
|
|
12
|
+
this.cadenceMs = options.cadenceMs ?? INBOX_AUDIT_CADENCE_MS;
|
|
13
|
+
if (!Number.isSafeInteger(this.cadenceMs) || this.cadenceMs < 1)
|
|
14
|
+
throw new Error("inbox audit cadence must be a positive integer");
|
|
15
|
+
}
|
|
16
|
+
start() {
|
|
17
|
+
if (this.running)
|
|
18
|
+
return;
|
|
19
|
+
this.running = true;
|
|
20
|
+
this.schedule();
|
|
21
|
+
}
|
|
22
|
+
stop() {
|
|
23
|
+
this.running = false;
|
|
24
|
+
if (this.timer)
|
|
25
|
+
(this.options.clearTimer ?? clearTimeout)(this.timer);
|
|
26
|
+
this.timer = null;
|
|
27
|
+
}
|
|
28
|
+
schedule() {
|
|
29
|
+
if (!this.running || this.timer)
|
|
30
|
+
return;
|
|
31
|
+
this.timer = (this.options.setTimer ?? setTimeout)(() => {
|
|
32
|
+
this.timer = null;
|
|
33
|
+
return this.fire().finally(() => { if (this.running)
|
|
34
|
+
this.schedule(); });
|
|
35
|
+
}, this.cadenceMs);
|
|
36
|
+
this.timer.unref?.();
|
|
37
|
+
}
|
|
38
|
+
async fire() {
|
|
39
|
+
const now = this.options.now ?? Date.now;
|
|
40
|
+
for (const agent of this.options.agents) {
|
|
41
|
+
const message_id = `rem_inbox_audit_${now()}_${++this.sequence}_${agent.agentId}`;
|
|
42
|
+
const envelope = {
|
|
43
|
+
kind: "reminder",
|
|
44
|
+
message_id,
|
|
45
|
+
target: RUNTIME_REMINDER_TARGET,
|
|
46
|
+
wake: true,
|
|
47
|
+
content: "Internal inbox audit wake. Poll runtime:reminder, then run larkin inbox audit --json. No finding stays silent.",
|
|
48
|
+
};
|
|
49
|
+
try {
|
|
50
|
+
const appended = this.options.stateStore(agent).appendCanonicalInboxOnce(envelope);
|
|
51
|
+
if (appended.status === "appended")
|
|
52
|
+
await this.options.runtimeHost.deliver(agent.agentId, appended.envelope);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
this.options.log?.(`inbox audit heartbeat failed agent=${agent.agentId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.MAX_INBOX_AUDIT_TARGETS = exports.INBOX_AUDIT_LEGACY_MIGRATION_NON_GOAL = void 0;
|
|
37
|
+
exports.inboxAuditRegistryFile = inboxAuditRegistryFile;
|
|
38
|
+
exports.observeInboxAuditTarget = observeInboxAuditTarget;
|
|
39
|
+
exports.readInboxAuditTargets = readInboxAuditTargets;
|
|
40
|
+
const fs = __importStar(require("node:fs"));
|
|
41
|
+
const path = __importStar(require("node:path"));
|
|
42
|
+
/**
|
|
43
|
+
* #169's host-owned heartbeat no longer creates per-target missed-outbound loops.
|
|
44
|
+
* Pre-v0.4.21 records have no unique ownership marker and can collide exactly
|
|
45
|
+
* with user reminders, so existing indistinguishable loops are never migrated
|
|
46
|
+
* or deleted automatically.
|
|
47
|
+
*/
|
|
48
|
+
exports.INBOX_AUDIT_LEGACY_MIGRATION_NON_GOAL = "New versions no longer create missed-outbound loops; existing indistinguishable historical loops are not migrated or deleted automatically.";
|
|
49
|
+
// Audits return every retained target. Storage remains bounded so a heartbeat
|
|
50
|
+
// cannot accumulate an unbounded work list.
|
|
51
|
+
exports.MAX_INBOX_AUDIT_TARGETS = 96;
|
|
52
|
+
const MAX_STORED_TARGETS = exports.MAX_INBOX_AUDIT_TARGETS;
|
|
53
|
+
const CHAT = /^oc_[A-Za-z0-9]+$/;
|
|
54
|
+
const THREAD = /^omt_[A-Za-z0-9]+$/;
|
|
55
|
+
const ANCHOR = /^om_[A-Za-z0-9_-]+$/;
|
|
56
|
+
function inboxAuditRegistryFile(larkinHome) {
|
|
57
|
+
return path.join(larkinHome, "inbox-audit.json");
|
|
58
|
+
}
|
|
59
|
+
function parseTarget(event) {
|
|
60
|
+
const chatId = String(event.chat_id || "");
|
|
61
|
+
const threadId = event.thread_id ? String(event.thread_id) : "";
|
|
62
|
+
const anchor = String(event.message_id || "");
|
|
63
|
+
if (!CHAT.test(chatId) || !ANCHOR.test(anchor) || (threadId && !THREAD.test(threadId)))
|
|
64
|
+
return null;
|
|
65
|
+
return { target: threadId ? `thread:${chatId}:${threadId}` : `chat:${chatId}`, anchor };
|
|
66
|
+
}
|
|
67
|
+
function load(file) {
|
|
68
|
+
let value;
|
|
69
|
+
try {
|
|
70
|
+
value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error.code === "ENOENT")
|
|
74
|
+
return { version: 1, targets: [] };
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
if (value?.version !== 1 || !Array.isArray(value.targets))
|
|
78
|
+
return { version: 1, targets: [] };
|
|
79
|
+
return { version: 1, targets: value.targets.flatMap((row) => {
|
|
80
|
+
if (!row || typeof row !== "object")
|
|
81
|
+
return [];
|
|
82
|
+
const candidate = row;
|
|
83
|
+
if (typeof candidate.target !== "string" || typeof candidate.anchor !== "string")
|
|
84
|
+
return [];
|
|
85
|
+
const parsed = parseTarget({
|
|
86
|
+
chat_id: candidate.target.startsWith("chat:") ? candidate.target.slice(5) : candidate.target.split(":")[1],
|
|
87
|
+
thread_id: candidate.target.startsWith("thread:") ? candidate.target.split(":")[2] : null,
|
|
88
|
+
message_id: candidate.anchor,
|
|
89
|
+
});
|
|
90
|
+
return typeof candidate.agent_id === "string" && candidate.agent_id && parsed && candidate.target === parsed.target
|
|
91
|
+
&& typeof candidate.observed_at === "string" && Number.isFinite(Date.parse(candidate.observed_at))
|
|
92
|
+
? [{ agent_id: candidate.agent_id, ...parsed, observed_at: candidate.observed_at }] : [];
|
|
93
|
+
}) };
|
|
94
|
+
}
|
|
95
|
+
function save(file, registry) {
|
|
96
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
97
|
+
const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
|
|
98
|
+
fs.writeFileSync(temporary, `${JSON.stringify(registry)}\n`, { mode: 0o600 });
|
|
99
|
+
fs.renameSync(temporary, file);
|
|
100
|
+
fs.chmodSync(file, 0o600);
|
|
101
|
+
}
|
|
102
|
+
/** Record only an authoritative human group/topic source; DMs and bots are ignored. */
|
|
103
|
+
function observeInboxAuditTarget(file, agentId, event, now = new Date()) {
|
|
104
|
+
if (event._scan_authority !== true || event._sender_is_bot !== false || event.chat_type !== "group")
|
|
105
|
+
return false;
|
|
106
|
+
const parsed = parseTarget(event);
|
|
107
|
+
if (!parsed)
|
|
108
|
+
return false;
|
|
109
|
+
const registry = load(file);
|
|
110
|
+
const observed_at = now.toISOString();
|
|
111
|
+
registry.targets = registry.targets.filter((row) => row.agent_id !== agentId || row.target !== parsed.target);
|
|
112
|
+
registry.targets.unshift({ agent_id: agentId, ...parsed, observed_at });
|
|
113
|
+
registry.targets = registry.targets.slice(0, MAX_STORED_TARGETS);
|
|
114
|
+
save(file, registry);
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
function instruction(target) {
|
|
118
|
+
const history = target.target.startsWith("thread:")
|
|
119
|
+
? "Use larkin im +threads-messages-list for this exact thread."
|
|
120
|
+
: "Use larkin im +chat-messages-list for this exact chat.";
|
|
121
|
+
return `${history} If a real missed outbound finding requires a response, use the existing guarded larkin im reply path anchored at ${target.anchor}; otherwise stay silent.`;
|
|
122
|
+
}
|
|
123
|
+
/** Content-free, bounded audit work list consumed by the Agent CLI. */
|
|
124
|
+
function readInboxAuditTargets(file, agentId) {
|
|
125
|
+
const rows = load(file).targets.filter((row) => row.agent_id === agentId)
|
|
126
|
+
.sort((left, right) => right.observed_at.localeCompare(left.observed_at));
|
|
127
|
+
return {
|
|
128
|
+
version: 1,
|
|
129
|
+
targets: rows.slice(0, exports.MAX_INBOX_AUDIT_TARGETS).map(({ agent_id: _agentId, ...target }) => ({ ...target, instruction: instruction(target) })),
|
|
130
|
+
has_more: rows.length > exports.MAX_INBOX_AUDIT_TARGETS,
|
|
131
|
+
no_finding: "stay_silent",
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* #169's host-owned heartbeat no longer creates per-target missed-outbound loops.
|
|
5
|
+
* Pre-v0.4.21 records have no unique ownership marker and can collide exactly
|
|
6
|
+
* with user reminders, so existing indistinguishable loops are never migrated
|
|
7
|
+
* or deleted automatically.
|
|
8
|
+
*/
|
|
9
|
+
export const INBOX_AUDIT_LEGACY_MIGRATION_NON_GOAL = "New versions no longer create missed-outbound loops; existing indistinguishable historical loops are not migrated or deleted automatically.";
|
|
10
|
+
// Audits return every retained target. Storage remains bounded so a heartbeat
|
|
11
|
+
// cannot accumulate an unbounded work list.
|
|
12
|
+
export const MAX_INBOX_AUDIT_TARGETS = 96;
|
|
13
|
+
const MAX_STORED_TARGETS = MAX_INBOX_AUDIT_TARGETS;
|
|
14
|
+
const CHAT = /^oc_[A-Za-z0-9]+$/;
|
|
15
|
+
const THREAD = /^omt_[A-Za-z0-9]+$/;
|
|
16
|
+
const ANCHOR = /^om_[A-Za-z0-9_-]+$/;
|
|
17
|
+
export function inboxAuditRegistryFile(larkinHome) {
|
|
18
|
+
return path.join(larkinHome, "inbox-audit.json");
|
|
19
|
+
}
|
|
20
|
+
function parseTarget(event) {
|
|
21
|
+
const chatId = String(event.chat_id || "");
|
|
22
|
+
const threadId = event.thread_id ? String(event.thread_id) : "";
|
|
23
|
+
const anchor = String(event.message_id || "");
|
|
24
|
+
if (!CHAT.test(chatId) || !ANCHOR.test(anchor) || (threadId && !THREAD.test(threadId)))
|
|
25
|
+
return null;
|
|
26
|
+
return { target: threadId ? `thread:${chatId}:${threadId}` : `chat:${chatId}`, anchor };
|
|
27
|
+
}
|
|
28
|
+
function load(file) {
|
|
29
|
+
let value;
|
|
30
|
+
try {
|
|
31
|
+
value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error.code === "ENOENT")
|
|
35
|
+
return { version: 1, targets: [] };
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
if (value?.version !== 1 || !Array.isArray(value.targets))
|
|
39
|
+
return { version: 1, targets: [] };
|
|
40
|
+
return { version: 1, targets: value.targets.flatMap((row) => {
|
|
41
|
+
if (!row || typeof row !== "object")
|
|
42
|
+
return [];
|
|
43
|
+
const candidate = row;
|
|
44
|
+
if (typeof candidate.target !== "string" || typeof candidate.anchor !== "string")
|
|
45
|
+
return [];
|
|
46
|
+
const parsed = parseTarget({
|
|
47
|
+
chat_id: candidate.target.startsWith("chat:") ? candidate.target.slice(5) : candidate.target.split(":")[1],
|
|
48
|
+
thread_id: candidate.target.startsWith("thread:") ? candidate.target.split(":")[2] : null,
|
|
49
|
+
message_id: candidate.anchor,
|
|
50
|
+
});
|
|
51
|
+
return typeof candidate.agent_id === "string" && candidate.agent_id && parsed && candidate.target === parsed.target
|
|
52
|
+
&& typeof candidate.observed_at === "string" && Number.isFinite(Date.parse(candidate.observed_at))
|
|
53
|
+
? [{ agent_id: candidate.agent_id, ...parsed, observed_at: candidate.observed_at }] : [];
|
|
54
|
+
}) };
|
|
55
|
+
}
|
|
56
|
+
function save(file, registry) {
|
|
57
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
58
|
+
const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
|
|
59
|
+
fs.writeFileSync(temporary, `${JSON.stringify(registry)}\n`, { mode: 0o600 });
|
|
60
|
+
fs.renameSync(temporary, file);
|
|
61
|
+
fs.chmodSync(file, 0o600);
|
|
62
|
+
}
|
|
63
|
+
/** Record only an authoritative human group/topic source; DMs and bots are ignored. */
|
|
64
|
+
export function observeInboxAuditTarget(file, agentId, event, now = new Date()) {
|
|
65
|
+
if (event._scan_authority !== true || event._sender_is_bot !== false || event.chat_type !== "group")
|
|
66
|
+
return false;
|
|
67
|
+
const parsed = parseTarget(event);
|
|
68
|
+
if (!parsed)
|
|
69
|
+
return false;
|
|
70
|
+
const registry = load(file);
|
|
71
|
+
const observed_at = now.toISOString();
|
|
72
|
+
registry.targets = registry.targets.filter((row) => row.agent_id !== agentId || row.target !== parsed.target);
|
|
73
|
+
registry.targets.unshift({ agent_id: agentId, ...parsed, observed_at });
|
|
74
|
+
registry.targets = registry.targets.slice(0, MAX_STORED_TARGETS);
|
|
75
|
+
save(file, registry);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
function instruction(target) {
|
|
79
|
+
const history = target.target.startsWith("thread:")
|
|
80
|
+
? "Use larkin im +threads-messages-list for this exact thread."
|
|
81
|
+
: "Use larkin im +chat-messages-list for this exact chat.";
|
|
82
|
+
return `${history} If a real missed outbound finding requires a response, use the existing guarded larkin im reply path anchored at ${target.anchor}; otherwise stay silent.`;
|
|
83
|
+
}
|
|
84
|
+
/** Content-free, bounded audit work list consumed by the Agent CLI. */
|
|
85
|
+
export function readInboxAuditTargets(file, agentId) {
|
|
86
|
+
const rows = load(file).targets.filter((row) => row.agent_id === agentId)
|
|
87
|
+
.sort((left, right) => right.observed_at.localeCompare(left.observed_at));
|
|
88
|
+
return {
|
|
89
|
+
version: 1,
|
|
90
|
+
targets: rows.slice(0, MAX_INBOX_AUDIT_TARGETS).map(({ agent_id: _agentId, ...target }) => ({ ...target, instruction: instruction(target) })),
|
|
91
|
+
has_more: rows.length > MAX_INBOX_AUDIT_TARGETS,
|
|
92
|
+
no_finding: "stay_silent",
|
|
93
|
+
};
|
|
94
|
+
}
|
package/dist/app/agent-cli.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import * as path from "node:path";
|
|
|
7
7
|
import { createAgentStateStore } from "../agent/agent-state-store.mjs";
|
|
8
8
|
import * as larkinConfig from "../platform/config.mjs";
|
|
9
9
|
import { projectInboxCheck, projectInboxEvents } from "../agent/inbox-projection.mjs";
|
|
10
|
+
import { inboxAuditRegistryFile, readInboxAuditTargets } from "../agent/missed-outbound-scan.mjs";
|
|
10
11
|
import { createReminderRoutes } from "../agent/reminder-routes.mjs";
|
|
11
12
|
import { InteractionStateMachine } from "../agent/interaction-state-machine.mjs";
|
|
12
13
|
import { issueCallbackProbe, readCallbackCapability } from "../platform/callback-capability.mjs";
|
|
@@ -501,10 +502,17 @@ export function runAgentCli(argv, env = process.env, dependencies = {}) {
|
|
|
501
502
|
const { config, agent } = requireAgent(env);
|
|
502
503
|
const stateStore = dependencies.stateStore ?? createAgentStateStore(config.larkinHome, agent.agentId);
|
|
503
504
|
if (group === "inbox") {
|
|
504
|
-
if (!["check", "poll"].includes(subcommand || "")) {
|
|
505
|
-
throw new Error("inbox 只支持 `larkin inbox check` 与 `larkin inbox
|
|
505
|
+
if (!["check", "poll", "audit"].includes(subcommand || "")) {
|
|
506
|
+
throw new Error("inbox 只支持 `larkin inbox check`、`larkin inbox poll` 与 `larkin inbox audit`");
|
|
506
507
|
}
|
|
507
508
|
const options = parseOptions(rest, new Set(["--json"]));
|
|
509
|
+
if (subcommand === "audit") {
|
|
510
|
+
if (options.positionals.length || options.values.size || options.booleans.size > 1) {
|
|
511
|
+
throw new Error("inbox audit 只接受 --json");
|
|
512
|
+
}
|
|
513
|
+
emitJson(io, readInboxAuditTargets(inboxAuditRegistryFile(config.larkinHome), agent.agentId));
|
|
514
|
+
return 0;
|
|
515
|
+
}
|
|
508
516
|
if (options.positionals.length || [...options.values.keys()].some((flag) => !["--target", "--limit"].includes(flag))) {
|
|
509
517
|
throw new Error(`inbox ${subcommand} 只接受 --target、${subcommand === "poll" ? "--limit、" : ""}--json`);
|
|
510
518
|
}
|
|
@@ -51,6 +51,8 @@ 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 inbox_audit_heartbeat_js_1 = require("../agent/inbox-audit-heartbeat.cjs");
|
|
55
|
+
const missed_outbound_scan_js_1 = require("../agent/missed-outbound-scan.cjs");
|
|
54
56
|
const host_channel_business_js_1 = require("./host-channel-business.cjs");
|
|
55
57
|
const interaction_orchestrator_js_1 = require("./interaction-orchestrator.cjs");
|
|
56
58
|
const message_policy_js_1 = require("./message-policy.cjs");
|
|
@@ -339,6 +341,13 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
|
|
|
339
341
|
for (const agent of agents)
|
|
340
342
|
prepareAgentState(agent);
|
|
341
343
|
const reminder = new host_reminder_orchestrator_js_1.HostReminderOrchestrator({ agents, stateStore, envelopeProjector, deliveryTarget: runtimeHost, log });
|
|
344
|
+
const inboxAudit = new inbox_audit_heartbeat_js_1.InboxAuditHeartbeat({
|
|
345
|
+
agents,
|
|
346
|
+
stateStore,
|
|
347
|
+
runtimeHost,
|
|
348
|
+
log,
|
|
349
|
+
});
|
|
350
|
+
const auditRegistry = (0, missed_outbound_scan_js_1.inboxAuditRegistryFile)(larkinHome);
|
|
342
351
|
const seenEventIds = new Set();
|
|
343
352
|
const inFlightEventIds = new Set();
|
|
344
353
|
const onFeishuMessage = async (agent, event, options) => {
|
|
@@ -385,6 +394,12 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
|
|
|
385
394
|
// append/dedupe decision is durable. Agent model-seen state is untouched.
|
|
386
395
|
if (event.event_id)
|
|
387
396
|
seenEventIds.add(eventKey);
|
|
397
|
+
try {
|
|
398
|
+
(0, missed_outbound_scan_js_1.observeInboxAuditTarget)(auditRegistry, agent.agentId, event);
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
log(`inbox audit target 未持久化: ${error.message}`);
|
|
402
|
+
}
|
|
388
403
|
if (append.status === "duplicate_consumed")
|
|
389
404
|
return null;
|
|
390
405
|
const inboxEnvelope = append.envelope;
|
|
@@ -1336,6 +1351,7 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
|
|
|
1336
1351
|
eventSourceStartTimer = null;
|
|
1337
1352
|
await Promise.resolve(eventSourceStop());
|
|
1338
1353
|
reminder.stopSync();
|
|
1354
|
+
inboxAudit.stop();
|
|
1339
1355
|
interaction.stopSync();
|
|
1340
1356
|
await runtimeHost.shutdown(reason);
|
|
1341
1357
|
})();
|
|
@@ -1550,6 +1566,7 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
|
|
|
1550
1566
|
sessionId: agentStates.get(agent.agentId)?.state.sessions[agent.runtime] || null,
|
|
1551
1567
|
})));
|
|
1552
1568
|
reminder.startSync();
|
|
1569
|
+
inboxAudit.start();
|
|
1553
1570
|
interaction.startSync();
|
|
1554
1571
|
eventSourceStartTimer = setTimeout(() => { eventSourceStartTimer = null; startEventSource(); }, eventSourceStartDelayMs);
|
|
1555
1572
|
}
|
|
@@ -11,6 +11,8 @@ 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 { InboxAuditHeartbeat } from "../agent/inbox-audit-heartbeat.mjs";
|
|
15
|
+
import { inboxAuditRegistryFile, observeInboxAuditTarget } from "../agent/missed-outbound-scan.mjs";
|
|
14
16
|
import { HostChannelBusiness } from "./host-channel-business.mjs";
|
|
15
17
|
import { HostInteractionOrchestrator } from "./interaction-orchestrator.mjs";
|
|
16
18
|
import { targetFor } from "./message-policy.mjs";
|
|
@@ -299,6 +301,13 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
|
|
|
299
301
|
for (const agent of agents)
|
|
300
302
|
prepareAgentState(agent);
|
|
301
303
|
const reminder = new HostReminderOrchestrator({ agents, stateStore, envelopeProjector, deliveryTarget: runtimeHost, log });
|
|
304
|
+
const inboxAudit = new InboxAuditHeartbeat({
|
|
305
|
+
agents,
|
|
306
|
+
stateStore,
|
|
307
|
+
runtimeHost,
|
|
308
|
+
log,
|
|
309
|
+
});
|
|
310
|
+
const auditRegistry = inboxAuditRegistryFile(larkinHome);
|
|
302
311
|
const seenEventIds = new Set();
|
|
303
312
|
const inFlightEventIds = new Set();
|
|
304
313
|
const onFeishuMessage = async (agent, event, options) => {
|
|
@@ -345,6 +354,12 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
|
|
|
345
354
|
// append/dedupe decision is durable. Agent model-seen state is untouched.
|
|
346
355
|
if (event.event_id)
|
|
347
356
|
seenEventIds.add(eventKey);
|
|
357
|
+
try {
|
|
358
|
+
observeInboxAuditTarget(auditRegistry, agent.agentId, event);
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
log(`inbox audit target 未持久化: ${error.message}`);
|
|
362
|
+
}
|
|
348
363
|
if (append.status === "duplicate_consumed")
|
|
349
364
|
return null;
|
|
350
365
|
const inboxEnvelope = append.envelope;
|
|
@@ -1296,6 +1311,7 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
|
|
|
1296
1311
|
eventSourceStartTimer = null;
|
|
1297
1312
|
await Promise.resolve(eventSourceStop());
|
|
1298
1313
|
reminder.stopSync();
|
|
1314
|
+
inboxAudit.stop();
|
|
1299
1315
|
interaction.stopSync();
|
|
1300
1316
|
await runtimeHost.shutdown(reason);
|
|
1301
1317
|
})();
|
|
@@ -1510,6 +1526,7 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
|
|
|
1510
1526
|
sessionId: agentStates.get(agent.agentId)?.state.sessions[agent.runtime] || null,
|
|
1511
1527
|
})));
|
|
1512
1528
|
reminder.startSync();
|
|
1529
|
+
inboxAudit.start();
|
|
1513
1530
|
interaction.startSync();
|
|
1514
1531
|
eventSourceStartTimer = setTimeout(() => { eventSourceStartTimer = null; startEventSource(); }, eventSourceStartDelayMs);
|
|
1515
1532
|
}
|
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
|
|
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}
|
|
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(
|
|
113
|
-
log(`\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,
|
|
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(
|
|
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
|
+
}
|