@copilotkit/channels-discord 0.0.2
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/LICENSE +21 -0
- package/README.md +308 -0
- package/dist/__tests__/ephemeral.test.d.ts +2 -0
- package/dist/__tests__/ephemeral.test.d.ts.map +1 -0
- package/dist/__tests__/ephemeral.test.js +26 -0
- package/dist/__tests__/intents.test.d.ts +2 -0
- package/dist/__tests__/intents.test.d.ts.map +1 -0
- package/dist/__tests__/intents.test.js +8 -0
- package/dist/__tests__/modal-submit.test.d.ts +2 -0
- package/dist/__tests__/modal-submit.test.d.ts.map +1 -0
- package/dist/__tests__/modal-submit.test.js +26 -0
- package/dist/__tests__/reactions.test.d.ts +2 -0
- package/dist/__tests__/reactions.test.d.ts.map +1 -0
- package/dist/__tests__/reactions.test.js +87 -0
- package/dist/adapter.d.ts +118 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +567 -0
- package/dist/adapter.test.d.ts +2 -0
- package/dist/adapter.test.d.ts.map +1 -0
- package/dist/adapter.test.js +343 -0
- package/dist/auto-close-streaming.d.ts +61 -0
- package/dist/auto-close-streaming.d.ts.map +1 -0
- package/dist/auto-close-streaming.js +276 -0
- package/dist/auto-close-streaming.test.d.ts +2 -0
- package/dist/auto-close-streaming.test.d.ts.map +1 -0
- package/dist/auto-close-streaming.test.js +149 -0
- package/dist/built-in-context.d.ts +20 -0
- package/dist/built-in-context.d.ts.map +1 -0
- package/dist/built-in-context.js +30 -0
- package/dist/built-in-context.test.d.ts +2 -0
- package/dist/built-in-context.test.d.ts.map +1 -0
- package/dist/built-in-context.test.js +21 -0
- package/dist/built-in-tools.d.ts +4 -0
- package/dist/built-in-tools.d.ts.map +1 -0
- package/dist/built-in-tools.js +27 -0
- package/dist/built-in-tools.test.d.ts +2 -0
- package/dist/built-in-tools.test.d.ts.map +1 -0
- package/dist/built-in-tools.test.js +30 -0
- package/dist/chunked-message-stream.d.ts +94 -0
- package/dist/chunked-message-stream.d.ts.map +1 -0
- package/dist/chunked-message-stream.js +203 -0
- package/dist/chunked-message-stream.test.d.ts +2 -0
- package/dist/chunked-message-stream.test.d.ts.map +1 -0
- package/dist/chunked-message-stream.test.js +384 -0
- package/dist/commands.d.ts +29 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +193 -0
- package/dist/commands.test.d.ts +2 -0
- package/dist/commands.test.d.ts.map +1 -0
- package/dist/commands.test.js +281 -0
- package/dist/conversation-store.d.ts +45 -0
- package/dist/conversation-store.d.ts.map +1 -0
- package/dist/conversation-store.js +78 -0
- package/dist/conversation-store.test.d.ts +2 -0
- package/dist/conversation-store.test.d.ts.map +1 -0
- package/dist/conversation-store.test.js +106 -0
- package/dist/discord-listener.d.ts +88 -0
- package/dist/discord-listener.d.ts.map +1 -0
- package/dist/discord-listener.js +118 -0
- package/dist/discord-listener.test.d.ts +2 -0
- package/dist/discord-listener.test.d.ts.map +1 -0
- package/dist/discord-listener.test.js +361 -0
- package/dist/download-files.d.ts +94 -0
- package/dist/download-files.d.ts.map +1 -0
- package/dist/download-files.js +145 -0
- package/dist/download-files.test.d.ts +2 -0
- package/dist/download-files.test.d.ts.map +1 -0
- package/dist/download-files.test.js +181 -0
- package/dist/event-renderer.d.ts +36 -0
- package/dist/event-renderer.d.ts.map +1 -0
- package/dist/event-renderer.js +270 -0
- package/dist/event-renderer.test.d.ts +2 -0
- package/dist/event-renderer.test.d.ts.map +1 -0
- package/dist/event-renderer.test.js +160 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/interaction.d.ts +11 -0
- package/dist/interaction.d.ts.map +1 -0
- package/dist/interaction.js +138 -0
- package/dist/interaction.test.d.ts +2 -0
- package/dist/interaction.test.d.ts.map +1 -0
- package/dist/interaction.test.js +120 -0
- package/dist/markdown.d.ts +7 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +66 -0
- package/dist/markdown.test.d.ts +2 -0
- package/dist/markdown.test.d.ts.map +1 -0
- package/dist/markdown.test.js +47 -0
- package/dist/message-stream.d.ts +45 -0
- package/dist/message-stream.d.ts.map +1 -0
- package/dist/message-stream.js +76 -0
- package/dist/message-stream.test.d.ts +2 -0
- package/dist/message-stream.test.d.ts.map +1 -0
- package/dist/message-stream.test.js +162 -0
- package/dist/pending-interactions.d.ts +26 -0
- package/dist/pending-interactions.d.ts.map +1 -0
- package/dist/pending-interactions.js +50 -0
- package/dist/pending-interactions.test.d.ts +2 -0
- package/dist/pending-interactions.test.d.ts.map +1 -0
- package/dist/pending-interactions.test.js +73 -0
- package/dist/render/budget.d.ts +28 -0
- package/dist/render/budget.d.ts.map +1 -0
- package/dist/render/budget.js +49 -0
- package/dist/render/budget.test.d.ts +2 -0
- package/dist/render/budget.test.d.ts.map +1 -0
- package/dist/render/budget.test.js +59 -0
- package/dist/render/components-v2.d.ts +16 -0
- package/dist/render/components-v2.d.ts.map +1 -0
- package/dist/render/components-v2.js +396 -0
- package/dist/render/components-v2.test.d.ts +2 -0
- package/dist/render/components-v2.test.d.ts.map +1 -0
- package/dist/render/components-v2.test.js +395 -0
- package/dist/render/modal.d.ts +12 -0
- package/dist/render/modal.d.ts.map +1 -0
- package/dist/render/modal.js +44 -0
- package/dist/types.d.ts +16 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.test.d.ts +2 -0
- package/dist/types.test.d.ts.map +1 -0
- package/dist/types.test.js +12 -0
- package/package.json +56 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { MessageFlags } from "discord.js";
|
|
3
|
+
import { DiscordAdapter, discord } from "./adapter.js";
|
|
4
|
+
function fakeClient() {
|
|
5
|
+
const handlers = {};
|
|
6
|
+
return {
|
|
7
|
+
on(e, cb) {
|
|
8
|
+
handlers[e] = cb;
|
|
9
|
+
},
|
|
10
|
+
once(e, cb) {
|
|
11
|
+
handlers[e] = cb;
|
|
12
|
+
},
|
|
13
|
+
login: vi.fn(async () => "ok"),
|
|
14
|
+
destroy: vi.fn(async () => { }),
|
|
15
|
+
user: { id: "bot-1" },
|
|
16
|
+
channels: {
|
|
17
|
+
fetch: vi.fn(async () => ({
|
|
18
|
+
id: "c1",
|
|
19
|
+
send: vi.fn(async () => ({ id: "m1" })),
|
|
20
|
+
})),
|
|
21
|
+
},
|
|
22
|
+
emit(e, a) {
|
|
23
|
+
handlers[e]?.(a);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const sink = () => ({
|
|
28
|
+
onTurn: vi.fn(),
|
|
29
|
+
onInteraction: vi.fn(),
|
|
30
|
+
onCommand: vi.fn(),
|
|
31
|
+
onModalSubmit: vi.fn(),
|
|
32
|
+
});
|
|
33
|
+
describe("DiscordAdapter", () => {
|
|
34
|
+
it("advertises Discord capabilities", () => {
|
|
35
|
+
const a = new DiscordAdapter({ botToken: "t", appId: "app" });
|
|
36
|
+
expect(a.platform).toBe("discord");
|
|
37
|
+
expect(a.capabilities.supportsModals).toBe(true);
|
|
38
|
+
expect(a.capabilities.supportsTyping).toBe(true);
|
|
39
|
+
expect(a.capabilities.supportsReactions).toBe(true);
|
|
40
|
+
expect(a.capabilities.supportsEphemeral).toBe(false);
|
|
41
|
+
expect(a.capabilities.supportsStreaming).toBe(true);
|
|
42
|
+
expect(a.capabilities.maxBlocksPerMessage).toBe(40);
|
|
43
|
+
expect(a.ackDeadlineMs).toBe(3000);
|
|
44
|
+
});
|
|
45
|
+
it("renders IR to a components-v2 container", () => {
|
|
46
|
+
const a = new DiscordAdapter({ botToken: "t", appId: "app" });
|
|
47
|
+
const out = a.render([
|
|
48
|
+
{
|
|
49
|
+
type: "message",
|
|
50
|
+
props: { children: { type: "text", props: { value: "hi" } } },
|
|
51
|
+
},
|
|
52
|
+
]);
|
|
53
|
+
expect(out).toBeTruthy(); // ContainerBuilder
|
|
54
|
+
});
|
|
55
|
+
it("logs in and captures the bot id on start, publishing commands on ready", async () => {
|
|
56
|
+
const client = fakeClient();
|
|
57
|
+
const put = vi.fn(async () => { });
|
|
58
|
+
const a = new DiscordAdapter({ botToken: "t", appId: "app" }, { client: client, rest: { put } });
|
|
59
|
+
await a.start(sink());
|
|
60
|
+
expect(client.login).toHaveBeenCalledWith("t");
|
|
61
|
+
expect(put).not.toHaveBeenCalled();
|
|
62
|
+
// Stash a non-empty command list — publishCommands guards an empty list (an
|
|
63
|
+
// empty PUT would clear all of the bot's commands), so a command must be
|
|
64
|
+
// registered for the once("ready") publish to PUT anything.
|
|
65
|
+
a.registerCommands([{ name: "agent", description: "x" }]);
|
|
66
|
+
client.emit("ready", client); // discord.js passes the ready client
|
|
67
|
+
// ready handler is async; flush microtasks
|
|
68
|
+
await Promise.resolve();
|
|
69
|
+
await Promise.resolve();
|
|
70
|
+
expect(put).toHaveBeenCalledTimes(1);
|
|
71
|
+
});
|
|
72
|
+
it("discord() factory returns an adapter", () => {
|
|
73
|
+
expect(discord({ botToken: "t", appId: "app" })).toBeInstanceOf(DiscordAdapter);
|
|
74
|
+
});
|
|
75
|
+
it("stream() edits each posted message with ITS own chunk, not all-on-first", async () => {
|
|
76
|
+
// Each send() mints a distinct id and its own edit spy, so we can assert
|
|
77
|
+
// the per-message Map wiring (chunk N → message N), not chunk-on-#0.
|
|
78
|
+
const posted = [];
|
|
79
|
+
let n = 0;
|
|
80
|
+
const channel = {
|
|
81
|
+
id: "c1",
|
|
82
|
+
send: vi.fn(async () => {
|
|
83
|
+
const m = { id: `m${++n}`, edit: vi.fn(async () => { }) };
|
|
84
|
+
posted.push(m);
|
|
85
|
+
return m;
|
|
86
|
+
}),
|
|
87
|
+
messages: { fetch: vi.fn() },
|
|
88
|
+
};
|
|
89
|
+
const client = {
|
|
90
|
+
...fakeClient(),
|
|
91
|
+
channels: { fetch: vi.fn(async () => channel) },
|
|
92
|
+
};
|
|
93
|
+
const a = new DiscordAdapter({ botToken: "t", appId: "app" }, { client: client, rest: { put: vi.fn() } });
|
|
94
|
+
// Two chunks: >2000 chars forces ChunkedMessageStream to post a second
|
|
95
|
+
// Discord message. Distinct markers per half let us check routing.
|
|
96
|
+
const first = "A".repeat(1500) + "\n";
|
|
97
|
+
const second = "B".repeat(1500);
|
|
98
|
+
async function* chunks() {
|
|
99
|
+
yield first;
|
|
100
|
+
yield second;
|
|
101
|
+
}
|
|
102
|
+
const ref = await a.stream({ channelId: "c1" }, chunks());
|
|
103
|
+
// Two messages posted, ref points at the first.
|
|
104
|
+
expect(posted.length).toBe(2);
|
|
105
|
+
expect(ref.id).toBe("m1");
|
|
106
|
+
// The first message's final edit must NOT contain the second chunk's
|
|
107
|
+
// marker, and the second message's final edit must contain it. If the
|
|
108
|
+
// Map were ignored (old bug), every edit would land on message #0.
|
|
109
|
+
const firstFinal = posted[0].edit.mock.calls.at(-1)?.[0];
|
|
110
|
+
const secondFinal = posted[1].edit.mock.calls.at(-1)?.[0];
|
|
111
|
+
expect(firstFinal).toContain("A");
|
|
112
|
+
expect(firstFinal).not.toContain("B");
|
|
113
|
+
expect(secondFinal).toContain("B");
|
|
114
|
+
});
|
|
115
|
+
it("resolveUser does NOT cache the bare-id fallback on transient fetch failure", async () => {
|
|
116
|
+
const fetch = vi
|
|
117
|
+
.fn()
|
|
118
|
+
// first call throws → bare-id fallback, must not be cached
|
|
119
|
+
.mockRejectedValueOnce(new Error("rate limited"))
|
|
120
|
+
// second call succeeds → real user
|
|
121
|
+
.mockResolvedValueOnce({ id: "u1", globalName: "Ada", username: "ada" });
|
|
122
|
+
const client = { ...fakeClient(), users: { fetch } };
|
|
123
|
+
const a = new DiscordAdapter({ botToken: "t", appId: "app" }, { client: client, rest: { put: vi.fn() } });
|
|
124
|
+
const first = await a.resolveUser("u1");
|
|
125
|
+
expect(first).toEqual({ id: "u1" }); // bare-id fallback
|
|
126
|
+
const second = await a.resolveUser("u1");
|
|
127
|
+
// A retry happened (not served from cache) and resolved the real user.
|
|
128
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
129
|
+
expect(second).toEqual({ id: "u1", name: "Ada", handle: "ada" });
|
|
130
|
+
});
|
|
131
|
+
it("getMessages excludes the bot's own streaming placeholders from history", async () => {
|
|
132
|
+
// Mix: a user message, a real bot reply, and a bot streaming placeholder.
|
|
133
|
+
// fetch() returns a Map-like with values(); getMessages reverses + filters.
|
|
134
|
+
const messages = [
|
|
135
|
+
{
|
|
136
|
+
id: "m1",
|
|
137
|
+
content: "hey bot",
|
|
138
|
+
author: { id: "u1", bot: false, username: "ann", globalName: "Ann" },
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
id: "m2",
|
|
142
|
+
content: "_thinking…_", // bot placeholder — must be excluded
|
|
143
|
+
author: { id: "bot-1", bot: true, username: "bot" },
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
id: "m3",
|
|
147
|
+
content: "here is the real answer",
|
|
148
|
+
author: { id: "bot-1", bot: true, username: "bot" },
|
|
149
|
+
},
|
|
150
|
+
];
|
|
151
|
+
const channel = {
|
|
152
|
+
id: "c1",
|
|
153
|
+
send: vi.fn(async () => ({ id: "x" })), // fetchSendable requires `send`
|
|
154
|
+
messages: {
|
|
155
|
+
fetch: vi.fn(async () => new Map(messages.map((m) => [m.id, m]))),
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
const client = {
|
|
159
|
+
...fakeClient(),
|
|
160
|
+
channels: { fetch: vi.fn(async () => channel) },
|
|
161
|
+
};
|
|
162
|
+
const a = new DiscordAdapter({ botToken: "t", appId: "app" }, { client: client, rest: { put: vi.fn() } });
|
|
163
|
+
const out = await a.getMessages({ channelId: "c1" });
|
|
164
|
+
const texts = out.map((m) => m.text);
|
|
165
|
+
expect(texts).toContain("hey bot");
|
|
166
|
+
expect(texts).toContain("here is the real answer");
|
|
167
|
+
expect(texts).not.toContain("_thinking…_");
|
|
168
|
+
expect(out).toHaveLength(2);
|
|
169
|
+
});
|
|
170
|
+
it("addReaction falls back to the target channel when the reacted ref has no channelId", async () => {
|
|
171
|
+
// The reacted ref the bot-ui example sends is just `{ id }` (no channelId);
|
|
172
|
+
// the channel must come from the conversation's reply target — parity with
|
|
173
|
+
// Slack/Telegram. Regression: previously Discord resolved fetchSendable("")
|
|
174
|
+
// and the react silently failed (acks never fired on Discord).
|
|
175
|
+
const message = { react: vi.fn(async () => undefined) };
|
|
176
|
+
const channel = {
|
|
177
|
+
id: "c1",
|
|
178
|
+
send: vi.fn(async () => ({ id: "x" })),
|
|
179
|
+
messages: { fetch: vi.fn(async () => message) },
|
|
180
|
+
};
|
|
181
|
+
const channelsFetch = vi.fn(async (id) => {
|
|
182
|
+
if (!id)
|
|
183
|
+
throw new Error("channel is not sendable"); // empty id is the bug
|
|
184
|
+
return channel;
|
|
185
|
+
});
|
|
186
|
+
const client = { ...fakeClient(), channels: { fetch: channelsFetch } };
|
|
187
|
+
const a = new DiscordAdapter({ botToken: "t", appId: "app" }, { client: client, rest: { put: vi.fn() } });
|
|
188
|
+
const res = await a.addReaction({ channelId: "c1" }, { id: "m1" }, "eyes");
|
|
189
|
+
expect(res).toEqual({ ok: true });
|
|
190
|
+
// Resolved the TARGET channel, never the empty string.
|
|
191
|
+
expect(channelsFetch).toHaveBeenCalledWith("c1");
|
|
192
|
+
expect(channelsFetch).not.toHaveBeenCalledWith("");
|
|
193
|
+
expect(message.react).toHaveBeenCalled();
|
|
194
|
+
});
|
|
195
|
+
it("interactionCreate dispatch failures are caught, not left unhandled", async () => {
|
|
196
|
+
const client = fakeClient();
|
|
197
|
+
const a = new DiscordAdapter({ botToken: "t", appId: "app" }, { client: client, rest: { put: vi.fn() } });
|
|
198
|
+
const s = sink();
|
|
199
|
+
// sink.onInteraction rejects — without the try/catch this becomes an
|
|
200
|
+
// unhandled promise rejection inside the event handler.
|
|
201
|
+
s.onInteraction.mockRejectedValue(new Error("dispatch boom"));
|
|
202
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
203
|
+
await a.start(s);
|
|
204
|
+
// A well-formed button interaction that decodes to an event.
|
|
205
|
+
const deferUpdate = vi.fn(async () => { });
|
|
206
|
+
const interaction = {
|
|
207
|
+
isButton: () => true,
|
|
208
|
+
isStringSelectMenu: () => false,
|
|
209
|
+
id: "int-1",
|
|
210
|
+
customId: "btn-1",
|
|
211
|
+
channelId: "c1",
|
|
212
|
+
user: { id: "u1", username: "ada" },
|
|
213
|
+
message: { id: "m1" },
|
|
214
|
+
deferUpdate,
|
|
215
|
+
};
|
|
216
|
+
// emit() invokes the (async) handler synchronously; the body runs across
|
|
217
|
+
// several microtask turns (deferUpdate → decode → dispatch → catch). If the
|
|
218
|
+
// rejection escaped the handler, it would surface as an unhandled rejection
|
|
219
|
+
// (and crash under --unhandled-rejections=throw) rather than the logged path.
|
|
220
|
+
client.emit("interactionCreate", interaction);
|
|
221
|
+
await Promise.resolve();
|
|
222
|
+
await Promise.resolve();
|
|
223
|
+
await Promise.resolve();
|
|
224
|
+
expect(deferUpdate).toHaveBeenCalledTimes(1);
|
|
225
|
+
expect(s.onInteraction).toHaveBeenCalledTimes(1);
|
|
226
|
+
expect(errSpy).toHaveBeenCalledWith("[bot-discord] interaction dispatch failed:", expect.any(Error));
|
|
227
|
+
errSpy.mockRestore();
|
|
228
|
+
});
|
|
229
|
+
// A modal opened from a slash command yields a ModalSubmitInteraction with
|
|
230
|
+
// no originating message — `deferUpdate()` is invalid there and throws. The
|
|
231
|
+
// ack must use `deferReply` (ephemeral) for that origin, and must never throw
|
|
232
|
+
// uncaught.
|
|
233
|
+
function fakeModalSubmit(over) {
|
|
234
|
+
return {
|
|
235
|
+
isButton: () => false,
|
|
236
|
+
isStringSelectMenu: () => false,
|
|
237
|
+
isModalSubmit: () => true,
|
|
238
|
+
customId: "triage",
|
|
239
|
+
channelId: "c1",
|
|
240
|
+
guildId: "g1",
|
|
241
|
+
user: { id: "u1", username: "ada" },
|
|
242
|
+
fields: {
|
|
243
|
+
fields: new Map([["summary", { customId: "summary", value: "x" }]]),
|
|
244
|
+
},
|
|
245
|
+
replied: false,
|
|
246
|
+
deferred: false,
|
|
247
|
+
...over,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
it("modal-submit from a slash command acks with deferReply (ephemeral), not deferUpdate", async () => {
|
|
251
|
+
const client = fakeClient();
|
|
252
|
+
const a = new DiscordAdapter({ botToken: "t", appId: "app" }, { client: client, rest: { put: vi.fn() } });
|
|
253
|
+
const s = sink();
|
|
254
|
+
await a.start(s);
|
|
255
|
+
// Slash-command origin: isFromMessage() === false. deferUpdate would throw
|
|
256
|
+
// for such an interaction in discord.js, so simulate that to prove the
|
|
257
|
+
// correct method is chosen (and that a stray call would surface).
|
|
258
|
+
const deferReply = vi.fn(async (_opts) => { });
|
|
259
|
+
const deferUpdate = vi.fn(async () => {
|
|
260
|
+
throw new Error("interaction not from message");
|
|
261
|
+
});
|
|
262
|
+
const interaction = fakeModalSubmit({
|
|
263
|
+
isFromMessage: () => false,
|
|
264
|
+
deferReply,
|
|
265
|
+
deferUpdate,
|
|
266
|
+
});
|
|
267
|
+
client.emit("interactionCreate", interaction);
|
|
268
|
+
for (let i = 0; i < 5; i++)
|
|
269
|
+
await Promise.resolve();
|
|
270
|
+
expect(s.onModalSubmit).toHaveBeenCalledTimes(1);
|
|
271
|
+
expect(deferReply).toHaveBeenCalledTimes(1);
|
|
272
|
+
// ephemeral flag passed
|
|
273
|
+
expect(deferReply.mock.calls[0]?.[0]).toMatchObject({
|
|
274
|
+
flags: MessageFlags.Ephemeral,
|
|
275
|
+
});
|
|
276
|
+
expect(deferUpdate).not.toHaveBeenCalled();
|
|
277
|
+
});
|
|
278
|
+
it("modal-submit from a message component acks with deferUpdate, not deferReply", async () => {
|
|
279
|
+
const client = fakeClient();
|
|
280
|
+
const a = new DiscordAdapter({ botToken: "t", appId: "app" }, { client: client, rest: { put: vi.fn() } });
|
|
281
|
+
const s = sink();
|
|
282
|
+
await a.start(s);
|
|
283
|
+
const deferReply = vi.fn(async () => { });
|
|
284
|
+
const deferUpdate = vi.fn(async () => { });
|
|
285
|
+
const interaction = fakeModalSubmit({
|
|
286
|
+
isFromMessage: () => true,
|
|
287
|
+
deferReply,
|
|
288
|
+
deferUpdate,
|
|
289
|
+
});
|
|
290
|
+
client.emit("interactionCreate", interaction);
|
|
291
|
+
for (let i = 0; i < 5; i++)
|
|
292
|
+
await Promise.resolve();
|
|
293
|
+
expect(s.onModalSubmit).toHaveBeenCalledTimes(1);
|
|
294
|
+
expect(deferUpdate).toHaveBeenCalledTimes(1);
|
|
295
|
+
expect(deferReply).not.toHaveBeenCalled();
|
|
296
|
+
});
|
|
297
|
+
it("openModal opens a modal for a slash-command interaction (commandPending registry)", async () => {
|
|
298
|
+
// The bug: openModal only consulted the component registry (`pending`), so a
|
|
299
|
+
// modal opened from a slash command — whose live interaction lives in
|
|
300
|
+
// `commandPending` — silently failed. openModal must try BOTH registries.
|
|
301
|
+
const client = fakeClient();
|
|
302
|
+
const a = new DiscordAdapter({ botToken: "t", appId: "app" }, { client: client, rest: { put: vi.fn() } });
|
|
303
|
+
// `commandPending` is constructed in start(); spin it up with the fake client.
|
|
304
|
+
await a.start(sink());
|
|
305
|
+
// Register a live slash-command interaction in the COMMAND registry only
|
|
306
|
+
// (not the component `pending` one), then open a modal against its trigger.
|
|
307
|
+
const showModal = vi.fn(async () => { });
|
|
308
|
+
const triggerId = a.commandPending.register({ id: "cmdTrigger", showModal });
|
|
309
|
+
// A minimal valid modal IR: a <Modal> root with no children renders fine
|
|
310
|
+
// (zero text inputs is allowed; renderDiscordModal only throws on
|
|
311
|
+
// unsupported elements or >5 inputs).
|
|
312
|
+
const modalIr = [
|
|
313
|
+
{ type: "modal", props: { callbackId: "x", title: "t", children: [] } },
|
|
314
|
+
];
|
|
315
|
+
const res = await a.openModal({ channelId: "c1" }, triggerId, modalIr);
|
|
316
|
+
expect(showModal).toHaveBeenCalledTimes(1);
|
|
317
|
+
expect(res).toEqual({ ok: true });
|
|
318
|
+
});
|
|
319
|
+
it("registerCommands never clears on empty, and publishes when already ready", async () => {
|
|
320
|
+
const client = fakeClient();
|
|
321
|
+
const put = vi.fn(async (_route, _opts) => undefined);
|
|
322
|
+
const a = new DiscordAdapter({ botToken: "t", appId: "app" }, { client: client, rest: { put } });
|
|
323
|
+
await a.start(sink());
|
|
324
|
+
// (i) An empty command list must NOT PUT — an empty PUT clears all of the
|
|
325
|
+
// bot's registered commands. Register empty, then fire `ready`.
|
|
326
|
+
a.registerCommands([]);
|
|
327
|
+
client.emit("ready", client);
|
|
328
|
+
await Promise.resolve();
|
|
329
|
+
await Promise.resolve();
|
|
330
|
+
expect(put).not.toHaveBeenCalled();
|
|
331
|
+
// (ii) Registering after `ready` must publish immediately (the once("ready")
|
|
332
|
+
// publish already ran, so a later registerCommands has to PUT itself).
|
|
333
|
+
a.registerCommands([{ name: "agent", description: "x" }]);
|
|
334
|
+
await Promise.resolve();
|
|
335
|
+
await Promise.resolve();
|
|
336
|
+
expect(put).toHaveBeenCalledTimes(1);
|
|
337
|
+
// The published body carries the registered command.
|
|
338
|
+
const body = put.mock.calls[0]?.[1];
|
|
339
|
+
expect(body.body).toEqual([
|
|
340
|
+
expect.objectContaining({ name: "agent", description: "x" }),
|
|
341
|
+
]);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming-polish layer: while an agent is mid-stream, the buffer is
|
|
3
|
+
* usually an *unfinished* markdown document — an open code fence, an
|
|
4
|
+
* unclosed `**`, etc. If we send the unbalanced text straight to Discord,
|
|
5
|
+
* the rest of the message renders broken (a stray ``` will turn the rest
|
|
6
|
+
* of the Discord message into a code block until the closer arrives).
|
|
7
|
+
*
|
|
8
|
+
* `autoCloseOpenMarkdown` returns a balanced copy of the buffer by
|
|
9
|
+
* appending the minimum set of closers needed so that the markdown is
|
|
10
|
+
* well-formed for display. When the agent later emits the real closer,
|
|
11
|
+
* the buffer becomes balanced on its own and this function adds nothing
|
|
12
|
+
* — i.e. no double-close in the committed Discord message.
|
|
13
|
+
*
|
|
14
|
+
* Operates in **markdown space** (i.e. on the raw text from the agent).
|
|
15
|
+
* Discord renders a Markdown subset natively, so a balanced document
|
|
16
|
+
* displays correctly without further translation.
|
|
17
|
+
*
|
|
18
|
+
* What it handles, in priority order:
|
|
19
|
+
*
|
|
20
|
+
* 1. Unclosed fenced code blocks ``` … (most severe — leaks code styling
|
|
21
|
+
* through the rest of the Discord message)
|
|
22
|
+
* 2. Unclosed inline code `…`
|
|
23
|
+
* 3. Unclosed bold `**…` / `__…`
|
|
24
|
+
* 4. Unclosed italic `*…` / `_…`
|
|
25
|
+
* 5. Unclosed strike `~~…`
|
|
26
|
+
*
|
|
27
|
+
* Heuristics:
|
|
28
|
+
*
|
|
29
|
+
* - A marker with no content after it (e.g. trailing `**`) is **not**
|
|
30
|
+
* closed — the agent might be just opening the bracket; closing would
|
|
31
|
+
* produce a transient `****` which looks worse than `**`.
|
|
32
|
+
* - Markers inside code/fence regions don't count.
|
|
33
|
+
* - When closing, we emit closers in reverse stack order (innermost
|
|
34
|
+
* first) so the rendered structure is well-nested.
|
|
35
|
+
*/
|
|
36
|
+
export declare function autoCloseOpenMarkdown(text: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* What markdown context is *still open* at the end of `text`? Used to
|
|
39
|
+
* decide what opening marker(s) the NEXT chunk needs prepended when we
|
|
40
|
+
* split a long buffer across multiple Discord messages mid-formatting
|
|
41
|
+
* — without this, a chunk boundary inside a ` ```python … ``` ` block
|
|
42
|
+
* would have the second Discord message start with raw code that Discord
|
|
43
|
+
* renders as plain text.
|
|
44
|
+
*/
|
|
45
|
+
export interface OpenMarkdownContext {
|
|
46
|
+
/** If inside an unclosed fence, the language tag (may be ""). null otherwise. */
|
|
47
|
+
fenceLang: string | null;
|
|
48
|
+
/** True if an unclosed single-backtick inline-code span is in flight. */
|
|
49
|
+
inlineCode: boolean;
|
|
50
|
+
/** Bold/italic/strike markers still open, in stack order (outermost first). */
|
|
51
|
+
brackets: string[];
|
|
52
|
+
}
|
|
53
|
+
export declare function detectOpenContext(text: string): OpenMarkdownContext;
|
|
54
|
+
/**
|
|
55
|
+
* Render the opener prefix for a continuation chunk so it can stand on
|
|
56
|
+
* its own as a self-renderable markdown fragment. E.g. for an open
|
|
57
|
+
* `` ```python `` fence, returns ` ```python\n `; for an open `**` bold,
|
|
58
|
+
* returns `**`.
|
|
59
|
+
*/
|
|
60
|
+
export declare function renderContextOpener(ctx: OpenMarkdownContext): string;
|
|
61
|
+
//# sourceMappingURL=auto-close-streaming.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auto-close-streaming.d.ts","sourceRoot":"","sources":["../src/auto-close-streaming.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAKH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CA+E1D;AAqFD;;;;;;;GAOG;AACH,MAAM,WAAW,mBAAmB;IAClC,iFAAiF;IACjF,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,yEAAyE;IACzE,UAAU,EAAE,OAAO,CAAC;IACpB,+EAA+E;IAC/E,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,mBAAmB,CAiCnE;AAiCD;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,mBAAmB,GAAG,MAAM,CAUpE"}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming-polish layer: while an agent is mid-stream, the buffer is
|
|
3
|
+
* usually an *unfinished* markdown document — an open code fence, an
|
|
4
|
+
* unclosed `**`, etc. If we send the unbalanced text straight to Discord,
|
|
5
|
+
* the rest of the message renders broken (a stray ``` will turn the rest
|
|
6
|
+
* of the Discord message into a code block until the closer arrives).
|
|
7
|
+
*
|
|
8
|
+
* `autoCloseOpenMarkdown` returns a balanced copy of the buffer by
|
|
9
|
+
* appending the minimum set of closers needed so that the markdown is
|
|
10
|
+
* well-formed for display. When the agent later emits the real closer,
|
|
11
|
+
* the buffer becomes balanced on its own and this function adds nothing
|
|
12
|
+
* — i.e. no double-close in the committed Discord message.
|
|
13
|
+
*
|
|
14
|
+
* Operates in **markdown space** (i.e. on the raw text from the agent).
|
|
15
|
+
* Discord renders a Markdown subset natively, so a balanced document
|
|
16
|
+
* displays correctly without further translation.
|
|
17
|
+
*
|
|
18
|
+
* What it handles, in priority order:
|
|
19
|
+
*
|
|
20
|
+
* 1. Unclosed fenced code blocks ``` … (most severe — leaks code styling
|
|
21
|
+
* through the rest of the Discord message)
|
|
22
|
+
* 2. Unclosed inline code `…`
|
|
23
|
+
* 3. Unclosed bold `**…` / `__…`
|
|
24
|
+
* 4. Unclosed italic `*…` / `_…`
|
|
25
|
+
* 5. Unclosed strike `~~…`
|
|
26
|
+
*
|
|
27
|
+
* Heuristics:
|
|
28
|
+
*
|
|
29
|
+
* - A marker with no content after it (e.g. trailing `**`) is **not**
|
|
30
|
+
* closed — the agent might be just opening the bracket; closing would
|
|
31
|
+
* produce a transient `****` which looks worse than `**`.
|
|
32
|
+
* - Markers inside code/fence regions don't count.
|
|
33
|
+
* - When closing, we emit closers in reverse stack order (innermost
|
|
34
|
+
* first) so the rendered structure is well-nested.
|
|
35
|
+
*/
|
|
36
|
+
const SENTINEL_FENCE = "";
|
|
37
|
+
const SENTINEL_INLINE = "";
|
|
38
|
+
export function autoCloseOpenMarkdown(text) {
|
|
39
|
+
if (!text)
|
|
40
|
+
return text;
|
|
41
|
+
// ── 1. Fenced code blocks ──────────────────────────────────────────
|
|
42
|
+
// Pair complete fences first, then determine if there's a dangling open.
|
|
43
|
+
const fences = [];
|
|
44
|
+
let work = text.replace(/```[\s\S]*?```/g, (m) => {
|
|
45
|
+
fences.push(m);
|
|
46
|
+
return `${SENTINEL_FENCE}${fences.length - 1}${SENTINEL_FENCE}`;
|
|
47
|
+
});
|
|
48
|
+
const openFenceIdx = work.indexOf("```");
|
|
49
|
+
let openFenceTail = "";
|
|
50
|
+
if (openFenceIdx >= 0) {
|
|
51
|
+
openFenceTail = work.slice(openFenceIdx);
|
|
52
|
+
work = work.slice(0, openFenceIdx);
|
|
53
|
+
}
|
|
54
|
+
// ── 2. Inline code regions ─────────────────────────────────────────
|
|
55
|
+
const inlines = [];
|
|
56
|
+
work = work.replace(/`[^`\n]+`/g, (m) => {
|
|
57
|
+
inlines.push(m);
|
|
58
|
+
return `${SENTINEL_INLINE}${inlines.length - 1}${SENTINEL_INLINE}`;
|
|
59
|
+
});
|
|
60
|
+
// Dangling open inline backtick? Only count if there's content after it.
|
|
61
|
+
const openBacktickIdx = work.indexOf("`");
|
|
62
|
+
let openBacktickTail = "";
|
|
63
|
+
if (openBacktickIdx >= 0) {
|
|
64
|
+
openBacktickTail = work.slice(openBacktickIdx);
|
|
65
|
+
work = work.slice(0, openBacktickIdx);
|
|
66
|
+
}
|
|
67
|
+
// ── 3. Bold / italic / strike via stack scan ──────────────────────
|
|
68
|
+
const stack = scanBracketStack(work);
|
|
69
|
+
// Closers in pop order (innermost first) so the rendered structure
|
|
70
|
+
// nests correctly: e.g. `**bold _italic` → close `_` then `**`.
|
|
71
|
+
const closers = stack.slice().toReversed().join("");
|
|
72
|
+
// ── 4. Reassemble ─────────────────────────────────────────────────
|
|
73
|
+
// Insert closers BEFORE any trailing whitespace on the work text so a
|
|
74
|
+
// buffer like "**bold " produces "**bold**" rather than "**bold **".
|
|
75
|
+
let output = work;
|
|
76
|
+
if (closers) {
|
|
77
|
+
const trail = output.match(/\s*$/)?.[0] ?? "";
|
|
78
|
+
output = output.slice(0, output.length - trail.length) + closers + trail;
|
|
79
|
+
}
|
|
80
|
+
// Inline backtick: if dangling open has content after it, close it.
|
|
81
|
+
if (openBacktickTail) {
|
|
82
|
+
output += openBacktickTail;
|
|
83
|
+
if (hasContentAfterMarker(openBacktickTail, "`")) {
|
|
84
|
+
output += "`";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Restore inline regions (paired backticks that were untouched).
|
|
88
|
+
output = output.replace(new RegExp(`${SENTINEL_INLINE}(\\d+)${SENTINEL_INLINE}`, "g"), (_, idx) => inlines[Number(idx)] ?? "");
|
|
89
|
+
// Fenced code: if there's real code content (text after the optional
|
|
90
|
+
// language line), close the fence. A buffer like "```py" or "```py\n"
|
|
91
|
+
// is "just opened" and should NOT yet be closed.
|
|
92
|
+
if (openFenceTail) {
|
|
93
|
+
output += openFenceTail;
|
|
94
|
+
if (hasFenceCodeContent(openFenceTail)) {
|
|
95
|
+
output += openFenceTail.endsWith("\n") ? "```" : "\n```";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Restore paired fences.
|
|
99
|
+
output = output.replace(new RegExp(`${SENTINEL_FENCE}(\\d+)${SENTINEL_FENCE}`, "g"), (_, idx) => fences[Number(idx)] ?? "");
|
|
100
|
+
return output;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Walks `text` left-to-right and returns the unbalanced bracket stack.
|
|
104
|
+
* Recognised markers (longer ones first): `**`, `__`, `~~`, then `*`, `_`.
|
|
105
|
+
*
|
|
106
|
+
* Standalone single-char markers don't match when they're part of the
|
|
107
|
+
* doubled variant — `**` is parsed as one bold marker, not two italics.
|
|
108
|
+
*/
|
|
109
|
+
function scanBracketStack(text) {
|
|
110
|
+
// Each entry remembers the marker AND the position where it was pushed
|
|
111
|
+
// during the scan. Tracking the actual push position is required for the
|
|
112
|
+
// trailing-strip step below: a single-char marker (`*`) is a substring of
|
|
113
|
+
// its doubled variant (`**`), so `text.lastIndexOf("*")` could match the
|
|
114
|
+
// `*` inside a later `**` and wrongly classify a marker with real content
|
|
115
|
+
// as empty (e.g. `"*ab**"` would lose its open italic).
|
|
116
|
+
const stack = [];
|
|
117
|
+
const tryToggle = (m, index) => {
|
|
118
|
+
if (stack[stack.length - 1]?.marker === m)
|
|
119
|
+
stack.pop();
|
|
120
|
+
else
|
|
121
|
+
stack.push({ marker: m, index });
|
|
122
|
+
};
|
|
123
|
+
let i = 0;
|
|
124
|
+
while (i < text.length) {
|
|
125
|
+
if (text.startsWith("**", i)) {
|
|
126
|
+
tryToggle("**", i);
|
|
127
|
+
i += 2;
|
|
128
|
+
}
|
|
129
|
+
else if (text.startsWith("__", i)) {
|
|
130
|
+
tryToggle("__", i);
|
|
131
|
+
i += 2;
|
|
132
|
+
}
|
|
133
|
+
else if (text.startsWith("~~", i)) {
|
|
134
|
+
tryToggle("~~", i);
|
|
135
|
+
i += 2;
|
|
136
|
+
}
|
|
137
|
+
else if (text[i] === "*") {
|
|
138
|
+
tryToggle("*", i);
|
|
139
|
+
i += 1;
|
|
140
|
+
}
|
|
141
|
+
else if (text[i] === "_") {
|
|
142
|
+
tryToggle("_", i);
|
|
143
|
+
i += 1;
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
i += 1;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// Strip any marker at the top of the stack that has NO content after it
|
|
150
|
+
// (i.e. it appears at the very end of the buffer with only whitespace
|
|
151
|
+
// following). Closing those would produce a transient `****`-style
|
|
152
|
+
// artefact that looks worse than the open `**`. We use the stored push
|
|
153
|
+
// index (not lastIndexOf) so substring markers can't be mis-located.
|
|
154
|
+
while (stack.length > 0) {
|
|
155
|
+
const top = stack[stack.length - 1];
|
|
156
|
+
const after = text.slice(top.index + top.marker.length);
|
|
157
|
+
if (/^\s*$/.test(after)) {
|
|
158
|
+
stack.pop();
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return stack.map((e) => e.marker);
|
|
165
|
+
}
|
|
166
|
+
function hasContentAfterMarker(tail, marker) {
|
|
167
|
+
if (!tail.startsWith(marker))
|
|
168
|
+
return false;
|
|
169
|
+
const after = tail.slice(marker.length);
|
|
170
|
+
return /\S/.test(after);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* True if a `\`\`\`<lang>?\n<content>` buffer has actual code content
|
|
174
|
+
* past the optional language line. We treat the first newline as the
|
|
175
|
+
* boundary between the (optional) language tag and code body.
|
|
176
|
+
*/
|
|
177
|
+
function hasFenceCodeContent(tail) {
|
|
178
|
+
if (!tail.startsWith("```"))
|
|
179
|
+
return false;
|
|
180
|
+
const after = tail.slice(3);
|
|
181
|
+
const nl = after.indexOf("\n");
|
|
182
|
+
if (nl < 0) {
|
|
183
|
+
// No newline yet — still on the language line. Nothing to close.
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
const code = after.slice(nl + 1);
|
|
187
|
+
return /\S/.test(code);
|
|
188
|
+
}
|
|
189
|
+
export function detectOpenContext(text) {
|
|
190
|
+
if (!text)
|
|
191
|
+
return { fenceLang: null, inlineCode: false, brackets: [] };
|
|
192
|
+
// ── Fence ────────────────────────────────────────────────────────
|
|
193
|
+
// Pair complete fences off the text; what remains tells us if a fence
|
|
194
|
+
// is still open and (if so) what its language tag is.
|
|
195
|
+
const paired = text.replace(/```[\s\S]*?```/g, "");
|
|
196
|
+
const openFenceIdx = paired.indexOf("```");
|
|
197
|
+
let fenceLang = null;
|
|
198
|
+
let remainder = paired;
|
|
199
|
+
if (openFenceIdx >= 0) {
|
|
200
|
+
// Everything from openFenceIdx onward is inside the open fence.
|
|
201
|
+
const tail = paired.slice(openFenceIdx);
|
|
202
|
+
const firstLine = tail.slice(3).split("\n", 1)[0] ?? "";
|
|
203
|
+
fenceLang = firstLine.trim();
|
|
204
|
+
remainder = paired.slice(0, openFenceIdx);
|
|
205
|
+
}
|
|
206
|
+
// ── Inline code (outside fences) ─────────────────────────────────
|
|
207
|
+
// Strip paired inline backticks; an odd remaining ` means open.
|
|
208
|
+
remainder = remainder.replace(/`[^`\n]+`/g, "");
|
|
209
|
+
const inlineCode = (remainder.match(/`/g) || []).length % 2 === 1;
|
|
210
|
+
// Cut at the last lone backtick — inside-inline content shouldn't
|
|
211
|
+
// contribute to bracket counting.
|
|
212
|
+
if (inlineCode) {
|
|
213
|
+
const lastBt = remainder.lastIndexOf("`");
|
|
214
|
+
if (lastBt >= 0)
|
|
215
|
+
remainder = remainder.slice(0, lastBt);
|
|
216
|
+
}
|
|
217
|
+
// ── Brackets (bold/italic/strike) ────────────────────────────────
|
|
218
|
+
const brackets = scanBracketStackForContext(remainder);
|
|
219
|
+
return { fenceLang, inlineCode, brackets };
|
|
220
|
+
}
|
|
221
|
+
/** Like scanBracketStack but exposed for context detection (no whitespace strip). */
|
|
222
|
+
function scanBracketStackForContext(text) {
|
|
223
|
+
const stack = [];
|
|
224
|
+
const tryToggle = (m) => {
|
|
225
|
+
if (stack[stack.length - 1] === m)
|
|
226
|
+
stack.pop();
|
|
227
|
+
else
|
|
228
|
+
stack.push(m);
|
|
229
|
+
};
|
|
230
|
+
let i = 0;
|
|
231
|
+
while (i < text.length) {
|
|
232
|
+
if (text.startsWith("**", i)) {
|
|
233
|
+
tryToggle("**");
|
|
234
|
+
i += 2;
|
|
235
|
+
}
|
|
236
|
+
else if (text.startsWith("__", i)) {
|
|
237
|
+
tryToggle("__");
|
|
238
|
+
i += 2;
|
|
239
|
+
}
|
|
240
|
+
else if (text.startsWith("~~", i)) {
|
|
241
|
+
tryToggle("~~");
|
|
242
|
+
i += 2;
|
|
243
|
+
}
|
|
244
|
+
else if (text[i] === "*") {
|
|
245
|
+
tryToggle("*");
|
|
246
|
+
i += 1;
|
|
247
|
+
}
|
|
248
|
+
else if (text[i] === "_") {
|
|
249
|
+
tryToggle("_");
|
|
250
|
+
i += 1;
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
i += 1;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return stack;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Render the opener prefix for a continuation chunk so it can stand on
|
|
260
|
+
* its own as a self-renderable markdown fragment. E.g. for an open
|
|
261
|
+
* `` ```python `` fence, returns ` ```python\n `; for an open `**` bold,
|
|
262
|
+
* returns `**`.
|
|
263
|
+
*/
|
|
264
|
+
export function renderContextOpener(ctx) {
|
|
265
|
+
// Fences are exclusive — if a fence is open, no other markers count
|
|
266
|
+
// (they're inside opaque code).
|
|
267
|
+
if (ctx.fenceLang !== null) {
|
|
268
|
+
return "```" + (ctx.fenceLang ?? "") + "\n";
|
|
269
|
+
}
|
|
270
|
+
let out = "";
|
|
271
|
+
for (const m of ctx.brackets)
|
|
272
|
+
out += m;
|
|
273
|
+
if (ctx.inlineCode)
|
|
274
|
+
out += "`";
|
|
275
|
+
return out;
|
|
276
|
+
}
|