Skip to content

Commit 5502d12

Browse files
committed
🌟 feat: implement guide synchronization and management functionality
1 parent 2441fc0 commit 5502d12

File tree

5 files changed

+309
-2
lines changed

5 files changed

+309
-2
lines changed

β€ŽGUIDE_SYNC.mdβ€Ž

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Guide Synchronization System
2+
3+
The bot automatically synchronizes guide markdown files from `src/commands/guides/subjects/` to a Discord channel when it starts up.
4+
5+
## Setup
6+
7+
Add to your `.env.local` file:
8+
```
9+
GUIDES_CHANNEL_ID=1234567890123456789
10+
```
11+
12+
## Commands
13+
14+
- `npm run sync-guides` - Manual sync (updates only changed guides)
15+
- `npm run sync-guides:init` - Force sync (posts all guides fresh)
16+
17+
## Guide Format
18+
19+
Guides need frontmatter with a `name` field:
20+
21+
```markdown
22+
---
23+
name: JavaScript
24+
---
25+
26+
Your guide content here...
27+
```

β€Žpackage.jsonβ€Ž

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
"test": "pnpm run build:dev && node --test dist/**/*.test.js",
2121
"test:ci": "node --test dist/**/*.test.js",
2222
"prepare": "husky",
23-
"pre-commit": "lint-staged"
23+
"pre-commit": "lint-staged",
24+
"sync-guides": "tsx scripts/sync-guides.js",
25+
"sync-guides:init": "tsx scripts/sync-guides.js --initialize"
2426
},
2527
"keywords": [],
2628
"author": "",

β€Žscripts/sync-guides.jsβ€Ž

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Standalone script for synchronizing guides to Discord channel
5+
* Usage: npm run sync-guides [--initialize]
6+
*/
7+
8+
import { Client, GatewayIntentBits } from 'discord.js';
9+
import { config } from '../src/env.js';
10+
import { syncGuidesToChannel, initializeGuidesChannel } from '../src/util/post-guides.js';
11+
12+
async function main() {
13+
const args = process.argv.slice(2);
14+
const shouldInitialize = args.includes('--initialize');
15+
16+
if (!config.guides.channelId) {
17+
console.error('❌ GUIDES_CHANNEL_ID environment variable is required');
18+
console.error('Please set it in your environment variables');
19+
process.exit(1);
20+
}
21+
22+
console.log(`πŸ€– Starting Discord client for guide sync...`);
23+
24+
const client = new Client({
25+
intents: [
26+
GatewayIntentBits.Guilds,
27+
GatewayIntentBits.GuildMessages,
28+
],
29+
});
30+
31+
try {
32+
await client.login(config.discord.token);
33+
console.log(`βœ… Logged in as ${client.user?.tag}`);
34+
35+
if (shouldInitialize) {
36+
console.log('πŸš€ Initializing guides channel (will post all guides fresh)...');
37+
await initializeGuidesChannel(client, config.guides.channelId);
38+
} else {
39+
console.log('πŸ”„ Synchronizing guides...');
40+
await syncGuidesToChannel(client, config.guides.channelId);
41+
}
42+
43+
console.log('βœ… Guide synchronization completed successfully');
44+
} catch (error) {
45+
console.error('❌ Guide synchronization failed:', error);
46+
process.exit(1);
47+
} finally {
48+
await client.destroy();
49+
console.log('πŸ‘‹ Discord client disconnected');
50+
}
51+
}
52+
53+
main().catch((error) => {
54+
console.error('❌ Unexpected error:', error);
55+
process.exit(1);
56+
});

β€Žsrc/events/ready.tsβ€Ž

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
11
import { Events } from 'discord.js';
2+
import { config } from '../env.js';
23
import { createEvent } from '../util/events.js';
4+
import { syncGuidesToChannel } from '../util/post-guides.js';
35

46
export const readyEvent = createEvent(
57
{
68
name: Events.ClientReady,
79
once: true,
810
},
9-
(client) => {
11+
async (client) => {
1012
console.log(`Ready! Logged in as ${client.user.tag}`);
13+
14+
// Sync guides to channel
15+
try {
16+
console.log(`πŸ”„ Starting guide sync to channel ${config.guides.channelId}...`);
17+
await syncGuidesToChannel(client, config.guides.channelId);
18+
} catch (error) {
19+
console.error('❌ Failed to sync guides:', error);
20+
}
1121
}
1222
);

β€Žsrc/util/post-guides.tsβ€Ž

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { createHash } from 'node:crypto';
2+
import { readdir, readFile, writeFile } from 'node:fs/promises';
3+
import { join } from 'node:path';
4+
import { fileURLToPath } from 'node:url';
5+
import { ChannelType, type Client, EmbedBuilder, type TextChannel } from 'discord.js';
6+
import { config } from '../env.js';
7+
import { parseMarkdown } from './markdown.js';
8+
9+
export type GuideInfo = {
10+
name: string;
11+
filename: string;
12+
hash: string;
13+
messageId?: string;
14+
content: string;
15+
frontmatter: Record<string, unknown>;
16+
};
17+
18+
const guidesColors = [0xff5733, 0x33ff57, 0x3357ff, 0xff33a8, 0xa833ff, 0x33fff5];
19+
const getRandomColor = () => guidesColors[Math.floor(Math.random() * guidesColors.length)];
20+
const createGuideEmbed = (guide: GuideInfo) =>
21+
new EmbedBuilder()
22+
.setTitle(guide.name)
23+
.setDescription(guide.content)
24+
.setColor(getRandomColor())
25+
.setFooter({ text: `Last updated: ${new Date().toLocaleDateString()}` });
26+
27+
export type GuideTracker = {
28+
[filename: string]: {
29+
hash: string;
30+
messageId?: string;
31+
};
32+
};
33+
34+
const GUIDES_DIR = fileURLToPath(new URL('../commands/guides/subjects/', import.meta.url));
35+
36+
const TRACKER_FILE = config.guides.trackerPath ?? 'guides-tracker.json';
37+
38+
const calculateHash = (content: string): string => {
39+
return createHash('sha256').update(content, 'utf8').digest('hex');
40+
};
41+
42+
const loadTracker = async (): Promise<GuideTracker> => {
43+
try {
44+
const content = await readFile(TRACKER_FILE, 'utf8');
45+
return JSON.parse(content);
46+
} catch {
47+
console.log('No existing tracker file found, starting fresh');
48+
return {};
49+
}
50+
};
51+
52+
const saveTracker = async (tracker: GuideTracker): Promise<void> => {
53+
await writeFile(TRACKER_FILE, JSON.stringify(tracker, null, 2), 'utf8');
54+
};
55+
56+
const scanGuideFiles = async (): Promise<GuideInfo[]> => {
57+
const files = await readdir(GUIDES_DIR);
58+
const guides: GuideInfo[] = [];
59+
60+
for (const filename of files) {
61+
if (!filename.endsWith('.md')) {
62+
continue;
63+
}
64+
65+
const filePath = join(GUIDES_DIR, filename);
66+
const content = await readFile(filePath, 'utf8');
67+
const { frontmatter, content: markdownContent } = await parseMarkdown(content);
68+
69+
const hash = calculateHash(content);
70+
const name = (frontmatter.name as string) || filename.replace('.md', '');
71+
72+
guides.push({
73+
name,
74+
filename,
75+
hash,
76+
content: markdownContent,
77+
frontmatter,
78+
});
79+
}
80+
81+
return guides;
82+
};
83+
84+
const postGuideToChannel = async (channel: TextChannel, guide: GuideInfo): Promise<string> => {
85+
const message = await channel.send({
86+
embeds: [createGuideEmbed(guide)],
87+
});
88+
89+
console.log(`βœ… Posted guide "${guide.name}" (${guide.filename})`);
90+
return message.id;
91+
};
92+
93+
const editGuideMessage = async (
94+
channel: TextChannel,
95+
messageId: string,
96+
guide: GuideInfo
97+
): Promise<void> => {
98+
try {
99+
const message = await channel.messages.fetch(messageId);
100+
await message.edit({
101+
embeds: [createGuideEmbed(guide)],
102+
});
103+
104+
console.log(`πŸ“ Updated guide "${guide.name}" (${guide.filename})`);
105+
} catch (error) {
106+
console.error(`Failed to edit message ${messageId} for guide "${guide.name}":`, error);
107+
throw error;
108+
}
109+
};
110+
111+
const deleteGuideMessage = async (
112+
channel: TextChannel,
113+
messageId: string,
114+
guideName: string
115+
): Promise<void> => {
116+
try {
117+
const message = await channel.messages.fetch(messageId);
118+
await message.delete();
119+
120+
console.log(`πŸ—‘οΈ Deleted guide "${guideName}"`);
121+
} catch (error) {
122+
console.error(`Failed to delete message ${messageId} for guide "${guideName}":`, error);
123+
}
124+
};
125+
126+
export const syncGuidesToChannel = async (client: Client, channelId: string): Promise<void> => {
127+
console.log('πŸ”„ Starting guide synchronization...');
128+
129+
try {
130+
const channel = await client.channels.fetch(channelId);
131+
if (!channel || !channel.isTextBased() || channel.type !== ChannelType.GuildText) {
132+
throw new Error(`Channel ${channelId} is not a valid text channel`);
133+
}
134+
// Load current state
135+
const tracker = await loadTracker();
136+
const currentGuides = await scanGuideFiles();
137+
138+
// Create maps for easier lookup
139+
const currentGuideMap = new Map(currentGuides.map((guide) => [guide.filename, guide]));
140+
const trackedFiles = new Set(Object.keys(tracker));
141+
const currentFiles = new Set(currentGuides.map((guide) => guide.filename));
142+
143+
// Find changes
144+
const newFiles = [...currentFiles].filter((file) => !trackedFiles.has(file));
145+
const deletedFiles = [...trackedFiles].filter((file) => !currentFiles.has(file));
146+
const modifiedFiles = [...currentFiles].filter((file) => {
147+
const guide = currentGuideMap.get(file);
148+
return guide && trackedFiles.has(file) && tracker[file].hash !== guide.hash;
149+
});
150+
151+
console.log(
152+
`πŸ“Š Found: ${newFiles.length} new, ${modifiedFiles.length} modified, ${deletedFiles.length} deleted`
153+
);
154+
155+
// Process deletions first
156+
for (const filename of deletedFiles) {
157+
const messageId = tracker[filename].messageId;
158+
if (messageId) {
159+
await deleteGuideMessage(channel, messageId, filename);
160+
}
161+
delete tracker[filename];
162+
}
163+
164+
// Process new guides
165+
for (const filename of newFiles) {
166+
const guide = currentGuideMap.get(filename)!;
167+
const messageId = await postGuideToChannel(channel, guide);
168+
169+
tracker[filename] = {
170+
hash: guide.hash,
171+
messageId,
172+
};
173+
}
174+
175+
// Process modifications
176+
for (const filename of modifiedFiles) {
177+
const guide = currentGuideMap.get(filename)!;
178+
const messageId = tracker[filename].messageId;
179+
180+
if (messageId) {
181+
await editGuideMessage(channel, messageId, guide);
182+
} else {
183+
// If no message ID, treat as new
184+
const newMessageId = await postGuideToChannel(channel, guide);
185+
tracker[filename].messageId = newMessageId;
186+
}
187+
188+
tracker[filename].hash = guide.hash;
189+
}
190+
191+
await saveTracker(tracker);
192+
193+
const totalChanges = newFiles.length + modifiedFiles.length + deletedFiles.length;
194+
if (totalChanges === 0) {
195+
console.log('✨ All guides are up to date!');
196+
} else {
197+
console.log(`βœ… Guide synchronization complete! Made ${totalChanges} changes.`);
198+
}
199+
} catch (error) {
200+
console.error('❌ Guide synchronization failed:', error);
201+
throw error;
202+
}
203+
};
204+
205+
export const initializeGuidesChannel = async (client: Client, channelId: string): Promise<void> => {
206+
console.log('πŸš€ Initializing guides channel...');
207+
208+
// Clear existing tracker for fresh start
209+
await saveTracker({});
210+
211+
await syncGuidesToChannel(client, channelId);
212+
};

0 commit comments

Comments
Β (0)