@thewaifu/dsh-bot 1.1.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.
@@ -0,0 +1,65 @@
1
+ // "@Name" is read by one rule everywhere: the group host picks who answers with it,
2
+ // and the client draws the same mentions in the bubbles, so what the user sees called
3
+ // is what gets called. Pure, so both sides import it.
4
+
5
+ export const ALL_WORDS = ['all', 'everyone', '所有人', '全体']
6
+ // A letter, digit, "_" or "-" after a name means a longer word ("@Mi" in "@MiniMax"),
7
+ // and "/", "\" or a "." that goes on into a word mean a path: DSH writes a file
8
+ // reference as "@docs/guide.md", which must not call a Bot named Docs. A letter,
9
+ // digit, "_", "." or "/" before "@" means an address ("a@b", "medium.com/@Writer").
10
+ const NAME_GOES_ON = /^(?:[a-z0-9_\-/\\]|\.[a-z0-9_-])/i
11
+ const ADDRESS_BEFORE = /[a-z0-9_./]/i
12
+ // Code and Markdown links, with or without a title, are literal text.
13
+ const LITERAL = /```[\s\S]*?(?:```|$)|`[^`\n]*`|\[[^\]\n]*\]\([^)\s]*(?:\s+(?:"[^"\n]*"|'[^'\n]*'))?\)/g
14
+
15
+ const fitsAt = (text, at, word) => text.slice(at, at + word.length).toLowerCase() === word.toLowerCase()
16
+ && !NAME_GOES_ON.test(text.slice(at + word.length))
17
+ const overlaps = (spans, start, end) => spans.some(span => start < span.end && span.start < end)
18
+
19
+ // What a Bot may be named: something "@" can call without a second reading.
20
+ export function nameProblem(name) {
21
+ const text = String(name ?? '').trim()
22
+ if (text === '') return 'A Bot needs a name'
23
+ if (/[@`\r\n]/.test(text)) return 'A Bot name cannot contain @, ` or a line break'
24
+ if (ALL_WORDS.includes(text.toLowerCase())) return `"${text}" calls everyone in a group, so it cannot be a Bot name`
25
+ return undefined
26
+ }
27
+
28
+ // DSH writes each referenced conversation into the message once as "@label"; that one
29
+ // occurrence per listed label is a reference, not a call. Longest labels go first so
30
+ // "@K notes" is never read as the label "K".
31
+ export function referenceSpans(text, labels = []) {
32
+ const spans = []
33
+ for (const label of [...labels].sort((a, b) => b.length - a.length)) {
34
+ const mention = `@${label}`
35
+ for (let at = text.indexOf(mention); at !== -1; at = text.indexOf(mention, at + 1)) {
36
+ const end = at + mention.length
37
+ if ((at > 0 && ADDRESS_BEFORE.test(text[at - 1])) || NAME_GOES_ON.test(text.slice(end)) || overlaps(spans, at, end)) continue
38
+ spans.push({ start: at, end, label })
39
+ break
40
+ }
41
+ }
42
+ return spans.sort((a, b) => a.start - b.start)
43
+ }
44
+
45
+ // Every "@Name" and "@all" outside code, links and references, in order. Longest names
46
+ // match first, so "@新 Bot 2" is never "@新 Bot"; a name may hold spaces.
47
+ export function mentionSpans(text, bots, { references = [] } = {}) {
48
+ const value = String(text ?? '')
49
+ const skip = [
50
+ ...[...value.matchAll(LITERAL)].map(match => ({ start: match.index, end: match.index + match[0].length })),
51
+ ...referenceSpans(value, references),
52
+ ]
53
+ const byLength = bots.filter(bot => bot?.name).sort((a, b) => b.name.length - a.name.length)
54
+ const spans = []
55
+ for (let at = value.indexOf('@'); at !== -1; at = value.indexOf('@', at + 1)) {
56
+ if ((at > 0 && ADDRESS_BEFORE.test(value[at - 1])) || overlaps(skip, at, at + 1)) continue
57
+ const bot = byLength.find(candidate => fitsAt(value, at + 1, candidate.name))
58
+ const all = bot ? undefined : ALL_WORDS.find(word => fitsAt(value, at + 1, word))
59
+ if (!bot && !all) continue
60
+ const end = at + 1 + (bot ? bot.name.length : all.length)
61
+ spans.push(bot ? { start: at, end, bot } : { start: at, end, all: true })
62
+ at = end - 1
63
+ }
64
+ return spans
65
+ }
@@ -0,0 +1,26 @@
1
+ import { createServer } from 'node:net'
2
+
3
+ export const DEFAULT_WEB_PORT = 3082
4
+
5
+ function canListen(port, host) {
6
+ return new Promise((resolve, reject) => {
7
+ const server = createServer()
8
+ server.unref()
9
+ server.once('error', (error) => {
10
+ if (error.code === 'EADDRINUSE' || error.code === 'EACCES') resolve(false)
11
+ else reject(error)
12
+ })
13
+ server.once('listening', () => {
14
+ server.close(error => error ? reject(error) : resolve(true))
15
+ })
16
+ server.listen({ port, host, exclusive: true })
17
+ })
18
+ }
19
+
20
+ export async function findAvailablePort(start = DEFAULT_WEB_PORT, host = '127.0.0.1') {
21
+ if (!Number.isInteger(start) || start < 1 || start > 65535) throw new RangeError('start must be an integer between 1 and 65535')
22
+ for (let port = start; port <= 65535; port += 1) {
23
+ if (await canListen(port, host)) return port
24
+ }
25
+ throw new Error(`no available TCP port at or above ${start}`)
26
+ }