Skip to content

feat: Instagram production readiness — implement UploadImageToPublicUrl via Azure Blob Storage #72

@artcava

Description

@artcava

Context

IgSender already implements the Instagram Graph API two-step flow (container creation + media publish), but the private helper UploadImageToPublicUrl always throws NotImplementedException. This is the only blocker preventing Instagram from going live.

Additionally, the two Instagram time-slots in GeneratorFactory (10:00 and 18:00) are commented out and must be re-enabled once the sender is stable.

This issue covers everything required to bring Instagram to production.


Part 1 — Instagram Platform Prerequisites

Before writing any code, complete the following steps on the Instagram / Meta Developer side.

1.1 Meta Developer App

  1. Go to https://developers.facebook.com/apps and create a new app (type: Business).
  2. Add the Instagram Graph API product to the app.
  3. In App Roles, add your Instagram business account as a tester or assign it directly.

1.2 Instagram Business Account

The Graph API only works with Instagram Business or Creator accounts:

  1. Open the Instagram mobile app → Settings → Account → Switch to Professional Account.
  2. Choose Business.
  3. Connect the Instagram account to a Facebook Page (required for Graph API access).

1.3 Generate a Long-Lived Access Token

  1. In Meta for Developers, go to Tools → Graph API Explorer.
  2. Select your app and generate a User Token with scopes:
    • instagram_basic
    • instagram_content_publish
    • pages_read_engagement
  3. Exchange the short-lived token for a long-lived token (valid 60 days):
GET https://graph.facebook.com/v20.0/oauth/access_token
  ?grant_type=fb_exchange_token
  &client_id={APP_ID}
  &client_secret={APP_SECRET}
  &fb_exchange_token={SHORT_LIVED_TOKEN}
  1. Store the resulting token in IG_ACCESS_TOKEN.

⚠️ Tokens expire after 60 days. Manual rotation is required until a refresh flow is implemented.

1.4 Find Your Instagram Account ID

GET https://graph.facebook.com/v20.0/me/accounts
  ?access_token={IG_ACCESS_TOKEN}

Find the connected Facebook page, then:

GET https://graph.facebook.com/v20.0/{PAGE_ID}?fields=instagram_business_account&access_token={IG_ACCESS_TOKEN}

The instagram_business_account.id value is your IG_ACCOUNT_ID.


Part 2 — Azure Blob Storage Setup (Public Image Hosting)

The Instagram Graph API requires a publicly accessible image URL to create a media container. The simplest solution within the existing Azure infrastructure is Azure Blob Storage with anonymous read access.

2.1 Azure Setup

  1. Create (or reuse) an Azure Storage Account in the same resource group as the Azure Function.
  2. Create a Blob Container named xposter-images with Blob (anonymous read) access level.
  3. In the Function App settings, add:
Variable Description
AZURE_STORAGE_CONNECTION_STRING Connection string for the Storage Account
AZURE_STORAGE_CONTAINER_NAME Container name (e.g. xposter-images)
  1. For production, consider using Managed Identity instead of a connection string:
    • Assign Storage Blob Data Contributor role to the Function App's managed identity.
    • Use DefaultAzureCredential with a BlobServiceClient.

2.2 Blob Lifecycle Management

Images are ephemeral (only needed during the Instagram API call). Add a lifecycle rule to delete blobs older than 1 day:

{
  "rules": [{
    "name": "delete-old-images",
    "type": "Lifecycle",
    "definition": {
      "actions": { "baseBlob": { "delete": { "daysAfterModificationGreaterThan": 1 } } },
      "filters": { "blobTypes": ["blockBlob"], "prefixMatch": ["xposter-images/"] }
    }
  }]
}

Part 3 — Code Implementation

3.1 Add Azure.Storage.Blobs NuGet package

dotnet add src/XPoster.csproj package Azure.Storage.Blobs

3.2 Implement UploadImageToPublicUrl in IgSender.cs

Replace the NotImplementedException stub:

private async Task<string> UploadImageToPublicUrl(byte[] image)
{
    var connectionString = Environment.GetEnvironmentVariable("AZURE_STORAGE_CONNECTION_STRING")
        ?? throw new InvalidOperationException("AZURE_STORAGE_CONNECTION_STRING is not set.");
    var containerName = Environment.GetEnvironmentVariable("AZURE_STORAGE_CONTAINER_NAME") 
        ?? "xposter-images";

    var blobName = $"{Guid.NewGuid()}.jpg";
    var blobServiceClient = new BlobServiceClient(connectionString);
    var containerClient = blobServiceClient.GetBlobContainerClient(containerName);
    await containerClient.CreateIfNotExistsAsync(PublicAccessType.Blob);

    var blobClient = containerClient.GetBlobClient(blobName);
    using var stream = new MemoryStream(image);
    await blobClient.UploadAsync(stream, new BlobHttpHeaders { ContentType = "image/jpeg" });

    _logger.LogInformation("Image uploaded to blob: {BlobUri}", blobClient.Uri.ToString());
    return blobClient.Uri.ToString();
}

3.3 Inject dependencies via constructor

To make IgSender testable, inject IBlobStorageService (a new interface wrapping the Blob client) rather than instantiating it directly. This follows the same pattern used by InSender and XSender.

Interface:

public interface IBlobStorageService
{
    Task<string> UploadAsync(byte[] data, string contentType);
}

Update IgSender constructor to accept IBlobStorageService blobStorageService.

Register in Program.cs:

builder.Services.AddTransient<IBlobStorageService, AzureBlobStorageService>();
builder.Services.AddTransient<IgSender>();

3.4 Enable Instagram slots in GeneratorFactory

Once the sender is verified in staging, uncomment:

{ 10, MessageSender.IgSummaryFeed },
{ 18, MessageSender.IgPowerLow },

Part 4 — Environment Variables Summary

Update src/local.settings.json.example and docs/configuration.md:

Variable Required Description
IG_ACCESS_TOKEN ✅ Yes Long-lived Instagram Graph API token
IG_ACCOUNT_ID ✅ Yes Instagram Business Account numeric ID
AZURE_STORAGE_CONNECTION_STRING ✅ Yes Azure Storage connection string for image hosting
AZURE_STORAGE_CONTAINER_NAME Optional Blob container name (default: xposter-images)

Part 5 — Tests to Add

tests/SenderPlugins/IgSenderTests.cs:

  • SendAsync_WhenImageIsNull_LogsWarningAndReturnsFalse
  • SendAsync_WhenBlobUploadSucceeds_CreatesMediaContainerWithCorrectUrl
  • SendAsync_WhenMediaContainerFails_ReturnsFalse
  • SendAsync_WhenPublishFails_ReturnsFalse
  • SendAsync_WhenCaptionExceedsLimit_TruncatesCaption

Acceptance Criteria

  • UploadImageToPublicUrl uploads to Azure Blob and returns a valid HTTPS URL
  • IgSender.SendAsync successfully publishes a post with image to Instagram (tested in staging)
  • No NotImplementedException is thrown at runtime
  • AZURE_STORAGE_* variables documented in docs/configuration.md and local.settings.json.example
  • Instagram time-slots (10:00, 18:00) re-enabled in GeneratorFactory
  • All unit tests pass (mock IBlobStorageService in tests)
  • Old blobs are cleaned up by lifecycle rule (manual verification)
  • CI remains green

Related

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions