openclaw/extensions/discord/src/monitor/model-picker-preferences.ts
scoootscooob 5682ec37fa
refactor: move Discord channel implementation to extensions/ (#45660)
* refactor: move Discord channel implementation to extensions/discord/src/

Move all Discord source files from src/discord/ to extensions/discord/src/,
following the extension migration pattern. Source files in src/discord/ are
replaced with re-export shims. Channel-plugin files from
src/channels/plugins/*/discord* are similarly moved and shimmed.

- Copy all .ts source files preserving subdirectory structure (monitor/, voice/)
- Move channel-plugin files (actions, normalize, onboarding, outbound, status-issues)
- Fix all relative imports to use correct paths from new location
- Create re-export shims at original locations for backward compatibility
- Delete test files from shim locations (tests live in extension now)
- Update tsconfig.plugin-sdk.dts.json rootDir from "src" to "." to accommodate
  extension files outside src/
- Update write-plugin-sdk-entry-dts.ts to match new declaration output paths

* fix: add importOriginal to thread-bindings session-meta mock for extensions test

* style: fix formatting in thread-bindings lifecycle test
2026-03-14 02:53:57 -07:00

166 lines
4.9 KiB
TypeScript

import os from "node:os";
import path from "node:path";
import { normalizeProviderId } from "../../../../src/agents/model-selection.js";
import { resolveStateDir } from "../../../../src/config/paths.js";
import { withFileLock } from "../../../../src/infra/file-lock.js";
import { resolveRequiredHomeDir } from "../../../../src/infra/home-dir.js";
import {
readJsonFileWithFallback,
writeJsonFileAtomically,
} from "../../../../src/plugin-sdk/json-store.js";
import { normalizeAccountId as normalizeSharedAccountId } from "../../../../src/routing/account-id.js";
const MODEL_PICKER_PREFERENCES_LOCK_OPTIONS = {
retries: {
retries: 8,
factor: 2,
minTimeout: 50,
maxTimeout: 5_000,
randomize: true,
},
stale: 15_000,
} as const;
const DEFAULT_RECENT_LIMIT = 5;
type ModelPickerPreferencesEntry = {
recent: string[];
updatedAt: string;
};
type ModelPickerPreferencesStore = {
version: 1;
entries: Record<string, ModelPickerPreferencesEntry>;
};
export type DiscordModelPickerPreferenceScope = {
accountId?: string;
guildId?: string;
userId: string;
};
function resolvePreferencesStorePath(env: NodeJS.ProcessEnv = process.env): string {
const stateDir = resolveStateDir(env, () => resolveRequiredHomeDir(env, os.homedir));
return path.join(stateDir, "discord", "model-picker-preferences.json");
}
function normalizeId(value?: string): string {
return value?.trim() ?? "";
}
export function buildDiscordModelPickerPreferenceKey(
scope: DiscordModelPickerPreferenceScope,
): string | null {
const userId = normalizeId(scope.userId);
if (!userId) {
return null;
}
const accountId = normalizeSharedAccountId(scope.accountId);
const guildId = normalizeId(scope.guildId);
if (guildId) {
return `discord:${accountId}:guild:${guildId}:user:${userId}`;
}
return `discord:${accountId}:dm:user:${userId}`;
}
function normalizeModelRef(raw?: string): string | null {
const value = raw?.trim();
if (!value) {
return null;
}
const slashIndex = value.indexOf("/");
if (slashIndex <= 0 || slashIndex >= value.length - 1) {
return null;
}
const provider = normalizeProviderId(value.slice(0, slashIndex));
const model = value.slice(slashIndex + 1).trim();
if (!provider || !model) {
return null;
}
return `${provider}/${model}`;
}
function sanitizeRecentModels(models: string[] | undefined, limit: number): string[] {
const deduped: string[] = [];
const seen = new Set<string>();
for (const item of models ?? []) {
const normalized = normalizeModelRef(item);
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
deduped.push(normalized);
if (deduped.length >= limit) {
break;
}
}
return deduped;
}
async function readPreferencesStore(filePath: string): Promise<ModelPickerPreferencesStore> {
const { value } = await readJsonFileWithFallback<ModelPickerPreferencesStore>(filePath, {
version: 1,
entries: {},
});
if (!value || typeof value !== "object" || value.version !== 1) {
return { version: 1, entries: {} };
}
return {
version: 1,
entries: value.entries && typeof value.entries === "object" ? value.entries : {},
};
}
export async function readDiscordModelPickerRecentModels(params: {
scope: DiscordModelPickerPreferenceScope;
limit?: number;
allowedModelRefs?: Set<string>;
env?: NodeJS.ProcessEnv;
}): Promise<string[]> {
const key = buildDiscordModelPickerPreferenceKey(params.scope);
if (!key) {
return [];
}
const limit = Math.max(1, Math.min(params.limit ?? DEFAULT_RECENT_LIMIT, 10));
const filePath = resolvePreferencesStorePath(params.env);
const store = await readPreferencesStore(filePath);
const entry = store.entries[key];
const recent = sanitizeRecentModels(entry?.recent, limit);
if (!params.allowedModelRefs || params.allowedModelRefs.size === 0) {
return recent;
}
return recent.filter((modelRef) => params.allowedModelRefs?.has(modelRef));
}
export async function recordDiscordModelPickerRecentModel(params: {
scope: DiscordModelPickerPreferenceScope;
modelRef: string;
limit?: number;
env?: NodeJS.ProcessEnv;
}): Promise<void> {
const key = buildDiscordModelPickerPreferenceKey(params.scope);
const normalizedModelRef = normalizeModelRef(params.modelRef);
if (!key || !normalizedModelRef) {
return;
}
const limit = Math.max(1, Math.min(params.limit ?? DEFAULT_RECENT_LIMIT, 10));
const filePath = resolvePreferencesStorePath(params.env);
await withFileLock(filePath, MODEL_PICKER_PREFERENCES_LOCK_OPTIONS, async () => {
const store = await readPreferencesStore(filePath);
const existing = sanitizeRecentModels(store.entries[key]?.recent, limit);
const next = [
normalizedModelRef,
...existing.filter((entry) => entry !== normalizedModelRef),
].slice(0, limit);
store.entries[key] = {
recent: next,
updatedAt: new Date().toISOString(),
};
await writeJsonFileAtomically(filePath, store);
});
}