mirror of
https://github.com/FxEmbed/FxEmbed.git
synced 2026-10-02 01:54:54 +08:00
Merge branch 'main' into fix/bluesky-external-card-thumbnail
This commit is contained in:
@@ -46,12 +46,12 @@ Paths use `/profile/` before the handle, matching other FxBluesky profile URLs.
|
||||
|
||||
All parameters are optional.
|
||||
|
||||
| Parameter | Applies to | Description |
|
||||
| ------------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `count` | Timeline and media | Number of items to fetch, **1–100** (default **90**). |
|
||||
| `with_replies` or `withReplies` | Timeline only | Include reply posts. |
|
||||
| `safe` | Timeline and media | Hides posts (and quoted bodies) marked sensitive. |
|
||||
| `lang` or `language` | Timeline and media | Requests for automatic translation of content when available. Currently it's a bit iffy whether it works or not, so don't be surprised if things aren't translated just yet. |
|
||||
| Parameter | Applies to | Description |
|
||||
| ------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `count` | Timeline and media | Number of items to fetch, **1–100** (default **90**). |
|
||||
| `with_replies` or `withReplies` | Timeline and Bluesky media | Include reply posts. Bluesky media feeds exclude replies by default. |
|
||||
| `safe` | Timeline and media | Hides posts (and quoted bodies) marked sensitive. |
|
||||
| `lang` or `language` | Timeline and media | Requests for automatic translation of content when available. Currently it's a bit iffy whether it works or not, so don't be surprised if things aren't translated just yet. |
|
||||
|
||||
Example (X):
|
||||
|
||||
|
||||
@@ -17,6 +17,12 @@ https://d.fxtwitter.com/user/status/123456789/photo/3
|
||||
https://d.fixupx.com/user/status/123456789/video/2
|
||||
```
|
||||
|
||||
For Bluesky image posts, use the same `/photo/N` suffix:
|
||||
|
||||
```
|
||||
https://fxbsky.app/profile/user.bsky.social/post/abc123/photo/2
|
||||
```
|
||||
|
||||
<img
|
||||
src="/guide/readme/selectphoto.png"
|
||||
alt="Discord embed showing a specific photo or video from a post"
|
||||
|
||||
Generated
+24
-8
@@ -25,13 +25,13 @@
|
||||
"@aws-sdk/client-s3": "^3.1136.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1136.0",
|
||||
"@cloudflare/vitest-pool-workers": "^0.22.0",
|
||||
"@cloudflare/workers-types": "^5.20260919.1",
|
||||
"@cloudflare/workers-types": "^5.20260920.1",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@microsoft/eslint-formatter-sarif": "^3.1.0",
|
||||
"@sentry/esbuild-plugin": "^5.4.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.70.0",
|
||||
"@typescript-eslint/parser": "^8.70.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"dotenv": "^18.0.1",
|
||||
"eslint": "^10.10.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-config-typescript": "^3.0.0",
|
||||
@@ -881,9 +881,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudflare/workers-types": {
|
||||
"version": "5.20260919.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260919.1.tgz",
|
||||
"integrity": "sha512-xUdKJoTHhNcXwnBrfrM1STkzm3oSUc9cRVzP8fO5NyJZ/UtnDnWZHDk9z8IkTnbC2WRWFAS87HICmqZhbHDvQw==",
|
||||
"version": "5.20260920.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260920.1.tgz",
|
||||
"integrity": "sha512-wP7wbKqfQjFRGhE/tvu7bwvmBBTqJjvrETTvNzF4u5oolVEXt0gy6JxbqcUC2Khz0Cy+M/q7rhOOODng8VpnPw==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
@@ -2962,6 +2962,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/bundler-plugins/node_modules/dotenv": {
|
||||
"version": "17.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli": {
|
||||
"version": "2.58.6",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.6.tgz",
|
||||
@@ -4499,11 +4512,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||
"version": "18.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.1.tgz",
|
||||
"integrity": "sha512-0eR4m4D/jH5eaI3evo2ZqqMii5mrTCR12v12VChdie6O1gPHs7XAjzTep0CWc1Bd+oNx3vVfbi734dK4zghigw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"dotenv": "dist/index.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
+2
-2
@@ -36,13 +36,13 @@
|
||||
"@aws-sdk/client-s3": "^3.1136.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1136.0",
|
||||
"@cloudflare/vitest-pool-workers": "^0.22.0",
|
||||
"@cloudflare/workers-types": "^5.20260919.1",
|
||||
"@cloudflare/workers-types": "^5.20260920.1",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@microsoft/eslint-formatter-sarif": "^3.1.0",
|
||||
"@sentry/esbuild-plugin": "^5.4.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.70.0",
|
||||
"@typescript-eslint/parser": "^8.70.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"dotenv": "^18.0.1",
|
||||
"eslint": "^10.10.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-config-typescript": "^3.0.0",
|
||||
|
||||
@@ -143,6 +143,7 @@ async function blueskyAuthorFeedSearchPage(
|
||||
filter: BlueskyAuthorFeedFilter;
|
||||
language?: string;
|
||||
groupThreads?: boolean;
|
||||
excludeReplies?: boolean;
|
||||
},
|
||||
host: BlueskyBuildHost
|
||||
): Promise<APISearchResultsBluesky | APIGroupedSearchResultsBluesky> {
|
||||
@@ -163,7 +164,8 @@ async function blueskyAuthorFeedSearchPage(
|
||||
return { code: 500, results: [], cursor: { top: null, bottom: null } };
|
||||
}
|
||||
|
||||
const feed = result.data.feed ?? [];
|
||||
const rawFeed = result.data.feed ?? [];
|
||||
const feed = options.excludeReplies ? rawFeed.filter(item => !item.post?.record?.reply) : rawFeed;
|
||||
const nextCursor = result.data.cursor ?? null;
|
||||
const results = options.groupThreads
|
||||
? await feedViewPostsToGroupedTimeline(host, feed, options.language)
|
||||
@@ -206,7 +208,7 @@ export const blueskyProfileStatusesAPI = async (
|
||||
|
||||
export const blueskyProfileMediaAPI = async (
|
||||
actor: string,
|
||||
options: { count: number; cursor: string | null; language?: string },
|
||||
options: { count: number; cursor: string | null; language?: string; withReplies?: boolean },
|
||||
host: BlueskyBuildHost
|
||||
): Promise<APISearchResultsBluesky> =>
|
||||
blueskyAuthorFeedSearchPage(
|
||||
@@ -215,7 +217,8 @@ export const blueskyProfileMediaAPI = async (
|
||||
count: options.count,
|
||||
cursor: options.cursor,
|
||||
filter: 'posts_with_media',
|
||||
language: options.language
|
||||
language: options.language,
|
||||
excludeReplies: options.withReplies === false
|
||||
},
|
||||
host
|
||||
);
|
||||
@@ -323,6 +326,7 @@ export const blueskyProfileMediaAPIPaginated = async (
|
||||
actor: string,
|
||||
maxTotal: number,
|
||||
host: BlueskyBuildHost,
|
||||
withReplies = false,
|
||||
language?: string
|
||||
): Promise<APISearchResultsBluesky> => {
|
||||
const target = Math.min(BLUESKY_PROFILE_FEED_TARGET_CAP, Math.max(1, maxTotal));
|
||||
@@ -337,7 +341,7 @@ export const blueskyProfileMediaAPIPaginated = async (
|
||||
pages += 1;
|
||||
const page = await blueskyProfileMediaAPI(
|
||||
actor,
|
||||
{ count: BLUESKY_PROFILE_FEED_PER_PAGE, cursor, language },
|
||||
{ count: BLUESKY_PROFILE_FEED_PER_PAGE, cursor, language, withReplies },
|
||||
host
|
||||
);
|
||||
|
||||
@@ -358,10 +362,6 @@ export const blueskyProfileMediaAPIPaginated = async (
|
||||
anySuccessfulPage = true;
|
||||
lastCursors = page.cursor;
|
||||
|
||||
if (page.results.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const r of page.results) {
|
||||
if (seenIds.has(r.id)) continue;
|
||||
seenIds.add(r.id);
|
||||
|
||||
@@ -260,6 +260,13 @@ export const handleStatus = async (
|
||||
console.log(api);
|
||||
return returnError(c, Strings.ERROR_API_FAIL);
|
||||
}
|
||||
if (
|
||||
provider === DataProvider.Bluesky &&
|
||||
mediaNumber &&
|
||||
!status.media?.photos?.[mediaNumber - 1]
|
||||
) {
|
||||
return c.text('Photo not found', 404);
|
||||
}
|
||||
/* Should sensitive statuses be allowed Instant View? */
|
||||
let useIV = false;
|
||||
|
||||
|
||||
@@ -7,6 +7,18 @@ export const getPbsProxyDomain = (): string | null =>
|
||||
export const shouldProxyTelegramPbsPhotos = (isTelegram: boolean): boolean =>
|
||||
isTelegram && experimentCheck(Experiment.TELEGRAM_PBS_PROXY, !!getPbsProxyDomain());
|
||||
|
||||
export const formatTelegramTwitterPostPhotoUrl = (url: string, isTelegram: boolean): string => {
|
||||
if (!isTelegram || !url) return url;
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.hostname !== 'pbs.twimg.com' || !u.pathname.includes('/media/')) return url;
|
||||
u.searchParams.set('name', 'large');
|
||||
return u.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
export const proxyTwitterPostPhotoUrl = (url: string, enabled: boolean): string => {
|
||||
const domain = getPbsProxyDomain();
|
||||
if (!enabled || !domain || !url) return url;
|
||||
|
||||
@@ -19,7 +19,21 @@ bluesky.use(trimTrailingSlash());
|
||||
bluesky.get('/owoembed', oembed);
|
||||
bluesky.get('/api/v1/statuses/:snowcode', activityRequest);
|
||||
bluesky.get('/:prefix/:handle/post/:id', blueskyStatusRequest);
|
||||
bluesky.get('/:prefix/:handle/post/:id/photo/:mediaNumber{[1-9][0-9]*}', blueskyStatusRequest);
|
||||
bluesky.get(
|
||||
'/:prefix/:handle/post/:id/photo/:mediaNumber{[1-9][0-9]*}/:language',
|
||||
blueskyStatusRequest
|
||||
);
|
||||
bluesky.get('/profile/:handle/post/:id', blueskyStatusRequest);
|
||||
bluesky.get('/profile/:handle/post/:id/photo/:mediaNumber{[1-9][0-9]*}', blueskyStatusRequest);
|
||||
bluesky.get(
|
||||
'/profile/:handle/post/:id/photo/:mediaNumber{[1-9][0-9]*}/:language',
|
||||
blueskyStatusRequest
|
||||
);
|
||||
bluesky.get(
|
||||
'/:prefix/profile/:handle/post/:id/photo/:mediaNumber{[1-9][0-9]*}/:language',
|
||||
blueskyStatusRequest
|
||||
);
|
||||
bluesky.get('/:prefix/profile/:handle/post/:id/:language', blueskyStatusRequest);
|
||||
bluesky.get('/profile/:handle/post/:id/:language', blueskyStatusRequest);
|
||||
bluesky.get('/profile/:handle/feed.xml', profileFeedRssBluesky);
|
||||
|
||||
@@ -49,16 +49,6 @@ function parseFeedQuery(c: Context): {
|
||||
};
|
||||
}
|
||||
|
||||
function parseMediaFeedQuery(c: Context): { count: number; safe: boolean; language?: string } {
|
||||
const q = c.req.query();
|
||||
const language = feedRequestLanguage(c);
|
||||
return {
|
||||
count: clampCount(q.count),
|
||||
safe: isParamTruthy(q.safe),
|
||||
...(language ? { language } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function feedAuthorName(handle: string, results: APIBlueskyStatus[]): string {
|
||||
const screen = handle.replace(/^@/, '');
|
||||
const fromPost = results.find(s => s.author?.name?.trim())?.author?.name?.trim();
|
||||
@@ -157,12 +147,13 @@ async function serveFeed(
|
||||
q.language
|
||||
);
|
||||
} else {
|
||||
const q = parseMediaFeedQuery(c);
|
||||
const q = parseFeedQuery(c);
|
||||
omitSensitive = q.safe;
|
||||
apiResult = await blueskyProfileMediaAPIPaginated(
|
||||
handle,
|
||||
q.count,
|
||||
blueskyBuildHostFromContext(c),
|
||||
q.withReplies,
|
||||
q.language
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { isHorizonEmbedParam } from '../../twitter/router';
|
||||
|
||||
export const blueskyStatusRequest = async (c: Context) => {
|
||||
console.log('bluesky status request!!!');
|
||||
const { handle, id, language } = c.req.param();
|
||||
const { handle, id, mediaNumber, language } = c.req.param();
|
||||
const actualId = id.match(/\w+/g)?.[0] ?? '';
|
||||
|
||||
const userAgent = c.req.header('User-Agent') || '';
|
||||
@@ -73,7 +73,7 @@ export const blueskyStatusRequest = async (c: Context) => {
|
||||
c,
|
||||
actualId,
|
||||
handle,
|
||||
undefined, //mediaNumber ? parseInt(mediaNumber) : undefined,
|
||||
mediaNumber ? parseInt(mediaNumber, 10) : undefined,
|
||||
userAgent,
|
||||
flags,
|
||||
language,
|
||||
|
||||
@@ -10,7 +10,11 @@ import type { APIStatusTombstone, APITwitterStatus } from '../realms/api/schemas
|
||||
import { isTombstone } from '../helpers/tombstone';
|
||||
import { getVideoTranscodeDomain, getVideoTranscodeDomainBluesky } from '../helpers/transcode';
|
||||
import { experimentCheck, Experiment } from '../experiments';
|
||||
import { proxyTwitterPostPhotoUrl, shouldProxyTelegramPbsPhotos } from '../helpers/pbsProxy';
|
||||
import {
|
||||
formatTelegramTwitterPostPhotoUrl,
|
||||
proxyTwitterPostPhotoUrl,
|
||||
shouldProxyTelegramPbsPhotos
|
||||
} from '../helpers/pbsProxy';
|
||||
|
||||
/**
|
||||
* Check if the tweet text is essentially just an article URL with no meaningful additional content.
|
||||
@@ -69,7 +73,7 @@ const populateUserLinks = (text: string, status: APIStatus): string => {
|
||||
return text;
|
||||
};
|
||||
|
||||
const generateStatusMedia = (status: APIStatus, proxyPbs: boolean): string => {
|
||||
const generateStatusMedia = (status: APIStatus, proxyPbs: boolean, isTelegram: boolean): string => {
|
||||
let media = '';
|
||||
if (status.media?.all?.length) {
|
||||
status.media.all.forEach(mediaItem => {
|
||||
@@ -86,6 +90,9 @@ const generateStatusMedia = (status: APIStatus, proxyPbs: boolean): string => {
|
||||
case 'photo':
|
||||
// eslint-disable-next-line no-case-declarations
|
||||
const { altText } = mediaItem as APIPhoto;
|
||||
if (status.provider === DataProvider.Twitter) {
|
||||
url = formatTelegramTwitterPostPhotoUrl(url, isTelegram);
|
||||
}
|
||||
url = proxyTwitterPostPhotoUrl(url, proxyPbs);
|
||||
media += `<img src="{url}" {altText}/>`.format({
|
||||
altText: altText ? `alt="${altText}"` : '',
|
||||
@@ -332,7 +339,8 @@ const generateStatus = (
|
||||
language: string,
|
||||
isQuote = false,
|
||||
authorActionType: AuthorActionType | null,
|
||||
proxyPbs: boolean
|
||||
proxyPbs: boolean,
|
||||
isTelegram: boolean
|
||||
): string => {
|
||||
if (isTombstone(status)) {
|
||||
const inner = `<i>${sanitizeText(status.message)}</i>`;
|
||||
@@ -383,7 +391,7 @@ const generateStatus = (
|
||||
<!-- Embed article (if applicable) -->
|
||||
${articleHtml || notApplicableComment}
|
||||
<!-- Embed media -->
|
||||
${generateStatusMedia(status, proxyPbs)}
|
||||
${generateStatusMedia(status, proxyPbs, isTelegram)}
|
||||
<!-- Translated text (if applicable) -->
|
||||
${translatedText ? translatedText : notApplicableComment}
|
||||
<!-- Inline author (if applicable) -->
|
||||
@@ -399,7 +407,7 @@ const generateStatus = (
|
||||
!isQuote && status.quote
|
||||
? isTombstone(status.quote)
|
||||
? `<blockquote><i>${sanitizeText(status.quote.message)}</i></blockquote>`
|
||||
: generateStatus(status.quote, author, language, true, null, proxyPbs)
|
||||
: generateStatus(status.quote, author, language, true, null, proxyPbs, isTelegram)
|
||||
: notApplicableComment
|
||||
}`.format({
|
||||
quoteHeader: isQuote
|
||||
@@ -524,7 +532,8 @@ export const renderInstantView = (properties: RenderProperties): ResponseInstruc
|
||||
properties?.targetLanguage ?? 'en',
|
||||
false,
|
||||
authorAction,
|
||||
proxyPbs
|
||||
proxyPbs,
|
||||
isTelegram
|
||||
);
|
||||
})
|
||||
.join('')}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { app } from '../src/worker';
|
||||
import authorFeed from './fixtures/bluesky/author-feed.json';
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
test.each(['media.xml', 'media.atom.xml'])(
|
||||
'%s excludes replies by default and includes them with with_replies',
|
||||
async format => {
|
||||
const makePost = (rkey: string, reply = false) => {
|
||||
const entry = structuredClone(authorFeed.feed[0]);
|
||||
entry.post.uri = `at://did:plc:test111/app.bsky.feed.post/${rkey}`;
|
||||
entry.post.record.text = rkey;
|
||||
if (reply) {
|
||||
Object.assign(entry.post.record, {
|
||||
reply: { parent: { uri: 'at://did:plc:test111/app.bsky.feed.post/parent' } }
|
||||
});
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async input => {
|
||||
const url = new URL(typeof input === 'string' ? input : input.url);
|
||||
if (url.pathname.endsWith('getAuthorFeed')) {
|
||||
const secondPage = url.searchParams.has('cursor');
|
||||
return Response.json(
|
||||
secondPage
|
||||
? { feed: [makePost('next')] }
|
||||
: { feed: [makePost('reply', true), makePost('main')], cursor: 'next-page' }
|
||||
);
|
||||
}
|
||||
if (url.pathname.endsWith('getProfiles')) {
|
||||
return Response.json({ profiles: [authorFeed.feed[0].post.author] });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
const feedUrl = `https://fxbsky.app/profile/author.test/${format}?count=2`;
|
||||
const withoutReplies = await app.request(feedUrl);
|
||||
expect(withoutReplies.status).toBe(200);
|
||||
const defaultXml = await withoutReplies.text();
|
||||
expect(defaultXml).toContain('/post/main');
|
||||
expect(defaultXml).toContain('/post/next');
|
||||
expect(defaultXml).not.toContain('/post/reply');
|
||||
|
||||
const withReplies = await app.request(`${feedUrl}&with_replies=1`);
|
||||
expect(withReplies.status).toBe(200);
|
||||
const repliesXml = await withReplies.text();
|
||||
expect(repliesXml).toContain('/post/reply');
|
||||
expect(repliesXml).toContain('/post/main');
|
||||
expect(repliesXml).not.toContain('/post/next');
|
||||
}
|
||||
);
|
||||
|
||||
test('media feed skips a full page of replies, including handle-based parent URIs', async () => {
|
||||
const makePost = (rkey: string, reply: boolean) => {
|
||||
const entry = structuredClone(authorFeed.feed[0]);
|
||||
entry.post.uri = `at://did:plc:test111/app.bsky.feed.post/${rkey}`;
|
||||
entry.post.record.text = rkey;
|
||||
if (reply) {
|
||||
Object.assign(entry.post.record, {
|
||||
reply: { parent: { uri: 'at://author.test/app.bsky.feed.post/parent' } }
|
||||
});
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async input => {
|
||||
const url = new URL(typeof input === 'string' ? input : input.url);
|
||||
if (url.pathname.endsWith('getAuthorFeed')) {
|
||||
return Response.json(
|
||||
url.searchParams.has('cursor')
|
||||
? { feed: [makePost('main', false)] }
|
||||
: { feed: [makePost('reply', true)], cursor: 'next-page' }
|
||||
);
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
const feedUrl = 'https://fxbsky.app/profile/author.test/media.xml?count=1';
|
||||
const withoutReplies = await app.request(feedUrl);
|
||||
expect(withoutReplies.status).toBe(200);
|
||||
const defaultXml = await withoutReplies.text();
|
||||
expect(defaultXml).toContain('/post/main');
|
||||
expect(defaultXml).not.toContain('/post/reply');
|
||||
|
||||
const withReplies = await app.request(`${feedUrl}&with_replies=1`);
|
||||
expect(withReplies.status).toBe(200);
|
||||
const repliesXml = await withReplies.text();
|
||||
expect(repliesXml).toContain('/post/reply');
|
||||
expect(repliesXml).not.toContain('/post/main');
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { app } from '../src/worker';
|
||||
import threadMultiImage from './fixtures/bluesky/thread-multi-image.json';
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
function mockMultiImageThread() {
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async input => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url.includes('app.bsky.feed.getPostThread')) return Response.json(threadMultiImage);
|
||||
if (url.includes('app.bsky.actor.getProfiles')) return Response.json({ profiles: [] });
|
||||
if (url.includes('mosaic.fxbsky.app')) return Response.json({ ok: true });
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
}
|
||||
|
||||
test.each([
|
||||
['photo/1', 'https://cdn.bsky.app/full1'],
|
||||
['photo/2', 'https://cdn.bsky.app/full2'],
|
||||
['photo/2/en', 'https://cdn.bsky.app/full2']
|
||||
])('Bluesky /%s embeds only the selected image', async (path, expected) => {
|
||||
mockMultiImageThread();
|
||||
const response = await app.request(`https://fxbsky.app/profile/pics.test/post/rkeypics/${path}`, {
|
||||
headers: { 'User-Agent': 'Twitterbot' }
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const html = await response.text();
|
||||
expect(html).toContain(`<meta property="og:image" content="${expected}"`);
|
||||
});
|
||||
|
||||
test('Bluesky prefixed localized photo URL embeds the selected image', async () => {
|
||||
mockMultiImageThread();
|
||||
const response = await app.request(
|
||||
'https://fxbsky.app/bsky/profile/pics.test/post/rkeypics/photo/2/en',
|
||||
{ headers: { 'User-Agent': 'Twitterbot' } }
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const html = await response.text();
|
||||
expect(html).toContain('<meta property="og:image" content="https://cdn.bsky.app/full2"');
|
||||
expect(html).not.toContain('<meta property="og:image" content="https://cdn.bsky.app/full1"');
|
||||
});
|
||||
|
||||
test('Bluesky /photo/N rejects an unavailable image', async () => {
|
||||
mockMultiImageThread();
|
||||
const response = await app.request(
|
||||
'https://fxbsky.app/profile/pics.test/post/rkeypics/photo/99',
|
||||
{
|
||||
headers: { 'User-Agent': 'Twitterbot' }
|
||||
}
|
||||
);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
test('Bluesky photo selection reaches the Discord activity response', async () => {
|
||||
mockMultiImageThread();
|
||||
const response = await app.request('https://fxbsky.app/profile/pics.test/post/rkeypics/photo/2', {
|
||||
headers: { 'User-Agent': 'Discordbot/2.0' }
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const html = await response.text();
|
||||
const activityHref = html.match(
|
||||
/<link href='([^']+)' rel='alternate' type='application\/activity\+json'/
|
||||
)?.[1];
|
||||
expect(activityHref).toBeDefined();
|
||||
const snowcode = activityHref?.split('/').at(-1);
|
||||
const activity = await app.request(`https://fxbsky.app/api/v1/statuses/${snowcode}`, {
|
||||
headers: { 'User-Agent': 'Discordbot/2.0' }
|
||||
});
|
||||
expect(activity.status).toBe(200);
|
||||
const body = await activity.text();
|
||||
expect(body).toContain('https://cdn.bsky.app/full2');
|
||||
expect(body).not.toContain('https://cdn.bsky.app/full1');
|
||||
});
|
||||
+25
-1
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { proxyTwitterPostPhotoUrl } from '../src/helpers/pbsProxy';
|
||||
import {
|
||||
formatTelegramTwitterPostPhotoUrl,
|
||||
proxyTwitterPostPhotoUrl
|
||||
} from '../src/helpers/pbsProxy';
|
||||
|
||||
describe('proxyTwitterPostPhotoUrl', () => {
|
||||
const mediaUrl = 'https://pbs.twimg.com/media/abc.jpg?name=orig';
|
||||
@@ -24,3 +27,24 @@ describe('proxyTwitterPostPhotoUrl', () => {
|
||||
expect(proxyTwitterPostPhotoUrl(mediaUrl, false)).toBe(mediaUrl);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTelegramTwitterPostPhotoUrl', () => {
|
||||
const mediaUrl = 'https://pbs.twimg.com/media/abc.jpg?name=orig&format=jpg';
|
||||
const profileUrl = 'https://pbs.twimg.com/profile_images/1/normal.jpg';
|
||||
|
||||
it('requests a Telegram-friendly size for X post photos', () => {
|
||||
expect(formatTelegramTwitterPostPhotoUrl(mediaUrl, true)).toBe(
|
||||
'https://pbs.twimg.com/media/abc.jpg?name=large&format=jpg'
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves profile images and other hosts unchanged', () => {
|
||||
expect(formatTelegramTwitterPostPhotoUrl(profileUrl, true)).toBe(profileUrl);
|
||||
const blueskyUrl = 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:abc@jpeg';
|
||||
expect(formatTelegramTwitterPostPhotoUrl(blueskyUrl, true)).toBe(blueskyUrl);
|
||||
});
|
||||
|
||||
it('leaves non-Telegram requests unchanged', () => {
|
||||
expect(formatTelegramTwitterPostPhotoUrl(mediaUrl, false)).toBe(mediaUrl);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,8 @@ test('Telegram Instant View remains enabled for multi-photo mosaic posts', async
|
||||
|
||||
expect(isInstantViewHtml(html)).toBe(true);
|
||||
expect(html.match(/<img /g)?.length).toBeGreaterThanOrEqual(3);
|
||||
expect(html.match(/name=large/g)?.length).toBeGreaterThanOrEqual(3);
|
||||
expect(html).not.toContain('name=orig');
|
||||
});
|
||||
|
||||
test('i. prefix still forces Instant View for a single video', async () => {
|
||||
|
||||
Reference in New Issue
Block a user