@opentag/lark 0.3.0 → 0.3.1

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/index.js CHANGED
@@ -133,6 +133,8 @@ function normalizeLarkMessage(input) {
133
133
  chatId: input.chatId,
134
134
  messageId: input.messageId,
135
135
  chatType: input.chatType,
136
+ sourceDeliveryId: input.eventId,
137
+ larkEventId: input.eventId,
136
138
  ...input.rootId ? { rootId: input.rootId } : {},
137
139
  ...input.botOpenId ? { larkBotOpenId: input.botOpenId } : {},
138
140
  ...commandMetadata(command),
@@ -144,9 +146,406 @@ function normalizeLarkMessage(input) {
144
146
  }
145
147
 
146
148
  // src/inbound.ts
147
- import { parseProjectTargetRef } from "@opentag/core";
149
+ import {
150
+ createDoctorSummaryPresentation,
151
+ createSourceThreadStatusPresentation,
152
+ parseProjectTargetRef,
153
+ renderOpenTagPresentationPlainText
154
+ } from "@opentag/core";
155
+
156
+ // src/render.ts
157
+ import {
158
+ createFinalSummaryPresentation
159
+ } from "@opentag/core";
160
+ function larkPlain(value) {
161
+ return value.replace(/`/g, "");
162
+ }
163
+ function auditCommand(runId) {
164
+ return `opentag status --run ${runId}`;
165
+ }
166
+ function renderLarkAcknowledgement(runId) {
167
+ return ["Received. OpenTag is working.", `Run: ${runId}`, `Use /status here for queue state; audit locally with ${auditCommand(runId)}.`].join("\n");
168
+ }
169
+ function renderLarkRunStatusPresentation(presentation) {
170
+ if (presentation.state === "received") {
171
+ return renderLarkAcknowledgement(presentation.runId);
172
+ }
173
+ if (presentation.state === "queued") {
174
+ return ["Queued behind an active run.", `Run: ${presentation.runId}`, "Use /status here for queue details."].join("\n");
175
+ }
176
+ if (presentation.state === "waiting_for_approval") {
177
+ return ["Waiting for your review.", `Run: ${presentation.runId}`, "Review the action receipt in this thread, or use /status here for details."].join("\n");
178
+ }
179
+ if (presentation.state === "running") {
180
+ return ["OpenTag is working.", `Run: ${presentation.runId}`, "Use /status here for details."].join("\n");
181
+ }
182
+ return [
183
+ `OpenTag run ${presentation.state}.`,
184
+ `Run: ${presentation.runId}`,
185
+ ...presentation.nextAction ? [presentation.nextAction] : [`Audit locally with ${auditCommand(presentation.runId)}.`]
186
+ ].join("\n");
187
+ }
188
+ function larkRunStatusHeaderTemplate(state) {
189
+ if (state === "received" || state === "queued" || state === "running") return "blue";
190
+ if (state === "waiting_for_approval") return "yellow";
191
+ if (state === "completed") return "green";
192
+ if (state === "failed" || state === "timed_out") return "red";
193
+ if (state === "cancelled" || state === "interrupted") return "grey";
194
+ return "blue";
195
+ }
196
+ function larkRunStatusTitle(state) {
197
+ if (state === "received") return "OpenTag received this";
198
+ if (state === "queued") return "OpenTag queued this";
199
+ if (state === "running") return "OpenTag is working";
200
+ if (state === "waiting_for_approval") return "OpenTag is waiting for review";
201
+ return `OpenTag ${state}`;
202
+ }
203
+ function createLarkRunStatusCard(presentation) {
204
+ const statusText = renderLarkRunStatusPresentation(presentation);
205
+ return {
206
+ config: {
207
+ wide_screen_mode: true,
208
+ update_multi: true
209
+ },
210
+ header: {
211
+ template: larkRunStatusHeaderTemplate(presentation.state),
212
+ title: {
213
+ tag: "plain_text",
214
+ content: larkRunStatusTitle(presentation.state)
215
+ }
216
+ },
217
+ elements: [
218
+ {
219
+ tag: "div",
220
+ text: {
221
+ tag: "lark_md",
222
+ content: statusText
223
+ }
224
+ }
225
+ ]
226
+ };
227
+ }
228
+ function renderLarkFinalResult(result, options = {}) {
229
+ return renderLarkFinalSummaryPresentation(
230
+ createFinalSummaryPresentation({
231
+ result,
232
+ ...options.auditRunId ? { auditRunId: options.auditRunId } : {}
233
+ })
234
+ );
235
+ }
236
+ function renderLarkFinalSummaryPresentation(presentation) {
237
+ const lines = [`Finished with ${presentation.outcome}.`, "", presentation.summary];
238
+ if (presentation.verification?.length) {
239
+ lines.push("", "Verification");
240
+ for (const check of presentation.verification) {
241
+ lines.push(`- ${check.command}: ${check.outcome}`);
242
+ }
243
+ }
244
+ if (presentation.nextActions?.length) {
245
+ lines.push("", `Next action: ${presentation.nextActions[0]}`);
246
+ }
247
+ if (presentation.auditRunId) {
248
+ lines.push("", `Audit: opentag status --run ${presentation.auditRunId}`);
249
+ }
250
+ return lines.join("\n");
251
+ }
252
+ function larkHeaderTemplate(outcome) {
253
+ if (outcome === "success") return "green";
254
+ if (outcome === "failure") return "red";
255
+ if (outcome === "cancelled") return "grey";
256
+ if (outcome === "needs_human") return "blue";
257
+ return "yellow";
258
+ }
259
+ function compactCardText(text, maxLength) {
260
+ const compact = text.replace(/\s+/g, " ").trim();
261
+ if (compact.length <= maxLength) return compact;
262
+ return `${compact.slice(0, Math.max(0, maxLength - 1)).trimEnd()}...`;
263
+ }
264
+ function larkMarkdownList(items) {
265
+ return items.map((item) => `- ${item}`).join("\n");
266
+ }
267
+ function larkActionDecisionLabel(decision, index) {
268
+ if (decision === "apply") return `Apply now: apply ${index}`;
269
+ if (decision === "approve") return `Approve only: approve ${index}`;
270
+ if (decision === "continue") return `Continue: continue ${index}`;
271
+ return `Reject: reject ${index}`;
272
+ }
273
+ function larkActionDetails(action) {
274
+ const rowDetails = action.detailRows?.map((row) => `${row.label}: ${larkPlain(row.value)}`) ?? [];
275
+ if (rowDetails.length > 0) return rowDetails.map((detail) => compactCardText(detail, 180));
276
+ const details = [`Target: ${action.targetLabel}`];
277
+ if (action.setupReason) details.push(`Status: ${action.setupReason}`);
278
+ if (action.details?.length) details.push(...action.details.slice(0, 3));
279
+ return details.map((detail) => compactCardText(larkPlain(detail), 180));
280
+ }
281
+ function larkActionReceiptMarkdown(input, options = {}) {
282
+ if (input.actions.length === 0) return void 0;
283
+ const visibleActions = input.actions.slice(0, 5);
284
+ const lines = [
285
+ ...options.includeTitle === false ? [] : [`**${input.title}**`],
286
+ "Choose a command in this source thread. Details stay in OpenTag audit/status."
287
+ ];
288
+ for (const action of visibleActions) {
289
+ lines.push("", `**${action.index}. ${compactCardText(action.title, 180)}**`);
290
+ lines.push(...larkMarkdownList(larkActionDetails(action)).split("\n"));
291
+ lines.push(...larkMarkdownList(action.visibleDecisions.map((decision) => larkActionDecisionLabel(decision, action.index))).split("\n"));
292
+ }
293
+ const remaining = input.actions.length - visibleActions.length;
294
+ if (remaining > 0) {
295
+ lines.push("", `Showing first ${visibleActions.length} of ${input.actions.length} actions. Use opentag status locally for full detail.`);
296
+ }
297
+ return lines.join("\n");
298
+ }
299
+ function larkFinalSummaryActionReceiptMarkdown(presentation) {
300
+ const actions = presentation.actions ?? [];
301
+ if (!presentation.actionReceiptTitle || actions.length === 0) return void 0;
302
+ return larkActionReceiptMarkdown({ title: presentation.actionReceiptTitle, actions });
303
+ }
304
+ function larkActionReceiptHeaderTemplate(actions) {
305
+ if (actions.some((action) => action.state === "needs_setup" || action.state === "unsupported")) return "yellow";
306
+ if (actions.some((action) => action.state === "ready_to_apply" || action.state === "needs_approval")) return "blue";
307
+ return "grey";
308
+ }
309
+ function createLarkFinalSummaryCard(presentation) {
310
+ const elements = [
311
+ {
312
+ tag: "div",
313
+ text: {
314
+ tag: "lark_md",
315
+ content: compactCardText(presentation.summary, 900)
316
+ }
317
+ }
318
+ ];
319
+ if (presentation.verification?.length) {
320
+ elements.push({ tag: "hr" });
321
+ elements.push({
322
+ tag: "div",
323
+ text: {
324
+ tag: "lark_md",
325
+ content: ["**Verification**", larkMarkdownList(presentation.verification.slice(0, 5).map((check) => `${larkPlain(check.command)}: ${check.outcome}`))].join(
326
+ "\n"
327
+ )
328
+ }
329
+ });
330
+ }
331
+ if (presentation.changedFiles?.length) {
332
+ elements.push({
333
+ tag: "div",
334
+ text: {
335
+ tag: "lark_md",
336
+ content: ["**Changed files**", larkMarkdownList(presentation.changedFiles.slice(0, 8).map(larkPlain))].join("\n")
337
+ }
338
+ });
339
+ }
340
+ if (presentation.nextActions?.length) {
341
+ elements.push({
342
+ tag: "div",
343
+ text: {
344
+ tag: "lark_md",
345
+ content: ["**Next action**", larkMarkdownList(presentation.nextActions.map((action) => compactCardText(action, 240)))].join("\n")
346
+ }
347
+ });
348
+ }
349
+ const actionReceipt = larkFinalSummaryActionReceiptMarkdown(presentation);
350
+ if (actionReceipt) {
351
+ elements.push({ tag: "hr" });
352
+ elements.push({
353
+ tag: "div",
354
+ text: {
355
+ tag: "lark_md",
356
+ content: actionReceipt
357
+ }
358
+ });
359
+ }
360
+ if (presentation.auditRunId) {
361
+ elements.push({
362
+ tag: "note",
363
+ elements: [
364
+ {
365
+ tag: "plain_text",
366
+ content: `Audit: opentag status --run ${presentation.auditRunId}`
367
+ }
368
+ ]
369
+ });
370
+ }
371
+ return {
372
+ config: {
373
+ wide_screen_mode: true,
374
+ update_multi: true
375
+ },
376
+ header: {
377
+ template: larkHeaderTemplate(presentation.outcome),
378
+ title: {
379
+ tag: "plain_text",
380
+ content: `Finished: ${presentation.outcome}`
381
+ }
382
+ },
383
+ elements
384
+ };
385
+ }
386
+ function renderLarkActionReceiptPresentation(presentation) {
387
+ return [
388
+ presentation.title,
389
+ "",
390
+ larkActionReceiptMarkdown({ title: presentation.title, actions: presentation.actions }, { includeTitle: false }) ?? "No source-thread actions.",
391
+ ...presentation.auditRunId ? ["", `Audit: opentag status --run ${presentation.auditRunId}`] : []
392
+ ].join("\n");
393
+ }
394
+ function createLarkActionReceiptCard(presentation) {
395
+ const elements = [
396
+ {
397
+ tag: "div",
398
+ text: {
399
+ tag: "lark_md",
400
+ content: larkActionReceiptMarkdown({ title: presentation.title, actions: presentation.actions }, { includeTitle: false }) ?? "No source-thread actions."
401
+ }
402
+ }
403
+ ];
404
+ if (presentation.auditRunId) {
405
+ elements.push({
406
+ tag: "note",
407
+ elements: [
408
+ {
409
+ tag: "plain_text",
410
+ content: `Audit: opentag status --run ${presentation.auditRunId}`
411
+ }
412
+ ]
413
+ });
414
+ }
415
+ return {
416
+ config: {
417
+ wide_screen_mode: true
418
+ },
419
+ header: {
420
+ template: larkActionReceiptHeaderTemplate(presentation.actions),
421
+ title: {
422
+ tag: "plain_text",
423
+ content: presentation.title.replace(/:$/, "")
424
+ }
425
+ },
426
+ elements
427
+ };
428
+ }
429
+ function larkDoctorHeaderTemplate(checks) {
430
+ if (checks.some((check) => check.status === "fail")) return "red";
431
+ if (checks.some((check) => check.status === "warn" || check.status === "unknown")) return "yellow";
432
+ return "green";
433
+ }
434
+ function larkStatusHeaderTemplate(presentation) {
435
+ if (presentation.bindingState === "unbound") return "yellow";
436
+ if (presentation.activeRun) return "blue";
437
+ return "green";
438
+ }
439
+ function checkStatusLabel(status) {
440
+ return status.toUpperCase();
441
+ }
442
+ function createLarkDoctorSummaryCard(presentation) {
443
+ return {
444
+ config: {
445
+ wide_screen_mode: true
446
+ },
447
+ header: {
448
+ template: larkDoctorHeaderTemplate(presentation.checks),
449
+ title: {
450
+ tag: "plain_text",
451
+ content: presentation.title.replace(/:$/, "")
452
+ }
453
+ },
454
+ elements: presentation.checks.map((check) => ({
455
+ tag: "div",
456
+ text: {
457
+ tag: "lark_md",
458
+ content: `**${checkStatusLabel(check.status)} ${check.name}**${check.message ? `
459
+ ${compactCardText(check.message, 500)}` : ""}`
460
+ }
461
+ }))
462
+ };
463
+ }
464
+ function createLarkSourceThreadStatusCard(presentation) {
465
+ const elements = [
466
+ {
467
+ tag: "div",
468
+ text: {
469
+ tag: "lark_md",
470
+ content: [
471
+ presentation.sourceContainer ? `**Source container**
472
+ ${presentation.sourceContainer}` : void 0,
473
+ `**Project Target**
474
+ ${presentation.projectTarget ?? "not bound"}`,
475
+ `**Active run**
476
+ ${presentation.activeRun ? `${presentation.activeRun.id} (${presentation.activeRun.status})${presentation.activeRun.updatedAt ? `, updated ${presentation.activeRun.updatedAt}` : ""}` : "none"}`,
477
+ presentation.currentCommand ? `**Command**
478
+ ${compactCardText(presentation.currentCommand, 240)}` : void 0
479
+ ].filter((line) => Boolean(line)).join("\n\n")
480
+ }
481
+ }
482
+ ];
483
+ const queuedTotal = presentation.queuedFollowUpsTotal ?? presentation.queuedFollowUps.length;
484
+ const queuedIds = presentation.queuedFollowUps.map((followUp) => {
485
+ const status = followUp.status ? ` (${followUp.status})` : "";
486
+ const command = followUp.command ? `: ${compactCardText(followUp.command, 120)}` : "";
487
+ return `${followUp.id}${status}${command}`;
488
+ });
489
+ const queuedOverflow = Math.max(queuedTotal - queuedIds.length, 0);
490
+ const queuedSummary = queuedTotal === 0 ? "none" : `${queuedTotal}${queuedIds.length ? ` (${queuedIds.join(", ")}${queuedOverflow > 0 ? `, +${queuedOverflow} more` : ""})` : ""}`;
491
+ elements.push({
492
+ tag: "div",
493
+ text: {
494
+ tag: "lark_md",
495
+ content: ["**Queued follow-ups**", queuedSummary, "", "**Next action**", compactCardText(presentation.nextAction, 360)].join("\n")
496
+ }
497
+ });
498
+ if (presentation.stopHint || presentation.detailHint) {
499
+ elements.push({
500
+ tag: "note",
501
+ elements: [
502
+ {
503
+ tag: "plain_text",
504
+ content: [presentation.stopHint ? `Stop/timeout: ${presentation.stopHint}` : void 0, presentation.detailHint ? `Details: ${presentation.detailHint}` : void 0].filter((line) => Boolean(line)).join(" ")
505
+ }
506
+ ]
507
+ });
508
+ }
509
+ return {
510
+ config: {
511
+ wide_screen_mode: true
512
+ },
513
+ header: {
514
+ template: larkStatusHeaderTemplate(presentation),
515
+ title: {
516
+ tag: "plain_text",
517
+ content: presentation.title.replace(/:$/, "")
518
+ }
519
+ },
520
+ elements
521
+ };
522
+ }
523
+ function createLarkTextMessageContent(text) {
524
+ return JSON.stringify({ text });
525
+ }
526
+ function createLarkInteractiveMessageContent(card) {
527
+ return JSON.stringify(card);
528
+ }
529
+
530
+ // src/inbound.ts
148
531
  var BIND_USAGE = "Usage: /bind <owner>/<repo> \u2014 e.g. /bind amplifthq/opentag (or /bind github:amplifthq/opentag).";
532
+ var UNBIND_USAGE = "Usage: /unbind confirm \u2014 disconnects this chat from its current Project Target. This does not remove local checkout allowlists or repository bindings.";
149
533
  var UNBOUND_HINT = "This chat isn't connected to a Project Target yet. @-mention me with `/bind <owner>/<repo>` to connect it \u2014 e.g. /bind amplifthq/opentag.";
534
+ var HELP_TEXT = [
535
+ "OpenTag commands:",
536
+ "- /bind <owner>/<repo> or /bind <provider>:<owner>/<repo> connects this chat to a Project Target.",
537
+ "- /unbind confirm disconnects this chat from its current Project Target; it never deletes local checkout config.",
538
+ "- /status shows the current Project Target, active-run guidance, queued follow-ups, and the next safe action.",
539
+ "- /doctor shows a redacted readiness summary for this source container.",
540
+ "- /stop [run_id] requests cancellation for the active chat run or the specified run; this connector will not treat stop as a successful completion.",
541
+ "Project Targets never use absolute local paths. Keep local checkout paths in runner config and allowlists.",
542
+ "Group chats must @-mention the bot before commands or runs."
543
+ ].join("\n");
544
+ var STOP_UNAVAILABLE_TEXT = [
545
+ "Run cancellation from this Lark ingress is not configured.",
546
+ "OpenTag will not treat a stop request as a successful completion. Use `opentag status --run <run_id>` for audit detail, or `opentag service stop` if you need to stop the local background service."
547
+ ].join("\n");
548
+ var BINDING_AUTH_DENIED_TEXT = "Only an authorized Lark binding manager can change this chat's Project Target. Ask an admin to run the command or update local OpenTag channel bindings.";
150
549
  function bindingFromDefault(input) {
151
550
  return {
152
551
  tenantKey: input.tenantKey,
@@ -171,6 +570,108 @@ function extractText(content) {
171
570
  function mentionsBot(mentions, botOpenId) {
172
571
  return (mentions ?? []).some((mention) => mention.id?.open_id === botOpenId);
173
572
  }
573
+ function formatProjectTarget(binding) {
574
+ return `${binding.repoProvider}:${binding.owner}/${binding.repo}`;
575
+ }
576
+ function normalizeSelfServiceReply(reply) {
577
+ return typeof reply === "string" ? { text: reply } : reply;
578
+ }
579
+ function parseSelfServiceCommand(command) {
580
+ const trimmed = command.trim();
581
+ if (/^\/help(\s|$)/.test(trimmed)) return "help";
582
+ if (/^\/status(\s|$)/.test(trimmed)) return "status";
583
+ if (/^\/doctor(\s|$)/.test(trimmed)) return "doctor";
584
+ return null;
585
+ }
586
+ function statusPresentation(input) {
587
+ if (!input.binding) {
588
+ return createSourceThreadStatusPresentation({
589
+ title: "OpenTag status:",
590
+ sourceContainer: `lark:${input.tenantKey}/${input.chatId}`,
591
+ bindingState: "unbound",
592
+ nextAction: UNBOUND_HINT,
593
+ detailHint: "active run and queued follow-up status are unavailable until this chat is bound."
594
+ });
595
+ }
596
+ return createSourceThreadStatusPresentation({
597
+ title: "OpenTag status:",
598
+ sourceContainer: `lark:${input.tenantKey}/${input.chatId}`,
599
+ projectTarget: formatProjectTarget(input.binding),
600
+ bindingState: "bound",
601
+ nextAction: "send a follow-up in this thread, or check `opentag status --run <run_id>` locally for audit detail.",
602
+ stopHint: "cancellation is explicit and is not reported as successful completion; timeout policy is surfaced in status/audit.",
603
+ detailHint: "at most one run is active per Project Target + source thread; new same-thread requests queue behind it."
604
+ });
605
+ }
606
+ function statusReply(input) {
607
+ const presentation = statusPresentation(input);
608
+ return {
609
+ text: renderOpenTagPresentationPlainText(presentation),
610
+ card: createLarkSourceThreadStatusCard(presentation)
611
+ };
612
+ }
613
+ function doctorPresentation(input) {
614
+ return createDoctorSummaryPresentation({
615
+ title: "OpenTag doctor (redacted):",
616
+ checks: [
617
+ { status: "ok", name: "Source container", message: `lark:${input.tenantKey}/${input.chatId}` },
618
+ {
619
+ status: input.binding ? "ok" : "warn",
620
+ name: "Project Target",
621
+ message: input.binding ? formatProjectTarget(input.binding) : "not bound"
622
+ },
623
+ { status: "ok", name: "Secrets", message: "redacted. Use env/file/keychain SecretRef entries in local config instead of sharing secrets in chat." },
624
+ {
625
+ status: "warn",
626
+ name: "Runtime readiness",
627
+ message: "check `opentag service status` locally; launchd running is not the same as connector ready."
628
+ },
629
+ { status: "ok", name: "Source-thread output", message: "concise final replies by default; detailed process stays in audit/status." }
630
+ ]
631
+ });
632
+ }
633
+ function doctorReply(input) {
634
+ const presentation = doctorPresentation(input);
635
+ return {
636
+ text: renderOpenTagPresentationPlainText(presentation),
637
+ card: createLarkDoctorSummaryCard(presentation)
638
+ };
639
+ }
640
+ function formatFollowUpQueuedText(input) {
641
+ return [
642
+ "Queued as a follow-up.",
643
+ input.activeRunId ? `- Active run: ${input.activeRunId}` : "- Active run: currently in this source thread.",
644
+ `- Follow-up request: ${input.followUpRequestId}`,
645
+ input.reason ? `- Reason: ${input.reason}` : "- Reason: another run is already active for this thread.",
646
+ "- Next action: wait for the active run final reply, or inspect locally with `opentag status --run <run_id>`.",
647
+ "- Stop/timeout: cancellation is explicit and will not be treated as successful completion; timeout details are recorded in audit/status."
648
+ ].join("\n");
649
+ }
650
+ function formatRunReceivedText(runId) {
651
+ return [
652
+ `Received. Run: ${runId}.`,
653
+ "- Use `/status` in this chat for active-run and queue state.",
654
+ `- Use \`opentag status --run ${runId}\` locally for audit detail.`
655
+ ].join("\n");
656
+ }
657
+ function parseStopCommand(command) {
658
+ const match = command.trim().match(/^\/stop(?:\s+(\S+))?\s*$/);
659
+ if (!match) return null;
660
+ return match[1] ? { runId: match[1] } : {};
661
+ }
662
+ function formatStopResultText(result) {
663
+ if (result.outcome === "cancelled") {
664
+ return [
665
+ `Cancellation requested for run ${result.runId}.`,
666
+ "- OpenTag will not treat this stop request as a successful completion.",
667
+ "- The local executor may need a moment to observe the cancellation; further nonessential completion writes are suppressed."
668
+ ].join("\n");
669
+ }
670
+ if (result.outcome === "already_terminal") {
671
+ return `Run ${result.runId} is already finished. OpenTag will not change its final result.`;
672
+ }
673
+ return result.runId ? `Run ${result.runId} was not found or is no longer cancelable.` : "No active run was found for this chat and Project Target.";
674
+ }
174
675
  function parseBindCommand(command) {
175
676
  if (!/^\/bind(\s|$)/.test(command)) return null;
176
677
  const match = command.match(/^\/bind\s+(\S+)\s*$/);
@@ -182,6 +683,14 @@ function parseBindCommand(command) {
182
683
  return { ok: false };
183
684
  }
184
685
  }
686
+ function parseUnbindCommand(command) {
687
+ if (!/^\/unbind(\s|$)/.test(command)) return null;
688
+ return /^\/unbind\s+confirm\s*$/.test(command) ? { ok: true } : { ok: false };
689
+ }
690
+ async function canManageLarkBinding(config, context) {
691
+ if (config.canManageBinding) return config.canManageBinding(context);
692
+ return context.chatType === "p2p";
693
+ }
185
694
  function createLarkMessageHandler(config) {
186
695
  return async function handleLarkMessage(data) {
187
696
  const message = data.message;
@@ -196,7 +705,8 @@ function createLarkMessageHandler(config) {
196
705
  if (!tenantKey || !chatId || !messageId || !eventId || !senderOpenId) {
197
706
  return { status: "ignored_invalid_payload" };
198
707
  }
199
- const isDirect = message.chat_type === "p2p";
708
+ const chatType = message.chat_type ?? "group";
709
+ const isDirect = chatType === "p2p";
200
710
  if (!isDirect) {
201
711
  if (!config.botOpenId) {
202
712
  return { status: "ignored_group_requires_bot_open_id", tenantKey, chatId };
@@ -215,6 +725,21 @@ function createLarkMessageHandler(config) {
215
725
  await config.reply?.({ messageId, text: BIND_USAGE });
216
726
  return { status: "ignored_bind_usage", tenantKey, chatId };
217
727
  }
728
+ const authorized = await canManageLarkBinding(config, {
729
+ action: "bind",
730
+ tenantKey,
731
+ chatId,
732
+ chatType,
733
+ senderOpenId,
734
+ ...data.sender?.sender_id?.user_id ? { senderUserId: data.sender.sender_id.user_id } : {},
735
+ ...data.sender?.sender_id?.union_id ? { senderUnionId: data.sender.sender_id.union_id } : {},
736
+ messageId,
737
+ eventId
738
+ });
739
+ if (!authorized) {
740
+ await config.reply?.({ messageId, text: BINDING_AUTH_DENIED_TEXT });
741
+ return { status: "ignored_bind_unauthorized", tenantKey, chatId };
742
+ }
218
743
  await config.bindChannel({
219
744
  tenantKey,
220
745
  chatId,
@@ -228,9 +753,72 @@ function createLarkMessageHandler(config) {
228
753
  });
229
754
  return { status: "bound", tenantKey, chatId };
230
755
  }
756
+ const unbindRequest = parseUnbindCommand(command);
757
+ if (unbindRequest && !config.unbindChannel) {
758
+ return { status: "ignored_unbind_unavailable", tenantKey, chatId };
759
+ }
760
+ if (unbindRequest && config.unbindChannel) {
761
+ if (!unbindRequest.ok) {
762
+ await config.reply?.({ messageId, text: UNBIND_USAGE });
763
+ return { status: "ignored_unbind_usage", tenantKey, chatId };
764
+ }
765
+ const authorized = await canManageLarkBinding(config, {
766
+ action: "unbind",
767
+ tenantKey,
768
+ chatId,
769
+ chatType,
770
+ senderOpenId,
771
+ ...data.sender?.sender_id?.user_id ? { senderUserId: data.sender.sender_id.user_id } : {},
772
+ ...data.sender?.sender_id?.union_id ? { senderUnionId: data.sender.sender_id.union_id } : {},
773
+ messageId,
774
+ eventId
775
+ });
776
+ if (!authorized) {
777
+ await config.reply?.({ messageId, text: BINDING_AUTH_DENIED_TEXT });
778
+ return { status: "ignored_unbind_unauthorized", tenantKey, chatId };
779
+ }
780
+ const binding2 = await config.resolveChannelBinding({ tenantKey, chatId });
781
+ if (!binding2) {
782
+ await config.reply?.({ messageId, text: UNBOUND_HINT });
783
+ return { status: "ignored_unbound_chat", tenantKey, chatId };
784
+ }
785
+ await config.unbindChannel({ tenantKey, chatId });
786
+ await config.reply?.({
787
+ messageId,
788
+ text: `Disconnected this chat from Project Target ${formatProjectTarget(binding2)}. @-mention me with \`/bind <owner>/<repo>\` to connect a new target.`
789
+ });
790
+ return { status: "unbound", tenantKey, chatId };
791
+ }
231
792
  if (command.trim().length === 0) {
232
793
  return { status: "ignored_empty_command", tenantKey, chatId };
233
794
  }
795
+ const stopRequest = parseStopCommand(command);
796
+ if (stopRequest) {
797
+ if (!config.stopRun) {
798
+ await config.reply?.({ messageId, text: STOP_UNAVAILABLE_TEXT });
799
+ return { status: "self_service_stop_unavailable", tenantKey, chatId };
800
+ }
801
+ const result2 = await config.stopRun({
802
+ tenantKey,
803
+ chatId,
804
+ ...stopRequest.runId ? { runId: stopRequest.runId } : {},
805
+ requestedBy: `lark:${senderOpenId}`
806
+ });
807
+ await config.reply?.({ messageId, text: formatStopResultText(result2) });
808
+ return { status: "self_service_stop", ...result2.runId ? { runId: result2.runId } : {}, tenantKey, chatId };
809
+ }
810
+ const selfServiceCommand = parseSelfServiceCommand(command);
811
+ if (selfServiceCommand === "help") {
812
+ await config.reply?.({ messageId, text: HELP_TEXT });
813
+ return { status: "self_service_help", tenantKey, chatId };
814
+ }
815
+ if (selfServiceCommand === "status" || selfServiceCommand === "doctor") {
816
+ const binding2 = await config.resolveChannelBinding({ tenantKey, chatId });
817
+ const context = { tenantKey, chatId, messageId, binding: binding2 };
818
+ const reply = selfServiceCommand === "status" ? normalizeSelfServiceReply(await (config.status?.(context) ?? Promise.resolve(statusReply({ tenantKey, chatId, binding: binding2 })))) : normalizeSelfServiceReply(await (config.doctor?.(context) ?? Promise.resolve(doctorReply({ tenantKey, chatId, binding: binding2 }))));
819
+ await config.reply?.({ messageId, text: reply.text, ...reply.card ? { card: reply.card } : {} });
820
+ return { status: selfServiceCommand === "status" ? "self_service_status" : "self_service_doctor", tenantKey, chatId };
821
+ }
234
822
  let binding = await config.resolveChannelBinding({ tenantKey, chatId });
235
823
  if (binding && config.defaultRepoBinding && config.bindChannel) {
236
824
  if (shouldMigrateLegacyLocalBinding({ existing: binding, defaultBinding: config.defaultRepoBinding })) {
@@ -281,9 +869,20 @@ function createLarkMessageHandler(config) {
281
869
  }
282
870
  const result = await config.createRun(event);
283
871
  if (result.outcome === "run_created") {
872
+ if (!result.idempotentReplay) {
873
+ await config.reply?.({ messageId, text: formatRunReceivedText(result.run.id) });
874
+ }
284
875
  return { status: "created", runId: result.run.id, tenantKey, chatId };
285
876
  }
286
877
  if (result.outcome === "follow_up_queued") {
878
+ await config.reply?.({
879
+ messageId,
880
+ text: formatFollowUpQueuedText({
881
+ followUpRequestId: result.followUpRequest.id,
882
+ ...result.decision.activeRunId ? { activeRunId: result.decision.activeRunId } : {},
883
+ reason: result.decision.reason
884
+ })
885
+ });
287
886
  return {
288
887
  status: "follow_up_queued",
289
888
  followUpRequestId: result.followUpRequest.id,
@@ -306,39 +905,15 @@ function createLarkMessageHandler(config) {
306
905
  import { randomUUID } from "crypto";
307
906
  import * as lark2 from "@larksuiteoapi/node-sdk";
308
907
  import { createOpenTagClient } from "@opentag/client";
309
- import { parseProjectTargetRef as parseProjectTargetRef2 } from "@opentag/core";
908
+ import {
909
+ createDoctorSummaryPresentation as createDoctorSummaryPresentation2,
910
+ createSourceThreadStatusPresentation as createSourceThreadStatusPresentation2,
911
+ parseProjectTargetRef as parseProjectTargetRef2,
912
+ renderOpenTagPresentationPlainText as renderOpenTagPresentationPlainText2
913
+ } from "@opentag/core";
310
914
 
311
915
  // src/outbound.ts
312
916
  import * as lark from "@larksuiteoapi/node-sdk";
313
-
314
- // src/render.ts
315
- function nextActionSummary(result) {
316
- if (!result.nextAction) return void 0;
317
- if (typeof result.nextAction === "string") return result.nextAction;
318
- return result.nextAction.summary;
319
- }
320
- function renderLarkAcknowledgement(runId) {
321
- return `I picked this up: ${runId}`;
322
- }
323
- function renderLarkFinalResult(result) {
324
- const lines = [`Finished with ${result.conclusion}.`, "", result.summary];
325
- if (result.verification?.length) {
326
- lines.push("", "Verification");
327
- for (const check of result.verification) {
328
- lines.push(`- ${check.command}: ${check.outcome}`);
329
- }
330
- }
331
- const nextAction = nextActionSummary(result);
332
- if (nextAction) {
333
- lines.push("", `Next action: ${nextAction}`);
334
- }
335
- return lines.join("\n");
336
- }
337
- function createLarkTextMessageContent(text) {
338
- return JSON.stringify({ text });
339
- }
340
-
341
- // src/outbound.ts
342
917
  function createLarkReplyClient(input) {
343
918
  return new lark.Client({
344
919
  appId: input.appId,
@@ -346,10 +921,55 @@ function createLarkReplyClient(input) {
346
921
  domain: input.domain === "feishu" ? lark.Domain.Feishu : lark.Domain.Lark
347
922
  });
348
923
  }
924
+ function larkMessageApi(client, method) {
925
+ const legacyApi = client.im.message;
926
+ if (legacyApi?.[method]) return legacyApi;
927
+ const v1Api = client.im.v1?.message;
928
+ if (v1Api?.[method]) return v1Api;
929
+ throw new Error(`Lark client does not support message.${method}.`);
930
+ }
931
+ function larkReplyMessageId(response) {
932
+ if (!response || typeof response !== "object") return void 0;
933
+ const data = "data" in response ? response.data : void 0;
934
+ if (data && typeof data === "object" && "message_id" in data) {
935
+ const value = data.message_id;
936
+ if (typeof value === "string" && value.length > 0) return value;
937
+ }
938
+ if ("message_id" in response) {
939
+ const value = response.message_id;
940
+ if (typeof value === "string" && value.length > 0) return value;
941
+ }
942
+ return void 0;
943
+ }
349
944
  async function replyLarkMessage(client, input) {
350
- await client.im.message.reply({
945
+ const reply = larkMessageApi(client, "reply").reply;
946
+ if (!reply) throw new Error("Lark client does not support message.reply.");
947
+ const response = await reply({
948
+ path: { message_id: input.messageId },
949
+ data: input.card ? { content: createLarkInteractiveMessageContent(input.card), msg_type: "interactive", reply_in_thread: true } : { content: createLarkTextMessageContent(input.text), msg_type: "text", reply_in_thread: true }
950
+ });
951
+ const messageId = larkReplyMessageId(response);
952
+ return messageId ? { messageId } : {};
953
+ }
954
+ async function patchLarkMessageCard(client, input) {
955
+ const patch = larkMessageApi(client, "patch").patch;
956
+ if (!patch) throw new Error("Lark client does not support message.patch.");
957
+ await patch({
351
958
  path: { message_id: input.messageId },
352
- data: { content: createLarkTextMessageContent(input.text), msg_type: "text", reply_in_thread: true }
959
+ data: {
960
+ content: createLarkInteractiveMessageContent(input.card)
961
+ }
962
+ });
963
+ }
964
+ async function updateLarkTextMessage(client, input) {
965
+ const update = larkMessageApi(client, "update").update;
966
+ if (!update) throw new Error("Lark client does not support message.update.");
967
+ await update({
968
+ path: { message_id: input.messageId },
969
+ data: {
970
+ msg_type: "text",
971
+ content: createLarkTextMessageContent(input.text)
972
+ }
353
973
  });
354
974
  }
355
975
 
@@ -375,6 +995,18 @@ function domainFromEnv(value) {
375
995
  }
376
996
  return domain;
377
997
  }
998
+ function positiveIntegerFromEnv(name, value) {
999
+ if (!value) return void 0;
1000
+ const parsed = Number(value);
1001
+ if (!Number.isInteger(parsed) || parsed <= 0) {
1002
+ throw new Error(`${name} must be a positive integer`);
1003
+ }
1004
+ return parsed;
1005
+ }
1006
+ function csvList(value) {
1007
+ const items = value?.split(",").map((item) => item.trim()).filter(Boolean);
1008
+ return items?.length ? items : void 0;
1009
+ }
378
1010
  function larkIngressConfigFromEnv(env) {
379
1011
  const appId = env.LARK_APP_ID;
380
1012
  const appSecret = env.LARK_APP_SECRET;
@@ -386,6 +1018,10 @@ function larkIngressConfigFromEnv(env) {
386
1018
  throw new Error("OPENTAG_DISPATCHER_URL is required");
387
1019
  }
388
1020
  const defaultRepoBinding = defaultRepoBindingFromEnv(env.OPENTAG_LARK_DEFAULT_REPO);
1021
+ const runTimeoutMs = positiveIntegerFromEnv("OPENTAG_RUN_TIMEOUT_MS", env.OPENTAG_RUN_TIMEOUT_MS);
1022
+ const bindingAdminOpenIds = csvList(env.OPENTAG_LARK_BINDING_ADMIN_OPEN_IDS);
1023
+ const bindingAdminUserIds = csvList(env.OPENTAG_LARK_BINDING_ADMIN_USER_IDS);
1024
+ const bindingAdminUnionIds = csvList(env.OPENTAG_LARK_BINDING_ADMIN_UNION_IDS);
389
1025
  return {
390
1026
  appId,
391
1027
  appSecret,
@@ -394,6 +1030,10 @@ function larkIngressConfigFromEnv(env) {
394
1030
  agentId: env.OPENTAG_LARK_AGENT_ID ?? DEFAULT_AGENT_ID,
395
1031
  ...env.OPENTAG_DISPATCHER_TOKEN ? { dispatcherToken: env.OPENTAG_DISPATCHER_TOKEN } : {},
396
1032
  ...env.LARK_BOT_OPEN_ID ? { botOpenId: env.LARK_BOT_OPEN_ID } : {},
1033
+ ...bindingAdminOpenIds ? { bindingAdminOpenIds } : {},
1034
+ ...bindingAdminUserIds ? { bindingAdminUserIds } : {},
1035
+ ...bindingAdminUnionIds ? { bindingAdminUnionIds } : {},
1036
+ ...runTimeoutMs ? { runTimeoutMs } : {},
397
1037
  ...defaultRepoBinding ? { defaultRepoBinding } : {}
398
1038
  };
399
1039
  }
@@ -410,7 +1050,9 @@ function createDefaultEventDispatcher(handler) {
410
1050
  });
411
1051
  }
412
1052
  function logIgnored(outcome) {
413
- if (outcome.status === "created" || outcome.status === "bound") return;
1053
+ if (outcome.status === "created" || outcome.status === "bound" || outcome.status === "unbound" || outcome.status === "self_service_help" || outcome.status === "self_service_status" || outcome.status === "self_service_doctor" || outcome.status === "self_service_stop" || outcome.status === "self_service_stop_unavailable") {
1054
+ return;
1055
+ }
414
1056
  if (outcome.status === "follow_up_queued") {
415
1057
  console.log(
416
1058
  `[lark] queued follow-up${outcome.followUpRequestId ? ` follow_up_request_id=${outcome.followUpRequestId}` : ""}${outcome.runId ? ` active_run_id=${outcome.runId}` : ""}`
@@ -429,6 +1071,109 @@ function logIgnored(outcome) {
429
1071
  }
430
1072
  console.log(`[lark] ignored event: ${outcome.status}${outcome.chatId ? ` chat_id=${outcome.chatId}` : ""}`);
431
1073
  }
1074
+ function formatProjectTarget2(input) {
1075
+ return `${input.repoProvider}:${input.owner}/${input.repo}`;
1076
+ }
1077
+ function queuedFollowUpsSummary(status) {
1078
+ if (status.queuedFollowUps.length === 0) return "none.";
1079
+ const visible = status.queuedFollowUps.slice(0, 3).map((followUp) => followUp.id);
1080
+ const suffix = status.queuedFollowUps.length > visible.length ? `, +${status.queuedFollowUps.length - visible.length} more` : "";
1081
+ return `${status.queuedFollowUps.length} (${visible.join(", ")}${suffix}).`;
1082
+ }
1083
+ function formatDurationMs(ms) {
1084
+ if (ms % 6e4 === 0) return `${ms / 6e4} minute(s)`;
1085
+ if (ms % 1e3 === 0) return `${ms / 1e3} second(s)`;
1086
+ return `${ms}ms`;
1087
+ }
1088
+ function runTimeoutPolicy(input) {
1089
+ const hardTimeoutMs = input.status?.runTimeoutPolicy?.hardTimeoutMs ?? input.runTimeoutMs;
1090
+ return hardTimeoutMs ? `hard timeout after ${formatDurationMs(hardTimeoutMs)}` : "disabled";
1091
+ }
1092
+ function larkRuntimeStatusPresentation(status, input = {}) {
1093
+ return createSourceThreadStatusPresentation2({
1094
+ title: "OpenTag status:",
1095
+ sourceContainer: `${status.binding.provider}:${status.binding.accountId}/${status.binding.conversationId}`,
1096
+ projectTarget: formatProjectTarget2(status.binding),
1097
+ bindingState: "bound",
1098
+ ...status.activeRun ? {
1099
+ activeRun: {
1100
+ id: status.activeRun.id,
1101
+ status: status.activeRun.status,
1102
+ updatedAt: status.activeRun.updatedAt
1103
+ }
1104
+ } : {},
1105
+ ...status.activeEvent?.command.rawText ? { currentCommand: status.activeEvent.command.rawText } : {},
1106
+ queuedFollowUps: status.queuedFollowUps.slice(0, 3).map((followUp) => ({
1107
+ id: followUp.id,
1108
+ status: followUp.status,
1109
+ command: followUp.event.command.rawText
1110
+ })),
1111
+ queuedFollowUpsTotal: status.queuedFollowUps.length,
1112
+ nextAction: status.activeRun ? "wait for the final reply, send a follow-up to queue more context, or use `/stop` to request cancellation." : "@-mention me with a task to start a run.",
1113
+ stopHint: `cancellation is explicit and is not reported as successful completion; timeout policy: ${runTimeoutPolicy({ ...input, status })}.`,
1114
+ detailHint: "use `opentag status --run <run_id>` locally for audit events and executor detail."
1115
+ });
1116
+ }
1117
+ function larkRuntimeStatusReply(status, input = {}) {
1118
+ const presentation = larkRuntimeStatusPresentation(status, input);
1119
+ return {
1120
+ text: renderOpenTagPresentationPlainText2(presentation),
1121
+ card: createLarkSourceThreadStatusCard(presentation)
1122
+ };
1123
+ }
1124
+ function larkRuntimeDoctorPresentation(input) {
1125
+ return createDoctorSummaryPresentation2({
1126
+ title: "OpenTag doctor (redacted):",
1127
+ checks: [
1128
+ { status: "ok", name: "Source container", message: `lark:${input.tenantKey}/${input.chatId}` },
1129
+ { status: "ok", name: "Project Target", message: formatProjectTarget2(input.status.binding) },
1130
+ { status: "ok", name: "Dispatcher", message: "reachable for this source container." },
1131
+ {
1132
+ status: "ok",
1133
+ name: "Active run",
1134
+ message: input.status.activeRun ? `${input.status.activeRun.id} (${input.status.activeRun.status}), updated ${input.status.activeRun.updatedAt}.` : "none."
1135
+ },
1136
+ { status: "ok", name: "Queued follow-ups", message: queuedFollowUpsSummary(input.status) },
1137
+ { status: "ok", name: "Timeout policy", message: runTimeoutPolicy({ ...input, status: input.status }) },
1138
+ {
1139
+ status: "ok",
1140
+ name: "Runtime readiness",
1141
+ message: "source-container status is reachable; run `opentag service status` locally to confirm launchd, connector, executor, and heartbeat health."
1142
+ },
1143
+ { status: "ok", name: "Secrets", message: "redacted. Use env/file/keychain SecretRef config and never paste app secrets into this chat." },
1144
+ { status: "ok", name: "Source-thread output", message: "concise final replies by default; detailed process belongs in audit/status." }
1145
+ ]
1146
+ });
1147
+ }
1148
+ function larkRuntimeDoctorReply(input) {
1149
+ const presentation = larkRuntimeDoctorPresentation(input);
1150
+ return {
1151
+ text: renderOpenTagPresentationPlainText2(presentation),
1152
+ card: createLarkDoctorSummaryCard(presentation)
1153
+ };
1154
+ }
1155
+ function formatStatusUnavailable(input) {
1156
+ const message = input.error instanceof Error ? input.error.message : String(input.error);
1157
+ return [
1158
+ "OpenTag status:",
1159
+ input.binding ? `- Project Target: ${formatProjectTarget2({ repoProvider: input.binding.repoProvider ?? "github", owner: input.binding.owner, repo: input.binding.repo })}` : "- Project Target: not bound.",
1160
+ "- Runtime status: unavailable from dispatcher.",
1161
+ `- Reason: ${message}`,
1162
+ "- Next action: check `opentag service status` and `opentag status` locally."
1163
+ ].join("\n");
1164
+ }
1165
+ function formatDoctorUnavailable(input) {
1166
+ const message = input.error instanceof Error ? input.error.message : String(input.error);
1167
+ return [
1168
+ "OpenTag doctor (redacted):",
1169
+ `- Source container: lark:${input.tenantKey}/${input.chatId}`,
1170
+ input.binding ? `- Project Target: ${formatProjectTarget2({ repoProvider: input.binding.repoProvider ?? "github", owner: input.binding.owner, repo: input.binding.repo })}` : "- Project Target: not bound.",
1171
+ "- Dispatcher: source-container status unavailable.",
1172
+ `- Reason: ${message}`,
1173
+ "- Runtime readiness: run `opentag service status` and `opentag status --channel lark:<tenant>/<chat>` locally.",
1174
+ "- Secrets: redacted; do not share local config or app secrets in this chat."
1175
+ ].join("\n");
1176
+ }
432
1177
  function startLarkIngress(config, dependencies = {}) {
433
1178
  const dispatcherClient = createOpenTagClient({
434
1179
  dispatcherUrl: config.dispatcherUrl,
@@ -474,6 +1219,103 @@ function startLarkIngress(config, dependencies = {}) {
474
1219
  repo: input.repo
475
1220
  });
476
1221
  },
1222
+ async unbindChannel(input) {
1223
+ await dispatcherClient.unbindChannel({
1224
+ provider: "lark",
1225
+ accountId: input.tenantKey,
1226
+ conversationId: input.chatId
1227
+ });
1228
+ },
1229
+ canManageBinding(input) {
1230
+ if (input.chatType === "p2p") return true;
1231
+ return Boolean(
1232
+ config.bindingAdminOpenIds?.includes(input.senderOpenId) || input.senderUserId && config.bindingAdminUserIds?.includes(input.senderUserId) || input.senderUnionId && config.bindingAdminUnionIds?.includes(input.senderUnionId)
1233
+ );
1234
+ },
1235
+ async stopRun(input) {
1236
+ try {
1237
+ const result = input.runId ? await dispatcherClient.cancelRun({
1238
+ runId: input.runId,
1239
+ reason: "Stop requested from Lark.",
1240
+ requestedBy: input.requestedBy
1241
+ }) : await dispatcherClient.cancelActiveChannelRun({
1242
+ provider: "lark",
1243
+ accountId: input.tenantKey,
1244
+ conversationId: input.chatId,
1245
+ reason: "Stop requested from Lark.",
1246
+ requestedBy: input.requestedBy
1247
+ });
1248
+ return { outcome: "cancelled", runId: result.run.id };
1249
+ } catch (error) {
1250
+ const message = error instanceof Error ? error.message : String(error);
1251
+ if (message.includes("run_already_terminal")) {
1252
+ return { outcome: "already_terminal", runId: input.runId ?? "active run" };
1253
+ }
1254
+ if (message.includes("run_not_found") || message.includes("active_run_not_found") || message.includes("channel_binding_not_found")) {
1255
+ return input.runId ? { outcome: "not_found", runId: input.runId } : { outcome: "not_found" };
1256
+ }
1257
+ throw error;
1258
+ }
1259
+ },
1260
+ async status(input) {
1261
+ if (!input.binding) {
1262
+ const presentation = createSourceThreadStatusPresentation2({
1263
+ title: "OpenTag status:",
1264
+ sourceContainer: `lark:${input.tenantKey}/${input.chatId}`,
1265
+ bindingState: "unbound",
1266
+ nextAction: "@-mention me with `/bind <owner>/<repo>` to connect a Project Target.",
1267
+ detailHint: "active run and queued follow-up status are unavailable until this chat is bound."
1268
+ });
1269
+ return {
1270
+ text: renderOpenTagPresentationPlainText2(presentation),
1271
+ card: createLarkSourceThreadStatusCard(presentation)
1272
+ };
1273
+ }
1274
+ try {
1275
+ return larkRuntimeStatusReply(
1276
+ await dispatcherClient.getChannelRuntimeStatus({
1277
+ provider: "lark",
1278
+ accountId: input.tenantKey,
1279
+ conversationId: input.chatId
1280
+ }),
1281
+ { ...config.runTimeoutMs ? { runTimeoutMs: config.runTimeoutMs } : {} }
1282
+ );
1283
+ } catch (error) {
1284
+ return formatStatusUnavailable({ binding: input.binding, error });
1285
+ }
1286
+ },
1287
+ async doctor(input) {
1288
+ if (!input.binding) {
1289
+ const presentation = createDoctorSummaryPresentation2({
1290
+ title: "OpenTag doctor (redacted):",
1291
+ checks: [
1292
+ { status: "ok", name: "Source container", message: `lark:${input.tenantKey}/${input.chatId}` },
1293
+ { status: "warn", name: "Project Target", message: "not bound." },
1294
+ { status: "warn", name: "Dispatcher", message: "binding not found for this source container." },
1295
+ { status: "ok", name: "Next action", message: "@-mention me with `/bind <owner>/<repo>` to connect a Project Target." },
1296
+ { status: "ok", name: "Secrets", message: "redacted; do not share local config or app secrets in this chat." }
1297
+ ]
1298
+ });
1299
+ return {
1300
+ text: renderOpenTagPresentationPlainText2(presentation),
1301
+ card: createLarkDoctorSummaryCard(presentation)
1302
+ };
1303
+ }
1304
+ try {
1305
+ return larkRuntimeDoctorReply({
1306
+ tenantKey: input.tenantKey,
1307
+ chatId: input.chatId,
1308
+ status: await dispatcherClient.getChannelRuntimeStatus({
1309
+ provider: "lark",
1310
+ accountId: input.tenantKey,
1311
+ conversationId: input.chatId
1312
+ }),
1313
+ ...config.runTimeoutMs ? { runTimeoutMs: config.runTimeoutMs } : {}
1314
+ });
1315
+ } catch (error) {
1316
+ return formatDoctorUnavailable({ tenantKey: input.tenantKey, chatId: input.chatId, binding: input.binding, error });
1317
+ }
1318
+ },
477
1319
  async reply(input) {
478
1320
  await reply(input);
479
1321
  },
@@ -512,10 +1354,10 @@ function accountDomainFor(domain) {
512
1354
  function sdkDomainFor(domain) {
513
1355
  return domain === "feishu" ? lark3.Domain.Feishu : lark3.Domain.Lark;
514
1356
  }
515
- function registrationDomainFromUserInfo(requestedDomain, userInfo) {
1357
+ function registrationDomainFromUserInfo(userInfo) {
516
1358
  if (userInfo?.tenant_brand === "lark") return "lark";
517
1359
  if (userInfo?.tenant_brand === "feishu") return "feishu";
518
- return requestedDomain;
1360
+ return "feishu";
519
1361
  }
520
1362
  function createDefaultBotInfoClient(input) {
521
1363
  return new lark3.Client({
@@ -535,6 +1377,24 @@ function botInfoFromResponse(response) {
535
1377
  botName: bot.app_name || bot.name || "OpenTag"
536
1378
  };
537
1379
  }
1380
+ async function validateLarkCredentials(input, dependencies = {}) {
1381
+ const client = (dependencies.createBotInfoClient ?? createDefaultBotInfoClient)(input);
1382
+ let response;
1383
+ try {
1384
+ response = await client.request({
1385
+ url: "/open-apis/bot/v3/info",
1386
+ method: "GET"
1387
+ });
1388
+ } catch (error) {
1389
+ const reason = error instanceof Error ? error.message : String(error);
1390
+ throw new Error(`Lark credentials could not be verified: ${reason}`);
1391
+ }
1392
+ const botIdentity = botInfoFromResponse(response);
1393
+ if (!botIdentity) {
1394
+ throw new Error("Lark credentials could not be verified: bot/v3/info response did not include bot.open_id.");
1395
+ }
1396
+ return botIdentity;
1397
+ }
538
1398
  async function fetchBotIdentity(input, options) {
539
1399
  const client = options.createBotInfoClient(input);
540
1400
  let lastError;
@@ -564,11 +1424,12 @@ async function fetchBotIdentity(input, options) {
564
1424
  return {};
565
1425
  }
566
1426
  async function registerLarkPersonalAgent(input, dependencies = {}) {
567
- const requestedDomain = input.domain ?? "lark";
568
1427
  let detectedDomain;
569
1428
  const registerApp2 = dependencies.registerApp ?? lark3.registerApp;
570
1429
  const registrationOptions = {
571
- // The Personal Agent registration flow starts on Feishu and switches to Lark after scan when needed.
1430
+ // QR registration does not preselect a tenant. The SDK starts on the Feishu
1431
+ // bootstrap host and can switch polling to Lark when it detects an
1432
+ // international tenant; the created app's actual tenant is persisted below.
572
1433
  domain: accountDomainFor("feishu"),
573
1434
  larkDomain: accountDomainFor("lark"),
574
1435
  source: REGISTRATION_SOURCE,
@@ -599,7 +1460,7 @@ async function registerLarkPersonalAgent(input, dependencies = {}) {
599
1460
  ...registrationOptions,
600
1461
  ...input.signal ? { signal: input.signal } : {}
601
1462
  });
602
- const domain = detectedDomain ?? registrationDomainFromUserInfo(requestedDomain, registration.user_info);
1463
+ const domain = detectedDomain ?? registrationDomainFromUserInfo(registration.user_info);
603
1464
  const botIdentity = await fetchBotIdentity(
604
1465
  {
605
1466
  appId: registration.client_id,
@@ -623,18 +1484,30 @@ async function registerLarkPersonalAgent(input, dependencies = {}) {
623
1484
  }
624
1485
  export {
625
1486
  DEFAULT_AGENT_ID,
1487
+ createLarkActionReceiptCard,
1488
+ createLarkDoctorSummaryCard,
1489
+ createLarkFinalSummaryCard,
1490
+ createLarkInteractiveMessageContent,
626
1491
  createLarkMessageHandler,
627
1492
  createLarkReplyClient,
1493
+ createLarkRunStatusCard,
1494
+ createLarkSourceThreadStatusCard,
628
1495
  createLarkTextMessageContent,
629
1496
  encodeLarkThreadKey,
630
1497
  larkIngressConfigFromEnv,
631
1498
  normalizeLarkMessage,
632
1499
  parseLarkThreadKey,
1500
+ patchLarkMessageCard,
633
1501
  registerLarkPersonalAgent,
634
1502
  renderLarkAcknowledgement,
1503
+ renderLarkActionReceiptPresentation,
635
1504
  renderLarkFinalResult,
1505
+ renderLarkFinalSummaryPresentation,
1506
+ renderLarkRunStatusPresentation,
636
1507
  replyLarkMessage,
637
1508
  startLarkIngress,
638
- stripLarkMention
1509
+ stripLarkMention,
1510
+ updateLarkTextMessage,
1511
+ validateLarkCredentials
639
1512
  };
640
1513
  //# sourceMappingURL=index.js.map