@owlmeans/bot 0.1.18-rc.2 → 0.1.18
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 +23 -21
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/bot/SKILL.md +68 -46
- package/build/consts.d.ts +6 -31
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +10 -36
- package/build/consts.js.map +1 -1
- package/build/consts.local.d.ts +3 -1
- package/build/consts.local.d.ts.map +1 -1
- package/build/consts.local.js +3 -1
- package/build/consts.local.js.map +1 -1
- package/build/helpers/index.d.ts +2 -2
- package/build/helpers/index.js +1 -1
- package/build/helpers/text/types.d.ts +35 -0
- package/build/helpers/text/types.d.ts.map +1 -0
- package/build/helpers/{rule → text}/types.js.map +1 -1
- package/build/helpers/text.d.ts +4 -0
- package/build/helpers/text.d.ts.map +1 -0
- package/build/helpers/text.js +77 -0
- package/build/helpers/text.js.map +1 -0
- package/build/protocols/types.d.ts +2 -17
- package/build/protocols/types.d.ts.map +1 -1
- package/build/protocols.d.ts +1 -1
- package/build/protocols.d.ts.map +1 -1
- package/build/protocols.js +2 -7
- package/build/protocols.js.map +1 -1
- package/build/schemas.d.ts +2 -4
- package/build/schemas.d.ts.map +1 -1
- package/build/schemas.js +3 -31
- package/build/schemas.js.map +1 -1
- package/build/types.d.ts +7 -41
- package/build/types.d.ts.map +1 -1
- package/build/utils/aliases/types.d.ts +0 -5
- package/build/utils/aliases/types.d.ts.map +1 -1
- package/build/utils/aliases.d.ts.map +1 -1
- package/build/utils/aliases.js +0 -5
- package/build/utils/aliases.js.map +1 -1
- package/package.json +7 -7
- package/src/consts.local.ts +4 -1
- package/src/consts.ts +6 -37
- package/src/helpers/index.ts +2 -2
- package/src/helpers/text/types.ts +35 -0
- package/src/helpers/text.ts +91 -0
- package/src/protocols/types.ts +2 -12
- package/src/protocols.ts +4 -21
- package/src/schemas.ts +5 -39
- package/src/types.ts +7 -45
- package/src/utils/aliases/types.ts +0 -1
- package/src/utils/aliases.ts +0 -5
- package/tests/protocols.spec.ts +3 -7
- package/tests/schemas.spec.ts +2 -30
- package/tests/text.spec.ts +101 -0
- package/tests/update.spec.ts +4 -10
- package/build/helpers/rule/types.d.ts +0 -26
- package/build/helpers/rule/types.d.ts.map +0 -1
- package/build/helpers/rule.d.ts +0 -4
- package/build/helpers/rule.d.ts.map +0 -1
- package/build/helpers/rule.js +0 -71
- package/build/helpers/rule.js.map +0 -1
- package/src/helpers/rule/types.ts +0 -26
- package/src/helpers/rule.ts +0 -89
- package/tests/rule.spec.ts +0 -129
- /package/build/helpers/{rule → text}/types.js +0 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { BotTextMatch } from '../consts.js'
|
|
2
|
+
import { BOT_PHRASE_SEPARATOR_PATTERN, BOT_SPACE_PATTERN, BOT_TOKEN_PATTERN } from '../consts.local.js'
|
|
3
|
+
import type { BotTextHelper } from './text/types.js'
|
|
4
|
+
|
|
5
|
+
export const createBotTextHelper = (): BotTextHelper => {
|
|
6
|
+
/**
|
|
7
|
+
* NFKC, then a case fold: upper-casing first maps what lower-casing alone keeps apart (`ß` → `SS`
|
|
8
|
+
* → `ss`, final `ς` → `Σ` → `σ`), and NFKC again recomposes what the case mapping decomposed.
|
|
9
|
+
*/
|
|
10
|
+
const fold = (text: string): string =>
|
|
11
|
+
typeof text === 'string' ? text.normalize('NFKC').toUpperCase().toLowerCase().normalize('NFKC') : ''
|
|
12
|
+
|
|
13
|
+
const tokensOf = (text: string): string[] => fold(text).match(BOT_TOKEN_PATTERN) ?? []
|
|
14
|
+
|
|
15
|
+
/** The folded text with whitespace runs collapsed — the `contains` form and the dedupe key. */
|
|
16
|
+
const spacedOf = (text: string): string => fold(text).replace(BOT_SPACE_PATTERN, ' ').trim()
|
|
17
|
+
|
|
18
|
+
const hasPhrase = (tokens: readonly string[], phrase: readonly string[]): boolean => {
|
|
19
|
+
for (let at = 0; at + phrase.length <= tokens.length; at++) {
|
|
20
|
+
if (phrase.every((token, offset) => tokens[at + offset] === token)) {
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const matched = (text: string, phrases: readonly string[], mode: BotTextMatch = BotTextMatch.Word): string[] => {
|
|
28
|
+
if (typeof text !== 'string' || text.trim() === '' || !Array.isArray(phrases)) {
|
|
29
|
+
return []
|
|
30
|
+
}
|
|
31
|
+
// Folded once per text, never per phrase.
|
|
32
|
+
let tokens: string[] | undefined
|
|
33
|
+
let spaced: string | undefined
|
|
34
|
+
|
|
35
|
+
const found = (phrase: string): boolean => {
|
|
36
|
+
if (mode === BotTextMatch.Contains) {
|
|
37
|
+
const needle = spacedOf(phrase)
|
|
38
|
+
spaced ??= spacedOf(text)
|
|
39
|
+
return needle !== '' && spaced.includes(needle)
|
|
40
|
+
}
|
|
41
|
+
const words = tokensOf(phrase)
|
|
42
|
+
tokens ??= tokensOf(text)
|
|
43
|
+
return words.length > 0 && hasPhrase(tokens, words)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const seen = new Set<string>()
|
|
47
|
+
const result: string[] = []
|
|
48
|
+
for (const phrase of phrases) {
|
|
49
|
+
if (typeof phrase !== 'string') {
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
const key = spacedOf(phrase)
|
|
53
|
+
if (key === '' || seen.has(key)) {
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
seen.add(key)
|
|
57
|
+
if (found(phrase)) {
|
|
58
|
+
result.push(phrase)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return result
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const phrasesOf = (input: string | readonly string[]): string[] => {
|
|
66
|
+
const items: readonly unknown[] = typeof input === 'string' ? [input] : Array.isArray(input) ? input : []
|
|
67
|
+
const seen = new Set<string>()
|
|
68
|
+
const result: string[] = []
|
|
69
|
+
|
|
70
|
+
for (const item of items) {
|
|
71
|
+
if (typeof item !== 'string') {
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
for (const part of item.split(BOT_PHRASE_SEPARATOR_PATTERN)) {
|
|
75
|
+
const phrase = part.replace(BOT_SPACE_PATTERN, ' ').trim()
|
|
76
|
+
const key = spacedOf(phrase)
|
|
77
|
+
if (key === '' || seen.has(key)) {
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
seen.add(key)
|
|
81
|
+
result.push(phrase)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return result
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { fold, tokensOf, matched, phrasesOf }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const botTextHelper = createBotTextHelper()
|
package/src/protocols/types.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { EntrypointProtocol, OpenRequest, OpenValue } from '@owlmeans/entry
|
|
|
2
2
|
import type { RouteParent } from '@owlmeans/route'
|
|
3
3
|
import type { BotPlatform } from '../consts.js'
|
|
4
4
|
import type {
|
|
5
|
-
BotActivity, BotConnection, BotConnectionInput,
|
|
5
|
+
BotActivity, BotConnection, BotConnectionInput, BotSimulateInput, BotSimulateResult,
|
|
6
6
|
} from '../types.js'
|
|
7
7
|
|
|
8
8
|
/** Where a bot tree mounts. Every leaf alias derives from `alias` (`<alias>:connection:list` …). */
|
|
@@ -32,7 +32,7 @@ export interface BotHookProtocolOptions {
|
|
|
32
32
|
base: BotBaseOptions
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/** `:id` of a connection
|
|
35
|
+
/** `:id` of a connection. */
|
|
36
36
|
export interface BotIdParams {
|
|
37
37
|
id: string
|
|
38
38
|
}
|
|
@@ -41,11 +41,6 @@ export interface BotRemoveResult {
|
|
|
41
41
|
id: string
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
export interface BotRuleListQuery {
|
|
45
|
-
/** Rules of this connection plus the scope-wide ones; every rule of the scope when absent. */
|
|
46
|
-
connection?: string
|
|
47
|
-
}
|
|
48
|
-
|
|
49
44
|
export interface BotActivityQuery {
|
|
50
45
|
connection?: string
|
|
51
46
|
/** 1 … `BOT_ACTIVITY_LIMIT_MAX`; `BOT_ACTIVITY_LIMIT_DEFAULT` when absent. */
|
|
@@ -88,11 +83,6 @@ export interface BotProtocols {
|
|
|
88
83
|
/** Mock transport only — a live connection answers `BotTransportMismatch` (409). */
|
|
89
84
|
simulate: EntrypointProtocol<{ params: BotIdParams, body: BotSimulateInput }, BotSimulateResult>
|
|
90
85
|
}
|
|
91
|
-
rule: {
|
|
92
|
-
list: EntrypointProtocol<{ query: BotRuleListQuery }, BotRule[]>
|
|
93
|
-
save: EntrypointProtocol<{ body: BotRuleInput }, BotRule>
|
|
94
|
-
remove: EntrypointProtocol<{ params: BotIdParams }, BotRemoveResult>
|
|
95
|
-
}
|
|
96
86
|
activity: {
|
|
97
87
|
/** Newest first. */
|
|
98
88
|
list: EntrypointProtocol<{ query: BotActivityQuery }, BotActivity[]>
|
package/src/protocols.ts
CHANGED
|
@@ -2,16 +2,14 @@ import { contract, openProtocol, protocol, typed } from '@owlmeans/entrypoint'
|
|
|
2
2
|
import { backend, route, RouteMethod, type RouteOptions } from '@owlmeans/route'
|
|
3
3
|
import {
|
|
4
4
|
BotActivityQuerySchema, BotChallengeQuerySchema, BotConnectionInputSchema, BotHookParamsSchema, BotIdParamsSchema,
|
|
5
|
-
|
|
5
|
+
BotSimulateInputSchema,
|
|
6
6
|
} from './schemas.js'
|
|
7
7
|
import { botAliasUtils } from './utils/aliases.js'
|
|
8
8
|
import type {
|
|
9
9
|
BotActivityQuery, BotBaseOptions, BotChallengeQuery, BotHookParams, BotHookPayload, BotHookProtocolOptions,
|
|
10
|
-
BotHookProtocols, BotHookReply, BotIdParams, BotProtocolOptions, BotProtocols, BotRemoveResult,
|
|
10
|
+
BotHookProtocols, BotHookReply, BotIdParams, BotProtocolOptions, BotProtocols, BotRemoveResult,
|
|
11
11
|
} from './protocols/types.js'
|
|
12
|
-
import type {
|
|
13
|
-
BotActivity, BotConnection, BotConnectionInput, BotRule, BotRuleInput, BotSimulateInput, BotSimulateResult,
|
|
14
|
-
} from './types.js'
|
|
12
|
+
import type { BotActivity, BotConnection, BotConnectionInput, BotSimulateInput, BotSimulateResult } from './types.js'
|
|
15
13
|
|
|
16
14
|
const mountOf = (base: BotBaseOptions): Partial<RouteOptions> => ({
|
|
17
15
|
...(base.parent != null ? { parent: base.parent } : {}),
|
|
@@ -19,7 +17,7 @@ const mountOf = (base: BotBaseOptions): Partial<RouteOptions> => ({
|
|
|
19
17
|
})
|
|
20
18
|
|
|
21
19
|
/**
|
|
22
|
-
* Declare one managing surface of the bot — connections
|
|
20
|
+
* Declare one managing surface of the bot — connections and activity — as an immutable tree a
|
|
23
21
|
* server binds and a client calls.
|
|
24
22
|
*
|
|
25
23
|
* Every leaf hangs under ONE base that carries the guards and the gate, so they cannot drift apart;
|
|
@@ -64,21 +62,6 @@ export const makeBotProtocols = (opts: BotProtocolOptions): BotProtocols => {
|
|
|
64
62
|
),
|
|
65
63
|
}),
|
|
66
64
|
|
|
67
|
-
rule: Object.freeze({
|
|
68
|
-
list: protocol(
|
|
69
|
-
leaf(aliases.rule.list, '/rules', RouteMethod.GET),
|
|
70
|
-
contract.request({ query: typed<BotRuleListQuery>(BotRuleListQuerySchema) }, typed<BotRule[]>())
|
|
71
|
-
),
|
|
72
|
-
save: protocol(
|
|
73
|
-
leaf(aliases.rule.save, '/rules', RouteMethod.POST),
|
|
74
|
-
contract.request({ body: typed<BotRuleInput>(BotRuleInputSchema) }, typed<BotRule>())
|
|
75
|
-
),
|
|
76
|
-
remove: protocol(
|
|
77
|
-
leaf(aliases.rule.remove, '/rules/:id', RouteMethod.DELETE),
|
|
78
|
-
contract.request({ params: typed<BotIdParams>(BotIdParamsSchema) }, typed<BotRemoveResult>())
|
|
79
|
-
),
|
|
80
|
-
}),
|
|
81
|
-
|
|
82
65
|
activity: Object.freeze({
|
|
83
66
|
list: protocol(
|
|
84
67
|
leaf(aliases.activity.list, '/activity', RouteMethod.GET),
|
package/src/schemas.ts
CHANGED
|
@@ -1,19 +1,15 @@
|
|
|
1
1
|
import type { JSONSchemaType } from 'ajv'
|
|
2
2
|
import {
|
|
3
3
|
BOT_ACTIVITY_LIMIT_MAX, BOT_CREDENTIAL_KEY_PATTERN, BOT_CREDENTIAL_MAX, BOT_CREDENTIALS_MAX, BOT_ID_MAX,
|
|
4
|
-
BOT_ID_PATTERN,
|
|
5
|
-
BOT_REPLY_MAX, BOT_RULE_NAME_MAX, BOT_TEXT_MAX, BotChatKind, BotMatchMode, BotPlatform, BotRuleAction,
|
|
6
|
-
BotTransport,
|
|
4
|
+
BOT_ID_PATTERN, BOT_LABEL_MAX, BOT_NAME_MAX, BOT_TEXT_MAX, BotChatKind, BotPlatform, BotTransport,
|
|
7
5
|
} from './consts.js'
|
|
8
|
-
import type {
|
|
9
|
-
|
|
10
|
-
} from './protocols/types.js'
|
|
11
|
-
import type { BotConnectionInput, BotRuleInput, BotSimulateInput } from './types.js'
|
|
6
|
+
import type { BotActivityQuery, BotChallengeQuery, BotHookParams, BotIdParams } from './protocols/types.js'
|
|
7
|
+
import type { BotConnectionInput, BotSimulateInput } from './types.js'
|
|
12
8
|
|
|
13
9
|
/**
|
|
14
10
|
* Hand-written request schemas, cast: an optional key is OPTIONAL, never `null` — the wire matches the
|
|
15
|
-
* TypeScript type exactly
|
|
16
|
-
*
|
|
11
|
+
* TypeScript type exactly, and nothing is nullable. `additionalProperties: false` everywhere a client
|
|
12
|
+
* writes.
|
|
17
13
|
*/
|
|
18
14
|
const cast = <T>(schema: object): JSONSchemaType<T> => schema as unknown as JSONSchemaType<T>
|
|
19
15
|
|
|
@@ -51,29 +47,6 @@ export const BotConnectionInputSchema = cast<BotConnectionInput>({
|
|
|
51
47
|
additionalProperties: false,
|
|
52
48
|
})
|
|
53
49
|
|
|
54
|
-
export const BotRuleInputSchema = cast<BotRuleInput>({
|
|
55
|
-
type: 'object',
|
|
56
|
-
properties: {
|
|
57
|
-
id: Id,
|
|
58
|
-
connection: { ...Id, nullable: true },
|
|
59
|
-
name: { type: 'string', minLength: 1, maxLength: BOT_RULE_NAME_MAX },
|
|
60
|
-
keywords: {
|
|
61
|
-
type: 'array',
|
|
62
|
-
items: { type: 'string', minLength: 1, maxLength: BOT_KEYWORD_MAX },
|
|
63
|
-
minItems: 1,
|
|
64
|
-
maxItems: BOT_KEYWORDS_MAX,
|
|
65
|
-
},
|
|
66
|
-
mode: { type: 'string', enum: Object.values(BotMatchMode) },
|
|
67
|
-
action: { type: 'string', enum: Object.values(BotRuleAction) },
|
|
68
|
-
reply: { type: 'string', maxLength: BOT_REPLY_MAX },
|
|
69
|
-
instruction: { type: 'string', maxLength: BOT_INSTRUCTION_MAX },
|
|
70
|
-
enabled: { type: 'boolean' },
|
|
71
|
-
order: { type: 'number' },
|
|
72
|
-
},
|
|
73
|
-
required: ['name', 'keywords', 'action'],
|
|
74
|
-
additionalProperties: false,
|
|
75
|
-
})
|
|
76
|
-
|
|
77
50
|
export const BotSimulateInputSchema = cast<BotSimulateInput>({
|
|
78
51
|
type: 'object',
|
|
79
52
|
properties: {
|
|
@@ -115,13 +88,6 @@ export const BotSimulateInputSchema = cast<BotSimulateInput>({
|
|
|
115
88
|
additionalProperties: false,
|
|
116
89
|
})
|
|
117
90
|
|
|
118
|
-
export const BotRuleListQuerySchema = cast<BotRuleListQuery>({
|
|
119
|
-
type: 'object',
|
|
120
|
-
properties: { connection: Id },
|
|
121
|
-
required: [],
|
|
122
|
-
additionalProperties: false,
|
|
123
|
-
})
|
|
124
|
-
|
|
125
91
|
/** A query string arrives as text: the server coerces `limit` before this checks it. */
|
|
126
92
|
export const BotActivityQuerySchema = cast<BotActivityQuery>({
|
|
127
93
|
type: 'object',
|
package/src/types.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
BotActionKind, BotChatKind, BotConnectionStatus,
|
|
3
|
-
BotTransport, BotUpdateKind,
|
|
2
|
+
BotActionKind, BotChatKind, BotConnectionStatus, BotPlatform, BotReceiveMode, BotTransport, BotUpdateKind,
|
|
4
3
|
} from './consts.js'
|
|
5
4
|
|
|
6
5
|
/** One credential a platform needs — what the panel draws an input for. */
|
|
@@ -119,45 +118,6 @@ export interface BotCommand {
|
|
|
119
118
|
args: string
|
|
120
119
|
}
|
|
121
120
|
|
|
122
|
-
/** A keyword group: the keywords and what the bot does with a message that matches them. */
|
|
123
|
-
export interface BotRule {
|
|
124
|
-
id: string
|
|
125
|
-
scope: string
|
|
126
|
-
/** One connection only; `null` or absent applies to every connection of the scope. */
|
|
127
|
-
connection?: string | null
|
|
128
|
-
name: string
|
|
129
|
-
keywords: string[]
|
|
130
|
-
mode: BotMatchMode
|
|
131
|
-
action: BotRuleAction
|
|
132
|
-
/** The fixed text of `reply` / `warn` (and the confirmation of `record`). */
|
|
133
|
-
reply?: string
|
|
134
|
-
/** What the product's agent is told for this group. */
|
|
135
|
-
instruction?: string
|
|
136
|
-
enabled: boolean
|
|
137
|
-
order: number
|
|
138
|
-
createdAt: string
|
|
139
|
-
updatedAt: string
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
export interface BotRuleInput {
|
|
143
|
-
id?: string
|
|
144
|
-
connection?: string | null
|
|
145
|
-
name: string
|
|
146
|
-
keywords: string[]
|
|
147
|
-
mode?: BotMatchMode
|
|
148
|
-
action: BotRuleAction
|
|
149
|
-
reply?: string
|
|
150
|
-
instruction?: string
|
|
151
|
-
enabled?: boolean
|
|
152
|
-
order?: number
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** A rule a message matched, with the rule's keywords (as stored) that matched it. */
|
|
156
|
-
export interface BotMatch {
|
|
157
|
-
rule: BotRule
|
|
158
|
-
keywords: string[]
|
|
159
|
-
}
|
|
160
|
-
|
|
161
121
|
/** A normalized inbound update, as a handler receives it. */
|
|
162
122
|
export interface BotUpdate {
|
|
163
123
|
id: string
|
|
@@ -171,8 +131,6 @@ export interface BotUpdate {
|
|
|
171
131
|
command?: BotCommand
|
|
172
132
|
/** A direct message, a mention of the bot, or a reply to it. */
|
|
173
133
|
addressed: boolean
|
|
174
|
-
/** Enabled rules of the scope (and connection) the text matches, by order. */
|
|
175
|
-
matches: BotMatch[]
|
|
176
134
|
receivedAt: string
|
|
177
135
|
raw?: unknown
|
|
178
136
|
}
|
|
@@ -195,8 +153,12 @@ export interface BotActivity {
|
|
|
195
153
|
action: BotActionKind
|
|
196
154
|
target?: BotActivityTarget
|
|
197
155
|
text?: string
|
|
198
|
-
/**
|
|
199
|
-
|
|
156
|
+
/**
|
|
157
|
+
* An opaque reference the acting handler passed (`{ ref }` on the action) — e.g. the id of the
|
|
158
|
+
* application's own record it followed, such as the keyword group a message matched. The bot
|
|
159
|
+
* packages never read it.
|
|
160
|
+
*/
|
|
161
|
+
ref?: string
|
|
200
162
|
ok: boolean
|
|
201
163
|
/** Why it failed, as a short sentence a person reads. */
|
|
202
164
|
error?: string
|
package/src/utils/aliases.ts
CHANGED
|
@@ -10,11 +10,6 @@ export const createBotAliasUtils = (): BotAliasUtils => {
|
|
|
10
10
|
remove: `${base}:connection:remove`,
|
|
11
11
|
simulate: `${base}:connection:simulate`,
|
|
12
12
|
}),
|
|
13
|
-
rule: Object.freeze({
|
|
14
|
-
list: `${base}:rule:list`,
|
|
15
|
-
save: `${base}:rule:save`,
|
|
16
|
-
remove: `${base}:rule:remove`,
|
|
17
|
-
}),
|
|
18
13
|
activity: Object.freeze({ list: `${base}:activity:list` }),
|
|
19
14
|
})
|
|
20
15
|
|
package/tests/protocols.spec.ts
CHANGED
|
@@ -16,7 +16,6 @@ describe('makeBotProtocols', () => {
|
|
|
16
16
|
BASE,
|
|
17
17
|
`${BASE}:connection:list`, `${BASE}:connection:save`, `${BASE}:connection:verify`,
|
|
18
18
|
`${BASE}:connection:remove`, `${BASE}:connection:simulate`,
|
|
19
|
-
`${BASE}:rule:list`, `${BASE}:rule:save`, `${BASE}:rule:remove`,
|
|
20
19
|
`${BASE}:activity:list`,
|
|
21
20
|
].sort())
|
|
22
21
|
})
|
|
@@ -28,9 +27,6 @@ describe('makeBotProtocols', () => {
|
|
|
28
27
|
[tree.connection.verify, RouteMethod.POST, '/connections/:id/verify'],
|
|
29
28
|
[tree.connection.remove, RouteMethod.DELETE, '/connections/:id'],
|
|
30
29
|
[tree.connection.simulate, RouteMethod.POST, '/connections/:id/simulate'],
|
|
31
|
-
[tree.rule.list, RouteMethod.GET, '/rules'],
|
|
32
|
-
[tree.rule.save, RouteMethod.POST, '/rules'],
|
|
33
|
-
[tree.rule.remove, RouteMethod.DELETE, '/rules/:id'],
|
|
34
30
|
[tree.activity.list, RouteMethod.GET, '/activity'],
|
|
35
31
|
] as const
|
|
36
32
|
|
|
@@ -47,9 +43,9 @@ describe('makeBotProtocols', () => {
|
|
|
47
43
|
expect(tree.base.guards).toEqual(['guard:default'])
|
|
48
44
|
expect(tree.base.gate).toEqual(gate)
|
|
49
45
|
expect(tree.base.route.route.path).toBe('/bot/operator')
|
|
50
|
-
expect(tree.
|
|
51
|
-
expect(tree.
|
|
52
|
-
expect(Object.isFrozen(tree) && Object.isFrozen(tree.connection) && Object.isFrozen(tree.
|
|
46
|
+
expect(tree.activity.list.guards).toEqual([])
|
|
47
|
+
expect(tree.activity.list.gate).toBeUndefined()
|
|
48
|
+
expect(Object.isFrozen(tree) && Object.isFrozen(tree.connection) && Object.isFrozen(tree.connection.save)).toBe(true)
|
|
53
49
|
})
|
|
54
50
|
|
|
55
51
|
test('two mounts are two alias families, and parent and service reach the base', () => {
|
package/tests/schemas.spec.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
2
|
import {
|
|
3
|
-
BOT_ACTIVITY_LIMIT_MAX,
|
|
4
|
-
BotHookParamsSchema, BotIdParamsSchema,
|
|
3
|
+
BOT_ACTIVITY_LIMIT_MAX, BotActivityQuerySchema, BotChallengeQuerySchema, BotConnectionInputSchema,
|
|
4
|
+
BotHookParamsSchema, BotIdParamsSchema, BotSimulateInputSchema,
|
|
5
5
|
} from '../src/index.js'
|
|
6
6
|
import { validatorOf } from './context.js'
|
|
7
7
|
|
|
@@ -25,27 +25,6 @@ describe('connection input', () => {
|
|
|
25
25
|
})
|
|
26
26
|
})
|
|
27
27
|
|
|
28
|
-
describe('rule input', () => {
|
|
29
|
-
const valid = validatorOf(BotRuleInputSchema)
|
|
30
|
-
|
|
31
|
-
test('accepts a rule with keywords and an action, scope-wide (null) or bound to a connection', () => {
|
|
32
|
-
expect(valid({ name: 'Spam', keywords: ['casino'], action: 'delete' })).toBe(true)
|
|
33
|
-
expect(valid({
|
|
34
|
-
id: 'r1', connection: null, name: 'Hours', keywords: ['open', 'hours'], mode: 'contains',
|
|
35
|
-
action: 'reply', reply: 'We open at 9.', instruction: 'Be brief.', enabled: false, order: 2.5,
|
|
36
|
-
})).toBe(true)
|
|
37
|
-
expect(valid({ connection: 'conn_1', name: 'x', keywords: ['k'], action: 'agent' })).toBe(true)
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
test('refuses no keywords, too many, an unknown action or mode, and a missing name', () => {
|
|
41
|
-
expect(valid({ name: 'x', keywords: [], action: 'delete' })).toBe(false)
|
|
42
|
-
expect(valid({ name: 'x', keywords: Array.from({ length: BOT_KEYWORDS_MAX + 1 }, (_, n) => `k${n}`), action: 'delete' })).toBe(false)
|
|
43
|
-
expect(valid({ name: 'x', keywords: ['k'], action: 'kick' })).toBe(false)
|
|
44
|
-
expect(valid({ name: 'x', keywords: ['k'], action: 'delete', mode: 'regex' })).toBe(false)
|
|
45
|
-
expect(valid({ keywords: ['k'], action: 'delete' })).toBe(false)
|
|
46
|
-
})
|
|
47
|
-
})
|
|
48
|
-
|
|
49
28
|
describe('simulate input', () => {
|
|
50
29
|
const valid = validatorOf(BotSimulateInputSchema)
|
|
51
30
|
|
|
@@ -84,13 +63,6 @@ describe('params and queries', () => {
|
|
|
84
63
|
expect(valid({ limit: 1.5 })).toBe(false)
|
|
85
64
|
})
|
|
86
65
|
|
|
87
|
-
test('the rule list filters by connection only', () => {
|
|
88
|
-
const valid = validatorOf(BotRuleListQuerySchema)
|
|
89
|
-
|
|
90
|
-
expect(valid({ connection: 'conn_1' })).toBe(true)
|
|
91
|
-
expect(valid({ scope: 'org-1' })).toBe(false)
|
|
92
|
-
})
|
|
93
|
-
|
|
94
66
|
test('the hook names a known platform and a path-safe connection; its handshake query stays open', () => {
|
|
95
67
|
const params = validatorOf(BotHookParamsSchema)
|
|
96
68
|
const query = validatorOf(BotChallengeQuerySchema)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { BotTextMatch, botTextHelper } from '../src/index.js'
|
|
3
|
+
|
|
4
|
+
const { matched, phrasesOf, fold, tokensOf } = botTextHelper
|
|
5
|
+
|
|
6
|
+
describe('botTextHelper.fold and tokensOf', () => {
|
|
7
|
+
test('fold applies NFKC, a case fold and NFKC again', () => {
|
|
8
|
+
expect(fold('STRASSE')).toBe(fold('straße'))
|
|
9
|
+
expect(fold('CASINO')).toBe('casino')
|
|
10
|
+
expect(fold('finance')).toBe('finance')
|
|
11
|
+
expect(fold('ЁЛКА')).toBe('ёлка')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('tokens are runs of letters, marks, digits and _ — punctuation splits', () => {
|
|
15
|
+
expect(tokensOf('Free—MONEY, now_2!')).toEqual(['free', 'money', 'now_2'])
|
|
16
|
+
expect(tokensOf('!!! ...')).toEqual([])
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('botTextHelper.matched — word mode (the default)', () => {
|
|
21
|
+
test('matches whole words only, case-insensitively and across punctuation', () => {
|
|
22
|
+
expect(matched('Visit the CASINO!', ['casino'])).toEqual(['casino'])
|
|
23
|
+
expect(matched('casino, now', ['casino'])).toEqual(['casino'])
|
|
24
|
+
expect(matched('casinos are fun', ['casino'])).toEqual([])
|
|
25
|
+
expect(matched('megacasino', ['casino'])).toEqual([])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('a multi-word phrase must appear as consecutive words', () => {
|
|
29
|
+
expect(matched('Get FREE money now', ['free money'])).toEqual(['free money'])
|
|
30
|
+
expect(matched('free—money', ['free money'])).toEqual(['free money'])
|
|
31
|
+
expect(matched('money for free', ['free money'])).toEqual([])
|
|
32
|
+
expect(matched('free real money', ['free money'])).toEqual([])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('is Unicode-aware: Cyrillic, accents, compatibility forms and the German sharp s', () => {
|
|
36
|
+
expect(matched('Купите СПАМ сейчас', ['спам'])).toEqual(['спам'])
|
|
37
|
+
expect(matched('ёлка', ['ЁЛКА'])).toEqual(['ЁЛКА'])
|
|
38
|
+
expect(matched('Un café, por favor', ['CAFÉ'])).toEqual(['CAFÉ'])
|
|
39
|
+
// Accents are kept: "cafe" is not "café".
|
|
40
|
+
expect(matched('Un café', ['cafe'])).toEqual([])
|
|
41
|
+
// NFKC: the full-width form and the ligature fold onto plain letters; NFD text onto NFC.
|
|
42
|
+
expect(matched('CASINO night', ['casino'])).toEqual(['casino'])
|
|
43
|
+
expect(matched('finance tips', ['finance'])).toEqual(['finance'])
|
|
44
|
+
expect(matched('café au lait', ['café'])).toEqual(['café'])
|
|
45
|
+
expect(matched('STRASSE gesperrt', ['straße'])).toEqual(['straße'])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('a phrase with no letters or digits never matches a word', () => {
|
|
49
|
+
expect(matched('!!! now', ['!!!'])).toEqual([])
|
|
50
|
+
expect(matched('!!! now', ['!!!'], BotTextMatch.Word)).toEqual([])
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('botTextHelper.matched — contains mode', () => {
|
|
55
|
+
test('matches a folded substring, inside words too, with whitespace runs collapsed', () => {
|
|
56
|
+
const contains = BotTextMatch.Contains
|
|
57
|
+
|
|
58
|
+
expect(matched('MEGACASINO.com', ['casino'], contains)).toEqual(['casino'])
|
|
59
|
+
expect(matched('wow!!! ok', ['!!!'], contains)).toEqual(['!!!'])
|
|
60
|
+
expect(matched('get free\n money', ['free money'], contains)).toEqual(['free money'])
|
|
61
|
+
expect(matched('no match here', ['casino', '!!!', 'free money'], contains)).toEqual([])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('finds a phrase in a script written without spaces', () => {
|
|
65
|
+
expect(matched('今日は営業時間を教えて', ['営業時間'], BotTextMatch.Contains)).toEqual(['営業時間'])
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('botTextHelper.matched — the answer', () => {
|
|
70
|
+
test('lists every phrase found, in the given order, as given', () => {
|
|
71
|
+
expect(matched('You idiot, what are the OPENING hours? stupid!', ['moron', 'stupid', 'Idiot', 'opening hours']))
|
|
72
|
+
.toEqual(['stupid', 'Idiot', 'opening hours'])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('answers each phrase once: a folded repeat is skipped, the first spelling kept', () => {
|
|
76
|
+
expect(matched('spam spam SPAM', ['Spam', 'spam', 'SPAM'])).toEqual(['Spam'])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('empty text, blank phrases and non-strings never match', () => {
|
|
80
|
+
expect(matched(' ', ['spam'])).toEqual([])
|
|
81
|
+
expect(matched('spam', ['', ' '])).toEqual([])
|
|
82
|
+
expect(matched('spam', [42 as unknown as string, 'spam'])).toEqual(['spam'])
|
|
83
|
+
expect(matched(undefined as unknown as string, ['spam'])).toEqual([])
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('botTextHelper.phrasesOf', () => {
|
|
88
|
+
test('splits on commas and line breaks, trims, collapses inner whitespace and drops empties', () => {
|
|
89
|
+
expect(phrasesOf(' Free money , casino,\n\nopening hours\r\n, '))
|
|
90
|
+
.toEqual(['Free money', 'casino', 'opening hours'])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('drops folded duplicates keeping the first spelling — across the items of a list too', () => {
|
|
94
|
+
expect(phrasesOf([' Free money ', '', ' ', 'free MONEY', 'Спам', 'СПАМ, café'])).toEqual(['Free money', 'Спам', 'café'])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('splits each item of a list and ignores what is not a string', () => {
|
|
98
|
+
expect(phrasesOf(['spam, scam', 7 as unknown as string, 'casino'])).toEqual(['spam', 'scam', 'casino'])
|
|
99
|
+
expect(phrasesOf('')).toEqual([])
|
|
100
|
+
})
|
|
101
|
+
})
|
package/tests/update.spec.ts
CHANGED
|
@@ -1,18 +1,12 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
2
|
import {
|
|
3
|
-
BotChatKind,
|
|
4
|
-
type BotMessageRef, type BotRule, type BotUpdate,
|
|
3
|
+
BotChatKind, BotPlatform, BotUpdateKind, botCommandHelper, botTextHelper, type BotMessageRef, type BotUpdate,
|
|
5
4
|
} from '../src/index.js'
|
|
6
5
|
|
|
7
6
|
const at = '2026-10-26T10:00:00.000Z'
|
|
8
7
|
|
|
9
|
-
const rules: BotRule[] = [
|
|
10
|
-
{ id: 'r-other', scope: 'org-1', connection: 'conn_2', name: 'Other', keywords: ['raid'], mode: BotMatchMode.Word, action: BotRuleAction.Ban, enabled: true, order: 0, createdAt: at, updatedAt: at },
|
|
11
|
-
{ id: 'r-all', scope: 'org-1', connection: null, name: 'Raid', keywords: ['raid'], mode: BotMatchMode.Word, action: BotRuleAction.Mute, enabled: true, order: 1, createdAt: at, updatedAt: at },
|
|
12
|
-
]
|
|
13
|
-
|
|
14
8
|
describe('a normalized update, assembled the way a runtime does', () => {
|
|
15
|
-
test('its
|
|
9
|
+
test('carries its command and a message ref that alone addresses the message; a handler matches the text itself', () => {
|
|
16
10
|
const ref: BotMessageRef = { chat: 'ch-1', id: 'm-1', thread: 't-1', space: 'guild-9' }
|
|
17
11
|
const text = '/report@ModBot RAID incoming'
|
|
18
12
|
const update: BotUpdate = {
|
|
@@ -26,12 +20,12 @@ describe('a normalized update, assembled the way a runtime does', () => {
|
|
|
26
20
|
message: { ref, text, date: at },
|
|
27
21
|
command: botCommandHelper.parse(text, 'ModBot') ?? undefined,
|
|
28
22
|
addressed: true,
|
|
29
|
-
matches: botRuleHelper.match(text, rules.filter(rule => botRuleHelper.appliesTo(rule, 'conn_1'))),
|
|
30
23
|
receivedAt: at,
|
|
31
24
|
}
|
|
32
25
|
|
|
33
26
|
expect(update.command).toEqual({ name: 'report', args: 'RAID incoming' })
|
|
34
|
-
expect(
|
|
27
|
+
expect(Object.keys(update)).not.toContain('matches')
|
|
28
|
+
expect(botTextHelper.matched(update.message?.text ?? '', ['raid', 'spam'])).toEqual(['raid'])
|
|
35
29
|
// A Discord timeout needs the guild: the reference carries it, so nothing else is consulted.
|
|
36
30
|
expect(update.message?.ref).toEqual({ chat: 'ch-1', id: 'm-1', thread: 't-1', space: 'guild-9' })
|
|
37
31
|
})
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import type { BotMatch, BotRule } from '../../types.js';
|
|
2
|
-
/**
|
|
3
|
-
* How a message is matched against keyword groups — the ONE implementation the server's dispatch,
|
|
4
|
-
* the panels and the tests share, so a rule means the same thing everywhere.
|
|
5
|
-
*/
|
|
6
|
-
export interface BotRuleHelper {
|
|
7
|
-
/**
|
|
8
|
-
* The enabled rules whose keywords the text matches, ordered by `order` (ties keep the given
|
|
9
|
-
* order), each with the keywords (as stored) that matched.
|
|
10
|
-
*
|
|
11
|
-
* Both sides are folded the same way: NFKC, then case-folded (`ß` ≡ `ss`, `Ё` ≡ `ё`), accents
|
|
12
|
-
* kept. `word` compares whole tokens — runs of letters, combining marks, digits and `_` — and a
|
|
13
|
-
* multi-word keyword is a PHRASE that must appear as consecutive tokens; `contains` is a substring
|
|
14
|
-
* of the folded text with whitespace runs collapsed. Disabled rules never match.
|
|
15
|
-
*/
|
|
16
|
-
match: (text: string, rules: readonly BotRule[]) => BotMatch[];
|
|
17
|
-
/**
|
|
18
|
-
* A keyword list as it is stored: each trimmed with inner whitespace collapsed, cut to
|
|
19
|
-
* `BOT_KEYWORD_MAX` code points, empties dropped, duplicates (by their folded form) dropped keeping
|
|
20
|
-
* the first spelling, at most `BOT_KEYWORDS_MAX` kept.
|
|
21
|
-
*/
|
|
22
|
-
normalizeKeywords: (list: readonly string[]) => string[];
|
|
23
|
-
/** Whether a rule applies to a connection: its own, or a scope-wide one (`connection` null/absent). */
|
|
24
|
-
appliesTo: (rule: Pick<BotRule, 'connection'>, connection: string) => boolean;
|
|
25
|
-
}
|
|
26
|
-
//# sourceMappingURL=types.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/helpers/rule/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAA;AAEvD;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B;;;;;;;;OAQG;IACH,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,OAAO,EAAE,KAAK,QAAQ,EAAE,CAAA;IAC9D;;;;OAIG;IACH,iBAAiB,EAAE,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,KAAK,MAAM,EAAE,CAAA;IACxD,uGAAuG;IACvG,SAAS,EAAE,CAAC,IAAI,EAAE,IAAI,CAAC,OAAO,EAAE,YAAY,CAAC,EAAE,UAAU,EAAE,MAAM,KAAK,OAAO,CAAA;CAC9E"}
|
package/build/helpers/rule.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"rule.d.ts","sourceRoot":"","sources":["../../src/helpers/rule.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAA;AAEpD,eAAO,MAAM,mBAAmB,QAAO,aAiFtC,CAAA;AAED,eAAO,MAAM,aAAa,eAAwB,CAAA"}
|