@badgerclaw/connect 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 (70) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/SETUP.md +131 -0
  3. package/index.ts +23 -0
  4. package/openclaw.plugin.json +1 -0
  5. package/package.json +32 -0
  6. package/src/actions.ts +195 -0
  7. package/src/channel.ts +461 -0
  8. package/src/config-schema.ts +62 -0
  9. package/src/connect.ts +17 -0
  10. package/src/directory-live.ts +209 -0
  11. package/src/group-mentions.ts +52 -0
  12. package/src/matrix/accounts.ts +114 -0
  13. package/src/matrix/actions/client.ts +47 -0
  14. package/src/matrix/actions/limits.ts +6 -0
  15. package/src/matrix/actions/messages.ts +126 -0
  16. package/src/matrix/actions/pins.ts +84 -0
  17. package/src/matrix/actions/reactions.ts +102 -0
  18. package/src/matrix/actions/room.ts +85 -0
  19. package/src/matrix/actions/summary.ts +75 -0
  20. package/src/matrix/actions/types.ts +85 -0
  21. package/src/matrix/actions.ts +15 -0
  22. package/src/matrix/active-client.ts +32 -0
  23. package/src/matrix/client/config.ts +245 -0
  24. package/src/matrix/client/create-client.ts +125 -0
  25. package/src/matrix/client/logging.ts +46 -0
  26. package/src/matrix/client/runtime.ts +4 -0
  27. package/src/matrix/client/shared.ts +210 -0
  28. package/src/matrix/client/startup.ts +29 -0
  29. package/src/matrix/client/storage.ts +131 -0
  30. package/src/matrix/client/types.ts +34 -0
  31. package/src/matrix/client-bootstrap.ts +47 -0
  32. package/src/matrix/client.ts +14 -0
  33. package/src/matrix/credentials.ts +125 -0
  34. package/src/matrix/deps.ts +126 -0
  35. package/src/matrix/format.ts +22 -0
  36. package/src/matrix/index.ts +11 -0
  37. package/src/matrix/monitor/access-policy.ts +126 -0
  38. package/src/matrix/monitor/allowlist.ts +94 -0
  39. package/src/matrix/monitor/auto-join.ts +72 -0
  40. package/src/matrix/monitor/direct.ts +152 -0
  41. package/src/matrix/monitor/events.ts +168 -0
  42. package/src/matrix/monitor/handler.ts +768 -0
  43. package/src/matrix/monitor/inbound-body.ts +28 -0
  44. package/src/matrix/monitor/index.ts +414 -0
  45. package/src/matrix/monitor/location.ts +100 -0
  46. package/src/matrix/monitor/media.ts +118 -0
  47. package/src/matrix/monitor/mentions.ts +62 -0
  48. package/src/matrix/monitor/replies.ts +124 -0
  49. package/src/matrix/monitor/room-info.ts +55 -0
  50. package/src/matrix/monitor/rooms.ts +47 -0
  51. package/src/matrix/monitor/threads.ts +68 -0
  52. package/src/matrix/monitor/types.ts +39 -0
  53. package/src/matrix/poll-types.ts +167 -0
  54. package/src/matrix/probe.ts +69 -0
  55. package/src/matrix/sdk-runtime.ts +18 -0
  56. package/src/matrix/send/client.ts +99 -0
  57. package/src/matrix/send/formatting.ts +93 -0
  58. package/src/matrix/send/media.ts +230 -0
  59. package/src/matrix/send/targets.ts +150 -0
  60. package/src/matrix/send/types.ts +110 -0
  61. package/src/matrix/send-queue.ts +28 -0
  62. package/src/matrix/send.ts +267 -0
  63. package/src/onboarding.ts +331 -0
  64. package/src/outbound.ts +58 -0
  65. package/src/resolve-targets.ts +125 -0
  66. package/src/runtime.ts +6 -0
  67. package/src/secret-input.ts +13 -0
  68. package/src/test-mocks.ts +53 -0
  69. package/src/tool-actions.ts +164 -0
  70. package/src/types.ts +118 -0
@@ -0,0 +1,62 @@
1
+ import { getMatrixRuntime } from "../../runtime.js";
2
+
3
+ // Type for room message content with mentions
4
+ type MessageContentWithMentions = {
5
+ msgtype: string;
6
+ body: string;
7
+ formatted_body?: string;
8
+ "m.mentions"?: {
9
+ user_ids?: string[];
10
+ room?: boolean;
11
+ };
12
+ };
13
+
14
+ /**
15
+ * Check if the formatted_body contains a matrix.to mention link for the given user ID.
16
+ * Many Matrix clients (including Element) use HTML links in formatted_body instead of
17
+ * or in addition to the m.mentions field.
18
+ */
19
+ function checkFormattedBodyMention(formattedBody: string | undefined, userId: string): boolean {
20
+ if (!formattedBody || !userId) {
21
+ return false;
22
+ }
23
+ // Escape special regex characters in the user ID (e.g., @user:matrix.org)
24
+ const escapedUserId = userId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
25
+ // Match matrix.to links with the user ID, handling both URL-encoded and plain formats
26
+ // Example: href="https://matrix.to/#/@user:matrix.org" or href="https://matrix.to/#/%40user%3Amatrix.org"
27
+ const plainPattern = new RegExp(`href=["']https://matrix\\.to/#/${escapedUserId}["']`, "i");
28
+ if (plainPattern.test(formattedBody)) {
29
+ return true;
30
+ }
31
+ // Also check URL-encoded version (@ -> %40, : -> %3A)
32
+ const encodedUserId = encodeURIComponent(userId).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
33
+ const encodedPattern = new RegExp(`href=["']https://matrix\\.to/#/${encodedUserId}["']`, "i");
34
+ return encodedPattern.test(formattedBody);
35
+ }
36
+
37
+ export function resolveMentions(params: {
38
+ content: MessageContentWithMentions;
39
+ userId?: string | null;
40
+ text?: string;
41
+ mentionRegexes: RegExp[];
42
+ }) {
43
+ const mentions = params.content["m.mentions"];
44
+ const mentionedUsers = Array.isArray(mentions?.user_ids)
45
+ ? new Set(mentions.user_ids)
46
+ : new Set<string>();
47
+
48
+ // Check formatted_body for matrix.to mention links (legacy/alternative mention format)
49
+ const mentionedInFormattedBody = params.userId
50
+ ? checkFormattedBodyMention(params.content.formatted_body, params.userId)
51
+ : false;
52
+
53
+ const wasMentioned =
54
+ Boolean(mentions?.room) ||
55
+ (params.userId ? mentionedUsers.has(params.userId) : false) ||
56
+ mentionedInFormattedBody ||
57
+ getMatrixRuntime().channel.mentions.matchesMentionPatterns(
58
+ params.text ?? "",
59
+ params.mentionRegexes,
60
+ );
61
+ return { wasMentioned, hasExplicitMention: Boolean(mentions) };
62
+ }
@@ -0,0 +1,124 @@
1
+ import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
2
+ import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk/matrix";
3
+ import { getMatrixRuntime } from "../../runtime.js";
4
+ import { sendMessageMatrix } from "../send.js";
5
+
6
+ export async function deliverMatrixReplies(params: {
7
+ replies: ReplyPayload[];
8
+ roomId: string;
9
+ client: MatrixClient;
10
+ runtime: RuntimeEnv;
11
+ textLimit: number;
12
+ replyToMode: "off" | "first" | "all";
13
+ threadId?: string;
14
+ accountId?: string;
15
+ tableMode?: MarkdownTableMode;
16
+ }): Promise<void> {
17
+ const core = getMatrixRuntime();
18
+ const cfg = core.config.loadConfig();
19
+ const tableMode =
20
+ params.tableMode ??
21
+ core.channel.text.resolveMarkdownTableMode({
22
+ cfg,
23
+ channel: "badgerclaw",
24
+ accountId: params.accountId,
25
+ });
26
+ const logVerbose = (message: string) => {
27
+ if (core.logging.shouldLogVerbose()) {
28
+ params.runtime.log?.(message);
29
+ }
30
+ };
31
+ const chunkLimit = Math.min(params.textLimit, 4000);
32
+ const chunkMode = core.channel.text.resolveChunkMode(cfg, "badgerclaw", params.accountId);
33
+ let hasReplied = false;
34
+ for (const reply of params.replies) {
35
+ const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
36
+ if (!reply?.text && !hasMedia) {
37
+ if (reply?.audioAsVoice) {
38
+ logVerbose("badgerclaw reply has audioAsVoice without media/text; skipping");
39
+ continue;
40
+ }
41
+ params.runtime.error?.("badgerclaw reply missing text/media");
42
+ continue;
43
+ }
44
+ // Skip pure reasoning messages so internal thinking traces are never delivered.
45
+ if (reply.text && isReasoningOnlyMessage(reply.text)) {
46
+ logVerbose("badgerclaw reply is reasoning-only; skipping");
47
+ continue;
48
+ }
49
+ const replyToIdRaw = reply.replyToId?.trim();
50
+ const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw;
51
+ const rawText = reply.text ?? "";
52
+ const text = core.channel.text.convertMarkdownTables(rawText, tableMode);
53
+ const mediaList = reply.mediaUrls?.length
54
+ ? reply.mediaUrls
55
+ : reply.mediaUrl
56
+ ? [reply.mediaUrl]
57
+ : [];
58
+
59
+ const shouldIncludeReply = (id?: string) =>
60
+ Boolean(id) && (params.replyToMode === "all" || !hasReplied);
61
+ const replyToIdForReply = shouldIncludeReply(replyToId) ? replyToId : undefined;
62
+
63
+ if (mediaList.length === 0) {
64
+ let sentTextChunk = false;
65
+ for (const chunk of core.channel.text.chunkMarkdownTextWithMode(
66
+ text,
67
+ chunkLimit,
68
+ chunkMode,
69
+ )) {
70
+ const trimmed = chunk.trim();
71
+ if (!trimmed) {
72
+ continue;
73
+ }
74
+ await sendMessageMatrix(params.roomId, trimmed, {
75
+ client: params.client,
76
+ replyToId: replyToIdForReply,
77
+ threadId: params.threadId,
78
+ accountId: params.accountId,
79
+ });
80
+ sentTextChunk = true;
81
+ }
82
+ if (replyToIdForReply && !hasReplied && sentTextChunk) {
83
+ hasReplied = true;
84
+ }
85
+ continue;
86
+ }
87
+
88
+ let first = true;
89
+ for (const mediaUrl of mediaList) {
90
+ const caption = first ? text : "";
91
+ await sendMessageMatrix(params.roomId, caption, {
92
+ client: params.client,
93
+ mediaUrl,
94
+ replyToId: replyToIdForReply,
95
+ threadId: params.threadId,
96
+ audioAsVoice: reply.audioAsVoice,
97
+ accountId: params.accountId,
98
+ });
99
+ first = false;
100
+ }
101
+ if (replyToIdForReply && !hasReplied) {
102
+ hasReplied = true;
103
+ }
104
+ }
105
+ }
106
+
107
+ const REASONING_PREFIX = "Reasoning:\n";
108
+ const THINKING_TAG_RE = /^\s*<\s*(?:think(?:ing)?|thought|antthinking)\b/i;
109
+
110
+ /**
111
+ * Detect messages that contain only reasoning/thinking content and no user-facing answer.
112
+ * These are emitted by the agent when `includeReasoning` is active but should not
113
+ * be forwarded to channels that do not support a dedicated reasoning lane.
114
+ */
115
+ function isReasoningOnlyMessage(text: string): boolean {
116
+ const trimmed = text.trim();
117
+ if (trimmed.startsWith(REASONING_PREFIX)) {
118
+ return true;
119
+ }
120
+ if (THINKING_TAG_RE.test(trimmed)) {
121
+ return true;
122
+ }
123
+ return false;
124
+ }
@@ -0,0 +1,55 @@
1
+ import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
2
+
3
+ export type MatrixRoomInfo = {
4
+ name?: string;
5
+ canonicalAlias?: string;
6
+ altAliases: string[];
7
+ };
8
+
9
+ export function createMatrixRoomInfoResolver(client: MatrixClient) {
10
+ const roomInfoCache = new Map<string, MatrixRoomInfo>();
11
+
12
+ const getRoomInfo = async (roomId: string): Promise<MatrixRoomInfo> => {
13
+ const cached = roomInfoCache.get(roomId);
14
+ if (cached) {
15
+ return cached;
16
+ }
17
+ let name: string | undefined;
18
+ let canonicalAlias: string | undefined;
19
+ let altAliases: string[] = [];
20
+ try {
21
+ const nameState = await client.getRoomStateEvent(roomId, "m.room.name", "").catch(() => null);
22
+ name = nameState?.name;
23
+ } catch {
24
+ // ignore
25
+ }
26
+ try {
27
+ const aliasState = await client
28
+ .getRoomStateEvent(roomId, "m.room.canonical_alias", "")
29
+ .catch(() => null);
30
+ canonicalAlias = aliasState?.alias;
31
+ altAliases = aliasState?.alt_aliases ?? [];
32
+ } catch {
33
+ // ignore
34
+ }
35
+ const info = { name, canonicalAlias, altAliases };
36
+ roomInfoCache.set(roomId, info);
37
+ return info;
38
+ };
39
+
40
+ const getMemberDisplayName = async (roomId: string, userId: string): Promise<string> => {
41
+ try {
42
+ const memberState = await client
43
+ .getRoomStateEvent(roomId, "m.room.member", userId)
44
+ .catch(() => null);
45
+ return memberState?.displayname ?? userId;
46
+ } catch {
47
+ return userId;
48
+ }
49
+ };
50
+
51
+ return {
52
+ getRoomInfo,
53
+ getMemberDisplayName,
54
+ };
55
+ }
@@ -0,0 +1,47 @@
1
+ import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "openclaw/plugin-sdk/matrix";
2
+ import type { MatrixRoomConfig } from "../../types.js";
3
+
4
+ export type MatrixRoomConfigResolved = {
5
+ allowed: boolean;
6
+ allowlistConfigured: boolean;
7
+ config?: MatrixRoomConfig;
8
+ matchKey?: string;
9
+ matchSource?: "direct" | "wildcard";
10
+ };
11
+
12
+ export function resolveMatrixRoomConfig(params: {
13
+ rooms?: Record<string, MatrixRoomConfig>;
14
+ roomId: string;
15
+ aliases: string[];
16
+ name?: string | null;
17
+ }): MatrixRoomConfigResolved {
18
+ const rooms = params.rooms ?? {};
19
+ const keys = Object.keys(rooms);
20
+ const allowlistConfigured = keys.length > 0;
21
+ const candidates = buildChannelKeyCandidates(
22
+ params.roomId,
23
+ `room:${params.roomId}`,
24
+ ...params.aliases,
25
+ );
26
+ const {
27
+ entry: matched,
28
+ key: matchedKey,
29
+ wildcardEntry,
30
+ wildcardKey,
31
+ } = resolveChannelEntryMatch({
32
+ entries: rooms,
33
+ keys: candidates,
34
+ wildcardKey: "*",
35
+ });
36
+ const resolved = matched ?? wildcardEntry;
37
+ const allowed = resolved ? resolved.enabled !== false && resolved.allow !== false : false;
38
+ const matchKey = matchedKey ?? wildcardKey;
39
+ const matchSource = matched ? "direct" : wildcardEntry ? "wildcard" : undefined;
40
+ return {
41
+ allowed,
42
+ allowlistConfigured,
43
+ config: resolved,
44
+ matchKey,
45
+ matchSource,
46
+ };
47
+ }
@@ -0,0 +1,68 @@
1
+ // Type for raw Matrix event from @vector-im/matrix-bot-sdk
2
+ type MatrixRawEvent = {
3
+ event_id: string;
4
+ sender: string;
5
+ type: string;
6
+ origin_server_ts: number;
7
+ content: Record<string, unknown>;
8
+ };
9
+
10
+ type RoomMessageEventContent = {
11
+ msgtype: string;
12
+ body: string;
13
+ "m.relates_to"?: {
14
+ rel_type?: string;
15
+ event_id?: string;
16
+ "m.in_reply_to"?: { event_id?: string };
17
+ };
18
+ };
19
+
20
+ const RelationType = {
21
+ Thread: "m.thread",
22
+ } as const;
23
+
24
+ export function resolveMatrixThreadTarget(params: {
25
+ threadReplies: "off" | "inbound" | "always";
26
+ messageId: string;
27
+ threadRootId?: string;
28
+ isThreadRoot?: boolean;
29
+ }): string | undefined {
30
+ const { threadReplies, messageId, threadRootId } = params;
31
+ if (threadReplies === "off") {
32
+ return undefined;
33
+ }
34
+ const isThreadRoot = params.isThreadRoot === true;
35
+ const hasInboundThread = Boolean(threadRootId && threadRootId !== messageId && !isThreadRoot);
36
+ if (threadReplies === "inbound") {
37
+ return hasInboundThread ? threadRootId : undefined;
38
+ }
39
+ if (threadReplies === "always") {
40
+ return threadRootId ?? messageId;
41
+ }
42
+ return undefined;
43
+ }
44
+
45
+ export function resolveMatrixThreadRootId(params: {
46
+ event: MatrixRawEvent;
47
+ content: RoomMessageEventContent;
48
+ }): string | undefined {
49
+ const relates = params.content["m.relates_to"];
50
+ if (!relates || typeof relates !== "object") {
51
+ return undefined;
52
+ }
53
+ if ("rel_type" in relates && relates.rel_type === RelationType.Thread) {
54
+ if ("event_id" in relates && typeof relates.event_id === "string") {
55
+ return relates.event_id;
56
+ }
57
+ if (
58
+ "m.in_reply_to" in relates &&
59
+ typeof relates["m.in_reply_to"] === "object" &&
60
+ relates["m.in_reply_to"] &&
61
+ "event_id" in relates["m.in_reply_to"] &&
62
+ typeof relates["m.in_reply_to"].event_id === "string"
63
+ ) {
64
+ return relates["m.in_reply_to"].event_id;
65
+ }
66
+ }
67
+ return undefined;
68
+ }
@@ -0,0 +1,39 @@
1
+ import type { EncryptedFile, MessageEventContent } from "@vector-im/matrix-bot-sdk";
2
+
3
+ export const EventType = {
4
+ RoomMessage: "m.room.message",
5
+ RoomMessageEncrypted: "m.room.encrypted",
6
+ RoomMember: "m.room.member",
7
+ Location: "m.location",
8
+ } as const;
9
+
10
+ export const RelationType = {
11
+ Replace: "m.replace",
12
+ Thread: "m.thread",
13
+ } as const;
14
+
15
+ export type MatrixRawEvent = {
16
+ event_id: string;
17
+ sender: string;
18
+ type: string;
19
+ origin_server_ts: number;
20
+ content: Record<string, unknown>;
21
+ unsigned?: {
22
+ age?: number;
23
+ redacted_because?: unknown;
24
+ };
25
+ };
26
+
27
+ export type RoomMessageEventContent = MessageEventContent & {
28
+ url?: string;
29
+ file?: EncryptedFile;
30
+ info?: {
31
+ mimetype?: string;
32
+ size?: number;
33
+ };
34
+ "m.relates_to"?: {
35
+ rel_type?: string;
36
+ event_id?: string;
37
+ "m.in_reply_to"?: { event_id?: string };
38
+ };
39
+ };
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Matrix Poll Types (MSC3381)
3
+ *
4
+ * Defines types for Matrix poll events:
5
+ * - m.poll.start - Creates a new poll
6
+ * - m.poll.response - Records a vote
7
+ * - m.poll.end - Closes a poll
8
+ */
9
+
10
+ import type { PollInput } from "openclaw/plugin-sdk/matrix";
11
+
12
+ export const M_POLL_START = "m.poll.start" as const;
13
+ export const M_POLL_RESPONSE = "m.poll.response" as const;
14
+ export const M_POLL_END = "m.poll.end" as const;
15
+
16
+ export const ORG_POLL_START = "org.matrix.msc3381.poll.start" as const;
17
+ export const ORG_POLL_RESPONSE = "org.matrix.msc3381.poll.response" as const;
18
+ export const ORG_POLL_END = "org.matrix.msc3381.poll.end" as const;
19
+
20
+ export const POLL_EVENT_TYPES = [
21
+ M_POLL_START,
22
+ M_POLL_RESPONSE,
23
+ M_POLL_END,
24
+ ORG_POLL_START,
25
+ ORG_POLL_RESPONSE,
26
+ ORG_POLL_END,
27
+ ];
28
+
29
+ export const POLL_START_TYPES = [M_POLL_START, ORG_POLL_START];
30
+ export const POLL_RESPONSE_TYPES = [M_POLL_RESPONSE, ORG_POLL_RESPONSE];
31
+ export const POLL_END_TYPES = [M_POLL_END, ORG_POLL_END];
32
+
33
+ export type PollKind = "m.poll.disclosed" | "m.poll.undisclosed";
34
+
35
+ export type TextContent = {
36
+ "m.text"?: string;
37
+ "org.matrix.msc1767.text"?: string;
38
+ body?: string;
39
+ };
40
+
41
+ export type PollAnswer = {
42
+ id: string;
43
+ } & TextContent;
44
+
45
+ export type PollStartSubtype = {
46
+ question: TextContent;
47
+ kind?: PollKind;
48
+ max_selections?: number;
49
+ answers: PollAnswer[];
50
+ };
51
+
52
+ export type LegacyPollStartContent = {
53
+ "m.poll"?: PollStartSubtype;
54
+ };
55
+
56
+ export type PollStartContent = {
57
+ [M_POLL_START]?: PollStartSubtype;
58
+ [ORG_POLL_START]?: PollStartSubtype;
59
+ "m.poll"?: PollStartSubtype;
60
+ "m.text"?: string;
61
+ "org.matrix.msc1767.text"?: string;
62
+ };
63
+
64
+ export type PollSummary = {
65
+ eventId: string;
66
+ roomId: string;
67
+ sender: string;
68
+ senderName: string;
69
+ question: string;
70
+ answers: string[];
71
+ kind: PollKind;
72
+ maxSelections: number;
73
+ };
74
+
75
+ export function isPollStartType(eventType: string): boolean {
76
+ return (POLL_START_TYPES as readonly string[]).includes(eventType);
77
+ }
78
+
79
+ export function getTextContent(text?: TextContent): string {
80
+ if (!text) {
81
+ return "";
82
+ }
83
+ return text["m.text"] ?? text["org.matrix.msc1767.text"] ?? text.body ?? "";
84
+ }
85
+
86
+ export function parsePollStartContent(content: PollStartContent): PollSummary | null {
87
+ const poll =
88
+ (content as Record<string, PollStartSubtype | undefined>)[M_POLL_START] ??
89
+ (content as Record<string, PollStartSubtype | undefined>)[ORG_POLL_START] ??
90
+ (content as Record<string, PollStartSubtype | undefined>)["m.poll"];
91
+ if (!poll) {
92
+ return null;
93
+ }
94
+
95
+ const question = getTextContent(poll.question);
96
+ if (!question) {
97
+ return null;
98
+ }
99
+
100
+ const answers = poll.answers
101
+ .map((answer) => getTextContent(answer))
102
+ .filter((a) => a.trim().length > 0);
103
+
104
+ return {
105
+ eventId: "",
106
+ roomId: "",
107
+ sender: "",
108
+ senderName: "",
109
+ question,
110
+ answers,
111
+ kind: poll.kind ?? "m.poll.disclosed",
112
+ maxSelections: poll.max_selections ?? 1,
113
+ };
114
+ }
115
+
116
+ export function formatPollAsText(summary: PollSummary): string {
117
+ const lines = [
118
+ "[Poll]",
119
+ summary.question,
120
+ "",
121
+ ...summary.answers.map((answer, idx) => `${idx + 1}. ${answer}`),
122
+ ];
123
+ return lines.join("\n");
124
+ }
125
+
126
+ function buildTextContent(body: string): TextContent {
127
+ return {
128
+ "m.text": body,
129
+ "org.matrix.msc1767.text": body,
130
+ };
131
+ }
132
+
133
+ function buildPollFallbackText(question: string, answers: string[]): string {
134
+ if (answers.length === 0) {
135
+ return question;
136
+ }
137
+ return `${question}\n${answers.map((answer, idx) => `${idx + 1}. ${answer}`).join("\n")}`;
138
+ }
139
+
140
+ export function buildPollStartContent(poll: PollInput): PollStartContent {
141
+ const question = poll.question.trim();
142
+ const answers = poll.options
143
+ .map((option) => option.trim())
144
+ .filter((option) => option.length > 0)
145
+ .map((option, idx) => ({
146
+ id: `answer${idx + 1}`,
147
+ ...buildTextContent(option),
148
+ }));
149
+
150
+ const isMultiple = (poll.maxSelections ?? 1) > 1;
151
+ const maxSelections = isMultiple ? Math.max(1, answers.length) : 1;
152
+ const fallbackText = buildPollFallbackText(
153
+ question,
154
+ answers.map((answer) => getTextContent(answer)),
155
+ );
156
+
157
+ return {
158
+ [M_POLL_START]: {
159
+ question: buildTextContent(question),
160
+ kind: isMultiple ? "m.poll.undisclosed" : "m.poll.disclosed",
161
+ max_selections: maxSelections,
162
+ answers,
163
+ },
164
+ "m.text": fallbackText,
165
+ "org.matrix.msc1767.text": fallbackText,
166
+ };
167
+ }
@@ -0,0 +1,69 @@
1
+ import type { BaseProbeResult } from "openclaw/plugin-sdk/matrix";
2
+ import { createMatrixClient, isBunRuntime } from "./client.js";
3
+
4
+ export type MatrixProbe = BaseProbeResult & {
5
+ status?: number | null;
6
+ elapsedMs: number;
7
+ userId?: string | null;
8
+ };
9
+
10
+ export async function probeMatrix(params: {
11
+ homeserver: string;
12
+ accessToken: string;
13
+ userId?: string;
14
+ timeoutMs: number;
15
+ }): Promise<MatrixProbe> {
16
+ const started = Date.now();
17
+ const result: MatrixProbe = {
18
+ ok: false,
19
+ status: null,
20
+ error: null,
21
+ elapsedMs: 0,
22
+ };
23
+ if (isBunRuntime()) {
24
+ return {
25
+ ...result,
26
+ error: "BadgerClaw probe requires Node (bun runtime not supported)",
27
+ elapsedMs: Date.now() - started,
28
+ };
29
+ }
30
+ if (!params.homeserver?.trim()) {
31
+ return {
32
+ ...result,
33
+ error: "missing homeserver",
34
+ elapsedMs: Date.now() - started,
35
+ };
36
+ }
37
+ if (!params.accessToken?.trim()) {
38
+ return {
39
+ ...result,
40
+ error: "missing access token",
41
+ elapsedMs: Date.now() - started,
42
+ };
43
+ }
44
+ try {
45
+ const client = await createMatrixClient({
46
+ homeserver: params.homeserver,
47
+ userId: params.userId ?? "",
48
+ accessToken: params.accessToken,
49
+ localTimeoutMs: params.timeoutMs,
50
+ });
51
+ // @vector-im/matrix-bot-sdk uses getUserId() which calls whoami internally
52
+ const userId = await client.getUserId();
53
+ result.ok = true;
54
+ result.userId = userId ?? null;
55
+
56
+ result.elapsedMs = Date.now() - started;
57
+ return result;
58
+ } catch (err) {
59
+ return {
60
+ ...result,
61
+ status:
62
+ typeof err === "object" && err && "statusCode" in err
63
+ ? Number((err as { statusCode?: number }).statusCode)
64
+ : result.status,
65
+ error: err instanceof Error ? err.message : String(err),
66
+ elapsedMs: Date.now() - started,
67
+ };
68
+ }
69
+ }
@@ -0,0 +1,18 @@
1
+ import { createRequire } from "node:module";
2
+
3
+ type MatrixSdkRuntime = typeof import("@vector-im/matrix-bot-sdk");
4
+
5
+ let cachedMatrixSdkRuntime: MatrixSdkRuntime | null = null;
6
+
7
+ export function loadMatrixSdk(): MatrixSdkRuntime {
8
+ if (cachedMatrixSdkRuntime) {
9
+ return cachedMatrixSdkRuntime;
10
+ }
11
+ const req = createRequire(import.meta.url);
12
+ cachedMatrixSdkRuntime = req("@vector-im/matrix-bot-sdk") as MatrixSdkRuntime;
13
+ return cachedMatrixSdkRuntime;
14
+ }
15
+
16
+ export function getMatrixLogService() {
17
+ return loadMatrixSdk().LogService;
18
+ }