forgesocial

package module
v0.6.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 16, 2026 License: AGPL-3.0 Imports: 25 Imported by: 0

README

forge-social

Social post scheduling and AI agent routing for Forge applications.

Go Reference v0.6.0 — stable. See CHANGELOG.md.

go get forge-cms.dev/forge-social@latest

What it does

forge-social adds two layers to any Forge application:

Layer 2 — Scheduler: Create ScheduledPost records; the built-in scheduler publishes them to Mastodon, LinkedIn, or X (Twitter) at the right time, with exponential backoff and automatic retries.

Layer 1 — Agent routing: Wire Forge lifecycle signals to outbound HTTP calls, so AI agents can react when content is published, scheduled, archived, or deleted.


Quick start

import (
    forgesocial "forge-cms.dev/forge-social"
    forgemcp    "forge-cms.dev/forge-mcp"
)

social := forgesocial.New(db, forgesocial.Config{
    Secret: cfg.Secret,
})

social.Register(app)   // wire OAuth callbacks + REST endpoints
defer social.Stop()    // drain scheduler + delivery worker on shutdown

// Wire MCP tools (optional).
mcpSrv := forgemcp.New(app,
    forgemcp.WithModule(social.PostModule()),
    forgemcp.WithModule(social.CredentialModule()),
    forgemcp.WithModule(social.ConfigModule()),     // create_platform_config (Admin)
    forgemcp.WithModule(social.ScheduleModule()),   // slot-queue (v0.4.0+)
)

// Wire agent routing (optional — Layer 1).
social.AddRoutes(app,
    forgesocial.OnPublish("Post", "https://agent.example.com/hooks/post-published"),
)

Platform credentials are stored in the database — no environment variables required after initial setup. Call create_platform_config via MCP (Admin role) to configure each platform.

Backwards compat: Config.Mastodon and Config.LinkedIn are still accepted as fallbacks but are deprecated. A warning is logged at startup when env-var config is present and no DB config exists for that platform.


ScheduledPost workflow

draft → scheduled → published
              ↓
           failed (up to 5 attempts, then terminal)
              ↓
           archived

Create a post via MCP tool create_scheduled_post, set scheduled_at, and the scheduler handles the rest. Call publish_scheduled_post to publish immediately without waiting.

Platforms: mastodon (default), linkedin, or x.
Body limits: Mastodon 500 characters; LinkedIn 3000 characters; X 280 characters (returns terminal error if exceeded — not truncated).
Media: Set media_url to attach an HTTPS image URL (Mastodon and LinkedIn only; X media is not yet supported).


Slot queue (v0.4.0+)

Instead of a fixed scheduled_at time, posts can be queued for the next available slot in a PublicationSchedule.

Create a schedule
// Via MCP: create_publication_schedule
// credential_id: the ID of a connected SocialCredential
// slots: JSON array of slot objects
// status: "active" (default) or "paused"

Slot format:

{ "weekday": 1, "time": "09:00", "timezone": "Europe/Copenhagen" }
  • weekday: 0 = Sunday … 6 = Saturday (matches Go time.Weekday)
  • time: HH:MM in 24-hour format
  • timezone: IANA timezone name (e.g. "Europe/Copenhagen", "America/New_York")

Each credential may have at most one schedule.

Queue a post

Set status: "queued" on create_scheduled_post (omit scheduled_at). The scheduler dequeues the oldest queued post for each credential whenever a slot fires.

draft → queued → published
Catch-up policy

If the server was offline when a slot fired, the scheduler catches up on the next tick. One post is published per missed slot. The total catch-up per tick is capped at len(slots) to avoid flooding.

Schedule status
  • active — slots fire normally
  • paused — slots are skipped; queued posts remain in the queue

OAuth connect flows

Mastodon
  1. Call create_platform_config (Admin) — set platform=mastodon, client_id, client_secret, redirect_url, instance_url
  2. Call create_social_credential (MCP) → get redirect_url
  3. Operator visits redirect_url in browser and authorises
  4. Callback stores encrypted token automatically
  5. Tokens do not expire — connect once
LinkedIn
  1. Call create_platform_config (Admin) — set platform=linkedin, client_id, client_secret, redirect_url
  2. Call create_social_credential (MCP) with platform=linkedin → get redirect_url
  3. Operator visits redirect_url in browser and authorises
  4. Callback stores token + person URN automatically
  5. Tokens expire after 60 days — repeat OAuth flow to reconnect
X (Twitter) — OAuth 2.0 + PKCE (v0.5.0+)
  1. Call create_platform_config (Admin) — set platform=x, client_id, client_secret, redirect_url
  2. Call create_social_credential (MCP) with platform=x → get redirect_url (contains PKCE challenge — single use)
  3. Operator visits redirect_url in browser and authorises
  4. Callback validates PKCE code verifier and stores token automatically
  5. The code_verifier is stored server-side; the agent never sees it

OAuth tokens are encrypted at rest with AES-256-GCM, keyed from Config.Secret. Never stored in plaintext.


MCP tools

Tool Description
create_platform_config Configure OAuth 2.0 app credentials for a platform (Admin)
create_scheduled_post Create a draft post
list_scheduled_posts List posts by status
publish_scheduled_post Publish immediately or retry a failed post
archive_scheduled_post Archive a post
delete_scheduled_post Permanently delete a post
create_social_credential Initiate OAuth connect flow; returns redirect_url
list_social_credentials List all credentials
get_social_credential Read a credential by ID
delete_social_credential Delete a credential
create_publication_schedule Create a recurring slot schedule for a credential
get_publication_schedule Read a schedule by ID
update_publication_schedule Update slots or pause/resume
list_publication_schedules List all schedules
delete_publication_schedule Delete a schedule

REST API

social.Register(app) wires REST endpoints auto-generated by Forge:

POST   /social/posts
GET    /social/posts
GET    /social/posts/{slug}
PUT    /social/posts/{slug}
DELETE /social/posts/{slug}

Bearer token required on all endpoints.


Agent routing (Layer 1)

AddRoutes is one way to act on Forge signals. For in-process handlers (audit logs, cache invalidation, SSE), use app.OnSignal() directly — see the Signal bus docs.

social.AddRoutes(app,
    forgesocial.OnPublish("Post",   "https://agent.example.com/hook"),
    forgesocial.OnArchive("Post",   "https://agent.example.com/hook"),
    forgesocial.OnSchedule("Event", "https://agent.example.com/hook"),
    forgesocial.OnDelete("Post",    "https://agent.example.com/hook"),
)

SSRF protection: Agent URLs must be HTTPS. Private IPs, localhost, and .local domains are rejected at startup (panic).

Payload
{
  "type":           "Post",
  "slug":           "my-post",
  "title":          "My Post",
  "url":            "https://mysite.com/posts/my-post",
  "timestamp":      "2026-05-12T14:30:00Z",
  "previous_state": "",
  "actor_role":     "Author",
  "actor_id":       "user-abc"
}
Signature verification

Every POST includes X-Forge-Signature: sha256=<HMAC-SHA256 of body, key=Config.Secret>.

func verifyForgeSignature(body []byte, secret []byte, header string) bool {
    mac := hmac.New(sha256.New, secret)
    mac.Write(body)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(header))
}
Retry schedule
Attempt Delay
1 30 seconds
2 2 minutes
3 10 minutes
4 1 hour
5+ Terminal

2xx = delivered. 4xx (non-429) = terminal (no retry). 429 = honour Retry-After. 5xx/network = transient retry.

Jobs survive restarts — persisted in SQLite.


Graceful shutdown

Always call social.Stop(). It waits for the scheduler and delivery worker to finish in-flight work:

defer social.Stop()

Requirements

  • Go 1.26+
  • forge-cms.dev/forge v1.20.0+
  • A forge.DB (SQLite or Postgres)

License

AGPL-3.0. See LICENSE.

Documentation

Overview

Package forgesocial provides platform publishing for Forge applications. It supports scheduling and publishing content to Mastodon and LinkedIn via OAuth 2.0.

Quick start

import forgesocial "forge-cms.dev/forge-social"

social := forgesocial.New(db, forgesocial.Config{
    Secret: cfg.Secret,
    Mastodon: forgesocial.MastodonConfig{
        ClientID:     os.Getenv("MASTODON_CLIENT_ID"),
        ClientSecret: os.Getenv("MASTODON_CLIENT_SECRET"),
        InstanceURL:  os.Getenv("MASTODON_INSTANCE_URL"),
        RedirectURL:  cfg.BaseURL + "/oauth/mastodon/callback",
    },
    LinkedIn: forgesocial.LinkedInConfig{
        ClientID:     os.Getenv("LINKEDIN_CLIENT_ID"),
        ClientSecret: os.Getenv("LINKEDIN_CLIENT_SECRET"),
        RedirectURL:  cfg.BaseURL + "/oauth/linkedin/callback",
    },
})
social.Register(app)
defer social.Stop()

// Wire MCP tools.
mcpSrv := forgemcp.New(app,
    forgemcp.WithModule(social.PostModule()),
    forgemcp.WithModule(social.CredentialModule()),
)
// Layer 1 — wire agent routing (optional).
// Fires on AfterPublish for "Post" content type.
social.AddRoutes(app,
    forgesocial.OnPublish("Post", "https://agent.example.com/social"),
)

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func CreateTables

func CreateTables(db forge.DB) error

CreateTables creates all forge_social_* database tables and indexes. It is safe to call multiple times — all statements use CREATE ... IF NOT EXISTS. Call this once at application startup before any other forge-social operations.

It also applies an idempotent migration that adds the actor_id column to forge_social_credentials for databases created before v0.2.0.

Types

type Config

type Config struct {
	// Secret is the application's Config.Secret, used to derive the AES-256-GCM
	// key for encrypting stored OAuth tokens. Must match the forge.App's secret.
	Secret []byte
	// Mastodon holds the Mastodon OAuth 2.0 client credentials.
	Mastodon MastodonConfig
	// LinkedIn holds the LinkedIn OAuth 2.0 client credentials.
	// Leave zero-valued to disable LinkedIn publishing.
	LinkedIn LinkedInConfig
}

Config holds the configuration for a Social instance.

type LinkedInConfig added in v0.2.0

type LinkedInConfig struct {
	// ClientID is the LinkedIn OAuth application client ID.
	ClientID string
	// ClientSecret is the LinkedIn OAuth application client secret.
	ClientSecret string
	// RedirectURL is the callback URL registered with the LinkedIn application,
	// e.g. cfg.BaseURL + "/oauth/linkedin/callback".
	RedirectURL string
	// SuccessURL is an optional URL to redirect the user to after a successful
	// OAuth connection. If empty, a plain confirmation response is returned.
	SuccessURL string
}

LinkedInConfig holds OAuth 2.0 client credentials for the LinkedIn platform.

type MastodonConfig

type MastodonConfig struct {
	// ClientID is the OAuth 2.0 client_id issued by the Mastodon instance.
	ClientID string
	// ClientSecret is the OAuth 2.0 client_secret issued by the Mastodon instance.
	ClientSecret string
	// InstanceURL is the base URL of the Mastodon instance, e.g. "https://mastodon.social".
	InstanceURL string
	// RedirectURL is the OAuth callback URL registered with the Mastodon instance.
	// Set this to your app's BaseURL + "/oauth/mastodon/callback".
	RedirectURL string
	// SuccessURL is an optional URL to redirect the browser to after a
	// successful OAuth callback. If empty, a plain HTML confirmation is shown.
	SuccessURL string
	// Scopes is the list of OAuth 2.0 scopes to request. Defaults to
	// ["write:statuses", "write:media"] when empty.
	Scopes []string
}

MastodonConfig holds the OAuth 2.0 client credentials for a Mastodon instance. Register your application once on the Mastodon instance admin panel and supply the resulting client_id and client_secret here.

type PlatformConfig added in v0.5.0

type PlatformConfig struct {
	ClientID     string `json:"client_id"`
	ClientSecret string `json:"client_secret"`
	RedirectURL  string `json:"redirect_url"`
	// InstanceURL is the Mastodon instance base URL (e.g. https://mastodon.social).
	// Empty for all other platforms.
	InstanceURL string `json:"instance_url,omitempty"`
	// SuccessURL is an optional redirect URL shown after a successful OAuth callback.
	SuccessURL string `json:"success_url,omitempty"`
}

PlatformConfig holds the operator-supplied OAuth 2.0 app credentials for a social platform. One row per platform in forge_social_platform_config.

type PlatformCredential

type PlatformCredential struct {
	ID          string `json:"id"`
	Platform    string `json:"platform"`
	Name        string `json:"name"`
	InstanceURL string `json:"instance_url"`
	// ActorID is the platform-specific author identifier used when publishing.
	// For LinkedIn this is the person URN ("urn:li:person:{sub}").
	// For Mastodon it is unused and stored as an empty string.
	ActorID   string     `json:"actor_id,omitempty"`
	ExpiresAt *time.Time `json:"expires_at,omitempty"`
	CreatedAt time.Time  `json:"created_at"`
	UpdatedAt time.Time  `json:"updated_at"`
	// contains filtered or unexported fields
}

PlatformCredential stores OAuth 2.0 credentials for a social platform. AccessToken and RefreshToken are stored encrypted in the database and are never exposed through MCP responses.

type PostStatus

type PostStatus string

PostStatus represents the lifecycle state of a ScheduledPost.

const (
	// PostStatusDraft is the initial state. scheduled_at may be nil.
	PostStatusDraft PostStatus = "draft"
	// PostStatusScheduled means the post has a scheduled_at in the future
	// and will be published by the internal scheduler.
	PostStatusScheduled PostStatus = "scheduled"
	// PostStatusPublished means the post has been successfully sent to the platform.
	PostStatusPublished PostStatus = "published"
	// PostStatusFailed means a terminal publish error occurred.
	PostStatusFailed PostStatus = "failed"
	// PostStatusArchived means the post has been manually archived.
	PostStatusArchived PostStatus = "archived"
	// PostStatusQueued means the post is waiting for the next available slot
	// in a PublicationSchedule. It has no scheduled_at and will be published
	// by the slot-queue scheduler when a slot fires for its credential.
	PostStatusQueued PostStatus = "queued"
)

type PublicationSchedule added in v0.4.0

type PublicationSchedule struct {
	ID           string         `json:"id"`
	CredentialID string         `json:"credential_id"`
	Slots        []Slot         `json:"slots"`
	Status       ScheduleStatus `json:"status"`
	LastTickAt   *time.Time     `json:"last_tick_at,omitempty"`
	CreatedAt    time.Time      `json:"created_at"`
	UpdatedAt    time.Time      `json:"updated_at"`
}

PublicationSchedule defines a recurring set of time slots for automatically publishing queued posts for a specific credential.

Each credential may have at most one PublicationSchedule. Use Social.ScheduleModule to expose this type via MCP tools.

func (PublicationSchedule) GetSlug added in v0.4.0

func (ps PublicationSchedule) GetSlug() string

GetSlug satisfies the slugger interface used by forge-mcp's allResources.

type Route added in v0.3.0

type Route struct {
	// Signal is the Forge lifecycle signal this route responds to.
	Signal forge.Signal

	// ContentType is the Go type name of the content type (e.g. "Post", "Story").
	// Matching is exact. Use the PascalCase struct name exactly as it appears
	// in your content type definition.
	ContentType string

	// AgentURL is the HTTPS endpoint that receives the signed JSON payload.
	// Validated at [AddRoutes] time — must be HTTPS and not a private address.
	AgentURL string
}

Route associates a lifecycle signal and content type with an agent URL. When Forge fires the matching signal for the matching content type, the Router enqueues an outbound HTTP POST to AgentURL.

Use the builder functions OnPublish, OnSchedule, OnArchive, and OnDelete to construct Route values.

func OnArchive added in v0.3.0

func OnArchive(contentType, agentURL string) Route

OnArchive returns a Route that fires on forge.AfterArchive for the named content type.

func OnDelete added in v0.3.0

func OnDelete(contentType, agentURL string) Route

OnDelete returns a Route that fires on forge.AfterDelete for the named content type.

func OnPublish added in v0.3.0

func OnPublish(contentType, agentURL string) Route

OnPublish returns a Route that fires on forge.AfterPublish for the named content type. agentURL must be a public HTTPS URL (validated at [AddRoutes] time).

func OnSchedule added in v0.3.0

func OnSchedule(contentType, agentURL string) Route

OnSchedule returns a Route that fires on forge.AfterSchedule for the named content type.

type Router added in v0.3.0

type Router struct {
	// contains filtered or unexported fields
}

Router holds the registered routes and the route delivery worker. It is created by [AddRoutes] and owned by Social.

type ScheduleStatus added in v0.4.0

type ScheduleStatus string

ScheduleStatus represents whether a PublicationSchedule is actively firing.

const (
	// ScheduleStatusActive means the schedule fires slots and dequeues posts.
	ScheduleStatusActive ScheduleStatus = "active"
	// ScheduleStatusPaused means the schedule exists but does not fire.
	ScheduleStatusPaused ScheduleStatus = "paused"
)

type ScheduledPost

type ScheduledPost struct {
	ID             string     `json:"id"`
	Platform       string     `json:"platform"`
	CredentialID   string     `json:"credential_id"`
	Body           string     `json:"body"`
	MediaURL       string     `json:"media_url,omitempty"`
	AltText        string     `json:"alt_text,omitempty"`
	ScheduledAt    *time.Time `json:"scheduled_at,omitempty"`
	Status         PostStatus `json:"status"`
	PlatformPostID string     `json:"platform_post_id,omitempty"`
	ErrorMsg       string     `json:"error_msg,omitempty"`
	CreatedAt      time.Time  `json:"created_at"`
	UpdatedAt      time.Time  `json:"updated_at"`
}

ScheduledPost represents a piece of content to be published to a social platform. Use Social.PostModule to expose ScheduledPost via MCP tools.

func (ScheduledPost) GetSlug

func (p ScheduledPost) GetSlug() string

GetSlug satisfies the slugger interface used by forge-mcp's allResources.

type Slot added in v0.4.0

type Slot struct {
	// Weekday is the day of the week (0=Sunday … 6=Saturday, matches time.Weekday).
	Weekday int `json:"weekday"`
	// Time is the wall-clock time in 24-hour HH:MM format, e.g. "09:00".
	Time string `json:"time"`
	// Timezone is an IANA timezone name, e.g. "Europe/Copenhagen".
	Timezone string `json:"timezone"`
}

Slot defines a recurring publication time within a PublicationSchedule.

type Social

type Social struct {
	// contains filtered or unexported fields
}

Social manages platform publishing for a Forge application. Create it with New and register its HTTP routes with [Register]. Optionally register agent routes with [AddRoutes] for Layer 1 routing. Call [Stop] in your application's shutdown handler.

func New

func New(db forge.DB, cfg Config) *Social

New creates a Social instance backed by db. It panics if db is nil, if Config.Secret is empty, or if the database tables cannot be created.

func (*Social) AddRoutes added in v0.3.0

func (s *Social) AddRoutes(app *forge.App, routes ...Route)

AddRoutes registers routes on the Social instance and wires each unique signal to the Forge App's signal bus. It also starts the route delivery worker goroutine.

Call AddRoutes before [app.Run]. Panics if any route has an invalid ContentType or AgentURL (see Route for validation rules).

Layer 1 (AddRoutes) and Layer 2 (Register) are independent — you may call either, both, or neither.

func (*Social) ConfigModule added in v0.5.0

func (s *Social) ConfigModule() forge.MCPModule

ConfigModule returns a forge.MCPModule that exposes the configure_platform admin tool for storing per-platform OAuth 2.0 app credentials in the DB. Pass it to forgemcp.WithModule when wiring the MCP server. Only users with Admin role can call the configure_platform tool.

func (*Social) CredentialModule

func (s *Social) CredentialModule() forge.MCPModule

CredentialModule returns a forge.MCPModule that exposes PlatformCredential as MCP tools (create/connect, list, get, delete). Pass it to forgemcp.WithModule when wiring the MCP server.

func (*Social) PostModule

func (s *Social) PostModule() forge.MCPModule

PostModule returns a forge.MCPModule that exposes ScheduledPost as MCP tools (create, update, publish, archive, delete, list, get). Pass it to forgemcp.WithModule when wiring the MCP server.

func (*Social) Register

func (s *Social) Register(app *forge.App)

Register mounts the forge-social HTTP routes on app and starts the internal scheduler goroutine.

Routes registered:

GET /oauth/mastodon/callback — OAuth 2.0 callback from Mastodon
GET /oauth/linkedin/callback — OAuth 2.0 callback from LinkedIn (when configured)
GET /oauth/x/callback        — OAuth 2.0 + PKCE callback from X (when configured)

Call Social.Stop in your shutdown handler to drain the scheduler.

func (*Social) ScheduleModule added in v0.4.0

func (s *Social) ScheduleModule() forge.MCPModule

ScheduleModule returns a forge.MCPModule that exposes PublicationSchedule as MCP tools (create, update, get, list, delete). Pass it to forgemcp.WithModule when wiring the MCP server.

func (*Social) Stop

func (s *Social) Stop()

Stop gracefully shuts down the scheduler and (if AddRoutes was called) the route delivery worker. It waits for any in-progress operations to complete. Call this in your application's shutdown handler.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL