Skip to content

Commit 4ee2610

Browse files
committed
feat: post random immediately on startup if no recent post
- Add lastRandomPost tracking to state (timestamp per channel) - Add getLastRandomPost, setLastRandomPost, shouldPostRandom methods - On startup, check if interval has elapsed since last post - If no last post or interval elapsed, post immediately - Then start the recurring interval Fixes issue where random posts only started after first interval.
1 parent d01562f commit 4ee2610

File tree

3 files changed

+208
-42
lines changed

3 files changed

+208
-42
lines changed

src/index.ts

Lines changed: 70 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -361,15 +361,66 @@ const getNextCollection = (
361361
return collection;
362362
};
363363

364+
/**
365+
* Post a random NFT to a channel
366+
*/
367+
const postRandomToChannel = async (
368+
channel: TextBasedChannel,
369+
channelId: string,
370+
chanName: string,
371+
targetCollections: CollectionConfig[]
372+
): Promise<void> => {
373+
const stateManager = getStateManager();
374+
const userLog: Log = [];
375+
const startTime = Date.now();
376+
377+
// Get next collection in rotation
378+
const collection = getNextCollection(channelId, targetCollections);
379+
const tokenId = getUniqueRandomToken(collection, channelId);
380+
381+
const prefix = collection.prefix ? `${collection.prefix}#` : "#";
382+
log.debug(
383+
`Random posting to #${chanName}, ${collection.name} ${prefix}${tokenId}`
384+
);
385+
386+
const embed = await buildEmbed(collection, tokenId, userLog);
387+
388+
if (embed) {
389+
// Track this token and timestamp
390+
stateManager.addRecentToken(channelId, tokenId);
391+
stateManager.setLastRandomPost(channelId);
392+
await stateManager.save();
393+
394+
userLog.push(
395+
`Sending random ${collection.name} ${prefix}${tokenId} to #${chanName}`
396+
);
397+
await sendEmbed(channel, embed);
398+
399+
const duration = Date.now() - startTime;
400+
log.info(
401+
`Sent random ${collection.name} ${prefix}${tokenId} to #${chanName} (${duration}ms)`
402+
);
403+
}
404+
405+
if (userLog.length > 0) {
406+
userLog.push(SEPARATOR);
407+
for (const line of userLog) {
408+
logger.info(line);
409+
}
410+
}
411+
};
412+
364413
/**
365414
* Set up random interval posting for collections
366415
*
367416
* Format: CHANNEL_ID=minutes[:collection_option]
368417
* Examples:
369-
* - 123456=30 (default collection every 30 min)
370-
* - 123456=30:* (rotate all collections every 30 min)
418+
* - 123456=30 (rotate all collections every 30 min)
419+
* - 123456=30:* (rotate all collections every 30 min, explicit)
371420
* - 123456=30:artifacts (artifacts collection every 30 min)
372-
* - 123456=30:default+artifacts (rotate between default and artifacts)
421+
* - 123456=30:bots+artifacts (rotate between bots and artifacts)
422+
*
423+
* On startup, posts immediately if no previous post exists or if interval has elapsed.
373424
*/
374425
const setupRandomIntervals = async (client: Client): Promise<void> => {
375426
if (!RANDOM_INTERVALS) {
@@ -401,47 +452,24 @@ const setupRandomIntervals = async (client: Client): Promise<void> => {
401452
continue;
402453
}
403454
const chanName = getChannelName(channel);
455+
const intervalMs = minutes * SECONDS_PER_MINUTE * ONE_SECOND_MS;
456+
457+
// Post immediately on startup if no previous post or interval has elapsed
458+
if (stateManager.shouldPostRandom(channelId, intervalMs)) {
459+
log.info(`Posting initial random to #${chanName}`);
460+
await postRandomToChannel(
461+
channel,
462+
channelId,
463+
chanName,
464+
targetCollections
465+
);
466+
}
404467

468+
// Set up recurring interval
405469
setInterval(
406-
async () => {
407-
const userLog: Log = [];
408-
const startTime = Date.now();
409-
410-
// Get next collection in rotation
411-
const collection = getNextCollection(channelId, targetCollections);
412-
const tokenId = getUniqueRandomToken(collection, channelId);
413-
414-
const prefix = collection.prefix ? `${collection.prefix}#` : "#";
415-
log.debug(
416-
`Random interval triggered for #${chanName}, ${collection.name} ${prefix}${tokenId}`
417-
);
418-
419-
const embed = await buildEmbed(collection, tokenId, userLog);
420-
421-
if (embed) {
422-
// Track this token as recently sent
423-
stateManager.addRecentToken(channelId, tokenId);
424-
await stateManager.save();
425-
426-
userLog.push(
427-
`Sending random ${collection.name} ${prefix}${tokenId} to #${chanName}`
428-
);
429-
await sendEmbed(channel, embed);
430-
431-
const duration = Date.now() - startTime;
432-
log.info(
433-
`Sent random ${collection.name} ${prefix}${tokenId} to #${chanName} (${duration}ms)`
434-
);
435-
}
436-
437-
if (userLog.length > 0) {
438-
userLog.push(SEPARATOR);
439-
for (const line of userLog) {
440-
logger.info(line);
441-
}
442-
}
443-
},
444-
minutes * SECONDS_PER_MINUTE * ONE_SECOND_MS
470+
() =>
471+
postRandomToChannel(channel, channelId, chanName, targetCollections),
472+
intervalMs
445473
);
446474
}
447475
};

src/state/state.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ type StateData = {
2323
updatedAt: string;
2424
/** Recent random tokens per channel (to avoid repeats) */
2525
recentTokens: Record<string, number[]>;
26+
/** Last random post timestamp per channel (ISO string) */
27+
lastRandomPost: Record<string, string>;
2628
/** Custom state data (extensible) */
2729
custom: Record<string, unknown>;
2830
};
@@ -32,6 +34,7 @@ const createDefaultState = (): StateData => ({
3234
version: 1,
3335
updatedAt: new Date().toISOString(),
3436
recentTokens: {},
37+
lastRandomPost: {},
3538
custom: {},
3639
});
3740

@@ -106,6 +109,9 @@ class StateManager {
106109
if (parsed.recentTokens !== undefined) {
107110
this.state.recentTokens = parsed.recentTokens;
108111
}
112+
if (parsed.lastRandomPost !== undefined) {
113+
this.state.lastRandomPost = parsed.lastRandomPost;
114+
}
109115
if (parsed.custom !== undefined) {
110116
this.state.custom = parsed.custom;
111117
}
@@ -198,6 +204,48 @@ class StateManager {
198204
}
199205
}
200206

207+
/**
208+
* Get the last random post timestamp for a channel
209+
* Returns undefined if no random post has been made
210+
*/
211+
getLastRandomPost(channelId: string): Date | undefined {
212+
const timestamp = this.state.lastRandomPost[channelId];
213+
if (timestamp) {
214+
return new Date(timestamp);
215+
}
216+
return;
217+
}
218+
219+
/**
220+
* Set the last random post timestamp for a channel
221+
*/
222+
setLastRandomPost(channelId: string, timestamp: Date = new Date()): void {
223+
this.state.lastRandomPost[channelId] = timestamp.toISOString();
224+
this.markDirty();
225+
log.debug(
226+
`Set last random post for channel ${channelId}: ${timestamp.toISOString()}`
227+
);
228+
}
229+
230+
/**
231+
* Check if enough time has passed since the last random post
232+
* Returns true if no last post exists or if intervalMs has passed
233+
*/
234+
shouldPostRandom(channelId: string, intervalMs: number): boolean {
235+
const lastPost = this.getLastRandomPost(channelId);
236+
if (!lastPost) {
237+
log.debug(`No last random post for channel ${channelId}, should post`);
238+
return true;
239+
}
240+
const elapsed = Date.now() - lastPost.getTime();
241+
const shouldPost = elapsed >= intervalMs;
242+
log.debug(
243+
`Last random post for channel ${channelId} was ${Math.round(elapsed / 1000)}s ago, ` +
244+
`interval is ${Math.round(intervalMs / 1000)}s, should post: ${shouldPost}`
245+
);
246+
return shouldPost;
247+
}
248+
201249
/**
202250
* Get a custom state value
203251
*/

test/state.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,96 @@ describe("StateManager", () => {
211211
});
212212
});
213213

214+
describe("lastRandomPost", () => {
215+
it("returns undefined when no last post exists", async () => {
216+
await manager.load();
217+
expect(manager.getLastRandomPost("channel1")).toBeUndefined();
218+
});
219+
220+
it("sets and gets last random post timestamp", async () => {
221+
await manager.load();
222+
const now = new Date();
223+
manager.setLastRandomPost("channel1", now);
224+
225+
const retrieved = manager.getLastRandomPost("channel1");
226+
expect(retrieved).toBeDefined();
227+
expect(retrieved?.getTime()).toBe(now.getTime());
228+
});
229+
230+
it("uses current time when no timestamp provided", async () => {
231+
await manager.load();
232+
const before = Date.now();
233+
manager.setLastRandomPost("channel1");
234+
const after = Date.now();
235+
236+
const retrieved = manager.getLastRandomPost("channel1");
237+
expect(retrieved).toBeDefined();
238+
expect(retrieved?.getTime()).toBeGreaterThanOrEqual(before);
239+
expect(retrieved?.getTime()).toBeLessThanOrEqual(after);
240+
});
241+
242+
it("marks state as dirty when setting", async () => {
243+
await manager.load();
244+
expect(manager.isDirty()).toBe(false);
245+
246+
manager.setLastRandomPost("channel1");
247+
expect(manager.isDirty()).toBe(true);
248+
});
249+
250+
it("persists across save/load cycle", async () => {
251+
await manager.load();
252+
const timestamp = new Date("2024-01-15T12:00:00Z");
253+
manager.setLastRandomPost("channel1", timestamp);
254+
await manager.save();
255+
256+
const manager2 = createStateManager({
257+
filePath: TEST_STATE_FILE,
258+
enablePersistence: true,
259+
});
260+
await manager2.load();
261+
262+
const retrieved = manager2.getLastRandomPost("channel1");
263+
expect(retrieved?.toISOString()).toBe(timestamp.toISOString());
264+
});
265+
});
266+
267+
describe("shouldPostRandom", () => {
268+
it("returns true when no last post exists", async () => {
269+
await manager.load();
270+
expect(manager.shouldPostRandom("channel1", 60_000)).toBe(true);
271+
});
272+
273+
it("returns true when interval has elapsed", async () => {
274+
await manager.load();
275+
// Set last post to 2 minutes ago
276+
const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000);
277+
manager.setLastRandomPost("channel1", twoMinutesAgo);
278+
279+
// Should post if interval is 1 minute
280+
expect(manager.shouldPostRandom("channel1", 60_000)).toBe(true);
281+
});
282+
283+
it("returns false when interval has not elapsed", async () => {
284+
await manager.load();
285+
// Set last post to 30 seconds ago
286+
const thirtySecondsAgo = new Date(Date.now() - 30 * 1000);
287+
manager.setLastRandomPost("channel1", thirtySecondsAgo);
288+
289+
// Should not post if interval is 1 minute
290+
expect(manager.shouldPostRandom("channel1", 60_000)).toBe(false);
291+
});
292+
293+
it("returns true at exact interval boundary", async () => {
294+
await manager.load();
295+
// Set last post to exactly 1 minute ago
296+
const oneMinuteAgo = new Date(Date.now() - 60 * 1000);
297+
manager.setLastRandomPost("channel1", oneMinuteAgo);
298+
299+
// Should post if interval is 1 minute
300+
expect(manager.shouldPostRandom("channel1", 60_000)).toBe(true);
301+
});
302+
});
303+
214304
describe("persistence disabled", () => {
215305
it("does not save when persistence is disabled", async () => {
216306
const inMemoryManager = createStateManager({

0 commit comments

Comments
 (0)