4 Commits

Author SHA1 Message Date
admin
ccf6f9000d fix: rewrite bot file sending to use getMessage + inputFileRemote
Some checks failed
continuous-integration/drone/push Build is failing
forwardMessages with send_copy is unreliable for bot accounts — it
queues an internal download+reupload that can silently fail.

New approach:
1. getMessage to get the file's remote ID from the dest channel
2. sendMessage with inputMessageDocument + inputFileRemote to send
   directly to the user (no re-upload needed, file is already on
   Telegram's servers)
3. waitForSendConfirmation to actually verify delivery via
   updateMessageSendSucceeded/Failed

Also removed getChats call (not available to bot accounts).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 13:48:11 +01:00
admin
a4c264a144 fix: bot send confirmation, preview picker dialog nesting, upload button
- Bot: wait for updateMessageSendSucceeded/Failed before marking send
  complete (was returning on temp message, actual send was async)
- Preview picker: move ArchivePreviewPicker outside parent Dialog to
  fix Radix nested dialog focus trap conflict
- Upload: add explicit "Upload Preview" button always visible in the
  action bar alongside "Pick Preview"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 13:15:31 +01:00
admin
f4488a079f fix: add getChat before forwardMessages and debug logging for bot sends
The bot may not have the source channel loaded in TDLib's internal
state. Calling getChat first ensures it's resolved. Also added result
logging to diagnose silent send failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 12:39:42 +01:00
admin
729f296232 fix: use forward (not send_copy) for bot message delivery and add logging
send_copy requires re-uploading which may silently fail for bots.
Regular forward is more reliable. Added logging to debug delivery.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 12:38:24 +01:00
2 changed files with 166 additions and 42 deletions

View File

@@ -38,20 +38,6 @@ export async function createBotClient(): Promise<tdl.Client> {
})); }));
log.info("Bot client authenticated successfully"); log.info("Bot client authenticated successfully");
// Load chat list so TDLib knows about channels the bot has access to.
// Without this, forwardMessages/copyMessage will fail with "Chat not found".
try {
await client.invoke({
_: "getChats",
chat_list: { _: "chatListMain" },
limit: 200,
});
log.info("Chat list loaded");
} catch (err) {
log.warn({ err }, "Failed to load chat list — forwarding may fail");
}
return client; return client;
} }
@@ -68,11 +54,14 @@ export async function closeBotClient(): Promise<void> {
} }
/** /**
* Forward a message from a channel to a user's DM. * Send a document from a channel to a user's DM.
* Uses forwardMessages with send_copy to make it appear as sent by the bot.
* *
* The fromChatId is the TDLib chat ID stored in the DB — already in the correct * Instead of forwardMessages (unreliable for bot accounts with send_copy),
* format (negative for supergroups/channels, e.g. -1001234567890). * we fetch the original message to get the file's remote ID, then send a
* new message with inputFileRemote. This is the documented reliable approach
* for bots — the file is already on Telegram's servers so no re-upload is needed.
*
* Falls back to a plain forward (without send_copy) if getMessage fails.
*/ */
export async function copyMessageToUser( export async function copyMessageToUser(
fromChatId: bigint, fromChatId: bigint,
@@ -82,18 +71,136 @@ export async function copyMessageToUser(
if (!client) throw new Error("Bot client not initialized"); if (!client) throw new Error("Bot client not initialized");
const c = client; const c = client;
await withFloodWait( log.info(
() => { fromChatId: fromChatId.toString(), messageId: messageId.toString(), toUserId: toUserId.toString() },
c.invoke({ "Sending file to user"
_: "forwardMessages",
chat_id: Number(toUserId),
from_chat_id: Number(fromChatId),
message_ids: [Number(messageId)],
send_copy: true,
remove_caption: false,
}),
"copyMessageToUser"
); );
// Step 1: Get the original message to extract the file's remote ID
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let message: any;
try {
message = await withFloodWait(
() => c.invoke({
_: "getMessage",
chat_id: Number(fromChatId),
message_id: Number(messageId),
}),
"getMessage"
);
} catch (err) {
log.error({ err, fromChatId: fromChatId.toString(), messageId: messageId.toString() }, "getMessage failed");
throw new Error(`Cannot get source message: ${err instanceof Error ? err.message : String(err)}`);
}
// Step 2: Extract the document's remote file ID
const doc = message?.content?.document;
if (!doc?.document?.remote?.id) {
log.error(
{ messageContent: message?.content?._, messageId: messageId.toString() },
"Source message has no document with remote file ID"
);
throw new Error(`Source message is not a document or has no remote file ID (type: ${message?.content?._})`);
}
const remoteFileId: string = doc.document.remote.id;
const fileName: string = doc.file_name ?? "file";
const caption = message.content?.caption;
log.info(
{ remoteFileId: remoteFileId.slice(0, 20) + "...", fileName, toUserId: toUserId.toString() },
"Sending document via inputFileRemote"
);
// Step 3: Send the document to the user using the remote file ID
// This doesn't require downloading — Telegram serves the existing file.
await waitForSendConfirmation(c, Number(toUserId), {
_: "inputMessageDocument",
document: { _: "inputFileRemote", id: remoteFileId },
caption: caption ?? undefined,
}, fileName);
}
/**
* Send a message and wait for Telegram to confirm delivery.
* Returns when updateMessageSendSucceeded fires for the temp message.
* Throws if updateMessageSendFailed fires or timeout is reached.
*/
async function waitForSendConfirmation(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
c: any,
chatId: number,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
inputMessageContent: any,
label: string
): Promise<void> {
return new Promise<void>((resolve, reject) => {
let settled = false;
let tempMsgId: number | null = null;
const TIMEOUT_MS = 5 * 60_000;
const timer = setTimeout(() => {
if (!settled) {
settled = true;
cleanup();
reject(new Error(`Send timed out after 5min for ${label}`));
}
}, TIMEOUT_MS);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleUpdate = (update: any) => {
if (update?._ === "updateMessageSendSucceeded") {
if (tempMsgId !== null && update.old_message_id === tempMsgId) {
if (!settled) {
settled = true;
cleanup();
log.info({ tempMsgId, finalMsgId: update.message?.id, label }, "Send confirmed");
resolve();
}
}
}
if (update?._ === "updateMessageSendFailed") {
if (tempMsgId !== null && update.old_message_id === tempMsgId) {
if (!settled) {
settled = true;
cleanup();
const errorMsg = update.error?.message ?? "Unknown";
const errorCode = update.error?.code ?? 0;
log.error({ tempMsgId, errorCode, errorMsg, label }, "Send failed");
reject(new Error(`Send failed for ${label}: [${errorCode}] ${errorMsg}`));
}
}
}
};
const cleanup = () => {
clearTimeout(timer);
c.off("update", handleUpdate);
};
// Attach BEFORE sending to avoid race
c.on("update", handleUpdate);
withFloodWait(
() => c.invoke({
_: "sendMessage",
chat_id: chatId,
input_message_content: inputMessageContent,
}),
"sendMessage:copyToUser"
)
.then((result: { id: number }) => {
tempMsgId = result.id;
log.debug({ tempMsgId, label }, "Message queued, waiting for confirmation");
})
.catch((err: Error) => {
if (!settled) {
settled = true;
cleanup();
reject(err);
}
});
});
} }
/** /**

View File

@@ -335,6 +335,7 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange }: PackageFilesDraw
}, [filtered]); }, [filtered]);
return ( return (
<>
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[80vh] flex flex-col gap-0 p-0"> <DialogContent className="sm:max-w-2xl max-h-[80vh] flex flex-col gap-0 p-0">
<DialogHeader className="px-6 pt-6 pb-4 border-b border-border space-y-3"> <DialogHeader className="px-6 pt-6 pb-4 border-b border-border space-y-3">
@@ -408,6 +409,20 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange }: PackageFilesDraw
Pick Preview Pick Preview
</Button> </Button>
)} )}
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
Upload Preview
</Button>
</div> </div>
)} )}
</div> </div>
@@ -512,19 +527,21 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange }: PackageFilesDraw
</ScrollArea> </ScrollArea>
</DialogContent> </DialogContent>
{/* Archive preview picker modal */}
{pkg && pkg.archiveType !== "DOCUMENT" && !pkg.isMultipart && (
<ArchivePreviewPicker
packageId={pkg.id}
packageName={pkg.fileName}
open={showPreviewPicker}
onOpenChange={setShowPreviewPicker}
onPreviewSet={() => {
// Refresh the preview by setting a cache-busting URL
setLocalPreviewUrl(`/api/zips/${pkg.id}/preview?t=${Date.now()}`);
}}
/>
)}
</Dialog> </Dialog>
{/* Archive preview picker modal — rendered as sibling to avoid nested Dialog issues */}
{pkg && pkg.archiveType !== "DOCUMENT" && !pkg.isMultipart && (
<ArchivePreviewPicker
packageId={pkg.id}
packageName={pkg.fileName}
open={showPreviewPicker}
onOpenChange={setShowPreviewPicker}
onPreviewSet={() => {
// Refresh the preview by setting a cache-busting URL
setLocalPreviewUrl(`/api/zips/${pkg.id}/preview?t=${Date.now()}`);
}}
/>
)}
</>
); );
} }