@loomcycle/bridge-discord 0.3.0 → 0.4.1
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 +45 -9
- package/dist/api.d.ts +27 -1
- package/dist/api.js +18 -2
- package/dist/api.js.map +1 -1
- package/dist/bridge.d.ts +1 -1
- package/dist/bridge.js +20 -3
- package/dist/bridge.js.map +1 -1
- package/dist/commands.d.ts +22 -4
- package/dist/commands.js +29 -6
- package/dist/commands.js.map +1 -1
- package/dist/config.js +10 -1
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/interaction.d.ts +29 -0
- package/dist/interaction.js +2 -0
- package/dist/interaction.js.map +1 -1
- package/dist/interactions.d.ts +7 -0
- package/dist/interactions.js +35 -4
- package/dist/interactions.js.map +1 -1
- package/dist/port.d.ts +12 -0
- package/dist/port.js +73 -11
- package/dist/port.js.map +1 -1
- package/dist/voice.d.ts +69 -0
- package/dist/voice.js +249 -0
- package/dist/voice.js.map +1 -0
- package/package.json +2 -2
package/dist/interactions.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { DiscordApi } from "./api.js";
|
|
|
4
4
|
import { type DiscordConfig } from "./config.js";
|
|
5
5
|
import { type Fences } from "./identity.js";
|
|
6
6
|
import type { DiscordPort, DiscordRef } from "./port.js";
|
|
7
|
+
import { type DiscordVoice } from "./voice.js";
|
|
7
8
|
export interface InteractionDeps {
|
|
8
9
|
cfg: Fences & Pick<DiscordConfig, "dev">;
|
|
9
10
|
key: KeyObject;
|
|
@@ -14,6 +15,12 @@ export interface InteractionDeps {
|
|
|
14
15
|
now: () => number;
|
|
15
16
|
/** How long a button press may take before the bridge acks it and finishes later. */
|
|
16
17
|
inlineCardMs?: number;
|
|
18
|
+
/** Set with BRIDGE_VOICE=1: Ask by voice is taken. untrusted is
|
|
19
|
+
* BRIDGE_VOICE_UNTRUSTED. */
|
|
20
|
+
voice?: {
|
|
21
|
+
port: DiscordVoice;
|
|
22
|
+
untrusted: boolean;
|
|
23
|
+
};
|
|
17
24
|
}
|
|
18
25
|
export declare const NO_LONGER_ACTIVE = "This button is no longer active.";
|
|
19
26
|
export declare function interactionRoute(deps: InteractionDeps): Route;
|
package/dist/interactions.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// POST /discord/interactions: verify, then answer within Discord's 3 seconds,
|
|
2
2
|
// then do the work.
|
|
3
|
-
import { header } from "@loomcycle/bridge-core";
|
|
3
|
+
import { header, VOICE_TEXT } from "@loomcycle/bridge-core";
|
|
4
4
|
import { NO_MENTIONS, parseCustomId, renderCard, SUPPRESS_EMBEDS, textMessage } from "./cards.js";
|
|
5
|
-
import {
|
|
5
|
+
import { ASK_BY_VOICE, MAX_PROMPT_CHARS, slashHelpText } from "./commands.js";
|
|
6
6
|
import { isSnowflake } from "./config.js";
|
|
7
7
|
import { identify, NOT_HERE, placeOf } from "./identity.js";
|
|
8
|
-
import { CallbackType, EPHEMERAL, InteractionType } from "./interaction.js";
|
|
8
|
+
import { CallbackType, CommandType, EPHEMERAL, InteractionType } from "./interaction.js";
|
|
9
9
|
import { verifyInteraction } from "./verify.js";
|
|
10
|
+
import { voiceOfMessage, VOICE_OFF } from "./voice.js";
|
|
10
11
|
/** Interaction tokens are opaque; this keeps one from bending the URL path it is put in. */
|
|
11
12
|
const TOKEN = /^[A-Za-z0-9._-]{1,1000}$/;
|
|
12
13
|
/** Longer than the 5-minute signature window, so a replay inside it is refused. */
|
|
@@ -58,6 +59,31 @@ function promptOf(ix) {
|
|
|
58
59
|
export function interactionRoute(deps) {
|
|
59
60
|
const seen = new Seen(deps.now);
|
|
60
61
|
const { cfg, port, log } = deps;
|
|
62
|
+
const help = slashHelpText(deps.voice !== undefined);
|
|
63
|
+
/** voiceCommand takes Ask by voice: the core downloads, transcribes and
|
|
64
|
+
* answers it, after a deferred response like /ask's. */
|
|
65
|
+
const voiceCommand = (ix, id) => {
|
|
66
|
+
if (ix.data?.name !== ASK_BY_VOICE)
|
|
67
|
+
return ephemeral("Unknown command. Try /help.");
|
|
68
|
+
if (!deps.voice)
|
|
69
|
+
return ephemeral(VOICE_OFF);
|
|
70
|
+
// Said here rather than left to the core, which stays quiet in a shared
|
|
71
|
+
// channel: an interaction must be answered.
|
|
72
|
+
if (id.speaker.trust === "untrusted" && !deps.voice.untrusted)
|
|
73
|
+
return ephemeral(VOICE_TEXT.unavailable);
|
|
74
|
+
const found = voiceOfMessage(ix);
|
|
75
|
+
if ("refusal" in found)
|
|
76
|
+
return ephemeral(found.refusal);
|
|
77
|
+
const place = placeOf(ix);
|
|
78
|
+
const ref = refOf(place);
|
|
79
|
+
port.track(ix.id, ix.token, place.channelId, FALLBACK.ask);
|
|
80
|
+
setImmediate(() => {
|
|
81
|
+
deps.router.onVoice(id, ref, found.voice, typeof ix.locale === "string" ? ix.locale : undefined).catch((err) => {
|
|
82
|
+
log.error("voice message failed", { conversation: id.conversationId, error: err?.name });
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
return json({ type: CallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, data: { flags: SUPPRESS_EMBEDS, allowed_mentions: NO_MENTIONS } });
|
|
86
|
+
};
|
|
61
87
|
const command = (ix) => {
|
|
62
88
|
const id = identify(ix, cfg);
|
|
63
89
|
if (!id.ok) {
|
|
@@ -65,9 +91,14 @@ export function interactionRoute(deps) {
|
|
|
65
91
|
// Every interaction must be answered, so each refused one hears why.
|
|
66
92
|
return ephemeral(id.notice ?? NOT_HERE);
|
|
67
93
|
}
|
|
94
|
+
// A spoken reply may follow any turn (/voice on), so every command says
|
|
95
|
+
// how large an upload this channel takes.
|
|
96
|
+
deps.voice?.port.observe(placeOf(ix).channelId, ix.attachment_size_limit);
|
|
97
|
+
if (ix.data?.type === CommandType.MESSAGE)
|
|
98
|
+
return voiceCommand(ix, id);
|
|
68
99
|
const name = ix.data?.name ?? "";
|
|
69
100
|
if (name === "help")
|
|
70
|
-
return ephemeral(
|
|
101
|
+
return ephemeral(help);
|
|
71
102
|
const text = name === "ask" ? promptOf(ix) : name === "new" || name === "stop" ? `/${name}` : undefined;
|
|
72
103
|
if (text === undefined)
|
|
73
104
|
return ephemeral("Unknown command. Try /help.");
|
package/dist/interactions.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"interactions.js","sourceRoot":"","sources":["../src/interactions.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,oBAAoB;AAEpB,OAAO,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"interactions.js","sourceRoot":"","sources":["../src/interactions.ts"],"names":[],"mappings":"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"}
|
package/dist/port.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export declare class DiscordPort implements BridgePort<DiscordRef> {
|
|
|
23
23
|
/** By interaction id, oldest first (Map keeps insertion order). */
|
|
24
24
|
private readonly tracked;
|
|
25
25
|
private readonly noTyping;
|
|
26
|
+
private readonly lastText;
|
|
26
27
|
private readonly now;
|
|
27
28
|
private readonly retryUnknownMs;
|
|
28
29
|
constructor(api: DiscordApi, log: Logger, opts?: {
|
|
@@ -33,6 +34,17 @@ export declare class DiscordPort implements BridgePort<DiscordRef> {
|
|
|
33
34
|
* deferred message the next send in the channel replaces. */
|
|
34
35
|
track(interactionId: string, token: string, channelId: string, defer?: DeferOptions): void;
|
|
35
36
|
send(ref: DiscordRef, activity: OutboundActivity): Promise<void>;
|
|
37
|
+
/** labelLast puts a voice reply's label on the last line of the text it
|
|
38
|
+
* goes with (a voice message itself carries no text), and returns that
|
|
39
|
+
* message's id for the voice message to reply to. When the text cannot be
|
|
40
|
+
* edited (unknown, too long, its token expired), the label is posted as a
|
|
41
|
+
* message of its own. It throws when neither works: a voice reply nobody
|
|
42
|
+
* can tell is generated is not sent. */
|
|
43
|
+
labelLast(ref: DiscordRef, label: string): Promise<string>;
|
|
44
|
+
/** editorOf edits a remembered message with the credential it was posted
|
|
45
|
+
* with, or is undefined once that interaction's token is too old. */
|
|
46
|
+
private editorOf;
|
|
47
|
+
private remember;
|
|
36
48
|
private deliver;
|
|
37
49
|
private editOriginal;
|
|
38
50
|
private typing;
|
package/dist/port.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// goes by the bot token until someone uses a command again.
|
|
13
13
|
import { splitMessage } from "@loomcycle/bridge-core";
|
|
14
14
|
import { DiscordApiError } from "./api.js";
|
|
15
|
-
import { MAX_CONTENT, neutraliseLinks, renderCard, textMessage } from "./cards.js";
|
|
15
|
+
import { MAX_CONTENT, neutraliseLinks, NO_MENTIONS, renderCard, SUPPRESS_EMBEDS, textMessage } from "./cards.js";
|
|
16
16
|
export const TOKEN_LIFETIME_MS = 15 * 60_000;
|
|
17
17
|
/** A token this close to expiry is not used: a request may land after it. */
|
|
18
18
|
const EXPIRY_MARGIN_MS = 10_000;
|
|
@@ -24,12 +24,17 @@ const MAX_TRACKED = 10_000;
|
|
|
24
24
|
* this long: a refused call counts towards Discord's invalid-request ban. */
|
|
25
25
|
const TYPING_BACKOFF_MS = 10 * 60_000;
|
|
26
26
|
export const CONTINUED = "Continued below.";
|
|
27
|
+
function messageIdOf(message) {
|
|
28
|
+
const id = message?.id;
|
|
29
|
+
return typeof id === "string" && /^\d{1,20}$/.test(id) ? id : undefined;
|
|
30
|
+
}
|
|
27
31
|
export class DiscordPort {
|
|
28
32
|
api;
|
|
29
33
|
log;
|
|
30
34
|
/** By interaction id, oldest first (Map keeps insertion order). */
|
|
31
35
|
tracked = new Map();
|
|
32
36
|
noTyping = new Map();
|
|
37
|
+
lastText = new Map();
|
|
33
38
|
now;
|
|
34
39
|
retryUnknownMs;
|
|
35
40
|
constructor(api, log, opts = {}) {
|
|
@@ -55,10 +60,17 @@ export class DiscordPort {
|
|
|
55
60
|
return this.typing(ref.channelId);
|
|
56
61
|
// The core cut the text to Discord's size, but rewriting its links can
|
|
57
62
|
// lengthen it, so it is cut again after.
|
|
58
|
-
const
|
|
63
|
+
const isCard = "card" in activity;
|
|
64
|
+
const bodies = isCard ? [renderCard(activity.card)] : splitMessage(neutraliseLinks(activity.text), MAX_CONTENT).map(textMessage);
|
|
59
65
|
try {
|
|
60
|
-
for (const body of bodies)
|
|
61
|
-
await this.deliver(ref, body);
|
|
66
|
+
for (const body of bodies) {
|
|
67
|
+
const posted = await this.deliver(ref, body);
|
|
68
|
+
// A card is never labelled: its content is rebuilt when it is answered.
|
|
69
|
+
if (isCard)
|
|
70
|
+
this.lastText.delete(ref.channelId);
|
|
71
|
+
else
|
|
72
|
+
this.remember(ref.channelId, posted, body);
|
|
73
|
+
}
|
|
62
74
|
}
|
|
63
75
|
catch (err) {
|
|
64
76
|
// A failed delivery is logged, never thrown: the core would take a throw
|
|
@@ -66,6 +78,56 @@ export class DiscordPort {
|
|
|
66
78
|
this.log.error("could not send to Discord", { channel: ref.channelId, status: err?.status, route: err?.route });
|
|
67
79
|
}
|
|
68
80
|
}
|
|
81
|
+
/** labelLast puts a voice reply's label on the last line of the text it
|
|
82
|
+
* goes with (a voice message itself carries no text), and returns that
|
|
83
|
+
* message's id for the voice message to reply to. When the text cannot be
|
|
84
|
+
* edited (unknown, too long, its token expired), the label is posted as a
|
|
85
|
+
* message of its own. It throws when neither works: a voice reply nobody
|
|
86
|
+
* can tell is generated is not sent. */
|
|
87
|
+
async labelLast(ref, label) {
|
|
88
|
+
const line = `-# ${neutraliseLinks(label)}`;
|
|
89
|
+
const last = this.lastText.get(ref.channelId);
|
|
90
|
+
const edit = last && last.content.length + 1 + line.length <= MAX_CONTENT ? this.editorOf(ref.channelId, last) : undefined;
|
|
91
|
+
if (last && edit) {
|
|
92
|
+
const content = `${last.content}\n${line}`;
|
|
93
|
+
try {
|
|
94
|
+
await edit({ content, allowed_mentions: NO_MENTIONS, flags: SUPPRESS_EMBEDS });
|
|
95
|
+
last.content = content;
|
|
96
|
+
return last.messageId;
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
this.log.warn("could not label the reply text; posting the label on its own", { channel: ref.channelId, status: err?.status });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const posted = await this.deliver(ref, textMessage(line));
|
|
103
|
+
const id = messageIdOf(posted.message);
|
|
104
|
+
if (!id)
|
|
105
|
+
throw new Error("discord: the label message has no id");
|
|
106
|
+
this.remember(ref.channelId, posted, textMessage(line));
|
|
107
|
+
return id;
|
|
108
|
+
}
|
|
109
|
+
/** editorOf edits a remembered message with the credential it was posted
|
|
110
|
+
* with, or is undefined once that interaction's token is too old. */
|
|
111
|
+
editorOf(channelId, last) {
|
|
112
|
+
const via = last.via;
|
|
113
|
+
if (!via)
|
|
114
|
+
return (body) => this.api.editMessage(channelId, last.messageId, body);
|
|
115
|
+
if (this.now() >= via.expiresAt - EXPIRY_MARGIN_MS)
|
|
116
|
+
return undefined;
|
|
117
|
+
return via.original ? (body) => this.api.editOriginal(via.token, body) : (body) => this.api.editFollowUp(via.token, last.messageId, body);
|
|
118
|
+
}
|
|
119
|
+
remember(channelId, posted, body) {
|
|
120
|
+
const messageId = messageIdOf(posted.message);
|
|
121
|
+
if (!messageId || typeof body.content !== "string") {
|
|
122
|
+
this.lastText.delete(channelId);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (this.lastText.size >= MAX_TRACKED)
|
|
126
|
+
this.lastText.clear();
|
|
127
|
+
// Re-inserted, so the map stays in the order channels were last written.
|
|
128
|
+
this.lastText.delete(channelId);
|
|
129
|
+
this.lastText.set(channelId, { messageId, content: body.content, ...(posted.via ? { via: posted.via } : {}) });
|
|
130
|
+
}
|
|
69
131
|
async deliver(ref, body) {
|
|
70
132
|
const inChannel = [...this.tracked.values()].filter((t) => t.channelId === ref.channelId && this.usable(t));
|
|
71
133
|
const pending = inChannel.filter((t) => t.pending);
|
|
@@ -77,25 +139,25 @@ export class DiscordPort {
|
|
|
77
139
|
for (const t of pending)
|
|
78
140
|
this.claim(t);
|
|
79
141
|
try {
|
|
80
|
-
await this.editOriginal(target.token, body);
|
|
142
|
+
const message = await this.editOriginal(target.token, body);
|
|
143
|
+
return { message, via: { token: target.token, original: true, expiresAt: target.expiresAt } };
|
|
81
144
|
}
|
|
82
145
|
finally {
|
|
83
146
|
for (const t of pending.slice(0, -1)) {
|
|
84
147
|
await this.api.editOriginal(t.token, textMessage(CONTINUED)).catch(() => undefined);
|
|
85
148
|
}
|
|
86
149
|
}
|
|
87
|
-
return;
|
|
88
150
|
}
|
|
89
151
|
const newest = inChannel[inChannel.length - 1];
|
|
90
152
|
if (newest) {
|
|
91
|
-
await this.api.followUp(newest.token, body);
|
|
92
|
-
return;
|
|
153
|
+
const message = await this.api.followUp(newest.token, body);
|
|
154
|
+
return { message, via: { token: newest.token, original: false, expiresAt: newest.expiresAt } };
|
|
93
155
|
}
|
|
94
|
-
await this.api.createMessage(ref.channelId, body);
|
|
156
|
+
return { message: await this.api.createMessage(ref.channelId, body) };
|
|
95
157
|
}
|
|
96
158
|
async editOriginal(token, body) {
|
|
97
159
|
try {
|
|
98
|
-
await this.api.editOriginal(token, body);
|
|
160
|
+
return await this.api.editOriginal(token, body);
|
|
99
161
|
}
|
|
100
162
|
catch (err) {
|
|
101
163
|
// The edit may have overtaken the deferred response it replaces: the
|
|
@@ -103,7 +165,7 @@ export class DiscordPort {
|
|
|
103
165
|
if (!(err instanceof DiscordApiError && err.status === 404))
|
|
104
166
|
throw err;
|
|
105
167
|
await new Promise((r) => setTimeout(r, this.retryUnknownMs));
|
|
106
|
-
|
|
168
|
+
return this.api.editOriginal(token, body);
|
|
107
169
|
}
|
|
108
170
|
}
|
|
109
171
|
async typing(channelId) {
|
package/dist/port.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"port.js","sourceRoot":"","sources":["../src/port.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,6BAA6B;AAC7B,EAAE;AACF,8EAA8E;AAC9E,2EAA2E;AAC3E,gDAAgD;AAChD,4EAA4E;AAC5E,oEAAoE;AACpE,EAAE;AACF,2EAA2E;AAC3E,8EAA8E;AAC9E,4DAA4D;AAE5D,OAAO,EAAE,YAAY,EAAuD,MAAM,wBAAwB,CAAC;AAE3G,OAAO,EAAE,eAAe,EAAmB,MAAM,UAAU,CAAC;AAC5D,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,UAAU,EAAE,WAAW,EAAoB,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"port.js","sourceRoot":"","sources":["../src/port.ts"],"names":[],"mappings":"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"}
|
package/dist/voice.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type VoiceIn, type VoiceOut, type VoicePort } from "@loomcycle/bridge-core";
|
|
2
|
+
import { type DiscordApi } from "./api.js";
|
|
3
|
+
import type { Interaction } from "./interaction.js";
|
|
4
|
+
import type { DiscordPort, DiscordRef } from "./port.js";
|
|
5
|
+
export declare const IS_VOICE_MESSAGE: number;
|
|
6
|
+
/** The hosts Discord serves attachments from. Nothing is fetched elsewhere. */
|
|
7
|
+
export declare const VOICE_HOSTS: ReadonlySet<string>;
|
|
8
|
+
/** Used until an interaction in the channel says otherwise
|
|
9
|
+
* (attachment_size_limit): the lowest free-tier limit Discord has had. */
|
|
10
|
+
export declare const DEFAULT_MAX_UPLOAD_BYTES: number;
|
|
11
|
+
export declare const VOICE_FILENAME = "voice-message.ogg";
|
|
12
|
+
/** Decision D10: Discord is sent an empty waveform, as community clients do,
|
|
13
|
+
* unless P0 shows it refuses one. The client then draws a flat bar. The seam
|
|
14
|
+
* for a real one is here: loomcycle may add an X-Loomcycle-Speech-Waveform
|
|
15
|
+
* header (256 one-byte samples, base64), which would replace this constant. */
|
|
16
|
+
export declare const WAVEFORM = "";
|
|
17
|
+
export declare const NEEDS_VOICE = "Ask by voice works on a voice message, or a message with one audio file.";
|
|
18
|
+
export declare const OWN_VOICE_ONLY = "You can ask by voice only with your own voice message.";
|
|
19
|
+
export declare const VOICE_OFF = "Voice messages aren't turned on for this bot.";
|
|
20
|
+
/** voiceOfMessage reads the recording an Ask by voice interaction points at:
|
|
21
|
+
* a voice message, or a message whose one attachment is audio.
|
|
22
|
+
*
|
|
23
|
+
* Only the person who recorded it may ask with it. The words are its
|
|
24
|
+
* author's; taken from someone else they would be the invoker's request in
|
|
25
|
+
* another person's voice, run under the invoker's trust, and they would
|
|
26
|
+
* send a person's recording to transcription without that person asking.
|
|
27
|
+
* This also keeps the bridge from transcribing its own voice replies. */
|
|
28
|
+
export declare function voiceOfMessage(ix: Interaction): {
|
|
29
|
+
voice: VoiceIn;
|
|
30
|
+
} | {
|
|
31
|
+
refusal: string;
|
|
32
|
+
};
|
|
33
|
+
/** voiceUrl is an attachment URL the bridge may fetch: https, on Discord's
|
|
34
|
+
* CDN, with no credentials or port in it. */
|
|
35
|
+
export declare function voiceUrl(raw: unknown): URL | undefined;
|
|
36
|
+
/** baseMime is a content type without its parameters, lower-cased. */
|
|
37
|
+
export declare function baseMime(raw: unknown): string | undefined;
|
|
38
|
+
/** oggSeconds reads an Ogg Opus stream's length from its last page's granule
|
|
39
|
+
* position, less the pre-skip in its OpusHead (Opus is always 48 kHz), for
|
|
40
|
+
* when loomcycle sent no duration. undefined when it is not Ogg Opus. */
|
|
41
|
+
export declare function oggSeconds(b: Uint8Array): number | undefined;
|
|
42
|
+
export interface DiscordVoiceOptions {
|
|
43
|
+
/** The fetch attachments are downloaded with (tests). */
|
|
44
|
+
fetch?: typeof fetch;
|
|
45
|
+
}
|
|
46
|
+
/** DiscordVoice is the bridge's VoicePort. Its errors never carry the
|
|
47
|
+
* attachment URL: its signature is good until it expires. */
|
|
48
|
+
export declare class DiscordVoice implements VoicePort<DiscordRef> {
|
|
49
|
+
private readonly api;
|
|
50
|
+
private readonly port;
|
|
51
|
+
readonly capabilities: {
|
|
52
|
+
voiceFormats: "ogg"[];
|
|
53
|
+
out: "native";
|
|
54
|
+
maxUploadBytes: number;
|
|
55
|
+
};
|
|
56
|
+
/** attachment_size_limit by channel, from the interactions seen there. */
|
|
57
|
+
private readonly limits;
|
|
58
|
+
private readonly fetch;
|
|
59
|
+
constructor(api: DiscordApi, port: DiscordPort, opts?: DiscordVoiceOptions);
|
|
60
|
+
/** observe records the upload limit an interaction reports for its channel. */
|
|
61
|
+
observe(channelId: string, attachmentSizeLimit: unknown): void;
|
|
62
|
+
download(v: VoiceIn, maxBytes: number, signal: AbortSignal): Promise<{
|
|
63
|
+
bytes: Uint8Array;
|
|
64
|
+
mime: string;
|
|
65
|
+
name?: string;
|
|
66
|
+
}>;
|
|
67
|
+
sendVoice(ref: DiscordRef, audio: VoiceOut): Promise<void>;
|
|
68
|
+
indicate(ref: DiscordRef): Promise<void>;
|
|
69
|
+
}
|
package/dist/voice.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Voice messages (RFC GA §5.3): the Discord side of the core's VoicePort.
|
|
2
|
+
//
|
|
3
|
+
// In: a person picks "Ask by voice" on their own voice message (a message
|
|
4
|
+
// command; the bridge has no Gateway to see messages). The recording is the
|
|
5
|
+
// message's attachment on Discord's CDN, at a signed URL that needs no token.
|
|
6
|
+
//
|
|
7
|
+
// Out: a voice message is a bot-token channel message with flag
|
|
8
|
+
// IS_VOICE_MESSAGE and one OGG/Opus attachment. It may carry no text, so it
|
|
9
|
+
// replies to the text reply, whose last line says the voice is generated.
|
|
10
|
+
import { VoiceRefusedError } from "@loomcycle/bridge-core";
|
|
11
|
+
import { DiscordApiError, USER_AGENT } from "./api.js";
|
|
12
|
+
export const IS_VOICE_MESSAGE = 1 << 13;
|
|
13
|
+
/** The hosts Discord serves attachments from. Nothing is fetched elsewhere. */
|
|
14
|
+
export const VOICE_HOSTS = new Set(["cdn.discordapp.com", "media.discordapp.net"]);
|
|
15
|
+
/** Used until an interaction in the channel says otherwise
|
|
16
|
+
* (attachment_size_limit): the lowest free-tier limit Discord has had. */
|
|
17
|
+
export const DEFAULT_MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
18
|
+
export const VOICE_FILENAME = "voice-message.ogg";
|
|
19
|
+
/** Decision D10: Discord is sent an empty waveform, as community clients do,
|
|
20
|
+
* unless P0 shows it refuses one. The client then draws a flat bar. The seam
|
|
21
|
+
* for a real one is here: loomcycle may add an X-Loomcycle-Speech-Waveform
|
|
22
|
+
* header (256 one-byte samples, base64), which would replace this constant. */
|
|
23
|
+
export const WAVEFORM = "";
|
|
24
|
+
const MAX_REDIRECTS = 3;
|
|
25
|
+
const MAX_LIMITS = 10_000;
|
|
26
|
+
/** Discord's error codes for "not here": Missing Permissions (the bot lacks
|
|
27
|
+
* SEND_VOICE_MESSAGES or ATTACH_FILES), Cannot send messages to this user,
|
|
28
|
+
* You cannot send voice messages in this channel, and 40094 as RFC GA names
|
|
29
|
+
* it (verify in P0). */
|
|
30
|
+
const REFUSED_CODES = new Set([50013, 50007, 50173, 40094]);
|
|
31
|
+
const AUDIO_TYPE = /^(audio\/[\w.+-]+|video\/[\w.+-]+|application\/ogg|application\/octet-stream)$/i;
|
|
32
|
+
export const NEEDS_VOICE = "Ask by voice works on a voice message, or a message with one audio file.";
|
|
33
|
+
export const OWN_VOICE_ONLY = "You can ask by voice only with your own voice message.";
|
|
34
|
+
export const VOICE_OFF = "Voice messages aren't turned on for this bot.";
|
|
35
|
+
/** voiceOfMessage reads the recording an Ask by voice interaction points at:
|
|
36
|
+
* a voice message, or a message whose one attachment is audio.
|
|
37
|
+
*
|
|
38
|
+
* Only the person who recorded it may ask with it. The words are its
|
|
39
|
+
* author's; taken from someone else they would be the invoker's request in
|
|
40
|
+
* another person's voice, run under the invoker's trust, and they would
|
|
41
|
+
* send a person's recording to transcription without that person asking.
|
|
42
|
+
* This also keeps the bridge from transcribing its own voice replies. */
|
|
43
|
+
export function voiceOfMessage(ix) {
|
|
44
|
+
const targetId = ix.data?.target_id;
|
|
45
|
+
const msg = typeof targetId === "string" ? ix.data?.resolved?.messages?.[targetId] : undefined;
|
|
46
|
+
const attachments = Array.isArray(msg?.attachments) ? msg.attachments : [];
|
|
47
|
+
if (!msg || attachments.length !== 1)
|
|
48
|
+
return { refusal: NEEDS_VOICE };
|
|
49
|
+
const a = attachments[0];
|
|
50
|
+
const flagged = typeof msg.flags === "number" && (msg.flags & IS_VOICE_MESSAGE) !== 0;
|
|
51
|
+
const mime = baseMime(a.content_type);
|
|
52
|
+
if (!flagged && !mime?.startsWith("audio/"))
|
|
53
|
+
return { refusal: NEEDS_VOICE };
|
|
54
|
+
const invoker = (ix.member?.user ?? ix.user)?.id;
|
|
55
|
+
if (!invoker || msg.author?.id !== invoker || msg.author.bot)
|
|
56
|
+
return { refusal: OWN_VOICE_ONLY };
|
|
57
|
+
if (!voiceUrl(a.url))
|
|
58
|
+
return { refusal: NEEDS_VOICE };
|
|
59
|
+
const finite = (n) => (typeof n === "number" && Number.isFinite(n) && n >= 0 ? n : undefined);
|
|
60
|
+
const seconds = finite(a.duration_secs);
|
|
61
|
+
const bytes = finite(a.size);
|
|
62
|
+
return {
|
|
63
|
+
voice: {
|
|
64
|
+
kind: flagged ? "voice" : "audio_file",
|
|
65
|
+
ref: a.url,
|
|
66
|
+
mime: mime ?? "audio/ogg",
|
|
67
|
+
...(seconds !== undefined ? { seconds } : {}),
|
|
68
|
+
...(bytes !== undefined ? { bytes } : {}),
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** voiceUrl is an attachment URL the bridge may fetch: https, on Discord's
|
|
73
|
+
* CDN, with no credentials or port in it. */
|
|
74
|
+
export function voiceUrl(raw) {
|
|
75
|
+
if (typeof raw !== "string")
|
|
76
|
+
return undefined;
|
|
77
|
+
let u;
|
|
78
|
+
try {
|
|
79
|
+
u = new URL(raw);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
if (u.protocol !== "https:" || u.username || u.password || u.port)
|
|
85
|
+
return undefined;
|
|
86
|
+
return VOICE_HOSTS.has(u.hostname.toLowerCase()) ? u : undefined;
|
|
87
|
+
}
|
|
88
|
+
/** baseMime is a content type without its parameters, lower-cased. */
|
|
89
|
+
export function baseMime(raw) {
|
|
90
|
+
if (typeof raw !== "string")
|
|
91
|
+
return undefined;
|
|
92
|
+
const m = raw.split(";")[0].trim().toLowerCase();
|
|
93
|
+
return m || undefined;
|
|
94
|
+
}
|
|
95
|
+
/** oggSeconds reads an Ogg Opus stream's length from its last page's granule
|
|
96
|
+
* position, less the pre-skip in its OpusHead (Opus is always 48 kHz), for
|
|
97
|
+
* when loomcycle sent no duration. undefined when it is not Ogg Opus. */
|
|
98
|
+
export function oggSeconds(b) {
|
|
99
|
+
const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
100
|
+
// "OggS", then stream structure version 0.
|
|
101
|
+
const isPage = (i) => b[i] === 0x4f && b[i + 1] === 0x67 && b[i + 2] === 0x67 && b[i + 3] === 0x53 && b[i + 4] === 0;
|
|
102
|
+
let head = -1;
|
|
103
|
+
for (let i = 0; i + 19 <= b.length && i < 512; i++) {
|
|
104
|
+
if (String.fromCharCode(...b.subarray(i, i + 8)) === "OpusHead") {
|
|
105
|
+
head = i;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (head < 0)
|
|
110
|
+
return undefined;
|
|
111
|
+
const preSkip = view.getUint16(head + 10, true);
|
|
112
|
+
for (let i = b.length - 27; i >= 0; i--) {
|
|
113
|
+
if (!isPage(i))
|
|
114
|
+
continue;
|
|
115
|
+
const granule = view.getBigUint64(i + 6, true);
|
|
116
|
+
// All ones: no packet ends on this page.
|
|
117
|
+
if (granule === 0xffffffffffffffffn)
|
|
118
|
+
continue;
|
|
119
|
+
const samples = Number(granule) - preSkip;
|
|
120
|
+
return samples > 0 ? Math.round((samples / 48_000) * 100) / 100 : undefined;
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
/** readCapped reads a body, failing as soon as it passes maxBytes. */
|
|
125
|
+
async function readCapped(res, maxBytes) {
|
|
126
|
+
const declared = Number(res.headers.get("content-length"));
|
|
127
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
128
|
+
await res.body?.cancel().catch(() => undefined);
|
|
129
|
+
throw new Error("discord voice download: larger than allowed");
|
|
130
|
+
}
|
|
131
|
+
if (!res.body)
|
|
132
|
+
return new Uint8Array();
|
|
133
|
+
const reader = res.body.getReader();
|
|
134
|
+
const parts = [];
|
|
135
|
+
let total = 0;
|
|
136
|
+
for (;;) {
|
|
137
|
+
const { done, value } = await reader.read();
|
|
138
|
+
if (done)
|
|
139
|
+
break;
|
|
140
|
+
total += value.byteLength;
|
|
141
|
+
if (total > maxBytes) {
|
|
142
|
+
await reader.cancel().catch(() => undefined);
|
|
143
|
+
throw new Error("discord voice download: larger than allowed");
|
|
144
|
+
}
|
|
145
|
+
parts.push(value);
|
|
146
|
+
}
|
|
147
|
+
const out = new Uint8Array(total);
|
|
148
|
+
let at = 0;
|
|
149
|
+
for (const p of parts) {
|
|
150
|
+
out.set(p, at);
|
|
151
|
+
at += p.byteLength;
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
/** DiscordVoice is the bridge's VoicePort. Its errors never carry the
|
|
156
|
+
* attachment URL: its signature is good until it expires. */
|
|
157
|
+
export class DiscordVoice {
|
|
158
|
+
api;
|
|
159
|
+
port;
|
|
160
|
+
capabilities = { voiceFormats: ["ogg"], out: "native", maxUploadBytes: DEFAULT_MAX_UPLOAD_BYTES };
|
|
161
|
+
/** attachment_size_limit by channel, from the interactions seen there. */
|
|
162
|
+
limits = new Map();
|
|
163
|
+
fetch;
|
|
164
|
+
constructor(api, port, opts = {}) {
|
|
165
|
+
this.api = api;
|
|
166
|
+
this.port = port;
|
|
167
|
+
this.fetch = opts.fetch ?? globalThis.fetch;
|
|
168
|
+
}
|
|
169
|
+
/** observe records the upload limit an interaction reports for its channel. */
|
|
170
|
+
observe(channelId, attachmentSizeLimit) {
|
|
171
|
+
if (typeof attachmentSizeLimit !== "number" || !Number.isSafeInteger(attachmentSizeLimit) || attachmentSizeLimit <= 0)
|
|
172
|
+
return;
|
|
173
|
+
if (this.limits.size >= MAX_LIMITS)
|
|
174
|
+
this.limits.clear();
|
|
175
|
+
this.limits.set(channelId, attachmentSizeLimit);
|
|
176
|
+
}
|
|
177
|
+
async download(v, maxBytes, signal) {
|
|
178
|
+
const first = voiceUrl(v.ref);
|
|
179
|
+
if (!first)
|
|
180
|
+
throw new Error("discord voice download: not a Discord attachment URL");
|
|
181
|
+
let url = first;
|
|
182
|
+
for (let hop = 0;; hop++) {
|
|
183
|
+
// No Authorization header: the URL is signed, and the bot token is for
|
|
184
|
+
// Discord's API only.
|
|
185
|
+
const res = await this.fetch(url, { method: "GET", headers: { "user-agent": USER_AGENT }, redirect: "manual", signal });
|
|
186
|
+
if (res.status >= 300 && res.status < 400) {
|
|
187
|
+
await res.body?.cancel().catch(() => undefined);
|
|
188
|
+
const next = hop < MAX_REDIRECTS ? voiceUrl(safeResolve(res.headers.get("location"), url)) : undefined;
|
|
189
|
+
if (!next)
|
|
190
|
+
throw new Error("discord voice download: redirected off Discord's CDN");
|
|
191
|
+
url = next;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (!res.ok) {
|
|
195
|
+
await res.body?.cancel().catch(() => undefined);
|
|
196
|
+
throw new Error(`discord voice download: http ${res.status}`);
|
|
197
|
+
}
|
|
198
|
+
const served = baseMime(res.headers.get("content-type"));
|
|
199
|
+
if (!served || !AUDIO_TYPE.test(served)) {
|
|
200
|
+
await res.body?.cancel().catch(() => undefined);
|
|
201
|
+
throw new Error("discord voice download: not audio");
|
|
202
|
+
}
|
|
203
|
+
const bytes = await readCapped(res, maxBytes);
|
|
204
|
+
const mime = v.mime ?? (served.startsWith("audio/") ? served : "audio/ogg");
|
|
205
|
+
return { bytes, mime, name: VOICE_FILENAME };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async sendVoice(ref, audio) {
|
|
209
|
+
// pickFormat offers Discord only ogg; anything else would not show as voice.
|
|
210
|
+
if (baseMime(audio.mime) !== "audio/ogg")
|
|
211
|
+
throw new Error("discord voice: only audio/ogg is a voice message");
|
|
212
|
+
const limit = this.limits.get(ref.channelId) ?? DEFAULT_MAX_UPLOAD_BYTES;
|
|
213
|
+
if (audio.bytes.byteLength > limit)
|
|
214
|
+
throw new Error("discord voice: larger than this channel takes");
|
|
215
|
+
const seconds = audio.seconds ?? oggSeconds(audio.bytes);
|
|
216
|
+
if (seconds === undefined || !(seconds > 0))
|
|
217
|
+
throw new Error("discord voice: no duration");
|
|
218
|
+
try {
|
|
219
|
+
const replyTo = await this.port.labelLast(ref, audio.label);
|
|
220
|
+
await this.api.createVoiceMessage(ref.channelId, {
|
|
221
|
+
flags: IS_VOICE_MESSAGE,
|
|
222
|
+
attachments: [{ id: 0, filename: VOICE_FILENAME, duration_secs: seconds, waveform: WAVEFORM }],
|
|
223
|
+
message_reference: { message_id: replyTo, fail_if_not_exists: false },
|
|
224
|
+
}, { bytes: audio.bytes, filename: VOICE_FILENAME, contentType: "audio/ogg" });
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
if (err instanceof DiscordApiError && (err.status === 403 || (err.code !== undefined && REFUSED_CODES.has(err.code)))) {
|
|
228
|
+
throw new VoiceRefusedError(`discord refused the voice message (http ${err.status}${err.code !== undefined ? `, code ${err.code}` : ""})`);
|
|
229
|
+
}
|
|
230
|
+
throw err;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
async indicate(ref) {
|
|
234
|
+
// The port skips it while a deferred response shows Discord's own
|
|
235
|
+
// "thinking…", and backs off a channel that refuses it.
|
|
236
|
+
await this.port.send(ref, { type: "typing" });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function safeResolve(location, base) {
|
|
240
|
+
if (!location)
|
|
241
|
+
return undefined;
|
|
242
|
+
try {
|
|
243
|
+
return new URL(location, base).href;
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
//# sourceMappingURL=voice.js.map
|