-
Notifications
You must be signed in to change notification settings - Fork 1
feat: HouseholdEntity Actor — Living with AI Demo #129
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
2d4a1cc
feat: add channel runtime for direct platform webhook callbacks
eanzhao e61be88
test: add unit tests for ChannelRuntime (registration store + Lark ad…
eanzhao 6c5b406
fix: publish session completion before persistence to avoid blocking
eanzhao bd3ce05
feat: add mock NyxID server for local development and testing
eanzhao 632ed29
fix: address 3 P1 review findings in ChannelRuntime
eanzhao 13958e2
feat: add HouseholdEntity actor for Living with AI demo
eanzhao 1a27f41
feat: expose HouseholdEntity as agent-tool for NyxIdChatGAgent
eanzhao 52f2433
Merge branch 'dev' into feature/living-with-ai
eanzhao File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
198 changes: 198 additions & 0 deletions
198
agents/Aevatar.GAgents.ChannelRuntime/Adapters/LarkPlatformAdapter.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }); | ||
| } | ||
|
|
||
| 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); | ||
| } | ||
| } | ||
29 changes: 29 additions & 0 deletions
29
agents/Aevatar.GAgents.ChannelRuntime/Aevatar.GAgents.ChannelRuntime.csproj
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
122
agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationStore.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In
TryHandleVerificationAsync, the challenge is echoed without checking the incoming token/signature againstregistration.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 👍 / 👎.