Skip to content

Commit 20aedec

Browse files
committed
fix: handle order events correctly and reorganize tests
- Fix OpenSea order event handling (event_type='order' with order_type for specifics) - Add escapeMarkdown for Discord usernames to prevent formatting issues - Reorganize test folder structure by domain (opensea/, platforms/, utils/) - Add live BAYC fixture data for comprehensive event type testing - Update types to handle null nft/asset/criteria fields from API
1 parent f661dbd commit 20aedec

29 files changed

+4730
-135
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "opensea-activity-bot",
3-
"version": "3.5.5",
3+
"version": "3.5.6",
44
"description": "A bot that shares new OpenSea events for a collection to Discord and Twitter.",
55
"author": "Ryan Ghods <ryan@ryanio.com>",
66
"license": "MIT",
@@ -20,6 +20,7 @@
2020
},
2121
"dependencies": {
2222
"discord.js": "^14.25.1",
23+
"dotenv": "^17.2.3",
2324
"ethers": "^6.16.0",
2425
"sharp": "^0.33.5",
2526
"timeago.js": "^4.0.2",

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import "dotenv/config";
12
import { Client, Events, type TextBasedChannel } from "discord.js";
23
import {
34
type EventTimestampSource,

src/platforms/discord.ts

Lines changed: 62 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ import {
4040

4141
const log = prefixedLogger("Discord");
4242

43+
/**
44+
* Escapes Discord markdown special characters to prevent formatting issues.
45+
* Characters escaped: _ * ~ ` | >
46+
*/
47+
const escapeMarkdown = (text: string): string =>
48+
text.replace(/(?<special>[_*~`|>])/g, "\\$<special>");
49+
4350
// Initialize event group manager for Discord
4451
const groupConfig = getDefaultEventGroupConfig("DISCORD");
4552
const groupManager = new EventGroupManager(groupConfig);
@@ -99,25 +106,40 @@ const buildOrderEmbed = async (
99106
order_type: string;
100107
expiration_date: number;
101108
maker: string;
102-
criteria: { trait: { type: string; value: string } };
109+
criteria: {
110+
trait?: { type: string; value: string };
111+
traits?: Array<{ type: string; value: string }>;
112+
};
103113
};
104114
const fields: Field[] = [];
105115
let title = "";
106-
const { quantity, decimals, symbol } = payment;
107-
const inTime = format(new Date(expiration_date * MS_PER_SECOND));
116+
const { quantity, decimals, symbol } = payment ?? {
117+
quantity: "0",
118+
decimals: 18,
119+
symbol: "ETH",
120+
};
121+
const inTime = expiration_date
122+
? format(new Date(expiration_date * MS_PER_SECOND))
123+
: "Unknown";
108124
if (order_type === "auction") {
109125
title += "Auction:";
110126
const price = formatAmount(quantity, decimals, symbol);
111127
fields.push({ name: "Starting Price", value: price });
112128
fields.push({ name: "Ends", value: inTime });
113129
} else if (order_type === "trait_offer") {
114-
const traitType = criteria.trait.type;
115-
const traitValue = criteria.trait.value;
130+
// Get trait info from criteria - can be in trait or traits array
131+
const traitInfo = criteria?.trait ?? criteria?.traits?.[0];
132+
const traitType = traitInfo?.type ?? "Unknown";
133+
const traitValue = traitInfo?.value ?? "Unknown";
116134
title += `Trait offer: ${traitType} -> ${traitValue}`;
117135
const price = formatAmount(quantity, decimals, symbol);
118136
fields.push({ name: "Price", value: price });
119137
fields.push({ name: "Expires", value: inTime });
120-
} else if (order_type === "item_offer") {
138+
} else if (
139+
order_type === "item_offer" ||
140+
order_type === "offer" ||
141+
order_type === "criteria_offer"
142+
) {
121143
title += "Item offer:";
122144
const price = formatAmount(quantity, decimals, symbol);
123145
fields.push({ name: "Price", value: price });
@@ -128,12 +150,15 @@ const buildOrderEmbed = async (
128150
fields.push({ name: "Price", value: price });
129151
fields.push({ name: "Expires", value: inTime });
130152
} else {
153+
// Default to listing
131154
title += "Listed for sale:";
132155
const price = formatAmount(quantity, decimals, symbol);
133156
fields.push({ name: "Price", value: price });
134157
fields.push({ name: "Expires", value: inTime });
135158
}
136-
fields.push({ name: "By", value: await username(maker) });
159+
if (maker) {
160+
fields.push({ name: "By", value: escapeMarkdown(await username(maker)) });
161+
}
137162
return { title, fields };
138163
};
139164

@@ -148,7 +173,7 @@ const buildSaleEmbed = async (
148173
const { quantity, decimals, symbol } = payment;
149174
const price = formatAmount(quantity, decimals, symbol);
150175
fields.push({ name: "Price", value: price });
151-
fields.push({ name: "By", value: await username(buyer) });
176+
fields.push({ name: "By", value: escapeMarkdown(await username(buyer)) });
152177
return { title: "Purchased:", fields };
153178
};
154179

@@ -174,27 +199,48 @@ const buildTransferEmbed = async (
174199
(event as unknown as { asset?: { token_standard?: string } })?.asset
175200
?.token_standard;
176201

177-
const toName = await username(to_address);
202+
const toName = escapeMarkdown(await username(to_address));
178203
const toValue = formatEditionsText(toName, tokenStandard, quantity);
179204
fields.push({ name: "To", value: toValue });
180205
return { title: "Minted:", fields };
181206
}
182207
if (kind === "burn") {
183-
fields.push({ name: "From", value: await username(from_address) });
208+
fields.push({
209+
name: "From",
210+
value: escapeMarkdown(await username(from_address)),
211+
});
184212
return { title: "Burned:", fields };
185213
}
186-
fields.push({ name: "From", value: await username(from_address) });
187-
fields.push({ name: "To", value: await username(to_address) });
214+
fields.push({
215+
name: "From",
216+
value: escapeMarkdown(await username(from_address)),
217+
});
218+
fields.push({
219+
name: "To",
220+
value: escapeMarkdown(await username(to_address)),
221+
});
188222
return { title: "Transferred:", fields };
189223
};
190224

191-
const isOrderLikeType = (t: unknown): boolean => {
225+
const isOrderLikeType = (t: unknown, orderType?: string): boolean => {
192226
const s = String(t);
227+
// Check order_type for "order" events
228+
if (s === "order" && orderType) {
229+
return (
230+
orderType === "listing" ||
231+
orderType === "item_offer" ||
232+
orderType === "trait_offer" ||
233+
orderType === "collection_offer" ||
234+
orderType === "auction"
235+
);
236+
}
237+
// Legacy event_type handling
193238
return (
194239
s === BotEvent.listing ||
195240
s === BotEvent.offer ||
196241
s === "trait_offer" ||
197-
s === "collection_offer"
242+
s === "collection_offer" ||
243+
s === "listing"
198244
);
199245
};
200246

@@ -216,7 +262,7 @@ const embed = async (event: AggregatorEvent) => {
216262
}
217263
let fields: Field[] = [];
218264
let title = "";
219-
if (isOrderLikeType(event_type)) {
265+
if (isOrderLikeType(event_type, order_type)) {
220266
({ title, fields } = await buildOrderEmbed(event));
221267
} else if (event_type === EventType.sale) {
222268
({ title, fields } = await buildSaleEmbed(event));
@@ -310,7 +356,7 @@ const buildGroupEmbed = async (group: GroupedEvent): Promise<EmbedBuilder> => {
310356

311357
if (actorAddress) {
312358
const label = getActorLabelForKind(kind);
313-
const actorName = await username(actorAddress);
359+
const actorName = escapeMarkdown(await username(actorAddress));
314360
fields.push({ name: label, value: actorName, inline: true });
315361
}
316362

src/platforms/twitter.ts

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { TwitterApi } from "twitter-api-v2";
2-
import { EventType, getCollectionSlug, opensea, username } from "../opensea";
2+
import { getCollectionSlug, opensea, username } from "../opensea";
33
import type { BotEvent, OpenSeaAssetEvent, OpenSeaPayment } from "../types";
44
import { txHashFor } from "../utils/aggregator";
55
import { MS_PER_SECOND, SECONDS_PER_MINUTE } from "../utils/constants";
@@ -250,7 +250,11 @@ const formatOrderText = async (
250250
if (order_type === "listing") {
251251
return `listed on sale for ${price} by ${name}`;
252252
}
253-
if (order_type === "item_offer") {
253+
if (
254+
order_type === "item_offer" ||
255+
order_type === "offer" ||
256+
order_type === "criteria_offer"
257+
) {
254258
return `has a new offer for ${price} by ${name}`;
255259
}
256260
if (order_type === "collection_offer") {
@@ -259,6 +263,9 @@ const formatOrderText = async (
259263
if (order_type === "trait_offer") {
260264
return `has a new trait offer for ${price} by ${name}`;
261265
}
266+
if (order_type === "auction") {
267+
return `has a new auction starting at ${price} by ${name}`;
268+
}
262269
return "";
263270
};
264271

@@ -345,6 +352,16 @@ const textForTransfer = async (
345352
return text;
346353
};
347354

355+
// Helper sets for event type classification
356+
const ORDER_EVENT_TYPES = new Set([
357+
"order",
358+
"listing",
359+
"offer",
360+
"trait_offer",
361+
"collection_offer",
362+
]);
363+
const TRANSFER_EVENT_TYPES = new Set(["transfer", "mint"]);
364+
348365
export const textForTweet = async (event: OpenSeaAssetEvent) => {
349366
const ev = event;
350367
const {
@@ -356,32 +373,28 @@ export const textForTweet = async (event: OpenSeaAssetEvent) => {
356373
buyer,
357374
expiration_date,
358375
} = ev;
359-
const nft = ev.nft ?? asset;
376+
// Handle null asset from trait/collection offers by converting to undefined
377+
const nft = ev.nft ?? (asset === null ? undefined : asset);
360378
let text = "";
361379

362-
if (
363-
(event_type === "listing" ||
364-
event_type === "offer" ||
365-
event_type === "trait_offer" ||
366-
event_type === "collection_offer") &&
367-
payment &&
368-
maker &&
369-
order_type &&
370-
typeof expiration_date === "number"
371-
) {
380+
// Handle "order" event type - the API returns this for listings and offers
381+
// The actual type is determined by order_type
382+
const isListingOrOfferEvent = ORDER_EVENT_TYPES.has(event_type);
383+
const isTransferOrMintEvent = TRANSFER_EVENT_TYPES.has(event_type);
384+
385+
if (isListingOrOfferEvent && payment && maker && order_type) {
386+
// Use expiration_date if available, default to 0 if not
387+
const expDate = typeof expiration_date === "number" ? expiration_date : 0;
372388
text += await textForOrder({
373389
nft,
374390
payment,
375391
maker,
376392
order_type,
377-
expiration_date,
393+
expiration_date: expDate,
378394
});
379-
} else if (event_type === EventType.sale && payment && buyer) {
395+
} else if (event_type === "sale" && payment && buyer) {
380396
text += await textForSale({ nft, payment, buyer });
381-
} else if (
382-
event_type === EventType.transfer ||
383-
event_type === EventType.mint
384-
) {
397+
} else if (isTransferOrMintEvent) {
385398
text += await textForTransfer(nft, ev);
386399
}
387400
if (nft?.identifier) {
@@ -407,7 +420,9 @@ const uploadImagesForGroup = async (
407420

408421
const images: string[] = [];
409422
for (const e of sortedGroup) {
410-
const url = imageForNFT(e.nft ?? e.asset);
423+
// Handle null asset from trait/collection offers
424+
const nft = e.nft ?? (e.asset === null ? undefined : e.asset);
425+
const url = imageForNFT(nft);
411426
if (url) {
412427
images.push(url);
413428
}
@@ -479,7 +494,10 @@ const tweetSingle = async (
479494
await refetchMintMetadataForEvent(event);
480495

481496
let mediaId: string | undefined;
482-
const image = imageForNFT(event.nft ?? event.asset);
497+
// Handle null asset from trait/collection offers
498+
const nftForImage =
499+
event.nft ?? (event.asset === null ? undefined : event.asset);
500+
const image = imageForNFT(nftForImage);
483501
if (image) {
484502
try {
485503
const { buffer, mimeType } = await fetchImageBuffer(image);

src/types.ts

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,31 @@ export type OpenSeaNFT = {
7272
rarity?: OpenSeaRarity;
7373
};
7474

75+
// OpenSea API event_type values
76+
// Note: The API returns "order" for listings and all offer types
77+
// The actual distinction is made via the order_type field
78+
export type OpenSeaEventType =
79+
| "sale"
80+
| "transfer"
81+
| "mint"
82+
| "order" // Used for listings and offers - check order_type for specifics
83+
// Legacy values (for backwards compatibility with existing code/tests)
84+
| "listing"
85+
| "offer"
86+
| "trait_offer"
87+
| "collection_offer";
88+
89+
// OpenSea API order_type values (when event_type is "order")
90+
export type OpenSeaOrderType =
91+
| "listing"
92+
| "item_offer"
93+
| "trait_offer"
94+
| "collection_offer"
95+
| "auction"
96+
| "criteria_offer"; // Legacy value
97+
7598
export type OpenSeaAssetEvent = {
76-
event_type:
77-
| "sale"
78-
| "transfer"
79-
| "mint"
80-
| "listing"
81-
| "offer"
82-
| "trait_offer"
83-
| "collection_offer";
99+
event_type: OpenSeaEventType;
84100
event_timestamp: number;
85101
transaction?: string;
86102
order_hash?: string;
@@ -92,18 +108,27 @@ export type OpenSeaAssetEvent = {
92108
buyer?: string;
93109
quantity: number;
94110
nft?: OpenSeaNFT;
95-
asset?: OpenSeaNFT;
96-
order_type?: string;
97-
start_date?: number;
111+
asset?: OpenSeaNFT | null; // Can be null for trait/collection offers
112+
order_type?: OpenSeaOrderType;
113+
start_date?: number | null;
98114
expiration_date?: number;
99115
maker?: string;
100116
taker?: string;
101-
criteria?: unknown;
117+
criteria?: OpenSeaCriteria | null; // Can be null for collection offers and listings
102118
is_private_listing?: boolean;
103119
from_address?: string;
104120
to_address?: string;
105121
};
106122

123+
// Criteria for trait offers
124+
export type OpenSeaCriteria = {
125+
collection?: { slug: string };
126+
contract?: { address: string };
127+
trait?: { type: string; value: string };
128+
traits?: Array<{ type: string; value: string }>;
129+
encoded_token_ids?: string | null;
130+
};
131+
107132
export type OpenSeaEventsResponse = {
108133
asset_events: OpenSeaAssetEvent[];
109134
next?: string;

src/utils/aggregator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export type AggregatorEvent = {
1313
hash?: string;
1414
event_timestamp?: number | string;
1515
nft?: NFTLike;
16-
asset?: NFTLike;
16+
asset?: NFTLike | null; // Can be null for trait/collection offers
1717
event_type?: string;
1818
order_type?: string;
1919
};

src/utils/event-grouping.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,8 @@ export const getTopExpensiveEvents = (
397397
const price = payment
398398
? formatAmount(payment.quantity, payment.decimals, payment.symbol)
399399
: null;
400-
const nft = event.nft ?? event.asset;
400+
// Handle null asset from trait/collection offers
401+
const nft = event.nft ?? (event.asset === null ? undefined : event.asset);
401402

402403
return {
403404
event,

0 commit comments

Comments
 (0)