mirror of https://github.com/openclaw/openclaw.git
826 lines
25 KiB
TypeScript
826 lines
25 KiB
TypeScript
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { Button, Row, type TopLevelComponents } from "@buape/carbon";
|
|
import { ButtonStyle } from "discord-api-types/v10";
|
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
|
import { expandHomePrefix } from "../infra/home-dir.js";
|
|
import { writeJsonAtomic } from "../infra/json-files.js";
|
|
import {
|
|
getSessionBindingService,
|
|
type ConversationRef,
|
|
} from "../infra/outbound/session-binding-service.js";
|
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
|
import type {
|
|
PluginConversationBinding,
|
|
PluginConversationBindingRequestParams,
|
|
PluginConversationBindingRequestResult,
|
|
} from "./types.js";
|
|
|
|
const log = createSubsystemLogger("plugins/binding");
|
|
|
|
const APPROVALS_PATH = "~/.openclaw/plugin-binding-approvals.json";
|
|
const PLUGIN_BINDING_CUSTOM_ID_PREFIX = "pluginbind";
|
|
const PLUGIN_BINDING_OWNER = "plugin";
|
|
const PLUGIN_BINDING_SESSION_PREFIX = "plugin-binding";
|
|
const LEGACY_CODEX_PLUGIN_SESSION_PREFIXES = [
|
|
"openclaw-app-server:thread:",
|
|
"openclaw-codex-app-server:thread:",
|
|
] as const;
|
|
|
|
type PluginBindingApprovalDecision = "allow-once" | "allow-always" | "deny";
|
|
|
|
type PluginBindingApprovalEntry = {
|
|
pluginRoot: string;
|
|
pluginId: string;
|
|
pluginName?: string;
|
|
channel: string;
|
|
accountId: string;
|
|
approvedAt: number;
|
|
};
|
|
|
|
type PluginBindingApprovalsFile = {
|
|
version: 1;
|
|
approvals: PluginBindingApprovalEntry[];
|
|
};
|
|
|
|
type PluginBindingConversation = {
|
|
channel: string;
|
|
accountId: string;
|
|
conversationId: string;
|
|
parentConversationId?: string;
|
|
threadId?: string | number;
|
|
};
|
|
|
|
type PendingPluginBindingRequest = {
|
|
id: string;
|
|
pluginId: string;
|
|
pluginName?: string;
|
|
pluginRoot: string;
|
|
conversation: PluginBindingConversation;
|
|
requestedAt: number;
|
|
requestedBySenderId?: string;
|
|
summary?: string;
|
|
detachHint?: string;
|
|
};
|
|
|
|
type PluginBindingApprovalAction = {
|
|
approvalId: string;
|
|
decision: PluginBindingApprovalDecision;
|
|
};
|
|
|
|
type PluginBindingIdentity = {
|
|
pluginId: string;
|
|
pluginName?: string;
|
|
pluginRoot: string;
|
|
};
|
|
|
|
type PluginBindingMetadata = {
|
|
pluginBindingOwner: "plugin";
|
|
pluginId: string;
|
|
pluginName?: string;
|
|
pluginRoot: string;
|
|
summary?: string;
|
|
detachHint?: string;
|
|
};
|
|
|
|
type PluginBindingResolveResult =
|
|
| {
|
|
status: "approved";
|
|
binding: PluginConversationBinding;
|
|
request: PendingPluginBindingRequest;
|
|
decision: PluginBindingApprovalDecision;
|
|
}
|
|
| {
|
|
status: "denied";
|
|
request: PendingPluginBindingRequest;
|
|
}
|
|
| {
|
|
status: "expired";
|
|
};
|
|
|
|
const pendingRequests = new Map<string, PendingPluginBindingRequest>();
|
|
|
|
type PluginBindingGlobalState = {
|
|
fallbackNoticeBindingIds: Set<string>;
|
|
};
|
|
|
|
const pluginBindingGlobalStateKey = Symbol.for("openclaw.plugins.binding.global-state");
|
|
|
|
let approvalsCache: PluginBindingApprovalsFile | null = null;
|
|
let approvalsLoaded = false;
|
|
|
|
function getPluginBindingGlobalState(): PluginBindingGlobalState {
|
|
const globalStore = globalThis as typeof globalThis & {
|
|
[pluginBindingGlobalStateKey]?: PluginBindingGlobalState;
|
|
};
|
|
return (globalStore[pluginBindingGlobalStateKey] ??= {
|
|
fallbackNoticeBindingIds: new Set<string>(),
|
|
});
|
|
}
|
|
|
|
class PluginBindingApprovalButton extends Button {
|
|
customId: string;
|
|
label: string;
|
|
style: ButtonStyle;
|
|
|
|
constructor(params: {
|
|
approvalId: string;
|
|
decision: PluginBindingApprovalDecision;
|
|
label: string;
|
|
style: ButtonStyle;
|
|
}) {
|
|
super();
|
|
this.customId = buildPluginBindingApprovalCustomId(params.approvalId, params.decision);
|
|
this.label = params.label;
|
|
this.style = params.style;
|
|
}
|
|
}
|
|
|
|
function resolveApprovalsPath(): string {
|
|
return expandHomePrefix(APPROVALS_PATH);
|
|
}
|
|
|
|
function normalizeChannel(value: string): string {
|
|
return value.trim().toLowerCase();
|
|
}
|
|
|
|
function normalizeConversation(params: PluginBindingConversation): PluginBindingConversation {
|
|
return {
|
|
channel: normalizeChannel(params.channel),
|
|
accountId: params.accountId.trim() || "default",
|
|
conversationId: params.conversationId.trim(),
|
|
parentConversationId: params.parentConversationId?.trim() || undefined,
|
|
threadId:
|
|
typeof params.threadId === "number"
|
|
? Math.trunc(params.threadId)
|
|
: params.threadId?.toString().trim() || undefined,
|
|
};
|
|
}
|
|
|
|
function toConversationRef(params: PluginBindingConversation): ConversationRef {
|
|
const normalized = normalizeConversation(params);
|
|
if (normalized.channel === "telegram") {
|
|
const threadId =
|
|
typeof normalized.threadId === "number" || typeof normalized.threadId === "string"
|
|
? String(normalized.threadId).trim()
|
|
: "";
|
|
if (threadId) {
|
|
const parent = normalized.parentConversationId?.trim() || normalized.conversationId;
|
|
return {
|
|
channel: "telegram",
|
|
accountId: normalized.accountId,
|
|
conversationId: `${parent}:topic:${threadId}`,
|
|
};
|
|
}
|
|
}
|
|
return {
|
|
channel: normalized.channel,
|
|
accountId: normalized.accountId,
|
|
conversationId: normalized.conversationId,
|
|
...(normalized.parentConversationId
|
|
? { parentConversationId: normalized.parentConversationId }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
function buildApprovalScopeKey(params: {
|
|
pluginRoot: string;
|
|
channel: string;
|
|
accountId: string;
|
|
}): string {
|
|
return [
|
|
params.pluginRoot,
|
|
normalizeChannel(params.channel),
|
|
params.accountId.trim() || "default",
|
|
].join("::");
|
|
}
|
|
|
|
function buildPluginBindingSessionKey(params: {
|
|
pluginId: string;
|
|
channel: string;
|
|
accountId: string;
|
|
conversationId: string;
|
|
}): string {
|
|
const hash = crypto
|
|
.createHash("sha256")
|
|
.update(
|
|
JSON.stringify({
|
|
pluginId: params.pluginId,
|
|
channel: normalizeChannel(params.channel),
|
|
accountId: params.accountId,
|
|
conversationId: params.conversationId,
|
|
}),
|
|
)
|
|
.digest("hex")
|
|
.slice(0, 24);
|
|
return `${PLUGIN_BINDING_SESSION_PREFIX}:${params.pluginId}:${hash}`;
|
|
}
|
|
|
|
function isLegacyPluginBindingRecord(params: {
|
|
record:
|
|
| {
|
|
targetSessionKey: string;
|
|
metadata?: Record<string, unknown>;
|
|
}
|
|
| null
|
|
| undefined;
|
|
}): boolean {
|
|
if (!params.record || isPluginOwnedBindingMetadata(params.record.metadata)) {
|
|
return false;
|
|
}
|
|
const targetSessionKey = params.record.targetSessionKey.trim();
|
|
return (
|
|
targetSessionKey.startsWith(`${PLUGIN_BINDING_SESSION_PREFIX}:`) ||
|
|
LEGACY_CODEX_PLUGIN_SESSION_PREFIXES.some((prefix) => targetSessionKey.startsWith(prefix))
|
|
);
|
|
}
|
|
|
|
function buildDiscordButtonRow(
|
|
approvalId: string,
|
|
labels?: { once?: string; always?: string; deny?: string },
|
|
): TopLevelComponents[] {
|
|
return [
|
|
new Row([
|
|
new PluginBindingApprovalButton({
|
|
approvalId,
|
|
decision: "allow-once",
|
|
label: labels?.once ?? "Allow once",
|
|
style: ButtonStyle.Success,
|
|
}),
|
|
new PluginBindingApprovalButton({
|
|
approvalId,
|
|
decision: "allow-always",
|
|
label: labels?.always ?? "Always allow",
|
|
style: ButtonStyle.Primary,
|
|
}),
|
|
new PluginBindingApprovalButton({
|
|
approvalId,
|
|
decision: "deny",
|
|
label: labels?.deny ?? "Deny",
|
|
style: ButtonStyle.Danger,
|
|
}),
|
|
]),
|
|
];
|
|
}
|
|
|
|
function buildTelegramButtons(approvalId: string) {
|
|
return [
|
|
[
|
|
{
|
|
text: "Allow once",
|
|
callback_data: buildPluginBindingApprovalCustomId(approvalId, "allow-once"),
|
|
style: "success" as const,
|
|
},
|
|
{
|
|
text: "Always allow",
|
|
callback_data: buildPluginBindingApprovalCustomId(approvalId, "allow-always"),
|
|
style: "primary" as const,
|
|
},
|
|
{
|
|
text: "Deny",
|
|
callback_data: buildPluginBindingApprovalCustomId(approvalId, "deny"),
|
|
style: "danger" as const,
|
|
},
|
|
],
|
|
];
|
|
}
|
|
|
|
function createApprovalRequestId(): string {
|
|
// Keep approval ids compact so Telegram callback_data stays under its 64-byte limit.
|
|
return crypto.randomBytes(9).toString("base64url");
|
|
}
|
|
|
|
function loadApprovalsFromDisk(): PluginBindingApprovalsFile {
|
|
const filePath = resolveApprovalsPath();
|
|
try {
|
|
if (!fs.existsSync(filePath)) {
|
|
return { version: 1, approvals: [] };
|
|
}
|
|
const raw = fs.readFileSync(filePath, "utf8");
|
|
const parsed = JSON.parse(raw) as Partial<PluginBindingApprovalsFile>;
|
|
if (!Array.isArray(parsed.approvals)) {
|
|
return { version: 1, approvals: [] };
|
|
}
|
|
return {
|
|
version: 1,
|
|
approvals: parsed.approvals
|
|
.filter((entry): entry is PluginBindingApprovalEntry =>
|
|
Boolean(entry && typeof entry === "object"),
|
|
)
|
|
.map((entry) => ({
|
|
pluginRoot: typeof entry.pluginRoot === "string" ? entry.pluginRoot : "",
|
|
pluginId: typeof entry.pluginId === "string" ? entry.pluginId : "",
|
|
pluginName: typeof entry.pluginName === "string" ? entry.pluginName : undefined,
|
|
channel: typeof entry.channel === "string" ? normalizeChannel(entry.channel) : "",
|
|
accountId:
|
|
typeof entry.accountId === "string" ? entry.accountId.trim() || "default" : "default",
|
|
approvedAt:
|
|
typeof entry.approvedAt === "number" && Number.isFinite(entry.approvedAt)
|
|
? Math.floor(entry.approvedAt)
|
|
: Date.now(),
|
|
}))
|
|
.filter((entry) => entry.pluginRoot && entry.pluginId && entry.channel),
|
|
};
|
|
} catch (error) {
|
|
log.warn(`plugin binding approvals load failed: ${String(error)}`);
|
|
return { version: 1, approvals: [] };
|
|
}
|
|
}
|
|
|
|
async function saveApprovals(file: PluginBindingApprovalsFile): Promise<void> {
|
|
const filePath = resolveApprovalsPath();
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
approvalsCache = file;
|
|
approvalsLoaded = true;
|
|
await writeJsonAtomic(filePath, file, {
|
|
mode: 0o600,
|
|
trailingNewline: true,
|
|
});
|
|
}
|
|
|
|
function getApprovals(): PluginBindingApprovalsFile {
|
|
if (!approvalsLoaded || !approvalsCache) {
|
|
approvalsCache = loadApprovalsFromDisk();
|
|
approvalsLoaded = true;
|
|
}
|
|
return approvalsCache;
|
|
}
|
|
|
|
function hasPersistentApproval(params: {
|
|
pluginRoot: string;
|
|
channel: string;
|
|
accountId: string;
|
|
}): boolean {
|
|
const key = buildApprovalScopeKey(params);
|
|
return getApprovals().approvals.some(
|
|
(entry) =>
|
|
buildApprovalScopeKey({
|
|
pluginRoot: entry.pluginRoot,
|
|
channel: entry.channel,
|
|
accountId: entry.accountId,
|
|
}) === key,
|
|
);
|
|
}
|
|
|
|
async function addPersistentApproval(entry: PluginBindingApprovalEntry): Promise<void> {
|
|
const file = getApprovals();
|
|
const key = buildApprovalScopeKey(entry);
|
|
const approvals = file.approvals.filter(
|
|
(existing) =>
|
|
buildApprovalScopeKey({
|
|
pluginRoot: existing.pluginRoot,
|
|
channel: existing.channel,
|
|
accountId: existing.accountId,
|
|
}) !== key,
|
|
);
|
|
approvals.push(entry);
|
|
await saveApprovals({
|
|
version: 1,
|
|
approvals,
|
|
});
|
|
}
|
|
|
|
function buildBindingMetadata(params: {
|
|
pluginId: string;
|
|
pluginName?: string;
|
|
pluginRoot: string;
|
|
summary?: string;
|
|
detachHint?: string;
|
|
}): PluginBindingMetadata {
|
|
return {
|
|
pluginBindingOwner: PLUGIN_BINDING_OWNER,
|
|
pluginId: params.pluginId,
|
|
pluginName: params.pluginName,
|
|
pluginRoot: params.pluginRoot,
|
|
summary: params.summary?.trim() || undefined,
|
|
detachHint: params.detachHint?.trim() || undefined,
|
|
};
|
|
}
|
|
|
|
export function isPluginOwnedBindingMetadata(metadata: unknown): metadata is PluginBindingMetadata {
|
|
if (!metadata || typeof metadata !== "object") {
|
|
return false;
|
|
}
|
|
const record = metadata as Record<string, unknown>;
|
|
return (
|
|
record.pluginBindingOwner === PLUGIN_BINDING_OWNER &&
|
|
typeof record.pluginId === "string" &&
|
|
typeof record.pluginRoot === "string"
|
|
);
|
|
}
|
|
|
|
export function isPluginOwnedSessionBindingRecord(
|
|
record:
|
|
| {
|
|
metadata?: Record<string, unknown>;
|
|
}
|
|
| null
|
|
| undefined,
|
|
): boolean {
|
|
return isPluginOwnedBindingMetadata(record?.metadata);
|
|
}
|
|
|
|
export function toPluginConversationBinding(
|
|
record:
|
|
| {
|
|
bindingId: string;
|
|
conversation: ConversationRef;
|
|
boundAt: number;
|
|
metadata?: Record<string, unknown>;
|
|
}
|
|
| null
|
|
| undefined,
|
|
): PluginConversationBinding | null {
|
|
if (!record || !isPluginOwnedBindingMetadata(record.metadata)) {
|
|
return null;
|
|
}
|
|
const metadata = record.metadata;
|
|
return {
|
|
bindingId: record.bindingId,
|
|
pluginId: metadata.pluginId,
|
|
pluginName: metadata.pluginName,
|
|
pluginRoot: metadata.pluginRoot,
|
|
channel: record.conversation.channel,
|
|
accountId: record.conversation.accountId,
|
|
conversationId: record.conversation.conversationId,
|
|
parentConversationId: record.conversation.parentConversationId,
|
|
boundAt: record.boundAt,
|
|
summary: metadata.summary,
|
|
detachHint: metadata.detachHint,
|
|
};
|
|
}
|
|
|
|
async function bindConversationNow(params: {
|
|
identity: PluginBindingIdentity;
|
|
conversation: PluginBindingConversation;
|
|
summary?: string;
|
|
detachHint?: string;
|
|
}): Promise<PluginConversationBinding> {
|
|
const ref = toConversationRef(params.conversation);
|
|
const targetSessionKey = buildPluginBindingSessionKey({
|
|
pluginId: params.identity.pluginId,
|
|
channel: ref.channel,
|
|
accountId: ref.accountId,
|
|
conversationId: ref.conversationId,
|
|
});
|
|
const record = await getSessionBindingService().bind({
|
|
targetSessionKey,
|
|
targetKind: "session",
|
|
conversation: ref,
|
|
placement: "current",
|
|
metadata: buildBindingMetadata({
|
|
pluginId: params.identity.pluginId,
|
|
pluginName: params.identity.pluginName,
|
|
pluginRoot: params.identity.pluginRoot,
|
|
summary: params.summary,
|
|
detachHint: params.detachHint,
|
|
}),
|
|
});
|
|
const binding = toPluginConversationBinding(record);
|
|
if (!binding) {
|
|
throw new Error("plugin binding was created without plugin metadata");
|
|
}
|
|
return {
|
|
...binding,
|
|
parentConversationId: params.conversation.parentConversationId,
|
|
threadId: params.conversation.threadId,
|
|
};
|
|
}
|
|
|
|
function buildApprovalMessage(request: PendingPluginBindingRequest): string {
|
|
const lines = [
|
|
`Plugin bind approval required`,
|
|
`Plugin: ${request.pluginName ?? request.pluginId}`,
|
|
`Channel: ${request.conversation.channel}`,
|
|
`Account: ${request.conversation.accountId}`,
|
|
];
|
|
if (request.summary?.trim()) {
|
|
lines.push(`Request: ${request.summary.trim()}`);
|
|
} else {
|
|
lines.push("Request: Bind this conversation so future plain messages route to the plugin.");
|
|
}
|
|
lines.push("Choose whether to allow this plugin to bind the current conversation.");
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function resolvePluginBindingDisplayName(binding: {
|
|
pluginId: string;
|
|
pluginName?: string;
|
|
}): string {
|
|
return binding.pluginName?.trim() || binding.pluginId;
|
|
}
|
|
|
|
function buildDetachHintSuffix(detachHint?: string): string {
|
|
const trimmed = detachHint?.trim();
|
|
return trimmed ? ` To detach this conversation, use ${trimmed}.` : "";
|
|
}
|
|
|
|
export function buildPluginBindingUnavailableText(binding: PluginConversationBinding): string {
|
|
return `The bound plugin ${resolvePluginBindingDisplayName(binding)} is not currently loaded. Routing this message to OpenClaw instead.${buildDetachHintSuffix(binding.detachHint)}`;
|
|
}
|
|
|
|
export function buildPluginBindingDeclinedText(binding: PluginConversationBinding): string {
|
|
return `The bound plugin ${resolvePluginBindingDisplayName(binding)} did not handle this message. This conversation is still bound to that plugin.${buildDetachHintSuffix(binding.detachHint)}`;
|
|
}
|
|
|
|
export function buildPluginBindingErrorText(binding: PluginConversationBinding): string {
|
|
return `The bound plugin ${resolvePluginBindingDisplayName(binding)} hit an error handling this message. This conversation is still bound to that plugin.${buildDetachHintSuffix(binding.detachHint)}`;
|
|
}
|
|
|
|
export function hasShownPluginBindingFallbackNotice(bindingId: string): boolean {
|
|
const normalized = bindingId.trim();
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
return getPluginBindingGlobalState().fallbackNoticeBindingIds.has(normalized);
|
|
}
|
|
|
|
export function markPluginBindingFallbackNoticeShown(bindingId: string): void {
|
|
const normalized = bindingId.trim();
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
getPluginBindingGlobalState().fallbackNoticeBindingIds.add(normalized);
|
|
}
|
|
|
|
function buildPendingReply(request: PendingPluginBindingRequest): ReplyPayload {
|
|
return {
|
|
text: buildApprovalMessage(request),
|
|
channelData: {
|
|
telegram: {
|
|
buttons: buildTelegramButtons(request.id),
|
|
},
|
|
discord: {
|
|
components: buildDiscordButtonRow(request.id),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function encodeCustomIdValue(value: string): string {
|
|
return encodeURIComponent(value);
|
|
}
|
|
|
|
function decodeCustomIdValue(value: string): string {
|
|
try {
|
|
return decodeURIComponent(value);
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
export function buildPluginBindingApprovalCustomId(
|
|
approvalId: string,
|
|
decision: PluginBindingApprovalDecision,
|
|
): string {
|
|
const decisionCode = decision === "allow-once" ? "o" : decision === "allow-always" ? "a" : "d";
|
|
return `${PLUGIN_BINDING_CUSTOM_ID_PREFIX}:${encodeCustomIdValue(approvalId)}:${decisionCode}`;
|
|
}
|
|
|
|
export function parsePluginBindingApprovalCustomId(
|
|
value: string,
|
|
): PluginBindingApprovalAction | null {
|
|
const trimmed = value.trim();
|
|
if (!trimmed.startsWith(`${PLUGIN_BINDING_CUSTOM_ID_PREFIX}:`)) {
|
|
return null;
|
|
}
|
|
const body = trimmed.slice(`${PLUGIN_BINDING_CUSTOM_ID_PREFIX}:`.length);
|
|
const separator = body.lastIndexOf(":");
|
|
if (separator <= 0 || separator === body.length - 1) {
|
|
return null;
|
|
}
|
|
const rawId = body.slice(0, separator).trim();
|
|
const rawDecisionCode = body.slice(separator + 1).trim();
|
|
if (!rawId) {
|
|
return null;
|
|
}
|
|
const rawDecision =
|
|
rawDecisionCode === "o"
|
|
? "allow-once"
|
|
: rawDecisionCode === "a"
|
|
? "allow-always"
|
|
: rawDecisionCode === "d"
|
|
? "deny"
|
|
: null;
|
|
if (!rawDecision) {
|
|
return null;
|
|
}
|
|
return {
|
|
approvalId: decodeCustomIdValue(rawId),
|
|
decision: rawDecision,
|
|
};
|
|
}
|
|
|
|
export async function requestPluginConversationBinding(params: {
|
|
pluginId: string;
|
|
pluginName?: string;
|
|
pluginRoot: string;
|
|
conversation: PluginBindingConversation;
|
|
requestedBySenderId?: string;
|
|
binding: PluginConversationBindingRequestParams | undefined;
|
|
}): Promise<PluginConversationBindingRequestResult> {
|
|
const conversation = normalizeConversation(params.conversation);
|
|
const ref = toConversationRef(conversation);
|
|
const existing = getSessionBindingService().resolveByConversation(ref);
|
|
const existingPluginBinding = toPluginConversationBinding(existing);
|
|
const existingLegacyPluginBinding = isLegacyPluginBindingRecord({
|
|
record: existing,
|
|
});
|
|
if (existing && !existingPluginBinding) {
|
|
if (existingLegacyPluginBinding) {
|
|
log.info(
|
|
`plugin binding migrating legacy record plugin=${params.pluginId} root=${params.pluginRoot} channel=${ref.channel} account=${ref.accountId} conversation=${ref.conversationId}`,
|
|
);
|
|
} else {
|
|
return {
|
|
status: "error",
|
|
message:
|
|
"This conversation is already bound by core routing and cannot be claimed by a plugin.",
|
|
};
|
|
}
|
|
}
|
|
if (existingPluginBinding && existingPluginBinding.pluginRoot !== params.pluginRoot) {
|
|
return {
|
|
status: "error",
|
|
message: `This conversation is already bound by plugin "${existingPluginBinding.pluginName ?? existingPluginBinding.pluginId}".`,
|
|
};
|
|
}
|
|
|
|
if (existingPluginBinding && existingPluginBinding.pluginRoot === params.pluginRoot) {
|
|
const rebound = await bindConversationNow({
|
|
identity: {
|
|
pluginId: params.pluginId,
|
|
pluginName: params.pluginName,
|
|
pluginRoot: params.pluginRoot,
|
|
},
|
|
conversation,
|
|
summary: params.binding?.summary,
|
|
detachHint: params.binding?.detachHint,
|
|
});
|
|
log.info(
|
|
`plugin binding auto-refresh plugin=${params.pluginId} root=${params.pluginRoot} channel=${ref.channel} account=${ref.accountId} conversation=${ref.conversationId}`,
|
|
);
|
|
return { status: "bound", binding: rebound };
|
|
}
|
|
|
|
if (
|
|
hasPersistentApproval({
|
|
pluginRoot: params.pluginRoot,
|
|
channel: ref.channel,
|
|
accountId: ref.accountId,
|
|
})
|
|
) {
|
|
const bound = await bindConversationNow({
|
|
identity: {
|
|
pluginId: params.pluginId,
|
|
pluginName: params.pluginName,
|
|
pluginRoot: params.pluginRoot,
|
|
},
|
|
conversation,
|
|
summary: params.binding?.summary,
|
|
detachHint: params.binding?.detachHint,
|
|
});
|
|
log.info(
|
|
`plugin binding auto-approved plugin=${params.pluginId} root=${params.pluginRoot} channel=${ref.channel} account=${ref.accountId} conversation=${ref.conversationId}`,
|
|
);
|
|
return { status: "bound", binding: bound };
|
|
}
|
|
|
|
const request: PendingPluginBindingRequest = {
|
|
id: createApprovalRequestId(),
|
|
pluginId: params.pluginId,
|
|
pluginName: params.pluginName,
|
|
pluginRoot: params.pluginRoot,
|
|
conversation,
|
|
requestedAt: Date.now(),
|
|
requestedBySenderId: params.requestedBySenderId?.trim() || undefined,
|
|
summary: params.binding?.summary?.trim() || undefined,
|
|
detachHint: params.binding?.detachHint?.trim() || undefined,
|
|
};
|
|
pendingRequests.set(request.id, request);
|
|
log.info(
|
|
`plugin binding requested plugin=${params.pluginId} root=${params.pluginRoot} channel=${ref.channel} account=${ref.accountId} conversation=${ref.conversationId}`,
|
|
);
|
|
return {
|
|
status: "pending",
|
|
approvalId: request.id,
|
|
reply: buildPendingReply(request),
|
|
};
|
|
}
|
|
|
|
export async function getCurrentPluginConversationBinding(params: {
|
|
pluginRoot: string;
|
|
conversation: PluginBindingConversation;
|
|
}): Promise<PluginConversationBinding | null> {
|
|
const record = getSessionBindingService().resolveByConversation(
|
|
toConversationRef(params.conversation),
|
|
);
|
|
const binding = toPluginConversationBinding(record);
|
|
if (!binding || binding.pluginRoot !== params.pluginRoot) {
|
|
return null;
|
|
}
|
|
return {
|
|
...binding,
|
|
parentConversationId: params.conversation.parentConversationId,
|
|
threadId: params.conversation.threadId,
|
|
};
|
|
}
|
|
|
|
export async function detachPluginConversationBinding(params: {
|
|
pluginRoot: string;
|
|
conversation: PluginBindingConversation;
|
|
}): Promise<{ removed: boolean }> {
|
|
const ref = toConversationRef(params.conversation);
|
|
const record = getSessionBindingService().resolveByConversation(ref);
|
|
const binding = toPluginConversationBinding(record);
|
|
if (!binding || binding.pluginRoot !== params.pluginRoot) {
|
|
return { removed: false };
|
|
}
|
|
await getSessionBindingService().unbind({
|
|
bindingId: binding.bindingId,
|
|
reason: "plugin-detach",
|
|
});
|
|
log.info(
|
|
`plugin binding detached plugin=${binding.pluginId} root=${binding.pluginRoot} channel=${binding.channel} account=${binding.accountId} conversation=${binding.conversationId}`,
|
|
);
|
|
return { removed: true };
|
|
}
|
|
|
|
export async function resolvePluginConversationBindingApproval(params: {
|
|
approvalId: string;
|
|
decision: PluginBindingApprovalDecision;
|
|
senderId?: string;
|
|
}): Promise<PluginBindingResolveResult> {
|
|
const request = pendingRequests.get(params.approvalId);
|
|
if (!request) {
|
|
return { status: "expired" };
|
|
}
|
|
if (
|
|
request.requestedBySenderId &&
|
|
params.senderId?.trim() &&
|
|
request.requestedBySenderId !== params.senderId.trim()
|
|
) {
|
|
return { status: "expired" };
|
|
}
|
|
pendingRequests.delete(params.approvalId);
|
|
if (params.decision === "deny") {
|
|
log.info(
|
|
`plugin binding denied plugin=${request.pluginId} root=${request.pluginRoot} channel=${request.conversation.channel} account=${request.conversation.accountId} conversation=${request.conversation.conversationId}`,
|
|
);
|
|
return { status: "denied", request };
|
|
}
|
|
if (params.decision === "allow-always") {
|
|
await addPersistentApproval({
|
|
pluginRoot: request.pluginRoot,
|
|
pluginId: request.pluginId,
|
|
pluginName: request.pluginName,
|
|
channel: request.conversation.channel,
|
|
accountId: request.conversation.accountId,
|
|
approvedAt: Date.now(),
|
|
});
|
|
}
|
|
const binding = await bindConversationNow({
|
|
identity: {
|
|
pluginId: request.pluginId,
|
|
pluginName: request.pluginName,
|
|
pluginRoot: request.pluginRoot,
|
|
},
|
|
conversation: request.conversation,
|
|
summary: request.summary,
|
|
detachHint: request.detachHint,
|
|
});
|
|
log.info(
|
|
`plugin binding approved plugin=${request.pluginId} root=${request.pluginRoot} decision=${params.decision} channel=${request.conversation.channel} account=${request.conversation.accountId} conversation=${request.conversation.conversationId}`,
|
|
);
|
|
return {
|
|
status: "approved",
|
|
binding,
|
|
request,
|
|
decision: params.decision,
|
|
};
|
|
}
|
|
|
|
export function buildPluginBindingResolvedText(params: PluginBindingResolveResult): string {
|
|
if (params.status === "expired") {
|
|
return "That plugin bind approval expired. Retry the bind command.";
|
|
}
|
|
if (params.status === "denied") {
|
|
return `Denied plugin bind request for ${params.request.pluginName ?? params.request.pluginId}.`;
|
|
}
|
|
const summarySuffix = params.request.summary?.trim() ? ` ${params.request.summary.trim()}` : "";
|
|
if (params.decision === "allow-always") {
|
|
return `Allowed ${params.request.pluginName ?? params.request.pluginId} to bind this conversation.${summarySuffix}`;
|
|
}
|
|
return `Allowed ${params.request.pluginName ?? params.request.pluginId} to bind this conversation once.${summarySuffix}`;
|
|
}
|
|
|
|
export const __testing = {
|
|
reset() {
|
|
pendingRequests.clear();
|
|
approvalsCache = null;
|
|
approvalsLoaded = false;
|
|
getPluginBindingGlobalState().fallbackNoticeBindingIds.clear();
|
|
},
|
|
};
|