Skip to content

Commit cb13e05

Browse files
committed
Bot player for projects
1 parent b0580e3 commit cb13e05

5 files changed

Lines changed: 67 additions & 36 deletions

File tree

apps/gif-service/src/hasura.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,21 +37,31 @@ export interface ProjectRecord {
3737
}
3838

3939
/**
40-
* Look up a public project by id. Returns null when the project is missing or
41-
* private: the `public` role's select permission filters to is_public, so a
42-
* private project is simply absent from the result rather than an error.
40+
* Look up a public project from its canonical /u/<userSlug>/<projectSlug> URL.
41+
*
42+
* Project slug is unique only per owner (user slug is globally unique), so the
43+
* lookup is scoped by the owner relationship rather than projectSlug alone.
44+
* Returns null when the project is missing or private: the `public` role's
45+
* select permission filters to is_public, so a private project is simply absent
46+
* rather than an error.
4347
*/
44-
export async function fetchProject(projectId: string): Promise<ProjectRecord | null> {
48+
export async function fetchProject(
49+
userSlug: string,
50+
projectSlug: string,
51+
): Promise<ProjectRecord | null> {
4552
const query = `
46-
query ($id: uuid!) {
47-
project(where: { project_id: { _eq: $id } }, limit: 1) {
53+
query ($userSlug: String!, $projectSlug: String!) {
54+
project(
55+
where: { slug: { _eq: $projectSlug }, owner: { slug: { _eq: $userSlug } } }
56+
limit: 1
57+
) {
4858
lang
4959
code
5060
title
5161
}
5262
}
5363
`;
54-
const data = await gql<{ project: ProjectRecord[] }>(query, { id: projectId });
64+
const data = await gql<{ project: ProjectRecord[] }>(query, { userSlug, projectSlug });
5565
return data.project[0] ?? null;
5666
}
5767

apps/gif-service/src/routes/project.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,25 @@ const router = Router();
88

99
type Format = 'gif' | 'mp4';
1010

11-
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11+
const SLUG = /^[a-z0-9-]+$/i;
1212

13-
function readProjectId(req: Request): string | null {
14-
const id: unknown = typeof req.body === 'object' && req.body ? req.body.projectId : undefined;
15-
return typeof id === 'string' && UUID.test(id) ? id.toLowerCase() : null;
13+
interface ProjectRef {
14+
userSlug: string;
15+
projectSlug: string;
16+
}
17+
18+
function readProjectRef(req: Request): ProjectRef | null {
19+
const body = typeof req.body === 'object' && req.body ? req.body : {};
20+
const { userSlug, projectSlug } = body;
21+
if (
22+
typeof userSlug === 'string' &&
23+
typeof projectSlug === 'string' &&
24+
SLUG.test(userSlug) &&
25+
SLUG.test(projectSlug)
26+
) {
27+
return { userSlug: userSlug.toLowerCase(), projectSlug: projectSlug.toLowerCase() };
28+
}
29+
return null;
1630
}
1731

1832
/**
@@ -23,13 +37,13 @@ function readProjectId(req: Request): string | null {
2337
*/
2438
async function handle(format: Format, req: Request, res: Response): Promise<void> {
2539
try {
26-
const projectId = readProjectId(req);
27-
if (!projectId) {
28-
res.status(400).json({ error: 'No valid projectId provided' });
40+
const ref = readProjectRef(req);
41+
if (!ref) {
42+
res.status(400).json({ error: 'No valid project reference provided' });
2943
return;
3044
}
3145

32-
const project = await fetchProject(projectId);
46+
const project = await fetchProject(ref.userSlug, ref.projectSlug);
3347
if (!project) {
3448
res.status(404).json({ error: 'Project not found or not public' });
3549
return;
@@ -59,7 +73,7 @@ async function handle(format: Format, req: Request, res: Response): Promise<void
5973
await generator.initialize();
6074

6175
console.log(
62-
`Generating ${format.toUpperCase()} from project ${projectId} (lang=${project.lang})...`,
76+
`Generating ${format.toUpperCase()} from project ${ref.userSlug}/${ref.projectSlug} (lang=${project.lang})...`,
6377
);
6478
if (format === 'mp4') {
6579
const mp4 = await generator.generateMp4FromTAP(tap, machineType);

apps/mastodon-bot/src/index.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { config, assertRuntimeConfig } from './config.js';
22
import { htmlToBasic } from './basic.js';
3-
import { extractProjectId } from './project.js';
3+
import { extractProjectRef } from './project.js';
44
import { basicToMedia, projectToMedia } from './media.js';
55
import { loadState, saveState } from './state.js';
66
import {
@@ -33,26 +33,27 @@ async function handleMention(self: MastodonAccount, n: MastodonNotification): Pr
3333

3434
// A project link takes precedence over inline BASIC: the source already
3535
// lives on the site, so render it directly rather than parsing the toot.
36-
const projectId = extractProjectId(status.content, config.projectHost);
37-
const code = projectId ? null : htmlToBasic(status.content);
38-
if (!projectId && !code) {
36+
const projectRef = extractProjectRef(status.content, config.projectHost);
37+
const code = projectRef ? null : htmlToBasic(status.content);
38+
if (!projectRef && !code) {
3939
console.log(`Mention ${n.id} from @${status.account.acct}: no project link or BASIC found, skipping`);
4040
return;
4141
}
42+
const projectPath = projectRef ? `${projectRef.userSlug}/${projectRef.projectSlug}` : '';
4243
console.log(
43-
projectId
44-
? `Mention ${n.id} from @${status.account.acct}: project ${projectId}`
44+
projectRef
45+
? `Mention ${n.id} from @${status.account.acct}: project ${projectPath}`
4546
: `Mention ${n.id} from @${status.account.acct}: ${code!.split('\n').length} line(s)`,
4647
);
4748

4849
const visibility = replyVisibility(status.visibility);
4950

5051
if (config.dryRun) {
51-
console.log(projectId ? `[dry-run] would render project ${projectId}` : `[dry-run] would run:\n${code}`);
52+
console.log(projectRef ? `[dry-run] would render project ${projectPath}` : `[dry-run] would run:\n${code}`);
5253
return;
5354
}
5455

55-
const result = projectId ? await projectToMedia(projectId) : await basicToMedia(code!);
56+
const result = projectRef ? await projectToMedia(projectRef) : await basicToMedia(code!);
5657

5758
if (!result.ok) {
5859
await postReply({

apps/mastodon-bot/src/media.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { config } from './config.js';
2+
import { ProjectRef } from './project.js';
23

34
export type MediaResult =
45
| { ok: true; data: Buffer; contentType: string; filename: string; altText: string }
@@ -39,10 +40,10 @@ export function basicToMedia(code: string): Promise<MediaResult> {
3940
}
4041

4142
/** Render a public code.zxplay.org project via gif-service, returning an MP4 or an error. */
42-
export function projectToMedia(projectId: string): Promise<MediaResult> {
43+
export function projectToMedia(ref: ProjectRef): Promise<MediaResult> {
4344
return requestMedia(
4445
'/api/project-to-mp4',
45-
{ projectId },
46-
`${config.projectHost}/projects/${projectId}`,
46+
ref,
47+
`${config.projectHost}/u/${ref.userSlug}/${ref.projectSlug}`,
4748
);
4849
}

apps/mastodon-bot/src/project.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
1-
const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
1+
export interface ProjectRef {
2+
userSlug: string;
3+
projectSlug: string;
4+
}
5+
6+
const SLUG = '[a-z0-9-]+';
27

38
/**
49
* Find a code.zxplay.org project link in a status's HTML content and return its
5-
* id. Matches the canonical project URL, https://<host>/projects/<uuid>, whether
6-
* it appears as an <a href> or as plain text. Returns null when there is none.
7-
*
8-
* The prettier /u/<user>/<slug> URLs are not handled yet: they need a slug
9-
* lookup, so for now a sharer links the /projects/<id> form.
10+
* user/project slugs. Matches the canonical URL, https://<host>/u/<user>/<project>,
11+
* whether it appears as an <a href> or as plain text. Returns null when there
12+
* is no such link.
1013
*/
11-
export function extractProjectId(content: string, host: string): string | null {
14+
export function extractProjectRef(content: string, host: string): ProjectRef | null {
1215
const escapedHost = host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
13-
const re = new RegExp(`https?://${escapedHost}/projects/(${UUID})`, 'i');
16+
const re = new RegExp(`https?://${escapedHost}/u/(${SLUG})/(${SLUG})`, 'i');
1417
const match = content.match(re);
15-
return match ? match[1].toLowerCase() : null;
18+
return match
19+
? { userSlug: match[1].toLowerCase(), projectSlug: match[2].toLowerCase() }
20+
: null;
1621
}

0 commit comments

Comments
 (0)