Skip to content

Replacing Services

UmbSocial’s services are registered in Umbraco’s dependency injection by SocialComposer (UmbSocial.Core.Configuration).

ISocialMediaUrls (UmbSocial.Core.Publishing) turns a post’s image (a media item key) into the URL the network downloads it from, and into the preview the composer shows:

public interface ISocialMediaUrls
{
// The absolute public URL a network downloads the image from. With framing, cut to the network's shape.
string? AbsoluteUrl(Guid mediaKey, string network, ImageFraming? framing = null);
// The same cut, `width` pixels wide, for the composer's preview (site-relative or absolute).
string? PreviewUrl(Guid mediaKey, string network, ImageFraming? framing, int width);
}

ImageFraming (UmbSocial.Core.Models) is the post’s focal point and each network’s own crop (see Images). CropFor(network) gives a network’s crop, or null.

Return null when the image can’t be resolved: nothing is sent and the post fails on that channel with media.unresolvable (safe to retry). The URL you return must be an absolute https URL, or the post fails with dispatch.invalid_url.

The built-in implementation reads the media item’s file path from the published media cache and:

  • uses it as it is when it is already an absolute URL (media on a CDN or blob storage);
  • otherwise adds it to the site’s UmbracoApplicationUrl (or the address of the first request), keeping any virtual directory;
  • with framing, has the site’s image processor cut it to the network’s shape (the network’s own crop, or around the focal point), through Umbraco’s IImageUrlGenerator;
  • for Instagram, which only takes JPEGs, always asks for a JPEG.

PreviewUrl makes the same cut at preview size, also through IImageUrlGenerator. That matters when the site sets an imaging HMAC key (Umbraco:CMS:Imaging:HMACSecretKey): the image processor then refuses resize and crop URLs that aren’t signed, and the generator signs them. Return null when there is no preview; the composer shows none.

It doesn’t use Umbraco’s URL provider, because posts are sent from a background job with no Umbraco context.

Replace it when, for example, your media is served from a CDN on another host:

CdnMediaUrls.cs
using UmbSocial.Core.Models;
using UmbSocial.Core.Networks;
using UmbSocial.Core.Publishing;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.PublishedCache;
public sealed class CdnMediaUrls(IPublishedMediaCache mediaCache, MediaUrlGeneratorCollection generators) : ISocialMediaUrls
{
private static readonly Uri CdnRoot = new("https://cdn.example.com/");
public string? AbsoluteUrl(Guid mediaKey, string network, ImageFraming? framing = null)
=> MediaPath(mediaKey) is { } path ? Cdn(path, network, TextLimits.For(network).ImageWidth, framing) : null;
public string? PreviewUrl(Guid mediaKey, string network, ImageFraming? framing, int width)
=> MediaPath(mediaKey) is { } path ? Cdn(path, network, width, framing) : null;
private string? MediaPath(Guid mediaKey)
=> mediaCache.GetById(mediaKey)?.GetProperty("umbracoFile") is { } file
&& generators.TryGetMediaPath(file.PropertyType.EditorAlias, file.GetSourceValue(), out var path)
&& !string.IsNullOrWhiteSpace(path)
? path
: null;
// Your CDN's own resizing parameters: here, width, height and a focal point. Instagram only takes JPEGs.
private static string Cdn(string path, string network, int width, ImageFraming? framing)
{
var limit = TextLimits.For(network);
var height = width * limit.ImageHeight / limit.ImageWidth;
var url = $"{new Uri(CdnRoot, path.TrimStart('~', '/')).AbsoluteUri}?width={width}&height={height}";
if (framing?.FocalPoint is { } point) url += $"&focus={point.Left},{point.Top}";
return network == SocialNetwork.Instagram ? url + "&format=jpg" : url;
}
}

Register it with a plain AddSingleton:

CdnMediaUrlsComposer.cs
using Microsoft.Extensions.DependencyInjection;
using UmbSocial.Core.Publishing;
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.DependencyInjection;
public class CdnMediaUrlsComposer : IComposer
{
public void Compose(IUmbracoBuilder builder)
=> builder.Services.AddSingleton<ISocialMediaUrls, CdnMediaUrls>();
}

This works whichever composer runs first. Social registers its own implementation with TryAddSingleton, so:

  • if your composer runs first, Social’s registration is skipped;
  • if Social’s runs first, your registration is added after it, and the last registration is the one that is used.

Don’t use TryAddSingleton yourself: if Social’s composer ran first, yours would be skipped.

ILinkPreviewService (UmbSocial.Core.Links) fills a link card from the page a link points to, for the composer (see Links and link cards):

public interface ILinkPreviewService
{
Task<LinkPreviewResult> PreviewAsync(string url, CancellationToken cancellationToken);
}
public sealed record LinkPreviewResult(LinkPreview? Preview, string? Error);
public sealed record LinkPreview(string? Title, string? Description, string? ImageUrl);

Return the card, or an error code and no card: link.https_required, link.not_allowed or link.unreachable. An image URL must be an absolute https URL. The management API rate-limits calls per user (30 a minute) before your service is asked.

The built-in implementation fetches the page itself, fenced in because the address comes from whoever writes the post: https only, public addresses only (checked again at connect time), at most 3 redirects, HTML only, the first 512 KB, 5 seconds. It remembers answers for an hour (failures for five minutes).

Replace it to read cards from somewhere else, such as your own cache or an unfurling service. Register yours with a plain AddSingleton (or AddTransient, or AddHttpClient): as with ISocialMediaUrls, it wins whichever composer runs first. If your implementation fetches pages itself, keep the same fences: the URL is user input.

ISlotFinder (UmbSocial.Core.Scheduling) picks the time a post goes out when someone chooses Add to queue:

public interface ISlotFinder
{
Task<DateTime?> NextFreeSlotAsync(IReadOnlyCollection<int> channelIds, DateTime nowUtc, int? ignorePostId, CancellationToken cancellationToken);
}
  • channelIds: the ids of the channels the post goes out to (a channel it was already published to isn’t counted). It is empty when the management API’s next-free-slot endpoint asks, which has no post.
  • nowUtc: now, in UTC. Return a time after it.
  • ignorePostId: the post being rescheduled, whose own time doesn’t count as taken; null for a new post.

Return the time in UTC, or null when there is no free slot: the save is then refused with schedule.no_free_slot.

The built-in implementation uses the shared posting schedule (Settings › Schedule), the same for every channel, in the site’s time zone. It returns the first slot after now that no scheduled post has taken (drafts don’t take a slot), looking up to 28 days ahead. It ignores channelIds.

Replace it to queue differently, for example with a schedule per channel or by spacing posts out. Register yours with a plain AddSingleton: as with ISocialMediaUrls, it wins whichever composer runs first.

WeekdaysOnlySlotFinderComposer.cs
using Microsoft.Extensions.DependencyInjection;
using UmbSocial.Core.Scheduling;
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.DependencyInjection;
// Every post goes out at the next 10:00 UTC on a weekday. A real one would also skip times other posts have taken.
public sealed class WeekdaysAtTen : ISlotFinder
{
public Task<DateTime?> NextFreeSlotAsync(IReadOnlyCollection<int> channelIds, DateTime nowUtc, int? ignorePostId, CancellationToken cancellationToken)
{
var slot = nowUtc.Date.AddHours(10);
while (slot <= nowUtc || slot.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday)
{
slot = slot.AddDays(1);
}
return Task.FromResult<DateTime?>(DateTime.SpecifyKind(slot, DateTimeKind.Utc));
}
}
public class WeekdaysOnlySlotFinderComposer : IComposer
{
public void Compose(IUmbracoBuilder builder)
=> builder.Services.AddSingleton<ISlotFinder, WeekdaysAtTen>();
}

The calendar still shows the shared schedule’s slots; your finder only decides where queued posts land.

IOutgoingLinkRewriter (UmbSocial.Core.Links) changes a post’s links as they go to each channel, for example to add tracking tags or to shorten them:

public interface IOutgoingLinkRewriter
{
Task<string> RewriteAsync(string url, LinkRewriteContext context, bool forPreview, CancellationToken cancellationToken);
}
public sealed record LinkRewriteContext(Guid PostKey, Guid ChannelKey, string Network, string? Campaign);
  • url: an absolute http(s) link, as written. Every such link in a channel’s text (its own text, or the main text), and the post’s link card URL, goes through it, once per channel (a link that appears twice is rewritten once, so both places get the same result).
  • context: the post’s key (Guid.Empty for an unsaved post in a preview), the channel’s key and network, and the post’s optional Campaign (a field under the link card in the composer, up to 100 characters; UmbSocial does nothing else with it).
  • forPreview: true in the composer’s live check, false when the post is sent. A preview must have no side effects: store nothing, and return a stand-in of the length the real link will have. A send (false) can happen more than once for the same link, after a retry or a failure, so a rewriter that stores something (a short link, say) must give the same link each time.

The composer’s character counts, network limits and previews use the rewritten links, so a longer link can push a post over a network’s limit in the check, and saving a post that would then be too long is refused with text.too_long:<network>. The check returns each channel’s text with its links as they will go out as previewText. When a post is sent, the links are rewritten just before SocialPostDispatchingNotification is raised, so handlers see the rewritten links. The stored post keeps its links as written.

If your rewriter throws, the channel’s send fails with link.rewrite_failed and nothing is sent: a post never goes out with a link that failed to rewrite. In the composer’s check and when saving it is only a warning on that channel, and the original link is counted and shown.

The built-in implementation, NoLinkRewriter, returns every link unchanged. Register yours with a plain AddSingleton: as with ISocialMediaUrls, it wins whichever composer runs first.

TrackedLinksComposer.cs
using Microsoft.Extensions.DependencyInjection;
using UmbSocial.Core.Links;
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.DependencyInjection;
// Tags every link with where it was shared, and the post's campaign when it has one.
public sealed class TaggedLinks : IOutgoingLinkRewriter
{
public Task<string> RewriteAsync(string url, LinkRewriteContext context, bool forPreview, CancellationToken cancellationToken)
{
var separator = url.Contains('?') ? '&' : '?';
var tagged = $"{url}{separator}utm_source={context.Network}";
return Task.FromResult(context.Campaign is { Length: > 0 } campaign ? $"{tagged}&utm_campaign={Uri.EscapeDataString(campaign)}" : tagged);
}
}
public class TrackedLinksComposer : IComposer
{
public void Compose(IUmbracoBuilder builder)
=> builder.Services.AddSingleton<IOutgoingLinkRewriter, TaggedLinks>();
}

IPostService, IChannelService and IScheduleService are public interfaces (see Services). Use them from your own code. You can also decorate or replace them like any registered service, but the management API and the paid packages rely on what they do, so a replacement must keep every rule the built-in one applies (validation, the edition’s limits, the history window and the Unknown-outcome rules). To change or refuse data, prefer notifications: they keep every rule in place.

What isn’t meant to be replaced:

  • The edition’s limits (3 channels, 90 days of history). They are compiled in and read from no service or setting.
  • History sealing. It isn’t a registered service and has no setting: it runs as part of the publishing loop.
  • The publishing pipeline (claiming, dispatching and recording outcomes). It is made of sealed classes, not interfaces. Extend it with notifications and with steps in the channel’s automation instead (see Automate integration).