@openclaw/matrix 2026.1.29
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 +59 -0
- package/index.ts +18 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +36 -0
- package/src/actions.ts +185 -0
- package/src/channel.directory.test.ts +56 -0
- package/src/channel.ts +417 -0
- package/src/config-schema.ts +62 -0
- package/src/directory-live.ts +175 -0
- package/src/group-mentions.ts +61 -0
- package/src/matrix/accounts.test.ts +83 -0
- package/src/matrix/accounts.ts +63 -0
- package/src/matrix/actions/client.ts +53 -0
- package/src/matrix/actions/messages.ts +120 -0
- package/src/matrix/actions/pins.ts +70 -0
- package/src/matrix/actions/reactions.ts +84 -0
- package/src/matrix/actions/room.ts +88 -0
- package/src/matrix/actions/summary.ts +77 -0
- package/src/matrix/actions/types.ts +84 -0
- package/src/matrix/actions.ts +15 -0
- package/src/matrix/active-client.ts +11 -0
- package/src/matrix/client/config.ts +165 -0
- package/src/matrix/client/create-client.ts +127 -0
- package/src/matrix/client/logging.ts +35 -0
- package/src/matrix/client/runtime.ts +4 -0
- package/src/matrix/client/shared.ts +169 -0
- package/src/matrix/client/storage.ts +131 -0
- package/src/matrix/client/types.ts +34 -0
- package/src/matrix/client.test.ts +57 -0
- package/src/matrix/client.ts +9 -0
- package/src/matrix/credentials.ts +103 -0
- package/src/matrix/deps.ts +57 -0
- package/src/matrix/format.test.ts +34 -0
- package/src/matrix/format.ts +22 -0
- package/src/matrix/index.ts +11 -0
- package/src/matrix/monitor/allowlist.ts +58 -0
- package/src/matrix/monitor/auto-join.ts +68 -0
- package/src/matrix/monitor/direct.ts +105 -0
- package/src/matrix/monitor/events.ts +103 -0
- package/src/matrix/monitor/handler.ts +645 -0
- package/src/matrix/monitor/index.ts +279 -0
- package/src/matrix/monitor/location.ts +83 -0
- package/src/matrix/monitor/media.test.ts +103 -0
- package/src/matrix/monitor/media.ts +113 -0
- package/src/matrix/monitor/mentions.ts +31 -0
- package/src/matrix/monitor/replies.ts +96 -0
- package/src/matrix/monitor/room-info.ts +58 -0
- package/src/matrix/monitor/rooms.ts +43 -0
- package/src/matrix/monitor/threads.ts +64 -0
- package/src/matrix/monitor/types.ts +39 -0
- package/src/matrix/poll-types.test.ts +22 -0
- package/src/matrix/poll-types.ts +157 -0
- package/src/matrix/probe.ts +70 -0
- package/src/matrix/send/client.ts +63 -0
- package/src/matrix/send/formatting.ts +92 -0
- package/src/matrix/send/media.ts +220 -0
- package/src/matrix/send/targets.test.ts +102 -0
- package/src/matrix/send/targets.ts +144 -0
- package/src/matrix/send/types.ts +109 -0
- package/src/matrix/send.test.ts +172 -0
- package/src/matrix/send.ts +255 -0
- package/src/onboarding.ts +432 -0
- package/src/outbound.ts +53 -0
- package/src/resolve-targets.ts +89 -0
- package/src/runtime.ts +14 -0
- package/src/tool-actions.ts +160 -0
- package/src/types.ts +95 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DimensionalFileInfo,
|
|
3
|
+
EncryptedFile,
|
|
4
|
+
FileWithThumbnailInfo,
|
|
5
|
+
MatrixClient,
|
|
6
|
+
TimedFileInfo,
|
|
7
|
+
VideoFileInfo,
|
|
8
|
+
} from "@vector-im/matrix-bot-sdk";
|
|
9
|
+
import { parseBuffer, type IFileInfo } from "music-metadata";
|
|
10
|
+
|
|
11
|
+
import { getMatrixRuntime } from "../../runtime.js";
|
|
12
|
+
import {
|
|
13
|
+
type MatrixMediaContent,
|
|
14
|
+
type MatrixMediaInfo,
|
|
15
|
+
type MatrixMediaMsgType,
|
|
16
|
+
type MatrixRelation,
|
|
17
|
+
type MediaKind,
|
|
18
|
+
} from "./types.js";
|
|
19
|
+
import { applyMatrixFormatting } from "./formatting.js";
|
|
20
|
+
|
|
21
|
+
const getCore = () => getMatrixRuntime();
|
|
22
|
+
|
|
23
|
+
export function buildMatrixMediaInfo(params: {
|
|
24
|
+
size: number;
|
|
25
|
+
mimetype?: string;
|
|
26
|
+
durationMs?: number;
|
|
27
|
+
imageInfo?: DimensionalFileInfo;
|
|
28
|
+
}): MatrixMediaInfo | undefined {
|
|
29
|
+
const base: FileWithThumbnailInfo = {};
|
|
30
|
+
if (Number.isFinite(params.size)) {
|
|
31
|
+
base.size = params.size;
|
|
32
|
+
}
|
|
33
|
+
if (params.mimetype) {
|
|
34
|
+
base.mimetype = params.mimetype;
|
|
35
|
+
}
|
|
36
|
+
if (params.imageInfo) {
|
|
37
|
+
const dimensional: DimensionalFileInfo = {
|
|
38
|
+
...base,
|
|
39
|
+
...params.imageInfo,
|
|
40
|
+
};
|
|
41
|
+
if (typeof params.durationMs === "number") {
|
|
42
|
+
const videoInfo: VideoFileInfo = {
|
|
43
|
+
...dimensional,
|
|
44
|
+
duration: params.durationMs,
|
|
45
|
+
};
|
|
46
|
+
return videoInfo;
|
|
47
|
+
}
|
|
48
|
+
return dimensional;
|
|
49
|
+
}
|
|
50
|
+
if (typeof params.durationMs === "number") {
|
|
51
|
+
const timedInfo: TimedFileInfo = {
|
|
52
|
+
...base,
|
|
53
|
+
duration: params.durationMs,
|
|
54
|
+
};
|
|
55
|
+
return timedInfo;
|
|
56
|
+
}
|
|
57
|
+
if (Object.keys(base).length === 0) return undefined;
|
|
58
|
+
return base;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function buildMediaContent(params: {
|
|
62
|
+
msgtype: MatrixMediaMsgType;
|
|
63
|
+
body: string;
|
|
64
|
+
url?: string;
|
|
65
|
+
filename?: string;
|
|
66
|
+
mimetype?: string;
|
|
67
|
+
size: number;
|
|
68
|
+
relation?: MatrixRelation;
|
|
69
|
+
isVoice?: boolean;
|
|
70
|
+
durationMs?: number;
|
|
71
|
+
imageInfo?: DimensionalFileInfo;
|
|
72
|
+
file?: EncryptedFile;
|
|
73
|
+
}): MatrixMediaContent {
|
|
74
|
+
const info = buildMatrixMediaInfo({
|
|
75
|
+
size: params.size,
|
|
76
|
+
mimetype: params.mimetype,
|
|
77
|
+
durationMs: params.durationMs,
|
|
78
|
+
imageInfo: params.imageInfo,
|
|
79
|
+
});
|
|
80
|
+
const base: MatrixMediaContent = {
|
|
81
|
+
msgtype: params.msgtype,
|
|
82
|
+
body: params.body,
|
|
83
|
+
filename: params.filename,
|
|
84
|
+
info: info ?? undefined,
|
|
85
|
+
};
|
|
86
|
+
// Encrypted media should only include the "file" payload, not top-level "url".
|
|
87
|
+
if (!params.file && params.url) {
|
|
88
|
+
base.url = params.url;
|
|
89
|
+
}
|
|
90
|
+
// For encrypted files, add the file object
|
|
91
|
+
if (params.file) {
|
|
92
|
+
base.file = params.file;
|
|
93
|
+
}
|
|
94
|
+
if (params.isVoice) {
|
|
95
|
+
base["org.matrix.msc3245.voice"] = {};
|
|
96
|
+
if (typeof params.durationMs === "number") {
|
|
97
|
+
base["org.matrix.msc1767.audio"] = {
|
|
98
|
+
duration: params.durationMs,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (params.relation) {
|
|
103
|
+
base["m.relates_to"] = params.relation;
|
|
104
|
+
}
|
|
105
|
+
applyMatrixFormatting(base, params.body);
|
|
106
|
+
return base;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const THUMBNAIL_MAX_SIDE = 800;
|
|
110
|
+
const THUMBNAIL_QUALITY = 80;
|
|
111
|
+
|
|
112
|
+
export async function prepareImageInfo(params: {
|
|
113
|
+
buffer: Buffer;
|
|
114
|
+
client: MatrixClient;
|
|
115
|
+
}): Promise<DimensionalFileInfo | undefined> {
|
|
116
|
+
const meta = await getCore().media.getImageMetadata(params.buffer).catch(() => null);
|
|
117
|
+
if (!meta) return undefined;
|
|
118
|
+
const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height };
|
|
119
|
+
const maxDim = Math.max(meta.width, meta.height);
|
|
120
|
+
if (maxDim > THUMBNAIL_MAX_SIDE) {
|
|
121
|
+
try {
|
|
122
|
+
const thumbBuffer = await getCore().media.resizeToJpeg({
|
|
123
|
+
buffer: params.buffer,
|
|
124
|
+
maxSide: THUMBNAIL_MAX_SIDE,
|
|
125
|
+
quality: THUMBNAIL_QUALITY,
|
|
126
|
+
withoutEnlargement: true,
|
|
127
|
+
});
|
|
128
|
+
const thumbMeta = await getCore().media.getImageMetadata(thumbBuffer).catch(() => null);
|
|
129
|
+
const thumbUri = await params.client.uploadContent(
|
|
130
|
+
thumbBuffer,
|
|
131
|
+
"image/jpeg",
|
|
132
|
+
"thumbnail.jpg",
|
|
133
|
+
);
|
|
134
|
+
imageInfo.thumbnail_url = thumbUri;
|
|
135
|
+
if (thumbMeta) {
|
|
136
|
+
imageInfo.thumbnail_info = {
|
|
137
|
+
w: thumbMeta.width,
|
|
138
|
+
h: thumbMeta.height,
|
|
139
|
+
mimetype: "image/jpeg",
|
|
140
|
+
size: thumbBuffer.byteLength,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
// Thumbnail generation failed, continue without it
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return imageInfo;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function resolveMediaDurationMs(params: {
|
|
151
|
+
buffer: Buffer;
|
|
152
|
+
contentType?: string;
|
|
153
|
+
fileName?: string;
|
|
154
|
+
kind: MediaKind;
|
|
155
|
+
}): Promise<number | undefined> {
|
|
156
|
+
if (params.kind !== "audio" && params.kind !== "video") return undefined;
|
|
157
|
+
try {
|
|
158
|
+
const fileInfo: IFileInfo | string | undefined =
|
|
159
|
+
params.contentType || params.fileName
|
|
160
|
+
? {
|
|
161
|
+
mimeType: params.contentType,
|
|
162
|
+
size: params.buffer.byteLength,
|
|
163
|
+
path: params.fileName,
|
|
164
|
+
}
|
|
165
|
+
: undefined;
|
|
166
|
+
const metadata = await parseBuffer(params.buffer, fileInfo, {
|
|
167
|
+
duration: true,
|
|
168
|
+
skipCovers: true,
|
|
169
|
+
});
|
|
170
|
+
const durationSeconds = metadata.format.duration;
|
|
171
|
+
if (typeof durationSeconds === "number" && Number.isFinite(durationSeconds)) {
|
|
172
|
+
return Math.max(0, Math.round(durationSeconds * 1000));
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
// Duration is optional; ignore parse failures.
|
|
176
|
+
}
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function uploadFile(
|
|
181
|
+
client: MatrixClient,
|
|
182
|
+
file: Buffer,
|
|
183
|
+
params: {
|
|
184
|
+
contentType?: string;
|
|
185
|
+
filename?: string;
|
|
186
|
+
},
|
|
187
|
+
): Promise<string> {
|
|
188
|
+
return await client.uploadContent(file, params.contentType, params.filename);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Upload media with optional encryption for E2EE rooms.
|
|
193
|
+
*/
|
|
194
|
+
export async function uploadMediaMaybeEncrypted(
|
|
195
|
+
client: MatrixClient,
|
|
196
|
+
roomId: string,
|
|
197
|
+
buffer: Buffer,
|
|
198
|
+
params: {
|
|
199
|
+
contentType?: string;
|
|
200
|
+
filename?: string;
|
|
201
|
+
},
|
|
202
|
+
): Promise<{ url: string; file?: EncryptedFile }> {
|
|
203
|
+
// Check if room is encrypted and crypto is available
|
|
204
|
+
const isEncrypted = client.crypto && await client.crypto.isRoomEncrypted(roomId);
|
|
205
|
+
|
|
206
|
+
if (isEncrypted && client.crypto) {
|
|
207
|
+
// Encrypt the media before uploading
|
|
208
|
+
const encrypted = await client.crypto.encryptMedia(buffer);
|
|
209
|
+
const mxc = await client.uploadContent(encrypted.buffer, params.contentType, params.filename);
|
|
210
|
+
const file: EncryptedFile = { url: mxc, ...encrypted.file };
|
|
211
|
+
return {
|
|
212
|
+
url: mxc,
|
|
213
|
+
file,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Upload unencrypted
|
|
218
|
+
const mxc = await uploadFile(client, buffer, params);
|
|
219
|
+
return { url: mxc };
|
|
220
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
|
4
|
+
import { EventType } from "./types.js";
|
|
5
|
+
|
|
6
|
+
let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId;
|
|
7
|
+
let normalizeThreadId: typeof import("./targets.js").normalizeThreadId;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
vi.resetModules();
|
|
11
|
+
({ resolveMatrixRoomId, normalizeThreadId } = await import("./targets.js"));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("resolveMatrixRoomId", () => {
|
|
15
|
+
it("uses m.direct when available", async () => {
|
|
16
|
+
const userId = "@user:example.org";
|
|
17
|
+
const client = {
|
|
18
|
+
getAccountData: vi.fn().mockResolvedValue({
|
|
19
|
+
[userId]: ["!room:example.org"],
|
|
20
|
+
}),
|
|
21
|
+
getJoinedRooms: vi.fn(),
|
|
22
|
+
getJoinedRoomMembers: vi.fn(),
|
|
23
|
+
setAccountData: vi.fn(),
|
|
24
|
+
} as unknown as MatrixClient;
|
|
25
|
+
|
|
26
|
+
const roomId = await resolveMatrixRoomId(client, userId);
|
|
27
|
+
|
|
28
|
+
expect(roomId).toBe("!room:example.org");
|
|
29
|
+
expect(client.getJoinedRooms).not.toHaveBeenCalled();
|
|
30
|
+
expect(client.setAccountData).not.toHaveBeenCalled();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("falls back to joined rooms and persists m.direct", async () => {
|
|
34
|
+
const userId = "@fallback:example.org";
|
|
35
|
+
const roomId = "!room:example.org";
|
|
36
|
+
const setAccountData = vi.fn().mockResolvedValue(undefined);
|
|
37
|
+
const client = {
|
|
38
|
+
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
|
|
39
|
+
getJoinedRooms: vi.fn().mockResolvedValue([roomId]),
|
|
40
|
+
getJoinedRoomMembers: vi.fn().mockResolvedValue([
|
|
41
|
+
"@bot:example.org",
|
|
42
|
+
userId,
|
|
43
|
+
]),
|
|
44
|
+
setAccountData,
|
|
45
|
+
} as unknown as MatrixClient;
|
|
46
|
+
|
|
47
|
+
const resolved = await resolveMatrixRoomId(client, userId);
|
|
48
|
+
|
|
49
|
+
expect(resolved).toBe(roomId);
|
|
50
|
+
expect(setAccountData).toHaveBeenCalledWith(
|
|
51
|
+
EventType.Direct,
|
|
52
|
+
expect.objectContaining({ [userId]: [roomId] }),
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("continues when a room member lookup fails", async () => {
|
|
57
|
+
const userId = "@continue:example.org";
|
|
58
|
+
const roomId = "!good:example.org";
|
|
59
|
+
const setAccountData = vi.fn().mockResolvedValue(undefined);
|
|
60
|
+
const getJoinedRoomMembers = vi
|
|
61
|
+
.fn()
|
|
62
|
+
.mockRejectedValueOnce(new Error("boom"))
|
|
63
|
+
.mockResolvedValueOnce(["@bot:example.org", userId]);
|
|
64
|
+
const client = {
|
|
65
|
+
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
|
|
66
|
+
getJoinedRooms: vi.fn().mockResolvedValue(["!bad:example.org", roomId]),
|
|
67
|
+
getJoinedRoomMembers,
|
|
68
|
+
setAccountData,
|
|
69
|
+
} as unknown as MatrixClient;
|
|
70
|
+
|
|
71
|
+
const resolved = await resolveMatrixRoomId(client, userId);
|
|
72
|
+
|
|
73
|
+
expect(resolved).toBe(roomId);
|
|
74
|
+
expect(setAccountData).toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("allows larger rooms when no 1:1 match exists", async () => {
|
|
78
|
+
const userId = "@group:example.org";
|
|
79
|
+
const roomId = "!group:example.org";
|
|
80
|
+
const client = {
|
|
81
|
+
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
|
|
82
|
+
getJoinedRooms: vi.fn().mockResolvedValue([roomId]),
|
|
83
|
+
getJoinedRoomMembers: vi.fn().mockResolvedValue([
|
|
84
|
+
"@bot:example.org",
|
|
85
|
+
userId,
|
|
86
|
+
"@extra:example.org",
|
|
87
|
+
]),
|
|
88
|
+
setAccountData: vi.fn().mockResolvedValue(undefined),
|
|
89
|
+
} as unknown as MatrixClient;
|
|
90
|
+
|
|
91
|
+
const resolved = await resolveMatrixRoomId(client, userId);
|
|
92
|
+
|
|
93
|
+
expect(resolved).toBe(roomId);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("normalizeThreadId", () => {
|
|
98
|
+
it("returns null for empty thread ids", () => {
|
|
99
|
+
expect(normalizeThreadId(" ")).toBeNull();
|
|
100
|
+
expect(normalizeThreadId("$thread")).toBe("$thread");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
|
2
|
+
|
|
3
|
+
import { EventType, type MatrixDirectAccountData } from "./types.js";
|
|
4
|
+
|
|
5
|
+
function normalizeTarget(raw: string): string {
|
|
6
|
+
const trimmed = raw.trim();
|
|
7
|
+
if (!trimmed) {
|
|
8
|
+
throw new Error("Matrix target is required (room:<id> or #alias)");
|
|
9
|
+
}
|
|
10
|
+
return trimmed;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function normalizeThreadId(raw?: string | number | null): string | null {
|
|
14
|
+
if (raw === undefined || raw === null) return null;
|
|
15
|
+
const trimmed = String(raw).trim();
|
|
16
|
+
return trimmed ? trimmed : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const directRoomCache = new Map<string, string>();
|
|
20
|
+
|
|
21
|
+
async function persistDirectRoom(
|
|
22
|
+
client: MatrixClient,
|
|
23
|
+
userId: string,
|
|
24
|
+
roomId: string,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
let directContent: MatrixDirectAccountData | null = null;
|
|
27
|
+
try {
|
|
28
|
+
directContent = (await client.getAccountData(
|
|
29
|
+
EventType.Direct,
|
|
30
|
+
)) as MatrixDirectAccountData | null;
|
|
31
|
+
} catch {
|
|
32
|
+
// Ignore fetch errors and fall back to an empty map.
|
|
33
|
+
}
|
|
34
|
+
const existing =
|
|
35
|
+
directContent && !Array.isArray(directContent) ? directContent : {};
|
|
36
|
+
const current = Array.isArray(existing[userId]) ? existing[userId] : [];
|
|
37
|
+
if (current[0] === roomId) return;
|
|
38
|
+
const next = [roomId, ...current.filter((id) => id !== roomId)];
|
|
39
|
+
try {
|
|
40
|
+
await client.setAccountData(EventType.Direct, {
|
|
41
|
+
...existing,
|
|
42
|
+
[userId]: next,
|
|
43
|
+
});
|
|
44
|
+
} catch {
|
|
45
|
+
// Ignore persistence errors.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function resolveDirectRoomId(
|
|
50
|
+
client: MatrixClient,
|
|
51
|
+
userId: string,
|
|
52
|
+
): Promise<string> {
|
|
53
|
+
const trimmed = userId.trim();
|
|
54
|
+
if (!trimmed.startsWith("@")) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Matrix user IDs must be fully qualified (got "${trimmed}")`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const cached = directRoomCache.get(trimmed);
|
|
61
|
+
if (cached) return cached;
|
|
62
|
+
|
|
63
|
+
// 1) Fast path: use account data (m.direct) for *this* logged-in user (the bot).
|
|
64
|
+
try {
|
|
65
|
+
const directContent = (await client.getAccountData(
|
|
66
|
+
EventType.Direct,
|
|
67
|
+
)) as MatrixDirectAccountData | null;
|
|
68
|
+
const list = Array.isArray(directContent?.[trimmed])
|
|
69
|
+
? directContent[trimmed]
|
|
70
|
+
: [];
|
|
71
|
+
if (list.length > 0) {
|
|
72
|
+
directRoomCache.set(trimmed, list[0]);
|
|
73
|
+
return list[0];
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// Ignore and fall back.
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 2) Fallback: look for an existing joined room that looks like a 1:1 with the user.
|
|
80
|
+
// Many clients only maintain m.direct for *their own* account data, so relying on it is brittle.
|
|
81
|
+
let fallbackRoom: string | null = null;
|
|
82
|
+
try {
|
|
83
|
+
const rooms = await client.getJoinedRooms();
|
|
84
|
+
for (const roomId of rooms) {
|
|
85
|
+
let members: string[];
|
|
86
|
+
try {
|
|
87
|
+
members = await client.getJoinedRoomMembers(roomId);
|
|
88
|
+
} catch {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!members.includes(trimmed)) continue;
|
|
92
|
+
// Prefer classic 1:1 rooms, but allow larger rooms if requested.
|
|
93
|
+
if (members.length === 2) {
|
|
94
|
+
directRoomCache.set(trimmed, roomId);
|
|
95
|
+
await persistDirectRoom(client, trimmed, roomId);
|
|
96
|
+
return roomId;
|
|
97
|
+
}
|
|
98
|
+
if (!fallbackRoom) {
|
|
99
|
+
fallbackRoom = roomId;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// Ignore and fall back.
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (fallbackRoom) {
|
|
107
|
+
directRoomCache.set(trimmed, fallbackRoom);
|
|
108
|
+
await persistDirectRoom(client, trimmed, fallbackRoom);
|
|
109
|
+
return fallbackRoom;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
throw new Error(`No direct room found for ${trimmed} (m.direct missing)`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function resolveMatrixRoomId(
|
|
116
|
+
client: MatrixClient,
|
|
117
|
+
raw: string,
|
|
118
|
+
): Promise<string> {
|
|
119
|
+
const target = normalizeTarget(raw);
|
|
120
|
+
const lowered = target.toLowerCase();
|
|
121
|
+
if (lowered.startsWith("matrix:")) {
|
|
122
|
+
return await resolveMatrixRoomId(client, target.slice("matrix:".length));
|
|
123
|
+
}
|
|
124
|
+
if (lowered.startsWith("room:")) {
|
|
125
|
+
return await resolveMatrixRoomId(client, target.slice("room:".length));
|
|
126
|
+
}
|
|
127
|
+
if (lowered.startsWith("channel:")) {
|
|
128
|
+
return await resolveMatrixRoomId(client, target.slice("channel:".length));
|
|
129
|
+
}
|
|
130
|
+
if (lowered.startsWith("user:")) {
|
|
131
|
+
return await resolveDirectRoomId(client, target.slice("user:".length));
|
|
132
|
+
}
|
|
133
|
+
if (target.startsWith("@")) {
|
|
134
|
+
return await resolveDirectRoomId(client, target);
|
|
135
|
+
}
|
|
136
|
+
if (target.startsWith("#")) {
|
|
137
|
+
const resolved = await client.resolveRoom(target);
|
|
138
|
+
if (!resolved) {
|
|
139
|
+
throw new Error(`Matrix alias ${target} could not be resolved`);
|
|
140
|
+
}
|
|
141
|
+
return resolved;
|
|
142
|
+
}
|
|
143
|
+
return target;
|
|
144
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
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
|
+
client?: import("@vector-im/matrix-bot-sdk").MatrixClient;
|
|
89
|
+
mediaUrl?: string;
|
|
90
|
+
accountId?: string;
|
|
91
|
+
replyToId?: string;
|
|
92
|
+
threadId?: string | number | null;
|
|
93
|
+
timeoutMs?: number;
|
|
94
|
+
/** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */
|
|
95
|
+
audioAsVoice?: boolean;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export type MatrixMediaMsgType =
|
|
99
|
+
| typeof MsgType.Image
|
|
100
|
+
| typeof MsgType.Audio
|
|
101
|
+
| typeof MsgType.Video
|
|
102
|
+
| typeof MsgType.File;
|
|
103
|
+
|
|
104
|
+
export type MediaKind = "image" | "audio" | "video" | "document" | "unknown";
|
|
105
|
+
|
|
106
|
+
export type MatrixFormattedContent = MessageEventContent & {
|
|
107
|
+
format?: string;
|
|
108
|
+
formatted_body?: string;
|
|
109
|
+
};
|