larkin 0.4.13 → 0.4.14

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.
@@ -1,7 +1,30 @@
1
1
  import * as ReminderStore from "./reminder-store.mjs";
2
+ import { isCanonicalInboxTarget, isUserDeliveryTarget } from "./inbox-projection.mjs";
2
3
  function versionOf(reminder) {
3
4
  return Number(reminder.version || 0);
4
5
  }
6
+ const VALID_IM_ANCHOR = /^om_[A-Za-z0-9_-]+$/;
7
+ const VALID_COMMENT_ANCHOR = /^doc_comment_[A-Za-z0-9_-]+$/;
8
+ const VALID_CHAT_TARGET = /^chat:oc_[A-Za-z0-9_-]+$/;
9
+ const VALID_THREAD_TARGET = /^thread:oc_[A-Za-z0-9_-]+:omt_[A-Za-z0-9_-]+$/;
10
+ const VALID_DOCUMENT_COMMENT_TARGET = /^document-comment:(doc|docx|sheet|file):[A-Za-z0-9_-]+:[A-Za-z0-9_-]+:(in-thread|top-level)$/;
11
+ function userTarget(value, field) {
12
+ const raw = String(value ?? "").trim();
13
+ // --channel historically accepted an oc_ chat id; retain that unambiguous form.
14
+ const target = /^oc_[A-Za-z0-9_-]+$/.test(raw) ? `chat:${raw}` : raw;
15
+ const complete = VALID_CHAT_TARGET.test(target) || VALID_THREAD_TARGET.test(target) || VALID_DOCUMENT_COMMENT_TARGET.test(target);
16
+ if (!complete || !isCanonicalInboxTarget(target) || !isUserDeliveryTarget(target)) {
17
+ throw new Error(`${field} 必须是完整可投递的 canonical target(chat:oc_<id>、thread:oc_<chat>:omt_<thread> 或完整 document-comment locator)`);
18
+ }
19
+ return target;
20
+ }
21
+ function validAnchor(value, field = "message-id", target) {
22
+ const anchor = String(value ?? "").trim();
23
+ const valid = VALID_IM_ANCHOR.test(anchor) || (target?.startsWith("document-comment:") === true && VALID_COMMENT_ANCHOR.test(anchor));
24
+ if (!valid)
25
+ throw new Error(`${field} 必须是有效的 Feishu om_ message_id,或 document-comment source 的 doc_comment_ anchor`);
26
+ return anchor;
27
+ }
5
28
  export function createReminderRoutes(options) {
6
29
  const now = options.now ?? Date.now;
7
30
  const timeZone = options.timeZone ?? (() => Intl.DateTimeFormat().resolvedOptions().timeZone);
@@ -18,6 +41,60 @@ export function createReminderRoutes(options) {
18
41
  return { ok: false, status: 400, error: "title 不能为空" };
19
42
  const current = now();
20
43
  const tz = typeof body.tz === "string" && body.tz ? body.tz : timeZone();
44
+ const internal = body.internal === true || body.noDelivery === true;
45
+ let deliveryTarget = null;
46
+ let deliveryAnchor = null;
47
+ if (internal) {
48
+ if (body.deliveryTarget !== undefined || body.channel !== undefined || body.msgId !== undefined) {
49
+ return { ok: false, status: 400, error: "internal/no-delivery reminder 不应携带 user-facing delivery target" };
50
+ }
51
+ }
52
+ else {
53
+ try {
54
+ const hasExplicitTarget = body.deliveryTarget !== undefined && body.deliveryTarget !== null && Boolean(String(body.deliveryTarget).trim());
55
+ const hasExplicitChannel = body.channel !== undefined && body.channel !== null && Boolean(String(body.channel).trim());
56
+ // deliveryTarget and channel are destination aliases. Preferring one
57
+ // over a contradicting other could route to the wrong conversation,
58
+ // so a disagreement fails closed instead of being silently resolved.
59
+ if (hasExplicitTarget && hasExplicitChannel
60
+ && userTarget(body.deliveryTarget, "delivery target") !== userTarget(body.channel, "channel")) {
61
+ return { ok: false, status: 400, error: "deliveryTarget 与 channel 指向不同目的地;请只提供其中一个" };
62
+ }
63
+ const explicit = hasExplicitTarget ? body.deliveryTarget : hasExplicitChannel ? body.channel : undefined;
64
+ if (explicit !== undefined)
65
+ deliveryTarget = userTarget(explicit, "delivery target");
66
+ if (body.msgId !== undefined && body.msgId !== null && String(body.msgId).trim()) {
67
+ const anchorTarget = options.resolveMessageTarget?.(String(body.msgId).trim()) || null;
68
+ deliveryAnchor = validAnchor(body.msgId, "message-id", anchorTarget || deliveryTarget);
69
+ if (deliveryTarget && !anchorTarget) {
70
+ return { ok: false, status: 400, error: `message-id ${String(body.msgId).trim()} 无法验证其所属 Inbox target;请省略 anchor 或提供可验证的 anchor` };
71
+ }
72
+ if (!deliveryTarget && !anchorTarget) {
73
+ return { ok: false, status: 400, error: `message-id ${deliveryAnchor} 没有当前 Inbox 的 canonical delivery target` };
74
+ }
75
+ if (anchorTarget) {
76
+ const normalizedAnchorTarget = userTarget(anchorTarget, "message-id target");
77
+ if (deliveryTarget && deliveryTarget !== normalizedAnchorTarget) {
78
+ return { ok: false, status: 400, error: "delivery target 与 message-id 所属 Inbox target 冲突" };
79
+ }
80
+ deliveryTarget = deliveryTarget || normalizedAnchorTarget;
81
+ }
82
+ }
83
+ if (deliveryTarget && (deliveryTarget.startsWith("thread:") || deliveryTarget.startsWith("document-comment:")) && !deliveryAnchor) {
84
+ return { ok: false, status: 400, error: `${deliveryTarget} 必须同时提供可验证的 message-id anchor` };
85
+ }
86
+ if (!deliveryTarget) {
87
+ const source = options.currentInboxSource?.() || null;
88
+ if (!source)
89
+ return { ok: false, status: 400, error: "user-facing reminder 必须显式指定 delivery target,或从当前 Inbox source 派生" };
90
+ deliveryTarget = userTarget(source.deliveryTarget, "current Inbox target");
91
+ deliveryAnchor = validAnchor(source.deliveryAnchor, "current Inbox anchor", deliveryTarget);
92
+ }
93
+ }
94
+ catch (error) {
95
+ return { ok: false, status: 400, error: error.message };
96
+ }
97
+ }
21
98
  let recurrence = null;
22
99
  if (body.repeat !== undefined) {
23
100
  recurrence = ReminderStore.parseRepeat(body.repeat, tz);
@@ -42,14 +119,17 @@ export function createReminderRoutes(options) {
42
119
  const reminder = {
43
120
  reminderId: ReminderStore.newId(), ownerAgentId: options.agentId, title,
44
121
  fireAt: ReminderStore.nowIso(fireAtMs), firedAt: null, createdAt: ReminderStore.nowIso(current),
45
- status: "scheduled", msgRef: body.msgId ?? null, msgPermalink: null,
122
+ status: "scheduled", msgRef: deliveryAnchor, msgPermalink: null,
123
+ deliveryTarget, deliveryAnchor, deliveryMode: internal ? "internal" : "user",
46
124
  repeat: body.repeat ?? null, tz: body.repeat !== undefined ? tz : null,
47
125
  channel: body.channel ?? null, payload: body.payload ?? null,
48
126
  version: 1, events: [],
49
127
  };
50
- ReminderStore.appendEvent(reminder, "scheduled", "agent", options.agentId, reminder.fireAt, current);
128
+ ReminderStore.appendEvent(reminder, "scheduled", "agent", options.agentId, reminder.fireAt, current, {
129
+ deliveryTarget, deliveryAnchor, deliveryMode: internal ? "internal" : "user",
130
+ });
51
131
  ReminderStore.mutate(options.stateFile, (store) => store.reminders.push(reminder));
52
- options.log(`reminder 已建 #${reminder.reminderId.slice(0, 8)} fireAt=${reminder.fireAt}${reminder.repeat ? ` repeat=${String(reminder.repeat)}` : ""}`);
132
+ options.log(`reminder 已建 #${reminder.reminderId.slice(0, 8)} fireAt=${reminder.fireAt}${reminder.repeat ? ` repeat=${String(reminder.repeat)}` : ""} deliveryTarget=${deliveryTarget || "none"}`);
53
133
  return { ok: true, status: 200, data: { reminder: ReminderStore.toSummary(reminder), ...(warning ? { warning } : {}) } };
54
134
  }
55
135
  if (method === "GET" && !reminderId) {
@@ -201,10 +201,13 @@ function toSummary(reminder) {
201
201
  status: reminder.status,
202
202
  msgRef: reminder.msgRef ?? null,
203
203
  msgPermalink: reminder.msgPermalink ?? null,
204
+ deliveryTarget: reminder.deliveryTarget ?? null,
205
+ deliveryAnchor: reminder.deliveryAnchor ?? null,
206
+ deliveryMode: reminder.deliveryMode === "internal" ? "internal" : "user",
204
207
  recurrence,
205
208
  };
206
209
  }
207
- function appendEvent(reminder, eventType, actorType, actorId, nextFireAt, ms) {
210
+ function appendEvent(reminder, eventType, actorType, actorId, nextFireAt, ms, metadata) {
208
211
  if (!Array.isArray(reminder.events))
209
212
  reminder.events = [];
210
213
  reminder.events.push({
@@ -215,6 +218,6 @@ function appendEvent(reminder, eventType, actorType, actorId, nextFireAt, ms) {
215
218
  actorId: actorId ?? null,
216
219
  occurredAt: (0, exports.nowIso)(ms),
217
220
  nextFireAt: nextFireAt ?? null,
218
- metadata: null,
221
+ metadata: metadata ?? null,
219
222
  });
220
223
  }
@@ -186,10 +186,13 @@ export function toSummary(reminder) {
186
186
  status: reminder.status,
187
187
  msgRef: reminder.msgRef ?? null,
188
188
  msgPermalink: reminder.msgPermalink ?? null,
189
+ deliveryTarget: reminder.deliveryTarget ?? null,
190
+ deliveryAnchor: reminder.deliveryAnchor ?? null,
191
+ deliveryMode: reminder.deliveryMode === "internal" ? "internal" : "user",
189
192
  recurrence,
190
193
  };
191
194
  }
192
- export function appendEvent(reminder, eventType, actorType, actorId, nextFireAt, ms) {
195
+ export function appendEvent(reminder, eventType, actorType, actorId, nextFireAt, ms, metadata) {
193
196
  if (!Array.isArray(reminder.events))
194
197
  reminder.events = [];
195
198
  reminder.events.push({
@@ -200,6 +203,6 @@ export function appendEvent(reminder, eventType, actorType, actorId, nextFireAt,
200
203
  actorId: actorId ?? null,
201
204
  occurredAt: nowIso(ms),
202
205
  nextFireAt: nextFireAt ?? null,
203
- metadata: null,
206
+ metadata: metadata ?? null,
204
207
  });
205
208
  }
@@ -46,6 +46,7 @@ const inbox_projection_js_1 = require("./inbox-projection.cjs");
46
46
  const message_policy_js_1 = require("../feishu/message-policy.cjs");
47
47
  const outbound_transport_js_1 = require("../feishu/outbound-transport.cjs");
48
48
  const reminder_routes_js_1 = require("./reminder-routes.cjs");
49
+ const reminder_delivery_audit_js_1 = require("./reminder-delivery-audit.cjs");
49
50
  const transport_shell_js_1 = require("./transport-shell.cjs");
50
51
  const agent_lark_cli_workspace_js_1 = require("../app/agent-lark-cli-workspace.cjs");
51
52
  function formatFeishuError(error, grantLink = null) {
@@ -152,6 +153,24 @@ function createTransportBusinessContext(env = process.env) {
152
153
  }
153
154
  };
154
155
  const resolveChatId = (target) => (0, transport_shell_js_1.resolveMappedChatId)(loadMap(), target);
156
+ const recordReminderDelivery = ({ target, succeeded, messageId, reason }) => {
157
+ const current = stateStore.resolveCurrentReminder();
158
+ try {
159
+ (0, reminder_delivery_audit_js_1.auditReminderDelivery)({ stateStore, agentId, target, succeeded, ...(messageId ? { messageId } : {}),
160
+ ...(reason ? { reason } : {}), resolveChatId });
161
+ }
162
+ catch (error) {
163
+ if (succeeded && current && stateStore.markCurrentReminderDeliveryCommitted) {
164
+ try {
165
+ stateStore.markCurrentReminderDeliveryCommitted(current.reminderId, current.deliveryAnchor, messageId);
166
+ }
167
+ catch (markerError) {
168
+ log(`reminder committed marker 写失败 id=#${current.reminderId.slice(0, 8)}: ${markerError.message}`);
169
+ }
170
+ }
171
+ log(`reminder outbound audit 写失败 id=#${current?.reminderId.slice(0, 8) || "unknown"}: ${error.message}`);
172
+ }
173
+ };
155
174
  const larkArgs = (...rest) => rest;
156
175
  const larkCli = (args, cwd) => {
157
176
  const managed = (0, agent_lark_cli_workspace_js_1.managedOfficialLarkCli)(selectedAgent, process.env);
@@ -307,7 +326,8 @@ function createTransportBusinessContext(env = process.env) {
307
326
  const outbound = (0, outbound_transport_js_1.createOutboundTransport)({
308
327
  attachmentDir, attachmentIndexFile, resolveChatId, replyContextFor,
309
328
  sendText: larkSend, replyText: larkReply, sendMedia: larkSendMedia,
310
- appendConversation, botDisplayName: () => botIdentity()?.name, agentName, dryRun, log,
329
+ appendConversation, onDeliveryOutcome: recordReminderDelivery,
330
+ botDisplayName: () => botIdentity()?.name, agentName, dryRun, log,
311
331
  });
312
332
  const liveGrantLink = () => {
313
333
  try {
@@ -469,7 +489,11 @@ function createTransportBusinessContext(env = process.env) {
469
489
  },
470
490
  mappedChannels: loadMap, larkJsonOut, feishuError, botDisplayName, feishuAppInfo, log,
471
491
  });
472
- const reminderRoutes = (0, reminder_routes_js_1.createReminderRoutes)({ stateFile: paths.reminders, agentId, query, log });
492
+ const reminderRoutes = (0, reminder_routes_js_1.createReminderRoutes)({
493
+ stateFile: paths.reminders, agentId, query, log,
494
+ currentInboxSource: () => stateStore.resolveCurrentInboxSource(),
495
+ resolveMessageTarget: (messageId) => stateStore.resolveInboxMessageTarget(messageId),
496
+ });
473
497
  try {
474
498
  fs.appendFileSync(transportLogFile, `${new Date().toISOString()} INIT profile=${selectedAgent.feishuProfile} larkCfgDir=${selectedAgent.larkConfigDir} inbox=${paths.inbox}\n`);
475
499
  }
@@ -9,6 +9,7 @@ import { projectInboxEvents } from "./inbox-projection.mjs";
9
9
  import { signatureDescription } from "../feishu/message-policy.mjs";
10
10
  import { createOutboundTransport } from "../feishu/outbound-transport.mjs";
11
11
  import { createReminderRoutes } from "./reminder-routes.mjs";
12
+ import { auditReminderDelivery } from "./reminder-delivery-audit.mjs";
12
13
  import { isImageMime, looksLikeMarkdown, resolveMappedChatId, safeConversationExcerpt } from "./transport-shell.mjs";
13
14
  import { managedOfficialLarkCli } from "../app/agent-lark-cli-workspace.mjs";
14
15
  export function formatFeishuError(error, grantLink = null) {
@@ -115,6 +116,24 @@ export function createTransportBusinessContext(env = process.env) {
115
116
  }
116
117
  };
117
118
  const resolveChatId = (target) => resolveMappedChatId(loadMap(), target);
119
+ const recordReminderDelivery = ({ target, succeeded, messageId, reason }) => {
120
+ const current = stateStore.resolveCurrentReminder();
121
+ try {
122
+ auditReminderDelivery({ stateStore, agentId, target, succeeded, ...(messageId ? { messageId } : {}),
123
+ ...(reason ? { reason } : {}), resolveChatId });
124
+ }
125
+ catch (error) {
126
+ if (succeeded && current && stateStore.markCurrentReminderDeliveryCommitted) {
127
+ try {
128
+ stateStore.markCurrentReminderDeliveryCommitted(current.reminderId, current.deliveryAnchor, messageId);
129
+ }
130
+ catch (markerError) {
131
+ log(`reminder committed marker 写失败 id=#${current.reminderId.slice(0, 8)}: ${markerError.message}`);
132
+ }
133
+ }
134
+ log(`reminder outbound audit 写失败 id=#${current?.reminderId.slice(0, 8) || "unknown"}: ${error.message}`);
135
+ }
136
+ };
118
137
  const larkArgs = (...rest) => rest;
119
138
  const larkCli = (args, cwd) => {
120
139
  const managed = managedOfficialLarkCli(selectedAgent, process.env);
@@ -270,7 +289,8 @@ export function createTransportBusinessContext(env = process.env) {
270
289
  const outbound = createOutboundTransport({
271
290
  attachmentDir, attachmentIndexFile, resolveChatId, replyContextFor,
272
291
  sendText: larkSend, replyText: larkReply, sendMedia: larkSendMedia,
273
- appendConversation, botDisplayName: () => botIdentity()?.name, agentName, dryRun, log,
292
+ appendConversation, onDeliveryOutcome: recordReminderDelivery,
293
+ botDisplayName: () => botIdentity()?.name, agentName, dryRun, log,
274
294
  });
275
295
  const liveGrantLink = () => {
276
296
  try {
@@ -432,7 +452,11 @@ export function createTransportBusinessContext(env = process.env) {
432
452
  },
433
453
  mappedChannels: loadMap, larkJsonOut, feishuError, botDisplayName, feishuAppInfo, log,
434
454
  });
435
- const reminderRoutes = createReminderRoutes({ stateFile: paths.reminders, agentId, query, log });
455
+ const reminderRoutes = createReminderRoutes({
456
+ stateFile: paths.reminders, agentId, query, log,
457
+ currentInboxSource: () => stateStore.resolveCurrentInboxSource(),
458
+ resolveMessageTarget: (messageId) => stateStore.resolveInboxMessageTarget(messageId),
459
+ });
436
460
  try {
437
461
  fs.appendFileSync(transportLogFile, `${new Date().toISOString()} INIT profile=${selectedAgent.feishuProfile} larkCfgDir=${selectedAgent.larkConfigDir} inbox=${paths.inbox}\n`);
438
462
  }
@@ -262,7 +262,17 @@ function reminderRequest(groupArgv, stateStore, agentId, deps) {
262
262
  if (!fs.existsSync(stateStore.paths.reminders))
263
263
  stateStore.writeJson("reminders", { reminders: [] });
264
264
  const [operation, ...rest] = groupArgv;
265
- const options = parseOptions(rest, new Set(["--all", "--json"]));
265
+ const options = parseOptions(rest, new Set(["--all", "--json", "--no-delivery", "--internal"]));
266
+ const assertOnlyFlags = (valueFlags, booleanFlags = []) => {
267
+ const allowedValues = new Set(valueFlags);
268
+ const allowedBooleans = new Set(booleanFlags);
269
+ const unsupported = [
270
+ ...[...options.values.keys()].filter((flag) => !allowedValues.has(flag)),
271
+ ...[...options.booleans].filter((flag) => !allowedBooleans.has(flag)),
272
+ ];
273
+ if (unsupported.length)
274
+ throw new Error(`reminder ${operation || ""} 不支持参数:${unsupported.join(", ")}`);
275
+ };
266
276
  if (options.positionals.length)
267
277
  throw new Error(`reminder ${operation || ""} 不接受位置参数:${options.positionals.join(" ")}`);
268
278
  const id = options.values.get("--id");
@@ -270,7 +280,8 @@ function reminderRequest(groupArgv, stateStore, agentId, deps) {
270
280
  let requestPath = "/reminders";
271
281
  const body = {};
272
282
  switch (operation) {
273
- case "schedule":
283
+ case "schedule": {
284
+ assertOnlyFlags(["--title", "--fire-at", "--delay-seconds", "--repeat", "--tz", "--message-id", "--delivery-target", "--target", "--channel"], ["--json", "--no-delivery", "--internal"]);
274
285
  method = "POST";
275
286
  body.title = options.values.get("--title") || "";
276
287
  if (options.values.has("--fire-at"))
@@ -283,10 +294,24 @@ function reminderRequest(groupArgv, stateStore, agentId, deps) {
283
294
  body.tz = options.values.get("--tz");
284
295
  if (options.values.has("--message-id"))
285
296
  body.msgId = options.values.get("--message-id");
297
+ // The destination controls the recipient. Aliases must never silently
298
+ // overwrite each other, so any combination fails closed at parse time.
299
+ const destinationFlags = ["--delivery-target", "--target", "--channel"].filter((flag) => options.values.has(flag));
300
+ if (destinationFlags.length > 1) {
301
+ throw new Error(`reminder schedule 收到多个 delivery 目的地 flag(${destinationFlags.join("、")});它们互为别名且可能指向不同会话,只能指定其中一个`);
302
+ }
286
303
  if (options.values.has("--channel"))
287
304
  body.channel = options.values.get("--channel");
305
+ if (options.values.has("--delivery-target"))
306
+ body.deliveryTarget = options.values.get("--delivery-target");
307
+ if (options.values.has("--target"))
308
+ body.deliveryTarget = options.values.get("--target");
309
+ if (options.booleans.has("--no-delivery") || options.booleans.has("--internal"))
310
+ body.noDelivery = true;
288
311
  break;
312
+ }
289
313
  case "list": {
314
+ assertOnlyFlags(["--status"], ["--all", "--json"]);
290
315
  const search = new URLSearchParams();
291
316
  if (options.values.has("--status"))
292
317
  search.set("status", options.values.get("--status"));
@@ -296,6 +321,7 @@ function reminderRequest(groupArgv, stateStore, agentId, deps) {
296
321
  break;
297
322
  }
298
323
  case "snooze":
324
+ assertOnlyFlags(["--id", "--delay-seconds"], ["--json"]);
299
325
  if (!id)
300
326
  throw new Error("reminder snooze 需要 --id");
301
327
  method = "POST";
@@ -303,6 +329,7 @@ function reminderRequest(groupArgv, stateStore, agentId, deps) {
303
329
  body.delaySeconds = numberOption(options, "--delay-seconds");
304
330
  break;
305
331
  case "update":
332
+ assertOnlyFlags(["--id", "--title", "--fire-at", "--delay-seconds", "--repeat", "--tz"], ["--json"]);
306
333
  if (!id)
307
334
  throw new Error("reminder update 需要 --id");
308
335
  method = "PATCH";
@@ -319,12 +346,14 @@ function reminderRequest(groupArgv, stateStore, agentId, deps) {
319
346
  body.tz = options.values.get("--tz");
320
347
  break;
321
348
  case "cancel":
349
+ assertOnlyFlags(["--id"], ["--json"]);
322
350
  if (!id)
323
351
  throw new Error("reminder cancel 需要 --id");
324
352
  method = "DELETE";
325
353
  requestPath += `/${encodeURIComponent(id)}`;
326
354
  break;
327
355
  case "log":
356
+ assertOnlyFlags(["--id"], ["--json"]);
328
357
  if (!id)
329
358
  throw new Error("reminder log 需要 --id");
330
359
  requestPath += `/${encodeURIComponent(id)}/log`;
@@ -339,6 +368,8 @@ function reminderRequest(groupArgv, stateStore, agentId, deps) {
339
368
  log: () => undefined,
340
369
  ...(deps.now ? { now: deps.now } : {}),
341
370
  ...(deps.timeZone ? { timeZone: deps.timeZone } : {}),
371
+ currentInboxSource: () => stateStore.resolveCurrentInboxSource(),
372
+ resolveMessageTarget: (messageId) => stateStore.resolveInboxMessageTarget(messageId),
342
373
  });
343
374
  return routes.handle({ path: requestPath, pathNoQuery: requestPath.split("?")[0], method, body });
344
375
  }
@@ -4,6 +4,7 @@ import { createHash } from "node:crypto";
4
4
  import * as path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { createAgentStateStore } from "../agent/agent-state-store.mjs";
7
+ import { auditReminderDelivery } from "../agent/reminder-delivery-audit.mjs";
7
8
  import { evaluateFreshness } from "../agent/freshness-gate.mjs";
8
9
  import { feishuImFreshnessAdapter, feishuImTarget, mergeFeishuImCursor, serializeFeishuImTarget, } from "../feishu/im-freshness-adapter.mjs";
9
10
  import * as larkinConfig from "../platform/config.mjs";
@@ -281,21 +282,22 @@ const IM_WRITE_MEMO_LIMIT = 512;
281
282
  // 只标注、不拦截:每次成功写把「实际生效的幂等 key → 服务端返回的 message_id」记进备忘。
282
283
  // 同 key 再次成功且服务端返回同一个 message_id,说明服务端走了幂等去重(没有产生新消息),
283
284
  // 返回 true 供输出标注 duplicate。拦截权始终在服务端,备忘不会吞掉任何发送。
284
- function recordImWriteMemo(store, key, messageId) {
285
- let duplicate = false;
286
- store.mutateJson("freshnessState", { version: 1, cursors: {} }, (state) => {
285
+ function recordImWriteMemo(store, key, messageId, sourceMessageId) {
286
+ return store.mutateJson("freshnessState", { version: 1, cursors: {} }, (state) => {
287
287
  state.im_write_memo ??= {};
288
288
  const prior = state.im_write_memo[key];
289
- if (prior && prior.message_id === messageId)
290
- duplicate = true;
291
- state.im_write_memo[key] = { message_id: messageId, updated_at: new Date().toISOString() };
289
+ if (prior && prior.message_id === messageId) {
290
+ return { duplicate: true, ...(prior.source_message_id ? { priorSourceMessageId: prior.source_message_id } : {}) };
291
+ }
292
+ state.im_write_memo[key] = { message_id: messageId, updated_at: new Date().toISOString(),
293
+ ...(sourceMessageId ? { source_message_id: sourceMessageId } : {}) };
292
294
  const keys = Object.keys(state.im_write_memo);
293
295
  for (const stale of keys.slice(0, Math.max(0, keys.length - IM_WRITE_MEMO_LIMIT)))
294
296
  delete state.im_write_memo[stale];
297
+ return { duplicate: false };
295
298
  });
296
- return duplicate;
297
299
  }
298
- function runCommentReply(argv, privateEnv, io, dependencies, store) {
300
+ function runCommentReply(argv, privateEnv, io, dependencies, store, agentId) {
299
301
  const input = parseCommentReply(argv);
300
302
  const targetKey = store.resolveInboxMessageTarget(input.messageId);
301
303
  const target = targetKey ? parseDocumentCommentTarget(targetKey) : null;
@@ -324,6 +326,18 @@ function runCommentReply(argv, privateEnv, io, dependencies, store) {
324
326
  return "ready";
325
327
  });
326
328
  if (claim === "sent") {
329
+ const currentReminder = store.resolveCurrentReminders().filter((reminder) => reminder.deliveryTarget === targetKey).at(-1) ?? null;
330
+ try {
331
+ auditReminderDelivery({ stateStore: store, agentId, target: targetKey, deliveryAnchor: currentReminder?.deliveryAnchor,
332
+ succeeded: !currentReminder, ...(currentReminder ? { reason: "document comment ledger already sent this text to the anchor on an earlier reminder firing" } : {}) });
333
+ }
334
+ catch (error) {
335
+ io.stderr(`lark-cli: reminder delivery audit failed: ${error instanceof Error ? error.message : String(error)}\n`);
336
+ }
337
+ if (currentReminder) {
338
+ io.stderr("comment reply was already sent for an earlier reminder firing; no new delivery was committed\n");
339
+ return 2;
340
+ }
327
341
  io.stdout(`${JSON.stringify({ ok: true, identity: "bot", committed: true, duplicate: true, target: targetKey })}\n`);
328
342
  return 0;
329
343
  }
@@ -362,6 +376,25 @@ function runCommentReply(argv, privateEnv, io, dependencies, store) {
362
376
  };
363
377
  });
364
378
  }
379
+ const committedWrite = !result.error && result.status === 0;
380
+ const currentReminder = committedWrite
381
+ ? store.resolveCurrentReminders().filter((reminder) => reminder.deliveryTarget === targetKey).at(-1) ?? null
382
+ : null;
383
+ try {
384
+ auditReminderDelivery({ stateStore: store, agentId, target: targetKey, succeeded: committedWrite,
385
+ ...(committedWrite ? {} : { reason: (result.stderr || result.stdout || "comment provider failed").trim().split("\n")[0] }) });
386
+ }
387
+ catch (error) {
388
+ if (committedWrite && currentReminder) {
389
+ try {
390
+ store.markCurrentReminderDeliveryCommitted(currentReminder.reminderId, currentReminder.deliveryAnchor);
391
+ }
392
+ catch (markerError) {
393
+ io.stderr(`lark-cli: reminder committed marker failed: ${markerError instanceof Error ? markerError.message : String(markerError)}\n`);
394
+ }
395
+ }
396
+ io.stderr(`lark-cli: reminder delivery audit failed: ${error instanceof Error ? error.message : String(error)}\n`);
397
+ }
365
398
  return emitNativeResult(result, io);
366
399
  }
367
400
  function definitiveProviderRejection(result) {
@@ -1031,7 +1064,7 @@ export function runLarkCli(argv, env = process.env, dependencies = {}) {
1031
1064
  }
1032
1065
  catch { /* telemetry is failure-isolated */ }
1033
1066
  }
1034
- return telemetry.externalPhase(agent.agentId, store.paths.root, "document.comment.reply", SpanKind.CLIENT, () => runCommentReply(argv, privateEnv, io, nativeDependencies, store), "comment_cli");
1067
+ return telemetry.externalPhase(agent.agentId, store.paths.root, "document.comment.reply", SpanKind.CLIENT, () => runCommentReply(argv, privateEnv, io, nativeDependencies, store, agent.agentId), "comment_cli");
1035
1068
  }
1036
1069
  catch (error) {
1037
1070
  io.stderr(`lark-cli: ${error instanceof Error ? error.message : String(error)}\n`);
@@ -1084,8 +1117,43 @@ export function runLarkCli(argv, env = process.env, dependencies = {}) {
1084
1117
  return emitNativeResult(write, io);
1085
1118
  }
1086
1119
  const writeMessage = writeResponseMessage(write);
1087
- const duplicate = !write.error && write.status === 0 && writeMessage
1088
- ? recordImWriteMemo(store, intentKey, writeMessage.message_id) : false;
1120
+ const deliveryTarget = decision.operation === "send"
1121
+ ? `chat:${policyFlagValue(effectiveArgv, "--chat-id")}`
1122
+ : store.resolveInboxMessageTarget(policyFlagValue(effectiveArgv, "--message-id") || "") || targetKey;
1123
+ const matchingReminderContexts = store.resolveCurrentReminders().filter((reminder) => reminder.deliveryTarget === deliveryTarget);
1124
+ // When recurring firings overlap, attach this write to one occurrence rather
1125
+ // than dropping audit state merely because reminder_id is shared. Reuse this
1126
+ // exact anchor for both the memo and audit so they cannot select different
1127
+ // occurrences.
1128
+ const currentReminder = matchingReminderContexts.at(-1) ?? null;
1129
+ const deliveryAnchor = currentReminder?.deliveryAnchor;
1130
+ const memo = !write.error && write.status === 0 && writeMessage
1131
+ ? recordImWriteMemo(store, intentKey, writeMessage.message_id, deliveryAnchor) : { duplicate: false };
1132
+ const duplicateOfEarlierReminder = memo.duplicate && Boolean(currentReminder)
1133
+ && memo.priorSourceMessageId !== currentReminder?.deliveryAnchor;
1134
+ const committedWrite = !write.error && write.status === 0 && !duplicateOfEarlierReminder;
1135
+ try {
1136
+ auditReminderDelivery({ stateStore: store, agentId: agent.agentId, target: deliveryTarget, deliveryAnchor,
1137
+ succeeded: committedWrite,
1138
+ ...(!committedWrite ? {
1139
+ reason: duplicateOfEarlierReminder
1140
+ ? "provider deduplicated this write to an earlier reminder firing"
1141
+ : (write.stderr || write.stdout || "provider write failed").trim().split("\n")[0],
1142
+ } : {}),
1143
+ ...(writeMessage?.message_id ? { messageId: writeMessage.message_id } : {}) });
1144
+ }
1145
+ catch (error) {
1146
+ if (committedWrite && currentReminder) {
1147
+ try {
1148
+ store.markCurrentReminderDeliveryCommitted(currentReminder.reminderId, currentReminder.deliveryAnchor, writeMessage?.message_id);
1149
+ }
1150
+ catch (markerError) {
1151
+ io.stderr(`lark-cli: reminder committed marker failed: ${markerError instanceof Error ? markerError.message : String(markerError)}\n`);
1152
+ }
1153
+ }
1154
+ io.stderr(`lark-cli: reminder delivery audit failed: ${error instanceof Error ? error.message : String(error)}\n`);
1155
+ }
1156
+ const duplicate = memo.duplicate;
1089
1157
  if (duplicate) {
1090
1158
  observeSuccessfulWrite(write, target, targetKey, store, generation);
1091
1159
  emitDuplicatedWrite(write, io, { target: targetKey });
@@ -316,31 +316,50 @@ class HostEnvelopeProjector {
316
316
  }
317
317
  createReminderEnvelope(agentId, reminder, overdueMs, repeatDescription) {
318
318
  const seq = this.nextSequence(agentId);
319
- const anchorMessageId = typeof reminder.msgRef === "string" && /^om_[A-Za-z0-9_-]+$/.test(reminder.msgRef)
320
- ? reminder.msgRef
321
- : null;
319
+ const legacyChannel = typeof reminder.channel === "string" ? reminder.channel.trim() : "";
320
+ const legacyChatTarget = /^oc_[A-Za-z0-9_-]+$/.test(legacyChannel) ? `chat:${legacyChannel}` : null;
321
+ const deliveryTarget = typeof reminder.deliveryTarget === "string" && reminder.deliveryTarget
322
+ ? reminder.deliveryTarget : legacyChatTarget;
323
+ const anchorMessageId = typeof reminder.deliveryAnchor === "string" && /^om_[A-Za-z0-9_-]+$/.test(reminder.deliveryAnchor)
324
+ ? reminder.deliveryAnchor
325
+ : typeof reminder.msgRef === "string" && /^om_[A-Za-z0-9_-]+$/.test(reminder.msgRef)
326
+ ? reminder.msgRef
327
+ : null;
328
+ const commentAnchorId = deliveryTarget?.startsWith("document-comment:") && typeof reminder.deliveryAnchor === "string"
329
+ && /^doc_comment_[A-Za-z0-9_-]+$/.test(reminder.deliveryAnchor) ? reminder.deliveryAnchor : null;
330
+ const anchorId = anchorMessageId || commentAnchorId;
331
+ const chatId = deliveryTarget?.match(/^chat:(oc_[A-Za-z0-9_-]+)$/)?.[1] || null;
322
332
  const lines = [
323
333
  `[定时提醒触发] ${reminder.title}`,
324
334
  `提醒ID: #${reminder.reminderId.slice(0, 8)}` + (reminder.repeat && repeatDescription
325
335
  ? ` 重复: ${repeatDescription}(下次已自动排在 ${reminder.fireAt})`
326
336
  : " 类型: 一次性"),
327
337
  overdueMs > 120_000 ? `注意: 原定时间已过 ${Math.round(overdueMs / 60_000)} 分钟(Runtime Host 离线期间错过,现补触发)` : null,
328
- anchorMessageId ? `锚定消息: ${anchorMessageId}` : reminder.msgRef ? `历史锚点 ${String(reminder.msgRef)} 不是飞书 om_ message_id,不能用于回复` : null,
338
+ deliveryTarget ? `原始 deliveryTarget: ${deliveryTarget}`
339
+ : anchorId ? "这是升级前存量 user-facing reminder,优先回复其安全锚点;不得向标题中的任何人或第三方发送消息"
340
+ : "本条为 internal/no-delivery reminder,不得向标题中的任何人或第三方发送消息",
341
+ anchorId ? `锚定消息: ${anchorId}` : reminder.msgRef ? `历史锚点 ${String(reminder.msgRef)} 不是可用的 delivery anchor,不能用于回复` : null,
329
342
  anchorMessageId
330
- ? `回复原会话: ${this.larkCommand(`im +messages-reply --message-id ${anchorMessageId} ...`)}`
331
- : null,
332
- reminder.channel && /^oc_[A-Za-z0-9_-]+$/.test(reminder.channel)
333
- ? `发送到原群: ${this.larkCommand(`im +messages-send --chat-id ${reminder.channel} ...`)}`
334
- : reminder.channel
335
- ? `历史目标 ${reminder.channel} 不是 chat_id;${anchorMessageId ? "若不回复锚定消息," : ""}先用 ${this.larkCommand("im +chat-search")} 查询并确认 oc_ chat_id,禁止按名称猜测发送目标`
336
- : anchorMessageId
337
- ? null
338
- : `本条存量提醒缺少可用的飞书 message_id/chat_id,无法安全推断原会话;请先用 ${this.larkCommand("im +chat-search")} 确认目标,禁止猜测发送`,
343
+ ? [
344
+ `回复原会话: ${this.larkCommand(`im +messages-reply --message-id ${anchorMessageId}${deliveryTarget?.startsWith("thread:") ? " --reply-in-thread" : ""} ...`)}`,
345
+ chatId ? `若 ${anchorMessageId} 无法由当前 Inbox 解析(例如 interaction_* 卡片锚点或已淘汰的历史消息),改用聊天兜底发送: ${this.larkCommand(`im +messages-send --chat-id ${chatId} ...`)};仅使用这个持久化 chat_id,不得猜测收件人` : null,
346
+ ].filter((line) => Boolean(line)).join("\n")
347
+ : commentAnchorId
348
+ ? `回复原文档评论: ${this.agentCommand(`comment reply --message-id ${commentAnchorId} --text ...`)}`
349
+ : deliveryTarget
350
+ ? chatId
351
+ ? `发送到原始 target: ${deliveryTarget}: ${this.larkCommand(`im +messages-send --chat-id ${chatId} ...`)}(不得从提醒标题推断收件人)`
352
+ : `发送到原始 target: ${deliveryTarget}(不得从提醒标题推断收件人)`
353
+ : null,
339
354
  `这是你之前用 ${this.agentCommand("reminder schedule")} 设置的提醒,请按标题执行相应动作。管理: ${this.agentCommand("reminder list")} / ${this.agentCommand("reminder snooze")} / ${this.agentCommand("reminder cancel")}`,
340
355
  ].filter((line) => Boolean(line));
341
356
  const envelope = {
342
357
  kind: "reminder",
343
- message_id: `rem_${reminder.reminderId.slice(0, 16)}_${seq}`,
358
+ // The random suffix keeps each firing's occurrence identity unique across
359
+ // Host restarts: the in-memory seq restarts at 1 and would otherwise let a
360
+ // recurring reminder reuse a prior occurrenceId in the delivery audit.
361
+ message_id: `rem_${reminder.reminderId.slice(0, 16)}_${seq}_${this.randomHex(6)}`,
362
+ reminderId: reminder.reminderId,
344
363
  seq,
345
364
  sender_name: "定时提醒",
346
365
  sender_type: "system",
@@ -350,6 +369,8 @@ class HostEnvelopeProjector {
350
369
  timestamp: this.now().toISOString(),
351
370
  thread_id: null,
352
371
  wake: true,
372
+ deliveryTarget,
373
+ deliveryAnchor: anchorId,
353
374
  };
354
375
  return { ...envelope, target: inbox_projection_js_1.RUNTIME_REMINDER_TARGET };
355
376
  }