@simonfestl/husky-cli 1.38.4 ā 1.38.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/agent-msg.js +9 -4
- package/dist/commands/biz/gotess.js +10 -4
- package/dist/commands/chat.js +33 -37
- package/dist/commands/config.d.ts +9 -6
- package/dist/commands/config.js +103 -127
- package/dist/commands/interactive/auth.js +7 -2
- package/dist/commands/research.d.ts +2 -0
- package/dist/commands/research.js +774 -0
- package/dist/commands/supervisor.js +0 -1
- package/dist/index.js +46 -1
- package/dist/lib/api-client.d.ts +1 -0
- package/dist/lib/api-client.js +16 -83
- package/dist/lib/biz/api-brain.js +6 -23
- package/dist/lib/biz/emove-playwright.js +0 -1
- package/dist/lib/biz/index.d.ts +2 -0
- package/dist/lib/biz/index.js +2 -0
- package/dist/lib/biz/reddit.d.ts +83 -0
- package/dist/lib/biz/reddit.js +168 -0
- package/dist/lib/biz/skuterzone-playwright.js +0 -1
- package/dist/lib/biz/x-client.d.ts +27 -0
- package/dist/lib/biz/x-client.js +123 -0
- package/dist/lib/biz/youtube-monitor.d.ts +72 -0
- package/dist/lib/biz/youtube-monitor.js +180 -0
- package/dist/lib/permissions-cache.js +9 -35
- package/package.json +1 -1
|
@@ -250,7 +250,6 @@ supervisorCommand
|
|
|
250
250
|
}
|
|
251
251
|
console.log("\nš Husky Supervisor Workflow Status");
|
|
252
252
|
console.log("=".repeat(50));
|
|
253
|
-
// Task status
|
|
254
253
|
console.log("\nš Task Backlog:");
|
|
255
254
|
const backlogTasks = tasks.tasks || [];
|
|
256
255
|
if (backlogTasks.length === 0) {
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import { createRequire } from "module";
|
|
4
4
|
import { taskCommand } from "./commands/task.js";
|
|
5
|
-
import { configCommand } from "./commands/config.js";
|
|
5
|
+
import { configCommand, ensureValidSession, getAuthHeaders, getConfig } from "./commands/config.js";
|
|
6
6
|
import { agentCommand } from "./commands/agent.js";
|
|
7
7
|
import { roadmapCommand } from "./commands/roadmap.js";
|
|
8
8
|
import { changelogCommand } from "./commands/changelog.js";
|
|
@@ -33,6 +33,7 @@ import { infraCommand } from "./commands/infra.js";
|
|
|
33
33
|
import { e2eCommand } from "./commands/e2e.js";
|
|
34
34
|
import { prCommand } from "./commands/pr.js";
|
|
35
35
|
import { youtubeCommand } from "./commands/youtube.js";
|
|
36
|
+
import { researchCommand } from "./commands/research.js";
|
|
36
37
|
import { imageCommand } from "./commands/image.js";
|
|
37
38
|
import { authCommand } from "./commands/auth.js";
|
|
38
39
|
import { businessCommand } from "./commands/business.js";
|
|
@@ -45,6 +46,49 @@ import { checkVersion } from "./lib/version-check.js";
|
|
|
45
46
|
// Read version from package.json
|
|
46
47
|
const require = createRequire(import.meta.url);
|
|
47
48
|
const packageJson = require("../package.json");
|
|
49
|
+
const originalFetch = globalThis.fetch.bind(globalThis);
|
|
50
|
+
function isHuskyApiRequest(requestUrl) {
|
|
51
|
+
try {
|
|
52
|
+
const config = getConfig();
|
|
53
|
+
if (!config.apiUrl)
|
|
54
|
+
return false;
|
|
55
|
+
const apiBase = new URL(config.apiUrl);
|
|
56
|
+
const basePath = apiBase.pathname.replace(/\/$/, "");
|
|
57
|
+
const apiPrefix = `${basePath}/api/`.replace(/\/{2,}/g, "/");
|
|
58
|
+
return requestUrl.origin === apiBase.origin && requestUrl.pathname.startsWith(apiPrefix);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function isAuthBypassRequest(request) {
|
|
65
|
+
if (request.method.toUpperCase() !== "POST")
|
|
66
|
+
return false;
|
|
67
|
+
const pathname = new URL(request.url).pathname;
|
|
68
|
+
return pathname.endsWith("/api/auth/session") || pathname.endsWith("/api/auth/refresh");
|
|
69
|
+
}
|
|
70
|
+
globalThis.fetch = async (input, init) => {
|
|
71
|
+
const request = new Request(input, init);
|
|
72
|
+
const requestUrl = new URL(request.url);
|
|
73
|
+
if (!isHuskyApiRequest(requestUrl) || isAuthBypassRequest(request)) {
|
|
74
|
+
return originalFetch(request);
|
|
75
|
+
}
|
|
76
|
+
const headers = new Headers(request.headers);
|
|
77
|
+
headers.delete("x-api-key");
|
|
78
|
+
// Always ensure the stored session is valid (refresh if needed), and
|
|
79
|
+
// always use the current token for Husky API requests.
|
|
80
|
+
const ok = await ensureValidSession();
|
|
81
|
+
if (!ok) {
|
|
82
|
+
throw new Error("No active session. Run: husky auth login --agent <name>");
|
|
83
|
+
}
|
|
84
|
+
const authHeaders = getAuthHeaders();
|
|
85
|
+
const authToken = authHeaders.Authorization;
|
|
86
|
+
if (!authToken) {
|
|
87
|
+
throw new Error("No active session. Run: husky auth login --agent <name>");
|
|
88
|
+
}
|
|
89
|
+
headers.set("Authorization", authToken);
|
|
90
|
+
return originalFetch(new Request(request, { headers }));
|
|
91
|
+
};
|
|
48
92
|
const program = new Command();
|
|
49
93
|
program
|
|
50
94
|
.name("husky")
|
|
@@ -82,6 +126,7 @@ program.addCommand(infraCommand);
|
|
|
82
126
|
program.addCommand(e2eCommand);
|
|
83
127
|
program.addCommand(prCommand);
|
|
84
128
|
program.addCommand(youtubeCommand);
|
|
129
|
+
program.addCommand(researchCommand);
|
|
85
130
|
program.addCommand(imageCommand);
|
|
86
131
|
program.addCommand(authCommand);
|
|
87
132
|
program.addCommand(businessCommand);
|
package/dist/lib/api-client.d.ts
CHANGED
package/dist/lib/api-client.js
CHANGED
|
@@ -1,56 +1,10 @@
|
|
|
1
|
-
import { getConfig, getSessionConfig,
|
|
2
|
-
const REFRESH_THRESHOLD_MS = 5 * 60 * 1000;
|
|
3
|
-
let refreshInProgress = null;
|
|
1
|
+
import { getConfig, getSessionConfig, clearSessionConfig, ensureValidSession } from "../commands/config.js";
|
|
4
2
|
function isSessionExpired(expiresAt) {
|
|
5
3
|
if (!expiresAt)
|
|
6
4
|
return true;
|
|
7
5
|
return new Date(expiresAt).getTime() < Date.now();
|
|
8
6
|
}
|
|
9
|
-
function
|
|
10
|
-
if (!expiresAt)
|
|
11
|
-
return true;
|
|
12
|
-
return new Date(expiresAt).getTime() - Date.now() < REFRESH_THRESHOLD_MS;
|
|
13
|
-
}
|
|
14
|
-
async function doRefresh(agentName) {
|
|
15
|
-
const config = getConfig();
|
|
16
|
-
if (!config.apiUrl || !config.apiKey)
|
|
17
|
-
return null;
|
|
18
|
-
try {
|
|
19
|
-
const url = new URL("/api/auth/session", config.apiUrl);
|
|
20
|
-
const res = await fetch(url.toString(), {
|
|
21
|
-
method: "POST",
|
|
22
|
-
headers: {
|
|
23
|
-
"x-api-key": config.apiKey,
|
|
24
|
-
"Content-Type": "application/json",
|
|
25
|
-
},
|
|
26
|
-
body: JSON.stringify({ agent: agentName }),
|
|
27
|
-
});
|
|
28
|
-
if (!res.ok)
|
|
29
|
-
return null;
|
|
30
|
-
const session = await res.json();
|
|
31
|
-
// Extract agent id for storage (API returns object with id, name, emoji)
|
|
32
|
-
setSessionConfig({
|
|
33
|
-
token: session.token,
|
|
34
|
-
expiresAt: session.expiresAt,
|
|
35
|
-
role: session.role,
|
|
36
|
-
agent: session.agent.id,
|
|
37
|
-
});
|
|
38
|
-
return session;
|
|
39
|
-
}
|
|
40
|
-
catch {
|
|
41
|
-
return null;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
async function refreshSession(agentName) {
|
|
45
|
-
if (refreshInProgress) {
|
|
46
|
-
return refreshInProgress;
|
|
47
|
-
}
|
|
48
|
-
refreshInProgress = doRefresh(agentName).finally(() => {
|
|
49
|
-
refreshInProgress = null;
|
|
50
|
-
});
|
|
51
|
-
return refreshInProgress;
|
|
52
|
-
}
|
|
53
|
-
async function doFetch(url, method, authHeader, body) {
|
|
7
|
+
async function doFetch(url, method, authHeader, body, signal) {
|
|
54
8
|
return fetch(url.toString(), {
|
|
55
9
|
method,
|
|
56
10
|
headers: {
|
|
@@ -58,61 +12,40 @@ async function doFetch(url, method, authHeader, body) {
|
|
|
58
12
|
"Content-Type": "application/json",
|
|
59
13
|
},
|
|
60
14
|
body: body ? JSON.stringify(body) : undefined,
|
|
15
|
+
signal,
|
|
61
16
|
});
|
|
62
17
|
}
|
|
63
|
-
|
|
64
|
-
// DEBUG: console.log('DEBUG session:', session?.token ? 'exists' : 'missing', session?.expiresAt);
|
|
18
|
+
function getAuthHeader(session) {
|
|
65
19
|
if (session?.token && session.expiresAt) {
|
|
66
|
-
// DEBUG: console.log('DEBUG: Checking expiration...');
|
|
67
20
|
if (isSessionExpired(session.expiresAt)) {
|
|
68
|
-
// DEBUG: console.log('DEBUG: Session expired, refreshing...');
|
|
69
|
-
if (session.agent) {
|
|
70
|
-
const newSession = await refreshSession(session.agent);
|
|
71
|
-
if (newSession) {
|
|
72
|
-
// DEBUG: console.log('DEBUG: Refresh successful, using Bearer token');
|
|
73
|
-
return { "Authorization": `Bearer ${newSession.token}` };
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
21
|
clearSessionConfig();
|
|
77
|
-
|
|
78
|
-
// DEBUG: console.log('DEBUG: Refresh failed, falling back to API key');
|
|
79
|
-
return { "x-api-key": apiKey };
|
|
80
|
-
}
|
|
81
|
-
throw new Error("Session expired and no API key available for refresh");
|
|
22
|
+
throw new Error("Session expired. Run: husky auth login --agent <name>");
|
|
82
23
|
}
|
|
83
|
-
if (isSessionExpiringSoon(session.expiresAt) && session.agent) {
|
|
84
|
-
refreshSession(session.agent).catch(() => { });
|
|
85
|
-
}
|
|
86
|
-
// DEBUG: console.log('DEBUG: Using existing Bearer token');
|
|
87
24
|
return { "Authorization": `Bearer ${session.token}` };
|
|
88
25
|
}
|
|
89
|
-
|
|
90
|
-
// DEBUG: console.log('DEBUG: No session, using API key');
|
|
91
|
-
return { "x-api-key": apiKey };
|
|
92
|
-
}
|
|
93
|
-
throw new Error("No authentication configured. Run: husky auth login --agent <name> or husky config set api-key <key>");
|
|
26
|
+
throw new Error("No active session. Run: husky auth login --agent <name>");
|
|
94
27
|
}
|
|
95
28
|
export async function apiRequest(path, options = {}) {
|
|
96
29
|
const config = getConfig();
|
|
97
30
|
if (!config.apiUrl) {
|
|
98
31
|
throw new Error("API URL not configured. Run: husky config set api-url <url>");
|
|
99
32
|
}
|
|
33
|
+
if (!options.skipAuth) {
|
|
34
|
+
const ok = await ensureValidSession();
|
|
35
|
+
if (!ok) {
|
|
36
|
+
throw new Error("No active session. Run: husky auth login --agent <name>");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
100
39
|
const session = getSessionConfig();
|
|
101
40
|
const method = options.method || "GET";
|
|
102
41
|
const url = new URL(path, config.apiUrl);
|
|
103
42
|
const authHeader = options.skipAuth
|
|
104
43
|
? {}
|
|
105
|
-
:
|
|
106
|
-
const res = await doFetch(url, method, authHeader, options.body);
|
|
44
|
+
: getAuthHeader(session);
|
|
45
|
+
const res = await doFetch(url, method, authHeader, options.body, options.signal);
|
|
107
46
|
if (!res.ok) {
|
|
108
|
-
if (res.status === 401
|
|
109
|
-
|
|
110
|
-
if (newSession) {
|
|
111
|
-
const retryRes = await doFetch(url, method, { "Authorization": `Bearer ${newSession.token}` }, options.body);
|
|
112
|
-
if (retryRes.ok) {
|
|
113
|
-
return retryRes.json();
|
|
114
|
-
}
|
|
115
|
-
}
|
|
47
|
+
if (res.status === 401) {
|
|
48
|
+
clearSessionConfig();
|
|
116
49
|
}
|
|
117
50
|
const error = await res.json().catch(() => ({ error: res.statusText }));
|
|
118
51
|
throw new Error(error.message || error.error || `HTTP ${res.status}`);
|
|
@@ -1,31 +1,18 @@
|
|
|
1
|
-
import { getConfig } from "../../commands/config.js";
|
|
1
|
+
import { getAuthHeaders, getConfig } from "../../commands/config.js";
|
|
2
2
|
import { canAccessKnowledgeBase as checkKbAccess } from "../permissions-cache.js";
|
|
3
3
|
async function apiRequest(path, options = {}) {
|
|
4
4
|
const config = getConfig();
|
|
5
5
|
if (!config.apiUrl) {
|
|
6
6
|
throw new Error("API not configured. Run: husky config set api-url <url>");
|
|
7
7
|
}
|
|
8
|
-
|
|
8
|
+
const authHeaders = getAuthHeaders();
|
|
9
|
+
if (!authHeaders.Authorization) {
|
|
10
|
+
throw new Error("Not authenticated. Run: husky auth login --agent <name>");
|
|
11
|
+
}
|
|
9
12
|
const headers = {
|
|
10
13
|
"Content-Type": "application/json",
|
|
14
|
+
...authHeaders,
|
|
11
15
|
};
|
|
12
|
-
if (config.sessionToken) {
|
|
13
|
-
// Check if session is expired
|
|
14
|
-
if (config.sessionExpiresAt) {
|
|
15
|
-
const expiresAt = new Date(config.sessionExpiresAt);
|
|
16
|
-
if (expiresAt < new Date()) {
|
|
17
|
-
throw new Error("Session expired. Run: husky auth login --agent <name>");
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
headers["Authorization"] = `Bearer ${config.sessionToken}`;
|
|
21
|
-
}
|
|
22
|
-
else if (config.apiKey) {
|
|
23
|
-
// Legacy fallback - will be rejected by new API but kept for error message
|
|
24
|
-
headers["x-api-key"] = config.apiKey;
|
|
25
|
-
}
|
|
26
|
-
else {
|
|
27
|
-
throw new Error("Not authenticated. Run: husky auth login --agent <name>");
|
|
28
|
-
}
|
|
29
16
|
const url = new URL(`/api/brain${path}`, config.apiUrl);
|
|
30
17
|
const res = await fetch(url.toString(), {
|
|
31
18
|
method: options.method || "POST",
|
|
@@ -35,10 +22,6 @@ async function apiRequest(path, options = {}) {
|
|
|
35
22
|
if (!res.ok) {
|
|
36
23
|
const error = await res.json().catch(() => ({ error: res.statusText }));
|
|
37
24
|
if (res.status === 401) {
|
|
38
|
-
// Check if it's the new API rejecting API key auth
|
|
39
|
-
if (error.upgrade) {
|
|
40
|
-
throw new Error("Session required. Run: husky auth login --agent <name>");
|
|
41
|
-
}
|
|
42
25
|
throw new Error(error.message || "Authentication failed");
|
|
43
26
|
}
|
|
44
27
|
if (res.status === 403) {
|
|
@@ -173,7 +173,6 @@ export class EmovePlaywrightClient {
|
|
|
173
173
|
catch (error) {
|
|
174
174
|
// Customer details not available
|
|
175
175
|
}
|
|
176
|
-
// Extract line items
|
|
177
176
|
const items = [];
|
|
178
177
|
const itemRows = page.locator('table.woocommerce-table--order-details tbody tr.woocommerce-table__line-item');
|
|
179
178
|
const itemCount = await itemRows.count();
|
package/dist/lib/biz/index.d.ts
CHANGED
|
@@ -18,3 +18,5 @@ export type { Order as ShopifyOrder, Customer as ShopifyCustomer, Product as Sho
|
|
|
18
18
|
export { SupplierFeedService } from './supplier-feed.js';
|
|
19
19
|
export type { SupplierId, SupplierProduct, SupplierProductSearchResult, SyncStats, } from './supplier-feed.js';
|
|
20
20
|
export * from './supplier-feed-types.js';
|
|
21
|
+
export { RedditClient } from './reddit.js';
|
|
22
|
+
export { YouTubeMonitorClient } from './youtube-monitor.js';
|
package/dist/lib/biz/index.js
CHANGED
|
@@ -14,3 +14,5 @@ export { GotessClient } from './gotess.js';
|
|
|
14
14
|
export { ShopifyClient } from './shopify.js';
|
|
15
15
|
export { SupplierFeedService } from './supplier-feed.js';
|
|
16
16
|
export * from './supplier-feed-types.js';
|
|
17
|
+
export { RedditClient } from './reddit.js';
|
|
18
|
+
export { YouTubeMonitorClient } from './youtube-monitor.js';
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export interface RedditConfig {
|
|
2
|
+
clientId: string;
|
|
3
|
+
clientSecret: string;
|
|
4
|
+
userAgent: string;
|
|
5
|
+
}
|
|
6
|
+
export interface RedditApiPost {
|
|
7
|
+
id: string;
|
|
8
|
+
title: string;
|
|
9
|
+
selftext: string;
|
|
10
|
+
url: string;
|
|
11
|
+
author: string;
|
|
12
|
+
subreddit: string;
|
|
13
|
+
score: number;
|
|
14
|
+
upvote_ratio: number;
|
|
15
|
+
num_comments: number;
|
|
16
|
+
created_utc: number;
|
|
17
|
+
permalink: string;
|
|
18
|
+
is_self: boolean;
|
|
19
|
+
thumbnail?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface RedditPost {
|
|
22
|
+
id: string;
|
|
23
|
+
title: string;
|
|
24
|
+
selftext: string;
|
|
25
|
+
url: string;
|
|
26
|
+
author: string;
|
|
27
|
+
subreddit: string;
|
|
28
|
+
score: number;
|
|
29
|
+
upvoteRatio: number;
|
|
30
|
+
numComments: number;
|
|
31
|
+
createdUtc: number;
|
|
32
|
+
permalink: string;
|
|
33
|
+
isSelf: boolean;
|
|
34
|
+
thumbnail?: string;
|
|
35
|
+
}
|
|
36
|
+
export interface RedditTokenResponse {
|
|
37
|
+
access_token: string;
|
|
38
|
+
token_type: string;
|
|
39
|
+
expires_in: number;
|
|
40
|
+
scope: string;
|
|
41
|
+
}
|
|
42
|
+
export interface RedditComment {
|
|
43
|
+
id: string;
|
|
44
|
+
author: string;
|
|
45
|
+
body: string;
|
|
46
|
+
score: number;
|
|
47
|
+
created_utc: number;
|
|
48
|
+
replies?: {
|
|
49
|
+
data: {
|
|
50
|
+
children: Array<{
|
|
51
|
+
data: RedditComment;
|
|
52
|
+
}>;
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export declare class RedditClient {
|
|
57
|
+
private config;
|
|
58
|
+
private accessToken?;
|
|
59
|
+
private tokenExpiry?;
|
|
60
|
+
private baseUrl;
|
|
61
|
+
private static AUTH_URL;
|
|
62
|
+
private static TOKEN_REFRESH_BUFFER_MS;
|
|
63
|
+
constructor(config: RedditConfig);
|
|
64
|
+
static fromConfig(): RedditClient;
|
|
65
|
+
private ensureAuthenticated;
|
|
66
|
+
private makeRequest;
|
|
67
|
+
getSubredditPosts(subreddit: string, options?: {
|
|
68
|
+
sort?: 'hot' | 'new' | 'top' | 'rising';
|
|
69
|
+
time?: 'hour' | 'day' | 'week' | 'month' | 'year' | 'all';
|
|
70
|
+
limit?: number;
|
|
71
|
+
after?: string;
|
|
72
|
+
}): Promise<{
|
|
73
|
+
posts: RedditPost[];
|
|
74
|
+
after: string | null;
|
|
75
|
+
}>;
|
|
76
|
+
searchSubreddit(subreddit: string, query: string, options?: {
|
|
77
|
+
sort?: 'relevance' | 'hot' | 'top' | 'new' | 'comments';
|
|
78
|
+
time?: 'hour' | 'day' | 'week' | 'month' | 'year' | 'all';
|
|
79
|
+
limit?: number;
|
|
80
|
+
}): Promise<RedditPost[]>;
|
|
81
|
+
getPostComments(postId: string, subreddit: string): Promise<RedditComment[]>;
|
|
82
|
+
}
|
|
83
|
+
export declare function isRedditConfigured(): boolean;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { getConfig } from "../../commands/config.js";
|
|
2
|
+
function mapRedditPost(raw) {
|
|
3
|
+
return {
|
|
4
|
+
id: raw.id,
|
|
5
|
+
title: raw.title,
|
|
6
|
+
selftext: raw.selftext,
|
|
7
|
+
url: raw.url,
|
|
8
|
+
author: raw.author,
|
|
9
|
+
subreddit: raw.subreddit,
|
|
10
|
+
score: raw.score,
|
|
11
|
+
upvoteRatio: raw.upvote_ratio,
|
|
12
|
+
numComments: raw.num_comments,
|
|
13
|
+
createdUtc: raw.created_utc,
|
|
14
|
+
permalink: raw.permalink,
|
|
15
|
+
isSelf: raw.is_self,
|
|
16
|
+
thumbnail: raw.thumbnail,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export class RedditClient {
|
|
20
|
+
config;
|
|
21
|
+
accessToken;
|
|
22
|
+
tokenExpiry;
|
|
23
|
+
baseUrl = 'https://oauth.reddit.com';
|
|
24
|
+
static AUTH_URL = 'https://www.reddit.com/api/v1/access_token';
|
|
25
|
+
static TOKEN_REFRESH_BUFFER_MS = 60_000;
|
|
26
|
+
constructor(config) {
|
|
27
|
+
this.config = config;
|
|
28
|
+
}
|
|
29
|
+
static fromConfig() {
|
|
30
|
+
const config = getConfig();
|
|
31
|
+
const env = process.env.HUSKY_ENV || 'PROD';
|
|
32
|
+
const clientId = process.env[`${env}_REDDIT_CLIENT_ID`] || process.env.REDDIT_CLIENT_ID || config.redditClientId;
|
|
33
|
+
const clientSecret = process.env[`${env}_REDDIT_CLIENT_SECRET`] || process.env.REDDIT_CLIENT_SECRET || config.redditClientSecret;
|
|
34
|
+
if (!clientId || !clientSecret) {
|
|
35
|
+
throw new Error("Reddit credentials not configured. Please set reddit-client-id and reddit-client-secret.");
|
|
36
|
+
}
|
|
37
|
+
const clientConfig = {
|
|
38
|
+
clientId,
|
|
39
|
+
clientSecret,
|
|
40
|
+
userAgent: process.env.REDDIT_USER_AGENT || 'HuskyResearchBot/1.0 (by /u/husky-bot)',
|
|
41
|
+
};
|
|
42
|
+
return new RedditClient(clientConfig);
|
|
43
|
+
}
|
|
44
|
+
async ensureAuthenticated() {
|
|
45
|
+
if (this.accessToken && this.tokenExpiry && Date.now() < this.tokenExpiry) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const authString = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString('base64');
|
|
49
|
+
const timeout = 15000;
|
|
50
|
+
const controller = new AbortController();
|
|
51
|
+
const id = setTimeout(() => controller.abort(), timeout);
|
|
52
|
+
try {
|
|
53
|
+
const response = await fetch(RedditClient.AUTH_URL, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: {
|
|
56
|
+
'Authorization': `Basic ${authString}`,
|
|
57
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
58
|
+
'User-Agent': this.config.userAgent,
|
|
59
|
+
},
|
|
60
|
+
body: new URLSearchParams({
|
|
61
|
+
grant_type: 'client_credentials',
|
|
62
|
+
}),
|
|
63
|
+
signal: controller.signal,
|
|
64
|
+
});
|
|
65
|
+
clearTimeout(id);
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
const errorText = await response.text();
|
|
68
|
+
throw new Error(`Reddit auth failed: ${response.status} ${response.statusText} - ${errorText.substring(0, 500)}`);
|
|
69
|
+
}
|
|
70
|
+
const data = await response.json();
|
|
71
|
+
if (!data.access_token) {
|
|
72
|
+
throw new Error("Reddit auth response missing access_token");
|
|
73
|
+
}
|
|
74
|
+
this.accessToken = data.access_token;
|
|
75
|
+
this.tokenExpiry = Date.now() + (data.expires_in * 1000) - RedditClient.TOKEN_REFRESH_BUFFER_MS;
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
clearTimeout(id);
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async makeRequest(endpoint, params, retryCount = 0) {
|
|
83
|
+
await this.ensureAuthenticated();
|
|
84
|
+
const url = new URL(`${this.baseUrl}${endpoint}`);
|
|
85
|
+
if (params) {
|
|
86
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
87
|
+
url.searchParams.append(key, value);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const timeout = 15000;
|
|
91
|
+
const controller = new AbortController();
|
|
92
|
+
const id = setTimeout(() => controller.abort(), timeout);
|
|
93
|
+
try {
|
|
94
|
+
const response = await fetch(url.toString(), {
|
|
95
|
+
headers: {
|
|
96
|
+
'Authorization': `Bearer ${this.accessToken}`,
|
|
97
|
+
'User-Agent': this.config.userAgent,
|
|
98
|
+
},
|
|
99
|
+
signal: controller.signal,
|
|
100
|
+
});
|
|
101
|
+
clearTimeout(id);
|
|
102
|
+
if (response.status === 429 && retryCount < 2) {
|
|
103
|
+
const retryAfter = parseInt(response.headers.get('retry-after') || '5', 10);
|
|
104
|
+
await new Promise(resolve => setTimeout(resolve, (retryAfter + 1) * 1000));
|
|
105
|
+
return this.makeRequest(endpoint, params, retryCount + 1);
|
|
106
|
+
}
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
const errorText = await response.text();
|
|
109
|
+
throw new Error(`Reddit API error: ${response.status} ${response.statusText} - ${errorText.substring(0, 500)}`);
|
|
110
|
+
}
|
|
111
|
+
return response.json();
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
clearTimeout(id);
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async getSubredditPosts(subreddit, options = {}) {
|
|
119
|
+
const { sort = 'hot', time = 'day', limit = 25, after } = options;
|
|
120
|
+
const params = {
|
|
121
|
+
limit: Math.min(limit, 100).toString(),
|
|
122
|
+
};
|
|
123
|
+
if (time && (sort === 'top' || sort === 'new')) { // Reddit also accepts 't' for some other sorts
|
|
124
|
+
params.t = time;
|
|
125
|
+
}
|
|
126
|
+
if (after) {
|
|
127
|
+
params.after = after;
|
|
128
|
+
}
|
|
129
|
+
const safeSubreddit = encodeURIComponent(subreddit);
|
|
130
|
+
const data = await this.makeRequest(`/r/${safeSubreddit}/${sort}`, params);
|
|
131
|
+
return {
|
|
132
|
+
posts: data.data.children.filter(child => child.kind === 't3').map(child => mapRedditPost(child.data)),
|
|
133
|
+
after: data.data.after,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
async searchSubreddit(subreddit, query, options = {}) {
|
|
137
|
+
const { sort = 'relevance', time = 'week', limit = 25 } = options;
|
|
138
|
+
const params = {
|
|
139
|
+
q: query,
|
|
140
|
+
restrict_sr: 'true',
|
|
141
|
+
sort,
|
|
142
|
+
t: time,
|
|
143
|
+
limit: Math.min(limit, 100).toString(),
|
|
144
|
+
};
|
|
145
|
+
const safeSubreddit = encodeURIComponent(subreddit);
|
|
146
|
+
const data = await this.makeRequest(`/r/${safeSubreddit}/search`, params);
|
|
147
|
+
return data.data.children.filter(child => child.kind === 't3').map(child => mapRedditPost(child.data));
|
|
148
|
+
}
|
|
149
|
+
async getPostComments(postId, subreddit) {
|
|
150
|
+
const safeSubreddit = encodeURIComponent(subreddit);
|
|
151
|
+
const safePostId = encodeURIComponent(postId);
|
|
152
|
+
const data = await this.makeRequest(`/r/${safeSubreddit}/comments/${safePostId}`);
|
|
153
|
+
if (Array.isArray(data) && data.length > 1) {
|
|
154
|
+
const commentsData = data[1];
|
|
155
|
+
return commentsData.data.children
|
|
156
|
+
.filter(child => child.kind === 't1')
|
|
157
|
+
.map(child => child.data);
|
|
158
|
+
}
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
export function isRedditConfigured() {
|
|
163
|
+
const config = getConfig();
|
|
164
|
+
const env = process.env.HUSKY_ENV || 'PROD';
|
|
165
|
+
const clientId = process.env[`${env}_REDDIT_CLIENT_ID`] || process.env.REDDIT_CLIENT_ID || config.redditClientId;
|
|
166
|
+
const clientSecret = process.env[`${env}_REDDIT_CLIENT_SECRET`] || process.env.REDDIT_CLIENT_SECRET || config.redditClientSecret;
|
|
167
|
+
return !!(clientId && clientSecret);
|
|
168
|
+
}
|
|
@@ -153,7 +153,6 @@ export class SkuterzonePlaywrightClient {
|
|
|
153
153
|
catch (error) {
|
|
154
154
|
// Customer details not available
|
|
155
155
|
}
|
|
156
|
-
// Extract line items
|
|
157
156
|
const items = [];
|
|
158
157
|
const itemRows = page.locator('table.woocommerce-table--order-details tbody tr.woocommerce-table__line-item');
|
|
159
158
|
const itemCount = await itemRows.count();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface XConfig {
|
|
2
|
+
bearerToken: string;
|
|
3
|
+
}
|
|
4
|
+
export interface XPost {
|
|
5
|
+
id: string;
|
|
6
|
+
text: string;
|
|
7
|
+
authorId: string;
|
|
8
|
+
username: string;
|
|
9
|
+
createdAt: string;
|
|
10
|
+
publicMetrics: {
|
|
11
|
+
retweetCount: number;
|
|
12
|
+
replyCount: number;
|
|
13
|
+
likeCount: number;
|
|
14
|
+
quoteCount: number;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export declare class XClient {
|
|
18
|
+
private config;
|
|
19
|
+
private baseUrl;
|
|
20
|
+
private userIdCache;
|
|
21
|
+
constructor(config: XConfig);
|
|
22
|
+
static fromConfig(): XClient | null;
|
|
23
|
+
private makeRequest;
|
|
24
|
+
searchRecentTweets(query: string, maxResults?: number): Promise<XPost[]>;
|
|
25
|
+
getUserTweets(username: string, maxResults?: number): Promise<XPost[]>;
|
|
26
|
+
}
|
|
27
|
+
export declare function isXConfigured(): boolean;
|