项目文件夹

文件
Ali Khokhar ef76bddd58 Make Telegram startup notices clearable (#1066)
## Problem

Telegram's online notice was sent directly by the SDK runtime and its
message ID was discarded, so `/clear` could not delete it. Moving
delivery into the workflow also needs to keep slow sends from blocking
commands and prevent acknowledged notices from losing clear ownership.

## Changes

| Before | After |
| --- | --- |
| The Telegram runtime sent a transport-specific startup side effect. |
The platform declares a semantic notice intent that the application
gives to the workflow after transport readiness. |
| Startup delivery bypassed the persisted message log. | The workflow
renders and records each acknowledged notice in the same bounded log
used by `/clear`. |
| Serializing send and record held workflow state across platform I/O. |
A dedicated clear generation reserves publication, delivery runs outside
the state lock, and a short receipt finalizer commits or compensates. |
| Concurrent clear, cancellation, or record failure could leave a
delivered notice unowned. | Clear or cancellation deletes a late
receipt; record failure deletes it; failed deletion restores tracking
for a later `/clear`. |
| A standalone `/clear` command could evict an older target at the log
cap. | Successful standalone clear owns its command ID directly, while
failed or cancelled clear records it for the next attempt. |
| Startup ownership races were implicit. | Deterministic race, failure,
cap, restart, and product-smoke coverage enforce the final state machine
in version 3.5.10. |

<!-- greptile_comment -->

<details open><summary><h3>Greptile Summary</h3></summary>

This PR makes Telegram startup notices clearable through the messaging
workflow. The main changes are:

- Moves the Telegram online notice out of the SDK runtime and into
workflow-owned publication.
- Adds a startup-notice intent to platform composition and publishes it
after runtime start and restored-status repair.
- Records delivered startup notice IDs for later `/clear` ownership,
with delete compensation on interrupted ownership transfer.
- Defers standalone `/clear` command ID recording so it cannot evict
older deletion targets at the log cap.
- Adds tests and smoke coverage for startup notice clearing,
cancellation, failures, cap pressure, persistence, and startup ordering.
- Bumps the package version and lockfile to 3.5.10.
</details>

<h3>Confidence Score: 5/5</h3>

This looks safe to merge.

No blocking issues were found in the changed code.

No files need attention.

<details><summary><h3><a href="https://www.greptile.com/trex"><img
alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="20" align="absmiddle"></a> T-Rex Logs</h3></summary>

**What T-Rex did**
- A focused proof run for the telegram startup tests completed, showing
121 tests passed in 2.94 seconds with EXIT\_CODE 0.
- A smoke proof run for the same flow completed, showing 18 tests
skipped in 0.97 seconds with EXIT\_CODE 0.
- The shell wrapper issue was addressed by re-running with bash -lc,
producing a clean result with EXIT\_CODE 0 in the final artifact.

<a
href="https://app.greptile.com/trex/runs/14118230/artifacts"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=4"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"><img
alt="View all artifacts"
src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"></picture></a>

<sub><a href="https://www.greptile.com/trex"><img alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="14" align="absmiddle"></a> Ran code and verified through
T-Rex</sub>
</details>

<details open><summary><h3>Important Files Changed</h3></summary>

| Filename | Overview |
|----------|----------|
| src/free_claude_code/messaging/workflow.py | Adds workflow-owned
startup notice sending, tracking, compensation, and clear-generation
ordering. |
| src/free_claude_code/messaging/turn_intake.py | Defers standalone
`/clear` command ID recording until failure or cancellation paths need
it. |
| src/free_claude_code/runtime/application.py | Publishes optional
startup notices after messaging runtime start and restored-status
repair. |
| src/free_claude_code/messaging/platforms/factory.py | Creates a
Telegram startup-notice intent when an allowed Telegram user is
configured. |
| src/free_claude_code/messaging/platforms/telegram.py | Removes the
direct Telegram runtime startup-message side effect. |

</details>

<sub>Reviews (2): Last reviewed commit: ["Make Telegram startup notices
clearable"](https://github.com/alishahryar1/free-claude-code/commit/6e779006e0cdaf1df24c27a8d04784e2d7220a66)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=43565904)</sub>

<!-- /greptile_comment -->
2026-07-11 16:27:58 -07:00

192 行
7.0 KiB
Python

"""Tests for messaging platform factory."""
from unittest.mock import MagicMock, patch
from free_claude_code.messaging.platforms.factory import (
MessagingPlatformOptions,
create_messaging_components,
)
from free_claude_code.messaging.platforms.ports import MessagingStartupNotice
class TestCreateMessagingComponents:
"""Tests for create_messaging_components factory function."""
def test_telegram_with_token(self):
"""Create Telegram platform when bot_token is provided."""
mock_runtime = MagicMock()
mock_runtime.name = "telegram"
mock_runtime.outbound = MagicMock()
limiter = MagicMock()
transcriber = MagicMock()
with (
patch(
"free_claude_code.messaging.platforms.factory.MessagingRateLimiter",
return_value=limiter,
) as limiter_cls,
patch(
"free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True
),
patch(
"free_claude_code.messaging.platforms.telegram.TelegramRuntime",
return_value=mock_runtime,
) as runtime_cls,
):
result = create_messaging_components(
"telegram",
MessagingPlatformOptions(
telegram_bot_token="test_token",
allowed_telegram_user_id="12345",
telegram_proxy_url="socks5://127.0.0.1:1080",
transcriber=transcriber,
messaging_rate_limit=7,
messaging_rate_window=2.5,
),
)
assert result is not None
assert result.runtime is mock_runtime
assert result.outbound is mock_runtime.outbound
assert result.voice_cancellation is mock_runtime
assert result.startup_notice == MessagingStartupNotice(
chat_id="12345",
transport_label="Bot API",
)
limiter_cls.assert_called_once_with(
rate_limit=7,
rate_window=2.5,
log_error_details=False,
)
runtime_cls.assert_called_once_with(
bot_token="test_token",
allowed_user_id="12345",
telegram_proxy_url="socks5://127.0.0.1:1080",
limiter=limiter,
transcriber=transcriber,
log_raw_messaging_content=False,
log_api_error_tracebacks=False,
)
def test_telegram_without_token(self):
"""Return None when no bot_token for Telegram."""
result = create_messaging_components("telegram")
assert result is None
def test_telegram_empty_token(self):
"""Return None when bot_token is empty string."""
result = create_messaging_components(
"telegram", MessagingPlatformOptions(telegram_bot_token="")
)
assert result is None
def test_discord_with_token(self):
"""Create Discord platform when discord_bot_token is provided."""
mock_runtime = MagicMock()
mock_runtime.name = "discord"
mock_runtime.outbound = MagicMock()
limiter = MagicMock()
transcriber = MagicMock()
with (
patch(
"free_claude_code.messaging.platforms.factory.MessagingRateLimiter",
return_value=limiter,
) as limiter_cls,
patch(
"free_claude_code.messaging.platforms.discord.DISCORD_AVAILABLE", True
),
patch(
"free_claude_code.messaging.platforms.discord.DiscordRuntime",
return_value=mock_runtime,
) as runtime_cls,
):
result = create_messaging_components(
"discord",
MessagingPlatformOptions(
discord_bot_token="test_token",
allowed_discord_channels="123,456",
transcriber=transcriber,
messaging_rate_limit=3,
messaging_rate_window=4.5,
),
)
assert result is not None
assert result.runtime is mock_runtime
assert result.outbound is mock_runtime.outbound
assert result.voice_cancellation is mock_runtime
assert result.startup_notice is None
limiter_cls.assert_called_once_with(
rate_limit=3,
rate_window=4.5,
log_error_details=False,
)
runtime_cls.assert_called_once_with(
bot_token="test_token",
allowed_channel_ids="123,456",
limiter=limiter,
transcriber=transcriber,
log_raw_messaging_content=False,
log_api_error_tracebacks=False,
)
def test_discord_without_token(self):
"""Return None when no discord_bot_token for Discord."""
result = create_messaging_components("discord")
assert result is None
def test_discord_empty_token(self):
"""Return None when discord_bot_token is empty string."""
result = create_messaging_components(
"discord",
MessagingPlatformOptions(
discord_bot_token="",
allowed_discord_channels="123",
),
)
assert result is None
def test_unknown_platform(self):
"""Return None for unknown platform types."""
result = create_messaging_components("slack")
assert result is None
def test_unknown_platform_with_kwargs(self):
"""Return None for unknown platform even with kwargs."""
result = create_messaging_components(
"slack", MessagingPlatformOptions(telegram_bot_token="token")
)
assert result is None
def test_separate_factory_calls_construct_distinct_limiters(self):
"""Each selected platform runtime owns a new limiter instance."""
runtime = MagicMock(name="runtime")
runtime.name = "telegram"
runtime.outbound = MagicMock()
with (
patch(
"free_claude_code.messaging.platforms.telegram.TelegramRuntime",
return_value=runtime,
) as runtime_cls,
patch(
"free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True
),
):
first = create_messaging_components(
"telegram",
MessagingPlatformOptions(telegram_bot_token="one"),
)
second = create_messaging_components(
"telegram",
MessagingPlatformOptions(telegram_bot_token="two"),
)
assert first is not None
assert second is not None
assert first.startup_notice is None
assert second.startup_notice is None
first_limiter = runtime_cls.call_args_list[0].kwargs["limiter"]
second_limiter = runtime_cls.call_args_list[1].kwargs["limiter"]
assert first_limiter is not second_limiter
assert runtime_cls.call_args_list[0].kwargs["transcriber"] is None
assert runtime_cls.call_args_list[1].kwargs["transcriber"] is None