Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions aevatar.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
<Project Path="agents\Aevatar.GAgents.NyxidChat\Aevatar.GAgents.NyxidChat.csproj" />
<Project Path="agents\Aevatar.GAgents.StreamingProxy\Aevatar.GAgents.StreamingProxy.csproj" />
<Project Path="agents\Aevatar.GAgents.ChatbotClassifier\Aevatar.GAgents.ChatbotClassifier.csproj" />
<Project Path="agents\Aevatar.GAgents.ChannelRuntime\Aevatar.GAgents.ChannelRuntime.csproj" />
<Project Path="agents\Aevatar.GAgents.Household\Aevatar.GAgents.Household.csproj" />
</Folder>
<Folder Name="/demos/">
<Project Path="demos\Aevatar.Demos.Cli\Aevatar.Demos.Cli.csproj" />
Expand Down Expand Up @@ -145,9 +147,12 @@
<Project Path="test\Aevatar.Workflow.Host.Api.Tests\Aevatar.Workflow.Host.Api.Tests.csproj" />
<Project Path="test\Aevatar.Workflow.Sdk.Tests\Aevatar.Workflow.Sdk.Tests.csproj" />
<Project Path="test\Aevatar.Architecture.Tests\Aevatar.Architecture.Tests.csproj" />
<Project Path="test\Aevatar.GAgents.ChannelRuntime.Tests\Aevatar.GAgents.ChannelRuntime.Tests.csproj" />
<Project Path="test\Aevatar.GAgents.Household.Tests\Aevatar.GAgents.Household.Tests.csproj" />
</Folder>
<Folder Name="/tools/">
<Project Path="tools\Aevatar.Tools.Config\Aevatar.Tools.Config.csproj" />
<Project Path="tools\Aevatar.Tools.Cli\Aevatar.Tools.Cli.csproj" />
<Project Path="tools\Aevatar.Tools.MockNyxId\Aevatar.Tools.MockNyxId.csproj" />
</Folder>
</Solution>
198 changes: 198 additions & 0 deletions agents/Aevatar.GAgents.ChannelRuntime/Adapters/LarkPlatformAdapter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
using System.Text.Json;
using Aevatar.AI.ToolProviders.NyxId;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;

namespace Aevatar.GAgents.ChannelRuntime.Adapters;

/// <summary>
/// Platform adapter for Lark (Feishu) bot callbacks.
/// Handles URL verification challenges and im.message.receive_v1 events.
/// Outbound replies go through Nyx's api-lark-bot provider.
/// </summary>
public sealed class LarkPlatformAdapter : IPlatformAdapter
{
private readonly ILogger<LarkPlatformAdapter> _logger;

public LarkPlatformAdapter(ILogger<LarkPlatformAdapter> logger) => _logger = logger;

public string Platform => "lark";

public async Task<IResult?> TryHandleVerificationAsync(
HttpContext http, ChannelBotRegistrationEntry registration)
{
http.Request.EnableBuffering();
http.Request.Body.Position = 0;

using var doc = await JsonDocument.ParseAsync(http.Request.Body, cancellationToken: http.RequestAborted);
http.Request.Body.Position = 0;

var root = doc.RootElement;

// Lark URL verification challenge
if (root.TryGetProperty("type", out var typeProp) &&
typeProp.GetString() == "url_verification")
{
// Verify the token matches the registration before echoing the challenge.
// Without this check, any caller who can reach the callback URL could
// forge Lark payloads and drive bot traffic.
var incomingToken = root.TryGetProperty("token", out var tokenProp)
? tokenProp.GetString()
: null;

if (!string.IsNullOrWhiteSpace(registration.VerificationToken) &&
!string.Equals(incomingToken, registration.VerificationToken, StringComparison.Ordinal))
{
_logger.LogWarning(
"Lark URL verification token mismatch — rejecting challenge");
return Results.Unauthorized();
}

var challenge = root.TryGetProperty("challenge", out var ch) ? ch.GetString() : null;
_logger.LogInformation("Lark URL verification challenge accepted");
return Results.Json(new { challenge });
Comment on lines +34 to +53
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Verify Lark callback token before accepting challenge

In TryHandleVerificationAsync, the challenge is echoed without checking the incoming token/signature against registration.VerificationToken. This leaves the webhook path effectively unauthenticated: anyone who can hit the callback URL can forge Lark payloads and drive bot traffic/costs. The registration model already stores a verification token, so the adapter should reject verification and message callbacks that fail authenticity checks.

Useful? React with 👍 / 👎.

}

return null;
}

public async Task<InboundMessage?> ParseInboundAsync(
HttpContext http, ChannelBotRegistrationEntry registration)
{
http.Request.Body.Position = 0;
using var doc = await JsonDocument.ParseAsync(http.Request.Body, cancellationToken: http.RequestAborted);
var root = doc.RootElement;

// Lark v2 event callback format:
// { "schema": "2.0", "header": { "event_type": "im.message.receive_v1", ... },
// "event": { "sender": { "sender_id": { "open_id": "..." } },
// "message": { "chat_id": "...", "message_type": "text",
// "content": "{\"text\":\"hello\"}", "message_id": "..." } } }

if (!root.TryGetProperty("header", out var header))
{
_logger.LogDebug("Lark callback missing 'header' field, skipping");
return null;
}

// Verify token on v2 event callbacks (header.token) to reject forged payloads.
if (!string.IsNullOrWhiteSpace(registration.VerificationToken))
{
var headerToken = header.TryGetProperty("token", out var ht) ? ht.GetString() : null;
if (!string.Equals(headerToken, registration.VerificationToken, StringComparison.Ordinal))
{
_logger.LogWarning("Lark event callback token mismatch — ignoring");
return null;
}
}

var eventType = header.TryGetProperty("event_type", out var et) ? et.GetString() : null;
if (eventType != "im.message.receive_v1")
{
_logger.LogDebug("Lark event type {EventType} is not a message receive, skipping", eventType);
return null;
}

if (!root.TryGetProperty("event", out var eventObj))
return null;

// Extract sender
var senderId = string.Empty;
var senderName = string.Empty;
if (eventObj.TryGetProperty("sender", out var sender))
{
if (sender.TryGetProperty("sender_id", out var senderIdObj) &&
senderIdObj.TryGetProperty("open_id", out var openId))
senderId = openId.GetString() ?? string.Empty;

if (sender.TryGetProperty("sender_type", out var senderType) &&
senderType.GetString() == "bot")
{
_logger.LogDebug("Ignoring message from bot sender");
return null;
}
}

// Extract message
if (!eventObj.TryGetProperty("message", out var message))
return null;

var chatId = message.TryGetProperty("chat_id", out var cid) ? cid.GetString() : null;
var messageId = message.TryGetProperty("message_id", out var mid) ? mid.GetString() : null;
var messageType = message.TryGetProperty("message_type", out var mt) ? mt.GetString() : null;
var chatType = message.TryGetProperty("chat_type", out var ct) ? ct.GetString() : null;

if (chatId is null)
{
_logger.LogWarning("Lark message missing chat_id");
return null;
}

// Parse message content — Lark wraps text in JSON: {"text":"actual message"}
string? text = null;
if (messageType == "text" && message.TryGetProperty("content", out var content))
{
var contentStr = content.GetString();
if (contentStr is not null)
{
try
{
using var contentDoc = JsonDocument.Parse(contentStr);
text = contentDoc.RootElement.TryGetProperty("text", out var t) ? t.GetString() : contentStr;
}
catch
{
text = contentStr;
}
}
}

if (string.IsNullOrWhiteSpace(text))
{
_logger.LogDebug("Lark message has no text content (type={MessageType})", messageType);
return null;
}

_logger.LogInformation("Lark inbound: chat={ChatId}, sender={SenderId}, type={ChatType}",
chatId, senderId, chatType);

return new InboundMessage
{
Platform = Platform,
ConversationId = chatId,
SenderId = senderId,
SenderName = senderName,
Text = text,
MessageId = messageId,
ChatType = chatType,
};
}

public async Task SendReplyAsync(
string replyText,
InboundMessage inbound,
ChannelBotRegistrationEntry registration,
NyxIdApiClient nyxClient,
CancellationToken ct)
{
// Lark Send Message API: POST /open-apis/im/v1/messages?receive_id_type=chat_id
var body = JsonSerializer.Serialize(new
{
receive_id = inbound.ConversationId,
msg_type = "text",
content = JsonSerializer.Serialize(new { text = replyText }),
});

var result = await nyxClient.ProxyRequestAsync(
registration.NyxUserToken,
registration.NyxProviderSlug,
"open-apis/im/v1/messages?receive_id_type=chat_id",
"POST",
body,
extraHeaders: null,
ct);

_logger.LogInformation("Lark outbound reply sent: chat={ChatId}, slug={Slug}, result_length={Length}",
inbound.ConversationId, registration.NyxProviderSlug, result?.Length ?? 0);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Aevatar.GAgents.ChannelRuntime</AssemblyName>
<RootNamespace>Aevatar.GAgents.ChannelRuntime</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Aevatar.AI.Abstractions\Aevatar.AI.Abstractions.csproj" />
<ProjectReference Include="..\..\src\Aevatar.AI.Core\Aevatar.AI.Core.csproj" />
<ProjectReference Include="..\..\src\Aevatar.AI.ToolProviders.NyxId\Aevatar.AI.ToolProviders.NyxId.csproj" />
<ProjectReference Include="..\..\src\Aevatar.Foundation.Abstractions\Aevatar.Foundation.Abstractions.csproj" />
<ProjectReference Include="..\Aevatar.GAgents.NyxidChat\Aevatar.GAgents.NyxidChat.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Google.Protobuf" />
<PackageReference Include="Grpc.Tools">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="channel_runtime_messages.proto" GrpcServices="None" />
</ItemGroup>
</Project>
122 changes: 122 additions & 0 deletions agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
using Google.Protobuf;
using Microsoft.Extensions.Logging;

namespace Aevatar.GAgents.ChannelRuntime;

/// <summary>
/// Persistent store for channel bot registrations.
/// Uses Protobuf file-based storage at ~/.aevatar/channel-registrations.bin.
/// Thread-safe via lock; suitable for low-frequency config operations.
/// </summary>
public sealed class ChannelBotRegistrationStore
{
private readonly string _filePath;
private readonly ILogger<ChannelBotRegistrationStore> _logger;
private readonly object _lock = new();
private ChannelBotRegistrationStoreState _state;

public ChannelBotRegistrationStore(ILogger<ChannelBotRegistrationStore> logger)
{
_logger = logger;
var aevatarDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aevatar");
Directory.CreateDirectory(aevatarDir);
_filePath = Path.Combine(aevatarDir, "channel-registrations.bin");
_state = Load();
}

public ChannelBotRegistrationEntry? Get(string registrationId)
{
lock (_lock)
{
return _state.Registrations.FirstOrDefault(r => r.Id == registrationId);
}
}

public IReadOnlyList<ChannelBotRegistrationEntry> List()
{
lock (_lock)
{
return _state.Registrations.ToList();
}
}

public ChannelBotRegistrationEntry Register(
string platform,
string nyxProviderSlug,
string nyxUserToken,
string? verificationToken,
string? scopeId)
{
var entry = new ChannelBotRegistrationEntry
{
Id = Guid.NewGuid().ToString("N"),
Platform = platform,
NyxProviderSlug = nyxProviderSlug,
NyxUserToken = nyxUserToken,
VerificationToken = verificationToken ?? string.Empty,
ScopeId = scopeId ?? string.Empty,
CreatedAt = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
};

lock (_lock)
{
_state.Registrations.Add(entry);
Save();
}

_logger.LogInformation("Registered channel bot: id={Id}, platform={Platform}, slug={Slug}",
entry.Id, platform, nyxProviderSlug);
return entry;
}

public bool Delete(string registrationId)
{
lock (_lock)
{
var entry = _state.Registrations.FirstOrDefault(r => r.Id == registrationId);
if (entry is null)
return false;

_state.Registrations.Remove(entry);
Save();

_logger.LogInformation("Deleted channel bot registration: id={Id}", registrationId);
return true;
}
}

private ChannelBotRegistrationStoreState Load()
{
try
{
if (File.Exists(_filePath))
{
var bytes = File.ReadAllBytes(_filePath);
var state = ChannelBotRegistrationStoreState.Parser.ParseFrom(bytes);
_logger.LogInformation("Loaded {Count} channel bot registrations from {Path}",
state.Registrations.Count, _filePath);
return state;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load channel registrations from {Path}, starting fresh", _filePath);
}

return new ChannelBotRegistrationStoreState();
}

private void Save()
{
try
{
var bytes = _state.ToByteArray();
File.WriteAllBytes(_filePath, bytes);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save channel registrations to {Path}", _filePath);
}
}
}
Loading
Loading