Skip to content

Commit ac71211

Browse files
committed
chore(release): bump version to 0.8.1
1 parent 39d7365 commit ac71211

File tree

5 files changed

+260
-10
lines changed

5 files changed

+260
-10
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.8.1] - 2026-03-08
9+
10+
### Fixed
11+
- Volcengine voice transcription now deletes the temporary TOS object after ASR completes, preventing staged voice files from accumulating over time
12+
- TOS cleanup failures are isolated to logs and no longer affect user-facing transcription replies
13+
14+
### Changed
15+
- Extended TOS uploader API to return uploaded object metadata (`object_key` + signed URL) for explicit post-transcription cleanup
16+
- Added tests covering TOS object deletion on both success and failure paths
17+
818
## [0.8.0] - 2026-03-08
919

1020
### Added

core/bot.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1905,10 +1905,15 @@ async def run_task():
19051905
outcome = "download_failed"
19061906
return
19071907

1908+
uploaded_object_key: Optional[str] = None
19081909
try:
1909-
audio_url = await asyncio.to_thread(
1910-
tos_uploader.upload_file, source_path, user_id
1910+
uploaded = await asyncio.to_thread(
1911+
tos_uploader.upload_file_with_object_key,
1912+
source_path,
1913+
user_id,
19111914
)
1915+
audio_url = uploaded.signed_url
1916+
uploaded_object_key = uploaded.object_key
19121917
except TOSUploadError as exc:
19131918
logger.error(
19141919
"Failed to upload voice file to TOS for user %s: %s",
@@ -1954,6 +1959,20 @@ async def run_task():
19541959
)
19551960
outcome = "transcription_failed"
19561961
return
1962+
finally:
1963+
if uploaded_object_key:
1964+
try:
1965+
await asyncio.to_thread(
1966+
tos_uploader.delete_object, uploaded_object_key
1967+
)
1968+
except Exception as exc:
1969+
logger.warning(
1970+
"Failed to delete temporary TOS voice object for user %s key=%s: %s",
1971+
user_id,
1972+
uploaded_object_key,
1973+
exc,
1974+
exc_info=True,
1975+
)
19571976
else:
19581977
logger.error(
19591978
"Unsupported transcription provider '%s' for user %s",

tests/test_tos_uploader.py

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,29 @@
33
from tempfile import TemporaryDirectory
44
from types import SimpleNamespace
55

6-
from telegram_bot.utils.tos_uploader import TOSUploadError, VolcengineTOSUploader
6+
from telegram_bot.utils.tos_uploader import (
7+
TOSUploadError,
8+
TOSUploadedObject,
9+
VolcengineTOSUploader,
10+
)
711

812

913
class _FakeTOSClient:
10-
def __init__(self, *, put_error=None, sign_error=None, signed_url=""):
14+
def __init__(
15+
self,
16+
*,
17+
put_error=None,
18+
sign_error=None,
19+
delete_error=None,
20+
signed_url="",
21+
):
1122
self.put_error = put_error
1223
self.sign_error = sign_error
24+
self.delete_error = delete_error
1325
self.signed_url = signed_url
1426
self.put_calls = []
1527
self.sign_calls = []
28+
self.delete_calls = []
1629

1730
def put_object_from_file(self, **kwargs):
1831
self.put_calls.append(kwargs)
@@ -26,6 +39,12 @@ def pre_signed_url(self, http_method, **kwargs):
2639
raise self.sign_error
2740
return SimpleNamespace(signed_url=self.signed_url)
2841

42+
def delete_object(self, **kwargs):
43+
self.delete_calls.append(kwargs)
44+
if self.delete_error is not None:
45+
raise self.delete_error
46+
return SimpleNamespace()
47+
2948

3049
class TOSUploaderTests(unittest.TestCase):
3150
def test_upload_file_returns_signed_url(self):
@@ -67,6 +86,67 @@ def test_upload_file_returns_signed_url(self):
6786
self.assertEqual(kwargs["key"], object_key)
6887
self.assertEqual(kwargs["expires"], 1200)
6988

89+
def test_upload_file_with_object_key_returns_metadata(self):
90+
with TemporaryDirectory() as td:
91+
path = Path(td) / "voice.ogg"
92+
path.write_bytes(b"OggS")
93+
94+
client = _FakeTOSClient(
95+
signed_url="https://tos.example.com/bucket/voice.ogg?X-Tos-Signature=abc"
96+
)
97+
uploader = VolcengineTOSUploader(
98+
access_key="ak",
99+
secret_access_key="sk",
100+
endpoint="https://tos-cn-shanghai.volces.com",
101+
region="cn-shanghai",
102+
bucket_name="voice-stage",
103+
signed_url_ttl_seconds=1200,
104+
client=client,
105+
http_method_get="GET",
106+
)
107+
108+
uploaded = uploader.upload_file_with_object_key(path, user_id=42)
109+
110+
self.assertIsInstance(uploaded, TOSUploadedObject)
111+
self.assertEqual(
112+
uploaded.signed_url,
113+
"https://tos.example.com/bucket/voice.ogg?X-Tos-Signature=abc",
114+
)
115+
self.assertTrue(uploaded.object_key.startswith("telegram-voice/42/"))
116+
self.assertTrue(uploaded.object_key.endswith(".ogg"))
117+
118+
def test_delete_object_calls_tos_client(self):
119+
client = _FakeTOSClient()
120+
uploader = VolcengineTOSUploader(
121+
access_key="ak",
122+
secret_access_key="sk",
123+
endpoint="https://tos-cn-shanghai.volces.com",
124+
region="cn-shanghai",
125+
bucket_name="voice-stage",
126+
client=client,
127+
)
128+
129+
uploader.delete_object("telegram-voice/42/object.ogg")
130+
131+
self.assertEqual(
132+
client.delete_calls,
133+
[{"bucket": "voice-stage", "key": "telegram-voice/42/object.ogg"}],
134+
)
135+
136+
def test_delete_object_raises_when_client_delete_fails(self):
137+
client = _FakeTOSClient(delete_error=RuntimeError("delete failed"))
138+
uploader = VolcengineTOSUploader(
139+
access_key="ak",
140+
secret_access_key="sk",
141+
endpoint="https://tos-cn-shanghai.volces.com",
142+
region="cn-shanghai",
143+
bucket_name="voice-stage",
144+
client=client,
145+
)
146+
147+
with self.assertRaises(TOSUploadError):
148+
uploader.delete_object("telegram-voice/42/object.ogg")
149+
70150
def test_upload_file_raises_when_upload_fails(self):
71151
with TemporaryDirectory() as td:
72152
path = Path(td) / "voice.ogg"

tests/test_voice_flow.py

Lines changed: 114 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,8 @@ def get_session_last_assistant_message(self, session_id):
134134

135135
import telegram_bot.core.bot as bot_module
136136
from telegram_bot.core.bot import TelegramBot
137-
from telegram_bot.utils.transcription import EmptyTranscriptionError
137+
from telegram_bot.utils.tos_uploader import TOSUploadError
138+
from telegram_bot.utils.transcription import EmptyTranscriptionError, TranscriptionError
138139

139140
_NOISY_LOGGERS = ["telegram_bot.core.bot"]
140141
_ORIGINAL_LEVELS = {}
@@ -342,10 +343,13 @@ async def run_now(user_id, run_task, on_overflow):
342343
side_effect=lambda path, cleanup: path
343344
)
344345
bot._process_user_message_text = AsyncMock()
346+
uploaded = SimpleNamespace(
347+
signed_url="https://tos.example.com/stage/voice.ogg?X-Tos-Signature=abc",
348+
object_key="telegram-voice/11/object.ogg",
349+
)
345350
uploader = SimpleNamespace(
346-
upload_file=MagicMock(
347-
return_value="https://tos.example.com/stage/voice.ogg?X-Tos-Signature=abc"
348-
),
351+
upload_file_with_object_key=MagicMock(return_value=uploaded),
352+
delete_object=MagicMock(return_value=None),
349353
redact_signed_url=lambda url: (
350354
"https://tos.example.com/stage/voice.ogg?***REDACTED***"
351355
),
@@ -367,11 +371,12 @@ async def run_now(user_id, run_task, on_overflow):
367371
bot_module.config.transcription_provider = old_provider
368372

369373
bot._download_voice_file.assert_awaited_once()
370-
self.assertEqual(uploader.upload_file.call_count, 1)
374+
self.assertEqual(uploader.upload_file_with_object_key.call_count, 1)
371375
transcriber.transcribe_audio.assert_awaited_once_with(
372376
"https://tos.example.com/stage/voice.ogg?X-Tos-Signature=abc",
373377
duration_seconds=30,
374378
)
379+
uploader.delete_object.assert_called_once_with("telegram-voice/11/object.ogg")
375380
bot._prepare_audio_for_whisper.assert_not_called()
376381
bot._process_user_message_text.assert_awaited_once()
377382
called = bot._process_user_message_text.await_args
@@ -381,6 +386,110 @@ async def run_now(user_id, run_task, on_overflow):
381386
"🎤 Voice: hello from volcengine",
382387
)
383388

389+
async def test_volcengine_delete_failure_does_not_break_successful_reply(self):
390+
bot = TelegramBot()
391+
bot._check_access = AsyncMock(return_value=True)
392+
393+
old_provider = bot_module.config.transcription_provider
394+
config_module.config.transcription_provider = "volcengine"
395+
bot_module.config.transcription_provider = "volcengine"
396+
397+
async def run_now(user_id, run_task, on_overflow):
398+
del user_id, on_overflow
399+
await run_task()
400+
return True
401+
402+
bot._enqueue_user_task = run_now
403+
bot._download_voice_file = AsyncMock(return_value=None)
404+
bot._prepare_audio_for_whisper = AsyncMock(
405+
side_effect=lambda path, cleanup: path
406+
)
407+
bot._process_user_message_text = AsyncMock()
408+
uploader = SimpleNamespace(
409+
upload_file_with_object_key=MagicMock(
410+
return_value=SimpleNamespace(
411+
signed_url="https://tos.example.com/stage/voice.ogg?X-Tos-Signature=abc",
412+
object_key="telegram-voice/11/object.ogg",
413+
)
414+
),
415+
delete_object=MagicMock(side_effect=TOSUploadError("delete failed")),
416+
redact_signed_url=lambda url: (
417+
"https://tos.example.com/stage/voice.ogg?***REDACTED***"
418+
),
419+
)
420+
bot._get_volcengine_tos_uploader = lambda: uploader
421+
transcriber = SimpleNamespace(
422+
transcribe_audio=AsyncMock(return_value="hello from volcengine")
423+
)
424+
bot._get_volcengine_transcriber = lambda: transcriber
425+
voice = SimpleNamespace(file_id="v1", duration=30, mime_type="audio/ogg")
426+
update = _build_update(11, voice)
427+
428+
try:
429+
with TemporaryDirectory() as td:
430+
bot._audio_dir = Path(td)
431+
await bot._handle_voice_message(update, None)
432+
finally:
433+
config_module.config.transcription_provider = old_provider
434+
bot_module.config.transcription_provider = old_provider
435+
436+
transcriber.transcribe_audio.assert_awaited_once()
437+
uploader.delete_object.assert_called_once_with("telegram-voice/11/object.ogg")
438+
bot._process_user_message_text.assert_awaited_once()
439+
440+
async def test_volcengine_transcription_failure_still_deletes_tos_object(self):
441+
bot = TelegramBot()
442+
bot._check_access = AsyncMock(return_value=True)
443+
444+
old_provider = bot_module.config.transcription_provider
445+
config_module.config.transcription_provider = "volcengine"
446+
bot_module.config.transcription_provider = "volcengine"
447+
448+
async def run_now(user_id, run_task, on_overflow):
449+
del user_id, on_overflow
450+
await run_task()
451+
return True
452+
453+
bot._enqueue_user_task = run_now
454+
bot._download_voice_file = AsyncMock(return_value=None)
455+
uploader = SimpleNamespace(
456+
upload_file_with_object_key=MagicMock(
457+
return_value=SimpleNamespace(
458+
signed_url="https://tos.example.com/stage/voice.ogg?X-Tos-Signature=abc",
459+
object_key="telegram-voice/11/object.ogg",
460+
)
461+
),
462+
delete_object=MagicMock(return_value=None),
463+
redact_signed_url=lambda url: (
464+
"https://tos.example.com/stage/voice.ogg?***REDACTED***"
465+
),
466+
)
467+
bot._get_volcengine_tos_uploader = lambda: uploader
468+
transcriber = SimpleNamespace(
469+
transcribe_audio=AsyncMock(side_effect=TranscriptionError("asr failed"))
470+
)
471+
bot._get_volcengine_transcriber = lambda: transcriber
472+
bot._process_user_message_text = AsyncMock()
473+
voice = SimpleNamespace(file_id="v1", duration=30, mime_type="audio/ogg")
474+
update = _build_update(11, voice)
475+
476+
try:
477+
with TemporaryDirectory() as td:
478+
bot._audio_dir = Path(td)
479+
await bot._handle_voice_message(update, None)
480+
finally:
481+
config_module.config.transcription_provider = old_provider
482+
bot_module.config.transcription_provider = old_provider
483+
484+
uploader.delete_object.assert_called_once_with("telegram-voice/11/object.ogg")
485+
bot._process_user_message_text.assert_not_awaited()
486+
self.assertTrue(
487+
any(
488+
"Failed to transcribe your voice message" in msg
489+
for msg in update.message.replies
490+
)
491+
)
492+
384493
async def test_reports_missing_volcengine_configuration(self):
385494
bot = TelegramBot()
386495
bot._check_access = AsyncMock(return_value=True)

utils/tos_uploader.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import secrets
44
import time
55
import uuid
6+
from dataclasses import dataclass
67
from pathlib import Path
78
from typing import Any, Callable, Optional
89
from urllib.parse import urlparse, urlunparse
@@ -14,6 +15,12 @@ class TOSUploadError(RuntimeError):
1415
"""Raised when uploading or signing a TOS object fails."""
1516

1617

18+
@dataclass(frozen=True)
19+
class TOSUploadedObject:
20+
object_key: str
21+
signed_url: str
22+
23+
1724
class VolcengineTOSUploader:
1825
"""Upload local voice files to Volcengine TOS and return signed URLs."""
1926

@@ -82,6 +89,12 @@ def __init__(
8289
self._http_method_get = self._http_method_get or "GET"
8390

8491
def upload_file(self, local_path: Path, user_id: int) -> str:
92+
uploaded = self.upload_file_with_object_key(local_path, user_id)
93+
return uploaded.signed_url
94+
95+
def upload_file_with_object_key(
96+
self, local_path: Path, user_id: int
97+
) -> TOSUploadedObject:
8598
source = Path(local_path)
8699
suffix = source.suffix or ".ogg"
87100
object_key = self._build_object_key(source, user_id, suffix)
@@ -116,7 +129,26 @@ def upload_file(self, local_path: Path, user_id: int) -> str:
116129
object_key,
117130
self.redact_signed_url(signed_url),
118131
)
119-
return signed_url
132+
return TOSUploadedObject(object_key=object_key, signed_url=signed_url)
133+
134+
def delete_object(self, object_key: str) -> None:
135+
key = str(object_key or "").strip()
136+
if not key:
137+
raise ValueError("object_key is required for TOS delete.")
138+
139+
try:
140+
self._client.delete_object(
141+
bucket=self.bucket_name,
142+
key=key,
143+
)
144+
except Exception as exc:
145+
raise TOSUploadError("Failed to delete Volcengine TOS object.") from exc
146+
147+
logger.debug(
148+
"Deleted temporary voice file from Volcengine TOS bucket=%s key=%s",
149+
self.bucket_name,
150+
key,
151+
)
120152

121153
@staticmethod
122154
def _build_object_key(source: Path, user_id: int, suffix: str) -> str:

0 commit comments

Comments
 (0)