@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 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, RawImage, LocalImage, logger } from '@hieuxyz/rpc';
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({ token });
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(new RawImage("mp:external/b7uybXM7LoJRB6_ig-65aX6dCHm2qGCEe8CiS5j7c2M/https/cdn.worldvectorlogo.com/logos/typescript.svg"), "TypeScript")
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
 
@@ -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({ token: "YOUR_DISCORD_TOKEN" });
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
- * Terminate RPC and clean up resources.
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
  }
@@ -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({ token: "YOUR_DISCORD_TOKEN" });
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
- * Terminate RPC and clean up resources.
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.websocket.close();
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
- * Close the WebSocket connection and clean up the resources.
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 = new Promise(resolve => (this.resolveReady = resolve));
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', () => logger_1.logger.info(`Connected to Discord Gateway at ${url}.`));
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
- logger_1.logger.info("Trying to reconnect...");
56
- setTimeout(() => this.connect(), 350);
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
- this.sessionId = null;
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.startHeartbeating(payload.d.heartbeat_interval);
75
- if (this.sessionId) {
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 is READY. Session ID: ${this.sessionId}`);
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("Invalid session. Re-identifying after 3 seconds.");
99
- this.sessionId = null;
100
- this.resumeGatewayUrl = null;
101
- setTimeout(() => this.connect(), 3000);
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 reconnect. Closing and reconnecting.");
105
- this.ws?.close(4000, "Reconnect request");
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(interval) {
149
+ startHeartbeating() {
112
150
  this.cleanupHeartbeat();
113
- this.heartbeatInterval = setInterval(() => {
114
- this.sendJson({ op: OpCode_1.OpCode.HEARTBEAT, d: this.sequence });
115
- logger_1.logger.info(`Heartbeat sent with sequence ${this.sequence}.`);
116
- }, interval);
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
- * Close the WebSocket connection and clean up the resources.
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 closed connection");
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
- return code !== 1000 && code !== 4004;
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
- resolvedAssetsCache = {};
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
- console.warn(`Could not parse "${source}" into a valid URL. Treating as RawImage.`);
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
- async buildActivity() {
188
- if (this.assets.large_image && !this.resolvedAssetsCache.large_image) {
189
- this.resolvedAssetsCache.large_image = await this.assets.large_image.resolve(this.imageService);
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
- if (this.assets.small_image && !this.resolvedAssetsCache.small_image) {
192
- this.resolvedAssetsCache.small_image = await this.assets.small_image.resolve(this.imageService);
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 (this.resolvedAssetsCache.large_image) {
199
- finalAssets.large_image = this.resolvedAssetsCache.large_image;
200
- }
201
- if (this.resolvedAssetsCache.small_image) {
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 = "Custom Status";
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: false,
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.6",
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.7.2",
24
+ "axios": "^1.12.2",
25
25
  "ws": "^8.18.3"
26
26
  },
27
27
  "devDependencies": {