district.js 0.1.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 +94 -0
- package/dist/client.d.ts +50 -0
- package/dist/client.js +139 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +10 -0
- package/dist/rest.d.ts +17 -0
- package/dist/rest.js +49 -0
- package/dist/structures.d.ts +67 -0
- package/dist/structures.js +102 -0
- package/dist/types.d.ts +82 -0
- package/dist/types.js +2 -0
- package/dist/webhook.d.ts +18 -0
- package/dist/webhook.js +37 -0
- package/package.json +26 -0
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# district.js
|
|
2
|
+
|
|
3
|
+
A powerful Node.js module for building [District](https://usedistrict.org) bots — post, edit, react, run slash commands, and receive events with a clean, object-oriented API.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install district.js
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
> Requires Node.js 18+. Create an app and a bot in the [Developer Portal](https://usedistrict.org/developers) to get your **token** (`dbot_…`) and **signing secret** (`dsk_…`).
|
|
10
|
+
|
|
11
|
+
## Your first bot
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import { Client } from "district.js";
|
|
15
|
+
|
|
16
|
+
const client = new Client({
|
|
17
|
+
token: process.env.DISTRICT_TOKEN, // dbot_…
|
|
18
|
+
signingSecret: process.env.DISTRICT_SECRET, // dsk_… (needed to receive events)
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
client.on("ready", (me) => console.log(`Logged in as ${me.displayName}`));
|
|
22
|
+
|
|
23
|
+
client.on("messageCreate", (message) => {
|
|
24
|
+
if (message.authoredByMe) return; // don't reply to yourself
|
|
25
|
+
if (message.content === "!ping") message.reply("pong 🏓");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Starts an HTTP server that receives District's signed webhook events.
|
|
29
|
+
client.listen(3000);
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Point your app's **Event Subscription** (in the portal) at `https://your-host:3000/` and tick the events you want. District signs every event; `district.js` verifies the signature for you before emitting it.
|
|
33
|
+
|
|
34
|
+
## Sending & acting
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
// Send to a channel
|
|
38
|
+
const msg = await client.channels.send(channelId, "hello world");
|
|
39
|
+
|
|
40
|
+
// Act on a message you have
|
|
41
|
+
await msg.edit("hello, world!");
|
|
42
|
+
await msg.react("👍");
|
|
43
|
+
await msg.delete();
|
|
44
|
+
|
|
45
|
+
// Read history
|
|
46
|
+
const recent = await client.channel(channelId).fetchMessages(50);
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Slash commands
|
|
50
|
+
|
|
51
|
+
Register the command in the portal (name + handler URL), then:
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
client.on("interactionCreate", (interaction) => {
|
|
55
|
+
if (interaction.commandName === "weather") {
|
|
56
|
+
interaction.reply(`It's sunny in ${interaction.args.text}`);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Events
|
|
62
|
+
|
|
63
|
+
| Event | Argument |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| `ready` | `ClientUser` |
|
|
66
|
+
| `messageCreate` | `Message` |
|
|
67
|
+
| `messageUpdate` | `Message` |
|
|
68
|
+
| `messageDelete` | `{ id, channelId, spaceId }` |
|
|
69
|
+
| `messageReactionAdd` | `{ messageId, channelId, userId, emoji, spaceId }` |
|
|
70
|
+
| `messageReactionRemove` | `{ messageId, channelId, userId, emoji, spaceId }` |
|
|
71
|
+
| `interactionCreate` | `Interaction` |
|
|
72
|
+
| `error` | `Error` |
|
|
73
|
+
|
|
74
|
+
## Using with Express
|
|
75
|
+
|
|
76
|
+
If you already run a server, mount the middleware (feed it the **raw** body so the signature verifies):
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
import express from "express";
|
|
80
|
+
const app = express();
|
|
81
|
+
app.post("/district", express.text({ type: "*/*" }), client.middleware());
|
|
82
|
+
await client.login();
|
|
83
|
+
app.listen(3000);
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Scopes & permissions
|
|
87
|
+
|
|
88
|
+
A bot only acts where its app is **installed**, and each action needs a scope
|
|
89
|
+
(`send_messages`, `read_messages`, `manage_messages`). See the
|
|
90
|
+
[full API docs](https://usedistrict.org/developers/docs).
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import { REST } from "./rest.js";
|
|
4
|
+
import { ClientUser, Message, TextChannel, Interaction, type ClientContext } from "./structures.js";
|
|
5
|
+
import { type EventHeaders } from "./webhook.js";
|
|
6
|
+
import type { ClientOptions, ReactionEvent, DeletedMessage } from "./types.js";
|
|
7
|
+
/** The events a Client emits, and their argument tuples. */
|
|
8
|
+
export interface ClientEvents {
|
|
9
|
+
ready: [user: ClientUser];
|
|
10
|
+
messageCreate: [message: Message];
|
|
11
|
+
messageUpdate: [message: Message];
|
|
12
|
+
messageDelete: [message: DeletedMessage];
|
|
13
|
+
messageReactionAdd: [reaction: ReactionEvent];
|
|
14
|
+
messageReactionRemove: [reaction: ReactionEvent];
|
|
15
|
+
interactionCreate: [interaction: Interaction];
|
|
16
|
+
error: [error: Error];
|
|
17
|
+
}
|
|
18
|
+
export declare class Client extends EventEmitter implements ClientContext {
|
|
19
|
+
readonly rest: REST;
|
|
20
|
+
/** The bot's own identity — populated after `login()`. */
|
|
21
|
+
user: ClientUser | null;
|
|
22
|
+
private signingSecret?;
|
|
23
|
+
constructor(options: ClientOptions);
|
|
24
|
+
on<K extends keyof ClientEvents>(event: K, listener: (...args: ClientEvents[K]) => void): this;
|
|
25
|
+
once<K extends keyof ClientEvents>(event: K, listener: (...args: ClientEvents[K]) => void): this;
|
|
26
|
+
off<K extends keyof ClientEvents>(event: K, listener: (...args: ClientEvents[K]) => void): this;
|
|
27
|
+
emit<K extends keyof ClientEvents>(event: K, ...args: ClientEvents[K]): boolean;
|
|
28
|
+
/** A handle to a channel, for `.send()` / `.fetchMessages()`. */
|
|
29
|
+
channel(id: string): TextChannel;
|
|
30
|
+
readonly channels: {
|
|
31
|
+
send: (channelId: string, content: string) => Promise<Message>;
|
|
32
|
+
fetch: (channelId: string, limit?: number) => Promise<Message[]>;
|
|
33
|
+
};
|
|
34
|
+
/** Validate the token, load the bot's identity, and emit `ready`. */
|
|
35
|
+
login(): Promise<ClientUser>;
|
|
36
|
+
/**
|
|
37
|
+
* Verify + dispatch a single raw event body. Use this when you run your own
|
|
38
|
+
* server. Returns true if the signature checked out and the event dispatched.
|
|
39
|
+
*/
|
|
40
|
+
handle(rawBody: string, headers: EventHeaders): boolean;
|
|
41
|
+
/** An Express/Connect-style handler. Feed it the RAW request body (use a
|
|
42
|
+
* raw/text body parser) so the HMAC signature can be verified. */
|
|
43
|
+
middleware(): (req: any, res: any) => void;
|
|
44
|
+
/**
|
|
45
|
+
* Start a minimal HTTP server that receives District events. Logs in first if
|
|
46
|
+
* you haven't already. Returns the underlying server.
|
|
47
|
+
*/
|
|
48
|
+
listen(port: number, path?: string): Promise<http.Server>;
|
|
49
|
+
private dispatch;
|
|
50
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// The main entry point — a discord.js-style Client. Post via REST helpers, and
|
|
2
|
+
// receive District's signed webhook events as typed emitter events.
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import http from "node:http";
|
|
5
|
+
import { REST } from "./rest.js";
|
|
6
|
+
import { ClientUser, Message, TextChannel, Interaction } from "./structures.js";
|
|
7
|
+
import { verifyAndParse } from "./webhook.js";
|
|
8
|
+
export class Client extends EventEmitter {
|
|
9
|
+
rest;
|
|
10
|
+
/** The bot's own identity — populated after `login()`. */
|
|
11
|
+
user = null;
|
|
12
|
+
signingSecret;
|
|
13
|
+
constructor(options) {
|
|
14
|
+
super();
|
|
15
|
+
if (!options?.token)
|
|
16
|
+
throw new Error("district.js: `token` is required");
|
|
17
|
+
this.rest = new REST(options.token, options.apiBase);
|
|
18
|
+
this.signingSecret = options.signingSecret;
|
|
19
|
+
}
|
|
20
|
+
// ── typed emitter surface ──────────────────────────────────────────────────
|
|
21
|
+
on(event, listener) {
|
|
22
|
+
return super.on(event, listener);
|
|
23
|
+
}
|
|
24
|
+
once(event, listener) {
|
|
25
|
+
return super.once(event, listener);
|
|
26
|
+
}
|
|
27
|
+
off(event, listener) {
|
|
28
|
+
return super.off(event, listener);
|
|
29
|
+
}
|
|
30
|
+
emit(event, ...args) {
|
|
31
|
+
return super.emit(event, ...args);
|
|
32
|
+
}
|
|
33
|
+
// ── REST convenience ───────────────────────────────────────────────────────
|
|
34
|
+
/** A handle to a channel, for `.send()` / `.fetchMessages()`. */
|
|
35
|
+
channel(id) {
|
|
36
|
+
return new TextChannel(this, id);
|
|
37
|
+
}
|
|
38
|
+
channels = {
|
|
39
|
+
send: (channelId, content) => new TextChannel(this, channelId).send(content),
|
|
40
|
+
fetch: (channelId, limit) => new TextChannel(this, channelId).fetchMessages(limit),
|
|
41
|
+
};
|
|
42
|
+
/** Validate the token, load the bot's identity, and emit `ready`. */
|
|
43
|
+
async login() {
|
|
44
|
+
const res = await this.rest.get("/me");
|
|
45
|
+
this.user = new ClientUser(res.bot);
|
|
46
|
+
this.emit("ready", this.user);
|
|
47
|
+
return this.user;
|
|
48
|
+
}
|
|
49
|
+
// ── receiving events ───────────────────────────────────────────────────────
|
|
50
|
+
/**
|
|
51
|
+
* Verify + dispatch a single raw event body. Use this when you run your own
|
|
52
|
+
* server. Returns true if the signature checked out and the event dispatched.
|
|
53
|
+
*/
|
|
54
|
+
handle(rawBody, headers) {
|
|
55
|
+
if (!this.signingSecret)
|
|
56
|
+
throw new Error("district.js: `signingSecret` is required to receive events");
|
|
57
|
+
const evt = verifyAndParse(this.signingSecret, headers, rawBody);
|
|
58
|
+
if (!evt)
|
|
59
|
+
return false;
|
|
60
|
+
try {
|
|
61
|
+
this.dispatch(evt);
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
this.emit("error", e);
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
/** An Express/Connect-style handler. Feed it the RAW request body (use a
|
|
69
|
+
* raw/text body parser) so the HMAC signature can be verified. */
|
|
70
|
+
middleware() {
|
|
71
|
+
return (req, res) => {
|
|
72
|
+
const raw = typeof req.body === "string" ? req.body : (req.rawBody ?? JSON.stringify(req.body ?? {}));
|
|
73
|
+
let ok = false;
|
|
74
|
+
try {
|
|
75
|
+
ok = this.handle(raw, req.headers);
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
this.emit("error", e);
|
|
79
|
+
}
|
|
80
|
+
res.status(ok ? 204 : 401).end();
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Start a minimal HTTP server that receives District events. Logs in first if
|
|
85
|
+
* you haven't already. Returns the underlying server.
|
|
86
|
+
*/
|
|
87
|
+
async listen(port, path = "/") {
|
|
88
|
+
if (!this.user)
|
|
89
|
+
await this.login();
|
|
90
|
+
const server = http.createServer((req, res) => {
|
|
91
|
+
if (req.method !== "POST" || (path !== "*" && req.url !== path)) {
|
|
92
|
+
res.statusCode = 404;
|
|
93
|
+
res.end();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
let body = "";
|
|
97
|
+
req.on("data", (c) => (body += c));
|
|
98
|
+
req.on("end", () => {
|
|
99
|
+
try {
|
|
100
|
+
const ok = this.handle(body, req.headers);
|
|
101
|
+
res.statusCode = ok ? 204 : 401;
|
|
102
|
+
res.end();
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
this.emit("error", e);
|
|
106
|
+
res.statusCode = 500;
|
|
107
|
+
res.end();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
server.listen(port);
|
|
112
|
+
return server;
|
|
113
|
+
}
|
|
114
|
+
dispatch(evt) {
|
|
115
|
+
switch (evt.event) {
|
|
116
|
+
case "message.create":
|
|
117
|
+
this.emit("messageCreate", new Message(this, evt.message));
|
|
118
|
+
break;
|
|
119
|
+
case "message.update":
|
|
120
|
+
this.emit("messageUpdate", new Message(this, evt.message));
|
|
121
|
+
break;
|
|
122
|
+
case "message.delete":
|
|
123
|
+
this.emit("messageDelete", { id: evt.message.id, channelId: evt.channel_id, spaceId: evt.space_id });
|
|
124
|
+
break;
|
|
125
|
+
case "message.reaction_add":
|
|
126
|
+
this.emit("messageReactionAdd", toReaction(evt));
|
|
127
|
+
break;
|
|
128
|
+
case "message.reaction_remove":
|
|
129
|
+
this.emit("messageReactionRemove", toReaction(evt));
|
|
130
|
+
break;
|
|
131
|
+
case "interaction.create":
|
|
132
|
+
this.emit("interactionCreate", new Interaction(this, evt));
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function toReaction(evt) {
|
|
138
|
+
return { spaceId: evt.space_id, channelId: evt.channel_id, messageId: evt.message_id, userId: evt.user_id, emoji: evt.emoji };
|
|
139
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { Client, type ClientEvents } from "./client.js";
|
|
2
|
+
export { REST, DistrictAPIError, DEFAULT_API_BASE } from "./rest.js";
|
|
3
|
+
export { ClientUser, Message, TextChannel, Interaction, type ClientContext } from "./structures.js";
|
|
4
|
+
export { verifySignature, parseEvent, verifyAndParse, type EventHeaders } from "./webhook.js";
|
|
5
|
+
export type { ClientOptions, RawMessage, RawSelfUser, DistrictEvent, ReactionEvent, DeletedMessage, } from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// district.js — build District bots in Node.js.
|
|
2
|
+
//
|
|
3
|
+
// import { Client } from "district.js";
|
|
4
|
+
// const client = new Client({ token: "dbot_…", signingSecret: "dsk_…" });
|
|
5
|
+
// client.on("messageCreate", (m) => { if (m.content === "!ping") m.reply("pong"); });
|
|
6
|
+
// client.listen(3000);
|
|
7
|
+
export { Client } from "./client.js";
|
|
8
|
+
export { REST, DistrictAPIError, DEFAULT_API_BASE } from "./rest.js";
|
|
9
|
+
export { ClientUser, Message, TextChannel, Interaction } from "./structures.js";
|
|
10
|
+
export { verifySignature, parseEvent, verifyAndParse } from "./webhook.js";
|
package/dist/rest.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const DEFAULT_API_BASE = "https://dfbestmduuyrsqvwrekn.functions.supabase.co/bot-api";
|
|
2
|
+
export declare class DistrictAPIError extends Error {
|
|
3
|
+
status: number;
|
|
4
|
+
code: string;
|
|
5
|
+
constructor(status: number, code: string);
|
|
6
|
+
}
|
|
7
|
+
export declare class REST {
|
|
8
|
+
private token;
|
|
9
|
+
private base;
|
|
10
|
+
constructor(token: string, base?: string);
|
|
11
|
+
request<T = any>(method: string, path: string, body?: unknown): Promise<T>;
|
|
12
|
+
get<T = any>(path: string): Promise<T>;
|
|
13
|
+
post<T = any>(path: string, body?: unknown): Promise<T>;
|
|
14
|
+
patch<T = any>(path: string, body?: unknown): Promise<T>;
|
|
15
|
+
delete<T = any>(path: string): Promise<T>;
|
|
16
|
+
put<T = any>(path: string, body?: unknown): Promise<T>;
|
|
17
|
+
}
|
package/dist/rest.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Thin REST client for the District bot API. Every request is authenticated with
|
|
2
|
+
// the bot token; non-2xx responses throw a DistrictAPIError.
|
|
3
|
+
export const DEFAULT_API_BASE = "https://dfbestmduuyrsqvwrekn.functions.supabase.co/bot-api";
|
|
4
|
+
export class DistrictAPIError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
code;
|
|
7
|
+
constructor(status, code) {
|
|
8
|
+
super(`District API error ${status}: ${code}`);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.name = "DistrictAPIError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export class REST {
|
|
15
|
+
token;
|
|
16
|
+
base;
|
|
17
|
+
constructor(token, base = DEFAULT_API_BASE) {
|
|
18
|
+
this.token = token;
|
|
19
|
+
this.base = base.replace(/\/+$/, "");
|
|
20
|
+
}
|
|
21
|
+
async request(method, path, body) {
|
|
22
|
+
const res = await fetch(`${this.base}${path}`, {
|
|
23
|
+
method,
|
|
24
|
+
headers: {
|
|
25
|
+
authorization: `Bot ${this.token}`,
|
|
26
|
+
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
|
27
|
+
},
|
|
28
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
29
|
+
});
|
|
30
|
+
const text = await res.text();
|
|
31
|
+
const json = text ? safeParse(text) : {};
|
|
32
|
+
if (!res.ok)
|
|
33
|
+
throw new DistrictAPIError(res.status, json?.error ?? res.statusText);
|
|
34
|
+
return json;
|
|
35
|
+
}
|
|
36
|
+
get(path) { return this.request("GET", path); }
|
|
37
|
+
post(path, body) { return this.request("POST", path, body); }
|
|
38
|
+
patch(path, body) { return this.request("PATCH", path, body); }
|
|
39
|
+
delete(path) { return this.request("DELETE", path); }
|
|
40
|
+
put(path, body) { return this.request("PUT", path, body); }
|
|
41
|
+
}
|
|
42
|
+
function safeParse(s) {
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(s);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { REST } from "./rest.js";
|
|
2
|
+
import type { RawMessage, RawSelfUser } from "./types.js";
|
|
3
|
+
/** Minimal client surface the structures need (avoids a circular import). */
|
|
4
|
+
export interface ClientContext {
|
|
5
|
+
rest: REST;
|
|
6
|
+
user: ClientUser | null;
|
|
7
|
+
}
|
|
8
|
+
export declare class ClientUser {
|
|
9
|
+
id: string;
|
|
10
|
+
handle: string;
|
|
11
|
+
displayName: string;
|
|
12
|
+
avatarUrl: string | null;
|
|
13
|
+
constructor(raw: RawSelfUser);
|
|
14
|
+
/** Alias for `handle`, for discord.js familiarity. */
|
|
15
|
+
get username(): string;
|
|
16
|
+
}
|
|
17
|
+
export declare class Message {
|
|
18
|
+
private client;
|
|
19
|
+
id: string;
|
|
20
|
+
channelId: string;
|
|
21
|
+
content: string | null;
|
|
22
|
+
authorId: string;
|
|
23
|
+
createdAt: string | null;
|
|
24
|
+
editedAt: string | null;
|
|
25
|
+
parentId: string | null;
|
|
26
|
+
constructor(client: ClientContext, raw: RawMessage);
|
|
27
|
+
/** True if this message was sent by the current bot. */
|
|
28
|
+
get authoredByMe(): boolean;
|
|
29
|
+
/** Reply to this message (threads it via replyTo). */
|
|
30
|
+
reply(content: string): Promise<Message>;
|
|
31
|
+
/** Edit this message (only the bot's own messages). */
|
|
32
|
+
edit(content: string): Promise<this>;
|
|
33
|
+
/** Delete this message. */
|
|
34
|
+
delete(): Promise<void>;
|
|
35
|
+
/** Add a reaction from the bot. */
|
|
36
|
+
react(emoji: string): Promise<void>;
|
|
37
|
+
/** Remove the bot's reaction. */
|
|
38
|
+
removeReaction(emoji: string): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
export declare class TextChannel {
|
|
41
|
+
private client;
|
|
42
|
+
id: string;
|
|
43
|
+
constructor(client: ClientContext, id: string);
|
|
44
|
+
/** Send a message to this channel. */
|
|
45
|
+
send(content: string): Promise<Message>;
|
|
46
|
+
/** Fetch recent messages (oldest → newest), up to 100. */
|
|
47
|
+
fetchMessages(limit?: number): Promise<Message[]>;
|
|
48
|
+
}
|
|
49
|
+
export declare class Interaction {
|
|
50
|
+
private client;
|
|
51
|
+
commandName: string;
|
|
52
|
+
commandId: string;
|
|
53
|
+
spaceId: string;
|
|
54
|
+
channelId: string;
|
|
55
|
+
userId: string;
|
|
56
|
+
args: Record<string, unknown>;
|
|
57
|
+
constructor(client: ClientContext, raw: {
|
|
58
|
+
command: string;
|
|
59
|
+
command_id: string;
|
|
60
|
+
space_id: string;
|
|
61
|
+
channel_id: string;
|
|
62
|
+
user_id: string;
|
|
63
|
+
args: Record<string, unknown>;
|
|
64
|
+
});
|
|
65
|
+
/** Respond to the command by posting a message in its channel. */
|
|
66
|
+
reply(content: string): Promise<Message>;
|
|
67
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Rich objects returned by the client — each carries a back-reference so you can
|
|
2
|
+
// act on it directly: message.reply(), channel.send(), interaction.reply().
|
|
3
|
+
export class ClientUser {
|
|
4
|
+
id;
|
|
5
|
+
handle;
|
|
6
|
+
displayName;
|
|
7
|
+
avatarUrl;
|
|
8
|
+
constructor(raw) {
|
|
9
|
+
this.id = raw.id;
|
|
10
|
+
this.handle = raw.handle;
|
|
11
|
+
this.displayName = raw.display_name;
|
|
12
|
+
this.avatarUrl = raw.avatar_url;
|
|
13
|
+
}
|
|
14
|
+
/** Alias for `handle`, for discord.js familiarity. */
|
|
15
|
+
get username() { return this.handle; }
|
|
16
|
+
}
|
|
17
|
+
export class Message {
|
|
18
|
+
client;
|
|
19
|
+
id;
|
|
20
|
+
channelId;
|
|
21
|
+
content;
|
|
22
|
+
authorId;
|
|
23
|
+
createdAt;
|
|
24
|
+
editedAt;
|
|
25
|
+
parentId;
|
|
26
|
+
constructor(client, raw) {
|
|
27
|
+
this.client = client;
|
|
28
|
+
this.id = raw.id;
|
|
29
|
+
this.channelId = raw.channel_id;
|
|
30
|
+
this.content = raw.content ?? null;
|
|
31
|
+
this.authorId = raw.author_id;
|
|
32
|
+
this.createdAt = raw.created_at ?? null;
|
|
33
|
+
this.editedAt = raw.edited_at ?? null;
|
|
34
|
+
this.parentId = raw.parent_id ?? null;
|
|
35
|
+
}
|
|
36
|
+
/** True if this message was sent by the current bot. */
|
|
37
|
+
get authoredByMe() { return !!this.client.user && this.authorId === this.client.user.id; }
|
|
38
|
+
/** Reply to this message (threads it via replyTo). */
|
|
39
|
+
async reply(content) {
|
|
40
|
+
const { message } = await this.client.rest.post(`/channels/${this.channelId}/messages`, { content, replyTo: this.id });
|
|
41
|
+
return new Message(this.client, { ...message, content, author_id: this.client.user?.id ?? "" });
|
|
42
|
+
}
|
|
43
|
+
/** Edit this message (only the bot's own messages). */
|
|
44
|
+
async edit(content) {
|
|
45
|
+
await this.client.rest.patch(`/channels/${this.channelId}/messages/${this.id}`, { content });
|
|
46
|
+
this.content = content;
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
/** Delete this message. */
|
|
50
|
+
async delete() {
|
|
51
|
+
await this.client.rest.delete(`/channels/${this.channelId}/messages/${this.id}`);
|
|
52
|
+
}
|
|
53
|
+
/** Add a reaction from the bot. */
|
|
54
|
+
async react(emoji) {
|
|
55
|
+
await this.client.rest.put(`/channels/${this.channelId}/messages/${this.id}/reactions/${encodeURIComponent(emoji)}`);
|
|
56
|
+
}
|
|
57
|
+
/** Remove the bot's reaction. */
|
|
58
|
+
async removeReaction(emoji) {
|
|
59
|
+
await this.client.rest.delete(`/channels/${this.channelId}/messages/${this.id}/reactions/${encodeURIComponent(emoji)}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export class TextChannel {
|
|
63
|
+
client;
|
|
64
|
+
id;
|
|
65
|
+
constructor(client, id) {
|
|
66
|
+
this.client = client;
|
|
67
|
+
this.id = id;
|
|
68
|
+
}
|
|
69
|
+
/** Send a message to this channel. */
|
|
70
|
+
async send(content) {
|
|
71
|
+
const { message } = await this.client.rest.post(`/channels/${this.id}/messages`, { content });
|
|
72
|
+
return new Message(this.client, { ...message, content, author_id: this.client.user?.id ?? "" });
|
|
73
|
+
}
|
|
74
|
+
/** Fetch recent messages (oldest → newest), up to 100. */
|
|
75
|
+
async fetchMessages(limit = 50) {
|
|
76
|
+
const { messages } = await this.client.rest.get(`/channels/${this.id}/messages?limit=${limit}`);
|
|
77
|
+
return (messages ?? []).map((m) => new Message(this.client, m));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export class Interaction {
|
|
81
|
+
client;
|
|
82
|
+
commandName;
|
|
83
|
+
commandId;
|
|
84
|
+
spaceId;
|
|
85
|
+
channelId;
|
|
86
|
+
userId;
|
|
87
|
+
args;
|
|
88
|
+
constructor(client, raw) {
|
|
89
|
+
this.client = client;
|
|
90
|
+
this.commandName = raw.command;
|
|
91
|
+
this.commandId = raw.command_id;
|
|
92
|
+
this.spaceId = raw.space_id;
|
|
93
|
+
this.channelId = raw.channel_id;
|
|
94
|
+
this.userId = raw.user_id;
|
|
95
|
+
this.args = raw.args ?? {};
|
|
96
|
+
}
|
|
97
|
+
/** Respond to the command by posting a message in its channel. */
|
|
98
|
+
async reply(content) {
|
|
99
|
+
const { message } = await this.client.rest.post(`/channels/${this.channelId}/messages`, { content });
|
|
100
|
+
return new Message(this.client, { ...message, content, author_id: this.client.user?.id ?? "" });
|
|
101
|
+
}
|
|
102
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export interface ClientOptions {
|
|
2
|
+
/** The bot token from the Developer Portal → Bot tab (starts with `dbot_`). */
|
|
3
|
+
token: string;
|
|
4
|
+
/**
|
|
5
|
+
* The app's signing secret (starts with `dsk_`). Required only if you receive
|
|
6
|
+
* events via `client.listen()` / `client.middleware()` — it verifies the HMAC
|
|
7
|
+
* signature District sends on every event.
|
|
8
|
+
*/
|
|
9
|
+
signingSecret?: string;
|
|
10
|
+
/** Override the API base URL. Defaults to District's hosted bot API. */
|
|
11
|
+
apiBase?: string;
|
|
12
|
+
}
|
|
13
|
+
/** Raw message shape as returned by the REST API / delivered in events. */
|
|
14
|
+
export interface RawMessage {
|
|
15
|
+
id: string;
|
|
16
|
+
channel_id: string;
|
|
17
|
+
content: string | null;
|
|
18
|
+
author_id: string;
|
|
19
|
+
created_at?: string;
|
|
20
|
+
edited_at?: string | null;
|
|
21
|
+
parent_id?: string | null;
|
|
22
|
+
}
|
|
23
|
+
/** The bot's own identity (`GET /me`). */
|
|
24
|
+
export interface RawSelfUser {
|
|
25
|
+
id: string;
|
|
26
|
+
handle: string;
|
|
27
|
+
display_name: string;
|
|
28
|
+
avatar_url: string | null;
|
|
29
|
+
is_bot: boolean;
|
|
30
|
+
}
|
|
31
|
+
export type DistrictEvent = {
|
|
32
|
+
event: "message.create";
|
|
33
|
+
space_id: string;
|
|
34
|
+
channel_id: string;
|
|
35
|
+
message: RawMessage;
|
|
36
|
+
} | {
|
|
37
|
+
event: "message.update";
|
|
38
|
+
space_id: string;
|
|
39
|
+
channel_id: string;
|
|
40
|
+
message: RawMessage;
|
|
41
|
+
} | {
|
|
42
|
+
event: "message.delete";
|
|
43
|
+
space_id: string;
|
|
44
|
+
channel_id: string;
|
|
45
|
+
message: {
|
|
46
|
+
id: string;
|
|
47
|
+
};
|
|
48
|
+
} | {
|
|
49
|
+
event: "message.reaction_add";
|
|
50
|
+
space_id: string;
|
|
51
|
+
channel_id: string;
|
|
52
|
+
message_id: string;
|
|
53
|
+
user_id: string;
|
|
54
|
+
emoji: string;
|
|
55
|
+
} | {
|
|
56
|
+
event: "message.reaction_remove";
|
|
57
|
+
space_id: string;
|
|
58
|
+
channel_id: string;
|
|
59
|
+
message_id: string;
|
|
60
|
+
user_id: string;
|
|
61
|
+
emoji: string;
|
|
62
|
+
} | {
|
|
63
|
+
event: "interaction.create";
|
|
64
|
+
command: string;
|
|
65
|
+
command_id: string;
|
|
66
|
+
space_id: string;
|
|
67
|
+
channel_id: string;
|
|
68
|
+
user_id: string;
|
|
69
|
+
args: Record<string, unknown>;
|
|
70
|
+
};
|
|
71
|
+
export interface ReactionEvent {
|
|
72
|
+
spaceId: string;
|
|
73
|
+
channelId: string;
|
|
74
|
+
messageId: string;
|
|
75
|
+
userId: string;
|
|
76
|
+
emoji: string;
|
|
77
|
+
}
|
|
78
|
+
export interface DeletedMessage {
|
|
79
|
+
id: string;
|
|
80
|
+
channelId: string;
|
|
81
|
+
spaceId: string;
|
|
82
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { DistrictEvent } from "./types.js";
|
|
2
|
+
export interface EventHeaders {
|
|
3
|
+
"x-district-event"?: string;
|
|
4
|
+
"x-district-timestamp"?: string;
|
|
5
|
+
"x-district-signature"?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Verify an event's HMAC-SHA256 signature. District signs `${timestamp}.${rawBody}`
|
|
9
|
+
* with your app's signing secret. Always call this before trusting a payload.
|
|
10
|
+
*/
|
|
11
|
+
export declare function verifySignature(secret: string, timestamp: string, rawBody: string, signature: string): boolean;
|
|
12
|
+
/** Parse a verified raw body into a typed event. Throws on malformed JSON. */
|
|
13
|
+
export declare function parseEvent(rawBody: string): DistrictEvent;
|
|
14
|
+
/**
|
|
15
|
+
* Verify + parse in one step. Returns the event, or null if the signature is
|
|
16
|
+
* invalid (or the secret is missing).
|
|
17
|
+
*/
|
|
18
|
+
export declare function verifyAndParse(secret: string, headers: EventHeaders, rawBody: string): DistrictEvent | null;
|
package/dist/webhook.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Verifying + parsing the signed events District POSTs to your endpoint.
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
/**
|
|
4
|
+
* Verify an event's HMAC-SHA256 signature. District signs `${timestamp}.${rawBody}`
|
|
5
|
+
* with your app's signing secret. Always call this before trusting a payload.
|
|
6
|
+
*/
|
|
7
|
+
export function verifySignature(secret, timestamp, rawBody, signature) {
|
|
8
|
+
if (!secret || !timestamp || !signature)
|
|
9
|
+
return false;
|
|
10
|
+
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
|
|
11
|
+
try {
|
|
12
|
+
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Parse a verified raw body into a typed event. Throws on malformed JSON. */
|
|
19
|
+
export function parseEvent(rawBody) {
|
|
20
|
+
return JSON.parse(rawBody);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Verify + parse in one step. Returns the event, or null if the signature is
|
|
24
|
+
* invalid (or the secret is missing).
|
|
25
|
+
*/
|
|
26
|
+
export function verifyAndParse(secret, headers, rawBody) {
|
|
27
|
+
const ts = headers["x-district-timestamp"] ?? "";
|
|
28
|
+
const sig = headers["x-district-signature"] ?? "";
|
|
29
|
+
if (!verifySignature(secret, ts, rawBody, sig))
|
|
30
|
+
return null;
|
|
31
|
+
try {
|
|
32
|
+
return parseEvent(rawBody);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "district.js",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A powerful Node.js module for building District bots — post, react, run slash commands, and receive events.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": ["dist"],
|
|
15
|
+
"engines": { "node": ">=18" },
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc -p tsconfig.json",
|
|
18
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
19
|
+
},
|
|
20
|
+
"keywords": ["district", "bot", "api", "chat", "sdk"],
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^20.14.0",
|
|
24
|
+
"typescript": "^5.5.0"
|
|
25
|
+
}
|
|
26
|
+
}
|