@manaobot/kick 1.0.0
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/README.md +163 -0
- package/biome.json +44 -0
- package/bun.lock +78 -0
- package/example/01-authorize-bot/.env.example +7 -0
- package/example/01-authorize-bot/index.ts +60 -0
- package/example/02-webhook/.env.example +7 -0
- package/example/02-webhook/index.ts +80 -0
- package/example/03-ngrok/.env.example +7 -0
- package/example/03-ngrok/index.ts +92 -0
- package/example/04-categories-api/.env.example +7 -0
- package/example/04-categories-api/index.ts +77 -0
- package/example/05-users-api/.env.example +7 -0
- package/example/05-users-api/index.ts +60 -0
- package/example/06-channels-api/.env.example +7 -0
- package/example/06-channels-api/index.ts +60 -0
- package/example/07-channel-rewards-api/.env.example +7 -0
- package/example/07-channel-rewards-api/index.ts +60 -0
- package/example/08-basic-chat-bot/.env.example +7 -0
- package/example/08-basic-chat-bot/index.ts +102 -0
- package/package.json +23 -0
- package/qodana.yaml +31 -0
- package/src/KickClient.ts +172 -0
- package/src/Logger.ts +25 -0
- package/src/api/CategoriesAPI.ts +45 -0
- package/src/api/ChannelRewardsAPI.ts +121 -0
- package/src/api/ChannelsAPI.ts +63 -0
- package/src/api/KicksAPI.ts +37 -0
- package/src/api/LivestreamsAPI.ts +65 -0
- package/src/api/ModerationAPI.ts +60 -0
- package/src/api/UsersAPI.ts +72 -0
- package/src/auth/AuthManager.ts +64 -0
- package/src/auth/CallbackServer.ts +57 -0
- package/src/auth/OAuth.ts +55 -0
- package/src/auth/PKCE.ts +13 -0
- package/src/auth/TokenManager.ts +53 -0
- package/src/chat/ChatClient.ts +48 -0
- package/src/rest/RestClient.ts +39 -0
- package/src/webhooks/NgrokAdapter.ts +46 -0
- package/src/webhooks/WebhookRouter.ts +135 -0
- package/src/webhooks/WebhookServer.ts +41 -0
- package/tsconfig.json +29 -0
- package/types/api.d.ts +158 -0
- package/types/auth.d.ts +38 -0
- package/types/chat.d.ts +14 -0
- package/types/client.d.ts +67 -0
- package/types/index.d.ts +4 -0
- package/types/webhooks.d.ts +35 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { RestClient } from "../rest/RestClient";
|
|
2
|
+
import type {
|
|
3
|
+
KickChannelReward,
|
|
4
|
+
ChannelRewardBase,
|
|
5
|
+
GetChannelRewardsResponse,
|
|
6
|
+
GetRedemptionsParams,
|
|
7
|
+
GetRedemptionsResponse,
|
|
8
|
+
RedemptionActionResponse,
|
|
9
|
+
} from "../../types/api";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* ChannelRewardsAPI provides access to Kick Channel Rewards APIs.
|
|
13
|
+
*
|
|
14
|
+
* Requires scopes:
|
|
15
|
+
* - channel:rewards:read
|
|
16
|
+
* - channel:rewards:write
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* const rewards = await kick.api.channelRewards.get();
|
|
20
|
+
*/
|
|
21
|
+
export class ChannelRewardsAPI {
|
|
22
|
+
constructor(private readonly rest: RestClient) {}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get all channel rewards for the authorized broadcaster.
|
|
26
|
+
*/
|
|
27
|
+
async get(): Promise<GetChannelRewardsResponse> {
|
|
28
|
+
return this.rest.fetch("/public/v1/channels/rewards", {
|
|
29
|
+
method: "GET",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Create a new channel reward.
|
|
35
|
+
*/
|
|
36
|
+
async create(
|
|
37
|
+
body: ChannelRewardBase,
|
|
38
|
+
): Promise<{ data: KickChannelReward; message: string }> {
|
|
39
|
+
return this.rest.fetch("/public/v1/channels/rewards", {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: {
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
},
|
|
44
|
+
body: JSON.stringify(body),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Update an existing reward.
|
|
50
|
+
*
|
|
51
|
+
* Only the creating app can update a reward.
|
|
52
|
+
*/
|
|
53
|
+
async update(
|
|
54
|
+
id: string,
|
|
55
|
+
body: Partial<ChannelRewardBase>,
|
|
56
|
+
): Promise<{ data: KickChannelReward; message: string }> {
|
|
57
|
+
return this.rest.fetch(`/public/v1/channels/rewards/${id}`, {
|
|
58
|
+
method: "PATCH",
|
|
59
|
+
headers: {
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
},
|
|
62
|
+
body: JSON.stringify(body),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Delete a channel reward.
|
|
68
|
+
*/
|
|
69
|
+
async delete(id: string): Promise<void> {
|
|
70
|
+
await this.rest.fetch(`/public/v1/channels/rewards/${id}`, {
|
|
71
|
+
method: "DELETE",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Retrieve reward redemptions.
|
|
77
|
+
*/
|
|
78
|
+
async getRedemptions(
|
|
79
|
+
params: GetRedemptionsParams = {},
|
|
80
|
+
): Promise<GetRedemptionsResponse> {
|
|
81
|
+
const search = new URLSearchParams();
|
|
82
|
+
|
|
83
|
+
if (params.reward_id) search.append("reward_id", params.reward_id);
|
|
84
|
+
if (params.status) search.append("status", params.status);
|
|
85
|
+
if (params.cursor) search.append("cursor", params.cursor);
|
|
86
|
+
params.id?.forEach((v) => search.append("id", v));
|
|
87
|
+
|
|
88
|
+
const query = search.toString();
|
|
89
|
+
|
|
90
|
+
return this.rest.fetch(
|
|
91
|
+
`/public/v1/channels/rewards/redemptions${query ? `?${query}` : ""}`,
|
|
92
|
+
{ method: "GET" },
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Accept redemptions (max 25).
|
|
98
|
+
*/
|
|
99
|
+
async accept(ids: string[]): Promise<RedemptionActionResponse> {
|
|
100
|
+
return this.rest.fetch("/public/v1/channels/rewards/redemptions/accept", {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: {
|
|
103
|
+
"Content-Type": "application/json",
|
|
104
|
+
},
|
|
105
|
+
body: JSON.stringify({ ids }),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Reject redemptions (max 25).
|
|
111
|
+
*/
|
|
112
|
+
async reject(ids: string[]): Promise<RedemptionActionResponse> {
|
|
113
|
+
return this.rest.fetch("/public/v1/channels/rewards/redemptions/reject", {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: {
|
|
116
|
+
"Content-Type": "application/json",
|
|
117
|
+
},
|
|
118
|
+
body: JSON.stringify({ ids }),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { RestClient } from "../rest/RestClient";
|
|
2
|
+
import type { KickChannel } from "../../types/api";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ChannelsAPI provides access to Kick Channel APIs.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const channels = await kick.api.channels.get();
|
|
9
|
+
* const channels = await kick.api.channels.get({ slug: ["manaobot"] });
|
|
10
|
+
*/
|
|
11
|
+
export class ChannelsAPI {
|
|
12
|
+
constructor(private readonly rest: RestClient) {}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Retrieve channel information.
|
|
16
|
+
*
|
|
17
|
+
* - Provided no params, returns current authorized channel
|
|
18
|
+
* - Provide broadcaster_user_id[] OR slug[] only, not both
|
|
19
|
+
*/
|
|
20
|
+
async get(params?: {
|
|
21
|
+
broadcaster_user_id?: number[];
|
|
22
|
+
slug?: string[];
|
|
23
|
+
}): Promise<{ data: KickChannel[] }> {
|
|
24
|
+
const search = new URLSearchParams();
|
|
25
|
+
|
|
26
|
+
if (params?.broadcaster_user_id && params?.slug) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
"ChannelsClient.get(): cannot mix broadcaster_user_id and slug",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
params?.broadcaster_user_id?.forEach((id) =>
|
|
33
|
+
search.append("broadcaster_user_id", String(id)),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
params?.slug?.forEach((s) => search.append("slug", s));
|
|
37
|
+
|
|
38
|
+
const query = search.toString();
|
|
39
|
+
|
|
40
|
+
return this.rest.fetch(`/public/v1/channels${query ? `?${query}` : ""}`, {
|
|
41
|
+
method: "GET",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Update livestream metadata for the authorized channel.
|
|
47
|
+
*
|
|
48
|
+
* Requires scope: channel:write
|
|
49
|
+
*/
|
|
50
|
+
async update(body: {
|
|
51
|
+
category_id?: number;
|
|
52
|
+
custom_tags?: string[];
|
|
53
|
+
stream_title?: string;
|
|
54
|
+
}): Promise<void> {
|
|
55
|
+
await this.rest.fetch(`/public/v1/channels`, {
|
|
56
|
+
method: "PATCH",
|
|
57
|
+
headers: {
|
|
58
|
+
"Content-Type": "application/json",
|
|
59
|
+
},
|
|
60
|
+
body: JSON.stringify(body),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { RestClient } from "../rest/RestClient";
|
|
2
|
+
import type { KickLeaderboardResponse } from "../../types/api";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* KicksAPI provides access to Kick KICKs APIs.
|
|
6
|
+
*
|
|
7
|
+
* Required scope:
|
|
8
|
+
* - kicks:read
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const leaderboard = await kick.api.kicks.getLeaderboard({ top: 10 });
|
|
12
|
+
*/
|
|
13
|
+
export class KicksAPI {
|
|
14
|
+
constructor(private readonly rest: RestClient) {}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get KICKs leaderboard for the authenticated broadcaster.
|
|
18
|
+
*
|
|
19
|
+
* @param params Optional query params
|
|
20
|
+
*/
|
|
21
|
+
async getLeaderboard(params?: {
|
|
22
|
+
top?: number;
|
|
23
|
+
}): Promise<KickLeaderboardResponse> {
|
|
24
|
+
const search = new URLSearchParams();
|
|
25
|
+
|
|
26
|
+
if (params?.top) {
|
|
27
|
+
search.set("top", String(params.top));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const query = search.toString();
|
|
31
|
+
|
|
32
|
+
return this.rest.fetch(
|
|
33
|
+
`/public/v1/kicks/leaderboard${query ? `?${query}` : ""}`,
|
|
34
|
+
{ method: "GET" },
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { RestClient } from "../rest/RestClient";
|
|
2
|
+
import type {
|
|
3
|
+
KickLivestream,
|
|
4
|
+
KickLivestreamStatsResponse,
|
|
5
|
+
KickLivestreamsResponse,
|
|
6
|
+
} from "../../types/api";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* LivestreamsAPI provides access to Kick Livestream APIs.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* const streams = await kick.api.livestreams.get({ limit: 10 });
|
|
13
|
+
*/
|
|
14
|
+
export class LivestreamsAPI {
|
|
15
|
+
constructor(private readonly rest: RestClient) {}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get livestreams with optional filters.
|
|
19
|
+
*
|
|
20
|
+
* Supported filters:
|
|
21
|
+
* - broadcaster_user_id[]
|
|
22
|
+
* - category_id
|
|
23
|
+
* - language
|
|
24
|
+
* - limit
|
|
25
|
+
* - sort ("viewer_count" | "started_at")
|
|
26
|
+
*/
|
|
27
|
+
async get(params?: {
|
|
28
|
+
broadcaster_user_id?: number[];
|
|
29
|
+
category_id?: number;
|
|
30
|
+
language?: string;
|
|
31
|
+
limit?: number;
|
|
32
|
+
sort?: "viewer_count" | "started_at";
|
|
33
|
+
}): Promise<KickLivestreamsResponse> {
|
|
34
|
+
const search = new URLSearchParams();
|
|
35
|
+
|
|
36
|
+
params?.broadcaster_user_id?.forEach((id) =>
|
|
37
|
+
search.append("broadcaster_user_id", String(id)),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
if (params?.category_id)
|
|
41
|
+
search.set("category_id", String(params.category_id));
|
|
42
|
+
if (params?.language) search.set("language", params.language);
|
|
43
|
+
if (params?.limit) search.set("limit", String(params.limit));
|
|
44
|
+
if (params?.sort) search.set("sort", params.sort);
|
|
45
|
+
|
|
46
|
+
const query = search.toString();
|
|
47
|
+
|
|
48
|
+
return this.rest.fetch(
|
|
49
|
+
`/public/v1/livestreams${query ? `?${query}` : ""}`,
|
|
50
|
+
{ method: "GET" },
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get livestream statistics.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* const stats = await kick.api.livestreams.getStats();
|
|
59
|
+
*/
|
|
60
|
+
async getStats(): Promise<KickLivestreamStatsResponse> {
|
|
61
|
+
return this.rest.fetch("/public/v1/livestreams/stats", {
|
|
62
|
+
method: "GET",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { RestClient } from "../rest/RestClient";
|
|
2
|
+
import type {
|
|
3
|
+
ModerationBanRequest,
|
|
4
|
+
ModerationUnbanRequest,
|
|
5
|
+
KickOkResponse,
|
|
6
|
+
} from "../../types/api";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* ModerationAPI provides access to Kick Moderation APIs.
|
|
10
|
+
*
|
|
11
|
+
* Required scope:
|
|
12
|
+
* - moderation:ban
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* await kick.api.moderation.ban({ broadcaster_user_id: 123, user_id: 333, reason: "Not vibing" });
|
|
16
|
+
* await kick.api.moderation.timeout({ broadcaster_user_id: 123, user_id: 333, duration: 10 });
|
|
17
|
+
* await kick.api.moderation.unban({ broadcaster_user_id: 123, user_id: 333 });
|
|
18
|
+
*/
|
|
19
|
+
export class ModerationAPI {
|
|
20
|
+
constructor(private readonly rest: RestClient) {}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Ban a user (permanent ban).
|
|
24
|
+
* Omit `duration (minutes)` to ban permanently.
|
|
25
|
+
*/
|
|
26
|
+
async ban(
|
|
27
|
+
body: Omit<ModerationBanRequest, "duration">,
|
|
28
|
+
): Promise<KickOkResponse> {
|
|
29
|
+
return this.rest.fetch("/public/v1/moderation/bans", {
|
|
30
|
+
method: "POST",
|
|
31
|
+
body: JSON.stringify(body),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Timeout a user for `duration` minutes.
|
|
37
|
+
*/
|
|
38
|
+
async timeout(body: ModerationBanRequest): Promise<KickOkResponse> {
|
|
39
|
+
if (typeof body.duration !== "number") {
|
|
40
|
+
throw new Error(
|
|
41
|
+
"ModerationAPI.timeout(): duration is required (minutes)",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return this.rest.fetch("/public/v1/moderation/bans", {
|
|
46
|
+
method: "POST",
|
|
47
|
+
body: JSON.stringify(body),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Unban a user OR remove an active timeout.
|
|
53
|
+
*/
|
|
54
|
+
async unban(body: ModerationUnbanRequest): Promise<KickOkResponse> {
|
|
55
|
+
return this.rest.fetch("/public/v1/moderation/bans", {
|
|
56
|
+
method: "DELETE",
|
|
57
|
+
body: JSON.stringify(body),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { RestClient } from "../rest/RestClient";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parameters for retrieving users.
|
|
5
|
+
*/
|
|
6
|
+
export interface GetUsersParams {
|
|
7
|
+
/**
|
|
8
|
+
* User IDs to retrieve.
|
|
9
|
+
* If omitted, returns the currently authorized user.
|
|
10
|
+
*/
|
|
11
|
+
id?: number[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Kick User object returned by the API.
|
|
16
|
+
*/
|
|
17
|
+
export interface KickUser {
|
|
18
|
+
email?: string;
|
|
19
|
+
name: string;
|
|
20
|
+
profile_picture: string;
|
|
21
|
+
user_id: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Response returned from GET /public/v1/users
|
|
26
|
+
*/
|
|
27
|
+
export interface GetUsersResponse {
|
|
28
|
+
data: KickUser[];
|
|
29
|
+
message: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* UsersAPI provides access to Kick User APIs.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* const users = await kick.api.users.get();
|
|
37
|
+
* console.log(users.data);
|
|
38
|
+
*/
|
|
39
|
+
export class UsersAPI {
|
|
40
|
+
constructor(private readonly rest: RestClient) {}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Retrieve user information.
|
|
44
|
+
*
|
|
45
|
+
* If no IDs are provided, the currently authorized user is returned.
|
|
46
|
+
*
|
|
47
|
+
* Requires scope:
|
|
48
|
+
* - user:read
|
|
49
|
+
*
|
|
50
|
+
* @param params Query parameters
|
|
51
|
+
* @returns Kick user data
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* // Get current user
|
|
55
|
+
* const me = await kick.api.users.get();
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* // Get specific users
|
|
59
|
+
* const users = await kick.api.users.get({ id: [123, 456] });
|
|
60
|
+
*/
|
|
61
|
+
async get(params: GetUsersParams = {}): Promise<GetUsersResponse> {
|
|
62
|
+
const search = new URLSearchParams();
|
|
63
|
+
|
|
64
|
+
params.id?.forEach((v) => search.append("id", String(v)));
|
|
65
|
+
|
|
66
|
+
const query = search.toString();
|
|
67
|
+
|
|
68
|
+
return this.rest.fetch(`/public/v1/users${query ? `?${query}` : ""}`, {
|
|
69
|
+
method: "GET",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { callbackServer } from "./CallbackServer.ts";
|
|
2
|
+
import type { KickClient } from "../KickClient";
|
|
3
|
+
|
|
4
|
+
type AuthorizedHandler = () => void | Promise<void>;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* AuthManager handles authentication lifecycle events and local callback servers.
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* kick.auth.onAuthorized(() => {
|
|
11
|
+
* console.log("Authenticated and ready!");
|
|
12
|
+
* });
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export class AuthManager {
|
|
16
|
+
private handlers = new Set<AuthorizedHandler>();
|
|
17
|
+
private readonly client: KickClient;
|
|
18
|
+
|
|
19
|
+
constructor(client: KickClient) {
|
|
20
|
+
this.client = client;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Register a callback to be executed when the client is authorized.
|
|
25
|
+
* @param handler Function to run on authorization
|
|
26
|
+
*/
|
|
27
|
+
onAuthorized(handler: AuthorizedHandler) {
|
|
28
|
+
this.handlers.add(handler);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Trigger all registered authorization handlers.
|
|
33
|
+
* Internal use only.
|
|
34
|
+
*/
|
|
35
|
+
async emitAuthorized() {
|
|
36
|
+
for (const handler of this.handlers) {
|
|
37
|
+
await handler();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Returns a promise that resolves once the client is authorized.
|
|
43
|
+
* @example
|
|
44
|
+
* await kick.auth.waitForAuthorization();
|
|
45
|
+
* console.log("Authorized!");
|
|
46
|
+
*/
|
|
47
|
+
waitForAuthorization(): Promise<void> {
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
this.onAuthorized(() => resolve());
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Creates a temporary local server to handle OAuth2 redirects.
|
|
55
|
+
* @param options Server configuration
|
|
56
|
+
* @returns {Bun.Server<undefined>} Callback server instance
|
|
57
|
+
*/
|
|
58
|
+
createCallbackServer(options?: {
|
|
59
|
+
port?: number;
|
|
60
|
+
path?: string;
|
|
61
|
+
}): Bun.Server<undefined> {
|
|
62
|
+
return callbackServer(this.client, options);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { KickClient } from "../KickClient";
|
|
2
|
+
import { logger } from "../Logger.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Configuration options for the local callback server.
|
|
6
|
+
*/
|
|
7
|
+
interface CallbackServerOptions {
|
|
8
|
+
/** @default 3000 */
|
|
9
|
+
port?: number;
|
|
10
|
+
/** @default "/callback" */
|
|
11
|
+
path?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function callbackServer(
|
|
15
|
+
client: KickClient,
|
|
16
|
+
options: CallbackServerOptions = {},
|
|
17
|
+
): Bun.Server<undefined> {
|
|
18
|
+
const port = options.port ?? 3000;
|
|
19
|
+
const path = options.path ?? "/callback";
|
|
20
|
+
|
|
21
|
+
const server = Bun.serve({
|
|
22
|
+
port,
|
|
23
|
+
reusePort: true,
|
|
24
|
+
async fetch(req) {
|
|
25
|
+
const url = new URL(req.url);
|
|
26
|
+
|
|
27
|
+
if (req.method !== "GET" || url.pathname !== path) {
|
|
28
|
+
return new Response(null, { status: 404 });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const code = url.searchParams.get("code");
|
|
32
|
+
const state = url.searchParams.get("state");
|
|
33
|
+
|
|
34
|
+
if (!code || state !== client.getState()) {
|
|
35
|
+
logger.error("Invalid OAuth callback: state mismatch or missing code");
|
|
36
|
+
return new Response("Invalid OAuth callback", { status: 400 });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
await client.exchangeCode(code);
|
|
41
|
+
await client.auth.emitAuthorized();
|
|
42
|
+
|
|
43
|
+
return new Response("Authorization success, you can close this tab.", {
|
|
44
|
+
status: 200,
|
|
45
|
+
headers: { "Content-Type": "text/plain" },
|
|
46
|
+
});
|
|
47
|
+
} catch (err) {
|
|
48
|
+
logger.error("OAuth exchange failed", err);
|
|
49
|
+
return new Response("Authorization failed", { status: 500 });
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
logger.success("Callback server started", { port, path });
|
|
55
|
+
|
|
56
|
+
return server;
|
|
57
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { KickTokenResponse } from "../../types";
|
|
2
|
+
|
|
3
|
+
export async function exchangeCode(
|
|
4
|
+
code: string,
|
|
5
|
+
verifier: string,
|
|
6
|
+
clientId: string,
|
|
7
|
+
clientSecret: string,
|
|
8
|
+
redirectUri: string,
|
|
9
|
+
): Promise<KickTokenResponse> {
|
|
10
|
+
const params = new URLSearchParams({
|
|
11
|
+
grant_type: "authorization_code",
|
|
12
|
+
code,
|
|
13
|
+
redirect_uri: redirectUri,
|
|
14
|
+
client_id: clientId,
|
|
15
|
+
client_secret: clientSecret,
|
|
16
|
+
code_verifier: verifier,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const res = await fetch("https://id.kick.com/oauth/token", {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
22
|
+
body: params.toString(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
throw new Error(`Code exchange failed (${res.status})`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return res.json() as Promise<KickTokenResponse>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function refreshToken(
|
|
33
|
+
refreshToken: string,
|
|
34
|
+
clientId: string,
|
|
35
|
+
clientSecret: string,
|
|
36
|
+
): Promise<KickTokenResponse> {
|
|
37
|
+
const params = new URLSearchParams({
|
|
38
|
+
grant_type: "refresh_token",
|
|
39
|
+
refresh_token: refreshToken,
|
|
40
|
+
client_id: clientId,
|
|
41
|
+
client_secret: clientSecret,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const res = await fetch("https://id.kick.com/oauth/token", {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
47
|
+
body: params.toString(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (!res.ok) {
|
|
51
|
+
throw new Error(`Token refresh failed (${res.status})`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return res.json() as Promise<KickTokenResponse>;
|
|
55
|
+
}
|
package/src/auth/PKCE.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function createPkce(): {
|
|
2
|
+
verifier: string;
|
|
3
|
+
challenge: string;
|
|
4
|
+
} {
|
|
5
|
+
const verifier = Buffer.from(
|
|
6
|
+
crypto.getRandomValues(new Uint8Array(32)),
|
|
7
|
+
).toString("base64url");
|
|
8
|
+
const challenge = new Bun.CryptoHasher("sha256")
|
|
9
|
+
.update(verifier)
|
|
10
|
+
.digest("base64url");
|
|
11
|
+
|
|
12
|
+
return { verifier, challenge };
|
|
13
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { KickTokenResponse } from "../../types";
|
|
2
|
+
|
|
3
|
+
type RefreshFn = (refreshToken: string) => Promise<KickTokenResponse>;
|
|
4
|
+
type TokenUpdateCallback = (tokens: KickTokenResponse) => void | Promise<void>;
|
|
5
|
+
|
|
6
|
+
export class TokenManager {
|
|
7
|
+
private accessToken?: string;
|
|
8
|
+
private refreshToken?: string;
|
|
9
|
+
private expiresAt?: number;
|
|
10
|
+
private readonly onUpdate?: TokenUpdateCallback;
|
|
11
|
+
|
|
12
|
+
private readonly refreshFn: RefreshFn;
|
|
13
|
+
private refreshThresholdMs = 60_000; // refresh 60s before expiry
|
|
14
|
+
|
|
15
|
+
constructor(refreshFn: RefreshFn, onUpdate?: TokenUpdateCallback) {
|
|
16
|
+
this.refreshFn = refreshFn;
|
|
17
|
+
this.onUpdate = onUpdate;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
setTokens(token: KickTokenResponse) {
|
|
21
|
+
this.accessToken = token.access_token;
|
|
22
|
+
this.refreshToken = token.refresh_token;
|
|
23
|
+
|
|
24
|
+
this.onUpdate?.(token);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async getAccessToken(): Promise<string> {
|
|
28
|
+
if (!this.accessToken) {
|
|
29
|
+
throw new Error("TokenManager not initialized");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!this.expiresAt) {
|
|
33
|
+
return this.accessToken;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
|
|
38
|
+
if (now >= this.expiresAt - this.refreshThresholdMs) {
|
|
39
|
+
await this.refresh();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return this.accessToken;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private async refresh() {
|
|
46
|
+
if (!this.refreshToken) {
|
|
47
|
+
throw new Error("No refresh token available");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const newToken = await this.refreshFn(this.refreshToken);
|
|
51
|
+
this.setTokens(newToken);
|
|
52
|
+
}
|
|
53
|
+
}
|