@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.
Files changed (63) hide show
  1. package/README.md +23 -21
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/bot/SKILL.md +68 -46
  4. package/build/consts.d.ts +6 -31
  5. package/build/consts.d.ts.map +1 -1
  6. package/build/consts.js +10 -36
  7. package/build/consts.js.map +1 -1
  8. package/build/consts.local.d.ts +3 -1
  9. package/build/consts.local.d.ts.map +1 -1
  10. package/build/consts.local.js +3 -1
  11. package/build/consts.local.js.map +1 -1
  12. package/build/helpers/index.d.ts +2 -2
  13. package/build/helpers/index.js +1 -1
  14. package/build/helpers/text/types.d.ts +35 -0
  15. package/build/helpers/text/types.d.ts.map +1 -0
  16. package/build/helpers/{rule → text}/types.js.map +1 -1
  17. package/build/helpers/text.d.ts +4 -0
  18. package/build/helpers/text.d.ts.map +1 -0
  19. package/build/helpers/text.js +77 -0
  20. package/build/helpers/text.js.map +1 -0
  21. package/build/protocols/types.d.ts +2 -17
  22. package/build/protocols/types.d.ts.map +1 -1
  23. package/build/protocols.d.ts +1 -1
  24. package/build/protocols.d.ts.map +1 -1
  25. package/build/protocols.js +2 -7
  26. package/build/protocols.js.map +1 -1
  27. package/build/schemas.d.ts +2 -4
  28. package/build/schemas.d.ts.map +1 -1
  29. package/build/schemas.js +3 -31
  30. package/build/schemas.js.map +1 -1
  31. package/build/types.d.ts +7 -41
  32. package/build/types.d.ts.map +1 -1
  33. package/build/utils/aliases/types.d.ts +0 -5
  34. package/build/utils/aliases/types.d.ts.map +1 -1
  35. package/build/utils/aliases.d.ts.map +1 -1
  36. package/build/utils/aliases.js +0 -5
  37. package/build/utils/aliases.js.map +1 -1
  38. package/package.json +7 -7
  39. package/src/consts.local.ts +4 -1
  40. package/src/consts.ts +6 -37
  41. package/src/helpers/index.ts +2 -2
  42. package/src/helpers/text/types.ts +35 -0
  43. package/src/helpers/text.ts +91 -0
  44. package/src/protocols/types.ts +2 -12
  45. package/src/protocols.ts +4 -21
  46. package/src/schemas.ts +5 -39
  47. package/src/types.ts +7 -45
  48. package/src/utils/aliases/types.ts +0 -1
  49. package/src/utils/aliases.ts +0 -5
  50. package/tests/protocols.spec.ts +3 -7
  51. package/tests/schemas.spec.ts +2 -30
  52. package/tests/text.spec.ts +101 -0
  53. package/tests/update.spec.ts +4 -10
  54. package/build/helpers/rule/types.d.ts +0 -26
  55. package/build/helpers/rule/types.d.ts.map +0 -1
  56. package/build/helpers/rule.d.ts +0 -4
  57. package/build/helpers/rule.d.ts.map +0 -1
  58. package/build/helpers/rule.js +0 -71
  59. package/build/helpers/rule.js.map +0 -1
  60. package/src/helpers/rule/types.ts +0 -26
  61. package/src/helpers/rule.ts +0 -89
  62. package/tests/rule.spec.ts +0 -129
  63. /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()
@@ -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, BotRule, BotRuleInput, BotSimulateInput, BotSimulateResult,
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 or a rule. */
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
- BotRuleInputSchema, BotRuleListQuerySchema, BotSimulateInputSchema,
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, BotRuleListQuery,
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, rules, activity — as an immutable tree a
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, BOT_INSTRUCTION_MAX, BOT_KEYWORD_MAX, BOT_KEYWORDS_MAX, BOT_LABEL_MAX, BOT_NAME_MAX,
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
- BotActivityQuery, BotChallengeQuery, BotHookParams, BotIdParams, BotRuleListQuery,
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. The only nullable is a rule's `connection`, whose `null` means "every
16
- * connection of the scope". `additionalProperties: false` everywhere a client writes.
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, BotMatchMode, BotPlatform, BotReceiveMode, BotRuleAction,
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
- /** The rule it followed. */
199
- rule?: string
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
@@ -8,7 +8,6 @@ export interface BotAliases {
8
8
  readonly remove: string
9
9
  readonly simulate: string
10
10
  }
11
- readonly rule: { readonly list: string, readonly save: string, readonly remove: string }
12
11
  readonly activity: { readonly list: string }
13
12
  }
14
13
 
@@ -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
 
@@ -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.rule.list.guards).toEqual([])
51
- expect(tree.rule.list.gate).toBeUndefined()
52
- expect(Object.isFrozen(tree) && Object.isFrozen(tree.connection) && Object.isFrozen(tree.rule.save)).toBe(true)
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', () => {
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import {
3
- BOT_ACTIVITY_LIMIT_MAX, BOT_KEYWORDS_MAX, BotActivityQuerySchema, BotChallengeQuerySchema, BotConnectionInputSchema,
4
- BotHookParamsSchema, BotIdParamsSchema, BotRuleInputSchema, BotRuleListQuerySchema, BotSimulateInputSchema,
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
+ })
@@ -1,18 +1,12 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import {
3
- BotChatKind, BotMatchMode, BotPlatform, BotRuleAction, BotUpdateKind, botCommandHelper, botRuleHelper,
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 matches come from the rules that apply to its connection, and its message ref alone addresses the message', () => {
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(update.matches.map(found => [found.rule.id, found.keywords])).toEqual([['r-all', ['raid']]])
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"}
@@ -1,4 +0,0 @@
1
- import type { BotRuleHelper } from './rule/types.js';
2
- export declare const createBotRuleHelper: () => BotRuleHelper;
3
- export declare const botRuleHelper: BotRuleHelper;
4
- //# sourceMappingURL=rule.d.ts.map
@@ -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"}