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:
@@ -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';
|
import { cleanHtml, toSummary } from '../clean.js';
|
||||||
|
|
||||||
export interface FetchedItem {
|
export interface FetchedItem {
|
||||||
@@ -17,6 +17,7 @@ export interface FetchedItem {
|
|||||||
channelUsername: string;
|
channelUsername: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
media: TelegramMediaRef[];
|
media: TelegramMediaRef[];
|
||||||
|
forwardedFrom: TelegramForwardedFrom | null;
|
||||||
};
|
};
|
||||||
raw: unknown;
|
raw: unknown;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
// where those refs turn into an actual servable url.
|
// where those refs turn into an actual servable url.
|
||||||
|
|
||||||
import type { Api } from 'telegram';
|
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 type { SourceAdapter, FetchedItem } from './base.js';
|
||||||
import { logger } from '../../storage/db/logs.js';
|
import { logger } from '../../storage/db/logs.js';
|
||||||
import { getClient, fetchChannelMessages } from '../../telegram/client.js';
|
import { getClient, fetchChannelMessages } from '../../telegram/client.js';
|
||||||
@@ -69,6 +69,30 @@ function refForMessage(message: TgMessage): TelegramMediaRef | null {
|
|||||||
return 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. */
|
/** Groups consecutive messages sharing a non-null groupedId (Telegram's multi-photo/video "album" concept) into one entry each. */
|
||||||
function groupMessages(messages: TgMessage[]): TgMessage[][] {
|
function groupMessages(messages: TgMessage[]): TgMessage[][] {
|
||||||
const order: string[] = [];
|
const order: string[] = [];
|
||||||
@@ -144,7 +168,8 @@ export const telegramAdapter: SourceAdapter = {
|
|||||||
channelName,
|
channelName,
|
||||||
channelUsername,
|
channelUsername,
|
||||||
messageId: String(group[0].id),
|
messageId: String(group[0].id),
|
||||||
media
|
media,
|
||||||
|
forwardedFrom: detectForward(primary)
|
||||||
},
|
},
|
||||||
raw: { messageIds: group.map((m) => m.id) }
|
raw: { messageIds: group.map((m) => m.id) }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -263,7 +263,8 @@ export async function publishDirect(item: ContentItem, settings: GlobalSettings)
|
|||||||
channelUsername,
|
channelUsername,
|
||||||
channelAvatarUrl: avatar.url,
|
channelAvatarUrl: avatar.url,
|
||||||
sourceItemId: item.id,
|
sourceItemId: item.id,
|
||||||
media: resolvedMedia.media
|
media: resolvedMedia.media,
|
||||||
|
forwardedFrom: item.telegramMessage.forwardedFrom
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ export interface TelegramMediaRef {
|
|||||||
height: number | null;
|
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 {
|
export interface ContentItem {
|
||||||
id: string;
|
id: string;
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
@@ -70,6 +76,7 @@ export interface ContentItem {
|
|||||||
channelUsername: string;
|
channelUsername: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
media: TelegramMediaRef[];
|
media: TelegramMediaRef[];
|
||||||
|
forwardedFrom: TelegramForwardedFrom | null;
|
||||||
} | null;
|
} | null;
|
||||||
raw: unknown;
|
raw: unknown;
|
||||||
}
|
}
|
||||||
@@ -96,6 +103,7 @@ export interface MergedArticle {
|
|||||||
channelAvatarUrl: string | null;
|
channelAvatarUrl: string | null;
|
||||||
sourceItemId: string;
|
sourceItemId: string;
|
||||||
media: TelegramMediaItem[];
|
media: TelegramMediaItem[];
|
||||||
|
forwardedFrom: TelegramForwardedFrom | null;
|
||||||
} | null;
|
} | null;
|
||||||
category: string[];
|
category: string[];
|
||||||
geo: string | null;
|
geo: string | null;
|
||||||
|
|||||||
@@ -28,6 +28,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a class="telegram-card" href={messageUrl} target="_blank" rel="noreferrer">
|
<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">
|
<div class="meta">
|
||||||
<span>{article.category[0] ?? ''}</span>
|
<span>{article.category[0] ?? ''}</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
@@ -88,6 +92,12 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-color: var(--border-accent);
|
border-color: var(--border-accent);
|
||||||
}
|
}
|
||||||
|
.forward-line {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
.meta {
|
.meta {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-accent);
|
color: var(--text-accent);
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ export interface TweetMediaItem {
|
|||||||
/** Same shape as TweetMediaItem — distinct name for readability at Telegram call sites. */
|
/** Same shape as TweetMediaItem — distinct name for readability at Telegram call sites. */
|
||||||
export type TelegramMediaItem = TweetMediaItem;
|
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 {
|
export interface MergedArticle {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -33,6 +39,7 @@ export interface MergedArticle {
|
|||||||
channelAvatarUrl: string | null;
|
channelAvatarUrl: string | null;
|
||||||
sourceItemId: string;
|
sourceItemId: string;
|
||||||
media: TelegramMediaItem[];
|
media: TelegramMediaItem[];
|
||||||
|
forwardedFrom: TelegramForwardedFrom | null;
|
||||||
} | null;
|
} | null;
|
||||||
category: string[];
|
category: string[];
|
||||||
geo: string | null;
|
geo: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user