@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.
- package/CHANGELOG.md +104 -0
- package/SETUP.md +131 -0
- package/index.ts +23 -0
- package/openclaw.plugin.json +1 -0
- package/package.json +32 -0
- package/src/actions.ts +195 -0
- package/src/channel.ts +461 -0
- package/src/config-schema.ts +62 -0
- package/src/connect.ts +17 -0
- package/src/directory-live.ts +209 -0
- package/src/group-mentions.ts +52 -0
- package/src/matrix/accounts.ts +114 -0
- package/src/matrix/actions/client.ts +47 -0
- package/src/matrix/actions/limits.ts +6 -0
- package/src/matrix/actions/messages.ts +126 -0
- package/src/matrix/actions/pins.ts +84 -0
- package/src/matrix/actions/reactions.ts +102 -0
- package/src/matrix/actions/room.ts +85 -0
- package/src/matrix/actions/summary.ts +75 -0
- package/src/matrix/actions/types.ts +85 -0
- package/src/matrix/actions.ts +15 -0
- package/src/matrix/active-client.ts +32 -0
- package/src/matrix/client/config.ts +245 -0
- package/src/matrix/client/create-client.ts +125 -0
- package/src/matrix/client/logging.ts +46 -0
- package/src/matrix/client/runtime.ts +4 -0
- package/src/matrix/client/shared.ts +210 -0
- package/src/matrix/client/startup.ts +29 -0
- package/src/matrix/client/storage.ts +131 -0
- package/src/matrix/client/types.ts +34 -0
- package/src/matrix/client-bootstrap.ts +47 -0
- package/src/matrix/client.ts +14 -0
- package/src/matrix/credentials.ts +125 -0
- package/src/matrix/deps.ts +126 -0
- package/src/matrix/format.ts +22 -0
- package/src/matrix/index.ts +11 -0
- package/src/matrix/monitor/access-policy.ts +126 -0
- package/src/matrix/monitor/allowlist.ts +94 -0
- package/src/matrix/monitor/auto-join.ts +72 -0
- package/src/matrix/monitor/direct.ts +152 -0
- package/src/matrix/monitor/events.ts +168 -0
- package/src/matrix/monitor/handler.ts +768 -0
- package/src/matrix/monitor/inbound-body.ts +28 -0
- package/src/matrix/monitor/index.ts +414 -0
- package/src/matrix/monitor/location.ts +100 -0
- package/src/matrix/monitor/media.ts +118 -0
- package/src/matrix/monitor/mentions.ts +62 -0
- package/src/matrix/monitor/replies.ts +124 -0
- package/src/matrix/monitor/room-info.ts +55 -0
- package/src/matrix/monitor/rooms.ts +47 -0
- package/src/matrix/monitor/threads.ts +68 -0
- package/src/matrix/monitor/types.ts +39 -0
- package/src/matrix/poll-types.ts +167 -0
- package/src/matrix/probe.ts +69 -0
- package/src/matrix/sdk-runtime.ts +18 -0
- package/src/matrix/send/client.ts +99 -0
- package/src/matrix/send/formatting.ts +93 -0
- package/src/matrix/send/media.ts +230 -0
- package/src/matrix/send/targets.ts +150 -0
- package/src/matrix/send/types.ts +110 -0
- package/src/matrix/send-queue.ts +28 -0
- package/src/matrix/send.ts +267 -0
- package/src/onboarding.ts +331 -0
- package/src/outbound.ts +58 -0
- package/src/resolve-targets.ts +125 -0
- package/src/runtime.ts +6 -0
- package/src/secret-input.ts +13 -0
- package/src/test-mocks.ts +53 -0
- package/src/tool-actions.ts +164 -0
- package/src/types.ts +118 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
|
2
|
+
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
|
3
|
+
import { getMatrixRuntime } from "../../runtime.js";
|
|
4
|
+
import type { CoreConfig } from "../../types.js";
|
|
5
|
+
import { getActiveMatrixClient, getAnyActiveMatrixClient } from "../active-client.js";
|
|
6
|
+
import { createPreparedMatrixClient } from "../client-bootstrap.js";
|
|
7
|
+
import { isBunRuntime, resolveMatrixAuth, resolveSharedMatrixClient } from "../client.js";
|
|
8
|
+
|
|
9
|
+
const getCore = () => getMatrixRuntime();
|
|
10
|
+
|
|
11
|
+
export function ensureNodeRuntime() {
|
|
12
|
+
if (isBunRuntime()) {
|
|
13
|
+
throw new Error("BadgerClaw support requires Node (bun runtime not supported)");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Look up account config with case-insensitive key fallback. */
|
|
18
|
+
function findAccountConfig(
|
|
19
|
+
accounts: Record<string, unknown> | undefined,
|
|
20
|
+
accountId: string,
|
|
21
|
+
): Record<string, unknown> | undefined {
|
|
22
|
+
if (!accounts) return undefined;
|
|
23
|
+
const normalized = normalizeAccountId(accountId);
|
|
24
|
+
// Direct lookup first
|
|
25
|
+
if (accounts[normalized]) return accounts[normalized] as Record<string, unknown>;
|
|
26
|
+
// Case-insensitive fallback
|
|
27
|
+
for (const key of Object.keys(accounts)) {
|
|
28
|
+
if (normalizeAccountId(key) === normalized) {
|
|
29
|
+
return accounts[key] as Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resolveMediaMaxBytes(accountId?: string, cfg?: CoreConfig): number | undefined {
|
|
36
|
+
const resolvedCfg = cfg ?? (getCore().config.loadConfig() as CoreConfig);
|
|
37
|
+
// Check account-specific config first (case-insensitive key matching)
|
|
38
|
+
const accountConfig = findAccountConfig(
|
|
39
|
+
resolvedCfg.channels?.badgerclaw?.accounts as Record<string, unknown> | undefined,
|
|
40
|
+
accountId ?? "",
|
|
41
|
+
);
|
|
42
|
+
if (typeof accountConfig?.mediaMaxMb === "number") {
|
|
43
|
+
return (accountConfig.mediaMaxMb as number) * 1024 * 1024;
|
|
44
|
+
}
|
|
45
|
+
// Fall back to top-level config
|
|
46
|
+
if (typeof resolvedCfg.channels?.badgerclaw?.mediaMaxMb === "number") {
|
|
47
|
+
return resolvedCfg.channels.badgerclaw.mediaMaxMb * 1024 * 1024;
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function resolveMatrixClient(opts: {
|
|
53
|
+
client?: MatrixClient;
|
|
54
|
+
timeoutMs?: number;
|
|
55
|
+
accountId?: string;
|
|
56
|
+
cfg?: CoreConfig;
|
|
57
|
+
}): Promise<{ client: MatrixClient; stopOnDone: boolean }> {
|
|
58
|
+
ensureNodeRuntime();
|
|
59
|
+
if (opts.client) {
|
|
60
|
+
return { client: opts.client, stopOnDone: false };
|
|
61
|
+
}
|
|
62
|
+
const accountId =
|
|
63
|
+
typeof opts.accountId === "string" && opts.accountId.trim().length > 0
|
|
64
|
+
? normalizeAccountId(opts.accountId)
|
|
65
|
+
: undefined;
|
|
66
|
+
// Try to get the client for the specific account
|
|
67
|
+
const active = getActiveMatrixClient(accountId);
|
|
68
|
+
if (active) {
|
|
69
|
+
return { client: active, stopOnDone: false };
|
|
70
|
+
}
|
|
71
|
+
// When no account is specified, try the default account first; only fall back to
|
|
72
|
+
// any active client as a last resort (prevents sending from an arbitrary account).
|
|
73
|
+
if (!accountId) {
|
|
74
|
+
const defaultClient = getActiveMatrixClient(DEFAULT_ACCOUNT_ID);
|
|
75
|
+
if (defaultClient) {
|
|
76
|
+
return { client: defaultClient, stopOnDone: false };
|
|
77
|
+
}
|
|
78
|
+
const anyActive = getAnyActiveMatrixClient();
|
|
79
|
+
if (anyActive) {
|
|
80
|
+
return { client: anyActive, stopOnDone: false };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT);
|
|
84
|
+
if (shouldShareClient) {
|
|
85
|
+
const client = await resolveSharedMatrixClient({
|
|
86
|
+
timeoutMs: opts.timeoutMs,
|
|
87
|
+
accountId,
|
|
88
|
+
cfg: opts.cfg,
|
|
89
|
+
});
|
|
90
|
+
return { client, stopOnDone: false };
|
|
91
|
+
}
|
|
92
|
+
const auth = await resolveMatrixAuth({ accountId, cfg: opts.cfg });
|
|
93
|
+
const client = await createPreparedMatrixClient({
|
|
94
|
+
auth,
|
|
95
|
+
timeoutMs: opts.timeoutMs,
|
|
96
|
+
accountId,
|
|
97
|
+
});
|
|
98
|
+
return { client, stopOnDone: true };
|
|
99
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { getMatrixRuntime } from "../../runtime.js";
|
|
2
|
+
import { markdownToMatrixHtml } from "../format.js";
|
|
3
|
+
import {
|
|
4
|
+
MsgType,
|
|
5
|
+
RelationType,
|
|
6
|
+
type MatrixFormattedContent,
|
|
7
|
+
type MatrixMediaMsgType,
|
|
8
|
+
type MatrixRelation,
|
|
9
|
+
type MatrixReplyRelation,
|
|
10
|
+
type MatrixTextContent,
|
|
11
|
+
type MatrixThreadRelation,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
|
|
14
|
+
const getCore = () => getMatrixRuntime();
|
|
15
|
+
|
|
16
|
+
export function buildTextContent(body: string, relation?: MatrixRelation): MatrixTextContent {
|
|
17
|
+
const content: MatrixTextContent = relation
|
|
18
|
+
? {
|
|
19
|
+
msgtype: MsgType.Text,
|
|
20
|
+
body,
|
|
21
|
+
"m.relates_to": relation,
|
|
22
|
+
}
|
|
23
|
+
: {
|
|
24
|
+
msgtype: MsgType.Text,
|
|
25
|
+
body,
|
|
26
|
+
};
|
|
27
|
+
applyMatrixFormatting(content, body);
|
|
28
|
+
return content;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function applyMatrixFormatting(content: MatrixFormattedContent, body: string): void {
|
|
32
|
+
const formatted = markdownToMatrixHtml(body ?? "");
|
|
33
|
+
if (!formatted) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
content.format = "org.matrix.custom.html";
|
|
37
|
+
content.formatted_body = formatted;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined {
|
|
41
|
+
const trimmed = replyToId?.trim();
|
|
42
|
+
if (!trimmed) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
return { "m.in_reply_to": { event_id: trimmed } };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildThreadRelation(threadId: string, replyToId?: string): MatrixThreadRelation {
|
|
49
|
+
const trimmed = threadId.trim();
|
|
50
|
+
return {
|
|
51
|
+
rel_type: RelationType.Thread,
|
|
52
|
+
event_id: trimmed,
|
|
53
|
+
is_falling_back: true,
|
|
54
|
+
"m.in_reply_to": { event_id: replyToId?.trim() || trimmed },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function resolveMatrixMsgType(contentType?: string, _fileName?: string): MatrixMediaMsgType {
|
|
59
|
+
const kind = getCore().media.mediaKindFromMime(contentType ?? "");
|
|
60
|
+
switch (kind) {
|
|
61
|
+
case "image":
|
|
62
|
+
return MsgType.Image;
|
|
63
|
+
case "audio":
|
|
64
|
+
return MsgType.Audio;
|
|
65
|
+
case "video":
|
|
66
|
+
return MsgType.Video;
|
|
67
|
+
default:
|
|
68
|
+
return MsgType.File;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function resolveMatrixVoiceDecision(opts: {
|
|
73
|
+
wantsVoice: boolean;
|
|
74
|
+
contentType?: string;
|
|
75
|
+
fileName?: string;
|
|
76
|
+
}): { useVoice: boolean } {
|
|
77
|
+
if (!opts.wantsVoice) {
|
|
78
|
+
return { useVoice: false };
|
|
79
|
+
}
|
|
80
|
+
if (isMatrixVoiceCompatibleAudio(opts)) {
|
|
81
|
+
return { useVoice: true };
|
|
82
|
+
}
|
|
83
|
+
return { useVoice: false };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isMatrixVoiceCompatibleAudio(opts: { contentType?: string; fileName?: string }): boolean {
|
|
87
|
+
// Matrix currently shares the core voice compatibility policy.
|
|
88
|
+
// Keep this wrapper as the seam if Matrix policy diverges later.
|
|
89
|
+
return getCore().media.isVoiceCompatibleAudio({
|
|
90
|
+
contentType: opts.contentType,
|
|
91
|
+
fileName: opts.fileName,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DimensionalFileInfo,
|
|
3
|
+
EncryptedFile,
|
|
4
|
+
FileWithThumbnailInfo,
|
|
5
|
+
MatrixClient,
|
|
6
|
+
TimedFileInfo,
|
|
7
|
+
VideoFileInfo,
|
|
8
|
+
} from "@vector-im/matrix-bot-sdk";
|
|
9
|
+
import { getMatrixRuntime } from "../../runtime.js";
|
|
10
|
+
import { applyMatrixFormatting } from "./formatting.js";
|
|
11
|
+
import {
|
|
12
|
+
type MatrixMediaContent,
|
|
13
|
+
type MatrixMediaInfo,
|
|
14
|
+
type MatrixMediaMsgType,
|
|
15
|
+
type MatrixRelation,
|
|
16
|
+
type MediaKind,
|
|
17
|
+
} from "./types.js";
|
|
18
|
+
|
|
19
|
+
const getCore = () => getMatrixRuntime();
|
|
20
|
+
type IFileInfo = import("music-metadata").IFileInfo;
|
|
21
|
+
|
|
22
|
+
export function buildMatrixMediaInfo(params: {
|
|
23
|
+
size: number;
|
|
24
|
+
mimetype?: string;
|
|
25
|
+
durationMs?: number;
|
|
26
|
+
imageInfo?: DimensionalFileInfo;
|
|
27
|
+
}): MatrixMediaInfo | undefined {
|
|
28
|
+
const base: FileWithThumbnailInfo = {};
|
|
29
|
+
if (Number.isFinite(params.size)) {
|
|
30
|
+
base.size = params.size;
|
|
31
|
+
}
|
|
32
|
+
if (params.mimetype) {
|
|
33
|
+
base.mimetype = params.mimetype;
|
|
34
|
+
}
|
|
35
|
+
if (params.imageInfo) {
|
|
36
|
+
const dimensional: DimensionalFileInfo = {
|
|
37
|
+
...base,
|
|
38
|
+
...params.imageInfo,
|
|
39
|
+
};
|
|
40
|
+
if (typeof params.durationMs === "number") {
|
|
41
|
+
const videoInfo: VideoFileInfo = {
|
|
42
|
+
...dimensional,
|
|
43
|
+
duration: params.durationMs,
|
|
44
|
+
};
|
|
45
|
+
return videoInfo;
|
|
46
|
+
}
|
|
47
|
+
return dimensional;
|
|
48
|
+
}
|
|
49
|
+
if (typeof params.durationMs === "number") {
|
|
50
|
+
const timedInfo: TimedFileInfo = {
|
|
51
|
+
...base,
|
|
52
|
+
duration: params.durationMs,
|
|
53
|
+
};
|
|
54
|
+
return timedInfo;
|
|
55
|
+
}
|
|
56
|
+
if (Object.keys(base).length === 0) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
return base;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function buildMediaContent(params: {
|
|
63
|
+
msgtype: MatrixMediaMsgType;
|
|
64
|
+
body: string;
|
|
65
|
+
url?: string;
|
|
66
|
+
filename?: string;
|
|
67
|
+
mimetype?: string;
|
|
68
|
+
size: number;
|
|
69
|
+
relation?: MatrixRelation;
|
|
70
|
+
isVoice?: boolean;
|
|
71
|
+
durationMs?: number;
|
|
72
|
+
imageInfo?: DimensionalFileInfo;
|
|
73
|
+
file?: EncryptedFile;
|
|
74
|
+
}): MatrixMediaContent {
|
|
75
|
+
const info = buildMatrixMediaInfo({
|
|
76
|
+
size: params.size,
|
|
77
|
+
mimetype: params.mimetype,
|
|
78
|
+
durationMs: params.durationMs,
|
|
79
|
+
imageInfo: params.imageInfo,
|
|
80
|
+
});
|
|
81
|
+
const base: MatrixMediaContent = {
|
|
82
|
+
msgtype: params.msgtype,
|
|
83
|
+
body: params.body,
|
|
84
|
+
filename: params.filename,
|
|
85
|
+
info: info ?? undefined,
|
|
86
|
+
};
|
|
87
|
+
// Encrypted media should only include the "file" payload, not top-level "url".
|
|
88
|
+
if (!params.file && params.url) {
|
|
89
|
+
base.url = params.url;
|
|
90
|
+
}
|
|
91
|
+
// For encrypted files, add the file object
|
|
92
|
+
if (params.file) {
|
|
93
|
+
base.file = params.file;
|
|
94
|
+
}
|
|
95
|
+
if (params.isVoice) {
|
|
96
|
+
base["org.matrix.msc3245.voice"] = {};
|
|
97
|
+
if (typeof params.durationMs === "number") {
|
|
98
|
+
base["org.matrix.msc1767.audio"] = {
|
|
99
|
+
duration: params.durationMs,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (params.relation) {
|
|
104
|
+
base["m.relates_to"] = params.relation;
|
|
105
|
+
}
|
|
106
|
+
applyMatrixFormatting(base, params.body);
|
|
107
|
+
return base;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const THUMBNAIL_MAX_SIDE = 800;
|
|
111
|
+
const THUMBNAIL_QUALITY = 80;
|
|
112
|
+
|
|
113
|
+
export async function prepareImageInfo(params: {
|
|
114
|
+
buffer: Buffer;
|
|
115
|
+
client: MatrixClient;
|
|
116
|
+
}): Promise<DimensionalFileInfo | undefined> {
|
|
117
|
+
const meta = await getCore()
|
|
118
|
+
.media.getImageMetadata(params.buffer)
|
|
119
|
+
.catch(() => null);
|
|
120
|
+
if (!meta) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height };
|
|
124
|
+
const maxDim = Math.max(meta.width, meta.height);
|
|
125
|
+
if (maxDim > THUMBNAIL_MAX_SIDE) {
|
|
126
|
+
try {
|
|
127
|
+
const thumbBuffer = await getCore().media.resizeToJpeg({
|
|
128
|
+
buffer: params.buffer,
|
|
129
|
+
maxSide: THUMBNAIL_MAX_SIDE,
|
|
130
|
+
quality: THUMBNAIL_QUALITY,
|
|
131
|
+
withoutEnlargement: true,
|
|
132
|
+
});
|
|
133
|
+
const thumbMeta = await getCore()
|
|
134
|
+
.media.getImageMetadata(thumbBuffer)
|
|
135
|
+
.catch(() => null);
|
|
136
|
+
const thumbUri = await params.client.uploadContent(
|
|
137
|
+
thumbBuffer,
|
|
138
|
+
"image/jpeg",
|
|
139
|
+
"thumbnail.jpg",
|
|
140
|
+
);
|
|
141
|
+
imageInfo.thumbnail_url = thumbUri;
|
|
142
|
+
if (thumbMeta) {
|
|
143
|
+
imageInfo.thumbnail_info = {
|
|
144
|
+
w: thumbMeta.width,
|
|
145
|
+
h: thumbMeta.height,
|
|
146
|
+
mimetype: "image/jpeg",
|
|
147
|
+
size: thumbBuffer.byteLength,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// Thumbnail generation failed, continue without it
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return imageInfo;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function resolveMediaDurationMs(params: {
|
|
158
|
+
buffer: Buffer;
|
|
159
|
+
contentType?: string;
|
|
160
|
+
fileName?: string;
|
|
161
|
+
kind: MediaKind;
|
|
162
|
+
}): Promise<number | undefined> {
|
|
163
|
+
if (params.kind !== "audio" && params.kind !== "video") {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const { parseBuffer } = await import("music-metadata");
|
|
168
|
+
const fileInfo: IFileInfo | string | undefined =
|
|
169
|
+
params.contentType || params.fileName
|
|
170
|
+
? {
|
|
171
|
+
mimeType: params.contentType,
|
|
172
|
+
size: params.buffer.byteLength,
|
|
173
|
+
path: params.fileName,
|
|
174
|
+
}
|
|
175
|
+
: undefined;
|
|
176
|
+
const metadata = await parseBuffer(params.buffer, fileInfo, {
|
|
177
|
+
duration: true,
|
|
178
|
+
skipCovers: true,
|
|
179
|
+
});
|
|
180
|
+
const durationSeconds = metadata.format.duration;
|
|
181
|
+
if (typeof durationSeconds === "number" && Number.isFinite(durationSeconds)) {
|
|
182
|
+
return Math.max(0, Math.round(durationSeconds * 1000));
|
|
183
|
+
}
|
|
184
|
+
} catch {
|
|
185
|
+
// Duration is optional; ignore parse failures.
|
|
186
|
+
}
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function uploadFile(
|
|
191
|
+
client: MatrixClient,
|
|
192
|
+
file: Buffer,
|
|
193
|
+
params: {
|
|
194
|
+
contentType?: string;
|
|
195
|
+
filename?: string;
|
|
196
|
+
},
|
|
197
|
+
): Promise<string> {
|
|
198
|
+
return await client.uploadContent(file, params.contentType, params.filename);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Upload media with optional encryption for E2EE rooms.
|
|
203
|
+
*/
|
|
204
|
+
export async function uploadMediaMaybeEncrypted(
|
|
205
|
+
client: MatrixClient,
|
|
206
|
+
roomId: string,
|
|
207
|
+
buffer: Buffer,
|
|
208
|
+
params: {
|
|
209
|
+
contentType?: string;
|
|
210
|
+
filename?: string;
|
|
211
|
+
},
|
|
212
|
+
): Promise<{ url: string; file?: EncryptedFile }> {
|
|
213
|
+
// Check if room is encrypted and crypto is available
|
|
214
|
+
const isEncrypted = client.crypto && (await client.crypto.isRoomEncrypted(roomId));
|
|
215
|
+
|
|
216
|
+
if (isEncrypted && client.crypto) {
|
|
217
|
+
// Encrypt the media before uploading
|
|
218
|
+
const encrypted = await client.crypto.encryptMedia(buffer);
|
|
219
|
+
const mxc = await client.uploadContent(encrypted.buffer, params.contentType, params.filename);
|
|
220
|
+
const file: EncryptedFile = { url: mxc, ...encrypted.file };
|
|
221
|
+
return {
|
|
222
|
+
url: mxc,
|
|
223
|
+
file,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Upload unencrypted
|
|
228
|
+
const mxc = await uploadFile(client, buffer, params);
|
|
229
|
+
return { url: mxc };
|
|
230
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
|
2
|
+
import { EventType, type MatrixDirectAccountData } from "./types.js";
|
|
3
|
+
|
|
4
|
+
function normalizeTarget(raw: string): string {
|
|
5
|
+
const trimmed = raw.trim();
|
|
6
|
+
if (!trimmed) {
|
|
7
|
+
throw new Error("BadgerClaw target is required (room:<id> or #alias)");
|
|
8
|
+
}
|
|
9
|
+
return trimmed;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function normalizeThreadId(raw?: string | number | null): string | null {
|
|
13
|
+
if (raw === undefined || raw === null) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const trimmed = String(raw).trim();
|
|
17
|
+
return trimmed ? trimmed : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Size-capped to prevent unbounded growth (#4948)
|
|
21
|
+
const MAX_DIRECT_ROOM_CACHE_SIZE = 1024;
|
|
22
|
+
const directRoomCache = new Map<string, string>();
|
|
23
|
+
function setDirectRoomCached(key: string, value: string): void {
|
|
24
|
+
directRoomCache.set(key, value);
|
|
25
|
+
if (directRoomCache.size > MAX_DIRECT_ROOM_CACHE_SIZE) {
|
|
26
|
+
const oldest = directRoomCache.keys().next().value;
|
|
27
|
+
if (oldest !== undefined) {
|
|
28
|
+
directRoomCache.delete(oldest);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function persistDirectRoom(
|
|
34
|
+
client: MatrixClient,
|
|
35
|
+
userId: string,
|
|
36
|
+
roomId: string,
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
let directContent: MatrixDirectAccountData | null = null;
|
|
39
|
+
try {
|
|
40
|
+
directContent = await client.getAccountData(EventType.Direct);
|
|
41
|
+
} catch {
|
|
42
|
+
// Ignore fetch errors and fall back to an empty map.
|
|
43
|
+
}
|
|
44
|
+
const existing = directContent && !Array.isArray(directContent) ? directContent : {};
|
|
45
|
+
const current = Array.isArray(existing[userId]) ? existing[userId] : [];
|
|
46
|
+
if (current[0] === roomId) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const next = [roomId, ...current.filter((id) => id !== roomId)];
|
|
50
|
+
try {
|
|
51
|
+
await client.setAccountData(EventType.Direct, {
|
|
52
|
+
...existing,
|
|
53
|
+
[userId]: next,
|
|
54
|
+
});
|
|
55
|
+
} catch {
|
|
56
|
+
// Ignore persistence errors.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise<string> {
|
|
61
|
+
const trimmed = userId.trim();
|
|
62
|
+
if (!trimmed.startsWith("@")) {
|
|
63
|
+
throw new Error(`BadgerClaw user IDs must be fully qualified (got "${trimmed}")`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const cached = directRoomCache.get(trimmed);
|
|
67
|
+
if (cached) {
|
|
68
|
+
return cached;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 1) Fast path: use account data (m.direct) for *this* logged-in user (the bot).
|
|
72
|
+
try {
|
|
73
|
+
const directContent = (await client.getAccountData(EventType.Direct)) as Record<
|
|
74
|
+
string,
|
|
75
|
+
string[] | undefined
|
|
76
|
+
>;
|
|
77
|
+
const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
|
|
78
|
+
if (list && list.length > 0) {
|
|
79
|
+
setDirectRoomCached(trimmed, list[0]);
|
|
80
|
+
return list[0];
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// Ignore and fall back.
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 2) Fallback: look for an existing joined room that looks like a 1:1 with the user.
|
|
87
|
+
// Many clients only maintain m.direct for *their own* account data, so relying on it is brittle.
|
|
88
|
+
let fallbackRoom: string | null = null;
|
|
89
|
+
try {
|
|
90
|
+
const rooms = await client.getJoinedRooms();
|
|
91
|
+
for (const roomId of rooms) {
|
|
92
|
+
let members: string[];
|
|
93
|
+
try {
|
|
94
|
+
members = await client.getJoinedRoomMembers(roomId);
|
|
95
|
+
} catch {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (!members.includes(trimmed)) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// Prefer classic 1:1 rooms, but allow larger rooms if requested.
|
|
102
|
+
if (members.length === 2) {
|
|
103
|
+
setDirectRoomCached(trimmed, roomId);
|
|
104
|
+
await persistDirectRoom(client, trimmed, roomId);
|
|
105
|
+
return roomId;
|
|
106
|
+
}
|
|
107
|
+
if (!fallbackRoom) {
|
|
108
|
+
fallbackRoom = roomId;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// Ignore and fall back.
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (fallbackRoom) {
|
|
116
|
+
setDirectRoomCached(trimmed, fallbackRoom);
|
|
117
|
+
await persistDirectRoom(client, trimmed, fallbackRoom);
|
|
118
|
+
return fallbackRoom;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
throw new Error(`No direct room found for ${trimmed} (m.direct missing)`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function resolveMatrixRoomId(client: MatrixClient, raw: string): Promise<string> {
|
|
125
|
+
const target = normalizeTarget(raw);
|
|
126
|
+
const lowered = target.toLowerCase();
|
|
127
|
+
if (lowered.startsWith("badgerclaw:")) {
|
|
128
|
+
return await resolveMatrixRoomId(client, target.slice("badgerclaw:".length));
|
|
129
|
+
}
|
|
130
|
+
if (lowered.startsWith("room:")) {
|
|
131
|
+
return await resolveMatrixRoomId(client, target.slice("room:".length));
|
|
132
|
+
}
|
|
133
|
+
if (lowered.startsWith("channel:")) {
|
|
134
|
+
return await resolveMatrixRoomId(client, target.slice("channel:".length));
|
|
135
|
+
}
|
|
136
|
+
if (lowered.startsWith("user:")) {
|
|
137
|
+
return await resolveDirectRoomId(client, target.slice("user:".length));
|
|
138
|
+
}
|
|
139
|
+
if (target.startsWith("@")) {
|
|
140
|
+
return await resolveDirectRoomId(client, target);
|
|
141
|
+
}
|
|
142
|
+
if (target.startsWith("#")) {
|
|
143
|
+
const resolved = await client.resolveRoom(target);
|
|
144
|
+
if (!resolved) {
|
|
145
|
+
throw new Error(`BadgerClaw alias ${target} could not be resolved`);
|
|
146
|
+
}
|
|
147
|
+
return resolved;
|
|
148
|
+
}
|
|
149
|
+
return target;
|
|
150
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DimensionalFileInfo,
|
|
3
|
+
EncryptedFile,
|
|
4
|
+
FileWithThumbnailInfo,
|
|
5
|
+
MessageEventContent,
|
|
6
|
+
TextualMessageEventContent,
|
|
7
|
+
TimedFileInfo,
|
|
8
|
+
VideoFileInfo,
|
|
9
|
+
} from "@vector-im/matrix-bot-sdk";
|
|
10
|
+
|
|
11
|
+
// Message types
|
|
12
|
+
export const MsgType = {
|
|
13
|
+
Text: "m.text",
|
|
14
|
+
Image: "m.image",
|
|
15
|
+
Audio: "m.audio",
|
|
16
|
+
Video: "m.video",
|
|
17
|
+
File: "m.file",
|
|
18
|
+
Notice: "m.notice",
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
// Relation types
|
|
22
|
+
export const RelationType = {
|
|
23
|
+
Annotation: "m.annotation",
|
|
24
|
+
Replace: "m.replace",
|
|
25
|
+
Thread: "m.thread",
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
// Event types
|
|
29
|
+
export const EventType = {
|
|
30
|
+
Direct: "m.direct",
|
|
31
|
+
Reaction: "m.reaction",
|
|
32
|
+
RoomMessage: "m.room.message",
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
export type MatrixDirectAccountData = Record<string, string[]>;
|
|
36
|
+
|
|
37
|
+
export type MatrixReplyRelation = {
|
|
38
|
+
"m.in_reply_to": { event_id: string };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type MatrixThreadRelation = {
|
|
42
|
+
rel_type: typeof RelationType.Thread;
|
|
43
|
+
event_id: string;
|
|
44
|
+
is_falling_back?: boolean;
|
|
45
|
+
"m.in_reply_to"?: { event_id: string };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type MatrixRelation = MatrixReplyRelation | MatrixThreadRelation;
|
|
49
|
+
|
|
50
|
+
export type MatrixReplyMeta = {
|
|
51
|
+
"m.relates_to"?: MatrixRelation;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type MatrixMediaInfo =
|
|
55
|
+
| FileWithThumbnailInfo
|
|
56
|
+
| DimensionalFileInfo
|
|
57
|
+
| TimedFileInfo
|
|
58
|
+
| VideoFileInfo;
|
|
59
|
+
|
|
60
|
+
export type MatrixTextContent = TextualMessageEventContent & MatrixReplyMeta;
|
|
61
|
+
|
|
62
|
+
export type MatrixMediaContent = MessageEventContent &
|
|
63
|
+
MatrixReplyMeta & {
|
|
64
|
+
info?: MatrixMediaInfo;
|
|
65
|
+
url?: string;
|
|
66
|
+
file?: EncryptedFile;
|
|
67
|
+
filename?: string;
|
|
68
|
+
"org.matrix.msc3245.voice"?: Record<string, never>;
|
|
69
|
+
"org.matrix.msc1767.audio"?: { duration: number };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type MatrixOutboundContent = MatrixTextContent | MatrixMediaContent;
|
|
73
|
+
|
|
74
|
+
export type ReactionEventContent = {
|
|
75
|
+
"m.relates_to": {
|
|
76
|
+
rel_type: typeof RelationType.Annotation;
|
|
77
|
+
event_id: string;
|
|
78
|
+
key: string;
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type MatrixSendResult = {
|
|
83
|
+
messageId: string;
|
|
84
|
+
roomId: string;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type MatrixSendOpts = {
|
|
88
|
+
cfg?: import("../../types.js").CoreConfig;
|
|
89
|
+
client?: import("@vector-im/matrix-bot-sdk").MatrixClient;
|
|
90
|
+
mediaUrl?: string;
|
|
91
|
+
accountId?: string;
|
|
92
|
+
replyToId?: string;
|
|
93
|
+
threadId?: string | number | null;
|
|
94
|
+
timeoutMs?: number;
|
|
95
|
+
/** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */
|
|
96
|
+
audioAsVoice?: boolean;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export type MatrixMediaMsgType =
|
|
100
|
+
| typeof MsgType.Image
|
|
101
|
+
| typeof MsgType.Audio
|
|
102
|
+
| typeof MsgType.Video
|
|
103
|
+
| typeof MsgType.File;
|
|
104
|
+
|
|
105
|
+
export type MediaKind = "image" | "audio" | "video" | "document" | "unknown";
|
|
106
|
+
|
|
107
|
+
export type MatrixFormattedContent = MessageEventContent & {
|
|
108
|
+
format?: string;
|
|
109
|
+
formatted_body?: string;
|
|
110
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_SEND_GAP_MS = 150;
|
|
4
|
+
|
|
5
|
+
type MatrixSendQueueOptions = {
|
|
6
|
+
gapMs?: number;
|
|
7
|
+
delayFn?: (ms: number) => Promise<void>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// Serialize sends per room to preserve Matrix delivery order.
|
|
11
|
+
const roomQueues = new KeyedAsyncQueue();
|
|
12
|
+
|
|
13
|
+
export function enqueueSend<T>(
|
|
14
|
+
roomId: string,
|
|
15
|
+
fn: () => Promise<T>,
|
|
16
|
+
options?: MatrixSendQueueOptions,
|
|
17
|
+
): Promise<T> {
|
|
18
|
+
const gapMs = options?.gapMs ?? DEFAULT_SEND_GAP_MS;
|
|
19
|
+
const delayFn = options?.delayFn ?? delay;
|
|
20
|
+
return roomQueues.enqueue(roomId, async () => {
|
|
21
|
+
await delayFn(gapMs);
|
|
22
|
+
return await fn();
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function delay(ms: number): Promise<void> {
|
|
27
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
28
|
+
}
|