fix(twitch): time out Helix user resolution lookups (#105883)

* fix(twitch): time out Helix user resolution lookups

* test(twitch): satisfy resolver lint guards

* test(twitch): satisfy resolver account typing

* test(twitch): prove resolver recovery after timeout

* test(twitch): use explicit credential placeholder

* chore(changelog): defer Twitch note to release

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Alix-007
2026-07-14 02:56:39 -07:00
committed by GitHub
co-authored by Peter Steinberger
parent d79573d457
commit a9c70f3956
2 changed files with 137 additions and 2 deletions
+122
View File
@@ -0,0 +1,122 @@
// Twitch resolver tests cover Helix lookup behavior.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveTwitchTargets } from "./resolver.js";
import type { TwitchAccountConfig } from "./types.js";
type TwitchUser = {
id: string;
name: string;
displayName: string;
};
const getUserByIdMock = vi.hoisted(() => vi.fn());
const getUserByNameMock = vi.hoisted(() => vi.fn());
vi.mock("@twurple/api", () => ({
ApiClient: class {
users = {
getUserById: getUserByIdMock,
getUserByName: getUserByNameMock,
};
},
}));
vi.mock("@twurple/auth", () => ({
StaticAuthProvider: class {
readonly accessToken: string;
readonly clientId: string;
constructor(clientId: string, accessToken: string) {
this.accessToken = accessToken;
this.clientId = clientId;
}
},
}));
describe("resolveTwitchTargets", () => {
const account: TwitchAccountConfig = {
username: "testbot",
accessToken: "test-auth-token",
clientId: "test-client-id",
channel: "testchannel",
};
beforeEach(() => {
vi.clearAllMocks();
getUserByIdMock.mockResolvedValue(null);
getUserByNameMock.mockResolvedValue(null);
});
afterEach(() => {
vi.useRealTimers();
});
it.each([
{
name: "user ID",
input: "123456",
hangingLookup: getUserByIdMock,
expectedLookup: getUserByIdMock,
expectedLookupArg: "123456",
},
{
name: "username",
input: "@StalledUser",
hangingLookup: getUserByNameMock,
expectedLookup: getUserByNameMock,
expectedLookupArg: "stalleduser",
},
])("times out a pending Helix $name lookup as unresolved", async (testCase) => {
vi.useFakeTimers();
testCase.hangingLookup.mockReturnValueOnce(new Promise<TwitchUser | null>(() => {}));
const resultPromise = resolveTwitchTargets([testCase.input], account, "user");
await expect(Promise.race([resultPromise, Promise.resolve("pending")])).resolves.toBe(
"pending",
);
await vi.advanceTimersByTimeAsync(10_000);
await expect(resultPromise).resolves.toEqual([
{
input: testCase.input,
resolved: false,
note: expect.stringContaining("timed out"),
},
]);
expect(testCase.expectedLookup).toHaveBeenCalledWith(testCase.expectedLookupArg);
});
it("continues resolving later inputs after a lookup times out", async () => {
vi.useFakeTimers();
getUserByNameMock
.mockReturnValueOnce(new Promise<TwitchUser | null>(() => {}))
.mockResolvedValueOnce({
id: "healthy-id",
name: "healthyuser",
displayName: "HealthyUser",
});
const resultPromise = resolveTwitchTargets(["@stalleduser", "@healthyuser"], account, "user");
await vi.advanceTimersByTimeAsync(10_000);
await expect(resultPromise).resolves.toEqual([
{
input: "@stalleduser",
resolved: false,
note: expect.stringContaining("timed out"),
},
{
input: "@healthyuser",
resolved: true,
id: "healthy-id",
name: "healthyuser",
note: "display: HealthyUser",
},
]);
expect(getUserByNameMock).toHaveBeenNthCalledWith(1, "stalleduser");
expect(getUserByNameMock).toHaveBeenNthCalledWith(2, "healthyuser");
});
});
+15 -2
View File
@@ -9,10 +9,13 @@ import { ApiClient } from "@twurple/api";
import { StaticAuthProvider } from "@twurple/auth";
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 } from "./types.js";
import type { ChannelLogSink, TwitchAccountConfig } from "./types.js";
import { normalizeToken } from "./utils/twitch.js";
const TWITCH_HELIX_USER_LOOKUP_TIMEOUT_MS = 10_000;
/**
* Normalize a Twitch username - strip @ prefix and convert to lowercase
*/
@@ -36,6 +39,16 @@ function createLogger(logger?: ChannelLogSink): ChannelLogSink {
};
}
// Twurple Helix user lookups have no per-call signal; bound the await here so
// a stalled lookup returns unresolved instead of hanging channel resolution.
async function resolveHelixUser<T>(request: Promise<T>): Promise<T> {
return await withTimeout(
request,
TWITCH_HELIX_USER_LOOKUP_TIMEOUT_MS,
"Twitch Helix user lookup",
);
}
/**
* Resolve Twitch usernames to user IDs via the Helix API
*
@@ -85,7 +98,7 @@ export async function resolveTwitchTargets(
try {
if (looksLikeUserId) {
const user = await apiClient.users.getUserById(normalized);
const user = await resolveHelixUser(apiClient.users.getUserById(normalized));
if (user) {
results.push({
@@ -104,7 +117,7 @@ export async function resolveTwitchTargets(
log.warn(`User ID ${normalized} not found`);
}
} else {
const user = await apiClient.users.getUserByName(normalized);
const user = await resolveHelixUser(apiClient.users.getUserByName(normalized));
if (user) {
results.push({