@app-connect/core 1.6.4 → 1.7.0-beta.1
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/handlers/admin.js +267 -0
- package/handlers/auth.js +29 -3
- package/handlers/calldown.js +60 -0
- package/handlers/log.js +28 -6
- package/index.js +310 -3
- package/lib/callLogComposer.js +110 -15
- package/lib/ringcentral.js +275 -0
- package/lib/util.js +26 -1
- package/models/adminConfigModel.js +18 -1
- package/models/callDownListModel.js +35 -0
- package/models/userModel.js +4 -0
- package/package.json +3 -3
- package/releaseNotes.json +44 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
|
|
2
|
+
const fetch = require('node-fetch');
|
|
3
|
+
|
|
4
|
+
const DEFAULT_RENEW_HANDICAP_MS = 60 * 1000; // 1 minute
|
|
5
|
+
|
|
6
|
+
function stringifyQuery(query) {
|
|
7
|
+
const queryParams = new URLSearchParams(query);
|
|
8
|
+
return queryParams.toString();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const REFRESH_RENEW_HANDICAP_MS = 10 * 1000; // 10s
|
|
12
|
+
function isRefreshTokenValid(token, handicap = REFRESH_RENEW_HANDICAP_MS) {
|
|
13
|
+
const expireTime = token.refresh_token_expire_time;
|
|
14
|
+
return expireTime - handicap > Date.now();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isAccessTokenValid(token, handicap = DEFAULT_RENEW_HANDICAP_MS) {
|
|
18
|
+
const expireTime = token.expire_time;
|
|
19
|
+
return expireTime - handicap > Date.now();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class RingCentral {
|
|
23
|
+
constructor(options) {
|
|
24
|
+
this._options = options;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
loginUrl({
|
|
28
|
+
state,
|
|
29
|
+
}) {
|
|
30
|
+
const query = {
|
|
31
|
+
response_type: 'code',
|
|
32
|
+
redirect_uri: this._options.redirectUri,
|
|
33
|
+
client_id: this._options.clientId,
|
|
34
|
+
response_hint: 'brand_id contracted_country_code',
|
|
35
|
+
};
|
|
36
|
+
if (state) {
|
|
37
|
+
query.state = state;
|
|
38
|
+
}
|
|
39
|
+
return `${this._options.server}/restapi/oauth/authorize?${stringifyQuery(query)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async generateToken({ code }) {
|
|
43
|
+
const body = {
|
|
44
|
+
code,
|
|
45
|
+
grant_type: 'authorization_code',
|
|
46
|
+
redirect_uri: this._options.redirectUri,
|
|
47
|
+
};
|
|
48
|
+
const response = await this._tokenRequest('/restapi/oauth/token', body);
|
|
49
|
+
if (Number.parseInt(response.status, 10) >= 400) {
|
|
50
|
+
throw new Error('Generate Token error', response.status);
|
|
51
|
+
}
|
|
52
|
+
const {
|
|
53
|
+
expires_in,
|
|
54
|
+
refresh_token_expires_in,
|
|
55
|
+
scope,
|
|
56
|
+
endpoint_id, // do no save this field into db to reduce db size
|
|
57
|
+
...token
|
|
58
|
+
} = await response.json();
|
|
59
|
+
return {
|
|
60
|
+
...token,
|
|
61
|
+
expire_time: Date.now() + parseInt(expires_in, 10) * 1000,
|
|
62
|
+
refresh_token_expire_time: Date.now() + parseInt(refresh_token_expires_in, 10) * 1000,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async refreshToken(token) {
|
|
67
|
+
const body = {
|
|
68
|
+
grant_type: 'refresh_token',
|
|
69
|
+
refresh_token: token.refresh_token,
|
|
70
|
+
access_token_ttl: token.expires_in,
|
|
71
|
+
refresh_token_ttl: token.refresh_token_expires_in,
|
|
72
|
+
};
|
|
73
|
+
const response = await this._tokenRequest('/restapi/oauth/token', body);
|
|
74
|
+
if (Number.parseInt(response.status, 10) >= 400) {
|
|
75
|
+
const error = new Error('Refresh Token error', response.status);
|
|
76
|
+
error.response = response;
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
const {
|
|
80
|
+
expires_in,
|
|
81
|
+
refresh_token_expires_in,
|
|
82
|
+
scope,
|
|
83
|
+
endpoint_id, // do no save this field into db to reduce db size
|
|
84
|
+
...newToken
|
|
85
|
+
} = await response.json();
|
|
86
|
+
return {
|
|
87
|
+
...newToken,
|
|
88
|
+
expire_time: Date.now() + parseInt(expires_in, 10) * 1000,
|
|
89
|
+
refresh_token_expire_time: Date.now() + parseInt(refresh_token_expires_in, 10) * 1000,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async revokeToken(token) {
|
|
94
|
+
const body = {
|
|
95
|
+
token: token.access_token,
|
|
96
|
+
};
|
|
97
|
+
const response = await this._tokenRequest('/restapi/oauth/revoke', body);
|
|
98
|
+
if (Number.parseInt(response.status, 10) >= 400) {
|
|
99
|
+
throw new Error('Revoke Token error', response.status);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async _tokenRequest(path, body) {
|
|
104
|
+
const authorization = `${this._options.clientId}:${this._options.clientSecret}`;
|
|
105
|
+
const response = await fetch(
|
|
106
|
+
`${this._options.server}${path}`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
body: stringifyQuery(body),
|
|
109
|
+
headers: {
|
|
110
|
+
'Accept': 'application/json',
|
|
111
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
112
|
+
'Authorization': `Basic ${Buffer.from(authorization).toString('base64')}`
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
);
|
|
116
|
+
return response;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async request({
|
|
120
|
+
server = this._options.server,
|
|
121
|
+
path,
|
|
122
|
+
query,
|
|
123
|
+
body,
|
|
124
|
+
method,
|
|
125
|
+
accept = 'application/json',
|
|
126
|
+
}, token) {
|
|
127
|
+
let uri = `${server}${path}`;
|
|
128
|
+
if (query) {
|
|
129
|
+
uri = uri + (uri.includes('?') ? '&' : '?') + stringifyQuery(query);
|
|
130
|
+
}
|
|
131
|
+
const response = await fetch(uri, {
|
|
132
|
+
method,
|
|
133
|
+
body: body ? JSON.stringify(body) : body,
|
|
134
|
+
headers: {
|
|
135
|
+
'Accept': accept,
|
|
136
|
+
'Content-Type': 'application/json',
|
|
137
|
+
'Authorization': `${token.token_type} ${token.access_token}`,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
if (Number.parseInt(response.status, 10) >= 400) {
|
|
141
|
+
const error = new Error(`request data error ${response.status}`);
|
|
142
|
+
const errorText = await response.text();
|
|
143
|
+
error.message = errorText;
|
|
144
|
+
error.response = response;
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
return response;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async createSubscription({
|
|
151
|
+
eventFilters,
|
|
152
|
+
webhookUri,
|
|
153
|
+
}, token) {
|
|
154
|
+
const response = await this.request({
|
|
155
|
+
method: 'POST',
|
|
156
|
+
path: '/restapi/v1.0/subscription',
|
|
157
|
+
body: {
|
|
158
|
+
eventFilters,
|
|
159
|
+
deliveryMode: {
|
|
160
|
+
transportType: 'WebHook',
|
|
161
|
+
address: webhookUri,
|
|
162
|
+
},
|
|
163
|
+
expiresIn: 7 * 24 * 3600, // 7 days
|
|
164
|
+
},
|
|
165
|
+
}, token);
|
|
166
|
+
const {
|
|
167
|
+
uri,
|
|
168
|
+
creationTime,
|
|
169
|
+
deliveryMode,
|
|
170
|
+
status, // do no save those field into db to reduce db size
|
|
171
|
+
...subscription
|
|
172
|
+
} = await response.json();
|
|
173
|
+
return subscription;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async getExtensionInfo(extensionId, token) {
|
|
177
|
+
const response = await this.request({
|
|
178
|
+
method: 'GET',
|
|
179
|
+
path: `/restapi/v1.0/account/~/extension/${extensionId}`,
|
|
180
|
+
}, token);
|
|
181
|
+
return response.json();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async getAccountInfo(token) {
|
|
185
|
+
const response = await this.request({
|
|
186
|
+
method: 'GET',
|
|
187
|
+
path: `/restapi/v1.0/account/~`,
|
|
188
|
+
}, token);
|
|
189
|
+
return response.json();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async getCallsAggregationData({ token, timezone, timeFrom, timeTo }) {
|
|
193
|
+
const body = {
|
|
194
|
+
grouping: {
|
|
195
|
+
groupBy: "Company"
|
|
196
|
+
},
|
|
197
|
+
timeSettings: {
|
|
198
|
+
timeZone: timezone,
|
|
199
|
+
timeRange: {
|
|
200
|
+
timeFrom: timeFrom,
|
|
201
|
+
timeTo: timeTo
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
responseOptions: {
|
|
205
|
+
counters: {
|
|
206
|
+
callsByDirection: {
|
|
207
|
+
aggregationType: "Sum"
|
|
208
|
+
},
|
|
209
|
+
callsByResponse: {
|
|
210
|
+
aggregationType: "Sum"
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
timers: {
|
|
214
|
+
allCallsDuration: {
|
|
215
|
+
aggregationType: "Sum"
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const response = await this.request({
|
|
221
|
+
method: 'POST',
|
|
222
|
+
path: `/analytics/calls/v1/accounts/~/aggregation/fetch`,
|
|
223
|
+
body,
|
|
224
|
+
accept: 'application/json'
|
|
225
|
+
}, token);
|
|
226
|
+
return response.json();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async getCallLogData({ extensionId = '~', token, timezone, timeFrom, timeTo }) {
|
|
230
|
+
let pageStart = 1;
|
|
231
|
+
let isFinalPage = false;
|
|
232
|
+
let callLogResponse = null;
|
|
233
|
+
let result = { records: [] };
|
|
234
|
+
while (!isFinalPage) {
|
|
235
|
+
callLogResponse = await this.request({
|
|
236
|
+
method: 'GET',
|
|
237
|
+
path: `/restapi/v1.0/account/~/extension/${extensionId}/call-log?dateFrom=${timeFrom}&dateTo=${timeTo}&page=${pageStart}&view=Simple&perPage=1000`,
|
|
238
|
+
}, token);
|
|
239
|
+
const resultJson = await callLogResponse.json();
|
|
240
|
+
result.records.push(...resultJson.records);
|
|
241
|
+
if (resultJson.navigation?.nextPage) {
|
|
242
|
+
pageStart++;
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
isFinalPage = true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
async getSMSData({ extensionId = '~', token, timezone, timeFrom, timeTo }) {
|
|
251
|
+
let pageStart = 1;
|
|
252
|
+
let isFinalPage = false;
|
|
253
|
+
let smsLogResponse = null;
|
|
254
|
+
let result = { records: [] };
|
|
255
|
+
while (!isFinalPage) {
|
|
256
|
+
smsLogResponse = await this.request({
|
|
257
|
+
method: 'GET',
|
|
258
|
+
path: `/restapi/v1.0/account/~/extension/${extensionId}/message-store?dateFrom=${timeFrom}&dateTo=${timeTo}&page=${pageStart}&perPage=100`,
|
|
259
|
+
}, token);
|
|
260
|
+
const resultJson = await smsLogResponse.json();
|
|
261
|
+
result.records.push(...resultJson.records);
|
|
262
|
+
if (resultJson.navigation?.nextPage) {
|
|
263
|
+
pageStart++;
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
isFinalPage = true;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
exports.RingCentral = RingCentral;
|
|
274
|
+
exports.isRefreshTokenValid = isRefreshTokenValid;
|
|
275
|
+
exports.isAccessTokenValid = isAccessTokenValid;
|
package/lib/util.js
CHANGED
|
@@ -37,7 +37,32 @@ function secondsToHoursMinutesSeconds(seconds) {
|
|
|
37
37
|
return resultString;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
function getMostRecentDate({ allDateValues }) {
|
|
41
|
+
var result = 0;
|
|
42
|
+
for (const date of allDateValues) {
|
|
43
|
+
if(!date)
|
|
44
|
+
{
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (date > result) {
|
|
48
|
+
result = date;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// media reader link: https://ringcentral.github.io/ringcentral-media-reader/?media=https://media.ringcentral.com/restapi/v1.0/account/{accountId}/extension/{extensionId}/message-store/{messageId}/content/{contentId}
|
|
55
|
+
// platform media link: https://media.ringcentral.com/restapi/v1.0/account/{accountId}/extension/{extensionId}/message-store/{messageId}/content/{contentId}
|
|
56
|
+
function getMediaReaderLinkByPlatformMediaLink(platformMediaLink){
|
|
57
|
+
if(!platformMediaLink){
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const encodedPlatformMediaLink = encodeURIComponent(platformMediaLink);
|
|
61
|
+
return `https://ringcentral.github.io/ringcentral-media-reader/?media=${encodedPlatformMediaLink}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
40
64
|
exports.getTimeZone = getTimeZone;
|
|
41
65
|
exports.getHashValue = getHashValue;
|
|
42
66
|
exports.secondsToHoursMinutesSeconds = secondsToHoursMinutesSeconds;
|
|
43
|
-
|
|
67
|
+
exports.getMostRecentDate = getMostRecentDate;
|
|
68
|
+
exports.getMediaReaderLinkByPlatformMediaLink = getMediaReaderLinkByPlatformMediaLink;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const Sequelize = require('sequelize');
|
|
2
2
|
const { sequelize } = require('./sequelize');
|
|
3
3
|
|
|
4
|
-
// Model for
|
|
4
|
+
// Model for Admin data
|
|
5
5
|
exports.AdminConfigModel = sequelize.define('adminConfigs', {
|
|
6
6
|
// hashed rc account ID
|
|
7
7
|
id: {
|
|
@@ -13,5 +13,22 @@ exports.AdminConfigModel = sequelize.define('adminConfigs', {
|
|
|
13
13
|
},
|
|
14
14
|
customAdapter: {
|
|
15
15
|
type: Sequelize.JSON
|
|
16
|
+
},
|
|
17
|
+
adminAccessToken: {
|
|
18
|
+
type: Sequelize.STRING(512),
|
|
19
|
+
},
|
|
20
|
+
adminRefreshToken: {
|
|
21
|
+
type: Sequelize.STRING(512),
|
|
22
|
+
},
|
|
23
|
+
adminTokenExpiry: {
|
|
24
|
+
type: Sequelize.DATE
|
|
25
|
+
},
|
|
26
|
+
// Array of:
|
|
27
|
+
// {
|
|
28
|
+
// crmUserId: string,
|
|
29
|
+
// rcExtensionId: array of strings
|
|
30
|
+
// }
|
|
31
|
+
userMappings: {
|
|
32
|
+
type: Sequelize.JSON
|
|
16
33
|
}
|
|
17
34
|
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const Sequelize = require('sequelize');
|
|
2
|
+
const { sequelize } = require('./sequelize');
|
|
3
|
+
|
|
4
|
+
exports.CallDownListModel = sequelize.define('callDownLists', {
|
|
5
|
+
id: {
|
|
6
|
+
type: Sequelize.STRING,
|
|
7
|
+
primaryKey: true,
|
|
8
|
+
},
|
|
9
|
+
userId: {
|
|
10
|
+
type: Sequelize.STRING,
|
|
11
|
+
},
|
|
12
|
+
contactId: {
|
|
13
|
+
type: Sequelize.STRING,
|
|
14
|
+
},
|
|
15
|
+
contactType: {
|
|
16
|
+
type: Sequelize.STRING,
|
|
17
|
+
},
|
|
18
|
+
status: {
|
|
19
|
+
type: Sequelize.STRING,
|
|
20
|
+
},
|
|
21
|
+
scheduledAt: {
|
|
22
|
+
type: Sequelize.DATE,
|
|
23
|
+
},
|
|
24
|
+
lastCallAt: {
|
|
25
|
+
type: Sequelize.DATE,
|
|
26
|
+
}
|
|
27
|
+
}, {
|
|
28
|
+
timestamps: true,
|
|
29
|
+
indexes: [
|
|
30
|
+
{ fields: ['userId'] },
|
|
31
|
+
{ fields: ['status'] },
|
|
32
|
+
{ fields: ['scheduledAt'] },
|
|
33
|
+
{ fields: ['userId', 'status'] }
|
|
34
|
+
]
|
|
35
|
+
});
|
package/models/userModel.js
CHANGED
|
@@ -3,10 +3,14 @@ const { sequelize } = require('./sequelize');
|
|
|
3
3
|
|
|
4
4
|
// Model for User data
|
|
5
5
|
exports.UserModel = sequelize.define('users', {
|
|
6
|
+
// id = {crmName}-{crmUserId}
|
|
6
7
|
id: {
|
|
7
8
|
type: Sequelize.STRING,
|
|
8
9
|
primaryKey: true,
|
|
9
10
|
},
|
|
11
|
+
rcAccountId: {
|
|
12
|
+
type: Sequelize.STRING,
|
|
13
|
+
},
|
|
10
14
|
hostname: {
|
|
11
15
|
type: Sequelize.STRING,
|
|
12
16
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@app-connect/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0-beta.1",
|
|
4
4
|
"description": "RingCentral App Connect Core",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"repository": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"author": "RingCentral Labs",
|
|
15
15
|
"license": "MIT",
|
|
16
16
|
"peerDependencies": {
|
|
17
|
-
"axios": "^1.
|
|
17
|
+
"axios": "^1.12.2",
|
|
18
18
|
"express": "^4.21.2",
|
|
19
19
|
"pg": "^8.8.0",
|
|
20
20
|
"sequelize": "^6.29.0",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@eslint/js": "^9.22.0",
|
|
46
46
|
"@octokit/rest": "^19.0.5",
|
|
47
|
-
"axios": "^1.
|
|
47
|
+
"axios": "^1.12.2",
|
|
48
48
|
"express": "^4.21.2",
|
|
49
49
|
"eslint": "^9.22.0",
|
|
50
50
|
"globals": "^16.0.0",
|
package/releaseNotes.json
CHANGED
|
@@ -1,4 +1,48 @@
|
|
|
1
1
|
{
|
|
2
|
+
"1.6.7": {
|
|
3
|
+
"global": [
|
|
4
|
+
{
|
|
5
|
+
"type": "New",
|
|
6
|
+
"description": "- Clio now supports image/video media link in message logs"
|
|
7
|
+
}
|
|
8
|
+
]
|
|
9
|
+
},
|
|
10
|
+
"1.6.6": {
|
|
11
|
+
"global": [
|
|
12
|
+
{
|
|
13
|
+
"type": "New",
|
|
14
|
+
"description": "- Server-side call logging now supports user mapping configuration in the admin tab, allowing admin users to log calls on behalf of other users."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"type": "New",
|
|
18
|
+
"description": "- Separate enable domains for click-to-dial and quick access button"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"type": "Fix",
|
|
22
|
+
"description": "- Server-side call logging now displays RingCentral user names in the correct order within log details."
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"type": "Fix",
|
|
26
|
+
"description": "- Server-side call logging now shows the correct RingCentral user name instead of displaying the Caller ID"
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"1.6.5": {
|
|
31
|
+
"global": [
|
|
32
|
+
{
|
|
33
|
+
"type": "New",
|
|
34
|
+
"description": "- Support call journey in call logging details with server side logging [Details](https://appconnect.labs.ringcentral.com/users/logging/#controlling-what-information-gets-logged)"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"type": "Better",
|
|
38
|
+
"description": "- Tabs orders updated"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"type": "Fix",
|
|
42
|
+
"description": "- Date/Time display issue"
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
},
|
|
2
46
|
"1.6.4": {
|
|
3
47
|
"global": [
|
|
4
48
|
{
|