rhythia-api 233.0.0 → 234.0.0

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.
Files changed (90) hide show
  1. package/.codex +0 -0
  2. package/.env +1 -12
  3. package/README.md +4 -4
  4. package/api/acceptInvite.ts +1 -1
  5. package/api/addCollectionMap.ts +1 -1
  6. package/api/chartPublicStats.ts +1 -1
  7. package/api/checkQualified.ts +93 -93
  8. package/api/createBeatmap.ts +53 -62
  9. package/api/createBeatmapPage.ts +1 -1
  10. package/api/createClan.ts +1 -1
  11. package/api/createCollection.ts +1 -1
  12. package/api/createInvite.ts +1 -1
  13. package/api/createSupporter.ts +1 -1
  14. package/api/deleteBeatmapPage.ts +2 -5
  15. package/api/deleteCollection.ts +1 -1
  16. package/api/deleteCollectionMap.ts +1 -1
  17. package/api/editAboutMe.ts +1 -1
  18. package/api/editClan.ts +1 -1
  19. package/api/editCollection.ts +1 -2
  20. package/api/editProfile.ts +1 -1
  21. package/api/enhancedSearch.ts +113 -113
  22. package/api/executeAdminOperation.ts +1 -22
  23. package/api/getAvatarUploadUrl.ts +1 -1
  24. package/api/getBadgeLeaders.ts +1 -1
  25. package/api/getBadgedUsers.ts +1 -1
  26. package/api/getBeatmapComments.ts +1 -1
  27. package/api/getBeatmapPage.ts +74 -106
  28. package/api/getBeatmapPageById.ts +70 -109
  29. package/api/getBeatmapStarRating.ts +1 -1
  30. package/api/getBeatmaps.ts +1 -1
  31. package/api/getClan.ts +1 -1
  32. package/api/getClans.ts +1 -1
  33. package/api/getCollection.ts +1 -1
  34. package/api/getCollections.ts +1 -1
  35. package/api/getInventory.ts +1 -1
  36. package/api/getLeaderboard.ts +1 -1
  37. package/api/getMapUploadUrl.ts +2 -2
  38. package/api/getOnlinePlayers.ts +1 -1
  39. package/api/getPassToken.ts +1 -1
  40. package/api/getProfile.ts +51 -31
  41. package/api/getPublicStats.ts +5 -5
  42. package/api/getRawStarRating.ts +1 -1
  43. package/api/getScore.ts +1 -1
  44. package/api/getStoryBeatmaps.ts +1 -1
  45. package/api/getTimestamp.ts +1 -1
  46. package/api/getUserScores.ts +19 -19
  47. package/api/getVerified.ts +1 -1
  48. package/api/getVideoUploadUrl.ts +1 -1
  49. package/api/postBeatmapComment.ts +1 -1
  50. package/api/qualifyMap.ts +97 -92
  51. package/api/rankMapsArchive.ts +20 -20
  52. package/api/searchUsers.ts +1 -1
  53. package/api/setPasskey.ts +1 -1
  54. package/api/submitScore.ts +1 -6
  55. package/api/submitScoreInternal.ts +461 -449
  56. package/api/updateBeatmapPage.ts +1 -1
  57. package/api/vetoMap.ts +101 -101
  58. package/index.ts +165 -153
  59. package/package.json +7 -12
  60. package/queries/admin_delete_user.sql +39 -39
  61. package/queries/admin_exclude_user.sql +21 -21
  62. package/queries/admin_invalidate_ranked_scores.sql +18 -18
  63. package/queries/admin_log_action.sql +10 -10
  64. package/queries/admin_profanity_clear.sql +29 -29
  65. package/queries/admin_remove_all_scores.sql +29 -29
  66. package/queries/admin_restrict_user.sql +21 -21
  67. package/queries/admin_search_users.sql +24 -24
  68. package/queries/admin_silence_user.sql +21 -21
  69. package/queries/admin_unban_user.sql +21 -21
  70. package/queries/enhanced_search.sql +217 -217
  71. package/queries/get_badge_leaderboard.sql +50 -50
  72. package/queries/get_clan_leaderboard.sql +68 -68
  73. package/queries/get_collections_v4.sql +109 -109
  74. package/queries/get_top_scores_for_beatmap.sql +44 -44
  75. package/queries/get_top_scores_for_beatmap3.sql +38 -0
  76. package/queries/get_user_by_email.sql +32 -32
  77. package/queries/get_user_scores_lastday.sql +47 -47
  78. package/queries/get_user_scores_reign.sql +31 -31
  79. package/queries/get_user_scores_top_and_stats.sql +84 -84
  80. package/queries/grant_special_badges.sql +69 -69
  81. package/types/database.ts +1288 -1248
  82. package/utils/beatmapTopScores.ts +84 -0
  83. package/utils/mapLifecycleWebhook.ts +287 -277
  84. package/utils/requestGeo.ts +13 -0
  85. package/utils/requestUtils.ts +127 -127
  86. package/utils/response.ts +11 -0
  87. package/worker.ts +189 -0
  88. package/wrangler.jsonc +10 -0
  89. package/index.html +0 -3
  90. package/vercel.json +0 -13
@@ -0,0 +1,84 @@
1
+ import { Database } from "../types/database";
2
+ import { getActiveProfileIdSet } from "./activityStatus";
3
+ import { supabase } from "./supabase";
4
+
5
+ type BeatmapTopScoreRow =
6
+ Database["public"]["Functions"]["get_top_scores_for_beatmap3"]["Returns"][number];
7
+
8
+ export type BeatmapTopScore = {
9
+ id: number;
10
+ awarded_sp: number | null;
11
+ created_at: string;
12
+ misses: number | null;
13
+ mods: Record<string, unknown>;
14
+ passed: boolean | null;
15
+ songId: string | null;
16
+ speed: number | null;
17
+ spin: boolean;
18
+ userId: number | null;
19
+ username: string | null;
20
+ avatar_url: string | null;
21
+ accuracy: number | null;
22
+ };
23
+
24
+ type GetBeatmapTopScoresResult = {
25
+ error?: string;
26
+ scores: BeatmapTopScore[];
27
+ };
28
+
29
+ function mapBeatmapTopScore(score: BeatmapTopScoreRow): BeatmapTopScore {
30
+ return {
31
+ id: score.id,
32
+ awarded_sp: score.awarded_sp,
33
+ created_at: score.created_at,
34
+ misses: score.misses,
35
+ mods: (score.mods || {}) as Record<string, unknown>,
36
+ passed: score.passed,
37
+ songId: score.songId,
38
+ speed: score.speed,
39
+ spin: score.spin,
40
+ userId: score.userId,
41
+ username: score.username,
42
+ avatar_url: score.avatar_url,
43
+ accuracy: score.accuracy,
44
+ };
45
+ }
46
+
47
+ export async function getVisibleTopScoresForBeatmap(
48
+ beatmapHash: string,
49
+ limit: number
50
+ ): Promise<GetBeatmapTopScoresResult> {
51
+ if (!beatmapHash) {
52
+ return { scores: [] };
53
+ }
54
+
55
+ const { data: rpcScores, error } = await supabase.rpc(
56
+ "get_top_scores_for_beatmap3",
57
+ { beatmap_hash: beatmapHash }
58
+ );
59
+
60
+ if (error) {
61
+ return { error: JSON.stringify(error), scores: [] };
62
+ }
63
+
64
+ const scoreData = rpcScores || [];
65
+
66
+ const userIds = Array.from(
67
+ new Set(
68
+ scoreData
69
+ .map((score) => score.userId)
70
+ .filter((userId): userId is number => typeof userId === "number")
71
+ )
72
+ );
73
+ const activeUserIds = await getActiveProfileIdSet(userIds);
74
+
75
+ return {
76
+ scores: scoreData
77
+ .filter(
78
+ (score) =>
79
+ typeof score.userId === "number" && activeUserIds.has(score.userId)
80
+ )
81
+ .slice(0, limit)
82
+ .map(mapBeatmapTopScore),
83
+ };
84
+ }
@@ -1,277 +1,287 @@
1
- import { supabase } from "./supabase";
2
-
3
- type MapLifecycleEvent = "qualified" | "ranked" | "vetoed";
4
-
5
- const WEBHOOK_COLORS: Record<MapLifecycleEvent, number> = {
6
- qualified: 0x3498db,
7
- ranked: 0x2ecc71,
8
- vetoed: 0xe74c3c,
9
- };
10
-
11
- const WEBHOOK_TITLES: Record<MapLifecycleEvent, string> = {
12
- qualified: "Map Qualified",
13
- ranked: "Map Ranked",
14
- vetoed: "Map Vetoed",
15
- };
16
-
17
- const WEBHOOK_MESSAGES: Record<MapLifecycleEvent, string> = {
18
- qualified: "A fresh map just reached qualification.",
19
- ranked: "This map cleared qualification and is now ranked.",
20
- vetoed: "This map was vetoed and sent back for improvements.",
21
- };
22
-
23
- function clampText(value: string, maxLength: number) {
24
- const sanitized = value.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "");
25
-
26
- if (sanitized.length <= maxLength) {
27
- return sanitized;
28
- }
29
-
30
- if (maxLength <= 3) {
31
- return sanitized.slice(0, maxLength);
32
- }
33
-
34
- return `${sanitized.slice(0, maxLength - 3)}...`;
35
- }
36
-
37
- function getSafeHttpUrl(value: string | null | undefined) {
38
- if (!value) {
39
- return null;
40
- }
41
-
42
- try {
43
- const parsed = new URL(value.trim());
44
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
45
- return null;
46
- }
47
- const serialized = parsed.toString();
48
- if (serialized.length > 2048) {
49
- return null;
50
- }
51
- return serialized;
52
- } catch {
53
- return null;
54
- }
55
- }
56
-
57
- function formatLength(milliseconds: number | null | undefined) {
58
- if (!milliseconds || milliseconds < 0) {
59
- return "-";
60
- }
61
-
62
- const totalSeconds = Math.floor(milliseconds / 1000);
63
- const minutes = Math.floor(totalSeconds / 60);
64
- const seconds = totalSeconds % 60;
65
- return `${minutes.toString().padStart(2, "0")}:${seconds
66
- .toString()
67
- .padStart(2, "0")}`;
68
- }
69
-
70
- export async function postMapLifecycleWebhook({
71
- mapId,
72
- event,
73
- vetoReason,
74
- }: {
75
- mapId: number;
76
- event: MapLifecycleEvent;
77
- vetoReason?: string;
78
- }) {
79
- const webhookUrl = process.env.WEBHOOK_MSG_DISCORD;
80
- if (!webhookUrl) {
81
- return;
82
- }
83
-
84
- try {
85
- const { data: beatmapPage } = await supabase
86
- .from("beatmapPages")
87
- .select(
88
- `
89
- id,
90
- owner,
91
- title,
92
- tags,
93
- status,
94
- qualified,
95
- qualifiedAt,
96
- beatmaps (
97
- title,
98
- starRating,
99
- length,
100
- difficulty,
101
- noteCount,
102
- image,
103
- imageLarge
104
- ),
105
- profiles (
106
- id,
107
- username,
108
- avatar_url
109
- )
110
- `
111
- )
112
- .eq("id", mapId)
113
- .single();
114
-
115
- if (!beatmapPage) {
116
- return;
117
- }
118
-
119
- const beatmapData = (beatmapPage as any).beatmaps;
120
- const profileData = (beatmapPage as any).profiles;
121
- const mapTitle =
122
- beatmapData?.title || beatmapPage.title || `Beatmap Page #${mapId}`;
123
- const creatorName = profileData?.username || "Unknown";
124
- const creatorId = beatmapPage.owner || profileData?.id || 0;
125
- const rawImage =
126
- beatmapData?.imageLarge || beatmapData?.image || "https://www.rhythia.com/unkimg.png";
127
- const mapImage = rawImage.includes("backfill")
128
- ? "https://www.rhythia.com/unkimg.png"
129
- : rawImage;
130
- const safeMapImageUrl = getSafeHttpUrl(mapImage);
131
- const safeAvatarUrl = getSafeHttpUrl(profileData?.avatar_url);
132
-
133
- const fields: Array<{ name: string; value: string; inline?: boolean }> = [
134
- {
135
- name: "Map ID",
136
- value: clampText(`${beatmapPage.id}`, 1024),
137
- inline: true,
138
- },
139
- {
140
- name: "Creator",
141
- value: clampText(creatorName, 1024),
142
- inline: true,
143
- },
144
- {
145
- name: "Stars",
146
- value: clampText(
147
- beatmapData?.starRating !== null && beatmapData?.starRating !== undefined
148
- ? `${Math.round(beatmapData.starRating * 100) / 100}*`
149
- : "-",
150
- 1024
151
- ),
152
- inline: true,
153
- },
154
- {
155
- name: "Length",
156
- value: clampText(formatLength(beatmapData?.length), 1024),
157
- inline: true,
158
- },
159
- {
160
- name: "Notes",
161
- value: clampText(
162
- beatmapData?.noteCount !== null && beatmapData?.noteCount !== undefined
163
- ? `${beatmapData.noteCount}`
164
- : "-",
165
- 1024
166
- ),
167
- inline: true,
168
- },
169
- {
170
- name: "Tags",
171
- value: clampText(beatmapPage.tags || "-", 1024),
172
- inline: false,
173
- },
174
- ];
175
-
176
- if (event === "vetoed") {
177
- fields.push({
178
- name: "Veto Reason",
179
- value: clampText(vetoReason || "No reason provided", 1024),
180
- inline: false,
181
- });
182
- }
183
-
184
- const embed: Record<string, any> = {
185
- title: clampText(`${WEBHOOK_TITLES[event]}: ${mapTitle}`, 256),
186
- url: `https://www.rhythia.com/maps/${beatmapPage.id}`,
187
- description: clampText(WEBHOOK_MESSAGES[event], 4096),
188
- color: WEBHOOK_COLORS[event],
189
- fields: fields.map((field) => ({
190
- ...field,
191
- name: clampText(field.name || "-", 256),
192
- value: clampText(field.value || "-", 1024),
193
- })),
194
- author: {
195
- name: clampText(creatorName, 256),
196
- url: `https://www.rhythia.com/player/${creatorId}`,
197
- icon_url: safeAvatarUrl || "https://www.rhythia.com/unkimg.png",
198
- },
199
- footer: {
200
- text: clampText(
201
- `Status: ${beatmapPage.status || "-"} | ${new Date().toUTCString()}`,
202
- 2048
203
- ),
204
- },
205
- };
206
-
207
- if (safeMapImageUrl) {
208
- embed.thumbnail = {
209
- url: safeMapImageUrl,
210
- };
211
- }
212
-
213
- const payload = {
214
- content: clampText(WEBHOOK_MESSAGES[event], 2000),
215
- embeds: [embed],
216
- };
217
-
218
- let response = await fetch(webhookUrl, {
219
- method: "POST",
220
- headers: {
221
- "Content-Type": "application/json",
222
- },
223
- body: JSON.stringify(payload),
224
- });
225
-
226
- if (
227
- !response.ok &&
228
- response.status === 400 &&
229
- (payload.embeds?.[0]?.image?.url || payload.embeds?.[0]?.thumbnail?.url)
230
- ) {
231
- // Most common Discord embed 400 here is a bad media URL. Retry without media.
232
- const retryPayload = {
233
- ...payload,
234
- embeds: payload.embeds.map((embed: any) => {
235
- const clone = { ...embed };
236
- delete clone.image;
237
- delete clone.thumbnail;
238
- return clone;
239
- }),
240
- };
241
-
242
- response = await fetch(webhookUrl, {
243
- method: "POST",
244
- headers: {
245
- "Content-Type": "application/json",
246
- },
247
- body: JSON.stringify(retryPayload),
248
- });
249
- }
250
-
251
- if (!response.ok) {
252
- const responseBody = await response.text();
253
- console.log("Discord webhook failed", {
254
- event,
255
- mapId,
256
- status: response.status,
257
- statusText: response.statusText,
258
- responseBody: clampText(responseBody || "-", 4000),
259
- payloadPreview: {
260
- content: payload.content,
261
- title: payload.embeds?.[0]?.title,
262
- fields: payload.embeds?.[0]?.fields?.map((field: any) => ({
263
- name: field.name,
264
- value: clampText(field.value || "-", 120),
265
- })),
266
- imageUrl: payload.embeds?.[0]?.image?.url || null,
267
- thumbnailUrl: payload.embeds?.[0]?.thumbnail?.url || null,
268
- authorIconUrl: payload.embeds?.[0]?.author?.icon_url || null,
269
- hasImage: Boolean(payload.embeds?.[0]?.image?.url),
270
- hasThumbnail: Boolean(payload.embeds?.[0]?.thumbnail?.url),
271
- },
272
- });
273
- }
274
- } catch (error) {
275
- console.log("Failed to post map lifecycle webhook", error);
276
- }
277
- }
1
+ import { supabase } from "./supabase";
2
+
3
+ type MapLifecycleEvent = "qualified" | "ranked" | "vetoed";
4
+
5
+ const WEBHOOK_COLORS: Record<MapLifecycleEvent, number> = {
6
+ qualified: 0x3498db,
7
+ ranked: 0x2ecc71,
8
+ vetoed: 0xe74c3c,
9
+ };
10
+
11
+ const WEBHOOK_TITLES: Record<MapLifecycleEvent, string> = {
12
+ qualified: "Map Qualified",
13
+ ranked: "Map Ranked",
14
+ vetoed: "Map Vetoed",
15
+ };
16
+
17
+ const WEBHOOK_MESSAGES: Record<MapLifecycleEvent, string> = {
18
+ qualified: "A fresh map just reached qualification.",
19
+ ranked: "This map cleared qualification and is now ranked.",
20
+ vetoed: "This map was vetoed and sent back for improvements.",
21
+ };
22
+
23
+ function clampText(value: string, maxLength: number) {
24
+ const sanitized = value.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "");
25
+
26
+ if (sanitized.length <= maxLength) {
27
+ return sanitized;
28
+ }
29
+
30
+ if (maxLength <= 3) {
31
+ return sanitized.slice(0, maxLength);
32
+ }
33
+
34
+ return `${sanitized.slice(0, maxLength - 3)}...`;
35
+ }
36
+
37
+ function getSafeHttpUrl(value: string | null | undefined) {
38
+ if (!value) {
39
+ return null;
40
+ }
41
+
42
+ try {
43
+ const parsed = new URL(value.trim());
44
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
45
+ return null;
46
+ }
47
+ const serialized = parsed.toString();
48
+ if (serialized.length > 2048) {
49
+ return null;
50
+ }
51
+ return serialized;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ function formatLength(milliseconds: number | null | undefined) {
58
+ if (!milliseconds || milliseconds < 0) {
59
+ return "-";
60
+ }
61
+
62
+ const totalSeconds = Math.floor(milliseconds / 1000);
63
+ const minutes = Math.floor(totalSeconds / 60);
64
+ const seconds = totalSeconds % 60;
65
+ return `${minutes.toString().padStart(2, "0")}:${seconds
66
+ .toString()
67
+ .padStart(2, "0")}`;
68
+ }
69
+
70
+ export async function postMapLifecycleWebhook({
71
+ mapId,
72
+ event,
73
+ vetoReason,
74
+ candidateQualifierUsername,
75
+ }: {
76
+ mapId: number;
77
+ event: MapLifecycleEvent;
78
+ vetoReason?: string;
79
+ candidateQualifierUsername?: string;
80
+ }) {
81
+ const webhookUrl = process.env.WEBHOOK_MSG_DISCORD;
82
+ if (!webhookUrl) {
83
+ return;
84
+ }
85
+
86
+ try {
87
+ const { data: beatmapPage } = await supabase
88
+ .from("beatmapPages")
89
+ .select(
90
+ `
91
+ id,
92
+ owner,
93
+ title,
94
+ tags,
95
+ status,
96
+ qualified,
97
+ qualifiedAt,
98
+ beatmaps (
99
+ title,
100
+ starRating,
101
+ length,
102
+ difficulty,
103
+ noteCount,
104
+ image,
105
+ imageLarge
106
+ ),
107
+ profiles (
108
+ id,
109
+ username,
110
+ avatar_url
111
+ )
112
+ `
113
+ )
114
+ .eq("id", mapId)
115
+ .single();
116
+
117
+ if (!beatmapPage) {
118
+ return;
119
+ }
120
+
121
+ const beatmapData = (beatmapPage as any).beatmaps;
122
+ const profileData = (beatmapPage as any).profiles;
123
+ const mapTitle =
124
+ beatmapData?.title || beatmapPage.title || `Beatmap Page #${mapId}`;
125
+ const creatorName = profileData?.username || "Unknown";
126
+ const creatorId = beatmapPage.owner || profileData?.id || 0;
127
+ const rawImage =
128
+ beatmapData?.imageLarge || beatmapData?.image || "https://www.rhythia.com/unkimg.png";
129
+ const mapImage = rawImage.includes("backfill")
130
+ ? "https://www.rhythia.com/unkimg.png"
131
+ : rawImage;
132
+ const safeMapImageUrl = getSafeHttpUrl(mapImage);
133
+ const safeAvatarUrl = getSafeHttpUrl(profileData?.avatar_url);
134
+
135
+ const fields: Array<{ name: string; value: string; inline?: boolean }> = [
136
+ {
137
+ name: "Map ID",
138
+ value: clampText(`${beatmapPage.id}`, 1024),
139
+ inline: true,
140
+ },
141
+ {
142
+ name: "Creator",
143
+ value: clampText(creatorName, 1024),
144
+ inline: true,
145
+ },
146
+ {
147
+ name: "Stars",
148
+ value: clampText(
149
+ beatmapData?.starRating !== null && beatmapData?.starRating !== undefined
150
+ ? `${Math.round(beatmapData.starRating * 100) / 100}*`
151
+ : "-",
152
+ 1024
153
+ ),
154
+ inline: true,
155
+ },
156
+ {
157
+ name: "Length",
158
+ value: clampText(formatLength(beatmapData?.length), 1024),
159
+ inline: true,
160
+ },
161
+ {
162
+ name: "Notes",
163
+ value: clampText(
164
+ beatmapData?.noteCount !== null && beatmapData?.noteCount !== undefined
165
+ ? `${beatmapData.noteCount}`
166
+ : "-",
167
+ 1024
168
+ ),
169
+ inline: true,
170
+ },
171
+ {
172
+ name: "Tags",
173
+ value: clampText(beatmapPage.tags || "-", 1024),
174
+ inline: false,
175
+ },
176
+ ];
177
+
178
+ if (event === "qualified" && candidateQualifierUsername) {
179
+ fields.splice(2, 0, {
180
+ name: "Qualified By",
181
+ value: clampText(`${candidateQualifierUsername} (Candidate)`, 1024),
182
+ inline: true,
183
+ });
184
+ }
185
+
186
+ if (event === "vetoed") {
187
+ fields.push({
188
+ name: "Veto Reason",
189
+ value: clampText(vetoReason || "No reason provided", 1024),
190
+ inline: false,
191
+ });
192
+ }
193
+
194
+ const embed: Record<string, any> = {
195
+ title: clampText(`${WEBHOOK_TITLES[event]}: ${mapTitle}`, 256),
196
+ url: `https://www.rhythia.com/maps/${beatmapPage.id}`,
197
+ description: clampText(WEBHOOK_MESSAGES[event], 4096),
198
+ color: WEBHOOK_COLORS[event],
199
+ fields: fields.map((field) => ({
200
+ ...field,
201
+ name: clampText(field.name || "-", 256),
202
+ value: clampText(field.value || "-", 1024),
203
+ })),
204
+ author: {
205
+ name: clampText(creatorName, 256),
206
+ url: `https://www.rhythia.com/player/${creatorId}`,
207
+ icon_url: safeAvatarUrl || "https://www.rhythia.com/unkimg.png",
208
+ },
209
+ footer: {
210
+ text: clampText(
211
+ `Status: ${beatmapPage.status || "-"} | ${new Date().toUTCString()}`,
212
+ 2048
213
+ ),
214
+ },
215
+ };
216
+
217
+ if (safeMapImageUrl) {
218
+ embed.thumbnail = {
219
+ url: safeMapImageUrl,
220
+ };
221
+ }
222
+
223
+ const payload = {
224
+ content: clampText(WEBHOOK_MESSAGES[event], 2000),
225
+ embeds: [embed],
226
+ };
227
+
228
+ let response = await fetch(webhookUrl, {
229
+ method: "POST",
230
+ headers: {
231
+ "Content-Type": "application/json",
232
+ },
233
+ body: JSON.stringify(payload),
234
+ });
235
+
236
+ if (
237
+ !response.ok &&
238
+ response.status === 400 &&
239
+ (payload.embeds?.[0]?.image?.url || payload.embeds?.[0]?.thumbnail?.url)
240
+ ) {
241
+ // Most common Discord embed 400 here is a bad media URL. Retry without media.
242
+ const retryPayload = {
243
+ ...payload,
244
+ embeds: payload.embeds.map((embed: any) => {
245
+ const clone = { ...embed };
246
+ delete clone.image;
247
+ delete clone.thumbnail;
248
+ return clone;
249
+ }),
250
+ };
251
+
252
+ response = await fetch(webhookUrl, {
253
+ method: "POST",
254
+ headers: {
255
+ "Content-Type": "application/json",
256
+ },
257
+ body: JSON.stringify(retryPayload),
258
+ });
259
+ }
260
+
261
+ if (!response.ok) {
262
+ const responseBody = await response.text();
263
+ console.log("Discord webhook failed", {
264
+ event,
265
+ mapId,
266
+ status: response.status,
267
+ statusText: response.statusText,
268
+ responseBody: clampText(responseBody || "-", 4000),
269
+ payloadPreview: {
270
+ content: payload.content,
271
+ title: payload.embeds?.[0]?.title,
272
+ fields: payload.embeds?.[0]?.fields?.map((field: any) => ({
273
+ name: field.name,
274
+ value: clampText(field.value || "-", 120),
275
+ })),
276
+ imageUrl: payload.embeds?.[0]?.image?.url || null,
277
+ thumbnailUrl: payload.embeds?.[0]?.thumbnail?.url || null,
278
+ authorIconUrl: payload.embeds?.[0]?.author?.icon_url || null,
279
+ hasImage: Boolean(payload.embeds?.[0]?.image?.url),
280
+ hasThumbnail: Boolean(payload.embeds?.[0]?.thumbnail?.url),
281
+ },
282
+ });
283
+ }
284
+ } catch (error) {
285
+ console.log("Failed to post map lifecycle webhook", error);
286
+ }
287
+ }
@@ -0,0 +1,13 @@
1
+ type RequestWithCf = Request & {
2
+ cf?: {
3
+ country?: string | null;
4
+ };
5
+ };
6
+
7
+ export function geolocation(request: Request) {
8
+ const { cf } = request as RequestWithCf;
9
+
10
+ return {
11
+ country: cf?.country || request.headers.get("cf-ipcountry") || undefined,
12
+ };
13
+ }