Share Telegram channels across sections
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s

This commit is contained in:
Grendgi
2026-06-08 23:54:49 +03:00
parent ddea7002f1
commit 2d0d751115
4 changed files with 146 additions and 54 deletions

View File

@@ -0,0 +1,43 @@
"""channel aliases across department sections
Revision ID: 0011
Revises: 0010
Create Date: 2026-06-08
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0011"
down_revision: Union[str, None] = "0010"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("channels", sa.Column("source_channel_id", sa.Integer(), nullable=True))
op.create_foreign_key(
"fk_channels_source_channel_id",
"channels",
"channels",
["source_channel_id"],
["id"],
ondelete="SET NULL",
)
op.drop_constraint("channels_identifier_key", "channels", type_="unique")
op.create_unique_constraint(
"uq_channels_section_identifier",
"channels",
["section_id", "identifier"],
)
op.create_index("ix_channels_source_channel_id", "channels", ["source_channel_id"])
def downgrade() -> None:
op.drop_index("ix_channels_source_channel_id", table_name="channels")
op.drop_constraint("uq_channels_section_identifier", "channels", type_="unique")
op.create_unique_constraint("channels_identifier_key", "channels", ["identifier"])
op.drop_constraint("fk_channels_source_channel_id", "channels", type_="foreignkey")
op.drop_column("channels", "source_channel_id")

View File

@@ -90,17 +90,18 @@ type sectionOut struct {
} }
type channelOut struct { type channelOut struct {
ID int64 `json:"id"` ID int64 `json:"id"`
TGID *int64 `json:"tg_id,omitempty"` TGID *int64 `json:"tg_id,omitempty"`
Identifier string `json:"identifier"` SourceChannelID *int64 `json:"source_channel_id,omitempty"`
Title *string `json:"title,omitempty"` Identifier string `json:"identifier"`
Vertical string `json:"vertical"` Title *string `json:"title,omitempty"`
SectionID int64 `json:"section_id"` Vertical string `json:"vertical"`
SectionSlug *string `json:"section_slug,omitempty"` SectionID int64 `json:"section_id"`
IsActive bool `json:"is_active"` SectionSlug *string `json:"section_slug,omitempty"`
LastMessageID *int64 `json:"last_message_id,omitempty"` IsActive bool `json:"is_active"`
LastPolledAt *time.Time `json:"last_polled_at,omitempty"` LastMessageID *int64 `json:"last_message_id,omitempty"`
CreatedAt time.Time `json:"created_at"` LastPolledAt *time.Time `json:"last_polled_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
} }
type messageOut struct { type messageOut struct {
@@ -287,12 +288,14 @@ func (a *app) listSections(ctx context.Context, w http.ResponseWriter, r *http.R
(SELECT count(*) FROM channels c WHERE c.section_id = s.id), (SELECT count(*) FROM channels c WHERE c.section_id = s.id),
(SELECT count(*) FROM channels c WHERE c.section_id = s.id AND c.is_active = true), (SELECT count(*) FROM channels c WHERE c.section_id = s.id AND c.is_active = true),
(SELECT count(*) (SELECT count(*)
FROM messages m FROM channels c
JOIN channels c ON c.id = m.channel_id JOIN channels src ON src.id = COALESCE(c.source_channel_id, c.id)
JOIN messages m ON m.channel_id = src.id
WHERE c.section_id = s.id), WHERE c.section_id = s.id),
(SELECT count(*) (SELECT count(*)
FROM messages m FROM channels c
JOIN channels c ON c.id = m.channel_id JOIN channels src ON src.id = COALESCE(c.source_channel_id, c.id)
JOIN messages m ON m.channel_id = src.id
WHERE c.section_id = s.id WHERE c.section_id = s.id
AND ( AND (
(s.vertical = 'hr' AND m.extracted->'hr_lead'->>'is_lead' = 'true') (s.vertical = 'hr' AND m.extracted->'hr_lead'->>'is_lead' = 'true')
@@ -448,7 +451,7 @@ func (a *app) updateSection(ctx context.Context, w http.ResponseWriter, r *http.
RETURNING id, vertical, COALESCE(department_id, ''), slug, title, COALESCE(emoji, ''), COALESCE(description, ''), created_at, RETURNING id, vertical, COALESCE(department_id, ''), slug, title, COALESCE(emoji, ''), COALESCE(description, ''), created_at,
(SELECT count(*) FROM channels c WHERE c.section_id = sections.id), (SELECT count(*) FROM channels c WHERE c.section_id = sections.id),
(SELECT count(*) FROM channels c WHERE c.section_id = sections.id AND c.is_active = true), (SELECT count(*) FROM channels c WHERE c.section_id = sections.id AND c.is_active = true),
(SELECT count(*) FROM messages m JOIN channels c ON c.id = m.channel_id WHERE c.section_id = sections.id), (SELECT count(*) FROM channels c JOIN channels src ON src.id = COALESCE(c.source_channel_id, c.id) JOIN messages m ON m.channel_id = src.id WHERE c.section_id = sections.id),
0::bigint 0::bigint
`, args...) `, args...)
item, err := scanSectionRow(row) item, err := scanSectionRow(row)
@@ -496,7 +499,7 @@ func (a *app) findSection(ctx context.Context, vertical, slug string, scope acce
SELECT s.id, s.vertical, COALESCE(s.department_id, ''), s.slug, s.title, COALESCE(s.emoji, ''), COALESCE(s.description, ''), s.created_at, SELECT s.id, s.vertical, COALESCE(s.department_id, ''), s.slug, s.title, COALESCE(s.emoji, ''), COALESCE(s.description, ''), s.created_at,
(SELECT count(*) FROM channels c WHERE c.section_id = s.id), (SELECT count(*) FROM channels c WHERE c.section_id = s.id),
(SELECT count(*) FROM channels c WHERE c.section_id = s.id AND c.is_active = true), (SELECT count(*) FROM channels c WHERE c.section_id = s.id AND c.is_active = true),
(SELECT count(*) FROM messages m JOIN channels c ON c.id = m.channel_id WHERE c.section_id = s.id), (SELECT count(*) FROM channels c JOIN channels src ON src.id = COALESCE(c.source_channel_id, c.id) JOIN messages m ON m.channel_id = src.id WHERE c.section_id = s.id),
0::bigint 0::bigint
FROM sections s FROM sections s
WHERE `+where+` WHERE `+where+`
@@ -536,10 +539,13 @@ func (a *app) listChannels(ctx context.Context, w http.ResponseWriter, r *http.R
where += fmt.Sprintf(" AND s.department_id = $%d", len(args)) where += fmt.Sprintf(" AND s.department_id = $%d", len(args))
} }
rows, err := a.db.Query(ctx, ` rows, err := a.db.Query(ctx, `
SELECT c.id, c.tg_id, c.identifier, c.title, c.vertical, c.section_id, s.slug, SELECT c.id, COALESCE(c.tg_id, src.tg_id), c.source_channel_id,
c.is_active, c.last_message_id, c.last_polled_at, c.created_at c.identifier, COALESCE(c.title, src.title), c.vertical, c.section_id, s.slug,
c.is_active, COALESCE(c.last_message_id, src.last_message_id),
COALESCE(c.last_polled_at, src.last_polled_at), c.created_at
FROM channels c FROM channels c
JOIN sections s ON s.id = c.section_id JOIN sections s ON s.id = c.section_id
LEFT JOIN channels src ON src.id = c.source_channel_id
WHERE `+where+` WHERE `+where+`
ORDER BY c.created_at DESC, c.id DESC ORDER BY c.created_at DESC, c.id DESC
`, args...) `, args...)
@@ -592,7 +598,9 @@ func (a *app) createChannel(ctx context.Context, w http.ResponseWriter, r *http.
row := a.db.QueryRow(ctx, ` row := a.db.QueryRow(ctx, `
INSERT INTO channels (identifier, vertical, section_id, is_active) INSERT INTO channels (identifier, vertical, section_id, is_active)
VALUES ($1, $2, $3, true) VALUES ($1, $2, $3, true)
RETURNING id, tg_id, identifier, title, vertical, section_id, $4::text, is_active, last_message_id, last_polled_at, created_at ON CONFLICT ON CONSTRAINT uq_channels_section_identifier DO UPDATE
SET is_active = true
RETURNING id, tg_id, source_channel_id, identifier, title, vertical, section_id, $4::text, is_active, last_message_id, last_polled_at, created_at
`, payload.Identifier, payload.Vertical, section.ID, section.Slug) `, payload.Identifier, payload.Vertical, section.ID, section.Slug)
item, err := scanChannelRow(row) item, err := scanChannelRow(row)
if err != nil { if err != nil {
@@ -702,7 +710,7 @@ func (a *app) updateChannel(ctx context.Context, w http.ResponseWriter, r *http.
UPDATE channels UPDATE channels
SET `+strings.Join(set, ", ")+` SET `+strings.Join(set, ", ")+`
WHERE id = $`+strconv.Itoa(len(args))+` WHERE id = $`+strconv.Itoa(len(args))+`
RETURNING id, tg_id, identifier, title, vertical, section_id, RETURNING id, tg_id, source_channel_id, identifier, title, vertical, section_id,
(SELECT slug FROM sections WHERE id = channels.section_id), (SELECT slug FROM sections WHERE id = channels.section_id),
is_active, last_message_id, last_polled_at, created_at is_active, last_message_id, last_polled_at, created_at
`, args...) `, args...)
@@ -746,10 +754,13 @@ func (a *app) findChannel(ctx context.Context, id int64, scope accessScope, vert
where += fmt.Sprintf(" AND s.department_id = $%d", len(args)) where += fmt.Sprintf(" AND s.department_id = $%d", len(args))
} }
row := a.db.QueryRow(ctx, ` row := a.db.QueryRow(ctx, `
SELECT c.id, c.tg_id, c.identifier, c.title, c.vertical, c.section_id, s.slug, SELECT c.id, COALESCE(c.tg_id, src.tg_id), c.source_channel_id,
c.is_active, c.last_message_id, c.last_polled_at, c.created_at c.identifier, COALESCE(c.title, src.title), c.vertical, c.section_id, s.slug,
c.is_active, COALESCE(c.last_message_id, src.last_message_id),
COALESCE(c.last_polled_at, src.last_polled_at), c.created_at
FROM channels c FROM channels c
JOIN sections s ON s.id = c.section_id JOIN sections s ON s.id = c.section_id
LEFT JOIN channels src ON src.id = c.source_channel_id
WHERE `+where+` WHERE `+where+`
`, args...) `, args...)
return scanChannelRow(row) return scanChannelRow(row)
@@ -772,20 +783,25 @@ func (a *app) channelStats(ctx context.Context, w http.ResponseWriter, r *http.R
key = "hr_lead" key = "hr_lead"
field = "is_lead" field = "is_lead"
} }
if err := a.db.QueryRow(ctx, `SELECT count(*) FROM messages WHERE channel_id = $1`, id).Scan(&messages); err != nil { sourceID := id
if ch.SourceChannelID != nil {
sourceID = *ch.SourceChannelID
}
if err := a.db.QueryRow(ctx, `SELECT count(*) FROM messages WHERE channel_id = $1`, sourceID).Scan(&messages); err != nil {
writeDBError(w, err) writeDBError(w, err)
return return
} }
if err := a.db.QueryRow(ctx, `SELECT count(*) FROM messages WHERE channel_id = $1 AND extracted -> $2 ->> $3 = 'true'`, id, key, field).Scan(&leads); err != nil { if err := a.db.QueryRow(ctx, `SELECT count(*) FROM messages WHERE channel_id = $1 AND extracted -> $2 ->> $3 = 'true'`, sourceID, key, field).Scan(&leads); err != nil {
writeDBError(w, err) writeDBError(w, err)
return return
} }
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"channel_id": id, "channel_id": id,
"messages_total": messages, "source_channel_id": sourceID,
"leads_total": leads, "messages_total": messages,
"last_polled_at": ch.LastPolledAt, "leads_total": leads,
"last_message_id": ch.LastMessageID, "last_polled_at": ch.LastPolledAt,
"last_message_id": ch.LastMessageID,
}) })
} }
@@ -800,13 +816,17 @@ func (a *app) reanalyzeChannel(ctx context.Context, w http.ResponseWriter, r *ht
return return
} }
key := verdictKey(ch.Vertical) key := verdictKey(ch.Vertical)
sourceID := id
if ch.SourceChannelID != nil {
sourceID = *ch.SourceChannelID
}
tag, err := a.db.Exec(ctx, ` tag, err := a.db.Exec(ctx, `
UPDATE messages UPDATE messages
SET extracted = ( SET extracted = (
CASE WHEN jsonb_typeof(extracted) = 'object' THEN extracted ELSE '{}'::jsonb END CASE WHEN jsonb_typeof(extracted) = 'object' THEN extracted ELSE '{}'::jsonb END
) - $1 ) - $1
WHERE channel_id = $2 WHERE channel_id = $2
`, key, id) `, key, sourceID)
if err != nil { if err != nil {
writeDBError(w, err) writeDBError(w, err)
return return
@@ -848,7 +868,7 @@ func (a *app) handleMessages(ctx context.Context, w http.ResponseWriter, r *http
return return
} }
args = append(args, id) args = append(args, id)
where += fmt.Sprintf(" AND m.channel_id = $%d", len(args)) where += fmt.Sprintf(" AND c.id = $%d", len(args))
} }
if text := strings.TrimSpace(q.Get("q")); text != "" { if text := strings.TrimSpace(q.Get("q")); text != "" {
args = append(args, "%"+text+"%") args = append(args, "%"+text+"%")
@@ -871,12 +891,13 @@ func (a *app) handleMessages(ctx context.Context, w http.ResponseWriter, r *http
fetchLimit := clampInt(limit*5, limit, 1000) fetchLimit := clampInt(limit*5, limit, 1000)
args = append(args, fetchLimit, offset) args = append(args, fetchLimit, offset)
rows, err := a.db.Query(ctx, ` rows, err := a.db.Query(ctx, `
SELECT m.id, m.channel_id, c.vertical, s.slug, m.tg_message_id, m.grouped_id, 1::int, SELECT m.id, c.id, c.vertical, s.slug, m.tg_message_id, m.grouped_id, 1::int,
m.date, m.text, m.sender_id, m.sender_username, m.sender_name, m.date, m.text, m.sender_id, m.sender_username, m.sender_name,
c.identifier, c.tg_id, m.has_media, COALESCE(m.extracted, 'null'::jsonb)::text, COALESCE(src.identifier, c.identifier), COALESCE(src.tg_id, c.tg_id), m.has_media, COALESCE(m.extracted, 'null'::jsonb)::text,
COALESCE(m.media_files, '[]'::jsonb)::text, m.views, m.forwards, m.fetched_at COALESCE(m.media_files, '[]'::jsonb)::text, m.views, m.forwards, m.fetched_at
FROM messages m FROM channels c
JOIN channels c ON c.id = m.channel_id JOIN channels src ON src.id = COALESCE(c.source_channel_id, c.id)
JOIN messages m ON m.channel_id = src.id
JOIN sections s ON s.id = c.section_id JOIN sections s ON s.id = c.section_id
WHERE `+where+` WHERE `+where+`
ORDER BY m.date DESC, m.id DESC ORDER BY m.date DESC, m.id DESC
@@ -927,14 +948,17 @@ func (a *app) handleMessageItem(ctx context.Context, w http.ResponseWriter, r *h
where += fmt.Sprintf(" AND s.department_id = $%d", len(args)) where += fmt.Sprintf(" AND s.department_id = $%d", len(args))
} }
row := a.db.QueryRow(ctx, ` row := a.db.QueryRow(ctx, `
SELECT m.id, m.channel_id, c.vertical, s.slug, m.tg_message_id, m.grouped_id, 1::int, SELECT m.id, c.id, c.vertical, s.slug, m.tg_message_id, m.grouped_id, 1::int,
m.date, m.text, m.sender_id, m.sender_username, m.sender_name, m.date, m.text, m.sender_id, m.sender_username, m.sender_name,
c.identifier, c.tg_id, m.has_media, COALESCE(m.extracted, 'null'::jsonb)::text, COALESCE(src.identifier, c.identifier), COALESCE(src.tg_id, c.tg_id), m.has_media, COALESCE(m.extracted, 'null'::jsonb)::text,
COALESCE(m.media_files, '[]'::jsonb)::text, m.views, m.forwards, m.fetched_at COALESCE(m.media_files, '[]'::jsonb)::text, m.views, m.forwards, m.fetched_at
FROM messages m FROM messages m
JOIN channels c ON c.id = m.channel_id JOIN channels src ON src.id = m.channel_id
JOIN channels c ON c.id = src.id OR c.source_channel_id = src.id
JOIN sections s ON s.id = c.section_id JOIN sections s ON s.id = c.section_id
WHERE `+where+` WHERE `+where+`
ORDER BY CASE WHEN c.id = src.id THEN 0 ELSE 1 END
LIMIT 1
`, args...) `, args...)
item, err := scanMessageRow(row) item, err := scanMessageRow(row)
if err != nil { if err != nil {
@@ -1022,23 +1046,20 @@ func (a *app) serveMinioMedia(w http.ResponseWriter, r *http.Request, key string
} }
func (a *app) canReadChannelMedia(ctx context.Context, scope accessScope, channelID int64) (bool, error) { func (a *app) canReadChannelMedia(ctx context.Context, scope accessScope, channelID int64) (bool, error) {
var dept sql.NullString var allowed bool
err := a.db.QueryRow(ctx, ` err := a.db.QueryRow(ctx, `
SELECT s.department_id SELECT COALESCE(bool_or(s.department_id = $2 OR $3::boolean), false)
FROM channels c FROM channels c
JOIN sections s ON s.id = c.section_id JOIN sections s ON s.id = c.section_id
WHERE c.id = $1 WHERE c.id = $1 OR c.source_channel_id = $1
`, channelID).Scan(&dept) `, channelID, scope.DeptID, scope.IsAdmin).Scan(&allowed)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return false, nil return false, nil
} }
if err != nil { if err != nil {
return false, err return false, err
} }
if scope.IsAdmin { return allowed, nil
return true, nil
}
return dept.Valid && dept.String == scope.DeptID, nil
} }
func (a *app) handleStats(ctx context.Context, w http.ResponseWriter, r *http.Request) { func (a *app) handleStats(ctx context.Context, w http.ResponseWriter, r *http.Request) {
@@ -1081,7 +1102,7 @@ func (a *app) handleStats(ctx context.Context, w http.ResponseWriter, r *http.Re
writeDBError(w, err) writeDBError(w, err)
return return
} }
messageFrom := `FROM messages m JOIN channels c ON c.id = m.channel_id JOIN sections s ON s.id = c.section_id WHERE ` + where messageFrom := `FROM channels c JOIN channels src ON src.id = COALESCE(c.source_channel_id, c.id) JOIN messages m ON m.channel_id = src.id JOIN sections s ON s.id = c.section_id WHERE ` + where
if err := a.db.QueryRow(ctx, `SELECT count(*) `+messageFrom, args...).Scan(&messagesTotal); err != nil { if err := a.db.QueryRow(ctx, `SELECT count(*) `+messageFrom, args...).Scan(&messagesTotal); err != nil {
writeDBError(w, err) writeDBError(w, err)
return return
@@ -1186,8 +1207,9 @@ func (a *app) pendingLLM(ctx context.Context, scope accessScope, vertical, secti
var pending int64 var pending int64
err := a.db.QueryRow(ctx, ` err := a.db.QueryRow(ctx, `
SELECT count(*) SELECT count(*)
FROM messages m FROM channels c
JOIN channels c ON c.id = m.channel_id JOIN channels src ON src.id = COALESCE(c.source_channel_id, c.id)
JOIN messages m ON m.channel_id = src.id
JOIN sections s ON s.id = c.section_id JOIN sections s ON s.id = c.section_id
WHERE `+where, args...).Scan(&pending) WHERE `+where, args...).Scan(&pending)
return pending, err return pending, err
@@ -1437,12 +1459,13 @@ func scanSectionRow(row pgx.Row) (sectionOut, error) {
func scanChannel(rows rowScanner) (channelOut, error) { func scanChannel(rows rowScanner) (channelOut, error) {
var item channelOut var item channelOut
var tgID, lastMsg sql.NullInt64 var tgID, sourceID, lastMsg sql.NullInt64
var title, slug sql.NullString var title, slug sql.NullString
var lastPoll sql.NullTime var lastPoll sql.NullTime
err := rows.Scan( err := rows.Scan(
&item.ID, &item.ID,
&tgID, &tgID,
&sourceID,
&item.Identifier, &item.Identifier,
&title, &title,
&item.Vertical, &item.Vertical,
@@ -1454,6 +1477,7 @@ func scanChannel(rows rowScanner) (channelOut, error) {
&item.CreatedAt, &item.CreatedAt,
) )
item.TGID = nullInt(tgID) item.TGID = nullInt(tgID)
item.SourceChannelID = nullInt(sourceID)
item.Title = nullString(title) item.Title = nullString(title)
item.SectionSlug = nullString(slug) item.SectionSlug = nullString(slug)
item.LastMessageID = nullInt(lastMsg) item.LastMessageID = nullInt(lastMsg)

View File

@@ -54,12 +54,16 @@ class Section(Base):
class Channel(Base): class Channel(Base):
__tablename__ = "channels" __tablename__ = "channels"
__table_args__ = (
UniqueConstraint("section_id", "identifier", name="uq_channels_section_identifier"),
Index("ix_channels_source_channel_id", "source_channel_id"),
)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
# Telegram numeric channel id (peer id), nullable until first resolve # Telegram numeric channel id (peer id), nullable until first resolve
tg_id: Mapped[int | None] = mapped_column(BigInteger, unique=True, nullable=True) tg_id: Mapped[int | None] = mapped_column(BigInteger, unique=True, nullable=True)
# Username or t.me/joinchat link supplied by user # Username or t.me/joinchat link supplied by user
identifier: Mapped[str] = mapped_column(String(255), unique=True) identifier: Mapped[str] = mapped_column(String(255))
title: Mapped[str | None] = mapped_column(String(512), nullable=True) title: Mapped[str | None] = mapped_column(String(512), nullable=True)
# 'real_estate' or 'hr' — picks which LLM prompt and lead schema is used # 'real_estate' or 'hr' — picks which LLM prompt and lead schema is used
vertical: Mapped[str] = mapped_column( vertical: Mapped[str] = mapped_column(
@@ -68,6 +72,9 @@ class Channel(Base):
section_id: Mapped[int] = mapped_column( section_id: Mapped[int] = mapped_column(
ForeignKey("sections.id", ondelete="RESTRICT"), index=True ForeignKey("sections.id", ondelete="RESTRICT"), index=True
) )
source_channel_id: Mapped[int | None] = mapped_column(
ForeignKey("channels.id", ondelete="SET NULL"), nullable=True
)
is_active: Mapped[bool] = mapped_column(default=True, server_default="true") is_active: Mapped[bool] = mapped_column(default=True, server_default="true")
last_message_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) last_message_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
last_polled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) last_polled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View File

@@ -92,6 +92,14 @@ async def poll_channel(channel_id: int) -> int:
channel = await session.get(Channel, channel_id) channel = await session.get(Channel, channel_id)
if channel is None or not channel.is_active: if channel is None or not channel.is_active:
return 0 return 0
if channel.source_channel_id is not None:
source = await session.get(Channel, channel.source_channel_id)
if source is not None:
channel.tg_id = None
channel.title = channel.title or source.title
channel.last_message_id = source.last_message_id
channel.last_polled_at = source.last_polled_at
return 0
if channel.tg_id is None or channel.title is None: if channel.tg_id is None or channel.title is None:
try: try:
@@ -108,9 +116,19 @@ async def poll_channel(channel_id: int) -> int:
) )
).scalar_one_or_none() ).scalar_one_or_none()
if duplicate_id is not None: if duplicate_id is not None:
raise PollDuplicateChannelError( source = await session.get(Channel, duplicate_id)
f"Telegram channel is already connected to channel #{duplicate_id}" channel.source_channel_id = duplicate_id
channel.tg_id = None
channel.title = channel.title or resolved.title or (source.title if source else None)
channel.last_message_id = source.last_message_id if source else channel.last_message_id
channel.last_polled_at = source.last_polled_at if source else channel.last_polled_at
log.info(
"linked_channel_alias",
channel_id=channel.id,
source_channel_id=duplicate_id,
tg_id=resolved.tg_id,
) )
return 0
channel.tg_id = resolved.tg_id channel.tg_id = resolved.tg_id
channel.title = resolved.title channel.title = resolved.title
try: try: