From 26810f1fed946205e505766c84c1450bcc9de092 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 22:14:15 +0000 Subject: [PATCH] Show "Forwarded from" attribution on forwarded Telegram messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the repost-line treatment tweets already get. GramJS resolves a forward's origin channel/user from entities Telegram sends alongside the same getMessages response (message.forward.chat/.sender), falling back to fwdFrom.fromName for the rarer case where the origin hid its identity. TelegramCard shows "↪️ Forwarded from @username" (or just the name if no public handle) above the meta row. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8 --- backend/src/ingestion/adapters/base.ts | 3 +- backend/src/ingestion/adapters/telegram.ts | 29 +++++++++++++++++-- backend/src/pipeline/publish.ts | 3 +- backend/src/storage/db/types.ts | 8 +++++ .../src/lib/components/TelegramCard.svelte | 10 +++++++ frontend/src/lib/types.ts | 7 +++++ 6 files changed, 56 insertions(+), 4 deletions(-) diff --git a/backend/src/ingestion/adapters/base.ts b/backend/src/ingestion/adapters/base.ts index cc63dfe..915f34b 100644 --- a/backend/src/ingestion/adapters/base.ts +++ b/backend/src/ingestion/adapters/base.ts @@ -1,4 +1,4 @@ -import type { Source, ContentItem, TweetMediaItem, TelegramMediaRef } from '../../storage/db/types.js'; +import type { Source, ContentItem, TweetMediaItem, TelegramMediaRef, TelegramForwardedFrom } from '../../storage/db/types.js'; import { cleanHtml, toSummary } from '../clean.js'; export interface FetchedItem { @@ -17,6 +17,7 @@ export interface FetchedItem { channelUsername: string; messageId: string; media: TelegramMediaRef[]; + forwardedFrom: TelegramForwardedFrom | null; }; raw: unknown; } diff --git a/backend/src/ingestion/adapters/telegram.ts b/backend/src/ingestion/adapters/telegram.ts index 4805f0b..78e8ae8 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 } from '../../storage/db/types.js'; +import type { Source, TelegramMediaRef, TelegramForwardedFrom } 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,30 @@ function refForMessage(message: TgMessage): TelegramMediaRef | null { return 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 + * getMessages response (the whole point of that header is letting clients show forward + * attribution without a separate resolve call) — `.chat` is the origin channel/group, + * `.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 { + const fwd = message.fwdFrom; + if (!fwd) return null; + + const chat = message.forward?.chat as { title?: string; username?: string } | undefined; + if (chat) return { name: chat.title ?? chat.username ?? 'Unknown', username: chat.username ?? null }; + + const sender = message.forward?.sender as { firstName?: string; lastName?: string; username?: string } | undefined; + if (sender) { + const name = [sender.firstName, sender.lastName].filter(Boolean).join(' ') || sender.username || 'Unknown'; + return { name, username: sender.username ?? null }; + } + + return { name: fwd.fromName ?? 'Unknown', username: null }; +} + /** Groups consecutive messages sharing a non-null groupedId (Telegram's multi-photo/video "album" concept) into one entry each. */ function groupMessages(messages: TgMessage[]): TgMessage[][] { const order: string[] = []; @@ -144,7 +168,8 @@ export const telegramAdapter: SourceAdapter = { channelName, channelUsername, messageId: String(group[0].id), - media + media, + forwardedFrom: detectForward(primary) }, raw: { messageIds: group.map((m) => m.id) } }); diff --git a/backend/src/pipeline/publish.ts b/backend/src/pipeline/publish.ts index 9574ea7..1351c60 100644 --- a/backend/src/pipeline/publish.ts +++ b/backend/src/pipeline/publish.ts @@ -263,7 +263,8 @@ export async function publishDirect(item: ContentItem, settings: GlobalSettings) channelUsername, channelAvatarUrl: avatar.url, sourceItemId: item.id, - media: resolvedMedia.media + media: resolvedMedia.media, + forwardedFrom: item.telegramMessage.forwardedFrom }; } diff --git a/backend/src/storage/db/types.ts b/backend/src/storage/db/types.ts index 8f8ef72..2c813c1 100644 --- a/backend/src/storage/db/types.ts +++ b/backend/src/storage/db/types.ts @@ -45,6 +45,12 @@ 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; @@ -70,6 +76,7 @@ export interface ContentItem { channelUsername: string; messageId: string; media: TelegramMediaRef[]; + forwardedFrom: TelegramForwardedFrom | null; } | null; raw: unknown; } @@ -96,6 +103,7 @@ export interface MergedArticle { channelAvatarUrl: string | null; sourceItemId: string; media: TelegramMediaItem[]; + forwardedFrom: TelegramForwardedFrom | null; } | null; category: string[]; geo: string | null; diff --git a/frontend/src/lib/components/TelegramCard.svelte b/frontend/src/lib/components/TelegramCard.svelte index 4f040ec..423c4d3 100644 --- a/frontend/src/lib/components/TelegramCard.svelte +++ b/frontend/src/lib/components/TelegramCard.svelte @@ -28,6 +28,10 @@ + {#if article.telegramMessage?.forwardedFrom} + {@const from = article.telegramMessage.forwardedFrom} +
↪️ Forwarded from {from.username ? `@${from.username}` : from.name}
+ {/if}
{article.category[0] ?? ''} · @@ -88,6 +92,12 @@ text-decoration: none; border-color: var(--border-accent); } + .forward-line { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 10px; + } .meta { font-size: 11px; color: var(--text-accent); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index ece038a..fb8d273 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -20,6 +20,12 @@ export interface TweetMediaItem { /** 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; @@ -33,6 +39,7 @@ export interface MergedArticle { channelAvatarUrl: string | null; sourceItemId: string; media: TelegramMediaItem[]; + forwardedFrom: TelegramForwardedFrom | null; } | null; category: string[]; geo: string | null;