@hieuxyz/rpc 1.0.6 → 1.0.8
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/README.md +10 -7
- package/dist/hieuxyz/Client.d.ts +12 -3
- package/dist/hieuxyz/Client.js +12 -5
- package/dist/hieuxyz/gateway/DiscordWebSocket.d.ts +15 -3
- package/dist/hieuxyz/gateway/DiscordWebSocket.js +102 -30
- package/dist/hieuxyz/rpc/HieuxyzRPC.d.ts +14 -0
- package/dist/hieuxyz/rpc/HieuxyzRPC.js +83 -18
- package/dist/hieuxyz/rpc/ImageService.d.ts +6 -0
- package/dist/hieuxyz/rpc/ImageService.js +19 -0
- package/dist/hieuxyz/rpc/RpcImage.d.ts +9 -0
- package/dist/hieuxyz/rpc/RpcImage.js +12 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ DISCORD_USER_TOKEN="YOUR_DISCORD_USER_TOKEN_HERE"
|
|
|
43
43
|
|
|
44
44
|
```typescript
|
|
45
45
|
import * as path from 'path';
|
|
46
|
-
import { Client,
|
|
46
|
+
import { Client, LocalImage, logger } from '@hieuxyz/rpc';
|
|
47
47
|
|
|
48
48
|
async function start() {
|
|
49
49
|
const token = process.env.DISCORD_USER_TOKEN;
|
|
@@ -54,7 +54,10 @@ async function start() {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
// Initialize client with token
|
|
57
|
-
const client = new Client({
|
|
57
|
+
const client = new Client({
|
|
58
|
+
token,
|
|
59
|
+
alwaysReconnect: true,
|
|
60
|
+
});
|
|
58
61
|
|
|
59
62
|
await client.run();
|
|
60
63
|
|
|
@@ -66,11 +69,8 @@ async function start() {
|
|
|
66
69
|
.setType(0) // 0: Playing
|
|
67
70
|
.setTimestamps(Date.now())
|
|
68
71
|
.setParty(1, 5)
|
|
69
|
-
.setLargeImage(
|
|
70
|
-
.setSmallImage(new LocalImage(path.join(__dirname, 'vscode.png')), "VS Code")
|
|
71
|
-
.setButtons([
|
|
72
|
-
{ label: "View on GitHub", url: "https://github.com/hieuxyz00/hieuxyz_rpc" }
|
|
73
|
-
]);
|
|
72
|
+
.setLargeImage("https://i.ibb.co/MDP0hfTM/typescript.png", "TypeScript")
|
|
73
|
+
.setSmallImage(new LocalImage(path.join(__dirname, 'vscode.png')), "VS Code");
|
|
74
74
|
|
|
75
75
|
await client.rpc.build();
|
|
76
76
|
|
|
@@ -139,8 +139,11 @@ This is the main starting point.
|
|
|
139
139
|
- `new Client(options)`: Create a new instance.
|
|
140
140
|
- `options.token` (required): Your Discord user token.
|
|
141
141
|
- `options.apiBaseUrl` (optional): Override the default image proxy service URL.
|
|
142
|
+
- `options.alwaysReconnect` (optional): If `true`, the client will attempt to reconnect even after a normal close (e.g., from `client.close()` or a Discord-initiated close). Defaults to `false`.
|
|
142
143
|
- `client.run()`: Start connecting to Discord Gateway.
|
|
143
144
|
- `client.rpc`: Access the instance of `HieuxyzRPC` to build the state.
|
|
145
|
+
- `client.close(force?: boolean)`: Closes the connection to the Discord Gateway.
|
|
146
|
+
- `force` (optional, boolean): If set to `true`, the client will close permanently and will not attempt to reconnect, overriding the `alwaysReconnect` option. Defaults to `false`.
|
|
144
147
|
|
|
145
148
|
### Class `HieuxyzRPC`
|
|
146
149
|
|
package/dist/hieuxyz/Client.d.ts
CHANGED
|
@@ -7,12 +7,20 @@ export interface ClientOptions {
|
|
|
7
7
|
token: string;
|
|
8
8
|
/** (Optional) Base URL of the image proxy service. */
|
|
9
9
|
apiBaseUrl?: string;
|
|
10
|
+
/**
|
|
11
|
+
* (Optional) If true, the client will attempt to reconnect even after a normal close (code 1000).
|
|
12
|
+
* Defaults to false.
|
|
13
|
+
*/
|
|
14
|
+
alwaysReconnect?: boolean;
|
|
10
15
|
}
|
|
11
16
|
/**
|
|
12
17
|
* The main Client class for interacting with Discord Rich Presence.
|
|
13
18
|
* This is the starting point for creating and managing your RPC state.
|
|
14
19
|
* @example
|
|
15
|
-
* const client = new Client({
|
|
20
|
+
* const client = new Client({
|
|
21
|
+
* token: "YOUR_DISCORD_TOKEN",
|
|
22
|
+
* alwaysReconnect: true // Keep the RPC alive no matter what
|
|
23
|
+
* });
|
|
16
24
|
* await client.run();
|
|
17
25
|
* client.rpc.setName("Visual Studio Code");
|
|
18
26
|
* await client.rpc.build();
|
|
@@ -40,7 +48,8 @@ export declare class Client {
|
|
|
40
48
|
run(): Promise<void>;
|
|
41
49
|
/**
|
|
42
50
|
* Close the connection to Discord Gateway.
|
|
43
|
-
*
|
|
51
|
+
* @param {boolean} [force=false] - If true, the client will close permanently and will not attempt to reconnect,
|
|
52
|
+
* even if `alwaysReconnect` is enabled. Defaults to false.
|
|
44
53
|
*/
|
|
45
|
-
close(): void;
|
|
54
|
+
close(force?: boolean): void;
|
|
46
55
|
}
|
package/dist/hieuxyz/Client.js
CHANGED
|
@@ -9,7 +9,10 @@ const logger_1 = require("./utils/logger");
|
|
|
9
9
|
* The main Client class for interacting with Discord Rich Presence.
|
|
10
10
|
* This is the starting point for creating and managing your RPC state.
|
|
11
11
|
* @example
|
|
12
|
-
* const client = new Client({
|
|
12
|
+
* const client = new Client({
|
|
13
|
+
* token: "YOUR_DISCORD_TOKEN",
|
|
14
|
+
* alwaysReconnect: true // Keep the RPC alive no matter what
|
|
15
|
+
* });
|
|
13
16
|
* await client.run();
|
|
14
17
|
* client.rpc.setName("Visual Studio Code");
|
|
15
18
|
* await client.rpc.build();
|
|
@@ -34,7 +37,9 @@ class Client {
|
|
|
34
37
|
}
|
|
35
38
|
this.token = options.token;
|
|
36
39
|
this.imageService = new ImageService_1.ImageService(options.apiBaseUrl);
|
|
37
|
-
this.websocket = new DiscordWebSocket_1.DiscordWebSocket(this.token
|
|
40
|
+
this.websocket = new DiscordWebSocket_1.DiscordWebSocket(this.token, {
|
|
41
|
+
alwaysReconnect: options.alwaysReconnect ?? false,
|
|
42
|
+
});
|
|
38
43
|
this.rpc = new HieuxyzRPC_1.HieuxyzRPC(this.websocket, this.imageService);
|
|
39
44
|
}
|
|
40
45
|
/**
|
|
@@ -50,10 +55,12 @@ class Client {
|
|
|
50
55
|
}
|
|
51
56
|
/**
|
|
52
57
|
* Close the connection to Discord Gateway.
|
|
53
|
-
*
|
|
58
|
+
* @param {boolean} [force=false] - If true, the client will close permanently and will not attempt to reconnect,
|
|
59
|
+
* even if `alwaysReconnect` is enabled. Defaults to false.
|
|
54
60
|
*/
|
|
55
|
-
close() {
|
|
56
|
-
this.
|
|
61
|
+
close(force = false) {
|
|
62
|
+
this.rpc.stopBackgroundRenewal();
|
|
63
|
+
this.websocket.close(force);
|
|
57
64
|
}
|
|
58
65
|
}
|
|
59
66
|
exports.Client = Client;
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { PresenceUpdatePayload } from './entities/types';
|
|
2
|
+
interface DiscordWebSocketOptions {
|
|
3
|
+
alwaysReconnect: boolean;
|
|
4
|
+
}
|
|
2
5
|
/**
|
|
3
6
|
* Manage WebSocket connections to Discord Gateway.
|
|
4
7
|
* Handles low-level operations like heartbeating, identifying, and resuming.
|
|
@@ -8,8 +11,12 @@ export declare class DiscordWebSocket {
|
|
|
8
11
|
private ws;
|
|
9
12
|
private sequence;
|
|
10
13
|
private heartbeatInterval;
|
|
14
|
+
private heartbeatIntervalValue;
|
|
11
15
|
private sessionId;
|
|
12
16
|
private resumeGatewayUrl;
|
|
17
|
+
private options;
|
|
18
|
+
private isReconnecting;
|
|
19
|
+
private permanentClose;
|
|
13
20
|
private resolveReady;
|
|
14
21
|
/**
|
|
15
22
|
* A promise will be resolved when the Gateway connection is ready.
|
|
@@ -19,9 +26,11 @@ export declare class DiscordWebSocket {
|
|
|
19
26
|
/**
|
|
20
27
|
* Create a DiscordWebSocket instance.
|
|
21
28
|
* @param token - Discord user token for authentication.
|
|
29
|
+
* @param options - Configuration options for the WebSocket client.
|
|
22
30
|
* @throws {Error} If the token is invalid.
|
|
23
31
|
*/
|
|
24
|
-
constructor(token: string);
|
|
32
|
+
constructor(token: string, options: DiscordWebSocketOptions);
|
|
33
|
+
private resetReadyPromise;
|
|
25
34
|
private isTokenValid;
|
|
26
35
|
/**
|
|
27
36
|
* Initiate connection to Discord Gateway.
|
|
@@ -30,6 +39,7 @@ export declare class DiscordWebSocket {
|
|
|
30
39
|
connect(): void;
|
|
31
40
|
private onMessage;
|
|
32
41
|
private startHeartbeating;
|
|
42
|
+
private sendHeartbeat;
|
|
33
43
|
private identify;
|
|
34
44
|
private resume;
|
|
35
45
|
/**
|
|
@@ -39,9 +49,11 @@ export declare class DiscordWebSocket {
|
|
|
39
49
|
sendActivity(presence: PresenceUpdatePayload): void;
|
|
40
50
|
private sendJson;
|
|
41
51
|
/**
|
|
42
|
-
*
|
|
52
|
+
* Closes the WebSocket connection.
|
|
53
|
+
* @param force If true, prevents any automatic reconnection attempts.
|
|
43
54
|
*/
|
|
44
|
-
close(): void;
|
|
55
|
+
close(force?: boolean): void;
|
|
45
56
|
private cleanupHeartbeat;
|
|
46
57
|
private shouldReconnect;
|
|
47
58
|
}
|
|
59
|
+
export {};
|
|
@@ -17,24 +17,34 @@ class DiscordWebSocket {
|
|
|
17
17
|
ws = null;
|
|
18
18
|
sequence = null;
|
|
19
19
|
heartbeatInterval = null;
|
|
20
|
+
heartbeatIntervalValue = 0;
|
|
20
21
|
sessionId = null;
|
|
21
22
|
resumeGatewayUrl = null;
|
|
23
|
+
options;
|
|
24
|
+
isReconnecting = false;
|
|
25
|
+
permanentClose = false;
|
|
22
26
|
resolveReady = () => { };
|
|
23
27
|
/**
|
|
24
28
|
* A promise will be resolved when the Gateway connection is ready.
|
|
25
29
|
* and received the READY event.
|
|
26
30
|
*/
|
|
27
|
-
readyPromise
|
|
31
|
+
readyPromise;
|
|
28
32
|
/**
|
|
29
33
|
* Create a DiscordWebSocket instance.
|
|
30
34
|
* @param token - Discord user token for authentication.
|
|
35
|
+
* @param options - Configuration options for the WebSocket client.
|
|
31
36
|
* @throws {Error} If the token is invalid.
|
|
32
37
|
*/
|
|
33
|
-
constructor(token) {
|
|
38
|
+
constructor(token, options) {
|
|
34
39
|
if (!this.isTokenValid(token)) {
|
|
35
40
|
throw new Error("Invalid token provided.");
|
|
36
41
|
}
|
|
37
42
|
this.token = token;
|
|
43
|
+
this.options = options;
|
|
44
|
+
this.readyPromise = new Promise(resolve => (this.resolveReady = resolve));
|
|
45
|
+
}
|
|
46
|
+
resetReadyPromise() {
|
|
47
|
+
this.readyPromise = new Promise(resolve => (this.resolveReady = resolve));
|
|
38
48
|
}
|
|
39
49
|
isTokenValid(token) {
|
|
40
50
|
return /^[a-zA-Z0-9_-]{24}\.[a-zA-Z0-9_-]{6}\.[a-zA-Z0-9_-]{38}$/.test(token) || /^mfa\.[a-zA-Z0-9_-]{84}$/.test(token);
|
|
@@ -44,20 +54,43 @@ class DiscordWebSocket {
|
|
|
44
54
|
* If there was a previous session, it will try to resume.
|
|
45
55
|
*/
|
|
46
56
|
connect() {
|
|
57
|
+
if (this.isReconnecting) {
|
|
58
|
+
logger_1.logger.info("Connection attempt aborted: reconnection already in progress.");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
this.permanentClose = false;
|
|
62
|
+
this.isReconnecting = true;
|
|
63
|
+
this.resetReadyPromise();
|
|
47
64
|
const url = this.resumeGatewayUrl || "wss://gateway.discord.gg/?v=10&encoding=json";
|
|
65
|
+
logger_1.logger.info(`Attempting to connect to ${url}...`);
|
|
48
66
|
this.ws = new ws_1.default(url);
|
|
49
|
-
this.ws.on('open', () =>
|
|
67
|
+
this.ws.on('open', () => {
|
|
68
|
+
logger_1.logger.info(`Successfully connected to Discord Gateway at ${url}.`);
|
|
69
|
+
this.isReconnecting = false;
|
|
70
|
+
});
|
|
50
71
|
this.ws.on('message', this.onMessage.bind(this));
|
|
51
72
|
this.ws.on('close', (code, reason) => {
|
|
52
|
-
logger_1.logger.warn(`Connection closed: ${code} - ${reason.toString()}`);
|
|
73
|
+
logger_1.logger.warn(`Connection closed: ${code} - ${reason.toString('utf-8')}`);
|
|
53
74
|
this.cleanupHeartbeat();
|
|
75
|
+
if (this.permanentClose) {
|
|
76
|
+
logger_1.logger.info("Connection permanently closed by client. Not reconnecting.");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (this.isReconnecting)
|
|
80
|
+
return;
|
|
54
81
|
if (this.shouldReconnect(code)) {
|
|
55
|
-
|
|
56
|
-
|
|
82
|
+
setTimeout(() => {
|
|
83
|
+
const canResume = code !== 4004 && !!this.sessionId;
|
|
84
|
+
if (!canResume) {
|
|
85
|
+
this.sessionId = null;
|
|
86
|
+
this.sequence = null;
|
|
87
|
+
this.resumeGatewayUrl = null;
|
|
88
|
+
}
|
|
89
|
+
this.connect();
|
|
90
|
+
}, 500);
|
|
57
91
|
}
|
|
58
92
|
else {
|
|
59
|
-
|
|
60
|
-
this.resumeGatewayUrl = null;
|
|
93
|
+
logger_1.logger.info("Not attempting to reconnect based on close code and client options.");
|
|
61
94
|
}
|
|
62
95
|
});
|
|
63
96
|
this.ws.on('error', (err) => {
|
|
@@ -71,8 +104,10 @@ class DiscordWebSocket {
|
|
|
71
104
|
}
|
|
72
105
|
switch (payload.op) {
|
|
73
106
|
case OpCode_1.OpCode.HELLO:
|
|
74
|
-
this.
|
|
75
|
-
|
|
107
|
+
this.heartbeatIntervalValue = payload.d.heartbeat_interval;
|
|
108
|
+
logger_1.logger.info(`Received HELLO. Setting heartbeat interval to ${this.heartbeatIntervalValue}ms.`);
|
|
109
|
+
this.startHeartbeating();
|
|
110
|
+
if (this.sessionId && this.sequence) {
|
|
76
111
|
this.resume();
|
|
77
112
|
}
|
|
78
113
|
else {
|
|
@@ -82,8 +117,8 @@ class DiscordWebSocket {
|
|
|
82
117
|
case OpCode_1.OpCode.DISPATCH:
|
|
83
118
|
if (payload.t === 'READY') {
|
|
84
119
|
this.sessionId = payload.d.session_id;
|
|
85
|
-
this.resumeGatewayUrl = payload.d.resume_gateway_url;
|
|
86
|
-
logger_1.logger.info(`Session
|
|
120
|
+
this.resumeGatewayUrl = payload.d.resume_gateway_url + "/?v=10&encoding=json";
|
|
121
|
+
logger_1.logger.info(`Session READY. Session ID: ${this.sessionId}. Resume URL set.`);
|
|
87
122
|
this.resolveReady();
|
|
88
123
|
}
|
|
89
124
|
else if (payload.t === 'RESUMED') {
|
|
@@ -95,25 +130,43 @@ class DiscordWebSocket {
|
|
|
95
130
|
logger_1.logger.info("Heartbeat acknowledged.");
|
|
96
131
|
break;
|
|
97
132
|
case OpCode_1.OpCode.INVALID_SESSION:
|
|
98
|
-
logger_1.logger.warn(
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
133
|
+
logger_1.logger.warn(`Received INVALID_SESSION. Resumable: ${payload.d}`);
|
|
134
|
+
if (payload.d) {
|
|
135
|
+
this.ws?.close(4000, "Invalid session, attempting to resume.");
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
this.ws?.close(4004, "Invalid session, starting a new session.");
|
|
139
|
+
}
|
|
102
140
|
break;
|
|
103
141
|
case OpCode_1.OpCode.RECONNECT:
|
|
104
|
-
logger_1.logger.info("Gateway requested
|
|
105
|
-
this.ws?.close(4000, "
|
|
142
|
+
logger_1.logger.info("Gateway requested RECONNECT. Closing to reconnect and resume.");
|
|
143
|
+
this.ws?.close(4000, "Gateway requested reconnect.");
|
|
106
144
|
break;
|
|
107
145
|
default:
|
|
108
146
|
break;
|
|
109
147
|
}
|
|
110
148
|
}
|
|
111
|
-
startHeartbeating(
|
|
149
|
+
startHeartbeating() {
|
|
112
150
|
this.cleanupHeartbeat();
|
|
113
|
-
|
|
114
|
-
this.
|
|
115
|
-
|
|
116
|
-
|
|
151
|
+
setTimeout(() => {
|
|
152
|
+
if (this.ws?.readyState === ws_1.default.OPEN) {
|
|
153
|
+
this.sendHeartbeat();
|
|
154
|
+
}
|
|
155
|
+
this.heartbeatInterval = setInterval(() => {
|
|
156
|
+
if (this.ws?.readyState !== ws_1.default.OPEN) {
|
|
157
|
+
logger_1.logger.warn("Heartbeat skipped: WebSocket is not open.");
|
|
158
|
+
this.cleanupHeartbeat();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
this.sendHeartbeat();
|
|
162
|
+
}, this.heartbeatIntervalValue);
|
|
163
|
+
}, this.heartbeatIntervalValue * Math.random());
|
|
164
|
+
}
|
|
165
|
+
sendHeartbeat() {
|
|
166
|
+
if (this.ws?.readyState !== ws_1.default.OPEN)
|
|
167
|
+
return;
|
|
168
|
+
this.sendJson({ op: OpCode_1.OpCode.HEARTBEAT, d: this.sequence });
|
|
169
|
+
logger_1.logger.info(`Heartbeat sent with sequence ${this.sequence}.`);
|
|
117
170
|
}
|
|
118
171
|
identify() {
|
|
119
172
|
const identifyPayload = (0, identify_1.getIdentifyPayload)(this.token);
|
|
@@ -121,6 +174,11 @@ class DiscordWebSocket {
|
|
|
121
174
|
logger_1.logger.info("Identify payload sent.");
|
|
122
175
|
}
|
|
123
176
|
resume() {
|
|
177
|
+
if (!this.sessionId || this.sequence === null) {
|
|
178
|
+
logger_1.logger.error("Attempted to resume without session ID or sequence. Falling back to identify.");
|
|
179
|
+
this.identify();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
124
182
|
const resumePayload = {
|
|
125
183
|
token: this.token,
|
|
126
184
|
session_id: this.sessionId,
|
|
@@ -146,15 +204,21 @@ class DiscordWebSocket {
|
|
|
146
204
|
}
|
|
147
205
|
}
|
|
148
206
|
/**
|
|
149
|
-
*
|
|
207
|
+
* Closes the WebSocket connection.
|
|
208
|
+
* @param force If true, prevents any automatic reconnection attempts.
|
|
150
209
|
*/
|
|
151
|
-
close() {
|
|
210
|
+
close(force = false) {
|
|
211
|
+
if (force) {
|
|
212
|
+
logger_1.logger.info("Forcing permanent closure. Reconnects will be disabled.");
|
|
213
|
+
this.permanentClose = true;
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
logger_1.logger.info("Closing connection manually...");
|
|
217
|
+
}
|
|
218
|
+
this.isReconnecting = false;
|
|
152
219
|
if (this.ws) {
|
|
153
|
-
this.ws.close(1000, "Client
|
|
220
|
+
this.ws.close(1000, "Client initiated closure");
|
|
154
221
|
}
|
|
155
|
-
this.cleanupHeartbeat();
|
|
156
|
-
this.sessionId = null;
|
|
157
|
-
this.resumeGatewayUrl = null;
|
|
158
222
|
}
|
|
159
223
|
cleanupHeartbeat() {
|
|
160
224
|
if (this.heartbeatInterval) {
|
|
@@ -163,7 +227,15 @@ class DiscordWebSocket {
|
|
|
163
227
|
}
|
|
164
228
|
}
|
|
165
229
|
shouldReconnect(code) {
|
|
166
|
-
|
|
230
|
+
const fatalErrorCodes = [4010, 4011, 4013, 4014];
|
|
231
|
+
if (fatalErrorCodes.includes(code)) {
|
|
232
|
+
logger_1.logger.error(`Fatal WebSocket error received (code: ${code}). Will not reconnect.`);
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
if (this.options.alwaysReconnect) {
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
return code !== 1000;
|
|
167
239
|
}
|
|
168
240
|
}
|
|
169
241
|
exports.DiscordWebSocket = DiscordWebSocket;
|
|
@@ -18,7 +18,13 @@ export declare class HieuxyzRPC {
|
|
|
18
18
|
private status;
|
|
19
19
|
private applicationId;
|
|
20
20
|
private platform;
|
|
21
|
+
/**
|
|
22
|
+
* Cache for resolved image assets to avoid re-uploading or re-fetching.
|
|
23
|
+
* Key: A unique string from RpcImage.getCacheKey().
|
|
24
|
+
* Value: The resolved asset key (e.g., "mp:attachments/...").
|
|
25
|
+
*/
|
|
21
26
|
private resolvedAssetsCache;
|
|
27
|
+
private renewalInterval;
|
|
22
28
|
constructor(websocket: DiscordWebSocket, imageService: ImageService);
|
|
23
29
|
private _toRpcImage;
|
|
24
30
|
private sanitize;
|
|
@@ -99,6 +105,14 @@ export declare class HieuxyzRPC {
|
|
|
99
105
|
* @returns {this}
|
|
100
106
|
*/
|
|
101
107
|
setPlatform(platform: DiscordPlatform): this;
|
|
108
|
+
private getExpiryTime;
|
|
109
|
+
private renewAssetIfNeeded;
|
|
110
|
+
private startBackgroundRenewal;
|
|
111
|
+
/**
|
|
112
|
+
* Stops the background process that checks for asset renewal.
|
|
113
|
+
*/
|
|
114
|
+
stopBackgroundRenewal(): void;
|
|
115
|
+
private resolveImage;
|
|
102
116
|
private buildActivity;
|
|
103
117
|
/**
|
|
104
118
|
* Build the final Rich Presence payload and send it to Discord.
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.HieuxyzRPC = void 0;
|
|
4
4
|
const types_1 = require("../gateway/entities/types");
|
|
5
5
|
const RpcImage_1 = require("./RpcImage");
|
|
6
|
+
const logger_1 = require("../utils/logger");
|
|
6
7
|
/**
|
|
7
8
|
* Class built for creating and managing Discord Rich Presence states.
|
|
8
9
|
*/
|
|
@@ -14,10 +15,17 @@ class HieuxyzRPC {
|
|
|
14
15
|
status = 'online';
|
|
15
16
|
applicationId = '1416676323459469363'; // Default ID, can be changed
|
|
16
17
|
platform = 'desktop';
|
|
17
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Cache for resolved image assets to avoid re-uploading or re-fetching.
|
|
20
|
+
* Key: A unique string from RpcImage.getCacheKey().
|
|
21
|
+
* Value: The resolved asset key (e.g., "mp:attachments/...").
|
|
22
|
+
*/
|
|
23
|
+
resolvedAssetsCache = new Map();
|
|
24
|
+
renewalInterval = null;
|
|
18
25
|
constructor(websocket, imageService) {
|
|
19
26
|
this.websocket = websocket;
|
|
20
27
|
this.imageService = imageService;
|
|
28
|
+
this.startBackgroundRenewal();
|
|
21
29
|
}
|
|
22
30
|
_toRpcImage(source) {
|
|
23
31
|
if (typeof source !== 'string') {
|
|
@@ -35,7 +43,7 @@ class HieuxyzRPC {
|
|
|
35
43
|
}
|
|
36
44
|
}
|
|
37
45
|
catch (e) {
|
|
38
|
-
|
|
46
|
+
logger_1.logger.warn(`Could not parse "${source}" into a valid URL. Treating as RawImage.`);
|
|
39
47
|
return new RpcImage_1.RawImage(source);
|
|
40
48
|
}
|
|
41
49
|
}
|
|
@@ -126,7 +134,6 @@ class HieuxyzRPC {
|
|
|
126
134
|
this.assets.large_image = this._toRpcImage(source);
|
|
127
135
|
if (text)
|
|
128
136
|
this.assets.large_text = this.sanitize(text);
|
|
129
|
-
delete this.resolvedAssetsCache.large_image;
|
|
130
137
|
return this;
|
|
131
138
|
}
|
|
132
139
|
/**
|
|
@@ -139,7 +146,6 @@ class HieuxyzRPC {
|
|
|
139
146
|
this.assets.small_image = this._toRpcImage(source);
|
|
140
147
|
if (text)
|
|
141
148
|
this.assets.small_text = this.sanitize(text);
|
|
142
|
-
delete this.resolvedAssetsCache.small_image;
|
|
143
149
|
return this;
|
|
144
150
|
}
|
|
145
151
|
/**
|
|
@@ -184,29 +190,88 @@ class HieuxyzRPC {
|
|
|
184
190
|
this.platform = platform;
|
|
185
191
|
return this;
|
|
186
192
|
}
|
|
187
|
-
|
|
188
|
-
if (
|
|
189
|
-
|
|
193
|
+
getExpiryTime(assetKey) {
|
|
194
|
+
if (!assetKey.startsWith('mp:attachments'))
|
|
195
|
+
return null;
|
|
196
|
+
const urlPart = assetKey.substring(3);
|
|
197
|
+
try {
|
|
198
|
+
const parsedUrl = new URL(`https://cdn.discordapp.com/${urlPart}`);
|
|
199
|
+
const expiresTimestamp = parsedUrl.searchParams.get('ex');
|
|
200
|
+
if (expiresTimestamp) {
|
|
201
|
+
return parseInt(expiresTimestamp, 16) * 1000;
|
|
202
|
+
}
|
|
190
203
|
}
|
|
191
|
-
|
|
192
|
-
|
|
204
|
+
catch (e) {
|
|
205
|
+
logger_1.logger.error(`Could not parse asset URL for expiry check: ${assetKey}`);
|
|
193
206
|
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
async renewAssetIfNeeded(cacheKey, assetKey) {
|
|
210
|
+
const expiryTimeMs = this.getExpiryTime(assetKey);
|
|
211
|
+
if (expiryTimeMs && expiryTimeMs < (Date.now() + 3600000)) {
|
|
212
|
+
logger_1.logger.info(`Asset ${cacheKey} is expiring soon. Renewing...`);
|
|
213
|
+
const assetId = assetKey.split('mp:attachments/')[1];
|
|
214
|
+
const newAsset = await this.imageService.renewImage(assetId);
|
|
215
|
+
if (newAsset) {
|
|
216
|
+
this.resolvedAssetsCache.set(cacheKey, newAsset);
|
|
217
|
+
return newAsset;
|
|
218
|
+
}
|
|
219
|
+
logger_1.logger.warn(`Failed to renew asset, will use the old one.`);
|
|
220
|
+
}
|
|
221
|
+
return assetKey;
|
|
222
|
+
}
|
|
223
|
+
startBackgroundRenewal() {
|
|
224
|
+
if (this.renewalInterval) {
|
|
225
|
+
clearInterval(this.renewalInterval);
|
|
226
|
+
}
|
|
227
|
+
this.renewalInterval = setInterval(async () => {
|
|
228
|
+
logger_1.logger.info("Running background asset renewal check...");
|
|
229
|
+
for (const [cacheKey, assetKey] of this.resolvedAssetsCache.entries()) {
|
|
230
|
+
await this.renewAssetIfNeeded(cacheKey, assetKey);
|
|
231
|
+
}
|
|
232
|
+
}, 600000);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Stops the background process that checks for asset renewal.
|
|
236
|
+
*/
|
|
237
|
+
stopBackgroundRenewal() {
|
|
238
|
+
if (this.renewalInterval) {
|
|
239
|
+
clearInterval(this.renewalInterval);
|
|
240
|
+
this.renewalInterval = null;
|
|
241
|
+
logger_1.logger.info("Stopped background asset renewal process.");
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
async resolveImage(image) {
|
|
245
|
+
if (!image)
|
|
246
|
+
return undefined;
|
|
247
|
+
const cacheKey = image.getCacheKey();
|
|
248
|
+
let cachedAsset = this.resolvedAssetsCache.get(cacheKey);
|
|
249
|
+
if (cachedAsset) {
|
|
250
|
+
return await this.renewAssetIfNeeded(cacheKey, cachedAsset);
|
|
251
|
+
}
|
|
252
|
+
const resolvedAsset = await image.resolve(this.imageService);
|
|
253
|
+
if (resolvedAsset) {
|
|
254
|
+
this.resolvedAssetsCache.set(cacheKey, resolvedAsset);
|
|
255
|
+
}
|
|
256
|
+
return resolvedAsset;
|
|
257
|
+
}
|
|
258
|
+
async buildActivity() {
|
|
259
|
+
const large_image = await this.resolveImage(this.assets.large_image);
|
|
260
|
+
const small_image = await this.resolveImage(this.assets.small_image);
|
|
194
261
|
const finalAssets = {
|
|
195
262
|
large_text: this.assets.large_text,
|
|
196
263
|
small_text: this.assets.small_text,
|
|
197
264
|
};
|
|
198
|
-
if (
|
|
199
|
-
finalAssets.large_image =
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
finalAssets.small_image = this.resolvedAssetsCache.small_image;
|
|
203
|
-
}
|
|
265
|
+
if (large_image)
|
|
266
|
+
finalAssets.large_image = large_image;
|
|
267
|
+
if (small_image)
|
|
268
|
+
finalAssets.small_image = small_image;
|
|
204
269
|
const finalActivity = { ...this.activity };
|
|
205
|
-
finalActivity.assets = finalAssets;
|
|
270
|
+
finalActivity.assets = (large_image || small_image) ? finalAssets : undefined;
|
|
206
271
|
finalActivity.application_id = this.applicationId;
|
|
207
272
|
finalActivity.platform = this.platform;
|
|
208
273
|
if (!finalActivity.name) {
|
|
209
|
-
finalActivity.name = "
|
|
274
|
+
finalActivity.name = "hieuxyzRPC";
|
|
210
275
|
}
|
|
211
276
|
if (typeof finalActivity.type === 'undefined') {
|
|
212
277
|
finalActivity.type = types_1.ActivityType.Playing;
|
|
@@ -223,7 +288,7 @@ class HieuxyzRPC {
|
|
|
223
288
|
since: 0,
|
|
224
289
|
activities: [activity],
|
|
225
290
|
status: this.status,
|
|
226
|
-
afk:
|
|
291
|
+
afk: true,
|
|
227
292
|
};
|
|
228
293
|
this.websocket.sendActivity(presencePayload);
|
|
229
294
|
}
|
|
@@ -22,4 +22,10 @@ export declare class ImageService {
|
|
|
22
22
|
* @returns {Promise<string | undefined>} Asset key resolved or undefined if failed.
|
|
23
23
|
*/
|
|
24
24
|
uploadImage(filePath: string, fileName: string): Promise<string | undefined>;
|
|
25
|
+
/**
|
|
26
|
+
* Requests a new signed URL for an expired or expiring attachment asset.
|
|
27
|
+
* @param assetId The asset ID part of the URL (e.g., "channel_id/message_id/filename.ext...")
|
|
28
|
+
* @returns {Promise<string | undefined>} The new asset key or undefined if it failed.
|
|
29
|
+
*/
|
|
30
|
+
renewImage(assetId: string): Promise<string | undefined>;
|
|
25
31
|
}
|
|
@@ -102,5 +102,24 @@ class ImageService {
|
|
|
102
102
|
}
|
|
103
103
|
return undefined;
|
|
104
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Requests a new signed URL for an expired or expiring attachment asset.
|
|
107
|
+
* @param assetId The asset ID part of the URL (e.g., "channel_id/message_id/filename.ext...")
|
|
108
|
+
* @returns {Promise<string | undefined>} The new asset key or undefined if it failed.
|
|
109
|
+
*/
|
|
110
|
+
async renewImage(assetId) {
|
|
111
|
+
try {
|
|
112
|
+
const response = await this.apiClient.post('/renew', { asset_id: assetId });
|
|
113
|
+
if (response.data && response.data.id) {
|
|
114
|
+
logger_1.logger.info(`Successfully renewed asset: ${assetId}`);
|
|
115
|
+
return response.data.id;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
const err = error;
|
|
120
|
+
logger_1.logger.error(`Failed to renew asset ${assetId}: ${err.response?.data || err.message}`);
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
105
124
|
}
|
|
106
125
|
exports.ImageService = ImageService;
|
|
@@ -9,6 +9,11 @@ export declare abstract class RpcImage {
|
|
|
9
9
|
* @returns {Promise<string | undefined>} Asset key has been resolved.
|
|
10
10
|
*/
|
|
11
11
|
abstract resolve(imageService: ImageService): Promise<string | undefined>;
|
|
12
|
+
/**
|
|
13
|
+
* Gets a unique key for this image instance, used for caching.
|
|
14
|
+
* @returns {string} A unique identifier for the image source.
|
|
15
|
+
*/
|
|
16
|
+
abstract getCacheKey(): string;
|
|
12
17
|
}
|
|
13
18
|
/**
|
|
14
19
|
* Represents an image that already exists on Discord's servers (e.g., via proxy or previous upload).
|
|
@@ -17,6 +22,7 @@ export declare class DiscordImage extends RpcImage {
|
|
|
17
22
|
private imageKey;
|
|
18
23
|
constructor(imageKey: string);
|
|
19
24
|
resolve(): Promise<string | undefined>;
|
|
25
|
+
getCacheKey(): string;
|
|
20
26
|
}
|
|
21
27
|
/**
|
|
22
28
|
* Represents an image from an external URL.
|
|
@@ -25,6 +31,7 @@ export declare class ExternalImage extends RpcImage {
|
|
|
25
31
|
private url;
|
|
26
32
|
constructor(url: string);
|
|
27
33
|
resolve(imageService: ImageService): Promise<string | undefined>;
|
|
34
|
+
getCacheKey(): string;
|
|
28
35
|
}
|
|
29
36
|
/**
|
|
30
37
|
* Represents an image from the local file system.
|
|
@@ -35,6 +42,7 @@ export declare class LocalImage extends RpcImage {
|
|
|
35
42
|
private fileName;
|
|
36
43
|
constructor(filePath: string, fileName?: string);
|
|
37
44
|
resolve(imageService: ImageService): Promise<string | undefined>;
|
|
45
|
+
getCacheKey(): string;
|
|
38
46
|
}
|
|
39
47
|
/**
|
|
40
48
|
* Represents a resolved raw asset key.
|
|
@@ -44,4 +52,5 @@ export declare class RawImage extends RpcImage {
|
|
|
44
52
|
private assetKey;
|
|
45
53
|
constructor(assetKey: string);
|
|
46
54
|
resolve(imageService: ImageService): Promise<string | undefined>;
|
|
55
|
+
getCacheKey(): string;
|
|
47
56
|
}
|
|
@@ -53,6 +53,9 @@ class DiscordImage extends RpcImage {
|
|
|
53
53
|
async resolve() {
|
|
54
54
|
return this.imageKey.startsWith('mp:') ? this.imageKey : `mp:${this.imageKey}`;
|
|
55
55
|
}
|
|
56
|
+
getCacheKey() {
|
|
57
|
+
return `discord:${this.imageKey}`;
|
|
58
|
+
}
|
|
56
59
|
}
|
|
57
60
|
exports.DiscordImage = DiscordImage;
|
|
58
61
|
/**
|
|
@@ -67,6 +70,9 @@ class ExternalImage extends RpcImage {
|
|
|
67
70
|
async resolve(imageService) {
|
|
68
71
|
return imageService.getExternalUrl(this.url);
|
|
69
72
|
}
|
|
73
|
+
getCacheKey() {
|
|
74
|
+
return `external:${this.url}`;
|
|
75
|
+
}
|
|
70
76
|
}
|
|
71
77
|
exports.ExternalImage = ExternalImage;
|
|
72
78
|
/**
|
|
@@ -84,6 +90,9 @@ class LocalImage extends RpcImage {
|
|
|
84
90
|
async resolve(imageService) {
|
|
85
91
|
return imageService.uploadImage(this.filePath, this.fileName);
|
|
86
92
|
}
|
|
93
|
+
getCacheKey() {
|
|
94
|
+
return `local:${this.filePath}`;
|
|
95
|
+
}
|
|
87
96
|
}
|
|
88
97
|
exports.LocalImage = LocalImage;
|
|
89
98
|
/**
|
|
@@ -99,5 +108,8 @@ class RawImage extends RpcImage {
|
|
|
99
108
|
async resolve(imageService) {
|
|
100
109
|
return this.assetKey;
|
|
101
110
|
}
|
|
111
|
+
getCacheKey() {
|
|
112
|
+
return `raw:${this.assetKey}`;
|
|
113
|
+
}
|
|
102
114
|
}
|
|
103
115
|
exports.RawImage = RawImage;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hieuxyz/rpc",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
4
4
|
"description": "A Discord Rich Presence library for Node.js",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"author": "hieuxyz",
|
|
22
22
|
"license": "ISC",
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"axios": "^1.
|
|
24
|
+
"axios": "^1.12.2",
|
|
25
25
|
"ws": "^8.18.3"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|