@kolisachint/hoobot 0.0.5 → 0.0.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.
- package/.env.example +18 -8
- package/README.md +84 -19
- package/package.json +5 -3
- package/src/attachments.ts +72 -5
- package/src/chat.ts +66 -0
- package/src/config.ts +83 -28
- package/src/context.ts +39 -13
- package/src/core.ts +216 -0
- package/src/discord.ts +187 -0
- package/src/inbound.ts +31 -19
- package/src/index.ts +30 -245
- package/src/mrkdwn.ts +87 -0
- package/src/session.ts +82 -99
- package/src/slack.ts +440 -0
- package/src/summary.ts +11 -1
package/src/index.ts
CHANGED
|
@@ -1,272 +1,57 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* hoobot: talk to hoocode from Discord.
|
|
3
|
+
* hoobot: talk to hoocode from Discord and Slack.
|
|
4
4
|
*
|
|
5
5
|
* - Every channel and thread is a shared space with its own hoocode conversation.
|
|
6
|
-
* - Mention the bot (or reply to it) → it reads what was said
|
|
7
|
-
* looked (up to 30 messages) and answers right there.
|
|
8
|
-
* - Commands
|
|
6
|
+
* - Mention the bot (or, on Discord, reply to it) → it reads what was said
|
|
7
|
+
* since it last looked (up to 30 messages) and answers right there.
|
|
8
|
+
* - Commands: !stop !new !status !model [name] !verbose !help
|
|
9
9
|
* - Each channel in WORKSPACES works in its own folder with its own app-server.
|
|
10
|
+
* - Discord runs when DISCORD_TOKEN is set; Slack when SLACK_BOT_TOKEN and
|
|
11
|
+
* SLACK_APP_TOKEN are. Both share the app-servers and the links file.
|
|
10
12
|
*/
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
Events,
|
|
15
|
-
GatewayIntentBits,
|
|
16
|
-
Partials,
|
|
17
|
-
type Message,
|
|
18
|
-
type TextChannel,
|
|
19
|
-
type ThreadChannel,
|
|
20
|
-
} from "discord.js";
|
|
21
|
-
import { allWorkdirs, config, prepareWorkspace, workdirFor } from "./config.ts";
|
|
22
|
-
import { CodexClient } from "./codex-client.ts";
|
|
23
|
-
import { LinkStore } from "./links.ts";
|
|
24
|
-
import { ThreadSession } from "./session.ts";
|
|
25
|
-
import { authorName, buildPrompt, gatherContext, toContext, type ContextMessage, type SpaceLike } from "./context.ts";
|
|
26
|
-
import { clearSpace, formatAttachments, imageInputs, pruneInbox, saveAttachments, type SavedFile, type SkippedFile } from "./inbound.ts";
|
|
13
|
+
import { allWorkdirs, config, prepareWorkspace, surfaces } from "./config.ts";
|
|
14
|
+
import { closeAll } from "./core.ts";
|
|
15
|
+
import { INBOX_DIRS, pruneInbox } from "./inbound.ts";
|
|
27
16
|
|
|
28
17
|
for (const dir of allWorkdirs()) prepareWorkspace(dir);
|
|
29
|
-
// Files sent on
|
|
18
|
+
// Files sent on a chat are kept a week.
|
|
30
19
|
const prune = () => {
|
|
31
20
|
for (const dir of allWorkdirs()) {
|
|
32
21
|
try {
|
|
33
22
|
pruneInbox(dir);
|
|
34
23
|
} catch (err) {
|
|
35
|
-
console.error(`Can't clean ${dir}
|
|
24
|
+
console.error(`Can't clean ${dir}/{${Object.values(INBOX_DIRS).join(",")}}: ${err instanceof Error ? err.message : String(err)}`);
|
|
36
25
|
}
|
|
37
26
|
}
|
|
38
27
|
};
|
|
39
28
|
prune();
|
|
40
29
|
setInterval(prune, 60 * 60 * 1000).unref();
|
|
41
30
|
|
|
42
|
-
|
|
43
|
-
const
|
|
31
|
+
console.log(`Working folder: ${config.workdir}`);
|
|
32
|
+
for (const [channel, dir] of config.workspaces) console.log(` channel ${channel} → ${dir}`);
|
|
33
|
+
console.log(`Allowed users: ${[...config.allowedUserIds].join(", ")}`);
|
|
44
34
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
/** The app-server for `workdir`; reconnects after it drops. */
|
|
55
|
-
function appServer(workdir: string): Promise<CodexClient> {
|
|
56
|
-
let app = apps.get(workdir);
|
|
57
|
-
if (app) return app;
|
|
58
|
-
app = (async () => {
|
|
59
|
-
// A stdio server runs in the work folder; a socket server has its own.
|
|
60
|
-
const client = await CodexClient.connect(
|
|
61
|
-
endpoint(),
|
|
62
|
-
{ name: "hoobot", version: "0.1.0" },
|
|
63
|
-
{ cwd: workdir },
|
|
64
|
-
);
|
|
65
|
-
console.log(`Connected to app-server ${client.endpoint} in ${workdir}`);
|
|
66
|
-
client.on("close", (reason: string) => {
|
|
67
|
-
console.error(`app-server for ${workdir} closed: ${reason}`);
|
|
68
|
-
apps.delete(workdir);
|
|
69
|
-
// Its sessions hold the old client; drop them. Threads resume on the next message.
|
|
70
|
-
for (const s of [...sessions.values()]) if (s.workdir === workdir) s.close();
|
|
71
|
-
});
|
|
72
|
-
return client;
|
|
73
|
-
})().catch((err) => {
|
|
74
|
-
console.error(`Can't reach app-server for ${workdir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
75
|
-
apps.delete(workdir);
|
|
76
|
-
throw err;
|
|
77
|
-
});
|
|
78
|
-
apps.set(workdir, app);
|
|
79
|
-
return app;
|
|
80
|
-
}
|
|
81
|
-
const client = new Client({
|
|
82
|
-
intents: [
|
|
83
|
-
GatewayIntentBits.Guilds,
|
|
84
|
-
GatewayIntentBits.GuildMessages,
|
|
85
|
-
GatewayIntentBits.MessageContent,
|
|
86
|
-
],
|
|
87
|
-
partials: [Partials.Channel],
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
const HELP = [
|
|
91
|
-
"**hoo: hoocode in Discord**",
|
|
92
|
-
"",
|
|
93
|
-
"**Call me:** mention me, or reply to one of my messages. I answer right here.",
|
|
94
|
-
"Every channel and thread is a shared space: anyone can talk, and when I'm called",
|
|
95
|
-
"I read what was said since I last looked (up to 30 messages).",
|
|
96
|
-
"Open a thread for a side task; it has its own conversation in the same folder.",
|
|
97
|
-
"Attach files (or reply to a message with files): I save them in the work folder and read them.",
|
|
98
|
-
"",
|
|
99
|
-
"**Commands** (for this channel or thread)",
|
|
100
|
-
"- `!stop`: stop the current run",
|
|
101
|
-
"- `!new`: start a fresh conversation here (for everyone; deletes files sent here)",
|
|
102
|
-
"- `!status`: model, busy or not, folder",
|
|
103
|
-
"- `!model`: pick the model here from a list (from the next message; the conversation carries on)",
|
|
104
|
-
"- `!model <part of name>`: pick it directly, e.g. `!model kimi`",
|
|
105
|
-
"- `!verbose`: show every step here (again to turn off)",
|
|
106
|
-
"- `!help`: this message",
|
|
107
|
-
"",
|
|
108
|
-
"Calling me while I'm busy steers the current run.",
|
|
109
|
-
"While I work you see one status line; then the answer with a short",
|
|
110
|
-
"summary (PR, commits, files, steps, time, model).",
|
|
111
|
-
...(config.approvals === "ask" ? ["Commands that change files or run shell ask for approval with buttons."] : []),
|
|
112
|
-
].join("\n");
|
|
113
|
-
|
|
114
|
-
client.once(Events.ClientReady, (c) => {
|
|
115
|
-
console.log(`Logged in as ${c.user.tag}`);
|
|
116
|
-
console.log(`Working folder: ${config.workdir}`);
|
|
117
|
-
for (const [channel, dir] of config.workspaces) console.log(` channel ${channel} → ${dir}`);
|
|
118
|
-
console.log(`Allowed users: ${[...config.allowedUserIds].join(", ")}`);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
client.on(Events.MessageCreate, async (message) => {
|
|
122
|
-
try {
|
|
123
|
-
await onMessage(message);
|
|
124
|
-
} catch (err) {
|
|
125
|
-
console.error("message handler failed", err);
|
|
126
|
-
await message.reply("Something went wrong on my side. Check the bot's terminal.").catch(() => {});
|
|
127
|
-
}
|
|
35
|
+
const stops: (() => Promise<void>)[] = [];
|
|
36
|
+
// Loaded only when used, so a Slack-only bot never loads discord.js and the other way round.
|
|
37
|
+
const starters = {
|
|
38
|
+
discord: async () => (await import("./discord.ts")).startDiscord(),
|
|
39
|
+
slack: async () => (await import("./slack.ts")).startSlack(),
|
|
40
|
+
};
|
|
41
|
+
const results = await Promise.allSettled(surfaces().map(async (s) => stops.push(await starters[s]())));
|
|
42
|
+
results.forEach((r, i) => {
|
|
43
|
+
if (r.status === "rejected") console.error(`Can't connect to ${surfaces()[i]}: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`);
|
|
128
44
|
});
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (config.guildId && message.guildId !== config.guildId) return;
|
|
133
|
-
|
|
134
|
-
const channel = message.channel;
|
|
135
|
-
if (channel.type !== ChannelType.GuildText && !channel.isThread()) return;
|
|
136
|
-
const parentId = channel.isThread() ? channel.parentId : channel.id;
|
|
137
|
-
if (config.channelIds.size && (!parentId || !(config.channelIds.has(parentId) || config.workspaces.has(parentId)))) return;
|
|
138
|
-
|
|
139
|
-
// Called by a mention (the bot user, or its auto-created role that Discord's
|
|
140
|
-
// autocomplete often picks), or by replying to one of the bot's messages.
|
|
141
|
-
const botRoleId = message.guild?.members.me?.roles.botRole?.id;
|
|
142
|
-
const called =
|
|
143
|
-
message.mentions.users.has(client.user.id) ||
|
|
144
|
-
(!!botRoleId && message.mentions.roles.has(botRoleId)) ||
|
|
145
|
-
message.mentions.repliedUser?.id === client.user.id;
|
|
146
|
-
if (!called) return;
|
|
147
|
-
console.log(`[msg] ${message.author.username} (${message.author.id}) in ${channel.id}: ${message.content.slice(0, 80)}`);
|
|
148
|
-
|
|
149
|
-
// Only allowed users can call the bot (everyone's messages still count as context).
|
|
150
|
-
if (!config.allowedUserIds.has(message.author.id)) {
|
|
151
|
-
await message.reply("Sorry, you're not on this bot's allow list.");
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const text = message.content
|
|
156
|
-
.replace(new RegExp(`<@!?${client.user.id}>`, "g"), "")
|
|
157
|
-
.replace(botRoleId ? new RegExp(`<@&${botRoleId}>`, "g") : /$^/, "")
|
|
158
|
-
.trim();
|
|
159
|
-
if (!text && message.attachments.size === 0) {
|
|
160
|
-
await message.reply(HELP);
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Commands.
|
|
165
|
-
const [cmd, ...rest] = text.split(/\s+/);
|
|
166
|
-
const arg = rest.join(" ").trim();
|
|
167
|
-
if (cmd?.toLowerCase() === "!help") {
|
|
168
|
-
await message.reply(HELP);
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
const space = channel as TextChannel | ThreadChannel;
|
|
172
|
-
let session: ThreadSession;
|
|
173
|
-
try {
|
|
174
|
-
session = await getSession(space, workdirFor(parentId));
|
|
175
|
-
} catch (err) {
|
|
176
|
-
await message.reply(`**Can't reach hoocode:** ${err instanceof Error ? err.message : String(err)}`);
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
switch (cmd?.toLowerCase()) {
|
|
180
|
-
case "!stop":
|
|
181
|
-
return session.abort();
|
|
182
|
-
case "!new":
|
|
183
|
-
clearSpace(session.workdir, space.id);
|
|
184
|
-
return session.newSession();
|
|
185
|
-
case "!status":
|
|
186
|
-
return session.status();
|
|
187
|
-
case "!verbose":
|
|
188
|
-
return session.toggleVerbose();
|
|
189
|
-
case "!model":
|
|
190
|
-
return session.chooseModel(arg);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// One call at a time per space, so read positions advance in order.
|
|
194
|
-
await inOrder(space.id, async () => {
|
|
195
|
-
// What was said here since the conversation last read, plus the message replied to.
|
|
196
|
-
const { linked, seen } = await session.readState();
|
|
197
|
-
const [context, reply] = await Promise.all([
|
|
198
|
-
gatherContext({ space: space as unknown as SpaceLike, before: message.id, botId: client.user!.id, linked, seen }),
|
|
199
|
-
fetchReplyTo(message, client.user!.id),
|
|
200
|
-
]);
|
|
201
|
-
// Files on the calling message and on the message it replies to are
|
|
202
|
-
// saved in the work folder; small text ones are pasted in too.
|
|
203
|
-
const saved: SavedFile[] = [];
|
|
204
|
-
const skipped: SkippedFile[] = [];
|
|
205
|
-
for (const m of [reply?.message, message]) {
|
|
206
|
-
if (!m?.attachments.size) continue;
|
|
207
|
-
const r = await saveAttachments({
|
|
208
|
-
workdir: session.workdir,
|
|
209
|
-
spaceId: space.id,
|
|
210
|
-
messageId: m.id,
|
|
211
|
-
author: authorName(m),
|
|
212
|
-
attachments: m.attachments.values(),
|
|
213
|
-
});
|
|
214
|
-
saved.push(...r.saved);
|
|
215
|
-
skipped.push(...r.skipped);
|
|
216
|
-
}
|
|
217
|
-
const prompt = buildPrompt({
|
|
218
|
-
context,
|
|
219
|
-
replyTo: reply?.context,
|
|
220
|
-
attachments: formatAttachments(saved, skipped),
|
|
221
|
-
author: authorName(message),
|
|
222
|
-
text: text || "(see the attached files)",
|
|
223
|
-
});
|
|
224
|
-
if (await session.prompt(prompt, imageInputs(saved), message)) session.markSeen(message.id);
|
|
225
|
-
});
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/** Per-space promise chains. */
|
|
229
|
-
const lanes = new Map<string, Promise<unknown>>();
|
|
230
|
-
function inOrder(key: string, fn: () => Promise<unknown>): Promise<unknown> {
|
|
231
|
-
const next = (lanes.get(key) ?? Promise.resolve()).then(fn, fn);
|
|
232
|
-
const tail = next.catch(() => {});
|
|
233
|
-
lanes.set(key, tail);
|
|
234
|
-
tail.then(() => lanes.get(key) === tail && lanes.delete(key));
|
|
235
|
-
return next;
|
|
45
|
+
if (stops.length === 0) {
|
|
46
|
+
console.error("No chat connected. Check the tokens in .env.");
|
|
47
|
+
process.exit(1);
|
|
236
48
|
}
|
|
237
49
|
|
|
238
|
-
|
|
239
|
-
* The message `message` replies to, unless it's the bot's own (already in the
|
|
240
|
-
* conversation, and its files are already in the work folder).
|
|
241
|
-
*/
|
|
242
|
-
async function fetchReplyTo(message: Message, botId: string): Promise<{ message: Message; context: ContextMessage | null } | null> {
|
|
243
|
-
if (!message.reference?.messageId || message.mentions.repliedUser?.id === botId) return null;
|
|
244
|
-
try {
|
|
245
|
-
const ref = await message.fetchReference();
|
|
246
|
-
if (ref.author.id === botId) return null;
|
|
247
|
-
return { message: ref, context: toContext(ref, botId) };
|
|
248
|
-
} catch {
|
|
249
|
-
return null;
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
async function getSession(space: TextChannel | ThreadChannel, workdir: string): Promise<ThreadSession> {
|
|
254
|
-
const client = await appServer(workdir);
|
|
255
|
-
let s = sessions.get(space.id);
|
|
256
|
-
if (!s) {
|
|
257
|
-
s = new ThreadSession(space, client, links, (id) => sessions.delete(id), workdir);
|
|
258
|
-
sessions.set(space.id, s);
|
|
259
|
-
}
|
|
260
|
-
return s;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
function shutdown() {
|
|
50
|
+
async function shutdown() {
|
|
264
51
|
console.log("Shutting down…");
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
52
|
+
await closeAll();
|
|
53
|
+
await Promise.allSettled(stops.map((stop) => stop()));
|
|
54
|
+
process.exit(0);
|
|
268
55
|
}
|
|
269
56
|
process.on("SIGINT", shutdown);
|
|
270
57
|
process.on("SIGTERM", shutdown);
|
|
271
|
-
|
|
272
|
-
client.login(config.token);
|
package/src/mrkdwn.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discord-flavoured Markdown (what the session writes and the model answers
|
|
3
|
+
* in) → Slack mrkdwn.
|
|
4
|
+
*
|
|
5
|
+
* - `**bold**` → `*bold*`, `*italic*` / `_italic_` → `_italic_`, `~~x~~` → `~x~`
|
|
6
|
+
* - `# Heading` → `*Heading*`, `-# small` → plain line
|
|
7
|
+
* - `[text](<url>)` / `[text](url)` → `<url|text>`; bare URLs stay as they are
|
|
8
|
+
* - fenced code keeps its fence, minus the language (Slack shows it as text)
|
|
9
|
+
* - `&`, `<`, `>` are escaped everywhere, as Slack requires
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const escape = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
13
|
+
|
|
14
|
+
/** Bold/italic markers become these while converting, so `*x*` from `**x**` isn't read as italic. */
|
|
15
|
+
const B = "\u0001";
|
|
16
|
+
|
|
17
|
+
export function toMrkdwn(md: string): string {
|
|
18
|
+
const out: string[] = [];
|
|
19
|
+
let inFence = false;
|
|
20
|
+
for (const line of md.split("\n")) {
|
|
21
|
+
const fence = line.trim().match(/^```/);
|
|
22
|
+
if (fence) {
|
|
23
|
+
// Slack has no language tags: ```ts → ```
|
|
24
|
+
out.push(inFence ? "```" : "```");
|
|
25
|
+
inFence = !inFence;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
out.push(inFence ? escape(line) : convertLine(line));
|
|
29
|
+
}
|
|
30
|
+
return out.join("\n");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function convertLine(line: string): string {
|
|
34
|
+
const heading = line.match(/^#{1,6}\s+(.*)$/);
|
|
35
|
+
if (heading) return wrapBold(inline(heading[1]!));
|
|
36
|
+
const small = line.match(/^-#\s+(.*)$/);
|
|
37
|
+
if (small) return inline(small[1]!);
|
|
38
|
+
return inline(line);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A whole line in bold, unless it's bold already. */
|
|
42
|
+
function wrapBold(text: string): string {
|
|
43
|
+
return /^\*[^*]+\*$/.test(text) ? text : `*${text}*`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Inline formatting outside code spans; code spans are only escaped. */
|
|
47
|
+
function inline(text: string): string {
|
|
48
|
+
return text
|
|
49
|
+
.split(/(`[^`]*`)/)
|
|
50
|
+
.map((part, i) => (i % 2 === 1 ? escape(part) : prose(part)))
|
|
51
|
+
.join("");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function prose(text: string): string {
|
|
55
|
+
const links: string[] = [];
|
|
56
|
+
// Links first (their text and URL must not be touched by escaping or emphasis).
|
|
57
|
+
let s = text.replace(/\[([^\]]+)\]\(<?([^)\s>]+)>?\)/g, (_, label: string, url: string) => {
|
|
58
|
+
links.push(`<${url.replace(/[<>|]/g, encodeURIComponent)}|${escape(label).replace(/\|/g, "¦")}>`);
|
|
59
|
+
return `\u0002${links.length - 1}\u0002`;
|
|
60
|
+
});
|
|
61
|
+
s = escape(s);
|
|
62
|
+
s = s
|
|
63
|
+
.replace(/\*\*(?=\S)(.+?)(?<=\S)\*\*/g, `${B}$1${B}`)
|
|
64
|
+
.replace(/__(?=\S)(.+?)(?<=\S)__/g, `${B}$1${B}`)
|
|
65
|
+
.replace(/(^|[^\w*])\*(?=\S)([^*]+?)(?<=\S)\*(?![\w*])/g, "$1_$2_")
|
|
66
|
+
.replace(/~~(?=\S)(.+?)(?<=\S)~~/g, "~$1~")
|
|
67
|
+
.replaceAll(B, "*");
|
|
68
|
+
return s.replace(/\u0002(\d+)\u0002/g, (_, i: string) => links[Number(i)]!);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Slack message text (as typed by people) → plain text for the model:
|
|
73
|
+
* `<@U1>` → `@name`, `<#C1|dev>` → `#dev`, `<url|text>` → `text (url)`,
|
|
74
|
+
* entities unescaped. `name` resolves user ids (unknown ones stay as ids).
|
|
75
|
+
*/
|
|
76
|
+
export function fromMrkdwn(text: string, name: (userId: string) => string | undefined = () => undefined): string {
|
|
77
|
+
return text
|
|
78
|
+
.replace(/<@([UW][A-Z0-9]+)(?:\|([^>]+))?>/g, (_, id: string, label?: string) => `@${label ?? name(id) ?? id}`)
|
|
79
|
+
.replace(/<#([CG][A-Z0-9]+)(?:\|([^>]*))?>/g, (_, id: string, label?: string) => `#${label || id}`)
|
|
80
|
+
.replace(/<!(here|channel|everyone)(?:\|[^>]*)?>/g, "@$1")
|
|
81
|
+
.replace(/<!subteam\^[A-Z0-9]+(?:\|([^>]+))?>/g, (_, label?: string) => label ?? "@group")
|
|
82
|
+
.replace(/<(https?:[^>|]+)\|([^>]+)>/g, (_, url: string, label: string) => (label === url ? url : `${label} (${url})`))
|
|
83
|
+
.replace(/<(https?:[^>]+|mailto:[^>]+)>/g, "$1")
|
|
84
|
+
.replace(/</g, "<")
|
|
85
|
+
.replace(/>/g, ">")
|
|
86
|
+
.replace(/&/g, "&");
|
|
87
|
+
}
|