Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
<ProjectReference Include="..\platforms\Aevatar.GAgents.Platform.Telegram\Aevatar.GAgents.Platform.Telegram.csproj" />
<ProjectReference Include="..\..\src\platform\Aevatar.GAgentService.Abstractions\Aevatar.GAgentService.Abstractions.csproj" />
<ProjectReference Include="..\..\src\workflow\Aevatar.Workflow.Application.Abstractions\Aevatar.Workflow.Application.Abstractions.csproj" />
<ProjectReference Include="..\..\src\workflow\Aevatar.Workflow.Abstractions\Aevatar.Workflow.Abstractions.csproj" />
<ProjectReference Include="..\..\src\workflow\Aevatar.Workflow.Core\Aevatar.Workflow.Core.csproj" />
<ProjectReference Include="..\Aevatar.GAgents.NyxidChat\Aevatar.GAgents.NyxidChat.csproj" />
<ProjectReference Include="..\Aevatar.GAgents.Household\Aevatar.GAgents.Household.csproj" />
</ItemGroup>
Expand Down
30 changes: 24 additions & 6 deletions agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTemplates.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ public static bool TryBuildSocialMediaSpec(
string topic,
string? audience,
string? style,
string? deliveryProviderSlug,
string? publishProviderSlug,
out SocialMediaTemplateSpec? spec,
out string? error)
{
Expand All @@ -103,6 +105,8 @@ public static bool TryBuildSocialMediaSpec(

var normalizedAudience = NormalizeOptional(audience) ?? "general followers";
var normalizedStyle = NormalizeOptional(style) ?? "clear, concise, and professional";
var normalizedDeliverySlug = NormalizeOptional(deliveryProviderSlug) ?? "api-lark-bot";
var normalizedPublishSlug = NormalizeOptional(publishProviderSlug) ?? "api-twitter";
var workflowId = BuildSocialMediaWorkflowId(normalizedAgentId);
var workflowName = BuildSocialMediaWorkflowName(normalizedAgentId);
var displayName = $"Social Media Approval {normalizedAgentId}";
Expand All @@ -117,8 +121,10 @@ public static bool TryBuildSocialMediaSpec(
normalizedAgentId,
normalizedTopic,
normalizedAudience,
normalizedStyle),
ExecutionPrompt: executionPrompt);
normalizedStyle,
normalizedPublishSlug),
ExecutionPrompt: executionPrompt,
RequiredServiceSlugs: [normalizedDeliverySlug, normalizedPublishSlug]);
return true;
}

Expand All @@ -133,11 +139,12 @@ private static string BuildSocialMediaWorkflowYaml(
string deliveryTargetId,
string topic,
string audience,
string style)
string style,
string publishProviderSlug)
{
return $$"""
name: {{workflowName}}
description: Generate a social media draft and request human approval in Feishu.
description: Generate a social media draft, request human approval in Feishu, and publish the approved post to Twitter (X).

roles:
- id: writer
Expand Down Expand Up @@ -170,9 +177,19 @@ Draft one short social media post.
delivery_target_id: "{{EscapeDoubleQuoted(deliveryTargetId)}}"
on_reject: skip
branches:
"true": done
"true": publish_to_twitter
"false": done

- id: publish_to_twitter
type: twitter_publish
parameters:
publish_provider_slug: "{{EscapeDoubleQuoted(publishProviderSlug)}}"
delivery_target_id: "{{EscapeDoubleQuoted(deliveryTargetId)}}"
on_error:
strategy: skip
default_output: "twitter_publish_failed"
next: done

- id: done
type: assign
parameters:
Expand Down Expand Up @@ -230,4 +247,5 @@ internal sealed record SocialMediaTemplateSpec(
string WorkflowName,
string DisplayName,
string WorkflowYaml,
string ExecutionPrompt);
string ExecutionPrompt,
IReadOnlyList<string> RequiredServiceSlugs);
137 changes: 134 additions & 3 deletions agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ public AgentBuilderTool(
"type": "string",
"description": "Outbound Nyx proxy slug (default: api-lark-bot)"
},
"publish_provider_slug": {
"type": "string",
"description": "Optional Nyx proxy slug used to publish approved content (default: api-twitter for the social_media template)"
},
"run_immediately": {
"type": "boolean",
"description": "When true, trigger one execution right after creation"
Expand Down Expand Up @@ -373,9 +377,12 @@ private async Task<string> CreateSocialMediaAgentAsync(
return """{"error":"Could not resolve current NyxID user id"}""";

var providerSlug = (args.Str("nyx_provider_slug") ?? "api-lark-bot").Trim();
var requiredServiceIds = await ResolveProxyServiceIdsAsync(nyxClient, token, [providerSlug], ct);
if (requiredServiceIds.errorJson != null)
return requiredServiceIds.errorJson;
// The social_media template now publishes the approved post to Twitter (X) via the
// api-twitter NyxID proxy in addition to delivering the approval card via api-lark-bot
// (issue #216). Mint the agent api-key with both slugs so a single key carries both
// entitlements; without api-twitter here, NyxID's `allowed_service_ids` enforcement
// (api_keys.rs / proxy.rs) would 403 every publish call regardless of OAuth scope.
var publishProviderSlug = (args.Str("publish_provider_slug") ?? "api-twitter").Trim();

var agentId = string.IsNullOrWhiteSpace(args.Str("agent_id"))
? WorkflowAgentDefaults.GenerateActorId()
Expand All @@ -386,12 +393,27 @@ private async Task<string> CreateSocialMediaAgentAsync(
args.Str("topic") ?? string.Empty,
args.Str("audience"),
args.Str("style"),
providerSlug,
publishProviderSlug,
out var templateSpec,
out var templateError))
{
return JsonSerializer.Serialize(new { error = templateError });
}

// Resolve service IDs from the spec's authoritative slug list (parity with
// daily_report's TemplateSpec.RequiredServiceSlugs — PR #461 review item #6). Inlined
// hardcoded `[providerSlug, publishProviderSlug]` was fine for two slugs but would
// drift if a third slug were ever added; route through the spec so the source of
// truth lives next to the workflow YAML.
var requiredServiceIds = await ResolveProxyServiceIdsAsync(
nyxClient,
token,
templateSpec!.RequiredServiceSlugs,
ct);
if (requiredServiceIds.errorJson != null)
return requiredServiceIds.errorJson;

var createKeyResponse = await nyxClient.CreateApiKeyAsync(
token,
BuildCreateApiKeyPayload(agentId, requiredServiceIds.value!),
Expand All @@ -403,6 +425,18 @@ private async Task<string> CreateSocialMediaAgentAsync(
if (!TryParseApiKeyCreateResponse(createKeyResponse, out var apiKeyId, out var apiKeyValue, out var apiKeyError))
return JsonSerializer.Serialize(new { error = apiKeyError });

// Mirror the daily_report preflight (#411 / #418) for Twitter: the user may not have
// connected Twitter at NyxID yet, or may have revoked the OAuth grant at x.com between
// connect-time and create-time. Surfacing 401/403 here keeps us from persisting a
// social_media agent whose every approved post would fail at publish time. Best-effort
// revoke the freshly minted key on failure so retries don't accumulate orphan keys.
var preflight = await PreflightTwitterProxyAsync(nyxClient, apiKeyValue!, publishProviderSlug, ct);
if (preflight is not null)
{
await BestEffortRevokeApiKeyAsync(nyxClient, token, apiKeyId!, "twitter_preflight_failed", ct);
return preflight;
}

var workflowUpsert = await workflowCommandPort.UpsertAsync(
new ScopeWorkflowUpsertRequest(
scopeId.Trim(),
Expand Down Expand Up @@ -1812,6 +1846,103 @@ private static string NormalizeScopeId(string? value) =>
}
}

/// <summary>
/// Preflights Twitter (X) proxy access using the newly created agent API key against
/// Twitter's <c>/users/me</c> — a cheap read-only endpoint that returns 401 when NyxID has
/// no OAuth grant for the user (or the grant was revoked) and 403 when the bound token
/// lacks <c>tweet.write</c> scope. Returns a structured error JSON suitable for returning
/// verbatim from the tool when access is denied; returns <c>null</c> on success or on
/// probe shapes we don't classify as "fundamentally broken" (rate limits, 5xx).
/// </summary>
/// <remarks>
/// Mirrors <see cref="PreflightGitHubProxyAsync"/> (issue aevatarAI/aevatar#216 / #418).
/// Two error codes instead of one because 401 and 403 lead to different user actions:
/// 401 means "go connect Twitter at NyxID" (or re-authorize a revoked grant); 403 means
/// "the bound token is missing <c>tweet.write</c> — operator/seed bug, not user fixable".
/// The freshly minted api-key is best-effort revoked at the call site so retries don't
/// accumulate orphan proxy-scoped keys.
/// </remarks>
private async Task<string?> PreflightTwitterProxyAsync(
NyxIdApiClient nyxClient,
string apiKey,
string nyxProviderSlug,
CancellationToken ct)
{
// Cheap read-only endpoint; succeeds with the default `users.read` scope, fails with
// 401 when no OAuth grant is bound to the user behind the api-key, and 403 when the
// bound token's scope set is too narrow.
var probe = await nyxClient.ProxyRequestAsync(
apiKey,
"api-twitter",
"/users/me",
Comment on lines +1874 to +1877
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Probe Twitter preflight with configured publish proxy slug

CreateSocialMediaAgentAsync passes publish_provider_slug into PreflightTwitterProxyAsync, but the preflight call ignores that argument and hardcodes "api-twitter". If a caller configures a non-default publish proxy slug, agent creation can fail on the wrong service (or report the wrong remediation) even though the configured slug is valid, because service-id resolution and workflow YAML use one slug while preflight probes another.

Useful? React with 👍 / 👎.

"GET",
body: null,
extraHeaders: null,
ct);

if (string.IsNullOrWhiteSpace(probe))
return null;

try
{
using var doc = JsonDocument.Parse(probe);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
return null;

if (!root.TryGetProperty("error", out var errorProp))
return null;
if (errorProp.ValueKind != JsonValueKind.True && errorProp.ValueKind != JsonValueKind.String)
return null;

var status = TryReadInt32Property(root, "status")
?? TryReadInt32Property(root, "code")
?? 0;
if (status != (int)HttpStatusCode.Unauthorized && status != (int)HttpStatusCode.Forbidden)
return null;

var detail = root.TryGetProperty("message", out var msgProp) && msgProp.ValueKind == JsonValueKind.String
? msgProp.GetString()
: null;
var body = root.TryGetProperty("body", out var bodyProp) && bodyProp.ValueKind == JsonValueKind.String
? bodyProp.GetString()
: null;

// 401 vs 403 distinction is the actionable difference for the user. NyxID seeds
// `tweet.write` into the default scope set (provider_service.rs:405-450), so the
// realistic 401 path is "user has not connected Twitter yet at NyxID" or "the
// user revoked the grant at x.com/settings". A 403 here would mean either the
// seed regressed (ops escalation) or x.com itself denied the request body — keep
// both paths separate so the hint copy steers the right person.
if (status == (int)HttpStatusCode.Unauthorized)
{
return JsonSerializer.Serialize(new
{
error = "twitter_oauth_required",
detail = string.IsNullOrWhiteSpace(detail) ? "Twitter proxy returned 401 for the new agent API key." : detail,
http_status = status,
proxy_body = string.IsNullOrWhiteSpace(body) ? null : body,
hint = "Twitter (X) returned 401 through the NyxID proxy. The user has not connected Twitter at NyxID, or the OAuth grant was revoked at x.com/settings/connected_apps. Re-authorize the Twitter provider at NyxID before retrying agent creation.",
nyx_provider_slug = nyxProviderSlug,
});
}

return JsonSerializer.Serialize(new
{
error = "twitter_proxy_access_denied",
detail = string.IsNullOrWhiteSpace(detail) ? "Twitter proxy returned 403 for the new agent API key." : detail,
http_status = status,
proxy_body = string.IsNullOrWhiteSpace(body) ? null : body,
hint = "Twitter (X) returned 403 through the NyxID proxy. Default provider scope includes `tweet.write`; a 403 here usually means the seeded provider scope was downgraded or the bound token was issued before the scope was widened. Re-authorize at NyxID; if it still fails, ask ops to verify the Twitter provider seed includes `tweet.write`.",
nyx_provider_slug = nyxProviderSlug,
});
}
catch (JsonException)
{
return null;
}
}

private static int? TryReadInt32Property(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var property) ||
Expand Down
19 changes: 19 additions & 0 deletions agents/Aevatar.GAgents.ChannelRuntime/ChannelMetadataKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,23 @@ public static class ChannelMetadataKeys
/// <see cref="ConversationId"/> (which may be a NyxID-internal route id).
/// </summary>
public const string LarkChatId = "channel.lark.chat_id";
/// <summary>
/// Authoritative outbound Lark <c>receive_id</c> for the current workflow run, captured at
/// agent-create time. Propagated via <c>WorkflowChatRunRequest.Metadata</c> so workflow
/// modules (e.g. <c>TwitterPublishModule</c>) can surface their result back into the same
/// chat without having to look up the catalog at execution time.
/// </summary>
public const string LarkReceiveId = "channel.lark.receive_id";
/// <summary>Companion to <see cref="LarkReceiveId"/> — its <c>receive_id_type</c>.</summary>
public const string LarkReceiveIdType = "channel.lark.receive_id_type";
/// <summary>
/// NyxID outbound proxy slug used to deliver Lark messages from inside a workflow run
/// (default <c>api-lark-bot</c>). The <c>outbound</c> qualifier is deliberate — this is
/// specifically the routing target for Lark <em>send</em> calls (e.g.
/// <c>open-apis/im/v1/messages</c>) initiated by the workflow runtime, not a generic Lark
/// API field. PR #461 review item #4 flagged the original name (<c>channel.lark.proxy_slug</c>)
/// as ambiguous between "Lark API surface" and "NyxID provider routing" — the
/// <c>outbound_proxy_slug</c> form makes the routing-side semantics explicit.
/// </summary>
public const string LarkOutboundProxySlug = "channel.lark.outbound_proxy_slug";
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
using Aevatar.GAgents.Channel.NyxIdRelay;
using Aevatar.GAgents.Platform.Lark;
using Aevatar.GAgents.ChannelRuntime.Outbound;
using Aevatar.GAgents.ChannelRuntime.WorkflowModules;
using Aevatar.Foundation.Abstractions.HumanInteraction;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -234,6 +235,11 @@ public static IServiceCollection AddChannelRuntime(
services.TryAddSingleton<ILarkConversationInbox>(sp => sp.GetRequiredService<LarkConversationInboxRuntime>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService, LarkConversationInboxHostedService>());

// Register the channel-runtime-owned workflow module pack so the social_media template's
// `twitter_publish` step type resolves at workflow run time. AddWorkflowModulePack uses
// TryAddEnumerable, so calling this alongside AddAevatarWorkflow is safe and idempotent.
services.AddChannelRuntimeWorkflowExtensions();

return services;
}

Expand Down
10 changes: 10 additions & 0 deletions agents/Aevatar.GAgents.ChannelRuntime/WorkflowAgentGAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,16 @@ private IReadOnlyDictionary<string, string> BuildExecutionMetadata()
};
if (!string.IsNullOrWhiteSpace(State.ScopeId))
metadata["scope_id"] = State.ScopeId;
// Propagate the outbound Lark delivery target so workflow modules that need to surface
// their own status messages back into the originating chat (e.g. TwitterPublishModule
// posting "已发布: <url>" or "Twitter OAuth 过期…") can do so via the same api-lark-bot
// proxy this agent already uses, without re-resolving the catalog at run time.
if (!string.IsNullOrWhiteSpace(State.LarkReceiveId))
metadata[ChannelMetadataKeys.LarkReceiveId] = State.LarkReceiveId;
if (!string.IsNullOrWhiteSpace(State.LarkReceiveIdType))
metadata[ChannelMetadataKeys.LarkReceiveIdType] = State.LarkReceiveIdType;
if (!string.IsNullOrWhiteSpace(State.NyxProviderSlug))
metadata[ChannelMetadataKeys.LarkOutboundProxySlug] = State.NyxProviderSlug;
return metadata;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using Aevatar.Workflow.Core;
using Aevatar.Workflow.Core.Composition;

namespace Aevatar.GAgents.ChannelRuntime.WorkflowModules;

/// <summary>
/// Workflow module pack contributed by ChannelRuntime — currently registers
/// <see cref="TwitterPublishModule"/> for the social_media template's
/// <c>twitter_publish</c> step (issue aevatarAI/aevatar#216). Lives next to its
/// dependencies (<c>NyxIdApiClient</c>, <see cref="ChannelMetadataKeys"/>,
/// <see cref="LarkProxyResponse"/>) instead of in <c>Aevatar.Workflow.Core</c> so the
/// generic workflow runtime stays free of channel-specific compile-time coupling.
/// </summary>
public sealed class ChannelRuntimeWorkflowModulePack : IWorkflowModulePack
{
private static readonly IReadOnlyList<WorkflowModuleRegistration> ModuleRegistrations =
[
WorkflowModuleRegistration.Create<TwitterPublishModule>("twitter_publish"),
];

public string Name => "channelruntime.workflow";

public IReadOnlyList<WorkflowModuleRegistration> Modules => ModuleRegistrations;

public IReadOnlyList<IWorkflowModuleDependencyExpander> DependencyExpanders => [];

public IReadOnlyList<IWorkflowModuleConfigurator> Configurators => [];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Aevatar.Workflow.Core;
using Microsoft.Extensions.DependencyInjection;

namespace Aevatar.GAgents.ChannelRuntime.WorkflowModules;

/// <summary>
/// DI extension to register the ChannelRuntime workflow module pack. Hosts that compose
/// social_media template execution should call this so the <c>twitter_publish</c> step
/// type resolves at workflow run time.
/// </summary>
public static class ChannelRuntimeWorkflowModuleServiceCollectionExtensions
{
/// <summary>
/// Registers <see cref="ChannelRuntimeWorkflowModulePack"/> alongside any other module
/// packs already added to the workflow runtime. Idempotent — uses
/// <c>TryAddEnumerable</c> via <see cref="ServiceCollectionExtensions.AddWorkflowModulePack{TModulePack}"/>.
/// </summary>
public static IServiceCollection AddChannelRuntimeWorkflowExtensions(this IServiceCollection services) =>
services.AddWorkflowModulePack<ChannelRuntimeWorkflowModulePack>();
}
Loading
Loading