eve-lark 0.3.1 → 0.4.0

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.d.ts CHANGED
@@ -64,6 +64,44 @@ interface LarkChannelOptions {
64
64
  * Defaults to `$PORT` or `2000` (matches `eve dev`).
65
65
  */
66
66
  port?: number | undefined;
67
+ /**
68
+ * Allowlist of sender open_ids for DM (p2p) messages. When set, DMs from
69
+ * senders not in this list are dropped before reaching the agent. Has no
70
+ * effect on group messages (use `groupAllowFrom` for those). Default:
71
+ * unset → all DMs allowed.
72
+ */
73
+ allowFrom?: readonly string[] | undefined;
74
+ /**
75
+ * Allowlist of chat_ids for group messages. When set, messages from
76
+ * chats not in this list are dropped. Default: unset → all groups allowed.
77
+ */
78
+ groupAllowFrom?: readonly string[] | undefined;
79
+ /**
80
+ * Per-group configuration. Matched by chat_id on inbound group messages.
81
+ * Currently only `systemPrompt` is read; it's injected as `context` in
82
+ * the `send()` call so the agent treats it as an additional user-role
83
+ * instruction at the start of the turn. DMs ignore this.
84
+ */
85
+ groupConfigs?: readonly LarkGroupConfig[] | undefined;
86
+ /**
87
+ * ASR provider for audio/media transcription. When set, audio/media
88
+ * messages are downloaded, transcribed, and the transcript is forwarded
89
+ * to the agent as text. When unset (default), audio/media messages are
90
+ * ack-and-skipped.
91
+ */
92
+ asrProvider?: LarkAsrProvider | undefined;
93
+ }
94
+ interface LarkGroupConfig {
95
+ chatId: string;
96
+ systemPrompt?: string | undefined;
97
+ }
98
+ /**
99
+ * Pluggable ASR (Automatic Speech Recognition) provider. When configured,
100
+ * inbound audio/media messages are downloaded, transcribed, and the transcript
101
+ * replaces the empty text — the agent receives it as a normal text message.
102
+ */
103
+ interface LarkAsrProvider {
104
+ transcribe(audioBytes: Buffer, mediaType: string): Promise<string>;
67
105
  }
68
106
  interface ResolvedLarkOptions {
69
107
  appId: string;
@@ -86,6 +124,10 @@ interface ResolvedLarkOptions {
86
124
  ackReaction: string | readonly string[] | false;
87
125
  mode: LarkTransportMode;
88
126
  port: number;
127
+ allowFrom: readonly string[] | undefined;
128
+ groupAllowFrom: readonly string[] | undefined;
129
+ groupConfigs: readonly LarkGroupConfig[] | undefined;
130
+ asrProvider: LarkAsrProvider | undefined;
89
131
  }
90
132
  type LarkSenderType = "user" | "app";
91
133
  type LarkChatType = "p2p" | "group";
@@ -197,9 +239,34 @@ type LarkCardElement = {
197
239
  }>;
198
240
  } | {
199
241
  tag: "action";
200
- actions: LarkCardButton[];
242
+ actions: LarkCardActionItem[];
201
243
  layout?: "bisected" | "trisection" | "flow";
202
244
  };
245
+ /** Union of action-row item shapes: buttons (yes/no confirm style) and
246
+ * select menus (dropdowns for longer option lists). */
247
+ type LarkCardActionItem = LarkCardButton | LarkCardSelectMenu;
248
+ interface LarkCardSelectMenu {
249
+ tag: "select_static";
250
+ placeholder?: {
251
+ tag: "plain_text";
252
+ content: string;
253
+ };
254
+ /** Initially-selected option id (string). */
255
+ initial_option?: string;
256
+ /** Selectable options. `value` carries the optionId we get back in
257
+ * `action.option` when the user picks one. */
258
+ options: Array<{
259
+ text: {
260
+ tag: "plain_text";
261
+ content: string;
262
+ };
263
+ value: string;
264
+ }>;
265
+ /** Same marker payload as a button so the dispatcher can recognise our
266
+ * own callbacks. `optionId` is NOT set here — it comes back via
267
+ * `action.option` instead. */
268
+ value?: Record<string, unknown>;
269
+ }
203
270
  interface LarkCardButton {
204
271
  tag: "button";
205
272
  text: {
@@ -207,6 +274,8 @@ interface LarkCardButton {
207
274
  content: string;
208
275
  };
209
276
  type?: "default" | "primary" | "danger";
277
+ /** Opens this URL when clicked (instead of triggering card.action.trigger). */
278
+ url?: string;
210
279
  /** Arbitrary JSON returned in the card.action.trigger callback's
211
280
  * `action.value`. eve-lark sets `{ __eveLarkAsk, requestId, optionId }`. */
212
281
  value?: Record<string, unknown>;
package/dist/index.js CHANGED
@@ -535,6 +535,57 @@ var BASE_CONFIG = {
535
535
  wide_screen_mode: true,
536
536
  update_multi: true
537
537
  };
538
+ function buildAuthCard(opts) {
539
+ const elements = [
540
+ { tag: "div", text: { tag: "lark_md", content: `Sign in to **${escapeMarkdown(opts.displayName)}** to continue.` } },
541
+ {
542
+ tag: "action",
543
+ actions: [
544
+ {
545
+ tag: "button",
546
+ text: { tag: "plain_text", content: `Sign in with ${opts.displayName}` },
547
+ type: "primary",
548
+ url: opts.url
549
+ }
550
+ ]
551
+ }
552
+ ];
553
+ if (opts.userCode) {
554
+ elements.push({
555
+ tag: "div",
556
+ text: { tag: "lark_md", content: `Verification code: \`${escapeMarkdown(opts.userCode)}\`` }
557
+ });
558
+ }
559
+ return { config: { ...BASE_CONFIG }, elements };
560
+ }
561
+ __name(buildAuthCard, "buildAuthCard");
562
+ function buildAuthCompletedCard(opts) {
563
+ const outcomeLabel = {
564
+ authorized: "\u2713",
565
+ declined: "\u2717",
566
+ failed: "\u26A0",
567
+ "timed-out": "\u23F1"
568
+ };
569
+ const glyph = outcomeLabel[opts.outcome] ?? "\u2022";
570
+ const outcomeText = {
571
+ authorized: "connected",
572
+ declined: "declined",
573
+ failed: "failed",
574
+ "timed-out": "timed out"
575
+ };
576
+ const label = outcomeText[opts.outcome] ?? opts.outcome;
577
+ const suffix = opts.reason ? ` \u2014 ${escapeMarkdown(opts.reason)}` : "";
578
+ return {
579
+ config: { ...BASE_CONFIG },
580
+ elements: [
581
+ {
582
+ tag: "div",
583
+ text: { tag: "lark_md", content: `**${escapeMarkdown(opts.displayName)}**: ${glyph} ${label}${suffix}` }
584
+ }
585
+ ]
586
+ };
587
+ }
588
+ __name(buildAuthCompletedCard, "buildAuthCompletedCard");
538
589
  function buildTextCard(text) {
539
590
  return {
540
591
  config: { ...BASE_CONFIG },
@@ -564,26 +615,56 @@ function buildErrorCard(message) {
564
615
  }
565
616
  __name(buildErrorCard, "buildErrorCard");
566
617
  var ASK_BUTTON_VALUE_MARKER = "__eveLarkAsk";
618
+ var ASK_OPTIONS_BUTTON_MAX = 3;
567
619
  function buildAskCard(request) {
568
620
  const elements = [
569
621
  { tag: "div", text: { tag: "lark_md", content: request.prompt } }
570
622
  ];
571
- if (request.options && request.options.length > 0) {
572
- const buttons = request.options.map((opt) => ({
573
- tag: "button",
574
- text: { tag: "plain_text", content: opt.label },
575
- type: opt.style ?? "default",
576
- value: {
577
- [ASK_BUTTON_VALUE_MARKER]: true,
578
- requestId: request.requestId,
579
- optionId: opt.id
580
- },
581
- ...opt.description ? { confirm: { title: { tag: "plain_text", content: opt.label }, text: { tag: "plain_text", content: opt.description } } } : {}
582
- }));
583
- elements.push({ tag: "action", actions: buttons });
623
+ const optionCount = request.options?.length ?? 0;
624
+ if (optionCount > 0) {
625
+ const useSelect = request.display === "select" || optionCount > ASK_OPTIONS_BUTTON_MAX;
626
+ if (useSelect) {
627
+ elements.push({
628
+ tag: "action",
629
+ actions: [
630
+ {
631
+ tag: "select_static",
632
+ placeholder: { tag: "plain_text", content: "Select an option\u2026" },
633
+ options: request.options.map((opt) => ({
634
+ text: { tag: "plain_text", content: opt.label },
635
+ value: opt.id
636
+ })),
637
+ // Marker carries requestId; optionId is returned via action.option.
638
+ value: {
639
+ [ASK_BUTTON_VALUE_MARKER]: true,
640
+ requestId: request.requestId,
641
+ __larkSelect: true
642
+ }
643
+ }
644
+ ]
645
+ });
646
+ } else {
647
+ const buttons = request.options.map((opt) => ({
648
+ tag: "button",
649
+ text: { tag: "plain_text", content: opt.label },
650
+ type: opt.style ?? "default",
651
+ value: {
652
+ [ASK_BUTTON_VALUE_MARKER]: true,
653
+ requestId: request.requestId,
654
+ optionId: opt.id
655
+ },
656
+ ...opt.description ? {
657
+ confirm: {
658
+ title: { tag: "plain_text", content: opt.label },
659
+ text: { tag: "plain_text", content: opt.description }
660
+ }
661
+ } : {}
662
+ }));
663
+ elements.push({ tag: "action", actions: buttons });
664
+ }
584
665
  }
585
666
  if (request.allowFreeform) {
586
- const hint = request.options && request.options.length > 0 ? "_\u2026or reply to this chat with your own answer_" : "_Reply to this chat with your answer_";
667
+ const hint = optionCount > 0 ? "_\u2026or reply to this chat with your own answer_" : "_Reply to this chat with your answer_";
587
668
  elements.push({ tag: "div", text: { tag: "lark_md", content: hint } });
588
669
  }
589
670
  return { config: { ...BASE_CONFIG }, elements };
@@ -879,7 +960,11 @@ function resolveOptions(options, env = defaultEnv()) {
879
960
  fetch: options.fetch ?? globalThis.fetch,
880
961
  ackReaction: options.ackReaction ?? DEFAULTS.ackReaction,
881
962
  mode,
882
- port: options.port ?? (process.env.PORT ? Number(process.env.PORT) : 2e3)
963
+ port: options.port ?? (process.env.PORT ? Number(process.env.PORT) : 2e3),
964
+ allowFrom: options.allowFrom,
965
+ groupAllowFrom: options.groupAllowFrom,
966
+ groupConfigs: options.groupConfigs,
967
+ asrProvider: options.asrProvider
883
968
  };
884
969
  }
885
970
  __name(resolveOptions, "resolveOptions");
@@ -1327,6 +1412,30 @@ function buildUserContent(text, files, options, messageId) {
1327
1412
  return parts;
1328
1413
  }
1329
1414
  __name(buildUserContent, "buildUserContent");
1415
+ async function runDiagnostics(client, opts, chatId) {
1416
+ const lines = ["**eve-lark diagnostics**", ""];
1417
+ lines.push(`appId: \`${opts.appId}\``);
1418
+ lines.push(`baseUrl: \`${opts.baseUrl}\``);
1419
+ lines.push(`mode: \`${opts.mode}\``);
1420
+ lines.push(`replyMode: \`${opts.replyMode}\``);
1421
+ lines.push(`encryptKey: ${opts.encryptKey ? "\u2713 set" : "\u2717 not set"}`);
1422
+ lines.push(`ackReaction: \`${opts.ackReaction === false ? "disabled" : opts.ackReaction}\``);
1423
+ lines.push("");
1424
+ lines.push("**Token fetch:**");
1425
+ try {
1426
+ const token = await client.getTenantAccessToken();
1427
+ lines.push(`\u2713 tenant_access_token: ${token.slice(0, 8)}\u2026`);
1428
+ } catch (e) {
1429
+ lines.push(`\u2717 failed: ${e instanceof Error ? e.message : String(e)}`);
1430
+ }
1431
+ const report = lines.join("\n");
1432
+ try {
1433
+ await client.sendPost({ chatId, content: report });
1434
+ } catch (e) {
1435
+ console.error("[eve-lark] diagnostic report delivery failed:", e);
1436
+ }
1437
+ }
1438
+ __name(runDiagnostics, "runDiagnostics");
1330
1439
  function formatErrorHint(data) {
1331
1440
  if (typeof data !== "object" || data === null) return "";
1332
1441
  const d = data;
@@ -1376,6 +1485,7 @@ function createLarkChannel(optionsInput) {
1376
1485
  const sessionMeta = /* @__PURE__ */ new Map();
1377
1486
  const pendingInputsByRequestId = /* @__PURE__ */ new Map();
1378
1487
  const pendingInputsByChatToken = /* @__PURE__ */ new Map();
1488
+ const authCards = /* @__PURE__ */ new Map();
1379
1489
  function getController(sessionId, meta) {
1380
1490
  let ctrl = controllers.get(sessionId);
1381
1491
  if (!ctrl) {
@@ -1605,9 +1715,55 @@ function createLarkChannel(optionsInput) {
1605
1715
  if (parsed.senderType === "app") {
1606
1716
  return ackOk();
1607
1717
  }
1718
+ if (parsed.chatType === "p2p" && options.allowFrom) {
1719
+ if (!options.allowFrom.includes(parsed.senderOpenId)) {
1720
+ console.log(
1721
+ `[eve-lark] dropping DM from non-allowlisted sender ${parsed.senderOpenId}`
1722
+ );
1723
+ return ackOk();
1724
+ }
1725
+ }
1726
+ if (parsed.chatType === "group" && options.groupAllowFrom) {
1727
+ if (!options.groupAllowFrom.includes(parsed.chatId)) {
1728
+ console.log(
1729
+ `[eve-lark] dropping group message from non-allowlisted chat ${parsed.chatId}`
1730
+ );
1731
+ return ackOk();
1732
+ }
1733
+ }
1734
+ if (options.asrProvider && parsed.text === "" && parsed.files.length === 0) {
1735
+ const rawEvent = body.event;
1736
+ const msgType = rawEvent.message?.message_type;
1737
+ if (msgType === "audio" || msgType === "media") {
1738
+ try {
1739
+ const content = JSON.parse(rawEvent.message.content);
1740
+ if (content.file_key) {
1741
+ const bytes = await client.downloadResource({
1742
+ messageId: parsed.messageId,
1743
+ fileKey: content.file_key,
1744
+ type: "file"
1745
+ });
1746
+ const mediaType = msgType === "audio" ? "audio/mpeg" : "video/mp4";
1747
+ const transcript = await options.asrProvider.transcribe(bytes, mediaType);
1748
+ if (transcript) {
1749
+ parsed.text = transcript;
1750
+ }
1751
+ }
1752
+ } catch (e) {
1753
+ console.warn(
1754
+ "[eve-lark] audio transcription failed, skipping message:",
1755
+ e instanceof Error ? e.message : e
1756
+ );
1757
+ }
1758
+ }
1759
+ }
1608
1760
  if (parsed.text === "" && parsed.files.length === 0) {
1609
1761
  return ackOk();
1610
1762
  }
1763
+ if (parsed.text.trim().toLowerCase() === "/lark-diagnose") {
1764
+ helpers.waitUntil(runDiagnostics(client, options, parsed.chatId));
1765
+ return ackOk();
1766
+ }
1611
1767
  const tokenKey = chatTokenKey(parsed.chatId, parsed.rootId ?? void 0, parsed.parentId ?? void 0);
1612
1768
  const pending = pendingInputsByChatToken.get(tokenKey);
1613
1769
  if (pending && pending.awaitingFreeform && parsed.text.length > 0) {
@@ -1659,10 +1815,15 @@ function createLarkChannel(optionsInput) {
1659
1815
  chatType: parsed.chatType
1660
1816
  }
1661
1817
  };
1662
- const session = await helpers.send(userContent, {
1818
+ const groupConfig = parsed.chatType === "group" ? options.groupConfigs?.find((g) => g.chatId === parsed.chatId) : void 0;
1819
+ const sendPayload = {
1663
1820
  auth,
1664
1821
  continuationToken
1665
- });
1822
+ };
1823
+ if (groupConfig?.systemPrompt) {
1824
+ sendPayload.context = [groupConfig.systemPrompt];
1825
+ }
1826
+ const session = await helpers.send(userContent, sendPayload);
1666
1827
  sessionMeta.set(session.id, {
1667
1828
  chatId: parsed.chatId,
1668
1829
  rootId: parsed.rootId ?? void 0,
@@ -1693,50 +1854,69 @@ function createLarkChannel(optionsInput) {
1693
1854
  return ackOk();
1694
1855
  }
1695
1856
  const requestId = typeof value.requestId === "string" ? value.requestId : "";
1696
- const optionId = typeof value.optionId === "string" ? value.optionId : "";
1857
+ const optionId = (typeof value.optionId === "string" ? value.optionId : "") || (typeof evt.action?.option === "string" ? evt.action.option : "");
1697
1858
  if (!requestId) return ackOk();
1698
1859
  const pending = pendingInputsByRequestId.get(requestId);
1699
1860
  if (!pending) {
1700
- console.warn(`[eve-lark] card action for unknown requestId=${requestId} (already answered or expired)`);
1701
- return ackOk();
1702
- }
1703
- const resp = { requestId, optionId: optionId || void 0 };
1704
- const resumeToken = larkContinuationToken(pending.chatId, pending.parentId ?? pending.rootId ?? null);
1705
- const resumeAuth = {
1706
- authenticator: "lark",
1707
- principalType: "user",
1708
- principalId: evt.open_id,
1709
- attributes: {
1710
- chatId: pending.chatId,
1711
- rootMessageId: pending.rootId,
1712
- messageId: evt.open_message_id,
1713
- chatType: pending.request.display === "confirmation" ? "p2p" : "group"
1714
- }
1715
- };
1716
- try {
1717
- await helpers.send(
1718
- { inputResponses: [resp] },
1719
- { auth: resumeAuth, continuationToken: resumeToken }
1720
- );
1721
- console.log(`[eve-lark] ask answered via button click requestId=${requestId} optionId=${optionId}`);
1722
- } catch (e) {
1723
- console.error(
1724
- `[eve-lark] ask input-response send failed (requestId=${requestId}):`,
1725
- e instanceof Error ? e.message : e
1861
+ console.warn(
1862
+ `[eve-lark] card action for unknown requestId=${requestId} (already answered or expired)`
1726
1863
  );
1864
+ return ackOk();
1727
1865
  }
1728
1866
  const selectedOpt = pending.request.options?.find((o) => o.id === optionId);
1729
- if (pending.cardMessageId && selectedOpt) {
1730
- try {
1731
- await client.patchCard({
1732
- messageId: pending.cardMessageId,
1733
- card: buildAskAnsweredCard(pending.request, { kind: "option", label: selectedOpt.label })
1734
- });
1735
- } catch (e) {
1736
- console.warn("[eve-lark] patchCard after ask-answer failed:", e instanceof Error ? e.message : e);
1737
- }
1738
- }
1739
- dropPendingInput(pending);
1867
+ helpers.waitUntil(
1868
+ (async () => {
1869
+ if (pending.cardMessageId && selectedOpt) {
1870
+ try {
1871
+ await client.patchCard({
1872
+ messageId: pending.cardMessageId,
1873
+ card: buildAskAnsweredCard(pending.request, {
1874
+ kind: "option",
1875
+ label: selectedOpt.label
1876
+ })
1877
+ });
1878
+ } catch (e) {
1879
+ console.warn(
1880
+ "[eve-lark] patchCard after ask-answer failed:",
1881
+ e instanceof Error ? e.message : e
1882
+ );
1883
+ }
1884
+ }
1885
+ const resp = { requestId, optionId: optionId || void 0 };
1886
+ const resumeToken = larkContinuationToken(
1887
+ pending.chatId,
1888
+ pending.parentId ?? pending.rootId ?? null
1889
+ );
1890
+ const resumeAuth = {
1891
+ authenticator: "lark",
1892
+ principalType: "user",
1893
+ principalId: evt.open_id,
1894
+ attributes: {
1895
+ chatId: pending.chatId,
1896
+ rootMessageId: pending.rootId,
1897
+ messageId: evt.open_message_id,
1898
+ chatType: pending.request.display === "confirmation" ? "p2p" : "group"
1899
+ }
1900
+ };
1901
+ try {
1902
+ await helpers.send(
1903
+ { inputResponses: [resp] },
1904
+ { auth: resumeAuth, continuationToken: resumeToken }
1905
+ );
1906
+ console.log(
1907
+ `[eve-lark] ask answered via card action requestId=${requestId} optionId=${optionId}`
1908
+ );
1909
+ } catch (e) {
1910
+ console.error(
1911
+ `[eve-lark] ask input-response send failed (requestId=${requestId}):`,
1912
+ e instanceof Error ? e.message : e
1913
+ );
1914
+ }
1915
+ dropPendingInput(pending);
1916
+ })().catch((e) => {
1917
+ console.error("[eve-lark] card action background work failed:", e);
1918
+ })
1919
+ );
1740
1920
  return ackOk();
1741
1921
  }
1742
1922
  __name(handleCardAction, "handleCardAction");
@@ -1752,6 +1932,31 @@ function createLarkChannel(optionsInput) {
1752
1932
  const ctrl = getController(sessionId, info);
1753
1933
  ctrl.appendDelta(d.messageDelta);
1754
1934
  },
1935
+ // Model is about to call tools. Update the streaming card status so the
1936
+ // user sees what's happening mid-turn instead of a static typing dot.
1937
+ // Only fires when replyMode is "streaming" (cards exist). Post/static
1938
+ // modes have no live surface to update.
1939
+ async "actions.requested"(data, _channel, ctx) {
1940
+ if (options.replyMode !== "streaming") return;
1941
+ const sessionId = ctx.session.id;
1942
+ const ctrl = controllers.get(sessionId);
1943
+ if (!ctrl) return;
1944
+ const d = data;
1945
+ const names = (d.actions ?? []).map((a) => a.toolName).filter((n) => typeof n === "string");
1946
+ if (names.length === 0) return;
1947
+ const label = names.length === 1 ? `\u{1F527} ${names[0]}` : `\u{1F527} ${names.join(", ")}`;
1948
+ ctrl.setStatus(label);
1949
+ },
1950
+ // A tool finished. Clear the status (the next message.appended or
1951
+ // message.completed will overwrite anyway, but clearing here gives
1952
+ // snappier feedback for long tool chains). Best-effort.
1953
+ async "action.result"(_data, _channel, ctx) {
1954
+ if (options.replyMode !== "streaming") return;
1955
+ const sessionId = ctx.session.id;
1956
+ const ctrl = controllers.get(sessionId);
1957
+ if (!ctrl) return;
1958
+ ctrl.setStatus("");
1959
+ },
1755
1960
  // eve's ask_question (and similar HITL tools) fire this event with a
1756
1961
  // list of input requests. Each request becomes a Feishu card with
1757
1962
  // buttons (one per option) plus optional freeform hint.
@@ -1872,6 +2077,67 @@ function createLarkChannel(optionsInput) {
1872
2077
  console.error(
1873
2078
  `[eve-lark] session.failed: ${userText}` + (errorId ? ` (errorId=${errorId})` : "")
1874
2079
  );
2080
+ },
2081
+ // Turn ended cleanly. eve fires this after the final message.completed
2082
+ // (or instead of it when the assistant step ended in tool-calls with no
2083
+ // visible text). Either way, free this session's controller + ack
2084
+ // reaction so we don't leak waiting for a message.completed that's
2085
+ // never coming.
2086
+ async "turn.completed"(data, _channel, ctx) {
2087
+ const sessionId = ctx?.session?.id;
2088
+ if (!sessionId) return;
2089
+ try {
2090
+ await cleanupAckReaction(sessionId);
2091
+ } catch {
2092
+ }
2093
+ dropController(sessionId);
2094
+ },
2095
+ // The agent needs the user to sign in to an external service (e.g.
2096
+ // GitHub, Slack, Linear). Render a card with a "Sign in with <X>"
2097
+ // URL button so the user can complete the flow in their browser.
2098
+ // The card message id is tracked so `authorization.completed` can
2099
+ // patch it with the outcome.
2100
+ async "authorization.required"(data, _channel, ctx) {
2101
+ const sessionId = ctx?.session?.id;
2102
+ const info = sessionInfoFromCtx(ctx);
2103
+ if (!info || !sessionId) return;
2104
+ const d = data;
2105
+ const name = d.name ?? "service";
2106
+ const displayName = d.authorization?.displayName ?? name;
2107
+ const url = d.authorization?.url;
2108
+ if (!url) {
2109
+ console.warn(`[eve-lark] authorization.required for ${name}: no url, skipping card`);
2110
+ return;
2111
+ }
2112
+ const card = buildAuthCard({ displayName, url, userCode: d.authorization?.userCode });
2113
+ try {
2114
+ const res = await client.sendCard({ chatId: info.chatId, card, rootId: info.rootId, parentId: info.parentId });
2115
+ authCards.set(`${sessionId}:${name}`, res.messageId);
2116
+ } catch (e) {
2117
+ console.error(`[eve-lark] auth card send failed (${name}):`, e instanceof Error ? e.message : e);
2118
+ }
2119
+ },
2120
+ // The user completed (or declined) the external auth. Patch the card
2121
+ // we rendered in `authorization.required` to show the outcome.
2122
+ async "authorization.completed"(data, _channel, ctx) {
2123
+ const sessionId = ctx?.session?.id;
2124
+ if (!sessionId) return;
2125
+ const d = data;
2126
+ const name = d.name ?? "service";
2127
+ const cardMessageId = authCards.get(`${sessionId}:${name}`);
2128
+ if (!cardMessageId) return;
2129
+ const displayName = d.authorization?.displayName ?? name;
2130
+ const card = buildAuthCompletedCard({
2131
+ displayName,
2132
+ outcome: d.outcome ?? "completed",
2133
+ reason: d.reason
2134
+ });
2135
+ try {
2136
+ await client.patchCard({ messageId: cardMessageId, card });
2137
+ } catch (e) {
2138
+ console.warn(`[eve-lark] auth card patch failed (${name}):`, e instanceof Error ? e.message : e);
2139
+ }
2140
+ authCards.delete(`${sessionId}:${name}`);
1875
2141
  }
1876
2142
  };
1877
2143
  const channel = defineChannel({