refactor: seperate api and oauth

This commit is contained in:
sevichecc 2023-04-07 22:59:05 +08:00
parent 2e0377f7f4
commit f9f8bbe3b6
Signed by untrusted user who does not match committer: SevicheCC
GPG key ID: C577000000000000
6 changed files with 297 additions and 140 deletions

View file

@ -1,3 +1,3 @@
# Post to Akkoma
Write new post and send it to Akkoma
Write a new post and send it to Akkoma

View file

@ -28,7 +28,7 @@
"placeholder": "such as: example.dev"
},
{
"name": "defaultVisbility",
"name": "defaultVisibility",
"type": "dropdown",
"required": false,
"data": [
@ -36,17 +36,21 @@
"title": "🌎 Public",
"value": "public"
},
{
"title": "👥 Private",
"value": "private"
},
{
"title": "🙈 Unlist",
"value": "unlist"
},
{
"title": "👥 Private",
"value": "private"
},
{
"title": "✉️ Direct",
"value": "direct"
},
{
"title": "📍 Local",
"value": "local"
}
],
"title": "Akkoma instance's URL",

75
src/api.ts Normal file
View file

@ -0,0 +1,75 @@
import fetch from "node-fetch";
import { OAuth, getPreferenceValues } from "@raycast/api";
import { AppResponse, Preference, Status } from "./types";
import { authorize } from "./oauth";
export const fetchToken = async (params: URLSearchParams, errorMessage: string): Promise<OAuth.TokenResponse> => {
const { instance } = getPreferenceValues<Preference>();
const response = await fetch(`https://${instance}/oauth/token`, {
method: "POST",
body: params,
});
if (!response.ok) {
console.error(errorMessage, await response.text());
throw new Error(response.statusText);
}
return (await response.json()) as OAuth.TokenResponse;
};
export const createApp = async (): Promise<AppResponse> => {
const { instance } = getPreferenceValues<Preference>();
const response = await fetch(`https://${instance}/api/v1/apps`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
client_name: "raycast-akkoma-extension",
redirect_uris: "https://raycast.com/redirect?packageName=Extension",
scopes: "read write push",
website: "https://raycast.com",
}),
});
if (!response.ok) {
throw new Error("Failed to create Akkoma app");
}
return (await response.json()) as AppResponse;
};
export const postNewStatus = async <T>({
status,
visibility,
spoiler_text,
sensitive,
scheduled_at,
}: Status): Promise<T> => {
const { instance } = getPreferenceValues<Preference>();
const token = await authorize();
const response = await fetch(`https://${instance}/api/v1/statuses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer" + token,
},
body: JSON.stringify({
status,
visibility,
spoiler_text,
sensitive,
scheduled_at,
}),
});
if (!response.ok) {
throw new Error("Failed to pulish new status");
}
return (await response.json()) as T;
};

View file

@ -1,78 +1,51 @@
import { useCallback, useEffect } from "react";
import { useEffect } from "react";
import { Form, ActionPanel, Action, showToast } from "@raycast/api";
import { authorize, client } from "./oauth";
import fetch from "node-fetch";
import { getPreferenceValues } from "@raycast/api";
import { Preference,ApiResponse } from "./types";
type Values = {
textfield: string;
textarea: string;
datepicker: Date;
sensitive: boolean;
dropdown: string;
files: { name: string; url: string }[];
};
import { authorize } from "./oauth";
import { postNewStatus } from "./api";
import { Status, VisibilityOption } from "./types";
export default function Command() {
const { instance } = getPreferenceValues<Preference>();
useEffect(() => {
authorize();
}, []);
const handleSubmit = useCallback(
async (values: Values) => {
try {
const token = await client.getTokens();
const response = await fetch(`https://${instance}/api/v1/statuses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token?.accessToken}`,
},
body: JSON.stringify({
status: values.textarea,
visibility: values.dropdown,
spoiler_text: values.textfield,
sensitive: values.sensitive,
scheduled_at: values.datepicker.toISOString(),
}),
});
const data = (await response.json()) as ApiResponse;
console.log(data);
showToast({ title: "Submitted form", message: "Status has been posted!" });
} catch (error) {
console.error(error);
showToast({ title: "Error", message: "Something went wrong!" });
}
},
[instance]
);
const visibilityOptions: VisibilityOption[] = [
{ value: "direct", title: "Direct" },
{ value: "private", title: "Private" },
{ value: "unlisted", title: "Unlisted" },
{ value: "public", title: "Public" },
{ value: "local", title: "Local" },
];
const handleSubmit = async (values: Status) => {
try {
await postNewStatus({ ...values });
showToast({ title: "Submitted form", message: "Status has been posted!" });
} catch (error) {
console.error(error);
showToast({ title: "Error", message: "Something went wrong!" });
}
};
return (
<>
<Form
actions={
<ActionPanel>
<Action.SubmitForm onSubmit={handleSubmit} />
</ActionPanel>
}
>
<Form.TextField id="textfield" title="Content Warning" placeholder="" />
<Form.TextArea id="textarea" title="Post detail" placeholder="" enableMarkdown={true} />
<Form.Separator />
<Form.DatePicker id="datepicker" title="Scheduled Time" />
<Form.Dropdown id="dropdown" title="Visibility" storeValue={true} defaultValue="">
<Form.Dropdown.Item value="direct" title="Direct" />
<Form.Dropdown.Item value="private" title="Private" />
<Form.Dropdown.Item value="unlisted" title="Unlisted" />
<Form.Dropdown.Item value="public" title="Public" />
</Form.Dropdown>
<Form.FilePicker id="files" />
<Form.Checkbox id="sensitive" title="Sensitive" label="Sensitive" />
</Form>
</>
<Form
actions={
<ActionPanel>
<Action.SubmitForm onSubmit={handleSubmit} />
</ActionPanel>
}
>
<Form.TextField id="textfield" title="Content Warning" placeholder="" />
<Form.TextArea id="staus" title="Post detail" placeholder="" enableMarkdown={true} />
<Form.Separator />
<Form.DatePicker id="datepicker" title="Scheduled Time" />
<Form.Dropdown id="visibility" title="Visibility" storeValue={true} defaultValue="">
{visibilityOptions.map(({value, title}) => (
<Form.Dropdown.Item key={value} value={value} title={title} />
))}
</Form.Dropdown>
<Form.FilePicker id="files" />
<Form.Checkbox id="sensitive" title="Sensitive" label="Sensitive" />
</Form>
);
}

View file

@ -1,10 +1,8 @@
import { OAuth, getPreferenceValues } from "@raycast/api";
import fetch from "node-fetch";
import { Preference, AppResponse } from "./types";
import { Preference } from "./types";
import { fetchToken,createApp} from "./api";
const redirectUri = "https://raycast.com/redirect?packageName=Extension";
export const client = new OAuth.PKCEClient({
const client = new OAuth.PKCEClient({
redirectMethod: OAuth.RedirectMethod.Web,
providerName: "Akkoma",
providerIcon: "akkoma-icon.png",
@ -12,37 +10,12 @@ export const client = new OAuth.PKCEClient({
description: "Connect to your Akkoma | Pleroma | Mastodon account",
});
const createAkkomaApp = async (): Promise<AppResponse> => {
const { instance } = getPreferenceValues<Preference>();
const response = await fetch(`https://${instance}/api/v1/apps`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
client_name: "raycast-akkoma-extension",
redirect_uris: redirectUri,
scopes: "read write push",
website: "https://raycast.com",
}),
});
if (!response.ok) {
throw new Error("Failed to create Akkoma app");
}
const appResponse = await response.json();
return appResponse as AppResponse;
};
export const requestAccessToken = async (
const requestAccessToken = async (
clientId: string,
clientSecret: string,
authRequest: OAuth.AuthorizationRequest,
authCode: string
): Promise<OAuth.TokenResponse> => {
const { instance } = getPreferenceValues<Preference>();
const params = new URLSearchParams();
params.append("client_id", clientId);
@ -52,23 +25,14 @@ export const requestAccessToken = async (
params.append("grant_type", "authorization_code");
params.append("redirect_uri", authRequest.redirectURI);
const response = await fetch(`https://${instance}/oauth/token`, {
method: "POST",
body: params,
});
if (!response.ok) {
console.error("fetch tokens error:", await response.text());
throw new Error(response.statusText);
}
return (await response.json()) as OAuth.TokenResponse;
return await fetchToken(params, "fetch tokens error:");
};
export const refreshToken = async (
const refreshToken = async (
clientId: string,
clientSecret: string,
refreshToken: string
): Promise<OAuth.TokenResponse> => {
const { instance } = getPreferenceValues<Preference>();
const params = new URLSearchParams();
params.append("client_id", clientId);
@ -76,40 +40,33 @@ export const refreshToken = async (
params.append("refresh_token", refreshToken);
params.append("grant_type", "refresh_token");
const response = await fetch(`https://${instance}/oauth/token`, {
method: "POST",
body: params,
});
if (!response.ok) {
console.error("refresh tokens error:", await response.text());
throw new Error(response.statusText);
}
const tokenResponse = await fetchToken(params, "refresh tokens error:");
const tokenResponse = (await response.json()) as OAuth.TokenResponse;
tokenResponse.refresh_token = tokenResponse.refresh_token ?? refreshToken;
return tokenResponse;
};
// 授权过程
export const authorize = async (): Promise<void> => {
const { instance } = getPreferenceValues<Preference>();
const tokenSet = await client.getTokens();
if (tokenSet?.accessToken) {
if (tokenSet.refreshToken && tokenSet.isExpired()) {
const { client_id, client_secret } = await createAkkomaApp();
const { client_id, client_secret } = await createApp();
await client.setTokens(await refreshToken(client_id, client_secret, tokenSet.refreshToken));
}
return;
}
const { client_id, client_secret } = await createAkkomaApp();
const { client_id, client_secret } = await createApp();
const authRequest = await client.authorizationRequest({
endpoint: `https://${instance}/oauth/authorize`,
clientId: client_id,
scope: "read write push",
});
const { authorizationCode } = await client.authorize(authRequest);
await client.setTokens(await requestAccessToken(client_id, client_secret, authRequest, authorizationCode));

View file

@ -1,5 +1,13 @@
export type VisibilityScope = "public" | "unlisted" | "direct" | "private" | "local"
export interface VisibilityOption {
title: string;
value: VisibilityScope
}
export interface Preference {
instance: string
instance: string;
defaultVisibility: VisibilityScope;
}
export interface Credentials {
@ -28,18 +36,158 @@ export interface AppResponse {
vapid_key: string;
}
export interface Status {
content_type: string;
expires_in: number;
language: string;
media_ids: string[];
preview: boolean | string | number;
scheduled_at: string;
sensitive: string | boolean | number;
spoiler_text: string;
status: string;
to: string[];
visibility: "direct" | "private" | "unlisted" | "public";
interface Poll {
expired_in: number;
hide_totals?: boolean | string;
multiple?: boolean | string | number;
options: string[]
}
export interface Status {
content_type?: string;
expires_in?: number;
in_reply_to_conversation_id?: string;
in_reply_to_id?: string;
language?: string;
media_ids?: string[];
poll?: Poll;
preview?: boolean | string | number;
scheduled_at?: string;
sensitive?: string | boolean | number;
spoiler_text?: string;
status?: string;
to?: string[];
visibility?: VisibilityScope;
}
// interface Account {
// acct: string;
// avatar: string;
// avatar_static: string;
// bot: boolean;
// created_at: string;
// display_name: string;
// emojis: Emoji[];
// fields: Field[];
// followers_count: number;
// following_count: number;
// header: string;
// header_static: string;
// id: string;
// is_confirmed: boolean;
// note: string;
// pleroma: Pleroma;
// source: Source;
// statuses_count: number;
// url: string;
// username: string;
// }
// interface Emoji {
// shortcode: string;
// static_url: string;
// url: string;
// visible_in_picker: boolean;
// }
// interface Field {
// name: string;
// value: string;
// verified_at: string | null;
// }
// interface Pleroma {
// background_image: null;
// hide_favorites: boolean;
// hide_followers: boolean;
// hide_followers_count: boolean;
// hide_follows: boolean;
// hide_follows_count: boolean;
// is_admin: boolean;
// is_confirmed: boolean;
// is_moderator: boolean;
// relationship: Relationship;
// skip_thread_containment: boolean;
// tags: any[];
// }
// type Actor = "Application" | "Group" | "Organization" | "Person" | "Service"
// interface Relationship {
// blocked_by: boolean;
// blocking: boolean;
// domain_blocking: boolean;
// endorsed: boolean;
// followed_by: boolean;
// following: boolean;
// id: string;
// muting: boolean;
// muting_notifications: boolean;
// note: string;
// notifying: boolean;
// requested: boolean;
// showing_reblogs: boolean;
// subscribing: boolean;
// }
// interface Source {
// fields: Field[];
// note: string;
// pleroma: SourcePleroma;
// privacy: VisibilityScope;
// sensitive: boolean;
// }
// interface SourcePleroma {
// actor_type: Actor;
// discoverable: boolean;
// no_rich_text: boolean;
// show_role: boolean;
// }
// interface StatusResponse {
// account: Account;
// application: null;
// bookmarked: boolean;
// card: null;
// content: string;
// created_at: string;
// emojis: any[];
// favourited: boolean;
// favourites_count: number;
// id: string;
// in_reply_to_account_id: null;
// in_reply_to_id: null;
// language: null;
// media_attachments: any[];
// mentions: any[];
// muted: boolean;
// pinned: boolean;
// pleroma: StatusPleroma;
// poll: null;
// reblog: null;
// reblogged: boolean;
// reblogs_count: number;
// replies_count: number;
// sensitive: boolean;
// spoiler_text: string;
// tags: any[];
// uri: string;
// url: string;
// visibility: string;
// }
// interface StatusPleroma {
// content: PleromaContent;
// context: string;
// conversation_id: number;
// direct_conversation_id: null;
// emoji_reactions: any[];
// expires_at: null;
// in_reply_to_account_acct: null;
// local: boolean;
// spoiler_text: PleromaContent;
// thread_muted: boolean;
// }
// interface PleromaContent {
// "text/plain": string;
// }