Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
75b2215
feat(telegram): add Telegram channel integration
salma-marei Aug 9, 2026
adb2ee3
feat(telegram): add configuration UI
salma-marei Aug 10, 2026
1bc39f7
fix(telegram): persist private users correctly
salma-marei Aug 10, 2026
c1da9f6
feat(telegram): add health diagnostics
salma-marei Aug 10, 2026
c0da883
feat(telegram): complete message formatting
salma-marei Aug 10, 2026
a88f7c4
chore(telegram): normalize source encoding
salma-marei Aug 12, 2026
3784e46
fix(init): keep channel setup out of bootstrap
salma-marei Aug 12, 2026
65322b2
fix(telegram): register reminder delivery transport
salma-marei Aug 13, 2026
c7f4ad0
fix(telegram): support current session reminders
salma-marei Aug 13, 2026
5a14ca0
test(telegram): verify reminder delivery failure
salma-marei Aug 13, 2026
a7f9972
fix(telegram): format markdown tables safely
salma-marei Aug 13, 2026
f4fce96
fix(config): include telegram in channel summary
salma-marei Aug 13, 2026
e7c5e87
Merge dev into the Telegram channel branch
Aaronontheweb Sep 12, 2026
7e57c46
Merge branch 'dev' into telegram-integration
Aaronontheweb Sep 12, 2026
c71ac35
feat(telegram): render markdown tables as native rich messages
salma-marei Sep 10, 2026
883c10e
feat(telegram): inject fakeable bot API client behind transport
salma-marei Sep 17, 2026
e7725b7
fix(telegram): retry plain text only for entity parse errors
salma-marei Sep 17, 2026
339f60a
fix(telegram): enforce attachment size limit during download
salma-marei Sep 17, 2026
04d074a
feat(telegram): supervise initial startup failures
salma-marei Sep 17, 2026
f1987c7
feat(telegram): isolate forum topics by session
salma-marei Sep 17, 2026
76cb20c
fix(cli): persist rotated Telegram credentials
salma-marei Sep 17, 2026
a9b44d9
test(cli): cover Telegram configuration persistence
salma-marei Sep 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<SlackNetVersion>0.18.0</SlackNetVersion>
<DiscordNetVersion>3.20.1</DiscordNetVersion>
<MattermostNetVersion>5.0.10</MattermostNetVersion>
<TelegramBotVersion>22.10.2.1</TelegramBotVersion>
<MicrosoftExtensionsAIVersion>10.8.3</MicrosoftExtensionsAIVersion>
<!-- Microsoft.Extensions.TimeProvider.Testing ships on the dotnet/extensions minor
cadence and has no 10.8.x patch, so it cannot share MicrosoftExtensionsAIVersion
Expand Down Expand Up @@ -77,6 +78,7 @@
<PackageVersion Include="Mattermost.NET" Version="$(MattermostNetVersion)" />
<PackageVersion Include="SlackNet" Version="$(SlackNetVersion)" />
<PackageVersion Include="SlackNet.Extensions.DependencyInjection" Version="$(SlackNetVersion)" />
<PackageVersion Include="Telegram.Bot" Version="$(TelegramBotVersion)" />
<PackageVersion Include="Cronos" Version="0.13.0" />
<PackageVersion Include="Netclaw.SkillClient" Version="0.4.1" />
<PackageVersion Include="ShellSyntaxTree" Version="0.3.5" />
Expand Down Expand Up @@ -160,4 +162,4 @@
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-2m69-gcr7-jv3q" />
</ItemGroup>
</Project>
</Project>
2 changes: 2 additions & 0 deletions Netclaw.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<Project Path="src/Netclaw.Channels.Discord/Netclaw.Channels.Discord.csproj" />
<Project Path="src/Netclaw.Channels.Mattermost/Netclaw.Channels.Mattermost.csproj" />
<Project Path="src/Netclaw.Channels.Slack/Netclaw.Channels.Slack.csproj" />
<Project Path="src/Netclaw.Channels.Telegram/Netclaw.Channels.Telegram.csproj" />
<Project Path="src/Netclaw.Configuration.Tests/Netclaw.Configuration.Tests.csproj" />
<Project Path="src/Netclaw.Configuration/Netclaw.Configuration.csproj" />
<Project Path="src/Netclaw.Daemon.Tests/Netclaw.Daemon.Tests.csproj" />
Expand All @@ -38,6 +39,7 @@
<Folder Name="/samples/">
<Project Path="samples/Netclaw.Demo.AppHost/Netclaw.Demo.AppHost.csproj" />
<Project Path="samples/Netclaw.Demo.AppHost.IntegrationTests/Netclaw.Demo.AppHost.IntegrationTests.csproj" />
<Project Path="samples/Netclaw.Telegram.TestHost/Netclaw.Telegram.TestHost.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Netclaw.SmokeLlmServer.Tests/Netclaw.SmokeLlmServer.Tests.csproj" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ debugging a daemon-wide problem → read `daemon.log`.
| Memory embedding/relevance model unavailable | Fires a `memory.embedding_model.unavailable` / `memory.relevance_model.unavailable` operational alert (once per model per daemon run) naming the model, failure reason, and consequence when `Memory.Embeddings.Enabled=true` and either ONNX model fails to provision or load; see `netclaw-memory`'s Embeddings section and `netclaw doctor`'s Memory Embeddings / Memory Relevance Gate checks |
| Daemon won't start | crash logs at `<NETCLAW_HOME>/logs/crash-*.log` (`NETCLAW_HOME` defaults to `~/.netclaw`) |
| Docker daemon cannot create `/home/netclaw/.netclaw/*` | Official image entrypoint repairs writable bind mounts to UID/GID `1654:1654`; if bypassed or read-only, run `sudo chown -R 1654:1654 <host-data-dir>` or use a Docker named volume |
| Discord/Slack channel offline | `netclaw status` shows the channel `disconnected` with a reason. Discord may also report `degraded` when Discord.Net says the socket is connected but the gateway is not ready, such as after a resumed session that Netclaw is replacing with a clean reconnect. A misconfigured channel (bad token, missing Discord Message Content intent) degrades only that channel — the daemon keeps running and other channels are unaffected. A transient network failure retries automatically; a config/permission failure stays offline until the operator fixes the config and restarts the daemon. |
| Discord/Slack/Telegram channel offline | `netclaw status` shows the channel `disconnected` with a reason. Discord may also report `degraded` when Discord.Net says the socket is connected but the gateway is not ready, such as after a resumed session that Netclaw is replacing with a clean reconnect. Telegram reports polling failures and returns to healthy after it receives another update. Run `netclaw doctor` to validate the Telegram token, configured group access, and user/chat allow-lists. A misconfigured channel degrades only that channel — the daemon keeps running and other channels are unaffected. A transient network failure retries automatically; a config or permission failure stays offline until the operator fixes the config and restarts the daemon. |
| `command not found` for `netclaw`/`dotnet`/a user tool from the shell tool when the daemon runs as a systemd service | The systemd `--user` service does not inherit your login-shell `PATH`; `netclaw daemon install` captures it into `~/.netclaw/config/daemon.env`. Run `netclaw doctor` (the **Systemd Unit PATH** check flags a missing/stale/legacy env file), then `netclaw doctor --fix` to rehydrate `PATH` from your current shell (or re-run `netclaw daemon install`), and finally `systemctl --user restart netclaw`. Installed a new tool after install? Its dir won't be seen until you re-run one of those and restart. Per-directory managers (`mise`/`asdf`/`direnv`) are not captured. |

Slack health reads the live Socket Mode state. The connection supervisor checks this state every five seconds.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ translate it to the IANA id (`America/New_York`) before scheduling.
Delivery contract parameters:

- `delivery_kind`: required, one of `current_session`, `channel`, `none`
- `delivery_transport`: required when `delivery_kind=channel` (e.g. `slack`, `discord`, `mattermost`)
- `delivery_transport`: required when `delivery_kind=channel` (e.g. `slack`, `discord`, `mattermost`, `telegram`)
- `delivery_address`: required when `delivery_kind=channel` (`#channel`, `@user`, or canonical ID)
- `delivery_required`: optional bool, default `true`; set `false` only for audit/cleanup tasks
- `delivery_instructions`: optional content guidance only (never routing)
Expand All @@ -48,13 +48,15 @@ Rules:
- Always choose `delivery_kind` explicitly.
- Do not try to route via `delivery_instructions`.
- `current_session` is the session check-back path and should be preferred for
conversational follow-ups in Slack/TUI/SignalR sessions.
conversational follow-ups in Slack, Discord, Mattermost, Telegram, TUI, and SignalR sessions.
- `channel` requires both transport + address and resolves names/handles to
canonical IDs at set time; unresolved targets fail loud.
- Discord reminder targets must be explicit because channel IDs and user IDs are
both snowflakes: use `channel:<channelId>` or `<#channelId>` for channel posts,
and `dm:<userId>`, `@<userId>`, or `<@userId>` for DMs. Do not pass a bare
Discord ID.
- Telegram reminder targets use signed numeric IDs. A positive ID is an allowed
direct-message user. A negative ID is an allowed group or channel.
- `none` runs silently (history still records execution).
- `expires_in` is not valid for `once` reminders; omit it for one-shot schedules.
- For recurring reminders that are permanently complete (PR merged, deploy done,
Expand Down
15 changes: 15 additions & 0 deletions samples/Netclaw.Telegram.TestHost/Netclaw.Telegram.TestHost.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Netclaw.Channels.Telegram\Netclaw.Channels.Telegram.csproj" />
<ProjectReference Include="..\..\src\Netclaw.Configuration\Netclaw.Configuration.csproj" />
</ItemGroup>

</Project>
70 changes: 70 additions & 0 deletions samples/Netclaw.Telegram.TestHost/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// -----------------------------------------------------------------------
// <copyright file="Program.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging.Abstractions;
using Netclaw.Channels.Telegram;
using Netclaw.Configuration;
using Netclaw.Configuration.Secrets;

var paths = new NetclawPaths();
paths.EnsureDirectoriesExist();

if (!File.Exists(paths.SecretsPath))
{
Console.Error.WriteLine($"Netclaw secrets file was not found: {paths.SecretsPath}");
return 1;
}

var root = JsonNode.Parse(await File.ReadAllTextAsync(paths.SecretsPath))?.AsObject();
var storedToken = root?["Telegram"]?["BotToken"]?.GetValue<string>();
if (string.IsNullOrWhiteSpace(storedToken))
{
Console.Error.WriteLine("Telegram.BotToken was not found in Netclaw secrets.");
return 1;
}

var protector = SecretsProtection.CreateProtector(paths);
var token = ISecretsProtector.IsEncrypted(storedToken)
? protector.Unprotect(storedToken)
: storedToken;

var options = new TelegramChannelOptions
{
Enabled = true,
BotToken = new SensitiveString(token),
AllowDirectMessages = true
};

await using var transport = new TelegramTransport(options, NullLogger<TelegramTransport>.Instance);
transport.MessageReceived += message => transport.SendTextAsync(
message.ChatId,
$"Netclaw received: {message.Text}");
using var stopSource = new CancellationTokenSource();
Console.CancelKeyPress += (_, eventArgs) =>
{
eventArgs.Cancel = true;
stopSource.Cancel();
};

try
{
await transport.StartAsync(stopSource.Token);
Console.WriteLine("Telegram test bot is active.");
Console.WriteLine("Send a text message to @netclaw_agent_bot.");
Console.WriteLine("Press Ctrl+C to stop.");
await Task.Delay(Timeout.InfiniteTimeSpan, stopSource.Token);
}
catch (OperationCanceledException) when (stopSource.IsCancellationRequested)
{
Console.WriteLine("Telegram test bot stopped.");
}
catch (Exception ex)
{
Console.Error.WriteLine($"Telegram test bot failed: {ex.Message}");
return 1;
}

return 0;
2 changes: 1 addition & 1 deletion scripts/smoke/run-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ SMOKE_LOG_DIR="${SMOKE_LOG_DIR:-${ROOT_DIR}/smoke-logs}"

# Cheapest harness checks first so a harness-level break fails fast
# before paying for the wizard + probe tapes.
LIGHT_TAPES=(help init-wizard init-existing init-redo-identity provider-add provider-rename config-search config-exposure config-posture config-features config-audience config-channels config-mention-thread config-surfaces config-ops-surfaces config-workspaces-picker config-skill-picker config-back-nav tui-cleanup mcp-permissions mcp-permissions-save approvals model-manager sessions-tui)
LIGHT_TAPES=(help init-wizard init-existing init-redo-identity provider-add provider-rename config-search config-exposure config-posture config-features config-audience config-channels config-telegram config-mention-thread config-surfaces config-ops-surfaces config-workspaces-picker config-skill-picker config-back-nav tui-cleanup mcp-permissions mcp-permissions-save approvals model-manager sessions-tui)
FULL_TAPES=("${LIGHT_TAPES[@]}")

LIGHT_SCENARIOS=(
Expand Down
69 changes: 69 additions & 0 deletions src/Netclaw.Actors.Tests/Channels/TelegramAclPolicyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// -----------------------------------------------------------------------
// <copyright file="TelegramAclPolicyTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
using Netclaw.Channels;
using Netclaw.Channels.Telegram;
using Xunit;

namespace Netclaw.Actors.Tests.Channels;

public sealed class TelegramAclPolicyTests
{
[Fact]
public void Direct_message_is_denied_when_disabled()
{
var result = TelegramAclPolicy.EvaluateInbound(Message(true), new TelegramChannelOptions());
Assert.False(result.IsAllowed);
Assert.Equal(AclDenyReasons.DirectMessagesDisabled, result.DenyReason);
}

[Fact]
public void Allowed_direct_message_is_accepted()
{
var options = new TelegramChannelOptions { AllowDirectMessages = true };
Assert.True(TelegramAclPolicy.EvaluateInbound(Message(true), options).IsAllowed);
}

[Fact]
public void Group_message_requires_allowed_chat()
{
var result = TelegramAclPolicy.EvaluateInbound(Message(false), new TelegramChannelOptions());
Assert.False(result.IsAllowed);
Assert.Equal(AclDenyReasons.ChannelNotAllowed, result.DenyReason);
}

[Fact]
public void Allowed_group_and_user_are_accepted()
{
var options = new TelegramChannelOptions
{
AllowedChatIds = ["123"],
AllowedUserIds = ["456"]
};
Assert.True(TelegramAclPolicy.EvaluateInbound(Message(false), options).IsAllowed);
}

[Fact]
public void Unknown_user_is_denied_when_user_list_is_restricted()
{
var options = new TelegramChannelOptions { AllowDirectMessages = true, AllowedUserIds = ["999"] };
var result = TelegramAclPolicy.EvaluateInbound(Message(true), options);
Assert.False(result.IsAllowed);
Assert.Equal(AclDenyReasons.UserNotAllowed, result.DenyReason);
}

[Fact]
public void Missing_user_is_denied()
{
var message = Message(true) with { UserId = null };
var result = TelegramAclPolicy.EvaluateInbound(
message, new TelegramChannelOptions { AllowDirectMessages = true });
Assert.False(result.IsAllowed);
Assert.Equal(AclDenyReasons.MissingUserId, result.DenyReason);
}

private static TelegramInboundMessage Message(bool isDirect) =>
new(123, 456, 1, "hello", isDirect);
}
64 changes: 64 additions & 0 deletions src/Netclaw.Actors.Tests/Channels/TelegramAddressResolverTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// -----------------------------------------------------------------------
// <copyright file="TelegramAddressResolverTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
using Netclaw.Actors.Channels;
using Netclaw.Channels;
using Netclaw.Channels.Telegram;

namespace Netclaw.Actors.Tests.Channels;

public sealed class TelegramAddressResolverTests
{
private static readonly ChannelDescriptorKey TelegramKey =
ChannelDescriptorKey.FromChannelType(ChannelType.Telegram);

[Fact]
public async Task Resolves_only_configured_private_user()
{
var resolver = new TelegramAddressResolver(new TelegramChannelOptions
{
AllowDirectMessages = true,
AllowedUserIds = ["6875639362"]
});

var allowed = await resolver.ResolveAsync(new ChannelAddressResolutionRequest(
TelegramKey, ChannelAddressKind.DirectMessage, "6875639362"), TestContext.Current.CancellationToken);
var denied = await resolver.ResolveAsync(new ChannelAddressResolutionRequest(
TelegramKey, ChannelAddressKind.DirectMessage, "123456"), TestContext.Current.CancellationToken);

Assert.Equal(ChannelAddressResolutionStatus.Resolved, allowed.Status);
Assert.Equal("6875639362", Assert.Single(allowed.Candidates).StableId);
Assert.Equal(ChannelAddressResolutionStatus.NotFound, denied.Status);
}

[Fact]
public async Task Lists_only_configured_group_destinations()
{
var resolver = new TelegramAddressResolver(new TelegramChannelOptions
{
AllowedChatIds = ["-5364308250", "-5364308250", "invalid"]
});

var result = await resolver.ListDestinationsAsync(TestContext.Current.CancellationToken);

var destination = Assert.Single(result.Candidates);
Assert.Equal("-5364308250", destination.StableId);
Assert.Equal(ChannelAddressKind.Destination, destination.AddressKind);
}

[Fact]
public async Task Rejects_unconfigured_group()
{
var resolver = new TelegramAddressResolver(new TelegramChannelOptions
{
AllowedChatIds = ["-5364308250"]
});

var result = await resolver.ResolveAsync(new ChannelAddressResolutionRequest(
TelegramKey, ChannelAddressKind.Destination, "-999"), TestContext.Current.CancellationToken);

Assert.Equal(ChannelAddressResolutionStatus.NotFound, result.Status);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// -----------------------------------------------------------------------
// <copyright file="TelegramApprovalPromptBuilderTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
using Netclaw.Actors.Protocol;
using Netclaw.Channels.Telegram;
using Netclaw.Tools;
using Xunit;
using static Netclaw.Actors.Sessions.SessionProtocol;

namespace Netclaw.Actors.Tests.Channels;

public sealed class TelegramApprovalPromptBuilderTests
{
[Fact]
public void Prompt_contains_tool_action_and_instruction()
{
var prompt = TelegramApprovalPromptBuilder.BuildPrompt(Request());

Assert.Contains("Tool approval required", prompt, StringComparison.Ordinal);
Assert.Contains("shell_execute", prompt, StringComparison.Ordinal);
Assert.Contains("git push origin main", prompt, StringComparison.Ordinal);
Assert.Contains("Choose an action below", prompt, StringComparison.Ordinal);
}

[Theory]
[InlineData(ApprovalOptionKeys.ApproveOnce, "Once", "✅")]
[InlineData(ApprovalOptionKeys.Deny, "Deny", "⛔")]
public void Resolved_prompt_contains_decision(string key, string label, string marker)
{
var prompt = TelegramApprovalPromptBuilder.BuildResolvedPrompt(Request(), key, 123);

Assert.Contains(label, prompt, StringComparison.Ordinal);
Assert.Contains(marker, prompt, StringComparison.Ordinal);
Assert.Contains("123", prompt, StringComparison.Ordinal);
}

private static ToolInteractionRequest Request() => new()
{
SessionId = new SessionId("123/chat"),
CallId = new ToolCallId("call-1"),
Kind = "approval",
ToolName = new ToolName("shell_execute"),
DisplayText = "git push origin main",
Options =
[
new ToolInteractionOption(ApprovalOptionKeys.ApproveOnceKey, ApprovalOptionKeys.ApproveOnceLabel),
new ToolInteractionOption(ApprovalOptionKeys.DenyKey, ApprovalOptionKeys.DenyLabel)
]
};
}
Loading