Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 75 additions & 66 deletions examples/encryption_bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,73 +34,82 @@ const worksImage = fs.readFileSync("./examples/static/it-works.png");
const client = new MatrixClient(homeserverUrl, accessToken, storage, crypto);

(async function() {
let encryptedRoomId: string;
const joinedRooms = await client.getJoinedRooms();
await client.crypto.prepare(joinedRooms); // init crypto because we're doing things before the client is started
for (const roomId of joinedRooms) {
if (await client.crypto.isRoomEncrypted(roomId)) {
encryptedRoomId = roomId;
break;
}
}
if (!encryptedRoomId) {
encryptedRoomId = await client.createRoom({
invite: [dmTarget],
is_direct: true,
visibility: "private",
preset: "trusted_private_chat",
initial_state: [
{ type: "m.room.encryption", state_key: "", content: { algorithm: EncryptionAlgorithm.MegolmV1AesSha2 } },
{ type: "m.room.guest_access", state_key: "", content: { guest_access: "can_join" } },
],
});
}

client.on("room.failed_decryption", async (roomId: string, event: any, e: Error) => {
LogService.error("index", `Failed to decrypt ${roomId} ${event['event_id']} because `, e);
});

client.on("room.message", async (roomId: string, event: any) => {
if (roomId !== encryptedRoomId) return;

const message = new MessageEvent(event);

if (message.sender === (await client.getUserId()) && message.messageType === "m.notice") {
// yay, we decrypted our own message. Communicate that back for testing purposes.
const encrypted = await client.crypto.encryptMedia(Buffer.from(worksImage));
const mxc = await client.uploadContent(encrypted.buffer);
await client.sendMessage(roomId, {
msgtype: "m.image",
body: "it-works.png",
info: {
// XXX: We know these details, so have hardcoded them.
w: 256,
h: 256,
mimetype: "image/png",
size: worksImage.length,
},
file: {
url: mxc,
...encrypted.file,
},
});
return;
}

if (message.messageType === "m.image") {
const fileEvent = new MessageEvent<FileMessageEventContent>(message.raw);
const decrypted = await client.crypto.decryptMedia(fileEvent.content.file);
fs.writeFileSync('./examples/storage/decrypted.png', decrypted);
await client.unstableApis.addReactionToEvent(roomId, fileEvent.eventId, 'Decrypted');
return;
}

if (message.messageType !== "m.text") return;
if (message.textBody.startsWith("!ping")) {
await client.replyNotice(roomId, event, "Pong");
}
});
// let encryptedRoomId: string;
// const joinedRooms = await client.getJoinedRooms();
// await client.crypto.prepare(joinedRooms); // init crypto because we're doing things before the client is started
// for (const roomId of joinedRooms) {
// if (await client.crypto.isRoomEncrypted(roomId)) {
// encryptedRoomId = roomId;
// break;
// }
// }
// if (!encryptedRoomId) {
// encryptedRoomId = await client.createRoom({
// invite: [dmTarget],
// is_direct: true,
// visibility: "private",
// preset: "trusted_private_chat",
// initial_state: [
// {type: "m.room.encryption", state_key: "", content: {algorithm: EncryptionAlgorithm.MegolmV1AesSha2}},
// {type: "m.room.guest_access", state_key: "", content: {guest_access: "can_join"}},
// ],
// });
// }
//
// client.on("room.failed_decryption", async (roomId: string, event: any, e: Error) => {
// LogService.error("index", `Failed to decrypt ${roomId} ${event['event_id']} because `, e);
// });
//
// client.on("room.message", async (roomId: string, event: any) => {
// if (roomId !== encryptedRoomId) return;
//
// const message = new MessageEvent(event);
//
// if (message.sender === (await client.getUserId()) && message.messageType === "m.notice") {
// // yay, we decrypted our own message. Communicate that back for testing purposes.
// const encrypted = await client.crypto.encryptMedia(Buffer.from(worksImage));
// const mxc = await client.uploadContent(encrypted.buffer);
// await client.sendMessage(roomId, {
// msgtype: "m.image",
// body: "it-works.png",
// info: {
// // XXX: We know these details, so have hardcoded them.
// w: 256,
// h: 256,
// mimetype: "image/png",
// size: worksImage.length,
// },
// file: {
// url: mxc,
// ...encrypted.file,
// },
// });
// return;
// }
//
// if (message.messageType === "m.image") {
// const fileEvent = new MessageEvent<FileMessageEventContent>(message.raw);
// const decrypted = await client.crypto.decryptMedia(fileEvent.content.file);
// fs.writeFileSync('./examples/storage/decrypted.png', decrypted);
// await client.unstableApis.addReactionToEvent(roomId, fileEvent.eventId, 'Decrypted');
// return;
// }
//
// if (message.messageType !== "m.text") return;
// if (message.textBody.startsWith("!ping")) {
// await client.replyNotice(roomId, event, "Pong");
// }
// });

LogService.info("index", "Starting bot...");
await client.start();

const callRoomId = await client.joinRoom("#test-call-1:localhost");
const calls = await client.unstableApis.getCallsInRoom(callRoomId);
const activeCall = calls.find(c => !c.callEvent.isTerminated);
if (!activeCall) {
throw new Error("A call is not in progress");
}

await activeCall.join();
})();
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@
"morgan": "^1.10.0",
"request": "^2.88.2",
"request-promise": "^4.2.6",
"sanitize-html": "^2.7.0"
"sanitize-html": "^2.7.0",
"wrtc": "^0.4.7"
},
"devDependencies": {
"@babel/core": "^7.18.2",
Expand Down
4 changes: 2 additions & 2 deletions src/MatrixClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -957,12 +957,12 @@ export class MatrixClient extends EventEmitter {
* @returns {Promise<any>} resolves to the state event
*/
@timedMatrixClientFunctionCall()
public getRoomStateEvent(roomId, type, stateKey): Promise<any> {
public getRoomStateEvent(roomId, type, stateKey, format = "content"): Promise<any> {
const path = "/_matrix/client/v3/rooms/"
+ encodeURIComponent(roomId) + "/state/"
+ encodeURIComponent(type) + "/"
+ encodeURIComponent(stateKey ? stateKey : '');
return this.doRequest("GET", path)
return this.doRequest("GET", path, { format })
.then(ev => this.processEvent(ev));
}

Expand Down
65 changes: 65 additions & 0 deletions src/UnstableApis.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { MatrixClient } from "./MatrixClient";
import { MSC2380MediaInfo } from "./models/unstable/MediaInfo";
import { MSC3401Call } from "./voip/MSC3401Call";
import { MSC3401CallEvent } from "./models/events/MSC3401CallEvent";

/**
* Unstable APIs that shouldn't be used in most circumstances.
Expand Down Expand Up @@ -71,4 +73,67 @@ export class UnstableApis {
}
return this.client.doRequest("GET", `/_matrix/media/unstable/info/${encodeURIComponent(domain)}/${encodeURIComponent(mediaId)}`);
}

/**
* Creates an MSC3401 call room (public). This is essentially a proxy to the createRoom
* function with a special template.
* @param {string} name The name of the call.
* @returns {Promise<string>} Resolves to the room ID.
*/
public async createCallRoom(name: string): Promise<string> {
return this.client.createRoom({
// Template borrowed from Element Call
name: name,
preset: "public_chat",
room_alias_name: name,
visibility: "private",
power_level_content_override: {
users_default: 0,
events_default: 0,
state_default: 0,
invite: 100,
kick: 100,
redact: 50,
ban: 100,
events: {
"m.room.encrypted": 50,
"m.room.encryption": 100,
"m.room.history_visibility": 100,
"m.room.message": 0,
"m.room.name": 50,
"m.room.power_levels": 100,
"m.room.tombstone": 100,
"m.sticker": 50,
"org.matrix.msc3401.call.member": 0,
},
users: {
[await this.client.getUserId()]: 100,
},
},
});
}

/**
* Starts a call in the room.
* @param {string} roomId The room ID to start a call in.
* @returns {Promise<MSC3401Call>} Resolves to the call object.
*/
public async startCallInRoom(roomId: string): Promise<MSC3401Call> {
const roomName = await this.client.getRoomStateEvent(roomId, "m.room.name", "");
const call = new MSC3401Call(this.client, roomId, roomName["name"]);
await this.client.sendStateEvent(roomId, call.callEvent.type, call.callEvent.stateKey, call.callEvent.content);
return call;
}

/**
* Get all the calls in a room.
* @param {string} roomId The room ID.
* @returns {Promise<MSC3401Call[]>} Resolves to an array of all known calls.
*/
public async getCallsInRoom(roomId: string): Promise<MSC3401Call[]> {
const state = await this.client.getRoomState(roomId);
return state
.filter(s => s.type === 'org.matrix.msc3401.call')
.map(s => new MSC3401Call(this.client, roomId, new MSC3401CallEvent(s)));
}
}
48 changes: 48 additions & 0 deletions src/models/events/MSC3401CallEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { StateEvent } from "./RoomEvent";

/**
* The content definition for m.call state events
* @category Matrix event contents
* @see MSC3401CallEvent
*/
export interface MSC3401CallEventContent {
"m.intent": "m.room" | "m.ring" | "m.prompt" | string;
"m.type": "m.voice" | "m.video" | string;
"m.terminated": boolean; // TODO: Check type
"m.name": string; // TODO: Check if used
"m.foci"?: string[]; // Not currently supported
}

/**
* Represents an m.call state event
* @category Matrix events
*/
export class MSC3401CallEvent extends StateEvent<MSC3401CallEventContent> {
constructor(event: any) {
super(event);
}

public get callId(): string {
return this.stateKey;
}

public get startTime(): number {
return this.timestamp;
}

public get intent(): MSC3401CallEventContent["m.intent"] {
return this.content["m.intent"];
}

public get callType(): MSC3401CallEventContent["m.type"] {
return this.content["m.type"];
}

public get isTerminated(): boolean {
return !!this.content["m.terminated"];
}

public get name(): string {
return this.content["m.name"];
}
}
58 changes: 58 additions & 0 deletions src/models/events/MSC3401CallMemberEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { StateEvent } from "./RoomEvent";

/**
* The definition of a member's device in an m.call.member event content.
* @category Matrix event contents
* @see MSC3401CallMemberEventContent
*/
export interface MSC3401CallMemberEventDevice {
device_id: string;
session_id: string;
feeds: {
purpose: "m.usermedia" | "m.screenshare" | string;
}[];
}

/**
* The content definition for m.call.member state events
* @category Matrix event contents
* @see MSC3401CallMemberEvent
*/
export interface MSC3401CallMemberEventContent {
"m.calls": {
"m.call_id": string;
"m.foci"?: string[]; // not currently used
"m.devices": MSC3401CallMemberEventDevice[];
}[];
}

/**
* Represents an m.call.member state event
* @category Matrix events
*/
export class MSC3401CallMemberEvent extends StateEvent<MSC3401CallMemberEvent> {
constructor(event: any) {
super(event);
}

public get forUserId(): string {
return this.stateKey;
}

public get isInCall(): boolean {
return this.content["m.calls"]?.length > 0;
}

public get callId(): string {
return this.content["m.calls"]?.[0]?.["m.call_id"];
}

public get deviceIdSessions(): Record<string, string> {
return this.content["m.calls"]?.[0]?.["m.devices"]?.reduce((p, c) => {
if (c.feeds?.filter(f => f.purpose === "m.usermedia" || f.purpose === "m.screenshare").length === 0) {
return p;
}
return { ...p, [c.device_id]: c.session_id };
}, {});
}
}
Loading