Ozakboy.Line.AspNetCore
0.5.0
dotnet add package Ozakboy.Line.AspNetCore --version 0.5.0
NuGet\Install-Package Ozakboy.Line.AspNetCore -Version 0.5.0
<PackageReference Include="Ozakboy.Line.AspNetCore" Version="0.5.0" />
<PackageVersion Include="Ozakboy.Line.AspNetCore" Version="0.5.0" />
<PackageReference Include="Ozakboy.Line.AspNetCore" />
paket add Ozakboy.Line.AspNetCore --version 0.5.0
#r "nuget: Ozakboy.Line.AspNetCore, 0.5.0"
#:package Ozakboy.Line.AspNetCore@0.5.0
#addin nuget:?package=Ozakboy.Line.AspNetCore&version=0.5.0
#tool nuget:?package=Ozakboy.Line.AspNetCore&version=0.5.0
Ozakboy.Line
LINE Login, the Messaging API, and webhooks for .NET — with one line of setup for ASP.NET Core.
English | 繁體中文
dotnet add package Ozakboy.Line
dotnet add package Ozakboy.Line.AspNetCore
dotnet add package Ozakboy.Line.Mcp
| Package | What it is for |
|---|---|
Ozakboy.Line |
The core: LINE Login, the Messaging API, webhook verification and parsing, plus message templates and keyword auto reply. |
Ozakboy.Line.AspNetCore |
The integration: the AddLineLogin() authentication scheme and the MapLineWebhook() endpoint. |
Ozakboy.Line.Mcp |
Optional: the above as MCP tools, so an outside AI can operate the account — by default it can propose, not send. |
Requires .NET 10. The core and integration packages depend only on Microsoft packages and Ozakboy.*; the MCP package also takes the first-party ModelContextProtocol.AspNetCore (see Compatibility).
Why this exists
Putting LINE into an ASP.NET Core site means three separate jobs: signing people in with LINE Login, pushing messages from an official account, and receiving webhooks. They live in different parts of LINE's documentation, they use two different channels with two different sets of credentials, and each has a handful of details that fail quietly when you get them wrong — a redirect URI that does not match to the character, an id_token nobody verifies, a webhook body that was parsed before its signature was checked.
This package does all three, and the ASP.NET Core integration turns the first one into AddLineLogin().
It was extracted from a kindergarten review platform running in production at youmingdeng.ozakboy.life: LINE sign-in, add-friend prompting, push notifications and rich menus all run there.
How it behaves
Expected failures come back as Result<T>, not exceptions. A user declining consent, an expired authorization code, a spent push quota, someone who blocked the official account — these are a messaging system's normal days. They arrive as a Result carrying a stable line.* code and a category, so forgetting to handle one is a compile-time shape rather than a runtime surprise. Genuine defects — a null argument, a blank token — still throw.
LINE's own error body is folded into the error. LINE answers a bad request with {"message": "...", "details": [{"message": "...", "property": "..."}]}, and message is frequently no more than The request body has 1 error(s). The details array is the part that names the offending field, so it is parsed out into the error data under lineMessage and lineDetails, while the HTTP category is left alone: a 404 stays NotFound and a 429 stays RateLimited.
HTTP goes through Ozakboy.Http. Retries, timeouts and log masking belong to that pipeline. Channel secrets and access tokens are registered with its masker at startup, so they do not appear in logs or in error messages.
A push is retried only when you give it a retry key. A push is a POST, and resending one sends a second message — the user's phone buzzes twice, and the "failure" was quite possibly a lost response to a message that did go out. Supply RetryKey and the request carries X-Line-Retry-Key, which LINE deduplicates on; only then does the pipeline retry it. Without a key, a push goes out exactly once.
id_token is verified locally, with no JWT library. LINE signs id_tokens with HS256 keyed by the channel secret — verifier and signer hold the same secret, so there is no public key to fetch and no JWKS cache. HMACSHA256 and Base64Url from the BCL cover it, and the signature is compared with CryptographicOperations.FixedTimeEquals. Signature, issuer, audience, expiry and nonce are each checked, and each failure has its own error code.
Webhook signatures are verified over the raw bytes. The body is read as it arrived, never parsed and re-serialised first — field order, whitespace and escaping all change under a round trip, and the signature then never matches again. The comparison is fixed-time.
Quick start
1. Sign in with LINE (ASP.NET Core)
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie()
.AddLineLogin(options =>
{
options.ClientId = builder.Configuration["Line:Login:ChannelId"]!;
options.ClientSecret = builder.Configuration["Line:Login:ChannelSecret"]!;
options.BotPrompt = LineBotPrompt.Aggressive; // invite the user to add the official account
});
app.MapGet("/me", (ClaimsPrincipal user) => new
{
UserId = user.FindFirstValue(LineClaimTypes.UserId),
Name = user.FindFirstValue(ClaimTypes.Name),
Picture = user.FindFirstValue(LineClaimTypes.Picture),
IsFriend = user.FindFirstValue(LineClaimTypes.IsFriend), // absent when it could not be read
}).RequireAuthorization();
PKCE is on by default. A nonce is generated per sign-in, carried through the OAuth state, and checked against the id_token; a token that does not verify fails the whole sign-in rather than signing someone in anyway. Register the callback path — /signin-line by default — in the LINE Developers console, character for character.
2. The client on its own
When you are not using the authentication handler — a LIFF app, a background job, a non-web host — the core client does the same flow by hand.
builder.Services.AddLineLogin(options =>
{
options.ChannelId = builder.Configuration["Line:Login:ChannelId"]!;
options.ChannelSecret = builder.Configuration["Line:Login:ChannelSecret"]!;
});
var verifier = LinePkce.CreateCodeVerifier(); // keep this until the callback
var url = line.BuildAuthorizationUrl(new LineAuthorizationRequest
{
RedirectUri = "https://example.com/callback",
State = state,
Nonce = nonce,
CodeChallenge = LinePkce.ComputeCodeChallenge(verifier),
});
// …on the callback:
var login = await line.CompleteLoginAsync(code, redirectUri, verifier, nonce);
if (login.TryGetValue(out var result))
{
var userId = result.Profile.UserId;
var isFriend = result.IsFriend; // null when the Login channel has no linked official account
}
CompleteLoginAsync runs the exchange, the profile lookup, the friendship lookup and the id_token validation. Of the four only the friendship lookup is allowed to fail — recorded as a null IsFriend, which is a different fact from false.
3. Push, reply, and webhooks
builder.Services.AddLineMessaging(options =>
{
options.ChannelAccessToken = builder.Configuration["Line:Messaging:ChannelAccessToken"]!;
options.ChannelSecret = builder.Configuration["Line:Messaging:ChannelSecret"]!;
});
await line.PushTextAsync(userId, "您追蹤的園所有新的裁罰紀錄");
await line.PushAsync(userId, [
new TextMessage("新紀錄") { QuoteToken = quoteToken },
new StickerMessage("446", "1988"),
], new LinePushOptions { RetryKey = jobId }); // same key for one logical push, so a retry cannot duplicate it
app.MapLineWebhook("/line/webhook", async (evt, context, ct) =>
{
if (evt.Type == LineWebhookEventTypes.Message && evt.Message?.Text is { } text)
{
var line = context.RequestServices.GetRequiredService<ILineMessagingClient>();
await line.ReplyTextAsync(evt.ReplyToken!, $"收到:{text}", ct);
}
});
The endpoint verifies the signature before parsing — a bad one is a 401, an unparsable body a 400 — and answers 200 for everything else. One event's handler throwing is logged and does not stop the rest of the batch: a non-2xx makes LINE redeliver the whole batch, so letting one broken handler return a 500 means the events that did succeed get redone over and over while the broken one stays broken.
Quick replies
A row of buttons under a message. The whole row disappears once the user taps one or types something instead, so it is not a menu — an entry point that stays put belongs in a rich menu.
await line.PushAsync(userId, [
new TextMessage("Which district?")
{
QuickReply = new LineQuickReply
{
Items =
{
new LineQuickReplyItem(new MessageAction("Taipei") { Label = "Taipei" }),
new LineQuickReplyItem(new PostbackAction("area=tpe", "Taipei it is") { Label = "No trace" }),
new LineQuickReplyItem(new LocationAction { Label = "Send my location" }),
new LineQuickReplyItem(new DatetimePickerAction("when", DatetimePickerAction.ModeDate) { Label = "Pick a date" }),
},
},
},
]);
There are ten actions: MessageAction, PostbackAction, UriAction, DatetimePickerAction, CameraAction, CameraRollAction, LocationAction, ClipboardAction, RichMenuSwitchAction, and RawAction for whatever is not modelled yet. UriAction.AltUriDesktop gives the desktop clients a different address — a LIFF app or a line:// address only a phone understands opens as a blank page there — and PostbackAction.InputOption decides what the keyboard and the rich menu do after the tap.
Thirteen buttons is the cap, and going over does not mean LINE shows the first thirteen: it rejects the whole message. Every message is checked before sending, so an oversized call fails on your machine, with the actual count in the error.
Template and imagemap messages
Two more of LINE's message types, modelled so that the field names LINE is picky about are the compiler's business rather than yours. A template is one of four fixed layouts; an imagemap is one large image with tappable areas drawn on it.
await line.PushAsync(userId, [
new TemplateMessage("Which district?", new ButtonsTemplate("Pick one")
{
Title = "Districts",
ThumbnailImageUrl = "https://example.com/districts.jpg",
Actions = { new MessageAction("Taipei") { Label = "Taipei" }, new PostbackAction("area=ntpc") { Label = "New Taipei" } },
}),
new TemplateMessage("Confirm?", new ConfirmTemplate("Subscribe to alerts?", new MessageAction("Yes"), new MessageAction("No"))),
]);
await line.PushAsync(userId, [
new ImagemapMessage("https://example.com/map", "Map", baseHeight: 1040)
{
Actions =
{
new ImagemapUriAction("https://example.com/north", new LineImagemapArea(0, 0, 1040, 520)),
new ImagemapMessageAction("South", new LineImagemapArea(0, 520, 1040, 520)),
},
},
]);
CarouselTemplate takes up to ten CarouselColumns, ImageCarouselTemplate up to ten ImageCarouselColumns. The caps are checked before sending: four buttons, exactly two confirm actions, ten columns of at most three actions that must match across columns, fifty imagemap areas. A call that breaks one fails on your machine with line.validation.invalid_template or line.validation.invalid_imagemap, naming the count — LINE would reject the whole message with a 400 that names nothing.
Two things about imagemaps worth knowing: LINE fetches {baseUrl}/{width} rather than baseUrl itself, so several sizes have to sit under that address and it must not end in a slash; and the base width is always 1040, which is why the constructor takes only the height. The action's address field is linkUri, not the uri of a quick reply action — the two action sets are different types here for that reason.
Every message can also carry a Sender (name, iconUrl) that overrides the account's name and icon for that one message, and VideoMessage.TrackingId makes LINE send a videoPlayComplete event, readable as evt.VideoPlayComplete.TrackingId, once the user has watched it through.
Flex Message
Flex messages can be built in C# or read back from the JSON that LINE's Flex Message Simulator produces; both end up as the same typed model, and both are checked before anything is sent.
using Ozakboy.Line.Messaging.Actions;
using Ozakboy.Line.Messaging.Messages.Flex;
var card = new FlexBubble
{
Size = FlexBubbleSize.Mega,
Hero = new FlexImage("https://example.com/ramen.jpg")
{
Size = FlexSize.Full,
AspectRatio = FlexAspectRatio.Of(20, 13),
AspectMode = FlexAspectMode.Cover,
},
Body = new FlexBox
{
Layout = FlexLayout.Vertical,
Spacing = FlexSpacing.Sm,
Contents =
[
new FlexText("Ramen Ichiban") { Weight = FlexWeight.Bold, Size = FlexSize.Xl },
new FlexBox
{
Layout = FlexLayout.Baseline,
Margin = FlexSpacing.Md,
Contents =
[
new FlexIcon("https://example.com/star.png") { Size = FlexSize.Sm },
new FlexText("4.5") { Size = FlexSize.Sm, Color = FlexColor.Rgb(0x99, 0x99, 0x99), Margin = FlexSpacing.Md, Flex = 0 },
],
},
],
},
Footer = new FlexBox
{
Layout = FlexLayout.Vertical,
Contents = [new FlexButton(new UriAction("https://example.com/book") { Label = "Book a table" }) { Style = FlexButtonStyle.Primary }],
},
};
var message = new FlexMessage("Ramen Ichiban, rated 4.5", card);
await line.PushAsync(userId, [message]); // Validate() runs first; a broken layout sends nothing
Every valued field is a plain string — "3xl", "20px", "50%", "1.91:1", "#112233CC" — with the keywords and factories in static classes (FlexSize, FlexSpacing, FlexColor, FlexAspectRatio, FlexBorderWidth, FlexCornerRadius, FlexLayout, …). An enum would not hold those values, and every value LINE adds later would need a package release.
Reading JSON from the Simulator.
var read = FlexMessage.Parse("altText", jsonFromSimulator); // bubble or carousel
var message = read.GetValueOrThrow(); // message.Container is the typed tree
var raw = message.Container!.GetRawNodePaths(); // where the model gave up, e.g. "contents[1].body.contents[0]"
A node that carries a field the model does not know, has the wrong kind of value, or has an unknown type (the deprecated spacer among them) is kept as a whole in a RawFlexComponent / RawFlexContainer and written back exactly as it came, so reading and writing back loses nothing. Actions inside a layout read back as RawAction. FlexMessage.FromJson and the JsonElement constructor stay as they were: the JSON goes out untouched.
What Validate() checks (line.validation.invalid_flex, the message names the path, such as contents[2].body.contents[0].url; nothing is sent):
| Area | Rule |
|---|---|
| Carousel | 1 to 12 bubbles |
| Bubble | at least one of header / hero / body / footer; size one of the seven keywords |
| Hero and video | hero is an image, a box or a video; a video only as a hero, with an image or box altContent and a URI action |
| Box | layout present and horizontal / vertical / baseline; a baseline box holds only text, icon and filler; flex not negative |
| Text | text or contents (spans), exactly one; maxLines not negative |
| Values | keywords for size / align / gravity / weight / style / decoration / position / aspect mode / button height and style / justify / align items; margin, spacing, padding, offset, width, height, corner radius and border width take a keyword, {n}px or (width, height, padding, offset) {n}%; colours #RRGGBB or #RRGGBBAA; aspect ratio w:h; gradient angle {n}deg |
| Media | image, icon and video URLs are https, at most 2000 characters |
| Actions | each one through the usual action check, label at most 20 |
| Size | layout JSON at most 30 KB (a carousel 50 KB), in unescaped UTF-8 bytes |
RawFlexComponent, RawFlexContainer, RawAction and the verbatim-JSON path are not checked (beyond the overall size): they are the way out when LINE adds something the model has not caught up with, so a layout read from the Simulator is validated only as far as the model reaches, and GetRawNodePaths() (or the MCP tool line_validate_flex) tells you where that stops. Two things are deliberately left to LINE: whether a bubble with a video may sit in a carousel, and any limit this package does not know about. A message template whose rendered output contains a flex message stays as it is — rendering does not parse it — but FlexMessage.Parse is there to check one.
Message templates
A reusable set of LINE messages that can carry variables. What is stored is the JSON of a message array rather than a piece of plain text, so images, Flex layouts and quick replies all fit into a template — and changing the wording never means changing code.
services.AddLineTemplates(o => o.UseJsonFile("data/line-templates.json")); // in memory when not set
var store = provider.GetRequiredService<ILineTemplateStore>();
await store.UpsertAsync(new LineMessageTemplate
{
Name = "Sanction notice",
MessagesJson = """[{"type":"text","text":"{{name}} has a new sanction on record, document {{docNo}}."}]""",
});
var rendered = LineTemplateRenderer.Render(template.MessagesJson, new Dictionary<string, string>
{
["name"] = "Sunshine Kindergarten",
["docNo"] = "1130001",
});
if (rendered.IsSuccess)
{
await line.PushRawJsonAsync(userId, rendered.GetValueOrThrow());
}
A placeholder is {{name}}, where the name starts with a letter or an underscore. Values are escaped for JSON before they go in: without that, one quotation mark in a display name is enough to break the whole document, and the symptom is a 400 from LINE that names no field. A missing variable is a line.validation.missing_template_variables failure naming which ones, rather than {{name}} going out verbatim to a user.
Two stores ship with the package — in memory and one JSON file, the latter with atomic writes and one lock shared by reads and writes. A host with a database implements ILineTemplateStore instead; the package touches no ORM.
Keyword auto reply
A rule is data rather than code: the whole thing lives in a store and can be edited from an admin page or an MCP tool, so changing a welcome line never means a deployment.
services.AddLineMessaging(o => { /* … */ });
services.AddLineAutoReply(
o => o.Enabled = true,
s => s.UseJsonFile("data/line-auto-replies.json"));
app.MapLineWebhook("/line/webhook", o => o.AutoReply = true, async (evt, context, ct) =>
{
// Auto reply has already run; here is what it did:
var outcome = context.Items[LineWebhookItems.AutoReplyOutcome] as LineAutoReplyOutcome;
if (outcome?.Kind == LineAutoReplyOutcomeKind.Replied)
{
return; // a rule answered, so there is nothing to add
}
// …the host's own handling
});
There are six match modes: Exact, StartsWith, Contains and Regex, plus two that read no text — Follow, the welcome message for an add-friend event, and Fallback, the answer to a text message no other rule matched. Expressing both as rules makes the rule table the single source: changing the welcome message from an admin page is an upsert, with no second copy hiding in a settings file or an environment variable.
Rules are ordered by Priority first, smallest going first, and within a tie by how specific the match is (Exact > StartsWith > Contains > Regex). That second level matters: once there are many rules, one loose Contains easily swallows a pile of precise Exact ones, and nothing in the rule list shows it — every rule looks right on its own.
A reply can use {{text}}, the message the user sent, and {{displayName}}, their display name, which is looked up only when a rule actually uses it. Regular expressions are matched under a 100-millisecond cap, and a timeout or an invalid expression counts as no match rather than throwing: one badly written rule should not turn the whole webhook into a 500, which would have LINE redeliver the entire batch.
A rule can be pinned to one channel with ChannelName; empty means every channel, which is also how a rule stored before 0.4.0 reads back, so no upgrade silences a rule. Only the rules for the current channel are considered — the ordering above is unchanged — and the reply, and the {{displayName}} lookup, go through that channel's client. HandleAsync(evt) means the default channel, HandleAsync(evt, channelName) any other.
Auto reply always uses a reply token rather than a push: a reply costs no quota, and auto reply is the heaviest consumer of quota there is.
Groups and rooms
var summary = await line.GetGroupSummaryAsync(groupId); // name and picture
var count = await line.GetGroupMemberCountAsync(groupId); // excluding the account itself
var who = await line.GetGroupMemberProfileAsync(groupId, userId); // no friendship required
string? next = null;
do
{
var page = (await line.GetGroupMemberIdsAsync(groupId, next)).GetValueOrThrow();
// …page.UserIds
next = page.Next;
}
while (next is not null);
await line.LeaveGroupAsync(groupId);
Rooms have the same surface minus the summary — a room has neither a name nor a picture. The follower list, GetFollowerIdsAsync(start, limit), pages the same way, and all three return LineUserIdsPage even though LINE names the field memberIds in one and userIds in the other. The follower and member-id lists are available only to verified or premium accounts; LINE answers 403 for the rest, which is a fact about the account rather than a defect.
Narrowcast, audiences and insights
A narrowcast sends to a subset of friends, chosen by an audience you uploaded or by demographics LINE infers. It is asynchronous: the call returns LINE's request id, and the progress endpoint says whether it went out and to how many people.
var created = await line.CreateUploadAudienceGroupAsync("Taipei parents", userIds); // up to 10 000 per call
await line.AddAudienceGroupMembersAsync(created.GetValueOrThrow().AudienceGroupId, moreUserIds);
var requestId = await line.NarrowcastAsync(
[new TextMessage("New sanction on record")],
new LineNarrowcastOptions
{
Recipient = LineNarrowcastRecipient.And(
LineNarrowcastRecipient.Audience(audienceGroupId),
LineNarrowcastRecipient.Not(LineNarrowcastRecipient.Redelivery(previousRequestId))),
MaxRecipients = 1000,
RetryKey = jobId,
});
var progress = await line.GetNarrowcastProgressAsync(requestId.GetValueOrThrow());
// progress.Phase: waiting → sending → succeeded | failed; SuccessCount, FailureCount, TargetCount
Recipient and Demographic are JsonElement trees rather than a full model — both are nestable and / or / not operators whose leaf types LINE keeps extending — and LineNarrowcastRecipient builds the common shapes. A freshly created audience is IN_PROGRESS and needs enough members (LINE currently asks for 100) to become READY; GetAudienceGroupAsync, GetAudienceGroupListAsync and DeleteAudienceGroupAsync round out the set.
ValidateMessagesAsync(LineMessageValidationTarget.Push, messages) posts to LINE's validation endpoint, which checks the message objects without sending anything or using quota — useful before storing a template. Local caps are checked first, and LINE's 400 comes back with the offending field in lineDetails.
The three insight endpoints read what happened: GetMessageDeliveryInsightAsync(date) per channel, GetFollowersInsightAsync(date) for followers, reaches and blocks, and GetDemographicInsightAsync() for gender, age, area, OS and subscription period. A status other than ready leaves every number null — not counted yet, or not available — rather than zero.
Loading animation and read marks
await line.StartLoadingAnimationAsync(userId, loadingSeconds: 20); // 5 to 60, in steps of 5
// …do the slow thing; the animation stops as soon as the account sends a message
await line.MarkAsReadAsync(userId);
The animation shows only in one-to-one chats and only while the user has the chat open. Seconds outside 5–60 or not a multiple of 5 fail locally as line.validation.invalid_loading_seconds. MarkAsReadAsync matters only when the account's read mode (LineBotInfo.MarkAsReadMode) is manual.
textV2: mentions and emoji
TextMessage can carry LINE emoji by index, and TextV2Message is the newer text message that resolves {key} placeholders through a substitution map — which is the only way to mention someone.
await line.PushAsync(groupId, [
new TextV2Message("Hi {everyone}, {reporter} filed a new record {sparkle}")
{
Substitution =
{
["everyone"] = LineTextSubstitution.MentionAll(),
["reporter"] = LineTextSubstitution.Mention(userId),
["sparkle"] = LineTextSubstitution.Emoji("5ac1bfd5040ab15980c9b435", "001"),
},
},
]);
await line.PushAsync(userId, [
new TextMessage("$ New record") { Emojis = { new LineTextEmoji(0, "5ac1bfd5040ab15980c9b435", "001") } },
]);
LINE finds the placeholder itself, so a surrogate pair or a CJK character before it cannot shift it the way a character index can. Every {key} in the text must exist in Substitution, and every key must be referenced at least once; a key may only contain letters, digits and underscores. Breaking either rule fails locally as line.validation.invalid_message. Incoming mentions, emoji, quoted messages and image sets are typed on LineWebhookMessage (Mention, Emojis, QuotedMessageId, ImageSet).
Bulk send
A push takes at most 5 messages and a multicast at most 500 recipients. PushManyAsync and MulticastManyAsync take any number of either and cut them into legal requests.
var sent = await line.MulticastManyAsync(allUserIds, messages, new LinePushOptions { RetryKey = jobId });
if (sent.TryGetValue(out var ok))
{
// ok.BatchCount, ok.TotalMessages, ok.SentMessages
}
else
{
var error = sent.Error;
// error.Data[LineBulkSendErrorDataKeys.SentBatches] how many batches already went out
// error.Data[LineBulkSendErrorDataKeys.FailedBatchIndex] the one that failed, from 0
}
- Batches go out in order, and the first failure stops the run. The error keeps the failing batch's own code and category — a 429 stays
RateLimited— and addssentBatchesandfailedBatchIndex. For a multicast the index isrecipientBatch × messageBatchCount + messageBatch. - The whole input is validated before the first batch. A bad message in batch four is found before batch one is sent, not after.
- Zero messages is
too_many_messagesand zero recipients isline.validation.no_recipients, not a quiet success. RetryKeyis per batch, derived from yours. Each batch's key is the first 16 bytes ofSHA256(your key's 16 bytes + the batch index as 4 big-endian bytes)with the UUID version-5 and RFC 4122 bits set (LineBulkSendRetryKey.Derive). Retrying the whole call with the same key therefore cannot duplicate a batch LINE already accepted. A single batch derives a key too. Without aRetryKeynothing is retried.- There is no
ReplyManyAsyncon purpose. A reply token is single-use and short-lived, so a reply cannot be spread over several requests:ReplyAsynctakes at most 5 messages, and more than that belongs in a push.
Stats and insight
var count = await line.GetMessageDeliveryCountAsync(LineMessageDeliveryType.Push, new DateOnly(2026, 10, 26));
// count.Status: Ready | Unready | OutOfService | Unknown; count.Success is null unless Ready
await line.PushAsync(userId, messages, new LinePushOptions { CustomAggregationUnits = ["autumn_sale"] });
var byUnit = await line.GetMessageEventInsightByUnitAsync("autumn_sale", new DateOnly(2026, 10, 1), new DateOnly(2026, 10, 30));
var bySend = await line.GetMessageEventInsightAsync(requestId); // the X-Line-Request-Id of one send
var used = await line.GetAggregationUnitUsageAsync(); // units used this month
var names = await line.GetAggregationUnitNamesAsync(limit: 100);
LINE counts days in JST (UTC+9), so the hour between midnight and 1 a.m. in Taiwan belongs to the previous day. A figure that is not Ready is null, which is not zero. A statistics unit is checked before sending: at most one per request, at most 30 letters, digits or underscores (line.validation.invalid_aggregation_unit); CustomAggregationUnits works on push, multicast, broadcast and narrowcast. GetMessageEventInsightByUnitAsync takes from not after to and at most 30 days apart (line.validation.invalid_date_range).
Rich menus have a batch endpoint of their own — one request relinks or unlinks up to 1000 users' menus and returns a request id:
var requestId = await line.ReplaceOrUnlinkRichMenusInBatchAsync([
LineRichMenuBatchOperation.Link("richmenu-old", "richmenu-new"),
LineRichMenuBatchOperation.Unlink("richmenu-retired"),
]);
var progress = await line.GetRichMenuBatchProgressAsync(requestId.GetValueOrThrow());
ValidateRichMenuBatchAsync runs the same check without applying anything. One to 1000 operations are accepted (line.validation.invalid_rich_menu_batch).
Webhook endpoint, account linking and message content
var current = await line.GetWebhookEndpointAsync(); // endpoint + whether it is active
await line.SetWebhookEndpointAsync("https://example.com/line/webhook");
var test = await line.TestWebhookEndpointAsync(); // or TestWebhookEndpointAsync("https://…")
// test.GetValueOrThrow().Success / StatusCode / Reason — see below
The address must be https and at most 500 characters, or the call fails locally as line.validation.invalid_webhook_endpoint. A test whose far end does not answer 2xx is still a successful call: LINE reports what the server said, so read Success, StatusCode and Reason rather than the Result.
IssueLinkTokenAsync(userId) returns the link token an account-linking flow redirects with. GetMessageContentPreviewAsync(messageId) downloads the preview of an image or video, and GetMessageContentTranscodingStatusAsync(messageId) says whether a video is ready to download; a status string the package does not know fails as line.api.invalid_response rather than being guessed at.
LIFF
ILineLiffClient manages the LIFF apps of a LINE Login channel from your server.
builder.Services.AddLineLiff(o =>
o.ChannelAccessToken = builder.Configuration["Line:Login:ChannelAccessToken"]!); // the LOGIN channel's token
var added = await liff.AddAsync(new LineLiffApp
{
View = new LineLiffView { Type = LineLiffViewType.Tall, Url = "https://example.com/booking" },
Description = "Booking page",
BotPrompt = "normal",
});
var liffId = added.GetValueOrThrow(); // open it at https://liff.line.me/{liffId}
var all = await liff.GetAllAsync(); // 404 (no apps) is a successful empty list
await liff.DeleteAsync(liffId);
⚠ LIFF uses the LINE Login channel's token, not the Messaging API's. AddLineLiff therefore takes its own LineLiffOptions and registers its own named HttpClient (LineHttpClientNames.Liff); handing it the Messaging channel's token is a mix-up the package cannot detect, and LINE's refusal will not name it. Blank credentials fail as line.not_configured without sending anything. UpdateAsync(liffId, app) sends only the fields you set and never the liffId, and an app that breaks the specification fails locally as line.validation.invalid_liff_app. A second overload, AddLineLiff(IConfiguration), binds the options from configuration.
Multiple channels
One site, several official accounts. Register each channel under a name; the unnamed registration is the default channel, whose name is the empty string.
builder.Services.AddLineMessaging("shop", o =>
{
o.ChannelAccessToken = builder.Configuration["Line:Shop:ChannelAccessToken"]!;
o.ChannelSecret = builder.Configuration["Line:Shop:ChannelSecret"]!;
});
builder.Services.AddLineMessaging("care", builder.Configuration.GetSection("Line:Care")); // or bind a section
builder.Services.AddLineLiff("shop", o => o.ChannelAccessToken = builder.Configuration["Line:Shop:LoginToken"]!);
public sealed class Notifier(ILineMessagingClientFactory lines)
{
public Task NotifyAsync(string channel, string userId, string text) =>
lines.Get(channel).PushTextAsync(userId, text); // Get throws for a name that is not registered
}
ILineMessagingClientFactoryandILineLiffClientFactoryofferGet(name),Contains(name)andChannelNames. The same name always yields the same singleton client, andILineMessagingClient.ChannelNamesays which channel a client is bound to (empty for the default).- A named registration is only keyed (
GetRequiredKeyedService<ILineMessagingClient>(name)); the unnamed one is the keyed""entry and the plainILineMessagingClient, the same instance. Registering a name again replaces the earlier one whole, so a stale secret cannot survive a channel swap. - Each channel has its own named
HttpClient(LineHttpClientNames.ForMessaging(name)/ForLiff(name)), andline.not_configuredapplies per channel: a named channel without credentials sends nothing.
One webhook route per channel
app.MapLineWebhook(
"/line/webhook/{channel}",
handler: async (payload, http, ct) => { /* … */ },
new LineWebhookEndpointOptions { ChannelRouteParameter = "channel" }); // or ChannelName = "shop" for a fixed one
The secret is read from IOptionsMonitor<LineMessagingOptions>.Get(name), so each channel verifies with its own. A name that is not in ChannelNames is a 401 before anything is touched — no body read, no options fetched, and never a signature checked against an empty secret. Once the signature verifies, the name is in HttpContext.Items[LineWebhookItems.ChannelName] (an empty string for the default channel), the handler's LineWebhookContext.ChannelName carries it, and LineWebhookContext.Messaging is that channel's client. ChannelName and ChannelRouteParameter are mutually exclusive; setting both throws while mapping. With neither set, nothing changes: the default channel, as before. Auto reply, the maintenance reply and error notifications all go through the channel the event arrived on.
Event handlers
Derive from LineWebhookHandler instead of writing the if ladder yourself.
public sealed class ShopHandler(IBookingService bookings) : LineWebhookHandler
{
protected override Task OnTextMessageAsync(LineWebhookEvent evt, LineWebhookContext context, CancellationToken ct) =>
context.ReplyTextAsync($"收到:{evt.Message!.Text}", ct);
protected override async Task OnPostbackAsync(LineWebhookEvent evt, LineWebhookContext context, CancellationToken ct)
{
var data = await context.ResolvePostbackDataAsync(ct); // a staged token is resolved, plain data returned as is
if (data.IsSuccess)
{
await bookings.HandleAsync(evt.Source.UserId!, data.GetValueOrThrow(), ct);
}
}
protected override Task OnFollowAsync(LineWebhookEvent evt, LineWebhookContext context, CancellationToken ct) =>
context.ReplyTextAsync("歡迎加入!", ct);
}
builder.Services.AddLineWebhookHandler<ShopHandler>(); // scoped, so a DbContext can be injected
app.MapLineWebhook<ShopHandler>("/line/webhook"); // or (…, new LineWebhookEndpointOptions { … })
- A message event reaches
OnMessageAsyncfirst and then the type-specific method (OnTextMessageAsync,OnImageMessageAsync, …); a type this version does not know goes toOnUnknownEventAsync. There are 21On*Asyncmethods, each a no-op by default. - The base class does not swallow exceptions; isolating them is the endpoint's job, so the status-code contract is unchanged — a bad signature 401, an unparsable body 400, everything else 200.
context.ReplyAsync/ReplyTextAsynccan send once per event. Any reply that passes the local checks and goes out counts as using the token, even if LINE then rejects it; a second one answersline.webhook.reply_token_usedlocally and sends nothing, and an event without a token answersline.webhook.no_reply_token— neither throws. For more, push throughcontext.Messaging.context.Itemsis a per-event bag, andcontext.AutoReplyOutcomeis what auto reply did with the event.
Maintenance mode and error notification
app.MapLineWebhook<ShopHandler>("/line/webhook", new LineWebhookEndpointOptions
{
Maintenance =
{
Enabled = true,
AdminUserIds = { "Uadmin…" },
ReplyText = "系統維護中,請稍後再試。",
},
ErrorNotification =
{
Enabled = true,
AdminUserIds = { "Uadmin…" },
IncludeStackTrace = false,
},
});
- Maintenance looks at
Source.UserId, group events included; an event with noUserIdcounts as a non-administrator. A held-back event reaches neither the handler nor auto reply and is answered withReplyText— unless it has no reply token or itsmodeisstandby(another module owns the conversation), in which case it is simply dropped.HttpContext.Items[LineWebhookItems.MaintenanceSkipped]istruewhen this request held something back. - Error notification pushes a short description to every administrator when a handler throws. Each push fails on its own, the whole thing is capped at ten seconds and 5000 characters, and it can never change the 200.
- The options are copied when the endpoint is mapped. Mapping throws if either feature is enabled with an empty allow-list or a blank
ReplyText, or if the handler or the client is not registered — at startup, not when the first user writes in. Both apply to the per-event overloads andMapLineWebhook<THandler>; the whole-payload overload leaves the division of work to you.
Long postback data
PostbackAction data is capped at 300 characters. Stage the long data in a store and put a short token in the button instead.
builder.Services.AddLinePostbackData(); // in memory
// builder.Services.AddLinePostbackData(o => o.UseJsonFile("data/line-postbacks.json")); // survives a restart
var action = await PostbackAction.CreateStoredAsync(store, "查看詳情", longJson, displayText: "查看詳情");
// …later, in a handler:
var data = await context.ResolvePostbackDataAsync(ct); // line.postback.expired when missing or past its lifetime
- The token is
ozl:followed by 32 lowercase hex characters, 36 in all; the prefix and length are fixed.CreateStoredAsyncstages short data too, so the caller never has to branch on length. - A token the store cannot find and one that has expired are the same failure,
line.postback.expired. Data over the store's cap (LinePostbackDataOptions.MaxDataLength) fails asline.validation.postback_data_too_long.ILinePostbackDataStore.PurgeExpiredAsyncclears the dead entries. - The stores are singletons (the JSON one is
IDisposable). A resolved value is exactly as unsigned as a plainData: do not decide who may do what from it alone — look the event'sSource.UserIdup in your own records.
Single-image imagemap (experimental)
A normal imagemap needs the image served at 240 / 300 / 460 / 700 / 1040 pixels wide. ImagemapMessage.ForSingleImage takes one image and makes the whole of it the tap area.
var map = ImagemapMessage.ForSingleImage(
"https://example.com/banner.png", "春季活動", height: 520,
new ImagemapUriAction("https://example.com/spring", new LineImagemapArea(0, 0, 1040, 520))); // the area is replaced by the whole image
await line.PushAsync(userId, [map.GetValueOrThrow()]);
A ? is appended to the address (a & when it already carries a query), nothing is added when it already ends in ? or &, and an address with a # is refused. This leans on LINE appending /{width} to the base URL and on your server ignoring the resulting query string; LINE does not document it as a guarantee, hence experimental. Check it on a real device before relying on it. The base image width is fixed at 1040, so the original needs to be at least that wide.
What is checked before anything is sent
Every limit below fails on your machine with a named code and no request is sent — LINE's own answer to most of them is a 400 that says only "the request body has 1 error".
| What | Limit | Code |
|---|---|---|
| Messages per request | 1 to 5 | too_many_messages |
| Multicast recipients | 1 to 500 | too_many_recipients |
| Quick reply buttons | 1 to 13 | too_many_quick_reply_items |
text / textV2 |
at most 5000 characters | invalid_message |
altText |
at most 400 characters | invalid_message |
Location title / address |
at most 100 characters each | invalid_message |
| Media URLs | https, at most 2000 characters | invalid_message |
| Colours | #RRGGBB |
invalid_message |
textV2 placeholders |
every {key} in Substitution, every key referenced |
invalid_message |
Action label |
at most 20 (imagemap 50, image carousel 12) | invalid_action |
postback data |
at most 300 characters | invalid_action |
uri action |
scheme from an allow-list | invalid_action |
| Rich menu area actions | each one checked, the area named in the message | invalid_action |
| Flex layout | structure, value formats, URLs and size, the path named in the message; Raw nodes are not checked | invalid_flex |
| Template and imagemap shape | see above | invalid_template / invalid_imagemap |
| Statistics unit | one per request, 30 letters, digits or underscores | invalid_aggregation_unit |
| Statistics range | from ≤ to, at most 30 days |
invalid_date_range |
| Rich menu batch | 1 to 1000 operations | invalid_rich_menu_batch |
| Webhook address | https, at most 500 characters | invalid_webhook_endpoint |
| LIFF app | the specification's required fields | invalid_liff_app |
| Bulk send | at least one message, at least one recipient | too_many_messages / no_recipients |
RawMessage and RawAction are not checked: they are the way out when LINE adds something this package has not caught up with, and their content is yours to get right. The same goes for a rich menu read by an MCP tool, whose actions are always RawAction.
Letting an outside AI operate the account: Ozakboy.Line.Mcp
The above as a set of MCP tools, so Claude, a custom connector, or any MCP client can read the account's state, draft broadcasts, edit rules and templates, and replace rich menus.
builder.Services.AddLineMessaging(o => { /* … */ });
builder.Services.AddLineTemplates();
builder.Services.AddLineAutoReply();
builder.Services.AddLineMcpServer(
o =>
{
o.ApiKey = builder.Configuration["LINE_MCP_API_KEY"]; // unset means the endpoint is off
o.SendMode = LineMcpSendMode.Review; // the default
o.MaxSendRequestsPerDay = 10;
},
s => s.UseJsonFileOutbox("data/line-mcp-outbox.json"));
// Both gates go before UseRouting
app.UseLineMcpWellKnownNotFound();
app.UseLineMcpKeyGate();
app.UseRouting();
// …
app.MapLineMcp();
The rule is that the AI cannot send. In the default Review mode, line_send_push, line_send_multicast, line_send_broadcast, line_replace_rich_menu, line_create_liff_app and line_delete_liff_app send or change nothing at all: they write the payload to an outbox as PendingReview for a person on the host's side to approve. The approval API, ILineMcpOutboxService.ApproveAndSendAsync, is not a tool and belongs to the host — a review queue is worth something because whoever proposes cannot approve. Direct mode sends straight away and still writes a record: without it there is nowhere afterwards to find out who sent that message, when, or why.
A daily cap sits on top of that — ten items by default, counted against the local date in TimeZoneId. It is a runaway guard: an AI retrying the same tool in a loop is a common failure, and without a cap that loop fills the review queue until nobody is willing to read it.
The key gate. The public address is {prefix}/{ApiKey}, with /mcp/line as the default prefix. The key travels in the path rather than a header, because most MCP connectors give the user one field for a URL and nowhere for a custom header. It is compared in fixed time, and a failure answers 404 rather than 401 or 403: a 401 or 403 tells the caller that something is here, and this endpoint's existence is not worth announcing. An unset ApiKey means the feature is off and the whole subtree is a 404; rotating the key means changing the setting and restarting.
UseLineMcpWellKnownNotFound() answers a clean JSON 404 for everything under /.well-known/: an MCP connector probes /.well-known/oauth-protected-resource before connecting, and when that lands in MVC and comes back as an HTML sign-in page or a 302, the connector decides the site needs OAuth and cannot connect. ⚠ This gate covers the whole /.well-known subtree, so serving a security.txt later means handling it before this line.
One MCP endpoint serves one channel: LineMcpOptions.ChannelName (empty, the default channel, unless set) picks the official account whose client every tool and the outbox service use, and line_get_bot_info reports it as channelName. A name that is not registered makes every tool answer line.not_configured and send nothing — it never falls back to another account, and the sending tools refuse at proposal time rather than queueing something that cannot be approved. For several accounts, mount one endpoint per account, each with its own key. The default channel prefers a directly registered ILineMessagingClient, so a host with no factory carries on working. line_upsert_auto_reply takes an optional channelName for the rule, and line_test_auto_reply tries a text against one channel's rules.
A host that already runs its own MCP server just adds the set to it:
builder.Services.AddLineMcp(o => o.ApiKey = key);
builder.Services.AddMcpServer()
.WithHttpTransport(o => o.Stateless = true)
.WithTools<MyOwnTools>()
.WithLineTools();
The tools:
| Group | Tools |
|---|---|
| Reading | line_get_bot_info · line_get_quota · line_get_profile · line_get_webhook_info · line_test_webhook · line_get_delivery_stats · line_get_message_event_insight |
| Sending (reviewed) | line_send_push · line_send_multicast · line_send_broadcast |
| Outbox | line_list_outbox · line_get_outbox · line_cancel_outbox |
| Rich menus | line_list_rich_menus · line_get_default_rich_menu · line_list_rich_menu_aliases · line_validate_rich_menu · line_replace_rich_menu (reviewed) |
| Auto reply | line_list_auto_replies · line_upsert_auto_reply · line_delete_auto_reply · line_test_auto_reply |
| Templates | line_list_templates · line_get_template · line_upsert_template · line_delete_template · line_render_template |
| Flex | line_validate_flex |
| LIFF | line_list_liff_apps · line_create_liff_app (reviewed) · line_delete_liff_app (reviewed) |
The rule and template tools skip the review queue, because they send nothing by themselves: a broken rule answers wrongly rather than reaching out to everyone uninvited, and undoing it is one more upsert. To forbid them outright there are AllowRuleEdits, AllowTemplateEdits and AllowRichMenuChanges. Where the host has not called AddLineTemplates or AddLineAutoReply, those tools are still mounted and answer that the feature is off — which says more than their absence would.
line_validate_flex is read-only too: give it a Flex layout (a bubble, a carousel or a whole flex message) and it says whether it parses into the typed model, which nodes stayed Raw and so are not validated, the local validation result with the layout path, and the JSON size against its limit — a way for an outside AI to check a layout before queueing it. It sends nothing and counts against no daily cap.
The four 0.3.0 reading tools (line_get_webhook_info, line_test_webhook, line_get_delivery_stats, line_get_message_event_insight) change nothing and do not count against the daily cap. line_get_delivery_stats takes one yyyy-MM-dd date and answers for reply, push, multicast and broadcast at once; a channel whose figures are not ready is reported as unready with a null count, not zero. The LIFF tools need ILineLiffClient, so they answer line.not_configured where the host has not called AddLineLiff. Creating and deleting a LIFF app go through the review queue, like line_replace_rich_menu: in Review mode they only queue the plan (outbox kinds LiffCreate and LiffDelete), a person approves it, and only then does ILineMcpOutboxService.ApproveAndSendAsync call ILineLiffClient — deleting an app breaks every link that points at it, which an AI must not decide alone. Direct mode executes first and records afterwards, an execution writes a log line, and approving one where AddLineLiff was never called marks the item Failed as line.not_configured. line_list_liff_apps stays a direct, read-only query. The three sending tools take an optional customAggregationUnit, checked when proposed and stored with the outbox item, so the unit is applied at approval time.
Things about LINE worth knowing before you start
A Login channel and a Messaging channel are two different channels. Each has its own id and secret, and they are separate entries in the LINE Developers console. Putting one's credentials where the other belongs earns a 400 and nothing that points at the mix-up. AddLineLogin and AddLineMessaging therefore take separate options and register separate HTTP clients.
bot_prompt and the friendship API both need a linked official account. Without one on the Login channel, LINE ignores bot_prompt entirely — sign-in proceeds, the add-friend step simply never appears — and the friendship endpoint answers 4xx. Neither raises an error you can see, which is the usual explanation for "it is configured but nothing happens". This package treats an unreadable friendship status as unknown rather than as a failure.
Replies are free; pushes are not. A reply token costs nothing against the monthly quota, while every push does. The token is single-use and short-lived, so replying wherever the flow allows it is what decides whether the quota lasts the month. A redelivered event's token has usually expired already.
A multicast takes at most 500 recipients, and one request at most 5 messages. Both are checked before sending, so an oversized call fails on your machine rather than costing a slice of quota at LINE's.
A webhook event can arrive twice. LINE redelivers when it did not get a 200 in time, with the same WebhookEventId and IsRedelivery set. Any handler with side effects — recording, sending, deducting — needs to deduplicate on that id.
Rich menus are created and painted in two steps. CreateRichMenuAsync returns an id and UploadRichMenuImageAsync puts the image on it; a menu created without an image can still be linked, and shows up blank. The tappable areas are not drawn on the image either — where the buttons look like they are and where they actually are is your job to keep in agreement.
API at a glance
ILineLoginClient
| Member | What it does |
|---|---|
BuildAuthorizationUrl(request) |
The URL to send the browser to. PKCE, nonce, bot_prompt, ui_locales, forced consent, auto-login switches. |
ExchangeCodeAsync(code, redirectUri, codeVerifier?) |
Authorization code → tokens. |
RefreshAsync(refreshToken) |
New tokens, including a new refresh token. |
RevokeAsync(accessToken) |
Revokes an access token. |
VerifyAccessTokenAsync(accessToken) |
Scopes, owning channel, remaining seconds. |
VerifyIdTokenAsync(idToken, nonce?, userId?) |
Has LINE verify remotely. Prefer LineIdTokenValidator locally. |
GetProfileAsync(accessToken) |
User id, display name, avatar, status message. |
GetUserInfoAsync(accessToken) |
The OpenID Connect userinfo. |
GetFriendshipStatusAsync(accessToken) |
Whether the account has been added. |
CompleteLoginAsync(code, redirectUri, codeVerifier?, nonce?) |
All of the above, as one call. |
LineIdTokenValidator.Validate(...) and LinePkce.CreateCodeVerifier() / ComputeCodeChallenge() are static and need no client.
ILineMessagingClient
| Group | Members |
|---|---|
| Sending | PushAsync · PushTextAsync · PushRawJsonAsync · MulticastAsync · BroadcastAsync · BroadcastTextAsync · BroadcastRawJsonAsync · ReplyAsync · ReplyTextAsync · ReplyRawJsonAsync |
| Account | GetQuotaAsync · GetQuotaConsumptionAsync · GetBotInfoAsync · GetProfileAsync · GetMessageContentAsync |
| Rich menu | CreateRichMenuAsync · UploadRichMenuImageAsync · GetRichMenuListAsync · GetRichMenuAsync · DeleteRichMenuAsync · SetDefaultRichMenuAsync · ClearDefaultRichMenuAsync · GetDefaultRichMenuIdAsync · LinkRichMenuToUserAsync · UnlinkRichMenuFromUserAsync · GetRichMenuIdOfUserAsync · ValidateRichMenuAsync |
| Replacement and aliases | ReplaceRichMenuAsync · CreateRichMenuAliasAsync · UpdateRichMenuAliasAsync · DeleteRichMenuAliasAsync · GetRichMenuAliasAsync · GetRichMenuAliasListAsync · LinkRichMenuToUsersAsync · UnlinkRichMenuFromUsersAsync |
| Followers, groups and rooms | GetFollowerIdsAsync · GetGroupSummaryAsync · GetGroupMemberCountAsync · GetGroupMemberIdsAsync · LeaveGroupAsync · GetGroupMemberProfileAsync · GetRoomMemberCountAsync · GetRoomMemberIdsAsync · LeaveRoomAsync · GetRoomMemberProfileAsync |
| Chat and validation | StartLoadingAnimationAsync · MarkAsReadAsync · ValidateMessagesAsync · ValidateMessagesRawJsonAsync |
| Narrowcast and audiences | NarrowcastAsync · GetNarrowcastProgressAsync · CreateUploadAudienceGroupAsync · AddAudienceGroupMembersAsync · GetAudienceGroupAsync · GetAudienceGroupListAsync · DeleteAudienceGroupAsync |
| Insights | GetMessageDeliveryInsightAsync · GetFollowersInsightAsync · GetDemographicInsightAsync |
| Bulk send | PushManyAsync · MulticastManyAsync (with LineBulkSendResult, LineBulkSendRetryKey.Derive, LineBulkSendErrorDataKeys) |
| Stats and units | GetMessageDeliveryCountAsync · GetMessageEventInsightAsync · GetMessageEventInsightByUnitAsync · GetAggregationUnitUsageAsync · GetAggregationUnitNamesAsync (units are set through LinePushOptions.CustomAggregationUnits, narrowcast included) |
| Rich menu batch | ReplaceOrUnlinkRichMenusInBatchAsync · ValidateRichMenuBatchAsync · GetRichMenuBatchProgressAsync (with LineRichMenuBatchOperation.Link / Unlink / UnlinkAll) |
| Webhook endpoint | GetWebhookEndpointAsync · SetWebhookEndpointAsync · TestWebhookEndpointAsync |
| Account linking and content | IssueLinkTokenAsync · GetMessageContentPreviewAsync · GetMessageContentTranscodingStatusAsync |
| Identity | ChannelName (the channel the client is bound to; empty for the default) |
Message types: TextMessage (with Emojis), TextV2Message (with LineTextSubstitution), ImageMessage, VideoMessage, AudioMessage, LocationMessage, StickerMessage, FlexMessage (typed layouts in Ozakboy.Line.Messaging.Messages.Flex, see above), TemplateMessage (with ButtonsTemplate, ConfirmTemplate, CarouselTemplate, ImageCarouselTemplate), ImagemapMessage (with ImagemapUriAction, ImagemapMessageAction, LineImagemapVideo), and RawMessage for anything not modelled yet. Every message has QuickReply, Sender and Validate(). Actions: UriAction, MessageAction, PostbackAction, DatetimePickerAction, CameraAction, CameraRollAction, LocationAction, ClipboardAction, RichMenuSwitchAction, RawAction. Quick replies: LineQuickReply and LineQuickReplyItem.
ReplaceRichMenuAsync runs create, upload, set-as-default, repoint-the-alias and delete-the-old-menu as one call. Its failure handling is asymmetric on purpose: a failed image upload deletes the menu just created, since a menu without an image is worse than no menu, while a failed deletion of the old menu is only logged and the call still succeeds, since the new menu is already live and reporting a failure would have the caller redo it and end up with one menu more.
The *RawJsonAsync overloads accept either a single message object or an array of them, and wrap either into {"messages": […]}. They are the way out when LINE adds something this package has not caught up with.
ILineLiffClient
| Member | What it does |
|---|---|
AddAsync(app) |
Creates a LIFF app and returns its liffId. |
UpdateAsync(liffId, app) |
Changes the fields that are set; never sends the liffId. |
GetAllAsync() |
Lists the apps; a 404 is a successful empty list. |
DeleteAsync(liffId) |
Deletes an app. |
Registered with AddLineLiff(options) or AddLineLiff(configuration), using the Login channel's token on its own named HttpClient.
Multiple channels, event handlers and postback staging
| Member | What it does |
|---|---|
AddLineMessaging(name, …) · AddLineLiff(name, …) |
Register a named channel (keyed only); registering a name again replaces it. |
ILineMessagingClientFactory · ILineLiffClientFactory |
Get(name) · Contains(name) · ChannelNames; the empty string is the default channel. |
LineHttpClientNames.ForMessaging(name) · ForLiff(name) |
The named HttpClient of a channel. |
LineWebhookHandler |
The abstract base with 21 On*Async methods; a message goes to OnMessageAsync first, an unknown type to OnUnknownEventAsync. |
LineWebhookContext |
Event · ChannelName · Messaging · AutoReplyOutcome · Items · ReplyAsync · ReplyTextAsync · ResolvePostbackDataAsync. |
AddLineWebhookHandler<T>() · MapLineWebhook<T>(pattern, options?) |
Register the handler (scoped) and map the endpoint that dispatches to it. |
LineWebhookEndpointOptions |
AutoReply · ChannelName · ChannelRouteParameter · Maintenance · ErrorNotification. |
ILinePostbackDataStore |
StoreAsync · GetAsync · PurgeExpiredAsync, in memory or in a JSON file; registered with AddLinePostbackData. |
PostbackAction.CreateStoredAsync · LinePostbackDataResolver.ResolveAsync · LinePostbackDataToken |
Stage long data behind a short token, and turn the token back into data. |
ImagemapMessage.ForSingleImage(url, altText, height, action) |
A single-image imagemap (experimental). |
Flex messages
| Member | What it does |
|---|---|
FlexBubble · FlexCarousel |
The two containers; FlexCarousel.Contents holds 1 to 12 bubbles. |
FlexBox · FlexText (+ FlexSpan) · FlexImage · FlexVideo · FlexIcon · FlexButton · FlexSeparator · FlexFiller |
The components; every valued field is a string. |
FlexLinearGradient · FlexBubbleStyles · FlexBlockStyle |
Box gradients and per-area bubble styles. |
FlexSize · FlexSpacing · FlexColor · FlexAspectRatio · FlexBorderWidth · FlexCornerRadius · FlexLayout · … |
Keyword constants and value factories (Px, Percent, Rgb, Of). |
new FlexMessage(altText, container) · FlexMessage.Container |
The typed path; Container is null on the verbatim-JSON path. |
FlexMessage.Parse(altText, json) |
Reads layout JSON into the typed model; anything unknown is kept as Raw. |
FlexContainer.GetRawNodePaths() |
The paths of the nodes kept as RawFlexComponent / RawFlexContainer. |
RawFlexContainer · RawFlexComponent |
The escape hatch; sent as given, not validated. |
FlexMessage.Validate() |
The local check; line.validation.invalid_flex with the path. |
Templates and auto reply
| Member | What it does |
|---|---|
LineTemplateVariables.Extract(json) |
The variable names a template uses, deduplicated and in order. |
LineTemplateRenderer.Render(json, values) |
Substitutes and checks the result is still a valid one-to-five-message array. |
LineTemplateRenderer.RenderMessages(json, values) |
The same, returning RawMessage objects. |
LineTemplateRenderer.Validate(json) |
Validation without substitution, for use before storing. |
ILineTemplateStore |
ListAsync · GetAsync · UpsertAsync · DeleteAsync, with in-memory and JSON-file implementations. |
LineAutoReplyMatcher.Match(rules, text) |
Picks the rule that answers a message, across the four text modes. |
LineAutoReplyMatcher.FindFirst(rules, mode) |
The lowest-priority Follow or Fallback rule. |
LineAutoReplyMatcher.Validate(rule) |
The pattern, the regular expression, and the reply body, all at once. |
ILineAutoReplyStore |
The same shape as ILineTemplateStore. |
LineAutoReplyMatcher.ForChannel(rules, channelName) |
The rules that apply to a channel: an empty ChannelName, or the same one. |
ILineAutoReplyService.HandleAsync(evt) |
Applies the rules to one webhook event on the default channel: Skipped, NoMatch, or Replied. |
ILineAutoReplyService.HandleAsync(evt, channelName) |
The same for another channel; an unregistered name is line.not_configured. |
MCP (Ozakboy.Line.Mcp)
| Member | What it does |
|---|---|
AddLineMcp(configure, stores?) |
The settings, the outbox, the outbox service, and the named HTTP client used to fetch images. |
AddLineMcpServer(configure, stores?) |
All of the above plus a stateless MCP server carrying just the LINE tools. |
LineMcpOptions.ChannelName · LineMcpClients |
Which channel's clients the tools and the outbox service use; a missing channel is line.not_configured. |
IMcpServerBuilder.WithLineTools() |
Adds the eight tool sets to an MCP server the host already has. |
UseLineMcpKeyGate(prefix?) |
The key gate. Must come before UseRouting. |
UseLineMcpWellKnownNotFound() |
A clean 404 for everything under /.well-known/. |
MapLineMcp(prefix?) |
The MCP endpoint, on the same prefix the gate uses. |
ILineMcpOutboxService |
ApproveAndSendAsync · RejectAsync · CancelAsync. For the host only; never a tool. |
ILineMcpOutboxStore |
The outbox store, with in-memory and JSON-file implementations. |
Outbox statuses: PendingReview → Approved / Sent / Failed / Rejected / Canceled. They only move forward, and something already sent never returns to review — an item that could go back to review is one that could be approved twice.
Webhooks
| Member | What it does |
|---|---|
LineWebhookSignature.Verify(secret, body, signature) |
Fixed-time comparison; false for a missing header, a blank secret, or a signature that is not base64. |
LineWebhookParser.Parse(body) |
Events, with anything unmodelled kept in LineWebhookEvent.Raw. |
HttpRequest.ReadLineWebhookAsync(secret) |
Reads the raw bytes, verifies, parses. |
MapLineWebhook(pattern, handler) |
The endpoint. One overload per event, one for the whole payload. |
MapLineWebhook(pattern, configure, handler) |
The same, with LineWebhookEndpointOptions.AutoReply to run auto reply first. |
MapLineWebhook(pattern, handler, options) |
The whole-payload overload with LineWebhookEndpointOptions; of them only ChannelName and ChannelRouteParameter apply. |
LineWebhookItems.AutoReplyOutcome |
The HttpContext.Items key the auto reply outcome lands under. |
LineWebhookItems.ChannelName |
The channel a verified request belongs to (empty for the default); set only after the signature verifies. |
LineWebhookItems.MaintenanceSkipped |
true when maintenance mode held an event back in this request. |
LineWebhookEvent.Joined / Left / Beacon / Unsend / Link / Membership |
Typed payloads for member join and leave, beacons, unsent messages, account linking and membership events (LineWebhookBeaconTypes, LineWebhookLinkResults, LineWebhookMembershipTypes name the strings). |
LineWebhookMessage.Emojis / Mention / ImageSet / QuotedMessageId / MarkAsReadToken / StickerResourceType / Keywords |
Typed message fields; anything unmodelled is still in Raw. |
Error codes
line.not_configured · line.validation.too_many_recipients · line.validation.too_many_messages · line.validation.invalid_json · line.validation.too_many_quick_reply_items · line.validation.invalid_template · line.validation.invalid_imagemap · line.validation.invalid_loading_seconds · line.validation.invalid_page_size · line.validation.too_many_audience_members · line.validation.missing_template_variables · line.validation.invalid_auto_reply_rule · line.validation.invalid_message · line.validation.invalid_action · line.validation.invalid_webhook_endpoint · line.validation.invalid_liff_app · line.validation.invalid_date_range · line.validation.invalid_aggregation_unit · line.validation.invalid_rich_menu_batch · line.validation.no_recipients · line.validation.invalid_flex · line.mcp.outbox_not_found · line.mcp.outbox_invalid_status · line.mcp.image_fetch_failed · line.api.error · line.api.invalid_response · line.id_token.invalid_format · line.id_token.unsupported_algorithm · line.id_token.invalid_signature · line.id_token.invalid_issuer · line.id_token.invalid_audience · line.id_token.expired · line.id_token.nonce_mismatch · line.webhook.invalid_signature · line.webhook.invalid_payload · line.webhook.no_reply_token · line.webhook.reply_token_used · line.webhook.not_postback · line.postback.expired · line.validation.postback_data_too_long
These are a public contract and do not change once published.
Compatibility
- Target framework: net10.0.
- Dependencies:
Ozakboy.Core.Abstractions,Ozakboy.Http, andMicrosoft.Extensions.*.Ozakboy.Line.AspNetCoreadds a framework reference toMicrosoft.AspNetCore.App. No third party appears in the transitive graph of either package, and no JWT library is pulled in. - The MCP package's exception:
Ozakboy.Line.Mcpdepends onModelContextProtocol.AspNetCore, the official C# SDK for MCP, maintained by the modelcontextprotocol organisation together with Microsoft. This is the one listed exception to the dependency policy. MCP is a versioned wire protocol, and implementing it here would mean maintaining an implementation that falls behind every revision — where falling behind shows up as a connector that attaches successfully and then lists no tools. To avoid the dependency, do not install this package; the core and integration packages are untouched by it. - Tests: offline throughout. The suite never calls LINE, and uses placeholder credentials only.
Licence
MIT. See LICENSE.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Ozakboy.Line (>= 0.5.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
0.5.0: version alignment only; the typed Flex model lives in Ozakboy.Line. Webhook endpoints, the Login authentication scheme and their contracts are unchanged. See https://github.com/ozakboy/Ozakboy.Line/blob/main/CHANGELOG.md