mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
co-authored by
Peter Steinberger
parent
d79573d457
commit
a9c70f3956
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user