agora-appbuilder-core 4.1.20 → 4.1.21
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/package.json +1 -1
- package/template/bridge/rtm/web/__tests__/storage.test.ts +130 -0
- package/template/bridge/rtm/web/index.ts +44 -19
- package/template/customization-api/customize.tsx +6 -0
- package/template/customization-api/index.ts +11 -0
- package/template/customization-api/typeDefinition.ts +6 -0
- package/template/defaultConfig.js +2 -2
- package/template/src/assets/live-reactions/1f440.gif +0 -0
- package/template/src/assets/live-reactions/1f44d_1f3fb.gif +0 -0
- package/template/src/assets/live-reactions/1f44d_1f3fc.gif +0 -0
- package/template/src/assets/live-reactions/1f44d_1f3fd.gif +0 -0
- package/template/src/assets/live-reactions/1f44d_1f3fe.gif +0 -0
- package/template/src/assets/live-reactions/1f44d_1f3ff.gif +0 -0
- package/template/src/assets/live-reactions/1f44f_1f3fb.gif +0 -0
- package/template/src/assets/live-reactions/1f44f_1f3fc.gif +0 -0
- package/template/src/assets/live-reactions/1f44f_1f3fd.gif +0 -0
- package/template/src/assets/live-reactions/1f44f_1f3fe.gif +0 -0
- package/template/src/assets/live-reactions/1f44f_1f3ff.gif +0 -0
- package/template/src/assets/live-reactions/1f4a1.gif +0 -0
- package/template/src/assets/live-reactions/1f4af.gif +0 -0
- package/template/src/assets/live-reactions/1f525.gif +0 -0
- package/template/src/assets/live-reactions/1f60e.gif +0 -0
- package/template/src/assets/live-reactions/1f643.gif +0 -0
- package/template/src/assets/live-reactions/1f64c.gif +0 -0
- package/template/src/assets/live-reactions/1f64c_1f3fb.gif +0 -0
- package/template/src/assets/live-reactions/1f64c_1f3fc.gif +0 -0
- package/template/src/assets/live-reactions/1f64c_1f3fd.gif +0 -0
- package/template/src/assets/live-reactions/1f64c_1f3fe.gif +0 -0
- package/template/src/assets/live-reactions/1f64c_1f3ff.gif +0 -0
- package/template/src/assets/live-reactions/1f64f.gif +0 -0
- package/template/src/assets/live-reactions/1f64f_1f3fb.gif +0 -0
- package/template/src/assets/live-reactions/1f64f_1f3fc.gif +0 -0
- package/template/src/assets/live-reactions/1f64f_1f3fd.gif +0 -0
- package/template/src/assets/live-reactions/1f64f_1f3fe.gif +0 -0
- package/template/src/assets/live-reactions/1f64f_1f3ff.gif +0 -0
- package/template/src/assets/live-reactions/1f680.gif +0 -0
- package/template/src/assets/live-reactions/2705.gif +0 -0
- package/template/src/assets/live-reactions/animated/1f440.json +1 -0
- package/template/src/assets/live-reactions/animated/1f44d_1f3fb.json +1 -0
- package/template/src/assets/live-reactions/animated/1f44d_1f3fc.json +1 -0
- package/template/src/assets/live-reactions/animated/1f44d_1f3fd.json +1 -0
- package/template/src/assets/live-reactions/animated/1f44d_1f3fe.json +1 -0
- package/template/src/assets/live-reactions/animated/1f44d_1f3ff.json +1 -0
- package/template/src/assets/live-reactions/animated/1f44f_1f3fb.json +1 -0
- package/template/src/assets/live-reactions/animated/1f44f_1f3fc.json +1 -0
- package/template/src/assets/live-reactions/animated/1f44f_1f3fd.json +1 -0
- package/template/src/assets/live-reactions/animated/1f44f_1f3fe.json +1 -0
- package/template/src/assets/live-reactions/animated/1f44f_1f3ff.json +1 -0
- package/template/src/assets/live-reactions/animated/1f4a1.json +1 -0
- package/template/src/assets/live-reactions/animated/1f4af.json +1 -0
- package/template/src/assets/live-reactions/animated/1f525.json +1 -0
- package/template/src/assets/live-reactions/animated/1f60e.json +1 -0
- package/template/src/assets/live-reactions/animated/1f643.json +1 -0
- package/template/src/assets/live-reactions/animated/1f64c.json +1 -0
- package/template/src/assets/live-reactions/animated/1f64c_1f3fb.json +1 -0
- package/template/src/assets/live-reactions/animated/1f64c_1f3fc.json +1 -0
- package/template/src/assets/live-reactions/animated/1f64c_1f3fd.json +1 -0
- package/template/src/assets/live-reactions/animated/1f64c_1f3fe.json +1 -0
- package/template/src/assets/live-reactions/animated/1f64c_1f3ff.json +1 -0
- package/template/src/assets/live-reactions/animated/1f64f.json +1 -0
- package/template/src/assets/live-reactions/animated/1f64f_1f3fb.json +1 -0
- package/template/src/assets/live-reactions/animated/1f64f_1f3fc.json +1 -0
- package/template/src/assets/live-reactions/animated/1f64f_1f3fd.json +1 -0
- package/template/src/assets/live-reactions/animated/1f64f_1f3fe.json +1 -0
- package/template/src/assets/live-reactions/animated/1f64f_1f3ff.json +1 -0
- package/template/src/assets/live-reactions/animated/1f680.json +1 -0
- package/template/src/assets/live-reactions/animated/2705.json +1 -0
- package/template/src/assets/live-reactions/custom/ty.png +0 -0
- package/template/src/components/RTMConfigure.tsx +15 -10
- package/template/src/components/StorageContext.tsx +3 -0
- package/template/src/components/reactions/LiveReactionBadge.tsx +20 -2
- package/template/src/components/reactions/LiveReactionButton.tsx +200 -44
- package/template/src/components/reactions/LiveReactionStageOverlay.native.tsx +34 -10
- package/template/src/components/reactions/LiveReactionStageOverlay.tsx +30 -7
- package/template/src/components/reactions/catalog.ts +241 -0
- package/template/src/components/useVideoCall.tsx +20 -2
- package/template/src/rtm/__tests__/isRemoteDeparture.test.ts +19 -0
- package/template/src/rtm/isRemoteDeparture.ts +4 -0
- package/template/src/subComponents/caption/__tests__/sttRequestBody.test.ts +61 -0
- package/template/src/subComponents/caption/__tests__/sttSessionId.native.test.ts +76 -0
- package/template/src/subComponents/caption/__tests__/sttSessionId.web.test.ts +190 -0
- package/template/src/subComponents/caption/__tests__/useSTTAPI.test.tsx +129 -0
- package/template/src/subComponents/caption/sttRequestBody.ts +54 -0
- package/template/src/subComponents/caption/sttSessionCoordinator.ts +315 -0
- package/template/src/subComponents/caption/sttSessionId.ts +21 -0
- package/template/src/subComponents/caption/sttSessionId.web.ts +21 -0
- package/template/src/subComponents/caption/useSTTAPI.tsx +13 -33
- package/template/src/utils/__tests__/useEndCall.test.tsx +282 -0
- package/template/src/utils/useEndCall.ts +63 -22
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import {beforeEach, describe, expect, it, jest} from '@jest/globals';
|
|
2
|
+
|
|
3
|
+
jest.mock('../../../rtm/RTMEngine', () => ({
|
|
4
|
+
__esModule: true,
|
|
5
|
+
default: {
|
|
6
|
+
getInstance: jest.fn(),
|
|
7
|
+
},
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
jest.mock('../../../utils/getUniqueID', () => ({
|
|
11
|
+
__esModule: true,
|
|
12
|
+
default: jest.fn(() => 'default-candidate'),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
createSTTSessionCoordinator,
|
|
17
|
+
STT_SESSION_ID_KEY,
|
|
18
|
+
} from '../sttSessionId.web';
|
|
19
|
+
|
|
20
|
+
const channelMetadata = (value?: string, revision = 7) => ({
|
|
21
|
+
timestamp: 100,
|
|
22
|
+
channelName: 'room',
|
|
23
|
+
channelType: 1,
|
|
24
|
+
majorRevision: revision,
|
|
25
|
+
itemCount: value ? 1 : 0,
|
|
26
|
+
items: value
|
|
27
|
+
? [
|
|
28
|
+
{
|
|
29
|
+
key: STT_SESSION_ID_KEY,
|
|
30
|
+
value,
|
|
31
|
+
revision,
|
|
32
|
+
authorUserId: '1',
|
|
33
|
+
updateTs: 99,
|
|
34
|
+
},
|
|
35
|
+
]
|
|
36
|
+
: [],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const presence = (...userIds: string[]) => ({
|
|
40
|
+
timestamp: 100,
|
|
41
|
+
totalOccupancy: userIds.length,
|
|
42
|
+
occupants: userIds.map(userId => ({
|
|
43
|
+
userId,
|
|
44
|
+
statesCount: 0,
|
|
45
|
+
states: [],
|
|
46
|
+
})),
|
|
47
|
+
nextPage: '',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const createHarness = () => {
|
|
51
|
+
const storage = {
|
|
52
|
+
getChannelMetadata: jest.fn<any>(),
|
|
53
|
+
setChannelMetadata: jest.fn<any>(),
|
|
54
|
+
removeChannelMetadata: jest.fn<any>(),
|
|
55
|
+
};
|
|
56
|
+
const rtmPresence = {
|
|
57
|
+
getOnlineUsers: jest.fn<any>(),
|
|
58
|
+
};
|
|
59
|
+
const createId = jest.fn(() => 'candidate');
|
|
60
|
+
const wait = jest.fn(async () => {});
|
|
61
|
+
const coordinator = createSTTSessionCoordinator({
|
|
62
|
+
getClient: () => ({storage, presence: rtmPresence} as any),
|
|
63
|
+
createId,
|
|
64
|
+
wait,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return {coordinator, createId, rtmPresence, storage, wait};
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
describe('web STT session coordinator', () => {
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
jest.clearAllMocks();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('reuses existing channel metadata without writing', async () => {
|
|
76
|
+
const {coordinator, storage} = createHarness();
|
|
77
|
+
storage.getChannelMetadata.mockResolvedValue(
|
|
78
|
+
channelMetadata('existing-session'),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
await expect(coordinator.ensureSTTSessionId('room')).resolves.toBe(
|
|
82
|
+
'existing-session',
|
|
83
|
+
);
|
|
84
|
+
expect(storage.setChannelMetadata).not.toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('shares one revision-zero creation across concurrent local calls', async () => {
|
|
88
|
+
const {coordinator, storage} = createHarness();
|
|
89
|
+
storage.getChannelMetadata.mockResolvedValue(channelMetadata());
|
|
90
|
+
storage.setChannelMetadata.mockResolvedValue({
|
|
91
|
+
timestamp: 100,
|
|
92
|
+
channelName: 'room',
|
|
93
|
+
channelType: 1,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const [first, second] = await Promise.all([
|
|
97
|
+
coordinator.ensureSTTSessionId('room'),
|
|
98
|
+
coordinator.ensureSTTSessionId('room'),
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
expect(first).toBe('candidate');
|
|
102
|
+
expect(second).toBe('candidate');
|
|
103
|
+
expect(storage.setChannelMetadata).toHaveBeenCalledTimes(1);
|
|
104
|
+
expect(storage.setChannelMetadata).toHaveBeenCalledWith(
|
|
105
|
+
'room',
|
|
106
|
+
1,
|
|
107
|
+
{
|
|
108
|
+
items: [
|
|
109
|
+
{
|
|
110
|
+
key: STT_SESSION_ID_KEY,
|
|
111
|
+
value: 'candidate',
|
|
112
|
+
revision: 0,
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
{addUserId: true, addTimeStamp: true},
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('re-reads and uses the winning value after a create conflict', async () => {
|
|
121
|
+
const {coordinator, storage} = createHarness();
|
|
122
|
+
storage.getChannelMetadata
|
|
123
|
+
.mockResolvedValueOnce(channelMetadata())
|
|
124
|
+
.mockResolvedValueOnce(channelMetadata('winner-session'));
|
|
125
|
+
storage.setChannelMetadata.mockRejectedValue(
|
|
126
|
+
new Error('revision conflict'),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
await expect(coordinator.ensureSTTSessionId('room')).resolves.toBe(
|
|
130
|
+
'winner-session',
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('fails after three reads instead of returning a local fallback', async () => {
|
|
135
|
+
const {coordinator, storage, wait} = createHarness();
|
|
136
|
+
storage.getChannelMetadata.mockRejectedValue(new Error('RTM unavailable'));
|
|
137
|
+
|
|
138
|
+
await expect(coordinator.ensureSTTSessionId('room')).rejects.toThrow(
|
|
139
|
+
'Unable to resolve the shared STT session ID',
|
|
140
|
+
);
|
|
141
|
+
expect(storage.getChannelMetadata).toHaveBeenCalledTimes(3);
|
|
142
|
+
expect(storage.setChannelMetadata).not.toHaveBeenCalled();
|
|
143
|
+
expect(wait).toHaveBeenNthCalledWith(1, 100);
|
|
144
|
+
expect(wait).toHaveBeenNthCalledWith(2, 250);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('keeps metadata while another RTM participant is present', async () => {
|
|
148
|
+
const {coordinator, rtmPresence, storage} = createHarness();
|
|
149
|
+
rtmPresence.getOnlineUsers.mockResolvedValue(presence('1', '2'));
|
|
150
|
+
|
|
151
|
+
await expect(
|
|
152
|
+
coordinator.clearSTTSessionIdIfLast('room', '1'),
|
|
153
|
+
).resolves.toBe(false);
|
|
154
|
+
expect(storage.getChannelMetadata).not.toHaveBeenCalled();
|
|
155
|
+
expect(storage.removeChannelMetadata).not.toHaveBeenCalled();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('stops STT before final revision-aware metadata removal', async () => {
|
|
159
|
+
const {coordinator, rtmPresence, storage} = createHarness();
|
|
160
|
+
const calls: string[] = [];
|
|
161
|
+
rtmPresence.getOnlineUsers.mockResolvedValue(presence('1'));
|
|
162
|
+
storage.getChannelMetadata.mockResolvedValue(
|
|
163
|
+
channelMetadata('session-a', 9),
|
|
164
|
+
);
|
|
165
|
+
storage.removeChannelMetadata.mockImplementation(async () => {
|
|
166
|
+
calls.push('remove');
|
|
167
|
+
return {timestamp: 100, channelName: 'room', channelType: 1};
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await coordinator.cleanupSTTSessionOnEnd('room', '1', true, async () => {
|
|
171
|
+
calls.push('stop');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(calls).toEqual(['stop', 'remove']);
|
|
175
|
+
expect(rtmPresence.getOnlineUsers).toHaveBeenCalledTimes(3);
|
|
176
|
+
expect(storage.removeChannelMetadata).toHaveBeenCalledWith('room', 1, {
|
|
177
|
+
data: {
|
|
178
|
+
items: [
|
|
179
|
+
{
|
|
180
|
+
key: STT_SESSION_ID_KEY,
|
|
181
|
+
value: '',
|
|
182
|
+
revision: 9,
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
},
|
|
186
|
+
addUserId: true,
|
|
187
|
+
addTimeStamp: true,
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import TestRenderer, {act} from 'react-test-renderer';
|
|
3
|
+
import {beforeEach, describe, expect, it, jest} from '@jest/globals';
|
|
4
|
+
|
|
5
|
+
jest.mock('../sttSessionId', () => ({
|
|
6
|
+
ensureSTTSessionId: jest.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
jest.mock('../../../components/StorageContext', () => {
|
|
10
|
+
const ReactModule = require('react');
|
|
11
|
+
return {
|
|
12
|
+
__esModule: true,
|
|
13
|
+
default: ReactModule.createContext({store: {token: 'token'}}),
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
jest.mock('../../../components/room-info/useRoomInfo', () => ({
|
|
18
|
+
useRoomInfo: () => ({data: {roomId: {host: 'phrase'}}}),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
jest.mock('../../../../agora-rn-uikit', () => {
|
|
22
|
+
const ReactModule = require('react');
|
|
23
|
+
return {
|
|
24
|
+
PropsContext: ReactModule.createContext({
|
|
25
|
+
rtcProps: {channel: 'room', encryption: {mode: 8}},
|
|
26
|
+
}),
|
|
27
|
+
useLocalUid: () => 123,
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
jest.mock('../../../logger/AppBuilderLogger', () => ({
|
|
32
|
+
LogSource: {NetworkRest: 'NetworkRest'},
|
|
33
|
+
logger: {
|
|
34
|
+
getSessionId: () => 'logging-session',
|
|
35
|
+
log: jest.fn(),
|
|
36
|
+
error: jest.fn(),
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
jest.mock('../../../utils/getUniqueID', () => ({
|
|
41
|
+
__esModule: true,
|
|
42
|
+
default: () => 'request-id',
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
import useSTTAPI from '../useSTTAPI';
|
|
46
|
+
import {ensureSTTSessionId} from '../sttSessionId';
|
|
47
|
+
|
|
48
|
+
const mockEnsureSTTSessionId = ensureSTTSessionId as jest.Mock<any>;
|
|
49
|
+
|
|
50
|
+
const translationConfig = {
|
|
51
|
+
source: ['en-US'],
|
|
52
|
+
targets: ['de-DE'],
|
|
53
|
+
} as any;
|
|
54
|
+
|
|
55
|
+
const renderHook = async () => {
|
|
56
|
+
let api!: ReturnType<typeof useSTTAPI>;
|
|
57
|
+
let renderer!: TestRenderer.ReactTestRenderer;
|
|
58
|
+
|
|
59
|
+
await act(async () => {
|
|
60
|
+
renderer = TestRenderer.create(
|
|
61
|
+
<React.Fragment>
|
|
62
|
+
{React.createElement(() => {
|
|
63
|
+
api = useSTTAPI();
|
|
64
|
+
return null;
|
|
65
|
+
})}
|
|
66
|
+
</React.Fragment>,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return {api, renderer};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
describe('useSTTAPI session coordination', () => {
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
jest.clearAllMocks();
|
|
76
|
+
global.fetch = jest.fn<any>(async () => ({
|
|
77
|
+
json: async () => ({}),
|
|
78
|
+
}));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('does not call STT when the shared session ID cannot be resolved', async () => {
|
|
82
|
+
mockEnsureSTTSessionId.mockRejectedValue(new Error('RTM unavailable'));
|
|
83
|
+
const {api, renderer} = await renderHook();
|
|
84
|
+
|
|
85
|
+
let response;
|
|
86
|
+
await act(async () => {
|
|
87
|
+
response = await api.start(900000123, translationConfig);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(response).toMatchObject({
|
|
91
|
+
success: false,
|
|
92
|
+
error: {message: 'RTM unavailable'},
|
|
93
|
+
});
|
|
94
|
+
expect(mockEnsureSTTSessionId).toHaveBeenCalledWith('room');
|
|
95
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
96
|
+
renderer.unmount();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('adds the shared ID to start, update, and stop while retaining the logging header', async () => {
|
|
100
|
+
mockEnsureSTTSessionId.mockResolvedValue('shared-session');
|
|
101
|
+
const {api, renderer} = await renderHook();
|
|
102
|
+
|
|
103
|
+
await act(async () => {
|
|
104
|
+
await api.start(900000123, translationConfig);
|
|
105
|
+
await api.update(900000123, translationConfig);
|
|
106
|
+
await api.stop(900000123);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(global.fetch).toHaveBeenCalledTimes(3);
|
|
110
|
+
expect(mockEnsureSTTSessionId).toHaveBeenCalledTimes(3);
|
|
111
|
+
const fetchCalls = (global.fetch as jest.Mock<any>).mock.calls as [
|
|
112
|
+
string,
|
|
113
|
+
{body: string; headers: Record<string, string>},
|
|
114
|
+
][];
|
|
115
|
+
for (const [, options] of fetchCalls) {
|
|
116
|
+
expect(JSON.parse(options.body)).toMatchObject({
|
|
117
|
+
passphrase: 'phrase',
|
|
118
|
+
session_id: 'shared-session',
|
|
119
|
+
});
|
|
120
|
+
expect(options.headers['X-Session-Id']).toBe('logging-session');
|
|
121
|
+
}
|
|
122
|
+
expect(fetchCalls.map(([url]) => url)).toEqual([
|
|
123
|
+
expect.stringMatching(/\/startv7$/),
|
|
124
|
+
expect.stringMatching(/\/update$/),
|
|
125
|
+
expect.stringMatching(/\/stopv7$/),
|
|
126
|
+
]);
|
|
127
|
+
renderer.unmount();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {type LanguageTranslationConfig} from './useCaption';
|
|
2
|
+
|
|
3
|
+
export type STTMethod = 'startv7' | 'update' | 'stopv7';
|
|
4
|
+
|
|
5
|
+
interface BuildSTTRequestBodyOptions {
|
|
6
|
+
method: STTMethod;
|
|
7
|
+
botUid: number;
|
|
8
|
+
passphrase: string;
|
|
9
|
+
encryptionMode: number | null;
|
|
10
|
+
localUid: string | number;
|
|
11
|
+
channelName: string;
|
|
12
|
+
translationConfig?: LanguageTranslationConfig;
|
|
13
|
+
resolveSessionId: (channelName: string) => Promise<string | undefined>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const buildSTTRequestBody = async (
|
|
17
|
+
options: BuildSTTRequestBodyOptions,
|
|
18
|
+
): Promise<Record<string, any>> => {
|
|
19
|
+
const requestBody: Record<string, any> = {
|
|
20
|
+
passphrase: options.passphrase,
|
|
21
|
+
dataStream_uid: options.botUid,
|
|
22
|
+
encryption_mode: options.encryptionMode,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const sessionId = await options.resolveSessionId(options.channelName);
|
|
26
|
+
if (!sessionId) {
|
|
27
|
+
throw new Error('Unable to resolve the shared STT session ID');
|
|
28
|
+
}
|
|
29
|
+
requestBody.session_id = sessionId;
|
|
30
|
+
|
|
31
|
+
if (options.translationConfig?.source?.[0]) {
|
|
32
|
+
requestBody.lang = options.translationConfig.source;
|
|
33
|
+
const sanitizedTargets =
|
|
34
|
+
options.translationConfig.targets?.filter(
|
|
35
|
+
target => target !== options.translationConfig?.source[0],
|
|
36
|
+
) || [];
|
|
37
|
+
if (sanitizedTargets.length > 0) {
|
|
38
|
+
requestBody.translate_config = [
|
|
39
|
+
{
|
|
40
|
+
source_lang: options.translationConfig.source[0],
|
|
41
|
+
target_lang: sanitizedTargets,
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
if (options.method === 'update') {
|
|
45
|
+
requestBody.translate = true;
|
|
46
|
+
}
|
|
47
|
+
} else if (options.method === 'update') {
|
|
48
|
+
requestBody.translate = false;
|
|
49
|
+
}
|
|
50
|
+
requestBody.subscribeAudioUids = [`${options.localUid}`];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return requestBody;
|
|
54
|
+
};
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import {type RTMClient} from 'agora-react-native-rtm';
|
|
2
|
+
|
|
3
|
+
export const STT_SESSION_ID_KEY = 'STT_SESSION_ID';
|
|
4
|
+
|
|
5
|
+
interface CoordinatorDependencies {
|
|
6
|
+
getClient: () => Pick<RTMClient, 'storage' | 'presence'>;
|
|
7
|
+
createId: () => string;
|
|
8
|
+
wait: (milliseconds: number) => Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const createSTTSessionCoordinator = (
|
|
12
|
+
dependencies: CoordinatorDependencies,
|
|
13
|
+
) => {
|
|
14
|
+
const sessionIds = new Map<string, string>();
|
|
15
|
+
const pendingResolutions = new Map<string, Promise<string>>();
|
|
16
|
+
|
|
17
|
+
const getAndLogChannelMetadata = async (
|
|
18
|
+
channelName: string,
|
|
19
|
+
context: string,
|
|
20
|
+
) => {
|
|
21
|
+
const response = await dependencies
|
|
22
|
+
.getClient()
|
|
23
|
+
.storage.getChannelMetadata(channelName, 1);
|
|
24
|
+
|
|
25
|
+
console.log('[STT_SESSION_ID] Full RTM channel metadata', {
|
|
26
|
+
channelName,
|
|
27
|
+
context,
|
|
28
|
+
metadata: response,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return response;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const logChannelMetadataBestEffort = async (
|
|
35
|
+
channelName: string,
|
|
36
|
+
context: string,
|
|
37
|
+
): Promise<void> => {
|
|
38
|
+
try {
|
|
39
|
+
await getAndLogChannelMetadata(channelName, context);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.log('[STT_SESSION_ID] Unable to read RTM channel metadata', {
|
|
42
|
+
channelName,
|
|
43
|
+
context,
|
|
44
|
+
error,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const readSessionItem = async (channelName: string, context: string) => {
|
|
50
|
+
const response = await getAndLogChannelMetadata(channelName, context);
|
|
51
|
+
return response.items?.find(item => item.key === STT_SESSION_ID_KEY);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const isOnlyLocalRTMParticipant = async (
|
|
55
|
+
channelName: string,
|
|
56
|
+
localUid: string,
|
|
57
|
+
): Promise<boolean> => {
|
|
58
|
+
const response = await dependencies
|
|
59
|
+
.getClient()
|
|
60
|
+
.presence.getOnlineUsers(channelName, 1);
|
|
61
|
+
const occupantUserIds =
|
|
62
|
+
response.occupants?.map(occupant => String(occupant.userId)) ?? [];
|
|
63
|
+
const isOnlyLocalParticipant =
|
|
64
|
+
response.totalOccupancy === 1 &&
|
|
65
|
+
occupantUserIds.length === 1 &&
|
|
66
|
+
occupantUserIds[0] === String(localUid);
|
|
67
|
+
|
|
68
|
+
console.log('[STT_SESSION_ID] RTM participant check', {
|
|
69
|
+
channelName,
|
|
70
|
+
localUid: String(localUid),
|
|
71
|
+
totalOccupancy: response.totalOccupancy,
|
|
72
|
+
occupantUserIds,
|
|
73
|
+
isOnlyLocalParticipant,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return isOnlyLocalParticipant;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const resolveSessionId = async (channelName: string): Promise<string> => {
|
|
80
|
+
const delays = [0, 100, 250];
|
|
81
|
+
let lastError: unknown;
|
|
82
|
+
|
|
83
|
+
console.log('[STT_SESSION_ID] Resolving session ID from RTM metadata', {
|
|
84
|
+
channelName,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
for (let attempt = 0; attempt < delays.length; attempt += 1) {
|
|
88
|
+
if (delays[attempt] > 0) {
|
|
89
|
+
await dependencies.wait(delays[attempt]);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const existing = await readSessionItem(
|
|
94
|
+
channelName,
|
|
95
|
+
`session lookup attempt ${attempt + 1}`,
|
|
96
|
+
);
|
|
97
|
+
if (existing?.value) {
|
|
98
|
+
console.log(
|
|
99
|
+
'[STT_SESSION_ID] Reusing session ID from RTM channel metadata',
|
|
100
|
+
{
|
|
101
|
+
channelName,
|
|
102
|
+
sessionId: existing.value,
|
|
103
|
+
revision: existing.revision,
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
sessionIds.set(channelName, existing.value);
|
|
107
|
+
return existing.value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const candidate = dependencies.createId();
|
|
111
|
+
console.log(
|
|
112
|
+
'[STT_SESSION_ID] No existing session ID found; creating one',
|
|
113
|
+
{channelName, sessionId: candidate, attempt: attempt + 1},
|
|
114
|
+
);
|
|
115
|
+
try {
|
|
116
|
+
await dependencies.getClient().storage.setChannelMetadata(
|
|
117
|
+
channelName,
|
|
118
|
+
1,
|
|
119
|
+
{
|
|
120
|
+
items: [
|
|
121
|
+
{
|
|
122
|
+
key: STT_SESSION_ID_KEY,
|
|
123
|
+
value: candidate,
|
|
124
|
+
revision: 0,
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
{addUserId: true, addTimeStamp: true},
|
|
129
|
+
);
|
|
130
|
+
console.log(
|
|
131
|
+
'[STT_SESSION_ID] Created session ID in RTM channel metadata',
|
|
132
|
+
{channelName, sessionId: candidate},
|
|
133
|
+
);
|
|
134
|
+
await logChannelMetadataBestEffort(
|
|
135
|
+
channelName,
|
|
136
|
+
'after session ID creation',
|
|
137
|
+
);
|
|
138
|
+
sessionIds.set(channelName, candidate);
|
|
139
|
+
return candidate;
|
|
140
|
+
} catch (error) {
|
|
141
|
+
lastError = error;
|
|
142
|
+
console.log(
|
|
143
|
+
'[STT_SESSION_ID] Session ID creation did not win; reading shared value',
|
|
144
|
+
{channelName, sessionId: candidate, error},
|
|
145
|
+
);
|
|
146
|
+
const winner = await readSessionItem(
|
|
147
|
+
channelName,
|
|
148
|
+
`after creation conflict on attempt ${attempt + 1}`,
|
|
149
|
+
);
|
|
150
|
+
if (winner?.value) {
|
|
151
|
+
console.log(
|
|
152
|
+
'[STT_SESSION_ID] Reusing session ID created by another participant',
|
|
153
|
+
{
|
|
154
|
+
channelName,
|
|
155
|
+
sessionId: winner.value,
|
|
156
|
+
revision: winner.revision,
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
sessionIds.set(channelName, winner.value);
|
|
160
|
+
return winner.value;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} catch (error) {
|
|
164
|
+
lastError = error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
throw new Error(
|
|
169
|
+
`Unable to resolve the shared STT session ID: ${String(lastError)}`,
|
|
170
|
+
);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const ensureSTTSessionId = async (channelName: string): Promise<string> => {
|
|
174
|
+
const cached = sessionIds.get(channelName);
|
|
175
|
+
if (cached) {
|
|
176
|
+
console.log('[STT_SESSION_ID] Using locally cached session ID', {
|
|
177
|
+
channelName,
|
|
178
|
+
sessionId: cached,
|
|
179
|
+
});
|
|
180
|
+
return cached;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const pending = pendingResolutions.get(channelName);
|
|
184
|
+
if (pending) {
|
|
185
|
+
console.log('[STT_SESSION_ID] Waiting for pending session ID lookup', {
|
|
186
|
+
channelName,
|
|
187
|
+
});
|
|
188
|
+
return pending;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const resolution = resolveSessionId(channelName);
|
|
192
|
+
pendingResolutions.set(channelName, resolution);
|
|
193
|
+
try {
|
|
194
|
+
return await resolution;
|
|
195
|
+
} finally {
|
|
196
|
+
if (pendingResolutions.get(channelName) === resolution) {
|
|
197
|
+
pendingResolutions.delete(channelName);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const clearSTTSessionIdIfLast = async (
|
|
203
|
+
channelName: string,
|
|
204
|
+
localUid: string,
|
|
205
|
+
): Promise<boolean> => {
|
|
206
|
+
console.log(
|
|
207
|
+
'[STT_SESSION_ID] Rechecking participants before reading metadata',
|
|
208
|
+
{channelName, localUid: String(localUid)},
|
|
209
|
+
);
|
|
210
|
+
if (!(await isOnlyLocalRTMParticipant(channelName, localUid))) {
|
|
211
|
+
console.log(
|
|
212
|
+
'[STT_SESSION_ID] Metadata cleanup skipped because another participant is present',
|
|
213
|
+
{channelName, localUid: String(localUid)},
|
|
214
|
+
);
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const item = await readSessionItem(channelName, 'before metadata removal');
|
|
219
|
+
if (!item || !item.revision || item.revision <= 0) {
|
|
220
|
+
console.log(
|
|
221
|
+
'[STT_SESSION_ID] Metadata cleanup skipped because no removable session ID was found',
|
|
222
|
+
{channelName},
|
|
223
|
+
);
|
|
224
|
+
sessionIds.delete(channelName);
|
|
225
|
+
pendingResolutions.delete(channelName);
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
console.log(
|
|
230
|
+
'[STT_SESSION_ID] Final participant check before removing metadata',
|
|
231
|
+
{channelName, localUid: String(localUid)},
|
|
232
|
+
);
|
|
233
|
+
if (!(await isOnlyLocalRTMParticipant(channelName, localUid))) {
|
|
234
|
+
console.log(
|
|
235
|
+
'[STT_SESSION_ID] Metadata removal cancelled because a participant joined during cleanup',
|
|
236
|
+
{channelName, localUid: String(localUid)},
|
|
237
|
+
);
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
await dependencies
|
|
242
|
+
.getClient()
|
|
243
|
+
.storage.removeChannelMetadata(channelName, 1, {
|
|
244
|
+
data: {
|
|
245
|
+
items: [
|
|
246
|
+
{
|
|
247
|
+
key: STT_SESSION_ID_KEY,
|
|
248
|
+
value: '',
|
|
249
|
+
revision: item.revision,
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
},
|
|
253
|
+
addUserId: true,
|
|
254
|
+
addTimeStamp: true,
|
|
255
|
+
});
|
|
256
|
+
console.log('[STT_SESSION_ID] Removed RTM channel metadata', {
|
|
257
|
+
channelName,
|
|
258
|
+
key: STT_SESSION_ID_KEY,
|
|
259
|
+
revision: item.revision,
|
|
260
|
+
});
|
|
261
|
+
sessionIds.delete(channelName);
|
|
262
|
+
pendingResolutions.delete(channelName);
|
|
263
|
+
await logChannelMetadataBestEffort(channelName, 'after session ID removal');
|
|
264
|
+
return true;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const cleanupSTTSessionOnEnd = async (
|
|
268
|
+
channelName: string,
|
|
269
|
+
localUid: string,
|
|
270
|
+
isSTTActive: boolean,
|
|
271
|
+
stopSTT: () => Promise<void>,
|
|
272
|
+
): Promise<void> => {
|
|
273
|
+
console.log(
|
|
274
|
+
'[STT_SESSION_ID] Checking participants before starting end-call cleanup',
|
|
275
|
+
{channelName, localUid: String(localUid), isSTTActive},
|
|
276
|
+
);
|
|
277
|
+
if (!(await isOnlyLocalRTMParticipant(channelName, localUid))) {
|
|
278
|
+
console.log(
|
|
279
|
+
'[STT_SESSION_ID] End-call cleanup skipped because another participant is present',
|
|
280
|
+
{channelName, localUid: String(localUid)},
|
|
281
|
+
);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (isSTTActive) {
|
|
285
|
+
console.log(
|
|
286
|
+
'[STT_SESSION_ID] Local user is last; stopping STT before metadata cleanup',
|
|
287
|
+
{channelName},
|
|
288
|
+
);
|
|
289
|
+
await stopSTT();
|
|
290
|
+
}
|
|
291
|
+
console.log(
|
|
292
|
+
'[STT_SESSION_ID] STT stop completed; revalidating before metadata cleanup',
|
|
293
|
+
{channelName},
|
|
294
|
+
);
|
|
295
|
+
await clearSTTSessionIdIfLast(channelName, localUid);
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const resetSTTSessionIdCache = (channelName?: string): void => {
|
|
299
|
+
if (channelName) {
|
|
300
|
+
sessionIds.delete(channelName);
|
|
301
|
+
pendingResolutions.delete(channelName);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
sessionIds.clear();
|
|
305
|
+
pendingResolutions.clear();
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
ensureSTTSessionId,
|
|
310
|
+
isOnlyLocalRTMParticipant,
|
|
311
|
+
clearSTTSessionIdIfLast,
|
|
312
|
+
cleanupSTTSessionOnEnd,
|
|
313
|
+
resetSTTSessionIdCache,
|
|
314
|
+
};
|
|
315
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import RTMEngine from '../../rtm/RTMEngine';
|
|
2
|
+
import getUniqueID from '../../utils/getUniqueID';
|
|
3
|
+
import {createSTTSessionCoordinator} from './sttSessionCoordinator';
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
createSTTSessionCoordinator,
|
|
7
|
+
STT_SESSION_ID_KEY,
|
|
8
|
+
} from './sttSessionCoordinator';
|
|
9
|
+
|
|
10
|
+
const coordinator = createSTTSessionCoordinator({
|
|
11
|
+
getClient: () => RTMEngine.getInstance().engine,
|
|
12
|
+
createId: getUniqueID,
|
|
13
|
+
wait: milliseconds =>
|
|
14
|
+
new Promise(resolve => setTimeout(resolve, milliseconds)),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const ensureSTTSessionId = coordinator.ensureSTTSessionId;
|
|
18
|
+
export const isOnlyLocalRTMParticipant = coordinator.isOnlyLocalRTMParticipant;
|
|
19
|
+
export const clearSTTSessionIdIfLast = coordinator.clearSTTSessionIdIfLast;
|
|
20
|
+
export const cleanupSTTSessionOnEnd = coordinator.cleanupSTTSessionOnEnd;
|
|
21
|
+
export const resetSTTSessionIdCache = coordinator.resetSTTSessionIdCache;
|