@serifpersia/dango 3.0.3

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.
Files changed (141) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +247 -0
  3. package/client/dist/assets/AnimeInfo-CMxs0nRH.css +1 -0
  4. package/client/dist/assets/AnimeInfo-DVdaocCl.js +1 -0
  5. package/client/dist/assets/AnimeInfoPage-Bx5EvGt-.js +2 -0
  6. package/client/dist/assets/Asmr-C2i9GKD4.css +1 -0
  7. package/client/dist/assets/Asmr-CIjmFtRH.js +2 -0
  8. package/client/dist/assets/Asmr.module-DPG2JhDJ.js +1 -0
  9. package/client/dist/assets/AvSyncCalibrator-CIYcyon5.css +1 -0
  10. package/client/dist/assets/AvSyncCalibrator-vqErH7Cm.js +10 -0
  11. package/client/dist/assets/ErrorMessage-BwSjzblw.js +1 -0
  12. package/client/dist/assets/ErrorMessage-CXoRbrLN.css +1 -0
  13. package/client/dist/assets/Home-D7YfGBUk.js +1 -0
  14. package/client/dist/assets/Home-DKCfEiwl.css +1 -0
  15. package/client/dist/assets/Insights-BljI64Ua.css +1 -0
  16. package/client/dist/assets/Insights-CLQGrlpX.js +1 -0
  17. package/client/dist/assets/Manga-DQ9HYnpk.js +1 -0
  18. package/client/dist/assets/Manga-H8Wb04w3.css +1 -0
  19. package/client/dist/assets/Map-CXFqXmsZ.css +1 -0
  20. package/client/dist/assets/Map-L_ryVBTq.js +1 -0
  21. package/client/dist/assets/Mature-BeWzAJ7B.js +1 -0
  22. package/client/dist/assets/Mature-DJmUUtlF.css +1 -0
  23. package/client/dist/assets/Player-CYpd__FP.js +30 -0
  24. package/client/dist/assets/Player-DreAhybc.css +1 -0
  25. package/client/dist/assets/PlayerRedirect-fxUAp9JV.js +1 -0
  26. package/client/dist/assets/PlayerSettings-BcsotxP9.js +1 -0
  27. package/client/dist/assets/PlayerSettings-Cqua1CDD.css +1 -0
  28. package/client/dist/assets/QueueRail-B9Pgbqc3.js +1 -0
  29. package/client/dist/assets/QueueRail-BnaltOP8.css +1 -0
  30. package/client/dist/assets/Radio-BvdY5twD.js +1 -0
  31. package/client/dist/assets/Radio-CUuYy3DY.css +1 -0
  32. package/client/dist/assets/Search-BzO-aRP7.css +1 -0
  33. package/client/dist/assets/Search-R9YLAXS1.js +1 -0
  34. package/client/dist/assets/Search.module-D8ZbQAiK.js +1 -0
  35. package/client/dist/assets/Settings-9kYcXdTR.js +21 -0
  36. package/client/dist/assets/Settings-DF8JThUL.css +1 -0
  37. package/client/dist/assets/SkeletonGrid-D5vRvX5q.js +1 -0
  38. package/client/dist/assets/SkeletonGrid-DEbYtO4H.css +1 -0
  39. package/client/dist/assets/SynopsisText-BlFw0R-0.js +1 -0
  40. package/client/dist/assets/SynopsisText-CUgOjOxt.css +1 -0
  41. package/client/dist/assets/Trackers-DfZlnnVq.js +2 -0
  42. package/client/dist/assets/Trackers-Dt9lR_uP.css +1 -0
  43. package/client/dist/assets/Tv-CVKC2YBs.css +1 -0
  44. package/client/dist/assets/Tv-Db1Wx_b7.js +15 -0
  45. package/client/dist/assets/Watchlist-B2nkNZqM.css +1 -0
  46. package/client/dist/assets/Watchlist-a77CYtzu.js +1 -0
  47. package/client/dist/assets/hls-6Owgw2Aj.js +1 -0
  48. package/client/dist/assets/hls-BOLXTnjL.js +42 -0
  49. package/client/dist/assets/index-B6qdXFtl.css +1 -0
  50. package/client/dist/assets/index-B8TPVaIS.js +30354 -0
  51. package/client/dist/assets/index-CpjgU8ou.js +178 -0
  52. package/client/dist/assets/useAnimeInfoData-BOfyi9UJ.js +1 -0
  53. package/client/dist/assets/useIsMobile-CM15uQn8.css +1 -0
  54. package/client/dist/assets/useIsMobile-Cb8HcKRj.js +1 -0
  55. package/client/dist/assets/useMatureConsent-CZMYBVXd.js +1 -0
  56. package/client/dist/assets/vendor-rLNfZm7Y.js +1 -0
  57. package/client/dist/av-sync-test.mp4 +0 -0
  58. package/client/dist/favicon.ico +0 -0
  59. package/client/dist/index.html +31 -0
  60. package/client/dist/logo-mochi.png +0 -0
  61. package/client/dist/logo-sakura.png +0 -0
  62. package/client/dist/logo.png +0 -0
  63. package/client/dist/placeholder.svg +4 -0
  64. package/client/dist/robots.txt +3 -0
  65. package/orchestrator.js +416 -0
  66. package/package.json +89 -0
  67. package/server/dist/app-auth.js +231 -0
  68. package/server/dist/config.js +133 -0
  69. package/server/dist/controllers/auth.controller.js +247 -0
  70. package/server/dist/controllers/data.controller.js +785 -0
  71. package/server/dist/controllers/insights.controller.js +197 -0
  72. package/server/dist/controllers/proxy.controller.js +665 -0
  73. package/server/dist/controllers/settings.controller.js +267 -0
  74. package/server/dist/controllers/watchlist.controller.js +967 -0
  75. package/server/dist/db.js +152 -0
  76. package/server/dist/discord-gateway.js +381 -0
  77. package/server/dist/discord-rpc.js +424 -0
  78. package/server/dist/github-sync.js +403 -0
  79. package/server/dist/google.js +549 -0
  80. package/server/dist/lib/anilist.js +1519 -0
  81. package/server/dist/lib/anischedule.js +202 -0
  82. package/server/dist/lib/ipc.js +32 -0
  83. package/server/dist/lib/kitsu.js +552 -0
  84. package/server/dist/lib/mal.js +886 -0
  85. package/server/dist/lib/migration.js +335 -0
  86. package/server/dist/lib/temp-ids.js +11 -0
  87. package/server/dist/lib/tmdb.js +71 -0
  88. package/server/dist/lib/tracker/anilist-tracker.js +250 -0
  89. package/server/dist/lib/tracker/sync.service.js +312 -0
  90. package/server/dist/logger.js +19 -0
  91. package/server/dist/providers/123anime.provider.js +240 -0
  92. package/server/dist/providers/anilight.provider.js +366 -0
  93. package/server/dist/providers/animepahe.provider.js +531 -0
  94. package/server/dist/providers/animeya.provider.js +746 -0
  95. package/server/dist/providers/base-provider.js +45 -0
  96. package/server/dist/providers/hn.provider.js +473 -0
  97. package/server/dist/providers/ht.provider.js +439 -0
  98. package/server/dist/providers/jasmr.provider.js +504 -0
  99. package/server/dist/providers/kaa.provider.js +388 -0
  100. package/server/dist/providers/manga/manga.types.js +10 -0
  101. package/server/dist/providers/manga/mangadex.provider.js +227 -0
  102. package/server/dist/providers/manga/mangapill.provider.js +232 -0
  103. package/server/dist/providers/megaplay.provider.js +583 -0
  104. package/server/dist/providers/op.provider.js +440 -0
  105. package/server/dist/providers/provider.interface.js +2 -0
  106. package/server/dist/providers/title-matching.js +234 -0
  107. package/server/dist/providers/wh.provider.js +600 -0
  108. package/server/dist/rclone.js +126 -0
  109. package/server/dist/repositories/insights.repository.js +43 -0
  110. package/server/dist/repositories/mal-cache.repository.js +66 -0
  111. package/server/dist/repositories/notifications.repository.js +40 -0
  112. package/server/dist/repositories/queue.repository.js +64 -0
  113. package/server/dist/repositories/settings.repository.js +15 -0
  114. package/server/dist/repositories/shows-meta.repository.js +43 -0
  115. package/server/dist/repositories/temp-show-ids.repository.js +54 -0
  116. package/server/dist/repositories/watched-episodes.repository.js +54 -0
  117. package/server/dist/repositories/watchlist.repository.js +74 -0
  118. package/server/dist/routes/asmr.routes.js +72 -0
  119. package/server/dist/routes/auth.routes.js +28 -0
  120. package/server/dist/routes/data.routes.js +50 -0
  121. package/server/dist/routes/discord-gateway.routes.js +57 -0
  122. package/server/dist/routes/insights.routes.js +12 -0
  123. package/server/dist/routes/lan-auth.routes.js +49 -0
  124. package/server/dist/routes/manga.routes.js +115 -0
  125. package/server/dist/routes/proxy.routes.js +15 -0
  126. package/server/dist/routes/radio.routes.js +143 -0
  127. package/server/dist/routes/settings.routes.js +27 -0
  128. package/server/dist/routes/tracker.routes.js +143 -0
  129. package/server/dist/routes/translate.routes.js +90 -0
  130. package/server/dist/routes/tv.routes.js +843 -0
  131. package/server/dist/routes/watchlist.routes.js +312 -0
  132. package/server/dist/server.js +310 -0
  133. package/server/dist/shipped-defaults.js +8 -0
  134. package/server/dist/sync-config.js +32 -0
  135. package/server/dist/sync.js +506 -0
  136. package/server/dist/utils/cookie.utils.js +17 -0
  137. package/server/dist/utils/db-utils.js +15 -0
  138. package/server/dist/utils/env.utils.js +83 -0
  139. package/server/dist/utils/machine-id.js +46 -0
  140. package/server/dist/utils/request-context.js +5 -0
  141. package/server/dist/utils/security.utils.js +99 -0
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.DatabaseWrapper = void 0;
7
+ const node_sqlite_1 = require("node:sqlite");
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const logger_1 = __importDefault(require("./logger"));
11
+ class DatabaseWrapper {
12
+ db;
13
+ isClosed = false;
14
+ statementCache = new Map();
15
+ constructor(_dbPath, db) {
16
+ this.db = db;
17
+ }
18
+ static async create(dbPath) {
19
+ try {
20
+ const dir = path_1.default.dirname(dbPath);
21
+ if (!fs_1.default.existsSync(dir)) {
22
+ fs_1.default.mkdirSync(dir, { recursive: true });
23
+ }
24
+ const db = new node_sqlite_1.DatabaseSync(dbPath);
25
+ return new DatabaseWrapper(dbPath, db);
26
+ }
27
+ catch (e) {
28
+ logger_1.default.error({ err: e }, `Failed to initialize database at ${dbPath}`);
29
+ throw e;
30
+ }
31
+ }
32
+ scheduleSave() { }
33
+ async saveNow() { }
34
+ configure(option, value) {
35
+ if (option === 'busyTimeout') {
36
+ this.db.exec(`PRAGMA busy_timeout = ${value}`);
37
+ }
38
+ }
39
+ serialize(cb) {
40
+ this.db.exec('BEGIN IMMEDIATE');
41
+ try {
42
+ cb();
43
+ this.db.exec('COMMIT');
44
+ }
45
+ catch (e) {
46
+ this.db.exec('ROLLBACK');
47
+ throw e;
48
+ }
49
+ }
50
+ close(cb) {
51
+ if (this.isClosed) {
52
+ if (cb)
53
+ cb(null);
54
+ return;
55
+ }
56
+ try {
57
+ this.isClosed = true;
58
+ this.statementCache.clear();
59
+ this.db.close();
60
+ if (cb)
61
+ cb(null);
62
+ }
63
+ catch (e) {
64
+ logger_1.default.error({ err: e }, 'Error during database close');
65
+ if (cb)
66
+ cb(e);
67
+ }
68
+ }
69
+ isClosedCheck() {
70
+ return this.isClosed;
71
+ }
72
+ getPreparedStatement(query) {
73
+ let stmt = this.statementCache.get(query);
74
+ if (!stmt) {
75
+ if (this.statementCache.size > 100) {
76
+ this.statementCache.clear();
77
+ }
78
+ stmt = this.db.prepare(query);
79
+ this.statementCache.set(query, stmt);
80
+ }
81
+ return stmt;
82
+ }
83
+ run(query, params = []) {
84
+ if (this.isClosed) {
85
+ throw new Error('Database is closed');
86
+ }
87
+ const stmt = this.getPreparedStatement(query);
88
+ if (params.length > 0) {
89
+ stmt.run(...params);
90
+ }
91
+ else {
92
+ stmt.run();
93
+ }
94
+ }
95
+ get(query, params = []) {
96
+ if (this.isClosed) {
97
+ throw new Error('Database is closed');
98
+ }
99
+ const stmt = this.getPreparedStatement(query);
100
+ if (params.length > 0) {
101
+ return stmt.get(...params);
102
+ }
103
+ return stmt.get();
104
+ }
105
+ all(query, params = []) {
106
+ if (this.isClosed) {
107
+ throw new Error('Database is closed');
108
+ }
109
+ const stmt = this.getPreparedStatement(query);
110
+ if (params.length > 0) {
111
+ return stmt.all(...params);
112
+ }
113
+ return stmt.all();
114
+ }
115
+ prepare(query) {
116
+ const stmt = this.getPreparedStatement(query);
117
+ return {
118
+ run: (...args) => {
119
+ stmt.run(...args);
120
+ },
121
+ all: () => {
122
+ return stmt.all();
123
+ },
124
+ get: () => {
125
+ return stmt.get();
126
+ },
127
+ finalize: () => { },
128
+ };
129
+ }
130
+ backup(backupPath) {
131
+ try {
132
+ if (fs_1.default.existsSync(backupPath)) {
133
+ fs_1.default.rmSync(backupPath, { force: true });
134
+ }
135
+ this.db.exec(`VACUUM INTO '${backupPath}'`);
136
+ }
137
+ catch (e) {
138
+ logger_1.default.error({ err: e, backupPath }, 'Database backup failed via VACUUM INTO');
139
+ throw e;
140
+ }
141
+ }
142
+ checkpoint() {
143
+ try {
144
+ this.db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
145
+ }
146
+ catch (e) {
147
+ logger_1.default.error({ err: e }, 'Database WAL checkpoint failed');
148
+ throw e;
149
+ }
150
+ }
151
+ }
152
+ exports.DatabaseWrapper = DatabaseWrapper;
@@ -0,0 +1,381 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.discordGatewayService = void 0;
7
+ const ws_1 = __importDefault(require("ws"));
8
+ const logger_1 = __importDefault(require("./logger"));
9
+ const config_1 = require("./config");
10
+ const dotenv_1 = __importDefault(require("dotenv"));
11
+ const log = logger_1.default.child({ module: 'DiscordGateway' });
12
+ class DiscordGatewayService {
13
+ gateway = null;
14
+ heartbeat = null;
15
+ token = null;
16
+ isConnected = false;
17
+ reconnectTimeout = null;
18
+ lastActivity = null;
19
+ isEnabled = false;
20
+ RECONNECT_DELAY = 15000;
21
+ externalAssetCache = new Map();
22
+ setEnabled(enabled) {
23
+ this.isEnabled = enabled;
24
+ if (enabled) {
25
+ this.loadToken();
26
+ this.connect();
27
+ }
28
+ else {
29
+ this.disconnect();
30
+ }
31
+ }
32
+ get serviceEnabled() {
33
+ return this.isEnabled && !!this.token && this.token.length > 30;
34
+ }
35
+ hasToken() {
36
+ return !!this.token && this.token.length > 30;
37
+ }
38
+ loadToken() {
39
+ this.token = process.env.DISCORD_GATEWAY_TOKEN || null;
40
+ }
41
+ reloadToken() {
42
+ dotenv_1.default.config({ path: config_1.CONFIG.ENV_PATH, override: true });
43
+ this.loadToken();
44
+ if (this.serviceEnabled) {
45
+ this.disconnect();
46
+ this.connect();
47
+ }
48
+ }
49
+ async getExternalAsset(url) {
50
+ if (!config_1.CONFIG.DISCORD_CLIENT_ID || !this.token)
51
+ return null;
52
+ const cached = this.externalAssetCache.get(url);
53
+ if (cached)
54
+ return cached;
55
+ try {
56
+ const res = await fetch(`https://discord.com/api/v9/applications/${config_1.CONFIG.DISCORD_CLIENT_ID}/external-assets`, {
57
+ method: 'POST',
58
+ headers: {
59
+ Authorization: this.token,
60
+ 'Content-Type': 'application/json',
61
+ },
62
+ body: JSON.stringify({ urls: [url] }),
63
+ });
64
+ const text = await res.text();
65
+ if (!res.ok) {
66
+ log.warn(`external-assets proxy failed ${res.status}: ${text.slice(0, 200)}`);
67
+ return null;
68
+ }
69
+ const data = JSON.parse(text);
70
+ if (!data[0]?.external_asset_path)
71
+ return null;
72
+ const mp = `mp:${data[0].external_asset_path}`;
73
+ this.externalAssetCache.set(url, mp);
74
+ return mp;
75
+ }
76
+ catch (err) {
77
+ log.warn({ err }, 'external-assets proxy error');
78
+ return null;
79
+ }
80
+ }
81
+ resolveImageUrl(data) {
82
+ const isSafeUrl = (url) => {
83
+ if (!url.startsWith('https://'))
84
+ return false;
85
+ if (url.includes('localhost') || url.includes('127.0.0.1'))
86
+ return false;
87
+ if (url.includes('s4.anilist.co') || url.includes('anilistcdn'))
88
+ return true;
89
+ const blocked = [
90
+ 'youtube-anime.com',
91
+ 'animepahe',
92
+ 'animeya.cc',
93
+ 'gogocdn.net',
94
+ 'weeabo0.xyz',
95
+ 'japaneseasmr.com',
96
+ ];
97
+ return !blocked.some((d) => url.includes(d));
98
+ };
99
+ let thumb = data.thumbnail;
100
+ if (thumb && thumb.includes('/api/image-proxy')) {
101
+ const m = thumb.match(/url=([^&]+)/);
102
+ if (m) {
103
+ try {
104
+ thumb = decodeURIComponent(m[1]);
105
+ }
106
+ catch (_e) {
107
+ void _e;
108
+ }
109
+ }
110
+ }
111
+ if (thumb && data.providerName !== 'AnimePahe' && isSafeUrl(thumb))
112
+ return thumb;
113
+ if (data.thumbnails) {
114
+ const safe = data.thumbnails.find((t) => t && (t.includes('s4.anilist.co') || t.includes('anilistcdn')));
115
+ if (safe)
116
+ return safe;
117
+ }
118
+ if (thumb && isSafeUrl(thumb))
119
+ return thumb;
120
+ return null;
121
+ }
122
+ async updatePresence(data) {
123
+ this.lastActivity = data;
124
+ if (!this.isEnabled || !this.gateway || this.gateway.readyState !== ws_1.default.OPEN)
125
+ return;
126
+ const name = 'dango';
127
+ const details = data.title;
128
+ const state = data.stateLine
129
+ ? data.stateLine
130
+ : `Episode ${data.episode}${data.totalEpisodes ? `/${data.totalEpisodes}` : ''}`;
131
+ const type = data.providerName === 'ASMR' || data.providerName === 'Radio' ? 2 : 3;
132
+ const imageUrl = this.resolveImageUrl(data);
133
+ let assets;
134
+ if (imageUrl) {
135
+ if (config_1.CONFIG.DISCORD_CLIENT_ID) {
136
+ const proxied = await this.getExternalAsset(imageUrl);
137
+ if (proxied) {
138
+ assets = { large_image: proxied, large_text: data.title.slice(0, 128) };
139
+ }
140
+ else {
141
+ assets = { large_image: 'logo', large_text: data.title.slice(0, 128) };
142
+ }
143
+ }
144
+ }
145
+ else if (config_1.CONFIG.DISCORD_CLIENT_ID) {
146
+ assets = { large_image: 'logo', large_text: data.title.slice(0, 128) };
147
+ }
148
+ const presence = {
149
+ since: null,
150
+ activities: [
151
+ {
152
+ name,
153
+ type,
154
+ state: data.isPlaying ? state : `${state} (Paused)`,
155
+ details,
156
+ ...(config_1.CONFIG.DISCORD_CLIENT_ID ? { application_id: config_1.CONFIG.DISCORD_CLIENT_ID } : {}),
157
+ ...(assets ? { assets } : {}),
158
+ buttons: ['Learn More'],
159
+ metadata: { button_urls: ['https://github.com/serifpersia/dango'] },
160
+ status_display_type: 2,
161
+ },
162
+ ],
163
+ status: 'online',
164
+ afk: false,
165
+ };
166
+ const nowMs = Date.now();
167
+ if (data.isPlaying) {
168
+ if (data.currentTime && data.currentTime > 0) {
169
+ presence.since = nowMs - Math.round(data.currentTime * 1000);
170
+ }
171
+ if (data.duration && data.duration > data.currentTime) {
172
+ presence.activities[0].timestamps = {
173
+ start: nowMs - Math.round(data.currentTime * 1000),
174
+ end: nowMs + Math.round((data.duration - data.currentTime) * 1000),
175
+ };
176
+ }
177
+ }
178
+ try {
179
+ this.gateway.send(JSON.stringify({ op: 3, d: presence }));
180
+ }
181
+ catch (err) {
182
+ log.error({ err }, 'Failed to send gateway presence update');
183
+ }
184
+ }
185
+ setIdleStatus(page) {
186
+ if (!this.isEnabled || !this.gateway || this.gateway.readyState !== ws_1.default.OPEN)
187
+ return;
188
+ const pageLabels = {
189
+ home: { details: 'Home', state: 'Browsing anime' },
190
+ search: { details: 'Search', state: 'Exploring titles...' },
191
+ watchlist: { details: 'Watchlist', state: 'Reviewing the watchlist' },
192
+ anime: { details: 'Anime Info', state: 'Reading show details' },
193
+ insights: { details: 'Insights', state: 'Reviewing stats' },
194
+ settings: { details: 'Settings', state: 'Tweaking preferences' },
195
+ trackers: { details: 'Trackers', state: 'Syncing watchlists' },
196
+ map: { details: 'Map', state: 'Exploring the global user map' },
197
+ asmr: { details: 'ASMR', state: 'Browsing ASMR works' },
198
+ radio: { details: 'Radio', state: 'Browsing radio stations' },
199
+ tv: { details: 'TV', state: 'Browsing movies & shows' },
200
+ manga: { details: 'Manga', state: 'Browsing manga' },
201
+ };
202
+ const label = pageLabels[page] ?? { details: 'dango', state: 'Idle' };
203
+ try {
204
+ this.gateway.send(JSON.stringify({
205
+ op: 3,
206
+ d: {
207
+ since: null,
208
+ activities: [
209
+ {
210
+ name: 'dango',
211
+ type: page === 'asmr' || page === 'radio' ? 2 : 3,
212
+ state: label.state,
213
+ details: label.details,
214
+ ...(config_1.CONFIG.DISCORD_CLIENT_ID ? { application_id: config_1.CONFIG.DISCORD_CLIENT_ID } : {}),
215
+ buttons: ['Learn More'],
216
+ metadata: { button_urls: ['https://github.com/serifpersia/dango'] },
217
+ status_display_type: 2,
218
+ },
219
+ ],
220
+ status: 'online',
221
+ afk: false,
222
+ },
223
+ }));
224
+ }
225
+ catch (err) {
226
+ log.error({ err }, 'Failed to send gateway idle status');
227
+ }
228
+ }
229
+ clearPresence() {
230
+ if (!this.isEnabled)
231
+ return;
232
+ if (this.gateway && this.gateway.readyState === ws_1.default.OPEN) {
233
+ try {
234
+ this.gateway.send(JSON.stringify({
235
+ op: 3,
236
+ d: { since: null, activities: [], status: 'online', afk: false },
237
+ }));
238
+ }
239
+ catch {
240
+ // ignore
241
+ }
242
+ }
243
+ this.lastActivity = null;
244
+ }
245
+ disconnect() {
246
+ if (this.reconnectTimeout) {
247
+ clearTimeout(this.reconnectTimeout);
248
+ this.reconnectTimeout = null;
249
+ }
250
+ if (this.gateway) {
251
+ try {
252
+ if (this.gateway.readyState === ws_1.default.OPEN) {
253
+ this.gateway.send(JSON.stringify({
254
+ op: 3,
255
+ d: { since: null, activities: [], status: 'online', afk: false },
256
+ }));
257
+ }
258
+ this.gateway.close();
259
+ }
260
+ catch {
261
+ // ignore
262
+ }
263
+ this.gateway = null;
264
+ }
265
+ if (this.heartbeat) {
266
+ clearInterval(this.heartbeat);
267
+ this.heartbeat = null;
268
+ }
269
+ this.isConnected = false;
270
+ }
271
+ connect() {
272
+ if (!this.isEnabled || !this.token || this.gateway)
273
+ return;
274
+ const wsUrl = 'wss://gateway.discord.gg/?encoding=json&v=10';
275
+ log.debug('Connecting Discord gateway');
276
+ this.gateway = new ws_1.default(wsUrl);
277
+ let heartbeatInterval = 41250;
278
+ let responded = false;
279
+ this.gateway.on('message', (data) => {
280
+ try {
281
+ const msg = JSON.parse(data.toString());
282
+ if (msg.op === 10) {
283
+ heartbeatInterval = msg.d.heartbeat_interval;
284
+ this.heartbeat = setInterval(() => {
285
+ try {
286
+ if (this.gateway && this.gateway.readyState === ws_1.default.OPEN) {
287
+ this.gateway.send(JSON.stringify({ op: 1, d: null }));
288
+ }
289
+ }
290
+ catch {
291
+ // ignore
292
+ }
293
+ }, heartbeatInterval);
294
+ const identify = {
295
+ op: 2,
296
+ d: {
297
+ token: this.token,
298
+ capabilities: 1021,
299
+ client_state: {
300
+ guild_hashes: {},
301
+ highest_last_message_id: '0',
302
+ private_channels_version: '0',
303
+ read_state_version: 0,
304
+ user_guild_settings_version: -1,
305
+ user_settings_version: -1,
306
+ },
307
+ compress: false,
308
+ presence: {
309
+ since: null,
310
+ activities: [],
311
+ status: 'online',
312
+ afk: false,
313
+ },
314
+ properties: {
315
+ os: 'Windows',
316
+ os_version: '10.0.19044',
317
+ os_arch: 'x64',
318
+ browser: 'Discord Client',
319
+ client_version: '0.0.20',
320
+ client_build_number: 152131,
321
+ client_event_source: null,
322
+ release_channel: 'stable',
323
+ system_locale: 'en-US',
324
+ },
325
+ },
326
+ };
327
+ if (this.gateway) {
328
+ this.gateway.send(JSON.stringify(identify));
329
+ }
330
+ }
331
+ else if (msg.op === 11) {
332
+ // heartbeat ack
333
+ }
334
+ else if (msg.op === 0) {
335
+ if (msg.t === 'READY') {
336
+ responded = true;
337
+ this.isConnected = true;
338
+ log.info(`Gateway READY as ${msg.d.user?.username || 'unknown'}`);
339
+ if (this.lastActivity) {
340
+ void this.updatePresence(this.lastActivity).catch((err) => log.warn({ err }, 'failed to restore presence after READY'));
341
+ }
342
+ }
343
+ }
344
+ else if (msg.op === 7) {
345
+ log.warn('Gateway reconnect requested by Discord');
346
+ this.handleDisconnect();
347
+ }
348
+ else if (msg.op === 9) {
349
+ log.warn('Gateway invalid session, reconnecting...');
350
+ this.handleDisconnect();
351
+ }
352
+ }
353
+ catch (err) {
354
+ log.debug({ err }, 'Gateway message parse error');
355
+ }
356
+ });
357
+ this.gateway.on('close', (code, reason) => {
358
+ log.warn(`Gateway closed ${code} ${reason.toString().slice(0, 100)}`);
359
+ this.handleDisconnect();
360
+ });
361
+ this.gateway.on('error', (err) => {
362
+ log.error(err, 'Gateway WebSocket error');
363
+ this.handleDisconnect();
364
+ });
365
+ }
366
+ handleDisconnect() {
367
+ this.isConnected = false;
368
+ if (this.heartbeat) {
369
+ clearInterval(this.heartbeat);
370
+ this.heartbeat = null;
371
+ }
372
+ this.gateway = null;
373
+ if (this.isEnabled && this.token) {
374
+ this.reconnectTimeout = setTimeout(() => {
375
+ this.reconnectTimeout = null;
376
+ this.connect();
377
+ }, this.RECONNECT_DELAY);
378
+ }
379
+ }
380
+ }
381
+ exports.discordGatewayService = new DiscordGatewayService();