fix(telegram): fall back to text link when media-by-URL fails

When the Telegram Bot API rejects a sendDocument/sendPhoto/etc by URL
(e.g. "wrong type of the web page content" or "failed to get HTTP URL
content"), the entire reply was lost because the error propagated
immediately via `?` with no fallback.

Now when any send-media-by-URL call fails, the channel logs a warning
and falls back to sending the URL as a plain text link. This ensures
the user always receives the agent's response, even when Telegram
can't fetch the linked content.

Also makes `api_base` configurable via `with_api_base()` for local
Bot API server support and testability.
This commit is contained in:
s04 2026-02-20 17:18:41 +01:00 committed by Chummy
parent 0c2d4b18a7
commit 3a4215aa78
4 changed files with 474 additions and 101 deletions

238
Cargo.lock generated
View File

@ -11,7 +11,7 @@ dependencies = [
"macroific",
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -188,7 +188,7 @@ dependencies = [
"proc-macro-error2",
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -227,6 +227,16 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dbc3a507a82b17ba0d98f6ce8fd6954ea0c8152e98009d36a40d8dcc8ce078a"
[[package]]
name = "assert-json-diff"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "assign"
version = "1.1.1"
@ -339,7 +349,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -350,7 +360,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -367,9 +377,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
version = "1.15.4"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256"
checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9"
dependencies = [
"aws-lc-sys",
"zeroize",
@ -449,7 +459,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -518,7 +528,7 @@ checksum = "f48d6ace212fdf1b45fd6b566bb40808415344642b76c3224c07c8df9da81e97"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -597,9 +607,9 @@ dependencies = [
[[package]]
name = "bumpalo"
version = "3.19.1"
version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "bytecount"
@ -624,7 +634,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -846,7 +856,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -1184,7 +1194,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -1208,7 +1218,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -1219,7 +1229,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -1310,7 +1320,7 @@ dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -1324,7 +1334,7 @@ dependencies = [
"macroific",
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -1383,7 +1393,7 @@ checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -1395,7 +1405,7 @@ dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.116",
"syn 2.0.117",
"unicode-xid",
]
@ -1461,7 +1471,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -1481,7 +1491,7 @@ checksum = "11772ed3eb3db124d826f3abeadf5a791a557f62c19b123e3f07288158a71fdd"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -1594,7 +1604,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -1769,7 +1779,7 @@ dependencies = [
"macroific",
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -1948,7 +1958,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -2176,7 +2186,7 @@ checksum = "149e3ea90eb5a26ad354cfe3cb7f7401b9329032d0235f2687d03a35f30e5d4c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -2579,7 +2589,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -2699,7 +2709,7 @@ checksum = "0ab604ee7085efba6efc65e4ebca0e9533e3aff6cb501d7d77b211e3a781c6d5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -2839,9 +2849,9 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.85"
version = "0.3.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
checksum = "d36139f1c97c42c0c86a411910b04e48d4939a0376e6e0f989420cbdee0120e5"
dependencies = [
"once_cell",
"wasm-bindgen",
@ -2977,6 +2987,12 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.7.5"
@ -3088,7 +3104,7 @@ dependencies = [
"proc-macro2",
"quote",
"sealed",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -3100,7 +3116,7 @@ dependencies = [
"proc-macro2",
"quote",
"sealed",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -3113,7 +3129,7 @@ dependencies = [
"macroific_core",
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -3150,7 +3166,7 @@ checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -3188,7 +3204,7 @@ dependencies = [
"proc-macro-error2",
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -3457,7 +3473,7 @@ dependencies = [
"macroific",
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -3501,7 +3517,7 @@ checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -3738,7 +3754,7 @@ dependencies = [
"core-foundation-sys",
"futures-core",
"io-kit-sys 0.5.0",
"linux-raw-sys",
"linux-raw-sys 0.12.1",
"log",
"once_cell",
"rustix",
@ -4114,7 +4130,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -4322,7 +4338,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -4475,7 +4491,7 @@ dependencies = [
"itertools 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -4488,7 +4504,7 @@ dependencies = [
"itertools 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -4802,7 +4818,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -5085,7 +5101,7 @@ dependencies = [
"quote",
"ruma-identifiers-validation",
"serde",
"syn 2.0.116",
"syn 2.0.117",
"toml 0.9.12+spec-1.1.0",
]
@ -5143,7 +5159,7 @@ dependencies = [
"bitflags 2.11.0",
"errno",
"libc",
"linux-raw-sys",
"linux-raw-sys 0.11.0",
"windows-sys 0.61.2",
]
@ -5258,7 +5274,7 @@ dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -5281,14 +5297,14 @@ checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
name = "security-framework"
version = "3.6.0"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.11.0",
"core-foundation",
@ -5299,9 +5315,9 @@ dependencies = [
[[package]]
name = "security-framework-sys"
version = "2.16.0"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
@ -5376,7 +5392,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -5387,7 +5403,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -5718,7 +5734,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -5740,9 +5756,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.116"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
@ -5766,7 +5782,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -5831,7 +5847,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -5842,7 +5858,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -5953,7 +5969,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -6094,9 +6110,9 @@ dependencies = [
[[package]]
name = "toml"
version = "1.0.1+spec-1.1.0"
version = "1.0.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbe30f93627849fa362d4a602212d41bb237dc2bd0f8ba0b2ce785012e124220"
checksum = "c7614eaf19ad818347db24addfa201729cf2a9b6fdfd9eb0ab870fcacc606c0c"
dependencies = [
"indexmap",
"serde_core",
@ -6139,9 +6155,9 @@ dependencies = [
[[package]]
name = "toml_parser"
version = "1.0.8+spec-1.1.0"
version = "1.0.9+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0742ff5ff03ea7e67c8ae6c93cac239e0d9784833362da3f9a9c1da8dfefcbdc"
checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
dependencies = [
"winnow 0.7.14",
]
@ -6154,9 +6170,9 @@ checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
[[package]]
name = "tonic"
version = "0.14.4"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f32a6f80051a4111560201420c7885d0082ba9efe2ab61875c587bb6b18b9a0"
checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec"
dependencies = [
"async-trait",
"base64",
@ -6175,9 +6191,9 @@ dependencies = [
[[package]]
name = "tonic-prost"
version = "0.14.4"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f86539c0089bfd09b1f8c0ab0239d80392af74c21bc9e0f15e1b4aca4c1647f"
checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309"
dependencies = [
"bytes",
"prost 0.14.3",
@ -6253,7 +6269,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -6358,7 +6374,7 @@ checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -6421,9 +6437,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-ident"
version = "1.0.23"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-normalization"
@ -6734,7 +6750,7 @@ checksum = "75c03f610c9bc960e653d5d6d2a4cced9013bedbe5e6e8948787bbd418e4137c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -6900,9 +6916,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.108"
version = "0.2.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
checksum = "9ff9c7baef35ac3c0e17d8bfc9ad75eb62f85a2f02bccc906699dadb0aa9c622"
dependencies = [
"cfg-if",
"once_cell",
@ -6913,9 +6929,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.58"
version = "0.4.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f"
checksum = "d24699cd39db9966cf6e2ef10d2f72779c961ad905911f395ea201c3ec9f545d"
dependencies = [
"cfg-if",
"futures-util",
@ -6927,9 +6943,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.108"
version = "0.2.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
checksum = "39455e84ad887a0bbc93c116d72403f1bb0a39e37dd6f235a43e2128a0c7f1fd"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@ -6937,22 +6953,22 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.108"
version = "0.2.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
checksum = "dff4761f60b0b51fd13fec8764167b7bbcc34498ce3e52805fe1db6f2d56b6d6"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.108"
version = "0.2.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
checksum = "bc6a171c53d98021a93a474c4a4579d76ba97f9517d871bc12e27640f218b6dd"
dependencies = [
"unicode-ident",
]
@ -7024,9 +7040,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.85"
version = "0.3.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598"
checksum = "668fa5d00434e890a452ab060d24e3904d1be93f7bb01b70e5603baa2b8ab23b"
dependencies = [
"js-sys",
"wasm-bindgen",
@ -7107,7 +7123,7 @@ checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762"
dependencies = [
"either",
"env_home",
"rustix 1.1.3",
"rustix",
"winsafe",
]
@ -7182,7 +7198,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -7193,7 +7209,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -7475,6 +7491,29 @@ version = "0.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
[[package]]
name = "wiremock"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
dependencies = [
"assert-json-diff",
"base64",
"deadpool",
"futures",
"http 1.4.0",
"http-body-util",
"hyper",
"hyper-util",
"log",
"once_cell",
"regex",
"serde",
"serde_json",
"tokio",
"url",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
@ -7505,7 +7544,7 @@ dependencies = [
"heck",
"indexmap",
"prettyplease",
"syn 2.0.116",
"syn 2.0.117",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
@ -7521,7 +7560,7 @@ dependencies = [
"prettyplease",
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
"wit-bindgen-core",
"wit-bindgen-rust",
]
@ -7633,7 +7672,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
"synstructure",
]
@ -7645,7 +7684,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
"synstructure",
]
@ -7713,7 +7752,7 @@ dependencies = [
"tokio-stream",
"tokio-tungstenite",
"tokio-util",
"toml 1.0.1+spec-1.1.0",
"toml 1.0.3+spec-1.1.0",
"tower",
"tower-http",
"tracing",
@ -7728,6 +7767,7 @@ dependencies = [
"wa-rs-ureq-http",
"webpki-roots 1.0.6",
"which",
"wiremock",
]
[[package]]
@ -7747,7 +7787,7 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tokio-test",
"toml 1.0.1+spec-1.1.0",
"toml 1.0.3+spec-1.1.0",
"tracing",
]
@ -7768,7 +7808,7 @@ checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -7788,7 +7828,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
"synstructure",
]
@ -7809,7 +7849,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -7853,7 +7893,7 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]
@ -7864,7 +7904,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
"syn 2.0.117",
]
[[package]]

View File

@ -219,6 +219,7 @@ panic = "abort"
tempfile = "3.14"
criterion = { version = "0.8", features = ["async_tokio"] }
tokio-stream = { version = "0.1.18", default-features = false, features = ["fs"] }
wiremock = "0.6"
[[bench]]
name = "agent_benchmarks"

View File

@ -326,6 +326,9 @@ pub struct TelegramChannel {
last_draft_edit: Mutex<std::collections::HashMap<String, std::time::Instant>>,
mention_only: bool,
bot_username: Mutex<Option<String>>,
/// Base URL for the Telegram Bot API. Defaults to `https://api.telegram.org`.
/// Override for local Bot API servers or testing.
api_base: String,
}
impl TelegramChannel {
@ -353,6 +356,7 @@ impl TelegramChannel {
typing_handle: Mutex::new(None),
mention_only,
bot_username: Mutex::new(None),
api_base: "https://api.telegram.org".to_string(),
}
}
@ -367,6 +371,13 @@ impl TelegramChannel {
self
}
/// Override the Telegram Bot API base URL.
/// Useful for local Bot API servers or testing.
pub fn with_api_base(mut self, api_base: String) -> Self {
self.api_base = api_base;
self
}
/// Parse reply_target into (chat_id, optional thread_id).
fn parse_reply_target(reply_target: &str) -> (String, Option<String>) {
if let Some((chat_id, thread_id)) = reply_target.split_once(':') {
@ -466,7 +477,7 @@ impl TelegramChannel {
}
fn api_url(&self, method: &str) -> String {
format!("https://api.telegram.org/bot{}/{method}", self.bot_token)
format!("{}/bot{}/{method}", self.api_base, self.bot_token)
}
async fn fetch_bot_username(&self) -> anyhow::Result<String> {
@ -1055,7 +1066,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
let target = attachment.target.trim();
if is_http_url(target) {
return match attachment.kind {
let result = match attachment.kind {
TelegramAttachmentKind::Image => {
self.send_photo_by_url(chat_id, thread_id, target, None)
.await
@ -1077,6 +1088,29 @@ Allowlist Telegram username (without '@') or numeric user ID.",
.await
}
};
// If sending media by URL failed (e.g. Telegram can't fetch the URL,
// wrong content type, etc.), fall back to sending the URL as a text link
// instead of losing the reply entirely.
if let Err(e) = result {
tracing::warn!(
url = target,
error = %e,
"Telegram send media by URL failed; falling back to text link"
);
let kind_label = match attachment.kind {
TelegramAttachmentKind::Image => "Image",
TelegramAttachmentKind::Document => "Document",
TelegramAttachmentKind::Video => "Video",
TelegramAttachmentKind::Audio => "Audio",
TelegramAttachmentKind::Voice => "Voice",
};
let fallback_text = format!("{kind_label}: {target}");
self.send_text_chunks(&fallback_text, chat_id, thread_id)
.await?;
}
return Ok(());
}
let path = Path::new(target);

View File

@ -0,0 +1,298 @@
//! Regression tests for Telegram attachment fallback behavior.
//!
//! When sending media by URL fails (e.g. Telegram can't fetch the URL or the
//! content type is wrong), the channel should fall back to sending the URL as
//! a text link instead of losing the entire reply.
//!
//! Bug: Previously, `send_attachment()` would propagate the error from
//! `send_document_by_url()` immediately via `?`, causing the entire reply
//! (including already-sent text) to fail with no fallback.
use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
use zeroclaw::channels::telegram::TelegramChannel;
use zeroclaw::channels::traits::{Channel, SendMessage};
/// Helper: create a TelegramChannel pointing at a mock server.
fn test_channel(mock_url: &str) -> TelegramChannel {
TelegramChannel::new("TEST_TOKEN".into(), vec!["*".into()], false)
.with_api_base(mock_url.to_string())
}
/// Helper: mount a mock that accepts sendMessage requests (the fallback path).
async fn mock_send_message_ok(server: &MockServer) {
Mock::given(method("POST"))
.and(path_regex(r"/botTEST_TOKEN/sendMessage$"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ok": true,
"result": {
"message_id": 1,
"chat": {"id": 123},
"text": "ok"
}
})))
.expect(1..)
.mount(server)
.await;
}
/// When sendDocument by URL fails with "wrong type of the web page content",
/// the channel should fall back to sending the URL as a text link.
#[tokio::test]
async fn document_url_failure_falls_back_to_text_link() {
let server = MockServer::start().await;
// sendDocument returns 400 (simulates Telegram rejecting the URL)
Mock::given(method("POST"))
.and(path_regex(r"/botTEST_TOKEN/sendDocument$"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"ok": false,
"error_code": 400,
"description": "Bad Request: wrong type of the web page content"
})))
.expect(1)
.mount(&server)
.await;
// sendMessage should succeed (this is the fallback)
mock_send_message_ok(&server).await;
let channel = test_channel(&server.uri());
let msg = SendMessage::new(
"Here is the report [DOCUMENT:https://example.com/page.html]",
"123",
);
// This should NOT error — it should fall back to text
let result = channel.send(&msg).await;
assert!(
result.is_ok(),
"send should succeed via text fallback, got: {result:?}"
);
}
/// When sendPhoto by URL fails, the channel should fall back to text link.
#[tokio::test]
async fn photo_url_failure_falls_back_to_text_link() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"/botTEST_TOKEN/sendPhoto$"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"ok": false,
"error_code": 400,
"description": "Bad Request: failed to get HTTP URL content"
})))
.expect(1)
.mount(&server)
.await;
mock_send_message_ok(&server).await;
let channel = test_channel(&server.uri());
let msg = SendMessage::new(
"Check this [IMAGE:https://internal-server.local/screenshot.png]",
"456",
);
let result = channel.send(&msg).await;
assert!(
result.is_ok(),
"send should succeed via text fallback, got: {result:?}"
);
}
/// Text portion of a message with attachments is still delivered even when
/// the attachment fails.
#[tokio::test]
async fn text_portion_delivered_before_attachment_failure() {
let server = MockServer::start().await;
// sendDocument fails
Mock::given(method("POST"))
.and(path_regex(r"/botTEST_TOKEN/sendDocument$"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"ok": false,
"error_code": 400,
"description": "Bad Request: wrong type of the web page content"
})))
.expect(1)
.mount(&server)
.await;
// sendMessage should be called at least twice:
// 1. for the text portion ("Here is the file")
// 2. for the fallback text link
Mock::given(method("POST"))
.and(path_regex(r"/botTEST_TOKEN/sendMessage$"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ok": true,
"result": {
"message_id": 1,
"chat": {"id": 789},
"text": "ok"
}
})))
.expect(2)
.mount(&server)
.await;
let channel = test_channel(&server.uri());
let msg = SendMessage::new(
"Here is the file [DOCUMENT:https://example.com/report.html]",
"789",
);
let result = channel.send(&msg).await;
assert!(result.is_ok(), "send should succeed, got: {result:?}");
}
/// When multiple attachments are present and one fails, the others should
/// still be attempted (each gets its own fallback).
#[tokio::test]
async fn multiple_attachments_independent_fallback() {
let server = MockServer::start().await;
// sendDocument fails (for the .html attachment)
Mock::given(method("POST"))
.and(path_regex(r"/botTEST_TOKEN/sendDocument$"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"ok": false,
"error_code": 400,
"description": "Bad Request: wrong type of the web page content"
})))
.expect(1)
.mount(&server)
.await;
// sendPhoto also fails
Mock::given(method("POST"))
.and(path_regex(r"/botTEST_TOKEN/sendPhoto$"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"ok": false,
"error_code": 400,
"description": "Bad Request: failed to get HTTP URL content"
})))
.expect(1)
.mount(&server)
.await;
// sendMessage succeeds (text + 2 fallback links)
Mock::given(method("POST"))
.and(path_regex(r"/botTEST_TOKEN/sendMessage$"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ok": true,
"result": {
"message_id": 1,
"chat": {"id": 100},
"text": "ok"
}
})))
.expect(3) // text + doc fallback + image fallback
.mount(&server)
.await;
let channel = test_channel(&server.uri());
let msg = SendMessage::new(
"Files: [DOCUMENT:https://example.com/page.html] and [IMAGE:https://internal.local/pic.png]",
"100",
);
let result = channel.send(&msg).await;
assert!(
result.is_ok(),
"send should succeed with fallbacks for all attachments, got: {result:?}"
);
}
/// When attachment succeeds, no fallback text is sent.
#[tokio::test]
async fn successful_attachment_no_fallback() {
let server = MockServer::start().await;
// sendDocument succeeds
Mock::given(method("POST"))
.and(path_regex(r"/botTEST_TOKEN/sendDocument$"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ok": true,
"result": {
"message_id": 2,
"chat": {"id": 200},
"document": {"file_id": "abc"}
}
})))
.expect(1)
.mount(&server)
.await;
// sendMessage should only be called once (for the text portion),
// NOT a second time for a fallback
Mock::given(method("POST"))
.and(path_regex(r"/botTEST_TOKEN/sendMessage$"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ok": true,
"result": {
"message_id": 1,
"chat": {"id": 200},
"text": "ok"
}
})))
.expect(1) // only the text portion, no fallback
.mount(&server)
.await;
let channel = test_channel(&server.uri());
let msg = SendMessage::new(
"Report attached [DOCUMENT:https://example.com/report.pdf]",
"200",
);
let result = channel.send(&msg).await;
assert!(
result.is_ok(),
"send should succeed normally, got: {result:?}"
);
}
/// Document-only message (no text) with URL failure should still send
/// a fallback text link.
#[tokio::test]
async fn document_only_message_falls_back_to_text() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"/botTEST_TOKEN/sendDocument$"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"ok": false,
"error_code": 400,
"description": "Bad Request: failed to get HTTP URL content"
})))
.expect(1)
.mount(&server)
.await;
// Fallback text link
Mock::given(method("POST"))
.and(path_regex(r"/botTEST_TOKEN/sendMessage$"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ok": true,
"result": {
"message_id": 1,
"chat": {"id": 300},
"text": "ok"
}
})))
.expect(1)
.mount(&server)
.await;
let channel = test_channel(&server.uri());
// Message is ONLY the attachment marker — no surrounding text
let msg = SendMessage::new("[DOCUMENT:https://example.com/file.html]", "300");
let result = channel.send(&msg).await;
assert!(
result.is_ok(),
"document-only message should fall back to text, got: {result:?}"
);
}