Show "Forwarded from" attribution on forwarded Telegram messages

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
This commit is contained in:
Claude
2026-07-23 22:14:15 +00:00
parent 1c6c0d4092
commit 26810f1fed
6 changed files with 56 additions and 4 deletions
+2 -1
View File
@@ -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;
}
+27 -2
View File
@@ -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) }
});
+2 -1
View File
@@ -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
};
}
+8
View File
@@ -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;
@@ -28,6 +28,10 @@
</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}
<div class="meta">
<span>{article.category[0] ?? ''}</span>
<span>&middot;</span>
@@ -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);
+7
View File
@@ -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;