@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,265 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { PluginRuntime, RuntimeEnv } from "../../../runtime-api.js";
3
+ import type { MatrixClient } from "../sdk.js";
4
+
5
+ const sendMessageMatrixMock = vi.hoisted(() => vi.fn().mockResolvedValue({ messageId: "mx-1" }));
6
+ const chunkMatrixTextMock = vi.hoisted(() =>
7
+ vi.fn((text: string, _opts?: unknown) => ({
8
+ trimmedText: text.trim(),
9
+ convertedText: text,
10
+ singleEventLimit: 4000,
11
+ fitsInSingleEvent: true,
12
+ chunks: text ? [text] : [],
13
+ })),
14
+ );
15
+
16
+ vi.mock("../send.js", () => ({
17
+ chunkMatrixText: (text: string, opts?: unknown) => chunkMatrixTextMock(text, opts),
18
+ sendMessageMatrix: (to: string, message: string, opts?: unknown) =>
19
+ sendMessageMatrixMock(to, message, opts),
20
+ }));
21
+
22
+ import { setMatrixRuntime } from "../../runtime.js";
23
+ import { deliverMatrixReplies } from "./replies.js";
24
+
25
+ describe("deliverMatrixReplies", () => {
26
+ const cfg = { channels: { matrix: {} } };
27
+ const loadConfigMock = vi.fn(() => ({}));
28
+ const resolveMarkdownTableModeMock = vi.fn<(params: unknown) => string>(() => "code");
29
+ const convertMarkdownTablesMock = vi.fn((text: string) => text);
30
+ const resolveChunkModeMock = vi.fn<
31
+ (cfg: unknown, channel: unknown, accountId?: unknown) => string
32
+ >(() => "length");
33
+ const chunkMarkdownTextWithModeMock = vi.fn((text: string) => [text]);
34
+
35
+ const runtimeStub = {
36
+ config: {
37
+ loadConfig: () => loadConfigMock(),
38
+ },
39
+ channel: {
40
+ text: {
41
+ resolveMarkdownTableMode: (params: unknown) => resolveMarkdownTableModeMock(params),
42
+ convertMarkdownTables: (text: string) => convertMarkdownTablesMock(text),
43
+ resolveChunkMode: (cfg: unknown, channel: unknown, accountId?: unknown) =>
44
+ resolveChunkModeMock(cfg, channel, accountId),
45
+ chunkMarkdownTextWithMode: (text: string) => chunkMarkdownTextWithModeMock(text),
46
+ },
47
+ },
48
+ logging: {
49
+ shouldLogVerbose: () => false,
50
+ },
51
+ } as unknown as PluginRuntime;
52
+
53
+ const runtimeEnv: RuntimeEnv = {
54
+ log: vi.fn(),
55
+ error: vi.fn(),
56
+ } as unknown as RuntimeEnv;
57
+
58
+ beforeEach(() => {
59
+ vi.clearAllMocks();
60
+ setMatrixRuntime(runtimeStub);
61
+ chunkMatrixTextMock.mockReset().mockImplementation((text: string) => ({
62
+ trimmedText: text.trim(),
63
+ convertedText: text,
64
+ singleEventLimit: 4000,
65
+ fitsInSingleEvent: true,
66
+ chunks: text ? [text] : [],
67
+ }));
68
+ });
69
+
70
+ it("keeps replyToId on first reply only when replyToMode=first", async () => {
71
+ chunkMatrixTextMock.mockImplementation((text: string) => ({
72
+ trimmedText: text.trim(),
73
+ convertedText: text,
74
+ singleEventLimit: 4000,
75
+ fitsInSingleEvent: true,
76
+ chunks: text.split("|"),
77
+ }));
78
+
79
+ await deliverMatrixReplies({
80
+ cfg,
81
+ replies: [
82
+ { text: "first-a|first-b", replyToId: "reply-1" },
83
+ { text: "second", replyToId: "reply-2" },
84
+ ],
85
+ roomId: "room:1",
86
+ client: {} as MatrixClient,
87
+ runtime: runtimeEnv,
88
+ textLimit: 4000,
89
+ replyToMode: "first",
90
+ });
91
+
92
+ expect(sendMessageMatrixMock).toHaveBeenCalledTimes(3);
93
+ expect(sendMessageMatrixMock.mock.calls[0]?.[2]).toEqual(
94
+ expect.objectContaining({ replyToId: "reply-1", threadId: undefined }),
95
+ );
96
+ expect(sendMessageMatrixMock.mock.calls[1]?.[2]).toEqual(
97
+ expect.objectContaining({ replyToId: "reply-1", threadId: undefined }),
98
+ );
99
+ expect(sendMessageMatrixMock.mock.calls[2]?.[2]).toEqual(
100
+ expect.objectContaining({ replyToId: undefined, threadId: undefined }),
101
+ );
102
+ });
103
+
104
+ it("keeps replyToId on every reply when replyToMode=all", async () => {
105
+ await deliverMatrixReplies({
106
+ cfg,
107
+ replies: [
108
+ {
109
+ text: "caption",
110
+ mediaUrls: ["https://example.com/a.jpg", "https://example.com/b.jpg"],
111
+ replyToId: "reply-media",
112
+ audioAsVoice: true,
113
+ },
114
+ { text: "plain", replyToId: "reply-text" },
115
+ ],
116
+ roomId: "room:2",
117
+ client: {} as MatrixClient,
118
+ runtime: runtimeEnv,
119
+ textLimit: 4000,
120
+ replyToMode: "all",
121
+ mediaLocalRoots: ["/tmp/openclaw-matrix-test"],
122
+ });
123
+
124
+ expect(sendMessageMatrixMock).toHaveBeenCalledTimes(3);
125
+ expect(sendMessageMatrixMock.mock.calls[0]).toEqual([
126
+ "room:2",
127
+ "caption",
128
+ expect.objectContaining({
129
+ mediaUrl: "https://example.com/a.jpg",
130
+ mediaLocalRoots: ["/tmp/openclaw-matrix-test"],
131
+ replyToId: "reply-media",
132
+ }),
133
+ ]);
134
+ expect(sendMessageMatrixMock.mock.calls[1]).toEqual([
135
+ "room:2",
136
+ "",
137
+ expect.objectContaining({
138
+ mediaUrl: "https://example.com/b.jpg",
139
+ mediaLocalRoots: ["/tmp/openclaw-matrix-test"],
140
+ replyToId: "reply-media",
141
+ }),
142
+ ]);
143
+ expect(sendMessageMatrixMock.mock.calls[2]?.[2]).toEqual(
144
+ expect.objectContaining({ replyToId: "reply-text" }),
145
+ );
146
+ });
147
+
148
+ it("suppresses replyToId when threadId is set", async () => {
149
+ chunkMatrixTextMock.mockImplementation((text: string) => ({
150
+ trimmedText: text.trim(),
151
+ convertedText: text,
152
+ singleEventLimit: 4000,
153
+ fitsInSingleEvent: true,
154
+ chunks: text.split("|"),
155
+ }));
156
+
157
+ await deliverMatrixReplies({
158
+ cfg,
159
+ replies: [{ text: "hello|thread", replyToId: "reply-thread" }],
160
+ roomId: "room:3",
161
+ client: {} as MatrixClient,
162
+ runtime: runtimeEnv,
163
+ textLimit: 4000,
164
+ replyToMode: "all",
165
+ threadId: "thread-77",
166
+ });
167
+
168
+ expect(sendMessageMatrixMock).toHaveBeenCalledTimes(2);
169
+ expect(sendMessageMatrixMock.mock.calls[0]?.[2]).toEqual(
170
+ expect.objectContaining({ replyToId: undefined, threadId: "thread-77" }),
171
+ );
172
+ expect(sendMessageMatrixMock.mock.calls[1]?.[2]).toEqual(
173
+ expect.objectContaining({ replyToId: undefined, threadId: "thread-77" }),
174
+ );
175
+ });
176
+
177
+ it("suppresses reasoning-only text before Matrix sends", async () => {
178
+ await deliverMatrixReplies({
179
+ cfg,
180
+ replies: [
181
+ { text: "Reasoning:\n_hidden_" },
182
+ { text: "<think>still hidden</think>" },
183
+ { text: "Visible answer" },
184
+ ],
185
+ roomId: "room:5",
186
+ client: {} as MatrixClient,
187
+ runtime: runtimeEnv,
188
+ textLimit: 4000,
189
+ replyToMode: "off",
190
+ });
191
+
192
+ expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1);
193
+ expect(sendMessageMatrixMock).toHaveBeenCalledWith(
194
+ "room:5",
195
+ "Visible answer",
196
+ expect.objectContaining({ cfg }),
197
+ );
198
+ });
199
+
200
+ it("uses supplied cfg for chunking and send delivery without reloading runtime config", async () => {
201
+ const explicitCfg = {
202
+ channels: {
203
+ matrix: {
204
+ accounts: {
205
+ ops: {
206
+ chunkMode: "newline",
207
+ },
208
+ },
209
+ },
210
+ },
211
+ };
212
+ loadConfigMock.mockImplementation(() => {
213
+ throw new Error("deliverMatrixReplies should not reload runtime config when cfg is provided");
214
+ });
215
+
216
+ await deliverMatrixReplies({
217
+ cfg: explicitCfg,
218
+ replies: [{ text: "hello", replyToId: "reply-1" }],
219
+ roomId: "room:4",
220
+ client: {} as MatrixClient,
221
+ runtime: runtimeEnv,
222
+ textLimit: 4000,
223
+ replyToMode: "all",
224
+ accountId: "ops",
225
+ });
226
+
227
+ expect(loadConfigMock).not.toHaveBeenCalled();
228
+ expect(chunkMatrixTextMock).toHaveBeenCalledWith("hello", {
229
+ cfg: explicitCfg,
230
+ accountId: "ops",
231
+ tableMode: "code",
232
+ });
233
+ expect(sendMessageMatrixMock).toHaveBeenCalledWith(
234
+ "room:4",
235
+ "hello",
236
+ expect.objectContaining({
237
+ cfg: explicitCfg,
238
+ accountId: "ops",
239
+ replyToId: "reply-1",
240
+ }),
241
+ );
242
+ });
243
+
244
+ it("passes raw media captions through to sendMessageMatrix without pre-converting them", async () => {
245
+ convertMarkdownTablesMock.mockImplementation((text: string) => `converted:${text}`);
246
+
247
+ await deliverMatrixReplies({
248
+ cfg,
249
+ replies: [{ text: "caption", mediaUrl: "https://example.com/a.jpg" }],
250
+ roomId: "room:6",
251
+ client: {} as MatrixClient,
252
+ runtime: runtimeEnv,
253
+ textLimit: 4000,
254
+ replyToMode: "off",
255
+ });
256
+
257
+ expect(sendMessageMatrixMock).toHaveBeenCalledWith(
258
+ "room:6",
259
+ "caption",
260
+ expect.objectContaining({
261
+ mediaUrl: "https://example.com/a.jpg",
262
+ }),
263
+ );
264
+ });
265
+ });
@@ -0,0 +1,136 @@
1
+ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
2
+ import { getMatrixRuntime } from "../../runtime.js";
3
+ import type { MatrixClient } from "../sdk.js";
4
+ import { chunkMatrixText, sendMessageMatrix } from "../send.js";
5
+ import type { MarkdownTableMode, OpenClawConfig, ReplyPayload, RuntimeEnv } from "./runtime-api.js";
6
+
7
+ const THINKING_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought|antthinking)\b[^<>]*>/gi;
8
+ const THINKING_BLOCK_RE =
9
+ /<\s*(?:think(?:ing)?|thought|antthinking)\b[^<>]*>[\s\S]*?<\s*\/\s*(?:think(?:ing)?|thought|antthinking)\s*>/gi;
10
+
11
+ function shouldSuppressReasoningReplyText(text?: string): boolean {
12
+ if (typeof text !== "string") {
13
+ return false;
14
+ }
15
+ const trimmedStart = text.trimStart();
16
+ if (!trimmedStart) {
17
+ return false;
18
+ }
19
+ if (normalizeLowercaseStringOrEmpty(trimmedStart).startsWith("reasoning:")) {
20
+ return true;
21
+ }
22
+ THINKING_TAG_RE.lastIndex = 0;
23
+ if (!THINKING_TAG_RE.test(text)) {
24
+ return false;
25
+ }
26
+ THINKING_BLOCK_RE.lastIndex = 0;
27
+ const withoutThinkingBlocks = text.replace(THINKING_BLOCK_RE, "");
28
+ THINKING_TAG_RE.lastIndex = 0;
29
+ return !withoutThinkingBlocks.replace(THINKING_TAG_RE, "").trim();
30
+ }
31
+
32
+ export async function deliverMatrixReplies(params: {
33
+ cfg: OpenClawConfig;
34
+ replies: ReplyPayload[];
35
+ roomId: string;
36
+ client: MatrixClient;
37
+ runtime: RuntimeEnv;
38
+ textLimit: number;
39
+ replyToMode: "off" | "first" | "all" | "batched";
40
+ threadId?: string;
41
+ accountId?: string;
42
+ mediaLocalRoots?: readonly string[];
43
+ tableMode?: MarkdownTableMode;
44
+ }): Promise<boolean> {
45
+ const core = getMatrixRuntime();
46
+ const tableMode =
47
+ params.tableMode ??
48
+ core.channel.text.resolveMarkdownTableMode({
49
+ cfg: params.cfg,
50
+ channel: "matrix",
51
+ accountId: params.accountId,
52
+ });
53
+ const logVerbose = (message: string) => {
54
+ if (core.logging.shouldLogVerbose()) {
55
+ params.runtime.log?.(message);
56
+ }
57
+ };
58
+ let hasReplied = false;
59
+ let deliveredAny = false;
60
+ for (const reply of params.replies) {
61
+ if (reply.isReasoning === true || shouldSuppressReasoningReplyText(reply.text)) {
62
+ logVerbose("matrix reply suppressed as reasoning-only");
63
+ continue;
64
+ }
65
+ const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
66
+ if (!reply?.text && !hasMedia) {
67
+ if (reply?.audioAsVoice) {
68
+ logVerbose("matrix reply has audioAsVoice without media/text; skipping");
69
+ continue;
70
+ }
71
+ params.runtime.error?.("matrix reply missing text/media");
72
+ continue;
73
+ }
74
+ const replyToIdRaw = reply.replyToId?.trim();
75
+ const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw;
76
+ const rawText = reply.text ?? "";
77
+ const mediaList = reply.mediaUrls?.length
78
+ ? reply.mediaUrls
79
+ : reply.mediaUrl
80
+ ? [reply.mediaUrl]
81
+ : [];
82
+
83
+ const shouldIncludeReply = (id?: string) =>
84
+ Boolean(id) && (params.replyToMode === "all" || !hasReplied);
85
+ const replyToIdForReply = shouldIncludeReply(replyToId) ? replyToId : undefined;
86
+
87
+ if (mediaList.length === 0) {
88
+ let sentTextChunk = false;
89
+ const { chunks } = chunkMatrixText(rawText, {
90
+ cfg: params.cfg,
91
+ accountId: params.accountId,
92
+ tableMode,
93
+ });
94
+ for (const chunk of chunks) {
95
+ const trimmed = chunk.trim();
96
+ if (!trimmed) {
97
+ continue;
98
+ }
99
+ await sendMessageMatrix(params.roomId, trimmed, {
100
+ client: params.client,
101
+ cfg: params.cfg,
102
+ replyToId: replyToIdForReply,
103
+ threadId: params.threadId,
104
+ accountId: params.accountId,
105
+ });
106
+ deliveredAny = true;
107
+ sentTextChunk = true;
108
+ }
109
+ if (replyToIdForReply && !hasReplied && sentTextChunk) {
110
+ hasReplied = true;
111
+ }
112
+ continue;
113
+ }
114
+
115
+ let first = true;
116
+ for (const mediaUrl of mediaList) {
117
+ const caption = first ? rawText : "";
118
+ await sendMessageMatrix(params.roomId, caption, {
119
+ client: params.client,
120
+ cfg: params.cfg,
121
+ mediaUrl,
122
+ mediaLocalRoots: params.mediaLocalRoots,
123
+ replyToId: replyToIdForReply,
124
+ threadId: params.threadId,
125
+ audioAsVoice: reply.audioAsVoice,
126
+ accountId: params.accountId,
127
+ });
128
+ deliveredAny = true;
129
+ first = false;
130
+ }
131
+ if (replyToIdForReply && !hasReplied) {
132
+ hasReplied = true;
133
+ }
134
+ }
135
+ return deliveredAny;
136
+ }
@@ -0,0 +1,276 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createMatrixReplyContextResolver, summarizeMatrixReplyEvent } from "./reply-context.js";
3
+ import type { MatrixRawEvent } from "./types.js";
4
+
5
+ describe("matrix reply context", () => {
6
+ it("summarizes reply events from body text", () => {
7
+ expect(
8
+ summarizeMatrixReplyEvent({
9
+ event_id: "$original",
10
+ sender: "@alice:example.org",
11
+ type: "m.room.message",
12
+ origin_server_ts: Date.now(),
13
+ content: {
14
+ msgtype: "m.text",
15
+ body: " Some quoted message ",
16
+ },
17
+ } as MatrixRawEvent),
18
+ ).toBe("Some quoted message");
19
+ });
20
+
21
+ it("truncates long reply bodies", () => {
22
+ const longBody = "x".repeat(600);
23
+ const result = summarizeMatrixReplyEvent({
24
+ event_id: "$original",
25
+ sender: "@alice:example.org",
26
+ type: "m.room.message",
27
+ origin_server_ts: Date.now(),
28
+ content: {
29
+ msgtype: "m.text",
30
+ body: longBody,
31
+ },
32
+ } as MatrixRawEvent);
33
+ expect(result).toBeDefined();
34
+ expect(result!.length).toBeLessThanOrEqual(500);
35
+ expect(result!.endsWith("...")).toBe(true);
36
+ });
37
+
38
+ it("handles media-only reply events", () => {
39
+ expect(
40
+ summarizeMatrixReplyEvent({
41
+ event_id: "$original",
42
+ sender: "@alice:example.org",
43
+ type: "m.room.message",
44
+ origin_server_ts: Date.now(),
45
+ content: {
46
+ msgtype: "m.image",
47
+ body: "photo.jpg",
48
+ },
49
+ } as MatrixRawEvent),
50
+ ).toBe("[matrix image attachment]");
51
+ });
52
+
53
+ it("summarizes poll start events from poll content", () => {
54
+ expect(
55
+ summarizeMatrixReplyEvent({
56
+ event_id: "$poll",
57
+ sender: "@alice:example.org",
58
+ type: "m.poll.start",
59
+ origin_server_ts: Date.now(),
60
+ content: {
61
+ "m.poll.start": {
62
+ question: { "m.text": "Lunch?" },
63
+ kind: "m.poll.disclosed",
64
+ max_selections: 1,
65
+ answers: [
66
+ { id: "a1", "m.text": "Pizza" },
67
+ { id: "a2", "m.text": "Sushi" },
68
+ ],
69
+ },
70
+ },
71
+ } as MatrixRawEvent),
72
+ ).toBe("[Poll]\nLunch?\n\n1. Pizza\n2. Sushi");
73
+ });
74
+
75
+ it("resolves and caches reply context", async () => {
76
+ const getEvent = vi.fn(async () => ({
77
+ event_id: "$original",
78
+ sender: "@alice:example.org",
79
+ type: "m.room.message",
80
+ origin_server_ts: Date.now(),
81
+ content: {
82
+ msgtype: "m.text",
83
+ body: "This is the original message",
84
+ },
85
+ }));
86
+ const getMemberDisplayName = vi.fn(async () => "Alice");
87
+ const resolveReplyContext = createMatrixReplyContextResolver({
88
+ client: {
89
+ getEvent,
90
+ } as never,
91
+ getMemberDisplayName,
92
+ logVerboseMessage: () => {},
93
+ });
94
+
95
+ const result = await resolveReplyContext({
96
+ roomId: "!room:example.org",
97
+ eventId: "$original",
98
+ });
99
+
100
+ expect(result).toEqual({
101
+ replyToBody: "This is the original message",
102
+ replyToSender: "Alice",
103
+ replyToSenderId: "@alice:example.org",
104
+ });
105
+
106
+ // Second call should use cache
107
+ await resolveReplyContext({
108
+ roomId: "!room:example.org",
109
+ eventId: "$original",
110
+ });
111
+
112
+ expect(getEvent).toHaveBeenCalledTimes(1);
113
+ expect(getMemberDisplayName).toHaveBeenCalledTimes(1);
114
+ });
115
+
116
+ it("returns empty context when event fetch fails", async () => {
117
+ const getEvent = vi.fn().mockRejectedValueOnce(new Error("not found"));
118
+ const getMemberDisplayName = vi.fn(async () => "Alice");
119
+ const resolveReplyContext = createMatrixReplyContextResolver({
120
+ client: {
121
+ getEvent,
122
+ } as never,
123
+ getMemberDisplayName,
124
+ logVerboseMessage: () => {},
125
+ });
126
+
127
+ const result = await resolveReplyContext({
128
+ roomId: "!room:example.org",
129
+ eventId: "$missing",
130
+ });
131
+
132
+ expect(result).toEqual({});
133
+ });
134
+
135
+ it("returns empty context for redacted events", async () => {
136
+ const getEvent = vi.fn(async () => ({
137
+ event_id: "$redacted",
138
+ sender: "@alice:example.org",
139
+ type: "m.room.message",
140
+ origin_server_ts: Date.now(),
141
+ unsigned: {
142
+ redacted_because: { type: "m.room.redaction" },
143
+ },
144
+ content: {},
145
+ }));
146
+ const getMemberDisplayName = vi.fn(async () => "Alice");
147
+ const resolveReplyContext = createMatrixReplyContextResolver({
148
+ client: {
149
+ getEvent,
150
+ } as never,
151
+ getMemberDisplayName,
152
+ logVerboseMessage: () => {},
153
+ });
154
+
155
+ const result = await resolveReplyContext({
156
+ roomId: "!room:example.org",
157
+ eventId: "$redacted",
158
+ });
159
+
160
+ expect(result).toEqual({});
161
+ expect(getMemberDisplayName).not.toHaveBeenCalled();
162
+ });
163
+
164
+ it("does not cache fetch failures so retries can succeed", async () => {
165
+ const getEvent = vi
166
+ .fn()
167
+ .mockRejectedValueOnce(new Error("temporary failure"))
168
+ .mockResolvedValueOnce({
169
+ event_id: "$original",
170
+ sender: "@bob:example.org",
171
+ type: "m.room.message",
172
+ origin_server_ts: Date.now(),
173
+ content: {
174
+ msgtype: "m.text",
175
+ body: "Recovered message",
176
+ },
177
+ });
178
+ const getMemberDisplayName = vi.fn(async () => "Bob");
179
+ const resolveReplyContext = createMatrixReplyContextResolver({
180
+ client: {
181
+ getEvent,
182
+ } as never,
183
+ getMemberDisplayName,
184
+ logVerboseMessage: () => {},
185
+ });
186
+
187
+ // First call fails
188
+ const first = await resolveReplyContext({
189
+ roomId: "!room:example.org",
190
+ eventId: "$original",
191
+ });
192
+ expect(first).toEqual({});
193
+
194
+ // Second call succeeds (should retry, not use cached failure)
195
+ const second = await resolveReplyContext({
196
+ roomId: "!room:example.org",
197
+ eventId: "$original",
198
+ });
199
+ expect(second).toEqual({
200
+ replyToBody: "Recovered message",
201
+ replyToSender: "Bob",
202
+ replyToSenderId: "@bob:example.org",
203
+ });
204
+
205
+ expect(getEvent).toHaveBeenCalledTimes(2);
206
+ });
207
+
208
+ it("falls back to senderId when display name resolution fails", async () => {
209
+ const getEvent = vi.fn(async () => ({
210
+ event_id: "$original",
211
+ sender: "@charlie:example.org",
212
+ type: "m.room.message",
213
+ origin_server_ts: Date.now(),
214
+ content: {
215
+ msgtype: "m.text",
216
+ body: "Hello",
217
+ },
218
+ }));
219
+ const getMemberDisplayName = vi.fn().mockRejectedValueOnce(new Error("unknown member"));
220
+ const resolveReplyContext = createMatrixReplyContextResolver({
221
+ client: {
222
+ getEvent,
223
+ } as never,
224
+ getMemberDisplayName,
225
+ logVerboseMessage: () => {},
226
+ });
227
+
228
+ const result = await resolveReplyContext({
229
+ roomId: "!room:example.org",
230
+ eventId: "$original",
231
+ });
232
+
233
+ expect(result).toEqual({
234
+ replyToBody: "Hello",
235
+ replyToSender: "@charlie:example.org",
236
+ replyToSenderId: "@charlie:example.org",
237
+ });
238
+ });
239
+
240
+ it("uses LRU eviction — recently accessed entries survive over older ones", async () => {
241
+ let callCount = 0;
242
+ const getEvent = vi.fn().mockImplementation((_roomId: string, eventId: string) => {
243
+ callCount++;
244
+ return Promise.resolve({
245
+ event_id: eventId,
246
+ sender: `@user${callCount}:example.org`,
247
+ type: "m.room.message",
248
+ origin_server_ts: Date.now(),
249
+ content: { msgtype: "m.text", body: `msg-${eventId}` },
250
+ });
251
+ });
252
+ const getMemberDisplayName = vi
253
+ .fn()
254
+ .mockImplementation((_r: string, userId: string) => Promise.resolve(userId));
255
+
256
+ // Use a small cache by testing the eviction pattern:
257
+ // The actual MAX_CACHED_REPLY_CONTEXTS is 256. We cannot override it easily,
258
+ // but we can verify that a cache hit reorders entries (delete + re-insert).
259
+ const resolveReplyContext = createMatrixReplyContextResolver({
260
+ client: { getEvent } as never,
261
+ getMemberDisplayName,
262
+ logVerboseMessage: () => {},
263
+ });
264
+
265
+ // Populate cache with two entries
266
+ await resolveReplyContext({ roomId: "!r:e", eventId: "$A" });
267
+ await resolveReplyContext({ roomId: "!r:e", eventId: "$B" });
268
+ expect(getEvent).toHaveBeenCalledTimes(2);
269
+
270
+ // Access $A again — should be a cache hit (no new getEvent call)
271
+ // and should move $A to the end of the Map for LRU.
272
+ const hitResult = await resolveReplyContext({ roomId: "!r:e", eventId: "$A" });
273
+ expect(getEvent).toHaveBeenCalledTimes(2); // Still 2 — cache hit
274
+ expect(hitResult.replyToBody).toBe("msg-$A");
275
+ });
276
+ });