Skill: jmartel-olvid/olvid-channel (version 0.1.0)

--- name: olvid-channel version: 0.0.0-a9 description: Add a native Olvid channel in OpenClaw. homepage: https://doc.bot.olvid.io/openclaw metadata: {"openclaw":{"emoji":"🗨️","category":"communication"}} --- # Olvid Channel This plugin adds a native Olvid channel in OpenClaw. It allows to securely chat with your Agent within Olvid application. Olvid is available on every platform (Android/iOs, Linux/Windows/MacOs). With this channel you can use Olvid authenticated end-to-end encryption to exchange with your agent without exposing your OpenClaw instance on the web. # Install Follow our installation guide: https://doc.bot.olvid.io/openclaw. # Olvid Targets Here are examples of expected Olvid targets: ``` olvid:discussion:42 olvid:contact:21 olvid:group:12 ``` ## Documentation Documentation to use this skill is available here: https://doc.bot.olvid.io/openclaw. This skill code is hosted on GitHub: https://github.com/olvid-io/openclaw-channel-olvid. ## Publishing The skill is ready for publication on the OpenClaw Hub. Run: ```bash openclaw hub publish ``` This will upload the package to the hub and make it discoverable by other OpenClaw users. --- ### Contact Feel free to open [new issues](https://github.com/olvid-io/openclaw-channel-olvid/issues/new/choose) or contact us at: [[REDACTED_SECRET]](mailto:[REDACTED_SECRET]). ---

path: README.md
content: # Olvid Channel for OpenClaw

This package is an OpenClaw plugin to interact with your Agent in Olvid.

Documentation is available here: https://doc.bot.olvid.io/openclaw.


path: index.ts
content: import {
  type OpenClawPluginApi,
  emptyPluginConfigSchema,
} from "openclaw/plugin-sdk";
import {setOlvidRuntime} from "./src/runtime";
import {olvidPlugin} from "./src/channel";

const plugin: {id: string, name: string, description: string, configSchema: unknown, register: (api: OpenClawPluginApi) => void} = {
  id: "olvid",
  name: "Olvid",
  description: "Olvid channel plugin",
  configSchema: emptyPluginConfigSchema(),
  register(api: OpenClawPluginApi) {
    setOlvidRuntime(api.runtime);
    api.registerChannel(olvidPlugin);
  }
}

export default plugin;


path: openclaw.plugin.json
content: {
  "id": "olvid-channel",
  "channels": ["olvid"],
  "name": "Olvid Channel",
  "openclaw": {
    "extensions": ["./index.ts"],
    "channel": {
      "id": "olvid",
      "label": "olvid",
      "selectionLabel": "Olvid (Daemon)",
      "docsPath": "/channels/olvid",
      "docsLabel": "olvid",
      "blurb": "Trust your agent with authenticated e2e encryption.",
      "order": 35
    }
  },
  "configSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "daemonUrl": { "type": "string", "default": "http://localhost:50051" },
      "clientKey": { "type": "string" }
    }
  },
  "uiHints": {
    "daemonUrl": { "label": "Daemon Url", "placeholder": "http://localhost:50051" },
    "clientKey": { "label": "Client Key", "sensitive": true }
  }
}


path: package.json
content: {
  "name": "olvid-channel",
  "version": "0.0.0-a11",
  "description": "Olvid native channel for OpenClaw.",
  "type": "module",
  "dependencies": {
    "@olvid/bot-node": "^1.5.0",
    "zod": "^4.3.6"
  },
  "devDependencies": {
    "typescript": "^5.9.3"
  },
  "peerDependencies": {
    "openclaw": "2026.2.6-3"
  },
  "scripts": {
    "publishSkill": "git branch --show-current | grep -qx 'clawhub' && npx clawhub login && npx clawhub publish `pwd` --version '0.1.0' --name 'Olvid Channel' --slug olvid-channel"
  },
  "keywords": [
    "openclaw",
    "channel",
    "olvid"
  ],
  "author": "[REDACTED_SECRET]",
  "openclaw": {
    "extensions": [
      "./index.ts"
    ],
    "channel": {
      "id": "olvid",
      "label": "olvid",
      "selectionLabel": "Olvid (Daemon)",
      "docsPath": "/channels/olvid",
      "docsLabel": "olvid",
      "blurb": "Trust your agent with authenticated e2e encryption.",
      "order": 35
    },
    "install": {
      "npmSpec": "olvid-channel",
      "localPath": "extensions/olvid",
      "defaultChoice": "npm"
    }
  }
}


path: src/accounts.ts
content: import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import type { CoreConfig, OlvidAccountConfig } from "./types.js";

export type OlvidClientKeySource = "env" | "config" | "none";
export type OlvidDaemonUrlSource = "env" | "config" | "none";

export type ResolvedOlvidAccount = {
  accountId: string;
  enabled: boolean;
  name?: string;
  clientKey?: string;
  daemonUrl?: string;
  clientKeySource: OlvidClientKeySource;
  daemonUrlSource: OlvidDaemonUrlSource;
  config: OlvidAccountConfig;
};

function listConfiguredAccountIds(cfg: CoreConfig): string[] {
  const accounts = cfg.channels?.olvid?.accounts;
  if (!accounts || typeof accounts !== "object") {
    return [];
  }
  return Object.keys(accounts).filter(Boolean);
}

export function listOlvidAccountIds(cfg: CoreConfig): string[] {
  const ids = listConfiguredAccountIds(cfg);
  if (ids.length === 0) {
    return [DEFAULT_ACCOUNT_ID];
  }
  return ids.sort((a, b) => a.localeCompare(b));
}

export function resolveDefaultOlvidAccountId(cfg: CoreConfig): string {
  const ids = listOlvidAccountIds(cfg);
  if (ids.includes(DEFAULT_ACCOUNT_ID)) {
    return DEFAULT_ACCOUNT_ID;
  }
  return ids[0] ?? DEFAULT_ACCOUNT_ID;
}

function resolveAccountConfig(cfg: CoreConfig, accountId: string): OlvidAccountConfig | undefined {
  const accounts = cfg.channels?.olvid?.accounts;
  if (!accounts || typeof accounts !== "object") {
    return undefined;
  }
  return accounts[accountId] as ResolvedOlvidAccount | undefined;
}

function mergeOlvidAccountConfig(cfg: CoreConfig, accountId: string): OlvidAccountConfig {
  const { accounts: _ignored, ...base } = (cfg.channels?.olvid ?? {}) as OlvidAccountConfig & {
    accounts?: unknown;
  };
  const account = resolveAccountConfig(cfg, accountId) ?? {};
  return { ...base, ...account };
}

export function resolveOlvidAccount(params: {
  cfg: CoreConfig;
  accountId?: string | null;
}): ResolvedOlvidAccount {
  const accountId = normalizeAccountId(params.accountId);
  const baseEnabled = params.cfg.channels?.olvid?.enabled !== false;
  const merged = mergeOlvidAccountConfig(params.cfg, accountId);
  const accountEnabled = merged.enabled !== false;
  const enabled = baseEnabled && accountEnabled;

  const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
  const envClientKey = allowEnv ? process.env.OLVID_CLIENT_KEY?.trim() : undefined;
  const envDaemonUrl = allowEnv ? process.env.OLVID_DAEMON_TARGET?.trim() : undefined;
  const configClientKey = merged.clientKey?.trim();
  const configDaemonUrl = merged.daemonUrl?.trim();
  const clientKey = configClientKey || envClientKey;
  const daemonUrl = configDaemonUrl || envDaemonUrl;

  const clientKeySource: OlvidClientKeySource = configClientKey
    ? "config"
    : envClientKey
      ? "env"
      : "none";
  const daemonUrlSource: OlvidDaemonUrlSource = configDaemonUrl
    ? "config"
    : envDaemonUrl
      ? "env"
      : "none";

  return {
    accountId,
    enabled,
    name: merged.name?.trim() || undefined,
    clientKey,
    daemonUrl,
    clientKeySource: clientKeySource,
    daemonUrlSource: daemonUrlSource,
    config: merged,
  };
}


path: src/channel.ts
content: import {
  buildChannelConfigSchema,
  type ChannelPlugin,
  setAccountEnabledInConfigSection, deleteAccountFromConfigSection, DEFAULT_ACCOUNT_ID, ChannelAccountSnapshot,
} from "openclaw/plugin-sdk";
import {
  listOlvidAccountIds,
  resolveDefaultOlvidAccountId,
  ResolvedOlvidAccount,
  resolveOlvidAccount
} from "./accounts";
import {OlvidConfigSchema} from "./config-schema";
import {CoreConfig} from "./types";
import {getOlvidRuntime} from "./runtime";
import {sendMessageOlvid} from "./send";
import {monitorOlvidProvider} from "./monitor";
import {olvidOnboardingAdapter} from "./onboarding";
import {looksLikeOlvidTargetId, normalizeOlvidMessagingTarget} from "./normalize";
import {OlvidSetupAdapter} from "./setup";
import {discussionIdToString, messageIdToString} from "./tools";

export let olvidPlugin: ChannelPlugin<ResolvedOlvidAccount> = {
  id: "olvid",
  meta: {
    id: "olvid",
    label: "Olvid",
    selectionLabel: "Olvid (Daemon)",
    docsPath: "/channels/olvid",
    docsLabel: "olvid",
    blurb: "Securely exchange with your bot with authenticated e2e encryption",
    order: 65,
    quickstartAllowFrom: true,
  },
  capabilities: {
    chatTypes: ["direct", "group"],
    media: true,
    reactions: true,
    threads: false,
    reply: true,
    nativeCommands: false,
    blockStreaming: true
  },
  reload: { configPrefixes: ["channels.olvid"] },
  configSchema: buildChannelConfigSchema(OlvidConfigSchema),
  onboarding: olvidOnboardingAdapter,
  config: {
    listAccountIds: (cfg) => listOlvidAccountIds(cfg as CoreConfig),
    resolveAccount: (cfg, accountId) => resolveOlvidAccount({ cfg: cfg as CoreConfig, accountId }),
    defaultAccountId: (cfg) => resolveDefaultOlvidAccountId(cfg as CoreConfig),
    setAccountEnabled: ({ cfg, accountId, enabled }) =>
        setAccountEnabledInConfigSection({
          cfg,
          sectionKey: "olvid",
          accountId,
          enabled,
          allowTopLevel: true,
        }),
    deleteAccount: ({ cfg, accountId }) =>
        deleteAccountFromConfigSection({
          cfg,
          sectionKey: "olvid",
          accountId,
          clearBaseFields: ["clientKey", "daemonUrl", "name"],
        }),
    isConfigured: (account) => Boolean(account.clientKey && account.daemonUrl),
    describeAccount: (account) => ({
      accountId: account.accountId,
      name: account.name,
      enabled: account.enabled,
      configured: Boolean(account.clientKey && account.daemonUrl),
      clientKeySource: account.clientKeySource,
      daemonUrl: account.daemonUrl,
    }),
  },
  messaging: {
    normalizeTarget: normalizeOlvidMessagingTarget,
    targetResolver: {
      looksLikeId: looksLikeOlvidTargetId,
      hint: "olvid:discussion:DISCUSSION_ID | olvid:group:GROUP_ID | olvid:contact:CONTACT_ID"
    }
  },
  // directory: OlvidDirectoryAdapter,
  // actions: OlvidActionsAdapter,
  setup: OlvidSetupAdapter,
  outbound: {
    deliveryMode: "direct",
    chunker: (text: string, limit: number): string[] => {
        return getOlvidRuntime().channel.text.chunkMarkdownText(text, limit);
    },
    chunkerMode: "markdown",
    textChunkLimit: 4000,
    sendText: async ({ to, text, accountId, replyToId }) => {
      const sentMessage = await sendMessageOlvid(to, text, {
        accountId: accountId ?? undefined,
        replyTo: replyToId ?? undefined,
      });
      return {
        channel: "olvid",
        messageId: messageIdToString(sentMessage.id),
        chatId: discussionIdToString(sentMessage.discussionId),
        timestamp: Number(sentMessage.timestamp)
      };
    },
    sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => {
      const sentMessage = await sendMessageOlvid(to, text, {
        accountId: accountId ?? undefined,
        replyTo: replyToId ?? undefined,
        mediaUrls: mediaUrl ? [mediaUrl] : undefined,
      });
      return {
        channel: "olvid",
        messageId: messageIdToString(sentMessage.id),
        chatId: discussionIdToString(sentMessage.discussionId),
        timestamp: Number(sentMessage.timestamp)
      };
    },
  },
  status: {
    defaultRuntime: {
      accountId: DEFAULT_ACCOUNT_ID,
      running: false,
      connected: false,
      lastConnectedAt: null,
      lastDisconnect: null,
      lastStartAt: null,
      lastStopAt: null,
      lastError: null,
    },
    buildChannelSummary: ({ snapshot }) => ({
      configured: snapshot.configured ?? false,
      running: snapshot.running ?? false,
      connected: snapshot.connected ?? false,
      lastStartAt: snapshot.lastStartAt ?? null,
      lastStopAt: snapshot.lastStopAt ?? null,
      lastError: snapshot.lastError ?? null,
      baseUrl: snapshot.baseUrl ?? null,
      probe: snapshot.probe,
      lastProbeAt: snapshot.lastProbeAt ?? null,
    }),
    buildAccountSnapshot: ({ account, runtime }) => {
      return {
        accountId: account.accountId,
        name: account.name,
        enabled: account.enabled,
        configured: Boolean(account.clientKey && account.daemonUrl),
        clientKeySource: account.clientKeySource,
        baseUrl: account.daemonUrl,
        connected: runtime?.connected ?? false,
        lastConnectedAt: runtime?.lastConnectedAt ?? null,
        lastDisconnect: runtime?.lastDisconnect ?? null,
        lastStartAt: runtime?.lastStartAt ?? null,
        lastStopAt: runtime?.lastStopAt ?? null,
        lastError: runtime?.lastError ?? null,
        lastInboundAt: runtime?.lastInboundAt ?? null,
        lastOutboundAt: runtime?.lastOutboundAt ?? null,
      };
    },
  },
  gateway: {
    startAccount: async (ctx) => {
      const account = ctx.account;
      if (!account.daemonUrl || !account.clientKey) {
        throw new Error(`Olvid not configuration is invalid`);
      }
      ctx.setStatus({
        accountId: account.accountId,
        baseUrl: account.daemonUrl,
        botTokenSource: account.clientKeySource,
        lastStartAt: Date.now(),
      });
      ctx.log?.info(`[${account.accountId}] starting channel`);
      return monitorOlvidProvider({
        accountId: account.accountId,
        config: ctx.cfg as CoreConfig,
        runtime: ctx.runtime,
        abortSignal: ctx.abortSignal,
        logger: ctx.log,
        statusSink: (patch: Partial<ChannelAccountSnapshot>) =>
            ctx.setStatus({ accountId: ctx.accountId, ...patch }),
      });
    },
    stopAccount: async (ctx) => {
      ctx.setStatus({ accountId: ctx.accountId, lastStopAt: Date.now() });
    },
  },
}


path: src/config-schema.ts
content: import { z } from "zod";

export const OlvidAccountSchema = z
  .object({
    name: z.string().optional(),
    enabled: z.boolean().optional(),
    daemonUrl: z.string().optional(),
    clientKey: z.string().optional(),
  })
  .strict();

export const OlvidConfigSchema = OlvidAccountSchema.extend({
  accounts: z.record(z.string(), OlvidAccountSchema.optional()).optional(),
});


path: src/monitor.ts
content: import type {ChannelAccountSnapshot, RuntimeEnv, OpenClawConfig, ReplyPayload, ChannelLogSink} from "openclaw/plugin-sdk";
import { datatypes, OlvidClient } from "@olvid/bot-node";
import { ResolvedOlvidAccount, resolveOlvidAccount } from "./accounts.js";
import { getOlvidRuntime } from "./runtime.js";
import { sendMessageOlvid } from "./send.js";
import {contactIdToString, discussionIdToString, messageIdToString} from "./tools.js";
import { CoreConfig } from "./types.js";
import * as fs from "node:fs";

export type MonitorOlvidOpts = {
  accountId?: string;
  config?: CoreConfig;
  runtime?: RuntimeEnv;
  abortSignal?: AbortSignal;
  logger?: ChannelLogSink;
  statusSink?: (patch: Partial<ChannelAccountSnapshot>) => void;
};

class OpenClawBot extends OlvidClient {
  private readonly opts: MonitorOlvidOpts;
  private readonly account: ResolvedOlvidAccount;
  private readonly cfg: CoreConfig;
  private readonly logger?: ChannelLogSink;

  constructor(account: ResolvedOlvidAccount, opts: MonitorOlvidOpts, cfg: CoreConfig) {
    super({ serverUrl: account.daemonUrl, clientKey: account.clientKey });
    this.opts = opts;
    this.account = account;
    this.cfg = cfg;
    this.logger = opts.logger;

    this.onMessageReceived({
      callback: this.onMessageReceivedHandler,
    });
    this.startRoutine().then();
  }

  // list and handle unread messages (that arrived while bot was off)
  private async startRoutine(): Promise<void> {
    const runtime = getOlvidRuntime();
    runtime.config.loadConfig().agents

    let unreadMessageCount: number = 0;
    for await (let message of this.messageList({unread: true})) {
      await this.onMessageReceivedHandler(message);
      unreadMessageCount++;
    }
    if (unreadMessageCount) {
      this.logger?.info(`queued ${unreadMessageCount} pending message(s)`)
    }
  }

  private async onMessageReceivedHandler(message: datatypes.Message) {
    const runtime = getOlvidRuntime();

    // load metadata
    const discussion: datatypes.Discussion = await this.discussionGet({
      discussionId: message.discussionId,
    });
    const sender: datatypes.Contact | undefined = message.senderId
      ? await this.contactGet({ contactId: message.senderId })
      : undefined;
    const timestamp: number = Number(message.timestamp);
    const isGroup: boolean = await message.isGroupMessage(this);

    // ignore outbound messages (not supposed to happen)
    if (!sender) {
      return;
    }

    // log and update status
    this.logger?.info("message received")
    runtime.channel.activity.record({
      channel: "olvid",
      accountId: this.opts.accountId,
      direction: "inbound",
      at: timestamp,
    });
    this.opts.statusSink?.({ lastInboundAt: Date.now() });

    // download message attachments
    const attachmentsWithPaths: {attachment: datatypes.Attachment, path: string}[] = [];
    if (message.attachmentsCount > 0) {
      fs.mkdirSync("/tmp/olvid-attachments", {recursive: true});
      for await (const attachment of this.attachmentList({filter: new datatypes.AttachmentFilter({messageId: message.id})})) {
        attachmentsWithPaths.push({
          attachment: attachment,
          path: await attachment.save(this, "/tmp/olvid-attachments")
        })
      }
      this.logger?.info(`downloaded ${attachmentsWithPaths.length} attachment(s)`)
    }

    // get replied message
    const repliedMessage: datatypes.Message|undefined = message.repliedMessageId?.id ? await this.messageGet({messageId: message.repliedMessageId}) : undefined;
    let repliedMessageSenderId: BigInt|undefined = undefined;
    let repliedMessageSenderName: string|undefined = undefined;
    if (repliedMessage) {
      if (repliedMessage.senderId === 0n) {
        repliedMessageSenderName = "you";
        repliedMessageSenderId = 0n;
      } else {
        const repliedMessageContact: datatypes.Contact|undefined = repliedMessage && repliedMessage.senderId ? await repliedMessage.getSenderContact(this) : undefined;
        repliedMessageSenderId = repliedMessageContact?.id;
        repliedMessageSenderName = repliedMessageContact?.displayName;
      }
    }

    const route = runtime.channel.routing.resolveAgentRoute({
      cfg: this.cfg,
      channel: "olvid",
      accountId: this.account.accountId,
      peer: {
        id: discussionIdToString(discussion.id),
        kind: isGroup ? "group" : "dm",
      },
    });

    const body = runtime.channel.reply.formatInboundEnvelope({
      channel: "olvid",
      from: `olvid:contact:${message.senderId}`,
      body: message.body,
      timestamp: Number(message.timestamp),
      senderLabel: sender.displayName,
      sender: { name: sender.displayName, id: `olvid:contact:${sender.id.toString()}` },
    });

    const ctxPayload = runtime.channel.reply.finalizeInboundContext({
      Body: body,
      RawBody: message.body,
      CommandBody: message.body,
      From: contactIdToString(sender.id),
      To: discussionIdToString(discussion.id),
      SessionKey: route.sessionKey,
      AccountId: route.accountId,
      ChatType: discussion.isGroupDiscussion() ? "group" : "direct",
      ConversationLabel: discussion.title,
      SenderName: sender ? sender.displayName : "",
      SenderId: contactIdToString(sender.id),
      MessageId: messageIdToString(message.id!),
      Timestamp: message.timestamp,
      Provider: "olvid",
      Surface: "olvid",
      OriginatingChannel: "olvid",
      OriginatingTo: discussionIdToString(discussion.id),
      ReplyToId: messageIdToString(repliedMessage?.id),
      ReplyToBody: repliedMessage?.body,
      ReplyToSenderId: repliedMessageSenderId,
      ReplyToSenderName: repliedMessageSenderName,
      MediaPath: attachmentsWithPaths.length > 0 ? attachmentsWithPaths[0].path : undefined,
      MediaUrl: attachmentsWithPaths.length > 0 ? attachmentsWithPaths[0].path : undefined,
      MediaType: attachmentsWithPaths.length > 0 ? runtime.media.mediaKindFromMime(attachmentsWithPaths[0].attachment.mimeType) : undefined,
      MediaPaths: attachmentsWithPaths.length > 0 ? attachmentsWithPaths.map(awp => awp.path) : undefined,
      MediaUrls: attachmentsWithPaths.length > 0 ? attachmentsWithPaths.map(awp => awp.path) : undefined,
      MediaTypes: attachmentsWithPaths.length > 0 ? attachmentsWithPaths.map(awp => runtime.media.mediaKindFromMime(awp.attachment.mimeType)) : undefined,
    });

    const storePath = runtime.channel.session.resolveStorePath(
      (this.cfg as OpenClawConfig).session?.store,
      {agentId: route.agentId},
    );

    await runtime.channel.session.recordInboundSession({
      storePath,
      sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
      ctx: ctxPayload,
      onRecordError: (err: unknown) => {
        this.logger?.error(`olvid: failed updating session meta: ${String(err)}`);
      },
    });

    // let robot: boolean = false;
    await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
      ctx: ctxPayload,
      cfg: this.cfg,
      dispatcherOptions: {
        // onReplyStart: async () => {
          // if (!robot) {
          //   // add "typing" reaction
          //   await message.react(this, "🤖");
          //   robot = true;
          //   this.logger?.info("added typing reaction")
          // }
        // },
        deliver: async (payload: ReplyPayload) => {
          if (payload.replyToCurrent) {
            payload.replyToId = messageIdToString(message.id);
          }

          // prepare and check response validity
          const text = payload.text ?? "";
          const mediaList = payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : [];
          if (!text.trim() && mediaList.length === 0) {
            return;
          }
          const to: string = `olvid:discussion:${discussion.id}`;

          await sendMessageOlvid(to, text, { accountId: this.account.accountId, replyTo: payload.replyToId, mediaUrls: mediaList });
          this.opts.statusSink?.({ lastOutboundAt: Date.now() });

          // // remove "typing" reaction from this message
          // await message.react(this, "");
          // // remove "typing" reaction from messages older than this one
          // for await (const m of this.messageList({filter: new datatypes.MessageFilter({reactionsFilter: [new datatypes.ReactionFilter({reaction: "🤖"})], maxTimestamp: message.timestamp})})) {
          //   await m.react(this, "");
          // }
        },
        onError: (err, info) => {
          this.logger?.error?.(`${info.kind} reply failed: ${String(err)}`);
        },
      }
    });
  }
}

let globalRunBot = false;

export async function monitorOlvidProvider(opts: MonitorOlvidOpts = {}): Promise<void> {
  const core = getOlvidRuntime();
  const cfg: CoreConfig = opts.config ?? (core.config.loadConfig() as CoreConfig);
  const account = resolveOlvidAccount({ cfg: cfg, accountId: opts.accountId });

  if (!account.daemonUrl) {
    throw new Error(`Olvid daemon url not configured for account "${account.accountId}"`);
  }
  if (!account.clientKey) {
    throw new Error(`Olvid client key not configured for account "${account.accountId}"`);
  }

  globalRunBot = true;
  while (globalRunBot) {
    try {
      let bot = new OpenClawBot(account, opts, cfg);
      opts.statusSink?.({connected: true, lastConnectedAt: Date.now()});
      await bot.waitForCallbacksEnd();
    } catch (err) {
      opts.logger?.error(`olvid: ${err}`);
      opts.statusSink?.({
        lastError: String(err),
        connected: false,
        lastDisconnect: {at: Date.now(), error: String(err)},
      });
    }
    opts.logger?.info("wait before reconnection try")
    await new Promise(resolve => setTimeout(resolve, 5_000));
  }
}


path: src/normalize.ts
content: import {OlvidClient} from "@olvid/bot-node";

export function normalizeOlvidMessagingTarget(raw: string): string | undefined {
  const trimmed = raw.trim().toLowerCase();
  if (!trimmed) {
    return undefined;
  }
  let normalized = trimmed;

  if (normalized.startsWith("olvid:")) {
    normalized = normalized.slice("olvid:".length).trim();
  }

  if (!normalized) {
    return undefined;
  }

  return `olvid:${normalized}`.toLowerCase();
}

export function looksLikeOlvidTargetId(target: string): boolean {
  target = target.trim().toLowerCase();
  if (!target) {
    return false;
  }
  if (target.startsWith("olvid:")) {
    target = target.slice("olvid:".length);
  }
  return /^discussion:(\d+)$/.test(target) || /^group:(\d+)$/.test(target) || /^contact:(\d+)$/.test(target);
}

export async function getDiscussionIdFromTarget(client: OlvidClient, target: string): Promise<bigint> {
  target = target.trim().toLowerCase();
  if (target.startsWith("olvid:")) {
    target = target.slice("olvid:".length).trim();
  }

  // discussion
  if (/^discussion:(\d+)$/.test(target)) {
    return BigInt(target.slice("discussion:".length));
  }
  // group
  if (/^group:(\d+)$/.test(target)) {
    const id = BigInt(target.slice("group:".length));
    return (await client.discussionGetByGroup({groupId: id})).id;
  }
  // contact
  if (/^contact:(\d+)$/.test(target)) {
    const id = BigInt(target.slice("contact:".length));
    return (await client.discussionGetByContact({contactId: id})).id;
  }
  throw new Error(`Invalid discussion target: ${target}`);
}

export function getContactIdFromTarget(target: string): bigint {
  target = target.trim().toLowerCase();
  if (target.startsWith("olvid:")) {
    target = target.slice("olvid:".length).trim();
  }
  if (/^contact:(\d+)$/.test(target)) {
    target = target.slice("contact:".length).trim();
    return BigInt(target);
  }
  throw new Error(`Invalid group target: ${target}`);
}

export function getGroupIdFromTarget(target: string): bigint {
  target = target.trim().toLowerCase();
  if (target.startsWith("olvid:")) {
    target = target.slice("olvid:".length).trim();
  }
  if (/^group:(\d+)$/.test(target)) {
    return BigInt(target.slice("group:".length).trim());
  }
  throw new Error(`Invalid group target: ${target}`);
}


path: src/onboarding.ts
content: import {
  promptAccountId,
  DEFAULT_ACCOUNT_ID,
  normalizeAccountId,
  type ChannelOnboardingAdapter,
  OpenClawConfig,
} from "openclaw/plugin-sdk";
import type { CoreConfig } from "./types.js";
import {
  listOlvidAccountIds,
  resolveDefaultOlvidAccountId,
  resolveOlvidAccount,
} from "./accounts.js";

const channel = "olvid" as const;

export const olvidOnboardingAdapter: ChannelOnboardingAdapter = {
  channel,
  getStatus: async ({ cfg }) => {
    const configured = listOlvidAccountIds(cfg as CoreConfig).some((accountId: string) => {
      const account = resolveOlvidAccount({ cfg: cfg as CoreConfig, accountId });
      return Boolean(account.clientKey && account.daemonUrl);
    });
    return {
      channel,
      configured,
      statusLines: [`Olvid: ${configured ? "configured" : "needs setup"}`],
      selectionHint: configured ? "configured" : "privacy first",
      quickstartScore: configured ? 1 : 5,
    };
  },
  configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
    const olvidOverride = accountOverrides["olvid"]?.trim();
    const defaultAccountId: string = resolveDefaultOlvidAccountId(cfg as CoreConfig);
    let accountId = olvidOverride ? normalizeAccountId(olvidOverride) : defaultAccountId;

    if (shouldPromptAccountIds && !olvidOverride) {
      accountId = await promptAccountId({
        cfg,
        prompter,
        label: "Olvid",
        currentId: accountId,
        listAccountIds: listOlvidAccountIds as (core: OpenClawConfig) => string[],
        defaultAccountId: defaultAccountId,
      });
    }

    let next: CoreConfig = cfg as CoreConfig;
    const resolvedAccount = resolveOlvidAccount({
      cfg: next,
      accountId: accountId,
    });
    const allowEnv = accountId === DEFAULT_ACCOUNT_ID;

    /*
     ** determine / ask user for daemon target
     */
    let daemonUrl = resolvedAccount.daemonUrl;
    if (allowEnv && !daemonUrl && Boolean(process.env.OLVID_DAEMON_TARGET?.trim())) {
      const keepEnv = await prompter.confirm({
        message: "OLVID_DAEMON_TARGET detected. Use env var?",
        initialValue: true,
      });
      if (keepEnv) {
        daemonUrl = process.env.OLVID_DAEMON_TARGET?.trim();
      } else {
        daemonUrl = String(
          await prompter.text({
            message:
              "Enter url to access your Olvid daemon instance (e.g., http://localhost:50051)",
            validate: (value: string) => (value?.trim() ? undefined : "Required"),
          }),
        );
      }
    } else if (daemonUrl) {
      const keep = await prompter.confirm({
        message: "Daemon target already configured. Keep it?",
        initialValue: true,
      });
      if (!keep) {
        daemonUrl = String(
          await prompter.text({
            message:
              "Enter url to access your Olvid daemon instance (e.g., http://localhost:50051)",
            validate: (value: string) => (value?.trim() ? undefined : "Required"),
          }),
        );
      }
    } else {
      daemonUrl = String(
        await prompter.text({
          message: "Enter url to access your Olvid daemon instance (e.g., http://localhost:50051)",
          validate: (value: string) => (value?.trim() ? undefined : "Required"),
        }),
      );
    }

    /*
     ** determine / ask user for client key
     */
    let clientKey = resolvedAccount.clientKey;
    if (allowEnv && !clientKey && Boolean(process.env.OLVID_CLIENT_KEY?.trim())) {
      const keepEnv = await prompter.confirm({
        message: "OLVID_CLIENT_KEY detected. Use env var?",
        initialValue: true,
      });
      if (keepEnv) {
        clientKey = process.env.OLVID_CLIENT_KEY?.trim();
      } else {
        clientKey = String(
          await prompter.text({
            message: "Enter your client key",
            validate: (value: string) => (value?.trim() ? undefined : "Required"),
          }),
        );
      }
    } else if (clientKey) {
      const keep = await prompter.confirm({
        message: "Client key already configured. Keep it?",
        initialValue: true,
      });
      if (!keep) {
        clientKey = String(
          await prompter.text({
            message: "Enter your client key",
            validate: (value: string) => (value?.trim() ? undefined : "Required"),
          }),
        );
      }
    } else {
      clientKey = String(
        await prompter.text({
          message: "Enter your client key",
          validate: (value: string) => (value?.trim() ? undefined : "Required"),
        }),
      );
    }

    /*
     ** save in config
     */
    if (accountId === DEFAULT_ACCOUNT_ID) {
      if (accountId === DEFAULT_ACCOUNT_ID) {
        next = {
          ...next,
          channels: {
            ...next.channels,
            olvid: {
              ...next.channels?.["olvid"],
              enabled: true,
              daemonUrl,
              clientKey,
            },
          },
        };
      } else {
        next = {
          ...next,
          channels: {
            ...next.channels,
            olvid: {
              ...next.channels?.["olvid"],
              enabled: true,
              accounts: {
                ...next.channels?.["olvid"]?.accounts,
                [accountId]: {
                  ...next.channels?.["olvid"]?.accounts?.[accountId],
                  enabled: next.channels?.["olvid"]?.accounts?.[accountId]?.enabled ?? true,
                  daemonUrl: daemonUrl,
                  clientKey,
                },
              },
            },
          },
        };
      }
    }

    return { cfg: next, accountId: accountId };
  },
};


path: src/runtime.ts
content: import type { PluginRuntime } from "openclaw/plugin-sdk";

let runtime: PluginRuntime | null = null;

export function setOlvidRuntime(next: PluginRuntime) {
  runtime = next;
}

export function getOlvidRuntime(): PluginRuntime {
  if (!runtime) {
    throw new Error("Olvid runtime not initialized");
  }
  return runtime;
}


path: src/send.ts
content: import {datatypes} from "@olvid/bot-node";
import { getDiscussionIdFromTarget } from "./normalize.js";
import { getOlvidRuntime } from "./runtime.js";
import {getOlvidClient, messageIdFromString, messageIdToString} from "./tools";

type OlvidSendOpts = {
  daemonUrl?: string;
  clientKey?: string;
  accountId?: string;
  replyTo?: string;
  mediaUrls?: string[];
};

/*
** send a message in Olvid Channel
** As a native channel this method expects a message body and optional media Url as the agent can answer to the user request with some medias.
 */
export async function sendMessageOlvid(
  to: string,
  text: string,
  opts: OlvidSendOpts = {},
): Promise<datatypes.Message> {
  const runtime = getOlvidRuntime();
  const logger = runtime.logging.getChildLogger({module: "olvid"});

  if (!text?.trim() && !opts.mediaUrls) {
    throw new Error("Message must be non-empty for Olvid");
  }

  let olvidClient = getOlvidClient(opts.accountId);

  let discussionId: bigint|undefined = (await getDiscussionIdFromTarget(olvidClient, to))!;
  if (!discussionId) {
    throw new Error(`Cannot parse discussion id: ${to}`);
  }

  let sentMessage: datatypes.Message;
  if (opts.mediaUrls) {
    let ret = await olvidClient.messageSendWithAttachmentsFiles({ discussionId, body: text, replyId: opts.replyTo ? messageIdFromString(opts.replyTo) : undefined, filesPath: opts.mediaUrls });
    sentMessage = ret.message;
  } else {
    sentMessage = await olvidClient.messageSend({ discussionId, body: text, replyId: opts.replyTo ? messageIdFromString(opts.replyTo) : undefined });
  }

  logger.info(`message sent: ${messageIdToString(sentMessage.id)}`);

  getOlvidRuntime().channel.activity.record({
    channel: "olvid",
    accountId: opts.accountId,
    direction: "outbound",
  });

  olvidClient.stop();

  return sentMessage;
}


path: src/setup.ts
content: import {
    applyAccountNameToChannelSection,
    ChannelSetupAdapter,
    DEFAULT_ACCOUNT_ID, migrateBaseNameToDefaultAccount,
    normalizeAccountId
} from "openclaw/plugin-sdk";
import {CoreConfig} from "./types";

export let OlvidSetupAdapter: ChannelSetupAdapter = {
    resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
    applyAccountName: ({ cfg, accountId, name }) =>
    applyAccountNameToChannelSection({
        cfg,
        channelKey: "olvid",
        accountId,
        name,
    }),
    validateInput: ({ accountId, input }) => {
        if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
            return "Olvid env vars can only be used for the default account.";
        }
        const clientKey = input.botToken ?? input.token;
        const daemonUrl = input.url ?? input.httpUrl;
        if (!input.useEnv && (!clientKey || !daemonUrl)) {
            return "Olvid requires --bot-token and --http-url (or --use-env).";
        }
        if (!daemonUrl?.startsWith("https://") && !daemonUrl?.startsWith("http://")) {
            return "Olvid --url must include a valid base URL.";
        }
        return null;
    },
    applyAccountConfig: ({ cfg, accountId, input }) => {
        const clientKey = input.botToken?.trim() ?? input.token?.trim();
        const daemonUrl = input.url?.trim() ?? input.httpUrl?.trim();
        const namedConfig = applyAccountNameToChannelSection({
            cfg,
            channelKey: "olvid",
            accountId,
            name: input.name,
        });
        const next: CoreConfig =
            (accountId !== DEFAULT_ACCOUNT_ID
                ? migrateBaseNameToDefaultAccount({
                    cfg: namedConfig,
                    channelKey: "olvid",
                })
                : namedConfig) as CoreConfig;
        if (accountId === DEFAULT_ACCOUNT_ID) {
            return {
                ...next,
                channels: {
                    ...next.channels,
                    olvid: {
                        ...next.channels?.olvid,
                        enabled: true,
                        ...(input.useEnv
                            ? {}
                            : {
                                ...(clientKey ? { clientKey } : {}),
                                ...(daemonUrl ? { daemonUrl } : {}),
                            }),
                    },
                },
            };
        }
        return {
            ...next,
            channels: {
                ...next.channels,
                olvid: {
                    ...next.channels?.olvid,
                    enabled: true,
                    accounts: {
                        ...next.channels?.olvid?.accounts,
                        [accountId]: {
                            ...next.channels?.olvid?.accounts?.[accountId],
                            enabled: true,
                            ...(clientKey ? { clientKey } : {}),
                            ...(daemonUrl ? { daemonUrl } : {}),
                        },
                    },
                },
            },
        };
    },
};

path: src/tools.ts
content: import {datatypes, OlvidClient} from "@olvid/bot-node";
import {getOlvidRuntime} from "./runtime";
import {ResolvedOlvidAccount, resolveOlvidAccount} from "./accounts";
import {CoreConfig} from "./types";

export function messageIdToString(messageId?: datatypes.MessageId): string {
  if (!messageId) {
    return "undefined";
  }
  return messageId.type === datatypes.MessageId_Type.INBOUND
    ? `I${messageId.id}`
    : `O${messageId.id}`;
}

export function messageIdFromString(messageId: string): datatypes.MessageId {
  let type: datatypes.MessageId_Type = datatypes.MessageId_Type.UNSPECIFIED;
  if (messageId.startsWith("I")) {
    type = datatypes.MessageId_Type.INBOUND;
  } else if (messageId.startsWith("O")) {
    type = datatypes.MessageId_Type.OUTBOUND;
  }
  let id: bigint = 0n;
  try {
    id = BigInt(messageId.slice(1));
  } catch (e) {}

  return new datatypes.MessageId({ type, id });
}

export function discussionIdToString(discussionId: BigInt) {
  return `olvid:discussion:${discussionId}`;
}
export function groupIdToString(groupId: BigInt) {
  return `olvid:group:${groupId}`;
}
export function contactIdToString(contactId: BigInt) {
  return `olvid:contact:${contactId}`;
}

export function getOlvidClient(olvidChannelAccountId?: string): OlvidClient {
  const runtime = getOlvidRuntime();
  const config = runtime.config.loadConfig();

  // Retrieve the configuration/credentials for the specific olvidChannelAccountId, or fallback to default
  let olvidAccount: ResolvedOlvidAccount = resolveOlvidAccount({cfg: config as CoreConfig, accountId: olvidChannelAccountId ?? "default"});
  if (!olvidAccount || !olvidAccount.daemonUrl || !olvidAccount.clientKey) {
    olvidAccount = resolveOlvidAccount({cfg: config as CoreConfig, accountId: "default"});
  }
  return new OlvidClient({clientKey: olvidAccount.clientKey, serverUrl: olvidAccount.daemonUrl});
}

export function stringifyDatatypesEntity(obj: any): string {
  const raw = obj.toJson()!;
  const payload = raw as Record<string, unknown>;
  if (obj instanceof datatypes.Group) {
    payload.id = `olvid:group:${obj.id}`;
  } else if (obj instanceof datatypes.Contact) {
    payload.id = `olvid:contact:${obj.id}`;
  } else if (obj instanceof datatypes.Discussion) {
    payload.id = `olvid:discussion:${obj.id}`;
  }
  return JSON.stringify(payload);
}

path: src/types.ts
content: export type OlvidAccountConfig = {
  /** Optional display name for this account (used in CLI/UI lists). */
  name?: string;
  /** If false, do not start this Olvid account. Default: true. */
  enabled?: boolean;
  /** Daemon URL (e.g., "http://locahost:50051"). */
  daemonUrl?: string;
  /** Bot client key */
  clientKey?: string;
  disableActions?: boolean;
};

export type OlvidConfig = {
  /** Optional per-account Olvid configuration (multi-account). */
  accounts?: Record<string, OlvidAccountConfig>;
} & OlvidAccountConfig;

export type CoreConfig = {
  channels?: {
    olvid?: OlvidConfig;
  };
  [key: string]: unknown;
};


path: tsconfig.json
content: {
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist",
    "rootDir": ".",
    "declaration": true,
    "sourceMap": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true
  },
  "include": ["index.ts", "src/**/*.ts", "test/**/*.ts"],
  "exclude": ["node_modules", "dist"]
}


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)