diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6197b714..32f6dedc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -979,12 +979,14 @@ importers: '@types/styled-components': ^5.1.26 agora-rtc-react: ^1.1.3 ahooks: ^3.7.4 + lodash: ^4.17.21 react: 18.2.0 styled-components: ^5.3.6 zustand: ^4.1.5 dependencies: agora-rtc-react: 1.1.3_react@18.2.0 ahooks: 3.7.4_react@18.2.0 + lodash: 4.17.21 devDependencies: '@types/styled-components': 5.1.26 react: 18.2.0 @@ -13538,7 +13540,7 @@ packages: babel-plugin-syntax-jsx: 6.18.0 lodash: 4.17.21 picomatch: 2.3.1 - styled-components: 5.3.6_7i5myeigehqah43i5u7wbekgba + styled-components: 5.3.6_react@18.2.0 /babel-plugin-syntax-jsx/6.18.0: resolution: {integrity: sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==} @@ -32885,6 +32887,7 @@ packages: react-is: 18.2.0 shallowequal: 1.1.0 supports-color: 5.5.0 + dev: false /styled-components/5.3.6_mdz3marskokvq6744hhidi3r5a: resolution: {integrity: sha512-hGTZquGAaTqhGWldX7hhfzjnIYBZ0IXQXkCYdvF1Sq3DsUaLx6+NTHC5Jj1ooM2F68sBiVz3lvhfwQs/S3l6qg==} @@ -32930,7 +32933,6 @@ packages: react: 18.2.0 shallowequal: 1.1.0 supports-color: 5.5.0 - dev: true /styled-system/5.1.5: resolution: {integrity: sha512-7VoD0o2R3RKzOzPK0jYrVnS8iJdfkKsQJNiLRDjikOpQVqQHns/DXWaPZOH4tIKkhAT7I6wIsy9FWTWh2X3q+A==} diff --git a/server/locales/en-US/translation.json b/server/locales/en-US/translation.json index f06f4a01..81bdca17 100644 --- a/server/locales/en-US/translation.json +++ b/server/locales/en-US/translation.json @@ -24,6 +24,7 @@ "k82f5a5d4": "Password incorrect", "k89bf46fc": "Unable to recall messages from {{minutes}} minutes ago", "k986040de": "No group found", + "ka3eb52f8": "Call ended, duration: {{num}} minutes", "ka8b712f7": "Email already exists!", "kb143afe": "This data is not allowed to be modified", "kb32d3d62": "Anonymous", @@ -47,5 +48,6 @@ "ke82b4383": "You cannot send messages because you are banned", "ke99cd649": "No access to converse information permission", "ke9fabda8": "Token Invalid", + "kea5b4254": "This channel has opened a call", "kef3676e1": "The invitation code has expired" } diff --git a/server/locales/zh-CN/translation.json b/server/locales/zh-CN/translation.json index 81b3456e..9a9db0a7 100644 --- a/server/locales/zh-CN/translation.json +++ b/server/locales/zh-CN/translation.json @@ -24,6 +24,7 @@ "k82f5a5d4": "密码不正确", "k89bf46fc": "无法撤回 {{minutes}} 分钟前的消息", "k986040de": "没有找到群组", + "ka3eb52f8": "通话已结束, 时长: {{num}}分钟", "ka8b712f7": "邮箱已存在!", "kb143afe": "该数据不允许修改", "kb32d3d62": "匿名用户", @@ -47,5 +48,6 @@ "ke82b4383": "您因为被禁言无法发送消息", "ke99cd649": "没有获取会话信息权限", "ke9fabda8": "Token不合规", + "kea5b4254": "本频道开启了通话", "kef3676e1": "该邀请码已过期" } diff --git a/server/plugins/com.msgbyte.agora/models/agora-meeting.ts b/server/plugins/com.msgbyte.agora/models/agora-meeting.ts index 37827b44..7475b9a4 100644 --- a/server/plugins/com.msgbyte.agora/models/agora-meeting.ts +++ b/server/plugins/com.msgbyte.agora/models/agora-meeting.ts @@ -13,6 +13,12 @@ export class AgoraMeeting extends TimeStamps implements db.Base { @prop() converseId: string; + @prop() + groupId?: string; + + @prop() + messageId: string; + @prop() channelName: string; diff --git a/server/plugins/com.msgbyte.agora/services/agora.service.dev.ts b/server/plugins/com.msgbyte.agora/services/agora.service.dev.ts index c98d79ac..d08682ec 100644 --- a/server/plugins/com.msgbyte.agora/services/agora.service.dev.ts +++ b/server/plugins/com.msgbyte.agora/services/agora.service.dev.ts @@ -1,4 +1,9 @@ -import { DataNotFoundError, TcContext } from 'tailchat-server-sdk'; +import { + DataNotFoundError, + MessageStruct, + TcContext, + TcPureContext, +} from 'tailchat-server-sdk'; import { TcService, TcDbService, db } from 'tailchat-server-sdk'; import type { AgoraMeetingDocument, @@ -26,6 +31,8 @@ interface ChannelUserListRet { }; } +const noticeCacheKey = 'agora:notice:'; + /** * 声网音视频 * @@ -35,6 +42,8 @@ interface AgoraService extends TcService, TcDbService {} class AgoraService extends TcService { + botUserId: string | undefined; + get serviceName() { return 'plugin:com.msgbyte.agora'; } @@ -104,6 +113,25 @@ class AgoraService extends TcService { this.registerAuthWhitelist(['/webhook']); } + protected onInited(): void { + // 确保机器人用户存在, 并记录机器人用户id + this.waitForServices(['user']).then(async () => { + try { + const botUserId = await this.broker.call('user.ensurePluginBot', { + botId: 'agora-meeting', + nickname: 'Agora Bot', + avatar: '{BACKEND}/plugins/com.msgbyte.agora/assets/icon.png', + }); + + this.logger.info('Agora Meeting Bot Id:', botUserId); + + this.botUserId = String(botUserId); + } catch (e) { + this.logger.error(e); + } + }); + } + generateJoinInfo( ctx: TcContext<{ channelName: string; @@ -166,6 +194,7 @@ class AgoraService extends TcService { /** * agora服务的回调 + * NOTICE: 暂不支持密钥验证(因为拿不到header) * Reference: https://docs.agora.io/cn/live-streaming-premium-legacy/rtc_channel_event?platform=RESTful#101-channel-create */ async webhook( @@ -177,9 +206,16 @@ class AgoraService extends TcService { payload: any; }> ) { - const { eventType, payload } = ctx.params; + const { eventType, payload, noticeId } = ctx.params; + const { t } = ctx.meta; const channelName = payload.channelName; + const valid = await this.checkNoticeValid(noticeId); + if (!valid) { + this.logger.debug('agora notice has been handled'); + return true; + } + this.logger.info('webhook received', { eventType, payload }); if (channelName === 'test_webhook') { // 连通性检查 @@ -190,42 +226,74 @@ class AgoraService extends TcService { // 频道被创建 const ts = payload.ts; - const converseId = this.getConverseIdFromChannelName(channelName); + const [converseId, groupId] = this.getSourceFromChannelName(channelName); + + const existedMeeting = + await this.adapter.model.findLastestMeetingByConverseId(converseId); + if (existedMeeting) { + // 已经创建了,则跳过 + return; + } + + const message = await this.sendPluginBotMessage(ctx, { + converseId, + groupId, + content: t('本频道开启了通话'), + }); + const messageId = String(message._id); const meeting = await this.adapter.model.create({ channelName, converseId, + groupId, + messageId, active: true, - createdAt: new Date(ts), + createdAt: new Date(ts * 1000), }); + this.roomcastNotify(ctx, converseId, 'agoraChannelCreate', { converseId, + groupId, meetingId: String(meeting._id), }); } else if (eventType === 102) { // 频道被销毁 const ts = payload.ts; - const converseId = this.getConverseIdFromChannelName(channelName); + const [converseId, groupId] = this.getSourceFromChannelName(channelName); const meeting = await this.adapter.model.findLastestMeetingByConverseId( converseId ); if (!meeting) { + // 会议不存在,直接跳过 return; } meeting.active = false; - meeting.endAt = new Date(ts); + meeting.endAt = new Date(ts * 1000); await meeting.save(); this.roomcastNotify(ctx, converseId, 'agoraChannelDestroy', { converseId, + groupId, meetingId: String(meeting._id), }); + + const duration = + new Date(meeting.endAt).valueOf() - + new Date(meeting.createdAt).valueOf(); + + this.sendPluginBotMessage(ctx, { + converseId, + groupId, + content: t('通话已结束, 时长: {{num}}分钟', { + num: Math.round(duration / 1000 / 60), + }), + }); } else if (eventType === 103) { // 用户加入 const { channelName, uid: userId } = payload; - const converseId = this.getConverseIdFromChannelName(channelName); + const [converseId, groupId] = this.getSourceFromChannelName(channelName); const meeting = await this.adapter.model.findLastestMeetingByConverseId( converseId @@ -238,13 +306,14 @@ class AgoraService extends TcService { this.roomcastNotify(ctx, converseId, 'agoraBroadcasterJoin', { converseId, + groupId, meetingId: String(meeting._id), userId, }); } else if (eventType === 104) { // 用户离开 const { channelName, uid } = payload; - const converseId = this.getConverseIdFromChannelName(channelName); + const [converseId, groupId] = this.getSourceFromChannelName(channelName); const meeting = await this.adapter.model.findLastestMeetingByConverseId( converseId @@ -255,6 +324,7 @@ class AgoraService extends TcService { this.roomcastNotify(ctx, converseId, 'agoraBroadcasterLeave', { converseId, + groupId, meetingId: String(meeting._id), userId: uid, }); @@ -278,7 +348,9 @@ class AgoraService extends TcService { /** * NOTICE: 这里不用每次唯一的ChannelName是期望设计是会话维度的,即可以重复使用 */ - private getConverseIdFromChannelName(channelName: string): string { + private getSourceFromChannelName( + channelName: string + ): [string, string | undefined] { if (!channelName) { this.logger.error('channel name invalid', channelName); throw new Error('channel name invalid'); @@ -290,7 +362,57 @@ class AgoraService extends TcService { throw new Error('converseId invalid'); } - return converseId; + if (groupId === 'personal') { + return [converseId, undefined]; + } else { + if (!db.Types.ObjectId.isValid(groupId)) { + this.logger.error('groupId invalid:', groupId); + throw new Error('groupId invalid'); + } + + return [converseId, groupId]; + } + } + + private async checkNoticeValid(noticeId: string) { + const key = noticeCacheKey + noticeId; + const v = await this.broker.cacher.get(key); + if (v) { + return false; + } + + await this.broker.cacher.set(key, '1', 60 * 10); // 1分钟 + + return true; + } + + private async sendPluginBotMessage( + ctx: TcPureContext, + messagePayload: { + converseId: string; + groupId?: string; + content: string; + meta?: any; + } + ): Promise { + if (!this.botUserId) { + this.logger.warn('机器人尚未初始化,无法发送插件消息'); + return; + } + + const res = await ctx.call( + 'chat.message.sendMessage', + { + ...messagePayload, + }, + { + meta: { + userId: this.botUserId, + }, + } + ); + + return res as MessageStruct; } } diff --git a/server/plugins/com.msgbyte.agora/web/plugins/com.msgbyte.agora/package.json b/server/plugins/com.msgbyte.agora/web/plugins/com.msgbyte.agora/package.json index 21254933..0a55405d 100644 --- a/server/plugins/com.msgbyte.agora/web/plugins/com.msgbyte.agora/package.json +++ b/server/plugins/com.msgbyte.agora/web/plugins/com.msgbyte.agora/package.json @@ -9,7 +9,8 @@ }, "dependencies": { "agora-rtc-react": "^1.1.3", - "ahooks": "^3.7.4" + "ahooks": "^3.7.4", + "lodash": "^4.17.21" }, "devDependencies": { "@types/styled-components": "^5.1.26", diff --git a/server/plugins/com.msgbyte.agora/web/plugins/com.msgbyte.agora/src/FloatWindow/MeetingView.tsx b/server/plugins/com.msgbyte.agora/web/plugins/com.msgbyte.agora/src/FloatWindow/MeetingView.tsx index 0c99d396..3a97e49e 100644 --- a/server/plugins/com.msgbyte.agora/web/plugins/com.msgbyte.agora/src/FloatWindow/MeetingView.tsx +++ b/server/plugins/com.msgbyte.agora/web/plugins/com.msgbyte.agora/src/FloatWindow/MeetingView.tsx @@ -9,6 +9,8 @@ import { request } from '../request'; import styled from 'styled-components'; import { useMeetingStore } from './store'; import { NetworkStats } from './NetworkStats'; +import _once from 'lodash/once'; +import type { IAgoraRTCClient } from 'agora-rtc-react'; const Root = styled.div` .body { @@ -25,6 +27,10 @@ const Root = styled.div` } `; +const enableDualStream = _once((client: IAgoraRTCClient) => { + return client.enableDualStream(); +}); + export interface MeetingViewProps { meetingId: string; onClose: () => void; @@ -83,7 +89,7 @@ export const MeetingView: React.FC = React.memo((props) => { const { appId, token } = data ?? {}; await client.join(appId, channelName, token, _id); - await client.enableDualStream(); + await enableDualStream(client); client.enableAudioVolumeIndicator(); setStart(true); } catch (err) { diff --git a/server/plugins/com.msgbyte.agora/web/plugins/com.msgbyte.agora/src/FloatWindow/Videos.tsx b/server/plugins/com.msgbyte.agora/web/plugins/com.msgbyte.agora/src/FloatWindow/Videos.tsx index 27f17298..a649a62e 100644 --- a/server/plugins/com.msgbyte.agora/web/plugins/com.msgbyte.agora/src/FloatWindow/Videos.tsx +++ b/server/plugins/com.msgbyte.agora/web/plugins/com.msgbyte.agora/src/FloatWindow/Videos.tsx @@ -5,6 +5,7 @@ import { OwnVideoView, VideoView } from './VideoView'; const Root = styled.div` height: 70vh; + overflow: hidden; display: grid; grid-template-columns: repeat(auto-fit, minmax(440px, 1fr)); `; diff --git a/server/plugins/com.msgbyte.agora/web/plugins/com.msgbyte.agora/src/index.tsx b/server/plugins/com.msgbyte.agora/web/plugins/com.msgbyte.agora/src/index.tsx index de280f5b..b5972e3b 100644 --- a/server/plugins/com.msgbyte.agora/web/plugins/com.msgbyte.agora/src/index.tsx +++ b/server/plugins/com.msgbyte.agora/web/plugins/com.msgbyte.agora/src/index.tsx @@ -19,8 +19,7 @@ regPluginPanelAction({ title: '发起通话', content: '是否通过声网插件在当前会话开启音视频通讯?', onConfirm: async () => { - // startFastMeeting(`${groupId}|${panelId}`); - startFastMeeting('123456'); // for test + startFastMeeting(`${groupId}|${panelId}`); }, }); },