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 # 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" "placeholder": "such as: example.dev"
}, },
{ {
"name": "defaultVisbility", "name": "defaultVisibility",
"type": "dropdown", "type": "dropdown",
"required": false, "required": false,
"data": [ "data": [
@ -36,17 +36,21 @@
"title": "🌎 Public", "title": "🌎 Public",
"value": "public" "value": "public"
}, },
{
"title": "👥 Private",
"value": "private"
},
{ {
"title": "🙈 Unlist", "title": "🙈 Unlist",
"value": "unlist" "value": "unlist"
}, },
{
"title": "👥 Private",
"value": "private"
},
{ {
"title": "✉️ Direct", "title": "✉️ Direct",
"value": "direct" "value": "direct"
},
{
"title": "📍 Local",
"value": "local"
} }
], ],
"title": "Akkoma instance's URL", "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 { Form, ActionPanel, Action, showToast } from "@raycast/api";
import { authorize, client } from "./oauth"; import { authorize } from "./oauth";
import fetch from "node-fetch"; import { postNewStatus } from "./api";
import { getPreferenceValues } from "@raycast/api"; import { Status, VisibilityOption } from "./types";
import { Preference,ApiResponse } from "./types";
type Values = {
textfield: string;
textarea: string;
datepicker: Date;
sensitive: boolean;
dropdown: string;
files: { name: string; url: string }[];
};
export default function Command() { export default function Command() {
const { instance } = getPreferenceValues<Preference>();
useEffect(() => { useEffect(() => {
authorize(); authorize();
}, []); }, []);
const handleSubmit = useCallback( const visibilityOptions: VisibilityOption[] = [
async (values: Values) => { { value: "direct", title: "Direct" },
try { { value: "private", title: "Private" },
const token = await client.getTokens(); { value: "unlisted", title: "Unlisted" },
{ value: "public", title: "Public" },
{ value: "local", title: "Local" },
];
const response = await fetch(`https://${instance}/api/v1/statuses`, { const handleSubmit = async (values: Status) => {
method: "POST", try {
headers: { await postNewStatus({ ...values });
"Content-Type": "application/json", showToast({ title: "Submitted form", message: "Status has been posted!" });
"Authorization": `Bearer ${token?.accessToken}`, } catch (error) {
}, console.error(error);
body: JSON.stringify({ showToast({ title: "Error", message: "Something went wrong!" });
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]
);
return ( return (
<> <Form
<Form actions={
actions={ <ActionPanel>
<ActionPanel> <Action.SubmitForm onSubmit={handleSubmit} />
<Action.SubmitForm onSubmit={handleSubmit} /> </ActionPanel>
</ActionPanel> }
} >
> <Form.TextField id="textfield" title="Content Warning" placeholder="" />
<Form.TextField id="textfield" title="Content Warning" placeholder="" /> <Form.TextArea id="staus" title="Post detail" placeholder="" enableMarkdown={true} />
<Form.TextArea id="textarea" title="Post detail" placeholder="" enableMarkdown={true} /> <Form.Separator />
<Form.Separator /> <Form.DatePicker id="datepicker" title="Scheduled Time" />
<Form.DatePicker id="datepicker" title="Scheduled Time" /> <Form.Dropdown id="visibility" title="Visibility" storeValue={true} defaultValue="">
<Form.Dropdown id="dropdown" title="Visibility" storeValue={true} defaultValue=""> {visibilityOptions.map(({value, title}) => (
<Form.Dropdown.Item value="direct" title="Direct" /> <Form.Dropdown.Item key={value} value={value} title={title} />
<Form.Dropdown.Item value="private" title="Private" /> ))}
<Form.Dropdown.Item value="unlisted" title="Unlisted" /> </Form.Dropdown>
<Form.Dropdown.Item value="public" title="Public" /> <Form.FilePicker id="files" />
</Form.Dropdown> <Form.Checkbox id="sensitive" title="Sensitive" label="Sensitive" />
<Form.FilePicker id="files" /> </Form>
<Form.Checkbox id="sensitive" title="Sensitive" label="Sensitive" />
</Form>
</>
); );
} }

View file

@ -1,10 +1,8 @@
import { OAuth, getPreferenceValues } from "@raycast/api"; import { OAuth, getPreferenceValues } from "@raycast/api";
import fetch from "node-fetch"; import { Preference } from "./types";
import { Preference, AppResponse } from "./types"; import { fetchToken,createApp} from "./api";
const redirectUri = "https://raycast.com/redirect?packageName=Extension"; const client = new OAuth.PKCEClient({
export const client = new OAuth.PKCEClient({
redirectMethod: OAuth.RedirectMethod.Web, redirectMethod: OAuth.RedirectMethod.Web,
providerName: "Akkoma", providerName: "Akkoma",
providerIcon: "akkoma-icon.png", providerIcon: "akkoma-icon.png",
@ -12,37 +10,12 @@ export const client = new OAuth.PKCEClient({
description: "Connect to your Akkoma | Pleroma | Mastodon account", description: "Connect to your Akkoma | Pleroma | Mastodon account",
}); });
const createAkkomaApp = async (): Promise<AppResponse> => { const requestAccessToken = async (
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 (
clientId: string, clientId: string,
clientSecret: string, clientSecret: string,
authRequest: OAuth.AuthorizationRequest, authRequest: OAuth.AuthorizationRequest,
authCode: string authCode: string
): Promise<OAuth.TokenResponse> => { ): Promise<OAuth.TokenResponse> => {
const { instance } = getPreferenceValues<Preference>();
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append("client_id", clientId); params.append("client_id", clientId);
@ -52,23 +25,14 @@ export const requestAccessToken = async (
params.append("grant_type", "authorization_code"); params.append("grant_type", "authorization_code");
params.append("redirect_uri", authRequest.redirectURI); params.append("redirect_uri", authRequest.redirectURI);
const response = await fetch(`https://${instance}/oauth/token`, { return await fetchToken(params, "fetch tokens error:");
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;
}; };
export const refreshToken = async ( const refreshToken = async (
clientId: string, clientId: string,
clientSecret: string, clientSecret: string,
refreshToken: string refreshToken: string
): Promise<OAuth.TokenResponse> => { ): Promise<OAuth.TokenResponse> => {
const { instance } = getPreferenceValues<Preference>();
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append("client_id", clientId); params.append("client_id", clientId);
@ -76,40 +40,33 @@ export const refreshToken = async (
params.append("refresh_token", refreshToken); params.append("refresh_token", refreshToken);
params.append("grant_type", "refresh_token"); params.append("grant_type", "refresh_token");
const response = await fetch(`https://${instance}/oauth/token`, { const tokenResponse = await fetchToken(params, "refresh tokens error:");
method: "POST",
body: params,
});
if (!response.ok) {
console.error("refresh tokens error:", await response.text());
throw new Error(response.statusText);
}
const tokenResponse = (await response.json()) as OAuth.TokenResponse;
tokenResponse.refresh_token = tokenResponse.refresh_token ?? refreshToken; tokenResponse.refresh_token = tokenResponse.refresh_token ?? refreshToken;
return tokenResponse; return tokenResponse;
}; };
// 授权过程
export const authorize = async (): Promise<void> => { export const authorize = async (): Promise<void> => {
const { instance } = getPreferenceValues<Preference>(); const { instance } = getPreferenceValues<Preference>();
const tokenSet = await client.getTokens(); const tokenSet = await client.getTokens();
if (tokenSet?.accessToken) { if (tokenSet?.accessToken) {
if (tokenSet.refreshToken && tokenSet.isExpired()) { 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)); await client.setTokens(await refreshToken(client_id, client_secret, tokenSet.refreshToken));
} }
return; return;
} }
const { client_id, client_secret } = await createAkkomaApp(); const { client_id, client_secret } = await createApp();
const authRequest = await client.authorizationRequest({ const authRequest = await client.authorizationRequest({
endpoint: `https://${instance}/oauth/authorize`, endpoint: `https://${instance}/oauth/authorize`,
clientId: client_id, clientId: client_id,
scope: "read write push", scope: "read write push",
}); });
const { authorizationCode } = await client.authorize(authRequest); const { authorizationCode } = await client.authorize(authRequest);
await client.setTokens(await requestAccessToken(client_id, client_secret, authRequest, authorizationCode)); 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 { export interface Preference {
instance: string instance: string;
defaultVisibility: VisibilityScope;
} }
export interface Credentials { export interface Credentials {
@ -28,18 +36,158 @@ export interface AppResponse {
vapid_key: string; vapid_key: string;
} }
export interface Status { interface Poll {
content_type: string; expired_in: number;
expires_in: number; hide_totals?: boolean | string;
language: string; multiple?: boolean | string | number;
media_ids: string[]; options: string[]
preview: boolean | string | number;
scheduled_at: string;
sensitive: string | boolean | number;
spoiler_text: string;
status: string;
to: string[];
visibility: "direct" | "private" | "unlisted" | "public";
} }
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;
// }