File size: 27,121 Bytes
46252cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
/**
 * Plugin System Interfaces
 * Defines the contract for OpenWA plugins
 */

import { HookManager, HookEvent, HookHandler } from '../hooks';
import type { MessageResponseDto } from '../../modules/message/dto';
import type { IWhatsAppEngine } from '../../engine/interfaces/whatsapp-engine.interface';
import type { PluginNetRequestInit, PluginNetResponse } from './plugin-net';
import type { HandoverState } from '../../modules/integration/entities/conversation-mapping.entity';
import type { WebhookRequest, WebhookResponse, WebhookHandler } from './sandbox/worker-webhooks';

// Re-export the ingress webhook types on the public SDK surface so plugin authors can type their
// handler without importing from sandbox internals.
export type { WebhookRequest, WebhookResponse, WebhookHandler };

// ============================================================================
// Plugin Types
// ============================================================================

export enum PluginType {
  ENGINE = 'engine', // WhatsApp engine (whatsapp-web.js, baileys, etc.)
  STORAGE = 'storage', // Storage backends (local, S3, GCS, etc.)
  QUEUE = 'queue', // Queue systems (Redis, RabbitMQ, etc.)
  AUTH = 'auth', // Authentication providers
  EXTENSION = 'extension', // General extensions (auto-reply, scheduler, etc.)
}

export enum PluginStatus {
  INSTALLED = 'installed',
  ENABLED = 'enabled',
  DISABLED = 'disabled',
  ERROR = 'error',
}

// ============================================================================
// Plugin Manifest
// ============================================================================

export interface PluginManifest {
  id: string; // Unique identifier (e.g., 'whatsapp-web.js', 'auto-reply')
  name: string; // Display name
  version: string; // Semver
  type: PluginType;
  description?: string;
  author?: string;
  homepage?: string;
  repository?: string;
  license?: string;

  // Entry point
  main: string; // Relative path to main file

  // Dependencies
  dependencies?: Record<string, string>;
  peerDependencies?: Record<string, string>;

  // Configuration schema (optional, for UI generation)
  configSchema?: PluginConfigSchema;

  // Optional sandboxed-iframe config editor. `entry` is a plugin-relative path to a self-contained
  // HTML file (inline JS/CSS β€” a sandboxed opaque-origin iframe can't load subresources). Served by
  // the host via the authenticated GET /plugins/:id/config-ui and injected as an iframe `srcdoc`; the
  // editor exchanges config over a postMessage bridge (the API key never reaches the iframe). When
  // present, the dashboard prefers it over the declarative `configSchema` form.
  configUi?: { entry: string; height?: number };

  // Hooks this plugin listens to
  hooks?: HookEvent[];

  // Features provided by this plugin
  provides?: string[];

  // Required features from other plugins
  requires?: string[];

  // Capability permissions this plugin declares; the loader enforces them at the capability
  // boundary (see PluginCapabilityPermission). A capability call whose permission is not declared
  // here is denied with a PluginCapabilityError. Absent / empty = no capability access.
  permissions?: string[];

  // Session ids this plugin may act on, or ['*']. Absent = ['*'] (all). Enforced by the
  // capability facade. Static (manifest) by design: editing plugin config cannot widen scope.
  sessions?: string[];

  // Whether the plugin is scoped to specific sessions (default true). A session-scoped plugin only
  // receives hook events for the sessions an operator has activated it for (see activeSessions); a
  // global plugin (false) always runs, with no per-number notion (e.g. a metrics logger).
  sessionScoped?: boolean;

  // Outbound-HTTP host allowlist for `ctx.net.fetch` (requires the `net:fetch` permission). Each
  // entry is `host:port` (exact) or a bare `host` (any port); `'*'` allows any public host. Absent /
  // empty = deny all. `allowConfigHosts` additionally admits the host of each named config key (e.g. an
  // operator-set base URL), resolved at fetch time. The SSRF guard still blocks internal IPs regardless.
  net?: { allow?: string[]; allowConfigHosts?: string[] };

  // Localized dashboard text (name/description/config field titles) per locale code. English is the
  // base manifest + fallback. Dashboard-only; does not affect runtime behavior.
  i18n?: PluginI18n;

  // Integration SDK major.minor the plugin was authored against (e.g. '1' or '1.2'). Only the major
  // is enforced β€” see SUPPORTED_SDK_MAJOR / validateIngressManifest. Absent = treated as '1'.
  sdkVersion?: string;

  // Inbound webhook routes this plugin claims (requires the `webhook:ingress` permission). Validated
  // by validateIngressManifest, which the loader calls on every external plugin load (loadPlugin).
  // (Built-in registration declares no ingress and bypasses that validation.)
  ingress?: PluginIngressRoute[];
}

/** Localized overrides for a plugin's dashboard-facing text, per locale (dashboard i18n). */
export interface PluginI18nText {
  title?: string;
  description?: string;
}
export interface PluginI18nLocale {
  name?: string;
  description?: string;
  /** Keyed by a TOP-LEVEL configSchema.properties key; only title/description are localized. */
  config?: Record<string, PluginI18nText>;
}
/** Keyed by a dashboard locale code (e.g. "es", "zh-CN"). Untranslated entries fall back to English. */
export type PluginI18n = Record<string, PluginI18nLocale>;

/**
 * One field in a plugin's config schema. Recursive: an `object` field nests `properties`, an `array`
 * field describes its element with `items` (array-of-rows when `items.type === 'object'`). The host
 * renders this into an authenticated form; the plugin still reads `ctx.config` defensively.
 */
export interface PluginConfigField {
  // 'textarea' is a string rendered multi-line; a field with `enum` renders as a <select>.
  type: 'string' | 'number' | 'boolean' | 'array' | 'object' | 'textarea';
  title?: string;
  description?: string;
  default?: unknown;
  enum?: unknown[]; // when present (any scalar type), the field renders as a <select>
  required?: boolean;
  secret?: boolean; // sensitive value (e.g. API key): masked on read, preserved on an unchanged write
  // Validation hints, surfaced as HTML input attributes (advisory β€” not hard-enforced server-side):
  min?: number; // number: value bound; string/textarea: minLength; array: min rows
  max?: number; // number: value bound; string/textarea: maxLength; array: max rows
  pattern?: string; // string/textarea: HTML validation regex
  // Composite kinds:
  items?: PluginConfigField; // array element schema; array-of-rows when items.type === 'object'
  properties?: Record<string, PluginConfigField>; // nested-object fields (type: 'object')
}

export interface PluginConfigSchema {
  type: 'object';
  properties: Record<string, PluginConfigField>;
}

// ============================================================================
// Plugin Capability
// ============================================================================

/**
 * Capability permissions a plugin declares in its manifest `permissions` and that the loader
 * enforces at the capability boundary. A plugin may only use a capability whose permission it
 * declares; an undeclared (or missing-permission) plugin is denied with a PluginCapabilityError.
 */
export const PluginCapabilityPermission = {
  /** `ctx.messages.*` β€” send / reply on a session. */
  MESSAGES_SEND: 'messages:send',
  /** `ctx.engine.*` β€” read-only engine queries (group info, contacts, chats, number check). */
  ENGINE_READ: 'engine:read',
  /** `ctx.net.fetch` β€” SSRF-guarded outbound HTTP, scoped to the manifest `net.allow` host list. */
  NET_FETCH: 'net:fetch',
  /** `ctx.registerWebhook` β€” claim an inbound ingress route. Loader-enforced; cannot be widened by config. */
  WEBHOOK_INGRESS: 'webhook:ingress',
  /** `ctx.conversations.send` β€” normalized outbound send translated to MessageService. */
  CONVERSATION_SEND: 'conversation:send',
} as const;
export type PluginCapabilityPermission = (typeof PluginCapabilityPermission)[keyof typeof PluginCapabilityPermission];

// ============================================================================
// Integration SDK v1 β€” inbound webhook ingress + normalized outbound send
// ============================================================================

/** How an inbound webhook's authenticity is established before the plugin sees it. */
export interface IngressSignatureSpec {
  /**
   * - `hmac-sha256`: HMAC over a `contentTemplate` (tokens `{rawBody}`/`{timestamp}`/`{id}`).
   * - `shared-secret`: constant-time compare of a header value against `instance.secret`.
   * - `standard-webhooks`: host-side [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks)
   *   verify. The wire format is fixed by the spec (headers `webhook-id`/`webhook-timestamp`/
   *   `webhook-signature`, signed content `${webhook-id}.${webhook-timestamp}.${rawBody}`, base64
   *   HMAC-SHA256 with the base64-decoded Svix key, `v1,` prefix, space-separated candidate list), so
   *   `header`/`contentTemplate`/`encoding`/`prefix`/`timestampHeader` are IGNORED β€” only
   *   `toleranceSec` (default 300) and `dedupHeader` apply. The operator pastes the Svix secret
   *   (`v1,whsec_<base64>`) as `instance.secret`.
   */
  scheme: 'hmac-sha256' | 'shared-secret' | 'standard-webhooks' | 'none';
  header?: string;
  // Template over which the HMAC is computed. `{rawBody}` `{timestamp}` `{id}` placeholders.
  contentTemplate?: string;
  encoding?: 'hex' | 'base64';
  prefix?: string;
  timestampHeader?: string;
  toleranceSec?: number; // when present, must be > 0 (see validateIngressManifest)
  dedupHeader?: string;
}

/** Provider webhook-verification challenge (e.g. a GET handshake on route registration). */
export interface IngressChallengeSpec {
  method: 'GET';
  tokenParam: string;
  echoParam: string;
}

/** A host-side preflight check on an inbound route, evaluated AFTER signature verify and BEFORE the
 *  dedup persist. First failure short-circuits to its mapped HTTP status. O(1), never initializes the
 *  engine, never mutates state. */
export type IngressPreflightCheck = {
  // Reject (503) when the route's concrete-scoped WhatsApp session is not alive (no live engine, or
  // EngineStatus.FAILED). Recoverable statuses (INITIALIZING/QR_READY/AUTHENTICATING/DISCONNECTED) and
  // READY pass through to a normal 202+enqueue so the worker can fail fast and the dedup row holds the
  // delivery. Skipped for wildcard (sessionScope null/'*') scopes β€” there is no single session to probe.
  type: 'session-alive';
};

/** Declares the synchronous HTTP response an inbound route returns to the provider, computed entirely
 *  host-side. The plugin ALWAYS runs async (enqueued, full DLQ/retry) regardless of this contract. */
export interface IngressResponseContract {
  preflight?: IngressPreflightCheck[];
  ack?: {
    status?: number; // default 202
    body?: string; // literal, or a '{rawBody}'/'{timestamp}'/'{id}' template rendered host-side
    headers?: Record<string, string>; // static; validated at load (HTTP-token name, no CR/LF value)
  };
  deadlineMs?: number; // documented provider ack budget (advisory; not enforced)
}

/** One inbound webhook route a plugin claims. Requires the `webhook:ingress` permission. */
export interface PluginIngressRoute {
  route: string; // host prefixes it; the plugin never binds a port
  /**
   * @deprecated 'sync-reply' is inert dead code since the P0 substrate (#568) and is NOT wired to the
   * HTTP response β€” the pipeline is always async + fast-ack. Declare synchronous response behavior via
   * `response` instead. Kept in the union only to preserve SDK v1 additive-only compatibility; do not
   * remove within major 1, and do not rely on either value at runtime.
   */
  mode: 'async' | 'sync-reply';
  signature: IngressSignatureSpec;
  challenge?: IngressChallengeSpec;
  /** Reserved/advisory compatibility field. Authenticity is currently verified by the host according
   *  to `signature`; the worker does not perform an additional `self` verification pass. */
  verify: 'core' | 'self';
  maxBodyBytes: number;
  // Optional: where the provider's conversation id lives, so the host can compute a per-conversation
  // ordering key (P1). Absent => the P1 lock falls back to per-instance serialization. The host never
  // needs to understand the provider's schema beyond this one pointer.
  conversationId?: { header?: string; jsonPointer?: string };
  /** Optional synchronous-response contract (host-side preflight + ack). Additive; absent = today's
   *  default 202 fast-ack, byte-identical. Validated by validateIngressManifest. */
  response?: IngressResponseContract;
}

// Normalized outbound envelope for ctx.conversations.send (POJO across the wire).
export interface ConversationSendEnvelope {
  sessionId?: string;
  instanceId?: string;
  chatId?: string;
  type: 'text' | 'image' | 'file' | 'audio' | 'video' | 'voice' | 'location';
  text?: string;
  mediaUrl?: string;
  replyTo?: string;
  source?: { provider: string; externalConversationId: string };
}

/** Integration SDK major version this host supports. A plugin whose `sdkVersion` major differs is refused. */
export const SUPPORTED_SDK_MAJOR = 1;

// ack header guards: name must be an RFC 7230 token (no spaces/separators), value must contain no
// CR/LF (header-injection guard). The header source is the static manifest, validated once at load.
const HTTP_HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
const HTTP_HEADER_VALUE_NO_CRLF = /^[^\r\n]*$/;

/**
 * Validates a manifest's `ingress` declarations: SDK major compatibility, the `webhook:ingress`
 * permission, route uniqueness, and that a declared `toleranceSec` is usable (> 0 β€” a replay window
 * of zero or less would make the tolerance check a no-op). A manifest with no `ingress` entries is a
 * no-op. Called from PluginLoaderService.loadPlugin, so a malformed declaration is rejected at load time.
 */
export function validateIngressManifest(manifest: PluginManifest): void {
  if (!manifest.ingress?.length) return; // no ingress declared β†’ nothing to validate
  const declaredMajor = Number.parseInt((manifest.sdkVersion ?? '1').split('.')[0], 10);
  if (!Number.isFinite(declaredMajor) || declaredMajor !== SUPPORTED_SDK_MAJOR) {
    throw new Error(
      `Plugin ${manifest.id}: SDK major ${manifest.sdkVersion} is not supported by this host (supports ${SUPPORTED_SDK_MAJOR})`,
    );
  }
  const perms = manifest.permissions ?? [];
  if (!perms.includes(PluginCapabilityPermission.WEBHOOK_INGRESS)) {
    throw new Error(`Plugin ${manifest.id}: declares ingress routes but is missing the 'webhook:ingress' permission`);
  }
  const seen = new Set<string>();
  for (const r of manifest.ingress) {
    if (!r.route || seen.has(r.route)) {
      throw new Error(`Plugin ${manifest.id}: duplicate or empty ingress route '${r.route}'`);
    }
    seen.add(r.route);
    if (r.signature.toleranceSec !== undefined && r.signature.toleranceSec <= 0) {
      throw new Error(
        `Plugin ${manifest.id}: route '${r.route}' toleranceSec must be > 0 (a replay guard would be a no-op)`,
      );
    }
    if (r.response) {
      const ackStatus = r.response.ack?.status;
      if (ackStatus !== undefined && (!Number.isInteger(ackStatus) || ackStatus < 100 || ackStatus > 599)) {
        throw new Error(
          `Plugin ${manifest.id}: route '${r.route}' response.ack.status must be a valid HTTP status (100-599)`,
        );
      }
      if (r.response.ack?.headers) {
        for (const [name, value] of Object.entries(r.response.ack.headers)) {
          if (!HTTP_HEADER_NAME.test(name)) {
            throw new Error(
              `Plugin ${manifest.id}: route '${r.route}' response.ack header name '${name}' is not a valid HTTP token`,
            );
          }
          if (!HTTP_HEADER_VALUE_NO_CRLF.test(value)) {
            throw new Error(
              `Plugin ${manifest.id}: route '${r.route}' response.ack header '${name}' has invalid characters (CR/LF forbidden)`,
            );
          }
        }
      }
    }
  }
}

/**
 * Warns about each ingress route declared with `scheme: 'none'` β€” a fully-unauthenticated public endpoint
 * that anyone who can reach the host can use to trigger WhatsApp sends. Purely additive (a warning): a
 * deployment that legitimately relies on scheme:'none' (a provider that offers no HMAC) still boots; the
 * loud log surfaces the exposure so an operator can front the URL with a network/reverse-proxy guard.
 * Called from PluginLoaderService.loadPlugin at boot and on dynamic install.
 */
export function warnUnauthenticatedIngressRoutes(
  manifest: PluginManifest,
  logger: { warn: (message: string, context?: Record<string, unknown>) => void },
): void {
  for (const r of manifest.ingress ?? []) {
    if (r.signature.scheme === 'none') {
      logger.warn(
        `Ingress route '${r.route}' of plugin '${manifest.id}' uses signature scheme 'none' β€” it is an ` +
          `UNAUTHENTICATED public endpoint that can trigger WhatsApp sends. Only keep this if the provider ` +
          `offers no HMAC and the URL is guarded by a network/reverse-proxy ACL.`,
        { pluginId: manifest.id, route: r.route, action: 'ingress_unauthenticated_route' },
      );
    }
  }
}

/**
 * Thrown by a plugin capability when a call is rejected (missing permission, out-of-scope session,
 * unstarted session, etc.). Gives plugins a predictable failure instead of a raw TypeError.
 */
export class PluginCapabilityError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'PluginCapabilityError';
  }
}

export interface PluginMessagingCapability {
  sendText(sessionId: string, chatId: string, text: string): Promise<MessageResponseDto>;
  reply(sessionId: string, chatId: string, quotedMessageId: string, text: string): Promise<MessageResponseDto>;
}

export interface PluginEngineReadCapability {
  getGroupInfo(sessionId: string, groupId: string): ReturnType<IWhatsAppEngine['getGroupInfo']>;
  getContacts(sessionId: string): ReturnType<IWhatsAppEngine['getContacts']>;
  getContactById(sessionId: string, contactId: string): ReturnType<IWhatsAppEngine['getContactById']>;
  checkNumberExists(sessionId: string, phone: string): ReturnType<IWhatsAppEngine['checkNumberExists']>;
  getChats(sessionId: string): ReturnType<IWhatsAppEngine['getChats']>;
  /** Recent messages for a chat (both directions), for history backfill. `limit` is clamped host-side. */
  getChatHistory(
    sessionId: string,
    chatId: string,
    limit?: number,
    includeMedia?: boolean,
  ): ReturnType<IWhatsAppEngine['getChatHistory']>;
  /**
   * Canonical (neutral) form of a chat id: resolves a `@lid` privacy id to its stable `<phone>@c.us`
   * when the lid->phone mapping is known, and otherwise returns the id unchanged. Lets a plugin key a
   * chat by one identity across WhatsApp's `@lid` migration (best-effort; an unresolved lid stays `@lid`).
   */
  canonicalChatId(sessionId: string, chatId: string): Promise<string>;
}

/** Outbound HTTP for a plugin β€” always through the host SSRF guard, scoped to `manifest.net.allow`. */
export interface PluginNetCapability {
  fetch(url: string, init?: PluginNetRequestInit): Promise<PluginNetResponse>;
}

/** Normalized outbound send for a plugin β€” translated host-side to MessageService.sendText/reply. */
export interface PluginConversationsCapability {
  send(env: ConversationSendEnvelope): Promise<unknown>;
}

/**
 * Flip a mapped conversation's handover state. Reuses the `conversation:send` permission β€” flipping
 * handover is part of owning the conversation, not a distinct capability grant.
 */
export interface PluginHandoverCapability {
  set(key: { sessionId: string; chatId: string; instanceId: string }, state: HandoverState): Promise<unknown>;
}

/**
 * Plugin-facing conversation mapping: create/read the WA-chat <-> provider-conversation link an adapter
 * needs so handover.set and conversation.send({source}) can resolve. Reuses the `conversation:send`
 * permission β€” owning the mapping is part of owning the conversation.
 */
export interface PluginMappingsCapability {
  upsert(key: { sessionId: string; chatId: string; instanceId: string }, providerConversationId: string): Promise<void>;
  get(key: {
    sessionId: string;
    chatId: string;
    instanceId: string;
  }): Promise<{ providerConversationId: string; handoverState: HandoverState } | null>;
  getByProvider(
    instanceId: string,
    providerConversationId: string,
  ): Promise<{ sessionId: string; chatId: string; handoverState: HandoverState } | null>;
}

// ============================================================================
// Plugin Context (passed to plugin on initialization)
// ============================================================================

export interface PluginContext {
  // Plugin info
  pluginId: string;
  manifest: PluginManifest;

  // Configuration
  config: Record<string, unknown>;

  // Hook system
  hookManager: HookManager;

  // Logger instance for this plugin
  logger: PluginLogger;

  // Storage for plugin data
  storage: PluginStorage;

  // Register a hook handler
  registerHook: (event: HookEvent, handler: HookHandler, priority?: number) => void;

  // Claim an inbound ingress webhook route (requires the `webhook:ingress` permission). Delivered only
  // to sandboxed plugins via the ingress pipeline; in-process built-ins cannot receive ingress.
  registerWebhook: (route: string, handler: WebhookHandler) => void;

  // Curated write surface β€” routes through MessageService (persistence preserved).
  messages: PluginMessagingCapability;

  // Read-only, scoped engine queries.
  engine: PluginEngineReadCapability;

  // SSRF-guarded outbound HTTP, scoped to the manifest `net.allow` host list.
  net: PluginNetCapability;

  // Normalized outbound send, translated to MessageService. Requires `conversation:send`.
  conversations: PluginConversationsCapability;

  // Flip a mapped conversation's bot/human/closed handover state. Requires `conversation:send`.
  handover: PluginHandoverCapability;

  // Create/read the WA-chat <-> provider-conversation mapping. Requires `conversation:send`.
  mappings: PluginMappingsCapability;
}

export interface PluginLogger {
  log: (message: string, meta?: Record<string, unknown>) => void;
  debug: (message: string, meta?: Record<string, unknown>) => void;
  warn: (message: string, meta?: Record<string, unknown>) => void;
  error: (message: string, error?: unknown, meta?: Record<string, unknown>) => void;
}

export interface PluginStorage {
  get: <T = unknown>(key: string) => Promise<T | null>;
  set: <T = unknown>(key: string, value: T) => Promise<void>;
  delete: (key: string) => Promise<void>;
  list: (prefix?: string) => Promise<string[]>;
}

// ============================================================================
// Plugin Interface (what plugins must implement)
// ============================================================================

export interface IPlugin {
  // Lifecycle hooks
  onLoad?: (context: PluginContext) => Promise<void>;
  onEnable?: (context: PluginContext) => Promise<void>;
  onDisable?: (context: PluginContext) => Promise<void>;
  onUnload?: (context: PluginContext) => Promise<void>;

  // Configuration change handler
  onConfigChange?: (context: PluginContext, newConfig: Record<string, unknown>) => Promise<void>;

  // Health check (for dashboard monitoring)
  healthCheck?: () => Promise<{ healthy: boolean; message?: string }>;
}

// ============================================================================
// Engine Plugin Interface (extends IPlugin for engine-specific methods)
// ============================================================================

export interface IEnginePlugin extends IPlugin {
  type: PluginType.ENGINE;

  // Engine factory method
  createEngine: (config: Record<string, unknown>) => unknown;

  // Get supported features
  getFeatures: () => string[];

  // Underlying engine library name + version (e.g. { name: 'whatsapp-web.js', version: '1.34.7' }) β€”
  // distinct from the adapter/plugin's own manifest version. Optional: an engine may not report it.
  getEngineLibrary?: () => { name: string; version: string };
}

// ============================================================================
// Plugin Instance (runtime representation)
// ============================================================================

export interface PluginInstance {
  manifest: PluginManifest;
  status: PluginStatus;
  config: Record<string, unknown>;
  instance: IPlugin | null;
  error?: string;
  loadedAt?: Date;
  enabledAt?: Date;
  // Sessions a session-scoped plugin is activated for; ['*'] = all. Defaulted to ['*'] on enable.
  // Ignored for a global (sessionScoped:false) plugin. Persisted on the registry entry.
  activeSessions?: string[];
  // Per-session config overrides, keyed by sessionId. The config a hook sees for session S is the
  // override shallow-merged over `config` (the '*' base) β€” see resolvePluginConfig. Absent = no
  // overrides (every session gets the base). Persisted on the registry entry.
  sessionConfig?: Record<string, Record<string, unknown>>;
  // First-party built-ins (engines, bundled extensions) run in-process; plugins loaded from the
  // plugins directory are untrusted and run sandboxed in a worker. `false` => sandboxed.
  builtIn?: boolean;
}

// ============================================================================
// Plugin Registry Entry (for storage)
// ============================================================================

export interface PluginRegistryEntry {
  id: string;
  type: PluginType;
  name: string;
  version: string;
  status: PluginStatus;
  config: Record<string, unknown>;
  builtIn: boolean; // True for bundled plugins
  installedAt: Date;
  updatedAt: Date;
  // Sessions a session-scoped plugin is activated for; ['*'] = all. Absent = not yet set (treated
  // as ['*'] on enable).
  activeSessions?: string[];
  // Per-session config overrides (keyed by sessionId), merged over `config` per session at hook time.
  sessionConfig?: Record<string, Record<string, unknown>>;
  // The operator's standing decision, as opposed to `status`, which is where the runtime currently is.
  // `status` is reset to INSTALLED on every load (enabling runs the lifecycle and is never inherited
  // from a previous process), so it cannot carry intent across a restart β€” a restart used to silently
  // turn every extension plugin off (#856). This field is what bootstrap restores from. Written only by
  // the operator-facing enable/disable, never by the loader's own teardown. Absent on pre-#856 rows,
  // which are adopted from a lingering ENABLED status on first load.
  enabledByOperator?: boolean;
}