Fix forward attribution: show origin channel as author, not the polled one

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
This commit is contained in:
Claude
2026-07-23 22:36:06 +00:00
parent 4b1eca4c79
commit a2b9d3bb0a
6 changed files with 67 additions and 39 deletions
+4 -4
View File
@@ -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;
}
+26 -8
View File
@@ -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/<internal_id>/...
// 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 @<polled channel>" 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) }
});
+14 -4
View File
@@ -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
};
}
+16 -11
View File
@@ -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 <blockquote>. */
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;
@@ -28,9 +28,8 @@
</script>
<a class="telegram-card" href={messageUrl} target="_blank" rel="noreferrer">
{#if article.telegramMessage?.forwardedFrom}
{@const from = article.telegramMessage.forwardedFrom}
<div class="forward-line">↪️ Forwarded from {from.username ? `@${from.username}` : from.name}</div>
{#if article.telegramMessage?.repostedByHandle}
<div class="forward-line">↪️ Forwarded by @{article.telegramMessage.repostedByHandle}</div>
{/if}
<div class="meta">
<span>{article.category[0] ?? ''}</span>
@@ -46,7 +45,9 @@
<div class="avatar placeholder"></div>
{/if}
<span class="name">{article.telegramMessage?.channelName}</span>
<span class="handle">@{article.telegramMessage?.channelUsername}</span>
{#if article.telegramMessage?.channelUsername}
<span class="handle">@{article.telegramMessage.channelUsername}</span>
{/if}
</div>
<div class="text">{article.body}</div>
{#if media.length > 0}
+2 -8
View File
@@ -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;