@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/protocols.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { contract, openProtocol, protocol, typed } from '@owlmeans/entrypoint'
|
|
2
|
+
import { backend, route, RouteMethod, type RouteOptions } from '@owlmeans/route'
|
|
3
|
+
import {
|
|
4
|
+
BotActivityQuerySchema, BotChallengeQuerySchema, BotConnectionInputSchema, BotHookParamsSchema, BotIdParamsSchema,
|
|
5
|
+
BotRuleInputSchema, BotRuleListQuerySchema, BotSimulateInputSchema,
|
|
6
|
+
} from './schemas.js'
|
|
7
|
+
import { botAliasUtils } from './utils/aliases.js'
|
|
8
|
+
import type {
|
|
9
|
+
BotActivityQuery, BotBaseOptions, BotChallengeQuery, BotHookParams, BotHookPayload, BotHookProtocolOptions,
|
|
10
|
+
BotHookProtocols, BotHookReply, BotIdParams, BotProtocolOptions, BotProtocols, BotRemoveResult, BotRuleListQuery,
|
|
11
|
+
} from './protocols/types.js'
|
|
12
|
+
import type {
|
|
13
|
+
BotActivity, BotConnection, BotConnectionInput, BotRule, BotRuleInput, BotSimulateInput, BotSimulateResult,
|
|
14
|
+
} from './types.js'
|
|
15
|
+
|
|
16
|
+
const mountOf = (base: BotBaseOptions): Partial<RouteOptions> => ({
|
|
17
|
+
...(base.parent != null ? { parent: base.parent } : {}),
|
|
18
|
+
...(base.service != null ? { service: base.service } : {}),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Declare one managing surface of the bot — connections, rules, activity — as an immutable tree a
|
|
23
|
+
* server binds and a client calls.
|
|
24
|
+
*
|
|
25
|
+
* Every leaf hangs under ONE base that carries the guards and the gate, so they cannot drift apart;
|
|
26
|
+
* aliases derive from `opts.base.alias` (`<alias>:connection:list` …). A target declares one tree per
|
|
27
|
+
* area that runs bots (`api:bot:operator`, `api:bot:member`) — the same leaves, another gate.
|
|
28
|
+
*/
|
|
29
|
+
export const makeBotProtocols = (opts: BotProtocolOptions): BotProtocols => {
|
|
30
|
+
const aliases = botAliasUtils.botAliases(opts.base.alias)
|
|
31
|
+
const base = openProtocol(route(aliases.base, opts.base.path, backend(mountOf(opts.base))), {
|
|
32
|
+
...(opts.guards != null ? { guards: opts.guards } : {}),
|
|
33
|
+
...(opts.gates != null ? { gate: opts.gates } : {}),
|
|
34
|
+
})
|
|
35
|
+
const leaf = (alias: string, path: string, method: RouteMethod) =>
|
|
36
|
+
route(alias, path, backend({ parent: base }, method))
|
|
37
|
+
|
|
38
|
+
return Object.freeze({
|
|
39
|
+
base,
|
|
40
|
+
|
|
41
|
+
connection: Object.freeze({
|
|
42
|
+
list: protocol(
|
|
43
|
+
leaf(aliases.connection.list, '/connections', RouteMethod.GET),
|
|
44
|
+
contract(typed<BotConnection[]>())
|
|
45
|
+
),
|
|
46
|
+
save: protocol(
|
|
47
|
+
leaf(aliases.connection.save, '/connections', RouteMethod.POST),
|
|
48
|
+
contract.request({ body: typed<BotConnectionInput>(BotConnectionInputSchema) }, typed<BotConnection>())
|
|
49
|
+
),
|
|
50
|
+
verify: protocol(
|
|
51
|
+
leaf(aliases.connection.verify, '/connections/:id/verify', RouteMethod.POST),
|
|
52
|
+
contract.request({ params: typed<BotIdParams>(BotIdParamsSchema) }, typed<BotConnection>())
|
|
53
|
+
),
|
|
54
|
+
remove: protocol(
|
|
55
|
+
leaf(aliases.connection.remove, '/connections/:id', RouteMethod.DELETE),
|
|
56
|
+
contract.request({ params: typed<BotIdParams>(BotIdParamsSchema) }, typed<BotRemoveResult>())
|
|
57
|
+
),
|
|
58
|
+
simulate: protocol(
|
|
59
|
+
leaf(aliases.connection.simulate, '/connections/:id/simulate', RouteMethod.POST),
|
|
60
|
+
contract.request({
|
|
61
|
+
params: typed<BotIdParams>(BotIdParamsSchema),
|
|
62
|
+
body: typed<BotSimulateInput>(BotSimulateInputSchema),
|
|
63
|
+
}, typed<BotSimulateResult>())
|
|
64
|
+
),
|
|
65
|
+
}),
|
|
66
|
+
|
|
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
|
+
activity: Object.freeze({
|
|
83
|
+
list: protocol(
|
|
84
|
+
leaf(aliases.activity.list, '/activity', RouteMethod.GET),
|
|
85
|
+
contract.request({ query: typed<BotActivityQuery>(BotActivityQuerySchema) }, typed<BotActivity[]>())
|
|
86
|
+
),
|
|
87
|
+
}),
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Declare the platforms' inbound surface: `receive` (POST) and `challenge` (GET) at
|
|
93
|
+
* `/:platform/:connection` under the base.
|
|
94
|
+
*
|
|
95
|
+
* NO guard and no gate, by design: the caller is the platform, and it proves itself by signing the
|
|
96
|
+
* request (Telegram's secret token, WhatsApp's app-secret HMAC, Slack's signing secret), which the
|
|
97
|
+
* server verifies against the connection's credentials. The body carries no schema — the signature
|
|
98
|
+
* covers the raw bytes, and the payload is the platform's own.
|
|
99
|
+
*/
|
|
100
|
+
export const makeBotHookProtocols = (opts: BotHookProtocolOptions): BotHookProtocols => {
|
|
101
|
+
const aliases = botAliasUtils.botHookAliases(opts.base.alias)
|
|
102
|
+
const base = openProtocol(route(aliases.base, opts.base.path, backend(mountOf(opts.base))))
|
|
103
|
+
|
|
104
|
+
return Object.freeze({
|
|
105
|
+
base,
|
|
106
|
+
receive: protocol(
|
|
107
|
+
route(aliases.receive, '/:platform/:connection', backend({ parent: base }, RouteMethod.POST)),
|
|
108
|
+
contract.request({
|
|
109
|
+
params: typed<BotHookParams>(BotHookParamsSchema),
|
|
110
|
+
body: typed<BotHookPayload>(),
|
|
111
|
+
}, typed<BotHookReply>())
|
|
112
|
+
),
|
|
113
|
+
challenge: protocol(
|
|
114
|
+
route(aliases.challenge, '/:platform/:connection', backend({ parent: base }, RouteMethod.GET)),
|
|
115
|
+
contract.request({
|
|
116
|
+
params: typed<BotHookParams>(BotHookParamsSchema),
|
|
117
|
+
query: typed<BotChallengeQuery>(BotChallengeQuerySchema),
|
|
118
|
+
}, typed<string>())
|
|
119
|
+
),
|
|
120
|
+
})
|
|
121
|
+
}
|
package/src/schemas.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { JSONSchemaType } from 'ajv'
|
|
2
|
+
import {
|
|
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,
|
|
7
|
+
} 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'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 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.
|
|
17
|
+
*/
|
|
18
|
+
const cast = <T>(schema: object): JSONSchemaType<T> => schema as unknown as JSONSchemaType<T>
|
|
19
|
+
|
|
20
|
+
const Id = { type: 'string', minLength: 1, maxLength: BOT_ID_MAX, pattern: BOT_ID_PATTERN }
|
|
21
|
+
const Name = { type: 'string', maxLength: BOT_NAME_MAX }
|
|
22
|
+
/** A platform's own id (a chat, a user, a message) — no pattern: Telegram chat ids are negative numbers. */
|
|
23
|
+
const PlatformId = { type: 'string', minLength: 1, maxLength: BOT_ID_MAX }
|
|
24
|
+
|
|
25
|
+
export const BotIdParamsSchema = cast<BotIdParams>({
|
|
26
|
+
type: 'object',
|
|
27
|
+
properties: { id: Id },
|
|
28
|
+
required: ['id'],
|
|
29
|
+
additionalProperties: false,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export const BotConnectionInputSchema = cast<BotConnectionInput>({
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
id: Id,
|
|
36
|
+
platform: { type: 'string', enum: Object.values(BotPlatform) },
|
|
37
|
+
label: { type: 'string', minLength: 1, maxLength: BOT_LABEL_MAX },
|
|
38
|
+
transport: { type: 'string', enum: Object.values(BotTransport) },
|
|
39
|
+
enabled: { type: 'boolean' },
|
|
40
|
+
// Write-only. An omitted key keeps its stored value; an empty string is spellable so an optional
|
|
41
|
+
// credential can be cleared.
|
|
42
|
+
credentials: {
|
|
43
|
+
type: 'object',
|
|
44
|
+
propertyNames: { pattern: BOT_CREDENTIAL_KEY_PATTERN },
|
|
45
|
+
additionalProperties: { type: 'string', maxLength: BOT_CREDENTIAL_MAX },
|
|
46
|
+
maxProperties: BOT_CREDENTIALS_MAX,
|
|
47
|
+
required: [],
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
required: ['platform', 'label'],
|
|
51
|
+
additionalProperties: false,
|
|
52
|
+
})
|
|
53
|
+
|
|
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
|
+
export const BotSimulateInputSchema = cast<BotSimulateInput>({
|
|
78
|
+
type: 'object',
|
|
79
|
+
properties: {
|
|
80
|
+
text: { type: 'string', minLength: 1, maxLength: BOT_TEXT_MAX },
|
|
81
|
+
chat: {
|
|
82
|
+
type: 'object',
|
|
83
|
+
properties: {
|
|
84
|
+
id: PlatformId,
|
|
85
|
+
kind: { type: 'string', enum: Object.values(BotChatKind) },
|
|
86
|
+
title: Name,
|
|
87
|
+
thread: PlatformId,
|
|
88
|
+
space: PlatformId,
|
|
89
|
+
},
|
|
90
|
+
required: [],
|
|
91
|
+
additionalProperties: false,
|
|
92
|
+
},
|
|
93
|
+
sender: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties: {
|
|
96
|
+
id: PlatformId,
|
|
97
|
+
name: Name,
|
|
98
|
+
username: Name,
|
|
99
|
+
bot: { type: 'boolean' },
|
|
100
|
+
},
|
|
101
|
+
required: [],
|
|
102
|
+
additionalProperties: false,
|
|
103
|
+
},
|
|
104
|
+
replyTo: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: {
|
|
107
|
+
id: PlatformId,
|
|
108
|
+
text: { type: 'string', maxLength: BOT_TEXT_MAX },
|
|
109
|
+
},
|
|
110
|
+
required: ['id'],
|
|
111
|
+
additionalProperties: false,
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
required: ['text'],
|
|
115
|
+
additionalProperties: false,
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
export const BotRuleListQuerySchema = cast<BotRuleListQuery>({
|
|
119
|
+
type: 'object',
|
|
120
|
+
properties: { connection: Id },
|
|
121
|
+
required: [],
|
|
122
|
+
additionalProperties: false,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
/** A query string arrives as text: the server coerces `limit` before this checks it. */
|
|
126
|
+
export const BotActivityQuerySchema = cast<BotActivityQuery>({
|
|
127
|
+
type: 'object',
|
|
128
|
+
properties: {
|
|
129
|
+
connection: Id,
|
|
130
|
+
limit: { type: 'integer', minimum: 1, maximum: BOT_ACTIVITY_LIMIT_MAX },
|
|
131
|
+
},
|
|
132
|
+
required: [],
|
|
133
|
+
additionalProperties: false,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
export const BotHookParamsSchema = cast<BotHookParams>({
|
|
137
|
+
type: 'object',
|
|
138
|
+
properties: {
|
|
139
|
+
platform: { type: 'string', enum: Object.values(BotPlatform) },
|
|
140
|
+
connection: Id,
|
|
141
|
+
},
|
|
142
|
+
required: ['platform', 'connection'],
|
|
143
|
+
additionalProperties: false,
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The handshake query. Open (`additionalProperties: true`): a platform adds keys at will, and a
|
|
148
|
+
* stripping validator must never drop the one the server reads.
|
|
149
|
+
*/
|
|
150
|
+
export const BotChallengeQuerySchema = cast<BotChallengeQuery>({
|
|
151
|
+
type: 'object',
|
|
152
|
+
properties: {
|
|
153
|
+
'hub.mode': { type: 'string', maxLength: BOT_NAME_MAX },
|
|
154
|
+
'hub.verify_token': { type: 'string', maxLength: BOT_CREDENTIAL_MAX },
|
|
155
|
+
'hub.challenge': { type: 'string', maxLength: BOT_CREDENTIAL_MAX },
|
|
156
|
+
},
|
|
157
|
+
required: [],
|
|
158
|
+
additionalProperties: true,
|
|
159
|
+
})
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BotActionKind, BotChatKind, BotConnectionStatus, BotMatchMode, BotPlatform, BotReceiveMode, BotRuleAction,
|
|
3
|
+
BotTransport, BotUpdateKind,
|
|
4
|
+
} from './consts.js'
|
|
5
|
+
|
|
6
|
+
/** One credential a platform needs — what the panel draws an input for. */
|
|
7
|
+
export interface BotCredentialField {
|
|
8
|
+
key: string
|
|
9
|
+
/** Never shown back once saved; the connection only says the key holds a value. */
|
|
10
|
+
secret: boolean
|
|
11
|
+
/** The connection cannot be verified without it. */
|
|
12
|
+
required: boolean
|
|
13
|
+
/**
|
|
14
|
+
* The server creates it on save and nobody types it (WhatsApp's verify token); the connection's
|
|
15
|
+
* `setup` steps carry it for pasting into the platform console.
|
|
16
|
+
*/
|
|
17
|
+
generated?: boolean
|
|
18
|
+
/** Optional credential only: the platform calls it adds when set (Slack's admin `userToken`: `delete`). */
|
|
19
|
+
enables?: BotActionKind[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** What a platform needs and can do. One per platform, in `BOT_PLATFORMS`. */
|
|
23
|
+
export interface BotPlatformDescriptor {
|
|
24
|
+
platform: BotPlatform
|
|
25
|
+
title: string
|
|
26
|
+
credentials: BotCredentialField[]
|
|
27
|
+
/** The receive modes the platform offers, preferred first. */
|
|
28
|
+
receive: BotReceiveMode[]
|
|
29
|
+
/** The platform calls the required credentials allow; `note` is never listed. */
|
|
30
|
+
actions: BotActionKind[]
|
|
31
|
+
/** `auto`: the server registers the hook itself; `manual`: a person pastes `setup` into the console. */
|
|
32
|
+
setup: 'auto' | 'manual'
|
|
33
|
+
/** Where a person learns to create the bot and its credentials. */
|
|
34
|
+
docs: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A connection as any client sees it — never a credential. */
|
|
38
|
+
export interface BotConnection {
|
|
39
|
+
id: string
|
|
40
|
+
/** The organization (or application) key the connection belongs to — the server's, never the wire's. */
|
|
41
|
+
scope: string
|
|
42
|
+
platform: BotPlatform
|
|
43
|
+
label: string
|
|
44
|
+
transport: BotTransport
|
|
45
|
+
status: BotConnectionStatus
|
|
46
|
+
enabled: boolean
|
|
47
|
+
/** The receive mode in effect. */
|
|
48
|
+
receive: BotReceiveMode
|
|
49
|
+
/** Who the bot is on the platform, as verification answered. */
|
|
50
|
+
identity?: { id: string, username?: string, name?: string }
|
|
51
|
+
/** The address the platform posts to, for webhook platforms. */
|
|
52
|
+
hookUrl?: string
|
|
53
|
+
/** Manual steps: the URL / verify token to paste in the platform console. */
|
|
54
|
+
setup?: string[]
|
|
55
|
+
/** Which credential keys hold a value. */
|
|
56
|
+
credentialsSet: string[]
|
|
57
|
+
lastError?: string
|
|
58
|
+
lastEventAt?: string
|
|
59
|
+
createdAt: string
|
|
60
|
+
updatedAt: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A connection as a client writes it. `credentials` is write-only: an omitted key keeps its stored
|
|
65
|
+
* value.
|
|
66
|
+
*/
|
|
67
|
+
export interface BotConnectionInput {
|
|
68
|
+
id?: string
|
|
69
|
+
platform: BotPlatform
|
|
70
|
+
label: string
|
|
71
|
+
transport?: BotTransport
|
|
72
|
+
enabled?: boolean
|
|
73
|
+
credentials?: Record<string, string>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A chat; `space` is the Discord guild or the Slack team. */
|
|
77
|
+
export interface BotChatRef {
|
|
78
|
+
id: string
|
|
79
|
+
kind: BotChatKind
|
|
80
|
+
title?: string
|
|
81
|
+
thread?: string
|
|
82
|
+
space?: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface BotSenderRef {
|
|
86
|
+
id: string
|
|
87
|
+
name?: string
|
|
88
|
+
username?: string
|
|
89
|
+
bot?: boolean
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Enough to act on one message. `space` (the Discord guild / Slack team) is carried so a reference
|
|
94
|
+
* alone suffices where the platform addresses a member through it (a Discord timeout needs the guild).
|
|
95
|
+
*/
|
|
96
|
+
export interface BotMessageRef {
|
|
97
|
+
chat: string
|
|
98
|
+
id: string
|
|
99
|
+
thread?: string
|
|
100
|
+
space?: string
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The message an update carries. */
|
|
104
|
+
export interface BotReplyTarget extends BotMessageRef {
|
|
105
|
+
text?: string
|
|
106
|
+
sender?: BotSenderRef
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface BotMessage {
|
|
110
|
+
ref: BotMessageRef
|
|
111
|
+
text: string
|
|
112
|
+
date: string
|
|
113
|
+
replyTo?: BotReplyTarget
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A `/command`, its bot address stripped and its name lower-cased. */
|
|
117
|
+
export interface BotCommand {
|
|
118
|
+
name: string
|
|
119
|
+
args: string
|
|
120
|
+
}
|
|
121
|
+
|
|
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
|
+
/** A normalized inbound update, as a handler receives it. */
|
|
162
|
+
export interface BotUpdate {
|
|
163
|
+
id: string
|
|
164
|
+
connection: string
|
|
165
|
+
scope: string
|
|
166
|
+
platform: BotPlatform
|
|
167
|
+
kind: BotUpdateKind
|
|
168
|
+
chat: BotChatRef
|
|
169
|
+
sender?: BotSenderRef
|
|
170
|
+
message?: BotMessage
|
|
171
|
+
command?: BotCommand
|
|
172
|
+
/** A direct message, a mention of the bot, or a reply to it. */
|
|
173
|
+
addressed: boolean
|
|
174
|
+
/** Enabled rules of the scope (and connection) the text matches, by order. */
|
|
175
|
+
matches: BotMatch[]
|
|
176
|
+
receivedAt: string
|
|
177
|
+
raw?: unknown
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Whom an activity acted on. */
|
|
181
|
+
export interface BotActivityTarget {
|
|
182
|
+
chat: string
|
|
183
|
+
message?: string
|
|
184
|
+
user?: string
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** One thing the bot did — or tried to do — recorded whether it succeeded or not. */
|
|
188
|
+
export interface BotActivity {
|
|
189
|
+
id: string
|
|
190
|
+
scope: string
|
|
191
|
+
connection: string
|
|
192
|
+
platform: BotPlatform
|
|
193
|
+
/** The update it answered. */
|
|
194
|
+
update?: string
|
|
195
|
+
action: BotActionKind
|
|
196
|
+
target?: BotActivityTarget
|
|
197
|
+
text?: string
|
|
198
|
+
/** The rule it followed. */
|
|
199
|
+
rule?: string
|
|
200
|
+
ok: boolean
|
|
201
|
+
/** Why it failed, as a short sentence a person reads. */
|
|
202
|
+
error?: string
|
|
203
|
+
/**
|
|
204
|
+
* The machine marker of the failure (`bot:<marker>:<detail>`) — what code compares and what a
|
|
205
|
+
* translating view keys its copy on; never shown as is.
|
|
206
|
+
*/
|
|
207
|
+
code?: string
|
|
208
|
+
transport: BotTransport
|
|
209
|
+
at: string
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** A message fed to a MOCK connection through the platform's own path. */
|
|
213
|
+
export interface BotSimulateInput {
|
|
214
|
+
text: string
|
|
215
|
+
chat?: Partial<BotChatRef>
|
|
216
|
+
sender?: Partial<BotSenderRef>
|
|
217
|
+
replyTo?: { id: string, text?: string }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface BotSimulateResult {
|
|
221
|
+
/** The id of the update the simulated message became. */
|
|
222
|
+
update: string
|
|
223
|
+
/**
|
|
224
|
+
* `true` when the simulated payload's dedupe key was already in the inbox: `update` is that
|
|
225
|
+
* existing row and nothing new is dispatched. `false` (or absent) for a new row.
|
|
226
|
+
*/
|
|
227
|
+
duplicate?: boolean
|
|
228
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** The aliases one managing tree answers under — every one derived from the mount's base alias. */
|
|
2
|
+
export interface BotAliases {
|
|
3
|
+
readonly base: string
|
|
4
|
+
readonly connection: {
|
|
5
|
+
readonly list: string
|
|
6
|
+
readonly save: string
|
|
7
|
+
readonly verify: string
|
|
8
|
+
readonly remove: string
|
|
9
|
+
readonly simulate: string
|
|
10
|
+
}
|
|
11
|
+
readonly rule: { readonly list: string, readonly save: string, readonly remove: string }
|
|
12
|
+
readonly activity: { readonly list: string }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface BotHookAliases {
|
|
16
|
+
readonly base: string
|
|
17
|
+
readonly receive: string
|
|
18
|
+
readonly challenge: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface BotAliasUtils {
|
|
22
|
+
/** Two mounts in one deployment are two base aliases, never two copies of a table. */
|
|
23
|
+
botAliases: (base: string) => BotAliases
|
|
24
|
+
botHookAliases: (base: string) => BotHookAliases
|
|
25
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { BotAliases, BotAliasUtils, BotHookAliases } from './aliases/types.js'
|
|
2
|
+
|
|
3
|
+
export const createBotAliasUtils = (): BotAliasUtils => {
|
|
4
|
+
const botAliases = (base: string): BotAliases => Object.freeze({
|
|
5
|
+
base,
|
|
6
|
+
connection: Object.freeze({
|
|
7
|
+
list: `${base}:connection:list`,
|
|
8
|
+
save: `${base}:connection:save`,
|
|
9
|
+
verify: `${base}:connection:verify`,
|
|
10
|
+
remove: `${base}:connection:remove`,
|
|
11
|
+
simulate: `${base}:connection:simulate`,
|
|
12
|
+
}),
|
|
13
|
+
rule: Object.freeze({
|
|
14
|
+
list: `${base}:rule:list`,
|
|
15
|
+
save: `${base}:rule:save`,
|
|
16
|
+
remove: `${base}:rule:remove`,
|
|
17
|
+
}),
|
|
18
|
+
activity: Object.freeze({ list: `${base}:activity:list` }),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const botHookAliases = (base: string): BotHookAliases => Object.freeze({
|
|
22
|
+
base,
|
|
23
|
+
receive: `${base}:receive`,
|
|
24
|
+
challenge: `${base}:challenge`,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
return { botAliases, botHookAliases }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const botAliasUtils = createBotAliasUtils()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { botCommandHelper } from '../src/index.js'
|
|
3
|
+
|
|
4
|
+
describe('botCommandHelper.parse', () => {
|
|
5
|
+
test('reads a command addressed to this bot, its name lower-cased and its args trimmed', () => {
|
|
6
|
+
expect(botCommandHelper.parse('/report@MyBot some args', 'MyBot')).toEqual({ name: 'report', args: 'some args' })
|
|
7
|
+
expect(botCommandHelper.parse(' /Report@mybot spam here ', '@MYBOT')).toEqual({ name: 'report', args: 'spam here' })
|
|
8
|
+
expect(botCommandHelper.parse('/start')).toEqual({ name: 'start', args: '' })
|
|
9
|
+
expect(botCommandHelper.parse('/report\nline one\nline two', 'MyBot')).toEqual({ name: 'report', args: 'line one\nline two' })
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('ignores a command addressed to another bot, and accepts any address when its own name is unknown', () => {
|
|
13
|
+
expect(botCommandHelper.parse('/report@OtherBot spam', 'MyBot')).toBeNull()
|
|
14
|
+
expect(botCommandHelper.parse('/report@OtherBot spam')).toEqual({ name: 'report', args: 'spam' })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('answers null for text that is not a command', () => {
|
|
18
|
+
expect(botCommandHelper.parse('hello /report')).toBeNull()
|
|
19
|
+
expect(botCommandHelper.parse('/usr/bin is a path')).toBeNull()
|
|
20
|
+
expect(botCommandHelper.parse('/')).toBeNull()
|
|
21
|
+
expect(botCommandHelper.parse('/report@')).toBeNull()
|
|
22
|
+
})
|
|
23
|
+
})
|
package/tests/context.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import Ajv from 'ajv'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one validator the suite shares, configured as the server's HTTP boundary validates a request
|
|
5
|
+
* (`coerceTypes`, so a query string's `limit=50` is a number) but without stripping, so a refused
|
|
6
|
+
* extra key is SEEN refused instead of silently dropped.
|
|
7
|
+
*/
|
|
8
|
+
export const ajv = new Ajv({ coerceTypes: true, allErrors: true, strict: false })
|
|
9
|
+
|
|
10
|
+
export const validatorOf = (schema: object): ((value: unknown) => boolean) => {
|
|
11
|
+
const validate = ajv.compile(schema)
|
|
12
|
+
return (value: unknown) => validate(structuredClone(value)) === true
|
|
13
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { ResilientError } from '@owlmeans/error'
|
|
3
|
+
import * as errors from '../src/errors.js'
|
|
4
|
+
|
|
5
|
+
/** What an HTTP boundary reads off a class: a static `httpStatus`, absent on a fault. */
|
|
6
|
+
const statusOf = (error: Error): unknown => (error.constructor as { httpStatus?: unknown }).httpStatus
|
|
7
|
+
|
|
8
|
+
const DECLARED: Record<string, number | undefined> = {
|
|
9
|
+
BotError: undefined,
|
|
10
|
+
BotConnectionNotFound: 404,
|
|
11
|
+
BotCredentialsRejected: 422,
|
|
12
|
+
BotSignatureRejected: 401,
|
|
13
|
+
BotTransportMismatch: 409,
|
|
14
|
+
BotUnsupportedAction: 422,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const classes = Object.entries(errors).filter(([, value]) =>
|
|
18
|
+
typeof value === 'function' && (value === errors.BotError || value.prototype instanceof errors.BotError)
|
|
19
|
+
) as [string, new (message?: string) => ResilientError][]
|
|
20
|
+
|
|
21
|
+
describe('bot errors', () => {
|
|
22
|
+
test('every class has decided its status', () => {
|
|
23
|
+
expect(classes.map(([name]) => name).sort()).toEqual(Object.keys(DECLARED).sort())
|
|
24
|
+
for (const [name, Class] of classes) {
|
|
25
|
+
expect([name, statusOf(new Class('x'))]).toEqual([name, DECLARED[name]])
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('messages carry the family prefix and the class marker', () => {
|
|
30
|
+
expect(new errors.BotConnectionNotFound('conn_1').message).toBe('bot:connection-not-found:conn_1')
|
|
31
|
+
expect(new errors.BotTransportMismatch('live').message).toBe('bot:transport-mismatch:live')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('a refusal rebuilt from its marshalled form keeps its class and its status', () => {
|
|
35
|
+
for (const [name, Class] of classes) {
|
|
36
|
+
const back = ResilientError.ensure(new Class('x').marshal())
|
|
37
|
+
expect([name, back.constructor === Class, back instanceof errors.BotError, statusOf(back)])
|
|
38
|
+
.toEqual([name, true, true, DECLARED[name]])
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
})
|