@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,60 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { BOT_PLATFORMS, BotActionKind, BotPlatform, BotReceiveMode, botPlatformHelper } from '../src/index.js'
3
+
4
+ const ACTIONS = new Set<string>(Object.values(BotActionKind))
5
+ const keysOf = (platform: BotPlatform, pick: (field: { required: boolean, generated?: boolean }) => boolean) =>
6
+ BOT_PLATFORMS[platform].credentials.filter(pick).map(field => field.key)
7
+
8
+ describe('BOT_PLATFORMS', () => {
9
+ test('describes every platform once, its actions all BotActionKinds and never note', () => {
10
+ expect(Object.keys(BOT_PLATFORMS).sort()).toEqual(Object.values(BotPlatform).sort())
11
+ for (const platform of Object.values(BotPlatform)) {
12
+ const descriptor = BOT_PLATFORMS[platform]
13
+ expect(descriptor.platform).toBe(platform)
14
+ expect(descriptor.actions.every(action => ACTIONS.has(action))).toBe(true)
15
+ expect(descriptor.actions).not.toContain(BotActionKind.Note)
16
+ expect(descriptor.credentials.flatMap(field => field.enables ?? []).every(action => ACTIONS.has(action))).toBe(true)
17
+ expect(descriptor.receive.length).toBeGreaterThan(0)
18
+ expect(descriptor.docs.startsWith('https://')).toBe(true)
19
+ }
20
+ })
21
+
22
+ test('names the credentials each platform requires', () => {
23
+ expect(keysOf(BotPlatform.Telegram, field => field.required)).toEqual(['token'])
24
+ expect(keysOf(BotPlatform.WhatsApp, field => field.required)).toEqual(['accessToken', 'phoneNumberId', 'appSecret', 'verifyToken'])
25
+ expect(keysOf(BotPlatform.WhatsApp, field => field.generated === true)).toEqual(['verifyToken'])
26
+ expect(keysOf(BotPlatform.Discord, field => field.required)).toEqual(['token', 'applicationId'])
27
+ expect(keysOf(BotPlatform.Slack, field => field.required)).toEqual(['botToken', 'signingSecret'])
28
+ expect(keysOf(BotPlatform.Slack, field => !field.required)).toEqual(['userToken'])
29
+ })
30
+
31
+ test('receives as the contract says and is frozen', () => {
32
+ expect(BOT_PLATFORMS[BotPlatform.Telegram].receive).toEqual([BotReceiveMode.Webhook, BotReceiveMode.Poll])
33
+ expect(BOT_PLATFORMS[BotPlatform.WhatsApp].receive).toEqual([BotReceiveMode.Webhook])
34
+ expect(BOT_PLATFORMS[BotPlatform.Discord].receive).toEqual([BotReceiveMode.Gateway])
35
+ expect(BOT_PLATFORMS[BotPlatform.Slack].receive).toEqual([BotReceiveMode.Webhook])
36
+ expect(Object.isFrozen(BOT_PLATFORMS) && Object.isFrozen(BOT_PLATFORMS[BotPlatform.Slack].credentials)).toBe(true)
37
+ })
38
+ })
39
+
40
+ describe('botPlatformHelper', () => {
41
+ test('WhatsApp cannot delete, mute or ban; Telegram and Discord moderate fully; note is always supported', () => {
42
+ for (const kind of [BotActionKind.Delete, BotActionKind.Mute, BotActionKind.Ban]) {
43
+ expect(botPlatformHelper.supports(BotPlatform.WhatsApp, kind)).toBe(false)
44
+ expect(botPlatformHelper.supports(BotPlatform.Telegram, kind)).toBe(true)
45
+ expect(botPlatformHelper.supports(BotPlatform.Discord, kind)).toBe(true)
46
+ }
47
+ for (const platform of Object.values(BotPlatform)) {
48
+ expect(botPlatformHelper.supports(platform, BotActionKind.Note)).toBe(true)
49
+ expect(botPlatformHelper.supports(platform, BotActionKind.Reply)).toBe(true)
50
+ }
51
+ })
52
+
53
+ test('Slack deletes only with the admin userToken set', () => {
54
+ expect(botPlatformHelper.supports(BotPlatform.Slack, BotActionKind.Delete, ['botToken', 'signingSecret'])).toBe(false)
55
+ expect(botPlatformHelper.supports(BotPlatform.Slack, BotActionKind.Delete, ['botToken', 'signingSecret', 'userToken'])).toBe(true)
56
+ expect(botPlatformHelper.actionsOf(BotPlatform.Slack, ['userToken']).sort())
57
+ .toEqual([BotActionKind.Reply, BotActionKind.Send, BotActionKind.React, BotActionKind.Delete, BotActionKind.Note].sort())
58
+ expect(botPlatformHelper.supports(BotPlatform.Slack, BotActionKind.Mute, ['userToken'])).toBe(false)
59
+ })
60
+ })
@@ -0,0 +1,80 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { protocols } from '@owlmeans/entrypoint'
3
+ import { RouteMethod } from '@owlmeans/route'
4
+ import { makeBotHookProtocols, makeBotProtocols } from '../src/index.js'
5
+
6
+ const BASE = 'api:bot:operator'
7
+ const gate = { alias: 'gate:oidc', params: ['operator'] }
8
+
9
+ describe('makeBotProtocols', () => {
10
+ const tree = makeBotProtocols({ base: { alias: BASE, path: '/bot/operator' }, guards: 'guard:default', gates: gate })
11
+
12
+ test('declares exactly the leaves of the contract, each alias derived from the base alias', () => {
13
+ const declared = protocols(tree as never).map(protocol => protocol.alias)
14
+
15
+ expect(declared.sort()).toEqual([
16
+ BASE,
17
+ `${BASE}:connection:list`, `${BASE}:connection:save`, `${BASE}:connection:verify`,
18
+ `${BASE}:connection:remove`, `${BASE}:connection:simulate`,
19
+ `${BASE}:rule:list`, `${BASE}:rule:save`, `${BASE}:rule:remove`,
20
+ `${BASE}:activity:list`,
21
+ ].sort())
22
+ })
23
+
24
+ test('every leaf has its method and path, hanging under the base', () => {
25
+ const routes = [
26
+ [tree.connection.list, RouteMethod.GET, '/connections'],
27
+ [tree.connection.save, RouteMethod.POST, '/connections'],
28
+ [tree.connection.verify, RouteMethod.POST, '/connections/:id/verify'],
29
+ [tree.connection.remove, RouteMethod.DELETE, '/connections/:id'],
30
+ [tree.connection.simulate, RouteMethod.POST, '/connections/:id/simulate'],
31
+ [tree.rule.list, RouteMethod.GET, '/rules'],
32
+ [tree.rule.save, RouteMethod.POST, '/rules'],
33
+ [tree.rule.remove, RouteMethod.DELETE, '/rules/:id'],
34
+ [tree.activity.list, RouteMethod.GET, '/activity'],
35
+ ] as const
36
+
37
+ for (const [leaf, method, path] of routes) {
38
+ expect([leaf.alias, leaf.route.route.method, leaf.route.route.path, leaf.route.route.parent])
39
+ .toEqual([leaf.alias, method, path, BASE])
40
+ }
41
+ expect(tree.connection.save.contract?.requestSchemas.body).toBeDefined()
42
+ expect(tree.connection.simulate.contract?.requestSchemas.params).toBeDefined()
43
+ expect(tree.activity.list.contract?.requestSchemas.query).toBeDefined()
44
+ })
45
+
46
+ test('the guards and the gate sit on the base alone; the tree is frozen', () => {
47
+ expect(tree.base.guards).toEqual(['guard:default'])
48
+ expect(tree.base.gate).toEqual(gate)
49
+ expect(tree.base.route.route.path).toBe('/bot/operator')
50
+ expect(tree.rule.list.guards).toEqual([])
51
+ expect(tree.rule.list.gate).toBeUndefined()
52
+ expect(Object.isFrozen(tree) && Object.isFrozen(tree.connection) && Object.isFrozen(tree.rule.save)).toBe(true)
53
+ })
54
+
55
+ test('two mounts are two alias families, and parent and service reach the base', () => {
56
+ const member = makeBotProtocols({ base: { alias: 'api:bot:member', path: '/bot/member', parent: 'api', service: 'api' } })
57
+
58
+ expect(member.connection.list.alias).toBe('api:bot:member:connection:list')
59
+ expect(member.base.route.route).toMatchObject({ parent: 'api', service: 'api' })
60
+ expect(member.base.guards).toEqual([])
61
+ })
62
+ })
63
+
64
+ describe('makeBotHookProtocols', () => {
65
+ const hook = makeBotHookProtocols({ base: { alias: 'api:bot:hook', path: '/bot/hook' } })
66
+
67
+ test('receive is POST and challenge GET, both at /:platform/:connection', () => {
68
+ expect(protocols(hook as never).map(protocol => protocol.alias).sort())
69
+ .toEqual(['api:bot:hook', 'api:bot:hook:challenge', 'api:bot:hook:receive'])
70
+ expect(hook.receive.route.route).toMatchObject({ method: RouteMethod.POST, path: '/:platform/:connection', parent: 'api:bot:hook' })
71
+ expect(hook.challenge.route.route).toMatchObject({ method: RouteMethod.GET, path: '/:platform/:connection', parent: 'api:bot:hook' })
72
+ })
73
+
74
+ test('carries no guard and no gate anywhere — the platform signs', () => {
75
+ for (const protocol of protocols(hook as never)) {
76
+ expect([protocol.alias, protocol.guards, protocol.gate]).toEqual([protocol.alias, [], undefined])
77
+ }
78
+ expect(hook.receive.contract?.requestSchemas.body).toBeUndefined()
79
+ })
80
+ })
@@ -0,0 +1,129 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { BOT_KEYWORD_MAX, BOT_KEYWORDS_MAX, BotMatchMode, BotRuleAction, botRuleHelper, type BotRule } from '../src/index.js'
3
+
4
+ const at = '2026-10-26T10:00:00.000Z'
5
+
6
+ const rule = (id: string, keywords: string[], extra: Partial<BotRule> = {}): BotRule => ({
7
+ id,
8
+ scope: 'org-1',
9
+ connection: null,
10
+ name: id,
11
+ keywords,
12
+ mode: BotMatchMode.Word,
13
+ action: BotRuleAction.Delete,
14
+ enabled: true,
15
+ order: 0,
16
+ createdAt: at,
17
+ updatedAt: at,
18
+ ...extra,
19
+ })
20
+
21
+ const ids = (text: string, rules: BotRule[]): string[] => botRuleHelper.match(text, rules).map(found => found.rule.id)
22
+
23
+ describe('botRuleHelper.match — word mode', () => {
24
+ test('matches whole words only, case-insensitively and across punctuation', () => {
25
+ const spam = rule('spam', ['casino'])
26
+
27
+ expect(ids('Visit the CASINO!', [spam])).toEqual(['spam'])
28
+ expect(ids('casino, now', [spam])).toEqual(['spam'])
29
+ expect(ids('casinos are fun', [spam])).toEqual([])
30
+ expect(ids('megacasino', [spam])).toEqual([])
31
+ })
32
+
33
+ test('a multi-word keyword is a phrase of consecutive words', () => {
34
+ const offer = rule('offer', ['free money'])
35
+
36
+ expect(ids('Get FREE money now', [offer])).toEqual(['offer'])
37
+ expect(ids('free—money', [offer])).toEqual(['offer'])
38
+ expect(ids('money for free', [offer])).toEqual([])
39
+ expect(ids('free real money', [offer])).toEqual([])
40
+ })
41
+
42
+ test('is Unicode-aware: Cyrillic, accents, compatibility forms and the German sharp s', () => {
43
+ expect(ids('Купите СПАМ сейчас', [rule('ru', ['спам'])])).toEqual(['ru'])
44
+ expect(ids('ёлка', [rule('yo', ['ЁЛКА'])])).toEqual(['yo'])
45
+ expect(ids('Un café, por favor', [rule('es', ['CAFÉ'])])).toEqual(['es'])
46
+ // Accents are kept: "cafe" is not "café".
47
+ expect(ids('Un café', [rule('plain', ['cafe'])])).toEqual([])
48
+ // NFKC: the full-width form and the ligature fold onto plain letters; NFD text onto NFC.
49
+ expect(ids('CASINO night', [rule('wide', ['casino'])])).toEqual(['wide'])
50
+ expect(ids('finance tips', [rule('lig', ['finance'])])).toEqual(['lig'])
51
+ expect(ids('café au lait', [rule('nfd', ['café'])])).toEqual(['nfd'])
52
+ expect(ids('STRASSE gesperrt', [rule('de', ['straße'])])).toEqual(['de'])
53
+ })
54
+
55
+ test('a keyword with no letters or digits never matches a word', () => {
56
+ expect(ids('!!! now', [rule('bang', ['!!!'])])).toEqual([])
57
+ })
58
+ })
59
+
60
+ describe('botRuleHelper.match — contains mode', () => {
61
+ test('matches a folded substring, inside words too, with whitespace runs collapsed', () => {
62
+ const inside = rule('inside', ['casino'], { mode: BotMatchMode.Contains })
63
+ const punct = rule('punct', ['!!!'], { mode: BotMatchMode.Contains })
64
+ const spaced = rule('spaced', ['free money'], { mode: BotMatchMode.Contains })
65
+
66
+ expect(ids('MEGACASINO.com', [inside])).toEqual(['inside'])
67
+ expect(ids('wow!!! ok', [punct])).toEqual(['punct'])
68
+ expect(ids('get free\n money', [spaced])).toEqual(['spaced'])
69
+ expect(ids('no match here', [inside, punct, spaced])).toEqual([])
70
+ })
71
+ })
72
+
73
+ describe('botRuleHelper.match — groups', () => {
74
+ test('skips disabled rules and answers [] for empty text', () => {
75
+ const off = rule('off', ['spam'], { enabled: false })
76
+
77
+ expect(ids('spam', [off])).toEqual([])
78
+ expect(ids(' ', [rule('on', ['spam'])])).toEqual([])
79
+ })
80
+
81
+ test('orders matches by rule order, ties keeping the given order', () => {
82
+ const rules = [
83
+ rule('third', ['spam'], { order: 3 }),
84
+ rule('first', ['spam'], { order: 1 }),
85
+ rule('tie-a', ['spam'], { order: 2 }),
86
+ rule('tie-b', ['spam'], { order: 2 }),
87
+ ]
88
+
89
+ expect(ids('spam', rules)).toEqual(['first', 'tie-a', 'tie-b', 'third'])
90
+ })
91
+
92
+ test('reports every group a message matches, each with its own matched keywords as stored', () => {
93
+ const abuse = rule('abuse', ['Idiot', 'stupid', 'moron'], { order: 1, action: BotRuleAction.Warn })
94
+ const hours = rule('hours', ['opening hours', 'open'], { order: 2, action: BotRuleAction.Reply })
95
+ const quiet = rule('quiet', ['delivery'], { order: 0 })
96
+ const found = botRuleHelper.match('You idiot, what are the OPENING hours? stupid!', [hours, quiet, abuse])
97
+
98
+ expect(found.map(match => [match.rule.id, match.keywords])).toEqual([
99
+ ['abuse', ['Idiot', 'stupid']],
100
+ ['hours', ['opening hours']],
101
+ ])
102
+ })
103
+ })
104
+
105
+ describe('botRuleHelper.normalizeKeywords', () => {
106
+ test('trims, collapses inner whitespace, drops empties and folded duplicates keeping the first spelling', () => {
107
+ expect(botRuleHelper.normalizeKeywords([' Free money ', '', ' ', 'free MONEY', 'Спам', 'СПАМ', 'café']))
108
+ .toEqual(['Free money', 'Спам', 'café'])
109
+ })
110
+
111
+ test('caps the keyword length (in code points) and the list size', () => {
112
+ const long = '😀'.repeat(BOT_KEYWORD_MAX + 10)
113
+ const [capped] = botRuleHelper.normalizeKeywords([long])
114
+ const many = botRuleHelper.normalizeKeywords(Array.from({ length: BOT_KEYWORDS_MAX + 5 }, (_, n) => `kw${n}`))
115
+
116
+ expect(Array.from(capped)).toHaveLength(BOT_KEYWORD_MAX)
117
+ expect(many).toHaveLength(BOT_KEYWORDS_MAX)
118
+ expect(many[0]).toBe('kw0')
119
+ })
120
+ })
121
+
122
+ describe('botRuleHelper.appliesTo', () => {
123
+ test('a scope-wide rule applies to every connection, an own one to its connection only', () => {
124
+ expect(botRuleHelper.appliesTo({ connection: null }, 'c1')).toBe(true)
125
+ expect(botRuleHelper.appliesTo({}, 'c1')).toBe(true)
126
+ expect(botRuleHelper.appliesTo({ connection: 'c1' }, 'c1')).toBe(true)
127
+ expect(botRuleHelper.appliesTo({ connection: 'c2' }, 'c1')).toBe(false)
128
+ })
129
+ })
@@ -0,0 +1,102 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ BOT_ACTIVITY_LIMIT_MAX, BOT_KEYWORDS_MAX, BotActivityQuerySchema, BotChallengeQuerySchema, BotConnectionInputSchema,
4
+ BotHookParamsSchema, BotIdParamsSchema, BotRuleInputSchema, BotRuleListQuerySchema, BotSimulateInputSchema,
5
+ } from '../src/index.js'
6
+ import { validatorOf } from './context.js'
7
+
8
+ describe('connection input', () => {
9
+ const valid = validatorOf(BotConnectionInputSchema)
10
+
11
+ test('accepts a minimal and a full input, credentials write-only and clearable', () => {
12
+ expect(valid({ platform: 'telegram', label: 'Community' })).toBe(true)
13
+ expect(valid({
14
+ id: 'conn_1', platform: 'slack', label: 'Office', transport: 'mock', enabled: true,
15
+ credentials: { botToken: 'xoxb-1', signingSecret: 's', userToken: '' },
16
+ })).toBe(true)
17
+ })
18
+
19
+ test('refuses an unknown platform, an empty label, a stray key and a malformed credential key', () => {
20
+ expect(valid({ platform: 'viber', label: 'x' })).toBe(false)
21
+ expect(valid({ platform: 'telegram', label: '' })).toBe(false)
22
+ expect(valid({ platform: 'telegram', label: 'x', scope: 'org-1' })).toBe(false)
23
+ expect(valid({ platform: 'telegram', label: 'x', credentials: { 'bad key': 'v' } })).toBe(false)
24
+ expect(valid({ platform: 'telegram', label: 'x', credentials: { token: { value: 'v' } } })).toBe(false)
25
+ })
26
+ })
27
+
28
+ describe('rule input', () => {
29
+ const valid = validatorOf(BotRuleInputSchema)
30
+
31
+ test('accepts a rule with keywords and an action, scope-wide (null) or bound to a connection', () => {
32
+ expect(valid({ name: 'Spam', keywords: ['casino'], action: 'delete' })).toBe(true)
33
+ expect(valid({
34
+ id: 'r1', connection: null, name: 'Hours', keywords: ['open', 'hours'], mode: 'contains',
35
+ action: 'reply', reply: 'We open at 9.', instruction: 'Be brief.', enabled: false, order: 2.5,
36
+ })).toBe(true)
37
+ expect(valid({ connection: 'conn_1', name: 'x', keywords: ['k'], action: 'agent' })).toBe(true)
38
+ })
39
+
40
+ test('refuses no keywords, too many, an unknown action or mode, and a missing name', () => {
41
+ expect(valid({ name: 'x', keywords: [], action: 'delete' })).toBe(false)
42
+ expect(valid({ name: 'x', keywords: Array.from({ length: BOT_KEYWORDS_MAX + 1 }, (_, n) => `k${n}`), action: 'delete' })).toBe(false)
43
+ expect(valid({ name: 'x', keywords: ['k'], action: 'kick' })).toBe(false)
44
+ expect(valid({ name: 'x', keywords: ['k'], action: 'delete', mode: 'regex' })).toBe(false)
45
+ expect(valid({ keywords: ['k'], action: 'delete' })).toBe(false)
46
+ })
47
+ })
48
+
49
+ describe('simulate input', () => {
50
+ const valid = validatorOf(BotSimulateInputSchema)
51
+
52
+ test('accepts text with optional partial chat, sender and reply target — platform ids unconstrained', () => {
53
+ expect(valid({ text: 'hello' })).toBe(true)
54
+ expect(valid({
55
+ text: 'buy now', chat: { id: '-1001234', kind: 'group', space: 'T123' },
56
+ sender: { id: '42', username: 'member' }, replyTo: { id: '17', text: 'earlier' },
57
+ })).toBe(true)
58
+ })
59
+
60
+ test('refuses empty text, an unknown chat kind and a reply target without id', () => {
61
+ expect(valid({ text: '' })).toBe(false)
62
+ expect(valid({ text: 'x', chat: { kind: 'forum' } })).toBe(false)
63
+ expect(valid({ text: 'x', replyTo: { text: 'earlier' } })).toBe(false)
64
+ })
65
+ })
66
+
67
+ describe('params and queries', () => {
68
+ test('ids are path-safe', () => {
69
+ const valid = validatorOf(BotIdParamsSchema)
70
+
71
+ expect(valid({ id: 'conn_1' })).toBe(true)
72
+ expect(valid({ id: '../etc' })).toBe(false)
73
+ expect(valid({ id: '' })).toBe(false)
74
+ })
75
+
76
+ test('the activity limit is a whole number from 1 to the cap, coerced from a query string', () => {
77
+ const valid = validatorOf(BotActivityQuerySchema)
78
+
79
+ expect(valid({})).toBe(true)
80
+ expect(valid({ connection: 'conn_1', limit: '50' })).toBe(true)
81
+ expect(valid({ limit: BOT_ACTIVITY_LIMIT_MAX })).toBe(true)
82
+ expect(valid({ limit: BOT_ACTIVITY_LIMIT_MAX + 1 })).toBe(false)
83
+ expect(valid({ limit: 0 })).toBe(false)
84
+ expect(valid({ limit: 1.5 })).toBe(false)
85
+ })
86
+
87
+ test('the rule list filters by connection only', () => {
88
+ const valid = validatorOf(BotRuleListQuerySchema)
89
+
90
+ expect(valid({ connection: 'conn_1' })).toBe(true)
91
+ expect(valid({ scope: 'org-1' })).toBe(false)
92
+ })
93
+
94
+ test('the hook names a known platform and a path-safe connection; its handshake query stays open', () => {
95
+ const params = validatorOf(BotHookParamsSchema)
96
+ const query = validatorOf(BotChallengeQuerySchema)
97
+
98
+ expect(params({ platform: 'whatsapp', connection: 'conn_1' })).toBe(true)
99
+ expect(params({ platform: 'viber', connection: 'conn_1' })).toBe(false)
100
+ expect(query({ 'hub.mode': 'subscribe', 'hub.verify_token': 't', 'hub.challenge': '123', other: 'x' })).toBe(true)
101
+ })
102
+ })
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": [
3
+ "@owlmeans/dep-config/tsconfig.base.json",
4
+ "@owlmeans/dep-config/tsconfig.node.json"
5
+ ],
6
+ "compilerOptions": {
7
+ "types": ["bun"],
8
+ "rootDir": "../",
9
+ "noEmit": true
10
+ },
11
+ "include": ["./**/*", "../src/**/*"]
12
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ BotChatKind, BotMatchMode, BotPlatform, BotRuleAction, BotUpdateKind, botCommandHelper, botRuleHelper,
4
+ type BotMessageRef, type BotRule, type BotUpdate,
5
+ } from '../src/index.js'
6
+
7
+ const at = '2026-10-26T10:00:00.000Z'
8
+
9
+ const rules: BotRule[] = [
10
+ { id: 'r-other', scope: 'org-1', connection: 'conn_2', name: 'Other', keywords: ['raid'], mode: BotMatchMode.Word, action: BotRuleAction.Ban, enabled: true, order: 0, createdAt: at, updatedAt: at },
11
+ { id: 'r-all', scope: 'org-1', connection: null, name: 'Raid', keywords: ['raid'], mode: BotMatchMode.Word, action: BotRuleAction.Mute, enabled: true, order: 1, createdAt: at, updatedAt: at },
12
+ ]
13
+
14
+ describe('a normalized update, assembled the way a runtime does', () => {
15
+ test('its matches come from the rules that apply to its connection, and its message ref alone addresses the message', () => {
16
+ const ref: BotMessageRef = { chat: 'ch-1', id: 'm-1', thread: 't-1', space: 'guild-9' }
17
+ const text = '/report@ModBot RAID incoming'
18
+ const update: BotUpdate = {
19
+ id: 'u-1',
20
+ connection: 'conn_1',
21
+ scope: 'org-1',
22
+ platform: BotPlatform.Discord,
23
+ kind: BotUpdateKind.Command,
24
+ chat: { id: 'ch-1', kind: BotChatKind.Group, space: 'guild-9' },
25
+ sender: { id: 'user-5', username: 'member' },
26
+ message: { ref, text, date: at },
27
+ command: botCommandHelper.parse(text, 'ModBot') ?? undefined,
28
+ addressed: true,
29
+ matches: botRuleHelper.match(text, rules.filter(rule => botRuleHelper.appliesTo(rule, 'conn_1'))),
30
+ receivedAt: at,
31
+ }
32
+
33
+ expect(update.command).toEqual({ name: 'report', args: 'RAID incoming' })
34
+ expect(update.matches.map(found => [found.rule.id, found.keywords])).toEqual([['r-all', ['raid']]])
35
+ // A Discord timeout needs the guild: the reference carries it, so nothing else is consulted.
36
+ expect(update.message?.ref).toEqual({ chat: 'ch-1', id: 'm-1', thread: 't-1', space: 'guild-9' })
37
+ })
38
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": ["@owlmeans/dep-config/tsconfig.base.json"],
3
+ "compilerOptions": {
4
+ "rootDir": "./src/",
5
+ "outDir": "./build/"
6
+ },
7
+ "exclude": ["./dist/**/*", "./build/**/*", "./tests/**/*", "./*.ts"]
8
+ }