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 +85 -2
- package/dist/index.cjs +160 -0
- package/dist/index.d.cts +148 -0
- package/dist/index.d.ts +148 -0
- package/dist/index.js +154 -0
- package/package.json +55 -4
package/README.md
CHANGED
|
@@ -1,3 +1,86 @@
|
|
|
1
|
-
#
|
|
1
|
+
# discolisting
|
|
2
2
|
|
|
3
|
-
|
|
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;
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|