chore: publish pearcord-automation-export from pearcord workspace

This commit is contained in:
Pearcord
2026-07-12 23:40:23 -04:00
commit 58da066159
6 changed files with 526 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
node_modules/
+126
View File
@@ -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
+74
View File
@@ -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
}
+237
View File
@@ -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
}
+75
View File
@@ -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
}
}
}
+13
View File
@@ -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"
}
}