@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
@@ -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
+ })
@@ -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
+ })