@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.
- package/README.md +85 -2
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/bot/SKILL.md +187 -0
- package/build/consts.d.ts +132 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +197 -0
- package/build/consts.js.map +1 -0
- package/build/consts.local.d.ts +13 -0
- package/build/consts.local.d.ts.map +1 -0
- package/build/consts.local.js +13 -0
- package/build/consts.local.js.map +1 -0
- package/build/errors.d.ts +43 -0
- package/build/errors.d.ts.map +1 -0
- package/build/errors.js +66 -0
- package/build/errors.js.map +1 -0
- package/build/helpers/command/types.d.ts +14 -0
- package/build/helpers/command/types.d.ts.map +1 -0
- package/build/helpers/command/types.js +2 -0
- package/build/helpers/command/types.js.map +1 -0
- package/build/helpers/command.d.ts +4 -0
- package/build/helpers/command.d.ts.map +1 -0
- package/build/helpers/command.js +21 -0
- package/build/helpers/command.js.map +1 -0
- package/build/helpers/index.d.ts +7 -0
- package/build/helpers/index.d.ts.map +1 -0
- package/build/helpers/index.js +4 -0
- package/build/helpers/index.js.map +1 -0
- package/build/helpers/platform/types.d.ts +12 -0
- package/build/helpers/platform/types.d.ts.map +1 -0
- package/build/helpers/platform/types.js +2 -0
- package/build/helpers/platform/types.js.map +1 -0
- package/build/helpers/platform.d.ts +4 -0
- package/build/helpers/platform.d.ts.map +1 -0
- package/build/helpers/platform.js +17 -0
- package/build/helpers/platform.js.map +1 -0
- package/build/helpers/rule/types.d.ts +26 -0
- package/build/helpers/rule/types.d.ts.map +1 -0
- package/build/helpers/rule/types.js +2 -0
- package/build/helpers/rule/types.js.map +1 -0
- package/build/helpers/rule.d.ts +4 -0
- package/build/helpers/rule.d.ts.map +1 -0
- package/build/helpers/rule.js +71 -0
- package/build/helpers/rule.js.map +1 -0
- package/build/index.d.ts +8 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +6 -0
- package/build/index.js.map +1 -0
- package/build/protocols/types.d.ts +115 -0
- package/build/protocols/types.d.ts.map +1 -0
- package/build/protocols/types.js +2 -0
- package/build/protocols/types.js.map +1 -0
- package/build/protocols.d.ts +21 -0
- package/build/protocols.d.ts.map +1 -0
- package/build/protocols.js +70 -0
- package/build/protocols.js.map +1 -0
- package/build/schemas.d.ts +17 -0
- package/build/schemas.d.ts.map +1 -0
- package/build/schemas.js +140 -0
- package/build/schemas.js.map +1 -0
- package/build/types.d.ts +215 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/build/utils/aliases/types.d.ts +30 -0
- package/build/utils/aliases/types.d.ts.map +1 -0
- package/build/utils/aliases/types.js +2 -0
- package/build/utils/aliases/types.js.map +1 -0
- package/build/utils/aliases.d.ts +4 -0
- package/build/utils/aliases.d.ts.map +1 -0
- package/build/utils/aliases.js +26 -0
- package/build/utils/aliases.js.map +1 -0
- package/package.json +42 -4
- package/src/consts.local.ts +14 -0
- package/src/consts.ts +214 -0
- package/src/errors.ts +78 -0
- package/src/helpers/command/types.ts +14 -0
- package/src/helpers/command.ts +26 -0
- package/src/helpers/index.ts +6 -0
- package/src/helpers/platform/types.ts +12 -0
- package/src/helpers/platform.ts +23 -0
- package/src/helpers/rule/types.ts +26 -0
- package/src/helpers/rule.ts +89 -0
- package/src/index.ts +7 -0
- package/src/protocols/types.ts +107 -0
- package/src/protocols.ts +121 -0
- package/src/schemas.ts +159 -0
- package/src/types.ts +228 -0
- package/src/utils/aliases/types.ts +25 -0
- package/src/utils/aliases.ts +30 -0
- package/tests/command.spec.ts +23 -0
- package/tests/context.ts +13 -0
- package/tests/errors.spec.ts +41 -0
- package/tests/platforms.spec.ts +60 -0
- package/tests/protocols.spec.ts +80 -0
- package/tests/rule.spec.ts +129 -0
- package/tests/schemas.spec.ts +102 -0
- package/tests/tsconfig.json +12 -0
- package/tests/update.spec.ts +38 -0
- 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,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,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
|
+
}
|