discolisting 0.0.0-stage → 0.2.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.
package/README.md CHANGED
@@ -1,3 +1,86 @@
1
- # Temporary Holding Version
1
+ # discolisting
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Official TypeScript SDK for the [DiscoListing](https://discolisting.com) public API. Zero dependencies; works in Node 18+, Bun,
4
+ Deno and edge runtimes (uses `fetch` and Web Crypto).
5
+
6
+ ```sh
7
+ npm install discolisting
8
+ ```
9
+
10
+ ## Quick start
11
+
12
+ ```ts
13
+ import { ListingClient, verifyWebhook } from 'discolisting'
14
+
15
+ const client = new ListingClient({
16
+ apiKey: process.env.LISTING_API_KEY, // from your listing's Integrations page
17
+ })
18
+
19
+ // Bots: post your server count now and every 30 minutes
20
+ const stop = client.autoPost(() => ({ serverCount: bot.guilds.cache.size }), {
21
+ onError: console.error,
22
+ })
23
+
24
+ // Reward voters (24 h cooldown for servers, 12 h for bots)
25
+ const { voted, nextVoteAt } = await client.hasVoted(interaction.user.id)
26
+
27
+ // Recent votes, newest first
28
+ const votes = await client.getVotes({ limit: 50 })
29
+
30
+ // Public lookups (no key needed)
31
+ const server = await client.getServer('my-server-slug')
32
+ const someBot = await client.getBot('123456789012345678')
33
+
34
+ // Embed a live widget in a README
35
+ const img = client.widgetUrl('server', 'my-server-slug', { style: 'badge', stats: ['members'] })
36
+ ```
37
+
38
+ ## Vote webhooks
39
+
40
+ Each vote is POSTed to your endpoint, signed with `X-Listing-Signature: t=<unix>,v1=<hmac>`.
41
+ Verify it against the **raw** request body (not re-serialised JSON):
42
+
43
+ ```ts
44
+ import { parseWebhook } from 'discolisting'
45
+
46
+ app.post('/listing-webhook', express.raw({ type: 'application/json' }), async (req, res) => {
47
+ const event = await parseWebhook(
48
+ req.body.toString('utf8'),
49
+ req.header('x-listing-signature'),
50
+ process.env.WEBHOOK_SECRET!,
51
+ )
52
+ if (!event) return res.sendStatus(401)
53
+ if (event.event === 'vote') await rewardUser(event.data.userId)
54
+ res.sendStatus(204)
55
+ })
56
+ ```
57
+
58
+ Signatures older than five minutes are rejected to stop replays (`toleranceSeconds` to change).
59
+
60
+ ## Testing against another site
61
+
62
+ The client talks to `https://discolisting.com/api/v1` by default. Point it elsewhere (a local dev server or
63
+ a staging tunnel) with `baseUrl`:
64
+
65
+ ```ts
66
+ new ListingClient({ apiKey, baseUrl: 'http://localhost:3000/api/v1' })
67
+ ```
68
+
69
+ ## Errors
70
+
71
+ Failed requests throw `ListingApiError` with `status`, `message` and, for rate limits (429),
72
+ `retryAfter` in seconds.
73
+
74
+ ## API
75
+
76
+ | Method | Needs key | Description |
77
+ | --------------------------------------------------- | --------- | --------------------------------------- |
78
+ | `getServer(id)` | no | Server by guild ID, listing ID or slug |
79
+ | `getBot(id)` | no | Bot by bot ID, listing ID or slug |
80
+ | `hasVoted(userId)` | yes | Vote status for a Discord user |
81
+ | `getVotes({ limit, before })` | yes | Recent votes, newest first |
82
+ | `postStats({ serverCount, shardCount?, shardId? })` | yes | Report bot server count |
83
+ | `autoPost(getStats, { intervalMs, onError })` | yes | Post stats on a timer; returns `stop()` |
84
+ | `widgetUrl(kind, slug, options)` | no | URL of the live SVG widget |
85
+ | `verifyWebhook(body, header, secret)` | – | Check a webhook signature |
86
+ | `parseWebhook(body, header, secret)` | – | Verify and parse a webhook |
package/dist/index.cjs ADDED
@@ -0,0 +1,160 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/index.ts
3
+ /**
4
+ * Official TypeScript SDK for the DiscoListing public API (https://discolisting.com/api/v1).
5
+ * Zero dependencies; works in Node 18+, Bun, Deno and edge runtimes (fetch + Web Crypto).
6
+ */
7
+ const SDK_VERSION = "0.2.0";
8
+ /** The public API. Override `baseUrl` only to test against a local or staging site. */
9
+ const DEFAULT_BASE_URL = "https://discolisting.com/api/v1";
10
+ var ListingApiError = class extends Error {
11
+ status;
12
+ retryAfter;
13
+ constructor(status, message, retryAfter = null) {
14
+ super(message);
15
+ this.status = status;
16
+ this.retryAfter = retryAfter;
17
+ this.name = "ListingApiError";
18
+ }
19
+ };
20
+ var ListingClient = class {
21
+ apiKey;
22
+ baseUrl;
23
+ fetchImpl;
24
+ timeoutMs;
25
+ constructor(opts = {}) {
26
+ this.apiKey = opts.apiKey;
27
+ this.baseUrl = (opts.baseUrl || "https://discolisting.com/api/v1").replace(/\/+$/, "");
28
+ this.fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
29
+ this.timeoutMs = opts.timeoutMs ?? 1e4;
30
+ }
31
+ async request(method, path, opts = {}) {
32
+ const url = new URL(`${this.baseUrl}${path}`);
33
+ for (const [k, v] of Object.entries(opts.query ?? {})) if (v !== void 0) url.searchParams.set(k, String(v));
34
+ const headers = {
35
+ Accept: "application/json",
36
+ "User-Agent": `listing-sdk/${SDK_VERSION}`
37
+ };
38
+ if (opts.auth) {
39
+ if (!this.apiKey) throw new ListingApiError(401, "This method needs an apiKey");
40
+ headers.Authorization = `Bearer ${this.apiKey}`;
41
+ }
42
+ if (opts.body !== void 0) headers["Content-Type"] = "application/json";
43
+ const res = await this.fetchImpl(url, {
44
+ method,
45
+ headers,
46
+ body: opts.body === void 0 ? void 0 : JSON.stringify(opts.body),
47
+ signal: AbortSignal.timeout(this.timeoutMs)
48
+ });
49
+ const data = await res.json().catch(() => ({}));
50
+ if (!res.ok) {
51
+ const retry = res.headers.get("retry-after");
52
+ throw new ListingApiError(res.status, data.error ?? res.statusText, retry ? Number(retry) : null);
53
+ }
54
+ return data;
55
+ }
56
+ /** Look up a server by guild ID, listing ID or slug. */
57
+ getServer(id) {
58
+ return this.request("GET", `/servers/${encodeURIComponent(id)}`);
59
+ }
60
+ /** Look up a bot by bot ID, listing ID or slug. */
61
+ getBot(id) {
62
+ return this.request("GET", `/bots/${encodeURIComponent(id)}`);
63
+ }
64
+ /** Did this Discord user vote for your listing within its cooldown (24 h servers, 12 h bots)? */
65
+ async hasVoted(userId) {
66
+ return this.request("GET", "/votes/check", {
67
+ query: { userId },
68
+ auth: true
69
+ });
70
+ }
71
+ /** Recent votes, newest first. Pass the last `votedAt` as `before` to page. */
72
+ async getVotes(opts = {}) {
73
+ return (await this.request("GET", "/votes", {
74
+ query: opts,
75
+ auth: true
76
+ })).votes;
77
+ }
78
+ /**
79
+ * Live SVG widget for a listing, for websites and READMEs (no API key needed).
80
+ * Mirrors the options on the listing's Integrations → Embed widget page.
81
+ */
82
+ widgetUrl(kind, slug, opts = {}) {
83
+ const site = this.baseUrl.replace(/\/api\/v1$/, "");
84
+ const q = new URLSearchParams();
85
+ if (opts.style) q.set("style", opts.style);
86
+ if (opts.theme) q.set("theme", opts.theme);
87
+ if (opts.accent) q.set("accent", opts.accent.replace("#", ""));
88
+ if (opts.stats?.length) q.set("stats", opts.stats.join(","));
89
+ const qs = q.toString();
90
+ return `${site}/widget/${kind}/${encodeURIComponent(slug)}.svg${qs ? `?${qs}` : ""}`;
91
+ }
92
+ /** Report your bot's server count (at most once a minute per shard). */
93
+ postStats(stats) {
94
+ return this.request("POST", "/stats", {
95
+ body: stats,
96
+ auth: true
97
+ });
98
+ }
99
+ /**
100
+ * Post stats now and then every `intervalMs` (default 30 minutes, minimum 1 minute).
101
+ * Returns a function that stops the timer. Errors go to `onError` and never throw.
102
+ */
103
+ autoPost(getStats, opts = {}) {
104
+ const interval = Math.max(6e4, opts.intervalMs ?? 18e5);
105
+ const run = async () => {
106
+ try {
107
+ await this.postStats(await getStats());
108
+ } catch (err) {
109
+ opts.onError?.(err);
110
+ }
111
+ };
112
+ run();
113
+ const timer = setInterval(() => void run(), interval);
114
+ timer.unref?.();
115
+ return () => clearInterval(timer);
116
+ }
117
+ };
118
+ function toHex(buf) {
119
+ return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
120
+ }
121
+ function constantTimeEqual(a, b) {
122
+ if (a.length !== b.length) return false;
123
+ let diff = 0;
124
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
125
+ return diff === 0;
126
+ }
127
+ /**
128
+ * Verify a vote webhook. Pass the raw request body (not re-serialised JSON), the
129
+ * `X-Listing-Signature` header and your signing secret. Rejects signatures older than
130
+ * `toleranceSeconds` (default 300) to stop replays.
131
+ */
132
+ async function verifyWebhook(rawBody, signatureHeader, secret, opts = {}) {
133
+ if (!signatureHeader) return false;
134
+ const parts = Object.fromEntries(signatureHeader.split(",").map((kv) => {
135
+ const i = kv.indexOf("=");
136
+ return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
137
+ }));
138
+ const t = Number(parts.t);
139
+ if (!parts.v1 || !Number.isFinite(t)) return false;
140
+ const now = opts.now ?? Math.floor(Date.now() / 1e3);
141
+ if (Math.abs(now - t) > (opts.toleranceSeconds ?? 300)) return false;
142
+ const enc = new TextEncoder();
143
+ const key = await globalThis.crypto.subtle.importKey("raw", enc.encode(secret), {
144
+ name: "HMAC",
145
+ hash: "SHA-256"
146
+ }, false, ["sign"]);
147
+ return constantTimeEqual(toHex(await globalThis.crypto.subtle.sign("HMAC", key, enc.encode(`${String(t)}.${rawBody}`))), parts.v1);
148
+ }
149
+ /** Verify and parse a webhook in one step; returns null when the signature is invalid. */
150
+ async function parseWebhook(rawBody, signatureHeader, secret) {
151
+ if (!await verifyWebhook(rawBody, signatureHeader, secret)) return null;
152
+ return JSON.parse(rawBody);
153
+ }
154
+ //#endregion
155
+ exports.DEFAULT_BASE_URL = DEFAULT_BASE_URL;
156
+ exports.ListingApiError = ListingApiError;
157
+ exports.ListingClient = ListingClient;
158
+ exports.SDK_VERSION = SDK_VERSION;
159
+ exports.parseWebhook = parseWebhook;
160
+ exports.verifyWebhook = verifyWebhook;
@@ -0,0 +1,148 @@
1
+ //#region src/index.d.ts
2
+ /**
3
+ * Official TypeScript SDK for the DiscoListing public API (https://discolisting.com/api/v1).
4
+ * Zero dependencies; works in Node 18+, Bun, Deno and edge runtimes (fetch + Web Crypto).
5
+ */
6
+ export declare const SDK_VERSION = "0.2.0";
7
+ /** The public API. Override `baseUrl` only to test against a local or staging site. */
8
+ export declare const DEFAULT_BASE_URL = "https://discolisting.com/api/v1";
9
+ export interface Server {
10
+ id: string;
11
+ guildId: string;
12
+ slug: string;
13
+ name: string;
14
+ shortDescription: string;
15
+ url: string;
16
+ inviteUrl: string | null;
17
+ iconUrl: string | null;
18
+ memberCount: number;
19
+ onlineCount: number;
20
+ qualityScore: number;
21
+ votes30d: number;
22
+ tags: string[];
23
+ category: string;
24
+ languages: string[];
25
+ nsfw: boolean;
26
+ }
27
+ export interface Bot {
28
+ id: string;
29
+ botId: string;
30
+ slug: string;
31
+ name: string;
32
+ shortDescription: string;
33
+ url: string;
34
+ inviteUrl: string;
35
+ iconUrl: string | null;
36
+ serverCount: number;
37
+ shardCount: number;
38
+ statsUpdatedAt: string | null;
39
+ qualityScore: number;
40
+ votes30d: number;
41
+ tags: string[];
42
+ prefix: string | null;
43
+ nsfw: boolean;
44
+ }
45
+ export interface VoteCheck {
46
+ /** True while the user's last vote for your listing is inside its cooldown. */
47
+ voted: boolean;
48
+ votedAt: string | null;
49
+ /** When the user can vote again (null if they have not voted recently). */
50
+ nextVoteAt: string | null;
51
+ /** Hours between votes: 24 for servers, 12 for bots. */
52
+ cooldownHours: number;
53
+ }
54
+ export interface VoteEntry {
55
+ userId: string;
56
+ votedAt: string;
57
+ }
58
+ export interface BotStatsInput {
59
+ serverCount: number;
60
+ shardCount?: number;
61
+ shardId?: number;
62
+ }
63
+ /** Body of a vote webhook delivery. */
64
+ export interface VoteWebhook {
65
+ id: string;
66
+ event: 'vote' | 'test';
67
+ test: boolean;
68
+ data: {
69
+ type: 'vote' | 'test';
70
+ listingId: string;
71
+ /** Discord user ID of the voter. */
72
+ userId: string;
73
+ isWeekend: boolean;
74
+ votedAt: string;
75
+ };
76
+ }
77
+ export declare class ListingApiError extends Error {
78
+ status: number;
79
+ /** Seconds until the rate limit resets (429 only). */
80
+ retryAfter: number | null;
81
+ constructor(status: number, message: string,
82
+ /** Seconds until the rate limit resets (429 only). */
83
+ retryAfter?: number | null);
84
+ }
85
+ export interface ListingClientOptions {
86
+ /** Listing API key (lst_…), from your listing's Integrations page. Needed for vote checks and stats. */
87
+ apiKey?: string;
88
+ /** Defaults to https://discolisting.com/api/v1. Set it only for local or staging testing. */
89
+ baseUrl?: string;
90
+ fetch?: typeof fetch;
91
+ /** Request timeout in milliseconds (default 10 000). */
92
+ timeoutMs?: number;
93
+ }
94
+ export declare class ListingClient {
95
+ private readonly apiKey?;
96
+ private readonly baseUrl;
97
+ private readonly fetchImpl;
98
+ private readonly timeoutMs;
99
+ constructor(opts?: ListingClientOptions);
100
+ private request;
101
+ /** Look up a server by guild ID, listing ID or slug. */
102
+ getServer(id: string): Promise<Server>;
103
+ /** Look up a bot by bot ID, listing ID or slug. */
104
+ getBot(id: string): Promise<Bot>;
105
+ /** Did this Discord user vote for your listing within its cooldown (24 h servers, 12 h bots)? */
106
+ hasVoted(userId: string): Promise<VoteCheck>;
107
+ /** Recent votes, newest first. Pass the last `votedAt` as `before` to page. */
108
+ getVotes(opts?: {
109
+ limit?: number;
110
+ before?: string;
111
+ }): Promise<VoteEntry[]>;
112
+ /**
113
+ * Live SVG widget for a listing, for websites and READMEs (no API key needed).
114
+ * Mirrors the options on the listing's Integrations → Embed widget page.
115
+ */
116
+ widgetUrl(kind: 'server' | 'bot', slug: string, opts?: {
117
+ style?: 'card' | 'badge';
118
+ theme?: 'dark' | 'light';
119
+ /** Hex colour, with or without '#'. */
120
+ accent?: string;
121
+ stats?: ('members' | 'online' | 'votes' | 'score')[];
122
+ }): string;
123
+ /** Report your bot's server count (at most once a minute per shard). */
124
+ postStats(stats: BotStatsInput): Promise<{
125
+ serverCount: number;
126
+ shardCount: number;
127
+ }>;
128
+ /**
129
+ * Post stats now and then every `intervalMs` (default 30 minutes, minimum 1 minute).
130
+ * Returns a function that stops the timer. Errors go to `onError` and never throw.
131
+ */
132
+ autoPost(getStats: () => BotStatsInput | Promise<BotStatsInput>, opts?: {
133
+ intervalMs?: number;
134
+ onError?: (err: unknown) => void;
135
+ }): () => void;
136
+ }
137
+ /**
138
+ * Verify a vote webhook. Pass the raw request body (not re-serialised JSON), the
139
+ * `X-Listing-Signature` header and your signing secret. Rejects signatures older than
140
+ * `toleranceSeconds` (default 300) to stop replays.
141
+ */
142
+ export declare function verifyWebhook(rawBody: string, signatureHeader: string | null | undefined, secret: string, opts?: {
143
+ toleranceSeconds?: number;
144
+ now?: number;
145
+ }): Promise<boolean>;
146
+ /** Verify and parse a webhook in one step; returns null when the signature is invalid. */
147
+ export declare function parseWebhook(rawBody: string, signatureHeader: string | null | undefined, secret: string): Promise<VoteWebhook | null>;
148
+ //#endregion
@@ -0,0 +1,148 @@
1
+ //#region src/index.d.ts
2
+ /**
3
+ * Official TypeScript SDK for the DiscoListing public API (https://discolisting.com/api/v1).
4
+ * Zero dependencies; works in Node 18+, Bun, Deno and edge runtimes (fetch + Web Crypto).
5
+ */
6
+ export declare const SDK_VERSION = "0.2.0";
7
+ /** The public API. Override `baseUrl` only to test against a local or staging site. */
8
+ export declare const DEFAULT_BASE_URL = "https://discolisting.com/api/v1";
9
+ export interface Server {
10
+ id: string;
11
+ guildId: string;
12
+ slug: string;
13
+ name: string;
14
+ shortDescription: string;
15
+ url: string;
16
+ inviteUrl: string | null;
17
+ iconUrl: string | null;
18
+ memberCount: number;
19
+ onlineCount: number;
20
+ qualityScore: number;
21
+ votes30d: number;
22
+ tags: string[];
23
+ category: string;
24
+ languages: string[];
25
+ nsfw: boolean;
26
+ }
27
+ export interface Bot {
28
+ id: string;
29
+ botId: string;
30
+ slug: string;
31
+ name: string;
32
+ shortDescription: string;
33
+ url: string;
34
+ inviteUrl: string;
35
+ iconUrl: string | null;
36
+ serverCount: number;
37
+ shardCount: number;
38
+ statsUpdatedAt: string | null;
39
+ qualityScore: number;
40
+ votes30d: number;
41
+ tags: string[];
42
+ prefix: string | null;
43
+ nsfw: boolean;
44
+ }
45
+ export interface VoteCheck {
46
+ /** True while the user's last vote for your listing is inside its cooldown. */
47
+ voted: boolean;
48
+ votedAt: string | null;
49
+ /** When the user can vote again (null if they have not voted recently). */
50
+ nextVoteAt: string | null;
51
+ /** Hours between votes: 24 for servers, 12 for bots. */
52
+ cooldownHours: number;
53
+ }
54
+ export interface VoteEntry {
55
+ userId: string;
56
+ votedAt: string;
57
+ }
58
+ export interface BotStatsInput {
59
+ serverCount: number;
60
+ shardCount?: number;
61
+ shardId?: number;
62
+ }
63
+ /** Body of a vote webhook delivery. */
64
+ export interface VoteWebhook {
65
+ id: string;
66
+ event: 'vote' | 'test';
67
+ test: boolean;
68
+ data: {
69
+ type: 'vote' | 'test';
70
+ listingId: string;
71
+ /** Discord user ID of the voter. */
72
+ userId: string;
73
+ isWeekend: boolean;
74
+ votedAt: string;
75
+ };
76
+ }
77
+ export declare class ListingApiError extends Error {
78
+ status: number;
79
+ /** Seconds until the rate limit resets (429 only). */
80
+ retryAfter: number | null;
81
+ constructor(status: number, message: string,
82
+ /** Seconds until the rate limit resets (429 only). */
83
+ retryAfter?: number | null);
84
+ }
85
+ export interface ListingClientOptions {
86
+ /** Listing API key (lst_…), from your listing's Integrations page. Needed for vote checks and stats. */
87
+ apiKey?: string;
88
+ /** Defaults to https://discolisting.com/api/v1. Set it only for local or staging testing. */
89
+ baseUrl?: string;
90
+ fetch?: typeof fetch;
91
+ /** Request timeout in milliseconds (default 10 000). */
92
+ timeoutMs?: number;
93
+ }
94
+ export declare class ListingClient {
95
+ private readonly apiKey?;
96
+ private readonly baseUrl;
97
+ private readonly fetchImpl;
98
+ private readonly timeoutMs;
99
+ constructor(opts?: ListingClientOptions);
100
+ private request;
101
+ /** Look up a server by guild ID, listing ID or slug. */
102
+ getServer(id: string): Promise<Server>;
103
+ /** Look up a bot by bot ID, listing ID or slug. */
104
+ getBot(id: string): Promise<Bot>;
105
+ /** Did this Discord user vote for your listing within its cooldown (24 h servers, 12 h bots)? */
106
+ hasVoted(userId: string): Promise<VoteCheck>;
107
+ /** Recent votes, newest first. Pass the last `votedAt` as `before` to page. */
108
+ getVotes(opts?: {
109
+ limit?: number;
110
+ before?: string;
111
+ }): Promise<VoteEntry[]>;
112
+ /**
113
+ * Live SVG widget for a listing, for websites and READMEs (no API key needed).
114
+ * Mirrors the options on the listing's Integrations → Embed widget page.
115
+ */
116
+ widgetUrl(kind: 'server' | 'bot', slug: string, opts?: {
117
+ style?: 'card' | 'badge';
118
+ theme?: 'dark' | 'light';
119
+ /** Hex colour, with or without '#'. */
120
+ accent?: string;
121
+ stats?: ('members' | 'online' | 'votes' | 'score')[];
122
+ }): string;
123
+ /** Report your bot's server count (at most once a minute per shard). */
124
+ postStats(stats: BotStatsInput): Promise<{
125
+ serverCount: number;
126
+ shardCount: number;
127
+ }>;
128
+ /**
129
+ * Post stats now and then every `intervalMs` (default 30 minutes, minimum 1 minute).
130
+ * Returns a function that stops the timer. Errors go to `onError` and never throw.
131
+ */
132
+ autoPost(getStats: () => BotStatsInput | Promise<BotStatsInput>, opts?: {
133
+ intervalMs?: number;
134
+ onError?: (err: unknown) => void;
135
+ }): () => void;
136
+ }
137
+ /**
138
+ * Verify a vote webhook. Pass the raw request body (not re-serialised JSON), the
139
+ * `X-Listing-Signature` header and your signing secret. Rejects signatures older than
140
+ * `toleranceSeconds` (default 300) to stop replays.
141
+ */
142
+ export declare function verifyWebhook(rawBody: string, signatureHeader: string | null | undefined, secret: string, opts?: {
143
+ toleranceSeconds?: number;
144
+ now?: number;
145
+ }): Promise<boolean>;
146
+ /** Verify and parse a webhook in one step; returns null when the signature is invalid. */
147
+ export declare function parseWebhook(rawBody: string, signatureHeader: string | null | undefined, secret: string): Promise<VoteWebhook | null>;
148
+ //#endregion
package/dist/index.js ADDED
@@ -0,0 +1,154 @@
1
+ //#region src/index.ts
2
+ /**
3
+ * Official TypeScript SDK for the DiscoListing public API (https://discolisting.com/api/v1).
4
+ * Zero dependencies; works in Node 18+, Bun, Deno and edge runtimes (fetch + Web Crypto).
5
+ */
6
+ const SDK_VERSION = "0.2.0";
7
+ /** The public API. Override `baseUrl` only to test against a local or staging site. */
8
+ const DEFAULT_BASE_URL = "https://discolisting.com/api/v1";
9
+ var ListingApiError = class extends Error {
10
+ status;
11
+ retryAfter;
12
+ constructor(status, message, retryAfter = null) {
13
+ super(message);
14
+ this.status = status;
15
+ this.retryAfter = retryAfter;
16
+ this.name = "ListingApiError";
17
+ }
18
+ };
19
+ var ListingClient = class {
20
+ apiKey;
21
+ baseUrl;
22
+ fetchImpl;
23
+ timeoutMs;
24
+ constructor(opts = {}) {
25
+ this.apiKey = opts.apiKey;
26
+ this.baseUrl = (opts.baseUrl || "https://discolisting.com/api/v1").replace(/\/+$/, "");
27
+ this.fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
28
+ this.timeoutMs = opts.timeoutMs ?? 1e4;
29
+ }
30
+ async request(method, path, opts = {}) {
31
+ const url = new URL(`${this.baseUrl}${path}`);
32
+ for (const [k, v] of Object.entries(opts.query ?? {})) if (v !== void 0) url.searchParams.set(k, String(v));
33
+ const headers = {
34
+ Accept: "application/json",
35
+ "User-Agent": `listing-sdk/${SDK_VERSION}`
36
+ };
37
+ if (opts.auth) {
38
+ if (!this.apiKey) throw new ListingApiError(401, "This method needs an apiKey");
39
+ headers.Authorization = `Bearer ${this.apiKey}`;
40
+ }
41
+ if (opts.body !== void 0) headers["Content-Type"] = "application/json";
42
+ const res = await this.fetchImpl(url, {
43
+ method,
44
+ headers,
45
+ body: opts.body === void 0 ? void 0 : JSON.stringify(opts.body),
46
+ signal: AbortSignal.timeout(this.timeoutMs)
47
+ });
48
+ const data = await res.json().catch(() => ({}));
49
+ if (!res.ok) {
50
+ const retry = res.headers.get("retry-after");
51
+ throw new ListingApiError(res.status, data.error ?? res.statusText, retry ? Number(retry) : null);
52
+ }
53
+ return data;
54
+ }
55
+ /** Look up a server by guild ID, listing ID or slug. */
56
+ getServer(id) {
57
+ return this.request("GET", `/servers/${encodeURIComponent(id)}`);
58
+ }
59
+ /** Look up a bot by bot ID, listing ID or slug. */
60
+ getBot(id) {
61
+ return this.request("GET", `/bots/${encodeURIComponent(id)}`);
62
+ }
63
+ /** Did this Discord user vote for your listing within its cooldown (24 h servers, 12 h bots)? */
64
+ async hasVoted(userId) {
65
+ return this.request("GET", "/votes/check", {
66
+ query: { userId },
67
+ auth: true
68
+ });
69
+ }
70
+ /** Recent votes, newest first. Pass the last `votedAt` as `before` to page. */
71
+ async getVotes(opts = {}) {
72
+ return (await this.request("GET", "/votes", {
73
+ query: opts,
74
+ auth: true
75
+ })).votes;
76
+ }
77
+ /**
78
+ * Live SVG widget for a listing, for websites and READMEs (no API key needed).
79
+ * Mirrors the options on the listing's Integrations → Embed widget page.
80
+ */
81
+ widgetUrl(kind, slug, opts = {}) {
82
+ const site = this.baseUrl.replace(/\/api\/v1$/, "");
83
+ const q = new URLSearchParams();
84
+ if (opts.style) q.set("style", opts.style);
85
+ if (opts.theme) q.set("theme", opts.theme);
86
+ if (opts.accent) q.set("accent", opts.accent.replace("#", ""));
87
+ if (opts.stats?.length) q.set("stats", opts.stats.join(","));
88
+ const qs = q.toString();
89
+ return `${site}/widget/${kind}/${encodeURIComponent(slug)}.svg${qs ? `?${qs}` : ""}`;
90
+ }
91
+ /** Report your bot's server count (at most once a minute per shard). */
92
+ postStats(stats) {
93
+ return this.request("POST", "/stats", {
94
+ body: stats,
95
+ auth: true
96
+ });
97
+ }
98
+ /**
99
+ * Post stats now and then every `intervalMs` (default 30 minutes, minimum 1 minute).
100
+ * Returns a function that stops the timer. Errors go to `onError` and never throw.
101
+ */
102
+ autoPost(getStats, opts = {}) {
103
+ const interval = Math.max(6e4, opts.intervalMs ?? 18e5);
104
+ const run = async () => {
105
+ try {
106
+ await this.postStats(await getStats());
107
+ } catch (err) {
108
+ opts.onError?.(err);
109
+ }
110
+ };
111
+ run();
112
+ const timer = setInterval(() => void run(), interval);
113
+ timer.unref?.();
114
+ return () => clearInterval(timer);
115
+ }
116
+ };
117
+ function toHex(buf) {
118
+ return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
119
+ }
120
+ function constantTimeEqual(a, b) {
121
+ if (a.length !== b.length) return false;
122
+ let diff = 0;
123
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
124
+ return diff === 0;
125
+ }
126
+ /**
127
+ * Verify a vote webhook. Pass the raw request body (not re-serialised JSON), the
128
+ * `X-Listing-Signature` header and your signing secret. Rejects signatures older than
129
+ * `toleranceSeconds` (default 300) to stop replays.
130
+ */
131
+ async function verifyWebhook(rawBody, signatureHeader, secret, opts = {}) {
132
+ if (!signatureHeader) return false;
133
+ const parts = Object.fromEntries(signatureHeader.split(",").map((kv) => {
134
+ const i = kv.indexOf("=");
135
+ return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
136
+ }));
137
+ const t = Number(parts.t);
138
+ if (!parts.v1 || !Number.isFinite(t)) return false;
139
+ const now = opts.now ?? Math.floor(Date.now() / 1e3);
140
+ if (Math.abs(now - t) > (opts.toleranceSeconds ?? 300)) return false;
141
+ const enc = new TextEncoder();
142
+ const key = await globalThis.crypto.subtle.importKey("raw", enc.encode(secret), {
143
+ name: "HMAC",
144
+ hash: "SHA-256"
145
+ }, false, ["sign"]);
146
+ return constantTimeEqual(toHex(await globalThis.crypto.subtle.sign("HMAC", key, enc.encode(`${String(t)}.${rawBody}`))), parts.v1);
147
+ }
148
+ /** Verify and parse a webhook in one step; returns null when the signature is invalid. */
149
+ async function parseWebhook(rawBody, signatureHeader, secret) {
150
+ if (!await verifyWebhook(rawBody, signatureHeader, secret)) return null;
151
+ return JSON.parse(rawBody);
152
+ }
153
+ //#endregion
154
+ export { DEFAULT_BASE_URL, ListingApiError, ListingClient, SDK_VERSION, parseWebhook, verifyWebhook };
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "discolisting",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Official TypeScript SDK for DiscoListing (discolisting.com): bot stats, vote checks, webhooks and widgets.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": {
9
+ "import": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "require": {
14
+ "types": "./dist/index.d.cts",
15
+ "default": "./dist/index.cjs"
16
+ }
17
+ }
18
+ },
19
+ "main": "./dist/index.cjs",
20
+ "module": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "files": [
23
+ "dist",
24
+ "README.md"
25
+ ],
26
+ "scripts": {
27
+ "build": "tsdown",
28
+ "typecheck": "tsc -p tsconfig.json",
29
+ "prepublishOnly": "tsdown"
30
+ },
31
+ "devDependencies": {
32
+ "tsdown": "^0.23.0"
33
+ },
34
+ "sideEffects": false,
35
+ "engines": {
36
+ "node": ">=18"
37
+ },
38
+ "keywords": [
39
+ "bot-list",
40
+ "discolisting",
41
+ "discord",
42
+ "discord-bot",
43
+ "sdk",
44
+ "server-list",
45
+ "votes",
46
+ "webhooks"
47
+ ],
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "homepage": "https://discolisting.com/api/docs#sdk",
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/Generalcyno/Discord-listing.git",
55
+ "directory": "packages/sdk"
56
+ }
57
+ }