@fastagent-sh/fastagent 0.14.0 → 0.16.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.
Files changed (277) hide show
  1. package/README.md +16 -6
  2. package/dist/agent.d.ts +20 -0
  3. package/dist/agent.js +9 -0
  4. package/dist/channels/agentcore-limits.d.ts +7 -0
  5. package/dist/channels/agentcore-limits.js +9 -0
  6. package/dist/channels/agentcore-state.d.ts +83 -0
  7. package/dist/channels/agentcore-state.js +258 -0
  8. package/dist/channels/agentcore.d.ts +98 -0
  9. package/dist/channels/agentcore.js +260 -0
  10. package/dist/channels/busy.d.ts +23 -0
  11. package/dist/channels/busy.js +53 -0
  12. package/dist/channels/context-buffer.d.ts +35 -0
  13. package/dist/channels/context-buffer.js +103 -0
  14. package/dist/channels/control.d.ts +28 -0
  15. package/dist/channels/control.js +214 -0
  16. package/dist/channels/feishu/card.js +1 -1
  17. package/dist/channels/feishu/context-buffer.d.ts +15 -13
  18. package/dist/channels/feishu/context-buffer.js +62 -69
  19. package/dist/channels/feishu/feishu-api.d.ts +11 -3
  20. package/dist/channels/feishu/feishu-api.js +24 -3
  21. package/dist/channels/feishu/feishu.d.ts +27 -27
  22. package/dist/channels/feishu/feishu.js +318 -207
  23. package/dist/channels/feishu/invoke-turn.d.ts +7 -20
  24. package/dist/channels/feishu/invoke-turn.js +44 -79
  25. package/dist/channels/feishu/model.d.ts +2 -2
  26. package/dist/channels/feishu/normalize.d.ts +2 -1
  27. package/dist/channels/feishu/normalize.js +0 -1
  28. package/dist/channels/feishu/parse.d.ts +22 -8
  29. package/dist/channels/feishu/parse.js +24 -7
  30. package/dist/channels/feishu/preview.d.ts +4 -7
  31. package/dist/channels/feishu/preview.js +27 -142
  32. package/dist/channels/feishu/register-app.d.ts +2 -1
  33. package/dist/channels/feishu/register-webhook.d.ts +2 -1
  34. package/dist/channels/feishu/scaffold/channel.ts +9 -8
  35. package/dist/channels/feishu/scaffold/feishu-send.ts +6 -4
  36. package/dist/channels/feishu/setup-mode.d.ts +37 -0
  37. package/dist/channels/feishu/setup-mode.js +28 -0
  38. package/dist/channels/feishu/ws-ingress.d.ts +28 -0
  39. package/dist/channels/feishu/ws-ingress.js +136 -0
  40. package/dist/channels/github/github.js +16 -7
  41. package/dist/channels/http.d.ts +14 -0
  42. package/dist/channels/http.js +36 -3
  43. package/dist/channels/invoke-turn-kit.d.ts +65 -0
  44. package/dist/channels/invoke-turn-kit.js +87 -0
  45. package/dist/channels/lark/lark.d.ts +4 -2
  46. package/dist/channels/lark/lark.js +4 -1
  47. package/dist/channels/lark/onboard.d.ts +8 -4
  48. package/dist/channels/lark/onboard.js +8 -0
  49. package/dist/channels/lark/scaffold/channel.ts +9 -8
  50. package/dist/channels/lark/scaffold/lark-send.ts +6 -4
  51. package/dist/channels/preview-kit.d.ts +115 -0
  52. package/dist/channels/preview-kit.js +184 -0
  53. package/dist/channels/seen.d.ts +5 -0
  54. package/dist/channels/seen.js +35 -0
  55. package/dist/channels/slack/bot-auth.d.ts +15 -0
  56. package/dist/channels/slack/bot-auth.js +146 -0
  57. package/dist/channels/slack/config-api.d.ts +60 -0
  58. package/dist/channels/slack/config-api.js +149 -0
  59. package/dist/channels/slack/context-buffer.d.ts +24 -0
  60. package/dist/channels/slack/context-buffer.js +37 -0
  61. package/dist/channels/slack/invoke-turn.d.ts +19 -0
  62. package/dist/channels/slack/invoke-turn.js +63 -0
  63. package/dist/channels/slack/manifest.d.ts +49 -0
  64. package/dist/channels/slack/manifest.js +69 -0
  65. package/dist/channels/slack/model.d.ts +67 -0
  66. package/dist/channels/slack/model.js +2 -0
  67. package/dist/channels/slack/onboard.d.ts +41 -0
  68. package/dist/channels/slack/onboard.js +120 -0
  69. package/dist/channels/slack/onboarding-state.d.ts +31 -0
  70. package/dist/channels/slack/onboarding-state.js +69 -0
  71. package/dist/channels/slack/parse.d.ts +38 -0
  72. package/dist/channels/slack/parse.js +124 -0
  73. package/dist/channels/slack/preview.d.ts +23 -0
  74. package/dist/channels/slack/preview.js +403 -0
  75. package/dist/channels/slack/reaction.d.ts +24 -0
  76. package/dist/channels/slack/reaction.js +62 -0
  77. package/dist/channels/slack/register-webhook.d.ts +10 -0
  78. package/dist/channels/slack/register-webhook.js +49 -0
  79. package/dist/channels/slack/scaffold/channel.ts +33 -0
  80. package/dist/channels/slack/scaffold/slack-send.ts +171 -0
  81. package/dist/channels/slack/setup-server.d.ts +17 -0
  82. package/dist/channels/slack/setup-server.js +103 -0
  83. package/dist/channels/slack/slack-api.d.ts +57 -0
  84. package/dist/channels/slack/slack-api.js +399 -0
  85. package/dist/channels/slack/slack.d.ts +51 -0
  86. package/dist/channels/slack/slack.js +496 -0
  87. package/dist/channels/slack/welcomed.d.ts +5 -0
  88. package/dist/channels/slack/welcomed.js +32 -0
  89. package/dist/channels/state.d.ts +11 -4
  90. package/dist/channels/state.js +22 -15
  91. package/dist/channels/stop-command.d.ts +6 -0
  92. package/dist/channels/stop-command.js +36 -0
  93. package/dist/channels/tasks.d.ts +7 -0
  94. package/dist/channels/tasks.js +25 -0
  95. package/dist/channels/telegram/context-buffer.d.ts +8 -17
  96. package/dist/channels/telegram/context-buffer.js +6 -85
  97. package/dist/channels/telegram/invoke-turn.d.ts +5 -22
  98. package/dist/channels/telegram/invoke-turn.js +11 -58
  99. package/dist/channels/telegram/parse.d.ts +0 -7
  100. package/dist/channels/telegram/parse.js +4 -2
  101. package/dist/channels/telegram/preview.d.ts +4 -7
  102. package/dist/channels/telegram/preview.js +24 -142
  103. package/dist/channels/telegram/scaffold/telegram-send.ts +6 -3
  104. package/dist/channels/telegram/telegram.js +24 -10
  105. package/dist/channels/{feishu/text.d.ts → text.d.ts} +15 -1
  106. package/dist/channels/{feishu/text.js → text.js} +15 -1
  107. package/dist/channels/thread-participants.d.ts +21 -0
  108. package/dist/channels/thread-participants.js +132 -0
  109. package/dist/channels/turn-queue.js +8 -1
  110. package/dist/channels/turn-store.d.ts +1 -1
  111. package/dist/channels/turn-store.js +2 -3
  112. package/dist/cli/add-feishu.d.ts +30 -0
  113. package/dist/cli/add-feishu.js +366 -0
  114. package/dist/cli/add-slack.d.ts +11 -0
  115. package/dist/cli/add-slack.js +199 -0
  116. package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
  117. package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
  118. package/dist/cli/commands/add.d.ts +4 -0
  119. package/dist/cli/commands/add.js +191 -60
  120. package/dist/cli/commands/attach.d.ts +82 -0
  121. package/dist/cli/commands/attach.js +563 -0
  122. package/dist/cli/commands/chat.d.ts +1 -0
  123. package/dist/cli/commands/chat.js +12 -7
  124. package/dist/cli/commands/deploy.d.ts +3 -1
  125. package/dist/cli/commands/deploy.js +384 -82
  126. package/dist/cli/commands/dev.js +34 -26
  127. package/dist/cli/commands/fire.js +17 -18
  128. package/dist/cli/commands/info.js +36 -29
  129. package/dist/cli/commands/init.d.ts +1 -1
  130. package/dist/cli/commands/init.js +65 -53
  131. package/dist/cli/commands/invoke.js +11 -9
  132. package/dist/cli/commands/login.js +48 -45
  133. package/dist/cli/commands/models.js +1 -1
  134. package/dist/cli/commands/schedule.js +6 -8
  135. package/dist/cli/commands/start.js +102 -39
  136. package/dist/cli/commands/tool.js +23 -18
  137. package/dist/cli/fail.d.ts +17 -2
  138. package/dist/cli/fail.js +25 -1
  139. package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
  140. package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
  141. package/dist/cli/kernel.d.ts +3 -2
  142. package/dist/cli/models-view.d.ts +21 -0
  143. package/dist/cli/models-view.js +66 -0
  144. package/dist/cli/program.js +135 -56
  145. package/dist/cli/serve.d.ts +60 -20
  146. package/dist/cli/serve.js +252 -47
  147. package/dist/cli/shared.d.ts +40 -7
  148. package/dist/cli/shared.js +180 -38
  149. package/dist/collect.d.ts +14 -3
  150. package/dist/collect.js +24 -0
  151. package/dist/core.d.ts +3 -1
  152. package/dist/core.js +2 -0
  153. package/dist/deploy/agentcore/plan.d.ts +117 -0
  154. package/dist/deploy/agentcore/plan.js +721 -0
  155. package/dist/deploy/agentcore/run.d.ts +73 -0
  156. package/dist/deploy/agentcore/run.js +412 -0
  157. package/dist/deploy/agentcore/zip.d.ts +17 -0
  158. package/dist/deploy/agentcore/zip.js +68 -0
  159. package/dist/deploy/container.d.ts +26 -25
  160. package/dist/deploy/container.js +93 -89
  161. package/dist/deploy/docker/plan.d.ts +3 -3
  162. package/dist/deploy/docker/plan.js +20 -23
  163. package/dist/deploy/fly/plan.d.ts +5 -1
  164. package/dist/deploy/fly/plan.js +39 -26
  165. package/dist/deploy/fly/run.d.ts +17 -5
  166. package/dist/deploy/fly/run.js +52 -7
  167. package/dist/deploy/preflight.d.ts +20 -8
  168. package/dist/deploy/preflight.js +267 -71
  169. package/dist/deploy/railway/plan.d.ts +10 -1
  170. package/dist/deploy/railway/plan.js +53 -20
  171. package/dist/deploy/railway/run.d.ts +13 -5
  172. package/dist/deploy/railway/run.js +22 -7
  173. package/dist/deploy/runner.d.ts +7 -3
  174. package/dist/deploy/runner.js +9 -3
  175. package/dist/deploy/secrets.d.ts +2 -1
  176. package/dist/deploy/secrets.js +23 -3
  177. package/dist/dev-supervisor.d.ts +11 -10
  178. package/dist/dev-supervisor.js +56 -50
  179. package/dist/engines/pi/auth.d.ts +8 -7
  180. package/dist/engines/pi/auth.js +12 -10
  181. package/dist/engines/pi/channel.d.ts +22 -16
  182. package/dist/engines/pi/channel.js +92 -62
  183. package/dist/engines/pi/chat.d.ts +4 -16
  184. package/dist/engines/pi/chat.js +8 -261
  185. package/dist/engines/pi/config.d.ts +20 -55
  186. package/dist/engines/pi/config.js +49 -121
  187. package/dist/engines/pi/create.d.ts +30 -10
  188. package/dist/engines/pi/create.js +33 -26
  189. package/dist/engines/pi/definition.d.ts +7 -26
  190. package/dist/engines/pi/definition.js +8 -54
  191. package/dist/engines/pi/harness.d.ts +49 -0
  192. package/dist/engines/pi/harness.js +90 -2
  193. package/dist/engines/pi/invoke.d.ts +55 -2
  194. package/dist/engines/pi/invoke.js +304 -19
  195. package/dist/engines/pi/login.d.ts +12 -1
  196. package/dist/engines/pi/login.js +16 -4
  197. package/dist/engines/pi/models.d.ts +59 -13
  198. package/dist/engines/pi/models.js +62 -24
  199. package/dist/engines/pi/open.d.ts +114 -0
  200. package/dist/engines/pi/open.js +127 -0
  201. package/dist/engines/pi/session-builder.d.ts +16 -0
  202. package/dist/engines/pi/session-builder.js +308 -0
  203. package/dist/engines/pi/session-control.d.ts +50 -0
  204. package/dist/engines/pi/session-control.js +604 -0
  205. package/dist/engines/pi/sessions.d.ts +17 -2
  206. package/dist/engines/pi/sessions.js +9 -0
  207. package/dist/engines/pi/tool-context.d.ts +18 -11
  208. package/dist/engines/pi/tool-context.js +3 -9
  209. package/dist/engines/pi/tool.d.ts +6 -8
  210. package/dist/engines/pi/tool.js +10 -1
  211. package/dist/engines/pi/wake-tool.d.ts +0 -3
  212. package/dist/engines/pi/wake-tool.js +9 -7
  213. package/dist/env.d.ts +16 -4
  214. package/dist/env.js +43 -5
  215. package/dist/feishu.d.ts +1 -1
  216. package/dist/feishu.js +1 -1
  217. package/dist/host/node.d.ts +25 -8
  218. package/dist/host/node.js +5 -4
  219. package/dist/index.d.ts +1 -0
  220. package/dist/index.js +1 -0
  221. package/dist/lark.d.ts +1 -1
  222. package/dist/lark.js +1 -1
  223. package/dist/loader.d.ts +2 -2
  224. package/dist/loader.js +3 -3
  225. package/dist/log.d.ts +1 -1
  226. package/dist/log.js +1 -1
  227. package/dist/observe.js +3 -0
  228. package/dist/paths.d.ts +138 -0
  229. package/dist/paths.js +326 -0
  230. package/dist/pi.d.ts +6 -5
  231. package/dist/pi.js +4 -3
  232. package/dist/runtime.d.ts +7 -5
  233. package/dist/runtime.js +2 -2
  234. package/dist/scaffold/add-channel.d.ts +26 -9
  235. package/dist/scaffold/add-channel.js +175 -40
  236. package/dist/scaffold/init.d.ts +32 -41
  237. package/dist/scaffold/init.js +161 -185
  238. package/dist/scaffold/templates/env.example +15 -6
  239. package/dist/scaffold/templates/fastagent.config.mjs +5 -4
  240. package/dist/scaffold/templates/gitignore +14 -6
  241. package/dist/scaffold/templates/persona.md +4 -2
  242. package/dist/scaffold/templates/secrets.gitignore +5 -0
  243. package/dist/scaffold/templates.d.ts +1 -7
  244. package/dist/scaffold/templates.js +3 -25
  245. package/dist/scaffold/vendor-skill.d.ts +2 -2
  246. package/dist/scaffold/vendor-skill.js +13 -13
  247. package/dist/schedule/discover.js +4 -4
  248. package/dist/schedule/scheduler.d.ts +40 -1
  249. package/dist/schedule/scheduler.js +89 -56
  250. package/dist/schedule/state.js +1 -1
  251. package/dist/schedule/wake-alarm.d.ts +47 -0
  252. package/dist/schedule/wake-alarm.js +136 -0
  253. package/dist/schedule/wakeups.d.ts +1 -3
  254. package/dist/schedule/wakeups.js +19 -1
  255. package/dist/session-remote.d.ts +53 -0
  256. package/dist/session-remote.js +336 -0
  257. package/dist/session.d.ts +265 -0
  258. package/dist/session.js +37 -0
  259. package/dist/slack.d.ts +2 -0
  260. package/dist/slack.js +2 -0
  261. package/dist/tunnel.d.ts +7 -4
  262. package/dist/tunnel.js +23 -12
  263. package/package.json +22 -7
  264. package/dist/channels/feishu/owned-threads.d.ts +0 -11
  265. package/dist/channels/feishu/owned-threads.js +0 -47
  266. package/dist/channels/feishu/seen.d.ts +0 -5
  267. package/dist/channels/feishu/seen.js +0 -47
  268. package/dist/cli-add-feishu.d.ts +0 -8
  269. package/dist/cli-add-feishu.js +0 -223
  270. package/dist/cli-models.d.ts +0 -11
  271. package/dist/cli-models.js +0 -20
  272. package/dist/engines/pi/workspace.d.ts +0 -56
  273. package/dist/engines/pi/workspace.js +0 -72
  274. package/dist/scaffold/templates/gitignore.agentdir-root +0 -5
  275. package/dist/scaffold/templates/gitignore.kit +0 -2
  276. package/dist/workspace.d.ts +0 -9
  277. package/dist/workspace.js +0 -45
@@ -1,18 +1,21 @@
1
1
  /**
2
- * Canonical Feishu bot-channel engine: verify webhook answer url_verification → dedup → route → persist → run
3
- * the turn → stream a live card → ACK 200. Feishu (open.feishu.cn) is the reference cloud. Lark
2
+ * Canonical Feishu bot-channel engine: verified webhook or official-SDK WebSocket → dedup → route →
3
+ * persist enqueue → stream a live card. Feishu (open.feishu.cn) is the reference cloud. Lark
4
4
  * international binds this engine through an explicit compatibility profile because its control plane
5
5
  * trails Feishu; protocol reuse does not make Lark the design center.
6
6
  *
7
7
  * The channel kind remains the unit of route, env namespace, state home, logs, and onboarding, so one
8
- * workspace may mount both without sharing state. Webhook mode only; WebSocket long connection needs
9
- * the official SDK and a non-HTTP ingress seam. See docs/feishu.md.
8
+ * workspace may run both without sharing state. Webhook returns the existing route factory; WebSocket
9
+ * returns an explicit long-connection module. Both feed the same acceptance/turn engine. See docs/feishu.md.
10
10
  */
11
11
  import { isAbsolute, join } from "node:path";
12
12
  import { log } from "../../log.js";
13
13
  import { readBodyCapped } from "../body.js";
14
14
  import { text } from "../respond.js";
15
- import { ensureStateHome } from "../state.js";
15
+ import { createSeenRing } from "../seen.js";
16
+ import { createTaskTracker } from "../tasks.js";
17
+ import { ensureStateHome, removeRetiredStateFile } from "../state.js";
18
+ import { dispatchStop, isStopText } from "../stop-command.js";
16
19
  import { createTurnQueue } from "../turn-queue.js";
17
20
  import { createTurnStore } from "../turn-store.js";
18
21
  import { FEISHU_CLOUD } from "./cloud.js";
@@ -21,10 +24,11 @@ import { decryptEvent, timingSafeEqualStr, verifySignature } from "./crypto.js";
21
24
  import { invokeFeishuTurn } from "./invoke-turn.js";
22
25
  import { createFeishuApi } from "./feishu-api.js";
23
26
  import { normalizeFeishuMessage } from "./normalize.js";
24
- import { createOwnedFeishuThreads } from "./owned-threads.js";
25
- import { cloudEnvelope, defaultFeishuRoute, feishuEnvelope, placeKey, senderLabel, } from "./parse.js";
27
+ import { createThreadParticipants } from "../thread-participants.js";
28
+ import { FEISHU_GROUP_CONTEXT_SCOPE, FEISHU_MESSAGE_READ_REQUEST, FEISHU_MESSAGE_READ_SCOPE, scopeSatisfied, } from "./setup-mode.js";
29
+ import { cloudEnvelope, defaultFeishuRoute, feishuEnvelope, placeKey, senderId, senderLabel, } from "./parse.js";
26
30
  import { defaultErrorMessage, mountFeishuPreview, settleFeishuPreview, streamFeishuReply, } from "./preview.js";
27
- import { createSeenRing } from "./seen.js";
31
+ import { connectFeishuWs } from "./ws-ingress.js";
28
32
  // Canonical public surface; the Lark subpath aliases these types/functions at its compatibility boundary.
29
33
  export { defaultFeishuRoute, feishuEnvelope };
30
34
  /** Execution ceiling: a turn that has STARTED running this many times without finishing is dropped
@@ -48,7 +52,7 @@ function isStoredFeishuTurn(t) {
48
52
  typeof r.seq === "number" &&
49
53
  typeof r.session === "string" &&
50
54
  typeof r.baseText === "string" &&
51
- (r.bufferKey === undefined || typeof r.bufferKey === "string") &&
55
+ typeof r.bufferKey === "string" &&
52
56
  typeof r.chatId === "string" &&
53
57
  (r.replyTo === undefined || typeof r.replyTo === "string") &&
54
58
  (r.queueReplyTo === undefined || typeof r.queueReplyTo === "string") &&
@@ -58,40 +62,66 @@ function isStoredFeishuTurn(t) {
58
62
  refs(r.files) &&
59
63
  typeof r.attempts === "number");
60
64
  }
61
- /** Build the canonical Feishu channel. Lark calls the internal profile-bound builder below. */
65
+ /** Build the canonical Feishu Request-URL webhook channel. */
62
66
  export function feishuChannel(opts) {
63
67
  return buildFeishuChannel(FEISHU_CLOUD, opts, feishuChannel.name);
64
68
  }
65
- /** Internal compatibility seam: protocol behavior comes from Feishu; the profile binds cloud edges. */
66
- export function buildFeishuChannel(profile, { appId, appSecret, verificationToken, encryptKey, directMessageSession = "threaded", groupMessageSession = "threaded", route, onError, baseUrl = profile.apiBase, queueNoticeDelayMs = QUEUE_NOTICE_DELAY_MS, }, factoryName) {
67
- const { kind, envPrefix } = profile;
68
- const label = `[${kind}]`;
69
- // All three are mandatory: without the app credentials no reply can be sent; without the verification
70
- // token a plaintext-mode endpoint would accept forged events. Fail at construction (startup), not
71
- // silently at the first event.
72
- if (!appId || !appSecret) {
73
- throw new Error(`${factoryName} requires appId + appSecret (developer console Credentials & Basic Info)`);
74
- }
75
- if (!verificationToken) {
76
- throw new Error(`${factoryName} requires a non-empty verificationToken (console Events & Callbacks; an unset one accepts forged events)`);
77
- }
78
- if (directMessageSession !== "continuous" && directMessageSession !== "threaded") {
79
- throw new Error(`${factoryName} directMessageSession must be "continuous" or "threaded"`);
80
- }
81
- if (groupMessageSession !== "continuous" && groupMessageSession !== "threaded") {
82
- throw new Error(`${factoryName} groupMessageSession must be "continuous" or "threaded"`);
69
+ /** Build the canonical Feishu WebSocket long-connection channel. */
70
+ export function feishuWebSocketChannel(opts) {
71
+ return buildFeishuWebSocketChannel(FEISHU_CLOUD, opts, feishuWebSocketChannel.name);
72
+ }
73
+ /** The participant model removed the session modes (docs/design/participant-model.md §12). An upgraded
74
+ * workspace still passing one would otherwise start fine and silently get different placement AND a
75
+ * different memory boundary — the one breaking change most likely to be hit, and invisible. */
76
+ function rejectRemovedSessionOptions(opts, factoryName) {
77
+ const removed = ["directMessageSession", "groupMessageSession"].filter((name) => opts[name] !== undefined);
78
+ if (removed.length > 0) {
79
+ throw new Error(`${factoryName} no longer accepts ${removed.join(" / ")}: a chat is one session and a thread is another, ` +
80
+ "and the summon rule no longer depends on the mode remove the option (see docs/design/participant-model.md)");
83
81
  }
84
- return ({ agent, stateRoot }) => {
82
+ }
83
+ function createFeishuRuntimeFactory(profile, opts, factoryName) {
84
+ const { appId, appSecret, route, onError, queueNoticeDelayMs = QUEUE_NOTICE_DELAY_MS } = opts;
85
+ const baseUrl = opts.apiBaseUrl ?? profile.apiBase;
86
+ const { kind } = profile;
87
+ const label = `[${kind}]`;
88
+ return ({ agent, stateRoot, control }) => {
89
+ // Credential checks run when serving starts, not while the authored module is imported: deployment
90
+ // can inspect the module shape before secrets exist, while serving still fails before ready.
91
+ if (!appId || !appSecret) {
92
+ throw new Error(`${factoryName} requires appId + appSecret (developer console → Credentials & Basic Info)`);
93
+ }
85
94
  const formatError = onError ?? defaultErrorMessage;
86
95
  const api = createFeishuApi({ kind, baseUrl, appId, appSecret });
87
96
  // One bot/v3/info at startup: the bot's open_id drives the default route's group @mention summon.
88
- // Until it resolves (or if it fails), group summon stays off — fail-closed — while p2p works.
97
+ // Until it resolves (or if it fails), group summon stays off — fail-closed — while p2p works. A
98
+ // mention landing in that first moment is buffered as context rather than answered; it is folded
99
+ // into the next answered turn in that place, so the ask is delayed, never lost.
89
100
  let botOpenId;
90
101
  void api.botInfo().then((me) => {
91
102
  botOpenId = me.openId;
92
103
  if (!botOpenId)
93
104
  log.warn(`${label} bot/v3/info returned no open_id — group @mention summon stays off`);
94
105
  }, (e) => log.warn(`${label} bot/v3/info failed; group @mention summon stays off until restart: ${String(e)}`));
106
+ void api.listAppScopes().then((scopes) => {
107
+ const grantedScope = (name) => scopes.some((scope) => scope.name === name && scope.grantStatus === 1 && (scope.type === undefined || scope.type === "tenant"));
108
+ if (grantedScope(FEISHU_GROUP_CONTEXT_SCOPE)) {
109
+ // This scope settles the rule's whole input: it delivers the un-mentioned group messages the
110
+ // channel buffers, which is also what lets it HEAR a thread. Nothing is fetched, so nothing
111
+ // is pending — the only bootstrap left is social, one mention inside a thread. The read scope
112
+ // is a separate, softer dependency, so it is reported separately rather than folded in.
113
+ log.info(`${label} group visibility: context-aware — buffered discussion enabled; bare replies work in a thread once the agent has been mentioned in it`);
114
+ }
115
+ else {
116
+ log.warn(`${label} group visibility: @mentions only — ${FEISHU_GROUP_CONTEXT_SCOPE} is not granted; bare replies in the agent's threads + group context buffering are unavailable`);
117
+ }
118
+ // Reported OUTSIDE the branch above: the quoted-message read runs in every chat type and every
119
+ // posture (a p2p thread's opening ask, any quoted @mention in a group), so pairing this warning
120
+ // with the group scope would leave a mention-only deployment silently losing every referent.
121
+ if (!scopeSatisfied(FEISHU_MESSAGE_READ_REQUEST, grantedScope)) {
122
+ log.warn(`${label} ${FEISHU_MESSAGE_READ_SCOPE} is not granted — a message quoted by an ask cannot be read, and degrades to a marker in the prompt`);
123
+ }
124
+ }, (error) => log.warn(`${label} could not inspect group visibility: ${String(error)}`));
95
125
  const decide = route ?? ((event) => defaultFeishuRoute(event, { botOpenId }));
96
126
  // The channel-state convention: this channel's durable home is `<stateRoot>/channels/<kind>`
97
127
  // (engine state at the root, channel state under `channels/<kind>/`) — derived, not an option, so
@@ -100,8 +130,16 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
100
130
  throw new Error(`${factoryName} requires an absolute ctx.stateRoot, got "${stateRoot}"`);
101
131
  }
102
132
  const stateHome = join(stateRoot, "channels", kind);
103
- ensureStateHome(stateHome); // create + self-ignore — buffers/files may carry chat content
104
- const ownedThreads = createOwnedFeishuThreads(join(stateHome, "owned-threads.json"), label);
133
+ ensureStateHome(stateHome); // buffers/files may carry chat content; the agent .gitignore covers .state/
134
+ // The participant model replaced the owned-thread index (a cache, so nothing is lost). REMOVE THIS
135
+ // after the release following the participant model ships — by then no live deployment can still
136
+ // be carrying the file. test/migration-deadline.test.ts fails when due.
137
+ removeRetiredStateFile(stateHome, "owned-threads.json", label);
138
+ const threadParticipants = createThreadParticipants(join(stateHome, "thread-participants.json"), label);
139
+ /** This channel's place key for a thread (the shared store is key-agnostic). */
140
+ // The SAME identity the session uses (`placeKey`) — a thread's place. Defining it twice would let a
141
+ // future re-keying silently split participation from the sessions it is supposed to describe.
142
+ const threadKey = (chatId, threadId) => placeKey(kind, { chat_id: chatId, thread_id: threadId });
105
143
  const buffer = createFeishuContextBuffer(join(stateHome, "buffers.json"), label);
106
144
  const store = createTurnStore(join(stateHome, "turns.json"), {
107
145
  label,
@@ -109,6 +147,8 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
109
147
  order: (a, b) => a.seq - b.seq,
110
148
  });
111
149
  const seen = createSeenRing(join(stateHome, "seen.json"), label);
150
+ // Side tasks (stop feedback) run off the ingress path but drain in turnsIdle.
151
+ const sideTasks = createTaskTracker();
112
152
  const toStored = (r) => {
113
153
  const { preview: _live, ...intent } = r; // drop the live-only field; TS enforces the rest is complete
114
154
  return { ...intent, attempts: 0 };
@@ -226,7 +266,7 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
226
266
  // when the platform redelivers. Recovery re-enqueues a crash survivor without re-persisting it.
227
267
  const submit = (rec, persist) => {
228
268
  if (persist) {
229
- store.add(toStored(rec)); // failed write → webhook 500 → platform redelivery
269
+ store.add(toStored(rec)); // failed write → HTTP/WS 500 → platform re-push
230
270
  seen.add(rec.id); // post-persist, best-effort protection from documented duplicate pushes
231
271
  }
232
272
  queue.accept(rec);
@@ -244,122 +284,73 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
244
284
  if (recovered.length > 0)
245
285
  log.info(`${label} recovering ${recovered.length} unfinished turn(s) from a prior run`);
246
286
  let seqCounter = recovered.reduce((max, r) => Math.max(max, r.seq), 0);
247
- for (const { attempts: _a, ...intent } of recovered) {
248
- // A pre-buffer-version record has no trustworthy place identity. Give it an empty private bucket
249
- // rather than risk consuming new main-chat context that arrived after this restart.
250
- const bufferKey = intent.bufferKey ?? `${intent.chatId}:legacy-turn:${intent.id}`;
251
- submit({ ...intent, bufferKey, preview: undefined }, false);
252
- }
253
- const handler = async (req) => {
254
- if (req.method !== "POST")
255
- return text("POST only\n", 405);
256
- const body = await readBodyCapped(req, MAX_EVENT_BYTES);
257
- if ("tooLarge" in body)
258
- return text("payload too large\n", 413);
259
- let outer;
260
- try {
261
- outer = JSON.parse(body.text);
262
- if (typeof outer !== "object" || outer === null)
263
- throw new Error("not an object");
264
- }
265
- catch {
266
- return text("invalid json\n", 400);
267
- }
268
- // ── Verification. Two modes, decided by the CONSOLE's Encrypt Key setting, mirrored here. ──────
269
- let envelope;
270
- if (typeof outer.encrypt === "string") {
271
- if (!encryptKey) {
272
- log.error(`${label} received an ENCRYPTED event but no encryptKey is configured — set ${envPrefix}_ENCRYPT_KEY`);
273
- return text("encrypt key not configured\n", 400);
274
- }
275
- const sig = {
276
- timestamp: req.headers.get("x-lark-request-timestamp") ?? "",
277
- nonce: req.headers.get("x-lark-request-nonce") ?? "",
278
- signature: req.headers.get("x-lark-signature") ?? "",
279
- };
280
- // Ordinary encrypted events MUST verify the signature over the raw body before decryption.
281
- // Feishu's documented exception is Request URL verification: its encrypted challenge carries
282
- // no event-signature headers, so it is decrypted first and admitted ONLY when its type is
283
- // url_verification; the common constant-time Token check below then authenticates it.
284
- if (sig.signature && !verifySignature(encryptKey, sig, body.text)) {
285
- log.warn(`${label} rejected an event: invalid X-Lark-Signature (encrypt key mismatch, or a forgery)`);
286
- return text("invalid signature\n", 401);
287
- }
288
- try {
289
- envelope = JSON.parse(decryptEvent(encryptKey, outer.encrypt));
290
- }
291
- catch {
292
- if (!sig.signature) {
293
- log.warn(`${label} rejected an unsigned encrypted request that could not be decrypted`);
294
- return text("invalid encrypted payload\n", 401);
295
- }
296
- return text("invalid encrypted payload\n", 400);
297
- }
298
- if (!sig.signature && envelope.type !== "url_verification") {
299
- log.warn(`${label} rejected an encrypted event: missing X-Lark-Signature`);
300
- return text("invalid signature\n", 401);
301
- }
302
- }
303
- else {
304
- if (encryptKey) {
305
- // With an Encrypt Key configured, a PLAINTEXT event can only be a forgery (or a console
306
- // mismatch — surfaced in the log): accepting it would let a sender skip the signature.
307
- log.warn(`${label} rejected a plaintext event while encryptKey is set (console mismatch, or a forgery)`);
308
- return text("plaintext events not accepted\n", 401);
309
- }
310
- envelope = outer;
311
- }
312
- // The Verification Token authenticates plaintext mode and the platform-documented unsigned,
313
- // encrypted URL challenge; on signed encrypted events it is defense in depth. V2 events carry it
314
- // in header.token, while url_verification carries it at the top level. Fail closed when absent.
315
- const token = (typeof envelope.token === "string" ? envelope.token : undefined) ??
316
- (typeof envelope.header?.token === "string"
317
- ? envelope.header.token
318
- : undefined);
319
- if (!token || !timingSafeEqualStr(token, verificationToken)) {
320
- // Loud on purpose: the send side gets an opaque 401 and the platform just retries — this line is
321
- // the operator's ONLY signal that LARK_VERIFICATION_TOKEN does not match the console.
322
- log.warn(`${label} rejected an event: verification token mismatch (check ${envPrefix}_VERIFICATION_TOKEN against the console)`);
323
- return text("invalid token\n", 401);
324
- }
325
- // ── The console's URL-verification challenge (fires when the operator saves the Request URL). ──
326
- if (envelope.type === "url_verification" && typeof envelope.challenge === "string") {
327
- // The console fires this when the operator saves the Request URL; without this line a PASSING
328
- // handshake is invisible and "did the challenge even arrive?" becomes guesswork.
329
- log.info(`${label} answered the console's url_verification challenge`);
330
- return Response.json({ challenge: envelope.challenge });
331
- }
332
- // ── Events. Only im.message.receive_v1 is consumed; everything else is ACKed and dropped
333
- // (a non-2xx would just make the platform retry an event this channel will never act on). ──────
334
- const header = envelope.header;
335
- if (header?.event_type !== "im.message.receive_v1") {
336
- log.debug(`${label} ignoring event type ${header?.event_type ?? "(none)"}`);
337
- return new Response(null, { status: 200 });
287
+ for (const { attempts: _a, ...intent } of recovered)
288
+ submit({ ...intent, preview: undefined }, false);
289
+ // Who the agent has heard in a thread decides whether a bare message addresses it (participant
290
+ // model §3), and it comes from what this channel observed — see thread-participants.ts.
291
+ let warnedUnidentified = false;
292
+ /**
293
+ * What this delivery contributes to thread participation, or undefined when it contributes nothing.
294
+ *
295
+ * ONE gate for BOTH writes (the humans observation on the way in, and the `agentSpoke` merge once
296
+ * the turn is durable), and one definition of the synthetic speaker id — hand-written conditions in
297
+ * two places is the drift that has already bitten this branch once. Structural facts only; see
298
+ * thread-participants.ts for why configuration must not appear here.
299
+ *
300
+ * Feishu-specific: p2p is excluded (nothing reads those records), and a custom route may admit a
301
+ * bot the default route filters out — answering one is not participation the summon rule should
302
+ * act on, so such a thread keeps no record and the first human still needs the mention bootstrap.
303
+ */
304
+ const heardIn = (m, sender) => {
305
+ if (m.chat_type !== "group" || m.thread_id === undefined || sender?.sender_type !== "user")
306
+ return undefined;
307
+ const speakerId = senderId(sender);
308
+ if (speakerId === undefined && !warnedUnidentified) {
309
+ // Once per MOUNT — the flag lives in this channel's closure on purpose: the condition is a
310
+ // property of THIS app's event configuration, so a `feishu` and a `lark` mount must each be
311
+ // able to report it. A per-thread set would instead grow without bound to repeat one fact.
312
+ warnedUnidentified = true;
313
+ log.warn(`${label} human senders arrive with no usable id (first seen in thread ${m.thread_id}) — each counts as a distinct speaker, so affected threads permanently require an @mention until thread-participants.json is deleted`);
338
314
  }
339
- const event = (envelope.event ?? {});
315
+ // A human whose id no tenant flavour carries still SPOKE, and no human may speak unrecorded. A
316
+ // per-MESSAGE synthetic id keeps them distinct; a per-thread one would collapse every human on an
317
+ // id-less tenant into one, which is the direction that barges into a crowd. Its cost is PERMANENT
318
+ // — two such messages fill MAX_HUMANS and records never shed, so that thread needs an @mention
319
+ // from then on and only deleting the file resets it (§3).
320
+ return { key: threadKey(m.chat_id, m.thread_id), speaker: speakerId ?? `unidentified:${m.message_id}` };
321
+ };
322
+ // Transport-neutral acceptance boundary: normalize, route, persist intent/context, enqueue. It
323
+ // touches no network, so it stays synchronous inside the platform's ACK window and the delivery
324
+ // dedup ring alone is enough — there is no await for a duplicate push to race through. The
325
+ // minutes-long Agent turn remains fire-and-forget.
326
+ const acceptEvent = (event) => {
340
327
  const m = event.message;
341
328
  if (!m?.message_id || !m.chat_id)
342
- return new Response(null, { status: 200 });
329
+ return;
343
330
  if (seen.has(m.message_id)) {
344
331
  log.debug(`${label} duplicate push for message ${m.message_id} — already persisted, skipping`);
345
- return new Response(null, { status: 200 });
332
+ return;
346
333
  }
347
334
  let r = decide(event);
348
335
  const normalized = normalizeFeishuMessage(event);
349
336
  if (!normalized)
350
- return new Response(null, { status: 200 });
337
+ return;
351
338
  const bufferKey = feishuBufferPlaceKey(normalized.conversation);
352
339
  const isHumanGroup = event.sender?.sender_type === "user" && m.chat_type === "group";
353
- const managedThread = groupMessageSession === "threaded" &&
340
+ // Listening is not speaking: every message the channel can see refines who takes part in its
341
+ // thread, whether or not it is answered. The sender counts toward the rule immediately — a
342
+ // second human speaking is exactly what makes addressing ambiguous again.
343
+ const heard = heardIn(m, event.sender);
344
+ if (heard)
345
+ threadParticipants.merge(heard.key, { humans: [heard.speaker] });
346
+ if (!r &&
347
+ route === undefined &&
354
348
  isHumanGroup &&
355
349
  m.thread_id !== undefined &&
356
- m.root_id !== undefined &&
357
- ownedThreads.has(m.chat_id, m.root_id);
358
- // In an Agent-created thread, a bare user continuation still summons. Any explicit mention changes
359
- // that intent: only defaultFeishuRoute's structural @THIS-bot match summons; @other-only discussion
360
- // is buffered like unsummoned group context. A custom route remains fully authoritative.
361
- if (!r && route === undefined && managedThread && !normalized.content.hasMentions)
350
+ !normalized.content.hasMentions &&
351
+ threadParticipants.admitsBareMessage(threadKey(m.chat_id, m.thread_id))) {
362
352
  r = {};
353
+ }
363
354
  if (!r) {
364
355
  if (route === undefined && isHumanGroup) {
365
356
  const bodyText = feishuBufferText(normalized.content.text);
@@ -375,7 +366,8 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
375
366
  key: resource.key,
376
367
  name: resource.name,
377
368
  }));
378
- // Pre-ACK persistence: a write failure rejects the webhook so the platform can redeliver.
369
+ // A write failure escapes this boundary. HTTP turns it into a 500 response; the official WS
370
+ // SDK turns it into a 500 ACK frame. Both transports therefore ask the platform to re-push.
379
371
  buffer.push(bufferKey, {
380
372
  sender: senderLabel(event.sender) ?? "someone",
381
373
  body: bodyText,
@@ -384,7 +376,7 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
384
376
  files: files.length ? files : undefined,
385
377
  images: images.length ? images : undefined,
386
378
  });
387
- seen.add(m.message_id); // post-persist: a redelivery cannot duplicate buffered context
379
+ seen.add(m.message_id);
388
380
  log.debug(`${label} buffered unsummoned group message ${m.message_id} (place ${bufferKey})`);
389
381
  }
390
382
  else {
@@ -394,79 +386,198 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
394
386
  else {
395
387
  log.debug(`${label} not summoned — ignoring message ${m.message_id} (chat ${m.chat_id}, ${m.chat_type})`);
396
388
  }
397
- return new Response(null, { status: 200 });
389
+ return;
398
390
  }
399
- {
400
- const threadedP2p = directMessageSession === "threaded" && m.chat_type === "p2p";
401
- const threadedGroup = groupMessageSession === "threaded" && m.chat_type === "group";
402
- const threadedConversation = threadedP2p || threadedGroup;
403
- // A top-level threaded message has no thread_id yet. Its tenant-unique message_id is therefore
404
- // the only identity available both before and after the first reply creates the thread.
405
- // Continuations carry that same value as root_id (field-verified on Feishu p2p; shared protocol
406
- // shape for groups/Lark). Prefix with the channel kind to isolate Feishu/Lark while keeping pi's
407
- // provider-facing session/cache key under 64 characters.
408
- if (threadedConversation && m.thread_id !== undefined && m.root_id === undefined) {
409
- log.warn(`${label} threaded ${m.chat_type} message ${m.message_id} has thread_id ${m.thread_id} but no root_id session continuity cannot be guaranteed`);
410
- }
411
- const defaultSession = threadedConversation
412
- ? `${kind}:${m.thread_id === undefined ? m.message_id : (m.root_id ?? `missing-root:${m.thread_id}`)}`
413
- : placeKey(m);
414
- const session = r.session ?? defaultSession;
415
- const chatId = r.chatId ?? m.chat_id;
416
- // Groups always quote the summon. Threaded groups and p2p add reply_in_thread: on a top-level
417
- // message that creates the thread, and on a continuation it keeps the answer inside it. Only
418
- // quote when the resolved target is the source chat — a custom redirect cannot reuse a message
419
- // id there. A continuous group still keeps replies inside an already-existing platform topic.
420
- const sameTarget = chatId === m.chat_id;
421
- const replyTo = sameTarget && (m.chat_type === "group" || threadedP2p) ? m.message_id : undefined;
422
- const replyInThread = replyTo !== undefined && (threadedConversation || m.thread_id !== undefined) ? true : undefined;
423
- // Queue feedback always identifies the exact ask, including continuous modes. In threaded mode
424
- // it inherits replyInThread, so an ask queued inside a root cannot leak a status card to main chat.
425
- const queueReplyTo = sameTarget ? m.message_id : undefined;
426
- const resources = normalized.content.resources;
427
- const images = resources
428
- .filter((resource) => resource.kind === "image")
429
- .map((resource) => ({ msg: resource.messageId, key: resource.key }));
430
- const files = resources
431
- .filter((resource) => resource.kind === "file" || resource.kind === "audio" || resource.kind === "video")
432
- .map((resource) => ({ msg: resource.messageId, key: resource.key, name: resource.name }));
433
- const baseText = r.text ?? cloudEnvelope(event, kind);
434
- if (baseText.trim() !== "" || images.length > 0 || files.length > 0) {
435
- // Persist ownership before ACK. The platform thread does not exist until the first reply lands,
436
- // but a failed reply has no continuation to misroute; pre-ACK ownership closes the opposite,
437
- // worse window (thread created, process dies, then its unmentioned continuation is forgotten).
438
- if (route === undefined &&
439
- threadedGroup &&
440
- m.thread_id === undefined &&
441
- sameTarget &&
442
- replyInThread === true) {
443
- ownedThreads.add(m.chat_id, m.message_id);
444
- }
445
- submit({
446
- id: m.message_id,
447
- seq: ++seqCounter,
448
- session,
449
- baseText,
450
- bufferKey,
451
- chatId,
452
- replyTo,
453
- queueReplyTo,
454
- replyInThread,
455
- // Inside a threaded session the root conversation history already contains the previous
456
- // turns. Reloading parent_id would duplicate that input (and its attachments). A top-level
457
- // quoted reply has no thread_id, starts a new root, and still hydrates its referent.
458
- parentId: threadedConversation && m.thread_id !== undefined ? undefined : m.parent_id,
459
- images,
460
- files,
461
- }, true);
391
+ // Memory follows the place (participant model §5): one session per chat, and one per thread.
392
+ // Keyed by `thread_id`, never `root_id` the platform's root_id tracks the reply chain and can
393
+ // differ between messages of ONE thread, which would split a side conversation in two.
394
+ const session = r.session ?? placeKey(kind, m);
395
+ const chatId = r.chatId ?? m.chat_id;
396
+ const sameTarget = chatId === m.chat_id;
397
+ // Answer where asked (§4): quote in a group so the ask is identifiable among many speakers,
398
+ // stay plain in an ordinary direct message, and stay inside a thread whenever the question came
399
+ // from one a direct message's thread is a place too, and relocating out of it is the silent
400
+ // move the model refuses.
401
+ const replyTo = sameTarget && (m.chat_type === "group" || m.thread_id !== undefined) ? m.message_id : undefined;
402
+ const replyInThread = replyTo !== undefined && m.thread_id !== undefined ? true : undefined;
403
+ const queueReplyTo = sameTarget ? m.message_id : undefined;
404
+ // Explicit user stop: a control action, never a turn — it must not queue behind the run it
405
+ // stops. Mentions arrive as @name tokens; strip them before matching the bare word. Record the
406
+ // message id so a platform re-push doesn't double-abort or double-notify.
407
+ if (isStopText(normalized.content.text.replace(/@\S+/g, " "))) {
408
+ seen.add(m.message_id);
409
+ sideTasks.track(dispatchStop(control, session, label)
410
+ .then((feedback) => api.sendText({ chatId, replyTo, replyInThread }, feedback).then(() => undefined))
411
+ .catch((error) => log.warn(`${label} stop feedback failed: ${String(error)}`)));
412
+ return;
413
+ }
414
+ const resources = normalized.content.resources;
415
+ const images = resources
416
+ .filter((resource) => resource.kind === "image")
417
+ .map((resource) => ({ msg: resource.messageId, key: resource.key }));
418
+ const files = resources
419
+ .filter((resource) => resource.kind === "file" || resource.kind === "audio" || resource.kind === "video")
420
+ .map((resource) => ({ msg: resource.messageId, key: resource.key, name: resource.name }));
421
+ const baseText = r.text ?? cloudEnvelope(event, kind);
422
+ if (baseText.trim() === "" && images.length === 0 && files.length === 0)
423
+ return;
424
+ submit({
425
+ id: m.message_id,
426
+ seq: ++seqCounter, // arrival order; the turn store replays by it
427
+ session,
428
+ baseText,
429
+ bufferKey,
430
+ chatId,
431
+ replyTo,
432
+ queueReplyTo,
433
+ replyInThread,
434
+ // A quote is the user explicitly pointing at something that may predate this session
435
+ // (§8 rung 2), so it is always loaded.
436
+ //
437
+ // There used to be an exception: skip it inside a thread the agent had already answered in,
438
+ // since the session would hold it. That needed a second fact — did the channel RECEIVE the
439
+ // messages in between? — which depends on a scope that changes over time, while the record it
440
+ // was read against is durable. Every attempt to gate it correctly failed in the same
441
+ // direction (a silently missing quote), for an optimisation worth one `getMessage` on a
442
+ // quote-reply inside an active thread. Loading it costs a call and some text the session may
443
+ // already have; it also pins WHICH message is being answered, which a long thread benefits
444
+ // from anyway.
445
+ parentId: m.parent_id,
446
+ images,
447
+ files,
448
+ }, true);
449
+ // Answering inside a thread makes the agent a participant of it, which is what lets the NEXT
450
+ // bare message address it without a mention (§3).
451
+ //
452
+ // `r.session === undefined` is what makes this fact mean what its reader assumes. Participation
453
+ // is keyed by THREAD while the memory it stands in for is keyed by SESSION, and those agree only
454
+ // when the session is derived from the place. A route supplying its own (the scaffold's
455
+ // `session: user:<open_id>` example) can put two people's turns in one thread into different
456
+ // sessions — recording `agentSpoke` from one of them would tell the summon rule the agent took
457
+ // part in a conversation it cannot remember. So the flag records "the agent answered into THIS
458
+ // THREAD'S session". Such a thread keeps a bystander record and needs the ordinary mention to
459
+ // bootstrap if the route is later dropped. `group` matches the observation above so the record is
460
+ // never half-written.
461
+ //
462
+ // Recorded only once the intent is durable: `submit` can throw, and a redelivery must still see
463
+ // the thread as the agent has actually left it. A later delivery failure does not undo it —
464
+ // entering the conversation is the intent, not the send.
465
+ // Reuses `heardIn` from the way in, so both writes share ONE gate by construction. The extra
466
+ // conditions are about the ANSWER, not the speaker: it must land in this thread (`sameTarget`,
467
+ // `replyInThread`) and in the place's own session, or the flag would claim a memory that never
468
+ // held the turn.
469
+ if (heard && replyInThread === true && sameTarget && r.session === undefined) {
470
+ // Both halves in ONE merge, like Slack's: a record that needed an earlier merge to survive
471
+ // could otherwise say "answered here, heard nobody" — which admits bare messages forever.
472
+ threadParticipants.merge(heard.key, { agentSpoke: true, humans: [heard.speaker] });
473
+ }
474
+ };
475
+ return { acceptEvent, turnsIdle: () => Promise.all([queue.idle(), sideTasks.drain()]).then(() => undefined) };
476
+ };
477
+ }
478
+ function createFeishuWebhookRoutes(profile, opts, runtime) {
479
+ const { verificationToken, encryptKey } = opts;
480
+ const { kind, envPrefix } = profile;
481
+ const label = `[${kind}]`;
482
+ const handler = async (req) => {
483
+ if (req.method !== "POST")
484
+ return text("POST only\n", 405);
485
+ const body = await readBodyCapped(req, MAX_EVENT_BYTES);
486
+ if ("tooLarge" in body)
487
+ return text("payload too large\n", 413);
488
+ let outer;
489
+ try {
490
+ outer = JSON.parse(body.text);
491
+ if (typeof outer !== "object" || outer === null)
492
+ throw new Error("not an object");
493
+ }
494
+ catch {
495
+ return text("invalid json\n", 400);
496
+ }
497
+ let envelope;
498
+ if (typeof outer.encrypt === "string") {
499
+ if (!encryptKey) {
500
+ log.error(`${label} received an ENCRYPTED event but no encryptKey is configured — set ${envPrefix}_ENCRYPT_KEY`);
501
+ return text("encrypt key not configured\n", 400);
502
+ }
503
+ const sig = {
504
+ timestamp: req.headers.get("x-lark-request-timestamp") ?? "",
505
+ nonce: req.headers.get("x-lark-request-nonce") ?? "",
506
+ signature: req.headers.get("x-lark-signature") ?? "",
507
+ };
508
+ if (sig.signature && !verifySignature(encryptKey, sig, body.text)) {
509
+ log.warn(`${label} rejected an event: invalid X-Lark-Signature (encrypt key mismatch, or a forgery)`);
510
+ return text("invalid signature\n", 401);
511
+ }
512
+ try {
513
+ envelope = JSON.parse(decryptEvent(encryptKey, outer.encrypt));
514
+ }
515
+ catch {
516
+ if (!sig.signature) {
517
+ log.warn(`${label} rejected an unsigned encrypted request that could not be decrypted`);
518
+ return text("invalid encrypted payload\n", 401);
462
519
  }
520
+ return text("invalid encrypted payload\n", 400);
521
+ }
522
+ if (!sig.signature && envelope.type !== "url_verification") {
523
+ log.warn(`${label} rejected an encrypted event: missing X-Lark-Signature`);
524
+ return text("invalid signature\n", 401);
525
+ }
526
+ }
527
+ else {
528
+ if (encryptKey) {
529
+ log.warn(`${label} rejected a plaintext event while encryptKey is set (console mismatch, or a forgery)`);
530
+ return text("plaintext events not accepted\n", 401);
463
531
  }
464
- // ACK immediately (the platform expects a fast 200; the turn may outlast it by minutes) —
465
- // lifecycle goes to stderr; after the 200 those lines are the operator's only signal.
532
+ envelope = outer;
533
+ }
534
+ const token = (typeof envelope.token === "string" ? envelope.token : undefined) ??
535
+ (typeof envelope.header?.token === "string"
536
+ ? envelope.header.token
537
+ : undefined);
538
+ if (!token || !timingSafeEqualStr(token, verificationToken)) {
539
+ log.warn(`${label} rejected an event: verification token mismatch (check ${envPrefix}_VERIFICATION_TOKEN against the console)`);
540
+ return text("invalid token\n", 401);
541
+ }
542
+ if (envelope.type === "url_verification" && typeof envelope.challenge === "string") {
543
+ log.info(`${label} answered the console's url_verification challenge`);
544
+ return Response.json({ challenge: envelope.challenge });
545
+ }
546
+ const header = envelope.header;
547
+ if (header?.event_type !== "im.message.receive_v1") {
548
+ log.debug(`${label} ignoring event type ${header?.event_type ?? "(none)"}`);
466
549
  return new Response(null, { status: 200 });
467
- };
468
- // Test/observability seam: await the fire-and-forget turns this handler enqueues (see turn-queue).
469
- handler.turnsIdle = () => queue.idle();
470
- return { [`POST /${kind}`]: handler };
550
+ }
551
+ runtime.acceptEvent((envelope.event ?? {}));
552
+ return new Response(null, { status: 200 });
553
+ };
554
+ handler.turnsIdle = runtime.turnsIdle;
555
+ return { [`POST /${kind}`]: handler };
556
+ }
557
+ export function buildFeishuChannel(profile, opts, factoryName) {
558
+ rejectRemovedSessionOptions(opts, factoryName);
559
+ const createRuntime = createFeishuRuntimeFactory(profile, opts, factoryName);
560
+ return (ctx) => {
561
+ if (!opts.verificationToken) {
562
+ throw new Error(`${factoryName} requires a non-empty verificationToken (console → Events & Callbacks)`);
563
+ }
564
+ return createFeishuWebhookRoutes(profile, opts, createRuntime(ctx));
565
+ };
566
+ }
567
+ export function buildFeishuWebSocketChannel(profile, opts, factoryName, deps = {}) {
568
+ rejectRemovedSessionOptions(opts, factoryName);
569
+ const createRuntime = createFeishuRuntimeFactory(profile, opts, factoryName);
570
+ return {
571
+ name: `${profile.kind} websocket`,
572
+ connect(ctx, signal) {
573
+ const runtime = createRuntime(ctx);
574
+ return (deps.connectWs ?? connectFeishuWs)({
575
+ kind: profile.kind,
576
+ appId: opts.appId,
577
+ appSecret: opts.appSecret,
578
+ domain: opts.apiBaseUrl ?? profile.apiBase,
579
+ onEvent: runtime.acceptEvent,
580
+ }, signal);
581
+ },
471
582
  };
472
583
  }