Skip to content

Commit c1ebef2

Browse files
committed
feat: enhance Discord embeds and Twitter formatting
- Add editions field to Discord mint embeds (1/1 for singles, ×N for multiples) - Update Twitter text format for collection/trait offers to be more concise - Add collection logo support for Discord collection and trait offer embeds - Implement collection data fetching with LRU caching - Convert WETH to ETH for display across all activity types - Add comprehensive tests for fetchCollection function - Update test fixtures with real API response data Breaking changes: - Twitter collection offer text: 'has a new collection offer' → 'New collection offer.' - Twitter trait offer text: 'has a new trait offer' → 'New trait offer.' - All WETH amounts now display as ETH
1 parent 7aee5e1 commit c1ebef2

File tree

10 files changed

+259
-26
lines changed

10 files changed

+259
-26
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "opensea-activity-bot",
3-
"version": "3.5.10",
3+
"version": "3.6.0",
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",

src/opensea.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { channelsWithEvents } from "./platforms/discord/discord";
44
import type {
55
OpenSeaAccount,
66
OpenSeaAssetEvent,
7+
OpenSeaCollection,
78
OpenSeaContractResponse,
89
OpenSeaEventsResponse,
910
OpenSeaNFT,
@@ -134,6 +135,7 @@ export const opensea = {
134135
getAccount: (address: string) => `${opensea.api}accounts/${address}`,
135136
getNFT: (tokenId: number) =>
136137
`${opensea.api}chain/${chain}/contract/${TOKEN_ADDRESS}/nfts/${tokenId}`,
138+
getCollection: (slug: string) => `${opensea.api}collections/${slug}`,
137139
GET_OPTS: {
138140
method: "GET",
139141
headers: {
@@ -268,6 +270,31 @@ export const fetchCollectionSlug = async (address: string): Promise<string> => {
268270
export const getCollectionSlug = (): string | undefined =>
269271
collectionStore.getSlug();
270272

273+
/**
274+
* Fetches collection data from OpenSea API by slug.
275+
* Uses LRU cache to avoid repeated API calls.
276+
*/
277+
const COLLECTION_CACHE_CAPACITY = 1;
278+
const collectionCache = new LRUCache<string, OpenSeaCollection>(
279+
COLLECTION_CACHE_CAPACITY
280+
);
281+
282+
export const fetchCollection = async (
283+
slug: string
284+
): Promise<OpenSeaCollection | undefined> => {
285+
const cached = collectionCache.get(slug);
286+
if (cached) {
287+
return cached;
288+
}
289+
290+
const url = opensea.getCollection(slug);
291+
const result = await openseaGet<OpenSeaCollection>(url);
292+
if (result) {
293+
collectionCache.put(slug, result);
294+
}
295+
return result;
296+
};
297+
271298
const filterPrivateListings = (
272299
events: OpenSeaAssetEvent[]
273300
): { filtered: OpenSeaAssetEvent[]; count: number } => {

src/platforms/discord/utils.ts

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import {
44
EmbedBuilder,
55
} from "discord.js";
66
import { format } from "timeago.js";
7-
import { EventType, getCollectionSlug, opensea, username } from "../../opensea";
7+
import {
8+
EventType,
9+
fetchCollection,
10+
getCollectionSlug,
11+
opensea,
12+
username,
13+
} from "../../opensea";
814
import {
915
BotEvent,
1016
type OpenSeaAssetEvent,
@@ -150,6 +156,13 @@ export const buildTransferEmbed = async (
150156
const toName = escapeMarkdown(await username(to_address));
151157
const toValue = formatEditionsText(toName, tokenStandard, quantity);
152158
fields.push({ name: "To", value: toValue });
159+
160+
// Add Editions field: "1/1" for singles or "×{quantity}" for multiples
161+
const totalEditions = quantity ?? 1;
162+
const editionsDisplay = totalEditions === 1 ? "1/1" : ${totalEditions}`;
163+
fields.push({ name: "Editions", value: editionsDisplay, inline: true });
164+
log.debug(`Editions: ${editionsDisplay}`);
165+
153166
return { title: "Minted:", fields };
154167
}
155168
if (kind === "burn") {
@@ -268,6 +281,64 @@ export const setEmbedImage = async (
268281
return attachment;
269282
};
270283

284+
// Helper to set collection logo for collection/trait offers
285+
const setCollectionLogo = async (
286+
embedBuilder: EmbedBuilder
287+
): Promise<AttachmentBuilder | null> => {
288+
const collectionSlug = getCollectionSlug();
289+
if (!collectionSlug) {
290+
return null;
291+
}
292+
293+
const collection = await fetchCollection(collectionSlug);
294+
if (!collection?.image_url) {
295+
return null;
296+
}
297+
298+
const filename = `collection-logo-${collectionSlug}`;
299+
const attachment = await fetchDiscordAttachment(
300+
collection.image_url,
301+
filename
302+
);
303+
if (attachment) {
304+
embedBuilder.setImage(`attachment://${attachment.name}`);
305+
} else {
306+
embedBuilder.setImage(collection.image_url);
307+
}
308+
309+
return attachment;
310+
};
311+
312+
// Helper to set embed image/attachment based on event type
313+
const setEmbedImageForEvent = async (
314+
embedBuilder: EmbedBuilder,
315+
event: AggregatorEvent,
316+
nft:
317+
| { image_url?: string; identifier?: string | number; opensea_url?: string }
318+
| null
319+
| undefined
320+
): Promise<AttachmentBuilder | null> => {
321+
const order_type = (event as { order_type?: OpenSeaOrderType | string })
322+
.order_type;
323+
const isCollectionOffer =
324+
order_type === ("collection_offer" satisfies OpenSeaOrderType);
325+
const isTraitOffer =
326+
order_type === ("trait_offer" satisfies OpenSeaOrderType);
327+
328+
if (isCollectionOffer || isTraitOffer) {
329+
embedBuilder.setURL(opensea.collectionURL());
330+
return await setCollectionLogo(embedBuilder);
331+
}
332+
333+
if (nft && Object.keys(nft).length > 0) {
334+
embedBuilder.setURL(nft.opensea_url ?? null);
335+
return await setEmbedImage(embedBuilder, nft);
336+
}
337+
338+
embedBuilder.setURL(opensea.collectionURL());
339+
return null;
340+
};
341+
271342
export type EmbedResult = {
272343
embed: EmbedBuilder;
273344
attachment: AttachmentBuilder | null;
@@ -303,14 +374,7 @@ export const buildEmbed = async (
303374
})
304375
);
305376

306-
let attachment: AttachmentBuilder | null = null;
307-
308-
if (nft && Object.keys(nft).length > 0) {
309-
built.setURL(nft.opensea_url ?? null);
310-
attachment = await setEmbedImage(built, nft);
311-
} else {
312-
built.setURL(opensea.collectionURL());
313-
}
377+
const attachment = await setEmbedImageForEvent(built, event, nft);
314378

315379
return { embed: built, attachment };
316380
};

src/platforms/twitter/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,10 @@ export const formatOrderText = async (
6464
return `has a new offer for ${price} by ${name}`;
6565
}
6666
if (order_type === ("collection_offer" satisfies OpenSeaOrderType)) {
67-
return `has a new collection offer for ${price} by ${name}`;
67+
return `New collection offer. ${price} by ${name}`;
6868
}
6969
if (order_type === ("trait_offer" satisfies OpenSeaOrderType)) {
70-
return `has a new trait offer for ${price} by ${name}`;
70+
return `New trait offer. ${price} by ${name}`;
7171
}
7272
return "";
7373
};

src/utils/utils.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ export const classifyTransfer = (event: {
106106
/**
107107
* Formats amount, decimals, and symbols to final string output.
108108
* Rounds to MAX_DECIMALS places (instead of truncating).
109+
* Converts WETH to ETH for display.
109110
*/
110111
export const formatAmount = (
111112
amount: BigNumberish,
@@ -128,7 +129,10 @@ export const formatAmount = (
128129
.replace(TRAILING_ZEROS_REGEX, "$1")
129130
.replace(TRAILING_DOT_REGEX, "");
130131

131-
return `${value} ${symbol}`;
132+
// Convert WETH to ETH for display
133+
const displaySymbol = symbol === "WETH" ? "ETH" : symbol;
134+
135+
return `${value} ${displaySymbol}`;
132136
};
133137

134138
const WIDTH_QUERY_PARAM = /w=(\d)*/;

test/fixtures/opensea/get-collection.json

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
"collection": "glyphbots",
33
"name": "GlyphBots",
44
"description": "deep in the blockchain's murky shadows, where forbidden glyphs surf among sketchy code, glyphbots are shaking off their digital snooze",
5-
"image_url": "https://i2.seadn.io/collection/glyphbots/image_type_logo/eb2761fda8e04533a74e6477a35ab0/20eb2761fda8e04533a74e6477a35ab0.png",
6-
"banner_image_url": "https://i2.seadn.io/collection/glyphbots/image_type_hero_desktop/fda1f7461bb693ff0286d23f6f64e5/adfda1f7461bb693ff0286d23f6f64e5.png?fit=inside",
5+
"image_url": "https://i2c.seadn.io/collection/glyphbots/image_type_logo/eb2761fda8e04533a74e6477a35ab0/20eb2761fda8e04533a74e6477a35ab0.png",
6+
"banner_image_url": "https://i2c.seadn.io/collection/glyphbots/image_type_hero_desktop/8827cc22c7859c153993d8c8f6eef4/d18827cc22c7859c153993d8c8f6eef4.png?fit=inside",
77
"owner": "0x00a839de7922491683f547a67795204763ff8237",
88
"safelist_status": "approved",
99
"category": "pfps",
@@ -14,7 +14,7 @@
1414
"opensea_url": "https://opensea.io/collection/glyphbots",
1515
"project_url": "https://glyphbots.com",
1616
"wiki_url": "",
17-
"discord_url": "",
17+
"discord_url": "https://discord.gg/Dr3RBst4Fu",
1818
"telegram_url": "",
1919
"twitter_username": "glyphbots",
2020
"instagram_username": "",
@@ -27,19 +27,20 @@
2727
"editors": ["0x00a839de7922491683f547a67795204763ff8237"],
2828
"fees": [
2929
{
30-
"fee": 0.5,
30+
"fee": 1.0,
3131
"recipient": "0x0000a26b00c1f0df003000390027140000faa719",
3232
"required": true
3333
}
3434
],
3535
"rarity": {
36-
"calculated_at": "2025-08-30T06:24:37.545539",
37-
"max_rank": 11111,
38-
"total_supply": 11111,
36+
"calculated_at": "2025-12-08T05:45:03.544610",
37+
"max_rank": 10734,
38+
"total_supply": 10734,
3939
"strategy_id": "openrarity",
4040
"strategy_version": "1.0"
4141
},
42-
"total_supply": 11111,
42+
"total_supply": 10734,
43+
"unique_item_count": 10734,
4344
"created_date": "2025-08-23",
4445
"payment_tokens": []
4546
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import collectionFixture from "../fixtures/opensea/get-collection.json";
2+
3+
// Mock the fetch function
4+
global.fetch = jest.fn();
5+
6+
jest.mock("../../src/utils/logger", () => {
7+
const base = {
8+
debug: jest.fn(),
9+
info: jest.fn(),
10+
warn: jest.fn(),
11+
error: jest.fn(),
12+
};
13+
return {
14+
logger: base,
15+
prefixedLogger: () => base,
16+
};
17+
});
18+
19+
describe("fetchCollection", () => {
20+
beforeEach(() => {
21+
jest.clearAllMocks();
22+
// Clear the module cache to reset LRU cache
23+
jest.resetModules();
24+
});
25+
26+
it("should fetch collection data successfully", async () => {
27+
// Re-import to get fresh cache
28+
const { fetchCollection: freshFetchCollection } = await import(
29+
"../../src/opensea"
30+
);
31+
32+
(global.fetch as jest.Mock).mockResolvedValueOnce({
33+
ok: true,
34+
json: async () => collectionFixture,
35+
});
36+
37+
const result = await freshFetchCollection("glyphbots");
38+
39+
expect(result).toEqual(collectionFixture);
40+
expect(result?.image_url).toBe(
41+
"https://i2c.seadn.io/collection/glyphbots/image_type_logo/eb2761fda8e04533a74e6477a35ab0/20eb2761fda8e04533a74e6477a35ab0.png"
42+
);
43+
expect(result?.collection).toBe("glyphbots");
44+
expect(result?.name).toBe("GlyphBots");
45+
expect(global.fetch).toHaveBeenCalledTimes(1);
46+
expect(global.fetch).toHaveBeenCalledWith(
47+
"https://api.opensea.io/api/v2/collections/glyphbots",
48+
expect.objectContaining({
49+
method: "GET",
50+
headers: expect.objectContaining({
51+
Accept: "application/json",
52+
}),
53+
})
54+
);
55+
});
56+
57+
it("should cache collection data and return cached result on second call", async () => {
58+
// Re-import to get fresh cache
59+
const { fetchCollection: freshFetchCollection } = await import(
60+
"../../src/opensea"
61+
);
62+
63+
(global.fetch as jest.Mock).mockResolvedValueOnce({
64+
ok: true,
65+
json: async () => collectionFixture,
66+
});
67+
68+
const firstResult = await freshFetchCollection("glyphbots");
69+
expect(firstResult).toEqual(collectionFixture);
70+
expect(global.fetch).toHaveBeenCalledTimes(1);
71+
72+
// Second call should use cache
73+
const secondResult = await freshFetchCollection("glyphbots");
74+
expect(secondResult).toEqual(collectionFixture);
75+
// Fetch should still only be called once due to caching
76+
expect(global.fetch).toHaveBeenCalledTimes(1);
77+
});
78+
79+
it("should return undefined when API request fails", async () => {
80+
// Re-import to get fresh cache
81+
const { fetchCollection: freshFetchCollection } = await import(
82+
"../../src/opensea"
83+
);
84+
85+
(global.fetch as jest.Mock).mockResolvedValueOnce({
86+
ok: false,
87+
status: 404,
88+
statusText: "Not Found",
89+
text: async () => "Not Found",
90+
});
91+
92+
const result = await freshFetchCollection("nonexistent");
93+
94+
expect(result).toBeUndefined();
95+
expect(global.fetch).toHaveBeenCalledTimes(1);
96+
});
97+
98+
it("should handle fetch errors gracefully", async () => {
99+
// Re-import to get fresh cache
100+
const { fetchCollection: freshFetchCollection } = await import(
101+
"../../src/opensea"
102+
);
103+
104+
(global.fetch as jest.Mock).mockRejectedValueOnce(
105+
new Error("Network error")
106+
);
107+
108+
const result = await freshFetchCollection("glyphbots");
109+
110+
expect(result).toBeUndefined();
111+
});
112+
113+
it("should include image_url field for Discord embeds", async () => {
114+
// Re-import to get fresh cache
115+
const { fetchCollection: freshFetchCollection } = await import(
116+
"../../src/opensea"
117+
);
118+
119+
(global.fetch as jest.Mock).mockResolvedValueOnce({
120+
ok: true,
121+
json: async () => collectionFixture,
122+
});
123+
124+
const result = await freshFetchCollection("glyphbots");
125+
126+
expect(result?.image_url).toBeDefined();
127+
expect(typeof result?.image_url).toBe("string");
128+
expect(result?.image_url).toContain("seadn.io");
129+
});
130+
});

test/opensea/live-event-types.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -370,13 +370,13 @@ describe("Live Twitter Text Output", () => {
370370
test("generates text for trait offer events", async () => {
371371
const event = getTraitOfferEvents()[0];
372372
const text = await textForTweet(event);
373-
expect(text).toContain("has a new trait offer for");
373+
expect(text).toContain("New trait offer.");
374374
});
375375

376376
test("generates text for collection offer events", async () => {
377377
const event = getCollectionOfferEvents()[0];
378378
const text = await textForTweet(event);
379-
expect(text).toContain("has a new collection offer for");
379+
expect(text).toContain("New collection offer.");
380380
});
381381

382382
test("generates text for sale events", async () => {

0 commit comments

Comments
 (0)