diff --git a/backend/src/ingestion/adapters/base.ts b/backend/src/ingestion/adapters/base.ts index 791366b..aca6602 100644 --- a/backend/src/ingestion/adapters/base.ts +++ b/backend/src/ingestion/adapters/base.ts @@ -3,8 +3,7 @@ import type { ContentItem, TweetMediaItem, QuotedTweet, - TelegramMediaRef, - TelegramForwardedFrom + TelegramMediaRef } from '../../storage/db/types.js'; import { cleanHtml, toSummary } from '../clean.js'; @@ -29,10 +28,11 @@ export interface FetchedItem { /** Set by the Telegram adapter only — carries the channel/message info through to ContentItem.telegramMessage. Media is unresolved refs; publish.ts resolves them per the admin's configured telegramMediaMode. */ telegramMessage?: { channelName: string; - channelUsername: string; + channelUsername: string | null; + sourceChannelUsername: string; messageId: string; media: TelegramMediaRef[]; - forwardedFrom: TelegramForwardedFrom | null; + repostedByHandle: string | null; }; raw: unknown; } diff --git a/backend/src/ingestion/adapters/telegram.ts b/backend/src/ingestion/adapters/telegram.ts index 78e8ae8..be47038 100644 --- a/backend/src/ingestion/adapters/telegram.ts +++ b/backend/src/ingestion/adapters/telegram.ts @@ -16,7 +16,7 @@ // where those refs turn into an actual servable url. import type { Api } from 'telegram'; -import type { Source, TelegramMediaRef, TelegramForwardedFrom } from '../../storage/db/types.js'; +import type { Source, TelegramMediaRef } from '../../storage/db/types.js'; import type { SourceAdapter, FetchedItem } from './base.js'; import { logger } from '../../storage/db/logs.js'; import { getClient, fetchChannelMessages } from '../../telegram/client.js'; @@ -69,6 +69,12 @@ function refForMessage(message: TgMessage): TelegramMediaRef | null { return null; } +interface ForwardOrigin { + name: string; + /** Null when the origin has no public handle (e.g. a private channel/user, or a sender who hid their identity) — the card then falls back to showing just the name, with no avatar. */ + username: string | null; +} + /** * Detects a forwarded message and resolves where it came from. GramJS's `message.forward` * wraps the raw fwdFrom header using entities Telegram already sent alongside the same @@ -77,7 +83,7 @@ function refForMessage(message: TgMessage): TelegramMediaRef | null { * `.sender` the origin user. Falls back to fwdFrom.fromName for the rarer case where the * origin has no resolvable identity (e.g. a user who hid their account from forwards). */ -function detectForward(message: TgMessage): TelegramForwardedFrom | null { +function detectForward(message: TgMessage): ForwardOrigin | null { const fwd = message.fwdFrom; if (!fwd) return null; @@ -133,12 +139,14 @@ export const telegramAdapter: SourceAdapter = { // that means anything to a non-member — a private channel's t.me/c//... // link would make "click to open on Telegram" mostly useless, so v1 restricts to // public channels and fails soft otherwise (same style as youtube.ts's resolveFeedUrl). - const channelUsername: string | undefined = entity?.username; - if (!channelUsername) { + // This is always the channel we polled — used for the permalink and, on a forward, + // as the "Forwarded by @X" attribution — never the identity actually displayed. + const pollingChannelUsername: string | undefined = entity?.username; + if (!pollingChannelUsername) { logger.warn('telegram', `"${source.name}" has no public username — private channels aren't supported yet, skipping`); return []; } - const channelName: string = entity?.title ?? channelUsername; + const pollingChannelName: string = entity?.title ?? pollingChannelUsername; const items: FetchedItem[] = []; @@ -156,20 +164,30 @@ export const telegramAdapter: SourceAdapter = { if (!text && media.length === 0) continue; // nothing worth publishing (e.g. a service message) + // A forward displays as if it were authored by the ORIGIN channel/user — same + // treatment as a retweet, where tweet.authorName is always the original tweet's + // author, never the retweeter — with a "Forwarded by @" line + // (repostedByHandle) taking the place of the retweeter's own attribution. + const origin = detectForward(primary); + const channelName = origin?.name ?? pollingChannelName; + const channelUsername = origin ? origin.username : pollingChannelUsername; + const repostedByHandle = origin ? pollingChannelUsername : null; + items.push({ - title: firstLine || `Message from ${channelName}`, + title: firstLine || `Message from ${pollingChannelName}`, summary: text.slice(0, 500), body: text || null, images: [], videos: [], - link: `https://t.me/${channelUsername}/${group[0].id}`, + link: `https://t.me/${pollingChannelUsername}/${group[0].id}`, publishedAt: new Date(primary.date * 1000).toISOString(), telegramMessage: { channelName, channelUsername, + sourceChannelUsername: pollingChannelUsername, messageId: String(group[0].id), media, - forwardedFrom: detectForward(primary) + repostedByHandle }, raw: { messageIds: group.map((m) => m.id) } }); diff --git a/backend/src/pipeline/publish.ts b/backend/src/pipeline/publish.ts index 2ac1289..a158ac7 100644 --- a/backend/src/pipeline/publish.ts +++ b/backend/src/pipeline/publish.ts @@ -281,18 +281,28 @@ export async function publishDirect(item: ContentItem, settings: GlobalSettings) let telegramMessage: MergedArticle['telegramMessage'] = null; if (item.telegramMessage) { - const { channelUsername } = item.telegramMessage; - const avatar = await resolveTelegramAvatarUrl(channelUsername, settings.telegramMediaMode); + const { channelUsername, sourceChannelUsername } = item.telegramMessage; + + // The avatar shown is whoever is DISPLAYED (the origin channel on a forward) — if + // that has no public handle, there's no avatar to fetch at all, regardless of mode. + const avatar = channelUsername + ? await resolveTelegramAvatarUrl(channelUsername, settings.telegramMediaMode) + : { url: null, storedMediaId: null }; if (avatar.storedMediaId) storedMediaIds.push(avatar.storedMediaId); - const resolvedMedia = await resolveTelegramMedia(channelUsername, item.telegramMessage.media, settings.telegramMediaMode); + + // Attached media always lives on the polled channel's own copy of the message + // (forward or not), so media resolution uses sourceChannelUsername, never the + // (possibly different, possibly null) displayed channelUsername. + const resolvedMedia = await resolveTelegramMedia(sourceChannelUsername, item.telegramMessage.media, settings.telegramMediaMode); storedMediaIds.push(...resolvedMedia.storedMediaIds); + telegramMessage = { channelName: item.telegramMessage.channelName, channelUsername, channelAvatarUrl: avatar.url, sourceItemId: item.id, media: resolvedMedia.media, - forwardedFrom: item.telegramMessage.forwardedFrom + repostedByHandle: item.telegramMessage.repostedByHandle }; } diff --git a/backend/src/storage/db/types.ts b/backend/src/storage/db/types.ts index 3476469..47483dc 100644 --- a/backend/src/storage/db/types.ts +++ b/backend/src/storage/db/types.ts @@ -58,12 +58,6 @@ export interface TelegramMediaRef { height: number | null; } -/** Where a forwarded Telegram message originated — username is null when the origin has no public handle (e.g. a private channel/user, or a sender who hid their identity), in which case the card falls back to showing just the name. */ -export interface TelegramForwardedFrom { - name: string; - username: string | null; -} - export interface ContentItem { id: string; sourceId: string; @@ -93,13 +87,24 @@ export interface ContentItem { /** Set when this item is a quote-tweet — the tweet embedded in its
. */ quotedTweet: QuotedTweet | null; } | null; - /** Telegram-sourced items only — null for everything else. Media is unresolved refs (see TelegramMediaRef); publish.ts resolves them (and the channel avatar) per the admin's configured media mode. */ + /** + * Telegram-sourced items only — null for everything else. channelName/channelUsername + * describe whoever should be *displayed* as the author — the original channel when + * this message is a forward (same as tweet.authorName always being the original + * tweet's author, not the retweeter), or the polled channel itself otherwise. + * channelUsername is null when a forward's origin has no public handle. Media is + * unresolved refs (see TelegramMediaRef); publish.ts resolves them (and the display + * avatar) per the admin's configured media mode. + */ telegramMessage: { channelName: string; - channelUsername: string; + channelUsername: string | null; + /** The channel actually polled — always non-null, used to re-fetch this message's media (attached media lives on the polled channel's own copy of the message, forward or not). */ + sourceChannelUsername: string; messageId: string; media: TelegramMediaRef[]; - forwardedFrom: TelegramForwardedFrom | null; + /** Set when this message is a forward — the polled channel's own handle, e.g. "Forwarded by @X". */ + repostedByHandle: string | null; } | null; raw: unknown; } @@ -130,11 +135,11 @@ export interface MergedArticle { /** Telegram-sourced articles only — the embed card's channel info and attached media (see TelegramCard.svelte). Never set alongside video. */ telegramMessage: { channelName: string; - channelUsername: string; + channelUsername: string | null; channelAvatarUrl: string | null; sourceItemId: string; media: TelegramMediaItem[]; - forwardedFrom: TelegramForwardedFrom | null; + repostedByHandle: string | null; } | null; category: string[]; geo: string | null; diff --git a/frontend/src/lib/components/TelegramCard.svelte b/frontend/src/lib/components/TelegramCard.svelte index 423c4d3..aa7d494 100644 --- a/frontend/src/lib/components/TelegramCard.svelte +++ b/frontend/src/lib/components/TelegramCard.svelte @@ -28,9 +28,8 @@ - {#if article.telegramMessage?.forwardedFrom} - {@const from = article.telegramMessage.forwardedFrom} -
↪️ Forwarded from {from.username ? `@${from.username}` : from.name}
+ {#if article.telegramMessage?.repostedByHandle} +
↪️ Forwarded by @{article.telegramMessage.repostedByHandle}
{/if}
{article.category[0] ?? ''} @@ -46,7 +45,9 @@
{/if} {article.telegramMessage?.channelName} - @{article.telegramMessage?.channelUsername} + {#if article.telegramMessage?.channelUsername} + @{article.telegramMessage.channelUsername} + {/if}
{article.body}
{#if media.length > 0} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index f5ef202..10d3128 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -29,12 +29,6 @@ export interface QuotedTweet { /** Same shape as TweetMediaItem — distinct name for readability at Telegram call sites. */ export type TelegramMediaItem = TweetMediaItem; -/** Where a forwarded Telegram message originated — username is null when the origin has no public handle, in which case TelegramCard falls back to showing just the name. */ -export interface TelegramForwardedFrom { - name: string; - username: string | null; -} - export interface MergedArticle { id: string; title: string; @@ -52,11 +46,11 @@ export interface MergedArticle { } | null; telegramMessage: { channelName: string; - channelUsername: string; + channelUsername: string | null; channelAvatarUrl: string | null; sourceItemId: string; media: TelegramMediaItem[]; - forwardedFrom: TelegramForwardedFrom | null; + repostedByHandle: string | null; } | null; category: string[]; geo: string | null;