From a2b9d3bb0a5bc12f7e394b855ba14df11a1fb574 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 22:36:06 +0000 Subject: [PATCH] Fix forward attribution: show origin channel as author, not the polled one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was backwards — the card displayed the polled channel's own name/avatar with a "Forwarded from @origin" line. Now mirrors the tweet retweet pattern exactly: the card's author identity (name/handle/avatar) is always the original channel/user, forward or not, same as tweet.authorName never being the retweeter. A new repostedByHandle field (replacing forwardedFrom) carries the polled channel's own handle for the "Forwarded by @X" line above the card. Media resolution still keys off the polled channel specifically (a new sourceChannelUsername field) since attached media lives on the polled channel's own copy of the message regardless of who originally posted it — only the avatar now resolves against the displayed (possibly origin, possibly null) channel identity. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8 --- backend/src/ingestion/adapters/base.ts | 8 ++--- backend/src/ingestion/adapters/telegram.ts | 34 ++++++++++++++----- backend/src/pipeline/publish.ts | 18 +++++++--- backend/src/storage/db/types.ts | 27 +++++++++------ .../src/lib/components/TelegramCard.svelte | 9 ++--- frontend/src/lib/types.ts | 10 ++---- 6 files changed, 67 insertions(+), 39 deletions(-) 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;