@crewhaus/channel-adapter-discord 0.1.4 → 0.1.6

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.
@@ -0,0 +1,132 @@
1
+ /**
2
+ * @crewhaus/channel-adapter-discord — Discord channel adapter for the
3
+ * channel target (Section 33).
4
+ *
5
+ * Discord's interaction model is request/response: the bot is invoked
6
+ * via slash commands (interaction type 2), button clicks (type 3), and
7
+ * modal submits (type 5). Each request is Ed25519-signed via
8
+ * `X-Signature-Ed25519` + `X-Signature-Timestamp`.
9
+ *
10
+ * Discord requires the bot to respond *to the request itself* — the
11
+ * gateway POSTs the interaction-response body back. For backward
12
+ * compatibility with the channel adapter contract, `sendReply` instead
13
+ * uses Discord's "follow-up message" API (`POST /webhooks/{appId}/{token}`)
14
+ * which works at any time after the initial deferred response.
15
+ *
16
+ * Thread session keying uses Discord's native `thread_id` when present
17
+ * (forum / public thread / archived thread); otherwise the channel id.
18
+ */
19
+ import { CrewhausError } from "@crewhaus/errors";
20
+ export { generateEd25519Keypair, signDiscordBody, verifyDiscordSignature, } from "./verify.js";
21
+ export declare class DiscordAdapterError extends CrewhausError {
22
+ readonly name = "DiscordAdapterError";
23
+ constructor(message: string, cause?: unknown);
24
+ }
25
+ export type RawRequest = {
26
+ readonly headers: Headers;
27
+ readonly body: string;
28
+ };
29
+ /**
30
+ * Channel-generic inbound event. Same shape as Slack/Telegram:
31
+ *
32
+ * Mappings:
33
+ * - workspaceId → guild_id (or "dm" when not in a guild)
34
+ * - channelId → channel_id
35
+ * - userId → user.id (or member.user.id in guild context)
36
+ * - threadTs → channel_id when the channel is a thread
37
+ * - ts → interaction id (snowflake; monotonic-ish)
38
+ * - text → command-name + options for slash commands;
39
+ * custom_id for component clicks; modal field values
40
+ * joined for modal submits
41
+ * - subtype → "message" | "app_mention"
42
+ * - idempotencyKey → interaction id (Discord doesn't redeliver, but we
43
+ * surface the id so the gateway dedups consistently)
44
+ */
45
+ export type InboundEvent = {
46
+ readonly idempotencyKey: string;
47
+ readonly workspaceId: string;
48
+ readonly channelId: string;
49
+ readonly userId: string;
50
+ readonly threadTs?: string;
51
+ readonly ts: string;
52
+ readonly text: string;
53
+ readonly subtype: "app_mention" | "message";
54
+ };
55
+ export type ParsedInbound = {
56
+ readonly kind: "event";
57
+ readonly event: InboundEvent;
58
+ }
59
+ /**
60
+ * Discord pings every interactions endpoint with a `type:1` PING that
61
+ * must be answered with `type:1` PONG before any real interactions
62
+ * arrive. The gateway responds with the canned PONG body.
63
+ */
64
+ | {
65
+ readonly kind: "challenge";
66
+ readonly challenge: string;
67
+ } | {
68
+ readonly kind: "skip";
69
+ };
70
+ export interface ChannelAdapter {
71
+ readonly id: string;
72
+ verify(req: RawRequest): boolean;
73
+ parseInbound(req: RawRequest): ParsedInbound;
74
+ sendReply(args: {
75
+ event: InboundEvent;
76
+ text: string;
77
+ }): Promise<void>;
78
+ setTyping(args: {
79
+ event: InboundEvent;
80
+ }): Promise<void>;
81
+ }
82
+ export type DiscordAdapterConfig = {
83
+ /** Application id (snowflake). Used by the follow-up webhook URL. */
84
+ readonly applicationId: string;
85
+ /** Bot user token — used to authorize follow-up REST calls. */
86
+ readonly botToken: string;
87
+ /** Public key (hex, 64 chars) for Ed25519 verification. */
88
+ readonly publicKeyHex: string;
89
+ };
90
+ export type DiscordAdapterOptions = {
91
+ readonly apiBaseUrl?: string;
92
+ readonly fetch?: typeof fetch;
93
+ /** Clock injection for the signature replay-window check (tests). */
94
+ readonly now?: () => number;
95
+ };
96
+ export declare function createDiscordAdapter(config: DiscordAdapterConfig, opts?: DiscordAdapterOptions): ChannelAdapter;
97
+ export type DiscordInteraction = {
98
+ readonly id: string;
99
+ readonly application_id?: string;
100
+ readonly type: number;
101
+ readonly token?: string;
102
+ readonly channel_id?: string;
103
+ readonly guild_id?: string;
104
+ readonly user?: {
105
+ readonly id: string;
106
+ readonly username?: string;
107
+ };
108
+ readonly member?: {
109
+ readonly user?: {
110
+ readonly id: string;
111
+ readonly username?: string;
112
+ };
113
+ };
114
+ readonly channel?: {
115
+ readonly id?: string;
116
+ readonly parent_id?: string;
117
+ };
118
+ readonly data?: {
119
+ readonly name?: string;
120
+ readonly custom_id?: string;
121
+ readonly options?: ReadonlyArray<{
122
+ readonly name: string;
123
+ readonly value?: unknown;
124
+ }>;
125
+ readonly components?: ReadonlyArray<{
126
+ readonly components?: ReadonlyArray<{
127
+ readonly custom_id?: string;
128
+ readonly value?: string;
129
+ }>;
130
+ }>;
131
+ };
132
+ };
package/dist/index.js ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * @crewhaus/channel-adapter-discord — Discord channel adapter for the
3
+ * channel target (Section 33).
4
+ *
5
+ * Discord's interaction model is request/response: the bot is invoked
6
+ * via slash commands (interaction type 2), button clicks (type 3), and
7
+ * modal submits (type 5). Each request is Ed25519-signed via
8
+ * `X-Signature-Ed25519` + `X-Signature-Timestamp`.
9
+ *
10
+ * Discord requires the bot to respond *to the request itself* — the
11
+ * gateway POSTs the interaction-response body back. For backward
12
+ * compatibility with the channel adapter contract, `sendReply` instead
13
+ * uses Discord's "follow-up message" API (`POST /webhooks/{appId}/{token}`)
14
+ * which works at any time after the initial deferred response.
15
+ *
16
+ * Thread session keying uses Discord's native `thread_id` when present
17
+ * (forum / public thread / archived thread); otherwise the channel id.
18
+ */
19
+ import { CrewhausError } from "@crewhaus/errors";
20
+ import { verifyDiscordSignature } from "./verify.js";
21
+ export { generateEd25519Keypair, signDiscordBody, verifyDiscordSignature, } from "./verify.js";
22
+ export class DiscordAdapterError extends CrewhausError {
23
+ name = "DiscordAdapterError";
24
+ constructor(message, cause) {
25
+ super("channel", message, cause);
26
+ }
27
+ }
28
+ const DEFAULT_API_BASE_URL = "https://discord.com/api/v10";
29
+ const PONG_RESPONSE_BODY = JSON.stringify({ type: 1 });
30
+ export function createDiscordAdapter(config, opts = {}) {
31
+ const apiBaseUrl = opts.apiBaseUrl ?? process.env["DISCORD_API_BASE_URL"] ?? DEFAULT_API_BASE_URL;
32
+ const doFetch = opts.fetch ?? fetch;
33
+ return {
34
+ id: "discord",
35
+ verify(req) {
36
+ return verifyDiscordSignature({
37
+ headers: req.headers,
38
+ body: req.body,
39
+ publicKeyHex: config.publicKeyHex,
40
+ }, opts.now !== undefined ? { now: opts.now } : {});
41
+ },
42
+ parseInbound(req) {
43
+ let payload;
44
+ try {
45
+ payload = JSON.parse(req.body);
46
+ }
47
+ catch {
48
+ return { kind: "skip" };
49
+ }
50
+ if (typeof payload !== "object" || payload === null)
51
+ return { kind: "skip" };
52
+ const p = payload;
53
+ // PING → PONG handshake (Discord's URL-verification analogue).
54
+ if (p.type === 1) {
55
+ return { kind: "challenge", challenge: PONG_RESPONSE_BODY };
56
+ }
57
+ // We handle types 2 (slash command), 3 (component click), 5 (modal submit).
58
+ if (p.type !== 2 && p.type !== 3 && p.type !== 5) {
59
+ return { kind: "skip" };
60
+ }
61
+ const interactionId = p.id;
62
+ const channelId = p.channel_id;
63
+ const userId = p.member?.user?.id ?? p.user?.id;
64
+ if (!interactionId || !channelId || !userId)
65
+ return { kind: "skip" };
66
+ const workspaceId = p.guild_id ?? "dm";
67
+ // Threads: in Discord a "thread" is a Channel with `parent_id` set
68
+ // and type 11 (public thread) / 12 (private thread) / 10 (news
69
+ // thread). The interaction.channel object includes `parent_id` when
70
+ // the interaction is in a thread.
71
+ const isThread = typeof p.channel?.parent_id === "string" && p.channel.parent_id.length > 0;
72
+ const threadTs = isThread ? channelId : undefined;
73
+ const text = renderInteractionText(p);
74
+ const event = {
75
+ idempotencyKey: interactionId,
76
+ workspaceId,
77
+ channelId,
78
+ userId,
79
+ ...(threadTs !== undefined ? { threadTs } : {}),
80
+ ts: interactionId,
81
+ text,
82
+ subtype: "message",
83
+ };
84
+ return { kind: "event", event };
85
+ },
86
+ async sendReply(args) {
87
+ // Use Discord's interaction follow-up endpoint:
88
+ // POST /webhooks/{application.id}/{interaction.token}
89
+ // The interaction.token isn't part of `InboundEvent`; in practice the
90
+ // gateway should call `interaction.respond({type:5, data: {...}})`
91
+ // synchronously to defer, then we follow up via this REST call. For
92
+ // the v0 channel-bot daemon we use the application's webhook channel
93
+ // POST as a fallback so the message reaches the channel even without
94
+ // the interaction token (slightly different rendering — no
95
+ // ephemeral, no per-user replies).
96
+ const url = `${apiBaseUrl}/channels/${args.event.channelId}/messages`;
97
+ const res = await doFetch(url, {
98
+ method: "POST",
99
+ headers: {
100
+ "Content-Type": "application/json",
101
+ Authorization: `Bot ${config.botToken}`,
102
+ },
103
+ body: JSON.stringify({ content: args.text }),
104
+ });
105
+ if (!res.ok) {
106
+ throw new DiscordAdapterError(`channels/${args.event.channelId}/messages failed: ${res.status} ${res.statusText}`);
107
+ }
108
+ },
109
+ // Note: no `react()` hook. Discord's inbound is an *interaction* (slash
110
+ // command / component / modal), not a channel message, and `event.ts` is
111
+ // the interaction id — there is no user message to attach a reaction to.
112
+ // The session-router skips the (undefined) react hook silently, so status
113
+ // acks simply don't appear on Discord. (Slack/Telegram/WhatsApp implement
114
+ // react(); iMessage cannot.)
115
+ async setTyping(args) {
116
+ const url = `${apiBaseUrl}/channels/${args.event.channelId}/typing`;
117
+ // Best-effort — do not surface failures. A failed typing indicator must
118
+ // never reject and break the caller's reply flow, so we swallow both
119
+ // network rejections (DNS/abort/connection) and non-2xx responses.
120
+ try {
121
+ await doFetch(url, {
122
+ method: "POST",
123
+ headers: {
124
+ Authorization: `Bot ${config.botToken}`,
125
+ },
126
+ });
127
+ }
128
+ catch {
129
+ // ignore — typing indicator is non-essential.
130
+ }
131
+ },
132
+ };
133
+ }
134
+ /** Reduce an interaction payload to a single text body for the agent. */
135
+ function renderInteractionText(p) {
136
+ if (p.type === 2) {
137
+ // Slash command: render as `/<name> <option1=value1> <option2=value2>`
138
+ const cmd = p.data?.name ?? "?";
139
+ const opts = (p.data?.options ?? [])
140
+ .map((o) => `${o.name}=${stringifyOption(o.value)}`)
141
+ .join(" ");
142
+ return opts ? `/${cmd} ${opts}` : `/${cmd}`;
143
+ }
144
+ if (p.type === 3) {
145
+ // Component click: `[component:<custom_id>]`
146
+ return `[component:${p.data?.custom_id ?? ""}]`;
147
+ }
148
+ if (p.type === 5) {
149
+ // Modal submit: render as `<custom_id>: field1=value1 field2=value2`
150
+ const id = p.data?.custom_id ?? "";
151
+ const fields = (p.data?.components ?? [])
152
+ .flatMap((row) => row.components ?? [])
153
+ .map((c) => `${c.custom_id ?? "?"}=${c.value ?? ""}`)
154
+ .join(" ");
155
+ return `${id}: ${fields}`;
156
+ }
157
+ return "";
158
+ }
159
+ function stringifyOption(v) {
160
+ if (v === null || v === undefined)
161
+ return "";
162
+ if (typeof v === "string")
163
+ return v;
164
+ return JSON.stringify(v);
165
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Discord interaction signature verification per
3
+ * https://discord.com/developers/docs/interactions/receiving-and-responding#security-and-authorization.
4
+ *
5
+ * Discord signs interaction webhooks with Ed25519. Each request carries
6
+ * `X-Signature-Ed25519` (hex) and `X-Signature-Timestamp` (seconds). The
7
+ * signed payload is `<timestamp><body>`. The bot's `publicKey` (hex,
8
+ * 64 chars) is the verification key.
9
+ *
10
+ * We use Node's built-in `crypto.verify` with `null` algorithm and an
11
+ * Ed25519 KeyObject built from the raw 32-byte public key — no external
12
+ * SDK needed.
13
+ */
14
+ export type DiscordVerifyArgs = {
15
+ readonly headers: Headers;
16
+ readonly body: string;
17
+ readonly publicKeyHex: string;
18
+ };
19
+ export type DiscordVerifyOptions = {
20
+ readonly now?: () => number;
21
+ readonly toleranceMs?: number;
22
+ };
23
+ export declare function verifyDiscordSignature(args: DiscordVerifyArgs, opts?: DiscordVerifyOptions): boolean;
24
+ /**
25
+ * Sign a body with an Ed25519 private key — used by smoke tests and the
26
+ * in-repo integration test to construct fixtures the daemon will accept.
27
+ * Production code should NEVER call this — Discord signs requests, not us.
28
+ */
29
+ export declare function signDiscordBody(args: {
30
+ body: string;
31
+ timestamp: number | string;
32
+ privateKeyPem: string;
33
+ }): string;
34
+ /**
35
+ * Generate a fresh Ed25519 keypair (PEM-encoded). Used by the test
36
+ * harness so we can sign + verify our own fixtures end-to-end.
37
+ */
38
+ export declare function generateEd25519Keypair(): {
39
+ publicKeyHex: string;
40
+ privateKeyPem: string;
41
+ };
package/dist/verify.js ADDED
@@ -0,0 +1,87 @@
1
+ import { createPrivateKey, createPublicKey, sign as cryptoSign, generateKeyPairSync, verify, } from "node:crypto";
2
+ // Discord recommends rejecting interactions whose timestamp is too far from
3
+ // now; 5 minutes matches the Slack adapter's replay-window cap.
4
+ const DEFAULT_TOLERANCE_MS = 5 * 60 * 1000;
5
+ export function verifyDiscordSignature(args, opts = {}) {
6
+ const now = opts.now ?? (() => Date.now());
7
+ const tolerance = opts.toleranceMs ?? DEFAULT_TOLERANCE_MS;
8
+ const sigHex = args.headers.get("x-signature-ed25519");
9
+ const timestamp = args.headers.get("x-signature-timestamp");
10
+ if (!sigHex || !timestamp)
11
+ return false;
12
+ if (!/^[0-9a-f]{128}$/i.test(sigHex))
13
+ return false;
14
+ if (!/^\d+$/.test(timestamp))
15
+ return false;
16
+ // Replay-window: the timestamp is folded into the signed payload, so a
17
+ // captured (timestamp, body, signature) triple verifies indefinitely
18
+ // without this freshness bound. Reject stale/future timestamps (seconds
19
+ // since epoch), mirroring the Slack adapter.
20
+ const tsNum = Number.parseInt(timestamp, 10);
21
+ if (!Number.isFinite(tsNum))
22
+ return false;
23
+ if (Math.abs(now() - tsNum * 1000) > tolerance)
24
+ return false;
25
+ // `sigHex` is regex-validated above to be exactly 128 hex chars, so
26
+ // `Buffer.from(_, "hex")` always yields exactly 64 bytes and never throws
27
+ // (`Buffer.from` silently drops invalid hex rather than throwing). The
28
+ // length re-check below is a cheap, defensive belt for that invariant.
29
+ const signature = Buffer.from(sigHex, "hex");
30
+ if (signature.length !== 64)
31
+ return false;
32
+ let publicKey;
33
+ try {
34
+ publicKey = ed25519PublicKeyFromHex(args.publicKeyHex);
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ const message = Buffer.from(`${timestamp}${args.body}`, "utf8");
40
+ try {
41
+ return verify(null, message, publicKey, signature);
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
47
+ /**
48
+ * Construct an Ed25519 public KeyObject from a 32-byte hex string. Discord's
49
+ * docs publish the bot key as a 64-char hex string. Node's `createPublicKey`
50
+ * accepts a SubjectPublicKeyInfo DER blob — we wrap the raw key with the
51
+ * fixed Ed25519 OID prefix `302a300506032b6570032100`.
52
+ */
53
+ function ed25519PublicKeyFromHex(hex) {
54
+ if (!/^[0-9a-f]{64}$/i.test(hex)) {
55
+ throw new Error(`expected 32-byte (64-char hex) ed25519 public key, got ${hex.length} chars`);
56
+ }
57
+ const der = Buffer.concat([
58
+ Buffer.from("302a300506032b6570032100", "hex"),
59
+ Buffer.from(hex, "hex"),
60
+ ]);
61
+ return createPublicKey({ key: der, format: "der", type: "spki" });
62
+ }
63
+ /**
64
+ * Sign a body with an Ed25519 private key — used by smoke tests and the
65
+ * in-repo integration test to construct fixtures the daemon will accept.
66
+ * Production code should NEVER call this — Discord signs requests, not us.
67
+ */
68
+ export function signDiscordBody(args) {
69
+ const message = Buffer.from(`${args.timestamp}${args.body}`, "utf8");
70
+ const sk = createPrivateKey({ key: args.privateKeyPem });
71
+ const sig = cryptoSign(null, message, sk);
72
+ return sig.toString("hex");
73
+ }
74
+ /**
75
+ * Generate a fresh Ed25519 keypair (PEM-encoded). Used by the test
76
+ * harness so we can sign + verify our own fixtures end-to-end.
77
+ */
78
+ export function generateEd25519Keypair() {
79
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
80
+ // Extract raw 32-byte public key from the DER-encoded SPKI:
81
+ // SPKI = 30 2a 30 05 06 03 2b 65 70 03 21 00 <32-byte-key>
82
+ const spkiDer = publicKey.export({ format: "der", type: "spki" });
83
+ const rawKey = spkiDer.subarray(spkiDer.length - 32);
84
+ const publicKeyHex = Buffer.from(rawKey).toString("hex");
85
+ const privateKeyPem = privateKey.export({ format: "pem", type: "pkcs8" }).toString();
86
+ return { publicKeyHex, privateKeyPem };
87
+ }
package/package.json CHANGED
@@ -1,18 +1,21 @@
1
1
  {
2
2
  "name": "@crewhaus/channel-adapter-discord",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "Discord channel adapter: Ed25519 signature verification + slash-command/button/modal interaction parsing + interaction-response API (Section 33)",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/errors": "0.1.4"
18
+ "@crewhaus/errors": "0.1.6"
16
19
  },
17
20
  "license": "Apache-2.0",
18
21
  "author": {
@@ -32,5 +35,5 @@
32
35
  "publishConfig": {
33
36
  "access": "public"
34
37
  },
35
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
38
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
36
39
  }
@@ -1,13 +0,0 @@
1
- {
2
- "id": "100000000000000006",
3
- "application_id": "200000000000000001",
4
- "type": 3,
5
- "token": "tok-btn",
6
- "channel_id": "300000000000000001",
7
- "guild_id": "400000000000000001",
8
- "member": { "user": { "id": "500000000000000001", "username": "alice" } },
9
- "data": {
10
- "custom_id": "approve:doc-123",
11
- "component_type": 2
12
- }
13
- }
@@ -1,9 +0,0 @@
1
- {
2
- "id": "100000000000000009",
3
- "application_id": "200000000000000001",
4
- "type": 2,
5
- "token": "tok-no-user",
6
- "channel_id": "300000000000000001",
7
- "guild_id": "400000000000000001",
8
- "data": { "name": "ping" }
9
- }
@@ -1,22 +0,0 @@
1
- {
2
- "id": "100000000000000007",
3
- "application_id": "200000000000000001",
4
- "type": 5,
5
- "token": "tok-modal",
6
- "channel_id": "300000000000000001",
7
- "guild_id": "400000000000000001",
8
- "member": { "user": { "id": "500000000000000001", "username": "alice" } },
9
- "data": {
10
- "custom_id": "feedback-form",
11
- "components": [
12
- {
13
- "type": 1,
14
- "components": [{ "type": 4, "custom_id": "rating", "value": "5" }]
15
- },
16
- {
17
- "type": 1,
18
- "components": [{ "type": 4, "custom_id": "comment", "value": "loved it" }]
19
- }
20
- ]
21
- }
22
- }
@@ -1,6 +0,0 @@
1
- {
2
- "id": "100000000000000001",
3
- "application_id": "200000000000000001",
4
- "type": 1,
5
- "token": "ping-token"
6
- }
@@ -1,13 +0,0 @@
1
- {
2
- "id": "100000000000000002",
3
- "application_id": "200000000000000001",
4
- "type": 2,
5
- "token": "tok-basic",
6
- "channel_id": "300000000000000001",
7
- "guild_id": "400000000000000001",
8
- "member": { "user": { "id": "500000000000000001", "username": "alice" } },
9
- "data": {
10
- "name": "ping",
11
- "options": []
12
- }
13
- }
@@ -1,12 +0,0 @@
1
- {
2
- "id": "100000000000000005",
3
- "application_id": "200000000000000001",
4
- "type": 2,
5
- "token": "tok-dm",
6
- "channel_id": "300000000000000077",
7
- "user": { "id": "500000000000000007", "username": "bob" },
8
- "data": {
9
- "name": "help",
10
- "options": []
11
- }
12
- }
@@ -1,14 +0,0 @@
1
- {
2
- "id": "100000000000000004",
3
- "application_id": "200000000000000001",
4
- "type": 2,
5
- "token": "tok-thread",
6
- "channel_id": "300000000000000099",
7
- "guild_id": "400000000000000001",
8
- "channel": { "id": "300000000000000099", "parent_id": "300000000000000001" },
9
- "member": { "user": { "id": "500000000000000001", "username": "alice" } },
10
- "data": {
11
- "name": "explain",
12
- "options": []
13
- }
14
- }
@@ -1,16 +0,0 @@
1
- {
2
- "id": "100000000000000003",
3
- "application_id": "200000000000000001",
4
- "type": 2,
5
- "token": "tok-opts",
6
- "channel_id": "300000000000000001",
7
- "guild_id": "400000000000000001",
8
- "member": { "user": { "id": "500000000000000001", "username": "alice" } },
9
- "data": {
10
- "name": "summarize",
11
- "options": [
12
- { "name": "url", "type": 3, "value": "https://example.com" },
13
- { "name": "depth", "type": 4, "value": 3 }
14
- ]
15
- }
16
- }
@@ -1,7 +0,0 @@
1
- {
2
- "id": "100000000000000008",
3
- "application_id": "200000000000000001",
4
- "type": 4,
5
- "token": "tok-autocomplete",
6
- "channel_id": "300000000000000001"
7
- }