refactor: dedupe line qqbot slack lowercase helpers

This commit is contained in:
Peter Steinberger
2026-04-07 20:33:42 +01:00
parent 5b090561fb
commit ba68537d9d
16 changed files with 79 additions and 47 deletions

View File

@@ -13,7 +13,10 @@ import type {
ModelDefinitionConfig,
ModelProviderConfig,
} from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "openclaw/plugin-sdk/text-runtime";
const log = createSubsystemLogger("bedrock-discovery");
@@ -69,7 +72,7 @@ function buildCacheKey(params: {
}
function includesTextModalities(modalities?: Array<string>): boolean {
return (modalities ?? []).some((entry) => entry.toLowerCase() === "text");
return (modalities ?? []).some((entry) => normalizeOptionalLowercaseString(entry) === "text");
}
function isActive(summary: BedrockModelSummary): boolean {
@@ -81,7 +84,7 @@ function mapInputModalities(summary: BedrockModelSummary): Array<"text" | "image
const inputs = summary.inputModalities ?? [];
const mapped = new Set<"text" | "image">();
for (const modality of inputs) {
const lower = modality.toLowerCase();
const lower = normalizeOptionalLowercaseString(modality);
if (lower === "text") {
mapped.add("text");
}
@@ -96,7 +99,9 @@ function mapInputModalities(summary: BedrockModelSummary): Array<"text" | "image
}
function inferReasoningSupport(summary: BedrockModelSummary): boolean {
const haystack = `${summary.modelId ?? ""} ${summary.modelName ?? ""}`.toLowerCase();
const haystack = normalizeLowercaseStringOrEmpty(
`${summary.modelId ?? ""} ${summary.modelName ?? ""}`,
);
return haystack.includes("reasoning") || haystack.includes("thinking");
}
@@ -256,7 +261,9 @@ function resolveInferenceProfiles(
const models = profile.models ?? [];
const matchesFilter = models.some((m) => {
const provider = m.modelArn?.split("/")?.[1]?.split(".")?.[0];
return provider ? providerFilter.includes(provider.toLowerCase()) : false;
return provider
? providerFilter.includes(normalizeOptionalLowercaseString(provider) ?? "")
: false;
});
if (!matchesFilter) {
continue;
@@ -265,7 +272,9 @@ function resolveInferenceProfiles(
// Look up the underlying foundation model to inherit its capabilities.
const baseModelId = resolveBaseModelId(profile);
const baseModel = baseModelId ? foundationModels.get(baseModelId.toLowerCase()) : undefined;
const baseModel = baseModelId
? foundationModels.get(normalizeLowercaseStringOrEmpty(baseModelId))
: undefined;
discovered.push({
id: profile.inferenceProfileId,
@@ -356,8 +365,9 @@ export async function discoverBedrockModels(params: {
maxTokens: defaultMaxTokens,
});
discovered.push(def);
seenIds.add(def.id.toLowerCase());
foundationModels.set(def.id.toLowerCase(), def);
const normalizedId = normalizeLowercaseStringOrEmpty(def.id);
seenIds.add(normalizedId);
foundationModels.set(normalizedId, def);
}
// Merge inference profiles — inherit capabilities from foundation models.
@@ -368,9 +378,10 @@ export async function discoverBedrockModels(params: {
foundationModels,
);
for (const profile of inferenceProfiles) {
if (!seenIds.has(profile.id.toLowerCase())) {
const normalizedId = normalizeLowercaseStringOrEmpty(profile.id);
if (!seenIds.has(normalizedId)) {
discovered.push(profile);
seenIds.add(profile.id.toLowerCase());
seenIds.add(normalizedId);
}
}

View File

@@ -1,5 +1,6 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import {
createActionCard,
createImageCard,
@@ -136,7 +137,7 @@ function parseCardArgs(argsStr: string): {
// Extract type (first word)
const typeMatch = argsStr.match(/^(\w+)/);
if (typeMatch) {
result.type = typeMatch[1].toLowerCase();
result.type = normalizeLowercaseStringOrEmpty(typeMatch[1]);
argsStr = argsStr.slice(typeMatch[0].length).trim();
}

View File

@@ -1,3 +1,5 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
export type LineOutboundMediaKind = "image" | "video" | "audio";
export type LineOutboundMediaResolved = {
@@ -31,7 +33,7 @@ export function validateLineMediaUrl(url: string): void {
}
export function detectLineMediaKind(mimeType: string): LineOutboundMediaKind {
const normalized = mimeType.toLowerCase();
const normalized = normalizeLowercaseStringOrEmpty(mimeType);
if (normalized.startsWith("image/")) {
return "image";
}
@@ -54,7 +56,7 @@ function isHttpsUrl(url: string): boolean {
function detectLineMediaKindFromUrl(url: string): LineOutboundMediaKind | undefined {
try {
const pathname = new URL(url).pathname.toLowerCase();
const pathname = normalizeLowercaseStringOrEmpty(new URL(url).pathname);
if (/\.(png|jpe?g|gif|webp|bmp|heic|heif|avif)$/i.test(pathname)) {
return "image";
}

View File

@@ -1,3 +1,4 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import type { ReplyPayload } from "../runtime-api.js";
import {
createAgendaCard,
@@ -33,8 +34,7 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
...(result.channelData?.line as LineChannelData | undefined),
};
const toSlug = (value: string): string =>
value
.toLowerCase()
normalizeLowercaseStringOrEmpty(value)
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "") || "device";
const lineActionData = (action: string, extras?: Record<string, string>): string => {
@@ -85,10 +85,10 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
const [question, yesPart, noPart] = parts;
const [yesLabel, yesData] = yesPart.includes(":")
? yesPart.split(":").map((s) => s.trim())
: [yesPart, yesPart.toLowerCase()];
: [yesPart, normalizeLowercaseStringOrEmpty(yesPart)];
const [noLabel, noData] = noPart.includes(":")
? noPart.split(":").map((s) => s.trim())
: [noPart, noPart.toLowerCase()];
: [noPart, normalizeLowercaseStringOrEmpty(noPart)];
lineData.templateMessage = {
type: "confirm",
@@ -116,7 +116,7 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
if (index === -1) {
return -1;
}
const lower = trimmed.toLowerCase();
const lower = normalizeLowercaseStringOrEmpty(trimmed);
if (lower.startsWith("http://") || lower.startsWith("https://")) {
return -1;
}
@@ -161,7 +161,7 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
const parts = mediaPlayerMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 1) {
const [title, artist, source, imageUrl, statusStr] = parts;
const isPlaying = statusStr?.toLowerCase() === "playing";
const isPlaying = normalizeLowercaseStringOrEmpty(statusStr) === "playing";
const validImageUrl = imageUrl?.startsWith("https://") ? imageUrl : undefined;
const deviceKey = toSlug(source || title || "media");
const card = createMediaPlayerCard({
@@ -281,7 +281,7 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
const controls = controlsStr
? controlsStr.split(",").map((ctrlStr) => {
const [label, data] = ctrlStr.split(":").map((s) => s.trim());
const action = data || label.toLowerCase().replace(/\s+/g, "_");
const action = data || normalizeLowercaseStringOrEmpty(label).replace(/\s+/g, "_");
return { label, data: lineActionData(action, { "line.device": deviceKey }) };
})
: [];

View File

@@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises";
import { messagingApi } from "@line/bot-sdk";
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { resolveLineAccount } from "./accounts.js";
import { datetimePickerAction, messageAction, postbackAction, uriAction } from "./actions.js";
import { resolveLineChannelAccessToken } from "./channel-access-token.js";
@@ -104,7 +105,9 @@ export async function uploadRichMenuImage(
const blobClient = getBlobClient(opts);
const imageData = await readFile(imagePath);
const contentType = imagePath.toLowerCase().endsWith(".png") ? "image/png" : "image/jpeg";
const contentType = normalizeLowercaseStringOrEmpty(imagePath).endsWith(".png")
? "image/png"
: "image/jpeg";
await blobClient.setRichMenuImage(richMenuId, new Blob([imageData], { type: contentType }));

View File

@@ -1,5 +1,6 @@
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import WebSocket from "ws";
import {
clearTokenCache,
@@ -242,10 +243,11 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
return;
}
const contentLower = content.toLowerCase();
const contentLower = normalizeLowercaseStringOrEmpty(content);
const isUrgentCommand = URGENT_COMMANDS.some(
(cmd) =>
contentLower === cmd.toLowerCase() || contentLower.startsWith(cmd.toLowerCase() + " "),
contentLower === normalizeLowercaseStringOrEmpty(cmd) ||
contentLower.startsWith(normalizeLowercaseStringOrEmpty(cmd) + " "),
);
if (isUrgentCommand) {
log?.info(

View File

@@ -6,6 +6,7 @@
* 2. `sendPlainReply` handles plain replies, including markdown images and mixed text/media.
*/
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import {
sendC2CMessage,
sendDmMessage,
@@ -151,7 +152,7 @@ export async function parseAndSendMediaTags(
const tagCounts = mediaTagMatches.reduce(
(acc, m) => {
const t = m[1].toLowerCase();
const t = normalizeLowercaseStringOrEmpty(m[1]);
acc[t] = (acc[t] ?? 0) + 1;
return acc;
},
@@ -184,7 +185,7 @@ export async function parseAndSendMediaTags(
sendQueue.push({ type: "text", content: filterInternalMarkers(textBefore) });
}
const tagName = match[1].toLowerCase();
const tagName = normalizeLowercaseStringOrEmpty(match[1]);
let mediaPath = decodeMediaPath(match[2]?.trim() ?? "", log, prefix);
if (mediaPath) {

View File

@@ -1,5 +1,6 @@
import * as path from "path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import {
getAccessToken,
sendC2CFileMessage,
@@ -303,7 +304,7 @@ export async function sendPhoto(
return { channel: "qqbot", error: sizeCheck.error! };
}
const fileBuffer = await readFileAsync(mediaPath);
const ext = path.extname(mediaPath).toLowerCase();
const ext = normalizeLowercaseStringOrEmpty(path.extname(mediaPath));
const mimeTypes: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
@@ -482,7 +483,7 @@ async function sendVoiceFromLocal(
const needsTranscode = shouldTranscodeVoice(mediaPath);
if (needsTranscode && !transcodeEnabled) {
const ext = path.extname(mediaPath).toLowerCase();
const ext = normalizeLowercaseStringOrEmpty(path.extname(mediaPath));
debugLog(
`${prefix} sendVoice: transcode disabled, format ${ext} needs transcode, returning error for fallback`,
);
@@ -886,7 +887,7 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
sendQueue.push({ type: "text", content: textBefore });
}
const tagName = match[1].toLowerCase();
const tagName = normalizeLowercaseStringOrEmpty(match[1]);
let mediaPath = match[2]?.trim() ?? "";
if (mediaPath.startsWith("MEDIA:")) {
@@ -1368,7 +1369,7 @@ async function sendTextAfterMedia(ctx: MediaTargetContext, text: string): Promis
/** Extract a lowercase extension from a path or URL, ignoring query and hash segments. */
function getCleanExt(filePath: string): string {
const cleanPath = filePath.split("?")[0].split("#")[0];
return path.extname(cleanPath).toLowerCase();
return normalizeLowercaseStringOrEmpty(path.extname(cleanPath));
}
/** Check whether a file is an image using MIME first and extension as fallback. */

View File

@@ -11,6 +11,7 @@ import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { resolveRuntimeServiceVersion } from "openclaw/plugin-sdk/cli-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import type { QQBotAccountConfig } from "./types.js";
import { debugLog } from "./utils/debug-log.js";
import { getHomeDir, getQQBotDataDir, isWindows } from "./utils/platform.js";
@@ -134,9 +135,9 @@ const frameworkCommands: Map<string, SlashCommand> = new Map();
function registerCommand(cmd: SlashCommand): void {
if (cmd.requireAuth) {
frameworkCommands.set(cmd.name.toLowerCase(), cmd);
frameworkCommands.set(normalizeLowercaseStringOrEmpty(cmd.name), cmd);
} else {
commands.set(cmd.name.toLowerCase(), cmd);
commands.set(normalizeLowercaseStringOrEmpty(cmd.name), cmd);
}
}
@@ -604,7 +605,9 @@ export async function matchSlashCommand(ctx: SlashCommandContext): Promise<Slash
// Parse the command name and trailing arguments.
const spaceIdx = content.indexOf(" ");
const cmdName = (spaceIdx === -1 ? content.slice(1) : content.slice(1, spaceIdx)).toLowerCase();
const cmdName = normalizeLowercaseStringOrEmpty(
spaceIdx === -1 ? content.slice(1) : content.slice(1, spaceIdx),
);
const args = spaceIdx === -1 ? "" : content.slice(spaceIdx + 1).trim();
const cmd = commands.get(cmdName);

View File

@@ -117,7 +117,7 @@ export function isVoiceAttachment(att: { content_type?: string; filename?: strin
if (att.content_type === "voice" || att.content_type?.startsWith("audio/")) {
return true;
}
const ext = att.filename ? path.extname(att.filename).toLowerCase() : "";
const ext = att.filename ? normalizeLowercaseStringOrEmpty(path.extname(att.filename)) : "";
return [".amr", ".silk", ".slk", ".slac"].includes(ext);
}
@@ -139,7 +139,7 @@ export function isAudioFile(filePath: string, mimeType?: string): boolean {
return true;
}
}
const ext = path.extname(filePath).toLowerCase();
const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath));
return [
".silk",
".slk",
@@ -175,10 +175,10 @@ const QQ_NATIVE_VOICE_EXTS = new Set([".silk", ".slk", ".amr", ".wav", ".mp3"]);
*/
export function shouldTranscodeVoice(filePath: string, mimeType?: string): boolean {
// Prefer MIME when it is available.
if (mimeType && QQ_NATIVE_VOICE_MIMES.has(mimeType.toLowerCase())) {
if (mimeType && QQ_NATIVE_VOICE_MIMES.has(normalizeLowercaseStringOrEmpty(mimeType))) {
return false;
}
const ext = path.extname(filePath).toLowerCase();
const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath));
if (QQ_NATIVE_VOICE_EXTS.has(ext)) {
return false;
}
@@ -510,7 +510,7 @@ export async function audioFileToSilkBase64(
return null;
}
const ext = path.extname(filePath).toLowerCase();
const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath));
const uploadFormats = directUploadFormats
? normalizeFormats(directUploadFormats)

View File

@@ -4,6 +4,7 @@ import * as path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime";
import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
/** Maximum file size accepted by the QQ Bot API. */
export const MAX_UPLOAD_SIZE = 20 * 1024 * 1024;
@@ -92,7 +93,7 @@ export function formatFileSize(bytes: number): string {
/** Infer a MIME type from the file extension. */
export function getMimeType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath));
const mimeTypes: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",

View File

@@ -1,3 +1,4 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { expandTilde } from "./platform.js";
// Canonical media tags. `qqmedia` is the generic auto-routing tag.
@@ -84,7 +85,7 @@ const FUZZY_MEDIA_TAG_REGEX = new RegExp(
/** Normalize a raw tag name into the canonical tag set. */
function resolveTagName(raw: string): (typeof VALID_TAGS)[number] {
const lower = raw.toLowerCase();
const lower = normalizeLowercaseStringOrEmpty(raw);
if ((VALID_TAGS as readonly string[]).includes(lower)) {
return lower as (typeof VALID_TAGS)[number];
}

View File

@@ -1,3 +1,4 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import type { RefAttachmentSummary } from "../ref-index-store.js";
/** Replace QQ face tags with readable text labels. */
@@ -62,7 +63,7 @@ export function buildAttachmentSummaries(
return undefined;
}
return attachments.map((att, idx) => {
const ct = att.content_type?.toLowerCase() ?? "";
const ct = normalizeLowercaseStringOrEmpty(att.content_type);
let type: RefAttachmentSummary["type"] = "unknown";
if (ct.startsWith("image/")) {
type = "image";

View File

@@ -59,8 +59,8 @@ export function resolveSlackAllowListMatch(params: {
allowNameMatching?: boolean;
}): SlackAllowListMatch {
const compiledAllowList = compileAllowlist(params.allowList);
const id = params.id?.toLowerCase();
const name = params.name?.toLowerCase();
const id = normalizeOptionalLowercaseString(params.id);
const name = normalizeOptionalLowercaseString(params.name);
const slug = normalizeSlackSlug(name);
const candidates: Array<{ value?: string; source: SlackAllowListSource }> = [
{ value: id, source: "id" },

View File

@@ -5,7 +5,10 @@ import type { FetchLike } from "openclaw/plugin-sdk/media-runtime";
import { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime";
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
import { resolveRequestUrl } from "openclaw/plugin-sdk/request-url";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "openclaw/plugin-sdk/text-runtime";
import type { SlackAttachment, SlackFile } from "../types.js";
function isSlackHostname(hostname: string): boolean {
@@ -135,7 +138,9 @@ function resolveSlackMediaMimetype(
}
function looksLikeHtmlBuffer(buffer: Buffer): boolean {
const head = buffer.subarray(0, 512).toString("utf-8").replace(/^\s+/, "").toLowerCase();
const head = normalizeLowercaseStringOrEmpty(
buffer.subarray(0, 512).toString("utf-8").replace(/^\s+/, ""),
);
return head.startsWith("<!doctype html") || head.startsWith("<html");
}
@@ -235,8 +240,8 @@ export async function resolveSlackMedia(params: {
// Guard against auth/login HTML pages returned instead of binary media.
// Allow user-provided HTML files through.
const fileMime = file.mimetype?.toLowerCase();
const fileName = file.name?.toLowerCase() ?? "";
const fileMime = normalizeOptionalLowercaseString(file.mimetype);
const fileName = normalizeLowercaseStringOrEmpty(file.name);
const isExpectedHtml =
fileMime === "text/html" || fileName.endsWith(".html") || fileName.endsWith(".htm");
if (!isExpectedHtml) {

View File

@@ -771,7 +771,7 @@ export async function registerSlackMonitorSlashCommands(params: {
}
const query = normalizeLowercaseStringOrEmpty(typedBody.value);
const options = entry.choices
.filter((choice) => !query || choice.label.toLowerCase().includes(query))
.filter((choice) => !query || normalizeLowercaseStringOrEmpty(choice.label).includes(query))
.slice(0, SLACK_COMMAND_ARG_SELECT_OPTIONS_MAX)
.map((choice) => ({
text: { type: "plain_text", text: choice.label.slice(0, 75) },