chore: publish pearcord-automation-export from pearcord workspace
This commit is contained in:
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,126 @@
|
||||
# pearcord-automation-export
|
||||
|
||||
Guild automation manifest export/import and per-guild hook persistence. P2P-safe bundles contain no secrets.
|
||||
|
||||
**v0.8.661 (Phase 686):** Registry modal UX in `automation-ui.js`; platform `automation-mixin.js` gossip dedupe + partition heal; deep link `?automation=1`. Bundle: `test:ci-phase686` (app repo). See [AUTOMATION_EXPORT.md](../../docs/AUTOMATION_EXPORT.md).
|
||||
|
||||
**v0.8.541 (Phase 578):** Automation hub live panel + platform `guildCount` on automation spans; agentctl dry-run/export `refreshView`. Bundle: `test:phase578-automation` (app repo).
|
||||
|
||||
**v0.8.503 (Phase 540):** Split automation panel compositor; `automation.dispatch` span; agentctl `upsertGuildAutomationHook`/`dryRunGuildAutomationHook`/`exportGuildAutomationManifest`. Bundle: `test:phase540-automation` (app repo).
|
||||
|
||||
**v0.8.454 (Phase 491):** App automation compositor + platform span metadata on hook/manifest/dry-run. Bundle: `test:phase491-automation` (app repo). See [AUTOMATION_EXPORT.md](../../docs/AUTOMATION_EXPORT.md).
|
||||
|
||||
## Mission
|
||||
|
||||
Let moderators snapshot automod settings, slash commands, bot metadata, and automation hooks into a portable `pearcord-automation-manifest`, validate imports, and store up to 32 hooks per guild in JSON collections synced via mesh slices.
|
||||
|
||||
## When to use / not
|
||||
|
||||
**Use when:**
|
||||
|
||||
- Exporting/importing server automation configuration.
|
||||
- CRUD on automation hooks (`AutomationManifestStore`).
|
||||
- Building manifest JSON for backup or migration.
|
||||
|
||||
**Do not use when:**
|
||||
|
||||
- You need runtime hook dispatch — use `pearcord-delivery-receipts`.
|
||||
- You need live event streaming — use `pearcord-app-events`.
|
||||
|
||||
## Public API
|
||||
|
||||
| Export | Role |
|
||||
|--------|------|
|
||||
| `AutomationManifestStore` | `listHooks`, `upsertHook`, `deleteHook`, `replaceHooks`, `exportSyncSlice`, `ingestSyncSlice` |
|
||||
| `buildAutomationManifest({...})` | Versioned manifest object |
|
||||
| `validateAutomationManifest(manifest, opts?)` | `{ ok, errors[] }` |
|
||||
| `normalizeHook(partial, guildId)` | Sanitized hook row |
|
||||
| `MANIFEST_VERSION` | `1` |
|
||||
| `MAX_HOOKS` | 32 |
|
||||
| `ACTION_TYPES` / `ACTION_TYPE_LIST` | `log`, `notify`, `fanout-local`, `digest-relay` |
|
||||
| `CATEGORY_OPTIONS` | UI flags from `APP_EVENT_CATEGORIES` |
|
||||
| `HOOKS_COLLECTION` | `@pearcord/automation-hooks` |
|
||||
|
||||
### Hook shape
|
||||
|
||||
```javascript
|
||||
{
|
||||
id, guildId, name, enabled, categories, // bitmask
|
||||
filter: { eventTypes: [] }, // optional subset
|
||||
action: { type: 'log' },
|
||||
note
|
||||
}
|
||||
```
|
||||
|
||||
### Manifest kind
|
||||
|
||||
`kind: 'pearcord-automation-manifest'`, includes sanitized `automod`, `slashCommands`, `bots`, `hooks`—no tokens or webhook secrets.
|
||||
|
||||
## P2P surface
|
||||
|
||||
Hook rows gossip through platform automation sync (mesh slice `{ hooks: [...] }`). Manifest import is a local file/IPC operation; re-gossip happens after `importAutomationManifest`.
|
||||
|
||||
## Storage
|
||||
|
||||
`JsonStore` at `{storagePath}/automation-export`.
|
||||
|
||||
Requires `setGuild(guildId)` before mutations.
|
||||
|
||||
## Platform integration
|
||||
|
||||
```javascript
|
||||
const {
|
||||
AutomationManifestStore,
|
||||
buildAutomationManifest,
|
||||
validateAutomationManifest
|
||||
} = require('pearcord-automation-export')
|
||||
|
||||
this.automationStore = new AutomationManifestStore({ storagePath })
|
||||
await this.automationStore.ready()
|
||||
this.automationStore.setGuild(guildId)
|
||||
|
||||
const manifest = buildAutomationManifest({
|
||||
guildId,
|
||||
guildName,
|
||||
exportedBy: userId,
|
||||
automodConfig: await automod.getConfig(),
|
||||
slashCommands: await slashRegistry.list(),
|
||||
bots: installedBots,
|
||||
hooks: await this.automationStore.listHooks()
|
||||
})
|
||||
```
|
||||
|
||||
## UI / IPC
|
||||
|
||||
- `export-automation-manifest` → `{ type: 'automation-manifest', manifest }`
|
||||
- `import-automation-manifest` — `merge` option
|
||||
- `upsert-automation-hook` / `delete-automation-hook`
|
||||
|
||||
## Code example
|
||||
|
||||
```javascript
|
||||
const { AutomationManifestStore, validateAutomationManifest } = require('pearcord-automation-export')
|
||||
|
||||
const store = new AutomationManifestStore({ storagePath: './pearcord-storage' })
|
||||
await store.ready()
|
||||
store.setGuild('g1')
|
||||
await store.upsertHook({
|
||||
name: 'Log joins',
|
||||
categories: 1 << 1, // MEMBERS
|
||||
action: { type: 'log' },
|
||||
filter: { eventTypes: ['memberJoin'] }
|
||||
})
|
||||
const check = validateAutomationManifest(manifest, { expectedGuildId: 'g1' })
|
||||
```
|
||||
|
||||
## Related docs
|
||||
|
||||
- Platform automation settings UI
|
||||
- `pearcord-delivery-receipts` for hook actions
|
||||
|
||||
## Tests
|
||||
|
||||
**v0.8.391 (Phase 428):** Audit JSON/JSON export uses `formatAuditExport` in `pearcord-delivery-receipts`; see `test:audit-export` and `docs/AUDIT_EXPORT.md`.
|
||||
|
||||
- IPC import/export smokes
|
||||
- Hook merge on mesh ingest
|
||||
@@ -0,0 +1,74 @@
|
||||
'use strict'
|
||||
|
||||
const MAX_EVENT_BYTES = 65536
|
||||
const MAX_EVENT_DEPTH = 8
|
||||
const MAX_STRING = 8192
|
||||
const MAX_ARRAY = 128
|
||||
|
||||
function utf8ByteLength (s) {
|
||||
if (typeof TextEncoder !== 'undefined') {
|
||||
return new TextEncoder().encode(s).length
|
||||
}
|
||||
return Buffer.byteLength(s, 'utf8')
|
||||
}
|
||||
|
||||
function measureDepth (value, depth = 0) {
|
||||
if (depth > MAX_EVENT_DEPTH) return depth
|
||||
if (value == null || typeof value !== 'object') return depth
|
||||
if (Array.isArray(value)) {
|
||||
let max = depth
|
||||
for (const item of value.slice(0, MAX_ARRAY)) {
|
||||
max = Math.max(max, measureDepth(item, depth + 1))
|
||||
}
|
||||
return max
|
||||
}
|
||||
let max = depth
|
||||
for (const key of Object.keys(value).slice(0, MAX_ARRAY)) {
|
||||
max = Math.max(max, measureDepth(value[key], depth + 1))
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
function sanitizeAutomationEventPayload (payload = {}) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return { ok: false, code: 'invalid_payload', message: 'event payload must be an object' }
|
||||
}
|
||||
const depth = measureDepth(payload)
|
||||
if (depth > MAX_EVENT_DEPTH) {
|
||||
return { ok: false, code: 'depth_exceeded', message: 'automation event nested too deeply' }
|
||||
}
|
||||
let serialized
|
||||
try {
|
||||
serialized = JSON.stringify(payload)
|
||||
} catch {
|
||||
return { ok: false, code: 'non_serializable', message: 'automation event is not JSON-serializable' }
|
||||
}
|
||||
if (utf8ByteLength(serialized) > MAX_EVENT_BYTES) {
|
||||
return { ok: false, code: 'too_large', message: 'automation event payload exceeds size limit' }
|
||||
}
|
||||
const safe = {
|
||||
id: String(payload.id || '').slice(0, 64),
|
||||
type: String(payload.type || '').slice(0, 96),
|
||||
guildId: payload.guildId != null ? String(payload.guildId).slice(0, 64) : null,
|
||||
channelId: payload.channelId != null ? String(payload.channelId).slice(0, 64) : null,
|
||||
createdAt: Number(payload.createdAt) || Date.now(),
|
||||
data:
|
||||
payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)
|
||||
? JSON.parse(
|
||||
JSON.stringify(payload.data, (_k, v) =>
|
||||
typeof v === 'string' ? v.slice(0, MAX_STRING) : v
|
||||
)
|
||||
)
|
||||
: null
|
||||
}
|
||||
if (!safe.id || !safe.type) {
|
||||
return { ok: false, code: 'missing_fields', message: 'automation event requires id and type' }
|
||||
}
|
||||
return { ok: true, payload: safe }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sanitizeAutomationEventPayload,
|
||||
MAX_EVENT_BYTES,
|
||||
MAX_EVENT_DEPTH
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
'use strict'
|
||||
|
||||
const path = require('bare-path')
|
||||
const { JsonStore } = require('pearcord-db/store-json')
|
||||
const { id, now } = require('pearcord-shared')
|
||||
const { APP_EVENT_CATEGORIES } = require('pearcord-app-events')
|
||||
|
||||
const MANIFEST_VERSION = 1
|
||||
const HOOKS_COLLECTION = '@pearcord/automation-hooks'
|
||||
const MAX_HOOKS = 32
|
||||
const MAX_HOOK_NAME = 48
|
||||
const MAX_HOOK_NOTE = 256
|
||||
|
||||
const ACTION_TYPES = new Set(['log', 'notify', 'fanout-local', 'digest-relay'])
|
||||
const ACTION_TYPE_LIST = ['log', 'notify', 'fanout-local', 'digest-relay']
|
||||
|
||||
const CATEGORY_OPTIONS = [
|
||||
{ flag: APP_EVENT_CATEGORIES.MESSAGES, label: 'Messages' },
|
||||
{ flag: APP_EVENT_CATEGORIES.MEMBERS, label: 'Members' },
|
||||
{ flag: APP_EVENT_CATEGORIES.CHANNELS, label: 'Channels' },
|
||||
{ flag: APP_EVENT_CATEGORIES.PERMISSIONS, label: 'Permissions' },
|
||||
{ flag: APP_EVENT_CATEGORIES.GUILD, label: 'Guild' }
|
||||
]
|
||||
|
||||
function normalizeHook (partial = {}, guildId) {
|
||||
const hookId = partial.id || id()
|
||||
const name = String(partial.name || 'Automation hook')
|
||||
.trim()
|
||||
.slice(0, MAX_HOOK_NAME)
|
||||
const categories = Number(partial.categories)
|
||||
const cats = Number.isFinite(categories) ? categories : APP_EVENT_CATEGORIES.MESSAGES
|
||||
const actionType = ACTION_TYPES.has(partial.action?.type)
|
||||
? partial.action.type
|
||||
: 'log'
|
||||
const filter = partial.filter && typeof partial.filter === 'object' ? partial.filter : {}
|
||||
const eventTypes = Array.isArray(filter.eventTypes)
|
||||
? filter.eventTypes.map((e) => String(e).slice(0, 48)).slice(0, 16)
|
||||
: []
|
||||
return {
|
||||
id: hookId,
|
||||
guildId,
|
||||
name: name || 'Automation hook',
|
||||
enabled: partial.enabled !== false,
|
||||
categories: cats,
|
||||
filter: { eventTypes },
|
||||
action: { type: actionType },
|
||||
note: String(partial.note || '').slice(0, MAX_HOOK_NOTE),
|
||||
updatedAt: now()
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeSlashCommands (rows = []) {
|
||||
return rows
|
||||
.map((row) => ({
|
||||
name: String(row.name || '').slice(0, 32),
|
||||
description: String(row.description || '').slice(0, 100),
|
||||
builtin: !!row.builtin
|
||||
}))
|
||||
.filter((r) => r.name)
|
||||
.slice(0, 64)
|
||||
}
|
||||
|
||||
function sanitizeBots (rows = []) {
|
||||
return rows
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
name: String(row.name || '').slice(0, 32),
|
||||
intents: Number(row.intents) || 0,
|
||||
permissions: Number(row.permissions) || 0
|
||||
}))
|
||||
.filter((r) => r.id)
|
||||
.slice(0, 16)
|
||||
}
|
||||
|
||||
function buildAutomationManifest ({
|
||||
guildId,
|
||||
guildName,
|
||||
exportedBy,
|
||||
automodConfig = null,
|
||||
slashCommands = [],
|
||||
bots = [],
|
||||
hooks = [],
|
||||
appEventCategories = APP_EVENT_CATEGORIES.ALL
|
||||
}) {
|
||||
if (!guildId) throw new Error('guildId required')
|
||||
const at = now()
|
||||
const manifest = {
|
||||
v: MANIFEST_VERSION,
|
||||
kind: 'pearcord-automation-manifest',
|
||||
guildId,
|
||||
guildName: String(guildName || 'Server').slice(0, 64),
|
||||
exportedBy: exportedBy || null,
|
||||
exportedAt: at,
|
||||
appEventCategories: Number(appEventCategories) || APP_EVENT_CATEGORIES.ALL,
|
||||
automod: automodConfig
|
||||
? {
|
||||
enabled: !!automodConfig.enabled,
|
||||
blockedKeywords: automodConfig.blockedKeywords || [],
|
||||
maxMentions: automodConfig.maxMentions ?? 5,
|
||||
spamLimit: automodConfig.spamLimit ?? 5,
|
||||
spamWindowSeconds: automodConfig.spamWindowSeconds ?? 5,
|
||||
blockInvites: automodConfig.blockInvites !== false
|
||||
}
|
||||
: null,
|
||||
slashCommands: sanitizeSlashCommands(slashCommands),
|
||||
bots: sanitizeBots(bots),
|
||||
hooks: hooks.map((h) => normalizeHook(h, guildId))
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
function validateAutomationManifest (manifest, opts = {}) {
|
||||
const errors = []
|
||||
if (!manifest || typeof manifest !== 'object') {
|
||||
return { ok: false, errors: ['manifest must be an object'] }
|
||||
}
|
||||
if (manifest.v !== MANIFEST_VERSION) {
|
||||
errors.push(`unsupported manifest version (expected ${MANIFEST_VERSION})`)
|
||||
}
|
||||
if (manifest.kind !== 'pearcord-automation-manifest') {
|
||||
errors.push('invalid manifest kind')
|
||||
}
|
||||
if (!manifest.guildId) errors.push('guildId required')
|
||||
if (opts.expectedGuildId && manifest.guildId !== opts.expectedGuildId) {
|
||||
errors.push('manifest is for a different server')
|
||||
}
|
||||
if (!Array.isArray(manifest.hooks)) errors.push('hooks must be an array')
|
||||
if (manifest.hooks?.length > MAX_HOOKS) errors.push(`max ${MAX_HOOKS} hooks`)
|
||||
return { ok: errors.length === 0, errors }
|
||||
}
|
||||
|
||||
class AutomationManifestStore {
|
||||
constructor (opts = {}) {
|
||||
this.storagePath = opts.storagePath || './pearcord-storage'
|
||||
this.guildId = opts.guildId || null
|
||||
this.store = new JsonStore(path.join(this.storagePath, 'automation-export'))
|
||||
}
|
||||
|
||||
async ready () {
|
||||
await this.store.ready()
|
||||
return this
|
||||
}
|
||||
|
||||
setGuild (guildId) {
|
||||
this.guildId = guildId
|
||||
}
|
||||
|
||||
_guildKey () {
|
||||
return { guildId: this.guildId }
|
||||
}
|
||||
|
||||
async listHooks () {
|
||||
if (!this.guildId) return []
|
||||
const rows = await this.store.find(HOOKS_COLLECTION, this._guildKey())
|
||||
return rows.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
|
||||
}
|
||||
|
||||
async getHook (hookId) {
|
||||
const rows = await this.listHooks()
|
||||
return rows.find((r) => r.id === hookId) || null
|
||||
}
|
||||
|
||||
async upsertHook (partial = {}) {
|
||||
if (!this.guildId) throw new Error('guildId required')
|
||||
const rows = await this.listHooks()
|
||||
if (!partial.id && rows.length >= MAX_HOOKS) {
|
||||
throw new Error(`max ${MAX_HOOKS} automation hooks per server`)
|
||||
}
|
||||
const existing = partial.id ? rows.find((r) => r.id === partial.id) : null
|
||||
const row = normalizeHook({ ...existing, ...partial, guildId: this.guildId }, this.guildId)
|
||||
await this.store.insert(HOOKS_COLLECTION, row)
|
||||
return row
|
||||
}
|
||||
|
||||
async deleteHook (hookId) {
|
||||
if (!this.guildId || !hookId) return false
|
||||
const row = await this.getHook(hookId)
|
||||
if (!row) return false
|
||||
await this.store.delete(HOOKS_COLLECTION, { id: row.id, guildId: this.guildId })
|
||||
return true
|
||||
}
|
||||
|
||||
async replaceHooks (hooks = [], { merge = false } = {}) {
|
||||
if (!this.guildId) throw new Error('guildId required')
|
||||
const incoming = hooks.map((h) => normalizeHook(h, this.guildId))
|
||||
if (!merge) {
|
||||
const current = await this.listHooks()
|
||||
for (const row of current) {
|
||||
await this.store.delete(HOOKS_COLLECTION, { id: row.id, guildId: this.guildId })
|
||||
}
|
||||
}
|
||||
const merged = merge ? await this.listHooks() : []
|
||||
const byId = new Map(merged.map((r) => [r.id, r]))
|
||||
for (const row of incoming) {
|
||||
byId.set(row.id, row)
|
||||
}
|
||||
const final = [...byId.values()].slice(0, MAX_HOOKS)
|
||||
for (const row of final) {
|
||||
await this.store.insert(HOOKS_COLLECTION, row)
|
||||
}
|
||||
return final
|
||||
}
|
||||
|
||||
async exportSyncSlice () {
|
||||
if (!this.guildId) return { hooks: [] }
|
||||
return { hooks: await this.listHooks() }
|
||||
}
|
||||
|
||||
async ingestSyncSlice (guildId, payload = {}) {
|
||||
if (!guildId || guildId !== this.guildId) return []
|
||||
const hooks = payload.hooks || []
|
||||
if (!hooks.length) return []
|
||||
return this.replaceHooks(hooks, { merge: true })
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
sanitizeAutomationEventPayload,
|
||||
MAX_EVENT_BYTES,
|
||||
MAX_EVENT_DEPTH
|
||||
} = require('./automation-payload-guard')
|
||||
|
||||
module.exports = {
|
||||
MANIFEST_VERSION,
|
||||
MAX_HOOKS,
|
||||
APP_EVENT_CATEGORIES,
|
||||
ACTION_TYPES,
|
||||
ACTION_TYPE_LIST,
|
||||
CATEGORY_OPTIONS,
|
||||
normalizeHook,
|
||||
buildAutomationManifest,
|
||||
validateAutomationManifest,
|
||||
sanitizeAutomationEventPayload,
|
||||
MAX_EVENT_BYTES,
|
||||
MAX_EVENT_DEPTH,
|
||||
AutomationManifestStore
|
||||
}
|
||||
Generated
+75
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "pearcord-automation-export",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pearcord-automation-export",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"bare-path": "^3.0.0",
|
||||
"pearcord-app-events": "file:../pearcord-app-events",
|
||||
"pearcord-db": "file:../pearcord-db",
|
||||
"pearcord-shared": "file:../pearcord-shared"
|
||||
}
|
||||
},
|
||||
"../pearcord-app-events": {
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"bare-events": "^2.8.0",
|
||||
"pearcord-bot-events": "file:../pearcord-bot-events",
|
||||
"pearcord-shared": "file:../pearcord-shared"
|
||||
}
|
||||
},
|
||||
"../pearcord-db": {
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"bare-fs": "^4.0.0",
|
||||
"bare-path": "^3.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"hyperdb": "^5.0.0",
|
||||
"hyperschema": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"../pearcord-shared": {
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"b4a": "^1.6.7",
|
||||
"compact-encoding": "^2.0.0",
|
||||
"hypercore-crypto": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bare-os": {
|
||||
"version": "3.9.1",
|
||||
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz",
|
||||
"integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"bare": ">=1.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bare-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
|
||||
"integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bare-os": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pearcord-app-events": {
|
||||
"resolved": "../pearcord-app-events",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/pearcord-db": {
|
||||
"resolved": "../pearcord-db",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/pearcord-shared": {
|
||||
"resolved": "../pearcord-shared",
|
||||
"link": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "pearcord-automation-export",
|
||||
"version": "0.1.0",
|
||||
"main": "index.js",
|
||||
"type": "commonjs",
|
||||
"description": "Guild automation manifest export/import (P2P-safe, no secrets)",
|
||||
"dependencies": {
|
||||
"bare-path": "^3.0.0",
|
||||
"pearcord-app-events": "git+https://git.ssh.surf/pearcord/pearcord-app-events.git#main",
|
||||
"pearcord-db": "git+https://git.ssh.surf/pearcord/pearcord-db.git#main",
|
||||
"pearcord-shared": "git+https://git.ssh.surf/pearcord/pearcord-shared.git#main"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user