Skip to content

Commit

Permalink
Dev for w2024 (#2)
Browse files Browse the repository at this point in the history
* feat: Confirmation dialog

* feat: Disable submit on empty content

* feat: Quotes

* feat: Limit file number

* feat: Template
  • Loading branch information
cma2819 authored Dec 19, 2024
1 parent 37ac21d commit 77c876f
Show file tree
Hide file tree
Showing 11 changed files with 543 additions and 28 deletions.
72 changes: 61 additions & 11 deletions app/api/bluesky.server.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { BskyAgent } from "@atproto/api";
import { BlobRef, BskyAgent } from "@atproto/api";
import { env } from "../env.server";
import fs from "node:fs/promises";
import path from "node:path";
import sharp from "sharp";
import { tmpDir } from "../tmp-dir.server";
import { prisma } from "../prisma.server";
import { isThreadViewPost } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
import {
isThreadViewPost,
type PostView,
} from "@atproto/api/dist/client/types/app/bsky/feed/defs";

let blueskyEnabled = false;
export const getBlueskyEnabled = () => blueskyEnabled;
Expand Down Expand Up @@ -82,18 +85,71 @@ const uploadFile = async (filePath: string) => {
return res.data.blob;
};

export const post = async (text: string, files: string[], replyTo?: string) => {
const makeEmbed = (uploads: BlobRef[], quote: PostView | null) => {
if (quote && uploads.length > 0) {
return {
$type: "app.bsky.embed.recordWithMedia",
media: {
$type: "app.bsky.embed.images",
images: uploads.map((result) => ({
image: result,
alt: "", // TODO: allow setting alt text
})),
},
record: {
$type: "app.bsky.embed.record",
record: {
uri: quote.uri,
cid: quote.cid,
},
},
};
}

if (quote && uploads.length === 0) {
return {
$type: "app.bsky.embed.record",
record: {
uri: quote.uri,
cid: quote.cid,
},
};
}

return {
$type: "app.bsky.embed.images",
images: uploads.map((result) => ({
image: result,
alt: "", // TODO: allow setting alt text
})),
};
};

export const post = async (
text: string,
files: string[],
replyTo?: string,
quotePostId?: string
) => {
const replyPostThreadData = replyTo
? await agent.getPostThread({ uri: replyTo })
: null;
// console.log(JSON.stringify(replyPost?.data, null, 2));
// throw new Error("tmp!!!");

const replyPost =
replyPostThreadData && isThreadViewPost(replyPostThreadData.data.thread)
? replyPostThreadData.data.thread.post
: null;

const quotePostThreadData = quotePostId
? await agent.getPostThread({ uri: quotePostId })
: null;

const quotePost =
quotePostThreadData && isThreadViewPost(quotePostThreadData.data.thread)
? quotePostThreadData.data.thread.post
: null;

const replyPostRecord = replyPost?.record as
| { reply?: { root?: { uri: string; cid: string } } }
| undefined;
Expand All @@ -102,13 +158,7 @@ export const post = async (text: string, files: string[], replyTo?: string) => {
const uploadResults = await Promise.all(files.map(uploadFile));
await agent.post({
text,
embed: {
$type: "app.bsky.embed.images",
images: uploadResults.map((result) => ({
image: result,
alt: "", // TODO: allow setting alt text
})),
},
embed: makeEmbed(uploadResults, quotePost),
reply:
replyPost && rootPost
? {
Expand Down
36 changes: 36 additions & 0 deletions app/api/runs.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { env } from "../env.server";

export interface Run {
id: number;
gamename: string;
category: string;
runner: {
username: string;
twitterid: string;
}[];
commentary: {
username: string;
twitterid: string;
}[];
}

export const getRuns = async (): Promise<Run[]> => {
const apiUrl = env.RUNDATA_API_URL;
if (!apiUrl) {
return [];
}

try {
const response = await fetch(apiUrl);

if (!response.ok) {
console.error("Failed to load runs");
return [];
}
const json = (await response.json()) as { data: Run[] };
return json.data;
} catch (e) {
console.error(e);
return [];
}
};
22 changes: 18 additions & 4 deletions app/api/twitter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,15 +219,23 @@ export const getTweets = async () => {
}
};

export const tweet = async (text: string, files: string[]) => {
export const tweet = async (
text: string,
files: string[],
quoteTweetId?: string
) => {
const page = await newPage("https://x.com/");
const fixedText = (
quoteTweetId
? `${text}\n\nhttps://x.com/${username}/status/${quoteTweetId}`
: text
).replace(/\r\n|\r/g, "\n");
try {
const input = await page.waitForSelector(textAreaSelector);
if (!input) {
throw new Error("No tweet input label");
}
await input.click({ count: 3 });
const fixedText = text.replace(/\r\n|\r/g, "\n");
await input.type(fixedText);

if (files.length >= 1) {
Expand Down Expand Up @@ -300,9 +308,15 @@ export const deleteTweet = async (tweetId: string) => {
export const sendReply = async (
tweetId: string,
text: string,
files: string[]
files: string[],
quoteTweetId?: string
) => {
const page = await newPage(`https://x.com/${username}/status/${tweetId}`);
const fixedText = (
quoteTweetId
? `${text}\n\nhttps://x.com/${username}/status/${quoteTweetId}`
: text
).replace(/\r\n|\r/g, "\n");
try {
const replyButton = await page.waitForSelector(
"button[data-testid=reply]:not([aria-disabled=true])"
Expand All @@ -326,7 +340,7 @@ export const sendReply = async (
throw new Error("No tweet input label");
}
await label.click({ count: 3 });
await label.type(text);
await label.type(fixedText);
const tweetButton = await page.waitForSelector(
'button[data-testid="tweetButton"]:not([aria-disabled="true"])'
);
Expand Down
2 changes: 2 additions & 0 deletions app/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const envSchema = z.object({

BLUESKY_USERNAME: z.string().optional(),
BLUESKY_PASSWORD: z.string().optional(),

RUNDATA_API_URL: z.string().optional(),
});

export const env = envSchema.parse(process.env);
14 changes: 13 additions & 1 deletion app/i18next/translation/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,17 @@
"signOut": "Sign out",
"delete": "Delete",
"reply": "Reply",
"signInWithDiscord": "Sign in with Discord"
"signInWithDiscord": "Sign in with Discord",
"confirmTweetQuestion": "Confirm tweet content",
"cancel": "Cancel",
"quote": "Quote",
"apply": "Apply",
"template": "Template",
"templateTitle": "Select template",
"template-setup": "Next game",
"template-finishWithTime": "Result with finished time",
"template-finishWithoutTime": "Result without finished time",
"template-finishOnlyTime": "Result",
"template-running": "During run",
"template-backup": "Add backup run"
}
14 changes: 13 additions & 1 deletion app/i18next/translation/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,17 @@
"signOut": "ログアウト",
"delete": "削除",
"reply": "リプライ",
"signInWithDiscord": "Discordでログイン"
"signInWithDiscord": "Discordでログイン",
"confirmTweetQuestion": "以下のツイートを送信します。よろしいですか?",
"cancel": "キャンセル",
"quote": "引用",
"apply": "反映",
"template": "テンプレート",
"templateTitle": "テンプレート選択",
"template-setup": "次のゲーム",
"template-finishWithTime": "ゲーム終了 クリアタイム有",
"template-finishWithoutTime": "ゲーム終了 クリアタイム無",
"template-finishOnlyTime": "ゲーム終了 クリアタイムだけ別でツイート",
"template-running": "ゲーム中",
"template-backup": "バックアップ挿入"
}
15 changes: 15 additions & 0 deletions app/routes/_index/quote-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { create } from "zustand";

export const useQuoteStore = create<{
twitterId?: string;
blueskyId?: string;
setQuote: (args: { twitterId?: string; blueskyId?: string }) => void;
clearQuote: () => void;
}>((set) => ({
setQuote: ({ twitterId, blueskyId }) => {
set({ twitterId, blueskyId });
},
clearQuote: () => {
set({ twitterId: undefined, blueskyId: undefined });
},
}));
28 changes: 22 additions & 6 deletions app/routes/_index/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { useTranslation } from "react-i18next";
import { SignOutButton } from "./sign-out-button";
import { getBlueskyEnabled, post } from "../../api/bluesky.server";
import fs from "node:fs/promises";
import { getRuns } from "../../api/runs.server";

interface Post {
twitterId?: string;
Expand Down Expand Up @@ -76,11 +77,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {

posts.sort((a, b) => b.postedAt.getTime() - a.postedAt.getTime());

const rundata = await getRuns();

return json({
session,
posts,
twitterUsername: env.TWITTER_USERNAME,
blueskyUsername: env.BLUESKY_USERNAME,
runs: rundata,
});
};

Expand Down Expand Up @@ -131,6 +135,8 @@ const actionSchema = zfd.formData({
service: zfd.repeatableOfType(zfd.text(z.enum(["twitter", "bluesky"]))),
replyTwitterId: zfd.text(z.string().optional()),
replyBlueskyId: zfd.text(z.string().optional()),
quoteTwitterId: zfd.text(z.string().optional()),
quoteBlueskyId: zfd.text(z.string().optional()),
});

export const action = async ({ request }: ActionFunctionArgs) => {
Expand All @@ -148,8 +154,14 @@ export const action = async ({ request }: ActionFunctionArgs) => {
)
);

const { text, service, replyTwitterId, replyBlueskyId } =
actionSchema.parse(formData);
const {
text,
service,
replyTwitterId,
replyBlueskyId,
quoteTwitterId,
quoteBlueskyId,
} = actionSchema.parse(formData);

const postOnTwitter = service.includes("twitter");
const postOnBluesky = service.includes("bluesky");
Expand All @@ -170,16 +182,20 @@ export const action = async ({ request }: ActionFunctionArgs) => {
postOnTwitter &&
replyTwitterId &&
getTwitterEnabled() &&
sendReply(replyTwitterId, text ?? "", filePaths),
sendReply(replyTwitterId, text ?? "", filePaths, quoteTwitterId),
postOnBluesky &&
replyBlueskyId &&
getBlueskyEnabled() &&
post(text ?? "", filePaths, replyBlueskyId),
post(text ?? "", filePaths, replyBlueskyId, quoteBlueskyId),
]);
} else {
await Promise.all([
postOnTwitter && getTwitterEnabled() && tweet(text ?? "", filePaths),
postOnBluesky && getBlueskyEnabled() && post(text ?? "", filePaths),
postOnTwitter &&
getTwitterEnabled() &&
tweet(text ?? "", filePaths, quoteTwitterId),
postOnBluesky &&
getBlueskyEnabled() &&
post(text ?? "", filePaths, undefined, quoteBlueskyId),
]);
}

Expand Down
66 changes: 66 additions & 0 deletions app/routes/_index/templates.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { Run } from "../../api/runs.server";

interface Template {
label:
| "setup"
| "finishWithTime"
| "finishWithoutTime"
| "finishOnlyTime"
| "running"
| "backup";
apply: (run: Run) => string;
}

const presentRunners = (runners: Run["runner"]) => {
return runners
.map((runner) => {
const name = runner.username.replaceAll("@", "@ ");
return `${name}さん(@${runner.twitterid})`;
})
.join("、");
};

export const templates: Template[] = [
{
label: "setup",
apply: (run) =>
`次のタイムアタックは『${run.gamename}』\nカテゴリーは「${
run.category
}」\n走者は${presentRunners(
run.runner
)}です。\n\n配信はこちらから⇒https://www.twitch.tv/rtainjapan\n#RTAinJapan`,
},
{
label: "finishWithTime",
apply: (run) =>
`『${run.gamename}』のカテゴリー「${
run.category
}」RTA\n走者の${presentRunners(
run.runner
)}お疲れさまでした。\nクリアタイムはxx:xx:xxでした。\n\n配信はこちらから⇒https://www.twitch.tv/rtainjapan\n#RTAinJapan`,
},
{
label: "finishWithoutTime",
apply: (run) =>
`『${run.gamename}』のカテゴリー「${
run.category
}」RTA\n走者の${presentRunners(
run.runner
)}お疲れさまでした。\n\n配信はこちらから⇒https://www.twitch.tv/rtainjapan\n#RTAinJapan`,
},
{
label: "finishOnlyTime",
apply: (run) =>
`『${run.gamename}』\nクリアタイムは次の通りです\n\n\n配信はこちらから⇒https://www.twitch.tv/rtainjapan\n#RTAinJapan`,
},
{
label: "running",
apply: (run) =>
`現在のタイムアタックは『${run.gamename}』\n\n\n\n配信はこちらから⇒https://www.twitch.tv/rtainjapan\n#RTAinJapan`,
},
{
label: "backup",
apply: (run) =>
`【バックアップ追加のお知らせ】\n「xxx」の終了後、バックアップとして『${run.gamename}』を追加いたします。\n\n\n配信はこちらから⇒https://www.twitch.tv/rtainjapan\n#RTAinJapan`,
},
] as const;
Loading

0 comments on commit 77c876f

Please sign in to comment.