@texisoft/nexus-plugin-testkit 0.0.0-stage → 0.1.0
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/LICENSE +78 -0
- package/README.md +38 -2
- package/package.json +42 -4
- package/src/fixtures.js +74 -0
- package/src/harness.js +499 -0
- package/src/index.js +7 -0
- package/src/project.js +32 -0
- package/src/runner.js +241 -0
- package/src/storage.js +87 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
TEXISOFT PROPRIETARY LICENSE
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 texisoft. All rights reserved.
|
|
4
|
+
|
|
5
|
+
This software, including its source code, documentation and associated
|
|
6
|
+
materials (the "Software"), is proprietary to texisoft ("Licensor"). It is made
|
|
7
|
+
available on public package registries for distribution convenience only.
|
|
8
|
+
Publication does NOT grant an open-source license and does NOT transfer or
|
|
9
|
+
waive any right of the Licensor.
|
|
10
|
+
|
|
11
|
+
1. Limited permission
|
|
12
|
+
Subject to this license, you may install and run the Software solely to
|
|
13
|
+
develop, test, build and publish plugins for the Nexus Discord bot platform
|
|
14
|
+
operated by the Licensor ("Nexus"), and only for as long as you comply with
|
|
15
|
+
this license. This permission is personal, non-exclusive, non-transferable,
|
|
16
|
+
non-sublicensable and revocable at any time.
|
|
17
|
+
|
|
18
|
+
2. Restrictions
|
|
19
|
+
Except where applicable law expressly forbids the restriction, you may not:
|
|
20
|
+
a) copy, modify, adapt, translate or create derivative works of the Software;
|
|
21
|
+
b) redistribute, publish, sell, rent, lease, sublicense or otherwise make
|
|
22
|
+
the Software or any part of it available to third parties, whether
|
|
23
|
+
modified or not, including re-publishing it to any package registry or
|
|
24
|
+
repository;
|
|
25
|
+
c) decompile, reverse engineer or attempt to derive source code, algorithms
|
|
26
|
+
or the sandbox, permission or protocol design, except for the source
|
|
27
|
+
that is delivered in readable form with the package, which remains
|
|
28
|
+
subject to this license;
|
|
29
|
+
d) use the Software, or knowledge gained from it, to build, operate or
|
|
30
|
+
support a platform, service or product that competes with or replaces
|
|
31
|
+
Nexus, or that is compatible with Nexus plugins;
|
|
32
|
+
e) circumvent, disable or probe the security, sandbox, permission, rate
|
|
33
|
+
limit, review or consent mechanisms of Nexus, or use the Software to
|
|
34
|
+
attack, overload or gain unauthorized access to Nexus or any third
|
|
35
|
+
party;
|
|
36
|
+
f) remove or alter copyright, license or attribution notices;
|
|
37
|
+
g) use the Software to train, fine-tune or evaluate machine-learning models,
|
|
38
|
+
or to build datasets for that purpose.
|
|
39
|
+
|
|
40
|
+
3. Your plugins
|
|
41
|
+
Plugins you write remain yours. Submitting a plugin to Nexus is governed by
|
|
42
|
+
the Nexus developer terms, not by this license. Bundled portions of the
|
|
43
|
+
Software that the build tooling inserts into your plugin may be distributed
|
|
44
|
+
only as part of that plugin and only to Nexus.
|
|
45
|
+
|
|
46
|
+
4. Ownership
|
|
47
|
+
The Software is licensed, not sold. The Licensor retains all right, title
|
|
48
|
+
and interest, including all intellectual property rights.
|
|
49
|
+
|
|
50
|
+
5. Termination
|
|
51
|
+
This license ends automatically and without notice if you breach it. On
|
|
52
|
+
termination you must stop using the Software and delete all copies. The
|
|
53
|
+
Licensor may also withdraw this license at any time by publishing a notice
|
|
54
|
+
or removing the Software.
|
|
55
|
+
|
|
56
|
+
6. No warranty
|
|
57
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
58
|
+
IMPLIED, INCLUDING MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE,
|
|
59
|
+
NON-INFRINGEMENT, AVAILABILITY OR COMPATIBILITY WITH ANY FUTURE VERSION. THE
|
|
60
|
+
SOFTWARE IS A PRE-1.0 RELEASE AND ITS INTERFACES MAY CHANGE WITHOUT NOTICE.
|
|
61
|
+
|
|
62
|
+
7. Limitation of liability
|
|
63
|
+
TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE LICENSOR IS NOT LIABLE FOR ANY
|
|
64
|
+
INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL OR PUNITIVE DAMAGES, LOSS OF
|
|
65
|
+
DATA, PROFITS OR GOODWILL, ARISING FROM OR RELATED TO THE SOFTWARE. THE
|
|
66
|
+
LICENSOR'S TOTAL LIABILITY IS LIMITED TO EUR 100, EXCEPT FOR LIABILITY THAT
|
|
67
|
+
CANNOT BE EXCLUDED UNDER APPLICABLE LAW (INTENT, GROSS NEGLIGENCE, INJURY
|
|
68
|
+
TO LIFE, BODY OR HEALTH, AND LIABILITY UNDER THE PRODUCT LIABILITY ACT).
|
|
69
|
+
|
|
70
|
+
8. Governing law
|
|
71
|
+
This license is governed by the laws of the Federal Republic of Germany,
|
|
72
|
+
excluding its conflict-of-laws rules and the UN Convention on Contracts for
|
|
73
|
+
the International Sale of Goods. If you are a merchant or a legal entity,
|
|
74
|
+
the courts at the Licensor's registered seat have exclusive jurisdiction.
|
|
75
|
+
|
|
76
|
+
9. Severability
|
|
77
|
+
If a provision is invalid, the remaining provisions stay in force and the
|
|
78
|
+
invalid provision is replaced by the closest valid one.
|
package/README.md
CHANGED
|
@@ -1,3 +1,39 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @texisoft/nexus-plugin-testkit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Zum Testen von Nexus-Plugins, ohne dass Bot, Server oder Discord laufen. Das Testkit simuliert Events, Commands, Interaktionen, Jobs, Speicher, HTTP und Discord und hält sich dabei an dieselben Regeln wie die Sandbox in Produktion.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install --save-dev @texisoft/nexus-plugin-testkit
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Beispiel
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
import { test } from 'node:test'
|
|
13
|
+
import assert from 'node:assert/strict'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
import { createProjectHarness } from '@texisoft/nexus-plugin-testkit'
|
|
16
|
+
|
|
17
|
+
const dir = fileURLToPath(new URL('..', import.meta.url))
|
|
18
|
+
|
|
19
|
+
test('/hello antwortet und zählt hoch', async () => {
|
|
20
|
+
const harness = await createProjectHarness(dir)
|
|
21
|
+
const first = await harness.runCommand('hello')
|
|
22
|
+
assert.equal(first.ok, true)
|
|
23
|
+
assert.equal(harness.storage.get('hello:count'), 1)
|
|
24
|
+
})
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`createProjectHarness(dir)` lädt `manifest.json` und `index.js` deines Projekts. Danach stehen dir unter anderem `runCommand`, `emit` für Events, `runJob`, `runUiData` und `runUiAction` zur Verfügung, dazu Aufrufe für Interaktionen.
|
|
28
|
+
|
|
29
|
+
Jeder Aufruf gibt `{ ok, value, error, replies, reply, calls, logs, delivered, durationMs }` zurück. Wenn dein Plugin einen Fehler auslöst, wirft das Testkit ihn nicht, sondern legt ihn in `error`, mit einem festen Code wie `permission_denied`, `http_blocked`, `quota_exceeded`, `rate_limited`, `invalid_args` oder `timeout`.
|
|
30
|
+
|
|
31
|
+
## Wie in Produktion
|
|
32
|
+
|
|
33
|
+
Ein `ctx`-Aufruf ohne deklarierte Berechtigung schlägt mit `permission_denied` fehl. Eine Registrierung ohne passende Berechtigung bricht den Start ab, und Commands, die nicht im Manifest stehen, werden abgelehnt. Aufrufe an `ctx` direkt in `setup()` liefern `unavailable`. Mit `devMode: true` kommen Events und Interaktionen anderer Mitglieder gar nicht erst an.
|
|
34
|
+
|
|
35
|
+
Passend dazu gibt es `@texisoft/nexus-plugin-sdk` und `@texisoft/nexus-plugin-cli`.
|
|
36
|
+
|
|
37
|
+
## Lizenz
|
|
38
|
+
|
|
39
|
+
Node.js 22 oder neuer. Das Paket ist proprietär, alle Rechte bleiben bei texisoft. Du darfst es nur nutzen, um Plugins für Nexus zu entwickeln, Genaueres steht in der `LICENSE`. Die Version ist noch 0.x, Schnittstellen können sich ändern.
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@texisoft/nexus-plugin-testkit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Test-Kit für Nexus-Plugins: simulierte Events, Commands, Interaktionen, Jobs, Storage, HTTP und Discord mit denselben Regeln wie in Produktion.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"nexus",
|
|
7
|
+
"discord",
|
|
8
|
+
"discord-bot",
|
|
9
|
+
"plugin",
|
|
10
|
+
"texisoft"
|
|
11
|
+
],
|
|
12
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/texisoft/discord-nexus-bot.git",
|
|
16
|
+
"directory": "packages/plugin-testkit"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/texisoft/discord-nexus-bot/tree/master/packages/plugin-testkit",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/texisoft/discord-nexus-bot/issues"
|
|
21
|
+
},
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "./src/index.js",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./src/index.js",
|
|
26
|
+
"./runner": "./src/runner.js",
|
|
27
|
+
"./project": "./src/project.js"
|
|
28
|
+
},
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"files": [
|
|
31
|
+
"src",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@texisoft/nexus-plugin-sdk": "^0.1.0"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/fixtures.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
let counter = 0
|
|
2
|
+
|
|
3
|
+
export function nextSnowflake() {
|
|
4
|
+
counter += 1
|
|
5
|
+
return String(400000000000000000n + BigInt(counter))
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_IDS = Object.freeze({
|
|
9
|
+
guildId: '100000000000000001',
|
|
10
|
+
authorUserId: '200000000000000001',
|
|
11
|
+
otherUserId: '200000000000000002',
|
|
12
|
+
channelId: '300000000000000001'
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
export function makeUser(overrides = {}) {
|
|
16
|
+
const id = overrides.id ?? DEFAULT_IDS.authorUserId
|
|
17
|
+
return { id, username: `user${id.slice(-4)}`, bot: false, ...overrides }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function makeMember(overrides = {}) {
|
|
21
|
+
return { userId: DEFAULT_IDS.authorUserId, nickname: null, roles: [], joinedAt: null, ...overrides }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function makeMessage(overrides = {}) {
|
|
25
|
+
const authorId = overrides.authorId ?? overrides.author?.id ?? DEFAULT_IDS.authorUserId
|
|
26
|
+
return {
|
|
27
|
+
id: nextSnowflake(),
|
|
28
|
+
channelId: DEFAULT_IDS.channelId,
|
|
29
|
+
authorId,
|
|
30
|
+
author: makeUser({ id: authorId }),
|
|
31
|
+
content: 'Hallo Welt',
|
|
32
|
+
attachmentCount: 0,
|
|
33
|
+
createdAt: 1767225600000,
|
|
34
|
+
...overrides
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const eventFixtures = Object.freeze({
|
|
39
|
+
messageCreate: (overrides = {}) => ({ message: makeMessage(overrides) }),
|
|
40
|
+
messageUpdate: (overrides = {}) => ({ message: makeMessage(overrides) }),
|
|
41
|
+
messageDelete: (overrides = {}) => ({ messageId: nextSnowflake(), channelId: DEFAULT_IDS.channelId, ...overrides }),
|
|
42
|
+
messageReactionAdd: (overrides = {}) => ({
|
|
43
|
+
messageId: nextSnowflake(), channelId: DEFAULT_IDS.channelId, userId: DEFAULT_IDS.authorUserId, emoji: { name: '👍' }, ...overrides
|
|
44
|
+
}),
|
|
45
|
+
messageReactionRemove: (overrides = {}) => ({
|
|
46
|
+
messageId: nextSnowflake(), channelId: DEFAULT_IDS.channelId, userId: DEFAULT_IDS.authorUserId, emoji: { name: '👍' }, ...overrides
|
|
47
|
+
}),
|
|
48
|
+
guildMemberAdd: (overrides = {}) => ({ user: makeUser(), member: makeMember(), ...overrides }),
|
|
49
|
+
guildMemberRemove: (overrides = {}) => ({ user: makeUser(), ...overrides }),
|
|
50
|
+
guildMemberUpdate: (overrides = {}) => ({
|
|
51
|
+
user: makeUser(), member: makeMember(), addedRoles: [], removedRoles: [], ...overrides
|
|
52
|
+
}),
|
|
53
|
+
voiceStateUpdate: (overrides = {}) => ({
|
|
54
|
+
userId: DEFAULT_IDS.authorUserId, oldChannelId: null, newChannelId: DEFAULT_IDS.channelId, ...overrides
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
export function actorOfEvent(name, payload) {
|
|
59
|
+
switch (name) {
|
|
60
|
+
case 'messageCreate':
|
|
61
|
+
case 'messageUpdate':
|
|
62
|
+
return payload?.message?.authorId ?? null
|
|
63
|
+
case 'messageReactionAdd':
|
|
64
|
+
case 'messageReactionRemove':
|
|
65
|
+
case 'voiceStateUpdate':
|
|
66
|
+
return payload?.userId ?? null
|
|
67
|
+
case 'guildMemberAdd':
|
|
68
|
+
case 'guildMemberRemove':
|
|
69
|
+
case 'guildMemberUpdate':
|
|
70
|
+
return payload?.user?.id ?? null
|
|
71
|
+
default:
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/harness.js
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EVENT_NAMES, PROTOCOL_VERSION, RUNTIME_LIMITS as R, PluginValidationError, actionResultSchema, chartDataSchema,
|
|
3
|
+
checkCall, configValuesSchema, hasPermission, invokeSchema, isPluginDefinition, manifestSchema,
|
|
4
|
+
permissionForRegistration, registerSchema, shouldDeliverInDev, statDataSchema, tableDataSchema, utf8Size,
|
|
5
|
+
validate, validateCallArgs, validateFrame, validateOrThrow
|
|
6
|
+
} from '@texisoft/nexus-plugin-sdk'
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_IDS, actorOfEvent, eventFixtures, makeMember, makeUser, nextSnowflake
|
|
9
|
+
} from './fixtures.js'
|
|
10
|
+
import { PluginCallError, createPluginRunner } from './runner.js'
|
|
11
|
+
import { createMemoryKv } from './storage.js'
|
|
12
|
+
|
|
13
|
+
const DATA_SCHEMAS = { table: tableDataSchema, stat: statDataSchema, chart: chartDataSchema }
|
|
14
|
+
|
|
15
|
+
const DISCORD_DEFAULTS = {
|
|
16
|
+
sendMessage: (a) => ({ id: nextSnowflake(), channelId: a.channelId }),
|
|
17
|
+
editMessage: (a) => ({ id: a.messageId, channelId: a.channelId }),
|
|
18
|
+
sendDm: (a) => ({ id: nextSnowflake(), channelId: nextSnowflake(), userId: a.userId }),
|
|
19
|
+
createThread: (a) => ({ id: nextSnowflake(), name: a.name }),
|
|
20
|
+
archiveThread: (a) => ({ threadId: a.threadId, archived: true, locked: a.locked === true }),
|
|
21
|
+
setThreadTags: (a) => ({ threadId: a.threadId, tagIds: a.tagIds }),
|
|
22
|
+
fetchMember: (a) => ({ userId: a.userId, nickname: null, roles: [] }),
|
|
23
|
+
fetchChannel: (a) => ({ id: a.channelId, name: 'kanal', type: 'text' }),
|
|
24
|
+
fetchRole: (a) => ({ id: a.roleId, name: 'rolle' })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let fakeDepth = 0
|
|
28
|
+
let realNow = null
|
|
29
|
+
|
|
30
|
+
async function withFakeNow(clock, fn) {
|
|
31
|
+
if (fakeDepth === 0) {
|
|
32
|
+
realNow = Date.now
|
|
33
|
+
Date.now = () => clock.now()
|
|
34
|
+
}
|
|
35
|
+
fakeDepth += 1
|
|
36
|
+
try {
|
|
37
|
+
return await fn()
|
|
38
|
+
} finally {
|
|
39
|
+
fakeDepth -= 1
|
|
40
|
+
if (fakeDepth === 0) {
|
|
41
|
+
Date.now = realNow
|
|
42
|
+
realNow = null
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function createClock(start) {
|
|
48
|
+
let current = start
|
|
49
|
+
return { now: () => current, set: (value) => { current = value } }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function issueSummary(errors) {
|
|
53
|
+
return errors.map((e) => `${e.path || '<root>'}:${e.code}`).join(', ')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function matchMock(matcher, req) {
|
|
57
|
+
if (typeof matcher === 'function') return Boolean(matcher(req))
|
|
58
|
+
if (matcher instanceof RegExp) return matcher.test(req.url)
|
|
59
|
+
if (typeof matcher === 'string') {
|
|
60
|
+
const space = matcher.indexOf(' ')
|
|
61
|
+
if (space > 0 && /^[A-Z]+$/.test(matcher.slice(0, space))) return matcher.slice(0, space) === req.method && matcher.slice(space + 1) === req.url
|
|
62
|
+
return matcher === req.url
|
|
63
|
+
}
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeResponse(raw) {
|
|
68
|
+
const response = raw ?? {}
|
|
69
|
+
const headers = Object.fromEntries(Object.entries(response.headers ?? {}).map(([k, v]) => [k.toLowerCase(), String(v)]))
|
|
70
|
+
let text = response.text ?? ''
|
|
71
|
+
if (response.json !== undefined) {
|
|
72
|
+
text = JSON.stringify(response.json)
|
|
73
|
+
headers['content-type'] ??= 'application/json'
|
|
74
|
+
}
|
|
75
|
+
return { status: response.status ?? 200, headers, text: String(text) }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createTestHarness(plugin, options = {}) {
|
|
79
|
+
if (!isPluginDefinition(plugin)) throw new TypeError('createTestHarness: erwartet ein Ergebnis von definePlugin')
|
|
80
|
+
const manifest = validateOrThrow(manifestSchema, options.manifest)
|
|
81
|
+
const guildId = options.guildId ?? DEFAULT_IDS.guildId
|
|
82
|
+
const authorUserId = options.authorUserId ?? DEFAULT_IDS.authorUserId
|
|
83
|
+
const devMode = options.devMode ?? false
|
|
84
|
+
const locale = options.locale ?? manifest.locales.default
|
|
85
|
+
const deadlineMs = options.deadlineMs ?? R.invocationDeadlineMs
|
|
86
|
+
const clock = createClock(options.now ?? Date.now())
|
|
87
|
+
const kv = createMemoryKv({ quotaKb: manifest.permissions.storageQuotaKb, now: clock.now })
|
|
88
|
+
const allCalls = []
|
|
89
|
+
const allLogs = []
|
|
90
|
+
const records = new Map()
|
|
91
|
+
const mocks = []
|
|
92
|
+
const httpRequests = []
|
|
93
|
+
const discordResponders = new Map()
|
|
94
|
+
const discordCalls = []
|
|
95
|
+
const discordMessages = []
|
|
96
|
+
const configSchema = configValuesSchema(manifest.configSchema).partial()
|
|
97
|
+
const configFields = new Map(manifest.configSchema.map((f) => [f.key, f]))
|
|
98
|
+
const configValues = {}
|
|
99
|
+
let invocationCounter = 0
|
|
100
|
+
let runner = null
|
|
101
|
+
let startPromise = null
|
|
102
|
+
let jobTimers = []
|
|
103
|
+
|
|
104
|
+
for (const f of manifest.configSchema) if (f.default !== undefined) configValues[f.key] = structuredClone(f.default)
|
|
105
|
+
|
|
106
|
+
function setConfig(key, value) {
|
|
107
|
+
if (!configFields.has(key)) throw new PluginValidationError([{ path: key, code: 'unknown_key' }])
|
|
108
|
+
validateOrThrow(configSchema, { [key]: value })
|
|
109
|
+
configValues[key] = structuredClone(value)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const [key, value] of Object.entries(options.config ?? {})) setConfig(key, value)
|
|
113
|
+
|
|
114
|
+
function record(inv, entry) {
|
|
115
|
+
allCalls.push(entry)
|
|
116
|
+
records.get(inv.id)?.calls.push(entry)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function interactionCall(inv, method, args) {
|
|
120
|
+
if (inv.kind !== 'command' && inv.kind !== 'interaction') {
|
|
121
|
+
throw new PluginCallError('forbidden', `${method} ist nur in Command- und Interaktions-Aufrufen erlaubt`)
|
|
122
|
+
}
|
|
123
|
+
const s = inv.state
|
|
124
|
+
const rec = records.get(inv.id)
|
|
125
|
+
const type = method.slice('interaction.'.length)
|
|
126
|
+
if (type === 'reply' || type === 'update') {
|
|
127
|
+
if (type === 'update' && inv.kind !== 'interaction') throw new PluginCallError('forbidden', 'update ist nur für Komponenten-Interaktionen erlaubt')
|
|
128
|
+
if (s.replied) throw new PluginCallError('forbidden', 'Interaktion wurde bereits beantwortet')
|
|
129
|
+
s.replied = true
|
|
130
|
+
rec.replies.push({ type, message: args.message })
|
|
131
|
+
} else if (type === 'defer') {
|
|
132
|
+
if (s.replied || s.deferred) throw new PluginCallError('forbidden', 'Interaktion wurde bereits beantwortet')
|
|
133
|
+
s.deferred = true
|
|
134
|
+
rec.replies.push({ type, ephemeral: args.ephemeral })
|
|
135
|
+
} else if (type === 'followUp') {
|
|
136
|
+
if (!s.replied && !s.deferred) throw new PluginCallError('forbidden', 'followUp erfordert vorher reply oder defer')
|
|
137
|
+
rec.replies.push({ type, message: args.message })
|
|
138
|
+
} else if (type === 'showModal') {
|
|
139
|
+
if (s.replied || s.deferred) throw new PluginCallError('forbidden', 'Interaktion wurde bereits beantwortet')
|
|
140
|
+
s.replied = true
|
|
141
|
+
rec.replies.push({ type: 'modal', modal: args.modal })
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function httpFetch(inv, args) {
|
|
146
|
+
inv.state.http += 1
|
|
147
|
+
if (inv.state.http > R.httpRequestsPerInvocationMax) {
|
|
148
|
+
throw new PluginCallError('rate_limited', `Mehr als ${R.httpRequestsPerInvocationMax} HTTP-Anfragen je Aufruf`)
|
|
149
|
+
}
|
|
150
|
+
const req = { url: args.url, method: args.method, headers: args.headers ?? {}, body: args.body ?? null }
|
|
151
|
+
httpRequests.push(req)
|
|
152
|
+
const mock = mocks.find((m) => matchMock(m.matcher, req))
|
|
153
|
+
if (!mock) throw new PluginCallError('http_failed', `Kein HTTP-Mock für ${req.method} ${req.url}`)
|
|
154
|
+
let raw
|
|
155
|
+
try {
|
|
156
|
+
raw = typeof mock.responder === 'function' ? await mock.responder(req) : mock.responder
|
|
157
|
+
} catch (error) {
|
|
158
|
+
throw new PluginCallError('http_failed', error?.message ?? 'HTTP-Fehler')
|
|
159
|
+
}
|
|
160
|
+
const response = normalizeResponse(raw)
|
|
161
|
+
if (utf8Size(response.text) > R.httpResponseMaxBytes) {
|
|
162
|
+
throw new PluginCallError('http_failed', `Antwort größer als ${R.httpResponseMaxBytes} Bytes`)
|
|
163
|
+
}
|
|
164
|
+
return response
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function discordCall(method, args) {
|
|
168
|
+
const action = method.slice('discord.'.length)
|
|
169
|
+
const entry = { method: action, args: structuredClone(args) }
|
|
170
|
+
discordCalls.push(entry)
|
|
171
|
+
if (['sendMessage', 'editMessage', 'sendDm'].includes(action)) discordMessages.push(entry)
|
|
172
|
+
const responder = discordResponders.get(action)
|
|
173
|
+
if (responder !== undefined) return typeof responder === 'function' ? responder(args) : responder
|
|
174
|
+
return DISCORD_DEFAULTS[action] ? DISCORD_DEFAULTS[action](args) : null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function call(method, rawArgs, inv) {
|
|
178
|
+
if (!inv) throw new PluginCallError('forbidden', 'ctx-Aufruf außerhalb eines Aufrufs')
|
|
179
|
+
const entry = { invocationId: inv.id, method, args: structuredClone(rawArgs), ok: true, error: null }
|
|
180
|
+
record(inv, entry)
|
|
181
|
+
try {
|
|
182
|
+
inv.state.calls += 1
|
|
183
|
+
if (inv.state.calls > R.callsPerInvocationMax) {
|
|
184
|
+
throw new PluginCallError('rate_limited', `Mehr als ${R.callsPerInvocationMax} ctx-Aufrufe je Aufruf`)
|
|
185
|
+
}
|
|
186
|
+
const verdict = checkCall(manifest, method, rawArgs)
|
|
187
|
+
if (!verdict.ok) {
|
|
188
|
+
const error = new PluginCallError(verdict.code, verdict.permission ? `Permission fehlt: ${verdict.permission}` : verdict.code)
|
|
189
|
+
error.permission = verdict.permission
|
|
190
|
+
throw error
|
|
191
|
+
}
|
|
192
|
+
const valid = validateCallArgs(method, rawArgs)
|
|
193
|
+
if (!valid.ok) throw new PluginCallError('invalid_args', issueSummary(valid.errors))
|
|
194
|
+
const args = valid.data
|
|
195
|
+
switch (method) {
|
|
196
|
+
case 'storage.kv.get': return kv.get(args.key)
|
|
197
|
+
case 'storage.kv.set': return kv.set(args.key, args.value, { ttlSeconds: args.ttlSeconds })
|
|
198
|
+
case 'storage.kv.delete': return kv.delete(args.key)
|
|
199
|
+
case 'storage.kv.list': return kv.list(args)
|
|
200
|
+
case 'storage.kv.incr': return kv.incr(args.key, args.by)
|
|
201
|
+
case 'config.get':
|
|
202
|
+
if (!configFields.has(args.key)) throw new PluginCallError('not_found', `Unbekannter Config-Schlüssel "${args.key}"`)
|
|
203
|
+
return structuredClone(configValues[args.key] ?? null)
|
|
204
|
+
case 'config.getAll': return structuredClone(configValues)
|
|
205
|
+
case 'http.fetch': return await httpFetch(inv, args)
|
|
206
|
+
case 'log.write': {
|
|
207
|
+
const log = { level: args.level, message: args.message }
|
|
208
|
+
allLogs.push(log)
|
|
209
|
+
records.get(inv.id)?.logs.push(log)
|
|
210
|
+
return undefined
|
|
211
|
+
}
|
|
212
|
+
default:
|
|
213
|
+
if (method.startsWith('interaction.')) return interactionCall(inv, method, args)
|
|
214
|
+
return await discordCall(method, args)
|
|
215
|
+
}
|
|
216
|
+
} catch (error) {
|
|
217
|
+
entry.ok = false
|
|
218
|
+
entry.error = { code: error.code ?? 'plugin_error', message: error.message }
|
|
219
|
+
throw error
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function onRegister(registrations) {
|
|
224
|
+
const frame = validateFrame(registerSchema, { type: 'register', v: PROTOCOL_VERSION, registrations })
|
|
225
|
+
if (!frame.ok) throw new PluginCallError('invalid_message', `Ungültige Registrierung: ${issueSummary(frame.errors)}`)
|
|
226
|
+
const declared = new Set(manifest.commands.map((c) => c.name))
|
|
227
|
+
for (const r of registrations) {
|
|
228
|
+
const needed = permissionForRegistration(r.kind, r.name)
|
|
229
|
+
if (needed && !hasPermission(manifest, needed)) {
|
|
230
|
+
const error = new PluginCallError('permission_denied', `Permission fehlt: ${needed}`)
|
|
231
|
+
error.permission = needed
|
|
232
|
+
throw error
|
|
233
|
+
}
|
|
234
|
+
if (r.kind === 'command' && !declared.has(r.name)) {
|
|
235
|
+
throw new PluginCallError('forbidden', `Command "${r.name}" ist nicht im Manifest deklariert`)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (registrations.filter((r) => r.kind === 'job').length > R.jobsMax) {
|
|
239
|
+
throw new PluginCallError('quota_exceeded', `Mehr als ${R.jobsMax} Jobs registriert`)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function createRunner() {
|
|
244
|
+
return createPluginRunner(plugin, {
|
|
245
|
+
meta: { guildId, pluginId: manifest.id, locale, defaultLocale: manifest.locales.default, supportedLocales: manifest.locales.supported },
|
|
246
|
+
call,
|
|
247
|
+
locales: options.locales ?? {},
|
|
248
|
+
onRegister
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function start() {
|
|
253
|
+
if (!startPromise) {
|
|
254
|
+
runner = createRunner()
|
|
255
|
+
startPromise = withFakeNow(clock, () => runner.start()).then((registrations) => {
|
|
256
|
+
jobTimers = registrations
|
|
257
|
+
.filter((r) => r.kind === 'job' && r.options?.intervalSeconds)
|
|
258
|
+
.map((r) => ({ name: r.name, intervalMs: r.options.intervalSeconds * 1000, next: clock.now() + r.options.intervalSeconds * 1000 }))
|
|
259
|
+
return registrations
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
return startPromise
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function stop() {
|
|
266
|
+
if (!startPromise) return
|
|
267
|
+
const current = runner
|
|
268
|
+
const pending = startPromise
|
|
269
|
+
startPromise = null
|
|
270
|
+
runner = null
|
|
271
|
+
jobTimers = []
|
|
272
|
+
await pending.catch(() => {})
|
|
273
|
+
await current.stop().catch(() => {})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function skipped(reason) {
|
|
277
|
+
return { ok: true, value: undefined, error: null, delivered: false, skipped: reason, replies: [], reply: null, calls: [], logs: [], durationMs: 0 }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function gate(actorUserId) {
|
|
281
|
+
if (!devMode) return true
|
|
282
|
+
return shouldDeliverInDev({ authorUserId, actorUserId, guildId, boundGuilds: [guildId] })
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function buildInvoke(kind, extra, actorUserId) {
|
|
286
|
+
invocationCounter += 1
|
|
287
|
+
return {
|
|
288
|
+
type: 'invoke', v: PROTOCOL_VERSION, id: `t${invocationCounter}`, guildId, actorUserId: actorUserId ?? null, deadlineMs, kind, ...extra
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function execute(message) {
|
|
293
|
+
const frame = validateFrame(invokeSchema, message)
|
|
294
|
+
if (!frame.ok) throw new PluginValidationError(frame.errors)
|
|
295
|
+
await start()
|
|
296
|
+
const rec = { calls: [], replies: [], logs: [] }
|
|
297
|
+
records.set(frame.data.id, rec)
|
|
298
|
+
const began = performance.now()
|
|
299
|
+
let outcome
|
|
300
|
+
try {
|
|
301
|
+
outcome = await withFakeNow(clock, () => runner.invoke(frame.data))
|
|
302
|
+
} finally {
|
|
303
|
+
records.delete(frame.data.id)
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
ok: outcome.ok,
|
|
307
|
+
value: outcome.value,
|
|
308
|
+
error: outcome.error ?? null,
|
|
309
|
+
delivered: true,
|
|
310
|
+
replies: rec.replies,
|
|
311
|
+
reply: rec.replies.find((r) => r.type === 'reply' || r.type === 'update')?.message ?? null,
|
|
312
|
+
calls: rec.calls,
|
|
313
|
+
logs: rec.logs,
|
|
314
|
+
durationMs: performance.now() - began
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function interactionBase(opts = {}) {
|
|
319
|
+
const user = opts.user ?? makeUser({ id: authorUserId })
|
|
320
|
+
return {
|
|
321
|
+
interactionId: nextSnowflake(),
|
|
322
|
+
guildId,
|
|
323
|
+
channelId: opts.channelId ?? DEFAULT_IDS.channelId,
|
|
324
|
+
user,
|
|
325
|
+
member: opts.member ?? makeMember({ userId: user.id }),
|
|
326
|
+
locale
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function resolveOptionList(command, name, group, sub) {
|
|
331
|
+
const label = [name, group, sub].filter(Boolean).join(' ')
|
|
332
|
+
const hasNested = command.subcommands.length > 0 || command.groups.length > 0
|
|
333
|
+
if (!hasNested) {
|
|
334
|
+
if (group || sub) throw new TypeError(`/${name} hat keine Subcommands`)
|
|
335
|
+
return { options: command.options, label }
|
|
336
|
+
}
|
|
337
|
+
if (!sub) throw new TypeError(`/${name} braucht einen Subcommand`)
|
|
338
|
+
let list = command.subcommands
|
|
339
|
+
if (group) {
|
|
340
|
+
const found = command.groups.find((g) => g.name === group)
|
|
341
|
+
if (!found) throw new TypeError(`Gruppe "${group}" ist für /${name} nicht deklariert`)
|
|
342
|
+
list = found.subcommands
|
|
343
|
+
}
|
|
344
|
+
const subcommand = list.find((c) => c.name === sub)
|
|
345
|
+
if (!subcommand) throw new TypeError(`Subcommand "${sub}" ist für /${label} nicht deklariert`)
|
|
346
|
+
return { options: subcommand.options, label }
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function checkOptions(name, given, group, sub) {
|
|
350
|
+
const command = manifest.commands.find((c) => c.name === name)
|
|
351
|
+
if (!command) throw new TypeError(`Command "${name}" ist nicht im Manifest deklariert`)
|
|
352
|
+
const resolved = resolveOptionList(command, name, group, sub)
|
|
353
|
+
const known = new Map(resolved.options.map((o) => [o.name, o]))
|
|
354
|
+
for (const key of Object.keys(given)) {
|
|
355
|
+
if (!known.has(key)) throw new TypeError(`Unbekannte Option "${key}" für /${resolved.label}`)
|
|
356
|
+
}
|
|
357
|
+
for (const o of resolved.options) {
|
|
358
|
+
const value = given[o.name]
|
|
359
|
+
if (value === undefined) {
|
|
360
|
+
if (o.required) throw new TypeError(`Pflicht-Option "${o.name}" fehlt für /${resolved.label}`)
|
|
361
|
+
continue
|
|
362
|
+
}
|
|
363
|
+
const ok = { string: 'string', integer: 'number', number: 'number', boolean: 'boolean', user: 'string', channel: 'string', role: 'string' }[o.type]
|
|
364
|
+
if (typeof value !== ok || (o.type === 'integer' && !Number.isInteger(value))) {
|
|
365
|
+
throw new TypeError(`Option "${o.name}" hat den falschen Typ (erwartet ${o.type})`)
|
|
366
|
+
}
|
|
367
|
+
if (o.min !== undefined && value < o.min) throw new TypeError(`Option "${o.name}" unter min`)
|
|
368
|
+
if (o.max !== undefined && value > o.max) throw new TypeError(`Option "${o.name}" über max`)
|
|
369
|
+
if (o.maxLength !== undefined && typeof value === 'string' && value.length > o.maxLength) throw new TypeError(`Option "${o.name}" über maxLength`)
|
|
370
|
+
if (o.choices && !o.choices.some((c) => c.value === value)) throw new TypeError(`Option "${o.name}" ist keine gültige Auswahl`)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function emit(name, payload, opts = {}) {
|
|
375
|
+
if (!EVENT_NAMES.includes(name)) throw new TypeError(`Unbekanntes Event "${name}"`)
|
|
376
|
+
const dto = structuredClone(payload ?? eventFixtures[name]())
|
|
377
|
+
if (!manifest.permissions.messageContent) {
|
|
378
|
+
if (dto.message) delete dto.message.content
|
|
379
|
+
delete dto.oldContent
|
|
380
|
+
}
|
|
381
|
+
const actor = opts.actorUserId !== undefined ? opts.actorUserId : actorOfEvent(name, dto)
|
|
382
|
+
if (!gate(actor)) return skipped('dev_not_author')
|
|
383
|
+
return execute(buildInvoke('event', { name, payload: dto }, actor))
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function runCommand(name, opts = {}) {
|
|
387
|
+
const given = opts.options ?? {}
|
|
388
|
+
checkOptions(name, given, opts.subcommandGroup, opts.subcommand)
|
|
389
|
+
const base = interactionBase(opts)
|
|
390
|
+
if (!gate(base.user.id)) return skipped('dev_not_author')
|
|
391
|
+
const nested = {
|
|
392
|
+
...(opts.subcommandGroup ? { subcommandGroup: opts.subcommandGroup } : {}),
|
|
393
|
+
...(opts.subcommand ? { subcommand: opts.subcommand } : {})
|
|
394
|
+
}
|
|
395
|
+
return execute(buildInvoke('command', { interaction: { ...base, kind: 'command', commandName: name, ...nested, options: given } }, base.user.id))
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function component(kind, customId, extra, opts) {
|
|
399
|
+
const base = interactionBase(opts)
|
|
400
|
+
if (!gate(base.user.id)) return skipped('dev_not_author')
|
|
401
|
+
return execute(buildInvoke('interaction', { interaction: { ...base, kind, customId, ...extra } }, base.user.id))
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const pressButton = (customId, opts = {}) => component('button', customId, { messageId: opts.messageId ?? nextSnowflake() }, opts)
|
|
405
|
+
const selectOption = (customId, values, opts = {}) => component('select', customId, { messageId: opts.messageId ?? nextSnowflake(), values }, opts)
|
|
406
|
+
const submitModal = (customId, fields, opts = {}) => component('modal', customId, { fields }, opts)
|
|
407
|
+
|
|
408
|
+
async function runJob(name) {
|
|
409
|
+
return execute(buildInvoke('job', { name }, null))
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function runUi(kind, name, params, opts) {
|
|
413
|
+
const viewer = opts.viewer ?? makeUser({ id: authorUserId })
|
|
414
|
+
const result = await execute(buildInvoke(kind, { name, params, viewer }, viewer.id))
|
|
415
|
+
if (result.ok && kind === 'uiAction') {
|
|
416
|
+
const check = validate(actionResultSchema, result.value)
|
|
417
|
+
if (!check.ok) return { ...result, ok: false, error: { code: 'invalid_args', message: `Aktionsergebnis ungültig: ${issueSummary(check.errors)}` } }
|
|
418
|
+
}
|
|
419
|
+
if (result.ok && opts.schema) {
|
|
420
|
+
const check = validate(DATA_SCHEMAS[opts.schema], result.value)
|
|
421
|
+
if (!check.ok) return { ...result, ok: false, error: { code: 'invalid_args', message: `Daten ungültig (${opts.schema}): ${issueSummary(check.errors)}` } }
|
|
422
|
+
}
|
|
423
|
+
return result
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const runUiData = (name, params = {}, opts = {}) => runUi('uiData', name, params, opts)
|
|
427
|
+
const runUiAction = (name, params = {}, opts = {}) => runUi('uiAction', name, params, opts)
|
|
428
|
+
|
|
429
|
+
async function advance(ms) {
|
|
430
|
+
await start()
|
|
431
|
+
const target = clock.now() + ms
|
|
432
|
+
const results = []
|
|
433
|
+
for (;;) {
|
|
434
|
+
const due = jobTimers.filter((j) => j.next <= target).sort((a, b) => a.next - b.next)[0]
|
|
435
|
+
if (!due) break
|
|
436
|
+
clock.set(due.next)
|
|
437
|
+
due.next += due.intervalMs
|
|
438
|
+
results.push({ name: due.name, ...(await runJob(due.name)) })
|
|
439
|
+
}
|
|
440
|
+
clock.set(target)
|
|
441
|
+
return results
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return {
|
|
445
|
+
manifest,
|
|
446
|
+
ids: { guildId, authorUserId },
|
|
447
|
+
start,
|
|
448
|
+
stop,
|
|
449
|
+
restart: async () => {
|
|
450
|
+
await stop()
|
|
451
|
+
return start()
|
|
452
|
+
},
|
|
453
|
+
registrations: async () => {
|
|
454
|
+
await start()
|
|
455
|
+
return runner.registry()
|
|
456
|
+
},
|
|
457
|
+
emit,
|
|
458
|
+
runCommand,
|
|
459
|
+
pressButton,
|
|
460
|
+
selectOption,
|
|
461
|
+
submitModal,
|
|
462
|
+
runJob,
|
|
463
|
+
runUiData,
|
|
464
|
+
runUiAction,
|
|
465
|
+
storage: {
|
|
466
|
+
get: kv.get, set: kv.set, delete: kv.delete, list: kv.list, incr: kv.incr, entries: kv.entries, clear: kv.clear, usedBytes: kv.usedBytes, quotaBytes: kv.quotaBytes
|
|
467
|
+
},
|
|
468
|
+
config: {
|
|
469
|
+
set: setConfig,
|
|
470
|
+
get: (key) => structuredClone(configValues[key] ?? null),
|
|
471
|
+
all: () => structuredClone(configValues)
|
|
472
|
+
},
|
|
473
|
+
http: {
|
|
474
|
+
mock: (matcher, responder) => { mocks.push({ matcher, responder }) },
|
|
475
|
+
reset: () => { mocks.length = 0 },
|
|
476
|
+
requests: httpRequests
|
|
477
|
+
},
|
|
478
|
+
discord: {
|
|
479
|
+
calls: discordCalls,
|
|
480
|
+
messages: discordMessages,
|
|
481
|
+
respond: (action, value) => { discordResponders.set(action, value) },
|
|
482
|
+
fail: (action, code = 'discord_failed', message) => {
|
|
483
|
+
discordResponders.set(action, () => { throw new PluginCallError(code, message ?? code) })
|
|
484
|
+
},
|
|
485
|
+
reset: () => {
|
|
486
|
+
discordCalls.length = 0
|
|
487
|
+
discordMessages.length = 0
|
|
488
|
+
discordResponders.clear()
|
|
489
|
+
}
|
|
490
|
+
},
|
|
491
|
+
calls: allCalls,
|
|
492
|
+
logs: allLogs,
|
|
493
|
+
clock: {
|
|
494
|
+
now: clock.now,
|
|
495
|
+
set: (value) => clock.set(typeof value === 'number' ? value : Date.parse(value)),
|
|
496
|
+
advance
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createTestHarness } from './harness.js'
|
|
2
|
+
export { createMemoryKv } from './storage.js'
|
|
3
|
+
export { PluginCallError, createPluginRunner, createTranslator } from './runner.js'
|
|
4
|
+
export {
|
|
5
|
+
DEFAULT_IDS, actorOfEvent, eventFixtures, makeMember, makeMessage, makeUser, nextSnowflake
|
|
6
|
+
} from './fixtures.js'
|
|
7
|
+
export { createProjectHarness, loadLocales, loadPluginProject } from './project.js'
|
package/src/project.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readFile, readdir } from 'node:fs/promises'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
import { createTestHarness } from './harness.js'
|
|
5
|
+
|
|
6
|
+
export async function loadLocales(dir) {
|
|
7
|
+
const locales = {}
|
|
8
|
+
let files = []
|
|
9
|
+
try {
|
|
10
|
+
files = await readdir(join(dir, 'locales'))
|
|
11
|
+
} catch {
|
|
12
|
+
return locales
|
|
13
|
+
}
|
|
14
|
+
for (const file of files) {
|
|
15
|
+
if (!file.endsWith('.json')) continue
|
|
16
|
+
locales[file.slice(0, -5)] = JSON.parse(await readFile(join(dir, 'locales', file), 'utf8'))
|
|
17
|
+
}
|
|
18
|
+
return locales
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function loadPluginProject(dir) {
|
|
22
|
+
const root = resolve(dir)
|
|
23
|
+
const manifest = JSON.parse(await readFile(join(root, 'manifest.json'), 'utf8'))
|
|
24
|
+
const entry = join(root, manifest.main ?? 'index.js')
|
|
25
|
+
const module = await import(pathToFileURL(entry).href)
|
|
26
|
+
return { dir: root, manifest, locales: await loadLocales(root), plugin: module.default }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function createProjectHarness(dir, options = {}) {
|
|
30
|
+
const project = await loadPluginProject(dir)
|
|
31
|
+
return createTestHarness(project.plugin, { manifest: project.manifest, locales: project.locales, ...options })
|
|
32
|
+
}
|
package/src/runner.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import {
|
|
3
|
+
DISCORD_ACTIONS, RUNTIME_LIMITS, isPluginDefinition, makeError
|
|
4
|
+
} from '@texisoft/nexus-plugin-sdk'
|
|
5
|
+
|
|
6
|
+
export class PluginCallError extends Error {
|
|
7
|
+
constructor(code, message) {
|
|
8
|
+
super(message ?? code)
|
|
9
|
+
this.name = 'PluginCallError'
|
|
10
|
+
this.code = code
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const LOG_LEVELS = ['debug', 'info', 'warn', 'error']
|
|
15
|
+
|
|
16
|
+
function formatLogMessage(message) {
|
|
17
|
+
let text
|
|
18
|
+
try {
|
|
19
|
+
text = typeof message === 'string' ? message : JSON.stringify(message)
|
|
20
|
+
} catch {
|
|
21
|
+
text = String(message)
|
|
22
|
+
}
|
|
23
|
+
if (typeof text !== 'string' || text.length === 0) text = '(leer)'
|
|
24
|
+
return text.slice(0, RUNTIME_LIMITS.logMessageMax)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolveText(table, key) {
|
|
28
|
+
if (!table) return undefined
|
|
29
|
+
if (Object.hasOwn(table, key) && typeof table[key] === 'string') return table[key]
|
|
30
|
+
let node = table
|
|
31
|
+
for (const part of key.split('.')) {
|
|
32
|
+
if (node && typeof node === 'object' && Object.hasOwn(node, part)) node = node[part]
|
|
33
|
+
else return undefined
|
|
34
|
+
}
|
|
35
|
+
return typeof node === 'string' ? node : undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createTranslator({ locales = {}, locale, defaultLocale }) {
|
|
39
|
+
return (key, vars = {}) => {
|
|
40
|
+
const text = resolveText(locales[locale], key) ?? resolveText(locales[defaultLocale], key) ?? key
|
|
41
|
+
return text.replace(/\{(\w+)\}/g, (match, name) => (Object.hasOwn(vars, name) ? String(vars[name]) : match))
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function toError(error) {
|
|
46
|
+
if (error instanceof PluginCallError) return makeError(error.code, error.message)
|
|
47
|
+
return makeError('plugin_error', error?.message ?? String(error))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function toJsonValue(value) {
|
|
51
|
+
if (value === undefined) return undefined
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(JSON.stringify(value))
|
|
54
|
+
} catch {
|
|
55
|
+
throw new PluginCallError('plugin_error', 'Rückgabewert ist kein gültiges JSON')
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createPluginRunner(plugin, { meta, call, locales, onRegister } = {}) {
|
|
60
|
+
if (!isPluginDefinition(plugin)) throw new TypeError('createPluginRunner: erwartet ein Ergebnis von definePlugin')
|
|
61
|
+
const registry = []
|
|
62
|
+
const als = new AsyncLocalStorage()
|
|
63
|
+
let phase = 'new'
|
|
64
|
+
let ctx = null
|
|
65
|
+
const defaultLocale = meta.defaultLocale ?? meta.locale
|
|
66
|
+
const supportedLocales = meta.supportedLocales ?? [defaultLocale]
|
|
67
|
+
const currentLocale = () => als.getStore()?.locale ?? defaultLocale
|
|
68
|
+
const translate = (key, vars) => createTranslator({ locales, locale: currentLocale(), defaultLocale })(key, vars)
|
|
69
|
+
|
|
70
|
+
const rpc = (method, args) => {
|
|
71
|
+
if (phase !== 'running') {
|
|
72
|
+
return Promise.reject(new PluginCallError('unavailable', 'ctx-Aufrufe sind nur innerhalb eines Aufrufs erlaubt, nicht in setup()'))
|
|
73
|
+
}
|
|
74
|
+
return Promise.resolve(call(method, args ?? {}, als.getStore() ?? null))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const register = (kind, name, handler, options) => {
|
|
78
|
+
if (phase !== 'setup') throw new PluginCallError('unavailable', 'Registrierungen sind nur in setup() erlaubt')
|
|
79
|
+
if (typeof name !== 'string' || !name) throw new TypeError(`${kind}: Name muss ein nicht leerer String sein`)
|
|
80
|
+
if (typeof handler !== 'function') throw new TypeError(`${kind} "${name}": Handler muss eine Funktion sein`)
|
|
81
|
+
if (kind !== 'event' && registry.some((r) => r.kind === kind && r.name === name)) {
|
|
82
|
+
throw new TypeError(`doppelte Registrierung: ${kind}:${name}`)
|
|
83
|
+
}
|
|
84
|
+
registry.push({ kind, name, handler, options })
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const log = Object.fromEntries(LOG_LEVELS.map((level) => [level, (message) => {
|
|
88
|
+
const store = als.getStore()
|
|
89
|
+
const promise = rpc('log.write', { level, message: formatLogMessage(message) }).catch(() => {})
|
|
90
|
+
store?.pending.add(promise)
|
|
91
|
+
promise.finally(() => store?.pending.delete(promise))
|
|
92
|
+
}]))
|
|
93
|
+
|
|
94
|
+
const interactionApi = Object.freeze({
|
|
95
|
+
reply: (message) => rpc('interaction.reply', { message }),
|
|
96
|
+
update: (message) => rpc('interaction.update', { message }),
|
|
97
|
+
defer: (options) => rpc('interaction.defer', options === undefined ? {} : options),
|
|
98
|
+
followUp: (message) => rpc('interaction.followUp', { message }),
|
|
99
|
+
showModal: (modal) => rpc('interaction.showModal', { modal })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const discord = Object.freeze(Object.fromEntries(
|
|
103
|
+
Object.keys(DISCORD_ACTIONS).map((action) => [action, (args = {}) => rpc(`discord.${action}`, args)])
|
|
104
|
+
))
|
|
105
|
+
|
|
106
|
+
function buildCtx() {
|
|
107
|
+
return Object.freeze({
|
|
108
|
+
meta: Object.freeze({
|
|
109
|
+
guildId: meta.guildId,
|
|
110
|
+
pluginId: meta.pluginId,
|
|
111
|
+
get locale() {
|
|
112
|
+
return currentLocale()
|
|
113
|
+
}
|
|
114
|
+
}),
|
|
115
|
+
commands: Object.freeze({ on: (name, handler) => register('command', name, handler) }),
|
|
116
|
+
events: Object.freeze({ on: (name, handler) => register('event', name, handler) }),
|
|
117
|
+
interactions: Object.freeze({
|
|
118
|
+
button: (name, handler) => register('button', name, handler),
|
|
119
|
+
modal: (name, handler) => register('modal', name, handler),
|
|
120
|
+
select: (name, handler) => register('select', name, handler)
|
|
121
|
+
}),
|
|
122
|
+
jobs: Object.freeze({ schedule: (name, options, handler) => register('job', name, handler, options) }),
|
|
123
|
+
storage: Object.freeze({
|
|
124
|
+
kv: Object.freeze({
|
|
125
|
+
get: (key) => rpc('storage.kv.get', { key }),
|
|
126
|
+
set: (key, value, options) => rpc('storage.kv.set', {
|
|
127
|
+
key, value, ...(options && options.ttlSeconds !== undefined ? { ttlSeconds: options.ttlSeconds } : {})
|
|
128
|
+
}),
|
|
129
|
+
delete: (key) => rpc('storage.kv.delete', { key }),
|
|
130
|
+
list: (options) => rpc('storage.kv.list', options === undefined ? {} : options),
|
|
131
|
+
incr: (key, by) => rpc('storage.kv.incr', by === undefined ? { key } : { key, by })
|
|
132
|
+
})
|
|
133
|
+
}),
|
|
134
|
+
config: Object.freeze({
|
|
135
|
+
get: (key) => rpc('config.get', { key }),
|
|
136
|
+
getAll: () => rpc('config.getAll', {})
|
|
137
|
+
}),
|
|
138
|
+
http: Object.freeze({ fetch: (url, options) => rpc('http.fetch', { ...(options || {}), url }) }),
|
|
139
|
+
discord,
|
|
140
|
+
ui: Object.freeze({
|
|
141
|
+
data: (name, handler) => register('uiData', name, handler),
|
|
142
|
+
action: (name, handler) => register('uiAction', name, handler)
|
|
143
|
+
}),
|
|
144
|
+
log: Object.freeze(log),
|
|
145
|
+
t: translate
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function selectHandlers(msg) {
|
|
150
|
+
if (msg.kind === 'event') return registry.filter((r) => r.kind === 'event' && r.name === msg.name)
|
|
151
|
+
if (msg.kind === 'command') return registry.filter((r) => r.kind === 'command' && r.name === msg.interaction.commandName)
|
|
152
|
+
if (msg.kind === 'interaction') {
|
|
153
|
+
const kind = msg.interaction.kind
|
|
154
|
+
const id = msg.interaction.customId
|
|
155
|
+
const prefix = `plugin:${meta.pluginId}:`
|
|
156
|
+
const stripped = id.startsWith(prefix) ? id.slice(prefix.length) : id
|
|
157
|
+
const ofKind = registry.filter((r) => r.kind === kind)
|
|
158
|
+
for (const candidate of [id, stripped]) {
|
|
159
|
+
const exact = ofKind.filter((r) => r.name === candidate)
|
|
160
|
+
if (exact.length > 0) return exact
|
|
161
|
+
}
|
|
162
|
+
const byPrefix = ofKind.find((r) => stripped.startsWith(`${r.name}:`))
|
|
163
|
+
return byPrefix ? ofKind.filter((r) => r.name === byPrefix.name) : []
|
|
164
|
+
}
|
|
165
|
+
return registry.filter((r) => r.kind === msg.kind && r.name === msg.name)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function runHandler(handler, msg) {
|
|
169
|
+
switch (msg.kind) {
|
|
170
|
+
case 'event': return handler(msg.payload, ctx)
|
|
171
|
+
case 'command':
|
|
172
|
+
case 'interaction': return handler(msg.interaction, interactionApi)
|
|
173
|
+
case 'job': return handler(Object.freeze({ name: msg.name }))
|
|
174
|
+
default: return handler(msg.params ?? {}, Object.freeze({ viewer: msg.viewer ?? null }))
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function start() {
|
|
179
|
+
if (phase !== 'new') throw new Error('Runner ist bereits gestartet')
|
|
180
|
+
phase = 'setup'
|
|
181
|
+
ctx = buildCtx()
|
|
182
|
+
await plugin.setup(ctx)
|
|
183
|
+
phase = 'running'
|
|
184
|
+
const registrations = registry.map((r) => ({ kind: r.kind, name: r.name, ...(r.options ? { options: r.options } : {}) }))
|
|
185
|
+
if (onRegister) await onRegister(registrations)
|
|
186
|
+
return registrations
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function invoke(msg) {
|
|
190
|
+
const requested = typeof msg.interaction?.locale === 'string' ? msg.interaction.locale.slice(0, 2).toLowerCase() : null
|
|
191
|
+
const inv = {
|
|
192
|
+
locale: requested && supportedLocales.includes(requested) ? requested : defaultLocale,
|
|
193
|
+
id: msg.id,
|
|
194
|
+
kind: msg.kind,
|
|
195
|
+
guildId: msg.guildId,
|
|
196
|
+
actorUserId: msg.actorUserId ?? null,
|
|
197
|
+
pending: new Set(),
|
|
198
|
+
state: { calls: 0, http: 0, replied: false, deferred: false }
|
|
199
|
+
}
|
|
200
|
+
let timer
|
|
201
|
+
const deadline = new Promise((resolve) => {
|
|
202
|
+
timer = setTimeout(() => resolve({ ok: false, error: makeError('timeout', 'Frist überschritten') }), msg.deadlineMs ?? RUNTIME_LIMITS.invocationDeadlineMs)
|
|
203
|
+
})
|
|
204
|
+
const work = als.run(inv, async () => {
|
|
205
|
+
try {
|
|
206
|
+
if (phase !== 'running') throw new PluginCallError('unavailable', 'Runner ist nicht gestartet')
|
|
207
|
+
const handlers = selectHandlers(msg)
|
|
208
|
+
if (handlers.length === 0) throw new PluginCallError('not_registered', 'Kein Handler registriert')
|
|
209
|
+
let value
|
|
210
|
+
let firstError = null
|
|
211
|
+
for (const entry of handlers) {
|
|
212
|
+
try {
|
|
213
|
+
const result = await runHandler(entry.handler, msg)
|
|
214
|
+
if (result !== undefined && value === undefined) value = result
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if (firstError === null) firstError = error
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
await Promise.allSettled([...inv.pending])
|
|
220
|
+
if (firstError !== null) throw firstError
|
|
221
|
+
if (msg.kind === 'uiData' || msg.kind === 'uiAction') return { ok: true, value: toJsonValue(value) }
|
|
222
|
+
return { ok: true, value: undefined }
|
|
223
|
+
} catch (error) {
|
|
224
|
+
return { ok: false, error: toError(error) }
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
try {
|
|
228
|
+
return await Promise.race([work, deadline])
|
|
229
|
+
} finally {
|
|
230
|
+
clearTimeout(timer)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function stop() {
|
|
235
|
+
const previous = phase
|
|
236
|
+
phase = 'stopped'
|
|
237
|
+
if (previous === 'running' && typeof plugin.teardown === 'function') await plugin.teardown(ctx)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { start, invoke, stop, registry: () => registry.map(({ kind, name, options }) => ({ kind, name, options })) }
|
|
241
|
+
}
|
package/src/storage.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { utf8Size } from '@texisoft/nexus-plugin-sdk'
|
|
2
|
+
import { PluginCallError } from './runner.js'
|
|
3
|
+
|
|
4
|
+
export function createMemoryKv({ quotaKb = 0, now = () => Date.now() } = {}) {
|
|
5
|
+
const quotaBytes = quotaKb * 1024
|
|
6
|
+
const map = new Map()
|
|
7
|
+
|
|
8
|
+
const entrySize = (key, value) => utf8Size(key) + utf8Size(value)
|
|
9
|
+
|
|
10
|
+
function purge() {
|
|
11
|
+
const t = now()
|
|
12
|
+
for (const [key, entry] of map) {
|
|
13
|
+
if (entry.expiresAt !== null && entry.expiresAt <= t) map.delete(key)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function usedBytes() {
|
|
18
|
+
purge()
|
|
19
|
+
let total = 0
|
|
20
|
+
for (const [key, entry] of map) total += entrySize(key, entry.value)
|
|
21
|
+
return total
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function assertQuota(key, value) {
|
|
25
|
+
const existing = map.get(key)
|
|
26
|
+
const next = usedBytes() - (existing ? entrySize(key, existing.value) : 0) + entrySize(key, value)
|
|
27
|
+
if (next > quotaBytes) {
|
|
28
|
+
throw new PluginCallError('quota_exceeded', `Speicherkontingent von ${quotaKb} KB überschritten`)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function get(key) {
|
|
33
|
+
purge()
|
|
34
|
+
const entry = map.get(key)
|
|
35
|
+
return entry ? structuredClone(entry.value) : null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function set(key, value, { ttlSeconds } = {}) {
|
|
39
|
+
purge()
|
|
40
|
+
const cloned = structuredClone(value)
|
|
41
|
+
assertQuota(key, cloned)
|
|
42
|
+
map.set(key, { value: cloned, expiresAt: ttlSeconds ? now() + ttlSeconds * 1000 : null })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function remove(key) {
|
|
46
|
+
purge()
|
|
47
|
+
map.delete(key)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function list({ prefix = '', limit = 100, cursor } = {}) {
|
|
51
|
+
purge()
|
|
52
|
+
const keys = [...map.keys()].filter((k) => k.startsWith(prefix)).sort()
|
|
53
|
+
const start = cursor ? keys.findIndex((k) => k > cursor) : 0
|
|
54
|
+
if (start === -1) return { keys: [] }
|
|
55
|
+
const page = keys.slice(start, start + limit)
|
|
56
|
+
const more = start + limit < keys.length
|
|
57
|
+
return more ? { keys: page, cursor: page[page.length - 1] } : { keys: page }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function incr(key, by = 1) {
|
|
61
|
+
purge()
|
|
62
|
+
const existing = map.get(key)
|
|
63
|
+
const current = existing ? existing.value : 0
|
|
64
|
+
if (typeof current !== 'number' || !Number.isFinite(current)) {
|
|
65
|
+
throw new PluginCallError('invalid_args', `Wert von "${key}" ist keine Zahl`)
|
|
66
|
+
}
|
|
67
|
+
const next = current + by
|
|
68
|
+
assertQuota(key, next)
|
|
69
|
+
map.set(key, { value: next, expiresAt: existing ? existing.expiresAt : null })
|
|
70
|
+
return next
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
get,
|
|
75
|
+
set,
|
|
76
|
+
delete: remove,
|
|
77
|
+
list,
|
|
78
|
+
incr,
|
|
79
|
+
usedBytes,
|
|
80
|
+
quotaBytes: () => quotaBytes,
|
|
81
|
+
entries: () => {
|
|
82
|
+
purge()
|
|
83
|
+
return Object.fromEntries([...map].map(([k, e]) => [k, structuredClone(e.value)]))
|
|
84
|
+
},
|
|
85
|
+
clear: () => map.clear()
|
|
86
|
+
}
|
|
87
|
+
}
|