mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
568b920b21
* refactor(imports): dedupe and hoist imports * feat(lint): enforce import/no-duplicates and import/first
216 lines
6.0 KiB
TypeScript
216 lines
6.0 KiB
TypeScript
/**
|
|
* Twitch resolver adapter for channel/user name resolution.
|
|
*
|
|
* This module implements the ChannelResolverAdapter interface to resolve
|
|
* Twitch usernames to user IDs via the Twitch Helix API.
|
|
*/
|
|
|
|
import {
|
|
callTwitchApi,
|
|
HttpStatusCodeError,
|
|
type TwitchApiCallFetchOptions,
|
|
} from "@twurple/api-call";
|
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
import { withTimeout } from "openclaw/plugin-sdk/text-utility-runtime";
|
|
import type {
|
|
ChannelResolveKind,
|
|
ChannelResolveResult,
|
|
ChannelLogSink,
|
|
TwitchAccountConfig,
|
|
} from "./types.js";
|
|
import { normalizeToken } from "./utils/twitch.js";
|
|
|
|
const TWITCH_HELIX_USER_LOOKUP_TIMEOUT_MS = 10_000;
|
|
|
|
type TwitchTokenInfo = {
|
|
user_id?: string;
|
|
};
|
|
|
|
type TwitchUser = {
|
|
id: string;
|
|
login: string;
|
|
display_name: string;
|
|
};
|
|
|
|
type TwitchUsersResponse = {
|
|
data: TwitchUser[];
|
|
};
|
|
|
|
/**
|
|
* Normalize a Twitch username - strip @ prefix and convert to lowercase
|
|
*/
|
|
function normalizeUsername(input: string): string {
|
|
const trimmed = input.trim();
|
|
if (trimmed.startsWith("@")) {
|
|
return normalizeLowercaseStringOrEmpty(trimmed.slice(1));
|
|
}
|
|
return normalizeLowercaseStringOrEmpty(trimmed);
|
|
}
|
|
|
|
/**
|
|
* Create a logger that includes the Twitch prefix
|
|
*/
|
|
function createLogger(logger?: ChannelLogSink): ChannelLogSink {
|
|
return {
|
|
info: (msg: string) => logger?.info(msg),
|
|
warn: (msg: string) => logger?.warn(msg),
|
|
error: (msg: string) => logger?.error(msg),
|
|
debug: (msg: string) => logger?.debug?.(msg) ?? (() => {}),
|
|
};
|
|
}
|
|
|
|
function createHelixUserResolver(clientId: string, accessToken: string) {
|
|
let tokenValidated = false;
|
|
|
|
return async (query: { id: string } | { login: string }): Promise<TwitchUser | null> => {
|
|
const controller = new AbortController();
|
|
// ApiClient retries AbortError past the deadline. This sequential startup
|
|
// resolver uses Twurple's public one-shot call so cancellation stays bounded.
|
|
const fetchOptions = { signal: controller.signal } as TwitchApiCallFetchOptions;
|
|
const request = (async () => {
|
|
if (!tokenValidated) {
|
|
let tokenInfo: TwitchTokenInfo;
|
|
try {
|
|
tokenInfo = await callTwitchApi<TwitchTokenInfo>(
|
|
{ type: "auth", url: "validate" },
|
|
clientId,
|
|
accessToken,
|
|
undefined,
|
|
fetchOptions,
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof HttpStatusCodeError && error.statusCode === 401) {
|
|
throw new Error("Invalid token supplied", { cause: error });
|
|
}
|
|
throw error;
|
|
}
|
|
if (!tokenInfo.user_id) {
|
|
throw new Error("Trying to use an app access token as a user access token");
|
|
}
|
|
tokenValidated = true;
|
|
}
|
|
|
|
const response = await callTwitchApi<TwitchUsersResponse>(
|
|
{ type: "helix", url: "users", query },
|
|
clientId,
|
|
accessToken,
|
|
undefined,
|
|
fetchOptions,
|
|
);
|
|
return response.data[0] ?? null;
|
|
})();
|
|
|
|
try {
|
|
return await withTimeout(
|
|
request,
|
|
TWITCH_HELIX_USER_LOOKUP_TIMEOUT_MS,
|
|
"Twitch Helix user lookup",
|
|
);
|
|
} finally {
|
|
controller.abort();
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve Twitch usernames to user IDs via the Helix API
|
|
*
|
|
* @param inputs - Array of usernames or user IDs to resolve
|
|
* @param account - Twitch account configuration with auth credentials
|
|
* @param kind - Type of target to resolve ("user" or "group")
|
|
* @param logger - Optional logger
|
|
* @returns Promise resolving to array of ChannelResolveResult
|
|
*/
|
|
export async function resolveTwitchTargets(
|
|
inputs: string[],
|
|
account: TwitchAccountConfig,
|
|
_kind: ChannelResolveKind,
|
|
logger?: ChannelLogSink,
|
|
): Promise<ChannelResolveResult[]> {
|
|
const log = createLogger(logger);
|
|
|
|
if (!account.clientId || !account.accessToken) {
|
|
log.error("Missing Twitch client ID or accessToken");
|
|
return inputs.map((input) => ({
|
|
input,
|
|
resolved: false,
|
|
note: "missing Twitch credentials",
|
|
}));
|
|
}
|
|
|
|
const normalizedToken = normalizeToken(account.accessToken);
|
|
|
|
const resolveHelixUser = createHelixUserResolver(account.clientId, normalizedToken);
|
|
|
|
const results: ChannelResolveResult[] = [];
|
|
|
|
for (const input of inputs) {
|
|
const normalized = normalizeUsername(input);
|
|
|
|
if (!normalized) {
|
|
results.push({
|
|
input,
|
|
resolved: false,
|
|
note: "empty input",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const looksLikeUserId = /^\d+$/.test(normalized);
|
|
|
|
try {
|
|
if (looksLikeUserId) {
|
|
const user = await resolveHelixUser({ id: normalized });
|
|
|
|
if (user) {
|
|
results.push({
|
|
input,
|
|
resolved: true,
|
|
id: user.id,
|
|
name: user.login,
|
|
});
|
|
log.debug?.(`Resolved user ID ${normalized} -> ${user.login}`);
|
|
} else {
|
|
results.push({
|
|
input,
|
|
resolved: false,
|
|
note: "user ID not found",
|
|
});
|
|
log.warn(`User ID ${normalized} not found`);
|
|
}
|
|
} else {
|
|
const user = await resolveHelixUser({ login: normalized });
|
|
|
|
if (user) {
|
|
results.push({
|
|
input,
|
|
resolved: true,
|
|
id: user.id,
|
|
name: user.login,
|
|
note: user.display_name !== user.login ? `display: ${user.display_name}` : undefined,
|
|
});
|
|
log.debug?.(`Resolved username ${normalized} -> ${user.id} (${user.login})`);
|
|
} else {
|
|
results.push({
|
|
input,
|
|
resolved: false,
|
|
note: "username not found",
|
|
});
|
|
log.warn(`Username ${normalized} not found`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const errorMessage = formatErrorMessage(error);
|
|
results.push({
|
|
input,
|
|
resolved: false,
|
|
note: `API error: ${errorMessage}`,
|
|
});
|
|
log.error(`Failed to resolve ${input}: ${errorMessage}`);
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|