@owlmeans/bot 0.0.0-stage → 0.1.18-rc.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.
Files changed (99) hide show
  1. package/README.md +85 -2
  2. package/agent-meta/manifest.json +16 -0
  3. package/agent-meta/skills/bot/SKILL.md +187 -0
  4. package/build/consts.d.ts +132 -0
  5. package/build/consts.d.ts.map +1 -0
  6. package/build/consts.js +197 -0
  7. package/build/consts.js.map +1 -0
  8. package/build/consts.local.d.ts +13 -0
  9. package/build/consts.local.d.ts.map +1 -0
  10. package/build/consts.local.js +13 -0
  11. package/build/consts.local.js.map +1 -0
  12. package/build/errors.d.ts +43 -0
  13. package/build/errors.d.ts.map +1 -0
  14. package/build/errors.js +66 -0
  15. package/build/errors.js.map +1 -0
  16. package/build/helpers/command/types.d.ts +14 -0
  17. package/build/helpers/command/types.d.ts.map +1 -0
  18. package/build/helpers/command/types.js +2 -0
  19. package/build/helpers/command/types.js.map +1 -0
  20. package/build/helpers/command.d.ts +4 -0
  21. package/build/helpers/command.d.ts.map +1 -0
  22. package/build/helpers/command.js +21 -0
  23. package/build/helpers/command.js.map +1 -0
  24. package/build/helpers/index.d.ts +7 -0
  25. package/build/helpers/index.d.ts.map +1 -0
  26. package/build/helpers/index.js +4 -0
  27. package/build/helpers/index.js.map +1 -0
  28. package/build/helpers/platform/types.d.ts +12 -0
  29. package/build/helpers/platform/types.d.ts.map +1 -0
  30. package/build/helpers/platform/types.js +2 -0
  31. package/build/helpers/platform/types.js.map +1 -0
  32. package/build/helpers/platform.d.ts +4 -0
  33. package/build/helpers/platform.d.ts.map +1 -0
  34. package/build/helpers/platform.js +17 -0
  35. package/build/helpers/platform.js.map +1 -0
  36. package/build/helpers/rule/types.d.ts +26 -0
  37. package/build/helpers/rule/types.d.ts.map +1 -0
  38. package/build/helpers/rule/types.js +2 -0
  39. package/build/helpers/rule/types.js.map +1 -0
  40. package/build/helpers/rule.d.ts +4 -0
  41. package/build/helpers/rule.d.ts.map +1 -0
  42. package/build/helpers/rule.js +71 -0
  43. package/build/helpers/rule.js.map +1 -0
  44. package/build/index.d.ts +8 -0
  45. package/build/index.d.ts.map +1 -0
  46. package/build/index.js +6 -0
  47. package/build/index.js.map +1 -0
  48. package/build/protocols/types.d.ts +115 -0
  49. package/build/protocols/types.d.ts.map +1 -0
  50. package/build/protocols/types.js +2 -0
  51. package/build/protocols/types.js.map +1 -0
  52. package/build/protocols.d.ts +21 -0
  53. package/build/protocols.d.ts.map +1 -0
  54. package/build/protocols.js +70 -0
  55. package/build/protocols.js.map +1 -0
  56. package/build/schemas.d.ts +17 -0
  57. package/build/schemas.d.ts.map +1 -0
  58. package/build/schemas.js +140 -0
  59. package/build/schemas.js.map +1 -0
  60. package/build/types.d.ts +215 -0
  61. package/build/types.d.ts.map +1 -0
  62. package/build/types.js +2 -0
  63. package/build/types.js.map +1 -0
  64. package/build/utils/aliases/types.d.ts +30 -0
  65. package/build/utils/aliases/types.d.ts.map +1 -0
  66. package/build/utils/aliases/types.js +2 -0
  67. package/build/utils/aliases/types.js.map +1 -0
  68. package/build/utils/aliases.d.ts +4 -0
  69. package/build/utils/aliases.d.ts.map +1 -0
  70. package/build/utils/aliases.js +26 -0
  71. package/build/utils/aliases.js.map +1 -0
  72. package/package.json +42 -4
  73. package/src/consts.local.ts +14 -0
  74. package/src/consts.ts +214 -0
  75. package/src/errors.ts +78 -0
  76. package/src/helpers/command/types.ts +14 -0
  77. package/src/helpers/command.ts +26 -0
  78. package/src/helpers/index.ts +6 -0
  79. package/src/helpers/platform/types.ts +12 -0
  80. package/src/helpers/platform.ts +23 -0
  81. package/src/helpers/rule/types.ts +26 -0
  82. package/src/helpers/rule.ts +89 -0
  83. package/src/index.ts +7 -0
  84. package/src/protocols/types.ts +107 -0
  85. package/src/protocols.ts +121 -0
  86. package/src/schemas.ts +159 -0
  87. package/src/types.ts +228 -0
  88. package/src/utils/aliases/types.ts +25 -0
  89. package/src/utils/aliases.ts +30 -0
  90. package/tests/command.spec.ts +23 -0
  91. package/tests/context.ts +13 -0
  92. package/tests/errors.spec.ts +41 -0
  93. package/tests/platforms.spec.ts +60 -0
  94. package/tests/protocols.spec.ts +80 -0
  95. package/tests/rule.spec.ts +129 -0
  96. package/tests/schemas.spec.ts +102 -0
  97. package/tests/tsconfig.json +12 -0
  98. package/tests/update.spec.ts +38 -0
  99. package/tsconfig.json +8 -0
package/src/consts.ts ADDED
@@ -0,0 +1,214 @@
1
+ import type { BotPlatformDescriptor } from './types.js'
2
+
3
+ /** The messaging platforms a bot connection can live on. */
4
+ export enum BotPlatform {
5
+ Telegram = 'telegram',
6
+ WhatsApp = 'whatsapp',
7
+ Discord = 'discord',
8
+ Slack = 'slack',
9
+ }
10
+
11
+ /** How a connection's updates reach the application. */
12
+ export enum BotReceiveMode {
13
+ /** The platform POSTs every update to the connection's hook URL. */
14
+ Webhook = 'webhook',
15
+ /** The worker asks the platform for updates (Telegram long polling). */
16
+ Poll = 'poll',
17
+ /** The worker holds a socket the platform pushes over (the Discord gateway). */
18
+ Gateway = 'gateway',
19
+ }
20
+
21
+ /**
22
+ * Who answers a connection's platform calls.
23
+ *
24
+ * `mock` is the platform's API faithfully faked — no network, every call recorded as activity — so a
25
+ * preview, a test or an end-to-end run exercises the exact live path without a real bot.
26
+ */
27
+ export enum BotTransport {
28
+ Live = 'live',
29
+ Mock = 'mock',
30
+ }
31
+
32
+ export enum BotConnectionStatus {
33
+ /** Saved, not verified yet. */
34
+ Pending = 'pending',
35
+ /** The platform accepted the credentials and the receiver is (or can be) running. */
36
+ Connected = 'connected',
37
+ /** The last verification or receive failed — `lastError` says why. */
38
+ Failed = 'failed',
39
+ /** Switched off by its owner; nothing is received or sent. */
40
+ Disabled = 'disabled',
41
+ }
42
+
43
+ /**
44
+ * What a bot can DO — the platform calls a handler makes, each recorded as one activity.
45
+ *
46
+ * `note` is the exception: it records an activity and makes no platform call, so every connection
47
+ * supports it and no platform descriptor lists it.
48
+ */
49
+ export enum BotActionKind {
50
+ Reply = 'reply',
51
+ Send = 'send',
52
+ Delete = 'delete',
53
+ Mute = 'mute',
54
+ Ban = 'ban',
55
+ React = 'react',
56
+ Note = 'note',
57
+ }
58
+
59
+ /**
60
+ * What a keyword group (a rule) tells the bot to do with a message it matches.
61
+ *
62
+ * A product vocabulary, not a platform one: `warn` is a reply, `record` is a request record of the
63
+ * product, `agent` hands the message to the product's own agent.
64
+ */
65
+ export enum BotRuleAction {
66
+ Delete = 'delete',
67
+ Warn = 'warn',
68
+ Mute = 'mute',
69
+ Ban = 'ban',
70
+ Reply = 'reply',
71
+ Record = 'record',
72
+ Agent = 'agent',
73
+ }
74
+
75
+ /** How a rule's keywords are compared with a message. */
76
+ export enum BotMatchMode {
77
+ /** Whole words and whole phrases (consecutive words), Unicode-aware and case-insensitive. */
78
+ Word = 'word',
79
+ /** A case-insensitive substring — also the mode for scripts written without spaces. */
80
+ Contains = 'contains',
81
+ }
82
+
83
+ export enum BotUpdateKind {
84
+ Message = 'message',
85
+ Edited = 'edited',
86
+ Command = 'command',
87
+ Member = 'member',
88
+ }
89
+
90
+ export enum BotChatKind {
91
+ Private = 'private',
92
+ Group = 'group',
93
+ Channel = 'channel',
94
+ }
95
+
96
+ /** The longest identifier (connection, rule, activity, update) the wire accepts. */
97
+ export const BOT_ID_MAX = 128
98
+
99
+ /** What an identifier on the wire may look like — it is also a path segment of the hook URL. */
100
+ export const BOT_ID_PATTERN = '^[A-Za-z0-9][A-Za-z0-9_.:-]*$'
101
+
102
+ /** The longest connection label. */
103
+ export const BOT_LABEL_MAX = 120
104
+
105
+ /** The longest rule (keyword group) name. */
106
+ export const BOT_RULE_NAME_MAX = 120
107
+
108
+ /** How many keywords one rule keeps. */
109
+ export const BOT_KEYWORDS_MAX = 200
110
+
111
+ /** The longest keyword or phrase, in code points. */
112
+ export const BOT_KEYWORD_MAX = 100
113
+
114
+ /** The longest fixed reply text of a rule. */
115
+ export const BOT_REPLY_MAX = 4096
116
+
117
+ /** The longest instruction a rule hands to the product's agent. */
118
+ export const BOT_INSTRUCTION_MAX = 4000
119
+
120
+ /** The longest message text the simulator accepts. */
121
+ export const BOT_TEXT_MAX = 4096
122
+
123
+ /** The longest single credential value. */
124
+ export const BOT_CREDENTIAL_MAX = 4096
125
+
126
+ /** How many credential keys one connection input may carry. */
127
+ export const BOT_CREDENTIALS_MAX = 16
128
+
129
+ /** What a credential key may look like. */
130
+ export const BOT_CREDENTIAL_KEY_PATTERN = '^[A-Za-z][A-Za-z0-9]{0,63}$'
131
+
132
+ /** The longest free-text field of a simulated chat or sender (title, name, username). */
133
+ export const BOT_NAME_MAX = 256
134
+
135
+ /** The most activity rows one read returns. */
136
+ export const BOT_ACTIVITY_LIMIT_MAX = 200
137
+
138
+ /** The activity rows a read returns when it names no limit. */
139
+ export const BOT_ACTIVITY_LIMIT_DEFAULT = 50
140
+
141
+ /**
142
+ * The permission the MEMBER mount's gate requires (`gates: { alias: OIDC_GATE, params:
143
+ * [BOT_MANAGE_PERMISSION] }`) — who may connect an organization's bots, edit its keyword groups and
144
+ * read its bot activity. In a product whose users work in organizations, an organization's owners hold
145
+ * it by default; otherwise it is only ever granted explicitly.
146
+ */
147
+ export const BOT_MANAGE_PERMISSION = 'bot--manage'
148
+
149
+ /**
150
+ * What each platform needs and can do — the one table every layer reads: the panel draws the
151
+ * credential inputs from it, the server verifies and acts by it, generation picks actions from it.
152
+ *
153
+ * `actions` lists the platform CALLS a connection can make with its required credentials; an
154
+ * optional credential's `enables` adds more (Slack's admin `userToken` adds `delete`). `note` is never
155
+ * listed — it makes no platform call (`botPlatformHelper.actionsOf` answers the full set).
156
+ */
157
+ export const BOT_PLATFORMS: Record<BotPlatform, BotPlatformDescriptor> = Object.freeze({
158
+ [BotPlatform.Telegram]: Object.freeze({
159
+ platform: BotPlatform.Telegram,
160
+ title: 'Telegram',
161
+ credentials: Object.freeze([
162
+ Object.freeze({ key: 'token', secret: true, required: true }),
163
+ ]),
164
+ receive: Object.freeze([BotReceiveMode.Webhook, BotReceiveMode.Poll]),
165
+ actions: Object.freeze([
166
+ BotActionKind.Reply, BotActionKind.Send, BotActionKind.Delete, BotActionKind.Mute, BotActionKind.Ban,
167
+ BotActionKind.React,
168
+ ]),
169
+ setup: 'auto',
170
+ docs: 'https://core.telegram.org/bots/tutorial#obtain-your-bot-token',
171
+ }),
172
+ [BotPlatform.WhatsApp]: Object.freeze({
173
+ platform: BotPlatform.WhatsApp,
174
+ title: 'WhatsApp',
175
+ credentials: Object.freeze([
176
+ Object.freeze({ key: 'accessToken', secret: true, required: true }),
177
+ Object.freeze({ key: 'phoneNumberId', secret: false, required: true }),
178
+ Object.freeze({ key: 'appSecret', secret: true, required: true }),
179
+ Object.freeze({ key: 'verifyToken', secret: true, required: true, generated: true }),
180
+ ]),
181
+ receive: Object.freeze([BotReceiveMode.Webhook]),
182
+ actions: Object.freeze([BotActionKind.Reply, BotActionKind.Send, BotActionKind.React]),
183
+ setup: 'manual',
184
+ docs: 'https://developers.facebook.com/docs/whatsapp/cloud-api/get-started',
185
+ }),
186
+ [BotPlatform.Discord]: Object.freeze({
187
+ platform: BotPlatform.Discord,
188
+ title: 'Discord',
189
+ credentials: Object.freeze([
190
+ Object.freeze({ key: 'token', secret: true, required: true }),
191
+ Object.freeze({ key: 'applicationId', secret: false, required: true }),
192
+ ]),
193
+ receive: Object.freeze([BotReceiveMode.Gateway]),
194
+ actions: Object.freeze([
195
+ BotActionKind.Reply, BotActionKind.Send, BotActionKind.Delete, BotActionKind.Mute, BotActionKind.Ban,
196
+ BotActionKind.React,
197
+ ]),
198
+ setup: 'auto',
199
+ docs: 'https://discord.com/developers/docs/quick-start/getting-started',
200
+ }),
201
+ [BotPlatform.Slack]: Object.freeze({
202
+ platform: BotPlatform.Slack,
203
+ title: 'Slack',
204
+ credentials: Object.freeze([
205
+ Object.freeze({ key: 'botToken', secret: true, required: true }),
206
+ Object.freeze({ key: 'signingSecret', secret: true, required: true }),
207
+ Object.freeze({ key: 'userToken', secret: true, required: false, enables: Object.freeze([BotActionKind.Delete]) }),
208
+ ]),
209
+ receive: Object.freeze([BotReceiveMode.Webhook]),
210
+ actions: Object.freeze([BotActionKind.Reply, BotActionKind.Send, BotActionKind.React]),
211
+ setup: 'manual',
212
+ docs: 'https://api.slack.com/apis/events-api',
213
+ }),
214
+ }) as Record<BotPlatform, BotPlatformDescriptor>
package/src/errors.ts ADDED
@@ -0,0 +1,78 @@
1
+ import { ResilientError } from '@owlmeans/error'
2
+
3
+ /**
4
+ * The bot refusal family.
5
+ *
6
+ * Every message starts with `bot:` and the class's own marker (`connection-not-found:` …) — the
7
+ * marker survives a hop where the class is not registered, so it is part of the contract. The family
8
+ * base declares no status: a fault of the platform or the store answers 500.
9
+ */
10
+ export class BotError extends ResilientError {
11
+ public static override typeName: string = 'BotError'
12
+
13
+ constructor(message: string = 'error') {
14
+ super(BotError.typeName, `bot:${message}`)
15
+ }
16
+ }
17
+
18
+ /** The connection does not exist — or is another scope's, which is deliberately the same answer. */
19
+ export class BotConnectionNotFound extends BotError {
20
+ public static override typeName: string = `${BotError.typeName}ConnectionNotFound`
21
+ public static httpStatus = 404
22
+
23
+ constructor(message: string = 'error') {
24
+ super(`connection-not-found:${message}`)
25
+ this.type = BotConnectionNotFound.typeName
26
+ }
27
+ }
28
+
29
+ /** The platform refused the credentials (or a required one is missing) at verification. */
30
+ export class BotCredentialsRejected extends BotError {
31
+ public static override typeName: string = `${BotError.typeName}CredentialsRejected`
32
+ public static httpStatus = 422
33
+
34
+ constructor(message: string = 'error') {
35
+ super(`credentials-rejected:${message}`)
36
+ this.type = BotCredentialsRejected.typeName
37
+ }
38
+ }
39
+
40
+ /** An inbound hook whose signature (or verify token) does not prove it came from the platform. */
41
+ export class BotSignatureRejected extends BotError {
42
+ public static override typeName: string = `${BotError.typeName}SignatureRejected`
43
+ public static httpStatus = 401
44
+
45
+ constructor(message: string = 'error') {
46
+ super(`signature-rejected:${message}`)
47
+ this.type = BotSignatureRejected.typeName
48
+ }
49
+ }
50
+
51
+ /** The request needs the other transport — `simulate` on a live connection. */
52
+ export class BotTransportMismatch extends BotError {
53
+ public static override typeName: string = `${BotError.typeName}TransportMismatch`
54
+ public static httpStatus = 409
55
+
56
+ constructor(message: string = 'error') {
57
+ super(`transport-mismatch:${message}`)
58
+ this.type = BotTransportMismatch.typeName
59
+ }
60
+ }
61
+
62
+ /** The platform (or the connection's credentials) cannot perform the requested action. */
63
+ export class BotUnsupportedAction extends BotError {
64
+ public static override typeName: string = `${BotError.typeName}UnsupportedAction`
65
+ public static httpStatus = 422
66
+
67
+ constructor(message: string = 'error') {
68
+ super(`unsupported-action:${message}`)
69
+ this.type = BotUnsupportedAction.typeName
70
+ }
71
+ }
72
+
73
+ ResilientError.registerErrorClass(BotError)
74
+ ResilientError.registerErrorClass(BotConnectionNotFound)
75
+ ResilientError.registerErrorClass(BotCredentialsRejected)
76
+ ResilientError.registerErrorClass(BotSignatureRejected)
77
+ ResilientError.registerErrorClass(BotTransportMismatch)
78
+ ResilientError.registerErrorClass(BotUnsupportedAction)
@@ -0,0 +1,14 @@
1
+ import type { BotCommand } from '../../types.js'
2
+
3
+ export interface BotCommandHelper {
4
+ /**
5
+ * Read a `/command` off the start of a message: `/report@MyBot some args` →
6
+ * `{ name: 'report', args: 'some args' }`.
7
+ *
8
+ * The name is lower-cased and must end at whitespace or at the end of the text (`/usr/bin` is not a
9
+ * command); `args` is the trimmed rest, `''` when none. A command addressed to ANOTHER bot
10
+ * (`@OtherBot`, compared case-insensitively, a leading `@` of `botUsername` ignored) answers `null`
11
+ * — when `botUsername` is not given, any address is accepted. Not a command answers `null`.
12
+ */
13
+ parse: (text: string, botUsername?: string) => BotCommand | null
14
+ }
@@ -0,0 +1,26 @@
1
+ import { BOT_COMMAND_PATTERN } from '../consts.local.js'
2
+ import type { BotCommand } from '../types.js'
3
+ import type { BotCommandHelper } from './command/types.js'
4
+
5
+ export const createBotCommandHelper = (): BotCommandHelper => {
6
+ const parse = (text: string, botUsername?: string): BotCommand | null => {
7
+ if (typeof text !== 'string') {
8
+ return null
9
+ }
10
+ const found = BOT_COMMAND_PATTERN.exec(text.trimStart())
11
+ if (found == null) {
12
+ return null
13
+ }
14
+ const [, name, addressee, args] = found
15
+ const own = botUsername?.replace(/^@/, '').trim().toLowerCase()
16
+ if (addressee != null && own != null && own !== '' && addressee.toLowerCase() !== own) {
17
+ return null
18
+ }
19
+
20
+ return { name: name.toLowerCase(), args: (args ?? '').trim() }
21
+ }
22
+
23
+ return { parse }
24
+ }
25
+
26
+ export const botCommandHelper = createBotCommandHelper()
@@ -0,0 +1,6 @@
1
+ export type * from './rule/types.js'
2
+ export type * from './command/types.js'
3
+ export type * from './platform/types.js'
4
+ export * from './rule.js'
5
+ export * from './command.js'
6
+ export * from './platform.js'
@@ -0,0 +1,12 @@
1
+ import type { BotActionKind, BotPlatform } from '../../consts.js'
2
+
3
+ /** What a connection on a platform can do, given which of its credentials hold a value. */
4
+ export interface BotPlatformHelper {
5
+ /**
6
+ * The descriptor's actions, plus those an optional credential `enables` when its key is in
7
+ * `credentialsSet` (Slack's `userToken` → `delete`), plus `note`, which every connection supports.
8
+ */
9
+ actionsOf: (platform: BotPlatform, credentialsSet?: readonly string[]) => BotActionKind[]
10
+ /** Whether {@link BotPlatformHelper.actionsOf} includes `kind`. */
11
+ supports: (platform: BotPlatform, kind: BotActionKind, credentialsSet?: readonly string[]) => boolean
12
+ }
@@ -0,0 +1,23 @@
1
+ import { BOT_PLATFORMS, BotActionKind, type BotPlatform } from '../consts.js'
2
+ import type { BotPlatformHelper } from './platform/types.js'
3
+
4
+ export const createBotPlatformHelper = (): BotPlatformHelper => {
5
+ const actionsOf = (platform: BotPlatform, credentialsSet: readonly string[] = []): BotActionKind[] => {
6
+ const descriptor = BOT_PLATFORMS[platform]
7
+ if (descriptor == null) {
8
+ return [BotActionKind.Note]
9
+ }
10
+ const enabled = descriptor.credentials
11
+ .filter(field => field.enables != null && credentialsSet.includes(field.key))
12
+ .flatMap(field => field.enables ?? [])
13
+
14
+ return [...new Set([...descriptor.actions, ...enabled, BotActionKind.Note])]
15
+ }
16
+
17
+ const supports = (platform: BotPlatform, kind: BotActionKind, credentialsSet?: readonly string[]): boolean =>
18
+ actionsOf(platform, credentialsSet).includes(kind)
19
+
20
+ return { actionsOf, supports }
21
+ }
22
+
23
+ export const botPlatformHelper = createBotPlatformHelper()
@@ -0,0 +1,26 @@
1
+ import type { BotMatch, BotRule } from '../../types.js'
2
+
3
+ /**
4
+ * How a message is matched against keyword groups — the ONE implementation the server's dispatch,
5
+ * the panels and the tests share, so a rule means the same thing everywhere.
6
+ */
7
+ export interface BotRuleHelper {
8
+ /**
9
+ * The enabled rules whose keywords the text matches, ordered by `order` (ties keep the given
10
+ * order), each with the keywords (as stored) that matched.
11
+ *
12
+ * Both sides are folded the same way: NFKC, then case-folded (`ß` ≡ `ss`, `Ё` ≡ `ё`), accents
13
+ * kept. `word` compares whole tokens — runs of letters, combining marks, digits and `_` — and a
14
+ * multi-word keyword is a PHRASE that must appear as consecutive tokens; `contains` is a substring
15
+ * of the folded text with whitespace runs collapsed. Disabled rules never match.
16
+ */
17
+ match: (text: string, rules: readonly BotRule[]) => BotMatch[]
18
+ /**
19
+ * A keyword list as it is stored: each trimmed with inner whitespace collapsed, cut to
20
+ * `BOT_KEYWORD_MAX` code points, empties dropped, duplicates (by their folded form) dropped keeping
21
+ * the first spelling, at most `BOT_KEYWORDS_MAX` kept.
22
+ */
23
+ normalizeKeywords: (list: readonly string[]) => string[]
24
+ /** Whether a rule applies to a connection: its own, or a scope-wide one (`connection` null/absent). */
25
+ appliesTo: (rule: Pick<BotRule, 'connection'>, connection: string) => boolean
26
+ }
@@ -0,0 +1,89 @@
1
+ import { BOT_KEYWORD_MAX, BOT_KEYWORDS_MAX, BotMatchMode } from '../consts.js'
2
+ import { BOT_SPACE_PATTERN, BOT_TOKEN_PATTERN } from '../consts.local.js'
3
+ import type { BotMatch, BotRule } from '../types.js'
4
+ import type { BotRuleHelper } from './rule/types.js'
5
+
6
+ export const createBotRuleHelper = (): BotRuleHelper => {
7
+ /**
8
+ * NFKC, then a case fold: upper-casing first maps what lower-casing alone keeps apart (`ß` → `SS`
9
+ * → `ss`, final `ς` → `Σ` → `σ`), and NFKC again recomposes what the case mapping decomposed.
10
+ */
11
+ const fold = (text: string): string =>
12
+ text.normalize('NFKC').toUpperCase().toLowerCase().normalize('NFKC')
13
+
14
+ const tokensOf = (text: string): string[] => fold(text).match(BOT_TOKEN_PATTERN) ?? []
15
+
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 match = (text: string, rules: readonly BotRule[]): BotMatch[] => {
28
+ if (typeof text !== 'string' || text.trim() === '') {
29
+ return []
30
+ }
31
+ // Folded once per message, never per rule.
32
+ let tokens: string[] | undefined
33
+ let spaced: string | undefined
34
+
35
+ const matchesKeyword = (mode: BotMatchMode, keyword: string): boolean => {
36
+ if (mode === BotMatchMode.Contains) {
37
+ const needle = spacedOf(keyword)
38
+ spaced ??= spacedOf(text)
39
+ return needle !== '' && spaced.includes(needle)
40
+ }
41
+ const phrase = tokensOf(keyword)
42
+ tokens ??= tokensOf(text)
43
+ return phrase.length > 0 && hasPhrase(tokens, phrase)
44
+ }
45
+
46
+ return rules
47
+ .filter(rule => rule.enabled)
48
+ .map((rule): BotMatch => ({
49
+ rule,
50
+ keywords: [...new Set(rule.keywords.filter(
51
+ keyword => typeof keyword === 'string' && matchesKeyword(rule.mode ?? BotMatchMode.Word, keyword)
52
+ ))],
53
+ }))
54
+ .filter(found => found.keywords.length > 0)
55
+ // Array#sort is stable: rules of equal order keep the order they were given in.
56
+ .sort((left, right) => (left.rule.order ?? 0) - (right.rule.order ?? 0))
57
+ }
58
+
59
+ const normalizeKeywords = (list: readonly string[]): string[] => {
60
+ const seen = new Set<string>()
61
+ const result: string[] = []
62
+
63
+ for (const raw of list ?? []) {
64
+ if (typeof raw !== 'string') {
65
+ continue
66
+ }
67
+ const keyword = Array.from(raw.replace(BOT_SPACE_PATTERN, ' ').trim())
68
+ .slice(0, BOT_KEYWORD_MAX).join('').trim()
69
+ const key = spacedOf(keyword)
70
+ if (keyword === '' || seen.has(key)) {
71
+ continue
72
+ }
73
+ seen.add(key)
74
+ result.push(keyword)
75
+ if (result.length >= BOT_KEYWORDS_MAX) {
76
+ break
77
+ }
78
+ }
79
+
80
+ return result
81
+ }
82
+
83
+ const appliesTo = (rule: Pick<BotRule, 'connection'>, connection: string): boolean =>
84
+ rule.connection == null || rule.connection === connection
85
+
86
+ return { match, normalizeKeywords, appliesTo }
87
+ }
88
+
89
+ export const botRuleHelper = createBotRuleHelper()
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export type * from './types.js'
2
+ export type * from './protocols/types.js'
3
+ export * from './consts.js'
4
+ export * from './schemas.js'
5
+ export * from './errors.js'
6
+ export * from './protocols.js'
7
+ export * from './helpers/index.js'
@@ -0,0 +1,107 @@
1
+ import type { EntrypointProtocol, OpenRequest, OpenValue } from '@owlmeans/entrypoint'
2
+ import type { RouteParent } from '@owlmeans/route'
3
+ import type { BotPlatform } from '../consts.js'
4
+ import type {
5
+ BotActivity, BotConnection, BotConnectionInput, BotRule, BotRuleInput, BotSimulateInput, BotSimulateResult,
6
+ } from '../types.js'
7
+
8
+ /** Where a bot tree mounts. Every leaf alias derives from `alias` (`<alias>:connection:list` …). */
9
+ export interface BotBaseOptions {
10
+ alias: string
11
+ /** The base path — `/bot/operator`, `/bot/member`, `/bot/hook`. */
12
+ path: string
13
+ parent?: RouteParent
14
+ service?: string
15
+ }
16
+
17
+ /** The access gate the managing base carries — `EntrypointOptions['gate']`. */
18
+ export interface BotGateOptions {
19
+ alias: string
20
+ params?: string | readonly string[]
21
+ }
22
+
23
+ export interface BotProtocolOptions {
24
+ base: BotBaseOptions
25
+ /** The guard(s) the base carries; every leaf inherits them. */
26
+ guards?: string | readonly string[]
27
+ /** The ONE gate the base carries (a protocol has one gate); every leaf inherits it. */
28
+ gates?: BotGateOptions
29
+ }
30
+
31
+ export interface BotHookProtocolOptions {
32
+ base: BotBaseOptions
33
+ }
34
+
35
+ /** `:id` of a connection or a rule. */
36
+ export interface BotIdParams {
37
+ id: string
38
+ }
39
+
40
+ export interface BotRemoveResult {
41
+ id: string
42
+ }
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
+ export interface BotActivityQuery {
50
+ connection?: string
51
+ /** 1 … `BOT_ACTIVITY_LIMIT_MAX`; `BOT_ACTIVITY_LIMIT_DEFAULT` when absent. */
52
+ limit?: number
53
+ }
54
+
55
+ /** The hook address: `/:platform/:connection`. */
56
+ export interface BotHookParams {
57
+ platform: BotPlatform
58
+ connection: string
59
+ }
60
+
61
+ /** The platform's NATIVE payload — authenticated and normalized by the server, never by a schema. */
62
+ export interface BotHookPayload {
63
+ [key: string]: unknown
64
+ }
65
+
66
+ /** What the receive hook answers: 200 at once, or a handshake's challenge (Slack `url_verification`). */
67
+ export interface BotHookReply {
68
+ ok?: boolean
69
+ challenge?: string
70
+ }
71
+
72
+ /** The subscription handshake WhatsApp sends as a GET (`hub.*`); answered with `hub.challenge`. */
73
+ export interface BotChallengeQuery {
74
+ 'hub.mode'?: string
75
+ 'hub.verify_token'?: string
76
+ 'hub.challenge'?: string
77
+ }
78
+
79
+ /** The managing surface of one mount (operator or member). */
80
+ export interface BotProtocols {
81
+ /** Carries the guards and the gate; every leaf hangs under it. */
82
+ base: EntrypointProtocol<OpenRequest, OpenValue>
83
+ connection: {
84
+ list: EntrypointProtocol<{}, BotConnection[]>
85
+ save: EntrypointProtocol<{ body: BotConnectionInput }, BotConnection>
86
+ verify: EntrypointProtocol<{ params: BotIdParams }, BotConnection>
87
+ remove: EntrypointProtocol<{ params: BotIdParams }, BotRemoveResult>
88
+ /** Mock transport only — a live connection answers `BotTransportMismatch` (409). */
89
+ simulate: EntrypointProtocol<{ params: BotIdParams, body: BotSimulateInput }, BotSimulateResult>
90
+ }
91
+ rule: {
92
+ list: EntrypointProtocol<{ query: BotRuleListQuery }, BotRule[]>
93
+ save: EntrypointProtocol<{ body: BotRuleInput }, BotRule>
94
+ remove: EntrypointProtocol<{ params: BotIdParams }, BotRemoveResult>
95
+ }
96
+ activity: {
97
+ /** Newest first. */
98
+ list: EntrypointProtocol<{ query: BotActivityQuery }, BotActivity[]>
99
+ }
100
+ }
101
+
102
+ /** The platforms' inbound surface — no guard: the platform signs. */
103
+ export interface BotHookProtocols {
104
+ base: EntrypointProtocol<OpenRequest, OpenValue>
105
+ receive: EntrypointProtocol<{ params: BotHookParams, body: BotHookPayload }, BotHookReply>
106
+ challenge: EntrypointProtocol<{ params: BotHookParams, query: BotChallengeQuery }, string>
107
+ }