···173173174174 if (config.usingDefaultKey) {
175175 const exemptUserId = process.env.AI_EXEMPT_USER_ID;
176176- if (
177177- conversationKey !== `dm:${exemptUserId}` &&
178178- !conversationKey.startsWith(`guild:${exemptUserId}`)
179179- ) {
180180- const allowed = await incrementAndCheckDailyLimit(conversationKey, 10);
176176+ const actorId = message.author.id;
177177+178178+ if (actorId !== exemptUserId) {
179179+ const allowed = await incrementAndCheckDailyLimit(actorId, 10);
181180 if (!allowed) {
182181 await message.reply(
183182 "❌ You've reached your daily limit of AI requests. Please try again tomorrow or set up your own API key using the `/ai` command.",
+52-9
src/services/social/fetchers/UnifiedFetcher.ts
···7777 }
7878 }
79798080- isValidAccount(account: string): boolean {
8080+ isValidAccount(account: string | null | undefined): boolean {
8181 if (!account) return false;
8282- const handle = this.normalizeHandle(account);
8383- return /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/.test(handle);
8282+8383+ if (account.startsWith('did:')) {
8484+ return /^did:[a-z0-9]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$/.test(account);
8585+ }
8686+8787+ const parts = account.split('@').filter(Boolean);
8888+ if (parts.length < 2) return false;
8989+9090+ const handle = parts[0];
9191+ const domain = parts.slice(1).join('@');
9292+9393+ const isValidHandle = /^[a-zA-Z0-9-]+$/.test(handle);
9494+ const isValidDomain = /^[a-zA-Z0-9.-]+$/.test(domain);
9595+9696+ return isValidHandle && isValidDomain;
8497 }
85988699 private normalizeHandle(handle: string): string {
100100+ if (handle.startsWith('did:')) {
101101+ return handle;
102102+ }
103103+87104 handle = handle.startsWith('@') ? handle.slice(1) : handle;
88105 if (!handle.includes('.')) {
89106 return `${handle}.bsky.social`.toLowerCase();
···198215export class FediverseFetcher implements SocialMediaFetcher {
199216 platform: SocialPlatform = 'fediverse';
200217201201- async fetchLatestPost(account: string): Promise<SocialMediaPost | null> {
202202- const [username, domain] = this.parseAccount(account);
218218+ private validateFediverseAccount(username: string, domain: string | null): void {
203219 if (!domain) {
204220 throw new Error('Fediverse account must include a domain (e.g., user@instance.social)');
205221 }
206222207207- const apiUrl = `https://${domain}/api/v1/accounts/lookup?acct=${username}@${domain}`;
223223+ if (/^https?:\/\//i.test(username) || /^https?:\/\//i.test(domain)) {
224224+ throw new Error('URL schemes (http/https) are not allowed in Fediverse accounts');
225225+ }
226226+227227+ if (/[?#]/.test(username) || /[?#]/.test(domain)) {
228228+ throw new Error('URL paths and query parameters are not allowed in Fediverse accounts');
229229+ }
230230+231231+ const domainStr = domain as string;
232232+ if (!/^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i.test(domainStr)) {
233233+ throw new Error('Invalid domain format in Fediverse account');
234234+ }
235235+236236+ if (!/^[a-z0-9_.-]+$/i.test(username)) {
237237+ throw new Error('Invalid username format in Fediverse account');
238238+ }
239239+ }
240240+241241+ async fetchLatestPost(account: string): Promise<SocialMediaPost | null> {
242242+ const [username, domain] = this.parseAccount(account);
243243+ this.validateFediverseAccount(username, domain);
244244+245245+ const domainStr = domain as string;
246246+ const apiUrl = `https://${domainStr}/api/v1/accounts/lookup?acct=${username}@${domainStr}`;
208247209248 try {
210249 const accountResponse = await fetchWithTimeout(apiUrl);
···213252 }
214253215254 const accountData = await accountResponse.json();
255255+256256+ if (!accountData?.id) {
257257+ throw new Error('Invalid account data: missing account ID in response');
258258+ }
259259+216260 const accountId = accountData.id;
217217-218218- const statusesUrl = `https://${domain}/api/v1/accounts/${accountId}/statuses?limit=1&exclude_replies=true&exclude_reblogs=true`;
261261+ const statusesUrl = `https://${domainStr}/api/v1/accounts/${accountId}/statuses?limit=1&exclude_replies=true&exclude_reblogs=true`;
219262 const statusResponse = await fetchWithTimeout(statusesUrl);
220263221264 if (!statusResponse.ok) {
···227270 return null;
228271 }
229272230230- const post = this.mapToSocialMediaPost(statuses[0], domain);
273273+ const post = this.mapToSocialMediaPost(statuses[0], domainStr);
231274 return post;
232275 } catch (error: unknown) {
233276 const errorMessage = error instanceof Error ? error.message : 'Unknown error';