Files
WeKnora/pluginsdk/pluginapi/envelope.go
wizardchen e095ee7444 feat(plugin): event delivery and webhooks for plugins
Events:
- A plugin lists what it wants in permissions.events: knowledge.ingested,
  knowledge.failed, knowledge.deleted or chat.answered. The admin reviews
  the list at install; a plugin that only listens needs no other
  contribution.
- A publish enqueues one plugin:event task per subscribed plugin the
  workspace has on (maintenance queue, Redis or Lite).
- The task calls the plugin's new /v1/events at least once. Retryable
  failures retry up to 10 times, keeping the event ID; other failures drop
  the event.
- The plugin host refuses a process that subscribes without serving
  events.

Where events come from:
- A document reaches completed or failed through a dozen paths but a few
  repository writes, so a knowledge repository wrapper raises
  knowledge.ingested and knowledge.failed on the transition only:
  UpdateKnowledge, UpdateKnowledgeForTransfer, UpdateKnowledgeColumns,
  FinalizeSubtask, CompleteProcessingWithoutSubtasks.
- knowledge.deleted comes from the shared delete path.
- chat.answered comes from where RAG and agent answers are persisted. The
  stop button's early completion, which has no question, is skipped.

Webhooks:
- contributes.webhooks gives each workspace a secret URL per webhook:
  /api/v1/plugin-callbacks/{plugin}/{webhook}/{tenant}.{mac}. The MAC is
  keyed from SYSTEM_AES_KEY/JWT_SECRET.
- WeKnora answers 404 unless the URL is genuine, the webhook exists and the
  workspace has the plugin on. Calls are rate-limited per URL, bodies are
  capped at 1 MB and cookies are stripped.
- The call is relayed to the plugin's /v1/webhooks/{id} with that
  workspace's envelope; the plugin verifies the sender.
- Admins get the URLs from GET /plugins/:id/webhooks, shown with copy
  buttons in the plugin center's configure drawer. Plugins get them in
  context.webhooks when APP_EXTERNAL_URL is set.

SDKs:
- Go: OnEvent and Webhook.
- Python: @plugin.on_event and @plugin.webhook.
- Conformance, OpenAPI and READMEs are updated.

Also: examples/plugins/activity (Python) keeps a workspace activity feed
from events and a secret-checked note webhook, and shows it on a toolbox
page.
2026-09-26 11:21:55 +08:00

76 lines
2.6 KiB
Go

package pluginapi
import (
"encoding/json"
"time"
)
// Envelope is the body of every call. Plugins must be stateless: everything
// about the tenant comes in the envelope, and credentials must not be cached
// across calls.
type Envelope struct {
Context Context `json:"context"`
Config Config `json:"config"`
// Input is the endpoint-specific request, such as a SearchInput.
Input json.RawMessage `json:"input,omitempty"`
}
// Context describes who is calling and until when.
type Context struct {
TenantID uint64 `json:"tenantId"`
UserID string `json:"userId,omitempty"`
Locale string `json:"locale,omitempty"`
RequestID string `json:"requestId,omitempty"`
// Deadline is when WeKnora stops waiting; plugins should give up by then.
Deadline *time.Time `json:"deadline,omitempty"`
// Host lets the plugin call back into WeKnora (Host API); absent when
// the plugin was granted no scopes.
Host *HostAccess `json:"host,omitempty"`
// Webhooks are this workspace's URLs of the plugin's webhooks, by
// webhook ID, for registering them with a third party. Present only
// when WeKnora knows its public address.
Webhooks map[string]string `json:"webhooks,omitempty"`
}
// HostAccess is where and how a plugin calls the WeKnora Host API.
type HostAccess struct {
URL string `json:"url"`
// Token is a short-lived bearer token bound to this tenant and the
// plugin's granted scopes.
Token string `json:"token"`
}
// Config carries the three configuration scopes separately, secrets already
// decrypted. They are never merged, so a key means the same thing wherever
// it appears.
type Config struct {
// System is the platform-wide configuration (config.system).
System map[string]any `json:"system,omitempty"`
// Tenant is the workspace configuration (config.tenant).
Tenant map[string]any `json:"tenant,omitempty"`
// Instance is the configuration of the integration instance being used:
// a data source, a web search provider.
Instance map[string]any `json:"instance,omitempty"`
}
// Output wraps a successful answer.
type Output struct {
Output json.RawMessage `json:"output"`
}
// Manifest is what GET /v1/manifest answers: enough for WeKnora to check that
// the process it started is the package it installed.
type Manifest struct {
ID string `json:"id"`
Version string `json:"version"`
APIVersion string `json:"apiVersion"`
// Contributes lists the contribution IDs the process serves per
// extension point, e.g. {"connectors": ["rss"]}.
Contributes map[string][]string `json:"contributes"`
}
// Health is what GET /v1/health answers.
type Health struct {
Status string `json:"status"` // "ok"
}