Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion orbio-openclaw-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,6 @@ Reference env file: `.env.smoke.example`

1. Bump version in `package.json`.
2. Sync version in `openclaw.plugin.json`.
3. Sync `PLUGIN_VERSION` in `src/index.ts`.
3. Sync `PLUGIN_VERSION` in `src/constants.ts`.
4. Run `pnpm verify` and `pnpm pack --dry-run`.
5. Publish with `pnpm publish --access public --no-git-checks --provenance`.
32 changes: 27 additions & 5 deletions orbio-openclaw-plugin/scripts/check-env-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import path from "node:path";

const ROOT = process.cwd();
const ENV_TEMPLATE_PATH = path.join(ROOT, ".env.smoke.example");
const INDEX_PATH = path.join(ROOT, "src/index.ts");
const SRC_PATH = path.join(ROOT, "src");
const LIVE_SMOKE_PATH = path.join(ROOT, "scripts/live-smoke.mjs");

function readEnvKeys(filePath) {
Expand Down Expand Up @@ -38,22 +38,44 @@ function difference(left, right) {
return [...left].filter((value) => !right.has(value)).sort();
}

function collectSourceFiles(dirPath) {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const entryPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
files.push(...collectSourceFiles(entryPath));
continue;
}
if (entry.isFile() && entry.name.endsWith(".ts")) {
files.push(entryPath);
}
}
return files;
}

function main() {
if (!fs.existsSync(ENV_TEMPLATE_PATH)) {
throw new Error(`Missing env template: ${ENV_TEMPLATE_PATH}`);
}
if (!fs.existsSync(INDEX_PATH)) {
throw new Error(`Missing plugin source: ${INDEX_PATH}`);
if (!fs.existsSync(SRC_PATH)) {
throw new Error(`Missing plugin source directory: ${SRC_PATH}`);
}
if (!fs.existsSync(LIVE_SMOKE_PATH)) {
throw new Error(`Missing smoke script: ${LIVE_SMOKE_PATH}`);
}

const envKeys = readEnvKeys(ENV_TEMPLATE_PATH);
const indexContent = fs.readFileSync(INDEX_PATH, "utf-8");
const smokeContent = fs.readFileSync(LIVE_SMOKE_PATH, "utf-8");
const srcFiles = collectSourceFiles(SRC_PATH);

const pluginEnvRefs = extractMatches(indexContent, /env\.([A-Z0-9_]+)/gu);
const pluginEnvRefs = new Set();
for (const filePath of srcFiles) {
const content = fs.readFileSync(filePath, "utf-8");
for (const key of extractMatches(content, /env\.([A-Z0-9_]+)/gu)) {
pluginEnvRefs.add(key);
}
}
const smokeEnvRefs = extractMatches(
smokeContent,
/(requiredEnv|optionalEnv)\("([A-Z0-9_]+)"/gu,
Expand Down
139 changes: 139 additions & 0 deletions orbio-openclaw-plugin/src/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { createHash, randomUUID } from "node:crypto";

import { toTrimmedString } from "./config";
import type { CommandToolInput } from "./schemas";

export type ParsedCommand =
| {
action: "search";
queryText: string;
limit: number | undefined;
withContact: boolean;
}
| {
action: "export";
queryText: string;
limit: number | undefined;
withContact: boolean;
format: "csv" | "html";
}
| {
action: "export-status";
exportId: string;
};

export function clampLimit(raw: number | undefined): number {
const fallback = 20;
if (raw === undefined || raw === null || !Number.isFinite(raw)) {
return fallback;
}
return Math.min(50000, Math.max(1, Math.floor(raw)));
}

function parseTokens(raw: string): string[] {
const out: string[] = [];
const regex = /"([^"]*)"|'([^']*)'|(\S+)/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(raw)) !== null) {
const token = match[1] ?? match[2] ?? match[3] ?? "";
if (token) {
out.push(token);
}
}
return out;
}

export function usageText(): string {
return [
"Usage:",
"/orbio search <query> [--limit N] [--with-contact]",
"/orbio export <query> [--limit N] [--format csv|html] [--with-contact]",
"/orbio export-status <export_id>",
].join("\n");
}

export function parseCommand(raw: string): ParsedCommand | { error: string } {
const tokens = parseTokens(raw);
if (tokens.length === 0) {
return { error: usageText() };
}

const action = tokens[0]?.toLowerCase();
const rest = tokens.slice(1);

if (action === "search" || action === "export") {
let withContact = false;
let limit: number | undefined;
let format: "csv" | "html" = "csv";
const queryParts: string[] = [];

for (let idx = 0; idx < rest.length; idx += 1) {
const token = rest[idx] ?? "";
if (token === "--with-contact") {
withContact = true;
continue;
}
if (token === "--limit") {
const rawLimit = rest[idx + 1];
const parsed = rawLimit ? Number(rawLimit) : Number.NaN;
if (!Number.isFinite(parsed) || parsed <= 0) {
return { error: "Invalid --limit value. Use an integer >= 1." };
}
limit = Math.floor(parsed);
idx += 1;
continue;
}
if (action === "export" && token === "--format") {
const rawFormat = String(rest[idx + 1] ?? "").toLowerCase();
if (rawFormat !== "csv" && rawFormat !== "html") {
return { error: "Invalid --format value. Use csv or html." };
}
format = rawFormat;
idx += 1;
continue;
}
queryParts.push(token);
}

const queryText = queryParts.join(" ").trim();
if (!queryText) {
return { error: `Missing query text.\n\n${usageText()}` };
}

if (action === "search") {
return { action: "search", queryText, limit, withContact };
}

return { action: "export", queryText, limit, withContact, format };
}

if (action === "export-status" || action === "status") {
const exportId = (rest[0] ?? "").trim();
if (!exportId) {
return { error: "Missing export_id. Use: /orbio export-status <export_id>" };
}
return { action: "export-status", exportId };
}

return { error: `Unknown command: ${action}\n\n${usageText()}` };
}

export function buildIdempotencyKey(prefix: string, payload: unknown): string {
const digest = createHash("sha256").update(JSON.stringify(payload)).digest("hex").slice(0, 24);
const suffix = randomUUID().replace(/-/g, "").slice(0, 12);
return `openclaw:${prefix}:${digest}:${suffix}`;
}

export function resolveCommandRaw(args: CommandToolInput): string {
const raw = args.command ?? args.command_arg ?? args.commandArg;
const commandName = args.command_name ?? args.commandName;
const rawText = toTrimmedString(raw);
if (rawText) {
return rawText;
}
const commandText = toTrimmedString(commandName);
if (commandText) {
return commandText;
}
return "";
}
119 changes: 119 additions & 0 deletions orbio-openclaw-plugin/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { PLUGIN_ID, PLUGIN_VERSION } from "./constants";
import type { JsonRecord, OrbioPluginConfig } from "./types";

export function toTrimmedString(value: unknown): string {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number" || typeof value === "boolean" || value == null) {
return String(value ?? "").trim();
}
try {
return String(value).trim();
} catch {
return "";
}
}

export function asJsonRecord(value: unknown): JsonRecord | null {
return value && typeof value === "object" ? (value as JsonRecord) : null;
}

function parsePositiveInt(value: unknown, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return fallback;
}
return Math.floor(value);
}

function parseNonNegativeInt(value: unknown, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
return fallback;
}
return Math.floor(value);
}

function parseBoolean(value: unknown, fallback: boolean): boolean {
if (typeof value === "boolean") {
return value;
}
if (typeof value === "string" || value instanceof String) {
const normalized = toTrimmedString(value).toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) {
return true;
}
if (["0", "false", "no", "off"].includes(normalized)) {
return false;
}
}
return fallback;
}

function normalizeChannel(value: unknown): string {
const raw = toTrimmedString(value).toLowerCase();
if (!raw) {
return "chat";
}
const normalized = raw.replaceAll(" ", "_").replace(/[^a-z0-9_-]/g, "").slice(0, 64);
return normalized || "chat";
}

function normalizeBaseUrl(value: string): string {
return value.endsWith("/") ? value.slice(0, -1) : value;
}

export function readConfig(api: unknown): OrbioPluginConfig {
const asRecord = (api ?? {}) as JsonRecord;
const pluginConfigEnvelope = asJsonRecord(asRecord.pluginConfig);
const pluginConfig = asJsonRecord(pluginConfigEnvelope?.config) ?? pluginConfigEnvelope;
const rootConfig = asJsonRecord(asRecord.config);
const rootPlugins = asJsonRecord(rootConfig?.plugins);
const rootPluginEntries = asJsonRecord(rootPlugins?.entries);
const rootPluginEntry = asJsonRecord(rootPluginEntries?.[PLUGIN_ID]);
const rootPluginConfig = asJsonRecord(rootPluginEntry?.config) ?? rootPluginEntry;
const legacyConfig =
rootConfig &&
(Object.prototype.hasOwnProperty.call(rootConfig, "baseUrl") ||
Object.prototype.hasOwnProperty.call(rootConfig, "apiKey"))
? rootConfig
: null;
const rawConfig = pluginConfig ?? rootPluginConfig ?? legacyConfig ?? {};
const envSource = asJsonRecord(asRecord.env);
const env = ((envSource ?? process.env) as Record<string, string | undefined>) ?? {};

const baseUrl = toTrimmedString(rawConfig.baseUrl ?? env.ORBIO_BASE_URL ?? "");
const apiKey = toTrimmedString(rawConfig.apiKey ?? env.ORBIO_API_KEY ?? "");

if (!baseUrl) {
throw new Error("Missing plugin config: baseUrl");
}
if (!apiKey) {
throw new Error("Missing plugin config: apiKey");
}

const timeoutMs = parsePositiveInt(rawConfig.timeoutMs, 20_000);
const maxRequestsPerMinute = parsePositiveInt(rawConfig.maxRequestsPerMinute, 30);
const retryCount = Math.min(3, parseNonNegativeInt(rawConfig.retryCount, 1));
const retryBackoffMs = parsePositiveInt(rawConfig.retryBackoffMs, 300);
const capabilitiesTtlMs = parsePositiveInt(rawConfig.capabilitiesTtlMs, 60_000);
const workspaceId = toTrimmedString(rawConfig.workspaceId ?? env.ORBIO_WORKSPACE_ID ?? "default");
const channel = normalizeChannel(rawConfig.channel ?? env.ORBIO_CHANNEL ?? "chat");
const sendExecutionContext = parseBoolean(
rawConfig.sendExecutionContext ?? env.ORBIO_SEND_EXECUTION_CONTEXT,
true,
);

return {
baseUrl: normalizeBaseUrl(baseUrl),
apiKey,
workspaceId: workspaceId || "default",
channel,
sendExecutionContext,
timeoutMs,
maxRequestsPerMinute,
retryCount,
retryBackoffMs,
capabilitiesTtlMs,
userAgent: `${PLUGIN_ID}/${PLUGIN_VERSION}`,
};
}
5 changes: 5 additions & 0 deletions orbio-openclaw-plugin/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const PLUGIN_ID = "orbio-openclaw";
export const PLUGIN_NAME = "Orbio (official)";
export const PLUGIN_VERSION = "0.1.0";
export const EXECUTION_CONTEXT_HEADER = "X-Orbio-Execution-Context";
export const EXECUTION_CONTEXT_INTEGRATION = "openclaw";
Loading