diff --git a/desktop/renderer-app/package.json b/desktop/renderer-app/package.json index ccfd3e42bd7..fb8c35e5bde 100644 --- a/desktop/renderer-app/package.json +++ b/desktop/renderer-app/package.json @@ -29,6 +29,7 @@ "@netless/flat-service-provider-agora-cloud-recording": "workspace:*", "@netless/flat-service-provider-agora-rtc-electron": "workspace:*", "@netless/flat-service-provider-agora-rtm": "workspace:*", + "@netless/flat-service-provider-agora-rtm2": "workspace:*", "@netless/flat-service-provider-fastboard": "workspace:*", "@netless/flat-service-provider-file-convert-h5": "workspace:*", "@netless/flat-service-provider-file-convert-netless": "workspace:*", diff --git a/desktop/renderer-app/src/tasks/init-flat-services.ts b/desktop/renderer-app/src/tasks/init-flat-services.ts index dd2e4b2ef2b..79f23008a54 100644 --- a/desktop/renderer-app/src/tasks/init-flat-services.ts +++ b/desktop/renderer-app/src/tasks/init-flat-services.ts @@ -119,8 +119,8 @@ export function initFlatServices(): void { }); flatServices.register("textChat", async () => { - const { AgoraRTM } = await import("@netless/flat-service-provider-agora-rtm"); - return new AgoraRTM(config.agora.appId); + const { AgoraRTM2 } = await import("@netless/flat-service-provider-agora-rtm2"); + return new AgoraRTM2(config.agora.appId); }); flatServices.register("whiteboard", async () => { diff --git a/packages/flat-pages/package.json b/packages/flat-pages/package.json index f618eb8f546..a10b13b92e4 100644 --- a/packages/flat-pages/package.json +++ b/packages/flat-pages/package.json @@ -13,6 +13,7 @@ "@netless/flat-server-api": "workspace:*", "@netless/flat-service-provider-agora-rtc-web": "workspace:*", "@netless/flat-service-provider-agora-rtm": "workspace:*", + "@netless/flat-service-provider-agora-rtm2": "workspace:*", "@netless/flat-services": "workspace:*", "@netless/flat-stores": "workspace:*", "@netless/sync-player": "^1.0.7", diff --git a/packages/flat-stores/src/classroom-store/index.ts b/packages/flat-stores/src/classroom-store/index.ts index f236e01089c..7dfe418d54e 100644 --- a/packages/flat-stores/src/classroom-store/index.ts +++ b/packages/flat-stores/src/classroom-store/index.ts @@ -880,6 +880,7 @@ export class ClassroomStore { public async destroy(): Promise { this.sideEffect.flushAll(); + (window as any).classroomStore = null; await this.stopRecording(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72ff615dd16..f598dcf6b1a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -267,6 +267,9 @@ importers: '@netless/flat-service-provider-agora-rtm': specifier: workspace:* version: link:../../service-providers/agora-rtm + '@netless/flat-service-provider-agora-rtm2': + specifier: workspace:* + version: link:../../service-providers/agora-rtm2 '@netless/flat-service-provider-fastboard': specifier: workspace:* version: link:../../service-providers/fastboard @@ -612,6 +615,9 @@ importers: '@netless/flat-service-provider-agora-rtm': specifier: workspace:* version: link:../../service-providers/agora-rtm + '@netless/flat-service-provider-agora-rtm2': + specifier: workspace:* + version: link:../../service-providers/agora-rtm2 '@netless/flat-services': specifier: workspace:* version: link:../flat-services @@ -847,8 +853,8 @@ importers: specifier: workspace:* version: link:../../../packages/flat-services agora-rtc-sdk-ng: - specifier: 4.16.0 - version: 4.16.0 + specifier: ^4.20.0 + version: 4.21.0 side-effect-manager: specifier: ^1.2.1 version: 1.2.1 @@ -888,6 +894,31 @@ importers: specifier: ^4.8.3 version: 4.8.3 + service-providers/agora-rtm2: + dependencies: + '@netless/flat-server-api': + specifier: workspace:* + version: link:../../packages/flat-server-api + '@netless/flat-services': + specifier: workspace:* + version: link:../../packages/flat-services + agora-rtm: + specifier: ^2.1.10 + version: 2.1.10 + side-effect-manager: + specifier: ^1.2.1 + version: 1.2.1 + uuid: + specifier: ^9.0.0 + version: 9.0.0 + devDependencies: + prettier: + specifier: ^3.2.4 + version: 3.2.4 + typescript: + specifier: ^4.8.3 + version: 4.8.3 + service-providers/fastboard: dependencies: '@lukeed/uuid': @@ -1066,6 +1097,9 @@ importers: '@netless/flat-service-provider-agora-rtm': specifier: workspace:* version: link:../../service-providers/agora-rtm + '@netless/flat-service-provider-agora-rtm2': + specifier: workspace:* + version: link:../../service-providers/agora-rtm2 '@netless/flat-service-provider-fastboard': specifier: workspace:* version: link:../../service-providers/fastboard @@ -1090,9 +1124,6 @@ importers: '@zip.js/zip.js': specifier: ^2.6.29 version: 2.6.29 - agora-rtc-sdk-ng: - specifier: ^4.16.0 - version: 4.16.0 agora-rtm-sdk: specifier: ^1.6.0 version: 1.6.0 @@ -1197,6 +1228,37 @@ packages: resolution: {integrity: sha512-sAP4LldeWNz0lNzmTird3uWfFDWWTeg6V/MsmyyLR9X1idwKBWIgt/ZvinqQldJm3LecKEs1emkbquO6PCiLVQ==} dev: true + /@agora-js/media@4.21.0: + resolution: {integrity: sha512-X4aV84/gB4O7GOOkwP3+NGTHtT2IVkpa4DFBTlBNl7hrkjDwUeanzCQZyva92Zyw59N0NI6dKh9CjJKoIL5Now==} + dependencies: + '@agora-js/report': 4.21.0 + '@agora-js/shared': 4.21.0 + agora-rte-extension: 1.2.4 + axios: 1.7.2 + pako: 2.1.0 + webrtc-adapter: 8.2.0 + transitivePeerDependencies: + - debug + dev: false + + /@agora-js/report@4.21.0: + resolution: {integrity: sha512-c8KIdomuPItwvri431z5CPT7L4m+jLJrwWWt/QS3JN+sp/t49NnoOIyKQf3y5xCbyNPCNNeGofrwkaIRu4YE8g==} + dependencies: + '@agora-js/shared': 4.21.0 + axios: 1.7.2 + transitivePeerDependencies: + - debug + dev: false + + /@agora-js/shared@4.21.0: + resolution: {integrity: sha512-oqaiuIhG9ai/NXUDEmj9b3uGxxU9I0OZZszNaJexjakJuVZPM7ypzrCLUi5SzkTh++QOf68yuvD488fjq5WQsA==} + dependencies: + axios: 1.7.2 + ua-parser-js: 0.7.38 + transitivePeerDependencies: + - debug + dev: false + /@ampproject/remapping@2.2.0: resolution: {integrity: sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==} engines: {node: '>=6.0.0'} @@ -6589,14 +6651,23 @@ packages: - supports-color dev: false - /agora-rtc-sdk-ng@4.16.0: - resolution: {integrity: sha512-Wyoyzb0+ewRfMtyDxnJqHVQqBQYO4IkZsmlfzomXt69FA1rAr5yhQBxy1LxO/mwE9mm1XIC/9Yz5dEjL2d4ehg==} + /agora-rtc-sdk-ng@4.21.0: + resolution: {integrity: sha512-EAZMdhbqIXl/PtFqEQn0O5Pmj3Tt+4oTXtd76MK1CozgbcDsc0TmFZK3qM25eaKqhzTz1wiVCwzBCWs3pF5OpQ==} dependencies: - agora-rte-extension: 1.2.3 + '@agora-js/media': 4.21.0 + '@agora-js/report': 4.21.0 + '@agora-js/shared': 4.21.0 + agora-rte-extension: 1.2.4 + axios: 1.7.2 + formdata-polyfill: 4.0.10 + ua-parser-js: 0.7.38 + webrtc-adapter: 8.2.0 + transitivePeerDependencies: + - debug dev: false - /agora-rte-extension@1.2.3: - resolution: {integrity: sha512-k3yNrYVyzJRoQJjaJUktKUI1XRtf8J1XsW8OzYKFqGlS8WQRMsES1+Phj2rfuEriiLObfuyuCimG6KHQCt5tiw==} + /agora-rte-extension@1.2.4: + resolution: {integrity: sha512-0ovZz1lbe30QraG1cU+ji7EnQ8aUu+Hf3F+a8xPml3wPOyUQEK6CTdxV9kMecr9t+fIDrGeW7wgJTsM1DQE7Nw==} dev: false /agora-rtm-sdk@1.6.0: @@ -6604,6 +6675,14 @@ packages: deprecated: this package has been deprecated dev: false + /agora-rtm@2.1.10: + resolution: {integrity: sha512-Rc6frRI4ah3T8cJM+xggjaSgD5OXaLrh0q98fbGmZ9N92GFxz6xH4XJmmZ4/8UcK9pzvJeu/A6JOxN+fLI2Bcw==} + dependencies: + agora-rtc-sdk-ng: 4.21.0 + transitivePeerDependencies: + - debug + dev: false + /airbnb-js-shims@2.2.1: resolution: {integrity: sha512-wJNXPH66U2xjgo1Zwyjf9EydvJ2Si94+vSdk6EERcBfB2VZkeltpqIats0cqIZMLCXP3zcyaUKGYQeIBT6XjsQ==} dependencies: @@ -7240,6 +7319,16 @@ packages: - debug dev: false + /axios@1.7.2: + resolution: {integrity: sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==} + dependencies: + follow-redirects: 1.15.6 + form-data: 4.0.0 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + dev: false + /axobject-query@2.2.0: resolution: {integrity: sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA==} dev: true @@ -11186,6 +11275,14 @@ packages: dependencies: pend: 1.2.0 + /fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + dev: false + /fflate@0.4.8: resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} @@ -11410,6 +11507,16 @@ packages: optional: true dev: false + /follow-redirects@1.15.6: + resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + dev: false + /for-in@1.0.2: resolution: {integrity: sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==} engines: {node: '>=0.10.0'} @@ -11631,6 +11738,13 @@ packages: engines: {node: '>=0.4.x'} dev: true + /formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + dependencies: + fetch-blob: 3.2.0 + dev: false + /formstream@1.1.1: resolution: {integrity: sha512-yHRxt3qLFnhsKAfhReM4w17jP+U1OlhUjnKPPtonwKbIJO7oBP0MvoxkRUwb8AU9n0MIkYy5X5dK6pQnbj+R2Q==} dependencies: @@ -14874,6 +14988,11 @@ packages: minimatch: 3.1.2 dev: true + /node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + dev: false + /node-downloader-helper@2.1.4: resolution: {integrity: sha512-Cbc5jwGTe58apFIPjxgcUzX0Se+pcUgdbym6G+sk2yb1m/qwxYTLmD4C2xEHTJO9YkZ/eRujMJPl3WW+7fVksQ==} engines: {node: '>=14.18'} @@ -15516,6 +15635,10 @@ packages: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} dev: true + /pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + dev: false + /parallel-transform@1.2.0: resolution: {integrity: sha512-P2vSmIu38uIlvdcU7fDkyrxj33gTUy/ABO5ZUbGowxNCopBq/OoD42bP4UmMrJoPyk4Uqf0mu3mtWBhHCZD8yg==} dependencies: @@ -18217,6 +18340,10 @@ packages: get-ready: 1.0.0 dev: true + /sdp@3.2.0: + resolution: {integrity: sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw==} + dev: false + /seedrandom@3.0.5: resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==} @@ -19868,6 +19995,10 @@ packages: engines: {node: '>=4.2.0'} hasBin: true + /ua-parser-js@0.7.38: + resolution: {integrity: sha512-fYmIy7fKTSFAhG3fuPlubeGaMoAd6r0rSnfEsO5nEY55i26KSLt9EH7PLQiiqPUhNqYIJvSkTy1oArIcXAbPbA==} + dev: false + /ua-parser-js@1.0.2: resolution: {integrity: sha512-00y/AXhx0/SsnI51fTc0rLRmafiGOM4/O+ny10Ps7f+j/b8p/ZY11ytMgznXkOVo4GQ+KwQG5UQLkLGirsACRg==} dev: false @@ -20570,6 +20701,11 @@ packages: resolution: {integrity: sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw==} dev: true + /web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + dev: false + /webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} dev: true @@ -20804,6 +20940,13 @@ packages: - uglify-js dev: true + /webrtc-adapter@8.2.0: + resolution: {integrity: sha512-umxCMgedPAVq4Pe/jl3xmelLXLn4XZWFEMR5Iipb5wJ+k1xMX0yC4ZY9CueZUU1MjapFxai1tFGE7R/kotH6Ww==} + engines: {node: '>=6.0.0', npm: '>=3.10.0'} + dependencies: + sdp: 3.2.0 + dev: false + /whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} dependencies: diff --git a/service-providers/agora-rtc/agora-rtc-web/package.json b/service-providers/agora-rtc/agora-rtc-web/package.json index 17e6527b230..ef3f1a31317 100644 --- a/service-providers/agora-rtc/agora-rtc-web/package.json +++ b/service-providers/agora-rtc/agora-rtc-web/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@netless/flat-services": "workspace:*", - "agora-rtc-sdk-ng": "4.16.0", + "agora-rtc-sdk-ng": "^4.20.0", "side-effect-manager": "^1.2.1", "value-enhancer": "^1.3.2" } diff --git a/service-providers/agora-rtm2/README.md b/service-providers/agora-rtm2/README.md new file mode 100644 index 00000000000..e16f5a8ce96 --- /dev/null +++ b/service-providers/agora-rtm2/README.md @@ -0,0 +1,3 @@ +# @netless/flat-service-provider-agora-rtm2 + +Implements the `textChat` Flat service. diff --git a/service-providers/agora-rtm2/package.json b/service-providers/agora-rtm2/package.json new file mode 100644 index 00000000000..8ed17f7ef08 --- /dev/null +++ b/service-providers/agora-rtm2/package.json @@ -0,0 +1,22 @@ +{ + "name": "@netless/flat-service-provider-agora-rtm2", + "version": "0.1.0", + "description": "Agora Realtime Messaging 2.0", + "main": "src/index.ts", + "private": true, + "license": "MIT", + "scripts": { + "build": "tsc" + }, + "devDependencies": { + "prettier": "^3.2.4", + "typescript": "^4.8.3" + }, + "dependencies": { + "@netless/flat-server-api": "workspace:*", + "@netless/flat-services": "workspace:*", + "agora-rtm": "^2.1.10", + "side-effect-manager": "^1.2.1", + "uuid": "^9.0.0" + } +} diff --git a/service-providers/agora-rtm2/src/index.ts b/service-providers/agora-rtm2/src/index.ts new file mode 100644 index 00000000000..f2aea4a7ae9 --- /dev/null +++ b/service-providers/agora-rtm2/src/index.ts @@ -0,0 +1 @@ +export * from "./rtm2"; diff --git a/service-providers/agora-rtm2/src/rtm2.ts b/service-providers/agora-rtm2/src/rtm2.ts new file mode 100644 index 00000000000..e990127d2a1 --- /dev/null +++ b/service-providers/agora-rtm2/src/rtm2.ts @@ -0,0 +1,440 @@ +import { + IServiceTextChat, + IServiceTextChatJoinRoomConfig, + IServiceTextChatPeerCommand, + IServiceTextChatPeerCommandData, + IServiceTextChatRoomCommand, + IServiceTextChatRoomCommandData, +} from "@netless/flat-services"; +import AgoraRTM, { RTMClient, RTMEvents } from "agora-rtm"; +import { v4 as uuidv4 } from "uuid"; +import { generateRTMToken } from "@netless/flat-server-api"; + +const { RTM } = AgoraRTM; + +export class AgoraRTM2 extends IServiceTextChat { + public readonly members = new Set(); + + private readonly _encoder = new TextEncoder(); + private readonly _decoder = new TextDecoder(); + + private _pJoiningRoom?: Promise; + private _pLeavingRoom?: Promise; + + public client?: RTMClient; + public channel?: string; + + private roomUUID?: string; + private userUUID?: string; + private token?: string; + + public constructor(private readonly APP_ID: string) { + super(); + if (!APP_ID) { + throw new Error("APP_ID is not set"); + } + } + + public override async destroy(): Promise { + super.destroy(); + + try { + await this.leaveRoom(); + } catch (e) { + console.error(e); + } + } + + public async joinRoom(config: IServiceTextChatJoinRoomConfig): Promise { + if (this._pJoiningRoom) { + await this._pJoiningRoom; + } + if (this._pLeavingRoom) { + await this._pLeavingRoom; + } + + if (this.client) { + if (this.channel === config.roomUUID) { + return; + } + await this.leaveRoom(); + } + + this._pJoiningRoom = this._joinRoom(config); + await this._pJoiningRoom; + this._pJoiningRoom = undefined; + } + + public async leaveRoom(): Promise { + if (this._pJoiningRoom) { + await this._pJoiningRoom; + } + if (this._pLeavingRoom) { + await this._pLeavingRoom; + } + + if (this.channel) { + this.sideEffect.flushAll(); + this._pLeavingRoom = this._leaveRoom(this.channel); + await this._pLeavingRoom; + this._pLeavingRoom = undefined; + + this.channel = undefined; + this.token = undefined; + } + + this.roomUUID = undefined; + this.userUUID = undefined; + this.members.clear(); + } + + public async sendRoomMessage(message: string): Promise { + if (this.client && this.channel) { + await this.client.publish(this.channel, message); + // emit to local + if (this.roomUUID && this.userUUID) { + this.events.emit("room-message", { + uuid: uuidv4(), + roomUUID: this.roomUUID, + text: message, + senderID: this.userUUID, + timestamp: Date.now(), + }); + } + } else { + if (process.env.NODE_ENV === "development") { + console.error("sendRoomMessage: channel is not ready"); + } + } + } + + public async sendRoomCommand( + t: TName, + v: IServiceTextChatRoomCommandData[TName], + ): Promise { + if (this.client && this.channel) { + const command = { t, v } as IServiceTextChatRoomCommand; + await this.client.publish(this.channel, this._encoder.encode(JSON.stringify(command))); + // emit to local + if (this.roomUUID && this.userUUID) { + this._emitRoomCommand(this.roomUUID, this.userUUID, command); + } + } else { + if (process.env.NODE_ENV === "development") { + console.error("sendCommand: channel is not ready"); + } + } + } + + public async sendPeerMessage(message: string, peerID: string): Promise { + if (this.client && this.channel) { + await this.client.publish(peerID, message, { channelType: "USER" }); + return true; + } else { + if (process.env.NODE_ENV === "development") { + console.error("sendPeerMessage: channel is not ready"); + } + return false; + } + } + + public async sendPeerCommand( + t: TName, + v: IServiceTextChatPeerCommandData[TName], + peerID: string, + ): Promise { + if (this.client && this.channel) { + const command = { t, v } as IServiceTextChatPeerCommand; + await this.client.publish(peerID, this._encoder.encode(JSON.stringify(command)), { + channelType: "USER", + }); + return true; + } else { + if (process.env.NODE_ENV === "development") { + console.error("sendPeerCommand: channel is not ready"); + } + return false; + } + } + + private async _joinRoom({ + uid, + token, + roomUUID, + ownerUUID, + }: IServiceTextChatJoinRoomConfig): Promise { + this.token = token || (await generateRTMToken()); + + if (!this.token) { + throw new Error("Missing Agora RTM token"); + } + + const client = (this.client = new RTM(this.APP_ID, uid, { + logLevel: "warn", + logUpload: !process.env.DEV, + token: this.token, + })); + + this.sideEffect.add(() => { + const handler = async (): Promise => { + this.token = await generateRTMToken(); + await client.renewToken(this.token); + }; + client.addEventListener("tokenPrivilegeWillExpire", handler); + return () => client.removeEventListener("tokenPrivilegeWillExpire", handler); + }); + + this.sideEffect.add(() => { + type StatusChangeEvent = + | RTMEvents.RTMConnectionStatusChangeEvent + | RTMEvents.StreamChannelConnectionStatusChangeEvent; + const handler = ({ reason }: StatusChangeEvent): void => { + if (reason === "SAME_UID_LOGIN") { + this.events.emit("remote-login", { roomUUID }); + } + }; + client.addEventListener("status", handler); + return () => client.removeEventListener("status", handler); + }); + + this.sideEffect.add(() => { + const handleRoomMessage = (msg: RTMEvents.MessageEvent): void => { + switch (msg.messageType) { + case "STRING": { + this.events.emit( + msg.publisher === "flat-server" ? "admin-message" : "room-message", + { + uuid: uuidv4(), + roomUUID, + text: msg.message as string, + senderID: msg.publisher, + timestamp: Date.now(), + }, + ); + break; + } + case "BINARY": { + try { + const command = JSON.parse( + this._decoder.decode(msg.message as Uint8Array), + ) as IServiceTextChatRoomCommand; + if (msg.publisher === ownerUUID || command.t === "enter") { + this._emitRoomCommand(roomUUID, msg.publisher, command); + } + } catch (e) { + console.error(e); + } + } + } + }; + + const handlePeerMessage = (msg: RTMEvents.MessageEvent): void => { + if (msg.messageType === "BINARY") { + try { + const command = JSON.parse( + this._decoder.decode(msg.message as Uint8Array), + ) as IServiceTextChatPeerCommand; + if (command.v.roomUUID !== roomUUID) { + return; + } + switch (command.t) { + case "raise-hand": { + this.events.emit("raise-hand", { + roomUUID, + userUUID: msg.publisher, + raiseHand: command.v.raiseHand, + }); + break; + } + case "request-device": { + this.events.emit("request-device", { + roomUUID, + senderID: msg.publisher, + deviceState: command.v, + }); + break; + } + case "request-device-response": { + this.events.emit("request-device-response", { + roomUUID, + userUUID: msg.publisher, + deviceState: command.v, + }); + break; + } + case "notify-device-off": { + this.events.emit("notify-device-off", { + roomUUID, + senderID: msg.publisher, + deviceState: command.v, + }); + break; + } + case "users-info": { + this.events.emit("users-info", { + roomUUID, + userUUID: msg.publisher, + users: command.v.users, + }); + break; + } + } + } catch (e) { + console.error(e); + } + } else { + console.log("Ignored peer message:", msg.message); + } + }; + + const handler = (msg: RTMEvents.MessageEvent): void => { + console.log("[rtm2] message", msg); + + if (msg.channelName === roomUUID) { + handleRoomMessage(msg); + } else if (msg.channelName === uid) { + handlePeerMessage(msg); + } else { + console.warn("Message from unknown channel:", msg.channelName, msg.message); + } + }; + + client.addEventListener("message", handler); + return () => client.removeEventListener("message", handler); + }); + + await client.login(); + await client.subscribe(roomUUID, { + withMessage: true, + withPresence: true, + }); + await client.subscribe(uid, { + withMessage: true, + }); + this.channel = roomUUID; + + await this._initMembers(client, roomUUID); + + this.sideEffect.add(() => { + const handler = (event: RTMEvents.PresenceEvent): void => { + console.log(JSON.stringify(event, null, 2)); + + if (event.eventType === "REMOTE_JOIN") { + this.members.add(event.publisher); + this.events.emit("member-joined", { roomUUID, userUUID: event.publisher }); + } else if (event.eventType === "REMOTE_LEAVE") { + this.members.delete(event.publisher); + this.events.emit("member-left", { roomUUID, userUUID: event.publisher }); + } else if (event.eventType === "REMOTE_TIMEOUT") { + this.members.delete(event.publisher); + this.events.emit("member-left", { roomUUID, userUUID: event.publisher }); + } + + // When the user list exceeds Announce Max (default to 50), it will aggregate + // the events and send them in the "interval" field. + if (event.interval) { + const { join, leave, timeout } = event.interval; + join.users.forEach(user => { + this.members.add(user); + this.events.emit("member-joined", { roomUUID, userUUID: user }); + }); + leave.users.forEach(user => { + this.members.delete(user); + this.events.emit("member-left", { roomUUID, userUUID: user }); + }); + timeout.users.forEach(user => { + this.members.delete(user); + this.events.emit("member-left", { roomUUID, userUUID: user }); + }); + } + }; + client.addEventListener("presence", handler); + return () => client.removeEventListener("presence", handler); + }); + + this.roomUUID = roomUUID; + this.userUUID = uid; + } + + private async _leaveRoom(channel: string): Promise { + const { client } = this; + if (client) { + client.removeAllListeners(); + if (this.userUUID) { + await client.unsubscribe(this.userUUID); + } + await client.unsubscribe(channel); + await client.logout(); + } + } + + private _emitRoomCommand( + roomUUID: string, + ownerUUID: string, + command: IServiceTextChatRoomCommand, + ): void { + switch (command.t) { + case "ban": { + this.events.emit("ban", { + uuid: uuidv4(), + roomUUID, + status: command.v.status, + senderID: ownerUUID, + timestamp: Date.now(), + }); + break; + } + case "notice": { + this.events.emit("notice", { + uuid: uuidv4(), + roomUUID, + text: command.v.text, + senderID: ownerUUID, + timestamp: Date.now(), + }); + break; + } + case "update-room-status": { + this.events.emit("update-room-status", { + roomUUID, + status: command.v.status, + senderID: ownerUUID, + }); + break; + } + case "reward": { + this.events.emit("reward", { + roomUUID, + userUUID: command.v.userUUID, + senderID: ownerUUID, + }); + break; + } + case "enter": { + this.events.emit("enter", { + roomUUID, + userUUID: command.v.userUUID, + userInfo: command.v.userInfo, + peers: command.v.peers, + }); + break; + } + } + } + + private async _initMembers(client: RTMClient, roomUUID: string): Promise { + let page: string | undefined; + do { + const result = await client.presence.whoNow(roomUUID, "MESSAGE", { + includedUserId: true, + page, + }); + const { occupants, nextPage } = result; + occupants.forEach(user => this.members.add(user.userId)); + page = nextPage; + } while (page); + if (process.env.DEV) { + console.log("[rtm2] members.size after init:", this.members.size); + } + } +} diff --git a/service-providers/agora-rtm2/tsconfig.json b/service-providers/agora-rtm2/tsconfig.json new file mode 100644 index 00000000000..71a68c93efb --- /dev/null +++ b/service-providers/agora-rtm2/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "composite": true, + "noImplicitOverride": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["./src"] +} diff --git a/web/flat-web/package.json b/web/flat-web/package.json index a143f2e97d2..d8f25273b9c 100644 --- a/web/flat-web/package.json +++ b/web/flat-web/package.json @@ -29,6 +29,7 @@ "@netless/flat-service-provider-agora-cloud-recording": "workspace:*", "@netless/flat-service-provider-agora-rtc-web": "workspace:*", "@netless/flat-service-provider-agora-rtm": "workspace:*", + "@netless/flat-service-provider-agora-rtm2": "workspace:*", "@netless/flat-service-provider-fastboard": "workspace:*", "@netless/flat-service-provider-file-convert-h5": "workspace:*", "@netless/flat-service-provider-file-convert-netless": "workspace:*", @@ -37,7 +38,6 @@ "@netless/flat-services": "workspace:*", "@netless/flat-stores": "workspace:*", "@zip.js/zip.js": "^2.6.29", - "agora-rtc-sdk-ng": "^4.16.0", "agora-rtm-sdk": "^1.6.0", "antd": "^4.23.2", "axios": "^1.6.2", diff --git a/web/flat-web/src/tasks/init-flat-services.ts b/web/flat-web/src/tasks/init-flat-services.ts index f11369d8ae3..2b8a43a1575 100644 --- a/web/flat-web/src/tasks/init-flat-services.ts +++ b/web/flat-web/src/tasks/init-flat-services.ts @@ -54,8 +54,8 @@ export function initFlatServices(): void { }); flatServices.register("textChat", async () => { - const { AgoraRTM } = await import("@netless/flat-service-provider-agora-rtm"); - return new AgoraRTM(config.agora.appId); + const { AgoraRTM2 } = await import("@netless/flat-service-provider-agora-rtm2"); + return new AgoraRTM2(config.agora.appId); }); flatServices.register("whiteboard", async () => {