- MAX→TG album: caption over Telegram's 1024 media-group limit is no longer lost —
the album is sent without an inline caption and the full text follows as a
separate message (sendMaxAlbumToTg, covers both direct and queued delivery).
- Forwarded text: strip Telegram custom_emoji (MAX can't render them; the fallback
glyph only littered the copy) in tgEntitiesToHTML, and collapse whitespace padding
(runs of spaces/nbsp, trailing spaces, 3+ blank lines) in crosspost captions —
cuts bloated length (MAX ~4000 char limit) and fixes ugly gaps.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ajcn7YhgeexPwbfrUNMj8u
- Fix MAX photo upload: bypass SDK (it appends deprecated v=1.2.5 → 404) with a
custom two-step image upload, mirroring the video/file path.
- Update MAX SDK v1.4.2 → v1.6.24; pin base URL to platform-api.max.ru
(v1.6.x defaults to platform-api2.max.ru, whose TLS cert prod doesn't trust).
- Native group picker: TG sender supports KeyboardButtonRequestChat + chat_shared;
new generic addon hook HandleChatShared (core stays semantics-agnostic).
- Route unhandled MAX callbacks to the addon; add Repository.ListBotChats for
group pickers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- MAX media upload: drop deprecated &v=1.2.5 from /uploads and /messages
(MAX returns 404 with it; photos/videos broke, text was unaffected).
- Run group moderation before discussion-comment ingest.
- Generic addon extension hooks; core stays agnostic to their semantics:
CrosspostAllowed, CrosspostDeliverable, HandleMaxCommand.
- Customizable /help and /start from a file next to the binary (HELP_FILE).
- /link command; optional web_app menu button (MINIAPP_URL).
- Track bot chats and pair owners; migrations 017-019.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Pluggable optional addons via `addon` build tag: Addon interface,
stub for the public build, generic b.extraCommands injection so the core
stays agnostic of any extension. README section + working examples/echoaddon.
- publishTgCrosspost: единая точка кросспоста TG→MAX (формат entity→markdown +
замены), переиспользуется вместо дублирования.
- TG_API_URL local mode: getFile отдаёт абсолютный путь — срезаем префикс до
токена, чтобы файлы отдавались через статику (nginx).
- Автозамены: понятная инструкция вместо "from | to", человеко-читаемые ошибки.
- Очередь доставки: причина + номер поста при недоставке в MAX, быстрый дроп
перманентных ошибок (send-message.empty, нет прав) вместо 30 ретраев.
- ForwardOrigin msg_id проброшен в Addon (старт операций с конкретного поста).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
InlineKeyboardButton gains URL (renders a link button instead of a
callback). Used by the optional addon for a "buy" button.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tgEntitiesToMarkdown pushed leading/trailing spaces out of an entity
span (fix for caption "** bold **"), but TG often includes trailing
newlines inside a bold/header entity ("Header\n\n" bold). The closing
** then landed after the newline ("**Header\n\n**"), which MAX/CommonMark
won't treat as bold — the markers leaked as literal text.
Generalize the boundary trim to all ASCII whitespace (space, \n, \r,
\t) via isMarkupSpace. Test added for trailing-newline bold.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
handleTgCallback offers each callback to the addon first (when present);
if the addon owns it (by its data prefix) the bridge stops. Adds
HandleCallback to the Addon interface. No-op in the public build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces a minimal Addon interface (Start, HandleDMCommand,
HandleDMForward) wired into the bridge:
- bridge.go: Bridge gains an addon field, started as a goroutine in Run
when non-nil; loadAddon is provided per build configuration.
- addon_stub.go (default build, //go:build !addon): loadAddon returns
nil — the public binary has no addon and no extra behavior.
- telegram.go: private-chat messages and channel forwards are offered to
the addon first when present; if it handles them, the bridge skips its
own processing. No-op in the public build.
- TGSender.ForwardMessage(from, to, msgID, silent) added — a generic Bot
API wrapper returning the full message; useful for any addon that needs
to relay existing messages.
The concrete addon implementation is compiled separately via the `addon`
build tag and is not part of this repository.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Issue #44: forwarding a MAX message that contained a file/photo/video
from group A into group B (bridged to TG) delivered only the text to
TG; the media was missing.
The MAX SDK only converts Link.Message.RawAttachments → Attachments
when body.RawAttachments == nil. MAX sends body.RawAttachments=[] (an
empty array, NOT nil) for forwards, so the else-if branch never fires
and Link.Message.Attachments stays empty. Our merge-into-body code in
forwardMaxToTg saw nothing to add.
Parse Link.Message.RawAttachments ourselves: read each entry's "type",
unmarshal to the concrete PhotoAttachment / VideoAttachment / etc., and
prepend to body.Attachments before the downstream send loop. Unknown
types are skipped silently.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two reported issues, same root cause: markdown delimiter has to sit
flush against the content — "** bold**" or "**bold **" with spaces
inside the entity body is not closed by CommonMark/MAX parsers, so the
asterisks leak into the rendered text.
- markup.go (issue #48): when TG sends a bold/italic/etc entity that
also captures leading or trailing spaces (e.g. "Всего 5 минут ..."
with trailing space), trim those spaces out of the entity span before
inserting markers. If the entity is all-whitespace, skip it.
Tests updated to reflect the new (correct) expected output.
- telegram.go (issue #50): crosspost photo branch in forwardTgToMax
built mdCaption from entities but only set format=markdown for bridge
posts. Subscribers saw the raw "**bold**" under the picture. Now
format is set whenever the text is non-empty, regardless of bridge
vs crosspost.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two queue items in send_queue could pile up indefinitely behind the
30-attempt limit and block per-destination ordering (hasPendingForChat):
tg2max: "MAX attachment not ready after 10 retries", "must be at most"
max2tg: "can't parse entities", "caption/message is too long"
These never become deliverable — drop them on the first failure so the
queue can drain and new messages for the same destination skip the
queued path.
Also: subscribe-listed bot_added / bot_removed updates weren't handled,
so when admins kicked the bot from a MAX chat the only signal was
silence in logs. listenMax now logs both events with chat id, actor user
id, username and display name; the DB pair is left intact in case the
bot is re-added.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MAX API rejects messages where 'text' field exceeds 4000 characters
("Field 'text' size (N) must be at most 4000"). Long Telegram posts
landed in send_queue and retried forever until the 30-attempt cap,
then dropped — meanwhile the source user got nothing in MAX.
sendMaxDirectFormatted now slices text > 3900 runes into chunks at
newline/space boundaries (last 20% of the chunk) and sends them as a
chain: first chunk keeps the attachment + reply, the rest go as
text-only follow-ups linked to the first chunk via reply. Returns
the mid of the first chunk so the message-mapping stays valid for
edits and deletes.
3900 (not 4000) leaves margin for the byte/rune ambiguity in MAX's
limit and for the attribution prefix that forwardTgToMax prepends.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Without DELETE /subscriptions, two problems persist after the bridge
stops or switches to polling:
1. Switching webhook → polling — MAX keeps trying to POST to the old
webhook URL and the bot's polling getUpdates can return nothing
relevant until MAX gives up on its own backoff schedule.
2. During downtime — MAX retries the dead webhook with exponential
backoff; first delivery after restart may be delayed by minutes.
listenMax now defers a Subscriptions.Unsubscribe(whURL) with a fresh
5s context (the parent ctx is already cancelled by shutdown), so
subscriptions are released cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
processQueue dropped expired tg2max items with a SendMessage to
item.SrcChatID. For crossposts (TG-channel → MAX) that meant the
"message not delivered after N attempts" notice landed in the channel
itself, visible to all subscribers. v0.3.15 fixed this for live
errors via notifyTgUser but missed the queue-expiration path.
Now: if SrcChatID is a crosspost source, DM the tg_owner_id
(GetCrosspostOwner); otherwise keep the current behavior (post in
the source chat for bridge flows).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Refactor webhook server handling and configuration
Refactor webhook server initialization to support separate ports for MAX and TG webhooks. Update configuration structure to include new webhook URLs and ports.
* Update webhook URL configuration for Max
* Update webhook URL configuration for Telegram
* Add MaxWebhook and TgWebhook configuration options
* Update webhook configuration in README
* Include original attachments in forwarded messages
Add original message attachments when forwarding.
When a TG user edited a caption on a post with video/gif/document/audio/
voice, editTgMediaInMax called MAX EditMessage with text only — the SDK
sends a payload without attachments which strips the existing media on
MAX side. Subscribers ended up with the corrected caption but the media
gone.
editTgMediaInMax already handled Photo (re-upload via UploadPhotoFromUrl
or uploadTgPhotoToMax + AddPhoto). Now does the same for the rest of the
types via uploadTgMediaToMax + AddVideo/AddFile/AddAudio. Sticker and
VideoNote stay skipped — TG doesn't allow caption edits on them anyway.
Reported by user via the bridge: video disappeared from MAX after
fixing a typo on TG side.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a MAX user does "репост" (forward) of a message, MAX puts the
original content in Message.Link with Type=forward and leaves
body.Text empty (or only carries the user's own optional comment).
forwardMaxToTg only read body.Text, so reposts arrived in TG as a
naked "Name:" with no content.
Now: if Link.Type=forward, take Link.Message.Text and Link.Message.Markups
as the relay content; if body.Text also has the user's comment,
concatenate "comment\n\nforwarded text" (markups dropped — offsets
no longer match the combined text).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related fixes around the [TG]/[MAX] prefix:
1. HasPrefix on a chat that isn't in `pairs` (typically a thread-bridged
MAX chat) used to return true — making /bridge prefix off useless
for thread-bridges (the command can only update pairs rows). Now
returns false on no-row, matching the post-v0.3.x default of OFF.
2. New env DISABLE_PREFIX=true|1|yes globally disables [TG]/[MAX]
prefixes regardless of per-pair state. Implemented as a Bridge
wrapper b.hasPrefix that short-circuits to false when the flag is
set; all 10 call sites switched to the wrapper. The DB column
`pairs.prefix` keeps its meaning per-pair when DISABLE_PREFIX is off.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.3.14 started sending format="markdown" for every TG→MAX forward
(including crossposts). For media-only posts (video without caption,
etc.) text ends up empty and MAX API rejects the payload with
400 proto.payload errors.send-message.empty
because format is set but no text is present.
sendMaxDirectFormatted now drops format when text is "". This also
unsticks queued items that were retrying forever with the same 400.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For crosspost TG→MAX (a post in a TG channel), any delivery error
message used to be posted back into the same channel (visible to all
subscribers). The only person who can act on it is the crosspost owner.
New helper notifyTgUser(ctx, srcMsg, maxChatID, text, isCrosspost):
- crosspost: resolves tg_owner_id via GetCrosspostOwner and DMs that
user; drops with a warn if the crosspost has no owner (legacy).
- bridge: keeps the existing behavior (post into the source chat,
preserving forum thread).
All upload/size/circuit-breaker notifications in forwardTgToMax and
flushMediaGroup now go through notifyTgUser. Bridge flows are
unchanged; crosspost flows stop spamming channels.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues reported by a user via the bridge:
1. Links vanish on TG channel → MAX crosspost. formatTgCrosspostCaption
returned raw text and forwardTgToMax dropped markdown format for the
crosspost branch, so text_link entities became plain text. Now
formatTgCrosspostCaption runs tgEntitiesToMarkdown, and
forwardTgToMax always uses markdown. Media group flusher also
skips the second conversion for crosspost items (caption is already
markdown) and keeps markdown format.
2. When a channel video is too big for Bot API getFile (e.g. HD > 2 GB
on local server), the whole post was dropped — even the caption
text was lost. Video upload failure no longer returns from
forwardTgToMax; the fallback branch appends a "[Видео]" marker so
subscribers in MAX at least get the text and know a video was
attached but not relayed. The fallback condition now checks
presence of any media instead of only msg.Text == "" (it missed
video-with-caption posts) and also covers Animation and Photo.
Tradeoff: crosspost replacements now operate on markdown text. URL-
and phrase-level replacements keep working; regex rules touching
markdown characters (_, *, [, ]) may need adjustment.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migration 000016. Existing 825 rows keep created_at=0 (we can't
backfill accurately); new /bridge pairs get a real unix timestamp.
SQLite uses INSERT … ON CONFLICT … DO UPDATE SET prefix=0 (previously
INSERT OR REPLACE, which would have wiped created_at on conflict).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous hints mentioned bot config (e.g. "20 MB limit without local
server") which is useless to a regular group member who can't
administer the bot. Drop technical detail:
- "файл слишком большой" (no parenthetical)
- "файл не найден"
- "попробуйте ещё раз" (for transient MAX CDN)
- unknown error → no suffix, just the generic "Не удалось отправить X в MAX."
New helper uploadErrMsg(base, err) builds the final string, appending
": hint." only when uploadErrHint returns a non-empty known hint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Requested by user Victor via bridged group.
- New helper uploadErrHint maps common upload failures to a short
Russian hint ("файл слишком большой", "Telegram не нашёл файл",
"MAX CDN не успел обработать файл", etc) and falls back to a truncated
raw error for the unknown case.
- Every TG→MAX upload failure path now tells the user what happened
(photo, gif, sticker, video, video note, document, voice, audio,
edit-with-media, media group).
- Edit path previously failed silently and media group dropped the
error on the floor — both now notify.
- Media group notifies once with the first failure when no photos
survived upload (avoids N messages per album).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Issue #38 part 2. Allows using multiple MAX group chats as per-thread
mirrors of a TG forum group, since MAX has no native thread concept.
- New table thread_pairs (tg_chat_id, tg_thread_id, max_chat_id) with
unique(max_chat_id) — one MAX chat = at most one TG thread.
- pending gains thread_id column for thread-bridge key exchange.
- Repo methods: StartThreadBridge (TG issues key), CompleteThreadBridge
(MAX consumes key), GetThreadMaxChat, GetThreadTgPair, UnpairThread,
UnpairThreadByMax.
- Commands:
* /thread_bridge in a TG forum thread (admin, non-General) -> key
* /thread_bridge <key> in a MAX chat -> binds
* /thread_unbridge on either side
TG's BOT_COMMAND_INVALID rule forbids hyphens, so the commands use
underscore.
- Routing priority: thread-bridge > regular pair.
TG->MAX: if msg.MessageThreadID has a thread_pair, route there.
MAX->TG: if MAX chat is in thread_pairs, route to (tg_chat, thread).
- For thread-paired MAX chats, the Part 1 reply-to-source-thread
override is disabled: thread-paired chats have a fixed target thread,
replies stay there.
- Safeguard: a MAX chat cannot be in both pairs and thread_pairs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Issue #38 part 1: MAX reply to a bridged TG message now lands in the
same TG forum thread as the original, not in the pair's default
thread. messages table gets tg_thread_id column (migration 000014);
SaveMsg stores the TG thread, LookupTgMsgID returns it, forwardMaxToTg
applies source thread for reply-routing (both body.ReplyTo and
Link.Type=reply paths).
- Fix crosspost attribution heuristic: forwardMaxToTg used
"caption != text" to detect bridge mode, which broke when MaxToTg
replacements or whitespace made caption differ from raw body.Text —
MAX→TG crossposts then got [MAX] prefix and bold name. Now explicit
isCrosspost flag.
- /bridge without key in an already-linked chat no longer generates a
fresh key; instead shows "already linked" hint with pairing guidance
(both TG and MAX sides).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Default prefix to 0 for new pairs (user can enable via /bridge prefix on)
- Anonymous admins (SenderChat == Chat) recognized as admins
- Log getChatMember errors; show "make bot admin" hint on CHAT_ADMIN_REQUIRED
- Clarify /bridge key message: send in the other group, not in bot DM
- Add /start hint that TG forum supergroups require bot admin
- Add /bridge command logging with chat/user IDs for support
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a group owner/admin enables "Remain anonymous", their messages
are sent from @GroupAnonymousBot with msg.SenderChat pointing to the
group itself. GetChatMember was then checking the bot's status (not
admin) and rejecting valid owners with "only for admins".
New helper isTgAnonymousAdmin(msg) returns true when SenderChat.ID
equals Chat.ID; in that case we skip the GetChatMember check and
treat the user as admin.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
forwardTgToMax built the final text from msg.Text/msg.Caption directly,
discarding the caption parameter that already had replacements applied
in handleTgChannelPost. For single posts (not media groups) in crosspost,
replacements were effectively ignored.
Added isCrosspost flag: when true, use the caption as-is (post-replacements,
no attribution, no markdown format since replacement may have invalidated
entity offsets).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Backticks in commit messages caused 'no matches found' errors when
changelog was interpolated into shell string. Use --notes-file instead.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Update max.go
* Update telegram.go
* Update format.go
* Update telegram.go
* Implement message queuing for existing pending messages
Added logic to queue messages if there are pending messages for the chat to maintain delivery order.
* Refactor message sending logic for Telegram
* Implement hasPendingForChat function
Add hasPendingForChat function to check for pending messages in the queue.
* Add HasPendingQueue method to repository interface
* Add HasPendingQueue method to sqliteRepo
* Add HasPendingQueue method to check for pending jobs
tgEntitiesToMarkdown used a linear walk that output each entity's text
independently — when multiple entities covered the same range (e.g.
bold+italic), the text was duplicated for each entity.
Rewrote to tag-insertion approach (matching maxMarkupsToHTML): collect
open/close markers at UTF-16 positions, sort with proper nesting order,
then walk text inserting markers without duplication.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
TG→MAX: tgEntitiesToMarkdown was called on caption with "Name: " prefix,
but entity offsets are relative to raw text — formatting markers landed
in wrong positions. Now entities are converted on raw text first, then
attribution is applied.
MAX→TG: condition `caption == text` was always false for bridge chats
(caption has "Name: " prefix), so markups were never converted to HTML.
Now markups are always applied, with proper HTML-escaped attribution.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously, editing a TG message with photo/media would send a new
message in MAX. Now it properly calls EditMessage with the updated
attachment when a message mapping exists, falling back to new message
only when no mapping is found.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously all bot messages were skipped. Now only our own bot
is filtered (by TG username / MAX user ID), so messages from
other bots in the group are forwarded normally.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New setting per crosspost pair — disabled by default:
- Toggle via inline button "Синк правок" in TG and MAX
- When enabled: edits and deletes sync between channels (respecting direction)
- When disabled (default): only new posts are crossposted
- Migration 000013 adds sync_edits column to crossposts table
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Same rationale as delete sync — edits between crossposted channels
cause confusion. Now edits only sync for regular bridge pairs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Delete propagation between crossposted channels causes more harm
than good — posts get accidentally removed. Now deletes only sync
for regular bridge pairs, not crossposts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When direction is "tg>max" (TG-only to MAX), delete/edit events
from MAX should not propagate back to TG. Previously all deletes
and edits from MAX were forwarded regardless of direction setting.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Photo sends via SDK (Messages.SendWithResult) didn't convert
TG entities to markdown or set format field. Text-only sends
used sendMaxDirectFormatted which did both — hence the discrepancy.
Now photo and media group captions convert CaptionEntities to
markdown and call SetFormat("markdown") when formatting is present.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>