/
index.js
302 lines (252 loc) · 15.7 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
/*
This file is part of botten-nappet -- a Twitch bot and streaming tool.
<https://joelpurra.com/projects/botten-nappet/>
Copyright (c) 2018 Joel Purra <https://joelpurra.com/>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import PinoLogger from "./src/util/pino-logger";
import ShutdownManager from "./src/util/shutdown-manager";
import DatabaseConnection from "./src/storage/database-connection";
import UserRepository from "./src/storage/repository/user-repository";
import TwitchPubSubConnection from "./src/twitch/pubsub/pubsub-connection";
import TwitchPubSubLoggingHandler from "./src/twitch/pubsub/handler/logging";
import TwitchIrcConnection from "./src/twitch/irc/irc-connection";
import TwitchIrcLoggingHandler from "./src/twitch/irc/handler/logging";
import TwitchIrcPingHandler from "./src/twitch/irc/handler/ping";
import TwitchIrcGreetingHandler from "./src/twitch/irc/handler/greeting";
import TwitchIrcNewChatterHandler from "./src/twitch/irc/handler/new-chatter";
import TwitchIrcSubscribingHandler from "./src/twitch/irc/handler/subscribing";
import PollingClientIdConnection from "./src/twitch/polling/connection/polling-clientid-connection";
import TwitchPollingFollowingHandler from "./src/twitch/polling/handler/following";
import TwitchPollingApplicationTokenConnection from "./src/twitch/authentication/polling-application-token-connection";
import TwitchApplicationTokenManager from "./src/twitch/authentication/application-token-manager";
import TwitchUserTokenManager from "./src/twitch/authentication/user-token-manager";
import TwitchRequestHelper from "./src/twitch/helper/request-helper";
import TwitchTokenHelper from "./src/twitch/helper/token-helper";
import TwitchUserHelper from "./src/twitch/helper/user-helper";
import TwitchCSRFHelper from "./src/twitch/helper/csrf-helper";
const assert = require("power-assert");
const Promise = require("bluebird");
const fs = require("fs");
const pino = require("pino");
const BOTTEN_NAPPET_DEFAULT_LOGGING_LEVEL = "error";
const BOTTEN_NAPPET_DEFAULT_POLLING_INTERVAL = 30 * 1000;
// TODO: better token/config handling.
const loggingLevel = process.env.BOTTEN_NAPPET_LOGGING_LEVEL || BOTTEN_NAPPET_DEFAULT_LOGGING_LEVEL;
const loggingFile = process.env.BOTTEN_NAPPET_LOG_FILE;
const databaseUri = process.env.BOTTEN_NAPPET_DATABASE_URI;
const twitchAppClientId = process.env.TWITCH_APP_CLIENT_ID;
const twitchAppClientSecret = process.env.TWITCH_APP_CLIENT_SECRET;
const twitchAppOAuthRedirectUrl = process.env.TWITCH_APP_OAUTH_REDIRECT_URL;
const twitchUserName = process.env.TWITCH_USER_NAME;
// TODO: simplify validation and validation error messages.
assert.strictEqual(typeof loggingLevel, "string", "BOTTEN_NAPPET_LOGGING_LEVEL");
assert(loggingLevel.length > 0, "BOTTEN_NAPPET_LOGGING_LEVEL");
assert.strictEqual(typeof loggingFile, "string", "BOTTEN_NAPPET_LOG_FILE");
assert(loggingFile.length > 0, "BOTTEN_NAPPET_LOG_FILE");
assert.strictEqual(typeof databaseUri, "string", "BOTTEN_NAPPET_DATABASE_URI");
assert(databaseUri.length > 0, "BOTTEN_NAPPET_DATABASE_URI");
assert(databaseUri.startsWith("nedb://"), "BOTTEN_NAPPET_DATABASE_URI");
assert.strictEqual(typeof twitchAppClientId, "string", "TWITCH_APP_CLIENT_ID");
assert(twitchAppClientId.length > 0, "TWITCH_APP_CLIENT_ID");
assert.strictEqual(typeof twitchAppOAuthRedirectUrl, "string", "TWITCH_APP_OAUTH_REDIRECT_URL");
assert(twitchAppOAuthRedirectUrl.length > 0, "TWITCH_APP_OAUTH_REDIRECT_URL");
assert.strictEqual(typeof twitchUserName, "string", "TWITCH_USER_NAME");
assert(twitchUserName.length > 0, "TWITCH_USER_NAME");
const twitchOAuthTokenUri = "https://api.twitch.tv/kraken/oauth2/token";
const twitchOAuthTokenRevocationUri = "https://api.twitch.tv/kraken/oauth2/revoke";
const twitchOAuthAuthorizationUri = "https://api.twitch.tv/kraken/oauth2/authorize";
const twitchOAuthTokenVerificationUri = "https://api.twitch.tv/kraken";
const twitchUsersDataUri = "https://api.twitch.tv/helix/users";
const twitchPubSubWebSocketUri = "wss://pubsub-edge.twitch.tv/";
const twitchIrcWebSocketUri = "wss://irc-ws.chat.twitch.tv:443/";
const followingPollingLimit = 10;
const twitchAppScopes = [
"channel_feed_read",
];
const twitchAppTokenRefreshInterval = 45 * 60 * 1000;
// NOTE: assuming that the user only joins their own channel, with a "#" prefix.
const twitchChannelName = `#${twitchUserName}`;
const applicationName = "botten-nappet";
const logFileStream = fs.createWriteStream(loggingFile);
const rootPinoLogger = pino(
{
name: applicationName,
level: loggingLevel,
extreme: true,
onTerminated: (/* eslint-disable no-unused-vars */eventName, error/* eslint-enable no-unused-vars */) => {
// NOTE: override onTerminated to prevent pino from calling process.exit().
},
},
logFileStream
);
const rootLogger = new PinoLogger(rootPinoLogger);
const indexLogger = rootLogger.child("index");
const shutdownManager = new ShutdownManager(rootLogger);
const databaseConnection = new DatabaseConnection(rootLogger, databaseUri);
const twitchPollingApplicationTokenConnection = new TwitchPollingApplicationTokenConnection(rootLogger, twitchAppClientId, twitchAppClientSecret, twitchAppScopes, twitchAppTokenRefreshInterval, false, twitchOAuthTokenUri, "post");
const twitchApplicationTokenManager = new TwitchApplicationTokenManager(rootLogger, twitchPollingApplicationTokenConnection, twitchAppClientId, twitchOAuthTokenRevocationUri);
const twitchCSRFHelper = new TwitchCSRFHelper(rootLogger);
const twitchRequestHelper = new TwitchRequestHelper(rootLogger);
const twitchTokenHelper = new TwitchTokenHelper(rootLogger, twitchRequestHelper, twitchOAuthTokenRevocationUri, twitchOAuthTokenVerificationUri, twitchAppClientId);
Promise.resolve()
.then(() => shutdownManager.start())
.then(() => databaseConnection.connect())
.then(() => {
indexLogger.info("Managed.");
const shutdown = (incomingError) => Promise.resolve()
.then(() => databaseConnection.disconnect())
.then(() => shutdownManager.stop())
.then(() => {
if (incomingError) {
indexLogger.error("Unmanaged.", incomingError);
throw incomingError;
}
indexLogger.info("Unmanaged.");
return undefined;
});
return twitchPollingApplicationTokenConnection.connect()
.then(() => twitchApplicationTokenManager.start())
.then(() => twitchApplicationTokenManager.getOrWait())
.then(() => {
indexLogger.info("Application authenticated.");
const disconnectAuthentication = (incomingError) => Promise.resolve()
.then(() => twitchApplicationTokenManager.stop())
.then(() => twitchPollingApplicationTokenConnection.disconnect())
.then(() => {
if (incomingError) {
indexLogger.error("Unauthenticated.", incomingError);
throw incomingError;
}
indexLogger.info("Unauthenticated.");
return undefined;
});
const twitchApplicationAccessTokenProvider = () => twitchApplicationTokenManager.getOrWait();
const twitchUserHelper = new TwitchUserHelper(
rootLogger,
twitchCSRFHelper,
UserRepository,
twitchOAuthAuthorizationUri,
twitchAppOAuthRedirectUrl,
twitchOAuthTokenUri,
twitchUsersDataUri,
twitchAppClientId,
twitchAppClientSecret,
twitchApplicationAccessTokenProvider
);
const userTokenManager = new TwitchUserTokenManager(rootLogger, twitchOAuthTokenUri, twitchOAuthTokenRevocationUri, twitchAppClientId, twitchAppClientSecret);
const twitchUserAccessTokenProvider = () => {
// TODO: replace with an https server.
// TODO: revoke user token?
return twitchUserHelper.getUserToken(twitchUserName)
.then((twitchUserToken) => {
return twitchTokenHelper.isTokenValid(twitchUserToken)
.then((isValid) => {
if (isValid) {
return twitchUserToken;
}
return twitchUserHelper.forgetUserToken(twitchUserName)
// TODO: user-wrappers with username for the generic token functions?
.then(() => twitchTokenHelper.revokeToken(twitchUserToken))
.then(() => twitchUserHelper.getUserToken(twitchUserName));
});
})
// TODO: improve getting/refreshing the token to have a creation time, not just expiry time.
.then((twitchUserToken) => userTokenManager.get(twitchUserToken))
// TODO: don't store the token here, but in the userTokenManager, or in the twitchUserHelper?
.tap((refreshedToken) => twitchUserHelper.storeUserToken(twitchUserName, refreshedToken))
.then((refreshedToken) => refreshedToken.access_token);
};
return Promise.all([
twitchUserHelper.getUserIdByUserName(twitchUserName),
// TODO: move out of Promise.all?
twitchUserAccessTokenProvider(),
])
.then((
[
twitchUserId,
/* eslint-disable no-unused-vars */twitchUserToken, /* eslint-enable no-unused-vars */
]
) => {
// TODO: use twitchUserIdProvider instead of twitchUserId.
// const twitchUserIdProvider = () => Promise.resolve(twitchUserId);
const followingPollingUri = `https://api.twitch.tv/kraken/channels/${twitchUserId}/follows?limit=${followingPollingLimit}`;
const twitchPubSubConnection = new TwitchPubSubConnection(rootLogger, twitchPubSubWebSocketUri);
const twitchIrcConnection = new TwitchIrcConnection(rootLogger, twitchIrcWebSocketUri, twitchChannelName, twitchUserName, twitchUserAccessTokenProvider);
// TODO: use twitchUserIdProvider instead of twitchUserId.
const twitchPubSubLoggingHandler = new TwitchPubSubLoggingHandler(rootLogger, twitchPubSubConnection, twitchUserAccessTokenProvider, twitchUserId);
const twitchPollingFollowingConnection = new PollingClientIdConnection(rootLogger, twitchAppClientId, BOTTEN_NAPPET_DEFAULT_POLLING_INTERVAL, false, followingPollingUri, "get");
const connectables = [
twitchPubSubConnection,
twitchIrcConnection,
twitchPollingFollowingConnection,
];
return Promise.map(connectables, (connectable) => connectable.connect())
.then(() => {
indexLogger.info("Connected.");
const disconnect = (incomingError) => Promise.map(connectables, (connectable) => connectable.disconnect())
.then(() => {
if (incomingError) {
indexLogger.error("Disconnected.", incomingError);
throw incomingError;
}
indexLogger.info("Disconnected.");
return undefined;
});
const twitchIrcLoggingHandler = new TwitchIrcLoggingHandler(rootLogger, twitchIrcConnection);
const twitchIrcPingHandler = new TwitchIrcPingHandler(rootLogger, twitchIrcConnection);
const twitchIrcGreetingHandler = new TwitchIrcGreetingHandler(rootLogger, twitchIrcConnection, twitchUserName);
const twitchIrcNewChatterHandler = new TwitchIrcNewChatterHandler(rootLogger, twitchIrcConnection);
const twitchIrcSubscribingHandler = new TwitchIrcSubscribingHandler(rootLogger, twitchIrcConnection);
const twitchPollingFollowingHandler = new TwitchPollingFollowingHandler(rootLogger, twitchPollingFollowingConnection, twitchIrcConnection, twitchChannelName);
const startables = [
twitchPubSubLoggingHandler,
twitchIrcLoggingHandler,
twitchIrcPingHandler,
twitchIrcGreetingHandler,
twitchIrcNewChatterHandler,
twitchIrcSubscribingHandler,
twitchPollingFollowingHandler,
];
return Promise.resolve()
.then(() => Promise.map(startables, (startable) => startable.start()))
.then(() => {
indexLogger.info(`Started listening to events for ${twitchUserName} (${twitchUserId}).`);
const stop = (incomingError) => Promise.map(startables, (startable) => startable.stop())
.then(() => {
if (incomingError) {
indexLogger.error("Stopped.", incomingError);
throw incomingError;
}
indexLogger.info("Stopped.");
return undefined;
});
return shutdownManager.waitForShutdownSignal()
.then(() => stop(), (error) => stop(error));
})
.then(() => disconnect(), (error) => disconnect(error));
});
})
.then(() => disconnectAuthentication(), (error) => disconnectAuthentication(error));
})
.then(() => shutdown(), (error) => shutdown(error));
})
.then(() => {
process.exitCode = 0;
return undefined;
})
.catch((error) => {
/* eslint-disable no-console */
console.error("Error.", error);
/* eslint-enable no-console */
process.exitCode = 1;
});