@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,958 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { PluginRuntime } from "../../runtime-api.js";
3
+ import { setMatrixRuntime } from "../runtime.js";
4
+ import { voteMatrixPoll } from "./actions/polls.js";
5
+ import {
6
+ editMessageMatrix,
7
+ sendMessageMatrix,
8
+ sendPollMatrix,
9
+ sendSingleTextMessageMatrix,
10
+ sendTypingMatrix,
11
+ } from "./send.js";
12
+ import { MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY } from "./send/types.js";
13
+
14
+ const loadOutboundMediaFromUrlMock = vi.hoisted(() => vi.fn());
15
+ const loadWebMediaMock = vi.fn().mockResolvedValue({
16
+ buffer: Buffer.from("media"),
17
+ fileName: "photo.png",
18
+ contentType: "image/png",
19
+ kind: "image",
20
+ });
21
+ const loadConfigMock = vi.fn(() => ({}));
22
+ const getImageMetadataMock = vi.fn().mockResolvedValue(null);
23
+ const resizeToJpegMock = vi.fn();
24
+ const mediaKindFromMimeMock = vi.fn((_: string | null | undefined) => "image");
25
+ const isVoiceCompatibleAudioMock = vi.fn(
26
+ (_: { contentType?: string | null; fileName?: string | null }) => false,
27
+ );
28
+ const resolveTextChunkLimitMock = vi.fn<
29
+ (cfg: unknown, channel: unknown, accountId?: unknown) => number
30
+ >(() => 4000);
31
+ const resolveMarkdownTableModeMock = vi.fn(() => "code");
32
+ const convertMarkdownTablesMock = vi.fn((text: string) => text);
33
+ const chunkMarkdownTextWithModeMock = vi.fn((text: string) => (text ? [text] : []));
34
+
35
+ vi.mock("./outbound-media-runtime.js", () => ({
36
+ loadOutboundMediaFromUrl: loadOutboundMediaFromUrlMock,
37
+ }));
38
+
39
+ const runtimeStub = {
40
+ config: {
41
+ loadConfig: () => loadConfigMock(),
42
+ },
43
+ media: {
44
+ loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
45
+ mediaKindFromMime: (mime?: string | null) => mediaKindFromMimeMock(mime),
46
+ isVoiceCompatibleAudio: (opts: { contentType?: string | null; fileName?: string | null }) =>
47
+ isVoiceCompatibleAudioMock(opts),
48
+ getImageMetadata: (...args: unknown[]) => getImageMetadataMock(...args),
49
+ resizeToJpeg: (...args: unknown[]) => resizeToJpegMock(...args),
50
+ },
51
+ channel: {
52
+ text: {
53
+ resolveTextChunkLimit: (cfg: unknown, channel: unknown, accountId?: unknown) =>
54
+ resolveTextChunkLimitMock(cfg, channel, accountId),
55
+ resolveChunkMode: () => "length",
56
+ chunkMarkdownText: (text: string) => (text ? [text] : []),
57
+ chunkMarkdownTextWithMode: (text: string) => chunkMarkdownTextWithModeMock(text),
58
+ resolveMarkdownTableMode: () => resolveMarkdownTableModeMock(),
59
+ convertMarkdownTables: (text: string) => convertMarkdownTablesMock(text),
60
+ },
61
+ },
62
+ } as unknown as PluginRuntime;
63
+
64
+ function applyMatrixSendRuntimeStub() {
65
+ setMatrixRuntime(runtimeStub);
66
+ }
67
+
68
+ function createEncryptedMediaPayload() {
69
+ return {
70
+ buffer: Buffer.from("encrypted"),
71
+ file: {
72
+ key: {
73
+ kty: "oct",
74
+ key_ops: ["encrypt", "decrypt"],
75
+ alg: "A256CTR",
76
+ k: "secret",
77
+ ext: true,
78
+ },
79
+ iv: "iv",
80
+ hashes: { sha256: "hash" },
81
+ v: "v2",
82
+ },
83
+ };
84
+ }
85
+
86
+ const makeClient = () => {
87
+ const sendMessage = vi.fn().mockResolvedValue("evt1");
88
+ const sendEvent = vi.fn().mockResolvedValue("evt-poll-vote");
89
+ const getEvent = vi.fn();
90
+ const getJoinedRoomMembers = vi.fn().mockResolvedValue([]);
91
+ const uploadContent = vi.fn().mockResolvedValue("mxc://example/file");
92
+ const client = {
93
+ sendMessage,
94
+ sendEvent,
95
+ getEvent,
96
+ getJoinedRoomMembers,
97
+ uploadContent,
98
+ getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
99
+ prepareForOneOff: vi.fn(async () => undefined),
100
+ start: vi.fn(async () => undefined),
101
+ stop: vi.fn(() => undefined),
102
+ stopAndPersist: vi.fn(async () => undefined),
103
+ } as unknown as import("./sdk.js").MatrixClient;
104
+ return { client, sendMessage, sendEvent, getEvent, getJoinedRoomMembers, uploadContent };
105
+ };
106
+
107
+ function makeEncryptedMediaClient() {
108
+ const result = makeClient();
109
+ (result.client as { crypto?: object }).crypto = {
110
+ isRoomEncrypted: vi.fn().mockResolvedValue(true),
111
+ encryptMedia: vi.fn().mockResolvedValue(createEncryptedMediaPayload()),
112
+ };
113
+ return result;
114
+ }
115
+
116
+ function resetMatrixSendRuntimeMocks() {
117
+ setMatrixRuntime(runtimeStub);
118
+ loadOutboundMediaFromUrlMock.mockReset().mockImplementation(
119
+ async (
120
+ mediaUrl: string,
121
+ options?: {
122
+ maxBytes?: number;
123
+ mediaLocalRoots?: readonly string[];
124
+ mediaReadFile?: (filePath: string) => Promise<Buffer>;
125
+ },
126
+ ) =>
127
+ await loadWebMediaMock(mediaUrl, {
128
+ maxBytes: options?.maxBytes,
129
+ localRoots: options?.mediaLocalRoots,
130
+ hostReadCapability: false,
131
+ readFile: options?.mediaReadFile,
132
+ }),
133
+ );
134
+ loadWebMediaMock.mockReset().mockResolvedValue({
135
+ buffer: Buffer.from("media"),
136
+ fileName: "photo.png",
137
+ contentType: "image/png",
138
+ kind: "image",
139
+ });
140
+ loadConfigMock.mockReset().mockReturnValue({});
141
+ getImageMetadataMock.mockReset().mockResolvedValue(null);
142
+ resizeToJpegMock.mockReset();
143
+ mediaKindFromMimeMock.mockReset().mockReturnValue("image");
144
+ isVoiceCompatibleAudioMock.mockReset().mockReturnValue(false);
145
+ resolveTextChunkLimitMock.mockReset().mockReturnValue(4000);
146
+ resolveMarkdownTableModeMock.mockReset().mockReturnValue("code");
147
+ convertMarkdownTablesMock.mockReset().mockImplementation((text: string) => text);
148
+ chunkMarkdownTextWithModeMock
149
+ .mockReset()
150
+ .mockImplementation((text: string) => (text ? [text] : []));
151
+ applyMatrixSendRuntimeStub();
152
+ }
153
+
154
+ describe("sendMessageMatrix media", () => {
155
+ beforeEach(() => {
156
+ resetMatrixSendRuntimeMocks();
157
+ });
158
+
159
+ it("uploads media with url payloads", async () => {
160
+ const { client, sendMessage, uploadContent } = makeClient();
161
+
162
+ await sendMessageMatrix("room:!room:example", "caption", {
163
+ client,
164
+ mediaUrl: "file:///tmp/photo.png",
165
+ });
166
+
167
+ const uploadArg = uploadContent.mock.calls[0]?.[0];
168
+ expect(Buffer.isBuffer(uploadArg)).toBe(true);
169
+
170
+ const content = sendMessage.mock.calls[0]?.[1] as {
171
+ url?: string;
172
+ msgtype?: string;
173
+ format?: string;
174
+ formatted_body?: string;
175
+ };
176
+ expect(content.msgtype).toBe("m.image");
177
+ expect(content.format).toBe("org.matrix.custom.html");
178
+ expect(content.formatted_body).toContain("caption");
179
+ expect(content.url).toBe("mxc://example/file");
180
+ });
181
+
182
+ it("uploads encrypted media with file payloads", async () => {
183
+ const { client, sendMessage, uploadContent } = makeEncryptedMediaClient();
184
+
185
+ await sendMessageMatrix("room:!room:example", "caption", {
186
+ client,
187
+ mediaUrl: "file:///tmp/photo.png",
188
+ });
189
+
190
+ const uploadArg = uploadContent.mock.calls[0]?.[0] as Buffer | undefined;
191
+ expect(uploadArg?.toString()).toBe("encrypted");
192
+
193
+ const content = sendMessage.mock.calls[0]?.[1] as {
194
+ url?: string;
195
+ file?: { url?: string };
196
+ };
197
+ expect(content.url).toBeUndefined();
198
+ expect(content.file?.url).toBe("mxc://example/file");
199
+ });
200
+
201
+ it("encrypts thumbnail via thumbnail_file when room is encrypted", async () => {
202
+ const { client, sendMessage, uploadContent } = makeClient();
203
+ const isRoomEncrypted = vi.fn().mockResolvedValue(true);
204
+ const encryptMedia = vi.fn().mockResolvedValue({
205
+ buffer: Buffer.from("encrypted-thumb"),
206
+ file: {
207
+ key: { kty: "oct", key_ops: ["encrypt", "decrypt"], alg: "A256CTR", k: "tkey", ext: true },
208
+ iv: "tiv",
209
+ hashes: { sha256: "thash" },
210
+ v: "v2",
211
+ },
212
+ });
213
+ (client as { crypto?: object }).crypto = {
214
+ isRoomEncrypted,
215
+ encryptMedia,
216
+ };
217
+ // Return image metadata so thumbnail generation is triggered (image > 800px)
218
+ getImageMetadataMock
219
+ .mockResolvedValueOnce({ width: 1920, height: 1080 }) // original image
220
+ .mockResolvedValueOnce({ width: 800, height: 450 }); // thumbnail
221
+ resizeToJpegMock.mockResolvedValueOnce(Buffer.from("thumb-bytes"));
222
+ // Two uploadContent calls: one for the main encrypted image, one for the encrypted thumbnail
223
+ uploadContent
224
+ .mockResolvedValueOnce("mxc://example/main")
225
+ .mockResolvedValueOnce("mxc://example/thumb");
226
+
227
+ await sendMessageMatrix("room:!room:example", "caption", {
228
+ client,
229
+ mediaUrl: "file:///tmp/photo.png",
230
+ });
231
+
232
+ // encryptMedia called twice: once for main media, once for thumbnail
233
+ expect(isRoomEncrypted).toHaveBeenCalledTimes(1);
234
+ expect(encryptMedia).toHaveBeenCalledTimes(2);
235
+
236
+ const content = sendMessage.mock.calls[0]?.[1] as {
237
+ url?: string;
238
+ file?: { url?: string };
239
+ info?: { thumbnail_url?: string; thumbnail_file?: { url?: string } };
240
+ };
241
+ // Main media encrypted correctly
242
+ expect(content.url).toBeUndefined();
243
+ expect(content.file?.url).toBe("mxc://example/main");
244
+ // Thumbnail must use thumbnail_file (encrypted), NOT thumbnail_url (unencrypted)
245
+ expect(content.info?.thumbnail_url).toBeUndefined();
246
+ expect(content.info?.thumbnail_file?.url).toBe("mxc://example/thumb");
247
+ });
248
+
249
+ it("keeps reply context on voice transcript follow-ups outside threads", async () => {
250
+ const { client, sendMessage } = makeClient();
251
+ mediaKindFromMimeMock.mockReturnValue("audio");
252
+ isVoiceCompatibleAudioMock.mockReturnValue(true);
253
+ loadWebMediaMock.mockResolvedValueOnce({
254
+ buffer: Buffer.from("audio"),
255
+ fileName: "clip.mp3",
256
+ contentType: "audio/mpeg",
257
+ kind: "audio",
258
+ });
259
+
260
+ await sendMessageMatrix("room:!room:example", "voice caption", {
261
+ client,
262
+ mediaUrl: "file:///tmp/clip.mp3",
263
+ audioAsVoice: true,
264
+ replyToId: "$reply",
265
+ });
266
+
267
+ const transcriptContent = sendMessage.mock.calls[1]?.[1] as {
268
+ body?: string;
269
+ "m.relates_to"?: {
270
+ "m.in_reply_to"?: { event_id?: string };
271
+ };
272
+ };
273
+
274
+ expect(transcriptContent.body).toBe("voice caption");
275
+ expect(transcriptContent["m.relates_to"]).toMatchObject({
276
+ "m.in_reply_to": { event_id: "$reply" },
277
+ });
278
+ });
279
+
280
+ it("keeps regular audio payload when audioAsVoice media is incompatible", async () => {
281
+ const { client, sendMessage } = makeClient();
282
+ mediaKindFromMimeMock.mockReturnValue("audio");
283
+ isVoiceCompatibleAudioMock.mockReturnValue(false);
284
+ loadWebMediaMock.mockResolvedValueOnce({
285
+ buffer: Buffer.from("audio"),
286
+ fileName: "clip.wav",
287
+ contentType: "audio/wav",
288
+ kind: "audio",
289
+ });
290
+
291
+ await sendMessageMatrix("room:!room:example", "voice caption", {
292
+ client,
293
+ mediaUrl: "file:///tmp/clip.wav",
294
+ audioAsVoice: true,
295
+ });
296
+
297
+ expect(sendMessage).toHaveBeenCalledTimes(1);
298
+ const mediaContent = sendMessage.mock.calls[0]?.[1] as {
299
+ msgtype?: string;
300
+ body?: string;
301
+ "org.matrix.msc3245.voice"?: Record<string, never>;
302
+ };
303
+ expect(mediaContent.msgtype).toBe("m.audio");
304
+ expect(mediaContent.body).toBe("voice caption");
305
+ expect(mediaContent["org.matrix.msc3245.voice"]).toBeUndefined();
306
+ });
307
+
308
+ it("keeps thumbnail_url metadata for unencrypted large images", async () => {
309
+ const { client, sendMessage, uploadContent } = makeClient();
310
+ getImageMetadataMock
311
+ .mockResolvedValueOnce({ width: 1600, height: 1200 })
312
+ .mockResolvedValueOnce({ width: 800, height: 600 });
313
+ resizeToJpegMock.mockResolvedValueOnce(Buffer.from("thumb"));
314
+
315
+ await sendMessageMatrix("room:!room:example", "caption", {
316
+ client,
317
+ mediaUrl: "file:///tmp/photo.png",
318
+ });
319
+
320
+ expect(uploadContent).toHaveBeenCalledTimes(2);
321
+ const content = sendMessage.mock.calls[0]?.[1] as {
322
+ info?: {
323
+ thumbnail_url?: string;
324
+ thumbnail_file?: { url?: string };
325
+ thumbnail_info?: {
326
+ w?: number;
327
+ h?: number;
328
+ mimetype?: string;
329
+ size?: number;
330
+ };
331
+ };
332
+ };
333
+ expect(content.info?.thumbnail_url).toBe("mxc://example/file");
334
+ expect(content.info?.thumbnail_file).toBeUndefined();
335
+ expect(content.info?.thumbnail_info).toMatchObject({
336
+ w: 800,
337
+ h: 600,
338
+ mimetype: "image/jpeg",
339
+ size: Buffer.from("thumb").byteLength,
340
+ });
341
+ });
342
+
343
+ it("uses explicit cfg for media sends instead of runtime loadConfig fallbacks", async () => {
344
+ const { client } = makeClient();
345
+ const explicitCfg = {
346
+ channels: {
347
+ matrix: {
348
+ accounts: {
349
+ ops: {
350
+ mediaMaxMb: 1,
351
+ },
352
+ },
353
+ },
354
+ },
355
+ };
356
+
357
+ loadConfigMock.mockImplementation(() => {
358
+ throw new Error("sendMessageMatrix should not reload runtime config when cfg is provided");
359
+ });
360
+
361
+ await sendMessageMatrix("room:!room:example", "caption", {
362
+ client,
363
+ cfg: explicitCfg,
364
+ accountId: "ops",
365
+ mediaUrl: "file:///tmp/photo.png",
366
+ });
367
+
368
+ expect(loadConfigMock).not.toHaveBeenCalled();
369
+ expect(loadWebMediaMock).toHaveBeenCalledWith(
370
+ "file:///tmp/photo.png",
371
+ expect.objectContaining({
372
+ maxBytes: 1024 * 1024,
373
+ localRoots: undefined,
374
+ }),
375
+ );
376
+ expect(resolveTextChunkLimitMock).toHaveBeenCalledWith(explicitCfg, "matrix", "ops");
377
+ });
378
+
379
+ it("passes caller mediaLocalRoots to media loading", async () => {
380
+ const { client } = makeClient();
381
+
382
+ await sendMessageMatrix("room:!room:example", "caption", {
383
+ client,
384
+ mediaUrl: "file:///tmp/photo.png",
385
+ mediaLocalRoots: ["/tmp/openclaw-matrix-test"],
386
+ });
387
+
388
+ expect(loadWebMediaMock).toHaveBeenCalledWith(
389
+ "file:///tmp/photo.png",
390
+ expect.objectContaining({
391
+ maxBytes: undefined,
392
+ localRoots: ["/tmp/openclaw-matrix-test"],
393
+ }),
394
+ );
395
+ });
396
+ });
397
+
398
+ describe("sendMessageMatrix mentions", () => {
399
+ beforeEach(() => {
400
+ vi.clearAllMocks();
401
+ resetMatrixSendRuntimeMocks();
402
+ });
403
+
404
+ it("adds an empty m.mentions object for plain messages without mentions", async () => {
405
+ const { client, sendMessage } = makeClient();
406
+
407
+ await sendMessageMatrix("room:!room:example", "hello", {
408
+ client,
409
+ });
410
+
411
+ expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
412
+ body: "hello",
413
+ "m.mentions": {},
414
+ });
415
+ });
416
+
417
+ it("emits m.mentions and matrix.to anchors for qualified user mentions", async () => {
418
+ const { client, sendMessage } = makeClient();
419
+
420
+ await sendMessageMatrix("room:!room:example", "hello @alice:example.org", {
421
+ client,
422
+ });
423
+
424
+ expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
425
+ body: "hello @alice:example.org",
426
+ "m.mentions": { user_ids: ["@alice:example.org"] },
427
+ });
428
+ expect(
429
+ (sendMessage.mock.calls[0]?.[1] as { formatted_body?: string }).formatted_body,
430
+ ).toContain('href="https://matrix.to/#/%40alice%3Aexample.org"');
431
+ });
432
+
433
+ it("keeps bare localpart text as plain text", async () => {
434
+ const { client, sendMessage } = makeClient();
435
+
436
+ await sendMessageMatrix("room:!room:example", "hello @alice", {
437
+ client,
438
+ });
439
+
440
+ expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
441
+ "m.mentions": {},
442
+ });
443
+ expect(
444
+ (sendMessage.mock.calls[0]?.[1] as { formatted_body?: string }).formatted_body,
445
+ ).not.toContain("matrix.to/#/@alice:example.org");
446
+ });
447
+
448
+ it("does not emit mentions for escaped qualified users", async () => {
449
+ const { client, sendMessage } = makeClient();
450
+
451
+ await sendMessageMatrix("room:!room:example", "\\@alice:example.org", {
452
+ client,
453
+ });
454
+
455
+ expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
456
+ "m.mentions": {},
457
+ });
458
+ expect(
459
+ (sendMessage.mock.calls[0]?.[1] as { formatted_body?: string }).formatted_body,
460
+ ).not.toContain("matrix.to/#/@alice:example.org");
461
+ });
462
+
463
+ it("does not emit mentions for escaped room mentions", async () => {
464
+ const { client, sendMessage } = makeClient();
465
+
466
+ await sendMessageMatrix("room:!room:example", "\\@room please review", {
467
+ client,
468
+ });
469
+
470
+ expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
471
+ "m.mentions": {},
472
+ });
473
+ });
474
+
475
+ it("marks room mentions via m.mentions.room", async () => {
476
+ const { client, sendMessage } = makeClient();
477
+
478
+ await sendMessageMatrix("room:!room:example", "@room please review", {
479
+ client,
480
+ });
481
+
482
+ expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
483
+ "m.mentions": { room: true },
484
+ });
485
+ });
486
+
487
+ it("adds mention metadata to media captions", async () => {
488
+ const { client, sendMessage } = makeClient();
489
+
490
+ await sendMessageMatrix("room:!room:example", "caption @alice:example.org", {
491
+ client,
492
+ mediaUrl: "file:///tmp/photo.png",
493
+ });
494
+
495
+ expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
496
+ "m.mentions": { user_ids: ["@alice:example.org"] },
497
+ });
498
+ });
499
+
500
+ it("does not emit mentions from fallback filenames when there is no caption", async () => {
501
+ const { client, sendMessage } = makeClient();
502
+ loadWebMediaMock.mockResolvedValue({
503
+ buffer: Buffer.from("media"),
504
+ fileName: "@room.png",
505
+ contentType: "image/png",
506
+ kind: "image",
507
+ });
508
+
509
+ await sendMessageMatrix("room:!room:example", "", {
510
+ client,
511
+ mediaUrl: "file:///tmp/room.png",
512
+ });
513
+
514
+ expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
515
+ body: "@room.png",
516
+ "m.mentions": {},
517
+ });
518
+ expect(
519
+ (sendMessage.mock.calls[0]?.[1] as { formatted_body?: string }).formatted_body,
520
+ ).toBeUndefined();
521
+ });
522
+ });
523
+
524
+ describe("sendMessageMatrix threads", () => {
525
+ beforeEach(() => {
526
+ vi.clearAllMocks();
527
+ resetMatrixSendRuntimeMocks();
528
+ });
529
+
530
+ it("includes thread relation metadata when threadId is set", async () => {
531
+ const { client, sendMessage } = makeClient();
532
+
533
+ await sendMessageMatrix("room:!room:example", "hello thread", {
534
+ client,
535
+ threadId: "$thread",
536
+ });
537
+
538
+ const content = sendMessage.mock.calls[0]?.[1] as {
539
+ "m.relates_to"?: {
540
+ rel_type?: string;
541
+ event_id?: string;
542
+ "m.in_reply_to"?: { event_id?: string };
543
+ };
544
+ };
545
+
546
+ expect(content["m.relates_to"]).toMatchObject({
547
+ rel_type: "m.thread",
548
+ event_id: "$thread",
549
+ "m.in_reply_to": { event_id: "$thread" },
550
+ });
551
+ });
552
+
553
+ it("resolves text chunk limit using the active Matrix account", async () => {
554
+ const { client } = makeClient();
555
+
556
+ await sendMessageMatrix("room:!room:example", "hello", {
557
+ client,
558
+ accountId: "ops",
559
+ });
560
+
561
+ expect(resolveTextChunkLimitMock).toHaveBeenCalledWith(expect.anything(), "matrix", "ops");
562
+ });
563
+
564
+ it("returns ordered event ids for chunked text sends", async () => {
565
+ const { client, sendMessage } = makeClient();
566
+ sendMessage
567
+ .mockReset()
568
+ .mockResolvedValueOnce("$m1")
569
+ .mockResolvedValueOnce("$m2")
570
+ .mockResolvedValueOnce("$m3");
571
+ convertMarkdownTablesMock.mockImplementation(() => "part1|part2|part3");
572
+ chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|"));
573
+
574
+ const result = await sendMessageMatrix("room:!room:example", "ignored", {
575
+ client,
576
+ });
577
+
578
+ expect(result).toMatchObject({
579
+ roomId: "!room:example",
580
+ primaryMessageId: "$m1",
581
+ messageId: "$m3",
582
+ messageIds: ["$m1", "$m2", "$m3"],
583
+ });
584
+ });
585
+ });
586
+
587
+ describe("sendSingleTextMessageMatrix", () => {
588
+ beforeEach(() => {
589
+ vi.clearAllMocks();
590
+ resetMatrixSendRuntimeMocks();
591
+ });
592
+
593
+ it("rejects single-event sends when converted text exceeds the Matrix limit", async () => {
594
+ const { client, sendMessage } = makeClient();
595
+ resolveTextChunkLimitMock.mockReturnValue(5);
596
+ convertMarkdownTablesMock.mockImplementation(() => "123456");
597
+
598
+ await expect(
599
+ sendSingleTextMessageMatrix("room:!room:example", "1234", {
600
+ client,
601
+ }),
602
+ ).rejects.toThrow("Matrix single-message text exceeds limit");
603
+
604
+ expect(sendMessage).not.toHaveBeenCalled();
605
+ });
606
+
607
+ it("supports quiet draft preview sends without mention metadata", async () => {
608
+ const { client, sendMessage } = makeClient();
609
+
610
+ await sendSingleTextMessageMatrix("room:!room:example", "@room hi @alice:example.org", {
611
+ client,
612
+ msgtype: "m.notice",
613
+ includeMentions: false,
614
+ });
615
+
616
+ expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
617
+ msgtype: "m.notice",
618
+ body: "@room hi @alice:example.org",
619
+ });
620
+ expect(sendMessage.mock.calls[0]?.[1]).not.toHaveProperty("m.mentions");
621
+ expect(
622
+ (sendMessage.mock.calls[0]?.[1] as { formatted_body?: string }).formatted_body,
623
+ ).not.toContain("matrix.to");
624
+ });
625
+
626
+ it("merges extra content fields into single-event sends", async () => {
627
+ const { client, sendMessage } = makeClient();
628
+
629
+ await sendSingleTextMessageMatrix("room:!room:example", "done", {
630
+ client,
631
+ extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
632
+ });
633
+
634
+ expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
635
+ body: "done",
636
+ [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true,
637
+ });
638
+ });
639
+ });
640
+
641
+ describe("editMessageMatrix mentions", () => {
642
+ beforeEach(() => {
643
+ vi.clearAllMocks();
644
+ resetMatrixSendRuntimeMocks();
645
+ });
646
+
647
+ it("stores full mentions in m.new_content and only newly-added mentions in the edit event", async () => {
648
+ const { client, sendMessage, getEvent } = makeClient();
649
+ getEvent.mockResolvedValue({
650
+ content: {
651
+ body: "hello @alice:example.org",
652
+ "m.mentions": { user_ids: ["@alice:example.org"] },
653
+ },
654
+ });
655
+
656
+ await editMessageMatrix(
657
+ "room:!room:example",
658
+ "$original",
659
+ "hello @alice:example.org and @bob:example.org",
660
+ {
661
+ client,
662
+ },
663
+ );
664
+
665
+ expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
666
+ "m.mentions": { user_ids: ["@bob:example.org"] },
667
+ "m.new_content": {
668
+ "m.mentions": { user_ids: ["@alice:example.org", "@bob:example.org"] },
669
+ },
670
+ });
671
+ });
672
+
673
+ it("does not re-notify legacy mentions when the prior event body already mentioned the user", async () => {
674
+ const { client, sendMessage, getEvent } = makeClient();
675
+ getEvent.mockResolvedValue({
676
+ content: {
677
+ body: "hello @alice:example.org",
678
+ },
679
+ });
680
+
681
+ await editMessageMatrix("room:!room:example", "$original", "hello again @alice:example.org", {
682
+ client,
683
+ });
684
+
685
+ expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
686
+ "m.mentions": {},
687
+ "m.new_content": {
688
+ body: "hello again @alice:example.org",
689
+ "m.mentions": { user_ids: ["@alice:example.org"] },
690
+ },
691
+ });
692
+ });
693
+
694
+ it("keeps explicit empty prior m.mentions authoritative", async () => {
695
+ const { client, sendMessage, getEvent } = makeClient();
696
+ getEvent.mockResolvedValue({
697
+ content: {
698
+ body: "`@alice:example.org`",
699
+ "m.mentions": {},
700
+ },
701
+ });
702
+
703
+ await editMessageMatrix("room:!room:example", "$original", "@alice:example.org", {
704
+ client,
705
+ });
706
+
707
+ expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
708
+ "m.mentions": { user_ids: ["@alice:example.org"] },
709
+ "m.new_content": {
710
+ "m.mentions": { user_ids: ["@alice:example.org"] },
711
+ },
712
+ });
713
+ });
714
+
715
+ it("supports quiet draft preview edits without mention metadata", async () => {
716
+ const { client, sendMessage, getEvent } = makeClient();
717
+ getEvent.mockResolvedValue({
718
+ content: {
719
+ body: "@room hi @alice:example.org",
720
+ "m.mentions": { room: true, user_ids: ["@alice:example.org"] },
721
+ },
722
+ });
723
+
724
+ await editMessageMatrix("room:!room:example", "$original", "@room hi @alice:example.org", {
725
+ client,
726
+ msgtype: "m.notice",
727
+ includeMentions: false,
728
+ });
729
+
730
+ expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
731
+ msgtype: "m.notice",
732
+ "m.new_content": {
733
+ msgtype: "m.notice",
734
+ },
735
+ });
736
+ expect(sendMessage.mock.calls[0]?.[1]).not.toHaveProperty("m.mentions");
737
+ expect(sendMessage.mock.calls[0]?.[1]?.["m.new_content"]).not.toHaveProperty("m.mentions");
738
+ expect(
739
+ (sendMessage.mock.calls[0]?.[1] as { formatted_body?: string }).formatted_body,
740
+ ).not.toContain("matrix.to");
741
+ expect(
742
+ (
743
+ sendMessage.mock.calls[0]?.[1] as {
744
+ "m.new_content"?: { formatted_body?: string };
745
+ }
746
+ )["m.new_content"]?.formatted_body,
747
+ ).not.toContain("matrix.to");
748
+ });
749
+
750
+ it("merges extra content fields into edit payloads and m.new_content", async () => {
751
+ const { client, sendMessage } = makeClient();
752
+
753
+ await editMessageMatrix("room:!room:example", "$original", "done", {
754
+ client,
755
+ extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
756
+ });
757
+
758
+ expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
759
+ [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true,
760
+ "m.new_content": {
761
+ [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true,
762
+ },
763
+ });
764
+ });
765
+ });
766
+
767
+ describe("sendPollMatrix mentions", () => {
768
+ beforeEach(() => {
769
+ vi.clearAllMocks();
770
+ resetMatrixSendRuntimeMocks();
771
+ });
772
+
773
+ it("adds m.mentions for poll fallback text", async () => {
774
+ const { client, sendEvent } = makeClient();
775
+
776
+ await sendPollMatrix(
777
+ "room:!room:example",
778
+ {
779
+ question: "@room lunch with @alice:example.org?",
780
+ options: ["yes", "no"],
781
+ },
782
+ {
783
+ client,
784
+ },
785
+ );
786
+
787
+ expect(sendEvent).toHaveBeenCalledWith(
788
+ "!room:example",
789
+ "m.poll.start",
790
+ expect.objectContaining({
791
+ "m.mentions": {
792
+ room: true,
793
+ user_ids: ["@alice:example.org"],
794
+ },
795
+ }),
796
+ );
797
+ });
798
+ });
799
+
800
+ describe("voteMatrixPoll", () => {
801
+ beforeEach(() => {
802
+ vi.clearAllMocks();
803
+ resetMatrixSendRuntimeMocks();
804
+ });
805
+
806
+ it("maps 1-based option indexes to Matrix poll answer ids", async () => {
807
+ const { client, getEvent, sendEvent } = makeClient();
808
+ getEvent.mockResolvedValue({
809
+ type: "m.poll.start",
810
+ content: {
811
+ "m.poll.start": {
812
+ question: { "m.text": "Lunch?" },
813
+ max_selections: 1,
814
+ answers: [
815
+ { id: "a1", "m.text": "Pizza" },
816
+ { id: "a2", "m.text": "Sushi" },
817
+ ],
818
+ },
819
+ },
820
+ });
821
+
822
+ const result = await voteMatrixPoll("room:!room:example", "$poll", {
823
+ client,
824
+ optionIndex: 2,
825
+ });
826
+
827
+ expect(sendEvent).toHaveBeenCalledWith("!room:example", "m.poll.response", {
828
+ "m.poll.response": { answers: ["a2"] },
829
+ "org.matrix.msc3381.poll.response": { answers: ["a2"] },
830
+ "m.relates_to": {
831
+ rel_type: "m.reference",
832
+ event_id: "$poll",
833
+ },
834
+ });
835
+ expect(result).toMatchObject({
836
+ eventId: "evt-poll-vote",
837
+ roomId: "!room:example",
838
+ pollId: "$poll",
839
+ answerIds: ["a2"],
840
+ labels: ["Sushi"],
841
+ });
842
+ });
843
+
844
+ it("rejects out-of-range option indexes", async () => {
845
+ const { client, getEvent } = makeClient();
846
+ getEvent.mockResolvedValue({
847
+ type: "m.poll.start",
848
+ content: {
849
+ "m.poll.start": {
850
+ question: { "m.text": "Lunch?" },
851
+ max_selections: 1,
852
+ answers: [{ id: "a1", "m.text": "Pizza" }],
853
+ },
854
+ },
855
+ });
856
+
857
+ await expect(
858
+ voteMatrixPoll("room:!room:example", "$poll", {
859
+ client,
860
+ optionIndex: 2,
861
+ }),
862
+ ).rejects.toThrow("out of range");
863
+ });
864
+
865
+ it("rejects votes that exceed the poll selection cap", async () => {
866
+ const { client, getEvent } = makeClient();
867
+ getEvent.mockResolvedValue({
868
+ type: "m.poll.start",
869
+ content: {
870
+ "m.poll.start": {
871
+ question: { "m.text": "Lunch?" },
872
+ max_selections: 1,
873
+ answers: [
874
+ { id: "a1", "m.text": "Pizza" },
875
+ { id: "a2", "m.text": "Sushi" },
876
+ ],
877
+ },
878
+ },
879
+ });
880
+
881
+ await expect(
882
+ voteMatrixPoll("room:!room:example", "$poll", {
883
+ client,
884
+ optionIndexes: [1, 2],
885
+ }),
886
+ ).rejects.toThrow("at most 1 selection");
887
+ });
888
+
889
+ it("rejects non-poll events before sending a response", async () => {
890
+ const { client, getEvent, sendEvent } = makeClient();
891
+ getEvent.mockResolvedValue({
892
+ type: "m.room.message",
893
+ content: { body: "hello" },
894
+ });
895
+
896
+ await expect(
897
+ voteMatrixPoll("room:!room:example", "$poll", {
898
+ client,
899
+ optionIndex: 1,
900
+ }),
901
+ ).rejects.toThrow("is not a Matrix poll start event");
902
+ expect(sendEvent).not.toHaveBeenCalled();
903
+ });
904
+
905
+ it("accepts decrypted poll start events returned from encrypted rooms", async () => {
906
+ const { client, getEvent, sendEvent } = makeClient();
907
+ getEvent.mockResolvedValue({
908
+ type: "m.poll.start",
909
+ content: {
910
+ "m.poll.start": {
911
+ question: { "m.text": "Lunch?" },
912
+ max_selections: 1,
913
+ answers: [{ id: "a1", "m.text": "Pizza" }],
914
+ },
915
+ },
916
+ });
917
+
918
+ await expect(
919
+ voteMatrixPoll("room:!room:example", "$poll", {
920
+ client,
921
+ optionIndex: 1,
922
+ }),
923
+ ).resolves.toMatchObject({
924
+ pollId: "$poll",
925
+ answerIds: ["a1"],
926
+ });
927
+ expect(sendEvent).toHaveBeenCalledWith("!room:example", "m.poll.response", {
928
+ "m.poll.response": { answers: ["a1"] },
929
+ "org.matrix.msc3381.poll.response": { answers: ["a1"] },
930
+ "m.relates_to": {
931
+ rel_type: "m.reference",
932
+ event_id: "$poll",
933
+ },
934
+ });
935
+ });
936
+ });
937
+
938
+ describe("sendTypingMatrix", () => {
939
+ beforeEach(() => {
940
+ vi.clearAllMocks();
941
+ resetMatrixSendRuntimeMocks();
942
+ });
943
+
944
+ it("normalizes room-prefixed targets before sending typing state", async () => {
945
+ const setTyping = vi.fn().mockResolvedValue(undefined);
946
+ const client = {
947
+ setTyping,
948
+ prepareForOneOff: vi.fn(async () => undefined),
949
+ start: vi.fn(async () => undefined),
950
+ stop: vi.fn(() => undefined),
951
+ stopAndPersist: vi.fn(async () => undefined),
952
+ } as unknown as import("./sdk.js").MatrixClient;
953
+
954
+ await sendTypingMatrix("room:!room:example", true, undefined, client);
955
+
956
+ expect(setTyping).toHaveBeenCalledWith("!room:example", true, 30_000);
957
+ });
958
+ });