@archipelagolab/lobi 1.0.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 (315) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/ENDOFFILE +0 -0
  3. package/EOF +0 -0
  4. package/LICENSE +21 -0
  5. package/SPEC-SUPPORT.md +116 -0
  6. package/YAMLEND +0 -0
  7. package/api.ts +18 -0
  8. package/archipelagolab-lobi-1.0.0.tgz +0 -0
  9. package/auth-presence.ts +56 -0
  10. package/channel-plugin-api.ts +3 -0
  11. package/cli-metadata.ts +11 -0
  12. package/contract-api.ts +17 -0
  13. package/docs/CHECKLIST.md +83 -0
  14. package/docs/FORK_SDK_GUIDE.md +279 -0
  15. package/helper-api.ts +3 -0
  16. package/index.test.ts +61 -0
  17. package/index.ts +65 -0
  18. package/openclaw.plugin.json +23 -0
  19. package/package.json +52 -0
  20. package/plugin-entry.handlers.runtime.ts +1 -0
  21. package/runtime-api.ts +54 -0
  22. package/runtime-heavy-api.ts +1 -0
  23. package/scripts/migrate-to-lobi.sh +72 -0
  24. package/secret-contract-api.ts +5 -0
  25. package/setup-entry.ts +13 -0
  26. package/src/account-selection.test.ts +124 -0
  27. package/src/account-selection.ts +226 -0
  28. package/src/actions.account-propagation.test.ts +251 -0
  29. package/src/actions.test.ts +251 -0
  30. package/src/actions.ts +336 -0
  31. package/src/approval-auth.test.ts +23 -0
  32. package/src/approval-auth.ts +25 -0
  33. package/src/approval-handler.runtime.test.ts +46 -0
  34. package/src/approval-handler.runtime.ts +400 -0
  35. package/src/approval-ids.ts +6 -0
  36. package/src/approval-native.test.ts +329 -0
  37. package/src/approval-native.ts +336 -0
  38. package/src/approval-reactions.test.ts +107 -0
  39. package/src/approval-reactions.ts +158 -0
  40. package/src/auth-precedence.ts +61 -0
  41. package/src/channel-account-paths.ts +92 -0
  42. package/src/channel.account-paths.test.ts +102 -0
  43. package/src/channel.directory.test.ts +601 -0
  44. package/src/channel.resolve.test.ts +38 -0
  45. package/src/channel.runtime.ts +16 -0
  46. package/src/channel.setup.test.ts +269 -0
  47. package/src/channel.ts +570 -0
  48. package/src/cli-metadata.ts +19 -0
  49. package/src/cli.test.ts +1015 -0
  50. package/src/cli.ts +1198 -0
  51. package/src/config-adapter.ts +41 -0
  52. package/src/config-schema.test.ts +90 -0
  53. package/src/config-schema.ts +114 -0
  54. package/src/directory-live.test.ts +200 -0
  55. package/src/directory-live.ts +238 -0
  56. package/src/doctor-contract.ts +287 -0
  57. package/src/doctor.test.ts +440 -0
  58. package/src/doctor.ts +262 -0
  59. package/src/env-vars.ts +92 -0
  60. package/src/exec-approval-resolver.test.ts +68 -0
  61. package/src/exec-approval-resolver.ts +23 -0
  62. package/src/exec-approvals.test.ts +483 -0
  63. package/src/exec-approvals.ts +290 -0
  64. package/src/group-mentions.ts +41 -0
  65. package/src/legacy-crypto-inspector-availability.test.ts +81 -0
  66. package/src/legacy-crypto-inspector-availability.ts +60 -0
  67. package/src/legacy-crypto.test.ts +234 -0
  68. package/src/legacy-crypto.ts +549 -0
  69. package/src/legacy-state.test.ts +86 -0
  70. package/src/legacy-state.ts +156 -0
  71. package/src/matrix/account-config.ts +150 -0
  72. package/src/matrix/accounts.readiness.test.ts +27 -0
  73. package/src/matrix/accounts.test.ts +757 -0
  74. package/src/matrix/accounts.ts +194 -0
  75. package/src/matrix/actions/client.test.ts +215 -0
  76. package/src/matrix/actions/client.ts +31 -0
  77. package/src/matrix/actions/devices.test.ts +114 -0
  78. package/src/matrix/actions/devices.ts +34 -0
  79. package/src/matrix/actions/limits.test.ts +15 -0
  80. package/src/matrix/actions/limits.ts +6 -0
  81. package/src/matrix/actions/messages.test.ts +289 -0
  82. package/src/matrix/actions/messages.ts +123 -0
  83. package/src/matrix/actions/pins.test.ts +74 -0
  84. package/src/matrix/actions/pins.ts +64 -0
  85. package/src/matrix/actions/polls.test.ts +71 -0
  86. package/src/matrix/actions/polls.ts +109 -0
  87. package/src/matrix/actions/profile.test.ts +109 -0
  88. package/src/matrix/actions/profile.ts +37 -0
  89. package/src/matrix/actions/reactions.test.ts +135 -0
  90. package/src/matrix/actions/reactions.ts +59 -0
  91. package/src/matrix/actions/room.test.ts +79 -0
  92. package/src/matrix/actions/room.ts +71 -0
  93. package/src/matrix/actions/summary.test.ts +87 -0
  94. package/src/matrix/actions/summary.ts +88 -0
  95. package/src/matrix/actions/types.ts +82 -0
  96. package/src/matrix/actions/verification.test.ts +105 -0
  97. package/src/matrix/actions/verification.ts +237 -0
  98. package/src/matrix/actions.ts +37 -0
  99. package/src/matrix/active-client.ts +26 -0
  100. package/src/matrix/async-lock.ts +18 -0
  101. package/src/matrix/backup-health.ts +115 -0
  102. package/src/matrix/client/config-runtime-api.ts +14 -0
  103. package/src/matrix/client/config-secret-input.runtime.ts +1 -0
  104. package/src/matrix/client/config.ts +982 -0
  105. package/src/matrix/client/create-client.test.ts +115 -0
  106. package/src/matrix/client/create-client.ts +101 -0
  107. package/src/matrix/client/env-auth.ts +6 -0
  108. package/src/matrix/client/file-sync-store.test.ts +265 -0
  109. package/src/matrix/client/file-sync-store.ts +289 -0
  110. package/src/matrix/client/logging.ts +123 -0
  111. package/src/matrix/client/migration-snapshot.runtime.ts +1 -0
  112. package/src/matrix/client/private-network-host.ts +56 -0
  113. package/src/matrix/client/runtime.ts +4 -0
  114. package/src/matrix/client/shared.test.ts +344 -0
  115. package/src/matrix/client/shared.ts +306 -0
  116. package/src/matrix/client/storage.test.ts +634 -0
  117. package/src/matrix/client/storage.ts +544 -0
  118. package/src/matrix/client/types.ts +50 -0
  119. package/src/matrix/client-bootstrap.test.ts +84 -0
  120. package/src/matrix/client-bootstrap.ts +164 -0
  121. package/src/matrix/client-resolver.test-helpers.ts +147 -0
  122. package/src/matrix/client.test.ts +1521 -0
  123. package/src/matrix/client.ts +23 -0
  124. package/src/matrix/config-paths.ts +31 -0
  125. package/src/matrix/config-update.test.ts +237 -0
  126. package/src/matrix/config-update.ts +291 -0
  127. package/src/matrix/credentials-read.ts +206 -0
  128. package/src/matrix/credentials-write.runtime.ts +26 -0
  129. package/src/matrix/credentials.test.ts +501 -0
  130. package/src/matrix/credentials.ts +95 -0
  131. package/src/matrix/deps.test.ts +74 -0
  132. package/src/matrix/deps.ts +225 -0
  133. package/src/matrix/device-health.test.ts +45 -0
  134. package/src/matrix/device-health.ts +31 -0
  135. package/src/matrix/direct-management.test.ts +350 -0
  136. package/src/matrix/direct-management.ts +347 -0
  137. package/src/matrix/direct-room.test.ts +61 -0
  138. package/src/matrix/direct-room.ts +128 -0
  139. package/src/matrix/draft-stream.test.ts +406 -0
  140. package/src/matrix/draft-stream.ts +216 -0
  141. package/src/matrix/encryption-guidance.ts +27 -0
  142. package/src/matrix/errors.ts +21 -0
  143. package/src/matrix/format.test.ts +340 -0
  144. package/src/matrix/format.ts +428 -0
  145. package/src/matrix/legacy-crypto-inspector.ts +95 -0
  146. package/src/matrix/media-errors.ts +20 -0
  147. package/src/matrix/media-text.ts +169 -0
  148. package/src/matrix/monitor/access-state.test.ts +45 -0
  149. package/src/matrix/monitor/access-state.ts +77 -0
  150. package/src/matrix/monitor/ack-config.test.ts +57 -0
  151. package/src/matrix/monitor/ack-config.ts +26 -0
  152. package/src/matrix/monitor/allowlist.test.ts +45 -0
  153. package/src/matrix/monitor/allowlist.ts +94 -0
  154. package/src/matrix/monitor/auto-join.test.ts +203 -0
  155. package/src/matrix/monitor/auto-join.ts +86 -0
  156. package/src/matrix/monitor/config.test.ts +197 -0
  157. package/src/matrix/monitor/config.ts +303 -0
  158. package/src/matrix/monitor/context-summary.ts +43 -0
  159. package/src/matrix/monitor/direct.test.ts +529 -0
  160. package/src/matrix/monitor/direct.ts +270 -0
  161. package/src/matrix/monitor/events.test.ts +1524 -0
  162. package/src/matrix/monitor/events.ts +213 -0
  163. package/src/matrix/monitor/handler.body-for-agent.test.ts +396 -0
  164. package/src/matrix/monitor/handler.group-history.test.ts +648 -0
  165. package/src/matrix/monitor/handler.media-failure.test.ts +267 -0
  166. package/src/matrix/monitor/handler.test-helpers.ts +308 -0
  167. package/src/matrix/monitor/handler.test.ts +2952 -0
  168. package/src/matrix/monitor/handler.thread-root-media.test.ts +82 -0
  169. package/src/matrix/monitor/handler.ts +1679 -0
  170. package/src/matrix/monitor/inbound-dedupe.test.ts +146 -0
  171. package/src/matrix/monitor/inbound-dedupe.ts +267 -0
  172. package/src/matrix/monitor/index.test.ts +920 -0
  173. package/src/matrix/monitor/index.ts +434 -0
  174. package/src/matrix/monitor/legacy-crypto-restore.test.ts +206 -0
  175. package/src/matrix/monitor/legacy-crypto-restore.ts +139 -0
  176. package/src/matrix/monitor/location.ts +100 -0
  177. package/src/matrix/monitor/media.test.ts +159 -0
  178. package/src/matrix/monitor/media.ts +119 -0
  179. package/src/matrix/monitor/mentions.test.ts +289 -0
  180. package/src/matrix/monitor/mentions.ts +177 -0
  181. package/src/matrix/monitor/reaction-events.test.ts +326 -0
  182. package/src/matrix/monitor/reaction-events.ts +187 -0
  183. package/src/matrix/monitor/recent-invite.test.ts +92 -0
  184. package/src/matrix/monitor/recent-invite.ts +30 -0
  185. package/src/matrix/monitor/replies.test.ts +265 -0
  186. package/src/matrix/monitor/replies.ts +136 -0
  187. package/src/matrix/monitor/reply-context.test.ts +276 -0
  188. package/src/matrix/monitor/reply-context.ts +92 -0
  189. package/src/matrix/monitor/room-history.test.ts +258 -0
  190. package/src/matrix/monitor/room-history.ts +301 -0
  191. package/src/matrix/monitor/room-info.test.ts +201 -0
  192. package/src/matrix/monitor/room-info.ts +126 -0
  193. package/src/matrix/monitor/rooms.test.ts +121 -0
  194. package/src/matrix/monitor/rooms.ts +52 -0
  195. package/src/matrix/monitor/route.test.ts +255 -0
  196. package/src/matrix/monitor/route.ts +178 -0
  197. package/src/matrix/monitor/runtime-api.ts +31 -0
  198. package/src/matrix/monitor/startup-verification.test.ts +294 -0
  199. package/src/matrix/monitor/startup-verification.ts +237 -0
  200. package/src/matrix/monitor/startup.test.ts +257 -0
  201. package/src/matrix/monitor/startup.ts +218 -0
  202. package/src/matrix/monitor/status.ts +111 -0
  203. package/src/matrix/monitor/sync-lifecycle.test.ts +224 -0
  204. package/src/matrix/monitor/sync-lifecycle.ts +91 -0
  205. package/src/matrix/monitor/task-runner.ts +38 -0
  206. package/src/matrix/monitor/thread-context.test.ts +149 -0
  207. package/src/matrix/monitor/thread-context.ts +108 -0
  208. package/src/matrix/monitor/threads.test.ts +68 -0
  209. package/src/matrix/monitor/threads.ts +85 -0
  210. package/src/matrix/monitor/types.ts +30 -0
  211. package/src/matrix/monitor/verification-events.ts +627 -0
  212. package/src/matrix/monitor/verification-utils.test.ts +47 -0
  213. package/src/matrix/monitor/verification-utils.ts +46 -0
  214. package/src/matrix/outbound-media-runtime.ts +1 -0
  215. package/src/matrix/poll-summary.ts +110 -0
  216. package/src/matrix/poll-types.test.ts +205 -0
  217. package/src/matrix/poll-types.ts +433 -0
  218. package/src/matrix/probe.runtime.ts +4 -0
  219. package/src/matrix/probe.test.ts +154 -0
  220. package/src/matrix/probe.ts +96 -0
  221. package/src/matrix/profile.test.ts +154 -0
  222. package/src/matrix/profile.ts +184 -0
  223. package/src/matrix/reaction-common.test.ts +96 -0
  224. package/src/matrix/reaction-common.ts +147 -0
  225. package/src/matrix/sdk/crypto-bootstrap.test.ts +505 -0
  226. package/src/matrix/sdk/crypto-bootstrap.ts +341 -0
  227. package/src/matrix/sdk/crypto-facade.test.ts +197 -0
  228. package/src/matrix/sdk/crypto-facade.ts +207 -0
  229. package/src/matrix/sdk/crypto-node.runtime.test.ts +27 -0
  230. package/src/matrix/sdk/crypto-node.runtime.ts +9 -0
  231. package/src/matrix/sdk/crypto-runtime.ts +11 -0
  232. package/src/matrix/sdk/decrypt-bridge.ts +356 -0
  233. package/src/matrix/sdk/event-helpers.test.ts +60 -0
  234. package/src/matrix/sdk/event-helpers.ts +71 -0
  235. package/src/matrix/sdk/http-client.test.ts +134 -0
  236. package/src/matrix/sdk/http-client.ts +87 -0
  237. package/src/matrix/sdk/idb-persistence-lock.ts +51 -0
  238. package/src/matrix/sdk/idb-persistence.lock-order.test.ts +108 -0
  239. package/src/matrix/sdk/idb-persistence.test-helpers.ts +88 -0
  240. package/src/matrix/sdk/idb-persistence.test.ts +149 -0
  241. package/src/matrix/sdk/idb-persistence.ts +283 -0
  242. package/src/matrix/sdk/logger.test.ts +25 -0
  243. package/src/matrix/sdk/logger.ts +108 -0
  244. package/src/matrix/sdk/read-response-with-limit.ts +19 -0
  245. package/src/matrix/sdk/recovery-key-store.test.ts +385 -0
  246. package/src/matrix/sdk/recovery-key-store.ts +430 -0
  247. package/src/matrix/sdk/transport.test.ts +161 -0
  248. package/src/matrix/sdk/transport.ts +344 -0
  249. package/src/matrix/sdk/types.ts +236 -0
  250. package/src/matrix/sdk/verification-manager.test.ts +509 -0
  251. package/src/matrix/sdk/verification-manager.ts +694 -0
  252. package/src/matrix/sdk/verification-status.ts +23 -0
  253. package/src/matrix/sdk.test.ts +2568 -0
  254. package/src/matrix/sdk.ts +1789 -0
  255. package/src/matrix/send/client.test.ts +174 -0
  256. package/src/matrix/send/client.ts +90 -0
  257. package/src/matrix/send/formatting.ts +189 -0
  258. package/src/matrix/send/media.ts +244 -0
  259. package/src/matrix/send/targets.test.ts +254 -0
  260. package/src/matrix/send/targets.ts +104 -0
  261. package/src/matrix/send/types.ts +134 -0
  262. package/src/matrix/send.test.ts +958 -0
  263. package/src/matrix/send.ts +609 -0
  264. package/src/matrix/session-store-metadata.ts +108 -0
  265. package/src/matrix/startup-abort.ts +44 -0
  266. package/src/matrix/sync-state.ts +27 -0
  267. package/src/matrix/target-ids.ts +102 -0
  268. package/src/matrix/thread-bindings-shared.ts +201 -0
  269. package/src/matrix/thread-bindings.test.ts +673 -0
  270. package/src/matrix/thread-bindings.ts +577 -0
  271. package/src/matrix-migration.runtime.ts +9 -0
  272. package/src/migration-config.test.ts +228 -0
  273. package/src/migration-config.ts +243 -0
  274. package/src/migration-snapshot-backup.ts +117 -0
  275. package/src/migration-snapshot.test.ts +184 -0
  276. package/src/migration-snapshot.ts +55 -0
  277. package/src/onboarding.resolve.test.ts +55 -0
  278. package/src/onboarding.test-harness.ts +158 -0
  279. package/src/onboarding.test.ts +665 -0
  280. package/src/onboarding.ts +773 -0
  281. package/src/outbound.test.ts +173 -0
  282. package/src/outbound.ts +78 -0
  283. package/src/plugin-entry.runtime.js +159 -0
  284. package/src/plugin-entry.runtime.test.ts +108 -0
  285. package/src/plugin-entry.runtime.ts +68 -0
  286. package/src/profile-update.ts +68 -0
  287. package/src/record-shared.ts +3 -0
  288. package/src/resolve-targets.test.ts +178 -0
  289. package/src/resolve-targets.ts +175 -0
  290. package/src/resolver.ts +21 -0
  291. package/src/runtime-api.ts +144 -0
  292. package/src/runtime.ts +7 -0
  293. package/src/secret-contract.ts +174 -0
  294. package/src/session-route.test.ts +315 -0
  295. package/src/session-route.ts +113 -0
  296. package/src/setup-bootstrap.ts +94 -0
  297. package/src/setup-config.ts +222 -0
  298. package/src/setup-contract.ts +89 -0
  299. package/src/setup-core.test.ts +326 -0
  300. package/src/setup-core.ts +50 -0
  301. package/src/setup-surface.ts +4 -0
  302. package/src/startup-maintenance.test.ts +227 -0
  303. package/src/startup-maintenance.ts +114 -0
  304. package/src/storage-paths.ts +92 -0
  305. package/src/test-helpers.ts +42 -0
  306. package/src/test-mocks.ts +55 -0
  307. package/src/test-runtime.ts +72 -0
  308. package/src/test-support/monitor-route-test-support.ts +8 -0
  309. package/src/tool-actions.runtime.ts +1 -0
  310. package/src/tool-actions.test.ts +422 -0
  311. package/src/tool-actions.ts +498 -0
  312. package/src/types.ts +230 -0
  313. package/test-api.ts +2 -0
  314. package/thread-bindings-runtime.ts +4 -0
  315. package/tsconfig.json +16 -0
@@ -0,0 +1,1679 @@
1
+ import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
2
+ import {
3
+ loadSessionStore,
4
+ resolveChannelContextVisibilityMode,
5
+ resolveSessionStoreEntry,
6
+ } from "openclaw/plugin-sdk/config-runtime";
7
+ import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime";
8
+ import { evaluateSupplementalContextVisibility } from "openclaw/plugin-sdk/security-runtime";
9
+ import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
10
+ import type {
11
+ CoreConfig,
12
+ MatrixRoomConfig,
13
+ MatrixStreamingMode,
14
+ ReplyToMode,
15
+ } from "../../types.js";
16
+ import { createMatrixDraftStream } from "../draft-stream.js";
17
+ import { formatMatrixErrorMessage } from "../errors.js";
18
+ import { isMatrixMediaSizeLimitError } from "../media-errors.js";
19
+ import {
20
+ formatMatrixMediaTooLargeText,
21
+ formatMatrixMediaUnavailableText,
22
+ formatMatrixMessageText,
23
+ resolveMatrixMessageAttachment,
24
+ resolveMatrixMessageBody,
25
+ } from "../media-text.js";
26
+ import { fetchMatrixPollSnapshot, type MatrixPollSnapshot } from "../poll-summary.js";
27
+ import {
28
+ formatPollAsText,
29
+ isPollEventType,
30
+ isPollStartType,
31
+ parsePollStartContent,
32
+ } from "../poll-types.js";
33
+ import type { LocationMessageEventContent, MatrixClient } from "../sdk.js";
34
+ import {
35
+ editMessageMatrix,
36
+ reactMatrixMessage,
37
+ sendMessageMatrix,
38
+ sendReadReceiptMatrix,
39
+ sendTypingMatrix,
40
+ } from "../send.js";
41
+ import { MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY } from "../send/types.js";
42
+ import { resolveMatrixStoredSessionMeta } from "../session-store-metadata.js";
43
+ import { resolveMatrixMonitorAccessState } from "./access-state.js";
44
+ import { resolveMatrixAckReactionConfig } from "./ack-config.js";
45
+ import { resolveMatrixAllowListMatch } from "./allowlist.js";
46
+ import type { MatrixInboundEventDeduper } from "./inbound-dedupe.js";
47
+ import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js";
48
+ import { downloadMatrixMedia } from "./media.js";
49
+ import { resolveMentions } from "./mentions.js";
50
+ import { handleInboundMatrixReaction } from "./reaction-events.js";
51
+ import { deliverMatrixReplies } from "./replies.js";
52
+ import { createMatrixReplyContextResolver } from "./reply-context.js";
53
+ import { createRoomHistoryTracker } from "./room-history.js";
54
+ import type { HistoryEntry } from "./room-history.js";
55
+ import { resolveMatrixRoomConfig } from "./rooms.js";
56
+ import { resolveMatrixInboundRoute } from "./route.js";
57
+ import {
58
+ createReplyPrefixOptions,
59
+ createTypingCallbacks,
60
+ ensureConfiguredAcpBindingReady,
61
+ formatAllowlistMatchMeta,
62
+ getAgentScopedMediaLocalRoots,
63
+ logInboundDrop,
64
+ logTypingFailure,
65
+ type BlockReplyContext,
66
+ type PluginRuntime,
67
+ type ReplyPayload,
68
+ type RuntimeEnv,
69
+ type RuntimeLogger,
70
+ } from "./runtime-api.js";
71
+ import { createMatrixThreadContextResolver } from "./thread-context.js";
72
+ import {
73
+ resolveMatrixReplyToEventId,
74
+ resolveMatrixThreadRootId,
75
+ resolveMatrixThreadRouting,
76
+ } from "./threads.js";
77
+ import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js";
78
+ import { EventType, RelationType } from "./types.js";
79
+ import { isMatrixVerificationRoomMessage } from "./verification-utils.js";
80
+
81
+ const ALLOW_FROM_STORE_CACHE_TTL_MS = 30_000;
82
+ const PAIRING_REPLY_COOLDOWN_MS = 5 * 60_000;
83
+ const MAX_TRACKED_PAIRING_REPLY_SENDERS = 512;
84
+ const MAX_TRACKED_SHARED_DM_CONTEXT_NOTICES = 512;
85
+ type MatrixAllowBotsMode = "off" | "mentions" | "all";
86
+
87
+ async function redactMatrixDraftEvent(
88
+ client: MatrixClient,
89
+ roomId: string,
90
+ draftEventId: string,
91
+ ): Promise<void> {
92
+ await client.redactEvent(roomId, draftEventId).catch(() => {});
93
+ }
94
+
95
+ function buildMatrixFinalizedPreviewContent(): Record<string, unknown> {
96
+ return { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true };
97
+ }
98
+
99
+ export type MatrixMonitorHandlerParams = {
100
+ client: MatrixClient;
101
+ core: PluginRuntime;
102
+ cfg: CoreConfig;
103
+ accountId: string;
104
+ runtime: RuntimeEnv;
105
+ logger: RuntimeLogger;
106
+ logVerboseMessage: (message: string) => void;
107
+ allowFrom: string[];
108
+ groupAllowFrom?: string[];
109
+ roomsConfig?: Record<string, MatrixRoomConfig>;
110
+ accountAllowBots?: boolean | "mentions";
111
+ configuredBotUserIds?: ReadonlySet<string>;
112
+ groupPolicy: "open" | "allowlist" | "disabled";
113
+ replyToMode: ReplyToMode;
114
+ threadReplies: "off" | "inbound" | "always";
115
+ /** DM-specific threadReplies override. Falls back to threadReplies when absent. */
116
+ dmThreadReplies?: "off" | "inbound" | "always";
117
+ /** DM session grouping behavior. */
118
+ dmSessionScope?: "per-user" | "per-room";
119
+ streaming: MatrixStreamingMode;
120
+ blockStreamingEnabled: boolean;
121
+ dmEnabled: boolean;
122
+ dmPolicy: "open" | "pairing" | "allowlist" | "disabled";
123
+ textLimit: number;
124
+ mediaMaxBytes: number;
125
+ historyLimit: number;
126
+ startupMs: number;
127
+ startupGraceMs: number;
128
+ dropPreStartupMessages: boolean;
129
+ inboundDeduper?: Pick<MatrixInboundEventDeduper, "claimEvent" | "commitEvent" | "releaseEvent">;
130
+ directTracker: {
131
+ isDirectMessage: (params: {
132
+ roomId: string;
133
+ senderId: string;
134
+ selfUserId: string;
135
+ }) => Promise<boolean>;
136
+ };
137
+ getRoomInfo: (
138
+ roomId: string,
139
+ opts?: { includeAliases?: boolean },
140
+ ) => Promise<{ name?: string; canonicalAlias?: string; altAliases: string[] }>;
141
+ getMemberDisplayName: (roomId: string, userId: string) => Promise<string>;
142
+ needsRoomAliasesForConfig: boolean;
143
+ };
144
+
145
+ function resolveMatrixMentionPrecheckText(params: {
146
+ eventType: string;
147
+ content: RoomMessageEventContent;
148
+ locationText?: string | null;
149
+ }): string {
150
+ if (params.locationText?.trim()) {
151
+ return params.locationText.trim();
152
+ }
153
+ if (typeof params.content.body === "string" && params.content.body.trim()) {
154
+ return params.content.body.trim();
155
+ }
156
+ if (isPollStartType(params.eventType)) {
157
+ const parsed = parsePollStartContent(params.content as never);
158
+ if (parsed) {
159
+ return formatPollAsText(parsed);
160
+ }
161
+ }
162
+ return "";
163
+ }
164
+
165
+ function resolveMatrixInboundBodyText(params: {
166
+ rawBody: string;
167
+ filename?: string;
168
+ mediaPlaceholder?: string;
169
+ msgtype?: string;
170
+ hadMediaUrl: boolean;
171
+ mediaDownloadFailed: boolean;
172
+ mediaSizeLimitExceeded?: boolean;
173
+ }): string {
174
+ if (params.mediaPlaceholder) {
175
+ return params.rawBody || params.mediaPlaceholder;
176
+ }
177
+ if (!params.mediaDownloadFailed || !params.hadMediaUrl) {
178
+ return params.rawBody;
179
+ }
180
+ if (params.mediaSizeLimitExceeded) {
181
+ return formatMatrixMediaTooLargeText({
182
+ body: params.rawBody,
183
+ filename: params.filename,
184
+ msgtype: params.msgtype,
185
+ });
186
+ }
187
+ return formatMatrixMediaUnavailableText({
188
+ body: params.rawBody,
189
+ filename: params.filename,
190
+ msgtype: params.msgtype,
191
+ });
192
+ }
193
+
194
+ function markTrackedRoomIfFirst(set: Set<string>, roomId: string): boolean {
195
+ if (set.has(roomId)) {
196
+ return false;
197
+ }
198
+ set.add(roomId);
199
+ if (set.size > MAX_TRACKED_SHARED_DM_CONTEXT_NOTICES) {
200
+ const oldest = set.keys().next().value;
201
+ if (typeof oldest === "string") {
202
+ set.delete(oldest);
203
+ }
204
+ }
205
+ return true;
206
+ }
207
+
208
+ function resolveMatrixSharedDmContextNotice(params: {
209
+ storePath: string;
210
+ sessionKey: string;
211
+ roomId: string;
212
+ accountId: string;
213
+ dmSessionScope?: "per-user" | "per-room";
214
+ sentRooms: Set<string>;
215
+ logVerboseMessage: (message: string) => void;
216
+ }): string | null {
217
+ if ((params.dmSessionScope ?? "per-user") === "per-room") {
218
+ return null;
219
+ }
220
+ if (params.sentRooms.has(params.roomId)) {
221
+ return null;
222
+ }
223
+
224
+ try {
225
+ const store = loadSessionStore(params.storePath);
226
+ const currentSession = resolveMatrixStoredSessionMeta(
227
+ resolveSessionStoreEntry({
228
+ store,
229
+ sessionKey: params.sessionKey,
230
+ }).existing,
231
+ );
232
+ if (!currentSession) {
233
+ return null;
234
+ }
235
+ if (currentSession.channel && currentSession.channel !== "matrix") {
236
+ return null;
237
+ }
238
+ if (currentSession.accountId && currentSession.accountId !== params.accountId) {
239
+ return null;
240
+ }
241
+ if (!currentSession.directUserId) {
242
+ return null;
243
+ }
244
+ if (!currentSession.roomId || currentSession.roomId === params.roomId) {
245
+ return null;
246
+ }
247
+
248
+ return [
249
+ "This Matrix DM is sharing a session with another Matrix DM room.",
250
+ "Use /focus here for a one-off isolated thread session when thread bindings are enabled, or set",
251
+ "channels.lobi.dm.sessionScope to per-room to isolate each Matrix DM room.",
252
+ ].join(" ");
253
+ } catch (err) {
254
+ params.logVerboseMessage(
255
+ `matrix: failed checking shared DM session notice room=${params.roomId} (${String(err)})`,
256
+ );
257
+ return null;
258
+ }
259
+ }
260
+
261
+ function resolveMatrixPendingHistoryText(params: {
262
+ mentionPrecheckText: string;
263
+ content: RoomMessageEventContent;
264
+ mediaUrl?: string;
265
+ }): string {
266
+ if (params.mentionPrecheckText) {
267
+ return params.mentionPrecheckText;
268
+ }
269
+ if (!params.mediaUrl) {
270
+ return "";
271
+ }
272
+ const body = typeof params.content.body === "string" ? params.content.body.trim() : undefined;
273
+ const filename =
274
+ typeof params.content.filename === "string" ? params.content.filename.trim() : undefined;
275
+ const msgtype = typeof params.content.msgtype === "string" ? params.content.msgtype : undefined;
276
+ return (
277
+ formatMatrixMessageText({
278
+ body: resolveMatrixMessageBody({ body, filename, msgtype }),
279
+ attachment: resolveMatrixMessageAttachment({ body, filename, msgtype }),
280
+ }) ?? ""
281
+ );
282
+ }
283
+
284
+ function resolveMatrixAllowBotsMode(value?: boolean | "mentions"): MatrixAllowBotsMode {
285
+ if (value === true) {
286
+ return "all";
287
+ }
288
+ if (value === "mentions") {
289
+ return "mentions";
290
+ }
291
+ return "off";
292
+ }
293
+
294
+ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) {
295
+ const {
296
+ client,
297
+ core,
298
+ cfg,
299
+ accountId,
300
+ runtime,
301
+ logger,
302
+ logVerboseMessage,
303
+ allowFrom,
304
+ groupAllowFrom = [],
305
+ roomsConfig,
306
+ accountAllowBots,
307
+ configuredBotUserIds = new Set<string>(),
308
+ groupPolicy,
309
+ replyToMode,
310
+ threadReplies,
311
+ dmThreadReplies,
312
+ dmSessionScope,
313
+ streaming,
314
+ blockStreamingEnabled,
315
+ dmEnabled,
316
+ dmPolicy,
317
+ textLimit,
318
+ mediaMaxBytes,
319
+ historyLimit,
320
+ startupMs,
321
+ startupGraceMs,
322
+ dropPreStartupMessages,
323
+ inboundDeduper,
324
+ directTracker,
325
+ getRoomInfo,
326
+ getMemberDisplayName,
327
+ needsRoomAliasesForConfig,
328
+ } = params;
329
+ const contextVisibilityMode = resolveChannelContextVisibilityMode({
330
+ cfg,
331
+ channel: "matrix",
332
+ accountId,
333
+ });
334
+ let cachedStoreAllowFrom: {
335
+ value: string[];
336
+ expiresAtMs: number;
337
+ } | null = null;
338
+ const pairingReplySentAtMsBySender = new Map<string, number>();
339
+ const resolveThreadContext = createMatrixThreadContextResolver({
340
+ client,
341
+ getMemberDisplayName,
342
+ logVerboseMessage,
343
+ });
344
+ const resolveReplyContext = createMatrixReplyContextResolver({
345
+ client,
346
+ getMemberDisplayName,
347
+ logVerboseMessage,
348
+ });
349
+ const roomHistoryTracker = createRoomHistoryTracker();
350
+ const roomIngressTails = new Map<string, Promise<void>>();
351
+ const sharedDmContextNoticeRooms = new Set<string>();
352
+
353
+ const readStoreAllowFrom = async (): Promise<string[]> => {
354
+ const now = Date.now();
355
+ if (cachedStoreAllowFrom && now < cachedStoreAllowFrom.expiresAtMs) {
356
+ return cachedStoreAllowFrom.value;
357
+ }
358
+ const value = await core.channel.pairing
359
+ .readAllowFromStore({
360
+ channel: "matrix",
361
+ env: process.env,
362
+ accountId,
363
+ })
364
+ .catch(() => []);
365
+ cachedStoreAllowFrom = {
366
+ value,
367
+ expiresAtMs: now + ALLOW_FROM_STORE_CACHE_TTL_MS,
368
+ };
369
+ return value;
370
+ };
371
+
372
+ const shouldSendPairingReply = (senderId: string, created: boolean): boolean => {
373
+ const now = Date.now();
374
+ if (created) {
375
+ pairingReplySentAtMsBySender.set(senderId, now);
376
+ return true;
377
+ }
378
+ const lastSentAtMs = pairingReplySentAtMsBySender.get(senderId);
379
+ if (typeof lastSentAtMs === "number" && now - lastSentAtMs < PAIRING_REPLY_COOLDOWN_MS) {
380
+ return false;
381
+ }
382
+ pairingReplySentAtMsBySender.set(senderId, now);
383
+ if (pairingReplySentAtMsBySender.size > MAX_TRACKED_PAIRING_REPLY_SENDERS) {
384
+ const oldestSender = pairingReplySentAtMsBySender.keys().next().value;
385
+ if (typeof oldestSender === "string") {
386
+ pairingReplySentAtMsBySender.delete(oldestSender);
387
+ }
388
+ }
389
+ return true;
390
+ };
391
+
392
+ const runRoomIngress = async <T>(roomId: string, task: () => Promise<T>): Promise<T> => {
393
+ const previous = roomIngressTails.get(roomId) ?? Promise.resolve();
394
+ let releaseCurrent!: () => void;
395
+ const current = new Promise<void>((resolve) => {
396
+ releaseCurrent = resolve;
397
+ });
398
+ const chain = previous.catch(() => {}).then(() => current);
399
+ roomIngressTails.set(roomId, chain);
400
+ await previous.catch(() => {});
401
+ try {
402
+ return await task();
403
+ } finally {
404
+ releaseCurrent();
405
+ if (roomIngressTails.get(roomId) === chain) {
406
+ roomIngressTails.delete(roomId);
407
+ }
408
+ }
409
+ };
410
+
411
+ return async (roomId: string, event: MatrixRawEvent) => {
412
+ const eventId = typeof event.event_id === "string" ? event.event_id.trim() : "";
413
+ let claimedInboundEvent = false;
414
+ let draftStreamRef: ReturnType<typeof createMatrixDraftStream> | undefined;
415
+ let draftConsumed = false;
416
+ try {
417
+ const eventType = event.type;
418
+ if (eventType === EventType.RoomMessageEncrypted) {
419
+ // Encrypted payloads are emitted separately after decryption.
420
+ return;
421
+ }
422
+
423
+ const isPollEvent = isPollEventType(eventType);
424
+ const isReactionEvent = eventType === EventType.Reaction;
425
+ const locationContent = event.content as LocationMessageEventContent;
426
+ const isLocationEvent =
427
+ eventType === EventType.Location ||
428
+ (eventType === EventType.RoomMessage && locationContent.msgtype === EventType.Location);
429
+ if (
430
+ eventType !== EventType.RoomMessage &&
431
+ !isPollEvent &&
432
+ !isLocationEvent &&
433
+ !isReactionEvent
434
+ ) {
435
+ return;
436
+ }
437
+ logVerboseMessage(
438
+ `matrix: inbound event room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`,
439
+ );
440
+ if (event.unsigned?.redacted_because) {
441
+ return;
442
+ }
443
+ const senderId = event.sender;
444
+ if (!senderId) {
445
+ return;
446
+ }
447
+ const eventTs = event.origin_server_ts;
448
+ const eventAge = event.unsigned?.age;
449
+ const commitInboundEventIfClaimed = async () => {
450
+ if (!claimedInboundEvent || !inboundDeduper || !eventId) {
451
+ return;
452
+ }
453
+ await inboundDeduper.commitEvent({ roomId, eventId });
454
+ claimedInboundEvent = false;
455
+ };
456
+ const readIngressPrefix = async () => {
457
+ const selfUserId = await client.getUserId();
458
+ if (senderId === selfUserId) {
459
+ return undefined;
460
+ }
461
+ if (dropPreStartupMessages) {
462
+ if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) {
463
+ return undefined;
464
+ }
465
+ if (
466
+ typeof eventTs !== "number" &&
467
+ typeof eventAge === "number" &&
468
+ eventAge > startupGraceMs
469
+ ) {
470
+ return undefined;
471
+ }
472
+ }
473
+
474
+ let content = event.content as RoomMessageEventContent;
475
+
476
+ if (
477
+ eventType === EventType.RoomMessage &&
478
+ isMatrixVerificationRoomMessage({
479
+ msgtype: (content as { msgtype?: unknown }).msgtype,
480
+ body: content.body,
481
+ })
482
+ ) {
483
+ logVerboseMessage(`matrix: skip verification/system room message room=${roomId}`);
484
+ return undefined;
485
+ }
486
+
487
+ const locationPayload: MatrixLocationPayload | null = resolveMatrixLocation({
488
+ eventType,
489
+ content: content as LocationMessageEventContent,
490
+ });
491
+
492
+ const relates = content["m.relates_to"];
493
+ if (relates && "rel_type" in relates && relates.rel_type === RelationType.Replace) {
494
+ return undefined;
495
+ }
496
+ if (eventId && inboundDeduper) {
497
+ claimedInboundEvent = inboundDeduper.claimEvent({ roomId, eventId });
498
+ if (!claimedInboundEvent) {
499
+ logVerboseMessage(`matrix: skip duplicate inbound event room=${roomId} id=${eventId}`);
500
+ return undefined;
501
+ }
502
+ }
503
+
504
+ const isDirectMessage = await directTracker.isDirectMessage({
505
+ roomId,
506
+ senderId,
507
+ selfUserId,
508
+ });
509
+ return { content, isDirectMessage, locationPayload, selfUserId };
510
+ };
511
+ const continueIngress = async (params: {
512
+ content: RoomMessageEventContent;
513
+ isDirectMessage: boolean;
514
+ locationPayload: MatrixLocationPayload | null;
515
+ selfUserId: string;
516
+ }) => {
517
+ let content = params.content;
518
+ const isDirectMessage = params.isDirectMessage;
519
+ const isRoom = !isDirectMessage;
520
+ const { locationPayload, selfUserId } = params;
521
+ if (isRoom && groupPolicy === "disabled") {
522
+ await commitInboundEventIfClaimed();
523
+ return undefined;
524
+ }
525
+
526
+ const roomInfoForConfig =
527
+ isRoom && needsRoomAliasesForConfig
528
+ ? await getRoomInfo(roomId, { includeAliases: true })
529
+ : undefined;
530
+ const roomAliasesForConfig = roomInfoForConfig
531
+ ? [roomInfoForConfig.canonicalAlias ?? "", ...roomInfoForConfig.altAliases].filter(
532
+ Boolean,
533
+ )
534
+ : [];
535
+ const roomConfigInfo = isRoom
536
+ ? resolveMatrixRoomConfig({
537
+ rooms: roomsConfig,
538
+ roomId,
539
+ aliases: roomAliasesForConfig,
540
+ })
541
+ : undefined;
542
+ const roomConfig = roomConfigInfo?.config;
543
+ const allowBotsMode = resolveMatrixAllowBotsMode(roomConfig?.allowBots ?? accountAllowBots);
544
+ const isConfiguredBotSender = configuredBotUserIds.has(senderId);
545
+ const roomMatchMeta = roomConfigInfo
546
+ ? `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${
547
+ roomConfigInfo.matchSource ?? "none"
548
+ }`
549
+ : "matchKey=none matchSource=none";
550
+
551
+ if (isConfiguredBotSender && allowBotsMode === "off") {
552
+ logVerboseMessage(
553
+ `matrix: drop configured bot sender=${senderId} (allowBots=false${isDirectMessage ? "" : `, ${roomMatchMeta}`})`,
554
+ );
555
+ await commitInboundEventIfClaimed();
556
+ return undefined;
557
+ }
558
+
559
+ if (isRoom && roomConfig && !roomConfigInfo?.allowed) {
560
+ logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
561
+ await commitInboundEventIfClaimed();
562
+ return undefined;
563
+ }
564
+ if (isRoom && groupPolicy === "allowlist") {
565
+ if (!roomConfigInfo?.allowlistConfigured) {
566
+ logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
567
+ await commitInboundEventIfClaimed();
568
+ return undefined;
569
+ }
570
+ if (!roomConfig) {
571
+ logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
572
+ await commitInboundEventIfClaimed();
573
+ return undefined;
574
+ }
575
+ }
576
+
577
+ let senderNamePromise: Promise<string> | null = null;
578
+ const getSenderName = async (): Promise<string> => {
579
+ senderNamePromise ??= getMemberDisplayName(roomId, senderId).catch(() => senderId);
580
+ return await senderNamePromise;
581
+ };
582
+ const storeAllowFrom = await readStoreAllowFrom();
583
+ const roomUsers = roomConfig?.users ?? [];
584
+ const accessState = resolveMatrixMonitorAccessState({
585
+ allowFrom,
586
+ storeAllowFrom,
587
+ groupAllowFrom,
588
+ roomUsers,
589
+ senderId,
590
+ isRoom,
591
+ });
592
+ const {
593
+ effectiveGroupAllowFrom,
594
+ effectiveRoomUsers,
595
+ groupAllowConfigured,
596
+ directAllowMatch,
597
+ roomUserMatch,
598
+ groupAllowMatch,
599
+ commandAuthorizers,
600
+ } = accessState;
601
+
602
+ if (isDirectMessage) {
603
+ if (!dmEnabled || dmPolicy === "disabled") {
604
+ await commitInboundEventIfClaimed();
605
+ return undefined;
606
+ }
607
+ if (dmPolicy !== "open") {
608
+ const allowMatchMeta = formatAllowlistMatchMeta(directAllowMatch);
609
+ if (!directAllowMatch.allowed) {
610
+ if (!isReactionEvent && dmPolicy === "pairing") {
611
+ const senderName = await getSenderName();
612
+ const { code, created } = await core.channel.pairing.upsertPairingRequest({
613
+ channel: "matrix",
614
+ id: senderId,
615
+ accountId,
616
+ meta: { name: senderName },
617
+ });
618
+ if (shouldSendPairingReply(senderId, created)) {
619
+ const pairingReply = core.channel.pairing.buildPairingReply({
620
+ channel: "matrix",
621
+ idLine: `Your Matrix user id: ${senderId}`,
622
+ code,
623
+ });
624
+ logVerboseMessage(
625
+ created
626
+ ? `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`
627
+ : `matrix pairing reminder sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
628
+ );
629
+ try {
630
+ await sendMessageMatrix(
631
+ `room:${roomId}`,
632
+ created
633
+ ? pairingReply
634
+ : `${pairingReply}\n\nPairing request is still pending approval. Reusing existing code.`,
635
+ {
636
+ client,
637
+ cfg,
638
+ accountId,
639
+ },
640
+ );
641
+ await commitInboundEventIfClaimed();
642
+ } catch (err) {
643
+ logVerboseMessage(
644
+ `matrix pairing reply failed for ${senderId}: ${String(err)}`,
645
+ );
646
+ return undefined;
647
+ }
648
+ } else {
649
+ logVerboseMessage(
650
+ `matrix pairing reminder suppressed sender=${senderId} (cooldown)`,
651
+ );
652
+ await commitInboundEventIfClaimed();
653
+ }
654
+ }
655
+ if (isReactionEvent || dmPolicy !== "pairing") {
656
+ logVerboseMessage(
657
+ `matrix: blocked ${isReactionEvent ? "reaction" : "dm"} sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
658
+ );
659
+ await commitInboundEventIfClaimed();
660
+ }
661
+ return undefined;
662
+ }
663
+ }
664
+ }
665
+
666
+ if (isRoom && roomUserMatch && !roomUserMatch.allowed) {
667
+ logVerboseMessage(
668
+ `matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
669
+ roomUserMatch,
670
+ )})`,
671
+ );
672
+ await commitInboundEventIfClaimed();
673
+ return undefined;
674
+ }
675
+ if (
676
+ isRoom &&
677
+ groupPolicy === "allowlist" &&
678
+ effectiveRoomUsers.length === 0 &&
679
+ groupAllowConfigured &&
680
+ groupAllowMatch &&
681
+ !groupAllowMatch.allowed
682
+ ) {
683
+ logVerboseMessage(
684
+ `matrix: blocked sender ${senderId} (groupAllowFrom, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
685
+ groupAllowMatch,
686
+ )})`,
687
+ );
688
+ await commitInboundEventIfClaimed();
689
+ return undefined;
690
+ }
691
+ if (isRoom) {
692
+ logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`);
693
+ }
694
+
695
+ if (isReactionEvent) {
696
+ const senderName = await getSenderName();
697
+ await handleInboundMatrixReaction({
698
+ client,
699
+ core,
700
+ cfg,
701
+ accountId,
702
+ roomId,
703
+ event,
704
+ senderId,
705
+ senderLabel: senderName,
706
+ selfUserId,
707
+ isDirectMessage,
708
+ logVerboseMessage,
709
+ });
710
+ await commitInboundEventIfClaimed();
711
+ return undefined;
712
+ }
713
+
714
+ let pollSnapshotPromise: Promise<MatrixPollSnapshot | null> | null = null;
715
+ const getPollSnapshot = async (): Promise<MatrixPollSnapshot | null> => {
716
+ if (!isPollEvent) {
717
+ return null;
718
+ }
719
+ pollSnapshotPromise ??= fetchMatrixPollSnapshot(client, roomId, event).catch((err) => {
720
+ logVerboseMessage(
721
+ `matrix: failed resolving poll snapshot room=${roomId} id=${event.event_id ?? "unknown"}: ${String(err)}`,
722
+ );
723
+ return null;
724
+ });
725
+ return await pollSnapshotPromise;
726
+ };
727
+
728
+ const mentionPrecheckText = resolveMatrixMentionPrecheckText({
729
+ eventType,
730
+ content,
731
+ locationText: locationPayload?.text,
732
+ });
733
+ const contentUrl =
734
+ "url" in content && typeof content.url === "string" ? content.url : undefined;
735
+ const contentFile =
736
+ "file" in content && content.file && typeof content.file === "object"
737
+ ? content.file
738
+ : undefined;
739
+ const mediaUrl = contentUrl ?? contentFile?.url;
740
+ const pendingHistoryText = resolveMatrixPendingHistoryText({
741
+ mentionPrecheckText,
742
+ content,
743
+ mediaUrl,
744
+ });
745
+ const pendingHistoryPollText =
746
+ !pendingHistoryText && isPollEvent && historyLimit > 0
747
+ ? (await getPollSnapshot())?.text
748
+ : "";
749
+ if (!mentionPrecheckText && !mediaUrl && !isPollEvent) {
750
+ await commitInboundEventIfClaimed();
751
+ return undefined;
752
+ }
753
+
754
+ const _messageId = event.event_id ?? "";
755
+ const _threadRootId = resolveMatrixThreadRootId({ event, content });
756
+ const thread = resolveMatrixThreadRouting({
757
+ isDirectMessage,
758
+ threadReplies,
759
+ dmThreadReplies,
760
+ messageId: _messageId,
761
+ threadRootId: _threadRootId,
762
+ });
763
+ const {
764
+ route: _route,
765
+ configuredBinding: _configuredBinding,
766
+ runtimeBindingId: _runtimeBindingId,
767
+ } = resolveMatrixInboundRoute({
768
+ cfg,
769
+ accountId,
770
+ roomId,
771
+ senderId,
772
+ isDirectMessage,
773
+ dmSessionScope,
774
+ threadId: thread.threadId,
775
+ eventTs: eventTs ?? undefined,
776
+ resolveAgentRoute: core.channel.routing.resolveAgentRoute,
777
+ });
778
+ const hasExplicitSessionBinding = _configuredBinding !== null || _runtimeBindingId !== null;
779
+ const agentMentionRegexes = core.channel.mentions.buildMentionRegexes(cfg, _route.agentId);
780
+ const selfDisplayName = content.formatted_body
781
+ ? await getMemberDisplayName(roomId, selfUserId).catch(() => undefined)
782
+ : undefined;
783
+ const { wasMentioned, hasExplicitMention } = resolveMentions({
784
+ content,
785
+ userId: selfUserId,
786
+ displayName: selfDisplayName,
787
+ text: mentionPrecheckText,
788
+ mentionRegexes: agentMentionRegexes,
789
+ });
790
+ if (
791
+ isConfiguredBotSender &&
792
+ allowBotsMode === "mentions" &&
793
+ !isDirectMessage &&
794
+ !wasMentioned
795
+ ) {
796
+ logVerboseMessage(
797
+ `matrix: drop configured bot sender=${senderId} (allowBots=mentions, missing mention, ${roomMatchMeta})`,
798
+ );
799
+ await commitInboundEventIfClaimed();
800
+ return undefined;
801
+ }
802
+ const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
803
+ cfg,
804
+ surface: "matrix",
805
+ });
806
+ const useAccessGroups = cfg.commands?.useAccessGroups !== false;
807
+ const hasControlCommandInMessage = core.channel.text.hasControlCommand(
808
+ mentionPrecheckText,
809
+ cfg,
810
+ );
811
+ const commandGate = resolveControlCommandGate({
812
+ useAccessGroups,
813
+ authorizers: commandAuthorizers,
814
+ allowTextCommands,
815
+ hasControlCommand: hasControlCommandInMessage,
816
+ });
817
+ const commandAuthorized = commandGate.commandAuthorized;
818
+ if (isRoom && commandGate.shouldBlock) {
819
+ logInboundDrop({
820
+ log: logVerboseMessage,
821
+ channel: "matrix",
822
+ reason: "control command (unauthorized)",
823
+ target: senderId,
824
+ });
825
+ await commitInboundEventIfClaimed();
826
+ return undefined;
827
+ }
828
+ const shouldRequireMention = isRoom
829
+ ? roomConfig?.autoReply === true
830
+ ? false
831
+ : roomConfig?.autoReply === false
832
+ ? true
833
+ : typeof roomConfig?.requireMention === "boolean"
834
+ ? roomConfig?.requireMention
835
+ : true
836
+ : false;
837
+ const shouldBypassMention =
838
+ allowTextCommands &&
839
+ isRoom &&
840
+ shouldRequireMention &&
841
+ !wasMentioned &&
842
+ !hasExplicitMention &&
843
+ commandAuthorized &&
844
+ hasControlCommandInMessage;
845
+ const canDetectMention = agentMentionRegexes.length > 0 || hasExplicitMention;
846
+ if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
847
+ const pendingHistoryBody = pendingHistoryText || pendingHistoryPollText;
848
+ if (historyLimit > 0 && pendingHistoryBody) {
849
+ const pendingEntry: HistoryEntry = {
850
+ sender: senderId,
851
+ body: pendingHistoryBody,
852
+ timestamp: eventTs ?? undefined,
853
+ messageId: _messageId,
854
+ };
855
+ roomHistoryTracker.recordPending(roomId, pendingEntry);
856
+ }
857
+ logger.info("skipping room message", { roomId, reason: "no-mention" });
858
+ await commitInboundEventIfClaimed();
859
+ return undefined;
860
+ }
861
+
862
+ if (isPollEvent) {
863
+ const pollSnapshot = await getPollSnapshot();
864
+ if (!pollSnapshot) {
865
+ return undefined;
866
+ }
867
+ content = {
868
+ msgtype: "m.text",
869
+ body: pollSnapshot.text,
870
+ } as unknown as RoomMessageEventContent;
871
+ }
872
+
873
+ let media: {
874
+ path: string;
875
+ contentType?: string;
876
+ placeholder: string;
877
+ } | null = null;
878
+ let mediaDownloadFailed = false;
879
+ let mediaSizeLimitExceeded = false;
880
+ const finalContentUrl =
881
+ "url" in content && typeof content.url === "string" ? content.url : undefined;
882
+ const finalContentFile =
883
+ "file" in content && content.file && typeof content.file === "object"
884
+ ? content.file
885
+ : undefined;
886
+ const finalMediaUrl = finalContentUrl ?? finalContentFile?.url;
887
+ const contentBody = typeof content.body === "string" ? content.body.trim() : "";
888
+ const contentFilename = typeof content.filename === "string" ? content.filename.trim() : "";
889
+ const originalFilename = contentFilename || contentBody || undefined;
890
+ const contentInfo =
891
+ "info" in content && content.info && typeof content.info === "object"
892
+ ? (content.info as { mimetype?: string; size?: number })
893
+ : undefined;
894
+ const contentType = contentInfo?.mimetype;
895
+ const contentSize = typeof contentInfo?.size === "number" ? contentInfo.size : undefined;
896
+ if (finalMediaUrl?.startsWith("mxc://")) {
897
+ try {
898
+ media = await downloadMatrixMedia({
899
+ client,
900
+ mxcUrl: finalMediaUrl,
901
+ contentType,
902
+ sizeBytes: contentSize,
903
+ maxBytes: mediaMaxBytes,
904
+ file: finalContentFile,
905
+ originalFilename,
906
+ });
907
+ } catch (err) {
908
+ mediaDownloadFailed = true;
909
+ if (isMatrixMediaSizeLimitError(err)) {
910
+ mediaSizeLimitExceeded = true;
911
+ }
912
+ const errorText = formatMatrixErrorMessage(err);
913
+ logVerboseMessage(
914
+ `matrix: media download failed room=${roomId} id=${event.event_id ?? "unknown"} type=${content.msgtype} error=${errorText}`,
915
+ );
916
+ logger.warn("matrix media download failed", {
917
+ roomId,
918
+ eventId: event.event_id,
919
+ msgtype: content.msgtype,
920
+ encrypted: Boolean(finalContentFile),
921
+ error: errorText,
922
+ });
923
+ }
924
+ }
925
+
926
+ const rawBody = locationPayload?.text ?? contentBody;
927
+ const bodyText = resolveMatrixInboundBodyText({
928
+ rawBody,
929
+ filename: typeof content.filename === "string" ? content.filename : undefined,
930
+ mediaPlaceholder: media?.placeholder,
931
+ msgtype: content.msgtype,
932
+ hadMediaUrl: Boolean(finalMediaUrl),
933
+ mediaDownloadFailed,
934
+ mediaSizeLimitExceeded,
935
+ });
936
+ if (!bodyText) {
937
+ await commitInboundEventIfClaimed();
938
+ return undefined;
939
+ }
940
+ const senderName = await getSenderName();
941
+ if (_configuredBinding) {
942
+ const ensured = await ensureConfiguredAcpBindingReady({
943
+ cfg,
944
+ configuredBinding: _configuredBinding,
945
+ });
946
+ if (!ensured.ok) {
947
+ logInboundDrop({
948
+ log: logVerboseMessage,
949
+ channel: "matrix",
950
+ reason: "configured ACP binding unavailable",
951
+ target: _configuredBinding.spec.conversationId,
952
+ });
953
+ return undefined;
954
+ }
955
+ }
956
+ if (_runtimeBindingId) {
957
+ getSessionBindingService().touch(_runtimeBindingId, eventTs ?? undefined);
958
+ }
959
+ const preparedTrigger =
960
+ isRoom && historyLimit > 0
961
+ ? roomHistoryTracker.prepareTrigger(_route.agentId, roomId, historyLimit, {
962
+ sender: senderName,
963
+ body: bodyText,
964
+ timestamp: eventTs ?? undefined,
965
+ messageId: _messageId,
966
+ })
967
+ : undefined;
968
+ const inboundHistory = preparedTrigger?.history;
969
+ const triggerSnapshot = preparedTrigger;
970
+
971
+ return {
972
+ route: _route,
973
+ hasExplicitSessionBinding,
974
+ roomConfig,
975
+ isDirectMessage,
976
+ isRoom,
977
+ shouldRequireMention,
978
+ wasMentioned,
979
+ shouldBypassMention,
980
+ canDetectMention,
981
+ commandAuthorized,
982
+ inboundHistory,
983
+ senderName,
984
+ bodyText,
985
+ media,
986
+ locationPayload,
987
+ messageId: _messageId,
988
+ triggerSnapshot,
989
+ threadRootId: _threadRootId,
990
+ thread,
991
+ effectiveGroupAllowFrom,
992
+ effectiveRoomUsers,
993
+ };
994
+ };
995
+ const ingressResult =
996
+ historyLimit > 0
997
+ ? await runRoomIngress(roomId, async () => {
998
+ const prefix = await readIngressPrefix();
999
+ if (!prefix) {
1000
+ return undefined;
1001
+ }
1002
+ if (prefix.isDirectMessage) {
1003
+ return { deferredPrefix: prefix } as const;
1004
+ }
1005
+ return { ingressResult: await continueIngress(prefix) } as const;
1006
+ })
1007
+ : undefined;
1008
+ const resolvedIngressResult =
1009
+ historyLimit > 0
1010
+ ? ingressResult?.deferredPrefix
1011
+ ? await continueIngress(ingressResult.deferredPrefix)
1012
+ : ingressResult?.ingressResult
1013
+ : await (async () => {
1014
+ const prefix = await readIngressPrefix();
1015
+ if (!prefix) {
1016
+ return undefined;
1017
+ }
1018
+ return await continueIngress(prefix);
1019
+ })();
1020
+ if (!resolvedIngressResult) {
1021
+ return;
1022
+ }
1023
+
1024
+ const {
1025
+ route: _route,
1026
+ hasExplicitSessionBinding,
1027
+ roomConfig,
1028
+ isDirectMessage,
1029
+ isRoom,
1030
+ shouldRequireMention,
1031
+ wasMentioned,
1032
+ shouldBypassMention,
1033
+ canDetectMention,
1034
+ commandAuthorized,
1035
+ inboundHistory,
1036
+ senderName,
1037
+ bodyText,
1038
+ media,
1039
+ locationPayload,
1040
+ messageId: _messageId,
1041
+ triggerSnapshot,
1042
+ threadRootId: _threadRootId,
1043
+ thread,
1044
+ effectiveGroupAllowFrom,
1045
+ effectiveRoomUsers,
1046
+ } = resolvedIngressResult;
1047
+
1048
+ // Keep the per-room ingress gate focused on ordering-sensitive state updates.
1049
+ // Prompt/session enrichment below can run concurrently after the history snapshot is fixed.
1050
+ const replyToEventId = resolveMatrixReplyToEventId(event.content as RoomMessageEventContent);
1051
+ const threadTarget = thread.threadId;
1052
+ const isRoomContextSenderAllowed = (contextSenderId?: string): boolean => {
1053
+ if (!isRoom || !contextSenderId) {
1054
+ return true;
1055
+ }
1056
+ if (effectiveRoomUsers.length > 0) {
1057
+ return resolveMatrixAllowListMatch({
1058
+ allowList: effectiveRoomUsers,
1059
+ userId: contextSenderId,
1060
+ }).allowed;
1061
+ }
1062
+ if (groupPolicy === "allowlist" && effectiveGroupAllowFrom.length > 0) {
1063
+ return resolveMatrixAllowListMatch({
1064
+ allowList: effectiveGroupAllowFrom,
1065
+ userId: contextSenderId,
1066
+ }).allowed;
1067
+ }
1068
+ return true;
1069
+ };
1070
+ const shouldIncludeRoomContextSender = (
1071
+ kind: "thread" | "quote" | "history",
1072
+ contextSenderId?: string,
1073
+ ): boolean =>
1074
+ evaluateSupplementalContextVisibility({
1075
+ mode: contextVisibilityMode,
1076
+ kind,
1077
+ senderAllowed: isRoomContextSenderAllowed(contextSenderId),
1078
+ }).include;
1079
+ let threadContext = _threadRootId
1080
+ ? await resolveThreadContext({ roomId, threadRootId: _threadRootId })
1081
+ : undefined;
1082
+ let threadContextBlockedByPolicy = false;
1083
+ if (
1084
+ threadContext?.senderId &&
1085
+ !shouldIncludeRoomContextSender("thread", threadContext.senderId)
1086
+ ) {
1087
+ logVerboseMessage(`matrix: drop thread root context (mode=${contextVisibilityMode})`);
1088
+ threadContextBlockedByPolicy = true;
1089
+ threadContext = undefined;
1090
+ }
1091
+ let replyContext: Awaited<ReturnType<typeof resolveReplyContext>> | undefined;
1092
+ if (replyToEventId && replyToEventId === _threadRootId && threadContext?.summary) {
1093
+ replyContext = {
1094
+ replyToBody: threadContext.summary,
1095
+ replyToSender: threadContext.senderLabel,
1096
+ replyToSenderId: threadContext.senderId,
1097
+ };
1098
+ } else if (
1099
+ replyToEventId &&
1100
+ replyToEventId === _threadRootId &&
1101
+ threadContextBlockedByPolicy
1102
+ ) {
1103
+ replyContext = await resolveReplyContext({ roomId, eventId: replyToEventId });
1104
+ } else {
1105
+ replyContext = replyToEventId
1106
+ ? await resolveReplyContext({ roomId, eventId: replyToEventId })
1107
+ : undefined;
1108
+ }
1109
+ if (
1110
+ replyContext?.replyToSenderId &&
1111
+ !shouldIncludeRoomContextSender("quote", replyContext.replyToSenderId)
1112
+ ) {
1113
+ logVerboseMessage(`matrix: drop reply context (mode=${contextVisibilityMode})`);
1114
+ replyContext = undefined;
1115
+ }
1116
+ const roomInfo = isRoom ? await getRoomInfo(roomId) : undefined;
1117
+ const roomName = roomInfo?.name;
1118
+ const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
1119
+ const textWithId = `${bodyText}\n[matrix event id: ${_messageId} room: ${roomId}]`;
1120
+ const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
1121
+ agentId: _route.agentId,
1122
+ });
1123
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
1124
+ const previousTimestamp = core.channel.session.readSessionUpdatedAt({
1125
+ storePath,
1126
+ sessionKey: _route.sessionKey,
1127
+ });
1128
+ const sharedDmNoticeSessionKey = threadTarget
1129
+ ? _route.mainSessionKey || _route.sessionKey
1130
+ : _route.sessionKey;
1131
+ const sharedDmContextNotice = isDirectMessage
1132
+ ? hasExplicitSessionBinding
1133
+ ? null
1134
+ : resolveMatrixSharedDmContextNotice({
1135
+ storePath,
1136
+ sessionKey: sharedDmNoticeSessionKey,
1137
+ roomId,
1138
+ accountId: _route.accountId,
1139
+ dmSessionScope,
1140
+ sentRooms: sharedDmContextNoticeRooms,
1141
+ logVerboseMessage,
1142
+ })
1143
+ : null;
1144
+ const body = core.channel.reply.formatAgentEnvelope({
1145
+ channel: "Matrix",
1146
+ from: envelopeFrom,
1147
+ timestamp: eventTs ?? undefined,
1148
+ previousTimestamp,
1149
+ envelope: envelopeOptions,
1150
+ body: textWithId,
1151
+ });
1152
+ const groupSystemPrompt = normalizeOptionalString(roomConfig?.systemPrompt);
1153
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
1154
+ Body: body,
1155
+ RawBody: bodyText,
1156
+ CommandBody: bodyText,
1157
+ InboundHistory: inboundHistory && inboundHistory.length > 0 ? inboundHistory : undefined,
1158
+ From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`,
1159
+ To: `room:${roomId}`,
1160
+ SessionKey: _route.sessionKey,
1161
+ AccountId: _route.accountId,
1162
+ ChatType: isDirectMessage ? "direct" : "channel",
1163
+ ConversationLabel: envelopeFrom,
1164
+ SenderName: senderName,
1165
+ SenderId: senderId,
1166
+ SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""),
1167
+ GroupSubject: isRoom ? (roomName ?? roomId) : undefined,
1168
+ GroupId: isRoom ? roomId : undefined,
1169
+ GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined,
1170
+ Provider: "matrix" as const,
1171
+ Surface: "matrix" as const,
1172
+ WasMentioned: isRoom ? wasMentioned : undefined,
1173
+ MessageSid: _messageId,
1174
+ ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined),
1175
+ ReplyToBody: replyContext?.replyToBody,
1176
+ ReplyToSender: replyContext?.replyToSender,
1177
+ MessageThreadId: threadTarget,
1178
+ ThreadStarterBody: threadContext?.threadStarterBody,
1179
+ Timestamp: eventTs ?? undefined,
1180
+ MediaPath: media?.path,
1181
+ MediaType: media?.contentType,
1182
+ MediaUrl: media?.path,
1183
+ ...locationPayload?.context,
1184
+ CommandAuthorized: commandAuthorized,
1185
+ CommandSource: "text" as const,
1186
+ NativeChannelId: roomId,
1187
+ NativeDirectUserId: isDirectMessage ? senderId : undefined,
1188
+ OriginatingChannel: "matrix" as const,
1189
+ OriginatingTo: `room:${roomId}`,
1190
+ });
1191
+
1192
+ await core.channel.session.recordInboundSession({
1193
+ storePath,
1194
+ sessionKey: ctxPayload.SessionKey ?? _route.sessionKey,
1195
+ ctx: ctxPayload,
1196
+ updateLastRoute: isDirectMessage
1197
+ ? {
1198
+ sessionKey: _route.mainSessionKey,
1199
+ channel: "matrix",
1200
+ to: `room:${roomId}`,
1201
+ accountId: _route.accountId,
1202
+ }
1203
+ : undefined,
1204
+ onRecordError: (err) => {
1205
+ logger.warn("failed updating session meta", {
1206
+ error: String(err),
1207
+ storePath,
1208
+ sessionKey: ctxPayload.SessionKey ?? _route.sessionKey,
1209
+ });
1210
+ },
1211
+ });
1212
+
1213
+ if (sharedDmContextNotice && markTrackedRoomIfFirst(sharedDmContextNoticeRooms, roomId)) {
1214
+ client
1215
+ .sendMessage(roomId, {
1216
+ msgtype: "m.notice",
1217
+ body: sharedDmContextNotice,
1218
+ })
1219
+ .catch((err) => {
1220
+ logVerboseMessage(
1221
+ `matrix: failed sending shared DM session notice room=${roomId}: ${String(err)}`,
1222
+ );
1223
+ });
1224
+ }
1225
+
1226
+ const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
1227
+ logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
1228
+
1229
+ const replyTarget = ctxPayload.To;
1230
+ if (!replyTarget) {
1231
+ runtime.error?.("matrix: missing reply target");
1232
+ return;
1233
+ }
1234
+
1235
+ const { ackReaction, ackReactionScope: ackScope } = resolveMatrixAckReactionConfig({
1236
+ cfg,
1237
+ agentId: _route.agentId,
1238
+ accountId,
1239
+ });
1240
+ const shouldAckReaction = () =>
1241
+ Boolean(
1242
+ ackReaction &&
1243
+ core.channel.reactions.shouldAckReaction({
1244
+ scope: ackScope,
1245
+ isDirect: isDirectMessage,
1246
+ isGroup: isRoom,
1247
+ isMentionableGroup: isRoom,
1248
+ requireMention: shouldRequireMention,
1249
+ canDetectMention,
1250
+ effectiveWasMentioned: wasMentioned || shouldBypassMention,
1251
+ shouldBypassMention,
1252
+ }),
1253
+ );
1254
+ if (shouldAckReaction() && _messageId) {
1255
+ reactMatrixMessage(roomId, _messageId, ackReaction, client).catch((err) => {
1256
+ logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
1257
+ });
1258
+ }
1259
+
1260
+ if (_messageId) {
1261
+ sendReadReceiptMatrix(roomId, _messageId, client).catch((err) => {
1262
+ logVerboseMessage(
1263
+ `matrix: read receipt failed room=${roomId} id=${_messageId}: ${String(err)}`,
1264
+ );
1265
+ });
1266
+ }
1267
+
1268
+ const tableMode = core.channel.text.resolveMarkdownTableMode({
1269
+ cfg,
1270
+ channel: "matrix",
1271
+ accountId: _route.accountId,
1272
+ });
1273
+ const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, _route.agentId);
1274
+ let finalReplyDeliveryFailed = false;
1275
+ let nonFinalReplyDeliveryFailed = false;
1276
+ const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
1277
+ cfg,
1278
+ agentId: _route.agentId,
1279
+ channel: "matrix",
1280
+ accountId: _route.accountId,
1281
+ });
1282
+ const typingCallbacks = createTypingCallbacks({
1283
+ start: () => sendTypingMatrix(roomId, true, undefined, client),
1284
+ stop: () => sendTypingMatrix(roomId, false, undefined, client),
1285
+ onStartError: (err) => {
1286
+ logTypingFailure({
1287
+ log: logVerboseMessage,
1288
+ channel: "matrix",
1289
+ action: "start",
1290
+ target: roomId,
1291
+ error: err,
1292
+ });
1293
+ },
1294
+ onStopError: (err) => {
1295
+ logTypingFailure({
1296
+ log: logVerboseMessage,
1297
+ channel: "matrix",
1298
+ action: "stop",
1299
+ target: roomId,
1300
+ error: err,
1301
+ });
1302
+ },
1303
+ });
1304
+ const draftStreamingEnabled = streaming !== "off";
1305
+ const quietDraftStreaming = streaming === "quiet";
1306
+ const draftReplyToId = replyToMode !== "off" && !threadTarget ? _messageId : undefined;
1307
+ const draftStream = draftStreamingEnabled
1308
+ ? createMatrixDraftStream({
1309
+ roomId,
1310
+ client,
1311
+ cfg,
1312
+ mode: quietDraftStreaming ? "quiet" : "partial",
1313
+ threadId: threadTarget,
1314
+ replyToId: draftReplyToId,
1315
+ preserveReplyId: replyToMode === "all",
1316
+ accountId: _route.accountId,
1317
+ log: logVerboseMessage,
1318
+ })
1319
+ : undefined;
1320
+ draftStreamRef = draftStream;
1321
+ type PendingDraftBoundary = {
1322
+ messageGeneration: number;
1323
+ endOffset: number;
1324
+ };
1325
+ // Track the current draft block start plus any queued block-end offsets
1326
+ // inside the model's cumulative partial text so multiple block
1327
+ // boundaries can drain in order even when Matrix delivery lags behind.
1328
+ let currentDraftMessageGeneration = 0;
1329
+ let currentDraftBlockOffset = 0;
1330
+ let latestDraftFullText = "";
1331
+ const pendingDraftBoundaries: PendingDraftBoundary[] = [];
1332
+ const latestQueuedDraftBoundaryOffsets = new Map<number, number>();
1333
+ let currentDraftReplyToId = draftReplyToId;
1334
+ // Set after the first final payload consumes or discards the draft event
1335
+ // so subsequent finals go through normal delivery.
1336
+
1337
+ const getDisplayableDraftText = () => {
1338
+ const nextDraftBoundaryOffset = pendingDraftBoundaries.find(
1339
+ (boundary) => boundary.messageGeneration === currentDraftMessageGeneration,
1340
+ )?.endOffset;
1341
+ if (nextDraftBoundaryOffset === undefined) {
1342
+ return latestDraftFullText.slice(currentDraftBlockOffset);
1343
+ }
1344
+ return latestDraftFullText.slice(currentDraftBlockOffset, nextDraftBoundaryOffset);
1345
+ };
1346
+
1347
+ const updateDraftFromLatestFullText = () => {
1348
+ const blockText = getDisplayableDraftText();
1349
+ if (blockText) {
1350
+ draftStream?.update(blockText);
1351
+ }
1352
+ };
1353
+
1354
+ const queueDraftBlockBoundary = (payload: ReplyPayload, context?: BlockReplyContext) => {
1355
+ const payloadTextLength = payload.text?.length ?? 0;
1356
+ const messageGeneration = context?.assistantMessageIndex ?? currentDraftMessageGeneration;
1357
+ const lastQueuedDraftBoundaryOffset =
1358
+ latestQueuedDraftBoundaryOffsets.get(messageGeneration) ?? 0;
1359
+ // Logical block boundaries must follow emitted block text, not whichever
1360
+ // later partial preview has already arrived by the time the async
1361
+ // boundary callback drains.
1362
+ const nextDraftBoundaryOffset = lastQueuedDraftBoundaryOffset + payloadTextLength;
1363
+ latestQueuedDraftBoundaryOffsets.set(messageGeneration, nextDraftBoundaryOffset);
1364
+ pendingDraftBoundaries.push({
1365
+ messageGeneration,
1366
+ endOffset: nextDraftBoundaryOffset,
1367
+ });
1368
+ };
1369
+
1370
+ const advanceDraftBlockBoundary = (options?: { fallbackToLatestEnd?: boolean }) => {
1371
+ const completedBoundary = pendingDraftBoundaries.shift();
1372
+ if (completedBoundary) {
1373
+ if (
1374
+ !pendingDraftBoundaries.some(
1375
+ (entry) => entry.messageGeneration === completedBoundary.messageGeneration,
1376
+ )
1377
+ ) {
1378
+ latestQueuedDraftBoundaryOffsets.delete(completedBoundary.messageGeneration);
1379
+ }
1380
+ if (completedBoundary.messageGeneration === currentDraftMessageGeneration) {
1381
+ currentDraftBlockOffset = completedBoundary.endOffset;
1382
+ }
1383
+ return;
1384
+ }
1385
+ if (options?.fallbackToLatestEnd) {
1386
+ currentDraftBlockOffset = latestDraftFullText.length;
1387
+ }
1388
+ };
1389
+
1390
+ const resetDraftBlockOffsets = () => {
1391
+ currentDraftMessageGeneration += 1;
1392
+ currentDraftBlockOffset = 0;
1393
+ latestDraftFullText = "";
1394
+ };
1395
+
1396
+ const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } =
1397
+ core.channel.reply.createReplyDispatcherWithTyping({
1398
+ ...prefixOptions,
1399
+ humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, _route.agentId),
1400
+ deliver: async (payload: ReplyPayload, info: { kind: string }) => {
1401
+ if (draftStream && info.kind !== "tool" && !payload.isCompactionNotice) {
1402
+ const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
1403
+
1404
+ await draftStream.stop();
1405
+ const draftEventId = draftStream.eventId();
1406
+
1407
+ if (draftConsumed) {
1408
+ await deliverMatrixReplies({
1409
+ cfg,
1410
+ replies: [payload],
1411
+ roomId,
1412
+ client,
1413
+ runtime,
1414
+ textLimit,
1415
+ replyToMode,
1416
+ threadId: threadTarget,
1417
+ accountId: _route.accountId,
1418
+ mediaLocalRoots,
1419
+ tableMode,
1420
+ });
1421
+ return;
1422
+ }
1423
+
1424
+ const payloadReplyToId = normalizeOptionalString(payload.replyToId);
1425
+ const payloadReplyMismatch =
1426
+ replyToMode !== "off" &&
1427
+ !threadTarget &&
1428
+ payloadReplyToId !== currentDraftReplyToId;
1429
+ const mustDeliverFinalNormally = draftStream.mustDeliverFinalNormally();
1430
+
1431
+ if (
1432
+ draftEventId &&
1433
+ payload.text &&
1434
+ !hasMedia &&
1435
+ !payloadReplyMismatch &&
1436
+ !mustDeliverFinalNormally
1437
+ ) {
1438
+ try {
1439
+ const requiresFinalEdit =
1440
+ quietDraftStreaming || !draftStream.matchesPreparedText(payload.text);
1441
+ if (requiresFinalEdit) {
1442
+ await editMessageMatrix(roomId, draftEventId, payload.text, {
1443
+ client,
1444
+ cfg,
1445
+ threadId: threadTarget,
1446
+ accountId: _route.accountId,
1447
+ extraContent: quietDraftStreaming
1448
+ ? buildMatrixFinalizedPreviewContent()
1449
+ : undefined,
1450
+ });
1451
+ } else if (!(await draftStream.finalizeLive())) {
1452
+ throw new Error("Matrix draft live finalize failed");
1453
+ }
1454
+ } catch {
1455
+ await redactMatrixDraftEvent(client, roomId, draftEventId);
1456
+ await deliverMatrixReplies({
1457
+ cfg,
1458
+ replies: [payload],
1459
+ roomId,
1460
+ client,
1461
+ runtime,
1462
+ textLimit,
1463
+ replyToMode,
1464
+ threadId: threadTarget,
1465
+ accountId: _route.accountId,
1466
+ mediaLocalRoots,
1467
+ tableMode,
1468
+ });
1469
+ }
1470
+ draftConsumed = true;
1471
+ } else if (draftEventId && hasMedia && !payloadReplyMismatch) {
1472
+ let textEditOk = !mustDeliverFinalNormally;
1473
+ const payloadText = payload.text;
1474
+ const payloadTextMatchesDraft =
1475
+ typeof payloadText === "string" && draftStream.matchesPreparedText(payloadText);
1476
+ const reusesDraftTextUnchanged =
1477
+ typeof payloadText === "string" &&
1478
+ Boolean(payloadText.trim()) &&
1479
+ payloadTextMatchesDraft;
1480
+ const requiresFinalTextEdit =
1481
+ quietDraftStreaming ||
1482
+ (typeof payloadText === "string" && !payloadTextMatchesDraft);
1483
+ if (textEditOk && payloadText && requiresFinalTextEdit) {
1484
+ textEditOk = await editMessageMatrix(roomId, draftEventId, payloadText, {
1485
+ client,
1486
+ cfg,
1487
+ threadId: threadTarget,
1488
+ accountId: _route.accountId,
1489
+ extraContent: quietDraftStreaming
1490
+ ? buildMatrixFinalizedPreviewContent()
1491
+ : undefined,
1492
+ }).then(
1493
+ () => true,
1494
+ () => false,
1495
+ );
1496
+ } else if (textEditOk && reusesDraftTextUnchanged) {
1497
+ textEditOk = await draftStream.finalizeLive();
1498
+ }
1499
+ const reusesDraftAsFinalText = Boolean(payload.text?.trim()) && textEditOk;
1500
+ if (!reusesDraftAsFinalText) {
1501
+ await redactMatrixDraftEvent(client, roomId, draftEventId);
1502
+ }
1503
+ await deliverMatrixReplies({
1504
+ cfg,
1505
+ replies: [
1506
+ { ...payload, text: reusesDraftAsFinalText ? undefined : payload.text },
1507
+ ],
1508
+ roomId,
1509
+ client,
1510
+ runtime,
1511
+ textLimit,
1512
+ replyToMode,
1513
+ threadId: threadTarget,
1514
+ accountId: _route.accountId,
1515
+ mediaLocalRoots,
1516
+ tableMode,
1517
+ });
1518
+ draftConsumed = true;
1519
+ } else {
1520
+ const draftRedacted =
1521
+ Boolean(draftEventId) && (payloadReplyMismatch || mustDeliverFinalNormally);
1522
+ if (draftRedacted && draftEventId) {
1523
+ await redactMatrixDraftEvent(client, roomId, draftEventId);
1524
+ }
1525
+ const deliveredFallback = await deliverMatrixReplies({
1526
+ cfg,
1527
+ replies: [payload],
1528
+ roomId,
1529
+ client,
1530
+ runtime,
1531
+ textLimit,
1532
+ replyToMode,
1533
+ threadId: threadTarget,
1534
+ accountId: _route.accountId,
1535
+ mediaLocalRoots,
1536
+ tableMode,
1537
+ });
1538
+ if (draftRedacted || deliveredFallback) {
1539
+ draftConsumed = true;
1540
+ }
1541
+ }
1542
+
1543
+ if (info.kind === "block") {
1544
+ draftConsumed = false;
1545
+ advanceDraftBlockBoundary({ fallbackToLatestEnd: true });
1546
+ draftStream.reset();
1547
+ currentDraftReplyToId = replyToMode === "all" ? draftReplyToId : undefined;
1548
+ updateDraftFromLatestFullText();
1549
+
1550
+ // Re-assert typing so the user still sees the indicator while
1551
+ // the next block generates.
1552
+ await sendTypingMatrix(roomId, true, undefined, client).catch(() => {});
1553
+ }
1554
+ } else {
1555
+ await deliverMatrixReplies({
1556
+ cfg,
1557
+ replies: [payload],
1558
+ roomId,
1559
+ client,
1560
+ runtime,
1561
+ textLimit,
1562
+ replyToMode,
1563
+ threadId: threadTarget,
1564
+ accountId: _route.accountId,
1565
+ mediaLocalRoots,
1566
+ tableMode,
1567
+ });
1568
+ }
1569
+ },
1570
+ onError: (err: unknown, info: { kind: "tool" | "block" | "final" }) => {
1571
+ if (info.kind === "final") {
1572
+ finalReplyDeliveryFailed = true;
1573
+ } else {
1574
+ nonFinalReplyDeliveryFailed = true;
1575
+ }
1576
+ if (info.kind === "block") {
1577
+ advanceDraftBlockBoundary({ fallbackToLatestEnd: true });
1578
+ }
1579
+ runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
1580
+ },
1581
+ onReplyStart: typingCallbacks.onReplyStart,
1582
+ onIdle: typingCallbacks.onIdle,
1583
+ });
1584
+
1585
+ const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
1586
+ dispatcher,
1587
+ onSettled: () => {
1588
+ markDispatchIdle();
1589
+ },
1590
+ run: async () => {
1591
+ try {
1592
+ return await core.channel.reply.dispatchReplyFromConfig({
1593
+ ctx: ctxPayload,
1594
+ cfg,
1595
+ dispatcher,
1596
+ replyOptions: {
1597
+ ...replyOptions,
1598
+ skillFilter: roomConfig?.skills,
1599
+ // Keep block streaming enabled when explicitly requested, even
1600
+ // with draft previews on. The draft remains the live preview
1601
+ // for the current assistant block, while block deliveries
1602
+ // finalize completed blocks into their own preserved events.
1603
+ disableBlockStreaming: !blockStreamingEnabled,
1604
+ onPartialReply: draftStream
1605
+ ? (payload) => {
1606
+ latestDraftFullText = payload.text ?? "";
1607
+ updateDraftFromLatestFullText();
1608
+ }
1609
+ : undefined,
1610
+ onBlockReplyQueued: draftStream
1611
+ ? (payload, context) => {
1612
+ if (payload.isCompactionNotice === true) {
1613
+ return;
1614
+ }
1615
+ queueDraftBlockBoundary(payload, context);
1616
+ }
1617
+ : undefined,
1618
+ // Reset draft boundary bookkeeping on assistant message
1619
+ // boundaries so post-tool blocks stream from a fresh
1620
+ // cumulative payload (payload.text resets upstream).
1621
+ onAssistantMessageStart: draftStream
1622
+ ? () => {
1623
+ resetDraftBlockOffsets();
1624
+ }
1625
+ : undefined,
1626
+ onModelSelected,
1627
+ },
1628
+ });
1629
+ } finally {
1630
+ markRunComplete();
1631
+ }
1632
+ },
1633
+ });
1634
+ if (finalReplyDeliveryFailed) {
1635
+ logVerboseMessage(
1636
+ `matrix: final reply delivery failed room=${roomId} id=${_messageId}; leaving event uncommitted`,
1637
+ );
1638
+ // Do not advance watermark — the event will be retried and should see the same history.
1639
+ return;
1640
+ }
1641
+ if (!queuedFinal && nonFinalReplyDeliveryFailed) {
1642
+ logVerboseMessage(
1643
+ `matrix: non-final reply delivery failed room=${roomId} id=${_messageId}; leaving event uncommitted`,
1644
+ );
1645
+ // Do not advance watermark — the event will be retried.
1646
+ return;
1647
+ }
1648
+ // Advance the per-agent watermark now that the reply succeeded (or no reply was needed).
1649
+ // Only advance to the snapshot position — messages added during async processing remain
1650
+ // visible for the next trigger.
1651
+ if (isRoom && triggerSnapshot) {
1652
+ roomHistoryTracker.consumeHistory(_route.agentId, roomId, triggerSnapshot, _messageId);
1653
+ }
1654
+ if (!queuedFinal) {
1655
+ await commitInboundEventIfClaimed();
1656
+ return;
1657
+ }
1658
+ const finalCount = counts.final;
1659
+ logVerboseMessage(
1660
+ `matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
1661
+ );
1662
+ await commitInboundEventIfClaimed();
1663
+ } catch (err) {
1664
+ runtime.error?.(`matrix handler failed: ${String(err)}`);
1665
+ } finally {
1666
+ // Stop the draft stream timer so partial drafts don't leak if the
1667
+ // model run throws or times out mid-stream.
1668
+ if (draftStreamRef) {
1669
+ const draftEventId = await draftStreamRef.stop().catch(() => undefined);
1670
+ if (draftEventId && !draftConsumed) {
1671
+ await redactMatrixDraftEvent(client, roomId, draftEventId);
1672
+ }
1673
+ }
1674
+ if (claimedInboundEvent && inboundDeduper && eventId) {
1675
+ inboundDeduper.releaseEvent({ roomId, eventId });
1676
+ }
1677
+ }
1678
+ };
1679
+ }